vulnctl 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 (121) hide show
  1. vulnctl-0.1.0/.github/workflows/ci.yml +36 -0
  2. vulnctl-0.1.0/.github/workflows/live-smoke.yml +59 -0
  3. vulnctl-0.1.0/.github/workflows/publish-testpypi.yml +29 -0
  4. vulnctl-0.1.0/.github/workflows/release.yml +237 -0
  5. vulnctl-0.1.0/.gitignore +22 -0
  6. vulnctl-0.1.0/.pre-commit-config.yaml +27 -0
  7. vulnctl-0.1.0/.python-version +1 -0
  8. vulnctl-0.1.0/CHANGELOG.md +61 -0
  9. vulnctl-0.1.0/CLAUDE.md +79 -0
  10. vulnctl-0.1.0/FRAMEWORK.md +197 -0
  11. vulnctl-0.1.0/LICENSE +202 -0
  12. vulnctl-0.1.0/PKG-INFO +175 -0
  13. vulnctl-0.1.0/README.md +149 -0
  14. vulnctl-0.1.0/ROADMAP.md +198 -0
  15. vulnctl-0.1.0/SPEC.md +113 -0
  16. vulnctl-0.1.0/docs/announcement.md +46 -0
  17. vulnctl-0.1.0/docs/case-study.md +95 -0
  18. vulnctl-0.1.0/docs/context.md +126 -0
  19. vulnctl-0.1.0/docs/demo.tape +43 -0
  20. vulnctl-0.1.0/docs/exit-codes.md +33 -0
  21. vulnctl-0.1.0/docs/releasing.md +164 -0
  22. vulnctl-0.1.0/docs/schema.json +614 -0
  23. vulnctl-0.1.0/docs/schema.md +78 -0
  24. vulnctl-0.1.0/docs/trees.md +135 -0
  25. vulnctl-0.1.0/examples/app.cdx.json +1 -0
  26. vulnctl-0.1.0/examples/ci/vulnctl-gate.yml +48 -0
  27. vulnctl-0.1.0/examples/context.yaml +33 -0
  28. vulnctl-0.1.0/pyproject.toml +84 -0
  29. vulnctl-0.1.0/scripts/build_exploit_index.py +128 -0
  30. vulnctl-0.1.0/scripts/case_study_stats.py +139 -0
  31. vulnctl-0.1.0/src/vulnctl/__init__.py +5 -0
  32. vulnctl-0.1.0/src/vulnctl/adapters/__init__.py +5 -0
  33. vulnctl-0.1.0/src/vulnctl/adapters/base.py +150 -0
  34. vulnctl-0.1.0/src/vulnctl/adapters/epss.py +158 -0
  35. vulnctl-0.1.0/src/vulnctl/adapters/exploits.py +83 -0
  36. vulnctl-0.1.0/src/vulnctl/adapters/ghsa.py +220 -0
  37. vulnctl-0.1.0/src/vulnctl/adapters/kev.py +139 -0
  38. vulnctl-0.1.0/src/vulnctl/adapters/nvd.py +218 -0
  39. vulnctl-0.1.0/src/vulnctl/adapters/osv.py +437 -0
  40. vulnctl-0.1.0/src/vulnctl/cache.py +133 -0
  41. vulnctl-0.1.0/src/vulnctl/cli.py +160 -0
  42. vulnctl-0.1.0/src/vulnctl/context.py +140 -0
  43. vulnctl-0.1.0/src/vulnctl/data/__init__.py +9 -0
  44. vulnctl-0.1.0/src/vulnctl/data/epss_snapshot.csv.gz +0 -0
  45. vulnctl-0.1.0/src/vulnctl/data/exploit_index.json.gz +0 -0
  46. vulnctl-0.1.0/src/vulnctl/data/kev_snapshot.json.gz +0 -0
  47. vulnctl-0.1.0/src/vulnctl/ingest/__init__.py +5 -0
  48. vulnctl-0.1.0/src/vulnctl/ingest/cve_list.py +33 -0
  49. vulnctl-0.1.0/src/vulnctl/ingest/cyclonedx.py +135 -0
  50. vulnctl-0.1.0/src/vulnctl/ingest/grype.py +166 -0
  51. vulnctl-0.1.0/src/vulnctl/models.py +263 -0
  52. vulnctl-0.1.0/src/vulnctl/output/__init__.py +32 -0
  53. vulnctl-0.1.0/src/vulnctl/output/json_out.py +52 -0
  54. vulnctl-0.1.0/src/vulnctl/output/markdown.py +189 -0
  55. vulnctl-0.1.0/src/vulnctl/output/render.py +59 -0
  56. vulnctl-0.1.0/src/vulnctl/output/sarif.py +174 -0
  57. vulnctl-0.1.0/src/vulnctl/output/table.py +167 -0
  58. vulnctl-0.1.0/src/vulnctl/pipeline.py +334 -0
  59. vulnctl-0.1.0/src/vulnctl/py.typed +0 -0
  60. vulnctl-0.1.0/src/vulnctl/ssvc/__init__.py +1 -0
  61. vulnctl-0.1.0/src/vulnctl/ssvc/engine.py +100 -0
  62. vulnctl-0.1.0/src/vulnctl/ssvc/tree.py +341 -0
  63. vulnctl-0.1.0/src/vulnctl/ssvc/trees/cisa-deployer-v1.yaml +164 -0
  64. vulnctl-0.1.0/tests/conftest.py +39 -0
  65. vulnctl-0.1.0/tests/fixtures/epss/batch.json +1 -0
  66. vulnctl-0.1.0/tests/fixtures/epss/malformed.json +28 -0
  67. vulnctl-0.1.0/tests/fixtures/epss/missing.json +1 -0
  68. vulnctl-0.1.0/tests/fixtures/ghsa/advisory-by-cve.json +231 -0
  69. vulnctl-0.1.0/tests/fixtures/ghsa/advisory-by-ghsa-id.json +74 -0
  70. vulnctl-0.1.0/tests/fixtures/ghsa/malformed.json +1 -0
  71. vulnctl-0.1.0/tests/fixtures/ghsa/not-found-empty-list.json +3 -0
  72. vulnctl-0.1.0/tests/fixtures/ghsa/rate-limited.json +4 -0
  73. vulnctl-0.1.0/tests/fixtures/golden/enrich.json +252 -0
  74. vulnctl-0.1.0/tests/fixtures/golden/enrich.md +35 -0
  75. vulnctl-0.1.0/tests/fixtures/grype/duplicate-layers.json +394 -0
  76. vulnctl-0.1.0/tests/fixtures/grype/npm-app.json +1 -0
  77. vulnctl-0.1.0/tests/fixtures/kev/catalog.json +70 -0
  78. vulnctl-0.1.0/tests/fixtures/nvd/cve-2021-44228.json +3373 -0
  79. vulnctl-0.1.0/tests/fixtures/nvd/multiple-cvss.json +1029 -0
  80. vulnctl-0.1.0/tests/fixtures/nvd/not-found.json +9 -0
  81. vulnctl-0.1.0/tests/fixtures/nvd/rejected.json +28 -0
  82. vulnctl-0.1.0/tests/fixtures/nvd/v2-only.json +145 -0
  83. vulnctl-0.1.0/tests/fixtures/osv/cve-2021-44228.json +1 -0
  84. vulnctl-0.1.0/tests/fixtures/osv/malformed.json +1 -0
  85. vulnctl-0.1.0/tests/fixtures/osv/not-found.json +1 -0
  86. vulnctl-0.1.0/tests/fixtures/osv/querybatch-npm-app.json +14 -0
  87. vulnctl-0.1.0/tests/fixtures/osv/querybatch.json +37 -0
  88. vulnctl-0.1.0/tests/fixtures/osv/vuln-cve-record.json +1 -0
  89. vulnctl-0.1.0/tests/fixtures/osv/vuln-ghsa-no-cve.json +1 -0
  90. vulnctl-0.1.0/tests/fixtures/osv/vuln-ghsa-with-cve.json +1 -0
  91. vulnctl-0.1.0/tests/fixtures/osv/vuln-go.json +1 -0
  92. vulnctl-0.1.0/tests/fixtures/osv/vuln-pypi.json +1 -0
  93. vulnctl-0.1.0/tests/fixtures/sarif/sarif-schema-2.1.0.json +3389 -0
  94. vulnctl-0.1.0/tests/fixtures/sbom/npm-app-1.6.cdx.json +1 -0
  95. vulnctl-0.1.0/tests/fixtures/sbom/npm-app.cdx.json +1 -0
  96. vulnctl-0.1.0/tests/fixtures/sbom/py-app.cdx.json +1 -0
  97. vulnctl-0.1.0/tests/fixtures/trees/toy.yaml +26 -0
  98. vulnctl-0.1.0/tests/live/test_live_smoke.py +116 -0
  99. vulnctl-0.1.0/tests/test_adapter_epss.py +173 -0
  100. vulnctl-0.1.0/tests/test_adapter_exploits.py +112 -0
  101. vulnctl-0.1.0/tests/test_adapter_ghsa.py +270 -0
  102. vulnctl-0.1.0/tests/test_adapter_kev.py +125 -0
  103. vulnctl-0.1.0/tests/test_adapter_nvd.py +279 -0
  104. vulnctl-0.1.0/tests/test_adapter_osv.py +466 -0
  105. vulnctl-0.1.0/tests/test_adapters_base.py +99 -0
  106. vulnctl-0.1.0/tests/test_cache.py +112 -0
  107. vulnctl-0.1.0/tests/test_cli.py +223 -0
  108. vulnctl-0.1.0/tests/test_context.py +102 -0
  109. vulnctl-0.1.0/tests/test_ingest_cve_list.py +30 -0
  110. vulnctl-0.1.0/tests/test_ingest_cyclonedx.py +204 -0
  111. vulnctl-0.1.0/tests/test_ingest_grype.py +176 -0
  112. vulnctl-0.1.0/tests/test_models.py +140 -0
  113. vulnctl-0.1.0/tests/test_output_json.py +100 -0
  114. vulnctl-0.1.0/tests/test_output_markdown.py +98 -0
  115. vulnctl-0.1.0/tests/test_output_sarif.py +231 -0
  116. vulnctl-0.1.0/tests/test_output_table.py +242 -0
  117. vulnctl-0.1.0/tests/test_pipeline.py +383 -0
  118. vulnctl-0.1.0/tests/test_ssvc_cisa_tree.py +177 -0
  119. vulnctl-0.1.0/tests/test_ssvc_engine.py +186 -0
  120. vulnctl-0.1.0/tests/test_ssvc_tree.py +393 -0
  121. vulnctl-0.1.0/uv.lock +1108 -0
