main
py 1,180 lines 43.1 KB
Raw
1 from typing import Optional
2
3 from fastapi import APIRouter
4 from fastapi import HTTPException
5 from fastapi import Query
6 from fastapi import Security
7 from loguru import logger
8
9 from app.auth.routes.auth import AuthHandler
10 from app.connectors.graylog.routes.events import get_all_event_definitions
11 from app.connectors.graylog.schema.events import GraylogEventDefinitionsResponse
12 from app.integrations.copilot_searches.schema.copilot_searches import (
13 BulkProvisionGraylogAlertRequest,
14 )
15 from app.integrations.copilot_searches.schema.copilot_searches import (
16 BulkProvisionGraylogAlertResponse,
17 )
18 from app.integrations.copilot_searches.schema.copilot_searches import (
19 BulkProvisionRuleResult,
20 )
21 from app.integrations.copilot_searches.schema.copilot_searches import (
22 CatalogComplianceFrameworksResponse,
23 )
24 from app.integrations.copilot_searches.schema.copilot_searches import (
25 CatalogComplianceResponse,
26 )
27 from app.integrations.copilot_searches.schema.copilot_searches import (
28 CatalogCoverageGapsResponse,
29 )
30 from app.integrations.copilot_searches.schema.copilot_searches import (
31 CatalogLogTestRequest,
32 )
33 from app.integrations.copilot_searches.schema.copilot_searches import (
34 CatalogLogTestResponse,
35 )
36 from app.integrations.copilot_searches.schema.copilot_searches import (
37 CatalogStatsResponse,
38 )
39 from app.integrations.copilot_searches.schema.copilot_searches import (
40 CatalogStoryDetailResponse,
41 )
42 from app.integrations.copilot_searches.schema.copilot_searches import (
43 CatalogStoryListResponse,
44 )
45 from app.integrations.copilot_searches.schema.copilot_searches import (
46 CatalogWazuhRuleDetailResponse,
47 )
48 from app.integrations.copilot_searches.schema.copilot_searches import (
49 CatalogWazuhRulesResponse,
50 )
51 from app.integrations.copilot_searches.schema.copilot_searches import (
52 ExecuteGraylogQueryRequest,
53 )
54 from app.integrations.copilot_searches.schema.copilot_searches import (
55 ExecuteSearchRequest,
56 )
57 from app.integrations.copilot_searches.schema.copilot_searches import (
58 ExecuteSearchResponse,
59 )
60 from app.integrations.copilot_searches.schema.copilot_searches import (
61 GraylogProvisioningStatusResponse,
62 )
63 from app.integrations.copilot_searches.schema.copilot_searches import (
64 GraylogQueryResponse,
65 )
66 from app.integrations.copilot_searches.schema.copilot_searches import (
67 MitreCoverageResponse,
68 )
69 from app.integrations.copilot_searches.schema.copilot_searches import PlatformFilter
70 from app.integrations.copilot_searches.schema.copilot_searches import (
71 ProvisionGraylogAlertRequest,
72 )
73 from app.integrations.copilot_searches.schema.copilot_searches import (
74 ProvisionGraylogAlertRequest as PerRuleProvisionRequest,
75 )
76 from app.integrations.copilot_searches.schema.copilot_searches import (
77 ProvisionGraylogAlertResponse,
78 )
79 from app.integrations.copilot_searches.schema.copilot_searches import RefreshResponse
80 from app.integrations.copilot_searches.schema.copilot_searches import RuleDetailResponse
81 from app.integrations.copilot_searches.schema.copilot_searches import RuleListResponse
82 from app.integrations.copilot_searches.schema.copilot_searches import RulesByIdsRequest
83 from app.integrations.copilot_searches.schema.copilot_searches import RulesByIdsResponse
84 from app.integrations.copilot_searches.schema.copilot_searches import RuleSeverity
85 from app.integrations.copilot_searches.schema.copilot_searches import RuleStatsResponse
86 from app.integrations.copilot_searches.schema.copilot_searches import RuleStatus
87 from app.integrations.copilot_searches.services.copilot_searches import (
88 execute_rule_search,
89 )
90 from app.integrations.copilot_searches.services.copilot_searches import (
91 generate_graylog_query,
92 )
93 from app.integrations.copilot_searches.services.copilot_searches import get_rule_by_id
94 from app.integrations.copilot_searches.services.copilot_searches import get_rule_by_name
95 from app.integrations.copilot_searches.services.copilot_searches import get_rules_by_ids
96 from app.integrations.copilot_searches.services.copilot_searches import get_rules_list
97 from app.integrations.copilot_searches.services.copilot_searches import get_rules_stats
98 from app.integrations.copilot_searches.services.copilot_searches import (
99 provision_graylog_alert_from_rule,
100 )
101 from app.integrations.copilot_searches.services.copilot_searches import (
102 refresh_rules_cache,
103 )
104 from app.integrations.copilot_searches.services.copilot_searches import rules_cache
105 from app.integrations.copilot_searches.services.detection_catalog import (
106 get_catalog_stats,
107 )
108 from app.integrations.copilot_searches.services.detection_catalog import (
109 get_story_detail,
110 )
111 from app.integrations.copilot_searches.services.detection_catalog import (
112 get_wazuh_rule_detail,
113 )
114 from app.integrations.copilot_searches.services.detection_catalog import (
115 list_compliance_frameworks,
116 )
117 from app.integrations.copilot_searches.services.detection_catalog import (
118 list_compliance_pivot,
119 )
120 from app.integrations.copilot_searches.services.detection_catalog import (
121 list_coverage_gaps,
122 )
123 from app.integrations.copilot_searches.services.detection_catalog import list_stories
124 from app.integrations.copilot_searches.services.detection_catalog import (
125 list_wazuh_rules,
126 )
127 from app.integrations.copilot_searches.services.detection_catalog import run_log_test
128 from app.integrations.copilot_searches.services.mitre_coverage import get_coverage
129 from app.integrations.copilot_searches.services.mitre_coverage import mitre_matrix
130
131 copilot_searches_router = APIRouter()
132
133
134 async def check_if_event_definition_exists(event_definition_title: str) -> bool:
135 """
136 Check if an event definition with the given title already exists in Graylog.
137
138 Args:
139 event_definition_title: The title to check
140
141 Returns:
142 True if the event definition already exists
143
144 Raises:
145 HTTPException: If failed to check or if already exists
146 """
147 event_definitions_response = await get_all_event_definitions()
148 if not event_definitions_response.success:
149 raise HTTPException(
150 status_code=500,
151 detail="Failed to collect event definitions from Graylog",
152 )
153
154 event_definitions_response = GraylogEventDefinitionsResponse(
155 **event_definitions_response.model_dump(),
156 )
157
158 existing_titles = [ed.title for ed in event_definitions_response.event_definitions]
159
160 if event_definition_title in existing_titles:
161 raise HTTPException(
162 status_code=400,
163 detail=f"Event definition '{event_definition_title}' already exists in Graylog",
164 )
165
166 return False
167
168
169 @copilot_searches_router.get(
170 "",
171 response_model=RuleListResponse,
172 description="List all detection rules with optional filtering",
173 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
174 )
175 async def list_rules(
176 platform: PlatformFilter = Query(
177 PlatformFilter.ALL,
178 description="Filter by platform (linux, windows, powershell, all)",
179 ),
180 status: Optional[RuleStatus] = Query(
181 None,
182 description="Filter by rule status",
183 ),
184 severity: Optional[RuleSeverity] = Query(
185 None,
186 description="Filter by severity level",
187 ),
188 mitre_id: Optional[str] = Query(
189 None,
190 description="Filter by MITRE ATT&CK technique ID (e.g., T1136)",
191 ),
192 search: Optional[str] = Query(
193 None,
194 description="Search in rule name and description",
195 ),
196 has_graylog: Optional[bool] = Query(
197 None,
198 description="Filter for rules with Graylog queries",
199 ),
200 skip: int = Query(0, ge=0, description="Number of rules to skip"),
201 limit: int = Query(100, ge=1, le=500, description="Maximum rules to return"),
202 ):
203 """
204 List all detection rules with optional filtering.
205
206 Supports filtering by:
207 - **platform**: linux, windows, powershell, cve or all
208 - **status**: production, experimental, deprecated
209 - **severity**: low, medium, high, critical
210 - **mitre_id**: MITRE ATT&CK technique ID
211 - **search**: Text search in name/description
212 - **has_graylog**: Filter for rules with Graylog queries
213 """
214 result = await get_rules_list(
215 platform=platform,
216 status=status,
217 severity=severity,
218 mitre_id=mitre_id,
219 search=search,
220 has_graylog=has_graylog,
221 skip=skip,
222 limit=limit,
223 )
224
225 return RuleListResponse(**result)
226
227
228 @copilot_searches_router.get(
229 "/linux",
230 response_model=RuleListResponse,
231 description="List all Linux detection rules",
232 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
233 )
234 async def list_linux_rules(
235 status: Optional[RuleStatus] = Query(None),
236 severity: Optional[RuleSeverity] = Query(None),
237 mitre_id: Optional[str] = Query(None),
238 search: Optional[str] = Query(None),
239 has_graylog: Optional[bool] = Query(None),
240 skip: int = Query(0, ge=0),
241 limit: int = Query(100, ge=1, le=500),
242 ):
243 """List all Linux detection rules."""
244 result = await get_rules_list(
245 platform=PlatformFilter.LINUX,
246 status=status,
247 severity=severity,
248 mitre_id=mitre_id,
249 search=search,
250 has_graylog=has_graylog,
251 skip=skip,
252 limit=limit,
253 )
254
255 return RuleListResponse(**result)
256
257
258 @copilot_searches_router.get(
259 "/windows",
260 response_model=RuleListResponse,
261 description="List all Windows detection rules",
262 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
263 )
264 async def list_windows_rules(
265 status: Optional[RuleStatus] = Query(None),
266 severity: Optional[RuleSeverity] = Query(None),
267 mitre_id: Optional[str] = Query(None),
268 search: Optional[str] = Query(None),
269 has_graylog: Optional[bool] = Query(None),
270 skip: int = Query(0, ge=0),
271 limit: int = Query(100, ge=1, le=500),
272 ):
273 """List all Windows detection rules."""
274 result = await get_rules_list(
275 platform=PlatformFilter.WINDOWS,
276 status=status,
277 severity=severity,
278 mitre_id=mitre_id,
279 search=search,
280 has_graylog=has_graylog,
281 skip=skip,
282 limit=limit,
283 )
284
285 return RuleListResponse(**result)
286
287
288 @copilot_searches_router.get(
289 "/powershell",
290 response_model=RuleListResponse,
291 description="List all PowerShell detection rules",
292 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
293 )
294 async def list_powershell_rules(
295 status: Optional[RuleStatus] = Query(None),
296 severity: Optional[RuleSeverity] = Query(None),
297 mitre_id: Optional[str] = Query(None),
298 search: Optional[str] = Query(None),
299 has_graylog: Optional[bool] = Query(None),
300 skip: int = Query(0, ge=0),
301 limit: int = Query(100, ge=1, le=500),
302 ):
303 """List all PowerShell detection rules."""
304 result = await get_rules_list(
305 platform=PlatformFilter.POWERSHELL,
306 status=status,
307 severity=severity,
308 mitre_id=mitre_id,
309 search=search,
310 has_graylog=has_graylog,
311 skip=skip,
312 limit=limit,
313 )
314
315 return RuleListResponse(**result)
316
317
318 @copilot_searches_router.get(
319 "/cve",
320 response_model=RuleListResponse,
321 description="List all detection rules that have CVE tags",
322 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
323 )
324 async def list_cve_rules(
325 status: Optional[RuleStatus] = Query(None),
326 severity: Optional[RuleSeverity] = Query(None),
327 mitre_id: Optional[str] = Query(None),
328 search: Optional[str] = Query(None),
329 has_graylog: Optional[bool] = Query(None),
330 skip: int = Query(0, ge=0),
331 limit: int = Query(100, ge=1, le=500),
332 ):
333 """List all detection rules that have CVE tags."""
334 # Pull a generous slice unfiltered, then keep only CVE-tagged rules and
335 # paginate those. Previously the route filtered after slicing, which made
336 # pagination wrong (a page could come back empty even when more CVE rules
337 # existed later in the list).
338 full = await get_rules_list(
339 status=status,
340 severity=severity,
341 mitre_id=mitre_id,
342 search=search,
343 has_graylog=has_graylog,
344 skip=0,
345 limit=500,
346 )
347
348 cve_only = [r for r in full["rules"] if r.cve]
349 paginated = cve_only[skip : skip + limit]
350
351 return RuleListResponse(
352 total=full["total"],
353 filtered=len(cve_only),
354 platform=full["platform"],
355 rules=paginated,
356 )
357
358
359 @copilot_searches_router.get(
360 "/stats",
361 response_model=RuleStatsResponse,
362 description="Get statistics about loaded detection rules",
363 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
364 )
365 async def get_rule_stats():
366 """Get statistics about loaded detection rules."""
367 result = await get_rules_stats()
368
369 return RuleStatsResponse(**result)
370
371
372 @copilot_searches_router.get(
373 "/id/{rule_id}",
374 response_model=RuleDetailResponse,
375 description="Get full details of a specific rule by its ID",
376 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
377 )
378 async def get_rule_by_id_endpoint(rule_id: str):
379 """
380 Get full details of a specific rule by its ID.
381
382 Returns the complete rule including the search query, Graylog query, and raw YAML.
383 """
384 rule = await get_rule_by_id(rule_id)
385
386 if rule is None:
387 raise HTTPException(
388 status_code=404,
389 detail=f"Rule with ID '{rule_id}' not found",
390 )
391
392 return RuleDetailResponse(rule=rule)
393
394
395 @copilot_searches_router.get(
396 "/name/{rule_name:path}",
397 response_model=RuleDetailResponse,
398 description="Get full details of a specific rule by its name",
399 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
400 )
401 async def get_rule_by_name_endpoint(rule_name: str):
402 """
403 Get full details of a specific rule by its name.
404
405 Supports fuzzy matching - spaces can be underscores or hyphens.
406
407 Examples:
408 - `/name/Linux Auditd Add User`
409 - `/name/linux_auditd_add_user`
410 - `/name/linux-auditd-add-user`
411 """
412 rule = await get_rule_by_name(rule_name)
413
414 if rule is None:
415 raise HTTPException(
416 status_code=404,
417 detail=f"Rule with name '{rule_name}' not found",
418 )
419
420 return RuleDetailResponse(rule=rule)
421
422
423 @copilot_searches_router.post(
424 "/by-ids",
425 response_model=RulesByIdsResponse,
426 description="Fetch many rule summaries by ID in a single request",
427 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
428 )
429 async def get_rules_by_ids_endpoint(request: RulesByIdsRequest):
430 """
431 Fetch multiple rule summaries by ID in one round-trip.
432
433 Used by the MITRE matrix drawer to avoid N+1 calls when displaying
434 the rules covering a technique.
435 """
436 if not request.ids:
437 return RulesByIdsResponse(rules=[], missing=[])
438 found, missing = await get_rules_by_ids(request.ids)
439 return RulesByIdsResponse(rules=found, missing=missing)
440
441
442 @copilot_searches_router.get(
443 "/mitre/coverage",
444 response_model=MitreCoverageResponse,
445 description="MITRE ATT&CK matrix with per-technique rule coverage from CoPilot Searches",
446 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
447 )
448 async def get_mitre_coverage(
449 platform: PlatformFilter = Query(
450 PlatformFilter.ALL,
451 description="Restrict coverage to rules matching this platform",
452 ),
453 severity: Optional[RuleSeverity] = Query(None, description="Restrict coverage to rules of this severity"),
454 status: Optional[RuleStatus] = Query(None, description="Restrict coverage to rules of this status"),
455 has_graylog: Optional[bool] = Query(
456 None,
457 description="If true, only consider rules that have a Graylog query",
458 ),
459 search: Optional[str] = Query(
460 None,
461 description="Substring match against rule name/description",
462 ),
463 ):
464 """
465 Build the MITRE ATT&CK Enterprise matrix annotated with the CoPilot Search
466 rules that cover each technique and sub-technique.
467
468 Optional filters narrow which rules contribute to coverage so users can
469 answer "what's my Windows-only coverage?" or "where do I have *production*
470 detection?" without leaving the matrix view.
471 """
472 try:
473 result = await get_coverage(
474 platform=platform,
475 severity=severity,
476 status=status,
477 has_graylog=has_graylog,
478 search=search,
479 )
480 return MitreCoverageResponse(**result)
481 except Exception as e:
482 raise HTTPException(
483 status_code=503,
484 detail=f"Failed to build MITRE coverage: {str(e)}",
485 )
486
487
488 @copilot_searches_router.post(
489 "/mitre/refresh",
490 description="Force re-fetch of the MITRE ATT&CK STIX bundle",
491 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
492 )
493 async def refresh_mitre_matrix():
494 """Force re-fetch of the MITRE ATT&CK STIX bundle."""
495 try:
496 await mitre_matrix.refresh()
497 return {
498 "success": True,
499 "message": "MITRE matrix refreshed",
500 "tactics": len(mitre_matrix.tactics),
501 "techniques": len(mitre_matrix.techniques),
502 }
503 except Exception as e:
504 raise HTTPException(status_code=503, detail=f"Failed to refresh MITRE matrix: {str(e)}")
505
506
507 @copilot_searches_router.get(
508 "/mitre/{technique_id}",
509 response_model=RuleListResponse,
510 description="Get all rules that detect a specific MITRE ATT&CK technique",
511 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
512 )
513 async def get_rules_by_mitre(
514 technique_id: str,
515 platform: PlatformFilter = Query(PlatformFilter.ALL),
516 ):
517 """
518 Get all rules that detect a specific MITRE ATT&CK technique.
519
520 Examples:
521 - `/mitre/T1136` - Account creation
522 - `/mitre/T1003` - Credential dumping
523 - `/mitre/T1068` - Privilege escalation
524 """
525 result = await get_rules_list(
526 platform=platform,
527 mitre_id=technique_id,
528 )
529
530 return RuleListResponse(**result)
531
532
533 @copilot_searches_router.post(
534 "/refresh",
535 response_model=RefreshResponse,
536 description="Manually refresh the rules cache from GitHub",
537 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
538 )
539 async def refresh_rules():
540 """
541 Manually refresh the rules cache from GitHub.
542
543 This fetches the latest rules from the repository and updates the cache.
544 """
545 try:
546 result = await refresh_rules_cache()
547 return RefreshResponse(**result)
548 except Exception as e:
549 raise HTTPException(
550 status_code=503,
551 detail=f"Failed to refresh rules: {str(e)}",
552 )
553
554
555 @copilot_searches_router.post(
556 "/execute",
557 response_model=ExecuteSearchResponse,
558 description="Execute a detection rule search against the Wazuh indexer",
559 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
560 )
561 async def execute_search(request: ExecuteSearchRequest):
562 """
563 Execute a detection rule search against the Wazuh indexer.
564
565 This endpoint takes a rule ID and parameters, substitutes the parameters
566 into the rule's search query, and executes it against the specified index.
567
568 **Required Parameters:**
569 - **rule_id**: The ID of the rule to execute
570 - **index_pattern**: The Elasticsearch index pattern to search
571
572 **Optional Parameters:**
573 - **parameters**: Dictionary of parameter values to substitute
574 - **size**: Override the default result size
575
576 **Example Request:**
577 ```json
578 {
579 "rule_id": "linux-auditd-add-user-001",
580 "index_pattern": "wazuh-alerts-*",
581 "parameters": {
582 "AGENT_NAME": "my-server",
583 "CUSTOMER_CODE": "lab",
584 "START_TIME": "now-24h",
585 "END_TIME": "now"
586 },
587 "size": 50
588 }
589 ```
590 """
591 try:
592 result = await execute_rule_search(request)
593 return result
594 except ValueError as e:
595 raise HTTPException(
596 status_code=400,
597 detail=str(e),
598 )
599 except Exception as e:
600 raise HTTPException(
601 status_code=500,
602 detail=f"Search execution failed: {str(e)}",
603 )
604
605
606 @copilot_searches_router.post(
607 "/graylog",
608 response_model=GraylogQueryResponse,
609 description="Generate a Graylog query from a rule with parameter substitution",
610 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
611 )
612 async def generate_graylog_query_endpoint(request: ExecuteGraylogQueryRequest):
613 """
614 Generate a Graylog query string from a rule with parameter substitution.
615
616 This endpoint takes a rule ID and parameters, substitutes the parameters
617 into the rule's Graylog query template, and returns the ready-to-use query.
618
619 **Required Parameters:**
620 - **rule_id**: The ID of the rule to use
621
622 **Optional Parameters:**
623 - **parameters**: Dictionary of parameter values to substitute
624
625 **Example Request:**
626 ```json
627 {
628 "rule_id": "linux-auditd-add-user-001",
629 "parameters": {
630 "AGENT_NAME": "my-server",
631 "CUSTOMER_CODE": "lab"
632 }
633 }
634 ```
635
636 **Example Response:**
637 ```json
638 {
639 "success": true,
640 "rule_id": "linux-auditd-add-user-001",
641 "rule_name": "Linux Auditd Add User",
642 "graylog_query": "(full_log:/.*useradd.*/ OR full_log:/.*adduser.*/) AND agent_name:my-server",
643 "original_query": "(full_log:/.*useradd.*/ OR full_log:/.*adduser.*/) AND agent_name:${AGENT_NAME}"
644 }
645 ```
646 """
647 try:
648 result = await generate_graylog_query(request)
649 return result
650 except ValueError as e:
651 raise HTTPException(
652 status_code=400,
653 detail=str(e),
654 )
655 except Exception as e:
656 raise HTTPException(
657 status_code=500,
658 detail=f"Graylog query generation failed: {str(e)}",
659 )
660
661
662 @copilot_searches_router.post(
663 "/provision/graylog/check",
664 response_model=GraylogProvisioningStatusResponse,
665 description="For a list of rule IDs, return which ones already have a matching Graylog event definition",
666 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
667 )
668 async def check_graylog_provisioning_status(request: RulesByIdsRequest):
669 """
670 For each requested rule, compute the alert title that bulk-provision would
671 use and check whether Graylog already has an event definition with that
672 title. Lets the UI mark rules as "in Graylog" without re-provisioning.
673 """
674 existing_titles: set[str] = set()
675 warning: Optional[str] = None
676 try:
677 ed_resp = await get_all_event_definitions()
678 if ed_resp.success:
679 ed = GraylogEventDefinitionsResponse(**ed_resp.model_dump())
680 existing_titles = {e.title for e in ed.event_definitions}
681 else:
682 warning = "Failed to read event definitions from Graylog"
683 except Exception as e:
684 warning = f"Could not reach Graylog: {e}"
685 logger.warning(f"check-provisioning: {warning}")
686
687 await rules_cache.ensure_loaded()
688 provisioned: dict[str, bool] = {}
689 for rule_id in request.ids:
690 rule = rules_cache.get_rule_by_id(rule_id)
691 if rule is None:
692 continue
693 if warning:
694 # Conservative: don't claim "in Graylog" when we can't verify.
695 provisioned[rule_id] = False
696 continue
697 alert_title = rule.get("name", "").upper().replace(" ", " - ")
698 provisioned[rule_id] = alert_title in existing_titles
699
700 return GraylogProvisioningStatusResponse(
701 success=True,
702 provisioned=provisioned,
703 warning=warning,
704 )
705
706
707 @copilot_searches_router.post(
708 "/provision/graylog/bulk",
709 response_model=BulkProvisionGraylogAlertResponse,
710 description="Provision multiple CoPilot Search rules as Graylog event definitions in a single call",
711 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
712 )
713 async def bulk_provision_graylog_alerts(request: BulkProvisionGraylogAlertRequest):
714 """
715 Provision a batch of CoPilot Search rules as Graylog event definitions.
716
717 The endpoint never aborts on a single failure — instead, each rule's result
718 is captured (`provisioned`, `skipped`, or `failed`) and returned together so
719 the UI can show a partial-success summary. Skips are conservative: any rule
720 that has no Graylog query, or whose alert title already exists in Graylog,
721 is reported as skipped rather than failed.
722 """
723 # Resolve existing event definition titles once so we don't query Graylog
724 # per rule.
725 existing_titles: set[str] = set()
726 try:
727 ed_resp = await get_all_event_definitions()
728 if ed_resp.success:
729 ed = GraylogEventDefinitionsResponse(**ed_resp.model_dump())
730 existing_titles = {e.title for e in ed.event_definitions}
731 except Exception as e:
732 # If we can't pre-fetch the existing list, fall back to skipping the
733 # collision check. The per-rule provision call will surface failures.
734 logger.warning(f"bulk-provision: could not list event definitions: {e}")
735
736 results: list[BulkProvisionRuleResult] = []
737
738 for rule_id in request.rule_ids:
739 try:
740 rule = await get_rule_by_id(rule_id)
741 if rule is None:
742 results.append(
743 BulkProvisionRuleResult(rule_id=rule_id, status="failed", reason="Rule not found"),
744 )
745 continue
746 if rule.graylog is None or not rule.graylog.query:
747 results.append(
748 BulkProvisionRuleResult(
749 rule_id=rule_id,
750 rule_name=rule.name,
751 status="skipped",
752 reason="Rule has no Graylog query",
753 ),
754 )
755 continue
756
757 alert_title = rule.name.upper().replace(" ", " - ")
758 if alert_title in existing_titles:
759 results.append(
760 BulkProvisionRuleResult(
761 rule_id=rule_id,
762 rule_name=rule.name,
763 alert_title=alert_title,
764 status="skipped",
765 reason="Event definition with this title already exists in Graylog",
766 ),
767 )
768 continue
769
770 single = PerRuleProvisionRequest(
771 rule_id=rule_id,
772 search_within_seconds=request.search_within_seconds,
773 execute_every_seconds=request.execute_every_seconds,
774 streams=request.streams,
775 custom_title=None,
776 priority=request.priority,
777 event_limit=request.event_limit,
778 )
779 await provision_graylog_alert_from_rule(single)
780 existing_titles.add(alert_title) # avoid double-provisioning within the same batch
781 results.append(
782 BulkProvisionRuleResult(
783 rule_id=rule_id,
784 rule_name=rule.name,
785 alert_title=alert_title,
786 status="provisioned",
787 ),
788 )
789 except Exception as e:
790 logger.error(f"bulk-provision: rule '{rule_id}' failed: {e}")
791 results.append(
792 BulkProvisionRuleResult(rule_id=rule_id, status="failed", reason=str(e)),
793 )
794
795 provisioned = sum(1 for r in results if r.status == "provisioned")
796 skipped = sum(1 for r in results if r.status == "skipped")
797 failed = sum(1 for r in results if r.status == "failed")
798
799 return BulkProvisionGraylogAlertResponse(
800 success=failed == 0,
801 message=f"Provisioned {provisioned}, skipped {skipped}, failed {failed}",
802 provisioned_count=provisioned,
803 skipped_count=skipped,
804 failed_count=failed,
805 results=results,
806 )
807
808
809 @copilot_searches_router.post(
810 "/provision/graylog",
811 response_model=ProvisionGraylogAlertResponse,
812 description="Provision a Graylog event definition from a CoPilot Search rule",
813 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
814 )
815 async def provision_graylog_alert(request: ProvisionGraylogAlertRequest):
816 """
817 Provision a Graylog event definition from a CoPilot Search rule.
818
819 This endpoint takes a rule ID with a Graylog query and creates a Graylog
820 event definition that will alert when the query matches. The alert will
821 include standard field specifications for integration with CoPilot's
822 incident management workflow.
823
824 **Required Parameters:**
825 - **rule_id**: The ID of the rule to provision (must have a Graylog query)
826
827 **Optional Parameters:**
828 - **search_within_seconds**: Time window to search (default: 300 = 5 minutes)
829 - **execute_every_seconds**: Execution interval (default: 300 = 5 minutes)
830 - **streams**: List of Graylog stream IDs to limit the search
831 - **custom_title**: Custom alert title (default: uses rule name)
832 - **priority**: Alert priority 1-3 (default: 2 or derived from rule severity)
833 - **event_limit**: Max events per execution (default: 1000)
834
835 **Example Request:**
836 ```json
837 {
838 "rule_id": "linux-auditd-ssh-config-keys-deletion-001",
839 "search_within_seconds": 300,
840 "execute_every_seconds": 300,
841 "custom_title": "SSH Key Deletion Alert",
842 "priority": 3
843 }
844 ```
845
846 **Example Response:**
847 ```json
848 {
849 "success": true,
850 "message": "Graylog alert 'SSH Key Deletion Alert' provisioned successfully",
851 "rule_id": "linux-auditd-ssh-config-keys-deletion-001",
852 "rule_name": "Linux Auditd SSH Config Keys Deletion",
853 "alert_title": "SSH Key Deletion Alert",
854 "graylog_query": "(full_log:/.*\\/etc\\/ssh\\/.*/ OR ...) AND ..."
855 }
856 ```
857 """
858 try:
859 # Get the rule to determine the alert title for duplicate check
860 rule = await get_rule_by_id(request.rule_id)
861 if rule is None:
862 raise HTTPException(
863 status_code=404,
864 detail=f"Rule with ID '{request.rule_id}' not found",
865 )
866
867 # Check if rule has Graylog query
868 if rule.graylog is None:
869 raise HTTPException(
870 status_code=400,
871 detail=f"Rule '{request.rule_id}' does not contain a Graylog query",
872 )
873
874 # Determine the alert title
875 alert_title = request.custom_title if request.custom_title else rule.name.upper().replace(" ", " - ")
876
877 # Check if event definition already exists
878 await check_if_event_definition_exists(alert_title)
879
880 # Provision the alert
881 result = await provision_graylog_alert_from_rule(request)
882 return result
883
884 except HTTPException:
885 raise
886 except ValueError as e:
887 raise HTTPException(
888 status_code=400,
889 detail=str(e),
890 )
891 except Exception as e:
892 raise HTTPException(
893 status_code=500,
894 detail=f"Failed to provision Graylog alert: {str(e)}",
895 )
896
897
898 # =============================================================================
899 # Detection Catalog
900 #
901 # Read-only discovery surface over the rules already loaded by CoPilot Searches.
902 # Same underlying cache, same refresh story (POST /refresh above). All three
903 # endpoints walk the in-memory ``rules_cache`` and aggregate fresh per call;
904 # they do not maintain a second cache layer.
905 # =============================================================================
906
907
908 @copilot_searches_router.get(
909 "/catalog/stats",
910 response_model=CatalogStatsResponse,
911 description="Catalog overview counts (detections, stories, products, data sources, tactics).",
912 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
913 )
914 async def get_catalog_stats_endpoint() -> CatalogStatsResponse:
915 try:
916 stats = await get_catalog_stats()
917 return CatalogStatsResponse(**stats)
918 except Exception as e:
919 raise HTTPException(status_code=503, detail=f"Failed to build catalog stats: {str(e)}")
920
921
922 @copilot_searches_router.get(
923 "/catalog/stories",
924 response_model=CatalogStoryListResponse,
925 description=(
926 "List every analytic story discovered across the loaded detections, with "
927 "per-story summary fields (data sources, tactics, products, latest date, "
928 "detection count). Mirrors Splunk's Analytic Stories index table."
929 ),
930 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
931 )
932 async def list_catalog_stories_endpoint() -> CatalogStoryListResponse:
933 try:
934 stories = await list_stories()
935 return CatalogStoryListResponse(
936 success=True,
937 message=f"Found {len(stories)} story(ies)",
938 stories=stories,
939 )
940 except Exception as e:
941 raise HTTPException(status_code=503, detail=f"Failed to list catalog stories: {str(e)}")
942
943
944 @copilot_searches_router.get(
945 "/catalog/stories/{story_name:path}",
946 response_model=CatalogStoryDetailResponse,
947 description=(
948 "Detail view for a single analytic story: aggregated description, "
949 "the detections it contains, deduplicated data sources, references, "
950 "and metadata. ``story_name`` is the raw tag value (case-sensitive); "
951 "the route accepts arbitrary characters including spaces."
952 ),
953 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
954 )
955 async def get_catalog_story_detail_endpoint(story_name: str) -> CatalogStoryDetailResponse:
956 try:
957 detail = await get_story_detail(story_name)
958 if detail is None:
959 raise HTTPException(
960 status_code=404,
961 detail=f"No detections found for analytic story '{story_name}'",
962 )
963 return CatalogStoryDetailResponse(**detail)
964 except HTTPException:
965 raise
966 except Exception as e:
967 raise HTTPException(status_code=503, detail=f"Failed to load story detail: {str(e)}")
968
969
970 # ---------------------------------------------------------------------------
971 # Wazuh Rules tab — list + per-rule detail
972 #
973 # Route ordering note: the static ``/catalog/wazuh-rules`` MUST be declared
974 # before ``/catalog/wazuh-rules/{rule_id}`` so FastAPI doesn't route the bare
975 # list path into the wildcard handler and try to parse the empty path as an
976 # int. (Same footgun documented in CLAUDE.md "Things that bite".)
977 #
978 # Auth note: we deliberately call the underlying ``list_wazuh_rules`` /
979 # ``get_wazuh_rule_detail`` service functions instead of proxying the
980 # wazuh_manager router (which is admin-only). Catalog viewers should be able
981 # to *see* rule metadata without holding admin scope; the management surface
982 # (enable / disable / upload) stays gated where it already is.
983 # ---------------------------------------------------------------------------
984
985
986 @copilot_searches_router.get(
987 "/catalog/wazuh-rules",
988 response_model=CatalogWazuhRulesResponse,
989 description=(
990 "List the full Wazuh Manager ruleset projected to the catalog's "
991 "index-table shape. Returns every rule in one shot — pagination "
992 "and filtering happen client-side in the same pattern as the "
993 "Analytic Stories tab. When the Wazuh Manager is unreachable the "
994 "response carries ``available=false`` + ``unavailable_reason`` so "
995 "the UI can render an inline empty state instead of erroring."
996 ),
997 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
998 )
999 async def list_catalog_wazuh_rules_endpoint(
1000 customer_code: Optional[str] = Query(
1001 None,
1002 description=(
1003 "Optional customer code (e.g. ``00002``, ``lab``). When set, the "
1004 "Hits 30d / Hits 7d / Last fired columns are scoped to this "
1005 "customer's alerts only. When unset, the global firing-stats "
1006 "cache is used."
1007 ),
1008 ),
1009 ) -> CatalogWazuhRulesResponse:
1010 try:
1011 payload = await list_wazuh_rules(customer_code=customer_code)
1012 return CatalogWazuhRulesResponse(
1013 success=True,
1014 message=(f"Listed {payload['total']} Wazuh rule(s)" if payload["available"] else "Wazuh Manager not available"),
1015 **payload,
1016 )
1017 except Exception as e:
1018 raise HTTPException(status_code=503, detail=f"Failed to list Wazuh rules: {str(e)}")
1019
1020
1021 @copilot_searches_router.get(
1022 "/catalog/wazuh-rules/{rule_id}",
1023 response_model=CatalogWazuhRuleDetailResponse,
1024 description=(
1025 "Full meta payload for a single Wazuh rule: header (id/level/status), "
1026 "description, file location, groups, MITRE techniques + resolved "
1027 "tactics, compliance frameworks, and the raw if-then logic dict "
1028 "(if_sid / match / regex / decoded_as / etc.). Served entirely from "
1029 "the in-memory cache — no second call to the Wazuh Manager."
1030 ),
1031 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1032 )
1033 async def get_catalog_wazuh_rule_endpoint(rule_id: int) -> CatalogWazuhRuleDetailResponse:
1034 try:
1035 detail = await get_wazuh_rule_detail(rule_id)
1036 if detail is None:
1037 raise HTTPException(
1038 status_code=404,
1039 detail=f"No Wazuh rule found with id {rule_id}",
1040 )
1041 return CatalogWazuhRuleDetailResponse(**detail)
1042 except HTTPException:
1043 raise
1044 except Exception as e:
1045 raise HTTPException(status_code=503, detail=f"Failed to load Wazuh rule detail: {str(e)}")
1046
1047
1048 # ---------------------------------------------------------------------------
1049 # Coverage Gaps tab — uncovered MITRE techniques across both corpora
1050 # ---------------------------------------------------------------------------
1051
1052
1053 @copilot_searches_router.get(
1054 "/catalog/coverage-gaps",
1055 response_model=CatalogCoverageGapsResponse,
1056 description=(
1057 "MITRE ATT&CK techniques not covered by any rule in either the "
1058 "CoPilot Searches corpus or the Wazuh ruleset. Sub-techniques are "
1059 "collapsed into their parents (a hit on T1059.001 counts as coverage "
1060 "for T1059). Use this surface to spot detection gaps that warrant "
1061 "new rule authoring."
1062 ),
1063 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1064 )
1065 async def list_catalog_coverage_gaps_endpoint() -> CatalogCoverageGapsResponse:
1066 try:
1067 payload = await list_coverage_gaps()
1068 return CatalogCoverageGapsResponse(
1069 success=True,
1070 message=f"{payload['gap_count']} gap(s) across {payload['total_techniques']} technique(s)",
1071 **payload,
1072 )
1073 except Exception as e:
1074 raise HTTPException(status_code=503, detail=f"Failed to compute coverage gaps: {str(e)}")
1075
1076
1077 # ---------------------------------------------------------------------------
1078 # Compliance pivot — Wazuh rules grouped by framework control ID
1079 #
1080 # Route ordering: static ``/catalog/compliance/frameworks`` MUST come before
1081 # the parameterized ``/catalog/compliance/{framework}`` so FastAPI doesn't
1082 # route the bare list path into the wildcard handler. (Same footgun as the
1083 # wazuh-rules routes — see CLAUDE.md "Things that bite".)
1084 # ---------------------------------------------------------------------------
1085
1086
1087 @copilot_searches_router.get(
1088 "/catalog/compliance/frameworks",
1089 response_model=CatalogComplianceFrameworksResponse,
1090 description=(
1091 "List the compliance frameworks the catalog can pivot Wazuh rules "
1092 "by (PCI DSS, HIPAA, NIST 800-53, GDPR, TSC, GPG13). Drives the "
1093 "framework selector dropdown on the Compliance tab."
1094 ),
1095 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1096 )
1097 async def list_catalog_compliance_frameworks_endpoint() -> CatalogComplianceFrameworksResponse:
1098 return CatalogComplianceFrameworksResponse(
1099 success=True,
1100 message="Frameworks listed successfully",
1101 frameworks=list_compliance_frameworks(),
1102 )
1103
1104
1105 @copilot_searches_router.get(
1106 "/catalog/compliance/{framework}",
1107 response_model=CatalogComplianceResponse,
1108 description=(
1109 "Group every Wazuh rule by its control IDs for the given framework "
1110 "(e.g. ``pci_dss``, ``hipaa``, ``nist_800_53``). Each group reports "
1111 "rule count + total firing hits — the answer to ``which rules cover "
1112 "PCI DSS 10.2.4 and how active are they?`` in one round-trip. Rules "
1113 "without any control values for the framework are excluded."
1114 ),
1115 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1116 )
1117 async def get_catalog_compliance_endpoint(framework: str) -> CatalogComplianceResponse:
1118 try:
1119 payload = await list_compliance_pivot(framework)
1120 return CatalogComplianceResponse(
1121 success=True,
1122 message=(
1123 f"{payload['control_count']} control(s) across {payload['rules_with_compliance']} "
1124 f"rule(s) tagged for {payload['framework_label']}"
1125 ),
1126 **payload,
1127 )
1128 except ValueError as ve:
1129 # Unknown framework key — surface as 400, not 503.
1130 raise HTTPException(status_code=400, detail=str(ve))
1131 except Exception as e:
1132 raise HTTPException(status_code=503, detail=f"Failed to compute compliance pivot: {str(e)}")
1133
1134
1135 # ---------------------------------------------------------------------------
1136 # Logtest — "which rule would match this log line?"
1137 #
1138 # POST'ed by the catalog UI when an analyst pastes a sample log line. The
1139 # heavy lifting is done by Wazuh's own logtest API (PUT /logtest) — we
1140 # wrap it so the catalog gets a stable, enriched response shape.
1141 # ---------------------------------------------------------------------------
1142
1143
1144 @copilot_searches_router.post(
1145 "/catalog/wazuh-rules/test",
1146 response_model=CatalogLogTestResponse,
1147 description=(
1148 "Submit a raw log line to Wazuh's logtest engine and return the "
1149 "matched rule (if any) plus the full alert envelope (decoder, "
1150 "predecoder, data, full_log). Stateless — no Wazuh session is "
1151 "created or persisted. Wraps Wazuh's PUT /logtest with mitre_matrix "
1152 "tactic-name enrichment so the result panel matches the catalog "
1153 "elsewhere."
1154 ),
1155 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1156 )
1157 async def run_catalog_logtest_endpoint(request: CatalogLogTestRequest) -> CatalogLogTestResponse:
1158 try:
1159 result = await run_log_test(
1160 event=request.event,
1161 log_format=request.log_format,
1162 location=request.location,
1163 )
1164 # The service may return unavailable_reason — surface it on the
1165 # envelope; success=True still because the call shape was valid.
1166 return CatalogLogTestResponse(
1167 success=True,
1168 message=(
1169 f"Matched rule {result['rule']['id']}"
1170 if result.get("matched") and result.get("rule")
1171 else "No rule matched"
1172 if result.get("unavailable_reason") is None
1173 else "Logtest unavailable"
1174 ),
1175 **result,
1176 )
1177 except HTTPException:
1178 raise
1179 except Exception as e:
1180 raise HTTPException(status_code=503, detail=f"Failed to run logtest: {str(e)}")