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.
@@ -0,0 +1,5 @@
1
+ """First-party Cernus plugins. Importing this package registers them all."""
2
+
3
+ from . import cyclonedx, genesis, oscal, sarif, syft, trivy # noqa: F401
4
+
5
+ __version__ = "0.1.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")
@@ -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