forked from wangziqi/gongxue-base
feat: add content asset security scanning
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -8,6 +8,7 @@ const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||
const ids = {
|
||||
okAsset: '20000000-0000-0000-0000-000000000801',
|
||||
badAsset: '20000000-0000-0000-0000-000000000802',
|
||||
scanBadAsset: '20000000-0000-0000-0000-000000000803',
|
||||
};
|
||||
|
||||
const checksumA = 'a'.repeat(64);
|
||||
@@ -52,16 +53,23 @@ async function cleanup(pool) {
|
||||
delete from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and target_type = 'content_asset'
|
||||
and target_id in ($2, $3)
|
||||
and target_id in ($2, $3, $4)
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_asset_security_scan_events
|
||||
where tenant_id = $1 and asset_id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_assets
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +80,9 @@ async function seed(pool) {
|
||||
id, tenant_id, asset_key, title, asset_type, storage_provider,
|
||||
bucket, object_key, file_name, mime_type, file_size_bytes, checksum_sha256,
|
||||
visibility, status, upload_status, verified_at, verified_size_bytes,
|
||||
verified_checksum_sha256, verification_details, security_flags, source,
|
||||
verified_checksum_sha256, verification_details,
|
||||
security_scan_status, security_scan_provider, security_scan_summary,
|
||||
security_flags, source,
|
||||
created_at, updated_at
|
||||
)
|
||||
values
|
||||
@@ -80,14 +90,27 @@ async function seed(pool) {
|
||||
$1, $2, 'asset-worker-ok', '资源复检正常 PDF', 'pdf', 'local_dev',
|
||||
'tenant-assets', $3, 'ok.pdf', 'application/pdf', 4096, $4,
|
||||
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||
$4, '{"source":"asset-worker-test"}'::jsonb, '{}'::jsonb, 'integration-test',
|
||||
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||
'pending', 'metadata_rules', '{}'::jsonb,
|
||||
'{}'::jsonb, 'integration-test',
|
||||
now() - interval '2 days', now() - interval '2 days'
|
||||
),
|
||||
(
|
||||
$5, $2, 'asset-worker-bad', '资源复检异常 PDF', 'pdf', 'local_dev',
|
||||
'tenant-assets', $6, 'bad.pdf', 'application/pdf', 1024, $7,
|
||||
'tenant', 'active', 'verified', now() - interval '2 days', 2048,
|
||||
$7, '{"source":"asset-worker-test"}'::jsonb, '{}'::jsonb, 'integration-test',
|
||||
$7, '{"source":"asset-worker-test"}'::jsonb,
|
||||
'pending', 'metadata_rules', '{}'::jsonb,
|
||||
'{}'::jsonb, 'integration-test',
|
||||
now() - interval '2 days', now() - interval '2 days'
|
||||
),
|
||||
(
|
||||
$8, $2, 'asset-worker-scan-bad', '资源安全扫描异常 PDF', 'pdf', 'local_dev',
|
||||
'tenant-assets', $9, 'scan-bad.pdf', 'application/pdf', 4096, $4,
|
||||
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||
'pending', 'metadata_rules', '{"securityScanForceFail":true}'::jsonb,
|
||||
'{}'::jsonb, 'integration-test',
|
||||
now() - interval '2 days', now() - interval '2 days'
|
||||
)
|
||||
`,
|
||||
@@ -99,6 +122,8 @@ async function seed(pool) {
|
||||
ids.badAsset,
|
||||
`${tenantId}/assets/worker-bad.pdf`,
|
||||
checksumB,
|
||||
ids.scanBadAsset,
|
||||
`${tenantId}/assets/worker-scan-bad.pdf`,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -115,32 +140,78 @@ async function main() {
|
||||
|
||||
const output = await runWorkerOnce();
|
||||
assert.ok(countFromWorkerOutput(output, 'verified') >= 1, 'worker should verify at least one asset');
|
||||
assert.ok(countFromWorkerOutput(output, 'failed') >= 1, 'worker should fail at least one mismatched asset');
|
||||
assert.ok(countFromWorkerOutput(output, 'failed') >= 2, 'worker should fail metadata and security scan assets');
|
||||
|
||||
const assets = await pool.query(
|
||||
`
|
||||
select id, status, upload_status, verification_details, security_flags
|
||||
select id, status, upload_status, verification_details,
|
||||
security_scan_status, security_scan_provider, security_scan_summary,
|
||||
security_flags
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
order by id
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
const okAsset = assets.rows.find(row => row.id === ids.okAsset);
|
||||
const badAsset = assets.rows.find(row => row.id === ids.badAsset);
|
||||
const scanBadAsset = assets.rows.find(row => row.id === ids.scanBadAsset);
|
||||
assert.equal(okAsset?.status, 'active', 'verified asset should remain active');
|
||||
assert.equal(okAsset?.upload_status, 'verified', 'verified asset should remain verified');
|
||||
assert.equal(okAsset?.security_scan_status, 'passed', 'verified asset should pass security scan');
|
||||
assert.equal(okAsset?.security_scan_provider, 'metadata_rules', 'verified asset should record scan provider');
|
||||
assert.equal(okAsset?.verification_details?.assetWorker?.lastResult, 'verified', 'verified asset should record worker result');
|
||||
assert.equal(okAsset?.security_flags?.assetRecheckFailed, undefined, 'verified asset should not keep recheck failure flag');
|
||||
|
||||
assert.equal(badAsset?.status, 'draft', 'mismatched asset should be unpublished');
|
||||
assert.equal(badAsset?.upload_status, 'failed', 'mismatched asset should be marked failed');
|
||||
assert.equal(badAsset?.security_scan_status, 'skipped', 'mismatched asset should skip security scan');
|
||||
assert.equal(badAsset?.security_flags?.assetRecheckFailed, true, 'mismatched asset should record security flag');
|
||||
assert.deepEqual(
|
||||
badAsset?.verification_details?.assetWorker?.issues,
|
||||
['file_size_mismatch'],
|
||||
'mismatched asset should record exact issue',
|
||||
);
|
||||
assert.equal(scanBadAsset?.status, 'draft', 'security failed asset should be unpublished');
|
||||
assert.equal(scanBadAsset?.upload_status, 'verified', 'security failed asset should keep upload verification evidence');
|
||||
assert.equal(scanBadAsset?.security_scan_status, 'failed', 'security failed asset should record failed scan status');
|
||||
assert.equal(scanBadAsset?.security_flags?.assetSecurityScanFailed, true, 'security failed asset should record security flag');
|
||||
assert.ok(
|
||||
scanBadAsset?.security_scan_summary?.issueCodes?.includes('security_scan_forced_failure'),
|
||||
'security failed asset should record issue code',
|
||||
);
|
||||
|
||||
const scanEvents = await pool.query(
|
||||
`
|
||||
select asset_id, scan_status, risk_level, issue_codes, provider, details
|
||||
from public.content_asset_security_scan_events
|
||||
where tenant_id = $1 and asset_id in ($2::uuid, $3::uuid)
|
||||
order by created_at asc
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.scanBadAsset],
|
||||
);
|
||||
assert.ok(
|
||||
scanEvents.rows.some(row => row.asset_id === ids.okAsset && row.scan_status === 'passed' && row.provider === 'metadata_rules'),
|
||||
'worker should write passed security scan event',
|
||||
);
|
||||
assert.ok(
|
||||
scanEvents.rows.some(row => row.asset_id === ids.scanBadAsset && row.scan_status === 'failed' && row.issue_codes.includes('security_scan_forced_failure')),
|
||||
'worker should write failed security scan event',
|
||||
);
|
||||
|
||||
const skippedScanEvents = await pool.query(
|
||||
`
|
||||
select asset_id, scan_status, risk_level, issue_codes, provider, details
|
||||
from public.content_asset_security_scan_events
|
||||
where tenant_id = $1 and asset_id = $2::uuid
|
||||
order by created_at asc
|
||||
`,
|
||||
[tenantId, ids.badAsset],
|
||||
);
|
||||
assert.ok(
|
||||
skippedScanEvents.rows.some(row => row.scan_status === 'skipped' && row.issue_codes.includes('file_size_mismatch')),
|
||||
'worker should write skipped scan event when metadata verification fails',
|
||||
);
|
||||
|
||||
const audits = await pool.query(
|
||||
`
|
||||
@@ -148,10 +219,10 @@ async function main() {
|
||||
from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and target_type = 'content_asset'
|
||||
and target_id in ($2, $3)
|
||||
and target_id in ($2, $3, $4)
|
||||
order by created_at asc
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
assert.ok(
|
||||
audits.rows.some(row => row.action === 'content.asset.rechecked' && row.details?.result === 'verified'),
|
||||
@@ -161,6 +232,10 @@ async function main() {
|
||||
audits.rows.some(row => row.action === 'content.asset.recheck_failed' && row.details?.issues?.includes('file_size_mismatch')),
|
||||
'worker should write failed audit log',
|
||||
);
|
||||
assert.ok(
|
||||
audits.rows.some(row => row.action === 'content.asset.security_scan_failed' && row.details?.issueCodes?.includes('security_scan_forced_failure')),
|
||||
'worker should write failed security scan audit log',
|
||||
);
|
||||
|
||||
console.log('Asset worker integration test complete.');
|
||||
} catch (error) {
|
||||
|
||||
@@ -1356,12 +1356,17 @@ async function main() {
|
||||
id, tenant_id, asset_key, title, asset_type, storage_provider,
|
||||
bucket, object_key, file_name, mime_type, file_size_bytes,
|
||||
checksum_sha256, visibility, status, upload_status, verified_at,
|
||||
verified_size_bytes, verified_checksum_sha256, source
|
||||
verified_size_bytes, verified_checksum_sha256,
|
||||
security_scan_status, security_scanned_at, security_scan_provider,
|
||||
security_scan_summary, source
|
||||
)
|
||||
values (
|
||||
$1, $2, 'smoke-video-asset', '烟测视频对象', 'video', 'local_dev',
|
||||
'tenant-assets', $3, 'smoke.mp4', 'video/mp4', 8192,
|
||||
$4, 'svip', 'active', 'verified', now(), 8192, $4, 'smoke-seed'
|
||||
$4, 'svip', 'active', 'verified', now(), 8192, $4,
|
||||
'passed', now(), 'smoke_seed',
|
||||
'{"riskLevel":"none","issueCodes":[],"provider":"smoke_seed"}'::jsonb,
|
||||
'smoke-seed'
|
||||
)
|
||||
on conflict (id)
|
||||
do update set storage_provider = excluded.storage_provider,
|
||||
@@ -1375,6 +1380,10 @@ async function main() {
|
||||
verified_at = coalesce(public.content_assets.verified_at, now()),
|
||||
verified_size_bytes = excluded.verified_size_bytes,
|
||||
verified_checksum_sha256 = excluded.verified_checksum_sha256,
|
||||
security_scan_status = 'passed',
|
||||
security_scanned_at = coalesce(public.content_assets.security_scanned_at, now()),
|
||||
security_scan_provider = 'smoke_seed',
|
||||
security_scan_summary = excluded.security_scan_summary,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`, 'c'.repeat(64)],
|
||||
|
||||
Reference in New Issue
Block a user