main
py 728 lines 22.8 KB
Raw
1 from datetime import datetime
2 from enum import Enum
3 from typing import Any
4 from typing import Optional
5
6 from pydantic import BaseModel
7 from pydantic import Field
8
9
10 class PlatformFilter(str, Enum):
11 """Supported platform filters."""
12
13 ALL = "all"
14 LINUX = "linux"
15 WINDOWS = "windows"
16 POWERSHELL = "powershell"
17 CVE = "cve"
18
19
20 class RuleStatus(str, Enum):
21 """Rule status types."""
22
23 PRODUCTION = "production"
24 EXPERIMENTAL = "experimental"
25 DEPRECATED = "deprecated"
26
27
28 class RuleSeverity(str, Enum):
29 """Rule severity levels."""
30
31 LOW = "low"
32 MEDIUM = "medium"
33 HIGH = "high"
34 CRITICAL = "critical"
35
36
37 class ParameterSchema(BaseModel):
38 """Parameter definition schema."""
39
40 name: str
41 description: str
42 type: str
43 required: bool = False
44 default: Optional[Any] = None
45 example: Optional[Any] = None
46
47
48 class GraylogQuery(BaseModel):
49 """Graylog query definition."""
50
51 query: str = Field(..., description="The Graylog search query string")
52
53
54 class RuleSummary(BaseModel):
55 """Lightweight rule summary for list endpoints."""
56
57 id: str
58 name: str
59 version: int
60 status: str
61 type: str
62 description: str
63 author: str
64 date: str
65 severity: str
66 risk_score: int
67 platform: str
68 mitre_attack_id: list[str] = []
69 analytic_story: list[str] = []
70 cve: list[str] = []
71 file_path: str
72 has_graylog_query: bool = False
73
74
75 class RuleDetail(BaseModel):
76 """Full rule details including search query."""
77
78 id: str
79 name: str
80 version: int
81 schema_version: str
82 status: str
83 type: str
84 description: str
85 author: str
86 date: str
87 data_source: list[str]
88 search: dict
89 parameters: list[ParameterSchema]
90 how_to_implement: str
91 known_false_positives: str
92 references: list[str]
93 response: dict
94 tags: dict
95 file_path: str
96 raw_yaml: str
97 graylog: Optional[GraylogQuery] = None
98
99
100 class RuleListResponse(BaseModel):
101 """Response model for rule listing."""
102
103 total: int
104 filtered: int
105 platform: str
106 rules: list[RuleSummary]
107 success: bool = True
108 message: str = "Rules fetched successfully"
109
110
111 class RuleStatsResponse(BaseModel):
112 """Statistics about loaded rules."""
113
114 total_rules: int
115 by_platform: dict[str, int]
116 by_status: dict[str, int]
117 by_severity: dict[str, int]
118 by_mitre_tactic: dict[str, int]
119 rules_with_graylog: int
120 last_refreshed: Optional[datetime] = None
121 cache_ttl_minutes: int
122 success: bool = True
123 message: str = "Statistics fetched successfully"
124
125
126 class RefreshResponse(BaseModel):
127 """Response model for cache refresh."""
128
129 success: bool
130 message: str
131 rules_loaded: int
132 timestamp: datetime
133
134
135 class RuleDetailResponse(BaseModel):
136 """Response model for single rule detail."""
137
138 success: bool = True
139 message: str = "Rule fetched successfully"
140 rule: RuleDetail
141
142
143 # =============================================================================
144 # Search Execution Models
145 # =============================================================================
146
147
148 class ExecuteSearchRequest(BaseModel):
149 """Request model for executing a rule search."""
150
151 rule_id: str = Field(..., description="The ID of the rule to execute")
152 index_pattern: str = Field(
153 ...,
154 description="The index pattern to search (e.g., 'wazuh-alerts-*')",
155 examples=["wazuh-alerts-*"],
156 )
157 parameters: dict[str, Any] = Field(
158 default_factory=dict,
159 description="Parameter values to substitute in the query",
160 examples=[
161 {
162 "AGENT_NAME": "my-server",
163 "CUSTOMER_CODE": "lab",
164 "START_TIME": "now-24h",
165 "END_TIME": "now",
166 },
167 ],
168 )
169 size: Optional[int] = Field(
170 default=None,
171 description="Override the default result size from the rule",
172 ge=1,
173 le=10000,
174 )
175
176
177 class SearchHit(BaseModel):
178 """A single search result hit."""
179
180 index: str = Field(..., description="The index the document was found in")
181 id: str = Field(..., description="The document ID")
182 score: Optional[float] = Field(None, description="The relevance score")
183 source: dict[str, Any] = Field(..., description="The document source")
184
185
186 class ExecuteSearchResponse(BaseModel):
187 """Response model for search execution."""
188
189 success: bool = True
190 message: str = "Search executed successfully"
191 rule_id: str
192 rule_name: str
193 total_hits: int
194 returned_hits: int
195 took_ms: int
196 hits: list[SearchHit]
197 query_executed: dict = Field(
198 ...,
199 description="The actual query that was executed (for debugging)",
200 )
201
202
203 class SearchValidationError(BaseModel):
204 """Validation error details."""
205
206 parameter: str
207 message: str
208
209
210 class ExecuteSearchErrorResponse(BaseModel):
211 """Error response for search execution."""
212
213 success: bool = False
214 message: str
215 rule_id: Optional[str] = None
216 validation_errors: list[SearchValidationError] = []
217
218
219 # =============================================================================
220 # Graylog Query Execution Models
221 # =============================================================================
222
223
224 class ExecuteGraylogQueryRequest(BaseModel):
225 """Request model for executing a Graylog query from a rule."""
226
227 rule_id: str = Field(..., description="The ID of the rule to execute")
228 parameters: dict[str, Any] = Field(
229 default_factory=dict,
230 description="Parameter values to substitute in the query",
231 examples=[
232 {
233 "AGENT_NAME": "my-server",
234 "CUSTOMER_CODE": "lab",
235 },
236 ],
237 )
238
239
240 class GraylogQueryResponse(BaseModel):
241 """Response model for Graylog query generation."""
242
243 success: bool = True
244 message: str = "Graylog query generated successfully"
245 rule_id: str
246 rule_name: str
247 graylog_query: str = Field(
248 ...,
249 description="The Graylog query string with parameters substituted",
250 )
251 original_query: str = Field(
252 ...,
253 description="The original query template from the rule",
254 )
255
256
257 # =============================================================================
258 # Graylog Alert Provisioning Models
259 # =============================================================================
260
261
262 class ProvisionGraylogAlertRequest(BaseModel):
263 """Request model for provisioning a Graylog alert from a CoPilot Search rule."""
264
265 rule_id: str = Field(..., description="The ID of the rule to provision as a Graylog alert")
266 search_within_seconds: int = Field(
267 default=300,
268 description="Time window to search within (in seconds). Default is 300 (5 minutes).",
269 ge=60,
270 le=86400,
271 )
272 execute_every_seconds: int = Field(
273 default=300,
274 description="How often to execute the search (in seconds). Default is 300 (5 minutes).",
275 ge=60,
276 le=86400,
277 )
278 streams: list[str] = Field(
279 default_factory=list,
280 description="Optional list of Graylog stream IDs to limit the search to",
281 )
282 custom_title: Optional[str] = Field(
283 default=None,
284 description="Optional custom title for the alert. If not provided, uses the rule name.",
285 )
286 priority: int = Field(
287 default=2,
288 description="Alert priority (1=Low, 2=Normal, 3=High)",
289 ge=1,
290 le=3,
291 )
292 event_limit: int = Field(
293 default=1000,
294 description="Maximum number of events to process per execution",
295 ge=1,
296 le=10000,
297 )
298
299
300 class ProvisionGraylogAlertResponse(BaseModel):
301 """Response model for Graylog alert provisioning."""
302
303 success: bool = True
304 message: str
305 rule_id: str
306 rule_name: str
307 alert_title: str
308 graylog_query: str
309
310
311 class BulkProvisionGraylogAlertRequest(BaseModel):
312 """Provision multiple CoPilot Search rules as Graylog event definitions in one call.
313
314 Each rule is checked for an existing event definition with the resolved alert
315 title and skipped if a duplicate is found. Failures on one rule do not block
316 the rest — the response carries per-rule results.
317 """
318
319 rule_ids: list[str] = Field(..., description="Rule IDs to provision", min_length=1, max_length=200)
320 search_within_seconds: int = Field(default=300, ge=60, le=86400)
321 execute_every_seconds: int = Field(default=300, ge=60, le=86400)
322 streams: list[str] = Field(default_factory=list)
323 priority: int = Field(default=2, ge=1, le=3)
324 event_limit: int = Field(default=1000, ge=1, le=10000)
325
326
327 class BulkProvisionRuleResult(BaseModel):
328 rule_id: str
329 rule_name: Optional[str] = None
330 alert_title: Optional[str] = None
331 status: str # "provisioned" | "skipped" | "failed"
332 reason: Optional[str] = None
333
334
335 class BulkProvisionGraylogAlertResponse(BaseModel):
336 success: bool = True
337 message: str
338 provisioned_count: int
339 skipped_count: int
340 failed_count: int
341 results: list[BulkProvisionRuleResult]
342
343
344 class GraylogProvisioningStatusResponse(BaseModel):
345 """Per-rule view of which rules already have a matching Graylog event definition.
346
347 `provisioned` maps rule_id -> bool. Rules not present in the cache are omitted.
348 `warning` is set when Graylog itself was unreachable, in which case all values
349 are conservatively reported as `False` so the UI doesn't claim "in Graylog"
350 based on stale info.
351 """
352
353 success: bool = True
354 provisioned: dict[str, bool]
355 warning: Optional[str] = None
356
357
358 # =============================================================================
359 # MITRE Coverage Models
360 # =============================================================================
361
362
363 class MitreSubTechnique(BaseModel):
364 id: str
365 name: str
366 url: str
367 rule_count: int
368 rule_ids: list[str]
369
370
371 class MitreTechnique(BaseModel):
372 id: str
373 name: str
374 url: str
375 rule_count: int
376 rule_ids: list[str]
377 total_rule_count: int
378 subtechniques: list[MitreSubTechnique]
379
380
381 class MitreTactic(BaseModel):
382 id: str
383 name: str
384 short_name: str
385 url: str
386 techniques: list[MitreTechnique]
387
388
389 class MitreCoverageStats(BaseModel):
390 total_tactics: int
391 total_techniques: int
392 covered_techniques: int
393 total_rules: int
394 matrix_last_refreshed: Optional[datetime] = None
395 rules_last_refreshed: Optional[datetime] = None
396
397
398 class MitreRuleIndexEntry(BaseModel):
399 id: str
400 name: str
401 severity: str
402 platform: str
403 has_graylog: bool
404 data_sources: list[str] = Field(default_factory=list)
405
406
407 class MitreCoverageResponse(BaseModel):
408 success: bool = True
409 message: str = "MITRE coverage built successfully"
410 tactics: list[MitreTactic]
411 rules_index: dict[str, MitreRuleIndexEntry] = Field(default_factory=dict)
412 stats: MitreCoverageStats
413
414
415 # =============================================================================
416 # Batch Rule Lookup
417 # =============================================================================
418
419
420 class RulesByIdsRequest(BaseModel):
421 ids: list[str] = Field(..., description="Rule IDs to fetch", max_length=500)
422
423
424 class RulesByIdsResponse(BaseModel):
425 success: bool = True
426 message: str = "Rules fetched successfully"
427 rules: list[RuleSummary]
428 missing: list[str] = Field(default_factory=list, description="IDs that were requested but not found in cache")
429
430
431 # =============================================================================
432 # Detection Catalog — discovery surface over the rules cache.
433 # See backend/app/integrations/copilot_searches/services/detection_catalog.py.
434 # =============================================================================
435
436
437 class CatalogStoryRow(BaseModel):
438 """One row in the Stories index table."""
439
440 name: str
441 data_sources: list[str] = Field(default_factory=list)
442 tactics: list[str] = Field(default_factory=list, description="MITRE tactic display names derived from member rules")
443 products: list[str] = Field(default_factory=list)
444 date: Optional[str] = Field(None, description="Most recent date string across member detections")
445 detection_count: int = 0
446
447
448 class CatalogStoryListResponse(BaseModel):
449 success: bool = True
450 message: str = "Stories listed successfully"
451 stories: list[CatalogStoryRow] = Field(default_factory=list)
452
453
454 class CatalogStoryDetection(BaseModel):
455 """One detection (rule) appearing inside a story's Detections table."""
456
457 id: str
458 name: str
459 type: str
460 severity: Optional[str] = None
461 mitre_attack_id: list[str] = Field(default_factory=list)
462 tactics: list[str] = Field(default_factory=list)
463 description: Optional[str] = None
464
465
466 class CatalogStoryDetailResponse(BaseModel):
467 success: bool = True
468 message: str = "Story detail retrieved successfully"
469 name: str
470 id: str = Field(..., description="Stable slug for the story (URL-safe)")
471 description: str
472 why_it_matters: str = Field(..., description="Auto-generated until/unless curated narrative is added upstream")
473 detections: list[CatalogStoryDetection] = Field(default_factory=list)
474 data_sources: list[str] = Field(default_factory=list)
475 tactics: list[str] = Field(default_factory=list)
476 products: list[str] = Field(default_factory=list)
477 authors: list[str] = Field(default_factory=list)
478 references: list[str] = Field(default_factory=list)
479 date: Optional[str] = None
480 version: Optional[int] = None
481 detection_count: int = 0
482
483
484 class CatalogStatsResponse(BaseModel):
485 success: bool = True
486 message: str = "Catalog stats retrieved successfully"
487 detection_count: int = 0
488 story_count: int = 0
489 product_count: int = 0
490 data_source_count: int = 0
491 tactic_count: int = 0
492 last_refresh: Optional[datetime] = None
493 # Wazuh-side counts — mirror of the wazuh_rules_cache state. Optional /
494 # defaulted so deployments that have never loaded the cache (or hit an
495 # outage on first load) still get a valid response shape.
496 wazuh_rule_count: int = 0
497 wazuh_last_refresh: Optional[datetime] = None
498 wazuh_available: bool = True
499 wazuh_unavailable_reason: Optional[str] = None
500
501
502 # ---------------------------------------------------------------------------
503 # Wazuh Rules tab — index row + list envelope + detail
504 # ---------------------------------------------------------------------------
505
506
507 class CatalogWazuhRuleRow(BaseModel):
508 """
509 One row in the Wazuh Rules index table. Mirror of the projection in
510 ``detection_catalog._wazuh_row``; keeps the OpenAPI doc honest.
511 """
512
513 id: Optional[int] = None
514 level: Optional[int] = None
515 status: Optional[str] = None
516 description: str = ""
517 filename: str = ""
518 relative_dirname: str = ""
519 groups: list[str] = Field(default_factory=list)
520 mitre: list[str] = Field(default_factory=list, description="MITRE ATT&CK technique IDs declared on the rule")
521 # Firing counts from the indexer. Always 0 when the firing-stats cache
522 # is unavailable — readers should check the envelope's
523 # ``firing_stats_available`` flag before treating 0 as "no hits".
524 hits_7d: int = 0
525 hits_30d: int = 0
526 # ISO timestamp of the most recent hit in the 30d window, or None when
527 # the rule hasn't fired / stats are unavailable. The UI renders this as
528 # relative time ("2 minutes ago").
529 last_seen: Optional[str] = None
530
531
532 class CatalogWazuhRulesResponse(BaseModel):
533 """
534 Envelope for the full Wazuh rules list. ``available=False`` signals the
535 Wazuh Manager is unreachable / unconfigured — the UI should render an
536 inline empty state with ``unavailable_reason`` instead of erroring.
537
538 Firing-stats availability is reported separately because the Manager and
539 the Indexer are different services that can fail independently — you
540 could have rules loaded but no hit counts, or vice versa.
541 """
542
543 success: bool = True
544 message: str = "Wazuh rules listed successfully"
545 rules: list[CatalogWazuhRuleRow] = Field(default_factory=list)
546 total: int = 0
547 available: bool = True
548 unavailable_reason: Optional[str] = None
549 last_refresh: Optional[datetime] = None
550 # Indexer-side availability for the firing-stats column.
551 firing_stats_available: bool = True
552 firing_stats_unavailable_reason: Optional[str] = None
553 firing_stats_last_refresh: Optional[datetime] = None
554 # Echoes the customer scope: empty string for the global view, customer
555 # code when scoped. Lets the UI display "Showing hits for customer X"
556 # and confirm the right slice was returned.
557 customer_code: str = ""
558
559
560 class CatalogWazuhRuleCompliance(BaseModel):
561 """
562 Compliance-framework arrays grouped under one nested object so the UI can
563 iterate frameworks without hard-coding the list.
564 """
565
566 pci_dss: list[str] = Field(default_factory=list)
567 gdpr: list[str] = Field(default_factory=list)
568 hipaa: list[str] = Field(default_factory=list)
569 nist_800_53: list[str] = Field(default_factory=list)
570 tsc: list[str] = Field(default_factory=list)
571 gpg13: list[str] = Field(default_factory=list)
572
573
574 class CatalogWazuhRuleDetailResponse(BaseModel):
575 """Full meta payload for a single Wazuh rule — drives the detail modal."""
576
577 success: bool = True
578 message: str = "Wazuh rule detail retrieved successfully"
579 id: Optional[int] = None
580 level: Optional[int] = None
581 status: Optional[str] = None
582 description: str = ""
583 filename: str = ""
584 relative_dirname: str = ""
585 groups: list[str] = Field(default_factory=list)
586 mitre: list[str] = Field(default_factory=list)
587 tactics: list[str] = Field(default_factory=list, description="MITRE tactic display names resolved via mitre_matrix")
588 compliance: CatalogWazuhRuleCompliance = Field(default_factory=CatalogWazuhRuleCompliance)
589 # ``details`` is intentionally typed as a free-form dict: the keys Wazuh
590 # emits (if_sid, match, regex, decoded_as, info, group, …) vary per rule
591 # and we don't want to silently drop unknown ones. The frontend iterates
592 # whatever keys arrive.
593 details: dict = Field(default_factory=dict)
594 # Reconstructed ``<rule>...</rule>`` XML block, synthesized server-side
595 # from the cached fields above (no second Wazuh API call). Rendered by
596 # the modal as a code snippet so analysts can read the rule "as written".
597 source_xml: str = ""
598 # Firing counts pulled from the Wazuh indexer aggregation cache.
599 hits_7d: int = 0
600 hits_30d: int = 0
601 last_seen: Optional[str] = None
602 firing_stats_available: bool = True
603 firing_stats_unavailable_reason: Optional[str] = None
604
605
606 # ---------------------------------------------------------------------------
607 # Coverage Gaps — MITRE techniques not covered by either rule corpus
608 # ---------------------------------------------------------------------------
609
610
611 class CatalogCoverageGapRow(BaseModel):
612 """One uncovered MITRE technique."""
613
614 technique_id: str
615 technique_name: str
616 tactics: list[str] = Field(default_factory=list)
617 url: Optional[str] = None
618
619
620 class CatalogCoverageGapsResponse(BaseModel):
621 """Envelope for the Coverage Gaps tab — gaps + coverage summary numbers."""
622
623 success: bool = True
624 message: str = "Coverage gaps computed successfully"
625 gaps: list[CatalogCoverageGapRow] = Field(default_factory=list)
626 gap_count: int = 0
627 covered_count: int = 0
628 total_techniques: int = 0
629 coverage_pct: float = 0.0
630
631
632 # ---------------------------------------------------------------------------
633 # Compliance pivot — Wazuh rules grouped by framework control ID
634 # ---------------------------------------------------------------------------
635
636
637 class CatalogComplianceFramework(BaseModel):
638 """One row in the framework selector dropdown."""
639
640 key: str # API/URL value: pci_dss, hipaa, etc.
641 label: str # Human-facing: "PCI DSS", "HIPAA", etc.
642
643
644 class CatalogComplianceFrameworksResponse(BaseModel):
645 success: bool = True
646 message: str = "Frameworks listed successfully"
647 frameworks: list[CatalogComplianceFramework] = Field(default_factory=list)
648
649
650 class CatalogComplianceGroupRow(BaseModel):
651 """One control bucket: e.g. PCI DSS 10.2.4 → 23 rules, 487 hits in 30d."""
652
653 control: str # The control identifier itself, e.g. "10.2.4"
654 rule_count: int = 0
655 rule_ids: list[int] = Field(default_factory=list)
656 total_hits_30d: int = 0
657 total_hits_7d: int = 0
658
659
660 class CatalogComplianceResponse(BaseModel):
661 """Compliance pivot for a single framework."""
662
663 success: bool = True
664 message: str = "Compliance pivot computed successfully"
665 framework: str # echoes the request
666 framework_label: str
667 groups: list[CatalogComplianceGroupRow] = Field(default_factory=list)
668 control_count: int = 0
669 rules_with_compliance: int = 0 # rules carrying ≥1 control value for this framework
670 total_rules: int = 0 # total Wazuh rules in the cache (for "%" math)
671 firing_stats_available: bool = True
672
673
674 # ---------------------------------------------------------------------------
675 # Logtest — "which rule would match this log line?"
676 # ---------------------------------------------------------------------------
677
678
679 class CatalogLogTestRequest(BaseModel):
680 """Inputs for ``POST /catalog/wazuh-rules/test``."""
681
682 event: str = Field(..., description="Raw log line to evaluate (single line)")
683 log_format: str = Field(
684 default="syslog",
685 description="Wazuh log_format. Common: syslog, json, snort-full, squid, apache, iis",
686 )
687 location: str = Field(
688 default="logtest",
689 description="Pseudo-source label Wazuh records on the test. Keep generic to avoid location-conditional rule matches.",
690 )
691
692
693 class CatalogLogTestRuleSummary(BaseModel):
694 """The matched rule's summary, normalized from Wazuh's logtest output."""
695
696 id: Optional[int] = None
697 level: Optional[int] = None
698 description: str = ""
699 groups: list[str] = Field(default_factory=list)
700 mitre: list[str] = Field(default_factory=list)
701 pci_dss: list[str] = Field(default_factory=list)
702 gdpr: list[str] = Field(default_factory=list)
703 hipaa: list[str] = Field(default_factory=list)
704 nist_800_53: list[str] = Field(default_factory=list)
705 firedtimes: Optional[int] = None
706
707
708 class CatalogLogTestResponse(BaseModel):
709 """
710 Result envelope for a logtest run.
711
712 ``matched=False`` is a valid, successful outcome — it means Wazuh's
713 decoder/rule chain saw the event but no analyst-facing rule fired.
714 ``unavailable_reason`` is only populated when the logtest call itself
715 failed (Wazuh unreachable, invalid input, etc.).
716 """
717
718 success: bool = True
719 message: str = "Logtest executed"
720 matched: bool = False
721 rule: Optional[CatalogLogTestRuleSummary] = None
722 # Resolved tactic display names from the mitre_matrix — saves the UI
723 # from doing a second resolution pass on the frontend.
724 tactics: list[str] = Field(default_factory=list)
725 # Full Wazuh alert envelope (decoder, predecoder, data, full_log, …).
726 # Free-form dict because Wazuh's shape varies per decoder type.
727 alert: Optional[dict] = None
728 unavailable_reason: Optional[str] = None