Skip to content

Commit a8aeaf6

Browse files
Merge pull request #138 from blacklanternsecurity/dev
Dev -> Stable
2 parents 072e856 + 4afa292 commit a8aeaf6

File tree

15 files changed

+550
-243
lines changed

15 files changed

+550
-243
lines changed

bbot_server/modules/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
# needed for asset model preloading
1212
from bbot_server.assets import CustomAssetFields # noqa: F401
13-
from typing import List, Optional, Dict, Any, Annotated # noqa: F401
13+
from typing import List, Literal, Optional, Dict, Any, Annotated # noqa: F401
1414
from pydantic import Field, BeforeValidator, AfterValidator, UUID4 # noqa: F401
1515

1616
log = logging.getLogger(__name__)

bbot_server/modules/activity/activity_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Activity(BaseHostModel):
4343
default_factory=utc_now, description="Time when this activity was created"
4444
)
4545
archived: Annotated[bool, "indexed"] = False
46-
description: Annotated[str, "indexed"]
46+
description: Annotated[str, "indexed", "indexed-text"]
4747
description_colored: str = Field(default="")
4848
detail: dict[str, Any] = {}
4949
module: Annotated[Optional[str], "indexed"] = None

bbot_server/modules/assets/assets_models.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ class AdvancedAssetQuery(AssetQuery):
1414

1515
async def build(self, applet=None):
1616
query = await super().build(applet)
17-
print(f"QUERERY BEFOREE: {query}")
18-
print(f"SELF>TTTYPE: {self.type}")
1917
if ("type" not in query) and self.type:
2018
query["type"] = self.type
21-
print(f"UQYWERQWEYRYRY: {query}")
2219
return query

bbot_server/modules/findings/findings_api.py

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,31 @@
55
from bbot_server.applets.base import BaseApplet, api_endpoint
66
from bbot_server.modules.findings.findings_models import Finding, SEVERITY_COLORS, SeverityScore, FindingsQuery
77

8+
# Max CVSS score for each severity band (top of range).
9+
# Used to derive a default risk score from finding_max_severity.
10+
SEVERITY_TO_CVSS = {
11+
"INFO": 0.0,
12+
"LOW": 0.1,
13+
"MEDIUM": 4.0,
14+
"HIGH": 7.0,
15+
"CRITICAL": 9.0,
16+
}
17+
818

919
# add 'findings' field to the main asset model
1020
class FindingFields(CustomAssetFields):
1121
findings: Annotated[list[str], "indexed", "indexed-text"] = []
1222
finding_severities: Annotated[dict[str, int], "indexed"] = {}
13-
finding_max_severity: Optional[Annotated[str, "indexed"]] = None
23+
finding_max_severity: Annotated[Optional[str], "indexed"] = None
1424
finding_max_severity_score: Annotated[int, "indexed"] = 0
25+
# Effective risk score for this asset: None or a float from 0.0 to 10.0
26+
# (1 decimal place). Auto-synced from finding_max_severity (using CVSS
27+
# thresholds) unless risk_override is True.
28+
risk: Annotated[Optional[float], "indexed"] = None
29+
# Whether risk has been manually overridden. When True, new findings
30+
# will NOT auto-update risk. Clearing the override resets this to False
31+
# and reverts risk to the CVSS-derived value.
32+
risk_override: Annotated[bool, "indexed"] = False
1533

1634

