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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ jobs:
working-directory: hooks/${{ matrix.unit }}/hook
run: ./gradlew build --info --warning-mode all

hook-sdk-golang:
name: "Unit-Test | Go Hook SDK"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Go Setup
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "hook-sdk/golang/go.mod"
- name: Test Go Hook SDK
working-directory: ./hook-sdk/golang
run: |
go fmt ./...
go vet ./...
go test ./...

# ---- Build Stage ----

# ---- Build Stage | Operator & Lurker ----
Expand Down Expand Up @@ -615,6 +631,11 @@ jobs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

- name: Go Setup
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "hooks/finding-post-processing/hook/go.mod"

- name: Install Task
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
with:
Expand Down
32 changes: 18 additions & 14 deletions .github/workflows/release-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,16 @@ jobs:
strategy:
matrix:
hook:
- cascading-scans
- finding-post-processing
- generic-webhook
- notification
- persistence-elastic
- persistence-defectdojo
- persistence-dependencytrack
- persistence-azure-monitor
- update-field-hook
- name: cascading-scans
- name: finding-post-processing
dockerContext: "."
- name: generic-webhook
- name: notification
- name: persistence-elastic
- name: persistence-defectdojo
- name: persistence-dependencytrack
- name: persistence-azure-monitor
- name: update-field-hook
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -237,7 +238,7 @@ jobs:
id: docker_meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.DOCKER_NAMESPACE }}/hook-${{ matrix.hook }}
images: ${{ env.DOCKER_NAMESPACE }}/hook-${{ matrix.hook.name }}
tags: |
type=sha
type=semver,pattern={{version}}
Expand All @@ -260,8 +261,11 @@ jobs:
- name: Build and Push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./hooks/${{ matrix.hook }}/hook
file: ./hooks/${{ matrix.hook }}/hook/Dockerfile
# Hooks default to their hook-local build context; hooks that need to
# import shared code from the repository (e.g. Go SDK hooks) set
# dockerContext in the matrix entry above.
context: ${{ matrix.hook.dockerContext || format('./hooks/{0}/hook', matrix.hook.name) }}
file: ./hooks/${{ matrix.hook.name }}/hook/Dockerfile
build-args: |
namespace=${{ env.DOCKER_NAMESPACE }}
baseImageTag=${{ env.baseImageTag }}
Expand All @@ -275,8 +279,8 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
repository: ${{ env.DOCKER_NAMESPACE }}/hook-${{ matrix.hook }}
readme-filepath: ./hooks/${{ matrix.hook }}/docs/README.DockerHub-Hook.md
repository: ${{ env.DOCKER_NAMESPACE }}/hook-${{ matrix.hook.name }}
readme-filepath: ./hooks/${{ matrix.hook.name }}/docs/README.DockerHub-Hook.md

# ---- Dashboard Importer ----

Expand Down
18 changes: 17 additions & 1 deletion documentation/docs/api/finding.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,30 @@ The 'findings.json' file that contains these Findings complies with the followin
"description": "Full URL with protocol, port, and path if existing.",
"type": "string",
"nullable": true
},
"osi_layer": {
"description": "OSI layer associated with the finding.",
"type": "string"
},
"scan": {
"description": "Contains information about the scan that identified the finding.",
"type": "object",
"properties": {
"created_at": {"type": "string", "format": "date-time"},
"name": {"type": "string"},
"namespace": {"type": "string"},
"scan_type": {"type": "string"}
},
"required": ["created_at", "name", "namespace", "scan_type"]
}
},
"required": [
"id",
"parsed_at",
"severity",
"category",
"name"
"name",
"scan"
]
}
}
Expand Down
122 changes: 122 additions & 0 deletions hook-sdk/golang/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!--
SPDX-FileCopyrightText: the secureCodeBox authors

SPDX-License-Identifier: Apache-2.0
-->

# Go Hook SDK

The Go Hook SDK provides the runtime integration needed to author secureCodeBox hooks in Go. It reads the hook runtime configuration, creates Kubernetes and file clients, and exposes the scan, raw results, and findings through `HookRequest`.

## Authoring a Hook with the Go Hook SDK

Implement `hooksdk.HookHandler` and pass it to `Client.Run`. The context passed to `Handle` must be passed to every `HookRequest` operation so cancellation and deadlines reach the Kubernetes API and result-file storage.

```go
package main

import (
"context"
"log"

hooksdk "github.com/secureCodeBox/secureCodeBox/hook-sdk/golang"
)

type handler struct{}

func (handler) Handle(ctx context.Context, request hooksdk.HookRequest) error {
findings, err := request.GetFindings(ctx)
if err != nil {
return err
}

for index := range findings {
findings[index].Severity = "HIGH"
}

return request.UpdateFindings(ctx, findings)
}

func main() {
client, err := hooksdk.NewClient()
if err != nil {
log.Fatal(err)
}
if err := client.Run(context.Background(), handler{}); err != nil {
log.Fatal(err)
}
}
```

