diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..5e7df1c --- /dev/null +++ b/.github/README.md @@ -0,0 +1,122 @@ +# go-test-sarif + +[![CI](https://github.com/ivuorinen/go-test-sarif/actions/workflows/test.yml/badge.svg)](https://github.com/ivuorinen/go-test-sarif/actions/workflows/test.yml) + +`go-test-sarif` is a CLI tool and GitHub Action for converting `go test -json` output into SARIF format, making it compatible with GitHub Security Tab and other SARIF consumers. + +## 🚀 Features + +- Converts `go test -json` output to **SARIF format**. +- **GitHub Action integration** for CI/CD pipelines. +- Generates structured test failure reports for **security and compliance tools**. +- Works as a **standalone CLI tool**. + +## 📦 Installation + +### Using `go install` + +```sh +go install github.com/ivuorinen/go-test-sarif@latest +``` + +### Using Docker + +```sh +docker pull ghcr.io/ivuorinen/go-test-sarif:latest +``` + +## 🛠️ Usage + +### CLI Usage + +```sh +go test -json ./... > go-test-results.json +go-test-sarif go-test-results.json go-test-results.sarif +``` + +### Docker Usage + +```sh +docker run --rm -v $(pwd):/workspace ghcr.io/ivuorinen/go-test-sarif go-test-results.json go-test-results.sarif +``` + +### GitHub Action Usage + +Add the following step to your GitHub Actions workflow: + +```yaml +- name: Convert JSON to SARIF + uses: ivuorinen/go-test-sarif@v1 + with: + test_results: go-test-results.json +``` + +To upload the SARIF file to GitHub Security Tab, add: + +```yaml +- name: Upload SARIF report + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: go-test-results.sarif +``` + +## 📜 Output Example + +SARIF report example: +```json +{ + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Go Test", + "informationUri": "https://golang.org/cmd/go/#hdr-Test_packages", + "version": "1.0.0" + } + }, + "results": [ + { + "ruleId": "go-test-failure", + "level": "error", + "message": { + "text": "Test failed" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "github.com/example/package" + } + } + } + ] + } + ] + } + ] +} +``` + +## 🏗 Development + +Clone the repository and build the project: +```sh +git clone https://github.com/ivuorinen/go-test-sarif.git +cd go-test-sarif +go build -o go-test-sarif ./cmd/main.go +``` + +Run tests: + +```sh +go test ./... +``` + +## 📄 License + +This project is licensed under the **MIT License**. + +## 🤝 Contributing + +Pull requests are welcome! For major changes, please open an issue first to discuss the changes. diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 02bb9ce..0000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Release Drafter - -# yamllint disable-line rule:truthy -on: - workflow_call: - -permissions: - contents: write - statuses: write - -jobs: - Draft: - uses: ivuorinen/.github/.github/workflows/sync-labels.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3e6eee0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Run Go Tests and Generate SARIF + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Run Go Tests + run: go test -json ./... > go-test-results.json + + - name: Convert JSON to SARIF + uses: ivuorinen/go-test-sarif@v1 + with: + test_results: go-test-results.json + + - name: Upload SARIF to GitHub Security Tab + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: go-test-results.sarif diff --git a/.gitignore b/.gitignore index e2a1faa..a4c0bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,83 +1,7 @@ -.php-cs-fixer.cache -.php-cs-fixer.php -composer.phar -/vendor/ -.phpunit.result.cache -.phpunit.cache -/app/phpunit.xml -/phpunit.xml -/build/ logs *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -pids -*.pid -*.seed -*.pid.lock -lib-cov -coverage -*.lcov -.nyc_output -.grunt -bower_components -.lock-wscript -build/Release -node_modules/ -jspm_packages/ -web_modules/ -*.tsbuildinfo -.npm -.eslintcache -.stylelintcache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ -.node_repl_history *.tgz -.yarn-integrity -.env -.env.development.local -.env.test.local -.env.production.local -.env.local -.cache -.parcel-cache -.next -out -.nuxt -dist -.cache/ -.vuepress/dist -.temp -.docusaurus -.serverless/ -.fusebox/ -.dynamodb/ -.tern-port -.vscode-test -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* -[._]*.s[a-v][a-z] -!*.svg # comment out if you don't need vector files -[._]*.sw[a-p] -[._]s[a-rt-v][a-z] -[._]ss[a-gi-z] -[._]sw[a-p] -Session.vim -Sessionx.vim -.netrwhist *~ -tags -[._]*.un~ .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml @@ -94,41 +18,10 @@ tags .idea/**/dbnavigator.xml .idea/**/gradle.xml .idea/**/libraries -cmake-build-*/ -.idea/**/mongoSettings.xml -*.iws -out/ -.idea_modules/ -atlassian-ide-plugin.xml .idea/replstate.xml .idea/sonarlint/ -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties .idea/httpRequests .idea/caches/build_file_checksums.ser -npm-debug.log -yarn-error.log -bootstrap/compiled.php -app/storage/ -public/storage -public/hot -public_html/storage -public_html/hot -storage/*.key -Homestead.yaml -Homestead.json -/.vagrant -/node_modules -/.pnp -.pnp.js -/coverage -/.next/ -/out/ -/build .DS_Store -*.pem -.env*.local -.vercel -next-env.d.ts +bin/* +!bin/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9a17c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.21-alpine AS build +WORKDIR /app +COPY . . +RUN go build -o /go-test-sarif ./cmd/main.go + +FROM alpine:latest +COPY --from=build /go-test-sarif /go-test-sarif +COPY action/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..3095e4c --- /dev/null +++ b/Justfile @@ -0,0 +1,40 @@ +# Set the application name +app_name := "go-test-sarif" +binary_path := "./bin/" + app_name +src := "./cmd/main.go" + +# Default task +default: + just build + +# Build the Go binary +build: + echo "Building {{app_name}}..." + mkdir -p bin + GOOS=linux GOARCH=amd64 go build -o {{binary_path}} {{src}} + echo "Binary built at {{binary_path}}" + +# Run tests +test: + echo "Running tests..." + go test ./... -v + +# Run the application +run: + echo "Running {{app_name}}..." + {{binary_path}} go-test-results.json go-test-results.sarif + +# Clean build artifacts +clean: + echo "Cleaning up..." + rm -rf bin go-test-results.sarif + +# Build the Docker image +docker-build: + echo "Building Docker image..." + docker build -t ghcr.io/ivuorinen/{{app_name}}:latest . + +# Run the application inside Docker +docker-run: + echo "Running {{app_name}} in Docker..." + docker run --rm -v $(pwd):/workspace ghcr.io/ivuorinen/{{app_name}} go-test-results.json go-test-results.sarif diff --git a/action/entrypoint.sh b/action/entrypoint.sh new file mode 100644 index 0000000..d5fbbbc --- /dev/null +++ b/action/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +if [ -z "$INPUT_TEST_RESULTS" ]; then + echo "Missing test results input file" + exit 1 +fi + +OUTPUT_FILE="go-test-results.sarif" + +/go-test-sarif "$INPUT_TEST_RESULTS" "$OUTPUT_FILE" + +echo "Generated SARIF report: $OUTPUT_FILE" diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..1a10ffe --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,25 @@ +// main package contains the cli functionality +package main + +import ( + "fmt" + "os" + + "github.com/ivuorinen/go-test-sarif/internal" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: go-test-sarif ") + os.Exit(1) + } + + inputFile := os.Args[1] + outputFile := os.Args[2] + + err := internal.ConvertToSARIF(inputFile, outputFile) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1b79db5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/ivuorinen/go-test-sarif + +go 1.24.1 diff --git a/internal/converter.go b/internal/converter.go new file mode 100644 index 0000000..9088f83 --- /dev/null +++ b/internal/converter.go @@ -0,0 +1,75 @@ +package internal + +import ( + "encoding/json" + "fmt" + "io/ioutil" +) + +// TestResult represents a single test result from 'go test -json' output. +type TestResult struct { + Action string `json:"Action"` + Package string `json:"Package"` + Output string `json:"Output"` +} + +// ConvertToSARIF converts Go test JSON results to SARIF format. +func ConvertToSARIF(inputFile, outputFile string) error { + // Read the input file + data, err := ioutil.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read input file: %w", err) + } + + // Parse the JSON data + var testResults []TestResult + if err := json.Unmarshal(data, &testResults); err != nil { + return fmt.Errorf("invalid JSON format: %w", err) + } + + // Convert test results to SARIF format + sarifData := map[string]interface{}{ + "version": "2.1.0", + "runs": []map[string]interface{}{ + { + "tool": map[string]interface{}{ + "driver": map[string]interface{}{ + "name": "go-test-sarif", + "version": "1.0.0", + }, + }, + "results": convertResults(testResults), + }, + }, + } + + // Marshal SARIF data to JSON + sarifJSON, err := json.MarshalIndent(sarifData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal SARIF data: %w", err) + } + + // Write the SARIF JSON to the output file + if err := ioutil.WriteFile(outputFile, sarifJSON, 0644); err != nil { + return fmt.Errorf("failed to write SARIF output file: %w", err) + } + + fmt.Printf("SARIF report generated: %s\n", outputFile) + return nil +} + +// convertResults transforms test results into SARIF result objects. +func convertResults(testResults []TestResult) []map[string]interface{} { + var results []map[string]interface{} + for _, tr := range testResults { + if tr.Action == "fail" { + results = append(results, map[string]interface{}{ + "ruleId": "go-test-failure", + "message": map[string]string{"text": tr.Output}, + "level": "error", + "locations": []map[string]interface{}{}, + }) + } + } + return results +} diff --git a/internal/converter_test.go b/internal/converter_test.go new file mode 100644 index 0000000..4698056 --- /dev/null +++ b/internal/converter_test.go @@ -0,0 +1,94 @@ +package internal + +import ( + "os" + "testing" +) + +// TestConvertToSARIF_Success tests the successful conversion of a valid Go test JSON output to SARIF format. +func TestConvertToSARIF_Success(t *testing.T) { + // Create a temporary JSON input file with valid test data + inputFile, err := os.CreateTemp("", "test_input_*.json") + if err != nil { + t.Fatalf("Failed to create temp input file: %v", err) + } + defer os.Remove(inputFile.Name()) + + inputContent := `[{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif/internal","Output":"Test failed"}]` + if _, err := inputFile.WriteString(inputContent); err != nil { + t.Fatalf("Failed to write to temp input file: %v", err) + } + + // Create a temporary SARIF output file + outputFile, err := os.CreateTemp("", "test_output_*.sarif") + if err != nil { + t.Fatalf("Failed to create temp output file: %v", err) + } + defer os.Remove(outputFile.Name()) + + // Run the ConvertToSARIF function + err = ConvertToSARIF(inputFile.Name(), outputFile.Name()) + if err != nil { + t.Errorf("ConvertToSARIF returned an error: %v", err) + } + + // Read and validate the SARIF output + outputContent, err := os.ReadFile(outputFile.Name()) + if err != nil { + t.Fatalf("Failed to read SARIF output file: %v", err) + } + + // Perform basic validation on the SARIF output + if len(outputContent) == 0 { + t.Errorf("SARIF output is empty") + } + + // Additional validations can be added here to verify the correctness of the SARIF content +} + +// TestConvertToSARIF_InvalidInput tests the function's behavior when provided with invalid JSON input. +func TestConvertToSARIF_InvalidInput(t *testing.T) { + // Create a temporary JSON input file with invalid test data + inputFile, err := os.CreateTemp("", "test_input_invalid_*.json") + if err != nil { + t.Fatalf("Failed to create temp input file: %v", err) + } + defer os.Remove(inputFile.Name()) + + inputContent := `{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif/internal","Output":Test failed}` // Missing quotes around 'Test failed' + if _, err := inputFile.WriteString(inputContent); err != nil { + t.Fatalf("Failed to write to temp input file: %v", err) + } + + // Create a temporary SARIF output file + outputFile, err := os.CreateTemp("", "test_output_invalid_*.sarif") + if err != nil { + t.Fatalf("Failed to create temp output file: %v", err) + } + defer os.Remove(outputFile.Name()) + + // Run the ConvertToSARIF function + err = ConvertToSARIF(inputFile.Name(), outputFile.Name()) + if err == nil { + t.Errorf("Expected an error for invalid JSON input, but got none") + } +} + +// TestConvertToSARIF_FileNotFound tests the function's behavior when the input file does not exist. +func TestConvertToSARIF_FileNotFound(t *testing.T) { + // Define a non-existent input file path + inputFile := "non_existent_file.json" + + // Create a temporary SARIF output file + outputFile, err := os.CreateTemp("", "test_output_notfound_*.sarif") + if err != nil { + t.Fatalf("Failed to create temp output file: %v", err) + } + defer os.Remove(outputFile.Name()) + + // Run the ConvertToSARIF function + err = ConvertToSARIF(inputFile, outputFile.Name()) + if err == nil { + t.Errorf("Expected an error for non-existent input file, but got none") + } +}