Coverage for product_risk_suite/risk_assessment/models.py: 99%

254 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 12:45 +0000

1from django.db import models 

2from django.utils.translation import gettext_lazy as _ 

3from django.core.validators import MaxValueValidator, MinValueValidator 

4from django.contrib.auth.models import User 

5from django.core.exceptions import ValidationError 

6 

7from datetime import datetime, timedelta 

8from enum import Enum 

9import logging 

10 

11logger = logging.getLogger(f"risk_assessment.{__name__}") 

12 

13def truncate(s: str, max_len: int = 50) -> str: 

14 if s is None: 

15 return "" 

16 return s if len(s) <= max_len else s[:max_len - 3] + "..." 

17 

18class Asset(models.Model): 

19 id = models.AutoField(primary_key=True) 

20 name = models.CharField(max_length=200, unique=True) 

21 

22 def __str__(self): 

23 return self.name 

24 

25class LifeCycle(models.Model): 

26 id = models.AutoField(primary_key=True) 

27 name = models.CharField(max_length=200, unique=True) 

28 

29 def __str__(self): 

30 return self.name 

31 

32class Origin(models.Model): 

33 id = models.AutoField(primary_key=True) 

34 name = models.CharField(max_length=200, unique=True) 

35 

36 def __str__(self): 

37 return self.name 

38 

39class SecurityRequirement(models.Model): 

40 id = models.AutoField(primary_key=True) 

41 norm_short = models.CharField(max_length=255) 

42 norm_long = models.CharField(max_length=255) 

43 description_short = models.CharField(max_length=255) 

44 description_long = models.TextField() 

45 slug = models.SlugField(null=False, unique=True) 

46 

47 class Meta: 

48 unique_together = ('norm_short', 'description_short',) 

49 ordering = ['norm_short', 'description_short'] 

50 

51 def __str__(self): 

52 return f"{self.norm_short}: {self.description_short}" 

53 

54class Stride(models.Model): 

55 id = models.AutoField(primary_key=True) 

56 STRIDE_CHOICES = { 

57 "S": _("Spoofing"), 

58 "T": _("Tampering"), 

59 "R": _("Repudiation"), 

60 "I": _("Information Disclosure"), 

61 "D": _("Denial of Service"), 

62 "E": _("Elevation of Privileges"), 

63 } 

64 

65 name = models.CharField(max_length=1, choices=STRIDE_CHOICES, unique=True) 

66 

67 def __str__(self): 

68 return self.name 

69 

70 def save(self, *args, **kwargs): 

71 self.full_clean() # Calls clean() and validates all fields 

72 super().save(*args, **kwargs) 

73 

74 def clean(self): 

75 if self.name not in self.STRIDE_CHOICES: 

76 raise ValidationError( 

77 {"name": f"'{self.name}' is an invalid STRIDE value."} 

78 ) 

79 super().clean() 

80 

81class SuggestedMitigationValidation(models.Model): 

82 id = models.AutoField(primary_key=True) 

83 suggested_mitigation = models.TextField(null=True, blank=True) 

84 suggested_validation = models.TextField(null=True, blank=True) 

85 

86 def __str__(self): 

87 return f"{truncate(self.suggested_mitigation, 32)} - {truncate(self.suggested_validation, 32)}" 

88 

89 

90class Risk(models.Model): 

91 id = models.AutoField(primary_key=True) 

92 custom_id = models.CharField(max_length=255, default="RISK-") 

93 version = models.PositiveIntegerField(default=1) 

94 is_current = models.BooleanField(default=True) 

95 asset = models.ForeignKey(Asset, on_delete=models.PROTECT) 

96 origin = models.ForeignKey(Origin, on_delete=models.PROTECT) 

97 life_cycle = models.ForeignKey(LifeCycle, on_delete=models.PROTECT) 

98 stride = models.ManyToManyField(Stride) 

99 title = models.CharField(max_length=255) 

100 description = models.TextField() 

101 

102 suggested_mitigation_validation = models.ForeignKey(SuggestedMitigationValidation, null=True, blank=True, on_delete=models.SET_NULL) 

103 

104 class Meta: 

105 unique_together = ('custom_id', 'version') 

106 

107 def __str__(self): 

108 return f"{self.custom_id} (v{self.version}): {self.title}" 

109 

110 @property 

