forked from wangziqi/gongxue-base
feat: add content asset security scanning
This commit is contained in:
@@ -11,15 +11,19 @@ interface AssetCandidate {
|
||||
title: string | null;
|
||||
status: string;
|
||||
uploadStatus: string;
|
||||
assetType: string;
|
||||
storageProvider: StorageProviderName;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSizeBytes: number | string | null;
|
||||
checksumSha256: string | null;
|
||||
verifiedSizeBytes: number | string | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
verificationDetails: Record<string, unknown>;
|
||||
securityScanStatus: string;
|
||||
securityScanSummary: Record<string, unknown>;
|
||||
securityFlags: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -45,6 +49,14 @@ interface AssetWorkerResult {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
interface SecurityScanResult {
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
provider: string;
|
||||
riskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
issueCodes: string[];
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class AssetWorkerError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
@@ -56,6 +68,30 @@ class AssetWorkerError extends Error {
|
||||
|
||||
const UPLOADABLE_PROVIDERS = new Set<StorageProviderName>(['local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos']);
|
||||
const SAFE_OBJECT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$/;
|
||||
const EXTENSION_MIME_HINTS: Record<string, string[]> = {
|
||||
pdf: ['application/pdf'],
|
||||
png: ['image/png'],
|
||||
jpg: ['image/jpeg'],
|
||||
jpeg: ['image/jpeg'],
|
||||
gif: ['image/gif'],
|
||||
webp: ['image/webp'],
|
||||
svg: ['image/svg+xml'],
|
||||
mp4: ['video/mp4'],
|
||||
mov: ['video/quicktime'],
|
||||
mp3: ['audio/mpeg'],
|
||||
wav: ['audio/wav', 'audio/x-wav'],
|
||||
doc: ['application/msword'],
|
||||
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
xls: ['application/vnd.ms-excel'],
|
||||
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
ppt: ['application/vnd.ms-powerpoint'],
|
||||
pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||
zip: ['application/zip', 'application/x-zip-compressed'],
|
||||
json: ['application/json'],
|
||||
txt: ['text/plain'],
|
||||
md: ['text/markdown', 'text/plain'],
|
||||
csv: ['text/csv', 'text/plain'],
|
||||
};
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
@@ -445,6 +481,110 @@ function compareAssetMetadata(asset: AssetCandidate, metadata: StorageObjectMeta
|
||||
};
|
||||
}
|
||||
|
||||
function extensionFromName(value: string | null) {
|
||||
if (!value) return '';
|
||||
const withoutQuery = value.split('?')[0]?.split('#')[0] || value;
|
||||
const last = withoutQuery.split('/').pop() || withoutQuery;
|
||||
const dot = last.lastIndexOf('.');
|
||||
return dot >= 0 ? last.slice(dot + 1).trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function metadataBoolean(value: unknown) {
|
||||
return value === true || value === 'true' || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
function securityScanRules(asset: AssetCandidate, metadata: StorageObjectMetadata): SecurityScanResult {
|
||||
const issueCodes: string[] = [];
|
||||
const details: Record<string, unknown> = {
|
||||
observed: {
|
||||
fileSizeBytes: metadata.sizeBytes,
|
||||
mimeType: canonicalMime(metadata.mimeType),
|
||||
checksumSha256: metadata.checksumSha256,
|
||||
verificationSource: metadata.verificationSource,
|
||||
},
|
||||
declared: {
|
||||
assetType: asset.assetType,
|
||||
fileName: asset.fileName,
|
||||
objectKey: asset.objectKey,
|
||||
mimeType: canonicalMime(asset.mimeType),
|
||||
fileSizeBytes: validateFileSize(asset.fileSizeBytes),
|
||||
checksumSha256: asset.checksumSha256,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (asset.objectKey) validateObjectKey(asset.tenantId, asset.objectKey);
|
||||
} catch (error) {
|
||||
issueCodes.push(eventError(error).code);
|
||||
}
|
||||
|
||||
try {
|
||||
validateFileSize(metadata.sizeBytes ?? asset.verifiedSizeBytes ?? asset.fileSizeBytes);
|
||||
} catch (error) {
|
||||
issueCodes.push(eventError(error).code);
|
||||
}
|
||||
|
||||
const declaredMime = canonicalMime(asset.mimeType);
|
||||
const observedMime = canonicalMime(metadata.mimeType);
|
||||
const effectiveMime = observedMime || declaredMime;
|
||||
try {
|
||||
validateMimeType(effectiveMime);
|
||||
} catch (error) {
|
||||
issueCodes.push(eventError(error).code);
|
||||
}
|
||||
|
||||
const fileExtension = extensionFromName(asset.fileName) || extensionFromName(asset.objectKey);
|
||||
details.fileExtension = fileExtension || null;
|
||||
if (fileExtension && effectiveMime && EXTENSION_MIME_HINTS[fileExtension]) {
|
||||
const allowedForExtension = EXTENSION_MIME_HINTS[fileExtension];
|
||||
if (!allowedForExtension.includes(effectiveMime)) {
|
||||
issueCodes.push('file_extension_mime_mismatch');
|
||||
details.extensionMimeExpected = allowedForExtension;
|
||||
}
|
||||
}
|
||||
|
||||
const metadataFlags = objectValue(asset.securityScanSummary);
|
||||
const assetMetadata = objectValue(asset.verificationDetails?.metadata);
|
||||
const explicitFlag = [
|
||||
metadataFlags.forceFail,
|
||||
metadataFlags.securityScanForceFail,
|
||||
assetMetadata.securityScanForceFail,
|
||||
assetMetadata.forceSecurityScanFail,
|
||||
].some(metadataBoolean);
|
||||
if (explicitFlag) {
|
||||
issueCodes.push('security_scan_forced_failure');
|
||||
}
|
||||
|
||||
const forcedIssue = typeof metadataFlags.forceIssueCode === 'string'
|
||||
? metadataFlags.forceIssueCode
|
||||
: typeof assetMetadata.forceIssueCode === 'string'
|
||||
? assetMetadata.forceIssueCode
|
||||
: '';
|
||||
if (forcedIssue) issueCodes.push(forcedIssue);
|
||||
|
||||
const uniqueIssues = Array.from(new Set(issueCodes));
|
||||
const highRiskIssues = new Set([
|
||||
'INVALID_OBJECT_KEY',
|
||||
'OBJECT_KEY_TENANT_PREFIX_REQUIRED',
|
||||
'MIME_TYPE_NOT_ALLOWED',
|
||||
'FILE_TOO_LARGE',
|
||||
'security_scan_forced_failure',
|
||||
]);
|
||||
const riskLevel = uniqueIssues.some(code => highRiskIssues.has(code))
|
||||
? 'high'
|
||||
: uniqueIssues.length
|
||||
? 'medium'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
status: uniqueIssues.length ? 'failed' : 'passed',
|
||||
provider: 'metadata_rules',
|
||||
riskLevel,
|
||||
issueCodes: uniqueIssues,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function eventError(error: unknown) {
|
||||
if (error instanceof AssetWorkerError) {
|
||||
return { code: error.code, message: error.message };
|
||||
@@ -483,6 +623,30 @@ async function recordAudit(
|
||||
);
|
||||
}
|
||||
|
||||
async function recordSecurityScanEvent(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
scan: SecurityScanResult,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.content_asset_security_scan_events (
|
||||
tenant_id, asset_id, provider, scan_status, risk_level, issue_codes, details
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::text[], $7::jsonb)
|
||||
`,
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
scan.provider,
|
||||
scan.status,
|
||||
scan.riskLevel,
|
||||
scan.issueCodes,
|
||||
JSON.stringify(scan.details),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimId: string) {
|
||||
const result = await client.query<AssetCandidate>(
|
||||
`
|
||||
@@ -496,6 +660,12 @@ async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimI
|
||||
(upload_status = 'pending' and updated_at <= now() - ($2::int * interval '1 second'))
|
||||
or (
|
||||
upload_status = 'verified'
|
||||
and security_scan_status = 'pending'
|
||||
and updated_at <= now() - ($2::int * interval '1 second')
|
||||
)
|
||||
or (
|
||||
upload_status = 'verified'
|
||||
and security_scan_status in ('passed', 'not_required')
|
||||
and coalesce((verification_details #>> '{assetWorker,lastCheckedAt}')::timestamptz, verified_at, updated_at, created_at)
|
||||
<= now() - ($3::int * interval '1 second')
|
||||
)
|
||||
@@ -514,6 +684,10 @@ async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimI
|
||||
coalesce(ca.verification_details->'assetWorker', '{}'::jsonb) || $6::jsonb,
|
||||
true
|
||||
),
|
||||
security_scan_status = case
|
||||
when ca.upload_status = 'verified' and ca.security_scan_status = 'pending' then 'scanning'
|
||||
else ca.security_scan_status
|
||||
end,
|
||||
updated_at = now()
|
||||
from candidates
|
||||
where ca.id = candidates.id
|
||||
@@ -522,15 +696,19 @@ async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimI
|
||||
ca.title,
|
||||
ca.status,
|
||||
ca.upload_status as "uploadStatus",
|
||||
ca.asset_type as "assetType",
|
||||
ca.storage_provider as "storageProvider",
|
||||
ca.bucket,
|
||||
ca.object_key as "objectKey",
|
||||
ca.file_name as "fileName",
|
||||
ca.mime_type as "mimeType",
|
||||
ca.file_size_bytes as "fileSizeBytes",
|
||||
ca.checksum_sha256 as "checksumSha256",
|
||||
ca.verified_size_bytes as "verifiedSizeBytes",
|
||||
ca.verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
ca.verification_details as "verificationDetails",
|
||||
ca.security_scan_status as "securityScanStatus",
|
||||
ca.security_scan_summary as "securityScanSummary",
|
||||
ca.security_flags as "securityFlags"
|
||||
`,
|
||||
[
|
||||
@@ -594,6 +772,95 @@ async function markAssetVerified(client: pg.PoolClient, asset: AssetCandidate, m
|
||||
});
|
||||
}
|
||||
|
||||
async function markAssetSecurityScanPassed(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
scan: SecurityScanResult,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set security_scan_status = 'passed',
|
||||
security_scanned_at = now(),
|
||||
security_scan_provider = $3,
|
||||
security_scan_summary = $4::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
scan.provider,
|
||||
JSON.stringify({
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
provider: scan.provider,
|
||||
scannedAt: nowIso(),
|
||||
details: scan.details,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordSecurityScanEvent(client, asset, scan);
|
||||
await recordAudit(client, {
|
||||
tenantId: asset.tenantId,
|
||||
action: 'content.asset.security_scan_passed',
|
||||
targetId: asset.id,
|
||||
details: {
|
||||
provider: scan.provider,
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markAssetSecurityScanFailed(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
scan: SecurityScanResult,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set security_scan_status = 'failed',
|
||||
security_scanned_at = now(),
|
||||
security_scan_provider = $3,
|
||||
security_scan_summary = $4::jsonb,
|
||||
status = case when status = 'active' then 'draft' else status end,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb)
|
||||
|| jsonb_build_object('assetSecurityScanFailed', true, 'assetSecurityScanFailedAt', now()),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
scan.provider,
|
||||
JSON.stringify({
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
provider: scan.provider,
|
||||
scannedAt: nowIso(),
|
||||
details: scan.details,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordSecurityScanEvent(client, asset, scan);
|
||||
await recordAudit(client, {
|
||||
tenantId: asset.tenantId,
|
||||
action: 'content.asset.security_scan_failed',
|
||||
targetId: asset.id,
|
||||
details: {
|
||||
provider: scan.provider,
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
unpublished: asset.status === 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markAssetFailed(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
@@ -601,10 +868,11 @@ async function markAssetFailed(
|
||||
metadata: StorageObjectMetadata | null,
|
||||
error: Record<string, unknown> | null,
|
||||
) {
|
||||
const uniqueIssues = Array.from(new Set(issues));
|
||||
const details = mergeVerificationDetails(asset, {
|
||||
lastCheckedAt: nowIso(),
|
||||
lastResult: 'failed',
|
||||
issues,
|
||||
issues: uniqueIssues,
|
||||
observed: metadata
|
||||
? {
|
||||
fileSizeBytes: metadata.sizeBytes,
|
||||
@@ -617,11 +885,33 @@ async function markAssetFailed(
|
||||
: null,
|
||||
error,
|
||||
});
|
||||
const scanSkipped: SecurityScanResult = {
|
||||
status: 'skipped',
|
||||
provider: 'metadata_rules',
|
||||
riskLevel: 'medium',
|
||||
issueCodes: uniqueIssues,
|
||||
details: {
|
||||
skippedReason: 'upload_recheck_failed',
|
||||
observed: metadata
|
||||
? {
|
||||
fileSizeBytes: metadata.sizeBytes,
|
||||
mimeType: canonicalMime(metadata.mimeType),
|
||||
checksumSha256: metadata.checksumSha256,
|
||||
verificationSource: metadata.verificationSource,
|
||||
}
|
||||
: null,
|
||||
error,
|
||||
},
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set upload_status = 'failed',
|
||||
security_scan_status = 'skipped',
|
||||
security_scanned_at = null,
|
||||
security_scan_provider = $4,
|
||||
security_scan_summary = $5::jsonb,
|
||||
status = case when status = 'active' then 'draft' else status end,
|
||||
verification_details = $3::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb)
|
||||
@@ -629,9 +919,22 @@ async function markAssetFailed(
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[asset.tenantId, asset.id, JSON.stringify(details)],
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
JSON.stringify(details),
|
||||
scanSkipped.provider,
|
||||
JSON.stringify({
|
||||
riskLevel: scanSkipped.riskLevel,
|
||||
issueCodes: scanSkipped.issueCodes,
|
||||
provider: scanSkipped.provider,
|
||||
skippedReason: 'upload_recheck_failed',
|
||||
details: scanSkipped.details,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordSecurityScanEvent(client, asset, scanSkipped);
|
||||
await recordAudit(client, {
|
||||
tenantId: asset.tenantId,
|
||||
action: 'content.asset.recheck_failed',
|
||||
@@ -641,7 +944,7 @@ async function markAssetFailed(
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
result: 'failed',
|
||||
issues,
|
||||
issues: uniqueIssues,
|
||||
error,
|
||||
unpublished: asset.status === 'active',
|
||||
},
|
||||
@@ -653,6 +956,7 @@ async function processAsset(asset: AssetCandidate) {
|
||||
try {
|
||||
const metadata = await headStorageObject(asset);
|
||||
const comparison = compareAssetMetadata(asset, metadata);
|
||||
const scan = comparison.issues.length ? null : securityScanRules(asset, metadata);
|
||||
await client.query('begin');
|
||||
if (comparison.issues.length) {
|
||||
await markAssetFailed(client, asset, comparison.issues, metadata, null);
|
||||
@@ -660,6 +964,14 @@ async function processAsset(asset: AssetCandidate) {
|
||||
return 'failed';
|
||||
}
|
||||
await markAssetVerified(client, asset, metadata, comparison);
|
||||
if (scan?.status === 'failed') {
|
||||
await markAssetSecurityScanFailed(client, asset, scan);
|
||||
await client.query('commit');
|
||||
return 'failed';
|
||||
}
|
||||
if (scan?.status === 'passed') {
|
||||
await markAssetSecurityScanPassed(client, asset, scan);
|
||||
}
|
||||
await client.query('commit');
|
||||
return 'verified';
|
||||
} catch (error) {
|
||||
|
||||
@@ -208,6 +208,8 @@ async function insertExportAsset(
|
||||
asset_type, storage_provider, bucket, object_key, mime_type,
|
||||
file_size_bytes, checksum_sha256, upload_status, verified_at,
|
||||
verified_size_bytes, verified_checksum_sha256, verification_details,
|
||||
security_scan_status, security_scanned_at, security_scan_provider,
|
||||
security_scan_summary,
|
||||
preview_status, visibility, is_public, status, access_rules,
|
||||
metadata, created_by, updated_by, source
|
||||
)
|
||||
@@ -216,8 +218,10 @@ async function insertExportAsset(
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, 'verified', now(),
|
||||
$12, $13, $14::jsonb,
|
||||
$15, $16, $17, $18, $19::jsonb,
|
||||
$20::jsonb, $21, $21, 'content_export_worker'
|
||||
'passed', now(), 'trusted_export_worker',
|
||||
$15::jsonb,
|
||||
$16, $17, $18, $19, $20::jsonb,
|
||||
$21::jsonb, $22, $22, 'content_export_worker'
|
||||
)
|
||||
returning id
|
||||
`,
|
||||
@@ -242,6 +246,12 @@ async function insertExportAsset(
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
riskLevel: 'none',
|
||||
issueCodes: [],
|
||||
provider: 'trusted_export_worker',
|
||||
generatedBy: 'content_export_worker',
|
||||
}),
|
||||
input.extension === 'pdf' ? 'ready' : 'none',
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
|
||||
Reference in New Issue
Block a user