exposuregate 0.1.0__tar.gz

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.
Files changed (49) hide show
  1. exposuregate-0.1.0/LICENSE +21 -0
  2. exposuregate-0.1.0/PKG-INFO +594 -0
  3. exposuregate-0.1.0/README.md +557 -0
  4. exposuregate-0.1.0/pyproject.toml +80 -0
  5. exposuregate-0.1.0/setup.cfg +4 -0
  6. exposuregate-0.1.0/src/exposuregate/__init__.py +1 -0
  7. exposuregate-0.1.0/src/exposuregate/__main__.py +200 -0
  8. exposuregate-0.1.0/src/exposuregate/archive.py +145 -0
  9. exposuregate-0.1.0/src/exposuregate/config.py +242 -0
  10. exposuregate-0.1.0/src/exposuregate/diffing.py +40 -0
  11. exposuregate-0.1.0/src/exposuregate/freshness.py +58 -0
  12. exposuregate-0.1.0/src/exposuregate/gitio.py +131 -0
  13. exposuregate-0.1.0/src/exposuregate/grading.py +211 -0
  14. exposuregate-0.1.0/src/exposuregate/lockfiles/__init__.py +50 -0
  15. exposuregate-0.1.0/src/exposuregate/lockfiles/npm.py +109 -0
  16. exposuregate-0.1.0/src/exposuregate/lockfiles/poetry.py +87 -0
  17. exposuregate-0.1.0/src/exposuregate/lockfiles/requirements.py +121 -0
  18. exposuregate-0.1.0/src/exposuregate/lockfiles/uv.py +76 -0
  19. exposuregate-0.1.0/src/exposuregate/models.py +99 -0
  20. exposuregate-0.1.0/src/exposuregate/osv.py +152 -0
  21. exposuregate-0.1.0/src/exposuregate/rebuild.py +165 -0
  22. exposuregate-0.1.0/src/exposuregate/report.py +177 -0
  23. exposuregate-0.1.0/src/exposuregate/scan.py +398 -0
  24. exposuregate-0.1.0/src/exposuregate/store.py +332 -0
  25. exposuregate-0.1.0/src/exposuregate/update.py +90 -0
  26. exposuregate-0.1.0/src/exposuregate/versions.py +72 -0
  27. exposuregate-0.1.0/src/exposuregate.egg-info/PKG-INFO +594 -0
  28. exposuregate-0.1.0/src/exposuregate.egg-info/SOURCES.txt +47 -0
  29. exposuregate-0.1.0/src/exposuregate.egg-info/dependency_links.txt +1 -0
  30. exposuregate-0.1.0/src/exposuregate.egg-info/entry_points.txt +2 -0
  31. exposuregate-0.1.0/src/exposuregate.egg-info/requires.txt +15 -0
  32. exposuregate-0.1.0/src/exposuregate.egg-info/top_level.txt +1 -0
  33. exposuregate-0.1.0/tests/test_archive.py +229 -0
  34. exposuregate-0.1.0/tests/test_classify.py +69 -0
  35. exposuregate-0.1.0/tests/test_cli_e2e.py +580 -0
  36. exposuregate-0.1.0/tests/test_config.py +185 -0
  37. exposuregate-0.1.0/tests/test_diffing.py +98 -0
  38. exposuregate-0.1.0/tests/test_freshness.py +132 -0
  39. exposuregate-0.1.0/tests/test_gitio.py +130 -0
  40. exposuregate-0.1.0/tests/test_grading.py +263 -0
  41. exposuregate-0.1.0/tests/test_models.py +56 -0
  42. exposuregate-0.1.0/tests/test_osv.py +238 -0
  43. exposuregate-0.1.0/tests/test_planted.py +96 -0
  44. exposuregate-0.1.0/tests/test_readme.py +25 -0
  45. exposuregate-0.1.0/tests/test_rebuild.py +142 -0
  46. exposuregate-0.1.0/tests/test_report.py +146 -0
  47. exposuregate-0.1.0/tests/test_store.py +156 -0
  48. exposuregate-0.1.0/tests/test_update.py +112 -0
  49. exposuregate-0.1.0/tests/test_versions.py +45 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karthikeyan Sadayamuthu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,594 @@
