fix: harden platform audit coverage

This commit is contained in:
2026-07-27 11:13:55 +08:00
parent 48fc80b003
commit 0ba4136e1a
7 changed files with 125 additions and 13 deletions

View File

@@ -9,7 +9,7 @@ const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).fi
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 }];
if (contract.mode === 'append_only') return [{ method: 'GET', path: contract.path }, { 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 }];
@@ -23,7 +23,7 @@ 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 [, source] of sourceEntries(routeSources, 'src/router')) {
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;
@@ -34,6 +34,11 @@ function routeCoverage(contract, routeSources, viewSources) {
return { hasPage, hasMenu };
}
function sourceEntries(sources, fallbackDirectory) {
if (sources instanceof Map) return [...sources];
return sources.map((source, index) => [`${fallbackDirectory}/${index}`, source]);
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -43,11 +48,11 @@ 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/);
// Remove only the defensive predicates, then keep scanning the rest of the line.
const scanned = line.replace(/key\s*!==\s*['\"]id['\"]/g, '').replace(/key\.endsWith\(\s*['\"]_id['\"]\s*\)/g, '');
const relation = scanned.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`);
else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(scanned)) failures.push(`${file}: internal identifier id`);
}
}
return failures;
@@ -68,10 +73,21 @@ export function auditPlatform({ manifest, resources, readOnlyPage, routeSources,
}
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 (contract.mode === 'append_only') {
const event = resources.find((item) => item.name === 'saf_event');
if (!event?.detailActions?.some((action) => action.name === contract.name && action.resource === contract.path)) failures.push(`${label}: missing saf_event detail action`);
if ([...viewSources.keys()].some((file) => file.includes(`/${contract.name}/`))) failures.push(`${label}: independent page exposed`);
} else {
const coverage = routeCoverage(contract, routeSources, viewSources);
if (!coverage.hasPage) failures.push(`${label}: missing page`);
if (!coverage.hasMenu) failures.push(`${label}: missing menu route`);
}
if (contract.mode === 'readonly') {
const statusCall = new RegExp(`resourceApi\\.updateStatus\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]`);
for (const [file, source] of [...sourceEntries(routeSources, 'src/router'), ...sourceEntries(apiSources, 'src/api'), ...sourceEntries(viewSources, 'src/views')]) {
if (statusCall.test(source)) failures.push(`${label}: readonly status mutation in ${file}`);
}
}
}
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);
@@ -97,7 +113,7 @@ function runAudit() {
manifest,
resources: loadResources(),
readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'),
routeSources: [...sourceFiles('src/router/routes/modules', ['.ts']).values()],
routeSources: sourceFiles('src/router', ['.ts']),
viewSources,
apiSources: sourceFiles('src/api', ['.ts']),
responseShapeVerified,

View File

@@ -27,6 +27,18 @@ test('扫描 API 和页面中用于展示或请求的内部 ID', () => {
]);
});
test('防护表达式不能掩盖同一行的内部 ID 泄漏', () => {
const failures = scanInternalIdLeaks(new Map([
['src/views/leak.vue', "const visible = row.id; const safe = key !== 'id';"],
['src/api/leak.ts', "send({ gas_basic_id: 7 }); const safe = key.endsWith('_id');"],
]));
assert.deepEqual(failures, [
'src/views/leak.vue: internal identifier id',
'src/api/leak.ts: internal identifier gas_basic_id',
]);
});
test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => {
const failures = auditPlatform({
manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [
@@ -46,3 +58,37 @@ test('每个资源必须由带菜单元数据的路由实际加载对应页面',
assert.deepEqual(failures, ['gas/gas_basic: missing menu route']);
});
test('仅追加处置必须挂在安全事件详情动作且不得有独立页面', () => {
const failures = auditPlatform({
manifest: { resources: [{ domain: 'safety', name: 'saf_event_disposal', path: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list' }], routes: [
{ method: 'GET', path: '/safety/saf_event/:identity/disposals' },
{ method: 'POST', path: '/safety/saf_event/:identity/disposals' },
] },
resources: [{ name: 'saf_event_disposal', resource: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list', title: '事件处置', fields: [{ key: 'action', label: '处置动作' }] }],
readOnlyPage: '',
routeSources: [],
viewSources: new Map([['src/views/safety/saf_event_disposal/ListPage.vue', '<template />']]),
apiSources: new Map(),
});
assert.deepEqual(failures, [
'safety/saf_event_disposal: missing saf_event detail action',
'safety/saf_event_disposal: independent page exposed',
]);
});
test('只读资源拒绝 API 或路由中的状态写入', () => {
const failures = auditPlatform({
manifest: { resources: [{ domain: 'wallet', name: 'wallet', path: '/wallet/wallet', mode: 'readonly', pageKind: 'list' }], routes: [
{ method: 'GET', path: '/wallet/wallet' }, { method: 'GET', path: '/wallet/wallet/:identity' },
] },
resources: [{ name: 'wallet', resource: '/wallet/wallet', mode: 'readonly', pageKind: 'list', title: '钱包', fields: [{ key: 'balance_amount', label: '余额' }] }],
readOnlyPage: '',
routeSources: ["{ component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet' } }"],
viewSources: new Map([['src/views/wallet/wallet/ListPage.vue', "getResource('/wallet/wallet')"]]),
apiSources: new Map([['src/api/wallet.ts', "resourceApi.updateStatus('/wallet/wallet', identity, 'disabled')"]]),
});
assert.deepEqual(failures, ['wallet/wallet: readonly status mutation in src/api/wallet.ts']);
});