Repository: google/osv-scanner
Pull request #501 (2023-08-21 05:32:17)

License checker feature
TODO:
- [x] all the tests
- [x] license allowlist behaviour as detailed in the thread https://github.com/google/osv-scanner/pull/501#discussion_r1306839555
- [x] license table view

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) Report
> Merging [#501](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) (7675a78) into [main](https://app.codecov.io/gh/google/osv-scanner/commit/ac2897cf3e69379831e4ca583d5a671c827369dd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) (ac2897c) will **decrease** coverage by `1.64%`.
> Report is 1 commits behind head on main.
> The diff coverage is `56.88%`.

```diff
@@            Coverage Diff             @@
##             main     #501      +/-   ##
==========================================
- Coverage   80.33%   78.70%   -1.64%     
==========================================
  Files          78       79       +1     
  Lines        5406     5742     +336     
==========================================
+ Hits         4343     4519     +176     
- Misses        887     1035     +148     
- Partials      176      188      +12     
```


| [Files](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) | Coverage Δ | |
|---|---|---|
| [pkg/reporter/table\_reporter.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL3JlcG9ydGVyL3RhYmxlX3JlcG9ydGVyLmdv) | `100.00% <100.00%> (ø)` | |
| [internal/output/markdowntable.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-aW50ZXJuYWwvb3V0cHV0L21hcmtkb3dudGFibGUuZ28=) | `94.44% <90.90%> (+21.71%)` | :arrow_up: |
| [pkg/osvscanner/vulnerability\_result.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL29zdnNjYW5uZXIvdnVsbmVyYWJpbGl0eV9yZXN1bHQuZ28=) | `81.33% <92.50%> (+2.90%)` | :arrow_up: |
| [cmd/osv-scanner/main.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-Y21kL29zdi1zY2FubmVyL21haW4uZ28=) | `82.08% <78.26%> (-0.17%)` | :arrow_down: |
| [pkg/models/results.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL21vZGVscy9yZXN1bHRzLmdv) | `77.58% <61.53%> (-4.64%)` | :arrow_down: |
| [internal/output/table.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-aW50ZXJuYWwvb3V0cHV0L3RhYmxlLmdv) | `79.65% <86.25%> (+3.89%)` | :arrow_up: |
| [pkg/depsdev/license.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL2RlcHNkZXYvbGljZW5zZS5nbw==) | `57.89% <57.89%> (ø)` | |
| [pkg/osv/osv.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL29zdi9vc3YuZ28=) | `52.87% <0.00%> (-10.65%)` | :arrow_down: |
| [pkg/osvscanner/osvscanner.go](https://app.codecov.io/gh/google/osv-scanner/pull/501?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#diff-cGtnL29zdnNjYW5uZXIvb3N2c2Nhbm5lci5nbw==) | `54.25% <34.81%> (-6.39%)` | :arrow_down: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/google/osv-scanner/pull/501/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)


josieang: Ah I see, looks like we might need to update the linter and the go version in the checks. `errors.Join` was added in go 1.20.

Patch:
diff --git a/cmd/osv-scanner/fixtures/locks-licenses/package-lock.json b/cmd/osv-scanner/fixtures/locks-licenses/package-lock.json
new file mode 100644
index 0000000..e1f6b58
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-licenses/package-lock.json
@@ -0,0 +1,55 @@
+{
+  "name": "test",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "babel": "^6.23.0",
+        "human-signals": "^5.0.0",
+        "ms": "^2.1.3"
+      }
+    },
+    "node_modules/babel": {
+      "version": "6.23.0",
+      "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz",
+      "integrity": "sha512-ZDcCaI8Vlct8PJ3DvmyqUz+5X2Ylz3ZuuItBe/74yXosk2dwyVo/aN7MCJ8HJzhnnJ+6yP4o+lDgG9MBe91DLA==",
+      "deprecated": "In 6.x, the babel package has been deprecated in favor of babel-cli. Check https://opencollective.com/babel to support the Babel maintainers",
+      "bin": {
+        "babel": "lib/cli.js",
+        "babel-external-helpers": "lib/cli.js",
+        "babel-node": "lib/cli.js"
+      }
+    },
+    "node_modules/human-signals": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+      "engines": {
+        "node": ">=16.17.0"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+    }
+  },
+  "dependencies": {
+    "babel": {
+      "version": "6.23.0",
+      "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz",
+      "integrity": "sha512-ZDcCaI8Vlct8PJ3DvmyqUz+5X2Ylz3ZuuItBe/74yXosk2dwyVo/aN7MCJ8HJzhnnJ+6yP4o+lDgG9MBe91DLA=="
+    },
+    "human-signals": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="
+    },
+    "ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+    }
+  }
+}
diff --git a/cmd/osv-scanner/fixtures/locks-licenses/package.json b/cmd/osv-scanner/fixtures/locks-licenses/package.json
new file mode 100644
index 0000000..71e5bef
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-licenses/package.json
@@ -0,0 +1,7 @@
+{
+  "dependencies": {
+    "babel": "^6.23.0",
+    "human-signals": "^5.0.0",
+    "ms": "^2.1.3"
+  }
+}
diff --git a/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go
index 8253cc6..03f62c4 100644
--- a/cmd/osv-scanner/main.go
+++ b/cmd/osv-scanner/main.go
@@ -120,6 +120,14 @@ func run(args []string, stdout, stderr io.Writer) int {
 				Usage:  "sets the path that local databases should be stored",
 				Hidden: true,
 			},
+			&cli.BoolFlag{
+				Name:  "experimental-all-packages",
+				Usage: "when json output is selected, prints all packages",
+			},
+			&cli.StringSliceFlag{
+				Name:  "experimental-licenses",
+				Usage: "report on licenses",
+			},
 		},
 		ArgsUsage: "[directory1 directory2...]",
 		Action: func(context *cli.Context) error {
@@ -161,25 +169,31 @@ func run(args []string, stdout, stderr io.Writer) int {
 				ConfigOverridePath:   context.String("config"),
 				DirectoryPaths:       context.Args().Slice(),
 				ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{
-					LocalDBPath:    context.String("experimental-local-db-path"),
-					CallAnalysis:   context.Bool("experimental-call-analysis"),
-					CompareLocally: context.Bool("experimental-local-db"),
-					CompareOffline: context.Bool("experimental-offline"),
+					LocalDBPath:           context.String("experimental-local-db-path"),
+					CallAnalysis:          context.Bool("experimental-call-analysis"),
+					CompareLocally:        context.Bool("experimental-local-db"),
+					CompareOffline:        context.Bool("experimental-offline"),
+					ShowAllPackages:       context.Bool("experimental-all-packages"),
+					ScanLicenses:          context.IsSet("experimental-licenses"),
+					ScanLicensesAllowlist: context.StringSlice("experimental-licenses"),
 				},
 			}, r)
 
-			if err != nil &&
-				!errors.Is(err, osvscanner.VulnerabilitiesFoundErr) &&
-				!errors.Is(err, osvscanner.OnlyUncalledVulnerabilitiesFoundErr) {
-				//nolint:wrapcheck
+			issueResultErr := errors.Join(
+				osvscanner.VulnerabilitiesFoundErr,
+				osvscanner.OnlyUncalledVulnerabilitiesFoundErr,
+				osvscanner.LicenseViolationsErr,
+				osvscanner.VulnerabilitiesFoundAndLicenseViolationsErr,
+				osvscanner.OnlyUncalledVulnerabilitiesFoundAndLicenseViolationsErr,
+			)
+			if err != nil && !errors.Is(issueResultErr, err) {
 				return err
 			}
-
 			if errPrint := r.PrintResult(&vulnResult); errPrint != nil {
 				return fmt.Errorf("failed to write output: %w", errPrint)
 			}
 
-			// Could be nil, VulnerabilitiesFoundErr, or OnlyUncalledVulnerabilitiesFoundErr
+			// This may be nil.
 			return err
 		},
 	}
