cerberus-sca 1.0.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.
- cerberus_sca-1.0.0/.gitignore +9 -0
- cerberus_sca-1.0.0/LICENSE +21 -0
- cerberus_sca-1.0.0/PKG-INFO +241 -0
- cerberus_sca-1.0.0/README.md +222 -0
- cerberus_sca-1.0.0/dep_gate/__init__.py +3 -0
- cerberus_sca-1.0.0/dep_gate/cache.py +57 -0
- cerberus_sca-1.0.0/dep_gate/cli.py +386 -0
- cerberus_sca-1.0.0/dep_gate/diff.py +80 -0
- cerberus_sca-1.0.0/dep_gate/github_client.py +184 -0
- cerberus_sca-1.0.0/dep_gate/lockfile.py +191 -0
- cerberus_sca-1.0.0/dep_gate/osv_client.py +105 -0
- cerberus_sca-1.0.0/dep_gate/sarif.py +100 -0
- cerberus_sca-1.0.0/dep_gate/sbom.py +50 -0
- cerberus_sca-1.0.0/dep_gate/severity.py +164 -0
- cerberus_sca-1.0.0/dep_gate/suppress.py +140 -0
- cerberus_sca-1.0.0/pyproject.toml +49 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shankar Adhikary
|
|
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,241 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cerberus-sca
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A CI gate that scans package-lock.json/requirements.txt/go.sum/Cargo.lock against OSV.dev and fails the build on vulnerabilities at or above a configurable severity threshold.
|
|
5
|
+
Project-URL: Repository, https://github.com/ShankarAdhikary/Cerberus
|
|
6
|
+
Author-email: Shankar Adhikary <adhikaryshankar04@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: cvss==3.6
|
|
14
|
+
Requires-Dist: requests==2.32.5
|
|
15
|
+
Requires-Dist: rich==15.0.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest==9.1.1; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Dependency Vulnerability Gate
|
|
21
|
+
|
|
22
|
+
A minimal Software Composition Analysis (SCA) CLI that scans `package-lock.json`
|
|
23
|
+
or `requirements.txt` against the [OSV.dev](https://osv.dev) vulnerability
|
|
24
|
+
database and fails CI when findings meet a severity threshold.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
1. **Parse** the lockfile into a flat list of `{name, version, ecosystem}`.
|
|
29
|
+
2. **Batch query** `POST /v1/querybatch` — cheap, returns only vulnerability
|
|
30
|
+
IDs per package, not full details.
|
|
31
|
+
3. **Hydrate** each *unique* vulnerability ID via `GET /v1/vulns/{id}` to get
|
|
32
|
+
the full record (severity, description, fixed version), caching so the
|
|
33
|
+
same CVE isn't fetched twice even if it affects many packages.
|
|
34
|
+
4. **Score** each finding: parse the CVSS vector if present, fall back to
|
|
35
|
+
the advisory's plain-text `database_specific.severity` rating, or mark
|
|
36
|
+
it `UNKNOWN` if neither exists.
|
|
37
|
+
5. **Gate**: exit `1` if any finding is at or above `--fail-on` (default
|
|
38
|
+
`high`), else exit `0`.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install dep-vuln-gate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
From [PyPI](https://pypi.org/project/dep-vuln-gate/) — verified end-to-end
|
|
47
|
+
in a clean venv (install, `--help`, a real live scan against OSV.dev).
|
|
48
|
+
Note the PyPI/`pip install` name is `dep-vuln-gate`, not `dep-gate` —
|
|
49
|
+
that shorter name was already too similar to an existing, unrelated PyPI
|
|
50
|
+
package for PyPI's own upload validator to accept (see `docs/Tracker.md`
|
|
51
|
+
for the exact rejection and the availability checks that did and didn't
|
|
52
|
+
catch it up front). The **console command** installed either way is
|
|
53
|
+
`dep-gate` (short, what you actually type) — only the package name you
|
|
54
|
+
`pip install` differs.
|
|
55
|
+
|
|
56
|
+
Also installable from a tagged release without PyPI at all (equally
|
|
57
|
+
verified):
|
|
58
|
+
```bash
|
|
59
|
+
pip install git+https://github.com/ShankarAdhikary/Cerberus.git@v1.0.0
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Alternatively, without installing the package at all:
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/ShankarAdhikary/Cerberus.git
|
|
65
|
+
cd Cerberus/dep-vuln-gate
|
|
66
|
+
pip install -r requirements.txt
|
|
67
|
+
python -m dep_gate.cli --file package-lock.json --fail-on high
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**In another repo's own CI**, pin to a released version rather than a
|
|
71
|
+
moving target:
|
|
72
|
+
```yaml
|
|
73
|
+
- run: pip install dep-vuln-gate==1.0.0
|
|
74
|
+
- run: dep-gate --file package-lock.json --fail-on high
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
dep-gate --file package-lock.json --fail-on high
|
|
81
|
+
dep-gate --file requirements.txt --fail-on critical --json report.json
|
|
82
|
+
|
|
83
|
+
# --file is repeatable: scan multiple lockfiles in one invocation, with
|
|
84
|
+
# one combined JSON/SARIF/SBOM/PR-comment output instead of one per file.
|
|
85
|
+
dep-gate --file package-lock.json --file requirements.txt --fail-on high
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
(`python -m dep_gate.cli` works identically to `dep-gate` — the console
|
|
89
|
+
script is `dep_gate.cli:run` with no wrapper, so both invoke the exact
|
|
90
|
+
same code path. Every example below uses the module form only because it
|
|
91
|
+
also works without installing the package; swap in `dep-gate` freely.)
|
|
92
|
+
|
|
93
|
+
Flags:
|
|
94
|
+
- `--file PATH` — required, **repeatable**. Path to a lockfile
|
|
95
|
+
(`package-lock.json`, `requirements.txt`, `go.sum`, `Cargo.lock`).
|
|
96
|
+
Repeat it to scan several in one run; a single `--file` behaves exactly
|
|
97
|
+
as it always has.
|
|
98
|
+
- `--fail-on {low,moderate,high,critical}` — minimum severity that blocks the build.
|
|
99
|
+
- `--json PATH` — also write a machine-readable report.
|
|
100
|
+
- `--fail-open` — exit `0` instead of `1` if OSV.dev is unreachable (off by
|
|
101
|
+
default; a broken security gate should fail loudly, not silently pass).
|
|
102
|
+
- `--diff-only --base-ref REF` — only scan dependencies that are new or
|
|
103
|
+
version-changed relative to `REF` (see "Delta scanning" below).
|
|
104
|
+
- `--verbose` — add a "Source" column/JSON field showing how each
|
|
105
|
+
severity was derived (e.g. `CVSS_V3 vector` vs.
|
|
106
|
+
`database_specific.severity`). Off by default; purely additive.
|
|
107
|
+
- `--sbom PATH` — write a CycloneDX JSON Software Bill of Materials
|
|
108
|
+
listing every dependency scanned (respects `--diff-only`). Independent
|
|
109
|
+
of findings — written even on a clean scan or an OSV.dev outage. For a
|
|
110
|
+
complete build inventory, run it *without* `--diff-only` — the PR-gate
|
|
111
|
+
workflow below only ever scans the changed subset, so a `--sbom` there
|
|
112
|
+
would be a partial bill of materials, not a full one.
|
|
113
|
+
- `--ignore-file PATH` — suppress accepted-risk findings via a
|
|
114
|
+
`.dep-gate-ignore.yml` file (see below). A suppressed finding stays
|
|
115
|
+
visible in the table/JSON (marked, never hidden) and just doesn't
|
|
116
|
+
count toward `--fail-on`.
|
|
117
|
+
- `--sarif PATH` — also write a SARIF 2.1.0 report for GitHub
|
|
118
|
+
code-scanning upload (`github/codeql-action/upload-sarif`). Same
|
|
119
|
+
findings as `--json`, different shape.
|
|
120
|
+
- `--cache-file PATH` — reuse a local JSON cache of hydrated OSV records
|
|
121
|
+
across runs, skipping re-fetches for a vuln ID already fetched within
|
|
122
|
+
the last 6 hours. Off by default. Persist the file across CI runs
|
|
123
|
+
(e.g. `actions/cache`) to get the actual benefit.
|
|
124
|
+
- `--pr-comment` — post/update an idempotent PR comment summarizing
|
|
125
|
+
blocking findings, via the GitHub API. Off by default — this is the
|
|
126
|
+
tool's *only other* network egress point besides `api.osv.dev`.
|
|
127
|
+
Requires `--github-repo OWNER/REPO`, `--github-pr-number N`, and a
|
|
128
|
+
`GITHUB_TOKEN` environment variable (the workflow's own token — never
|
|
129
|
+
pass it as a CLI argument). A comment-posting failure prints a warning
|
|
130
|
+
but never changes the exit code.
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
# in a pull_request-triggered job, with:
|
|
134
|
+
# permissions:
|
|
135
|
+
# pull-requests: write
|
|
136
|
+
- run: >
|
|
137
|
+
python -m dep_gate.cli --file package-lock.json --file requirements.txt
|
|
138
|
+
--fail-on high
|
|
139
|
+
--pr-comment
|
|
140
|
+
--github-repo ${{ github.repository }}
|
|
141
|
+
--github-pr-number ${{ github.event.pull_request.number }}
|
|
142
|
+
env:
|
|
143
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
This is exactly what `security-scan.yml` below does: one combined
|
|
147
|
+
invocation across every lockfile present, so `--pr-comment` posts a
|
|
148
|
+
single comment covering all of them (grouped by file — see Design.md §4)
|
|
149
|
+
instead of one ecosystem's `--pr-comment` call finding-and-overwriting
|
|
150
|
+
another's. (An earlier version of this project ran one `dep_gate.cli`
|
|
151
|
+
invocation per ecosystem, which made `--pr-comment` impossible to wire in
|
|
152
|
+
safely — each invocation would've silently dropped every other
|
|
153
|
+
ecosystem's findings from the comment. Multi-file `--file` support fixed
|
|
154
|
+
that at the root, rather than working around it.)
|
|
155
|
+
|
|
156
|
+
## Suppressing accepted-risk findings
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
# .dep-gate-ignore.yml
|
|
160
|
+
- vuln_id: GHSA-jf85-cpcp-j695
|
|
161
|
+
package: lodash # optional - omit to suppress this ID for any package
|
|
162
|
+
expires: 2026-12-31 # required
|
|
163
|
+
reason: "Accepted risk - vendor patch pending, tracked in JIRA-1234"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`vuln_id`, `expires` (`YYYY-MM-DD`), and a non-blank `reason` are all
|
|
167
|
+
required per entry — a malformed file exits `2` rather than silently
|
|
168
|
+
ignoring the bad entry. Once `expires` passes, the entry stops
|
|
169
|
+
suppressing automatically and that finding blocks the build again.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
python -m dep_gate.cli --file package-lock.json --ignore-file .dep-gate-ignore.yml
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Delta scanning
|
|
176
|
+
|
|
177
|
+
By default the tool re-scans every dependency in the lockfile on every run.
|
|
178
|
+
With `--diff-only`, it instead reads the lockfile's content at `--base-ref`
|
|
179
|
+
via `git show` (no checkout needed), parses both versions, and only reports
|
|
180
|
+
on dependencies that are **new** or have a **different pinned version** —
|
|
181
|
+
a version bump counts as changed even if the old version had no known
|
|
182
|
+
vulnerabilities, since the new one might.
|
|
183
|
+
|
|
184
|
+
This mirrors how Dependabot avoids re-flagging pre-existing vulnerable
|
|
185
|
+
dependencies on every PR: a gate that only complains about risk *you just
|
|
186
|
+
introduced* is far less likely to get muted or bypassed by a frustrated team.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
python -m dep_gate.cli --file requirements.txt --diff-only --base-ref origin/main
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Requires running inside a git repo with the base ref actually fetched — in
|
|
193
|
+
GitHub Actions this means `actions/checkout@v4` with `fetch-depth: 0`
|
|
194
|
+
(shallow clones won't have the base branch's history available). If the
|
|
195
|
+
lockfile didn't exist at the base ref at all (e.g. it's brand new), every
|
|
196
|
+
dependency in it is treated as new — not an error.
|
|
197
|
+
|
|
198
|
+
## Supported lockfiles
|
|
199
|
+
|
|
200
|
+
- `package-lock.json` (npm lockfile v1, v2, and v3 — including transitive deps)
|
|
201
|
+
- `requirements.txt` (only pinned `==` lines have a resolvable version;
|
|
202
|
+
unpinned/VCS lines are skipped with a warning)
|
|
203
|
+
- `go.sum` (Go modules — the `/go.mod`-hash and content-hash rows for the
|
|
204
|
+
same module collapse into one dependency)
|
|
205
|
+
- `Cargo.lock` (Rust — only registry (crates.io) packages are checked;
|
|
206
|
+
path/workspace and git dependencies are skipped, since they aren't
|
|
207
|
+
published to crates.io and have no OSV-resolvable version)
|
|
208
|
+
|
|
209
|
+
## Testing
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
pip install -r requirements-dev.txt
|
|
213
|
+
python -m pytest
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The suite is fully network-free (the OSV.dev boundary is mocked in
|
|
217
|
+
`tests/test_osv_client.py` and `tests/test_cli.py`); `tests/test_diff.py`
|
|
218
|
+
spins up real temporary git repositories rather than mocking `git`.
|
|
219
|
+
|
|
220
|
+
## CI integration
|
|
221
|
+
|
|
222
|
+
See `.github/workflows/security-scan.yml`. Two triggers:
|
|
223
|
+
- **`pull_request`** — diff-only scan of whatever lockfile(s) the PR
|
|
224
|
+
touches; uploads the JSON report as a build artifact and the SARIF
|
|
225
|
+
report to code scanning.
|
|
226
|
+
- **`push` to `main`** — full (non-diff) scan on every merge. This isn't
|
|
227
|
+
just belt-and-suspenders: GitHub's code-scanning alerts only report a
|
|
228
|
+
PR's findings as "new" relative to the last analysis on the default
|
|
229
|
+
branch, so without this trigger a PR's SARIF upload silently never
|
|
230
|
+
shows up as a visible alert, even though it succeeds.
|
|
231
|
+
|
|
232
|
+
## Known limitations / good next steps
|
|
233
|
+
|
|
234
|
+
- Four ecosystems supported (`npm`, `PyPI`, `Go`, `crates.io`). OSV also
|
|
235
|
+
covers Maven, RubyGems, Packagist, etc. — the same batch/hydrate client
|
|
236
|
+
works for those, you'd just need lockfile parsers for each.
|
|
237
|
+
- SPDX SBOM format not supported (CycloneDX is).
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Dependency Vulnerability Gate
|
|
2
|
+
|
|
3
|
+
A minimal Software Composition Analysis (SCA) CLI that scans `package-lock.json`
|
|
4
|
+
or `requirements.txt` against the [OSV.dev](https://osv.dev) vulnerability
|
|
5
|
+
database and fails CI when findings meet a severity threshold.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
1. **Parse** the lockfile into a flat list of `{name, version, ecosystem}`.
|
|
10
|
+
2. **Batch query** `POST /v1/querybatch` — cheap, returns only vulnerability
|
|
11
|
+
IDs per package, not full details.
|
|
12
|
+
3. **Hydrate** each *unique* vulnerability ID via `GET /v1/vulns/{id}` to get
|
|
13
|
+
the full record (severity, description, fixed version), caching so the
|
|
14
|
+
same CVE isn't fetched twice even if it affects many packages.
|
|
15
|
+
4. **Score** each finding: parse the CVSS vector if present, fall back to
|
|
16
|
+
the advisory's plain-text `database_specific.severity` rating, or mark
|
|
17
|
+
it `UNKNOWN` if neither exists.
|
|
18
|
+
5. **Gate**: exit `1` if any finding is at or above `--fail-on` (default
|
|
19
|
+
`high`), else exit `0`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install dep-vuln-gate
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
From [PyPI](https://pypi.org/project/dep-vuln-gate/) — verified end-to-end
|
|
28
|
+
in a clean venv (install, `--help`, a real live scan against OSV.dev).
|
|
29
|
+
Note the PyPI/`pip install` name is `dep-vuln-gate`, not `dep-gate` —
|
|
30
|
+
that shorter name was already too similar to an existing, unrelated PyPI
|
|
31
|
+
package for PyPI's own upload validator to accept (see `docs/Tracker.md`
|
|
32
|
+
for the exact rejection and the availability checks that did and didn't
|
|
33
|
+
catch it up front). The **console command** installed either way is
|
|
34
|
+
`dep-gate` (short, what you actually type) — only the package name you
|
|
35
|
+
`pip install` differs.
|
|
36
|
+
|
|
37
|
+
Also installable from a tagged release without PyPI at all (equally
|
|
38
|
+
verified):
|
|
39
|
+
```bash
|
|
40
|
+
pip install git+https://github.com/ShankarAdhikary/Cerberus.git@v1.0.0
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Alternatively, without installing the package at all:
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/ShankarAdhikary/Cerberus.git
|
|
46
|
+
cd Cerberus/dep-vuln-gate
|
|
47
|
+
pip install -r requirements.txt
|
|
48
|
+
python -m dep_gate.cli --file package-lock.json --fail-on high
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**In another repo's own CI**, pin to a released version rather than a
|
|
52
|
+
moving target:
|
|
53
|
+
```yaml
|
|
54
|
+
- run: pip install dep-vuln-gate==1.0.0
|
|
55
|
+
- run: dep-gate --file package-lock.json --fail-on high
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
dep-gate --file package-lock.json --fail-on high
|
|
62
|
+
dep-gate --file requirements.txt --fail-on critical --json report.json
|
|
63
|
+
|
|
64
|
+
# --file is repeatable: scan multiple lockfiles in one invocation, with
|
|
65
|
+
# one combined JSON/SARIF/SBOM/PR-comment output instead of one per file.
|
|
66
|
+
dep-gate --file package-lock.json --file requirements.txt --fail-on high
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
(`python -m dep_gate.cli` works identically to `dep-gate` — the console
|
|
70
|
+
script is `dep_gate.cli:run` with no wrapper, so both invoke the exact
|
|
71
|
+
same code path. Every example below uses the module form only because it
|
|
72
|
+
also works without installing the package; swap in `dep-gate` freely.)
|
|
73
|
+
|
|
74
|
+
Flags:
|
|
75
|
+
- `--file PATH` — required, **repeatable**. Path to a lockfile
|
|
76
|
+
(`package-lock.json`, `requirements.txt`, `go.sum`, `Cargo.lock`).
|
|
77
|
+
Repeat it to scan several in one run; a single `--file` behaves exactly
|
|
78
|
+
as it always has.
|
|
79
|
+
- `--fail-on {low,moderate,high,critical}` — minimum severity that blocks the build.
|
|
80
|
+
- `--json PATH` — also write a machine-readable report.
|
|
81
|
+
- `--fail-open` — exit `0` instead of `1` if OSV.dev is unreachable (off by
|
|
82
|
+
default; a broken security gate should fail loudly, not silently pass).
|
|
83
|
+
- `--diff-only --base-ref REF` — only scan dependencies that are new or
|
|
84
|
+
version-changed relative to `REF` (see "Delta scanning" below).
|
|
85
|
+
- `--verbose` — add a "Source" column/JSON field showing how each
|
|
86
|
+
severity was derived (e.g. `CVSS_V3 vector` vs.
|
|
87
|
+
`database_specific.severity`). Off by default; purely additive.
|
|
88
|
+
- `--sbom PATH` — write a CycloneDX JSON Software Bill of Materials
|
|
89
|
+
listing every dependency scanned (respects `--diff-only`). Independent
|
|
90
|
+
of findings — written even on a clean scan or an OSV.dev outage. For a
|
|
91
|
+
complete build inventory, run it *without* `--diff-only` — the PR-gate
|
|
92
|
+
workflow below only ever scans the changed subset, so a `--sbom` there
|
|
93
|
+
would be a partial bill of materials, not a full one.
|
|
94
|
+
- `--ignore-file PATH` — suppress accepted-risk findings via a
|
|
95
|
+
`.dep-gate-ignore.yml` file (see below). A suppressed finding stays
|
|
96
|
+
visible in the table/JSON (marked, never hidden) and just doesn't
|
|
97
|
+
count toward `--fail-on`.
|
|
98
|
+
- `--sarif PATH` — also write a SARIF 2.1.0 report for GitHub
|
|
99
|
+
code-scanning upload (`github/codeql-action/upload-sarif`). Same
|
|
100
|
+
findings as `--json`, different shape.
|
|
101
|
+
- `--cache-file PATH` — reuse a local JSON cache of hydrated OSV records
|
|
102
|
+
across runs, skipping re-fetches for a vuln ID already fetched within
|
|
103
|
+
the last 6 hours. Off by default. Persist the file across CI runs
|
|
104
|
+
(e.g. `actions/cache`) to get the actual benefit.
|
|
105
|
+
- `--pr-comment` — post/update an idempotent PR comment summarizing
|
|
106
|
+
blocking findings, via the GitHub API. Off by default — this is the
|
|
107
|
+
tool's *only other* network egress point besides `api.osv.dev`.
|
|
108
|
+
Requires `--github-repo OWNER/REPO`, `--github-pr-number N`, and a
|
|
109
|
+
`GITHUB_TOKEN` environment variable (the workflow's own token — never
|
|
110
|
+
pass it as a CLI argument). A comment-posting failure prints a warning
|
|
111
|
+
but never changes the exit code.
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
# in a pull_request-triggered job, with:
|
|
115
|
+
# permissions:
|
|
116
|
+
# pull-requests: write
|
|
117
|
+
- run: >
|
|
118
|
+
python -m dep_gate.cli --file package-lock.json --file requirements.txt
|
|
119
|
+
--fail-on high
|
|
120
|
+
--pr-comment
|
|
121
|
+
--github-repo ${{ github.repository }}
|
|
122
|
+
--github-pr-number ${{ github.event.pull_request.number }}
|
|
123
|
+
env:
|
|
124
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This is exactly what `security-scan.yml` below does: one combined
|
|
128
|
+
invocation across every lockfile present, so `--pr-comment` posts a
|
|
129
|
+
single comment covering all of them (grouped by file — see Design.md §4)
|
|
130
|
+
instead of one ecosystem's `--pr-comment` call finding-and-overwriting
|
|
131
|
+
another's. (An earlier version of this project ran one `dep_gate.cli`
|
|
132
|
+
invocation per ecosystem, which made `--pr-comment` impossible to wire in
|
|
133
|
+
safely — each invocation would've silently dropped every other
|
|
134
|
+
ecosystem's findings from the comment. Multi-file `--file` support fixed
|
|
135
|
+
that at the root, rather than working around it.)
|
|
136
|
+
|
|
137
|
+
## Suppressing accepted-risk findings
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
# .dep-gate-ignore.yml
|
|
141
|
+
- vuln_id: GHSA-jf85-cpcp-j695
|
|
142
|
+
package: lodash # optional - omit to suppress this ID for any package
|
|
143
|
+
expires: 2026-12-31 # required
|
|
144
|
+
reason: "Accepted risk - vendor patch pending, tracked in JIRA-1234"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`vuln_id`, `expires` (`YYYY-MM-DD`), and a non-blank `reason` are all
|
|
148
|
+
required per entry — a malformed file exits `2` rather than silently
|
|
149
|
+
ignoring the bad entry. Once `expires` passes, the entry stops
|
|
150
|
+
suppressing automatically and that finding blocks the build again.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
python -m dep_gate.cli --file package-lock.json --ignore-file .dep-gate-ignore.yml
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Delta scanning
|
|
157
|
+
|
|
158
|
+
By default the tool re-scans every dependency in the lockfile on every run.
|
|
159
|
+
With `--diff-only`, it instead reads the lockfile's content at `--base-ref`
|
|
160
|
+
via `git show` (no checkout needed), parses both versions, and only reports
|
|
161
|
+
on dependencies that are **new** or have a **different pinned version** —
|
|
162
|
+
a version bump counts as changed even if the old version had no known
|
|
163
|
+
vulnerabilities, since the new one might.
|
|
164
|
+
|
|
165
|
+
This mirrors how Dependabot avoids re-flagging pre-existing vulnerable
|
|
166
|
+
dependencies on every PR: a gate that only complains about risk *you just
|
|
167
|
+
introduced* is far less likely to get muted or bypassed by a frustrated team.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
python -m dep_gate.cli --file requirements.txt --diff-only --base-ref origin/main
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Requires running inside a git repo with the base ref actually fetched — in
|
|
174
|
+
GitHub Actions this means `actions/checkout@v4` with `fetch-depth: 0`
|
|
175
|
+
(shallow clones won't have the base branch's history available). If the
|
|
176
|
+
lockfile didn't exist at the base ref at all (e.g. it's brand new), every
|
|
177
|
+
dependency in it is treated as new — not an error.
|
|
178
|
+
|
|
179
|
+
## Supported lockfiles
|
|
180
|
+
|
|
181
|
+
- `package-lock.json` (npm lockfile v1, v2, and v3 — including transitive deps)
|
|
182
|
+
- `requirements.txt` (only pinned `==` lines have a resolvable version;
|
|
183
|
+
unpinned/VCS lines are skipped with a warning)
|
|
184
|
+
- `go.sum` (Go modules — the `/go.mod`-hash and content-hash rows for the
|
|
185
|
+
same module collapse into one dependency)
|
|
186
|
+
- `Cargo.lock` (Rust — only registry (crates.io) packages are checked;
|
|
187
|
+
path/workspace and git dependencies are skipped, since they aren't
|
|
188
|
+
published to crates.io and have no OSV-resolvable version)
|
|
189
|
+
|
|
190
|
+
## Testing
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -r requirements-dev.txt
|
|
194
|
+
python -m pytest
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The suite is fully network-free (the OSV.dev boundary is mocked in
|
|
198
|
+
`tests/test_osv_client.py` and `tests/test_cli.py`); `tests/test_diff.py`
|
|
199
|
+
spins up real temporary git repositories rather than mocking `git`.
|
|
200
|
+
|
|
201
|
+
## CI integration
|
|
202
|
+
|
|
203
|
+
See `.github/workflows/security-scan.yml`. Two triggers:
|
|
204
|
+
- **`pull_request`** — diff-only scan of whatever lockfile(s) the PR
|
|
205
|
+
touches; uploads the JSON report as a build artifact and the SARIF
|
|
206
|
+
report to code scanning.
|
|
207
|
+
- **`push` to `main`** — full (non-diff) scan on every merge. This isn't
|
|
208
|
+
just belt-and-suspenders: GitHub's code-scanning alerts only report a
|
|
209
|
+
PR's findings as "new" relative to the last analysis on the default
|
|
210
|
+
branch, so without this trigger a PR's SARIF upload silently never
|
|
211
|
+
shows up as a visible alert, even though it succeeds.
|
|
212
|
+
|
|
213
|
+
## Known limitations / good next steps
|
|
214
|
+
|
|
215
|
+
- Four ecosystems supported (`npm`, `PyPI`, `Go`, `crates.io`). OSV also
|
|
216
|
+
covers Maven, RubyGems, Packagist, etc. — the same batch/hydrate client
|
|
217
|
+
works for those, you'd just need lockfile parsers for each.
|
|
218
|
+
- SPDX SBOM format not supported (CycloneDX is).
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A flat-JSON, TTL-based cache of hydrated OSV vuln records
|
|
3
|
+
(GET /v1/vulns/{id} responses), keyed by vuln_id, so a CI job that runs
|
|
4
|
+
repeatedly against the same repo doesn't re-fetch a record it already
|
|
5
|
+
has fresh.
|
|
6
|
+
|
|
7
|
+
Why TTL instead of OSV's `modified` timestamp: `batch_query()`'s response
|
|
8
|
+
does carry a per-result `modified` field (see Schema.md §5.1), which
|
|
9
|
+
could in principle drive exact staleness checks - but wiring that through
|
|
10
|
+
would change `batch_query()`'s documented, already-tested return shape
|
|
11
|
+
(`Dict[str, set[str]]`) into something structurally different, for a
|
|
12
|
+
benefit (byte-exact staleness detection) that a bounded TTL already
|
|
13
|
+
delivers with far less risk to the existing, well-tested contract. A
|
|
14
|
+
security tool's cache staleness window should be small and explicit
|
|
15
|
+
either way, so `CACHE_TTL_SECONDS` is deliberately short.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
CACHE_TTL_SECONDS = 6 * 60 * 60 # 6 hours
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_cache(cache_path: str) -> dict[str, dict]:
|
|
26
|
+
"""
|
|
27
|
+
Load the cache file. Returns `{}` if it doesn't exist (first run) or
|
|
28
|
+
is unreadable/corrupt - a broken cache file must never take down the
|
|
29
|
+
scan; it's just treated as an empty cache and rebuilt.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
with open(cache_path, "r", encoding="utf-8") as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
35
|
+
return {}
|
|
36
|
+
return data if isinstance(data, dict) else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_cache(cache_path: str, cache: dict[str, dict]) -> None:
|
|
40
|
+
"""Write the cache file."""
|
|
41
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
42
|
+
json.dump(cache, f)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_fresh(cache: dict[str, dict], vuln_id: str, now: float) -> dict | None:
|
|
46
|
+
"""Return the cached hydrated record for `vuln_id` if still within TTL, else None."""
|
|
47
|
+
entry = cache.get(vuln_id)
|
|
48
|
+
if entry is None:
|
|
49
|
+
return None
|
|
50
|
+
if now - entry["cached_at"] > CACHE_TTL_SECONDS:
|
|
51
|
+
return None
|
|
52
|
+
return entry["record"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def put(cache: dict[str, dict], vuln_id: str, record: dict, now: float) -> None:
|
|
56
|
+
"""Store a freshly-fetched hydrated record in `cache` (mutated in place)."""
|
|
57
|
+
cache[vuln_id] = {"record": record, "cached_at": now}
|