codeabc 0.1.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.
- backend/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Security-pattern scanner: surface common dangerous patterns in code.
|
|
2
|
+
|
|
3
|
+
A non-programmer reviewing code — a founder auditing outsourced work, say —
|
|
4
|
+
can't easily spot subtle security issues, but certain patterns are clear red
|
|
5
|
+
flags: hardcoded passwords, unvalidated shell commands, dangerous deserialisation.
|
|
6
|
+
|
|
7
|
+
Four categories:
|
|
8
|
+
|
|
9
|
+
hardcoded_secret — passwords / tokens / API keys embedded directly in code
|
|
10
|
+
instead of env vars or a secrets store
|
|
11
|
+
dangerous_call — eval() / exec() / pickle.loads() / yaml.load() without
|
|
12
|
+
SafeLoader — calls where untrusted input can run arbitrary
|
|
13
|
+
code or deserialise untrusted data unsafely
|
|
14
|
+
shell_injection — os.system() / subprocess with shell=True — OS commands
|
|
15
|
+
assembled from dynamic input can execute arbitrary shell code
|
|
16
|
+
debug_mode — DEBUG=True / app.debug=True / app.run(debug=True) outside
|
|
17
|
+
test files — settings that leak stack traces in production
|
|
18
|
+
|
|
19
|
+
Each finding carries the file path, line number, the matching line snippet
|
|
20
|
+
(truncated to 120 chars), category, and a plain-language explanation for a
|
|
21
|
+
non-programmer.
|
|
22
|
+
|
|
23
|
+
Limitations:
|
|
24
|
+
* Regex-based — cannot track data flow. A variable ultimately controlled by
|
|
25
|
+
user input may not match these patterns; a pattern in a comment or test
|
|
26
|
+
fixture may fire spuriously.
|
|
27
|
+
* Absence of findings does not mean the code is secure.
|
|
28
|
+
* Best treated as "flags for human review", not definitive security verdicts.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Category: hardcoded_secret
|
|
37
|
+
# Variables whose names suggest they hold a credential are assigned a
|
|
38
|
+
# non-trivial string literal rather than read from env vars or config.
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Step 1: match any identifier (possibly attribute like self.key) assigned a
|
|
42
|
+
# string literal that is at least 6 chars. Group 1 = varname, group 2 = quote
|
|
43
|
+
# char, group 3 = string content. Optional type annotation handled inline.
|
|
44
|
+
_ASSIGN_STR_RE = re.compile(
|
|
45
|
+
r"([\w.]+)" # variable / attribute name
|
|
46
|
+
r"(?:\s*:\s*\S+)?" # optional type annotation (: str, : bytes, …)
|
|
47
|
+
r"\s*=\s*" # assignment
|
|
48
|
+
r"(['\"])([^'\"]{6,})\2", # string literal ≥ 6 chars
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Step 2: check if the variable name (lowercased, underscores/dots stripped)
|
|
52
|
+
# contains one of these secret-component words.
|
|
53
|
+
_SECRET_KEYWORDS: frozenset[str] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"password",
|
|
56
|
+
"passwd",
|
|
57
|
+
"passphrase",
|
|
58
|
+
"token",
|
|
59
|
+
"apitoken",
|
|
60
|
+
"accesstoken",
|
|
61
|
+
"refreshtoken",
|
|
62
|
+
"secret",
|
|
63
|
+
"clientsecret",
|
|
64
|
+
"jwtsecret",
|
|
65
|
+
"apikey",
|
|
66
|
+
"apisecret",
|
|
67
|
+
"secretkey",
|
|
68
|
+
"authkey",
|
|
69
|
+
"authsecret",
|
|
70
|
+
"privatekey",
|
|
71
|
+
"accesskey",
|
|
72
|
+
"signingkey",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Obvious placeholder markers — these are NOT real secrets.
|
|
77
|
+
_PLACEHOLDER_RE = re.compile(
|
|
78
|
+
r"(?i)(your[_\-]|<[a-z]|{[a-z]|example|placeholder|dummy|fake|test|changeme|replace)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# All-same-character strings are clearly placeholders (e.g. "xxxxxxxxxxxx").
|
|
82
|
+
_UNIFORM_RE = re.compile(r"^(.)\1{5,}$")
|
|
83
|
+
|
|
84
|
+
_SECRET_REASON = (
|
|
85
|
+
"硬编码的凭据:把密码/密钥/Token 直接写进代码里,一旦代码泄漏"
|
|
86
|
+
"(GitHub 提交历史、日志、截图)这个凭据就对所有人可见。"
|
|
87
|
+
"应该改成从环境变量读取,或者用 Vault / Secrets Manager 管理。"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_real_secret(value: str) -> bool:
|
|
92
|
+
"""Return True if the string literal looks like a real credential, not a placeholder."""
|
|
93
|
+
if not value or len(value) < 6:
|
|
94
|
+
return False
|
|
95
|
+
if _PLACEHOLDER_RE.search(value):
|
|
96
|
+
return False
|
|
97
|
+
if _UNIFORM_RE.match(value.strip()):
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Category: dangerous_call
|
|
104
|
+
# eval / exec / pickle.loads / yaml.load without SafeLoader.
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
_EVAL_RE = re.compile(r"\beval\s*\(")
|
|
108
|
+
_EXEC_RE = re.compile(r"\bexec\s*\(")
|
|
109
|
+
_PICKLE_RE = re.compile(r"\bpickle\.(loads?)\s*\(")
|
|
110
|
+
# yaml.load( but NOT yaml.safe_load( (safe_load doesn't match \byaml\.load\b)
|
|
111
|
+
_YAML_LOAD_RE = re.compile(r"\byaml\.load\s*\(")
|
|
112
|
+
|
|
113
|
+
_DANGEROUS_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
114
|
+
(
|
|
115
|
+
_EVAL_RE,
|
|
116
|
+
"eval() 把字符串当代码执行——如果字符串来自用户输入或网络,"
|
|
117
|
+
"攻击者可以借此在服务器上运行任意代码。",
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
_EXEC_RE,
|
|
121
|
+
"exec() 与 eval() 一样危险——动态执行任意代码,输入来源可控时是严重的远程代码执行风险。",
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
_PICKLE_RE,
|
|
125
|
+
"pickle.load(s)() 会执行对象里嵌入的 Python 代码——"
|
|
126
|
+
"反序列化不可信来源的 pickle 数据等价于对攻击者开放一个 shell。",
|
|
127
|
+
),
|
|
128
|
+
(
|
|
129
|
+
_YAML_LOAD_RE,
|
|
130
|
+
"yaml.load() 在 PyYAML 5.1 之前默认允许执行任意 Python 对象——"
|
|
131
|
+
"反序列化不可信 YAML 可以在服务器上运行任意代码。"
|
|
132
|
+
"改用 yaml.safe_load() 或显式传 Loader=yaml.SafeLoader。",
|
|
133
|
+
),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Category: shell_injection
|
|
138
|
+
# os.system / os.popen / subprocess + shell=True.
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
_OS_SYSTEM_RE = re.compile(r"\bos\.(system|popen)\s*\(")
|
|
142
|
+
_SUBPROCESS_SHELL_RE = re.compile(r"\bshell\s*=\s*True\b")
|
|
143
|
+
|
|
144
|
+
_OS_SYSTEM_REASON = (
|
|
145
|
+
"os.system() / os.popen() 把字符串作为 shell 命令执行——"
|
|
146
|
+
"如果命令中包含来自用户输入或外部数据的部分,攻击者可以注入分号、管道等"
|
|
147
|
+
"执行任意命令(命令注入)。"
|
|
148
|
+
)
|
|
149
|
+
_SUBPROCESS_SHELL_REASON = (
|
|
150
|
+
"subprocess 使用 shell=True 时,参数经由 shell 解释——"
|
|
151
|
+
"如果命令字符串含有未经清洗的用户输入,攻击者可注入任意 shell 命令。"
|
|
152
|
+
"安全做法是 shell=False(默认)并把参数拆成列表传递。"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Category: debug_mode
|
|
157
|
+
# DEBUG=True / app.debug=True / app.run(debug=True) outside test files.
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
_DEBUG_TRUE_RE = re.compile(r"\bDEBUG\s*=\s*True\b")
|
|
161
|
+
_APP_DEBUG_RE = re.compile(r"\bapp\.(debug)\s*=\s*True\b")
|
|
162
|
+
_APP_RUN_DEBUG_RE = re.compile(r"\bapp\.run\s*\([^)]*debug\s*=\s*True")
|
|
163
|
+
|
|
164
|
+
_DEBUG_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
165
|
+
(
|
|
166
|
+
_DEBUG_TRUE_RE,
|
|
167
|
+
"DEBUG=True:调试模式开着——在生产环境中会把详细的错误栈(包括源代码、变量值)"
|
|
168
|
+
"暴露给用户,是严重的信息泄露。上线前必须改为 DEBUG=False 或通过环境变量控制。",
|
|
169
|
+
),
|
|
170
|
+
(
|
|
171
|
+
_APP_DEBUG_RE,
|
|
172
|
+
"app.debug=True:Flask/类似框架的调试模式——会在浏览器里显示交互式调试器,"
|
|
173
|
+
"生产环境中这允许任何用户在服务器上执行任意 Python 代码。",
|
|
174
|
+
),
|
|
175
|
+
(
|
|
176
|
+
_APP_RUN_DEBUG_RE,
|
|
177
|
+
"app.run(debug=True):以调试模式启动服务——同上,只应出现在本地开发脚本,"
|
|
178
|
+
"绝不应进入生产部署代码。",
|
|
179
|
+
),
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Helpers
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
_SNIPPET_MAX = 120
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _snip(line: str) -> str:
|
|
190
|
+
"""Return the line stripped of leading whitespace, capped at _SNIPPET_MAX chars."""
|
|
191
|
+
stripped = line.strip()
|
|
192
|
+
return stripped[:_SNIPPET_MAX] + ("…" if len(stripped) > _SNIPPET_MAX else "")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_comment(line: str, lang: str) -> bool:
|
|
196
|
+
"""True if the entire line is a comment (not just partially)."""
|
|
197
|
+
s = line.lstrip()
|
|
198
|
+
if not s:
|
|
199
|
+
return False
|
|
200
|
+
if lang in ("python",):
|
|
201
|
+
return s.startswith("#")
|
|
202
|
+
# JS / TS / Java / Go etc.
|
|
203
|
+
return s.startswith("//") or s.startswith("/*") or s.startswith("*")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_test_file(path: str) -> bool:
|
|
207
|
+
"""True if the path looks like a test or fixture file."""
|
|
208
|
+
p = path.replace("\\", "/").lower()
|
|
209
|
+
parts = p.split("/")
|
|
210
|
+
name = parts[-1]
|
|
211
|
+
if any(d in parts[:-1] for d in ("test", "tests", "__tests__", "spec", "specs", "fixtures")):
|
|
212
|
+
return True
|
|
213
|
+
stem = name.rsplit(".", 1)[0] if "." in name else name
|
|
214
|
+
return (
|
|
215
|
+
stem.startswith("test_")
|
|
216
|
+
or stem.endswith("_test")
|
|
217
|
+
or stem.endswith(".test")
|
|
218
|
+
or stem.endswith(".spec")
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _lang_for(path: str) -> str:
|
|
223
|
+
"""Coarse language label from file extension."""
|
|
224
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
225
|
+
if ext in ("py",):
|
|
226
|
+
return "python"
|
|
227
|
+
if ext in ("js", "ts", "jsx", "tsx", "mjs", "cjs"):
|
|
228
|
+
return "javascript"
|
|
229
|
+
return ext
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Public API
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def scan_security(file_contents: dict[str, str], *, limit: int = 30) -> dict:
|
|
238
|
+
"""Scan file contents for common security-risk patterns.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
file_contents: mapping of path to file text.
|
|
242
|
+
limit: cap on total findings returned (keep the report scannable).
|
|
243
|
+
|
|
244
|
+
Returns::
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
"total": int, # findings before capping
|
|
248
|
+
"critical": int, # hardcoded_secret + dangerous_call count
|
|
249
|
+
"findings": [ # capped to *limit*, sorted critical-first
|
|
250
|
+
{
|
|
251
|
+
"file": str,
|
|
252
|
+
"line": int,
|
|
253
|
+
"category": str, # one of the four category names above
|
|
254
|
+
"snippet": str, # truncated line text
|
|
255
|
+
"reason": str, # plain-language explanation
|
|
256
|
+
}, …
|
|
257
|
+
],
|
|
258
|
+
"notes": [str, …], # human-readable summary bullets
|
|
259
|
+
}
|
|
260
|
+
"""
|
|
261
|
+
findings: list[dict] = []
|
|
262
|
+
|
|
263
|
+
for path, content in file_contents.items():
|
|
264
|
+
if not content:
|
|
265
|
+
continue
|
|
266
|
+
lang = _lang_for(path)
|
|
267
|
+
is_test = _is_test_file(path)
|
|
268
|
+
|
|
269
|
+
for lineno, line in enumerate(content.splitlines(), start=1):
|
|
270
|
+
if _is_comment(line, lang):
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# ── hardcoded_secret ──────────────────────────────────────────
|
|
274
|
+
for m in _ASSIGN_STR_RE.finditer(line):
|
|
275
|
+
varname = m.group(1)
|
|
276
|
+
value = m.group(3)
|
|
277
|
+
# normalise to bare lowercase (strip _ and . separators) then
|
|
278
|
+
# check whether any secret keyword appears as a substring
|
|
279
|
+
norm = varname.lower().replace("_", "").replace(".", "")
|
|
280
|
+
if any(k in norm for k in _SECRET_KEYWORDS) and _is_real_secret(value):
|
|
281
|
+
findings.append(
|
|
282
|
+
{
|
|
283
|
+
"file": path,
|
|
284
|
+
"line": lineno,
|
|
285
|
+
"category": "hardcoded_secret",
|
|
286
|
+
"snippet": _snip(line),
|
|
287
|
+
"reason": _SECRET_REASON,
|
|
288
|
+
"is_test": is_test,
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
break # one finding per line is enough
|
|
292
|
+
|
|
293
|
+
# ── dangerous_call ────────────────────────────────────────────
|
|
294
|
+
for pattern, reason in _DANGEROUS_PATTERNS:
|
|
295
|
+
if pattern.search(line):
|
|
296
|
+
findings.append(
|
|
297
|
+
{
|
|
298
|
+
"file": path,
|
|
299
|
+
"line": lineno,
|
|
300
|
+
"category": "dangerous_call",
|
|
301
|
+
"snippet": _snip(line),
|
|
302
|
+
"reason": reason,
|
|
303
|
+
"is_test": is_test,
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
break # first matching pattern per line
|
|
307
|
+
|
|
308
|
+
# ── shell_injection ───────────────────────────────────────────
|
|
309
|
+
if _OS_SYSTEM_RE.search(line):
|
|
310
|
+
findings.append(
|
|
311
|
+
{
|
|
312
|
+
"file": path,
|
|
313
|
+
"line": lineno,
|
|
314
|
+
"category": "shell_injection",
|
|
315
|
+
"snippet": _snip(line),
|
|
316
|
+
"reason": _OS_SYSTEM_REASON,
|
|
317
|
+
"is_test": is_test,
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
elif _SUBPROCESS_SHELL_RE.search(line):
|
|
321
|
+
findings.append(
|
|
322
|
+
{
|
|
323
|
+
"file": path,
|
|
324
|
+
"line": lineno,
|
|
325
|
+
"category": "shell_injection",
|
|
326
|
+
"snippet": _snip(line),
|
|
327
|
+
"reason": _SUBPROCESS_SHELL_REASON,
|
|
328
|
+
"is_test": is_test,
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# ── debug_mode (skip test files entirely — intentional there) ──
|
|
333
|
+
if not is_test:
|
|
334
|
+
for pattern, reason in _DEBUG_PATTERNS:
|
|
335
|
+
if pattern.search(line):
|
|
336
|
+
findings.append(
|
|
337
|
+
{
|
|
338
|
+
"file": path,
|
|
339
|
+
"line": lineno,
|
|
340
|
+
"category": "debug_mode",
|
|
341
|
+
"snippet": _snip(line),
|
|
342
|
+
"reason": reason,
|
|
343
|
+
"is_test": False,
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
# Deduplicate: same file + line can match at most one finding per category.
|
|
349
|
+
seen: set[tuple] = set()
|
|
350
|
+
unique: list[dict] = []
|
|
351
|
+
for f in findings:
|
|
352
|
+
key = (f["file"], f["line"], f["category"])
|
|
353
|
+
if key not in seen:
|
|
354
|
+
seen.add(key)
|
|
355
|
+
unique.append(f)
|
|
356
|
+
|
|
357
|
+
total = len(unique)
|
|
358
|
+
|
|
359
|
+
# Sort: non-test critical findings first, then others; within each group
|
|
360
|
+
# by category weight then file+line.
|
|
361
|
+
_WEIGHT = {"hardcoded_secret": 0, "dangerous_call": 1, "shell_injection": 2, "debug_mode": 3}
|
|
362
|
+
unique.sort(
|
|
363
|
+
key=lambda f: (
|
|
364
|
+
int(f.get("is_test", False)), # non-test before test
|
|
365
|
+
_WEIGHT.get(f["category"], 9),
|
|
366
|
+
f["file"],
|
|
367
|
+
f["line"],
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
critical_cats = {"hardcoded_secret", "dangerous_call"}
|
|
372
|
+
critical = sum(1 for f in unique if f["category"] in critical_cats and not f.get("is_test"))
|
|
373
|
+
|
|
374
|
+
# Strip the internal `is_test` flag before returning.
|
|
375
|
+
capped = [{k: v for k, v in f.items() if k != "is_test"} for f in unique[:limit]]
|
|
376
|
+
|
|
377
|
+
notes = _build_notes(total, critical, unique)
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
"total": total,
|
|
381
|
+
"critical": critical,
|
|
382
|
+
"findings": capped,
|
|
383
|
+
"notes": notes,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _build_notes(total: int, critical: int, findings: list[dict]) -> list[str]:
|
|
388
|
+
if total == 0:
|
|
389
|
+
return ["未发现常见安全风险模式,代码没有明显的高危信号。"]
|
|
390
|
+
notes = []
|
|
391
|
+
if critical:
|
|
392
|
+
notes.append(
|
|
393
|
+
f"发现 {critical} 处高危问题(硬编码凭据或危险调用),建议在上线或开源前逐一排查。"
|
|
394
|
+
)
|
|
395
|
+
cats: dict[str, int] = {}
|
|
396
|
+
for f in findings:
|
|
397
|
+
cats[f["category"]] = cats.get(f["category"], 0) + 1
|
|
398
|
+
cat_zh = {
|
|
399
|
+
"hardcoded_secret": "硬编码凭据",
|
|
400
|
+
"dangerous_call": "危险调用",
|
|
401
|
+
"shell_injection": "Shell 注入",
|
|
402
|
+
"debug_mode": "调试模式残留",
|
|
403
|
+
}
|
|
404
|
+
for cat, count in sorted(cats.items(), key=lambda x: _WEIGHT.get(x[0], 9)):
|
|
405
|
+
notes.append(f"{cat_zh.get(cat, cat)}:{count} 处")
|
|
406
|
+
if total > 10:
|
|
407
|
+
notes.append("安全问题较多——建议优先清理高危项,再结合安全审计工具做全面扫描。")
|
|
408
|
+
notes.append("注意:这是静态模式匹配,无法追踪数据流。测试文件里的同类模式危险性通常较低。")
|
|
409
|
+
return notes
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def render_security_markdown(project_name: str, data: dict | None) -> str:
|
|
413
|
+
"""Render the security scan as a Markdown section, or ``""`` if clean."""
|
|
414
|
+
if not data or not data.get("findings"):
|
|
415
|
+
return ""
|
|
416
|
+
total = data.get("total", 0)
|
|
417
|
+
critical = data.get("critical", 0)
|
|
418
|
+
lines = [
|
|
419
|
+
f"# 安全风险({project_name})",
|
|
420
|
+
"",
|
|
421
|
+
f"> 发现 {total} 处安全模式,其中 {critical} 处高危(硬编码凭据/危险调用)。"
|
|
422
|
+
" 静态检测,建议逐一核查上下文。",
|
|
423
|
+
"",
|
|
424
|
+
]
|
|
425
|
+
lines.extend(f"- {note}" for note in data.get("notes", []))
|
|
426
|
+
|
|
427
|
+
_CAT_ZH = {
|
|
428
|
+
"hardcoded_secret": "⚠ 硬编码凭据",
|
|
429
|
+
"dangerous_call": "⚠ 危险调用",
|
|
430
|
+
"shell_injection": "Shell 注入",
|
|
431
|
+
"debug_mode": "调试模式残留",
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
lines.append("")
|
|
435
|
+
lines.append("## 发现清单")
|
|
436
|
+
lines.append("")
|
|
437
|
+
for f in data.get("findings", []):
|
|
438
|
+
cat = _CAT_ZH.get(f["category"], f["category"])
|
|
439
|
+
lines.append(f"- `{f['file']}:{f['line']}` [{cat}] `{f['snippet']}`")
|
|
440
|
+
lines.append(f" > {f['reason']}")
|
|
441
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# Expose the category weight for tests / external sorting.
|
|
445
|
+
_WEIGHT = {"hardcoded_secret": 0, "dangerous_call": 1, "shell_injection": 2, "debug_mode": 3}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Tunable-settings map — the values baked into the code you might want to change.
|
|
2
|
+
|
|
3
|
+
The env-var surface answers "what do I set *outside* the code before it runs".
|
|
4
|
+
This answers the opposite question: "what is set *inside* the code that I might
|
|
5
|
+
want to tweak". Almost every project hard-codes a handful of knobs — a retry
|
|
6
|
+
count, a timeout, a default model name, a page size, a feature flag — as
|
|
7
|
+
module-level constants. For a non-programmer those uppercase lines are the
|
|
8
|
+
closest thing to a settings panel the project has, but they are scattered across
|
|
9
|
+
files and easy to miss.
|
|
10
|
+
|
|
11
|
+
This collects them straight from the source CodeABC already loaded — no LLM,
|
|
12
|
+
nothing to install — by recognising the two ways a constant is conventionally
|
|
13
|
+
declared with a *literal* value:
|
|
14
|
+
|
|
15
|
+
Python ``MAX_RETRIES = 3`` / ``DEFAULT_MODEL: str = "gpt-5"`` (module level)
|
|
16
|
+
JS/TS ``const PAGE_SIZE = 20`` / ``export const DEBUG = false`` (top level)
|
|
17
|
+
|
|
18
|
+
Only ``UPPER_SNAKE_CASE`` names assigned a literal scalar or simple literal
|
|
19
|
+
collection are kept — that is the universal "this is a constant" convention, and
|
|
20
|
+
the literal test cleanly drops type aliases (``Vector = list[float]``) and
|
|
21
|
+
computed values (``TIMEOUT = BASE * 2``), which are not knobs a reader can just
|
|
22
|
+
edit.
|
|
23
|
+
|
|
24
|
+
:func:`find_tunable_settings` is pure over the file contents, so it is
|
|
25
|
+
unit-testable with plain strings and needs no repository.
|
|
26
|
+
|
|
27
|
+
Limitations (kept honest on purpose):
|
|
28
|
+
|
|
29
|
+
* Module/top level only. A constant defined inside a function or class body is
|
|
30
|
+
local to that scope and not a project-wide knob, so it is skipped.
|
|
31
|
+
* Literal values only. ``RETRIES = int(os.getenv("RETRIES", 3))`` is reported
|
|
32
|
+
by the env-var map instead; here only the bare literal forms are caught.
|
|
33
|
+
* Names must be ``UPPER_SNAKE_CASE``. Lower-case module globals are too easily
|
|
34
|
+
ordinary state to flag as settings safely.
|
|
35
|
+
* A file that does not parse as Python is skipped for the AST pass; the JS/TS
|
|
36
|
+
pass is a conservative line-anchored match and ignores indented constants.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import ast
|
|
42
|
+
import re
|
|
43
|
+
|
|
44
|
+
_PY_SUFFIX = ".py"
|
|
45
|
+
_JS_SUFFIXES = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs")
|
|
46
|
+
|
|
47
|
+
# A constant name: all-caps, digits and underscores, at least two chars so a
|
|
48
|
+
# lone loop variable like ``N`` is not mistaken for a setting.
|
|
49
|
+
_CONST_NAME = re.compile(r"^[A-Z][A-Z0-9_]*[A-Z0-9]$")
|
|
50
|
+
|
|
51
|
+
# Top-level JS/TS const with a literal scalar RHS. Anchored at line start (after
|
|
52
|
+
# optional ``export``) so indented constants inside functions are ignored.
|
|
53
|
+
_JS_CONST = re.compile(
|
|
54
|
+
r"""^(?:export\s+)?const\s+
|
|
55
|
+
([A-Z][A-Z0-9_]*[A-Z0-9]) # NAME (UPPER_SNAKE)
|
|
56
|
+
\s*(?::[^=]+?)?\s*=\s* # optional : type =
|
|
57
|
+
(.+?)\s*;?\s*$ # the value, up to an optional ;
|
|
58
|
+
""",
|
|
59
|
+
re.MULTILINE | re.VERBOSE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# A bare JS literal we are willing to report: number, quoted string, boolean.
|
|
63
|
+
_JS_NUMBER = re.compile(r"^-?\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?$")
|
|
64
|
+
_JS_STRING = re.compile(r"""^(['"`])(.*)\1$""", re.DOTALL)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_const_name(name: str) -> bool:
|
|
68
|
+
return bool(_CONST_NAME.match(name))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _kind_of(value: object) -> str:
|
|
72
|
+
# bool first: it is a subclass of int.
|
|
73
|
+
if isinstance(value, bool):
|
|
74
|
+
return "flag"
|
|
75
|
+
if isinstance(value, (int, float)):
|
|
76
|
+
return "number"
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return "text"
|
|
79
|
+
if isinstance(value, (list, tuple, set)):
|
|
80
|
+
return "list"
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
return "mapping"
|
|
83
|
+
return "other"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _truncate(text: str, width: int = 60) -> str:
|
|
87
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _render_py_value(value: object) -> str:
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
return '"' + _truncate(value) + '"'
|
|
93
|
+
return _truncate(repr(value))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _py_constants(content: str) -> list[dict]:
|
|
97
|
+
"""Module-level UPPER_SNAKE constants assigned a literal, in source order."""
|
|
98
|
+
try:
|
|
99
|
+
tree = ast.parse(content)
|
|
100
|
+
except (SyntaxError, ValueError):
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
found: list[dict] = []
|
|
104
|
+
# Module body only — nested scopes are not project-wide settings.
|
|
105
|
+
for stmt in tree.body:
|
|
106
|
+
if isinstance(stmt, ast.Assign):
|
|
107
|
+
targets = stmt.targets
|
|
108
|
+
value_node = stmt.value
|
|
109
|
+
elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
|
|
110
|
+
targets = [stmt.target]
|
|
111
|
+
value_node = stmt.value
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# A single simple name target only (skip ``A = B = 1`` and tuple unpack).
|
|
116
|
+
if len(targets) != 1 or not isinstance(targets[0], ast.Name):
|
|
117
|
+
continue
|
|
118
|
+
name = targets[0].id
|
|
119
|
+
if not _is_const_name(name):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
value = ast.literal_eval(value_node)
|
|
124
|
+
except (ValueError, SyntaxError, TypeError):
|
|
125
|
+
# Not a pure literal: type alias, computed value, call — not a knob.
|
|
126
|
+
continue
|
|
127
|
+
if value is None:
|
|
128
|
+
continue # a None sentinel is not a value a reader tweaks
|
|
129
|
+
|
|
130
|
+
found.append(
|
|
131
|
+
{
|
|
132
|
+
"name": name,
|
|
133
|
+
"kind": _kind_of(value),
|
|
134
|
+
"value": _render_py_value(value),
|
|
135
|
+
"line": stmt.lineno,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return found
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _js_constants(content: str) -> list[dict]:
|
|
142
|
+
"""Top-level UPPER_SNAKE consts assigned a literal scalar, in source order."""
|
|
143
|
+
found: list[dict] = []
|
|
144
|
+
for match in _JS_CONST.finditer(content):
|
|
145
|
+
name, raw = match.group(1), match.group(2).strip()
|
|
146
|
+
# Drop a trailing comment on the same line.
|
|
147
|
+
raw = re.split(r"\s+//", raw, maxsplit=1)[0].strip().rstrip(";").strip()
|
|
148
|
+
|
|
149
|
+
if _JS_NUMBER.match(raw):
|
|
150
|
+
kind, value = "number", raw
|
|
151
|
+
elif raw in ("true", "false"):
|
|
152
|
+
kind, value = "flag", raw
|
|
153
|
+
else:
|
|
154
|
+
string = _JS_STRING.match(raw)
|
|
155
|
+
if not string or "${" in raw:
|
|
156
|
+
# Objects, arrays, calls, template interpolation: not a scalar knob.
|
|
157
|
+
continue
|
|
158
|
+
kind, value = "text", '"' + _truncate(string.group(2)) + '"'
|
|
159
|
+
|
|
160
|
+
line = content.count("\n", 0, match.start()) + 1
|
|
161
|
+
found.append({"name": name, "kind": kind, "value": value, "line": line})
|
|
162
|
+
return found
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def find_tunable_settings(file_contents: dict[str, str], *, limit: int = 50) -> dict:
|
|
166
|
+
"""Collect the literal UPPER_SNAKE constants a project hard-codes.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
170
|
+
limit: how many settings to return in the sorted list.
|
|
171
|
+
|
|
172
|
+
Returns ``{"total", "kinds", "settings"}`` where each setting is
|
|
173
|
+
``{"name", "kind", "value", "path", "line"}`` and ``kind`` is one of
|
|
174
|
+
``number`` / ``text`` / ``flag`` / ``list`` / ``mapping``.
|
|
175
|
+
"""
|
|
176
|
+
seen: set[tuple[str, str, int]] = set()
|
|
177
|
+
settings: list[dict] = []
|
|
178
|
+
|
|
179
|
+
for path, content in file_contents.items():
|
|
180
|
+
if not content:
|
|
181
|
+
continue
|
|
182
|
+
if path.endswith(_PY_SUFFIX):
|
|
183
|
+
entries = _py_constants(content)
|
|
184
|
+
elif path.endswith(_JS_SUFFIXES):
|
|
185
|
+
entries = _js_constants(content)
|
|
186
|
+
else:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
for entry in entries:
|
|
190
|
+
key = (path, entry["name"], entry["line"])
|
|
191
|
+
if key in seen:
|
|
192
|
+
continue
|
|
193
|
+
seen.add(key)
|
|
194
|
+
settings.append({**entry, "path": path})
|
|
195
|
+
|
|
196
|
+
settings.sort(key=lambda s: (s["path"], s["line"]))
|
|
197
|
+
kinds = sorted({s["kind"] for s in settings})
|
|
198
|
+
return {"total": len(settings), "kinds": kinds, "settings": settings[:limit]}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_KIND_LABEL = {
|
|
202
|
+
"number": "数字",
|
|
203
|
+
"text": "文本",
|
|
204
|
+
"flag": "开关",
|
|
205
|
+
"list": "列表",
|
|
206
|
+
"mapping": "映射",
|
|
207
|
+
"other": "其它",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_settings_markdown(project_name: str, data: dict | None) -> str:
|
|
212
|
+
"""Render the tunable-settings map as Markdown, or ``""`` if none were found."""
|
|
213
|
+
settings = (data or {}).get("settings") or []
|
|
214
|
+
if not settings:
|
|
215
|
+
return ""
|
|
216
|
+
|
|
217
|
+
lines = [
|
|
218
|
+
f"# {project_name} — 能改哪些值(可调设置)",
|
|
219
|
+
"",
|
|
220
|
+
"> 环境变量是“在代码外面要设置什么”,这里是“代码里面写死、你可能想改的值”——"
|
|
221
|
+
"作者用全大写常量声明的重试次数、超时、默认模型、开关之类。"
|
|
222
|
+
"改它们通常不用读懂整段代码,但请改前看清它在哪、是什么类型。",
|
|
223
|
+
"",
|
|
224
|
+
]
|
|
225
|
+
current_path = None
|
|
226
|
+
for setting in settings:
|
|
227
|
+
if setting["path"] != current_path:
|
|
228
|
+
current_path = setting["path"]
|
|
229
|
+
lines.append(f"## `{current_path}`")
|
|
230
|
+
label = _KIND_LABEL.get(setting["kind"], setting["kind"])
|
|
231
|
+
lines.append(
|
|
232
|
+
f"- `{setting['name']}` = {setting['value']} ({label},第 {setting['line']} 行)"
|
|
233
|
+
)
|
|
234
|
+
return "\n".join(lines).rstrip() + "\n"
|