55from bbot_server .applets .base import BaseApplet , api_endpoint
66from 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
1020class 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
1735class 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
0 commit comments