diff --git a/.github/workflows/ci-dotnet.yaml b/.github/workflows/ci-dotnet.yaml index d3ca9f2e2..6aa46d800 100644 --- a/.github/workflows/ci-dotnet.yaml +++ b/.github/workflows/ci-dotnet.yaml @@ -28,7 +28,7 @@ on: env: CLIENT_ID: ${{ secrets.CLIENT_ID }} CLIENT_SECRET: ${{secrets.CLIENT_SECRET}} - VAAS_URL: "wss://gateway.production.vaas.gdatasecurity.de" + VAAS_URL: "https://gateway.production.vaas.gdatasecurity.de" TOKEN_URL: "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token" VAAS_CLIENT_ID: ${{ secrets.VAAS_CLIENT_ID }} VAAS_USER_NAME: ${{ secrets.VAAS_USER_NAME }} @@ -56,7 +56,7 @@ jobs: run: | echo "CLIENT_ID=${{ secrets.STAGING_CLIENT_ID }}" >> $GITHUB_ENV echo "CLIENT_SECRET=${{ secrets.STAGING_CLIENT_SECRET }}" >> $GITHUB_ENV - echo "VAAS_URL=wss://gateway.staging.vaas.gdatasecurity.de" >> $GITHUB_ENV + echo "VAAS_URL=https://gateway.staging.vaas.gdatasecurity.de" >> $GITHUB_ENV echo "TOKEN_URL=https://account-staging.gdata.de/realms/vaas-staging/protocol/openid-connect/token" >> $GITHUB_ENV echo "VAAS_CLIENT_ID=${{ secrets.STAGING_VAAS_CLIENT_ID }}" >> $GITHUB_ENV echo "VAAS_USER_NAME=${{ secrets.STAGING_VAAS_USER_NAME }}" >> $GITHUB_ENV @@ -67,7 +67,7 @@ jobs: run: | echo "CLIENT_ID=${{ secrets.DEVELOP_CLIENT_ID }}" >> $GITHUB_ENV echo "CLIENT_SECRET=${{ secrets.DEVELOP_CLIENT_SECRET }}" >> $GITHUB_ENV - echo "VAAS_URL=wss://gateway.develop.vaas.gdatasecurity.de" >> $GITHUB_ENV + echo "VAAS_URL=https://gateway.develop.vaas.gdatasecurity.de" >> $GITHUB_ENV echo "TOKEN_URL=https://account-staging.gdata.de/realms/vaas-develop/protocol/openid-connect/token" >> $GITHUB_ENV echo "VAAS_CLIENT_ID=${{ secrets.DEVELOP_VAAS_CLIENT_ID }}" >> $GITHUB_ENV echo "VAAS_USER_NAME=${{ secrets.DEVELOP_VAAS_USER_NAME }}" >> $GITHUB_ENV @@ -101,19 +101,19 @@ jobs: run: dotnet test --no-restore --verbosity normal working-directory: dotnet/Vaas - - name: Run example FileScan - env: - SCAN_PATH: "Program.cs" - run: dotnet run FileScan - working-directory: dotnet/examples/VaasExample + # - name: Run example FileScan + # env: + # SCAN_PATH: "Program.cs" + # run: dotnet run FileScan + # working-directory: dotnet/examples/VaasExample - - name: Run example UrlScan - run: dotnet run UrlScan - working-directory: dotnet/examples/VaasExample + # - name: Run example UrlScan + # run: dotnet run UrlScan + # working-directory: dotnet/examples/VaasExample - - name: Run example HashsumScan - run: dotnet run HashsumScan - working-directory: dotnet/examples/VaasExample + # - name: Run example HashsumScan + # run: dotnet run HashsumScan + # working-directory: dotnet/examples/VaasExample - name: Pack if: startsWith(github.ref, 'refs/tags/cs') diff --git a/.gitignore b/.gitignore index a762d5e23..9ff3bd531 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ lib/ bin/ obj/ +.idea/ +.mono/ # Python __pycache__/ diff --git a/dotnet/.devcontainer/devcontainer.json b/dotnet/.devcontainer/devcontainer.json index 1e394b708..2b76ed5a5 100644 --- a/dotnet/.devcontainer/devcontainer.json +++ b/dotnet/.devcontainer/devcontainer.json @@ -3,11 +3,12 @@ { "name": "C# (.NET)", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0-bullseye", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0-bookworm", "customizations": { "vscode": { "extensions": [ - "jcamp.dotnet-test-provider-explorer" + "jcamp.dotnet-test-provider-explorer", + "ms-dotnettools.csdevkit" ] } } diff --git a/dotnet/Vaas/.idea/.idea.Vaas/.idea/CSharpierPlugin.xml b/dotnet/Vaas/.idea/.idea.Vaas/.idea/CSharpierPlugin.xml new file mode 100644 index 000000000..5e2406196 --- /dev/null +++ b/dotnet/Vaas/.idea/.idea.Vaas/.idea/CSharpierPlugin.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/dotnet/Vaas/.idea/.idea.Vaas/.idea/externalDependencies.xml b/dotnet/Vaas/.idea/.idea.Vaas/.idea/externalDependencies.xml new file mode 100644 index 000000000..1a9178a92 --- /dev/null +++ b/dotnet/Vaas/.idea/.idea.Vaas/.idea/externalDependencies.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dotnet/Vaas/Vaas.sln b/dotnet/Vaas/Vaas.sln index 1813d99a7..9c9fd78b8 100644 --- a/dotnet/Vaas/Vaas.sln +++ b/dotnet/Vaas/Vaas.sln @@ -8,6 +8,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vaas", "src\Vaas\Vaas.csproj", "{F83EA992-9DB1-43FF-89F2-836F4B325345}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{59EEE214-8910-4FBC-AA18-F7070B700D49}" + ProjectSection(SolutionItems) = preProject + test\.env = test\.env + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vaas.Test", "test\Vaas.Test\Vaas.Test.csproj", "{5C11F0F4-1483-4850-BB24-4A04E7EED14A}" EndProject diff --git a/dotnet/Vaas/src/Vaas/Authentication/Authenticator.cs b/dotnet/Vaas/src/Vaas/Authentication/Authenticator.cs deleted file mode 100644 index b1f53e46c..000000000 --- a/dotnet/Vaas/src/Vaas/Authentication/Authenticator.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Vaas.Authentication; - -public class Authenticator : IAuthenticator, IDisposable -{ - private readonly HttpClient _httpClient; - private readonly ISystemClock _systemClock; - private readonly VaasOptions _options; - private readonly SemaphoreSlim _semaphore = new(1); - private readonly JwtSecurityTokenHandler _jwtSecurityTokenHandler = new(); - private TokenResponse? _lastResponse; - private DateTime _validTo; - - public Authenticator(HttpClient httpClient, ISystemClock systemClock, VaasOptions options) - { - _httpClient = httpClient; - _systemClock = systemClock; - _options = options; - } - - public async Task GetTokenAsync(CancellationToken cancellationToken) - { - try - { - await _semaphore.WaitAsync(cancellationToken); - - if (_lastResponse != null && _validTo.ToUniversalTime() >= _systemClock.UtcNow) - return _lastResponse.AccessToken; - - _lastResponse = await RequestTokenAsync(cancellationToken); - var accessToken = _jwtSecurityTokenHandler.ReadJwtToken(_lastResponse.AccessToken); - _validTo = accessToken.ValidTo; - return _lastResponse.AccessToken; - } - finally - { - _semaphore.Release(); - } - } - - private async Task RequestTokenAsync(CancellationToken cancellationToken) - { - var form = TokenRequestToForm(); - var response = await _httpClient.PostAsync(_options.TokenUrl, form, cancellationToken); - response.EnsureSuccessStatusCode(); - var stringResponse = await response.Content.ReadAsStringAsync(cancellationToken); - var tokenResponse = JsonSerializer.Deserialize(stringResponse); - if (tokenResponse == null) - throw new JsonException("Access token is null"); - return tokenResponse; - } - - private FormUrlEncodedContent TokenRequestToForm() - { - if (_options.Credentials.GrantType == GrantType.ClientCredentials) - { - return new FormUrlEncodedContent( - new List> - { - new("client_id", _options.Credentials.ClientId), - new("client_secret", _options.Credentials.ClientSecret ?? throw new InvalidOperationException()), - new("grant_type", "client_credentials") - } - ); - } - - return new FormUrlEncodedContent( - new List> - { - new("client_id", _options.Credentials.ClientId), - new("username", _options.Credentials.UserName ?? throw new InvalidOperationException()), - new("password", _options.Credentials.Password ?? throw new InvalidOperationException()), - new("grant_type", "password") - }); - } - - public Task RefreshTokenAsync(CancellationToken cancellationToken) - { - throw new System.NotImplementedException(); - } - - public void Dispose() - { - _semaphore.Dispose(); - } -} diff --git a/dotnet/Vaas/src/Vaas/Authentication/BearerTokenHandler.cs b/dotnet/Vaas/src/Vaas/Authentication/BearerTokenHandler.cs deleted file mode 100644 index 8a067b619..000000000 --- a/dotnet/Vaas/src/Vaas/Authentication/BearerTokenHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading; -using System.Threading.Tasks; - -namespace Vaas.Authentication; - -public class BearerTokenHandler : DelegatingHandler -{ - private readonly IAuthenticator _authenticator; - - public BearerTokenHandler(IAuthenticator authenticator) => _authenticator = authenticator; - - protected override async Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) - { - request.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", await _authenticator.GetTokenAsync(cancellationToken)); - return await base.SendAsync(request, cancellationToken); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Authentication/ClientCredentialsGrantAuthenticator.cs b/dotnet/Vaas/src/Vaas/Authentication/ClientCredentialsGrantAuthenticator.cs new file mode 100644 index 000000000..3f06fef54 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/ClientCredentialsGrantAuthenticator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace Vaas.Authentication; + +public class ClientCredentialsGrantAuthenticator( + string clientId, + string clientSecret, + Uri? tokenEndpoint = null, + HttpClient? httpClient = null, + ISystemClock? systemClock = null +) : TokenReceiver(tokenEndpoint, httpClient, systemClock), IAuthenticator +{ + private string ClientId { get; } = clientId; + private string ClientSecret { get; } = clientSecret; + + protected override FormUrlEncodedContent TokenRequestToForm() + { + return new FormUrlEncodedContent( + new List> + { + new("client_id", ClientId), + new("client_secret", ClientSecret ?? throw new InvalidOperationException()), + new("grant_type", "client_credentials"), + } + ); + } +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/ErrorResponse.cs b/dotnet/Vaas/src/Vaas/Authentication/ErrorResponse.cs new file mode 100644 index 000000000..8f8a1d9a4 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/ErrorResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Vaas.Authentication; + +public class ErrorResponse +{ + [JsonPropertyName("error")] + public required string Error { get; init; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; init; } +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/GrantType.cs b/dotnet/Vaas/src/Vaas/Authentication/GrantType.cs new file mode 100644 index 000000000..e91dcf84b --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/GrantType.cs @@ -0,0 +1,7 @@ +namespace Vaas.Authentication; + +public enum GrantType +{ + ClientCredentials, + Password, +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/ResourceOwnerPasswordGrantAuthenticator.cs b/dotnet/Vaas/src/Vaas/Authentication/ResourceOwnerPasswordGrantAuthenticator.cs new file mode 100644 index 000000000..5b0ccbefa --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/ResourceOwnerPasswordGrantAuthenticator.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace Vaas.Authentication; + +public class ResourceOwnerPasswordGrantAuthenticator( + string clientId, + string userName, + string password, + Uri? tokenEndpoint = null, + HttpClient? httpClient = null, + ISystemClock? systemClock = null +) : TokenReceiver(tokenEndpoint, httpClient, systemClock), IAuthenticator +{ + private string ClientId { get; } = clientId; + private string UserName { get; } = userName; + private string Password { get; } = password; + + protected override FormUrlEncodedContent TokenRequestToForm() + { + return new FormUrlEncodedContent( + new List> + { + new("client_id", ClientId), + new("username", UserName ?? throw new InvalidOperationException()), + new("password", Password ?? throw new InvalidOperationException()), + new("grant_type", "password"), + } + ); + } +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/SystemClock.cs b/dotnet/Vaas/src/Vaas/Authentication/SystemClock.cs index 77a6920e4..1524896b6 100644 --- a/dotnet/Vaas/src/Vaas/Authentication/SystemClock.cs +++ b/dotnet/Vaas/src/Vaas/Authentication/SystemClock.cs @@ -10,4 +10,4 @@ public interface ISystemClock public class SystemClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; -} \ No newline at end of file +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/TokenReceiver.cs b/dotnet/Vaas/src/Vaas/Authentication/TokenReceiver.cs new file mode 100644 index 000000000..d41e2e077 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/TokenReceiver.cs @@ -0,0 +1,114 @@ +using System; +using System.Net.Http; +using System.Security.Authentication; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Vaas.Authentication; + +public abstract class TokenReceiver( + Uri? tokenUrl = null, + HttpClient? httpClient = null, + ISystemClock? systemClock = null +) : IDisposable +{ + private readonly Uri _tokenUrl = + tokenUrl + ?? new Uri("https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token"); + private readonly HttpClient _httpClient = httpClient ?? new HttpClient(); + private readonly SemaphoreSlim _semaphore = new(1); + private readonly ISystemClock _systemClock = systemClock ?? new SystemClock(); + private TokenResponse? _lastResponse; + private DateTime _validTo; + private DateTime? _lastRequestTime; + + public async Task GetTokenAsync(CancellationToken cancellationToken) + { + try + { + await _semaphore.WaitAsync(cancellationToken); + + var now = _systemClock.UtcNow; + if (_lastResponse != null && _validTo.ToUniversalTime() >= now) + return _lastResponse.AccessToken; + + if (_lastRequestTime != null) + { + var timeToWait = _lastRequestTime + TimeSpan.FromSeconds(1) - now; + if (timeToWait > TimeSpan.Zero) + { + await Task.Delay(timeToWait.Value, cancellationToken); + } + } + + _lastRequestTime = now.UtcDateTime; + _lastResponse = await RequestTokenAsync(cancellationToken); + var expiresInSeconds = + _lastResponse.ExpiresInSeconds + ?? throw new AuthenticationException("Identity provider did not return expires_in"); + + _validTo = _systemClock.UtcNow.Add(TimeSpan.FromSeconds(expiresInSeconds)).UtcDateTime; + return _lastResponse.AccessToken; + } + finally + { + _semaphore.Release(); + } + } + + private async Task RequestTokenAsync(CancellationToken cancellationToken) + { + var form = TokenRequestToForm(); + HttpResponseMessage response; + try + { + response = await _httpClient.PostAsync(_tokenUrl, form, cancellationToken); + } + catch (Exception ex) + { + throw new AuthenticationException("Failed to request token", ex); + } + + var stringResponse = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + ErrorResponse? errorResponse; + var statusCode = (int)response.StatusCode; + try + { + errorResponse = JsonSerializer.Deserialize(stringResponse); + } + catch (JsonException e) + { + throw new AuthenticationException( + $"Identity provider returned status code {statusCode}: {e.Message}" + ); + } + + if (errorResponse == null) + { + throw new AuthenticationException( + $"Identity provider returned status code {statusCode}: Empty body" + ); + } + + throw new AuthenticationException( + $"Identity provider returned status code {statusCode}: {errorResponse.ErrorDescription ?? errorResponse.Error}" + ); + } + + var tokenResponse = JsonSerializer.Deserialize(stringResponse); + if (tokenResponse == null) + throw new AuthenticationException("Access token is null"); + return tokenResponse; + } + + protected abstract FormUrlEncodedContent TokenRequestToForm(); + + public void Dispose() + { + _semaphore.Dispose(); + } +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/TokenRequest.cs b/dotnet/Vaas/src/Vaas/Authentication/TokenRequest.cs deleted file mode 100644 index e0d503c95..000000000 --- a/dotnet/Vaas/src/Vaas/Authentication/TokenRequest.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using CommunityToolkit.Diagnostics; - -namespace Vaas.Authentication; - -public enum GrantType -{ - ClientCredentials, - Password -} - -public class TokenRequest -{ - [Required] public GrantType GrantType { get; set; } - - [Required] public string ClientId { get; set; } = string.Empty; - public string? ClientSecret { get; set; } - - public string? UserName { get; set; } - public string? Password { get; set; } - - public static ValidationResult IsValid(TokenRequest? request, ValidationContext context) - { - Guard.IsNotNull(request); - var memberNames = new[] { context.MemberName ?? "" }; - if (request.GrantType == GrantType.ClientCredentials) - { - if (string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.ClientSecret)) - { - return new ValidationResult( - "The fields ClientId and ClientSecret are required for the GrantType ClientCredentials.", - memberNames); - } - - return ValidationResult.Success!; - } - - if (request.GrantType == GrantType.Password) - { - if (string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.UserName) || - string.IsNullOrWhiteSpace(request.Password)) - { - return new ValidationResult( - "The fields ClientId, UserName and Password are required for the GrantType Password.", - memberNames); - } - - return ValidationResult.Success!; - } - - throw new ArgumentOutOfRangeException(); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Authentication/TokenResponse.cs b/dotnet/Vaas/src/Vaas/Authentication/TokenResponse.cs index 7e3c5d822..0f8f36b14 100644 --- a/dotnet/Vaas/src/Vaas/Authentication/TokenResponse.cs +++ b/dotnet/Vaas/src/Vaas/Authentication/TokenResponse.cs @@ -7,10 +7,14 @@ public class TokenResponse { [JsonPropertyName("access_token")] public string AccessToken { get; init; } - - public TokenResponse(string accessToken) + + [JsonPropertyName("expires_in")] + public int? ExpiresInSeconds { get; init; } + + public TokenResponse(string accessToken, int? expiresInSeconds) { Guard.IsNotNullOrEmpty(accessToken); AccessToken = accessToken; + ExpiresInSeconds = expiresInSeconds; } -} \ No newline at end of file +} diff --git a/dotnet/Vaas/src/Vaas/ChecksumSha256.cs b/dotnet/Vaas/src/Vaas/ChecksumSha256.cs index 9fedb227f..ec3404ad0 100644 --- a/dotnet/Vaas/src/Vaas/ChecksumSha256.cs +++ b/dotnet/Vaas/src/Vaas/ChecksumSha256.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -7,12 +9,13 @@ namespace Vaas; [JsonConverter(typeof(ChecksumSha256Converter))] -public class ChecksumSha256 +public partial class ChecksumSha256 { - private const string EmptyFileSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + private const string EmptyFileSha256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - public string Sha256 { get; } - private static readonly Regex Pattern = new("^[a-fA-F0-9]{64}$", RegexOptions.Compiled); + private string Sha256 { get; } + private static readonly Regex Pattern = Sha256Regex(); public ChecksumSha256(string sha256) { @@ -43,27 +46,47 @@ public static bool TryParse(string value, out ChecksumSha256? result) } catch (ArgumentException) { - result = default; + result = null; return false; } } - public static implicit operator ChecksumSha256(string sha256) => new (sha256); - + public static string Sha256CheckSum(string filePath) + { + using var sha256 = SHA256.Create(); + using var fileStream = File.OpenRead(filePath); + return Convert.ToHexString(sha256.ComputeHash(fileStream)).ToLower(); + } + + public static implicit operator ChecksumSha256(string sha256) => new(sha256); + public static implicit operator string(ChecksumSha256 s) => s.Sha256; public override string ToString() => Sha256; + + [GeneratedRegex("^[a-fA-F0-9]{64}$", RegexOptions.Compiled)] + private static partial Regex Sha256Regex(); } public class ChecksumSha256Converter : JsonConverter { - public override ChecksumSha256? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override ChecksumSha256 Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { - return new ChecksumSha256(reader.GetString() ?? throw new JsonException("Expected SHA256 string")); + return new ChecksumSha256( + reader.GetString() ?? throw new JsonException("Expected SHA256 string") + ); } - public override void Write(Utf8JsonWriter writer, ChecksumSha256 value, JsonSerializerOptions options) + public override void Write( + Utf8JsonWriter writer, + ChecksumSha256 value, + JsonSerializerOptions options + ) { writer.WriteStringValue(value.ToString()); } -} \ No newline at end of file +} diff --git a/dotnet/Vaas/src/Vaas/Exceptions.cs b/dotnet/Vaas/src/Vaas/Exceptions.cs deleted file mode 100644 index a4e9dc667..000000000 --- a/dotnet/Vaas/src/Vaas/Exceptions.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; - -namespace Vaas; - -public class VaasInvalidStateException : Exception -{ - public VaasInvalidStateException() : base("Connect() was not called") - { - } -} - -public class VaasAuthenticationException : Exception -{ - public VaasAuthenticationException() : base("Authentication failed") - { - } -} - -public class VaasConnectionClosedException : Exception -{ - public VaasConnectionClosedException() : base("Connection closed") - { - } -} - -/// The request is malformed or cannot be completed. -/// -/// Recommended actions: -///
    -///
  • Don't repeat the request.
  • -///
  • Log.
  • -///
  • Analyze the error
  • -///
