fix(GOV-007): 收紧 Schema 基线安全门禁

This commit is contained in:
2026-08-13 13:12:58 +08:00
parent da056681b6
commit d827bfadb1
3 changed files with 107 additions and 6 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **a-share-analysis** (864 symbols, 1024 relationships, 17 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** (879 symbols, 1062 relationships, 23 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.
+31 -4
View File
@@ -130,9 +130,12 @@ function assertSchemaHasNoSensitiveOrDataStatements(sql) {
const sqlWithoutStringLiterals = sql.replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
const prohibitedPatterns = [
[sqlWithoutStringLiterals, /\bDEFINER\s*=/i, 'DEFINER'],
[sql, /^--\s*MySQL dump\b/im, 'MySQL dump 头'],
[sql, /--\s*Dump completed on/i, '导出完成时间'],
[sql, /--\s*Host:/i, '连接主机注释'],
[sql, /\bpassword\s*=/i, '疑似密码'],
[sql, /\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i, '疑似凭据'],
[sql, /\b(?:mysql|mariadb):\/\/[^\s'"`]+/i, '数据库连接串'],
[sql, /\b(?:production|prod)[-_\s]*(?:host|db|database)?\s*[:=]\s*[^\s'"`]+/i, '疑似生产主机'],
];
for (const [content, pattern, label] of prohibitedPatterns) {
if (pattern.test(content)) {
@@ -156,11 +159,16 @@ function assertSchemaHasNoSensitiveOrDataStatements(sql) {
);
}
const dataStatement = candidates.find((candidate) =>
/^(?:INSERT\s+INTO|REPLACE\s+INTO)\b/i.test(candidate),
/^(?:INSERT\s+INTO|REPLACE\s+INTO|UPDATE\b|DELETE\s+FROM\b|TRUNCATE(?:\s+TABLE)?\b|LOAD\s+(?:DATA|XML)\b)/i.test(
candidate,
),
);
if (dataStatement) {
const operation = dataStatement.match(
/^(?:INSERT\s+INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE(?:\s+TABLE)?|LOAD\s+(?:DATA|XML))/i,
)[0];
throw new Error(
`Schema SQL 包含禁止的数据写入:${dataStatement.match(/^(?:INSERT\s+INTO|REPLACE\s+INTO)/i)[0]}`,
`Schema SQL 包含禁止的数据操作:${operation}`,
);
}
}
@@ -629,13 +637,31 @@ export function getCurrentReleasedVersion(repositoryRoot) {
};
}
/**
* not_applicable 清单只记录生成当时 MySQL 未启用,不能永久替代真实结构基线。
* 每次校验都重新读取固定开发环境配置;一旦开发库启用,旧清单立即失效,强制
* 下一版本开发开始前先从开发库生成 captured 基线。
*/
export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) {
if (result.status !== 'not_applicable') return result;
const config = assertSafeDevelopmentConfiguration(
parseEnvironmentFile(readFileSync(resolveFixedDevelopmentEnv(repositoryRoot), 'utf8')),
);
if (config.MYSQL_ENABLED) {
throw new Error('当前开发环境已启用 MySQL,不能继续使用 not_applicable Schema 基线');
}
return result;
}
export function assertCurrentReleasedBaseline(repositoryRoot) {
const { applicationVersion, gitCommit } = getCurrentReleasedVersion(repositoryRoot);
return verifyBaselineDirectory(
const result = verifyBaselineDirectory(
path.join(repositoryRoot, 'database', 'schema-baselines', applicationVersion),
applicationVersion,
gitCommit,
);
return assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result);
}
function runGitLines(repositoryRoot, args) {
@@ -660,6 +686,7 @@ export function assertCurrentReleasedBaselinePublished(repositoryRoot) {
release.applicationVersion,
release.gitCommit,
);
assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result);
const trackedFiles = new Set(
runGitLines(repositoryRoot, ['ls-files', '--', relativeDirectory]),
);
+75 -1
View File
@@ -1,11 +1,13 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertMysql57Metadata,
assertNotApplicableMatchesDevelopmentConfiguration,
assertCurrentReleasedBaselinePublished,
assertSafeDevelopmentConfiguration,
buildCapturedArtifacts,
@@ -45,6 +47,16 @@ function makeTemporaryDirectory() {
return mkdtempSync(path.join(tmpdir(), 'a-share-schema-baseline-'));
}
function replaceCapturedSql(target, sql) {
const checksum = createHash('sha256').update(sql).digest('hex');
const manifestPath = path.join(target, 'manifest.yaml');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
manifest.schema_sha256 = checksum;
writeFileSync(path.join(target, 'schema.sql'), sql);
writeFileSync(path.join(target, 'checksum.sha256'), `${checksum} schema.sql\n`);
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
test('拒绝任何非开发环境和非固定开发库配置', () => {
assert.throws(
() => assertSafeDevelopmentConfiguration({ ...validConfig, APP_ENV: 'production' }),
@@ -179,6 +191,41 @@ test('数据扫描拒绝同行顶层写入但保留例程中的合法 DML', () =
}
});
test('数据扫描拒绝所有顶层数据修改和装载语句', () => {
for (const statement of [
'UPDATE `sample` SET `id` = 2',
'DELETE FROM `sample`',
'TRUNCATE TABLE `sample`',
"LOAD DATA LOCAL INFILE '/tmp/sample.csv' INTO TABLE `sample`",
"LOAD XML LOCAL INFILE '/tmp/sample.xml' INTO TABLE `sample`",
]) {
assert.throws(() => normalizeSchemaSql(`${statement};\n`), /禁止的数据操作/);
}
});
test('安全扫描拒绝导出噪声、疑似凭据、连接串和生产主机', () => {
for (const unsafeSql of [
'-- API_TOKEN=supersecret\nCREATE TABLE `sample` (`id` int);\n',
'-- CLIENT_SECRET: supersecret\nCREATE TABLE `sample` (`id` int);\n',
'-- mysql://schema_reader:secret@127.0.0.1/a_share_analysis_dev\nCREATE TABLE `sample` (`id` int);\n',
'-- production host: prod-db.internal\nCREATE TABLE `sample` (`id` int);\n',
]) {
assert.throws(() => normalizeSchemaSql(unsafeSql), /Schema SQL 包含禁止内容/);
}
const root = makeTemporaryDirectory();
const target = path.join(root, 'v0.0.5');
persistBaselineArtifacts(
target,
buildCapturedArtifacts(validMetadata, 'CREATE TABLE `sample` (`id` int);\n'),
);
replaceCapturedSql(
target,
'-- MySQL dump 10.13 Distrib 5.7.44\nCREATE TABLE `sample` (`id` int);\n',
);
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /MySQL dump/);
});
test('真实基线的清单、校验文件和 SQL 必须三方一致', () => {
const sql = 'CREATE TABLE `sample` (`id` bigint NOT NULL AUTO_INCREMENT);\n';
const root = makeTemporaryDirectory();
@@ -384,6 +431,33 @@ test('不适用清单拒绝未知对象键和伪造的格式元数据', () => {
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /清单格式/);
});
test('不适用基线只在当前开发环境仍关闭 MySQL 时有效', () => {
const root = makeTemporaryDirectory();
const envDirectory = path.join(root, 'deploy', 'env', 'development');
const envFile = path.join(envDirectory, 'api.env.example');
const result = { status: 'not_applicable' };
mkdirSync(envDirectory, { recursive: true });
writeFileSync(
envFile,
'APP_ENV=development\nMYSQL_ENABLED=false\nMYSQL_DATABASE=a_share_analysis_dev\nMYSQL_CHARSET=utf8mb4\nMYSQL_COLLATION=utf8mb4_general_ci\n',
);
assert.doesNotThrow(() =>
assertNotApplicableMatchesDevelopmentConfiguration(root, result),
);
writeFileSync(
envFile,
Object.entries(validConfig)
.map(([key, value]) => `${key}=${value}`)
.join('\n'),
);
assert.throws(
() => assertNotApplicableMatchesDevelopmentConfiguration(root, result),
/已启用 MySQL.*not_applicable/,
);
});
test('严格发布门禁通过已提交且远程同步的当前基线', () => {
assert.doesNotThrow(() => assertCurrentReleasedBaselinePublished(process.cwd()));
});