The SDK requires these environment variables, which secureCodeBox provides to hook jobs:

| Variable | Description |
| --- | --- |
| `SCAN_NAME` | Name of the Scan being processed. |
| `NAMESPACE` | Namespace containing the Scan. |

The hook job also supplies result URLs as command-line arguments. They are consumed internally by the SDK; hook code should access results only through `HookRequest`.

## Hook Request Operations

All operations are deferred: calling `Client.Run` does not retrieve the Scan or download results. Each operation uses the `context.Context` supplied at the call site.

| Method | Description |
| --- | --- |
| `Scan(ctx)` | Retrieves the current Scan from the Kubernetes API. |
| `GetRawResults(ctx)` | Downloads the scanner's raw result text. |
| `GetFindings(ctx)` | Downloads, decodes, and validates the scanner findings. |
| `UpdateRawResults(ctx, content)` | Uploads replacement raw results. |
| `UpdateFindings(ctx, findings)` | Validates and uploads findings, then updates the Scan finding summary. |

`UpdateRawResults` and `UpdateFindings` are available only to ReadAndWrite hooks. A ReadOnly hook returns an error if either update method is called. Findings supplied to `UpdateFindings` must satisfy the SDK's finding validation rules.

Use `Scan(ctx)` only when the hook needs Scan metadata or spec/status data. A hook that only processes findings does not need to load the Scan.

## Testing

Use `hooksdk.HookRequestMock` to unit-test handlers without Kubernetes, object storage, or local hook runtime configuration. Configure only the request methods relevant to the test; unconfigured methods return their zero values and no error.

```go
func TestHandlerUpdatesFindings(t *testing.T) {
findings := []hooksdk.Finding{testFinding()}
updated := false

err := handler{}.Handle(context.Background(), &hooksdk.HookRequestMock{
GetFindingsFunc: func(context.Context) ([]hooksdk.Finding, error) {
return findings, nil
},
UpdateFindingsFunc: func(_ context.Context, result []hooksdk.Finding) error {
updated = true
findings = result
return nil
},
})
if err != nil {
t.Fatal(err)
}
if !updated {
t.Fatal("expected findings to be updated")
}
}
```

Run the SDK tests with:

```sh
go test ./...
```

The SDK's `Taskfile.yaml` also provides `task test`, which formats, vets, and tests the module.

## Local Development

When developing a hook in this repository, depend on the SDK module and use a local `replace` directive:

```go
require github.com/secureCodeBox/secureCodeBox/hook-sdk/golang v0.0.0

replace github.com/secureCodeBox/secureCodeBox/hook-sdk/golang => ../../../hook-sdk/golang
```

Adjust the replacement path for the location of your hook module.
17 changes: 17 additions & 0 deletions hook-sdk/golang/Taskfile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: the secureCodeBox authors
#
# SPDX-License-Identifier: Apache-2.0

version: "3.48.0"

tasks:
fmt:
cmds:
- go fmt ./...
vet:
cmds:
- go vet ./...
test:
deps: [fmt, vet]
cmds:
- go test ./...
91 changes: 91 additions & 0 deletions hook-sdk/golang/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: the secureCodeBox authors
//
// SPDX-License-Identifier: Apache-2.0

package hooksdk

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
)

type FileClient interface {
DownloadText(ctx context.Context, url string) (string, error)
DownloadJSON(ctx context.Context, url string, v any) error
Upload(ctx context.Context, url string, contentType string, body []byte) error
}

type httpFileClient struct {
client *http.Client
logger *slog.Logger
}

func NewFileClient(logger *slog.Logger) FileClient {
return &httpFileClient{client: &http.Client{}, logger: logger}
}

func (f *httpFileClient) DownloadText(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
resp, err := f.client.Do(req)
if err != nil {
return "", fmt.Errorf("download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("file download failed with status %d: %s", resp.StatusCode, body)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response body: %w", err)
}
return string(body), nil
}

func (f *httpFileClient) DownloadJSON(ctx context.Context, url string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := f.client.Do(req)
if err != nil {
return fmt.Errorf("download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("file download failed with status %d: %s", resp.StatusCode, body)
}
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(v); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
return nil
}

func (f *httpFileClient) Upload(ctx context.Context, url string, contentType string, body []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := f.client.Do(req)
if err != nil {
return fmt.Errorf("upload file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
responseBody, _ := io.ReadAll(resp.Body)
f.logger.Error("file upload failed", slog.Int("status_code", resp.StatusCode), slog.String("response_body", string(responseBody)))
return fmt.Errorf("file upload failed with status %d: %s", resp.StatusCode, responseBody)
}
return nil
}
Loading
Loading