securedeploy 0.1.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.
- securedeploy/__init__.py +3 -0
- securedeploy/cli.py +274 -0
- securedeploy/client.py +509 -0
- securedeploy/config.py +55 -0
- securedeploy/oauth.py +86 -0
- securedeploy-0.1.0.dist-info/METADATA +11 -0
- securedeploy-0.1.0.dist-info/RECORD +10 -0
- securedeploy-0.1.0.dist-info/WHEEL +5 -0
- securedeploy-0.1.0.dist-info/entry_points.txt +2 -0
- securedeploy-0.1.0.dist-info/top_level.txt +1 -0
securedeploy/__init__.py
ADDED
securedeploy/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from securedeploy import SecureDeploy
|
|
12
|
+
|
|
13
|
+
_WORKSPACE_SLUG_OPTION_HELP = "Workspace slug (default: config)."
|
|
14
|
+
_APPLICATION_SLUG_OPTION_HELP = "Application slug (default: config)."
|
|
15
|
+
_URL_PATH_OPTION_HELP = (
|
|
16
|
+
"Path under the app host URL (after the domain). "
|
|
17
|
+
"With --file: full path including file name (extension must match the file). "
|
|
18
|
+
"With --path: prefix added before each file's path relative to that directory."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle_errors(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
23
|
+
@functools.wraps(f)
|
|
24
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
25
|
+
try:
|
|
26
|
+
return f(*args, **kwargs)
|
|
27
|
+
except click.UsageError:
|
|
28
|
+
raise
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise click.ClickException(str(e)) from e
|
|
31
|
+
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _echo_json(data: object) -> None:
|
|
36
|
+
if data is None:
|
|
37
|
+
return
|
|
38
|
+
click.echo(json.dumps(data, indent=2))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group()
|
|
42
|
+
@click.option(
|
|
43
|
+
"--dev",
|
|
44
|
+
is_flag=True,
|
|
45
|
+
help="Use local dev URLs.",
|
|
46
|
+
)
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def cli(ctx: click.Context, dev: bool) -> None:
|
|
49
|
+
ctx.obj = SecureDeploy(env="development" if dev else "production")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.group()
|
|
53
|
+
def auth() -> None:
|
|
54
|
+
"""Authenticate with SecureDeploy."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@auth.command("login")
|
|
58
|
+
@click.pass_context
|
|
59
|
+
@handle_errors
|
|
60
|
+
def auth_login(ctx: click.Context) -> None:
|
|
61
|
+
ctx.obj.auth.login(
|
|
62
|
+
verification_callback=lambda uri: click.echo(
|
|
63
|
+
f"Please go to this URL to authorize: {uri}"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
click.echo("Login successful")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@auth.command("refresh-token")
|
|
70
|
+
@click.pass_context
|
|
71
|
+
@handle_errors
|
|
72
|
+
def auth_refresh_token(ctx: click.Context) -> None:
|
|
73
|
+
"""Exchange the stored refresh token for new tokens."""
|
|
74
|
+
ctx.obj.auth.refresh_token()
|
|
75
|
+
_echo_json(ctx.obj.whoami())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@auth.command("inspect")
|
|
79
|
+
@click.pass_context
|
|
80
|
+
@handle_errors
|
|
81
|
+
def auth_inspect(ctx: click.Context) -> None:
|
|
82
|
+
"""Print stored access and refresh tokens from local config as JSON."""
|
|
83
|
+
_echo_json(ctx.obj.auth.inspect())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.command("whoami")
|
|
87
|
+
@click.pass_context
|
|
88
|
+
@handle_errors
|
|
89
|
+
def whoami_cmd(ctx: click.Context) -> None:
|
|
90
|
+
_echo_json(ctx.obj.whoami())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@cli.group()
|
|
94
|
+
def workspaces() -> None:
|
|
95
|
+
"""Manage workspaces."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@workspaces.command("list")
|
|
99
|
+
@click.pass_context
|
|
100
|
+
@handle_errors
|
|
101
|
+
def workspaces_list(ctx: click.Context) -> None:
|
|
102
|
+
_echo_json(ctx.obj.workspaces.list())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@workspaces.command("set-default")
|
|
106
|
+
@click.argument("slug", required=False)
|
|
107
|
+
@click.pass_context
|
|
108
|
+
@handle_errors
|
|
109
|
+
def workspaces_set_default(ctx: click.Context, slug: Optional[str]) -> None:
|
|
110
|
+
if not slug:
|
|
111
|
+
workspaces = ctx.obj.workspaces.list()
|
|
112
|
+
if not workspaces:
|
|
113
|
+
raise click.UsageError("No workspaces found; create one first.")
|
|
114
|
+
slug = str(workspaces[0].get("slug") or "")
|
|
115
|
+
if not slug:
|
|
116
|
+
raise click.ClickException(
|
|
117
|
+
"First workspace in list has no slug; pass slug explicitly."
|
|
118
|
+
)
|
|
119
|
+
ctx.obj.workspaces.set_default(slug)
|
|
120
|
+
click.echo(f"Default workspace set to {slug!r}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@workspaces.command("get")
|
|
124
|
+
@click.argument("slug", required=False)
|
|
125
|
+
@click.pass_context
|
|
126
|
+
@handle_errors
|
|
127
|
+
def workspaces_get(ctx: click.Context, slug: Optional[str]) -> None:
|
|
128
|
+
if slug:
|
|
129
|
+
ws = ctx.obj.workspaces.get(slug)
|
|
130
|
+
else:
|
|
131
|
+
ws = ctx.obj.workspaces.get_default()
|
|
132
|
+
_echo_json(ws.data)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@workspaces.command("create")
|
|
136
|
+
@click.option("--name", default=None, help="Workspace display name.")
|
|
137
|
+
@click.option("--slug", required=True, help="Workspace slug.")
|
|
138
|
+
@click.pass_context
|
|
139
|
+
@handle_errors
|
|
140
|
+
def workspaces_create(ctx: click.Context, name: Optional[str], slug: str) -> None:
|
|
141
|
+
ws = ctx.obj.workspaces.create(name=name, slug=slug)
|
|
142
|
+
_echo_json(ws.data)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@cli.group()
|
|
146
|
+
def applications() -> None:
|
|
147
|
+
"""Manage applications."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@applications.command("list")
|
|
151
|
+
@click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
|
|
152
|
+
@click.pass_context
|
|
153
|
+
@handle_errors
|
|
154
|
+
def applications_list(ctx: click.Context, workspace: Optional[str]) -> None:
|
|
155
|
+
_echo_json(ctx.obj.applications.list(workspace=workspace))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@applications.command("get")
|
|
159
|
+
@click.argument("slug", required=False)
|
|
160
|
+
@click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
|
|
161
|
+
@click.pass_context
|
|
162
|
+
@handle_errors
|
|
163
|
+
def applications_get(
|
|
164
|
+
ctx: click.Context, slug: Optional[str], workspace: Optional[str]
|
|
165
|
+
) -> None:
|
|
166
|
+
app = ctx.obj.applications.get(slug, workspace=workspace)
|
|
167
|
+
_echo_json(app.data)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@applications.command("create")
|
|
171
|
+
@click.option("--name", default=None, help="Application display name.")
|
|
172
|
+
@click.option("--slug", required=True, help="Application slug.")
|
|
173
|
+
@click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
|
|
174
|
+
@click.pass_context
|
|
175
|
+
@handle_errors
|
|
176
|
+
def applications_create(
|
|
177
|
+
ctx: click.Context,
|
|
178
|
+
name: Optional[str],
|
|
179
|
+
slug: str,
|
|
180
|
+
workspace: Optional[str],
|
|
181
|
+
) -> None:
|
|
182
|
+
_echo_json(ctx.obj.applications.create(name=name, slug=slug, workspace=workspace))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@applications.command("set-default")
|
|
186
|
+
@click.argument("slug")
|
|
187
|
+
@click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
|
|
188
|
+
@click.pass_context
|
|
189
|
+
@handle_errors
|
|
190
|
+
def applications_set_default(
|
|
191
|
+
ctx: click.Context, slug: str, workspace: Optional[str]
|
|
192
|
+
) -> None:
|
|
193
|
+
ctx.obj.applications.set_default(slug, workspace=workspace)
|
|
194
|
+
click.echo(f"Default application set to {slug!r}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@cli.group()
|
|
198
|
+
def defaults() -> None:
|
|
199
|
+
"""Show configured defaults."""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@defaults.command("list")
|
|
203
|
+
@click.pass_context
|
|
204
|
+
@handle_errors
|
|
205
|
+
def defaults_list(ctx: click.Context) -> None:
|
|
206
|
+
_echo_json(ctx.obj.defaults.list())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _upload_cli_url_path_for_file(file: Path, url_path: str) -> str:
|
|
210
|
+
up = url_path.strip()
|
|
211
|
+
if not up:
|
|
212
|
+
raise click.UsageError("--url-path must not be empty.")
|
|
213
|
+
normalized = up.replace("\\", "/").lstrip("/")
|
|
214
|
+
if normalized.startswith(".."):
|
|
215
|
+
raise click.UsageError("--url-path must not start with '..'.")
|
|
216
|
+
if "/.." in normalized:
|
|
217
|
+
raise click.UsageError(
|
|
218
|
+
"--url-path must not contain '/..' (parent directory segments)."
|
|
219
|
+
)
|
|
220
|
+
if normalized.endswith("/"):
|
|
221
|
+
raise click.UsageError(
|
|
222
|
+
"--url-path must not be a directory path (remove trailing '/')."
|
|
223
|
+
)
|
|
224
|
+
base = Path(normalized).name
|
|
225
|
+
if not base or base in (".", ".."):
|
|
226
|
+
raise click.UsageError("--url-path must include a file name.")
|
|
227
|
+
ext_file = file.suffix.lower()
|
|
228
|
+
ext_url = Path(normalized).suffix.lower()
|
|
229
|
+
if ext_file != ext_url:
|
|
230
|
+
raise click.UsageError(
|
|
231
|
+
f"--url-path extension must match the file: expected {ext_file!r}, "
|
|
232
|
+
f"got {ext_url!r}."
|
|
233
|
+
)
|
|
234
|
+
return normalized
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@cli.command("upload")
|
|
238
|
+
@click.option("--file", type=click.Path(path_type=Path), default=None)
|
|
239
|
+
@click.option("--path", type=click.Path(path_type=Path), default=None)
|
|
240
|
+
@click.option("--url-path", default=None, help=_URL_PATH_OPTION_HELP)
|
|
241
|
+
@click.option("--application", default=None, help=_APPLICATION_SLUG_OPTION_HELP)
|
|
242
|
+
@click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
|
|
243
|
+
@click.pass_context
|
|
244
|
+
@handle_errors
|
|
245
|
+
def upload_cmd(
|
|
246
|
+
ctx: click.Context,
|
|
247
|
+
file: Optional[Path],
|
|
248
|
+
path: Optional[Path],
|
|
249
|
+
url_path: Optional[str],
|
|
250
|
+
application: Optional[str],
|
|
251
|
+
workspace: Optional[str],
|
|
252
|
+
) -> None:
|
|
253
|
+
if (file is None) == (path is None):
|
|
254
|
+
raise click.UsageError("Specify exactly one of --file or --path.")
|
|
255
|
+
if file is not None:
|
|
256
|
+
url_p = _upload_cli_url_path_for_file(file, url_path) if url_path else None
|
|
257
|
+
_echo_json(
|
|
258
|
+
ctx.obj.upload.file(
|
|
259
|
+
file, application=application, workspace=workspace, url_path=url_p
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
_echo_json(
|
|
264
|
+
ctx.obj.upload.path(
|
|
265
|
+
path,
|
|
266
|
+
application=application,
|
|
267
|
+
workspace=workspace,
|
|
268
|
+
url_path=url_path,
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
cli()
|
securedeploy/client.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import webbrowser
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from json import dumps
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Literal, Optional, Union
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import tomli_w
|
|
13
|
+
from httpx import ConnectError, TimeoutException
|
|
14
|
+
|
|
15
|
+
from securedeploy.config import Config
|
|
16
|
+
from securedeploy.oauth import OAuth
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Max size per file for uploads (directory walks skip oversize files; single-file upload raises).
|
|
21
|
+
_MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
# Basenames compared case-insensitively; common secret/credential artifacts in repos.
|
|
24
|
+
_UPLOAD_IGNORE_FILENAMES: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
".git-credentials",
|
|
27
|
+
".htpasswd",
|
|
28
|
+
".lesshst",
|
|
29
|
+
".mysql_history",
|
|
30
|
+
".netrc",
|
|
31
|
+
".npmrc",
|
|
32
|
+
".pgpass",
|
|
33
|
+
".psql_history",
|
|
34
|
+
".pypirc",
|
|
35
|
+
".rediscli_history",
|
|
36
|
+
".sqlite_history",
|
|
37
|
+
"credentials.json",
|
|
38
|
+
"google-services.json",
|
|
39
|
+
"googleservice-info.plist",
|
|
40
|
+
"id_dsa",
|
|
41
|
+
"id_ecdsa",
|
|
42
|
+
"id_ed25519",
|
|
43
|
+
"id_rsa",
|
|
44
|
+
"local.settings.json", # Azure Functions secrets
|
|
45
|
+
"pscredentials.xml",
|
|
46
|
+
"secrets.json",
|
|
47
|
+
"webhook.secret",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Suffixes (lowercase, leading dot) often used for keys, keystores, VPN, etc.
|
|
52
|
+
_UPLOAD_IGNORE_EXTENSIONS: frozenset[str] = frozenset(
|
|
53
|
+
{
|
|
54
|
+
".der",
|
|
55
|
+
".jks",
|
|
56
|
+
".kdbx",
|
|
57
|
+
".key",
|
|
58
|
+
".keystore",
|
|
59
|
+
".ovpn",
|
|
60
|
+
".p12",
|
|
61
|
+
".p8",
|
|
62
|
+
".pem",
|
|
63
|
+
".pfx",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _upload_ignore_reason(file_path: Path) -> Optional[str]:
|
|
69
|
+
"""If file_path should not be uploaded from a directory walk, return why; else None."""
|
|
70
|
+
name_lower = file_path.name.lower()
|
|
71
|
+
if name_lower == ".env" or name_lower.startswith(".env."):
|
|
72
|
+
return "sensitive env file (.env*)"
|
|
73
|
+
if name_lower == "credentials" and file_path.parent.name.lower() == ".aws":
|
|
74
|
+
return "AWS credentials file"
|
|
75
|
+
if name_lower in _UPLOAD_IGNORE_FILENAMES:
|
|
76
|
+
return "sensitive filename"
|
|
77
|
+
if file_path.suffix.lower() in _UPLOAD_IGNORE_EXTENSIONS:
|
|
78
|
+
return f"sensitive extension {file_path.suffix!r}"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def _upstream_request_guard() -> Iterator[None]:
|
|
84
|
+
try:
|
|
85
|
+
yield
|
|
86
|
+
except ConnectError as e:
|
|
87
|
+
raise ConnectError(
|
|
88
|
+
"Upstream API is not reachable; \n"
|
|
89
|
+
"Please check your connection and try again. \n\n"
|
|
90
|
+
"If the problem persists, please contact support at support@securedeploy.org. \n"
|
|
91
|
+
"Please include the following information in your support request: \n"
|
|
92
|
+
f"Error details: {e}."
|
|
93
|
+
) from e
|
|
94
|
+
except TimeoutException as e:
|
|
95
|
+
raise TimeoutException(
|
|
96
|
+
"Upstream API has timed out; \n"
|
|
97
|
+
"Please check your connection and try again. \n\n"
|
|
98
|
+
"If the problem persists, please contact support at support@securedeploy.org. \n"
|
|
99
|
+
"Please include the following information in your support request: \n"
|
|
100
|
+
f"Error details: {e}."
|
|
101
|
+
) from e
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _handle_api_error_response(
|
|
105
|
+
r: httpx.Response,
|
|
106
|
+
method: str,
|
|
107
|
+
path: str,
|
|
108
|
+
auth: _Auth,
|
|
109
|
+
retry: Optional[Callable[[], Any]],
|
|
110
|
+
) -> Any:
|
|
111
|
+
ct = r.headers.get("content-type", "").lower()
|
|
112
|
+
if r.content and "json" in ct:
|
|
113
|
+
try:
|
|
114
|
+
detail = dumps(r.json(), indent=2)
|
|
115
|
+
except ValueError:
|
|
116
|
+
detail = r.text
|
|
117
|
+
else:
|
|
118
|
+
detail = r.text
|
|
119
|
+
msg = f"{method} {path} failed ({r.status_code}):\n{detail}"
|
|
120
|
+
if r.status_code == 404:
|
|
121
|
+
raise ResourceNotFoundError(msg)
|
|
122
|
+
if r.status_code == 401:
|
|
123
|
+
if retry is None:
|
|
124
|
+
raise RuntimeError(msg)
|
|
125
|
+
logger.info("Refreshing access token")
|
|
126
|
+
auth.refresh_token()
|
|
127
|
+
return retry()
|
|
128
|
+
raise RuntimeError(msg)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ResourceNotFoundError(RuntimeError):
|
|
132
|
+
"""Raised when the API returns HTTP 404."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SecureDeploy:
|
|
136
|
+
"""Client for the securedeploy.org API; mirrors CLI command groups."""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self, env: Literal["development", "production"] = "production"
|
|
140
|
+
) -> None:
|
|
141
|
+
if env == "development":
|
|
142
|
+
self._config = Config(
|
|
143
|
+
api_base_url="http://api.securedeploy.local",
|
|
144
|
+
auth_base_url="http://localhost",
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
self._config = Config()
|
|
148
|
+
self.auth = _Auth(self)
|
|
149
|
+
self.workspaces = _Workspaces(self)
|
|
150
|
+
self.applications = _Applications(self)
|
|
151
|
+
self.defaults = _Defaults(self)
|
|
152
|
+
self.upload = _Upload(self)
|
|
153
|
+
|
|
154
|
+
def whoami(self) -> dict[str, Any]:
|
|
155
|
+
users = self._request("GET", "/v1/users")
|
|
156
|
+
if not users or len(users) == 0:
|
|
157
|
+
raise RuntimeError("Failed to get user details")
|
|
158
|
+
return users[0]
|
|
159
|
+
|
|
160
|
+
def _request(
|
|
161
|
+
self,
|
|
162
|
+
method: str,
|
|
163
|
+
path: str,
|
|
164
|
+
*,
|
|
165
|
+
json: Optional[dict[str, Any]] = None,
|
|
166
|
+
params: Optional[dict[str, Any]] = None,
|
|
167
|
+
reauth: bool = True,
|
|
168
|
+
) -> Any:
|
|
169
|
+
url = f"{self._config.api_base_url.rstrip('/')}{path}"
|
|
170
|
+
headers = {"Authorization": f"Bearer {self._config.get_access_token()}"}
|
|
171
|
+
with _upstream_request_guard(), httpx.Client(timeout=60.0) as client:
|
|
172
|
+
r = client.request(method, url, headers=headers, json=json, params=params)
|
|
173
|
+
if r.status_code >= 400:
|
|
174
|
+
retry: Optional[Callable[[], Any]] = (
|
|
175
|
+
(
|
|
176
|
+
lambda: self._request(
|
|
177
|
+
method, path, json=json, params=params, reauth=False
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
if reauth
|
|
181
|
+
else None
|
|
182
|
+
)
|
|
183
|
+
return _handle_api_error_response(r, method, path, self.auth, retry)
|
|
184
|
+
if not r.content:
|
|
185
|
+
return None
|
|
186
|
+
return r.json()
|
|
187
|
+
|
|
188
|
+
def _save_config(self) -> None:
|
|
189
|
+
self._config.config_file.write_text(
|
|
190
|
+
tomli_w.dumps(self._config.config), encoding="utf-8"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _default_workspace_slug(self) -> str:
|
|
194
|
+
slug = self._config.config.get("default_workspace")
|
|
195
|
+
if not slug:
|
|
196
|
+
raise RuntimeError("No default workspace; set one or pass workspace=")
|
|
197
|
+
return str(slug)
|
|
198
|
+
|
|
199
|
+
def _default_application_slug(self) -> str:
|
|
200
|
+
slug = self._config.config.get("default_application")
|
|
201
|
+
if not slug:
|
|
202
|
+
raise RuntimeError("No default application; set one or pass application=")
|
|
203
|
+
return str(slug)
|
|
204
|
+
|
|
205
|
+
def _default_application_workspace_slug(self) -> str:
|
|
206
|
+
slug = self._config.config.get("default_application_workspace")
|
|
207
|
+
if not slug:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
"No default application workspace; set one or pass application_workspace="
|
|
210
|
+
)
|
|
211
|
+
return str(slug)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _Auth:
|
|
215
|
+
def __init__(self, client: SecureDeploy) -> None:
|
|
216
|
+
self._client = client
|
|
217
|
+
|
|
218
|
+
def login(self, verification_callback: Optional[Callable] = None) -> None:
|
|
219
|
+
oauth = OAuth(self._client._config)
|
|
220
|
+
response = oauth.create_device_code()
|
|
221
|
+
verification_uri = response.get("verification_uri")
|
|
222
|
+
if verification_uri:
|
|
223
|
+
logger.info(f"Please go to this URL to authorize:\n{verification_uri}")
|
|
224
|
+
if verification_callback:
|
|
225
|
+
verification_callback(verification_uri)
|
|
226
|
+
webbrowser.open(str(verification_uri))
|
|
227
|
+
tokens = oauth.poll_for_token()
|
|
228
|
+
self._client._config.set_tokens(
|
|
229
|
+
tokens.get("access_token"), tokens.get("refresh_token")
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def refresh_token(self) -> None:
|
|
233
|
+
oauth = OAuth(self._client._config)
|
|
234
|
+
tokens = oauth.refresh_access_token()
|
|
235
|
+
self._client._config.set_tokens(
|
|
236
|
+
tokens.get("access_token"), tokens.get("refresh_token")
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def inspect(self) -> dict[str, Any]:
|
|
240
|
+
c = self._client._config.config
|
|
241
|
+
return {
|
|
242
|
+
"access_token": c.get("access_token"),
|
|
243
|
+
"refresh_token": c.get("refresh_token"),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class _Defaults:
|
|
248
|
+
def __init__(self, client: SecureDeploy) -> None:
|
|
249
|
+
self._client = client
|
|
250
|
+
|
|
251
|
+
def list(self) -> dict[str, Any]:
|
|
252
|
+
return {
|
|
253
|
+
"default_workspace": self._client._config.config.get("default_workspace"),
|
|
254
|
+
"default_application": self._client._config.config.get(
|
|
255
|
+
"default_application"
|
|
256
|
+
),
|
|
257
|
+
"default_application_workspace": self._client._config.config.get(
|
|
258
|
+
"default_application_workspace"
|
|
259
|
+
),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _Workspaces:
|
|
264
|
+
def __init__(self, client: SecureDeploy) -> None:
|
|
265
|
+
self._client = client
|
|
266
|
+
|
|
267
|
+
def list(self) -> list[dict[str, Any]]:
|
|
268
|
+
return self._client._request("GET", "/v1/workspaces")
|
|
269
|
+
|
|
270
|
+
def get(self, slug: str) -> Workspace:
|
|
271
|
+
data = self._client._request("GET", f"/v1/workspaces/{slug}")
|
|
272
|
+
return Workspace(self._client, slug, data if isinstance(data, dict) else {})
|
|
273
|
+
|
|
274
|
+
def get_default(self) -> Workspace:
|
|
275
|
+
slug = self._client._default_workspace_slug()
|
|
276
|
+
return self.get(slug)
|
|
277
|
+
|
|
278
|
+
def set_default(self, slug: str) -> None:
|
|
279
|
+
self.get(slug)
|
|
280
|
+
self._client._config.config["default_workspace"] = slug
|
|
281
|
+
self._client._save_config()
|
|
282
|
+
|
|
283
|
+
def create(
|
|
284
|
+
self,
|
|
285
|
+
*,
|
|
286
|
+
name: Optional[str] = None,
|
|
287
|
+
slug: Optional[str] = None,
|
|
288
|
+
) -> Workspace:
|
|
289
|
+
body = {k: v for k, v in {"name": name, "slug": slug}.items() if v is not None}
|
|
290
|
+
if not body:
|
|
291
|
+
raise ValueError("workspaces.create requires at least name or slug")
|
|
292
|
+
data = self._client._request("POST", "/v1/workspaces", json=body)
|
|
293
|
+
if not isinstance(data, dict):
|
|
294
|
+
raise RuntimeError("Unexpected API response for workspace create")
|
|
295
|
+
ws_slug = str(data.get("slug") or slug or "")
|
|
296
|
+
if not ws_slug:
|
|
297
|
+
raise RuntimeError("API did not return a workspace slug")
|
|
298
|
+
return Workspace(self._client, ws_slug, data)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Workspace:
|
|
302
|
+
def __init__(self, client: SecureDeploy, slug: str, data: dict[str, Any]) -> None:
|
|
303
|
+
self._client = client
|
|
304
|
+
self.slug = slug
|
|
305
|
+
self.data = data
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def applications(self) -> _ScopedApplications:
|
|
309
|
+
return _ScopedApplications(self._client, self.slug)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Application:
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
client: SecureDeploy,
|
|
316
|
+
workspace_slug: str,
|
|
317
|
+
slug: str,
|
|
318
|
+
data: dict[str, Any],
|
|
319
|
+
) -> None:
|
|
320
|
+
self._client = client
|
|
321
|
+
self.workspace_slug = workspace_slug
|
|
322
|
+
self.slug = slug
|
|
323
|
+
self.data = data
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class _Applications:
|
|
327
|
+
def __init__(self, client: SecureDeploy) -> None:
|
|
328
|
+
self._client = client
|
|
329
|
+
|
|
330
|
+
def list(self, *, workspace: Optional[str] = None) -> list[dict[str, Any]]:
|
|
331
|
+
ws = workspace or self._client._default_workspace_slug()
|
|
332
|
+
return self._client._request("GET", f"/v1/workspaces/{ws}/applications")
|
|
333
|
+
|
|
334
|
+
def get(
|
|
335
|
+
self,
|
|
336
|
+
slug: Optional[str] = None,
|
|
337
|
+
*,
|
|
338
|
+
workspace: Optional[str] = None,
|
|
339
|
+
) -> Application:
|
|
340
|
+
if slug is None:
|
|
341
|
+
slug = self._client._default_application_slug()
|
|
342
|
+
ws = (
|
|
343
|
+
self._client._default_application_workspace_slug()
|
|
344
|
+
or self._client._default_workspace_slug()
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
ws = workspace or self._client._default_workspace_slug()
|
|
348
|
+
data = self._client._request("GET", f"/v1/workspaces/{ws}/applications/{slug}")
|
|
349
|
+
return Application(
|
|
350
|
+
self._client,
|
|
351
|
+
ws,
|
|
352
|
+
slug,
|
|
353
|
+
data if isinstance(data, dict) else {},
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def create(
|
|
357
|
+
self,
|
|
358
|
+
*,
|
|
359
|
+
name: Optional[str] = None,
|
|
360
|
+
slug: Optional[str] = None,
|
|
361
|
+
workspace: Optional[str] = None,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
ws = workspace or self._client._default_workspace_slug()
|
|
364
|
+
body = {k: v for k, v in {"name": name, "slug": slug}.items() if v is not None}
|
|
365
|
+
if not body:
|
|
366
|
+
body = {}
|
|
367
|
+
return self._client._request(
|
|
368
|
+
"POST", f"/v1/workspaces/{ws}/applications", json=body
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def set_default(
|
|
372
|
+
self,
|
|
373
|
+
slug: str,
|
|
374
|
+
*,
|
|
375
|
+
workspace: Optional[str] = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
ws = workspace or self._client._default_workspace_slug()
|
|
378
|
+
try:
|
|
379
|
+
self.get(slug, workspace=ws)
|
|
380
|
+
except ResourceNotFoundError:
|
|
381
|
+
raise RuntimeError(f"Application {slug} not found in workspace {ws}")
|
|
382
|
+
self._client._config.config["default_application"] = slug
|
|
383
|
+
if not self._client._config.config.get("default_workspace"):
|
|
384
|
+
self._client._config.config["default_workspace"] = ws
|
|
385
|
+
elif self._client._config.config.get("default_workspace") != ws:
|
|
386
|
+
self._client._config.config["default_application_workspace"] = ws
|
|
387
|
+
|
|
388
|
+
self._client._save_config()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _ScopedApplications:
|
|
392
|
+
def __init__(self, client: SecureDeploy, workspace_slug: str) -> None:
|
|
393
|
+
self._client = client
|
|
394
|
+
self._workspace = workspace_slug
|
|
395
|
+
|
|
396
|
+
def list(self) -> list[dict[str, Any]]:
|
|
397
|
+
return self._client.applications.list(workspace=self._workspace)
|
|
398
|
+
|
|
399
|
+
def get(self, slug: Optional[str] = None) -> Application:
|
|
400
|
+
return self._client.applications.get(slug, workspace=self._workspace)
|
|
401
|
+
|
|
402
|
+
def create(
|
|
403
|
+
self,
|
|
404
|
+
*,
|
|
405
|
+
name: Optional[str] = None,
|
|
406
|
+
slug: Optional[str] = None,
|
|
407
|
+
) -> dict[str, Any]:
|
|
408
|
+
return self._client.applications.create(
|
|
409
|
+
name=name, slug=slug, workspace=self._workspace
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def set_default(self, slug: str) -> None:
|
|
413
|
+
self._client.applications.set_default(slug, workspace=self._workspace)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class _Upload:
|
|
417
|
+
def __init__(self, client: SecureDeploy) -> None:
|
|
418
|
+
self._client = client
|
|
419
|
+
|
|
420
|
+
def file(
|
|
421
|
+
self,
|
|
422
|
+
path: Union[str, Path],
|
|
423
|
+
*,
|
|
424
|
+
application: Optional[str] = None,
|
|
425
|
+
workspace: Optional[str] = None,
|
|
426
|
+
url_path: Optional[str] = None,
|
|
427
|
+
) -> Any:
|
|
428
|
+
p = Path(path)
|
|
429
|
+
if not p.is_file():
|
|
430
|
+
raise FileNotFoundError(p)
|
|
431
|
+
try:
|
|
432
|
+
file_size = p.stat().st_size
|
|
433
|
+
except OSError as e:
|
|
434
|
+
raise OSError(f"cannot stat {p}") from e
|
|
435
|
+
if file_size > _MAX_UPLOAD_FILE_BYTES:
|
|
436
|
+
raise ValueError(f"{p}: file size {file_size} bytes exceeds 10MiB limit")
|
|
437
|
+
ws = workspace or self._client._default_workspace_slug()
|
|
438
|
+
app = application or self._client._default_application_slug()
|
|
439
|
+
upload_path = f"/v1/workspaces/{ws}/applications/{app}/upload"
|
|
440
|
+
url = f"{self._client._config.api_base_url.rstrip('/')}{upload_path}"
|
|
441
|
+
|
|
442
|
+
def _post_upload(reauth: bool = True) -> Any:
|
|
443
|
+
headers = {
|
|
444
|
+
"Authorization": f"Bearer {self._client._config.get_access_token()}"
|
|
445
|
+
}
|
|
446
|
+
with _upstream_request_guard(), httpx.Client(timeout=120.0) as http:
|
|
447
|
+
with p.open("rb") as f:
|
|
448
|
+
# Ordered multipart parts: url_path field first, then file (httpx preserves
|
|
449
|
+
# sequence order; (None, value) is a non-file form field).
|
|
450
|
+
parts: list[tuple[str, Any]] = []
|
|
451
|
+
if url_path is not None:
|
|
452
|
+
parts.append(("url_path", (None, url_path)))
|
|
453
|
+
parts.append(("file", (p.name, f)))
|
|
454
|
+
r = http.post(url, headers=headers, files=parts)
|
|
455
|
+
if r.status_code >= 400:
|
|
456
|
+
return _handle_api_error_response(
|
|
457
|
+
r,
|
|
458
|
+
"POST",
|
|
459
|
+
upload_path,
|
|
460
|
+
self._client.auth,
|
|
461
|
+
_post_upload if reauth else None,
|
|
462
|
+
)
|
|
463
|
+
if not r.content:
|
|
464
|
+
return None
|
|
465
|
+
return r.json()
|
|
466
|
+
|
|
467
|
+
return _post_upload()
|
|
468
|
+
|
|
469
|
+
def path(
|
|
470
|
+
self,
|
|
471
|
+
dir_path: Union[str, Path],
|
|
472
|
+
*,
|
|
473
|
+
application: Optional[str] = None,
|
|
474
|
+
workspace: Optional[str] = None,
|
|
475
|
+
url_path: Optional[str] = None,
|
|
476
|
+
) -> list[Any]:
|
|
477
|
+
root = Path(dir_path)
|
|
478
|
+
if not root.is_dir():
|
|
479
|
+
raise NotADirectoryError(root)
|
|
480
|
+
prefix = (url_path or "").strip().replace("\\", "/").strip("/")
|
|
481
|
+
results: list[Any] = []
|
|
482
|
+
for file_path in root.rglob("*"):
|
|
483
|
+
if not file_path.is_file():
|
|
484
|
+
continue
|
|
485
|
+
skip_reason = _upload_ignore_reason(file_path)
|
|
486
|
+
if skip_reason is not None:
|
|
487
|
+
logger.warning(f"Skipped {file_path}: {skip_reason}")
|
|
488
|
+
continue
|
|
489
|
+
try:
|
|
490
|
+
size = file_path.stat().st_size
|
|
491
|
+
except OSError as e:
|
|
492
|
+
logger.warning(f"Skipped {file_path}: cannot read file metadata ({e})")
|
|
493
|
+
continue
|
|
494
|
+
if size > _MAX_UPLOAD_FILE_BYTES:
|
|
495
|
+
logger.warning(
|
|
496
|
+
f"Skipped {file_path}: exceeds 10MiB limit "
|
|
497
|
+
f"({size} bytes > {_MAX_UPLOAD_FILE_BYTES} bytes)"
|
|
498
|
+
)
|
|
499
|
+
continue
|
|
500
|
+
rel = file_path.relative_to(root).as_posix()
|
|
501
|
+
results.append(
|
|
502
|
+
self.file(
|
|
503
|
+
file_path,
|
|
504
|
+
application=application,
|
|
505
|
+
workspace=workspace,
|
|
506
|
+
url_path=(f"{prefix}/{rel}" if prefix else None),
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
return results
|
securedeploy/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import tomli_w
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else:
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Config:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
api_base_url: Optional[str] = None,
|
|
23
|
+
auth_base_url: Optional[str] = None,
|
|
24
|
+
):
|
|
25
|
+
self.api_base_url = api_base_url or "https://api.securedeploy.org"
|
|
26
|
+
self.auth_base_url = auth_base_url or "https://auth.securedeploy.org"
|
|
27
|
+
self.oauth_client_id = "27r2dr2ei9vagomf4096gcsdf5"
|
|
28
|
+
self.config_dir = Path(click.get_app_dir("securedeploy"))
|
|
29
|
+
self.config_file = self.config_dir / "config.toml"
|
|
30
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self.config_file.touch(exist_ok=True)
|
|
32
|
+
self.reload_config()
|
|
33
|
+
|
|
34
|
+
def get_access_token(self):
|
|
35
|
+
|
|
36
|
+
access_token = self.config.get("access_token")
|
|
37
|
+
if not access_token:
|
|
38
|
+
raise ConfigError("No access token found")
|
|
39
|
+
return access_token
|
|
40
|
+
|
|
41
|
+
def get_refresh_token(self):
|
|
42
|
+
refresh_token = self.config.get("refresh_token")
|
|
43
|
+
if not refresh_token:
|
|
44
|
+
raise ConfigError("No refresh token found")
|
|
45
|
+
return refresh_token
|
|
46
|
+
|
|
47
|
+
def set_tokens(self, access_token, refresh_token):
|
|
48
|
+
self.config["access_token"] = access_token
|
|
49
|
+
self.config["refresh_token"] = refresh_token
|
|
50
|
+
self.config_file.write_text(tomli_w.dumps(self.config), encoding="utf-8")
|
|
51
|
+
self.reload_config()
|
|
52
|
+
|
|
53
|
+
def reload_config(self):
|
|
54
|
+
content = self.config_file.read_text(encoding="utf-8").strip()
|
|
55
|
+
self.config = tomllib.loads(content) if content else {}
|
securedeploy/oauth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from securedeploy.config import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OAuthFailure(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OAuthTokenExpired(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OAuth:
|
|
17
|
+
def __init__(self, cfg: Config):
|
|
18
|
+
self._cfg = cfg
|
|
19
|
+
self.client_id = self._cfg.oauth_client_id
|
|
20
|
+
base = self._cfg.auth_base_url.rstrip("/")
|
|
21
|
+
self.device_code_endpoint = f"{base}/device/code"
|
|
22
|
+
self.token_endpoint = f"{base}/token"
|
|
23
|
+
self.interval = 5
|
|
24
|
+
|
|
25
|
+
def create_device_code(self):
|
|
26
|
+
response = httpx.post(
|
|
27
|
+
self.device_code_endpoint,
|
|
28
|
+
data={"client_id": self.client_id, "scope": "openid profile email"},
|
|
29
|
+
headers={
|
|
30
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
if response.status_code != 200:
|
|
34
|
+
raise OAuthFailure(f"Failed to create device code: {response.text}")
|
|
35
|
+
response = response.json()
|
|
36
|
+
self.interval = response.get("interval", 5)
|
|
37
|
+
self.device_code = response.get("device_code")
|
|
38
|
+
self.user_code = response.get("user_code")
|
|
39
|
+
self.expires_in = response.get("expires_in")
|
|
40
|
+
self.verification_uri = response.get("verification_uri")
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
def poll_for_token(self):
|
|
44
|
+
while True:
|
|
45
|
+
response = httpx.post(
|
|
46
|
+
self.token_endpoint,
|
|
47
|
+
data={
|
|
48
|
+
"client_id": self.client_id,
|
|
49
|
+
"device_code": self.device_code,
|
|
50
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
51
|
+
},
|
|
52
|
+
headers={
|
|
53
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
response_json = response.json()
|
|
57
|
+
if response.status_code == 200:
|
|
58
|
+
return response_json
|
|
59
|
+
elif response.status_code == 400:
|
|
60
|
+
if response_json.get("error") == "authorization_pending":
|
|
61
|
+
time.sleep(self.interval)
|
|
62
|
+
elif response_json.get("error") == "slow_down":
|
|
63
|
+
self.interval *= 2
|
|
64
|
+
time.sleep(self.interval)
|
|
65
|
+
elif response_json.get("error") == "expired_token":
|
|
66
|
+
raise OAuthTokenExpired("Token expired: Please login again.")
|
|
67
|
+
else:
|
|
68
|
+
raise OAuthFailure(f"Failed to poll for token: {response.text}")
|
|
69
|
+
else:
|
|
70
|
+
raise OAuthFailure(f"Failed to poll for token: {response.text}")
|
|
71
|
+
|
|
72
|
+
def refresh_access_token(self):
|
|
73
|
+
response = httpx.post(
|
|
74
|
+
self.token_endpoint,
|
|
75
|
+
data={
|
|
76
|
+
"client_id": self.client_id,
|
|
77
|
+
"grant_type": "refresh_token",
|
|
78
|
+
"refresh_token": self._cfg.get_refresh_token(),
|
|
79
|
+
},
|
|
80
|
+
headers={
|
|
81
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
if response.status_code != 200:
|
|
85
|
+
raise OAuthFailure(f"Failed to refresh access token: {response.text}")
|
|
86
|
+
return response.json()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: securedeploy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Project-URL: Homepage, https://securedeploy.org
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click>=8.1.0
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
11
|
+
Requires-Dist: tomli>=1.2.0; python_version < "3.11"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
securedeploy/__init__.py,sha256=xVJAWTxppE4us2IuwIcYt2014Ho70cPMX5JXqCekGhA,73
|
|
2
|
+
securedeploy/cli.py,sha256=FrSOBEB1sZAaYSv-bRhyDop6pBoViouqKYeicA-OYUw,8013
|
|
3
|
+
securedeploy/client.py,sha256=f-qedSySZnFlBNjeiRIb4vS_JVqhWne2dpSK4tZScZ0,17395
|
|
4
|
+
securedeploy/config.py,sha256=syVYjQ_PJ96hBMGf9nuLTL90sP4NSdKopF-mgP8nnb4,1690
|
|
5
|
+
securedeploy/oauth.py,sha256=bG3RQwss-xARptAmg5sFrEGei1VVJXD0Jbnv0jt9LpM,3101
|
|
6
|
+
securedeploy-0.1.0.dist-info/METADATA,sha256=VQk0E4vMfGkZNgBNRz5yFfJMJwJiak4Kj4nfrGJfnRs,342
|
|
7
|
+
securedeploy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
securedeploy-0.1.0.dist-info/entry_points.txt,sha256=xT-GOedjtxAIGdksaMHenRWzASPVBXTz1pfNW7tM7nE,54
|
|
9
|
+
securedeploy-0.1.0.dist-info/top_level.txt,sha256=0_6AVwCEoBPfaw-I9UZ44TxN88W8PMjLD38th1LMCfc,13
|
|
10
|
+
securedeploy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
securedeploy
|