refactor: HTTP checker 质量加固
- failure actual 截断格式改为 …(共 N 字符),标量不序列化直接返回 - 新增 redos.ts 实现 ReDoS 静态检测(嵌套量词/重叠交替),启动期拒绝危险正则 - JSON body rules 共享同一次 JSON.parse 结果,避免重复解析 - checkCssRule 重构为线性流程,消除 exist:true 与无 operator 的冗余分支 - extract checkEarlyTimeout 辅助函数,明确提前 duration 检查意图 - 补充 303/307/308 重定向、相对路径 Location、混合 body rules 集成测试
This commit is contained in:
@@ -483,14 +483,16 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
|
|||||||
**Body 规则类型**(`runner/http/body.ts`):
|
**Body 规则类型**(`runner/http/body.ts`):
|
||||||
|
|
||||||
- `contains`:文本包含匹配
|
- `contains`:文本包含匹配
|
||||||
- `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`)
|
- `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`,启动期会拒绝嵌套量词等 ReDoS 风险模式)
|
||||||
- `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
|
- `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
|
||||||
- `css`:cheerio CSS 选择器 + 操作符比较
|
- `css`:cheerio CSS 选择器 + 操作符比较
|
||||||
- `xpath`:XPath 节点提取 + 操作符比较
|
- `xpath`:XPath 节点提取 + 操作符比较
|
||||||
|
|
||||||
**文本规则**(`runner/command/text.ts`):stdout/stderr 文本匹配,支持 `contains`、`match`(正则)、操作符比较
|
**文本规则**(`runner/command/text.ts`):stdout/stderr 文本匹配,支持 `contains`、`match`(正则)、操作符比较
|
||||||
|
|
||||||
**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt`
|
**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则,启动期通过 `expect/redos.ts` 拒绝 ReDoS 风险模式)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt`
|
||||||
|
|
||||||
|
启动期语义校验会对 HTTP body `regex` 规则和所有 `match` operator 执行静态 ReDoS 检测,常见的嵌套量词模式如 `(a+)+`、`(\\d+)*x` 会被拒绝,避免运行期正则在大响应体上阻塞事件循环。
|
||||||
|
|
||||||
### 1.11 错误模式
|
### 1.11 错误模式
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ targets:
|
|||||||
- Command:覆盖命令执行耗时(含 stdout/stderr 读取)
|
- Command:覆盖命令执行耗时(含 stdout/stderr 读取)
|
||||||
- `body`: HTTP 响应体校验(数组,可组合使用)
|
- `body`: HTTP 响应体校验(数组,可组合使用)
|
||||||
- `contains`: 响应体包含的文本
|
- `contains`: 响应体包含的文本
|
||||||
- `regex`: 响应体匹配的正则表达式
|
- `regex`: 响应体匹配的正则表达式(启动期会拒绝嵌套量词等存在 ReDoS 风险的模式)
|
||||||
- `json`: JSONPath 提取值比较
|
- `json`: JSONPath 提取值比较
|
||||||
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`)
|
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`)
|
||||||
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
|
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
|
||||||
@@ -146,7 +146,7 @@ targets:
|
|||||||
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`)
|
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`)
|
||||||
- 比较操作符(可选,无操作符时仅检查节点是否存在)
|
- 比较操作符(可选,无操作符时仅检查节点是否存在)
|
||||||
- `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象)
|
- `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象)
|
||||||
- 比较操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
- 比较操作符:`equals`(默认)、`contains`、`match`(正则,启动期会拒绝存在 ReDoS 风险的模式)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
||||||
|
|
||||||
大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(非负安全整数字节数)。
|
大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(非负安全整数字节数)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-05-13
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
HTTP checker 是 DiAL 拨测系统的核心 runner 之一,负责对 HTTP 目标执行请求并校验响应。经审查发现以下质量问题:
|
|
||||||
|
|
||||||
1. **actual 值截断格式不符合 spec**:spec 要求 failure 中的 actual 摘要需截断并附带字符计数,但当前 `truncateActual` 函数只加省略号无计数,导致用户无法判断原始响应体规模。
|
|
||||||
2. **ReDoS 风险**:用户配置的 regex body 规则和 match operator 直接对大响应体执行 `new RegExp().test()`,恶意或不当正则可能导致 CPU 阻塞。
|
|
||||||
3. **JSON 重复解析**:多条 json body 规则各自独立调用 `JSON.parse(body)`,对大 JSON 响应体造成不必要的重复开销。
|
|
||||||
4. **CSS 规则分支冗余**:`checkCssRule` 中"无 operator 时检查元素存在"和"exists: true"是重复逻辑。
|
|
||||||
5. **重定向测试不足**:303、307/308、相对路径 Location 等分支缺少测试覆盖。
|
|
||||||
|
|
||||||
当前代码结构:
|
|
||||||
- `src/server/checker/expect/failure.ts` — failure 构造函数
|
|
||||||
- `src/server/checker/runner/http/body.ts` — body 规则检查
|
|
||||||
- `src/server/checker/runner/http/execute.ts` — HTTP 执行主流程
|
|
||||||
- `src/server/checker/expect/operator.ts` — operator 匹配逻辑
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- 实现 failure actual 值截断,满足 spec 要求
|
|
||||||
- 消除 regex 相关的 ReDoS 风险
|
|
||||||
- 优化多条 JSON 规则的解析性能
|
|
||||||
- 精简冗余代码分支
|
|
||||||
- 补全重定向和集成测试覆盖
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- 不改变 CheckResult / CheckFailure 的类型结构(截断在构造时完成,对外接口不变)
|
|
||||||
- 不引入新依赖
|
|
||||||
- 不改变 HTTP checker 的功能行为(纯内部质量改进)
|
|
||||||
- 不添加 response timing 分段记录(暂缓)
|
|
||||||
- 不添加重试机制(拨测场景下重试会掩盖网络问题信号)
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: actual 截断在 mismatchFailure 构造点统一实施
|
|
||||||
|
|
||||||
**选择**:在 `expect/failure.ts` 的 `mismatchFailure` 函数内部对 actual 参数截断,阈值 200 字符。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- 在存储层(store.ts insertCheckResult)截断 — 但这样 API 实时返回的 failure 仍然很大
|
|
||||||
- 在每个调用点手动截断 — 分散且容易遗漏
|
|
||||||
|
|
||||||
**理由**:构造点截断是最集中的拦截位置,所有 mismatch failure 都经过此函数,一处修改全局生效。expected 值不截断(来自用户配置,通常很短)。
|
|
||||||
|
|
||||||
**截断格式**:`<前 200 字符>…(共 N 字符)` — 保留前缀便于诊断,附带总长度便于判断规模(省略号为单字符 U+2026)。
|
|
||||||
|
|
||||||
### Decision 2: ReDoS 防护使用正则复杂度静态检测
|
|
||||||
|
|
||||||
**选择**:在启动期 validate 阶段对 regex body 规则和 match operator 进行静态复杂度检测,拒绝含有嵌套量词等危险模式的正则。运行期不做额外防护。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- 运行期用 AbortSignal + setTimeout 强制中断 — Bun 的 RegExp 执行不可中断,无法实现
|
|
||||||
- 使用 safe-regex 库 — 引入新依赖,违反项目规范
|
|
||||||
- 限制正则执行的输入长度 — 会影响正常大响应体的匹配
|
|
||||||
|
|
||||||
**理由**:自行实现轻量级检测函数,检查常见 ReDoS 模式(嵌套量词 `(a+)+`、重叠交替 `(a|a)*`)。在 validate 阶段拒绝危险正则,比运行期防护更可靠——配置错误应该在启动时暴露。
|
|
||||||
|
|
||||||
**检测规则**:
|
|
||||||
- 嵌套量词:量词内包含量词(如 `(a+)+`、`(a*)*`、`(a+)*`)
|
|
||||||
- 重叠字符类交替后跟量词:`(x|x)+` 模式
|
|
||||||
|
|
||||||
### Decision 3: JSON parse 结果缓存在 checkBodyExpect 层
|
|
||||||
|
|
||||||
**选择**:在 `checkBodyExpect` 函数中,首次遇到 json 规则时执行 `JSON.parse`,将结果缓存并传递给后续 json 规则复用。
|
|
||||||
|
|
||||||
**实现方式**:修改 `checkSingleBodyRule` 签名,接受可选的 `parsedJson` 参数;在 `checkBodyExpect` 循环中维护一个 `let parsedJson: { ok: boolean; value?: unknown; error?: string }` 状态。
|
|
||||||
|
|
||||||
**理由**:改动最小,不改变外部接口,只在内部传递缓存。对于非 json 规则(contains、regex、css、xpath)无影响。
|
|
||||||
|
|
||||||
### Decision 4: CSS 规则分支合并策略
|
|
||||||
|
|
||||||
**选择**:将 `checkCssRule` 重构为线性流程:
|
|
||||||
1. 解析 HTML
|
|
||||||
2. 处理 `exists: false`(元素不存在即通过)
|
|
||||||
3. 查找元素(不存在则失败)
|
|
||||||
4. 处理 `exists: true`(到这里已确认存在,直接通过)
|
|
||||||
5. 提取值(attr 或 text)
|
|
||||||
6. 无 operator 时检查值非 undefined 即通过
|
|
||||||
7. 有 operator 时执行匹配
|
|
||||||
|
|
||||||
**理由**:消除当前三层嵌套判断中的重复逻辑,使控制流线性化,更易理解和维护。
|
|
||||||
|
|
||||||
### Decision 5: execute.ts 提前 duration 检查保留但加注释
|
|
||||||
|
|
||||||
**选择**:保留第 56-74 行的提前 duration 检查逻辑(它是有效的性能优化——避免读取注定超时的 body),但重构为独立的 helper 函数使意图更明确。
|
|
||||||
|
|
||||||
**理由**:删除它会导致超时场景下仍然读取完整 body 后才报错,浪费网络带宽和时间。提取为 `checkEarlyTimeout` 函数名即可自解释。
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- **ReDoS 静态检测的误报**:过于严格的检测可能拒绝合法但看起来复杂的正则。→ 缓解:只检测最常见的嵌套量词模式,不做过度分析;提供清晰的错误信息指导用户修改。
|
|
||||||
- **actual 截断丢失诊断信息**:截断后用户无法看到完整 actual 值。→ 缓解:200 字符的前缀通常足够定位问题;如需完整响应体,用户应直接请求目标 URL 查看。
|
|
||||||
- **JSON parse 缓存的内存占用**:对于大 JSON 响应体,缓存的 parsed 对象会在整个 body rules 检查期间驻留内存。→ 缓解:这是短暂的(单次检查周期内),且原本每条规则都会各自 parse 一份,缓存反而减少了峰值内存。
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
HTTP checker 经过审查发现若干质量问题:failure 中 actual 值截断格式不符合 spec 要求(缺少字符计数)导致诊断信息不完整、regex 规则缺少 ReDoS 防护存在 CPU 阻塞风险、多条 JSON body 规则重复 parse 造成不必要开销、CSS 规则分支冗余、重定向测试覆盖不足。需要统一修复以提升健壮性和代码质量。
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- 修正 `mismatchFailure` 中 actual 值截断格式,添加字符计数信息,格式为 `前 N 字符…(共 M 字符)`
|
|
||||||
- 为 regex body 规则和 match operator 添加 ReDoS 防护(执行超时或正则复杂度检测)
|
|
||||||
- 优化多条 JSON body 规则共享同一次 `JSON.parse` 结果,避免重复解析
|
|
||||||
- 精简 `body.ts` 中 `checkCssRule` 的冗余分支逻辑
|
|
||||||
- 精简 `execute.ts` 中提前 duration 检查的代码结构
|
|
||||||
- 补充重定向相关测试:303 method 转换、307/308 保持 method、相对路径 Location、混合 body rules 集成测试
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
- `expect-body-checkers`: 新增 actual 值截断的具体实现要求(spec 已声明但未细化截断阈值和格式)
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/server/checker/expect/failure.ts` — 新增截断逻辑
|
|
||||||
- `src/server/checker/runner/http/body.ts` — JSON parse 优化、CSS 分支精简
|
|
||||||
- `src/server/checker/runner/http/execute.ts` — duration 检查精简
|
|
||||||
- `src/server/checker/expect/operator.ts` — match operator ReDoS 防护
|
|
||||||
- `tests/server/checker/runner/http/runner.test.ts` — 补充重定向和集成测试
|
|
||||||
- `tests/server/checker/runner/shared/body.test.ts` — 补充截断相关测试
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
> 注:仅展示变更的 scenarios,其余 scenarios 保持不变
|
|
||||||
|
|
||||||
### Requirement: 结构化 expect 失败信息
|
|
||||||
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符,超出部分以省略标记和总字符数替代。expected 值不截断。
|
|
||||||
|
|
||||||
#### Scenario: body 规则失败信息
|
|
||||||
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
|
||||||
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
|
||||||
|
|
||||||
#### Scenario: actual 值截断
|
|
||||||
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
|
|
||||||
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
|
|
||||||
|
|
||||||
#### Scenario: actual 值未超限
|
|
||||||
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
|
|
||||||
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
|
|
||||||
|
|
||||||
#### Scenario: actual 值为对象或数组
|
|
||||||
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
|
|
||||||
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
|
|
||||||
|
|
||||||
#### Scenario: actual 值为标量
|
|
||||||
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
|
|
||||||
- **THEN** failure.actual SHALL 保留原始值,不做截断
|
|
||||||
|
|
||||||
### Requirement: HTTP expect 规则启动期校验
|
|
||||||
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
|
|
||||||
|
|
||||||
#### Scenario: body rule 使用 regex 字段
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
|
|
||||||
|
|
||||||
#### Scenario: body rule 不支持 match 字段
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: body rule 未知字段启动失败
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
|
|
||||||
|
|
||||||
#### Scenario: body rule 多支持字段非法
|
|
||||||
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator match 正则非法
|
|
||||||
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator 数值比较类型非法
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte,且对应值不是有限数字
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator 布尔类型非法
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists,且对应值不是布尔值
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: JSONPath 子集非法
|
|
||||||
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator 未知字段非法
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: equals 支持对象
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
|
|
||||||
|
|
||||||
#### Scenario: equals 支持数组
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
|
|
||||||
|
|
||||||
#### Scenario: 纯 operator 对象不能为空
|
|
||||||
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
|
|
||||||
|
|
||||||
#### Scenario: json rule 允许存在性语义
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
|
|
||||||
|
|
||||||
#### Scenario: css rule 未知字段非法
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
|
||||||
|
|
||||||
#### Scenario: xpath rule 未知字段非法
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
|
||||||
|
|
||||||
#### Scenario: regex body 规则含嵌套量词启动失败
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
|
||||||
|
|
||||||
#### Scenario: match operator 含嵌套量词启动失败
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
|
||||||
|
|
||||||
#### Scenario: 安全正则通过校验
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
## 1. failure actual 截断
|
|
||||||
|
|
||||||
- [ ] 1.1 修改 `src/server/checker/expect/failure.ts` 中 `truncateActual` 函数,截断后缀从 `...` 改为 `…(共 N 字符)`,其中省略号为单字符 U+2026
|
|
||||||
- [ ] 1.2 更新 `tests/server/checker/runner/shared/failure.test.ts` 中截断相关测试断言,匹配新格式(检查省略号为单字符且带字符计数)
|
|
||||||
|
|
||||||
## 2. ReDoS 防护
|
|
||||||
|
|
||||||
- [ ] 2.1 在 `src/server/checker/expect/` 下新增 `redos.ts`,实现 `isUnsafeRegex(pattern: string): boolean` 函数,检测嵌套量词模式
|
|
||||||
- [ ] 2.2 在 `src/server/checker/runner/http/validate.ts` 的 `validateRegexRule` 和 `src/server/checker/expect/validate-operator.ts` 的 match 校验中调用 `isUnsafeRegex`,不安全时返回 issue
|
|
||||||
- [ ] 2.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 ReDoS 正则启动校验失败的测试用例
|
|
||||||
- [ ] 2.4 在 `tests/server/checker/runner/shared/` 下新增 `redos.test.ts`,覆盖常见 ReDoS 模式和安全正则的判定
|
|
||||||
|
|
||||||
## 3. JSON parse 优化
|
|
||||||
|
|
||||||
- [ ] 3.1 修改 `src/server/checker/runner/http/body.ts` 中 `checkBodyExpect` 函数,维护 parsedJson 缓存状态,首次 json 规则 parse 后复用结果
|
|
||||||
- [ ] 3.2 修改 `checkJsonRule` 签名接受可选的预解析 JSON 对象,避免重复 `JSON.parse`
|
|
||||||
- [ ] 3.3 在 `tests/server/checker/runner/shared/body.test.ts` 中补充多条 json 规则共享 parse 结果的测试(验证行为正确性)
|
|
||||||
|
|
||||||
## 4. CSS 规则精简
|
|
||||||
|
|
||||||
- [ ] 4.1 重构 `src/server/checker/runner/http/body.ts` 中 `checkCssRule` 为线性流程:解析 HTML → exists:false 短路 → 查找元素 → exists:true 短路 → 提取值 → operator 匹配
|
|
||||||
- [ ] 4.2 确认 `tests/server/checker/runner/shared/body.test.ts` 中现有 CSS 测试全部通过
|
|
||||||
|
|
||||||
## 5. execute.ts 精简
|
|
||||||
|
|
||||||
- [ ] 5.1 将 `src/server/checker/runner/http/execute.ts` 第 56-74 行的提前 duration 检查提取为 `checkEarlyTimeout` 辅助函数,明确意图
|
|
||||||
|
|
||||||
## 6. 补充测试
|
|
||||||
|
|
||||||
- [ ] 6.1 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 303 重定向 method 转 GET 的测试
|
|
||||||
- [ ] 6.2 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 307/308 保持原始 method 和 body 的测试
|
|
||||||
- [ ] 6.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充相对路径 Location header 重定向的测试
|
|
||||||
- [ ] 6.4 在 `tests/server/checker/runner/http/runner.test.ts` 中补充混合 body rules(contains + json + css)集成测试
|
|
||||||
|
|
||||||
## 7. 质量保障
|
|
||||||
|
|
||||||
- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归
|
|
||||||
- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要)
|
|
||||||
@@ -117,15 +117,27 @@
|
|||||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||||
|
|
||||||
### Requirement: 结构化 expect 失败信息
|
### Requirement: 结构化 expect 失败信息
|
||||||
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。
|
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符,超出部分以省略标记和总字符数替代。expected 值不截断。
|
||||||
|
|
||||||
#### Scenario: body 规则失败信息
|
#### Scenario: body 规则失败信息
|
||||||
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
||||||
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
||||||
|
|
||||||
#### Scenario: actual 值截断
|
#### Scenario: actual 值截断
|
||||||
- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度
|
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
|
||||||
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出
|
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
|
||||||
|
|
||||||
|
#### Scenario: actual 值未超限
|
||||||
|
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
|
||||||
|
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
|
||||||
|
|
||||||
|
#### Scenario: actual 值为对象或数组
|
||||||
|
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
|
||||||
|
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
|
||||||
|
|
||||||
|
#### Scenario: actual 值为标量
|
||||||
|
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
|
||||||
|
- **THEN** failure.actual SHALL 保留原始值,不做截断
|
||||||
|
|
||||||
### Requirement: 状态码范围匹配
|
### Requirement: 状态码范围匹配
|
||||||
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"`、`"2xx"`、`"3xx"`、`"4xx"`、`"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
|
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"`、`"2xx"`、`"3xx"`、`"4xx"`、`"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
|
||||||
@@ -163,10 +175,10 @@
|
|||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
- **THEN** 系统 SHALL 在启动期配置校验失败
|
||||||
|
|
||||||
### Requirement: HTTP expect 规则启动期校验
|
### Requirement: HTTP expect 规则启动期校验
|
||||||
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。
|
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
|
||||||
|
|
||||||
#### Scenario: body rule 使用 regex 字段
|
#### Scenario: body rule 使用 regex 字段
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译
|
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
|
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
|
||||||
|
|
||||||
#### Scenario: body rule 不支持 match 字段
|
#### Scenario: body rule 不支持 match 字段
|
||||||
@@ -225,6 +237,18 @@
|
|||||||
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
|
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
||||||
|
|
||||||
|
#### Scenario: regex body 规则含嵌套量词启动失败
|
||||||
|
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
|
||||||
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
||||||
|
|
||||||
|
#### Scenario: match operator 含嵌套量词启动失败
|
||||||
|
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
|
||||||
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
||||||
|
|
||||||
|
#### Scenario: 安全正则通过校验
|
||||||
|
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
|
||||||
|
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)
|
||||||
|
|
||||||
### Requirement: HTTP body 运行期失败结构化
|
### Requirement: HTTP body 运行期失败结构化
|
||||||
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。
|
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export function mismatchFailure(
|
|||||||
|
|
||||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||||
if (value === undefined || value === null) return value;
|
if (value === undefined || value === null) return value;
|
||||||
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
||||||
|
const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined;
|
||||||
|
if (str === undefined) return value;
|
||||||
if (str.length <= maxLen) return value;
|
if (str.length <= maxLen) return value;
|
||||||
return str.slice(0, maxLen) + "...";
|
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/server/checker/expect/redos.ts
Normal file
151
src/server/checker/expect/redos.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
export function isUnsafeRegex(pattern: string): boolean {
|
||||||
|
const groups = findQuantifiedGroups(pattern);
|
||||||
|
return groups.some((group) => containsQuantifier(group) || containsOverlappingAlternation(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsOverlappingAlternation(pattern: string): boolean {
|
||||||
|
const branches = splitTopLevelAlternation(stripGroupPrefix(pattern));
|
||||||
|
if (branches.length < 2) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < branches.length; i++) {
|
||||||
|
const current = branches[i]!;
|
||||||
|
if (current === "") continue;
|
||||||
|
for (let j = i + 1; j < branches.length; j++) {
|
||||||
|
const next = branches[j]!;
|
||||||
|
if (next === "") continue;
|
||||||
|
if (current === next || current.startsWith(next) || next.startsWith(current)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsQuantifier(pattern: string): boolean {
|
||||||
|
const input = stripGroupPrefix(pattern);
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i]!;
|
||||||
|
if (isEscaped(input, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
if (char === "*" || char === "+" || char === "?") return true;
|
||||||
|
if (char === "{" && readQuantifierBody(input, i) !== null) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuantifiedGroups(pattern: string): string[] {
|
||||||
|
const groups: string[] = [];
|
||||||
|
const stack: number[] = [];
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const char = pattern[i]!;
|
||||||
|
if (isEscaped(pattern, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
|
||||||
|
if (char === "(") {
|
||||||
|
stack.push(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ")") {
|
||||||
|
const start = stack.pop();
|
||||||
|
if (start === undefined) continue;
|
||||||
|
if (hasRepeatingQuantifierAt(pattern, i + 1)) {
|
||||||
|
groups.push(pattern.slice(start + 1, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRepeatingQuantifierAt(pattern: string, index: number): boolean {
|
||||||
|
const char = pattern[index];
|
||||||
|
if (char === "*" || char === "+") return true;
|
||||||
|
if (char !== "{") return false;
|
||||||
|
|
||||||
|
const body = readQuantifierBody(pattern, index);
|
||||||
|
if (body === null) return false;
|
||||||
|
const parts = body.split(",");
|
||||||
|
if (parts.length === 1) return Number(parts[0]) > 1;
|
||||||
|
if (parts[1] === "") return true;
|
||||||
|
return Number(parts[1]) > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEscaped(pattern: string, index: number): boolean {
|
||||||
|
let slashCount = 0;
|
||||||
|
for (let i = index - 1; i >= 0 && pattern[i] === "\\"; i--) {
|
||||||
|
slashCount++;
|
||||||
|
}
|
||||||
|
return slashCount % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readQuantifierBody(pattern: string, index: number): null | string {
|
||||||
|
const end = pattern.indexOf("}", index + 1);
|
||||||
|
if (end === -1) return null;
|
||||||
|
|
||||||
|
const body = pattern.slice(index + 1, end);
|
||||||
|
return /^\d+(?:,\d*)?$/.test(body) ? body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevelAlternation(pattern: string): string[] {
|
||||||
|
const branches: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let depth = 0;
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const char = pattern[i]!;
|
||||||
|
if (isEscaped(pattern, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
if (char === "(") {
|
||||||
|
depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ")") {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "|" && depth === 0) {
|
||||||
|
branches.push(pattern.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
branches.push(pattern.slice(start));
|
||||||
|
return branches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripGroupPrefix(pattern: string): string {
|
||||||
|
if (pattern.startsWith("?:") || pattern.startsWith("?=") || pattern.startsWith("?!")) return pattern.slice(2);
|
||||||
|
if (pattern.startsWith("?<=") || pattern.startsWith("?<!")) return pattern.slice(3);
|
||||||
|
|
||||||
|
const namedCapture = /^\?<[^>]+>/.exec(pattern);
|
||||||
|
return namedCapture ? pattern.slice(namedCapture[0].length) : pattern;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { JsonValue } from "../types";
|
|||||||
|
|
||||||
import { OperatorKeys } from "../schema/fragments";
|
import { OperatorKeys } from "../schema/fragments";
|
||||||
import { issue, joinPath } from "../schema/issues";
|
import { issue, joinPath } from "../schema/issues";
|
||||||
|
import { isUnsafeRegex } from "./redos";
|
||||||
|
|
||||||
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
|
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
|
||||||
|
|
||||||
@@ -70,10 +71,10 @@ export function validateOperatorValue(
|
|||||||
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||||
try {
|
try {
|
||||||
new RegExp(value);
|
new RegExp(value);
|
||||||
return [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||||
}
|
}
|
||||||
|
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||||
default:
|
default:
|
||||||
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
|
|||||||
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
||||||
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
|
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
|
||||||
|
|
||||||
|
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||||
|
|
||||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||||
|
|
||||||
|
let parsedJson: ParsedJsonResult | undefined;
|
||||||
|
|
||||||
for (let i = 0; i < rules.length; i++) {
|
for (let i = 0; i < rules.length; i++) {
|
||||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
const rule = rules[i]!;
|
||||||
|
if ("json" in rule && parsedJson === undefined) {
|
||||||
|
parsedJson = parseJsonBody(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = checkSingleBodyRule(body, rule, i, parsedJson);
|
||||||
if (!result.matched) return result;
|
if (!result.matched) return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const el = $(selector);
|
const el = $(selector);
|
||||||
const opKeys = Object.keys(operators);
|
|
||||||
|
|
||||||
if (opKeys.length === 0) {
|
|
||||||
if (attr !== undefined) {
|
|
||||||
if (el.attr(attr) === undefined) {
|
|
||||||
return {
|
|
||||||
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
|
||||||
matched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { failure: null, matched: true };
|
|
||||||
}
|
|
||||||
if (el.length === 0) {
|
|
||||||
return {
|
|
||||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
|
||||||
matched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { failure: null, matched: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operators.exists === true) {
|
|
||||||
if (el.length === 0) {
|
|
||||||
return {
|
|
||||||
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
|
|
||||||
matched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { failure: null, matched: true };
|
|
||||||
}
|
|
||||||
if (operators.exists === false) {
|
if (operators.exists === false) {
|
||||||
if (el.length > 0) {
|
if (el.length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (el.length === 0) {
|
if (el.length === 0) {
|
||||||
|
const expected = operators.exists === true ? true : "element found";
|
||||||
|
const actual = operators.exists === true ? false : "no match";
|
||||||
return {
|
return {
|
||||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
failure: mismatchFailure("body", fullPath, expected, actual, `selector ${selector} not found`),
|
||||||
matched: false,
|
matched: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operators.exists === true) return { failure: null, matched: true };
|
||||||
|
|
||||||
const actual = attr ? el.attr(attr) : el.text();
|
const actual = attr ? el.attr(attr) : el.text();
|
||||||
|
const opKeys = Object.keys(operators);
|
||||||
|
if (opKeys.length === 0) {
|
||||||
|
if (actual === undefined) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
const matched = applyOperator(actual ?? "", operators);
|
const matched = applyOperator(actual ?? "", operators);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return {
|
return {
|
||||||
@@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
|||||||
return { failure: null, matched: true };
|
return { failure: null, matched: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult {
|
function checkJsonRule(body: string, rule: JsonRule, rulePath: string, parsedJson?: ParsedJsonResult): ExpectResult {
|
||||||
const { path, ...operators } = rule;
|
const { path, ...operators } = rule;
|
||||||
const fullPath = `${rulePath}.json(${path})`;
|
const fullPath = `${rulePath}.json(${path})`;
|
||||||
|
|
||||||
let json: unknown;
|
const jsonResult = parsedJson ?? parseJsonBody(body);
|
||||||
try {
|
if (!jsonResult.ok) {
|
||||||
json = JSON.parse(body);
|
|
||||||
} catch {
|
|
||||||
return {
|
return {
|
||||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
failure: errorFailure("body", fullPath, jsonResult.error),
|
||||||
matched: false,
|
matched: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const actual = evaluateJsonPath(json, path);
|
const actual = evaluateJsonPath(jsonResult.value, path);
|
||||||
const opKeys = Object.keys(operators);
|
const opKeys = Object.keys(operators);
|
||||||
|
|
||||||
if (opKeys.length === 0) {
|
if (opKeys.length === 0) {
|
||||||
@@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe
|
|||||||
return { failure: null, matched: true };
|
return { failure: null, matched: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult {
|
function checkSingleBodyRule(body: string, rule: BodyRule, index: number, parsedJson?: ParsedJsonResult): ExpectResult {
|
||||||
const rulePath = `body[${index}]`;
|
const rulePath = `body[${index}]`;
|
||||||
|
|
||||||
if ("contains" in rule) {
|
if ("contains" in rule) {
|
||||||
@@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("json" in rule) {
|
if ("json" in rule) {
|
||||||
return checkJsonRule(body, rule.json, rulePath);
|
return checkJsonRule(body, rule.json, rulePath, parsedJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("css" in rule) {
|
if ("css" in rule) {
|
||||||
@@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
|
|||||||
}
|
}
|
||||||
return { failure: null, matched: true };
|
return { failure: null, matched: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonBody(body: string): ParsedJsonResult {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(body) as unknown };
|
||||||
|
} catch {
|
||||||
|
return { error: "body is not valid JSON", ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
|||||||
|
|
||||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||||
|
|
||||||
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
|
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
|
||||||
const elapsed = performance.now() - start;
|
if (earlyTimeout) {
|
||||||
if (elapsed > expect.maxDurationMs) {
|
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
|
||||||
const durationMs = Math.round(elapsed);
|
|
||||||
return makeResult(
|
|
||||||
t,
|
|
||||||
timestamp,
|
|
||||||
elapsed,
|
|
||||||
mismatchFailure(
|
|
||||||
"duration",
|
|
||||||
"duration",
|
|
||||||
`<=${expect.maxDurationMs}ms`,
|
|
||||||
durationMs,
|
|
||||||
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
|
|
||||||
),
|
|
||||||
statusCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBodyRules) {
|
if (hasBodyRules) {
|
||||||
@@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
|
|||||||
return newInit;
|
return newInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkEarlyTimeout(
|
||||||
|
start: number,
|
||||||
|
maxDurationMs: number | undefined,
|
||||||
|
): null | { elapsed: number; failure: CheckResult["failure"] } {
|
||||||
|
if (maxDurationMs === undefined) return null;
|
||||||
|
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
if (elapsed <= maxDurationMs) return null;
|
||||||
|
|
||||||
|
const durationMs = Math.round(elapsed);
|
||||||
|
return {
|
||||||
|
elapsed,
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"duration",
|
||||||
|
"duration",
|
||||||
|
`<=${maxDurationMs}ms`,
|
||||||
|
durationMs,
|
||||||
|
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function decodeBody(
|
function decodeBody(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as xpath from "xpath";
|
|||||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
import type { CheckerValidationInput } from "../types";
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import { isUnsafeRegex } from "../../expect/redos";
|
||||||
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
|
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
|
||||||
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
|
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
|
||||||
import { issue, joinPath } from "../../schema/issues";
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
@@ -188,10 +189,10 @@ function validateRegexRule(rule: unknown, path: string, targetName?: string): Co
|
|||||||
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||||
try {
|
try {
|
||||||
new RegExp(rule);
|
new RegExp(rule);
|
||||||
return [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||||
}
|
}
|
||||||
|
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
|||||||
@@ -67,13 +67,17 @@ describe("HttpChecker", () => {
|
|||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
server = Bun.serve({
|
server = Bun.serve({
|
||||||
fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
switch (url.pathname) {
|
switch (url.pathname) {
|
||||||
case "/echo":
|
case "/echo":
|
||||||
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
|
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
});
|
});
|
||||||
|
case "/echo-actual":
|
||||||
|
return new Response(JSON.stringify({ body: await req.text(), method: req.method }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
case "/gbk": {
|
case "/gbk": {
|
||||||
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
|
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
|
||||||
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
|
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
|
||||||
@@ -88,6 +92,10 @@ describe("HttpChecker", () => {
|
|||||||
});
|
});
|
||||||
case "/large":
|
case "/large":
|
||||||
return new Response("x".repeat(2000));
|
return new Response("x".repeat(2000));
|
||||||
|
case "/mixed":
|
||||||
|
return new Response(JSON.stringify({ html: "<span>OK</span>", status: "ok" }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
case "/notfound":
|
case "/notfound":
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
case "/ok":
|
case "/ok":
|
||||||
@@ -96,6 +104,12 @@ describe("HttpChecker", () => {
|
|||||||
});
|
});
|
||||||
case "/redirect":
|
case "/redirect":
|
||||||
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 });
|
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 });
|
||||||
|
case "/redirect-303":
|
||||||
|
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 303 });
|
||||||
|
case "/redirect-307":
|
||||||
|
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 307 });
|
||||||
|
case "/redirect-308":
|
||||||
|
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 308 });
|
||||||
case "/redirect-chain-1":
|
case "/redirect-chain-1":
|
||||||
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
|
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
|
||||||
case "/redirect-chain-2":
|
case "/redirect-chain-2":
|
||||||
@@ -109,6 +123,8 @@ describe("HttpChecker", () => {
|
|||||||
}
|
}
|
||||||
case "/redirect-post":
|
case "/redirect-post":
|
||||||
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
|
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
|
||||||
|
case "/redirect-relative":
|
||||||
|
return new Response(null, { headers: { location: "/ok" }, status: 302 });
|
||||||
case "/slow-body":
|
case "/slow-body":
|
||||||
return new Response("x".repeat(2000));
|
return new Response("x".repeat(2000));
|
||||||
case "/unknown-charset": {
|
case "/unknown-charset": {
|
||||||
@@ -529,6 +545,74 @@ describe("HttpChecker", () => {
|
|||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("303 重定向将 method 转为 GET 且清空 body", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
body: "payload",
|
||||||
|
expect: {
|
||||||
|
body: [{ json: { equals: "GET", path: "$.method" } }, { json: { equals: "", path: "$.body" } }],
|
||||||
|
status: [200],
|
||||||
|
},
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
maxRedirects: 1,
|
||||||
|
method: "POST",
|
||||||
|
url: `${baseUrl}/redirect-303`,
|
||||||
|
}),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("307/308 重定向保持原始 method 和 body", async () => {
|
||||||
|
for (const statusCode of [307, 308]) {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
body: `payload-${statusCode}`,
|
||||||
|
expect: {
|
||||||
|
body: [
|
||||||
|
{ json: { equals: "POST", path: "$.method" } },
|
||||||
|
{ json: { equals: `payload-${statusCode}`, path: "$.body" } },
|
||||||
|
],
|
||||||
|
status: [200],
|
||||||
|
},
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
maxRedirects: 1,
|
||||||
|
method: "POST",
|
||||||
|
url: `${baseUrl}/redirect-${statusCode}`,
|
||||||
|
}),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("相对路径 Location header 重定向", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-relative` }),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.statusDetail).toBe("HTTP 200");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("混合 body rules 集成检查", async () => {
|
||||||
|
const result = await checker.execute(
|
||||||
|
makeTarget({
|
||||||
|
expect: {
|
||||||
|
body: [
|
||||||
|
{ contains: '"status":"ok"' },
|
||||||
|
{ json: { equals: "ok", path: "$.status" } },
|
||||||
|
{ css: { equals: "OK", selector: "span" } },
|
||||||
|
],
|
||||||
|
status: [200],
|
||||||
|
},
|
||||||
|
url: `${baseUrl}/mixed`,
|
||||||
|
}),
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("跨 origin 重定向剥离敏感 headers", async () => {
|
test("跨 origin 重定向剥离敏感 headers", async () => {
|
||||||
const targetServer = Bun.serve({
|
const targetServer = Bun.serve({
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
@@ -616,6 +700,16 @@ describe("HttpChecker", () => {
|
|||||||
expect(errors).toContain("regex 正则不合法");
|
expect(errors).toContain("regex 正则不合法");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ReDoS regex body rule 启动校验失败", () => {
|
||||||
|
const errors = validateHttpTarget({
|
||||||
|
expect: { body: [{ regex: "(a+)+$" }] },
|
||||||
|
http: { url: "https://example.com" },
|
||||||
|
name: "test",
|
||||||
|
type: "http",
|
||||||
|
});
|
||||||
|
expect(errors).toContain("正则存在 ReDoS 风险");
|
||||||
|
});
|
||||||
|
|
||||||
test("非法 JSONPath 启动校验失败", () => {
|
test("非法 JSONPath 启动校验失败", () => {
|
||||||
const errors = validateHttpTarget({
|
const errors = validateHttpTarget({
|
||||||
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
|
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
|
||||||
@@ -636,6 +730,16 @@ describe("HttpChecker", () => {
|
|||||||
expect(errors).toContain("match 正则不合法");
|
expect(errors).toContain("match 正则不合法");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ReDoS operator match 启动校验失败", () => {
|
||||||
|
const errors = validateHttpTarget({
|
||||||
|
expect: { headers: { "x-test": { match: "(\\d+)*x" } } },
|
||||||
|
http: { url: "https://example.com" },
|
||||||
|
name: "test",
|
||||||
|
type: "http",
|
||||||
|
});
|
||||||
|
expect(errors).toContain("正则存在 ReDoS 风险");
|
||||||
|
});
|
||||||
|
|
||||||
test("非法 operator gte 类型启动失败", () => {
|
test("非法 operator gte 类型启动失败", () => {
|
||||||
const errors = validateHttpTarget({
|
const errors = validateHttpTarget({
|
||||||
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
|
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ describe("checkBodyExpect (BodyRule[])", () => {
|
|||||||
expect(r.failure).toBeNull();
|
expect(r.failure).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("多条 json 规则共享解析结果且全部通过", () => {
|
||||||
|
const body = JSON.stringify({ count: 5, status: "healthy" });
|
||||||
|
const originalParse = JSON.parse;
|
||||||
|
let parseCount = 0;
|
||||||
|
JSON.parse = ((text, reviver) => {
|
||||||
|
parseCount++;
|
||||||
|
return originalParse(text, reviver) as unknown;
|
||||||
|
}) as typeof JSON.parse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = checkBodyExpect(body, [
|
||||||
|
{ json: { equals: "healthy", path: "$.status" } },
|
||||||
|
{ json: { gte: 1, path: "$.count" } },
|
||||||
|
]);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
expect(parseCount).toBe(1);
|
||||||
|
} finally {
|
||||||
|
JSON.parse = originalParse;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("第二条规则失败返回正确索引", () => {
|
test("第二条规则失败返回正确索引", () => {
|
||||||
const body = JSON.stringify({ status: "ok" });
|
const body = JSON.stringify({ status: "ok" });
|
||||||
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]);
|
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]);
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ describe("truncateActual", () => {
|
|||||||
expect(truncateActual(str)).toBe(str);
|
expect(truncateActual(str)).toBe(str);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("超过限制长度截断并加省略号", () => {
|
test("超过限制长度截断并加省略号与字符计数", () => {
|
||||||
const str = "a".repeat(300);
|
const str = "a".repeat(300);
|
||||||
const result = truncateActual(str) as string;
|
const result = truncateActual(str) as string;
|
||||||
expect(result.length).toBe(203);
|
expect(result).toBe(`${"a".repeat(200)}…(共 300 字符)`);
|
||||||
expect(result.endsWith("...")).toBe(true);
|
expect(result.includes("...")).toBe(false);
|
||||||
expect(result.startsWith("a".repeat(200))).toBe(true);
|
expect(result.startsWith("a".repeat(200))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("自定义最大长度", () => {
|
test("自定义最大长度", () => {
|
||||||
const str = "abcdefghij";
|
const str = "abcdefghij";
|
||||||
const result = truncateActual(str, 5) as string;
|
const result = truncateActual(str, 5) as string;
|
||||||
expect(result).toBe("abcde...");
|
expect(result).toBe("abcde…(共 10 字符)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("null 不截断", () => {
|
test("null 不截断", () => {
|
||||||
@@ -34,9 +34,16 @@ describe("truncateActual", () => {
|
|||||||
expect(truncateActual(undefined)).toBe(undefined);
|
expect(truncateActual(undefined)).toBe(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("数字转换为字符串后判断", () => {
|
test("标量不截断", () => {
|
||||||
expect(truncateActual(42)).toBe(42);
|
expect(truncateActual(42)).toBe(42);
|
||||||
expect(truncateActual(123456789, 3) as string).toBe("123...");
|
expect(truncateActual(123456789, 3)).toBe(123456789);
|
||||||
|
expect(truncateActual(true, 3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("对象序列化后超限时截断", () => {
|
||||||
|
const result = truncateActual({ value: "x".repeat(20) }, 10) as string;
|
||||||
|
expect(result.startsWith('{"value":"')).toBe(true);
|
||||||
|
expect(result.endsWith("…(共 32 字符)")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,8 +63,7 @@ describe("mismatchFailure", () => {
|
|||||||
test("自动截断过长的 actual", () => {
|
test("自动截断过长的 actual", () => {
|
||||||
const longStr = "x".repeat(300);
|
const longStr = "x".repeat(300);
|
||||||
const f = mismatchFailure("body", "body[0]", "short", longStr, "too long");
|
const f = mismatchFailure("body", "body[0]", "short", longStr, "too long");
|
||||||
expect((f.actual as string).endsWith("...")).toBe(true);
|
expect(f.actual).toBe(`${"x".repeat(200)}…(共 300 字符)`);
|
||||||
expect((f.actual as string).length).toBe(203);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
25
tests/server/checker/runner/shared/redos.test.ts
Normal file
25
tests/server/checker/runner/shared/redos.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { isUnsafeRegex } from "../../../../../src/server/checker/expect/redos";
|
||||||
|
|
||||||
|
describe("isUnsafeRegex", () => {
|
||||||
|
test("识别嵌套量词", () => {
|
||||||
|
expect(isUnsafeRegex("(a+)+$")).toBe(true);
|
||||||
|
expect(isUnsafeRegex("(a*)*")).toBe(true);
|
||||||
|
expect(isUnsafeRegex("(a?)+")).toBe(true);
|
||||||
|
expect(isUnsafeRegex("(\\d+)*x")).toBe(true);
|
||||||
|
expect(isUnsafeRegex("(?:a+)+")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("识别重叠交替分支", () => {
|
||||||
|
expect(isUnsafeRegex("(a|a)+")).toBe(true);
|
||||||
|
expect(isUnsafeRegex("(a|aa)*")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("安全正则不误判", () => {
|
||||||
|
expect(isUnsafeRegex("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")).toBe(false);
|
||||||
|
expect(isUnsafeRegex("^(ok|healthy)$")).toBe(false);
|
||||||
|
expect(isUnsafeRegex("^[a-z0-9_-]+$")).toBe(false);
|
||||||
|
expect(isUnsafeRegex("([a+])+")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user