@@ -188,20 +202,23 @@ func run(args []string, stdout, stderr io.Writer) int {
 		if r == nil {
 			r = reporter.NewTableReporter(stdout, stderr, false, 0)
 		}
-		if errors.Is(err, osvscanner.VulnerabilitiesFoundErr) {
-			return 1
-		}
-
-		if errors.Is(err, osvscanner.OnlyUncalledVulnerabilitiesFoundErr) {
-			// TODO: Discuss whether to have a different exit code now that running call analysis is not default
-			return 2
-		}
-
-		if errors.Is(err, osvscanner.NoPackagesFoundErr) {
+		switch {
+		case errors.Is(err, osvscanner.VulnerabilitiesFoundErr):
+			return 0b0001 // 1
+		case errors.Is(err, osvscanner.OnlyUncalledVulnerabilitiesFoundErr):
+			// TODO: Discuss whether to have a different exit code
+			// now that running call analysis is not default.
+			return 0b0010 // 2
+		case errors.Is(err, osvscanner.LicenseViolationsErr):
+			return 0b0100 // 4
+		case errors.Is(err, osvscanner.VulnerabilitiesFoundAndLicenseViolationsErr):
+			return 0b0101 // 5
+		case errors.Is(err, osvscanner.OnlyUncalledVulnerabilitiesFoundAndLicenseViolationsErr):
+			return 0b0110 // 6
+		case errors.Is(err, osvscanner.NoPackagesFoundErr):
 			r.PrintError("No package sources found, --help for usage information.\n")
 			return 128
 		}
-
 		r.PrintError(fmt.Sprintf("%v\n", err))
 	}
 
diff --git a/go.mod b/go.mod
index 33ed0b0..7afacee 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/google/osv-scanner
 go 1.20
 
 require (
+	deps.dev/api/v3alpha v0.0.0-20230809051853-667b62c44a10
 	github.com/BurntSushi/toml v1.3.2
 	github.com/CycloneDX/cyclonedx-go v0.7.2
 	github.com/go-git/go-billy/v5 v5.5.0
@@ -10,6 +11,7 @@ require (
 	github.com/goark/go-cvss v1.6.6
 	github.com/google/go-cmp v0.6.0
 	github.com/hexops/gotextdiff v1.0.3
+	github.com/ianlancetaylor/demangle v0.0.0-20231023195312-e2daf7ba7156
 	github.com/jedib0t/go-pretty/v6 v6.4.9
 	github.com/owenrumney/go-sarif/v2 v2.3.0
 	github.com/package-url/packageurl-go v0.1.2
@@ -20,6 +22,7 @@ require (
 	golang.org/x/sync v0.4.0
 	golang.org/x/term v0.13.0
 	golang.org/x/vuln v1.0.1
+	google.golang.org/grpc v1.58.3
 	gopkg.in/yaml.v3 v3.0.1
 )
 
@@ -37,7 +40,7 @@ require (
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/goark/errs v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/ianlancetaylor/demangle v0.0.0-20231023195312-e2daf7ba7156
+	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
@@ -52,6 +55,9 @@ require (
 	golang.org/x/crypto v0.14.0 // indirect
 	golang.org/x/net v0.17.0 // indirect
 	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/text v0.13.0 // indirect
 	golang.org/x/tools v0.14.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
+	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 )
diff --git a/go.sum b/go.sum
index 660829f..54cd133 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
 dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+deps.dev/api/v3alpha v0.0.0-20230809051853-667b62c44a10 h1:qarCOf30WxJl59R04MCCq6IuL1pzE3Z1M4YeFJ8hEOk=
+deps.dev/api/v3alpha v0.0.0-20230809051853-667b62c44a10/go.mod h1:69Mvmw3jh/42Ci9wYemDIid4hXXAEJGHI6oUAWaZnqA=
 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.2 h1:kKQ0t1dPOlugSIYVOMiMtFqeXI2wp/f5DBIdfux8gnQ=
@@ -47,7 +49,11 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -187,6 +193,7 @@ 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.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -196,7 +203,16 @@ golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg
 golang.org/x/vuln v1.0.1 h1:KUas02EjQK5LTuIx1OylBQdKKZ9jeugs+HiqO5HormU=
 golang.org/x/vuln v1.0.1/go.mod h1:bb2hMwln/tqxg32BNY4CcxHWtHXuYa3SbIBmtsyjxtM=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
+google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ=
+google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/output/markdowntable.go b/internal/output/markdowntable.go
index ac69b7a..f1e923f 100644
--- a/internal/output/markdowntable.go
+++ b/internal/output/markdowntable.go
@@ -12,12 +12,19 @@ import (
 func PrintMarkdownTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer) {
 	outputTable := table.NewWriter()
 	outputTable.SetOutputMirror(outputWriter)
-	outputTable.AppendHeader(table.Row{"OSV URL", "CVSS", "Ecosystem", "Package", "Version", "Source"})
-
 	outputTable = tableBuilder(outputTable, vulnResult, false)
 
-	if outputTable.Length() == 0 {
+	if outputTable.Length() != 0 {
+		outputTable.RenderMarkdown()
+	}
+
+	outputLicenseTable := table.NewWriter()
+	outputLicenseTable.SetOutputMirror(outputWriter)
+
+	outputLicenseTable = licenseTableBuilder(outputLicenseTable, vulnResult)
+
+	if outputLicenseTable.Length() == 0 {
 		return
 	}
-	outputTable.RenderMarkdown()
+	outputLicenseTable.RenderMarkdown()
 }
diff --git a/internal/output/table.go b/internal/output/table.go
index 6976772..3f26023 100644
--- a/internal/output/table.go
+++ b/internal/output/table.go
@@ -7,10 +7,12 @@ import (
 	"math"
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
 
 	v2_metric "github.com/goark/go-cvss/v2/metric"
 	v3_metric "github.com/goark/go-cvss/v3/metric"
+	"golang.org/x/exp/maps"
 
 	"github.com/google/osv-scanner/internal/utility/results"
 	"github.com/google/osv-scanner/pkg/models"
@@ -25,9 +27,25 @@ const OSVBaseVulnerabilityURL = "https://osv.dev/"
 
 // PrintTableResults prints the osv scan results into a human friendly table.
 func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer, terminalWidth int) {
+	// Render the vulnerabilities.
+	outputTable := newTable(outputWriter, terminalWidth)
+	outputTable = tableBuilder(outputTable, vulnResult, terminalWidth > 0)
+	if outputTable.Length() != 0 {
+		outputTable.Render()
+	}
+
+	// Render the licenses if any.
+	outputLicenseTable := newTable(outputWriter, terminalWidth)
+	outputLicenseTable = licenseTableBuilder(outputLicenseTable, vulnResult)
+	if outputLicenseTable.Length() == 0 {
+		return
+	}
+	outputLicenseTable.Render()
+}
+
+func newTable(outputWriter io.Writer, terminalWidth int) table.Writer {
 	outputTable := table.NewWriter()
 	outputTable.SetOutputMirror(outputWriter)
-	outputTable.AppendHeader(table.Row{"OSV URL", "CVSS", "Ecosystem", "Package", "Version", "Source"})
 
 	if terminalWidth > 0 { // If output is a terminal, set max length to width and add styling
 		outputTable.SetStyle(table.StyleRounded)
@@ -37,15 +55,11 @@ func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.
 		outputTable.SetAllowedRowLength(terminalWidth)
 	} // Otherwise use default ascii (e.g. getting piped to a file)
 
-	outputTable = tableBuilder(outputTable, vulnResult, terminalWidth > 0)
-
-	if outputTable.Length() == 0 {
-		return
-	}
-	outputTable.Render()
+	return outputTable
 }
 
 func tableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults, addStyling bool) table.Writer {
+	outputTable.AppendHeader(table.Row{"OSV URL", "CVSS", "Ecosystem", "Package", "Version", "Source"})
 	rows := tableBuilderInner(vulnResult, addStyling, true)
 	for _, elem := range rows {
 		outputTable.AppendRow(elem.row, table.RowConfig{AutoMerge: elem.shouldMerge})
@@ -156,3 +170,84 @@ func MaxSeverity(group models.GroupInfo, pkg models.PackageVulns) string {
 
 	return fmt.Sprintf("%v", maxSeverity)
 }
+
+func licenseTableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults) table.Writer {
+	licenseConfig := vulnResult.ExperimentalAnalysisConfig.Licenses
+	if !licenseConfig.Enabled {
+		return outputTable
+	}
+	if len(licenseConfig.Allowlist) == 0 {
+		return licenseSummaryTableBuilder(outputTable, vulnResult)
+	}
+
+	return licenseViolationsTableBuilder(outputTable, vulnResult)
+}
+
+func licenseSummaryTableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults) table.Writer {
+	counts := make(map[models.License]int)
+	for _, pkgSource := range vulnResult.Results {
+		for _, pkg := range pkgSource.Packages {
+			for _, l := range pkg.Licenses {
+				counts[l] += 1
+			}
+		}
+	}
+	if len(counts) == 0 {
+		// No packages found.
+		return outputTable
+	}
+	licenses := maps.Keys(counts)
+	// Sort the license count in descending count order with the UNKNOWN
+	// license last.
+	sort.Slice(licenses, func(i, j int) bool {
+		if licenses[i] == "UNKNOWN" {
+			return false
+		}
+		if licenses[j] == "UNKNOWN" {
+			return true
+		}
+		if counts[licenses[i]] == counts[licenses[j]] {
+			return licenses[i] < licenses[j]
+		}
+
+		return counts[licenses[i]] > counts[licenses[j]]
+	})
+	outputTable.AppendHeader(table.Row{"License", "No. of package versions"})
+	for _, license := range licenses {
+		outputTable.AppendRow(table.Row{license, counts[license]})
+	}
+
+	return outputTable
+}
+
+func licenseViolationsTableBuilder(outputTable table.Writer, vulnResult *models.VulnerabilityResults) table.Writer {
+	outputTable.AppendHeader(table.Row{"License Violation", "Ecosystem", "Package", "Version", "Source"})
+	workingDir, err := os.Getwd()
+	if err != nil {
+		log.Panicf("can't get working dir: %v", err)
+	}
+	for _, pkgSource := range vulnResult.Results {
+		for _, pkg := range pkgSource.Packages {
+			if len(pkg.LicenseViolations) == 0 {
+				continue
+			}
+			violations := make([]string, len(pkg.LicenseViolations))
+			for i, l := range pkg.LicenseViolations {
+				violations[i] = string(l)
+			}
+			path := pkgSource.Source.Path
+			if simplifiedPath, err := filepath.Rel(workingDir, pkgSource.Source.Path); err == nil {
+				path = simplifiedPath
+			}
+			outputTable.AppendRow(table.Row{
+				strings.Join(violations, ", "),
+				pkg.Package.Ecosystem,
+				pkg.Package.Name,
+				pkg.Package.Version,
+				path,
+			})
+		}
+	}
+
+	return outputTable
+}
diff --git a/pkg/depsdev/license.go b/pkg/depsdev/license.go
new file mode 100644
index 0000000..3a99ac4
--- /dev/null
+++ b/pkg/depsdev/license.go
@@ -0,0 +1,104 @@
+package depsdev
+
+import (
+	"context"
+	"crypto/x509"
+	"fmt"
+
+	"github.com/google/osv-scanner/pkg/lockfile"
+	"github.com/google/osv-scanner/pkg/models"
+
+	depsdevpb "deps.dev/api/v3alpha"
+	"golang.org/x/sync/errgroup"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/status"
+)
+
+// DepsdevAPI is the URL to the deps.dev API. It is documented at
+// docs.deps.dev/api.
+const DepsdevAPI = "api.deps.dev:443"
+
+// System maps from a lockfile system to the depsdev API system.
+var System = map[lockfile.Ecosystem]depsdevpb.System{
+	lockfile.NpmEcosystem:   depsdevpb.System_NPM,
+	lockfile.NuGetEcosystem: depsdevpb.System_NUGET,
+	lockfile.CargoEcosystem: depsdevpb.System_CARGO,
+	lockfile.GoEcosystem:    depsdevpb.System_GO,
+	lockfile.MavenEcosystem: depsdevpb.System_MAVEN,
+	lockfile.PipEcosystem:   depsdevpb.System_PYPI,
+}
+
+// VersionQuery constructs a GetVersion request from the arguments.
+func VersionQuery(system depsdevpb.System, name string, version string) *depsdevpb.GetVersionRequest {
+	if system == depsdevpb.System_GO {
+		version = "v" + version
+	}
+
+	return &depsdevpb.GetVersionRequest{
+		VersionKey: &depsdevpb.VersionKey{
+			System:  system,
+			Name:    name,
+			Version: version,
+		},
+	}
+}
+
+// MakeVersionRequests calls the deps.dev GetVersion gRPC API endpoint for each
+// query. It makes these requests concurrently, sharing the single HTTP/2
+// connection. The order in which the requests are specified should correspond
+// to the order of licenses returned by this function.
+func MakeVersionRequests(queries []*depsdevpb.GetVersionRequest) ([][]models.License, error) {
+	ctx := context.TODO()
+	certPool, err := x509.SystemCertPool()
+	if err != nil {
+		return nil, fmt.Errorf("getting system cert pool: %w", err)
+	}
+	creds := credentials.NewClientTLSFromCert(certPool, "")
+	conn, err := grpc.Dial(DepsdevAPI, grpc.WithTransportCredentials(creds))
+	if err != nil {
+		return nil, fmt.Errorf("dialing deps.dev gRPC API: %w", err)
+	}
+	client := depsdevpb.NewInsightsClient(conn)
+
+	licenses := make([][]models.License, len(queries))
+	var g errgroup.Group
+	for i := range queries {
+		if queries[i] == nil {
+			// This may be a private package.
+			licenses[i] = []models.License{models.License("UNKNOWN")}
+			continue
+		}
+		i := i
+		g.Go(func() error {
+			resp, err := client.GetVersion(ctx, queries[i])
+			if err != nil {
+				if status.Code(err) == codes.NotFound {
+					licenses[i] = append(licenses[i], "UNKNOWN")
+					return nil
+				}
+
+				return err
+			}
+			ls := make([]models.License, len(resp.Licenses))
+			for j, license := range resp.Licenses {
+				ls[j] = models.License(license)
+			}
+			if len(ls) == 0 {
+				// The deps.dev API will return an
+				// empty slice if the license is
+				// unknown.
+				ls = []models.License{models.License("UNKNOWN")}
+			}
+			licenses[i] = ls
+
+			return nil
+		})
+	}
+	if err := g.Wait(); err != nil {
+		return nil, err
+	}
+
+	return licenses, nil
+}
diff --git a/pkg/models/results.go b/pkg/models/results.go
index 02d9f2e..81425aa 100644
--- a/pkg/models/results.go
+++ b/pkg/models/results.go
@@ -8,7 +8,24 @@ import (
 
 // Combined vulnerabilities found for the scanned packages
 type VulnerabilityResults struct {
-	Results []PackageSource `json:"results"`
+	Results                    []PackageSource            `json:"results"`
+	ExperimentalAnalysisConfig ExperimentalAnalysisConfig `json:"experimental_config"`
+}
+
+// ExperimentalAnalysisConfig is an experimental type intended to contain the
+// types of analysis performed on packages found by the scanner.
+type ExperimentalAnalysisConfig struct {
+	CallAnalysis ExperimentalCallAnalysisConfig `json:"call_analysis"`
+	Licenses     ExperimentalLicenseConfig      `json:"licenses"`
+}
+
+type ExperimentalCallAnalysisConfig struct {
+	Enabled bool `json:"enabled"`
+}
+
+type ExperimentalLicenseConfig struct {
+	Enabled   bool      `json:"enabled"`
+	Allowlist []License `json:"allowlist"`
 }
 
 // Flatten the grouped/nested vulnerability results into one flat array.
@@ -24,6 +41,14 @@ func (vulns *VulnerabilityResults) Flatten() []VulnerabilityFlattened {
 					GroupInfo:     getGroupInfoForVuln(pkg.Groups, v.ID),
 				})
 			}
+			if len(pkg.LicenseViolations) > 0 {
+				results = append(results, VulnerabilityFlattened{
+					Source:            res.Source,
+					Package:           pkg.Package,
+					Licenses:          pkg.Licenses,
+					LicenseViolations: pkg.LicenseViolations,
+				})
+			}
 		}
 	}
 
@@ -37,11 +62,15 @@ func getGroupInfoForVuln(groups []GroupInfo, vulnID string) GroupInfo {
 }
 
 // Flattened Vulnerability Information.
+// TODO: rename this to IssueFlattened or similar in the next major release as
+// it now contains license violations.
 type VulnerabilityFlattened struct {
-	Source        SourceInfo
-	Package       PackageInfo
-	Vulnerability Vulnerability
-	GroupInfo     GroupInfo
+	Source            SourceInfo
+	Package           PackageInfo
+	Vulnerability     Vulnerability
+	GroupInfo         GroupInfo
+	Licenses          []License
+	LicenseViolations []License
 }
 
 type SourceInfo struct {
@@ -63,11 +92,17 @@ type PackageSource struct {
 	Packages []PackageVulns `json:"packages"`
 }
 
+// License is an SPDX license.
+type License string
+
 // Vulnerabilities grouped by package
+// TODO: rename this to be Package as it now includes license information too.
 type PackageVulns struct {
-	Package         PackageInfo     `json:"package"`
-	Vulnerabilities []Vulnerability `json:"vulnerabilities"`
-	Groups          []GroupInfo     `json:"groups"`
+	Package           PackageInfo     `json:"package"`
+	Vulnerabilities   []Vulnerability `json:"vulnerabilities,omitempty"`
+	Groups            []GroupInfo     `json:"groups,omitempty"`
+	Licenses          []License       `json:"licenses,omitempty"`
+	LicenseViolations []License       `json:"license_violations,omitempty"`
 }
 
 type GroupInfo struct {
@@ -80,6 +115,12 @@ type GroupInfo struct {
 // IsCalled returns true if any analysis performed determines that the vulnerability is being called
 // Also returns true if no analysis is performed
 func (groupInfo *GroupInfo) IsCalled() bool {
+	if len(groupInfo.IDs) == 0 {
+		// This PackageVulns may be a license violation, not a
+		// vulnerability.
+		return false
+	}
+
 	if len(groupInfo.ExperimentalAnalysis) == 0 {
 		return true
 	}
diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go
index fce5b03..230fdf0 100644
--- a/pkg/osvscanner/osvscanner.go
+++ b/pkg/osvscanner/osvscanner.go
@@ -16,11 +16,13 @@ import (
 	"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/depsdev"
 	"github.com/google/osv-scanner/pkg/lockfile"
 	"github.com/google/osv-scanner/pkg/models"
 	"github.com/google/osv-scanner/pkg/osv"
 	"github.com/google/osv-scanner/pkg/reporter"
 
+	depsdevpb "deps.dev/api/v3alpha"
 	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/osfs"
 	"github.com/go-git/go-git/v5"
@@ -42,9 +44,12 @@ type ScannerActions struct {
 }
 
 type ExperimentalScannerActions struct {
-	CallAnalysis   bool
-	CompareLocally bool
-	CompareOffline bool
+	CallAnalysis          bool
+	CompareLocally        bool
+	CompareOffline        bool
+	ShowAllPackages       bool
+	ScanLicenses          bool
+	ScanLicensesAllowlist []string
 
 	LocalDBPath string
 }
@@ -60,6 +65,15 @@ var VulnerabilitiesFoundErr = errors.New("vulnerabilities found")
 //nolint:errname,stylecheck // Would require version bump to change
 var OnlyUncalledVulnerabilitiesFoundErr = errors.New("only uncalled vulnerabilities found")
 
+//nolint:errname,stylecheck // Would require version bump to change
+var LicenseViolationsErr = errors.New("license violations found")
+
+//nolint:errname,stylecheck // Would require version bump to change
+var VulnerabilitiesFoundAndLicenseViolationsErr = errors.New("vulnerabilities found and license violations found")
+
+//nolint:errname,stylecheck // Would require version bump to change
+var OnlyUncalledVulnerabilitiesFoundAndLicenseViolationsErr = errors.New("only uncalled vulnerabilities found and license violations found")
+
 var (
 	vendoredLibNames = map[string]struct{}{
 		"3rdparty":    {},
@@ -87,7 +101,6 @@ const (
 //   - Any lockfiles with scanLockfile
 //   - Any SBOM files with scanSBOMFile
 //   - Any git repositories with scanGit
-
 func scanDir(r reporter.Reporter, dir string, skipGit bool, recursive bool, useGitIgnore bool, compareOffline bool) ([]scannedPackage, error) {
 	var ignoreMatcher *gitIgnoreMatcher
 	if useGitIgnore {
@@ -591,7 +604,7 @@ func scanDebianDocker(r reporter.Reporter, dockerImageName string) ([]scannedPac
 }
 
 // Filters results according to config, preserving order. Returns total number of vulnerabilities removed.
-func filterResults(r reporter.Reporter, results *models.VulnerabilityResults, configManager *config.ConfigManager) int {
+func filterResults(r reporter.Reporter, results *models.VulnerabilityResults, configManager *config.ConfigManager, allPackages bool) int {
 	removedCount := 0
 	newResults := []models.PackageSource{} // Want 0 vulnerabilities to show in JSON as an empty list, not null.
 	for _, pkgSrc := range results.Results {
@@ -600,8 +613,7 @@ func filterResults(r reporter.Reporter, results *models.VulnerabilityResults, co
 		for _, pkgVulns := range pkgSrc.Packages {
 			newVulns := filterPackageVulns(r, pkgVulns, configToUse)
 			removedCount += len(pkgVulns.Vulnerabilities) - len(newVulns.Vulnerabilities)
-			// Don't want to include the package at all if there are no vulns.
-			if len(newVulns.Vulnerabilities) > 0 {
+			if allPackages || len(newVulns.Vulnerabilities) > 0 || len(pkgVulns.LicenseViolations) > 0 {
 				newPackages = append(newPackages, newVulns)
 			}
 		}
@@ -762,15 +774,21 @@ func DoScan(actions ScannerActions, r reporter.Reporter) (models.VulnerabilityRe
 		return models.VulnerabilityResults{}, NoPackagesFoundErr
 	}
 
-	hydratedResp, err := makeRequest(r, scannedPackages, actions.CompareLocally, actions.CompareOffline, actions.LocalDBPath)
-
+	vulnsResp, err := makeRequest(r, scannedPackages, actions.CompareLocally, actions.CompareOffline, actions.LocalDBPath)
 	if err != nil {
 		return models.VulnerabilityResults{}, err
 	}
 
-	vulnerabilityResults := buildVulnerabilityResults(r, scannedPackages, hydratedResp, actions.CallAnalysis)
+	var licensesResp [][]models.License
+	if actions.ScanLicenses {
+		licensesResp, err = makeLicensesRequests(scannedPackages)
+		if err != nil {
+			return models.VulnerabilityResults{}, err
+		}
+	}
+	results := buildVulnerabilityResults(r, scannedPackages, vulnsResp, licensesResp, actions.CallAnalysis, actions.ShowAllPackages, actions.ScanLicenses, actions.ScanLicensesAllowlist)
 
-	filtered := filterResults(r, &vulnerabilityResults, &configManager)
+	filtered := filterResults(r, &results, &configManager, actions.ShowAllPackages)
 	if filtered > 0 {
 		r.PrintText(fmt.Sprintf(
 			"Filtered %d %s from output\n",
@@ -779,19 +797,49 @@ func DoScan(actions ScannerActions, r reporter.Reporter) (models.VulnerabilityRe
 		))
 	}
 
-	// if vulnerability exists it should return error
-	if len(vulnerabilityResults.Results) > 0 {
-		// If any vulnerabilities are called, then we return VulnerabilitiesFoundErr
-		for _, vf := range vulnerabilityResults.Flatten() {
-			if vf.GroupInfo.IsCalled() {
-				return vulnerabilityResults, VulnerabilitiesFoundErr
+	if len(results.Results) > 0 {
+		// Determine the correct error to return.
+		// TODO: in the next breaking release of osv-scanner, consider
+		// returning a ScanError instead of an error.
+		var vuln bool
+		onlyUncalledVuln := true
+		var licenseViolation bool
+		for _, vf := range results.Flatten() {
+			if vf.Vulnerability.ID != "" {
+				vuln = true
+				if vf.GroupInfo.IsCalled() {
+					onlyUncalledVuln = false
+				}
+			}
+			if len(vf.LicenseViolations) > 0 {
+				licenseViolation = true
 			}
 		}
-		// Otherwise return OnlyUncalledVulnerabilitiesFoundErr
-		return vulnerabilityResults, OnlyUncalledVulnerabilitiesFoundErr
-	}
+		onlyUncalledVuln = onlyUncalledVuln && vuln
 
-	return vulnerabilityResults, nil
+		switch {
+		case !vuln && !onlyUncalledVuln && !licenseViolation:
+			// There is no error.
+			return results, nil
+		case vuln && !onlyUncalledVuln && !licenseViolation:
+			return results, VulnerabilitiesFoundErr
+		case !vuln && onlyUncalledVuln && !licenseViolation:
+			// Impossible state.
+			panic("internal error: uncalled vulnerabilities exist but no vulnerabilities exist")
+		case vuln && onlyUncalledVuln && !licenseViolation:
+			return results, OnlyUncalledVulnerabilitiesFoundErr
+		case !vuln && !onlyUncalledVuln && licenseViolation:
+			return results, LicenseViolationsErr
+		case vuln && !onlyUncalledVuln && licenseViolation:
+			return results, VulnerabilitiesFoundAndLicenseViolationsErr
+		case !vuln && onlyUncalledVuln && licenseViolation:
+			panic("internal error: uncalled vulnerabilities exist but no vulnerabilities exist")
+		case vuln && onlyUncalledVuln && licenseViolation:
+			return results, OnlyUncalledVulnerabilitiesFoundAndLicenseViolationsErr
+		}
+	}
+
+	return results, nil
 }
 
 func makeRequest(
@@ -845,3 +893,20 @@ func makeRequest(
 
 	return hydratedResp, nil
 }
+
+func makeLicensesRequests(packages []scannedPackage) ([][]models.License, error) {
+	queries := make([]*depsdevpb.GetVersionRequest, len(packages))
+	for i, pkg := range packages {
+		system, ok := depsdev.System[pkg.Ecosystem]
+		if !ok || pkg.Name == "" || pkg.Version == "" {
+			continue
+		}
+		queries[i] = depsdev.VersionQuery(system, pkg.Name, pkg.Version)
+	}
+	licenses, err := depsdev.MakeVersionRequests(queries)
+	if err != nil {
+		return nil, err
+	}
+
+	return licenses, nil
+}
diff --git a/pkg/osvscanner/vulnerability_result.go b/pkg/osvscanner/vulnerability_result.go
index beade2f..8eb0b48 100644
--- a/pkg/osvscanner/vulnerability_result.go
+++ b/pkg/osvscanner/vulnerability_result.go
@@ -11,19 +11,30 @@ import (
 	"github.com/google/osv-scanner/pkg/reporter"
 )
 
-// buildVulnerabilityResults converts raw OSV API response into structured vulnerability information
-// grouped by source location.
-func buildVulnerabilityResults(r reporter.Reporter, packages []scannedPackage, resp *osv.HydratedBatchedResponse, callAnalysis bool) models.VulnerabilityResults {
+// buildVulnerablityResults takes the responses from the OSV API and the deps.dev API
+// and converts this into a VulnerabilityResults. As part is this, it groups
+// vulnerability information by source location.
+// TODO: This function is getting long, we should refactor it
+func buildVulnerabilityResults(
+	r reporter.Reporter,
+	packages []scannedPackage,
+	vulnsResp *osv.HydratedBatchedResponse,
+	licensesResp [][]models.License,
+	callAnalysis bool,
+	allPackages bool,
+	licenses bool,
+	licensesAllowlist []string,
+) models.VulnerabilityResults {
 	output := models.VulnerabilityResults{
 		Results: []models.PackageSource{},
 	}
 	groupedBySource := map[models.SourceInfo][]models.PackageVulns{}
+	if len(licensesAllowlist) == 1 && licensesAllowlist[0] == "" {
+		licensesAllowlist = []string{}
+	}
 
 	for i, rawPkg := range packages {
-		response := resp.Results[i]
-		if len(response.Vulns) == 0 {
-			continue
-		}
+		includePackage := allPackages
 		var pkg models.PackageVulns
 		if rawPkg.Commit != "" {
 			pkg.Package.Commit = rawPkg.Commit
@@ -39,20 +50,36 @@ func buildVulnerabilityResults(r reporter.Reporter, packages []scannedPackage, r
 				continue
 			}
 		} else {
-			pkg = models.PackageVulns{
-				Package: models.PackageInfo{
-					Name:      rawPkg.Name,
-					Version:   rawPkg.Version,
-					Ecosystem: string(rawPkg.Ecosystem),
-				},
+			pkg.Package = models.PackageInfo{
+				Name:      rawPkg.Name,
+				Version:   rawPkg.Version,
+				Ecosystem: string(rawPkg.Ecosystem),
 			}
 		}
 
-		pkg.Vulnerabilities = response.Vulns
-
-		groups := grouper.Group(grouper.ConvertVulnerabilityToIDAliases(pkg.Vulnerabilities))
-		pkg.Groups = groups
-		groupedBySource[rawPkg.Source] = append(groupedBySource[rawPkg.Source], pkg)
+		if len(vulnsResp.Results[i].Vulns) > 0 {
+			includePackage = true
+			pkg.Vulnerabilities = vulnsResp.Results[i].Vulns
+			pkg.Groups = grouper.Group(grouper.ConvertVulnerabilityToIDAliases(pkg.Vulnerabilities))
+		}
+		if licenses {
+			pkg.Licenses = licensesResp[i]
+			allowlist := make(map[string]bool)
+			for _, license := range licensesAllowlist {
+				allowlist[license] = true
+			}
+			for _, license := range pkg.Licenses {
+				if !allowlist[string(license)] {
+					pkg.LicenseViolations = append(pkg.LicenseViolations, license)
+				}
+			}
+			if len(pkg.LicenseViolations) > 0 {
+				includePackage = true
+			}
+		}
+		if includePackage {
+			groupedBySource[rawPkg.Source] = append(groupedBySource[rawPkg.Source], pkg)
+		}
 	}
 
 	for source, packages := range groupedBySource {
@@ -73,5 +100,17 @@ func buildVulnerabilityResults(r reporter.Reporter, packages []scannedPackage, r
 		return output.Results[i].Source.Path < output.Results[j].Source.Path
 	})
 
+	if licenses {
+		output.ExperimentalAnalysisConfig.Licenses.Enabled = true
+		allowlist := make([]models.License, len(licensesAllowlist))
+		for i, l := range licensesAllowlist {
+			allowlist[i] = models.License(l)
+		}
+		output.ExperimentalAnalysisConfig.Licenses.Allowlist = allowlist
+	}
+	if callAnalysis {
+		output.ExperimentalAnalysisConfig.CallAnalysis.Enabled = true
+	}
+
 	return output
 }
diff --git a/pkg/reporter/table_reporter.go b/pkg/reporter/table_reporter.go
index dde1436..5e159bc 100644
--- a/pkg/reporter/table_reporter.go
+++ b/pkg/reporter/table_reporter.go
@@ -42,7 +42,7 @@ func (r *TableReporter) PrintText(msg string) {
 
 func (r *TableReporter) PrintResult(vulnResult *models.VulnerabilityResults) error {
 	if len(vulnResult.Results) == 0 && !r.hasPrintedError {
-		fmt.Fprintf(r.stdout, "No vulnerabilities found\n")
+		fmt.Fprintf(r.stdout, "No issues found\n")
 		return nil
 	}
 


Test patch:
diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go
index 2990774..cca785d 100644
--- a/cmd/osv-scanner/main_test.go
+++ b/cmd/osv-scanner/main_test.go
@@ -173,11 +173,11 @@ func TestRun(t *testing.T) {
 			wantStdout: `
 				Scanning dir ./fixtures/locks-many/composer.lock
 				Scanned <rootdir>/fixtures/locks-many/composer.lock file and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
-		// folder of supported sbom with vulns
+		// one specific supported sbom with vulns
 		{
 			name:         "folder of supported sbom with vulns",
 			args:         []string{"", "--config=./fixtures/osv-scanner-empty-config.toml", "./fixtures/sbom-insecure/"},
@@ -251,7 +251,7 @@ func TestRun(t *testing.T) {
 				CVE-2022-48174 has been filtered out because: Test manifest file (alpine.cdx.xml)
 				GHSA-whgm-jr23-g3j9 has been filtered out because: Test manifest file
 				Filtered 2 vulnerabilities from output
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -277,7 +277,7 @@ func TestRun(t *testing.T) {
 			wantStdout: `
 				Scanning dir ./fixtures/locks-one-with-nested
 				Scanned <rootdir>/fixtures/locks-one-with-nested/yarn.lock file and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -290,7 +290,7 @@ func TestRun(t *testing.T) {
 				Scanning dir ./fixtures/locks-one-with-nested
 				Scanned <rootdir>/fixtures/locks-one-with-nested/nested/composer.lock file and found 1 package
 				Scanned <rootdir>/fixtures/locks-one-with-nested/yarn.lock file and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -303,7 +303,7 @@ func TestRun(t *testing.T) {
 				Scanning dir ./fixtures/locks-gitignore
 				Scanned <rootdir>/fixtures/locks-gitignore/Gemfile.lock file and found 1 package
 				Scanned <rootdir>/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -322,7 +322,7 @@ func TestRun(t *testing.T) {
 				Scanned <rootdir>/fixtures/locks-gitignore/subdir/composer.lock file and found 1 package
 				Scanned <rootdir>/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 package
         Scanned <rootdir>/fixtures/locks-gitignore/yarn.lock file and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -333,7 +333,16 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				{
-					"results": []
+					"results": [],
+					"experimental_config": {
+						"call_analysis": {
+							"enabled": false
+						},
+						"licenses": {
+							"enabled": false,
+							"allowlist": null
+						}
+					}
 				}
 			`,
 			wantStderr: `
@@ -347,7 +356,16 @@ func TestRun(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				{
-					"results": []
+					"results": [],
+					"experimental_config": {
+						"call_analysis": {
+							"enabled": false
+						},
+						"licenses": {
+							"enabled": false,
+							"allowlist": null
+						}
+					}
 				}
 			`,
 			wantStderr: `
@@ -486,7 +504,7 @@ func TestRun(t *testing.T) {
 			args:         []string{"", "--format", "markdown", "--config", "./fixtures/osv-scanner-empty-config.toml", "./fixtures/locks-many/package-lock.json"},
 			wantExitCode: 1,
 			wantStdout: `
-				Scanning dir ./fixtures/locks-many/package-lock.json
+        Scanning dir ./fixtures/locks-many/package-lock.json
         Scanned <rootdir>/fixtures/locks-many/package-lock.json file and found 1 package
         | OSV URL | CVSS | Ecosystem | Package | Version | Source |
         | --- | --- | --- | --- | --- | --- |
@@ -540,7 +558,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanned <rootdir>/fixtures/locks-many/composer.lock file and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -681,7 +699,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanned <rootdir>/fixtures/locks-many/installed file as a apk-installed and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -696,7 +714,7 @@ func TestRun_LockfileWithExplicitParseAs(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				Scanned <rootdir>/fixtures/locks-many/status file as a dpkg-status and found 1 package
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -861,7 +879,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Scanning dir ./fixtures/locks-many/composer.lock
 				Scanned <rootdir>/fixtures/locks-many/composer.lock file and found 1 package
 				Loaded Packagist local db from %%/osv-scanner/Packagist/all.zip
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -920,7 +938,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Loaded filter from: <rootdir>/fixtures/locks-many/osv-scanner.toml
 				GHSA-whgm-jr23-g3j9 has been filtered out because: Test manifest file
 				Filtered 1 vulnerability from output
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -949,7 +967,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Scanning dir ./fixtures/locks-one-with-nested
 				Scanned <rootdir>/fixtures/locks-one-with-nested/yarn.lock file and found 1 package
 				Loaded npm local db from %%/osv-scanner/npm/all.zip
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -964,7 +982,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Scanned <rootdir>/fixtures/locks-one-with-nested/yarn.lock file and found 1 package
 				Loaded Packagist local db from %%/osv-scanner/Packagist/all.zip
 				Loaded npm local db from %%/osv-scanner/npm/all.zip
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -979,7 +997,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Scanned <rootdir>/fixtures/locks-gitignore/subdir/yarn.lock file and found 1 package
 				Loaded RubyGems local db from %%/osv-scanner/RubyGems/all.zip
 				Loaded npm local db from %%/osv-scanner/npm/all.zip
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -1001,7 +1019,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Loaded RubyGems local db from %%/osv-scanner/RubyGems/all.zip
 				Loaded Packagist local db from %%/osv-scanner/Packagist/all.zip
 				Loaded npm local db from %%/osv-scanner/npm/all.zip
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -1012,7 +1030,16 @@ func TestRun_LocalDatabases(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				{
-					"results": []
+					"results": [],
+					"experimental_config": {
+						"call_analysis": {
+							"enabled": false
+						},
+						"licenses": {
+							"enabled": false,
+							"allowlist": null
+						}
+					}
 				}
 			`,
 			wantStderr: `
@@ -1027,7 +1054,16 @@ func TestRun_LocalDatabases(t *testing.T) {
 			wantExitCode: 0,
 			wantStdout: `
 				{
-					"results": []
+					"results": [],
+					"experimental_config": {
+						"call_analysis": {
+							"enabled": false
+						},
+						"licenses": {
+							"enabled": false,
+							"allowlist": null
+						}
+					}
 				}
 			`,
 			wantStderr: `
@@ -1045,7 +1081,7 @@ func TestRun_LocalDatabases(t *testing.T) {
 				Scanning dir ./fixtures/locks-many/composer.lock
 				Scanned <rootdir>/fixtures/locks-many/composer.lock file and found 1 package
 				Loaded Packagist local db from %%/osv-scanner/Packagist/all.zip
-				No vulnerabilities found
+				No issues found
 			`,
 			wantStderr: "",
 		},
@@ -1071,6 +1107,308 @@ func TestRun_LocalDatabases(t *testing.T) {
 	}
 }
 
+func TestRun_Licenses(t *testing.T) {
+	t.Parallel()
+	tests := []cliTestCase{
+		{
+			name:         "No vulnerabilities but contains license violations",
+			args:         []string{"", "--experimental-licenses", "", "./fixtures/locks-many"},
+			wantExitCode: 4,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many
+				Scanned <rootdir>/fixtures/locks-many/Gemfile.lock file and found 1 package
+				Scanned <rootdir>/fixtures/locks-many/alpine.cdx.xml as CycloneDX SBOM and found 15 packages
+				Scanned <rootdir>/fixtures/locks-many/composer.lock file and found 1 package
+				Scanned <rootdir>/fixtures/locks-many/package-lock.json file and found 1 package
+				Scanned <rootdir>/fixtures/locks-many/yarn.lock file and found 1 package
+				Loaded filter from: <rootdir>/fixtures/locks-many/osv-scanner.toml
+				CVE-2022-48174 has been filtered out because: Test manifest file (alpine.cdx.xml)
+				GHSA-whgm-jr23-g3j9 has been filtered out because: Test manifest file
+				Filtered 2 vulnerabilities from output
+				+------------+-------------------------+
+				| LICENSE    | NO. OF PACKAGE VERSIONS |
+				+------------+-------------------------+
+				| Apache-2.0 |                       1 |
+				| MIT        |                       1 |
+				| UNKNOWN    |                      17 |
+				+------------+-------------------------+
+			`,
+			wantStderr: "",
+		},
+		{
+			name:         "No vulnerabilities but contains license violations markdown",
+			args:         []string{"", "--experimental-licenses", "", "--format=markdown", "./fixtures/locks-many"},
+			wantExitCode: 4,
+			wantStdout: `Scanning dir ./fixtures/locks-many
+Scanned <rootdir>/fixtures/locks-many/Gemfile.lock file and found 1 package
+Scanned <rootdir>/fixtures/locks-many/alpine.cdx.xml as CycloneDX SBOM and found 15 packages
+Scanned <rootdir>/fixtures/locks-many/composer.lock file and found 1 package
+Scanned <rootdir>/fixtures/locks-many/package-lock.json file and found 1 package
+Scanned <rootdir>/fixtures/locks-many/yarn.lock file and found 1 package
+Loaded filter from: <rootdir>/fixtures/locks-many/osv-scanner.toml
+CVE-2022-48174 has been filtered out because: Test manifest file (alpine.cdx.xml)
+GHSA-whgm-jr23-g3j9 has been filtered out because: Test manifest file
+Filtered 2 vulnerabilities from output
+| License | No. of package versions |
+| --- | ---:|
+| Apache-2.0 | 1 |
+| MIT | 1 |
+| UNKNOWN | 17 |
+			`,
+			wantStderr: "",
+		},
+		{
+			name:         "Vulnerabilities and license violations",
+			args:         []string{"", "--experimental-licenses", "", "--config=./fixtures/osv-scanner-empty-config.toml", "./fixtures/locks-many/package-lock.json"},
+			wantExitCode: 5,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many/package-lock.json
+				Scanned <rootdir>/fixtures/locks-many/package-lock.json file and found 1 package
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				| OSV URL                             | CVSS | ECOSYSTEM | PACKAGE   | VERSION | SOURCE                                |
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				| https://osv.dev/GHSA-whgm-jr23-g3j9 | 7.5  | npm       | ansi-html | 0.0.1   | fixtures/locks-many/package-lock.json |
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				+------------+-------------------------+
+				| LICENSE    | NO. OF PACKAGE VERSIONS |
+				+------------+-------------------------+
+				| Apache-2.0 |                       1 |
+				+------------+-------------------------+
+			`,
+			wantStderr: "",
+		},
+		{
+			name:         "Vulnerabilities and license violations with allowlist",
+			args:         []string{"", "--experimental-licenses", "MIT", "--config=./fixtures/osv-scanner-empty-config.toml", "./fixtures/locks-many/package-lock.json"},
+			wantExitCode: 5,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many/package-lock.json
+				Scanned <rootdir>/fixtures/locks-many/package-lock.json file and found 1 package
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				| OSV URL                             | CVSS | ECOSYSTEM | PACKAGE   | VERSION | SOURCE                                |
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				| https://osv.dev/GHSA-whgm-jr23-g3j9 | 7.5  | npm       | ansi-html | 0.0.1   | fixtures/locks-many/package-lock.json |
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				+-------------------+-----------+-----------+---------+---------------------------------------+
+				| LICENSE VIOLATION | ECOSYSTEM | PACKAGE   | VERSION | SOURCE                                |
+				+-------------------+-----------+-----------+---------+---------------------------------------+
+				| Apache-2.0        | npm       | ansi-html | 0.0.1   | fixtures/locks-many/package-lock.json |
+				+-------------------+-----------+-----------+---------+---------------------------------------+
+			`,
+			wantStderr: "",
+		},
+		{
+			name:         "Vulnerabilities and all license violations allowlisted",
+			args:         []string{"", "--experimental-licenses", "Apache-2.0", "--config=./fixtures/osv-scanner-empty-config.toml", "./fixtures/locks-many/package-lock.json"},
+			wantExitCode: 1,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many/package-lock.json
+				Scanned <rootdir>/fixtures/locks-many/package-lock.json file and found 1 package
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				| OSV URL                             | CVSS | ECOSYSTEM | PACKAGE   | VERSION | SOURCE                                |
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+				| https://osv.dev/GHSA-whgm-jr23-g3j9 | 7.5  | npm       | ansi-html | 0.0.1   | fixtures/locks-many/package-lock.json |
+				+-------------------------------------+------+-----------+-----------+---------+---------------------------------------+
+			`,
+			wantStderr: "",
+		},
+		{
+			name:         "Some packages with license violations and show-all-packages in json",
+			args:         []string{"", "--format=json", "--experimental-licenses", "MIT", "--experimental-all-packages", "./fixtures/locks-licenses/package-lock.json"},
+			wantExitCode: 4,
+			wantStdout: `
+			{
+				"results": [
+					{
+						"source": {
+							"path": "<rootdir>/fixtures/locks-licenses/package-lock.json",
+							"type": "lockfile"
+						},
+						"packages": [
+							{
+								"package": {
+									"name": "babel",
+									"version": "6.23.0",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"MIT"
+								]
+							},
+							{
+								"package": {
+									"name": "human-signals",
+									"version": "5.0.0",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"Apache-2.0"
+								],
+								"license_violations": [
+									"Apache-2.0"
+								]
+							},
+							{
+								"package": {
+									"name": "ms",
+									"version": "2.1.3",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"MIT"
+								]
+							}
+						]
+					}
+				],
+				"experimental_config": {
+					"call_analysis": {
+						"enabled": false
+					},
+					"licenses": {
+						"enabled": true,
+						"allowlist": [
+							"MIT"
+						]
+					}
+				}
+			}
+		`,
+			wantStderr: `
+			Scanning dir ./fixtures/locks-licenses/package-lock.json
+			Scanned %%/fixtures/locks-licenses/package-lock.json file and found 3 packages
+			`,
+		},
+		{
+			name:         "Some packages with license violations in json",
+			args:         []string{"", "--format=json", "--experimental-licenses", "MIT", "./fixtures/locks-licenses/package-lock.json"},
+			wantExitCode: 4,
+			wantStdout: `
+			{
+				"results": [
+					{
+						"source": {
+							"path": "<rootdir>/fixtures/locks-licenses/package-lock.json",
+							"type": "lockfile"
+						},
+						"packages": [
+							{
+								"package": {
+									"name": "human-signals",
+									"version": "5.0.0",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"Apache-2.0"
+								],
+								"license_violations": [
+									"Apache-2.0"
+								]
+							}
+						]
+					}
+				],
+				"experimental_config": {
+					"call_analysis": {
+						"enabled": false
+					},
+					"licenses": {
+						"enabled": true,
+						"allowlist": [
+							"MIT"
+						]
+					}
+				}
+			}
+		`,
+			wantStderr: `
+			Scanning dir ./fixtures/locks-licenses/package-lock.json
+			Scanned <rootdir>/fixtures/locks-licenses/package-lock.json file and found 3 packages
+			`,
+		},
+		{
+			name:         "No license violations and show-all-packages in json",
+			args:         []string{"", "--format=json", "--experimental-licenses", "MIT,Apache-2.0", "--experimental-all-packages", "./fixtures/locks-licenses/package-lock.json"},
+			wantExitCode: 0,
+			wantStdout: `
+			{
+				"results": [
+					{
+						"source": {
+							"path": "<rootdir>/fixtures/locks-licenses/package-lock.json",
+							"type": "lockfile"
+						},
+						"packages": [
+							{
+								"package": {
+									"name": "babel",
+									"version": "6.23.0",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"MIT"
+								]
+							},
+							{
+								"package": {
+									"name": "human-signals",
+									"version": "5.0.0",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"Apache-2.0"
+								]
+							},
+							{
+								"package": {
+									"name": "ms",
+									"version": "2.1.3",
+									"ecosystem": "npm",
+									"commit": ""
+								},
+								"licenses": [
+									"MIT"
+								]
+							}
+						]
+					}
+				],
+				"experimental_config": {
+					"call_analysis": {
+						"enabled": false
+					},
+					"licenses": {
+						"enabled": true,
+						"allowlist": [
+							"MIT",
+							"Apache-2.0"
+						]
+					}
+				}
+			}
+		`,
+			wantStderr: `
+			Scanning dir ./fixtures/locks-licenses/package-lock.json
+			Scanned <rootdir>/fixtures/locks-licenses/package-lock.json file and found 3 packages
+			`,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			testCli(t, tt)
+		})
+	}
+}
+
 func TestMain(m *testing.M) {
 	// ensure a git repository doesn't already exist in the fixtures directory,
 	// in case we didn't get a chance to clean-up properly in the last run
diff --git a/pkg/models/results_test.go b/pkg/models/results_test.go
index 5b7df9a..a765208 100644
--- a/pkg/models/results_test.go
+++ b/pkg/models/results_test.go
@@ -32,6 +32,7 @@ func TestFlatten(t *testing.T) {
 				},
 			},
 		},
+		Licenses: []License{License("MIT")},
 	}
 	source := PackageSource{Source: SourceInfo{Path: "package"}, Packages: []PackageVulns{pkg}}
 	vulns = VulnerabilityResults{Results: []PackageSource{source}}
@@ -47,4 +48,67 @@ func TestFlatten(t *testing.T) {
 	if diff := cmp.Diff(flattened, expectedFlattened); diff != "" {
 		t.Errorf("Flatten() returned unexpected result (-got +want):\n%s", diff)
 	}
+
+	// Test case 3: When there are no vulnerabilities and but license violations
+	group = GroupInfo{IDs: []string{"CVE-2021-1234"}}
+	pkg = PackageVulns{
+		Package:           PackageInfo{Name: "package"},
+		Groups:            []GroupInfo{group},
+		Licenses:          []License{License("MIT")},
+		LicenseViolations: []License{License("MIT")},
+	}
+	source = PackageSource{Source: SourceInfo{Path: "package"}, Packages: []PackageVulns{pkg}}
+	vulns = VulnerabilityResults{Results: []PackageSource{source}}
+	expectedFlattened = []VulnerabilityFlattened{
+		{
+			Source:            source.Source,
+			Package:           pkg.Package,
+			Licenses:          []License{License("MIT")},
+			LicenseViolations: []License{License("MIT")},
+		},
+	}
+	flattened = vulns.Flatten()
+	if diff := cmp.Diff(flattened, expectedFlattened); diff != "" {
+		t.Errorf("Flatten() returned unexpected result (-got +want):\n%s", diff)
+	}
+
+	// Test case 4: When there are vulnerabilities and license violations
+	group = GroupInfo{IDs: []string{"CVE-2021-1234"}}
+	pkg = PackageVulns{
+		Package: PackageInfo{Name: "package"},
+		Groups:  []GroupInfo{group},
+		Vulnerabilities: []Vulnerability{
+			{
+				ID: "CVE-2021-1234",
+				Severity: []Severity{
+					{
+						Type:  SeverityType("high"),
+						Score: "1",
+					},
+				},
+			},
+		},
+		Licenses:          []License{License("MIT")},
+		LicenseViolations: []License{License("MIT")},
+	}
+	source = PackageSource{Source: SourceInfo{Path: "package"}, Packages: []PackageVulns{pkg}}
+	vulns = VulnerabilityResults{Results: []PackageSource{source}}
+	expectedFlattened = []VulnerabilityFlattened{
+		{
+			Source:        source.Source,
+			Package:       pkg.Package,
+			Vulnerability: pkg.Vulnerabilities[0],
+			GroupInfo:     group,
+		},
+		{
+			Source:            source.Source,
+			Package:           pkg.Package,
+			Licenses:          []License{License("MIT")},
+			LicenseViolations: []License{License("MIT")},
+		},
+	}
+	flattened = vulns.Flatten()
+	if diff := cmp.Diff(flattened, expectedFlattened); diff != "" {
+		t.Errorf("Flatten() returned unexpected result (-got +want):\n%s", diff)
+	}
 }
diff --git a/pkg/osvscanner/osvscanner_internal_test.go b/pkg/osvscanner/osvscanner_internal_test.go
index 96a4373..f5b1340 100644
--- a/pkg/osvscanner/osvscanner_internal_test.go
+++ b/pkg/osvscanner/osvscanner_internal_test.go
@@ -60,7 +60,7 @@ func Test_filterResults(t *testing.T) {
 				ConfigMap:     make(map[string]config.Config),
 			}
 			got := tt.testCase.input
-			filtered := filterResults(r, &got, &configManager)
+			filtered := filterResults(r, &got, &configManager, false)
 			if diff := cmp.Diff(tt.testCase.want, got); diff != "" {
 				out := filepath.Join(tt.testCase.path, "out.json")
 				t.Errorf("filterResults() returned an unexpected results (-want, +got):\n%s\n"+
diff --git a/pkg/osvscanner/vulnerability_result_internal_test.go b/pkg/osvscanner/vulnerability_result_internal_test.go
index 2c2f8d9..5a81b67 100644
--- a/pkg/osvscanner/vulnerability_result_internal_test.go
+++ b/pkg/osvscanner/vulnerability_result_internal_test.go
@@ -4,31 +4,467 @@ import (
 	"reflect"
 	"testing"
 
+	"github.com/google/osv-scanner/pkg/lockfile"
 	"github.com/google/osv-scanner/pkg/models"
 	"github.com/google/osv-scanner/pkg/osv"
 	"github.com/google/osv-scanner/pkg/reporter"
 )
 
-func Test_groupResponseBySource(t *testing.T) {
+func Test_assembleResult(t *testing.T) {
 	t.Parallel()
 	type args struct {
-		r            reporter.Reporter
-		packages     []scannedPackage
-		resp         *osv.HydratedBatchedResponse
-		callAnalysis bool
+		r                 reporter.Reporter
+		packages          []scannedPackage
+		vulnsResp         *osv.HydratedBatchedResponse
+		licensesResp      [][]models.License
+		callAnalysis      bool
+		allPackages       bool
+		licenses          bool
+		licensesAllowlist []string
 	}
+	packages := []scannedPackage{
+		{
+			Name:      "pkg-1",
+			Ecosystem: lockfile.Ecosystem("npm"),
+			Version:   "1.0.0",
+			Source: models.SourceInfo{
+				Path: "dir/package-lock.json",
+				Type: "lockfile",
+			},
+		},
+		{
+			Name:      "pkg-2",
+			Ecosystem: lockfile.Ecosystem("npm"),
+			Version:   "1.0.0",
+			Source: models.SourceInfo{
+				Path: "dir/package-lock.json",
+				Type: "lockfile",
+			},
+		},
+		{
+			Name:      "pkg-3",
+			Ecosystem: lockfile.Ecosystem("npm"),
+			Version:   "1.0.0",
+			Source: models.SourceInfo{
+				Path: "other-dir/package-lock.json",
+				Type: "lockfile",
+			},
+		},
+	}
+	vulnsResp := &osv.HydratedBatchedResponse{
+		Results: []osv.Response{
+			{Vulns: models.Vulnerabilities([]models.Vulnerability{
+				{
+					ID:      "GHSA-123",
+					Aliases: []string{"CVE-123"},
+				}, {
+					ID: "CVE-123",
+				},
+			})},
+			{Vulns: models.Vulnerabilities{}},
+			{Vulns: models.Vulnerabilities([]models.Vulnerability{
+				{
+					ID: "GHSA-456",
+				},
+			})},
+		},
+	}
+	licensesResp := [][]models.License{
+		{models.License("MIT"), models.License("0BSD")},
+		{models.License("MIT")},
+		{models.License("UNKNOWN")},
+	}
+
 	tests := []struct {
 		name string
 		args args
 		want models.VulnerabilityResults
-	}{}
+	}{{
+		name: "group vulnerabilities",
+		args: args{
+			r:                 &reporter.VoidReporter{},
+			packages:          packages,
+			vulnsResp:         vulnsResp,
+			licensesResp:      licensesResp,
+			allPackages:       false,
+			callAnalysis:      false,
+			licenses:          false,
+			licensesAllowlist: nil,
+		},
+		want: models.VulnerabilityResults{
+			Results: []models.PackageSource{
+				{
+					Source: models.SourceInfo{
+						Path: "dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-1",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{
+									ID:      "GHSA-123",
+									Aliases: []string{"CVE-123"},
+								},
+								{
+									ID: "CVE-123",
+								},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"CVE-123", "GHSA-123"}},
+							},
+						},
+					},
+				},
+				{
+					Source: models.SourceInfo{
+						Path: "other-dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-3",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{ID: "GHSA-456"},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"GHSA-456"}},
+							},
+						},
+					},
+				},
+			},
+		},
+	}, {
+		name: "group vulnerabilities, with all packages included",
+		args: args{
+			r:                 &reporter.VoidReporter{},
+			packages:          packages,
+			vulnsResp:         vulnsResp,
+			licensesResp:      licensesResp,
+			allPackages:       true,
+			callAnalysis:      false,
+			licenses:          false,
+			licensesAllowlist: nil,
+		},
+		want: models.VulnerabilityResults{
+			Results: []models.PackageSource{
+				{
+					Source: models.SourceInfo{
+						Path: "dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-1",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{
+									ID:      "GHSA-123",
+									Aliases: []string{"CVE-123"},
+								},
+								{
+									ID: "CVE-123",
+								},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"CVE-123", "GHSA-123"}},
+							},
+						}, {
+							Package: models.PackageInfo{
+								Name:      "pkg-2",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+						},
+					},
+				},
+				{
+					Source: models.SourceInfo{
+						Path: "other-dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-3",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{ID: "GHSA-456"},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"GHSA-456"}},
+							},
+						},
+					},
+				},
+			},
+		},
+	}, {
+		name: "group vulnerabilities with licenses",
+		args: args{
+			r:                 &reporter.VoidReporter{},
+			packages:          packages,
+			vulnsResp:         vulnsResp,
+			licensesResp:      licensesResp,
+			allPackages:       false,
+			callAnalysis:      false,
+			licenses:          true,
+			licensesAllowlist: nil,
+		},
+		want: models.VulnerabilityResults{
+			ExperimentalAnalysisConfig: models.ExperimentalAnalysisConfig{
+				Licenses: models.ExperimentalLicenseConfig{
+					Enabled:   true,
+					Allowlist: []models.License{},
+				},
+			},
+			Results: []models.PackageSource{
+				{
+					Source: models.SourceInfo{
+						Path: "dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-1",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{
+									ID:      "GHSA-123",
+									Aliases: []string{"CVE-123"},
+								},
+								{
+									ID: "CVE-123",
+								},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"CVE-123", "GHSA-123"}},
+							},
+							Licenses:          makeLicenses([]string{"MIT", "0BSD"}),
+							LicenseViolations: makeLicenses([]string{"MIT", "0BSD"}),
+						}, {
+							Package: models.PackageInfo{
+								Name:      "pkg-2",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Licenses:          makeLicenses([]string{"MIT"}),
+							LicenseViolations: makeLicenses([]string{"MIT"}),
+						},
+					},
+				},
+				{
+					Source: models.SourceInfo{
+						Path: "other-dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-3",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{ID: "GHSA-456"},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"GHSA-456"}},
+							},
+							Licenses:          makeLicenses([]string{"UNKNOWN"}),
+							LicenseViolations: makeLicenses([]string{"UNKNOWN"}),
+						},
+					},
+				},
+			},
+		},
+	}, {
+		name: "group vulnerabilities with license allowlist",
+		args: args{
+			r:                 &reporter.VoidReporter{},
+			packages:          packages,
+			vulnsResp:         vulnsResp,
+			licensesResp:      licensesResp,
+			allPackages:       false,
+			callAnalysis:      false,
+			licenses:          true,
+			licensesAllowlist: []string{"MIT", "0BSD"},
+		},
+		want: models.VulnerabilityResults{
+			ExperimentalAnalysisConfig: models.ExperimentalAnalysisConfig{
+				Licenses: models.ExperimentalLicenseConfig{
+					Enabled:   true,
+					Allowlist: []models.License{models.License("MIT"), models.License("0BSD")},
+				},
+			},
+			Results: []models.PackageSource{
+				{
+					Source: models.SourceInfo{
+						Path: "dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-1",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{
+									ID:      "GHSA-123",
+									Aliases: []string{"CVE-123"},
+								},
+								{
+									ID: "CVE-123",
+								},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"CVE-123", "GHSA-123"}},
+							},
+							Licenses: makeLicenses([]string{"MIT", "0BSD"}),
+						},
+					},
+				},
+				{
+					Source: models.SourceInfo{
+						Path: "other-dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-3",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{ID: "GHSA-456"},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"GHSA-456"}},
+							},
+							Licenses:          makeLicenses([]string{"UNKNOWN"}),
+							LicenseViolations: makeLicenses([]string{"UNKNOWN"}),
+						},
+					},
+				},
+			},
+		},
+	}, {
+		name: "group vulnerabilities, with license allowlist and all packages",
+		args: args{
+			r:                 &reporter.VoidReporter{},
+			packages:          packages,
+			vulnsResp:         vulnsResp,
+			licensesResp:      licensesResp,
+			allPackages:       true,
+			callAnalysis:      false,
+			licenses:          true,
+			licensesAllowlist: []string{"MIT", "0BSD"},
+		},
+		want: models.VulnerabilityResults{
+			ExperimentalAnalysisConfig: models.ExperimentalAnalysisConfig{
+				Licenses: models.ExperimentalLicenseConfig{
+					Enabled:   true,
+					Allowlist: []models.License{models.License("MIT"), models.License("0BSD")},
+				},
+			},
+			Results: []models.PackageSource{
+				{
+					Source: models.SourceInfo{
+						Path: "dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-1",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{
+									ID:      "GHSA-123",
+									Aliases: []string{"CVE-123"},
+								},
+								{
+									ID: "CVE-123",
+								},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"CVE-123", "GHSA-123"}},
+							},
+							Licenses: makeLicenses([]string{"MIT", "0BSD"}),
+						}, {
+							Package: models.PackageInfo{
+								Name:      "pkg-2",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Licenses: makeLicenses([]string{"MIT"}),
+						},
+					},
+				},
+				{
+					Source: models.SourceInfo{
+						Path: "other-dir/package-lock.json",
+						Type: "lockfile",
+					},
+					Packages: []models.PackageVulns{
+						{
+							Package: models.PackageInfo{
+								Name:      "pkg-3",
+								Ecosystem: "npm",
+								Version:   "1.0.0",
+							},
+							Vulnerabilities: []models.Vulnerability{
+								{ID: "GHSA-456"},
+							},
+							Groups: []models.GroupInfo{
+								{IDs: []string{"GHSA-456"}},
+							},
+							Licenses:          makeLicenses([]string{"UNKNOWN"}),
+							LicenseViolations: makeLicenses([]string{"UNKNOWN"}),
+						},
+					},
+				},
+			},
+		},
+	}}
 	for _, tt := range tests {
 		tt := tt // Reinitialize for t.Parallel()
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			if got := buildVulnerabilityResults(tt.args.r, tt.args.packages, tt.args.resp, tt.args.callAnalysis); !reflect.DeepEqual(got, tt.want) {
-				t.Errorf("groupResponse() = %v, want %v", got, tt.want)
+			if got := buildVulnerabilityResults(tt.args.r, tt.args.packages, tt.args.vulnsResp, tt.args.licensesResp, tt.args.callAnalysis, tt.args.allPackages, tt.args.licenses, tt.args.licensesAllowlist); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("buildVulnerabilityResults() = %v,\nwant %v", got, tt.want)
 			}
 		})
 	}
 }
+
+func makeLicenses(strLicenses []string) []models.License {
+	licenses := make([]models.License, len(strLicenses))
+	for i, l := range strLicenses {
+		licenses[i] = models.License(l)
+	}
+
+	return licenses
+}