Repository: google/osv-scanner
Pull request #463 (2023-08-02 01:31:50)

refactor: move experimental flags into their own struct
This pulls across the experimental struct from #183 which I'm assuming folks are happy with given that PR is approved - I think it is good to land this in its own PR as its technically an allowed breaking change (or "experimental change" if you will), and that way it can be explicitly linked to in changelogs etc without having to understand #183 as much.


Patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 4a5fde9..107b12b 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -44,7 +44,7 @@ jobs:
 
       # Initializes the CodeQL tools for scanning.
       - name: Initialize CodeQL
-        uses: github/codeql-action/init@1813ca74c3faaa3a2da2070b9b8a0b3e7373a0d8 # v2.21.0
+        uses: github/codeql-action/init@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2
         with:
           languages: ${{ matrix.language }}
           # If you wish to specify custom queries, you can do so here or in a config file.
@@ -55,7 +55,7 @@ jobs:
       # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
       # If this step fails, then you should remove it and run the build manually (see below)
       - name: Autobuild
-        uses: github/codeql-action/autobuild@1813ca74c3faaa3a2da2070b9b8a0b3e7373a0d8 # v2.21.0
+        uses: github/codeql-action/autobuild@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2
 
       # ℹ️ Command-line programs to run using the OS shell.
       # 📚 https://git.io/JvXDl
@@ -69,4 +69,4 @@ jobs:
       #   make release
 
       - name: Perform CodeQL Analysis
-        uses: github/codeql-action/analyze@1813ca74c3faaa3a2da2070b9b8a0b3e7373a0d8 # v2.21.0
+        uses: github/codeql-action/analyze@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2
diff --git a/.github/workflows/osv-scanner-reusable-pr.yml b/.github/workflows/osv-scanner-reusable-pr.yml
index 9bf1ce6..ab1f7c8 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"
@@ -76,7 +76,7 @@ jobs:
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
         if: '!cancelled()'
-        uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0
+        uses: github/codeql-action/upload-sarif@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2
         with:
           sarif_file: final-results.sarif
 
diff --git a/.github/workflows/osv-scanner-reusable-scheduled.yml b/.github/workflows/osv-scanner-reusable-scheduled.yml
index cc3f247..21c0232 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:
@@ -41,7 +41,7 @@ jobs:
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
         if: '!cancelled()'
-        uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0
+        uses: github/codeql-action/upload-sarif@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2
         with:
           sarif_file: results.sarif
 
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/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
index 1673667..e81cc02 100644
--- a/.github/workflows/scorecards.yml
+++ b/.github/workflows/scorecards.yml
@@ -67,6 +67,6 @@ jobs:
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@1813ca74c3faaa3a2da2070b9b8a0b3e7373a0d8 # v2.21.0
+        uses: github/codeql-action/upload-sarif@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2
         with:
           sarif_file: results.sarif
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/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go
index 2a9ec4c..bdf5271 100644
--- a/cmd/osv-scanner/main.go
+++ b/cmd/osv-scanner/main.go
@@ -140,15 +140,17 @@ func run(args []string, stdout, stderr io.Writer) int {
 			}
 
 			vulnResult, err := osvscanner.DoScan(osvscanner.ScannerActions{
-				LockfilePaths:            context.StringSlice("lockfile"),
-				SBOMPaths:                context.StringSlice("sbom"),
-				DockerContainerNames:     context.StringSlice("docker"),
-				Recursive:                context.Bool("recursive"),
-				SkipGit:                  context.Bool("skip-git"),
-				NoIgnore:                 context.Bool("no-ignore"),
-				ConfigOverridePath:       context.String("config"),
-				DirectoryPaths:           context.Args().Slice(),
-				ExperimentalCallAnalysis: context.Bool("experimental-call-analysis"),
+				LockfilePaths:        context.StringSlice("lockfile"),
+				SBOMPaths:            context.StringSlice("sbom"),
+				DockerContainerNames: context.StringSlice("docker"),
+				Recursive:            context.Bool("recursive"),
+				SkipGit:              context.Bool("skip-git"),
+				NoIgnore:             context.Bool("no-ignore"),
+				ConfigOverridePath:   context.String("config"),
+				DirectoryPaths:       context.Args().Slice(),
+				ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{
+					CallAnalysis: context.Bool("experimental-call-analysis"),
+				},
 			}, r)
 
 			if err != nil &&
