@lievclouds/dsh-cortex 0.5.4 → 1.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.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/README.md +1 -1
- package/client.js +434 -412
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -110
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/host/cortex/index.js +42 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/tools.js +2 -0
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static analysis of an unpacked mini-program tree.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is read-only over the already-unpacked workspace: no network,
|
|
5
|
+
* no execution of extracted JavaScript, no access to the original WeChat cache.
|
|
6
|
+
* The analyzer extracts small, high-signal intelligence for the Cortex asset
|
|
7
|
+
* library:
|
|
8
|
+
*
|
|
9
|
+
* - pages / subpackages from `app-config.json`,
|
|
10
|
+
* - business domains and API endpoint candidates from text files,
|
|
11
|
+
* - hard-coded secrets (masked in every summary / persisted masked overview),
|
|
12
|
+
* - authz-test parameter candidates (query / path / config values).
|
|
13
|
+
*
|
|
14
|
+
* `analyzeUnpacked` keeps full secret values in the returned object so the
|
|
15
|
+
* local asset importer can persist them. `saveOverview` writes `overview.json`
|
|
16
|
+
* with masked values only plus `extracted.json` with the full values.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path'
|
|
21
|
+
import { ERROR_CODES, WxapkgError, readPackage } from './wxapkg.js'
|
|
22
|
+
|
|
23
|
+
/** Deterministic caps for one overview. */
|
|
24
|
+
export const OVERVIEW_LIMITS = Object.freeze({
|
|
25
|
+
domains: 200,
|
|
26
|
+
endpoints: 300,
|
|
27
|
+
secrets: 100,
|
|
28
|
+
parameters: 100,
|
|
29
|
+
pages: 500,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const BINARY_PROBE_BYTES = 8 * 1024
|
|
33
|
+
const MAX_ANALYZE_FILE_BYTES = 16 * 1024 * 1024
|
|
34
|
+
const MAX_LISTED_FILES = 5
|
|
35
|
+
const MAX_DOMAIN_EXAMPLES = 3
|
|
36
|
+
const MAX_CONFIG_DEPTH = 8
|
|
37
|
+
const MAX_TEXT_INSIGHT_WARNINGS = 20
|
|
38
|
+
|
|
39
|
+
/* ------------------------------------------------------------------------- */
|
|
40
|
+
/* Display name extraction */
|
|
41
|
+
/* ------------------------------------------------------------------------- */
|
|
42
|
+
|
|
43
|
+
/** Generic page titles that are navigation labels, not the mini-program name. */
|
|
44
|
+
const GENERIC_TITLES = new Set([
|
|
45
|
+
'首页',
|
|
46
|
+
'我的',
|
|
47
|
+
'分类',
|
|
48
|
+
'购物车',
|
|
49
|
+
'订单',
|
|
50
|
+
'搜索',
|
|
51
|
+
'详情',
|
|
52
|
+
'活动',
|
|
53
|
+
'登录',
|
|
54
|
+
'注册',
|
|
55
|
+
'预约',
|
|
56
|
+
'立即预约',
|
|
57
|
+
'提交订单',
|
|
58
|
+
'商品详情',
|
|
59
|
+
'订单详情',
|
|
60
|
+
'我的订单',
|
|
61
|
+
'个人中心',
|
|
62
|
+
'会员中心',
|
|
63
|
+
'设置',
|
|
64
|
+
'消息',
|
|
65
|
+
'通知',
|
|
66
|
+
'列表',
|
|
67
|
+
'更多',
|
|
68
|
+
'加载中',
|
|
69
|
+
'暂无数据',
|
|
70
|
+
'我的积分',
|
|
71
|
+
'积分商城',
|
|
72
|
+
'会员注册',
|
|
73
|
+
'会员信息',
|
|
74
|
+
'我的权益',
|
|
75
|
+
'商品评价',
|
|
76
|
+
'评价中心',
|
|
77
|
+
'免费领券',
|
|
78
|
+
'运动打卡',
|
|
79
|
+
'景点',
|
|
80
|
+
'景点预约',
|
|
81
|
+
'确认信息',
|
|
82
|
+
'预约登记',
|
|
83
|
+
'医生列表',
|
|
84
|
+
'报告查询',
|
|
85
|
+
'报告信息',
|
|
86
|
+
'健康档案',
|
|
87
|
+
'选择服务',
|
|
88
|
+
'访客管理',
|
|
89
|
+
'邀请函',
|
|
90
|
+
'同行人',
|
|
91
|
+
'提货码',
|
|
92
|
+
'物流查询',
|
|
93
|
+
'我的佣金',
|
|
94
|
+
'积分分配',
|
|
95
|
+
'失物招领',
|
|
96
|
+
'礼品卡',
|
|
97
|
+
'扫码积分',
|
|
98
|
+
'拍照积分',
|
|
99
|
+
'缴费记录',
|
|
100
|
+
'企业客户预约',
|
|
101
|
+
'预约详情',
|
|
102
|
+
'畅享卡',
|
|
103
|
+
'扫码领券',
|
|
104
|
+
'分销商品',
|
|
105
|
+
'分类',
|
|
106
|
+
'活动',
|
|
107
|
+
'首页',
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
const TITLE_CJK_RE = /[\u3400-\u9fff]/
|
|
111
|
+
|
|
112
|
+
function cleanName(value) {
|
|
113
|
+
if (typeof value !== 'string') return ''
|
|
114
|
+
let name = value
|
|
115
|
+
.replace(/\s+/g, ' ')
|
|
116
|
+
.replace(/^["'“”‘’《》\s]+|["'“”‘’《》\s]+$/g, '')
|
|
117
|
+
.trim()
|
|
118
|
+
// Devtools project names are often percent-encoded in project.private.config.json.
|
|
119
|
+
if (/%[0-9A-Fa-f]{2}/.test(name)) {
|
|
120
|
+
try {
|
|
121
|
+
name = decodeURIComponent(name).replace(/\s+/g, ' ').trim()
|
|
122
|
+
} catch {
|
|
123
|
+
/* keep the raw value */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (name.length < 2 || name.length > 32) return ''
|
|
127
|
+
if (/^(?:https?:\/\/|\d{4,}|undefined|null|true|false|none|todo|示例|测试)$/i.test(name)) return ''
|
|
128
|
+
if (GENERIC_TITLES.has(name)) return ''
|
|
129
|
+
if (!TITLE_CJK_RE.test(name) && !/^[A-Za-z][A-Za-z0-9 ._-]{1,31}$/.test(name)) return ''
|
|
130
|
+
return name
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function collectTitleCandidates(config, displaySource, acc) {
|
|
134
|
+
if (!config || typeof config !== 'object') return
|
|
135
|
+
const subRoots = Array.isArray(config.subPackages)
|
|
136
|
+
? config.subPackages
|
|
137
|
+
: Array.isArray(config.subpackages)
|
|
138
|
+
? config.subpackages
|
|
139
|
+
: []
|
|
140
|
+
const subRootsList = subRoots
|
|
141
|
+
.map((entry) => (entry && typeof entry.root === 'string' ? entry.root.replace(/\/$/, '') : ''))
|
|
142
|
+
.filter((entry) => entry.length > 0)
|
|
143
|
+
const entryPagePath =
|
|
144
|
+
typeof config.entryPagePath === 'string' ? config.entryPagePath.replace(/\.html$/, '') : ''
|
|
145
|
+
|
|
146
|
+
const globalTitle = config.global && config.global.window && config.global.window.navigationBarTitleText
|
|
147
|
+
if (typeof globalTitle === 'string' && cleanName(globalTitle)) {
|
|
148
|
+
acc.push({ title: globalTitle, weight: 3, source: displaySource })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pageMap = config.page && typeof config.page === 'object' ? config.page : null
|
|
152
|
+
if (!pageMap) return
|
|
153
|
+
const entries = Object.entries(pageMap)
|
|
154
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
155
|
+
const [pagePath, pageConfig] = entries[index]
|
|
156
|
+
const title = pageConfig && pageConfig.window && pageConfig.window.navigationBarTitleText
|
|
157
|
+
if (typeof title !== 'string' || cleanName(title).length === 0) continue
|
|
158
|
+
const normalizedPage = String(pagePath).replace(/\.html$/, '')
|
|
159
|
+
const inSubPackage = subRootsList.some((root) => normalizedPage.startsWith(`${root}/`))
|
|
160
|
+
let weight = inSubPackage ? 1 : 3
|
|
161
|
+
if (index < 20) weight += 1
|
|
162
|
+
if (normalizedPage.endsWith('/index') || normalizedPage.endsWith('/index/index')) weight += 1
|
|
163
|
+
if (entryPagePath && normalizedPage === entryPagePath) weight += 4
|
|
164
|
+
acc.push({ title, weight, source: displaySource })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Derive a human display name from package config files. Official app names are
|
|
170
|
+
* not stored in the WeChat cache in plaintext, so this is a best-effort
|
|
171
|
+
* extraction from `project.config.json` / app config / page titles, with a
|
|
172
|
+
* stable heuristic that prefers the entry page and main-package titles.
|
|
173
|
+
*/
|
|
174
|
+
export function deriveAppName({ appConfig = null, projectConfig = null, pluginConfig = null } = {}) {
|
|
175
|
+
const directCandidates = [
|
|
176
|
+
['project.config.json', projectConfig && (projectConfig.projectname || projectConfig.projectName || projectConfig.name)],
|
|
177
|
+
['app-config.json', appConfig && (appConfig.name || appConfig.appName || appConfig.nickname || appConfig.brandName || appConfig.shopName || appConfig.mallName)],
|
|
178
|
+
['plugin.json', pluginConfig && (pluginConfig.name || pluginConfig.pluginName)],
|
|
179
|
+
]
|
|
180
|
+
for (const [source, value] of directCandidates) {
|
|
181
|
+
const name = cleanName(value)
|
|
182
|
+
if (name) return { appName: name, appNameSource: source }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const candidates = []
|
|
186
|
+
collectTitleCandidates(appConfig, 'app-config.json', candidates)
|
|
187
|
+
collectTitleCandidates(pluginConfig, 'plugin.json', candidates)
|
|
188
|
+
if (candidates.length === 0) return { appName: '', appNameSource: '' }
|
|
189
|
+
|
|
190
|
+
const scores = new Map()
|
|
191
|
+
for (const candidate of candidates) {
|
|
192
|
+
const name = cleanName(candidate.title)
|
|
193
|
+
if (!name) continue
|
|
194
|
+
const current = scores.get(name) || { name, score: 0, hits: 0 }
|
|
195
|
+
current.score += candidate.weight
|
|
196
|
+
current.hits += 1
|
|
197
|
+
scores.set(name, current)
|
|
198
|
+
}
|
|
199
|
+
const ranked = [...scores.values()].sort((left, right) => {
|
|
200
|
+
if (right.score !== left.score) return right.score - left.score
|
|
201
|
+
if (right.hits !== left.hits) return right.hits - left.hits
|
|
202
|
+
return left.name.length - right.name.length
|
|
203
|
+
})
|
|
204
|
+
if (ranked.length === 0) return { appName: '', appNameSource: '' }
|
|
205
|
+
return { appName: ranked[0].name, appNameSource: 'page-title' }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readJsonFileQuiet(filePath, maxBytes = 2 * 1024 * 1024) {
|
|
209
|
+
try {
|
|
210
|
+
const stats = statSync(filePath)
|
|
211
|
+
if (!stats.isFile() || stats.size > maxBytes) return null
|
|
212
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
213
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
214
|
+
} catch {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Derive the display name from an already unpacked workspace (fast, no scan). */
|
|
220
|
+
export function deriveAppNameFromUnpacked(unpackedPath) {
|
|
221
|
+
if (typeof unpackedPath !== 'string' || unpackedPath.length === 0) return { appName: '', appNameSource: '' }
|
|
222
|
+
const root = resolve(unpackedPath)
|
|
223
|
+
return deriveAppName({
|
|
224
|
+
appConfig: readJsonFileQuiet(join(root, 'app-config.json')) || readJsonFileQuiet(join(root, 'app.json')),
|
|
225
|
+
projectConfig:
|
|
226
|
+
readJsonFileQuiet(join(root, 'project.config.json')) || readJsonFileQuiet(join(root, 'project.private.config.json')),
|
|
227
|
+
pluginConfig: readJsonFileQuiet(join(root, 'plugin.json')),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Derive the display name from one encrypted/plain wxapkg file. Used by the
|
|
233
|
+
* scan phase (one package per app) so the list can show a Chinese name before
|
|
234
|
+
* the user unpacks anything.
|
|
235
|
+
*/
|
|
236
|
+
export function extractAppNameFromPackage(filePath, appid) {
|
|
237
|
+
try {
|
|
238
|
+
const parsed = readPackage(filePath, { appid })
|
|
239
|
+
const findEntry = (fileName) => {
|
|
240
|
+
const exact = parsed.files.find((entry) => entry.name === `/${fileName}`)
|
|
241
|
+
if (exact) return exact
|
|
242
|
+
return parsed.files.find((entry) => entry.name.endsWith(`/${fileName}`))
|
|
243
|
+
}
|
|
244
|
+
const readEntryJson = (fileName) => {
|
|
245
|
+
const entry = findEntry(fileName)
|
|
246
|
+
if (!entry || entry.size > 2 * 1024 * 1024) return null
|
|
247
|
+
try {
|
|
248
|
+
const parsedJson = JSON.parse(
|
|
249
|
+
parsed.buffer.subarray(entry.offset, entry.offset + entry.size).toString('utf8'),
|
|
250
|
+
)
|
|
251
|
+
return parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson) ? parsedJson : null
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return deriveAppName({
|
|
257
|
+
appConfig: readEntryJson('app-config.json') || readEntryJson('app.json'),
|
|
258
|
+
projectConfig: readEntryJson('project.config.json') || readEntryJson('project.private.config.json'),
|
|
259
|
+
pluginConfig: readEntryJson('plugin.json'),
|
|
260
|
+
})
|
|
261
|
+
} catch {
|
|
262
|
+
return { appName: '', appNameSource: '' }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Workspace metadata files are counted but not mined (they echo file paths). */
|
|
267
|
+
const ANALYSIS_METADATA_FILES = new Set(['manifest.json', 'overview.json', 'extracted.json'])
|
|
268
|
+
|
|
269
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
|
|
270
|
+
const NOISE_DOMAINS = new Set([
|
|
271
|
+
'localhost',
|
|
272
|
+
'127.0.0.1',
|
|
273
|
+
'0.0.0.0',
|
|
274
|
+
'w3.org',
|
|
275
|
+
'www.w3.org',
|
|
276
|
+
'w3c.org',
|
|
277
|
+
'www.w3c.org',
|
|
278
|
+
'schema.org',
|
|
279
|
+
'www.schema.org',
|
|
280
|
+
'example.com',
|
|
281
|
+
'www.example.com',
|
|
282
|
+
])
|
|
283
|
+
const NOISE_DOMAIN_SUFFIXES = ['.w3.org', '.w3c.org', '.schema.org', '.example.com']
|
|
284
|
+
const STATIC_ASSET_RE = /\.(?:js|json|wxml|wxss|wxs|html?|css|png|jpe?g|gif|svg|webp|ico|bmp|ttf|otf|woff2?|eot|mp[34]|wav|amr|map)$/i
|
|
285
|
+
/**
|
|
286
|
+
* References to bundled page/component/plugin modules (`"/pages/index/index"`,
|
|
287
|
+
* `"/./components/x/x"`) are not HTTP endpoints; keeping them would drown the
|
|
288
|
+
* real API candidates on large packages.
|
|
289
|
+
*/
|
|
290
|
+
const MODULE_REFERENCE_RE = /^\/(?:pages|components|__plugin__|miniprogram_npm)\/|^\/\.\//
|
|
291
|
+
const DOMAIN_RE = /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?::\d+)?/gi
|
|
292
|
+
const ENDPOINT_RE = /["'](\/[a-zA-Z0-9_\-./{}:]+(?:\?[^"']*)?)["']/g
|
|
293
|
+
const QUERY_PAIR_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,60}$/
|
|
294
|
+
const PARAM_KEY_RE = /(tenant|user|order|member|card|shop|store|org|account|id)$/i
|
|
295
|
+
const SECRET_KEY_RE = /(secret|password|passwd|pwd|token|api[_-]?key|access[_-]?key|private[_-]?key)$/i
|
|
296
|
+
const PATH_PLACEHOLDER_RE = /^(?:\{([^{}]+)\}|:([A-Za-z_][A-Za-z0-9_]*))$/
|
|
297
|
+
|
|
298
|
+
const SECRET_PATTERNS = [
|
|
299
|
+
{
|
|
300
|
+
re: /AKID[0-9A-Za-z]{13,}/g,
|
|
301
|
+
name: 'tencent_akid',
|
|
302
|
+
info_type: 'API_Key',
|
|
303
|
+
potential_impact: '腾讯云 API 密钥泄露,可能被用于调用云资源或读取数据',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
re: /sk-[A-Za-z0-9]{20,}/g,
|
|
307
|
+
name: 'openai_api_key',
|
|
308
|
+
info_type: 'API_Key',
|
|
309
|
+
potential_impact: '第三方模型 API Key 泄露,可能被滥用并产生费用',
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
re: /ghp_[A-Za-z0-9]{20,}/g,
|
|
313
|
+
name: 'github_token',
|
|
314
|
+
info_type: 'Token',
|
|
315
|
+
potential_impact: 'GitHub Token 泄露,可能访问仓库或流水线',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
re: /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g,
|
|
319
|
+
name: 'jwt',
|
|
320
|
+
info_type: 'Token',
|
|
321
|
+
potential_impact: 'JWT 泄露,可能被重放或用于伪造会话',
|
|
322
|
+
},
|
|
323
|
+
]
|
|
324
|
+
const ASSIGNMENT_RE =
|
|
325
|
+
/(api[_-]?key|secret|token|app[_-]?secret|access[_-]?key)\s*[:=]\s*['"]([^'"]{8,120})['"]/gi
|
|
326
|
+
/** Reject minified-code fragments such as `).concat(d,` captured by ASSIGNMENT_RE. */
|
|
327
|
+
const ASSIGNMENT_VALUE_RE = /^[A-Za-z0-9_\-.\/+=:@]{8,120}$/
|
|
328
|
+
/** Full PEM block; multi-line scan runs at content level (bare headers are noise). */
|
|
329
|
+
const PEM_KEY_RE =
|
|
330
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]{20,}?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g
|
|
331
|
+
/** `tenantId:"10001"` / `userId: 42` style config samples in JS/config text. */
|
|
332
|
+
const CONFIG_ASSIGNMENT_RE =
|
|
333
|
+
/(?:^|[{,;\s])["']?([A-Za-z_$][\w$]{0,60})["']?\s*[:=]\s*(?:"([^"\r\n]{1,120})"|'([^'\r\n]{1,120})'|(\d{1,12}))\s*(?=[,;}\s]|$)/gm
|
|
334
|
+
|
|
335
|
+
function readPrefix(filePath, bytes) {
|
|
336
|
+
if (bytes <= 0) return Buffer.alloc(0)
|
|
337
|
+
const buffer = Buffer.alloc(bytes)
|
|
338
|
+
const fd = openSync(filePath, 'r')
|
|
339
|
+
try {
|
|
340
|
+
const read = readSync(fd, buffer, 0, bytes, 0)
|
|
341
|
+
return buffer.subarray(0, read)
|
|
342
|
+
} finally {
|
|
343
|
+
closeSync(fd)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function splitLines(text) {
|
|
348
|
+
if (text.length === 0) return []
|
|
349
|
+
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
350
|
+
const lines = normalized.split('\n')
|
|
351
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
352
|
+
return lines
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const toPosix = (value) => String(value).split('\\').join('/')
|
|
356
|
+
|
|
357
|
+
function walkFiles(rootPath) {
|
|
358
|
+
const files = []
|
|
359
|
+
const stack = [{ dir: rootPath, rel: '' }]
|
|
360
|
+
while (stack.length > 0) {
|
|
361
|
+
const current = stack.pop()
|
|
362
|
+
let dirents
|
|
363
|
+
try {
|
|
364
|
+
dirents = readdirSync(current.dir, { withFileTypes: true })
|
|
365
|
+
} catch {
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
dirents.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
|
369
|
+
for (const dirent of dirents) {
|
|
370
|
+
if (dirent.isSymbolicLink()) continue
|
|
371
|
+
const absolute = join(current.dir, dirent.name)
|
|
372
|
+
const rel = current.rel ? `${current.rel}/${dirent.name}` : dirent.name
|
|
373
|
+
if (dirent.isDirectory()) {
|
|
374
|
+
stack.push({ dir: absolute, rel })
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (!dirent.isFile()) continue
|
|
378
|
+
let size = 0
|
|
379
|
+
try {
|
|
380
|
+
size = statSync(absolute).size
|
|
381
|
+
} catch {
|
|
382
|
+
size = 0
|
|
383
|
+
}
|
|
384
|
+
files.push({ path: rel, abs: absolute, size })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
files.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
388
|
+
return files
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function maskSecret(value) {
|
|
392
|
+
const text = typeof value === 'string' ? value : String(value === undefined || value === null ? '' : value)
|
|
393
|
+
if (text.length <= 8) return '***'
|
|
394
|
+
return `${text.slice(0, 4)}\u2026${text.slice(-2)}`
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isPlaceholder(value) {
|
|
398
|
+
const text = String(value || '').trim()
|
|
399
|
+
if (text.length === 0) return true
|
|
400
|
+
const lower = text.toLowerCase()
|
|
401
|
+
if (lower.length < 8) return true
|
|
402
|
+
if (/^(?:undefined|null|true|false|changeme|password|secret|example|sample|placeholder|todo|none)$/i.test(lower)) {
|
|
403
|
+
return true
|
|
404
|
+
}
|
|
405
|
+
if (/(?:your[_ -]?(?:key|secret|token|appid)|placeholder|example|xxxxx|xxx-xxx)/i.test(lower)) return true
|
|
406
|
+
if (/^<.*>$/.test(lower) || /^\*.+$/.test(lower)) return true
|
|
407
|
+
if (/^(.)\1{6,}$/.test(lower)) return true // 'aaaaaaaa', '00000000'
|
|
408
|
+
if (new Set(lower).size <= 2) return true
|
|
409
|
+
return false
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function infoTypeForKey(key) {
|
|
413
|
+
const lower = String(key || '').toLowerCase()
|
|
414
|
+
if (lower.includes('token')) return 'Token'
|
|
415
|
+
if (lower.includes('secret') || lower.includes('password') || lower.includes('passwd') || lower.includes('pwd')) {
|
|
416
|
+
return 'Credential'
|
|
417
|
+
}
|
|
418
|
+
if (lower.includes('key')) return 'API_Key'
|
|
419
|
+
return 'Other'
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isNoiseDomain(host) {
|
|
423
|
+
const lower = String(host || '').toLowerCase()
|
|
424
|
+
if (NOISE_DOMAINS.has(lower)) return true
|
|
425
|
+
if (/^127\./.test(lower) || /^10\./.test(lower) || /^192\.168\./.test(lower)) return true
|
|
426
|
+
if (NOISE_DOMAIN_SUFFIXES.some((suffix) => lower.endsWith(suffix))) return true
|
|
427
|
+
return false
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function formatBytes(bytes) {
|
|
431
|
+
const value = Number(bytes) || 0
|
|
432
|
+
if (value < 1024) return `${value} B`
|
|
433
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
|
434
|
+
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
|
|
435
|
+
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function decodeSafe(value) {
|
|
439
|
+
try {
|
|
440
|
+
return decodeURIComponent(String(value).replace(/\+/g, ' '))
|
|
441
|
+
} catch {
|
|
442
|
+
return String(value)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isSampleValue(value) {
|
|
447
|
+
if (typeof value === 'number') {
|
|
448
|
+
if (!Number.isInteger(value) || value < 0) return ''
|
|
449
|
+
const text = String(value)
|
|
450
|
+
return text.length <= 12 ? text : ''
|
|
451
|
+
}
|
|
452
|
+
if (typeof value !== 'string') return ''
|
|
453
|
+
const text = value.trim()
|
|
454
|
+
if (/^\d{1,12}$/.test(text)) return text
|
|
455
|
+
if (/^wx[0-9a-z]{8,}$/i.test(text)) return text
|
|
456
|
+
return ''
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function addParameter(map, { name, endpoint, location, value, source }) {
|
|
460
|
+
const paramName = String(name || '').trim()
|
|
461
|
+
if (paramName.length === 0 || paramName.length > 80) return
|
|
462
|
+
const targetEndpoint = String(endpoint || '').trim()
|
|
463
|
+
const targetLocation = String(location || 'query')
|
|
464
|
+
const key = `${paramName}\u0000${targetEndpoint}\u0000${targetLocation}`
|
|
465
|
+
let entry = map.get(key)
|
|
466
|
+
if (!entry) {
|
|
467
|
+
entry = { name: paramName, endpoint: targetEndpoint, location: targetLocation, values: [], source: String(source || '') }
|
|
468
|
+
map.set(key, entry)
|
|
469
|
+
}
|
|
470
|
+
if (value === undefined || value === null) return
|
|
471
|
+
const text = String(value).trim()
|
|
472
|
+
if (text.length === 0 || text.length > 120) return
|
|
473
|
+
if (entry.values.length >= 5 || entry.values.includes(text)) return
|
|
474
|
+
entry.values.push(text)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function configParameters(config, sourceName, map) {
|
|
478
|
+
const visit = (value, depth) => {
|
|
479
|
+
if (depth > MAX_CONFIG_DEPTH || value === null || typeof value !== 'object') return
|
|
480
|
+
if (Array.isArray(value)) {
|
|
481
|
+
for (const item of value) visit(item, depth + 1)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
for (const [key, child] of Object.entries(value)) {
|
|
485
|
+
if (child !== null && typeof child === 'object') {
|
|
486
|
+
visit(child, depth + 1)
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
if (!PARAM_KEY_RE.test(key) || SECRET_KEY_RE.test(key)) continue
|
|
490
|
+
const sample = isSampleValue(child)
|
|
491
|
+
if (sample.length === 0) continue
|
|
492
|
+
addParameter(map, { name: key, endpoint: sourceName, location: 'body', value: sample, source: sourceName })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
visit(config, 0)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function scanConfigAssignments(content, sourceName, parameterMap) {
|
|
499
|
+
CONFIG_ASSIGNMENT_RE.lastIndex = 0
|
|
500
|
+
let match
|
|
501
|
+
while ((match = CONFIG_ASSIGNMENT_RE.exec(content)) !== null) {
|
|
502
|
+
const key = match[1]
|
|
503
|
+
if (!key || !PARAM_KEY_RE.test(key) || SECRET_KEY_RE.test(key)) continue
|
|
504
|
+
const rawValue = match[2] !== undefined ? match[2] : match[3] !== undefined ? match[3] : match[4]
|
|
505
|
+
const sample = isSampleValue(rawValue)
|
|
506
|
+
if (sample.length === 0) continue
|
|
507
|
+
addParameter(parameterMap, {
|
|
508
|
+
name: key,
|
|
509
|
+
endpoint: sourceName,
|
|
510
|
+
location: 'body',
|
|
511
|
+
value: sample,
|
|
512
|
+
source: sourceName,
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function nearbyMethod(content, endIndex) {
|
|
518
|
+
const window = content.slice(endIndex, Math.min(content.length, endIndex + 120))
|
|
519
|
+
const match = /method\s*[:=]\s*['"]([A-Za-z]{3,7})['"]/i.exec(window)
|
|
520
|
+
if (!match) return ''
|
|
521
|
+
const method = match[1].toUpperCase()
|
|
522
|
+
return HTTP_METHODS.includes(method) ? method : ''
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Analyze one unpacked mini-program workspace.
|
|
527
|
+
*
|
|
528
|
+
* @param {{unpackedPath:string, appid?:string, version?:string, manifest?:object|null}} input
|
|
529
|
+
* @returns {object} Overview (see contract §3.6)
|
|
530
|
+
*/
|
|
531
|
+
export function analyzeUnpacked({ unpackedPath, appid = '', version = '', manifest = null } = {}) {
|
|
532
|
+
const rootPath = typeof unpackedPath === 'string' && unpackedPath.trim().length > 0 ? resolve(unpackedPath) : ''
|
|
533
|
+
if (!rootPath) throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'unpackedPath is required')
|
|
534
|
+
let rootStats
|
|
535
|
+
try {
|
|
536
|
+
rootStats = lstatSync(rootPath)
|
|
537
|
+
} catch {
|
|
538
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PATH, `unpacked directory does not exist: ${rootPath}`)
|
|
539
|
+
}
|
|
540
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
541
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'unpacked path is not a directory')
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const warnings = []
|
|
545
|
+
const files = walkFiles(rootPath)
|
|
546
|
+
const extensionMap = new Map()
|
|
547
|
+
const domainMap = new Map()
|
|
548
|
+
const endpointMap = new Map()
|
|
549
|
+
const parameterMap = new Map()
|
|
550
|
+
const secrets = []
|
|
551
|
+
const secretKeys = new Set()
|
|
552
|
+
|
|
553
|
+
let totalBytes = 0
|
|
554
|
+
let textCount = 0
|
|
555
|
+
let binaryCount = 0
|
|
556
|
+
let partialTextFiles = 0
|
|
557
|
+
let truncatedSecrets = false
|
|
558
|
+
let appConfig = null
|
|
559
|
+
let appConfigName = ''
|
|
560
|
+
let projectConfig = null
|
|
561
|
+
let pluginConfig = null
|
|
562
|
+
|
|
563
|
+
const rememberDomain = (host, url, sourceFile) => {
|
|
564
|
+
const lower = String(host || '').toLowerCase()
|
|
565
|
+
if (!lower || isNoiseDomain(lower)) return
|
|
566
|
+
let entry = domainMap.get(lower)
|
|
567
|
+
if (!entry) {
|
|
568
|
+
entry = { host: lower, count: 0, examples: [], files: [] }
|
|
569
|
+
domainMap.set(lower, entry)
|
|
570
|
+
}
|
|
571
|
+
entry.count += 1
|
|
572
|
+
if (url && entry.examples.length < MAX_DOMAIN_EXAMPLES && !entry.examples.includes(url)) {
|
|
573
|
+
entry.examples.push(url.length > 300 ? url.slice(0, 300) : url)
|
|
574
|
+
}
|
|
575
|
+
if (entry.files.length < MAX_LISTED_FILES && !entry.files.includes(sourceFile)) entry.files.push(sourceFile)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const rememberEndpoint = (path, method, sourceFile) => {
|
|
579
|
+
const key = `${method || ''}\u0000${path}`
|
|
580
|
+
let entry = endpointMap.get(key)
|
|
581
|
+
if (!entry) {
|
|
582
|
+
entry = { path, count: 0, files: [] }
|
|
583
|
+
if (method) entry.method = method
|
|
584
|
+
endpointMap.set(key, entry)
|
|
585
|
+
}
|
|
586
|
+
entry.count += 1
|
|
587
|
+
if (entry.files.length < MAX_LISTED_FILES && !entry.files.includes(sourceFile)) entry.files.push(sourceFile)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const rememberSecret = (infoType, name, value, sourceFile, line) => {
|
|
591
|
+
if (secrets.length >= OVERVIEW_LIMITS.secrets) {
|
|
592
|
+
truncatedSecrets = true
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if (isPlaceholder(value)) return
|
|
596
|
+
const dedupeKey = `${infoType}\u0000${value}\u0000${sourceFile}\u0000${line}`
|
|
597
|
+
if (secretKeys.has(dedupeKey)) return
|
|
598
|
+
secretKeys.add(dedupeKey)
|
|
599
|
+
secrets.push({
|
|
600
|
+
info_type: infoType,
|
|
601
|
+
name,
|
|
602
|
+
value_masked: maskSecret(value),
|
|
603
|
+
value,
|
|
604
|
+
source: sourceFile,
|
|
605
|
+
line: Number.isFinite(Number(line)) ? Math.trunc(Number(line)) : 0,
|
|
606
|
+
potential_impact:
|
|
607
|
+
infoType === 'Token'
|
|
608
|
+
? '令牌泄露,可能被重放或用于伪造会话'
|
|
609
|
+
: infoType === 'API_Key'
|
|
610
|
+
? 'API 密钥泄露,可能被用于调用后端或云服务'
|
|
611
|
+
: infoType === 'Credential'
|
|
612
|
+
? '凭据/私钥泄露,可能导致未授权访问'
|
|
613
|
+
: '硬编码敏感信息泄露',
|
|
614
|
+
})
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const scanSecrets = (content, sourceFile) => {
|
|
618
|
+
const lines = splitLines(content)
|
|
619
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
620
|
+
const line = lines[index]
|
|
621
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
622
|
+
pattern.re.lastIndex = 0
|
|
623
|
+
let match
|
|
624
|
+
while ((match = pattern.re.exec(line)) !== null) {
|
|
625
|
+
rememberSecret(pattern.info_type, pattern.name, match[0], sourceFile, index + 1)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
ASSIGNMENT_RE.lastIndex = 0
|
|
629
|
+
let assignment
|
|
630
|
+
while ((assignment = ASSIGNMENT_RE.exec(line)) !== null) {
|
|
631
|
+
if (!ASSIGNMENT_VALUE_RE.test(assignment[2])) continue
|
|
632
|
+
rememberSecret(infoTypeForKey(assignment[1]), assignment[1].toLowerCase(), assignment[2], sourceFile, index + 1)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// A real embedded private key may span lines; scan the whole content once.
|
|
636
|
+
// Bare headers and code fragments that merely mention the markers are not
|
|
637
|
+
// secrets: the body must look like PEM base64.
|
|
638
|
+
PEM_KEY_RE.lastIndex = 0
|
|
639
|
+
let pem
|
|
640
|
+
while ((pem = PEM_KEY_RE.exec(content)) !== null) {
|
|
641
|
+
const bodyMatch = /^-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----([\s\S]*?)-----END /.exec(pem[0])
|
|
642
|
+
const body = bodyMatch ? bodyMatch[1] : ''
|
|
643
|
+
if (!/^(?:[A-Za-z0-9+/=]|\\[nr]|\s)+$/.test(body)) continue
|
|
644
|
+
const line = content.slice(0, pem.index).split('\n').length
|
|
645
|
+
rememberSecret('Credential', 'private_key', pem[0].slice(0, 4096), sourceFile, line)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const scanEndpoints = (content, sourceFile) => {
|
|
650
|
+
ENDPOINT_RE.lastIndex = 0
|
|
651
|
+
let match
|
|
652
|
+
while ((match = ENDPOINT_RE.exec(content)) !== null) {
|
|
653
|
+
const raw = match[1]
|
|
654
|
+
const queryIndex = raw.indexOf('?')
|
|
655
|
+
const rawPath = queryIndex >= 0 ? raw.slice(0, queryIndex) : raw
|
|
656
|
+
const query = queryIndex >= 0 ? raw.slice(queryIndex + 1) : ''
|
|
657
|
+
if (!rawPath.startsWith('/')) continue
|
|
658
|
+
const segments = rawPath.split('/').filter(Boolean)
|
|
659
|
+
const startsWithApiRoot = /^\/(?:api|wx|miniapp|mp)(?:\/|$)/.test(rawPath)
|
|
660
|
+
if (segments.length < 2 && !startsWithApiRoot) continue
|
|
661
|
+
if (STATIC_ASSET_RE.test(rawPath) || MODULE_REFERENCE_RE.test(rawPath)) continue
|
|
662
|
+
const method = nearbyMethod(content, match.index + match[0].length)
|
|
663
|
+
rememberEndpoint(rawPath, method, sourceFile)
|
|
664
|
+
|
|
665
|
+
if (query.length > 0) {
|
|
666
|
+
for (const pair of query.split('&')) {
|
|
667
|
+
const equals = pair.indexOf('=')
|
|
668
|
+
const rawName = equals >= 0 ? pair.slice(0, equals) : pair
|
|
669
|
+
const rawValue = equals >= 0 ? pair.slice(equals + 1) : ''
|
|
670
|
+
const name = decodeSafe(rawName).trim()
|
|
671
|
+
if (!QUERY_PAIR_RE.test(name) || SECRET_KEY_RE.test(name)) continue
|
|
672
|
+
addParameter(parameterMap, {
|
|
673
|
+
name,
|
|
674
|
+
endpoint: rawPath,
|
|
675
|
+
location: 'query',
|
|
676
|
+
value: rawValue.length > 0 ? decodeSafe(rawValue).trim() : undefined,
|
|
677
|
+
source: sourceFile,
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
for (const segment of segments) {
|
|
683
|
+
const placeholder = PATH_PLACEHOLDER_RE.exec(segment)
|
|
684
|
+
if (!placeholder) continue
|
|
685
|
+
const name = (placeholder[1] || placeholder[2] || '').trim()
|
|
686
|
+
if (name.length === 0 || SECRET_KEY_RE.test(name)) continue
|
|
687
|
+
addParameter(parameterMap, { name, endpoint: rawPath, location: 'path', source: sourceFile })
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
for (const file of files) {
|
|
693
|
+
totalBytes += file.size
|
|
694
|
+
const extension = extname(file.path).toLowerCase() || '(none)'
|
|
695
|
+
let extensionEntry = extensionMap.get(extension)
|
|
696
|
+
if (!extensionEntry) {
|
|
697
|
+
extensionEntry = { ext: extension, count: 0, bytes: 0 }
|
|
698
|
+
extensionMap.set(extension, extensionEntry)
|
|
699
|
+
}
|
|
700
|
+
extensionEntry.count += 1
|
|
701
|
+
extensionEntry.bytes += file.size
|
|
702
|
+
|
|
703
|
+
let probe
|
|
704
|
+
try {
|
|
705
|
+
probe = readPrefix(file.abs, Math.min(file.size, BINARY_PROBE_BYTES))
|
|
706
|
+
} catch {
|
|
707
|
+
continue
|
|
708
|
+
}
|
|
709
|
+
if (probe.includes(0)) {
|
|
710
|
+
binaryCount += 1
|
|
711
|
+
continue
|
|
712
|
+
}
|
|
713
|
+
textCount += 1
|
|
714
|
+
|
|
715
|
+
if (ANALYSIS_METADATA_FILES.has(file.path)) continue
|
|
716
|
+
|
|
717
|
+
let content
|
|
718
|
+
try {
|
|
719
|
+
const readBytes = Math.min(file.size, MAX_ANALYZE_FILE_BYTES)
|
|
720
|
+
if (file.size > MAX_ANALYZE_FILE_BYTES) partialTextFiles += 1
|
|
721
|
+
content = readPrefix(file.abs, readBytes).toString('utf8')
|
|
722
|
+
} catch {
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (file.path === 'app-config.json' || file.path === 'app.json') {
|
|
727
|
+
try {
|
|
728
|
+
const parsed = JSON.parse(content)
|
|
729
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
730
|
+
if (!appConfig) {
|
|
731
|
+
appConfig = parsed
|
|
732
|
+
appConfigName = file.path
|
|
733
|
+
}
|
|
734
|
+
configParameters(parsed, file.path, parameterMap)
|
|
735
|
+
}
|
|
736
|
+
} catch (error) {
|
|
737
|
+
warnings.push(`could not parse ${file.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (
|
|
741
|
+
(file.path === 'project.config.json' || file.path === 'project.private.config.json') &&
|
|
742
|
+
projectConfig === null
|
|
743
|
+
) {
|
|
744
|
+
try {
|
|
745
|
+
const parsed = JSON.parse(content)
|
|
746
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) projectConfig = parsed
|
|
747
|
+
} catch {
|
|
748
|
+
/* optional metadata */
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (file.path === 'plugin.json' && pluginConfig === null) {
|
|
752
|
+
try {
|
|
753
|
+
const parsed = JSON.parse(content)
|
|
754
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) pluginConfig = parsed
|
|
755
|
+
} catch {
|
|
756
|
+
/* optional metadata */
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
DOMAIN_RE.lastIndex = 0
|
|
761
|
+
let domainMatch
|
|
762
|
+
while ((domainMatch = DOMAIN_RE.exec(content)) !== null) {
|
|
763
|
+
rememberDomain(domainMatch[1], domainMatch[0], file.path)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
scanEndpoints(content, file.path)
|
|
767
|
+
scanSecrets(content, file.path)
|
|
768
|
+
scanConfigAssignments(content, file.path, parameterMap)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (partialTextFiles > 0) {
|
|
772
|
+
warnings.push(`${Math.min(partialTextFiles, MAX_TEXT_INSIGHT_WARNINGS)} large text file(s) were only partially analyzed (${formatBytes(MAX_ANALYZE_FILE_BYTES)} cap)`)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* ------------------------------ pages ------------------------------ */
|
|
776
|
+
let pages = []
|
|
777
|
+
let subPackages = []
|
|
778
|
+
if (appConfig) {
|
|
779
|
+
const rawPages = Array.isArray(appConfig.pages) ? appConfig.pages : []
|
|
780
|
+
pages = rawPages.filter((page) => typeof page === 'string' && page.trim().length > 0).map((page) => page.trim())
|
|
781
|
+
if (pages.length > OVERVIEW_LIMITS.pages) {
|
|
782
|
+
warnings.push(`pages truncated at ${OVERVIEW_LIMITS.pages}`)
|
|
783
|
+
pages = pages.slice(0, OVERVIEW_LIMITS.pages)
|
|
784
|
+
}
|
|
785
|
+
const rawSubPackages = Array.isArray(appConfig.subPackages)
|
|
786
|
+
? appConfig.subPackages
|
|
787
|
+
: Array.isArray(appConfig.subpackages)
|
|
788
|
+
? appConfig.subpackages
|
|
789
|
+
: []
|
|
790
|
+
subPackages = []
|
|
791
|
+
for (const item of rawSubPackages.slice(0, 100)) {
|
|
792
|
+
if (!item || typeof item !== 'object') continue
|
|
793
|
+
const root = typeof item.root === 'string' ? item.root.trim() : ''
|
|
794
|
+
const raw = Array.isArray(item.pages) ? item.pages : []
|
|
795
|
+
const subPages = raw.filter((page) => typeof page === 'string' && page.trim().length > 0).map((page) => page.trim())
|
|
796
|
+
if (root.length === 0 && subPages.length === 0) continue
|
|
797
|
+
subPackages.push({ root, pages: subPages.slice(0, OVERVIEW_LIMITS.pages) })
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
warnings.push('app-config.json was not found; pages/subPackages are empty')
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/* ------------------------------ domains ------------------------------ */
|
|
804
|
+
const sortedDomains = [...domainMap.values()].sort((left, right) => {
|
|
805
|
+
if (right.count !== left.count) return right.count - left.count
|
|
806
|
+
return left.host < right.host ? -1 : left.host > right.host ? 1 : 0
|
|
807
|
+
})
|
|
808
|
+
if (sortedDomains.length > OVERVIEW_LIMITS.domains) {
|
|
809
|
+
warnings.push(`domains truncated at ${OVERVIEW_LIMITS.domains}`)
|
|
810
|
+
}
|
|
811
|
+
const domains = sortedDomains.slice(0, OVERVIEW_LIMITS.domains).map((entry) => ({
|
|
812
|
+
host: entry.host,
|
|
813
|
+
count: entry.count,
|
|
814
|
+
examples: entry.examples.slice(),
|
|
815
|
+
files: entry.files.slice(),
|
|
816
|
+
}))
|
|
817
|
+
|
|
818
|
+
/* ----------------------------- endpoints ----------------------------- */
|
|
819
|
+
const sortedEndpoints = [...endpointMap.values()].sort((left, right) => {
|
|
820
|
+
if (right.count !== left.count) return right.count - left.count
|
|
821
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1
|
|
822
|
+
return String(left.method || '') < String(right.method || '') ? -1 : 1
|
|
823
|
+
})
|
|
824
|
+
if (sortedEndpoints.length > OVERVIEW_LIMITS.endpoints) {
|
|
825
|
+
warnings.push(`endpoints truncated at ${OVERVIEW_LIMITS.endpoints}`)
|
|
826
|
+
}
|
|
827
|
+
const endpoints = sortedEndpoints.slice(0, OVERVIEW_LIMITS.endpoints).map((entry) => {
|
|
828
|
+
const item = { path: entry.path, count: entry.count, files: entry.files.slice() }
|
|
829
|
+
if (entry.method) item.method = entry.method
|
|
830
|
+
return item
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
/* ----------------------------- parameters ----------------------------- */
|
|
834
|
+
const parameters = [...parameterMap.values()]
|
|
835
|
+
.map((entry) => {
|
|
836
|
+
const item = {
|
|
837
|
+
name: entry.name,
|
|
838
|
+
endpoint: entry.endpoint,
|
|
839
|
+
location: entry.location,
|
|
840
|
+
values: entry.values.slice(0, 5),
|
|
841
|
+
source: entry.source,
|
|
842
|
+
}
|
|
843
|
+
if (entry.values.length > 0) {
|
|
844
|
+
if (entry.values.every((value) => /^\d+$/.test(value))) item.pattern = 'numeric'
|
|
845
|
+
else if (entry.values.every((value) => /^wx[0-9a-z]+$/i.test(value))) item.pattern = 'wxid'
|
|
846
|
+
else item.pattern = 'string'
|
|
847
|
+
}
|
|
848
|
+
return item
|
|
849
|
+
})
|
|
850
|
+
.sort((left, right) => {
|
|
851
|
+
if (left.endpoint !== right.endpoint) return left.endpoint < right.endpoint ? -1 : 1
|
|
852
|
+
if (left.name !== right.name) return left.name < right.name ? -1 : 1
|
|
853
|
+
return left.location < right.location ? -1 : 1
|
|
854
|
+
})
|
|
855
|
+
if (parameters.length > OVERVIEW_LIMITS.parameters) {
|
|
856
|
+
warnings.push(`parameters truncated at ${OVERVIEW_LIMITS.parameters}`)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (truncatedSecrets) warnings.push(`secrets truncated at ${OVERVIEW_LIMITS.secrets}`)
|
|
860
|
+
|
|
861
|
+
/* ------------------------- files / extensions ------------------------- */
|
|
862
|
+
const byExtension = [...extensionMap.values()]
|
|
863
|
+
.sort((left, right) => {
|
|
864
|
+
if (right.count !== left.count) return right.count - left.count
|
|
865
|
+
return left.ext < right.ext ? -1 : left.ext > right.ext ? 1 : 0
|
|
866
|
+
})
|
|
867
|
+
.slice(0, 12)
|
|
868
|
+
|
|
869
|
+
const largestFiles = files
|
|
870
|
+
.slice()
|
|
871
|
+
.sort((left, right) => {
|
|
872
|
+
if (right.size !== left.size) return right.size - left.size
|
|
873
|
+
return left.path < right.path ? -1 : left.path > right.path ? 1 : 0
|
|
874
|
+
})
|
|
875
|
+
.slice(0, 10)
|
|
876
|
+
.map((file) => ({ path: file.path, size: file.size }))
|
|
877
|
+
|
|
878
|
+
const contentFileCount = files.filter((file) => !ANALYSIS_METADATA_FILES.has(file.path)).length
|
|
879
|
+
if (manifest && typeof manifest === 'object' && Array.isArray(manifest.files) && manifest.files.length !== contentFileCount) {
|
|
880
|
+
warnings.push(`manifest lists ${manifest.files.length} file(s) but the workspace contains ${contentFileCount}`)
|
|
881
|
+
}
|
|
882
|
+
const nameInfo = deriveAppName({ appConfig, projectConfig, pluginConfig })
|
|
883
|
+
|
|
884
|
+
return {
|
|
885
|
+
appid: typeof appid === 'string' ? appid : String(appid || ''),
|
|
886
|
+
version: typeof version === 'string' ? version : String(version || ''),
|
|
887
|
+
generatedAt: new Date().toISOString(),
|
|
888
|
+
appName: nameInfo.appName,
|
|
889
|
+
appNameSource: nameInfo.appNameSource,
|
|
890
|
+
files: { total: files.length, text: textCount, binary: binaryCount, bytes: totalBytes },
|
|
891
|
+
byExtension,
|
|
892
|
+
pages,
|
|
893
|
+
subPackages,
|
|
894
|
+
domains,
|
|
895
|
+
endpoints,
|
|
896
|
+
secrets: secrets.slice(0, OVERVIEW_LIMITS.secrets),
|
|
897
|
+
parameters: parameters.slice(0, OVERVIEW_LIMITS.parameters),
|
|
898
|
+
largestFiles,
|
|
899
|
+
warnings,
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Render a compact, token-friendly overview. Secret values are NEVER printed:
|
|
905
|
+
* only `value_masked` is used.
|
|
906
|
+
*
|
|
907
|
+
* @param {object} overview
|
|
908
|
+
* @param {{maxChars?:number}} [options]
|
|
909
|
+
* @returns {string}
|
|
910
|
+
*/
|
|
911
|
+
export function buildOverviewText(overview, { maxChars = 8000 } = {}) {
|
|
912
|
+
const data = overview && typeof overview === 'object' ? overview : {}
|
|
913
|
+
const cap = Number.isFinite(Number(maxChars)) ? Math.max(200, Math.trunc(Number(maxChars))) : 8000
|
|
914
|
+
const lines = []
|
|
915
|
+
const push = (line = '') => lines.push(line)
|
|
916
|
+
const list = (items, render, capItems) => {
|
|
917
|
+
const visible = items.slice(0, capItems)
|
|
918
|
+
for (const item of visible) push(render(item))
|
|
919
|
+
if (items.length > visible.length) push(`… and ${items.length - visible.length} more`)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
push(`Mini-program: ${data.appName ? `${data.appName} · ` : ''}${data.appid || '(unknown)'}${data.version ? `@${data.version}` : ''}`)
|
|
923
|
+
if (data.generatedAt) push(`Generated: ${data.generatedAt}`)
|
|
924
|
+
const files = data.files && typeof data.files === 'object' ? data.files : {}
|
|
925
|
+
push(
|
|
926
|
+
`Files: ${Number(files.total) || 0} total (${Number(files.text) || 0} text, ${Number(files.binary) || 0} binary), ${formatBytes(files.bytes)}`,
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
const pages = Array.isArray(data.pages) ? data.pages : []
|
|
930
|
+
push('')
|
|
931
|
+
push(`Pages (${pages.length}):`)
|
|
932
|
+
list(pages, (page) => `- ${page}`, 25)
|
|
933
|
+
|
|
934
|
+
const subPackages = Array.isArray(data.subPackages) ? data.subPackages : []
|
|
935
|
+
if (subPackages.length > 0) {
|
|
936
|
+
push('')
|
|
937
|
+
push(`Subpackages (${subPackages.length}):`)
|
|
938
|
+
list(
|
|
939
|
+
subPackages,
|
|
940
|
+
(item) => `- ${item.root || '(root)'}: ${Array.isArray(item.pages) ? item.pages.length : 0} page(s)`,
|
|
941
|
+
10,
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const domains = Array.isArray(data.domains) ? data.domains : []
|
|
946
|
+
push('')
|
|
947
|
+
push(`Domains (${domains.length}):`)
|
|
948
|
+
list(domains, (item) => `- ${item.host} (${Number(item.count) || 0} hit(s))`, 20)
|
|
949
|
+
|
|
950
|
+
const endpoints = Array.isArray(data.endpoints) ? data.endpoints : []
|
|
951
|
+
push('')
|
|
952
|
+
push(`Endpoint candidates (${endpoints.length}):`)
|
|
953
|
+
list(endpoints, (item) => `- ${item.method ? `${item.method} ` : ''}${item.path} (${Number(item.count) || 0})`, 30)
|
|
954
|
+
|
|
955
|
+
const secrets = Array.isArray(data.secrets) ? data.secrets : []
|
|
956
|
+
push('')
|
|
957
|
+
push(`Secrets (${secrets.length}, masked):`)
|
|
958
|
+
list(
|
|
959
|
+
secrets,
|
|
960
|
+
(item) =>
|
|
961
|
+
`- ${item.info_type || 'Other'} ${item.name || ''} ${item.value_masked || '***'}`.trimEnd() +
|
|
962
|
+
` (${item.source || 'unknown'}${item.line ? `:${item.line}` : ''})`,
|
|
963
|
+
30,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
const parameters = Array.isArray(data.parameters) ? data.parameters : []
|
|
967
|
+
push('')
|
|
968
|
+
push(`Test parameter candidates (${parameters.length}):`)
|
|
969
|
+
list(
|
|
970
|
+
parameters,
|
|
971
|
+
(item) =>
|
|
972
|
+
`- ${item.name} [${item.location || 'query'}] @ ${item.endpoint || 'unknown'}` +
|
|
973
|
+
(Array.isArray(item.values) && item.values.length > 0 ? ` values=${item.values.join(',')}` : ''),
|
|
974
|
+
20,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
const largest = Array.isArray(data.largestFiles) ? data.largestFiles : []
|
|
978
|
+
if (largest.length > 0) {
|
|
979
|
+
push('')
|
|
980
|
+
push('Largest files:')
|
|
981
|
+
list(largest, (item) => `- ${item.path} (${formatBytes(item.size)})`, 5)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const warnings = Array.isArray(data.warnings) ? data.warnings : []
|
|
985
|
+
if (warnings.length > 0) {
|
|
986
|
+
push('')
|
|
987
|
+
push('Warnings:')
|
|
988
|
+
list(warnings, (warning) => `- ${warning}`, 10)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let text = lines.join('\n')
|
|
992
|
+
if (text.length > cap) {
|
|
993
|
+
const suffix = '\n…(truncated)'
|
|
994
|
+
text = text.slice(0, Math.max(0, cap - suffix.length)) + suffix
|
|
995
|
+
}
|
|
996
|
+
return text
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function maskedOverview(overview) {
|
|
1000
|
+
const secrets = Array.isArray(overview.secrets)
|
|
1001
|
+
? overview.secrets.map((secret) => ({
|
|
1002
|
+
info_type: secret && secret.info_type ? secret.info_type : 'Other',
|
|
1003
|
+
name: secret && secret.name ? String(secret.name) : '',
|
|
1004
|
+
value_masked:
|
|
1005
|
+
secret && typeof secret.value_masked === 'string' && secret.value_masked.length > 0
|
|
1006
|
+
? secret.value_masked
|
|
1007
|
+
: maskSecret(secret && secret.value),
|
|
1008
|
+
source: secret && secret.source ? String(secret.source) : '',
|
|
1009
|
+
line: Number(secret && secret.line) || 0,
|
|
1010
|
+
potential_impact: secret && secret.potential_impact ? String(secret.potential_impact) : '',
|
|
1011
|
+
}))
|
|
1012
|
+
: []
|
|
1013
|
+
return { ...overview, secrets }
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function extractedPayload(overview) {
|
|
1017
|
+
const secrets = Array.isArray(overview.secrets)
|
|
1018
|
+
? overview.secrets.map((secret) => ({
|
|
1019
|
+
info_type: secret && secret.info_type ? secret.info_type : 'Other',
|
|
1020
|
+
name: secret && secret.name ? String(secret.name) : '',
|
|
1021
|
+
value: secret && typeof secret.value === 'string' ? secret.value : String((secret && secret.value) || ''),
|
|
1022
|
+
source: secret && secret.source ? String(secret.source) : '',
|
|
1023
|
+
line: Number(secret && secret.line) || 0,
|
|
1024
|
+
potential_impact: secret && secret.potential_impact ? String(secret.potential_impact) : '',
|
|
1025
|
+
}))
|
|
1026
|
+
: []
|
|
1027
|
+
return {
|
|
1028
|
+
appid: overview.appid || '',
|
|
1029
|
+
version: overview.version || '',
|
|
1030
|
+
generatedAt: overview.generatedAt || '',
|
|
1031
|
+
secrets,
|
|
1032
|
+
endpoints: Array.isArray(overview.endpoints) ? overview.endpoints : [],
|
|
1033
|
+
parameters: Array.isArray(overview.parameters) ? overview.parameters : [],
|
|
1034
|
+
domains: Array.isArray(overview.domains) ? overview.domains : [],
|
|
1035
|
+
pages: Array.isArray(overview.pages) ? overview.pages : [],
|
|
1036
|
+
subPackages: Array.isArray(overview.subPackages) ? overview.subPackages : [],
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Persist the analyzer output inside the unpacked directory.
|
|
1042
|
+
*
|
|
1043
|
+
* `overview.json` contains masked secret values only; `extracted.json` contains
|
|
1044
|
+
* the full values for local asset import.
|
|
1045
|
+
*
|
|
1046
|
+
* @param {string} unpackedPath
|
|
1047
|
+
* @param {object} overview
|
|
1048
|
+
*/
|
|
1049
|
+
export function saveOverview(unpackedPath, overview) {
|
|
1050
|
+
const root = typeof unpackedPath === 'string' && unpackedPath.trim().length > 0 ? resolve(unpackedPath) : ''
|
|
1051
|
+
if (!root) throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'unpackedPath is required')
|
|
1052
|
+
const data = overview && typeof overview === 'object' ? overview : {}
|
|
1053
|
+
mkdirSync(root, { recursive: true })
|
|
1054
|
+
writeFileSync(join(root, 'overview.json'), JSON.stringify(maskedOverview(data), null, 2), 'utf8')
|
|
1055
|
+
writeFileSync(join(root, 'extracted.json'), JSON.stringify(extractedPayload(data), null, 2), 'utf8')
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Read a previously saved `overview.json` (masked secrets only).
|
|
1060
|
+
*
|
|
1061
|
+
* @param {string} unpackedPath
|
|
1062
|
+
* @returns {object|null}
|
|
1063
|
+
*/
|
|
1064
|
+
export function loadOverview(unpackedPath) {
|
|
1065
|
+
if (typeof unpackedPath !== 'string' || unpackedPath.trim().length === 0) return null
|
|
1066
|
+
const root = resolve(unpackedPath)
|
|
1067
|
+
const candidates = [join(root, 'overview.json')]
|
|
1068
|
+
if (basename(root) === 'unpacked') candidates.push(join(dirname(root), 'overview.json'))
|
|
1069
|
+
for (const candidate of candidates) {
|
|
1070
|
+
try {
|
|
1071
|
+
const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
|
|
1072
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
1073
|
+
} catch {
|
|
1074
|
+
/* try the next location */
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return null
|
|
1078
|
+
}
|