diff --git a/AGENTS.md b/AGENTS.md index a19277d..0966f98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ # 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. diff --git a/tools/database/schema-baseline.mjs b/tools/database/schema-baseline.mjs index 7aae8ab..a5df994 100644 --- a/tools/database/schema-baseline.mjs +++ b/tools/database/schema-baseline.mjs @@ -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]), ); diff --git a/tools/database/schema-baseline.test.mjs b/tools/database/schema-baseline.test.mjs index 7f6d112..3535865 100644 --- a/tools/database/schema-baseline.test.mjs +++ b/tools/database/schema-baseline.test.mjs @@ -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())); });