@cryptotaxi247 / CoPilot / commits / 083933b5

Feat/mitre navigator (#837)

* feat(copilot-searches): MITRE ATT&CK navigator view Add a matrix view inside CoPilot Searches that cross-references detection rules against the MITRE ATT&CK Enterprise matrix. Backend - New /api/copilot_searches/mitre/coverage endpoint builds the matrix by fetching the official STIX bundle from mitre/cti (24h cache) and joining it with the existing rules cache. Returns tactics in canonical kill-chain order with per-technique and per-sub-technique rule lists. - New /api/copilot_searches/mitre/refresh endpoint forces a re-fetch of the STIX bundle. - New /api/copilot_searches/by-ids batch endpoint returns many rule summaries in one round-trip (used by the matrix drawer to avoid N+1 calls). - GitHub PAT is now read from GITHUB_TOKEN env var and forwarded as Bearer auth on rule + matrix fetches to avoid the 60/hr unauth rate limit. Frontend - View toggle in CoPilot Searches header switches between the existing rule grid and a new MITRE Navigator matrix. - Matrix renders tactics-as-columns with techniques colored by rule count (primary-color heat gradient, theme-driven). Sub-techniques expand inline. Per-tactic coverage badges and a color legend make the heatmap readable at a glance. - Clicking a cell opens a side drawer that fetches its rules in one batch call and renders them as the existing RuleCard component, so the existing rule-detail / execute / provision-Graylog modals work unchanged. - View mode and matrix expanded state persist via localStorage. * chore: remove unused wait-for-it and SSL setup scripts * feat: add wait-for-it script for TCP host/port availability check * chore: comment out logger and import statements in Singul integration * chore: comment out execute_singul import and update function return in Singul integration Co-authored-by: Copilot <copilot@github.com> * chore: remove Singul client creation function and related code from universal.py * feat: Add bulk provisioning for Graylog alerts and enhance RuleCard component - Implemented BulkProvisionModal for bulk provisioning of Graylog alerts. - Enhanced RuleCard component with selectable checkbox and provisioned status tooltip. - Updated TechniqueDrawer to support bulk provisioning and display provisionable count. - Added new types for bulk provisioning requests and responses in TypeScript definitions. - Improved styling for RuleCard and BulkProvisionModal components. * chore: update @shuffleio/shuffle-mcps dependency to version 0.0.4 * feat: Replace PlatformBadge with custom Badge component for platform display * precommit-fixes --------- Co-authored-by: aminemoussaa <amine.moussa@socfortress.co> Co-authored-by: Copilot <copilot@github.com>

taylor_socfortress committed May 5, 2026 at 16:19 UTC 083933b5b53bbdeb993effce8af7e8837dc5598c
16 files changed +3065 -93
backend/app/connectors/shuffle/routes/singul.py
+5 -3
@@ -4,7 +4,8 @@ from loguru import logger
4
5 from app.auth.utils import AuthHandler
6 from app.connectors.shuffle.schema.singul import SingulRequest
7 -from app.connectors.shuffle.services.singul import execute_singul
7 +
8 +# from app.connectors.shuffle.services.singul import execute_singul
9
10 shuffle_singul_router = APIRouter()
11
@@ -22,7 +23,8 @@ async def execute_integration_route(request: SingulRequest):
23 request (SingulRequest): The request object containing the workflow ID.
24
25 Returns:
25 - dict: The response containing the execution ID.
26 + # dict: The response containing the execution ID
27 """
28 logger.info("Executing Singul integration")
28 - return await execute_singul(request)
29 + exit(0)
30 + # return await execute_singul(request)
backend/app/connectors/shuffle/services/singul.py
+34 -34
@@ -1,8 +1,8 @@
1 -from loguru import logger
1 +# from loguru import logger
2
3 -from app.connectors.shuffle.schema.singul import SingulRequest
4 -from app.connectors.shuffle.utils.universal import get_shuffle_org_id
5 -from app.connectors.shuffle.utils.universal import get_singul_client
3 +# from app.connectors.shuffle.schema.singul import SingulRequest
4 +# from app.connectors.shuffle.utils.universal import get_shuffle_org_id
5 +# from app.connectors.shuffle.utils.universal import get_singul_client
6
7 # async def execute_singul(
8 # request: SingulRequest,
@@ -45,38 +45,38 @@ from app.connectors.shuffle.utils.universal import get_singul_client
45 # return {"executionId": "unknown", "message": f"Singul integration failed: {e}", "success": False}
46
47
48 -async def execute_singul(
49 - request: SingulRequest,
50 -) -> dict:
51 - """
52 - Execute a Singul integration.
48 +# async def execute_singul(
49 +# request: SingulRequest,
50 +# ) -> dict:
51 +# """
52 +# Execute a Singul integration.
53
54 - Args:
55 - request (SingulRequest): The request object containing the workflow ID.
54 +# Args:
55 +# request (SingulRequest): The request object containing the workflow ID.
56
57 - Returns:
58 - dict: The response containing the execution ID.
59 - """
60 - logger.info("Executing Singul integration")
57 +# Returns:
58 +# dict: The response containing the execution ID.
59 +# """
60 +# logger.info("Executing Singul integration")
61
62 - # Get Singul client from database credentials
63 - singul = await get_singul_client()
62 +# # Get Singul client from database credentials
63 +# singul = await get_singul_client()
64
65 - try:
66 - response = singul.connect(
67 - app="opencti",
68 - action="get_ioc",
69 - org_id=await get_shuffle_org_id(),
70 - environment="THREATINTEL",
71 - fields=[{"key": "ip", "value": "185.215.113.75"}],
72 - )
73 - logger.info(f"Singul response: {response}")
74 - logger.info(f"Singul response success: {response.get('success', 'unknown')}")
65 +# try:
66 +# response = singul.connect(
67 +# app="opencti",
68 +# action="get_ioc",
69 +# org_id=await get_shuffle_org_id(),
70 +# environment="THREATINTEL",
71 +# fields=[{"key": "ip", "value": "185.215.113.75"}],
72 +# )
73 +# logger.info(f"Singul response: {response}")
74 +# logger.info(f"Singul response success: {response.get('success', 'unknown')}")
75
76 - return {
77 - "executionId": response.get("id", "unknown"),
78 - "message": "Singul integration executed successfully",
79 - }
80 - except Exception as e:
81 - logger.error(f"Failed to execute Singul integration: {e}")
82 - return {"executionId": "unknown", "message": f"Singul integration failed: {e}", "success": False}
76 +# return {
77 +# "executionId": response.get("id", "unknown"),
78 +# "message": "Singul integration executed successfully",
79 +# }
80 +# except Exception as e:
81 +# logger.error(f"Failed to execute Singul integration: {e}")
82 +# return {"executionId": "unknown", "message": f"Singul integration failed: {e}", "success": False}
backend/app/connectors/shuffle/utils/universal.py
+2 -28
@@ -5,11 +5,12 @@ from typing import Optional
5 import requests
6 from fastapi import HTTPException
7 from loguru import logger
8 -from shufflepy import Singul
8
9 from app.connectors.utils import get_connector_info_from_db
10 from app.db.db_session import get_db_session
11
12 +# from shufflepy import Singul
13 +
14
15 async def verify_shuffle_credentials(attributes: Dict[str, Any]) -> Dict[str, Any]:
16 """
@@ -295,30 +296,3 @@ def send_put_request(
296 status_code=500,
297 detail=f"Failed to send PUT request to {endpoint} with error: {e}",
298 )
298 -
299 -
300 -async def get_singul_client(connector_name: str = "Shuffle") -> Singul:
301 - """
302 - Create and return a Singul client using database credentials.
303 -
304 - Args:
305 - connector_name (str, optional): The name of the connector to use. Defaults to "Shuffle".
306 -
307 - Returns:
308 - Singul: An initialized Singul client instance.
309 - """
310 - logger.info("Creating Singul client from database credentials")
311 - async with get_db_session() as session:
312 - attributes = await get_connector_info_from_db(connector_name, session)
313 -
314 - if attributes is None:
315 - logger.error("No Shuffle connector found in the database")
316 - raise HTTPException(status_code=404, detail="Shuffle connector not found in database")
317 -
318 - try:
319 - singul_client = Singul(auth=attributes["connector_api_key"], url=attributes["connector_url"])
320 - logger.info(f"Singul client created successfully for {attributes['connector_url']}")
321 - return singul_client
322 - except Exception as e:
323 - logger.error(f"Failed to create Singul client: {e}")
324 - raise HTTPException(status_code=500, detail=f"Failed to create Singul client: {e}")
backend/app/integrations/copilot_searches/routes/copilot_searches.py
+271 -7
@@ -4,10 +4,20 @@ 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 ExecuteGraylogQueryRequest,
23 )
@@ -17,19 +27,30 @@ from app.integrations.copilot_searches.schema.copilot_searches import (
27 from app.integrations.copilot_searches.schema.copilot_searches import (
28 ExecuteSearchResponse,
29 )
30 +from app.integrations.copilot_searches.schema.copilot_searches import (
31 + GraylogProvisioningStatusResponse,
32 +)
33 from app.integrations.copilot_searches.schema.copilot_searches import (
34 GraylogQueryResponse,
35 )
36 +from app.integrations.copilot_searches.schema.copilot_searches import (
37 + MitreCoverageResponse,
38 +)
39 from app.integrations.copilot_searches.schema.copilot_searches import PlatformFilter
40 from app.integrations.copilot_searches.schema.copilot_searches import (
41 ProvisionGraylogAlertRequest,
42 )
43 +from app.integrations.copilot_searches.schema.copilot_searches import (
44 + ProvisionGraylogAlertRequest as PerRuleProvisionRequest,
45 +)
46 from app.integrations.copilot_searches.schema.copilot_searches import (
47 ProvisionGraylogAlertResponse,
48 )
49 from app.integrations.copilot_searches.schema.copilot_searches import RefreshResponse
50 from app.integrations.copilot_searches.schema.copilot_searches import RuleDetailResponse
51 from app.integrations.copilot_searches.schema.copilot_searches import RuleListResponse
52 +from app.integrations.copilot_searches.schema.copilot_searches import RulesByIdsRequest
53 +from app.integrations.copilot_searches.schema.copilot_searches import RulesByIdsResponse
54 from app.integrations.copilot_searches.schema.copilot_searches import RuleSeverity
55 from app.integrations.copilot_searches.schema.copilot_searches import RuleStatsResponse
56 from app.integrations.copilot_searches.schema.copilot_searches import RuleStatus
@@ -41,6 +62,7 @@ from app.integrations.copilot_searches.services.copilot_searches import (
62 )
63 from app.integrations.copilot_searches.services.copilot_searches import get_rule_by_id
64 from app.integrations.copilot_searches.services.copilot_searches import get_rule_by_name
65 +from app.integrations.copilot_searches.services.copilot_searches import get_rules_by_ids
66 from app.integrations.copilot_searches.services.copilot_searches import get_rules_list
67 from app.integrations.copilot_searches.services.copilot_searches import get_rules_stats
68 from app.integrations.copilot_searches.services.copilot_searches import (
@@ -49,6 +71,9 @@ from app.integrations.copilot_searches.services.copilot_searches import (
71 from app.integrations.copilot_searches.services.copilot_searches import (
72 refresh_rules_cache,
73 )
74 +from app.integrations.copilot_searches.services.copilot_searches import rules_cache
75 +from app.integrations.copilot_searches.services.mitre_coverage import get_coverage
76 +from app.integrations.copilot_searches.services.mitre_coverage import mitre_matrix
77
78 copilot_searches_router = APIRouter()
79
@@ -253,21 +278,29 @@ async def list_cve_rules(
278 limit: int = Query(100, ge=1, le=500),
279 ):
280 """List all detection rules that have CVE tags."""
256 - result = await get_rules_list(
281 + # Pull a generous slice unfiltered, then keep only CVE-tagged rules and
282 + # paginate those. Previously the route filtered after slicing, which made
283 + # pagination wrong (a page could come back empty even when more CVE rules
284 + # existed later in the list).
285 + full = await get_rules_list(
286 status=status,
287 severity=severity,
288 mitre_id=mitre_id,
289 search=search,
290 has_graylog=has_graylog,
262 - skip=skip,
263 - limit=limit,
291 + skip=0,
292 + limit=500,
293 )
294
266 - # Filter to only rules with CVE tags
267 - result["rules"] = [r for r in result["rules"] if r.cve]
268 - result["filtered"] = len(result["rules"])
295 + cve_only = [r for r in full["rules"] if r.cve]
296 + paginated = cve_only[skip : skip + limit]
297
270 - return RuleListResponse(**result)
298 + return RuleListResponse(
299 + total=full["total"],
300 + filtered=len(cve_only),
301 + platform=full["platform"],
302 + rules=paginated,
303 + )
304
305
306 @copilot_searches_router.get(
@@ -334,6 +367,90 @@ async def get_rule_by_name_endpoint(rule_name: str):
367 return RuleDetailResponse(rule=rule)
368
369
370 +@copilot_searches_router.post(
371 + "/by-ids",
372 + response_model=RulesByIdsResponse,
373 + description="Fetch many rule summaries by ID in a single request",
374 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
375 +)
376 +async def get_rules_by_ids_endpoint(request: RulesByIdsRequest):
377 + """
378 + Fetch multiple rule summaries by ID in one round-trip.
379 +
380 + Used by the MITRE matrix drawer to avoid N+1 calls when displaying
381 + the rules covering a technique.
382 + """
383 + if not request.ids:
384 + return RulesByIdsResponse(rules=[], missing=[])
385 + found, missing = await get_rules_by_ids(request.ids)
386 + return RulesByIdsResponse(rules=found, missing=missing)
387 +
388 +
389 +@copilot_searches_router.get(
390 + "/mitre/coverage",
391 + response_model=MitreCoverageResponse,
392 + description="MITRE ATT&CK matrix with per-technique rule coverage from CoPilot Searches",
393 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
394 +)
395 +async def get_mitre_coverage(
396 + platform: PlatformFilter = Query(
397 + PlatformFilter.ALL,
398 + description="Restrict coverage to rules matching this platform",
399 + ),
400 + severity: Optional[RuleSeverity] = Query(None, description="Restrict coverage to rules of this severity"),
401 + status: Optional[RuleStatus] = Query(None, description="Restrict coverage to rules of this status"),
402 + has_graylog: Optional[bool] = Query(
403 + None,
404 + description="If true, only consider rules that have a Graylog query",
405 + ),
406 + search: Optional[str] = Query(
407 + None,
408 + description="Substring match against rule name/description",
409 + ),
410 +):
411 + """
412 + Build the MITRE ATT&CK Enterprise matrix annotated with the CoPilot Search
413 + rules that cover each technique and sub-technique.
414 +
415 + Optional filters narrow which rules contribute to coverage so users can
416 + answer "what's my Windows-only coverage?" or "where do I have *production*
417 + detection?" without leaving the matrix view.
418 + """
419 + try:
420 + result = await get_coverage(
421 + platform=platform,
422 + severity=severity,
423 + status=status,
424 + has_graylog=has_graylog,
425 + search=search,
426 + )
427 + return MitreCoverageResponse(**result)
428 + except Exception as e:
429 + raise HTTPException(
430 + status_code=503,
431 + detail=f"Failed to build MITRE coverage: {str(e)}",
432 + )
433 +
434 +
435 +@copilot_searches_router.post(
436 + "/mitre/refresh",
437 + description="Force re-fetch of the MITRE ATT&CK STIX bundle",
438 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
439 +)
440 +async def refresh_mitre_matrix():
441 + """Force re-fetch of the MITRE ATT&CK STIX bundle."""
442 + try:
443 + await mitre_matrix.refresh()
444 + return {
445 + "success": True,
446 + "message": "MITRE matrix refreshed",
447 + "tactics": len(mitre_matrix.tactics),
448 + "techniques": len(mitre_matrix.techniques),
449 + }
450 + except Exception as e:
451 + raise HTTPException(status_code=503, detail=f"Failed to refresh MITRE matrix: {str(e)}")
452 +
453 +
454 @copilot_searches_router.get(
455 "/mitre/{technique_id}",
456 response_model=RuleListResponse,
@@ -489,6 +606,153 @@ async def generate_graylog_query_endpoint(request: ExecuteGraylogQueryRequest):
606 )
607
608
609 +@copilot_searches_router.post(
610 + "/provision/graylog/check",
611 + response_model=GraylogProvisioningStatusResponse,
612 + description="For a list of rule IDs, return which ones already have a matching Graylog event definition",
613 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
614 +)
615 +async def check_graylog_provisioning_status(request: RulesByIdsRequest):
616 + """
617 + For each requested rule, compute the alert title that bulk-provision would
618 + use and check whether Graylog already has an event definition with that
619 + title. Lets the UI mark rules as "in Graylog" without re-provisioning.
620 + """
621 + existing_titles: set[str] = set()
622 + warning: Optional[str] = None
623 + try:
624 + ed_resp = await get_all_event_definitions()
625 + if ed_resp.success:
626 + ed = GraylogEventDefinitionsResponse(**ed_resp.dict())
627 + existing_titles = {e.title for e in ed.event_definitions}
628 + else:
629 + warning = "Failed to read event definitions from Graylog"
630 + except Exception as e:
631 + warning = f"Could not reach Graylog: {e}"
632 + logger.warning(f"check-provisioning: {warning}")
633 +
634 + await rules_cache.ensure_loaded()
635 + provisioned: dict[str, bool] = {}
636 + for rule_id in request.ids:
637 + rule = rules_cache.get_rule_by_id(rule_id)
638 + if rule is None:
639 + continue
640 + if warning:
641 + # Conservative: don't claim "in Graylog" when we can't verify.
642 + provisioned[rule_id] = False
643 + continue
644 + alert_title = rule.get("name", "").upper().replace(" ", " - ")
645 + provisioned[rule_id] = alert_title in existing_titles
646 +
647 + return GraylogProvisioningStatusResponse(
648 + success=True,
649 + provisioned=provisioned,
650 + warning=warning,
651 + )
652 +
653 +
654 +@copilot_searches_router.post(
655 + "/provision/graylog/bulk",
656 + response_model=BulkProvisionGraylogAlertResponse,
657 + description="Provision multiple CoPilot Search rules as Graylog event definitions in a single call",
658 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
659 +)
660 +async def bulk_provision_graylog_alerts(request: BulkProvisionGraylogAlertRequest):
661 + """
662 + Provision a batch of CoPilot Search rules as Graylog event definitions.
663 +
664 + The endpoint never aborts on a single failure — instead, each rule's result
665 + is captured (`provisioned`, `skipped`, or `failed`) and returned together so
666 + the UI can show a partial-success summary. Skips are conservative: any rule
667 + that has no Graylog query, or whose alert title already exists in Graylog,
668 + is reported as skipped rather than failed.
669 + """
670 + # Resolve existing event definition titles once so we don't query Graylog
671 + # per rule.
672 + existing_titles: set[str] = set()
673 + try:
674 + ed_resp = await get_all_event_definitions()
675 + if ed_resp.success:
676 + ed = GraylogEventDefinitionsResponse(**ed_resp.dict())
677 + existing_titles = {e.title for e in ed.event_definitions}
678 + except Exception as e:
679 + # If we can't pre-fetch the existing list, fall back to skipping the
680 + # collision check. The per-rule provision call will surface failures.
681 + logger.warning(f"bulk-provision: could not list event definitions: {e}")
682 +
683 + results: list[BulkProvisionRuleResult] = []
684 +
685 + for rule_id in request.rule_ids:
686 + try:
687 + rule = await get_rule_by_id(rule_id)
688 + if rule is None:
689 + results.append(
690 + BulkProvisionRuleResult(rule_id=rule_id, status="failed", reason="Rule not found"),
691 + )
692 + continue
693 + if rule.graylog is None or not rule.graylog.query:
694 + results.append(
695 + BulkProvisionRuleResult(
696 + rule_id=rule_id,
697 + rule_name=rule.name,
698 + status="skipped",
699 + reason="Rule has no Graylog query",
700 + ),
701 + )
702 + continue
703 +
704 + alert_title = rule.name.upper().replace(" ", " - ")
705 + if alert_title in existing_titles:
706 + results.append(
707 + BulkProvisionRuleResult(
708 + rule_id=rule_id,
709 + rule_name=rule.name,
710 + alert_title=alert_title,
711 + status="skipped",
712 + reason="Event definition with this title already exists in Graylog",
713 + ),
714 + )
715 + continue
716 +
717 + single = PerRuleProvisionRequest(
718 + rule_id=rule_id,
719 + search_within_seconds=request.search_within_seconds,
720 + execute_every_seconds=request.execute_every_seconds,
721 + streams=request.streams,
722 + custom_title=None,
723 + priority=request.priority,
724 + event_limit=request.event_limit,
725 + )
726 + await provision_graylog_alert_from_rule(single)
727 + existing_titles.add(alert_title) # avoid double-provisioning within the same batch
728 + results.append(
729 + BulkProvisionRuleResult(
730 + rule_id=rule_id,
731 + rule_name=rule.name,
732 + alert_title=alert_title,
733 + status="provisioned",
734 + ),
735 + )
736 + except Exception as e:
737 + logger.error(f"bulk-provision: rule '{rule_id}' failed: {e}")
738 + results.append(
739 + BulkProvisionRuleResult(rule_id=rule_id, status="failed", reason=str(e)),
740 + )
741 +
742 + provisioned = sum(1 for r in results if r.status == "provisioned")
743 + skipped = sum(1 for r in results if r.status == "skipped")
744 + failed = sum(1 for r in results if r.status == "failed")
745 +
746 + return BulkProvisionGraylogAlertResponse(
747 + success=failed == 0,
748 + message=f"Provisioned {provisioned}, skipped {skipped}, failed {failed}",
749 + provisioned_count=provisioned,
750 + skipped_count=skipped,
751 + failed_count=failed,
752 + results=results,
753 + )
754 +
755 +
756 @copilot_searches_router.post(
757 "/provision/graylog",
758 response_model=ProvisionGraylogAlertResponse,
backend/app/integrations/copilot_searches/schema/copilot_searches.py
+120
@@ -302,3 +302,123 @@ class ProvisionGraylogAlertResponse(BaseModel):
302 rule_name: str
303 alert_title: str
304 graylog_query: str
305 +
306 +
307 +class BulkProvisionGraylogAlertRequest(BaseModel):
308 + """Provision multiple CoPilot Search rules as Graylog event definitions in one call.
309 +
310 + Each rule is checked for an existing event definition with the resolved alert
311 + title and skipped if a duplicate is found. Failures on one rule do not block
312 + the rest — the response carries per-rule results.
313 + """
314 +
315 + rule_ids: list[str] = Field(..., description="Rule IDs to provision", min_length=1, max_length=200)
316 + search_within_seconds: int = Field(default=300, ge=60, le=86400)
317 + execute_every_seconds: int = Field(default=300, ge=60, le=86400)
318 + streams: list[str] = Field(default_factory=list)
319 + priority: int = Field(default=2, ge=1, le=3)
320 + event_limit: int = Field(default=1000, ge=1, le=10000)
321 +
322 +
323 +class BulkProvisionRuleResult(BaseModel):
324 + rule_id: str
325 + rule_name: Optional[str] = None
326 + alert_title: Optional[str] = None
327 + status: str # "provisioned" | "skipped" | "failed"
328 + reason: Optional[str] = None
329 +
330 +
331 +class BulkProvisionGraylogAlertResponse(BaseModel):
332 + success: bool = True
333 + message: str
334 + provisioned_count: int
335 + skipped_count: int
336 + failed_count: int
337 + results: list[BulkProvisionRuleResult]
338 +
339 +
340 +class GraylogProvisioningStatusResponse(BaseModel):
341 + """Per-rule view of which rules already have a matching Graylog event definition.
342 +
343 + `provisioned` maps rule_id -> bool. Rules not present in the cache are omitted.
344 + `warning` is set when Graylog itself was unreachable, in which case all values
345 + are conservatively reported as `False` so the UI doesn't claim "in Graylog"
346 + based on stale info.
347 + """
348 +
349 + success: bool = True
350 + provisioned: dict[str, bool]
351 + warning: Optional[str] = None
352 +
353 +
354 +# =============================================================================
355 +# MITRE Coverage Models
356 +# =============================================================================
357 +
358 +
359 +class MitreSubTechnique(BaseModel):
360 + id: str
361 + name: str
362 + url: str
363 + rule_count: int
364 + rule_ids: list[str]
365 +
366 +
367 +class MitreTechnique(BaseModel):
368 + id: str
369 + name: str
370 + url: str
371 + rule_count: int
372 + rule_ids: list[str]
373 + total_rule_count: int
374 + subtechniques: list[MitreSubTechnique]
375 +
376 +
377 +class MitreTactic(BaseModel):
378 + id: str
379 + name: str
380 + short_name: str
381 + url: str
382 + techniques: list[MitreTechnique]
383 +
384 +
385 +class MitreCoverageStats(BaseModel):
386 + total_tactics: int
387 + total_techniques: int
388 + covered_techniques: int
389 + total_rules: int
390 + matrix_last_refreshed: Optional[datetime] = None
391 + rules_last_refreshed: Optional[datetime] = None
392 +
393 +
394 +class MitreRuleIndexEntry(BaseModel):
395 + id: str
396 + name: str
397 + severity: str
398 + platform: str
399 + has_graylog: bool
400 + data_sources: list[str] = Field(default_factory=list)
401 +
402 +
403 +class MitreCoverageResponse(BaseModel):
404 + success: bool = True
405 + message: str = "MITRE coverage built successfully"
406 + tactics: list[MitreTactic]
407 + rules_index: dict[str, MitreRuleIndexEntry] = Field(default_factory=dict)
408 + stats: MitreCoverageStats
409 +
410 +
411 +# =============================================================================
412 +# Batch Rule Lookup
413 +# =============================================================================
414 +
415 +
416 +class RulesByIdsRequest(BaseModel):
417 + ids: list[str] = Field(..., description="Rule IDs to fetch", max_length=500)
418 +
419 +
420 +class RulesByIdsResponse(BaseModel):
421 + success: bool = True
422 + message: str = "Rules fetched successfully"
423 + rules: list[RuleSummary]
424 + missing: list[str] = Field(default_factory=list, description="IDs that were requested but not found in cache")
backend/app/integrations/copilot_searches/services/copilot_searches.py
+30 -1
@@ -1,6 +1,7 @@
1 import asyncio
2 import copy
3 import json
4 +import os
5 import re
6 from datetime import datetime
7 from datetime import timedelta
@@ -11,6 +12,15 @@ import httpx
12 import yaml
13 from loguru import logger
14
15 +
16 +def _github_headers() -> dict[str, str]:
17 + headers = {"Accept": "application/vnd.github+json"}
18 + token = os.getenv("GITHUB_TOKEN")
19 + if token:
20 + headers["Authorization"] = f"Bearer {token}"
21 + return headers
22 +
23 +
24 from app.connectors.wazuh_indexer.utils.universal import (
25 create_wazuh_indexer_client_async,
26 )
@@ -158,7 +168,7 @@ class RulesCache:
168 """Fetch all YAML rules from GitHub repository."""
169 rules = []
170
161 - async with httpx.AsyncClient(timeout=30.0) as client:
171 + async with httpx.AsyncClient(timeout=30.0, headers=_github_headers()) as client:
172 # Get the directory tree for detections
173 tree_url = f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/git/trees/{GITHUB_BRANCH}" f"?recursive=1"
174
@@ -590,6 +600,25 @@ async def get_rule_by_id(rule_id: str) -> Optional[RuleDetail]:
600 return rule_to_detail(rule)
601
602
603 +async def get_rules_by_ids(ids: list[str]) -> tuple[list[RuleSummary], list[str]]:
604 + """
605 + Fetch many rule summaries by ID in one shot.
606 +
607 + Returns (found_summaries, missing_ids).
608 + """
609 + await rules_cache.ensure_loaded()
610 +
611 + found: list[RuleSummary] = []
612 + missing: list[str] = []
613 + for rule_id in ids:
614 + rule = rules_cache.get_rule_by_id(rule_id)
615 + if rule is None:
616 + missing.append(rule_id)
617 + else:
618 + found.append(rule_to_summary(rule))
619 + return found, missing
620 +
621 +
622 async def get_rule_by_name(rule_name: str) -> Optional[RuleDetail]:
623 """
624 Get full details of a rule by its name (fuzzy match).
backend/app/integrations/copilot_searches/services/mitre_coverage.py new
+316
@@ -0,0 +1,316 @@
1 +import asyncio
2 +import os
3 +from datetime import datetime
4 +from datetime import timedelta
5 +from typing import Optional
6 +
7 +import httpx
8 +from loguru import logger
9 +
10 +from app.integrations.copilot_searches.schema.copilot_searches import PlatformFilter
11 +from app.integrations.copilot_searches.schema.copilot_searches import RuleSeverity
12 +from app.integrations.copilot_searches.schema.copilot_searches import RuleStatus
13 +from app.integrations.copilot_searches.services.copilot_searches import rules_cache
14 +
15 +MITRE_STIX_URL = "https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json"
16 +MITRE_CACHE_TTL_HOURS = 24
17 +
18 +
19 +class MitreMatrix:
20 + """In-memory cache of the MITRE ATT&CK Enterprise matrix structure.
21 +
22 + Pulls the official STIX bundle from mitre/cti and indexes tactics + techniques
23 + so we can cross-reference them against CoPilot Search rules.
24 + """
25 +
26 + def __init__(self) -> None:
27 + self._tactics: list[dict] = []
28 + self._techniques: dict[str, dict] = {}
29 + self._last_refresh: Optional[datetime] = None
30 + self._lock = asyncio.Lock()
31 +
32 + @property
33 + def is_stale(self) -> bool:
34 + if self._last_refresh is None:
35 + return True
36 + return datetime.utcnow() - self._last_refresh > timedelta(hours=MITRE_CACHE_TTL_HOURS)
37 +
38 + async def ensure_loaded(self) -> None:
39 + if self.is_stale:
40 + await self.refresh()
41 +
42 + async def refresh(self) -> None:
43 + async with self._lock:
44 + logger.info(f"Fetching MITRE ATT&CK Enterprise STIX bundle from {MITRE_STIX_URL}")
45 + headers = {}
46 + token = os.getenv("GITHUB_TOKEN")
47 + if token:
48 + headers["Authorization"] = f"Bearer {token}"
49 +
50 + async with httpx.AsyncClient(timeout=120.0, headers=headers) as client:
51 + response = await client.get(MITRE_STIX_URL)
52 + response.raise_for_status()
53 + bundle = response.json()
54 +
55 + self._parse_bundle(bundle)
56 + self._last_refresh = datetime.utcnow()
57 + logger.info(
58 + f"Loaded MITRE matrix: {len(self._tactics)} tactics, {len(self._techniques)} techniques",
59 + )
60 +
61 + def _parse_bundle(self, bundle: dict) -> None:
62 + tactics_by_short: dict[str, dict] = {}
63 + techniques: dict[str, dict] = {}
64 +
65 + for obj in bundle.get("objects", []):
66 + obj_type = obj.get("type")
67 +
68 + if obj.get("revoked") or obj.get("x_mitre_deprecated"):
69 + continue
70 +
71 + if obj_type == "x-mitre-tactic":
72 + external_id = self._get_external_id(obj)
73 + short_name = obj.get("x_mitre_shortname", "")
74 + if not external_id or not short_name:
75 + continue
76 + tactics_by_short[short_name] = {
77 + "id": external_id,
78 + "name": obj.get("name", ""),
79 + "short_name": short_name,
80 + "url": self._get_external_url(obj),
81 + "description": obj.get("description", ""),
82 + }
83 +
84 + elif obj_type == "attack-pattern":
85 + external_id = self._get_external_id(obj)
86 + if not external_id:
87 + continue
88 + tactic_short_names = [
89 + phase.get("phase_name", "")
90 + for phase in obj.get("kill_chain_phases", [])
91 + if phase.get("kill_chain_name") == "mitre-attack"
92 + ]
93 + techniques[external_id] = {
94 + "id": external_id,
95 + "name": obj.get("name", ""),
96 + "url": self._get_external_url(obj),
97 + "is_subtechnique": bool(obj.get("x_mitre_is_subtechnique")),
98 + "tactic_short_names": tactic_short_names,
99 + }
100 +
101 + # Order tactics by the canonical kill-chain order. The STIX bundle stores
102 + # the official ordering in the matrix object's tactic_refs; we approximate
103 + # with the conventional order here as a fallback.
104 + canonical_order = [
105 + "reconnaissance",
106 + "resource-development",
107 + "initial-access",
108 + "execution",
109 + "persistence",
110 + "privilege-escalation",
111 + "defense-evasion",
112 + "credential-access",
113 + "discovery",
114 + "lateral-movement",
115 + "collection",
116 + "command-and-control",
117 + "exfiltration",
118 + "impact",
119 + ]
120 + ordered_tactics: list[dict] = []
121 + for short in canonical_order:
122 + if short in tactics_by_short:
123 + ordered_tactics.append(tactics_by_short[short])
124 + # Append any tactics not in the canonical list (forward-compat)
125 + for short, t in tactics_by_short.items():
126 + if short not in canonical_order:
127 + ordered_tactics.append(t)
128 +
129 + self._tactics = ordered_tactics
130 + self._techniques = techniques
131 +
132 + @staticmethod
133 + def _get_external_id(obj: dict) -> str:
134 + for ref in obj.get("external_references", []):
135 + if ref.get("source_name") == "mitre-attack":
136 + return ref.get("external_id", "")
137 + return ""
138 +
139 + @staticmethod
140 + def _get_external_url(obj: dict) -> str:
141 + for ref in obj.get("external_references", []):
142 + if ref.get("source_name") == "mitre-attack":
143 + return ref.get("url", "")
144 + return ""
145 +
146 + @property
147 + def tactics(self) -> list[dict]:
148 + return self._tactics
149 +
150 + @property
151 + def techniques(self) -> dict[str, dict]:
152 + return self._techniques
153 +
154 +
155 +mitre_matrix = MitreMatrix()
156 +
157 +
158 +def _rule_matches_filters(
159 + rule: dict,
160 + platform: Optional[PlatformFilter],
161 + severity: Optional[RuleSeverity],
162 + status: Optional[RuleStatus],
163 + has_graylog: Optional[bool],
164 + search: Optional[str],
165 +) -> bool:
166 + if platform is not None and platform != PlatformFilter.ALL:
167 + if rule.get("_platform", "unknown") != platform.value:
168 + return False
169 + if severity is not None:
170 + if rule.get("response", {}).get("severity", "").lower() != severity.value:
171 + return False
172 + if status is not None:
173 + if rule.get("status", "").lower() != status.value:
174 + return False
175 + if has_graylog is not None:
176 + if rule.get("_has_graylog", False) != has_graylog:
177 + return False
178 + if search:
179 + s = search.lower()
180 + name = rule.get("name", "").lower()
181 + desc = rule.get("description", "").lower()
182 + if s not in name and s not in desc:
183 + return False
184 + return True
185 +
186 +
187 +async def get_coverage(
188 + platform: Optional[PlatformFilter] = None,
189 + severity: Optional[RuleSeverity] = None,
190 + status: Optional[RuleStatus] = None,
191 + has_graylog: Optional[bool] = None,
192 + search: Optional[str] = None,
193 +) -> dict:
194 + """Build the MITRE coverage map by cross-referencing rules against the matrix.
195 +
196 + Optional filters narrow the rules considered (platform/severity/status/has_graylog/search)
197 + so the matrix can show "Windows-only coverage", etc.
198 +
199 + Returns a payload shaped for the frontend matrix view: ordered tactic columns,
200 + techniques grouped under each tactic, per-technique rule counts + IDs with
201 + sub-techniques nested, and a flat `rules_index` mapping rule ID to a small
202 + summary (name, severity, platform, has_graylog) for hover previews.
203 + """
204 + await mitre_matrix.ensure_loaded()
205 + await rules_cache.ensure_loaded()
206 +
207 + # Map base technique -> {rule_ids set, subtechniques: {sub_id -> rule_ids set}}
208 + coverage: dict[str, dict] = {}
209 + rules_index: dict[str, dict] = {}
210 +
211 + for rule in rules_cache.get_all_rules():
212 + rule_id = rule.get("id", "")
213 + if not rule_id:
214 + continue
215 + if not _rule_matches_filters(rule, platform, severity, status, has_graylog, search):
216 + continue
217 +
218 + # Cap data_sources at 3 to keep payload small; full list is in /id/{rule_id}.
219 + ds = rule.get("data_source", []) or []
220 + rules_index[rule_id] = {
221 + "id": rule_id,
222 + "name": rule.get("name", ""),
223 + "severity": rule.get("response", {}).get("severity", "medium"),
224 + "platform": rule.get("_platform", "unknown"),
225 + "has_graylog": rule.get("_has_graylog", False),
226 + "data_sources": [s for s in ds if isinstance(s, str)][:3],
227 + }
228 +
229 + for raw_tid in rule.get("tags", {}).get("mitre_attack_id", []) or []:
230 + tid = raw_tid.strip().upper()
231 + if not tid.startswith("T"):
232 + continue
233 + base = tid.split(".")[0]
234 + entry = coverage.setdefault(base, {"rule_ids": set(), "subtechniques": {}})
235 + if "." in tid:
236 + sub = entry["subtechniques"].setdefault(tid, set())
237 + sub.add(rule_id)
238 + else:
239 + entry["rule_ids"].add(rule_id)
240 +
241 + # Build tactic-column structure
242 + techniques_meta = mitre_matrix.techniques
243 + tactics_out: list[dict] = []
244 +
245 + for tactic in mitre_matrix.tactics:
246 + techniques_in_tactic: list[dict] = []
247 + for tid, meta in techniques_meta.items():
248 + if meta["is_subtechnique"]:
249 + continue
250 + if tactic["short_name"] not in meta["tactic_short_names"]:
251 + continue
252 +
253 + cov = coverage.get(tid, {"rule_ids": set(), "subtechniques": {}})
254 + base_rule_ids = sorted(cov["rule_ids"])
255 + sub_entries: list[dict] = []
256 + total_with_subs = set(cov["rule_ids"])
257 +
258 + for sub_tid, sub_meta in techniques_meta.items():
259 + if not sub_meta["is_subtechnique"]:
260 + continue
261 + if not sub_tid.startswith(tid + "."):
262 + continue
263 + sub_rule_ids = sorted(cov["subtechniques"].get(sub_tid, set()))
264 + total_with_subs.update(sub_rule_ids)
265 + sub_entries.append(
266 + {
267 + "id": sub_tid,
268 + "name": sub_meta["name"],
269 + "url": sub_meta["url"],
270 + "rule_count": len(sub_rule_ids),
271 + "rule_ids": sub_rule_ids,
272 + },
273 + )
274 + sub_entries.sort(key=lambda s: s["id"])
275 +
276 + techniques_in_tactic.append(
277 + {
278 + "id": tid,
279 + "name": meta["name"],
280 + "url": meta["url"],
281 + "rule_count": len(base_rule_ids),
282 + "rule_ids": base_rule_ids,
283 + "total_rule_count": len(total_with_subs),
284 + "subtechniques": sub_entries,
285 + },
286 + )
287 +
288 + techniques_in_tactic.sort(key=lambda t: t["id"])
289 +
290 + tactics_out.append(
291 + {
292 + "id": tactic["id"],
293 + "name": tactic["name"],
294 + "short_name": tactic["short_name"],
295 + "url": tactic["url"],
296 + "techniques": techniques_in_tactic,
297 + },
298 + )
299 +
300 + total_techniques = sum(len(t["techniques"]) for t in tactics_out)
301 + covered_techniques = sum(1 for t in tactics_out for tech in t["techniques"] if tech["total_rule_count"] > 0)
302 +
303 + return {
304 + "success": True,
305 + "message": "MITRE coverage built successfully",
306 + "tactics": tactics_out,
307 + "rules_index": rules_index,
308 + "stats": {
309 + "total_tactics": len(tactics_out),
310 + "total_techniques": total_techniques,
311 + "covered_techniques": covered_techniques,
312 + "total_rules": len(rules_index),
313 + "matrix_last_refreshed": mitre_matrix._last_refresh,
314 + "rules_last_refreshed": rules_cache.last_refresh,
315 + },
316 + }
frontend/package.json
+2 -2
@@ -48,7 +48,7 @@
48 "@fontsource/public-sans": "^5.2.7",
49 "@microsoft/fetch-event-source": "^2.0.1",
50 "@shikijs/markdown-it": "^4.0.2",
51 - "@shuffleio/shuffle-mcps": "^0.0.3",
51 + "@shuffleio/shuffle-mcps": "^0.0.4",
52 "@types/codemirror": "^5.60.17",
53 "@vueuse/core": "^14.3.0",
54 "@vueuse/motion": "^3.0.3",
@@ -153,4 +153,4 @@
153 "unrs-resolver"
154 ]
155 }
156 -}
\ No newline at end of file
156 +}
frontend/pnpm-lock.yaml
+5 -5
@@ -48,8 +48,8 @@ importers:
48 specifier: ^4.0.2
49 version: 4.0.2
50 '@shuffleio/shuffle-mcps':
51 - specifier: ^0.0.3
52 - version: 0.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
51 + specifier: ^0.0.4
52 + version: 0.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
53 '@types/codemirror':
54 specifier: ^5.60.17
55 version: 5.60.17
@@ -1384,8 +1384,8 @@ packages:
1384 '@shikijs/vscode-textmate@10.0.2':
1385 resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
1386
1387 - '@shuffleio/shuffle-mcps@0.0.3':
1388 - resolution: {integrity: sha512-w2A2wyuxtPo70sdb/aXQPAIXQVIcUoCRdzh2EkxpCttpnPxDSBjVlx+PMCVW5kblQMIvU4Q7EQCndLvdK+FzfA==}
1387 + '@shuffleio/shuffle-mcps@0.0.4':
1388 + resolution: {integrity: sha512-Oj7m3KY4QBO5OdjhDrhAGyf0yQ7SSaaZ8ty75wx0M9C+o+9ftPpefli3n4/P/kD4btCmPmUK3iRGMd3Ov4jP0Q==}
1389 peerDependencies:
1390 react: '>=18'
1391 react-dom: '>=18'
@@ -5877,7 +5877,7 @@ snapshots:
5877
5878 '@shikijs/vscode-textmate@10.0.2': {}
5879
5880 - '@shuffleio/shuffle-mcps@0.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
5880 + '@shuffleio/shuffle-mcps@0.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
5881 dependencies:
5882 algoliasearch: 5.52.0
5883 react: 19.2.5
frontend/src/api/endpoints/copilotSearches.ts
+65
@@ -1,8 +1,13 @@
1 import type {
2 + BulkProvisionGraylogAlertRequest,
3 + BulkProvisionGraylogAlertResponse,
4 + GraylogProvisioningStatusResponse,
5 ExecuteGraylogQueryRequest,
6 ExecuteSearchRequest,
7 ExecuteSearchResponse,
8 GraylogQueryResponse,
9 + MitreCoverageQuery,
10 + MitreCoverageResponse,
11 PlatformFilter,
12 ProvisionGraylogAlertRequest,
13 ProvisionGraylogAlertResponse,
@@ -10,6 +15,8 @@ import type {
15 RuleDetailResponse,
16 RuleListQuery,
17 RuleListResponse,
18 + RulesByIdsRequest,
19 + RulesByIdsResponse,
20 RuleStatsResponse
21 } from "@/types/copilotSearches.d"
22 import type { FlaskBaseResponse } from "@/types/flask.d"
@@ -178,5 +185,63 @@ export default {
185 `/copilot_searches/provision/graylog`,
186 request
187 )
188 + },
189 +
190 + /**
191 + * Provision multiple CoPilot Search rules as Graylog event definitions in a single call.
192 + * Per-rule failures are reported in `results` rather than aborting the batch.
193 + */
194 + bulkProvisionGraylogAlerts(request: BulkProvisionGraylogAlertRequest) {
195 + return HttpClient.post<FlaskBaseResponse & BulkProvisionGraylogAlertResponse>(
196 + `/copilot_searches/provision/graylog/bulk`,
197 + request
198 + )
199 + },
200 +
201 + /**
202 + * For each rule ID, check whether a matching Graylog event definition already exists.
203 + * Used to mark rules as "in Graylog" without forcing a re-provision.
204 + */
205 + checkGraylogProvisioningStatus(ids: string[]) {
206 + return HttpClient.post<FlaskBaseResponse & GraylogProvisioningStatusResponse>(
207 + `/copilot_searches/provision/graylog/check`,
208 + { ids }
209 + )
210 + },
211 +
212 + /**
213 + * Fetch many rule summaries by ID in one round-trip
214 + */
215 + getRulesByIds(ids: string[], signal?: AbortSignal) {
216 + const body: RulesByIdsRequest = { ids }
217 + return HttpClient.post<FlaskBaseResponse & RulesByIdsResponse>(`/copilot_searches/by-ids`, body, {
218 + signal
219 + })
220 + },
221 +
222 + /**
223 + * MITRE ATT&CK matrix annotated with per-technique CoPilot Search rule coverage.
224 + * Optional filters narrow which rules contribute to coverage (e.g. Windows-only).
225 + */
226 + getMitreCoverage(query?: MitreCoverageQuery, signal?: AbortSignal) {
227 + return HttpClient.get<FlaskBaseResponse & MitreCoverageResponse>(`/copilot_searches/mitre/coverage`, {
228 + params: {
229 + platform: query?.platform,
230 + severity: query?.severity,
231 + status: query?.status,
232 + has_graylog: query?.has_graylog,
233 + search: query?.search
234 + },
235 + signal
236 + })
237 + },
238 +
239 + /**
240 + * Force re-fetch of the MITRE ATT&CK STIX bundle
241 + */
242 + refreshMitreMatrix() {
243 + return HttpClient.post<FlaskBaseResponse & { tactics: number; techniques: number }>(
244 + `/copilot_searches/mitre/refresh`
245 + )
246 }
247 }
frontend/src/components/copilotSearches/BulkProvisionModal.vue new
+268
@@ -0,0 +1,268 @@
1 +<template>
2 + <n-modal
3 + v-model:show="showLocal"
4 + preset="card"
5 + :style="{ maxWidth: 'min(560px, 92vw)' }"
6 + title="Bulk Provision Graylog Alerts"
7 + :bordered="false"
8 + segmented
9 + >
10 + <div v-if="!result" class="flex flex-col gap-3">
11 + <n-alert type="info" :show-icon="false">
12 + This will create one Graylog event definition per rule, using the shared
13 + configuration below. Rules whose alert title already exists in Graylog
14 + are skipped automatically. Rules without a Graylog query are also skipped.
15 + </n-alert>
16 +
17 + <div class="grid grid-cols-2 gap-3">
18 + <div class="flex flex-col gap-1">
19 + <label class="text-secondary text-xs">Search within (seconds)</label>
20 + <n-input-number v-model:value="config.search_within_seconds" :min="60" :max="86400" size="small" />
21 + </div>
22 + <div class="flex flex-col gap-1">
23 + <label class="text-secondary text-xs">Execute every (seconds)</label>
24 + <n-input-number v-model:value="config.execute_every_seconds" :min="60" :max="86400" size="small" />
25 + </div>
26 + <div class="flex flex-col gap-1">
27 + <label class="text-secondary text-xs">Priority</label>
28 + <n-select v-model:value="config.priority" :options="priorityOptions" size="small" />
29 + </div>
30 + <div class="flex flex-col gap-1">
31 + <label class="text-secondary text-xs">Event limit</label>
32 + <n-input-number v-model:value="config.event_limit" :min="1" :max="10000" size="small" />
33 + </div>
34 + </div>
35 +
36 + <div class="text-secondary text-xs">
37 + About to provision
38 + <strong>{{ provisionableCount }}</strong>
39 + rule{{ provisionableCount === 1 ? "" : "s" }}.
40 + </div>
41 +
42 + <div class="flex justify-end gap-2">
43 + <n-button size="small" quaternary :disabled="submitting" @click="showLocal = false">
44 + Cancel
45 + </n-button>
46 + <n-button
47 + size="small"
48 + type="primary"
49 + :loading="submitting"
50 + :disabled="!provisionableCount"
51 + @click="submit"
52 + >
53 + Provision {{ provisionableCount }} rule{{ provisionableCount === 1 ? "" : "s" }}
54 + </n-button>
55 + </div>
56 + </div>
57 +
58 + <div v-else class="flex flex-col gap-3">
59 + <div class="grid grid-cols-3 gap-2">
60 + <div class="result-stat" :class="{ 'is-active result-provisioned': result.provisioned_count > 0 }">
61 + <div class="result-num">{{ result.provisioned_count }}</div>
62 + <div class="result-label">Provisioned</div>
63 + </div>
64 + <div class="result-stat" :class="{ 'is-active': result.skipped_count > 0 }">
65 + <div class="result-num">{{ result.skipped_count }}</div>
66 + <div class="result-label">Skipped</div>
67 + </div>
68 + <div class="result-stat" :class="{ 'is-active result-failed': result.failed_count > 0 }">
69 + <div class="result-num">{{ result.failed_count }}</div>
70 + <div class="result-label">Failed</div>
71 + </div>
72 + </div>
73 +
74 + <div class="bulk-results-list">
75 + <div v-for="r of result.results" :key="r.rule_id" class="bulk-result-row">
76 + <div class="flex min-w-0 flex-col">
77 + <div class="text-default truncate text-sm">{{ r.rule_name || r.rule_id }}</div>
78 + <div v-if="r.reason" class="text-tertiary truncate text-xs">{{ r.reason }}</div>
79 + </div>
80 + <Badge :color="statusBadgeColor(r.status)" size="small">
81 + <template #value>{{ r.status }}</template>
82 + </Badge>
83 + </div>
84 + </div>
85 +
86 + <div class="flex justify-end">
87 + <n-button size="small" type="primary" @click="close">Done</n-button>
88 + </div>
89 + </div>
90 + </n-modal>
91 +</template>
92 +
93 +<script setup lang="ts">
94 +import type { BadgeColor } from "@/components/common/Badge.vue"
95 +import type {
96 + BulkProvisionGraylogAlertResponse,
97 + BulkProvisionRuleStatus
98 +} from "@/types/copilotSearches.d"
99 +import { NAlert, NButton, NInputNumber, NModal, NSelect, useMessage } from "naive-ui"
100 +import { computed, reactive, ref, watch } from "vue"
101 +import Api from "@/api"
102 +import Badge from "@/components/common/Badge.vue"
103 +
104 +const props = defineProps<{
105 + show: boolean
106 + /** Rule IDs to provision. Caller should pass only IDs that have a Graylog query if known. */
107 + ruleIds: string[]
108 + /** Optional override for the displayed count. Defaults to ruleIds.length. */
109 + provisionableCount?: number
110 +}>()
111 +
112 +const emit = defineEmits<{
113 + (e: "update:show", value: boolean): void
114 + (e: "success", result: BulkProvisionGraylogAlertResponse): void
115 +}>()
116 +
117 +const message = useMessage()
118 +
119 +const showLocal = computed({
120 + get: () => props.show,
121 + set: v => emit("update:show", v)
122 +})
123 +
124 +const submitting = ref(false)
125 +const result = ref<BulkProvisionGraylogAlertResponse | null>(null)
126 +
127 +const config = reactive({
128 + search_within_seconds: 300,
129 + execute_every_seconds: 300,
130 + priority: 2 as 1 | 2 | 3,
131 + event_limit: 1000
132 +})
133 +
134 +const priorityOptions = [
135 + { label: "Low", value: 1 },
136 + { label: "Normal", value: 2 },
137 + { label: "High", value: 3 }
138 +]
139 +
140 +const provisionableCount = computed(() => props.provisionableCount ?? props.ruleIds.length)
141 +
142 +async function submit() {
143 + if (!props.ruleIds.length) return
144 + submitting.value = true
145 + try {
146 + const res = await Api.copilotSearches.bulkProvisionGraylogAlerts({
147 + rule_ids: props.ruleIds,
148 + search_within_seconds: config.search_within_seconds,
149 + execute_every_seconds: config.execute_every_seconds,
150 + priority: config.priority,
151 + event_limit: config.event_limit
152 + })
153 + result.value = res.data
154 + emit("success", res.data)
155 + if (res.data.failed_count === 0) message.success(res.data.message)
156 + else message.warning(res.data.message)
157 + } catch (err: any) {
158 + message.error(err.response?.data?.message || "Bulk provision failed")
159 + } finally {
160 + submitting.value = false
161 + }
162 +}
163 +
164 +function close() {
165 + showLocal.value = false
166 + setTimeout(() => {
167 + result.value = null
168 + }, 250)
169 +}
170 +
171 +// Reset result when the modal opens fresh OR the rule set changes
172 +watch(
173 + () => props.show,
174 + open => {
175 + if (open) result.value = null
176 + }
177 +)
178 +watch(
179 + () => props.ruleIds,
180 + () => {
181 + result.value = null
182 + },
183 + { deep: true }
184 +)
185 +
186 +function statusBadgeColor(status: BulkProvisionRuleStatus): BadgeColor | undefined {
187 + switch (status) {
188 + case "provisioned":
189 + return "success"
190 + case "failed":
191 + return "danger"
192 + case "skipped":
193 + return undefined
194 + }
195 +}
196 +</script>
197 +
198 +<style scoped lang="scss">
199 +.result-stat {
200 + border: 1px solid var(--border-color);
201 + border-radius: var(--border-radius);
202 + background: var(--bg-default-color);
203 + padding: 10px;
204 + text-align: center;
205 + color: var(--fg-secondary-color);
206 +}
207 +.result-stat.is-active {
208 + color: var(--fg-default-color);
209 +}
210 +.result-stat.result-provisioned.is-active {
211 + border-color: rgba(var(--success-color-rgb) / 0.45);
212 + background: rgba(var(--success-color-rgb) / 0.06);
213 +}
214 +.result-stat.result-failed.is-active {
215 + border-color: rgba(var(--error-color-rgb) / 0.45);
216 + background: rgba(var(--error-color-rgb) / 0.06);
217 +}
218 +.result-num {
219 + font-size: 1.4rem;
220 + font-weight: 700;
221 + line-height: 1;
222 +}
223 +.result-stat.is-active .result-num {
224 + color: inherit;
225 +}
226 +.result-stat.result-provisioned.is-active .result-num {
227 + color: var(--success-color);
228 +}
229 +.result-stat.result-failed.is-active .result-num {
230 + color: var(--error-color);
231 +}
232 +.result-label {
233 + font-size: 0.7rem;
234 + color: var(--fg-tertiary-color);
235 + text-transform: uppercase;
236 + letter-spacing: 0.04em;
237 + margin-top: 4px;
238 +}
239 +
240 +.bulk-results-list {
241 + max-height: 320px;
242 + overflow-y: auto;
243 + display: flex;
244 + flex-direction: column;
245 + border: 1px solid var(--border-color);
246 + border-radius: var(--border-radius);
247 + background: var(--bg-default-color);
248 +}
249 +
250 +.bulk-result-row {
251 + display: flex;
252 + align-items: center;
253 + justify-content: space-between;
254 + gap: 12px;
255 + padding: 8px 12px;
256 +}
257 +.bulk-result-row + .bulk-result-row {
258 + border-top: 1px solid var(--border-color);
259 +}
260 +.bulk-result-row:hover {
261 + background: rgba(var(--primary-color-rgb) / 0.04);
262 +}
263 +
264 +.bulk-result-row :deep(.badge) {
265 + flex-shrink: 0;
266 + white-space: nowrap;
267 +}
268 +</style>
frontend/src/components/copilotSearches/List.vue
+322 -7
@@ -10,7 +10,22 @@
10
11 <div class="flex flex-col">
12 <div class="flex flex-wrap items-center justify-end gap-2">
13 - <div class="flex min-w-80 grow gap-2">
13 + <n-button-group size="small">
14 + <n-button :type="viewMode === 'grid' ? 'primary' : 'default'" @click="viewMode = 'grid'">
15 + <template #icon>
16 + <Icon :name="GridIcon" />
17 + </template>
18 + Rules
19 + </n-button>
20 + <n-button :type="viewMode === 'matrix' ? 'primary' : 'default'" @click="viewMode = 'matrix'">
21 + <template #icon>
22 + <Icon :name="MatrixIcon" />
23 + </template>
24 + MITRE Navigator
25 + </n-button>
26 + </n-button-group>
27 +
28 + <div v-if="viewMode === 'grid'" class="flex min-w-80 grow gap-2">
29 <n-popover overlap placement="bottom-start">
30 <template #trigger>
31 <div class="bg-default rounded-lg">
@@ -105,7 +120,19 @@
120 </n-popover>
121 </div>
122
108 - <n-button size="small" :loading="refreshing" @click="handleRefresh">
123 + <n-button
124 + v-if="viewMode === 'grid'"
125 + size="small"
126 + :type="selectMode ? 'primary' : 'default'"
127 + @click="toggleSelectMode"
128 + >
129 + <template #icon>
130 + <Icon :name="SelectIcon" />
131 + </template>
132 + {{ selectMode ? "Exit select" : "Select" }}
133 + </n-button>
134 +
135 + <n-button v-if="viewMode === 'grid'" size="small" :loading="refreshing" @click="handleRefresh">
136 <template #icon>
137 <Icon :name="RefreshIcon" />
138 </template>
@@ -113,6 +140,7 @@
140 </n-button>
141
142 <n-pagination
143 + v-if="viewMode === 'grid'"
144 v-model:page="pagination.current"
145 :page-size="pagination.size"
146 :item-count="pagination.filtered"
@@ -120,13 +148,21 @@
148 />
149 </div>
150
123 - <n-spin :show="loading">
151 + <n-spin v-if="viewMode === 'grid'" :show="loading">
152 <div class="my-3">
153 <div
154 v-if="list.length"
155 class="grid grid-cols-1 gap-4 @2xl:grid-cols-2 @5xl:grid-cols-3 @6xl:grid-cols-4"
156 >
129 - <RuleCard v-for="rule of list" :key="rule.id" :rule />
157 + <RuleCard
158 + v-for="rule of list"
159 + :key="rule.id"
160 + :rule
161 + :provisioned="provisionedMap[rule.id] === true"
162 + :selectable="selectMode"
163 + :selected="selection.has(rule.id)"
164 + @update:selected="v => toggleRuleSelected(rule.id, v)"
165 + />
166 </div>
167
168 <template v-else>
@@ -135,7 +171,9 @@
171 </div>
172 </n-spin>
173
138 - <div class="flex justify-end">
174 + <MatrixView v-else class="my-3" />
175 +
176 + <div v-if="viewMode === 'grid'" class="flex justify-end">
177 <n-pagination
178 v-if="list.length > 3"
179 v-model:page="pagination.current"
@@ -145,17 +183,87 @@
183 />
184 </div>
185 </div>
186 +
187 + <!-- Floating selection footer — only shown in grid view, when in select mode AND at least 1 rule selected. -->
188 + <Transition name="fade-up">
189 + <div v-if="viewMode === 'grid' && selectMode && selection.size > 0" class="selection-footer">
190 + <div class="text-default text-sm">
191 + <strong>{{ selection.size }}</strong>
192 + selected
193 + <span class="text-tertiary ml-2 text-xs">
194 + ({{ provisionableSelectedCount }} with Graylog query)
195 + </span>
196 + </div>
197 +
198 + <div class="ml-auto flex items-center gap-2">
199 + <n-tooltip placement="top">
200 + <template #trigger>
201 + <n-button
202 + size="small"
203 + type="primary"
204 + :disabled="provisionableSelectedCount === 0"
205 + @click="openBulkProvisionModal"
206 + >
207 + <template #icon>
208 + <Icon :name="ProvisionIcon" />
209 + </template>
210 + Provision selected
211 + </n-button>
212 + </template>
213 + <template v-if="provisionableSelectedCount === 0">
214 + None of the selected rules has a Graylog query.
215 + </template>
216 + <template v-else>
217 + Provision {{ provisionableSelectedCount }} rule{{
218 + provisionableSelectedCount === 1 ? "" : "s"
219 + }}
220 + as Graylog event definitions.
221 + </template>
222 + </n-tooltip>
223 +
224 + <n-button size="small" @click="exportSelectedCsv">
225 + <template #icon>
226 + <Icon :name="ExportIcon" />
227 + </template>
228 + CSV
229 + </n-button>
230 + <n-button size="small" @click="exportSelectedJson">
231 + <template #icon>
232 + <Icon :name="ExportIcon" />
233 + </template>
234 + JSON
235 + </n-button>
236 +
237 + <n-button size="small" quaternary @click="clearSelection">Clear</n-button>
238 + </div>
239 + </div>
240 + </Transition>
241 +
242 + <BulkProvisionModal
243 + v-model:show="showBulkProvisionModal"
244 + :rule-ids="selectedRuleIdsWithGraylog"
245 + :provisionable-count="provisionableSelectedCount"
246 + @success="onBulkProvisionSuccess"
247 + />
248 </div>
249 </template>
250
251 <script setup lang="ts">
152 -import type { PlatformFilter, RuleListQuery, RuleSeverity, RuleStatus, RuleSummary } from "@/types/copilotSearches.d"
252 +import type {
253 + BulkProvisionGraylogAlertResponse,
254 + PlatformFilter,
255 + RuleListQuery,
256 + RuleSeverity,
257 + RuleStatus,
258 + RuleSummary
259 +} from "@/types/copilotSearches.d"
260 import { watchDebounced } from "@vueuse/core"
261 import axios from "axios"
262 import {
263 NAlert,
264 NBadge,
265 NButton,
266 + NButtonGroup,
267 NCheckbox,
268 NEmpty,
269 NInput,
@@ -163,17 +271,22 @@ import {
271 NPopover,
272 NSelect,
273 NSpin,
274 + NTooltip,
275 useMessage
276 } from "naive-ui"
168 -import { computed, ref } from "vue"
277 +import { computed, onMounted, ref, watch } from "vue"
278 +import { useRoute, useRouter } from "vue-router"
279 import Api from "@/api"
280 import Icon from "@/components/common/Icon.vue"
281 +import BulkProvisionModal from "./BulkProvisionModal.vue"
282 +import MatrixView from "./MatrixView.vue"
283 import RuleCard from "./RuleCard.vue"
284
285 const loading = ref(false)
286 const refreshing = ref(false)
287 const message = useMessage()
288 const list = ref<RuleSummary[]>([])
289 +const provisionedMap = ref<Record<string, boolean>>({})
290 const pagination = ref({
291 current: 1,
292 size: 24,
@@ -196,6 +309,42 @@ const InfoIcon = "carbon:information"
309 const FilterIcon = "carbon:filter-edit"
310 const SearchIcon = "carbon:search"
311 const RefreshIcon = "carbon:renew"
312 +const GridIcon = "carbon:grid"
313 +const MatrixIcon = "carbon:chart-network"
314 +const SelectIcon = "carbon:checkbox-checked"
315 +const ProvisionIcon = "carbon:add-alt"
316 +const ExportIcon = "carbon:download"
317 +
318 +// Always defaults to the rule grid. Matrix view is only entered when the
319 +// user clicks the toggle in this session, or follows a `?view=matrix` link.
320 +// Intentionally NOT persisted — entering CoPilot Searches always lands on
321 +// the familiar rule cards.
322 +const viewMode = ref<"grid" | "matrix">("grid")
323 +
324 +// Deep-linking: ?view=matrix overrides the default; toggling the view writes
325 +// back to the URL so links are shareable. Lives in List.vue (not the parent
326 +// view) so we don't have to touch routing config.
327 +const route = useRoute()
328 +const router = useRouter()
329 +
330 +onMounted(() => {
331 + const v = route.query.view
332 + if (v === "matrix" || v === "grid") viewMode.value = v
333 +})
334 +
335 +watch(viewMode, v => {
336 + if (route.query.view === v) return
337 + const next = { ...route.query }
338 + if (v === "grid") {
339 + // Drop matrix-only deep-link params when leaving the matrix.
340 + delete next.view
341 + delete next.technique
342 + delete next.sub
343 + } else {
344 + next.view = v
345 + }
346 + router.replace({ query: next })
347 +})
348
349 const platformOptions = [
350 { label: "Linux", value: "linux" },
@@ -227,6 +376,20 @@ function resetFilters() {
376 showFilters.value = false
377 }
378
379 +async function refreshProvisionedMap() {
380 + const ids = list.value.map(r => r.id)
381 + provisionedMap.value = {}
382 + if (!ids.length) return
383 + try {
384 + const res = await Api.copilotSearches.checkGraylogProvisioningStatus(ids)
385 + if (res.data?.success && !res.data.warning) {
386 + provisionedMap.value = res.data.provisioned || {}
387 + }
388 + } catch {
389 + // Silent — if Graylog is unreachable, just don't show the chip.
390 + }
391 +}
392 +
393 function getList() {
394 abortController?.abort()
395 abortController = new AbortController()
@@ -252,6 +415,10 @@ function getList() {
415 list.value = res.data?.rules || []
416 pagination.value.total = res.data?.total || 0
417 pagination.value.filtered = res.data?.filtered || 0
418 + // Best-effort: also fetch which of these rules are already in
419 + // Graylog so RuleCard can show the "in Graylog" chip. If it
420 + // fails (e.g. Graylog unreachable), no chips, no error.
421 + refreshProvisionedMap()
422 } else {
423 message.warning(res.data?.message || "An error occurred. Please try again later.")
424 }
@@ -290,4 +457,152 @@ watchDebounced(
457 immediate: true
458 }
459 )
460 +
461 +// ---------------------------------------------------------------------------
462 +// Multi-select bulk actions
463 +// ---------------------------------------------------------------------------
464 +
465 +const selectMode = ref(false)
466 +const selection = ref<Set<string>>(new Set())
467 +// Cache of full RuleSummary objects for selected IDs, so we can keep them
468 +// available across pagination changes (the visible `list` only holds the
469 +// current page).
470 +const selectionCache = ref<Map<string, RuleSummary>>(new Map())
471 +
472 +const showBulkProvisionModal = ref(false)
473 +
474 +const provisionableSelectedCount = computed(
475 + () => Array.from(selectionCache.value.values()).filter(r => r.has_graylog_query).length
476 +)
477 +
478 +const selectedRuleIdsWithGraylog = computed(() =>
479 + Array.from(selectionCache.value.values())
480 + .filter(r => r.has_graylog_query)
481 + .map(r => r.id)
482 +)
483 +
484 +function toggleSelectMode() {
485 + selectMode.value = !selectMode.value
486 + if (!selectMode.value) clearSelection()
487 +}
488 +
489 +function toggleRuleSelected(ruleId: string, value: boolean) {
490 + if (value) {
491 + selection.value.add(ruleId)
492 + const summary = list.value.find(r => r.id === ruleId)
493 + if (summary) selectionCache.value.set(ruleId, summary)
494 + } else {
495 + selection.value.delete(ruleId)
496 + selectionCache.value.delete(ruleId)
497 + }
498 + // Trigger reactivity (Set/Map mutations don't notify by themselves).
499 + selection.value = new Set(selection.value)
500 + selectionCache.value = new Map(selectionCache.value)
501 +}
502 +
503 +function clearSelection() {
504 + selection.value = new Set()
505 + selectionCache.value = new Map()
506 +}
507 +
508 +function openBulkProvisionModal() {
509 + if (provisionableSelectedCount.value === 0) return
510 + showBulkProvisionModal.value = true
511 +}
512 +
513 +function onBulkProvisionSuccess(res: BulkProvisionGraylogAlertResponse) {
514 + // Reflect new "in Graylog" state on the visible list immediately.
515 + const next = { ...provisionedMap.value }
516 + for (const r of res.results) {
517 + if (r.status === "provisioned" || r.status === "skipped") {
518 + next[r.rule_id] = true
519 + }
520 + }
521 + provisionedMap.value = next
522 +}
523 +
524 +function downloadBlob(blob: Blob, filename: string) {
525 + const url = URL.createObjectURL(blob)
526 + const link = document.createElement("a")
527 + link.href = url
528 + link.download = filename
529 + document.body.appendChild(link)
530 + link.click()
531 + document.body.removeChild(link)
532 + URL.revokeObjectURL(url)
533 +}
534 +
535 +function exportSelectedCsv() {
536 + const rules = Array.from(selectionCache.value.values())
537 + if (!rules.length) return
538 + const header = [
539 + "id",
540 + "name",
541 + "severity",
542 + "platform",
543 + "status",
544 + "has_graylog_query",
545 + "mitre_attack_id",
546 + "description"
547 + ]
548 + const rows = rules.map(r => [
549 + r.id,
550 + r.name,
551 + r.severity,
552 + r.platform,
553 + r.status,
554 + String(r.has_graylog_query),
555 + (r.mitre_attack_id || []).join("|"),
556 + (r.description || "").replace(/\s+/g, " ")
557 + ])
558 + const csv = [header, ...rows]
559 + .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(","))
560 + .join("\n")
561 + const stamp = new Date().toISOString().slice(0, 10)
562 + downloadBlob(
563 + new Blob([csv], { type: "text/csv;charset=utf-8;" }),
564 + `copilot-searches-selected-${stamp}.csv`
565 + )
566 +}
567 +
568 +function exportSelectedJson() {
569 + const rules = Array.from(selectionCache.value.values())
570 + if (!rules.length) return
571 + const json = JSON.stringify(rules, null, 2)
572 + const stamp = new Date().toISOString().slice(0, 10)
573 + downloadBlob(
574 + new Blob([json], { type: "application/json;charset=utf-8;" }),
575 + `copilot-searches-selected-${stamp}.json`
576 + )
577 +}
578 </script>
579 +
580 +<style scoped lang="scss">
581 +.selection-footer {
582 + position: fixed;
583 + bottom: 16px;
584 + left: 50%;
585 + transform: translateX(-50%);
586 + z-index: 50;
587 + display: flex;
588 + align-items: center;
589 + gap: 16px;
590 + min-width: min(680px, 92vw);
591 + max-width: 92vw;
592 + padding: 10px 16px;
593 + background: var(--bg-secondary-color);
594 + border: 1px solid var(--border-color);
595 + border-radius: var(--border-radius);
596 + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
597 +}
598 +
599 +.fade-up-enter-active,
600 +.fade-up-leave-active {
601 + transition: opacity 0.2s ease, transform 0.2s ease;
602 +}
603 +.fade-up-enter-from,
604 +.fade-up-leave-to {
605 + opacity: 0;
606 + transform: translate(-50%, 12px);
607 +}
608 +</style>
frontend/src/components/copilotSearches/MatrixView.vue new
+1210
@@ -0,0 +1,1210 @@
1 +<template>
2 + <div class="flex flex-col gap-3">
3 + <div class="flex flex-wrap items-center justify-end gap-2">
4 + <div class="flex min-w-80 grow gap-2">
5 + <n-popover overlap placement="bottom-start">
6 + <template #trigger>
7 + <div class="bg-default rounded-lg">
8 + <n-button size="small" class="cursor-help!">
9 + <template #icon>
10 + <Icon :name="InfoIcon" />
11 + </template>
12 + </n-button>
13 + </div>
14 + </template>
15 + <div v-if="coverage" class="flex flex-col gap-2">
16 + <div class="box">
17 + Tactics:
18 + <code>{{ coverage.stats.total_tactics }}</code>
19 + </div>
20 + <div class="box">
21 + Techniques:
22 + <code>{{ coverage.stats.total_techniques }}</code>
23 + </div>
24 + <div class="box">
25 + Covered:
26 + <code>{{ coverage.stats.covered_techniques }}</code>
27 + </div>
28 + <div class="box">
29 + Rules in scope:
30 + <code>{{ coverage.stats.total_rules }}</code>
31 + </div>
32 + </div>
33 + </n-popover>
34 +
35 + <n-input
36 + v-model:value="searchQuery"
37 + size="small"
38 + placeholder="Search techniques or rule names..."
39 + class="max-w-120"
40 + clearable
41 + >
42 + <template #prefix>
43 + <Icon :name="SearchIcon" />
44 + </template>
45 + </n-input>
46 +
47 + <n-popover :show="showFilters" trigger="manual" overlap placement="bottom-start" class="px-0!">
48 + <template #trigger>
49 + <div class="bg-default rounded-lg">
50 + <n-badge :show="anyFiltersActive" dot type="success" :offset="[-4, 0]">
51 + <n-button size="small" @click="showFilters = !showFilters">
52 + <template #icon>
53 + <Icon :name="FilterIcon" />
54 + </template>
55 + </n-button>
56 + </n-badge>
57 + </div>
58 + </template>
59 + <div class="divide-border flex w-50 flex-col gap-0 divide-y">
60 + <div class="flex flex-col gap-2.5 px-3 pt-1 pb-3">
61 + <n-select
62 + v-model:value="selectedPlatform"
63 + :options="platformOptions"
64 + size="small"
65 + placeholder="Platform"
66 + class="w-full"
67 + clearable
68 + :consistent-menu-width="false"
69 + />
70 + <n-select
71 + v-model:value="selectedSeverity"
72 + :options="severityOptions"
73 + clearable
74 + size="small"
75 + placeholder="Severity"
76 + class="w-full"
77 + :consistent-menu-width="false"
78 + />
79 + <n-select
80 + v-model:value="selectedStatus"
81 + :options="statusOptions"
82 + clearable
83 + size="small"
84 + placeholder="Status"
85 + class="w-full"
86 + :consistent-menu-width="false"
87 + />
88 + <n-checkbox v-model:checked="hasGraylogFilter" size="small">
89 + <span class="text-xs">Graylog Only</span>
90 + </n-checkbox>
91 + </div>
92 + <div class="flex justify-between gap-2 px-3 pt-2">
93 + <n-button size="small" quaternary @click="showFilters = false">Close</n-button>
94 + <n-button size="small" secondary @click="resetFilters">Reset</n-button>
95 + </div>
96 + </div>
97 + </n-popover>
98 +
99 + <n-checkbox
100 + v-model:checked="onlyCovered"
101 + size="small"
102 + class="shrink-0! self-center whitespace-nowrap"
103 + >
104 + <span class="text-xs">Only covered</span>
105 + </n-checkbox>
106 + </div>
107 +
108 + <n-tooltip placement="bottom-end">
109 + <template #trigger>
110 + <n-button size="small" :disabled="!coverage" @click="exportCoverageCsv">
111 + <template #icon>
112 + <Icon :name="ExportIcon" />
113 + </template>
114 + Export CSV
115 + </n-button>
116 + </template>
117 + Download a CSV of the current coverage (one row per technique and sub-technique, with rule counts and IDs).
118 + </n-tooltip>
119 +
120 + <n-tooltip placement="bottom-end">
121 + <template #trigger>
122 + <n-button size="small" :loading="refreshing" @click="handleRefresh">
123 + <template #icon>
124 + <Icon :name="RefreshIcon" />
125 + </template>
126 + Refresh Matrix
127 + </n-button>
128 + </template>
129 + Force a re-fetch of the MITRE ATT&amp;CK STIX bundle from
130 + <code>github.com/mitre/cti</code>
131 + , bypassing the 24-hour cache. Use this if MITRE published a new release and you want
132 + the matrix to pick it up immediately.
133 + </n-tooltip>
134 + </div>
135 +
136 + <div class="legend">
137 + <span class="text-tertiary text-xs">Rules:</span>
138 + <div v-for="step of legendSteps" :key="step.label" class="legend-item">
139 + <span class="legend-swatch" :class="step.cls" />
140 + <span class="text-secondary text-xs">{{ step.label }}</span>
141 + </div>
142 + <div v-if="coverage" class="text-secondary ml-auto text-xs">
143 + <strong>{{ coverage.stats.covered_techniques }}</strong>
144 + /
145 + <strong>{{ coverage.stats.total_techniques }}</strong>
146 + techniques ·
147 + <strong>{{ coverage.stats.total_rules }}</strong>
148 + rules
149 + </div>
150 + </div>
151 +
152 + <div class="matrix-scroll-wrap">
153 + <!-- Subtle top progress bar replaces the heavy spin overlay during refetches. -->
154 + <div v-if="loading && coverage" class="matrix-progress" />
155 +
156 + <div
157 + class="matrix-scroll"
158 + :class="{ 'matrix-scroll-loading': loading && coverage }"
159 + >
160 + <n-empty
161 + v-if="!loading && coverage && filteredTactics.length === 0"
162 + description="No techniques match your filters."
163 + class="matrix-empty"
164 + >
165 + <template #extra>
166 + <n-button size="small" @click="clearAllFilters">Clear filters</n-button>
167 + </template>
168 + </n-empty>
169 +
170 + <n-spin v-else-if="loading && !coverage" :show="true" class="matrix-initial-load" />
171 +
172 + <div v-else class="matrix-grid">
173 + <div v-for="tactic of filteredTactics" :key="tactic.id" class="tactic-column">
174 + <div
175 + class="tactic-header"
176 + :class="{ 'tactic-uncovered': isTacticUncovered(tactic) }"
177 + >
178 + <div class="flex items-center justify-between gap-2">
179 + <div class="tactic-name">{{ tactic.name }}</div>
180 + <span
181 + class="tactic-coverage"
182 + :class="{ 'tactic-coverage-zero': isTacticUncovered(tactic) }"
183 + :title="`${tacticStats(tactic).covered} of ${tacticStats(tactic).total} techniques covered by CoPilot rules`"
184 + >
185 + {{ tacticStats(tactic).covered }}/{{ tacticStats(tactic).total }}
186 + </span>
187 + </div>
188 + <div class="text-tertiary text-xs">{{ tactic.techniques.length }} shown</div>
189 + </div>
190 +
191 + <div class="technique-list">
192 + <n-popover
193 + v-for="tech of tactic.techniques"
194 + :key="tactic.id + tech.id"
195 + trigger="hover"
196 + :delay="350"
197 + :duration="80"
198 + :show-arrow="false"
199 + placement="right"
200 + :disabled="tech.total_rule_count === 0"
201 + >
202 + <template #trigger>
203 + <div
204 + class="technique-cell"
205 + :class="[
206 + cellClass(tech),
207 + {
208 + 'cell-cross-tactic':
209 + hoveredTechniqueId === tech.id &&
210 + hoveredTacticId !== tactic.id
211 + }
212 + ]"
213 + :title="cellTooltip(tech)"
214 + @click="openTechnique(tactic, tech)"
215 + @mouseenter="onCellEnter(tactic.id, tech.id)"
216 + @mouseleave="onCellLeave"
217 + >
218 + <div class="technique-row">
219 + <div class="technique-id">{{ tech.id }}</div>
220 + <n-tag
221 + v-if="tech.total_rule_count > 0"
222 + size="tiny"
223 + round
224 + :bordered="false"
225 + class="count-tag"
226 + >
227 + {{ tech.total_rule_count }}
228 + </n-tag>
229 + </div>
230 + <div class="technique-name">{{ tech.name }}</div>
231 +
232 + <div
233 + v-if="tech.subtechniques.length"
234 + class="technique-sub-toggle"
235 + @click.stop="toggleExpand(tactic.id, tech.id)"
236 + >
237 + <Icon
238 + :name="expanded[tactic.id + tech.id] ? ChevronDown : ChevronRight"
239 + :size="10"
240 + />
241 + {{ tech.subtechniques.length }} sub
242 + </div>
243 +
244 + <div
245 + v-if="expanded[tactic.id + tech.id]"
246 + class="subtechnique-list"
247 + @click.stop
248 + >
249 + <n-popover
250 + v-for="sub of visibleSubs(tech, tactic.id + tech.id)"
251 + :key="sub.id"
252 + trigger="hover"
253 + :delay="350"
254 + :duration="80"
255 + :show-arrow="false"
256 + placement="right"
257 + :disabled="sub.rule_count === 0"
258 + >
259 + <template #trigger>
260 + <div
261 + class="subtechnique-cell"
262 + :class="cellClass(sub, true)"
263 + :title="subCellTooltip(sub)"
264 + @click="openSubTechnique(tactic, tech, sub)"
265 + >
266 + <div class="technique-row">
267 + <div class="subtechnique-id">{{ sub.id }}</div>
268 + <n-tag
269 + v-if="sub.rule_count > 0"
270 + size="tiny"
271 + round
272 + :bordered="false"
273 + class="count-tag"
274 + >
275 + {{ sub.rule_count }}
276 + </n-tag>
277 + </div>
278 + <div class="subtechnique-name">{{ sub.name }}</div>
279 + </div>
280 + </template>
281 +
282 + <RulePreviewList :rule-ids="sub.rule_ids" :index="rulesIndex" />
283 + </n-popover>
284 +
285 + <div
286 + v-if="tech.subtechniques.length > SUB_PREVIEW_LIMIT"
287 + class="show-all-subs"
288 + @click.stop="toggleShowAllSubs(tactic.id + tech.id)"
289 + >
290 + {{
291 + showAllSubs[tactic.id + tech.id]
292 + ? `Show fewer`
293 + : `Show all ${tech.subtechniques.length}`
294 + }}
295 + </div>
296 + </div>
297 + </div>
298 + </template>
299 +
300 + <RulePreviewList :rule-ids="tech.rule_ids" :index="rulesIndex" :extra-via-subs="tech.total_rule_count - tech.rule_count" />
301 + </n-popover>
302 +
303 + <n-empty
304 + v-if="!tactic.techniques.length"
305 + description="No techniques"
306 + class="py-4"
307 + size="small"
308 + />
309 + </div>
310 + </div>
311 + </div>
312 + </div>
313 + </div>
314 +
315 + <TechniqueDrawer
316 + v-model:show="drawerOpen"
317 + :technique="selectedTechnique"
318 + :sub-technique="selectedSubTechnique"
319 + @update:show="onDrawerToggle"
320 + />
321 +
322 + <!-- Direct-from-hover rule detail modal: skips the drawer entirely
323 + when the user clicks a rule name inside the hover preview. -->
324 + <n-modal
325 + v-model:show="quickRuleOpen"
326 + preset="card"
327 + :style="{ maxWidth: 'min(750px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
328 + title="Detection Rule"
329 + :bordered="false"
330 + segmented
331 + >
332 + <RuleCardContent v-if="quickRuleId" :rule-id="quickRuleId" />
333 + </n-modal>
334 + </div>
335 +</template>
336 +
337 +<script setup lang="ts">
338 +import type {
339 + MitreCoverageQuery,
340 + MitreCoverageResponse,
341 + MitreRuleIndexEntry,
342 + MitreSubTechnique,
343 + MitreTactic,
344 + MitreTechnique,
345 + PlatformFilter,
346 + RuleSeverity,
347 + RuleStatus
348 +} from "@/types/copilotSearches.d"
349 +import { useLocalStorage, watchDebounced } from "@vueuse/core"
350 +import {
351 + NBadge,
352 + NButton,
353 + NCheckbox,
354 + NEmpty,
355 + NInput,
356 + NModal,
357 + NPopover,
358 + NSelect,
359 + NSpin,
360 + NTag,
361 + NTooltip,
362 + useMessage
363 +} from "naive-ui"
364 +import { computed, h, onMounted, ref, watch } from "vue"
365 +import { useRoute, useRouter } from "vue-router"
366 +import Api from "@/api"
367 +import Icon from "@/components/common/Icon.vue"
368 +import RuleCardContent from "./RuleCardContent.vue"
369 +import TechniqueDrawer from "./TechniqueDrawer.vue"
370 +
371 +const InfoIcon = "carbon:information"
372 +const SearchIcon = "carbon:search"
373 +const RefreshIcon = "carbon:renew"
374 +const FilterIcon = "carbon:filter-edit"
375 +const ExportIcon = "carbon:download"
376 +const ChevronRight = "carbon:chevron-right"
377 +const ChevronDown = "carbon:chevron-down"
378 +
379 +const route = useRoute()
380 +const router = useRouter()
381 +const message = useMessage()
382 +
383 +const loading = ref(false)
384 +const refreshing = ref(false)
385 +const coverage = ref<MitreCoverageResponse | null>(null)
386 +const onlyCovered = useLocalStorage("copilot-searches/matrix/only-covered", false)
387 +const searchQuery = ref("")
388 +const expanded = useLocalStorage<Record<string, boolean>>("copilot-searches/matrix/expanded", {})
389 +const showAllSubs = ref<Record<string, boolean>>({})
390 +
391 +const SUB_PREVIEW_LIMIT = 5
392 +
393 +function visibleSubs(tech: MitreTechnique, key: string) {
394 + if (showAllSubs.value[key]) return tech.subtechniques
395 + return tech.subtechniques.slice(0, SUB_PREVIEW_LIMIT)
396 +}
397 +function toggleShowAllSubs(key: string) {
398 + showAllSubs.value[key] = !showAllSubs.value[key]
399 +}
400 +
401 +const selectedPlatform = ref<PlatformFilter | null>(null)
402 +const selectedSeverity = ref<RuleSeverity | null>(null)
403 +const selectedStatus = ref<RuleStatus | null>(null)
404 +const hasGraylogFilter = ref(false)
405 +const showFilters = ref(false)
406 +
407 +const drawerOpen = ref(false)
408 +const selectedTechnique = ref<MitreTechnique | null>(null)
409 +const selectedSubTechnique = ref<MitreSubTechnique | null>(null)
410 +const selectedTacticIdForDeepLink = ref<string | null>(null)
411 +
412 +// Direct-from-hover rule modal
413 +const quickRuleOpen = ref(false)
414 +const quickRuleId = ref<string | null>(null)
415 +function openQuickRule(ruleId: string) {
416 + quickRuleId.value = ruleId
417 + quickRuleOpen.value = true
418 +}
419 +
420 +const hoveredTechniqueId = ref<string | null>(null)
421 +const hoveredTacticId = ref<string | null>(null)
422 +
423 +// Suppresses the filter-change watcher during the initial URL→ref hydration
424 +// so we don't fire a duplicate fetch right after the first load.
425 +const ready = ref(false)
426 +
427 +const platformOptions = [
428 + { label: "Linux", value: "linux" },
429 + { label: "Windows", value: "windows" },
430 + { label: "PowerShell", value: "powershell" },
431 + { label: "CVE", value: "cve" }
432 +]
433 +const severityOptions = [
434 + { label: "Low", value: "low" },
435 + { label: "Medium", value: "medium" },
436 + { label: "High", value: "high" },
437 + { label: "Critical", value: "critical" }
438 +]
439 +const statusOptions = [
440 + { label: "Production", value: "production" },
441 + { label: "Experimental", value: "experimental" },
442 + { label: "Deprecated", value: "deprecated" }
443 +]
444 +
445 +const anyFiltersActive = computed(
446 + () =>
447 + !!selectedPlatform.value ||
448 + !!selectedSeverity.value ||
449 + !!selectedStatus.value ||
450 + !!hasGraylogFilter.value
451 +)
452 +
453 +const rulesIndex = computed<Record<string, MitreRuleIndexEntry>>(() => coverage.value?.rules_index ?? {})
454 +
455 +/**
456 + * Match against rule names/IDs via the in-memory rules_index. Used to surface
457 + * techniques whose rules — not whose own name — match the search query.
458 + */
459 +function ruleIdsMatch(ruleIds: string[], q: string): boolean {
460 + const idx = rulesIndex.value
461 + for (const id of ruleIds) {
462 + if (id.toLowerCase().includes(q)) return true
463 + const r = idx[id]
464 + if (r && r.name.toLowerCase().includes(q)) return true
465 + }
466 + return false
467 +}
468 +
469 +const filteredTactics = computed<MitreTactic[]>(() => {
470 + if (!coverage.value) return []
471 + const q = searchQuery.value.trim().toLowerCase()
472 + const tactics = coverage.value.tactics.map(tactic => ({
473 + ...tactic,
474 + techniques: tactic.techniques.filter(tech => {
475 + if (onlyCovered.value && tech.total_rule_count === 0) return false
476 + if (q) {
477 + const techHaystack = `${tech.id} ${tech.name}`.toLowerCase()
478 + const techMatches = techHaystack.includes(q)
479 + const ruleMatches =
480 + ruleIdsMatch(tech.rule_ids, q) ||
481 + tech.subtechniques.some(s => ruleIdsMatch(s.rule_ids, q))
482 + if (!techMatches && !ruleMatches) return false
483 + }
484 + return true
485 + })
486 + }))
487 + // When the user is actively searching, drop tactics with no matches so the
488 + // matrix collapses to just the relevant columns. Without an active search,
489 + // we keep empty tactics visible (they're informative on their own).
490 + return q ? tactics.filter(t => t.techniques.length > 0) : tactics
491 +})
492 +
493 +const legendSteps = [
494 + { label: "0", cls: "cov-empty" },
495 + { label: "1", cls: "cov-1" },
496 + { label: "2-3", cls: "cov-2" },
497 + { label: "4-7", cls: "cov-3" },
498 + { label: "8+", cls: "cov-4" }
499 +] as const
500 +
501 +function tacticStats(tactic: MitreTactic) {
502 + const source = coverage.value?.tactics.find(t => t.id === tactic.id)?.techniques ?? tactic.techniques
503 + const total = source.length
504 + const covered = source.filter(t => t.total_rule_count > 0).length
505 + return { total, covered }
506 +}
507 +
508 +function isTacticUncovered(tactic: MitreTactic): boolean {
509 + const { covered, total } = tacticStats(tactic)
510 + return total > 0 && covered === 0
511 +}
512 +
513 +function cellClass(item: MitreTechnique | MitreSubTechnique, isSub = false) {
514 + const count = "total_rule_count" in item ? item.total_rule_count : item.rule_count
515 + if (count === 0) return `cov-empty`
516 + if (count === 1) return `cov-1`
517 + if (count <= 3) return `cov-2`
518 + if (count <= 7) return `cov-3`
519 + return `cov-4`
520 +}
521 +
522 +function cellTooltip(tech: MitreTechnique) {
523 + if (tech.total_rule_count === 0) return `${tech.id} ${tech.name} — no CoPilot rules`
524 + const subDelta = tech.total_rule_count - tech.rule_count
525 + return subDelta
526 + ? `${tech.id} ${tech.name} — ${tech.rule_count} direct, +${subDelta} via sub-techniques`
527 + : `${tech.id} ${tech.name} — ${tech.rule_count} rule(s)`
528 +}
529 +function subCellTooltip(sub: MitreSubTechnique) {
530 + return sub.rule_count
531 + ? `${sub.id} ${sub.name} — ${sub.rule_count} rule(s)`
532 + : `${sub.id} ${sub.name} — no rules`
533 +}
534 +
535 +function toggleExpand(tacticId: string, techId: string) {
536 + const k = tacticId + techId
537 + const willOpen = !expanded.value[k]
538 + if (willOpen) {
539 + // Auto-collapse other expanded techniques in the same tactic so columns
540 + // don't sprawl vertically when several are open at once.
541 + for (const otherKey of Object.keys(expanded.value)) {
542 + if (otherKey.startsWith(tacticId) && otherKey !== k) {
543 + expanded.value[otherKey] = false
544 + }
545 + }
546 + }
547 + expanded.value[k] = willOpen
548 +}
549 +
550 +function clearAllFilters() {
551 + selectedPlatform.value = null
552 + selectedSeverity.value = null
553 + selectedStatus.value = null
554 + hasGraylogFilter.value = false
555 + searchQuery.value = ""
556 + onlyCovered.value = false
557 +}
558 +
559 +function exportCoverageCsv() {
560 + if (!coverage.value) return
561 + const rows: string[][] = [["tactic_id", "tactic_name", "technique_id", "technique_name", "rule_count_direct", "rule_count_total", "rule_ids"]]
562 + for (const tactic of coverage.value.tactics) {
563 + for (const tech of tactic.techniques) {
564 + rows.push([
565 + tactic.id,
566 + tactic.name,
567 + tech.id,
568 + tech.name,
569 + String(tech.rule_count),
570 + String(tech.total_rule_count),
571 + tech.rule_ids.join("|")
572 + ])
573 + for (const sub of tech.subtechniques) {
574 + rows.push([
575 + tactic.id,
576 + tactic.name,
577 + sub.id,
578 + sub.name,
579 + String(sub.rule_count),
580 + String(sub.rule_count),
581 + sub.rule_ids.join("|")
582 + ])
583 + }
584 + }
585 + }
586 + const csv = rows.map(r => r.map(cell => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n")
587 + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
588 + const url = URL.createObjectURL(blob)
589 + const link = document.createElement("a")
590 + link.href = url
591 + link.download = `copilot-mitre-coverage-${new Date().toISOString().slice(0, 10)}.csv`
592 + document.body.appendChild(link)
593 + link.click()
594 + document.body.removeChild(link)
595 + URL.revokeObjectURL(url)
596 +}
597 +
598 +function onCellEnter(tacticId: string, techId: string) {
599 + hoveredTacticId.value = tacticId
600 + hoveredTechniqueId.value = techId
601 +}
602 +function onCellLeave() {
603 + hoveredTacticId.value = null
604 + hoveredTechniqueId.value = null
605 +}
606 +
607 +function resetFilters() {
608 + selectedPlatform.value = null
609 + selectedSeverity.value = null
610 + selectedStatus.value = null
611 + hasGraylogFilter.value = false
612 + showFilters.value = false
613 +}
614 +
615 +function openTechnique(tactic: MitreTactic, tech: MitreTechnique) {
616 + selectedTacticIdForDeepLink.value = tactic.id
617 + selectedTechnique.value = tech
618 + selectedSubTechnique.value = null
619 + drawerOpen.value = true
620 + syncRouteFromSelection()
621 +}
622 +function openSubTechnique(tactic: MitreTactic, tech: MitreTechnique, sub: MitreSubTechnique) {
623 + selectedTacticIdForDeepLink.value = tactic.id
624 + selectedTechnique.value = tech
625 + selectedSubTechnique.value = sub
626 + drawerOpen.value = true
627 + syncRouteFromSelection()
628 +}
629 +
630 +function onDrawerToggle(open: boolean) {
631 + if (!open) {
632 + // Drawer just closed — drop the technique deep-link query.
633 + const next = { ...route.query }
634 + delete next.technique
635 + delete next.sub
636 + router.replace({ query: next })
637 + }
638 +}
639 +
640 +function syncRouteFromSelection() {
641 + if (!selectedTechnique.value) return
642 + const next: Record<string, string> = { ...(route.query as Record<string, string>) }
643 + next.view = "matrix"
644 + next.technique = selectedTechnique.value.id
645 + if (selectedSubTechnique.value) next.sub = selectedSubTechnique.value.id
646 + else delete next.sub
647 + router.replace({ query: next })
648 +}
649 +
650 +function syncFiltersToUrl() {
651 + const next: Record<string, string> = { ...(route.query as Record<string, string>) }
652 + if (selectedPlatform.value) next.platform = selectedPlatform.value
653 + else delete next.platform
654 + if (selectedSeverity.value) next.severity = selectedSeverity.value
655 + else delete next.severity
656 + if (selectedStatus.value) next.status = selectedStatus.value
657 + else delete next.status
658 + if (hasGraylogFilter.value) next.has_graylog = "true"
659 + else delete next.has_graylog
660 + router.replace({ query: next })
661 +}
662 +
663 +function applyFiltersFromUrl() {
664 + const q = route.query
665 + const platform = q.platform as string | undefined
666 + const severity = q.severity as string | undefined
667 + const status = q.status as string | undefined
668 +
669 + const validPlatforms: PlatformFilter[] = ["all", "linux", "windows", "powershell", "cve"]
670 + const validSeverities: RuleSeverity[] = ["low", "medium", "high", "critical"]
671 + const validStatuses: RuleStatus[] = ["production", "experimental", "deprecated"]
672 +
673 + selectedPlatform.value =
674 + platform && (validPlatforms as string[]).includes(platform) ? (platform as PlatformFilter) : null
675 + selectedSeverity.value =
676 + severity && (validSeverities as string[]).includes(severity) ? (severity as RuleSeverity) : null
677 + selectedStatus.value =
678 + status && (validStatuses as string[]).includes(status) ? (status as RuleStatus) : null
679 + hasGraylogFilter.value = q.has_graylog === "true"
680 +}
681 +
682 +function applyDeepLinkFromRoute() {
683 + if (!coverage.value) return
684 + const techId = (route.query.technique as string | undefined)?.toUpperCase()
685 + const subId = (route.query.sub as string | undefined)?.toUpperCase()
686 + if (!techId) return
687 +
688 + for (const tactic of coverage.value.tactics) {
689 + const tech = tactic.techniques.find(t => t.id === techId)
690 + if (!tech) continue
691 + if (subId) {
692 + const sub = tech.subtechniques.find(s => s.id === subId)
693 + if (sub) {
694 + expanded.value[tactic.id + tech.id] = true
695 + openSubTechnique(tactic, tech, sub)
696 + return
697 + }
698 + }
699 + openTechnique(tactic, tech)
700 + return
701 + }
702 +}
703 +
704 +async function load(opts: { preserveDeepLink?: boolean } = {}) {
705 + loading.value = true
706 + const query: MitreCoverageQuery = {
707 + platform: selectedPlatform.value || undefined,
708 + severity: selectedSeverity.value || undefined,
709 + status: selectedStatus.value || undefined,
710 + has_graylog: hasGraylogFilter.value || undefined
711 + }
712 + try {
713 + const res = await Api.copilotSearches.getMitreCoverage(query)
714 + if (res.data?.success) {
715 + coverage.value = res.data
716 + if (opts.preserveDeepLink) applyDeepLinkFromRoute()
717 + } else {
718 + message.warning(res.data?.message || "Failed to load MITRE coverage")
719 + }
720 + } catch (err: any) {
721 + message.error(err.response?.data?.message || "Failed to load MITRE coverage")
722 + } finally {
723 + loading.value = false
724 + }
725 +}
726 +
727 +async function handleRefresh() {
728 + refreshing.value = true
729 + try {
730 + await Api.copilotSearches.refreshMitreMatrix()
731 + await load()
732 + message.success("MITRE matrix refreshed")
733 + } catch (err: any) {
734 + message.error(err.response?.data?.message || "Failed to refresh MITRE matrix")
735 + } finally {
736 + refreshing.value = false
737 + }
738 +}
739 +
740 +watchDebounced(
741 + [selectedPlatform, selectedSeverity, selectedStatus, hasGraylogFilter],
742 + () => {
743 + if (!ready.value) return
744 + syncFiltersToUrl()
745 + load()
746 + },
747 + { debounce: 250 }
748 +)
749 +
750 +// React to deep-link URL changes (back/forward, paste-link, etc.).
751 +watch(
752 + () => [route.query.technique, route.query.sub] as const,
753 + () => applyDeepLinkFromRoute()
754 +)
755 +
756 +onMounted(async () => {
757 + applyFiltersFromUrl()
758 + await load({ preserveDeepLink: true })
759 + ready.value = true
760 +})
761 +
762 +// ---------------------------------------------------------------------------
763 +// Hover preview list — inline component. Rule rows are clickable; clicking
764 +// one opens the existing rule-detail modal directly without going through
765 +// the technique drawer.
766 +// ---------------------------------------------------------------------------
767 +const platformIcon: Record<string, string> = {
768 + linux: "logos:linux-tux",
769 + windows: "logos:microsoft-icon",
770 + powershell: "vscode-icons:file-type-powershell",
771 + cve: "carbon:security",
772 + unknown: "carbon:help"
773 +}
774 +
775 +const RulePreviewList = (props: {
776 + ruleIds: string[]
777 + index: Record<string, MitreRuleIndexEntry>
778 + extraViaSubs?: number
779 +}) => {
780 + const ids = props.ruleIds || []
781 + if (!ids.length) {
782 + return h("div", { class: "preview-empty text-secondary text-xs" }, "No rules")
783 + }
784 + const shown = ids.slice(0, 6)
785 + const remainder = ids.length - shown.length
786 + return h("div", { class: "preview-wrap flex flex-col gap-1" }, [
787 + h(
788 + "div",
789 + { class: "text-tertiary text-xs uppercase tracking-wide" },
790 + `${ids.length} rule${ids.length === 1 ? "" : "s"}` +
791 + (props.extraViaSubs ? ` · +${props.extraViaSubs} via sub-techniques` : "")
792 + ),
793 + ...shown.map(id => {
794 + const entry = props.index[id]
795 + const platform = (entry?.platform || "unknown").toLowerCase()
796 + const iconName = platformIcon[platform] || platformIcon.unknown
797 + const dataSources = entry?.data_sources || []
798 + return h(
799 + "div",
800 + {
801 + class: "preview-row flex flex-col gap-1",
802 + key: id,
803 + onClick: (e: MouseEvent) => {
804 + e.stopPropagation()
805 + openQuickRule(id)
806 + },
807 + title: "Click to open rule details"
808 + },
809 + [
810 + h("div", { class: "flex items-center gap-2" }, [
811 + h(Icon as any, { name: iconName, size: 14, class: "preview-platform shrink-0" }),
812 + h("span", { class: "preview-name text-default text-xs" }, entry?.name || id),
813 + entry?.severity
814 + ? h(
815 + "span",
816 + { class: `preview-sev preview-sev-${entry.severity.toLowerCase()} text-xs` },
817 + entry.severity
818 + )
819 + : null
820 + ]),
821 + dataSources.length
822 + ? h(
823 + "div",
824 + { class: "preview-sources flex flex-wrap items-center gap-1" },
825 + dataSources.map(s => h("span", { class: "preview-source text-xs", key: s }, s))
826 + )
827 + : null
828 + ]
829 + )
830 + }),
831 + remainder > 0
832 + ? h("div", { class: "text-tertiary text-xs" }, `+ ${remainder} more — click cell to view all`)
833 + : null
834 + ])
835 +}
836 +</script>
837 +
838 +<style scoped lang="scss">
839 +.legend {
840 + display: flex;
841 + flex-wrap: wrap;
842 + align-items: center;
843 + gap: 10px;
844 + padding: 6px 10px;
845 + background: var(--bg-secondary-color);
846 + border: 1px solid var(--border-color);
847 + border-radius: var(--border-radius);
848 +}
849 +
850 +.legend-item {
851 + display: inline-flex;
852 + align-items: center;
853 + gap: 5px;
854 +}
855 +
856 +.legend-swatch {
857 + display: inline-block;
858 + width: 14px;
859 + height: 14px;
860 + border-radius: 3px;
861 + border: 1px solid var(--border-color);
862 +}
863 +
864 +.tactic-coverage {
865 + font-family: var(--font-family-mono, monospace);
866 + font-size: 0.7rem;
867 + font-weight: 600;
868 + color: var(--fg-secondary-color);
869 + background: var(--bg-default-color);
870 + border: 1px solid var(--border-color);
871 + border-radius: 3px;
872 + padding: 1px 6px;
873 + white-space: nowrap;
874 +}
875 +
876 +.matrix-scroll-wrap {
877 + position: relative;
878 +}
879 +
880 +/* Subtle indeterminate progress bar shown during filter refetches in place
881 + of a heavy spin overlay. Sits at the top of the scroll container and
882 + doesn't shift the layout when it appears/disappears. */
883 +.matrix-progress {
884 + position: absolute;
885 + left: 0;
886 + right: 0;
887 + top: 0;
888 + height: 2px;
889 + overflow: hidden;
890 + background: rgba(var(--primary-color-rgb) / 0.1);
891 + z-index: 3;
892 + pointer-events: none;
893 + border-radius: var(--border-radius) var(--border-radius) 0 0;
894 +}
895 +.matrix-progress::after {
896 + content: "";
897 + position: absolute;
898 + top: 0;
899 + left: -40%;
900 + width: 40%;
901 + height: 100%;
902 + background: var(--primary-color);
903 + animation: matrix-progress-slide 1.1s ease-in-out infinite;
904 +}
905 +@keyframes matrix-progress-slide {
906 + 0% { left: -40%; }
907 + 100% { left: 100%; }
908 +}
909 +
910 +/* Matrix scrolls inside its own bounded box so the horizontal scrollbar
911 + is always reachable without scrolling the whole page. Height adapts to
912 + the viewport minus app chrome + our toolbar/legend rows. */
913 +.matrix-scroll {
914 + overflow: auto;
915 + max-height: calc(100vh - 260px);
916 + min-height: 420px;
917 + padding-bottom: 4px;
918 + border: 1px solid var(--border-color);
919 + border-radius: var(--border-radius);
920 + background: var(--bg-secondary-color);
921 + transition: opacity 0.18s ease;
922 +}
923 +
924 +/* During a filter refetch, fade existing data slightly so the user sees
925 + the fresh load is happening without the matrix disappearing. */
926 +.matrix-scroll-loading {
927 + opacity: 0.55;
928 +}
929 +
930 +.matrix-empty {
931 + height: 100%;
932 + min-height: 380px;
933 + display: flex;
934 + align-items: center;
935 + justify-content: center;
936 +}
937 +
938 +.matrix-initial-load {
939 + display: flex;
940 + align-items: center;
941 + justify-content: center;
942 + min-height: 380px;
943 + width: 100%;
944 +}
945 +
946 +.matrix-grid {
947 + display: flex;
948 + gap: 6px;
949 + min-width: max-content;
950 + padding: 4px;
951 +}
952 +
953 +.tactic-column {
954 + width: 200px;
955 + flex-shrink: 0;
956 + display: flex;
957 + flex-direction: column;
958 +}
959 +
960 +/* Tactic headers stick to the top of the scroll container so the column
961 + label is always visible while scrolling vertically through techniques. */
962 +.tactic-header {
963 + position: sticky;
964 + top: 0;
965 + z-index: 2;
966 + padding: 8px 10px;
967 + background: var(--bg-secondary-color);
968 + border: 1px solid var(--border-color);
969 + border-radius: 6px 6px 0 0;
970 + border-bottom-width: 2px;
971 +}
972 +
973 +/* Tactic with no covered techniques — soft warning border so coverage gaps
974 + surface at a glance without screaming. */
975 +.tactic-header.tactic-uncovered {
976 + border-color: rgba(var(--warning-color-rgb) / 0.55);
977 + border-bottom-color: rgba(var(--warning-color-rgb) / 0.7);
978 + background: rgba(var(--warning-color-rgb) / 0.06);
979 +}
980 +
981 +.tactic-coverage-zero {
982 + color: var(--warning-color);
983 + border-color: rgba(var(--warning-color-rgb) / 0.55);
984 + background: rgba(var(--warning-color-rgb) / 0.08);
985 +}
986 +
987 +.tactic-name {
988 + font-weight: 600;
989 + font-size: 0.85rem;
990 + color: var(--fg-default-color);
991 +}
992 +
993 +.technique-list {
994 + display: flex;
995 + flex-direction: column;
996 + gap: 3px;
997 + padding-top: 3px;
998 +}
999 +
1000 +.technique-cell {
1001 + padding: 6px 8px;
1002 + border-radius: 4px;
1003 + cursor: pointer;
1004 + transition: background-color 0.12s, border-color 0.12s, box-shadow 0.12s;
1005 + font-size: 0.75rem;
1006 + border: 1px solid var(--border-color);
1007 + background: var(--bg-default-color);
1008 +}
1009 +
1010 +.technique-cell:hover {
1011 + border-color: rgba(var(--primary-color-rgb) / 0.6);
1012 + background: rgba(var(--primary-color-rgb) / 0.08);
1013 +}
1014 +
1015 +/* Same technique appearing in another tactic column — gets a soft outline
1016 + so you can see cross-tactic membership at a glance. */
1017 +.cell-cross-tactic {
1018 + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb) / 0.45);
1019 +}
1020 +
1021 +.technique-row {
1022 + display: flex;
1023 + justify-content: space-between;
1024 + align-items: center;
1025 + gap: 6px;
1026 +}
1027 +
1028 +.technique-id {
1029 + font-weight: 600;
1030 + font-family: var(--font-family-mono, monospace);
1031 + color: var(--fg-default-color);
1032 + font-size: 0.72rem;
1033 +}
1034 +
1035 +.technique-name {
1036 + font-size: 0.7rem;
1037 + color: var(--fg-secondary-color);
1038 + margin-top: 2px;
1039 + line-height: 1.25;
1040 +}
1041 +
1042 +.count-tag {
1043 + font-weight: 700;
1044 + min-width: 22px;
1045 + justify-content: center;
1046 +}
1047 +
1048 +.technique-sub-toggle {
1049 + margin-top: 4px;
1050 + font-size: 0.65rem;
1051 + color: var(--fg-tertiary-color);
1052 + cursor: pointer;
1053 + user-select: none;
1054 + display: inline-flex;
1055 + align-items: center;
1056 + gap: 3px;
1057 + padding: 2px 4px;
1058 + border-radius: 3px;
1059 + width: fit-content;
1060 +}
1061 +
1062 +.technique-sub-toggle:hover {
1063 + color: var(--primary-color);
1064 + background: rgba(var(--primary-color-rgb) / 0.08);
1065 +}
1066 +
1067 +.subtechnique-list {
1068 + margin-top: 4px;
1069 + padding-left: 6px;
1070 + display: flex;
1071 + flex-direction: column;
1072 + gap: 2px;
1073 + border-left: 2px solid var(--border-color);
1074 +}
1075 +
1076 +.subtechnique-cell {
1077 + padding: 4px 6px;
1078 + border-radius: 3px;
1079 + cursor: pointer;
1080 + font-size: 0.7rem;
1081 + border: 1px solid var(--border-color);
1082 + background: var(--bg-default-color);
1083 + transition: background-color 0.12s, border-color 0.12s;
1084 +}
1085 +
1086 +.subtechnique-cell:hover {
1087 + border-color: rgba(var(--primary-color-rgb) / 0.6);
1088 + background: rgba(var(--primary-color-rgb) / 0.08);
1089 +}
1090 +
1091 +.subtechnique-id {
1092 + font-family: var(--font-family-mono, monospace);
1093 + font-weight: 600;
1094 + font-size: 0.65rem;
1095 + color: var(--fg-default-color);
1096 +}
1097 +
1098 +.subtechnique-name {
1099 + font-size: 0.65rem;
1100 + color: var(--fg-secondary-color);
1101 + line-height: 1.25;
1102 +}
1103 +
1104 +.show-all-subs {
1105 + margin-top: 2px;
1106 + padding: 3px 6px;
1107 + font-size: 0.65rem;
1108 + color: var(--fg-tertiary-color);
1109 + cursor: pointer;
1110 + border-radius: 3px;
1111 + user-select: none;
1112 + text-align: center;
1113 + border: 1px dashed var(--border-color);
1114 +}
1115 +.show-all-subs:hover {
1116 + color: var(--primary-color);
1117 + border-color: rgba(var(--primary-color-rgb) / 0.5);
1118 + background: rgba(var(--primary-color-rgb) / 0.06);
1119 +}
1120 +
1121 +/* Coverage heat — subtle brand-tinted backgrounds, neutral borders so the
1122 + grid still reads as a grid. Text never goes white-on-orange. */
1123 +.cov-empty {
1124 + background: var(--bg-default-color);
1125 +}
1126 +.cov-1 {
1127 + background: rgba(var(--primary-color-rgb) / 0.07);
1128 +}
1129 +.cov-2 {
1130 + background: rgba(var(--primary-color-rgb) / 0.16);
1131 +}
1132 +.cov-3 {
1133 + background: rgba(var(--primary-color-rgb) / 0.28);
1134 +}
1135 +.cov-4 {
1136 + background: rgba(var(--primary-color-rgb) / 0.45);
1137 +}
1138 +</style>
1139 +
1140 +<style lang="scss">
1141 +/* Unscoped: applies to the inline RulePreviewList rendered inside n-popover bodies,
1142 + which sit outside the component tree. */
1143 +.preview-wrap {
1144 + max-width: 360px;
1145 +}
1146 +.preview-row {
1147 + cursor: pointer;
1148 + padding: 2px 4px;
1149 + border-radius: 3px;
1150 + transition: background-color 0.1s;
1151 +}
1152 +.preview-row:hover {
1153 + background: rgba(var(--primary-color-rgb) / 0.1);
1154 +}
1155 +.preview-row:hover .preview-name {
1156 + color: var(--primary-color);
1157 +}
1158 +.preview-row .preview-name {
1159 + flex: 1;
1160 + overflow: hidden;
1161 + text-overflow: ellipsis;
1162 + white-space: nowrap;
1163 +}
1164 +.preview-platform {
1165 + opacity: 0.85;
1166 +}
1167 +
1168 +.preview-sources {
1169 + margin-left: 22px;
1170 +}
1171 +.preview-source {
1172 + font-size: 0.6rem;
1173 + font-weight: 500;
1174 + letter-spacing: 0.02em;
1175 + color: var(--fg-tertiary-color);
1176 + background: var(--bg-default-color);
1177 + border: 1px solid var(--border-color);
1178 + border-radius: 3px;
1179 + padding: 1px 5px;
1180 +}
1181 +.preview-sev {
1182 + font-size: 0.65rem;
1183 + font-weight: 600;
1184 + text-transform: uppercase;
1185 + padding: 1px 6px;
1186 + border-radius: 3px;
1187 + border: 1px solid var(--border-color);
1188 + color: var(--fg-secondary-color);
1189 +}
1190 +.preview-sev-low {
1191 + color: var(--info-color);
1192 + border-color: rgba(var(--info-color-rgb) / 0.4);
1193 + background: rgba(var(--info-color-rgb) / 0.1);
1194 +}
1195 +.preview-sev-medium {
1196 + color: var(--warning-color);
1197 + border-color: rgba(var(--warning-color-rgb) / 0.4);
1198 + background: rgba(var(--warning-color-rgb) / 0.1);
1199 +}
1200 +.preview-sev-high {
1201 + color: var(--error-color);
1202 + border-color: rgba(var(--error-color-rgb) / 0.4);
1203 + background: rgba(var(--error-color-rgb) / 0.1);
1204 +}
1205 +.preview-sev-critical {
1206 + color: var(--error-color);
1207 + border-color: var(--error-color);
1208 + background: rgba(var(--error-color-rgb) / 0.18);
1209 +}
1210 +</style>
frontend/src/components/copilotSearches/RuleCard.vue
+103 -6
@@ -1,5 +1,13 @@
1 <template>
2 - <div class="h-full">
2 + <div class="rule-card-wrap h-full" :class="{ 'is-selected': selectable && selected }">
3 + <n-checkbox
4 + v-if="selectable"
5 + :checked="selected"
6 + class="rule-card-checkbox"
7 + size="small"
8 + @update:checked="emit('update:selected', $event)"
9 + @click.stop
10 + />
11 <CardEntity
12 hoverable
13 clickable
@@ -38,6 +46,15 @@
46 </template>
47 <template #headerExtra>
48 <div class="text-default pt-.5 flex h-full items-center gap-2">
49 + <n-tooltip v-if="provisioned">
50 + <template #trigger>
51 + <div class="provisioned-chip">
52 + <Icon :name="ProvisionedIcon" :size="11" />
53 + <span>in Graylog</span>
54 + </div>
55 + </template>
56 + An event definition with this rule's title already exists in Graylog
57 + </n-tooltip>
58 <n-tooltip v-if="rule.has_graylog_query">
59 <template #trigger>
60 <Icon :name="GraylogIcon" :size="16" />
@@ -57,7 +74,14 @@
74 <template #mainExtra>
75 <div class="flex flex-wrap items-center justify-between gap-2">
76 <SeverityBadge :severity="rule.severity" />
60 - <PlatformBadge :platform="rule.platform" />
77 + <Badge>
78 + <template #value>
79 + <div class="flex items-center gap-2">
80 + <Icon :name="platformInfo.icon" :size="14" />
81 + <span class="whitespace-nowrap">{{ platformInfo.label }}</span>
82 + </div>
83 + </template>
84 + </Badge>
85 </div>
86 </template>
87 <template #footerExtra>
@@ -134,18 +158,27 @@
158 <script setup lang="ts">
159 import type { BadgeColor } from "@/components/common/Badge.vue"
160 import type { RuleSummary } from "@/types/copilotSearches.d"
137 -import { NButton, NModal, NTooltip, useMessage } from "naive-ui"
138 -import { ref } from "vue"
161 +import { NButton, NCheckbox, NModal, NTooltip, useMessage } from "naive-ui"
162 +import { computed, ref } from "vue"
163 import Badge from "@/components/common/Badge.vue"
164 import CardEntity from "@/components/common/cards/CardEntity.vue"
165 import Icon from "@/components/common/Icon.vue"
142 -import PlatformBadge from "@/components/common/PlatformBadge.vue"
166 import ExecuteSearchForm from "./ExecuteSearchForm.vue"
167 import ProvisionGraylogForm from "./ProvisionGraylogForm.vue"
168 import RuleCardContent from "./RuleCardContent.vue"
169 import SeverityBadge from "./SeverityBadge.vue"
170
148 -const { rule } = defineProps<{ rule: RuleSummary; embedded?: boolean }>()
171 +const { rule } = defineProps<{
172 + rule: RuleSummary
173 + embedded?: boolean
174 + provisioned?: boolean
175 + selectable?: boolean
176 + selected?: boolean
177 +}>()
178 +
179 +const emit = defineEmits<{
180 + (e: "update:selected", value: boolean): void
181 +}>()
182
183 const showDetails = ref(false)
184 const showExecuteModal = ref(false)
@@ -155,6 +188,22 @@ const message = useMessage()
188 const PlayIcon = "carbon:play"
189 const ProvisionIcon = "carbon:add-alt"
190 const GraylogIcon = "carbon:notification"
191 +const ProvisionedIcon = "carbon:checkmark-filled"
192 +
193 +// Platform → icon + label mapping. Mirrors the platforms the CoPilot Searches
194 +// backend actually emits (linux, windows, powershell, cve, unknown). Done
195 +// locally instead of using the shared PlatformBadge so we cover values like
196 +// "powershell" and "cve" that the shared `getOS` util doesn't recognize.
197 +const PLATFORM_INFO: Record<string, { icon: string; label: string }> = {
198 + linux: { icon: "mdi:linux", label: "Linux" },
199 + windows: { icon: "mdi:microsoft-windows", label: "Windows" },
200 + powershell: { icon: "mdi:powershell", label: "PowerShell" },
201 + cve: { icon: "carbon:security", label: "CVE" }
202 +}
203 +const platformInfo = computed(() => {
204 + const key = (rule.platform || "").toLowerCase()
205 + return PLATFORM_INFO[key] || { icon: "mdi:help-box", label: "Unknown" }
206 +})
207
208 function getStatusColor(status: string): BadgeColor | undefined {
209 switch (status.toLowerCase()) {
@@ -178,3 +227,51 @@ function handleProvisionSuccess() {
227 showProvisionModal.value = false
228 }
229 </script>
230 +
231 +<style scoped lang="scss">
232 +.provisioned-chip {
233 + display: inline-flex;
234 + align-items: center;
235 + gap: 4px;
236 + padding: 2px 6px;
237 + font-size: 0.65rem;
238 + font-weight: 600;
239 + letter-spacing: 0.02em;
240 + color: var(--success-color);
241 + background: rgba(var(--success-color-rgb) / 0.1);
242 + border: 1px solid rgba(var(--success-color-rgb) / 0.4);
243 + border-radius: 3px;
244 + white-space: nowrap;
245 +}
246 +
247 +.rule-card-wrap {
248 + position: relative;
249 +}
250 +
251 +/* Sits just outside the card's top-left corner like a sticker so it never
252 + overlaps the header badges. The card's own click handler still triggers
253 + the rule-detail modal; the checkbox stops propagation. */
254 +.rule-card-checkbox {
255 + position: absolute;
256 + top: -7px;
257 + left: -7px;
258 + z-index: 2;
259 + display: flex;
260 + align-items: center;
261 + justify-content: center;
262 + background: var(--bg-default-color);
263 + border: 1px solid var(--border-color);
264 + border-radius: 4px;
265 + padding: 2px;
266 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
267 + transition: border-color 0.12s, box-shadow 0.12s;
268 +}
269 +.rule-card-wrap.is-selected .rule-card-checkbox {
270 + border-color: rgba(var(--primary-color-rgb) / 0.6);
271 + box-shadow: 0 1px 4px rgba(var(--primary-color-rgb) / 0.4);
272 +}
273 +
274 +.rule-card-wrap.is-selected :deep(.card-entity) {
275 + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb) / 0.6);
276 +}
277 +</style>
frontend/src/components/copilotSearches/TechniqueDrawer.vue new
+203
@@ -0,0 +1,203 @@
1 +<template>
2 + <n-drawer v-model:show="showLocal" :width="drawerWidth" placement="right">
3 + <n-drawer-content :title="drawerTitle" closable>
4 + <template v-if="technique">
5 + <div class="mb-3 flex flex-col gap-1">
6 + <div class="text-secondary text-sm">
7 + <a v-if="technique.url" :href="technique.url" target="_blank" rel="noopener">
8 + {{ technique.id }} — view on attack.mitre.org ↗
9 + </a>
10 + </div>
11 + <div v-if="subTechnique" class="text-secondary text-sm">
12 + Sub-technique:
13 + <a v-if="subTechnique.url" :href="subTechnique.url" target="_blank" rel="noopener">
14 + {{ subTechnique.id }} {{ subTechnique.name }} ↗
15 + </a>
16 + <span v-else>{{ subTechnique.id }} {{ subTechnique.name }}</span>
17 + </div>
18 + </div>
19 +
20 + <div v-if="provisionableCount > 0" class="mb-3 flex items-center justify-between gap-2 rounded-md border border-default bg-secondary p-2">
21 + <div class="text-secondary text-xs">
22 + <strong>{{ provisionableCount }}</strong>
23 + of
24 + <strong>{{ rules.length }}</strong>
25 + rule{{ rules.length === 1 ? "" : "s" }} have a Graylog query available.
26 + </div>
27 + <n-button
28 + size="small"
29 + type="primary"
30 + secondary
31 + :disabled="loading || !provisionableCount"
32 + @click="showBulkModal = true"
33 + >
34 + <template #icon>
35 + <Icon :name="ProvisionIcon" />
36 + </template>
37 + Provision all
38 + </n-button>
39 + </div>
40 +
41 + <n-spin :show="loading">
42 + <div v-if="rules.length" class="grid grid-cols-1 gap-3">
43 + <RuleCard
44 + v-for="rule of rules"
45 + :key="rule.id"
46 + :rule
47 + embedded
48 + :provisioned="provisionedMap[rule.id] === true"
49 + />
50 + </div>
51 + <n-empty
52 + v-else-if="!loading"
53 + description="No CoPilot Search rules cover this technique yet."
54 + class="h-40 justify-center"
55 + />
56 + </n-spin>
57 + </template>
58 + </n-drawer-content>
59 +
60 + <BulkProvisionModal
61 + v-model:show="showBulkModal"
62 + :rule-ids="provisionableRules.map(r => r.id)"
63 + @success="onBulkSuccess"
64 + />
65 + </n-drawer>
66 +</template>
67 +
68 +<script setup lang="ts">
69 +import type {
70 + BulkProvisionGraylogAlertResponse,
71 + MitreSubTechnique,
72 + MitreTechnique,
73 + RuleSummary
74 +} from "@/types/copilotSearches.d"
75 +import { NButton, NDrawer, NDrawerContent, NEmpty, NSpin, useMessage } from "naive-ui"
76 +import { computed, ref, watch } from "vue"
77 +import Api from "@/api"
78 +import Icon from "@/components/common/Icon.vue"
79 +import BulkProvisionModal from "./BulkProvisionModal.vue"
80 +import RuleCard from "./RuleCard.vue"
81 +
82 +const props = defineProps<{
83 + show: boolean
84 + technique: MitreTechnique | null
85 + subTechnique?: MitreSubTechnique | null
86 +}>()
87 +
88 +const emit = defineEmits<{
89 + (e: "update:show", value: boolean): void
90 +}>()
91 +
92 +const showLocal = computed({
93 + get: () => props.show,
94 + set: v => emit("update:show", v)
95 +})
96 +
97 +const message = useMessage()
98 +const rules = ref<RuleSummary[]>([])
99 +const provisionedMap = ref<Record<string, boolean>>({})
100 +const loading = ref(false)
101 +const drawerWidth = computed(() => Math.min(820, window.innerWidth - 40))
102 +
103 +// Severity ordering: critical > high > medium > low > unknown
104 +const SEVERITY_RANK: Record<string, number> = {
105 + critical: 4,
106 + high: 3,
107 + medium: 2,
108 + low: 1
109 +}
110 +function sortBySeverity(list: RuleSummary[]): RuleSummary[] {
111 + return [...list].sort((a, b) => {
112 + const sa = SEVERITY_RANK[(a.severity || "").toLowerCase()] ?? 0
113 + const sb = SEVERITY_RANK[(b.severity || "").toLowerCase()] ?? 0
114 + if (sa !== sb) return sb - sa
115 + return (a.name || "").localeCompare(b.name || "")
116 + })
117 +}
118 +
119 +const ProvisionIcon = "carbon:add-alt"
120 +
121 +const drawerTitle = computed(() => {
122 + if (!props.technique) return "Technique"
123 + if (props.subTechnique) return `${props.subTechnique.id} ${props.subTechnique.name}`
124 + return `${props.technique.id} ${props.technique.name}`
125 +})
126 +
127 +const ruleIdsToLoad = computed<string[]>(() => {
128 + if (!props.technique) return []
129 + return props.subTechnique ? props.subTechnique.rule_ids : props.technique.rule_ids
130 +})
131 +
132 +const provisionableRules = computed<RuleSummary[]>(() => rules.value.filter(r => r.has_graylog_query))
133 +const provisionableCount = computed(() => provisionableRules.value.length)
134 +
135 +const showBulkModal = ref(false)
136 +
137 +function onBulkSuccess(res: BulkProvisionGraylogAlertResponse) {
138 + // Reflect new "in Graylog" state immediately on the visible rules list,
139 + // so the chip pops the moment the result modal closes.
140 + const next = { ...provisionedMap.value }
141 + for (const r of res.results) {
142 + if (r.status === "provisioned" || r.status === "skipped") {
143 + next[r.rule_id] = true
144 + }
145 + }
146 + provisionedMap.value = next
147 +}
148 +
149 +async function loadRules() {
150 + const ids = ruleIdsToLoad.value
151 + if (!ids.length) {
152 + rules.value = []
153 + provisionedMap.value = {}
154 + return
155 + }
156 + loading.value = true
157 + provisionedMap.value = {}
158 + try {
159 + // Load rules and provisioning status in parallel — provisioning is best-effort:
160 + // if Graylog is unreachable the chip just doesn't show, no error to the user.
161 + const [rulesRes, statusRes] = await Promise.all([
162 + Api.copilotSearches.getRulesByIds(ids),
163 + Api.copilotSearches.checkGraylogProvisioningStatus(ids).catch(() => null)
164 + ])
165 +
166 + if (rulesRes.data?.success) {
167 + rules.value = sortBySeverity(rulesRes.data.rules || [])
168 + if (rulesRes.data.missing?.length) {
169 + message.warning(
170 + `Some rules could not be loaded (${rulesRes.data.missing.length}). Try refreshing the cache.`
171 + )
172 + }
173 + } else {
174 + message.warning(rulesRes.data?.message || "Failed to load rules for this technique")
175 + }
176 +
177 + if (statusRes?.data?.success && !statusRes.data.warning) {
178 + provisionedMap.value = statusRes.data.provisioned || {}
179 + }
180 + } catch (err: any) {
181 + message.error(err.response?.data?.message || "Failed to load rules for this technique")
182 + } finally {
183 + loading.value = false
184 + }
185 +}
186 +
187 +watch(
188 + () => [props.show, ruleIdsToLoad.value] as const,
189 + ([open]) => {
190 + if (open) loadRules()
191 + },
192 + { immediate: true, deep: true }
193 +)
194 +
195 +// Close the bulk modal automatically when the drawer's context changes —
196 +// the modal itself resets its own internal state on next open.
197 +watch(
198 + () => [props.technique?.id, props.subTechnique?.id] as const,
199 + () => {
200 + showBulkModal.value = false
201 + }
202 +)
203 +</script>
frontend/src/types/copilotSearches.d.ts
+109
@@ -197,6 +197,40 @@ export interface ProvisionGraylogAlertResponse {
197 graylog_query: string
198 }
199
200 +export interface BulkProvisionGraylogAlertRequest {
201 + rule_ids: string[]
202 + search_within_seconds?: number
203 + execute_every_seconds?: number
204 + streams?: string[]
205 + priority?: 1 | 2 | 3
206 + event_limit?: number
207 +}
208 +
209 +export type BulkProvisionRuleStatus = "provisioned" | "skipped" | "failed"
210 +
211 +export interface BulkProvisionRuleResult {
212 + rule_id: string
213 + rule_name: string | null
214 + alert_title: string | null
215 + status: BulkProvisionRuleStatus
216 + reason: string | null
217 +}
218 +
219 +export interface BulkProvisionGraylogAlertResponse {
220 + success: boolean
221 + message: string
222 + provisioned_count: number
223 + skipped_count: number
224 + failed_count: number
225 + results: BulkProvisionRuleResult[]
226 +}
227 +
228 +export interface GraylogProvisioningStatusResponse {
229 + success: boolean
230 + provisioned: Record<string, boolean>
231 + warning: string | null
232 +}
233 +
234 // Query Parameters
235
236 export interface RuleListQuery {
@@ -209,3 +243,78 @@ export interface RuleListQuery {
243 skip?: number
244 limit?: number
245 }
246 +
247 +// MITRE Coverage
248 +
249 +export interface MitreSubTechnique {
250 + id: string
251 + name: string
252 + url: string
253 + rule_count: number
254 + rule_ids: string[]
255 +}
256 +
257 +export interface MitreTechnique {
258 + id: string
259 + name: string
260 + url: string
261 + rule_count: number
262 + rule_ids: string[]
263 + total_rule_count: number
264 + subtechniques: MitreSubTechnique[]
265 +}
266 +
267 +export interface MitreTactic {
268 + id: string
269 + name: string
270 + short_name: string
271 + url: string
272 + techniques: MitreTechnique[]
273 +}
274 +
275 +export interface MitreCoverageStats {
276 + total_tactics: number
277 + total_techniques: number
278 + covered_techniques: number
279 + total_rules: number
280 + matrix_last_refreshed: string | null
281 + rules_last_refreshed: string | null
282 +}
283 +
284 +export interface MitreRuleIndexEntry {
285 + id: string
286 + name: string
287 + severity: string
288 + platform: string
289 + has_graylog: boolean
290 + data_sources: string[]
291 +}
292 +
293 +export interface MitreCoverageQuery {
294 + platform?: PlatformFilter
295 + severity?: RuleSeverity
296 + status?: RuleStatus
297 + has_graylog?: boolean
298 + search?: string
299 +}
300 +
301 +export interface MitreCoverageResponse {
302 + success: boolean
303 + message: string
304 + tactics: MitreTactic[]
305 + rules_index: Record<string, MitreRuleIndexEntry>
306 + stats: MitreCoverageStats
307 +}
308 +
309 +// Batch rule lookup
310 +
311 +export interface RulesByIdsRequest {
312 + ids: string[]
313 +}
314 +
315 +export interface RulesByIdsResponse {
316 + success: boolean
317 + message: string
318 + rules: RuleSummary[]
319 + missing: string[]
320 +}