@@ -0,0 +1,36 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ checks:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Checkout
16
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
20
+ with:
21
+ enable-cache: true
22
+
23
+ - name: Install dependencies
24
+ run: uv sync --locked
25
+
26
+ - name: Ruff lint
27
+ run: uv run ruff check .
28
+
29
+ - name: Ruff format
30
+ run: uv run ruff format --check .
31
+
32
+ - name: Mypy
33
+ run: uv run mypy src/
34
+
35
+ - name: Pytest (with 100% branch-coverage gate on ssvc/)
36
+ run: uv run pytest --cov=vulnctl.ssvc --cov-branch --cov-report=term-missing --cov-fail-under=100
@@ -0,0 +1,59 @@
1
+ # Weekly live-smoke: exercise every adapter against real upstream APIs to catch
2
+ # schema drift before it silently degrades production runs. This is the ONLY
3
+ # workflow that hits the network — PR/push CI stays fixture-only. On failure it
4
+ # opens a tracking issue. Never triggered by pull_request.
5
+ name: live-smoke
6
+
7
+ on:
8
+ schedule:
9
+ - cron: "17 6 * * 1" # Mondays 06:17 UTC
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+ issues: write # open a tracking issue when an upstream source drifts/breaks
15
+
16
+ jobs:
17
+ live-smoke:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
25
+
26
+ - name: Sync dependencies
27
+ run: uv sync --locked
28
+
29
+ - name: Live adapter smoke (real APIs)
30
+ env:
31
+ VULNCTL_LIVE: "1"
32
+ # Optional secrets: raise NVD/GitHub rate limits. Anonymous also works.
33
+ VULNCTL_NVD_API_KEY: ${{ secrets.VULNCTL_NVD_API_KEY }}
34
+ VULNCTL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35
+ run: uv run pytest tests/live -v
36
+
37
+ - name: Open an issue on failure
38
+ if: failure()
39
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
40
+ with:
41
+ script: |
42
+ const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
43
+ await github.rest.issues.create({
44
+ owner: context.repo.owner,
45
+ repo: context.repo.repo,
46
+ title: `live-smoke failed — upstream drift or outage (${new Date().toISOString().slice(0, 10)})`,
47
+ body: [
48
+ `The weekly [live-smoke run](${run}) failed.`,
49
+ '',
50
+ 'An adapter returned no data for a known-good CVE. Because every adapter',
51
+ 'parses upstream responses through strict Pydantic models, this usually',
52
+ 'means an upstream API changed its schema (the adapter degraded to',
53
+ '`Unavailable`) — or the source is temporarily down.',
54
+ '',
55
+ 'Check the run logs to see which source failed, then update the adapter',
56
+ 'and its recorded fixtures if the schema changed.',
57
+ ].join('\n'),
58
+ labels: ['live-smoke', 'upstream-drift'],
59
+ });
@@ -0,0 +1,29 @@
1
+ name: Publish to TestPyPI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ permissions:
7
+ contents: read
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment: testpypi
13
+ permissions:
14
+ contents: read
15
+ id-token: write # OIDC trusted publishing — no stored token
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
22
+
23
+ - name: Build sdist and wheel
24
+ run: uv build
25
+
26
+ - name: Publish to TestPyPI
27
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
28
+ with:
29
+ repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,237 @@
1
+ name: release
2
+
3
+ # Cut a signed release of vulnctl.
4
+ #
5
+ # Pipeline (in order, each stage gates the next):
6
+ # build → build sdist+wheel (uv), install the wheel into a clean env,
7
+ # SBOM that runtime closure (Syft, CycloneDX JSON), scan it (Grype),
8
+ # and gate: (1) no critical-with-fix vuln, (2) dogfood vulnctl's own
9
+ # --fail-on act against the Grype report.
10
+ # sign → cosign keyless (OIDC) signatures for the wheel, sdist, and SBOM.
11
+ # publish → PyPI via trusted publishing (OIDC, no stored token).
12
+ # release → GitHub Release with wheel, sdist, SBOM, and signatures attached.
13
+ #
14
+ # Triggers:
15
+ # - push of a `v*` tag → the full pipeline, including publish + GitHub Release.
16
+ # - workflow_dispatch → DRY RUN: build, SBOM, scan, gates, and signing all
17
+ # run (so the release path is fully exercised), but publish + release are
18
+ # skipped. Every produced artifact is downloadable from the run.
19
+ #
20
+ # Security posture: every action is pinned to a full commit SHA; permissions are
21
+ # least-privilege per job; `id-token: write` (OIDC) is granted only to the jobs
22
+ # that actually sign or publish.
23
+
24
+ on:
25
+ push:
26
+ tags: ["v*"]
27
+ workflow_dispatch:
28
+
29
+ # Deny everything by default; each job opts in to exactly what it needs.
30
+ permissions: {}
31
+
32
+ concurrency:
33
+ group: release-${{ github.ref }}
34
+ cancel-in-progress: false
35
+
36
+ jobs:
37
+ build:
38
+ name: Build, SBOM, scan & gate
39
+ runs-on: ubuntu-latest
40
+ permissions:
41
+ contents: read
42
+ steps:
43
+ - name: Checkout
44
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
45
+
46
+ - name: Install uv
47
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
48
+
49
+ - name: Verify tag matches package version
50
+ if: github.event_name == 'push'
51
+ run: |
52
+ set -euo pipefail
53
+ tag="${GITHUB_REF_NAME#v}"
54
+ pkg="$(grep -m1 -E '^version\s*=' pyproject.toml | sed -E 's/.*=\s*"([^"]+)".*/\1/')"
55
+ echo "tag=$tag pyproject=$pkg"
56
+ if [ "$tag" != "$pkg" ]; then
57
+ echo "::error::tag v$tag does not match pyproject version $pkg — bump pyproject first"
58
+ exit 1
59
+ fi
60
+
61
+ - name: Build sdist and wheel
62
+ run: uv build
63
+
64
+ - name: Resolve built wheel path
65
+ run: echo "WHEEL=$(ls dist/*.whl)" >> "$GITHUB_ENV"
66
+
67
+ - name: Install the release artifact into a clean environment
68
+ # Installs exactly what a `pipx install vulnctl` user receives, so the
69
+ # SBOM and scan below describe the real shipped runtime closure.
70
+ run: |
71
+ set -euo pipefail
72
+ uv venv .relenv
73
+ uv pip install --python .relenv "$WHEEL"
74
+
75
+ - name: Generate CycloneDX SBOM of the runtime closure (Syft)
76
+ uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
77
+ with:
78
+ path: .relenv
79
+ format: cyclonedx-json
80
+ output-file: sbom.cdx.json
81
+ upload-artifact: false
82
+
83
+ - name: Install Grype
84
+ id: grype
85
+ uses: anchore/scan-action/download-grype@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
86
+
87
+ - name: Scan the SBOM (Grype → JSON)
88
+ run: ${{ steps.grype.outputs.cmd }} sbom:sbom.cdx.json -o json=grype.json
89
+
90
+ - name: "Gate 1 — no critical vulnerability that already has a fix"
91
+ run: |
92
+ set -euo pipefail
93
+ count="$(jq '[.matches[]
94
+ | select(.vulnerability.severity == "Critical"
95
+ and .vulnerability.fix.state == "fixed")] | length' grype.json)"
96
+ if [ "$count" -gt 0 ]; then
97
+ echo "::error::$count critical vulnerability(ies) with an available fix — release blocked"
98
+ jq -r '.matches[]
99
+ | select(.vulnerability.severity == "Critical" and .vulnerability.fix.state == "fixed")
100
+ | " - \(.artifact.name) \(.artifact.version): \(.vulnerability.id) → fix \(.vulnerability.fix.versions | join(", "))"' grype.json
101
+ exit 1
102
+ fi
103
+ echo "OK: no critical-with-fix findings."
104
+
105
+ - name: "Gate 2 — dogfood vulnctl on its own Grype report (fail on ACT)"
106
+ env:
107
+ # Optional: raises NVD rate limits. vulnctl fails open if unset/down.
108
+ VULNCTL_NVD_API_KEY: ${{ secrets.VULNCTL_NVD_API_KEY }}
109
+ run: |
110
+ # Run the exact wheel we're about to ship against its own scan. A
111
+ # KEV-listed dependency resolves to exploitation=active → ACT → exit 2.
112
+ ./.relenv/bin/vulnctl enrich --grype grype.json --fail-on act
113
+
114
+ - name: Upload distributions
115
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
116
+ with:
117
+ name: dist
118
+ path: dist/
119
+ if-no-files-found: error
120
+
121
+ - name: Upload SBOM and scan report
122
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
123
+ with:
124
+ name: reports
125
+ path: |
126
+ sbom.cdx.json
127
+ grype.json
128
+ if-no-files-found: error
129
+
130
+ sign:
131
+ name: Sign artifacts (cosign keyless)
132
+ runs-on: ubuntu-latest
133
+ needs: build
134
+ permissions:
135
+ contents: read
136
+ id-token: write # OIDC identity for keyless signing (Fulcio); no private key
137
+ steps:
138
+ - name: Download distributions
139
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
140
+ with:
141
+ name: dist
142
+ path: dist
143
+
144
+ - name: Download SBOM
145
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
146
+ with:
147
+ name: reports
148
+ path: .
149
+
150
+ - name: Install cosign
151
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
152
+ with:
153
+ cosign-release: "v3.0.6"
154
+
155
+ - name: Sign wheel, sdist, and SBOM (keyless)
156
+ run: |
157
+ set -euo pipefail
158
+ mkdir -p sigs
159
+ # cosign v3 defaults to the Sigstore "new bundle format": one bundle
160
+ # file per artifact carrying the signature, certificate, and Rekor
161
+ # entry. (The old --output-signature/--output-certificate flags are
162
+ # deprecated and ignored under this format.)
163
+ for f in dist/*.whl dist/*.tar.gz sbom.cdx.json; do
164
+ base="$(basename "$f")"
165
+ echo "signing $f"
166
+ cosign sign-blob --yes \
167
+ --bundle "sigs/$base.sigstore.json" \
168
+ "$f"
169
+ done
170
+
171
+ - name: Upload signatures
172
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
173
+ with:
174
+ name: signatures
175
+ path: sigs/
176
+ if-no-files-found: error
177
+
178
+ publish:
179
+ name: Publish to PyPI (trusted publishing)
180
+ runs-on: ubuntu-latest
181
+ needs: [build, sign]
182
+ if: github.event_name == 'push'
183
+ environment:
184
+ name: pypi
185
+ url: https://pypi.org/p/vulnctl
186
+ permissions:
187
+ id-token: write # OIDC for PyPI trusted publishing — no stored API token
188
+ steps:
189
+ - name: Download distributions
190
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
191
+ with:
192
+ name: dist
193
+ path: dist
194
+
195
+ - name: Publish to PyPI
196
+ # Uploads only dist/*.whl and dist/*.tar.gz (signatures/SBOM live in
197
+ # separate artifacts). Generates PEP 740 attestations via the same OIDC
198
+ # identity by default.
199
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
200
+
201
+ release:
202
+ name: GitHub Release
203
+ runs-on: ubuntu-latest
204
+ needs: [build, sign, publish]
205
+ if: github.event_name == 'push'
206
+ permissions:
207
+ contents: write # create the release and upload assets
208
+ steps:
209
+ - name: Download distributions
210
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
211
+ with:
212
+ name: dist
213
+ path: dist
214
+
215
+ - name: Download SBOM and scan report
216
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
217
+ with:
218
+ name: reports
219
+ path: .
220
+
221
+ - name: Download signatures
222
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
223
+ with:
224
+ name: signatures
225
+ path: signatures
226
+
227
+ - name: Create GitHub Release
228
+ uses: softprops/action-gh-release@2bb465e97f322d3cb2a965294d483e0d26a67aa9 # v3.0.1
229
+ with:
230
+ tag_name: ${{ github.ref_name }}
231
+ generate_release_notes: true
232
+ fail_on_unmatched_files: true
233
+ files: |
234
+ dist/*.whl
235
+ dist/*.tar.gz
236
+ sbom.cdx.json
237
+ signatures/*
@@ -0,0 +1,22 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+
8
+ # Virtual environments / uv
9
+ .venv/
10
+ .python-version.local
11
+
12
+ # Tooling caches
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ # OS / editor
20
+ .DS_Store
21
+ .idea/
22
+ .vscode/
@@ -0,0 +1,27 @@
1
+ # Runs the same four checks CI runs, via the project venv (uv), so hook and CI
2
+ # tool versions can never drift apart.
3
+ repos:
4
+ - repo: local
5
+ hooks:
6
+ - id: ruff-check
7
+ name: ruff check
8
+ entry: uv run ruff check --force-exclude
9
+ language: system
10
+ types_or: [python, pyi]
11
+ - id: ruff-format
12
+ name: ruff format
13
+ entry: uv run ruff format --check --force-exclude
14
+ language: system
15
+ types_or: [python, pyi]
16
+ - id: mypy
17
+ name: mypy
18
+ entry: uv run mypy src/
19
+ language: system
20
+ pass_filenames: false
21
+ types_or: [python, pyi]
22
+ - id: pytest
23
+ name: pytest
24
+ entry: uv run pytest
25
+ language: system
26
+ pass_filenames: false
27
+ always_run: true
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to vulnctl are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-07
11
+
12
+ First public release. `vulnctl` turns CVE lists, SBOMs, and scanner output into
13
+ auditable, SSVC-based remediation verdicts — each with the full decision path
14
+ that produced it.
15
+
16
+ ### Added
17
+
18
+ - **`enrich` command** accepting three input modes (exactly one per run): one or
19
+ more CVE IDs, a CycloneDX 1.4–1.6 SBOM (`--sbom`, components resolved to CVEs
20
+ via OSV), or Grype JSON (`--grype <file>` or `-` for stdin).
21
+ - **Fused enrichment** from six public intelligence sources: FIRST EPSS
22
+ (exploit probability + percentile), CISA KEV (known-exploited + ransomware
23
+ flag), NVD (CVSS vector/score, CWE), OSV and GHSA (affected/fixed versions,
24
+ advisories), and exploit presence (Exploit-DB, Metasploit, nuclei).
25
+ - **SSVC decision engine**: a pure, deterministic tree-walker bundling the
26
+ CISA-style deployer tree `cisa-deployer-v1`. Every verdict carries a full
27
+ `DecisionPath` — each node visited, its value, and the source that supplied
28
+ it — and flags when a degraded input fell back to a tree default. Bring your
29
+ own tree with `--tree`.
30
+ - **Organizational context** via `--context context.yaml`: `exposure`,
31
+ `mission_impact`, `asset_tier`, and per-decision-point `overrides`, with
32
+ conservative defaults when absent and hard errors on unknown keys.
33
+ - **Offline mode** (`--offline`): runs from cached data and bundled EPSS/KEV/
34
+ exploit snapshots with zero network access.
35
+ - **Output formats**: rich terminal table (with `--show-path`), machine-readable
36
+ `--format json` (versioned, schema-documented), `--format sarif` (SARIF 2.1.0
37
+ for GitHub code scanning), and `--format md` (stakeholder report).
38
+ - **CI gating**: `--fail-on track|track*|attend|act` exits `2` when any finding
39
+ meets or exceeds the threshold; output is written before the gate is applied.
40
+ - **Response cache**: SQLite with per-source TTLs; `cache stats` and
41
+ `cache purge` subcommands.
42
+ - **`--version`** sourced from installed package metadata (single source of
43
+ truth in `pyproject.toml`).
44
+ - **Documentation**: README with quickstart, plus `docs/` references for the
45
+ context file, tree format, JSON schema, exit codes, and the release runbook;
46
+ a `vhs` demo tape; and an example CI gate under `examples/ci/`.
47
+
48
+ ### Security
49
+
50
+ - All GitHub Actions pinned by full commit SHA; least-privilege token scopes per
51
+ workflow.
52
+ - Strict Pydantic validation of every external JSON payload before it becomes a
53
+ model; response- and file-size bounds on all inputs; no `eval`, no `pickle` of
54
+ untrusted data, no shelling out to parse files.
55
+ - Signed release pipeline: build with `uv`, generate a CycloneDX SBOM (Syft),
56
+ scan the artifact (Grype, dogfooded through vulnctl's own `--fail-on` gate),
57
+ sign with keyless cosign (OIDC), and publish to PyPI via trusted publishing
58
+ (no stored token).
59
+
60
+ [Unreleased]: https://github.com/NokiGuard/vulnctl/compare/v0.1.0...HEAD
61
+ [0.1.0]: https://github.com/NokiGuard/vulnctl/releases/tag/v0.1.0
@@ -0,0 +1,79 @@
1
+ # CLAUDE.md — vulnctl
2
+
3
+ ## What this project is
4
+
5
+ `vulnctl` is a Python CLI that ingests CVE identifiers, SBOMs (CycloneDX/SPDX), or scanner output (Grype/Trivy JSON), enriches each finding from multiple threat-intelligence sources (EPSS, CISA KEV, NVD, OSV, GHSA, exploit-presence feeds), evaluates it against a declarative SSVC decision tree with user-supplied organizational context, and emits a prioritized, **auditable** ranking. The differentiator is that every verdict ships with its full decision path — not just a score.
6
+
7
+ Read `SPEC.md` for the product spec and `FRAMEWORK.md` for architecture before making structural changes.
8
+
9
+ ## Tech stack & conventions
10
+
11
+ - **Python 3.11+**, packaged with `pyproject.toml` (hatchling or setuptools), managed with `uv`
12
+ - **CLI:** Typer. Entry point: `vulnctl` console script
13
+ - **HTTP:** `httpx` with async batching for source adapters; respect per-source rate limits
14
+ - **Models:** Pydantic v2 for ALL data structures crossing module boundaries. No bare dicts across interfaces.
15
+ - **Cache:** SQLite via `sqlite3` stdlib (no ORM), single file at `~/.cache/vulnctl/cache.db`, per-source TTLs
16
+ - **Terminal output:** `rich` tables
17
+ - **Config/trees:** YAML via `ruamel.yaml` (round-trip safe, preserves comments in user files)
18
+ - **Tests:** pytest + pytest-asyncio; adapters tested against recorded fixtures (JSON files in `tests/fixtures/`), never live APIs in CI
19
+ - **Lint/format:** ruff (lint + format), mypy --strict on `src/`
20
+ - **Line length 100. Google-style docstrings.**
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ uv sync # install deps
26
+ uv run pytest # run tests
27
+ uv run pytest -k adapter # adapter tests only
28
+ uv run ruff check --fix . # lint
29
+ uv run ruff format . # format
30
+ uv run mypy src/ # type check
31
+ uv run vulnctl --help # smoke-test the CLI
32
+ ```
33
+
34
+ All four (pytest, ruff check, ruff format, mypy) must pass before any commit.
35
+
36
+ ## Repository layout
37
+
38
+ ```
39
+ src/vulnctl/
40
+ ├── cli.py # Typer app; thin — no business logic here
41
+ ├── models.py # Pydantic: Finding, Enrichment, Verdict, DecisionPath
42
+ ├── ingest/ # cve_list.py, cyclonedx.py, spdx.py, grype.py, trivy.py
43
+ ├── adapters/ # one module per intel source; all implement SourceAdapter ABC
44
+ │ ├── base.py # SourceAdapter ABC + registry
45
+ │ ├── epss.py, kev.py, nvd.py, osv.py, ghsa.py, exploits.py
46
+ ├── cache.py # SQLite cache: get/set with TTL, per-source namespacing
47
+ ├── ssvc/ # engine.py (tree walker), trees/ (bundled YAML trees)
48
+ ├── context.py # org context loading + validation (context.yaml)
49
+ ├── output/ # table.py, json_out.py, sarif.py, markdown.py
50
+ └── __init__.py
51
+ tests/
52
+ ├── fixtures/ # recorded API responses, sample SBOMs, scanner JSON
53
+ └── ...mirrors src layout
54
+ ```
55
+
56
+ ## Architecture rules (do not violate without discussion)
57
+
58
+ 1. **Adapters are isolated.** An adapter may import from `base.py`, `cache.py`, and `models.py` only. Adapters never import each other or the SSVC engine.
59
+ 2. **The SSVC engine is pure.** `ssvc/engine.py` takes `(Enrichment, OrgContext, tree)` and returns a `Verdict` with a `DecisionPath`. No I/O, no network, no cache access. Fully deterministic and unit-testable.
60
+ 3. **Fail open on enrichment, fail loud on input.** A source being down degrades the enrichment (mark the field `unavailable`, note it in the decision path) — it never crashes a run. Malformed SBOMs/scanner files are hard errors with actionable messages.
61
+ 4. **Every verdict is explainable.** `DecisionPath` records each tree node visited, the input value used, and which source supplied it. If you add a scoring signal, you must thread it through `DecisionPath`.
62
+ 5. **Offline mode is first-class.** `--offline` must work using only cached/bundled data (EPSS CSV snapshot, KEV JSON snapshot). Adapters declare whether they support offline.
63
+ 6. **No secrets in code or fixtures.** NVD API key comes from `VULNCTL_NVD_API_KEY` env var only. Scrub any recorded fixtures of keys before committing.
64
+
65
+ ## Security posture (this is a security tool — hold the bar)
66
+
67
+ - Pin all GitHub Actions by full SHA
68
+ - Dependabot/renovate on; lockfile committed
69
+ - No `eval`, no `pickle` for untrusted data, no shelling out to parse files
70
+ - Validate and bound all external JSON before model construction (Pydantic strict mode)
71
+ - SARIF output must validate against the 2.1.0 schema (test enforces this)
72
+
73
+ ## Style notes for Claude Code
74
+
75
+ - Prefer small PRs/commits scoped to one adapter or one output format
76
+ - When adding a source adapter: implement ABC → add fixtures → add tests → register in `base.py` registry → update `SPEC.md` source table
77
+ - Never mock `httpx` inline in tests; use the fixture-loading helper in `tests/conftest.py`
78
+ - Update `FRAMEWORK.md` diagrams when module boundaries change
79
+ - Keep `cli.py` under ~150 lines; push logic down into modules