exposuregate 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.
- exposuregate/__init__.py +1 -0
- exposuregate/__main__.py +200 -0
- exposuregate/archive.py +145 -0
- exposuregate/config.py +242 -0
- exposuregate/diffing.py +40 -0
- exposuregate/freshness.py +58 -0
- exposuregate/gitio.py +131 -0
- exposuregate/grading.py +211 -0
- exposuregate/lockfiles/__init__.py +50 -0
- exposuregate/lockfiles/npm.py +109 -0
- exposuregate/lockfiles/poetry.py +87 -0
- exposuregate/lockfiles/requirements.py +121 -0
- exposuregate/lockfiles/uv.py +76 -0
- exposuregate/models.py +99 -0
- exposuregate/osv.py +152 -0
- exposuregate/rebuild.py +165 -0
- exposuregate/report.py +177 -0
- exposuregate/scan.py +398 -0
- exposuregate/store.py +332 -0
- exposuregate/update.py +90 -0
- exposuregate/versions.py +72 -0
- exposuregate-0.1.0.dist-info/METADATA +594 -0
- exposuregate-0.1.0.dist-info/RECORD +27 -0
- exposuregate-0.1.0.dist-info/WHEEL +5 -0
- exposuregate-0.1.0.dist-info/entry_points.txt +2 -0
- exposuregate-0.1.0.dist-info/licenses/LICENSE +21 -0
- exposuregate-0.1.0.dist-info/top_level.txt +1 -0
exposuregate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
exposuregate/__main__.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Command line interface (spec 4.1, 4.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from exposuregate import __version__
|
|
12
|
+
from exposuregate.config import REPORT_FORMATS, Config, ConfigError, load_config
|
|
13
|
+
from exposuregate.gitio import GitError
|
|
14
|
+
from exposuregate.lockfiles import PARSERS
|
|
15
|
+
from exposuregate.report import to_json, to_markdown
|
|
16
|
+
from exposuregate.scan import ScanOptions, UsageError, run_scan
|
|
17
|
+
from exposuregate.store import DataInvalid, DataMissing
|
|
18
|
+
|
|
19
|
+
USAGE_EXIT = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _iso_date(value: str) -> date:
|
|
23
|
+
try:
|
|
24
|
+
return date.fromisoformat(value)
|
|
25
|
+
except ValueError:
|
|
26
|
+
raise argparse.ArgumentTypeError(f"invalid date {value!r}, expected YYYY-MM-DD") from None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _non_negative(value: str) -> int:
|
|
30
|
+
try:
|
|
31
|
+
n = int(value)
|
|
32
|
+
except ValueError:
|
|
33
|
+
n = -1
|
|
34
|
+
if n < 0:
|
|
35
|
+
raise argparse.ArgumentTypeError(f"expected a non-negative integer, got {value!r}")
|
|
36
|
+
return n
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parser() -> argparse.ArgumentParser:
|
|
40
|
+
p = argparse.ArgumentParser(prog="exposuregate")
|
|
41
|
+
p.add_argument("--version", action="version", version=f"exposuregate {__version__}")
|
|
42
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
43
|
+
|
|
44
|
+
s = sub.add_parser("scan", help="grade dependency versions added in lockfiles")
|
|
45
|
+
s.add_argument("--mode", choices=["pre-commit", "pr", "full"])
|
|
46
|
+
s.add_argument("--target-branch", default="main")
|
|
47
|
+
s.add_argument("--base")
|
|
48
|
+
s.add_argument("--head")
|
|
49
|
+
s.add_argument("--old", type=Path)
|
|
50
|
+
s.add_argument("--new", type=Path)
|
|
51
|
+
s.add_argument("--lockfile-type", choices=sorted(PARSERS))
|
|
52
|
+
s.add_argument("--manifest", type=Path)
|
|
53
|
+
s.add_argument("--path", type=Path, default=Path("."))
|
|
54
|
+
s.add_argument("--config", type=Path)
|
|
55
|
+
s.add_argument("--format", nargs="+", choices=REPORT_FORMATS, dest="formats")
|
|
56
|
+
s.add_argument("--output-dir", type=Path)
|
|
57
|
+
s.add_argument("--data-dir", type=Path)
|
|
58
|
+
s.add_argument("--oebc", type=Path)
|
|
59
|
+
s.add_argument("--osv-dir", type=Path)
|
|
60
|
+
s.add_argument("--as-of", type=_iso_date)
|
|
61
|
+
s.add_argument("--replay", action="store_true")
|
|
62
|
+
s.add_argument("--archive", type=Path)
|
|
63
|
+
s.add_argument("--max-age-days", type=_non_negative)
|
|
64
|
+
s.add_argument("--fail-open", action="store_true")
|
|
65
|
+
|
|
66
|
+
u = sub.add_parser("update-data", help="download OEBC and OSV data (uses the network)")
|
|
67
|
+
u.add_argument("--data-dir", type=Path)
|
|
68
|
+
u.add_argument("--ecosystems", nargs="+", default=["PyPI", "npm"], choices=["PyPI", "npm"])
|
|
69
|
+
|
|
70
|
+
r = sub.add_parser("rebuild-oebc", help="rebuild an OEBC catalog as of a past date")
|
|
71
|
+
r.add_argument("--as-of", type=_iso_date, required=True)
|
|
72
|
+
r.add_argument("--kev", type=Path, required=True)
|
|
73
|
+
r.add_argument("--epss-dir", type=Path, required=True)
|
|
74
|
+
r.add_argument("--oebc", type=Path, required=True)
|
|
75
|
+
r.add_argument("--out", type=Path, required=True)
|
|
76
|
+
|
|
77
|
+
i = sub.add_parser("data-info", help="show the local data snapshot")
|
|
78
|
+
i.add_argument("--data-dir", type=Path)
|
|
79
|
+
return p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _scan_mode(p: argparse.ArgumentParser, a: argparse.Namespace) -> str:
|
|
83
|
+
"""Validate the mutually exclusive input selections and return the scan mode."""
|
|
84
|
+
git_refs = a.base is not None or a.head is not None
|
|
85
|
+
files = a.old is not None or a.new is not None
|
|
86
|
+
if sum([a.mode is not None, git_refs, files]) > 1:
|
|
87
|
+
p.error("choose one input: --mode, --base/--head, or --old/--new")
|
|
88
|
+
if git_refs and (a.base is None or a.head is None):
|
|
89
|
+
p.error("--base and --head must be given together")
|
|
90
|
+
if files and a.new is None:
|
|
91
|
+
p.error("--old needs --new")
|
|
92
|
+
if a.new is not None and a.lockfile_type is None:
|
|
93
|
+
p.error("--new needs --lockfile-type")
|
|
94
|
+
if a.new is None and (a.lockfile_type is not None or a.manifest is not None):
|
|
95
|
+
p.error("--lockfile-type and --manifest are only valid with --new")
|
|
96
|
+
if a.replay and a.as_of is None:
|
|
97
|
+
p.error("--replay needs --as-of")
|
|
98
|
+
if a.archive is not None and a.as_of is None:
|
|
99
|
+
p.error("--archive needs --as-of")
|
|
100
|
+
if a.archive is not None and (a.oebc is not None or a.osv_dir is not None):
|
|
101
|
+
p.error("--archive cannot be combined with --oebc/--osv-dir")
|
|
102
|
+
if git_refs:
|
|
103
|
+
return "explicit"
|
|
104
|
+
if files:
|
|
105
|
+
return "files"
|
|
106
|
+
return a.mode or "pre-commit"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve(base: Path, p: Path) -> Path:
|
|
110
|
+
p = p.expanduser()
|
|
111
|
+
return p if p.is_absolute() else base / p
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _scan(p: argparse.ArgumentParser, a: argparse.Namespace) -> int:
|
|
115
|
+
mode = _scan_mode(p, a)
|
|
116
|
+
cfg: Config = load_config(a.config, a.path)
|
|
117
|
+
# Precedence: CLI flags > EXPOSUREGATE_DATA_DIR (applied by load_config) > config file.
|
|
118
|
+
if a.data_dir is not None:
|
|
119
|
+
cfg = replace(cfg, data_dir=a.data_dir.expanduser())
|
|
120
|
+
if a.max_age_days is not None:
|
|
121
|
+
cfg = replace(cfg, max_age_days=a.max_age_days) # per-source overrides still win
|
|
122
|
+
formats = a.formats or cfg.report_formats
|
|
123
|
+
out_dir = a.output_dir or (Path(cfg.output_dir) if cfg.output_dir else None)
|
|
124
|
+
opts = ScanOptions(
|
|
125
|
+
mode=mode, target_branch=a.target_branch, base=a.base, head=a.head,
|
|
126
|
+
old_file=a.old, new_file=a.new, lockfile_type=a.lockfile_type, manifest_file=a.manifest,
|
|
127
|
+
path=a.path, data_dir=cfg.data_dir, oebc_file=a.oebc, osv_dir=a.osv_dir, as_of=a.as_of,
|
|
128
|
+
replay=a.replay, archive=a.archive, fail_open=a.fail_open, formats=formats,
|
|
129
|
+
output_dir=_resolve(a.path, out_dir) if out_dir else None,
|
|
130
|
+
)
|
|
131
|
+
print(f"exposuregate: scanning ({mode})", file=sys.stderr)
|
|
132
|
+
report, code = run_scan(opts, cfg)
|
|
133
|
+
rendered = {"json": to_json(report), "markdown": to_markdown(report)}
|
|
134
|
+
for fmt in formats:
|
|
135
|
+
sys.stdout.write(rendered[fmt].rstrip("\n") + "\n")
|
|
136
|
+
if opts.output_dir is not None:
|
|
137
|
+
opts.output_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
names = {"json": "report.json", "markdown": "report.md"}
|
|
139
|
+
for fmt in formats:
|
|
140
|
+
(opts.output_dir / names[fmt]).write_text(rendered[fmt], encoding="utf-8")
|
|
141
|
+
print(f"exposuregate: reports written to {opts.output_dir}", file=sys.stderr)
|
|
142
|
+
s = report["summary"]
|
|
143
|
+
print(
|
|
144
|
+
f"exposuregate: {report['verdict']} ({s['block']} block, {s['warn']} warn,"
|
|
145
|
+
f" {s['pass']} pass, {s['denials']} denials)",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
return code
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _data_dir(a: argparse.Namespace) -> Path:
|
|
152
|
+
if a.data_dir is not None:
|
|
153
|
+
return Path(a.data_dir).expanduser()
|
|
154
|
+
return load_config(None, Path(".")).data_dir
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
p = _parser()
|
|
159
|
+
try:
|
|
160
|
+
a = p.parse_args(argv)
|
|
161
|
+
except SystemExit as e:
|
|
162
|
+
return e.code if isinstance(e.code, int) else USAGE_EXIT
|
|
163
|
+
try:
|
|
164
|
+
if a.command == "scan":
|
|
165
|
+
return _scan(p, a)
|
|
166
|
+
if a.command == "update-data":
|
|
167
|
+
from exposuregate.update import update_data
|
|
168
|
+
|
|
169
|
+
data_dir = _data_dir(a)
|
|
170
|
+
print(f"exposuregate: updating data in {data_dir}", file=sys.stderr)
|
|
171
|
+
update_data(data_dir, list(a.ecosystems))
|
|
172
|
+
return 0
|
|
173
|
+
if a.command == "rebuild-oebc":
|
|
174
|
+
from exposuregate.rebuild import rebuild_oebc
|
|
175
|
+
|
|
176
|
+
cat = rebuild_oebc(a.as_of, a.kev, a.epss_dir, a.oebc, a.out)
|
|
177
|
+
print(f"exposuregate: rebuilt OEBC as of {a.as_of}: {cat['count']} CVEs -> {a.out}",
|
|
178
|
+
file=sys.stderr)
|
|
179
|
+
return 0
|
|
180
|
+
if a.command == "data-info":
|
|
181
|
+
from exposuregate.update import data_info
|
|
182
|
+
|
|
183
|
+
print(data_info(_data_dir(a)))
|
|
184
|
+
return 0
|
|
185
|
+
except SystemExit as e:
|
|
186
|
+
return e.code if isinstance(e.code, int) else USAGE_EXIT
|
|
187
|
+
except (ConfigError, GitError, UsageError) as e:
|
|
188
|
+
print(f"exposuregate: error: {e}", file=sys.stderr)
|
|
189
|
+
return USAGE_EXIT
|
|
190
|
+
except (DataMissing, DataInvalid) as e:
|
|
191
|
+
print(f"exposuregate: error: {e}", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
except Exception as e: # noqa: BLE001 - exit 1 means BLOCK, so never let a crash reach it
|
|
194
|
+
print(f"exposuregate: unexpected error: {type(e).__name__}: {e}", file=sys.stderr)
|
|
195
|
+
return USAGE_EXIT
|
|
196
|
+
return USAGE_EXIT
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
sys.exit(main())
|
exposuregate/archive.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Reader for the daily snapshot archive (spec 6.5; layout of paper/eval/archive/archive.py).
|
|
2
|
+
|
|
3
|
+
Layout under the root:
|
|
4
|
+
days/<D>.json manifest: "sources" per source, "errors" per failed source
|
|
5
|
+
blobs/<source>/<sha256>.<ext> KEV JSON and OEBC catalog
|
|
6
|
+
osv/<eco>/index-<D>.jsonl.gz every advisory {"id", "modified"} in that day's export
|
|
7
|
+
osv/<eco>/delta-<D>.jsonl.gz records whose (id, modified) was new that day
|
|
8
|
+
|
|
9
|
+
OSV state as of D: for each id in index-D, the record with that id and the same ``modified``
|
|
10
|
+
from delta files dated <= D.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterable, Iterator
|
|
19
|
+
from datetime import date
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from exposuregate.store import DataInvalid, DataMissing, MemoryStore, parse_osv_record
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_manifest(path: Path) -> dict[str, Any]:
|
|
27
|
+
try:
|
|
28
|
+
man: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
if not isinstance(man.get("sources"), dict) or not isinstance(man.get("errors", {}), dict):
|
|
30
|
+
raise ValueError("sources/errors are not objects")
|
|
31
|
+
except (OSError, ValueError, AttributeError) as e:
|
|
32
|
+
raise DataInvalid(f"invalid archive manifest {path}: {e}") from e
|
|
33
|
+
return man
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _usable(man: dict[str, Any], needed: list[str]) -> bool:
|
|
37
|
+
errors = man.get("errors", {})
|
|
38
|
+
return all(s in man["sources"] and s not in errors for s in needed)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pick_day(root: Path, as_of: date, ecosystems: list[str]) -> tuple[str, dict[str, Any]]:
|
|
42
|
+
"""Latest manifest dated <= as_of with every needed source present and error-free."""
|
|
43
|
+
days_dir = root / "days"
|
|
44
|
+
if not days_dir.is_dir():
|
|
45
|
+
raise DataMissing(f"no snapshot archive at {root} (missing days/)")
|
|
46
|
+
days: list[date] = []
|
|
47
|
+
for p in days_dir.glob("*.json"):
|
|
48
|
+
try:
|
|
49
|
+
days.append(date.fromisoformat(p.stem))
|
|
50
|
+
except ValueError:
|
|
51
|
+
continue
|
|
52
|
+
needed = ["oebc", *(f"osv-{e}" for e in ecosystems)]
|
|
53
|
+
for d in sorted((d for d in days if d <= as_of), reverse=True):
|
|
54
|
+
man = _read_manifest(days_dir / f"{d.isoformat()}.json")
|
|
55
|
+
if _usable(man, needed):
|
|
56
|
+
return d.isoformat(), man
|
|
57
|
+
raise DataMissing(
|
|
58
|
+
f"archive {root}: no day on or before {as_of} with {', '.join(needed)} archived"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sha256(path: Path) -> str:
|
|
63
|
+
h = hashlib.sha256()
|
|
64
|
+
with open(path, "rb") as f:
|
|
65
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
66
|
+
h.update(chunk)
|
|
67
|
+
return h.hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _jsonl(path: Path) -> Iterator[str]:
|
|
71
|
+
try:
|
|
72
|
+
with gzip.open(path, "rt", encoding="utf-8") as g:
|
|
73
|
+
yield from (line for line in g if line.strip())
|
|
74
|
+
except (OSError, EOFError, UnicodeDecodeError) as e:
|
|
75
|
+
raise DataInvalid(f"cannot read {path}: {e}") from e
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _osv_state(root: Path, eco: str, day: str, entry: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
79
|
+
"""Every record of ``eco`` as it stood on ``day``."""
|
|
80
|
+
rel = entry.get("index") if isinstance(entry, dict) else None
|
|
81
|
+
index = root / str(rel or f"osv/{eco}/index-{day}.jsonl.gz")
|
|
82
|
+
if not index.is_file():
|
|
83
|
+
raise DataMissing(f"archive index missing: {index}")
|
|
84
|
+
wanted: dict[str, str] = {}
|
|
85
|
+
for line in _jsonl(index):
|
|
86
|
+
try:
|
|
87
|
+
row = json.loads(line)
|
|
88
|
+
wanted[str(row["id"])] = str(row["modified"])
|
|
89
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
90
|
+
raise DataInvalid(f"invalid archive index line in {index}: {e!r}") from e
|
|
91
|
+
deltas = sorted(
|
|
92
|
+
p for p in (root / "osv" / eco).glob("delta-*.jsonl.gz")
|
|
93
|
+
if p.name[len("delta-"):-len(".jsonl.gz")] <= day
|
|
94
|
+
)
|
|
95
|
+
for delta in deltas:
|
|
96
|
+
if not wanted:
|
|
97
|
+
break
|
|
98
|
+
for line in _jsonl(delta):
|
|
99
|
+
rec = parse_osv_record(line, str(delta))
|
|
100
|
+
if wanted.get(rec["id"]) == rec.get("modified", ""):
|
|
101
|
+
del wanted[rec["id"]]
|
|
102
|
+
yield rec
|
|
103
|
+
if wanted:
|
|
104
|
+
sample = ", ".join(sorted(wanted)[:5])
|
|
105
|
+
raise DataInvalid(
|
|
106
|
+
f"archive {root}: {len(wanted)} {eco} records of {day} not found in deltas ({sample})"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ArchiveStore:
|
|
111
|
+
@staticmethod
|
|
112
|
+
def open(root: Path, as_of: date, ecosystems: Iterable[str] = ()) -> MemoryStore:
|
|
113
|
+
"""Data as archived on the latest day <= as_of whose OEBC and needed OSV were archived.
|
|
114
|
+
|
|
115
|
+
``ecosystems``: the OSV ecosystems to load; a day with an error for any of them (or for
|
|
116
|
+
OEBC) is skipped. Archived state is already as of that day, so no ``published`` filter.
|
|
117
|
+
"""
|
|
118
|
+
names = sorted(set(ecosystems))
|
|
119
|
+
day, man = _pick_day(root, as_of, names)
|
|
120
|
+
oebc_entry = man["sources"]["oebc"]
|
|
121
|
+
try:
|
|
122
|
+
blob = root / str(oebc_entry["blob"])
|
|
123
|
+
expected = str(oebc_entry["sha256"])
|
|
124
|
+
except (KeyError, TypeError) as e:
|
|
125
|
+
raise DataInvalid(f"archive manifest {day}: bad oebc entry: {e!r}") from e
|
|
126
|
+
if not blob.is_file():
|
|
127
|
+
raise DataMissing(f"archived OEBC blob missing: {blob}")
|
|
128
|
+
if _sha256(blob) != expected:
|
|
129
|
+
raise DataInvalid(f"archived OEBC blob {blob} does not match sha256 {expected}")
|
|
130
|
+
seen: set[str] = set()
|
|
131
|
+
|
|
132
|
+
def records() -> Iterator[dict[str, Any]]:
|
|
133
|
+
for eco in names:
|
|
134
|
+
for rec in _osv_state(root, eco, day, man["sources"][f"osv-{eco}"]):
|
|
135
|
+
if rec["id"] not in seen: # an advisory in two ecosystems' exports
|
|
136
|
+
seen.add(rec["id"])
|
|
137
|
+
yield rec
|
|
138
|
+
|
|
139
|
+
store = MemoryStore.from_records(blob, records())
|
|
140
|
+
store.meta.replay = {"osv_mode": "archived", "archive_day": day}
|
|
141
|
+
if store.meta.oebc is not None:
|
|
142
|
+
store.meta.oebc.extra["source"] = f"{blob} (archive {root}, day {day})"
|
|
143
|
+
for eco, src in store.meta.osv.items():
|
|
144
|
+
src.extra["source"] = f"{root / 'osv' / eco} (archive {root}, day {day})"
|
|
145
|
+
return store
|
exposuregate/config.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Configuration loading with strict validation (spec section 8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
ACTIONS = ("block", "warn", "pass")
|
|
15
|
+
REPORT_FORMATS = ("json", "markdown")
|
|
16
|
+
CONFIG_FILENAME = ".exposuregate.yml"
|
|
17
|
+
|
|
18
|
+
DEFAULTS: dict[str, Any] = {
|
|
19
|
+
"fail_closed": True,
|
|
20
|
+
"data": {
|
|
21
|
+
"dir": "~/.cache/exposuregate",
|
|
22
|
+
"max_age_days": 7,
|
|
23
|
+
"oebc_max_age_days": None,
|
|
24
|
+
"osv_max_age_days": None,
|
|
25
|
+
},
|
|
26
|
+
"policy": {
|
|
27
|
+
"CRITICAL": "block",
|
|
28
|
+
"HIGH": "warn",
|
|
29
|
+
"MEDIUM": "pass",
|
|
30
|
+
"LOW": "pass",
|
|
31
|
+
"not_in_oebc": "warn",
|
|
32
|
+
"no_cve": "warn",
|
|
33
|
+
"unparseable_version": "warn",
|
|
34
|
+
"unpinned": "warn",
|
|
35
|
+
"unsupported_lockfile": "warn",
|
|
36
|
+
"transitive_max_action": "block",
|
|
37
|
+
},
|
|
38
|
+
"lockfiles": {
|
|
39
|
+
"requirements": {"enabled": True, "globs": ["requirements*.txt"]},
|
|
40
|
+
"poetry": {"enabled": True, "globs": ["poetry.lock"]},
|
|
41
|
+
"uv": {"enabled": True, "globs": ["uv.lock"]},
|
|
42
|
+
"npm": {"enabled": True, "globs": ["package-lock.json"]},
|
|
43
|
+
},
|
|
44
|
+
"unsupported_globs": [
|
|
45
|
+
"yarn.lock", "pnpm-lock.yaml", "Pipfile.lock", "bun.lock", "bun.lockb",
|
|
46
|
+
"Cargo.lock", "go.sum", "Gemfile.lock", "composer.lock", "gradle.lockfile",
|
|
47
|
+
],
|
|
48
|
+
"exclusions": {"paths": []},
|
|
49
|
+
"suppressions": [],
|
|
50
|
+
"reporting": {"format": ["json", "markdown"], "output_dir": ".exposuregate/reports"},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_SUPPRESSION_KEYS = {"id", "reason", "expires", "package", "ecosystem"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConfigError(Exception):
|
|
57
|
+
"""Invalid configuration (exit code 2)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Suppression:
|
|
62
|
+
id: str
|
|
63
|
+
reason: str
|
|
64
|
+
expires: date
|
|
65
|
+
package: str | None = None
|
|
66
|
+
ecosystem: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class LockfileTypeConfig:
|
|
71
|
+
enabled: bool
|
|
72
|
+
globs: list[str]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Config:
|
|
77
|
+
fail_closed: bool
|
|
78
|
+
data_dir: Path
|
|
79
|
+
max_age_days: int
|
|
80
|
+
oebc_max_age_days: int | None
|
|
81
|
+
osv_max_age_days: int | None
|
|
82
|
+
policy: dict[str, str]
|
|
83
|
+
transitive_max_action: str
|
|
84
|
+
lockfiles: dict[str, LockfileTypeConfig]
|
|
85
|
+
unsupported_globs: list[str]
|
|
86
|
+
excluded_paths: list[str]
|
|
87
|
+
suppressions: list[Suppression]
|
|
88
|
+
report_formats: list[str]
|
|
89
|
+
output_dir: str | None
|
|
90
|
+
|
|
91
|
+
def max_age(self, source: str) -> int:
|
|
92
|
+
if source == "oebc":
|
|
93
|
+
override = self.oebc_max_age_days
|
|
94
|
+
elif source == "osv":
|
|
95
|
+
override = self.osv_max_age_days
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"unknown data source: {source!r}")
|
|
98
|
+
return self.max_age_days if override is None else override
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_int(v: Any) -> bool:
|
|
102
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _str_list(v: Any, path: str) -> list[str]:
|
|
106
|
+
if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
|
|
107
|
+
raise ConfigError(f"{path}: expected a list of strings")
|
|
108
|
+
return list(v)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _merge(default: Any, user: Any, path: str) -> Any:
|
|
112
|
+
"""Deep-merge user over default, rejecting unknown keys in dict sections."""
|
|
113
|
+
if not isinstance(default, dict):
|
|
114
|
+
return user
|
|
115
|
+
if not isinstance(user, dict):
|
|
116
|
+
raise ConfigError(f"{path or '<root>'}: expected a mapping")
|
|
117
|
+
merged = copy.deepcopy(default)
|
|
118
|
+
for key, value in user.items():
|
|
119
|
+
kpath = f"{path}.{key}" if path else str(key)
|
|
120
|
+
if key not in default:
|
|
121
|
+
raise ConfigError(f"unknown key: {kpath}")
|
|
122
|
+
merged[key] = _merge(default[key], value, kpath)
|
|
123
|
+
return merged
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_scalar_types(d: dict[str, Any]) -> None:
|
|
127
|
+
if not isinstance(d["fail_closed"], bool):
|
|
128
|
+
raise ConfigError("fail_closed: expected a boolean")
|
|
129
|
+
data = d["data"]
|
|
130
|
+
if not isinstance(data["dir"], str) or not data["dir"]:
|
|
131
|
+
raise ConfigError("data.dir: expected a non-empty string")
|
|
132
|
+
if not _is_int(data["max_age_days"]) or data["max_age_days"] < 0:
|
|
133
|
+
raise ConfigError("data.max_age_days: expected a non-negative integer")
|
|
134
|
+
for k in ("oebc_max_age_days", "osv_max_age_days"):
|
|
135
|
+
v = data[k]
|
|
136
|
+
if v is not None and (not _is_int(v) or v < 0):
|
|
137
|
+
raise ConfigError(f"data.{k}: expected a non-negative integer or null")
|
|
138
|
+
for k, v in d["policy"].items():
|
|
139
|
+
if v not in ACTIONS:
|
|
140
|
+
raise ConfigError(f"policy.{k}: invalid action {v!r}, expected one of {list(ACTIONS)}")
|
|
141
|
+
for name, lf in d["lockfiles"].items():
|
|
142
|
+
if not isinstance(lf["enabled"], bool):
|
|
143
|
+
raise ConfigError(f"lockfiles.{name}.enabled: expected a boolean")
|
|
144
|
+
_str_list(lf["globs"], f"lockfiles.{name}.globs")
|
|
145
|
+
_str_list(d["unsupported_globs"], "unsupported_globs")
|
|
146
|
+
_str_list(d["exclusions"]["paths"], "exclusions.paths")
|
|
147
|
+
fmts = _str_list(d["reporting"]["format"], "reporting.format")
|
|
148
|
+
for f in fmts:
|
|
149
|
+
if f not in REPORT_FORMATS:
|
|
150
|
+
raise ConfigError(
|
|
151
|
+
f"reporting.format: invalid format {f!r}, expected one of {list(REPORT_FORMATS)}"
|
|
152
|
+
)
|
|
153
|
+
out = d["reporting"]["output_dir"]
|
|
154
|
+
if out is not None and not isinstance(out, str):
|
|
155
|
+
raise ConfigError("reporting.output_dir: expected a string or null")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_suppression(raw: Any, idx: int) -> Suppression:
|
|
159
|
+
path = f"suppressions[{idx}]"
|
|
160
|
+
if not isinstance(raw, dict):
|
|
161
|
+
raise ConfigError(f"{path}: expected a mapping")
|
|
162
|
+
for key in raw:
|
|
163
|
+
if key not in _SUPPRESSION_KEYS:
|
|
164
|
+
raise ConfigError(f"unknown key: {path}.{key}")
|
|
165
|
+
for key in ("id", "reason", "expires"):
|
|
166
|
+
if key not in raw or raw[key] is None:
|
|
167
|
+
raise ConfigError(f"{path}.{key}: required")
|
|
168
|
+
for key in ("id", "reason"):
|
|
169
|
+
if not isinstance(raw[key], str) or not raw[key].strip():
|
|
170
|
+
raise ConfigError(f"{path}.{key}: expected a non-empty string")
|
|
171
|
+
for key in ("package", "ecosystem"):
|
|
172
|
+
v = raw.get(key)
|
|
173
|
+
if v is not None and (not isinstance(v, str) or not v):
|
|
174
|
+
raise ConfigError(f"{path}.{key}: expected a non-empty string")
|
|
175
|
+
exp = raw["expires"]
|
|
176
|
+
if isinstance(exp, datetime):
|
|
177
|
+
exp = exp.date()
|
|
178
|
+
elif isinstance(exp, str):
|
|
179
|
+
try:
|
|
180
|
+
exp = date.fromisoformat(exp)
|
|
181
|
+
except ValueError:
|
|
182
|
+
raise ConfigError(f"{path}.expires: invalid date {exp!r}") from None
|
|
183
|
+
elif not isinstance(exp, date):
|
|
184
|
+
raise ConfigError(f"{path}.expires: expected a date (YYYY-MM-DD)")
|
|
185
|
+
return Suppression(
|
|
186
|
+
id=raw["id"], reason=raw["reason"], expires=exp,
|
|
187
|
+
package=raw.get("package"), ecosystem=raw.get("ecosystem"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
192
|
+
try:
|
|
193
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
194
|
+
except (OSError, yaml.YAMLError) as e:
|
|
195
|
+
raise ConfigError(f"cannot read config {path}: {e}") from e
|
|
196
|
+
if loaded is None:
|
|
197
|
+
return {}
|
|
198
|
+
if not isinstance(loaded, dict):
|
|
199
|
+
raise ConfigError(f"{path}: top level must be a mapping")
|
|
200
|
+
return loaded
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_config(path: Path | None, cwd: Path) -> Config:
|
|
204
|
+
user: dict[str, Any] = {}
|
|
205
|
+
if path is not None:
|
|
206
|
+
if not path.is_file():
|
|
207
|
+
raise ConfigError(f"config file not found: {path}")
|
|
208
|
+
user = _read_yaml(path)
|
|
209
|
+
elif (cwd / CONFIG_FILENAME).is_file():
|
|
210
|
+
user = _read_yaml(cwd / CONFIG_FILENAME)
|
|
211
|
+
|
|
212
|
+
# Suppressions and lockfile entries are validated separately: lists are
|
|
213
|
+
# replaced wholesale, and lockfile types are a closed set from the defaults.
|
|
214
|
+
suppressions_raw = user.pop("suppressions", [])
|
|
215
|
+
if not isinstance(suppressions_raw, list):
|
|
216
|
+
raise ConfigError("suppressions: expected a list")
|
|
217
|
+
merged = _merge({**DEFAULTS, "suppressions": None}, {**user, "suppressions": None}, "")
|
|
218
|
+
merged["suppressions"] = suppressions_raw
|
|
219
|
+
_validate_scalar_types(merged)
|
|
220
|
+
|
|
221
|
+
env_dir = os.environ.get("EXPOSUREGATE_DATA_DIR")
|
|
222
|
+
data = merged["data"]
|
|
223
|
+
policy = dict(merged["policy"])
|
|
224
|
+
transitive = policy.pop("transitive_max_action")
|
|
225
|
+
return Config(
|
|
226
|
+
fail_closed=merged["fail_closed"],
|
|
227
|
+
data_dir=Path(env_dir or data["dir"]).expanduser(),
|
|
228
|
+
max_age_days=data["max_age_days"],
|
|
229
|
+
oebc_max_age_days=data["oebc_max_age_days"],
|
|
230
|
+
osv_max_age_days=data["osv_max_age_days"],
|
|
231
|
+
policy=policy,
|
|
232
|
+
transitive_max_action=transitive,
|
|
233
|
+
lockfiles={
|
|
234
|
+
n: LockfileTypeConfig(enabled=lf["enabled"], globs=list(lf["globs"]))
|
|
235
|
+
for n, lf in merged["lockfiles"].items()
|
|
236
|
+
},
|
|
237
|
+
unsupported_globs=list(merged["unsupported_globs"]),
|
|
238
|
+
excluded_paths=list(merged["exclusions"]["paths"]),
|
|
239
|
+
suppressions=[_parse_suppression(s, i) for i, s in enumerate(suppressions_raw)],
|
|
240
|
+
report_formats=list(merged["reporting"]["format"]),
|
|
241
|
+
output_dir=merged["reporting"]["output_dir"],
|
|
242
|
+
)
|
exposuregate/diffing.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Diff two parsed lockfiles into added/changed pins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cmp_to_key
|
|
6
|
+
|
|
7
|
+
from exposuregate.models import ChangedPin, ParsedLockfile, Pin
|
|
8
|
+
from exposuregate.versions import InvalidVersion, compare
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cmp(ecosystem: str, a: str, b: str) -> int:
|
|
12
|
+
try:
|
|
13
|
+
return compare(ecosystem, a, b)
|
|
14
|
+
except InvalidVersion:
|
|
15
|
+
return (a > b) - (a < b)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def changed_pins(old: ParsedLockfile | None, new: ParsedLockfile) -> list[ChangedPin]:
|
|
19
|
+
"""Pins in new whose key is not in old, with the lowest differing old version."""
|
|
20
|
+
old_pins = old.pins if old is not None else []
|
|
21
|
+
old_keys = {p.key for p in old_pins}
|
|
22
|
+
old_versions: dict[str, set[str]] = {}
|
|
23
|
+
for p in old_pins:
|
|
24
|
+
if p.version:
|
|
25
|
+
old_versions.setdefault(p.name, set()).add(p.version)
|
|
26
|
+
|
|
27
|
+
def previous(pin: Pin) -> str | None:
|
|
28
|
+
if not pin.version:
|
|
29
|
+
return None
|
|
30
|
+
others = [v for v in old_versions.get(pin.name, ()) if v != pin.version]
|
|
31
|
+
if not others:
|
|
32
|
+
return None
|
|
33
|
+
def cmp(a: str, b: str) -> int:
|
|
34
|
+
return _cmp(pin.ecosystem, a, b)
|
|
35
|
+
|
|
36
|
+
return min(others, key=cmp_to_key(cmp))
|
|
37
|
+
|
|
38
|
+
out = [ChangedPin(p, previous(p)) for p in new.pins if p.key not in old_keys]
|
|
39
|
+
out.sort(key=lambda c: (c.pin.name, c.pin.version or c.pin.unpinned_spec or ""))
|
|
40
|
+
return out
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Data freshness checks (spec 6.3, 6.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
|
|
7
|
+
from exposuregate.config import Config
|
|
8
|
+
from exposuregate.models import Denial
|
|
9
|
+
from exposuregate.store import DataMeta, SourceMeta
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _utc_date(dt: datetime) -> date:
|
|
13
|
+
if dt.tzinfo is None:
|
|
14
|
+
return dt.date()
|
|
15
|
+
return dt.astimezone(timezone.utc).date()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_source(
|
|
19
|
+
label: str, name: str, src: SourceMeta, now: date, max_age: int, out: list[Denial]
|
|
20
|
+
) -> None:
|
|
21
|
+
d = _utc_date(src.date)
|
|
22
|
+
if d > now:
|
|
23
|
+
out.append(
|
|
24
|
+
Denial("data_from_future", f"{label} {name} {d} is after as-of date {now}")
|
|
25
|
+
)
|
|
26
|
+
return
|
|
27
|
+
age = (now - d).days
|
|
28
|
+
if age > max_age:
|
|
29
|
+
out.append(
|
|
30
|
+
Denial("data_stale", f"{label} {name} {d} is {age} days old (max {max_age})")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check(
|
|
35
|
+
meta: DataMeta | None,
|
|
36
|
+
needed_ecosystems: set[str],
|
|
37
|
+
now: date,
|
|
38
|
+
cfg: Config,
|
|
39
|
+
replay: bool,
|
|
40
|
+
) -> list[Denial]:
|
|
41
|
+
if not needed_ecosystems:
|
|
42
|
+
return []
|
|
43
|
+
if meta is None:
|
|
44
|
+
return [Denial("data_missing", "no advisory data store found; run update-data")]
|
|
45
|
+
out: list[Denial] = []
|
|
46
|
+
if meta.oebc is None:
|
|
47
|
+
out.append(Denial("data_missing", "OEBC catalog is missing from the data store"))
|
|
48
|
+
else:
|
|
49
|
+
_check_source("OEBC", "generated", meta.oebc, now, cfg.max_age("oebc"), out)
|
|
50
|
+
for eco in sorted(needed_ecosystems):
|
|
51
|
+
src = meta.osv.get(eco)
|
|
52
|
+
if src is None:
|
|
53
|
+
out.append(
|
|
54
|
+
Denial("data_missing", f"OSV {eco} data is missing from the data store")
|
|
55
|
+
)
|
|
56
|
+
elif not replay:
|
|
57
|
+
_check_source("OSV", f"{eco} as of", src, now, cfg.max_age("osv"), out)
|
|
58
|
+
return out
|