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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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())
@@ -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
+ )
@@ -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