liveapisec 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.
- liveapisec/__init__.py +31 -0
- liveapisec/cli.py +295 -0
- liveapisec/client.py +192 -0
- liveapisec-0.1.0.dist-info/METADATA +196 -0
- liveapisec-0.1.0.dist-info/RECORD +8 -0
- liveapisec-0.1.0.dist-info/WHEEL +5 -0
- liveapisec-0.1.0.dist-info/entry_points.txt +2 -0
- liveapisec-0.1.0.dist-info/top_level.txt +1 -0
liveapisec/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""liveapisec — oficjalny klient LiveAPISec Developer API.
|
|
2
|
+
|
|
3
|
+
Instalacja::
|
|
4
|
+
|
|
5
|
+
pip install git+https://github.com/<owner>/<repo>.git#subdirectory=cli
|
|
6
|
+
|
|
7
|
+
Potem w dowolnym projekcie/CI::
|
|
8
|
+
|
|
9
|
+
export LIVEAPISEC_API_KEY=las_dev_...
|
|
10
|
+
liveapisec push --name my-api --base-url https://api.example.com --endpoint "GET /users"
|
|
11
|
+
liveapisec scan --site SITE_ID --wait --fail-on high
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .cli import main
|
|
15
|
+
from .client import (
|
|
16
|
+
DEFAULT_API_URL,
|
|
17
|
+
LiveAPISecError,
|
|
18
|
+
ScanStatus,
|
|
19
|
+
severity_rank,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_API_URL",
|
|
26
|
+
"LiveAPISecError",
|
|
27
|
+
"ScanStatus",
|
|
28
|
+
"__version__",
|
|
29
|
+
"main",
|
|
30
|
+
"severity_rank",
|
|
31
|
+
]
|
liveapisec/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""liveapisec — komendy CLI (TODO 2.25).
|
|
2
|
+
|
|
3
|
+
Komendy:
|
|
4
|
+
push — utwórz/aktualizuj site + endpointy + opcjonalny token (idempotentne)
|
|
5
|
+
scan — odpal skan; --wait czeka na wynik; --fail-on ustawia próg błędu CI
|
|
6
|
+
status — status site'a / ostatnich skanów
|
|
7
|
+
findings — listuj findings (--json)
|
|
8
|
+
sites — pokaż site (endpointy, last_scan)
|
|
9
|
+
|
|
10
|
+
Przykład w CI (gate)::
|
|
11
|
+
|
|
12
|
+
liveapisec push --name my-api --base-url https://api.example.com \\
|
|
13
|
+
--endpoint "GET /users" --endpoint "POST /payments"
|
|
14
|
+
liveapisec scan --site SITE_ID --branch main --commit "$SHA" --wait --fail-on high
|
|
15
|
+
|
|
16
|
+
Exit codes (dla CI):
|
|
17
|
+
0 — ok (brak findings >= progu) 1 — findings >= progu (gate failed)
|
|
18
|
+
2 — błąd użycia / API
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .client import DEFAULT_API_URL, LiveAPISec, LiveAPISecError
|
|
28
|
+
|
|
29
|
+
_SEV = ["critical", "high", "medium", "low", "info"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_endpoint(value: str) -> dict[str, str]:
|
|
33
|
+
"""'GET /users' → {"method":"GET","path":"/users"}."""
|
|
34
|
+
parts = value.split(None, 1)
|
|
35
|
+
if len(parts) != 2:
|
|
36
|
+
raise argparse.ArgumentTypeError(f"expected 'METHOD /path', got {value!r}")
|
|
37
|
+
method, path = parts
|
|
38
|
+
return {"method": method.upper(), "path": path}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _auth_args(parser: argparse.ArgumentParser) -> None:
|
|
42
|
+
parser.add_argument("--auth-type", choices=["none", "jwt", "bearer", "cookie", "api_key"], default="none")
|
|
43
|
+
parser.add_argument("--auth-token", help="token dla jwt/bearer/api_key")
|
|
44
|
+
parser.add_argument("--auth-cookie", help="pełny nagłówek Cookie dla type=cookie")
|
|
45
|
+
parser.add_argument("--auth-header", default="X-API-Key", help="nazwa nagłówka dla api_key")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_auth(args: argparse.Namespace) -> dict[str, Any] | None:
|
|
49
|
+
if args.auth_type == "none":
|
|
50
|
+
return None
|
|
51
|
+
auth: dict[str, Any] = {"type": args.auth_type}
|
|
52
|
+
if args.auth_type in ("jwt", "bearer", "api_key"):
|
|
53
|
+
auth["token"] = args.auth_token
|
|
54
|
+
if args.auth_type == "cookie":
|
|
55
|
+
auth["cookie"] = args.auth_cookie
|
|
56
|
+
if args.auth_type == "api_key":
|
|
57
|
+
auth["header"] = args.auth_header
|
|
58
|
+
return auth
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fmt_scan(scan: dict[str, Any]) -> str:
|
|
62
|
+
status = scan.get("status", "?")
|
|
63
|
+
summary = scan.get("summary") or {}
|
|
64
|
+
by_sev = summary.get("by_severity") or {}
|
|
65
|
+
parts = [
|
|
66
|
+
f"scan {scan.get('scan_id')}",
|
|
67
|
+
f"status={status}",
|
|
68
|
+
]
|
|
69
|
+
if scan.get("branch"):
|
|
70
|
+
parts.append(f"branch={scan['branch']}")
|
|
71
|
+
if scan.get("commit"):
|
|
72
|
+
parts.append(f"commit={scan['commit']}")
|
|
73
|
+
if status == "completed":
|
|
74
|
+
sev = " ".join(f"{k}={v}" for k, v in sorted(by_sev.items(), key=lambda kv: _SEV.index(kv[0]) if kv[0] in _SEV else 9))
|
|
75
|
+
parts.append(f"tests={summary.get('tests_run', '?')}")
|
|
76
|
+
parts.append(f"findings={summary.get('findings', 0)}" + (f" ({sev})" if sev else ""))
|
|
77
|
+
return " ".join(parts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fmt_finding(f: dict[str, Any]) -> str:
|
|
81
|
+
sev = f.get("severity", "?")
|
|
82
|
+
title = f.get("title") or f.get("category") or "?"
|
|
83
|
+
target = f.get("target") or ""
|
|
84
|
+
line = f"[{sev}] {title}"
|
|
85
|
+
if target:
|
|
86
|
+
line += f" ({target})"
|
|
87
|
+
return line
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cmd_push(client: LiveAPISec, args: argparse.Namespace) -> int:
|
|
91
|
+
if not args.name:
|
|
92
|
+
print("error: --name is required", file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
if not args.base_url and not args.site:
|
|
95
|
+
print("error: --base-url is required", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
if not args.endpoint and not args.openapi_url:
|
|
98
|
+
print("error: provide at least one --endpoint or --openapi-url", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
auth = _build_auth(args)
|
|
101
|
+
if auth and args.auth_type in ("jwt", "bearer", "api_key") and not args.auth_token:
|
|
102
|
+
print(f"error: --auth-token required for auth-type={args.auth_type}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
if auth and args.auth_type == "cookie" and not args.auth_cookie:
|
|
105
|
+
print("error: --auth-cookie required for auth-type=cookie", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
|
|
108
|
+
site = client.create_site(
|
|
109
|
+
name=args.name,
|
|
110
|
+
base_url=args.base_url,
|
|
111
|
+
endpoints=args.endpoint,
|
|
112
|
+
openapi_url=args.openapi_url,
|
|
113
|
+
project=args.project,
|
|
114
|
+
auth=auth,
|
|
115
|
+
site_id=args.site,
|
|
116
|
+
)
|
|
117
|
+
if args.json:
|
|
118
|
+
print(LiveAPISec.dump(site))
|
|
119
|
+
else:
|
|
120
|
+
updated = " (updated)" if site.get("updated") else ""
|
|
121
|
+
print(f"site {site['site_id']}{updated}: {site['name']} — {site['endpoints_count']} endpoints, auth={site['auth']}")
|
|
122
|
+
print(f"export SITE_ID={site['site_id']}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_scan(client: LiveAPISec, args: argparse.Namespace) -> int:
|
|
127
|
+
if not args.site:
|
|
128
|
+
print("error: --site (site_id) is required", file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
scan = client.trigger_scan(args.site, branch=args.branch, commit=args.commit)
|
|
131
|
+
scan_id = scan["scan_id"]
|
|
132
|
+
if args.json:
|
|
133
|
+
print(LiveAPISec.dump(scan))
|
|
134
|
+
else:
|
|
135
|
+
print(f"scan queued: {scan_id}")
|
|
136
|
+
if not args.wait:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
if not args.json:
|
|
140
|
+
print("waiting for scan to finish…", file=sys.stderr)
|
|
141
|
+
done = client.wait_for_scan(args.site, scan_id)
|
|
142
|
+
findings = done.get("findings") or []
|
|
143
|
+
if args.json:
|
|
144
|
+
print(LiveAPISec.dump(done))
|
|
145
|
+
else:
|
|
146
|
+
print(_fmt_scan(done))
|
|
147
|
+
|
|
148
|
+
if done.get("status") != "completed":
|
|
149
|
+
return 2 if args.fail_on else 0
|
|
150
|
+
|
|
151
|
+
gate_sev = args.fail_on # "high" | "critical" | ...
|
|
152
|
+
if gate_sev:
|
|
153
|
+
blocked = LiveAPISec.findings_above(findings, gate_sev)
|
|
154
|
+
if blocked:
|
|
155
|
+
if not args.json:
|
|
156
|
+
print(f"\n❌ {len(blocked)} finding(s) at or above {gate_sev} — gate failed:", file=sys.stderr)
|
|
157
|
+
for f in blocked:
|
|
158
|
+
print(" " + _fmt_finding(f), file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
if not args.json:
|
|
161
|
+
print(f"✅ no findings at or above {gate_sev}")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cmd_status(client: LiveAPISec, args: argparse.Namespace) -> int:
|
|
166
|
+
if not args.site:
|
|
167
|
+
print("error: --site (site_id) is required", file=sys.stderr)
|
|
168
|
+
return 2
|
|
169
|
+
site = client.get_site(args.site)
|
|
170
|
+
scans = client.list_scans(args.site)
|
|
171
|
+
if args.json:
|
|
172
|
+
print(LiveAPISec.dump({"site": site, "scans": scans[:10]}))
|
|
173
|
+
return 0
|
|
174
|
+
print(f"site {site['site_id']}: {site.get('name')} — {site.get('endpoints_count')} endpoints")
|
|
175
|
+
if site.get("base_url"):
|
|
176
|
+
print(f" base_url: {site['base_url']}")
|
|
177
|
+
if site.get("project"):
|
|
178
|
+
print(f" project: {site['project']}")
|
|
179
|
+
if site.get("last_scan_at"):
|
|
180
|
+
print(f" last_scan_at: {site['last_scan_at']}")
|
|
181
|
+
if not scans:
|
|
182
|
+
print(" (no scans yet)")
|
|
183
|
+
return 0
|
|
184
|
+
print(" recent scans:")
|
|
185
|
+
for s in scans[:5]:
|
|
186
|
+
print(" " + _fmt_scan(s))
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cmd_findings(client: LiveAPISec, args: argparse.Namespace) -> int:
|
|
191
|
+
if not args.site or not args.scan:
|
|
192
|
+
print("error: --site and --scan are required", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
findings = client.get_findings(args.site, args.scan)
|
|
195
|
+
if args.json:
|
|
196
|
+
print(LiveAPISec.dump(findings))
|
|
197
|
+
return 0
|
|
198
|
+
if not findings:
|
|
199
|
+
print("no findings")
|
|
200
|
+
return 0
|
|
201
|
+
for f in findings:
|
|
202
|
+
print(_fmt_finding(f))
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _cmd_sites(client: LiveAPISec, args: argparse.Namespace) -> int:
|
|
207
|
+
if not args.site:
|
|
208
|
+
print("error: --site (site_id) is required", file=sys.stderr)
|
|
209
|
+
return 2
|
|
210
|
+
site = client.get_site(args.site)
|
|
211
|
+
if args.json:
|
|
212
|
+
print(LiveAPISec.dump(site))
|
|
213
|
+
return 0
|
|
214
|
+
print(f"site {site['site_id']}: {site.get('name')} — {site.get('endpoints_count')} endpoints")
|
|
215
|
+
if site.get("base_url"):
|
|
216
|
+
print(f" base_url: {site['base_url']}")
|
|
217
|
+
if site.get("project"):
|
|
218
|
+
print(f" project: {site['project']}")
|
|
219
|
+
print(f" source: {site.get('source')} last_scan_at: {site.get('last_scan_at')}")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
224
|
+
parser = argparse.ArgumentParser(
|
|
225
|
+
prog="liveapisec",
|
|
226
|
+
description="LiveAPISec Developer API — push API specs, run security scans, gate your CI/CD.",
|
|
227
|
+
)
|
|
228
|
+
parser.add_argument("--api-url", help=f"API base URL (default: $LIVEAPISEC_API_URL or {DEFAULT_API_URL})")
|
|
229
|
+
parser.add_argument("--api-key", help="dev API key las_dev_... (default: $LIVEAPISEC_API_KEY)")
|
|
230
|
+
parser.add_argument("--json", action="store_true", help="print raw JSON output")
|
|
231
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
232
|
+
|
|
233
|
+
def _json_flag(p: argparse.ArgumentParser) -> None:
|
|
234
|
+
# --json działa też po nazwie podkomendy (np. `findings ... --json`)
|
|
235
|
+
p.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
236
|
+
|
|
237
|
+
p_push = sub.add_parser("push", help="create/update a site (idempotent)")
|
|
238
|
+
p_push.add_argument("--name", required=True)
|
|
239
|
+
p_push.add_argument("--base-url")
|
|
240
|
+
p_push.add_argument("--project")
|
|
241
|
+
p_push.add_argument("--endpoint", action="append", type=_parse_endpoint, help="'METHOD /path' (repeatable)")
|
|
242
|
+
p_push.add_argument("--openapi-url", help="URL to OpenAPI spec instead of --endpoint")
|
|
243
|
+
p_push.add_argument("--site", help="existing site_id to update (PUT)")
|
|
244
|
+
_auth_args(p_push)
|
|
245
|
+
_json_flag(p_push)
|
|
246
|
+
p_push.set_defaults(func=_cmd_push)
|
|
247
|
+
|
|
248
|
+
p_scan = sub.add_parser("scan", help="run a security scan (optionally wait + gate)")
|
|
249
|
+
p_scan.add_argument("--site", required=True)
|
|
250
|
+
p_scan.add_argument("--branch")
|
|
251
|
+
p_scan.add_argument("--commit")
|
|
252
|
+
p_scan.add_argument("--wait", action="store_true", help="poll until finished")
|
|
253
|
+
p_scan.add_argument("--fail-on", choices=_SEV, help="exit 1 if findings at/above this severity (default: high)")
|
|
254
|
+
p_scan.add_argument("--poll-interval", type=float, default=3.0)
|
|
255
|
+
p_scan.add_argument("--timeout", type=float, default=600.0)
|
|
256
|
+
_json_flag(p_scan)
|
|
257
|
+
p_scan.set_defaults(func=_cmd_scan)
|
|
258
|
+
|
|
259
|
+
p_status = sub.add_parser("status", help="site status + recent scans")
|
|
260
|
+
p_status.add_argument("--site", required=True)
|
|
261
|
+
_json_flag(p_status)
|
|
262
|
+
p_status.set_defaults(func=_cmd_status)
|
|
263
|
+
|
|
264
|
+
p_find = sub.add_parser("findings", help="list findings for a scan")
|
|
265
|
+
p_find.add_argument("--site", required=True)
|
|
266
|
+
p_find.add_argument("--scan", required=True)
|
|
267
|
+
_json_flag(p_find)
|
|
268
|
+
p_find.set_defaults(func=_cmd_findings)
|
|
269
|
+
|
|
270
|
+
p_sites = sub.add_parser("sites", help="show a site")
|
|
271
|
+
p_sites.add_argument("--site", required=True)
|
|
272
|
+
_json_flag(p_sites)
|
|
273
|
+
p_sites.set_defaults(func=_cmd_sites)
|
|
274
|
+
|
|
275
|
+
return parser
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main(argv: list[str] | None = None) -> int:
|
|
279
|
+
parser = build_parser()
|
|
280
|
+
args = parser.parse_args(argv)
|
|
281
|
+
args.json = bool(getattr(args, "json", False))
|
|
282
|
+
try:
|
|
283
|
+
client = LiveAPISec(api_url=args.api_url, api_key=args.api_key)
|
|
284
|
+
except LiveAPISecError as exc:
|
|
285
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
286
|
+
return 2
|
|
287
|
+
try:
|
|
288
|
+
return int(args.func(client, args))
|
|
289
|
+
except LiveAPISecError as exc:
|
|
290
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
291
|
+
return 2
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
sys.exit(main())
|
liveapisec/client.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Cienki klient HTTP dla LiveAPISec Developer API (TODO 2.25).
|
|
2
|
+
|
|
3
|
+
Wrapsuje endpointy /developers/* tak, żeby dało się ich używać z konsoli,
|
|
4
|
+
CI/CD i skryptów — bez curl i bez dashboardu.
|
|
5
|
+
|
|
6
|
+
Auth: `Authorization: Bearer <LIVEAPISEC_API_KEY>` (klucz `las_dev_...`
|
|
7
|
+
wygenerowany w Settings → Developer API). Token dewelopera (JWT/cookie/API-key)
|
|
8
|
+
jest wysyłany w payloadzie i szyfrowany po stronie serwera (AES-256).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
DEFAULT_API_URL = "https://liveapisec.com"
|
|
21
|
+
ENV_API_URL = "LIVEAPISEC_API_URL"
|
|
22
|
+
ENV_API_KEY = "LIVEAPISEC_API_KEY"
|
|
23
|
+
|
|
24
|
+
# Kolejność istotności severity (do gate'ów w CI).
|
|
25
|
+
_SEV_ORDER = ["critical", "high", "medium", "low", "info"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def severity_rank(severity: str) -> int:
|
|
29
|
+
"""0 = critical (najgorzej) … 4 = info. Nieznane → 5 (poniżej info)."""
|
|
30
|
+
try:
|
|
31
|
+
return _SEV_ORDER.index(severity)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return len(_SEV_ORDER)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LiveAPISecError(RuntimeError):
|
|
37
|
+
"""Błąd API: status HTTP + title/detail (RFC 7807)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, status: int | None, title: str, detail: str = "") -> None:
|
|
40
|
+
super().__init__(f"{title}: {detail}".strip(" :"))
|
|
41
|
+
self.status = status
|
|
42
|
+
self.title = title
|
|
43
|
+
self.detail = detail
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScanStatus:
|
|
47
|
+
"""Statusy skanu (jak w UI)."""
|
|
48
|
+
|
|
49
|
+
QUEUED = "queued"
|
|
50
|
+
RUNNING = "running"
|
|
51
|
+
COMPLETED = "completed"
|
|
52
|
+
FAILED = "failed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def api_url_from_env() -> str:
|
|
56
|
+
return os.environ.get(ENV_API_URL, DEFAULT_API_URL).rstrip("/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LiveAPISec:
|
|
60
|
+
"""Klient Developer API. `api_url`/`api_key` z env (LIVEAPISEC_API_URL/KEY)."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
api_url: str | None = None,
|
|
65
|
+
api_key: str | None = None,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
transport: httpx.BaseTransport | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.api_url = (api_url or api_url_from_env()).rstrip("/")
|
|
70
|
+
self.api_key = api_key or os.environ.get(ENV_API_KEY, "")
|
|
71
|
+
if not self.api_key:
|
|
72
|
+
raise LiveAPISecError(
|
|
73
|
+
None,
|
|
74
|
+
"Missing API key",
|
|
75
|
+
f"set {ENV_API_KEY}=las_dev_... (Settings → Developer API) or pass --api-key",
|
|
76
|
+
)
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self._transport = transport
|
|
79
|
+
|
|
80
|
+
# -- transport -----------------------------------------------------------
|
|
81
|
+
def _headers(self) -> dict[str, str]:
|
|
82
|
+
return {
|
|
83
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _request(self, method: str, path: str, **kw: Any) -> Any:
|
|
88
|
+
url = f"{self.api_url}{path}"
|
|
89
|
+
try:
|
|
90
|
+
with httpx.Client(transport=self._transport) as client:
|
|
91
|
+
resp = client.request(method, url, headers=self._headers(), timeout=self.timeout, **kw)
|
|
92
|
+
except httpx.HTTPError as exc:
|
|
93
|
+
raise LiveAPISecError(None, "Connection error", str(exc)) from exc
|
|
94
|
+
if resp.status_code >= 400:
|
|
95
|
+
try:
|
|
96
|
+
body = resp.json()
|
|
97
|
+
title = body.get("title", "Error")
|
|
98
|
+
detail = body.get("detail", resp.text[:300])
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
title, detail = "Error", resp.text[:300]
|
|
101
|
+
raise LiveAPISecError(resp.status_code, title, detail)
|
|
102
|
+
if resp.status_code == 204 or not resp.content:
|
|
103
|
+
return None
|
|
104
|
+
return resp.json()
|
|
105
|
+
|
|
106
|
+
# -- keys / sites ---------------------------------------------------------
|
|
107
|
+
def create_site(
|
|
108
|
+
self,
|
|
109
|
+
name: str,
|
|
110
|
+
base_url: str,
|
|
111
|
+
endpoints: list[dict[str, str]] | None = None,
|
|
112
|
+
openapi_url: str | None = None,
|
|
113
|
+
project: str | None = None,
|
|
114
|
+
auth: dict[str, Any] | None = None,
|
|
115
|
+
site_id: str | None = None,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""Push site (idempotentny wg nazwy+base_url). Bez `site_id` → POST (create/update),
|
|
118
|
+
z `site_id` → PUT (explicit update)."""
|
|
119
|
+
payload: dict[str, Any] = {"name": name, "base_url": base_url}
|
|
120
|
+
if endpoints:
|
|
121
|
+
payload["endpoints"] = endpoints
|
|
122
|
+
if openapi_url:
|
|
123
|
+
payload["openapi_url"] = openapi_url
|
|
124
|
+
if project:
|
|
125
|
+
payload["project"] = project
|
|
126
|
+
if auth:
|
|
127
|
+
payload["auth"] = auth
|
|
128
|
+
if site_id:
|
|
129
|
+
return self._request("PUT", f"/developers/sites/{site_id}", json=payload)
|
|
130
|
+
return self._request("POST", "/developers/sites", json=payload)
|
|
131
|
+
|
|
132
|
+
def get_site(self, site_id: str) -> dict[str, Any]:
|
|
133
|
+
return self._request("GET", f"/developers/sites/{site_id}")
|
|
134
|
+
|
|
135
|
+
# -- scans ----------------------------------------------------------------
|
|
136
|
+
def trigger_scan(
|
|
137
|
+
self, site_id: str, branch: str | None = None, commit: str | None = None
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""Odpal skan (202). Zwraca {scan_id, status, branch, commit}."""
|
|
140
|
+
payload: dict[str, Any] = {}
|
|
141
|
+
if branch:
|
|
142
|
+
payload["branch"] = branch
|
|
143
|
+
if commit:
|
|
144
|
+
payload["commit"] = commit
|
|
145
|
+
return self._request("POST", f"/developers/sites/{site_id}/scans", json=payload)
|
|
146
|
+
|
|
147
|
+
def list_scans(self, site_id: str) -> list[dict[str, Any]]:
|
|
148
|
+
return self._request("GET", f"/developers/sites/{site_id}/scans")
|
|
149
|
+
|
|
150
|
+
def get_scan(self, site_id: str, scan_id: str) -> dict[str, Any] | None:
|
|
151
|
+
"""Pojedynczy skan (przez listę — brak dedykowanego GET scan)."""
|
|
152
|
+
for s in self.list_scans(site_id):
|
|
153
|
+
if s.get("scan_id") == scan_id:
|
|
154
|
+
return s
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def get_findings(self, site_id: str, scan_id: str) -> list[dict[str, Any]]:
|
|
158
|
+
return self._request("GET", f"/developers/sites/{site_id}/scans/{scan_id}/findings")
|
|
159
|
+
|
|
160
|
+
# -- helpers dla CI --------------------------------------------------------
|
|
161
|
+
def wait_for_scan(
|
|
162
|
+
self,
|
|
163
|
+
site_id: str,
|
|
164
|
+
scan_id: str,
|
|
165
|
+
poll_interval: float = 3.0,
|
|
166
|
+
timeout: float = 600.0,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Polluj aż skan się zakończy (completed/failed). Zwraca skan + findings."""
|
|
169
|
+
deadline = time.monotonic() + timeout
|
|
170
|
+
while True:
|
|
171
|
+
scan = self.get_scan(site_id, scan_id)
|
|
172
|
+
if scan is None:
|
|
173
|
+
raise LiveAPISecError(None, "Scan not found", f"scan {scan_id} on site {site_id}")
|
|
174
|
+
status = scan.get("status")
|
|
175
|
+
if status in (ScanStatus.COMPLETED, ScanStatus.FAILED):
|
|
176
|
+
scan["findings"] = self.get_findings(site_id, scan_id)
|
|
177
|
+
return scan
|
|
178
|
+
if time.monotonic() > deadline:
|
|
179
|
+
raise LiveAPISecError(
|
|
180
|
+
None, "Timeout", f"scan {scan_id} still {status!r} after {timeout:.0f}s"
|
|
181
|
+
)
|
|
182
|
+
time.sleep(poll_interval)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def findings_above(findings: list[dict[str, Any]], min_severity: str) -> list[dict[str, Any]]:
|
|
186
|
+
"""Findings o severity >= min_severity (wg ranku: critical < high < ...)."""
|
|
187
|
+
threshold = severity_rank(min_severity)
|
|
188
|
+
return [f for f in findings if severity_rank(f.get("severity", "info")) <= threshold]
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def dump(data: Any) -> str:
|
|
192
|
+
return json.dumps(data, indent=2, ensure_ascii=False, default=str)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: liveapisec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LiveAPISec Developer API client — push API specs, run security scans and gate your CI/CD from the command line.
|
|
5
|
+
Author: LiveAPISec
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://liveapisec.com
|
|
8
|
+
Project-URL: Documentation, https://liveapisec.com/settings
|
|
9
|
+
Keywords: security,api,dast,scanning,ci,cd
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.24
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# liveapisec — CLI/SDK do LiveAPISec Developer API
|
|
23
|
+
|
|
24
|
+
Oficjalny, cienki klient do **LiveAPISec Developer API**. Instalujesz raz,
|
|
25
|
+
używasz w dowolnym projekcie, skrypcie i pipeline CI/CD — bez dashboardu i bez curl.
|
|
26
|
+
|
|
27
|
+
> **Kiedy to jest?** Zamiast ręcznie przechodzić kreatora w panelu, developer
|
|
28
|
+
> pushuje endpointy + opcjonalny token z **swojego** środowiska (CI/CD, agent,
|
|
29
|
+
> skrypt). Token jest generowany u Ciebie i szyfrowany po stronie serwera (AES-256).
|
|
30
|
+
> **Tip: brak tokena = testujemy tylko to, co publiczne.**
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Instalacja
|
|
35
|
+
|
|
36
|
+
Z GitHub (rekomendowane, zanim trafimy na PyPI):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install "liveapisec @ git+https://github.com/LiveApiSec/liveapisec.git"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Po publikacji na PyPI:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install liveapisec
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Sprawdź:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
liveapisec --help
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Kiedy zainstalujesz raz (np. w obrazie CI, na maszynie dev, w GitHub Actions) —
|
|
55
|
+
komenda `liveapisec` jest dostępna **w każdym projekcie** w tej maszynie.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Konfiguracja
|
|
60
|
+
|
|
61
|
+
Klucz API generujesz raz w panelu: **Settings → Developer API → Create API key**
|
|
62
|
+
(klucz `las_dev_...` pokazywany jest tylko raz — trzymaj go jako secret).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export LIVEAPISEC_API_KEY=las_dev_... # wymagane
|
|
66
|
+
export LIVEAPISEC_API_URL=https://liveapisec.com # opcjonalne (domyślne)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Można też podać per-komenda: `--api-key` / `--api-url`.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Komendy
|
|
74
|
+
|
|
75
|
+
### 1. `push` — wyślij API (idempotentne, bezpieczne w CI)
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
liveapisec push \
|
|
79
|
+
--name my-api \
|
|
80
|
+
--base-url https://api.example.com \
|
|
81
|
+
--endpoint "GET /users" \
|
|
82
|
+
--endpoint "POST /payments"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- Ten sam `name` + `base_url` = **ten sam site** (aktualizacja, nie duplikat) —
|
|
86
|
+
możesz wołać push w każdym buildzie.
|
|
87
|
+
- Zamiast listy endpointów możesz podać OpenAPI: `--openapi-url https://api.example.com/openapi.json`.
|
|
88
|
+
- Opcjonalny token: `--auth-type jwt --auth-token <TOKEN>` (albo `bearer`,
|
|
89
|
+
`cookie --auth-cookie "session=..."`, `api_key --auth-header X-API-Key`).
|
|
90
|
+
|
|
91
|
+
Wynik:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
site 65f...abc: my-api — 2 endpoints, auth=none
|
|
95
|
+
export SITE_ID=65f...abc
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 2. `scan` — odpal test bezpieczeństwa
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# zwykłe odpalanie (202, nie czeka)
|
|
102
|
+
liveapisec scan --site SITE_ID --branch main --commit "$GITHUB_SHA"
|
|
103
|
+
|
|
104
|
+
# czekaj na wynik i próg błędu dla CI (gate)
|
|
105
|
+
liveapisec scan --site SITE_ID --branch main --commit "$SHA" \
|
|
106
|
+
--wait --fail-on high
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `--wait` — polluje aż skan się zakończy (domyślnie timeout 600 s,
|
|
110
|
+
interwał 3 s; zmiana przez `--timeout` / `--poll-interval`).
|
|
111
|
+
- `--fail-on high` — **exit code 1** gdy znajdzie finding severity `high`/`critical`;
|
|
112
|
+
`--fail-on critical` tylko przy krytycznych; pomiń → zawsze exit 0 (poza błędami).
|
|
113
|
+
|
|
114
|
+
### 3. `status` — stan site'a i ostatnich skanów
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
liveapisec status --site SITE_ID
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 4. `findings` — wyniki skanu
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
liveapisec findings --site SITE_ID --scan SCAN_ID
|
|
124
|
+
liveapisec findings --site SITE_ID --scan SCAN_ID --json # surowe dane (dla agenta/AI)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 5. `sites` — szczegóły site'a
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
liveapisec sites --site SITE_ID
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## GitHub Actions — pełny przykład (gate na push)
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
name: liveapisec
|
|
139
|
+
on: push
|
|
140
|
+
jobs:
|
|
141
|
+
security-test:
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v4
|
|
145
|
+
- uses: actions/setup-python@v5
|
|
146
|
+
with: { python-version: "3.12" }
|
|
147
|
+
- name: Install CLI
|
|
148
|
+
run: pip install "liveapisec @ git+https://github.com/LiveApiSec/liveapisec.git"
|
|
149
|
+
- name: Push API + run security test (gate on high)
|
|
150
|
+
env:
|
|
151
|
+
LIVEAPISEC_API_KEY: ${{ secrets.LIVEAPISEC_KEY }}
|
|
152
|
+
run: |
|
|
153
|
+
liveapisec push --name my-api --base-url "$BASE_URL" \
|
|
154
|
+
--endpoint "GET /users" --endpoint "POST /payments"
|
|
155
|
+
liveapisec scan --site "$SITE_ID" \
|
|
156
|
+
--branch "${GITHUB_REF#refs/heads/}" --commit "$GITHUB_SHA" \
|
|
157
|
+
--wait --fail-on high
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
> **Dlaczego push jest bezpieczny?** Push jest idempotentny (name+base_url →
|
|
161
|
+
> ten sam site), więc kolejny build nie tworzy śmieci — aktualizuje endpointy
|
|
162
|
+
> i token, a następny `scan` testuje najnowszy stan.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Exit codes
|
|
167
|
+
|
|
168
|
+
| Code | Znaczenie |
|
|
169
|
+
|------|-----------|
|
|
170
|
+
| 0 | OK (brak findings ≥ progu, lub bez `--fail-on`) |
|
|
171
|
+
| 1 | Gate failed — znaleziono findings ≥ `--fail-on` |
|
|
172
|
+
| 2 | Błąd użycia / błąd API / brak klucza |
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Rozwój / testy
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install -e ./cli[dev]
|
|
180
|
+
cd cli && python -m pytest tests/ -q
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## API (SDK)
|
|
184
|
+
|
|
185
|
+
Poza CLI pakiet eksportuje też klienta do skryptów:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from liveapisec import LiveAPISec
|
|
189
|
+
|
|
190
|
+
api = LiveAPISec() # LIVEAPISEC_API_KEY z env
|
|
191
|
+
site = api.create_site("my-api", "https://api.example.com",
|
|
192
|
+
endpoints=[{"method": "GET", "path": "/users"}])
|
|
193
|
+
scan = api.trigger_scan(site["site_id"], branch="main", commit="abc")
|
|
194
|
+
done = api.wait_for_scan(site["site_id"], scan["scan_id"])
|
|
195
|
+
blocked = LiveAPISec.findings_above(done["findings"], "high")
|
|
196
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
liveapisec/__init__.py,sha256=N1gXj7YighntKJMgf9MwRQXe6R1uFRKeStqWqfbkvmA,659
|
|
2
|
+
liveapisec/cli.py,sha256=1PoXrUvfuF2fAW_8dJrGmr8K8pB1ssCTbyz46Yz6et0,11074
|
|
3
|
+
liveapisec/client.py,sha256=WkJLwadR-cCobUIn7YVOYFy2T9g1M7qatf8jCJFXyuY,7239
|
|
4
|
+
liveapisec-0.1.0.dist-info/METADATA,sha256=oqaStjDo5L9zWXX2mgBr96f_UyA8W_uYKZyqamq9Beg,5636
|
|
5
|
+
liveapisec-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
liveapisec-0.1.0.dist-info/entry_points.txt,sha256=g_b-0QQOXSBVpGrlee-81X9TzMvE9_2P7ToBuikjcVo,51
|
|
7
|
+
liveapisec-0.1.0.dist-info/top_level.txt,sha256=JvGd8EoaUOP0UCTIYKrxzsTBwPe40OtwWycgM2sbUYc,11
|
|
8
|
+
liveapisec-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
liveapisec
|