clckernel 1.2.19 → 2.0.1
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.
- package/.agents/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/deprecated.js +13 -0
- package/package.json +14 -4
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +23 -38
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +734 -302
- package/src/gitignore.js +60 -14
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -38
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
package/tools/scan_secrets.py
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
),
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
(r"
|
|
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
|
|
74
|
-
"""
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
if
|
|
216
|
+
content = _changes.read_file(dir_path, filepath, mode)
|
|
217
|
+
if content is None:
|
|
111
218
|
continue
|
|
112
219
|
|
|
113
|
-
|
|
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
|
|