refactor: modernize symbolicator runtime and security
这个提交包含在:
父节点
76384bbd0c
当前提交
b1141bcacc
9
.dockerignore
普通文件
9
.dockerignore
普通文件
@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
test
|
||||
docs
|
||||
*.log
|
||||
.DS_Store
|
||||
5
.gitignore
vendored
普通文件
5
.gitignore
vendored
普通文件
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
35
Dockerfile
35
Dockerfile
@ -1,33 +1,36 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM dependencies AS builder
|
||||
|
||||
# Copy source
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:20-alpine
|
||||
FROM node:24.18.0-alpine AS production-dependencies
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
FROM node:24.18.0-alpine AS runtime
|
||||
|
||||
# Install atos for iOS symbolication (if needed)
|
||||
RUN apk add --no-cache binutils
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
23
Jenkinsfile
vendored
23
Jenkinsfile
vendored
@ -27,7 +27,8 @@ pipeline {
|
||||
branches: [[name: 'main']],
|
||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||
userRemoteConfigs: [[
|
||||
url: 'https://xuqinmin12:28115f261568d510d230727b3e82c0167414286e@xuqinmin.com/xuqmGroup/XuqmGroup-Symbolicator.git'
|
||||
url: 'ssh://git@xuqinmin.com:2222/xuqmGroup/XuqmGroup-Symbolicator.git',
|
||||
credentialsId: 'jenkins-ssh-key'
|
||||
]]
|
||||
])
|
||||
}
|
||||
@ -109,15 +110,17 @@ print('versions.json updated: symbolicator = ${env.IMAGE_VERSION}')
|
||||
|
||||
stage('Commit Version') {
|
||||
steps {
|
||||
script {
|
||||
bat """
|
||||
chcp 65001 >nul
|
||||
git config user.email "jenkins@xuqm.com"
|
||||
git config user.name "Jenkins CI"
|
||||
git add ${env.VERSION_FILE}
|
||||
git diff --cached --quiet || git commit -m "ci: bump ${env.IMAGE_NAME} to ${env.IMAGE_VERSION} [skip ci]"
|
||||
git push origin HEAD:main
|
||||
"""
|
||||
sshagent(credentials: ['jenkins-ssh-key']) {
|
||||
script {
|
||||
bat """
|
||||
chcp 65001 >nul
|
||||
git config user.email "jenkins@xuqm.com"
|
||||
git config user.name "Jenkins CI"
|
||||
git add ${env.VERSION_FILE}
|
||||
git diff --cached --quiet || git commit -m "ci: bump ${env.IMAGE_NAME} to ${env.IMAGE_VERSION} [skip ci]"
|
||||
git push origin HEAD:main
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
README.md
普通文件
44
README.md
普通文件
@ -0,0 +1,44 @@
|
||||
# Xuqm Symbolicator
|
||||
|
||||
BugCollect 的独立符号化服务,提供 React Native、Electron、Vue、Android、iOS 和 Flutter
|
||||
堆栈处理入口。服务只负责符号化,不保存上传内容。
|
||||
|
||||
## 运行基线
|
||||
|
||||
- 生产容器:Node.js 24 LTS
|
||||
- 本地开发:Node.js 22.22 及以上受支持版本
|
||||
- 安装:`npm ci`
|
||||
- 构建:`npm run build`
|
||||
- 测试:`npm test`
|
||||
- 完整检查:`npm run check`
|
||||
|
||||
## HTTP 契约
|
||||
|
||||
- `GET /health`:返回服务状态。
|
||||
- `POST /api/symbolicate`:保持现有 JSON 请求与 JSON 响应结构。
|
||||
- 配置 `SYMBOLICATOR_API_KEY` 后,请求必须通过 `x-api-key` 请求头认证;未配置时保持
|
||||
既有开发模式行为。
|
||||
- 请求体最大 105 MiB。业务字段仍分别限制堆栈 100 KB、Sourcemap 50 MB 和
|
||||
ProGuard/R8 mapping 100 MB,超限请求不会进入解析器。
|
||||
|
||||
服务不提供 multipart 文件上传接口。`multer` 仅保留为现有依赖契约并升级至安全版本,
|
||||
不得在未定义文件数量、单文件大小、总大小和临时文件清理规则前直接启用。
|
||||
|
||||
## 容器
|
||||
|
||||
```bash
|
||||
docker build -t xuqm-symbolicator:local .
|
||||
docker run --rm -p 3000:3000 xuqm-symbolicator:local
|
||||
```
|
||||
|
||||
镜像使用锁文件安装、多阶段构建和非 root 用户运行。健康检查访问容器内
|
||||
`http://127.0.0.1:3000/health`。
|
||||
|
||||
当前 Linux 容器不内置 macOS `atos` 或 Flutter SDK。因此 iOS/Flutter 请求接口仍保留,
|
||||
但对应外部工具必须由实际部署环境提供;不能用 TypeScript 构建结果替代这两类运行验证。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 日志不得输出 API Key、请求体、Sourcemap、mapping、堆栈内容或宿主文件路径。
|
||||
- 不得把来自请求的路径或命令参数拼接进 shell;现有外部工具调用统一使用参数数组。
|
||||
- 依赖变更必须同步提交 `package-lock.json` 并使用 `npm ci` 验证。
|
||||
49
docs/IMPLEMENTATION_HANDOFF.md
普通文件
49
docs/IMPLEMENTATION_HANDOFF.md
普通文件
@ -0,0 +1,49 @@
|
||||
# Symbolicator 实施交接
|
||||
|
||||
## 当前架构
|
||||
|
||||
- `src/index.ts`:Express 应用工厂、公共中间件、健康检查和统一错误边界。
|
||||
- `src/routes/symbolicate.ts`:认证和 `/api/symbolicate` 路由编排。
|
||||
- `src/parsers/`:各平台解析器;路由层不复制解析规则。
|
||||
- `test/`:使用 Node 内置测试运行器验证 HTTP 契约和关键解析器。
|
||||
- `Dockerfile`:Node 24 LTS 多阶段、锁文件安装、非 root 运行和容器健康检查。
|
||||
|
||||
## 本次现代化决策
|
||||
|
||||
1. Express 5、source-map 0.8 和 TypeScript 7 已通过当前源码构建与契约测试,不保留旧版
|
||||
并行实现。
|
||||
2. multer 升级到 2.2.0,消除旧依赖链安全问题;当前没有 multipart HTTP 契约,因此不注册
|
||||
上传中间件。
|
||||
3. 全局请求体上限由 200 MiB 收敛为 105 MiB,覆盖现有 100 MB mapping 业务上限且避免
|
||||
无边界内存占用。
|
||||
4. 应用工厂使测试无需修改全局环境变量或占用固定端口;生产入口仍是
|
||||
`node dist/index.js`。
|
||||
5. 错误日志只记录错误类型,不记录请求体、凭据、符号文件和用户提供的路径。
|
||||
|
||||
## 接手验证
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run check
|
||||
npm audit --audit-level=low
|
||||
docker build -t xuqm-symbolicator:local .
|
||||
```
|
||||
|
||||
最后一项仅在 Docker daemon 可用时执行。iOS `atos` 和 Flutter SDK 不属于该 Linux
|
||||
镜像,相关平台仍需在具有对应工具链的实际运行环境单独验证。
|
||||
|
||||
## 2026-07-27 验证记录
|
||||
|
||||
- `npm ci`:通过,安装过程的依赖安全审计为 0 个漏洞。
|
||||
- `npm run check`:通过,共 8 项构建、HTTP 契约、解析器、安全和 Dockerfile 静态测试。
|
||||
- `npm start` + `GET /health`:构建产物启动成功并返回既有健康响应。
|
||||
- `node:24.18.0-alpine`:官方镜像清单包含 amd64、arm64 和 s390x。
|
||||
- `docker build`:未执行;本机安装了 Docker CLI,但 Docker daemon 未运行。
|
||||
- iOS/Flutter 外部工具链:未验证;当前 Linux 容器不提供 `atos` 和 Flutter SDK。
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 不得恢复 `npm install` 容器构建或忽略锁文件。
|
||||
- 不得把 Jenkins 仓库地址改回包含 Token 的 URL;现有 SSH credentials 方案是基线。
|
||||
- 不得在日志中输出认证信息或完整异常输入。
|
||||
- 不得在没有显式限额与清理策略时启用 multipart 上传。
|
||||
2201
package-lock.json
自动生成的
普通文件
2201
package-lock.json
自动生成的
普通文件
文件差异内容过多而无法显示
加载差异
25
package.json
25
package.json
@ -3,23 +3,28 @@
|
||||
"version": "1.0.0",
|
||||
"description": "BugCollect 符号化服务 - 支持 RN/Android/iOS/Flutter",
|
||||
"main": "dist/index.js",
|
||||
"engines": {
|
||||
"node": ">=22.22.0 <27"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "node --import tsx --test test/**/*.test.ts",
|
||||
"check": "npm run build && npm test",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"source-map": "^0.7.4",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"cors": "^2.8.5"
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
"multer": "2.2.0",
|
||||
"source-map": "0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.2",
|
||||
"tsx": "^4.6.2"
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/multer": "2.2.0",
|
||||
"@types/node": "24.13.3",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
71
src/index.ts
71
src/index.ts
@ -1,31 +1,62 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { symbolicateRouter } from './routes/symbolicate'
|
||||
import { createSymbolicateRouter } from './routes/symbolicate'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
const DEFAULT_BODY_LIMIT = '105mb'
|
||||
|
||||
// Middleware
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '200mb' }))
|
||||
app.use(express.text({ limit: '200mb' }))
|
||||
export interface AppOptions {
|
||||
apiKey?: string
|
||||
bodyLimit?: string
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.use('/api', symbolicateRouter)
|
||||
/**
|
||||
* 创建服务实例。
|
||||
*
|
||||
* 生产环境使用默认配置;测试通过显式参数隔离认证和请求体上限,
|
||||
* 避免修改全局环境变量或启动固定端口。
|
||||
*/
|
||||
export function createApp(options: AppOptions = {}) {
|
||||
const app = express()
|
||||
const bodyLimit = options.bodyLimit || DEFAULT_BODY_LIMIT
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'xuqm-symbolicator', version: '1.0.0' })
|
||||
})
|
||||
app.disable('x-powered-by')
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: bodyLimit }))
|
||||
app.use(express.text({ limit: bodyLimit }))
|
||||
app.use('/api', createSymbolicateRouter(options.apiKey))
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Error:', err.message)
|
||||
res.status(500).json({ success: false, error: err.message })
|
||||
})
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', service: 'xuqm-symbolicator', version: '1.0.0' })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Symbolicator service running on port ${PORT}`)
|
||||
})
|
||||
app.use((
|
||||
err: Error & { type?: string; status?: number; statusCode?: number },
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
const isBodyTooLarge =
|
||||
err.type === 'entity.too.large' ||
|
||||
err.status === 413 ||
|
||||
err.statusCode === 413
|
||||
const status = isBodyTooLarge ? 413 : 500
|
||||
const message = isBodyTooLarge ? 'request body too large' : 'internal server error'
|
||||
|
||||
// 只记录错误类型,不记录请求体、API Key、符号文件内容或宿主文件路径。
|
||||
console.error('Symbolicator request failed:', err.name)
|
||||
res.status(status).json({ success: false, error: message })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
export const app = createApp()
|
||||
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Symbolicator service running on port ${PORT}`)
|
||||
})
|
||||
}
|
||||
|
||||
export default app
|
||||
|
||||
@ -59,24 +59,24 @@ export function symbolicateWithDsym(
|
||||
|
||||
// 验证输入参数(防止命令注入)
|
||||
if (!validatePath(dsymPath)) {
|
||||
console.error('Invalid dsymPath:', dsymPath)
|
||||
console.error('Invalid dSYM path')
|
||||
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
|
||||
}
|
||||
|
||||
if (!validateAddress(loadAddress)) {
|
||||
console.error('Invalid loadAddress:', loadAddress)
|
||||
console.error('Invalid dSYM load address')
|
||||
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
|
||||
}
|
||||
|
||||
if (!validateArch(arch)) {
|
||||
console.error('Invalid arch:', arch)
|
||||
console.error('Invalid dSYM architecture')
|
||||
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
|
||||
}
|
||||
|
||||
// 验证所有地址格式
|
||||
for (const addr of addresses) {
|
||||
if (!validateAddress(addr)) {
|
||||
console.error('Invalid address:', addr)
|
||||
console.error('Invalid dSYM frame address')
|
||||
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
|
||||
}
|
||||
}
|
||||
@ -95,8 +95,9 @@ export function symbolicateWithDsym(
|
||||
}
|
||||
return `${f.function} (${f.file}:${f.line})`
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('atos symbolication failed:', err)
|
||||
} catch {
|
||||
// 子进程异常对象可能包含完整文件路径和调用参数,不可直接写入日志。
|
||||
console.error('atos symbolication failed')
|
||||
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,12 +46,12 @@ export function symbolicateWithFlutter(
|
||||
|
||||
// 验证路径安全性
|
||||
if (!validatePath(debugSymbolsPath)) {
|
||||
console.error('Invalid debugSymbolsPath:', debugSymbolsPath)
|
||||
console.error('Invalid Flutter debug symbols path')
|
||||
return stacktrace
|
||||
}
|
||||
|
||||
if (!validatePath(flutterPath)) {
|
||||
console.error('Invalid flutterPath:', flutterPath)
|
||||
console.error('Invalid Flutter executable path')
|
||||
return stacktrace
|
||||
}
|
||||
|
||||
@ -74,8 +74,9 @@ export function symbolicateWithFlutter(
|
||||
// 读取符号化结果
|
||||
const result = readFileSync(tmpOutput, 'utf-8')
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('Flutter symbolization failed:', err)
|
||||
} catch {
|
||||
// 子进程异常对象可能包含完整文件路径和调用参数,不可直接写入日志。
|
||||
console.error('Flutter symbolization failed')
|
||||
return stacktrace
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
|
||||
@ -25,27 +25,40 @@ export async function symbolicateWithSourcemap(
|
||||
const rawSourceMap = JSON.parse(sourcemapContent)
|
||||
const consumer = await new SourceMapConsumer(rawSourceMap)
|
||||
|
||||
const results: SymbolicatedFrame[] = []
|
||||
try {
|
||||
const results: SymbolicatedFrame[] = []
|
||||
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
const original = consumer.originalPositionFor({
|
||||
line: frame.line,
|
||||
column: frame.column,
|
||||
})
|
||||
|
||||
if (original.source) {
|
||||
results.push({
|
||||
originalFile: original.source,
|
||||
originalLine: original.line || 0,
|
||||
originalColumn: original.column || 0,
|
||||
originalFunction: original.name || frame.function,
|
||||
minifiedFile: frame.file,
|
||||
minifiedLine: frame.line,
|
||||
minifiedColumn: frame.column,
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
const original = consumer.originalPositionFor({
|
||||
line: frame.line,
|
||||
column: frame.column,
|
||||
})
|
||||
} else {
|
||||
// 无法映射,保留原始帧
|
||||
|
||||
if (original.source) {
|
||||
results.push({
|
||||
originalFile: original.source,
|
||||
originalLine: original.line || 0,
|
||||
originalColumn: original.column || 0,
|
||||
originalFunction: original.name || frame.function,
|
||||
minifiedFile: frame.file,
|
||||
minifiedLine: frame.line,
|
||||
minifiedColumn: frame.column,
|
||||
})
|
||||
} else {
|
||||
// 无法映射,保留原始帧
|
||||
results.push({
|
||||
originalFile: frame.file,
|
||||
originalLine: frame.line,
|
||||
originalColumn: frame.column,
|
||||
originalFunction: frame.function,
|
||||
minifiedFile: frame.file,
|
||||
minifiedLine: frame.line,
|
||||
minifiedColumn: frame.column,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 单帧解析失败不影响其余堆栈,保留该帧原始位置。
|
||||
results.push({
|
||||
originalFile: frame.file,
|
||||
originalLine: frame.line,
|
||||
@ -56,22 +69,12 @@ export async function symbolicateWithSourcemap(
|
||||
minifiedColumn: frame.column,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
// 解析失败,保留原始帧
|
||||
results.push({
|
||||
originalFile: frame.file,
|
||||
originalLine: frame.line,
|
||||
originalColumn: frame.column,
|
||||
originalFunction: frame.function,
|
||||
minifiedFile: frame.file,
|
||||
minifiedLine: frame.line,
|
||||
minifiedColumn: frame.column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
consumer.destroy()
|
||||
return results
|
||||
return results
|
||||
} finally {
|
||||
consumer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -3,36 +3,50 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { parseStackFrames, Platform } from '../parsers/stack-parser'
|
||||
import { symbolicateWithSourcemap, formatSymbolicatedStack, SymbolicatedFrame } from '../parsers/sourcemap'
|
||||
import { parseProguardMapping, deobfuscateStack } from '../parsers/proguard'
|
||||
import { symbolicateWithDsym } from '../parsers/dsym'
|
||||
import { symbolicateWithFlutter } from '../parsers/flutter'
|
||||
|
||||
export const symbolicateRouter = Router()
|
||||
function authMiddleware(apiKey: string) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// 如果没有配置 API Key,则跳过认证(开发模式)
|
||||
if (!apiKey) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// API Key 认证中间件
|
||||
const API_KEY = process.env.SYMBOLICATOR_API_KEY || ''
|
||||
const providedKey = req.headers['x-api-key']
|
||||
if (typeof providedKey !== 'string' || !sameSecret(providedKey, apiKey)) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Unauthorized: invalid or missing API key',
|
||||
})
|
||||
}
|
||||
|
||||
function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// 如果没有配置 API Key,则跳过认证(开发模式)
|
||||
if (!API_KEY) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const providedKey = req.headers['x-api-key'] as string
|
||||
if (!providedKey || providedKey !== API_KEY) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Unauthorized: invalid or missing API key',
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
// 应用认证中间件
|
||||
symbolicateRouter.use(authMiddleware)
|
||||
function sameSecret(provided: string, expected: string) {
|
||||
const providedBuffer = Buffer.from(provided)
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
return providedBuffer.length === expectedBuffer.length &&
|
||||
timingSafeEqual(providedBuffer, expectedBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 为每个应用实例创建独立路由,避免认证配置在模块加载时被全局固化。
|
||||
*/
|
||||
export function createSymbolicateRouter(
|
||||
apiKey = process.env.SYMBOLICATOR_API_KEY || ''
|
||||
) {
|
||||
const router = Router()
|
||||
router.use(authMiddleware(apiKey))
|
||||
router.post('/symbolicate', handleSymbolicate)
|
||||
return router
|
||||
}
|
||||
|
||||
interface SymbolicateRequest {
|
||||
/** 平台类型 */
|
||||
@ -70,8 +84,15 @@ interface SymbolicateRequest {
|
||||
*
|
||||
* 符号化堆栈,支持多个平台
|
||||
*/
|
||||
symbolicateRouter.post('/symbolicate', async (req: Request, res: Response) => {
|
||||
async function handleSymbolicate(req: Request, res: Response) {
|
||||
try {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'platform and stacktrace are required',
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
platform,
|
||||
stacktrace,
|
||||
@ -85,7 +106,8 @@ symbolicateRouter.post('/symbolicate', async (req: Request, res: Response) => {
|
||||
debugSymbolsPath,
|
||||
} = req.body as SymbolicateRequest
|
||||
|
||||
if (!platform || !stacktrace) {
|
||||
if (typeof platform !== 'string' || typeof stacktrace !== 'string' ||
|
||||
!platform || !stacktrace) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'platform and stacktrace are required',
|
||||
@ -177,13 +199,14 @@ symbolicateRouter.post('/symbolicate', async (req: Request, res: Response) => {
|
||||
...result,
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('Symbolication error:', err)
|
||||
// 输入文件内容可能包含业务代码,日志仅记录异常类型。
|
||||
console.error('Symbolication failed:', err?.name || 'Error')
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
error: err instanceof Error ? err.message : 'symbolication failed',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* JS Sourcemap 符号化
|
||||
|
||||
15
test/dockerfile.test.ts
普通文件
15
test/dockerfile.test.ts
普通文件
@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { test } from 'node:test'
|
||||
|
||||
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8')
|
||||
|
||||
test('Dockerfile 使用锁文件、多阶段和非 root 运行', () => {
|
||||
assert.match(dockerfile, /^FROM node:24\.18\.0-alpine AS dependencies$/m)
|
||||
assert.match(dockerfile, /COPY package\.json package-lock\.json \.\//)
|
||||
assert.equal(/\bRUN npm install\b/.test(dockerfile), false)
|
||||
assert.ok((dockerfile.match(/\bRUN npm ci\b/g) || []).length >= 2)
|
||||
assert.match(dockerfile, /^USER node$/m)
|
||||
assert.match(dockerfile, /^HEALTHCHECK /m)
|
||||
assert.match(dockerfile, /^CMD \["node", "dist\/index\.js"\]$/m)
|
||||
})
|
||||
91
test/http.test.ts
普通文件
91
test/http.test.ts
普通文件
@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Server } from 'node:http'
|
||||
import { AddressInfo } from 'node:net'
|
||||
import { afterEach, test } from 'node:test'
|
||||
import { createApp } from '../src'
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.splice(0).map(server => new Promise<void>((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve())
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
async function start(options: Parameters<typeof createApp>[0] = {}) {
|
||||
const server = createApp(options).listen(0, '127.0.0.1')
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.once('listening', resolve))
|
||||
const { port } = server.address() as AddressInfo
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
test('health 保持稳定响应契约', async () => {
|
||||
const baseUrl = await start()
|
||||
const response = await fetch(`${baseUrl}/health`)
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), {
|
||||
status: 'ok',
|
||||
service: 'xuqm-symbolicator',
|
||||
version: '1.0.0',
|
||||
})
|
||||
assert.equal(response.headers.get('x-powered-by'), null)
|
||||
})
|
||||
|
||||
test('配置 API Key 时拒绝未认证请求', async () => {
|
||||
const baseUrl = await start({ apiKey: 'test-only-key' })
|
||||
const response = await fetch(`${baseUrl}/api/symbolicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ platform: 'android', stacktrace: 'sample' }),
|
||||
})
|
||||
|
||||
assert.equal(response.status, 401)
|
||||
assert.deepEqual(await response.json(), {
|
||||
success: false,
|
||||
error: 'Unauthorized: invalid or missing API key',
|
||||
})
|
||||
})
|
||||
|
||||
test('Android 反混淆接口保持既有请求响应结构', async () => {
|
||||
const baseUrl = await start()
|
||||
const response = await fetch(`${baseUrl}/api/symbolicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
platform: 'android',
|
||||
stacktrace: 'at a.b.c.a(r8-map-id-sample:13)',
|
||||
mappingFile: [
|
||||
'com.example.Sample -> a.b.c:',
|
||||
' 13:13:void com.example.Sample.run():42:42 -> a',
|
||||
].join('\n'),
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
const body = await response.json() as { success: boolean; symbolicated: string }
|
||||
assert.equal(body.success, true)
|
||||
assert.match(body.symbolicated, /com\.example\.Sample\.[\w$]+\(Sample\.kt:42\)/)
|
||||
})
|
||||
|
||||
test('请求体超限返回受控错误且不进入业务处理', async () => {
|
||||
const baseUrl = await start({ bodyLimit: '1kb' })
|
||||
const response = await fetch(`${baseUrl}/api/symbolicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
platform: 'rn',
|
||||
stacktrace: 'x'.repeat(2048),
|
||||
sourcemap: '{}',
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(response.status, 413)
|
||||
assert.deepEqual(await response.json(), {
|
||||
success: false,
|
||||
error: 'request body too large',
|
||||
})
|
||||
})
|
||||
37
test/security.test.ts
普通文件
37
test/security.test.ts
普通文件
@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { test } from 'node:test'
|
||||
|
||||
function source(relativePath: string) {
|
||||
return readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
test('日志不直接输出认证值、请求体或用户文件路径', () => {
|
||||
const files = [
|
||||
'src/index.ts',
|
||||
'src/routes/symbolicate.ts',
|
||||
'src/parsers/dsym.ts',
|
||||
'src/parsers/flutter.ts',
|
||||
].map(source).join('\n')
|
||||
|
||||
const forbidden = [
|
||||
/console\.(?:log|error|warn)\([^)]*apiKey/,
|
||||
/console\.(?:log|error|warn)\([^)]*req\.body/,
|
||||
/console\.(?:log|error|warn)\([^)]*dsymPath/,
|
||||
/console\.(?:log|error|warn)\([^)]*debugSymbolsPath/,
|
||||
/console\.(?:log|error|warn)\([^)]*,\s*err\s*[),]/,
|
||||
]
|
||||
|
||||
for (const pattern of forbidden) {
|
||||
assert.equal(pattern.test(files), false, `发现不安全日志模式:${pattern}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('HTTP 输入和未来文件上传依赖均保持显式上限', () => {
|
||||
assert.match(source('src/index.ts'), /DEFAULT_BODY_LIMIT = '105mb'/)
|
||||
|
||||
const packageJson = JSON.parse(source('package.json')) as {
|
||||
dependencies: Record<string, string>
|
||||
}
|
||||
assert.equal(packageJson.dependencies.multer, '2.2.0')
|
||||
})
|
||||
33
test/sourcemap.test.ts
普通文件
33
test/sourcemap.test.ts
普通文件
@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { SourceMapGenerator } from 'source-map'
|
||||
import { symbolicateWithSourcemap } from '../src/parsers/sourcemap'
|
||||
|
||||
test('source-map 0.8 可解析并释放 consumer', async () => {
|
||||
const generator = new SourceMapGenerator({ file: 'index.bundle' })
|
||||
generator.addMapping({
|
||||
generated: { line: 1, column: 0 },
|
||||
original: { line: 8, column: 2 },
|
||||
source: 'src/example.ts',
|
||||
name: 'run',
|
||||
})
|
||||
|
||||
const result = await symbolicateWithSourcemap([
|
||||
{
|
||||
function: 'a',
|
||||
file: 'index.bundle',
|
||||
line: 1,
|
||||
column: 0,
|
||||
},
|
||||
], generator.toString())
|
||||
|
||||
assert.deepEqual(result, [{
|
||||
originalFile: 'src/example.ts',
|
||||
originalLine: 8,
|
||||
originalColumn: 2,
|
||||
originalFunction: 'run',
|
||||
minifiedFile: 'index.bundle',
|
||||
minifiedLine: 1,
|
||||
minifiedColumn: 0,
|
||||
}])
|
||||
})
|
||||
正在加载...
在新工单中引用
屏蔽一个用户