sieve-scan 0.3.1__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.
- sieve/__init__.py +2 -0
- sieve/__main__.py +5 -0
- sieve/cli.py +302 -0
- sieve/collect.py +796 -0
- sieve/intel.py +90 -0
- sieve/inuse.py +53 -0
- sieve/osv.py +841 -0
- sieve/projects.py +251 -0
- sieve/rank.py +71 -0
- sieve/sarif.py +120 -0
- sieve/sbom.py +272 -0
- sieve/sources.py +321 -0
- sieve/verify.py +558 -0
- sieve_scan-0.3.1.dist-info/METADATA +580 -0
- sieve_scan-0.3.1.dist-info/RECORD +19 -0
- sieve_scan-0.3.1.dist-info/WHEEL +5 -0
- sieve_scan-0.3.1.dist-info/entry_points.txt +2 -0
- sieve_scan-0.3.1.dist-info/licenses/LICENSE +662 -0
- sieve_scan-0.3.1.dist-info/top_level.txt +1 -0
sieve/__init__.py
ADDED
sieve/__main__.py
ADDED
sieve/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""sieve command line."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from . import __version__, inuse, intel, osv, projects, rank, sources
|
|
10
|
+
|
|
11
|
+
EXIT_OK, EXIT_KEV, EXIT_ERROR = 0, 1, 2 # 1 = known-exploited (KEV) or known-malicious in the output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_cache():
|
|
15
|
+
base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
|
|
16
|
+
return os.path.join(base, "sieve", "sieve.db")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parser():
|
|
20
|
+
p = argparse.ArgumentParser(
|
|
21
|
+
prog="sieve",
|
|
22
|
+
description="Find the CVEs that matter on a Linux box and its running containers, using OSV.dev, "
|
|
23
|
+
"CISA KEV and EPSS.")
|
|
24
|
+
p.add_argument("--version", action="version", version=f"sieve {__version__}")
|
|
25
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
26
|
+
common.add_argument("--json", action="store_true", help="machine-readable output")
|
|
27
|
+
common.add_argument("--sarif", action="store_true",
|
|
28
|
+
help="SARIF 2.1.0 output, for GitHub code scanning (upload with codeql-action/upload-sarif)")
|
|
29
|
+
common.add_argument("--sbom", choices=["cyclonedx", "spdx"], type=str.lower, metavar="{cyclonedx,spdx}",
|
|
30
|
+
help="print an SBOM of what was read (CycloneDX 1.6 or SPDX 2.3 JSON); no OSV query")
|
|
31
|
+
common.add_argument("--with-vulns", action="store_true",
|
|
32
|
+
help="with --sbom cyclonedx: scan as usual and add the findings as `vulnerabilities`")
|
|
33
|
+
common.add_argument("--min-severity", choices=["low", "medium", "high", "critical"], type=str.lower,
|
|
34
|
+
help="hide findings below this severity (known-exploited findings are always shown)")
|
|
35
|
+
common.add_argument("--kev-only", action="store_true", help="only known-exploited findings (CISA KEV)")
|
|
36
|
+
common.add_argument("--fixable-only", action="store_true", help="only findings with a fixed version")
|
|
37
|
+
common.add_argument("--in-use-only", action="store_true",
|
|
38
|
+
help="only findings whose code is loaded in a running process right now")
|
|
39
|
+
common.add_argument("--cache", default=default_cache(), metavar="PATH",
|
|
40
|
+
help="advisory cache (default: %(default)s); KEV and EPSS files sit next to it")
|
|
41
|
+
common.add_argument("--no-epss", action="store_true", help="do not download or use EPSS scores")
|
|
42
|
+
common.add_argument("--kernel", action="store_true",
|
|
43
|
+
help="also check kernel packages (linux-image, linux-libc-dev, ...); skipped by default")
|
|
44
|
+
common.add_argument("--limit", type=int, default=50, metavar="N",
|
|
45
|
+
help="table rows to print (default 50, 0 = all); the summary always counts everything")
|
|
46
|
+
sub = p.add_subparsers(dest="cmd", required=True, metavar="{host,containers,all,path,sbom,verify,remote,collector}")
|
|
47
|
+
root = argparse.ArgumentParser(add_help=False)
|
|
48
|
+
root.add_argument("--root", default="/", metavar="PATH",
|
|
49
|
+
help="read the host's packages from this directory instead of / (e.g. the host's / "
|
|
50
|
+
"mounted at /host inside a container)")
|
|
51
|
+
sub.add_parser("host", parents=[common, root], help="scan this machine's OS and Python packages")
|
|
52
|
+
c = sub.add_parser("containers", parents=[common], help="scan every running Docker container (needs root)")
|
|
53
|
+
c.add_argument("--from-tar", metavar="FILE",
|
|
54
|
+
help="read a tar written by `sieve collector` on another machine ('-' = stdin) instead")
|
|
55
|
+
sub.add_parser("all", parents=[common, root], help="host + containers")
|
|
56
|
+
pa = sub.add_parser("path", parents=[common], help="scan project directories: npm/yarn/pnpm lockfiles, "
|
|
57
|
+
"node_modules, requirements*.txt, poetry.lock, uv.lock, virtualenvs")
|
|
58
|
+
pa.add_argument("dirs", nargs="+", metavar="DIR")
|
|
59
|
+
pa.add_argument("--depth", type=int, default=8, metavar="N", help="how deep to look under each DIR (default 8)")
|
|
60
|
+
sb = sub.add_parser("sbom", parents=[common], help="scan SBOMs made by other tools (CycloneDX or SPDX JSON: syft, "
|
|
61
|
+
"trivy, cdxgen, GitHub's dependency graph)")
|
|
62
|
+
sb.add_argument("files", nargs="+", metavar="FILE")
|
|
63
|
+
v = sub.add_parser("verify", parents=[root], help="has any installed file changed since its package manager put it "
|
|
64
|
+
"there? (dpkg, apk, rpm and pip checksums already on disk)")
|
|
65
|
+
v.add_argument("--json", action="store_true", help="machine-readable output")
|
|
66
|
+
v.add_argument("--sarif", action="store_true", help="SARIF 2.1.0 output, for GitHub code scanning")
|
|
67
|
+
v.add_argument("--packages", metavar="A,B", help="only these packages (dpkg/apk/rpm names or Python distributions)")
|
|
68
|
+
v.add_argument("--include-config", action="store_true",
|
|
69
|
+
help="also check dpkg conffiles (expected to differ when you have edited them)")
|
|
70
|
+
v.add_argument("--containers", action="store_true",
|
|
71
|
+
help="check every running Docker container in place instead of the host (needs root)")
|
|
72
|
+
v.add_argument("--host", action="store_true", help="with --containers: check the host as well")
|
|
73
|
+
v.add_argument("--all-missing", action="store_true",
|
|
74
|
+
help="also list missing docs, man pages and translations (images trim these; counted otherwise)")
|
|
75
|
+
v.add_argument("--limit", type=int, default=50, metavar="N", help="table rows to print (default 50, 0 = all)")
|
|
76
|
+
r = sub.add_parser("remote", parents=[common],
|
|
77
|
+
help="scan another machine over ssh (host + its containers); it needs only python3")
|
|
78
|
+
r.add_argument("dest", metavar="user@host", help="ssh destination (key auth: sieve runs ssh in BatchMode)")
|
|
79
|
+
r.add_argument("--port", type=int, help="ssh port")
|
|
80
|
+
r.add_argument("-i", "--identity", metavar="FILE", help="ssh private key")
|
|
81
|
+
r.add_argument("--ssh-opt", action="append", default=[], metavar="OPTION",
|
|
82
|
+
help="extra ssh -o option, e.g. --ssh-opt ProxyJump=bastion (repeatable)")
|
|
83
|
+
r.add_argument("--sudo", action="store_true",
|
|
84
|
+
help="run the collector with `sudo -n` (passwordless sudo): containers need root, and so does a "
|
|
85
|
+
"full in-use picture; package lists alone don't")
|
|
86
|
+
r.add_argument("--no-containers", action="store_true", help="host packages only")
|
|
87
|
+
sub.add_parser("collector", help="print the standalone collector script (pipe it to `python3 -` elsewhere)")
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan(args, warn):
|
|
92
|
+
"""→ (targets, findings, errors). With --sbom (and no --with-vulns) targets keep their package lists and
|
|
93
|
+
nothing is asked of OSV, KEV or EPSS."""
|
|
94
|
+
export = args.sbom and not (args.with_vulns and args.sbom == "cyclonedx")
|
|
95
|
+
targets, findings, errors = [], [], []
|
|
96
|
+
client = kev = None
|
|
97
|
+
|
|
98
|
+
def run(t):
|
|
99
|
+
nonlocal client, kev
|
|
100
|
+
if export:
|
|
101
|
+
targets.append(t)
|
|
102
|
+
return
|
|
103
|
+
if client is None:
|
|
104
|
+
client = osv.OSV(args.cache)
|
|
105
|
+
kev = intel.kev_set(os.path.dirname(os.path.abspath(args.cache)))
|
|
106
|
+
before, start = client.failed, len(findings)
|
|
107
|
+
try:
|
|
108
|
+
if t.pkgs:
|
|
109
|
+
findings.extend(osv.match(client, t.pkgs, kev, target=t.name))
|
|
110
|
+
inuse.annotate(findings[start:], t)
|
|
111
|
+
except Exception as e: # noqa: BLE001 — one unreadable target must not stop the rest
|
|
112
|
+
t.status = "error"
|
|
113
|
+
t.notes.append(f"OSV query failed: {type(e).__name__}: {str(e)[:160]}")
|
|
114
|
+
lost = client.failed - before
|
|
115
|
+
if lost and t.status == "ok":
|
|
116
|
+
t.status = "partial"
|
|
117
|
+
t.notes.append(f"{lost} advisories could not be fetched from OSV; counts may be low")
|
|
118
|
+
note = inuse.coverage_note(t.inuse)
|
|
119
|
+
if note:
|
|
120
|
+
t.notes.append(note)
|
|
121
|
+
if not args.sbom:
|
|
122
|
+
t.pkgs, t.osinv = [], None # free the lists once matched
|
|
123
|
+
targets.append(t)
|
|
124
|
+
|
|
125
|
+
if args.cmd in ("host", "all"):
|
|
126
|
+
run(sources.host(root=args.root, kernel=args.kernel))
|
|
127
|
+
if args.cmd == "sbom":
|
|
128
|
+
from . import sbom
|
|
129
|
+
for f in args.files:
|
|
130
|
+
run(sbom.read_file(f))
|
|
131
|
+
if args.cmd == "path":
|
|
132
|
+
for d in args.dirs:
|
|
133
|
+
run(projects.scan_path(d, depth=args.depth))
|
|
134
|
+
if args.cmd in ("containers", "all"):
|
|
135
|
+
proc = None
|
|
136
|
+
try:
|
|
137
|
+
if getattr(args, "from_tar", None):
|
|
138
|
+
stream = sys.stdin.buffer if args.from_tar == "-" else open(args.from_tar, "rb")
|
|
139
|
+
else:
|
|
140
|
+
proc, stream = sources.run_collector()
|
|
141
|
+
errs = []
|
|
142
|
+
for t in sources.read_collected(stream, errs, kernel=args.kernel):
|
|
143
|
+
run(t)
|
|
144
|
+
errors += [f"collector: {e}" for e in errs]
|
|
145
|
+
if proc:
|
|
146
|
+
rc = proc.wait()
|
|
147
|
+
if rc:
|
|
148
|
+
tail = proc.stderr.read().decode("utf-8", "replace").strip()[-300:]
|
|
149
|
+
errors.append(f"collector exited {rc}: {tail}")
|
|
150
|
+
except Exception as e: # noqa: BLE001
|
|
151
|
+
errors.append(f"containers: {e}")
|
|
152
|
+
if args.cmd == "containers" and not targets and not errors:
|
|
153
|
+
warn("no running containers found")
|
|
154
|
+
if args.cmd == "remote":
|
|
155
|
+
remote(args, run, errors)
|
|
156
|
+
if not args.no_epss and findings:
|
|
157
|
+
scores = intel.epss_scores(os.path.dirname(os.path.abspath(args.cache)),
|
|
158
|
+
{f["id"] for f in findings if f["id"].startswith("CVE-")})
|
|
159
|
+
for f in findings:
|
|
160
|
+
e = scores.get(f["id"])
|
|
161
|
+
f["epss"], f["epss_percentile"] = e if e else (None, None)
|
|
162
|
+
else:
|
|
163
|
+
for f in findings:
|
|
164
|
+
f["epss"] = f["epss_percentile"] = None
|
|
165
|
+
return targets, findings, errors
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
DEST = re.compile(r"^[A-Za-z0-9_.@:\[\]%+-]+$")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remote(args, run, errors):
|
|
172
|
+
"""`sieve remote`: the collector in host mode, then (unless --no-containers) in container mode, each piped to the
|
|
173
|
+
remote python3 over ssh. Nothing is installed or written there; only package metadata comes back."""
|
|
174
|
+
if not DEST.match(args.dest) or args.dest.startswith("-"):
|
|
175
|
+
errors.append(f"remote: {args.dest!r} is not an ssh destination (user@host)")
|
|
176
|
+
return
|
|
177
|
+
label = args.dest.rsplit("@", 1)[-1]
|
|
178
|
+
ssh = {"port": args.port, "identity": args.identity, "opts": args.ssh_opt}
|
|
179
|
+
script = sources.collector_source()
|
|
180
|
+
|
|
181
|
+
def over_ssh(mode, sudo, read):
|
|
182
|
+
p, stream = sources.run_remote(sources.ssh_command(args.dest, sudo=sudo, mode=mode, **ssh), script)
|
|
183
|
+
failed = None
|
|
184
|
+
try:
|
|
185
|
+
read(stream)
|
|
186
|
+
except Exception as e: # noqa: BLE001 — usually ssh gave no tar at all; its exit code says why
|
|
187
|
+
failed = e
|
|
188
|
+
rc = p.wait()
|
|
189
|
+
err = p.stderr.read()
|
|
190
|
+
if rc:
|
|
191
|
+
raise RuntimeError(sources.ssh_error(rc, err))
|
|
192
|
+
if failed:
|
|
193
|
+
raise failed
|
|
194
|
+
|
|
195
|
+
sudo = args.sudo and not args.dest.startswith("root@")
|
|
196
|
+
host = []
|
|
197
|
+
try:
|
|
198
|
+
over_ssh("host", sudo, lambda s: host.append(sources.read_host(s, label, kernel=args.kernel)))
|
|
199
|
+
run(host[0])
|
|
200
|
+
except Exception as e: # noqa: BLE001
|
|
201
|
+
errors.append(f"remote host {label}: {e}")
|
|
202
|
+
return
|
|
203
|
+
if args.no_containers:
|
|
204
|
+
return
|
|
205
|
+
errs, n = [], []
|
|
206
|
+
try:
|
|
207
|
+
over_ssh("", sudo,
|
|
208
|
+
lambda s: n.extend(run(t) for t in sources.read_collected(s, errs, kernel=args.kernel)))
|
|
209
|
+
except Exception as e: # noqa: BLE001
|
|
210
|
+
errors.append(f"remote containers {label}: {e}")
|
|
211
|
+
return
|
|
212
|
+
errors += [f"remote collector {label}: {e}" for e in errs]
|
|
213
|
+
if not n and not errs:
|
|
214
|
+
host[0].notes.append("no running containers seen (no Docker, none running, or this user can't list them: "
|
|
215
|
+
"try root@ or --sudo)")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _cut(s, n):
|
|
219
|
+
s = str(s)
|
|
220
|
+
return s if len(s) <= n else s[:n - 1] + "…"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def table(findings, limit):
|
|
224
|
+
rows = [("ALERT", "EPSS", "SEVERITY", "IN USE", "ID", "PACKAGE", "INSTALLED → FIXED", "TARGET")]
|
|
225
|
+
use = {inuse.LOADED: "yes", inuse.NOT_LOADED: "no", inuse.UNKNOWN: "?"}
|
|
226
|
+
shown = findings if not limit else findings[:limit]
|
|
227
|
+
for f in shown:
|
|
228
|
+
epss = f"{f['epss'] * 100:.1f}%" if f.get("epss") is not None else "-"
|
|
229
|
+
fix = f["fixed"] or ("no fix" if f["status"] in ("affected", "fixed") else f["status"])
|
|
230
|
+
flag = "☠ MALWARE" if f.get("malicious") else "⚠" if f["kev"] else ""
|
|
231
|
+
rows.append((flag, epss, f["severity"],
|
|
232
|
+
use.get(f.get("in_use"), "?") + (" ↻" if f.get("restart_needed") else ""), f["id"],
|
|
233
|
+
_cut(f["package"], 28),
|
|
234
|
+
f"{_cut(f['installed'], 26)} → {_cut(fix, 26)}", _cut(f["target"], 32)))
|
|
235
|
+
w = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
|
|
236
|
+
lines = [" ".join(c.ljust(w[i]) for i, c in enumerate(r)).rstrip() for r in rows]
|
|
237
|
+
if limit and len(findings) > limit:
|
|
238
|
+
lines.append(f"… {len(findings) - limit} more (use --limit 0 to show all, or --json)")
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main(argv=None):
|
|
243
|
+
args = parser().parse_args(argv)
|
|
244
|
+
if args.cmd == "collector":
|
|
245
|
+
sys.stdout.write(sources.collector_source())
|
|
246
|
+
return EXIT_OK
|
|
247
|
+
if args.cmd == "verify":
|
|
248
|
+
from . import verify
|
|
249
|
+
return verify.main(args)
|
|
250
|
+
|
|
251
|
+
def warn(m):
|
|
252
|
+
print(f"sieve: {m}", file=sys.stderr)
|
|
253
|
+
targets, findings, errors = scan(args, warn)
|
|
254
|
+
total = len(findings)
|
|
255
|
+
findings = rank.rank(rank.apply_filters(findings, args.min_severity, args.kev_only, args.fixable_only,
|
|
256
|
+
args.in_use_only))
|
|
257
|
+
s = rank.summary(findings)
|
|
258
|
+
if args.sbom:
|
|
259
|
+
from . import sbom
|
|
260
|
+
if args.with_vulns and args.sbom == "spdx":
|
|
261
|
+
warn("--with-vulns needs --sbom cyclonedx (SPDX 2.3 has no place for vulnerabilities); not added")
|
|
262
|
+
vulns = findings if args.with_vulns and args.sbom == "cyclonedx" else None
|
|
263
|
+
json.dump(sbom.build(args.sbom, targets, vulns), sys.stdout, indent=1)
|
|
264
|
+
sys.stdout.write("\n")
|
|
265
|
+
elif args.sarif:
|
|
266
|
+
from . import sarif
|
|
267
|
+
json.dump(sarif.build([t.as_dict() for t in targets], findings, errors), sys.stdout, indent=1)
|
|
268
|
+
sys.stdout.write("\n")
|
|
269
|
+
elif args.json:
|
|
270
|
+
json.dump({"sieve": __version__, "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
271
|
+
"targets": [t.as_dict() for t in targets], "errors": errors,
|
|
272
|
+
"summary": dict(s, unfiltered=total), "findings": findings}, sys.stdout, indent=1)
|
|
273
|
+
sys.stdout.write("\n")
|
|
274
|
+
else:
|
|
275
|
+
for t in targets:
|
|
276
|
+
note = f" ({'; '.join(t.notes)})" if t.notes else ""
|
|
277
|
+
flag = "" if t.status == "ok" else f" [{t.status}]"
|
|
278
|
+
print(f"# {t.name}: {t.os}, {t.packages} packages{flag}{note}")
|
|
279
|
+
if findings:
|
|
280
|
+
print()
|
|
281
|
+
print(table(findings, args.limit))
|
|
282
|
+
print()
|
|
283
|
+
hidden = f" ({total - len(findings)} hidden by filters)" if total != len(findings) else ""
|
|
284
|
+
n = s["malicious"]
|
|
285
|
+
mal = (f"{n} MALICIOUS PACKAGE{'S' if n != 1 else ''} (remove {'them' if n != 1 else 'it'}, rotate secrets), "
|
|
286
|
+
if n else "")
|
|
287
|
+
print(f"{mal}{s['findings']} findings, {s['kev']} actively exploited, {s['fixable']} fixable{hidden}")
|
|
288
|
+
if any(t.inuse for t in targets):
|
|
289
|
+
print(f"{s['in_use']} in code running right now"
|
|
290
|
+
+ (f", {s['restart_needed']} still running an old copy that was upgraded on disk (↻: restart "
|
|
291
|
+
"it)" if s["restart_needed"] else ""))
|
|
292
|
+
for e in errors:
|
|
293
|
+
warn(e)
|
|
294
|
+
if s["kev"] or s["malicious"]:
|
|
295
|
+
return EXIT_KEV
|
|
296
|
+
if errors or any(t.status == "error" for t in targets):
|
|
297
|
+
return EXIT_ERROR
|
|
298
|
+
return EXIT_OK
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
sys.exit(main())
|