diff --git a/.github/workflows/ci-golang.yaml b/.github/workflows/ci-golang.yaml index 3458fdc5..96af7f3d 100644 --- a/.github/workflows/ci-golang.yaml +++ b/.github/workflows/ci-golang.yaml @@ -62,17 +62,31 @@ jobs: image: golang:latest strategy: matrix: - version-directory: ["./", "v2/"] + version-directory: ["./", "v2/", "v3/"] steps: - uses: actions/checkout@v4 + - name: set legacy vaas gateway for production + run: | + if [ "${{ matrix.version-directory }}" = "./" -o "${{ matrix.version-directory }}" = "v2/" ]; then + echo "VAAS_URL=wss://gateway.production.vaas.gdatasecurity.de" >> $GITHUB_ENV + else + echo "VAAS_URL=https://gateway.production.vaas.gdatasecurity.de" >> $GITHUB_ENV + fi + + - name: set staging environment if: (inputs.environment == 'staging' || (startsWith(github.ref, 'refs/tags') && endsWith(github.ref, '-beta'))) run: | + echo "Beta version: Testing against staging" 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 "TOKEN_URL=https://account-staging.gdata.de/realms/vaas-staging/protocol/openid-connect/token" >> $GITHUB_ENV + if [ "${{ matrix.version-directory }}" = "./" -o "${{ matrix.version-directory }}" = "v2/" ]; then + echo "VAAS_URL=wss://gateway.staging.vaas.gdatasecurity.de" >> $GITHUB_ENV + else + echo "VAAS_URL=https://gateway.staging.vaas.gdatasecurity.de" >> $GITHUB_ENV + fi echo "VAAS_CLIENT_ID=${{ secrets.STAGING_VAAS_CLIENT_ID }}" >> $GITHUB_ENV echo "VAAS_USER_NAME=${{ secrets.STAGING_VAAS_USER_NAME }}" >> $GITHUB_ENV echo "VAAS_PASSWORD=${{ secrets.STAGING_VAAS_PASSWORD }}" >> $GITHUB_ENV @@ -80,10 +94,15 @@ jobs: - name: set develop environment if: (inputs.environment == 'develop' || (startsWith(github.ref, 'refs/tags') && endsWith(github.ref, '-alpha'))) run: | + echo "Alpha version: Testing against develop" 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 "TOKEN_URL=https://account-staging.gdata.de/realms/vaas-develop/protocol/openid-connect/token" >> $GITHUB_ENV + if [ "${{ matrix.version-directory }}" = "./" -o "${{ matrix.version-directory }}" = "v2/" ]; then + echo "VAAS_URL=wss://gateway.develop.vaas.gdatasecurity.de" >> $GITHUB_ENV + else + echo "VAAS_URL=https://gateway.develop.vaas.gdatasecurity.de" >> $GITHUB_ENV + fi echo "VAAS_CLIENT_ID=${{ secrets.DEVELOP_VAAS_CLIENT_ID }}" >> $GITHUB_ENV echo "VAAS_USER_NAME=${{ secrets.DEVELOP_VAAS_USER_NAME }}" >> $GITHUB_ENV echo "VAAS_PASSWORD=${{ secrets.DEVELOP_VAAS_PASSWORD }}" >> $GITHUB_ENV @@ -141,7 +160,7 @@ jobs: image: golang:latest strategy: matrix: - version-directory: [".", "v2"] + version-directory: [".", "v2", "v3"] steps: - uses: actions/checkout@v4 - name: Install govulncheck @@ -176,7 +195,7 @@ jobs: MAJOR_VERSION: ${{ needs.extract-major-version.outputs.major_version }} if: startsWith(github.ref, 'refs/tags') run: | - if [ "$MAJOR_VERSION" == "v1" ]; then + if [ "$MAJOR_VERSION" = "v1" ]; then GOPROXY=proxy.golang.org go list -m github.com/GDATASoftwareAG/vaas/golang/vaas@${GITHUB_REF#refs/tags/golang/vaas/} else GOPROXY=proxy.golang.org go list -m github.com/GDATASoftwareAG/vaas/golang/vaas/${MAJOR_VERSION}@${GITHUB_REF#refs/tags/golang/vaas/} diff --git a/golang/vaas/v3/README.md b/golang/vaas/v3/README.md new file mode 100644 index 00000000..865747d4 --- /dev/null +++ b/golang/vaas/v3/README.md @@ -0,0 +1,164 @@ +[![vaas-golang-ci](https://github.com/GDATASoftwareAG/vaas/actions/workflows/ci-golang.yaml/badge.svg)](https://github.com/GDATASoftwareAG/vaas/actions/workflows/ci-golang.yaml) +[![Vulnerability Check](https://github.com/GDATASoftwareAG/vaas/actions/workflows/vulncheck-golang.yml/badge.svg)](https://github.com/GDATASoftwareAG/vaas/actions/workflows/vulncheck-golang.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/GDATASoftwareAG/vaas/golang/vaas/.svg)](https://pkg.go.dev/github.com/GDATASoftwareAG/vaas/golang/vaas/) +[![Go Report Card](https://goreportcard.com/badge/github.com/GDATASoftwareAG/vaas/golang/vaas)](https://goreportcard.com/report/github.com/GDATASoftwareAG/vaas/golang/vaas) + +# Go VaaS Client + +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(ctx context.Context, auth authenticator.Authenticator) (errorChan <-chan error, err error) + +Connect opens a websocket connection to the VAAS Server. Use Close() to terminate the connection. The errorChan indicates when a connection was closed. In the case of an unexpected close, an error is written to the channel. + +### ForSha256(ctx context.Context, sha256 string) (messages.VaasVerdict, error) + +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. + +### ForFile(ctx context.Context, filePath string) (messages.VaasVerdict, error) + +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. + +### ForFileInMemory(ctx context.Context, fileData io.Reader) (messages.VaasVerdict, error) + +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(ctx context.Context, url string) (messages.VaasVerdict, error) + +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 github.com/GDATASoftwareAG/vaas/golang/vaas +``` + +### Import + +```go +import ( + "github.com/GDATASoftwareAG/vaas/golang/vaas/pkg/authenticator" + "github.com/GDATASoftwareAG/vaas/golang/vaas/pkg/vaas" +) +``` + +### 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 +// Create a new authenticator with the provided Client ID and Client Secret +auth := authenticator.NewWithDefaultTokenEndpoint(clientID, clientSecret) + +// Create a new VaaS client with default options +vaasClient := vaas.NewWithDefaultEndpoint(options.VaasOptions{ + UseHashLookup: true, + UseCache: false, + EnableLogs: false, +}) + +// Create a context with a cancellation function +ctx, webSocketCancel := context.WithCancel(context.Background()) + +// Establish a WebSocket connection to the VaaS server +errorChan, err := vaasClient.Connect(ctx, auth) +if err != nil { + log.Fatalf("failed to connect to VaaS %s", err.Error()) +} +defer vaasClient.Close() + +// 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 +// 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 { + log.Fatalf("Failed to get verdict: %v", err) +} +fmt.Println(result.Verdict) +``` + +Verdict Request for a file: +```go +// 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 { + log.Fatalf("Failed to get verdict: %v", err) +} +fmt.Printf("Verdict: %s\n", result.Verdict) +``` + +Verdict Request for file data provided as an io.Reader: +```go +fileData := bytes.NewReader([]byte("file contents")) +result, err := vaasClient.ForFileInMemory(analysisCtx, fileData) +if err != nil { + log.Fatalf("Failed to get verdict: %v", err) +} +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) +``` + + +## 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). + +## Developing with Visual Studio Code + +Every single SDKs also includes [Devcontainer](./.devcontainer/). If you use the [Visual Studio Code Dev Containers extension](https://code.visualstudio.com/docs/devcontainers/containers), you can run the code in a full-featured development environment. diff --git a/golang/vaas/v3/cmd/git-scan/main.go b/golang/vaas/v3/cmd/git-scan/main.go new file mode 100644 index 00000000..73fdd991 --- /dev/null +++ b/golang/vaas/v3/cmd/git-scan/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "errors" + "log" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/authenticator" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/messages" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/vaas" +) + +func main() { + if len(os.Args) < 3 { + log.Fatal("need 2 parameter: remote, targetBranch") + } + + remote := os.Args[1] + if remote == "" { + log.Fatal("no remote set") + } + log.Println("remote:", remote) + targetBranch := os.Args[2] + if targetBranch == "" { + log.Fatal("no targetBranch set") + } + log.Println("targetBranch:", targetBranch) + + vaasAuthenticator, credentialsError := getAuthenticator( + os.Getenv("VAAS_CLIENT_ID"), os.Getenv("VAAS_CLIENT_SECRET"), os.Getenv("VAAS_USERAME"), os.Getenv("VAAS_PASSWORD")) + if credentialsError != nil { + log.Fatal(credentialsError) + } + + vaasURLString, exists := os.LookupEnv("VAAS_URL") + if !exists { + vaasURLString = "https://gateway.production.vaas.gdatasecurity.de" + } + vaasURL, err := url.Parse(vaasURLString) + if err != nil { + log.Fatal("VAAS_URL is not an URL") + } + log.Println("vaas url:", vaasURL) + + gitRevParseCommand := exec.Command("git", "rev-parse", "--show-toplevel") + rootDirectoryBytes, err := gitRevParseCommand.CombinedOutput() + if err != nil { + log.Fatal("git rev-parse: ", err, " ", string(rootDirectoryBytes)) + } + rootDirectory := strings.Split(strings.ReplaceAll(string(rootDirectoryBytes), "\r\n", "\n"), "\n")[0] + log.Println("repository root directory: ", rootDirectory) + + fetchBytesCommand := exec.Command("git", "fetch", remote, targetBranch) + fetchBytes, err := fetchBytesCommand.CombinedOutput() + if err != nil { + log.Fatal("git fetch ", err, " ", string(fetchBytes)) + } + log.Println("fetch result: ", string(fetchBytes)) + + gitDiffCommand := exec.Command("git", "diff", "--name-only", remote+"/"+targetBranch) + diffBytes, err := gitDiffCommand.CombinedOutput() + if err != nil { + log.Fatal("git diff ", err, " ", string(diffBytes)) + } + files := strings.Split(strings.ReplaceAll(string(diffBytes), "\r\n", "\n"), "\n") + if len(files) < 1 { + log.Println("no changed files found in diff") + os.Exit(0) + } + + vaas := vaas.New(vaasURL, vaasAuthenticator) + ctx, webSocketCancel := context.WithCancel(context.Background()) + var maliciousFileFound bool + for _, file := range files { + if file == "" { + continue + } + if _, err := os.Stat(file); err != nil { + continue + } + log.Println("checking file: ", file) + pathToFile := filepath.Join(rootDirectory, file) + verdict, err := vaas.ForFile(ctx, pathToFile, nil) + if err != nil { + log.Fatalln(err) + } + log.Println(pathToFile + ": " + string(verdict.Verdict)) + if verdict.Verdict == messages.Malicious { + maliciousFileFound = true + } + } + webSocketCancel() + if maliciousFileFound { + os.Exit(1) + } +} + +func getAuthenticator(clientId, clientSecret, username, password string) (vaasAuthenticator authenticator.Authenticator, credentialsError error) { + tokenUrl, exists := os.LookupEnv("VAAS_TOKEN_URL") + if !exists { + tokenUrl = "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token" + } + log.Println("token url:", tokenUrl) + + if (clientId != "" && clientSecret != "") || (username != "" && password != "") { + if username != "" && password != "" { + vaasAuthenticator = authenticator.NewWithResourceOwnerPassword(username, password, "vaas-github-actions", tokenUrl) + } else { + vaasAuthenticator = authenticator.New(clientId, clientSecret, tokenUrl) + } + + return + + } + return nil, errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD") + +} diff --git a/golang/vaas/v3/cmd/git-scan/main_test.go b/golang/vaas/v3/cmd/git-scan/main_test.go new file mode 100644 index 00000000..947f835b --- /dev/null +++ b/golang/vaas/v3/cmd/git-scan/main_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "errors" + "fmt" + "reflect" + "testing" +) + +func TestDoubleMe(t *testing.T) { + table := []struct { + name string + clientId string + clientSecret string + username string + password string + exprectedError error + expectedAuthrenticator string + }{ + {"no credentials set", + "", "", "", "", + errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD"), ""}, + {"client_id set but client_secret empty", + "client_id", "", "", "", + errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD"), ""}, + {"client_secret set but client_id empty", + "", "client_secret", "", "", + errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD"), ""}, + {"username set but password empty", + "", "", "username", "", + errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD"), ""}, + {"password set but username empty", + "", "", "", "password", + errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD"), ""}, + {"only client_id and username set", + "client_id", "", "username", "", + errors.New("you either need VAAS_CLIENT_ID and VAAS_CLIENT_SECRET or VAAS_USERAME and VAAS_PASSWORD"), ""}, + {"client_credentials set", + "client_id", "client_secret", "", "", + nil, "*authenticator.commonOIDCAuthenticator"}, + {"client_credentials and username set", + "client_id", "client_secret", "username", "", + nil, "*authenticator.commonOIDCAuthenticator"}, + {"username and password set", + "", "", "username", "password", + nil, "*authenticator.commonOIDCAuthenticator"}, + {"username and password and client_id set", + "client_id", "", "username", "password", + nil, "*authenticator.commonOIDCAuthenticator"}, + } + + for _, tc := range table { + t.Run(tc.name, func(t *testing.T) { + vaasAuthenticator, credentialsError := getAuthenticator(tc.clientId, tc.clientSecret, tc.username, tc.password) + + if reflect.TypeOf(credentialsError) != reflect.TypeOf(tc.exprectedError) { + t.Errorf("Expected error to be %s, but got %s", tc.exprectedError, credentialsError) + } + + typestring := fmt.Sprint(reflect.TypeOf(vaasAuthenticator)) + if typestring != tc.expectedAuthrenticator { + t.Errorf("Expected type %s, but got %s", tc.expectedAuthrenticator, typestring) + } + }) + } +} diff --git a/golang/vaas/v3/examples/file-verdict-request/main.go b/golang/vaas/v3/examples/file-verdict-request/main.go new file mode 100644 index 00000000..d8a2a2d7 --- /dev/null +++ b/golang/vaas/v3/examples/file-verdict-request/main.go @@ -0,0 +1,68 @@ +// package main implements a simple example of how to +// request a verdict for a file from a VaaS (Verdict as a Service) server using Go. +package main + +import ( + "context" + "fmt" + "log" + "net/url" + "os" + "time" + + "github.com/joho/godotenv" + + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/authenticator" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/vaas" +) + +func main() { + // Load environment variables from a .env file (if it exists) + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + // Retrieve the Client ID and Client Secret from environment variables + clientID, exists := os.LookupEnv("CLIENT_ID") + if !exists { + log.Fatal("no Client ID set") + } + clientSecret, exists := os.LookupEnv("CLIENT_SECRET") + if !exists { + log.Fatal("no Client Secret set") + } + tokenEndpoint, exists := os.LookupEnv("TOKEN_URL") + if !exists { + tokenEndpoint = "https://account-staging.gdata.de/realms/vaas-staging/protocol/openid-connect/token" + } + vaasURLString, exists := os.LookupEnv("VAAS_URL") + if !exists { + vaasURLString = "https://gateway.staging.vaas.gdatasecurity.de" + } + vaasURL, err := url.Parse(vaasURLString) + if err != nil { + log.Fatal("VAAS_URL is not an URL") + } + + scanPath, exists := os.LookupEnv("SCAN_PATH") + if !exists { + scanPath = "README.md" + } + + // Create a new authenticator with the provided Client ID and Client Secret + auth := authenticator.New(clientID, clientSecret, tokenEndpoint) + + // Create a new VaaS client with default options + vaasClient := vaas.New(vaasURL, auth) + + // Create a context with a timeout for the analysis + analysisCtx, analysisCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer analysisCancel() + + // Request a verdict for a specific file + result, err := vaasClient.ForFile(analysisCtx, scanPath, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println(result.Verdict) +} diff --git a/golang/vaas/v3/examples/vaasctl/README.md b/golang/vaas/v3/examples/vaasctl/README.md new file mode 100644 index 00000000..63735f66 --- /dev/null +++ b/golang/vaas/v3/examples/vaasctl/README.md @@ -0,0 +1,60 @@ +# VaaS Command Line Interface (CLI) + +This Go programm is an example implementation of a Command Line Interface (CLI) for the G DATA VaaS API. This program allows users to scan URLs, SHA256 hashes and files. + +## Installation + +1. Clone the repository: `git clone https://github.com/GDATASoftwareAG/vaas.git` +2. Change directory into the `vaas/golang/vaas/cmd/vaasctl` directory +3. Build the program: `go build` +4. Run the program: `./vaas` + +Note: Before running the program, please make sure to fill out the required credentials in the environment or `.env` file. + +## Usage + +### Command Line Arguments + +The following command line arguments are supported: + ++ `-s`: Check one ore multiple SHA256 hashes. ++ `-f`: Check one or multiple files. ++ `-u`: Check one or multiple URLs. + +### Example Usage + +To check a file: + +``` bash +./vaas -f file_to_check +``` + +To check multiple files: + +``` bash +./vaas -f file1_to_check file2_to_check file3_to_check +``` + +To check a URL: + +``` bash +./vaas -u url_to_check +``` + +To check multiple URLs: + +``` bash +./vaas -f url1_to_check url2_to_check url3_to_check +``` + +To check a SHA256 hash: + +``` bash +./vaas -f sha256_to_check +``` + +To check multiple SHA256 hashes: + +``` bash +./vaas -f sha2561_to_check sha2562_to_check sha2563_to_check +``` \ No newline at end of file diff --git a/golang/vaas/v3/examples/vaasctl/vaas.go b/golang/vaas/v3/examples/vaasctl/vaas.go new file mode 100644 index 00000000..e4a2a828 --- /dev/null +++ b/golang/vaas/v3/examples/vaasctl/vaas.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/joho/godotenv" + "log" + "net/url" + "os" + "sync" + "time" + + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/authenticator" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/vaas" +) + +var sha256Check = flag.Bool("s", false, "sha256") +var fileCheck = flag.Bool("f", false, "file") +var urlCheck = flag.Bool("u", false, "url") + +func main() { + flag.Parse() + + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + clientID, exists := os.LookupEnv("CLIENT_ID") + if !exists { + log.Fatal("no Client ID set") + } + clientSecret, exists := os.LookupEnv("CLIENT_SECRET") + if !exists { + log.Fatal("no Client Secret set") + } + tokenEndpoint, exists := os.LookupEnv("TOKEN_URL") + if !exists { + tokenEndpoint = "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token" + } + vaasURLString, exists := os.LookupEnv("VAAS_URL") + if !exists { + vaasURLString = "wss://gateway.production.vaas.gdatasecurity.de" + } + vaasURL, err := url.Parse(vaasURLString) + if err != nil { + log.Fatal("VAAS_URL is not an URL") + } + + auth := authenticator.New(clientID, clientSecret, tokenEndpoint) + + vaasClient := vaas.New(vaasURL, auth) + + analysisCtx, analysisCancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer analysisCancel() + + if *sha256Check { + sha256List := flag.Args() + if err := checkSha256(analysisCtx, sha256List, vaasClient); err != nil { + log.Fatal(err) + } + } + + if *urlCheck { + urlList := flag.Args() + if err := checkURL(analysisCtx, urlList, vaasClient); err != nil { + log.Fatal(err) + } + } + + if *fileCheck { + fileList := flag.Args() + if err := checkFile(analysisCtx, fileList, vaasClient); err != nil { + log.Fatal(err) + } + } +} + +func checkFile(ctx context.Context, fileList []string, vaasClient vaas.Vaas) error { + if len(fileList) == 0 { + log.Fatal("no file entered in arguments") + } + + for _, file := range fileList { + result, err := vaasClient.ForFile(ctx, file, nil) + if err != nil { + log.Printf("%s: %s", file, err.Error()) + continue + } + fmt.Println(file, result.Sha256, result.Verdict, result.Detection) + } + return nil +} + +func checkSha256(ctx context.Context, sha256List []string, vaasClient vaas.Vaas) error { + if len(sha256List) == 0 { + log.Fatal("no sha256 entered in arguments") + } + for _, sha256 := range sha256List { + result, err := vaasClient.ForSha256(ctx, sha256, nil) + if err != nil { + log.Printf("%s: %s", sha256, err.Error()) + continue + } + fmt.Println(sha256, result.Verdict) + } + + return nil +} + +func checkURL(ctx context.Context, urlList []string, vaasClient vaas.Vaas) error { + if len(urlList) == 0 { + log.Fatal("no url entered in arguments") + } + + if len(urlList) == 1 { + checkUrl, err := url.Parse(urlList[0]) + if err != nil { + return err + } + result, err := vaasClient.ForUrl(ctx, checkUrl, nil) + if err != nil { + return err + } + fmt.Println(result.Verdict) + + } else if len(urlList) > 1 { + var waitGroup sync.WaitGroup + for _, u := range urlList { + waitGroup.Add(1) + checkUrl, err := url.Parse(u) + if err != nil { + return err + } + go func(checkUrl *url.URL) { + defer waitGroup.Done() + result, err := vaasClient.ForUrl(ctx, checkUrl, nil) + if err != nil { + fmt.Println(err) + } else { + fmt.Println(result) + } + }(checkUrl) + } + waitGroup.Wait() + } + return nil +} diff --git a/golang/vaas/v3/git-scan.Dockerfile b/golang/vaas/v3/git-scan.Dockerfile new file mode 100644 index 00000000..93d74bb9 --- /dev/null +++ b/golang/vaas/v3/git-scan.Dockerfile @@ -0,0 +1,13 @@ +FROM ubuntu:24.04 as runner + +RUN apt update && apt install -y git +WORKDIR /app + +FROM golang:1.23 as builder + +COPY . . +RUN go build -o /build/git-scan cmd/git-scan/main.go + +FROM runner +COPY --from=builder /build/git-scan /app/git-scan +ENTRYPOINT ["/app/git-scan"] \ No newline at end of file diff --git a/golang/vaas/v3/go.mod b/golang/vaas/v3/go.mod new file mode 100644 index 00000000..0bd238db --- /dev/null +++ b/golang/vaas/v3/go.mod @@ -0,0 +1,23 @@ +module github.com/GDATASoftwareAG/vaas/golang/vaas/v3 + +go 1.21 + +require ( + github.com/go-playground/validator/v10 v10.23.0 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/golang/vaas/v3/go.sum b/golang/vaas/v3/go.sum new file mode 100644 index 00000000..785e02d1 --- /dev/null +++ b/golang/vaas/v3/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/golang/vaas/v3/internal/hash/hash.go b/golang/vaas/v3/internal/hash/hash.go new file mode 100644 index 00000000..f4df7351 --- /dev/null +++ b/golang/vaas/v3/internal/hash/hash.go @@ -0,0 +1,18 @@ +// Package hash provides utility functions for hashing data using the SHA-256 algorithm. +package hash + +import ( + "crypto/sha256" + "fmt" + "io" +) + +// CalculateSha256 calculates the SHA-256 hash of the data from the given io.Reader. +// It returns the hexadecimal representation of the hash and any error encountered during the process. +func CalculateSha256(data io.Reader) (string, error) { + sha := sha256.New() + if _, err := io.Copy(sha, data); err != nil { + return "", err + } + return fmt.Sprintf("%x", sha.Sum(nil)), nil +} diff --git a/golang/vaas/v3/pkg/authenticator/authenticator.go b/golang/vaas/v3/pkg/authenticator/authenticator.go new file mode 100644 index 00000000..8fade34e --- /dev/null +++ b/golang/vaas/v3/pkg/authenticator/authenticator.go @@ -0,0 +1,202 @@ +// Package authenticator provides a set of implementations for obtaining authentication tokens +// for G DATA CyberDefense's Verdict as a Service (VaaS) using different grant types. +// +// # Overview +// +// VaaS (Verdict as a Service) is a service that allows clients to obtain verdicts and security information +// about files and URLs. To access VaaS, clients need to authenticate themselves by obtaining an access token. +// +// This package offers two primary implementations of the Authenticator interface: +// +// 1. clientCredentialsGrantAuthenticator: This implementation follows the Client Credentials Grant flow, +// suitable for machine-to-machine communication where the client directly authenticates with its credentials. +// To use this grant type, you need a client ID and a client secret. +// +// 2. resourceOwnerPasswordGrantAuthenticator: This implementation follows the Resource Owner Password Grant flow, +// suitable for scenarios where the client has access to the user's credentials and can authenticate on their behalf. +// To use this grant type, you need a client ID, username, and password. +// +// # Usage +// +// To use this package, you typically follow these steps: +// +// 1. Choose the appropriate grant type based on your use case: +// - Use `New` or `NewWithDefaultTokenEndpoint` for the Client Credentials Grant. +// - Use `NewWithResourceOwnerPassword` for the Resource Owner Password Grant. +// +// 2. Initialize an authenticator with the required parameters, such as client ID, client secret, username, and password. +// +// 3. Call the `GetToken` method to obtain an authentication token. +// +// Example: +// +// clientID := "your-client-id" +// clientSecret := "your-client-secret" +// tokenEndpoint := "https://example.com/token-endpoint" +// +// // Create an authenticator for the Client Credentials Grant +// auth := authenticator.New(clientID, clientSecret, tokenEndpoint) +// +// // Obtain an authentication token +// token, err := auth.GetToken() +// if err != nil { +// log.Fatal(err) +// } +// +// // Use the obtained token for accessing VaaS services. +// +// Note: Make sure to keep your client credentials secure, and use the appropriate grant type for your use case. +// +// For more information about VaaS and the different grant types, refer to the official G DATA CyberDefense VaaS documentation. +package authenticator + +import ( + "bytes" + "encoding/json" + "fmt" + "sync" + + "net/http" + "net/url" + "time" + + msg "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/messages" +) + +// Authenticator represents the interface for obtaining an authentication token using client credentials. +type Authenticator interface { + GetToken() (string, error) +} + +// commonOIDCAuthenticator implements the Authenticator, supporting both the clientCredentials and +// resourceOwnerPassword flows. +type commonOIDCAuthenticator struct { + httpClient *http.Client + tokenEndpoint string + parameters url.Values + token *cachedToken + tokenLock sync.Mutex +} + +// cachedToken is a cached access token that may be reused +type cachedToken struct { + accessToken string + expires time.Time +} + +// ShouldRefresh determines whether this token should be replaced by a newer one +func (c cachedToken) ShouldRefresh() bool { + return time.Now().After(c.expires) +} + +func parametersForClientCredentials(clientID, clientSecret string) url.Values { + data := url.Values{} + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("grant_type", "client_credentials") + return data +} + +func parametersForResourceOwnerPassword(clientID, username, password string) url.Values { + data := url.Values{} + data.Set("client_id", clientID) + data.Set("username", username) + data.Set("password", password) + data.Set("grant_type", "password") + return data +} + +// New creates a new instance of the clientCredentialsGrantAuthenticator. +// It requires the client ID, client secret, and token endpoint URL as arguments. +// Example usage: +// +// clientID := "your-client-id" +// clientSecret := "your-client-secret" +// tokenEndpoint := "https://example.com/token-endpoint" +// +// auth := authenticator.New(clientID, clientSecret, tokenEndpoint) +// token, err := auth.GetToken() +// if err != nil { +// log.Fatal(err) +// } +func New(clientID string, clientSecret string, tokenEndpoint string) Authenticator { + return &commonOIDCAuthenticator{ + tokenEndpoint: tokenEndpoint, + httpClient: &http.Client{Timeout: 120 * time.Second}, + parameters: parametersForClientCredentials(clientID, clientSecret), + } +} + +// NewWithDefaultTokenEndpoint creates a new instance of the clientCredentialsGrantAuthenticator with a default token endpoint. +// It requires the client ID and client secret as arguments. +// Example usage: +// +// clientID := "your-client-id" +// clientSecret := "your-client-secret" +// tokenEndpoint := "https://example.com/token-endpoint" +// +// auth := authenticator.NewWithDefaultTokenEndpoint(clientID, clientSecret) +// token, err := auth.GetToken() +// if err != nil { +// log.Fatal(err) +// } +func NewWithDefaultTokenEndpoint(clientID string, clientSecret string) Authenticator { + return New(clientID, clientSecret, "https://account.gdata.de/realms/vaas-production/protocol/openid-connect/token") +} + +// GetToken obtains an authentication token using the configured authentication flow. +// It returns the obtained token and any error encountered during the process. +func (c *commonOIDCAuthenticator) GetToken() (string, error) { + c.tokenLock.Lock() + defer c.tokenLock.Unlock() + + if c.token == nil || c.token.ShouldRefresh() { + // New token + response, err := c.httpClient.Post(c.tokenEndpoint, "application/x-www-form-urlencoded", bytes.NewReader([]byte(c.parameters.Encode()))) + if err != nil { + return "", err + } + + if response.StatusCode != 200 { + var tokenErrResponse msg.TokenErrorResponse + if err := json.NewDecoder(response.Body).Decode(&tokenErrResponse); err != nil { + return "", fmt.Errorf("http request failed: %s", response.Status) + } + return "", fmt.Errorf(tokenErrResponse.Error + ": " + tokenErrResponse.ErrorDescription) + } + + var tokenResponse msg.TokenResponse + if err = json.NewDecoder(response.Body).Decode(&tokenResponse); err != nil { + return "", err + } + + c.token = &cachedToken{ + accessToken: tokenResponse.Accesstoken, + expires: time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second), + } + } + return c.token.accessToken, nil +} + +// NewWithResourceOwnerPassword creates a new instance of the resourceOwnerPasswordGrantAuthenticator. +// It requires the client ID, username, password, and token endpoint URL as arguments. +// Example usage: +// +// clientID := "your-client-id" +// username := "your-username" +// password := "your-password" +// tokenEndpoint := "https://example.com/token-endpoint" +// +// auth := authenticator.NewWithResourceOwnerPassword(clientID, username, password, tokenEndpoint) +// token, err := auth.GetToken() +// +// if err != nil { +// log.Fatal(err) +// } +func NewWithResourceOwnerPassword(clientID string, username string, password string, tokenEndpoint string) Authenticator { + return &commonOIDCAuthenticator{ + tokenEndpoint: tokenEndpoint, + httpClient: &http.Client{Timeout: 120 * time.Second}, + parameters: parametersForResourceOwnerPassword(clientID, username, password), + } +} diff --git a/golang/vaas/v3/pkg/authenticator/authenticator_test.go b/golang/vaas/v3/pkg/authenticator/authenticator_test.go new file mode 100644 index 00000000..19b343f8 --- /dev/null +++ b/golang/vaas/v3/pkg/authenticator/authenticator_test.go @@ -0,0 +1,215 @@ +package authenticator + +import ( + "log" + "os" + "testing" + + "github.com/joho/godotenv" +) + +func Test_clientCredentialsGrantAuthenticator_GetToken(t *testing.T) { + type args struct { + clientID string + clientSecret string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "With valid credentials - got token", + args: func() args { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + clientID, exists := os.LookupEnv("CLIENT_ID") + if !exists { + log.Fatal("no Client ID set") + } + clientSecret, exists := os.LookupEnv("CLIENT_SECRET") + if !exists { + log.Fatal("no Client Secret set") + } + + return args{ + clientID: clientID, + clientSecret: clientSecret, + } + }(), + wantErr: false, + }, + { + name: "With invalid credentials - error", + args: func() args { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + return args{ + clientID: "foo", + clientSecret: "bar", + } + }(), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenEndpoint, exists := os.LookupEnv("TOKEN_URL") + if !exists { + log.Fatal("no token endpoint configured") + } + + authenticator := New(tt.args.clientID, tt.args.clientSecret, tokenEndpoint) + accessToken, err := authenticator.GetToken() + + if (err != nil) != tt.wantErr { + t.Fatalf("unexpected error - %v", err) + } + + if err == nil && accessToken == "" { + t.Errorf("token should not be empty") + } + }) + } +} + +func Test_clientCredentialsGrantAuthenticator_GetToken_ReusesToken(t *testing.T) { + type args struct { + clientID string + clientSecret string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Same token when called twice", + args: func() args { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + clientID, exists := os.LookupEnv("CLIENT_ID") + if !exists { + log.Fatal("no Client ID set") + } + clientSecret, exists := os.LookupEnv("CLIENT_SECRET") + if !exists { + log.Fatal("no Client Secret set") + } + + return args{ + clientID: clientID, + clientSecret: clientSecret, + } + }(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenEndpoint, exists := os.LookupEnv("TOKEN_URL") + if !exists { + log.Fatal("no token endpoint configured") + } + + authenticator := New(tt.args.clientID, tt.args.clientSecret, tokenEndpoint) + accessToken, err := authenticator.GetToken() + + if (err != nil) != tt.wantErr { + t.Fatalf("unexpected error - %v", err) + } + + accessToken2, err := authenticator.GetToken() + + if (err != nil) != tt.wantErr { + t.Fatalf("unexpected error - %v", err) + } + + if accessToken != accessToken2 { + t.Errorf("tokens should be equal") + } + }) + } +} + +func Test_resourceOwnerPasswordGrantAuthenticator_GetToken(t *testing.T) { + type args struct { + clientID string + username string + password string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "With valid credentials - got token", + args: func() args { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + clientID, exists := os.LookupEnv("VAAS_CLIENT_ID") + if !exists { + log.Fatal("no client-id set") + } + username, exists := os.LookupEnv("VAAS_USER_NAME") + if !exists { + log.Fatal("no username set") + } + password, exists := os.LookupEnv("VAAS_PASSWORD") + if !exists { + log.Fatal("no password set") + } + + return args{ + clientID: clientID, + username: username, + password: password, + } + }(), + wantErr: false, + }, + { + name: "With invalid credentials - error", + args: func() args { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + return args{ + clientID: "foo", + username: "bar", + password: "baz", + } + }(), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenEndpoint, exists := os.LookupEnv("TOKEN_URL") + if !exists { + log.Fatal("no token endpoint configured") + } + + authenticator := NewWithResourceOwnerPassword(tt.args.clientID, tt.args.username, tt.args.password, tokenEndpoint) + accessToken, err := authenticator.GetToken() + + if (err != nil) != tt.wantErr { + t.Fatalf("unexpected error - %v", err) + } + + if err == nil && accessToken == "" { + t.Errorf("token should not be empty") + } + }) + } +} diff --git a/golang/vaas/v3/pkg/messages/token_response.go b/golang/vaas/v3/pkg/messages/token_response.go new file mode 100644 index 00000000..8aca80c4 --- /dev/null +++ b/golang/vaas/v3/pkg/messages/token_response.go @@ -0,0 +1,14 @@ +// Package messages provides structures for handling communication messages between the client and the VaaS server. +package messages + +// TokenResponse represents a response containing an access token. +type TokenResponse struct { + Accesstoken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +// TokenErrorResponse represents an error response containing an error message. +type TokenErrorResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} diff --git a/golang/vaas/v3/pkg/messages/unmarshaller.go b/golang/vaas/v3/pkg/messages/unmarshaller.go new file mode 100644 index 00000000..9cba7fce --- /dev/null +++ b/golang/vaas/v3/pkg/messages/unmarshaller.go @@ -0,0 +1,16 @@ +package messages + +import ( + "encoding/json" + "github.com/go-playground/validator/v10" +) + +func UnmarshalAndValidate(data []byte, v any) error { + err := json.Unmarshal(data, v) + if err != nil { + return err + } + validate := validator.New() + err = validate.Struct(v) + return err +} diff --git a/golang/vaas/v3/pkg/messages/vaas_analysis.go b/golang/vaas/v3/pkg/messages/vaas_analysis.go new file mode 100644 index 00000000..6a13ce5b --- /dev/null +++ b/golang/vaas/v3/pkg/messages/vaas_analysis.go @@ -0,0 +1,9 @@ +package messages + +type URLAnalysis struct { + JobId string `json:"id" validate:"required"` +} + +type FileAnalysis struct { + Sha256 string `json:"sha256" validate:"required"` +} diff --git a/golang/vaas/v3/pkg/messages/vaas_error.go b/golang/vaas/v3/pkg/messages/vaas_error.go new file mode 100644 index 00000000..38f8975f --- /dev/null +++ b/golang/vaas/v3/pkg/messages/vaas_error.go @@ -0,0 +1,6 @@ +package messages + +type ProblemDetails struct { + Type string `json:"type" validate:"required"` + Detail string `json:"detail"` +} diff --git a/golang/vaas/v3/pkg/messages/vaas_report.go b/golang/vaas/v3/pkg/messages/vaas_report.go new file mode 100644 index 00000000..db313f1d --- /dev/null +++ b/golang/vaas/v3/pkg/messages/vaas_report.go @@ -0,0 +1,38 @@ +package messages + +type FileReport struct { + Sha256 string `json:"sha256" validate:"required"` + Verdict Verdict `json:"verdict" validate:"required"` + Detection string `json:"detection,omitempty"` + MimeType string `json:"mimetype,omitempty"` + FileType string `json:"file_type,omitempty"` +} + +func (r *FileReport) ConvertToVaasVerdict() VaasVerdict { + return VaasVerdict{ + Verdict: r.Verdict, + Sha256: r.Sha256, + Detection: r.Detection, + MimeType: r.MimeType, + FileType: r.FileType, + } +} + +type URLReport struct { + Sha256 string `json:"sha256" validate:"required"` + Verdict Verdict `json:"verdict" validate:"required"` + Detection string `json:"detection,omitempty"` + MimeType string `json:"mimetype,omitempty"` + FileType string `json:"file_type,omitempty"` + URL string `json:"url" validate:"required"` +} + +func (r *URLReport) ConvertToVaasVerdict() VaasVerdict { + return VaasVerdict{ + Verdict: r.Verdict, + Sha256: r.Sha256, + Detection: r.Detection, + MimeType: r.MimeType, + FileType: r.FileType, + } +} diff --git a/golang/vaas/v3/pkg/messages/vaas_url.go b/golang/vaas/v3/pkg/messages/vaas_url.go new file mode 100644 index 00000000..50450c23 --- /dev/null +++ b/golang/vaas/v3/pkg/messages/vaas_url.go @@ -0,0 +1,6 @@ +package messages + +type URLAnalysisRequest struct { + Url string `json:"url"` + UseHashLookup bool `json:"useHashLookup"` +} diff --git a/golang/vaas/v3/pkg/messages/vaas_verdict.go b/golang/vaas/v3/pkg/messages/vaas_verdict.go new file mode 100644 index 00000000..ea52ac3c --- /dev/null +++ b/golang/vaas/v3/pkg/messages/vaas_verdict.go @@ -0,0 +1,11 @@ +// Package messages provides structures for handling communication messages between the client and the VaaS server. +package messages + +// VaasVerdict represents the verdict information returned by the VaaS server. +type VaasVerdict struct { + Verdict Verdict + Sha256 string + Detection string + MimeType string + FileType string +} diff --git a/golang/vaas/v3/pkg/messages/verdict.go b/golang/vaas/v3/pkg/messages/verdict.go new file mode 100644 index 00000000..f2e99820 --- /dev/null +++ b/golang/vaas/v3/pkg/messages/verdict.go @@ -0,0 +1,13 @@ +// Package messages provides structures for handling communication messages between the client and the VaaS server. +package messages + +// Verdict represents different verdict outcomes. +type Verdict string + +// Verdict outcomes. +const ( + Clean Verdict = "Clean" + Unknown Verdict = "Unknown" + Malicious Verdict = "Malicious" + Pup Verdict = "Pup" +) diff --git a/golang/vaas/v3/pkg/options/options.go b/golang/vaas/v3/pkg/options/options.go new file mode 100644 index 00000000..82cb392c --- /dev/null +++ b/golang/vaas/v3/pkg/options/options.go @@ -0,0 +1,40 @@ +// Package options provides structures and functions for configuring options related to the VaaS client. +package options + +type ForSha256Options struct { + UseHashLookup bool // UseHashLookup Controls whether SHA256 hash lookups are used. + UseCache bool // UseCache enables or disables server-side caching. + VaasRequestId string +} + +func NewForSha256Options() ForSha256Options { + return ForSha256Options{UseCache: true, UseHashLookup: true} +} + +type ForFileOptions struct { + UseHashLookup bool + UseCache bool + VaasRequestId string +} + +func NewForFileOptions() ForFileOptions { + return ForFileOptions{UseCache: true, UseHashLookup: true} +} + +type ForStreamOptions struct { + UseHashLookup bool + VaasRequestId string +} + +func NewForStreamOptions() ForStreamOptions { + return ForStreamOptions{UseHashLookup: true} +} + +type ForUrlOptions struct { + UseHashLookup bool + VaasRequestId string +} + +func NewForUrlOptions() ForUrlOptions { + return ForUrlOptions{UseHashLookup: true} +} diff --git a/golang/vaas/v3/pkg/vaas/vaas.go b/golang/vaas/v3/pkg/vaas/vaas.go new file mode 100644 index 00000000..c12a6da7 --- /dev/null +++ b/golang/vaas/v3/pkg/vaas/vaas.go @@ -0,0 +1,431 @@ +// Package vaas provides a client for interacting with G DATA CyberDefense's VaaS Service +// for sending analysis requests to the Vaas server for various types of data, such as URLs, SHA256 hashes, and files. +package vaas + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/internal/hash" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/authenticator" + msg "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/messages" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/options" +) + +const ( + userAgent = "Go/3.0.11" +) + +// Errors returned by the VaaS API +var ( + ErrVaasClient = errors.New("client error") + ErrVaasServer = errors.New("server reported an internal error") + ErrVaasAuthentication = errors.New("VaaS authentication failed") + ErrVaasConnection = errors.New("could not connect to VaaS") +) + +// Vaas provides various ForXXX-functions to send analysis requests to a VaaS server. +// All kinds of requests can be canceled by the context. +// Please refer to the individual function comments for more details on their usage and behavior. +type Vaas interface { + ForSha256(ctx context.Context, sha256 string, options *options.ForSha256Options) (msg.VaasVerdict, error) + ForFile(ctx context.Context, path string, options *options.ForFileOptions) (msg.VaasVerdict, error) + ForStream(ctx context.Context, stream io.Reader, contentLength int64, options *options.ForStreamOptions) (msg.VaasVerdict, error) + ForUrl(ctx context.Context, url *url.URL, options *options.ForUrlOptions) (msg.VaasVerdict, error) +} + +// vaas provides the implementation of the Vaas interface. +type vaas struct { + vaasURL *url.URL + authenticator authenticator.Authenticator + httpClient *http.Client +} + +// New creates a new instance of the Vaas struct, which represents a client for interacting with a Vaas service. +// The vaasURL parameter specifies the endpoint for the VaaS service. +func New(vaasURL *url.URL, authenticator authenticator.Authenticator) Vaas { + client := &vaas{ + vaasURL: vaasURL, + authenticator: authenticator, + httpClient: &http.Client{ + Transport: &http.Transport{ + // Disable HTTP/2 + TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + }, + }, + } + return client +} + +// NewWithDefaultEndpoint creates a new instance of the Vaas struct with a default endpoint. +// It represents a client for interacting with a Vaas service. +func NewWithDefaultEndpoint(authenticator authenticator.Authenticator) Vaas { + vaasURL, _ := url.Parse("https://gateway.production.vaas.gdatasecurity.de") + return New(vaasURL, authenticator) +} + +func parseVaasError(response *http.Response, responseBody []byte) error { + var problemDetails msg.ProblemDetails + err := msg.UnmarshalAndValidate(responseBody, &problemDetails) + var baseErr error + if err != nil { + statusCode := response.StatusCode + switch { + case statusCode == 401: + baseErr = errors.Join(ErrVaasAuthentication, errors.New("server did not accept token from identity provider. Check if you are using the correct identity provider")) + case statusCode >= 400 && statusCode < 500: + baseErr = ErrVaasClient + case statusCode >= 500: + baseErr = ErrVaasServer + default: + baseErr = ErrVaasServer + } + // Server did not reply with a parseable error body, returning the HTTP code instead + return errors.Join(baseErr, errors.New("HTTP error: "+response.Status)) + } + + switch problemDetails.Type { + case "VaasClientException": + baseErr = ErrVaasClient + case "VaasServerException": + baseErr = ErrVaasServer + default: + baseErr = ErrVaasServer + } + return errors.Join(baseErr, errors.New(problemDetails.Detail)) +} + +func readHttpResponse(httpClient *http.Client, request *http.Request) (Response *http.Response, Body []byte, Error error) { + resp, err := httpClient.Do(request) + if err != nil { + return nil, nil, errors.Join(ErrVaasConnection, err) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, errors.Join(ErrVaasClient, err) + } + err = resp.Body.Close() + if err != nil { + return resp, data, errors.Join(ErrVaasClient, err) + } + return resp, data, nil +} + +func encodeToJsonBuffer(data any) (*bytes.Buffer, error) { + encoded, err := json.Marshal(data) + if err != nil { + return nil, errors.Join(ErrVaasClient, err) + } + return bytes.NewBuffer(encoded), nil +} + +func (v *vaas) newAuthenticatedRequest(ctx context.Context, method string, requestId string, url string, body io.Reader) (*http.Request, error) { + token, err := v.authenticator.GetToken() + if err != nil { + return nil, errors.Join(ErrVaasAuthentication, err) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, errors.Join(ErrVaasClient, err) + } + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("User-Agent", userAgent) + if requestId != "" { + req.Header.Add("tracestate", fmt.Sprintf("vaasrequestid=%v", requestId)) + } + return req, nil +} + +func (v *vaas) uploadUrl(ctx context.Context, url *url.URL, opts options.ForUrlOptions) (msg.URLAnalysis, error) { + var analysis = msg.URLAnalysis{} + submitUrl := v.vaasURL.JoinPath("urls").String() + var analysisRequest = msg.URLAnalysisRequest{ + Url: url.String(), + UseHashLookup: opts.UseHashLookup, + } + buffer, err := encodeToJsonBuffer(&analysisRequest) + if err != nil { + return analysis, err + } + req, err := v.newAuthenticatedRequest(ctx, http.MethodPost, opts.VaasRequestId, submitUrl, buffer) + if err != nil { + return analysis, err + } + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + response, body, err := readHttpResponse(v.httpClient, req) + if err != nil { + return analysis, err + } + + if response.StatusCode != http.StatusCreated { + return analysis, parseVaasError(response, body) + } + + err = msg.UnmarshalAndValidate(body, &analysis) + if err != nil { + return analysis, errors.Join(ErrVaasClient, err) + } + return analysis, nil +} + +func (v *vaas) uploadFile(ctx context.Context, file io.Reader, contentLength int64, opts options.ForStreamOptions) (msg.FileAnalysis, error) { + var analysis = msg.FileAnalysis{} + uploadUrl := v.vaasURL.JoinPath("files") + params := url.Values{} + params.Add("useHashLookup", strconv.FormatBool(opts.UseHashLookup)) + uploadUrl.RawQuery = params.Encode() + req, err := v.newAuthenticatedRequest(ctx, http.MethodPost, opts.VaasRequestId, uploadUrl.String(), file) + if err != nil { + return analysis, err + } + req.ContentLength = contentLength + response, body, err := readHttpResponse(v.httpClient, req) + if err != nil { + return analysis, err + } + + if response.StatusCode != http.StatusCreated { + return analysis, parseVaasError(response, body) + } + + err = msg.UnmarshalAndValidate(body, &analysis) + if err != nil { + return analysis, errors.Join(ErrVaasClient, err) + } + return analysis, nil +} + +func (v *vaas) pollUrlJob(ctx context.Context, urlJobId string, opts options.ForUrlOptions) (*msg.URLReport, error) { + submitUrl := v.vaasURL.JoinPath("urls", urlJobId, "report").String() + // Loop until 200 or error + for { + req, err := v.newAuthenticatedRequest(ctx, http.MethodGet, opts.VaasRequestId, submitUrl, nil) + if err != nil { + return nil, err + } + response, body, err := readHttpResponse(v.httpClient, req) + if err != nil { + return nil, err + } + + switch response.StatusCode { + case http.StatusNotFound: + return nil, errors.Join(ErrVaasClient, fmt.Errorf("url job %v not found", urlJobId)) + case http.StatusAccepted: + continue + case http.StatusOK: + var report msg.URLReport + err := msg.UnmarshalAndValidate(body, &report) + if err != nil { + return nil, errors.Join(ErrVaasClient, err) + } + return &report, nil + default: + return nil, parseVaasError(response, body) + } + } +} + +func (v *vaas) pollFileReport(ctx context.Context, sha256 string, opts options.ForSha256Options) (*msg.FileReport, error) { + reportUrl := v.vaasURL.JoinPath("files", sha256, "report") + params := url.Values{} + params.Add("useCache", strconv.FormatBool(opts.UseCache)) + params.Add("useHashLookup", strconv.FormatBool(opts.UseHashLookup)) + reportUrl.RawQuery = params.Encode() + reportUrlString := reportUrl.String() + // Loop until we get 200 or an error + for { + req, err := v.newAuthenticatedRequest(ctx, http.MethodGet, opts.VaasRequestId, reportUrlString, nil) + if err != nil { + return nil, err + } + + response, body, err := readHttpResponse(v.httpClient, req) + if err != nil { + return nil, err + } + + switch response.StatusCode { + case http.StatusNotFound: + return nil, nil + case http.StatusAccepted: + continue + case http.StatusOK: + var report msg.FileReport + err = msg.UnmarshalAndValidate(body, &report) + if err != nil { + return nil, errors.Join(ErrVaasClient, err) + } + return &report, nil + default: + return nil, parseVaasError(response, body) + } + } +} + +// ForSha256 sends an analysis request for a file identified by its SHA256 hash to the VaaS server and returns the verdict. +// The analysis can be canceled using the provided context. +// +// Example usage: +// +// vaasClient := vaas.NewWithDefaultEndpoint(options, auth) +// ctx := context.Background() +// sha256 := "..." +// verdict, err := vaasClient.ForSha256(ctx, sha256) +// if err != nil { +// log.Fatalf("Failed to get verdict: %v", err) +// } +// fmt.Printf("Verdict: %s\n", verdict.Verdict) +// fmt.Printf("SHA256: %s\n", verdict.Sha256) +func (v *vaas) ForSha256(ctx context.Context, sha256 string, opts *options.ForSha256Options) (msg.VaasVerdict, error) { + if opts == nil { + newOpts := options.NewForSha256Options() + opts = &newOpts + } + + report, err := v.pollFileReport(ctx, sha256, *opts) + if err != nil { + return msg.VaasVerdict{}, err + } + + if report == nil { + // Not found + return msg.VaasVerdict{ + Verdict: msg.Unknown, + Sha256: sha256, + }, nil + } + return report.ConvertToVaasVerdict(), nil +} + +// ForFile sends an analysis request for a file at the given filePath to the VaaS server and returns the verdict. +// The analysis can be canceled using the provided context. +// +// Example usage: +// +// vaasClient := vaas.NewWithDefaultEndpoint(options, auth) +// ctx := context.Background() +// filePath := "path/to/file.txt" +// verdict, err := vaasClient.ForFile(ctx, filePath) +// if err != nil { +// log.Fatalf("Failed to get verdict: %v", err) +// } +// fmt.Printf("Verdict: %s\n", verdict.Verdict) +// fmt.Printf("SHA256: %s\n", verdict.Sha256) +func (v *vaas) ForFile(ctx context.Context, filePath string, opts *options.ForFileOptions) (msg.VaasVerdict, error) { + if opts == nil { + newOpts := options.NewForFileOptions() + opts = &newOpts + } + + file, err := os.Open(filePath) + if err != nil { + return msg.VaasVerdict{}, errors.Join(ErrVaasClient, err) + } + defer func() { + _ = file.Close() + }() + + sha256, err := hash.CalculateSha256(file) + if err != nil { + return msg.VaasVerdict{}, errors.Join(ErrVaasClient, err) + } + + shaOpts := options.NewForSha256Options() + shaOpts.UseCache = opts.UseCache + shaOpts.UseHashLookup = opts.UseHashLookup + shaOpts.VaasRequestId = opts.VaasRequestId + verdict, err := v.ForSha256(ctx, sha256, &shaOpts) + // We only care about the hash lookup if it's not failed and has actionable verdict + if err == nil && verdict.Verdict != msg.Unknown { + return verdict, nil + } + + if _, err = file.Seek(0, io.SeekStart); err != nil { + return msg.VaasVerdict{}, errors.Join(ErrVaasClient, err) + } + + stat, err := file.Stat() + if err != nil { + return msg.VaasVerdict{}, errors.Join(ErrVaasClient, err) + } + + streamOpts := options.NewForStreamOptions() + streamOpts.UseHashLookup = opts.UseHashLookup + streamOpts.VaasRequestId = opts.VaasRequestId + return v.ForStream(ctx, file, stat.Size(), &streamOpts) +} + +// ForStream sends an analysis request for a file stream to the VaaS server and returns the verdict. +// contentLength must be set to the stream's length, in bytes. +// The analysis can be canceled using the provided context. +// +// Example usage: +// +// vaasClient := vaas.NewWithDefaultEndpoint(options, auth) +// ctx := context.Background() +// contentLength := 1234 +// verdict, err := vaasClient.ForStream(ctx, stream, contentLength) +// if err != nil { +// log.Fatalf("Failed to get verdict: %v", err) +// } +// fmt.Printf("Verdict: %s\n", verdict.Verdict) +// fmt.Printf("SHA256: %s\n", verdict.Sha256) +func (v *vaas) ForStream(ctx context.Context, stream io.Reader, contentLength int64, opts *options.ForStreamOptions) (msg.VaasVerdict, error) { + if opts == nil { + newOpts := options.NewForStreamOptions() + opts = &newOpts + } + + analysis, err := v.uploadFile(ctx, stream, contentLength, *opts) + if err != nil { + return msg.VaasVerdict{}, err + } + + sha256Opts := options.NewForSha256Options() + sha256Opts.UseHashLookup = opts.UseHashLookup + sha256Opts.VaasRequestId = opts.VaasRequestId + return v.ForSha256(ctx, analysis.Sha256, &sha256Opts) +} + +// ForUrl sends an analysis request for a file URL to the VaaS server and returns the verdict. +// The analysis can be canceled using the provided context. +// +// Example usage: +// +// vaasClient := vaas.NewWithDefaultEndpoint(options, auth) +// ctx := context.Background() +// myUrl, _ := url.Parse("https://example.com/examplefile") +// verdict, err := vaasClient.ForUrl(ctx, myUrl) +// if err != nil { +// log.Fatalf("Failed to get verdict: %v", err) +// } +// fmt.Printf("Verdict: %s\n", verdict.Verdict) +// fmt.Printf("SHA256: %s\n", verdict.Sha256) +func (v *vaas) ForUrl(ctx context.Context, url *url.URL, opts *options.ForUrlOptions) (msg.VaasVerdict, error) { + if opts == nil { + newOpts := options.NewForUrlOptions() + opts = &newOpts + } + + analysis, err := v.uploadUrl(ctx, url, *opts) + if err != nil { + return msg.VaasVerdict{}, err + } + report, err := v.pollUrlJob(ctx, analysis.JobId, *opts) + if err != nil { + return msg.VaasVerdict{}, err + } + return report.ConvertToVaasVerdict(), nil +} diff --git a/golang/vaas/v3/pkg/vaas/vaas_test.go b/golang/vaas/v3/pkg/vaas/vaas_test.go new file mode 100644 index 00000000..814e84a6 --- /dev/null +++ b/golang/vaas/v3/pkg/vaas/vaas_test.go @@ -0,0 +1,965 @@ +package vaas + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/authenticator" + msg "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/messages" + "github.com/GDATASoftwareAG/vaas/golang/vaas/v3/pkg/options" + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "io" + "log" + "math/rand" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +type testFixture struct { + vaasClient Vaas + errorChan <-chan error +} + +const ( + eicarSha256 = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f" + eicarUrl = "https://secure.eicar.org/eicar.com" +) + +func (tf *testFixture) setUp() Vaas { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + vaasURLString, exists := os.LookupEnv("VAAS_URL") + if !exists { + log.Fatal("no vaas endpoint configured") + } + + return tf.setUpWithVaasURL(vaasURLString) +} + +func (tf *testFixture) setUpWithVaasURL(vaasURLString string) Vaas { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + clientID, exists := os.LookupEnv("CLIENT_ID") + if !exists { + log.Fatal("no Client ID set") + } + clientSecret, exists := os.LookupEnv("CLIENT_SECRET") + if !exists { + log.Fatal("no Client Secret set") + } + tokenEndpoint, exists := os.LookupEnv("TOKEN_URL") + if !exists { + log.Fatal("no token endpoint configured") + } + + auth := authenticator.New(clientID, clientSecret, tokenEndpoint) + tf.setUpWithVaasURLAndAuthenticator(vaasURLString, auth) + return tf.vaasClient +} + +func (tf *testFixture) setUpWithVaasURLAndAuthenticator(vaasURLString string, auth authenticator.Authenticator) Vaas { + if err := godotenv.Load(); err != nil { + log.Printf("failed to load environment - %v", err) + } + + vaasURL, err := url.Parse(vaasURLString) + if err != nil { + log.Fatal(err) + } + + tf.vaasClient = New(vaasURL, auth) + + return tf.vaasClient +} + +func Test_ForSha256(t *testing.T) { + const ( + cleanSha256 string = "cd617c5c1b1ff1c94a52ab8cf07192654f271a3f8bad49490288131ccb9efc1e" + maliciousSha256 string = "ab5788279033b0a96f2d342e5f35159f103f69e0191dd391e036a1cd711791a2" + unknownSha256 string = "1f72c1111111111111f912e40b7323a0192a300b376186c10f6803dc5efe28df" + ) + type args struct { + sha256 string + expectedVerdict msg.Verdict + } + tests := []struct { + args args + name string + expectedError error + }{ + { + name: "With clean sha256 - got verdict clean", + args: args{ + sha256: cleanSha256, + expectedVerdict: msg.Clean, + }, + }, + { + name: "With malicious sha256 - got verdict malicious", + args: args{ + sha256: maliciousSha256, + expectedVerdict: msg.Malicious, + }, + }, + { + name: "With unknown sha256 - got verdict unknown", + args: args{ + sha256: unknownSha256, + expectedVerdict: msg.Unknown, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixture := new(testFixture) + vaasClient := fixture.setUp() + + verdict, err := vaasClient.ForSha256(context.Background(), tt.args.sha256, nil) + + if !errors.Is(err, tt.expectedError) { + t.Fatalf("unexpected error, expected %v but got %v", tt.expectedError, err) + } + + if err == nil && verdict.Verdict != tt.args.expectedVerdict { + t.Errorf("verdict should be %v, got %v", tt.args.expectedVerdict, verdict.Verdict) + } + }) + } +} + +func Test_ForSha256_IfVaasRequestIdIsSet_SendsTraceState(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("tracestate"), "vaasrequestid=MyRequestId") + defaultHttpHandler(t, w, r) + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + opts := options.NewForSha256Options() + opts.VaasRequestId = "MyRequestId" + _, err := vaasClient.ForSha256(context.Background(), eicarSha256, &opts) + assert.NoError(t, err, "ForSha256 returned err") +} + +func Test_ForSha256_SendsUserAgent(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("User-Agent"), userAgent) + defaultHttpHandler(t, w, r) + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + verdict, err := vaasClient.ForSha256(context.Background(), eicarSha256, nil) + assert.NoError(t, err, "ForSha256 returned err") + assert.Equalf(t, msg.Malicious, verdict.Verdict, "Verdict is not malicious") +} + +func Test_ForSha256_SendsOptions(t *testing.T) { + tests := []options.ForSha256Options{ + { + UseHashLookup: true, + UseCache: true, + }, + { + UseHashLookup: true, + UseCache: false, + }, + { + UseHashLookup: false, + UseCache: true, + }, + { + UseHashLookup: false, + UseCache: false, + }, + } + + for _, option := range tests { + t.Run(fmt.Sprintf("%v", option), func(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + useCache := strconv.FormatBool(option.UseCache) + useHashLookup := strconv.FormatBool(option.UseHashLookup) + expectedUrl := fmt.Sprintf("/files/275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f/report?useCache=%s&useHashLookup=%s", useCache, useHashLookup) + assert.Equal(t, expectedUrl, r.URL.String()) + defaultHttpHandler(t, w, r) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForSha256(context.Background(), eicarSha256, &option) + assert.NoError(t, err, "ForSha256 returned err") + }) + } +} + +func Test_ForSha256_IfVaasClientException_ReturnErrVaasClient(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForSha256(context.Background(), "", nil) + + assert.ErrorIs(t, err, ErrVaasClient) +} + +func Test_ForSha256_IfVaasServerException_ReturnErrVaasServer(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForSha256(context.Background(), eicarSha256, nil) + + assert.ErrorIs(t, err, ErrVaasServer) +} + +func Test_ForSha256_IfVaasReturns401_ReturnErrVaasAuthentication(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForSha256(context.Background(), eicarSha256, nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) +} + +func Test_ForSha256_IfAuthenticationFailure_ReturnErrVaasAuthentication(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + + vaasClient := fixture.setUpWithVaasURLAndAuthenticator(server.URL, mockFailureAuthenticator{}) + + _, err := vaasClient.ForSha256(context.Background(), eicarSha256, nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) + assert.ErrorContains(t, err, "placeholder error message") +} + +func Test_ForSha256_WithDeadlineContext_Cancels(t *testing.T) { + fixture := new(testFixture) + vaasClient := fixture.setUp() + + cancelCtx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + verdict, err := vaasClient.ForSha256(cancelCtx, eicarSha256, nil) + + if err == nil { + t.Fatalf("expected error got success instead (%v)", verdict) + } + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected cancelled error, got %v", err) + } +} + +type mockFailureAuthenticator struct { +} + +func (m mockFailureAuthenticator) GetToken() (string, error) { + return "", errors.New("placeholder error message") +} + +func getHttpTestServer(handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(handler)) +} + +func defaultHttpHandler(t *testing.T, w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/urls" { + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{"id":"1"}`)) + assert.NoError(t, err) + return + } + if r.URL.String() == "/urls/1/report" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(fmt.Sprintf(`{"verdict":"Malicious","sha256":"%s","url":"%s"}`, eicarSha256, eicarUrl))) + assert.NoError(t, err) + return + } + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(fmt.Sprintf(`{"verdict":"Malicious","sha256":"%s"}`, eicarSha256))) + assert.NoError(t, err) +} + +func createEicarFile(t *testing.T) string { + const ( + eicarString string = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" + ) + testFile := filepath.Join(t.TempDir(), "testfile") + if err := os.WriteFile(testFile, []byte(eicarString), 0644); err != nil { + t.Fatalf("error while writing file: %v", err) + } + return testFile +} + +func Test_ForFile(t *testing.T) { + const ( + eicarBase64String string = "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo" + ) + type args struct { + fileContent string + expectedVerdict msg.Verdict + } + tests := []struct { + args args + name string + expectedError error + }{ + { + name: "with eicar file - got verdict malicious", + args: args{ + fileContent: func() string { + decodedEicarString, _ := base64.StdEncoding.DecodeString(eicarBase64String) + return string(decodedEicarString) + }(), + expectedVerdict: msg.Malicious, + }, + }, + { + name: "With random file - got verdict clean", + args: args{ + fileContent: RandomString(200), + expectedVerdict: msg.Clean, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixture := new(testFixture) + vaasClient := fixture.setUp() + + testFile := filepath.Join(t.TempDir(), "testfile") + if err := os.WriteFile(testFile, []byte(tt.args.fileContent), 0644); err != nil { + t.Fatalf("error while writing file: %v", err) + } + + // test disk file + verdict, err := vaasClient.ForFile(context.Background(), testFile, nil) + + if !errors.Is(err, tt.expectedError) { + t.Fatalf("unexpected error, expected %v but got %v", tt.expectedError, err) + } + + if err == nil && verdict.Verdict != tt.args.expectedVerdict { + t.Errorf("verdict should be %v, got %v", tt.args.expectedVerdict, verdict.Verdict) + } + }) + } +} + +func Test_ForFile_IfVaasRequestIdIsSet_SendsTraceState(t *testing.T) { + eicar := createEicarFile(t) + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("tracestate"), "vaasrequestid=MyRequestId") + defaultHttpHandler(t, w, r) + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + opts := options.NewForFileOptions() + opts.VaasRequestId = "MyRequestId" + _, err := vaasClient.ForFile(context.Background(), eicar, &opts) + assert.NoError(t, err) +} + +func Test_ForFile_SendsUserAgent(t *testing.T) { + eicar := createEicarFile(t) + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("User-Agent"), userAgent) + defaultHttpHandler(t, w, r) + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + verdict, err := vaasClient.ForFile(context.Background(), eicar, nil) + assert.NoError(t, err, "ForFile returned err") + assert.Equalf(t, msg.Malicious, verdict.Verdict, "Verdict is not malicious") +} + +func Test_ForFile_SendsOptions(t *testing.T) { + tests := []options.ForFileOptions{ + { + UseHashLookup: true, + UseCache: true, + }, + { + UseHashLookup: true, + UseCache: false, + }, + { + UseHashLookup: false, + UseCache: true, + }, + { + UseHashLookup: false, + UseCache: false, + }, + } + + eicar := createEicarFile(t) + + for _, option := range tests { + t.Run(fmt.Sprintf("%v", option), func(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + useCache := strconv.FormatBool(option.UseCache) + useHashLookup := strconv.FormatBool(option.UseHashLookup) + expectedUrl := fmt.Sprintf("/files/275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f/report?useCache=%s&useHashLookup=%s", useCache, useHashLookup) + assert.Equal(t, expectedUrl, r.URL.String()) + defaultHttpHandler(t, w, r) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForFile(context.Background(), eicar, &option) + assert.NoError(t, err) + }) + } +} + +func Test_ForFile_IfVaasClientException_ReturnErrVaasClient(t *testing.T) { + eicar := createEicarFile(t) + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForFile(context.Background(), eicar, nil) + + assert.ErrorIs(t, err, ErrVaasClient) +} + +func Test_ForFile_IfVaasServerException_ReturnErrVaasServer(t *testing.T) { + eicar := createEicarFile(t) + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForFile(context.Background(), eicar, nil) + + assert.ErrorIs(t, err, ErrVaasServer) +} + +func Test_ForFile_IfVaasReturns401_ReturnErrVaasAuthentication(t *testing.T) { + eicar := createEicarFile(t) + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForFile(context.Background(), eicar, nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) +} + +func Test_ForFile_IfAuthenticationFailure_ReturnErrVaasAuthentication(t *testing.T) { + eicar := createEicarFile(t) + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + + vaasClient := fixture.setUpWithVaasURLAndAuthenticator(server.URL, mockFailureAuthenticator{}) + + _, err := vaasClient.ForFile(context.Background(), eicar, nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) + assert.ErrorContains(t, err, "placeholder error message") +} + +func Test_ForFile_WithDeadlineContext_Cancels(t *testing.T) { + eicar := createEicarFile(t) + fixture := new(testFixture) + vaasClient := fixture.setUp() + + cancelCtx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + verdict, err := vaasClient.ForFile(cancelCtx, eicar, nil) + + if err == nil { + t.Fatalf("expected error got success instead (%v)", verdict) + } + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected cancelled error, got %v", err) + } +} + +func Test_ForStream_WithEicarString_ReturnsMalicious(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + fixture := new(testFixture) + vaasClient := fixture.setUp() + + verdict, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), nil) + + if err != nil { + t.Fatalf("unexpected error - %v", err) + } + + if verdict.Verdict != msg.Malicious { + t.Errorf("verdict should be %v, got %v", msg.Malicious, verdict.Verdict) + } +} + +func Test_ForStream_WitEicarFromUrl_ReturnsMalicious(t *testing.T) { + response, _ := http.Get("https://secure.eicar.org/eicar.com.txt") + + fixture := new(testFixture) + vaasClient := fixture.setUp() + + verdict, err := vaasClient.ForStream(context.Background(), response.Body, response.ContentLength, nil) + + if err != nil { + t.Fatalf("unexpected error - %v", err) + } + + if verdict.Verdict != msg.Malicious { + t.Errorf("verdict should be %v, got %v", msg.Malicious, verdict.Verdict) + } +} + +func Test_ForStream_SendsUserAgent(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/report") { + assert.Equal(t, r.Header.Get("User-Agent"), userAgent) + defaultHttpHandler(t, w, r) + } else { + assert.Equal(t, r.Header.Get("User-Agent"), userAgent) + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{"sha256": "12345"}`)) + assert.NoError(t, err) + } + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + verdict, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), nil) + assert.NoError(t, err, "ForStream returned err") + assert.Equalf(t, msg.Malicious, verdict.Verdict, "Verdict is not malicious") +} + +func Test_ForStream_SendsOptions(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + tests := []options.ForStreamOptions{ + { + UseHashLookup: true, + }, + { + UseHashLookup: false, + }, + } + + for _, option := range tests { + t.Run(fmt.Sprintf("%v", option), func(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/report") { + defaultHttpHandler(t, w, r) + } else { + useHashLookup := strconv.FormatBool(option.UseHashLookup) + expectedUrl := fmt.Sprintf("/files?useHashLookup=%s", useHashLookup) + assert.Equal(t, expectedUrl, r.URL.String()) + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{"sha256": "12345"}`)) + assert.NoError(t, err) + } + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), &option) + assert.NoError(t, err) + }) + } +} + +func Test_ForStream_IfVaasRequestIdIsSet_SendsTraceState(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/report") { + assert.Equal(t, r.Header.Get("tracestate"), "vaasrequestid=MyRequestId") + defaultHttpHandler(t, w, r) + } else { + assert.Equal(t, r.Header.Get("tracestate"), "vaasrequestid=MyRequestId") + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{"sha256": "12345"}`)) + assert.NoError(t, err) + } + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + opts := options.NewForStreamOptions() + opts.VaasRequestId = "MyRequestId" + _, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), &opts) + assert.NoError(t, err) +} + +func Test_ForStream_IfVaasClientException_ReturnErrVaasClient(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), nil) + + assert.ErrorIs(t, err, ErrVaasClient) +} + +func Test_ForStream_IfVaasServerException_ReturnErrVaasServer(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), nil) + + assert.ErrorIs(t, err, ErrVaasServer) +} + +func Test_ForStream_IfVaasReturns401_ReturnErrVaasAuthentication(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + _, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) +} + +func Test_ForStream_IfAuthenticationFailure_ReturnErrVaasAuthentication(t *testing.T) { + eicarReader := strings.NewReader("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*") + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + + vaasClient := fixture.setUpWithVaasURLAndAuthenticator(server.URL, mockFailureAuthenticator{}) + + _, err := vaasClient.ForStream(context.Background(), eicarReader, eicarReader.Size(), nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) + assert.ErrorContains(t, err, "placeholder error message") +} + +func Test_ForStream_WithDeadlineContext_Cancels(t *testing.T) { + response, _ := http.Get("https://secure.eicar.org/eicar.com.txt") + + fixture := new(testFixture) + vaasClient := fixture.setUp() + + cancelCtx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + verdict, err := vaasClient.ForStream(cancelCtx, response.Body, response.ContentLength, nil) + + if err == nil { + t.Fatalf("expected error got success instead (%v)", verdict) + } + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected cancelled error, got %v", err) + } +} + +func Test_ForStream_WithMaliciousStream_RetunsMaliciousWithDetectionsAndMimeType(t *testing.T) { + response, _ := http.Get("https://secure.eicar.org/eicar.com.txt") + + fixture := new(testFixture) + VaasClient := fixture.setUp() + + verdict, err := VaasClient.ForStream(context.Background(), response.Body, response.ContentLength, nil) + + if err != nil { + t.Fatalf("unexpected error - %v", err) + } + + if verdict.Verdict != msg.Malicious { + t.Errorf("verdict should be %v, got %v", msg.Malicious, verdict.Verdict) + } + + if verdict.MimeType != "text/plain" { + t.Errorf("expected mime type to be text/plain, got %v", verdict.MimeType) + } + + if verdict.Detection == "" { + t.Errorf("expected a detection, got empty string") + } + + if verdict.Detection != "EICAR-Test-File#462103" { + t.Errorf("detection has to be EICAR-Test-File#462103, got %v", verdict.Detection) + } +} + +func Test_ForUrl(t *testing.T) { + const ( + cleanURL string = "https://www.gdatasoftware.com/oem/verdict-as-a-service" + eicarURL string = "https://secure.eicar.org/eicar.com" + invalidURL string = "https://gateway.production.vaas.gdatasecurity.de/swagger/nocontenthere" + ) + type args struct { + url string + expectedVerdict msg.Verdict + } + tests := []struct { + args args + name string + expectedError error + }{ + { + name: "with clean url - got verdict clean", + args: args{ + url: cleanURL, + expectedVerdict: msg.Clean, + }, + }, + { + name: "with eicar url - got verdict malicious", + args: args{ + url: eicarURL, + expectedVerdict: msg.Malicious, + }, + }, + { + name: "with invalid url - got client error", + args: args{ + url: invalidURL, + expectedVerdict: msg.Malicious, + }, + expectedError: ErrVaasClient, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixture := new(testFixture) + VaasClient := fixture.setUp() + + testUrl, err := url.Parse(tt.args.url) + if err != nil { + t.Fatalf("Cannot parse test testUrl - %v", err) + } + verdict, err := VaasClient.ForUrl(context.Background(), testUrl, nil) + + if !errors.Is(err, tt.expectedError) { + t.Fatalf("unexpected error, expected %v but got %v", tt.expectedError, err) + } + + if err == nil && verdict.Verdict != tt.args.expectedVerdict { + t.Errorf("verdict should be %v, got %v", tt.args.expectedVerdict, verdict.Verdict) + } + }) + } +} + +func Test_ForUrl_IfVaasRequestIdIsSet_SendsTraceState(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("tracestate"), "vaasrequestid=MyRequestId") + defaultHttpHandler(t, w, r) + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + opts := options.NewForUrlOptions() + opts.VaasRequestId = "MyRequestId" + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, &opts) + assert.NoError(t, err, "ForUrl returned err") +} + +func Test_ForUrl_SendsUserAgent(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("User-Agent"), userAgent) + defaultHttpHandler(t, w, r) + }) + defer server.Close() + + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, nil) + assert.NoError(t, err, "ForUrl returned err") +} + +func Test_ForUrl_SendsOptions(t *testing.T) { + tests := []options.ForUrlOptions{ + { + UseHashLookup: false, + }, + { + UseHashLookup: true, + }, + } + + for _, option := range tests { + t.Run(fmt.Sprintf("%v", option), func(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/urls" { + analysisRequest := msg.URLAnalysisRequest{ + Url: eicarUrl, + UseHashLookup: option.UseHashLookup, + } + data, err := io.ReadAll(r.Body) + assert.NoError(t, err) + err = json.Unmarshal(data, &analysisRequest) + assert.Equal(t, option.UseHashLookup, analysisRequest.UseHashLookup) + + } + defaultHttpHandler(t, w, r) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, &option) + assert.NoError(t, err, "ForUrl returned err") + }) + } +} + +func Test_ForUrl_IfVaasClientException_ReturnErrVaasClient(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, nil) + + assert.ErrorIs(t, err, ErrVaasClient) +} + +func Test_ForUrl_IfVaasServerException_ReturnErrVaasServer(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, nil) + + assert.ErrorIs(t, err, ErrVaasServer) +} + +func Test_ForUrl_IfVaasReturns401_ReturnErrVaasAuthentication(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + vaasClient := fixture.setUpWithVaasURL(server.URL) + + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) +} + +func Test_ForUrl_IfAuthenticationFailure_ReturnErrVaasAuthentication(t *testing.T) { + server := getHttpTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer server.Close() + fixture := new(testFixture) + + vaasClient := fixture.setUpWithVaasURLAndAuthenticator(server.URL, mockFailureAuthenticator{}) + + u, err := url.Parse(eicarUrl) + _, err = vaasClient.ForUrl(context.Background(), u, nil) + + assert.ErrorIs(t, err, ErrVaasAuthentication) + assert.ErrorContains(t, err, "placeholder error message") +} + +func Test_ForUrl_WithDeadlineContext_Cancels(t *testing.T) { + fixture := new(testFixture) + vaasClient := fixture.setUp() + + cancelCtx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + u, err := url.Parse(eicarUrl) + verdict, err := vaasClient.ForUrl(cancelCtx, u, nil) + + if err == nil { + t.Fatalf("expected error got success instead (%v)", verdict) + } + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected cancelled error, got %v", err) + } +} + +func RandomString(n int) string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + + s := make([]rune, n) + for i := range s { + s[i] = letters[rand.Intn(len(letters))] + } + return string(s) +} diff --git a/justfile b/justfile index c2ac1140..52435aad 100644 --- a/justfile +++ b/justfile @@ -33,10 +33,16 @@ populate-env: cp .env golang/vaas/.env cp .env golang/vaas/v2/.env cp .env golang/vaas/v2/examples/file-verdict-request/.env + cp .env golang/vaas/v2/examples/vaasctl/.env cp .env golang/vaas/v2/pkg/vaas/.env cp .env golang/vaas/v2/pkg/authenticator/.env cp .env golang/vaas/pkg/authenticator/.env cp .env golang/vaas/pkg/vaas/.env + cp .env golang/vaas/v3/.env + cp .env golang/vaas/v3/examples/file-verdict-request/.env + cp .env golang/vaas/v3/examples/vaasctl/.env + cp .env golang/vaas/v3/pkg/vaas/.env + cp .env golang/vaas/v3/pkg/authenticator/.env cp .env java/.env cp .env php/tests/vaas/.env cp .env ruby/test/.env