opencra-shared 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencra_shared-0.1.0/.gitignore +249 -0
- opencra_shared-0.1.0/PKG-INFO +8 -0
- opencra_shared-0.1.0/opencra_shared/__init__.py +59 -0
- opencra_shared-0.1.0/opencra_shared/clocks.py +128 -0
- opencra_shared-0.1.0/opencra_shared/kev.py +74 -0
- opencra_shared-0.1.0/opencra_shared/models.py +102 -0
- opencra_shared-0.1.0/opencra_shared/py.typed +1 -0
- opencra_shared-0.1.0/opencra_shared/sbom.py +291 -0
- opencra_shared-0.1.0/opencra_shared/vex.py +112 -0
- opencra_shared-0.1.0/pyproject.toml +17 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# Environment variables & secrets (CRITICAL)
|
|
221
|
+
.env
|
|
222
|
+
.env.*
|
|
223
|
+
!.env.example
|
|
224
|
+
|
|
225
|
+
# IDEs & Editor settings
|
|
226
|
+
.vscode/
|
|
227
|
+
.idea/
|
|
228
|
+
|
|
229
|
+
# Local OS metadata
|
|
230
|
+
.DS_Store
|
|
231
|
+
Thumbs.db
|
|
232
|
+
|
|
233
|
+
# SQLite / Local Databases
|
|
234
|
+
*.db
|
|
235
|
+
*.sqlite3
|
|
236
|
+
crashield.db
|
|
237
|
+
|
|
238
|
+
# OpenCRA CLI output test files
|
|
239
|
+
*.cyclonedx.json
|
|
240
|
+
*.spdx.json
|
|
241
|
+
cra-report.pdf
|
|
242
|
+
*.pdf-fallback.txt
|
|
243
|
+
|
|
244
|
+
# Node / frontend
|
|
245
|
+
node_modules/
|
|
246
|
+
apps/web/dist/
|
|
247
|
+
|
|
248
|
+
# uv
|
|
249
|
+
.python-version
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared OpenCRA models, SBOM helpers, and CRA Article 14 clock calculators."""
|
|
2
|
+
|
|
3
|
+
from opencra_shared.clocks import (
|
|
4
|
+
CaseStatus,
|
|
5
|
+
CaseType,
|
|
6
|
+
ClockSet,
|
|
7
|
+
add_one_calendar_month,
|
|
8
|
+
compute_clocks,
|
|
9
|
+
early_warning_due_at,
|
|
10
|
+
final_report_due_at,
|
|
11
|
+
notification_due_at,
|
|
12
|
+
)
|
|
13
|
+
from opencra_shared.kev import KevEntry, index_kev_by_cve, parse_kev_catalog
|
|
14
|
+
from opencra_shared.models import (
|
|
15
|
+
Component,
|
|
16
|
+
FailOn,
|
|
17
|
+
OutputFormat,
|
|
18
|
+
SbomDocument,
|
|
19
|
+
SbomMetadata,
|
|
20
|
+
SbomTool,
|
|
21
|
+
ScanResult,
|
|
22
|
+
VulnMatch,
|
|
23
|
+
)
|
|
24
|
+
from opencra_shared.sbom import (
|
|
25
|
+
cyclonedx_to_spdx,
|
|
26
|
+
normalize_component,
|
|
27
|
+
normalize_purl,
|
|
28
|
+
parse_cyclonedx,
|
|
29
|
+
serialize_cyclonedx,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CaseStatus",
|
|
36
|
+
"CaseType",
|
|
37
|
+
"ClockSet",
|
|
38
|
+
"Component",
|
|
39
|
+
"FailOn",
|
|
40
|
+
"KevEntry",
|
|
41
|
+
"OutputFormat",
|
|
42
|
+
"ScanResult",
|
|
43
|
+
"SbomDocument",
|
|
44
|
+
"SbomMetadata",
|
|
45
|
+
"SbomTool",
|
|
46
|
+
"VulnMatch",
|
|
47
|
+
"add_one_calendar_month",
|
|
48
|
+
"compute_clocks",
|
|
49
|
+
"cyclonedx_to_spdx",
|
|
50
|
+
"early_warning_due_at",
|
|
51
|
+
"final_report_due_at",
|
|
52
|
+
"index_kev_by_cve",
|
|
53
|
+
"normalize_component",
|
|
54
|
+
"normalize_purl",
|
|
55
|
+
"notification_due_at",
|
|
56
|
+
"parse_cyclonedx",
|
|
57
|
+
"parse_kev_catalog",
|
|
58
|
+
"serialize_cyclonedx",
|
|
59
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pure CRA Article 14 clock calculators.
|
|
2
|
+
|
|
3
|
+
A scanner hit is a candidate. Clocks start only when a human sets awareness_at.
|
|
4
|
+
The 14-day final report is measured from fix_available_at for vulnerabilities,
|
|
5
|
+
not from awareness. Severe incidents use one calendar month after notification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import calendar
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CaseType(str, Enum):
|
|
18
|
+
ACTIVELY_EXPLOITED_VULN = "actively_exploited_vuln"
|
|
19
|
+
SEVERE_INCIDENT = "severe_incident"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CaseStatus(str, Enum):
|
|
23
|
+
CANDIDATE = "candidate"
|
|
24
|
+
ACKNOWLEDGED = "acknowledged"
|
|
25
|
+
EARLY_WARNING_SUBMITTED = "early_warning_submitted"
|
|
26
|
+
NOTIFICATION_SUBMITTED = "notification_submitted"
|
|
27
|
+
AWAITING_FIX = "awaiting_fix"
|
|
28
|
+
FINAL_SUBMITTED = "final_submitted"
|
|
29
|
+
CLOSED = "closed"
|
|
30
|
+
DISMISSED = "dismissed"
|
|
31
|
+
OVERDUE_EARLY_WARNING = "overdue_early_warning"
|
|
32
|
+
OVERDUE_NOTIFICATION = "overdue_notification"
|
|
33
|
+
OVERDUE_FINAL = "overdue_final"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ClockSet(BaseModel):
|
|
37
|
+
awareness_at: datetime | None = None
|
|
38
|
+
early_warning_due_at: datetime | None = None
|
|
39
|
+
notification_due_at: datetime | None = None
|
|
40
|
+
fix_available_at: datetime | None = None
|
|
41
|
+
notification_submitted_at: datetime | None = None
|
|
42
|
+
final_report_due_at: datetime | None = None
|
|
43
|
+
overdue: list[str] = Field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ensure_aware(dt: datetime) -> datetime:
|
|
47
|
+
if dt.tzinfo is None:
|
|
48
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
49
|
+
return dt
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_one_calendar_month(dt: datetime) -> datetime:
|
|
53
|
+
"""Add one calendar month, clamping the day to the last valid day."""
|
|
54
|
+
dt = _ensure_aware(dt)
|
|
55
|
+
month = dt.month + 1
|
|
56
|
+
year = dt.year
|
|
57
|
+
if month > 12:
|
|
58
|
+
month = 1
|
|
59
|
+
year += 1
|
|
60
|
+
last_day = calendar.monthrange(year, month)[1]
|
|
61
|
+
return dt.replace(year=year, month=month, day=min(dt.day, last_day))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def early_warning_due_at(awareness_at: datetime) -> datetime:
|
|
65
|
+
return _ensure_aware(awareness_at) + timedelta(hours=24)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def notification_due_at(awareness_at: datetime) -> datetime:
|
|
69
|
+
return _ensure_aware(awareness_at) + timedelta(hours=72)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def final_report_due_at(
|
|
73
|
+
case_type: CaseType,
|
|
74
|
+
*,
|
|
75
|
+
fix_available_at: datetime | None = None,
|
|
76
|
+
notification_submitted_at: datetime | None = None,
|
|
77
|
+
) -> datetime | None:
|
|
78
|
+
if case_type is CaseType.ACTIVELY_EXPLOITED_VULN:
|
|
79
|
+
if fix_available_at is None:
|
|
80
|
+
return None
|
|
81
|
+
return _ensure_aware(fix_available_at) + timedelta(days=14)
|
|
82
|
+
if notification_submitted_at is None:
|
|
83
|
+
return None
|
|
84
|
+
return add_one_calendar_month(_ensure_aware(notification_submitted_at))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compute_clocks(
|
|
88
|
+
case_type: CaseType,
|
|
89
|
+
*,
|
|
90
|
+
awareness_at: datetime | None = None,
|
|
91
|
+
fix_available_at: datetime | None = None,
|
|
92
|
+
notification_submitted_at: datetime | None = None,
|
|
93
|
+
now: datetime | None = None,
|
|
94
|
+
early_warning_submitted: bool = False,
|
|
95
|
+
notification_submitted: bool = False,
|
|
96
|
+
final_submitted: bool = False,
|
|
97
|
+
) -> ClockSet:
|
|
98
|
+
"""Compute due dates. Does nothing if awareness_at is unset (candidate)."""
|
|
99
|
+
now = _ensure_aware(now or datetime.now(timezone.utc))
|
|
100
|
+
clocks = ClockSet(
|
|
101
|
+
awareness_at=_ensure_aware(awareness_at) if awareness_at else None,
|
|
102
|
+
fix_available_at=_ensure_aware(fix_available_at) if fix_available_at else None,
|
|
103
|
+
notification_submitted_at=(
|
|
104
|
+
_ensure_aware(notification_submitted_at) if notification_submitted_at else None
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
if awareness_at is None:
|
|
108
|
+
return clocks
|
|
109
|
+
|
|
110
|
+
clocks.early_warning_due_at = early_warning_due_at(awareness_at)
|
|
111
|
+
clocks.notification_due_at = notification_due_at(awareness_at)
|
|
112
|
+
clocks.final_report_due_at = final_report_due_at(
|
|
113
|
+
case_type,
|
|
114
|
+
fix_available_at=fix_available_at,
|
|
115
|
+
notification_submitted_at=notification_submitted_at,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not early_warning_submitted and now > clocks.early_warning_due_at:
|
|
119
|
+
clocks.overdue.append("early_warning")
|
|
120
|
+
if not notification_submitted and now > clocks.notification_due_at:
|
|
121
|
+
clocks.overdue.append("notification")
|
|
122
|
+
if (
|
|
123
|
+
clocks.final_report_due_at is not None
|
|
124
|
+
and not final_submitted
|
|
125
|
+
and now > clocks.final_report_due_at
|
|
126
|
+
):
|
|
127
|
+
clocks.overdue.append("final_report")
|
|
128
|
+
return clocks
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CISA Known Exploited Vulnerabilities catalog helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KevEntry(BaseModel):
|
|
12
|
+
cve_id: str
|
|
13
|
+
vendor: str | None = None
|
|
14
|
+
product: str | None = None
|
|
15
|
+
vulnerability_name: str | None = None
|
|
16
|
+
date_added: datetime | None = None
|
|
17
|
+
short_description: str | None = None
|
|
18
|
+
required_action: str | None = None
|
|
19
|
+
due_date: datetime | None = None
|
|
20
|
+
known_ransomware: str | None = None
|
|
21
|
+
notes: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_date(value: Any) -> datetime | None:
|
|
25
|
+
if not value or not isinstance(value, str):
|
|
26
|
+
return None
|
|
27
|
+
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
|
|
28
|
+
try:
|
|
29
|
+
return datetime.strptime(value[:19] if "T" in value else value, fmt.replace("Z", ""))
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_kev_catalog(payload: dict[str, Any]) -> list[KevEntry]:
|
|
39
|
+
entries: list[KevEntry] = []
|
|
40
|
+
for raw in payload.get("vulnerabilities") or []:
|
|
41
|
+
if not isinstance(raw, dict):
|
|
42
|
+
continue
|
|
43
|
+
cve = raw.get("cveID") or raw.get("cve_id")
|
|
44
|
+
if not cve:
|
|
45
|
+
continue
|
|
46
|
+
entries.append(
|
|
47
|
+
KevEntry(
|
|
48
|
+
cve_id=str(cve).upper(),
|
|
49
|
+
vendor=raw.get("vendorProject"),
|
|
50
|
+
product=raw.get("product"),
|
|
51
|
+
vulnerability_name=raw.get("vulnerabilityName"),
|
|
52
|
+
date_added=_parse_date(raw.get("dateAdded")),
|
|
53
|
+
short_description=raw.get("shortDescription"),
|
|
54
|
+
required_action=raw.get("requiredAction"),
|
|
55
|
+
due_date=_parse_date(raw.get("dueDate")),
|
|
56
|
+
known_ransomware=raw.get("knownRansomwareCampaignUse"),
|
|
57
|
+
notes=raw.get("notes"),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return entries
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def index_kev_by_cve(entries: list[KevEntry]) -> dict[str, KevEntry]:
|
|
64
|
+
return {entry.cve_id.upper(): entry for entry in entries}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_cves(aliases: list[str], osv_id: str | None = None) -> list[str]:
|
|
68
|
+
"""Collect CVE-IDs from OSV aliases and the primary id."""
|
|
69
|
+
found: list[str] = []
|
|
70
|
+
for value in [*aliases, osv_id or ""]:
|
|
71
|
+
upper = value.upper()
|
|
72
|
+
if upper.startswith("CVE-") and upper not in found:
|
|
73
|
+
found.append(upper)
|
|
74
|
+
return found
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Canonical OpenCRA scan and SBOM models shared by CLI and SaaS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutputFormat(str, Enum):
|
|
14
|
+
TABLE = "table"
|
|
15
|
+
JSON = "json"
|
|
16
|
+
CYCLONEDX = "cyclonedx"
|
|
17
|
+
SPDX = "spdx"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FailOn(str, Enum):
|
|
21
|
+
NONE = "none"
|
|
22
|
+
KEV = "kev"
|
|
23
|
+
CRITICAL = "critical"
|
|
24
|
+
HIGH = "high"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SbomTool(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
version: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SbomMetadata(BaseModel):
|
|
33
|
+
timestamp: datetime | None = None
|
|
34
|
+
name: str | None = None
|
|
35
|
+
version: str | None = None
|
|
36
|
+
component_type: str = "application"
|
|
37
|
+
tools: list[SbomTool] = Field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Component(BaseModel):
|
|
41
|
+
type: str = "library"
|
|
42
|
+
name: str
|
|
43
|
+
version: str | None = None
|
|
44
|
+
purl: str | None = None
|
|
45
|
+
licenses: list[str] = Field(default_factory=list)
|
|
46
|
+
hashes: dict[str, str] = Field(default_factory=dict)
|
|
47
|
+
skipped_reason: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VulnMatch(BaseModel):
|
|
51
|
+
purl: str
|
|
52
|
+
component_name: str | None = None
|
|
53
|
+
component_version: str | None = None
|
|
54
|
+
osv_id: str | None = None
|
|
55
|
+
cve_id: str | None = None
|
|
56
|
+
severity: str | None = None
|
|
57
|
+
cvss_v3: float | None = None
|
|
58
|
+
epss: float | None = None
|
|
59
|
+
in_kev: bool = False
|
|
60
|
+
kev_added_at: datetime | None = None
|
|
61
|
+
kev_ransomware: str | None = None
|
|
62
|
+
aliases: list[str] = Field(default_factory=list)
|
|
63
|
+
status: str = "open"
|
|
64
|
+
vex_status: str | None = None
|
|
65
|
+
summary: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SbomDocument(BaseModel):
|
|
69
|
+
bom_format: str = "CycloneDX"
|
|
70
|
+
spec_version: str = "1.6"
|
|
71
|
+
serial_number: str = Field(default_factory=lambda: f"urn:uuid:{uuid4()}")
|
|
72
|
+
version: int = 1
|
|
73
|
+
metadata: SbomMetadata = Field(default_factory=SbomMetadata)
|
|
74
|
+
components: list[Component] = Field(default_factory=list)
|
|
75
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ScanResult(BaseModel):
|
|
79
|
+
target: str
|
|
80
|
+
scanned_at: datetime
|
|
81
|
+
sbom: SbomDocument
|
|
82
|
+
matches: list[VulnMatch] = Field(default_factory=list)
|
|
83
|
+
skipped_components: list[Component] = Field(default_factory=list)
|
|
84
|
+
offline: bool = False
|
|
85
|
+
warnings: list[str] = Field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def kev_hits(self) -> list[VulnMatch]:
|
|
89
|
+
return [m for m in self.matches if m.in_kev]
|
|
90
|
+
|
|
91
|
+
def fails(self, threshold: FailOn) -> bool:
|
|
92
|
+
if threshold is FailOn.NONE:
|
|
93
|
+
return False
|
|
94
|
+
if threshold is FailOn.KEV:
|
|
95
|
+
return any(m.in_kev for m in self.matches)
|
|
96
|
+
ranks = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
97
|
+
cutoff = 4 if threshold is FailOn.CRITICAL else 3
|
|
98
|
+
for match in self.matches:
|
|
99
|
+
rank = ranks.get((match.severity or "NONE").upper(), 0)
|
|
100
|
+
if rank >= cutoff:
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""CycloneDX 1.6 subset parser and PURL normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from packageurl import PackageURL
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from opencra_shared.models import Component, SbomDocument, SbomMetadata, SbomTool
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("opencra.sbom")
|
|
16
|
+
|
|
17
|
+
# Map common Syft/CycloneDX purl types we synthesize when purl is missing.
|
|
18
|
+
_TYPE_TO_ECOSYSTEM = {
|
|
19
|
+
"pypi": "pypi",
|
|
20
|
+
"npm": "npm",
|
|
21
|
+
"golang": "golang",
|
|
22
|
+
"go": "golang",
|
|
23
|
+
"maven": "maven",
|
|
24
|
+
"cargo": "cargo",
|
|
25
|
+
"nuget": "nuget",
|
|
26
|
+
"gem": "gem",
|
|
27
|
+
"composer": "composer",
|
|
28
|
+
"hex": "hex",
|
|
29
|
+
"pub": "pub",
|
|
30
|
+
"swift": "swift",
|
|
31
|
+
"apk": "apk",
|
|
32
|
+
"deb": "deb",
|
|
33
|
+
"rpm": "rpm",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _has_broken_percent_encoding(text: str) -> bool:
|
|
38
|
+
i = 0
|
|
39
|
+
while i < len(text):
|
|
40
|
+
if text[i] == "%":
|
|
41
|
+
hexpart = text[i + 1 : i + 3]
|
|
42
|
+
if len(hexpart) < 2 or any(c not in "0123456789abcdefABCDEF" for c in hexpart):
|
|
43
|
+
return True
|
|
44
|
+
i += 3
|
|
45
|
+
else:
|
|
46
|
+
i += 1
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_purl(value: str | None) -> str | None:
|
|
51
|
+
"""Parse and re-serialize a PURL, dropping invalid/unencodable qualifiers.
|
|
52
|
+
|
|
53
|
+
Returns None if the value cannot be made into a valid Package URL.
|
|
54
|
+
"""
|
|
55
|
+
if not value or not value.strip():
|
|
56
|
+
return None
|
|
57
|
+
raw = value.strip()
|
|
58
|
+
if "?" in raw and _has_broken_percent_encoding(raw.split("?", 1)[1]):
|
|
59
|
+
raw = raw.split("?", 1)[0].split("#", 1)[0]
|
|
60
|
+
try:
|
|
61
|
+
parsed = PackageURL.from_string(raw)
|
|
62
|
+
return str(parsed)
|
|
63
|
+
except (ValueError, TypeError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
base = raw.split("?", 1)[0].split("#", 1)[0]
|
|
67
|
+
try:
|
|
68
|
+
parsed = PackageURL.from_string(base)
|
|
69
|
+
return str(parsed)
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
logger.warning("Dropping invalid PURL: %s", value)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _licenses_from_cdx(raw: Any) -> list[str]:
|
|
76
|
+
if not raw:
|
|
77
|
+
return []
|
|
78
|
+
out: list[str] = []
|
|
79
|
+
if not isinstance(raw, list):
|
|
80
|
+
return out
|
|
81
|
+
for item in raw:
|
|
82
|
+
if isinstance(item, str):
|
|
83
|
+
out.append(item)
|
|
84
|
+
continue
|
|
85
|
+
if not isinstance(item, dict):
|
|
86
|
+
continue
|
|
87
|
+
license_obj = item.get("license") or item
|
|
88
|
+
if isinstance(license_obj, dict):
|
|
89
|
+
ident = license_obj.get("id") or license_obj.get("name")
|
|
90
|
+
if ident:
|
|
91
|
+
out.append(str(ident))
|
|
92
|
+
expression = item.get("expression")
|
|
93
|
+
if expression:
|
|
94
|
+
out.append(str(expression))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _hashes_from_cdx(raw: Any) -> dict[str, str]:
|
|
99
|
+
hashes: dict[str, str] = {}
|
|
100
|
+
if not isinstance(raw, list):
|
|
101
|
+
return hashes
|
|
102
|
+
for item in raw:
|
|
103
|
+
if isinstance(item, dict) and item.get("alg") and item.get("content"):
|
|
104
|
+
hashes[str(item["alg"])] = str(item["content"])
|
|
105
|
+
return hashes
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _synthesize_purl(component: dict[str, Any]) -> str | None:
|
|
109
|
+
name = component.get("name")
|
|
110
|
+
version = component.get("version")
|
|
111
|
+
if not name or not version:
|
|
112
|
+
return None
|
|
113
|
+
cdx_type = str(component.get("type") or "library").lower()
|
|
114
|
+
purl_type = component.get("purl-type") or _TYPE_TO_ECOSYSTEM.get(cdx_type)
|
|
115
|
+
# Prefer bom-ref hints like pkg:pypi/...
|
|
116
|
+
bom_ref = str(component.get("bom-ref") or "")
|
|
117
|
+
if bom_ref.startswith("pkg:"):
|
|
118
|
+
return normalize_purl(bom_ref)
|
|
119
|
+
if not purl_type:
|
|
120
|
+
# Last resort: generic generic type is invalid; skip synthesis.
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
return str(PackageURL(type=purl_type, name=str(name), version=str(version)))
|
|
124
|
+
except (ValueError, TypeError):
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalize_component(raw: dict[str, Any]) -> Component | None:
|
|
129
|
+
name = raw.get("name")
|
|
130
|
+
if not name:
|
|
131
|
+
return None
|
|
132
|
+
purl = normalize_purl(raw.get("purl")) or _synthesize_purl(raw)
|
|
133
|
+
skipped = None if purl else "invalid or missing PURL"
|
|
134
|
+
try:
|
|
135
|
+
return Component(
|
|
136
|
+
type=str(raw.get("type") or "library"),
|
|
137
|
+
name=str(name),
|
|
138
|
+
version=str(raw["version"]) if raw.get("version") is not None else None,
|
|
139
|
+
purl=purl,
|
|
140
|
+
licenses=_licenses_from_cdx(raw.get("licenses")),
|
|
141
|
+
hashes=_hashes_from_cdx(raw.get("hashes")),
|
|
142
|
+
skipped_reason=skipped,
|
|
143
|
+
)
|
|
144
|
+
except ValidationError:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_cyclonedx(payload: dict[str, Any]) -> SbomDocument:
|
|
149
|
+
"""Parse a CycloneDX JSON document, dropping unknown fields and bad components."""
|
|
150
|
+
metadata_raw = payload.get("metadata") or {}
|
|
151
|
+
component_raw = metadata_raw.get("component") or {}
|
|
152
|
+
tools: list[SbomTool] = []
|
|
153
|
+
tools_raw = metadata_raw.get("tools")
|
|
154
|
+
# CycloneDX 1.5+ uses tools.components; 1.4 uses a list.
|
|
155
|
+
if isinstance(tools_raw, dict):
|
|
156
|
+
for item in tools_raw.get("components") or tools_raw.get("services") or []:
|
|
157
|
+
if isinstance(item, dict) and item.get("name"):
|
|
158
|
+
tools.append(SbomTool(name=str(item["name"]), version=item.get("version")))
|
|
159
|
+
elif isinstance(tools_raw, list):
|
|
160
|
+
for item in tools_raw:
|
|
161
|
+
if isinstance(item, dict) and item.get("name"):
|
|
162
|
+
tools.append(SbomTool(name=str(item["name"]), version=item.get("version")))
|
|
163
|
+
|
|
164
|
+
timestamp = metadata_raw.get("timestamp")
|
|
165
|
+
parsed_ts: datetime | None = None
|
|
166
|
+
if isinstance(timestamp, str):
|
|
167
|
+
try:
|
|
168
|
+
parsed_ts = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
169
|
+
except ValueError:
|
|
170
|
+
parsed_ts = None
|
|
171
|
+
|
|
172
|
+
components: list[Component] = []
|
|
173
|
+
for raw in payload.get("components") or []:
|
|
174
|
+
if not isinstance(raw, dict):
|
|
175
|
+
continue
|
|
176
|
+
component = normalize_component(raw)
|
|
177
|
+
if component is not None:
|
|
178
|
+
components.append(component)
|
|
179
|
+
|
|
180
|
+
serial = payload.get("serialNumber") or f"urn:uuid:{uuid4()}"
|
|
181
|
+
return SbomDocument(
|
|
182
|
+
bom_format=str(payload.get("bomFormat") or "CycloneDX"),
|
|
183
|
+
spec_version=str(payload.get("specVersion") or "1.6"),
|
|
184
|
+
serial_number=str(serial),
|
|
185
|
+
version=int(payload.get("version") or 1),
|
|
186
|
+
metadata=SbomMetadata(
|
|
187
|
+
timestamp=parsed_ts or datetime.now(timezone.utc),
|
|
188
|
+
name=component_raw.get("name"),
|
|
189
|
+
version=component_raw.get("version"),
|
|
190
|
+
component_type=str(component_raw.get("type") or "application"),
|
|
191
|
+
tools=tools,
|
|
192
|
+
),
|
|
193
|
+
components=components,
|
|
194
|
+
raw=payload,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def serialize_cyclonedx(document: SbomDocument) -> dict[str, Any]:
|
|
199
|
+
"""Rebuild a CycloneDX-shaped dict from the normalized document."""
|
|
200
|
+
if document.raw:
|
|
201
|
+
# Preserve original Syft document when present; overlay serial if missing.
|
|
202
|
+
out = dict(document.raw)
|
|
203
|
+
out.setdefault("bomFormat", "CycloneDX")
|
|
204
|
+
out.setdefault("specVersion", document.spec_version)
|
|
205
|
+
out.setdefault("serialNumber", document.serial_number)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
components = []
|
|
209
|
+
for component in document.components:
|
|
210
|
+
item: dict[str, Any] = {
|
|
211
|
+
"type": component.type,
|
|
212
|
+
"name": component.name,
|
|
213
|
+
}
|
|
214
|
+
if component.version:
|
|
215
|
+
item["version"] = component.version
|
|
216
|
+
if component.purl:
|
|
217
|
+
item["purl"] = component.purl
|
|
218
|
+
if component.licenses:
|
|
219
|
+
item["licenses"] = [{"license": {"id": lic}} for lic in component.licenses]
|
|
220
|
+
if component.hashes:
|
|
221
|
+
item["hashes"] = [{"alg": k, "content": v} for k, v in component.hashes.items()]
|
|
222
|
+
components.append(item)
|
|
223
|
+
|
|
224
|
+
ts = document.metadata.timestamp or datetime.now(timezone.utc)
|
|
225
|
+
return {
|
|
226
|
+
"bomFormat": "CycloneDX",
|
|
227
|
+
"specVersion": document.spec_version,
|
|
228
|
+
"serialNumber": document.serial_number,
|
|
229
|
+
"version": document.version,
|
|
230
|
+
"metadata": {
|
|
231
|
+
"timestamp": ts.isoformat(),
|
|
232
|
+
"component": {
|
|
233
|
+
"type": document.metadata.component_type,
|
|
234
|
+
"name": document.metadata.name or "unknown",
|
|
235
|
+
"version": document.metadata.version or "0.0.0",
|
|
236
|
+
},
|
|
237
|
+
"tools": {
|
|
238
|
+
"components": [
|
|
239
|
+
{"name": t.name, **({"version": t.version} if t.version else {})}
|
|
240
|
+
for t in document.metadata.tools
|
|
241
|
+
]
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
"components": components,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cyclonedx_to_spdx(document: SbomDocument) -> dict[str, Any]:
|
|
249
|
+
"""Minimal SPDX 2.3 JSON adapter. Not a full SPDX implementation."""
|
|
250
|
+
packages = []
|
|
251
|
+
for component in document.components:
|
|
252
|
+
pkg: dict[str, Any] = {
|
|
253
|
+
"name": component.name,
|
|
254
|
+
"SPDXID": f"SPDXRef-{_spdx_id(component.name, component.version)}",
|
|
255
|
+
"downloadLocation": "NOASSERTION",
|
|
256
|
+
"filesAnalyzed": False,
|
|
257
|
+
}
|
|
258
|
+
if component.version:
|
|
259
|
+
pkg["versionInfo"] = component.version
|
|
260
|
+
if component.purl:
|
|
261
|
+
pkg["externalRefs"] = [
|
|
262
|
+
{
|
|
263
|
+
"referenceCategory": "PACKAGE-MANAGER",
|
|
264
|
+
"referenceType": "purl",
|
|
265
|
+
"referenceLocator": component.purl,
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
if component.licenses:
|
|
269
|
+
pkg["licenseConcluded"] = component.licenses[0]
|
|
270
|
+
packages.append(pkg)
|
|
271
|
+
|
|
272
|
+
created = (document.metadata.timestamp or datetime.now(timezone.utc)).strftime(
|
|
273
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
274
|
+
)
|
|
275
|
+
return {
|
|
276
|
+
"spdxVersion": "SPDX-2.3",
|
|
277
|
+
"dataLicense": "CC0-1.0",
|
|
278
|
+
"SPDXID": "SPDXRef-DOCUMENT",
|
|
279
|
+
"name": document.metadata.name or document.serial_number,
|
|
280
|
+
"documentNamespace": document.serial_number,
|
|
281
|
+
"creationInfo": {
|
|
282
|
+
"created": created,
|
|
283
|
+
"creators": ["Tool: opencra-0.1.0"],
|
|
284
|
+
},
|
|
285
|
+
"packages": packages,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _spdx_id(name: str, version: str | None) -> str:
|
|
290
|
+
base = f"{name}-{version or 'unknown'}"
|
|
291
|
+
return "".join(ch if ch.isalnum() else "-" for ch in base)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""OpenVEX document models and candidate-dismissal policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, model_validator
|
|
11
|
+
|
|
12
|
+
from opencra_shared.clocks import CaseStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VexStatus(str, Enum):
|
|
16
|
+
NOT_AFFECTED = "not_affected"
|
|
17
|
+
AFFECTED = "affected"
|
|
18
|
+
FIXED = "fixed"
|
|
19
|
+
UNDER_INVESTIGATION = "under_investigation"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VexJustification(str, Enum):
|
|
23
|
+
COMPONENT_NOT_PRESENT = "component_not_present"
|
|
24
|
+
VULNERABLE_CODE_NOT_PRESENT = "vulnerable_code_not_present"
|
|
25
|
+
VULNERABLE_CODE_NOT_IN_EXECUTE_PATH = "vulnerable_code_not_in_execute_path"
|
|
26
|
+
VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY = (
|
|
27
|
+
"vulnerable_code_cannot_be_controlled_by_adversary"
|
|
28
|
+
)
|
|
29
|
+
INLINE_MITIGATIONS_ALREADY_EXIST = "inline_mitigations_already_exist"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ACKNOWLEDGED_STATUSES = {
|
|
33
|
+
CaseStatus.ACKNOWLEDGED,
|
|
34
|
+
CaseStatus.EARLY_WARNING_SUBMITTED,
|
|
35
|
+
CaseStatus.NOTIFICATION_SUBMITTED,
|
|
36
|
+
CaseStatus.AWAITING_FIX,
|
|
37
|
+
CaseStatus.OVERDUE_EARLY_WARNING,
|
|
38
|
+
CaseStatus.OVERDUE_NOTIFICATION,
|
|
39
|
+
CaseStatus.OVERDUE_FINAL,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class VexProduct(BaseModel):
|
|
44
|
+
id: str = Field(alias="@id")
|
|
45
|
+
|
|
46
|
+
model_config = {"populate_by_name": True}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VexVulnerability(BaseModel):
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class VexStatement(BaseModel):
|
|
54
|
+
vulnerability: VexVulnerability
|
|
55
|
+
products: list[VexProduct] = Field(default_factory=list)
|
|
56
|
+
status: VexStatus
|
|
57
|
+
justification: VexJustification | None = None
|
|
58
|
+
impact_statement: str | None = None
|
|
59
|
+
action_statement: str | None = None
|
|
60
|
+
status_notes: str | None = None
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def require_not_affected_evidence(self) -> VexStatement:
|
|
64
|
+
if self.status is VexStatus.NOT_AFFECTED:
|
|
65
|
+
if self.justification is None and not self.impact_statement:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"not_affected requires a justification label or an impact_statement"
|
|
68
|
+
)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OpenVexDocument(BaseModel):
|
|
73
|
+
context: str = Field(
|
|
74
|
+
default="https://openvex.dev/ns/v0.2.0",
|
|
75
|
+
alias="@context",
|
|
76
|
+
)
|
|
77
|
+
id: str = Field(default_factory=lambda: f"https://open.cra/vex/{uuid4()}", alias="@id")
|
|
78
|
+
author: str = "OpenCRA"
|
|
79
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
80
|
+
version: int = 1
|
|
81
|
+
statements: list[VexStatement] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
model_config = {"populate_by_name": True}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class VexApplyResult(BaseModel):
|
|
87
|
+
applied: bool
|
|
88
|
+
requires_override: bool = False
|
|
89
|
+
reason: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def can_dismiss_case(case_status: CaseStatus) -> VexApplyResult:
|
|
93
|
+
"""VEX may dismiss candidates. Acknowledged Art. 14 cases need an override."""
|
|
94
|
+
if case_status is CaseStatus.CANDIDATE:
|
|
95
|
+
return VexApplyResult(applied=True, reason="candidate_dismissed")
|
|
96
|
+
if case_status is CaseStatus.DISMISSED:
|
|
97
|
+
return VexApplyResult(applied=False, reason="already_dismissed")
|
|
98
|
+
if case_status in ACKNOWLEDGED_STATUSES:
|
|
99
|
+
return VexApplyResult(
|
|
100
|
+
applied=False,
|
|
101
|
+
requires_override=True,
|
|
102
|
+
reason="acknowledged_case_requires_compliance_officer_override",
|
|
103
|
+
)
|
|
104
|
+
return VexApplyResult(applied=False, reason=f"status_{case_status.value}_not_dismissable")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_openvex(payload: dict[str, Any]) -> OpenVexDocument:
|
|
108
|
+
return OpenVexDocument.model_validate(payload)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def serialize_openvex(document: OpenVexDocument) -> dict[str, Any]:
|
|
112
|
+
return document.model_dump(by_alias=True, mode="json")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opencra-shared"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared Pydantic models, CycloneDX subset, and CRA clock calculators."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = { text = "Apache-2.0" }
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.9",
|
|
9
|
+
"packageurl-python>=0.16",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["opencra_shared"]
|