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

@@ -31,6 +31,7 @@ const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
const ASSET_STATUSES = ['draft', 'active', 'archived'];
const UPLOAD_STATUSES = ['not_required', 'pending', 'verified', 'failed'];
const PREVIEW_STATUSES = ['none', 'pending', 'ready', 'failed'];
const SECURITY_SCAN_STATUSES = ['not_required', 'pending', 'scanning', 'passed', 'failed', 'skipped'];
interface AssetRow {
id: string;
@@ -50,6 +51,9 @@ interface AssetRow {
visibility: string;
status: string;
uploadStatus: string;
securityScanStatus: string;
securityScanProvider: string | null;
securityScanSummary: Record<string, unknown>;
verifiedSizeBytes: number | null;
verifiedChecksumSha256: string | null;
previewStatus: string;
@@ -122,6 +126,9 @@ async function existingAssetForUpsert(tenantId: string, id: string | null) {
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus"
@@ -156,14 +163,46 @@ function objectUploadStatus(input: {
return 'pending';
}
function assertPublishableManagedObject(status: string, uploadStatus: string, provider: StorageProviderName, objectKey: string | null) {
if (status === 'active' && objectKey && isManagedObjectProvider(provider) && uploadStatus !== 'verified') {
function objectSecurityScanStatus(input: {
existing: AssetRow | null;
provider: StorageProviderName;
objectKey: string | null;
uploadStatus: string;
}) {
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return 'not_required';
if (input.uploadStatus !== 'verified') return 'pending';
return input.existing?.securityScanStatus || 'pending';
}
export function assertAssetSecurityScanPassed(input: {
provider: StorageProviderName;
objectKey: string | null;
securityScanStatus?: string | null;
}) {
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return;
if (input.securityScanStatus === 'passed') return;
if (input.securityScanStatus === 'failed') {
throw new HttpError(409, 'Asset security scan failed', 'ASSET_SECURITY_SCAN_FAILED');
}
throw new HttpError(409, 'Asset security scan is required before access', 'ASSET_SECURITY_SCAN_REQUIRED');
}
function assertPublishableManagedObject(
status: string,
uploadStatus: string,
scanStatus: string,
provider: StorageProviderName,
objectKey: string | null,
) {
if (status !== 'active' || !objectKey || !isManagedObjectProvider(provider)) return;
if (uploadStatus !== 'verified') {
throw new HttpError(
409,
'Managed storage asset must be confirmed before publishing',
'ASSET_UPLOAD_CONFIRMATION_REQUIRED',
);
}
assertAssetSecurityScanPassed({ provider, objectKey, securityScanStatus: scanStatus });
}
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
@@ -219,6 +258,7 @@ export async function assetsAdminRoute(ctx: RequestContext) {
const categoryId = stringParam(ctx, 'categoryId');
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const securityScanStatus = stringParam(ctx, 'securityScanStatus');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
@@ -254,6 +294,13 @@ export async function assetsAdminRoute(ctx: RequestContext) {
params.push(contentNodeId);
filters.push(`content_node_id = $${params.length}`);
}
if (securityScanStatus) {
if (!SECURITY_SCAN_STATUSES.includes(securityScanStatus)) {
throw new HttpError(400, `Invalid securityScanStatus: ${securityScanStatus}`, 'INVALID_FIELD_VALUE');
}
params.push(securityScanStatus);
filters.push(`security_scan_status = $${params.length}`);
}
params.push(limit);
const items = await query(
@@ -269,6 +316,10 @@ export async function assetsAdminRoute(ctx: RequestContext) {
verified_checksum_sha256 as "verifiedChecksumSha256",
verification_details as "verificationDetails",
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
security_scan_status as "securityScanStatus",
security_scanned_at as "securityScannedAt",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
security_flags as "securityFlags",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
@@ -288,6 +339,44 @@ export async function assetsAdminRoute(ctx: RequestContext) {
return { items };
}
export async function assetSecurityScanEventsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetId = stringParam(ctx, 'assetId');
const scanStatus = stringParam(ctx, 'scanStatus');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetId) {
assertUuidParam(assetId, 'assetId');
params.push(assetId);
filters.push(`asset_id = $${params.length}::uuid`);
}
if (scanStatus) {
if (!SECURITY_SCAN_STATUSES.includes(scanStatus) || scanStatus === 'not_required') {
throw new HttpError(400, `Invalid scanStatus: ${scanStatus}`, 'INVALID_FIELD_VALUE');
}
params.push(scanStatus);
filters.push(`scan_status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, asset_id as "assetId", provider,
scan_status as "scanStatus", risk_level as "riskLevel",
issue_codes as "issueCodes", details, created_at as "createdAt"
from public.content_asset_security_scan_events
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
@@ -362,14 +451,22 @@ export async function upsertAssetRoute(ctx: RequestContext) {
objectKey: cleanObjectKey,
cdnUrl,
});
const nextSecurityScanStatus = objectSecurityScanStatus({
existing,
provider: storageProvider,
objectKey: cleanObjectKey,
uploadStatus: nextUploadStatus,
});
const nextPreviewStatus = previewObjectKey || nullableString(body.previewUrl)
? choice(body.previewStatus, PREVIEW_STATUSES, existing?.previewStatus || 'ready', 'previewStatus')
: 'none';
const statusFallback = cleanObjectKey && isManagedObjectProvider(storageProvider) && nextUploadStatus !== 'verified'
const statusFallback = cleanObjectKey
&& isManagedObjectProvider(storageProvider)
&& (nextUploadStatus !== 'verified' || nextSecurityScanStatus !== 'passed')
? 'draft'
: 'active';
const status = choice(body.status, ASSET_STATUSES, statusFallback, 'status');
assertPublishableManagedObject(status, nextUploadStatus, storageProvider, cleanObjectKey);
assertPublishableManagedObject(status, nextUploadStatus, nextSecurityScanStatus, storageProvider, cleanObjectKey);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
@@ -388,7 +485,9 @@ export async function upsertAssetRoute(ctx: RequestContext) {
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
bucket, object_key, title, category, description, file_name, cdn_url,
preview_url, mime_type, file_size_bytes, checksum_sha256,
upload_status, preview_object_key, preview_status, visibility,
upload_status, security_scan_status, security_scanned_at,
security_scan_provider, security_scan_summary,
preview_object_key, preview_status, visibility,
is_public, region_id, subject_id, category_id, node_id, entry_id,
content_node_id, status,
sort_order, access_rules, metadata, created_by, updated_by, source
@@ -397,10 +496,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20, $21,
$22, $23::uuid, $24::uuid, $25::uuid, $26::uuid, $27::uuid,
$28::uuid, $29,
$30, $31::jsonb, $32::jsonb, $33, $33, $34
$19, case when $19 in ('passed', 'not_required') then now() else null end,
$20, $21::jsonb,
$22, $23, $24,
$25, $26::uuid, $27::uuid, $28::uuid, $29::uuid, $30::uuid,
$31::uuid, $32,
$33, $34::jsonb, $35::jsonb, $36, $36, $37
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
@@ -419,6 +520,31 @@ export async function upsertAssetRoute(ctx: RequestContext) {
file_size_bytes = excluded.file_size_bytes,
checksum_sha256 = excluded.checksum_sha256,
upload_status = excluded.upload_status,
security_scan_status = excluded.security_scan_status,
security_scanned_at = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
and excluded.security_scan_status = public.content_assets.security_scan_status
then public.content_assets.security_scanned_at
else excluded.security_scanned_at
end,
security_scan_provider = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
and excluded.security_scan_status = public.content_assets.security_scan_status
then public.content_assets.security_scan_provider
else excluded.security_scan_provider
end,
security_scan_summary = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
and excluded.security_scan_status = public.content_assets.security_scan_status
then public.content_assets.security_scan_summary
else excluded.security_scan_summary
end,
verified_at = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
@@ -476,6 +602,10 @@ export async function upsertAssetRoute(ctx: RequestContext) {
verified_checksum_sha256 as "verifiedChecksumSha256",
verification_details as "verificationDetails",
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
security_scan_status as "securityScanStatus",
security_scanned_at as "securityScannedAt",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
security_flags as "securityFlags",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
@@ -504,6 +634,15 @@ export async function upsertAssetRoute(ctx: RequestContext) {
fileSizeBytes,
checksum,
nextUploadStatus,
nextSecurityScanStatus,
nextSecurityScanStatus === 'not_required' ? null : existing?.securityScanProvider || 'metadata_rules',
JSON.stringify(
nextSecurityScanStatus === 'not_required'
? {}
: nextSecurityScanStatus === 'passed'
? existing?.securityScanSummary || { riskLevel: 'none', issueCodes: [] }
: { riskLevel: 'unknown', issueCodes: [], pendingReason: 'asset_upsert' },
),
previewObjectKey,
nextPreviewStatus,
visibility,
@@ -550,6 +689,9 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus"
@@ -623,13 +765,23 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
`
update public.content_assets
set upload_status = 'failed',
security_scan_status = 'skipped',
security_scanned_at = null,
security_scan_provider = 'metadata_rules',
security_scan_summary = $5::jsonb,
verification_details = $3::jsonb,
security_flags = jsonb_set(coalesce(security_flags, '{}'::jsonb), '{uploadVerificationFailed}', 'true'::jsonb, true),
updated_by = $4,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId],
[
auth.tenantId,
assetId,
JSON.stringify(verificationDetails),
auth.userId,
JSON.stringify({ riskLevel: 'medium', issueCodes: issues, skippedReason: 'upload_verification_failed' }),
],
);
await recordAssetAccessEvent({
ctx,
@@ -666,7 +818,12 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
mime_type = coalesce(mime_type, $6),
checksum_sha256 = coalesce(checksum_sha256, $5),
verification_details = $7::jsonb,
status = case when $8::boolean and status <> 'archived' then 'active' else status end,
security_scan_status = 'pending',
security_scanned_at = null,
security_scan_provider = 'metadata_rules',
security_scan_summary = '{"riskLevel":"unknown","issueCodes":[],"pendingReason":"upload_confirmed"}'::jsonb,
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
status = case when status = 'archived' then status else 'draft' end,
updated_by = $3,
updated_at = now()
where tenant_id = $1 and id = $2
@@ -677,7 +834,11 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
upload_status as "uploadStatus", verified_at as "verifiedAt",
verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
verification_details as "verificationDetails"
verification_details as "verificationDetails",
security_scan_status as "securityScanStatus",
security_scanned_at as "securityScannedAt",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary"
`,
[
auth.tenantId,
@@ -687,7 +848,6 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
metadata.checksumSha256 ?? declaredChecksum,
observedMime || expectedMime,
JSON.stringify(verificationDetails),
publish,
],
);
@@ -720,7 +880,16 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
},
});
return { item, metadata, verification: verificationDetails };
return {
item,
metadata,
verification: verificationDetails,
securityScan: {
status: 'pending',
provider: 'metadata_rules',
requiredBeforePublish: true,
},
};
}
export async function signAssetUploadRoute(ctx: RequestContext) {
@@ -811,6 +980,9 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus",
@@ -844,6 +1016,11 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
metadata: asset.metadata,
accessRules: asset.accessRules,
});
assertAssetSecurityScanPassed({
provider: asset.storageProvider as StorageProviderName,
objectKey: asset.objectKey,
securityScanStatus: asset.securityScanStatus,
});
} catch (error) {
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
throw error;
@@ -898,6 +1075,9 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus",
@@ -936,6 +1116,11 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
metadata: asset.metadata,
accessRules: asset.accessRules,
});
assertAssetSecurityScanPassed({
provider: asset.storageProvider as StorageProviderName,
objectKey,
securityScanStatus: asset.securityScanStatus,
});
} catch (error) {
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
throw error;

View File

@@ -1,6 +1,7 @@
import type { RouteDefinition } from '../../core/router.js';
import {
assetAccessEventsAdminRoute,
assetSecurityScanEventsAdminRoute,
assetsAdminRoute,
confirmAssetUploadRoute,
signAssetDownloadAdminRoute,
@@ -104,6 +105,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],
['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute],
['GET', '/api/tenant-content/assets/security-scan-events', assetSecurityScanEventsAdminRoute],
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],