111 def has_newer_version(self): 

112 return not self.is_current 

113 

114 @property 

115 def stride_str(self): 

116 strides = [stride.name for stride in self.stride.all()] 

117 return ", ".join(strides) 

118 

119 @property 

120 def list_stride(self): 

121 return [str(Stride.STRIDE_CHOICES[stride.name]) for stride in self.stride.all()] 

122 

123class Risk5x5(Enum): 

124 LOW = 0 

125 MID = 1 

126 HIGH = 2 

127 

128class SeverityName(Enum): 

129 Negligible = 1 

130 Minor = 2 

131 Moderate = 3 

132 Major = 4 

133 Critical = 5 

134 

135 @staticmethod 

136 def to_human_str(value): 

137 try: 

138 sn = SeverityName(value) 

139 match sn: 

140 case SeverityName.Negligible: return "Negligible" 

141 case SeverityName.Minor: return "Minor" 

142 case SeverityName.Moderate: return "Moderate" 

143 case SeverityName.Major: return "Major" 

144 case SeverityName.Critical: return "Critical" 

145 except ValueError as ve: 

146 logger.error(ve) 

147 return str(value) 

148 

149class SeverityExample(models.Model): 

150 id = models.AutoField(primary_key=True) 

151 severity_of_impact = models.IntegerField(default=1, validators=[MaxValueValidator(5), MinValueValidator(1)], unique=True) 

152 examples = models.TextField(null=True, blank=True) 

153 

154 def __str__(self): 

155 return f"{SeverityName.to_human_str(self.severity_of_impact)} - Examples" 

156 

157class LikelihoodName(Enum): 

158 VeryLow = 1 

159 Low = 2 

160 Medium = 3 

161 High = 4 

162 VeryHigh = 5 

163 

164 @staticmethod 

165 def to_human_str(value): 

166 li = LikelihoodName(value) 

167 match li: 

168 case LikelihoodName.VeryLow: return "Very low" 

169 case LikelihoodName.Low: return "Low" 

170 case LikelihoodName.Medium: return "Medium" 

171 case LikelihoodName.High: return "High" 

172 case LikelihoodName.VeryHigh: return "Very high" 

173 return str(value) 

174 

175class LikelihoodExample(models.Model): 

176 id = models.AutoField(primary_key=True) 

177 likelihood_of_occurrence = models.IntegerField(default=1, validators=[MaxValueValidator(5), MinValueValidator(1)], unique=True) 

178 examples = models.TextField(null=True, blank=True) 

179 

180 def __str__(self): 

181 return f"{LikelihoodName.to_human_str(self.likelihood_of_occurrence)} - Examples" 

182 

183def _RiskRating__loo_help_list(): 

184 str = "<ul>" 

185 for i in range(1, 6): 

186 str = str + f"<li>{i}: {LikelihoodName.to_human_str(i)}</li>" 

187 str = str + "</ul>" 

188 return str 

189 

190def _RiskRating__sev_help_list(): 

191 str = "<ul>" 

192 for i in range(1, 6): 

193 str = str + f"<li>{i}: {SeverityName.to_human_str(i)}</li>" 

194 str = str + "</ul>" 

195 return str 

196 

197class RiskRating(models.Model): 

198 id = models.AutoField(primary_key=True) 

199 likelihood_of_occurrence = models.IntegerField(default=1, validators=[MaxValueValidator(5), MinValueValidator(1)], help_text=__loo_help_list()) 

200 severity_of_impact = models.IntegerField(default=1, validators=[MaxValueValidator(5), MinValueValidator(1)], help_text=__sev_help_list()) 

201 

202 @staticmethod 

203 def risk_level(code) -> Risk5x5: 

204 if code >= 15: 

205 return Risk5x5.HIGH 

206 if code >= 7: 

207 return Risk5x5.MID 

208 return Risk5x5.LOW 

209 

210 @staticmethod 

211 def risk_level_to_color(risk_level: Risk5x5): 

212 match risk_level: 

213 case Risk5x5.HIGH: 

214 return "danger" # red 

215 case Risk5x5.MID: 

216 return "warning" # yellow 

217 case Risk5x5.LOW: 

218 return "success" # green 

219 return "danger" # fallback red 

220 

221 @staticmethod 

222 def color_class(code): 

223 if code == 1 or code == 2: 

