obvyr-cli 2.6.0__tar.gz → 2.8.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.
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/PKG-INFO +7 -7
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/pyproject.toml +10 -12
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/api_client.py +1 -3
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/archive_builder.py +56 -9
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/cli.py +80 -26
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/config.py +40 -12
- obvyr_cli-2.8.0/src/obvyr_cli/redaction.py +135 -0
- obvyr_cli-2.8.0/src/obvyr_cli/utils.py +6 -0
- obvyr_cli-2.6.0/src/obvyr_cli/utils.py +0 -13
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/README_PYPI.md +0 -0
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/__init__.py +0 -0
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/command_wrapper.py +0 -0
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/error_handling.py +0 -0
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/jwt_utils.py +0 -0
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/logging_config.py +0 -0
- {obvyr_cli-2.6.0 → obvyr_cli-2.8.0}/src/obvyr_cli/schemas.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: obvyr-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Command-line wrapper to allow Obvyr to capture evidence from any machine.
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Author: Wyrd Technology Ltd
|
|
@@ -8,14 +8,14 @@ Requires-Python: >=3.14,<4.0
|
|
|
8
8
|
Classifier: License :: Other/Proprietary License
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
-
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
12
|
+
Requires-Dist: click (==8.5.0)
|
|
12
13
|
Requires-Dist: httpx (==0.28.1)
|
|
13
|
-
Requires-Dist: orjson (==3.
|
|
14
|
-
Requires-Dist: pydantic (==2.13.
|
|
15
|
-
Requires-Dist: pydantic-settings (==2.
|
|
16
|
-
Requires-Dist: python-dotenv (==1.2.
|
|
14
|
+
Requires-Dist: orjson (==3.12.0)
|
|
15
|
+
Requires-Dist: pydantic (==2.13.5)
|
|
16
|
+
Requires-Dist: pydantic-settings (==2.15.0)
|
|
17
|
+
Requires-Dist: python-dotenv (==1.2.4)
|
|
17
18
|
Requires-Dist: tenacity (==9.1.4)
|
|
18
|
-
Requires-Dist: toml (==0.10.2)
|
|
19
19
|
Requires-Dist: zstandard (==0.25.0)
|
|
20
20
|
Project-URL: Homepage, https://wyrd-technology.com
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["poetry-core==2.
|
|
2
|
+
requires = ["poetry-core==2.5.0"]
|
|
3
3
|
build-backend = "poetry.core.masonry.api"
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "obvyr-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.8.0"
|
|
8
8
|
description = "Command-line wrapper to allow Obvyr to capture evidence from any machine."
|
|
9
9
|
authors = ["Wyrd Technology Ltd"]
|
|
10
10
|
license = "Proprietary"
|
|
@@ -28,26 +28,24 @@ include = "obvyr_cli"
|
|
|
28
28
|
|
|
29
29
|
[tool.poetry.dependencies]
|
|
30
30
|
python = ">=3.14,<4.0"
|
|
31
|
-
python-dotenv = "1.2.
|
|
32
|
-
click = "8.
|
|
33
|
-
pydantic = "2.13.
|
|
34
|
-
pydantic-settings = "2.
|
|
31
|
+
python-dotenv = "1.2.4"
|
|
32
|
+
click = "8.5.0"
|
|
33
|
+
pydantic = "2.13.5"
|
|
34
|
+
pydantic-settings = "2.15.0"
|
|
35
35
|
httpx = "0.28.1"
|
|
36
36
|
tenacity = "9.1.4"
|
|
37
|
-
|
|
38
|
-
orjson = "3.11.9"
|
|
37
|
+
orjson = "3.12.0"
|
|
39
38
|
zstandard = "0.25.0"
|
|
40
39
|
|
|
41
40
|
[tool.poetry.group.dev.dependencies]
|
|
42
|
-
ruff = "0.
|
|
41
|
+
ruff = "0.16.10"
|
|
43
42
|
mypy = "1.20.2"
|
|
44
43
|
pytest = "9.1.1"
|
|
45
44
|
pytest-cov = "7.1.0"
|
|
46
45
|
black = "26.5.1"
|
|
47
|
-
pyproject-fmt = "2.
|
|
46
|
+
pyproject-fmt = "2.29.4"
|
|
48
47
|
pytest-subprocess = "1.6.0"
|
|
49
|
-
|
|
50
|
-
poethepoet = "0.47.1"
|
|
48
|
+
poethepoet = "0.48.0"
|
|
51
49
|
|
|
52
50
|
[tool.poetry.scripts]
|
|
53
51
|
obvyr = "obvyr_cli.cli:cli_run_process"
|
|
@@ -16,8 +16,6 @@ from obvyr_cli.logging_config import get_logger
|
|
|
16
16
|
|
|
17
17
|
logger = get_logger("api_client")
|
|
18
18
|
|
|
19
|
-
project_config = utils.get_project_config()
|
|
20
|
-
|
|
21
19
|
|
|
22
20
|
class ObvyrAPIClient:
|
|
23
21
|
"""Client for sending execution data to the Obvyr API."""
|
|
@@ -43,7 +41,7 @@ class ObvyrAPIClient:
|
|
|
43
41
|
"""Generate headers for authentication."""
|
|
44
42
|
return {
|
|
45
43
|
"Authorization": (f"Bearer {self.api_key}"),
|
|
46
|
-
"User-Agent": f"obvyr-cli/{
|
|
44
|
+
"User-Agent": f"obvyr-cli/{utils.get_version()}",
|
|
47
45
|
}
|
|
48
46
|
|
|
49
47
|
@retry(
|
|
@@ -204,6 +204,7 @@ def build_artifacts_tar_zst(
|
|
|
204
204
|
attachment_paths: Optional[list[pathlib.Path]] = None,
|
|
205
205
|
tmp_dir: Optional[pathlib.Path] = None,
|
|
206
206
|
tags: Optional[list[str]] = None,
|
|
207
|
+
env: Optional[Dict[str, str]] = None,
|
|
207
208
|
) -> pathlib.Path:
|
|
208
209
|
"""
|
|
209
210
|
Build artifacts.tar.zst archive from command execution data.
|
|
@@ -218,6 +219,7 @@ def build_artifacts_tar_zst(
|
|
|
218
219
|
attachment_paths: Optional list of attachment files to include
|
|
219
220
|
tmp_dir: Optional temporary directory for output file
|
|
220
221
|
tags: Optional list of tags to include in command.json
|
|
222
|
+
env: Environment variables to include in command.json (default: none)
|
|
221
223
|
|
|
222
224
|
Returns:
|
|
223
225
|
Path to the created artifacts.tar.zst file
|
|
@@ -226,9 +228,6 @@ def build_artifacts_tar_zst(
|
|
|
226
228
|
Invalid or oversized attachments are skipped with warning logs.
|
|
227
229
|
Attachments are prioritized by value (XML/JSON > YAML/CSV > TXT/LOG).
|
|
228
230
|
"""
|
|
229
|
-
if tmp_dir is None:
|
|
230
|
-
tmp_dir = pathlib.Path(tempfile.gettempdir())
|
|
231
|
-
|
|
232
231
|
# Select valid attachments with priority-based ordering
|
|
233
232
|
selected_attachments: list[pathlib.Path] = []
|
|
234
233
|
if attachment_paths:
|
|
@@ -236,9 +235,6 @@ def build_artifacts_tar_zst(
|
|
|
236
235
|
attachment_paths
|
|
237
236
|
)
|
|
238
237
|
|
|
239
|
-
# Create output file path
|
|
240
|
-
output_path = tmp_dir / "artifacts.tar.zst"
|
|
241
|
-
|
|
242
238
|
# Prepare command.json data exactly as specified in the doc
|
|
243
239
|
command_data = {
|
|
244
240
|
"command": run_command_response.command,
|
|
@@ -246,7 +242,7 @@ def build_artifacts_tar_zst(
|
|
|
246
242
|
"return_code": run_command_response.returncode,
|
|
247
243
|
"execution_time_ms": round(run_command_response.execution_time * 1000),
|
|
248
244
|
"executed": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
249
|
-
"env":
|
|
245
|
+
"env": env or {},
|
|
250
246
|
"tags": tags or [],
|
|
251
247
|
}
|
|
252
248
|
|
|
@@ -287,8 +283,17 @@ def build_artifacts_tar_zst(
|
|
|
287
283
|
compressor = zstd.ZstdCompressor(write_content_size=True)
|
|
288
284
|
compressed_data = compressor.compress(tar_data)
|
|
289
285
|
|
|
290
|
-
|
|
291
|
-
|
|
286
|
+
# mkstemp creates a unique file readable and writable by the owner only
|
|
287
|
+
output_fd, output_name = tempfile.mkstemp(
|
|
288
|
+
prefix="obvyr-", suffix=".tar.zst", dir=tmp_dir
|
|
289
|
+
)
|
|
290
|
+
output_path = pathlib.Path(output_name)
|
|
291
|
+
try:
|
|
292
|
+
with os.fdopen(output_fd, "wb") as output_file:
|
|
293
|
+
output_file.write(compressed_data)
|
|
294
|
+
except Exception:
|
|
295
|
+
output_path.unlink(missing_ok=True)
|
|
296
|
+
raise
|
|
292
297
|
|
|
293
298
|
return output_path
|
|
294
299
|
|
|
@@ -329,3 +334,45 @@ def summarize_archive(archive_path: pathlib.Path) -> ArchiveSummary:
|
|
|
329
334
|
members[member.name] = {"bytes": member.size}
|
|
330
335
|
|
|
331
336
|
return ArchiveSummary(archive_bytes=archive_bytes, members=members)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def render_archive_preview(archive_path: pathlib.Path) -> str:
|
|
340
|
+
"""
|
|
341
|
+
Render the contents of an archive as text for a dry run.
|
|
342
|
+
|
|
343
|
+
Reads the archive that would be uploaded, so the preview is exactly what
|
|
344
|
+
would be sent. command.json is pretty-printed and output.txt is shown in
|
|
345
|
+
full. Attachments are listed with their sizes only.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
archive_path: Path to the artifacts.tar.zst file
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Human-readable preview of the archive contents
|
|
352
|
+
"""
|
|
353
|
+
sections: list[str] = []
|
|
354
|
+
decompressor = zstd.ZstdDecompressor()
|
|
355
|
+
|
|
356
|
+
with open(archive_path, "rb") as archive_file:
|
|
357
|
+
with decompressor.stream_reader(archive_file) as reader:
|
|
358
|
+
with tarfile.open(
|
|
359
|
+
fileobj=reader, mode="r|"
|
|
360
|
+
) as tar: # pragma: no branch
|
|
361
|
+
for member in tar:
|
|
362
|
+
content = tar.extractfile(member)
|
|
363
|
+
assert content is not None # nosec: members are files
|
|
364
|
+
data = content.read()
|
|
365
|
+
if member.name == "command.json":
|
|
366
|
+
body = json.dumps(json.loads(data), indent=2)
|
|
367
|
+
sections.append(f"--- command.json ---\n{body}")
|
|
368
|
+
elif member.name == "output.txt":
|
|
369
|
+
sections.append(
|
|
370
|
+
f"--- output.txt ---\n{data.decode('utf-8')}"
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
sections.append(
|
|
374
|
+
f"--- {member.name} ({member.size} bytes, "
|
|
375
|
+
"content not shown) ---"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return "\n\n".join(sections)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import glob
|
|
2
2
|
import logging
|
|
3
3
|
import math
|
|
4
|
+
import os
|
|
4
5
|
import pathlib
|
|
5
6
|
import time
|
|
6
7
|
from datetime import datetime, timezone
|
|
@@ -9,7 +10,10 @@ from typing import List
|
|
|
9
10
|
import click
|
|
10
11
|
|
|
11
12
|
from obvyr_cli.api_client import ObvyrAPIClient
|
|
12
|
-
from obvyr_cli.archive_builder import
|
|
13
|
+
from obvyr_cli.archive_builder import (
|
|
14
|
+
build_artifacts_tar_zst,
|
|
15
|
+
render_archive_preview,
|
|
16
|
+
)
|
|
13
17
|
from obvyr_cli.command_wrapper import run_command
|
|
14
18
|
from obvyr_cli.config import ProfileSettings, Settings, get_settings
|
|
15
19
|
from obvyr_cli.error_handling import (
|
|
@@ -18,6 +22,7 @@ from obvyr_cli.error_handling import (
|
|
|
18
22
|
)
|
|
19
23
|
from obvyr_cli.jwt_utils import get_jwt_expiry
|
|
20
24
|
from obvyr_cli.logging_config import configure_logging, get_logger
|
|
25
|
+
from obvyr_cli.redaction import filter_environment, redact_response
|
|
21
26
|
from obvyr_cli.schemas import (
|
|
22
27
|
CommandExecutionConfig,
|
|
23
28
|
OutputMode,
|
|
@@ -223,6 +228,51 @@ def display_output(response: RunCommandResponse) -> None:
|
|
|
223
228
|
display_execution_summary(response)
|
|
224
229
|
|
|
225
230
|
|
|
231
|
+
def prepare_archive(
|
|
232
|
+
active_profile: ProfileSettings, data: RunCommandResponse
|
|
233
|
+
) -> pathlib.Path:
|
|
234
|
+
"""
|
|
235
|
+
Build the archive to upload, applying the profile's capture controls.
|
|
236
|
+
|
|
237
|
+
Only allow-listed environment variables are included, and secrets are
|
|
238
|
+
redacted from the environment, command arguments and output.
|
|
239
|
+
|
|
240
|
+
:param active_profile: Profile configuration controlling what is captured.
|
|
241
|
+
:param data: Command execution result to be archived.
|
|
242
|
+
:return: Path to the temporary archive file.
|
|
243
|
+
"""
|
|
244
|
+
attachment_paths = None
|
|
245
|
+
if active_profile.ATTACHMENT_PATHS:
|
|
246
|
+
resolved_paths = resolve_attachment_globs(
|
|
247
|
+
active_profile.ATTACHMENT_PATHS
|
|
248
|
+
)
|
|
249
|
+
fresh_paths = []
|
|
250
|
+
for path in resolved_paths:
|
|
251
|
+
if is_attachment_fresh(
|
|
252
|
+
path, active_profile.ATTACHMENT_MAX_AGE_SECONDS
|
|
253
|
+
):
|
|
254
|
+
fresh_paths.append(path)
|
|
255
|
+
logger.debug(f"Including fresh attachment: {path}")
|
|
256
|
+
else:
|
|
257
|
+
logger.debug(f"Skipping stale attachment: {path}")
|
|
258
|
+
|
|
259
|
+
# Only set attachment_paths if we have fresh files
|
|
260
|
+
if fresh_paths:
|
|
261
|
+
attachment_paths = fresh_paths
|
|
262
|
+
|
|
263
|
+
return build_artifacts_tar_zst(
|
|
264
|
+
redact_response(data, active_profile.REDACT_PATTERNS),
|
|
265
|
+
attachment_paths=attachment_paths,
|
|
266
|
+
tags=active_profile.TAGS,
|
|
267
|
+
env=filter_environment(
|
|
268
|
+
os.environ,
|
|
269
|
+
mode=active_profile.ENV_MODE,
|
|
270
|
+
include=active_profile.ENV_INCLUDE,
|
|
271
|
+
extra_patterns=active_profile.REDACT_PATTERNS,
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
226
276
|
def send_to_api(
|
|
227
277
|
active_profile: ProfileSettings, data: RunCommandResponse
|
|
228
278
|
) -> None:
|
|
@@ -239,30 +289,7 @@ def send_to_api(
|
|
|
239
289
|
|
|
240
290
|
archive_path = None
|
|
241
291
|
try:
|
|
242
|
-
|
|
243
|
-
attachment_paths = None
|
|
244
|
-
if active_profile.ATTACHMENT_PATHS:
|
|
245
|
-
resolved_paths = resolve_attachment_globs(
|
|
246
|
-
active_profile.ATTACHMENT_PATHS
|
|
247
|
-
)
|
|
248
|
-
fresh_paths = []
|
|
249
|
-
for path in resolved_paths:
|
|
250
|
-
if is_attachment_fresh(
|
|
251
|
-
path, active_profile.ATTACHMENT_MAX_AGE_SECONDS
|
|
252
|
-
):
|
|
253
|
-
fresh_paths.append(path)
|
|
254
|
-
logger.debug(f"Including fresh attachment: {path}")
|
|
255
|
-
else:
|
|
256
|
-
logger.debug(f"Skipping stale attachment: {path}")
|
|
257
|
-
|
|
258
|
-
# Only set attachment_paths if we have fresh files
|
|
259
|
-
if fresh_paths:
|
|
260
|
-
attachment_paths = fresh_paths
|
|
261
|
-
|
|
262
|
-
# Build archive with or without attachments
|
|
263
|
-
archive_path = build_artifacts_tar_zst(
|
|
264
|
-
data, attachment_paths=attachment_paths, tags=active_profile.TAGS
|
|
265
|
-
)
|
|
292
|
+
archive_path = prepare_archive(active_profile, data)
|
|
266
293
|
|
|
267
294
|
with ObvyrAPIClient(
|
|
268
295
|
api_key=active_profile.API_KEY,
|
|
@@ -294,6 +321,23 @@ def send_to_api(
|
|
|
294
321
|
archive_path.unlink()
|
|
295
322
|
|
|
296
323
|
|
|
324
|
+
def show_dry_run_payload(
|
|
325
|
+
active_profile: ProfileSettings, data: RunCommandResponse
|
|
326
|
+
) -> None:
|
|
327
|
+
"""
|
|
328
|
+
Print exactly what would be sent to the Obvyr API, without sending it.
|
|
329
|
+
|
|
330
|
+
:param active_profile: Profile configuration controlling what is captured.
|
|
331
|
+
:param data: Command execution result that would have been sent.
|
|
332
|
+
"""
|
|
333
|
+
archive_path = prepare_archive(active_profile, data)
|
|
334
|
+
try:
|
|
335
|
+
click.echo("\nDry run: nothing was sent. Payload that would be sent:\n")
|
|
336
|
+
click.echo(render_archive_preview(archive_path))
|
|
337
|
+
finally:
|
|
338
|
+
archive_path.unlink(missing_ok=True)
|
|
339
|
+
|
|
340
|
+
|
|
297
341
|
# ===================================
|
|
298
342
|
# Click CLI
|
|
299
343
|
# ===================================
|
|
@@ -351,6 +395,11 @@ def send_to_api(
|
|
|
351
395
|
is_flag=True,
|
|
352
396
|
help="Disable color output (don't set FORCE_COLOR environment variable).",
|
|
353
397
|
)
|
|
398
|
+
@click.option(
|
|
399
|
+
"--dry-run",
|
|
400
|
+
is_flag=True,
|
|
401
|
+
help="Run the command, then print exactly what would be sent instead of sending it.",
|
|
402
|
+
)
|
|
354
403
|
def cli_run_process(
|
|
355
404
|
command: List[str],
|
|
356
405
|
list_profiles: bool,
|
|
@@ -362,6 +411,7 @@ def cli_run_process(
|
|
|
362
411
|
no_stream: bool,
|
|
363
412
|
force_stream: bool,
|
|
364
413
|
no_color: bool,
|
|
414
|
+
dry_run: bool,
|
|
365
415
|
) -> None:
|
|
366
416
|
"""
|
|
367
417
|
Executes a system command while using the Obvyr CLI profile configuration.
|
|
@@ -404,7 +454,9 @@ def cli_run_process(
|
|
|
404
454
|
config=execution_config,
|
|
405
455
|
)
|
|
406
456
|
|
|
407
|
-
if
|
|
457
|
+
if dry_run:
|
|
458
|
+
show_dry_run_payload(active_profile, response)
|
|
459
|
+
elif active_profile.API_URL and active_profile.API_KEY:
|
|
408
460
|
send_to_api(active_profile, response)
|
|
409
461
|
|
|
410
462
|
display_execution_summary(response)
|
|
@@ -412,5 +464,7 @@ def cli_run_process(
|
|
|
412
464
|
if response.returncode != 0:
|
|
413
465
|
raise click.exceptions.Exit(response.returncode)
|
|
414
466
|
|
|
467
|
+
except click.exceptions.Exit:
|
|
468
|
+
raise
|
|
415
469
|
except Exception as e:
|
|
416
470
|
raise click.ClickException(str(e)) from e
|
|
@@ -6,8 +6,10 @@ from environment variables, allowing environmental overrides.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
import secrets
|
|
10
|
-
from typing import Dict
|
|
11
|
+
from typing import Dict, Literal
|
|
12
|
+
from urllib.parse import urlparse
|
|
11
13
|
|
|
12
14
|
from dotenv import load_dotenv
|
|
13
15
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
@@ -28,23 +30,49 @@ class ProfileSettings(BaseModel):
|
|
|
28
30
|
ATTACHMENT_MAX_AGE_SECONDS: int = 10
|
|
29
31
|
TIMEOUT: float = 10.0
|
|
30
32
|
VERIFY_SSL: bool = True
|
|
33
|
+
ALLOW_INSECURE_API_URL: bool = False
|
|
31
34
|
TAGS: list[str] = Field(default_factory=list)
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
ENV_MODE: Literal["allowlist", "none"] = "allowlist"
|
|
36
|
+
ENV_INCLUDE: list[str] = Field(default_factory=list)
|
|
37
|
+
REDACT_PATTERNS: list[str] = Field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@field_validator(
|
|
40
|
+
"TAGS",
|
|
41
|
+
"ATTACHMENT_PATHS",
|
|
42
|
+
"ENV_INCLUDE",
|
|
43
|
+
"REDACT_PATTERNS",
|
|
44
|
+
mode="before",
|
|
45
|
+
)
|
|
34
46
|
@classmethod
|
|
35
|
-
def
|
|
36
|
-
"""Parse
|
|
47
|
+
def parse_comma_separated(cls, value: str | list[str]) -> list[str]:
|
|
48
|
+
"""Parse a comma-separated string into a list, or return a list as-is."""
|
|
37
49
|
if isinstance(value, str):
|
|
38
|
-
return [
|
|
50
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
39
51
|
return value or []
|
|
40
52
|
|
|
41
|
-
@field_validator("
|
|
53
|
+
@field_validator("REDACT_PATTERNS")
|
|
42
54
|
@classmethod
|
|
43
|
-
def
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
def validate_redact_patterns(cls, value: list[str]) -> list[str]:
|
|
56
|
+
"""Fail fast on patterns that are not valid regular expressions."""
|
|
57
|
+
for pattern in value:
|
|
58
|
+
try:
|
|
59
|
+
re.compile(pattern)
|
|
60
|
+
except re.error as error:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Invalid REDACT_PATTERNS entry '{pattern}': {error}"
|
|
63
|
+
) from error
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def require_https_api_url(self) -> "ProfileSettings":
|
|
68
|
+
"""Fail fast on an API_URL that would send the token and archive in clear text."""
|
|
69
|
+
is_https = urlparse(self.API_URL).scheme.lower() == "https"
|
|
70
|
+
if self.API_URL and not is_https and not self.ALLOW_INSECURE_API_URL:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"API_URL '{self.API_URL}' must use HTTPS. "
|
|
73
|
+
"Set ALLOW_INSECURE_API_URL to true to override."
|
|
74
|
+
)
|
|
75
|
+
return self
|
|
48
76
|
|
|
49
77
|
@model_validator(mode="after")
|
|
50
78
|
def migrate_attachment_path(self) -> "ProfileSettings":
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Controls what the CLI sends: environment filtering and secret redaction.
|
|
3
|
+
|
|
4
|
+
All functions are pure. Callers pass in the environment and patterns
|
|
5
|
+
explicitly so behaviour is predictable and testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
from obvyr_cli.schemas import RunCommandResponse
|
|
12
|
+
|
|
13
|
+
# Exact names only. Prefix matching is deliberately avoided: CI_JOB_TOKEN
|
|
14
|
+
# and similar secrets share prefixes with harmless CI identifiers.
|
|
15
|
+
ENV_ALLOWLIST = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"CI",
|
|
18
|
+
"CONTINUOUS_INTEGRATION",
|
|
19
|
+
# GitHub Actions
|
|
20
|
+
"GITHUB_ACTIONS",
|
|
21
|
+
"GITHUB_RUN_ID",
|
|
22
|
+
"GITHUB_RUN_NUMBER",
|
|
23
|
+
"GITHUB_WORKFLOW",
|
|
24
|
+
"GITHUB_REPOSITORY",
|
|
25
|
+
"GITHUB_SHA",
|
|
26
|
+
"GITHUB_REF_NAME",
|
|
27
|
+
# GitLab CI
|
|
28
|
+
"GITLAB_CI",
|
|
29
|
+
"CI_PIPELINE_ID",
|
|
30
|
+
"CI_JOB_ID",
|
|
31
|
+
"CI_COMMIT_SHA",
|
|
32
|
+
"CI_COMMIT_REF_NAME",
|
|
33
|
+
# Jenkins
|
|
34
|
+
"JENKINS_URL",
|
|
35
|
+
"BUILD_NUMBER",
|
|
36
|
+
# Buildkite
|
|
37
|
+
"BUILDKITE",
|
|
38
|
+
"BUILDKITE_BUILD_NUMBER",
|
|
39
|
+
# CircleCI
|
|
40
|
+
"CIRCLECI",
|
|
41
|
+
"CIRCLE_BUILD_NUM",
|
|
42
|
+
"CIRCLE_SHA1",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# The CLI's own settings (including its API key) are never sent.
|
|
48
|
+
ALWAYS_EXCLUDED_PREFIX = "OBVYR_"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
REDACTED = "[REDACTED]"
|
|
52
|
+
|
|
53
|
+
# Variable and flag names that suggest the value is a secret.
|
|
54
|
+
SECRET_NAME_PATTERN = re.compile(
|
|
55
|
+
r"token|key|password|passwd|secret|credential", re.IGNORECASE
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Value shapes that are secrets wherever they appear.
|
|
59
|
+
SECRET_VALUE_PATTERNS = (
|
|
60
|
+
re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
61
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+"),
|
|
62
|
+
# name=value or name: value; the prefix group keeps the name visible.
|
|
63
|
+
re.compile(
|
|
64
|
+
r"(?i)(?P<prefix>\b[\w-]*(?:token|key|password|passwd|secret|credential)"
|
|
65
|
+
r"[\w-]*\s*[=:]\s*)\S+"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def keep_prefix_and_redact(match: re.Match[str]) -> str:
|
|
71
|
+
"""Replace a match with the redaction marker, keeping a named prefix group."""
|
|
72
|
+
return (match.groupdict().get("prefix") or "") + REDACTED
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def redact_text(text: str, extra_patterns: Sequence[str] = ()) -> str:
|
|
76
|
+
"""Replace anything in the text that matches a built-in or extra pattern."""
|
|
77
|
+
patterns = SECRET_VALUE_PATTERNS + tuple(
|
|
78
|
+
re.compile(pattern) for pattern in extra_patterns
|
|
79
|
+
)
|
|
80
|
+
for pattern in patterns:
|
|
81
|
+
text = pattern.sub(keep_prefix_and_redact, text)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def redact_command(
|
|
86
|
+
command: Sequence[str], extra_patterns: Sequence[str] = ()
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
"""Redact secrets in command arguments, including a flag's separate value."""
|
|
89
|
+
redacted: list[str] = []
|
|
90
|
+
previous_was_secret_flag = False
|
|
91
|
+
for argument in command:
|
|
92
|
+
if previous_was_secret_flag:
|
|
93
|
+
redacted.append(REDACTED)
|
|
94
|
+
else:
|
|
95
|
+
redacted.append(redact_text(argument, extra_patterns))
|
|
96
|
+
previous_was_secret_flag = (
|
|
97
|
+
argument.startswith("-")
|
|
98
|
+
and "=" not in argument
|
|
99
|
+
and SECRET_NAME_PATTERN.search(argument) is not None
|
|
100
|
+
)
|
|
101
|
+
return redacted
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def redact_response(
|
|
105
|
+
response: RunCommandResponse, extra_patterns: Sequence[str] = ()
|
|
106
|
+
) -> RunCommandResponse:
|
|
107
|
+
"""Return a copy of the response with secrets removed from what is uploaded."""
|
|
108
|
+
output = response.output
|
|
109
|
+
return response.model_copy(
|
|
110
|
+
update={
|
|
111
|
+
"command": redact_command(response.command, extra_patterns),
|
|
112
|
+
"output": redact_text(output, extra_patterns) if output else output,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def filter_environment(
|
|
118
|
+
environ: Mapping[str, str],
|
|
119
|
+
mode: str,
|
|
120
|
+
include: Sequence[str] = (),
|
|
121
|
+
extra_patterns: Sequence[str] = (),
|
|
122
|
+
) -> dict[str, str]:
|
|
123
|
+
"""Return the subset of the environment that is safe to send."""
|
|
124
|
+
if mode == "none":
|
|
125
|
+
return {}
|
|
126
|
+
permitted = ENV_ALLOWLIST | set(include)
|
|
127
|
+
return {
|
|
128
|
+
k: (
|
|
129
|
+
REDACTED
|
|
130
|
+
if SECRET_NAME_PATTERN.search(k)
|
|
131
|
+
else redact_text(v, extra_patterns)
|
|
132
|
+
)
|
|
133
|
+
for k, v in environ.items()
|
|
134
|
+
if k in permitted and not k.startswith(ALWAYS_EXCLUDED_PREFIX)
|
|
135
|
+
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
from importlib.metadata import version
|
|
2
|
-
|
|
3
|
-
import toml
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def get_project_config(pyproject_path: str = "pyproject.toml") -> dict:
|
|
7
|
-
try:
|
|
8
|
-
with open(pyproject_path, "r") as file:
|
|
9
|
-
config = toml.load(file)
|
|
10
|
-
return config["tool"]["poetry"]
|
|
11
|
-
except (KeyError, FileNotFoundError):
|
|
12
|
-
# Fall back to package metadata for installed versions
|
|
13
|
-
return {"version": version("obvyr-cli")}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|