@cryptotaxi247 / CoPilot / commits / ecc3c441

743 sca recommendations (#759)

* Add endpoints to fetch SCA policies and policy content from CoPilot-SCA repository * Add syscollector packages endpoint and response schema for agent packages * Add search endpoint for packages in Wazuh Indexer with filtering options * Add SCA package registry and agent detection endpoints * Add SCA policies management features including listing, details, and agent detection * Refactor agent detection logic to use agent ID for uniqueness * Add SCA section to Navbar with links for Overview and Policies * Update SCA Policies documentation and add deployment instructions for Wazuh agents * Bump version to 0.1.48 * Add SCA Policies documentation and link to power features

taylor_socfortress committed Mar 11, 2026 at 09:09 UTC ecc3c441f5d9d2e1eb984eda2841057e3729e7c7
20 files changed +1754 -14
backend/app/agents/sca/models/sca_package_registry.py new
+78
@@ -0,0 +1,78 @@
1 +"""
2 +Registry that maps SCA application categories to the package names you would
3 +expect to find on agents via the Wazuh Indexer.
4 +
5 +To add support for a new application:
6 + 1. Add a new entry to ``SCA_PACKAGE_REGISTRY`` below.
7 + 2. Set ``sca_application`` to the value used in the CoPilot-SCA index.json
8 + ``application`` field so the link back to available policies is automatic.
9 + 3. List every package name pattern (lowercase) that indicates the software
10 + is installed. These are matched with an OpenSearch **wildcard** query
11 + (``*pattern*``), so partial names work.
12 +"""
13 +
14 +from __future__ import annotations
15 +
16 +from dataclasses import dataclass
17 +from dataclasses import field
18 +from typing import Dict
19 +from typing import List
20 +
21 +
22 +@dataclass(frozen=True)
23 +class ScaPackageEntry:
24 + """A single entry in the SCA package registry."""
25 +
26 + # Human-readable label shown in API responses.
27 + display_name: str
28 +
29 + # The ``application`` value from the CoPilot-SCA index.json so we can
30 + # cross-reference available policies automatically.
31 + sca_application: str
32 +
33 + # Package-name patterns to search for in the Wazuh Indexer. Each
34 + # pattern is matched case-insensitively with wildcards on both sides.
35 + package_patterns: List[str] = field(default_factory=list)
36 +
37 +
38 +# ── The registry ────────────────────────────────────────────────────────
39 +# Add new entries here as SCA policies are created for more applications.
40 +
41 +SCA_PACKAGE_REGISTRY: Dict[str, ScaPackageEntry] = {
42 + "apache": ScaPackageEntry(
43 + display_name="Apache HTTP Server",
44 + sca_application="apache",
45 + package_patterns=["apache2", "httpd", "apache2-bin", "apache2-utils"],
46 + ),
47 + "nginx": ScaPackageEntry(
48 + display_name="NGINX",
49 + sca_application="nginx",
50 + package_patterns=["nginx", "nginx-common", "nginx-core", "nginx-full"],
51 + ),
52 + "iis": ScaPackageEntry(
53 + display_name="Microsoft IIS",
54 + sca_application="iis",
55 + package_patterns=["iis", "w3svc"],
56 + ),
57 + "mysql": ScaPackageEntry(
58 + display_name="MySQL / MariaDB",
59 + sca_application="mysql",
60 + package_patterns=[
61 + "mysql-server",
62 + "mysql-community-server",
63 + "mysql-common",
64 + "mariadb-server",
65 + "mariadb-common",
66 + ],
67 + ),
68 + "postgresql": ScaPackageEntry(
69 + display_name="PostgreSQL",
70 + sca_application="postgresql",
71 + package_patterns=["postgresql", "postgresql-common", "postgresql-client"],
72 + ),
73 + "sqlserver": ScaPackageEntry(
74 + display_name="Microsoft SQL Server",
75 + sca_application="sqlserver",
76 + package_patterns=["mssql-server", "mssql-tools"],
77 + ),
78 +}
backend/app/agents/sca/routes/sca.py
+94
@@ -16,12 +16,20 @@ from loguru import logger
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from app.agents.sca.schema.sca import ScaOverviewResponse
19 +from app.agents.sca.schema.sca import ScaPackageAgentsResponse
20 +from app.agents.sca.schema.sca import ScaPackageRegistryResponse
21 +from app.agents.sca.schema.sca import ScaPoliciesIndexResponse
22 +from app.agents.sca.schema.sca import ScaPolicyContentResponse
23 from app.agents.sca.schema.sca import SCAReportGenerateRequest
24 from app.agents.sca.schema.sca import SCAReportGenerateResponse
25 from app.agents.sca.schema.sca import SCAReportListResponse
26 from app.agents.sca.schema.sca import ScaStatsResponse
27 from app.agents.sca.services.sca import delete_sca_report
28 +from app.agents.sca.services.sca import detect_agents_for_sca_package
29 +from app.agents.sca.services.sca import fetch_sca_policies_index
30 +from app.agents.sca.services.sca import fetch_sca_policy_content
31 from app.agents.sca.services.sca import generate_sca_csv_report
32 +from app.agents.sca.services.sca import list_sca_package_registry
33 from app.agents.sca.services.sca import get_sca_report_download
34 from app.agents.sca.services.sca import get_sca_statistics
35 from app.agents.sca.services.sca import list_sca_reports
@@ -566,3 +574,89 @@ async def delete_report(
574 except Exception as e:
575 logger.error(f"Error in delete report endpoint: {e}")
576 raise HTTPException(status_code=500, detail=f"Failed to delete report: {e}")
577 +
578 +
579 +@sca_router.get(
580 + "/policies",
581 + response_model=ScaPoliciesIndexResponse,
582 + description="List all available SCA policies from the CoPilot-SCA repository",
583 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
584 +)
585 +async def list_available_sca_policies() -> ScaPoliciesIndexResponse:
586 + """
587 + List all available SCA (Security Configuration Assessment) policies from the
588 + public CoPilot-SCA GitHub repository.
589 +
590 + This endpoint fetches the repository index and returns metadata for every
591 + policy that can be deployed, including its name, description, target
592 + application, platform, and CIS benchmark version.
593 +
594 + **Use Cases:**
595 + - Browse available CIS benchmark policies
596 + - Discover policies for a specific application or platform
597 + - Review available policy versions before deployment
598 + """
599 + return await fetch_sca_policies_index()
600 +
601 +
602 +@sca_router.get(
603 + "/policies/{policy_id}",
604 + response_model=ScaPolicyContentResponse,
605 + description="Fetch the YAML content of a specific SCA policy",
606 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
607 +)
608 +async def get_sca_policy_content(policy_id: str) -> ScaPolicyContentResponse:
609 + """
610 + Fetch the raw YAML content of a single SCA policy from the public
611 + CoPilot-SCA GitHub repository.
612 +
613 + The ``policy_id`` must match one of the identifiers returned by the
614 + ``/policies`` listing endpoint (e.g. ``cis_apache_24_rpm``).
615 +
616 + **Use Cases:**
617 + - Preview the full YAML of a policy before deploying it
618 + - Review the checks included in a specific CIS benchmark
619 + - Download policy content for offline analysis
620 + """
621 + return await fetch_sca_policy_content(policy_id)
622 +
623 +
624 +@sca_router.get(
625 + "/packages/registry",
626 + response_model=ScaPackageRegistryResponse,
627 + description="List all tracked SCA-relevant package categories",
628 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
629 +)
630 +async def get_sca_package_registry() -> ScaPackageRegistryResponse:
631 + """
632 + Return every entry in the SCA package registry.
633 +
634 + Each entry maps an application category (e.g. ``apache``, ``mysql``) to
635 + the package name patterns that indicate the software is installed on an
636 + agent. Use the ``key`` value with the ``/packages/registry/{key}/agents``
637 + endpoint to discover which agents have that software.
638 + """
639 + return await list_sca_package_registry()
640 +
641 +
642 +@sca_router.get(
643 + "/packages/registry/{registry_key}/agents",
644 + response_model=ScaPackageAgentsResponse,
645 + description="Detect agents running a tracked SCA-relevant package",
646 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
647 +)
648 +async def get_agents_for_sca_package(registry_key: str) -> ScaPackageAgentsResponse:
649 + """
650 + Given a registry key (e.g. ``apache``, ``nginx``, ``mysql``), search the
651 + Wazuh Indexer for agents that have any matching packages installed.
652 +
653 + The response also includes the list of SCA policies from the CoPilot-SCA
654 + repository that are applicable to that application, making it easy to see
655 + which benchmarks can be deployed to the discovered agents.
656 +
657 + **Use Cases:**
658 + - Identify all agents running Apache to deploy the CIS Apache benchmark
659 + - Find agents with MySQL/MariaDB for targeted SCA policy deployment
660 + - Audit which agents would benefit from a specific SCA policy
661 + """
662 + return await detect_agents_for_sca_package(registry_key)
backend/app/agents/sca/schema/sca.py
+80
@@ -131,3 +131,83 @@ class SCAReportGenerateResponse(BaseModel):
131 message: str
132 report: Optional[SCAReportResponse] = None
133 error: Optional[str] = None
134 +
135 +
136 +# ── Available SCA Policies (from CoPilot-SCA GitHub repo) ──
137 +
138 +
139 +class ScaPolicyItem(BaseModel):
140 + """A single SCA policy entry from the CoPilot-SCA index"""
141 +
142 + id: str
143 + name: str
144 + description: str
145 + file: str
146 + application: str
147 + app_version: str
148 + platform: str
149 + cis_version: str
150 +
151 +
152 +class ScaPoliciesIndexResponse(BaseModel):
153 + """Response for listing all available SCA policies from the public repo"""
154 +
155 + version: str
156 + last_updated: str
157 + policies: List[ScaPolicyItem]
158 + success: bool
159 + message: str
160 +
161 +
162 +class ScaPolicyContentResponse(BaseModel):
163 + """Response for fetching the raw YAML content of a single SCA policy"""
164 +
165 + policy_id: str
166 + file_path: str
167 + content: str
168 + success: bool
169 + message: str
170 +
171 +
172 +# ── SCA Package Registry & Agent Detection ──
173 +
174 +
175 +class ScaPackageRegistryItem(BaseModel):
176 + """A single entry from the SCA package registry."""
177 +
178 + key: str
179 + display_name: str
180 + sca_application: str
181 + package_patterns: List[str]
182 +
183 +
184 +class ScaPackageRegistryResponse(BaseModel):
185 + """Response listing all tracked SCA-relevant packages."""
186 +
187 + entries: List[ScaPackageRegistryItem]
188 + total: int
189 + success: bool
190 + message: str
191 +
192 +
193 +class AgentPackageMatch(BaseModel):
194 + """An agent that was found running a tracked SCA package."""
195 +
196 + agent_id: Optional[str] = None
197 + agent_name: Optional[str] = None
198 + package_name: Optional[str] = None
199 + package_version: Optional[str] = None
200 + package_architecture: Optional[str] = None
201 +
202 +
203 +class ScaPackageAgentsResponse(BaseModel):
204 + """Response listing agents that have a particular SCA-relevant package installed."""
205 +
206 + registry_key: str
207 + display_name: str
208 + sca_application: str
209 + matched_agents: List[AgentPackageMatch] = []
210 + total: int = 0
211 + applicable_policies: List[ScaPolicyItem] = []
212 + success: bool
213 + message: str
backend/app/agents/sca/services/sca.py
+211
@@ -11,6 +11,7 @@ from typing import Dict
11 from typing import List
12 from typing import Optional
13
14 +import httpx
15 from fastapi import HTTPException
16 from loguru import logger
17 from sqlalchemy import desc
@@ -19,6 +20,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
20
21 from app.agents.sca.schema.sca import AgentScaOverviewItem
22 from app.agents.sca.schema.sca import ScaOverviewResponse
23 +from app.agents.sca.schema.sca import ScaPoliciesIndexResponse
24 +from app.agents.sca.schema.sca import ScaPolicyContentResponse
25 +from app.agents.sca.schema.sca import ScaPolicyItem
26 from app.agents.sca.schema.sca import SCAReportGenerateRequest
27 from app.agents.sca.schema.sca import SCAReportGenerateResponse
28 from app.agents.sca.schema.sca import SCAReportListResponse
@@ -36,6 +40,10 @@ from app.middleware.customer_access import customer_access_handler
40 # Default concurrency limit for parallel API requests
41 DEFAULT_MAX_CONCURRENT_REQUESTS = 100
42
43 +# CoPilot-SCA public repository base URL
44 +COPILOT_SCA_RAW_BASE = "https://raw.githubusercontent.com/socfortress/CoPilot-SCA/refs/heads/main"
45 +COPILOT_SCA_INDEX_URL = f"{COPILOT_SCA_RAW_BASE}/index.json"
46 +
47
48 async def get_all_agents_from_db(
49 db_session: AsyncSession,
@@ -1217,3 +1225,206 @@ async def stream_sca_for_all_agents(
1225 "message": "Fatal error during SCA collection",
1226 },
1227 }
1228 +
1229 +
1230 +async def fetch_sca_policies_index() -> ScaPoliciesIndexResponse:
1231 + """
1232 + Fetch the SCA policies index from the CoPilot-SCA public GitHub repository.
1233 +
1234 + Returns:
1235 + ScaPoliciesIndexResponse with the list of available policies.
1236 + """
1237 + try:
1238 + async with httpx.AsyncClient(timeout=15.0) as client:
1239 + response = await client.get(COPILOT_SCA_INDEX_URL)
1240 + response.raise_for_status()
1241 +
1242 + data = response.json()
1243 +
1244 + policies = [ScaPolicyItem(**p) for p in data.get("policies", [])]
1245 +
1246 + return ScaPoliciesIndexResponse(
1247 + version=data.get("version", "unknown"),
1248 + last_updated=data.get("last_updated", "unknown"),
1249 + policies=policies,
1250 + success=True,
1251 + message=f"Successfully fetched {len(policies)} available SCA policies",
1252 + )
1253 + except httpx.HTTPStatusError as e:
1254 + logger.error(f"HTTP error fetching SCA policies index: {e}")
1255 + raise HTTPException(
1256 + status_code=e.response.status_code,
1257 + detail=f"Failed to fetch SCA policies index from GitHub: {e}",
1258 + )
1259 + except Exception as e:
1260 + logger.error(f"Error fetching SCA policies index: {e}")
1261 + raise HTTPException(status_code=502, detail=f"Failed to fetch SCA policies index: {e}")
1262 +
1263 +
1264 +async def fetch_sca_policy_content(policy_id: str) -> ScaPolicyContentResponse:
1265 + """
1266 + Fetch the raw YAML content of a single SCA policy from the CoPilot-SCA
1267 + public GitHub repository.
1268 +
1269 + The policy is looked up by its ``id`` field in the index, and the
1270 + corresponding YAML file is downloaded from the raw content URL.
1271 +
1272 + Args:
1273 + policy_id: The policy identifier (e.g. ``cis_apache_24_rpm``).
1274 +
1275 + Returns:
1276 + ScaPolicyContentResponse with the YAML content.
1277 + """
1278 + # First fetch the index to resolve the file path for the requested policy
1279 + index_response = await fetch_sca_policies_index()
1280 +
1281 + policy = next((p for p in index_response.policies if p.id == policy_id), None)
1282 +
1283 + if policy is None:
1284 + raise HTTPException(
1285 + status_code=404,
1286 + detail=f"SCA policy '{policy_id}' not found in the CoPilot-SCA repository index",
1287 + )
1288 +
1289 + file_url = f"{COPILOT_SCA_RAW_BASE}/{policy.file}"
1290 +
1291 + try:
1292 + async with httpx.AsyncClient(timeout=15.0) as client:
1293 + response = await client.get(file_url)
1294 + response.raise_for_status()
1295 +
1296 + return ScaPolicyContentResponse(
1297 + policy_id=policy.id,
1298 + file_path=policy.file,
1299 + content=response.text,
1300 + success=True,
1301 + message=f"Successfully fetched policy '{policy.name}'",
1302 + )
1303 + except httpx.HTTPStatusError as e:
1304 + logger.error(f"HTTP error fetching SCA policy content for {policy_id}: {e}")
1305 + raise HTTPException(
1306 + status_code=e.response.status_code,
1307 + detail=f"Failed to fetch SCA policy file from GitHub: {e}",
1308 + )
1309 + except Exception as e:
1310 + logger.error(f"Error fetching SCA policy content for {policy_id}: {e}")
1311 + raise HTTPException(status_code=502, detail=f"Failed to fetch SCA policy content: {e}")
1312 +
1313 +
1314 +async def list_sca_package_registry() -> "ScaPackageRegistryResponse":
1315 + """
1316 + Return every entry in the SCA package registry so callers can see
1317 + which application packages are tracked for SCA applicability.
1318 + """
1319 + from app.agents.sca.models.sca_package_registry import SCA_PACKAGE_REGISTRY
1320 + from app.agents.sca.schema.sca import ScaPackageRegistryItem
1321 + from app.agents.sca.schema.sca import ScaPackageRegistryResponse
1322 +
1323 + entries = [
1324 + ScaPackageRegistryItem(
1325 + key=key,
1326 + display_name=entry.display_name,
1327 + sca_application=entry.sca_application,
1328 + package_patterns=list(entry.package_patterns),
1329 + )
1330 + for key, entry in SCA_PACKAGE_REGISTRY.items()
1331 + ]
1332 +
1333 + return ScaPackageRegistryResponse(
1334 + entries=entries,
1335 + total=len(entries),
1336 + success=True,
1337 + message=f"Found {len(entries)} tracked SCA package categories",
1338 + )
1339 +
1340 +
1341 +async def detect_agents_for_sca_package(registry_key: str) -> "ScaPackageAgentsResponse":
1342 + """
1343 + Given a registry key (e.g. ``apache``, ``mysql``), search the Wazuh
1344 + Indexer for agents that have any of the associated packages installed,
1345 + then cross-reference with available SCA policies for that application.
1346 + """
1347 + from app.agents.sca.models.sca_package_registry import SCA_PACKAGE_REGISTRY
1348 + from app.agents.sca.schema.sca import AgentPackageMatch
1349 + from app.agents.sca.schema.sca import ScaPackageAgentsResponse
1350 +
1351 + entry = SCA_PACKAGE_REGISTRY.get(registry_key)
1352 + if entry is None:
1353 + raise HTTPException(
1354 + status_code=404,
1355 + detail=(
1356 + f"Registry key '{registry_key}' not found. "
1357 + f"Valid keys: {', '.join(SCA_PACKAGE_REGISTRY.keys())}"
1358 + ),
1359 + )
1360 +
1361 + # Build an OR query across all package patterns for this application
1362 + should_clauses = [
1363 + {"wildcard": {"package.name": {"value": f"*{pattern}*", "case_insensitive": True}}}
1364 + for pattern in entry.package_patterns
1365 + ]
1366 +
1367 + query = {"query": {"bool": {"should": should_clauses, "minimum_should_match": 1}}}
1368 +
1369 + from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client_async
1370 +
1371 + es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
1372 +
1373 + try:
1374 + response = await es_client.search(
1375 + index="wazuh-states-inventory-packages-*",
1376 + body=query,
1377 + size=10000,
1378 + )
1379 +
1380 + hits = response.get("hits", {}).get("hits", [])
1381 +
1382 + # De-duplicate by (agent_id, package_name) to avoid repeated entries
1383 + seen = set()
1384 + matches: list[AgentPackageMatch] = []
1385 + for hit in hits:
1386 + src = hit.get("_source", {})
1387 + agent = src.get("agent", {})
1388 + pkg = src.get("package", {})
1389 + agent_id = agent.get("id")
1390 + if agent_id in seen:
1391 + continue
1392 + seen.add(agent_id)
1393 + matches.append(
1394 + AgentPackageMatch(
1395 + agent_id=agent.get("id"),
1396 + agent_name=agent.get("name"),
1397 + package_name=pkg.get("name"),
1398 + package_version=pkg.get("version"),
1399 + package_architecture=pkg.get("architecture"),
1400 + ),
1401 + )
1402 + except Exception as e:
1403 + logger.error(f"Error detecting agents for SCA package '{registry_key}': {e}")
1404 + raise HTTPException(
1405 + status_code=500,
1406 + detail=f"Failed to search packages in Wazuh Indexer: {e}",
1407 + )
1408 + finally:
1409 + await es_client.close()
1410 +
1411 + # Fetch applicable SCA policies for this application
1412 + applicable_policies = []
1413 + try:
1414 + index_resp = await fetch_sca_policies_index()
1415 + applicable_policies = [
1416 + p for p in index_resp.policies if p.application == entry.sca_application
1417 + ]
1418 + except Exception as e:
1419 + logger.warning(f"Could not fetch SCA policies index for cross-reference: {e}")
1420 +
1421 + return ScaPackageAgentsResponse(
1422 + registry_key=registry_key,
1423 + display_name=entry.display_name,
1424 + sca_application=entry.sca_application,
1425 + matched_agents=matches,
1426 + total=len(matches),
1427 + applicable_policies=applicable_policies,
1428 + success=True,
1429 + message=f"Found {len(matches)} agent-package combinations for '{entry.display_name}'",
1430 + )
backend/app/agents/wazuh/syscollector/routes/packages.py new
+125
@@ -0,0 +1,125 @@
1 +from typing import List
2 +from typing import Optional
3 +
4 +from fastapi import APIRouter
5 +from fastapi import Depends
6 +from fastapi import HTTPException
7 +from fastapi import Query
8 +from fastapi import Security
9 +from loguru import logger
10 +from sqlalchemy import select as sa_select
11 +from sqlalchemy.ext.asyncio import AsyncSession
12 +
13 +from app.agents.wazuh.syscollector.schema.packages import AgentPackagesResponse
14 +from app.agents.wazuh.syscollector.schema.packages import IndexerPackagesResponse
15 +from app.agents.wazuh.syscollector.services.packages import collect_agent_packages
16 +from app.agents.wazuh.syscollector.services.packages import search_packages_in_indexer
17 +from app.auth.models.users import User
18 +from app.auth.routes.auth import AuthHandler
19 +from app.db.db_session import get_db
20 +from app.db.universal_models import Agents
21 +from app.middleware.customer_access import customer_access_handler
22 +
23 +packages_router = APIRouter()
24 +
25 +
26 +@packages_router.get(
27 + "/search/packages",
28 + response_model=IndexerPackagesResponse,
29 + description="Search installed packages across all agents via the Wazuh Indexer",
30 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
31 +)
32 +async def search_packages(
33 + package_name: Optional[str] = Query(None, description="Filter by package name (wildcard match)"),
34 + agent_name: Optional[str] = Query(None, description="Filter by agent name (wildcard match)"),
35 + agent_id: Optional[str] = Query(None, description="Filter by agent ID (exact match)"),
36 + architecture: Optional[str] = Query(None, description="Filter by architecture (e.g. amd64, x86_64)"),
37 + package_type: Optional[str] = Query(None, description="Filter by package type (e.g. deb, rpm)"),
38 + vendor: Optional[str] = Query(None, description="Filter by vendor (wildcard match)"),
39 + package_version: Optional[str] = Query(None, description="Filter by package version (wildcard match)"),
40 + size: int = Query(500, ge=1, le=10000, description="Maximum number of results to return"),
41 +) -> IndexerPackagesResponse:
42 + """
43 + Search the Wazuh Indexer for package inventory data across all agents.
44 +
45 + Unlike the per-agent endpoint that queries the Wazuh Manager API, this
46 + endpoint queries the ``wazuh-states-inventory-packages-*`` index in the
47 + Wazuh Indexer (OpenSearch), allowing you to search for packages across
48 + every agent without specifying an agent ID.
49 +
50 + **Use Cases:**
51 + - Find all agents that have a specific package installed
52 + - Search for outdated versions of a package across the fleet
53 + - Inventory packages by type, architecture, or vendor
54 + """
55 + return await search_packages_in_indexer(
56 + package_name=package_name,
57 + agent_name=agent_name,
58 + agent_id=agent_id,
59 + architecture=architecture,
60 + package_type=package_type,
61 + vendor=vendor,
62 + package_version=package_version,
63 + size=size,
64 + )
65 +
66 +
67 +@packages_router.get(
68 + "/{agent_id}/packages",
69 + response_model=AgentPackagesResponse,
70 + description="Get installed packages for a specific agent from the Wazuh Manager syscollector",
71 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
72 +)
73 +async def get_agent_packages(
74 + agent_id: str,
75 + limit: int = Query(500, ge=1, le=100000, description="Maximum number of packages to return"),
76 + offset: int = Query(0, ge=0, description="First element to return"),
77 + sort: Optional[str] = Query(None, description="Sort by field(s). Use +/- prefix for asc/desc order"),
78 + search: Optional[str] = Query(None, description="Free-text search string"),
79 + select: Optional[List[str]] = Query(None, description="Fields to return"),
80 + vendor: Optional[str] = Query(None, description="Filter by vendor"),
81 + name: Optional[str] = Query(None, description="Filter by package name"),
82 + architecture: Optional[str] = Query(None, description="Filter by architecture"),
83 + format: Optional[str] = Query(None, alias="format", description="Filter by package format (e.g. deb, rpm)"),
84 + version: Optional[str] = Query(None, description="Filter by package version"),
85 + q: Optional[str] = Query(None, description="Advanced query filter (e.g. q=\"name=openssl\")"),
86 + current_user: User = Depends(AuthHandler().get_current_user),
87 + session: AsyncSession = Depends(get_db),
88 +) -> AgentPackagesResponse:
89 + """
90 + Fetch installed packages for a specific agent via the Wazuh Manager
91 + syscollector API.
92 +
93 + Returns package name, version, architecture, vendor, format, and other
94 + metadata for every installed package on the agent.
95 + """
96 + logger.info(f"Fetching packages for agent {agent_id}")
97 +
98 + # Verify the user has access to this agent's customer
99 + base_query = sa_select(Agents).filter(Agents.agent_id == agent_id)
100 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
101 + current_user, session, base_query, Agents.customer_code,
102 + )
103 + result = await session.execute(filtered_query)
104 + agent = result.scalars().first()
105 +
106 + if not agent:
107 + raise HTTPException(
108 + status_code=404,
109 + detail=f"Agent with agent_id {agent_id} not found or access denied",
110 + )
111 +
112 + return await collect_agent_packages(
113 + agent_id=agent_id,
114 + limit=limit,
115 + offset=offset,
116 + sort=sort,
117 + search=search,
118 + select=select,
119 + vendor=vendor,
120 + name=name,
121 + architecture=architecture,
122 + format=format,
123 + version=version,
124 + q=q,
125 + )
backend/app/agents/wazuh/syscollector/schema/packages.py new
+80
@@ -0,0 +1,80 @@
1 +from typing import Any
2 +from typing import Dict
3 +from typing import List
4 +from typing import Optional
5 +
6 +from pydantic import BaseModel
7 +from pydantic import Field
8 +
9 +
10 +class PackageItem(BaseModel):
11 + """A single package returned by the Wazuh syscollector packages endpoint."""
12 +
13 + architecture: Optional[str] = None
14 + description: Optional[str] = None
15 + format: Optional[str] = None
16 + name: Optional[str] = None
17 + priority: Optional[str] = None
18 + scan: Optional[Dict[str, Any]] = None
19 + section: Optional[str] = None
20 + size: Optional[int] = None
21 + vendor: Optional[str] = None
22 + version: Optional[str] = None
23 + agent_id: Optional[str] = None
24 +
25 + class Config:
26 + extra = "allow"
27 +
28 +
29 +class AgentPackagesResponse(BaseModel):
30 + """Response wrapper for an agent's syscollector packages."""
31 +
32 + packages: List[PackageItem] = []
33 + total_affected_items: int = 0
34 + success: bool
35 + message: str
36 +
37 +
38 +# ── Wazuh Indexer (OpenSearch) package inventory models ──
39 +
40 +
41 +class IndexerPackageAgent(BaseModel):
42 + """Agent info nested inside a Wazuh indexer package document."""
43 +
44 + id: Optional[str] = None
45 + name: Optional[str] = None
46 + version: Optional[str] = None
47 +
48 +
49 +class IndexerPackageDetail(BaseModel):
50 + """Package info nested inside a Wazuh indexer package document."""
51 +
52 + architecture: Optional[str] = None
53 + description: Optional[str] = None
54 + name: Optional[str] = None
55 + size: Optional[int] = None
56 + type: Optional[str] = None
57 + vendor: Optional[str] = None
58 + version: Optional[str] = None
59 +
60 +
61 +class IndexerPackageItem(BaseModel):
62 + """A single document from ``wazuh-states-inventory-packages-*``."""
63 +
64 + index: Optional[str] = Field(None, alias="_index")
65 + id: Optional[str] = Field(None, alias="_id")
66 + agent: Optional[IndexerPackageAgent] = None
67 + package: Optional[IndexerPackageDetail] = None
68 +
69 + class Config:
70 + populate_by_name = True
71 + extra = "allow"
72 +
73 +
74 +class IndexerPackagesResponse(BaseModel):
75 + """Response wrapper for packages fetched from the Wazuh Indexer."""
76 +
77 + packages: List[IndexerPackageItem] = []
78 + total: int = 0
79 + success: bool
80 + message: str
backend/app/agents/wazuh/syscollector/services/packages.py new
+195
@@ -0,0 +1,195 @@
1 +from typing import Any
2 +from typing import Dict
3 +from typing import List
4 +from typing import Optional
5 +
6 +from fastapi import HTTPException
7 +from loguru import logger
8 +
9 +from app.agents.wazuh.syscollector.schema.packages import AgentPackagesResponse
10 +from app.agents.wazuh.syscollector.schema.packages import IndexerPackageAgent
11 +from app.agents.wazuh.syscollector.schema.packages import IndexerPackageDetail
12 +from app.agents.wazuh.syscollector.schema.packages import IndexerPackageItem
13 +from app.agents.wazuh.syscollector.schema.packages import IndexerPackagesResponse
14 +from app.agents.wazuh.syscollector.schema.packages import PackageItem
15 +from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client_async
16 +from app.connectors.wazuh_manager.utils.universal import send_get_request
17 +
18 +PACKAGES_INDEX_PATTERN = "wazuh-states-inventory-packages-*"
19 +
20 +
21 +async def collect_agent_packages(
22 + agent_id: str,
23 + limit: int = 500,
24 + offset: int = 0,
25 + sort: Optional[str] = None,
26 + search: Optional[str] = None,
27 + select: Optional[List[str]] = None,
28 + vendor: Optional[str] = None,
29 + name: Optional[str] = None,
30 + architecture: Optional[str] = None,
31 + format: Optional[str] = None,
32 + version: Optional[str] = None,
33 + q: Optional[str] = None,
34 +) -> AgentPackagesResponse:
35 + """
36 + Fetch installed packages for a specific agent from the Wazuh Manager
37 + syscollector API.
38 +
39 + Args:
40 + agent_id: The Wazuh agent ID.
41 + limit: Maximum number of packages to return (1-100000, default 500).
42 + offset: First element to return (pagination).
43 + sort: Sort field(s), prefixed with +/- for order.
44 + search: Free-text search string.
45 + select: List of fields to return.
46 + vendor: Filter by vendor.
47 + name: Filter by package name.
48 + architecture: Filter by architecture.
49 + format: Filter by package format (e.g. 'deb', 'rpm').
50 + version: Filter by package version.
51 + q: Advanced query filter string.
52 +
53 + Returns:
54 + AgentPackagesResponse with the list of packages.
55 + """
56 + params: Dict[str, Any] = {
57 + "limit": limit,
58 + "offset": offset,
59 + "wait_for_complete": True,
60 + }
61 +
62 + if sort is not None:
63 + params["sort"] = sort
64 + if search is not None:
65 + params["search"] = search
66 + if select is not None:
67 + params["select"] = ",".join(select)
68 + if vendor is not None:
69 + params["vendor"] = vendor
70 + if name is not None:
71 + params["name"] = name
72 + if architecture is not None:
73 + params["architecture"] = architecture
74 + if format is not None:
75 + params["format"] = format
76 + if version is not None:
77 + params["version"] = version
78 + if q is not None:
79 + params["q"] = q
80 +
81 + response = await send_get_request(
82 + endpoint=f"/syscollector/{agent_id}/packages",
83 + params=params,
84 + )
85 +
86 + if not response.get("success"):
87 + raise HTTPException(
88 + status_code=500,
89 + detail=response.get("message", "Failed to fetch packages from Wazuh Manager"),
90 + )
91 +
92 + wazuh_data = response.get("data", {}).get("data", {})
93 + affected_items = wazuh_data.get("affected_items", [])
94 + total_affected_items = wazuh_data.get("total_affected_items", len(affected_items))
95 +
96 + packages = [PackageItem(**item) for item in affected_items]
97 +
98 + logger.info(f"Fetched {len(packages)} packages for agent {agent_id}")
99 +
100 + return AgentPackagesResponse(
101 + packages=packages,
102 + total_affected_items=total_affected_items,
103 + success=True,
104 + message=f"Successfully fetched {len(packages)} packages for agent {agent_id}",
105 + )
106 +
107 +
108 +async def search_packages_in_indexer(
109 + package_name: Optional[str] = None,
110 + agent_name: Optional[str] = None,
111 + agent_id: Optional[str] = None,
112 + architecture: Optional[str] = None,
113 + package_type: Optional[str] = None,
114 + vendor: Optional[str] = None,
115 + package_version: Optional[str] = None,
116 + size: int = 500,
117 +) -> IndexerPackagesResponse:
118 + """
119 + Search the Wazuh Indexer for package inventory data across all agents.
120 +
121 + Queries the ``wazuh-states-inventory-packages-*`` index pattern and returns
122 + matching documents with optional filters.
123 +
124 + Args:
125 + package_name: Filter by package name (wildcard match).
126 + agent_name: Filter by agent name (wildcard match).
127 + agent_id: Filter by agent ID (exact match).
128 + architecture: Filter by architecture (exact match).
129 + package_type: Filter by package type, e.g. ``deb``, ``rpm`` (exact match).
130 + vendor: Filter by vendor (wildcard match).
131 + package_version: Filter by package version (wildcard match).
132 + size: Maximum number of documents to return (default 500).
133 +
134 + Returns:
135 + IndexerPackagesResponse with the matching packages.
136 + """
137 + must_clauses: List[Dict[str, Any]] = []
138 +
139 + if package_name is not None:
140 + must_clauses.append({"wildcard": {"package.name": {"value": f"*{package_name}*", "case_insensitive": True}}})
141 + if agent_name is not None:
142 + must_clauses.append({"wildcard": {"agent.name": {"value": f"*{agent_name}*", "case_insensitive": True}}})
143 + if agent_id is not None:
144 + must_clauses.append({"term": {"agent.id": agent_id}})
145 + if architecture is not None:
146 + must_clauses.append({"term": {"package.architecture": architecture}})
147 + if package_type is not None:
148 + must_clauses.append({"term": {"package.type": package_type}})
149 + if vendor is not None:
150 + must_clauses.append({"wildcard": {"package.vendor": {"value": f"*{vendor}*", "case_insensitive": True}}})
151 + if package_version is not None:
152 + must_clauses.append({"wildcard": {"package.version": {"value": f"*{package_version}*", "case_insensitive": True}}})
153 +
154 + query: Dict[str, Any] = (
155 + {"query": {"bool": {"must": must_clauses}}} if must_clauses else {"query": {"match_all": {}}}
156 + )
157 +
158 + es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
159 +
160 + try:
161 + response = await es_client.search(
162 + index=PACKAGES_INDEX_PATTERN,
163 + body=query,
164 + size=size,
165 + )
166 +
167 + hits = response.get("hits", {})
168 + total = hits.get("total", {})
169 + total_value = total.get("value", 0) if isinstance(total, dict) else total
170 +
171 + packages: List[IndexerPackageItem] = []
172 + for hit in hits.get("hits", []):
173 + source = hit.get("_source", {})
174 + packages.append(
175 + IndexerPackageItem(
176 + _index=hit.get("_index"),
177 + _id=hit.get("_id"),
178 + agent=IndexerPackageAgent(**source.get("agent", {})) if source.get("agent") else None,
179 + package=IndexerPackageDetail(**source.get("package", {})) if source.get("package") else None,
180 + ),
181 + )
182 +
183 + logger.info(f"Indexer search returned {len(packages)} packages (total matched: {total_value})")
184 +
185 + return IndexerPackagesResponse(
186 + packages=packages,
187 + total=total_value,
188 + success=True,
189 + message=f"Successfully retrieved {len(packages)} packages from the indexer",
190 + )
191 + except Exception as e:
192 + logger.error(f"Error searching packages in Wazuh Indexer: {e}")
193 + raise HTTPException(status_code=500, detail=f"Failed to search packages in Wazuh Indexer: {e}")
194 + finally:
195 + await es_client.close()
backend/app/routers/agents.py
+2
@@ -3,6 +3,7 @@ from fastapi import APIRouter
3 from app.agents.routes.agents import agents_router
4 from app.agents.sca.routes.sca import sca_router
5 from app.agents.vulnerabilities.routes.vulnerabilities import vulnerabilities_router
6 +from app.agents.wazuh.syscollector.routes.packages import packages_router
7
8 # Instantiate the APIRouter
9 router = APIRouter()
@@ -11,3 +12,4 @@ router = APIRouter()
12 router.include_router(agents_router, prefix="/agents", tags=["agents"])
13 router.include_router(vulnerabilities_router, prefix="/vulnerabilities", tags=["vulnerabilities"])
14 router.include_router(sca_router, prefix="/sca", tags=["sca"])
15 +router.include_router(packages_router, prefix="/syscollector", tags=["syscollector"])
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.47"
10 +CURRENT_VERSION = "0.1.48"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
docs/docs.json
+2 -1
@@ -186,7 +186,8 @@
186 "power-features/ai-analyst",
187 "power-features/atomic-red-team",
188 "power-features/report-creation",
189 - "power-features/copilot-searches"
189 + "power-features/copilot-searches",
190 + "power-features/sca-policies"
191 ]
192 }
193 ]
docs/power-features/index.mdx
+1
@@ -33,3 +33,4 @@ They typically have additional inputs (connectors, permissions, targets) and sho
33 - [Atomic Red Team (detection simulation)](/power-features/atomic-red-team)
34 - [Report creation](/power-features/report-creation)
35 - [CoPilot Searches (threat hunting)](/power-features/copilot-searches)
36 +- [SCA Policies (CIS benchmarks)](/power-features/sca-policies)
docs/power-features/sca-policies.mdx new
+179
@@ -0,0 +1,179 @@
1 +---
2 +title: SCA Policies (CIS benchmarks)
3 +description: Browse, preview, and deploy CIS benchmark policies for Wazuh Security Configuration Assessment — with automatic agent detection to see which endpoints need each policy.
4 +---
5 +
6 +SCA Policies is a catalog of CIS benchmark policy files maintained in the public [CoPilot-SCA](https://github.com/socfortress/CoPilot-SCA) GitHub repository. It lets you browse available policies, detect which agents are running the relevant software, and get ready-to-run deployment commands — all from within CoPilot.
7 +
8 +These policies extend the [Wazuh Security Configuration Assessment (SCA)](https://documentation.wazuh.com/current/user-manual/capabilities/sec-config-assessment/index.html) capability. Once deployed to an agent, Wazuh evaluates the endpoint against the CIS benchmark and reports pass/fail results in the [SCA Overview](/user/ui/agents-sca-overview).
9 +
10 +---
11 +
12 +## What it is
13 +
14 +A searchable catalog of SCA policy `.yml` files that you can:
15 +
16 +- **Browse** by platform (Linux, Windows), application (Apache, NGINX, MySQL, etc.), and CIS version
17 +- **Search** by keyword across policy names, descriptions, and application names
18 +- **Preview** the full YAML content before deploying
19 +- **Detect agents** — discover which of your endpoints have the relevant software installed
20 +- **Deploy** with copy-paste-ready shell commands
21 +
22 +Each policy card shows:
23 +- The CIS benchmark version
24 +- Target platform and application
25 +- Application version
26 +- A description of what the benchmark covers
27 +
28 +---
29 +
30 +## Why this is a power feature
31 +
32 +CIS benchmarks are most valuable once your agents are reporting and you have visibility into what software is running across your fleet. SCA Policies builds on top of the SCA Overview by helping you answer two questions:
33 +
34 +1. **Which policies should I deploy?** — Browse the catalog and filter by the applications you care about.
35 +2. **Which agents need this policy?** — Use the built-in agent detection to find endpoints running the target software (e.g., Apache, MySQL, NGINX).
36 +
37 +It closes the loop between "we have a benchmark" and "we know where to apply it."
38 +
39 +---
40 +
41 +## Where it lives in the UI
42 +
43 +**Menu path:** Agents → Security Configuration Assessment → SCA Policies
44 +
45 +The page shows the full policy catalog with:
46 +- A **search bar** for free-text search across policy names, descriptions, and applications
47 +- A **filter panel** with dropdowns for Platform and Application (dynamically populated from available policies)
48 +- **Policy cards** showing the CIS version, platform badge, application version, policy name, and description
49 +
50 +Clicking a policy card opens the detail view.
51 +
52 +---
53 +
54 +## Operator workflows
55 +
56 +### Browse available policies
57 +
58 +1. Navigate to **Agents → Security Configuration Assessment → SCA Policies**
59 +2. The catalog loads automatically from the [CoPilot-SCA](https://github.com/socfortress/CoPilot-SCA) repository
60 +3. Use the search bar to find policies by name or application (e.g., "apache", "mysql")
61 +4. Use the filter panel to narrow by platform or application
62 +
63 +### Detect agents running a specific application
64 +
65 +1. Click a policy card to open the detail view
66 +2. In the **Agents with [application]** section, click **Detect Agents**
67 +3. CoPilot queries the Wazuh Indexer for installed packages matching the application
68 +4. Results show each agent with the package name, version, and architecture
69 +5. Use this to identify which endpoints should receive the policy
70 +
71 +### Preview the policy YAML
72 +
73 +1. In the policy detail view, find the **Policy YAML** section
74 +2. Click **Load YAML** to fetch the full policy content from GitHub
75 +3. Review the checks, requirements, and conditions before deploying
76 +
77 +### Deploy a policy to an agent
78 +
79 +1. In the policy detail view, find the **Deployment Instructions** section
80 +2. The section provides ready-to-run commands tailored to the selected policy
81 +3. SSH into the target agent and run the commands:
82 +
83 +```bash
84 +# Download the SCA policy
85 +wget https://raw.githubusercontent.com/socfortress/CoPilot-SCA/main/policies/<app>/<policy_file>.yml \
86 + -O /var/ossec/ruleset/sca/<policy_file>.yml
87 +
88 +# Set correct ownership
89 +chown root:wazuh /var/ossec/ruleset/sca/<policy_file>.yml
90 +
91 +# Restart the Wazuh agent
92 +systemctl restart wazuh-agent
93 +
94 +# Verify the policy is loaded
95 +tail -f /var/ossec/logs/ossec.log
96 +```
97 +
98 +4. After the agent restarts, Wazuh will automatically pick up the new policy and begin scanning
99 +5. Results will appear in the [SCA Overview](/user/ui/agents-sca-overview) within a few minutes
100 +
101 +### End-to-end example: deploying a MySQL benchmark
102 +
103 +1. Open **SCA Policies** and search for "mysql"
104 +2. Click the **CIS Oracle MySQL Community Server 5.6 - Debian** card
105 +3. Click **Detect Agents** — CoPilot finds agent `piHole` (ID 088) with `mariadb-server` installed
106 +4. Review the **Deployment Instructions** — the commands are pre-filled with the correct filename
107 +5. SSH into the agent and run:
108 +
109 +```bash
110 +wget https://raw.githubusercontent.com/socfortress/CoPilot-SCA/main/policies/mysql/cis_mysql_community_deb.yml \
111 + -O /var/ossec/ruleset/sca/cis_mysql_community_deb.yml
112 +
113 +chown root:wazuh /var/ossec/ruleset/sca/cis_mysql_community_deb.yml
114 +
115 +systemctl restart wazuh-agent
116 +```
117 +
118 +6. Verify in logs: `tail -f /var/ossec/logs/ossec.log` — look for SCA scan messages
119 +7. Check **SCA Overview** to see the compliance score for the new policy
120 +
121 +---
122 +
123 +## Policy detail view
124 +
125 +When you open a policy, you'll see:
126 +
127 +| Section | Description |
128 +|---------|-------------|
129 +| **Policy Information** | ID, application, app version, platform, CIS version |
130 +| **Description** | What the CIS benchmark covers |
131 +| **Deployment Instructions** | Copy-paste shell commands to deploy the `.yml` file to an agent |
132 +| **Agents with [application]** | Detect which agents have the relevant software installed |
133 +| **Applicable SCA Policies** | Other policies in the catalog for the same application |
134 +| **Policy YAML** | Full YAML content preview (loaded on demand) |
135 +
136 +---
137 +
138 +## Supported applications
139 +
140 +The following application categories are tracked for automatic agent detection:
141 +
142 +| Application | Package patterns searched | Example policy |
143 +|-------------|--------------------------|----------------|
144 +| **Apache** | `apache2`, `httpd`, `apache2-bin`, `apache2-utils` | CIS Apache HTTP Server 2.4 |
145 +| **NGINX** | `nginx`, `nginx-common`, `nginx-core`, `nginx-full` | CIS NGINX |
146 +| **IIS** | `iis`, `w3svc` | CIS Microsoft IIS |
147 +| **MySQL / MariaDB** | `mysql-server`, `mysql-community-server`, `mariadb-server`, `mariadb-common` | CIS Oracle MySQL Community Server 5.6 |
148 +| **PostgreSQL** | `postgresql`, `postgresql-common`, `postgresql-client` | CIS PostgreSQL |
149 +| **SQL Server** | `mssql-server`, `mssql-tools` | CIS Microsoft SQL Server |
150 +
151 +New applications can be added to the package registry as additional SCA policies are created.
152 +
153 +---
154 +
155 +## Setup checklist
156 +
157 +SCA Policies works out of the box with no additional configuration:
158 +
159 +- [x] CoPilot is running (policies are fetched from GitHub automatically)
160 +- [x] Wazuh Indexer is connected (required for agent detection)
161 +- [ ] SSH access to agents (required for deploying `.yml` files)
162 +
163 +---
164 +
165 +## Important notes
166 +
167 +- **File location:** SCA policy `.yml` files must be placed in `/var/ossec/ruleset/sca/` on the agent.
168 +- **Ownership:** Files must be owned by `root:wazuh` — use `chown root:wazuh <filename>` after downloading.
169 +- **Agent restart:** The Wazuh agent must be restarted (`systemctl restart wazuh-agent`) to pick up new policies.
170 +- **Verification:** Check `/var/ossec/logs/ossec.log` for SCA scan messages after deploying a new policy.
171 +- **Results:** Once deployed, SCA scan results appear in the [SCA Overview](/user/ui/agents-sca-overview) page.
172 +
173 +---
174 +
175 +## Related resources
176 +
177 +- Policy repository: https://github.com/socfortress/CoPilot-SCA
178 +- Wazuh SCA documentation: https://documentation.wazuh.com/current/user-manual/capabilities/sec-config-assessment/index.html
179 +- [SCA Overview (compliance results)](/user/ui/agents-sca-overview)
frontend/src/api/endpoints/sca.ts
+33 -1
@@ -5,7 +5,11 @@ import type {
5 SCAReportGenerateRequest,
6 SCAReportGenerateResponse,
7 SCAReportListResponse,
8 - ScaStatsResponse
8 + ScaStatsResponse,
9 + ScaPoliciesIndexResponse,
10 + ScaPolicyContentResponse,
11 + ScaPackageRegistryResponse,
12 + ScaPackageAgentsResponse
13 } from "@/types/sca.d"
14 import { HttpClient } from "../httpClient"
15 import { createSSEStream } from "../sseClient"
@@ -110,5 +114,33 @@ export default {
114 */
115 deleteReport(reportId: number) {
116 return HttpClient.delete<SCAReportDeleteResponse>(`/sca/reports/${reportId}`)
117 + },
118 +
119 + /**
120 + * List all available SCA policies from the CoPilot-SCA repository
121 + */
122 + getPolicies() {
123 + return HttpClient.get<ScaPoliciesIndexResponse>(`/sca/policies`)
124 + },
125 +
126 + /**
127 + * Fetch the YAML content of a specific SCA policy
128 + */
129 + getPolicyContent(policyId: string) {
130 + return HttpClient.get<ScaPolicyContentResponse>(`/sca/policies/${policyId}`)
131 + },
132 +
133 + /**
134 + * List all tracked SCA-relevant package categories
135 + */
136 + getPackageRegistry() {
137 + return HttpClient.get<ScaPackageRegistryResponse>(`/sca/packages/registry`)
138 + },
139 +
140 + /**
141 + * Detect agents running a tracked SCA-relevant package
142 + */
143 + getAgentsForPackage(registryKey: string) {
144 + return HttpClient.get<ScaPackageAgentsResponse>(`/sca/packages/registry/${registryKey}/agents`)
145 }
146 }
frontend/src/app-layouts/common/Navbar/items.tsx
+30 -11
@@ -283,17 +283,36 @@ export default function getItems(): MenuMixedOption[] {
283 key: "PatchTuesday"
284 },
285 {
286 - label: () =>
287 - h(
288 - RouterLink,
289 - {
290 - to: {
291 - name: "ScaOverview"
292 - }
293 - },
294 - { default: () => "SCA Overview" }
295 - ),
296 - key: "ScaOverview"
286 + label: "Security Configuration Assessment",
287 + key: "SCA",
288 + children: [
289 + {
290 + label: () =>
291 + h(
292 + RouterLink,
293 + {
294 + to: {
295 + name: "ScaOverview"
296 + }
297 + },
298 + { default: () => "SCA Overview" }
299 + ),
300 + key: "ScaOverview"
301 + },
302 + {
303 + label: () =>
304 + h(
305 + RouterLink,
306 + {
307 + to: {
308 + name: "ScaPolicies"
309 + }
310 + },
311 + { default: () => "SCA Policies" }
312 + ),
313 + key: "ScaPolicies"
314 + }
315 + ]
316 }
317 ]
318 },
frontend/src/components/scaPolicies/List.vue new
+221
@@ -0,0 +1,221 @@
1 +<template>
2 + <div class="@container flex flex-col gap-4">
3 + <n-alert type="info">
4 + SCA Policies provides CIS benchmark policies for Security Configuration Assessment using
5 + <a
6 + href="https://documentation.wazuh.com/current/user-manual/capabilities/sec-config-assessment/index.html"
7 + target="_blank"
8 + >
9 + Wazuh Security Configuration Assessment (SCA)
10 + </a>
11 + . Deploy policy
12 + <code>.yml</code>
13 + files to your endpoints under
14 + <code>/var/ossec/ruleset/sca/</code>
15 + , set ownership, and restart the Wazuh agent. See
16 + <a href="https://github.com/socfortress/CoPilot-SCA" target="_blank">CoPilot-SCA</a>
17 + for available policies.
18 + </n-alert>
19 +
20 + <div class="flex flex-col">
21 + <div class="flex flex-wrap items-center justify-end gap-2">
22 + <div class="flex min-w-80 grow gap-2">
23 + <n-popover overlap placement="bottom-start">
24 + <template #trigger>
25 + <div class="bg-default rounded-lg">
26 + <n-button size="small" class="cursor-help!">
27 + <template #icon>
28 + <Icon :name="InfoIcon" />
29 + </template>
30 + </n-button>
31 + </div>
32 + </template>
33 + <div class="flex flex-col gap-2">
34 + <div class="box">
35 + Total Policies:
36 + <code>{{ policies.length }}</code>
37 + </div>
38 + <div class="box">
39 + Filtered:
40 + <code>{{ filteredPolicies.length }}</code>
41 + </div>
42 + </div>
43 + </n-popover>
44 +
45 + <n-input
46 + v-model:value="searchQuery"
47 + size="small"
48 + placeholder="Search policies..."
49 + class="max-w-120"
50 + clearable
51 + >
52 + <template #prefix>
53 + <Icon :name="SearchIcon" />
54 + </template>
55 + </n-input>
56 +
57 + <n-popover :show="showFilters" trigger="manual" overlap placement="right" class="px-0!">
58 + <template #trigger>
59 + <div class="bg-default rounded-lg">
60 + <n-badge :show="filtered" dot type="success" :offset="[-4, 0]">
61 + <n-button size="small" @click="showFilters = true">
62 + <template #icon>
63 + <Icon :name="FilterIcon" />
64 + </template>
65 + </n-button>
66 + </n-badge>
67 + </div>
68 + </template>
69 + <div class="divide-border flex w-50 flex-col gap-0 divide-y">
70 + <div class="flex flex-col gap-2.5 px-3 pt-1 pb-3">
71 + <n-select
72 + v-model:value="selectedPlatform"
73 + :options="platformOptions"
74 + size="small"
75 + placeholder="Platform"
76 + class="w-full"
77 + clearable
78 + :consistent-menu-width="false"
79 + />
80 + <n-select
81 + v-model:value="selectedApplication"
82 + :options="applicationOptions"
83 + clearable
84 + size="small"
85 + placeholder="Application"
86 + class="w-full"
87 + :consistent-menu-width="false"
88 + />
89 + </div>
90 + <div class="flex justify-between gap-2 px-3 pt-2">
91 + <div class="flex justify-start gap-2">
92 + <n-button size="small" quaternary @click="showFilters = false">Close</n-button>
93 + </div>
94 + <div class="flex justify-end gap-2">
95 + <n-button size="small" secondary @click="resetFilters()">Reset</n-button>
96 + </div>
97 + </div>
98 + </div>
99 + </n-popover>
100 + </div>
101 + </div>
102 +
103 + <n-spin :show="loading">
104 + <div class="my-3">
105 + <div
106 + v-if="paginatedPolicies.length"
107 + class="grid grid-cols-1 gap-4 @2xl:grid-cols-2 @5xl:grid-cols-3 @6xl:grid-cols-4"
108 + >
109 + <PolicyCard v-for="policy of paginatedPolicies" :key="policy.id" :policy />
110 + </div>
111 +
112 + <template v-else>
113 + <n-empty v-if="!loading" description="No policies found" class="h-48 justify-center" />
114 + </template>
115 + </div>
116 + </n-spin>
117 +
118 + <div class="flex justify-end">
119 + <n-pagination
120 + v-if="filteredPolicies.length > pageSize"
121 + v-model:page="currentPage"
122 + :page-size="pageSize"
123 + :item-count="filteredPolicies.length"
124 + :page-slot="6"
125 + />
126 + </div>
127 + </div>
128 + </div>
129 +</template>
130 +
131 +<script setup lang="ts">
132 +import type { ScaPolicyItem } from "@/types/sca.d"
133 +import { NBadge, NAlert, NButton, NEmpty, NInput, NPagination, NPopover, NSelect, NSpin, useMessage } from "naive-ui"
134 +import { computed, onBeforeMount, ref } from "vue"
135 +import Api from "@/api"
136 +import Icon from "@/components/common/Icon.vue"
137 +import PolicyCard from "./PolicyCard.vue"
138 +
139 +const loading = ref(false)
140 +const message = useMessage()
141 +const policies = ref<ScaPolicyItem[]>([])
142 +
143 +const searchQuery = ref<string | null>(null)
144 +const selectedPlatform = ref<string | null>(null)
145 +const selectedApplication = ref<string | null>(null)
146 +const showFilters = ref(false)
147 +const currentPage = ref(1)
148 +const pageSize = 24
149 +
150 +const filtered = computed(() => !!selectedPlatform.value || !!selectedApplication.value)
151 +
152 +const InfoIcon = "carbon:information"
153 +const FilterIcon = "carbon:filter-edit"
154 +const SearchIcon = "carbon:search"
155 +
156 +const platformOptions = computed(() => {
157 + const platforms = [...new Set(policies.value.map(p => p.platform))].sort()
158 + return platforms.map(p => ({ label: p, value: p }))
159 +})
160 +
161 +const applicationOptions = computed(() => {
162 + const apps = [...new Set(policies.value.map(p => p.application))].sort()
163 + return apps.map(a => ({ label: a, value: a }))
164 +})
165 +
166 +const filteredPolicies = computed(() => {
167 + let result = policies.value
168 +
169 + if (searchQuery.value) {
170 + const q = searchQuery.value.toLowerCase()
171 + result = result.filter(
172 + p =>
173 + p.name.toLowerCase().includes(q) ||
174 + p.description.toLowerCase().includes(q) ||
175 + p.application.toLowerCase().includes(q) ||
176 + p.id.toLowerCase().includes(q)
177 + )
178 + }
179 +
180 + if (selectedPlatform.value) {
181 + result = result.filter(p => p.platform === selectedPlatform.value)
182 + }
183 +
184 + if (selectedApplication.value) {
185 + result = result.filter(p => p.application === selectedApplication.value)
186 + }
187 +
188 + return result
189 +})
190 +
191 +const paginatedPolicies = computed(() => {
192 + const start = (currentPage.value - 1) * pageSize
193 + return filteredPolicies.value.slice(start, start + pageSize)
194 +})
195 +
196 +function resetFilters() {
197 + selectedPlatform.value = null
198 + selectedApplication.value = null
199 + showFilters.value = false
200 +}
201 +
202 +async function loadPolicies() {
203 + loading.value = true
204 + try {
205 + const res = await Api.sca.getPolicies()
206 + if (res.data.success) {
207 + policies.value = res.data.policies || []
208 + } else {
209 + message.warning(res.data?.message || "Failed to load SCA policies")
210 + }
211 + } catch (err: any) {
212 + message.error(err.response?.data?.message || "Failed to load SCA policies")
213 + } finally {
214 + loading.value = false
215 + }
216 +}
217 +
218 +onBeforeMount(() => {
219 + loadPolicies()
220 +})
221 +</script>
frontend/src/components/scaPolicies/PolicyCard.vue new
+68
@@ -0,0 +1,68 @@
1 +<template>
2 + <div class="h-full">
3 + <CardEntity
4 + hoverable
5 + clickable
6 + :embedded
7 + class="@container h-full"
8 + main-box-class="grow"
9 + card-entity-wrapper-class="h-full"
10 + header-box-class="flex-nowrap! items-start"
11 + @click.stop="showDetails = true"
12 + >
13 + <template #headerMain>
14 + <div class="flex flex-wrap items-center gap-2">
15 + <Badge type="splitted" size="small">
16 + <template #label>CIS</template>
17 + <template #value>{{ policy.cis_version }}</template>
18 + </Badge>
19 + <PlatformBadge :platform="policy.platform" />
20 + </div>
21 + </template>
22 + <template #headerExtra>
23 + <Badge size="small" color="primary">
24 + <template #value>{{ policy.app_version }}</template>
25 + </Badge>
26 + </template>
27 + <template #default>
28 + <div class="flex flex-col gap-2">
29 + <div class="font-semibold">{{ policy.name }}</div>
30 + <p class="line-clamp-3 text-sm">{{ policy.description }}</p>
31 + </div>
32 + </template>
33 + <template #mainExtra>
34 + <div class="flex flex-wrap items-center gap-2">
35 + <Badge type="splitted" size="small">
36 + <template #label>App</template>
37 + <template #value>{{ policy.application }}</template>
38 + </Badge>
39 + </div>
40 + </template>
41 + </CardEntity>
42 +
43 + <n-modal
44 + v-model:show="showDetails"
45 + preset="card"
46 + :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
47 + :title="policy.name"
48 + :bordered="false"
49 + segmented
50 + >
51 + <PolicyCardContent :policy="policy" />
52 + </n-modal>
53 + </div>
54 +</template>
55 +
56 +<script setup lang="ts">
57 +import type { ScaPolicyItem } from "@/types/sca.d"
58 +import { NModal } from "naive-ui"
59 +import { ref } from "vue"
60 +import Badge from "@/components/common/Badge.vue"
61 +import CardEntity from "@/components/common/cards/CardEntity.vue"
62 +import PlatformBadge from "@/components/common/PlatformBadge.vue"
63 +import PolicyCardContent from "./PolicyCardContent.vue"
64 +
65 +defineProps<{ policy: ScaPolicyItem; embedded?: boolean }>()
66 +
67 +const showDetails = ref(false)
68 +</script>
frontend/src/components/scaPolicies/PolicyCardContent.vue new
+275
@@ -0,0 +1,275 @@
1 +<template>
2 + <div class="flex flex-col gap-4 pb-1">
3 + <!-- Policy Information -->
4 + <PropsList :list="infoFields" embedded title="Policy Information" />
5 +
6 + <!-- Description -->
7 + <CardKV>
8 + <template #key>Description</template>
9 + <template #value>{{ policy.description }}</template>
10 + </CardKV>
11 +
12 + <!-- Deployment Instructions -->
13 + <CardKV>
14 + <template #key>
15 + <div class="flex items-center gap-2">
16 + <Icon :name="DeployIcon" :size="14" />
17 + <span>Deployment Instructions</span>
18 + </div>
19 + </template>
20 + <template #value>
21 + <div class="flex flex-col gap-2 text-sm">
22 + <p>
23 + Deploy this policy to a Wazuh agent by downloading the
24 + <code>.yml</code>
25 + file to
26 + <code>/var/ossec/ruleset/sca/</code>
27 + , setting the correct ownership, and restarting the agent:
28 + </p>
29 + <CodeSource :code="deploymentCommands" lang="bash" />
30 + </div>
31 + </template>
32 + </CardKV>
33 +
34 + <!-- Agent Detection -->
35 + <CardKV>
36 + <template #key>
37 + <div class="flex items-center justify-between gap-2">
38 + <div class="flex items-center gap-2">
39 + <Icon :name="AgentsIcon" :size="14" />
40 + <span>Agents with {{ policy.application }}</span>
41 + </div>
42 + <n-button
43 + v-if="!agentsLoaded"
44 + size="tiny"
45 + type="primary"
46 + secondary
47 + :loading="loadingAgents"
48 + @click="detectAgents"
49 + >
50 + <template #icon>
51 + <Icon :name="SearchIcon" />
52 + </template>
53 + Detect Agents
54 + </n-button>
55 + </div>
56 + </template>
57 + <template #value>
58 + <n-spin :show="loadingAgents">
59 + <div v-if="agentsLoaded">
60 + <div v-if="agentsResponse && agentsResponse.matched_agents.length" class="flex flex-col gap-3">
61 + <div class="text-xs opacity-60">
62 + Found
63 + <strong>{{ agentsResponse.total }}</strong>
64 + agent(s) with
65 + <strong>{{ agentsResponse.display_name }}</strong>
66 + installed
67 + </div>
68 + <div class="grid grid-cols-1 gap-3 py-1 lg:grid-cols-2">
69 + <CardEntity
70 + v-for="agent in agentsResponse.matched_agents"
71 + :key="`${agent.agent_id}-${agent.package_name}`"
72 + embedded
73 + size="small"
74 + class="h-full"
75 + main-box-class="grow"
76 + card-entity-wrapper-class="h-full"
77 + >
78 + <template #headerMain>
79 + <div class="text-default flex items-center gap-2">
80 + <span class="text-sm font-semibold">
81 + {{ agent.agent_name || "Unknown" }}
82 + </span>
83 + <Badge size="small">
84 + <template #value>ID: {{ agent.agent_id }}</template>
85 + </Badge>
86 + </div>
87 + </template>
88 + <template #default>
89 + <div class="flex flex-wrap gap-2 text-xs">
90 + <Badge type="splitted" size="small">
91 + <template #label>Package</template>
92 + <template #value>{{ agent.package_name }}</template>
93 + </Badge>
94 + <Badge v-if="agent.package_version" type="splitted" size="small">
95 + <template #label>Version</template>
96 + <template #value>{{ agent.package_version }}</template>
97 + </Badge>
98 + <Badge v-if="agent.package_architecture" type="splitted" size="small">
99 + <template #label>Arch</template>
100 + <template #value>{{ agent.package_architecture }}</template>
101 + </Badge>
102 + </div>
103 + </template>
104 + </CardEntity>
105 + </div>
106 + </div>
107 + <n-empty
108 + v-else
109 + description="No agents found with this package installed"
110 + class="h-24 justify-center"
111 + />
112 + </div>
113 + <div v-else class="text-secondary text-sm">
114 + Click "Detect Agents" to search for agents running {{ policy.application }}
115 + </div>
116 + </n-spin>
117 + </template>
118 + </CardKV>
119 +
120 + <!-- Applicable Policies -->
121 + <CardKV v-if="agentsResponse?.applicable_policies?.length">
122 + <template #key>Applicable SCA Policies</template>
123 + <template #value>
124 + <div class="flex flex-wrap gap-2">
125 + <Badge v-for="ap of agentsResponse.applicable_policies" :key="ap.id" color="primary">
126 + <template #value>{{ ap.name }}</template>
127 + </Badge>
128 + </div>
129 + </template>
130 + </CardKV>
131 +
132 + <!-- Policy YAML Content -->
133 + <CardKV>
134 + <template #key>
135 + <div class="flex items-center justify-between gap-2">
136 + <div class="flex items-center gap-2">
137 + <Icon :name="CodeIcon" :size="14" />
138 + <span>Policy YAML</span>
139 + </div>
140 + <n-button
141 + v-if="!yamlLoaded"
142 + size="tiny"
143 + type="primary"
144 + secondary
145 + :loading="loadingYaml"
146 + @click="loadYamlContent"
147 + >
148 + <template #icon>
149 + <Icon :name="DownloadIcon" />
150 + </template>
151 + Load YAML
152 + </n-button>
153 + </div>
154 + </template>
155 + <template #value>
156 + <n-spin :show="loadingYaml">
157 + <div v-if="yamlLoaded">
158 + <CodeSource v-if="yamlContent" :code="yamlContent" lang="yaml" />
159 + <n-empty v-else description="Failed to load YAML content" class="h-24 justify-center" />
160 + </div>
161 + <div v-else class="text-secondary text-sm">Click "Load YAML" to preview the policy content</div>
162 + </n-spin>
163 + </template>
164 + </CardKV>
165 + </div>
166 +</template>
167 +
168 +<script setup lang="ts">
169 +import type { ScaPolicyItem, ScaPackageAgentsResponse } from "@/types/sca.d"
170 +import { computed, ref } from "vue"
171 +import { NButton, NEmpty, NSpin, useMessage } from "naive-ui"
172 +import Api from "@/api"
173 +import Badge from "@/components/common/Badge.vue"
174 +import CardEntity from "@/components/common/cards/CardEntity.vue"
175 +import CardKV from "@/components/common/cards/CardKV.vue"
176 +import CodeSource from "@/components/common/CodeSource.vue"
177 +import Icon from "@/components/common/Icon.vue"
178 +import PropsList from "@/components/common/PropsList.vue"
179 +
180 +const props = defineProps<{ policy: ScaPolicyItem }>()
181 +
182 +const message = useMessage()
183 +
184 +const loadingAgents = ref(false)
185 +const agentsLoaded = ref(false)
186 +const agentsResponse = ref<ScaPackageAgentsResponse | null>(null)
187 +
188 +const loadingYaml = ref(false)
189 +const yamlLoaded = ref(false)
190 +const yamlContent = ref<string | null>(null)
191 +
192 +const AgentsIcon = "carbon:devices"
193 +const SearchIcon = "carbon:search"
194 +const CodeIcon = "carbon:code"
195 +const DownloadIcon = "carbon:download"
196 +const DeployIcon = "carbon:deploy"
197 +
198 +const fileName = computed(() => props.policy.file.split("/").pop())
199 +
200 +const infoFields = computed(() => ({
201 + id: props.policy.id,
202 + application: props.policy.application,
203 + app_version: props.policy.app_version,
204 + platform: props.policy.platform,
205 + cis_version: props.policy.cis_version
206 +}))
207 +
208 +const deploymentCommands = computed(() =>
209 + [
210 + `# Download the SCA policy`,
211 + `wget https://raw.githubusercontent.com/socfortress/CoPilot-SCA/main/${props.policy.file} -O /var/ossec/ruleset/sca/${fileName.value}`,
212 + ``,
213 + `# Set correct ownership`,
214 + `chown root:wazuh /var/ossec/ruleset/sca/${fileName.value}`,
215 + ``,
216 + `# Restart the Wazuh agent`,
217 + `systemctl restart wazuh-agent`,
218 + ``,
219 + `# Verify the policy is loaded`,
220 + `tail -f /var/ossec/logs/ossec.log`
221 + ].join("\n")
222 +)
223 +
224 +function getRegistryKey(application: string): string | null {
225 + const app = application.toLowerCase()
226 + if (app.includes("apache")) return "apache"
227 + if (app.includes("nginx")) return "nginx"
228 + if (app.includes("iis")) return "iis"
229 + if (app.includes("mysql") || app.includes("mariadb")) return "mysql"
230 + if (app.includes("postgresql") || app.includes("postgres")) return "postgresql"
231 + if (app === "sqlserver" || app.includes("sql server") || app.includes("mssql")) return "sqlserver"
232 + return null
233 +}
234 +
235 +async function detectAgents() {
236 + const registryKey = getRegistryKey(props.policy.application)
237 + if (!registryKey) {
238 + message.warning(`No package registry mapping for "${props.policy.application}"`)
239 + agentsLoaded.value = true
240 + return
241 + }
242 +
243 + loadingAgents.value = true
244 + try {
245 + const res = await Api.sca.getAgentsForPackage(registryKey)
246 + if (res.data.success) {
247 + agentsResponse.value = res.data
248 + } else {
249 + message.warning(res.data?.message || "Failed to detect agents")
250 + }
251 + } catch (err: any) {
252 + message.error(err.response?.data?.message || "Failed to detect agents")
253 + } finally {
254 + loadingAgents.value = false
255 + agentsLoaded.value = true
256 + }
257 +}
258 +
259 +async function loadYamlContent() {
260 + loadingYaml.value = true
261 + try {
262 + const res = await Api.sca.getPolicyContent(props.policy.id)
263 + if (res.data.success) {
264 + yamlContent.value = res.data.content
265 + } else {
266 + message.warning(res.data?.message || "Failed to load policy content")
267 + }
268 + } catch (err: any) {
269 + message.error(err.response?.data?.message || "Failed to load policy content")
270 + } finally {
271 + loadingYaml.value = false
272 + yamlLoaded.value = true
273 + }
274 +}
275 +</script>
frontend/src/router/index.ts
+6
@@ -111,6 +111,12 @@ const router = createRouter({
111 component: () => import("@/views/agents/ScaOverview.vue"),
112 meta: { title: "SCA Overview" }
113 },
114 + {
115 + path: "sca-policies",
116 + name: "ScaPolicies",
117 + component: () => import("@/views/agents/ScaPolicies.vue"),
118 + meta: { title: "SCA Policies" }
119 + },
120 {
121 path: "/patch-tuesday",
122 name: "PatchTuesday",
frontend/src/types/sca.d.ts
+64
@@ -171,3 +171,67 @@ export interface ScaStreamError {
171 agent_id?: string
172 agent_name?: string
173 }
174 +
175 +// ── SCA Policies (from CoPilot-SCA GitHub repo) ──
176 +
177 +export interface ScaPolicyItem {
178 + id: string
179 + name: string
180 + description: string
181 + file: string
182 + application: string
183 + app_version: string
184 + platform: string
185 + cis_version: string
186 +}
187 +
188 +export interface ScaPoliciesIndexResponse {
189 + version: string
190 + last_updated: string
191 + policies: ScaPolicyItem[]
192 + success: boolean
193 + message: string
194 +}
195 +
196 +export interface ScaPolicyContentResponse {
197 + policy_id: string
198 + file_path: string
199 + content: string
200 + success: boolean
201 + message: string
202 +}
203 +
204 +// ── SCA Package Registry & Agent Detection ──
205 +
206 +export interface ScaPackageRegistryItem {
207 + key: string
208 + display_name: string
209 + sca_application: string
210 + package_patterns: string[]
211 +}
212 +
213 +export interface ScaPackageRegistryResponse {
214 + entries: ScaPackageRegistryItem[]
215 + total: number
216 + success: boolean
217 + message: string
218 +}
219 +
220 +export interface AgentPackageMatch {
221 + agent_id: string | null
222 + agent_name: string | null
223 + package_name: string | null
224 + package_version: string | null
225 + package_architecture: string | null
226 +}
227 +
228 +export interface ScaPackageAgentsResponse {
229 + registry_key: string
230 + display_name: string
231 + sca_application: string
232 + matched_agents: AgentPackageMatch[]
233 + total: number
234 + applicable_policies: ScaPolicyItem[]
235 + success: boolean
236 + message: string
237 +}
frontend/src/views/agents/ScaPolicies.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <List />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import List from "@/components/scaPolicies/List.vue"
9 +</script>