224 return RiskRating.risk_level_to_color(Risk5x5.LOW) 

225 if code == 3 or code == 4: 

226 return RiskRating.risk_level_to_color(Risk5x5.MID) 

227 return RiskRating.risk_level_to_color(Risk5x5.HIGH) 

228 

229 

230 def risk_color_class(self, code): 

231 return self.risk_level_to_color(self.risk_level(code)) 

232 

233 @staticmethod 

234 def calc_risk(likelihood_of_occurrence, severity_of_impact): 

235 return likelihood_of_occurrence * severity_of_impact 

236 

237 @property 

238 def risk(self): 

239 return RiskRating.calc_risk(self.likelihood_of_occurrence, self.severity_of_impact) 

240 

241 def __str__(self): 

242 return f"{LikelihoodName.to_human_str(self.likelihood_of_occurrence)} ({self.likelihood_of_occurrence}) * {SeverityName.to_human_str(self.severity_of_impact)} ({self.severity_of_impact}) = {self.risk}" 

243 

244 @property 

245 def color_likelihood_of_occurrence(self): 

246 return self.color_class(self.likelihood_of_occurrence) 

247 @property 

248 def color_severity_of_impact(self): 

249 return self.color_class(self.severity_of_impact) 

250 @property 

251 def color_risk(self): 

252 return self.risk_color_class(self.risk) 

253 

254class Status(models.Model): 

255 id = models.AutoField(primary_key=True) 

256 STATUS_CHOICES = { 

257 "Open": True, 

258 "On going": True , 

259 "Finished": False , 

260 "Rejected": False , 

261 } 

262 

263 status = models.CharField(max_length=255, choices=STATUS_CHOICES, unique=True) 

264 

265 def __str__(self): 

266 return self.status 

267 

268 @property 

269 def is_todo(self): 

270 return self.STATUS_CHOICES[self.status] 

271 

272 @staticmethod 

273 def web_idx_name(choice): 

274 match choice: 

275 case "Open": return "open" 

276 case "On going": return "on_going" 

277 case "Finished": return "finished" 

278 case "Rejected": return "rejected" 

279 return "unknown" 

280 

281 def save(self, *args, **kwargs): 

282 self.full_clean() # Calls clean() and validates all fields 

283 super().save(*args, **kwargs) 

284 

285 def clean(self): 

286 if self.status not in self.STATUS_CHOICES: 

287 raise ValidationError( 

288 {"name": f"'{self.status}' is an invalid status value."} 

289 ) 

290 super().clean() 

291 

292class Evidence(models.Model): 

293 id = models.AutoField(primary_key=True) 

294 due_date = models.DateField() 

295 responsible = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) 

296 status = models.ForeignKey(Status, on_delete=models.PROTECT) 

297 evidence = models.TextField(null=True, blank=True) 

298 evidence_link = models.TextField(null=True, blank=True) 

299 

300 product = models.ForeignKey('product.Product', blank=True, null=True, on_delete=models.SET_NULL) 

301 

302 def __str__(self): 

303 if self.status.is_todo: 

304 if self.evidence: 

305 return f"{self.status} responsible: {self.responsible} due: {self.due_date} evidence: {self.evidence}" 

306 return f"{self.status} responsible: {self.responsible} due: {self.due_date}" 

307 else: 

308 return f"{self.status} evidence: {self.evidence}" 

309 

310 @property 

311 def color_due_date(self): 

312 if not self.status.is_todo: 

313 return "success" #green 

314 in_4_weeks = datetime.today() + timedelta(weeks=4) 

315 if self.due_date > in_4_weeks.date(): 

316 return "warning" # yellow 

317 return "danger" # red 

318 

319class RiskMitigation(models.Model): 

320 id = models.AutoField(primary_key=True) 

321 security_requirements = models.ManyToManyField(SecurityRequirement, blank=True) 

322 title = models.CharField(max_length=255, null=True, blank=True) 

323 mitigation = models.TextField(null=True, blank=True) 

324 rational = models.TextField(null=True, blank=True) 

325 

326 @property 

327 def list_security_requirements(self): 

328 return [{ "str": str(req), "slug": req.slug } for req in self.security_requirements.all()] 

329 

330 def __str__(self): 

331 if self.title: 

332 return self.title 

333 return f"{self.mitigation}"