Skip to content

Commit 4f02a61

Browse files
authored
Yara bundles & optimized validation (#1230)
1 parent 0f81c1e commit 4f02a61

5 files changed

Lines changed: 135 additions & 21 deletions

File tree

core/errors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class YetiError(RuntimeError):
2-
def __init__(self, message: str, meta: dict):
3-
self.meta = meta
2+
def __init__(self, message: str, meta: dict | None = None):
3+
self.meta = meta or {}
44
super().__init__(message)
55

66

core/schemas/indicators/yara.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Any, ClassVar, Literal
1+
import logging
2+
from typing import ClassVar, Literal
23

34
import plyara
45
import plyara.exceptions
@@ -19,6 +20,9 @@
1920
}
2021

2122

23+
logger = logging.getLogger(__name__)
24+
25+
2226
class MatchInstance(BaseModel):
2327
"""
2428
Represents an instance of a string match.
@@ -122,10 +126,8 @@ class Yara(indicator.Indicator):
122126
dependencies: list[str] = []
123127
private: bool = False
124128

125-
@model_validator(mode="before")
126-
@classmethod
127-
def validate_yara(cls, data: Any):
128-
rule = data.get("pattern")
129+
def validate_yara(self):
130+
rule = self.pattern
129131
if not rule:
130132
raise ValueError("Yara rule body is required.")
131133
try:
@@ -138,13 +140,16 @@ def validate_yara(cls, data: Any):
138140
raise ValueError("No valid Yara rules found in the rule body.")
139141
parsed_rule = rules[0]
140142
rule_deps = set(plyara.utils.detect_dependencies(parsed_rule))
141-
data["dependencies"] = rule_deps - ALLOWED_EXTERNALS.keys()
142-
data["name"] = parsed_rule["rule_name"]
143-
data["private"] = "private" in parsed_rule.get("scopes", [])
144-
145-
return data
143+
self.dependencies = list(rule_deps - ALLOWED_EXTERNALS.keys())
144+
self.name = parsed_rule["rule_name"]
145+
self.private = "private" in parsed_rule.get("scopes", [])
146146

147147
def save(self):
148+
try:
149+
self.validate_yara()
150+
except ValueError as error:
151+
raise errors.ObjectCreationError(str(error)) from error
152+
148153
missing_deps = []
149154
for dep_name in self.dependencies:
150155
dep = Yara.find(name=dep_name)
@@ -247,19 +252,36 @@ def rule_with_dependencies(
247252

248253
concatenated_rules = ""
249254

250-
parsed_rule = plyara.Plyara().parse_string(self.pattern)[0]
251-
dependencies = plyara.utils.detect_dependencies(parsed_rule)
255+
dependencies = self.dependencies
252256

253257
for dependency in dependencies:
258+
if dependency in resolved:
259+
continue
260+
logger.info(f"Resolving dependency: {dependency}")
254261
dep_rule = Yara.find(name=dependency)
255262
if not dep_rule:
256263
raise ValueError(f"Rule depends on unknown dependency '{dependency}'")
257-
if dep_rule.name not in resolved:
258-
concatenated_rules += dep_rule.rule_with_dependencies(resolved, seen)
264+
concatenated_rules += dep_rule.rule_with_dependencies(resolved, seen)
259265

260266
if self.name not in resolved:
261267
concatenated_rules += self.pattern + "\n\n"
262268
resolved.add(self.name)
263269

264270
seen.remove(self.name)
265271
return concatenated_rules
272+
273+
@classmethod
274+
def generate_yara_bundle(cls, rules: list["Yara"]) -> str:
275+
"""Export a list of Yara rules to a single string.
276+
277+
Args:
278+
rules: A list of Yara rules to export.
279+
280+
Returns:
281+
A string containing the exported rules.
282+
"""
283+
resolved: set[str] = set()
284+
bulk_rules = ""
285+
for rule in rules:
286+
bulk_rules += rule.rule_with_dependencies(resolved)
287+
return bulk_rules

core/web/apiv2/indicators.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from fastapi import APIRouter, HTTPException, Request
24
from pydantic import BaseModel, ConfigDict, Field, conlist
35

@@ -8,10 +10,13 @@
810
Indicator,
911
IndicatorType,
1012
IndicatorTypes,
13+
Yara,
1114
)
1215
from core.schemas.rbac import global_permission, permission_on_ids, permission_on_target
1316
from core.schemas.tag import MAX_TAGS_REQUEST
1417

18+
logger = logging.getLogger(__name__)
19+
1520

1621
# Request schemas
1722
class NewIndicatorRequest(BaseModel):
@@ -59,6 +64,20 @@ class IndicatorTagResponse(BaseModel):
5964
tags: dict[str, dict[str, graph.TagRelationship]]
6065

6166

67+
class YaraBundleRequest(BaseModel):
68+
model_config = ConfigDict(extra="forbid")
69+
70+
ids: list[str] = []
71+
tags: list[str] = []
72+
exclude_tags: list[str] = []
73+
74+
75+
class YaraBundleResponse(BaseModel):
76+
model_config = ConfigDict(extra="forbid")
77+
78+
bundle: str
79+
80+
6281
# API endpoints
6382
router = APIRouter()
6483

@@ -171,3 +190,34 @@ def tag(httpreq: Request, request: IndicatorTagRequest) -> IndicatorTagResponse:
171190
indicator_tags[db_indicator.extended_id] = db_indicator.tags
172191

173192
return IndicatorTagResponse(tagged=len(indicators), tags=indicator_tags)
193+
194+
195+
@router.post("/yara/bundle")
196+
def get_yara_bundle(httpreq: Request, request: YaraBundleRequest) -> YaraBundleResponse:
197+
"""Generates a YARA bundle from a list of indicators."""
198+
indicators = []
199+
for indicator_id in request.ids:
200+
db_indicator = Yara.get(indicator_id)
201+
if not db_indicator:
202+
raise HTTPException(
203+
status_code=404,
204+
detail=f"YARA bundle request contained an unknown indicator: ID:{indicator_id}",
205+
)
206+
indicators.append(db_indicator)
207+
import time
208+
209+
indicators_from_tags, _ = Indicator.filter(
210+
query_args={"type": "yara"},
211+
tag_filter=request.tags,
212+
graph_queries=[("tags", "tagged", "outbound", "name")],
213+
user=httpreq.state.user,
214+
)
215+
216+
for indicator in indicators_from_tags:
217+
if any(tag in request.exclude_tags for tag in indicator.tags):
218+
continue
219+
indicators.append(indicator)
220+
221+
bundle = Yara.generate_yara_bundle(rules=indicators)
222+
223+
return YaraBundleResponse(bundle=bundle)

tests/apiv2/indicators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,10 @@ def test_bad_yara(self):
172172
"/api/v2/indicators/",
173173
json={"indicator": indicator_dict},
174174
)
175-
self.assertEqual(response.status_code, 422)
175+
self.assertEqual(response.status_code, 400)
176176
data = response.json()
177177
self.assertIn(
178-
"No valid Yara rules found in the rule body", data["detail"][0]["msg"]
178+
"No valid Yara rules found in the rule body", data["detail"]["description"]
179179
)
180180

181181
def test_bad_yara_graceful_failure(self):

tests/schemas/yararule.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import unittest
22

3-
from core import database_arango
3+
from core import database_arango, errors
44
from core.schemas.indicator import DiamondModel
55
from core.schemas.indicators.yara import Yara
66

@@ -29,12 +29,13 @@ def test_yara_name_and_deps(self):
2929
location="any",
3030
diamond=DiamondModel.capability,
3131
)
32+
yara.validate_yara()
3233

3334
self.assertEqual(yara.name, "test")
3435
self.assertEqual(yara.dependencies, ["dep"])
3536

3637
def test_invalid_yara_rule(self):
37-
with self.assertRaises(ValueError) as error:
38+
with self.assertRaises(errors.ObjectCreationError) as error:
3839
Yara(
3940
pattern='rule test { wooo: $a = "test" fooo: $a and dep }',
4041
location="any",
@@ -44,7 +45,7 @@ def test_invalid_yara_rule(self):
4445
self.assertIn("Unknown text wooo", str(error.exception))
4546

4647
def test_fail_on_more_than_one_rule(self):
47-
with self.assertRaises(ValueError) as error:
48+
with self.assertRaises(errors.ObjectCreationError) as error:
4849
Yara(
4950
pattern="rule test { condition: true } rule test2 { condition: true }",
5051
location="any",
@@ -91,6 +92,47 @@ def test_dependency_calculation(self):
9192
),
9293
)
9394

95+
def test_bulk_dependency_export(self):
96+
Yara(
97+
pattern="rule dep0 { condition: true }",
98+
location="any",
99+
diamond=DiamondModel.capability,
100+
).save()
101+
102+
Yara(
103+
pattern="rule dep1 { condition: true and dep0 }",
104+
location="any",
105+
diamond=DiamondModel.capability,
106+
).save()
107+
108+
Yara(
109+
pattern="rule dep2 { condition: true and dep1 }",
110+
location="any",
111+
diamond=DiamondModel.capability,
112+
).save()
113+
114+
yara_rule = Yara(
115+
pattern="rule test { condition: true and dep2 and dep1 }",
116+
location="any",
117+
diamond=DiamondModel.capability,
118+
).save()
119+
120+
yara_rule2 = Yara(
121+
pattern="rule test2 { condition: true and dep2 }",
122+
location="any",
123+
diamond=DiamondModel.capability,
124+
)
125+
126+
export = Yara.generate_yara_bundle([yara_rule, yara_rule2])
127+
self.assertEqual(
128+
export,
129+
"rule dep0 { condition: true }\n\n"
130+
"rule dep1 { condition: true and dep0 }\n\n"
131+
"rule dep2 { condition: true and dep1 }\n\n"
132+
"rule test { condition: true and dep2 and dep1 }\n\n"
133+
"rule test2 { condition: true and dep2 }\n\n",
134+
)
135+
94136
def test_yara_dependency_creates_links(self):
95137
dep0 = Yara(
96138
pattern="rule dep0 { condition: true }",

0 commit comments

Comments
 (0)