119 lines
5.4 KiB
C#
119 lines
5.4 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Assets;
|
|
using Tiku.Application.Jobs;
|
|
using Tiku.Application.Storage;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Infrastructure.Jobs;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Assets.Security;
|
|
|
|
internal sealed class AssetSecurityScanJobHandler(
|
|
IContentAssetPersistence dbContext,
|
|
IAssetSecurityScanner scanner,
|
|
IObjectStorageService storage) : IBackgroundJobHandler
|
|
{
|
|
public string JobType => "asset_security_scan";
|
|
|
|
public async Task<BackgroundJobHandlerResult> HandleAsync(
|
|
BackgroundJobExecutionContext context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var assetId = BackgroundJobPayload.GetGuid(context.Payload, "assetId") ??
|
|
throw new InvalidOperationException("asset_security_scan job requires assetId.");
|
|
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
|
item => item.TenantId == context.TenantId && item.Id == assetId,
|
|
cancellationToken) ?? throw new InvalidOperationException("Asset security scan target was not found.");
|
|
if (asset.UploadStatus != AssetUploadStatus.Verified ||
|
|
string.IsNullOrWhiteSpace(asset.Bucket) ||
|
|
string.IsNullOrWhiteSpace(asset.ObjectKey))
|
|
throw new InvalidOperationException("Asset must have a verified object location before security scanning.");
|
|
|
|
asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
try
|
|
{
|
|
await using var content = await storage.OpenReadAsync(
|
|
new ObjectStorageReadRequest(
|
|
context.TenantId,
|
|
asset.StorageProvider switch
|
|
{
|
|
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
|
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
|
_ => throw new InvalidOperationException(
|
|
"Asset storage provider does not support security scanning.")
|
|
},
|
|
asset.Bucket,
|
|
asset.ObjectKey),
|
|
cancellationToken);
|
|
var result =
|
|
await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken);
|
|
var infected = result.Verdict == AssetSecurityScanVerdict.Infected;
|
|
asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed;
|
|
asset.SecurityScannedAt = DateTimeOffset.UtcNow;
|
|
asset.SecurityScanProvider = result.Provider;
|
|
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new
|
|
{
|
|
verdict = result.Verdict.ToString(),
|
|
result.Signature,
|
|
result.BytesScanned
|
|
});
|
|
dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
|
|
{
|
|
TenantId = context.TenantId,
|
|
AssetId = asset.Id,
|
|
Provider = result.Provider,
|
|
ScanStatus = asset.SecurityScanStatus,
|
|
RiskLevel = infected ? AssetSecurityRiskLevel.Critical : AssetSecurityRiskLevel.None,
|
|
IssueCodes = infected ? [result.Signature ?? "malware_detected"] : [],
|
|
Details = asset.SecurityScanSummary
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return new BackgroundJobHandlerResult(JsonSerializer.SerializeToElement(new
|
|
{
|
|
assetId = asset.Id,
|
|
status = asset.SecurityScanStatus.ToString(),
|
|
result.Signature,
|
|
result.BytesScanned
|
|
}));
|
|
}
|
|
catch (AssetSecurityScannerException exception)
|
|
{
|
|
throw new BackgroundJobHandlerException(exception.Code, exception.Message, exception);
|
|
}
|
|
}
|
|
|
|
public async Task HandleFailureAsync(
|
|
BackgroundJobExecutionContext context,
|
|
Exception exception,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (exception is not BackgroundJobHandlerException handlerException ||
|
|
handlerException.InnerException is not AssetSecurityScannerException)
|
|
return;
|
|
|
|
var assetId = BackgroundJobPayload.GetGuid(context.Payload, "assetId");
|
|
if (assetId is null) return;
|
|
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
|
item => item.TenantId == context.TenantId && item.Id == assetId,
|
|
cancellationToken);
|
|
if (asset is null) return;
|
|
|
|
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
|
asset.SecurityScanProvider = "clamav";
|
|
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new { errorCode = handlerException.Code });
|
|
dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
|
|
{
|
|
TenantId = context.TenantId,
|
|
AssetId = asset.Id,
|
|
Provider = "clamav",
|
|
ScanStatus = AssetSecurityScanStatus.Pending,
|
|
RiskLevel = AssetSecurityRiskLevel.None,
|
|
IssueCodes = [handlerException.Code],
|
|
Details = asset.SecurityScanSummary
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|