@lievclouds/dsh-cortex 0.4.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.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -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
+ }