fix(GOV-012): 修复 Schema 定义安全与错误语义

This commit is contained in:
2026-08-18 02:46:50 +08:00
parent 3d12c83621
commit 874796ab65
3 changed files with 79 additions and 13 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **a-share-analysis** (2358 symbols, 3190 relationships, 65 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **a-share-analysis** (2373 symbols, 3222 relationships, 63 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+41 -12
View File
@@ -136,9 +136,9 @@ async function queryCatalogRows(connection, sql, database, limits) {
values: [database], values: [database],
timeout: limits.queryTimeoutMs, timeout: limits.queryTimeoutMs,
}); });
} catch { } catch (error) {
// mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。 // mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。
throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败'); sanitizeDatabaseQueryError(error);
} }
if (!Array.isArray(result) || !Array.isArray(result[0])) { if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效'); throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效');
@@ -302,6 +302,19 @@ function maskStringLiterals(sql) {
return String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, "''"); return String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
} }
function removeTableAutoIncrementOutsideLiterals(sql) {
const literals = [];
const masked = String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, (literal) => {
const index = literals.push(literal) - 1;
return `\u0001${index}\u0002`;
});
const cleaned = masked.replace(
/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i,
'',
);
return cleaned.replace(/\u0001(\d+)\u0002/g, (_match, index) => literals[Number(index)]);
}
/** /**
* SHOW CREATE 的格式由服务端决定,这里只移除与结构无关的账号绑定、当前表自增值、 * SHOW CREATE 的格式由服务端决定,这里只移除与结构无关的账号绑定、当前表自增值、
* 行尾分号和换行差异,不重排对象体,也不删除字符串字面量中的业务文本。 * 行尾分号和换行差异,不重排对象体,也不删除字符串字面量中的业务文本。
@@ -313,20 +326,26 @@ export function normalizeCreateDefinition(definition) {
/^(CREATE\s+(?:ALGORITHM=\S+\s+)?)DEFINER=`(?:``|[^`])+`@`(?:`(?:``|[^`])+`|'(?:''|[^'])+'|\S+)\s+/i, /^(CREATE\s+(?:ALGORITHM=\S+\s+)?)DEFINER=`(?:``|[^`])+`@`(?:`(?:``|[^`])+`|'(?:''|[^'])+'|\S+)\s+/i,
'$1', '$1',
) )
.replace(/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i, '')
.trim() .trim()
.replace(/;+\s*$/, ''); .replace(/;+\s*$/, '');
const inspectable = maskStringLiterals(normalized); const withoutCurrentAutoIncrement = removeTableAutoIncrementOutsideLiterals(normalized);
const inspectable = maskStringLiterals(withoutCurrentAutoIncrement);
if ( if (
/\bDEFINER\s*=/i.test(inspectable) || /\bDEFINER\s*=/i.test(inspectable) ||
/\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i.test( /\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i.test(
inspectable, inspectable,
) || ) ||
/\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable) /\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable) ||
/(?:--|\/\*)\s*(?:host|hostname|server)\s*[:=]\s*[^\s*]+/i.test(inspectable)
) { ) {
throw new SchemaExportError('UNSAFE_DEFINITION', 'Schema 对象定义包含禁止内容'); throw new SchemaExportError('UNSAFE_DEFINITION', 'Schema 对象定义包含禁止内容');
} }
return normalized; return withoutCurrentAutoIncrement;
}
function sanitizeDatabaseQueryError(error) {
if (error instanceof SchemaExportError) throw error;
throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
} }
export async function readMysql57ServerMetadata( export async function readMysql57ServerMetadata(
@@ -334,11 +353,16 @@ export async function readMysql57ServerMetadata(
database, database,
limits = DEFAULT_EXPORT_LIMITS, limits = DEFAULT_EXPORT_LIMITS,
) { ) {
const [rows] = await connection.query({ let rows;
sql: SERVER_METADATA_QUERY, try {
values: [database], [rows] = await connection.query({
timeout: limits.queryTimeoutMs, sql: SERVER_METADATA_QUERY,
}); values: [database],
timeout: limits.queryTimeoutMs,
});
} catch (error) {
sanitizeDatabaseQueryError(error);
}
if (!Array.isArray(rows) || rows.length !== 1) { if (!Array.isArray(rows) || rows.length !== 1) {
throw new SchemaExportError('DATABASE_METADATA_INVALID', '开发库元数据查询结果无效'); throw new SchemaExportError('DATABASE_METADATA_INVALID', '开发库元数据查询结果无效');
} }
@@ -365,7 +389,12 @@ function getCreateDescriptors(catalog) {
async function readCreateDefinition(connection, descriptor, limits) { async function readCreateDefinition(connection, descriptor, limits) {
const sql = `SHOW CREATE ${descriptor.type} ${quoteIdentifier(DEVELOPMENT_DATABASE)}.${quoteIdentifier(descriptor.name)}`; const sql = `SHOW CREATE ${descriptor.type} ${quoteIdentifier(DEVELOPMENT_DATABASE)}.${quoteIdentifier(descriptor.name)}`;
const [rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs }); let rows;
try {
[rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs });
} catch (error) {
sanitizeDatabaseQueryError(error);
}
if (!Array.isArray(rows) || rows.length === 0) { if (!Array.isArray(rows) || rows.length === 0) {
throw new SchemaExportError('SHOW_CREATE_EMPTY', 'SHOW CREATE 未返回对象定义'); throw new SchemaExportError('SHOW_CREATE_EMPTY', 'SHOW CREATE 未返回对象定义');
} }
@@ -494,6 +494,20 @@ test('定义规范化只移除账号绑定和当前自增值', () => {
); );
}); });
test('规范化保留例程中的自增文本并拒绝主机信息', () => {
const routine = normalizeCreateDefinition(
"CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'",
);
assert.equal(
routine,
"CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'",
);
assert.throws(
() => normalizeCreateDefinition('CREATE TABLE `sample` (`id` int) /* Host: prod-db.internal */'),
(error) => error.code === 'UNSAFE_DEFINITION',
);
});
test('完整导出双重读取一致并始终关闭连接', async () => { test('完整导出双重读取一致并始终关闭连接', async () => {
const connection = createExportConnection(); const connection = createExportConnection();
let receivedOptions; let receivedOptions;
@@ -592,6 +606,29 @@ test('SHOW CREATE 拒绝空结果、错误形状和过大定义', async () => {
); );
}); });
test('直接元数据和 SHOW CREATE 查询错误统一脱敏且保留既有业务错误', async () => {
const rawFailure = new Error('host=prod-db.internal password=not-for-logs');
await assert.rejects(
() => readMysql57ServerMetadata({ query: async () => { throw rawFailure; } }, 'a_share_analysis_dev'),
(error) =>
error.code === 'DATABASE_QUERY_FAILED' &&
!error.message.includes('prod-db.internal') &&
!error.message.includes('not-for-logs'),
);
await assert.rejects(
() => readDefinitions({ query: async () => { throw rawFailure; } }, completeCatalog),
(error) =>
error.code === 'DATABASE_QUERY_FAILED' &&
!error.message.includes('prod-db.internal') &&
!error.message.includes('not-for-logs'),
);
const timeout = new SchemaExportError('EXPORT_TIMEOUT', 'Schema 导出超过总时长限制');
await assert.rejects(
() => readDefinitions({ query: async () => { throw timeout; } }, completeCatalog),
(error) => error === timeout,
);
});
test('查询失败和总超时只返回脱敏错误并关闭连接', async () => { test('查询失败和总超时只返回脱敏错误并关闭连接', async () => {
const failed = createExportConnection({ const failed = createExportConnection({
queryError: new Error('connect 127.0.0.1 password=not-for-logs'), queryError: new Error('connect 127.0.0.1 password=not-for-logs'),