Compare commits
13 Commits
feature/we
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
| c89404cf26 | |||
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd | |||
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 |
7
Ivanti_config_template.ini
Normal file
7
Ivanti_config_template.ini
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[platform]
|
||||||
|
url = https://platform4.risksense.com
|
||||||
|
api_ver = /api/v1
|
||||||
|
# PROD 1550 | UAT 1551
|
||||||
|
client_id = <pick 1550 or 1551>
|
||||||
|
[secrets]
|
||||||
|
api_key = <your API key here>
|
||||||
838
architecture.excalidraw
Normal file
838
architecture.excalidraw
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "title-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 400,
|
||||||
|
"y": 30,
|
||||||
|
"width": 400,
|
||||||
|
"height": 45,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 1,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "CVE Dashboard Architecture",
|
||||||
|
"fontSize": 36,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 32,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "CVE Dashboard Architecture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users-box",
|
||||||
|
"type": "ellipse",
|
||||||
|
"x": 500,
|
||||||
|
"y": 120,
|
||||||
|
"width": 200,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#e7f5ff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 2,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "users-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 505,
|
||||||
|
"y": 145,
|
||||||
|
"width": 190,
|
||||||
|
"height": 30,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 3,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Users\n(Admin/Editor/Viewer)",
|
||||||
|
"fontSize": 16,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 23,
|
||||||
|
"containerId": "users-box",
|
||||||
|
"originalText": "Users\n(Admin/Editor/Viewer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 450,
|
||||||
|
"y": 250,
|
||||||
|
"width": 300,
|
||||||
|
"height": 120,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#a5d8ff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 4,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "frontend-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 455,
|
||||||
|
"y": 255,
|
||||||
|
"width": 290,
|
||||||
|
"height": 110,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 5,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 103,
|
||||||
|
"containerId": "frontend-box",
|
||||||
|
"originalText": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 400,
|
||||||
|
"y": 420,
|
||||||
|
"width": 400,
|
||||||
|
"height": 180,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "#d0bfff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 6,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "backend-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 405,
|
||||||
|
"y": 425,
|
||||||
|
"width": 390,
|
||||||
|
"height": 170,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 7,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 163,
|
||||||
|
"containerId": "backend-box",
|
||||||
|
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 200,
|
||||||
|
"y": 680,
|
||||||
|
"width": 280,
|
||||||
|
"height": 140,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "#b2f2bb",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 8,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "db-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 205,
|
||||||
|
"y": 685,
|
||||||
|
"width": 270,
|
||||||
|
"height": 130,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 9,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 123,
|
||||||
|
"containerId": "db-box",
|
||||||
|
"originalText": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 550,
|
||||||
|
"y": 680,
|
||||||
|
"width": 280,
|
||||||
|
"height": 140,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "#ffec99",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 10,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "storage-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 555,
|
||||||
|
"y": 685,
|
||||||
|
"width": 270,
|
||||||
|
"height": 130,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 11,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 123,
|
||||||
|
"containerId": "storage-box",
|
||||||
|
"originalText": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nvd-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 900,
|
||||||
|
"y": 420,
|
||||||
|
"width": 220,
|
||||||
|
"height": 100,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "#ffc9c9",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 12,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "nvd-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nvd-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 905,
|
||||||
|
"y": 425,
|
||||||
|
"width": 210,
|
||||||
|
"height": 90,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 13,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 83,
|
||||||
|
"containerId": "nvd-box",
|
||||||
|
"originalText": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 600,
|
||||||
|
"y": 200,
|
||||||
|
"width": 0,
|
||||||
|
"height": 50,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 14,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 50]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "users-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "frontend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 600,
|
||||||
|
"y": 370,
|
||||||
|
"width": 0,
|
||||||
|
"height": 50,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 15,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 50]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "frontend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 500,
|
||||||
|
"y": 600,
|
||||||
|
"width": -140,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 16,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[-140, 0],
|
||||||
|
[-140, 80]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "db-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": true,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 700,
|
||||||
|
"y": 600,
|
||||||
|
"width": 0,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 17,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 80]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0.5,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "storage-box",
|
||||||
|
"focus": 0.5,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 800,
|
||||||
|
"y": 480,
|
||||||
|
"width": 100,
|
||||||
|
"height": 0,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 18,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[100, 0]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "nvd-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-http",
|
||||||
|
"type": "text",
|
||||||
|
"x": 610,
|
||||||
|
"y": 390,
|
||||||
|
"width": 100,
|
||||||
|
"height": 20,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 19,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "HTTP/REST API",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 17,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "HTTP/REST API"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-https",
|
||||||
|
"type": "text",
|
||||||
|
"x": 820,
|
||||||
|
"y": 460,
|
||||||
|
"width": 60,
|
||||||
|
"height": 20,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 20,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "HTTPS",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 17,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "HTTPS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "auth-note",
|
||||||
|
"type": "text",
|
||||||
|
"x": 100,
|
||||||
|
"y": 250,
|
||||||
|
"width": 280,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#495057",
|
||||||
|
"backgroundColor": "#f8f9fa",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "dashed",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 21,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 73,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "features-note",
|
||||||
|
"type": "text",
|
||||||
|
"x": 900,
|
||||||
|
"y": 580,
|
||||||
|
"width": 280,
|
||||||
|
"height": 120,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#495057",
|
||||||
|
"backgroundColor": "#f8f9fa",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "dashed",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 22,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 113,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"appState": {
|
||||||
|
"gridSize": null,
|
||||||
|
"viewBackgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
50
backend/migrations/add_archer_tickets_table.js
Normal file
50
backend/migrations/add_archer_tickets_table.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Migration: Add archer_tickets table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting Archer tickets migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Create archer_tickets table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
exc_number TEXT NOT NULL UNIQUE,
|
||||||
|
archer_url TEXT,
|
||||||
|
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ archer_tickets table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor)', (err) => {
|
||||||
|
if (err) console.error('Error creating CVE index:', err);
|
||||||
|
else console.log('✓ CVE index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status)', (err) => {
|
||||||
|
if (err) console.error('Error creating status index:', err);
|
||||||
|
else console.log('✓ Status index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number)', (err) => {
|
||||||
|
if (err) console.error('Error creating EXC number index:', err);
|
||||||
|
else console.log('✓ EXC number index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Indexes created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
70
backend/migrations/add_knowledge_base_table.js
Normal file
70
backend/migrations/add_knowledge_base_table.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Migration: Add knowledge_base table for storing documentation and policies
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Running migration: add_knowledge_base_table');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100),
|
||||||
|
file_path VARCHAR(500),
|
||||||
|
file_name VARCHAR(255),
|
||||||
|
file_type VARCHAR(50),
|
||||||
|
file_size INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating knowledge_base table:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created knowledge_base table');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug
|
||||||
|
ON knowledge_base(slug)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating slug index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category
|
||||||
|
ON knowledge_base(category)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating category index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on category');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at
|
||||||
|
ON knowledge_base(created_at DESC)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating created_at index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on created_at');
|
||||||
|
console.log('\nMigration completed successfully!');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
223
backend/routes/archerTickets.js
Normal file
223
backend/routes/archerTickets.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// routes/archerTickets.js
|
||||||
|
const express = require('express');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
// Validation helpers
|
||||||
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
function isValidCveId(cveId) {
|
||||||
|
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidVendor(vendor) {
|
||||||
|
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArcherTicketsRouter(db) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all Archer tickets (with optional filters)
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
const { cve_id, vendor, status } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (cve_id) {
|
||||||
|
query += ' AND cve_id = ?';
|
||||||
|
params.push(cve_id);
|
||||||
|
}
|
||||||
|
if (vendor) {
|
||||||
|
query += ' AND vendor = ?';
|
||||||
|
params.push(vendor);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
query += ' AND status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching Archer tickets:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Archer ticket
|
||||||
|
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!exc_number || typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'EXC number is required.' });
|
||||||
|
}
|
||||||
|
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
||||||
|
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
||||||
|
}
|
||||||
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||||
|
}
|
||||||
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||||
|
}
|
||||||
|
if (archer_url && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedStatus = status || 'Draft';
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating Archer ticket:', err);
|
||||||
|
if (err.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'CREATE_ARCHER_TICKET',
|
||||||
|
targetType: 'archer_ticket',
|
||||||
|
targetId: this.lastID,
|
||||||
|
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
message: 'Archer ticket created successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Archer ticket
|
||||||
|
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { exc_number, archer_url, status } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (exc_number !== undefined) {
|
||||||
|
if (typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'EXC number cannot be empty.' });
|
||||||
|
}
|
||||||
|
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
||||||
|
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (archer_url !== undefined && archer_url !== null && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing ticket
|
||||||
|
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (exc_number !== undefined) {
|
||||||
|
updates.push('exc_number = ?');
|
||||||
|
params.push(exc_number.trim());
|
||||||
|
}
|
||||||
|
if (archer_url !== undefined) {
|
||||||
|
updates.push('archer_url = ?');
|
||||||
|
params.push(archer_url || null);
|
||||||
|
}
|
||||||
|
if (status !== undefined) {
|
||||||
|
updates.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
|
||||||
|
params,
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (err.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'UPDATE_ARCHER_TICKET',
|
||||||
|
targetType: 'archer_ticket',
|
||||||
|
targetId: id,
|
||||||
|
details: { before: existing, changes: req.body },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Archer ticket
|
||||||
|
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'DELETE_ARCHER_TICKET',
|
||||||
|
targetType: 'archer_ticket',
|
||||||
|
targetId: id,
|
||||||
|
details: { deleted: ticket },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Archer ticket deleted successfully' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createArcherTicketsRouter;
|
||||||
352
backend/routes/knowledgeBase.js
Normal file
352
backend/routes/knowledgeBase.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
function createKnowledgeBaseRouter(db, upload) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper to sanitize filename
|
||||||
|
function sanitizePathSegment(segment) {
|
||||||
|
if (!segment || typeof segment !== 'string') return '';
|
||||||
|
return segment
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to generate slug from title
|
||||||
|
function generateSlug(title) {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to validate file type
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'.pdf', '.md', '.txt', '.doc', '.docx',
|
||||||
|
'.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.html', '.htm', '.json', '.yaml', '.yml',
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isValidFileType(filename) {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return ALLOWED_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/knowledge-base/upload - Upload new document
|
||||||
|
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
|
||||||
|
upload.single('file')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[KB Upload] Multer error:', err);
|
||||||
|
return res.status(400).json({ error: err.message || 'File upload failed' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}, async (req, res) => {
|
||||||
|
console.log('[KB Upload] Request received:', {
|
||||||
|
hasFile: !!req.file,
|
||||||
|
body: req.body,
|
||||||
|
contentType: req.headers['content-type']
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFile = req.file;
|
||||||
|
const { title, description, category } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
console.error('[KB Upload] Error: Title is missing');
|
||||||
|
if (uploadedFile) fs.unlinkSync(uploadedFile.path);
|
||||||
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!isValidFileType(uploadedFile.originalname)) {
|
||||||
|
fs.unlinkSync(uploadedFile.path);
|
||||||
|
return res.status(400).json({ error: 'File type not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||||
|
const slug = generateSlug(title);
|
||||||
|
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(kbDir)) {
|
||||||
|
fs.mkdirSync(kbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${timestamp}_${sanitizedName}`;
|
||||||
|
const filePath = path.join(kbDir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move uploaded file to permanent location
|
||||||
|
fs.renameSync(uploadedFile.path, filePath);
|
||||||
|
|
||||||
|
// Check if slug already exists
|
||||||
|
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.error('Error checking slug:', err);
|
||||||
|
return res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If slug exists, append timestamp to make it unique
|
||||||
|
const finalSlug = row ? `${slug}-${timestamp}` : slug;
|
||||||
|
|
||||||
|
// Insert new knowledge base entry
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO knowledge_base (
|
||||||
|
title, slug, description, category, file_path, file_name,
|
||||||
|
file_type, file_size, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
insertSql,
|
||||||
|
[
|
||||||
|
title.trim(),
|
||||||
|
finalSlug,
|
||||||
|
description || null,
|
||||||
|
category || 'General',
|
||||||
|
filePath,
|
||||||
|
sanitizedName,
|
||||||
|
uploadedFile.mimetype,
|
||||||
|
uploadedFile.size,
|
||||||
|
req.user.id
|
||||||
|
],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.error('Error inserting knowledge base entry:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'CREATE_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
this.lastID,
|
||||||
|
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
id: this.lastID,
|
||||||
|
title: title.trim(),
|
||||||
|
slug: finalSlug,
|
||||||
|
category: category || 'General'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up file on error
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
console.error('Error uploading knowledge base document:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base - List all articles
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||||
|
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by = u.id
|
||||||
|
ORDER BY kb.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching knowledge base articles:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch articles' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base/:id - Get single article details
|
||||||
|
router.get('/:id', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||||
|
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by = u.id
|
||||||
|
WHERE kb.id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching article:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base/:id/content - Get document content for display
|
||||||
|
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching document:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(row.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'VIEW_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ filename: row.file_name }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine content type for inline display
|
||||||
|
let contentType = row.file_type || 'application/octet-stream';
|
||||||
|
|
||||||
|
// For markdown files, send as plain text so frontend can parse it
|
||||||
|
if (row.file_name.endsWith('.md')) {
|
||||||
|
contentType = 'text/plain; charset=utf-8';
|
||||||
|
} else if (row.file_name.endsWith('.txt')) {
|
||||||
|
contentType = 'text/plain; charset=utf-8';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
// Use inline instead of attachment to allow browser to display
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
|
||||||
|
// Allow iframe embedding from frontend origin
|
||||||
|
res.removeHeader('X-Frame-Options');
|
||||||
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
|
||||||
|
res.sendFile(row.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/knowledge-base/:id/download - Download document
|
||||||
|
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching document:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(row.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'DOWNLOAD_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ filename: row.file_name }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||||
|
res.sendFile(row.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/knowledge-base/:id - Delete article
|
||||||
|
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching article for deletion:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete database record
|
||||||
|
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
if (fs.existsSync(row.file_path)) {
|
||||||
|
fs.unlinkSync(row.file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(
|
||||||
|
db,
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
'DELETE_KB_ARTICLE',
|
||||||
|
'knowledge_base',
|
||||||
|
id,
|
||||||
|
JSON.stringify({ title: row.title }),
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createKnowledgeBaseRouter;
|
||||||
@@ -19,6 +19,8 @@ const createAuditLogRouter = require('./routes/auditLog');
|
|||||||
const logAudit = require('./helpers/auditLog');
|
const logAudit = require('./helpers/auditLog');
|
||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
||||||
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -33,7 +35,7 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
|||||||
// Allowed file extensions for document uploads (documents only, no executables)
|
// Allowed file extensions for document uploads (documents only, no executables)
|
||||||
const ALLOWED_EXTENSIONS = new Set([
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
||||||
'.txt', '.csv', '.log', '.msg', '.eml',
|
'.txt', '.md', '.csv', '.log', '.msg', '.eml',
|
||||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
'.odt', '.ods', '.odp',
|
'.odt', '.ods', '.odp',
|
||||||
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||||
@@ -95,7 +97,7 @@ app.use((req, res, next) => {
|
|||||||
// Security headers
|
// Security headers
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
|
||||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
@@ -107,7 +109,11 @@ app.use(cors({
|
|||||||
origin: CORS_ORIGINS,
|
origin: CORS_ORIGINS,
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json({ limit: '1mb' }));
|
// Only parse JSON for requests with application/json content type
|
||||||
|
app.use(express.json({
|
||||||
|
limit: '1mb',
|
||||||
|
type: 'application/json'
|
||||||
|
}));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use('/uploads', express.static('uploads', {
|
app.use('/uploads', express.static('uploads', {
|
||||||
dotfiles: 'deny',
|
dotfiles: 'deny',
|
||||||
@@ -171,6 +177,12 @@ const upload = multer({
|
|||||||
// Weekly reports routes (editor/admin for upload, all authenticated for download)
|
// Weekly reports routes (editor/admin for upload, all authenticated for download)
|
||||||
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
||||||
|
|
||||||
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||||
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||||
|
|
||||||
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
|
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -647,3 +647,179 @@ h3.text-intel-accent {
|
|||||||
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
||||||
0 2px 8px rgba(14, 165, 233, 0.1);
|
0 2px 8px rgba(14, 165, 233, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Knowledge Base Content Area */
|
||||||
|
.kb-content-area {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 700px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Styling */
|
||||||
|
.markdown-content {
|
||||||
|
color: #E2E8F0;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0EA5E9;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #10B981;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F59E0B;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h4,
|
||||||
|
.markdown-content h5,
|
||||||
|
.markdown-content h6 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: #0EA5E9;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a:hover {
|
||||||
|
color: #38BDF8;
|
||||||
|
border-bottom-color: #38BDF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #10B981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid #0EA5E9;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th,
|
||||||
|
.markdown-content td {
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th {
|
||||||
|
background: rgba(14, 165, 233, 0.1);
|
||||||
|
color: #0EA5E9;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content td {
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content tr:hover {
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content strong {
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content em {
|
||||||
|
color: #CBD5E1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown } from 'lucide-react';
|
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
@@ -7,6 +7,8 @@ import UserManagement from './components/UserManagement';
|
|||||||
import AuditLog from './components/AuditLog';
|
import AuditLog from './components/AuditLog';
|
||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
import WeeklyReportModal from './components/WeeklyReportModal';
|
import WeeklyReportModal from './components/WeeklyReportModal';
|
||||||
|
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
||||||
|
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
@@ -156,7 +158,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
|||||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isAuthenticated, loading: authLoading, canWrite, isAdmin } = useAuth();
|
const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
||||||
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
||||||
@@ -175,6 +177,9 @@ export default function App() {
|
|||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showNvdSync, setShowNvdSync] = useState(false);
|
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||||
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
|
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
|
||||||
|
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
||||||
|
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
||||||
|
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
||||||
const [newCVE, setNewCVE] = useState({
|
const [newCVE, setNewCVE] = useState({
|
||||||
cve_id: '',
|
cve_id: '',
|
||||||
vendor: '',
|
vendor: '',
|
||||||
@@ -195,6 +200,7 @@ export default function App() {
|
|||||||
const [editNvdError, setEditNvdError] = useState(null);
|
const [editNvdError, setEditNvdError] = useState(null);
|
||||||
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
||||||
const [expandedCVEs, setExpandedCVEs] = useState({});
|
const [expandedCVEs, setExpandedCVEs] = useState({});
|
||||||
|
const [visibleCount, setVisibleCount] = useState(5);
|
||||||
const [jiraTickets, setJiraTickets] = useState([]);
|
const [jiraTickets, setJiraTickets] = useState([]);
|
||||||
const [showAddTicket, setShowAddTicket] = useState(false);
|
const [showAddTicket, setShowAddTicket] = useState(false);
|
||||||
const [showEditTicket, setShowEditTicket] = useState(false);
|
const [showEditTicket, setShowEditTicket] = useState(false);
|
||||||
@@ -205,6 +211,16 @@ export default function App() {
|
|||||||
// For adding ticket from within a CVE card
|
// For adding ticket from within a CVE card
|
||||||
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
|
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
|
// Archer tickets state
|
||||||
|
const [archerTickets, setArcherTickets] = useState([]);
|
||||||
|
const [showAddArcherTicket, setShowAddArcherTicket] = useState(false);
|
||||||
|
const [showEditArcherTicket, setShowEditArcherTicket] = useState(false);
|
||||||
|
const [editingArcherTicket, setEditingArcherTicket] = useState(null);
|
||||||
|
const [archerTicketForm, setArcherTicketForm] = useState({
|
||||||
|
exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: ''
|
||||||
|
});
|
||||||
|
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
};
|
};
|
||||||
@@ -278,6 +294,19 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchKnowledgeBaseArticles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
|
||||||
|
const data = await response.json();
|
||||||
|
setKnowledgeBaseArticles(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching knowledge base articles:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchJiraTickets = async () => {
|
const fetchJiraTickets = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
||||||
@@ -291,6 +320,19 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchArcherTickets = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch Archer tickets');
|
||||||
|
const data = await response.json();
|
||||||
|
setArcherTickets(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching Archer tickets:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDocuments = async (cveId, vendor) => {
|
const fetchDocuments = async (cveId, vendor) => {
|
||||||
const key = `${cveId}-${vendor}`;
|
const key = `${cveId}-${vendor}`;
|
||||||
if (cveDocuments[key]) return;
|
if (cveDocuments[key]) return;
|
||||||
@@ -346,6 +388,45 @@ export default function App() {
|
|||||||
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewKBArticle = async (articleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch article');
|
||||||
|
|
||||||
|
const article = await response.json();
|
||||||
|
setSelectedKBArticle(article);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching knowledge base article:', err);
|
||||||
|
setError('Failed to load article');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadKBArticle = async (id, filename) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading knowledge base article:', err);
|
||||||
|
setError('Failed to download document');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddCVE = async (e) => {
|
const handleAddCVE = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -688,12 +769,99 @@ export default function App() {
|
|||||||
setShowAddTicket(true);
|
setShowAddTicket(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== ARCHER TICKET HANDLERS ==========
|
||||||
|
|
||||||
|
const handleAddArcherTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(archerTicketForm)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to create Archer ticket');
|
||||||
|
}
|
||||||
|
alert('Archer ticket added successfully!');
|
||||||
|
setShowAddArcherTicket(false);
|
||||||
|
setAddArcherTicketContext(null);
|
||||||
|
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
|
||||||
|
fetchArcherTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditArcherTicket = (ticket) => {
|
||||||
|
setEditingArcherTicket(ticket);
|
||||||
|
setArcherTicketForm({
|
||||||
|
exc_number: ticket.exc_number,
|
||||||
|
archer_url: ticket.archer_url || '',
|
||||||
|
status: ticket.status,
|
||||||
|
cve_id: ticket.cve_id,
|
||||||
|
vendor: ticket.vendor
|
||||||
|
});
|
||||||
|
setShowEditArcherTicket(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateArcherTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets/${editingArcherTicket.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
exc_number: archerTicketForm.exc_number,
|
||||||
|
archer_url: archerTicketForm.archer_url,
|
||||||
|
status: archerTicketForm.status
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to update Archer ticket');
|
||||||
|
}
|
||||||
|
alert('Archer ticket updated!');
|
||||||
|
setShowEditArcherTicket(false);
|
||||||
|
setEditingArcherTicket(null);
|
||||||
|
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
|
||||||
|
fetchArcherTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArcherTicket = async (ticket) => {
|
||||||
|
if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete Archer ticket');
|
||||||
|
alert('Archer ticket deleted');
|
||||||
|
fetchArcherTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddArcherTicketForCVE = (cve_id, vendor) => {
|
||||||
|
setAddArcherTicketContext({ cve_id, vendor });
|
||||||
|
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor });
|
||||||
|
setShowAddArcherTicket(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch CVEs from API when authenticated
|
// Fetch CVEs from API when authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
fetchVendors();
|
fetchVendors();
|
||||||
fetchJiraTickets();
|
fetchJiraTickets();
|
||||||
|
fetchArcherTickets();
|
||||||
|
fetchKnowledgeBaseArticles();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
@@ -702,6 +870,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
|
setVisibleCount(5);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchQuery, selectedVendor, selectedSeverity]);
|
}, [searchQuery, selectedVendor, selectedSeverity]);
|
||||||
@@ -826,6 +995,14 @@ export default function App() {
|
|||||||
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
|
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Knowledge Base Modal */}
|
||||||
|
{showKnowledgeBase && (
|
||||||
|
<KnowledgeBaseModal
|
||||||
|
onClose={() => setShowKnowledgeBase(false)}
|
||||||
|
onUpdate={fetchKnowledgeBaseArticles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add CVE Modal */}
|
{/* Add CVE Modal */}
|
||||||
{showAddCVE && (
|
{showAddCVE && (
|
||||||
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
@@ -1271,52 +1448,235 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Archer Ticket Modal */}
|
||||||
|
{showAddArcherTicket && (
|
||||||
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-purple-400 font-mono">Add Archer Risk Ticket</h2>
|
||||||
|
<button onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAddArcherTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="EXC-5754"
|
||||||
|
value={archerTicketForm.exc_number}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://archer.example.com/..."
|
||||||
|
value={archerTicketForm.archer_url}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="CVE-2024-1234"
|
||||||
|
value={archerTicketForm.cve_id}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, cve_id: e.target.value.toUpperCase()})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
readOnly={!!addArcherTicketContext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Vendor name"
|
||||||
|
value={archerTicketForm.vendor}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, vendor: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
readOnly={!!addArcherTicketContext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||||
|
<select
|
||||||
|
value={archerTicketForm.status}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Under Review">Under Review</option>
|
||||||
|
<option value="Accepted">Accepted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||||
|
Create Ticket
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Archer Ticket Modal */}
|
||||||
|
{showEditArcherTicket && editingArcherTicket && (
|
||||||
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-purple-400 font-mono">Edit Archer Risk Ticket</h2>
|
||||||
|
<button onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
|
||||||
|
{editingArcherTicket.cve_id} / {editingArcherTicket.vendor}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdateArcherTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={archerTicketForm.exc_number}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={archerTicketForm.archer_url}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||||
|
<select
|
||||||
|
value={archerTicketForm.status}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Under Review">Under Review</option>
|
||||||
|
<option value="Accepted">Accepted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Three Column Layout */}
|
{/* Three Column Layout */}
|
||||||
<div className="grid grid-cols-12 gap-6">
|
<div className="grid grid-cols-12 gap-6">
|
||||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
||||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
<div className="col-span-12 lg:col-span-3 space-y-4">
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '1rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
|
<div className="flex items-center justify-between mb-4">
|
||||||
Knowledge Base
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
|
||||||
</h2>
|
Knowledge Base
|
||||||
|
</h2>
|
||||||
|
{(user?.role === 'admin' || user?.role === 'editor') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKnowledgeBase(true)}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem' }}
|
||||||
|
title="Manage Knowledge Base"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Wiki/Blog Style Entries */}
|
{/* Knowledge Base Entries */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
{knowledgeBaseArticles.length === 0 ? (
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">CVE Response Procedures</h3>
|
<div className="text-center py-8" style={{ color: '#64748B' }}>
|
||||||
<p className="text-gray-400 text-xs mb-2">Standard operating procedures for vulnerability response and escalation...</p>
|
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-08</span>
|
<p className="text-sm">No documents yet</p>
|
||||||
</div>
|
{(user?.role === 'admin' || user?.role === 'editor') && (
|
||||||
|
<button
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
onClick={() => setShowKnowledgeBase(true)}
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Vendor Contact Matrix</h3>
|
className="intel-button intel-button-small mt-3"
|
||||||
<p className="text-gray-400 text-xs mb-2">Emergency contacts and escalation paths for security vendors...</p>
|
>
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-05</span>
|
Add First Document
|
||||||
</div>
|
</button>
|
||||||
|
)}
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
</div>
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Severity Classification Guide</h3>
|
) : (
|
||||||
<p className="text-gray-400 text-xs mb-2">Guidelines for assessing and classifying vulnerability severity levels...</p>
|
knowledgeBaseArticles.slice(0, 5).map((article) => (
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-28</span>
|
<div
|
||||||
</div>
|
key={article.id}
|
||||||
|
onClick={() => handleViewKBArticle(article.id)}
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }}
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Patching Policy</h3>
|
className="hover:border-intel-success"
|
||||||
<p className="text-gray-400 text-xs mb-2">Enterprise patch management timelines and approval workflow...</p>
|
>
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-15</span>
|
<h3 className="text-white font-semibold text-sm mb-1 font-mono">{article.title}</h3>
|
||||||
</div>
|
{article.description && (
|
||||||
|
<p className="text-gray-400 text-xs mb-2 line-clamp-2">{article.description}</p>
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
)}
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Documentation Standards</h3>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 text-xs mb-2">Required documentation for vulnerability tracking and audit compliance...</p>
|
<span className="text-xs text-intel-success font-mono">
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-10</span>
|
{new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
</div>
|
</span>
|
||||||
|
{article.category && article.category !== 'General' && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ background: 'rgba(16, 185, 129, 0.2)', color: '#10B981' }}>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{knowledgeBaseArticles.length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKnowledgeBase(true)}
|
||||||
|
className="text-xs text-center w-full py-2"
|
||||||
|
style={{ color: '#10B981' }}
|
||||||
|
>
|
||||||
|
View all {knowledgeBaseArticles.length} documents →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CENTER PANEL - Main Content */}
|
{/* CENTER PANEL - Main Content */}
|
||||||
<div className="col-span-12 lg:col-span-6 space-y-4">
|
<div className="col-span-12 lg:col-span-6 space-y-4">
|
||||||
|
{/* Knowledge Base Viewer */}
|
||||||
|
{selectedKBArticle ? (
|
||||||
|
<KnowledgeBaseViewer
|
||||||
|
article={selectedKBArticle}
|
||||||
|
onClose={() => setSelectedKBArticle(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Quick Check */}
|
{/* Quick Check */}
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
@@ -1471,7 +1831,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => {
|
{Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
|
||||||
const isCVEExpanded = expandedCVEs[cveId];
|
const isCVEExpanded = expandedCVEs[cveId];
|
||||||
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
|
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
|
||||||
const highestSeverity = vendorEntries.reduce((highest, entry) => {
|
const highestSeverity = vendorEntries.reduce((highest, entry) => {
|
||||||
@@ -1743,6 +2103,40 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Show more / pagination footer */}
|
||||||
|
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-gray-500 font-mono text-xs">
|
||||||
|
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(v => v + 5)}
|
||||||
|
className="intel-button intel-button-primary text-xs px-3 py-1"
|
||||||
|
>
|
||||||
|
Show 5 more
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
|
||||||
|
className="intel-button text-xs px-3 py-1"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(5)}
|
||||||
|
className="intel-button text-xs px-3 py-1"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||||
|
>
|
||||||
|
Collapse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1753,6 +2147,8 @@ export default function App() {
|
|||||||
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
|
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* End Center Panel */}
|
{/* End Center Panel */}
|
||||||
|
|
||||||
@@ -1887,6 +2283,70 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archer Risk Acceptance Tickets */}
|
||||||
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
Archer Risk Tickets
|
||||||
|
</h2>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
|
||||||
|
{archerTickets.filter(t => t.status !== 'Accepted').length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
|
||||||
|
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<a
|
||||||
|
href={ticket.archer_url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
{ticket.exc_number}
|
||||||
|
</a>
|
||||||
|
{canWrite() && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||||
|
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
|
||||||
|
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* End Right Panel */}
|
{/* End Right Panel */}
|
||||||
|
|
||||||
|
|||||||
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||||
|
const [phase, setPhase] = useState('idle'); // idle, uploading, success, error
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('General');
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [existingArticles, setExistingArticles] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Fetch existing articles on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExistingArticles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchExistingArticles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||||
|
const data = await response.json();
|
||||||
|
setExistingArticles(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching articles:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
const allowedExtensions = ['.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.json', '.yaml', '.yml'];
|
||||||
|
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(ext)) {
|
||||||
|
setError('File type not allowed. Please upload: PDF, Markdown, Text, Office docs, or HTML files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Auto-populate title from filename if empty
|
||||||
|
if (!title) {
|
||||||
|
const filename = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||||
|
setTitle(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!selectedFile || !title.trim()) {
|
||||||
|
setError('Please provide both a title and file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('uploading');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('title', title.trim());
|
||||||
|
formData.append('description', description.trim());
|
||||||
|
formData.append('category', category);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setResult(data);
|
||||||
|
setPhase('success');
|
||||||
|
|
||||||
|
// Refresh the list of existing articles
|
||||||
|
await fetchExistingArticles();
|
||||||
|
|
||||||
|
// Notify parent to refresh
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (id, filename) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError('Failed to download file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id, articleTitle) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Delete failed');
|
||||||
|
|
||||||
|
// Refresh the list
|
||||||
|
await fetchExistingArticles();
|
||||||
|
|
||||||
|
// Notify parent to refresh
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
setError('Failed to delete article');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setSelectedFile(null);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setCategory('General');
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (!bytes) return 'Unknown size';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (cat) => {
|
||||||
|
const colors = {
|
||||||
|
'General': '#94A3B8',
|
||||||
|
'Policy': '#0EA5E9',
|
||||||
|
'Procedure': '#10B981',
|
||||||
|
'Guide': '#F59E0B',
|
||||||
|
'Reference': '#8B5CF6'
|
||||||
|
};
|
||||||
|
return colors[cat] || '#94A3B8';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Knowledge Base</h2>
|
||||||
|
<button onClick={onClose} className="modal-close">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* Idle Phase - Upload Form */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g., Inventory Management Policy"
|
||||||
|
className="intel-input w-full"
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of this document..."
|
||||||
|
className="intel-input w-full"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="General">General</option>
|
||||||
|
<option value="Policy">Policy</option>
|
||||||
|
<option value="Procedure">Procedure</option>
|
||||||
|
<option value="Guide">Guide</option>
|
||||||
|
<option value="Reference">Reference</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Document File *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||||
|
Selected: {selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!selectedFile || !title.trim()}
|
||||||
|
className={`intel-button w-full ${selectedFile && title.trim() ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<UploadIcon className="w-4 h-4 mr-2" />
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Phase */}
|
||||||
|
{phase === 'uploading' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Uploading document...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Phase */}
|
||||||
|
{phase === 'success' && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||||
|
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||||
|
{result.title} has been added to the knowledge base.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Upload Another Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Phase */}
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing Articles Section */}
|
||||||
|
{(phase === 'idle' || phase === 'success') && existingArticles.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Existing Documents ({existingArticles.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{existingArticles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="intel-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<FileText className="w-4 h-4 flex-shrink-0" style={{ color: getCategoryColor(article.category) }} />
|
||||||
|
<p className="font-medium truncate" style={{ color: '#E2E8F0' }}>
|
||||||
|
{article.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm mb-2 line-clamp-2" style={{ color: '#94A3B8' }}>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: `${getCategoryColor(article.category)}33`,
|
||||||
|
color: getCategoryColor(article.category)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span>{formatDate(article.created_at)}</span>
|
||||||
|
<span>{formatFileSize(article.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(article.id, article.file_name)}
|
||||||
|
className="intel-button intel-button-small intel-button-success"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(article.id, article.title)}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
style={{ borderColor: '#EF4444', color: '#EF4444' }}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArticleContent();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [article.id]);
|
||||||
|
|
||||||
|
const fetchArticleContent = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/content`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch article content');
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
setContent(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching article content:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = article.file_name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError('Failed to download file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMarkdown = article.file_name?.endsWith('.md');
|
||||||
|
const isText = article.file_name?.endsWith('.txt');
|
||||||
|
const isPDF = article.file_name?.endsWith('.pdf');
|
||||||
|
const isImage = /\.(jpg|jpeg|png|gif|bmp)$/i.test(article.file_name || '');
|
||||||
|
|
||||||
|
const getCategoryColor = (cat) => {
|
||||||
|
const colors = {
|
||||||
|
'General': '#94A3B8',
|
||||||
|
'Policy': '#0EA5E9',
|
||||||
|
'Procedure': '#10B981',
|
||||||
|
'Guide': '#F59E0B',
|
||||||
|
'Reference': '#8B5CF6'
|
||||||
|
};
|
||||||
|
return colors[cat] || '#94A3B8';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||||
|
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
|
||||||
|
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded"
|
||||||
|
style={{
|
||||||
|
background: `${getCategoryColor(article.category)}33`,
|
||||||
|
color: getCategoryColor(article.category),
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span>Created: {formatDate(article.created_at)}</span>
|
||||||
|
{article.created_by_username && (
|
||||||
|
<span>By: {article.created_by_username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="kb-content-area">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Loading document...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Failed to Load Document</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{/* Markdown Rendering */}
|
||||||
|
{isMarkdown && (
|
||||||
|
<div className="markdown-content">
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plain Text */}
|
||||||
|
{isText && !isMarkdown && (
|
||||||
|
<pre
|
||||||
|
className="text-sm p-4 rounded overflow-auto"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
maxHeight: '600px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF */}
|
||||||
|
{isPDF && (
|
||||||
|
<div className="w-full" style={{ height: '700px' }}>
|
||||||
|
<iframe
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
title={article.title}
|
||||||
|
className="w-full h-full rounded"
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} />
|
||||||
|
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Your browser doesn't support PDF preview. Click the download button to view this file.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{isImage && (
|
||||||
|
<div className="text-center">
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
alt={article.title}
|
||||||
|
className="max-w-full h-auto rounded"
|
||||||
|
style={{ border: '1px solid rgba(14, 165, 233, 0.3)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other file types */}
|
||||||
|
{!isMarkdown && !isText && !isPDF && !isImage && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#94A3B8' }} />
|
||||||
|
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Preview not available for this file type.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
ivantiAPI.py
Normal file
155
ivantiAPI.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Ivanti API class/wrapper | Evan Compton (P2886385), updated 11/13/2025
|
||||||
|
|
||||||
|
### ! README | IMPORTANT INFORMATION ! ###
|
||||||
|
# requires an "Ivanti_config.ini" file in the same directory
|
||||||
|
# edit "Ivanti_config_template.ini", then save as "Ivanti_config.ini"
|
||||||
|
|
||||||
|
### ? CODE PURPOSE ? ###
|
||||||
|
# the primary purpose of this class/wrapper is to export data as a Pandas Dataframe and/or a CSV file
|
||||||
|
# this class primarily targets these endpoints: host, tag, hostFinding, vulnerability
|
||||||
|
# it should work on other endpoints as well, but the 4 above are the only ones tested
|
||||||
|
# usage examples of this class are at the end of this file
|
||||||
|
|
||||||
|
# library imports
|
||||||
|
import requests, urllib3, configparser, pandas as pd
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3 import Retry
|
||||||
|
|
||||||
|
# fix (ignore) SSL verification...
|
||||||
|
# Charter-specific issue; feel free to fix this if you can...
|
||||||
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
|
urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
|
# Ivanti API class
|
||||||
|
class Ivanti:
|
||||||
|
def __init__(self, config_file='./Ivanti_config.ini'):
|
||||||
|
# read our config file
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(config_file)
|
||||||
|
|
||||||
|
# set up environment & auth
|
||||||
|
PLATFORM = config.get('platform', 'url') + config.get('platform', 'api_ver')
|
||||||
|
IVANTI_API_KEY = config.get('secrets', 'api_key')
|
||||||
|
self.CLIENT_ID = config.get('platform', 'client_id')
|
||||||
|
self.URL_BASE = f'{PLATFORM}/client/{self.CLIENT_ID}'
|
||||||
|
|
||||||
|
# universal header for our requests
|
||||||
|
self.header = {
|
||||||
|
'x-api-key': IVANTI_API_KEY,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# dictionaries for filters and fields, sorted with keys by endpoint prefixes
|
||||||
|
self.filters = {}
|
||||||
|
self.fields = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
# function used for HTTP requests- thank you, Ivanti... useful code
|
||||||
|
def request(max_retries=5, backoff_factor=0.5, status_forcelist=(419,429)):
|
||||||
|
"""
|
||||||
|
Create a Requests session that uses automatic retries.
|
||||||
|
:param max_retries: Maximum number of retries to attempt
|
||||||
|
:type max_retries: int
|
||||||
|
:param backoff_factor: Backoff factor used to calculate time between retries.
|
||||||
|
:type backoff_factor: float
|
||||||
|
:param status_forcelist: A tuple containing the response status codes that should trigger a retry.
|
||||||
|
:type status_forcelist: tuple
|
||||||
|
:return: Requests Session
|
||||||
|
:rtype: Requests Session Object
|
||||||
|
"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry = Retry(
|
||||||
|
total=max_retries,
|
||||||
|
read=max_retries,
|
||||||
|
connect=max_retries,
|
||||||
|
backoff_factor=backoff_factor,
|
||||||
|
status_forcelist=status_forcelist,
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry)
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
# retrieve all filters for an endpoint (tag, host, etc)
|
||||||
|
def get_filters(self, endp='tag'):
|
||||||
|
URL_FILTERS = f'{self.URL_BASE}/{endp}/filter'
|
||||||
|
self.last_resp = self.request().get(URL_FILTERS, headers=self.header, verify=False)
|
||||||
|
self.filters[endp] = self.last_resp.json()
|
||||||
|
return self.filters[endp]
|
||||||
|
|
||||||
|
# retrieve all fields for an endpoint (tag, host, etc)
|
||||||
|
def get_fields(self, endp='tag'):
|
||||||
|
URL_FIELDS = f'{self.URL_BASE}/{endp}/export/template'
|
||||||
|
self.last_resp = self.request().get(URL_FIELDS, headers=self.header, verify=False)
|
||||||
|
self.fields[endp] = self.last_resp.json()['exportableFields']
|
||||||
|
return self.fields[endp]
|
||||||
|
|
||||||
|
# this uses the "{subject}/search" endpoint instead of "{subject}/export"
|
||||||
|
def search(self, endp='tag', save=None, pages=None, size=750):
|
||||||
|
'''
|
||||||
|
Uses the "/client/{client_id}/{subject}/search" endpoint to export data as JSON.
|
||||||
|
:param endp: String for endpoint name; host, tag, group, etc. (default: "tag")
|
||||||
|
:param save: String for filename to save, end with ".csv" (default: none)
|
||||||
|
:param pages: Integer to limit the number of pages to pull (default: all pages)
|
||||||
|
:param size: Integer defining how many records to pull per page (default: 750 records)
|
||||||
|
:return: Pandas DataFrame
|
||||||
|
'''
|
||||||
|
# most endpoints follow the same URL structure and usage pattern
|
||||||
|
# filters and fields dont matter for searches- only for exports!
|
||||||
|
URL_SEARCH = f'{self.URL_BASE}/{endp}/search'
|
||||||
|
body = {
|
||||||
|
'projection': 'basic', # can also be set to 'detail'
|
||||||
|
'sort': [
|
||||||
|
{
|
||||||
|
'field': 'id',
|
||||||
|
'direction': 'ASC'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'page': 0,
|
||||||
|
'size': size
|
||||||
|
}
|
||||||
|
|
||||||
|
# post a search, get first page
|
||||||
|
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f'[!] ERROR: Search failed.\n- code: {resp.status_code}\n- text: {resp.text}')
|
||||||
|
totalPages = int(resp.json()['page']['totalPages'])
|
||||||
|
totalRecords = int(resp.json()['page']['totalElements'])
|
||||||
|
body['page'] = int(resp.json()['page']['number']) + 1
|
||||||
|
msg = f'[?] Search requested for "{endp}"\n[?] Total pages: {totalPages}\n[?] Total records: {totalRecords}\n[?] Batch size: {size}'
|
||||||
|
if pages:
|
||||||
|
msg += f'\n[?] Page limit: {pages} pages'
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
# limit results?
|
||||||
|
if pages:
|
||||||
|
totalPages = pages
|
||||||
|
|
||||||
|
# loop until the last page
|
||||||
|
subject = f'{endp[:-1]}ies' if endp.endswith('y') else f'{endp}s'
|
||||||
|
data = []
|
||||||
|
while body['page'] < totalPages:
|
||||||
|
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||||
|
body['page'] = int(resp.json()['page']['number']) + 1
|
||||||
|
data.extend(resp.json()['_embedded'][subject])
|
||||||
|
print(f'[?] Page progress: [{body["page"]}/{totalPages}] ({len(data)} total records retrieved)\r', end='')
|
||||||
|
print(f'\n[+] Search completed. {len(data)} records retrieved!')
|
||||||
|
|
||||||
|
# make a nice dataframe, save file if wanted, return the frame
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
if save:
|
||||||
|
df.to_csv(save, index=False)
|
||||||
|
return df
|
||||||
|
|
||||||
|
### ? EXAMPLE USAGE ? ###
|
||||||
|
# configure the connection and auth, create an instance object
|
||||||
|
#API = Ivanti('./Ivanti_config.ini')
|
||||||
|
|
||||||
|
# the "search" function goes to the "/client/{clientID}/{subject}/search" endpoint
|
||||||
|
#df = API.search('host', save='IvantiHostsTest_5pages.csv', pages=5)
|
||||||
|
#df = API.search('tag', save='IvantiTagsTest_5pages.csv', pages=5)
|
||||||
|
#df = API.search('hostFinding', save='IvantiHostFindingsTest_5pages.csv', pages=5)
|
||||||
|
#df = API.search('vulnerability', save='IvantiVulnerabilitiesTest_5pages.csv', pages=5)
|
||||||
|
|
||||||
|
# you can also retrieve all possible filters and exportable fields per subject
|
||||||
|
#filters = API.get_fields('host')
|
||||||
|
#fields = API.get_filters('tag')
|
||||||
Reference in New Issue
Block a user