1735
class FindingsApplet(BaseApplet):
@@ -134,6 +152,68 @@ async def severity_counts(
134152
findings = dict(sorted(findings.items(), key=lambda x: x[1], reverse=True))
135153
return findings
136154

155+
@api_endpoint("/set_risk", methods=["PATCH"], summary="Set or clear a manual risk score for an asset")
156+
async def set_risk(
157+
self,
158+
host: Annotated[str, Query(description="The host of the asset to update")],
159+
risk: Annotated[
160+
Optional[float],
161+
Query(
162+
description=(
163+
"Risk score from 0.0 to 10.0 (1 decimal place). "
164+
"Omit to clear the override and revert to the auto-calculated CVSS value."
165+
)
166+
),
167+
] = None,
168+
override_none: Annotated[
169+
bool,
170+
Query(
171+
description=(
172+
"Set to true to explicitly override risk to None (no risk score). "
173+
"Takes precedence over the risk parameter."
174+
)
175+
),
176+
] = False,
177+
) -> dict:
178+
"""
179+
Manually set or clear an asset's risk score.
180+
181+
Three modes:
182+
- risk=<float> → override risk to the given value (0.0–10.0, 1 decimal).
183+
- override_none=true → override risk to None (e.g. "no risk score").
184+
- (omit both) → clear the override and revert to the CVSS-derived
185+
value from finding_max_severity.
186+
"""
187+
asset = await self.root._get_asset(host=host, fields=["finding_max_severity"])
188+
if not asset:
189+
raise self.BBOTServerNotFoundError(f"Asset {host} not found")
190+
191+
if override_none:
192+
# Explicit override to None
193+
update = {"risk": None, "risk_override": True}
194+
description = f"Risk manually set to [bold]None[/bold] on [bold]{host}[/bold]"
195+
elif risk is not None:
196+
# Override to a specific float value
197+
if risk < 0.0 or risk > 10.0:
198+
raise self.BBOTServerValueError("risk must be between 0.0 and 10.0")
199+
risk = round(risk, 1)
200+
update = {"risk": risk, "risk_override": True}
201+
description = f"Risk manually set to [bold]{risk}[/bold] on [bold]{host}[/bold]"
202+
else:
203+
# Clear the override: revert to CVSS-derived value
204+
finding_max_severity = asset.get("finding_max_severity", None)
205+
reverted_risk = SEVERITY_TO_CVSS.get(finding_max_severity) if finding_max_severity else None
206+
update = {"risk": reverted_risk, "risk_override": False}
207+
description = f"Risk override cleared on [bold]{host}[/bold], reverted to [bold]{reverted_risk}[/bold]"
208+
209+
await self.root._update_asset(host, update)
210+
await self.emit_activity(
211+
type="RISK_UPDATED",
212+
description=description,
213+
detail={"host": host, **update},
214+
)
215+
return {"host": host, "risk": update["risk"], "risk_override": update["risk_override"]}
216+
137217
async def handle_event(self, event, asset):
138218
name = event.data_json["name"]
139219
description = event.data_json["description"]
@@ -164,8 +244,7 @@ async def compute_stats(self, asset, stats):
164244
- finding names
165245
- finding severities
166246
- finding hosts
167-
- finding max severity
168-
- finding max severity score
247+
- severity counts by host
169248
"""
170249
finding_names = getattr(asset, "findings", [])
171250
finding_severities = getattr(asset, "finding_severities", {})
@@ -181,24 +260,13 @@ async def compute_stats(self, asset, stats):
181260
for finding_severity, count in finding_severities.items():
182261
severity_stats[finding_severity] = severity_stats.get(finding_severity, 0) + count
183262

184-
max_severity_score = max([asset.finding_max_severity_score, finding_stats.get("max_severity_score", 0)])
185-
finding_stats["max_severity_score"] = max_severity_score
186-
if max_severity_score > 0:
187-
max_severity = SeverityScore.to_str(max_severity_score)
188-
else:
189-
max_severity = None
190-
finding_stats["max_severity"] = max_severity
191-
192-
if asset.finding_max_severity_score > 0:
193-
severities_by_host[asset.host] = {
194-
"max_severity": asset.finding_max_severity,
195-
"max_severity_score": asset.finding_max_severity_score,
196-
}
263+
if finding_severities:
264+
severities_by_host[asset.host] = dict(sorted(finding_severities.items(), key=lambda x: x[1], reverse=True))
197265

198266
finding_stats["names"] = dict(sorted(name_stats.items(), key=lambda x: x[1], reverse=True))
199267
finding_stats["counts_by_host"] = dict(sorted(counts_by_host.items(), key=lambda x: x[1], reverse=True))
200268
finding_stats["severities_by_host"] = dict(
201-
sorted(severities_by_host.items(), key=lambda x: x[1]["max_severity_score"], reverse=True)
269+
sorted(severities_by_host.items(), key=lambda x: sum(x[1].values()), reverse=True)
202270
)
203271
finding_stats["severities"] = dict(sorted(severity_stats.items(), key=lambda x: x[1], reverse=True))
204272
stats["findings"] = finding_stats
@@ -245,17 +313,36 @@ async def _insert_or_update_finding(self, finding: Finding, asset, event=None):
245313
else:
246314
asset.finding_max_severity_score = 0
247315
asset.finding_max_severity = None
316+
# Auto-sync risk from finding_max_severity when not manually overridden.
317+
old_risk = getattr(asset, "risk", None)
318+
if not getattr(asset, "risk_override", False):
319+
if asset.finding_max_severity is not None:
320+
asset.risk = SEVERITY_TO_CVSS[asset.finding_max_severity]
321+
else:
322+
asset.risk = None
248323

249324
# insert the new vulnerability
250325
await self.root._insert_asset(finding.model_dump())
251326

252327
severity_color = SEVERITY_COLORS[finding.severity_score]
253328

254-
return [
329+
activities = [
255330
self.make_activity(
256331
type="NEW_FINDING",
257332
description=f"New finding with severity [bold {severity_color}]{finding.severity}[/bold {severity_color}]: [[bold {severity_color}]{finding.name}[/bold {severity_color}]] on [bold]{finding.host}[/bold]",
258333
event=event,
259334
detail=finding.model_dump(),
260335
)
261336
]
337+
338+
# emit RISK_UPDATED if risk actually changed
339+
if asset.risk != old_risk:
340+
activities.append(
341+
self.make_activity(
342+
type="RISK_UPDATED",
343+
description=f"Risk updated from [bold]{old_risk}[/bold] to [bold]{asset.risk}[/bold] on [bold]{asset.host}[/bold]",
344+
detail={"host": asset.host, "risk": asset.risk, "old_risk": old_risk},
345+
)
346+
)
347+
348+
return activities

bbot_server/modules/server/server_cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from subprocess import run
77
from contextlib import suppress
88

9+
from pymongo import uri_parser
10+
911
from bbot_server.config import BBOT_SERVER_CONFIG as bbcfg, BBOT_SERVER_DIR
1012
from bbot_server.cli.base import BaseBBCTL, subcommand, Option, Annotated
1113

@@ -158,7 +160,7 @@ def cleardb(
158160
)
159161

160162
for store_name, store_config, data_desc in stores_to_clear:
161-
db_name = store_config.uri.split("/")[-1]
163+
db_name = uri_parser.parse_uri(store_config.uri)["database"]
162164
prefix = store_config.collection_prefix
163165
if not db_name:
164166
raise self.BBOTServerError(f"{store_name.title()} database not found in config")

bbot_server/modules/targets/targets_api.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ async def refresh_asset_scope(self, host: str, target: BBOTTarget, target_id: UU
113113
scope_result_type = getattr(scope_result, "type", None)
114114
if scope_result_type == "NEW_IN_SCOPE_ASSET":
115115
asset_scope = sorted(set(asset_scope) | set([target_id]))
116-
else:
116+
elif scope_result_type == "ASSET_SCOPE_CHANGED":
117117
asset_scope = sorted(set(asset_scope) - set([target_id]))
118118
asset_results = await self.root.assets.collection.update_many(
119119
{"host": host},
@@ -358,17 +358,17 @@ async def _check_scope(self, host, resolved_hosts, target: BBOTTarget, target_id
358358
try:
359359
# we take the main host and its A/AAAA DNS records into account
360360
for rdtype, hosts in resolved_hosts.items():
361-
for host in hosts:
361+
for h in hosts:
362362
# if any of the hosts are blacklisted, abort immediately
363-
if target.blacklisted(host):
364-
blacklisted_reason = f"{rdtype}->{host}"
363+
if target.blacklisted(h):
364+
blacklisted_reason = f"{rdtype}->{h}"
365365
in_target_reason = ""
366366
# break out of the loop
367367
raise BlacklistedError
368368
# check against whitelist
369369
if not in_target_reason:
370-
if target.in_target(host):
371-
in_target_reason = f"{rdtype}->{host}"
370+
if target.in_target(h):
371+
in_target_reason = f"{rdtype}->{h}"
372372
except BlacklistedError:
373373
pass
374374

bbot_server/store.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class BaseMongoStore(BaseDB):
88
async def setup(self):
99
self.client = AsyncMongoClient(self.uri)
10-
self.db = self.client.get_database(self.db_name)
10+
self.db = self.client.get_default_database()
1111
self.collection_prefix = getattr(self.db_config, "collection_prefix", "")
1212
bucket_name = f"{self.collection_prefix}fs" if self.collection_prefix else "fs"
1313
self.fs = AsyncGridFSBucket(self.db, bucket_name=bucket_name)

bbot_server/utils/misc.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,13 @@ def combine_pydantic_models(models, model_name, base_model=BaseModel):
255255

256256

257257
# fmt: off
258+
# 20260417: removed $jsonSchema because it may reveal internal/private fields
258259
ALLOWED_QUERY_OPERATORS = {
259260
# Query Operators (excluding $where, $expr)
260261
"$eq", "$gt", "$gte", "$in", "$lt", "$lte", "$ne", "$nin",
261262
"$and", "$not", "$nor", "$or",
262263
"$exists", "$type",
263-
"$jsonSchema", "$mod", "$search", "$text", "$regex",
264+
"$mod", "$search", "$text", "$regex",
264265
"$geoIntersects", "$geoWithin", "$near", "$nearSphere",
265266
"$all", "$elemMatch", "$size",
266267
"$bitsAllClear", "$bitsAllSet", "$bitsAnyClear", "$bitsAnySet",
@@ -289,6 +290,7 @@ def _sanitize_mongo_query(data: Any) -> Any:
289290

290291

291292
# fmt: off
293+
# 20260417: removed $unionWith because it may allow fetches to other collections
292294
ALLOWED_AGG_OPERATORS = {
293295
# We intentionally exclude $match because it"s automatically added and sanitized separately
294296

@@ -299,7 +301,7 @@ def _sanitize_mongo_query(data: Any) -> Any:
299301
"$planCacheStats", "$project", "$redact",
300302
"$replaceRoot", "$replaceWith", "$sample", "$search", "$searchMeta",
301303
"$set", "$setWindowFields", "$skip", "$sort", "$sortByCount",
302-
"$unionWith", "$unset", "$unwind",
304+
"$unset", "$unwind",
303305

304306
# Aggregation Expression Operators (excluding $function, $accumulator)
305307
# Arithmetic

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "bbot-server"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
description = ""
55
authors = [{name = "TheTechromancer"}]
66
license = "AGPL-3.0"

tests/test_applets/test_applet_assets.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,83 @@ async def test_applet_target_filter(bbot_server, bbot_events):
346346
assert set(assets) == all_hosts_target2
347347
hosts = await bbot_server.get_hosts(target_id=target.id)
348348
assert set(hosts) == all_hosts_target2
349+
350+
351+
# test to make sure custom attributes on assets are queryable
352+
async def test_applet_custom_attributes(bbot_server, bbot_events):
353+
bbot_server = await bbot_server(needs_worker=True)
354+
355+
# skip testing of the http interface (since insertion isn't supported)
356+
if not bbot_server.is_native:
357+
return
358+
359+
# ingest BBOT events to create some assets
360+
scan1_events, scan2_events = bbot_events
361+
for e in scan1_events:
362+
await bbot_server.insert_event(e)
363+
364+
# wait for events to be processed
365+
await asyncio.sleep(INGEST_PROCESSING_DELAY)
366+
367+
# verify assets exist
368+
hosts = set(await bbot_server.get_hosts())
369+
assert "evilcorp.com" in hosts
370+
assert "www.evilcorp.com" in hosts
371+
assert "api.evilcorp.com" in hosts
372+
373+
# manually add custom attributes to some assets via the collection
374+
collection = bbot_server.assets.collection
375+
await collection.update_one(
376+
{"host": "evilcorp.com", "type": "Asset"},
377+
{"$set": {"custom_tag": "important", "risk_score": 95}},
378+
)
379+
await collection.update_one(
380+
{"host": "www.evilcorp.com", "type": "Asset"},
381+
{"$set": {"custom_tag": "important", "risk_score": 50}},
382+
)
383+
await collection.update_one(
384+
{"host": "api.evilcorp.com", "type": "Asset"},
385+
{"$set": {"custom_tag": "low-priority", "risk_score": 10}},
386+
)
387+
388+
# query by custom attribute - exact match
389+
results = [a async for a in bbot_server.query_assets(query={"custom_tag": "important"})]
390+
assert {a["host"] for a in results} == {"evilcorp.com", "www.evilcorp.com"}
391+
392+
# query by custom attribute - comparison operator
393+
results = [a async for a in bbot_server.query_assets(query={"risk_score": {"$gte": 50}})]
394+
assert {a["host"] for a in results} == {"evilcorp.com", "www.evilcorp.com"}
395+
396+
# query combining custom attribute with built-in filters
397+
results = [a async for a in bbot_server.query_assets(query={"custom_tag": "important", "host": "evilcorp.com"})]
398+
assert len(results) == 1
399+
assert results[0]["host"] == "evilcorp.com"
400+
401+
# verify custom fields are returned in query results
402+
results = [a async for a in bbot_server.query_assets(query={"host": "api.evilcorp.com"})]
403+
assert len(results) == 1
404+
assert results[0]["custom_tag"] == "low-priority"
405+
assert results[0]["risk_score"] == 10
406+
407+
# query for a custom attribute value that doesn't exist
408+
results = [a async for a in bbot_server.query_assets(query={"custom_tag": "nonexistent"})]
409+
assert results == []
410+
411+
# aggregation on custom attributes
412+
agg_results = [
413+
a
414+
async for a in bbot_server.query_assets(
415+
aggregate=[
416+
{"$group": {"_id": "$custom_tag", "avg_risk": {"$avg": "$risk_score"}}},
417+
{"$sort": {"_id": 1}},
418+
],
419+
)
420+
]
421+
assert len(agg_results) == 3
422+
assert agg_results[0] == {"_id": None, "avg_risk": None}
423+
assert agg_results[1] == {"_id": "important", "avg_risk": 72.5}
424+
assert agg_results[2] == {"_id": "low-priority", "avg_risk": 10.0}
425+
426+
# count with custom attribute filter
427+
count = await bbot_server.count_assets(query={"custom_tag": "important"})
428+
assert count == 2

0 commit comments

Comments
 (0)