clckernel 1.2.18 → 2.0.0

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 (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +27 -46
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +737 -309
  32. package/src/gitignore.js +144 -0
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -36
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
@@ -3,6 +3,16 @@
3
3
  Secrets & API Key Scanner
4
4
  Detects hardcoded secrets, API keys, tokens, and private keys in changed files.
5
5
  Self-contained: uses only Python stdlib (re, pathlib, subprocess, json).
6
+
7
+ Rules (sdds/secret-patterns) are tried in order; the first match labels the
8
+ line (one finding per line). They mirror RULES in scan_secrets.js exactly,
9
+ same regex sources and flags. The two generic rules skip placeholder values
10
+ (see _is_placeholder):
11
+ - Quoted Key Secret: a quoted JSON/YAML key ending in a secret word, with a
12
+ quoted value of 8+ non-space characters holding a letter and a digit;
13
+ - Dotenv Secret: in `.env`, `.env.*` and `*.env` files (not `.env.example`,
14
+ `.env.sample`, `.env.template`), `NAME=value` where a `_` segment of NAME
15
+ is a secret word and the value is not numeric-only or a boolean/null word.
6
16
  """
7
17
  from __future__ import annotations
8
18
 
@@ -10,33 +20,120 @@ import json
10
20
  import os
11
21
  import pathlib
12
22
  import re
13
- import subprocess
14
23
  import sys
24
+ from typing import Callable, List, NamedTuple, Optional
25
+
26
+ # Sibling helper shipped next to the guards; works both as a script and when
27
+ # loaded by audit.py through importlib.
28
+ _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
29
+ if _TOOLS_DIR not in sys.path:
30
+ sys.path.insert(0, _TOOLS_DIR)
31
+ import _changes # noqa: E402
32
+
33
+ SECRET_KEY_WORDS = (
34
+ r"api[_-]?key|apikey|secret[_-]?key|api[_-]?secret|client[_-]?secret|secret|"
35
+ r"access[_-]?token|auth[_-]?token|refresh[_-]?token|token|password|passwd|private[_-]?key"
36
+ )
37
+ DOTENV_SECRET_NAME = re.compile(
38
+ r"(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD|PASS|CREDENTIALS?|DSN|PRIVATE)(?:_|$)",
39
+ re.IGNORECASE,
40
+ )
41
+ PLACEHOLDER_WORDS = (
42
+ "xxx", "your", "changeme", "change_me", "change-me", "placeholder", "example",
43
+ "dummy", "replace", "insert", "redacted", "fake", "sample", "todo", "...", "***",
44
+ )
45
+ NON_SECRET_WORDS = ("true", "false", "yes", "no", "on", "off", "null", "none")
46
+ TEMPLATE_SEGMENTS = ("example", "sample", "template")
15
47
 
16
- # One finding per matching line: (line_no, label, matched_snippet)
17
- PATTERNS: list[tuple[str, str]] = [
18
- (r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----", "Private Key"),
19
- (r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
20
- (
21
- r"(?:api[_-]?key|apikey|secret[_-]?key|api[_-]?secret)\s*[=:]\s*['\"]"
22
- r"[A-Za-z0-9_\-]{16,}['\"]",
23
- "API Key",
24
- ),
25
- (
26
- r"(?:password|passwd|pwd)\s*[=:]\s*['\"][^'\"]{6,}['\"]",
27
- "Hardcoded Password",
48
+
49
+ def _is_placeholder(value: str) -> bool:
50
+ """True for values that are obviously not a real secret."""
51
+ v = value.lower()
52
+ if v.startswith("$") or "${" in v or "$(" in v or "{{" in v:
53
+ return True
54
+ if "<" in v and ">" in v:
55
+ return True
56
+ if re.match(r"^(.)\1*$", v):
57
+ return True
58
+ return any(w in v for w in PLACEHOLDER_WORDS)
59
+
60
+
61
+ def _dotenv_value(raw: str) -> str:
62
+ """The value of a dotenv assignment: quotes removed, or an unquoted value without its ` #` comment."""
63
+ quoted = re.match(r"^([\"'])([^\n]*?)\1", raw)
64
+ if quoted:
65
+ return quoted.group(2)
66
+ comment = re.search(r"\s#", raw)
67
+ return (raw if comment is None else raw[: comment.start()]).strip()
68
+
69
+
70
+ def _is_dotenv_file(filename: str) -> bool:
71
+ """`.env`, `.env.<suffix>` or `<name>.env`, unless a dot segment marks it as a template."""
72
+ base = os.path.basename(filename or "")
73
+ if not (base == ".env" or base.startswith(".env.") or (base.endswith(".env") and len(base) > 4)):
74
+ return False
75
+ return not any(seg in TEMPLATE_SEGMENTS for seg in base.lower().split("."))
76
+
77
+
78
+ def _accept_quoted(m: re.Match) -> bool:
79
+ value = m.group(3)
80
+ return bool(re.search(r"[A-Za-z]", value)) and bool(re.search(r"[0-9]", value)) and not _is_placeholder(value)
81
+
82
+
83
+ def _accept_dotenv(m: re.Match) -> bool:
84
+ if not DOTENV_SECRET_NAME.search(m.group(1)):
85
+ return False
86
+ value = _dotenv_value(m.group(2).strip())
87
+ if not value or re.match(r"^[0-9]+$", value) or value.lower() in NON_SECRET_WORDS:
88
+ return False
89
+ return not _is_placeholder(value)
90
+
91
+
92
+ class Rule(NamedTuple):
93
+ regex: "re.Pattern[str]"
94
+ label: str
95
+ accept: Optional[Callable[[re.Match], bool]] = None
96
+ dotenv_only: bool = False
97
+
98
+
99
+ def _rule(source: str, label: str, ignore_case: bool = False, **kw) -> Rule:
100
+ return Rule(re.compile(source, re.IGNORECASE if ignore_case else 0), label, **kw)
101
+
102
+
103
+ # One finding per matching line: {lineNo, col, label, snippet}; the snippet is redacted.
104
+ RULES: List[Rule] = [
105
+ _rule(r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----", "Private Key"),
106
+ _rule(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
107
+ _rule(
108
+ r"(?:api[_-]?key|apikey|secret[_-]?key|api[_-]?secret)\s*[=:]\s*['\"][A-Za-z0-9_\-]{16,}['\"]",
109
+ "API Key", ignore_case=True,
28
110
  ),
29
- (
30
- r"(?:secret|token|auth[_-]?token|bearer)\s*[=:]\s*['\"]"
31
- r"[A-Za-z0-9_\-\.]{16,}['\"]",
32
- "Secret/Token",
111
+ _rule(r"(?:password|passwd|pwd)\s*[=:]\s*['\"][^'\"]{6,}['\"]", "Hardcoded Password", ignore_case=True),
112
+ _rule(
113
+ r"(?:secret|token|auth[_-]?token|bearer)\s*[=:]\s*['\"][A-Za-z0-9_\-\.]{16,}['\"]",
114
+ "Secret/Token", ignore_case=True,
33
115
  ),
34
- (r"ghp_[A-Za-z0-9]{36}", "GitHub Personal Access Token"),
35
- (r"sk-[A-Za-z0-9]{20,}", "OpenAI/Stripe API Key"),
36
- (r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", "JWT Token"),
37
- (
116
+ _rule(r"ghp_[A-Za-z0-9]{36}", "GitHub Personal Access Token"),
117
+ _rule(r"sk-[A-Za-z0-9]{20,}", "OpenAI API Key"),
118
+ _rule(r"(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}", "Stripe API Key"),
119
+ _rule(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", "JWT Token"),
120
+ _rule(
38
121
  r"(?:SUPABASE|NEXT_PUBLIC|VITE)_\w*_KEY\s*=\s*['\"][^'\"]{8,}['\"]",
39
- "Framework Secret Key",
122
+ "Framework Secret Key", ignore_case=True,
123
+ ),
124
+ _rule(r"sk-ant-[A-Za-z0-9_-]{20,}", "Anthropic API Key"),
125
+ _rule(r"sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}", "OpenAI Project Key"),
126
+ _rule(r"AIza[0-9A-Za-z_-]{35}", "Google API Key"),
127
+ _rule(r"xox[abprs]-[0-9]+-[0-9A-Za-z-]{8,}", "Slack Token"),
128
+ _rule(r"gh[ousr]_[A-Za-z0-9]{36}", "GitHub Token"),
129
+ _rule(r"github_pat_[A-Za-z0-9_]{40,}", "GitHub Fine-Grained PAT"),
130
+ _rule(
131
+ r"([\"'])[A-Za-z0-9_.-]*(?:" + SECRET_KEY_WORDS + r")\1\s*:\s*([\"'])([^\"'\s]{8,})\2",
132
+ "Quoted Key Secret", ignore_case=True, accept=_accept_quoted,
133
+ ),
134
+ _rule(
135
+ r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^\n]*)$",
136
+ "Dotenv Secret", accept=_accept_dotenv, dotenv_only=True,
40
137
  ),
41
138
  ]
42
139
 
@@ -48,45 +145,56 @@ SKIP_EXTENSIONS = frozenset(
48
145
  }
49
146
  )
50
147
 
51
-
52
- def _get_changed_files(target_dir: str) -> list[str] | None:
53
- """Return list of changed files from git diff, or None on failure."""
54
- for ref in ("HEAD~1", "--cached"):
55
- try:
56
- args = ["git", "diff", "--name-only"]
57
- if ref == "--cached":
58
- args.append("--cached")
59
- else:
60
- args.append(ref)
61
- out = subprocess.check_output(
62
- args, cwd=target_dir,
63
- stderr=subprocess.DEVNULL, text=True,
64
- )
65
- files = [f.strip() for f in out.splitlines() if f.strip()]
66
- if files:
67
- return files
68
- except (subprocess.CalledProcessError, FileNotFoundError):
69
- continue
70
- return None
148
+ REDACTED = "…[REDACTED]"
71
149
 
72
150
 
73
- def _compile_patterns() -> list[tuple[re.Pattern, str]]:
74
- """Compile all secret-detection patterns once."""
75
- return [(re.compile(p), label) for p, label in PATTERNS]
76
-
77
-
78
- def check(target_dir: str | None = None) -> dict:
151
+ def _redact(line: str, rules: List[Rule]) -> str:
152
+ """
153
+ The line with every match of every applicable rule cut to its first 4
154
+ characters plus a marker, stripped and capped at 120 characters. Filters
155
+ are ignored here: over-redaction is safe. The full matched value never
156
+ leaves the guard.
157
+ """
158
+ out = line
159
+ for rule in rules:
160
+ out = rule.regex.sub(lambda m: m.group(0)[:4] + REDACTED, out)
161
+ return out.strip()[:120]
162
+
163
+
164
+ def find_secrets(text: str, filename: str) -> List[dict]:
165
+ """Pure matcher: the findings for `text` as the content of `filename` (repo-relative path)."""
166
+ dotenv = _is_dotenv_file(filename)
167
+ rules = [r for r in RULES if dotenv or not r.dotenv_only]
168
+ findings: List[dict] = []
169
+ # Split on "\n" only, like the JS guard, so line numbers and snippets match.
170
+ for line_no, line in enumerate(str(text).split("\n"), start=1):
171
+ for rule in rules:
172
+ m = rule.regex.search(line)
173
+ if m and (rule.accept is None or rule.accept(m)):
174
+ findings.append({
175
+ "lineNo": line_no,
176
+ "col": m.start(),
177
+ "label": rule.label,
178
+ "snippet": _redact(line, rules),
179
+ })
180
+ break # one finding per line
181
+ return findings
182
+
183
+
184
+ def check(target_dir: Optional[str] = None) -> dict:
79
185
  """
80
186
  Scan changed files for secrets and API keys.
81
187
 
82
188
  Returns:
83
189
  dict with keys: exit_code, skipped, name, data
84
- data.findings maps filepath -> list of {lineNo, label, snippet}
190
+ data.findings maps filepath -> list of {lineNo, col, label, snippet}
85
191
  """
86
192
  dir_path = target_dir or os.getcwd()
87
193
 
88
- files = _get_changed_files(dir_path)
89
- if files is None:
194
+ # The snapshot being verified: the index, or HEAD when nothing is staged.
195
+ changes = _changes.changed_files(dir_path)
196
+ mode, files = changes["mode"], changes["files"]
197
+ if mode is None:
90
198
  return {
91
199
  "exit_code": 0, "skipped": True, "name": "scan_secrets",
92
200
  "data": {"findings": {}, "message": "No git diff available"},
@@ -98,35 +206,18 @@ def check(target_dir: str | None = None) -> dict:
98
206
  "data": {"findings": {}, "message": "No changed files to scan"},
99
207
  }
100
208
 
101
- compiled = _compile_patterns()
102
- findings: dict[str, list[dict]] = {}
209
+ findings: dict = {}
103
210
 
104
211
  for filepath in files:
105
212
  ext = pathlib.Path(filepath).suffix
106
213
  if ext in SKIP_EXTENSIONS:
107
214
  continue
108
215
 
109
- full_path = pathlib.Path(dir_path) / filepath
110
- if not full_path.exists():
216
+ content = _changes.read_file(dir_path, filepath, mode)
217
+ if content is None:
111
218
  continue
112
219
 
113
- try:
114
- content = full_path.read_text(encoding="utf-8", errors="replace")
115
- except (OSError, PermissionError):
116
- continue
117
-
118
- file_hits: list[dict] = []
119
- for line_no, line in enumerate(content.splitlines(), start=1):
120
- for regex, label in compiled:
121
- m = regex.search(line)
122
- if m:
123
- file_hits.append({
124
- "lineNo": line_no,
125
- "label": label,
126
- "snippet": line.strip()[:120],
127
- })
128
- break # one finding per line
129
-
220
+ file_hits = find_secrets(content, filepath)
130
221
  if file_hits:
131
222
  findings[filepath] = file_hits
132
223