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 ADDED
@@ -0,0 +1,2 @@
1
+ """sieve: find the CVEs that matter on a Linux box and its running containers."""
2
+ __version__ = "0.3.1"
sieve/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())