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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- 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
|