@cryptotaxi247 / CoPilot / commits / 6f084fd9

596 sca reports (#627)

* feat: add SCA report generation and management endpoints with database model * feat: implement SCA report generation, listing, and management features * precommit-fixes * chore: update CURRENT_VERSION to 0.1.29

taylor_socfortress committed Jan 24, 2026 at 13:50 UTC 6f084fd9d2d1d1a366bc651980f9c802811e17ec
12 files changed +1642 -32
backend/alembic/versions/2b39bb1f528f_add_sca_reports_table.py new
+62
@@ -0,0 +1,62 @@
1 +"""Add sca reports table
2 +
3 +Revision ID: 2b39bb1f528f
4 +Revises: d21f0b3329b4
5 +Create Date: 2026-01-24 13:18:30.452434
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 = "2b39bb1f528f"
17 +down_revision: Union[str, None] = "d21f0b3329b4"
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 + "sca_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_policies", sa.Integer(), nullable=False),
39 + sa.Column("total_checks", sa.Integer(), nullable=False),
40 + sa.Column("passed_count", sa.Integer(), nullable=False),
41 + sa.Column("failed_count", sa.Integer(), nullable=False),
42 + sa.Column("invalid_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_sca_reports_customer_code"), "sca_reports", ["customer_code"], unique=False)
51 + op.create_index(op.f("ix_sca_reports_generated_at"), "sca_reports", ["generated_at"], unique=False)
52 + op.create_index(op.f("ix_sca_reports_status"), "sca_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_sca_reports_status"), table_name="sca_reports")
59 + op.drop_index(op.f("ix_sca_reports_generated_at"), table_name="sca_reports")
60 + op.drop_index(op.f("ix_sca_reports_customer_code"), table_name="sca_reports")
61 + op.drop_table("sca_reports")
62 + # ### end Alembic commands ###
backend/app/agents/sca/routes/sca.py
+308
@@ -1,17 +1,29 @@
1 +from typing import Any
2 +from typing import Dict
3 from typing import Optional
4
5 from fastapi import APIRouter
6 +from fastapi import BackgroundTasks
7 from fastapi import Depends
8 from fastapi import HTTPException
9 from fastapi import Query
10 +from fastapi import Response
11 from fastapi import Security
12 from loguru import logger
13 from sqlalchemy.ext.asyncio import AsyncSession
14
15 from app.agents.sca.schema.sca import ScaOverviewResponse
16 +from app.agents.sca.schema.sca import SCAReportGenerateRequest
17 +from app.agents.sca.schema.sca import SCAReportGenerateResponse
18 +from app.agents.sca.schema.sca import SCAReportListResponse
19 from app.agents.sca.schema.sca import ScaStatsResponse
20 +from app.agents.sca.services.sca import delete_sca_report
21 +from app.agents.sca.services.sca import generate_sca_csv_report
22 +from app.agents.sca.services.sca import get_sca_report_download
23 from app.agents.sca.services.sca import get_sca_statistics
24 +from app.agents.sca.services.sca import list_sca_reports
25 from app.agents.sca.services.sca import search_sca_overview
26 +from app.auth.models.users import User
27 from app.auth.routes.auth import AuthHandler
28 from app.db.db_session import get_db
29
@@ -173,3 +185,299 @@ async def get_sca_stats(
185 except Exception as e:
186 logger.error(f"Error getting SCA statistics: {e}")
187 raise HTTPException(status_code=500, detail=f"Failed to get SCA statistics: {e}")
188 +
189 +
190 +@sca_router.post(
191 + "/reports/generate",
192 + response_model=SCAReportGenerateResponse,
193 + description="Generate a CSV report of SCA results",
194 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
195 +)
196 +async def generate_sca_report(
197 + request: SCAReportGenerateRequest,
198 + background_tasks: BackgroundTasks,
199 + db: AsyncSession = Depends(get_db),
200 + current_user: User = Security(AuthHandler().get_current_user),
201 +) -> SCAReportGenerateResponse:
202 + """
203 + Generate a comprehensive CSV report of SCA (Security Configuration Assessment) results.
204 +
205 + This endpoint creates a downloadable CSV file containing all SCA policy results
206 + matching the specified criteria. The report is generated in the background and
207 + stored in MinIO for later download.
208 +
209 + **Features:**
210 + - Generates comprehensive CSV reports with all SCA policy data
211 + - Background processing for large datasets
212 + - Stores reports in MinIO for persistent access
213 + - Filters applied are saved with the report
214 + - Tracks report generation status (processing, completed, failed)
215 + - Customer access control enforced
216 +
217 + **Report Contents:**
218 + The CSV report includes the following columns:
219 + - Agent ID
220 + - Agent Name
221 + - Customer Code
222 + - Policy ID
223 + - Policy Name
224 + - Description
225 + - Total Checks
226 + - Passed
227 + - Failed
228 + - Invalid
229 + - Score
230 + - Start Scan
231 + - End Scan
232 + - References
233 + - Hash File
234 +
235 + **Use Cases:**
236 + - Export compliance data for external analysis
237 + - Generate reports for compliance audits
238 + - Share security posture with stakeholders
239 + - Integrate with third-party tools
240 + - Historical record keeping
241 +
242 + **Filtering Options:**
243 + - **customer_code** (required): Customer to generate report for
244 + - **report_name** (optional): Custom name for the report
245 + - **agent_name**: Filter by specific agent hostname
246 + - **policy_id**: Filter by specific policy ID
247 + - **min_score**: Filter by minimum compliance score
248 + - **max_score**: Filter by maximum compliance score
249 +
250 + **Report Statistics:**
251 + - **total_policies**: Number of policy results included
252 + - **total_checks**: Sum of all checks across policies
253 + - **passed_count**: Total passed checks
254 + - **failed_count**: Total failed checks
255 + - **invalid_count**: Total invalid checks
256 +
257 + **Background Processing:**
258 + - Report generation starts immediately in background
259 + - Status tracked in database (processing → completed/failed)
260 + - Use `/reports` endpoint to check generation status
261 + - Download via `/reports/{id}/download` when completed
262 +
263 + Args:
264 + request: Report generation request with filters
265 + background_tasks: FastAPI background tasks for async generation
266 + db: Database session
267 + current_user: Current authenticated user
268 +
269 + Returns:
270 + SCAReportGenerateResponse: Report generation status and details
271 + """
272 + logger.info(f"Generating SCA report for customer {request.customer_code} " f"with filters: {request.dict(exclude_none=True)}")
273 +
274 + try:
275 + # Note: This will be processed synchronously for now
276 + # For true background processing, implement background task pattern
277 + result = await generate_sca_csv_report(
278 + db_session=db,
279 + current_user=current_user,
280 + request=request,
281 + )
282 + return result
283 +
284 + except Exception as e:
285 + logger.error(f"Error generating SCA report: {e}")
286 + raise HTTPException(status_code=500, detail=f"Failed to generate report: {e}")
287 +
288 +
289 +@sca_router.get(
290 + "/reports",
291 + response_model=SCAReportListResponse,
292 + description="List available SCA reports",
293 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
294 +)
295 +async def list_reports(
296 + customer_code: Optional[str] = Query(None, description="Filter by customer code"),
297 + db: AsyncSession = Depends(get_db),
298 + current_user: User = Security(AuthHandler().get_current_user),
299 +) -> SCAReportListResponse:
300 + """
301 + List all available SCA reports.
302 +
303 + This endpoint returns all SCA reports that the current user has access to,
304 + with optional filtering by customer code.
305 +
306 + **Features:**
307 + - Lists all generated reports with metadata
308 + - Respects customer access permissions
309 + - Shows report status (processing, completed, failed)
310 + - Includes generation details and statistics
311 + - Sorted by generation time (newest first)
312 +
313 + **Report Information:**
314 + - **id**: Unique report identifier
315 + - **report_name**: Name of the report
316 + - **customer_code**: Customer the report belongs to
317 + - **file_name**: CSV filename
318 + - **file_size**: Size in bytes
319 + - **generated_at**: Timestamp when report was generated
320 + - **generated_by**: User ID who generated the report
321 + - **status**: Current status (processing/completed/failed)
322 + - **total_policies**: Number of policy results in report
323 + - **total_checks**: Sum of all checks
324 + - **passed/failed/invalid_count**: Check result breakdowns
325 + - **filters_applied**: Filters used during generation
326 + - **download_url**: Endpoint to download the report
327 +
328 + **Use Cases:**
329 + - View all available reports
330 + - Check report generation status
331 + - Find specific reports by customer
332 + - Monitor report history
333 +
334 + Args:
335 + customer_code: Optional customer code filter
336 + db: Database session
337 + current_user: Current authenticated user
338 +
339 + Returns:
340 + SCAReportListResponse: List of available reports
341 + """
342 + logger.info(f"Listing SCA reports for customer: {customer_code or 'all accessible'}")
343 +
344 + try:
345 + result = await list_sca_reports(
346 + db_session=db,
347 + current_user=current_user,
348 + customer_code=customer_code,
349 + )
350 + return result
351 +
352 + except Exception as e:
353 + logger.error(f"Error listing SCA reports: {e}")
354 + raise HTTPException(status_code=500, detail=f"Failed to list reports: {e}")
355 +
356 +
357 +@sca_router.get(
358 + "/reports/{report_id}/download",
359 + description="Download an SCA report",
360 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
361 +)
362 +async def download_report(
363 + report_id: int,
364 + db: AsyncSession = Depends(get_db),
365 + current_user: User = Security(AuthHandler().get_current_user),
366 +) -> Response:
367 + """
368 + Download a specific SCA report as CSV.
369 +
370 + This endpoint retrieves a previously generated SCA report from MinIO and
371 + returns it as a downloadable CSV file.
372 +
373 + **Features:**
374 + - Downloads report as CSV file
375 + - Verifies customer access permissions
376 + - Returns proper CSV content type
377 + - Includes filename in response headers
378 +
379 + **Use Cases:**
380 + - Download reports for analysis
381 + - Share reports with stakeholders
382 + - Import into other tools
383 + - Archive compliance records
384 +
385 + **Access Control:**
386 + - Users can only download reports for customers they have access to
387 + - Admin users can download any report
388 + - Report ID must exist and belong to an accessible customer
389 +
390 + Args:
391 + report_id: ID of the report to download
392 + db: Database session
393 + current_user: Current authenticated user
394 +
395 + Returns:
396 + Response: CSV file download
397 + """
398 + logger.info(f"Downloading SCA report: {report_id}")
399 +
400 + try:
401 + result = await get_sca_report_download(
402 + db_session=db,
403 + current_user=current_user,
404 + report_id=report_id,
405 + )
406 +
407 + return Response(
408 + content=result["file_content"],
409 + media_type=result["content_type"],
410 + headers={
411 + "Content-Disposition": f"attachment; filename={result['file_name']}",
412 + },
413 + )
414 +
415 + except HTTPException:
416 + raise
417 + except Exception as e:
418 + logger.error(f"Error downloading SCA report: {e}")
419 + raise HTTPException(status_code=500, detail=f"Failed to download report: {e}")
420 +
421 +
422 +@sca_router.delete(
423 + "/reports/{report_id}",
424 + description="Delete an SCA report",
425 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
426 +)
427 +async def delete_report(
428 + report_id: int,
429 + db: AsyncSession = Depends(get_db),
430 + current_user: User = Security(AuthHandler().get_current_user),
431 +) -> Dict[str, Any]:
432 + """
433 + Delete a specific SCA report.
434 +
435 + This endpoint deletes both the report record from the database and the
436 + associated CSV file from MinIO storage.
437 +
438 + **Features:**
439 + - Deletes report from database
440 + - Removes CSV file from MinIO
441 + - Verifies customer access permissions
442 + - Graceful handling if MinIO file is already deleted
443 +
444 + **Use Cases:**
445 + - Clean up old or unnecessary reports
446 + - Remove reports with errors
447 + - Free up storage space
448 + - Manage report lifecycle
449 +
450 + **Access Control:**
451 + - Users can only delete reports for customers they have access to
452 + - Admin users can delete any report
453 + - Report ID must exist and belong to an accessible customer
454 +
455 + **Behavior:**
456 + - Deletes the database record
457 + - Attempts to delete the file from MinIO
458 + - If MinIO deletion fails, still removes database record (file may be orphaned)
459 + - Returns success if database deletion succeeds
460 +
461 + Args:
462 + report_id: ID of the report to delete
463 + db: Database session
464 + current_user: Current authenticated user
465 +
466 + Returns:
467 + Dict with success status and message
468 + """
469 + logger.info(f"Deleting SCA report: {report_id}")
470 +
471 + try:
472 + result = await delete_sca_report(
473 + db_session=db,
474 + current_user=current_user,
475 + report_id=report_id,
476 + )
477 + return result
478 +
479 + except HTTPException:
480 + raise
481 + except Exception as e:
482 + logger.error(f"Error in delete report endpoint: {e}")
483 + raise HTTPException(status_code=500, detail=f"Failed to delete report: {e}")
backend/app/agents/sca/schema/sca.py
+53
@@ -1,3 +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
@@ -78,3 +81,53 @@ class ScaStatsResponse(BaseModel):
81 by_customer: dict = {}
82 success: bool
83 message: str
84 +
85 +
86 +class SCAReportGenerateRequest(BaseModel):
87 + """Request schema for generating SCA report"""
88 +
89 + customer_code: str
90 + report_name: Optional[str] = None # Auto-generate if not provided
91 + agent_name: Optional[str] = None
92 + policy_id: Optional[str] = None
93 + min_score: Optional[int] = Field(None, ge=0, le=100, description="Minimum score filter")
94 + max_score: Optional[int] = Field(None, ge=0, le=100, description="Maximum score filter")
95 +
96 +
97 +class SCAReportResponse(BaseModel):
98 + """Response schema for SCA report details"""
99 +
100 + id: int
101 + report_name: str
102 + customer_code: str
103 + file_name: str
104 + file_size: int
105 + generated_at: datetime
106 + generated_by: int
107 + total_policies: int
108 + total_checks: int
109 + passed_count: int
110 + failed_count: int
111 + invalid_count: int
112 + filters_applied: Dict[str, Any]
113 + status: str # processing, completed, failed
114 + error_message: Optional[str] = None
115 + download_url: Optional[str] = None
116 +
117 +
118 +class SCAReportListResponse(BaseModel):
119 + """Response schema for listing SCA reports"""
120 +
121 + reports: List[SCAReportResponse]
122 + total_count: int
123 + success: bool
124 + message: str
125 +
126 +
127 +class SCAReportGenerateResponse(BaseModel):
128 + """Response schema for report generation"""
129 +
130 + success: bool
131 + message: str
132 + report: Optional[SCAReportResponse] = None
133 + error: Optional[str] = None
backend/app/agents/sca/services/sca.py
+554
@@ -1,16 +1,34 @@
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
8 from typing import List
9 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
17 from app.agents.sca.schema.sca import AgentScaOverviewItem
18 from app.agents.sca.schema.sca import ScaOverviewResponse
19 +from app.agents.sca.schema.sca import SCAReportGenerateRequest
20 +from app.agents.sca.schema.sca import SCAReportGenerateResponse
21 +from app.agents.sca.schema.sca import SCAReportListResponse
22 +from app.agents.sca.schema.sca import SCAReportResponse
23 from app.agents.sca.schema.sca import ScaStatsResponse
24 from app.agents.wazuh.services.sca import collect_agent_sca
25 +from app.auth.models.users import User
26 +from app.data_store.data_store_operations import delete_file_from_minio
27 +from app.data_store.data_store_operations import retrieve_file_from_minio
28 +from app.data_store.data_store_operations import store_file_in_minio
29 from app.db.universal_models import Agents
30 +from app.db.universal_models import SCAReport
31 +from app.middleware.customer_access import customer_access_handler
32
33
34 async def get_all_agents_from_db(
@@ -365,3 +383,539 @@ async def get_sca_statistics(
383 except Exception as e:
384 logger.error(f"Error getting SCA statistics: {e}")
385 raise HTTPException(status_code=500, detail=f"Failed to get SCA statistics: {e}")
386 +
387 +
388 +async def collect_sca_for_report(
389 + db_session: AsyncSession,
390 + current_user: User,
391 + customer_code: str,
392 + agent_name: Optional[str] = None,
393 + policy_id: Optional[str] = None,
394 + min_score: Optional[int] = None,
395 + max_score: Optional[int] = None,
396 +) -> List[AgentScaOverviewItem]:
397 + """
398 + Collect ALL SCA results from Wazuh Manager API for CSV report generation.
399 +
400 + This uses the same data collection method as the overview search,
401 + but without pagination to get all results for comprehensive reports.
402 +
403 + Args:
404 + db_session: Database session for agent lookup
405 + current_user: Current authenticated user for customer access filtering
406 + customer_code: Customer code to filter by
407 + agent_name: Optional agent hostname filter
408 + policy_id: Optional policy ID filter
409 + min_score: Optional minimum score filter
410 + max_score: Optional maximum score filter
411 +
412 + Returns:
413 + List of all matching SCA policy results (no pagination)
414 + """
415 + logger.info(
416 + f"Collecting ALL SCA results from Wazuh Manager for report: customer_code={customer_code}, "
417 + f"agent_name={agent_name}, policy_id={policy_id}, "
418 + f"min_score={min_score}, max_score={max_score}",
419 + )
420 +
421 + # Apply customer access filtering
422 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
423 +
424 + if "*" not in accessible_customers and customer_code not in accessible_customers:
425 + logger.warning(f"User {current_user.username} denied access to customer {customer_code}")
426 + return []
427 +
428 + try:
429 + # Use existing collect function to get all SCA results from Wazuh Manager
430 + all_sca_results = await collect_sca_for_all_agents(
431 + db_session=db_session,
432 + customer_code=customer_code,
433 + agent_name=agent_name,
434 + policy_id=policy_id,
435 + policy_name=None, # Not used for reports
436 + min_score=min_score,
437 + max_score=max_score,
438 + )
439 +
440 + logger.info(f"Successfully collected {len(all_sca_results)} SCA policy results from Wazuh Manager for report")
441 + return all_sca_results
442 +
443 + except Exception as e:
444 + logger.error(f"Error collecting SCA results for report: {e}")
445 + raise
446 +
447 +
448 +async def generate_sca_csv_report(
449 + db_session: AsyncSession,
450 + current_user: User,
451 + request: SCAReportGenerateRequest,
452 + report_id: Optional[int] = None,
453 +) -> SCAReportGenerateResponse:
454 + """
455 + Generate a CSV SCA report for a specific customer and store it in MinIO.
456 +
457 + Uses Wazuh Manager API to fetch all SCA policy-level data for comprehensive reports.
458 +
459 + Args:
460 + db_session: Database session
461 + current_user: Current authenticated user
462 + request: Report generation request with filters
463 + report_id: Optional existing report ID (for background task updates)
464 +
465 + Returns:
466 + SCAReportGenerateResponse with report details
467 + """
468 + try:
469 + # Verify customer access
470 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
471 +
472 + if "*" not in accessible_customers and request.customer_code not in accessible_customers:
473 + # If we have a report_id, update it to failed status
474 + if report_id:
475 + stmt = select(SCAReport).filter(SCAReport.id == report_id)
476 + result = await db_session.execute(stmt)
477 + report = result.scalars().first()
478 + if report:
479 + report.status = "failed"
480 + report.error_message = "Insufficient permissions"
481 + await db_session.commit()
482 +
483 + return SCAReportGenerateResponse(
484 + success=False,
485 + message=f"Access denied to customer {request.customer_code}",
486 + error="Insufficient permissions",
487 + )
488 +
489 + # If report_id exists, get the existing report name and object_key
490 + # Otherwise generate new ones
491 + if report_id:
492 + stmt = select(SCAReport).filter(SCAReport.id == report_id)
493 + result = await db_session.execute(stmt)
494 + existing_report = result.scalars().first()
495 +
496 + if not existing_report:
497 + return SCAReportGenerateResponse(
498 + success=False,
499 + message=f"Report ID {report_id} not found",
500 + error="Report not found",
501 + )
502 +
503 + # Use existing report name and paths
504 + report_name = existing_report.report_name
505 + file_name = existing_report.file_name
506 + object_key = existing_report.object_key
507 + bucket_name = existing_report.bucket_name
508 +
509 + logger.info(f"Generating SCA report for existing record: {report_name} (ID: {report_id})")
510 + else:
511 + # Generate new report name for synchronous generation
512 + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
513 + report_name = request.report_name or f"sca_report_{timestamp}"
514 + file_name = f"{report_name}.csv"
515 + object_key = f"{request.customer_code}/{file_name}"
516 + bucket_name = "sca-reports"
517 +
518 + logger.info(f"Generating new SCA report: {report_name}")
519 +
520 + # Fetch ALL SCA results from Wazuh Manager API (no pagination limits)
521 + all_sca_results = await collect_sca_for_report(
522 + db_session=db_session,
523 + current_user=current_user,
524 + customer_code=request.customer_code,
525 + agent_name=request.agent_name,
526 + policy_id=request.policy_id,
527 + min_score=request.min_score,
528 + max_score=request.max_score,
529 + )
530 +
531 + logger.info(f"Fetched {len(all_sca_results)} SCA policy results from Wazuh Manager for report")
532 +
533 + if not all_sca_results:
534 + # If we have a report_id, update it to failed status
535 + if report_id:
536 + stmt = select(SCAReport).filter(SCAReport.id == report_id)
537 + result = await db_session.execute(stmt)
538 + report = result.scalars().first()
539 + if report:
540 + report.status = "failed"
541 + report.error_message = "No SCA results found matching criteria"
542 + await db_session.commit()
543 +
544 + return SCAReportGenerateResponse(
545 + success=False,
546 + message="No SCA results found matching the specified criteria",
547 + error="No data to export",
548 + )
549 +
550 + # Generate CSV content
551 + csv_buffer = io.StringIO()
552 + csv_writer = csv.writer(csv_buffer)
553 +
554 + # Write headers
555 + headers = [
556 + "Agent ID",
557 + "Agent Name",
558 + "Customer Code",
559 + "Policy ID",
560 + "Policy Name",
561 + "Description",
562 + "Total Checks",
563 + "Passed",
564 + "Failed",
565 + "Invalid",
566 + "Score",
567 + "Start Scan",
568 + "End Scan",
569 + "References",
570 + "Hash File",
571 + ]
572 + csv_writer.writerow(headers)
573 +
574 + # Count totals
575 + total_policies = len(all_sca_results)
576 + total_checks = sum(result.total_checks for result in all_sca_results)
577 + passed_count = sum(result.pass_count for result in all_sca_results)
578 + failed_count = sum(result.fail_count for result in all_sca_results)
579 + invalid_count = sum(result.invalid_count for result in all_sca_results)
580 +
581 + # Write data rows
582 + for result in all_sca_results:
583 + try:
584 + row = [
585 + result.agent_id,
586 + result.agent_name,
587 + result.customer_code or "",
588 + result.policy_id,
589 + result.policy_name,
590 + result.description,
591 + result.total_checks,
592 + result.pass_count,
593 + result.fail_count,
594 + result.invalid_count,
595 + result.score,
596 + result.start_scan,
597 + result.end_scan,
598 + result.references or "",
599 + result.hash_file or "",
600 + ]
601 + csv_writer.writerow(row)
602 +
603 + except Exception as row_error:
604 + logger.error(f"Error processing SCA row: {row_error}")
605 + continue
606 +
607 + # Get CSV content as bytes
608 + csv_content = csv_buffer.getvalue().encode("utf-8")
609 + csv_buffer.close()
610 +
611 + # Calculate file hash
612 + file_hash = hashlib.sha256(csv_content).hexdigest()
613 +
614 + # Store in MinIO
615 + minio_result = await store_file_in_minio(
616 + file_content=csv_content,
617 + bucket_name=bucket_name,
618 + object_key=object_key,
619 + content_type="text/csv",
620 + )
621 +
622 + if not minio_result["success"]:
623 + # If we have a report_id, update it to failed status
624 + if report_id:
625 + stmt = select(SCAReport).filter(SCAReport.id == report_id)
626 + result = await db_session.execute(stmt)
627 + report = result.scalars().first()
628 + if report:
629 + report.status = "failed"
630 + report.error_message = minio_result.get("error", "Unknown error")
631 + await db_session.commit()
632 +
633 + return SCAReportGenerateResponse(
634 + success=False,
635 + message="Failed to store report in MinIO",
636 + error=minio_result.get("error", "Unknown error"),
637 + )
638 +
639 + # Build filters JSON
640 + filters = {}
641 + if request.agent_name:
642 + filters["agent_name"] = request.agent_name
643 + if request.policy_id:
644 + filters["policy_id"] = request.policy_id
645 + if request.min_score is not None:
646 + filters["min_score"] = request.min_score
647 + if request.max_score is not None:
648 + filters["max_score"] = request.max_score
649 +
650 + # Check if we're updating an existing report or creating a new one
651 + if report_id:
652 + # Update existing report (background task scenario)
653 + stmt = select(SCAReport).filter(SCAReport.id == report_id)
654 + result = await db_session.execute(stmt)
655 + report_record = result.scalars().first()
656 +
657 + if report_record:
658 + report_record.file_size = len(csv_content)
659 + report_record.file_hash = file_hash
660 + report_record.total_policies = total_policies
661 + report_record.total_checks = total_checks
662 + report_record.passed_count = passed_count
663 + report_record.failed_count = failed_count
664 + report_record.invalid_count = invalid_count
665 + report_record.status = "completed"
666 + report_record.error_message = None
667 +
668 + await db_session.commit()
669 + await db_session.refresh(report_record)
670 +
671 + logger.info(f"Successfully updated SCA report: {report_name} (ID: {report_id})")
672 + else:
673 + logger.error(f"Report ID {report_id} not found for update")
674 + return SCAReportGenerateResponse(
675 + success=False,
676 + message=f"Report ID {report_id} not found",
677 + error="Report not found",
678 + )
679 + else:
680 + # Create new database record (synchronous scenario)
681 + report_record = SCAReport(
682 + report_name=report_name,
683 + customer_code=request.customer_code,
684 + bucket_name=bucket_name,
685 + object_key=object_key,
686 + file_name=file_name,
687 + file_size=len(csv_content),
688 + file_hash=file_hash,
689 + generated_by=current_user.id,
690 + filters_json=json.dumps(filters),
691 + total_policies=total_policies,
692 + total_checks=total_checks,
693 + passed_count=passed_count,
694 + failed_count=failed_count,
695 + invalid_count=invalid_count,
696 + status="completed",
697 + )
698 +
699 + db_session.add(report_record)
700 + await db_session.commit()
701 + await db_session.refresh(report_record)
702 +
703 + logger.info(f"Successfully generated SCA report: {report_name}")
704 +
705 + # Build response
706 + report_response = SCAReportResponse(
707 + id=report_record.id,
708 + report_name=report_record.report_name,
709 + customer_code=report_record.customer_code,
710 + file_name=report_record.file_name,
711 + file_size=report_record.file_size,
712 + generated_at=report_record.generated_at,
713 + generated_by=report_record.generated_by,
714 + total_policies=report_record.total_policies,
715 + total_checks=report_record.total_checks,
716 + passed_count=report_record.passed_count,
717 + failed_count=report_record.failed_count,
718 + invalid_count=report_record.invalid_count,
719 + filters_applied=json.loads(report_record.filters_json or "{}"),
720 + status=report_record.status,
721 + download_url=f"/api/v1/sca/reports/{report_record.id}/download",
722 + )
723 +
724 + return SCAReportGenerateResponse(
725 + success=True,
726 + message=f"Successfully generated report with {total_policies} SCA policy results",
727 + report=report_response,
728 + )
729 +
730 + except Exception as e:
731 + logger.error(f"Error generating SCA report: {e}")
732 +
733 + # If we have a report_id, update it to failed status
734 + if report_id:
735 + try:
736 + stmt = select(SCAReport).filter(SCAReport.id == report_id)
737 + result = await db_session.execute(stmt)
738 + report = result.scalars().first()
739 + if report:
740 + report.status = "failed"
741 + report.error_message = str(e)
742 + await db_session.commit()
743 + except Exception as update_error:
744 + logger.error(f"Failed to update report status: {update_error}")
745 +
746 + return SCAReportGenerateResponse(
747 + success=False,
748 + message="Failed to generate SCA report",
749 + error=str(e),
750 + )
751 +
752 +
753 +async def list_sca_reports(
754 + db_session: AsyncSession,
755 + current_user: User,
756 + customer_code: Optional[str] = None,
757 +) -> SCAReportListResponse:
758 + """List available SCA reports"""
759 + try:
760 + # Get accessible customers
761 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
762 +
763 + # Build query
764 + query = select(SCAReport).order_by(desc(SCAReport.generated_at))
765 +
766 + # Apply customer filtering
767 + if "*" not in accessible_customers:
768 + query = query.filter(SCAReport.customer_code.in_(accessible_customers))
769 +
770 + if customer_code:
771 + if "*" not in accessible_customers and customer_code not in accessible_customers:
772 + return SCAReportListResponse(
773 + reports=[],
774 + total_count=0,
775 + success=True,
776 + message=f"Access denied to customer {customer_code}",
777 + )
778 + query = query.filter(SCAReport.customer_code == customer_code)
779 +
780 + result = await db_session.execute(query)
781 + reports = result.scalars().all()
782 +
783 + report_list = []
784 + for report in reports:
785 + report_response = SCAReportResponse(
786 + id=report.id,
787 + report_name=report.report_name,
788 + customer_code=report.customer_code,
789 + file_name=report.file_name,
790 + file_size=report.file_size,
791 + generated_at=report.generated_at,
792 + generated_by=report.generated_by,
793 + total_policies=report.total_policies,
794 + total_checks=report.total_checks,
795 + passed_count=report.passed_count,
796 + failed_count=report.failed_count,
797 + invalid_count=report.invalid_count,
798 + filters_applied=json.loads(report.filters_json or "{}"),
799 + status=report.status,
800 + error_message=report.error_message,
801 + download_url=f"/api/v1/sca/reports/{report.id}/download",
802 + )
803 + report_list.append(report_response)
804 +
805 + return SCAReportListResponse(
806 + reports=report_list,
807 + total_count=len(report_list),
808 + success=True,
809 + message=f"Found {len(report_list)} SCA reports",
810 + )
811 +
812 + except Exception as e:
813 + logger.error(f"Error listing SCA reports: {e}")
814 + return SCAReportListResponse(
815 + reports=[],
816 + total_count=0,
817 + success=False,
818 + message=f"Failed to list reports: {e}",
819 + )
820 +
821 +
822 +async def get_sca_report_download(
823 + db_session: AsyncSession,
824 + current_user: User,
825 + report_id: int,
826 +) -> Dict[str, Any]:
827 + """Get SCA report for download"""
828 + try:
829 + # Get report record
830 + result = await db_session.execute(select(SCAReport).filter(SCAReport.id == report_id))
831 + report = result.scalars().first()
832 +
833 + if not report:
834 + raise HTTPException(status_code=404, detail="Report not found")
835 +
836 + # Verify customer access
837 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
838 +
839 + if "*" not in accessible_customers and report.customer_code not in accessible_customers:
840 + raise HTTPException(status_code=403, detail="Access denied to this report")
841 +
842 + # Retrieve file from MinIO
843 + file_data = await retrieve_file_from_minio(
844 + bucket_name=report.bucket_name,
845 + object_key=report.object_key,
846 + )
847 +
848 + if not file_data["success"]:
849 + raise HTTPException(status_code=500, detail="Failed to retrieve report file")
850 +
851 + return {
852 + "file_content": file_data["file_content"],
853 + "file_name": report.file_name,
854 + "content_type": "text/csv",
855 + }
856 +
857 + except HTTPException:
858 + raise
859 + except Exception as e:
860 + logger.error(f"Error retrieving SCA report: {e}")
861 + raise HTTPException(status_code=500, detail=f"Failed to retrieve report: {e}")
862 +
863 +
864 +async def delete_sca_report(
865 + db_session: AsyncSession,
866 + current_user: User,
867 + report_id: int,
868 +) -> Dict[str, Any]:
869 + """
870 + Delete an SCA report and its associated file from MinIO.
871 +
872 + Args:
873 + db_session: Database session
874 + current_user: Current authenticated user
875 + report_id: ID of the report to delete
876 +
877 + Returns:
878 + Dict with success status and message
879 + """
880 + try:
881 + # Get report record
882 + result = await db_session.execute(select(SCAReport).filter(SCAReport.id == report_id))
883 + report = result.scalars().first()
884 +
885 + if not report:
886 + raise HTTPException(status_code=404, detail="Report not found")
887 +
888 + # Verify customer access
889 + accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db_session)
890 +
891 + if "*" not in accessible_customers and report.customer_code not in accessible_customers:
892 + raise HTTPException(status_code=403, detail="Access denied to this report")
893 +
894 + # Delete file from MinIO
895 + logger.info(f"Deleting SCA report file from MinIO: {report.bucket_name}/{report.object_key}")
896 +
897 + minio_result = await delete_file_from_minio(
898 + bucket_name=report.bucket_name,
899 + object_key=report.object_key,
900 + )
901 +
902 + if not minio_result["success"]:
903 + logger.warning(f"Failed to delete file from MinIO: {minio_result.get('message')}")
904 + # Continue with database deletion even if MinIO deletion fails
905 +
906 + # Delete database record
907 + await db_session.delete(report)
908 + await db_session.commit()
909 +
910 + logger.info(f"Successfully deleted SCA report: {report.report_name} (ID: {report_id})")
911 +
912 + return {
913 + "success": True,
914 + "message": f"Successfully deleted report '{report.report_name}'",
915 + }
916 +
917 + except HTTPException:
918 + raise
919 + except Exception as e:
920 + logger.error(f"Error deleting SCA report: {e}")
921 + raise HTTPException(status_code=500, detail=f"Failed to delete report: {e}")
backend/app/db/universal_models.py
+38
@@ -462,3 +462,41 @@ class VulnerabilityReport(SQLModel, table=True):
462
463 # Relationship to Customers table
464 customer: Optional["Customers"] = Relationship()
465 +
466 +
467 +class SCAReport(SQLModel, table=True):
468 + __tablename__ = "sca_reports"
469 +
470 + id: Optional[int] = Field(primary_key=True)
471 +
472 + # Report metadata
473 + report_name: str = Field(max_length=255, nullable=False)
474 + customer_code: str = Field(foreign_key="customers.customer_code", max_length=50, index=True, nullable=False)
475 +
476 + # MinIO storage details
477 + bucket_name: str = Field(max_length=255, nullable=False, default="sca-reports")
478 + object_key: str = Field(max_length=1024, nullable=False) # Path: customer_code/report_name_timestamp.csv
479 + file_name: str = Field(max_length=255, nullable=False) # CSV filename
480 + file_size: int = Field(nullable=False, default=0) # File size in bytes
481 + file_hash: str = Field(max_length=128, nullable=False, default="pending") # SHA-256 hash
482 +
483 + # Report generation details
484 + generated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
485 + generated_by: int = Field(nullable=False) # User ID who generated the report
486 +
487 + # Report filters applied
488 + filters_json: Optional[str] = Field(sa_column=Column(Text), nullable=True) # JSON string of filters used
489 +
490 + # SCA Statistics
491 + total_policies: int = Field(default=0) # Number of policy results in report
492 + total_checks: int = Field(default=0) # Sum of all checks across policies
493 + passed_count: int = Field(default=0) # Sum of passed checks
494 + failed_count: int = Field(default=0) # Sum of failed checks
495 + invalid_count: int = Field(default=0) # Sum of invalid/not applicable checks
496 +
497 + # Status tracking (for background generation)
498 + status: str = Field(max_length=50, default="processing", index=True) # processing, completed, failed
499 + error_message: Optional[str] = Field(sa_column=Column(Text), nullable=True)
500 +
501 + # Relationship to Customers table
502 + 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.28"
10 +CURRENT_VERSION = "0.1.29"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/sca.ts
+67 -30
@@ -1,35 +1,72 @@
1 -import type { ScaOverviewQuery, ScaOverviewResponse, ScaStatsResponse } from "@/types/sca.d"
1 +import type {
2 + ScaOverviewQuery,
3 + ScaOverviewResponse,
4 + SCAReportDeleteResponse,
5 + SCAReportGenerateRequest,
6 + SCAReportGenerateResponse,
7 + SCAReportListResponse,
8 + ScaStatsResponse
9 +} from "@/types/sca.d"
10 import { HttpClient } from "../httpClient"
11
12 export default {
5 - /**
6 - * Search SCA results across all agents with filtering and pagination
7 - */
8 - searchScaOverview(query?: ScaOverviewQuery, signal?: AbortSignal) {
9 - return HttpClient.get<ScaOverviewResponse>(`/sca/overview`, {
10 - params: {
11 - customer_code: query?.customer_code,
12 - agent_name: query?.agent_name,
13 - policy_id: query?.policy_id,
14 - policy_name: query?.policy_name,
15 - min_score: query?.min_score,
16 - max_score: query?.max_score,
17 - page: query?.page || 1,
18 - page_size: query?.page_size || 50
19 - },
20 - signal
21 - })
22 - },
13 + /**
14 + * Search SCA results across all agents with filtering and pagination
15 + */
16 + searchScaOverview(query?: ScaOverviewQuery, signal?: AbortSignal) {
17 + return HttpClient.get<ScaOverviewResponse>(`/sca/overview`, {
18 + params: {
19 + customer_code: query?.customer_code,
20 + agent_name: query?.agent_name,
21 + policy_id: query?.policy_id,
22 + policy_name: query?.policy_name,
23 + min_score: query?.min_score,
24 + max_score: query?.max_score,
25 + page: query?.page || 1,
26 + page_size: query?.page_size || 50
27 + },
28 + signal
29 + })
30 + },
31
24 - /**
25 - * Get SCA statistics across all agents or for a specific customer
26 - */
27 - getScaStats(customer_code?: string, signal?: AbortSignal) {
28 - return HttpClient.get<ScaStatsResponse>(`/sca/stats`, {
29 - params: {
30 - customer_code
31 - },
32 - signal
33 - })
34 - }
32 + /**
33 + * Get SCA statistics
34 + */
35 + getScaStats(customer_code?: string) {
36 + return HttpClient.get<ScaStatsResponse>(`/sca/stats`, {
37 + params: customer_code ? { customer_code } : undefined
38 + })
39 + },
40 +
41 + /**
42 + * Generate an SCA report (synchronous)
43 + */
44 + generateReport(request: SCAReportGenerateRequest) {
45 + return HttpClient.post<SCAReportGenerateResponse>(`/sca/reports/generate`, request)
46 + },
47 +
48 + /**
49 + * List all SCA reports
50 + */
51 + listReports(customer_code?: string) {
52 + return HttpClient.get<SCAReportListResponse>(`/sca/reports`, {
53 + params: customer_code ? { customer_code } : undefined
54 + })
55 + },
56 +
57 + /**
58 + * Download an SCA report
59 + */
60 + downloadReport(reportId: number) {
61 + return HttpClient.get<Blob>(`/sca/reports/${reportId}/download`, {
62 + responseType: "blob"
63 + })
64 + },
65 +
66 + /**
67 + * Delete an SCA report
68 + */
69 + deleteReport(reportId: number) {
70 + return HttpClient.delete<SCAReportDeleteResponse>(`/sca/reports/${reportId}`)
71 + }
72 }
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -494,6 +494,19 @@ export default function getItems(): MenuMixedOption[] {
494 { default: () => "Vulnerability Reports" }
495 ),
496 key: "VulnerabilityReports"
497 + },
498 + {
499 + label: () =>
500 + h(
501 + RouterLink,
502 + {
503 + to: {
504 + name: "SCAReports"
505 + }
506 + },
507 + { default: () => "SCA Reports" }
508 + ),
509 + key: "SCAReports"
510 }
511 ]
512 },
frontend/src/components/sca/GenerateReportForm.vue new
+144
@@ -0,0 +1,144 @@
1 +<template>
2 + <n-form ref="formRef" :model="formValue" :rules="rules" label-placement="top">
3 + <n-form-item label="Customer" path="customer_code">
4 + <n-select
5 + v-model:value="formValue.customer_code"
6 + :options="customers"
7 + placeholder="Select customer"
8 + filterable
9 + clearable
10 + />
11 + </n-form-item>
12 +
13 + <n-form-item label="Report Name (Optional)" path="report_name">
14 + <n-input
15 + v-model:value="formValue.report_name"
16 + placeholder="Leave empty for auto-generated name"
17 + clearable
18 + />
19 + </n-form-item>
20 +
21 + <n-divider>Filters (Optional)</n-divider>
22 +
23 + <n-form-item label="Agent Name" path="agent_name">
24 + <n-input
25 + v-model:value="formValue.agent_name"
26 + placeholder="Filter by specific agent"
27 + clearable
28 + />
29 + </n-form-item>
30 +
31 + <n-form-item label="Policy ID" path="policy_id">
32 + <n-input
33 + v-model:value="formValue.policy_id"
34 + placeholder="Filter by specific policy ID"
35 + clearable
36 + />
37 + </n-form-item>
38 +
39 + <n-form-item label="Minimum Score" path="min_score">
40 + <n-input-number
41 + v-model:value="formValue.min_score"
42 + :min="0"
43 + :max="100"
44 + placeholder="Filter by minimum compliance score"
45 + clearable
46 + class="w-full"
47 + />
48 + </n-form-item>
49 +
50 + <n-form-item label="Maximum Score" path="max_score">
51 + <n-input-number
52 + v-model:value="formValue.max_score"
53 + :min="0"
54 + :max="100"
55 + placeholder="Filter by maximum compliance score"
56 + clearable
57 + class="w-full"
58 + />
59 + </n-form-item>
60 +
61 + <n-space justify="end" class="mt-4">
62 + <n-button @click="$emit('cancel')">Cancel</n-button>
63 + <n-button type="primary" :loading="loading" @click="handleGenerate">
64 + <template #icon>
65 + <Icon :name="GenerateIcon" />
66 + </template>
67 + Generate Report
68 + </n-button>
69 + </n-space>
70 + </n-form>
71 +</template>
72 +
73 +<script setup lang="ts">
74 +import type { FormInst, FormRules } from "naive-ui"
75 +import type { SCAReportGenerateRequest } from "@/types/sca.d"
76 +import { NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NSelect, NSpace } from "naive-ui"
77 +import { ref } from "vue"
78 +import Icon from "@/components/common/Icon.vue"
79 +
80 +defineProps<Props>()
81 +
82 +const emit = defineEmits<{
83 + generate: [request: SCAReportGenerateRequest]
84 + cancel: []
85 +}>()
86 +
87 +const GenerateIcon = "carbon:document-add"
88 +
89 +interface Props {
90 + customers: Array<{ label: string; value: string }>
91 + loading?: boolean
92 +}
93 +
94 +const formRef = ref<FormInst | null>(null)
95 +const formValue = ref<SCAReportGenerateRequest>({
96 + customer_code: "",
97 + report_name: undefined,
98 + agent_name: undefined,
99 + policy_id: undefined,
100 + min_score: undefined,
101 + max_score: undefined
102 +})
103 +
104 +const rules: FormRules = {
105 + customer_code: [
106 + {
107 + required: true,
108 + message: "Please select a customer",
109 + trigger: ["blur", "change"]
110 + }
111 + ]
112 +}
113 +
114 +async function handleGenerate() {
115 + try {
116 + await formRef.value?.validate()
117 +
118 + // Clean up empty optional fields
119 + const request: SCAReportGenerateRequest = {
120 + customer_code: formValue.value.customer_code
121 + }
122 +
123 + if (formValue.value.report_name) request.report_name = formValue.value.report_name
124 + if (formValue.value.agent_name) request.agent_name = formValue.value.agent_name
125 + if (formValue.value.policy_id) request.policy_id = formValue.value.policy_id
126 + if (formValue.value.min_score !== undefined && formValue.value.min_score !== null) {
127 + request.min_score = formValue.value.min_score
128 + }
129 + if (formValue.value.max_score !== undefined && formValue.value.max_score !== null) {
130 + request.max_score = formValue.value.max_score
131 + }
132 +
133 + emit("generate", request)
134 + } catch (error) {
135 + console.error("Form validation failed:", error)
136 + }
137 +}
138 +</script>
139 +
140 +<style scoped>
141 +.w-full {
142 + width: 100%;
143 +}
144 +</style>
frontend/src/components/sca/SCAReports.vue new
+348
@@ -0,0 +1,348 @@
1 +<template>
2 + <div class="sca-reports">
3 + <div class="header mb-6 flex items-center justify-between">
4 + <div>
5 + <h2 class="text-2xl font-bold mb-2">SCA Reports</h2>
6 + <p class="text-secondary">Generate, manage, and download Security Configuration Assessment 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: SCAReport) => 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 SCA 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 { SCAReport, SCAReportGenerateRequest } from "@/types/sca.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<SCAReport[]>([])
103 +const showGenerateModal = ref(false)
104 +const showDeleteModal = ref(false)
105 +const reportToDelete = ref<SCAReport | null>(null)
106 +const filterCustomerCode = ref<string | null>(null)
107 +const customers = ref<Array<{ label: string; value: string }>>([])
108 +
109 +const customerOptions = computed(() => [...customers.value])
110 +
111 +const pagination = {
112 + pageSize: 20,
113 + showSizePicker: true,
114 + pageSizes: [10, 20, 50, 100]
115 +}
116 +
117 +const columns: DataTableColumns<SCAReport> = [
118 + {
119 + title: "Report Name",
120 + key: "report_name",
121 + ellipsis: {
122 + tooltip: true
123 + }
124 + },
125 + {
126 + title: "Customer",
127 + key: "customer_code",
128 + width: 120
129 + },
130 + {
131 + title: "Status",
132 + key: "status",
133 + width: 120,
134 + render: (row: SCAReport) => {
135 + const statusMap = {
136 + completed: { type: "success", icon: CheckIcon, text: "Completed" },
137 + processing: { type: "warning", icon: LoadingIcon, text: "Processing" },
138 + failed: { type: "error", icon: ErrorIcon, text: "Failed" }
139 + }
140 + const status = statusMap[row.status]
141 + return h(
142 + NTag,
143 + { type: status.type as any, size: "small" },
144 + {
145 + default: () => status.text,
146 + icon: () => h(Icon, { name: status.icon, size: "14" })
147 + }
148 + )
149 + }
150 + },
151 + {
152 + title: "Policies",
153 + key: "total_policies",
154 + width: 100,
155 + render: (row: SCAReport) => row.total_policies.toLocaleString()
156 + },
157 + {
158 + title: "Checks",
159 + key: "total_checks",
160 + width: 140,
161 + render: (row: SCAReport) => {
162 + return h(
163 + NTooltip,
164 + {},
165 + {
166 + trigger: () => row.total_checks.toLocaleString(),
167 + default: () =>
168 + h("div", {}, [
169 + h("div", {}, `Passed: ${row.passed_count}`),
170 + h("div", {}, `Failed: ${row.failed_count}`),
171 + h("div", {}, `Invalid: ${row.invalid_count}`)
172 + ])
173 + }
174 + )
175 + }
176 + },
177 + {
178 + title: "File Size",
179 + key: "file_size",
180 + width: 120,
181 + render: (row: SCAReport) => formatBytes(row.file_size)
182 + },
183 + {
184 + title: "Generated",
185 + key: "generated_at",
186 + width: 180,
187 + render: (row: SCAReport) => formatDate(row.generated_at)
188 + },
189 + {
190 + title: "Actions",
191 + key: "actions",
192 + width: 140,
193 + render: (row: SCAReport) => {
194 + return h(
195 + NSpace,
196 + { size: "small" },
197 + {
198 + default: () => [
199 + row.status === "completed"
200 + ? h(
201 + NButton,
202 + {
203 + size: "small",
204 + type: "primary",
205 + onClick: () => handleDownload(row)
206 + },
207 + {
208 + icon: () => h(Icon, { name: DownloadIcon })
209 + }
210 + )
211 + : null,
212 + h(
213 + NButton,
214 + {
215 + size: "small",
216 + type: "error",
217 + onClick: () => handleDeleteClick(row)
218 + },
219 + {
220 + icon: () => h(Icon, { name: DeleteIcon })
221 + }
222 + )
223 + ]
224 + }
225 + )
226 + }
227 + }
228 +]
229 +
230 +async function loadReports() {
231 + loading.value = true
232 + try {
233 + const response = await Api.sca.listReports(filterCustomerCode.value || undefined)
234 + if (response.data.success) {
235 + reports.value = response.data.reports
236 + } else {
237 + message.error(response.data.message || "Failed to load reports")
238 + }
239 + } catch (error: any) {
240 + message.error(error?.response?.data?.detail || "Failed to load reports")
241 + } finally {
242 + loading.value = false
243 + }
244 +}
245 +
246 +async function loadCustomers() {
247 + try {
248 + const response = await Api.customers.getCustomers()
249 + let customerData = response.data
250 +
251 + if (customerData.data) {
252 + customerData = customerData.data
253 + }
254 +
255 + if (Array.isArray(customerData)) {
256 + customers.value = customerData.map((c: any) => ({
257 + label: `${c.customer_name || c.name || c.customer_code} (${c.customer_code})`,
258 + value: c.customer_code
259 + }))
260 + } else if (customerData.customers && Array.isArray(customerData.customers)) {
261 + customers.value = customerData.customers.map((c: any) => ({
262 + label: `${c.customer_name || c.name || c.customer_code} (${c.customer_code})`,
263 + value: c.customer_code
264 + }))
265 + } else {
266 + console.warn("Unexpected customer data structure:", customerData)
267 + message.warning("Customer data format unexpected")
268 + }
269 + } catch (error: any) {
270 + console.error("Failed to load customers:", error)
271 + message.error("Failed to load customers list")
272 + }
273 +}
274 +
275 +async function handleGenerateReport(request: SCAReportGenerateRequest) {
276 + generating.value = true
277 + try {
278 + const response = await Api.sca.generateReport(request)
279 +
280 + if (response.data.success) {
281 + message.success(response.data.message)
282 + showGenerateModal.value = false
283 + await loadReports()
284 + } else {
285 + message.error(response.data.error || "Failed to generate report")
286 + }
287 + } catch (error: any) {
288 + message.error(error?.response?.data?.detail || "Failed to generate report")
289 + } finally {
290 + generating.value = false
291 + }
292 +}
293 +
294 +async function handleDownload(report: SCAReport) {
295 + try {
296 + const response = await Api.sca.downloadReport(report.id)
297 +
298 + const url = window.URL.createObjectURL(new Blob([response.data]))
299 + const link = document.createElement("a")
300 + link.href = url
301 + link.setAttribute("download", report.file_name)
302 + document.body.appendChild(link)
303 + link.click()
304 + link.remove()
305 + window.URL.revokeObjectURL(url)
306 +
307 + message.success("Report downloaded successfully")
308 + } catch (error: any) {
309 + message.error(error?.response?.data?.detail || "Failed to download report")
310 + }
311 +}
312 +
313 +function handleDeleteClick(report: SCAReport) {
314 + reportToDelete.value = report
315 + showDeleteModal.value = true
316 +}
317 +
318 +async function confirmDelete() {
319 + if (!reportToDelete.value) return
320 +
321 + try {
322 + const response = await Api.sca.deleteReport(reportToDelete.value.id)
323 +
324 + if (response.data.success) {
325 + message.success(response.data.message)
326 + await loadReports()
327 + } else {
328 + message.error("Failed to delete report")
329 + }
330 + } catch (error: any) {
331 + message.error(error?.response?.data?.detail || "Failed to delete report")
332 + } finally {
333 + showDeleteModal.value = false
334 + reportToDelete.value = null
335 + }
336 +}
337 +
338 +onMounted(() => {
339 + loadReports()
340 + loadCustomers()
341 +})
342 +</script>
343 +
344 +<style scoped>
345 +.sca-reports {
346 + padding: 20px;
347 +}
348 +</style>
frontend/src/router/index.ts
+7 -1
@@ -297,7 +297,13 @@ const router = createRouter({
297 name: "VulnerabilityReports",
298 component: () => import("@/components/vulnerabilities/VulnerabilityReports.vue"),
299 meta: { title: "Vulnerability Reports" }
300 - }
300 + },
301 + {
302 + path: "sca-reports",
303 + name: "SCAReports",
304 + component: () => import("@/components/sca/SCAReports.vue"),
305 + meta: { title: "SCA Reports" }
306 + }
307 ]
308 },
309 {
frontend/src/types/sca.d.ts
+47
@@ -71,6 +71,53 @@ export interface ScaStatsResponse {
71 message: string
72 }
73
74 +export interface SCAReport {
75 + id: number
76 + report_name: string
77 + customer_code: string
78 + file_name: string
79 + file_size: number
80 + generated_at: string
81 + generated_by: number
82 + total_policies: number
83 + total_checks: number
84 + passed_count: number
85 + failed_count: number
86 + invalid_count: number
87 + filters_applied: Record<string, string | number | boolean>
88 + status: "processing" | "completed" | "failed"
89 + error_message?: string | null
90 + download_url: string
91 +}
92 +
93 +export interface SCAReportGenerateRequest {
94 + customer_code: string
95 + report_name?: string
96 + agent_name?: string
97 + policy_id?: string
98 + min_score?: number
99 + max_score?: number
100 +}
101 +
102 +export interface SCAReportGenerateResponse {
103 + success: boolean
104 + message: string
105 + report?: SCAReport
106 + error?: string
107 +}
108 +
109 +export interface SCAReportListResponse {
110 + success: boolean
111 + message: string
112 + reports: SCAReport[]
113 + total_count: number
114 +}
115 +
116 +export interface SCAReportDeleteResponse {
117 + success: boolean
118 + message: string
119 +}
120 +
121 export enum ScaComplianceLevel {
122 Excellent = "Excellent", // 90-100%
123 Good = "Good", // 80-89%