cloudsmith-cli 1.20.0__py3-none-any.whl
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.
- cloudsmith_cli/__init__.py +10 -0
- cloudsmith_cli/__main__.py +8 -0
- cloudsmith_cli/cli/__init__.py +1 -0
- cloudsmith_cli/cli/command.py +160 -0
- cloudsmith_cli/cli/commands/__init__.py +33 -0
- cloudsmith_cli/cli/commands/auth.py +173 -0
- cloudsmith_cli/cli/commands/check.py +129 -0
- cloudsmith_cli/cli/commands/copy.py +98 -0
- cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
- cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
- cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
- cloudsmith_cli/cli/commands/delete.py +67 -0
- cloudsmith_cli/cli/commands/dependencies.py +108 -0
- cloudsmith_cli/cli/commands/docs.py +16 -0
- cloudsmith_cli/cli/commands/download.py +620 -0
- cloudsmith_cli/cli/commands/entitlements.py +819 -0
- cloudsmith_cli/cli/commands/help_.py +12 -0
- cloudsmith_cli/cli/commands/list_.py +317 -0
- cloudsmith_cli/cli/commands/login.py +99 -0
- cloudsmith_cli/cli/commands/logout.py +151 -0
- cloudsmith_cli/cli/commands/main.py +73 -0
- cloudsmith_cli/cli/commands/mcp.py +523 -0
- cloudsmith_cli/cli/commands/metadata.py +503 -0
- cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
- cloudsmith_cli/cli/commands/metrics/command.py +20 -0
- cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
- cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
- cloudsmith_cli/cli/commands/move.py +111 -0
- cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
- cloudsmith_cli/cli/commands/policy/command.py +20 -0
- cloudsmith_cli/cli/commands/policy/deny.py +248 -0
- cloudsmith_cli/cli/commands/policy/license.py +335 -0
- cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
- cloudsmith_cli/cli/commands/push.py +1323 -0
- cloudsmith_cli/cli/commands/quarantine.py +148 -0
- cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
- cloudsmith_cli/cli/commands/quota/command.py +20 -0
- cloudsmith_cli/cli/commands/quota/history.py +122 -0
- cloudsmith_cli/cli/commands/quota/quota.py +107 -0
- cloudsmith_cli/cli/commands/repos.py +330 -0
- cloudsmith_cli/cli/commands/resync.py +90 -0
- cloudsmith_cli/cli/commands/status.py +92 -0
- cloudsmith_cli/cli/commands/tags.py +375 -0
- cloudsmith_cli/cli/commands/tokens.py +318 -0
- cloudsmith_cli/cli/commands/upstream.py +479 -0
- cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
- cloudsmith_cli/cli/commands/whoami.py +188 -0
- cloudsmith_cli/cli/config.py +635 -0
- cloudsmith_cli/cli/decorators.py +624 -0
- cloudsmith_cli/cli/exceptions.py +215 -0
- cloudsmith_cli/cli/metadata_common.py +146 -0
- cloudsmith_cli/cli/saml.py +109 -0
- cloudsmith_cli/cli/table.py +59 -0
- cloudsmith_cli/cli/types.py +14 -0
- cloudsmith_cli/cli/utils.py +267 -0
- cloudsmith_cli/cli/validators.py +378 -0
- cloudsmith_cli/cli/webserver.py +263 -0
- cloudsmith_cli/core/__init__.py +1 -0
- cloudsmith_cli/core/api/__init__.py +1 -0
- cloudsmith_cli/core/api/distros.py +31 -0
- cloudsmith_cli/core/api/entitlements.py +130 -0
- cloudsmith_cli/core/api/exceptions.py +57 -0
- cloudsmith_cli/core/api/files.py +131 -0
- cloudsmith_cli/core/api/init.py +109 -0
- cloudsmith_cli/core/api/metadata.py +217 -0
- cloudsmith_cli/core/api/metrics.py +78 -0
- cloudsmith_cli/core/api/orgs.py +201 -0
- cloudsmith_cli/core/api/packages.py +309 -0
- cloudsmith_cli/core/api/quota.py +64 -0
- cloudsmith_cli/core/api/rates.py +28 -0
- cloudsmith_cli/core/api/repos.py +81 -0
- cloudsmith_cli/core/api/status.py +27 -0
- cloudsmith_cli/core/api/upstreams.py +72 -0
- cloudsmith_cli/core/api/user.py +109 -0
- cloudsmith_cli/core/api/version.py +15 -0
- cloudsmith_cli/core/api/vulnerabilities.py +230 -0
- cloudsmith_cli/core/cache_utils.py +160 -0
- cloudsmith_cli/core/config.py +140 -0
- cloudsmith_cli/core/credentials/__init__.py +0 -0
- cloudsmith_cli/core/credentials/chain.py +69 -0
- cloudsmith_cli/core/credentials/models.py +44 -0
- cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
- cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
- cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
- cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
- cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
- cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
- cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
- cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
- cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
- cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
- cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
- cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
- cloudsmith_cli/core/credentials/provider.py +17 -0
- cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
- cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
- cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
- cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
- cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
- cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
- cloudsmith_cli/core/download.py +594 -0
- cloudsmith_cli/core/keyring.py +171 -0
- cloudsmith_cli/core/mcp/__init__.py +0 -0
- cloudsmith_cli/core/mcp/data.py +17 -0
- cloudsmith_cli/core/mcp/server.py +786 -0
- cloudsmith_cli/core/pagination.py +131 -0
- cloudsmith_cli/core/ratelimits.py +88 -0
- cloudsmith_cli/core/rest.py +255 -0
- cloudsmith_cli/core/utils.py +95 -0
- cloudsmith_cli/core/version.py +20 -0
- cloudsmith_cli/credential_helpers/__init__.py +7 -0
- cloudsmith_cli/credential_helpers/backends.py +41 -0
- cloudsmith_cli/credential_helpers/common.py +111 -0
- cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
- cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
- cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
- cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
- cloudsmith_cli/credential_helpers/launchers.py +175 -0
- cloudsmith_cli/data/VERSION +1 -0
- cloudsmith_cli/data/config.ini +23 -0
- cloudsmith_cli/data/credentials.ini +14 -0
- cloudsmith_cli/templates/__init__.py +3 -0
- cloudsmith_cli/templates/auth_error.html +45 -0
- cloudsmith_cli/templates/auth_success.html +37 -0
- cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
- cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
- cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
- cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
- cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
- cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""API version utilities."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
import semver
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_version():
|
|
9
|
+
"""Get the raw/unparsed version of the API as a string."""
|
|
10
|
+
return importlib.metadata.version("cloudsmith_api")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_version_info():
|
|
14
|
+
"""Get the API version as VersionInfo object."""
|
|
15
|
+
return semver.parse_version_info(get_version())
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""API - Vulnerabilities endpoints."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import cloudsmith_api
|
|
5
|
+
|
|
6
|
+
from ...cli import utils
|
|
7
|
+
from .. import ratelimits
|
|
8
|
+
from .exceptions import catch_raise_api_exception
|
|
9
|
+
from .init import get_api_client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_vulnerabilities_api():
|
|
13
|
+
"""Get the vulnerabilities API client."""
|
|
14
|
+
return get_api_client(cloudsmith_api.VulnerabilitiesApi)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_vulnerabilities_summary_table(data, severity_filter, total_filtered_vulns):
|
|
18
|
+
"""Print vulnerabilities as a table."""
|
|
19
|
+
|
|
20
|
+
severity_keys = {
|
|
21
|
+
"Critical": "critical",
|
|
22
|
+
"High": "high",
|
|
23
|
+
"Medium": "medium",
|
|
24
|
+
"Low": "low",
|
|
25
|
+
"Unknown": "unknown",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if severity_filter:
|
|
29
|
+
allowed = [s.strip().lower() for s in severity_filter.split(",")]
|
|
30
|
+
severity_keys = {k: v for k, v in severity_keys.items() if v in allowed}
|
|
31
|
+
|
|
32
|
+
headers = [{"header": "Package", "justify": "left", "style": "cyan"}]
|
|
33
|
+
for key in severity_keys.keys():
|
|
34
|
+
headers.append({"header": key, "justify": "center", "style": "white"})
|
|
35
|
+
|
|
36
|
+
# Get package name and version for the target label
|
|
37
|
+
pkg_data = getattr(data, "package", None)
|
|
38
|
+
pkg_name = getattr(pkg_data, "name", "Unknown")
|
|
39
|
+
pkg_version = getattr(pkg_data, "version", "Unknown")
|
|
40
|
+
target_label = f"{pkg_name}:{pkg_version}"
|
|
41
|
+
|
|
42
|
+
# Initialize aggregate counts
|
|
43
|
+
counts = {v: 0 for v in severity_keys.values()}
|
|
44
|
+
|
|
45
|
+
# Parse the scans and aggregate results
|
|
46
|
+
scans = getattr(data, "scans", [])
|
|
47
|
+
for scan in scans:
|
|
48
|
+
results = getattr(scan, "results", [])
|
|
49
|
+
for result in results:
|
|
50
|
+
severity = getattr(result, "severity", "unknown").lower()
|
|
51
|
+
if severity in counts:
|
|
52
|
+
counts[severity] += 1
|
|
53
|
+
elif "unknown" in counts:
|
|
54
|
+
counts["unknown"] += 1
|
|
55
|
+
|
|
56
|
+
# Create the single summary row
|
|
57
|
+
row = [target_label]
|
|
58
|
+
for _header, key in severity_keys.items():
|
|
59
|
+
row.append(str(counts[key]))
|
|
60
|
+
|
|
61
|
+
rows = [row]
|
|
62
|
+
|
|
63
|
+
click.echo()
|
|
64
|
+
click.echo()
|
|
65
|
+
|
|
66
|
+
utils.rich_print_table(headers=headers, rows=rows, title="Vulnerabilities Summary")
|
|
67
|
+
|
|
68
|
+
if severity_filter:
|
|
69
|
+
filters = severity_filter.upper()
|
|
70
|
+
click.echo(
|
|
71
|
+
f"\nTotal Vulnerabilities: {getattr(data, 'num_vulnerabilities', 0)}"
|
|
72
|
+
)
|
|
73
|
+
click.echo(f"\nTotal {filters} Vulnerabilities: {total_filtered_vulns}")
|
|
74
|
+
else:
|
|
75
|
+
click.echo(
|
|
76
|
+
f"\nTotal Vulnerabilities: {getattr(data, 'num_vulnerabilities', 0)}"
|
|
77
|
+
)
|
|
78
|
+
click.echo()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _print_vulnerabilities_assessment_table(data, severity_filter=None):
|
|
82
|
+
"""Print vulnerabilities assessment as a table."""
|
|
83
|
+
|
|
84
|
+
# Group vulnerabilities by package
|
|
85
|
+
grouped_vulns = {}
|
|
86
|
+
|
|
87
|
+
allowed_severities = None
|
|
88
|
+
if severity_filter:
|
|
89
|
+
allowed_severities = [s.strip().lower() for s in severity_filter.split(",")]
|
|
90
|
+
|
|
91
|
+
# Get top level package info as fallback
|
|
92
|
+
pkg_data = getattr(data, "package", None)
|
|
93
|
+
top_pkg_name = getattr(pkg_data, "name", "Unknown")
|
|
94
|
+
|
|
95
|
+
# Get scan data
|
|
96
|
+
scans = getattr(data, "scans", [])
|
|
97
|
+
for scan in scans:
|
|
98
|
+
results = getattr(scan, "results", [])
|
|
99
|
+
for result in results:
|
|
100
|
+
# Filter by severity if requested
|
|
101
|
+
if allowed_severities:
|
|
102
|
+
severity = getattr(result, "severity", "unknown").lower()
|
|
103
|
+
if severity not in allowed_severities:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
pkg_name = getattr(result, "package_name", top_pkg_name)
|
|
107
|
+
if pkg_name not in grouped_vulns:
|
|
108
|
+
grouped_vulns[pkg_name] = []
|
|
109
|
+
grouped_vulns[pkg_name].append(result)
|
|
110
|
+
|
|
111
|
+
if not grouped_vulns:
|
|
112
|
+
click.echo("\nNo vulnerabilities found matching criteria.")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Severity mapping for sorting
|
|
116
|
+
sev_map = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
|
|
117
|
+
|
|
118
|
+
# Iterate through sorted packages
|
|
119
|
+
for pkg_name in sorted(grouped_vulns.keys()):
|
|
120
|
+
vulns = grouped_vulns[pkg_name]
|
|
121
|
+
|
|
122
|
+
# Sort vulns by severity (Critical first)
|
|
123
|
+
vulns.sort(
|
|
124
|
+
key=lambda r: sev_map.get(getattr(r, "severity", "unknown").lower(), 99)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
rows = []
|
|
128
|
+
for result in vulns:
|
|
129
|
+
# Severity
|
|
130
|
+
severity = getattr(result, "severity", "Unknown").title()
|
|
131
|
+
severity_style = "white"
|
|
132
|
+
s = severity.lower()
|
|
133
|
+
if s == "critical":
|
|
134
|
+
severity_style = "red bold"
|
|
135
|
+
elif s == "high":
|
|
136
|
+
severity_style = "red"
|
|
137
|
+
elif s == "medium":
|
|
138
|
+
severity_style = "yellow"
|
|
139
|
+
elif s == "low":
|
|
140
|
+
severity_style = "blue"
|
|
141
|
+
|
|
142
|
+
# ID
|
|
143
|
+
vuln_id = getattr(
|
|
144
|
+
result, "vulnerability_id", getattr(result, "identifier", "Unknown")
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Affected Version
|
|
148
|
+
affected_raw = getattr(
|
|
149
|
+
result, "affected_version", getattr(result, "affected_version", None)
|
|
150
|
+
)
|
|
151
|
+
if hasattr(affected_raw, "version"):
|
|
152
|
+
aff_version = affected_raw.version
|
|
153
|
+
affected_operator = affected_raw.operator
|
|
154
|
+
affected_version = f"{affected_operator} {aff_version}"
|
|
155
|
+
else:
|
|
156
|
+
affected_version = str(affected_raw) if affected_raw else "-"
|
|
157
|
+
|
|
158
|
+
# Fixed Version
|
|
159
|
+
fixed_raw = getattr(
|
|
160
|
+
result, "fix_version", getattr(result, "fixed_version", None)
|
|
161
|
+
)
|
|
162
|
+
if hasattr(fixed_raw, "version"):
|
|
163
|
+
fix_version = fixed_raw.version
|
|
164
|
+
fixed_operator = fixed_raw.operator
|
|
165
|
+
fixed_version = f"{fixed_operator} {fix_version}"
|
|
166
|
+
else:
|
|
167
|
+
fixed_version = str(fixed_raw) if fixed_raw else "-"
|
|
168
|
+
|
|
169
|
+
# Title / Description
|
|
170
|
+
title = getattr(result, "title", "")
|
|
171
|
+
|
|
172
|
+
rows.append(
|
|
173
|
+
[
|
|
174
|
+
f"[{severity_style}]{severity}[/{severity_style}]",
|
|
175
|
+
vuln_id,
|
|
176
|
+
affected_version,
|
|
177
|
+
fixed_version,
|
|
178
|
+
title,
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
click.echo()
|
|
183
|
+
utils.rich_print_table(
|
|
184
|
+
headers=[
|
|
185
|
+
"Severity",
|
|
186
|
+
"Vulnerability",
|
|
187
|
+
"Affected Version",
|
|
188
|
+
"Fixed Version",
|
|
189
|
+
"Title",
|
|
190
|
+
],
|
|
191
|
+
rows=rows,
|
|
192
|
+
title=f"Package: {pkg_name}",
|
|
193
|
+
show_lines=True,
|
|
194
|
+
)
|
|
195
|
+
click.echo()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_package_scan_identifier(owner, repo, package):
|
|
199
|
+
"""Get the scan identifier using the package identifier"""
|
|
200
|
+
client = get_vulnerabilities_api()
|
|
201
|
+
|
|
202
|
+
with catch_raise_api_exception():
|
|
203
|
+
data, _, headers = client.vulnerabilities_package_list_with_http_info(
|
|
204
|
+
owner=owner, repo=repo, package=package
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
ratelimits.maybe_rate_limit(client, headers)
|
|
208
|
+
|
|
209
|
+
return data[0].identifier
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_package_scan_result(
|
|
213
|
+
opts, owner, repo, package, show_assessment, fixable, severity_filter
|
|
214
|
+
):
|
|
215
|
+
"""Get the package vulnerability scan result."""
|
|
216
|
+
client = get_vulnerabilities_api()
|
|
217
|
+
|
|
218
|
+
with catch_raise_api_exception():
|
|
219
|
+
scan_identifier = get_package_scan_identifier(
|
|
220
|
+
owner=owner, repo=repo, package=package
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
with catch_raise_api_exception():
|
|
224
|
+
data, _, headers = client.vulnerabilities_read_with_http_info(
|
|
225
|
+
owner=owner, repo=repo, package=package, identifier=scan_identifier
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
ratelimits.maybe_rate_limit(client, headers)
|
|
229
|
+
|
|
230
|
+
return data
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""Shared utilities for on-disk credential and cache storage."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _atomic_write_text(dest: str, text: str, *, mode: int = 0o600) -> None:
|
|
14
|
+
"""Atomically write *text* to *dest* using a sibling temp file.
|
|
15
|
+
|
|
16
|
+
Caller is responsible for ensuring the parent directory exists.
|
|
17
|
+
"""
|
|
18
|
+
parent = os.path.dirname(dest) or "."
|
|
19
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".tmp_", suffix=".json")
|
|
20
|
+
try:
|
|
21
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
22
|
+
f.write(text)
|
|
23
|
+
f.flush()
|
|
24
|
+
os.fsync(f.fileno())
|
|
25
|
+
os.chmod(tmp_path, mode)
|
|
26
|
+
os.replace(tmp_path, dest)
|
|
27
|
+
except (OSError, TypeError, ValueError):
|
|
28
|
+
try:
|
|
29
|
+
os.unlink(tmp_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) -> None:
|
|
36
|
+
"""Atomically write JSON to a file with restrictive permissions.
|
|
37
|
+
|
|
38
|
+
Writes to a sibling temp file, fsyncs, sets mode, then renames over the
|
|
39
|
+
destination. Concurrent readers never see a partial file. Temp file is
|
|
40
|
+
removed on error. Caller is responsible for ensuring the parent directory
|
|
41
|
+
exists.
|
|
42
|
+
"""
|
|
43
|
+
dest = os.fspath(path)
|
|
44
|
+
_atomic_write_text(dest, json.dumps(data), mode=mode)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge_json_file(
|
|
48
|
+
path: str | os.PathLike,
|
|
49
|
+
mutate: Callable[[dict], None],
|
|
50
|
+
*,
|
|
51
|
+
backup: bool = True,
|
|
52
|
+
dry_run: bool = False,
|
|
53
|
+
mode: int = 0o600,
|
|
54
|
+
) -> bool:
|
|
55
|
+
"""Read a JSON object file, apply *mutate* in place, and atomically write it back.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
path:
|
|
60
|
+
Path to the JSON file (e.g. ``~/.docker/config.json``).
|
|
61
|
+
mutate:
|
|
62
|
+
Callable that receives the loaded ``dict`` and modifies it in place.
|
|
63
|
+
It must not return a value; changes are applied to the dict directly.
|
|
64
|
+
backup:
|
|
65
|
+
When ``True`` (default) and the file already existed and content will
|
|
66
|
+
change, copy the prior file to ``{path}.bak`` before writing.
|
|
67
|
+
dry_run:
|
|
68
|
+
When ``True``, perform the read + mutate + change-detection but make
|
|
69
|
+
**no** writes (no temp file, no ``.bak``, no replace).
|
|
70
|
+
mode:
|
|
71
|
+
File-permission bits for the written file (default ``0o600``).
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
bool
|
|
76
|
+
``True`` if the file content changed (or would change under
|
|
77
|
+
``dry_run``), ``False`` otherwise.
|
|
78
|
+
|
|
79
|
+
Notes
|
|
80
|
+
-----
|
|
81
|
+
* If the file is missing, empty, or does not parse as a JSON object
|
|
82
|
+
(``dict``), the starting value is ``{}``.
|
|
83
|
+
* Key order is preserved — ``sort_keys`` is **not** used.
|
|
84
|
+
* The on-disk form is ``json.dumps(data, indent=2, ensure_ascii=False) + "\\n"``.
|
|
85
|
+
* Parent directory is created (mode ``0o700``) if absent.
|
|
86
|
+
|
|
87
|
+
Concurrency
|
|
88
|
+
-----------
|
|
89
|
+
This is a single-writer, install-time helper (used by
|
|
90
|
+
``credential-helper install/uninstall``). The read-modify-write is
|
|
91
|
+
last-writer-wins and is **NOT** safe against concurrent writers mutating
|
|
92
|
+
the same file; do not use it on a hot path. The atomic replace guarantees
|
|
93
|
+
the file is never left partially written, but concurrent merges can drop
|
|
94
|
+
each other's changes.
|
|
95
|
+
"""
|
|
96
|
+
dest = os.fspath(path)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# 1. Read existing content
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
existing_text: str | None = None
|
|
102
|
+
try:
|
|
103
|
+
with open(dest, encoding="utf-8") as f:
|
|
104
|
+
existing_text = f.read()
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
existing_text = None
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# 2. Parse → dict (treat missing/empty/non-dict/malformed as {})
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
data: dict = {}
|
|
112
|
+
if existing_text:
|
|
113
|
+
try:
|
|
114
|
+
parsed = json.loads(existing_text)
|
|
115
|
+
if isinstance(parsed, dict):
|
|
116
|
+
data = parsed
|
|
117
|
+
except (json.JSONDecodeError, ValueError):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# 3. Mutate in place
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
mutate(data)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# 4. Stable serialisation + change detection
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
new_text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
129
|
+
|
|
130
|
+
if existing_text is not None:
|
|
131
|
+
# Normalise existing content for comparison: if the file already has
|
|
132
|
+
# the exact canonical form we produce, treat as no-change.
|
|
133
|
+
no_change = new_text == existing_text
|
|
134
|
+
else:
|
|
135
|
+
no_change = False # file didn't exist → always a change
|
|
136
|
+
|
|
137
|
+
if no_change:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
if dry_run:
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# 5. Ensure parent directory exists
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
parent = os.path.dirname(dest) or "."
|
|
147
|
+
os.makedirs(parent, mode=0o700, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# 6. Backup (only when file existed and content changes)
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
if backup and existing_text is not None:
|
|
153
|
+
_atomic_write_text(dest + ".bak", existing_text, mode=0o600)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# 7. Atomic write
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
_atomic_write_text(dest, new_text, mode=mode)
|
|
159
|
+
|
|
160
|
+
return True
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import stat
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from .utils import get_help_website
|
|
7
|
+
|
|
8
|
+
ConfigValues = collections.namedtuple(
|
|
9
|
+
"ConfigValues", ["reader", "present", "mode", "data"]
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_config_files(ctx, opts, api_key, force=False):
|
|
14
|
+
"""Create default config files."""
|
|
15
|
+
# pylint: disable=unused-argument
|
|
16
|
+
config_reader = opts.get_config_reader()
|
|
17
|
+
creds_reader = opts.get_creds_reader()
|
|
18
|
+
has_config = config_reader.has_default_file()
|
|
19
|
+
has_creds = creds_reader.has_default_file()
|
|
20
|
+
|
|
21
|
+
if has_config and has_creds:
|
|
22
|
+
create = False
|
|
23
|
+
else:
|
|
24
|
+
click.echo()
|
|
25
|
+
if not force:
|
|
26
|
+
create = click.confirm(
|
|
27
|
+
"No default config file(s) found, do you want to create them?"
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
create = "y"
|
|
31
|
+
|
|
32
|
+
click.echo()
|
|
33
|
+
if not create:
|
|
34
|
+
click.secho(
|
|
35
|
+
"For reference here are your default config file locations:", fg="yellow"
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
click.secho(
|
|
39
|
+
"Great! Let me just create your default configs for you now ...", fg="green"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
configs = (
|
|
43
|
+
ConfigValues(reader=config_reader, present=has_config, mode=None, data={}),
|
|
44
|
+
ConfigValues(
|
|
45
|
+
reader=creds_reader,
|
|
46
|
+
present=has_creds,
|
|
47
|
+
mode=stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP,
|
|
48
|
+
data={"api_key": api_key},
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
has_errors = False
|
|
53
|
+
for config in configs:
|
|
54
|
+
click.echo(
|
|
55
|
+
"%(name)s config file: %(filepath)s ... "
|
|
56
|
+
% {
|
|
57
|
+
"name": click.style(config.reader.config_name.capitalize(), bold=True),
|
|
58
|
+
"filepath": click.style(
|
|
59
|
+
config.reader.get_default_filepath(), fg="magenta"
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
nl=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if not config.present and create:
|
|
66
|
+
try:
|
|
67
|
+
ok = config.reader.create_default_file(
|
|
68
|
+
data=config.data, mode=config.mode
|
|
69
|
+
)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
ok = False
|
|
72
|
+
error_message = exc.strerror
|
|
73
|
+
has_errors = True
|
|
74
|
+
|
|
75
|
+
if ok:
|
|
76
|
+
click.secho("CREATED", fg="green")
|
|
77
|
+
else:
|
|
78
|
+
click.secho("ERROR", fg="red")
|
|
79
|
+
click.secho(
|
|
80
|
+
"The following error occurred while trying to "
|
|
81
|
+
"create the file: %(message)s"
|
|
82
|
+
% {"message": click.style(error_message, fg="red")}
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
# Update existing credentials file with new API key if provided
|
|
87
|
+
if (
|
|
88
|
+
config.present
|
|
89
|
+
and config.data.get("api_key")
|
|
90
|
+
and hasattr(config.reader, "update_api_key")
|
|
91
|
+
):
|
|
92
|
+
try:
|
|
93
|
+
config.reader.update_api_key(
|
|
94
|
+
config.reader.get_default_filepath(),
|
|
95
|
+
config.data["api_key"],
|
|
96
|
+
)
|
|
97
|
+
click.secho("UPDATED", fg="green")
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
has_errors = True
|
|
100
|
+
click.secho("ERROR", fg="red")
|
|
101
|
+
click.secho(
|
|
102
|
+
"The following error occurred while trying to "
|
|
103
|
+
"update the file: %(message)s"
|
|
104
|
+
% {"message": click.style(exc.strerror, fg="red")}
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow")
|
|
109
|
+
|
|
110
|
+
return create, has_errors
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def new_config_messaging(has_errors, opts, create, api_key):
|
|
114
|
+
"""Provide messaging to user after generating new configs"""
|
|
115
|
+
if has_errors:
|
|
116
|
+
click.echo()
|
|
117
|
+
click.secho("Oops, please fix the errors and try again!", fg="red")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if opts.api_key != api_key:
|
|
121
|
+
click.echo()
|
|
122
|
+
if opts.api_key:
|
|
123
|
+
click.secho(
|
|
124
|
+
"Note: The above API key doesn't match what you have in "
|
|
125
|
+
"your default credentials config file.",
|
|
126
|
+
fg="yellow",
|
|
127
|
+
)
|
|
128
|
+
elif not create:
|
|
129
|
+
click.secho(
|
|
130
|
+
"Note: Don't forget to put your API key in a config file, "
|
|
131
|
+
"export it on the environment, or set it via -k.",
|
|
132
|
+
fg="yellow",
|
|
133
|
+
)
|
|
134
|
+
click.secho(
|
|
135
|
+
"If you need more help please see the documentation: "
|
|
136
|
+
"%(website)s" % {"website": click.style(get_help_website(), bold=True)}
|
|
137
|
+
)
|
|
138
|
+
click.echo()
|
|
139
|
+
|
|
140
|
+
click.secho("You're ready to rock, let's start automating!", fg="green")
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Credential provider chain for the Cloudsmith CLI.
|
|
2
|
+
|
|
3
|
+
Implements an AWS SDK-style credential resolution chain that evaluates
|
|
4
|
+
credential sources sequentially and returns the first valid result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from .models import CredentialContext, CredentialResult
|
|
12
|
+
from .provider import CredentialProvider
|
|
13
|
+
from .providers import (
|
|
14
|
+
CLIFlagProvider,
|
|
15
|
+
CredentialsFileProvider,
|
|
16
|
+
EnvVarProvider,
|
|
17
|
+
KeyringProvider,
|
|
18
|
+
OidcProvider,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CredentialProviderChain:
|
|
25
|
+
"""Evaluates credential providers in order, returning the first valid result.
|
|
26
|
+
|
|
27
|
+
If no providers are given, uses the default chain:
|
|
28
|
+
CLIFlag → EnvVar → CredentialsFile → Keyring → OIDC.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, providers: list[CredentialProvider] | None = None):
|
|
32
|
+
if providers is not None:
|
|
33
|
+
self.providers = providers
|
|
34
|
+
else:
|
|
35
|
+
self.providers = [
|
|
36
|
+
CLIFlagProvider(),
|
|
37
|
+
EnvVarProvider(),
|
|
38
|
+
CredentialsFileProvider(),
|
|
39
|
+
KeyringProvider(),
|
|
40
|
+
OidcProvider(),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
|
|
44
|
+
"""Evaluate each provider in order. Return the first successful result."""
|
|
45
|
+
for provider in self.providers:
|
|
46
|
+
try:
|
|
47
|
+
result = provider.resolve(context)
|
|
48
|
+
if result is not None:
|
|
49
|
+
if context.debug:
|
|
50
|
+
logger.debug(
|
|
51
|
+
"Credentials resolved by %s: %s",
|
|
52
|
+
provider.name,
|
|
53
|
+
result.source_detail or result.source_name,
|
|
54
|
+
)
|
|
55
|
+
return result
|
|
56
|
+
if context.debug:
|
|
57
|
+
logger.debug(
|
|
58
|
+
"Provider %s did not resolve credentials, trying next",
|
|
59
|
+
provider.name,
|
|
60
|
+
)
|
|
61
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
62
|
+
# Intentionally broad - one provider failing shouldn't stop others
|
|
63
|
+
logger.debug(
|
|
64
|
+
"Provider %s raised an exception, skipping",
|
|
65
|
+
provider.name,
|
|
66
|
+
exc_info=True,
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Credential data models for the Cloudsmith CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CredentialContext:
|
|
13
|
+
"""Context passed to credential providers during resolution.
|
|
14
|
+
|
|
15
|
+
Separate per-source fields allow the chain to evaluate sources in priority
|
|
16
|
+
order without conflating them. Populated from Click options in
|
|
17
|
+
``resolve_credentials``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
session: requests.Session | None = None
|
|
21
|
+
api_key_from_flag: str | None = None
|
|
22
|
+
api_key_from_env: str | None = None
|
|
23
|
+
api_key_from_file: str | None = None
|
|
24
|
+
api_host: str = "https://api.cloudsmith.io"
|
|
25
|
+
creds_file_path: str | None = None
|
|
26
|
+
profile: str | None = None
|
|
27
|
+
debug: bool = False
|
|
28
|
+
keyring_refresh_failed: bool = False
|
|
29
|
+
oidc_audience: str | None = None
|
|
30
|
+
oidc_org: str | None = None
|
|
31
|
+
oidc_service_slug: str | None = None
|
|
32
|
+
oidc_discovery_disabled: bool = False
|
|
33
|
+
oidc_detector_order: str | None = None
|
|
34
|
+
oidc_disabled_detectors: frozenset[str] = frozenset()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CredentialResult:
|
|
39
|
+
"""Result from a successful credential resolution."""
|
|
40
|
+
|
|
41
|
+
api_key: str
|
|
42
|
+
source_name: str
|
|
43
|
+
source_detail: str | None = None
|
|
44
|
+
auth_type: Literal["api_key", "bearer"] = "api_key"
|