fix: complete platform admin audit remediation

This commit is contained in:
2026-07-27 11:00:21 +08:00
parent a545559fa8
commit 48fc80b003
6 changed files with 268 additions and 90 deletions

View File

@@ -4,50 +4,106 @@ import path from 'node:path';
import vm from 'node:vm';
import ts from 'typescript';
const backendDirectory = path.resolve('../..', 'backend/api');
const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' }));
let responseShapeVerified = true;
try {
execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' });
} catch {
responseShapeVerified = false;
}
const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const resourceModule = { exports: {} };
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
const resources = resourceModule.exports.resources;
const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : [];
const failures = [];
if (!responseShapeVerified) failures.push('backend list response shape is not identity-only');
const expected = manifest.resources;
if (resources.length !== expected.length) failures.push(`catalogue count: ${resources.length}/${expected.length}`);
for (const contract of expected) {
const resource = resources.find((item) => item.name === contract.name);
const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind;
if (!resource || resource.mode !== contract.mode || resource.pageKind !== pageKind) { failures.push(`${contract.name}: contract mismatch`); continue; }
if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${contract.name}: missing Chinese business labels`);
if (resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id'))) failures.push(`${contract.name}: invalid allowlist`);
const method = resource.mode === 'append_only' ? 'POST' : 'GET';
if (!manifest.routes.some((route) => route.method === method && route.path === resource.resource)) failures.push(`${contract.name}: resource route mismatch`);
if (resource.mode === 'readonly') {
const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue'));
if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`);
const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).filter((file) => extensions.includes(path.extname(file))).map((file) => [file.replaceAll('\\', '/'), fs.readFileSync(file, 'utf8')]));
const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]);
function requiredBackendRoutes(contract) {
if (contract.mode === 'append_only') return [{ method: 'POST', path: contract.path }];
const resource = contract.path;
const detail = `${resource}/:identity`;
if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }];
return [
{ method: 'GET', path: resource }, { method: 'POST', path: resource }, { method: 'GET', path: detail },
{ method: 'PUT', path: detail }, { method: 'PATCH', path: `${detail}/status` }, { method: 'DELETE', path: detail },
];
}
function routeCoverage(contract, routeSources, viewSources) {
const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`);
let hasPage = false;
let hasMenu = false;
for (const source of routeSources) {
for (const match of source.matchAll(/component:\s*\(\)\s*=>\s*import\(['\"]@\/views\/([^'\"]+)['\"]\)([\s\S]{0,260}?meta:\s*\{[^}]*\})?/g)) {
const view = viewSources.get(`src/views/${match[1]}`);
if (!view || !expectedView.test(view)) continue;
hasPage = true;
if (/locale:\s*['\"]menu\.platform\./.test(match[2] ?? '')) hasMenu = true;
}
}
return { hasPage, hasMenu };
}
if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(expected.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources');
for (const name of ['ec_category', 'platform_menu']) { const view = files('src/views').find((file) => file.includes(name) && file.endsWith('TreePage.vue')); if (!view) failures.push(`${name}: tree page missing`); }
const tree = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8');
if (!tree.includes('parent_identity') || !tree.includes('canWrite') || !tree.includes('resourceApi.create') || !tree.includes('resourceApi.update')) failures.push('tree: writable identity actions missing');
if (/(?:['"`]id['"`]|\bparent_id\b|\.id\b)/.test(tree)) failures.push('tree: internal id key leaked');
const readOnlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8');
if (/resourceApi\.(?:create|update|archive)\b/.test(readOnlyPage)) failures.push('readonly: mutation action exposed');
for (const detailPage of ['src/views/shared/CrudListPage.vue', 'src/views/shared/ReadOnlyListPage.vue']) {
const source = fs.readFileSync(detailPage, 'utf8');
if (!source.includes("key !== 'id'") || !source.includes("key.endsWith('_id')")) failures.push(`${detailPage}: internal ids shown in detail`);
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const event = resources.find((resource) => resource.name === 'saf_event');
const disposal = resources.find((resource) => resource.name === 'saf_event_disposal');
if (!event?.detailActions?.some((action) => action.name === 'saf_event_disposal' && action.resource.includes(':identity/disposals'))) failures.push('saf_event_disposal: detail action missing');
if (!disposal || disposal.resource !== '/safety/saf_event/:identity/disposals' || files('src/views').some((file) => file.includes('saf_event_disposal'))) failures.push('saf_event_disposal: independent page exposed');
for (const failure of failures) console.log(failure);
if (failures.length) process.exitCode = 1;
/** Scans user-facing API and view sources for auto-increment primary or relation IDs. */
export function scanInternalIdLeaks(sources) {
const failures = [];
for (const [file, source] of sources) {
for (const line of source.split(/\r?\n/)) {
// These are defensive filters that explicitly remove IDs from details, not leaks.
if (line.includes("key !== 'id'") || line.includes("key.endsWith('_id')")) continue;
const relation = line.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/);
if (relation) failures.push(`${file}: internal identifier ${relation[1]}`);
else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(line)) failures.push(`${file}: internal identifier id`);
}
}
return failures;
}
/** Evaluates every platform contract against its backend route, UI definition, page and menu route. */
export function auditPlatform({ manifest, resources, readOnlyPage, routeSources, viewSources, apiSources, responseShapeVerified = true }) {
const failures = [];
if (!responseShapeVerified) failures.push('backend list response shape is not identity-only');
if (resources.length !== manifest.resources.length) failures.push(`catalogue count: ${resources.length}/${manifest.resources.length}`);
for (const contract of manifest.resources) {
const resource = resources.find((item) => item.name === contract.name);
const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind;
const label = `${contract.domain}/${contract.name}`;
if (!resource || resource.resource !== contract.path || resource.mode !== contract.mode || resource.pageKind !== pageKind) {
failures.push(`${label}: missing frontend resource`);
continue;
}
if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${label}: invalid frontend allowlist`);
for (const expected of requiredBackendRoutes(contract)) if (!manifest.routes.some((route) => route.method === expected.method && route.path === expected.path)) failures.push(`${label}: missing backend ${expected.method}`);
if (contract.mode === 'append_only') continue;
const coverage = routeCoverage(contract, routeSources, viewSources);
if (!coverage.hasPage) failures.push(`${label}: missing page`);
if (!coverage.hasMenu) failures.push(`${label}: missing menu route`);
}
if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(manifest.resources.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources');
const readonlyMutations = mutationActions(readOnlyPage);
if (readonlyMutations.length) failures.push(`readonly: mutation action exposed (${readonlyMutations.join(', ')})`);
failures.push(...scanInternalIdLeaks(new Map([...apiSources, ...viewSources])));
return failures;
}
function loadResources() {
const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const resourceModule = { exports: {} };
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
return resourceModule.exports.resources;
}
function runAudit() {
const backendDirectory = path.resolve('../..', 'backend/api');
const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' }));
let responseShapeVerified = true;
try { execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' }); } catch { responseShapeVerified = false; }
const viewSources = sourceFiles('src/views', ['.vue']);
const failures = auditPlatform({
manifest,
resources: loadResources(),
readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'),
routeSources: [...sourceFiles('src/router/routes/modules', ['.ts']).values()],
viewSources,
apiSources: sourceFiles('src/api', ['.ts']),
responseShapeVerified,
});
for (const failure of failures) console.log(failure);
if (failures.length) process.exitCode = 1;
}
if (import.meta.url === `file://${process.argv[1]?.replaceAll('\\', '/')}`) runAudit();

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { auditPlatform, scanInternalIdLeaks } from './audit-check.mjs';
test('只读页面将状态变更视为违规写操作', () => {
const failures = auditPlatform({
manifest: { resources: [], routes: [] },
resources: [],
readOnlyPage: '<script setup>resourceApi.updateStatus(resource, identity, status)</script>',
routeSources: [],
viewSources: new Map(),
apiSources: new Map(),
});
assert.deepEqual(failures, ['readonly: mutation action exposed (updateStatus)']);
});
test('扫描 API 和页面中用于展示或请求的内部 ID', () => {
const failures = scanInternalIdLeaks(new Map([
['src/api/leak.ts', "resourceApi.create('/gas/gas_basic', { gas_basic_id: 7 })"],
['src/views/leak.vue', '<a-table-column data-index="id" />'],
]));
assert.deepEqual(failures, [
'src/api/leak.ts: internal identifier gas_basic_id',
'src/views/leak.vue: internal identifier id',
]);
});
test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => {
const failures = auditPlatform({
manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [
{ method: 'GET', path: '/gas/gas_basic' },
{ method: 'POST', path: '/gas/gas_basic' },
{ method: 'GET', path: '/gas/gas_basic/:identity' },
{ method: 'PUT', path: '/gas/gas_basic/:identity' },
{ method: 'PATCH', path: '/gas/gas_basic/:identity/status' },
{ method: 'DELETE', path: '/gas/gas_basic/:identity' },
] },
resources: [{ name: 'gas_basic', resource: '/gas/gas_basic', mode: 'writable', pageKind: 'list', title: '气站管理', fields: [{ key: 'name', label: '名称' }] }],
readOnlyPage: '',
routeSources: ["{ component: () => import('@/views/gas/gas_basic/ListPage.vue') }"],
viewSources: new Map([['src/views/gas/gas_basic/ListPage.vue', "getResource('/gas/gas_basic')"]]),
apiSources: new Map(),
});
assert.deepEqual(failures, ['gas/gas_basic: missing menu route']);
});