Repository: google/osv.dev

Issue:
PURLs for scoped NPM packages are invalid
The package URLs for scoped NPM packages (e.g. [`@hapi/hoek`](https://osv.dev/vulnerability/GHSA-22h7-7wwg-qmgg)) are invalid. Parsing them with any package URL library fails.

According to [the spec](https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component), segments in the namespace (here: `@hapi`) must be percent-encdoded.

So

```
pkg:npm/@hapi/hoek
```

should be

```
pkg:npm/%40hapi/hoek
```

On the same note, the name segment must be percent-encoded, too. I haven't encountered a PURL in OSV where the package name contains characters that'd need encoding, but if this is done for the namespace, it should be considered for the name as well.


Discussion:
Thanks for reporting this! We'll get this fixed.

Resolving patch:
diff --git a/lib/osv/purl_helpers.py b/lib/osv/purl_helpers.py
--- a/lib/osv/purl_helpers.py
+++ b/lib/osv/purl_helpers.py
@@ -13,6 +13,8 @@
 # limitations under the License.
 """PURL conversion utilities."""
 
+from urllib.parse import quote
+
 PURL_ECOSYSTEMS = {
     'crates.io': 'cargo',
     'Hex': 'hex',
@@ -27,6 +29,12 @@
 }
 
 
+def _url_encode(package_name):
+  """URL encode a PURL `namespace/name` or `name`."""
+  parts = package_name.split('/')
+  return '/'.join(quote(p) for p in parts)
+
+
 def package_to_purl(ecosystem, package_name):
   """Convert a ecosystem and package name to PURL."""
   purl_type = PURL_ECOSYSTEMS.get(ecosystem)
@@ -37,4 +45,4 @@ def package_to_purl(ecosystem, package_name):
     # PURLs use / to separate the group ID and the artifact ID.
     package_name = package_name.replace(':', '/', 1)
 
-  return f'pkg:{purl_type}/{package_name}'
+  return f'pkg:{purl_type}/{_url_encode(package_name)}'


Test patch:
diff --git a/lib/osv/purl_helpers_test.py b/lib/osv/purl_helpers_test.py
new file mode 100644
--- /dev/null
+++ b/lib/osv/purl_helpers_test.py
@@ -0,0 +1,42 @@
+# Copyright 2021 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Bug helper tests."""
+
+import unittest
+
+from . import purl_helpers
+
+
+class PurlHelpersTest(unittest.TestCase):
+  """purl_helpers tests."""
+
+  def test_pypi(self):
+    """Test PURL generation for PyPI."""
+    self.assertEqual('pkg:pypi/django',
+                     purl_helpers.package_to_purl('PyPI', 'django'))
+
+  def test_maven(self):
+    """Test PURL generation for Maven."""
+    self.assertEqual(
+        'pkg:maven/org.apache.struts/struts2-core',
+        purl_helpers.package_to_purl('Maven', 'org.apache.struts:struts2-core'))
+
+  def test_npm(self):
+    """Test PURL generation for npm."""
+    self.assertEqual('pkg:npm/%40hapi/hoek',
+                     purl_helpers.package_to_purl('npm', '@hapi/hoek'))
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/lib/run_tests.sh b/lib/run_tests.sh
--- a/lib/run_tests.sh
+++ b/lib/run_tests.sh
@@ -1,8 +1,9 @@
 #!/bin/bash -ex
 
 unset PIP_NO_BINARY
-pipenv sync
-pipenv run python -m unittest osv.bug_test
-pipenv run python -m unittest osv.ecosystems_test
-pipenv run python -m unittest osv.maven.version_test
-pipenv run python -m unittest osv.nuget_test
+python3 -m pipenv sync
+python3 -m pipenv run python -m unittest osv.bug_test
+python3 -m pipenv run python -m unittest osv.ecosystems_test
+python3 -m pipenv run python -m unittest osv.maven.version_test
+python3 -m pipenv run python -m unittest osv.nuget_test
+python3 -m pipenv run python -m unittest osv.purl_helpers_test