kctl-react 0.6.2__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 (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,660 @@
1
+ """Security scanning commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_react.core.callbacks import AppContext
13
+ from kctl_react.core.discovery import get_app_dir
14
+ from kctl_react.core.exceptions import CommandError
15
+ from kctl_react.core.runner import run, run_pnpm
16
+
17
+ app = typer.Typer(help="Security scanning and compliance checks.")
18
+
19
+ # Patterns for hardcoded secrets detection
20
+ _SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
21
+ ("AWS Access Key", re.compile(r"AKIA[0-9A-Z]{16}")),
22
+ ("AWS Secret Key", re.compile(r"""(?:aws_secret|secret_key)\s*[=:]\s*['"][A-Za-z0-9/+=]{40}['"]""", re.I)),
23
+ ("Generic API Key", re.compile(r"""(?:api_key|apikey|api-key)\s*[=:]\s*['"][A-Za-z0-9_\-]{20,}['"]""", re.I)),
24
+ (
25
+ "Generic Secret",
26
+ re.compile(r"""(?:secret|token|password|passwd)\s*[=:]\s*['"][A-Za-z0-9_\-/+=]{8,}['"]""", re.I),
27
+ ),
28
+ ("Private Key", re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----")),
29
+ ("Bearer Token", re.compile(r"""['"]Bearer\s+[A-Za-z0-9\-._~+/]+=*['"]""")),
30
+ ("Basic Auth", re.compile(r"""['"]Basic\s+[A-Za-z0-9+/]+=*['"]""")),
31
+ ("GitHub Token", re.compile(r"gh[pousr]_[A-Za-z0-9_]{36,}")),
32
+ ("Slack Token", re.compile(r"xox[baprs]-[A-Za-z0-9\-]+")),
33
+ ("JWT", re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+")),
34
+ ]
35
+
36
+ # Files to skip during secret scanning
37
+ _SKIP_DIRS = {
38
+ "node_modules",
39
+ "dist",
40
+ ".next",
41
+ ".git",
42
+ "coverage",
43
+ "generated",
44
+ "__pycache__",
45
+ ".turbo",
46
+ ".kctl-react",
47
+ }
48
+ _SCAN_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".json", ".env", ".yaml", ".yml", ".toml"}
49
+
50
+ # Known security headers
51
+ _SECURITY_HEADERS = [
52
+ "content-security-policy",
53
+ "strict-transport-security",
54
+ "x-frame-options",
55
+ "x-content-type-options",
56
+ "x-xss-protection",
57
+ "referrer-policy",
58
+ "permissions-policy",
59
+ ]
60
+
61
+
62
+ def _scan_file_for_secrets(file_path: Path) -> list[dict]:
63
+ """Scan a single file for hardcoded secrets."""
64
+ findings: list[dict] = []
65
+ try:
66
+ content = file_path.read_text(errors="ignore")
67
+ except Exception:
68
+ return findings
69
+
70
+ for line_num, line in enumerate(content.splitlines(), 1):
71
+ stripped = line.strip()
72
+ # Skip comments and imports
73
+ if stripped.startswith(("//", "#", "*", "/*", "import ", "from ")) or "VITE_" in stripped:
74
+ continue
75
+ for name, pattern in _SECRET_PATTERNS:
76
+ if pattern.search(line):
77
+ findings.append(
78
+ {
79
+ "file": str(file_path),
80
+ "line": line_num,
81
+ "type": name,
82
+ "snippet": stripped[:120],
83
+ }
84
+ )
85
+ return findings
86
+
87
+
88
+ def _parse_audit_json(raw: str) -> dict:
89
+ """Parse pnpm audit --json output into severity counts."""
90
+ try:
91
+ data = json.loads(raw)
92
+ except json.JSONDecodeError:
93
+ return {"critical": 0, "high": 0, "moderate": 0, "low": 0, "info": 0, "total": 0}
94
+
95
+ # pnpm audit JSON uses "advisories" dict
96
+ advisories = data.get("advisories", {})
97
+ counts: dict[str, int] = {"critical": 0, "high": 0, "moderate": 0, "low": 0, "info": 0}
98
+ for advisory in advisories.values():
99
+ severity = advisory.get("severity", "info").lower()
100
+ if severity in counts:
101
+ counts[severity] += 1
102
+ else:
103
+ counts["info"] += 1
104
+
105
+ counts["total"] = sum(counts.values())
106
+ return counts
107
+
108
+
109
+ def _parse_audit_advisories(raw: str) -> list[dict]:
110
+ """Parse pnpm audit --json output into detailed advisory list."""
111
+ try:
112
+ data = json.loads(raw)
113
+ except json.JSONDecodeError:
114
+ return []
115
+
116
+ advisories = data.get("advisories", {})
117
+ results: list[dict] = []
118
+ for _id, advisory in advisories.items():
119
+ results.append(
120
+ {
121
+ "id": advisory.get("id", _id),
122
+ "title": advisory.get("title", "Unknown"),
123
+ "severity": advisory.get("severity", "info"),
124
+ "module": advisory.get("module_name", "?"),
125
+ "url": advisory.get("url", ""),
126
+ "cves": advisory.get("cves", []),
127
+ "recommendation": advisory.get("recommendation", "Update to latest"),
128
+ "vulnerable_versions": advisory.get("vulnerable_versions", ""),
129
+ "patched_versions": advisory.get("patched_versions", ""),
130
+ "findings": [
131
+ {"version": f.get("version", "?"), "paths": f.get("paths", [])}
132
+ for f in advisory.get("findings", [])
133
+ ],
134
+ }
135
+ )
136
+ return sorted(results, key=lambda x: {"critical": 0, "high": 1, "moderate": 2, "low": 3}.get(x["severity"], 4))
137
+
138
+
139
+ @app.command()
140
+ def audit(
141
+ ctx: typer.Context,
142
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for monorepo root)")] = None,
143
+ detail: Annotated[
144
+ bool, typer.Option("--detail", "-d", help="Show CVE IDs, fix commands, and advisory URLs")
145
+ ] = False,
146
+ ) -> None:
147
+ """Run pnpm audit — show vulnerabilities with severity, CVEs, and fix commands."""
148
+ actx: AppContext = ctx.obj
149
+ out = actx.output
150
+ root = actx.project_root
151
+
152
+ if app_name:
153
+ actx.validate_app(app_name)
154
+ cwd = get_app_dir(root, app_name)
155
+ target = app_name
156
+ else:
157
+ cwd = root
158
+ target = "monorepo root"
159
+
160
+ out.info(f"Running dependency audit for {target}...")
161
+
162
+ try:
163
+ result = run_pnpm(["audit", "--json"], cwd=cwd, capture=True, timeout=120)
164
+ raw = result.stdout
165
+ except CommandError as e:
166
+ raw = e.stderr or ""
167
+
168
+ # Summary counts
169
+ counts = _parse_audit_json(raw)
170
+
171
+ severity_colors = {
172
+ "critical": "red",
173
+ "high": "red",
174
+ "moderate": "yellow",
175
+ "low": "dim",
176
+ "info": "dim",
177
+ }
178
+
179
+ # Summary table
180
+ summary_rows: list[list[str]] = [
181
+ [sev, f"[{severity_colors.get(sev, 'white')}]{counts[sev]}[/{severity_colors.get(sev, 'white')}]"]
182
+ for sev in ("critical", "high", "moderate", "low", "info")
183
+ ]
184
+ summary_rows.append(["total", str(counts["total"])])
185
+ out.table(
186
+ f"Vulnerability Summary — {target}",
187
+ [("Severity", "cyan"), ("Count", "")],
188
+ summary_rows,
189
+ )
190
+
191
+ if counts["total"] == 0:
192
+ out.success("No known vulnerabilities found")
193
+ if out.json_mode:
194
+ out.raw_json({"target": target, "counts": counts, "advisories": []})
195
+ return
196
+
197
+ # Detailed advisory table
198
+ advisories = _parse_audit_advisories(raw)
199
+
200
+ if out.json_mode:
201
+ out.raw_json({"target": target, "counts": counts, "advisories": advisories})
202
+ return
203
+
204
+ if advisories and detail:
205
+ detail_rows: list[list[str]] = []
206
+ for adv in advisories:
207
+ sev = adv["severity"]
208
+ color = severity_colors.get(sev, "white")
209
+ cve_str = ", ".join(adv["cves"]) if adv["cves"] else "—"
210
+ fix = adv["patched_versions"] or adv["recommendation"]
211
+ detail_rows.append(
212
+ [
213
+ f"[{color}]{sev.upper()}[/{color}]",
214
+ adv["module"],
215
+ adv["title"][:50],
216
+ cve_str,
217
+ fix[:40],
218
+ ]
219
+ )
220
+ out.table(
221
+ f"Advisory Details ({len(advisories)} found)",
222
+ [("Severity", ""), ("Package", "cyan"), ("Title", ""), ("CVEs", "yellow"), ("Fix", "green")],
223
+ detail_rows,
224
+ )
225
+
226
+ # Show fix commands
227
+ fix_packages = sorted({adv["module"] for adv in advisories if adv["patched_versions"]})
228
+ if fix_packages:
229
+ out.info("Fix commands:")
230
+ for pkg in fix_packages:
231
+ out.text(f" pnpm update {pkg}")
232
+ elif advisories and not detail:
233
+ out.info("Use --detail to see CVE IDs, advisory titles, and fix commands")
234
+
235
+ critical_high = counts["critical"] + counts["high"]
236
+ if critical_high > 0:
237
+ out.error(f"{counts['total']} vulnerability(s) found — {critical_high} critical/high")
238
+ else:
239
+ out.warn(f"{counts['total']} vulnerability(s) found (no critical/high)")
240
+
241
+
242
+ @app.command()
243
+ def scan(
244
+ ctx: typer.Context,
245
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
246
+ ) -> None:
247
+ """Combined security scan: dependency audit + secret detection."""
248
+ actx: AppContext = ctx.obj
249
+ out = actx.output
250
+
251
+ out.info("[bold]--- Dependency Audit ---[/bold]")
252
+ ctx.invoke(audit, app_name=app_name)
253
+
254
+ out.info("")
255
+ out.info("[bold]--- Secret Scanning ---[/bold]")
256
+ ctx.invoke(secrets, app_name=app_name)
257
+
258
+
259
+ @app.command()
260
+ def secrets(
261
+ ctx: typer.Context,
262
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
263
+ ) -> None:
264
+ """Scan source code for hardcoded API keys, tokens, and passwords."""
265
+ actx: AppContext = ctx.obj
266
+ out = actx.output
267
+ root = actx.project_root
268
+
269
+ if app_name:
270
+ actx.validate_app(app_name)
271
+ scan_dirs = [get_app_dir(root, app_name) / "src"]
272
+ else:
273
+ scan_dirs = [get_app_dir(root, name) / "src" for name in actx.app_names]
274
+ # Also scan packages
275
+ packages_dir = root / "packages"
276
+ if packages_dir.is_dir():
277
+ for pkg_dir in sorted(packages_dir.iterdir()):
278
+ src = pkg_dir / "src"
279
+ if src.is_dir():
280
+ scan_dirs.append(src)
281
+
282
+ out.info("Scanning for hardcoded secrets...")
283
+
284
+ all_findings: list[dict] = []
285
+ for scan_dir in scan_dirs:
286
+ if not scan_dir.is_dir():
287
+ continue
288
+ for file_path in scan_dir.rglob("*"):
289
+ if not file_path.is_file():
290
+ continue
291
+ if any(skip in file_path.parts for skip in _SKIP_DIRS):
292
+ continue
293
+ if file_path.suffix not in _SCAN_EXTENSIONS:
294
+ continue
295
+ all_findings.extend(_scan_file_for_secrets(file_path))
296
+
297
+ if not all_findings:
298
+ out.success("No hardcoded secrets detected")
299
+ if out.json_mode:
300
+ out.raw_json({"findings": [], "total": 0})
301
+ return
302
+
303
+ rows: list[list[str]] = []
304
+ for f in all_findings:
305
+ rel_path = str(Path(f["file"]).relative_to(root)) if root in Path(f["file"]).parents else f["file"]
306
+ rows.append([rel_path, str(f["line"]), f["type"], f["snippet"][:60]])
307
+
308
+ out.table(
309
+ "Secret Scan Results",
310
+ [("File", "cyan"), ("Line", "green"), ("Type", "yellow"), ("Snippet", "dim")],
311
+ rows,
312
+ data_for_json=all_findings,
313
+ )
314
+ out.warn(f"{len(all_findings)} potential secret(s) found — review and rotate if real")
315
+
316
+
317
+ @app.command()
318
+ def headers(
319
+ ctx: typer.Context,
320
+ app_name: Annotated[str, typer.Argument(help="App name to check")],
321
+ url: Annotated[str | None, typer.Option("--url", help="Override URL (default: localhost:<port>)")] = None,
322
+ ) -> None:
323
+ """Check security headers on a running app."""
324
+ actx: AppContext = ctx.obj
325
+ out = actx.output
326
+ root = actx.project_root
327
+
328
+ actx.validate_app(app_name)
329
+ app_info = actx.apps[app_name]
330
+ target_url = url or f"http://localhost:{app_info['port']}"
331
+
332
+ out.info(f"Checking security headers on {target_url}...")
333
+
334
+ try:
335
+ result = run(
336
+ ["curl", "-sI", "--max-time", "5", target_url],
337
+ cwd=root,
338
+ capture=True,
339
+ timeout=10,
340
+ )
341
+ response_headers = result.stdout.lower()
342
+ except Exception as e:
343
+ out.error(f"Cannot reach {target_url}: {e}")
344
+ if out.json_mode:
345
+ out.raw_json({"url": target_url, "error": str(e), "headers": {}})
346
+ raise typer.Exit(1) from None
347
+
348
+ rows: list[list[str]] = []
349
+ json_data: dict[str, dict] = {}
350
+
351
+ for header in _SECURITY_HEADERS:
352
+ present = header in response_headers
353
+ status = "[green]present[/green]" if present else "[red]missing[/red]"
354
+ rows.append([header, status])
355
+ json_data[header] = {"present": present}
356
+
357
+ present_count = sum(1 for h in _SECURITY_HEADERS if h in response_headers)
358
+ total = len(_SECURITY_HEADERS)
359
+ score = f"{present_count}/{total}"
360
+
361
+ out.table(
362
+ f"Security Headers — {app_name} ({target_url})",
363
+ [("Header", "cyan"), ("Status", "")],
364
+ rows,
365
+ data_for_json=[{"url": target_url, "score": score, "headers": json_data}],
366
+ )
367
+
368
+ if present_count < total:
369
+ out.warn(f"Score: {score} — missing {total - present_count} header(s)")
370
+ else:
371
+ out.success(f"Score: {score} — all security headers present")
372
+
373
+
374
+ @app.command("deps-license")
375
+ @app.command("licenses")
376
+ def deps_license(
377
+ ctx: typer.Context,
378
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for root)")] = None,
379
+ check: Annotated[str | None, typer.Option("--check", help="Fail on disallowed licenses (comma-separated)")] = None,
380
+ ) -> None:
381
+ """Check license compliance across dependencies."""
382
+ actx: AppContext = ctx.obj
383
+ out = actx.output
384
+ root = actx.project_root
385
+
386
+ if app_name:
387
+ actx.validate_app(app_name)
388
+
389
+ cwd = get_app_dir(root, app_name) if app_name else root
390
+ target = app_name or "monorepo root"
391
+ out.info(f"Checking dependency licenses for {target}...")
392
+
393
+ # Read node_modules for license info
394
+ licenses: dict[str, int] = {}
395
+ flagged: list[dict] = []
396
+ disallowed = {lic.strip().upper() for lic in check.split(",")} if check else set()
397
+
398
+ node_modules = cwd / "node_modules"
399
+ if not node_modules.is_dir():
400
+ # Fall back to root node_modules (pnpm hoists)
401
+ node_modules = root / "node_modules"
402
+
403
+ if not node_modules.is_dir():
404
+ out.warn("node_modules not found — run `pnpm install` first")
405
+ return
406
+
407
+ scanned = 0
408
+ for pkg_json in node_modules.rglob("package.json"):
409
+ # Skip nested node_modules
410
+ rel = pkg_json.relative_to(node_modules)
411
+ parts = rel.parts
412
+ if len(parts) > 2 and not parts[0].startswith("@"):
413
+ continue
414
+ if len(parts) > 3:
415
+ continue
416
+
417
+ try:
418
+ pkg = json.loads(pkg_json.read_text())
419
+ except Exception:
420
+ continue
421
+
422
+ pkg_name = pkg.get("name", "")
423
+ if not pkg_name:
424
+ continue
425
+
426
+ license_val = pkg.get("license", "UNKNOWN")
427
+ if isinstance(license_val, dict):
428
+ license_val = license_val.get("type", "UNKNOWN")
429
+
430
+ licenses[license_val] = licenses.get(license_val, 0) + 1
431
+ scanned += 1
432
+
433
+ if disallowed and license_val.upper() in disallowed:
434
+ flagged.append({"package": pkg_name, "license": license_val})
435
+
436
+ # Summary table
437
+ rows: list[list[str]] = []
438
+ json_data: list[dict] = []
439
+ for lic, count in sorted(licenses.items(), key=lambda x: -x[1]):
440
+ is_flagged = lic.upper() in disallowed if disallowed else False
441
+ status = "[red]DISALLOWED[/red]" if is_flagged else "[green]OK[/green]"
442
+ rows.append([lic, str(count), status])
443
+ json_data.append({"license": lic, "count": count, "allowed": not is_flagged})
444
+
445
+ out.table(
446
+ f"License Summary — {target} ({scanned} packages)",
447
+ [("License", "cyan"), ("Count", "green"), ("Status", "")],
448
+ rows,
449
+ data_for_json=json_data,
450
+ )
451
+
452
+ if flagged:
453
+ out.warn(f"{len(flagged)} package(s) with disallowed licenses")
454
+ flag_rows = [[f["package"], f["license"]] for f in flagged]
455
+ out.table(
456
+ "Disallowed Packages",
457
+ [("Package", "cyan"), ("License", "red")],
458
+ flag_rows,
459
+ data_for_json=flagged,
460
+ )
461
+ raise typer.Exit(1) from None
462
+ else:
463
+ out.success(f"Scanned {scanned} packages — no license violations")
464
+
465
+
466
+ def _collect_secrets(root: Path, app_name: str | None, app_names: list[str], packages_dir: Path) -> list[dict]:
467
+ """Collect secret scan findings without printing output."""
468
+ if app_name:
469
+ scan_dirs = [get_app_dir(root, app_name) / "src"]
470
+ else:
471
+ scan_dirs = [get_app_dir(root, name) / "src" for name in app_names]
472
+ if packages_dir.is_dir():
473
+ for pkg_dir in sorted(packages_dir.iterdir()):
474
+ src = pkg_dir / "src"
475
+ if src.is_dir():
476
+ scan_dirs.append(src)
477
+
478
+ all_findings: list[dict] = []
479
+ for scan_dir in scan_dirs:
480
+ if not scan_dir.is_dir():
481
+ continue
482
+ for file_path in scan_dir.rglob("*"):
483
+ if not file_path.is_file():
484
+ continue
485
+ if any(skip in file_path.parts for skip in _SKIP_DIRS):
486
+ continue
487
+ if file_path.suffix not in _SCAN_EXTENSIONS:
488
+ continue
489
+ all_findings.extend(_scan_file_for_secrets(file_path))
490
+ return all_findings
491
+
492
+
493
+ def _collect_audit(root: Path, app_names: list[str]) -> dict:
494
+ """Collect audit counts without printing output."""
495
+ totals: dict[str, int] = {"critical": 0, "high": 0, "moderate": 0, "low": 0, "info": 0, "total": 0}
496
+ for name in app_names:
497
+ cwd = get_app_dir(root, name)
498
+ try:
499
+ result = run_pnpm(["audit", "--json"], cwd=cwd, capture=True, timeout=120)
500
+ counts = _parse_audit_json(result.stdout)
501
+ except CommandError as e:
502
+ counts = _parse_audit_json(e.stderr or "")
503
+ except Exception:
504
+ counts = {"critical": 0, "high": 0, "moderate": 0, "low": 0, "info": 0, "total": 0}
505
+ for key in totals:
506
+ totals[key] += counts.get(key, 0)
507
+ return totals
508
+
509
+
510
+ def _collect_license_flags(root: Path) -> list[dict]:
511
+ """Collect license info without printing output. Returns flagged packages (GPL etc)."""
512
+ node_modules = root / "node_modules"
513
+ if not node_modules.is_dir():
514
+ return []
515
+
516
+ copyleft = {"GPL-2.0", "GPL-3.0", "AGPL-3.0", "LGPL-2.1", "LGPL-3.0"}
517
+ flagged: list[dict] = []
518
+ for pkg_json in node_modules.rglob("package.json"):
519
+ rel = pkg_json.relative_to(node_modules)
520
+ parts = rel.parts
521
+ if len(parts) > 2 and not parts[0].startswith("@"):
522
+ continue
523
+ if len(parts) > 3:
524
+ continue
525
+ try:
526
+ pkg = json.loads(pkg_json.read_text())
527
+ except Exception:
528
+ continue
529
+ pkg_name = pkg.get("name", "")
530
+ if not pkg_name:
531
+ continue
532
+ license_val = pkg.get("license", "UNKNOWN")
533
+ if isinstance(license_val, dict):
534
+ license_val = license_val.get("type", "UNKNOWN")
535
+ if license_val.upper() in {c.upper() for c in copyleft}:
536
+ flagged.append({"package": pkg_name, "license": license_val})
537
+ return flagged
538
+
539
+
540
+ @app.command()
541
+ def report(
542
+ ctx: typer.Context,
543
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
544
+ strict: Annotated[bool, typer.Option("--strict", help="Exit 1 if any check has findings")] = False,
545
+ ) -> None:
546
+ """Run all security checks and produce an aggregated report."""
547
+ actx: AppContext = ctx.obj
548
+ out = actx.output
549
+ root = actx.project_root
550
+
551
+ if app_name:
552
+ actx.validate_app(app_name)
553
+ apps_to_check = [app_name]
554
+ else:
555
+ apps_to_check = actx.app_names
556
+
557
+ target = app_name or "all apps"
558
+ out.info(f"Running full security report for {target}...")
559
+
560
+ has_issues = False
561
+
562
+ # 1. Audit
563
+ out.info(" [1/4] Dependency audit...")
564
+ audit_counts = _collect_audit(root, apps_to_check)
565
+ audit_critical = audit_counts["critical"] + audit_counts["high"]
566
+ audit_ok = audit_counts["total"] == 0
567
+ if not audit_ok:
568
+ has_issues = True
569
+
570
+ # 2. Secrets
571
+ out.info(" [2/4] Secret scan...")
572
+ findings = _collect_secrets(root, app_name, actx.app_names, root / "packages")
573
+ secrets_count = len(findings)
574
+ secrets_ok = secrets_count == 0
575
+ if not secrets_ok:
576
+ has_issues = True
577
+
578
+ # 3. Headers (skip — requires running app, just report "skipped" unless single app)
579
+ headers_missing = -1 # -1 means skipped
580
+ headers_ok = True
581
+ if app_name:
582
+ app_info = actx.apps[app_name]
583
+ target_url = f"http://localhost:{app_info['port']}"
584
+ out.info(f" [3/4] Security headers ({target_url})...")
585
+ try:
586
+ result = run(
587
+ ["curl", "-sI", "--max-time", "5", target_url],
588
+ cwd=root,
589
+ capture=True,
590
+ timeout=10,
591
+ )
592
+ response_headers = result.stdout.lower()
593
+ headers_missing = sum(1 for h in _SECURITY_HEADERS if h not in response_headers)
594
+ headers_ok = headers_missing == 0
595
+ if not headers_ok:
596
+ has_issues = True
597
+ except Exception:
598
+ headers_missing = -1 # Unreachable
599
+ else:
600
+ out.info(" [3/4] Security headers... skipped (specify APP to check)")
601
+
602
+ # 4. Licenses
603
+ out.info(" [4/4] License compliance...")
604
+ license_flags = _collect_license_flags(root)
605
+ license_count = len(license_flags)
606
+ license_ok = license_count == 0
607
+ if not license_ok:
608
+ has_issues = True
609
+
610
+ # Build summary
611
+ checks: list[dict] = [
612
+ {
613
+ "check": "audit",
614
+ "status": "pass" if audit_ok else "fail",
615
+ "detail": f"{audit_counts['total']} vuln(s), {audit_critical} critical+high",
616
+ "counts": audit_counts,
617
+ },
618
+ {
619
+ "check": "secrets",
620
+ "status": "pass" if secrets_ok else "fail",
621
+ "detail": f"{secrets_count} finding(s)",
622
+ "count": secrets_count,
623
+ },
624
+ {
625
+ "check": "headers",
626
+ "status": "skip" if headers_missing == -1 else ("pass" if headers_ok else "fail"),
627
+ "detail": "skipped" if headers_missing == -1 else f"{headers_missing} missing",
628
+ "missing": headers_missing,
629
+ },
630
+ {
631
+ "check": "licenses",
632
+ "status": "pass" if license_ok else "fail",
633
+ "detail": f"{license_count} copyleft package(s)",
634
+ "count": license_count,
635
+ },
636
+ ]
637
+
638
+ rows: list[list[str]] = []
639
+ for c in checks:
640
+ status_map = {"pass": "[green]PASS[/green]", "fail": "[red]FAIL[/red]", "skip": "[dim]SKIP[/dim]"}
641
+ rows.append([c["check"], status_map.get(c["status"], c["status"]), c["detail"]])
642
+
643
+ out.table(
644
+ f"Security Report — {target}",
645
+ [("Check", "cyan"), ("Status", ""), ("Detail", "dim")],
646
+ rows,
647
+ data_for_json=checks,
648
+ )
649
+
650
+ passed = sum(1 for c in checks if c["status"] == "pass")
651
+ failed = sum(1 for c in checks if c["status"] == "fail")
652
+ skipped = sum(1 for c in checks if c["status"] == "skip")
653
+
654
+ if failed == 0:
655
+ out.success(f"{passed} passed, {skipped} skipped — no issues found")
656
+ else:
657
+ out.warn(f"{passed} passed, {failed} failed, {skipped} skipped")
658
+
659
+ if strict and has_issues:
660
+ raise typer.Exit(1) from None