1
+ Metadata-Version: 2.4
2
+ Name: exposuregate
3
+ Version: 0.1.0
4
+ Summary: Gate that grades dependency versions added in lockfiles using OSV and OEBC data.
5
+ Author: Karthikeyan Sadayamuthu
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sadayamuthu/exposuregate
8
+ Project-URL: Repository, https://github.com/sadayamuthu/exposuregate
9
+ Project-URL: Issues, https://github.com/sadayamuthu/exposuregate/issues
10
+ Project-URL: Documentation, https://github.com/sadayamuthu/exposuregate#readme
11
+ Project-URL: Changelog, https://github.com/sadayamuthu/exposuregate/blob/main/CHANGELOG.md
12
+ Keywords: security,vulnerability,supply-chain,kev,epss,osv,lockfile,pre-commit,dependencies
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: pyyaml
25
+ Requires-Dist: packaging
26
+ Requires-Dist: tomli; python_version < "3.11"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest; extra == "dev"
29
+ Requires-Dist: pytest-cov; extra == "dev"
30
+ Requires-Dist: ruff; extra == "dev"
31
+ Requires-Dist: mypy; extra == "dev"
32
+ Requires-Dist: types-PyYAML; extra == "dev"
33
+ Requires-Dist: jsonschema; extra == "dev"
34
+ Requires-Dist: build; extra == "dev"
35
+ Requires-Dist: twine; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ <div align="center">
39
+
40
+ # ExposureGate
41
+
42
+ <h3>Exploited-Vulnerability Gate for Lockfiles</h3>
43
+
44
+ <p>Pre-Commit & Pull-Request Dependency Exposure Gate</p>
45
+
46
+ [![CI](https://img.shields.io/github/actions/workflow/status/sadayamuthu/exposuregate/ci.yml?branch=main&label=CI&style=flat-square)](https://github.com/sadayamuthu/exposuregate/actions/workflows/ci.yml)
47
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue?style=flat-square)](https://www.python.org/downloads/)
48
+ [![License](https://img.shields.io/github/license/sadayamuthu/exposuregate?style=flat-square)](https://github.com/sadayamuthu/exposuregate/blob/main/LICENSE)
49
+ [![PyPI](https://img.shields.io/pypi/v/exposuregate?style=flat-square)](https://pypi.org/project/exposuregate/)
50
+
51
+ ---
52
+
53
+ [Quick Start](#quick-start) · [How It Works](#how-it-works) · [What It Grades](#what-it-grades) · [Installation](#installation) · [Configuration](#configuration) · [CLI Usage](#cli-usage)
54
+
55
+ </div>
56
+
57
+ ---
58
+
59
+ ## What is ExposureGate?
60
+
61
+ ExposureGate is a pre-commit hook and pull-request check. It stops a change from adding a
62
+ dependency version that has a known, exploited vulnerability. It is the counterpart of
63
+ [ControlGate](https://github.com/sadayamuthu/controlgate): ControlGate grades control findings in
64
+ a diff against OCBC, and ExposureGate grades the dependency versions a diff adds against OEBC.
65
+
66
+ OEBC (the Open Exposure Baseline Catalog) says which CVEs are being exploited: KEV-listed CVEs
67
+ are CRITICAL, and EPSS and CVSS set the lower tiers. OEBC has no package names or version ranges,
68
+ so ExposureGate joins it to a local export of the OSV database (see [Data Sources](#data-sources)).
69
+
70
+ ---
71
+
72
+ ## Quick Start
73
+
74
+ ```bash
75
+ # Install
76
+ pip install exposuregate
77
+
78
+ # Download the OEBC catalog and the OSV PyPI and npm exports (the only network step)
79
+ exposuregate update-data
80
+ exposuregate data-info
81
+
82
+ # Pre-commit (the default): grade the staged lockfiles against HEAD
83
+ git add uv.lock
84
+ exposuregate scan
85
+
86
+ # Pull request: grade HEAD against its merge-base with main
87
+ exposuregate scan --mode pr --target-branch main
88
+ ```
89
+
90
+ Exit code 0 is PASS or WARN, 1 is BLOCK, 2 is DENY or a usage error. See
91
+ [Exit codes](#exit-codes).
92
+
93
+ ---
94
+
95
+ ## How It Works
96
+
97
+ ```
98
+ git commit / pull request
99
+ ↓
100
+ lockfile diff (requirements*.txt, poetry.lock, uv.lock, package-lock.json)
101
+ ↓
102
+ added or changed (package, version) pins, direct and transitive
103
+ ↓
104
+ OSV advisories that affect each version → their CVE aliases
105
+ ↓
106
+ OEBC exposure_tier for each CVE (CRITICAL = KEV-listed, then EPSS and CVSS)
107
+ ↓
108
+ policy: block / warn / pass per finding, then transitive cap and suppressions
109
+ ↓
110
+ Verdict: DENY / BLOCK / WARN / PASS
111
+ ```
112
+
113
+ `scan` never uses the network. It reads the local data snapshot written by `update-data`, and
114
+ fails closed when that data is missing, stale, from the future or invalid.
115
+
116
+ ---
117
+
118
+ ## What It Grades
119
+
120
+ | Type | Files (by basename, anywhere in the repo) | Ecosystem |
121
+ |---|---|---|
122
+ | `requirements` | `requirements*.txt` (pip-compile style, `==` pins) | PyPI |
123
+ | `poetry` | `poetry.lock` (dependency kind from the `pyproject.toml` next to it) | PyPI |
124
+ | `uv` | `uv.lock` | PyPI |
125
+ | `npm` | `package-lock.json` (lockfileVersion 2 or 3) | npm |
126
+
127
+ It grades every locked version that a change adds or changes, direct and transitive. Each
128
+ finding is tagged `direct`, `transitive` or `unknown`. Packages the change leaves alone are not
129
+ graded, except with `--mode full`. Git, path, URL, editable and workspace sources are listed under
130
+ `skipped` and are not graded.
131
+
132
+ ---
133
+
134
+ ## Installation
135
+
136
+ Python 3.10 or later. The only runtime dependencies are `pyyaml`, `packaging`, and `tomli` on
137
+ Python 3.10.
138
+
139
+ ### From PyPI
140
+
141
+ ```bash
142
+ pip install exposuregate
143
+ ```
144
+
145
+ [View ExposureGate on PyPI](https://pypi.org/project/exposuregate/)
146
+
147
+ ### From source
148
+
149
+ ```bash
150
+ pip install git+https://github.com/sadayamuthu/exposuregate
151
+ # or, from a checkout
152
+ pip install .
153
+ ```
154
+
155
+ ### As a Pre-Commit Hook
156
+
157
+ ```yaml
158
+ # .pre-commit-config.yaml
159
+ repos:
160
+ - repo: https://github.com/sadayamuthu/exposuregate
161
+ rev: v0.1.0
162
+ hooks:
163
+ - id: exposuregate
164
+ ```
165
+
166
+ The hook runs `exposuregate scan --mode pre-commit` with `always_run: true` and `verbose: true`.
167
+ A `files` regex would bypass your configured globs (custom `lockfiles.*.globs`,
168
+ `unsupported_globs`) and pre-commit hides the output of passing hooks, which would hide WARN
169
+ reports. The scan itself decides what matters: a commit that changes no graded lockfile needs no
170
+ data and exits quickly with PASS.
171
+
172
+ Run `exposuregate update-data` regularly (daily) so the hook is not denied for stale data. A
173
+ commit that changes no graded lockfile never checks data freshness, so it is never blocked by
174
+ stale data.
175
+
176
+ ### As a GitHub Action
177
+
178
+ There are two ways to run ExposureGate on pull requests. Both check out with `fetch-depth: 0`,
179
+ cache the data directory keyed on the date, run `update-data` and then
180
+ `scan --mode pr --target-branch <base branch>`. The base branch usually exists only as
181
+ `origin/<branch>` in the checkout, and `--target-branch` falls back to that.
182
+
183
+ **Option 1: the composite action.** [`action.yml`](https://github.com/sadayamuthu/exposuregate/blob/main/action.yml) at the root of this repository
184
+ installs ExposureGate with pip, from the action's own checkout by default, so the tool always
185
+ matches the action ref (`@v0.1.0` runs ExposureGate 0.1.0). It then runs the scan:
186
+
187
+ ```yaml
188
+ # .github/workflows/exposuregate.yml
189
+ name: exposuregate
190
+ on:
191
+ pull_request:
192
+
193
+ permissions:
194
+ contents: read
195
+ pull-requests: write # only for the PR comment step
196
+
197
+ jobs:
198
+ exposuregate:
199
+ runs-on: ubuntu-latest
200
+ steps:
201
+ - uses: actions/checkout@v4
202
+ with:
203
+ fetch-depth: 0
204
+
205
+ - id: exposuregate
206
+ uses: sadayamuthu/exposuregate@v0.1.0
207
+
208
+ - name: Upload report.json
209
+ if: always()
210
+ uses: actions/upload-artifact@v4
211
+ with:
212
+ name: exposuregate-report
213
+ path: exposuregate-report/report.json
214
+
215
+ - name: Post report.md as a PR comment
216
+ if: always() && hashFiles('exposuregate-report/report.md') != ''
217
+ continue-on-error: true # fork PRs get a read-only token and cannot comment
218
+ env:
219
+ GH_TOKEN: ${{ github.token }}
220
+ run: gh pr comment ${{ github.event.pull_request.number }} --body-file exposuregate-report/report.md
221
+ ```
222
+
223
+ | Input | Default | Meaning |
224
+ |---|---|---|
225
+ | `target-branch` | the PR's base branch, else the repository's default branch, else `main` | Passed to `--target-branch` |
226
+ | `data-dir` | `~/.cache/exposuregate` | Data directory (`--data-dir`), cached per day; `~` is the home directory and a relative path is relative to the workspace |
227
+ | `update-data` | `true` | `false` skips the cache restore, `update-data` and the cache save, for example when `args` passes `--oebc`/`--osv-dir` files |
228
+ | `ecosystems` | `PyPI npm` | OSV exports for `update-data --ecosystems` |
229
+ | `config` | none | Config file (`--config`); otherwise `.exposuregate.yml` in `working-directory` |
230
+ | `output-dir` | `exposuregate-report` | Where `report.json` and `report.md` are written (`--output-dir`) |
231
+ | `args` | none | Extra `scan` arguments, such as `--fail-open` or `--max-age-days 200`. Split on whitespace with no quoting, so no argument may contain a space. `--mode`, `--target-branch`, `--path`, `--data-dir`, `--output-dir` and `--format` are set by the action and rejected here (use the inputs) |
232
+ | `version` | the action's own checkout | Set an exact version such as `0.1.0`, or `latest`, to install that release from PyPI instead |
233
+ | `python-version` | `3.12` | Python for `actions/setup-python` |
234
+ | `working-directory` | `.` | The repository to scan (`--path`) |
235
+
236
+ | Output | Meaning |
237
+ |---|---|
238
+ | `verdict` | `PASS`, `WARN`, `BLOCK` or `DENY`; empty when the scan failed before writing a report |
239
+ | `json` | Absolute path of `report.json` |
240
+ | `markdown` | Absolute path of `report.md` |
241
+ | `exit-code` | The scan's exit code |
242
+
243
+ The action fails the step with the scan's own exit code: 1 for BLOCK, 2 for DENY or a usage
244
+ error, so a WARN does not fail the job. It also writes the Markdown report to the job summary.
245
+ The day's data is saved to the cache right after `update-data`, before the scan, so a BLOCK or
246
+ DENY still caches it. If `update-data` fails, the step only warns, and the scan uses the cached
247
+ data or is denied for missing or stale data.
248
+
249
+ On pull requests from forks, `GITHUB_TOKEN` is read-only, so the PR comment step cannot post;
250
+ it is marked `continue-on-error` and the report is still in the artifact and the job summary.
251
+
252
+ **Option 2: raw steps.** [`docs/github-action.yml`](https://github.com/sadayamuthu/exposuregate/blob/main/docs/github-action.yml) is a ready-to-copy
253
+ workflow that does the same with plain `run:` steps, if you would rather not depend on the
254
+ action. It installs ExposureGate from PyPI, uploads `report.json` and posts `report.md` as a PR
255
+ comment (skipped when there is no report, and allowed to fail on fork PRs).
256
+
257
+ ---
258
+
259
+ ## Data: `update-data`
260
+
261
+ `scan` never uses the network. Download the data first:
262
+
263
+ ```bash
264
+ exposuregate update-data # OEBC catalog + OSV PyPI and npm exports
265
+ exposuregate update-data --ecosystems PyPI
266
+ exposuregate data-info # what is in the local snapshot, and how old it is
267
+ ```
268
+
269
+ The data goes into `~/.cache/exposuregate`. You can change this with `data.dir` in the config,
270
+ `EXPOSUREGATE_DATA_DIR` or `--data-dir`. The download is validated and indexed into
271
+ `index.sqlite`. The new index is written to a temporary file and renamed into place, so a failed
272
+ update leaves the old data as it was.
273
+
274
+ Freshness: data older than `data.max_age_days` (7 by default) is **stale**, and a scan that needs
275
+ it is denied (exit 2). This is checked per source (OEBC, and OSV for each ecosystem) and only for
276
+ ecosystems the change touches.
277
+
278
+ > **Operational note.** OEBC's newest published catalog is from 2026-06-14. Until OEBC's daily
279
+ > build is fixed, every scan that needs OEBC is denied as `data_stale` under the defaults. This is
280
+ > the intended fail-closed behaviour. The explicit escape hatches are `--max-age-days N` (or
281
+ > `data.oebc_max_age_days`) and `--fail-open`.
282
+
283
+ ---
284
+
285
+ ## Configuration
286
+
287
+ ExposureGate reads `.exposuregate.yml` from the directory given by `--path` (default: the
288
+ current directory), or the file given by `--config`. The file is deep-merged over the defaults
289
+ below, and lists replace the default list. An unknown key, a wrong type or an invalid value is a
290
+ usage error (exit 2). Every key changes behaviour. CLI flags override the config.
291
+
292
+ These are the defaults. `tests/test_readme.py` checks this block against
293
+ `exposuregate.config.DEFAULTS`, so the two cannot drift apart.
294
+
295
+ <!-- config-defaults:start -->
296
+ ```yaml
297
+ fail_closed: true # false: report denials with enforced: false, verdict floor WARN
298
+ data:
299
+ dir: ~/.cache/exposuregate # overridden by EXPOSUREGATE_DATA_DIR, then --data-dir
300
+ max_age_days: 7 # limit for both sources; --max-age-days sets this one
301
+ oebc_max_age_days: null # per-source override
302
+ osv_max_age_days: null # per-source override
303
+ policy: # actions: block | warn | pass
304
+ CRITICAL: block
305
+ HIGH: warn
306
+ MEDIUM: pass
307
+ LOW: pass
308
+ not_in_oebc: warn
309
+ no_cve: warn
310
+ unparseable_version: warn
311
+ unpinned: warn
312
+ unsupported_lockfile: warn
313
+ transitive_max_action: block # cap for transitive findings (warn = transitive is advisory)
314
+ lockfiles: # globs match the file's basename
315
+ requirements: { enabled: true, globs: ["requirements*.txt"] }
316
+ poetry: { enabled: true, globs: ["poetry.lock"] }
317
+ uv: { enabled: true, globs: ["uv.lock"] }
318
+ npm: { enabled: true, globs: ["package-lock.json"] }
319
+ unsupported_globs: [yarn.lock, pnpm-lock.yaml, Pipfile.lock, bun.lock, bun.lockb,
320
+ Cargo.lock, go.sum, Gemfile.lock, composer.lock, gradle.lockfile]
321
+ exclusions:
322
+ paths: [] # fnmatch globs on the full repo-relative path
323
+ suppressions: []
324
+ reporting:
325
+ format: [json, markdown]
326
+ output_dir: .exposuregate/reports # relative to --path; null to disable
327
+ ```
328
+ <!-- config-defaults:end -->
329
+
330
+ `exclusions.paths` is matched against the **full repo-relative path**, not the basename. To
331
+ exclude `examples/demo/uv.lock`, write `examples/*/uv.lock` or `examples/*` (`*` also matches
332
+ `/`). A bare `uv.lock` matches only the root lockfile. Matching is case-sensitive. Excluded files
333
+ are listed in the report with status `excluded`.
334
+
335
+ ### Suppressions
336
+
337
+ ```yaml
338
+ suppressions:
339
+ - id: CVE-2024-12345 # CVE or advisory ID (required)
340
+ package: urllib3 # optional, normalized name
341
+ ecosystem: PyPI # optional
342
+ reason: "not reachable: we never call X" # required, non-empty
343
+ expires: 2027-01-31 # required
344
+ ```
345
+
346
+ An expired suppression does not apply. It is listed under `expired_suppressions` and produces a
347
+ `suppression_expired` WARN finding.
348
+
349
+ ### Default policy
350
+
351
+ | Reason | Condition | Action |
352
+ |---|---|---|
353
+ | `graded` | CVE in OEBC with tier CRITICAL (KEV-listed) | block |
354
+ | `graded` | tier HIGH | warn |
355
+ | `graded` | tier MEDIUM or LOW | pass |
356
+ | `not_in_oebc` | the advisory's CVE is not in the OEBC snapshot | warn |
357
+ | `no_cve` | the advisory has no CVE alias | warn |
358
+ | `unparseable_version` | the version does not parse in its ecosystem's scheme | warn |
359
+ | `unpinned` | a requirements line that is not `==`/`===` | warn |
360
+ | `unsupported_lockfile` | `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, ... changed | warn |
361
+
362
+ After that, `policy.transitive_max_action` caps the action of transitive findings. Then a
363
+ matching, unexpired suppression sets the action to `pass` and records `suppressed_by`. Every
364
+ advisory match appears in the report, including passed and suppressed ones.
365
+
366
+ ---
367
+
368
+ ## CLI Usage
369
+
370
+ ```bash
371
+ # pre-commit (the default): staged lockfiles vs HEAD
372
+ exposuregate scan
373
+
374
+ # pull request: HEAD vs merge-base with the target branch
375
+ exposuregate scan --mode pr --target-branch main
376
+
377
+ # explicit refs, for replaying a historical PR (no merge-base is taken)
378
+ exposuregate scan --base <sha> --head <sha>
379
+
380
+ # everything currently pinned
381
+ exposuregate scan --mode full
382
+
383
+ # two files directly (reproducible evaluation)
384
+ exposuregate scan --old old/uv.lock --new new/uv.lock --lockfile-type uv \
385
+ --oebc oebc.json.gz --osv-dir osv/ --as-of 2026-11-13
386
+
387
+ # data management
388
+ exposuregate update-data
389
+ exposuregate data-info
390
+
391
+ # rebuild OEBC as of a past date (see "Replaying a past date")
392
+ exposuregate rebuild-oebc --as-of 2026-03-01 --kev known_exploited_vulnerabilities.json \
393
+ --epss-dir epss/ --oebc latest.json.gz --out oebc-2026-03-01.json.gz
394
+ ```
395
+
396
+ In `--mode pr`, `--target-branch` (default `main`) falls back to `origin/<branch>` when there is
397
+ no local branch of that name, as in a CI checkout. `--manifest FILE` supplies the
398
+ `pyproject.toml` for poetry direct/transitive tagging in `--old/--new` mode.
399
+
400
+ ### Exit codes
401
+
402
+ | Verdict | Meaning | Exit |
403
+ |---|---|---|
404
+ | `PASS` | no finding with action `warn` or `block` | 0 |
405
+ | `WARN` | the worst finding action is `warn` | 0 |
406
+ | `BLOCK` | at least one finding with action `block` | 1 |
407
+ | `DENY` | a fail-closed condition (data missing, stale, from the future or invalid; a lockfile that will not parse; a git error) with `fail_closed: true` | 2 |
408
+ | (none) | usage or config error: bad flags, unknown config key, not a git repo when a git mode is asked for | 2 |
409
+
410
+ Precedence is DENY > BLOCK > WARN > PASS. When a scan is denied, ExposureGate still grades
411
+ everything it can, so the report shows the findings next to the denial reasons. With
412
+ `fail_closed: false` or `--fail-open`, denials are reported with `enforced: false` and the
413
+ verdict comes from the findings, but a scan with any denial is never better than WARN.
414
+
415
+ ---
416
+
417
+ ## Output Formats
418
+
419
+ | Format | Use Case |
420
+ |--------|----------|
421
+ | `json` | Programmatic consumption, agents, dashboards; conforms to [`schemas/report.schema.json`](https://github.com/sadayamuthu/exposuregate/blob/main/schemas/report.schema.json) |
422
+ | `markdown` | PR comments, job summaries, terminal output |
423
+
424
+ Reports go to stdout, JSON first and then Markdown, or in the order given by
425
+ `--format json markdown`. If `reporting.output_dir` or `--output-dir` is set, they are also
426
+ written there as `report.json` and `report.md`. Progress lines go to stderr.
427
+
428
+ The default `output_dir` writes reports inside the repository, so add `.exposuregate/` to your
429
+ `.gitignore`.
430
+
431
+ Each finding records the lockfile, package, version and previous version, whether the dependency
432
+ is `direct` or `transitive`, the reason, the CVE and advisory IDs, the OEBC tier with its KEV,
433
+ EPSS and CVSS data, the `fixed_in` versions from OSV, the action before and after the transitive
434
+ cap and suppressions, and `suppressed_by`.
435
+
436
+ ---
437
+
438
+ ## Replaying a past date
439
+
440
+ For evaluation, a PR can be graded as it would have been graded on date D. OEBC serves only
441
+ `latest` and OSV's export is current-only, so there are two ways to do this:
442
+
443
+ **Option A: rebuild OEBC as of D.**
444
+
445
+ ```bash
446
+ exposuregate rebuild-oebc --as-of 2026-03-01 --kev known_exploited_vulnerabilities.json \
447
+ --epss-dir epss/ --oebc latest.json.gz --out oebc-2026-03-01.json.gz
448
+ exposuregate scan --base <sha> --head <sha> --as-of 2026-03-01 --replay \
449
+ --oebc oebc-2026-03-01.json.gz --osv-dir osv/
450
+ ```
451
+
452
+ `rebuild-oebc` applies OEBC's tier rule to the KEV entries with `dateAdded <= D`. Every one of
453
+ them is CRITICAL, even when OEBC and EPSS do not list it. It uses the EPSS daily file
454
+ `epss_scores-D.csv.gz`, or the nearest earlier one, and CVSS from the current catalog. The output
455
+ records each input's path, date and sha256 under `rebuilt_from`. `--replay` drops OSV advisories
456
+ published after D, and the report records `osv_mode: current_filtered`. Advisory ranges and
457
+ aliases are still today's, which is a known leak. OSV staleness and `data_from_future` checks are
458
+ not enforced in replay.
459
+
460
+ **Option B: the snapshot archive.**
461
+
462
+ ```bash
463
+ exposuregate scan --base <sha> --head <sha> --as-of 2026-11-13 \
464
+ --archive ~/data/exposuregate-archive --max-age-days 200
465
+ ```
466
+
467
+ `--archive DIR` reads the daily snapshot archive written by the paper's daily archive script,
468
+ which lives in the papers repository, not in this one. It
469
+ uses the OEBC catalog and the OSV state as archived on the latest day on or before `--as-of`
470
+ (`osv_mode: archived`), so nothing from later leaks in. `--archive` needs `--as-of` and cannot be
471
+ combined with `--oebc`/`--osv-dir`. The archived OEBC is still checked for freshness against
472
+ `--as-of`. Until OEBC's daily build is fixed, every archived catalog is the 2026-06-14 build, so an
473
+ archive replay is denied as stale unless you pass `--max-age-days`, as in the example.
474
+
475
+ ---
476
+
477
+ ## Planted-Exposure Set
478
+
479
+ `tests/test_planted.py` runs 24 planted cases against data frozen from the 2026-11-13 archive
480
+ day (OEBC built 2026-06-14). The cases cover every lockfile type: a direct KEV pin (BLOCK), a
481
+ transitive KEV pin (BLOCK, tagged `transitive`), a HIGH pin (WARN), a LOW pin (PASS) and a benign
482
+ bump (PASS). Further cases document OEBC staleness: CVEs that were KEV-listed after the OEBC build
483
+ grade as `not_in_oebc` or LOW. The cases, the chosen versions and the reason for each choice are in
484
+ `tests/fixtures/planted/cases.json`. `scripts/freeze_fixtures.py` regenerates the frozen data
485
+ from the archive and checks it.
486
+
487
+ ---
488
+
489
+ ## Limitations
490
+
491
+ - **No reachability.** A vulnerable version is flagged whether or not the vulnerable code is
492
+ called.
493
+ - **Lockfiles only.** Manifests are not resolved. A `pyproject.toml` or `package.json` change
494
+ without its lockfile is listed as `manifest_only` and not graded. `yarn.lock`,
495
+ `pnpm-lock.yaml`, `Pipfile.lock`, `package-lock.json` v1, Go, Maven and Cargo are not graded.
496
+ A changed unsupported lockfile gives a WARN, and a v1 `package-lock.json` is denied.
497
+ - **Requirements markers are ignored.** A pin is graded even when its environment marker means
498
+ it would never be installed. The marker is recorded on the finding, but this is a known source of
499
+ false positives. `-r`/`-c` includes are not followed.
500
+ - **OEBC tiers are used as published.** A CVE KEV-listed after the OEBC snapshot was built is
501
+ `not_in_oebc` (WARN) or carries its older tier until OEBC is rebuilt. With OEBC's build stalled
502
+ at 2026-06-14, this already matters: see the `stale_oebc` planted cases.
503
+ - **Replay leakage (option A).** OSV ranges and aliases are as of today. CVSS comes from the
504
+ current OEBC catalog.
505
+ - **Matching** uses OSV `versions` lists and `ECOSYSTEM`/`SEMVER` ranges. `GIT` ranges are
506
+ ignored. A range with an unparseable event version is skipped.
507
+
508
+ ---
509
+
510
+ ## Development
511
+
512
+ ```bash
513
+ make install-dev # Install with dev dependencies
514
+ make test # Run tests
515
+ make test-cov # Run tests with coverage (fails under 90%)
516
+ make lint # Lint with ruff
517
+ make format # Apply ruff's automatic lint fixes
518
+ make typecheck # Type check with mypy (strict)
519
+ make check # Run all checks (lint + typecheck + test)
520
+ make build # Build distribution packages
521
+ ```
522
+
523
+ Or without `make`:
524
+
525
+ ```bash
526
+ python -m venv .venv && .venv/bin/pip install -e '.[dev]'
527
+ .venv/bin/pytest -q --cov=exposuregate --cov-report=term-missing
528
+ .venv/bin/ruff check . && .venv/bin/mypy src
529
+ ```
530
+
531
+ ---
532
+
533
+ ## AI Agent Skills
534
+
535
+ ExposureGate provides skills for popular AI coding assistants. They teach the agent to run
536
+ `exposuregate scan` before committing a lockfile change, read the JSON report, and fix a finding
537
+ by moving the package to a version in its `fixed_in` list, never by adding a suppression without a
538
+ reason and an expiry date.
539
+
540
+ The agent prompts and workflows are located in the [`skills/`](https://github.com/sadayamuthu/exposuregate/tree/main/skills) directory:
541
+
542
+ - **Antigravity**: Full workflow definitions available in `skills/antigravity/exposuregate/`
543
+ - **Cursor**: Repository rules available in `skills/cursor/.cursorrules`
544
+ - **Claude Code**: System prompt instructions in `skills/claude_code/.clauderules`
545
+ - **Codex**: Integration prompts in `skills/codex/instructions.md`
546
+
547
+ ---
548
+
549
+ ## Data Sources
550
+
551
+ ExposureGate joins two public data sets, both downloaded by `exposuregate update-data`:
552
+
553
+ - **[OEBC](https://github.com/sadayamuthu/oebc)**, the Open Exposure Baseline Catalog, published
554
+ by [OpenAstra](https://openastra.org): one `exposure_tier` per CVE, from CISA KEV (CRITICAL),
555
+ FIRST EPSS and CVSS. Downloaded from `https://openastra.org/oebc/catalog/v0.1/latest.json.gz`.
556
+ - **[OSV](https://osv.dev)**: the per-ecosystem exports of the OSV database
557
+ (`https://osv-vulnerabilities.storage.googleapis.com/<ecosystem>/all.zip`, for `PyPI` and
558
+ `npm`), which map package versions to advisories and advisories to CVE aliases.
559
+
560
+ `exposuregate data-info` shows the OEBC build date and the OSV export dates in the local
561
+ snapshot, and every report records them under `data`.
562
+
563
+ ---
564
+
565
+ ## Contributing
566
+
567
+ Bug reports, wrong or missed findings and new lockfile parsers are welcome. See
568
+ [CONTRIBUTING.md](https://github.com/sadayamuthu/exposuregate/blob/main/CONTRIBUTING.md) for the development setup, the tests and how to add a lockfile
569
+ parser.
570
+
571
+ ---
572
+
573
+ ## Security
574
+
575
+ Please report security problems privately, not in a public issue. See
576
+ [SECURITY.md](https://github.com/sadayamuthu/exposuregate/blob/main/SECURITY.md).
577
+
578
+ ---
579
+
580
+ ## License
581
+
582
+ [MIT License](https://github.com/sadayamuthu/exposuregate/blob/main/LICENSE). See [CHANGELOG.md](https://github.com/sadayamuthu/exposuregate/blob/main/CHANGELOG.md) for release notes.
583
+
584
+ ---
585
+
586
+ <div align="center">
587
+
588
+ **ExposureGate is an open-source exploited-vulnerability gate for dependency lockfiles**
589
+
590
+ ExposureGate is managed by [OpenAstra](https://openastra.org).
591
+
592
+ [PyPI](https://pypi.org/project/exposuregate/) · [GitHub](https://github.com/sadayamuthu/exposuregate) · [OEBC](https://github.com/sadayamuthu/oebc) · [ControlGate](https://github.com/sadayamuthu/controlgate)
593
+
594
+ </div>