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.
@@ -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,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: opencra-shared
3
+ Version: 0.1.0
4
+ Summary: Shared Pydantic models, CycloneDX subset, and CRA clock calculators.
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: packageurl-python>=0.16
8
+ Requires-Dist: pydantic>=2.9
@@ -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,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"]