feat(GOV-012): 建立纯 Node 导出器安全契约
This commit is contained in:
@@ -34,3 +34,29 @@
|
||||
## 高风险处置
|
||||
|
||||
这是全部版本开发的关键路径,风险至少为 HIGH。修改任何相关函数前必须重新执行 GitNexus upstream impact;若 MCP 仍不可用,使用静态调用关系、现有 50 条流程和全量回归交叉验证。历史基线失败、数据语句进入 SQL、生产边界被放宽或自动门禁未恢复时立即停止发布并回退。
|
||||
|
||||
## GOV-012 Task 1 重新核对(2026-08-18)
|
||||
|
||||
### 工具状态与静态替代证据
|
||||
|
||||
- 已执行 `npx gitnexus analyze`,结果为 `Already up to date`;已执行 `npx gitnexus status`,索引和当前提交均为 `235163c`,状态为 up-to-date。
|
||||
- GitNexus MCP 在本次开发环境没有可调用传输入口;结合任务启动时持续的 `Transport closed`,不能把 MCP 不可用解释为无影响。本轮使用 `rg -n` 对五个符号检索全部仓库静态引用(排除 `node_modules`、`.git` 与 `.gitnexus`),并以已刷新的 CLI 索引和后续 `git diff` 交叉验证。
|
||||
- 静态引用集中在 `tools/database/schema-baseline.mjs`、`tools/database/schema-baseline.test.mjs`、GOV-007/009/011/012 的治理档案及 `tools/runtime/verify-runtime.mjs`;Task 1 不修改这些既有执行符号或 format v1/v2 目录。
|
||||
- 以本 worktree 路径指定 CLI 后,`assertMysql57Metadata` 的 upstream 结果为 CRITICAL(4 个直接调用方、9 个受影响符号、5 组流程),`verifyBaselineDirectory` 为 HIGH(3 个直接调用方、6 个受影响符号、4 组流程)。前者较历史 5/10 计数少一,是当前索引未计入测试引用,不能降低历史关键路径风险判断。
|
||||
- CLI 将 `runBaselineGeneration` 和 `buildCapturedArtifacts` 显示为 LOW/0;与源码中 `runCommandLine -> runBaselineGeneration -> buildCapturedArtifacts` 的静态调用不一致,因此以静态调用补足,不记录为无影响。`runCommandLine` 与 `tools/runtime/verify-runtime.mjs` 中同名符号歧义,CLI 不支持按 UID 或文件路径消歧,使用 `schema-baseline.mjs` 主入口守卫的静态引用作为替代证据。
|
||||
|
||||
### 关键符号影响结论
|
||||
|
||||
| 符号 | 直接调用方/静态入口 | 受影响流程 | 实际风险与本任务控制 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runBaselineGeneration` | `runCommandLine` 的 `generate` 分支;Schema 单元测试直接调用 | `schema:baseline` 生成、随后 `schema:verify`、当前发布基线校验和 `governance:start` | HIGH;后续异步替换会改变错误传播与注入边界。Task 1 只新建模块,不改此函数。 |
|
||||
| `assertMysql57Metadata` | `buildCapturedArtifacts`、`buildCapturedEmptyArtifacts`、`verifyBaselineDirectory` 的 captured/captured_empty 分支、`runBaselineGeneration` 与 Schema 单元测试 | captured、captured_empty、历史目录验证、当前发布基线、基线生成和 `governance:start` | CRITICAL;历史 GitNexus 证据为 5 个直接调用方、10 个受影响符号和 5 组流程。Task 1 建立独立 `assertMysql57ServerMetadata`,不改变 v1/v2 语义。 |
|
||||
| `buildCapturedArtifacts` | `runBaselineGeneration`;Schema 单元测试 | captured 产物、清单/checksum、`schema:verify` | MEDIUM;v3 产物契约将在后续 Task 修改。本任务不改该函数。 |
|
||||
| `verifyBaselineDirectory` | `runBaselineGeneration` 的已存在目录、MySQL 关闭和 captured 分支;`assertCurrentReleasedBaseline`;`assertCurrentReleasedBaselinePublished`;Schema 单元测试 | `schema:verify`、已发布基线校验、`governance:start` | HIGH;format v1/v2 只读兼容是发布门禁。本任务不改该函数或历史目录。 |
|
||||
| `runCommandLine` | 模块主入口守卫;内部 `generate` 分支调用 `runBaselineGeneration` | `schema:baseline`、`schema:verify` 的 CLI 非零退出边界 | HIGH;后续改异步入口会影响命令完成时序。本任务不改此函数。 |
|
||||
|
||||
### 跨模块同步判断
|
||||
|
||||
- HTTP/OpenAPI、Web API、消息契约、Redis、API、Scheduler、Worker、部署:本 Task 仅新增 Node 治理工具安全基础,不接入运行时服务,均无同步代码修改;边界是后续 Task 将替换 Schema CLI 内部实现。
|
||||
- 数据库:不建立连接、不查询业务数据、不执行 DDL/DML/多语句/锁表;本 Task 仅固定未来连接选项和服务端元数据契约。
|
||||
- 测试、依赖和文档:新增 mysql2 根开发依赖、Node 单元测试和本影响记录;`pnpm audit --audit-level high` 是依赖安全门禁。
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"eslint": "9.39.5",
|
||||
"eslint-plugin-vue": "9.33.0",
|
||||
"globals": "17.11.0",
|
||||
"mysql2": "3.23.3",
|
||||
"prettier": "3.9.6",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.46.2"
|
||||
|
||||
Generated
+3
@@ -26,6 +26,9 @@ importers:
|
||||
globals:
|
||||
specifier: 17.11.0
|
||||
version: 17.11.0
|
||||
mysql2:
|
||||
specifier: 3.23.3
|
||||
version: 3.23.3(@types/node@22.20.0)
|
||||
prettier:
|
||||
specifier: 3.9.6
|
||||
version: 3.9.6
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const mysql2Version = require('mysql2/package.json').version;
|
||||
|
||||
export const MYSQL2_EXPORTER_METADATA = Object.freeze({
|
||||
name: 'mysql2-show-create',
|
||||
version: '1.0.0',
|
||||
driverName: 'mysql2',
|
||||
driverVersion: mysql2Version,
|
||||
});
|
||||
|
||||
export const DEFAULT_EXPORT_LIMITS = Object.freeze({
|
||||
connectTimeoutMs: 5_000,
|
||||
queryTimeoutMs: 5_000,
|
||||
totalTimeoutMs: 30_000,
|
||||
maxObjects: 5_000,
|
||||
maxDefinitionBytes: 1_048_576,
|
||||
});
|
||||
|
||||
export class SchemaExportError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = 'SchemaExportError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置在 Schema 治理入口已完成 development、主机白名单和固定库名校验;这里仅映射
|
||||
* 连接所需字段,固定禁用多语句以防止未来元数据查询被拼接为任意 SQL。
|
||||
*/
|
||||
export function buildConnectionOptions(config) {
|
||||
return {
|
||||
host: config.MYSQL_HOST,
|
||||
port: Number(config.MYSQL_PORT),
|
||||
user: config.MYSQL_USERNAME,
|
||||
password: config.MYSQL_PASSWORD,
|
||||
database: config.MYSQL_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
connectTimeout: DEFAULT_EXPORT_LIMITS.connectTimeoutMs,
|
||||
multipleStatements: false,
|
||||
enableKeepAlive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据对象名仍需防御性转义,避免控制字符破坏 `SHOW CREATE` 查询边界;名称不合法时
|
||||
* 不回显输入,确保异常路径不会把潜在敏感值写入日志。
|
||||
*/
|
||||
export function quoteIdentifier(identifier) {
|
||||
if (typeof identifier !== 'string' || identifier.length === 0 || /[\u0000-\u001F\u007F]/.test(identifier)) {
|
||||
throw new SchemaExportError('INVALID_IDENTIFIER', 'Schema 标识符不能为空且不得包含控制字符');
|
||||
}
|
||||
|
||||
return `\`${identifier.replaceAll('`', '``')}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出器只接受项目数据库兼容基线,拒绝 MariaDB 和 MySQL 5.7 之外的服务器,避免相同
|
||||
* `SHOW CREATE` 文本在不兼容语义下被误认为可恢复的 Schema 快照。
|
||||
*/
|
||||
export function assertMysql57ServerMetadata(metadata) {
|
||||
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata?.serverVersion ?? '')) {
|
||||
throw new SchemaExportError('UNSUPPORTED_SERVER_VERSION', 'MySQL 服务端必须为 5.7.x');
|
||||
}
|
||||
if (metadata.databaseCharset !== 'utf8mb4') {
|
||||
throw new SchemaExportError('INVALID_DATABASE_CHARSET', '数据库字符集必须为 utf8mb4');
|
||||
}
|
||||
if (metadata.databaseCollation !== 'utf8mb4_general_ci') {
|
||||
throw new SchemaExportError('INVALID_DATABASE_COLLATION', '数据库排序规则必须为 utf8mb4_general_ci');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_EXPORT_LIMITS,
|
||||
MYSQL2_EXPORTER_METADATA,
|
||||
SchemaExportError,
|
||||
assertMysql57ServerMetadata,
|
||||
buildConnectionOptions,
|
||||
quoteIdentifier,
|
||||
} from './mysql2-schema-exporter.mjs';
|
||||
|
||||
const config = Object.freeze({
|
||||
MYSQL_HOST: '127.0.0.1',
|
||||
MYSQL_PORT: '3306',
|
||||
MYSQL_DATABASE: 'a_share_analysis_dev',
|
||||
MYSQL_USERNAME: 'schema_reader',
|
||||
MYSQL_PASSWORD: 'not-for-logs',
|
||||
});
|
||||
|
||||
test('连接配置固定单连接、utf8mb4、东八区且禁用多语句', () => {
|
||||
const options = buildConnectionOptions(config);
|
||||
assert.deepEqual(options, {
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'schema_reader',
|
||||
password: 'not-for-logs',
|
||||
database: 'a_share_analysis_dev',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
connectTimeout: 5_000,
|
||||
multipleStatements: false,
|
||||
enableKeepAlive: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('标识符转义反引号并拒绝空值和控制字符', () => {
|
||||
assert.equal(quoteIdentifier('market`view'), '`market``view`');
|
||||
for (const value of ['', 'line\nbreak', 'zero\0byte']) {
|
||||
assert.throws(() => quoteIdentifier(value), SchemaExportError);
|
||||
}
|
||||
});
|
||||
|
||||
test('只接受 MySQL 5.7.x 与固定字符规则', () => {
|
||||
const valid = {
|
||||
serverVersion: '5.7.42-log',
|
||||
databaseCharset: 'utf8mb4',
|
||||
databaseCollation: 'utf8mb4_general_ci',
|
||||
};
|
||||
assert.doesNotThrow(() => assertMysql57ServerMetadata(valid));
|
||||
for (const serverVersion of ['5.6.51', '8.0.40', '10.6.18-MariaDB']) {
|
||||
assert.throws(() => assertMysql57ServerMetadata({ ...valid, serverVersion }));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user