@cryptotaxi247 / CoPilot / commits / bde974de

579 vulns report csv (#580)

* Add VulnerabilityReport model and migration for vulnerability_reports table * Implement CSV vulnerability report generation and retrieval endpoints * Add background task for CSV vulnerability report generation and update report status handling * Add endpoint and service function to delete vulnerability reports * feat: Add vulnerability report generation and management features - Implemented API endpoints for generating, listing, downloading, and deleting vulnerability reports. - Enhanced the Navbar to include links for generating and managing vulnerability reports. - Created a new GenerateReportForm component for report generation with various filters. - Developed a VulnerabilityReports component to display and manage generated reports. - Added utility functions for formatting dates and byte sizes. - Updated types in vulnerabilities.d.ts to support new report functionalities. * fix: Enhance customer loading logic to handle various API response structures * fix: Adjust modal width for generating vulnerability reports * fix: Update default value of include_epss to false in report generation form * precommit fixes * lint * fix: Update current version to 0.1.17

taylor_socfortress committed Jan 1, 2026 at 09:11 UTC bde974dec152831fc400b229641e005137fc64b4
14 files changed +2457 -511
backend/alembic/versions/d21f0b3329b4_add_vuln_reports_table.py new
+62
@@ -0,0 +1,62 @@
1 +"""Add vuln reports table
2 +
3 +Revision ID: d21f0b3329b4
4 +Revises: ea1e223c3d62
5 +Create Date: 2025-12-31 15:06:46.622255
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "d21f0b3329b4"
17 +down_revision: Union[str, None] = "ea1e223c3d62"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "vulnerability_reports",
26 + sa.Column("filters_json", sa.Text(), nullable=True),
27 + sa.Column("error_message", sa.Text(), nullable=True),
28 + sa.Column("id", sa.Integer(), nullable=False),
29 + sa.Column("report_name", sa.String(length=255), nullable=False),
30 + sa.Column("customer_code", sa.String(length=50), nullable=False),
31 + sa.Column("bucket_name", sa.String(length=255), nullable=False),
32 + sa.Column("object_key", sa.String(length=1024), nullable=False),
33 + sa.Column("file_name", sa.String(length=255), nullable=False),
34 + sa.Column("file_size", sa.Integer(), nullable=False),
35 + sa.Column("file_hash", sa.String(length=128), nullable=False),
36 + sa.Column("generated_at", sa.DateTime(), nullable=False),
37 + sa.Column("generated_by", sa.Integer(), nullable=False),
38 + sa.Column("total_vulnerabilities", sa.Integer(), nullable=False),
39 + sa.Column("critical_count", sa.Integer(), nullable=False),
40 + sa.Column("high_count", sa.Integer(), nullable=False),
41 + sa.Column("medium_count", sa.Integer(), nullable=False),
42 + sa.Column("low_count", sa.Integer(), nullable=False),
43 + sa.Column("status", sa.String(length=50), nullable=False),
44 + sa.ForeignKeyConstraint(
45 + ["customer_code"],
46 + ["customers.customer_code"],
47 + ),
48 + sa.PrimaryKeyConstraint("id"),
49 + )
50 + op.create_index(op.f("ix_vulnerability_reports_customer_code"), "vulnerability_reports", ["customer_code"], unique=False)
51 + op.create_index(op.f("ix_vulnerability_reports_generated_at"), "vulnerability_reports", ["generated_at"], unique=False)
52 + op.create_index(op.f("ix_vulnerability_reports_status"), "vulnerability_reports", ["status"], unique=False)
53 + # ### end Alembic commands ###
54 +
55 +
56 +def downgrade() -> None:
57 + # ### commands auto generated by Alembic - please adjust! ###
58 + op.drop_index(op.f("ix_vulnerability_reports_status"), table_name="vulnerability_reports")
59 + op.drop_index(op.f("ix_vulnerability_reports_generated_at"), table_name="vulnerability_reports")
60 + op.drop_index(op.f("ix_vulnerability_reports_customer_code"), table_name="vulnerability_reports")
61 + op.drop_table("vulnerability_reports")
62 + # ### end Alembic commands ###
backend/app/agents/vulnerabilities/routes/vulnerabilities.py
+355
@@ -1,3 +1,6 @@
1 +import io
2 +import json
3 +from datetime import datetime
4 from typing import List
5 from typing import Optional
6
@@ -7,8 +10,10 @@ from fastapi import Depends
10 from fastapi import HTTPException
11 from fastapi import Query
12 from fastapi import Security
13 +from fastapi.responses import StreamingResponse
14 from loguru import logger
15 from sqlalchemy.ext.asyncio import AsyncSession
16 +from sqlalchemy.future import select
17
18 from app.agents.vulnerabilities.schema.vulnerabilities import (
19 AgentVulnerabilitiesResponse,
@@ -16,6 +21,15 @@ from app.agents.vulnerabilities.schema.vulnerabilities import (
21 from app.agents.vulnerabilities.schema.vulnerabilities import (
22 VulnerabilityDeleteResponse,
23 )
24 +from app.agents.vulnerabilities.schema.vulnerabilities import (
25 + VulnerabilityReportGenerateRequest,
26 +)
27 +from app.agents.vulnerabilities.schema.vulnerabilities import (
28 + VulnerabilityReportGenerateResponse,
29 +)
30 +from app.agents.vulnerabilities.schema.vulnerabilities import (
31 + VulnerabilityReportListResponse,
32 +)
33 from app.agents.vulnerabilities.schema.vulnerabilities import (
34 VulnerabilitySearchResponse,
35 )
@@ -23,12 +37,21 @@ from app.agents.vulnerabilities.schema.vulnerabilities import VulnerabilityStats
37 from app.agents.vulnerabilities.schema.vulnerabilities import VulnerabilitySyncRequest
38 from app.agents.vulnerabilities.schema.vulnerabilities import VulnerabilitySyncResponse
39 from app.agents.vulnerabilities.services.vulnerabilities import delete_vulnerabilities
40 +from app.agents.vulnerabilities.services.vulnerabilities import (
41 + generate_vulnerability_csv_report,
42 +)
43 from app.agents.vulnerabilities.services.vulnerabilities import (
44 get_vulnerabilities_by_agent,
45 )
46 +from app.agents.vulnerabilities.services.vulnerabilities import (
47 + get_vulnerability_report_download,
48 +)
49 from app.agents.vulnerabilities.services.vulnerabilities import (
50 get_vulnerability_statistics,
51 )
52 +from app.agents.vulnerabilities.services.vulnerabilities import (
53 + list_vulnerability_reports,
54 +)
55 from app.agents.vulnerabilities.services.vulnerabilities import (
56 search_vulnerabilities_from_indexer,
57 )
@@ -40,6 +63,7 @@ from app.auth.models.users import User
63 from app.auth.routes.auth import AuthHandler
64 from app.db.db_session import get_db
65 from app.db.db_session import get_db_session
66 +from app.db.universal_models import VulnerabilityReport
67
68 # Create router for vulnerability endpoints
69 vulnerabilities_router = APIRouter()
@@ -449,3 +473,334 @@ async def search_vulnerabilities(
473 except Exception as e:
474 logger.error(f"Error in vulnerability search endpoint: {e}")
475 raise HTTPException(status_code=500, detail=f"Failed to search vulnerabilities: {e}")
476 +
477 +
478 +@vulnerabilities_router.post(
479 + "/reports/generate",
480 + response_model=VulnerabilityReportGenerateResponse,
481 + description="Generate a CSV vulnerability report for a specific customer",
482 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
483 +)
484 +async def generate_report(
485 + request: VulnerabilityReportGenerateRequest,
486 + current_user: User = Depends(AuthHandler().get_current_user),
487 + db: AsyncSession = Depends(get_db),
488 +) -> VulnerabilityReportGenerateResponse:
489 + """
490 + Generate a CSV vulnerability report for a specific customer.
491 +
492 + The report will include all vulnerabilities matching the specified filters
493 + and will be stored in MinIO for later retrieval.
494 +
495 + **Features:**
496 + - Generates comprehensive CSV report with all vulnerability details
497 + - Applies same filtering as search endpoint
498 + - Stores report in MinIO for persistent access
499 + - Tracks report metadata in database
500 + - Optional EPSS scoring inclusion
501 +
502 + **Report Contents:**
503 + - CVE ID, Severity, Title
504 + - Agent Name, Customer Code
505 + - Package details (name, version, architecture)
506 + - Detection and publication dates
507 + - EPSS scores (if enabled)
508 + - References
509 +
510 + Args:
511 + request: Report generation request with filters
512 + current_user: Current authenticated user
513 + db: Database session
514 +
515 + Returns:
516 + VulnerabilityReportGenerateResponse with report details and download URL
517 + """
518 + return await generate_vulnerability_csv_report(db, current_user, request)
519 +
520 +
521 +@vulnerabilities_router.post(
522 + "/reports/generate/background",
523 + response_model=dict,
524 + description="Generate a CSV vulnerability report as a background task (recommended for large datasets)",
525 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
526 +)
527 +async def generate_report_background(
528 + request: VulnerabilityReportGenerateRequest,
529 + background_tasks: BackgroundTasks,
530 + current_user: User = Depends(AuthHandler().get_current_user),
531 + db: AsyncSession = Depends(get_db),
532 +) -> dict:
533 + """
534 + Generate a CSV vulnerability report as a background task.
535 +
536 + This endpoint is recommended for large datasets that may take significant time
537 + to process. The report will be generated asynchronously and can be retrieved
538 + later via the list/download endpoints.
539 +
540 + **Workflow:**
541 + 1. Submit report generation request (returns immediately with report_id)
542 + 2. Poll the `/reports` endpoint to check for completion
543 + 3. Download the completed report via `/reports/{report_id}/download`
544 +
545 + **Features:**
546 + - Non-blocking operation (immediate response)
547 + - Generates comprehensive CSV report with all vulnerability details
548 + - Applies same filtering as search endpoint
549 + - Stores report in MinIO for later retrieval
550 + - Tracks report metadata and status in database
551 + - Handles large datasets efficiently
552 +
553 + **Status Tracking:**
554 + - Reports are created with status "processing"
555 + - Check status via `/reports` endpoint
556 + - Status changes to "completed" when done
557 + - If errors occur, status becomes "failed" with error message
558 +
559 + Args:
560 + request: Report generation request with filters
561 + background_tasks: FastAPI background tasks
562 + current_user: Current authenticated user
563 + db: Database session
564 +
565 + Returns:
566 + dict: Confirmation with queued report details
567 + """
568 + logger.info(f"Queueing vulnerability report generation for customer: {request.customer_code}")
569 +
570 + try:
571 + # Create a "processing" report record immediately
572 + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
573 + report_name = request.report_name or f"vulnerability_report_{timestamp}"
574 +
575 + # Build filters JSON
576 + filters = {}
577 + if request.agent_name:
578 + filters["agent_name"] = request.agent_name
579 + if request.severity:
580 + filters["severity"] = request.severity
581 + if request.cve_id:
582 + filters["cve_id"] = request.cve_id
583 + if request.package_name:
584 + filters["package_name"] = request.package_name
585 + filters["include_epss"] = request.include_epss
586 +
587 + # Create placeholder report record
588 + report_record = VulnerabilityReport(
589 + report_name=report_name,
590 + customer_code=request.customer_code,
591 + bucket_name="vulnerability-reports",
592 + object_key=f"{request.customer_code}/{report_name}.csv",
593 + file_name=f"{report_name}.csv",
594 + file_size=0,
595 + file_hash="pending",
596 + generated_by=current_user.id,
597 + filters_json=json.dumps(filters),
598 + status="processing",
599 + )
600 +
601 + db.add(report_record)
602 + await db.commit()
603 + await db.refresh(report_record)
604 +
605 + report_id = report_record.id
606 +
607 + # Define the background task
608 + async def generate_report_task():
609 + try:
610 + # Create a new database session for the background task
611 + async with get_db_session() as bg_db:
612 + result = await generate_vulnerability_csv_report(bg_db, current_user, request, report_id=report_id)
613 +
614 + if result.success:
615 + logger.info(f"Successfully completed background report generation (ID: {report_id})")
616 + else:
617 + logger.error(f"Background report generation failed (ID: {report_id}): {result.error}")
618 +
619 + except Exception as e:
620 + logger.error(f"Background report generation failed (ID: {report_id}): {e}")
621 + # Update report status to failed
622 + try:
623 + async with get_db_session() as bg_db:
624 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
625 + result_db = await bg_db.execute(stmt)
626 + report = result_db.scalars().first()
627 + if report:
628 + report.status = "failed"
629 + report.error_message = str(e)
630 + await bg_db.commit()
631 + except Exception as update_error:
632 + logger.error(f"Failed to update report status: {update_error}")
633 +
634 + # Add the task to background tasks
635 + background_tasks.add_task(generate_report_task)
636 +
637 + return {
638 + "success": True,
639 + "message": "Report generation queued successfully",
640 + "report_id": report_id,
641 + "report_name": report_name,
642 + "customer_code": request.customer_code,
643 + "status": "processing",
644 + "check_status_url": "/api/v1/vulnerabilities/reports",
645 + "download_url": f"/api/v1/vulnerabilities/reports/{report_id}/download",
646 + }
647 +
648 + except Exception as e:
649 + logger.error(f"Error queueing report generation: {e}")
650 + raise HTTPException(status_code=500, detail=f"Failed to queue report generation: {e}")
651 +
652 +
653 +@vulnerabilities_router.get(
654 + "/reports",
655 + response_model=VulnerabilityReportListResponse,
656 + description="List available vulnerability reports",
657 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
658 +)
659 +async def list_reports(
660 + customer_code: Optional[str] = Query(None, description="Filter by customer code"),
661 + current_user: User = Depends(AuthHandler().get_current_user),
662 + db: AsyncSession = Depends(get_db),
663 +) -> VulnerabilityReportListResponse:
664 + """
665 + List all available vulnerability reports.
666 +
667 + Reports are filtered based on user access permissions.
668 +
669 + **Features:**
670 + - Lists all reports accessible to the user
671 + - Includes report metadata and statistics
672 + - Provides download URLs for each report
673 + - Filters by customer if specified
674 +
675 + Args:
676 + customer_code: Optional filter by customer code
677 + current_user: Current authenticated user
678 + db: Database session
679 +
680 + Returns:
681 + VulnerabilityReportListResponse with list of available reports
682 + """
683 + return await list_vulnerability_reports(db, current_user, customer_code)
684 +
685 +
686 +@vulnerabilities_router.get(
687 + "/reports/{report_id}/download",
688 + description="Download a vulnerability report CSV file",
689 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
690 +)
691 +async def download_report(
692 + report_id: int,
693 + current_user: User = Depends(AuthHandler().get_current_user),
694 + db: AsyncSession = Depends(get_db),
695 +):
696 + """
697 + Download a vulnerability report CSV file.
698 +
699 + **Features:**
700 + - Retrieves report from MinIO storage
701 + - Verifies user has access to the customer
702 + - Returns CSV file as downloadable attachment
703 +
704 + Args:
705 + report_id: ID of the report to download
706 + current_user: Current authenticated user
707 + db: Database session
708 +
709 + Returns:
710 + StreamingResponse with CSV file
711 + """
712 + report_data = await get_vulnerability_report_download(db, current_user, report_id)
713 +
714 + return StreamingResponse(
715 + io.BytesIO(report_data["file_content"]),
716 + media_type=report_data["content_type"],
717 + headers={"Content-Disposition": f'attachment; filename="{report_data["file_name"]}"'},
718 + )
719 +
720 +
721 +@vulnerabilities_router.delete(
722 + "/reports/{report_id}",
723 + response_model=dict,
724 + description="Delete a vulnerability report",
725 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
726 +)
727 +async def delete_report(
728 + report_id: int,
729 + current_user: User = Depends(AuthHandler().get_current_user),
730 + db: AsyncSession = Depends(get_db),
731 +) -> dict:
732 + """
733 + Delete a vulnerability report and its associated file from MinIO.
734 +
735 + This endpoint removes both the database record and the CSV file stored in MinIO.
736 + Users can only delete reports for customers they have access to.
737 +
738 + **Features:**
739 + - Deletes report metadata from database
740 + - Removes CSV file from MinIO storage
741 + - Verifies user has access to the customer
742 + - Provides detailed error messages
743 +
744 + **Access Control:**
745 + - Admin/analyst users: Can delete reports for customers they have access to
746 + - Customer users: Can only delete reports for their assigned customers
747 +
748 + Args:
749 + report_id: ID of the report to delete
750 + current_user: Current authenticated user
751 + db: Database session
752 +
753 + Returns:
754 + dict: Confirmation of successful deletion
755 + """
756 + from app.data_store.data_store_operations import delete_file_from_minio
757 + from app.middleware.customer_access import customer_access_handler
758 +
759 + try:
760 + # Get the report record
761 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
762 + result = await db.execute(stmt)
763 + report = result.scalars().first()
764 +
765 + if not report:
766 + raise HTTPException(status_code=404, detail=f"Report with ID {report_id} not found")
767 +
768 + # Verify customer access
769 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
770 +
771 + if "*" not in accessible_customers and report.customer_code not in accessible_customers:
772 + raise HTTPException(status_code=403, detail=f"Access denied to delete report for customer {report.customer_code}")
773 +
774 + logger.info(f"Deleting vulnerability report ID {report_id} for customer {report.customer_code}")
775 +
776 + # Delete file from MinIO
777 + minio_result = await delete_file_from_minio(
778 + bucket_name=report.bucket_name,
779 + object_key=report.object_key,
780 + )
781 +
782 + if not minio_result["success"]:
783 + logger.warning(
784 + f"Failed to delete file from MinIO for report {report_id}: {minio_result.get('error')}. "
785 + "Proceeding with database deletion.",
786 + )
787 +
788 + # Delete database record
789 + await db.delete(report)
790 + await db.commit()
791 +
792 + logger.info(f"Successfully deleted vulnerability report ID {report_id}")
793 +
794 + return {
795 + "success": True,
796 + "message": f"Report '{report.report_name}' deleted successfully",
797 + "report_id": report_id,
798 + "report_name": report.report_name,
799 + "customer_code": report.customer_code,
800 + }
801 +
802 + except HTTPException:
803 + raise
804 + except Exception as e:
805 + logger.error(f"Error deleting vulnerability report {report_id}: {e}")
806 + raise HTTPException(status_code=500, detail=f"Failed to delete report: {e}")
backend/app/agents/vulnerabilities/schema/vulnerabilities.py
+44
@@ -1,4 +1,6 @@
1 from datetime import datetime
2 +from typing import Any
3 +from typing import Dict
4 from typing import List
5 from typing import Optional
6
@@ -138,3 +140,45 @@ class VulnerabilitySearchResponse(BaseModel):
140 success: bool
141 message: str
142 filters_applied: dict = {}
143 +
144 +
145 +class VulnerabilityReportGenerateRequest(BaseModel):
146 + customer_code: str
147 + report_name: Optional[str] = None # Auto-generate if not provided
148 + agent_name: Optional[str] = None
149 + severity: Optional[str] = None
150 + cve_id: Optional[str] = None
151 + package_name: Optional[str] = None
152 + include_epss: bool = False
153 +
154 +
155 +class VulnerabilityReportResponse(BaseModel):
156 + id: int
157 + report_name: str
158 + customer_code: str
159 + file_name: str
160 + file_size: int
161 + generated_at: datetime
162 + generated_by: int
163 + total_vulnerabilities: int
164 + critical_count: int
165 + high_count: int
166 + medium_count: int
167 + low_count: int
168 + filters_applied: Dict[str, Any]
169 + status: str
170 + download_url: Optional[str] = None
171 +
172 +
173 +class VulnerabilityReportListResponse(BaseModel):
174 + reports: List[VulnerabilityReportResponse]
175 + total_count: int
176 + success: bool
177 + message: str
178 +
179 +
180 +class VulnerabilityReportGenerateResponse(BaseModel):
181 + success: bool
182 + message: str
183 + report: Optional[VulnerabilityReportResponse] = None
184 + error: Optional[str] = None
backend/app/agents/vulnerabilities/services/vulnerabilities.py
+545
@@ -1,3 +1,7 @@
1 +import csv
2 +import hashlib
3 +import io
4 +import json
5 from datetime import datetime
6 from typing import Any
7 from typing import Dict
@@ -6,6 +10,7 @@ from typing import Optional
10
11 from fastapi import HTTPException
12 from loguru import logger
13 +from sqlalchemy import desc
14 from sqlalchemy import select
15 from sqlalchemy.ext.asyncio import AsyncSession
16
@@ -13,6 +18,18 @@ from app.agents.vulnerabilities.schema.vulnerabilities import (
18 AgentVulnerabilitiesResponse,
19 )
20 from app.agents.vulnerabilities.schema.vulnerabilities import AgentVulnerabilityOut
21 +from app.agents.vulnerabilities.schema.vulnerabilities import (
22 + VulnerabilityReportGenerateRequest,
23 +)
24 +from app.agents.vulnerabilities.schema.vulnerabilities import (
25 + VulnerabilityReportGenerateResponse,
26 +)
27 +from app.agents.vulnerabilities.schema.vulnerabilities import (
28 + VulnerabilityReportListResponse,
29 +)
30 +from app.agents.vulnerabilities.schema.vulnerabilities import (
31 + VulnerabilityReportResponse,
32 +)
33 from app.agents.vulnerabilities.schema.vulnerabilities import VulnerabilitySearchItem
34 from app.agents.vulnerabilities.schema.vulnerabilities import (
35 VulnerabilitySearchResponse,
@@ -25,8 +42,11 @@ from app.connectors.wazuh_indexer.utils.universal import collect_indices
42 from app.connectors.wazuh_indexer.utils.universal import (
43 create_wazuh_indexer_client_async,
44 )
45 +from app.data_store.data_store_operations import store_file_in_minio
46 from app.db.universal_models import Agents
47 from app.db.universal_models import AgentVulnerabilities
48 +from app.db.universal_models import Customers
49 +from app.db.universal_models import VulnerabilityReport
50 from app.middleware.customer_access import customer_access_handler
51 from app.threat_intel.schema.epss import EpssThreatIntelRequest
52 from app.threat_intel.services.epss import collect_epss_score
@@ -1195,3 +1215,528 @@ async def search_vulnerabilities_from_indexer(
1215 message=f"Unexpected error occurred: {e}",
1216 filters_applied=filters_applied if "filters_applied" in locals() else {},
1217 )
1218 +
1219 +
1220 +async def generate_vulnerability_csv_report(
1221 + db_session: AsyncSession,
1222 + current_user: User,
1223 + request: VulnerabilityReportGenerateRequest,
1224 + report_id: Optional[int] = None,
1225 +) -> VulnerabilityReportGenerateResponse:
1226 + """
1227 + Generate a CSV vulnerability report for a specific customer and store it in MinIO
1228 +
1229 + Args:
1230 + db_session: Database session
1231 + current_user: Current authenticated user
1232 + request: Report generation request with filters
1233 + report_id: Optional existing report ID (for background task updates)
1234 +
1235 + Returns:
1236 + VulnerabilityReportGenerateResponse with report details
1237 + """
1238 + try:
1239 + # Verify customer access
1240 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
1241 +
1242 + if "*" not in accessible_customers and request.customer_code not in accessible_customers:
1243 + # If we have a report_id, update it to failed status
1244 + if report_id:
1245 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1246 + result = await db_session.execute(stmt)
1247 + report = result.scalars().first()
1248 + if report:
1249 + report.status = "failed"
1250 + report.error_message = "Insufficient permissions"
1251 + await db_session.commit()
1252 +
1253 + return VulnerabilityReportGenerateResponse(
1254 + success=False,
1255 + message=f"Access denied to customer {request.customer_code}",
1256 + error="Insufficient permissions",
1257 + )
1258 +
1259 + # Verify customer exists
1260 + customer_result = await db_session.execute(select(Customers).filter(Customers.customer_code == request.customer_code))
1261 + customer = customer_result.scalars().first()
1262 +
1263 + if not customer:
1264 + # If we have a report_id, update it to failed status
1265 + if report_id:
1266 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1267 + result = await db_session.execute(stmt)
1268 + report = result.scalars().first()
1269 + if report:
1270 + report.status = "failed"
1271 + report.error_message = "Customer not found"
1272 + await db_session.commit()
1273 +
1274 + return VulnerabilityReportGenerateResponse(
1275 + success=False,
1276 + message=f"Customer {request.customer_code} not found",
1277 + error="Customer not found",
1278 + )
1279 +
1280 + logger.info(f"Generating vulnerability report for customer: {request.customer_code}")
1281 +
1282 + # Fetch ALL vulnerabilities (no pagination)
1283 + all_vulnerabilities = []
1284 + page = 1
1285 + page_size = 1000 # Large page size for efficiency
1286 +
1287 + while True:
1288 + search_result = await search_vulnerabilities_from_indexer(
1289 + db_session=db_session,
1290 + current_user=current_user,
1291 + customer_code=request.customer_code,
1292 + agent_name=request.agent_name,
1293 + severity=request.severity,
1294 + cve_id=request.cve_id,
1295 + package_name=request.package_name,
1296 + page=page,
1297 + page_size=page_size,
1298 + include_epss=request.include_epss,
1299 + )
1300 +
1301 + if not search_result.success:
1302 + # If we have a report_id, update it to failed status
1303 + if report_id:
1304 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1305 + result = await db_session.execute(stmt)
1306 + report = result.scalars().first()
1307 + if report:
1308 + report.status = "failed"
1309 + report.error_message = search_result.message
1310 + await db_session.commit()
1311 +
1312 + return VulnerabilityReportGenerateResponse(
1313 + success=False,
1314 + message="Failed to fetch vulnerability data",
1315 + error=search_result.message,
1316 + )
1317 +
1318 + all_vulnerabilities.extend(search_result.vulnerabilities)
1319 +
1320 + if not search_result.has_next:
1321 + break
1322 +
1323 + page += 1
1324 +
1325 + logger.info(f"Fetched {len(all_vulnerabilities)} vulnerabilities for report")
1326 +
1327 + # Generate CSV content
1328 + csv_buffer = io.StringIO()
1329 + csv_writer = csv.writer(csv_buffer)
1330 +
1331 + # Write headers
1332 + headers = [
1333 + "CVE ID",
1334 + "Severity",
1335 + "Title",
1336 + "Agent Name",
1337 + "Customer Code",
1338 + "Package Name",
1339 + "Package Version",
1340 + "Package Architecture",
1341 + "Detected At",
1342 + "Published At",
1343 + "Base Score",
1344 + ]
1345 +
1346 + if request.include_epss:
1347 + headers.extend(["EPSS Score", "EPSS Percentile"])
1348 +
1349 + headers.append("References")
1350 + csv_writer.writerow(headers)
1351 +
1352 + # Write data rows
1353 + for vuln in all_vulnerabilities:
1354 + row = [
1355 + vuln.cve_id,
1356 + vuln.severity,
1357 + vuln.title,
1358 + vuln.agent_name,
1359 + vuln.customer_code or "",
1360 + vuln.package_name or "",
1361 + vuln.package_version or "",
1362 + vuln.package_architecture or "",
1363 + vuln.detected_at.isoformat() if vuln.detected_at else "",
1364 + vuln.published_at.isoformat() if vuln.published_at else "",
1365 + vuln.base_score or "",
1366 + ]
1367 +
1368 + if request.include_epss:
1369 + row.extend(
1370 + [
1371 + vuln.epss_score or "",
1372 + vuln.epss_percentile or "",
1373 + ],
1374 + )
1375 +
1376 + row.append(vuln.references or "")
1377 + csv_writer.writerow(row)
1378 +
1379 + # Get CSV content as bytes
1380 + csv_content = csv_buffer.getvalue().encode("utf-8")
1381 + csv_buffer.close()
1382 +
1383 + # Generate file name
1384 + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1385 + report_name = request.report_name or f"vulnerability_report_{timestamp}"
1386 + file_name = f"{report_name}.csv"
1387 +
1388 + # Calculate file hash
1389 + file_hash = hashlib.sha256(csv_content).hexdigest()
1390 +
1391 + # Store in MinIO
1392 + object_key = f"{request.customer_code}/{file_name}"
1393 + bucket_name = "vulnerability-reports"
1394 +
1395 + minio_result = await store_file_in_minio(
1396 + file_content=csv_content,
1397 + bucket_name=bucket_name,
1398 + object_key=object_key,
1399 + content_type="text/csv",
1400 + )
1401 +
1402 + if not minio_result["success"]:
1403 + # If we have a report_id, update it to failed status
1404 + if report_id:
1405 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1406 + result = await db_session.execute(stmt)
1407 + report = result.scalars().first()
1408 + if report:
1409 + report.status = "failed"
1410 + report.error_message = minio_result.get("error", "Unknown error")
1411 + await db_session.commit()
1412 +
1413 + return VulnerabilityReportGenerateResponse(
1414 + success=False,
1415 + message="Failed to store report in MinIO",
1416 + error=minio_result.get("error", "Unknown error"),
1417 + )
1418 +
1419 + # Build filters JSON
1420 + filters = {}
1421 + if request.agent_name:
1422 + filters["agent_name"] = request.agent_name
1423 + if request.severity:
1424 + filters["severity"] = request.severity
1425 + if request.cve_id:
1426 + filters["cve_id"] = request.cve_id
1427 + if request.package_name:
1428 + filters["package_name"] = request.package_name
1429 + filters["include_epss"] = request.include_epss
1430 +
1431 + # Check if we're updating an existing report or creating a new one
1432 + if report_id:
1433 + # Update existing report (background task scenario)
1434 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1435 + result = await db_session.execute(stmt)
1436 + report_record = result.scalars().first()
1437 +
1438 + if report_record:
1439 + report_record.file_size = len(csv_content)
1440 + report_record.file_hash = file_hash
1441 + report_record.total_vulnerabilities = len(all_vulnerabilities)
1442 + report_record.critical_count = sum(1 for v in all_vulnerabilities if v.severity == "Critical")
1443 + report_record.high_count = sum(1 for v in all_vulnerabilities if v.severity == "High")
1444 + report_record.medium_count = sum(1 for v in all_vulnerabilities if v.severity == "Medium")
1445 + report_record.low_count = sum(1 for v in all_vulnerabilities if v.severity == "Low")
1446 + report_record.status = "completed"
1447 + report_record.error_message = None
1448 +
1449 + await db_session.commit()
1450 + await db_session.refresh(report_record)
1451 +
1452 + logger.info(f"Successfully updated vulnerability report: {report_name} (ID: {report_id})")
1453 + else:
1454 + logger.error(f"Report ID {report_id} not found for update")
1455 + return VulnerabilityReportGenerateResponse(
1456 + success=False,
1457 + message=f"Report ID {report_id} not found",
1458 + error="Report not found",
1459 + )
1460 + else:
1461 + # Create new database record (synchronous scenario)
1462 + report_record = VulnerabilityReport(
1463 + report_name=report_name,
1464 + customer_code=request.customer_code,
1465 + bucket_name=bucket_name,
1466 + object_key=object_key,
1467 + file_name=file_name,
1468 + file_size=len(csv_content),
1469 + file_hash=file_hash,
1470 + generated_by=current_user.id,
1471 + filters_json=json.dumps(filters),
1472 + total_vulnerabilities=len(all_vulnerabilities),
1473 + critical_count=sum(1 for v in all_vulnerabilities if v.severity == "Critical"),
1474 + high_count=sum(1 for v in all_vulnerabilities if v.severity == "High"),
1475 + medium_count=sum(1 for v in all_vulnerabilities if v.severity == "Medium"),
1476 + low_count=sum(1 for v in all_vulnerabilities if v.severity == "Low"),
1477 + status="completed",
1478 + )
1479 +
1480 + db_session.add(report_record)
1481 + await db_session.commit()
1482 + await db_session.refresh(report_record)
1483 +
1484 + logger.info(f"Successfully generated vulnerability report: {report_name}")
1485 +
1486 + # Build response
1487 + report_response = VulnerabilityReportResponse(
1488 + id=report_record.id,
1489 + report_name=report_record.report_name,
1490 + customer_code=report_record.customer_code,
1491 + file_name=report_record.file_name,
1492 + file_size=report_record.file_size,
1493 + generated_at=report_record.generated_at,
1494 + generated_by=report_record.generated_by,
1495 + total_vulnerabilities=report_record.total_vulnerabilities,
1496 + critical_count=report_record.critical_count,
1497 + high_count=report_record.high_count,
1498 + medium_count=report_record.medium_count,
1499 + low_count=report_record.low_count,
1500 + filters_applied=json.loads(report_record.filters_json or "{}"),
1501 + status=report_record.status,
1502 + download_url=f"/api/v1/vulnerabilities/reports/{report_record.id}/download",
1503 + )
1504 +
1505 + return VulnerabilityReportGenerateResponse(
1506 + success=True,
1507 + message=f"Successfully generated report with {len(all_vulnerabilities)} vulnerabilities",
1508 + report=report_response,
1509 + )
1510 +
1511 + except Exception as e:
1512 + logger.error(f"Error generating vulnerability report: {e}")
1513 +
1514 + # If we have a report_id, update it to failed status
1515 + if report_id:
1516 + try:
1517 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1518 + result = await db_session.execute(stmt)
1519 + report = result.scalars().first()
1520 + if report:
1521 + report.status = "failed"
1522 + report.error_message = str(e)
1523 + await db_session.commit()
1524 + except Exception as update_error:
1525 + logger.error(f"Failed to update report status: {update_error}")
1526 +
1527 + return VulnerabilityReportGenerateResponse(
1528 + success=False,
1529 + message="Failed to generate vulnerability report",
1530 + error=str(e),
1531 + )
1532 +
1533 +
1534 +async def list_vulnerability_reports(
1535 + db_session: AsyncSession,
1536 + current_user: User,
1537 + customer_code: Optional[str] = None,
1538 +) -> VulnerabilityReportListResponse:
1539 + """
1540 + List available vulnerability reports
1541 +
1542 + Args:
1543 + db_session: Database session
1544 + current_user: Current authenticated user
1545 + customer_code: Optional filter by customer code
1546 +
1547 + Returns:
1548 + VulnerabilityReportListResponse with list of reports
1549 + """
1550 + try:
1551 + # Get accessible customers
1552 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
1553 +
1554 + # Build query
1555 + query = select(VulnerabilityReport).order_by(desc(VulnerabilityReport.generated_at))
1556 +
1557 + # Apply customer filtering
1558 + if "*" not in accessible_customers:
1559 + query = query.filter(VulnerabilityReport.customer_code.in_(accessible_customers))
1560 +
1561 + if customer_code:
1562 + if "*" not in accessible_customers and customer_code not in accessible_customers:
1563 + return VulnerabilityReportListResponse(
1564 + reports=[],
1565 + total_count=0,
1566 + success=True,
1567 + message=f"Access denied to customer {customer_code}",
1568 + )
1569 + query = query.filter(VulnerabilityReport.customer_code == customer_code)
1570 +
1571 + result = await db_session.execute(query)
1572 + reports = result.scalars().all()
1573 +
1574 + report_list = []
1575 + for report in reports:
1576 + report_response = VulnerabilityReportResponse(
1577 + id=report.id,
1578 + report_name=report.report_name,
1579 + customer_code=report.customer_code,
1580 + file_name=report.file_name,
1581 + file_size=report.file_size,
1582 + generated_at=report.generated_at,
1583 + generated_by=report.generated_by,
1584 + total_vulnerabilities=report.total_vulnerabilities,
1585 + critical_count=report.critical_count,
1586 + high_count=report.high_count,
1587 + medium_count=report.medium_count,
1588 + low_count=report.low_count,
1589 + filters_applied=json.loads(report.filters_json or "{}"),
1590 + status=report.status,
1591 + download_url=f"/api/v1/vulnerabilities/reports/{report.id}/download",
1592 + )
1593 + report_list.append(report_response)
1594 +
1595 + return VulnerabilityReportListResponse(
1596 + reports=report_list,
1597 + total_count=len(report_list),
1598 + success=True,
1599 + message=f"Found {len(report_list)} vulnerability reports",
1600 + )
1601 +
1602 + except Exception as e:
1603 + logger.error(f"Error listing vulnerability reports: {e}")
1604 + return VulnerabilityReportListResponse(
1605 + reports=[],
1606 + total_count=0,
1607 + success=False,
1608 + message=f"Failed to list reports: {e}",
1609 + )
1610 +
1611 +
1612 +async def get_vulnerability_report_download(
1613 + db_session: AsyncSession,
1614 + current_user: User,
1615 + report_id: int,
1616 +) -> Dict[str, Any]:
1617 + """
1618 + Get vulnerability report for download
1619 +
1620 + Args:
1621 + db_session: Database session
1622 + current_user: Current authenticated user
1623 + report_id: Report ID to download
1624 +
1625 + Returns:
1626 + Dict with file_content, file_name, and content_type
1627 + """
1628 + try:
1629 + # Get report record
1630 + result = await db_session.execute(select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id))
1631 + report = result.scalars().first()
1632 +
1633 + if not report:
1634 + raise HTTPException(status_code=404, detail="Report not found")
1635 +
1636 + # Verify customer access
1637 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
1638 +
1639 + if "*" not in accessible_customers and report.customer_code not in accessible_customers:
1640 + raise HTTPException(status_code=403, detail="Access denied to this report")
1641 +
1642 + # Retrieve file from MinIO
1643 + from app.data_store.data_store_operations import retrieve_file_from_minio
1644 +
1645 + file_data = await retrieve_file_from_minio(
1646 + bucket_name=report.bucket_name,
1647 + object_key=report.object_key,
1648 + )
1649 +
1650 + if not file_data["success"]:
1651 + raise HTTPException(status_code=500, detail="Failed to retrieve report file")
1652 +
1653 + return {
1654 + "file_content": file_data["file_content"],
1655 + "file_name": report.file_name,
1656 + "content_type": "text/csv",
1657 + }
1658 +
1659 + except HTTPException:
1660 + raise
1661 + except Exception as e:
1662 + logger.error(f"Error retrieving vulnerability report: {e}")
1663 + raise HTTPException(status_code=500, detail=f"Failed to retrieve report: {e}")
1664 +
1665 +
1666 +async def delete_vulnerability_report(
1667 + db_session: AsyncSession,
1668 + current_user: User,
1669 + report_id: int,
1670 +) -> Dict[str, Any]:
1671 + """
1672 + Delete a vulnerability report and its associated file from MinIO.
1673 +
1674 + Args:
1675 + db_session: Database session
1676 + current_user: Current authenticated user
1677 + report_id: ID of the report to delete
1678 +
1679 + Returns:
1680 + Dict with success status and details
1681 + """
1682 + from app.data_store.data_store_operations import delete_file_from_minio
1683 +
1684 + try:
1685 + # Get the report record
1686 + stmt = select(VulnerabilityReport).filter(VulnerabilityReport.id == report_id)
1687 + result = await db_session.execute(stmt)
1688 + report = result.scalars().first()
1689 +
1690 + if not report:
1691 + return {
1692 + "success": False,
1693 + "error": f"Report with ID {report_id} not found",
1694 + }
1695 +
1696 + # Verify customer access
1697 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
1698 +
1699 + if "*" not in accessible_customers and report.customer_code not in accessible_customers:
1700 + return {
1701 + "success": False,
1702 + "error": f"Access denied to delete report for customer {report.customer_code}",
1703 + }
1704 +
1705 + logger.info(f"Deleting vulnerability report ID {report_id} for customer {report.customer_code}")
1706 +
1707 + # Delete file from MinIO
1708 + minio_result = await delete_file_from_minio(
1709 + bucket_name=report.bucket_name,
1710 + object_key=report.object_key,
1711 + )
1712 +
1713 + if not minio_result["success"]:
1714 + logger.warning(
1715 + f"Failed to delete file from MinIO for report {report_id}: {minio_result.get('error')}. "
1716 + "Proceeding with database deletion.",
1717 + )
1718 +
1719 + # Store report details before deletion
1720 + report_name = report.report_name
1721 + customer_code = report.customer_code
1722 +
1723 + # Delete database record
1724 + await db_session.delete(report)
1725 + await db_session.commit()
1726 +
1727 + logger.info(f"Successfully deleted vulnerability report ID {report_id}")
1728 +
1729 + return {
1730 + "success": True,
1731 + "message": f"Report '{report_name}' deleted successfully",
1732 + "report_id": report_id,
1733 + "report_name": report_name,
1734 + "customer_code": customer_code,
1735 + }
1736 +
1737 + except Exception as e:
1738 + logger.error(f"Error deleting vulnerability report {report_id}: {e}")
1739 + return {
1740 + "success": False,
1741 + "error": str(e),
1742 + }
backend/app/data_store/data_store_operations.py
+215
@@ -394,3 +394,218 @@ async def delete_agent_artifact_file(agent_id: str, flow_id: str, file_name: str
394 object_key = f"{agent_id}/{flow_id}/{file_name}"
395
396 await delete_file(bucket_name, object_key)
397 +
398 +
399 +async def store_file_in_minio(
400 + file_content: bytes,
401 + bucket_name: str,
402 + object_key: str,
403 + content_type: str = "application/octet-stream",
404 +) -> dict:
405 + """
406 + Store file content directly in MinIO without requiring an UploadFile object.
407 + Useful for programmatically generated files like CSV reports.
408 +
409 + Args:
410 + file_content: The file content as bytes
411 + bucket_name: The name of the bucket
412 + object_key: The object path/name within the bucket
413 + content_type: MIME type of the file
414 +
415 + Returns:
416 + dict: Upload details including success status, object_key, file_size, and file_hash
417 + """
418 + import tempfile
419 +
420 + client = await create_session()
421 +
422 + # Create bucket if it doesn't exist
423 + await create_bucket_if_not_exists(bucket_name)
424 +
425 + logger.info(f"Storing file to bucket {bucket_name} as {object_key}")
426 +
427 + try:
428 + # Calculate file hash
429 + sha256_hash = hashlib.sha256()
430 + sha256_hash.update(file_content)
431 + file_hash = sha256_hash.hexdigest()
432 + file_size = len(file_content)
433 +
434 + # Create a temporary file to upload
435 + with tempfile.NamedTemporaryFile(delete=False) as temp_file:
436 + temp_file.write(file_content)
437 + temp_file_path = temp_file.name
438 +
439 + # Upload the file to MinIO
440 + await client.fput_object(
441 + bucket_name=bucket_name,
442 + object_name=object_key,
443 + file_path=temp_file_path,
444 + content_type=content_type,
445 + )
446 +
447 + # Remove the temporary file after upload
448 + os.remove(temp_file_path)
449 +
450 + logger.info(f"Successfully stored file ({file_size} bytes) to {bucket_name}/{object_key}")
451 +
452 + return {
453 + "success": True,
454 + "bucket_name": bucket_name,
455 + "object_key": object_key,
456 + "file_size": file_size,
457 + "file_hash": file_hash,
458 + "content_type": content_type,
459 + }
460 +
461 + except Exception as e:
462 + logger.error(f"Error storing file in MinIO: {e}")
463 + return {
464 + "success": False,
465 + "error": str(e),
466 + }
467 +
468 +
469 +async def retrieve_file_from_minio(
470 + bucket_name: str,
471 + object_key: str,
472 +) -> dict:
473 + """
474 + Retrieve file content from MinIO.
475 +
476 + Args:
477 + bucket_name: The name of the bucket
478 + object_key: The object path/name within the bucket
479 +
480 + Returns:
481 + dict: Contains success status and file_content (bytes) if successful, or error message
482 + """
483 + client = await create_session()
484 +
485 + logger.info(f"Retrieving file {object_key} from bucket {bucket_name}")
486 +
487 + try:
488 + # Check if the file exists
489 + await client.stat_object(bucket_name, object_key)
490 +
491 + # Download the file
492 + async with aiohttp.ClientSession() as session:
493 + response = await client.get_object(bucket_name, object_key, session)
494 + if response is None:
495 + raise Exception("Received None response from get_object")
496 + if not isinstance(response, aiohttp.ClientResponse):
497 + raise Exception("Response is not an instance of aiohttp.ClientResponse")
498 +
499 + file_content = await response.read()
500 + response.close()
501 +
502 + logger.info(f"Successfully retrieved file {object_key} from bucket {bucket_name}")
503 +
504 + return {
505 + "success": True,
506 + "file_content": file_content,
507 + }
508 +
509 + except Exception as e:
510 + logger.error(f"Error retrieving file from MinIO: {e}")
511 + return {
512 + "success": False,
513 + "error": str(e),
514 + }
515 +
516 +
517 +async def delete_file_from_minio(
518 + bucket_name: str,
519 + object_key: str,
520 +) -> dict:
521 + """
522 + Delete a file from MinIO.
523 +
524 + Args:
525 + bucket_name: The name of the bucket
526 + object_key: The object path/name within the bucket
527 +
528 + Returns:
529 + dict: Contains success status and message
530 + """
531 + client = await create_session()
532 +
533 + logger.info(f"Deleting file {object_key} from bucket {bucket_name}")
534 +
535 + try:
536 + # Check if the file exists
537 + await client.stat_object(bucket_name, object_key)
538 +
539 + # Delete the file
540 + await client.remove_object(bucket_name, object_key)
541 +
542 + logger.info(f"Successfully deleted file {object_key} from bucket {bucket_name}")
543 +
544 + return {
545 + "success": True,
546 + "message": f"File {object_key} deleted successfully",
547 + }
548 +
549 + except Exception as e:
550 + logger.error(f"Error deleting file from MinIO: {e}")
551 + return {
552 + "success": False,
553 + "error": str(e),
554 + }
555 +
556 +
557 +async def list_files_in_bucket(
558 + bucket_name: str,
559 + prefix: Optional[str] = None,
560 +) -> dict:
561 + """
562 + List all files in a bucket with optional prefix filtering.
563 +
564 + Args:
565 + bucket_name: The name of the bucket
566 + prefix: Optional prefix to filter objects
567 +
568 + Returns:
569 + dict: Contains success status and list of object names
570 + """
571 + client = await create_session()
572 +
573 + logger.info(f"Listing files in bucket {bucket_name}" + (f" with prefix {prefix}" if prefix else ""))
574 +
575 + try:
576 + # Check if bucket exists
577 + if not await client.bucket_exists(bucket_name):
578 + return {
579 + "success": False,
580 + "error": f"Bucket {bucket_name} does not exist",
581 + "objects": [],
582 + }
583 +
584 + # List objects
585 + objects = client.list_objects(bucket_name, prefix=prefix or "", recursive=True)
586 + object_list = []
587 +
588 + async for obj in objects:
589 + object_list.append(
590 + {
591 + "object_name": obj.object_name,
592 + "size": obj.size,
593 + "last_modified": obj.last_modified,
594 + },
595 + )
596 +
597 + logger.info(f"Found {len(object_list)} files in bucket {bucket_name}")
598 +
599 + return {
600 + "success": True,
601 + "objects": object_list,
602 + "count": len(object_list),
603 + }
604 +
605 + except Exception as e:
606 + logger.error(f"Error listing files in bucket: {e}")
607 + return {
608 + "success": False,
609 + "error": str(e),
610 + "objects": [],
611 + }
backend/app/db/universal_models.py
+38
@@ -424,3 +424,41 @@ class CustomerPortalSettings(SQLModel, table=True):
424 logo_base64=defaults["logo_base64"],
425 logo_mime_type=defaults["logo_mime_type"],
426 )
427 +
428 +
429 +class VulnerabilityReport(SQLModel, table=True):
430 + __tablename__ = "vulnerability_reports"
431 +
432 + id: Optional[int] = Field(primary_key=True)
433 +
434 + # Report metadata
435 + report_name: str = Field(max_length=255, nullable=False)
436 + customer_code: str = Field(foreign_key="customers.customer_code", max_length=50, index=True, nullable=False)
437 +
438 + # MinIO storage details
439 + bucket_name: str = Field(max_length=255, nullable=False, default="vulnerability-reports")
440 + object_key: str = Field(max_length=1024, nullable=False) # Path: customer_code/report_name_timestamp.csv
441 + file_name: str = Field(max_length=255, nullable=False) # CSV filename
442 + file_size: int = Field(nullable=False) # File size in bytes
443 + file_hash: str = Field(max_length=128, nullable=False) # SHA-256 hash
444 +
445 + # Report generation details
446 + generated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
447 + generated_by: int = Field(nullable=False) # User ID who generated the report
448 +
449 + # Report filters applied
450 + filters_json: Optional[str] = Field(sa_column=Column(Text), nullable=True) # JSON string of filters used
451 +
452 + # Statistics
453 + total_vulnerabilities: int = Field(default=0)
454 + critical_count: int = Field(default=0)
455 + high_count: int = Field(default=0)
456 + medium_count: int = Field(default=0)
457 + low_count: int = Field(default=0)
458 +
459 + # Status
460 + status: str = Field(max_length=50, default="completed", index=True) # completed, failed, processing
461 + error_message: Optional[str] = Field(sa_column=Column(Text), nullable=True)
462 +
463 + # Relationship to Customers table
464 + customer: Optional["Customers"] = Relationship()
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.16"
10 +CURRENT_VERSION = "0.1.17"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/vulnerabilities.ts
+74 -19
@@ -1,23 +1,78 @@
1 -import type { VulnerabilitySearchQuery, VulnerabilitySearchResponse } from "@/types/vulnerabilities.d"
1 +import type {
2 + VulnerabilityReportDeleteResponse,
3 + VulnerabilityReportGenerateRequest,
4 + VulnerabilityReportGenerateResponse,
5 + VulnerabilityReportListResponse,
6 + VulnerabilitySearchQuery,
7 + VulnerabilitySearchResponse
8 +} from "@/types/vulnerabilities.d"
9 import { HttpClient } from "../httpClient"
10
11 export default {
5 - /**
6 - * Search vulnerabilities directly from Wazuh indexer with filtering and pagination
7 - */
8 - searchVulnerabilities(query?: VulnerabilitySearchQuery, signal?: AbortSignal) {
9 - return HttpClient.get<VulnerabilitySearchResponse>(`/vulnerabilities/search`, {
10 - params: {
11 - customer_code: query?.customer_code,
12 - agent_name: query?.agent_name,
13 - severity: query?.severity,
14 - cve_id: query?.cve_id,
15 - package_name: query?.package_name,
16 - page: query?.page || 1,
17 - page_size: query?.page_size || 50,
18 - include_epss: query?.include_epss !== false
19 - },
20 - signal
21 - })
22 - }
12 + /**
13 + * Search vulnerabilities directly from Wazuh indexer with filtering and pagination
14 + */
15 + searchVulnerabilities(query?: VulnerabilitySearchQuery, signal?: AbortSignal) {
16 + return HttpClient.get<VulnerabilitySearchResponse>(`/vulnerabilities/search`, {
17 + params: {
18 + customer_code: query?.customer_code,
19 + agent_name: query?.agent_name,
20 + severity: query?.severity,
21 + cve_id: query?.cve_id,
22 + package_name: query?.package_name,
23 + page: query?.page || 1,
24 + page_size: query?.page_size || 50,
25 + include_epss: query?.include_epss !== false
26 + },
27 + signal
28 + })
29 + },
30 +
31 + /**
32 + * Generate a vulnerability report (synchronous)
33 + */
34 + generateReport(request: VulnerabilityReportGenerateRequest) {
35 + return HttpClient.post<VulnerabilityReportGenerateResponse>(`/vulnerabilities/reports/generate`, request)
36 + },
37 +
38 + /**
39 + * Generate a vulnerability report (background task)
40 + */
41 + generateReportBackground(request: VulnerabilityReportGenerateRequest) {
42 + return HttpClient.post<{
43 + success: boolean
44 + message: string
45 + report_id: number
46 + report_name: string
47 + customer_code: string
48 + status: string
49 + check_status_url: string
50 + download_url: string
51 + }>(`/vulnerabilities/reports/generate/background`, request)
52 + },
53 +
54 + /**
55 + * List all vulnerability reports
56 + */
57 + listReports(customer_code?: string) {
58 + return HttpClient.get<VulnerabilityReportListResponse>(`/vulnerabilities/reports`, {
59 + params: customer_code ? { customer_code } : undefined
60 + })
61 + },
62 +
63 + /**
64 + * Download a vulnerability report
65 + */
66 + downloadReport(reportId: number) {
67 + return HttpClient.get<Blob>(`/vulnerabilities/reports/${reportId}/download`, {
68 + responseType: "blob"
69 + })
70 + },
71 +
72 + /**
73 + * Delete a vulnerability report
74 + */
75 + deleteReport(reportId: number) {
76 + return HttpClient.delete<VulnerabilityReportDeleteResponse>(`/vulnerabilities/reports/${reportId}`)
77 + }
78 }
frontend/src/app-layouts/common/Navbar/items.tsx
+505 -486
@@ -21,490 +21,509 @@ const SchedulerIcon = "material-symbols:autoplay"
21 const CustomerPortalIcon = "streamline-ultimate:coding-apps-website-apps-browser"
22
23 export default function getItems(): MenuMixedOption[] {
24 - return [
25 - {
26 - label: () =>
27 - h(
28 - RouterLink,
29 - {
30 - to: {
31 - name: "Overview"
32 - }
33 - },
34 - { default: () => "Overview" }
35 - ),
36 - key: "Overview",
37 - icon: renderIcon(OverviewIcon)
38 - },
39 - {
40 - label: () =>
41 - h(
42 - RouterLink,
43 - {
44 - to: {
45 - name: "Indices"
46 - }
47 - },
48 - { default: () => "Indices" }
49 - ),
50 - key: "Indices",
51 - icon: renderIcon(IndiciesIcon)
52 - },
53 - {
54 - label: "Agents",
55 - key: "Agents",
56 - icon: renderIcon(AgentsIcon),
57 - children: [
58 - {
59 - label: () =>
60 - h(
61 - RouterLink,
62 - {
63 - to: {
64 - name: "Agents"
65 - }
66 - },
67 - { default: () => "Agents list" }
68 - ),
69 - key: "Agents"
70 - },
71 - {
72 - label: () =>
73 - h(
74 - RouterLink,
75 - {
76 - to: {
77 - name: "Groups"
78 - }
79 - },
80 - { default: () => "Groups" }
81 - ),
82 - key: "Groups"
83 - },
84 - {
85 - label: () =>
86 - h(
87 - RouterLink,
88 - {
89 - to: {
90 - name: "SysmonConfig"
91 - }
92 - },
93 - { default: () => "Sysmon Config" }
94 - ),
95 - key: "SysmonConfig"
96 - },
97 - {
98 - label: () =>
99 - h(
100 - RouterLink,
101 - {
102 - to: {
103 - name: "DetectionRules"
104 - }
105 - },
106 - { default: () => "Detection Rules" }
107 - ),
108 - key: "DetectionRules"
109 - },
110 - {
111 - label: () =>
112 - h(
113 - RouterLink,
114 - {
115 - to: {
116 - name: "CopilotActions"
117 - }
118 - },
119 - { default: () => "CoPilot Actions" }
120 - ),
121 - key: "CopilotActions"
122 - },
123 - {
124 - label: () =>
125 - h(
126 - RouterLink,
127 - {
128 - to: {
129 - name: "VulnerabilityOverview"
130 - }
131 - },
132 - { default: () => "Vulnerability Overview" }
133 - ),
134 - key: "VulnerabilityOverview"
135 - },
136 - {
137 - label: () =>
138 - h(
139 - RouterLink,
140 - {
141 - to: {
142 - name: "ScaOverview"
143 - }
144 - },
145 - { default: () => "SCA Overview" }
146 - ),
147 - key: "ScaOverview"
148 - }
149 - ]
150 - },
151 - {
152 - label: () =>
153 - h(
154 - RouterLink,
155 - {
156 - to: {
157 - name: "Connectors"
158 - }
159 - },
160 - { default: () => "Connectors" }
161 - ),
162 - key: "Connectors",
163 - icon: renderIcon(ConnectorsIcon)
164 - },
165 - {
166 - label: "Graylog",
167 - key: "Graylog",
168 - icon: renderIcon(GraylogIcon),
169 - children: [
170 - {
171 - label: () =>
172 - h(
173 - RouterLink,
174 - {
175 - to: {
176 - name: "Graylog-Management"
177 - }
178 - },
179 - { default: () => "Management" }
180 - ),
181 - key: "Graylog-Management"
182 - },
183 - {
184 - label: () =>
185 - h(
186 - RouterLink,
187 - {
188 - to: {
189 - name: "Graylog-Metrics"
190 - }
191 - },
192 - { default: () => "Metrics" }
193 - ),
194 - key: "Graylog-Metrics"
195 - },
196 - {
197 - label: () =>
198 - h(
199 - RouterLink,
200 - {
201 - to: {
202 - name: "Graylog-Pipelines"
203 - }
204 - },
205 - { default: () => "Pipelines" }
206 - ),
207 - key: "Graylog-Pipelines"
208 - }
209 - ]
210 - },
211 - {
212 - label: "Alerts",
213 - key: "Alerts",
214 - icon: renderIcon(AlertsIcon),
215 - children: [
216 - {
217 - label: () =>
218 - h(
219 - RouterLink,
220 - {
221 - to: {
222 - name: "Alerts-SIEM"
223 - }
224 - },
225 - { default: () => "SIEM" }
226 - ),
227 - key: "Alerts-SIEM"
228 - },
229 - {
230 - label: () =>
231 - h(
232 - RouterLink,
233 - {
234 - to: {
235 - name: "Alerts-Mitre"
236 - }
237 - },
238 - { default: () => "MITRE ATT&CK" }
239 - ),
240 - key: "Alerts-Mitre"
241 - },
242 - {
243 - label: () =>
244 - h(
245 - RouterLink,
246 - {
247 - to: {
248 - name: "Alerts-AtomicRedTeam"
249 - }
250 - },
251 - { default: () => "Atomic Red Team" }
252 - ),
253 - key: "Alerts-AtomicRedTeam"
254 - }
255 - ]
256 - },
257 - {
258 - label: () =>
259 - h(
260 - RouterLink,
261 - {
262 - to: {
263 - name: "Artifacts"
264 - }
265 - },
266 - { default: () => "Artifacts" }
267 - ),
268 - key: "Artifacts",
269 - icon: renderIcon(ArtifactsIcon)
270 - },
271 - /*
272 - {
273 - label: "SOC",
274 - key: "Soc",
275 - icon: renderIcon(SocIcon),
276 - children: [
277 - {
278 - label: () =>
279 - h(
280 - RouterLink,
281 - {
282 - to: {
283 - name: "Soc-Alerts"
284 - }
285 - },
286 - { default: () => "Alerts" }
287 - ),
288 - key: "Soc-Alerts"
289 - },
290 - {
291 - label: () =>
292 - h(
293 - RouterLink,
294 - {
295 - to: {
296 - name: "Soc-Cases"
297 - }
298 - },
299 - { default: () => "Cases" }
300 - ),
301 - key: "Soc-Cases"
302 - },
303 - {
304 - label: () =>
305 - h(
306 - RouterLink,
307 - {
308 - to: {
309 - name: "Soc-Users"
310 - }
311 - },
312 - { default: () => "Users" }
313 - ),
314 - key: "Soc-Users"
315 - },
316 - {
317 - label: () =>
318 - h(
319 - RouterLink,
320 - {
321 - to: {
322 - name: "Soc-PendingAlerts"
323 - }
324 - },
325 - { default: () => "Pending Alerts" }
326 - ),
327 - key: "Soc-PendingAlerts"
328 - }
329 - ]
330 - },
331 - */
332 - {
333 - label: "Incident Management",
334 - key: "IncidentManagement",
335 - icon: renderIcon(IncidentManagementIcon),
336 - children: [
337 - {
338 - label: () =>
339 - h(
340 - RouterLink,
341 - {
342 - to: {
343 - name: "IncidentManagement-Sources"
344 - }
345 - },
346 - { default: () => "Sources" }
347 - ),
348 - key: "IncidentManagement-Sources"
349 - },
350 - {
351 - label: () =>
352 - h(
353 - RouterLink,
354 - {
355 - to: {
356 - name: "IncidentManagement-Alerts"
357 - }
358 - },
359 - { default: () => "Alerts" }
360 - ),
361 - key: "IncidentManagement-Alerts"
362 - },
363 - {
364 - label: () =>
365 - h(
366 - RouterLink,
367 - {
368 - to: {
369 - name: "IncidentManagement-Cases"
370 - }
371 - },
372 - { default: () => "Cases" }
373 - ),
374 - key: "IncidentManagement-Cases"
375 - }
376 - /*
377 - {
378 - label: () =>
379 - h(
380 - RouterLink,
381 - {
382 - to: {
383 - name: "IncidentManagement-Sigma"
384 - }
385 - },
386 - { default: () => "SIGMA" }
387 - ),
388 - key: "IncidentManagement-Sigma"
389 - }
390 - */
391 - ]
392 - },
393 - {
394 - label: () =>
395 - h(
396 - RouterLink,
397 - {
398 - to: {
399 - name: "Healthcheck"
400 - }
401 - },
402 - { default: () => "Healthcheck" }
403 - ),
404 - key: "Healthcheck",
405 - icon: renderIcon(HealthcheckIcon)
406 - },
407 - {
408 - label: () =>
409 - h(
410 - RouterLink,
411 - {
412 - to: {
413 - name: "Customers"
414 - }
415 - },
416 - { default: () => "Customers" }
417 - ),
418 - key: "Customers",
419 - icon: renderIcon(CustomersIcon)
420 - },
421 - {
422 - label: "External Services",
423 - key: "ExternalServices",
424 - icon: renderIcon(ExternalServicesIcon),
425 - children: [
426 - {
427 - label: () =>
428 - h(
429 - RouterLink,
430 - {
431 - to: {
432 - name: "ExternalServices-ThirdPartyIntegrations"
433 - }
434 - },
435 - { default: () => "3rd Party Integrations" }
436 - ),
437 - key: "ExternalServices-ThirdPartyIntegrations"
438 - },
439 - {
440 - label: () =>
441 - h(
442 - RouterLink,
443 - {
444 - to: {
445 - name: "ExternalServices-NetworkConnectors"
446 - }
447 - },
448 - { default: () => "Network Connectors" }
449 - ),
450 - key: "ExternalServices-NetworkConnectors"
451 - },
452 - {
453 - label: () =>
454 - h(
455 - RouterLink,
456 - {
457 - to: {
458 - name: "ExternalServices-SingulAppAuth"
459 - }
460 - },
461 - { default: () => "Singul App Auth" }
462 - ),
463 - key: "ExternalServices-SingulAppAuth"
464 - }
465 - ]
466 - },
467 - {
468 - label: () =>
469 - h(
470 - RouterLink,
471 - {
472 - to: {
473 - name: "ReportCreation"
474 - }
475 - },
476 - { default: () => "Report Creation" }
477 - ),
478 - key: "ReportCreation",
479 - icon: renderIcon(ReportCreationIcon)
480 - },
481 - {
482 - label: () =>
483 - h(
484 - RouterLink,
485 - {
486 - to: {
487 - name: "Scheduler"
488 - }
489 - },
490 - { default: () => "Scheduler" }
491 - ),
492 - key: "Scheduler",
493 - icon: renderIcon(SchedulerIcon)
494 - },
495 - {
496 - label: () =>
497 - h(
498 - RouterLink,
499 - {
500 - to: {
501 - name: "CustomerPortal"
502 - }
503 - },
504 - { default: () => "Customer Portal" }
505 - ),
506 - key: "CustomerPortal",
507 - icon: renderIcon(CustomerPortalIcon)
508 - }
509 - ]
24 + return [
25 + {
26 + label: () =>
27 + h(
28 + RouterLink,
29 + {
30 + to: {
31 + name: "Overview"
32 + }
33 + },
34 + { default: () => "Overview" }
35 + ),
36 + key: "Overview",
37 + icon: renderIcon(OverviewIcon)
38 + },
39 + {
40 + label: () =>
41 + h(
42 + RouterLink,
43 + {
44 + to: {
45 + name: "Indices"
46 + }
47 + },
48 + { default: () => "Indices" }
49 + ),
50 + key: "Indices",
51 + icon: renderIcon(IndiciesIcon)
52 + },
53 + {
54 + label: "Agents",
55 + key: "Agents",
56 + icon: renderIcon(AgentsIcon),
57 + children: [
58 + {
59 + label: () =>
60 + h(
61 + RouterLink,
62 + {
63 + to: {
64 + name: "Agents"
65 + }
66 + },
67 + { default: () => "Agents list" }
68 + ),
69 + key: "Agents"
70 + },
71 + {
72 + label: () =>
73 + h(
74 + RouterLink,
75 + {
76 + to: {
77 + name: "Groups"
78 + }
79 + },
80 + { default: () => "Groups" }
81 + ),
82 + key: "Groups"
83 + },
84 + {
85 + label: () =>
86 + h(
87 + RouterLink,
88 + {
89 + to: {
90 + name: "SysmonConfig"
91 + }
92 + },
93 + { default: () => "Sysmon Config" }
94 + ),
95 + key: "SysmonConfig"
96 + },
97 + {
98 + label: () =>
99 + h(
100 + RouterLink,
101 + {
102 + to: {
103 + name: "DetectionRules"
104 + }
105 + },
106 + { default: () => "Detection Rules" }
107 + ),
108 + key: "DetectionRules"
109 + },
110 + {
111 + label: () =>
112 + h(
113 + RouterLink,
114 + {
115 + to: {
116 + name: "CopilotActions"
117 + }
118 + },
119 + { default: () => "CoPilot Actions" }
120 + ),
121 + key: "CopilotActions"
122 + },
123 + {
124 + label: () =>
125 + h(
126 + RouterLink,
127 + {
128 + to: {
129 + name: "VulnerabilityOverview"
130 + }
131 + },
132 + { default: () => "Vulnerability Overview" }
133 + ),
134 + key: "VulnerabilityOverview"
135 + },
136 + {
137 + label: () =>
138 + h(
139 + RouterLink,
140 + {
141 + to: {
142 + name: "ScaOverview"
143 + }
144 + },
145 + { default: () => "SCA Overview" }
146 + ),
147 + key: "ScaOverview"
148 + }
149 + ]
150 + },
151 + {
152 + label: () =>
153 + h(
154 + RouterLink,
155 + {
156 + to: {
157 + name: "Connectors"
158 + }
159 + },
160 + { default: () => "Connectors" }
161 + ),
162 + key: "Connectors",
163 + icon: renderIcon(ConnectorsIcon)
164 + },
165 + {
166 + label: "Graylog",
167 + key: "Graylog",
168 + icon: renderIcon(GraylogIcon),
169 + children: [
170 + {
171 + label: () =>
172 + h(
173 + RouterLink,
174 + {
175 + to: {
176 + name: "Graylog-Management"
177 + }
178 + },
179 + { default: () => "Management" }
180 + ),
181 + key: "Graylog-Management"
182 + },
183 + {
184 + label: () =>
185 + h(
186 + RouterLink,
187 + {
188 + to: {
189 + name: "Graylog-Metrics"
190 + }
191 + },
192 + { default: () => "Metrics" }
193 + ),
194 + key: "Graylog-Metrics"
195 + },
196 + {
197 + label: () =>
198 + h(
199 + RouterLink,
200 + {
201 + to: {
202 + name: "Graylog-Pipelines"
203 + }
204 + },
205 + { default: () => "Pipelines" }
206 + ),
207 + key: "Graylog-Pipelines"
208 + }
209 + ]
210 + },
211 + {
212 + label: "Alerts",
213 + key: "Alerts",
214 + icon: renderIcon(AlertsIcon),
215 + children: [
216 + {
217 + label: () =>
218 + h(
219 + RouterLink,
220 + {
221 + to: {
222 + name: "Alerts-SIEM"
223 + }
224 + },
225 + { default: () => "SIEM" }
226 + ),
227 + key: "Alerts-SIEM"
228 + },
229 + {
230 + label: () =>
231 + h(
232 + RouterLink,
233 + {
234 + to: {
235 + name: "Alerts-Mitre"
236 + }
237 + },
238 + { default: () => "MITRE ATT&CK" }
239 + ),
240 + key: "Alerts-Mitre"
241 + },
242 + {
243 + label: () =>
244 + h(
245 + RouterLink,
246 + {
247 + to: {
248 + name: "Alerts-AtomicRedTeam"
249 + }
250 + },
251 + { default: () => "Atomic Red Team" }
252 + ),
253 + key: "Alerts-AtomicRedTeam"
254 + }
255 + ]
256 + },
257 + {
258 + label: () =>
259 + h(
260 + RouterLink,
261 + {
262 + to: {
263 + name: "Artifacts"
264 + }
265 + },
266 + { default: () => "Artifacts" }
267 + ),
268 + key: "Artifacts",
269 + icon: renderIcon(ArtifactsIcon)
270 + },
271 + /*
272 + {
273 + label: "SOC",
274 + key: "Soc",
275 + icon: renderIcon(SocIcon),
276 + children: [
277 + {
278 + label: () =>
279 + h(
280 + RouterLink,
281 + {
282 + to: {
283 + name: "Soc-Alerts"
284 + }
285 + },
286 + { default: () => "Alerts" }
287 + ),
288 + key: "Soc-Alerts"
289 + },
290 + {
291 + label: () =>
292 + h(
293 + RouterLink,
294 + {
295 + to: {
296 + name: "Soc-Cases"
297 + }
298 + },
299 + { default: () => "Cases" }
300 + ),
301 + key: "Soc-Cases"
302 + },
303 + {
304 + label: () =>
305 + h(
306 + RouterLink,
307 + {
308 + to: {
309 + name: "Soc-Users"
310 + }
311 + },
312 + { default: () => "Users" }
313 + ),
314 + key: "Soc-Users"
315 + },
316 + {
317 + label: () =>
318 + h(
319 + RouterLink,
320 + {
321 + to: {
322 + name: "Soc-PendingAlerts"
323 + }
324 + },
325 + { default: () => "Pending Alerts" }
326 + ),
327 + key: "Soc-PendingAlerts"
328 + }
329 + ]
330 + },
331 + */
332 + {
333 + label: "Incident Management",
334 + key: "IncidentManagement",
335 + icon: renderIcon(IncidentManagementIcon),
336 + children: [
337 + {
338 + label: () =>
339 + h(
340 + RouterLink,
341 + {
342 + to: {
343 + name: "IncidentManagement-Sources"
344 + }
345 + },
346 + { default: () => "Sources" }
347 + ),
348 + key: "IncidentManagement-Sources"
349 + },
350 + {
351 + label: () =>
352 + h(
353 + RouterLink,
354 + {
355 + to: {
356 + name: "IncidentManagement-Alerts"
357 + }
358 + },
359 + { default: () => "Alerts" }
360 + ),
361 + key: "IncidentManagement-Alerts"
362 + },
363 + {
364 + label: () =>
365 + h(
366 + RouterLink,
367 + {
368 + to: {
369 + name: "IncidentManagement-Cases"
370 + }
371 + },
372 + { default: () => "Cases" }
373 + ),
374 + key: "IncidentManagement-Cases"
375 + }
376 + /*
377 + {
378 + label: () =>
379 + h(
380 + RouterLink,
381 + {
382 + to: {
383 + name: "IncidentManagement-Sigma"
384 + }
385 + },
386 + { default: () => "SIGMA" }
387 + ),
388 + key: "IncidentManagement-Sigma"
389 + }
390 + */
391 + ]
392 + },
393 + {
394 + label: () =>
395 + h(
396 + RouterLink,
397 + {
398 + to: {
399 + name: "Healthcheck"
400 + }
401 + },
402 + { default: () => "Healthcheck" }
403 + ),
404 + key: "Healthcheck",
405 + icon: renderIcon(HealthcheckIcon)
406 + },
407 + {
408 + label: () =>
409 + h(
410 + RouterLink,
411 + {
412 + to: {
413 + name: "Customers"
414 + }
415 + },
416 + { default: () => "Customers" }
417 + ),
418 + key: "Customers",
419 + icon: renderIcon(CustomersIcon)
420 + },
421 + {
422 + label: "External Services",
423 + key: "ExternalServices",
424 + icon: renderIcon(ExternalServicesIcon),
425 + children: [
426 + {
427 + label: () =>
428 + h(
429 + RouterLink,
430 + {
431 + to: {
432 + name: "ExternalServices-ThirdPartyIntegrations"
433 + }
434 + },
435 + { default: () => "3rd Party Integrations" }
436 + ),
437 + key: "ExternalServices-ThirdPartyIntegrations"
438 + },
439 + {
440 + label: () =>
441 + h(
442 + RouterLink,
443 + {
444 + to: {
445 + name: "ExternalServices-NetworkConnectors"
446 + }
447 + },
448 + { default: () => "Network Connectors" }
449 + ),
450 + key: "ExternalServices-NetworkConnectors"
451 + },
452 + {
453 + label: () =>
454 + h(
455 + RouterLink,
456 + {
457 + to: {
458 + name: "ExternalServices-SingulAppAuth"
459 + }
460 + },
461 + { default: () => "Singul App Auth" }
462 + ),
463 + key: "ExternalServices-SingulAppAuth"
464 + }
465 + ]
466 + },
467 + {
468 + label: "Report Creation",
469 + key: "ReportCreation",
470 + icon: renderIcon(ReportCreationIcon),
471 + children: [
472 + {
473 + label: () =>
474 + h(
475 + RouterLink,
476 + {
477 + to: {
478 + name: "ReportCreation"
479 + }
480 + },
481 + { default: () => "General Reports" }
482 + ),
483 + key: "ReportCreation"
484 + },
485 + {
486 + label: () =>
487 + h(
488 + RouterLink,
489 + {
490 + to: {
491 + name: "VulnerabilityReports"
492 + }
493 + },
494 + { default: () => "Vulnerability Reports" }
495 + ),
496 + key: "VulnerabilityReports"
497 + }
498 + ]
499 + },
500 + {
501 + label: () =>
502 + h(
503 + RouterLink,
504 + {
505 + to: {
506 + name: "Scheduler"
507 + }
508 + },
509 + { default: () => "Scheduler" }
510 + ),
511 + key: "Scheduler",
512 + icon: renderIcon(SchedulerIcon)
513 + },
514 + {
515 + label: () =>
516 + h(
517 + RouterLink,
518 + {
519 + to: {
520 + name: "CustomerPortal"
521 + }
522 + },
523 + { default: () => "Customer Portal" }
524 + ),
525 + key: "CustomerPortal",
526 + icon: renderIcon(CustomerPortalIcon)
527 + }
528 + ]
529 }
frontend/src/components/vulnerabilities/GenerateReportForm.vue new
+137
@@ -0,0 +1,137 @@
1 +<template>
2 + <n-form ref="formRef" :model="formData" :rules="rules" label-placement="top">
3 + <n-form-item label="Report Name" path="report_name">
4 + <n-input
5 + v-model:value="formData.report_name"
6 + placeholder="Leave empty for auto-generated name"
7 + clearable
8 + />
9 + </n-form-item>
10 +
11 + <n-form-item label="Customer" path="customer_code" required>
12 + <n-select
13 + v-model:value="formData.customer_code"
14 + :options="customers"
15 + placeholder="Select customer"
16 + filterable
17 + />
18 + </n-form-item>
19 +
20 + <n-divider>Optional Filters</n-divider>
21 +
22 + <n-form-item label="Agent Name" path="agent_name">
23 + <n-input v-model:value="formData.agent_name" placeholder="Filter by agent hostname" clearable />
24 + </n-form-item>
25 +
26 + <n-form-item label="Severity" path="severity">
27 + <n-select
28 + v-model:value="formData.severity"
29 + :options="severityOptions"
30 + placeholder="Filter by severity"
31 + clearable
32 + />
33 + </n-form-item>
34 +
35 + <n-form-item label="CVE ID" path="cve_id">
36 + <n-input v-model:value="formData.cve_id" placeholder="Filter by CVE ID" clearable />
37 + </n-form-item>
38 +
39 + <n-form-item label="Package Name" path="package_name">
40 + <n-input v-model:value="formData.package_name" placeholder="Filter by package name" clearable />
41 + </n-form-item>
42 +
43 + <n-form-item label="Include EPSS Scores" path="include_epss">
44 + <n-switch v-model:value="formData.include_epss">
45 + <template #checked>Enabled</template>
46 + <template #unchecked>Disabled</template>
47 + </n-switch>
48 + </n-form-item>
49 +
50 + <div class="flex justify-end gap-3 mt-6">
51 + <n-button @click="$emit('cancel')">Cancel</n-button>
52 + <n-button type="primary" :loading="loading" @click="handleSubmit">Generate Report</n-button>
53 + </div>
54 + </n-form>
55 +</template>
56 +
57 +<script setup lang="ts">
58 +import type { FormInst, FormRules } from "naive-ui"
59 +import type { VulnerabilityReportGenerateRequest } from "@/types/vulnerabilities.d"
60 +import { NButton, NDivider, NForm, NFormItem, NInput, NSelect, NSwitch } from "naive-ui"
61 +import { ref } from "vue"
62 +import { VulnerabilitySeverity } from "@/types/vulnerabilities.d"
63 +
64 +interface Props {
65 + customers: Array<{ label: string; value: string }>
66 + loading?: boolean
67 +}
68 +
69 +defineProps<Props>()
70 +
71 +const emit = defineEmits<{
72 + (e: "generate", value: VulnerabilityReportGenerateRequest): void
73 + (e: "cancel"): void
74 +}>()
75 +
76 +const formRef = ref<FormInst | null>(null)
77 +const formData = ref<VulnerabilityReportGenerateRequest>({
78 + customer_code: "",
79 + report_name: "",
80 + agent_name: "",
81 + severity: undefined,
82 + cve_id: "",
83 + package_name: "",
84 + include_epss: false
85 +})
86 +
87 +const severityOptions = [
88 + { label: "Critical", value: VulnerabilitySeverity.Critical },
89 + { label: "High", value: VulnerabilitySeverity.High },
90 + { label: "Medium", value: VulnerabilitySeverity.Medium },
91 + { label: "Low", value: VulnerabilitySeverity.Low }
92 +]
93 +
94 +const rules: FormRules = {
95 + customer_code: [
96 + {
97 + required: true,
98 + message: "Please select a customer",
99 + trigger: ["blur", "change"]
100 + }
101 + ]
102 +}
103 +
104 +async function handleSubmit() {
105 + if (!formRef.value) return
106 +
107 + try {
108 + await formRef.value.validate()
109 +
110 + // Clean up empty strings
111 + const request: VulnerabilityReportGenerateRequest = {
112 + customer_code: formData.value.customer_code,
113 + include_epss: formData.value.include_epss
114 + }
115 +
116 + if (formData.value.report_name?.trim()) {
117 + request.report_name = formData.value.report_name.trim()
118 + }
119 + if (formData.value.agent_name?.trim()) {
120 + request.agent_name = formData.value.agent_name.trim()
121 + }
122 + if (formData.value.severity) {
123 + request.severity = formData.value.severity
124 + }
125 + if (formData.value.cve_id?.trim()) {
126 + request.cve_id = formData.value.cve_id.trim()
127 + }
128 + if (formData.value.package_name?.trim()) {
129 + request.package_name = formData.value.package_name.trim()
130 + }
131 +
132 + emit("generate", request)
133 + } catch (error) {
134 + console.error("Form validation failed:", error)
135 + }
136 +}
137 +</script>
frontend/src/components/vulnerabilities/VulnerabilityReports.vue new
+383
@@ -0,0 +1,383 @@
1 +<template>
2 + <div class="vulnerability-reports">
3 + <div class="header mb-6 flex items-center justify-between">
4 + <div>
5 + <h2 class="text-2xl font-bold mb-2">Vulnerability Reports</h2>
6 + <p class="text-secondary">Generate, manage, and download vulnerability reports</p>
7 + </div>
8 + <n-button type="primary" size="large" @click="showGenerateModal = true">
9 + <template #icon>
10 + <Icon :name="AddIcon" />
11 + </template>
12 + Generate Report
13 + </n-button>
14 + </div>
15 +
16 + <!-- Filters -->
17 + <n-card class="mb-6">
18 + <div class="flex gap-4">
19 + <n-select
20 + v-model:value="filterCustomerCode"
21 + :options="customerOptions"
22 + placeholder="Filter by Customer"
23 + clearable
24 + filterable
25 + class="flex-1"
26 + @update:value="loadReports"
27 + />
28 + <n-button :loading="loading" @click="loadReports">
29 + <template #icon>
30 + <Icon :name="RefreshIcon" />
31 + </template>
32 + Refresh
33 + </n-button>
34 + </div>
35 + </n-card>
36 +
37 + <!-- Reports Table -->
38 + <n-card>
39 + <n-data-table
40 + :columns="columns"
41 + :data="reports"
42 + :loading="loading"
43 + :pagination="pagination"
44 + :row-key="(row: VulnerabilityReport) => row.id"
45 + />
46 + </n-card>
47 +
48 + <!-- Generate Report Modal -->
49 + <n-modal
50 + v-model:show="showGenerateModal"
51 + preset="card"
52 + title="Generate Vulnerability Report"
53 + style="width: 600px; max-width: 90vw;"
54 + :closable="true"
55 + >
56 + <GenerateReportForm
57 + :customers="customers"
58 + :loading="generating"
59 + @generate="handleGenerateReport"
60 + @cancel="showGenerateModal = false"
61 + />
62 + </n-modal>
63 +
64 + <!-- Delete Confirmation Modal -->
65 + <n-modal
66 + v-model:show="showDeleteModal"
67 + preset="dialog"
68 + title="Delete Report"
69 + positive-text="Delete"
70 + negative-text="Cancel"
71 + @positive-click="confirmDelete"
72 + @negative-click="showDeleteModal = false"
73 + >
74 + <p>Are you sure you want to delete the report "{{ reportToDelete?.report_name }}"?</p>
75 + <p class="text-secondary mt-2">This action cannot be undone.</p>
76 + </n-modal>
77 + </div>
78 +</template>
79 +
80 +<script setup lang="ts">
81 +import type { DataTableColumns } from "naive-ui"
82 +import type { VulnerabilityReport, VulnerabilityReportGenerateRequest } from "@/types/vulnerabilities.d"
83 +import { NButton, NCard, NDataTable, NModal, NSelect, NSpace, NTag, NTooltip, useMessage } from "naive-ui"
84 +import { computed, h, onMounted, ref } from "vue"
85 +import Api from "@/api"
86 +import Icon from "@/components/common/Icon.vue"
87 +import { formatBytes, formatDate } from "@/utils/format"
88 +import GenerateReportForm from "./GenerateReportForm.vue"
89 +
90 +const AddIcon = "carbon:document-add"
91 +const RefreshIcon = "carbon:renew"
92 +const DownloadIcon = "carbon:download"
93 +const DeleteIcon = "carbon:trash-can"
94 +const CheckIcon = "carbon:checkmark-filled"
95 +const ErrorIcon = "carbon:warning-filled"
96 +const LoadingIcon = "eos-icons:loading"
97 +
98 +const message = useMessage()
99 +
100 +const loading = ref(false)
101 +const generating = ref(false)
102 +const reports = ref<VulnerabilityReport[]>([])
103 +const showGenerateModal = ref(false)
104 +const showDeleteModal = ref(false)
105 +const reportToDelete = ref<VulnerabilityReport | null>(null)
106 +const filterCustomerCode = ref<string | null>(null)
107 +const customers = ref<Array<{ label: string; value: string }>>([])
108 +
109 +const customerOptions = computed(() => [
110 + ...customers.value
111 +])
112 +
113 +const pagination = {
114 + pageSize: 20,
115 + showSizePicker: true,
116 + pageSizes: [10, 20, 50, 100]
117 +}
118 +
119 +const columns: DataTableColumns<VulnerabilityReport> = [
120 + {
121 + title: "Report Name",
122 + key: "report_name",
123 + ellipsis: {
124 + tooltip: true
125 + }
126 + },
127 + {
128 + title: "Customer",
129 + key: "customer_code",
130 + width: 120
131 + },
132 + {
133 + title: "Status",
134 + key: "status",
135 + width: 120,
136 + render: (row: VulnerabilityReport) => {
137 + const statusMap = {
138 + completed: { type: "success", icon: CheckIcon, text: "Completed" },
139 + processing: { type: "warning", icon: LoadingIcon, text: "Processing" },
140 + failed: { type: "error", icon: ErrorIcon, text: "Failed" }
141 + }
142 + const status = statusMap[row.status]
143 + return h(
144 + NTag,
145 + { type: status.type as any, size: "small" },
146 + {
147 + default: () => status.text,
148 + icon: () => h(Icon, { name: status.icon, size: "14" })
149 + }
150 + )
151 + }
152 + },
153 + {
154 + title: "Vulnerabilities",
155 + key: "total_vulnerabilities",
156 + width: 140,
157 + render: (row: VulnerabilityReport) => {
158 + return h(
159 + NTooltip,
160 + {},
161 + {
162 + trigger: () => row.total_vulnerabilities.toLocaleString(),
163 + default: () => h("div", {}, [
164 + h("div", {}, `Critical: ${row.critical_count}`),
165 + h("div", {}, `High: ${row.high_count}`),
166 + h("div", {}, `Medium: ${row.medium_count}`),
167 + h("div", {}, `Low: ${row.low_count}`)
168 + ])
169 + }
170 + )
171 + }
172 + },
173 + {
174 + title: "File Size",
175 + key: "file_size",
176 + width: 120,
177 + render: (row: VulnerabilityReport) => formatBytes(row.file_size)
178 + },
179 + {
180 + title: "Generated",
181 + key: "generated_at",
182 + width: 180,
183 + render: (row: VulnerabilityReport) => formatDate(row.generated_at)
184 + },
185 + {
186 + title: "Actions",
187 + key: "actions",
188 + width: 140,
189 + render: (row: VulnerabilityReport) => {
190 + return h(
191 + NSpace,
192 + { size: "small" },
193 + {
194 + default: () => [
195 + row.status === "completed"
196 + ? h(
197 + NButton,
198 + {
199 + size: "small",
200 + type: "primary",
201 + onClick: () => handleDownload(row)
202 + },
203 + {
204 + icon: () => h(Icon, { name: DownloadIcon })
205 + }
206 + )
207 + : null,
208 + h(
209 + NButton,
210 + {
211 + size: "small",
212 + type: "error",
213 + onClick: () => handleDeleteClick(row)
214 + },
215 + {
216 + icon: () => h(Icon, { name: DeleteIcon })
217 + }
218 + )
219 + ]
220 + }
221 + )
222 + }
223 + }
224 +]
225 +
226 +async function loadReports() {
227 + loading.value = true
228 + try {
229 + const response = await Api.vulnerabilities.listReports(filterCustomerCode.value || undefined)
230 + if (response.data.success) {
231 + reports.value = response.data.reports
232 + } else {
233 + message.error(response.data.message || "Failed to load reports")
234 + }
235 + } catch (error: any) {
236 + message.error(error?.response?.data?.detail || "Failed to load reports")
237 + } finally {
238 + loading.value = false
239 + }
240 +}
241 +
242 +async function loadCustomers() {
243 + try {
244 + // Try the customers API endpoint
245 + const response = await Api.customers.getCustomers()
246 +
247 + // Handle different possible response structures
248 + let customerData = response.data
249 +
250 + // If response.data is wrapped in a data property
251 + if (customerData.data) {
252 + customerData = customerData.data
253 + }
254 +
255 + // If it's an array, map it
256 + if (Array.isArray(customerData)) {
257 + customers.value = customerData.map((c: any) => ({
258 + label: `${c.customer_name || c.name || c.customer_code} (${c.customer_code})`,
259 + value: c.customer_code
260 + }))
261 + } else if (customerData.customers && Array.isArray(customerData.customers)) {
262 + // If customers are in a customers property
263 + customers.value = customerData.customers.map((c: any) => ({
264 + label: `${c.customer_name || c.name || c.customer_code} (${c.customer_code})`,
265 + value: c.customer_code
266 + }))
267 + } else {
268 + console.warn("Unexpected customer data structure:", customerData)
269 + message.warning("Customer data format unexpected")
270 + }
271 +
272 + console.log("Loaded customers:", customers.value)
273 + } catch (error: any) {
274 + console.error("Failed to load customers:", error)
275 + message.error("Failed to load customers list")
276 + }
277 +}
278 +
279 +async function handleGenerateReport(request: VulnerabilityReportGenerateRequest) {
280 + generating.value = true
281 + try {
282 + // Use background generation for better UX
283 + const response = await Api.vulnerabilities.generateReportBackground(request)
284 +
285 + if (response.data.success) {
286 + message.success(response.data.message)
287 + showGenerateModal.value = false
288 +
289 + // Add the new processing report to the list
290 + await loadReports()
291 +
292 + // Start polling for status updates
293 + startStatusPolling(response.data.report_id)
294 + } else {
295 + message.error("Failed to queue report generation")
296 + }
297 + } catch (error: any) {
298 + message.error(error?.response?.data?.detail || "Failed to generate report")
299 + } finally {
300 + generating.value = false
301 + }
302 +}
303 +
304 +function startStatusPolling(reportId: number) {
305 + const pollInterval = setInterval(async () => {
306 + try {
307 + await loadReports()
308 +
309 + const report = reports.value.find(r => r.id === reportId)
310 + if (report && report.status !== "processing") {
311 + clearInterval(pollInterval)
312 +
313 + if (report.status === "completed") {
314 + message.success(`Report "${report.report_name}" completed successfully`)
315 + } else if (report.status === "failed") {
316 + message.error(`Report "${report.report_name}" failed: ${report.error_message}`)
317 + }
318 + }
319 + } catch (error) {
320 + clearInterval(pollInterval)
321 + }
322 + }, 3000) // Poll every 3 seconds
323 +
324 + // Stop polling after 5 minutes
325 + setTimeout(() => clearInterval(pollInterval), 300000)
326 +}
327 +
328 +async function handleDownload(report: VulnerabilityReport) {
329 + try {
330 + const response = await Api.vulnerabilities.downloadReport(report.id)
331 +
332 + // Create download link
333 + const url = window.URL.createObjectURL(new Blob([response.data]))
334 + const link = document.createElement("a")
335 + link.href = url
336 + link.setAttribute("download", report.file_name)
337 + document.body.appendChild(link)
338 + link.click()
339 + link.remove()
340 + window.URL.revokeObjectURL(url)
341 +
342 + message.success("Report downloaded successfully")
343 + } catch (error: any) {
344 + message.error(error?.response?.data?.detail || "Failed to download report")
345 + }
346 +}
347 +
348 +function handleDeleteClick(report: VulnerabilityReport) {
349 + reportToDelete.value = report
350 + showDeleteModal.value = true
351 +}
352 +
353 +async function confirmDelete() {
354 + if (!reportToDelete.value) return
355 +
356 + try {
357 + const response = await Api.vulnerabilities.deleteReport(reportToDelete.value.id)
358 +
359 + if (response.data.success) {
360 + message.success(response.data.message)
361 + await loadReports()
362 + } else {
363 + message.error("Failed to delete report")
364 + }
365 + } catch (error: any) {
366 + message.error(error?.response?.data?.detail || "Failed to delete report")
367 + } finally {
368 + showDeleteModal.value = false
369 + reportToDelete.value = null
370 + }
371 +}
372 +
373 +onMounted(() => {
374 + loadReports()
375 + loadCustomers()
376 +})
377 +</script>
378 +
379 +<style scoped>
380 +.vulnerability-reports {
381 + padding: 20px;
382 +}
383 +</style>
frontend/src/router/index.ts
+27 -5
@@ -272,12 +272,34 @@ const router = createRouter({
272 }
273 ]
274 },
275 + // {
276 + // path: "/report-creation",
277 + // name: "ReportCreation",
278 + // component: () => import("@/views/ReportCreation.vue"),
279 + // meta: { title: "Report Creation", auth: true, roles: RouteRole.All }
280 + // },
281 {
276 - path: "/report-creation",
277 - name: "ReportCreation",
278 - component: () => import("@/views/ReportCreation.vue"),
279 - meta: { title: "Report Creation", auth: true, roles: RouteRole.All }
280 - },
282 + path: "/report-creation",
283 + redirect: "/report-creation/general",
284 + meta: {
285 + auth: true,
286 + roles: RouteRole.All
287 + },
288 + children: [
289 + {
290 + path: "general",
291 + name: "ReportCreation",
292 + component: () => import("@/views/ReportCreation.vue"),
293 + meta: { title: "General Reports" }
294 + },
295 + {
296 + path: "vulnerability-reports",
297 + name: "VulnerabilityReports",
298 + component: () => import("@/components/vulnerabilities/VulnerabilityReports.vue"),
299 + meta: { title: "Vulnerability Reports" }
300 + }
301 + ]
302 + },
303 {
304 path: "/scheduler",
305 name: "Scheduler",
frontend/src/types/vulnerabilities.d.ts
+51
@@ -49,3 +49,54 @@ export interface VulnerabilitySearchQuery {
49 page_size?: number
50 include_epss?: boolean
51 }
52 +
53 +export interface VulnerabilityReport {
54 + id: number
55 + report_name: string
56 + customer_code: string
57 + file_name: string
58 + file_size: number
59 + generated_at: string
60 + generated_by: number
61 + total_vulnerabilities: number
62 + critical_count: number
63 + high_count: number
64 + medium_count: number
65 + low_count: number
66 + filters_applied: Record<string, string | number | boolean>
67 + status: "processing" | "completed" | "failed"
68 + error_message?: string | null
69 + download_url: string
70 +}
71 +
72 +export interface VulnerabilityReportGenerateRequest {
73 + customer_code: string
74 + report_name?: string
75 + agent_name?: string
76 + severity?: VulnerabilitySeverity
77 + cve_id?: string
78 + package_name?: string
79 + include_epss?: boolean
80 +}
81 +
82 +export interface VulnerabilityReportGenerateResponse {
83 + success: boolean
84 + message: string
85 + report?: VulnerabilityReport
86 + error?: string
87 +}
88 +
89 +export interface VulnerabilityReportListResponse {
90 + success: boolean
91 + message: string
92 + reports: VulnerabilityReport[]
93 + total_count: number
94 +}
95 +
96 +export interface VulnerabilityReportDeleteResponse {
97 + success: boolean
98 + message: string
99 + report_id: number
100 + report_name: string
101 + customer_code: string
102 +}
frontend/src/utils/format.ts new
+20
@@ -0,0 +1,20 @@
1 +export function formatDate(dateString: string): string {
2 + const date = new Date(dateString)
3 + return new Intl.DateTimeFormat("en-US", {
4 + year: "numeric",
5 + month: "short",
6 + day: "numeric",
7 + hour: "2-digit",
8 + minute: "2-digit"
9 + }).format(date)
10 +}
11 +
12 +export function formatBytes(bytes: number): string {
13 + if (bytes === 0) return "0 Bytes"
14 +
15 + const k = 1024
16 + const sizes = ["Bytes", "KB", "MB", "GB"]
17 + const i = Math.floor(Math.log(bytes) / Math.log(k))
18 +
19 + return `${Math.round(bytes / k ** i * 100) / 100} ${sizes[i]}`
20 +}