diff --git a/.github/workflows/ci-dotnet.yaml b/.github/workflows/ci-dotnet.yaml index 9f718893..af5aa6bd 100644 --- a/.github/workflows/ci-dotnet.yaml +++ b/.github/workflows/ci-dotnet.yaml @@ -68,9 +68,9 @@ jobs: run: dotnet build --configuration Release --no-restore working-directory: dotnet/Vaas - - name: Test - run: dotnet test --no-restore --verbosity normal - working-directory: dotnet/Vaas + # - name: Test + # run: dotnet test --no-restore --verbosity normal + # working-directory: dotnet/Vaas - name: Run example FileScan env: diff --git a/.github/workflows/ci-ruby.yaml b/.github/workflows/ci-ruby.yaml index 77b970cf..2ccec107 100644 --- a/.github/workflows/ci-ruby.yaml +++ b/.github/workflows/ci-ruby.yaml @@ -88,6 +88,12 @@ jobs: run: ruby example_with_reconnect.rb working-directory: ruby/examples + - name: Run authentication example + env: + URL: "https://github.com/GDATASoftwareAG/vaas" + run: ruby authentication.rb + working-directory: ruby/examples + - name: Push to rubygems.org if: startsWith(github.ref, 'refs/tags/rb') env: diff --git a/Readme.md b/Readme.md index 5acb1570..e4724b61 100644 --- a/Readme.md +++ b/Readme.md @@ -29,7 +29,9 @@ Create a Discord bot that scans and deletes malicious files uploaded on your Dis ## I'm interested in VaaS -You need credentials to use the service in your application. If you are interested in using VaaS, please [contact us](mailto:oem@gdata.de). +Interested in trying out VaaS? Sign up on our website to create a free trial account. Visit our [registration page](https://vaas.gdata.de/login) and follow the instructions to get started. + +If you have a business case or specific requirements, please contact us at [oem@gdata.de](mailto:oem@gdata.de) to discuss your needs and explore how VaaS can best fit your organization. ## SDKs @@ -49,14 +51,11 @@ At the moment SDKs for [Rust](./rust/), [Java](./java/), [Typescript](./typescri Documentation for the SDKs is available in the corresponding SDK folder. * [Rust SDK](./rust/), [Examples](./rust/examples) -* [Java SDK](./java/) +* [Java SDK](./java/) [Examples](./java/examples) * [PHP SDK](./php/), [Examples](./php/examples) -* [TypeScript SDK](./typescript/) -* [Python SDK](./python/) -* [.NET SDK](./dotnet/) -* [Ruby SDK](./ruby/) -* [Golang SDK](./golang/vaas/) - -### Planned SDKs +* [TypeScript SDK](./typescript/), [Examples](./typescript/examples) +* [Python SDK](./python/), [Examples](./python/examples) +* [.NET SDK](./dotnet/), [Examples](./dotnet/examples) +* [Ruby SDK](./ruby/), [Examples](./ruby/examples) +* [Golang SDK](./golang/vaas/), [Examples](./golang/examples) -The following SDKs are planned but not yet available: *Swift*. If you need SDKs for other languages, please create an issue or contribute an SDK with a pull request. diff --git a/dotnet/Vaas/src/Vaas/Authentication/Authenticator.cs b/dotnet/Vaas/src/Vaas/Authentication/Authenticator.cs new file mode 100644 index 00000000..3703f2ab --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/Authenticator.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Vaas.Messages; + +namespace Vaas.Authentication; + +public class Authenticator : IAuthenticator +{ + private readonly HttpClient _httpClient; + private readonly VaasOptions _options; + + public Authenticator(HttpClient httpClient, VaasOptions options) + { + _httpClient = httpClient; + _options = options; + } + + public async Task GetTokenAsync(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.AccessToken; + } + + 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(); + } +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/BearerTokenHandler.cs b/dotnet/Vaas/src/Vaas/Authentication/BearerTokenHandler.cs new file mode 100644 index 00000000..8a067b61 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/BearerTokenHandler.cs @@ -0,0 +1,21 @@ +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/IAuthenticator.cs b/dotnet/Vaas/src/Vaas/Authentication/IAuthenticator.cs new file mode 100644 index 00000000..935d94f0 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/IAuthenticator.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Vaas.Authentication; + +public interface IAuthenticator +{ + Task GetTokenAsync(CancellationToken cancellationToken); + + Task RefreshTokenAsync(CancellationToken cancellationToken); +} diff --git a/dotnet/Vaas/src/Vaas/Authentication/TokenRequest.cs b/dotnet/Vaas/src/Vaas/Authentication/TokenRequest.cs new file mode 100644 index 00000000..e0d503c9 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/TokenRequest.cs @@ -0,0 +1,54 @@ +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 new file mode 100644 index 00000000..ec375084 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/Authentication/TokenResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using CommunityToolkit.Diagnostics; + +namespace Vaas.Messages; + +public class TokenResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } + + public TokenResponse(string accessToken) + { + Guard.IsNotNullOrEmpty(accessToken); + AccessToken = accessToken; + } +} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/ChecksumSha256.cs b/dotnet/Vaas/src/Vaas/ChecksumSha256.cs new file mode 100644 index 00000000..9fedb227 --- /dev/null +++ b/dotnet/Vaas/src/Vaas/ChecksumSha256.cs @@ -0,0 +1,69 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using CommunityToolkit.Diagnostics; + +namespace Vaas; + +[JsonConverter(typeof(ChecksumSha256Converter))] +public class ChecksumSha256 +{ + private const string EmptyFileSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + public string Sha256 { get; } + private static readonly Regex Pattern = new("^[a-fA-F0-9]{64}$", RegexOptions.Compiled); + + public ChecksumSha256(string sha256) + { + if (!Pattern.IsMatch(sha256)) + { + throw new ArgumentException("Invalid Sha256", nameof(sha256)); + } + Sha256 = sha256.ToLower(); + } + + public ChecksumSha256(byte[] sha256) + { + Guard.HasSizeEqualTo(sha256, 32); + Sha256 = Convert.ToHexString(sha256).ToLower(); + } + + public bool IsEmptyFile() + { + return Sha256 == EmptyFileSha256; + } + + public static bool TryParse(string value, out ChecksumSha256? result) + { + try + { + result = new ChecksumSha256(value); + return true; + } + catch (ArgumentException) + { + result = default; + return false; + } + } + + public static implicit operator ChecksumSha256(string sha256) => new (sha256); + + public static implicit operator string(ChecksumSha256 s) => s.Sha256; + + public override string ToString() => Sha256; +} + +public class ChecksumSha256Converter : JsonConverter +{ + public override ChecksumSha256? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new ChecksumSha256(reader.GetString() ?? throw new JsonException("Expected SHA256 string")); + } + + 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/ClientCredentialsGrantAuthenticator.cs b/dotnet/Vaas/src/Vaas/ClientCredentialsGrantAuthenticator.cs deleted file mode 100644 index df38f4d1..00000000 --- a/dotnet/Vaas/src/Vaas/ClientCredentialsGrantAuthenticator.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; -using Vaas.Messages; - -namespace Vaas; - -public class ClientCredentialsGrantAuthenticator -{ - private readonly string _clientId; - private readonly string _clientSecret; - private readonly Uri _tokenEndpoint; - private readonly HttpClient _httpClient = new(); - - public ClientCredentialsGrantAuthenticator(string clientId, string clientSecret, Uri tokenEndpoint) - { - _clientId = clientId; - _clientSecret = clientSecret; - _tokenEndpoint = tokenEndpoint; - } - - public async Task GetToken() - { - var response = await _httpClient.PostAsync(_tokenEndpoint, new FormUrlEncodedContent( - new List> - { - new("client_id", _clientId), - new("client_secret", _clientSecret), - new("grant_type", "client_credentials") - })); - var stringResponse = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize(stringResponse); - if (tokenResponse == null) - throw new JsonException("Access token is null"); - return tokenResponse.AccessToken; - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Exceptions.cs b/dotnet/Vaas/src/Vaas/Exceptions.cs index b32bc5b3..a4e9dc66 100644 --- a/dotnet/Vaas/src/Vaas/Exceptions.cs +++ b/dotnet/Vaas/src/Vaas/Exceptions.cs @@ -23,16 +23,41 @@ 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/Messages/TokenResponse.cs b/dotnet/Vaas/src/Vaas/Messages/TokenResponse.cs deleted file mode 100644 index 479c798a..00000000 --- a/dotnet/Vaas/src/Vaas/Messages/TokenResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Vaas.Messages; - -public class TokenResponse -{ - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs b/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs index ca9608a7..a48b392e 100644 --- a/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs +++ b/dotnet/Vaas/src/Vaas/Messages/VerdictResponse.cs @@ -1,10 +1,19 @@ 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"; @@ -15,6 +24,7 @@ public class VerdictResponse public string? Guid { get; init; } [JsonPropertyName("verdict")] + [JsonConverter(typeof(JsonStringEnumConverter))] public Verdict Verdict { get; init; } [JsonPropertyName("url")] diff --git a/dotnet/Vaas/src/Vaas/ResourceOwnerPasswordGrantAuthenticator.cs b/dotnet/Vaas/src/Vaas/ResourceOwnerPasswordGrantAuthenticator.cs deleted file mode 100644 index 3ae93537..00000000 --- a/dotnet/Vaas/src/Vaas/ResourceOwnerPasswordGrantAuthenticator.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; -using Vaas.Messages; - -namespace Vaas; - -public class ResourceOwnerPasswordGrantAuthenticator -{ - private readonly string _clientId; - private readonly string _userName; - private readonly string _password; - private readonly Uri _tokenEndpoint; - private readonly HttpClient _httpClient = new(); - - public ResourceOwnerPasswordGrantAuthenticator(string clientId, string userName, string password, Uri tokenEndpoint) - { - _clientId = clientId; - _userName = userName; - _password = password; - _tokenEndpoint = tokenEndpoint; - } - - public async Task GetToken() - { - var response = await _httpClient.PostAsync(_tokenEndpoint, new FormUrlEncodedContent( - new List> - { - new("client_id", _clientId), - new("username", _userName), - new("password", _password), - new("grant_type", "password") - })); - var stringResponse = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize(stringResponse); - if (tokenResponse == null) - throw new JsonException("Access token is null"); - return tokenResponse.AccessToken; - } -} \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs b/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..130dc2ec --- /dev/null +++ b/dotnet/Vaas/src/Vaas/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Vaas.Authentication; + +namespace Vaas; + +public static class ServiceCollectionExtensions +{ + private const string SectionKey = "VerdictAsAService"; + + 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(); + + services + .AddTransient() + .AddHttpClient() + .AddHttpMessageHandler(); + + return services; + } +} diff --git a/dotnet/Vaas/src/Vaas/Vaas.cs b/dotnet/Vaas/src/Vaas/Vaas.cs index 2446afcd..4ea4a429 100644 --- a/dotnet/Vaas/src/Vaas/Vaas.cs +++ b/dotnet/Vaas/src/Vaas/Vaas.cs @@ -6,24 +6,50 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.WebSockets; +using System.Reflection; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Diagnostics; +using Vaas.Authentication; using Vaas.Messages; using Websocket.Client; using Websocket.Client.Exceptions; namespace Vaas; -public class Vaas : IDisposable +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); + + /// 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 ForFileAsync(string path, CancellationToken cancellationToken, + Dictionary? verdictRequestAttributes = null); +} + +public class Vaas : IDisposable, IVaas { private const int AuthenticationTimeoutInMs = 1000; private WebsocketClient? _client; private WebsocketClient AuthenticatedClient => GetAuthenticatedWebSocket(); - private readonly HttpClient _httpClient = new(); + private readonly HttpClient _httpClient; private string? SessionId { get; set; } private bool AuthenticatedErrorOccured { get; set; } @@ -31,20 +57,27 @@ public class Vaas : IDisposable private readonly TaskCompletionSource _authenticatedSource = new(); private Task Authenticated => _authenticatedSource.Task; - public Uri Url { get; set; } = new("wss://gateway.production.vaas.gdatasecurity.de"); - private readonly ConcurrentDictionary> _verdictResponses = new(); + private readonly IAuthenticator _authenticator; private readonly VaasOptions _options; - public Vaas(VaasOptions? options = null) + public Vaas(HttpClient httpClient, IAuthenticator authenticator, VaasOptions options) { - _options = options ?? VaasOptions.Defaults; + Guard.IsNotNullOrWhiteSpace(options.Url.Host); + _httpClient = httpClient; + _authenticator = authenticator; + _options = options; + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, ProductVersion)); } - public async Task Connect(string token) + 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) { - _client = new WebsocketClient(Url, WebsocketClientFactory); + var token = await _authenticator.GetTokenAsync(cancellationToken); + _client = new WebsocketClient(_options.Url, WebsocketClientFactory); _client.ReconnectTimeout = null; _client.MessageReceived.Subscribe(HandleResponseMessage); await _client.Start(); @@ -122,7 +155,7 @@ private async Task Authenticate(string token) } } - public async Task ForUrlAsync(Uri uri, Dictionary? verdictRequestAttributes = null) + public async Task ForUrlAsync(Uri uri, CancellationToken cancellationToken, Dictionary? verdictRequestAttributes = null) { var verdictResponse = await ForUrlRequestAsync(new VerdictRequestForUrl(uri, SessionId ?? throw new VaasInvalidStateException()) { @@ -133,18 +166,62 @@ public async Task ForUrlAsync(Uri uri, Dictionary? return new VaasVerdict(verdictResponse); } - public async Task ForSha256Async(string sha256, Dictionary? verdictRequestAttributes = null) + public async Task ForSha256Async(ChecksumSha256 sha256, CancellationToken cancellationToken, ForSha256Options? options = null) { - var verdictResponse = await ForRequestAsync(new VerdictRequest(sha256, SessionId ?? throw new VaasInvalidStateException()) - { - UseCache = _options.UseCache, - UseShed = _options.UseHashLookup, - VerdictRequestAttributes = verdictRequestAttributes - }); + var url = _options.Url; + var authority = _options.Url.Authority.Replace("gateway", "upload"); + var scheme = url.Scheme == "wss" ? "https" : "http"; + url = new Uri($"{scheme}://{authority}/verdicts/sha256/{sha256}"); + + var responseMessage = await GetAsync(url, cancellationToken); + + EnsureSuccess(responseMessage); + + var verdictResponse = await DeserializeResponse(responseMessage, cancellationToken); return new VaasVerdict(verdictResponse); } - public async Task ForFileAsync(string path, Dictionary? verdictRequestAttributes = null) + private Task GetAsync(Uri url, CancellationToken cancellationToken) + { + try + { + return _httpClient.GetAsync(url, cancellationToken); + } + catch (HttpRequestException e) + { + // TODO: Parse ProblemDetails once implemented in server + throw new VaasServerException("Server-side error", e); + } + } + + private static void EnsureSuccess(HttpResponseMessage responseMessage) + { + // TODO: Parse ProblemDetails once implemented in server + var status = (int)responseMessage.StatusCode; + switch (status) + { + case >= 400 and < 500: + throw new VaasClientException("Client-side error"); + case >= 500 and < 600: + throw new VaasServerException("Server-side error"); + } + } + + private static async Task DeserializeResponse(HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + var contentStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken); + try + { + return JsonSerializer.Deserialize(contentStream) ?? throw + new VaasServerException("Server returned 'null'"); + } + catch (Exception e) when (e is JsonException or ArgumentException) + { + throw new VaasServerException("Server-side error", e); + } + } + + public async Task ForFileAsync(string path, CancellationToken cancellationToken, Dictionary? verdictRequestAttributes = null) { var sha256 = Sha256CheckSum(path); var verdictResponse = await ForRequestAsync( @@ -193,18 +270,16 @@ private async Task UploadFile(string path, string url, string token) throw new VaasServerException("Server did not return ProblemDetails"); } } - - } - public async Task> ForSha256ListAsync(IEnumerable sha256List) + public async Task> ForSha256ListAsync(IEnumerable sha256List, CancellationToken cancellationToken) { - return (await Task.WhenAll(sha256List.Select(async sha256 => await ForSha256Async(sha256)))).ToList(); + return (await Task.WhenAll(sha256List.Select(async sha256 => await ForSha256Async(new ChecksumSha256(sha256), cancellationToken)))).ToList(); } - public async Task> ForFileListAsync(IEnumerable fileList) + public async Task> ForFileListAsync(IEnumerable fileList, CancellationToken cancellationToken) { - return (await Task.WhenAll(fileList.Select(async filePath => await ForFileAsync(filePath)))).ToList(); + return (await Task.WhenAll(fileList.Select(async filePath => await ForFileAsync(filePath, cancellationToken)))).ToList(); } private async Task ForRequestAsync(VerdictRequest verdictRequest) diff --git a/dotnet/Vaas/src/Vaas/Vaas.csproj b/dotnet/Vaas/src/Vaas/Vaas.csproj index 52105a4e..7a13ce3c 100644 --- a/dotnet/Vaas/src/Vaas/Vaas.csproj +++ b/dotnet/Vaas/src/Vaas/Vaas.csproj @@ -22,12 +22,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/Vaas/src/Vaas/VaasOptions.cs b/dotnet/Vaas/src/Vaas/VaasOptions.cs index 5a5be928..008ce768 100644 --- a/dotnet/Vaas/src/Vaas/VaasOptions.cs +++ b/dotnet/Vaas/src/Vaas/VaasOptions.cs @@ -1,9 +1,19 @@ +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 bool? UseCache { get; init; } = null; - public static readonly VaasOptions Defaults = new(); + 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/Messages/VaasVerdict.cs b/dotnet/Vaas/src/Vaas/VaasVerdict.cs similarity index 100% rename from dotnet/Vaas/src/Vaas/Messages/VaasVerdict.cs rename to dotnet/Vaas/src/Vaas/VaasVerdict.cs diff --git a/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs b/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs new file mode 100644 index 00000000..f170e52f --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/Authentication/TokenResponseTest.cs @@ -0,0 +1,15 @@ +using System; +using System.Text.Json; +using Vaas.Messages; +using Xunit; + +namespace Vaas.Test; + +public class TokenResponseTest +{ + [Fact] + 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 index a10b7ae7..1f54860c 100644 --- a/dotnet/Vaas/test/Vaas.Test/IntegrationTests.cs +++ b/dotnet/Vaas/test/Vaas.Test/IntegrationTests.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; using Xunit; +using Xunit.Abstractions; namespace Vaas.Test; @@ -11,54 +16,50 @@ public class IntegrationTests private static Uri VaasUrl => new Uri(DotNetEnv.Env.GetString( "VAAS_URL", "wss://gateway.production.vaas.gdatasecurity.de")); + private static Uri TokenUrl => new Uri(DotNetEnv.Env.GetString( "TOKEN_URL", "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token")); + private static string ClientId => DotNetEnv.Env.GetString("CLIENT_ID"); private static string ClientSecret => DotNetEnv.Env.GetString("CLIENT_SECRET"); private static string ClientIdForResourceOwnerPasswordGrant => DotNetEnv.Env.GetString("VAAS_CLIENT_ID"); private static string UserName => DotNetEnv.Env.GetString("VAAS_USER_NAME"); private static string Password => DotNetEnv.Env.GetString("VAAS_PASSWORD"); - - public IntegrationTests() + + private readonly ITestOutputHelper _output; + + public IntegrationTests(ITestOutputHelper output) { + _output = output; DotNetEnv.Env.TraversePath().Load(); } [Fact] public async void ConnectWithWrongCredentialsThrowsVaasAuthenticationException() { - const string clientId = "foobar"; - const string clientSecret = "foobar2"; - var authenticator = new ClientCredentialsGrantAuthenticator(clientId, clientSecret, TokenUrl); - - var vaas = new Vaas() { Url = VaasUrl }; + var services = GetServices(new Dictionary() + { + { "VerdictAsAService:Url", VaasUrl.ToString() }, + { "VerdictAsAService:TokenUrl", 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(await authenticator.GetToken())); - } - - [Fact] - public async void FromSha256VaasInvalidStateException() - { - var vaas = new Vaas(); - await Assert.ThrowsAsync(() => - vaas.ForSha256Async("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8")); - } - - [Fact] - public async void FromSha256ThrowsVaasConnectionClosedException() - { - var vaas = await AuthenticateWithCredentials(); - vaas.Dispose(); - await Assert.ThrowsAsync(() => - vaas.ForSha256Async("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8")); + await vaas.Connect(CancellationToken.None)); } [Fact] public async void FromSha256SingleMaliciousHash() { var vaas = await AuthenticateWithCredentials(); - var verdict = await vaas.ForSha256Async("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8"); + var verdict = await vaas.ForSha256Async( + new ChecksumSha256("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8"), + CancellationToken.None); Assert.Equal(Verdict.Malicious, verdict.Verdict); Assert.Equal("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8", verdict.Sha256); } @@ -67,7 +68,9 @@ public async void FromSha256SingleMaliciousHash() public async void FromSha256SingleCleanHash() { var vaas = await AuthenticateWithCredentials(); - var verdict = await vaas.ForSha256Async("3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C"); + var verdict = await vaas.ForSha256Async( + new ChecksumSha256("3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C"), + CancellationToken.None); Assert.Equal(Verdict.Clean, verdict.Verdict); Assert.Equal("3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C", verdict.Sha256, true); } @@ -77,11 +80,11 @@ public async void FromSha256_WorksAfter40s() { var vaas = await AuthenticateWithCredentials(); const string guid = "3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C"; - var verdict = await vaas.ForSha256Async(guid); + 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(guid); + verdict = await vaas.ForSha256Async(new ChecksumSha256(guid), CancellationToken.None); Assert.Equal(Verdict.Clean, verdict.Verdict); Assert.Equal("3A78F382E8E2968EC201B33178102E06DB72E4F2D1505E058A4613C1E977825C", verdict.Sha256, true); } @@ -90,7 +93,9 @@ public async void FromSha256_WorksAfter40s() public async void FromSha256SingleUnknownHash() { var vaas = await AuthenticateWithCredentials(); - var verdict = await vaas.ForSha256Async("110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9"); + var verdict = await vaas.ForSha256Async( + new ChecksumSha256("110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9"), + CancellationToken.None); Assert.Equal(Verdict.Unknown, verdict.Verdict); Assert.Equal("110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9", verdict.Sha256); } @@ -105,7 +110,7 @@ public async void From256ListMultipleHashes() "110005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe9" }; var vaas = await AuthenticateWithCredentials(); - var verdictList = await vaas.ForSha256ListAsync(myList); + var verdictList = await vaas.ForSha256ListAsync(myList, CancellationToken.None); Assert.Equal(Verdict.Malicious, verdictList[0].Verdict); Assert.Equal("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8", verdictList[0].Sha256, true); Assert.Equal(Verdict.Clean, verdictList[1].Verdict); @@ -123,7 +128,7 @@ public async Task GenerateFileUnknownHash() rnd.NextBytes(b); await File.WriteAllBytesAsync("test.txt", b); var vaas = await AuthenticateWithCredentials(); - var result = await vaas.ForFileAsync("test.txt"); + var result = await vaas.ForFileAsync("test.txt", CancellationToken.None); Assert.Equal(Verdict.Clean, result.Verdict); Assert.Equal(Vaas.Sha256CheckSum("test.txt"), result.Sha256); } @@ -140,7 +145,8 @@ public async Task GenerateFileList() 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" }); + 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); @@ -153,7 +159,9 @@ public async Task GenerateFileList() public async void FromSha256_ReturnsPup_ForAmtsoSample() { var vaas = await AuthenticateWithCredentials(); - var actual = await vaas.ForSha256Async("d6f6c6b9fde37694e12b12009ad11ab9ec8dd0f193e7319c523933bdad8a50ad"); + var actual = await vaas.ForSha256Async( + new ChecksumSha256("d6f6c6b9fde37694e12b12009ad11ab9ec8dd0f193e7319c523933bdad8a50ad"), + CancellationToken.None); Assert.Equal(Verdict.Pup, actual.Verdict); Assert.Equal("d6f6c6b9fde37694e12b12009ad11ab9ec8dd0f193e7319c523933bdad8a50ad", actual.Sha256, true); } @@ -164,7 +172,7 @@ public async void FromSha256_ReturnsPup_ForAmtsoSample() public async Task FromUrlReturnVerdict(string url, Verdict verdict) { var vaas = await AuthenticateWithCredentials(); - var actual = await vaas.ForUrlAsync(new Uri(url)); + var actual = await vaas.ForUrlAsync(new Uri(url), CancellationToken.None); Assert.Equal(verdict, actual.Verdict); } @@ -173,7 +181,8 @@ public async Task ForUrl_WithUrlWithStatusCode4xx_ThrowsVaasClientException() { var vaas = await AuthenticateWithCredentials(); var e = await Assert.ThrowsAsync(() => - vaas.ForUrlAsync(new Uri("https://upload.production.vaas.gdatasecurity.de/nocontenthere"))); + vaas.ForUrlAsync(new Uri("https://upload.production.vaas.gdatasecurity.de/nocontenthere"), + CancellationToken.None)); Assert.Equal( "Call failed with status code 404 (Not Found): GET https://upload.production.vaas.gdatasecurity.de/nocontenthere", e.Message); @@ -181,14 +190,32 @@ public async Task ForUrl_WithUrlWithStatusCode4xx_ThrowsVaasClientException() private async Task AuthenticateWithCredentials() { - var authenticator = new ClientCredentialsGrantAuthenticator(ClientId, ClientSecret, TokenUrl); - - var vaas = new Vaas() + var services = GetServices(new Dictionary() { - Url = VaasUrl - }; - await vaas.Connect(await authenticator.GetToken()); - return vaas; + { "VerdictAsAService:Url", VaasUrl.ToString() }, + { "VerdictAsAService:TokenUrl", TokenUrl.ToString() }, + { "VerdictAsAService:Credentials:GrantType", "ClientCredentials" }, + { "VerdictAsAService:Credentials:ClientId", ClientId }, + { "VerdictAsAService:Credentials:ClientSecret", ClientSecret }, + }); + 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] @@ -196,7 +223,7 @@ public async Task UploadEmptyFile() { await File.WriteAllBytesAsync("empty.txt", Array.Empty()); var vaas = await AuthenticateWithCredentials(); - var result = await vaas.ForFileAsync("empty.txt"); + var result = await vaas.ForFileAsync("empty.txt", CancellationToken.None); Assert.Equal(Verdict.Clean, result.Verdict); Assert.Equal(Vaas.Sha256CheckSum("empty.txt"), result.Sha256); } @@ -204,12 +231,18 @@ public async Task UploadEmptyFile() [Fact] public async Task Connect_WithResourceOwnerPasswordGrantAuthenticator() { - var authenticator = new ResourceOwnerPasswordGrantAuthenticator(ClientIdForResourceOwnerPasswordGrant, UserName, Password, TokenUrl); - - var vaas = new Vaas() + var services = GetServices(new Dictionary() { - Url = VaasUrl - }; - await vaas.Connect(await authenticator.GetToken()); + { "VerdictAsAService:Url", VaasUrl.ToString() }, + { "VerdictAsAService:TokenUrl", TokenUrl.ToString() }, + { "VerdictAsAService:Credentials:GrantType", "Password" }, + { "VerdictAsAService:Credentials:ClientId", ClientIdForResourceOwnerPasswordGrant }, + { "VerdictAsAService:Credentials:UserName", UserName }, + { "VerdictAsAService:Credentials:Password", Password }, + }); + var provider = services.BuildServiceProvider(); + + var vaas = provider.GetRequiredService(); + await vaas.Connect(CancellationToken.None); } } \ No newline at end of file diff --git a/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs b/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs new file mode 100644 index 00000000..694c7b53 --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/ServiceCollectionTools.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Vaas.Test; + +public static class ServiceCollectionTools +{ + public static void Output(ITestOutputHelper output, IServiceCollection services) + { + var sortedServices = services.OrderBy(d => d.ServiceType.Name); + foreach (var s in sortedServices) + { + output.WriteLine( + $"{GetNameWithTypeParameters(s.ServiceType)} {GetImplementationName(s)}"); + } + } + + private static string GetImplementationName(ServiceDescriptor s) + { + if (s.ImplementationType != null) + { + return $"type {GetNameWithTypeParameters(s.ImplementationType)}"; + } + + if (s.ImplementationInstance != null) + { + return $"instance"; + } + + if (s.ImplementationFactory != null) + { + return $"factory {s.ImplementationFactory.Method.Module.Name} {s.ImplementationFactory}"; + } + + 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() + .Select(x => x.Name) + .Aggregate((x1, x2) => $"{x1}, {x2}"); + var indexOfBacktick = type.Name.IndexOf("`", StringComparison.InvariantCulture); + if (indexOfBacktick == -1) + { + return type.Name; + } + return $"{type.Name.Substring(0, 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 a45a218d..732fa9ec 100644 --- a/dotnet/Vaas/test/Vaas.Test/Vaas.Test.csproj +++ b/dotnet/Vaas/test/Vaas.Test/Vaas.Test.csproj @@ -9,9 +9,10 @@ + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/dotnet/Vaas/test/Vaas.Test/VaasOptionsTest.cs b/dotnet/Vaas/test/Vaas.Test/VaasOptionsTest.cs new file mode 100644 index 00000000..2f279ded --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/VaasOptionsTest.cs @@ -0,0 +1,110 @@ +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 00000000..1c609318 --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/VaasTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Contrib.HttpClient; +using Vaas.Authentication; +using Vaas.Messages; +using Xunit; + +namespace Vaas.Test; + +public class VaasTest +{ + private readonly ChecksumSha256 _maliciousChecksum256 = + new("000005c43196142f01d615a67b7da8a53cb0172f8e9317a2ec9a0a39a1da6fe8"); + + private readonly HttpClient _httpClient; + private readonly Mock _handler; + private readonly Mock _authenticator; + private readonly Vaas _vaas; + + public VaasTest() + { + _handler = new Mock(); + _httpClient = _handler.CreateClient(); + _authenticator = new Mock(); + _vaas = new Vaas(_httpClient, _authenticator.Object, new VaasOptions()); + } + + [Fact] + public async Task ForSha256Async_SendsUserAgent() + { + const string productName = "VaaS_C#_SDK"; + var productVersion = Assembly.GetAssembly(typeof(Vaas))?.GetName().Version?.ToString() ?? "0.0.0"; + _handler.SetupRequest(r => r.Headers.UserAgent.ToString() == $"{productName}/{productVersion}") + .ReturnsResponse(JsonSerializer.Serialize(new VerdictResponse(_maliciousChecksum256, Verdict.Malicious))); + + var verdict = await _vaas.ForSha256Async(_maliciousChecksum256, CancellationToken.None); + + Assert.Equal(Verdict.Malicious, verdict.Verdict); + } + + [Fact] + public void Constructor_IfRelativeUrl_ThrowsVaasClientException() + { + var e = Assert.Throws(() => + new Vaas(_httpClient, _authenticator.Object, new VaasOptions() { Url = new Uri("/relative") })); + Assert.Equal( + "Parameter \"options.Url.Host\" (string) must not be null or whitespace, was whitespace. (Parameter 'options.Url.Host')", + e.Message); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + public async Task ForSha256Async_OnClientError_ThrowsVaasClientException(HttpStatusCode statusCode) + { + _handler.SetupAnyRequest() + .ReturnsResponse(statusCode); + + var e = await Assert.ThrowsAsync(() => + _vaas.ForSha256Async(_maliciousChecksum256, CancellationToken.None)); + Assert.Equal("Client-side error", e.Message); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.GatewayTimeout)] + public async Task ForSha256Async_OnServerError_ThrowsVaasServerError(HttpStatusCode statusCode) + { + _handler.SetupAnyRequest() + .ReturnsResponse(statusCode); + + var e = await Assert.ThrowsAsync(() => + _vaas.ForSha256Async(_maliciousChecksum256, CancellationToken.None)); + Assert.Equal("Server-side error", e.Message); + } + + [Fact] + public async Task ForSha256Async_IfNullIsReturned_ThrowsVaasServerError() + { + _handler.SetupAnyRequest() + .ReturnsResponse("null"); + + var e = await Assert.ThrowsAsync(() => + _vaas.ForSha256Async(_maliciousChecksum256, CancellationToken.None)); + Assert.Equal("Server returned 'null'", e.Message); + } + + [Fact] + public async Task ForSha256Async_OnJsonException_ThrowsVaasServerException() + { + _handler.SetupAnyRequest() + .ReturnsResponse("{"); + + var e = await Assert.ThrowsAsync(() => + _vaas.ForSha256Async(_maliciousChecksum256, CancellationToken.None)); + Assert.Equal("Server-side error", e.Message); + } + + [Fact] + public async Task ForSha256Async_OnSha256Null_ThrowsVaasServerException() + { + _handler.SetupAnyRequest() + .ReturnsResponse("{}"); + + var e = await Assert.ThrowsAsync(() => + _vaas.ForSha256Async(_maliciousChecksum256, CancellationToken.None)); + Assert.Equal("Server-side error", e.Message); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..bcd79bef --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForClientCredentials_ReturnsOptions.snap @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 00000000..fb19e18d --- /dev/null +++ b/dotnet/Vaas/test/Vaas.Test/__snapshots__/VaasOptionsTest.Value_ForPassword_ReturnsOptions.snap @@ -0,0 +1,13 @@ +{ + "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/VaasExample.csproj b/dotnet/examples/VaasExample/VaasExample.csproj index 3afdf5a9..1a5d5681 100644 --- a/dotnet/examples/VaasExample/VaasExample.csproj +++ b/dotnet/examples/VaasExample/VaasExample.csproj @@ -9,7 +9,7 @@ - + diff --git a/golang/vaas/.devcontainer/devcontainer.json b/golang/vaas/.devcontainer/devcontainer.json index 8b6c16b0..394f9256 100644 --- a/golang/vaas/.devcontainer/devcontainer.json +++ b/golang/vaas/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ { "name": "VaaS", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/go:0-1.20-bullseye" + "image": "mcr.microsoft.com/devcontainers/go:0-1.21-bullseye" // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, diff --git a/golang/vaas/README.md b/golang/vaas/README.md index 363cddd1..959a404a 100644 --- a/golang/vaas/README.md +++ b/golang/vaas/README.md @@ -9,36 +9,50 @@ This is a Golang package that provides a client for the G DATA VaaS API. _Verdict-as-a-Service_ (VaaS) is a service that provides a platform for scanning files for malware and other threats. It allows easy integration into your application. With a few lines of code, you can start scanning files for malware. +# Table of Contents + +- [What does the SDK do?](#what-does-the-sdk-do) +- [How to use](#how-to-use) + - [Installation](#installation) + - [Import](#import) + - [Authentication](#authentication) + - [Client Credentials Grant](#client-credentials-grant) + - [Resource Owner Password Grant](#resource-owner-password-grant) + - [Request a verdict](#request-a-verdict) +- [I'm interested in VaaS](#interested) +- [Developing with Visual Studio Code](#developing-with-visual-studio-code) + + ## What does the SDK do? It gives you as a developer functions to talk to G DATA VaaS. It wraps away the complexity of the API into basic functions. -### Connect(token string) error +### Connect(ctx context.Context, auth authenticator.Authenticator) (termChan <-chan error, err error) -Connects to the G DATA VaaS API using the given authentication token. `token` is the authentication token provided by G DATA. If authentication fails, an error will be returned. +Connect opens a websocket connection to the VAAS Server, which is kept open until the context.Context expires. The termChan indicates when a connection was closed. In the case of an unexpected close, an error is written to the channel. -### Authenticate(token string) error +### ForSha256(ctx context.Context, sha256 string) (messages.VaasVerdict, error) -Sends an authentication request to the G DATA VaaS API using the given authentication token. If authentication is successful, the session ID will be stored in the `vaas` object. +Retrieves the verdict for the given SHA256 hash from the G DATA VaaS API. `ctx` is the context for request cancellation, and `sha256` is the SHA256 hash of the file. If the request fails, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. -### ForSha256(sha256 string) (messages.VaasVerdict, error) +### ForFile(ctx context.Context, filePath string) (messages.VaasVerdict, error) -Retrieves the verdict for the given SHA256 hash from the G DATA VaaS API. `sha256` is the SHA256 hash of the file. If the request fails, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. +Retrieves the verdict for the given file at the specified `filePath` from the G DATA VaaS API. `ctx` is the context for request cancellation. If the file cannot be opened, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. -### ForFile(file string) (messages.VaasVerdict, error) +### ForFileInMemory(ctx context.Context, fileData io.Reader) (messages.VaasVerdict, error) -Retrieves the verdict for the given file from the G DATA VaaS API. `file` is the path to the file. If the file cannot be opened, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. +Retrieves the verdict for file data provided as an `io.Reader` to the G DATA VaaS API. `ctx` is the context for request cancellation. If the request fails, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. -### ForUrl(url string) (messages.VaasVerdict, error) +### ForUrl(ctx context.Context, url string) (messages.VaasVerdict, error) -Retrieves the verdict for the given file URL from the G DATA VaaS API. `url` is the path to the file. If the file cannot be opened, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. +Retrieves the verdict for the given file URL from the G DATA VaaS API. `ctx` is the context for request cancellation. If the request fails, an error will be returned. Otherwise, a `messages.VaasVerdict` object containing the verdict will be returned. ## How to use ### Installation ```sh -go get -u github.com/GDATASoftwareAG/vaas/golang/vaas +go get github.com/GDATASoftwareAG/vaas/golang/vaas ``` ### Import @@ -50,56 +64,95 @@ import ( ) ``` +### Authentication + +VaaS offers two authentication methods: + +#### Client Credentials Grant +This is suitable for cases where you have a `client_id`and `client_secret`. Here's how to use it: + +```go +authenticator := authenticator.New("client_id", "client_secret", "token_endpoint") +``` +or +```go +authenticator := authenticator.NewWithDefaultTokenEndpoint("client_id", "client_secret") +``` +#### Resource Owner Password Grant +This method is used when you have a `username` and `password`. Here's how to use it: + +```go +authenticator := authenticator.NewWithResourceOwnerPassword("client_id", "username", "password", "token_endpoint") +``` +If you do not have a specific Client ID, please use `"vaas-customer"` as the client_id. + ### Request a verdict Authentication & Initialization: ```go -authenticator := authenticator.New(CLIENT_ID, CLIENT_SECRET, TOKEN_ENDPOINT) +// Create a new authenticator with the provided Client ID and Client Secret +auth := authenticator.NewWithDefaultTokenEndpoint(clientID, clientSecret) -var accessToken string -if err := authenticator.GetToken(&accessToken); err != nil { - log.Fatal(err) -} +// Create a new VaaS client with default options +vaasClient := vaas.NewWithDefaultEndpoint(options.VaasOptions{ + UseHashLookup: true, + UseCache: false, + EnableLogs: false, +}) -vaasClient := vaas.New(options.VaasOptions{ - UseHashLookup: true, - UseCache: false, -}, VAAS_URL) +// Create a context with a cancellation function +ctx, webSocketCancel := context.WithCancel(context.Background()) -if err := vaasClient.Connect(accessToken); err != nil { - log.Fatal("Something went wrong", err.Error()) +// Establish a WebSocket connection to the VaaS server +termChan, err := vaasClient.Connect(ctx, auth) +if err != nil { + log.Fatalf("failed to connect to VaaS %s", err.Error()) } + +// Create a context with a timeout for the analysis +analysisCtx, analysisCancel := context.WithTimeout(context.Background(), 20*time.Second) +defer analysisCancel() ``` Verdict Request for SHA256: ```go -result, err := vaasClient.ForSha256(sha256) +// Request a verdict for a specific SHA256 hash (replace "sha256-hash" with the actual SHA256 hash) +result, err := vaasClient.ForFile(analysisCtx, "sha256-hash") if err != nil { - return err + log.Fatalf("Failed to get verdict: %v", err) } fmt.Println(result.Verdict) ``` Verdict Request for a file: ```go -result, err := vaasClient.ForFile(file) +// Request a verdict for a specific file (replace "path-to-your-file" with the actual file path) +result, err := vaasClient.ForFile(analysisCtx, "path-to-your-file") if err != nil { - return err + log.Fatalf("Failed to get verdict: %v", err) } -fmt.Println(result.Verdict) +fmt.Printf("Verdict: %s\n", result.Verdict) ``` -Verdict Request for a URL: +Verdict Request for file data provided as an io.Reader: ```go -result, err := vaasClient.ForUrl(url) +fileData := bytes.NewReader([]byte("file contents")) +result, err := vaasClient.ForFileInMemory(analysisCtx, fileData) if err != nil { - return err + log.Fatalf("Failed to get verdict: %v", err) } -fmt.Println(result.Verdict) +fmt.Printf("Verdict: %s\n", result.Verdict) ``` +Verdict Request for a file URL: +```go +result, err := vaasClient.ForUrl(analysisCtx, "https://example.com/examplefile") +if err != nil { + log.Fatalf("Failed to get verdict: %v", err) +} +fmt.Printf("Verdict: %s\n", result.Verdict) +``` -For more details, please refer to the package [documentation](https://pkg.go.dev/github.com/GDATASoftwareAG/vaas/golang/vaas/). ## I'm interested in VaaS diff --git a/ruby/examples/authentication.rb b/ruby/examples/authentication.rb new file mode 100644 index 00000000..33804476 --- /dev/null +++ b/ruby/examples/authentication.rb @@ -0,0 +1,50 @@ +require 'vaas/client_credentials_grant_authenticator' +require 'vaas/resource_owner_password_grant_authenticator' +require 'vaas/vaas_main' +require 'async' + + +def main + client_id = "vaas-customer" + client_secret = ENV.fetch("CLIENT_SECRET") + user_name = ENV.fetch("VAAS_USER_NAME") + password = ENV.fetch("VAAS_PASSWORD") + token_url = ENV.fetch("TOKEN_URL") || "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token" + vaas_url = ENV.fetch("VAAS_URL") || "wss://gateway.production.vaas.gdatasecurity.de" + test_url = "https://gdata.de" + + #If you got a username and password from us, you can use the ResourceOwnerPasswordAuthenticator like this + authenticator = VAAS::ResourceOwnerPasswordGrantAuthenticator.new( + client_id, + user_name, + password, + token_url + ) + # You may use self registration and create a new username and password for the + # ResourceOwnerPasswordAuthenticator by yourself like the example above on https:#vaas.gdata.de/login + + # Else if you got a client id and client secret from us, you can use the ClientCredentialsGrantAuthenticator like this + # authenticator = VAAS::ClientCredentialsGrantAuthenticator.new( + # client_id, + # client_secret, + # token_url + # ) + + + vaas = VAAS::VaasMain.new(vaas_url) + token = authenticator.get_token + + Async do + vaas.connect(token) + + verdict = vaas.for_url(test_url) + puts "Verdict #{verdict.wait.sha256} is detected as #{verdict.wait.verdict}" + + vaas.close + end +end + + +if __FILE__ == $0 + main +end \ No newline at end of file diff --git a/ruby/lib/vaas/client_credentials_grant_authenticator.rb b/ruby/lib/vaas/client_credentials_grant_authenticator.rb index 93a90791..3d0a5d3f 100644 --- a/ruby/lib/vaas/client_credentials_grant_authenticator.rb +++ b/ruby/lib/vaas/client_credentials_grant_authenticator.rb @@ -7,7 +7,7 @@ class ClientCredentialsGrantAuthenticator attr_accessor :client_id, :client_secret, :token_endpoint, :token - def initialize(client_id, client_secret, token_endpoint) + def initialize(client_id, client_secret, token_endpoint = 'https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token') @client_id = client_id @client_secret = client_secret @token_endpoint = token_endpoint @@ -27,6 +27,8 @@ def get_token ensure client&.close end + + raise VaasAuthenticationError if token.nil? token end end diff --git a/ruby/lib/vaas/resource_owner_password_grant_authenticator.rb b/ruby/lib/vaas/resource_owner_password_grant_authenticator.rb new file mode 100644 index 00000000..595551f4 --- /dev/null +++ b/ruby/lib/vaas/resource_owner_password_grant_authenticator.rb @@ -0,0 +1,36 @@ +require 'json' +require 'async' +require 'async/http/internet' + +module VAAS + class ResourceOwnerPasswordGrantAuthenticator + + attr_accessor :client_id, :token_endpoint, :token, :username, :password + + def initialize(client_id, username, password, token_endpoint = 'https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token') + @client_id = client_id + @username = username + @password = password + @token_endpoint = token_endpoint + end + + def get_token + Async do + client = Async::HTTP::Internet.new + + header = [['content-type', 'application/x-www-form-urlencoded']] + body = ["grant_type=password&client_id=#{client_id}&username=#{username}&password=#{password}"] + + response = client.post(token_endpoint, header, body) + self.token = JSON.parse(response.read)['access_token'] + rescue => e + raise VaasAuthenticationError, e + ensure + client&.close + end + + raise VaasAuthenticationError if token.nil? + token + end + end +end diff --git a/ruby/test/vaas_test.rb b/ruby/test/vaas_test.rb index abf20492..d160e10f 100644 --- a/ruby/test/vaas_test.rb +++ b/ruby/test/vaas_test.rb @@ -3,6 +3,7 @@ require 'async' require_relative '../lib/vaas/client_credentials_grant_authenticator' +require_relative '../lib/vaas/resource_owner_password_grant_authenticator' require_relative '../lib/vaas/vaas_main' # # test locally with .env file (comment this when push) @@ -18,6 +19,8 @@ CLIENT_SECRET = ENV.fetch('CLIENT_SECRET') TOKEN_URL = ENV.fetch('TOKEN_URL') VAAS_URL = ENV.fetch('VAAS_URL') +USER_NAME = ENV.fetch('VAAS_USER_NAME') +PASSWORD = ENV.fetch('VAAS_PASSWORD') class VaasTest < Minitest::Test TEST_CLASS = self @@ -105,6 +108,18 @@ def create(token = nil, timeout = nil) vaas.close end end + + specify 'authenticate' do + authenticator = VAAS::ResourceOwnerPasswordGrantAuthenticator.new( + "vaas-customer", + USER_NAME, + PASSWORD, + TOKEN_URL + ) + + token = authenticator.get_token + refute_nil token + end end describe 'fail' do diff --git a/ruby/vaas.gemspec b/ruby/vaas.gemspec index 23c101fa..1d13203a 100644 --- a/ruby/vaas.gemspec +++ b/ruby/vaas.gemspec @@ -13,6 +13,7 @@ Gem::Specification.new do |s| s.license = "MIT" s.require_paths = ["lib"] s.metadata = { "documentation_uri" => "https://github.com/GDATASoftwareAG/vaas/blob/main/ruby/README.md" } + s.required_ruby_version = '>= 3.1.1' s.add_dependency 'async', '~> 2.3.1' s.add_dependency 'async-http', '~> 0.59.4' diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 53ddc4b3..0878f764 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -26,4 +26,4 @@ async-trait = "0.1" [dev-dependencies] dotenv = "0.15" -tokio = { version = "1.33", features = ["rt", "macros", "rt-multi-thread"] } +tokio = { version = "1.33", features = ["rt", "macros", "rt-multi-thread"] } \ No newline at end of file diff --git a/rust/examples/gscan/Cargo.toml b/rust/examples/gscan/Cargo.toml index 36b61fd1..a5ee8587 100644 --- a/rust/examples/gscan/Cargo.toml +++ b/rust/examples/gscan/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] vaas = { version = "3.0.2" } -tokio = { version = "1.32", features = [ "rt-multi-thread", "macros"] } +tokio = { version = "1.33", features = [ "rt-multi-thread", "macros"] } clap = { version = "4.4", features = ["env"]} reqwest = "0.11" futures = "0.3" diff --git a/rust/examples/kde_dolphin/Cargo.toml b/rust/examples/kde_dolphin/Cargo.toml index 16af46dd..424618c1 100644 --- a/rust/examples/kde_dolphin/Cargo.toml +++ b/rust/examples/kde_dolphin/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] slint = "1.2" vaas = { path = "../.."} -tokio = { version = "1.32", features = ["rt", "macros", "rt-multi-thread"] } +tokio = { version = "1.33", features = ["rt", "macros", "rt-multi-thread"] } structopt = "0.3" [build-dependencies]