Repository: google/osv-scanner
Pull request #460 (2023-08-01 04:40:18)

Add annotations and osv-scanner table in the Github Action output
Changed osv-diff action/binary to be a more general osv results reporter for github actions. This allows the logic to be in go code rather than having that logic in github action yaml lines. 

This PR adds annotations and osv-scanner table output to the github action in addition to the existing SARIF generation and uploading.

After this is merged, we have to point the resusable workflows back to main again rather than to this PR branch.

To see the annotation, you need to press Load Diff.

![image](https://github.com/google/osv-scanner/assets/106129829/a1f0d46c-21bd-4fa6-9c8b-e7c1c36d1e5e)



Patch:
diff --git a/.github/workflows/osv-scanner-reusable-pr.yml b/.github/workflows/osv-scanner-reusable-pr.yml
index 9bf1ce6..796daa1 100644
--- a/.github/workflows/osv-scanner-reusable-pr.yml
+++ b/.github/workflows/osv-scanner-reusable-pr.yml
@@ -43,13 +43,13 @@ jobs:
           results-file: new-results.json
           to-scan: .
         continue-on-error: true
-      - name: "Run osv-diff"
-        uses: google/osv-scanner/actions/diff@main
+      - name: "Run osv-scanner-reporter"
+        uses: another-rex/osv-scanner/actions/reporter@fix-github-action
         with:
-          results-format: sarif
-          output-file: final-results.sarif
+          output-sarif-file: final-results.sarif
           old-results: old-results.json
           new-results: new-results.json
+          gh-annotations: true
       # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
       # format to the repository Actions tab.
       - name: "Upload artifact"
diff --git a/.github/workflows/osv-scanner-reusable-scheduled.yml b/.github/workflows/osv-scanner-reusable-scheduled.yml
index cc3f247..06f403d 100644
--- a/.github/workflows/osv-scanner-reusable-scheduled.yml
+++ b/.github/workflows/osv-scanner-reusable-scheduled.yml
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-name: OSV-Scanner PR scanning
+name: OSV-Scanner Scheduled scanning
 
 on:
   workflow_call:
diff --git a/.github/workflows/osv-scanner-scheduled.yml b/.github/workflows/osv-scanner-scheduled.yml
index 60e77d5..fcd6827 100644
--- a/.github/workflows/osv-scanner-scheduled.yml
+++ b/.github/workflows/osv-scanner-scheduled.yml
@@ -21,7 +21,9 @@ on:
     branches: [ "main" ]
 
 # Declare default permissions as read only.
-permissions: read-all
+permissions: 
+  security-events: write
+  contents: read
 
 jobs:
   scan-pr-attempt:
diff --git a/actions/diff/action.yml b/actions/reporter/action.yml
similarity index 50%
rename from actions/diff/action.yml
rename to actions/reporter/action.yml
index 563a446..e4bb3e2 100644
--- a/actions/diff/action.yml
+++ b/actions/reporter/action.yml
@@ -1,16 +1,17 @@
 # Currently experimental.
-name: 'osv-scanner-diff'
-description: 'Finds the difference between two osv-scanner json results'
+name: 'osv-scanner-reporter'
+description: 'Specialized reporting of scanner results for github actions'
 inputs:
-  output-file:
-    description: 'Output path'
+  output-sarif-file:
+    description: 'Output SARIF file path'
     required: true
-  results-format:
-    description: 'Output result format'
-    default: 'sarif'
+  gh-annotations:
+    description: 'Whether to print github annotations'
+    default: false
   old-results:
     description: 'Old results to get the difference against'
-    required: true
+    required: false
+    default: ""
   new-results:
     description: 'New results to get the difference against'
     required: true
@@ -19,7 +20,7 @@ runs:
   image: '../../action.dockerfile'
   entrypoint: /root/osv-diff
   args:
-    - '--output=${{ inputs.output-file }}'
-    - '--format=${{ inputs.results-format }}'
+    - '--output=${{ inputs.output-sarif-file }}'
     - '--old=${{ inputs.old-results }}'
     - '--new=${{ inputs.new-results }}'
+    - '--gh-annotations=${{ inputs.gh-annotations }}'
diff --git a/cmd/osv-diff/main.go b/cmd/osv-diff/main.go
index 7d55135..a74ad2f 100644
--- a/cmd/osv-diff/main.go
+++ b/cmd/osv-diff/main.go
@@ -5,13 +5,12 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"strings"
 
 	"github.com/google/osv-scanner/internal/ci"
+	"github.com/google/osv-scanner/pkg/models"
 	"github.com/google/osv-scanner/pkg/osvscanner"
 	"github.com/google/osv-scanner/pkg/reporter"
 	"github.com/urfave/cli/v2"
-	"golang.org/x/exp/slices"
 	"golang.org/x/term"
 )
 
