asec 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.
- asec/__init__.py +1 -0
- asec/api_client.py +186 -0
- asec/cli.py +38 -0
- asec/cli_api_key.py +64 -0
- asec/cli_auth.py +220 -0
- asec/cli_devsecops/__init__.py +28 -0
- asec/cli_devsecops/asset.py +103 -0
- asec/cli_devsecops/issue.py +59 -0
- asec/cli_devsecops/product.py +89 -0
- asec/cli_devsecops/revision.py +113 -0
- asec/cli_devsecops/upload.py +73 -0
- asec/cli_pentest/__init__.py +32 -0
- asec/cli_pentest/run.py +189 -0
- asec/cli_pentest/run_profile.py +284 -0
- asec/cli_pentest/run_record.py +49 -0
- asec/cli_pentest/run_record_decisions.py +40 -0
- asec/cli_pentest/run_record_findings.py +83 -0
- asec/cli_pentest/run_record_target_analysis.py +58 -0
- asec/cli_pentest/run_record_task_board.py +50 -0
- asec/cli_pentest/run_report.py +176 -0
- asec/cli_pentest/supervisor.py +29 -0
- asec/cli_pentest/target.py +103 -0
- asec/cli_pentest/vpn.py +56 -0
- asec/cli_pentest/web_login.py +111 -0
- asec/cli_profile.py +45 -0
- asec/cli_scanners.py +27 -0
- asec/config.py +175 -0
- asec/oidc_auth.py +220 -0
- asec/output.py +10 -0
- asec/templates/__init__.py +0 -0
- asec/templates/callback_error.html +105 -0
- asec/templates/callback_success.html +107 -0
- asec/templates/svg/icon-check.svg +3 -0
- asec/templates/svg/icon-cross.svg +4 -0
- asec/templates/svg/icon-info.svg +5 -0
- asec/templates/svg/icon-warning.svg +5 -0
- asec/templates/svg/logo.svg +20 -0
- asec-0.1.0.dist-info/METADATA +80 -0
- asec-0.1.0.dist-info/RECORD +42 -0
- asec-0.1.0.dist-info/WHEEL +4 -0
- asec-0.1.0.dist-info/entry_points.txt +2 -0
- asec-0.1.0.dist-info/licenses/LICENSE +31 -0
asec/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""asec - AgenticSec DevSecOps CLI."""
|
asec/api_client.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HTTP client for API communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from asec.config import Credentials, load_credentials
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiClientError(Exception):
|
|
14
|
+
"""API client error."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status_code: int, message: str):
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.message = message
|
|
19
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LicenseError(ApiClientError):
|
|
23
|
+
"""Raised when the organization lacks a required license (AUTH_LICENSE_REQUIRED)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, detail: str):
|
|
26
|
+
self.detail = detail
|
|
27
|
+
message = f"{detail}\nPlease contact your organization administrator."
|
|
28
|
+
super().__init__(400, message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_credentials(
|
|
32
|
+
credentials: Credentials | None = None,
|
|
33
|
+
profile: str | None = None,
|
|
34
|
+
) -> Credentials:
|
|
35
|
+
"""Resolve credentials from profile, then apply environment variable overrides.
|
|
36
|
+
|
|
37
|
+
Resolution order:
|
|
38
|
+
1. Explicit credentials argument (if provided)
|
|
39
|
+
2. Load from profile (saved credentials file)
|
|
40
|
+
3. ASEC_API_KEY / ASEC_ORG_ID / ASEC_API_URL env vars override profile values
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
credentials: Explicit credentials (takes priority over profile)
|
|
44
|
+
profile: Profile name to load from
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Resolved Credentials
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ApiClientError: If resolved credentials are not valid
|
|
51
|
+
"""
|
|
52
|
+
creds = credentials or load_credentials(profile)
|
|
53
|
+
|
|
54
|
+
# Environment variables override profile values. ASEC_API_KEY additionally switches to
|
|
55
|
+
# api-key auth, but ASEC_ORG_ID / ASEC_API_URL apply in ANY auth mode (incl. OIDC) so
|
|
56
|
+
# callers can pin the target Hub/org without mutating the shared credentials.toml
|
|
57
|
+
# profile (e.g. multi-worktree dev / E2E, where the global `dev` profile is racy).
|
|
58
|
+
env_api_key = os.environ.get("ASEC_API_KEY", "")
|
|
59
|
+
if env_api_key:
|
|
60
|
+
creds.api_key = env_api_key
|
|
61
|
+
env_org = os.environ.get("ASEC_ORG_ID", "")
|
|
62
|
+
if env_org:
|
|
63
|
+
creds.org_id = env_org
|
|
64
|
+
env_url = os.environ.get("ASEC_API_URL", "")
|
|
65
|
+
if env_url:
|
|
66
|
+
creds.api_url = env_url
|
|
67
|
+
|
|
68
|
+
if not creds.is_valid():
|
|
69
|
+
raise ApiClientError(0, "Not authenticated. Run 'asec auth login' or set ASEC_API_KEY.")
|
|
70
|
+
|
|
71
|
+
return creds
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ApiClient:
|
|
75
|
+
"""HTTP client for API."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, credentials: Credentials | None = None, profile: str | None = None):
|
|
78
|
+
self._creds = resolve_credentials(credentials, profile)
|
|
79
|
+
self.base_url = self._creds.api_url.rstrip("/")
|
|
80
|
+
|
|
81
|
+
def _headers(self) -> dict[str, str]:
|
|
82
|
+
if self._creds.api_key:
|
|
83
|
+
headers: dict[str, str] = {"X-API-Key": self._creds.api_key}
|
|
84
|
+
if self._creds.org_id:
|
|
85
|
+
headers["X-Org-Id"] = self._creds.org_id
|
|
86
|
+
return headers
|
|
87
|
+
headers = {"Authorization": f"Bearer {self._creds.id_token}"}
|
|
88
|
+
if self._creds.org_id:
|
|
89
|
+
headers["X-Org-Id"] = self._creds.org_id
|
|
90
|
+
if self._creds.dev_oidc_sub:
|
|
91
|
+
headers["X-Oidc-Sub"] = self._creds.dev_oidc_sub
|
|
92
|
+
return headers
|
|
93
|
+
|
|
94
|
+
def _api_url(self, path: str) -> str:
|
|
95
|
+
if not path.startswith("/"):
|
|
96
|
+
path = "/" + path
|
|
97
|
+
prefix = "/api/cli-apikey/v1" if self._creds.api_key else "/api/cli-oidc/v1"
|
|
98
|
+
return f"{self.base_url}{prefix}{path}"
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _parse_json(response: httpx.Response) -> dict:
|
|
102
|
+
"""Return parsed JSON body, or {} for empty bodies (e.g. 204 No Content)."""
|
|
103
|
+
if response.status_code == 204 or not response.content:
|
|
104
|
+
return {}
|
|
105
|
+
return response.json()
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
109
|
+
"""Raise an appropriate error for non-2xx responses."""
|
|
110
|
+
if response.status_code < 400:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
detail = response.text
|
|
114
|
+
error_code = ""
|
|
115
|
+
try:
|
|
116
|
+
body = response.json()
|
|
117
|
+
if isinstance(body, dict):
|
|
118
|
+
if "error_code" in body:
|
|
119
|
+
error_code = body["error_code"]
|
|
120
|
+
detail = body.get("message", detail)
|
|
121
|
+
elif "detail" in body:
|
|
122
|
+
detail = body["detail"]
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
if error_code == "AUTH_LICENSE_REQUIRED":
|
|
127
|
+
raise LicenseError(detail)
|
|
128
|
+
|
|
129
|
+
raise ApiClientError(response.status_code, detail)
|
|
130
|
+
|
|
131
|
+
def get(self, path: str, params: dict | None = None) -> dict:
|
|
132
|
+
with httpx.Client(timeout=30) as client:
|
|
133
|
+
response = client.get(self._api_url(path), headers=self._headers(), params=params)
|
|
134
|
+
self._raise_for_status(response)
|
|
135
|
+
return self._parse_json(response)
|
|
136
|
+
|
|
137
|
+
def patch(self, path: str, json_data: dict | None = None) -> dict:
|
|
138
|
+
with httpx.Client(timeout=30) as client:
|
|
139
|
+
response = client.patch(self._api_url(path), headers=self._headers(), json=json_data)
|
|
140
|
+
self._raise_for_status(response)
|
|
141
|
+
return self._parse_json(response)
|
|
142
|
+
|
|
143
|
+
def put(self, path: str, json_data: dict | None = None) -> dict:
|
|
144
|
+
with httpx.Client(timeout=30) as client:
|
|
145
|
+
response = client.put(self._api_url(path), headers=self._headers(), json=json_data)
|
|
146
|
+
self._raise_for_status(response)
|
|
147
|
+
return self._parse_json(response)
|
|
148
|
+
|
|
149
|
+
def post(self, path: str, json_data: dict | None = None) -> dict:
|
|
150
|
+
with httpx.Client(timeout=30) as client:
|
|
151
|
+
response = client.post(self._api_url(path), headers=self._headers(), json=json_data)
|
|
152
|
+
self._raise_for_status(response)
|
|
153
|
+
return self._parse_json(response)
|
|
154
|
+
|
|
155
|
+
def upload_file(self, path: str, file_path: Path, form_data: dict) -> dict:
|
|
156
|
+
with httpx.Client(timeout=120) as client:
|
|
157
|
+
with open(file_path, "rb") as f:
|
|
158
|
+
files = {"file": (file_path.name, f, "application/json")}
|
|
159
|
+
response = client.post(self._api_url(path), headers=self._headers(), data=form_data, files=files)
|
|
160
|
+
self._raise_for_status(response)
|
|
161
|
+
return self._parse_json(response)
|
|
162
|
+
|
|
163
|
+
def download_from_url(self, url: str, output_path: Path) -> None:
|
|
164
|
+
"""Stream a binary response from an arbitrary URL to a local file.
|
|
165
|
+
|
|
166
|
+
Used for two-step download flows (e.g. Run Report PDF): the API
|
|
167
|
+
first returns a short-lived signed URL via ``GET /reports/.../download``,
|
|
168
|
+
and the caller follows up with this method to fetch the bytes
|
|
169
|
+
directly from object storage.
|
|
170
|
+
|
|
171
|
+
No auth headers are sent — the URL itself is the authorization
|
|
172
|
+
token, which keeps API credentials out of the cross-origin hop.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ApiClientError: For non-2xx responses from the storage backend.
|
|
176
|
+
"""
|
|
177
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
with httpx.Client(timeout=300) as client:
|
|
179
|
+
with client.stream("GET", url) as response:
|
|
180
|
+
if response.status_code >= 400:
|
|
181
|
+
# ``stream`` mode requires explicit read before .text/.json
|
|
182
|
+
response.read()
|
|
183
|
+
raise ApiClientError(response.status_code, response.text)
|
|
184
|
+
with output_path.open("wb") as f:
|
|
185
|
+
for chunk in response.iter_bytes():
|
|
186
|
+
f.write(chunk)
|
asec/cli.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""asec CLI - AgenticSec command line interface."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from asec.cli_auth import auth
|
|
6
|
+
from asec.cli_devsecops import devsecops
|
|
7
|
+
from asec.cli_pentest import pentest
|
|
8
|
+
from asec.cli_profile import profile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
@click.version_option(package_name="asec", prog_name="asec")
|
|
13
|
+
@click.option(
|
|
14
|
+
"--profile",
|
|
15
|
+
envvar="ASEC_PROFILE",
|
|
16
|
+
default=None,
|
|
17
|
+
help="Profile name (default: active profile)",
|
|
18
|
+
)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def cli(ctx, profile):
|
|
21
|
+
"""asec - AgenticSec CLI."""
|
|
22
|
+
ctx.ensure_object(dict)
|
|
23
|
+
ctx.obj["profile"] = profile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
cli.add_command(auth)
|
|
27
|
+
cli.add_command(profile)
|
|
28
|
+
cli.add_command(devsecops)
|
|
29
|
+
cli.add_command(pentest)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
"""Main entry point."""
|
|
34
|
+
cli()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
asec/cli_api_key.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""API Key configuration CLI commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from asec.config import (
|
|
6
|
+
Credentials,
|
|
7
|
+
save_credentials,
|
|
8
|
+
set_active_profile,
|
|
9
|
+
)
|
|
10
|
+
from asec.output import output_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group("api-key")
|
|
14
|
+
def api_key():
|
|
15
|
+
"""Configure API key for non-interactive authentication."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@api_key.command("configure")
|
|
20
|
+
@click.argument("api_url")
|
|
21
|
+
@click.argument("key_value", required=False)
|
|
22
|
+
@click.option("--org", required=True, help="Organization ID")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def configure(ctx, api_url: str, key_value: str | None, org: str):
|
|
25
|
+
"""Configure API key for non-interactive authentication.
|
|
26
|
+
|
|
27
|
+
API_URL: API base URL (e.g., https://api.agenticsec.tech)
|
|
28
|
+
KEY_VALUE: API Key value (obtain from organization admin).
|
|
29
|
+
If omitted, you will be prompted securely.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
|
|
33
|
+
# Interactive (key is hidden):
|
|
34
|
+
asec auth api-key configure https://api.agenticsec.tech --org org-acme
|
|
35
|
+
|
|
36
|
+
# Non-interactive (argument):
|
|
37
|
+
asec auth api-key configure https://api.agenticsec.tech KEY --org org-acme
|
|
38
|
+
|
|
39
|
+
# Non-interactive (pipe):
|
|
40
|
+
echo $ASEC_API_KEY | asec auth api-key configure https://api.agenticsec.tech --org org-acme
|
|
41
|
+
"""
|
|
42
|
+
if not key_value:
|
|
43
|
+
# Prompt goes to stderr so stdout stays pure JSON.
|
|
44
|
+
key_value = click.prompt("API Key", hide_input=True, err=True)
|
|
45
|
+
|
|
46
|
+
api_url = api_url.rstrip("/")
|
|
47
|
+
profile_name = ctx.obj.get("profile") if ctx.obj else None
|
|
48
|
+
if not profile_name:
|
|
49
|
+
profile_name = "ci"
|
|
50
|
+
|
|
51
|
+
creds = Credentials(
|
|
52
|
+
api_url=api_url,
|
|
53
|
+
api_key=key_value,
|
|
54
|
+
org_id=org,
|
|
55
|
+
)
|
|
56
|
+
save_credentials(creds, profile_name)
|
|
57
|
+
set_active_profile(profile_name)
|
|
58
|
+
output_json(
|
|
59
|
+
{
|
|
60
|
+
"profile": profile_name,
|
|
61
|
+
"api_url": api_url,
|
|
62
|
+
"org_id": org,
|
|
63
|
+
}
|
|
64
|
+
)
|
asec/cli_auth.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Auth CLI commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from asec.cli_api_key import api_key
|
|
10
|
+
from asec.config import (
|
|
11
|
+
Credentials,
|
|
12
|
+
clear_credentials,
|
|
13
|
+
get_active_profile,
|
|
14
|
+
load_credentials,
|
|
15
|
+
resolve_profile_for_login,
|
|
16
|
+
save_credentials,
|
|
17
|
+
set_active_profile,
|
|
18
|
+
)
|
|
19
|
+
from asec.oidc_auth import AuthorizationError, perform_oidc_login
|
|
20
|
+
from asec.output import output_json
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fetch_orgs(api_url: str, id_token: str) -> list[dict]:
|
|
24
|
+
"""Fetch organizations the user belongs to from API.
|
|
25
|
+
|
|
26
|
+
Returns a list of org dicts with keys: org_id, org_name, role, status, licenses.
|
|
27
|
+
Returns empty list on error.
|
|
28
|
+
"""
|
|
29
|
+
url = f"{api_url}/api/cli-oidc/v1/user/organizations"
|
|
30
|
+
try:
|
|
31
|
+
with httpx.Client(timeout=10) as client:
|
|
32
|
+
res = client.get(url, headers={"Authorization": f"Bearer {id_token}"})
|
|
33
|
+
if res.status_code != 200:
|
|
34
|
+
click.echo(f"Warning: Could not fetch organizations (HTTP {res.status_code})", err=True)
|
|
35
|
+
return []
|
|
36
|
+
return res.json().get("organizations", [])
|
|
37
|
+
except httpx.RequestError as e:
|
|
38
|
+
click.echo(f"Warning: Could not fetch organizations: {e}", err=True)
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_org_id(orgs: list[dict], explicit_org: str | None) -> str | None:
|
|
43
|
+
"""Resolve org_id from explicit flag or org list.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
org_id string, or None if no org could be resolved.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
SystemExit: If multiple orgs exist and --org is not specified.
|
|
50
|
+
"""
|
|
51
|
+
if explicit_org:
|
|
52
|
+
return explicit_org
|
|
53
|
+
|
|
54
|
+
if not orgs:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Filter to active orgs
|
|
58
|
+
active_orgs = [o for o in orgs if o.get("status") == "active"]
|
|
59
|
+
if not active_orgs:
|
|
60
|
+
active_orgs = orgs
|
|
61
|
+
|
|
62
|
+
if len(active_orgs) == 1:
|
|
63
|
+
org = active_orgs[0]
|
|
64
|
+
click.echo(f"Organization: {org['org_name']} ({org['org_id']})", err=True)
|
|
65
|
+
return org["org_id"]
|
|
66
|
+
|
|
67
|
+
# Multiple orgs: show list and exit
|
|
68
|
+
click.echo("Multiple organizations found. Specify one with --org:", err=True)
|
|
69
|
+
for org in active_orgs:
|
|
70
|
+
licenses = org.get("licenses", {})
|
|
71
|
+
flags = []
|
|
72
|
+
if licenses.get("pentest"):
|
|
73
|
+
flags.append("pentest")
|
|
74
|
+
if licenses.get("devsecops"):
|
|
75
|
+
flags.append("devsecops")
|
|
76
|
+
license_info = f" [{', '.join(flags)}]" if flags else ""
|
|
77
|
+
click.echo(f" {org['org_id']} {org['org_name']}{license_info}", err=True)
|
|
78
|
+
raise SystemExit(1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@click.group()
|
|
82
|
+
def auth():
|
|
83
|
+
"""Manage authentication."""
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
auth.add_command(api_key)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@auth.command()
|
|
91
|
+
@click.argument("api_url", required=False)
|
|
92
|
+
@click.option("--org", "explicit_org", default=None, help="Organization ID to use for this profile")
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def login(ctx, api_url: str | None, explicit_org: str | None):
|
|
95
|
+
"""Log in to AgenticSec.
|
|
96
|
+
|
|
97
|
+
API_URL: API base URL (e.g., https://api-stg.agenticsec.tech).
|
|
98
|
+
If omitted, uses the saved URL from the current profile.
|
|
99
|
+
"""
|
|
100
|
+
explicit_profile = ctx.obj.get("profile") if ctx.obj else None
|
|
101
|
+
|
|
102
|
+
# Resolve api_url: argument > env > saved profile > default (prd)
|
|
103
|
+
if not api_url:
|
|
104
|
+
api_url = os.environ.get("ASEC_API_URL")
|
|
105
|
+
if not api_url:
|
|
106
|
+
existing_creds = load_credentials(explicit_profile)
|
|
107
|
+
if existing_creds.api_url:
|
|
108
|
+
api_url = existing_creds.api_url
|
|
109
|
+
if not api_url:
|
|
110
|
+
api_url = "https://api.agenticsec.tech"
|
|
111
|
+
|
|
112
|
+
api_url = api_url.rstrip("/")
|
|
113
|
+
|
|
114
|
+
# Resolve profile: explicit > existing match by URL > auto-derived from URL
|
|
115
|
+
profile_name = resolve_profile_for_login(explicit_profile, api_url)
|
|
116
|
+
|
|
117
|
+
# Dev environment shortcut
|
|
118
|
+
dev_oidc_sub = os.environ.get("ASEC_DEV_OIDC_SUB")
|
|
119
|
+
if dev_oidc_sub:
|
|
120
|
+
dev_org_id = os.environ.get("ASEC_DEV_ORG_ID", "")
|
|
121
|
+
creds = Credentials(
|
|
122
|
+
api_url=api_url,
|
|
123
|
+
id_token="dev-token",
|
|
124
|
+
refresh_token="",
|
|
125
|
+
expires_at=time.time() + 86400 * 365,
|
|
126
|
+
user_email="dev@agenticsec.local",
|
|
127
|
+
org_id=dev_org_id,
|
|
128
|
+
dev_oidc_sub=dev_oidc_sub,
|
|
129
|
+
)
|
|
130
|
+
save_credentials(creds, profile_name)
|
|
131
|
+
set_active_profile(profile_name)
|
|
132
|
+
output_json(
|
|
133
|
+
{
|
|
134
|
+
"profile": profile_name,
|
|
135
|
+
"api_url": api_url,
|
|
136
|
+
"user_email": creds.user_email,
|
|
137
|
+
"org_id": creds.org_id or None,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# Discover auth config from server
|
|
143
|
+
auth_config_url = f"{api_url}/api/cli-oidc/v1/auth-config"
|
|
144
|
+
click.echo(f"Fetching auth config from {auth_config_url}...", err=True)
|
|
145
|
+
try:
|
|
146
|
+
with httpx.Client(timeout=10) as client:
|
|
147
|
+
res = client.get(auth_config_url)
|
|
148
|
+
if res.status_code != 200:
|
|
149
|
+
click.echo(f"Error: Failed to fetch auth config (HTTP {res.status_code})", err=True)
|
|
150
|
+
click.echo("The server may not be configured for CLI authentication.", err=True)
|
|
151
|
+
raise SystemExit(1)
|
|
152
|
+
auth_config = res.json()
|
|
153
|
+
except httpx.RequestError as e:
|
|
154
|
+
click.echo(f"Error: Cannot connect to {auth_config_url}: {e}", err=True)
|
|
155
|
+
raise SystemExit(1) from None
|
|
156
|
+
|
|
157
|
+
cognito_domain = auth_config["cognito_domain"]
|
|
158
|
+
client_id = auth_config["client_id"]
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
tokens = perform_oidc_login(cognito_domain=cognito_domain, client_id=client_id)
|
|
162
|
+
creds = Credentials(
|
|
163
|
+
api_url=api_url,
|
|
164
|
+
id_token=tokens["id_token"],
|
|
165
|
+
refresh_token=tokens.get("refresh_token", ""),
|
|
166
|
+
expires_at=time.time() + tokens.get("expires_in", 3600),
|
|
167
|
+
cognito_domain=cognito_domain,
|
|
168
|
+
client_id=client_id,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Resolve organization
|
|
172
|
+
orgs = _fetch_orgs(api_url, creds.id_token)
|
|
173
|
+
org_id = _resolve_org_id(orgs, explicit_org)
|
|
174
|
+
if org_id:
|
|
175
|
+
creds.org_id = org_id
|
|
176
|
+
|
|
177
|
+
save_credentials(creds, profile_name)
|
|
178
|
+
set_active_profile(profile_name)
|
|
179
|
+
output_json(
|
|
180
|
+
{
|
|
181
|
+
"profile": profile_name,
|
|
182
|
+
"api_url": api_url,
|
|
183
|
+
"user_email": creds.user_email or None,
|
|
184
|
+
"org_id": creds.org_id or None,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
except AuthorizationError as e:
|
|
188
|
+
click.echo(f"Login failed: {e}", err=True)
|
|
189
|
+
raise SystemExit(1) from None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@auth.command()
|
|
193
|
+
@click.pass_context
|
|
194
|
+
def status(ctx):
|
|
195
|
+
"""Show current authentication status."""
|
|
196
|
+
profile_name = ctx.obj.get("profile") if ctx.obj else None
|
|
197
|
+
resolved = get_active_profile(profile_name)
|
|
198
|
+
creds = load_credentials(profile_name)
|
|
199
|
+
authenticated = creds.is_valid()
|
|
200
|
+
|
|
201
|
+
output_json(
|
|
202
|
+
{
|
|
203
|
+
"profile": resolved,
|
|
204
|
+
"authenticated": authenticated,
|
|
205
|
+
"api_url": creds.api_url or None,
|
|
206
|
+
"user_email": creds.user_email or None,
|
|
207
|
+
"org_id": creds.org_id or None,
|
|
208
|
+
"expires_at": creds.expires_at if creds.expires_at > 0 else None,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@auth.command()
|
|
214
|
+
@click.pass_context
|
|
215
|
+
def logout(ctx):
|
|
216
|
+
"""Clear stored credentials."""
|
|
217
|
+
profile_name = ctx.obj.get("profile") if ctx.obj else None
|
|
218
|
+
resolved = get_active_profile(profile_name)
|
|
219
|
+
clear_credentials(profile_name)
|
|
220
|
+
output_json({"profile": resolved})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""DevSecOps CLI command group."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from asec.cli_devsecops.asset import asset
|
|
6
|
+
from asec.cli_devsecops.issue import issue
|
|
7
|
+
from asec.cli_devsecops.product import product
|
|
8
|
+
from asec.cli_devsecops.revision import revision
|
|
9
|
+
from asec.cli_devsecops.upload import upload
|
|
10
|
+
from asec.cli_scanners import scanners
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def devsecops(ctx):
|
|
16
|
+
"""Manage DevSecOps products, assets, and security issues."""
|
|
17
|
+
ctx.ensure_object(dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
devsecops.add_command(product)
|
|
21
|
+
devsecops.add_command(asset)
|
|
22
|
+
devsecops.add_command(upload)
|
|
23
|
+
devsecops.add_command(issue)
|
|
24
|
+
devsecops.add_command(revision)
|
|
25
|
+
devsecops.add_command(scanners)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["devsecops"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Asset CLI commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from asec.api_client import ApiClient, ApiClientError
|
|
6
|
+
from asec.output import output_json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_profile(ctx: click.Context) -> str | None:
|
|
10
|
+
return ctx.obj.get("profile") if ctx.obj else None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def asset():
|
|
15
|
+
"""Manage assets."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@asset.command()
|
|
20
|
+
@click.option("--product", "product_id", required=True, help="Product ID")
|
|
21
|
+
@click.option("--type", "asset_type", required=True, help="Asset type (repository, container_image, cloud_account)")
|
|
22
|
+
@click.option("--name", required=True, help="Asset name")
|
|
23
|
+
@click.option("--description", default=None, help="Description (also used as context for security analysis)")
|
|
24
|
+
@click.option("--version", default=None, help="Asset version")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def create(ctx, product_id: str, asset_type: str, name: str, description: str | None, version: str | None):
|
|
27
|
+
"""Create a new asset."""
|
|
28
|
+
try:
|
|
29
|
+
client = ApiClient(profile=_get_profile(ctx))
|
|
30
|
+
result = client.post(
|
|
31
|
+
"/assets",
|
|
32
|
+
{
|
|
33
|
+
"product_id": product_id,
|
|
34
|
+
"asset_type": asset_type,
|
|
35
|
+
"name": name,
|
|
36
|
+
"description": description,
|
|
37
|
+
"version": version,
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
output_json(result)
|
|
41
|
+
except ApiClientError as e:
|
|
42
|
+
click.echo(f"Error: {e.message}", err=True)
|
|
43
|
+
raise SystemExit(1) from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@asset.command("list")
|
|
47
|
+
@click.option("--product", "product_id", required=True, help="Product ID")
|
|
48
|
+
@click.option("--include-inactive", is_flag=True, default=False, help="Include inactive assets")
|
|
49
|
+
@click.pass_context
|
|
50
|
+
def list_assets(ctx, product_id: str, include_inactive: bool):
|
|
51
|
+
"""List assets for a product."""
|
|
52
|
+
try:
|
|
53
|
+
client = ApiClient(profile=_get_profile(ctx))
|
|
54
|
+
params: dict = {"product_id": product_id}
|
|
55
|
+
if include_inactive:
|
|
56
|
+
params["include_inactive"] = "true"
|
|
57
|
+
result = client.get("/assets", params=params)
|
|
58
|
+
output_json(result.get("assets", []))
|
|
59
|
+
except ApiClientError as e:
|
|
60
|
+
click.echo(f"Error: {e.message}", err=True)
|
|
61
|
+
raise SystemExit(1) from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@asset.command()
|
|
65
|
+
@click.argument("asset_id")
|
|
66
|
+
@click.pass_context
|
|
67
|
+
def show(ctx, asset_id: str):
|
|
68
|
+
"""Show asset details."""
|
|
69
|
+
try:
|
|
70
|
+
client = ApiClient(profile=_get_profile(ctx))
|
|
71
|
+
result = client.get(f"/assets/{asset_id}")
|
|
72
|
+
output_json(result)
|
|
73
|
+
except ApiClientError as e:
|
|
74
|
+
click.echo(f"Error: {e.message}", err=True)
|
|
75
|
+
raise SystemExit(1) from None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@asset.command()
|
|
79
|
+
@click.argument("asset_id")
|
|
80
|
+
@click.pass_context
|
|
81
|
+
def deactivate(ctx, asset_id: str):
|
|
82
|
+
"""Deactivate an asset."""
|
|
83
|
+
try:
|
|
84
|
+
client = ApiClient(profile=_get_profile(ctx))
|
|
85
|
+
client.patch(f"/assets/{asset_id}/deactivate")
|
|
86
|
+
output_json({"asset_id": asset_id})
|
|
87
|
+
except ApiClientError as e:
|
|
88
|
+
click.echo(f"Error: {e.message}", err=True)
|
|
89
|
+
raise SystemExit(1) from None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@asset.command()
|
|
93
|
+
@click.argument("asset_id")
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def activate(ctx, asset_id: str):
|
|
96
|
+
"""Activate an asset."""
|
|
97
|
+
try:
|
|
98
|
+
client = ApiClient(profile=_get_profile(ctx))
|
|
99
|
+
client.patch(f"/assets/{asset_id}/activate")
|
|
100
|
+
output_json({"asset_id": asset_id})
|
|
101
|
+
except ApiClientError as e:
|
|
102
|
+
click.echo(f"Error: {e.message}", err=True)
|
|
103
|
+
raise SystemExit(1) from None
|