diff --git a/MalwareMultiScan.Api/Attributes/IsHttpUrlAttribute.cs b/MalwareMultiScan.Api/Attributes/IsHttpUrlAttribute.cs index e6be77f..5c49f61 100644 --- a/MalwareMultiScan.Api/Attributes/IsHttpUrlAttribute.cs +++ b/MalwareMultiScan.Api/Attributes/IsHttpUrlAttribute.cs @@ -8,9 +8,23 @@ namespace MalwareMultiScan.Api.Attributes /// public class IsHttpUrlAttribute : ValidationAttribute { + private readonly bool _failOnNull; + + /// + /// Initialize http URL validation attribute. + /// + /// True if URL is mandatory, false otherwise. + public IsHttpUrlAttribute(bool failOnNull = true) + { + _failOnNull = failOnNull; + } + /// protected override ValidationResult IsValid(object value, ValidationContext validationContext) { + if (!_failOnNull && string.IsNullOrEmpty(value.ToString())) + return ValidationResult.Success; + if (!Uri.TryCreate((string) value, UriKind.Absolute, out var uri) || !uri.IsAbsoluteUri || uri.Scheme.ToLowerInvariant() != "http" diff --git a/MalwareMultiScan.Api/Controllers/QueueController.cs b/MalwareMultiScan.Api/Controllers/QueueController.cs index 4df3af3..bbee862 100644 --- a/MalwareMultiScan.Api/Controllers/QueueController.cs +++ b/MalwareMultiScan.Api/Controllers/QueueController.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using MalwareMultiScan.Api.Attributes; @@ -31,13 +32,16 @@ namespace MalwareMultiScan.Api.Controllers /// Queue file for scanning. /// /// File from form data. + /// Optional callback URL. [HttpPost("file")] [ProducesResponseType(typeof(ScanResult), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task ScanFile( - [Required] [MaxFileSize] IFormFile file) + [Required] [MaxFileSize] IFormFile file, + [FromForm] [IsHttpUrl(false)] string callbackUrl = null) { - var result = await _scanResultService.CreateScanResult(); + var result = await _scanResultService.CreateScanResult( + string.IsNullOrEmpty(callbackUrl) ? null : new Uri(callbackUrl)); string storedFileId; @@ -56,13 +60,16 @@ namespace MalwareMultiScan.Api.Controllers /// Queue URL for scanning. /// /// URL from form data. + /// Optional callback URL. [HttpPost("url")] [ProducesResponseType(typeof(ScanResult), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task ScanUrl( - [FromForm] [Required] [IsHttpUrl] string url) + [FromForm] [Required] [IsHttpUrl] string url, + [FromForm] [IsHttpUrl(false)] string callbackUrl = null) { - var result = await _scanResultService.CreateScanResult(); + var result = await _scanResultService.CreateScanResult( + string.IsNullOrEmpty(callbackUrl) ? null : new Uri(callbackUrl)); await _scanResultService.QueueUrlScan(result, url); diff --git a/MalwareMultiScan.Api/Controllers/ScanResultsController.cs b/MalwareMultiScan.Api/Controllers/ScanResultsController.cs index a144f08..c75dc4f 100644 --- a/MalwareMultiScan.Api/Controllers/ScanResultsController.cs +++ b/MalwareMultiScan.Api/Controllers/ScanResultsController.cs @@ -15,7 +15,7 @@ namespace MalwareMultiScan.Api.Controllers public class ScanResultsController : Controller { private readonly IScanResultService _scanResultService; - + /// /// Initialize scan results controller. /// diff --git a/MalwareMultiScan.Api/Data/Configuration/ScanBackend.cs b/MalwareMultiScan.Api/Data/Configuration/ScanBackend.cs index 75911a2..f75111c 100644 --- a/MalwareMultiScan.Api/Data/Configuration/ScanBackend.cs +++ b/MalwareMultiScan.Api/Data/Configuration/ScanBackend.cs @@ -9,7 +9,7 @@ namespace MalwareMultiScan.Api.Data.Configuration /// Backend id. /// public string Id { get; set; } - + /// /// Backend state. /// diff --git a/MalwareMultiScan.Api/Data/Models/ScanResult.cs b/MalwareMultiScan.Api/Data/Models/ScanResult.cs index ce41477..4a9ddbe 100644 --- a/MalwareMultiScan.Api/Data/Models/ScanResult.cs +++ b/MalwareMultiScan.Api/Data/Models/ScanResult.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; @@ -17,7 +18,12 @@ namespace MalwareMultiScan.Api.Data.Models public string Id { get; set; } /// - /// Result entries where key is backend id and value is . + /// Optional callback URL. + /// + public Uri CallbackUrl { get; set; } + + /// + /// Result entries where key is backend id and value is . /// public Dictionary Results { get; set; } = new Dictionary(); diff --git a/MalwareMultiScan.Api/Data/Models/ScanResultEntry.cs b/MalwareMultiScan.Api/Data/Models/ScanResultEntry.cs index 48af9ff..eca493d 100644 --- a/MalwareMultiScan.Api/Data/Models/ScanResultEntry.cs +++ b/MalwareMultiScan.Api/Data/Models/ScanResultEntry.cs @@ -9,17 +9,17 @@ namespace MalwareMultiScan.Api.Data.Models /// Completion status. /// public bool Completed { get; set; } - + /// /// Indicates that scanning completed without error. /// public bool? Succeeded { get; set; } - + /// /// Scanning duration in seconds. /// public long Duration { get; set; } - + /// /// Detected names of threats. /// diff --git a/MalwareMultiScan.Api/Services/Implementations/ReceiverHostedService.cs b/MalwareMultiScan.Api/Services/Implementations/ReceiverHostedService.cs index 86db217..02b3452 100644 --- a/MalwareMultiScan.Api/Services/Implementations/ReceiverHostedService.cs +++ b/MalwareMultiScan.Api/Services/Implementations/ReceiverHostedService.cs @@ -1,3 +1,6 @@ +using System; +using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; using EasyNetQ; @@ -5,6 +8,7 @@ using MalwareMultiScan.Api.Services.Interfaces; using MalwareMultiScan.Backends.Messages; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace MalwareMultiScan.Api.Services.Implementations { @@ -13,6 +17,7 @@ namespace MalwareMultiScan.Api.Services.Implementations { private readonly IBus _bus; private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly IScanResultService _scanResultService; @@ -23,31 +28,23 @@ namespace MalwareMultiScan.Api.Services.Implementations /// Configuration. /// Scan result service. /// Logger. + /// HTTP client factory. public ReceiverHostedService(IBus bus, IConfiguration configuration, IScanResultService scanResultService, - ILogger logger) + ILogger logger, IHttpClientFactory httpClientFactory) { _bus = bus; _configuration = configuration; _scanResultService = scanResultService; _logger = logger; + _httpClientFactory = httpClientFactory; } /// public Task StartAsync(CancellationToken cancellationToken) { - _bus.Receive(_configuration.GetValue("ResultsSubscriptionId"), async message => - { - message.Threats ??= new string[] { }; - - _logger.LogInformation( - $"Received a result from {message.Backend} for {message.Id} " + - $"with threats {string.Join(",", message.Threats)}"); - - await _scanResultService.UpdateScanResultForBackend( - message.Id, message.Backend, message.Duration, true, - message.Succeeded, message.Threats); - }); + _bus.Receive( + _configuration.GetValue("ResultsSubscriptionId"), StoreScanResult); _logger.LogInformation( "Started hosted service for receiving scan results"); @@ -65,5 +62,42 @@ namespace MalwareMultiScan.Api.Services.Implementations return Task.CompletedTask; } + + private async Task StoreScanResult(ScanResultMessage message) + { + message.Threats ??= new string[] { }; + + _logger.LogInformation( + $"Received a result from {message.Backend} for {message.Id} " + + $"with threats {string.Join(",", message.Threats)}"); + + await _scanResultService.UpdateScanResultForBackend( + message.Id, message.Backend, message.Duration, true, + message.Succeeded, message.Threats); + + var result = await _scanResultService.GetScanResult(message.Id); + + if (result?.CallbackUrl == null) + return; + + var cancellationTokenSource = new CancellationTokenSource( + TimeSpan.FromSeconds(3)); + + using var httpClient = _httpClientFactory.CreateClient(); + + try + { + var response = await httpClient.PostAsync( + result.CallbackUrl, + new StringContent(JsonConvert.SerializeObject(result), Encoding.UTF8, "application/json"), + cancellationTokenSource.Token); + + response.EnsureSuccessStatusCode(); + } + catch (Exception exception) + { + _logger.LogError(exception, $"Failed to POST to callback URL {result.CallbackUrl}"); + } + } } } \ No newline at end of file diff --git a/MalwareMultiScan.Api/Services/Implementations/ScanResultService.cs b/MalwareMultiScan.Api/Services/Implementations/ScanResultService.cs index 68aa580..51b1812 100644 --- a/MalwareMultiScan.Api/Services/Implementations/ScanResultService.cs +++ b/MalwareMultiScan.Api/Services/Implementations/ScanResultService.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -31,12 +32,14 @@ namespace MalwareMultiScan.Api.Services.Implementations _collection = db.GetCollection(CollectionName); } - + /// - public async Task CreateScanResult() + public async Task CreateScanResult(Uri callbackUrl) { var scanResult = new ScanResult { + CallbackUrl = callbackUrl, + Results = _scanBackendService.List .Where(b => b.Enabled) .ToDictionary(k => k.Id, v => new ScanResultEntry()) diff --git a/MalwareMultiScan.Api/Services/Interfaces/IScanResultService.cs b/MalwareMultiScan.Api/Services/Interfaces/IScanResultService.cs index 55cf404..9f0b6d9 100644 --- a/MalwareMultiScan.Api/Services/Interfaces/IScanResultService.cs +++ b/MalwareMultiScan.Api/Services/Interfaces/IScanResultService.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Threading.Tasks; using MalwareMultiScan.Api.Data.Models; @@ -12,8 +13,9 @@ namespace MalwareMultiScan.Api.Services.Interfaces /// /// Create scan result. /// + /// Optional callback URL. /// Scan result entry with id. - Task CreateScanResult(); + Task CreateScanResult(Uri callbackUrl); /// /// Get scan result. diff --git a/MalwareMultiScan.Api/Startup.cs b/MalwareMultiScan.Api/Startup.cs index f23ba0c..3e8624c 100644 --- a/MalwareMultiScan.Api/Startup.cs +++ b/MalwareMultiScan.Api/Startup.cs @@ -38,6 +38,8 @@ namespace MalwareMultiScan.Api services.AddControllers(); services.AddHostedService(); + + services.AddHttpClient(); } public void Configure(IApplicationBuilder app) diff --git a/MalwareMultiScan.Backends/Backends/Abstracts/AbstractLocalProcessScanBackend.cs b/MalwareMultiScan.Backends/Backends/Abstracts/AbstractLocalProcessScanBackend.cs index c52a616..034609c 100644 --- a/MalwareMultiScan.Backends/Backends/Abstracts/AbstractLocalProcessScanBackend.cs +++ b/MalwareMultiScan.Backends/Backends/Abstracts/AbstractLocalProcessScanBackend.cs @@ -22,17 +22,17 @@ namespace MalwareMultiScan.Backends.Backends.Abstracts /// Regex to extract names of threats. /// protected abstract Regex MatchRegex { get; } - + /// /// Path to the backend. /// protected abstract string BackendPath { get; } - + /// /// Parse StdErr instead of StdOut. /// protected virtual bool ParseStdErr { get; } = false; - + /// /// Throw on non-zero exit code. /// @@ -44,7 +44,7 @@ namespace MalwareMultiScan.Backends.Backends.Abstracts /// Path to the temporary file. /// Formatted string with parameters and path. protected abstract string GetBackendArguments(string path); - + /// public override Task ScanAsync(string path, CancellationToken cancellationToken) { diff --git a/MalwareMultiScan.Backends/Backends/Implementations/WindowsDefenderScanBackend.cs b/MalwareMultiScan.Backends/Backends/Implementations/WindowsDefenderScanBackend.cs index 45b9f2f..4db9613 100644 --- a/MalwareMultiScan.Backends/Backends/Implementations/WindowsDefenderScanBackend.cs +++ b/MalwareMultiScan.Backends/Backends/Implementations/WindowsDefenderScanBackend.cs @@ -11,7 +11,7 @@ namespace MalwareMultiScan.Backends.Backends.Implementations public WindowsDefenderScanBackend(IProcessRunner processRunner) : base(processRunner) { } - + /// public override string Id { get; } = "windows-defender"; @@ -22,7 +22,7 @@ namespace MalwareMultiScan.Backends.Backends.Implementations protected override Regex MatchRegex { get; } = new Regex(@"EngineScanCallback\(\): Threat (?[\S]+) identified", RegexOptions.Compiled | RegexOptions.Multiline); - + /// protected override bool ParseStdErr { get; } = true; diff --git a/MalwareMultiScan.Backends/Enums/BackendType.cs b/MalwareMultiScan.Backends/Enums/BackendType.cs index e858ce2..5dda416 100644 --- a/MalwareMultiScan.Backends/Enums/BackendType.cs +++ b/MalwareMultiScan.Backends/Enums/BackendType.cs @@ -9,37 +9,37 @@ namespace MalwareMultiScan.Backends.Enums /// Dummy /// Dummy, - + /// /// Windows Defender. /// Defender, - + /// /// ClamAV. /// Clamav, - + /// /// DrWeb. /// DrWeb, - + /// /// KES. /// Kes, - + /// /// Comodo. /// Comodo, - + /// /// Sophos. /// Sophos, - + /// /// McAfee. /// diff --git a/MalwareMultiScan.Backends/Interfaces/IScanBackend.cs b/MalwareMultiScan.Backends/Interfaces/IScanBackend.cs index 64f42be..b352ea0 100644 --- a/MalwareMultiScan.Backends/Interfaces/IScanBackend.cs +++ b/MalwareMultiScan.Backends/Interfaces/IScanBackend.cs @@ -14,7 +14,7 @@ namespace MalwareMultiScan.Backends.Interfaces /// Unique backend id. /// public string Id { get; } - + /// /// Scan file. /// @@ -22,7 +22,7 @@ namespace MalwareMultiScan.Backends.Interfaces /// Cancellation token. /// List of detected threats. public Task ScanAsync(string path, CancellationToken cancellationToken); - + /// /// Scan URL. /// @@ -30,7 +30,7 @@ namespace MalwareMultiScan.Backends.Interfaces /// Cancellation token. /// List of detected threats. public Task ScanAsync(Uri uri, CancellationToken cancellationToken); - + /// /// Scan stream. /// diff --git a/MalwareMultiScan.Backends/Messages/ScanResultMessage.cs b/MalwareMultiScan.Backends/Messages/ScanResultMessage.cs index 515d64c..01686ff 100644 --- a/MalwareMultiScan.Backends/Messages/ScanResultMessage.cs +++ b/MalwareMultiScan.Backends/Messages/ScanResultMessage.cs @@ -9,7 +9,7 @@ namespace MalwareMultiScan.Backends.Messages /// Result id. /// public string Id { get; set; } - + /// /// Backend. /// @@ -19,7 +19,7 @@ namespace MalwareMultiScan.Backends.Messages /// Status. /// public bool Succeeded { get; set; } - + /// /// List of detected threats. /// diff --git a/MalwareMultiScan.Backends/Services/Implementations/ProcessRunner.cs b/MalwareMultiScan.Backends/Services/Implementations/ProcessRunner.cs index 1805e34..2e4c6f3 100644 --- a/MalwareMultiScan.Backends/Services/Implementations/ProcessRunner.cs +++ b/MalwareMultiScan.Backends/Services/Implementations/ProcessRunner.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Logging; namespace MalwareMultiScan.Backends.Services.Implementations { /// - /// /// [ExcludeFromCodeCoverage] public class ProcessRunner : IProcessRunner @@ -26,7 +25,6 @@ namespace MalwareMultiScan.Backends.Services.Implementations } /// - /// /// /// /// diff --git a/MalwareMultiScan.Scanner/MalwareMultiScan.Scanner.csproj b/MalwareMultiScan.Scanner/MalwareMultiScan.Scanner.csproj index 3fc1786..b904dcc 100644 --- a/MalwareMultiScan.Scanner/MalwareMultiScan.Scanner.csproj +++ b/MalwareMultiScan.Scanner/MalwareMultiScan.Scanner.csproj @@ -10,6 +10,8 @@ + + diff --git a/MalwareMultiScan.Scanner/Program.cs b/MalwareMultiScan.Scanner/Program.cs index 94c5afc..8fc6e45 100644 --- a/MalwareMultiScan.Scanner/Program.cs +++ b/MalwareMultiScan.Scanner/Program.cs @@ -1,7 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using Hangfire; +using Hangfire.MemoryStorage; using MalwareMultiScan.Backends.Extensions; using MalwareMultiScan.Scanner.Services.Implementations; +using MalwareMultiScan.Scanner.Services.Interfaces; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -26,7 +29,16 @@ namespace MalwareMultiScan.Scanner services.AddRabbitMq(context.Configuration); services.AddScanningBackend(context.Configuration); + services.AddSingleton(); services.AddHostedService(); + + services.AddHangfire( + configuration => configuration.UseMemoryStorage()); + + services.AddHangfireServer(options => + { + options.WorkerCount = context.Configuration.GetValue("WorkerCount"); + }); }).RunConsoleAsync(); } } diff --git a/MalwareMultiScan.Scanner/Services/Implementations/ScanBackgroundJob.cs b/MalwareMultiScan.Scanner/Services/Implementations/ScanBackgroundJob.cs new file mode 100644 index 0000000..0804298 --- /dev/null +++ b/MalwareMultiScan.Scanner/Services/Implementations/ScanBackgroundJob.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using EasyNetQ; +using MalwareMultiScan.Backends.Interfaces; +using MalwareMultiScan.Backends.Messages; +using MalwareMultiScan.Scanner.Services.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace MalwareMultiScan.Scanner.Services.Implementations +{ + /// + public class ScanBackgroundJob : IScanBackgroundJob + { + private readonly IScanBackend _backend; + private readonly IBus _bus; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + /// + /// Initialize scan background job. + /// + /// Configuration. + /// Bus. + /// Logger. + /// Scan backend. + public ScanBackgroundJob(IConfiguration configuration, IBus bus, + ILogger logger, IScanBackend backend) + { + _configuration = configuration; + _bus = bus; + _logger = logger; + _backend = backend; + } + + /// + public async Task Process(ScanRequestMessage message) + { + _logger.LogInformation( + $"Starting scan of {message.Uri} via backend {_backend.Id} from {message.Id}"); + + var cancellationTokenSource = new CancellationTokenSource( + TimeSpan.FromSeconds(_configuration.GetValue("MaxScanningTime"))); + + var result = new ScanResultMessage + { + Id = message.Id, + Backend = _backend.Id + }; + + var stopwatch = new Stopwatch(); + + stopwatch.Start(); + + try + { + result.Threats = await _backend.ScanAsync( + message.Uri, cancellationTokenSource.Token); + + result.Succeeded = true; + + _logger.LogInformation( + $"Backend {_backend.Id} completed a scan of {message.Id} " + + $"with result '{string.Join(", ", result.Threats)}'"); + } + catch (Exception exception) + { + result.Succeeded = false; + + _logger.LogError( + exception, "Scanning failed with exception"); + } + finally + { + stopwatch.Stop(); + } + + result.Duration = stopwatch.ElapsedMilliseconds / 1000; + + _logger.LogInformation( + $"Sending scan results with status {result.Succeeded}"); + + await _bus.SendAsync( + _configuration.GetValue("ResultsSubscriptionId"), result); + } + } +} \ No newline at end of file diff --git a/MalwareMultiScan.Scanner/Services/Implementations/ScanHostedService.cs b/MalwareMultiScan.Scanner/Services/Implementations/ScanHostedService.cs index 9e6c2f2..180249a 100644 --- a/MalwareMultiScan.Scanner/Services/Implementations/ScanHostedService.cs +++ b/MalwareMultiScan.Scanner/Services/Implementations/ScanHostedService.cs @@ -1,12 +1,10 @@ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using EasyNetQ; +using Hangfire; using MalwareMultiScan.Backends.Interfaces; using MalwareMultiScan.Backends.Messages; using MalwareMultiScan.Scanner.Services.Interfaces; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace MalwareMultiScan.Scanner.Services.Implementations @@ -16,7 +14,6 @@ namespace MalwareMultiScan.Scanner.Services.Implementations { private readonly IScanBackend _backend; private readonly IBus _bus; - private readonly IConfiguration _configuration; private readonly ILogger _logger; /// @@ -25,22 +22,20 @@ namespace MalwareMultiScan.Scanner.Services.Implementations /// Logger. /// Scan backend. /// EasyNetQ bus. - /// Configuration. public ScanHostedService( ILogger logger, - IScanBackend backend, IBus bus, - IConfiguration configuration) + IScanBackend backend, IBus bus) { _logger = logger; _bus = bus; - _configuration = configuration; _backend = backend; } /// public Task StartAsync(CancellationToken cancellationToken) { - _bus.Receive(_backend.Id, Scan); + _bus.Receive(_backend.Id, message => + BackgroundJob.Enqueue(j => j.Process(message))); _logger.LogInformation( $"Started scan hosting service for the backend {_backend.Id}"); @@ -58,55 +53,5 @@ namespace MalwareMultiScan.Scanner.Services.Implementations return Task.CompletedTask; } - - private async Task Scan(ScanRequestMessage message) - { - _logger.LogInformation( - $"Starting scan of {message.Uri} via backend {_backend.Id} from {message.Id}"); - - var cancellationTokenSource = new CancellationTokenSource( - TimeSpan.FromSeconds(_configuration.GetValue("MaxScanningTime"))); - - var result = new ScanResultMessage - { - Id = message.Id, - Backend = _backend.Id - }; - - var stopwatch = new Stopwatch(); - - stopwatch.Start(); - - try - { - result.Threats = await _backend.ScanAsync( - message.Uri, cancellationTokenSource.Token); - - result.Succeeded = true; - - _logger.LogInformation( - $"Backend {_backend.Id} completed a scan of {message.Id} " + - $"with result '{string.Join(", ", result.Threats)}'"); - } - catch (Exception exception) - { - result.Succeeded = false; - - _logger.LogError( - exception, "Scanning failed with exception"); - } - finally - { - stopwatch.Stop(); - } - - result.Duration = stopwatch.ElapsedMilliseconds / 1000; - - _logger.LogInformation( - $"Sending scan results with status {result.Succeeded}"); - - await _bus.SendAsync( - _configuration.GetValue("ResultsSubscriptionId"), result); - } } } \ No newline at end of file diff --git a/MalwareMultiScan.Scanner/Services/Interfaces/IScanBackgroundJob.cs b/MalwareMultiScan.Scanner/Services/Interfaces/IScanBackgroundJob.cs new file mode 100644 index 0000000..e688dc9 --- /dev/null +++ b/MalwareMultiScan.Scanner/Services/Interfaces/IScanBackgroundJob.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using MalwareMultiScan.Backends.Messages; + +namespace MalwareMultiScan.Scanner.Services.Interfaces +{ + /// + /// Background scanning job. + /// + public interface IScanBackgroundJob + { + /// + /// Process scan request in the background worker. + /// + /// Request message. + Task Process(ScanRequestMessage requestMessage); + } +} \ No newline at end of file diff --git a/MalwareMultiScan.Scanner/appsettings.json b/MalwareMultiScan.Scanner/appsettings.json index 3c086e9..55e4b45 100644 --- a/MalwareMultiScan.Scanner/appsettings.json +++ b/MalwareMultiScan.Scanner/appsettings.json @@ -11,6 +11,7 @@ "MaxScanningTime": 60, "ResultsSubscriptionId": "mms.results", + "WorkerCount": 4, "ConnectionStrings": { "RabbitMQ": "host=localhost;prefetchcount=1" diff --git a/MalwareMultiScan.Tests/Api/ControllersTests.cs b/MalwareMultiScan.Tests/Api/ControllersTests.cs index 8cdea7e..7383af2 100644 --- a/MalwareMultiScan.Tests/Api/ControllersTests.cs +++ b/MalwareMultiScan.Tests/Api/ControllersTests.cs @@ -46,7 +46,7 @@ namespace MalwareMultiScan.Tests.Api .Returns(Task.FromResult((Stream) null)); _scanResultServiceMock - .Setup(x => x.CreateScanResult()) + .Setup(x => x.CreateScanResult(null)) .Returns(Task.FromResult(new ScanResult())); _downloadController = new DownloadController(_scanResultServiceMock.Object); diff --git a/MalwareMultiScan.Tests/Api/ReceiverHostedServiceTests.cs b/MalwareMultiScan.Tests/Api/ReceiverHostedServiceTests.cs index 4790955..a92f897 100644 --- a/MalwareMultiScan.Tests/Api/ReceiverHostedServiceTests.cs +++ b/MalwareMultiScan.Tests/Api/ReceiverHostedServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading.Tasks; using EasyNetQ; using MalwareMultiScan.Api.Services.Implementations; @@ -50,11 +51,14 @@ namespace MalwareMultiScan.Tests.Api ["ResultsSubscriptionId"] = "mms.results" }; + var httpClientFactoryMock = new Mock(); + _receiverHostedService = new ReceiverHostedService( _busMock.Object, configuration, _scanResultServiceMock.Object, - Mock.Of>()); + Mock.Of>(), + httpClientFactoryMock.Object); } [Test] diff --git a/MalwareMultiScan.Tests/Api/ScanResultServiceTests.cs b/MalwareMultiScan.Tests/Api/ScanResultServiceTests.cs index 60b8d58..e85cdbb 100644 --- a/MalwareMultiScan.Tests/Api/ScanResultServiceTests.cs +++ b/MalwareMultiScan.Tests/Api/ScanResultServiceTests.cs @@ -82,7 +82,7 @@ namespace MalwareMultiScan.Tests.Api [Test] public async Task TestScanResultCreateGet() { - var result = await _resultService.CreateScanResult(); + var result = await _resultService.CreateScanResult(null); Assert.NotNull(result.Id); Assert.That(result.Results, Contains.Key("dummy")); @@ -107,7 +107,7 @@ namespace MalwareMultiScan.Tests.Api [Test] public async Task TestQueueUrlScan() { - var result = await _resultService.CreateScanResult(); + var result = await _resultService.CreateScanResult(null); await _resultService.QueueUrlScan( result, "http://url.com"); diff --git a/MalwareMultiScan.Tests/Scanner/ScanBackgroundJobTests.cs b/MalwareMultiScan.Tests/Scanner/ScanBackgroundJobTests.cs new file mode 100644 index 0000000..d255d03 --- /dev/null +++ b/MalwareMultiScan.Tests/Scanner/ScanBackgroundJobTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using EasyNetQ; +using MalwareMultiScan.Backends.Interfaces; +using MalwareMultiScan.Backends.Messages; +using MalwareMultiScan.Scanner.Services.Implementations; +using MalwareMultiScan.Scanner.Services.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace MalwareMultiScan.Tests.Scanner +{ + public class ScanBackgroundJobTests + { + private Mock _busMock; + private Mock _scanBackendMock; + private IScanBackgroundJob _scanBackgroundJob; + + [SetUp] + public void SetUp() + { + var configuration = new ConfigurationRoot(new List + { + new MemoryConfigurationProvider(new MemoryConfigurationSource()) + }) + { + ["ResultsSubscriptionId"] = "mms.results" + }; + + _busMock = new Mock(); + + _scanBackendMock = new Mock(); + + _scanBackendMock + .SetupGet(x => x.Id) + .Returns("dummy"); + + _scanBackendMock + .Setup(x => x.ScanAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new[] {"Test"})); + + _scanBackgroundJob = new ScanBackgroundJob(configuration, _busMock.Object, + Mock.Of>(), _scanBackendMock.Object); + } + + [Test] + public async Task TestMessageProcessing() + { + await _scanBackgroundJob.Process(new ScanRequestMessage + { + Id = "test", + Uri = new Uri("http://test.com") + }); + + _scanBackendMock.Verify( + x => x.ScanAsync(It.IsAny(), It.IsAny())); + + _busMock.Verify(x => x.SendAsync("mms.results", It.Is(m => + m.Succeeded && m.Backend == "dummy" && m.Id == "test" && m.Threats.Contains("Test")))); + } + } +} \ No newline at end of file diff --git a/MalwareMultiScan.Tests/Scanner/ScanHostedServiceTests.cs b/MalwareMultiScan.Tests/Scanner/ScanHostedServiceTests.cs index 9f8c484..a7a2a19 100644 --- a/MalwareMultiScan.Tests/Scanner/ScanHostedServiceTests.cs +++ b/MalwareMultiScan.Tests/Scanner/ScanHostedServiceTests.cs @@ -1,15 +1,10 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; using System.Threading.Tasks; using EasyNetQ; using MalwareMultiScan.Backends.Interfaces; using MalwareMultiScan.Backends.Messages; using MalwareMultiScan.Scanner.Services.Implementations; using MalwareMultiScan.Scanner.Services.Interfaces; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Memory; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; @@ -25,35 +20,10 @@ namespace MalwareMultiScan.Tests.Scanner [SetUp] public void SetUp() { - var configuration = new ConfigurationRoot(new List - { - new MemoryConfigurationProvider(new MemoryConfigurationSource()) - }) - { - ["ResultsSubscriptionId"] = "mms.results" - }; - _busMock = new Mock(); - _busMock - .Setup(x => x.Receive("dummy", It.IsAny>())) - .Callback>((s, func) => - { - var task = func.Invoke(new ScanRequestMessage - { - Id = "test", - Uri = new Uri("http://test.com") - }); - - task.Wait(); - }); - _scanBackendMock = new Mock(); - _scanBackendMock - .Setup(x => x.ScanAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(new[] {"Test"})); - _scanBackendMock .SetupGet(x => x.Id) .Returns("dummy"); @@ -61,8 +31,7 @@ namespace MalwareMultiScan.Tests.Scanner _scanHostedService = new ScanHostedService( Mock.Of>(), _scanBackendMock.Object, - _busMock.Object, - configuration); + _busMock.Object); } [Test] @@ -70,9 +39,8 @@ namespace MalwareMultiScan.Tests.Scanner { await _scanHostedService.StartAsync(default); - _busMock.Verify(x => x.SendAsync("mms.results", It.Is( - m => m.Succeeded && m.Backend == "dummy" && m.Id == "test" && m.Threats.Contains("Test") - ))); + _busMock.Verify( + x => x.Receive("dummy", It.IsAny>())); } [Test] diff --git a/MalwareMultiScan.Ui/pages/index.vue b/MalwareMultiScan.Ui/pages/index.vue index e0769a2..a0bedb7 100644 --- a/MalwareMultiScan.Ui/pages/index.vue +++ b/MalwareMultiScan.Ui/pages/index.vue @@ -9,6 +9,9 @@ + + @@ -19,6 +22,9 @@ Scan + + @@ -37,6 +43,7 @@ export default Vue.extend({ file: null as File | null, url: '' as string, + callbackUrl: '' as string } }, @@ -53,6 +60,9 @@ export default Vue.extend({ data.append('file', this.file, this.file.name); + if (this.isUrl(this.callbackUrl)) + data.append('callbackUrl', this.callbackUrl); + try { this.uploading = true; @@ -80,6 +90,9 @@ export default Vue.extend({ data.append('url', this.url); + if (this.isUrl(this.callbackUrl)) + data.append('callbackUrl', this.callbackUrl); + const result = await this.$axios.$post('queue/url', data); await this.$router.push({name: 'id', params: {id: result.id}}); diff --git a/MalwareMultiScan.sln b/MalwareMultiScan.sln index be584ea..2c0b362 100644 --- a/MalwareMultiScan.sln +++ b/MalwareMultiScan.sln @@ -11,6 +11,8 @@ ProjectSection(SolutionItems) = preProject .dockerignore = .dockerignore .gitignore = .gitignore docker-compose.yaml = docker-compose.yaml + README.md = README.md + LICENSE = LICENSE EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MalwareMultiScan.Tests", "MalwareMultiScan.Tests\MalwareMultiScan.Tests.csproj", "{9896162D-8FC7-4911-933F-A78C94128923}" diff --git a/README.md b/README.md index 8d8e0fb..5e9913d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ In the end, it's nothing but the set of Docker containers running the agent. Tha **IMPORTANT**: MalwareMultiScan is not intended as a publicly-facing API / UI. It has (intentionally) no authorization, authentication, rate-limiting, or logging. Therefore, it should be used only as an internal / private API or behind the restrictive API gateway. +Whole solution can be started with `docker-compose up` executed in a root folder of repository. + +It can be also deployed to the Docker Swarm cluster by using the command `docker stack deploy malware-multi-scan --compose-file docker-compose.yaml`. + +After the start the Demo Web UI will become available under http://localhost:8888. + See [components](#components) chapter below and the [docker-compose.yaml](docker-compose.yaml) file. ### Configuration @@ -48,18 +54,22 @@ Configuration of API and Scanners is performed by passing the environment variab * `MaxScanningTime=60` - Scan time limit. It is used not just for actual scanning but also for getting the file. +* `WorkerCount=4` - Number of workers for parallel scanning. + #### MalwareMultiScan.Ui * `API_URL=http://localhost:5000` - Absolute URL incl. port number for the running instance of MalwareMultiScan.Api. ### API Endpoints -* POST `/api/queue/url` with a `url` parameter passed via the form data. Returns `201 Accepted` response with a [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs) or `400 Bad Request` error. +* POST `/api/queue/url` with a `url` parameter passed via the form data.. Returns `201 Accepted` response with a [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs) or `400 Bad Request` error. - * POST `/api/queue/file` with a `file` parameter passed via the form data. Returns `201 Accepted` response with a [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs) or `400 Bad Request` error. +* POST `/api/queue/file` with a `file` parameter passed via the form data. Returns `201 Accepted` response with a [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs) or `400 Bad Request` error. * GET `/api/results/{result-id}` where `{result-id}` corresponds to the id value of a [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs). Returns `200 OK` response with a [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs) or `404 Not Found` error. +Both `/api/queue/url` and `/api/queue/file` also accept an optional `callbackUrl` parameter with the http(s) URL in it. This URL will be requested by the POST method with JSON serialized [ScanResult](MalwareMultiScan.Api/Data/Models/ScanResult.cs) in a body on every update from scan backends. + ## Supported Scan Engines | Name | Dockerfile | Enabled | Comments | @@ -85,7 +95,7 @@ More scan backends can be added in the future. Some of the popular ones do not h * **Docker** and **docker-compose** running under Windows (in Linux containers mode), Linux, or OSX. Docker Compose is needed only for test / local deployments. -* **Optional**: DockerSwarm / Kubernetes cluster for scaling up the scanning capacities. A simultaneous scan by a single node is not supported, yet load-balancing is possible by the built-in orchestrators' round-robin routing. +* **Optional**: DockerSwarm / Kubernetes cluster for scaling up the scanning capacities. ### Parts @@ -99,4 +109,4 @@ More scan backends can be added in the future. Some of the popular ones do not h ## Plans - See [issues](https://github.com/mindcollapse/MalwareMultiScan/issues) for the list of planned features, bug-fixes, and improvements. + See [issues](https://github.com/mindcollapse/MalwareMultiScan/issues) for the list of planned features, bug-fixes, and improvements. \ No newline at end of file