pylitmus 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylitmus/__init__.py +76 -0
- pylitmus/conditions/__init__.py +15 -0
- pylitmus/conditions/base.py +53 -0
- pylitmus/conditions/builder.py +92 -0
- pylitmus/conditions/composite.py +52 -0
- pylitmus/conditions/simple.py +62 -0
- pylitmus/engine.py +244 -0
- pylitmus/evaluators/__init__.py +11 -0
- pylitmus/evaluators/base.py +27 -0
- pylitmus/evaluators/factory.py +372 -0
- pylitmus/exceptions.py +39 -0
- pylitmus/factory.py +179 -0
- pylitmus/integrations/__init__.py +3 -0
- pylitmus/integrations/flask/__init__.py +10 -0
- pylitmus/integrations/flask/extension.py +234 -0
- pylitmus/patterns/__init__.py +21 -0
- pylitmus/patterns/base.py +25 -0
- pylitmus/patterns/engine.py +82 -0
- pylitmus/patterns/exact.py +34 -0
- pylitmus/patterns/fuzzy.py +69 -0
- pylitmus/patterns/glob.py +38 -0
- pylitmus/patterns/range.py +53 -0
- pylitmus/patterns/regex.py +51 -0
- pylitmus/storage/__init__.py +17 -0
- pylitmus/storage/base.py +78 -0
- pylitmus/storage/cached.py +167 -0
- pylitmus/storage/database.py +181 -0
- pylitmus/storage/file.py +143 -0
- pylitmus/storage/memory.py +107 -0
- pylitmus/strategies/__init__.py +15 -0
- pylitmus/strategies/base.py +25 -0
- pylitmus/strategies/max.py +26 -0
- pylitmus/strategies/sum.py +36 -0
- pylitmus/strategies/weighted.py +45 -0
- pylitmus/types.py +93 -0
- pylitmus-1.0.0.dist-info/METADATA +459 -0
- pylitmus-1.0.0.dist-info/RECORD +39 -0
- pylitmus-1.0.0.dist-info/WHEEL +4 -0
- pylitmus-1.0.0.dist-info/licenses/LICENSE +21 -0
pylitmus/storage/base.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base repository class for rule storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..types import Rule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuleRepository(ABC):
|
|
12
|
+
"""Abstract base for rule storage backends."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
16
|
+
"""
|
|
17
|
+
Get all rules, optionally filtered.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
filters: Optional filters to apply
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of rules
|
|
24
|
+
"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def get_by_code(self, code: str) -> Optional[Rule]:
|
|
29
|
+
"""
|
|
30
|
+
Get a specific rule by code.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
code: Rule code
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Rule if found, None otherwise
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def save(self, rule: Rule) -> Rule:
|
|
42
|
+
"""
|
|
43
|
+
Save a rule (create or update).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
rule: Rule to save
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Saved rule
|
|
50
|
+
"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def delete(self, code: str) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
Delete a rule by code.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
code: Rule code
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if deleted, False otherwise
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def get_enabled(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
67
|
+
"""
|
|
68
|
+
Get only enabled rules.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
filters: Additional filters
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of enabled rules
|
|
75
|
+
"""
|
|
76
|
+
all_filters = filters.copy() if filters else {}
|
|
77
|
+
all_filters["enabled"] = True
|
|
78
|
+
return self.get_all(all_filters)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cached rule repository decorator implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from ..types import Rule
|
|
10
|
+
from .base import RuleRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CachedRuleRepository(RuleRepository):
|
|
14
|
+
"""Decorator that adds caching to any repository."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
repository: RuleRepository,
|
|
19
|
+
cache_backend: str = "memory",
|
|
20
|
+
cache_url: Optional[str] = None,
|
|
21
|
+
ttl_seconds: int = 300,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Initialize cached repository.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
repository: The underlying repository to cache
|
|
28
|
+
cache_backend: 'memory' or 'redis'
|
|
29
|
+
cache_url: Redis URL (for redis backend)
|
|
30
|
+
ttl_seconds: Cache TTL in seconds
|
|
31
|
+
"""
|
|
32
|
+
self._repository = repository
|
|
33
|
+
self._ttl = ttl_seconds
|
|
34
|
+
self._cache_backend = cache_backend
|
|
35
|
+
|
|
36
|
+
if cache_backend == "redis":
|
|
37
|
+
try:
|
|
38
|
+
import redis
|
|
39
|
+
|
|
40
|
+
self._cache = redis.from_url(cache_url or "redis://localhost:6379/0")
|
|
41
|
+
self._is_redis = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
raise ImportError("redis package required for redis cache backend")
|
|
44
|
+
else:
|
|
45
|
+
self._cache: Dict[str, Any] = {}
|
|
46
|
+
self._cache_expiry: Dict[str, float] = {}
|
|
47
|
+
self._is_redis = False
|
|
48
|
+
|
|
49
|
+
def _make_key(
|
|
50
|
+
self, operation: str, filters: Optional[Dict[str, Any]] = None
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Create cache key."""
|
|
53
|
+
key = f"rules:{operation}"
|
|
54
|
+
if filters:
|
|
55
|
+
key += f":{json.dumps(filters, sort_keys=True)}"
|
|
56
|
+
return key
|
|
57
|
+
|
|
58
|
+
def _get_cached(self, key: str) -> Optional[List[Rule]]:
|
|
59
|
+
"""Get value from cache."""
|
|
60
|
+
if self._is_redis:
|
|
61
|
+
data = self._cache.get(key)
|
|
62
|
+
if data:
|
|
63
|
+
return self._deserialize_rules(json.loads(data))
|
|
64
|
+
return None
|
|
65
|
+
else:
|
|
66
|
+
# Check expiry for memory cache
|
|
67
|
+
if key in self._cache:
|
|
68
|
+
if time.time() < self._cache_expiry.get(key, 0):
|
|
69
|
+
return self._cache[key]
|
|
70
|
+
else:
|
|
71
|
+
# Expired
|
|
72
|
+
del self._cache[key]
|
|
73
|
+
if key in self._cache_expiry:
|
|
74
|
+
del self._cache_expiry[key]
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _set_cached(self, key: str, rules: List[Rule]) -> None:
|
|
78
|
+
"""Set value in cache."""
|
|
79
|
+
if self._is_redis:
|
|
80
|
+
data = json.dumps(self._serialize_rules(rules))
|
|
81
|
+
self._cache.setex(key, self._ttl, data)
|
|
82
|
+
else:
|
|
83
|
+
self._cache[key] = rules
|
|
84
|
+
self._cache_expiry[key] = time.time() + self._ttl
|
|
85
|
+
|
|
86
|
+
def _serialize_rules(self, rules: List[Rule]) -> List[Dict[str, Any]]:
|
|
87
|
+
"""Serialize rules for caching."""
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
"code": r.code,
|
|
91
|
+
"name": r.name,
|
|
92
|
+
"description": r.description,
|
|
93
|
+
"category": r.category,
|
|
94
|
+
"severity": r.severity.value,
|
|
95
|
+
"score": r.score,
|
|
96
|
+
"enabled": r.enabled,
|
|
97
|
+
"conditions": r.conditions,
|
|
98
|
+
"version": r.version,
|
|
99
|
+
"metadata": r.metadata,
|
|
100
|
+
}
|
|
101
|
+
for r in rules
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def _deserialize_rules(self, data: List[Dict[str, Any]]) -> List[Rule]:
|
|
105
|
+
"""Deserialize rules from cache."""
|
|
106
|
+
from ..types import Severity
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
Rule(
|
|
110
|
+
code=r["code"],
|
|
111
|
+
name=r["name"],
|
|
112
|
+
description=r["description"],
|
|
113
|
+
category=r["category"],
|
|
114
|
+
severity=Severity(r["severity"]),
|
|
115
|
+
score=r["score"],
|
|
116
|
+
enabled=r["enabled"],
|
|
117
|
+
conditions=r["conditions"],
|
|
118
|
+
version=r["version"],
|
|
119
|
+
metadata=r["metadata"],
|
|
120
|
+
)
|
|
121
|
+
for r in data
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
125
|
+
"""Get all rules, with caching."""
|
|
126
|
+
cache_key = self._make_key("all", filters)
|
|
127
|
+
|
|
128
|
+
cached = self._get_cached(cache_key)
|
|
129
|
+
if cached is not None:
|
|
130
|
+
return cached
|
|
131
|
+
|
|
132
|
+
rules = self._repository.get_all(filters)
|
|
133
|
+
self._set_cached(cache_key, rules)
|
|
134
|
+
return rules
|
|
135
|
+
|
|
136
|
+
def get_by_code(self, code: str) -> Optional[Rule]:
|
|
137
|
+
"""Get a specific rule by code (not cached, delegates to repository)."""
|
|
138
|
+
return self._repository.get_by_code(code)
|
|
139
|
+
|
|
140
|
+
def save(self, rule: Rule) -> Rule:
|
|
141
|
+
"""Save a rule and invalidate cache."""
|
|
142
|
+
result = self._repository.save(rule)
|
|
143
|
+
self.invalidate()
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
def delete(self, code: str) -> bool:
|
|
147
|
+
"""Delete a rule and invalidate cache."""
|
|
148
|
+
result = self._repository.delete(code)
|
|
149
|
+
if result:
|
|
150
|
+
self.invalidate()
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
def invalidate(self) -> None:
|
|
154
|
+
"""Clear the cache."""
|
|
155
|
+
if self._is_redis:
|
|
156
|
+
# Scan and delete all rules:* keys
|
|
157
|
+
for key in self._cache.scan_iter(match="rules:*"):
|
|
158
|
+
self._cache.delete(key)
|
|
159
|
+
else:
|
|
160
|
+
self._cache.clear()
|
|
161
|
+
self._cache_expiry.clear()
|
|
162
|
+
|
|
163
|
+
def get_enabled(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
164
|
+
"""Get only enabled rules, with caching."""
|
|
165
|
+
all_filters = filters.copy() if filters else {}
|
|
166
|
+
all_filters["enabled"] = True
|
|
167
|
+
return self.get_all(all_filters)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database rule repository implementation using SQLAlchemy.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Type
|
|
6
|
+
|
|
7
|
+
from ..exceptions import StorageError
|
|
8
|
+
from ..types import Rule, Severity
|
|
9
|
+
from .base import RuleRepository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseRuleRepository(RuleRepository):
|
|
13
|
+
"""SQLAlchemy-based rule storage."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
session_factory: Callable,
|
|
18
|
+
model_class: Optional[Type] = None,
|
|
19
|
+
table_name: str = "rules",
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Initialize database repository.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
session_factory: Callable that returns a database session
|
|
26
|
+
model_class: SQLAlchemy model class for rules (optional)
|
|
27
|
+
table_name: Name of rules table (if not using model_class)
|
|
28
|
+
"""
|
|
29
|
+
self.session_factory = session_factory
|
|
30
|
+
self.model_class = model_class
|
|
31
|
+
self.table_name = table_name
|
|
32
|
+
|
|
33
|
+
def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
34
|
+
"""Get all rules, optionally filtered."""
|
|
35
|
+
try:
|
|
36
|
+
with self.session_factory() as session:
|
|
37
|
+
if self.model_class:
|
|
38
|
+
query = session.query(self.model_class)
|
|
39
|
+
if filters:
|
|
40
|
+
query = self._apply_model_filters(query, filters)
|
|
41
|
+
return [self._model_to_rule(m) for m in query.all()]
|
|
42
|
+
else:
|
|
43
|
+
# Raw SQL fallback
|
|
44
|
+
return self._get_all_raw(session, filters)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
raise StorageError(f"Failed to get rules from database: {e}") from e
|
|
47
|
+
|
|
48
|
+
def get_by_code(self, code: str) -> Optional[Rule]:
|
|
49
|
+
"""Get a specific rule by code."""
|
|
50
|
+
try:
|
|
51
|
+
with self.session_factory() as session:
|
|
52
|
+
if self.model_class:
|
|
53
|
+
model = session.query(self.model_class).filter_by(code=code).first()
|
|
54
|
+
return self._model_to_rule(model) if model else None
|
|
55
|
+
else:
|
|
56
|
+
return self._get_by_code_raw(session, code)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise StorageError(f"Failed to get rule {code} from database: {e}") from e
|
|
59
|
+
|
|
60
|
+
def save(self, rule: Rule) -> Rule:
|
|
61
|
+
"""Save a rule (create or update)."""
|
|
62
|
+
try:
|
|
63
|
+
with self.session_factory() as session:
|
|
64
|
+
if self.model_class:
|
|
65
|
+
existing = (
|
|
66
|
+
session.query(self.model_class)
|
|
67
|
+
.filter_by(code=rule.code)
|
|
68
|
+
.first()
|
|
69
|
+
)
|
|
70
|
+
if existing:
|
|
71
|
+
self._update_model(existing, rule)
|
|
72
|
+
else:
|
|
73
|
+
model = self._rule_to_model(rule)
|
|
74
|
+
session.add(model)
|
|
75
|
+
session.commit()
|
|
76
|
+
else:
|
|
77
|
+
self._save_raw(session, rule)
|
|
78
|
+
return rule
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise StorageError(
|
|
81
|
+
f"Failed to save rule {rule.code} to database: {e}"
|
|
82
|
+
) from e
|
|
83
|
+
|
|
84
|
+
def delete(self, code: str) -> bool:
|
|
85
|
+
"""Delete a rule by code."""
|
|
86
|
+
try:
|
|
87
|
+
with self.session_factory() as session:
|
|
88
|
+
if self.model_class:
|
|
89
|
+
model = session.query(self.model_class).filter_by(code=code).first()
|
|
90
|
+
if model:
|
|
91
|
+
session.delete(model)
|
|
92
|
+
session.commit()
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
else:
|
|
96
|
+
return self._delete_raw(session, code)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
raise StorageError(
|
|
99
|
+
f"Failed to delete rule {code} from database: {e}"
|
|
100
|
+
) from e
|
|
101
|
+
|
|
102
|
+
def _model_to_rule(self, model: Any) -> Rule:
|
|
103
|
+
"""Convert SQLAlchemy model to Rule object."""
|
|
104
|
+
severity_value = model.severity
|
|
105
|
+
if isinstance(severity_value, str):
|
|
106
|
+
severity = Severity(severity_value.upper())
|
|
107
|
+
else:
|
|
108
|
+
severity = severity_value
|
|
109
|
+
|
|
110
|
+
return Rule(
|
|
111
|
+
code=model.code,
|
|
112
|
+
name=model.name,
|
|
113
|
+
description=getattr(model, "description", ""),
|
|
114
|
+
category=getattr(model, "category", "DEFAULT"),
|
|
115
|
+
severity=severity,
|
|
116
|
+
score=getattr(model, "score", 0),
|
|
117
|
+
enabled=getattr(model, "enabled", True),
|
|
118
|
+
conditions=getattr(model, "conditions", {}),
|
|
119
|
+
version=getattr(model, "version", 1),
|
|
120
|
+
metadata=getattr(model, "metadata", {}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _rule_to_model(self, rule: Rule) -> Any:
|
|
124
|
+
"""Convert Rule to SQLAlchemy model."""
|
|
125
|
+
if not self.model_class:
|
|
126
|
+
raise StorageError("No model class configured")
|
|
127
|
+
|
|
128
|
+
return self.model_class(
|
|
129
|
+
code=rule.code,
|
|
130
|
+
name=rule.name,
|
|
131
|
+
description=rule.description,
|
|
132
|
+
category=rule.category,
|
|
133
|
+
severity=rule.severity.value,
|
|
134
|
+
score=rule.score,
|
|
135
|
+
enabled=rule.enabled,
|
|
136
|
+
conditions=rule.conditions,
|
|
137
|
+
version=rule.version,
|
|
138
|
+
metadata=rule.metadata,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _update_model(self, model: Any, rule: Rule) -> None:
|
|
142
|
+
"""Update model from Rule."""
|
|
143
|
+
model.name = rule.name
|
|
144
|
+
model.description = rule.description
|
|
145
|
+
model.category = rule.category
|
|
146
|
+
model.severity = rule.severity.value
|
|
147
|
+
model.score = rule.score
|
|
148
|
+
model.enabled = rule.enabled
|
|
149
|
+
model.conditions = rule.conditions
|
|
150
|
+
model.version = rule.version
|
|
151
|
+
model.metadata = rule.metadata
|
|
152
|
+
|
|
153
|
+
def _apply_model_filters(self, query: Any, filters: Dict[str, Any]) -> Any:
|
|
154
|
+
"""Apply filters to SQLAlchemy query."""
|
|
155
|
+
if "enabled" in filters:
|
|
156
|
+
query = query.filter(self.model_class.enabled == filters["enabled"])
|
|
157
|
+
if "category" in filters:
|
|
158
|
+
query = query.filter(self.model_class.category == filters["category"])
|
|
159
|
+
if "severity" in filters:
|
|
160
|
+
query = query.filter(self.model_class.severity == filters["severity"].value)
|
|
161
|
+
return query
|
|
162
|
+
|
|
163
|
+
# Raw SQL fallback methods (for when no model class is provided)
|
|
164
|
+
def _get_all_raw(
|
|
165
|
+
self, session: Any, filters: Optional[Dict[str, Any]]
|
|
166
|
+
) -> List[Rule]:
|
|
167
|
+
"""Get all rules using raw SQL."""
|
|
168
|
+
# This is a placeholder - actual implementation depends on DB setup
|
|
169
|
+
raise NotImplementedError("Raw SQL queries require model_class to be specified")
|
|
170
|
+
|
|
171
|
+
def _get_by_code_raw(self, session: Any, code: str) -> Optional[Rule]:
|
|
172
|
+
"""Get rule by code using raw SQL."""
|
|
173
|
+
raise NotImplementedError("Raw SQL queries require model_class to be specified")
|
|
174
|
+
|
|
175
|
+
def _save_raw(self, session: Any, rule: Rule) -> None:
|
|
176
|
+
"""Save rule using raw SQL."""
|
|
177
|
+
raise NotImplementedError("Raw SQL queries require model_class to be specified")
|
|
178
|
+
|
|
179
|
+
def _delete_raw(self, session: Any, code: str) -> bool:
|
|
180
|
+
"""Delete rule using raw SQL."""
|
|
181
|
+
raise NotImplementedError("Raw SQL queries require model_class to be specified")
|
pylitmus/storage/file.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File-based rule repository implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from ..exceptions import StorageError
|
|
12
|
+
from ..types import Rule, Severity
|
|
13
|
+
from .base import RuleRepository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileRuleRepository(RuleRepository):
|
|
17
|
+
"""YAML/JSON file-based rule storage."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, file_path: str):
|
|
20
|
+
"""
|
|
21
|
+
Initialize file repository.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
file_path: Path to rules file (YAML or JSON)
|
|
25
|
+
"""
|
|
26
|
+
self.file_path = Path(file_path)
|
|
27
|
+
self._rules: Dict[str, Rule] = {}
|
|
28
|
+
self._load_rules()
|
|
29
|
+
|
|
30
|
+
def _load_rules(self) -> None:
|
|
31
|
+
"""Load rules from file."""
|
|
32
|
+
if not self.file_path.exists():
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with open(self.file_path) as f:
|
|
37
|
+
if self.file_path.suffix in [".yaml", ".yml"]:
|
|
38
|
+
data = yaml.safe_load(f)
|
|
39
|
+
else:
|
|
40
|
+
data = json.load(f)
|
|
41
|
+
|
|
42
|
+
if data and "rules" in data:
|
|
43
|
+
for rule_data in data["rules"]:
|
|
44
|
+
rule = self._dict_to_rule(rule_data)
|
|
45
|
+
self._rules[rule.code] = rule
|
|
46
|
+
except Exception as e:
|
|
47
|
+
raise StorageError(
|
|
48
|
+
f"Failed to load rules from {self.file_path}: {e}"
|
|
49
|
+
) from e
|
|
50
|
+
|
|
51
|
+
def _dict_to_rule(self, data: Dict[str, Any]) -> Rule:
|
|
52
|
+
"""Convert dictionary to Rule object."""
|
|
53
|
+
severity_str = data.get("severity", "MEDIUM")
|
|
54
|
+
if isinstance(severity_str, str):
|
|
55
|
+
severity = Severity(severity_str.upper())
|
|
56
|
+
else:
|
|
57
|
+
severity = severity_str
|
|
58
|
+
|
|
59
|
+
return Rule(
|
|
60
|
+
code=data["code"],
|
|
61
|
+
name=data["name"],
|
|
62
|
+
description=data.get("description", ""),
|
|
63
|
+
category=data.get("category", "DEFAULT"),
|
|
64
|
+
severity=severity,
|
|
65
|
+
score=data.get("score", 0),
|
|
66
|
+
enabled=data.get("enabled", True),
|
|
67
|
+
conditions=data.get("conditions", {}),
|
|
68
|
+
version=data.get("version", 1),
|
|
69
|
+
metadata=data.get("metadata", {}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _rule_to_dict(self, rule: Rule) -> Dict[str, Any]:
|
|
73
|
+
"""Convert Rule object to dictionary."""
|
|
74
|
+
return {
|
|
75
|
+
"code": rule.code,
|
|
76
|
+
"name": rule.name,
|
|
77
|
+
"description": rule.description,
|
|
78
|
+
"category": rule.category,
|
|
79
|
+
"severity": rule.severity.value,
|
|
80
|
+
"score": rule.score,
|
|
81
|
+
"enabled": rule.enabled,
|
|
82
|
+
"conditions": rule.conditions,
|
|
83
|
+
"version": rule.version,
|
|
84
|
+
"metadata": rule.metadata,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
88
|
+
"""Get all rules, optionally filtered."""
|
|
89
|
+
rules = list(self._rules.values())
|
|
90
|
+
if filters:
|
|
91
|
+
rules = self._apply_filters(rules, filters)
|
|
92
|
+
return rules
|
|
93
|
+
|
|
94
|
+
def get_by_code(self, code: str) -> Optional[Rule]:
|
|
95
|
+
"""Get a specific rule by code."""
|
|
96
|
+
return self._rules.get(code)
|
|
97
|
+
|
|
98
|
+
def save(self, rule: Rule) -> Rule:
|
|
99
|
+
"""Save a rule (updates in-memory, writes to file)."""
|
|
100
|
+
self._rules[rule.code] = rule
|
|
101
|
+
self._save_to_file()
|
|
102
|
+
return rule
|
|
103
|
+
|
|
104
|
+
def delete(self, code: str) -> bool:
|
|
105
|
+
"""Delete a rule by code."""
|
|
106
|
+
if code in self._rules:
|
|
107
|
+
del self._rules[code]
|
|
108
|
+
self._save_to_file()
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def _save_to_file(self) -> None:
|
|
113
|
+
"""Save rules to file."""
|
|
114
|
+
data = {"rules": [self._rule_to_dict(r) for r in self._rules.values()]}
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with open(self.file_path, "w") as f:
|
|
118
|
+
if self.file_path.suffix in [".yaml", ".yml"]:
|
|
119
|
+
yaml.safe_dump(data, f, default_flow_style=False)
|
|
120
|
+
else:
|
|
121
|
+
json.dump(data, f, indent=2)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
raise StorageError(f"Failed to save rules to {self.file_path}: {e}") from e
|
|
124
|
+
|
|
125
|
+
def _apply_filters(self, rules: List[Rule], filters: Dict[str, Any]) -> List[Rule]:
|
|
126
|
+
"""Apply filters to rule list."""
|
|
127
|
+
result = rules
|
|
128
|
+
|
|
129
|
+
if "enabled" in filters:
|
|
130
|
+
result = [r for r in result if r.enabled == filters["enabled"]]
|
|
131
|
+
|
|
132
|
+
if "category" in filters:
|
|
133
|
+
result = [r for r in result if r.category == filters["category"]]
|
|
134
|
+
|
|
135
|
+
if "severity" in filters:
|
|
136
|
+
result = [r for r in result if r.severity == filters["severity"]]
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
def reload(self) -> None:
|
|
141
|
+
"""Reload rules from file."""
|
|
142
|
+
self._rules.clear()
|
|
143
|
+
self._load_rules()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory rule repository implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..types import Rule
|
|
8
|
+
from .base import RuleRepository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryRuleRepository(RuleRepository):
|
|
12
|
+
"""In-memory rule storage for testing and simple use cases."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, rules: Optional[List[Rule]] = None):
|
|
15
|
+
"""
|
|
16
|
+
Initialize in-memory repository.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
rules: Optional list of rules to initialize with
|
|
20
|
+
"""
|
|
21
|
+
self._rules: Dict[str, Rule] = {}
|
|
22
|
+
if rules:
|
|
23
|
+
for rule in rules:
|
|
24
|
+
self._rules[rule.code] = rule
|
|
25
|
+
|
|
26
|
+
def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
27
|
+
"""
|
|
28
|
+
Get all rules, optionally filtered.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
filters: Optional filters to apply
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of rules
|
|
35
|
+
"""
|
|
36
|
+
rules = list(self._rules.values())
|
|
37
|
+
if filters:
|
|
38
|
+
rules = self._apply_filters(rules, filters)
|
|
39
|
+
return rules
|
|
40
|
+
|
|
41
|
+
def get_by_code(self, code: str) -> Optional[Rule]:
|
|
42
|
+
"""
|
|
43
|
+
Get a specific rule by code.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
code: Rule code
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Rule if found, None otherwise
|
|
50
|
+
"""
|
|
51
|
+
return self._rules.get(code)
|
|
52
|
+
|
|
53
|
+
def save(self, rule: Rule) -> Rule:
|
|
54
|
+
"""
|
|
55
|
+
Save a rule (create or update).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
rule: Rule to save
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Saved rule
|
|
62
|
+
"""
|
|
63
|
+
self._rules[rule.code] = rule
|
|
64
|
+
return rule
|
|
65
|
+
|
|
66
|
+
def delete(self, code: str) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Delete a rule by code.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
code: Rule code
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if deleted, False otherwise
|
|
75
|
+
"""
|
|
76
|
+
if code in self._rules:
|
|
77
|
+
del self._rules[code]
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def _apply_filters(self, rules: List[Rule], filters: Dict[str, Any]) -> List[Rule]:
|
|
82
|
+
"""
|
|
83
|
+
Apply filters to rule list.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
rules: List of rules to filter
|
|
87
|
+
filters: Filters to apply
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Filtered list of rules
|
|
91
|
+
"""
|
|
92
|
+
result = rules
|
|
93
|
+
|
|
94
|
+
if "enabled" in filters:
|
|
95
|
+
result = [r for r in result if r.enabled == filters["enabled"]]
|
|
96
|
+
|
|
97
|
+
if "category" in filters:
|
|
98
|
+
result = [r for r in result if r.category == filters["category"]]
|
|
99
|
+
|
|
100
|
+
if "severity" in filters:
|
|
101
|
+
result = [r for r in result if r.severity == filters["severity"]]
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
def clear(self) -> None:
|
|
106
|
+
"""Clear all rules from the repository."""
|
|
107
|
+
self._rules.clear()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scoring strategies module for the CMAP Rules Engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import ScoringStrategy
|
|
6
|
+
from .max import MaxStrategy
|
|
7
|
+
from .sum import SumStrategy
|
|
8
|
+
from .weighted import WeightedStrategy
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ScoringStrategy",
|
|
12
|
+
"SumStrategy",
|
|
13
|
+
"WeightedStrategy",
|
|
14
|
+
"MaxStrategy",
|
|
15
|
+
]
|