feat(GOV-012): 建立纯 Node 导出器安全契约

This commit is contained in:
2026-08-18 01:21:29 +08:00
parent 235163cb61
commit 06466f388e
5 changed files with 158 additions and 0 deletions
+74
View File
@@ -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 }));
}
});