610 lines
22 KiB
Python
610 lines
22 KiB
Python
"""Phase 13: Operational Alert service — rule evaluation engine + CRUD."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.domain.errors import EntityNotFoundError, BusinessRuleViolation
|
|
from app.models.operational_alert import (
|
|
AlertInstance, AlertRule, AlertRuleType, AlertSeverity, AlertStatus,
|
|
)
|
|
from app.models.technique import Technique
|
|
from app.models.risk_intelligence import TechniqueRiskProfile
|
|
from app.models.ownership_queue import RevalidationQueueItem, QueueStatus
|
|
from app.models.ownership_queue import TechniqueOwnership
|
|
from app.models.executive_dashboard import PostureSnapshot
|
|
from app.models.enums import TechniqueStatus
|
|
from app.models.user import User
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Notification & webhook dispatch helpers ───────────────────────────────────
|
|
|
|
def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertInstance) -> None:
|
|
"""Create in-app Notification rows for all admins and leads."""
|
|
from app.services.notification_service import create_notification
|
|
|
|
admin_roles = {"admin", "red_lead", "blue_lead"}
|
|
users = db.query(User).filter(
|
|
User.role.in_(admin_roles),
|
|
User.is_active == True, # noqa: E712
|
|
).all()
|
|
for user in users:
|
|
create_notification(
|
|
db,
|
|
user_id = user.id,
|
|
type = "alert_fired",
|
|
title = instance.title,
|
|
message = instance.message,
|
|
entity_type = "alert_instance",
|
|
entity_id = instance.id,
|
|
)
|
|
|
|
|
|
def _dispatch_webhooks(rule: AlertRule, instance: AlertInstance) -> None:
|
|
"""Fire webhook(s) for a triggered alert (all exceptions caught)."""
|
|
from app.services.webhook_service import dispatch_webhook, dispatch_webhook_targeted
|
|
|
|
payload = {
|
|
"alert_id": str(instance.id),
|
|
"rule_id": str(rule.id) if rule.id else None,
|
|
"rule_name": instance.rule_name,
|
|
"rule_type": instance.rule_type,
|
|
"severity": instance.severity,
|
|
"title": instance.title,
|
|
"message": instance.message,
|
|
"details": instance.details,
|
|
}
|
|
|
|
# 1. Targeted webhook configured on the rule
|
|
if rule.notify_webhook and rule.webhook_id:
|
|
dispatch_webhook_targeted(str(rule.webhook_id), "alert.fired", payload)
|
|
|
|
# 2. Broadcast to all global "alert.fired" subscribers
|
|
dispatch_webhook("alert.fired", payload)
|
|
|
|
|
|
# ── Pre-configured system rules (seeded at startup) ───────────────────────────
|
|
|
|
SYSTEM_RULES = [
|
|
{
|
|
"name": "Critical Risk Techniques",
|
|
"description": "Fires when 3 or more techniques reach critical risk level (score ≥ 75).",
|
|
"rule_type": AlertRuleType.high_risk.value,
|
|
"severity": AlertSeverity.critical.value,
|
|
"is_system": True,
|
|
"config": {"min_risk_score": 75.0, "min_count": 3},
|
|
"cooldown_hours": 24,
|
|
},
|
|
{
|
|
"name": "High-Risk Technique Spike",
|
|
"description": "Fires when 10 or more techniques reach high risk (score ≥ 50).",
|
|
"rule_type": AlertRuleType.high_risk.value,
|
|
"severity": AlertSeverity.high.value,
|
|
"is_system": True,
|
|
"config": {"min_risk_score": 50.0, "min_count": 10},
|
|
"cooldown_hours": 24,
|
|
},
|
|
{
|
|
"name": "Stale Technique Detection",
|
|
"description": "Fires when 5+ validated techniques have not been reviewed in 30+ days.",
|
|
"rule_type": AlertRuleType.stale_technique.value,
|
|
"severity": AlertSeverity.medium.value,
|
|
"is_system": True,
|
|
"config": {"days_stale": 30, "min_count": 5},
|
|
"cooldown_hours": 48,
|
|
},
|
|
{
|
|
"name": "Coverage Regression",
|
|
"description": "Fires when coverage drops by 5 or more percentage points between daily snapshots.",
|
|
"rule_type": AlertRuleType.coverage_regression.value,
|
|
"severity": AlertSeverity.high.value,
|
|
"is_system": True,
|
|
"config": {"min_drop_pct": 5.0},
|
|
"cooldown_hours": 12,
|
|
},
|
|
{
|
|
"name": "Low Coverage Warning",
|
|
"description": "Fires when overall coverage falls below 30%.",
|
|
"rule_type": AlertRuleType.low_coverage.value,
|
|
"severity": AlertSeverity.medium.value,
|
|
"is_system": True,
|
|
"config": {"max_coverage_pct": 30.0},
|
|
"cooldown_hours": 72,
|
|
},
|
|
{
|
|
"name": "Revalidation Queue Backlog",
|
|
"description": "Fires when 15+ techniques are waiting in the revalidation queue.",
|
|
"rule_type": AlertRuleType.expiry_wave.value,
|
|
"severity": AlertSeverity.medium.value,
|
|
"is_system": True,
|
|
"config": {"min_pending_count": 15},
|
|
"cooldown_hours": 24,
|
|
},
|
|
{
|
|
"name": "New MITRE Techniques Detected",
|
|
"description": "Fires when new ATT&CK techniques are added in the last 7 days.",
|
|
"rule_type": AlertRuleType.new_technique.value,
|
|
"severity": AlertSeverity.info.value,
|
|
"is_system": True,
|
|
"config": {"lookback_days": 7, "min_count": 1},
|
|
"cooldown_hours": 168, # once a week
|
|
},
|
|
{
|
|
"name": "Orphan Technique Spike",
|
|
"description": "Fires when 20+ techniques have no assigned owner.",
|
|
"rule_type": AlertRuleType.orphan_spike.value,
|
|
"severity": AlertSeverity.low.value,
|
|
"is_system": True,
|
|
"config": {"min_orphan_count": 20},
|
|
"cooldown_hours": 48,
|
|
},
|
|
]
|
|
|
|
|
|
def seed_system_rules(db: Session) -> int:
|
|
"""Ensure all system rules exist (idempotent). Returns count created."""
|
|
created = 0
|
|
for rule_def in SYSTEM_RULES:
|
|
exists = db.query(AlertRule).filter(
|
|
AlertRule.name == rule_def["name"],
|
|
AlertRule.is_system == True,
|
|
).first()
|
|
if not exists:
|
|
rule = AlertRule(**rule_def)
|
|
db.add(rule)
|
|
created += 1
|
|
if created:
|
|
db.commit()
|
|
return created
|
|
|
|
|
|
# ── Rule evaluators (one per AlertRuleType) ───────────────────────────────────
|
|
|
|
def _eval_high_risk(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
min_score = float(rule.config.get("min_risk_score", 75.0))
|
|
min_count = int(rule.config.get("min_count", 1))
|
|
|
|
profiles = db.query(TechniqueRiskProfile).filter(
|
|
TechniqueRiskProfile.risk_score >= min_score,
|
|
).all()
|
|
count = len(profiles)
|
|
if count < min_count:
|
|
return None
|
|
|
|
top = sorted(profiles, key=lambda p: p.risk_score, reverse=True)[:5]
|
|
return {
|
|
"title": f"{count} technique(s) with risk score ≥ {min_score:.0f}",
|
|
"message": (
|
|
f"{count} technique(s) have reached risk score ≥ {min_score:.0f}. "
|
|
f"Top: {', '.join(str(p.technique_id)[:8] + '…' for p in top[:3])}."
|
|
),
|
|
"details": {
|
|
"count": count,
|
|
"threshold": min_score,
|
|
"top_ids": [str(p.technique_id) for p in top],
|
|
"top_scores": [p.risk_score for p in top],
|
|
},
|
|
}
|
|
|
|
|
|
def _eval_stale_technique(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
days_stale = int(rule.config.get("days_stale", 30))
|
|
min_count = int(rule.config.get("min_count", 1))
|
|
cutoff = datetime.utcnow() - timedelta(days=days_stale)
|
|
|
|
stale = db.query(Technique).filter(
|
|
Technique.status_global == TechniqueStatus.validated,
|
|
Technique.last_review_date < cutoff,
|
|
).all()
|
|
count = len(stale)
|
|
if count < min_count:
|
|
return None
|
|
|
|
return {
|
|
"title": f"{count} validated technique(s) stale for {days_stale}+ days",
|
|
"message": (
|
|
f"{count} technique(s) have been validated but not reviewed in over "
|
|
f"{days_stale} days. Re-validate to maintain confidence."
|
|
),
|
|
"details": {
|
|
"count": count,
|
|
"days_stale": days_stale,
|
|
"example_ids": [str(t.id) for t in stale[:10]],
|
|
},
|
|
}
|
|
|
|
|
|
def _eval_coverage_regression(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
min_drop = float(rule.config.get("min_drop_pct", 5.0))
|
|
|
|
snaps = (
|
|
db.query(PostureSnapshot)
|
|
.order_by(PostureSnapshot.snapshot_date.desc())
|
|
.limit(2)
|
|
.all()
|
|
)
|
|
if len(snaps) < 2:
|
|
return None
|
|
|
|
latest, previous = snaps[0], snaps[1]
|
|
drop = previous.coverage_pct - latest.coverage_pct
|
|
if drop < min_drop:
|
|
return None
|
|
|
|
return {
|
|
"title": f"Coverage dropped {drop:.1f}% ({previous.coverage_pct:.1f}% → {latest.coverage_pct:.1f}%)",
|
|
"message": (
|
|
f"Overall coverage fell by {drop:.1f} percentage points "
|
|
f"between {previous.snapshot_date} and {latest.snapshot_date}. "
|
|
f"Investigate recent technique status changes."
|
|
),
|
|
"details": {
|
|
"previous_pct": previous.coverage_pct,
|
|
"current_pct": latest.coverage_pct,
|
|
"drop_pct": round(drop, 2),
|
|
"previous_date": str(previous.snapshot_date),
|
|
"current_date": str(latest.snapshot_date),
|
|
},
|
|
}
|
|
|
|
|
|
def _eval_low_coverage(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
max_pct = float(rule.config.get("max_coverage_pct", 30.0))
|
|
techniques = db.query(Technique).all()
|
|
total = len(techniques)
|
|
if total == 0:
|
|
return None
|
|
|
|
validated = sum(1 for t in techniques if t.status_global == TechniqueStatus.validated)
|
|
partial = sum(1 for t in techniques if t.status_global == TechniqueStatus.partial)
|
|
coverage = (validated + partial * 0.5) / total * 100.0
|
|
|
|
if coverage > max_pct:
|
|
return None
|
|
|
|
return {
|
|
"title": f"Coverage is critically low: {coverage:.1f}%",
|
|
"message": (
|
|
f"Current detection coverage is {coverage:.1f}%, below the minimum "
|
|
f"threshold of {max_pct:.0f}%. Prioritise coverage improvements."
|
|
),
|
|
"details": {
|
|
"coverage_pct": round(coverage, 2),
|
|
"threshold": max_pct,
|
|
"validated": validated,
|
|
"partial": partial,
|
|
"total": total,
|
|
},
|
|
}
|
|
|
|
|
|
def _eval_expiry_wave(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
min_pending = int(rule.config.get("min_pending_count", 15))
|
|
|
|
pending_count = db.query(RevalidationQueueItem).filter(
|
|
RevalidationQueueItem.status.in_([
|
|
QueueStatus.pending, QueueStatus.in_progress,
|
|
]),
|
|
).count()
|
|
|
|
if pending_count < min_pending:
|
|
return None
|
|
|
|
return {
|
|
"title": f"Revalidation queue backlog: {pending_count} items pending",
|
|
"message": (
|
|
f"{pending_count} technique(s) are waiting in the revalidation queue "
|
|
f"(threshold: {min_pending}). Assign analysts to clear the backlog."
|
|
),
|
|
"details": {
|
|
"pending_count": pending_count,
|
|
"threshold": min_pending,
|
|
},
|
|
}
|
|
|
|
|
|
def _eval_new_technique(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
lookback_days = int(rule.config.get("lookback_days", 7))
|
|
min_count = int(rule.config.get("min_count", 1))
|
|
cutoff = datetime.utcnow() - timedelta(days=lookback_days)
|
|
|
|
new_techs = db.query(Technique).filter(
|
|
Technique.mitre_last_modified >= cutoff,
|
|
).all()
|
|
count = len(new_techs)
|
|
if count < min_count:
|
|
return None
|
|
|
|
return {
|
|
"title": f"{count} new/updated MITRE technique(s) in last {lookback_days} days",
|
|
"message": (
|
|
f"{count} ATT&CK technique(s) have been added or updated in the last "
|
|
f"{lookback_days} days. Review and assign coverage."
|
|
),
|
|
"details": {
|
|
"count": count,
|
|
"lookback_days": lookback_days,
|
|
"technique_ids": [str(t.id) for t in new_techs[:20]],
|
|
"mitre_ids": [t.mitre_id for t in new_techs[:20]],
|
|
},
|
|
}
|
|
|
|
|
|
def _eval_orphan_spike(db: Session, rule: AlertRule) -> Optional[dict]:
|
|
min_orphans = int(rule.config.get("min_orphan_count", 20))
|
|
|
|
total = db.query(Technique).count()
|
|
owned = db.query(TechniqueOwnership).filter(
|
|
TechniqueOwnership.owner_id.isnot(None),
|
|
).count()
|
|
orphans = max(total - owned, 0)
|
|
|
|
if orphans < min_orphans:
|
|
return None
|
|
|
|
return {
|
|
"title": f"{orphans} unowned techniques detected",
|
|
"message": (
|
|
f"{orphans} out of {total} technique(s) have no assigned owner. "
|
|
f"Assign ownership to ensure accountability."
|
|
),
|
|
"details": {
|
|
"orphan_count": orphans,
|
|
"total": total,
|
|
"threshold": min_orphans,
|
|
},
|
|
}
|
|
|
|
|
|
_EVALUATORS = {
|
|
AlertRuleType.high_risk.value: _eval_high_risk,
|
|
AlertRuleType.stale_technique.value: _eval_stale_technique,
|
|
AlertRuleType.coverage_regression.value: _eval_coverage_regression,
|
|
AlertRuleType.low_coverage.value: _eval_low_coverage,
|
|
AlertRuleType.expiry_wave.value: _eval_expiry_wave,
|
|
AlertRuleType.new_technique.value: _eval_new_technique,
|
|
AlertRuleType.orphan_spike.value: _eval_orphan_spike,
|
|
}
|
|
|
|
|
|
# ── Core evaluation engine ────────────────────────────────────────────────────
|
|
|
|
def _in_cooldown(rule: AlertRule) -> bool:
|
|
if rule.last_fired_at is None:
|
|
return False
|
|
if rule.cooldown_hours <= 0:
|
|
return False
|
|
return datetime.utcnow() < rule.last_fired_at + timedelta(hours=rule.cooldown_hours)
|
|
|
|
|
|
def evaluate_all_rules(db: Session) -> dict:
|
|
"""Evaluate every enabled rule; create AlertInstances for those that fire.
|
|
|
|
After persisting each alert, dispatches:
|
|
- In-app notifications to all admins/leads (if rule.notify_in_app)
|
|
- Webhooks to the rule's targeted webhook + global "alert.fired" subscribers
|
|
(if rule.notify_webhook)
|
|
"""
|
|
t0 = time.monotonic()
|
|
rules = db.query(AlertRule).filter(AlertRule.is_enabled == True).all()
|
|
|
|
# (rule, instance) pairs so we can dispatch after commit
|
|
fired_pairs: List[tuple] = []
|
|
|
|
for rule in rules:
|
|
if _in_cooldown(rule):
|
|
continue
|
|
evaluator = _EVALUATORS.get(rule.rule_type)
|
|
if not evaluator:
|
|
continue
|
|
try:
|
|
result = evaluator(db, rule)
|
|
except Exception:
|
|
log.exception("Error evaluating rule %s (%s)", rule.id, rule.name)
|
|
continue
|
|
|
|
if result is None:
|
|
continue # condition not met
|
|
|
|
instance = AlertInstance(
|
|
rule_id = rule.id,
|
|
rule_name = rule.name,
|
|
rule_type = rule.rule_type,
|
|
severity = rule.severity,
|
|
title = result["title"],
|
|
message = result["message"],
|
|
details = result.get("details"),
|
|
status = AlertStatus.open.value,
|
|
)
|
|
db.add(instance)
|
|
rule.last_fired_at = datetime.utcnow()
|
|
fired_pairs.append((rule, instance))
|
|
|
|
# ── Persist alerts ────────────────────────────────────────────────────────
|
|
db.commit()
|
|
for _rule, inst in fired_pairs:
|
|
db.refresh(inst) # populate id + created_at from DB
|
|
|
|
# ── In-app notifications (need instance.id, so must be after refresh) ────
|
|
for rule, inst in fired_pairs:
|
|
if rule.notify_in_app:
|
|
try:
|
|
_dispatch_inapp_notifications(db, rule, inst)
|
|
except Exception:
|
|
log.exception("In-app notification failed for alert %s", inst.id)
|
|
if fired_pairs:
|
|
try:
|
|
db.commit() # commit notifications
|
|
except Exception:
|
|
log.exception("Failed to commit in-app notifications")
|
|
db.rollback()
|
|
|
|
# ── Webhooks (fire-and-forget, own sessions) ──────────────────────────────
|
|
for rule, inst in fired_pairs:
|
|
try:
|
|
_dispatch_webhooks(rule, inst)
|
|
except Exception:
|
|
log.exception("Webhook dispatch failed for alert %s", inst.id)
|
|
|
|
fired = [inst for _, inst in fired_pairs]
|
|
return {
|
|
"rules_evaluated": len(rules),
|
|
"alerts_fired": len(fired),
|
|
"alerts": fired,
|
|
"duration_seconds": round(time.monotonic() - t0, 3),
|
|
}
|
|
|
|
|
|
# ── AlertRule CRUD ────────────────────────────────────────────────────────────
|
|
|
|
def list_rules(
|
|
db: Session,
|
|
rule_type: Optional[str] = None,
|
|
include_disabled: bool = False,
|
|
) -> List[AlertRule]:
|
|
q = db.query(AlertRule)
|
|
if rule_type:
|
|
q = q.filter(AlertRule.rule_type == rule_type)
|
|
if not include_disabled:
|
|
q = q.filter(AlertRule.is_enabled == True)
|
|
return q.order_by(AlertRule.created_at.asc()).all()
|
|
|
|
|
|
def get_rule(db: Session, rule_id: UUID) -> AlertRule:
|
|
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
|
if not rule:
|
|
raise EntityNotFoundError("AlertRule", str(rule_id))
|
|
return rule
|
|
|
|
|
|
def create_rule(db: Session, created_by: UUID, **kwargs) -> AlertRule:
|
|
kwargs["is_system"] = False
|
|
kwargs["created_by"] = created_by
|
|
rule = AlertRule(**kwargs)
|
|
db.add(rule)
|
|
db.commit()
|
|
db.refresh(rule)
|
|
return rule
|
|
|
|
|
|
def update_rule(db: Session, rule_id: UUID, **kwargs) -> AlertRule:
|
|
rule = get_rule(db, rule_id)
|
|
for k, v in kwargs.items():
|
|
if v is not None:
|
|
setattr(rule, k, v)
|
|
db.commit()
|
|
db.refresh(rule)
|
|
return rule
|
|
|
|
|
|
def delete_rule(db: Session, rule_id: UUID) -> None:
|
|
rule = get_rule(db, rule_id)
|
|
if rule.is_system:
|
|
raise BusinessRuleViolation("System rules cannot be deleted. Disable them instead.")
|
|
db.delete(rule)
|
|
db.commit()
|
|
|
|
|
|
# ── AlertInstance CRUD ────────────────────────────────────────────────────────
|
|
|
|
def list_instances(
|
|
db: Session,
|
|
status: Optional[str] = None,
|
|
severity: Optional[str] = None,
|
|
rule_type: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> List[AlertInstance]:
|
|
q = db.query(AlertInstance)
|
|
if status:
|
|
q = q.filter(AlertInstance.status == status)
|
|
if severity:
|
|
q = q.filter(AlertInstance.severity == severity)
|
|
if rule_type:
|
|
q = q.filter(AlertInstance.rule_type == rule_type)
|
|
return q.order_by(AlertInstance.created_at.desc()).offset(offset).limit(limit).all()
|
|
|
|
|
|
def get_instance(db: Session, instance_id: UUID) -> AlertInstance:
|
|
inst = db.query(AlertInstance).filter(AlertInstance.id == instance_id).first()
|
|
if not inst:
|
|
raise EntityNotFoundError("AlertInstance", str(instance_id))
|
|
return inst
|
|
|
|
|
|
def _transition(
|
|
db: Session,
|
|
instance_id: UUID,
|
|
new_status: str,
|
|
user_id: Optional[UUID] = None,
|
|
) -> AlertInstance:
|
|
inst = get_instance(db, instance_id)
|
|
inst.status = new_status
|
|
if new_status == AlertStatus.acknowledged.value:
|
|
inst.acknowledged_by = user_id
|
|
inst.acknowledged_at = datetime.utcnow()
|
|
elif new_status == AlertStatus.resolved.value:
|
|
inst.resolved_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(inst)
|
|
return inst
|
|
|
|
|
|
def acknowledge(db: Session, instance_id: UUID, user_id: UUID) -> AlertInstance:
|
|
inst = get_instance(db, instance_id)
|
|
if inst.status != AlertStatus.open.value:
|
|
raise BusinessRuleViolation(f"Cannot acknowledge alert in status '{inst.status}'.")
|
|
return _transition(db, instance_id, AlertStatus.acknowledged.value, user_id)
|
|
|
|
|
|
def resolve(db: Session, instance_id: UUID, user_id: UUID) -> AlertInstance:
|
|
inst = get_instance(db, instance_id)
|
|
if inst.status == AlertStatus.resolved.value:
|
|
raise BusinessRuleViolation("Alert is already resolved.")
|
|
return _transition(db, instance_id, AlertStatus.resolved.value, user_id)
|
|
|
|
|
|
def dismiss(db: Session, instance_id: UUID, user_id: UUID) -> AlertInstance:
|
|
inst = get_instance(db, instance_id)
|
|
if inst.status in (AlertStatus.resolved.value, AlertStatus.dismissed.value):
|
|
raise BusinessRuleViolation(f"Cannot dismiss alert in status '{inst.status}'.")
|
|
return _transition(db, instance_id, AlertStatus.dismissed.value, user_id)
|
|
|
|
|
|
def get_summary(db: Session) -> dict:
|
|
instances = db.query(AlertInstance).all()
|
|
|
|
by_status = {s.value: 0 for s in AlertStatus}
|
|
by_severity = {s.value: 0 for s in AlertSeverity}
|
|
by_type = {}
|
|
|
|
for i in instances:
|
|
by_status[i.status] = by_status.get(i.status, 0) + 1
|
|
by_severity[i.severity] = by_severity.get(i.severity, 0) + 1
|
|
by_type[i.rule_type] = by_type.get(i.rule_type, 0) + 1
|
|
|
|
recent = (
|
|
db.query(AlertInstance)
|
|
.filter(AlertInstance.status == AlertStatus.open.value)
|
|
.order_by(AlertInstance.created_at.desc())
|
|
.limit(5)
|
|
.all()
|
|
)
|
|
|
|
return {
|
|
"total_open": by_status.get(AlertStatus.open.value, 0),
|
|
"total_acknowledged": by_status.get(AlertStatus.acknowledged.value, 0),
|
|
"total_resolved": by_status.get(AlertStatus.resolved.value, 0),
|
|
"by_severity": by_severity,
|
|
"by_rule_type": by_type,
|
|
"recent_alerts": recent,
|
|
}
|