-///
-public class VaasClientException : Exception -{ - public VaasClientException(string? message) : base(message) - { - } - - public VaasClientException(string? message, Exception? innerException) : base(message, innerException) - { - } -} - -/// The server encountered an internal error. -/// -/// Recommended actions: -///
    -///
  • You may retry the request after a certain delay.
  • -///
  • If the problem persists contact G DATA.
  • -///
-///
-public class VaasServerException : Exception -{ - public VaasServerException(string? message) : base(message) - { - } - - public VaasServerException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/Vaas/src/Vaas/Exceptions/VaasAuthenticationException.cs b/dotnet/Vaas/src/Vaas/Exceptions/VaasAuthenticationException.cs new file mode 100644 index 000000000..c212f756f --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Exceptions/VaasAuthenticationException.cs @@ -0,0 +1,12 @@ +using System; + +namespace Vaas.Exceptions; + +public class VaasAuthenticationException : Exception +{ + public VaasAuthenticationException() + : base("Authentication failed") { } + + public VaasAuthenticationException(string message) + : base(message) { } +} diff --git a/dotnet/Vaas/src/Vaas/Exceptions/VaasClientException.cs b/dotnet/Vaas/src/Vaas/Exceptions/VaasClientException.cs new file mode 100644 index 000000000..83bbe62ed --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Exceptions/VaasClientException.cs @@ -0,0 +1,21 @@ +using System; + +namespace Vaas.Exceptions; + +/// The request is malformed or cannot be completed. +/// +/// Recommended actions: +///
    +///
  • Don't repeat the request.
  • +///
  • Log.
  • +///
  • Analyze the error
  • +///
+///
+public class VaasClientException : Exception +{ + public VaasClientException(string? message) + : base(message) { } + + public VaasClientException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/dotnet/Vaas/src/Vaas/Exceptions/VaasServerException.cs b/dotnet/Vaas/src/Vaas/Exceptions/VaasServerException.cs new file mode 100644 index 000000000..56259e650 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Exceptions/VaasServerException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Vaas.Exceptions; + +/// The server encountered an internal error. +/// +/// Recommended actions: +///
    +///
  • You may retry the request after a certain delay.
  • +///
  • If the problem persists contact G DATA.
  • +///
+///
+public class VaasServerException : Exception +{ + public VaasServerException(string? message) + : base(message) { } + + public VaasServerException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/dotnet/Vaas/src/Vaas/Messages/AuthenticationRequest.cs b/dotnet/Vaas/src/Vaas/Messages/AuthenticationRequest.cs deleted file mode 100644 index 4296c905e..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/AuthenticationRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class AuthenticationRequest -{ - [JsonPropertyName("kind")] public string Kind => "AuthRequest"; - - [JsonPropertyName("token")] public string Token { get; } - - [JsonPropertyName("session_id")] public string? SessionId { get; } - - public AuthenticationRequest(string token, string? sessionId = null) - { - Token = token; - SessionId = sessionId; - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/AuthenticationResponse.cs b/dotnet/Vaas/src/Vaas/Messages/AuthenticationResponse.cs deleted file mode 100644 index ca47f37e3..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/AuthenticationResponse.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class AuthenticationResponse -{ - [JsonPropertyName("kind")] - public string Kind = "AuthResponse"; - - [JsonPropertyName("success")] - public bool Success { get; init; } - - [JsonPropertyName("session_id")] - public string? SessionId { get; init; } - - [JsonPropertyName("text")] - public string? Text { get; init; } - - [MemberNotNullWhen(true, nameof(SessionId), nameof(Text))] - public bool IsValid => !string.IsNullOrWhiteSpace(SessionId) - && !string.IsNullOrWhiteSpace(Text); -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/FileAnalysisStarted.cs b/dotnet/Vaas/src/Vaas/Messages/FileAnalysisStarted.cs new file mode 100644 index 000000000..be435d4b2 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Messages/FileAnalysisStarted.cs @@ -0,0 +1,6 @@ +namespace Vaas.Messages; + +public class FileAnalysisStarted +{ + public required ChecksumSha256 Sha256 { get; init; } +} diff --git a/dotnet/Vaas/src/Vaas/Messages/FileReport.cs b/dotnet/Vaas/src/Vaas/Messages/FileReport.cs new file mode 100644 index 000000000..7233f0ef7 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Messages/FileReport.cs @@ -0,0 +1,10 @@ +namespace Vaas.Messages; + +public class FileReport +{ + public required ChecksumSha256 Sha256 { get; init; } + public required Verdict Verdict { get; init; } + public string? Detection { get; init; } + public string? FileType { get; init; } + public string? MimeType { get; init; } +} diff --git a/dotnet/Vaas/src/Vaas/Messages/Message.cs b/dotnet/Vaas/src/Vaas/Messages/Message.cs deleted file mode 100644 index 7d51220ab..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/Message.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class Message -{ - [JsonPropertyName("kind")] - public string? Kind { get; init; } - public bool IsValid => !string.IsNullOrWhiteSpace(Kind); -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/ProblemDetails.cs b/dotnet/Vaas/src/Vaas/Messages/ProblemDetails.cs new file mode 100644 index 000000000..e85ac20c5 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Messages/ProblemDetails.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Vaas.Messages; + +public class ProblemDetails +{ + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("detail")] + public string? Detail { get; init; } +} diff --git a/dotnet/Vaas/src/Vaas/Messages/UrlAnalysisRequest.cs b/dotnet/Vaas/src/Vaas/Messages/UrlAnalysisRequest.cs new file mode 100644 index 000000000..a40630f05 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Messages/UrlAnalysisRequest.cs @@ -0,0 +1,9 @@ +using System; + +namespace Vaas.Messages; + +public class UrlAnalysisRequest +{ + public required Uri Url { get; set; } + public bool? UseHashLookup { get; set; } = true; +} diff --git a/dotnet/Vaas/src/Vaas/Messages/UrlAnalysisStarted.cs b/dotnet/Vaas/src/Vaas/Messages/UrlAnalysisStarted.cs new file mode 100644 index 000000000..58cbd0cfe --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Messages/UrlAnalysisStarted.cs @@ -0,0 +1,6 @@ +namespace Vaas.Messages; + +public class UrlAnalysisStarted +{ + public required string Id { get; set; } +} diff --git a/dotnet/Vaas/src/Vaas/Messages/UrlReport.cs b/dotnet/Vaas/src/Vaas/Messages/UrlReport.cs new file mode 100644 index 000000000..ae55c1a01 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Messages/UrlReport.cs @@ -0,0 +1,13 @@ +using System; + +namespace Vaas.Messages; + +public class UrlReport +{ + public required ChecksumSha256 Sha256 { get; init; } + public required Verdict Verdict { get; init; } + public required Uri Url { get; set; } + public string? Detection { get; init; } + public string? FileType { get; init; } + public string? MimeType { get; init; } +} diff --git a/dotnet/Vaas/src/Vaas/Messages/VerdictRequest.cs b/dotnet/Vaas/src/Vaas/Messages/VerdictRequest.cs deleted file mode 100644 index e62f7145d..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/VerdictRequest.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class VerdictRequest -{ - [JsonPropertyName("kind")] - public string Kind => "VerdictRequest"; - - [JsonPropertyName("sha256")] - public string Sha256 { get; } - - [JsonPropertyName("guid")] - public string Guid { get; } - - [JsonPropertyName("session_id")] - public string SessionId { get; } - - [JsonPropertyName("verdict_request_attributes")] - public Dictionary? VerdictRequestAttributes { get; set; } - - [JsonPropertyName("use_cache")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseCache { get; init; } - - [JsonPropertyName("use_shed")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseShed { get; init; } - - public VerdictRequest(string sha256, string sessionId) - { - Sha256 = sha256; - SessionId = sessionId; - Guid = System.Guid.NewGuid().ToString(); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/VerdictRequestForStream.cs b/dotnet/Vaas/src/Vaas/Messages/VerdictRequestForStream.cs deleted file mode 100644 index e07af587d..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/VerdictRequestForStream.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class VerdictRequestForStream -{ - [JsonPropertyName("kind")] - public string Kind => "VerdictRequestForStream"; - - [JsonPropertyName("guid")] - public string Guid { get; } - - [JsonPropertyName("session_id")] - public string SessionId { get; } - - [JsonPropertyName("verdict_request_attributes")] - public Dictionary? VerdictRequestAttributes { get; set; } - - [JsonPropertyName("use_cache")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseCache { get; init; } - - [JsonPropertyName("use_hash_lookup")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseHashLookup { get; init; } - - public VerdictRequestForStream(string sessionId) - { - SessionId = sessionId; - Guid = System.Guid.NewGuid().ToString(); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/VerdictRequestForUrl.cs b/dotnet/Vaas/src/Vaas/Messages/VerdictRequestForUrl.cs deleted file mode 100644 index 2abb51da3..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/VerdictRequestForUrl.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class VerdictRequestForUrl -{ - [JsonPropertyName("kind")] - public string Kind => "VerdictRequestForUrl"; - - [JsonPropertyName("url")] - public string Url { get; } - - [JsonPropertyName("guid")] - public string Guid { get; } - - [JsonPropertyName("session_id")] - public string SessionId { get; } - - [JsonPropertyName("verdict_request_attributes")] - public Dictionary? VerdictRequestAttributes { get; set; } - - [JsonPropertyName("use_cache")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseCache { get; init; } - - [JsonPropertyName("use_shed")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseShed { get; init; } - - public VerdictRequestForUrl(Uri uri, string sessionId) - { - Url = uri.ToString(); - SessionId = sessionId; - Guid = System.Guid.NewGuid().ToString(); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs b/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs deleted file mode 100644 index 9ea289db1..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using CommunityToolkit.Diagnostics; - -namespace Vaas.Messages; - -public class VerdictResponse -{ - public VerdictResponse(string sha256, Verdict verdict) - { - Guard.IsNotNull(sha256); - Guard.IsNotNull(verdict); - Sha256 = sha256; - Verdict = verdict; - } - - [JsonPropertyName("kind")] - public string Kind { get; init; } = "VerdictResponse"; - - [JsonPropertyName("sha256")] - public string? Sha256 { get; init; } - - [JsonPropertyName("guid")] - public string? Guid { get; init; } - - [JsonPropertyName("verdict")] - [JsonConverter(typeof(JsonStringEnumConverter))] - public Verdict Verdict { get; init; } - - [JsonPropertyName("url")] - public string? Url { get; init; } - - [JsonPropertyName("upload_token")] - public string? UploadToken { get; init; } - - [JsonPropertyName("detection")] - public string? Detection { get; init; } - - [JsonPropertyName("file_type")] - public string? FileType { get; init; } - - [JsonPropertyName("mime_type")] - public string? MimeType { get; init; } - - [MemberNotNullWhen(true, nameof(Sha256), nameof(Guid))] - public bool IsValid => !string.IsNullOrWhiteSpace(Sha256) - && !string.IsNullOrWhiteSpace(Guid); -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/WebSocketErrorMessage.cs b/dotnet/Vaas/src/Vaas/Messages/WebSocketErrorMessage.cs deleted file mode 100644 index 7dd907604..000000000 --- a/dotnet/Vaas/src/Vaas/Messages/WebSocketErrorMessage.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class ProblemDetails -{ - [JsonPropertyName("type")] public string? Type { get; set; } - - [JsonPropertyName("detail")] public string? Detail { get; set; } -} - -public class WebSocketErrorMessage : Message -{ - [JsonPropertyName("type")] public string Type { get; } - - [JsonPropertyName("problem_details")] public ProblemDetails? ProblemDetails { get; init; } - - [JsonPropertyName("request_id")] public string? RequestId { get; set; } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Options/ForFileOptions.cs b/dotnet/Vaas/src/Vaas/Options/ForFileOptions.cs new file mode 100644 index 000000000..5bd2d8dff --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Options/ForFileOptions.cs @@ -0,0 +1,18 @@ +namespace Vaas.Options; + +public class ForFileOptions +{ + public bool UseCache { get; init; } = true; + public bool UseHashLookup { get; init; } = true; + public string? VaasRequestId { get; init; } + + public static ForFileOptions From(VaasOptions options) + { + return new ForFileOptions + { + UseCache = options.UseCache, + UseHashLookup = options.UseHashLookup, + VaasRequestId = null, + }; + } +} diff --git a/dotnet/Vaas/src/Vaas/Options/ForSha256Options.cs b/dotnet/Vaas/src/Vaas/Options/ForSha256Options.cs new file mode 100644 index 000000000..7d4dcd772 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Options/ForSha256Options.cs @@ -0,0 +1,19 @@ +namespace Vaas.Options; + +public class ForSha256Options +{ + public bool UseCache { get; init; } = true; + public bool UseHashLookup { get; init; } = true; + + public string? VaasRequestId { get; init; } + + public static ForSha256Options From(VaasOptions options) + { + return new ForSha256Options + { + UseCache = options.UseCache, + UseHashLookup = options.UseHashLookup, + VaasRequestId = null, + }; + } +} diff --git a/dotnet/Vaas/src/Vaas/Options/ForStreamOptions.cs b/dotnet/Vaas/src/Vaas/Options/ForStreamOptions.cs new file mode 100644 index 000000000..192b0301d --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Options/ForStreamOptions.cs @@ -0,0 +1,12 @@ +namespace Vaas.Options; + +public class ForStreamOptions +{ + public bool UseHashLookup { get; init; } = true; + public string? VaasRequestId { get; init; } + + public static ForStreamOptions From(VaasOptions options) + { + return new ForStreamOptions { UseHashLookup = options.UseHashLookup, VaasRequestId = null }; + } +} diff --git a/dotnet/Vaas/src/Vaas/Options/ForUrlOptions.cs b/dotnet/Vaas/src/Vaas/Options/ForUrlOptions.cs new file mode 100644 index 000000000..57647c25a --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Options/ForUrlOptions.cs @@ -0,0 +1,12 @@ +namespace Vaas.Options; + +public class ForUrlOptions +{ + public bool UseHashLookup { get; init; } = true; + public string? VaasRequestId { get; init; } + + public static ForUrlOptions From(VaasOptions options) + { + return new ForUrlOptions { UseHashLookup = options.UseHashLookup, VaasRequestId = null }; + } +} diff --git a/dotnet/Vaas/src/Vaas/Options/VaasOptions.cs b/dotnet/Vaas/src/Vaas/Options/VaasOptions.cs new file mode 100644 index 000000000..628e15a9f --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Options/VaasOptions.cs @@ -0,0 +1,13 @@ +using System; + +namespace Vaas.Options; + +public class VaasOptions +{ + public bool UseHashLookup { get; init; } = true; + public bool UseCache { get; init; } = true; + + public Uri VaasUrl { get; init; } = new("https://gateway.production.vaas.gdatasecurity.de"); + + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(300); +} diff --git a/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs b/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs index c8560cb18..05a4945e8 100644 --- a/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs +++ b/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs @@ -1,31 +1,81 @@ +using System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Vaas.Authentication; +using Vaas.Options; namespace Vaas; public static class ServiceCollectionExtensions { private const string SectionKey = "VerdictAsAService"; - - public static IServiceCollection AddVerdictAsAService(this IServiceCollection services, IConfiguration configuration) + + public static IServiceCollection AddVerdictAsAService( + this IServiceCollection services, + IConfiguration configuration + ) { var configurationSection = configuration.GetSection(SectionKey); - services - .AddOptions() - .Bind(configurationSection) - .ValidateDataAnnotations(); - - services - .AddSingleton(p => p.GetRequiredService>().Value) - .AddSingleton() - .AddSingleton(); - - services - .AddTransient() - .AddHttpClient() - .AddHttpMessageHandler(); + + var optionsSection = configurationSection.GetSection("Options"); + var vaasOptions = new VaasOptions + { + UseHashLookup = optionsSection.GetValue("UseHashLookup"), + UseCache = optionsSection.GetValue("UseCache"), + VaasUrl = + optionsSection.GetValue("VaasUrl") + ?? new Uri("https://gateway.production.vaas.gdatasecurity.de"), + Timeout = TimeSpan.FromSeconds(optionsSection.GetValue("Timeout")), + }; + + IAuthenticator authenticator; + if ( + configurationSection.GetSection("Credentials").GetValue("GrantType") + == GrantType.ClientCredentials.ToString() + ) + { + authenticator = new ClientCredentialsGrantAuthenticator( + configurationSection.GetSection("Credentials").GetValue("ClientId") + ?? throw new ArgumentException( + "ClientId is required in VerdictAsAService configuration" + ), + configurationSection.GetSection("Credentials").GetValue("ClientSecret") + ?? throw new ArgumentException( + "ClientSecret is required in VerdictAsAService configuration" + ), + configurationSection.GetSection("Credentials").GetValue("TokenUrl") + ); + } + else if ( + configurationSection.GetSection("Credentials").GetValue("GrantType") + == GrantType.Password.ToString() + ) + { + authenticator = new ResourceOwnerPasswordGrantAuthenticator( + configurationSection.GetSection("Credentials").GetValue("ClientId") + ?? throw new ArgumentException( + "ClientId is required in VerdictAsAService configuration" + ), + configurationSection.GetSection("Credentials").GetValue("Username") + ?? throw new ArgumentException( + "UserName is required in VerdictAsAService configuration" + ), + configurationSection.GetSection("Credentials").GetValue("Password") + ?? throw new ArgumentException( + "Password is required in VerdictAsAService configuration" + ), + configurationSection.GetSection("Credentials").GetValue("TokenUrl") + ); + } + else + { + throw new ArgumentException("GrantType must be either ClientCredentials or Password"); + } + + services.AddSingleton(authenticator); + services.AddSingleton(vaasOptions); + services.AddHttpClient(); + services.AddSingleton(); return services; } diff --git a/dotnet/Vaas/src/Vaas/Vaas.cs b/dotnet/Vaas/src/Vaas/Vaas.cs index d762bec4b..684e82122 100644 --- a/dotnet/Vaas/src/Vaas/Vaas.cs +++ b/dotnet/Vaas/src/Vaas/Vaas.cs @@ -1,421 +1,340 @@ using System; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Net.WebSockets; +using System.Net.Http.Json; using System.Reflection; -using System.Security.Cryptography; +using System.Security.Authentication; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using CommunityToolkit.Diagnostics; using Vaas.Authentication; +using Vaas.Exceptions; using Vaas.Messages; -using Websocket.Client; -using Websocket.Client.Exceptions; +using Vaas.Options; namespace Vaas; -public class ForSha256Options -{ - public bool UseCache { get; set; } = true; - - public static ForSha256Options Default { get; } = new(); -} - public interface IVaas { - Task Connect(CancellationToken cancellationToken); - - Task ForUrlAsync(Uri uri, CancellationToken cancellationToken, - Dictionary? verdictRequestAttributes = null); - + /// Authentication failed. /// The request is malformed or cannot be completed. /// The server encountered an internal error. /// The request failed due to timeout. - Task ForSha256Async(ChecksumSha256 sha256, CancellationToken cancellationToken, - ForSha256Options? options = null); + Task ForSha256Async( + ChecksumSha256 sha256, + CancellationToken cancellationToken, + ForSha256Options? options = null + ); - Task ForFileAsync(string path, CancellationToken cancellationToken, - Dictionary? verdictRequestAttributes = null); + /// Authentication failed. + /// The request is malformed or cannot be completed. + /// The server encountered an internal error. + /// The request failed due to timeout. + Task ForFileAsync( + string path, + CancellationToken cancellationToken, + ForFileOptions? options = null + ); + /// Authentication failed. + /// The request is malformed or cannot be completed. + /// The server encountered an internal error. + /// The request failed due to timeout. Task ForStreamAsync( Stream stream, CancellationToken cancellationToken, - Dictionary? verdictRequestAttributes = null + ForStreamOptions? options = null + ); + + /// Authentication failed. + /// The request is malformed or cannot be completed. + /// The server encountered an internal error. + /// The request failed due to timeout. + Task ForUrlAsync( + Uri uri, + CancellationToken cancellationToken, + ForUrlOptions? options = null ); } -public class Vaas : IDisposable, IVaas +public class Vaas : IVaas { - private const int AuthenticationTimeoutInMs = 1000; + private const string ProductName = "Cs"; - private WebsocketClient? _client; - private WebsocketClient AuthenticatedClient => GetAuthenticatedWebSocket(); + private static string ProductVersion => + Assembly.GetAssembly(typeof(Vaas))?.GetName().Version?.ToString() ?? "0.0.0"; private readonly HttpClient _httpClient; - - // Uploads use a custom token instead of the identity provider token - // TODO: Use the identity provider token for uploads - private readonly HttpClient _uploadHttpClient = new(); - - private string? SessionId { get; set; } - private bool AuthenticatedErrorOccured { get; set; } - - private readonly TaskCompletionSource _authenticatedSource = new(); - private Task Authenticated => _authenticatedSource.Task; - - private readonly ConcurrentDictionary> _verdictResponses = new(); - private readonly IAuthenticator _authenticator; private readonly VaasOptions _options; - public Vaas(HttpClient httpClient, IAuthenticator authenticator, VaasOptions options) + public Vaas( + IAuthenticator authenticator, + VaasOptions? options = null, + HttpClient? httpClient = null + ) { - Guard.IsNotNullOrWhiteSpace(options.Url.Host); - _httpClient = httpClient; _authenticator = authenticator; - _options = options; - _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, ProductVersion)); + _options = options ?? new VaasOptions(); + _httpClient = httpClient ?? new HttpClient(); + _httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue(ProductName, ProductVersion) + ); + _httpClient.Timeout = _options.Timeout; } - private const string ProductName = "VaaS_C#_SDK"; - - private static string ProductVersion => - Assembly.GetAssembly(typeof(Vaas))?.GetName().Version?.ToString() ?? "0.0.0"; - - public async Task Connect(CancellationToken cancellationToken) + public async Task ForSha256Async( + ChecksumSha256 sha256, + CancellationToken cancellationToken, + ForSha256Options? options = null + ) { - string token; - try - { - token = await _authenticator.GetTokenAsync(cancellationToken); - } - catch (HttpRequestException e) + options ??= ForSha256Options.From(_options); + var reportUri = new Uri( + _options.VaasUrl, + $"/files/{sha256}/report?useCache={JsonSerializer.Serialize(options.UseCache)}&useHashLookup={JsonSerializer.Serialize(options.UseHashLookup)}" + ); + + while (true) { - if (e.StatusCode != null) + var request = new HttpRequestMessage + { + RequestUri = reportUri, + Method = HttpMethod.Get, + }; + await AddRequestHeadersAsync(request, cancellationToken, options.VaasRequestId); + var response = await _httpClient.SendAsync(request, cancellationToken); + switch (response.StatusCode) { - EnsureSuccess(e.StatusCode.Value); + case HttpStatusCode.OK: + var fileReport = await response.Content.ReadFromJsonAsync( + cancellationToken + ); + return VaasVerdict.From( + fileReport + ?? throw new VaasServerException( + $"Unable to deserialize FileReport {fileReport}" + ) + ); + case HttpStatusCode.Accepted: + continue; + case HttpStatusCode.Unauthorized: + throw new VaasAuthenticationException(); + case HttpStatusCode.BadRequest: + default: + throw await ParseVaasError(response); } - - // How can this happen? - throw new VaasClientException("Unknown authentication error", e); - } - - _client = new WebsocketClient(_options.Url, WebsocketClientFactory); - _client.ReconnectTimeout = null; - _client.MessageReceived.Subscribe(HandleResponseMessage); - await _client.Start(); - if (!_client.IsStarted) - { - throw new WebsocketException("Could not start client"); } - - await Authenticate(token); } - private void HandleResponseMessage(ResponseMessage msg) - { - if (msg.MessageType != WebSocketMessageType.Text || msg.Text == null) return; - var message = JsonSerializer.Deserialize(msg.Text); - TaskCompletionSource? tcs; - switch (message?.Kind) - { - case "AuthResponse": - var authenticationResponse = JsonSerializer.Deserialize(msg.Text); - if (authenticationResponse is { Success: true }) - { - AuthenticatedErrorOccured = false; - SessionId = authenticationResponse.SessionId; - _authenticatedSource.SetResult(); - } - else - AuthenticatedErrorOccured = true; - - break; - - case "VerdictResponse": - var options = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }; - var verdictResponse = JsonSerializer.Deserialize(msg.Text, options); - if (verdictResponse is not { IsValid: true }) - { - return; - } - - if (!_verdictResponses.TryRemove(verdictResponse.Guid, out tcs)) - { - // Error: Server sent guid we are not waiting for, ignore it - return; - } - - tcs.SetResult(verdictResponse); - break; - - case "Error": - var webSocketErrorResponse = JsonSerializer.Deserialize(msg.Text); - var requestId = webSocketErrorResponse?.RequestId; - if (requestId == null || !_verdictResponses.TryRemove(requestId, out tcs)) - { - return; - } - - var problemDetails = webSocketErrorResponse?.ProblemDetails; - tcs.SetException(ProblemDetailsToException(problemDetails)); - break; - } - } - - private static Exception ProblemDetailsToException(ProblemDetails? problemDetails) => problemDetails?.Type switch - { - "VaasClientException" => new VaasClientException(problemDetails.Detail), - _ => new VaasServerException(problemDetails?.Detail) - }; - - private async Task Authenticate(string token) - { - var authenticationRequest = new AuthenticationRequest(token); - var jsonString = JsonSerializer.Serialize(authenticationRequest); - _client?.Send(jsonString); - - var delay = Task.Delay(AuthenticationTimeoutInMs); - if (await Task.WhenAny(Authenticated, delay) == delay) - { - throw new VaasAuthenticationException(); - } - } - - public async Task ForUrlAsync(Uri uri, CancellationToken cancellationToken, - Dictionary? verdictRequestAttributes = null) + private async Task AddRequestHeadersAsync( + HttpRequestMessage request, + CancellationToken cancellationToken, + string? requestId = null + ) { - var verdictResponse = await ForUrlRequestAsync( - new VerdictRequestForUrl(uri, SessionId ?? throw new VaasInvalidStateException()) - { - UseCache = _options.UseCache, - UseShed = _options.UseHashLookup, - VerdictRequestAttributes = verdictRequestAttributes - }); - return new VaasVerdict(verdictResponse); + var token = await _authenticator.GetTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (!string.IsNullOrWhiteSpace(requestId)) + request.Headers.Add("tracestate", $"vaasrequestid={requestId}"); } - public async Task ForStreamAsync( - Stream stream, + public async Task ForFileAsync( + string path, CancellationToken cancellationToken, - Dictionary? verdictRequestAttributes = null + ForFileOptions? options = null ) { - if (stream == null) - throw new VaasClientException("Stream was null."); + if (!File.Exists(path)) + throw new VaasClientException("File does not exist: " + path); - var verdictResponse = await ForStreamRequestAsync( - new VerdictRequestForStream(SessionId ?? throw new InvalidOperationException()) - { - UseCache = _options.UseCache, - UseHashLookup = _options.UseHashLookup, - VerdictRequestAttributes = verdictRequestAttributes - }); - if (!verdictResponse.IsValid) - throw new JsonException("VerdictResponse is not valid"); - if (verdictResponse.Verdict != Verdict.Unknown) - throw new VaasServerException("Server returned verdict without receiving content."); - - if ( - string.IsNullOrWhiteSpace(verdictResponse.Url) - || string.IsNullOrWhiteSpace(verdictResponse.UploadToken) - ) - { - throw new JsonException( - "VerdictResponse missing URL or UploadToken for stream upload." - ); - } - - var response = WaitForResponseAsync(verdictResponse.Guid); - await UploadStream(stream, verdictResponse.Url, verdictResponse.UploadToken, cancellationToken); + options ??= ForFileOptions.From(_options); - return new VaasVerdict(await response); - } - - private async Task UploadStream(Stream stream, string url, string token, CancellationToken cancellationToken) - { - using var requestContent = new StreamContent(stream); - using var requestMessage = new HttpRequestMessage(HttpMethod.Put, url); - requestMessage.Version = HttpVersion.Version11; - requestMessage.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; - requestMessage.Content = requestContent; - requestMessage.Headers.Authorization = new AuthenticationHeaderValue(token); - - var response = await _uploadHttpClient.SendAsync(requestMessage, cancellationToken); - if (!response.IsSuccessStatusCode) + if (options.UseCache || options.UseHashLookup) { - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - ProblemDetails? problemDetails; + var forSha256Options = new ForSha256Options + { + VaasRequestId = options.VaasRequestId, + UseHashLookup = options.UseHashLookup, + UseCache = options.UseCache, + }; + try { - problemDetails = JsonSerializer.Deserialize(responseBody); + var sha256 = ChecksumSha256.Sha256CheckSum(path); + var response = await ForSha256Async(sha256, cancellationToken, forSha256Options); + var verdictWithoutDetection = + response.Verdict is Verdict.Malicious or Verdict.Pup + && string.IsNullOrEmpty(response.Detection); + if ( + response.Verdict != Verdict.Unknown + && !verdictWithoutDetection + && !string.IsNullOrWhiteSpace(response.FileType) + && !string.IsNullOrEmpty(response.MimeType) + ) + { + return response; + } } - catch (JsonException) + catch (Exception) { - throw new VaasServerException("Server did not return proper ProblemDetails"); + // ignore } - - throw ProblemDetailsToException(problemDetails); } - } - public async Task ForSha256Async(ChecksumSha256 sha256, CancellationToken cancellationToken, - ForSha256Options? options = null) - { - var verdictResponse = await ForRequestAsync( - new VerdictRequest(sha256, SessionId ?? throw new InvalidOperationException()) - { - UseCache = _options.UseCache, - UseShed = _options.UseHashLookup, - VerdictRequestAttributes = null - }); - if (!verdictResponse.IsValid) - throw new JsonException("VerdictResponse is not valid"); - return new VaasVerdict(verdictResponse); - } - - private static void EnsureSuccess(HttpStatusCode status) - { - switch ((int)status) + await using var stream = File.OpenRead(path); + var forStreamOptions = new ForStreamOptions { - case 401 or 403: - throw new VaasAuthenticationException(); - case >= 400 and < 500: - throw new VaasClientException("Client-side error"); - case >= 500 and < 600: - throw new VaasServerException("Server-side error"); - } - } - - public async Task ForFileAsync(string path, CancellationToken cancellationToken, Dictionary? verdictRequestAttributes = null) - { - var sha256 = Sha256CheckSum(path); - var verdictResponse = await ForRequestAsync( - new VerdictRequest(sha256, SessionId ?? throw new InvalidOperationException()) - { - UseCache = _options.UseCache, - UseShed = _options.UseHashLookup, - VerdictRequestAttributes = verdictRequestAttributes - }); - if (!verdictResponse.IsValid) - throw new JsonException("VerdictResponse is not valid"); - if (verdictResponse.Verdict != Verdict.Unknown) - return new VaasVerdict(verdictResponse); - if (string.IsNullOrWhiteSpace(verdictResponse.Url) || - string.IsNullOrWhiteSpace(verdictResponse.UploadToken)) - { - throw new JsonException("VerdictResponse is not valid"); - } - - var response = WaitForResponseAsync(verdictResponse.Guid); - await UploadFile(path, verdictResponse.Url, verdictResponse.UploadToken, cancellationToken); - - return new VaasVerdict(await response); + VaasRequestId = options.VaasRequestId, + UseHashLookup = options.UseHashLookup, + }; + return await ForStreamAsync(stream, cancellationToken, forStreamOptions); } - private async Task UploadFile(string path, string url, string token, CancellationToken cancellationToken) + public async Task ForStreamAsync( + Stream stream, + CancellationToken cancellationToken, + ForStreamOptions? options = null + ) { - await using var fileStream = File.OpenRead(path); - await UploadStream(fileStream, url, token, cancellationToken); - } + options ??= ForStreamOptions.From(_options); - public async Task> ForSha256ListAsync(IEnumerable sha256List, - CancellationToken cancellationToken) - { - return (await Task.WhenAll(sha256List.Select(async sha256 => - await ForSha256Async(new ChecksumSha256(sha256), cancellationToken)))).ToList(); - } + var url = new Uri( + _options.VaasUrl, + $"/files?useHashLookup={JsonSerializer.Serialize(options.UseHashLookup)}&useCache={JsonSerializer.Serialize(true)}" + ); - public async Task> ForFileListAsync(IEnumerable fileList, - CancellationToken cancellationToken) - { - return (await Task.WhenAll(fileList.Select(async filePath => await ForFileAsync(filePath, cancellationToken)))) - .ToList(); - } - - private async Task ForRequestAsync(VerdictRequest verdictRequest) - { - var jsonString = JsonSerializer.Serialize(verdictRequest); - AuthenticatedClient.Send(jsonString); + var request = new HttpRequestMessage + { + RequestUri = url, + Method = HttpMethod.Post, + Content = new StreamContent(stream), + }; + await AddRequestHeadersAsync(request, cancellationToken, options.VaasRequestId); - return await WaitForResponseAsync(verdictRequest.Guid); - } + var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + await ParseVaasError(response); - private async Task ForStreamRequestAsync(VerdictRequestForStream verdictRequest) - { - var jsonString = JsonSerializer.Serialize(verdictRequest); - AuthenticatedClient.Send(jsonString); + var fileAnalysisStarted = await response.Content.ReadFromJsonAsync( + cancellationToken + ); - return await WaitForResponseAsync(verdictRequest.Guid); - } + var forSha256Options = new ForSha256Options + { + VaasRequestId = options.VaasRequestId, + UseHashLookup = options.UseHashLookup, + }; - private async Task ForUrlRequestAsync(VerdictRequestForUrl verdictRequestForUrl) - { - var jsonString = JsonSerializer.Serialize(verdictRequestForUrl); - AuthenticatedClient.Send(jsonString); + if (fileAnalysisStarted?.Sha256 != null) + return await ForSha256Async( + fileAnalysisStarted.Sha256, + cancellationToken, + forSha256Options + ); - return await WaitForResponseAsync(verdictRequestForUrl.Guid); + throw new VaasServerException( + "Unexpected response from Vaas server, expected Sha256 in response: " + + response.StatusCode + ); } - private Task WaitForResponseAsync(string guid) + public async Task ForUrlAsync( + Uri uri, + CancellationToken cancellationToken, + ForUrlOptions? options = null + ) { - var tcs = _verdictResponses.GetOrAdd(guid, _ => new TaskCompletionSource()); - return tcs.Task; - } + options ??= ForUrlOptions.From(_options); + var urlAnalysisUri = new Uri(_options.VaasUrl, "/urls"); - public static string Sha256CheckSum(string filePath) - { - using var sha256 = SHA256.Create(); - using var fileStream = File.OpenRead(filePath); - return Convert.ToHexString(sha256.ComputeHash(fileStream)).ToLower(); - } + var urlAnalysisRequest = new HttpRequestMessage + { + RequestUri = urlAnalysisUri, + Method = HttpMethod.Post, + Content = JsonContent.Create( + new UrlAnalysisRequest { Url = uri, UseHashLookup = options.UseHashLookup } + ), + }; - private static ClientWebSocket WebsocketClientFactory() - { - var clientWebSocket = new ClientWebSocket + await AddRequestHeadersAsync(urlAnalysisRequest, cancellationToken, options.VaasRequestId); + var urlAnalysisResponse = await _httpClient.SendAsync( + urlAnalysisRequest, + cancellationToken + ); + if (!urlAnalysisResponse.IsSuccessStatusCode) + await ParseVaasError(urlAnalysisResponse); + + var id = ( + await urlAnalysisResponse.Content.ReadFromJsonAsync( + cancellationToken + ) + )?.Id; + + while (true) { - Options = + var reportUri = new Uri(_options.VaasUrl, $"/urls/{id}/report"); + var reportRequest = new HttpRequestMessage { - KeepAliveInterval = TimeSpan.FromSeconds(20) - } - }; - return clientWebSocket; - } + RequestUri = reportUri, + Method = HttpMethod.Get, + }; - protected virtual void Dispose(bool disposing) - { - if (!disposing) return; + await AddRequestHeadersAsync(reportRequest, cancellationToken, options.VaasRequestId); + var reportResponse = await _httpClient.SendAsync(reportRequest, cancellationToken); - _client?.Dispose(); - _httpClient.Dispose(); + switch (reportResponse.StatusCode) + { + case HttpStatusCode.OK: + var urlReport = await reportResponse.Content.ReadFromJsonAsync( + cancellationToken + ); + return VaasVerdict.From( + urlReport + ?? throw new VaasServerException( + $"Unable to deserialize UrlReport {urlReport}" + ) + ); + case HttpStatusCode.Accepted: + continue; + case HttpStatusCode.Unauthorized: + throw new VaasAuthenticationException(); + case HttpStatusCode.BadRequest: + default: + throw await ParseVaasError(reportResponse); + } + } } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + private static Exception ProblemDetailsToException(ProblemDetails? problemDetails) => + problemDetails?.Type switch + { + "VaasClientException" => new VaasClientException(problemDetails.Detail), + _ => new VaasServerException(problemDetails?.Detail), + }; - private WebsocketClient GetAuthenticatedWebSocket() + private static async Task ParseVaasError(HttpResponseMessage response) { - if (_client == null) - throw new VaasInvalidStateException(); - if (!_client.IsRunning) - throw new VaasConnectionClosedException(); - if (SessionId == null) + var responseBody = await response.Content.ReadAsStringAsync(); + try { - if (AuthenticatedErrorOccured) - throw new VaasAuthenticationException(); - throw new VaasInvalidStateException(); + var problemDetails = JsonSerializer.Deserialize(responseBody); + throw ProblemDetailsToException(problemDetails); + } + catch (JsonException) + { + throw (int)response.StatusCode switch + { + 401 => new VaasAuthenticationException( + "server did not accept token from identity provider. Check if you are using the correct identity provider" + ), + >= 400 and <= 500 => new VaasClientException( + "HTTP Error: " + (int)response.StatusCode + ), + _ => new VaasServerException("HTTP Error: " + (int)response.StatusCode), + }; } - - return _client; } -} \ No newline at end of file +} diff --git a/dotnet/Vaas/src/Vaas/Vaas.csproj b/dotnet/Vaas/src/Vaas/Vaas.csproj index 732e0a0de..a4c4b3d94 100644 --- a/dotnet/Vaas/src/Vaas/Vaas.csproj +++ b/dotnet/Vaas/src/Vaas/Vaas.csproj @@ -29,8 +29,6 @@ - - diff --git a/dotnet/Vaas/src/Vaas/VaasFactory.cs b/dotnet/Vaas/src/Vaas/VaasFactory.cs index 0a55942f6..834bab281 100644 --- a/dotnet/Vaas/src/Vaas/VaasFactory.cs +++ b/dotnet/Vaas/src/Vaas/VaasFactory.cs @@ -1,21 +1,21 @@ using System.Net.Http; using Vaas.Authentication; +using Vaas.Options; namespace Vaas; public static class VaasFactory { - private static readonly HttpClient HttpClient = new (); - - public static IVaas Create(VaasOptions vaasOptions) + public static IVaas Create( + IAuthenticator authenticator, + VaasOptions? vaasOptions = null, + HttpClient? httpClient = null + ) { - var systemClock = new SystemClock(); - var authenticator = new Authenticator(HttpClient, systemClock, vaasOptions); - - var bearerTokenHandler = new BearerTokenHandler(authenticator); - bearerTokenHandler.InnerHandler = new HttpClientHandler(); - var httpClient = new HttpClient(bearerTokenHandler); - - return new Vaas(httpClient, authenticator, vaasOptions); + return new Vaas( + authenticator, + vaasOptions ?? new VaasOptions(), + httpClient ?? new HttpClient() + ); } -} \ No newline at end of file +} diff --git a/dotnet/Vaas/src/Vaas/VaasOptions.cs b/dotnet/Vaas/src/Vaas/VaasOptions.cs deleted file mode 100644 index 008ce768c..000000000 --- a/dotnet/Vaas/src/Vaas/VaasOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using Vaas.Authentication; - -namespace Vaas; - -public class VaasOptions -{ - public Uri Url { get; set; } = new("https://upload.production.vaas.gdatasecurity.de"); - public bool? UseHashLookup { get; init; } = null; - public bool? UseCache { get; init; } = null; - - public Uri TokenUrl { get; set; } = - new Uri("https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token"); - - [Required] - [CustomValidation(typeof(TokenRequest), nameof(TokenRequest.IsValid))] - public TokenRequest Credentials { get; set; } = null!; -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/VaasVerdict.cs b/dotnet/Vaas/src/Vaas/VaasVerdict.cs index deb500e85..217dad5a9 100644 --- a/dotnet/Vaas/src/Vaas/VaasVerdict.cs +++ b/dotnet/Vaas/src/Vaas/VaasVerdict.cs @@ -1,12 +1,39 @@ -using System.Collections.Generic; +using System.Text.Json; +using Vaas.Messages; -namespace Vaas.Messages; +namespace Vaas; -public class VaasVerdict(VerdictResponse verdictResponse) +public class VaasVerdict { - public string Sha256 { get; init; } = verdictResponse.Sha256 ?? ""; - public Verdict Verdict { get; init; } = verdictResponse.Verdict; - public string? Detection { get; init; } = verdictResponse.Detection; - public string? MimeType { get; init; } = verdictResponse.FileType; - public string? FileType { get; init; } = verdictResponse.MimeType; + public required string Sha256 { get; init; } + public Verdict Verdict { get; init; } + public string? Detection { get; init; } + public string? MimeType { get; init; } + public string? FileType { get; init; } + + public static VaasVerdict From(FileReport fileReport) + { + return new VaasVerdict + { + Sha256 = fileReport.Sha256, + Verdict = fileReport.Verdict, + Detection = fileReport.Detection, + MimeType = fileReport.MimeType, + FileType = fileReport.FileType, + }; + } + + public static VaasVerdict From(UrlReport urlReport) + { + return new VaasVerdict + { + Sha256 = urlReport.Sha256, + Verdict = urlReport.Verdict, + Detection = urlReport.Detection, + MimeType = urlReport.MimeType, + FileType = urlReport.FileType, + }; + } + + public override string ToString() => JsonSerializer.Serialize(this); } diff --git a/dotnet/Vaas/src/Vaas/Verdict.cs b/dotnet/Vaas/src/Vaas/Verdict.cs index 9def1f7d0..7cb6fc236 100644 --- a/dotnet/Vaas/src/Vaas/Verdict.cs +++ b/dotnet/Vaas/src/Vaas/Verdict.cs @@ -1,6 +1,12 @@ +using System.Text.Json.Serialization; + namespace Vaas; +[JsonConverter(typeof(JsonStringEnumConverter))] public enum Verdict { - Clean, Unknown, Malicious, Pup -} \ No newline at end of file + Clean, + Unknown, + Malicious, + Pup, +} diff --git a/dotnet/Vaas/test/TestFiles/Program.cs b/dotnet/Vaas/test/TestFiles/Program.cs index 700837b0d..9596548ff 100644 --- a/dotnet/Vaas/test/TestFiles/Program.cs +++ b/dotnet/Vaas/test/TestFiles/Program.cs @@ -1,5 +1,6 @@ using Vaas; using Vaas.Authentication; +using Vaas.Options; if (args.Length == 0) { @@ -7,7 +8,7 @@ Environment.Exit(1); } -var vaas = await AuthenticateWithCredentials(); +var vaas = AuthenticateWithCredentials(); foreach (var path in args) { @@ -16,31 +17,31 @@ Console.WriteLine($"Tested {path}: Verdict {verdict}"); } -static async Task AuthenticateWithCredentials() +return; + +static IVaas AuthenticateWithCredentials() { DotNetEnv.Env.NoClobber().TraversePath().Load(); var url = DotNetEnv.Env.GetString( "VAAS_URL", - "wss://gateway.production.vaas.gdatasecurity.de"); - var tokenEndpoint = new Uri(DotNetEnv.Env.GetString( - "TOKEN_URL", - "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token")); + "https://gateway.production.vaas.gdatasecurity.de" + ); + var tokenEndpoint = new Uri( + DotNetEnv.Env.GetString( + "TOKEN_URL", + "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token" + ) + ); var clientId = DotNetEnv.Env.GetString("CLIENT_ID"); var clientSecret = DotNetEnv.Env.GetString("CLIENT_SECRET"); - - var vaas = VaasFactory.Create(new VaasOptions() - { - Url = new Uri(url), - TokenUrl = tokenEndpoint, - Credentials = new TokenRequest - { - GrantType = GrantType.ClientCredentials, - ClientId = clientId, - ClientSecret = clientSecret, - } - }); - - await vaas.Connect(CancellationToken.None); - Console.WriteLine($"Connected to Vaas {url}", url); + + var authenticator = new ClientCredentialsGrantAuthenticator( + clientId, + clientSecret, + tokenEndpoint + ); + + var vaas = VaasFactory.Create(authenticator, new VaasOptions { VaasUrl = new Uri(url) }); + return vaas; -} \ No newline at end of file +} diff --git a/dotnet/Vaas/test/TestFiles/create_big_sample.sh b/dotnet/Vaas/test/TestFiles/create_big_sample.sh index 570fb1492..4590a5bb5 100644 --- a/dotnet/Vaas/test/TestFiles/create_big_sample.sh +++ b/dotnet/Vaas/test/TestFiles/create_big_sample.sh @@ -13,7 +13,7 @@ then exit 1 fi -dd if=/dev/urandom of=big/big.dat bs=1M count=$1 +dd if=/dev/urandom of=big/big.dat bs=1M count="$1" curl https://secure.eicar.org/eicar.com.txt -o big/eicar.com.txt zip -0 -r big big/ diff --git a/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticationEnvironment.cs b/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticationEnvironment.cs index 3e05ab98a..3fcd43d7d 100644 --- a/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticationEnvironment.cs +++ b/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticationEnvironment.cs @@ -4,13 +4,18 @@ namespace Vaas.Test.Authentication; public static class AuthenticationEnvironment { - public static Uri TokenUrl => new Uri(DotNetEnv.Env.GetString( - "TOKEN_URL", - "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token")); + public static Uri TokenUrl => + new( + DotNetEnv.Env.GetString( + "TOKEN_URL", + "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token" + ) + ); public static string ClientId => DotNetEnv.Env.GetString("CLIENT_ID"); public static string ClientSecret => DotNetEnv.Env.GetString("CLIENT_SECRET"); - public static string ClientIdForResourceOwnerPasswordGrant => DotNetEnv.Env.GetString("VAAS_CLIENT_ID"); + public static string ClientIdForResourceOwnerPasswordGrant => + DotNetEnv.Env.GetString("VAAS_CLIENT_ID"); public static string UserName => DotNetEnv.Env.GetString("VAAS_USER_NAME"); public static string Password => DotNetEnv.Env.GetString("VAAS_PASSWORD"); } diff --git a/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticatorTest.cs b/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticatorTest.cs index 5ce49d66f..f616ffb46 100644 --- a/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticatorTest.cs +++ b/dotnet/Vaas/test/Vaas.Test/Authentication/AuthenticatorTest.cs @@ -1,8 +1,13 @@ using System; +using System.Diagnostics; +using System.Net; using System.Net.Http; +using System.Security.Authentication; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Moq; +using Moq.Contrib.HttpClient; using Vaas.Authentication; using Xunit; @@ -12,8 +17,10 @@ public class CountingDelegatingHandler : DelegatingHandler { public int Requests { get; private set; } - protected override async Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) { Requests++; return await base.SendAsync(request, cancellationToken); @@ -24,15 +31,59 @@ public class AuthenticatorTest { private readonly CountingDelegatingHandler _handler = new(); private readonly Mock _systemClock = new(); - private readonly Authenticator _authenticator; + private readonly HttpClient _httpClient; + private readonly ClientCredentialsGrantAuthenticator _authenticator; public AuthenticatorTest() { DotNetEnv.Env.TraversePath().Load(); _handler.InnerHandler = new HttpClientHandler(); - var httpClient = new HttpClient(_handler); + _httpClient = new HttpClient(_handler); _systemClock.Setup(x => x.UtcNow).Returns(() => DateTimeOffset.UtcNow); - _authenticator = new Authenticator(httpClient, _systemClock.Object, GetVaasOptions()); + _authenticator = GetClientCredentialsGrantAuthenticator(); + } + + private ClientCredentialsGrantAuthenticator GetClientCredentialsGrantAuthenticator() + { + return new ClientCredentialsGrantAuthenticator( + AuthenticationEnvironment.ClientId, + AuthenticationEnvironment.ClientSecret, + AuthenticationEnvironment.TokenUrl, + _httpClient, + _systemClock.Object + ); + } + + private ResourceOwnerPasswordGrantAuthenticator GetResourceOwnerPasswordGrantAuthenticator() + { + return new ResourceOwnerPasswordGrantAuthenticator( + AuthenticationEnvironment.ClientIdForResourceOwnerPasswordGrant, + AuthenticationEnvironment.UserName, + AuthenticationEnvironment.Password, + AuthenticationEnvironment.TokenUrl, + _httpClient, + _systemClock.Object + ); + } + + [Fact] + public async Task GetTokenAsync_WithClientCredentials_ReturnsToken() + { + var authenticator = GetClientCredentialsGrantAuthenticator(); + + _ = await authenticator.GetTokenAsync(CancellationToken.None); + + Assert.Equal(1, _handler.Requests); + } + + [Fact] + public async Task GetTokenAsync_WithPassword_ReturnsToken() + { + var authenticator = GetResourceOwnerPasswordGrantAuthenticator(); + + _ = await authenticator.GetTokenAsync(CancellationToken.None); + + Assert.Equal(1, _handler.Requests); } [Fact] @@ -40,26 +91,139 @@ public async Task GetTokenAsync_IfTokenNotExpired_ReturnsLastToken() { _ = await _authenticator.GetTokenAsync(CancellationToken.None); _ = await _authenticator.GetTokenAsync(CancellationToken.None); + Assert.Equal(1, _handler.Requests); } - private VaasOptions GetVaasOptions() => new() + [Fact] + public async Task GetTokenAsync_IfTokenExpired_GetsNewToken() { - TokenUrl = AuthenticationEnvironment.TokenUrl, - Credentials = new() - { - GrantType = GrantType.ClientCredentials, - ClientId = AuthenticationEnvironment.ClientId, - ClientSecret = AuthenticationEnvironment.ClientSecret - } - }; + _ = await _authenticator.GetTokenAsync(CancellationToken.None); + _systemClock + .Setup(x => x.UtcNow) + .Returns(() => DateTimeOffset.UtcNow + TimeSpan.FromHours(1)); + + _ = await _authenticator.GetTokenAsync(CancellationToken.None); + + Assert.Equal(2, _handler.Requests); + } [Fact] - public async Task GetTokenAsync_IfTokenExpired_RefreshesToken() + public async Task GetTokenAsync_IfClockSkew_ReusesToken() { + _systemClock + .Setup(x => x.UtcNow) + .Returns(() => DateTimeOffset.UtcNow + TimeSpan.FromHours(1)); _ = await _authenticator.GetTokenAsync(CancellationToken.None); - _systemClock.Setup(x => x.UtcNow).Returns(() => DateTimeOffset.UtcNow + TimeSpan.FromHours(1)); _ = await _authenticator.GetTokenAsync(CancellationToken.None); - Assert.Equal(2, _handler.Requests); + Assert.Equal(1, _handler.Requests); } -} \ No newline at end of file + + [Fact] + public async Task GetTokenAsync_IfNoExpiresIn_ThrowsAuthenticationException() + { + var handlerMock = UseHttpMessageHandlerMock(); + handlerMock + .SetupRequest(HttpMethod.Post, AuthenticationEnvironment.TokenUrl) + .ReturnsResponse("""{"access_token": "My great token"}"""); + + var e = await Assert.ThrowsAsync( + () => _authenticator.GetTokenAsync(CancellationToken.None) + ); + + e.Message.Should().Be("Identity provider did not return expires_in"); + } + + [Fact] + public async Task GetTokenAsync_IfUnauthorized_ThrowsAuthenticationException() + { + var handlerMock = UseHttpMessageHandlerMock(); + MockUnauthorizedClient(handlerMock); + + var e = await Assert.ThrowsAsync( + () => _authenticator.GetTokenAsync(CancellationToken.None) + ); + + e.Message.Should() + .Be( + "Identity provider returned status code 401: Invalid client or Invalid client credentials" + ); + } + + private static void MockUnauthorizedClient(Mock handlerMock) + { + handlerMock + .SetupRequest(HttpMethod.Post, AuthenticationEnvironment.TokenUrl) + .ReturnsResponse( + """{"error":"unauthorized_client","error_description":"Invalid client or Invalid client credentials"}""", + configure: response => + { + response.StatusCode = HttpStatusCode.Unauthorized; + } + ); + } + + [Fact] + public async Task GetTokenAsync_IfHttpError_ThrowsAuthenticationException() + { + var handlerMock = UseHttpMessageHandlerMock(); + handlerMock + .SetupRequest(HttpMethod.Post, AuthenticationEnvironment.TokenUrl) + .ReturnsResponse(HttpStatusCode.InternalServerError); + + var e = await Assert.ThrowsAsync( + () => _authenticator.GetTokenAsync(CancellationToken.None) + ); + + e.Message.Should() + .Be( + "Identity provider returned status code 500: The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. Path: $ | LineNumber: 0 | BytePositionInLine: 0." + ); + } + + [Fact] + public async Task GetTokenAsync_IfHttpRequestException_ThrowsAuthenticationException() + { + var handlerMock = UseHttpMessageHandlerMock(); + handlerMock + .SetupRequest(HttpMethod.Post, AuthenticationEnvironment.TokenUrl) + .Throws( + new HttpRequestException( + "Name or service not known (dsdkfsdufsdufoweuiruierlknclxoijfiowejf.de:80)" + ) + ); + + var e = await Assert.ThrowsAsync( + () => _authenticator.GetTokenAsync(CancellationToken.None) + ); + e.Message.Should().Be("Failed to request token"); + e.InnerException.Should().BeOfType(); + e.InnerException!.Message.Should() + .Be("Name or service not known (dsdkfsdufsdufoweuiruierlknclxoijfiowejf.de:80)"); + } + + private Mock UseHttpMessageHandlerMock() + { + var handlerMock = new Mock(); + _handler.InnerHandler = handlerMock.Object; + return handlerMock; + } + + [Fact] + public async Task GetTokenAsync_IfLastRequestFailed_Waits1sBeforeNextRequest() + { + var handlerMock = UseHttpMessageHandlerMock(); + MockUnauthorizedClient(handlerMock); + var staticNow = DateTimeOffset.UtcNow; + _systemClock.Setup(x => x.UtcNow).Returns(() => staticNow); + + await Assert.ThrowsAsync( + () => _authenticator.GetTokenAsync(CancellationToken.None) + ); + var sw = Stopwatch.StartNew(); + await Assert.ThrowsAsync( + () => _authenticator.GetTokenAsync(CancellationToken.None) + ); + sw.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromSeconds(1)); + } +} diff --git a/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs b/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs index 76e80b819..0e16935d4 100644 --- a/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs +++ b/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs @@ -1,10 +1,9 @@ using System; using System.Text.Json; using Vaas.Authentication; -using Vaas.Messages; using Xunit; -namespace Vaas.Test; +namespace Vaas.Test.Authentication; public class TokenResponseTest { @@ -13,4 +12,4 @@ public void Deserialize_IfFieldIsMissing_ThrowsArgumentNullException() { Assert.Throws(() => JsonSerializer.Deserialize("{}")); } -} \ No newline at end of file +} diff --git a/dotnet/Vaas/test/Vaas.Test/IntegrationTests.cs b/dotnet/Vaas/test/Vaas.Test/IntegrationTests.cs deleted file mode 100644 index a9dcc1035..000000000 --- a/dotnet/Vaas/test/Vaas.Test/IntegrationTests.cs +++ /dev/null @@ -1,338 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Memory; -using Microsoft.Extensions.DependencyInjection; -using Vaas.Test.Authentication; -using Xunit; -using Xunit.Abstractions; - -namespace Vaas.Test; - -public class IntegrationTests -{ - private static Uri VaasUrl => new Uri(DotNetEnv.Env.GetString( - "VAAS_URL", - "wss://gateway.production.vaas.gdatasecurity.de")); - - private readonly ITestOutputHelper _output; - private readonly HttpClient _httpClient = new(); - - public IntegrationTests(ITestOutputHelper output) - { - _output = output; - DotNetEnv.Env.TraversePath().Load(); - } - - [Fact] - public async Task ConnectWithWrongCredentialsThrowsVaasAuthenticationException() - { - var services = GetServices(new Dictionary() - { - { "VerdictAsAService:Url", VaasUrl.ToString() }, - { "VerdictAsAService:TokenUrl", AuthenticationEnvironment.TokenUrl.ToString() }, - { "VerdictAsAService:Credentials:GrantType", "ClientCredentials" }, - { "VerdictAsAService:Credentials:ClientId", "foobar" }, - { "VerdictAsAService:Credentials:ClientSecret", "foobar2" }, - }); - var provider = services.BuildServiceProvider(); - - var vaas = provider.GetRequiredService(); - await Assert.ThrowsAsync(async () => - await vaas.Connect(CancellationToken.None)); - } - - [Fact] - public async Task FromSha256SingleMaliciousHash() - { - var vaas = await AuthenticateWithCredentials(); - var verdict = await vaas.ForSha256Async( - new ChecksumSha256("ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2"), - CancellationToken.None); - Assert.Equal(Verdict.Malicious, verdict.Verdict); - Assert.Equal("ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2", verdict.Sha256); - } - - [Fact] - public async Task FromSha256SingleCleanHash() - { - var vaas = await AuthenticateWithCredentials(); - var verdict = await vaas.ForSha256Async( - new ChecksumSha256("cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e"), - CancellationToken.None); - Assert.Equal(Verdict.Clean, verdict.Verdict); - Assert.Equal("cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e", verdict.Sha256, true); - } - - [Fact(Skip = "Remove Skip to test keepalive")] - public async Task FromSha256_WorksAfter40s() - { - var vaas = await AuthenticateWithCredentials(); - const string guid = "3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C"; - var verdict = await vaas.ForSha256Async(new ChecksumSha256(guid), CancellationToken.None); - Assert.Equal(Verdict.Clean, verdict.Verdict); - Assert.Equal("3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C", verdict.Sha256, true); - await Task.Delay(40000); - verdict = await vaas.ForSha256Async(new ChecksumSha256(guid), CancellationToken.None); - Assert.Equal(Verdict.Clean, verdict.Verdict); - Assert.Equal("3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C", verdict.Sha256, true); - } - - [Fact] - public async Task FromSha256SingleUnknownHash() - { - var vaas = await AuthenticateWithCredentials(); - var verdict = await vaas.ForSha256Async( - new ChecksumSha256("110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9"), - CancellationToken.None); - Assert.Equal(Verdict.Unknown, verdict.Verdict); - Assert.Equal("110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9", verdict.Sha256); - } - - [Fact] - public async Task From256ListMultipleHashes() - { - var myList = new List - { - "ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2", - "cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e", - "1f72c1111111111111f912e40b7323a0192a300b376186c10f6803dc5efe28df" - }; - var vaas = await AuthenticateWithCredentials(); - var verdictList = await vaas.ForSha256ListAsync(myList, CancellationToken.None); - Assert.Equal(Verdict.Malicious, verdictList[0].Verdict); - Assert.Equal("ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2", verdictList[0].Sha256, true); - Assert.Equal(Verdict.Clean, verdictList[1].Verdict); - Assert.Equal("cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e", verdictList[1].Sha256, true); - Assert.Equal(Verdict.Unknown, verdictList[2].Verdict); - Assert.Equal("1f72c1111111111111f912e40b7323a0192a300b376186c10f6803dc5efe28df", verdictList[2].Sha256, true); - } - - - [Fact] - public async Task GenerateFileUnknownHash() - { - var rnd = new Random(); - var b = new byte[50]; - rnd.NextBytes(b); - await File.WriteAllBytesAsync("test.txt", b); - var vaas = await AuthenticateWithCredentials(); - var result = await vaas.ForFileAsync("test.txt", CancellationToken.None); - Assert.Equal(Verdict.Clean, result.Verdict); - Assert.Equal(Vaas.Sha256CheckSum("test.txt"), result.Sha256); - } - - [Fact] - public async Task GenerateFileList() - { - var rnd = new Random(); - var b = new byte[50]; - rnd.NextBytes(b); - await File.WriteAllBytesAsync("test1.txt", b); - rnd.NextBytes(b); - await File.WriteAllBytesAsync("test2.txt", b); - rnd.NextBytes(b); - await File.WriteAllBytesAsync("test3.txt", b); - var vaas = await AuthenticateWithCredentials(); - var resultList = await vaas.ForFileListAsync(new List { "test1.txt", "test2.txt", "test3.txt" }, - CancellationToken.None); - Assert.Equal(Verdict.Clean, resultList[0].Verdict); - Assert.Equal(Vaas.Sha256CheckSum("test1.txt"), resultList[0].Sha256); - Assert.Equal(Verdict.Clean, resultList[1].Verdict); - Assert.Equal(Vaas.Sha256CheckSum("test2.txt"), resultList[1].Sha256); - Assert.Equal(Verdict.Clean, resultList[2].Verdict); - Assert.Equal(Vaas.Sha256CheckSum("test3.txt"), resultList[2].Sha256); - } - - // [Fact] - // public async Task FromSha256_ReturnsPup_ForAmtsoSample() - // { - // var vaas = await AuthenticateWithCredentials(); - // var actual = await vaas.ForSha256Async( - // new ChecksumSha256("d6f6c6b9fde37694e12b12009ad11ab9ec8dd0f193e7319c523933bdad8a50ad"), - // CancellationToken.None); - // Assert.Equal(Verdict.Pup, actual.Verdict); - // Assert.Equal("d6f6c6b9fde37694e12b12009ad11ab9ec8dd0f193e7319c523933bdad8a50ad", actual.Sha256, true); - // } - - [Theory] - [InlineData("https://www.gdatasoftware.com/oem/verdict-as-a-service", Verdict.Clean)] - [InlineData("https://secure.eicar.org/eicar.com", Verdict.Malicious)] - public async Task FromUrlReturnVerdict(string url, Verdict verdict) - { - var vaas = await AuthenticateWithCredentials(); - var actual = await vaas.ForUrlAsync(new Uri(url), CancellationToken.None); - Assert.Equal(verdict, actual.Verdict); - } - - [Fact] - public async Task ForUrl_WithUrlWithStatusCode4xx_ThrowsVaasClientException() - { - var vaas = await AuthenticateWithCredentials(); - var e = await Assert.ThrowsAsync(() => - vaas.ForUrlAsync(new Uri("https://gateway.production.vaas.gdatasecurity.de/swagger/nocontenthere"), - CancellationToken.None)); - Assert.Equal( - "Call failed with status code 404 (Not Found): GET https://gateway.production.vaas.gdatasecurity.de/swagger/nocontenthere", - e.Message); - } - - [Fact] - public async Task ForStream_WithEicarString_ReturnsMalicious() - { - // Arrange - var vaas = await AuthenticateWithCredentials(); - var targetStream = new MemoryStream(); - var eicarBytes = System.Text.Encoding.UTF8.GetBytes("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"); - targetStream.Write(eicarBytes, 0, eicarBytes.Length); - targetStream.Position = 0; - - // Act - var verdict = await vaas.ForStreamAsync(targetStream, CancellationToken.None); - - // Assert - Assert.Equal(Verdict.Malicious, verdict.Verdict); - } - - [Fact] - public async Task ForStream_WithCleanString_ReturnsClean() - { - // Arrange - var vaas = await AuthenticateWithCredentials(); - var targetStream = new MemoryStream(); - var cleanBytes = System.Text.Encoding.UTF8.GetBytes("This is a clean file"); - targetStream.Write(cleanBytes, 0, cleanBytes.Length); - targetStream.Position = 0; - - // Act - var verdict = await vaas.ForStreamAsync(targetStream, CancellationToken.None); - - // Assert - Assert.Equal(Verdict.Clean, verdict.Verdict); - } - - [Fact] - public async Task ForStream_WithCleanUrl_ReturnsClean() - { - // Arrange - var vaas = await AuthenticateWithCredentials(); - var url = new Uri("https://raw.githubusercontent.com/GDATASoftwareAG/vaas/main/Readme.md"); - var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, url), CancellationToken.None); - var targetStream = await response.Content.ReadAsStreamAsync(); - - // Act - var verdict = await vaas.ForStreamAsync(targetStream, CancellationToken.None); - - // Assert - Assert.Equal(Verdict.Clean, verdict.Verdict); - } - - [Fact] - public async Task ForStream_WithEicarUrl_ReturnsEicar() - { - // Arrange - var vaas = await AuthenticateWithCredentials(); - var url = new Uri("https://secure.eicar.org/eicar.com.txt"); - var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, url), CancellationToken.None); - var targetStream = await response.Content.ReadAsStreamAsync(); - - // Act - var verdict = await vaas.ForStreamAsync(targetStream, CancellationToken.None); - - // Assert - Assert.Equal(Verdict.Malicious, verdict.Verdict); - } - - private async Task AuthenticateWithCredentials() - { - var services = GetServices(new Dictionary() - { - { "VerdictAsAService:Url", VaasUrl.ToString() }, - { "VerdictAsAService:TokenUrl", AuthenticationEnvironment.TokenUrl.ToString() }, - { "VerdictAsAService:Credentials:GrantType", "ClientCredentials" }, - { "VerdictAsAService:Credentials:ClientId", AuthenticationEnvironment.ClientId }, - { "VerdictAsAService:Credentials:ClientSecret", AuthenticationEnvironment.ClientSecret }, - { "VerdictAsAService:UseCache", "false" } - }); - ServiceCollectionTools.Output(_output, services); - var provider = services.BuildServiceProvider(); - - var vaas = provider.GetRequiredService(); - await vaas.Connect(CancellationToken.None); - return (Vaas)vaas; - } - - private static IServiceCollection GetServices(Dictionary data) - { - var s = new MemoryConfigurationSource() { InitialData = data }; - var configuration = new ConfigurationBuilder() - .Add(s) - .Build(); - - var services = new ServiceCollection(); - services.AddVerdictAsAService(configuration); - return services; - } - - [Fact] - public async Task UploadEmptyFile() - { - await File.WriteAllBytesAsync("empty.txt", Array.Empty()); - var vaas = await AuthenticateWithCredentials(); - var result = await vaas.ForFileAsync("empty.txt", CancellationToken.None); - Assert.Equal(Verdict.Clean, result.Verdict); - Assert.Equal(Vaas.Sha256CheckSum("empty.txt"), result.Sha256); - } - - [Fact] - public async Task Connect_WithResourceOwnerPasswordGrantAuthenticator() - { - var services = GetServices(new Dictionary() - { - { "VerdictAsAService:Url", VaasUrl.ToString() }, - { "VerdictAsAService:TokenUrl", AuthenticationEnvironment.TokenUrl.ToString() }, - { "VerdictAsAService:Credentials:GrantType", "Password" }, - { "VerdictAsAService:Credentials:ClientId", AuthenticationEnvironment.ClientIdForResourceOwnerPasswordGrant }, - { "VerdictAsAService:Credentials:UserName", AuthenticationEnvironment.UserName }, - { "VerdictAsAService:Credentials:Password", AuthenticationEnvironment.Password }, - }); - var provider = services.BuildServiceProvider(); - - var vaas = provider.GetRequiredService(); - await vaas.Connect(CancellationToken.None); - } - - [Fact] - public async Task ForStream_WithEicarUrl_ReturnsMaliciousWithDetectionsAndMimeType() - { - var vaas = await AuthenticateWithCredentials(); - var url = new Uri("https://secure.eicar.org/eicar.com.txt"); - var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, url), CancellationToken.None); - var targetStream = await response.Content.ReadAsStreamAsync(); - - var verdict = await vaas.ForStreamAsync(targetStream, CancellationToken.None); - - Assert.Equal(Verdict.Malicious, verdict.Verdict); - Assert.Equal("text/plain", verdict.FileType); - Assert.Equal("EICAR virus test files", verdict.MimeType); - Assert.Contains("EICAR-Test-File", verdict.Detection); - } - - [Fact] - public async Task ForUrl_WithEicarUrl_ReturnsMaliciousWithDetectionAndMimeType() - { - var vaas = await AuthenticateWithCredentials(); - var uri = new Uri("https://secure.eicar.org/eicar.com"); - - var verdict = await vaas.ForUrlAsync(uri, CancellationToken.None); - - Assert.Equal(Verdict.Malicious, verdict.Verdict); - Assert.Equal("text/plain", verdict.FileType); - Assert.Equal("EICAR virus test files", verdict.MimeType); - Assert.Contains("EICAR-Test-File", verdict.Detection); - } -} diff --git a/dotnet/Vaas/test/Vaas.Test/Messages/VerdictRequestTest.cs b/dotnet/Vaas/test/Vaas.Test/Messages/VerdictRequestTest.cs deleted file mode 100644 index f2b145a9f..000000000 --- a/dotnet/Vaas/test/Vaas.Test/Messages/VerdictRequestTest.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Text.Json; -using Snapshooter.Xunit; -using Vaas.Messages; -using Xunit; - -namespace Vaas.Test.Messages; - -public class VerdictRequestTest -{ - [Fact] - public void Serialize() - { - var json = JsonSerializer.Serialize(new VerdictRequest("", "")); - Snapshot.Match(json, matchOptions => matchOptions.IgnoreField("guid")); - } - - [Fact] - public void Serialize_WithOptions() - { - var json = JsonSerializer.Serialize(new VerdictRequest("", "") { UseCache = false, UseShed = false }); - Snapshot.Match(json, matchOptions => matchOptions.IgnoreField("guid")); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/test/Vaas.Test/Messages/__snapshots__/VerdictRequestTest.Serialize.snap b/dotnet/Vaas/test/Vaas.Test/Messages/__snapshots__/VerdictRequestTest.Serialize.snap deleted file mode 100644 index f81f24986..000000000 --- a/dotnet/Vaas/test/Vaas.Test/Messages/__snapshots__/VerdictRequestTest.Serialize.snap +++ /dev/null @@ -1 +0,0 @@ -{"kind":"VerdictRequest","sha256":"","guid":"5208034c-44f2-4b8e-a96b-4c05bd663224","session_id":"","verdict_request_attributes":null} diff --git a/dotnet/Vaas/test/Vaas.Test/Messages/__snapshots__/VerdictRequestTest.Serialize_WithOptions.snap b/dotnet/Vaas/test/Vaas.Test/Messages/__snapshots__/VerdictRequestTest.Serialize_WithOptions.snap deleted file mode 100644 index ad0023df2..000000000 --- a/dotnet/Vaas/test/Vaas.Test/Messages/__snapshots__/VerdictRequestTest.Serialize_WithOptions.snap +++ /dev/null @@ -1 +0,0 @@ -{"kind":"VerdictRequest","sha256":"","guid":"d77c0f61-ac73-4320-b77b-c7ffe64c3327","session_id":"","verdict_request_attributes":null,"use_cache":false,"use_shed":false} diff --git a/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs b/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs index 694c7b536..2904feaa0 100644 --- a/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs +++ b/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs @@ -13,7 +13,8 @@ public static void Output(ITestOutputHelper output, IServiceCollection services) foreach (var s in sortedServices) { output.WriteLine( - $"{GetNameWithTypeParameters(s.ServiceType)} {GetImplementationName(s)}"); + $"{GetNameWithTypeParameters(s.ServiceType)} {GetImplementationName(s)}" + ); } } @@ -26,7 +27,7 @@ private static string GetImplementationName(ServiceDescriptor s) if (s.ImplementationInstance != null) { - return $"instance"; + return "instance"; } if (s.ImplementationFactory != null) @@ -36,20 +37,20 @@ private static string GetImplementationName(ServiceDescriptor s) throw new ArgumentException("Unknown type of service descriptor", nameof(s)); } - + private static string GetNameWithTypeParameters(Type type) { - if (!type.IsGenericType) return type.Name; - - string genericArguments = type.GetGenericArguments() + if (!type.IsGenericType) + return type.Name; + + var genericArguments = type.GetGenericArguments() .Select(x => x.Name) .Aggregate((x1, x2) => $"{x1}, {x2}"); - var indexOfBacktick = type.Name.IndexOf("`", StringComparison.InvariantCulture); + var indexOfBacktick = type.Name.IndexOf('`'); if (indexOfBacktick == -1) { return type.Name; } - return $"{type.Name.Substring(0, indexOfBacktick)}" - + $"<{genericArguments}>"; + return $"{type.Name[..indexOfBacktick]}" + $"<{genericArguments}>"; } -} \ No newline at end of file +} diff --git a/dotnet/Vaas/test/Vaas.Test/Vaas.Test.csproj b/dotnet/Vaas/test/Vaas.Test/Vaas.Test.csproj index 92a88959e..85274ffe9 100644 --- a/dotnet/Vaas/test/Vaas.Test/Vaas.Test.csproj +++ b/dotnet/Vaas/test/Vaas.Test/Vaas.Test.csproj @@ -8,6 +8,7 @@ + @@ -26,8 +27,4 @@ - - - - diff --git a/dotnet/Vaas/test/Vaas.Test/VaasOptionsTest.cs b/dotnet/Vaas/test/Vaas.Test/VaasOptionsTest.cs deleted file mode 100644 index 2f279dedb..000000000 --- a/dotnet/Vaas/test/Vaas.Test/VaasOptionsTest.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Snapshooter.Xunit; -using Xunit; - -namespace Vaas.Test; - -public class VaasOptionsTest -{ - [Fact] - public void Value_ForPassword_ReturnsOptions() - { - var provider = GetServices(new() - { - { "TokenUrl", "https://account-staging.gdata.de/realms/vaas-staging/protocol/openid-connect/token" }, - { "Credentials:GrantType", "Password" }, - { "Credentials:ClientId", "clientId" }, - { "Credentials:UserName", "userName" }, - { "Credentials:Password", "password" }, - }); - - var options = provider.GetRequiredService>().Value; - - options.MatchSnapshot(); - } - - [Fact] - public void Value_ForClientCredentials_ReturnsOptions() - { - var provider = GetServices(new() - { - { "Credentials:GrantType", "ClientCredentials" }, - { "Credentials:ClientId", "clientId" }, - { "Credentials:ClientSecret", "clientSecret" }, - }); - - var options = provider.GetRequiredService>().Value; - - options.MatchSnapshot(); - } - - [Fact] - public void Value_IfFieldsAreMissing_ThrowsOptionsValidationException() - { - var provider = GetServices(new()); - - // Exception is thrown, when Value is called - var e = Assert.Throws(() => - provider.GetRequiredService>().Value); - - Assert.Equal( - "DataAnnotation validation failed for 'VaasOptions' members: 'Credentials' with the error: 'The Credentials field is required.'.", - e.Message); - } - - [Fact] - public void Value_IfClientCredentialsAndSecretIsMissing_ThrowsOptionsValidationException() - { - var provider = GetServices(new() - { - { "Credentials:GrantType", "ClientCredentials" }, - { "Credentials:ClientId", "ClientId" } - }); - - // Exception is thrown, when Value is called - var e = Assert.Throws(() => - provider.GetRequiredService>().Value); - - Assert.Equal( - "DataAnnotation validation failed for 'VaasOptions' members: 'Credentials' with the error: 'The fields ClientId and ClientSecret are required for the GrantType ClientCredentials.'.", - e.Message); - } - - [Fact] - public void Value_IfPasswordAndUserNameIsMissing_ThrowsOptionsValidationException() - { - var provider = GetServices(new() - { - { "Credentials:GrantType", "Password" }, - { "Credentials:ClientId", "ClientId" } - }); - - // Exception is thrown, when Value is called - var e = Assert.Throws(() => - provider.GetRequiredService>().Value); - - Assert.Equal( - "DataAnnotation validation failed for 'VaasOptions' members: 'Credentials' with the error: 'The fields ClientId, UserName and Password are required for the GrantType Password.'.", - e.Message); - } - - private static IServiceProvider GetServices(Dictionary data) - { - var s = new MemoryConfigurationSource() { InitialData = data }; - var configuration = new ConfigurationBuilder() - .Add(s) - .Build(); - - var services = new ServiceCollection(); - services - .AddOptions() - .Bind(configuration) - .ValidateDataAnnotations(); - return services.BuildServiceProvider(); - } -} \ No newline at end of file diff --git a/dotnet/Vaas/test/Vaas.Test/VaasTest.cs b/dotnet/Vaas/test/Vaas.Test/VaasTest.cs new file mode 100644 index 000000000..f955018ab --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/VaasTest.cs @@ -0,0 +1,1284 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Moq.Contrib.HttpClient; +using Vaas.Authentication; +using Vaas.Exceptions; +using Vaas.Messages; +using Vaas.Options; +using Vaas.Test.Authentication; +using Xunit; +using Xunit.Abstractions; + +namespace Vaas.Test; + +public class VaasTest +{ + private static Uri VaasUrl => + new( + DotNetEnv.Env.GetString("VAAS_URL", "https://gateway.production.vaas.gdatasecurity.de") + ); + + private readonly ITestOutputHelper _output; + private IVaas _vaas; + + private const string EicarSha256 = + "ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2"; + + public VaasTest(ITestOutputHelper output) + { + _output = output; + DotNetEnv.Env.TraversePath().Load(); + CreateVaas(); + } + + private void CreateVaas() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var provider = services.BuildServiceProvider(); + + _vaas = provider.GetRequiredService(); + } + + private static ServiceCollection GetServices() + { + return GetServices( + new Dictionary + { + { "VerdictAsAService:Options:UseHashLookup", "true" }, + { "VerdictAsAService:Options:UseCache", "false" }, + { "VerdictAsAService:Options:VaasUrl", VaasUrl.ToString() }, + { "VerdictAsAService:Options:Timeout", "10" }, + { + "VerdictAsAService:Credentials:TokenUrl", + AuthenticationEnvironment.TokenUrl.ToString() + }, + { + "VerdictAsAService:Credentials:GrantType", + GrantType.ClientCredentials.ToString() + }, + { "VerdictAsAService:Credentials:ClientId", AuthenticationEnvironment.ClientId }, + { + "VerdictAsAService:Credentials:ClientSecret", + AuthenticationEnvironment.ClientSecret + }, + } + ); + } + + private static ServiceCollection GetServices(Dictionary data) + { + var s = new MemoryConfigurationSource { InitialData = data }; + var configuration = new ConfigurationBuilder().Add(s).Build(); + + var services = new ServiceCollection(); + services.AddVerdictAsAService(configuration); + return services; + } + + [Theory] + [InlineData( + "110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9", + Verdict.Unknown + )] + [InlineData("cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e", Verdict.Clean)] + [InlineData( + "ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2", + Verdict.Malicious + )] + [InlineData("d6f6c6b9fde37694e12b12009ad11ab9ec8dd0f193e7319c523933bdad8a50ad", Verdict.Pup)] + public async Task ForSha256Async_ReturnsVerdict(string sha256, Verdict verdict) + { + var verdictResponse = await _vaas.ForSha256Async(sha256, CancellationToken.None); + Assert.Equal(verdict, verdictResponse.Verdict); + Assert.Equal(sha256, verdictResponse.Sha256, true); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ForSha256Async_SendsOptions(bool useCache, bool useHashLookup) + { + ChecksumSha256 sha256 = "cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e"; + + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.RequestUri.ToString().Contains(sha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(useCache)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(useHashLookup)) + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Clean } + ) + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.ForSha256Async( + sha256, + CancellationToken.None, + new ForSha256Options { UseCache = useCache, UseHashLookup = useHashLookup } + ); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForSha256Async_IfVaasRequestIdIsSet_SendsTraceState() + { + ChecksumSha256 sha256 = "cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e"; + + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + + var handlerMock = new Mock(); + const string requestId = "foobar"; + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.RequestUri.ToString().Contains(sha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(true)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(true)) + && request.Headers.GetValues("tracestate").Contains($"vaasrequestid={requestId}") + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + var sha256Options = new ForSha256Options { VaasRequestId = requestId }; + + await vaas.ForSha256Async(sha256, CancellationToken.None, sha256Options); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForSha256Async_IfVaasClientException_ThrowsVaasClientException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains(EicarSha256) + ) + .ReturnsResponse( + statusCode: HttpStatusCode.BadRequest, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked client-side error", + Type = "VaasClientException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForSha256Async(EicarSha256, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.GatewayTimeout)] + [InlineData(HttpStatusCode.HttpVersionNotSupported)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ForSha256Async_IfVaasServerException_ThrowsVaasServerException( + HttpStatusCode serverError + ) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains(EicarSha256) + ) + .ReturnsResponse( + statusCode: serverError, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked server-side error", + Type = "VaasServerException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForSha256Async(EicarSha256, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForSha256Async_IfAuthenticatorThrowsAuthenticationException_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + + var handlerMock = new Mock(); + handlerMock + .Setup(a => a.GetTokenAsync(CancellationToken.None)) + .Throws(); + services.RemoveAll(); + services.AddSingleton(handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForSha256Async(EicarSha256, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForSha256Async_If401_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains(EicarSha256) + ) + .ReturnsResponse(HttpStatusCode.Unauthorized); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForSha256Async(EicarSha256, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForSha256Async_IfCancellationRequested_ThrowsOperationCancelledException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var ct = new CancellationToken(true); + + await _vaas + .Invoking(async v => await v.ForSha256Async(EicarSha256, ct)) + .Should() + .ThrowAsync(); + } + + [Theory] + [InlineData("", Verdict.Clean)] + [InlineData("foobar", Verdict.Clean)] + [InlineData( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*", + Verdict.Malicious + )] + public async Task ForFileAsync_ReturnsVerdict(string content, Verdict verdict) + { + var fileName = Guid.NewGuid() + ".txt"; + await File.WriteAllBytesAsync(fileName, Encoding.UTF8.GetBytes(content)); + + var actual = await _vaas.ForFileAsync(fileName, CancellationToken.None); + + File.Delete(fileName); + + Assert.Equal(verdict, actual.Verdict); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ForFileOptions_SendsOptions(bool useCache, bool useHashLookup) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + const string sha256 = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f"; + var fileName = Guid.NewGuid() + ".txt"; + + await File.WriteAllBytesAsync(fileName, Encoding.UTF8.GetBytes(content)); + + var handlerMock = new Mock(MockBehavior.Strict); + + if (useCache || useHashLookup) + { + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Get + && request.RequestUri.ToString().Contains(sha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(useCache)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(useHashLookup)) + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + } + + if (!useCache) + { + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Get + && request.RequestUri.ToString().Contains(sha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(true)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(useHashLookup)) + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + } + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/files") + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(useHashLookup)) + ) + .ReturnsResponse(JsonSerializer.Serialize(new FileAnalysisStarted { Sha256 = sha256 })); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.ForFileAsync( + fileName, + CancellationToken.None, + new ForFileOptions { UseCache = useCache, UseHashLookup = useHashLookup } + ); + File.Delete(fileName); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForFileAsync_IfVaasRequestIdIsSet_SendsTraceState() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + const string sha256 = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f"; + await File.WriteAllBytesAsync("file.txt", Encoding.UTF8.GetBytes(content)); + + var handlerMock = new Mock(); + const string requestId = "foobar"; + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.RequestUri.ToString().Contains(sha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(true)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(true)) + && request.Headers.GetValues("tracestate").Contains($"vaasrequestid={requestId}") + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/files") + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(true)) + ) + .ReturnsResponse(JsonSerializer.Serialize(new FileAnalysisStarted { Sha256 = sha256 })); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + var forFileOptions = new ForFileOptions() { VaasRequestId = requestId }; + + await vaas.ForFileAsync("file.txt", CancellationToken.None, forFileOptions); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForFileAsync_IfVaasClientException_ThrowsVaasClientException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + await File.WriteAllBytesAsync("file.txt", Encoding.UTF8.GetBytes(content)); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains("/files") + ) + .ReturnsResponse( + statusCode: HttpStatusCode.BadRequest, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked client-side error", + Type = "VaasClientException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForFileAsync("file.txt", CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.GatewayTimeout)] + [InlineData(HttpStatusCode.HttpVersionNotSupported)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ForFileAsync_IfVaasServerException_ThrowsVaasServerException( + HttpStatusCode serverError + ) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + await File.WriteAllBytesAsync("file.txt", Encoding.UTF8.GetBytes(content)); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains("/files") + ) + .ReturnsResponse( + statusCode: serverError, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked server-side error", + Type = "VaasServerException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForFileAsync("file.txt", CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForFileAsync_IfAuthenticatorThrowsAuthenticationException_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + await File.WriteAllBytesAsync("file.txt", Encoding.UTF8.GetBytes(content)); + + var handlerMock = new Mock(); + handlerMock + .Setup(a => a.GetTokenAsync(CancellationToken.None)) + .Throws(); + services.RemoveAll(); + services.AddSingleton(handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForFileAsync("file.txt", CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForFileAsync_If401_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + await File.WriteAllBytesAsync("file.txt", Encoding.UTF8.GetBytes(content)); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains("/files") + ) + .ReturnsResponse(HttpStatusCode.Unauthorized); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForFileAsync("file.txt", CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForFileAsync_IfCancellationRequested_ThrowsOperationCancelledException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + const string content = + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + await File.WriteAllBytesAsync("file.txt", Encoding.UTF8.GetBytes(content)); + + var ct = new CancellationToken(true); + + await _vaas + .Invoking(async v => await v.ForFileAsync("file.txt", ct)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForStreamAsync_ReturnsVerdict() + { + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var verdict = await _vaas.ForStreamAsync(targetStream, CancellationToken.None); + + Assert.Equal(Verdict.Malicious, verdict.Verdict); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForStreamOptions_SendsOptions(bool useHashLookup) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(MockBehavior.Strict); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Get + && request.RequestUri.ToString().Contains(EicarSha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(true)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(useHashLookup)) + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/files") + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(useHashLookup)) + ) + .ReturnsResponse( + JsonSerializer.Serialize(new FileAnalysisStarted { Sha256 = EicarSha256 }) + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.ForStreamAsync( + targetStream, + CancellationToken.None, + new ForStreamOptions { UseHashLookup = useHashLookup } + ); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForStreamAsync_SendsUserAgent() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request + .Headers.UserAgent.ToString() + .Contains("Cs/" + Assembly.GetAssembly(typeof(Vaas))?.GetName().Version) + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + var verdict = await vaas.ForStreamAsync(targetStream, CancellationToken.None); + _output.WriteLine(verdict.ToString()); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForStreamAsync_IfVaasRequestIdIsSet_SendsTraceState() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(); + const string requestId = "foobar"; + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.RequestUri.ToString().Contains(EicarSha256) + && request + .RequestUri.ToString() + .Contains("useCache=" + JsonSerializer.Serialize(true)) + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(true)) + && request.Headers.GetValues("tracestate").Contains($"vaasrequestid={requestId}") + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new FileReport { Sha256 = EicarSha256, Verdict = Verdict.Unknown } + ) + ); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/files") + && request + .RequestUri.ToString() + .Contains("useHashLookup=" + JsonSerializer.Serialize(true)) + ) + .ReturnsResponse( + JsonSerializer.Serialize(new FileAnalysisStarted { Sha256 = EicarSha256 }) + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + var forStreamOptions = new ForStreamOptions() { VaasRequestId = requestId }; + + await vaas.ForStreamAsync(targetStream, CancellationToken.None, forStreamOptions); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForStreamAsync_IfVaasClientException_ThrowsVaasClientException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains("/files") + ) + .ReturnsResponse( + statusCode: HttpStatusCode.BadRequest, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked client-side error", + Type = "VaasClientException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForStreamAsync(targetStream, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.GatewayTimeout)] + [InlineData(HttpStatusCode.HttpVersionNotSupported)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ForStreamAsync_IfVaasServerException_ThrowsVaasServerException( + HttpStatusCode serverError + ) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains("/files") + ) + .ReturnsResponse( + statusCode: serverError, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked server-side error", + Type = "VaasServerException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForStreamAsync(targetStream, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForStreamAsync_IfAuthenticatorThrowsAuthenticationException_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(); + handlerMock + .Setup(a => a.GetTokenAsync(CancellationToken.None)) + .Throws(); + services.RemoveAll(); + services.AddSingleton(handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForStreamAsync(targetStream, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForStreamAsync_If401_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null && request.RequestUri.ToString().Contains("/files") + ) + .ReturnsResponse(HttpStatusCode.Unauthorized); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForStreamAsync(targetStream, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForStreamAsync_IfCancellationRequested_ThrowsOperationCancelledException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var targetStream = new MemoryStream( + "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"u8.ToArray() + ); + + var ct = new CancellationToken(true); + + await _vaas + .Invoking(async v => await v.ForStreamAsync(targetStream, ct)) + .Should() + .ThrowAsync(); + } + + [Theory] + [InlineData("https://www.gdatasoftware.com/oem/verdict-as-a-service", Verdict.Clean)] + [InlineData("https://secure.eicar.org/eicar.com", Verdict.Malicious)] + public async Task ForUrlAsync_ReturnsVerdict(string url, Verdict verdict) + { + var actual = await _vaas.ForUrlAsync(new Uri(url), CancellationToken.None); + Assert.Equal(verdict, actual.Verdict); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForUrlOptions_SendsOptions(bool useHashLookup) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + var urlAnalysisStarted = new UrlAnalysisStarted { Id = Guid.NewGuid().ToString() }; + + var handlerMock = new Mock(MockBehavior.Strict); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Get + && request.RequestUri.ToString().Contains(urlAnalysisStarted.Id) + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new UrlReport + { + Sha256 = EicarSha256, + Verdict = Verdict.Unknown, + Url = url, + } + ) + ); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/urls") + ) + .ReturnsResponse(JsonSerializer.Serialize(urlAnalysisStarted)); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.ForUrlAsync( + url, + CancellationToken.None, + new ForUrlOptions { UseHashLookup = useHashLookup } + ); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForUrlAsync_SendsUserAgent() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + var urlAnalysisStarted = new UrlAnalysisStarted { Id = Guid.NewGuid().ToString() }; + + var handlerMock = new Mock(); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Get + && request.RequestUri.ToString().Contains(urlAnalysisStarted.Id) + && request.Headers.UserAgent.ToString() + == new ProductInfoHeaderValue( + "Cs", + Assembly.GetAssembly(typeof(Vaas))?.GetName().Version?.ToString() + ).ToString() + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new UrlReport + { + Sha256 = EicarSha256, + Verdict = Verdict.Unknown, + Url = url, + } + ) + ); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/urls") + && request.Headers.UserAgent.ToString() + == new ProductInfoHeaderValue( + "Cs", + Assembly.GetAssembly(typeof(Vaas))?.GetName().Version?.ToString() + ).ToString() + ) + .ReturnsResponse(JsonSerializer.Serialize(urlAnalysisStarted)); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.ForUrlAsync(url, CancellationToken.None); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForUrlAsync_IfVaasRequestIdIsSet_SendsTraceState() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + var urlAnalysisStarted = new UrlAnalysisStarted { Id = Guid.NewGuid().ToString() }; + + var handlerMock = new Mock(); + const string requestId = "foobar"; + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Get + && request.RequestUri.ToString().Contains(urlAnalysisStarted.Id) + && request.Headers.GetValues("tracestate").Contains($"vaasrequestid={requestId}") + ) + .ReturnsResponse( + JsonSerializer.Serialize( + new UrlReport + { + Sha256 = EicarSha256, + Verdict = Verdict.Unknown, + Url = url, + } + ) + ); + + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/urls") + && request.Headers.GetValues("tracestate").Contains($"vaasrequestid={requestId}") + ) + .ReturnsResponse(JsonSerializer.Serialize(urlAnalysisStarted)); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + var forUrlOptions = new ForUrlOptions() { VaasRequestId = requestId }; + + await vaas.ForUrlAsync(url, CancellationToken.None, forUrlOptions); + + handlerMock.VerifyAll(); + } + + [Fact] + public async Task ForUrlAsync_IfVaasClientException_ThrowsVaasClientException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/urls") + ) + .ReturnsResponse( + statusCode: HttpStatusCode.BadRequest, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked client-side error", + Type = "VaasClientException", + } + ); + } + ); + + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForUrlAsync(url, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.GatewayTimeout)] + [InlineData(HttpStatusCode.HttpVersionNotSupported)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ForUrlAsync_IfVaasServerException_ThrowsVaasServerException( + HttpStatusCode serverError + ) + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/urls") + ) + .ReturnsResponse( + statusCode: serverError, + configure: message => + { + message.Content = JsonContent.Create( + new ProblemDetails + { + Detail = "Mocked server-side error", + Type = "VaasServerException", + } + ); + } + ); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForUrlAsync(url, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForUrlAsync_IfAuthenticatorThrowsAuthenticationException_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + + var handlerMock = new Mock(); + handlerMock + .Setup(a => a.GetTokenAsync(CancellationToken.None)) + .Throws(); + services.RemoveAll(); + services.AddSingleton(handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForUrlAsync(url, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForUrlAsync_If401_ThrowsAuthenticationException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + + var handlerMock = new Mock(); + handlerMock + .SetupRequest(request => + request.RequestUri != null + && request.Method == HttpMethod.Post + && request.RequestUri.ToString().Contains("/urls") + ) + .ReturnsResponse(HttpStatusCode.Unauthorized); + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => handlerMock.Object); + var provider = services.BuildServiceProvider(); + var vaas = provider.GetRequiredService(); + + await vaas.Invoking(async v => await v.ForUrlAsync(url, CancellationToken.None)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForUrlAsync_IfCancellationRequested_ThrowsOperationCancelledException() + { + var services = GetServices(); + ServiceCollectionTools.Output(_output, services); + var url = new Uri("https://secure.eicar.org/eicar.com"); + + var ct = new CancellationToken(true); + + await _vaas + .Invoking(async v => await v.ForUrlAsync(url, ct)) + .Should() + .ThrowAsync(); + } + + [Fact] + public async Task ForFile_WithTimeout_ThrowsException() + { + var authenticator = new ClientCredentialsGrantAuthenticator( + AuthenticationEnvironment.ClientId, + AuthenticationEnvironment.ClientSecret, + AuthenticationEnvironment.TokenUrl + ); + var options = new VaasOptions + { + Timeout = TimeSpan.FromSeconds(1), + UseCache = false, + UseHashLookup = false, + VaasUrl = new Uri("https://gateway.staging.vaas.gdatasecurity.de"), + }; + var vaas = new Vaas(authenticator, options); + + try + { + var random100Mb = new byte[1000 * 1024 * 1024]; + new Random().NextBytes(random100Mb); + await File.WriteAllBytesAsync("file.txt", random100Mb); + await vaas.Invoking(async v => await v.ForFileAsync("file.txt", CancellationToken.None)) + .Should() + .ThrowAsync(); + } + finally + { + File.Delete("file.txt"); + } + } +} diff --git a/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForClientCredentials_ReturnsOptions.snap b/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForClientCredentials_ReturnsOptions.snap deleted file mode 100644 index bcd79bef6..000000000 --- a/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForClientCredentials_ReturnsOptions.snap +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Url": "https://upload.production.vaas.gdatasecurity.de", - "UseHashLookup": null, - "UseCache": null, - "TokenUrl": "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token", - "Credentials": { - "GrantType": "ClientCredentials", - "ClientId": "clientId", - "ClientSecret": "clientSecret", - "UserName": null, - "Password": null - } -} diff --git a/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForPassword_ReturnsOptions.snap b/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForPassword_ReturnsOptions.snap deleted file mode 100644 index fb19e18d3..000000000 --- a/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForPassword_ReturnsOptions.snap +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Url": "https://upload.production.vaas.gdatasecurity.de", - "UseHashLookup": null, - "UseCache": null, - "TokenUrl": "https://account-staging.gdata.de/realms/vaas-staging/protocol/openid-connect/token", - "Credentials": { - "GrantType": "Password", - "ClientId": "clientId", - "ClientSecret": null, - "UserName": "userName", - "Password": "password" - } -} diff --git a/dotnet/examples/VaasExample/Program.cs b/dotnet/examples/VaasExample/Program.cs index bc6a6e537..3661d7aca 100644 --- a/dotnet/examples/VaasExample/Program.cs +++ b/dotnet/examples/VaasExample/Program.cs @@ -10,10 +10,10 @@ public static class Program private static string ClientSecret => Environment.GetEnvironmentVariable("CLIENT_SECRET") ?? string.Empty; private static string UserName => Environment.GetEnvironmentVariable("VAAS_USER_NAME") ?? string.Empty; private static string Password => Environment.GetEnvironmentVariable("VAAS_PASSWORD") ?? string.Empty; - private static Uri VaasUrl => new Uri(Environment.GetEnvironmentVariable("VAAS_URL") ?? - "wss://gateway.production.vaas.gdatasecurity.de"); - private static Uri TokenUrl => new Uri(Environment.GetEnvironmentVariable("TOKEN_URL") ?? - "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token"); + private static Uri VaasUrl => new(Environment.GetEnvironmentVariable("VAAS_URL") ?? + "wss://gateway.production.vaas.gdatasecurity.de"); + private static Uri TokenUrl => new(Environment.GetEnvironmentVariable("TOKEN_URL") ?? + "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token"); public static async Task Main(string[] args) { @@ -58,7 +58,7 @@ private static async Task CreateVaasAndConnect() // If you got a username and password from us, you can use the GrantType.Password like this // You may use self registration and create a new username and password for the // Credentials by yourself like the example above on https://vaas.gdata.de/login - var vaas = VaasFactory.Create(new VaasOptions() + var vaas = VaasFactory.Create(new VaasOptions { Url = VaasUrl, TokenUrl = TokenUrl,