diff --git a/go.mod b/go.mod
index 9cd24f3..4e347ae 100644
--- a/go.mod
+++ b/go.mod
@@ -6,16 +6,16 @@ require (
 	github.com/BurntSushi/toml v1.3.2
 	github.com/CycloneDX/cyclonedx-go v0.7.1
 	github.com/go-git/go-billy/v5 v5.4.1
-	github.com/go-git/go-git/v5 v5.8.0
+	github.com/go-git/go-git/v5 v5.8.1
 	github.com/goark/go-cvss v1.6.6
 	github.com/google/go-cmp v0.5.9
 	github.com/jedib0t/go-pretty/v6 v6.4.6
 	github.com/kr/pretty v0.3.1
 	github.com/owenrumney/go-sarif/v2 v2.2.0
 	github.com/package-url/packageurl-go v0.1.1
-	github.com/spdx/tools-golang v0.5.2
+	github.com/spdx/tools-golang v0.5.3
 	github.com/urfave/cli/v2 v2.25.7
-	golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
+	golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b
 	golang.org/x/mod v0.12.0
 	golang.org/x/sync v0.3.0
 	golang.org/x/term v0.10.0
@@ -24,8 +24,9 @@ require (
 )
 
 require (
-	github.com/Microsoft/go-winio v0.5.2 // indirect
-	github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 // indirect
+	dario.cat/mergo v1.0.0 // indirect
+	github.com/Microsoft/go-winio v0.6.1 // indirect
+	github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
 	github.com/acomagu/bufpipe v1.0.4 // indirect
 	github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect
 	github.com/cloudflare/circl v1.3.3 // indirect
@@ -34,7 +35,6 @@ require (
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/goark/errs v1.1.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/imdario/mergo v0.3.15 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
@@ -44,7 +44,7 @@ require (
 	github.com/rogpeppe/go-internal v1.9.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/sergi/go-diff v1.1.0 // indirect
-	github.com/skeema/knownhosts v1.1.1 // indirect
+	github.com/skeema/knownhosts v1.2.0 // indirect
 	github.com/spdx/gordf v0.0.0-20221230105357-b735bd5aac89 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
diff --git a/go.sum b/go.sum
index 6eab4c1..b58c1af 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,14 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/CycloneDX/cyclonedx-go v0.7.1 h1:5w1SxjGm9MTMNTuRbEPyw21ObdbaagTWF/KfF0qHTRE=
 github.com/CycloneDX/cyclonedx-go v0.7.1/go.mod h1:N/nrdWQI2SIjaACyyDs/u7+ddCkyl/zkNs8xFsHF2Ps=
-github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
-github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek=
-github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
+github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
+github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=
+github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
 github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc=
@@ -14,8 +17,7 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
-github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
-github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
 github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
 github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
@@ -33,8 +35,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmS
 github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
 github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
-github.com/go-git/go-git/v5 v5.8.0 h1:Rc543s6Tyq+YcyPwZRvU4jzZGM8rB/wWu94TnTIYALQ=
-github.com/go-git/go-git/v5 v5.8.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
+github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A=
+github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo=
 github.com/goark/errs v1.1.0 h1:FKnyw4LVyRADIjM8Nj0Up6r0/y5cfADvZAd1E+tthXE=
 github.com/goark/errs v1.1.0/go.mod h1:TtaPEoadm2mzqzfXdkkfpN2xuniCFm2q4JH+c1qzaqw=
 github.com/goark/go-cvss v1.6.6 h1:WJFuIWqmAw1Ilb9USv0vuX+nYzOWJp8lIujseJ/y3sU=
@@ -46,8 +48,6 @@ github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaW
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
-github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jedib0t/go-pretty/v6 v6.4.6 h1:v6aG9h6Uby3IusSSEjHaZNXpHFhzqMmjXcPq1Rjl9Jw=
@@ -89,13 +89,13 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/skeema/knownhosts v1.1.1 h1:MTk78x9FPgDFVFkDLTrsnnfCJl7g1C/nnKvePgrIngE=
-github.com/skeema/knownhosts v1.1.1/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
+github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
+github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
 github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM=
 github.com/spdx/gordf v0.0.0-20221230105357-b735bd5aac89 h1:dArkMwZ7Mf2JiU8OfdmqIv8QaHT4oyifLIe1UhsF1SY=
 github.com/spdx/gordf v0.0.0-20221230105357-b735bd5aac89/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM=
-github.com/spdx/tools-golang v0.5.2 h1:dtMNjJreWPe37584ajk7m/rQtfJaLpRMk7pUGgvekOg=
-github.com/spdx/tools-golang v0.5.2/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
+github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
+github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -120,11 +120,12 @@ github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uU
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
 golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
 golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
-golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
-golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI=
+golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
@@ -135,6 +136,7 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
@@ -150,11 +152,11 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -162,6 +164,7 @@ golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
 golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
@@ -172,6 +175,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
diff --git a/internal/output/form.go b/internal/output/form.go
new file mode 100644
index 0000000..f5e5a4d
--- /dev/null
+++ b/internal/output/form.go
@@ -0,0 +1,10 @@
+package output
+
+// Form returns the singular or plural form that should be used based on the given count
+func Form(count int, singular, plural string) string {
+	if count == 1 {
+		return singular
+	}
+
+	return plural
+}
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/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go
index d3c8d3f..e220d8c 100644
--- a/pkg/osvscanner/osvscanner.go
+++ b/pkg/osvscanner/osvscanner.go
@@ -9,6 +9,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/google/osv-scanner/internal/output"
 	"github.com/google/osv-scanner/internal/sbom"
 	"github.com/google/osv-scanner/pkg/config"
 	"github.com/google/osv-scanner/pkg/lockfile"
@@ -33,7 +34,11 @@ type ScannerActions struct {
 	DockerContainerNames []string
 	ConfigOverridePath   string
 
-	ExperimentalCallAnalysis bool
+	ExperimentalScannerActions
+}
+
+type ExperimentalScannerActions struct {
+	CallAnalysis bool
 }
 
 // NoPackagesFoundErr for when no packages are found during a scan.
@@ -205,7 +210,13 @@ func scanLockfile(r reporter.Reporter, query *osv.BatchedQuery, path string, par
 		parsedAsComment = fmt.Sprintf("as a %s ", parseAs)
 	}
 
-	r.PrintText(fmt.Sprintf("Scanned %s file %sand found %d packages\n", path, parsedAsComment, len(parsedLockfile.Packages)))
+	r.PrintText(fmt.Sprintf(
+		"Scanned %s file %sand found %d %s\n",
+		path,
+		parsedAsComment,
+		len(parsedLockfile.Packages),
+		output.Form(len(parsedLockfile.Packages), "package", "packages"),
+	))
 
 	for _, pkgDetail := range parsedLockfile.Packages {
 		pkgDetailQuery := osv.MakePkgRequest(pkgDetail)
@@ -273,9 +284,19 @@ func scanSBOMFile(r reporter.Reporter, query *osv.BatchedQuery, path string, fro
 
 				continue
 			}
-			r.PrintText(fmt.Sprintf("Scanned %s as %s SBOM and found %d packages\n", path, provider.Name(), count))
+			r.PrintText(fmt.Sprintf(
+				"Scanned %s as %s SBOM and found %d %s\n",
+				path,
+				provider.Name(),
+				count,
+				output.Form(count, "package", "packages"),
+			))
 			if ignoredCount > 0 {
-				r.PrintText(fmt.Sprintf("Ignored %d packages with invalid PURLs\n", ignoredCount))
+				r.PrintText(fmt.Sprintf(
+					"Ignored %d %s with invalid PURLs\n",
+					ignoredCount,
+					output.Form(ignoredCount, "package", "packages"),
+				))
 			}
 
 			return nil
@@ -378,7 +399,11 @@ func scanDebianDocker(r reporter.Reporter, query *osv.BatchedQuery, dockerImageN
 		query.Queries = append(query.Queries, pkgDetailsQuery)
 		packages += 1
 	}
-	r.PrintText(fmt.Sprintf("Scanned docker image with %d packages\n", packages))
+	r.PrintText(fmt.Sprintf(
+		"Scanned docker image with %d %s\n",
+		packages,
+		output.Form(packages, "package", "packages"),
+	))
 
 	return nil
 }
@@ -550,11 +575,15 @@ func DoScan(actions ScannerActions, r reporter.Reporter) (models.VulnerabilityRe
 		return models.VulnerabilityResults{}, fmt.Errorf("failed to hydrate OSV response: %w", err)
 	}
 
-	vulnerabilityResults := groupResponseBySource(r, query, hydratedResp, actions.ExperimentalCallAnalysis)
+	vulnerabilityResults := groupResponseBySource(r, query, hydratedResp, actions.CallAnalysis)
 
 	filtered := filterResults(r, &vulnerabilityResults, &configManager)
 	if filtered > 0 {
-		r.PrintText(fmt.Sprintf("Filtered %d vulnerabilities from output\n", filtered))
+		r.PrintText(fmt.Sprintf(
+			"Filtered %d %s from output\n",
+			filtered,
+			output.Form(filtered, "vulnerability", "vulnerabilities"),
+		))
 	}
 
 	// if vulnerability exists it should return error
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..16b64d1 100644
--- a/cmd/osv-scanner/main_test.go
+++ b/cmd/osv-scanner/main_test.go
@@ -131,7 +131,7 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-many/composer.lock
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -184,14 +184,14 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-many
-				Scanned %%/fixtures/locks-many/Gemfile.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/Gemfile.lock file and found 1 package
 				Scanned %%/fixtures/locks-many/alpine.cdx.xml as CycloneDX SBOM and found 15 packages
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
-				Scanned %%/fixtures/locks-many/package-lock.json file and found 1 packages
-				Scanned %%/fixtures/locks-many/yarn.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
+				Scanned %%/fixtures/locks-many/package-lock.json file and found 1 package
+				Scanned %%/fixtures/locks-many/yarn.lock file and found 1 package
 				Loaded filter from: %%/fixtures/locks-many/osv-scanner.toml
 				GHSA-whgm-jr23-g3j9 has been filtered out because: Test manifest file
-				Filtered 1 vulnerabilities from output
+				Filtered 1 vulnerability from output
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -203,8 +203,8 @@ func TestRun(t *testing.T) {
 			wantExitCode: 127,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-many-with-invalid
-				Scanned %%/fixtures/locks-many-with-invalid/Gemfile.lock file and found 1 packages
-				Scanned %%/fixtures/locks-many-with-invalid/yarn.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many-with-invalid/Gemfile.lock file and found 1 package
+				Scanned %%/fixtures/locks-many-with-invalid/yarn.lock file and found 1 package
 			`,
 			wantStderr: `
 				Attempted to scan lockfile but failed: %%/fixtures/locks-many-with-invalid/composer.lock
@@ -217,7 +217,7 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-one-with-nested
-				Scanned %%/fixtures/locks-one-with-nested/yarn.lock file and found 1 packages
+				Scanned %%/fixtures/locks-one-with-nested/yarn.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -229,8 +229,8 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-one-with-nested
-				Scanned %%/fixtures/locks-one-with-nested/nested/composer.lock file and found 1 packages
-				Scanned %%/fixtures/locks-one-with-nested/yarn.lock file and found 1 packages
+				Scanned %%/fixtures/locks-one-with-nested/nested/composer.lock file and found 1 package
+				Scanned %%/fixtures/locks-one-with-nested/yarn.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -242,8 +242,8 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-gitignore
-				Scanned %%/fixtures/locks-gitignore/Gemfile.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 packages
+				Scanned %%/fixtures/locks-gitignore/Gemfile.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -255,14 +255,14 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-gitignore
-				Scanned %%/fixtures/locks-gitignore/Gemfile.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/composer.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/ignored/Gemfile.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/ignored/yarn.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/subdir/Gemfile.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/subdir/composer.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 packages
-				Scanned %%/fixtures/locks-gitignore/yarn.lock file and found 1 packages
+				Scanned %%/fixtures/locks-gitignore/Gemfile.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/composer.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/ignored/Gemfile.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/ignored/yarn.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/subdir/Gemfile.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/subdir/composer.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 package
+				Scanned %%/fixtures/locks-gitignore/yarn.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -279,7 +279,7 @@ func TestRun(t *testing.T) {
 			`,
 			wantStderr: `
 				Scanning dir ./fixtures/locks-many/composer.lock
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
 			`,
 		},
 		{
@@ -293,7 +293,7 @@ func TestRun(t *testing.T) {
 			`,
 			wantStderr: `
 				Scanning dir ./fixtures/locks-many/composer.lock
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
 			`,
 		},
 		// output format: sarif
@@ -328,7 +328,7 @@ func TestRun(t *testing.T) {
 			`,
 			wantStderr: `
 				Scanning dir ./fixtures/locks-many/composer.lock
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
 			`,
 		},
 		{
@@ -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": [
 										{
@@ -388,7 +388,7 @@ func TestRun(t *testing.T) {
 			`,
 			wantStderr: `
 				Scanning dir ./fixtures/locks-many/package-lock.json
-				Scanned %%/fixtures/locks-many/package-lock.json file and found 1 packages
+				Scanned %%/fixtures/locks-many/package-lock.json file and found 1 package
 			`,
 		},
 		// output format: markdown table
@@ -398,7 +398,7 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanning dir ./fixtures/locks-many/composer.lock
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -438,7 +438,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			},
 			wantExitCode: 0,
 			wantStdout: `
-				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -481,7 +481,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			},
 			wantExitCode: 1,
 			wantStdout: `
-				Scanned %%/fixtures/locks-insecure/my-package-lock.json file as a package-lock.json and found 1 packages
+				Scanned %%/fixtures/locks-insecure/my-package-lock.json file as a package-lock.json and found 1 package
 				Scanning dir ./fixtures/locks-insecure
 				Scanned %%/fixtures/locks-insecure/composer.lock file and found 0 packages
 				+-------------------------------------+------+-----------+-----------+---------+----------------------------------------------+
@@ -503,8 +503,8 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			},
 			wantExitCode: 1,
 			wantStdout: `
-				Scanned %%/fixtures/locks-insecure/my-package-lock.json file as a package-lock.json and found 1 packages
-				Scanned %%/fixtures/locks-insecure/my-yarn.lock file as a yarn.lock and found 1 packages
+				Scanned %%/fixtures/locks-insecure/my-package-lock.json file as a package-lock.json and found 1 package
+				Scanned %%/fixtures/locks-insecure/my-yarn.lock file as a yarn.lock and found 1 package
 				Scanning dir ./fixtures/locks-insecure
 				Scanned %%/fixtures/locks-insecure/composer.lock file and found 0 packages
 				+-------------------------------------+------+-----------+-----------+---------+----------------------------------------------+
@@ -526,8 +526,8 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			},
 			wantExitCode: 1,
 			wantStdout: `
-				Scanned %%/fixtures/locks-insecure/my-yarn.lock file as a yarn.lock and found 1 packages
-				Scanned %%/fixtures/locks-insecure/my-package-lock.json file as a package-lock.json and found 1 packages
+				Scanned %%/fixtures/locks-insecure/my-yarn.lock file as a yarn.lock and found 1 package
+				Scanned %%/fixtures/locks-insecure/my-package-lock.json file as a package-lock.json and found 1 package
 				Scanning dir ./fixtures/locks-insecure
 				Scanned %%/fixtures/locks-insecure/composer.lock file and found 0 packages
 				+-------------------------------------+------+-----------+-----------+---------+----------------------------------------------+
@@ -579,7 +579,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			},
 			wantExitCode: 0,
 			wantStdout: `
-				Scanned %%/fixtures/locks-many/installed file as a apk-installed and found 1 packages
+				Scanned %%/fixtures/locks-many/installed file as a apk-installed and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
@@ -594,7 +594,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			},
 			wantExitCode: 0,
 			wantStdout: `
-				Scanned %%/fixtures/locks-many/status file as a dpkg-status and found 1 packages
+				Scanned %%/fixtures/locks-many/status file as a dpkg-status and found 1 package
 				No vulnerabilities found
 			`,
 			wantStderr: "",
diff --git a/internal/output/form_test.go b/internal/output/form_test.go
new file mode 100644
index 0000000..ef85b95
--- /dev/null
+++ b/internal/output/form_test.go
@@ -0,0 +1,60 @@
+package output_test
+
+import (
+	"testing"
+
+	"github.com/google/osv-scanner/internal/output"
+)
+
+func TestForm(t *testing.T) {
+	t.Parallel()
+
+	type args struct {
+		count    int
+		singular string
+		plural   string
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{
+		{
+			name: "",
+			args: args{
+				count:    0,
+				singular: "package",
+				plural:   "packages",
+			},
+			want: "packages",
+		},
+		{
+			name: "",
+			args: args{
+				count:    1,
+				singular: "package",
+				plural:   "packages",
+			},
+			want: "package",
+		},
+		{
+			name: "",
+			args: args{
+				count:    2,
+				singular: "package",
+				plural:   "packages",
+			},
+			want: "packages",
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := output.Form(tt.args.count, tt.args.singular, tt.args.plural); got != tt.want {
+				t.Errorf("Form() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/pkg/lockfile/helpers_test.go b/pkg/lockfile/helpers_test.go
index 3f50464..8e5c4e5 100644
--- a/pkg/lockfile/helpers_test.go
+++ b/pkg/lockfile/helpers_test.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/google/osv-scanner/internal/output"
 	"github.com/google/osv-scanner/pkg/lockfile"
 )
 
@@ -70,7 +71,12 @@ func expectPackages(t *testing.T, actualPackages []lockfile.PackageDetails, expe
 	t.Helper()
 
 	if len(expectedPackages) != len(actualPackages) {
-		t.Errorf("Expected to get %d packages, but got %d", len(expectedPackages), len(actualPackages))
+		t.Errorf(
+			"Expected to get %d %s, but got %d",
+			len(expectedPackages),
+			output.Form(len(expectedPackages), "package", "packages"),
+			len(actualPackages),
+		)
 	}
 
 	missingActualPackages := findMissingPackages(actualPackages, expectedPackages)
diff --git a/pkg/lockfile/parse_test.go b/pkg/lockfile/parse_test.go
index 40f3b52..cdda673 100644
--- a/pkg/lockfile/parse_test.go
+++ b/pkg/lockfile/parse_test.go
@@ -7,6 +7,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/google/osv-scanner/internal/output"
 	"github.com/google/osv-scanner/pkg/lockfile"
 )
 
@@ -30,8 +31,9 @@ func expectNumberOfParsersCalled(t *testing.T, numberOfParsersCalled int) {
 
 	if numberOfParsersCalled != count {
 		t.Errorf(
-			"Expected %d parsers to have been called, but had %d",
+			"Expected %d %s to have been called, but had %d",
 			count,
+			output.Form(count, "parser", "parsers"),
 			numberOfParsersCalled,
 		)
 	}