@@ -23,28 +22,29 @@ var (
 )
 
 func run(args []string, stdout, stderr io.Writer) int {
-	var r reporter.Reporter
+	var tableReporter reporter.Reporter
 
 	cli.VersionPrinter = func(ctx *cli.Context) {
 		// Use the app Writer and ErrWriter since they will be the writers to keep parallel tests consistent
-		r = reporter.NewTableReporter(ctx.App.Writer, ctx.App.ErrWriter, false, 0)
-		r.PrintText(fmt.Sprintf("osv-scanner version: %s\ncommit: %s\nbuilt at: %s\n", ctx.App.Version, commit, date))
+		tableReporter = reporter.NewTableReporter(ctx.App.Writer, ctx.App.ErrWriter, false, 0)
+		tableReporter.PrintText(fmt.Sprintf("osv-scanner version: %s\ncommit: %s\nbuilt at: %s\n", ctx.App.Version, commit, date))
 	}
 
 	app := &cli.App{
-		Name:        "osv-scanner-diff",
+		Name:        "osv-scanner-action-reporter",
 		Version:     version,
-		Usage:       "compares the output of multiple osv-scanner runs to find new vulnerabilities",
-		Description: "Remove vulnerabilities in the old OSV JSON output from the new OSV JSON output",
+		Usage:       "(Experimental) generates github action output",
+		Description: "(Experimental) Used specifically to generate github action output ",
 		Suggest:     true,
 		Writer:      stdout,
 		ErrWriter:   stderr,
 		Flags: []cli.Flag{
 			&cli.StringFlag{
-				Name:      "old",
-				Usage:     "the old osv json output",
-				TakesFile: true,
-				Required:  true,
+				Name:        "old",
+				Usage:       "the old osv json output",
+				TakesFile:   true,
+				Required:    false,
+				DefaultText: "",
 			},
 			&cli.StringFlag{
 				Name:      "new",
@@ -52,59 +52,39 @@ func run(args []string, stdout, stderr io.Writer) int {
 				TakesFile: true,
 				Required:  true,
 			},
-			&cli.StringFlag{
-				Name:    "format",
-				Aliases: []string{"f"},
-				Usage:   "sets the output format",
-				Value:   "table",
-				Action: func(context *cli.Context, s string) error {
-					if slices.Contains(reporter.Format(), s) {
-						return nil
-					}
-
-					return fmt.Errorf("unsupported output format \"%s\" - must be one of: %s", s, strings.Join(reporter.Format(), ", "))
-				},
-			},
 			&cli.StringFlag{
 				Name:      "output",
-				Usage:     "saves the result to the given file path",
+				Usage:     "saves the SARIF result to the given file path",
 				TakesFile: true,
 			},
+			&cli.BoolFlag{
+				Name:  "gh-annotations",
+				Usage: "prints github action annotations",
+			},
 		},
 		Action: func(context *cli.Context) error {
-			format := context.String("format")
-
-			outputPath := context.String("output")
-			if outputPath != "" {
-				var err error
-				stdout, err = os.Create(outputPath)
-				if err != nil {
-					return fmt.Errorf("failed to create output file: %w", err)
-				}
-			}
-
 			var termWidth int
-			if outputPath != "" {
-				var err error
-				termWidth, _, err = term.GetSize(int(os.Stdout.Fd()))
+			var err error
+			if stdoutAsFile, ok := stdout.(*os.File); ok {
+				termWidth, _, err = term.GetSize(int(stdoutAsFile.Fd()))
 				if err != nil { // If output is not a terminal,
 					termWidth = 0
 				}
-			} else { // Output is a file
-				termWidth = 0
 			}
 
-			var err error
-			if r, err = reporter.New(format, stdout, stderr, termWidth); err != nil {
+			if tableReporter, err = reporter.New("table", stdout, stderr, termWidth); err != nil {
 				return err
 			}
 
 			oldPath := context.String("old")
 			newPath := context.String("new")
 
-			oldVulns, err := ci.LoadVulnResults(oldPath)
-			if err != nil {
-				return fmt.Errorf("failed to open old results at %s: %w", oldPath, err)
+			oldVulns := models.VulnerabilityResults{}
+			if oldPath != "" {
+				oldVulns, err = ci.LoadVulnResults(oldPath)
+				if err != nil {
+					return fmt.Errorf("failed to open old results at %s: %w", oldPath, err)
+				}
 			}
 
 			newVulns, err := ci.LoadVulnResults(newPath)
@@ -114,10 +94,39 @@ func run(args []string, stdout, stderr io.Writer) int {
 
 			diffVulns := ci.DiffVulnerabilityResults(oldVulns, newVulns)
 
-			if errPrint := r.PrintResult(&diffVulns); errPrint != nil {
+			if errPrint := tableReporter.PrintResult(&diffVulns); errPrint != nil {
 				return fmt.Errorf("failed to write output: %w", errPrint)
 			}
 
+			if context.Bool("gh-annotations") {
+				var ghAnnotationsReporter reporter.Reporter
+				if ghAnnotationsReporter, err = reporter.New("gh-annotations", stdout, stderr, termWidth); err != nil {
+					return err
+				}
+
+				if errPrint := ghAnnotationsReporter.PrintResult(&diffVulns); errPrint != nil {
+					return fmt.Errorf("failed to write output: %w", errPrint)
+				}
+			}
+
+			outputPath := context.String("output")
+			if outputPath != "" {
+				var err error
+				stdout, err = os.Create(outputPath)
+				if err != nil {
+					return fmt.Errorf("failed to create output file: %w", err)
+				}
+				termWidth = 0
+				var sarifReporter reporter.Reporter
+				if sarifReporter, err = reporter.New("sarif", stdout, stderr, termWidth); err != nil {
+					return err
+				}
+
+				if errPrint := sarifReporter.PrintResult(&diffVulns); errPrint != nil {
+					return fmt.Errorf("failed to write output: %w", errPrint)
+				}
+			}
+
 			// if vulnerability exists it should return error
 			if len(diffVulns.Results) > 0 {
 				// If any vulnerabilities are called, then we return VulnerabilitiesFoundErr
@@ -135,8 +144,8 @@ func run(args []string, stdout, stderr io.Writer) int {
 	}
 
 	if err := app.Run(args); err != nil {
-		if r == nil {
-			r = reporter.NewTableReporter(stdout, stderr, false, 0)
+		if tableReporter == nil {
+			tableReporter = reporter.NewTableReporter(stdout, stderr, false, 0)
 		}
 		if errors.Is(err, osvscanner.VulnerabilitiesFoundErr) {
 			return 1
@@ -148,16 +157,16 @@ func run(args []string, stdout, stderr io.Writer) int {
 		}
 
 		if errors.Is(err, osvscanner.NoPackagesFoundErr) {
-			r.PrintError("No package sources found, --help for usage information.\n")
+			tableReporter.PrintError("No package sources found, --help for usage information.\n")
 			return 128
 		}
 
-		r.PrintError(fmt.Sprintf("%v\n", err))
+		tableReporter.PrintError(fmt.Sprintf("%v\n", err))
 	}
 
 	// if we've been told to print an error, and not already exited with
 	// a specific error code, then exit with a generic non-zero code
-	if r != nil && r.HasPrintedError() {
+	if tableReporter != nil && tableReporter.HasPrintedError() {
 		return 127
 	}
 
diff --git a/internal/output/githubannotation.go b/internal/output/githubannotation.go
new file mode 100644
index 0000000..72133eb
--- /dev/null
+++ b/internal/output/githubannotation.go
@@ -0,0 +1,46 @@
+package output
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+// PrintGHAnnotationReport prints Github specific annotations to outputWriter
+func PrintGHAnnotationReport(vulnResult *models.VulnerabilityResults, outputWriter io.Writer) error {
+	flattened := vulnResult.Flatten()
+
+	// TODO: Also support last affected
+	groupFixedVersions := GroupFixedVersions(flattened)
+	workingDir, err := os.Getwd()
+	if err != nil {
+		log.Panicf("can't get working dir: %v", err)
+	}
+
+	for _, source := range vulnResult.Results {
+		// TODO: Support docker images
+
+		var artifactPath string
+		var err error
+		artifactPath, err = filepath.Rel(workingDir, source.Source.Path)
+		if err != nil {
+			artifactPath = source.Source.Path
+		}
+
+		remediationTable := CreateSourceRemediationTable(source, groupFixedVersions)
+
+		renderedTable := remediationTable.Render()
+		// This is required as github action annotations must be on the same terminal line
+		// so we URL encode the new line character
+		renderedTable = strings.ReplaceAll(renderedTable, "\n", "%0A")
+		// Prepend the table with a new line to look nicer in the output
+		fmt.Fprintf(outputWriter, "::error file=%s::%s%s", artifactPath, artifactPath, "%0A"+renderedTable)
+	}
+
+	return nil
+}
diff --git a/internal/output/sarif.go b/internal/output/sarif.go
index bc1f14e..0a5274d 100644
--- a/internal/output/sarif.go
+++ b/internal/output/sarif.go
@@ -3,6 +3,7 @@ package output
 import (
 	"fmt"
 	"io"
+	"log"
 	"os"
 	"path/filepath"
 	"strings"
@@ -43,7 +44,7 @@ func GroupFixedVersions(flattened []models.VulnerabilityFlattened) map[string][]
 // CreateSourceRemediationTable creates a vulnerability table which includes the fixed versions for a specific source file
 func CreateSourceRemediationTable(source models.PackageSource, groupFixedVersions map[string][]string) table.Writer {
 	remediationTable := table.NewWriter()
-	remediationTable.AppendHeader(table.Row{"Package", "Vulnerability ID", "Current Version", "Fixed Version"})
+	remediationTable.AppendHeader(table.Row{"Package", "Vulnerability ID", "CVSS", "Current Version", "Fixed Version"})
 
 	for _, pv := range source.Packages {
 		for _, group := range pv.Groups {
@@ -56,6 +57,7 @@ func CreateSourceRemediationTable(source models.PackageSource, groupFixedVersion
 			remediationTable.AppendRow(table.Row{
 				pv.Package.Name,
 				strings.Join(vulnIDs, "\n"),
+				MaxSeverity(group, pv),
 				pv.Package.Version,
 				strings.Join(fixedVersions, "\n")})
 		}
@@ -78,18 +80,16 @@ func PrintSARIFReport(vulnResult *models.VulnerabilityResults, outputWriter io.W
 
 	// TODO: Also support last affected
 	groupFixedVersions := GroupFixedVersions(flattened)
-	workingDir, workingDirErr := os.Getwd()
-
+	workingDir, err := os.Getwd()
+	if err != nil {
+		log.Panicf("can't get working dir: %v", err)
+	}
 	for _, source := range vulnResult.Results {
 		// TODO: Support docker images
 
 		var artifactPath string
-		if workingDirErr == nil {
-			artifactPath, err = filepath.Rel(workingDir, source.Source.Path)
-			if err != nil {
-				artifactPath = source.Source.Path
-			}
-		} else {
+		artifactPath, err = filepath.Rel(workingDir, source.Source.Path)
+		if err != nil {
 			artifactPath = source.Source.Path
 		}
 		run.AddDistinctArtifact(artifactPath)
diff --git a/internal/output/table.go b/internal/output/table.go
index 11fc029..871f9d6 100644
--- a/internal/output/table.go
+++ b/internal/output/table.go
@@ -3,6 +3,8 @@ package output
 import (
 	"fmt"
 	"io"
+	"log"
+	"math"
 	"os"
 	"path/filepath"
 	"strings"
@@ -69,15 +71,16 @@ type tbInnerResponse struct {
 func tableBuilderInner(vulnResult *models.VulnerabilityResults, addStyling bool, calledVulns bool) []tbInnerResponse {
 	allOutputRows := []tbInnerResponse{}
 	// Working directory used to simplify path
-	workingDir, workingDirErr := os.Getwd()
+	workingDir, err := os.Getwd()
+	if err != nil {
+		log.Panicf("can't get working dir: %v", err)
+	}
 	for _, sourceRes := range vulnResult.Results {
 		for _, pkg := range sourceRes.Packages {
 			source := sourceRes.Source
-			if workingDirErr == nil {
-				sourcePath, err := filepath.Rel(workingDir, source.Path)
-				if err == nil { // Simplify the path if possible
-					source.Path = sourcePath
-				}
+			sourcePath, err := filepath.Rel(workingDir, source.Path)
+			if err == nil { // Simplify the path if possible
+				source.Path = sourcePath
 			}
 
 			// Merge groups into the same row
@@ -100,32 +103,7 @@ func tableBuilderInner(vulnResult *models.VulnerabilityResults, addStyling bool,
 				}
 
 				outputRow = append(outputRow, strings.Join(links, "\n"))
-
-				var outputSeverities []string
-				for _, vulnID := range group.IDs {
-					var severities []models.Severity
-					for _, vuln := range pkg.Vulnerabilities {
-						if vuln.ID == vulnID {
-							severities = vuln.Severity
-						}
-					}
-					for _, severity := range severities {
-						var outputSeverity string
-						switch severity.Type {
-						case models.SeverityCVSSV2:
-							numericSeverity, _ := v2_metric.NewBase().Decode(severity.Score)
-							outputSeverity = fmt.Sprintf("%v", numericSeverity.Score())
-						case models.SeverityCVSSV3:
-							numericSeverity, _ := v3_metric.NewBase().Decode(severity.Score)
-							outputSeverity = fmt.Sprintf("%v", numericSeverity.Score())
-						default:
-							outputSeverity = severity.Score
-						}
-
-						outputSeverities = append(outputSeverities, outputSeverity)
-					}
-				}
-				outputRow = append(outputRow, strings.Join(outputSeverities, "\n"))
+				outputRow = append(outputRow, MaxSeverity(group, pkg))
 
 				if pkg.Package.Ecosystem == "GIT" {
 					outputRow = append(outputRow, "GIT", pkg.Package.Version, pkg.Package.Version)
@@ -145,3 +123,31 @@ func tableBuilderInner(vulnResult *models.VulnerabilityResults, addStyling bool,
 
 	return allOutputRows
 }
+
+func MaxSeverity(group models.GroupInfo, pkg models.PackageVulns) string {
+	var maxSeverity float64
+	for _, vulnID := range group.IDs {
+		var severities []models.Severity
+		for _, vuln := range pkg.Vulnerabilities {
+			if vuln.ID == vulnID {
+				severities = vuln.Severity
+			}
+		}
+		for _, severity := range severities {
+			switch severity.Type {
+			case models.SeverityCVSSV2:
+				numericSeverity, _ := v2_metric.NewBase().Decode(severity.Score)
+				maxSeverity = math.Max(maxSeverity, numericSeverity.Score())
+			case models.SeverityCVSSV3:
+				numericSeverity, _ := v3_metric.NewBase().Decode(severity.Score)
+				maxSeverity = math.Max(maxSeverity, numericSeverity.Score())
+			}
+		}
+	}
+
+	if maxSeverity == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("%v", maxSeverity)
+}
diff --git a/pkg/models/results.go b/pkg/models/results.go
index dbb1520..4bc7d76 100644
--- a/pkg/models/results.go
+++ b/pkg/models/results.go
@@ -100,6 +100,10 @@ func (v *Vulnerability) FixedVersions() map[Package][]string {
 			for _, e := range r.Events {
 				if e.Fixed != "" {
 					output[packageKey] = append(output[packageKey], e.Fixed)
+					if strings.Contains(string(packageKey.Ecosystem), ":") {
+						packageKey.Ecosystem = Ecosystem(strings.Split(string(packageKey.Ecosystem), ":")[0])
+					}
+					output[packageKey] = append(output[packageKey], e.Fixed)
 				}
 			}
 		}
diff --git a/pkg/reporter/format.go b/pkg/reporter/format.go
index ab02e40..9cb1f70 100644
--- a/pkg/reporter/format.go
+++ b/pkg/reporter/format.go
@@ -5,7 +5,7 @@ import (
 	"io"
 )
 
-var format = []string{"table", "json", "markdown", "sarif"}
+var format = []string{"table", "json", "markdown", "sarif", "gh-annotations"}
 
 func Format() []string {
 	return format
@@ -23,6 +23,8 @@ func New(format string, stdout, stderr io.Writer, terminalWidth int) (Reporter,
 		return NewTableReporter(stdout, stderr, true, terminalWidth), nil
 	case "sarif":
 		return NewSarifReporter(stdout, stderr), nil
+	case "gh-annotations":
+		return NewGHAnnotationsReporter(stdout, stderr), nil
 	default:
 		return nil, fmt.Errorf("%v is not a valid format", format)
 	}
diff --git a/pkg/reporter/gh-annotations_reporter.go b/pkg/reporter/gh-annotations_reporter.go
new file mode 100644
index 0000000..01ca072
--- /dev/null
+++ b/pkg/reporter/gh-annotations_reporter.go
@@ -0,0 +1,40 @@
+package reporter
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/google/osv-scanner/internal/output"
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+type GHAnnotationsReporter struct {
+	hasPrintedError bool
+	stdout          io.Writer
+	stderr          io.Writer
+}
+
+func NewGHAnnotationsReporter(stdout io.Writer, stderr io.Writer) *GHAnnotationsReporter {
+	return &GHAnnotationsReporter{
+		stdout:          stdout,
+		stderr:          stderr,
+		hasPrintedError: false,
+	}
+}
+
+func (r *GHAnnotationsReporter) PrintError(msg string) {
+	fmt.Fprint(r.stderr, msg)
+	r.hasPrintedError = true
+}
+
+func (r *GHAnnotationsReporter) HasPrintedError() bool {
+	return r.hasPrintedError
+}
+
+func (r *GHAnnotationsReporter) PrintText(msg string) {
+	fmt.Fprint(r.stderr, msg)
+}
+
+func (r *GHAnnotationsReporter) PrintResult(vulnResult *models.VulnerabilityResults) error {
+	return output.PrintGHAnnotationReport(vulnResult, r.stderr)
+}


Test patch:
diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go
index 02a74e8..d3153f0 100644
--- a/cmd/osv-scanner/main_test.go
+++ b/cmd/osv-scanner/main_test.go
@@ -369,7 +369,7 @@ func TestRun(t *testing.T) {
 									"ruleIndex": 0,
 									"level": "warning",
 									"message": {
-										"text": "+-----------+-------------------------------------+-----------------+---------------+\n| PACKAGE \u0026nbsp; | VULNERABILITY ID \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp;| CURRENT VERSION | FIXED VERSION |\n+-----------+-------------------------------------+-----------------+---------------+\n| ansi-html | https://osv.dev/GHSA-whgm-jr23-g3j9 | 0.0.1 \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; | 0.0.8 \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; |\n+-----------+-------------------------------------+-----------------+---------------+"
+										"text": "+-----------+-------------------------------------+------+-----------------+---------------+\n| PACKAGE \u0026nbsp; | VULNERABILITY ID \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp;| CVSS | CURRENT VERSION | FIXED VERSION |\n+-----------+-------------------------------------+------+-----------------+---------------+\n| ansi-html | https://osv.dev/GHSA-whgm-jr23-g3j9 | 7.5 \u0026nbsp;| 0.0.1 \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; | 0.0.8 \u0026nbsp; \u0026nbsp; \u0026nbsp; \u0026nbsp; |\n+-----------+-------------------------------------+------+-----------------+---------------+"
 									},
 									"locations": [
 										{