Skip to content

Commit a44c508

Browse files
authored
feat: refactor and add support for tags (#4)
* refactor: input parsing logic * refactor: introduce upload package * upload service metadata * introduce source code path * fix service path * enable github action metadata upload * update github action metadata too * update service metadata upload logic * dockerfile path fix * update gh action path * update the readme
1 parent 5bf098d commit a44c508

File tree

8 files changed

+597
-323
lines changed

8 files changed

+597
-323
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,38 @@ upload-file:
1616
build-upload-file - Build the upload-file binary locally
1717
test-upload-file-local - Test binary locally (requires .env)
1818
test-upload-file-docker - Test with Docker (uses .env if available)
19+
```
20+
21+
## GitHub Actions
22+
23+
An example workflow to build a Docker image and scan it with Trivy, then upload the results to the Security Agent.
24+
25+
```yaml
26+
jobs:
27+
test-and-build:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v2
31+
32+
# docker build
33+
34+
- name: Run Trivy vulnerability scanner (json output)
35+
uses: aquasecurity/trivy-action@0.32.0
36+
with:
37+
image-ref: ${{ steps.docker-build.outputs.IMG_NAME }}
38+
format: json
39+
output: trivy-results.json
40+
severity: CRITICAL,HIGH
41+
scanners: vuln
42+
43+
- name: Upload Trivy scan results to PromptQL Security Agent
44+
uses: hasura/security-agent-tools/upload-file@main
45+
with:
46+
file_path: trivy-results.json
47+
security_agent_api_key: ${{ secrets.SECURITY_AGENT_API_KEY }}
48+
tags: |
49+
service=sample-service
50+
source_code_path=path/to/source-code
51+
docker_file_path=path/to/source-code/Dockerfile
52+
scanner=trivy
1953
```

upload-file/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ inputs:
2020
security_agent_api_key:
2121
description: "Security Agent API key"
2222
required: true
23+
tags:
24+
description: "key value pairs of tags separated by comma, e.g. key1=value1,key2=value2"
25+
required: false
2326

2427
runs:
2528
using: "docker"
@@ -29,3 +32,4 @@ runs:
2932
INPUT_DESTINATION: ${{ inputs.destination }}
3033
INPUT_SECURITY_AGENT_API_ENDPOINT: ${{ inputs.security_agent_api_endpoint }}
3134
INPUT_SECURITY_AGENT_API_KEY: ${{ inputs.security_agent_api_key }}
35+
INPUT_TAGS: ${{ inputs.tags }}

upload-file/input/input.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package input
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"log"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
)
14+
15+
type Input struct {
16+
FilePath string
17+
Destination string
18+
SecurityAgentAPIEndpoint string
19+
SecurityAgentAPIToken string
20+
Tags map[string]string
21+
}
22+
23+
var (
24+
ErrFilePath = errors.New("file-path input is required")
25+
ErrSecurityAgentAPIKey = errors.New("security-agent-api-key input is required")
26+
)
27+
28+
func Parse() (*Input, error) {
29+
input := &Input{}
30+
31+
filePath := os.Getenv("INPUT_FILE_PATH")
32+
if filePath == "" {
33+
return nil, ErrFilePath
34+
}
35+
if filepath.Ext(filePath) != ".json" {
36+
log.Fatalf("file must be a JSON file, got: %s", filePath)
37+
}
38+
input.FilePath = filePath
39+
40+
securityAgentAPIEndpoint := os.Getenv("INPUT_SECURITY_AGENT_API_ENDPOINT")
41+
if securityAgentAPIEndpoint == "" {
42+
input.SecurityAgentAPIEndpoint = "https://security-agent.ddn.pro.hasura.io/graphql"
43+
}
44+
input.SecurityAgentAPIEndpoint = securityAgentAPIEndpoint
45+
46+
securityAgentAPIKey := os.Getenv("INPUT_SECURITY_AGENT_API_KEY")
47+
if securityAgentAPIKey == "" {
48+
return nil, ErrSecurityAgentAPIKey
49+
}
50+
input.SecurityAgentAPIToken = securityAgentAPIKey
51+
52+
destination := os.Getenv("INPUT_DESTINATION")
53+
if destination == "" {
54+
// Calculate SHA256 of file contents
55+
file, err := os.Open(filePath)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to open file: %v", err)
58+
}
59+
defer file.Close()
60+
hash, err := calculateFileSHA256(file)
61+
if err != nil {
62+
return nil, err
63+
}
64+
destination = "uploads/" + hash + ".json"
65+
}
66+
input.Destination = destination
67+
68+
tags := os.Getenv("INPUT_TAGS")
69+
if tags != "" {
70+
input.Tags = parseTags(tags)
71+
}
72+
73+
return input, nil
74+
}
75+
76+
func parseTags(tags string) map[string]string {
77+
tagMap := make(map[string]string)
78+
for _, tag := range strings.Split(tags, "\n") {
79+
tag = strings.TrimSpace(tag)
80+
if tag == "" {
81+
continue
82+
}
83+
kv := strings.SplitN(tag, "=", 2)
84+
if len(kv) == 2 {
85+
tagMap[kv[0]] = kv[1]
86+
}
87+
}
88+
return tagMap
89+
}
90+
91+
// calculateFileSHA256 calculates the SHA256 hash of a file's contents
92+
func calculateFileSHA256(file *os.File) (string, error) {
93+
hasher := sha256.New()
94+
_, err := io.Copy(hasher, file)
95+
if err != nil {
96+
return "", fmt.Errorf("failed to read file for hashing: %v", err)
97+
}
98+
99+
hashBytes := hasher.Sum(nil)
100+
return hex.EncodeToString(hashBytes), nil
101+
}

upload-file/input/input_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package input
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestParseTags(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
expected map[string]string
13+
}{
14+
{
15+
name: "empty string",
16+
input: "",
17+
expected: map[string]string{},
18+
},
19+
{
20+
name: "single tag",
21+
input: "key1=value1",
22+
expected: map[string]string{"key1": "value1"},
23+
},
24+
{
25+
name: "multiple tags",
26+
input: "key1=value1\nkey2=value2\nkey3=value3",
27+
expected: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"},
28+
},
29+
{
30+
name: "tag with empty value",
31+
input: "key1=\nkey2=value2",
32+
expected: map[string]string{"key1": "", "key2": "value2"},
33+
},
34+
{
35+
name: "tag with empty key",
36+
input: "=value1\nkey2=value2",
37+
expected: map[string]string{"": "value1", "key2": "value2"},
38+
},
39+
{
40+
name: "tag without equals sign (invalid)",
41+
input: "key1\nkey2=value2",
42+
expected: map[string]string{"key2": "value2"},
43+
},
44+
{
45+
name: "tag with multiple equals signs",
46+
input: "key1=value1=extra\nkey2=value2",
47+
expected: map[string]string{"key1": "value1=extra", "key2": "value2"},
48+
},
49+
{
50+
name: "tags with leading/trailing spaces",
51+
input: " key1=value1 \n key2=value2 \n key3=value3",
52+
expected: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"},
53+
},
54+
{
55+
name: "duplicate keys (last one wins)",
56+
input: "key1=value1\nkey1=value2",
57+
expected: map[string]string{"key1": "value2"},
58+
},
59+
{
60+
name: "special characters in values",
61+
input: "url=https://example.com\npath=/home/user\nemail=test@example.com",
62+
expected: map[string]string{"url": "https://example.com", "path": "/home/user", "email": "test@example.com"},
63+
},
64+
{
65+
name: "empty lines",
66+
input: "\n\n\n",
67+
expected: map[string]string{},
68+
},
69+
{
70+
name: "mixed valid and invalid tags with empty lines",
71+
input: "valid=value\ninvalid\n\nanother=good\n=empty_key\nno_value=\n",
72+
expected: map[string]string{"valid": "value", "another": "good", "": "empty_key", "no_value": ""},
73+
},
74+
{
75+
name: "values with commas",
76+
input: "list=item1,item2,item3\nurl=https://example.com/path?param1=value1,param2=value2",
77+
expected: map[string]string{"list": "item1,item2,item3", "url": "https://example.com/path?param1=value1,param2=value2"},
78+
},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
result := parseTags(tt.input)
84+
if !reflect.DeepEqual(result, tt.expected) {
85+
t.Errorf("parseTags(%q) = %v, want %v", tt.input, result, tt.expected)
86+
}
87+
})
88+
}
89+
}

0 commit comments

Comments
 (0)