Skip to content

Commit beec37b

Browse files
authored
feat(threatscore): implement ThreatScoreSnapshot model, filter, serializer, and view for ThreatScore metrics retrieval (#9148)
1 parent 73a277f commit beec37b

File tree

11 files changed

+1721
-140
lines changed

11 files changed

+1721
-140
lines changed

api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to the **Prowler API** are documented in this file.
1414
- Support muting findings based on simple rules with custom reason [(#9051)](https://github.com/prowler-cloud/prowler/pull/9051)
1515
- Support C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097)
1616
- Support for Amazon Bedrock and OpenAI compatible providers in Lighthouse AI [(#8957)](https://github.com/prowler-cloud/prowler/pull/8957)
17+
- Tenant-wide ThreatScore overview aggregation and snapshot persistence with backfill support [(#9148)](https://github.com/prowler-cloud/prowler/pull/9148)
1718
- Support for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167)
1819

1920
---

api/src/backend/api/filters.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
StatusChoices,
4848
Task,
4949
TenantAPIKey,
50+
ThreatScoreSnapshot,
5051
User,
5152
)
5253
from api.rls import Tenant
@@ -998,3 +999,36 @@ class Meta:
998999
"inserted_at": ["gte", "lte"],
9991000
"updated_at": ["gte", "lte"],
10001001
}
1002+
1003+
1004+
class ThreatScoreSnapshotFilter(FilterSet):
1005+
"""
1006+
Filter for ThreatScore snapshots.
1007+
Allows filtering by scan, provider, compliance_id, and date ranges.
1008+
"""
1009+
1010+
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
1011+
scan_id = UUIDFilter(field_name="scan__id", lookup_expr="exact")
1012+
scan_id__in = UUIDInFilter(field_name="scan__id", lookup_expr="in")
1013+
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
1014+
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
1015+
provider_type = ChoiceFilter(
1016+
field_name="provider__provider", choices=Provider.ProviderChoices.choices
1017+
)
1018+
provider_type__in = ChoiceInFilter(
1019+
field_name="provider__provider",
1020+
choices=Provider.ProviderChoices.choices,
1021+
lookup_expr="in",
1022+
)
1023+
compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact")
1024+
compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in")
1025+
1026+
class Meta:
1027+
model = ThreatScoreSnapshot
1028+
fields = {
1029+
"scan": ["exact", "in"],
1030+
"provider": ["exact", "in"],
1031+
"compliance_id": ["exact", "in"],
1032+
"inserted_at": ["date", "gte", "lte"],
1033+
"overall_score": ["exact", "gte", "lte"],
1034+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Generated by Django 5.1.13 on 2025-10-31 09:04
2+
3+
import uuid
4+
5+
import django.db.models.deletion
6+
from django.db import migrations, models
7+
8+
import api.rls
9+
10+
11+
class Migration(migrations.Migration):
12+
dependencies = [
13+
("api", "0056_remove_provider_unique_provider_uids_and_more"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="ThreatScoreSnapshot",
19+
fields=[
20+
(
21+
"id",
22+
models.UUIDField(
23+
default=uuid.uuid4,
24+
editable=False,
25+
primary_key=True,
26+
serialize=False,
27+
),
28+
),
29+
("inserted_at", models.DateTimeField(auto_now_add=True)),
30+
(
31+
"compliance_id",
32+
models.CharField(
33+
help_text="Compliance framework ID (e.g., 'prowler_threatscore_aws')",
34+
max_length=100,
35+
),
36+
),
37+
(
38+
"overall_score",
39+
models.DecimalField(
40+
decimal_places=2,
41+
help_text="Overall ThreatScore percentage (0-100)",
42+
max_digits=5,
43+
),
44+
),
45+
(
46+
"score_delta",
47+
models.DecimalField(
48+
blank=True,
49+
decimal_places=2,
50+
help_text="Score change compared to previous snapshot (positive = improvement)",
51+
max_digits=5,
52+
null=True,
53+
),
54+
),
55+
(
56+
"section_scores",
57+
models.JSONField(
58+
blank=True,
59+
default=dict,
60+
help_text="ThreatScore breakdown by section",
61+
),
62+
),
63+
(
64+
"critical_requirements",
65+
models.JSONField(
66+
blank=True,
67+
default=list,
68+
help_text="List of critical failed requirements (risk >= 4)",
69+
),
70+
),
71+
(
72+
"total_requirements",
73+
models.IntegerField(
74+
default=0, help_text="Total number of requirements evaluated"
75+
),
76+
),
77+
(
78+
"passed_requirements",
79+
models.IntegerField(
80+
default=0, help_text="Number of requirements with PASS status"
81+
),
82+
),
83+
(
84+
"failed_requirements",
85+
models.IntegerField(
86+
default=0, help_text="Number of requirements with FAIL status"
87+
),
88+
),
89+
(
90+
"manual_requirements",
91+
models.IntegerField(
92+
default=0, help_text="Number of requirements with MANUAL status"
93+
),
94+
),
95+
(
96+
"total_findings",
97+
models.IntegerField(
98+
default=0,
99+
help_text="Total number of findings across all requirements",
100+
),
101+
),
102+
(
103+
"passed_findings",
104+
models.IntegerField(
105+
default=0, help_text="Number of findings with PASS status"
106+
),
107+
),
108+
(
109+
"failed_findings",
110+
models.IntegerField(
111+
default=0, help_text="Number of findings with FAIL status"
112+
),
113+
),
114+
(
115+
"provider",
116+
models.ForeignKey(
117+
on_delete=django.db.models.deletion.CASCADE,
118+
related_name="threatscore_snapshots",
119+
related_query_name="threatscore_snapshot",
120+
to="api.provider",
121+
),
122+
),
123+
(
124+
"scan",
125+
models.ForeignKey(
126+
on_delete=django.db.models.deletion.CASCADE,
127+
related_name="threatscore_snapshots",
128+
related_query_name="threatscore_snapshot",
129+
to="api.scan",
130+
),
131+
),
132+
(
133+
"tenant",
134+
models.ForeignKey(
135+
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
136+
),
137+
),
138+
],
139+
options={
140+
"db_table": "threatscore_snapshots",
141+
"abstract": False,
142+
},
143+
),
144+
migrations.AddIndex(
145+
model_name="threatscoresnapshot",
146+
index=models.Index(
147+
fields=["tenant_id", "scan_id"], name="threatscore_snap_t_scan_idx"
148+
),
149+
),
150+
migrations.AddIndex(
151+
model_name="threatscoresnapshot",
152+
index=models.Index(
153+
fields=["tenant_id", "provider_id"], name="threatscore_snap_t_prov_idx"
154+
),
155+
),
156+
migrations.AddIndex(
157+
model_name="threatscoresnapshot",
158+
index=models.Index(
159+
fields=["tenant_id", "inserted_at"], name="threatscore_snap_t_time_idx"
160+
),
161+
),
162+
migrations.AddConstraint(
163+
model_name="threatscoresnapshot",
164+
constraint=api.rls.RowLevelSecurityConstraint(
165+
"tenant_id",
166+
name="rls_on_threatscoresnapshot",
167+
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
168+
),
169+
),
170+
]

api/src/backend/api/models.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2239,3 +2239,137 @@ class Meta(RowLevelSecurityProtectedModel.Meta):
22392239

22402240
class JSONAPIMeta:
22412241
resource_name = "lighthouse-models"
2242+
2243+
2244+
class ThreatScoreSnapshot(RowLevelSecurityProtectedModel):
2245+
"""
2246+
Stores historical ThreatScore metrics for a given scan.
2247+
Snapshots are created automatically after each ThreatScore report generation.
2248+
"""
2249+
2250+
objects = models.Manager()
2251+
all_objects = models.Manager()
2252+
2253+
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
2254+
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
2255+
2256+
scan = models.ForeignKey(
2257+
Scan,
2258+
on_delete=models.CASCADE,
2259+
related_name="threatscore_snapshots",
2260+
related_query_name="threatscore_snapshot",
2261+
)
2262+
2263+
provider = models.ForeignKey(
2264+
Provider,
2265+
on_delete=models.CASCADE,
2266+
related_name="threatscore_snapshots",
2267+
related_query_name="threatscore_snapshot",
2268+
)
2269+
2270+
compliance_id = models.CharField(
2271+
max_length=100,
2272+
blank=False,
2273+
null=False,
2274+
help_text="Compliance framework ID (e.g., 'prowler_threatscore_aws')",
2275+
)
2276+
2277+
# Overall ThreatScore metrics
2278+
overall_score = models.DecimalField(
2279+
max_digits=5,
2280+
decimal_places=2,
2281+
help_text="Overall ThreatScore percentage (0-100)",
2282+
)
2283+
2284+
# Score improvement/degradation compared to previous snapshot
2285+
score_delta = models.DecimalField(
2286+
max_digits=5,
2287+
decimal_places=2,
2288+
null=True,
2289+
blank=True,
2290+
help_text="Score change compared to previous snapshot (positive = improvement)",
2291+
)
2292+
2293+
# Section breakdown stored as JSON
2294+
# Format: {"1. IAM": 85.5, "2. Attack Surface": 92.3, ...}
2295+
section_scores = models.JSONField(
2296+
default=dict,
2297+
blank=True,
2298+
help_text="ThreatScore breakdown by section",
2299+
)
2300+
2301+
# Critical requirements metadata stored as JSON
2302+
# Format: [{"requirement_id": "...", "risk_level": 5, "weight": 150, ...}, ...]
2303+
critical_requirements = models.JSONField(
2304+
default=list,
2305+
blank=True,
2306+
help_text="List of critical failed requirements (risk >= 4)",
2307+
)
2308+
2309+
# Summary statistics
2310+
total_requirements = models.IntegerField(
2311+
default=0,
2312+
help_text="Total number of requirements evaluated",
2313+
)
2314+
2315+
passed_requirements = models.IntegerField(
2316+
default=0,
2317+
help_text="Number of requirements with PASS status",
2318+
)
2319+
2320+
failed_requirements = models.IntegerField(
2321+
default=0,
2322+
help_text="Number of requirements with FAIL status",
2323+
)
2324+
2325+
manual_requirements = models.IntegerField(
2326+
default=0,
2327+
help_text="Number of requirements with MANUAL status",
2328+
)
2329+
2330+
total_findings = models.IntegerField(
2331+
default=0,
2332+
help_text="Total number of findings across all requirements",
2333+
)
2334+
2335+
passed_findings = models.IntegerField(
2336+
default=0,
2337+
help_text="Number of findings with PASS status",
2338+
)
2339+
2340+
failed_findings = models.IntegerField(
2341+
default=0,
2342+
help_text="Number of findings with FAIL status",
2343+
)
2344+
2345+
def __str__(self):
2346+
return f"ThreatScore {self.overall_score}% for scan {self.scan_id} ({self.inserted_at})"
2347+
2348+
class Meta(RowLevelSecurityProtectedModel.Meta):
2349+
db_table = "threatscore_snapshots"
2350+
2351+
constraints = [
2352+
RowLevelSecurityConstraint(
2353+
field="tenant_id",
2354+
name="rls_on_%(class)s",
2355+
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
2356+
),
2357+
]
2358+
2359+
indexes = [
2360+
models.Index(
2361+
fields=["tenant_id", "scan_id"],
2362+
name="threatscore_snap_t_scan_idx",
2363+
),
2364+
models.Index(
2365+
fields=["tenant_id", "provider_id"],
2366+
name="threatscore_snap_t_prov_idx",
2367+
),
2368+
models.Index(
2369+
fields=["tenant_id", "inserted_at"],
2370+
name="threatscore_snap_t_time_idx",
2371+
),
2372+
]
2373+
2374+
class JSONAPIMeta:
2375+
resource_name = "threatscore-snapshots"

0 commit comments

Comments
 (0)