fix(GOV-007): 收紧 Schema 基线安全门禁
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,12 @@ function assertSchemaHasNoSensitiveOrDataStatements(sql) {
|
|||||||
const sqlWithoutStringLiterals = sql.replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
|
const sqlWithoutStringLiterals = sql.replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
|
||||||
const prohibitedPatterns = [
|
const prohibitedPatterns = [
|
||||||
[sqlWithoutStringLiterals, /\bDEFINER\s*=/i, 'DEFINER'],
|
[sqlWithoutStringLiterals, /\bDEFINER\s*=/i, 'DEFINER'],
|
||||||
|
[sql, /^--\s*MySQL dump\b/im, 'MySQL dump 头'],
|
||||||
[sql, /--\s*Dump completed on/i, '导出完成时间'],
|
[sql, /--\s*Dump completed on/i, '导出完成时间'],
|
||||||
[sql, /--\s*Host:/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) {
|
for (const [content, pattern, label] of prohibitedPatterns) {
|
||||||
if (pattern.test(content)) {
|
if (pattern.test(content)) {
|
||||||
@@ -156,11 +159,16 @@ function assertSchemaHasNoSensitiveOrDataStatements(sql) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const dataStatement = candidates.find((candidate) =>
|
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) {
|
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(
|
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) {
|
export function assertCurrentReleasedBaseline(repositoryRoot) {
|
||||||
const { applicationVersion, gitCommit } = getCurrentReleasedVersion(repositoryRoot);
|
const { applicationVersion, gitCommit } = getCurrentReleasedVersion(repositoryRoot);
|
||||||
return verifyBaselineDirectory(
|
const result = verifyBaselineDirectory(
|
||||||
path.join(repositoryRoot, 'database', 'schema-baselines', applicationVersion),
|
path.join(repositoryRoot, 'database', 'schema-baselines', applicationVersion),
|
||||||
applicationVersion,
|
applicationVersion,
|
||||||
gitCommit,
|
gitCommit,
|
||||||
);
|
);
|
||||||
|
return assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runGitLines(repositoryRoot, args) {
|
function runGitLines(repositoryRoot, args) {
|
||||||
@@ -660,6 +686,7 @@ export function assertCurrentReleasedBaselinePublished(repositoryRoot) {
|
|||||||
release.applicationVersion,
|
release.applicationVersion,
|
||||||
release.gitCommit,
|
release.gitCommit,
|
||||||
);
|
);
|
||||||
|
assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result);
|
||||||
const trackedFiles = new Set(
|
const trackedFiles = new Set(
|
||||||
runGitLines(repositoryRoot, ['ls-files', '--', relativeDirectory]),
|
runGitLines(repositoryRoot, ['ls-files', '--', relativeDirectory]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
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 { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertMysql57Metadata,
|
assertMysql57Metadata,
|
||||||
|
assertNotApplicableMatchesDevelopmentConfiguration,
|
||||||
assertCurrentReleasedBaselinePublished,
|
assertCurrentReleasedBaselinePublished,
|
||||||
assertSafeDevelopmentConfiguration,
|
assertSafeDevelopmentConfiguration,
|
||||||
buildCapturedArtifacts,
|
buildCapturedArtifacts,
|
||||||
@@ -45,6 +47,16 @@ function makeTemporaryDirectory() {
|
|||||||
return mkdtempSync(path.join(tmpdir(), 'a-share-schema-baseline-'));
|
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('拒绝任何非开发环境和非固定开发库配置', () => {
|
test('拒绝任何非开发环境和非固定开发库配置', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertSafeDevelopmentConfiguration({ ...validConfig, APP_ENV: 'production' }),
|
() => 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 必须三方一致', () => {
|
test('真实基线的清单、校验文件和 SQL 必须三方一致', () => {
|
||||||
const sql = 'CREATE TABLE `sample` (`id` bigint NOT NULL AUTO_INCREMENT);\n';
|
const sql = 'CREATE TABLE `sample` (`id` bigint NOT NULL AUTO_INCREMENT);\n';
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
@@ -384,6 +431,33 @@ test('不适用清单拒绝未知对象键和伪造的格式元数据', () => {
|
|||||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /清单格式/);
|
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('严格发布门禁通过已提交且远程同步的当前基线', () => {
|
test('严格发布门禁通过已提交且远程同步的当前基线', () => {
|
||||||
assert.doesNotThrow(() => assertCurrentReleasedBaselinePublished(process.cwd()));
|
assert.doesNotThrow(() => assertCurrentReleasedBaselinePublished(process.cwd()));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user