kctl-api 0.2.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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,350 @@
1
+ """Security auditing commands for kctl-api.
2
+
3
+ Vulnerability scan, secret detection, CORS audit, headers check.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import pathlib
9
+ import re
10
+ import subprocess
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from kctl_api.core.callbacks import AppContext
16
+ from kctl_api.core.utils import find_project_root
17
+
18
+ app = typer.Typer(name="security", help="Security — audit, secrets, CORS, headers, deps.", no_args_is_help=True)
19
+
20
+ # Patterns for secret detection
21
+ _SECRET_PATTERNS: list[tuple[str, str]] = [
22
+ (r"(?i)(password|passwd|pwd)\s*=\s*['\"][^'\"]{6,}", "password assignment"),
23
+ (r"(?i)(secret|token|api[_-]?key|apikey)\s*=\s*['\"][^'\"]{8,}", "secret/token assignment"),
24
+ (r"(?i)authorization:\s*bearer\s+[a-zA-Z0-9._-]{20,}", "bearer token in code"),
25
+ (r"sk-[a-zA-Z0-9]{20,}", "OpenAI API key"),
26
+ (r"ghp_[a-zA-Z0-9]{36}", "GitHub personal token"),
27
+ (r"(?i)aws_secret_access_key\s*=\s*[a-zA-Z0-9/+]{40}", "AWS secret key"),
28
+ (r"-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----", "private key"),
29
+ ]
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # audit
34
+ # ---------------------------------------------------------------------------
35
+ @app.command()
36
+ def audit(ctx: typer.Context) -> None:
37
+ """Run a combined security report: deps, secrets, and CORS."""
38
+ actx: AppContext = ctx.obj
39
+ out = actx.output
40
+
41
+ root = find_project_root()
42
+ out.header("Security Audit Report")
43
+ out.text("")
44
+
45
+ issues: list[dict] = []
46
+
47
+ # 1. Dependency vulnerabilities
48
+ import shutil
49
+
50
+ out.info("[1/3] Checking dependency vulnerabilities ...")
51
+ if shutil.which("pip-audit"):
52
+ result = subprocess.run(["pip-audit", "--format", "json"], capture_output=True, text=True, cwd=str(root))
53
+ import json
54
+
55
+ try:
56
+ data = json.loads(result.stdout)
57
+ vulns = data.get("vulnerabilities", [])
58
+ for v in vulns:
59
+ issues.append(
60
+ {
61
+ "category": "dependency",
62
+ "severity": "HIGH",
63
+ "detail": f"{v.get('name')} {v.get('version')}: {v.get('id')}",
64
+ }
65
+ )
66
+ if not vulns:
67
+ out.success(" No dependency vulnerabilities.")
68
+ else:
69
+ out.warn(f" {len(vulns)} vulnerabilities found.")
70
+ except Exception:
71
+ out.info(" pip-audit output could not be parsed.")
72
+ else:
73
+ out.info(" pip-audit not installed — skipping. Run: uv add --dev pip-audit")
74
+
75
+ # 2. Secret detection (simplified scan)
76
+ out.info("[2/3] Scanning for secrets in source ...")
77
+ secret_issues = _scan_secrets(root)
78
+ issues.extend(secret_issues)
79
+ if not secret_issues:
80
+ out.success(" No secrets detected in source code.")
81
+ else:
82
+ out.warn(f" {len(secret_issues)} potential secrets detected.")
83
+
84
+ # 3. CORS config
85
+ out.info("[3/3] Checking CORS configuration ...")
86
+ cors_issues = _check_cors_config(root)
87
+ issues.extend(cors_issues)
88
+ if not cors_issues:
89
+ out.success(" CORS configuration looks safe.")
90
+ else:
91
+ out.warn(f" {len(cors_issues)} CORS concern(s).")
92
+
93
+ out.text("")
94
+ if not issues:
95
+ out.success("Security audit complete — no issues found.")
96
+ return
97
+
98
+ rows = [[i["category"], i["severity"], i["detail"][:80]] for i in issues]
99
+ out.table(
100
+ title=f"Security Issues ({len(issues)})",
101
+ columns=[("Category", "bold"), ("Severity", "red"), ("Detail", "")],
102
+ rows=rows,
103
+ data_for_json=issues,
104
+ )
105
+ raise typer.Exit(1)
106
+
107
+
108
+ def _scan_secrets(root: pathlib.Path) -> list[dict]:
109
+ """Scan Python source files for secret patterns."""
110
+
111
+ issues: list[dict] = []
112
+ skip_dirs = {".venv", "__pycache__", ".git", "node_modules", "dist", ".mypy_cache"}
113
+
114
+ for py_file in root.rglob("*.py"):
115
+ if any(part in skip_dirs for part in py_file.parts):
116
+ continue
117
+ try:
118
+ content = py_file.read_text(encoding="utf-8", errors="ignore")
119
+ except Exception:
120
+ continue
121
+ for pattern, label in _SECRET_PATTERNS:
122
+ if re.search(pattern, content):
123
+ issues.append(
124
+ {
125
+ "category": "secret",
126
+ "severity": "CRITICAL",
127
+ "detail": f"{py_file.relative_to(root)}: possible {label}",
128
+ }
129
+ )
130
+ break # One issue per file
131
+
132
+ return issues
133
+
134
+
135
+ def _check_cors_config(root: pathlib.Path) -> list[dict]:
136
+ """Check for overly permissive CORS settings."""
137
+
138
+ issues: list[dict] = []
139
+ skip_dirs = {".venv", "__pycache__", ".git", "node_modules"}
140
+
141
+ wildcard_patterns = [
142
+ (r'allow_origins\s*=\s*\[.*["\']?\*["\']?.*\]', "allow_origins = ['*']"),
143
+ (r"CORSMiddleware.*origins.*\*", "CORS wildcard origin"),
144
+ ]
145
+
146
+ for py_file in root.rglob("*.py"):
147
+ if any(part in skip_dirs for part in py_file.parts):
148
+ continue
149
+ try:
150
+ content = py_file.read_text(encoding="utf-8", errors="ignore")
151
+ except Exception:
152
+ continue
153
+ for pattern, label in wildcard_patterns:
154
+ if re.search(pattern, content):
155
+ issues.append(
156
+ {
157
+ "category": "cors",
158
+ "severity": "MEDIUM",
159
+ "detail": f"{py_file.relative_to(root)}: {label}",
160
+ }
161
+ )
162
+
163
+ return issues
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # secrets
168
+ # ---------------------------------------------------------------------------
169
+ @app.command()
170
+ def secrets(
171
+ ctx: typer.Context,
172
+ path: Annotated[str | None, typer.Argument(help="Path to scan (default: project root).")] = None,
173
+ ) -> None:
174
+ """Scan source code for potential secrets and credentials."""
175
+ actx: AppContext = ctx.obj
176
+ out = actx.output
177
+
178
+ import pathlib
179
+
180
+ root = pathlib.Path(path) if path else find_project_root()
181
+ out.info(f"Scanning {root} for secrets ...")
182
+
183
+ # Try gitleaks if available
184
+ import shutil
185
+
186
+ if shutil.which("gitleaks"):
187
+ result = subprocess.run(
188
+ ["gitleaks", "detect", "--source", str(root), "--report-format", "json"], capture_output=True, text=True
189
+ )
190
+ if result.returncode == 0:
191
+ out.success("No secrets detected (gitleaks).")
192
+ return
193
+ import json
194
+
195
+ try:
196
+ findings = json.loads(result.stdout)
197
+ rows = [[f.get("RuleID", ""), f.get("File", ""), str(f.get("StartLine", ""))] for f in findings]
198
+ out.table(
199
+ title=f"Secret Findings ({len(findings)})",
200
+ columns=[("Rule", "bold"), ("File", ""), ("Line", "")],
201
+ rows=rows,
202
+ data_for_json=findings,
203
+ )
204
+ raise typer.Exit(1)
205
+ except Exception:
206
+ out.text(result.stdout[:2000])
207
+ raise typer.Exit(1) from None
208
+
209
+ # Fall back to regex scan
210
+ issues = _scan_secrets(root)
211
+ if not issues:
212
+ out.success("No secrets detected in Python source files.")
213
+ return
214
+
215
+ rows = [[i["severity"], i["detail"]] for i in issues]
216
+ out.table(
217
+ title=f"Potential Secrets ({len(issues)})",
218
+ columns=[("Severity", "bold"), ("Detail", "")],
219
+ rows=rows,
220
+ data_for_json=issues,
221
+ )
222
+ out.info("Install gitleaks for comprehensive scanning: https://github.com/gitleaks/gitleaks")
223
+ raise typer.Exit(1)
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # cors
228
+ # ---------------------------------------------------------------------------
229
+ @app.command()
230
+ def cors(ctx: typer.Context) -> None:
231
+ """Audit CORS configuration in the codebase."""
232
+ actx: AppContext = ctx.obj
233
+ out = actx.output
234
+
235
+ root = find_project_root()
236
+ out.info("Auditing CORS configuration ...")
237
+
238
+ issues = _check_cors_config(root)
239
+
240
+ # Also check env files for CORS_ORIGINS
241
+
242
+ env_file = root / ".env"
243
+ if env_file.exists():
244
+ content = env_file.read_text()
245
+ if "CORS_ORIGINS=*" in content or 'CORS_ORIGINS="*"' in content:
246
+ issues.append(
247
+ {
248
+ "category": "cors",
249
+ "severity": "HIGH",
250
+ "detail": ".env: CORS_ORIGINS=* (wildcard in env var)",
251
+ }
252
+ )
253
+
254
+ if not issues:
255
+ out.success("CORS configuration looks safe — no wildcard origins found.")
256
+ return
257
+
258
+ rows = [[i["severity"], i["detail"]] for i in issues]
259
+ out.table(
260
+ title=f"CORS Issues ({len(issues)})",
261
+ columns=[("Severity", "bold"), ("Detail", "red")],
262
+ rows=rows,
263
+ data_for_json=issues,
264
+ )
265
+ out.warn("Review CORS config. Wildcard origins allow any site to call your API.")
266
+ raise typer.Exit(1)
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # headers
271
+ # ---------------------------------------------------------------------------
272
+ @app.command()
273
+ def headers(
274
+ ctx: typer.Context,
275
+ url: Annotated[str | None, typer.Argument(help="URL to check (default: configured API URL).")] = None,
276
+ ) -> None:
277
+ """Check security headers on a URL."""
278
+ actx: AppContext = ctx.obj
279
+ out = actx.output
280
+
281
+ import httpx
282
+
283
+ target_url = url or f"{actx.client.base_url.rstrip('/')}/api/v1/health"
284
+ out.info(f"Checking security headers: {target_url}")
285
+
286
+ try:
287
+ response = httpx.get(target_url, timeout=10, follow_redirects=True)
288
+ except Exception as e:
289
+ out.error(f"Request failed: {e}")
290
+ raise typer.Exit(1) from None
291
+
292
+ security_headers = {
293
+ "Strict-Transport-Security": {"required": True, "note": "HSTS — forces HTTPS"},
294
+ "X-Content-Type-Options": {"required": True, "note": "Prevents MIME sniffing"},
295
+ "X-Frame-Options": {"required": False, "note": "Clickjacking protection"},
296
+ "Content-Security-Policy": {"required": False, "note": "XSS protection"},
297
+ "X-XSS-Protection": {"required": False, "note": "Browser XSS filter"},
298
+ "Referrer-Policy": {"required": False, "note": "Controls referrer info"},
299
+ "Permissions-Policy": {"required": False, "note": "Controls browser features"},
300
+ }
301
+
302
+ results: list[dict] = []
303
+ for header, meta in security_headers.items():
304
+ value = response.headers.get(header, "")
305
+ present = bool(value)
306
+ results.append(
307
+ {
308
+ "header": header,
309
+ "present": present,
310
+ "value": value[:60] if value else "",
311
+ "required": meta["required"],
312
+ "note": meta["note"],
313
+ }
314
+ )
315
+
316
+ rows = [
317
+ [
318
+ r["header"],
319
+ "[green]yes[/green]" if r["present"] else ("[red]NO[/red]" if r["required"] else "[yellow]no[/yellow]"),
320
+ r["value"] or r["note"],
321
+ ]
322
+ for r in results
323
+ ]
324
+ out.table(
325
+ title=f"Security Headers: {target_url}",
326
+ columns=[("Header", "bold"), ("Present", ""), ("Value / Note", "")],
327
+ rows=rows,
328
+ data_for_json={"url": target_url, "status_code": response.status_code, "headers": results},
329
+ )
330
+
331
+ missing_required = [r for r in results if r["required"] and not r["present"]]
332
+ if missing_required:
333
+ out.warn(f"{len(missing_required)} required security header(s) missing.")
334
+ raise typer.Exit(1)
335
+ else:
336
+ out.success("All required security headers present.")
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # deps
341
+ # ---------------------------------------------------------------------------
342
+ @app.command()
343
+ def deps(ctx: typer.Context) -> None:
344
+ """Scan dependencies for known vulnerabilities (alias for: kctl-api deps audit)."""
345
+ _actx: AppContext = ctx.obj
346
+
347
+ # Delegate to deps audit
348
+ from kctl_api.commands.deps import audit as deps_audit
349
+
350
+ deps_audit(ctx)
@@ -0,0 +1,191 @@
1
+ """Docker Compose service management commands for kctl-api.
2
+
3
+ List, start, stop, restart, and view logs for services.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from kctl_api.core.callbacks import AppContext
14
+ from kctl_api.core.utils import find_project_root
15
+
16
+ app = typer.Typer(name="services", help="Docker Compose services — list, status, restart, logs.", no_args_is_help=True)
17
+
18
+
19
+ def _compose_cmd(args: list[str]) -> list[str]:
20
+ """Build a docker compose command rooted at the project."""
21
+ root = find_project_root()
22
+ return ["docker", "compose", "-f", str(root / "docker-compose.yml"), *args]
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # list
27
+ # ---------------------------------------------------------------------------
28
+ @app.command(name="list")
29
+ def list_services(ctx: typer.Context) -> None:
30
+ """List all docker compose services and their status."""
31
+ actx: AppContext = ctx.obj
32
+ out = actx.output
33
+
34
+ result = subprocess.run(
35
+ _compose_cmd(["ps", "--format", "json"]),
36
+ capture_output=True,
37
+ text=True,
38
+ )
39
+
40
+ if result.returncode != 0:
41
+ out.error(f"docker compose ps failed: {result.stderr.strip()}")
42
+ raise typer.Exit(1)
43
+
44
+ import json
45
+
46
+ services: list[dict] = []
47
+ for line in result.stdout.strip().splitlines():
48
+ if line.strip():
49
+ try:
50
+ services.append(json.loads(line))
51
+ except json.JSONDecodeError:
52
+ continue
53
+
54
+ if actx.json_mode:
55
+ out.raw_json(services)
56
+ return
57
+
58
+ rows: list[list[str]] = []
59
+ for svc in services:
60
+ name = svc.get("Name", svc.get("Service", ""))
61
+ state = svc.get("State", svc.get("Status", ""))
62
+ health = svc.get("Health", "")
63
+ ports = svc.get("Publishers", svc.get("Ports", ""))
64
+ if isinstance(ports, list):
65
+ ports = ", ".join(
66
+ f"{p.get('PublishedPort', '')}:{p.get('TargetPort', '')}" for p in ports if p.get("PublishedPort")
67
+ )
68
+ rows.append([name, str(state), str(health), str(ports)])
69
+
70
+ out.table(
71
+ title="Docker Compose Services",
72
+ columns=[
73
+ ("Name", "bold"),
74
+ ("State", ""),
75
+ ("Health", ""),
76
+ ("Ports", "dim"),
77
+ ],
78
+ rows=rows,
79
+ data_for_json=services,
80
+ )
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # status
85
+ # ---------------------------------------------------------------------------
86
+ @app.command()
87
+ def status(
88
+ ctx: typer.Context,
89
+ service: Annotated[str, typer.Argument(help="Service name.")],
90
+ ) -> None:
91
+ """Show status of a specific docker compose service."""
92
+ actx: AppContext = ctx.obj
93
+ result = subprocess.run(
94
+ _compose_cmd(["ps", service]),
95
+ capture_output=False,
96
+ )
97
+ if result.returncode != 0:
98
+ actx.output.error(f"Service '{service}' not found or not running.")
99
+ raise typer.Exit(result.returncode)
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # restart
104
+ # ---------------------------------------------------------------------------
105
+ @app.command()
106
+ def restart(
107
+ ctx: typer.Context,
108
+ service: Annotated[str, typer.Argument(help="Service name to restart.")],
109
+ ) -> None:
110
+ """Restart a docker compose service."""
111
+ actx: AppContext = ctx.obj
112
+ out = actx.output
113
+
114
+ out.info(f"Restarting {service} ...")
115
+ result = subprocess.run(_compose_cmd(["restart", service]), capture_output=False)
116
+ if result.returncode != 0:
117
+ out.error(f"Failed to restart {service}.")
118
+ raise typer.Exit(result.returncode)
119
+ out.success(f"Service {service} restarted.")
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # logs
124
+ # ---------------------------------------------------------------------------
125
+ @app.command()
126
+ def logs(
127
+ ctx: typer.Context,
128
+ service: Annotated[str, typer.Argument(help="Service name.")],
129
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output.")] = False,
130
+ tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines to show.")] = 100,
131
+ ) -> None:
132
+ """View logs for a docker compose service."""
133
+ cmd = _compose_cmd(["logs", "--tail", str(tail)])
134
+ if follow:
135
+ cmd.append("--follow")
136
+ cmd.append(service)
137
+ subprocess.run(cmd, capture_output=False)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # up
142
+ # ---------------------------------------------------------------------------
143
+ @app.command()
144
+ def up(
145
+ ctx: typer.Context,
146
+ service: Annotated[str | None, typer.Argument(help="Service name (all if omitted).")] = None,
147
+ build: Annotated[bool, typer.Option("--build", help="Rebuild images.")] = False,
148
+ detach: Annotated[bool, typer.Option("--detach", "-d", help="Detached mode.")] = True,
149
+ ) -> None:
150
+ """Start docker compose services."""
151
+ actx: AppContext = ctx.obj
152
+ out = actx.output
153
+
154
+ cmd = _compose_cmd(["up"])
155
+ if build:
156
+ cmd.append("--build")
157
+ if detach:
158
+ cmd.append("-d")
159
+ if service:
160
+ cmd.append(service)
161
+
162
+ out.info("Starting services ...")
163
+ result = subprocess.run(cmd, capture_output=False)
164
+ if result.returncode != 0:
165
+ out.error("Failed to start services.")
166
+ raise typer.Exit(result.returncode)
167
+ out.success("Services started.")
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # down
172
+ # ---------------------------------------------------------------------------
173
+ @app.command()
174
+ def down(
175
+ ctx: typer.Context,
176
+ volumes: Annotated[bool, typer.Option("--volumes", "-v", help="Remove volumes too.")] = False,
177
+ ) -> None:
178
+ """Stop and remove docker compose services."""
179
+ actx: AppContext = ctx.obj
180
+ out = actx.output
181
+
182
+ cmd = _compose_cmd(["down"])
183
+ if volumes:
184
+ cmd.append("--volumes")
185
+
186
+ out.info("Stopping services ...")
187
+ result = subprocess.run(cmd, capture_output=False)
188
+ if result.returncode != 0:
189
+ out.error("Failed to stop services.")
190
+ raise typer.Exit(result.returncode)
191
+ out.success("Services stopped.")