vexpath 0.1.0__tar.gz → 0.1.2__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.
- {vexpath-0.1.0 → vexpath-0.1.2}/PKG-INFO +2 -2
- {vexpath-0.1.0 → vexpath-0.1.2}/pyproject.toml +2 -2
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/auth.py +38 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/scan.py +54 -10
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/credentials.py +8 -1
- {vexpath-0.1.0 → vexpath-0.1.2}/tests/test_cli.py +66 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/.gitignore +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/LICENSE +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/README.md +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/__init__.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/__init__.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/advisory.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/attest.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/policy.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/reach.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/report.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/sbom.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/upload.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/vex.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/exit_codes.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/main.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/output.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/py.typed +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/sources.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/tests/test_auth_and_upload.py +0 -0
- {vexpath-0.1.0 → vexpath-0.1.2}/tests/test_report_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vexpath
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Command-line tool: generate SBOMs, match advisories, analyse reachability and emit signed VEX.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Unknown122-coder/vexpath
|
|
6
6
|
Project-URL: Documentation, https://github.com/Unknown122-coder/vexpath/tree/main/docs
|
|
@@ -27,7 +27,7 @@ Classifier: Typing :: Typed
|
|
|
27
27
|
Requires-Python: >=3.12
|
|
28
28
|
Requires-Dist: rich>=13.9
|
|
29
29
|
Requires-Dist: typer>=0.15
|
|
30
|
-
Requires-Dist: vexpath-core==0.1.
|
|
30
|
+
Requires-Dist: vexpath-core==0.1.2
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
33
33
|
# vexpath
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "vexpath"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Command-line tool: generate SBOMs, match advisories, analyse reachability and emit signed VEX."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -45,7 +45,7 @@ classifiers = [
|
|
|
45
45
|
]
|
|
46
46
|
|
|
47
47
|
dependencies = [
|
|
48
|
-
"vexpath-core==0.1.
|
|
48
|
+
"vexpath-core==0.1.2",
|
|
49
49
|
"typer>=0.15",
|
|
50
50
|
"rich>=13.9",
|
|
51
51
|
]
|
|
@@ -187,6 +187,44 @@ def _environment() -> dict[str, str]:
|
|
|
187
187
|
return dict(os.environ)
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def fetch_policy(credential: credentials.Credential, project: str) -> str:
|
|
191
|
+
"""Read the expression a project is governed by.
|
|
192
|
+
|
|
193
|
+
So that a policy set once in the dashboard governs every build, instead
|
|
194
|
+
of every build having to be told the rule on its command line. The
|
|
195
|
+
expression is returned rather than the preset name: the answer must not
|
|
196
|
+
depend on the client's idea of what a preset means.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
with httpx.Client(timeout=_TIMEOUT, follow_redirects=True) as client:
|
|
200
|
+
response = client.get(
|
|
201
|
+
f"{credential.api_url}/v1/projects/{project}/policy",
|
|
202
|
+
headers={"Authorization": f"Bearer {credential.secret}"},
|
|
203
|
+
)
|
|
204
|
+
except httpx.HTTPError as exc:
|
|
205
|
+
fail(f"{credential.api_url} could not be reached for the project policy: {exc}")
|
|
206
|
+
|
|
207
|
+
if response.status_code == 401:
|
|
208
|
+
fail_config("the platform rejected the credential. Run 'vexpath login' again.")
|
|
209
|
+
if response.status_code == 404:
|
|
210
|
+
fail_config(
|
|
211
|
+
f"no policy for project {project!r}. Either the project does not exist "
|
|
212
|
+
f"in this organisation, or it has no policy yet -- set one in the "
|
|
213
|
+
f"dashboard, or drop --policy project to use the default."
|
|
214
|
+
)
|
|
215
|
+
if response.status_code >= 400:
|
|
216
|
+
fail(f"the project policy could not be read: {response.status_code}")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
expression = str(response.json()["expression"])
|
|
220
|
+
except (ValueError, KeyError, TypeError):
|
|
221
|
+
fail("the platform returned a policy this build cannot read")
|
|
222
|
+
|
|
223
|
+
if not expression.strip():
|
|
224
|
+
fail_config(f"project {project!r} has an empty policy, which gates nothing")
|
|
225
|
+
return expression
|
|
226
|
+
|
|
227
|
+
|
|
190
228
|
def upload_results(
|
|
191
229
|
credential: credentials.Credential, project: str, results: Mapping[str, Any]
|
|
192
230
|
) -> dict[str, Any]:
|
|
@@ -125,10 +125,11 @@ def command(
|
|
|
125
125
|
typer.Option(
|
|
126
126
|
"--policy",
|
|
127
127
|
help=(
|
|
128
|
-
"The gate:
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
128
|
+
"The gate: 'project' to use the policy set in the dashboard for "
|
|
129
|
+
"--project, or a preset name, a CEL expression, or a path to a "
|
|
130
|
+
"file holding one. Exits 1 when it fails. Run 'vexpath policy "
|
|
131
|
+
"list' to see the presets. Without this, nothing is gated and "
|
|
132
|
+
"the scan only reports."
|
|
132
133
|
),
|
|
133
134
|
),
|
|
134
135
|
] = None,
|
|
@@ -178,7 +179,12 @@ def command(
|
|
|
178
179
|
# Resolved before any analysis runs. A missing credential or a forgotten
|
|
179
180
|
# --project discovered after a four-minute scan has thrown the scan away,
|
|
180
181
|
# and the person who sees that message is already in a hurry.
|
|
181
|
-
credential = _upload_credential(upload=upload, project=project)
|
|
182
|
+
credential = _upload_credential(upload=upload, project=project, policy=policy)
|
|
183
|
+
# Before any analysis too, and for the same reason: a policy that cannot
|
|
184
|
+
# be fetched should not be discovered after a four-minute scan. This may
|
|
185
|
+
# resolve a credential even without --upload, because reading the
|
|
186
|
+
# project's policy is itself an authenticated call.
|
|
187
|
+
resolved_policy = _resolve_policy(policy, project=project, credential=credential)
|
|
182
188
|
|
|
183
189
|
source = advisory_source(snapshot, result_noun="scan")
|
|
184
190
|
scopes = (
|
|
@@ -190,7 +196,7 @@ def command(
|
|
|
190
196
|
sbom_path=sbom,
|
|
191
197
|
lockfile=lockfile,
|
|
192
198
|
include_scopes=scopes,
|
|
193
|
-
policy=
|
|
199
|
+
policy=resolved_policy,
|
|
194
200
|
reachability=not no_reach,
|
|
195
201
|
entry_points=tuple(
|
|
196
202
|
entry.strip() for entry in (entry_point or "").split(",") if entry.strip()
|
|
@@ -233,18 +239,32 @@ def command(
|
|
|
233
239
|
raise typer.Exit(code=int(ExitCode.POLICY_FAILED))
|
|
234
240
|
|
|
235
241
|
|
|
236
|
-
def _upload_credential(
|
|
242
|
+
def _upload_credential(
|
|
243
|
+
*, upload: bool, project: str | None, policy: str | None = None
|
|
244
|
+
) -> credentials.Credential | None:
|
|
237
245
|
"""The credential to upload with, or None when no upload was asked for.
|
|
238
246
|
|
|
239
247
|
Resolved up front so the two mistakes that waste a whole scan -- no
|
|
240
248
|
credential, and --upload without --project -- are reported before the
|
|
241
249
|
analysis starts rather than after it.
|
|
250
|
+
|
|
251
|
+
``--project`` also means something without ``--upload``: ``--policy
|
|
252
|
+
project`` names the project whose policy to fetch. Refusing that
|
|
253
|
+
combination made it impossible to check a build against the
|
|
254
|
+
organisation's gate without also publishing the results, which are two
|
|
255
|
+
unrelated decisions.
|
|
242
256
|
"""
|
|
257
|
+
wants_project_policy = (policy or "").strip() == PROJECT_POLICY
|
|
258
|
+
|
|
243
259
|
if not upload:
|
|
244
|
-
if project is not None:
|
|
260
|
+
if project is not None and not wants_project_policy:
|
|
245
261
|
# Saying nothing would silently discard the flag, and the person
|
|
246
262
|
# would wait for a result that never appears on the platform.
|
|
247
|
-
fail_config("--project only means something with --upload.")
|
|
263
|
+
fail_config("--project only means something with --upload or --policy project.")
|
|
264
|
+
if wants_project_policy:
|
|
265
|
+
# Reading the policy is itself authenticated, so the credential
|
|
266
|
+
# is resolved here for the same reason: before the analysis.
|
|
267
|
+
return auth.credential_or_exit()
|
|
248
268
|
return None
|
|
249
269
|
if not project:
|
|
250
270
|
fail_config("--upload needs --project to say where the result goes.")
|
|
@@ -262,15 +282,39 @@ def _upload(credential: credentials.Credential, project: str, results: object) -
|
|
|
262
282
|
note(f"uploaded to {credential.api_url} as scan {scan_id}")
|
|
263
283
|
|
|
264
284
|
|
|
265
|
-
|
|
285
|
+
#: What ``--policy`` means when it means "ask the platform".
|
|
286
|
+
PROJECT_POLICY = "project"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _resolve_policy(
|
|
290
|
+
source: str | None,
|
|
291
|
+
*,
|
|
292
|
+
project: str | None = None,
|
|
293
|
+
credential: object | None = None,
|
|
294
|
+
) -> str | None:
|
|
266
295
|
"""Turn the flag into something the core can compile.
|
|
267
296
|
|
|
268
297
|
A preset name wins over a file of the same name: the presets are a closed
|
|
269
298
|
vocabulary, and a file called ``balanced`` in a working directory must not
|
|
270
299
|
silently change what the default means.
|
|
300
|
+
|
|
301
|
+
``--policy project`` fetches the expression the dashboard holds for
|
|
302
|
+
``--project``, so a rule written once governs every build instead of
|
|
303
|
+
every build repeating it. Nothing local can shadow that word: it is
|
|
304
|
+
checked before the preset table and before the filesystem, because a file
|
|
305
|
+
called ``project`` quietly replacing the organisation's gate is exactly
|
|
306
|
+
the substitution this flag must not allow.
|
|
271
307
|
"""
|
|
272
308
|
if source is None:
|
|
273
309
|
return None
|
|
310
|
+
|
|
311
|
+
if source.strip() == PROJECT_POLICY:
|
|
312
|
+
if not project:
|
|
313
|
+
fail_config("--policy project needs --project to say which project's policy to use")
|
|
314
|
+
resolved = credential or auth.credential_or_exit()
|
|
315
|
+
assert isinstance(resolved, credentials.Credential) # narrowed for the type checker
|
|
316
|
+
return auth.fetch_policy(resolved, project)
|
|
317
|
+
|
|
274
318
|
if source in PRESETS:
|
|
275
319
|
return source
|
|
276
320
|
candidate = Path(source)
|
|
@@ -34,7 +34,14 @@ KEY_ENVIRONMENT_VARIABLE = "VEXPATH_API_KEY"
|
|
|
34
34
|
#: Where the platform lives, overridable for a self-hosted install.
|
|
35
35
|
URL_ENVIRONMENT_VARIABLE = "VEXPATH_API_URL"
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
#: The deployment this tool talks to when nothing says otherwise.
|
|
38
|
+
#:
|
|
39
|
+
#: It was ``https://api.vexpath.dev`` until 2026-11-12, which is a hostname
|
|
40
|
+
#: nobody owns: ``vexpath login`` stored it silently and the first upload
|
|
41
|
+
#: failed with a DNS error naming a domain the user had never heard of. A
|
|
42
|
+
#: default that points nowhere is worse than no default, because it fails
|
|
43
|
+
#: later and somewhere else.
|
|
44
|
+
DEFAULT_API_URL = "https://vexpath.fly.dev"
|
|
38
45
|
|
|
39
46
|
#: Overrides the whole configuration directory, which is what a test needs and
|
|
40
47
|
#: what a container with a read-only home directory needs too.
|
|
@@ -1027,3 +1027,69 @@ def test_vex_export_refuses_an_unknown_format(
|
|
|
1027
1027
|
)
|
|
1028
1028
|
assert result.exit_code == int(ExitCode.CONFIG_ERROR)
|
|
1029
1029
|
assert "'openvex' or 'cyclonedx'" in result.stderr
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# --- --policy project ------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def test_the_project_policy_cannot_be_shadowed_by_a_local_file(
|
|
1036
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1037
|
+
) -> None:
|
|
1038
|
+
"""A file called ``project`` must not replace the organisation's gate.
|
|
1039
|
+
|
|
1040
|
+
``--policy`` accepts a path, so a file of that name in a working
|
|
1041
|
+
directory is the obvious substitution attack on a centrally managed
|
|
1042
|
+
policy: drop ``project`` in the repository, and the gate becomes whatever
|
|
1043
|
+
the repository says. The word is therefore resolved before the preset
|
|
1044
|
+
table and before the filesystem.
|
|
1045
|
+
"""
|
|
1046
|
+
from vexpath.commands import auth
|
|
1047
|
+
from vexpath.commands import scan as scan_module
|
|
1048
|
+
|
|
1049
|
+
(tmp_path / "project").write_text("findings.exists(f, false)", encoding="utf-8")
|
|
1050
|
+
|
|
1051
|
+
fetched: list[str] = []
|
|
1052
|
+
|
|
1053
|
+
def fake_fetch(credential: object, project: str) -> str:
|
|
1054
|
+
fetched.append(project)
|
|
1055
|
+
return "findings.exists(f, f.kev)"
|
|
1056
|
+
|
|
1057
|
+
monkeypatch.setattr(auth, "fetch_policy", fake_fetch)
|
|
1058
|
+
monkeypatch.chdir(tmp_path)
|
|
1059
|
+
|
|
1060
|
+
resolved = scan_module._resolve_policy("project", project="svc", credential=_credential())
|
|
1061
|
+
|
|
1062
|
+
assert resolved == "findings.exists(f, f.kev)"
|
|
1063
|
+
assert fetched == ["svc"]
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def test_the_project_policy_needs_a_project() -> None:
|
|
1067
|
+
import typer
|
|
1068
|
+
|
|
1069
|
+
from vexpath.commands import scan as scan_module
|
|
1070
|
+
|
|
1071
|
+
# fail_config raises typer.Exit, not SystemExit: the CLI exits through
|
|
1072
|
+
# Typer so the exit code stays in one place.
|
|
1073
|
+
with pytest.raises(typer.Exit):
|
|
1074
|
+
scan_module._resolve_policy("project", project=None, credential=_credential())
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def test_a_preset_still_wins_over_a_file_of_the_same_name(
|
|
1078
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1079
|
+
) -> None:
|
|
1080
|
+
from vexpath.commands import scan as scan_module
|
|
1081
|
+
|
|
1082
|
+
(tmp_path / "balanced").write_text("findings.exists(f, false)", encoding="utf-8")
|
|
1083
|
+
monkeypatch.chdir(tmp_path)
|
|
1084
|
+
|
|
1085
|
+
assert scan_module._resolve_policy("balanced") == "balanced"
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _credential() -> object:
|
|
1089
|
+
from vexpath.credentials import Credential
|
|
1090
|
+
|
|
1091
|
+
return Credential(
|
|
1092
|
+
secret="vexpath_live_xxxxxxxxxxxx",
|
|
1093
|
+
api_url="https://example.invalid",
|
|
1094
|
+
source="file",
|
|
1095
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|