cernus-plugins 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.
- cernus_plugins/__init__.py +5 -0
- cernus_plugins/cyclonedx.py +113 -0
- cernus_plugins/genesis.py +318 -0
- cernus_plugins/oscal.py +249 -0
- cernus_plugins/sarif.py +158 -0
- cernus_plugins/syft.py +50 -0
- cernus_plugins/trivy.py +294 -0
- cernus_plugins-0.1.0.dist-info/METADATA +35 -0
- cernus_plugins-0.1.0.dist-info/RECORD +12 -0
- cernus_plugins-0.1.0.dist-info/WHEEL +4 -0
- cernus_plugins-0.1.0.dist-info/entry_points.txt +13 -0
- cernus_plugins-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""CycloneDX SBOM connector: the document is stored as-is; the subject
|
|
2
|
+
(image/application) becomes an Asset so findings from a later scan reconcile
|
|
3
|
+
to the same asset id.
|
|
4
|
+
|
|
5
|
+
Components are *not* turned into assets — that explodes the graph. They are
|
|
6
|
+
queryable through the stored SBOM (Phase 1: new-CVE matching).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cernus_core.model import Asset, EvidenceRecord, IngestBatch, Sbom, Source, new_id
|
|
16
|
+
from cernus_core.plugins import Connector, register_connector
|
|
17
|
+
|
|
18
|
+
from .trivy import normalize_ref
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@register_connector
|
|
22
|
+
class CycloneDXConnector(Connector):
|
|
23
|
+
name = "cyclonedx"
|
|
24
|
+
description = "CycloneDX JSON SBOMs (syft, trivy, cdxgen, …)"
|
|
25
|
+
tools = ("syft", "trivy", "cdxgen")
|
|
26
|
+
|
|
27
|
+
def detect(self, path: Path, document: dict[str, Any] | None = None) -> bool:
|
|
28
|
+
return bool(document) and document.get("bomFormat") == "CycloneDX"
|
|
29
|
+
|
|
30
|
+
def ingest(
|
|
31
|
+
self, path: Path, document: dict[str, Any] | None = None, **context: Any
|
|
32
|
+
) -> IngestBatch:
|
|
33
|
+
doc = document if document is not None else json.loads(path.read_text())
|
|
34
|
+
meta = doc.get("metadata") or {}
|
|
35
|
+
tools = _tool_names(meta)
|
|
36
|
+
source = Source(tool=tools[0] if tools else "cyclonedx", connector=self.name)
|
|
37
|
+
batch = IngestBatch(source=source)
|
|
38
|
+
|
|
39
|
+
subject = meta.get("component") or {}
|
|
40
|
+
name = subject.get("name") or path.stem
|
|
41
|
+
version = subject.get("version")
|
|
42
|
+
ref = f"{name}:{version}" if version else name
|
|
43
|
+
kind = {"container": "container_image", "application": "service"}.get(
|
|
44
|
+
subject.get("type", ""), "other"
|
|
45
|
+
)
|
|
46
|
+
digest = _digest(subject)
|
|
47
|
+
facet = normalize_ref(ref) if kind == "container_image" else (subject.get("purl") or ref)
|
|
48
|
+
asset_id = context.get("asset_id") or Asset.id_for(kind, facet)
|
|
49
|
+
if not context.get("asset_id"):
|
|
50
|
+
identifiers = (
|
|
51
|
+
{"image_ref": ref}
|
|
52
|
+
if kind == "container_image"
|
|
53
|
+
else {"purl": subject.get("purl", ref)}
|
|
54
|
+
)
|
|
55
|
+
if digest:
|
|
56
|
+
identifiers["digest"] = digest
|
|
57
|
+
batch.assets.append(
|
|
58
|
+
Asset(
|
|
59
|
+
id=asset_id,
|
|
60
|
+
asset_kind=kind,
|
|
61
|
+
name=ref,
|
|
62
|
+
identifiers=identifiers,
|
|
63
|
+
system_id=context.get("system_id"),
|
|
64
|
+
deployment_id=context.get("deployment_id"),
|
|
65
|
+
environment=context.get("environment", "unknown"),
|
|
66
|
+
source=source,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
sbom_id = f"sbom_{doc.get('serialNumber', new_id('x'))}".replace("urn:uuid:", "")
|
|
71
|
+
batch.sboms.append(
|
|
72
|
+
Sbom(id=sbom_id, format="cyclonedx-json", document=doc, asset_id=asset_id)
|
|
73
|
+
)
|
|
74
|
+
components = doc.get("components") or []
|
|
75
|
+
batch.evidence.append(
|
|
76
|
+
EvidenceRecord(
|
|
77
|
+
id=new_id("ev"),
|
|
78
|
+
record_kind="sbom",
|
|
79
|
+
subject_kind="asset",
|
|
80
|
+
subject_id=asset_id,
|
|
81
|
+
produced_by=" ".join(tools) or "cyclonedx",
|
|
82
|
+
summary=f"SBOM for {ref}: {len(components)} components",
|
|
83
|
+
payload={
|
|
84
|
+
"sbom_id": sbom_id,
|
|
85
|
+
"spec_version": doc.get("specVersion"),
|
|
86
|
+
"component_count": len(components),
|
|
87
|
+
"timestamp": meta.get("timestamp"),
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return batch
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _tool_names(meta: dict[str, Any]) -> list[str]:
|
|
95
|
+
tools = meta.get("tools")
|
|
96
|
+
names: list[str] = []
|
|
97
|
+
if isinstance(tools, dict): # CycloneDX 1.5+
|
|
98
|
+
for c in tools.get("components") or []:
|
|
99
|
+
names.append(f"{c.get('name')} {c.get('version', '')}".strip())
|
|
100
|
+
elif isinstance(tools, list): # 1.4
|
|
101
|
+
for t in tools:
|
|
102
|
+
names.append(f"{t.get('name')} {t.get('version', '')}".strip())
|
|
103
|
+
return names
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _digest(component: dict[str, Any]) -> str | None:
|
|
107
|
+
for h in component.get("hashes") or []:
|
|
108
|
+
if h.get("alg", "").upper().startswith("SHA-256"):
|
|
109
|
+
return h.get("content")
|
|
110
|
+
purl = component.get("purl") or ""
|
|
111
|
+
if "sha256" in purl:
|
|
112
|
+
return purl.split("sha256")[-1].lstrip(":%3A")[:64]
|
|
113
|
+
return None
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Genesis connector — the seam between build-time proof and Day-2 posture.
|
|
2
|
+
|
|
3
|
+
Accepts any of:
|
|
4
|
+
|
|
5
|
+
* a Genesis **EvidencePackage** JSON (``kind: EvidencePackage``), as returned by
|
|
6
|
+
``GET /api/projects/{id}/evidence`` or stored in ``Evidence.package``;
|
|
7
|
+
* a **DeliveryManifest** YAML/JSON (``kind: DeliveryManifest``);
|
|
8
|
+
* a Genesis **workspace directory** containing ``.genesis-work/manifest.yaml``
|
|
9
|
+
and optionally ``.genesis-work/evidence.json`` and
|
|
10
|
+
``.genesis-work/deployment.json`` (written by the Genesis post-deploy hook).
|
|
11
|
+
|
|
12
|
+
Produces one System (from the manifest's ``service``), one Deployment (carrying
|
|
13
|
+
the manifest and evidence verbatim), and one Finding per evidence finding —
|
|
14
|
+
mapped losslessly because the Cernus Finding is a superset of the genesis-gates
|
|
15
|
+
finding shape.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
from cernus_core.model import (
|
|
27
|
+
Asset,
|
|
28
|
+
Component,
|
|
29
|
+
Deployment,
|
|
30
|
+
EvidenceRecord,
|
|
31
|
+
Finding,
|
|
32
|
+
IngestBatch,
|
|
33
|
+
Source,
|
|
34
|
+
System,
|
|
35
|
+
canonical_hash,
|
|
36
|
+
new_id,
|
|
37
|
+
now_iso,
|
|
38
|
+
)
|
|
39
|
+
from cernus_core.plugins import Connector, register_connector
|
|
40
|
+
|
|
41
|
+
from .trivy import normalize_ref
|
|
42
|
+
|
|
43
|
+
_GATE_CATEGORY = {
|
|
44
|
+
"secret-scan": "secret",
|
|
45
|
+
"dependency-scan": "vulnerability",
|
|
46
|
+
"container-scan": "vulnerability",
|
|
47
|
+
"sast": "sast",
|
|
48
|
+
"iac-policy-check": "misconfiguration",
|
|
49
|
+
"design-lint": "other",
|
|
50
|
+
"unit-tests": "other",
|
|
51
|
+
"sbom": "other",
|
|
52
|
+
"risk-classify": "other",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
WORK_DIR = ".genesis-work"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@register_connector
|
|
59
|
+
class GenesisConnector(Connector):
|
|
60
|
+
name = "genesis"
|
|
61
|
+
description = (
|
|
62
|
+
"Genesis EvidencePackage / DeliveryManifest / workspace → System, Deployment, Findings"
|
|
63
|
+
)
|
|
64
|
+
tools = ("genesis-gates", "genesis-server")
|
|
65
|
+
|
|
66
|
+
def detect(self, path: Path, document: dict[str, Any] | None = None) -> bool:
|
|
67
|
+
if document and document.get("kind") in {"EvidencePackage", "DeliveryManifest"}:
|
|
68
|
+
return True
|
|
69
|
+
if path.is_dir() and (path / WORK_DIR / "manifest.yaml").exists():
|
|
70
|
+
return True
|
|
71
|
+
if path.suffix in {".yaml", ".yml"}:
|
|
72
|
+
try:
|
|
73
|
+
doc = yaml.safe_load(path.read_text())
|
|
74
|
+
return isinstance(doc, dict) and doc.get("kind") == "DeliveryManifest"
|
|
75
|
+
except yaml.YAMLError:
|
|
76
|
+
return False
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def ingest(
|
|
80
|
+
self, path: Path, document: dict[str, Any] | None = None, **context: Any
|
|
81
|
+
) -> IngestBatch:
|
|
82
|
+
manifest, evidence, deployment_info = self._load(path, document)
|
|
83
|
+
return self.build(manifest, evidence, deployment_info, fallback_name=path.stem, **context)
|
|
84
|
+
|
|
85
|
+
def build(
|
|
86
|
+
self,
|
|
87
|
+
manifest: dict[str, Any] | None,
|
|
88
|
+
evidence: dict[str, Any] | None,
|
|
89
|
+
deployment_info: dict[str, Any] | None = None,
|
|
90
|
+
fallback_name: str = "genesis-app",
|
|
91
|
+
**context: Any,
|
|
92
|
+
) -> IngestBatch:
|
|
93
|
+
"""Build a batch from already-loaded documents (used by the server's
|
|
94
|
+
``POST /api/ingest/genesis`` as well as by :meth:`ingest`)."""
|
|
95
|
+
deployment_info = deployment_info or {}
|
|
96
|
+
source = Source(tool="genesis-gates", connector=self.name)
|
|
97
|
+
batch = IngestBatch(source=source)
|
|
98
|
+
seen = now_iso()
|
|
99
|
+
|
|
100
|
+
service = (manifest or {}).get("service") or {}
|
|
101
|
+
release = (manifest or {}).get("release") or {}
|
|
102
|
+
change = (manifest or {}).get("change") or {}
|
|
103
|
+
service_name = (
|
|
104
|
+
service.get("name")
|
|
105
|
+
or (evidence or {}).get("service")
|
|
106
|
+
or deployment_info.get("service")
|
|
107
|
+
or context.get("system_name")
|
|
108
|
+
or fallback_name
|
|
109
|
+
)
|
|
110
|
+
system_id = (
|
|
111
|
+
context.get("system_id") or f"sys_{canonical_hash(['genesis', service_name])[:16]}"
|
|
112
|
+
)
|
|
113
|
+
batch.systems.append(
|
|
114
|
+
System(
|
|
115
|
+
id=system_id,
|
|
116
|
+
name=service_name,
|
|
117
|
+
owner=service.get("owner") or (evidence or {}).get("owner"),
|
|
118
|
+
criticality=service.get("criticality", "unknown"),
|
|
119
|
+
data_classification=service.get("data_classification", "unknown"),
|
|
120
|
+
genesis_project_id=deployment_info.get("project_id")
|
|
121
|
+
or context.get("genesis_project_id"),
|
|
122
|
+
repository_url=deployment_info.get("repository_url"),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
change_id = change.get("id") or (evidence or {}).get("change_id")
|
|
127
|
+
deployed_at = (
|
|
128
|
+
deployment_info.get("deployed_at") or (evidence or {}).get("created_at") or seen
|
|
129
|
+
)
|
|
130
|
+
environment = (
|
|
131
|
+
deployment_info.get("environment") or context.get("environment") or "development"
|
|
132
|
+
)
|
|
133
|
+
deployment_id = (
|
|
134
|
+
context.get("deployment_id")
|
|
135
|
+
or f"dep_{canonical_hash([system_id, change_id, deployed_at])[:16]}"
|
|
136
|
+
)
|
|
137
|
+
artifact_refs = [
|
|
138
|
+
{"name": a.get("name", "?"), "image_ref": a.get("image_ref"), "digest": a.get("digest")}
|
|
139
|
+
for a in deployment_info.get("artifacts") or []
|
|
140
|
+
]
|
|
141
|
+
batch.deployments.append(
|
|
142
|
+
Deployment(
|
|
143
|
+
id=deployment_id,
|
|
144
|
+
system_id=system_id,
|
|
145
|
+
environment=environment,
|
|
146
|
+
deployed_at=deployed_at,
|
|
147
|
+
deployed_by=deployment_info.get("deployed_by"),
|
|
148
|
+
change_id=change_id,
|
|
149
|
+
artifact_refs=artifact_refs,
|
|
150
|
+
manifest=manifest,
|
|
151
|
+
evidence=evidence,
|
|
152
|
+
preview_url=deployment_info.get("preview_url"),
|
|
153
|
+
source=source,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# One asset per deployed artifact (compose service / image) so later
|
|
158
|
+
# image scans reconcile to the same ids. Falls back to one service asset.
|
|
159
|
+
asset_ids: dict[str, str] = {}
|
|
160
|
+
for a in artifact_refs:
|
|
161
|
+
facet = normalize_ref(a.get("image_ref") or a["name"])
|
|
162
|
+
aid = Asset.id_for("container_image", facet)
|
|
163
|
+
asset_ids[a["name"]] = aid
|
|
164
|
+
ident = {
|
|
165
|
+
k: v
|
|
166
|
+
for k, v in (("image_ref", a.get("image_ref")), ("digest", a.get("digest")))
|
|
167
|
+
if v
|
|
168
|
+
}
|
|
169
|
+
batch.assets.append(
|
|
170
|
+
Asset(
|
|
171
|
+
id=aid,
|
|
172
|
+
asset_kind="container_image",
|
|
173
|
+
name=a.get("image_ref") or a["name"],
|
|
174
|
+
identifiers=ident,
|
|
175
|
+
system_id=system_id,
|
|
176
|
+
deployment_id=deployment_id,
|
|
177
|
+
environment=environment,
|
|
178
|
+
data_classification=service.get("data_classification", "unknown"),
|
|
179
|
+
owner=service.get("owner"),
|
|
180
|
+
source=source,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
service_asset_id = Asset.id_for("service", system_id)
|
|
184
|
+
batch.assets.append(
|
|
185
|
+
Asset(
|
|
186
|
+
id=service_asset_id,
|
|
187
|
+
asset_kind="service",
|
|
188
|
+
name=service_name,
|
|
189
|
+
identifiers={"url": deployment_info.get("preview_url")}
|
|
190
|
+
if deployment_info.get("preview_url")
|
|
191
|
+
else {},
|
|
192
|
+
system_id=system_id,
|
|
193
|
+
deployment_id=deployment_id,
|
|
194
|
+
environment=environment,
|
|
195
|
+
data_classification=service.get("data_classification", "unknown"),
|
|
196
|
+
owner=service.get("owner"),
|
|
197
|
+
source=source,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for f in (evidence or {}).get("findings") or []:
|
|
202
|
+
gate = f.get("gate")
|
|
203
|
+
component = _component_from_text(f.get("title"), f.get("remediation"))
|
|
204
|
+
batch.findings.append(
|
|
205
|
+
Finding(
|
|
206
|
+
id=new_id("fnd"),
|
|
207
|
+
title=f.get("title", gate or "finding"),
|
|
208
|
+
severity=f.get("severity"),
|
|
209
|
+
source=Source(tool="genesis-gates", connector=self.name),
|
|
210
|
+
source_ref=f.get("title"),
|
|
211
|
+
gate=gate,
|
|
212
|
+
category=_GATE_CATEGORY.get(gate or "", "other"),
|
|
213
|
+
detail=f.get("detail"),
|
|
214
|
+
location=f.get("location"),
|
|
215
|
+
remediation=f.get("remediation"),
|
|
216
|
+
component=component,
|
|
217
|
+
asset_id=service_asset_id,
|
|
218
|
+
deployment_id=deployment_id,
|
|
219
|
+
system_id=system_id,
|
|
220
|
+
first_seen=deployed_at,
|
|
221
|
+
last_seen=seen,
|
|
222
|
+
raw=f,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if evidence:
|
|
227
|
+
batch.evidence.append(
|
|
228
|
+
EvidenceRecord(
|
|
229
|
+
id=new_id("ev"),
|
|
230
|
+
record_kind="gate_run",
|
|
231
|
+
subject_kind="deployment",
|
|
232
|
+
subject_id=deployment_id,
|
|
233
|
+
produced_by="genesis-gates",
|
|
234
|
+
summary=(
|
|
235
|
+
f"gates {evidence.get('status')} for change {change_id}: "
|
|
236
|
+
f"{len(evidence.get('gate_results') or [])} gates, "
|
|
237
|
+
f"{len(evidence.get('findings') or [])} findings"
|
|
238
|
+
),
|
|
239
|
+
payload={
|
|
240
|
+
"status": evidence.get("status"),
|
|
241
|
+
"risk_level": evidence.get("risk_level") or release.get("risk_level"),
|
|
242
|
+
"gate_results": [
|
|
243
|
+
{k: g.get(k) for k in ("gate", "status", "summary", "duration_s")}
|
|
244
|
+
for g in evidence.get("gate_results") or []
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
created_at=evidence.get("created_at") or seen,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
if deployment_info:
|
|
251
|
+
batch.evidence.append(
|
|
252
|
+
EvidenceRecord(
|
|
253
|
+
id=new_id("ev"),
|
|
254
|
+
record_kind="deploy",
|
|
255
|
+
subject_kind="deployment",
|
|
256
|
+
subject_id=deployment_id,
|
|
257
|
+
produced_by="genesis-server",
|
|
258
|
+
summary=f"deployed {service_name} to {environment}",
|
|
259
|
+
payload=deployment_info,
|
|
260
|
+
created_at=deployed_at,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return batch
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _load(path: Path, document: dict[str, Any] | None) -> tuple[dict | None, dict | None, dict]:
|
|
267
|
+
manifest: dict | None = None
|
|
268
|
+
evidence: dict | None = None
|
|
269
|
+
deployment: dict = {}
|
|
270
|
+
if document is not None:
|
|
271
|
+
if document.get("kind") == "EvidencePackage":
|
|
272
|
+
evidence = document
|
|
273
|
+
manifest = document.get("manifest") or None
|
|
274
|
+
elif document.get("kind") == "DeliveryManifest":
|
|
275
|
+
manifest = document
|
|
276
|
+
return manifest, evidence, deployment
|
|
277
|
+
if path.is_dir():
|
|
278
|
+
work = path / WORK_DIR
|
|
279
|
+
if (work / "manifest.yaml").exists():
|
|
280
|
+
manifest = yaml.safe_load((work / "manifest.yaml").read_text())
|
|
281
|
+
if (work / "evidence.json").exists():
|
|
282
|
+
evidence = json.loads((work / "evidence.json").read_text())
|
|
283
|
+
if (work / "deployment.json").exists():
|
|
284
|
+
deployment = json.loads((work / "deployment.json").read_text())
|
|
285
|
+
return manifest, evidence, deployment
|
|
286
|
+
text = path.read_text()
|
|
287
|
+
doc = yaml.safe_load(text) if path.suffix in {".yaml", ".yml"} else json.loads(text)
|
|
288
|
+
if isinstance(doc, dict) and doc.get("kind") == "EvidencePackage":
|
|
289
|
+
return doc.get("manifest") or None, doc, deployment
|
|
290
|
+
return doc, None, deployment
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_UPGRADE = re.compile(
|
|
294
|
+
r"^Upgrade\s+(?P<name>[A-Za-z0-9_.@/-]+)\s+to\s+(?P<version>[A-Za-z0-9_.:+~-]+)\.?$"
|
|
295
|
+
)
|
|
296
|
+
_CVE_PKG = re.compile(r"^(?:CVE|GHSA)-[A-Za-z0-9-]+\s+(?P<name>[A-Za-z0-9_.@/-]+)$")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _component_from_text(title: str | None, remediation: str | None) -> Component | None:
|
|
300
|
+
"""Genesis gate findings carry the fix as prose; recover the structure.
|
|
301
|
+
|
|
302
|
+
"Upgrade idna to 3.7" → Component(name=idna, fixed_version=3.7). The title
|
|
303
|
+
("CVE-2024-3651 idna") supplies the name when only that is available.
|
|
304
|
+
"""
|
|
305
|
+
name = None
|
|
306
|
+
fixed = None
|
|
307
|
+
if remediation:
|
|
308
|
+
match = _UPGRADE.match(remediation.strip())
|
|
309
|
+
if match:
|
|
310
|
+
name = match.group("name")
|
|
311
|
+
fixed = match.group("version")
|
|
312
|
+
if name is None and title:
|
|
313
|
+
match = _CVE_PKG.match(title.strip())
|
|
314
|
+
if match:
|
|
315
|
+
name = match.group("name")
|
|
316
|
+
if name is None:
|
|
317
|
+
return None
|
|
318
|
+
return Component(name=name, fixed_version=fixed, type="library")
|
cernus_plugins/oscal.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""OSCAL exporter — Phase 0 skeleton.
|
|
2
|
+
|
|
3
|
+
Writes a minimal, schema-shaped **OSCAL 1.1.x Plan of Action and Milestones**
|
|
4
|
+
from open findings. Each finding becomes one ``poam-item`` with an
|
|
5
|
+
``observation`` carrying the scanner evidence and a ``risk`` carrying severity
|
|
6
|
+
and remediation. Control mapping (``related-controls``) is filled in Phase 2
|
|
7
|
+
when the gate/finding → 800-53 crosswalk lands.
|
|
8
|
+
|
|
9
|
+
Validation against the official OSCAL JSON Schema is available through the
|
|
10
|
+
optional ``compliance-trestle`` extra; this module does not depend on it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from cernus_core.model import Finding, IngestBatch, now_iso
|
|
21
|
+
from cernus_core.plugins import Exporter, register_exporter
|
|
22
|
+
|
|
23
|
+
OSCAL_VERSION = "1.1.2"
|
|
24
|
+
|
|
25
|
+
# FedRAMP remediation windows by severity, in days (Rev 5 ConMon guidance).
|
|
26
|
+
FEDRAMP_SLA_DAYS = {"critical": 30, "high": 30, "medium": 90, "low": 180, "info": 180}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _uuid(seed: str) -> str:
|
|
30
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"https://cernus.ai/oscal/{seed}"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register_exporter
|
|
34
|
+
class OscalExporter(Exporter):
|
|
35
|
+
name = "oscal"
|
|
36
|
+
description = "OSCAL 1.1 Plan of Action & Milestones from open findings"
|
|
37
|
+
formats = ("oscal-poam", "oscal-assessment-results")
|
|
38
|
+
|
|
39
|
+
def export(self, fmt: str, batch: IngestBatch, out: Path, **options: Any) -> Path:
|
|
40
|
+
if fmt == "oscal-assessment-results":
|
|
41
|
+
return self._assessment_results(batch, out, **options)
|
|
42
|
+
if fmt != "oscal-poam":
|
|
43
|
+
raise ValueError(f"oscal exporter does not produce {fmt!r}")
|
|
44
|
+
system_name = options.get("system_name") or (
|
|
45
|
+
batch.systems[0].name if batch.systems else "unnamed-system"
|
|
46
|
+
)
|
|
47
|
+
system_id = options.get("system_id") or (
|
|
48
|
+
batch.systems[0].id if batch.systems else "unknown"
|
|
49
|
+
)
|
|
50
|
+
open_findings = [f for f in batch.findings if f.status.value == "open"]
|
|
51
|
+
|
|
52
|
+
observations, risks, items = [], [], []
|
|
53
|
+
for f in open_findings:
|
|
54
|
+
obs_uuid = _uuid(f"obs/{f.fingerprint}")
|
|
55
|
+
risk_uuid = _uuid(f"risk/{f.fingerprint}")
|
|
56
|
+
observations.append(_observation(f, obs_uuid))
|
|
57
|
+
risks.append(_risk(f, risk_uuid, obs_uuid))
|
|
58
|
+
items.append(
|
|
59
|
+
{
|
|
60
|
+
"uuid": _uuid(f"item/{f.fingerprint}"),
|
|
61
|
+
"title": f.title,
|
|
62
|
+
"description": f.detail or f.title,
|
|
63
|
+
"props": [
|
|
64
|
+
{
|
|
65
|
+
"name": "cernus-finding-id",
|
|
66
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
67
|
+
"value": f.id,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "cernus-fingerprint",
|
|
71
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
72
|
+
"value": f.fingerprint,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
"related-observations": [{"observation-uuid": obs_uuid}],
|
|
76
|
+
"associated-risks": [{"risk-uuid": risk_uuid}],
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
doc = {
|
|
81
|
+
"plan-of-action-and-milestones": {
|
|
82
|
+
"uuid": _uuid(f"poam/{system_id}/{batch.produced_at}"),
|
|
83
|
+
"metadata": {
|
|
84
|
+
"title": f"POA&M — {system_name}",
|
|
85
|
+
"last-modified": now_iso(),
|
|
86
|
+
"version": batch.produced_at,
|
|
87
|
+
"oscal-version": OSCAL_VERSION,
|
|
88
|
+
"props": [
|
|
89
|
+
{"name": "generator", "ns": "https://cernus.ai/ns/oscal", "value": "cernus"}
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
"system-id": {"identifier-type": "https://cernus.ai/ns/system-id", "id": system_id},
|
|
93
|
+
"observations": observations,
|
|
94
|
+
"risks": risks,
|
|
95
|
+
"poam-items": items,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
out.write_text(json.dumps(doc, indent=2))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
def _assessment_results(self, batch: IngestBatch, out: Path, **options: Any) -> Path:
|
|
102
|
+
"""OSCAL Assessment Results from Cernus ControlResults.
|
|
103
|
+
|
|
104
|
+
``options["control_results"]`` is a list of ControlResult dicts (the
|
|
105
|
+
contract shape, optionally with ``title``/``statement``). Each becomes
|
|
106
|
+
one ``finding`` with a ``target`` of type ``objective-id`` whose status
|
|
107
|
+
is ``satisfied`` / ``not-satisfied``; partial and unknown map to
|
|
108
|
+
``not-satisfied`` with the Cernus status preserved as a prop.
|
|
109
|
+
"""
|
|
110
|
+
results = list(options.get("control_results") or [])
|
|
111
|
+
system_name = options.get("system_name") or (
|
|
112
|
+
batch.systems[0].name if batch.systems else "unnamed-system"
|
|
113
|
+
)
|
|
114
|
+
system_id = options.get("system_id") or (
|
|
115
|
+
batch.systems[0].id if batch.systems else "unknown"
|
|
116
|
+
)
|
|
117
|
+
catalog = results[0]["catalog"] if results else "nist-800-53r5"
|
|
118
|
+
evaluated_at = results[0]["evaluated_at"] if results else now_iso()
|
|
119
|
+
findings = []
|
|
120
|
+
for r in results:
|
|
121
|
+
implemented = r["status"] == "satisfied"
|
|
122
|
+
findings.append(
|
|
123
|
+
{
|
|
124
|
+
"uuid": _uuid(f"ar-finding/{system_id}/{r['control_id']}/{r['evaluated_at']}"),
|
|
125
|
+
"title": f"{r['control_id']} {r.get('title') or ''}".strip(),
|
|
126
|
+
"description": r.get("rationale") or r.get("statement") or r["control_id"],
|
|
127
|
+
"target": {
|
|
128
|
+
"type": "objective-id",
|
|
129
|
+
"target-id": r["control_id"],
|
|
130
|
+
"status": {"state": "satisfied" if implemented else "not-satisfied"},
|
|
131
|
+
"props": [
|
|
132
|
+
{
|
|
133
|
+
"name": "cernus-status",
|
|
134
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
135
|
+
"value": r["status"],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "evaluator",
|
|
139
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
140
|
+
"value": r.get("evaluator", ""),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
doc = {
|
|
147
|
+
"assessment-results": {
|
|
148
|
+
"uuid": _uuid(f"ar/{system_id}/{evaluated_at}"),
|
|
149
|
+
"metadata": {
|
|
150
|
+
"title": f"Assessment Results — {system_name}",
|
|
151
|
+
"last-modified": now_iso(),
|
|
152
|
+
"version": evaluated_at,
|
|
153
|
+
"oscal-version": OSCAL_VERSION,
|
|
154
|
+
"props": [
|
|
155
|
+
{
|
|
156
|
+
"name": "generator",
|
|
157
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
158
|
+
"value": "cernus",
|
|
159
|
+
},
|
|
160
|
+
{"name": "catalog", "ns": "https://cernus.ai/ns/oscal", "value": catalog},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
"import-ap": {"href": f"urn:cernus:assessment-plan:{system_id}"},
|
|
164
|
+
"results": [
|
|
165
|
+
{
|
|
166
|
+
"uuid": _uuid(f"ar-result/{system_id}/{evaluated_at}"),
|
|
167
|
+
"title": f"Continuous control evaluation ({catalog})",
|
|
168
|
+
"description": (
|
|
169
|
+
"Automated evaluation of runtime facts against the Cernus crosswalk."
|
|
170
|
+
),
|
|
171
|
+
"start": evaluated_at,
|
|
172
|
+
"end": evaluated_at,
|
|
173
|
+
"findings": findings,
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
out.write_text(json.dumps(doc, indent=2))
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _observation(f: Finding, obs_uuid: str) -> dict[str, Any]:
|
|
183
|
+
subjects = []
|
|
184
|
+
if f.asset_id:
|
|
185
|
+
subjects.append({"subject-uuid": _uuid(f"asset/{f.asset_id}"), "type": "component"})
|
|
186
|
+
return {
|
|
187
|
+
"uuid": obs_uuid,
|
|
188
|
+
"title": f.title,
|
|
189
|
+
"description": f.detail or f.title,
|
|
190
|
+
"methods": ["TEST"],
|
|
191
|
+
"types": ["finding"],
|
|
192
|
+
"origins": [{"actors": [{"type": "tool", "actor-uuid": _uuid(f"tool/{f.source.tool}")}]}],
|
|
193
|
+
**({"subjects": subjects} if subjects else {}),
|
|
194
|
+
"collected": f.last_seen,
|
|
195
|
+
"props": [
|
|
196
|
+
{"name": "tool", "ns": "https://cernus.ai/ns/oscal", "value": f.source.tool},
|
|
197
|
+
*(
|
|
198
|
+
[
|
|
199
|
+
{
|
|
200
|
+
"name": "vulnerability-id",
|
|
201
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
202
|
+
"value": f.vuln.id,
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
if f.vuln
|
|
206
|
+
else []
|
|
207
|
+
),
|
|
208
|
+
*(
|
|
209
|
+
[{"name": "location", "ns": "https://cernus.ai/ns/oscal", "value": f.location}]
|
|
210
|
+
if f.location
|
|
211
|
+
else []
|
|
212
|
+
),
|
|
213
|
+
],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _risk(f: Finding, risk_uuid: str, obs_uuid: str) -> dict[str, Any]:
|
|
218
|
+
sev = f.severity.value
|
|
219
|
+
props = [
|
|
220
|
+
{"name": "severity", "ns": "https://cernus.ai/ns/oscal", "value": sev},
|
|
221
|
+
{
|
|
222
|
+
"name": "remediation-sla-days",
|
|
223
|
+
"ns": "https://cernus.ai/ns/oscal",
|
|
224
|
+
"value": str(FEDRAMP_SLA_DAYS[sev]),
|
|
225
|
+
},
|
|
226
|
+
]
|
|
227
|
+
if f.vuln and f.vuln.cvss is not None:
|
|
228
|
+
props.append(
|
|
229
|
+
{"name": "cvss-score", "ns": "https://cernus.ai/ns/oscal", "value": str(f.vuln.cvss)}
|
|
230
|
+
)
|
|
231
|
+
risk: dict[str, Any] = {
|
|
232
|
+
"uuid": risk_uuid,
|
|
233
|
+
"title": f.title,
|
|
234
|
+
"description": f.detail or f.title,
|
|
235
|
+
"statement": f"Open {sev} finding reported by {f.source.tool}.",
|
|
236
|
+
"status": "open",
|
|
237
|
+
"props": props,
|
|
238
|
+
"related-observations": [{"observation-uuid": obs_uuid}],
|
|
239
|
+
}
|
|
240
|
+
if f.remediation:
|
|
241
|
+
risk["remediations"] = [
|
|
242
|
+
{
|
|
243
|
+
"uuid": _uuid(f"rem/{f.fingerprint}"),
|
|
244
|
+
"lifecycle": "planned",
|
|
245
|
+
"title": "Remediate",
|
|
246
|
+
"description": f.remediation,
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
return risk
|