Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,38 @@ upload-file:
build-upload-file - Build the upload-file binary locally
test-upload-file-local - Test binary locally (requires .env)
test-upload-file-docker - Test with Docker (uses .env if available)
```

## GitHub Actions

An example workflow to build a Docker image and scan it with Trivy, then upload the results to the Security Agent.

```yaml
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

# docker build

- name: Run Trivy vulnerability scanner (json output)
uses: aquasecurity/trivy-action@0.32.0
with:
image-ref: ${{ steps.docker-build.outputs.IMG_NAME }}
format: json
output: trivy-results.json
severity: CRITICAL,HIGH
scanners: vuln

- name: Upload Trivy scan results to PromptQL Security Agent
uses: hasura/security-agent-tools/upload-file@main
with:
file_path: trivy-results.json
security_agent_api_key: ${{ secrets.SECURITY_AGENT_API_KEY }}
tags: |
service=sample-service
source_code_path=path/to/source-code
docker_file_path=path/to/source-code/Dockerfile
scanner=trivy
```
4 changes: 4 additions & 0 deletions upload-file/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ inputs:
security_agent_api_key:
description: "Security Agent API key"
required: true
tags:
description: "key value pairs of tags separated by comma, e.g. key1=value1,key2=value2"
required: false

runs:
using: "docker"
Expand All @@ -29,3 +32,4 @@ runs:
INPUT_DESTINATION: ${{ inputs.destination }}
INPUT_SECURITY_AGENT_API_ENDPOINT: ${{ inputs.security_agent_api_endpoint }}
INPUT_SECURITY_AGENT_API_KEY: ${{ inputs.security_agent_api_key }}
INPUT_TAGS: ${{ inputs.tags }}
101 changes: 101 additions & 0 deletions upload-file/input/input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package input

import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
)

type Input struct {
FilePath string
Destination string
SecurityAgentAPIEndpoint string
SecurityAgentAPIToken string
Tags map[string]string
}

var (
ErrFilePath = errors.New("file-path input is required")
ErrSecurityAgentAPIKey = errors.New("security-agent-api-key input is required")
)

func Parse() (*Input, error) {
input := &Input{}

filePath := os.Getenv("INPUT_FILE_PATH")
if filePath == "" {
return nil, ErrFilePath
}
if filepath.Ext(filePath) != ".json" {
log.Fatalf("file must be a JSON file, got: %s", filePath)
}
input.FilePath = filePath

securityAgentAPIEndpoint := os.Getenv("INPUT_SECURITY_AGENT_API_ENDPOINT")
if securityAgentAPIEndpoint == "" {
input.SecurityAgentAPIEndpoint = "https://security-agent.ddn.pro.hasura.io/graphql"
}
input.SecurityAgentAPIEndpoint = securityAgentAPIEndpoint

securityAgentAPIKey := os.Getenv("INPUT_SECURITY_AGENT_API_KEY")
if securityAgentAPIKey == "" {
return nil, ErrSecurityAgentAPIKey
}
input.SecurityAgentAPIToken = securityAgentAPIKey

destination := os.Getenv("INPUT_DESTINATION")
if destination == "" {
// Calculate SHA256 of file contents
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
hash, err := calculateFileSHA256(file)
if err != nil {
return nil, err
}
destination = "uploads/" + hash + ".json"
}
input.Destination = destination

tags := os.Getenv("INPUT_TAGS")
if tags != "" {
input.Tags = parseTags(tags)
}

return input, nil
}

func parseTags(tags string) map[string]string {
tagMap := make(map[string]string)
for _, tag := range strings.Split(tags, "\n") {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
kv := strings.SplitN(tag, "=", 2)
if len(kv) == 2 {
tagMap[kv[0]] = kv[1]
}
}
return tagMap
}

// calculateFileSHA256 calculates the SHA256 hash of a file's contents
func calculateFileSHA256(file *os.File) (string, error) {
hasher := sha256.New()
_, err := io.Copy(hasher, file)
if err != nil {
return "", fmt.Errorf("failed to read file for hashing: %v", err)
}

hashBytes := hasher.Sum(nil)
return hex.EncodeToString(hashBytes), nil
}
89 changes: 89 additions & 0 deletions upload-file/input/input_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package input

import (
"reflect"
"testing"
)

func TestParseTags(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]string
}{
{
name: "empty string",
input: "",
expected: map[string]string{},
},
{
name: "single tag",
input: "key1=value1",
expected: map[string]string{"key1": "value1"},
},
{
name: "multiple tags",
input: "key1=value1\nkey2=value2\nkey3=value3",
expected: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"},
},
{
name: "tag with empty value",
input: "key1=\nkey2=value2",
expected: map[string]string{"key1": "", "key2": "value2"},
},
{
name: "tag with empty key",
input: "=value1\nkey2=value2",
expected: map[string]string{"": "value1", "key2": "value2"},
},
{
name: "tag without equals sign (invalid)",
input: "key1\nkey2=value2",
expected: map[string]string{"key2": "value2"},
},
{
name: "tag with multiple equals signs",
input: "key1=value1=extra\nkey2=value2",
expected: map[string]string{"key1": "value1=extra", "key2": "value2"},
},
{
name: "tags with leading/trailing spaces",
input: " key1=value1 \n key2=value2 \n key3=value3",
expected: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"},
},
{
name: "duplicate keys (last one wins)",
input: "key1=value1\nkey1=value2",
expected: map[string]string{"key1": "value2"},
},
{
name: "special characters in values",
input: "url=https://example.com\npath=/home/user\nemail=test@example.com",
expected: map[string]string{"url": "https://example.com", "path": "/home/user", "email": "test@example.com"},
},
{
name: "empty lines",
input: "\n\n\n",
expected: map[string]string{},
},
{
name: "mixed valid and invalid tags with empty lines",
input: "valid=value\ninvalid\n\nanother=good\n=empty_key\nno_value=\n",
expected: map[string]string{"valid": "value", "another": "good", "": "empty_key", "no_value": ""},
},
{
name: "values with commas",
input: "list=item1,item2,item3\nurl=https://example.com/path?param1=value1,param2=value2",
expected: map[string]string{"list": "item1,item2,item3", "url": "https://example.com/path?param1=value1,param2=value2"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseTags(tt.input)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("parseTags(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
Loading