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%