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.
Files changed (26) hide show
  1. {vexpath-0.1.0 → vexpath-0.1.2}/PKG-INFO +2 -2
  2. {vexpath-0.1.0 → vexpath-0.1.2}/pyproject.toml +2 -2
  3. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/auth.py +38 -0
  4. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/scan.py +54 -10
  5. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/credentials.py +8 -1
  6. {vexpath-0.1.0 → vexpath-0.1.2}/tests/test_cli.py +66 -0
  7. {vexpath-0.1.0 → vexpath-0.1.2}/.gitignore +0 -0
  8. {vexpath-0.1.0 → vexpath-0.1.2}/LICENSE +0 -0
  9. {vexpath-0.1.0 → vexpath-0.1.2}/README.md +0 -0
  10. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/__init__.py +0 -0
  11. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/__init__.py +0 -0
  12. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/advisory.py +0 -0
  13. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/attest.py +0 -0
  14. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/policy.py +0 -0
  15. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/reach.py +0 -0
  16. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/report.py +0 -0
  17. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/sbom.py +0 -0
  18. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/upload.py +0 -0
  19. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/commands/vex.py +0 -0
  20. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/exit_codes.py +0 -0
  21. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/main.py +0 -0
  22. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/output.py +0 -0
  23. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/py.typed +0 -0
  24. {vexpath-0.1.0 → vexpath-0.1.2}/src/vexpath/sources.py +0 -0
  25. {vexpath-0.1.0 → vexpath-0.1.2}/tests/test_auth_and_upload.py +0 -0
  26. {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.0
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.0
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.0"
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.0",
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: a preset name, a CEL expression, or a path to a file "
129
- "holding one. Exits 1 when it fails. Run 'vexpath policy list' to "
130
- "see the presets. Without this, nothing is gated and the scan "
131
- "only reports."
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=_resolve_policy(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(*, upload: bool, project: str | None) -> credentials.Credential | None:
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
- def _resolve_policy(source: str | None) -> str | None:
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
- DEFAULT_API_URL = "https://api.vexpath.dev"
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