feat: add content asset security scanning

This commit is contained in:
Codex
2026-06-29 19:42:05 +08:00
parent d696fc38b0
commit ecd269b548
21 changed files with 936 additions and 75 deletions

View File

@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
import pg from 'pg';
import { SignJWT } from 'jose';
import ExcelJS from 'exceljs';
@@ -147,6 +148,39 @@ async function check(name, fn) {
console.log(`[PASS] ${name}`);
}
async function markAssetSecurityScanPassed(assetId, provider = 'api_integration_test') {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await pool.query(
`
update public.content_assets
set security_scan_status = 'passed',
security_scanned_at = now(),
security_scan_provider = $3,
security_scan_summary = '{"riskLevel":"none","issueCodes":[],"provider":"api_integration_test"}'::jsonb,
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
updated_at = now()
where tenant_id = $1 and id = $2::uuid
`,
[MAIN_TENANT_ID, assetId, provider],
);
await pool.query(
`
insert into public.content_asset_security_scan_events (
tenant_id, asset_id, provider, scan_status, risk_level, issue_codes, details
)
values (
$1, $2::uuid, $3, 'passed', 'none', '{}'::text[],
'{"source":"api_integration_test"}'::jsonb
)
`,
[MAIN_TENANT_ID, assetId, provider],
);
} finally {
await pool.end();
}
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -3518,6 +3552,7 @@ async function testTenantContentAssetsAndImports() {
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
assert.equal(localAsset.item?.status, 'draft', 'managed object asset should stay draft before upload confirmation');
assert.equal(localAsset.item?.uploadStatus, 'pending', 'managed object asset should be pending before upload confirmation');
assert.equal(localAsset.item?.securityScanStatus, 'pending', 'managed object asset should require security scan before publishing');
const unconfirmedDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
@@ -3536,16 +3571,60 @@ async function testTenantContentAssetsAndImports() {
publish: true,
},
});
assert.equal(confirmLocal.item?.status, 'active', 'confirmed managed asset should publish when requested');
assert.equal(confirmLocal.item?.status, 'draft', 'confirmed managed asset should stay draft until security scan passes');
assert.equal(confirmLocal.item?.uploadStatus, 'verified', 'confirm upload should mark asset verified');
assert.equal(confirmLocal.item?.securityScanStatus, 'pending', 'confirm upload should mark asset security scan pending');
assert.equal(confirmLocal.item?.verifiedChecksumSha256, 'a'.repeat(64), 'confirm upload should persist checksum');
const scanPendingEvents = await request('/api/tenant-content/assets/security-scan-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(Array.isArray(scanPendingEvents.items), 'tenant admin should list asset security scan events');
const localDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
expectStatus: 404,
});
assert.equal(localDownload.download?.method, 'GET', 'object asset download should sign GET');
assert.equal(localDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
assert.ok(localDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
assert.equal(localDownload.code, 'ASSET_NOT_FOUND', 'unscanned draft asset should not be downloadable');
await markAssetSecurityScanPassed(localAsset.item.id);
const publishScannedLocal = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: localAsset.item.id,
title: '集成测试本地对象资料',
assetType: 'pdf',
storageProvider: 'local_dev',
bucket: upload.assetDraft.bucket,
objectKey: upload.assetDraft.objectKey,
fileName: upload.assetDraft.fileName,
mimeType: upload.assetDraft.mimeType,
fileSizeBytes: upload.assetDraft.fileSizeBytes,
checksumSha256: upload.assetDraft.checksumSha256,
visibility: 'tenant',
status: 'active',
},
});
assert.equal(publishScannedLocal.item?.status, 'active', 'scanned managed asset should publish');
assert.equal(publishScannedLocal.item?.securityScanStatus, 'passed', 'published managed asset should keep passed scan status');
const scanEventsAfterPass = await request('/api/tenant-content/assets/security-scan-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(
scanEventsAfterPass.items?.some(item => item.scanStatus === 'passed' && item.provider === 'api_integration_test'),
'asset security scan pass event should be visible to tenant admin',
);
const scannedLocalDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
});
assert.equal(scannedLocalDownload.download?.method, 'GET', 'object asset download should sign GET');
assert.equal(scannedLocalDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
assert.ok(scannedLocalDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
const localPreview = await request('/api/catalog/assets/preview', {
query: { assetId: localAsset.item.id },