opencra-shared 0.1.0__py3-none-any.whl

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,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
opencra_shared/kev.py ADDED
@@ -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
+
opencra_shared/sbom.py ADDED
@@ -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)
opencra_shared/vex.py ADDED
@@ -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,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,10 @@
1
+ opencra_shared/__init__.py,sha256=11E2zKrkBuo3HZis9Wr-wbro05ipvMpjTwgFSdYeUnA,1232
2
+ opencra_shared/clocks.py,sha256=Iq6WiOe9m5cefUusYa-OYlsIxoiQKXZ22DAlStmrBjA,4325
3
+ opencra_shared/kev.py,sha256=FZ-wj4o2-0O5jrIgQ7qu4qo59kOPAwGeW1DbpyR8n9w,2457
4
+ opencra_shared/models.py,sha256=KKQMzF8SCn-FJBxcRh9Gl1cRexPbZgsgTTZWROHQFq4,2880
5
+ opencra_shared/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ opencra_shared/sbom.py,sha256=5hHVJ-R3rNRt6CmObPtb2BDe_E8jaG33TfRGqcRoDjM,9947
7
+ opencra_shared/vex.py,sha256=1mvOgHP-hyYt3OOm42AqCx19emMbcH0I5Xf_vDKitiU,3567
8
+ opencra_shared-0.1.0.dist-info/METADATA,sha256=8NRHoevXOSz3gpXLWhRpgDicDx7FBSKAzB3OLbL1zvM,248
9
+ opencra_shared-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ opencra_shared-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any