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
cernus_plugins/sarif.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""SARIF 2.1.0 — connector (semgrep, CodeQL, bandit, genesis-cli …) and exporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from cernus_core.model import Finding, IngestBatch, Severity, Source, new_id, now_iso
|
|
10
|
+
from cernus_core.plugins import Connector, Exporter, register_connector, register_exporter
|
|
11
|
+
|
|
12
|
+
_LEVEL_TO_SEV = {"error": "high", "warning": "medium", "note": "low", "none": "info"}
|
|
13
|
+
_SEV_TO_LEVEL = {
|
|
14
|
+
"critical": "error",
|
|
15
|
+
"high": "error",
|
|
16
|
+
"medium": "warning",
|
|
17
|
+
"low": "note",
|
|
18
|
+
"info": "none",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register_connector
|
|
23
|
+
class SarifConnector(Connector):
|
|
24
|
+
name = "sarif"
|
|
25
|
+
description = "SARIF 2.1.0 static-analysis logs"
|
|
26
|
+
tools = ("semgrep", "codeql", "bandit", "genesis-cli")
|
|
27
|
+
|
|
28
|
+
def detect(self, path: Path, document: dict[str, Any] | None = None) -> bool:
|
|
29
|
+
return bool(document) and document.get("version") == "2.1.0" and "runs" in document
|
|
30
|
+
|
|
31
|
+
def ingest(
|
|
32
|
+
self, path: Path, document: dict[str, Any] | None = None, **context: Any
|
|
33
|
+
) -> IngestBatch:
|
|
34
|
+
doc = document if document is not None else json.loads(path.read_text())
|
|
35
|
+
batch = IngestBatch(source=Source(tool="sarif", connector=self.name))
|
|
36
|
+
seen = now_iso()
|
|
37
|
+
for run in doc.get("runs") or []:
|
|
38
|
+
driver = (run.get("tool") or {}).get("driver") or {}
|
|
39
|
+
source = Source(
|
|
40
|
+
tool=driver.get("name", "sarif").lower(),
|
|
41
|
+
version=driver.get("version"),
|
|
42
|
+
connector=self.name,
|
|
43
|
+
)
|
|
44
|
+
rules = {r.get("id"): r for r in driver.get("rules") or []}
|
|
45
|
+
for res in run.get("results") or []:
|
|
46
|
+
rule_id = res.get("ruleId") or "unknown"
|
|
47
|
+
rule = rules.get(rule_id, {})
|
|
48
|
+
sev = _severity(res, rule)
|
|
49
|
+
loc = _location(res)
|
|
50
|
+
batch.findings.append(
|
|
51
|
+
Finding(
|
|
52
|
+
id=new_id("fnd"),
|
|
53
|
+
title=(rule.get("shortDescription") or {}).get("text") or rule_id,
|
|
54
|
+
severity=sev,
|
|
55
|
+
source=source,
|
|
56
|
+
source_ref=rule_id,
|
|
57
|
+
category="sast",
|
|
58
|
+
detail=(res.get("message") or {}).get("text"),
|
|
59
|
+
location=loc,
|
|
60
|
+
remediation=(rule.get("help") or {}).get("text"),
|
|
61
|
+
asset_id=context.get("asset_id"),
|
|
62
|
+
deployment_id=context.get("deployment_id"),
|
|
63
|
+
system_id=context.get("system_id"),
|
|
64
|
+
first_seen=seen,
|
|
65
|
+
last_seen=seen,
|
|
66
|
+
tags=[
|
|
67
|
+
t
|
|
68
|
+
for t in (rule.get("properties") or {}).get("tags", [])
|
|
69
|
+
if isinstance(t, str)
|
|
70
|
+
][:10],
|
|
71
|
+
raw=res,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if batch.findings:
|
|
75
|
+
batch.source = batch.findings[0].source
|
|
76
|
+
return batch
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _severity(res: dict, rule: dict) -> str:
|
|
80
|
+
props = rule.get("properties") or {}
|
|
81
|
+
if "security-severity" in props: # GitHub convention: CVSS-like 0–10 string
|
|
82
|
+
try:
|
|
83
|
+
s = float(props["security-severity"])
|
|
84
|
+
return "critical" if s >= 9 else "high" if s >= 7 else "medium" if s >= 4 else "low"
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass
|
|
87
|
+
level = res.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "warning"
|
|
88
|
+
return _LEVEL_TO_SEV.get(level, "medium")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _location(res: dict) -> str | None:
|
|
92
|
+
for loc in res.get("locations") or []:
|
|
93
|
+
phys = loc.get("physicalLocation") or {}
|
|
94
|
+
uri = (phys.get("artifactLocation") or {}).get("uri")
|
|
95
|
+
line = (phys.get("region") or {}).get("startLine")
|
|
96
|
+
if uri:
|
|
97
|
+
return f"{uri}:{line}" if line else uri
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@register_exporter
|
|
102
|
+
class SarifExporter(Exporter):
|
|
103
|
+
name = "sarif"
|
|
104
|
+
description = "Write findings as SARIF 2.1.0 (one run per source tool)"
|
|
105
|
+
formats = ("sarif",)
|
|
106
|
+
|
|
107
|
+
def export(self, fmt: str, batch: IngestBatch, out: Path, **options: Any) -> Path:
|
|
108
|
+
by_tool: dict[str, list[Finding]] = {}
|
|
109
|
+
for f in batch.findings:
|
|
110
|
+
by_tool.setdefault(f.source.tool, []).append(f)
|
|
111
|
+
runs = []
|
|
112
|
+
for tool, findings in by_tool.items():
|
|
113
|
+
rules: dict[str, dict] = {}
|
|
114
|
+
results = []
|
|
115
|
+
for f in findings:
|
|
116
|
+
rid = f.source_ref or f.fingerprint[:12]
|
|
117
|
+
rules.setdefault(rid, {"id": rid, "shortDescription": {"text": f.title}})
|
|
118
|
+
result: dict[str, Any] = {
|
|
119
|
+
"ruleId": rid,
|
|
120
|
+
"level": _SEV_TO_LEVEL[f.severity.value],
|
|
121
|
+
"message": {"text": f.detail or f.title},
|
|
122
|
+
"fingerprints": {"cernus/v1": f.fingerprint},
|
|
123
|
+
"properties": {
|
|
124
|
+
"cernus": {
|
|
125
|
+
"id": f.id,
|
|
126
|
+
"severity": f.severity.value,
|
|
127
|
+
"status": f.status.value,
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
if f.location:
|
|
132
|
+
uri, _, line = f.location.partition(":")
|
|
133
|
+
region = {"startLine": int(line)} if line.isdigit() else {}
|
|
134
|
+
result["locations"] = [
|
|
135
|
+
{
|
|
136
|
+
"physicalLocation": {
|
|
137
|
+
"artifactLocation": {"uri": uri},
|
|
138
|
+
**({"region": region} if region else {}),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
results.append(result)
|
|
143
|
+
runs.append(
|
|
144
|
+
{
|
|
145
|
+
"tool": {"driver": {"name": tool, "rules": list(rules.values())}},
|
|
146
|
+
"results": results,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
doc = {
|
|
150
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/sarif-2.1.0.json",
|
|
151
|
+
"version": "2.1.0",
|
|
152
|
+
"runs": runs,
|
|
153
|
+
}
|
|
154
|
+
out.write_text(json.dumps(doc, indent=2))
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = ["SarifConnector", "SarifExporter", "Severity"]
|
cernus_plugins/syft.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Syft scanner wrapper: produce a CycloneDX SBOM for an image or directory.
|
|
2
|
+
|
|
3
|
+
Normalization of the SBOM itself is the :mod:`cyclonedx` connector's job.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cernus_core.plugins import Scanner, ScanResult, register_scanner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register_scanner
|
|
15
|
+
class SyftScanner(Scanner):
|
|
16
|
+
name = "syft"
|
|
17
|
+
description = "Generate a CycloneDX SBOM with syft"
|
|
18
|
+
binary = "syft"
|
|
19
|
+
targets = ("image", "fs")
|
|
20
|
+
|
|
21
|
+
def version(self) -> str | None:
|
|
22
|
+
try:
|
|
23
|
+
out = subprocess.run(
|
|
24
|
+
[self.binary, "version", "-o", "raw"],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
timeout=20,
|
|
28
|
+
check=False,
|
|
29
|
+
).stdout
|
|
30
|
+
return out.strip() or None
|
|
31
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def scan(self, target_kind: str, target: str, workdir: Path) -> ScanResult:
|
|
35
|
+
if target_kind not in self.targets:
|
|
36
|
+
raise ValueError(f"syft scanner does not support target kind {target_kind!r}")
|
|
37
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
slug = "".join(c if c.isalnum() else "-" for c in target).strip("-")[:80]
|
|
39
|
+
out = workdir / f"syft-{slug}.cdx.json"
|
|
40
|
+
src = target if target_kind == "image" else f"dir:{target}"
|
|
41
|
+
argv = [self.binary, src, "-o", f"cyclonedx-json={out}", "-q"]
|
|
42
|
+
proc = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=1800)
|
|
43
|
+
return ScanResult(
|
|
44
|
+
tool="syft",
|
|
45
|
+
output_path=out if out.exists() else None,
|
|
46
|
+
connector="cyclonedx",
|
|
47
|
+
argv=argv,
|
|
48
|
+
returncode=proc.returncode,
|
|
49
|
+
stderr=proc.stderr[-4000:],
|
|
50
|
+
)
|
cernus_plugins/trivy.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Trivy — connector for `trivy … --format json` output, and a scanner wrapper.
|
|
2
|
+
|
|
3
|
+
Covers image scans (``ArtifactType: container_image``), filesystem scans, and
|
|
4
|
+
SBOM scans (``trivy sbom``). Misconfiguration and secret results are mapped to
|
|
5
|
+
their own categories when present.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cernus_core.model import (
|
|
16
|
+
Asset,
|
|
17
|
+
Component,
|
|
18
|
+
EvidenceRecord,
|
|
19
|
+
Finding,
|
|
20
|
+
IngestBatch,
|
|
21
|
+
Source,
|
|
22
|
+
Vuln,
|
|
23
|
+
new_id,
|
|
24
|
+
now_iso,
|
|
25
|
+
)
|
|
26
|
+
from cernus_core.plugins import Connector, Scanner, ScanResult, register_connector, register_scanner
|
|
27
|
+
|
|
28
|
+
_KIND_BY_ARTIFACT = {
|
|
29
|
+
"container_image": "container_image",
|
|
30
|
+
"filesystem": "repository",
|
|
31
|
+
"repository": "repository",
|
|
32
|
+
"cyclonedx": "container_image",
|
|
33
|
+
"spdx": "container_image",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register_connector
|
|
38
|
+
class TrivyConnector(Connector):
|
|
39
|
+
name = "trivy"
|
|
40
|
+
description = (
|
|
41
|
+
"Trivy JSON reports (image, fs, sbom): vulnerabilities, misconfigurations, secrets"
|
|
42
|
+
)
|
|
43
|
+
tools = ("trivy",)
|
|
44
|
+
|
|
45
|
+
def detect(self, path: Path, document: dict[str, Any] | None = None) -> bool:
|
|
46
|
+
return (
|
|
47
|
+
bool(document)
|
|
48
|
+
and "SchemaVersion" in document
|
|
49
|
+
and "Results" in document
|
|
50
|
+
and ("ArtifactName" in document or "ArtifactType" in document)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def ingest(
|
|
54
|
+
self, path: Path, document: dict[str, Any] | None = None, **context: Any
|
|
55
|
+
) -> IngestBatch:
|
|
56
|
+
doc = document if document is not None else json.loads(path.read_text())
|
|
57
|
+
version = (doc.get("Trivy") or {}).get("Version")
|
|
58
|
+
source = Source(tool="trivy", version=version, connector=self.name)
|
|
59
|
+
batch = IngestBatch(source=source)
|
|
60
|
+
|
|
61
|
+
artifact = doc.get("ArtifactName") or path.stem
|
|
62
|
+
artifact_type = doc.get("ArtifactType", "container_image")
|
|
63
|
+
meta = doc.get("Metadata") or {}
|
|
64
|
+
digest = (meta.get("RepoDigests") or [None])[0]
|
|
65
|
+
image_id = meta.get("ImageID")
|
|
66
|
+
|
|
67
|
+
asset_id = context.get("asset_id")
|
|
68
|
+
if asset_id is None:
|
|
69
|
+
# Identity facet is the image *reference* (name:tag) so that trivy,
|
|
70
|
+
# syft, and the Genesis deployment record reconcile to one asset;
|
|
71
|
+
# the digest rides along as an identifier for exact matching.
|
|
72
|
+
asset_id = Asset.id_for(
|
|
73
|
+
_KIND_BY_ARTIFACT.get(artifact_type, "other"), normalize_ref(artifact)
|
|
74
|
+
)
|
|
75
|
+
identifiers = {"image_ref": artifact}
|
|
76
|
+
if digest:
|
|
77
|
+
identifiers["digest"] = digest.split("@")[-1]
|
|
78
|
+
elif image_id:
|
|
79
|
+
identifiers["digest"] = image_id
|
|
80
|
+
batch.assets.append(
|
|
81
|
+
Asset(
|
|
82
|
+
id=asset_id,
|
|
83
|
+
asset_kind=_KIND_BY_ARTIFACT.get(artifact_type, "other"),
|
|
84
|
+
name=artifact,
|
|
85
|
+
identifiers=identifiers,
|
|
86
|
+
system_id=context.get("system_id"),
|
|
87
|
+
deployment_id=context.get("deployment_id"),
|
|
88
|
+
environment=context.get("environment", "unknown"),
|
|
89
|
+
source=source,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
seen = now_iso()
|
|
94
|
+
context = {**context, "scan_scope": artifact_type}
|
|
95
|
+
for result in doc.get("Results") or []:
|
|
96
|
+
target = result.get("Target")
|
|
97
|
+
for v in result.get("Vulnerabilities") or []:
|
|
98
|
+
batch.findings.append(self._vuln(v, target, asset_id, source, seen, context))
|
|
99
|
+
for m in result.get("Misconfigurations") or []:
|
|
100
|
+
batch.findings.append(self._misconf(m, target, asset_id, source, seen, context))
|
|
101
|
+
for s in result.get("Secrets") or []:
|
|
102
|
+
batch.findings.append(self._secret(s, target, asset_id, source, seen, context))
|
|
103
|
+
|
|
104
|
+
batch.evidence.append(
|
|
105
|
+
EvidenceRecord(
|
|
106
|
+
id=new_id("ev"),
|
|
107
|
+
record_kind="scan",
|
|
108
|
+
subject_kind="asset",
|
|
109
|
+
subject_id=asset_id,
|
|
110
|
+
produced_by=f"trivy {version or ''}".strip(),
|
|
111
|
+
summary=f"trivy scan of {artifact}: {len(batch.findings)} findings",
|
|
112
|
+
payload={
|
|
113
|
+
"artifact": artifact,
|
|
114
|
+
"artifact_type": artifact_type,
|
|
115
|
+
"created_at": doc.get("CreatedAt"),
|
|
116
|
+
"report_id": doc.get("ReportID"),
|
|
117
|
+
"os": meta.get("OS"),
|
|
118
|
+
"counts": _counts(batch.findings),
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return batch
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _vuln(
|
|
126
|
+
v: dict, target: str | None, asset_id: str, source: Source, seen: str, ctx: dict
|
|
127
|
+
) -> Finding:
|
|
128
|
+
cvss_block = v.get("CVSS") or {}
|
|
129
|
+
score, vector = None, None
|
|
130
|
+
# Prefer NVD v3, then any v3, then v2.
|
|
131
|
+
for key in ("nvd", *sorted(k for k in cvss_block if k != "nvd")):
|
|
132
|
+
c = cvss_block.get(key) or {}
|
|
133
|
+
if c.get("V3Score") is not None:
|
|
134
|
+
score, vector = c["V3Score"], c.get("V3Vector")
|
|
135
|
+
break
|
|
136
|
+
if score is None:
|
|
137
|
+
for c in cvss_block.values():
|
|
138
|
+
if c.get("V2Score") is not None:
|
|
139
|
+
score, vector = c["V2Score"], c.get("V2Vector")
|
|
140
|
+
break
|
|
141
|
+
purl = (v.get("PkgIdentifier") or {}).get("PURL")
|
|
142
|
+
return Finding(
|
|
143
|
+
id=new_id("fnd"),
|
|
144
|
+
title=v.get("Title") or f"{v.get('VulnerabilityID')} in {v.get('PkgName')}",
|
|
145
|
+
severity=v.get("Severity"),
|
|
146
|
+
source=source,
|
|
147
|
+
source_ref=v.get("VulnerabilityID"),
|
|
148
|
+
category="vulnerability",
|
|
149
|
+
detail=v.get("Description"),
|
|
150
|
+
location=target,
|
|
151
|
+
remediation=(
|
|
152
|
+
f"Upgrade {v.get('PkgName')} to {v['FixedVersion']}"
|
|
153
|
+
if v.get("FixedVersion")
|
|
154
|
+
else None
|
|
155
|
+
),
|
|
156
|
+
vuln=Vuln(
|
|
157
|
+
id=v.get("VulnerabilityID", ""),
|
|
158
|
+
cwe=list(v.get("CweIDs") or []),
|
|
159
|
+
cvss=score,
|
|
160
|
+
cvss_vector=vector,
|
|
161
|
+
published=v.get("PublishedDate"),
|
|
162
|
+
references=list(v.get("References") or [])[:5],
|
|
163
|
+
),
|
|
164
|
+
component=Component(
|
|
165
|
+
name=v.get("PkgName", ""),
|
|
166
|
+
purl=purl,
|
|
167
|
+
version=v.get("InstalledVersion"),
|
|
168
|
+
fixed_version=v.get("FixedVersion"),
|
|
169
|
+
type=(purl.split(":")[1].split("/")[0] if purl else None),
|
|
170
|
+
),
|
|
171
|
+
asset_id=asset_id,
|
|
172
|
+
deployment_id=ctx.get("deployment_id"),
|
|
173
|
+
system_id=ctx.get("system_id"),
|
|
174
|
+
scan_scope=ctx.get("scan_scope"),
|
|
175
|
+
first_seen=seen,
|
|
176
|
+
last_seen=seen,
|
|
177
|
+
tags=[t for t in (v.get("Status"),) if t],
|
|
178
|
+
raw=v,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _misconf(
|
|
183
|
+
m: dict, target: str | None, asset_id: str, source: Source, seen: str, ctx: dict
|
|
184
|
+
) -> Finding:
|
|
185
|
+
return Finding(
|
|
186
|
+
id=new_id("fnd"),
|
|
187
|
+
title=m.get("Title") or m.get("ID", "misconfiguration"),
|
|
188
|
+
severity=m.get("Severity"),
|
|
189
|
+
source=source,
|
|
190
|
+
source_ref=m.get("ID") or m.get("AVDID"),
|
|
191
|
+
category="misconfiguration",
|
|
192
|
+
detail=m.get("Description") or m.get("Message"),
|
|
193
|
+
location=target,
|
|
194
|
+
remediation=m.get("Resolution"),
|
|
195
|
+
asset_id=asset_id,
|
|
196
|
+
deployment_id=ctx.get("deployment_id"),
|
|
197
|
+
system_id=ctx.get("system_id"),
|
|
198
|
+
scan_scope=ctx.get("scan_scope"),
|
|
199
|
+
first_seen=seen,
|
|
200
|
+
last_seen=seen,
|
|
201
|
+
raw=m,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _secret(
|
|
206
|
+
s: dict, target: str | None, asset_id: str, source: Source, seen: str, ctx: dict
|
|
207
|
+
) -> Finding:
|
|
208
|
+
return Finding(
|
|
209
|
+
id=new_id("fnd"),
|
|
210
|
+
title=s.get("Title") or f"Secret: {s.get('RuleID')}",
|
|
211
|
+
severity=s.get("Severity"),
|
|
212
|
+
source=source,
|
|
213
|
+
source_ref=s.get("RuleID"),
|
|
214
|
+
category="secret",
|
|
215
|
+
detail=s.get("Match"),
|
|
216
|
+
location=f"{target}:{s.get('StartLine')}" if s.get("StartLine") else target,
|
|
217
|
+
remediation="Remove the secret from the artifact and rotate it.",
|
|
218
|
+
asset_id=asset_id,
|
|
219
|
+
deployment_id=ctx.get("deployment_id"),
|
|
220
|
+
system_id=ctx.get("system_id"),
|
|
221
|
+
scan_scope=ctx.get("scan_scope"),
|
|
222
|
+
first_seen=seen,
|
|
223
|
+
last_seen=seen,
|
|
224
|
+
raw={k: v for k, v in s.items() if k != "Match"}, # never persist the matched secret
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _counts(findings: list[Finding]) -> dict[str, int]:
|
|
229
|
+
out: dict[str, int] = {}
|
|
230
|
+
for f in findings:
|
|
231
|
+
out[f.severity.value] = out.get(f.severity.value, 0) + 1
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@register_scanner
|
|
236
|
+
class TrivyScanner(Scanner):
|
|
237
|
+
name = "trivy"
|
|
238
|
+
description = "Run trivy against an image, directory, or SBOM"
|
|
239
|
+
binary = "trivy"
|
|
240
|
+
targets = ("image", "fs", "sbom")
|
|
241
|
+
|
|
242
|
+
def version(self) -> str | None:
|
|
243
|
+
try:
|
|
244
|
+
out = subprocess.run(
|
|
245
|
+
[self.binary, "--version"], capture_output=True, text=True, timeout=20, check=False
|
|
246
|
+
).stdout
|
|
247
|
+
return out.strip().splitlines()[0].replace("Version:", "").strip() or None
|
|
248
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def scan(self, target_kind: str, target: str, workdir: Path) -> ScanResult:
|
|
252
|
+
if target_kind not in self.targets:
|
|
253
|
+
raise ValueError(f"trivy scanner does not support target kind {target_kind!r}")
|
|
254
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
out = workdir / f"trivy-{target_kind}-{_slug(target)}.json"
|
|
256
|
+
argv = [
|
|
257
|
+
self.binary,
|
|
258
|
+
target_kind,
|
|
259
|
+
"--skip-version-check",
|
|
260
|
+
"--format",
|
|
261
|
+
"json",
|
|
262
|
+
"--output",
|
|
263
|
+
str(out),
|
|
264
|
+
target,
|
|
265
|
+
]
|
|
266
|
+
if target_kind == "image":
|
|
267
|
+
argv[2:2] = ["--scanners", "vuln,secret"]
|
|
268
|
+
proc = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=1800)
|
|
269
|
+
return ScanResult(
|
|
270
|
+
tool="trivy",
|
|
271
|
+
output_path=out if out.exists() else None,
|
|
272
|
+
connector="trivy",
|
|
273
|
+
argv=argv,
|
|
274
|
+
returncode=proc.returncode,
|
|
275
|
+
stderr=proc.stderr[-4000:],
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def normalize_ref(ref: str) -> str:
|
|
280
|
+
"""``docker.io/library/python:3.11-slim`` and ``python:3.11-slim`` are one asset."""
|
|
281
|
+
ref = ref.split("@")[0]
|
|
282
|
+
for prefix in (
|
|
283
|
+
"docker.io/library/",
|
|
284
|
+
"docker.io/",
|
|
285
|
+
"index.docker.io/library/",
|
|
286
|
+
"index.docker.io/",
|
|
287
|
+
):
|
|
288
|
+
if ref.startswith(prefix):
|
|
289
|
+
ref = ref[len(prefix) :]
|
|
290
|
+
return ref if ":" in ref.rsplit("/", 1)[-1] else f"{ref}:latest"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _slug(s: str) -> str:
|
|
294
|
+
return "".join(c if c.isalnum() else "-" for c in s).strip("-")[:80]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cernus-plugins
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: First-party Cernus connectors, scanners, and exporters: Genesis, Trivy, Syft/CycloneDX, SARIF, OSCAL.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Inceptium-ai/cernus-framework
|
|
6
|
+
Project-URL: Repository, https://github.com/Inceptium-ai/cernus-framework
|
|
7
|
+
Author-email: Inceptium <opensource@inceptium.ai>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: cernus-core>=0.1.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# cernus-plugins
|
|
21
|
+
|
|
22
|
+
First-party plugins for [Cernus](https://github.com/Inceptium-ai/cernus-framework).
|
|
23
|
+
Each one wraps a tool you already run; none re-implements a scanner.
|
|
24
|
+
|
|
25
|
+
| Plugin | Kind | What it does |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `genesis` | connector | Genesis `EvidencePackage` + `DeliveryManifest` → System, Deployment, Findings |
|
|
28
|
+
| `trivy` | connector + scanner | `trivy image/sbom --format json` → Findings with CVE/CVSS/purl; runs trivy |
|
|
29
|
+
| `syft` | scanner | `syft <image> -o cyclonedx-json` → SBOM |
|
|
30
|
+
| `cyclonedx` | connector | CycloneDX SBOM → Sbom + component Assets |
|
|
31
|
+
| `sarif` | connector + exporter | SARIF 2.1.0 in (semgrep, CodeQL, …) and out |
|
|
32
|
+
| `oscal` | exporter | Findings → OSCAL 1.1 POA&M (minimal, schema-shaped) |
|
|
33
|
+
|
|
34
|
+
Plugins register through entry points (`cernus.connectors`, `cernus.scanners`,
|
|
35
|
+
`cernus.exporters`). Write your own the same way — see `cernus_core.plugins`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
cernus_plugins/__init__.py,sha256=2QAXxvntSldkQujJ4fSWYPyvcOTnaz56Tq968CSfcG8,175
|
|
2
|
+
cernus_plugins/cyclonedx.py,sha256=pj3lOW_wp6CD6qOLl-66s7EJHnAiymUpm5hvJdWwxLU,4306
|
|
3
|
+
cernus_plugins/genesis.py,sha256=zsuMkPd_gAIMUiR-pAmCBxbapIWDlKWVOu4doVds5v4,12444
|
|
4
|
+
cernus_plugins/oscal.py,sha256=riAe7wYectYHqHwST2v_OaJQEGwF5ppzHW94PKMpeRk,10035
|
|
5
|
+
cernus_plugins/sarif.py,sha256=y91Cw8aTGyj8PiPehfMMSXatlgHnAE_ZPlFu9Eqw_wg,6250
|
|
6
|
+
cernus_plugins/syft.py,sha256=mQsyEAdbFTBICDEInaSc7BdfT8-05-7ST_fIOo3koxA,1763
|
|
7
|
+
cernus_plugins/trivy.py,sha256=d4V_VotAHmyH8XDa_azRK9U75hCKbycgsmiChRPQGfU,10833
|
|
8
|
+
cernus_plugins-0.1.0.dist-info/METADATA,sha256=MXvyEtZ1L1rvVSuFQTom_4scDAFkEPPmDQG3sCb53pc,1683
|
|
9
|
+
cernus_plugins-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
cernus_plugins-0.1.0.dist-info/entry_points.txt,sha256=i9x-BMBZc_BUHQU9jBiQSpoqI5OojZhXhfPSi5cOnZg,420
|
|
11
|
+
cernus_plugins-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
12
|
+
cernus_plugins-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[cernus.connectors]
|
|
2
|
+
cyclonedx = cernus_plugins.cyclonedx:CycloneDXConnector
|
|
3
|
+
genesis = cernus_plugins.genesis:GenesisConnector
|
|
4
|
+
sarif = cernus_plugins.sarif:SarifConnector
|
|
5
|
+
trivy = cernus_plugins.trivy:TrivyConnector
|
|
6
|
+
|
|
7
|
+
[cernus.exporters]
|
|
8
|
+
oscal = cernus_plugins.oscal:OscalExporter
|
|
9
|
+
sarif = cernus_plugins.sarif:SarifExporter
|
|
10
|
+
|
|
11
|
+
[cernus.scanners]
|
|
12
|
+
syft = cernus_plugins.syft:SyftScanner
|
|
13
|
+
trivy = cernus_plugins.trivy:TrivyScanner
|