Skip to content

Commit 8185bdb

Browse files
authored
Merge pull request #114 from haikoschol/packageurl
Use Package URL for packages Signed-off-by: Haiko Schol <hs@haikoschol.com>
2 parents af561ae + b8548b5 commit 8185bdb

8 files changed

Lines changed: 188 additions & 42 deletions

File tree

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ lxml==4.3.3
55
django==2.2.4
66
djangorestframework==3.9.2
77
django-filter==2.1.0
8+
packageurl-python==0.8.7
89

910
# Tests
1011
pytest==3.2.3

vulnerabilities/api.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2323

2424
from rest_framework import serializers
25+
from rest_framework import status
2526
from rest_framework import viewsets
27+
from rest_framework.response import Response
28+
29+
from packageurl import PackageURL
2630

2731
from vulnerabilities.models import Package
2832
from vulnerabilities.models import PackageReference
@@ -72,7 +76,7 @@ class Meta:
7276
fields = [
7377
'name',
7478
'version',
75-
'platform',
79+
'package_url',
7680
'vulnerabilities',
7781
'references',
7882
]
@@ -81,4 +85,27 @@ class Meta:
8185
class PackageViewSet(viewsets.ReadOnlyModelViewSet):
8286
queryset = Package.objects.all()
8387
serializer_class = PackageSerializer
84-
filter_fields = ('name', 'version', 'platform')
88+
filter_fields = ('name', 'version')
89+
90+
def filter_queryset(self, qs):
91+
purl = self.request.query_params.get('package_url')
92+
if not purl:
93+
return super().filter_queryset(qs)
94+
95+
try:
96+
purl = PackageURL.from_string(purl)
97+
except ValueError as ve:
98+
raise serializers.ValidationError(
99+
detail={'error': f'"{purl}" is not a valid Package URL: {ve}'},
100+
)
101+
102+
# Remove "qualifiers" here because it is stored as one string in the model.
103+
# For example, a row in the database could have the "qualifiers" column
104+
# stored as "foo=bar&spam=eggs". If an API request contains a PURL with
105+
# "spam=eggs&foo=bar", the DB query would not include that row.
106+
attrs = {k: v for k, v in purl.to_dict().items() if v and k != 'qualifiers'}
107+
108+
# TODO
109+
# Since we are filtering on all the Package URL fields except "qualifiers",
110+
# we'll eventually need database indices on them.
111+
return self.queryset.filter(**attrs)

vulnerabilities/data_dump.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from vulnerabilities.models import VulnerabilityReference
3030

3131

32-
def debian_dump(extract_data):
32+
def debian_dump(extract_data, base_release='jessie'):
3333
"""
3434
Save data scraped from Debian' security tracker.
3535
"""
@@ -45,7 +45,10 @@ def debian_dump(extract_data):
4545
pkg_name = data.get('package_name', '')
4646
package = Package.objects.create(
4747
name=pkg_name,
48+
type='deb',
49+
namespace='debian',
4850
version=data.get('version', ''),
51+
qualifiers=f'distro={base_release}',
4952
)
5053

5154
if data['status'] == 'open':
@@ -63,7 +66,10 @@ def debian_dump(extract_data):
6366
if fixed_version:
6467
package = Package.objects.create(
6568
name=pkg_name,
69+
type='deb',
70+
namespace='debian',
6671
version=fixed_version,
72+
qualifiers=f'distro={base_release}',
6773
)
6874

6975
ResolvedPackage.objects.create(
@@ -86,6 +92,8 @@ def ubuntu_dump(html):
8692
)
8793
package = Package.objects.create(
8894
name=data.get('package_name'),
95+
type='deb',
96+
namespace='ubuntu'
8997
)
9098
ImpactedPackage.objects.create(
9199
vulnerability=vulnerability,
@@ -119,12 +127,14 @@ def archlinux_dump(extract_data):
119127
VulnerabilityReference.objects.create(
120128
vulnerability=vulnerability,
121129
reference_id=vulnerability_id,
122-
url='https://security.archlinux.org/{}'.format(vulnerability_id)
130+
url=f'https://security.archlinux.org/{vulnerability_id}',
123131
)
124132

125133
for package_name in packages_name:
126134
package_affected = Package.objects.create(
127135
name=package_name,
136+
type='pacman',
137+
namespace='archlinux',
128138
version=affected_version
129139
)
130140
ImpactedPackage.objects.create(
@@ -133,10 +143,12 @@ def archlinux_dump(extract_data):
133143
)
134144
PackageReference.objects.create(
135145
package=package_affected,
136-
repository='https://security.archlinux.org/package/{}'.format(package_name)
146+
repository=f'https://security.archlinux.org/package/{package_name}',
137147
)
138148
package_fixed = Package.objects.create(
139149
name=package_name,
150+
type='pacman',
151+
namespace='archlinux',
140152
version=fixed_version
141153
)
142154
ResolvedPackage.objects.create(
@@ -145,5 +157,5 @@ def archlinux_dump(extract_data):
145157
)
146158
PackageReference.objects.create(
147159
package=package_fixed,
148-
repository='https://security.archlinux.org/package/{}'.format(package_name)
160+
repository=f'https://security.archlinux.org/package/{package_name}',
149161
)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Generated by Django 2.2.4 on 2019-09-27 14:38
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('vulnerabilities', '0005_auto_20190411_1644'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='package',
15+
name='platform',
16+
),
17+
migrations.AddField(
18+
model_name='package',
19+
name='namespace',
20+
field=models.CharField(blank=True, help_text='Package name prefix, such as Maven groupid, Docker image owner, GitHub user or organization, etc.', max_length=255, null=True),
21+
),
22+
migrations.AddField(
23+
model_name='package',
24+
name='qualifiers',
25+
field=models.CharField(blank=True, help_text='Extra qualifying data for a package such as the name of an OS, architecture, distro, etc.', max_length=1024, null=True),
26+
),
27+
migrations.AddField(
28+
model_name='package',
29+
name='subpath',
30+
field=models.CharField(blank=True, help_text='Extra subpath within a package, relative to the package root.', max_length=200, null=True),
31+
),
32+
migrations.AddField(
33+
model_name='package',
34+
name='type',
35+
field=models.CharField(blank=True, help_text='A short code to identify the type of this package. For example: gem for a Rubygem, docker for a container, pypi for a Python Wheel or Egg, maven for a Maven Jar, deb for a Debian package, etc.', max_length=16, null=True),
36+
),
37+
migrations.AlterField(
38+
model_name='package',
39+
name='name',
40+
field=models.CharField(blank=True, help_text='Name of the package.', max_length=100, null=True),
41+
),
42+
migrations.AlterField(
43+
model_name='package',
44+
name='version',
45+
field=models.CharField(blank=True, help_text='Version of the package.', max_length=50, null=True),
46+
),
47+
]

vulnerabilities/models.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
from django.db import models
2525

26+
from packageurl.contrib.django_models import PackageURLMixin
27+
2628

2729
class Vulnerability(models.Model):
2830
"""
@@ -58,14 +60,11 @@ def __str__(self):
5860
return self.source
5961

6062

61-
class Package(models.Model):
63+
class Package(PackageURLMixin):
6264
"""
6365
A software package with minimal identifying information.
6466
Other identifiers are stored as PackageReference.
6567
"""
66-
platform = models.CharField(max_length=50, help_text='Package platform eg:maven', blank=True)
67-
name = models.CharField(max_length=50, help_text='Package name', blank=True)
68-
version = models.CharField(max_length=50, help_text='Package version', blank=True)
6968
vulnerabilities = models.ManyToManyField(to='Vulnerability', through='ImpactedPackage')
7069

7170
def __str__(self):

vulnerabilities/tests/test_api.py

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626

2727
from django.test import TestCase
2828

29-
from vulnerabilities.models import Package
3029
from vulnerabilities.api import PackageSerializer
3130
from vulnerabilities.data_dump import debian_dump
3231
from vulnerabilities.data_dump import ubuntu_dump
32+
from vulnerabilities.models import Package
3333
from vulnerabilities.scraper import debian
3434
from vulnerabilities.scraper import ubuntu
3535

@@ -38,16 +38,26 @@
3838
TEST_DATA = os.path.join(BASE_DIR, 'test_data/')
3939

4040

41-
class TestResponse(TestCase):
42-
def test_debian_response(self):
41+
class TestDebianResponse(TestCase):
42+
@classmethod
43+
def setUpTestData(cls):
4344
with open(os.path.join(TEST_DATA, 'debian.json')) as f:
4445
test_data = json.load(f)
4546

4647
extract_data = debian.extract_vulnerabilities(test_data)
4748
debian_dump(extract_data)
49+
50+
Package.objects.create(
51+
name='mimetex',
52+
version='1.50-1.1',
53+
type='deb',
54+
namespace='ubuntu'
55+
)
56+
57+
def test_query_by_name(self):
4858
response = self.client.get('/api/packages/?name=mimetex', format='json').data
4959

50-
self.assertEqual(4, response['count'])
60+
self.assertEqual(5, response['count'])
5161

5262
first_result = response['results'][0]
5363
self.assertEqual('mimetex', first_result['name'])
@@ -56,6 +66,47 @@ def test_debian_response(self):
5666
self.assertIn('1.50-1.1', versions)
5767
self.assertIn('1.74-1', versions)
5868

69+
purls = {r['package_url'] for r in response['results']}
70+
self.assertIn('pkg:deb/debian/mimetex@1.50-1.1?distro=jessie', purls)
71+
self.assertIn('pkg:deb/debian/mimetex@1.74-1?distro=jessie', purls)
72+
73+
def test_query_by_invalid_package_url(self):
74+
url = '/api/packages/?package_url=invalid_purl'
75+
response = self.client.get(url, format='json')
76+
77+
self.assertEqual(400, response.status_code)
78+
self.assertIn('error', response.data)
79+
error = response.data['error']
80+
self.assertIn('invalid_purl', error)
81+
82+
def test_query_by_package_url(self):
83+
url = '/api/packages/?package_url=pkg:deb/debian/mimetex@1.50-1.1?distro=jessie'
84+
response = self.client.get(url, format='json').data
85+
86+
self.assertEqual(2, response['count'])
87+
88+
first_result = response['results'][0]
89+
self.assertEqual('mimetex', first_result['name'])
90+
91+
versions = {r['version'] for r in response['results']}
92+
self.assertIn('1.50-1.1', versions)
93+
self.assertNotIn('1.74-1', versions)
94+
95+
def test_query_by_package_url_without_namespace(self):
96+
url = '/api/packages/?package_url=pkg:deb/mimetex@1.50-1.1'
97+
response = self.client.get(url, format='json').data
98+
99+
self.assertEqual(3, response['count'])
100+
101+
first_result = response['results'][0]
102+
self.assertEqual('mimetex', first_result['name'])
103+
104+
purls = {r['package_url'] for r in response['results']}
105+
self.assertIn('pkg:deb/debian/mimetex@1.50-1.1?distro=jessie', purls)
106+
self.assertIn('pkg:deb/ubuntu/mimetex@1.50-1.1', purls)
107+
108+
109+
class TestUbuntuResponse(TestCase):
59110
def test_ubuntu_response(self):
60111
with open(os.path.join(TEST_DATA, 'ubuntu_main.html')) as f:
61112
test_data = f.read()
@@ -64,32 +115,26 @@ def test_ubuntu_response(self):
64115
ubuntu_dump(extract_data)
65116
response = self.client.get('/api/packages/?name=automake', format='json')
66117

67-
expected = [{
68-
"name": "automake",
69-
"version": "",
70-
"platform": "",
71-
"vulnerabilities": [{
72-
"summary": "",
73-
"cvss": None,
74-
"references": [{
75-
"reference_id": "CVE-2012-3386",
76-
"source": "",
77-
"url": "",
78-
}]
79-
}],
80-
"references": [],
81-
}]
82-
83-
self.assertEqual(expected, response.data.get('results'))
118+
result = response.data.get('results')[0]
119+
self.assertEqual('automake', result['name'])
120+
self.assertEqual(None, result['version'])
121+
self.assertEqual(1, len(result['vulnerabilities']))
122+
123+
vuln = result['vulnerabilities'][0]
124+
self.assertEqual(1, len(vuln['references']))
125+
self.assertEqual('CVE-2012-3386', vuln['references'][0]['reference_id'])
84126

85127

86128
class TestSerializers(TestCase):
87-
def test_serializers(self):
129+
@classmethod
130+
def setUpTestData(cls):
88131
with open(os.path.join(TEST_DATA, 'debian.json')) as f:
89132
test_data = json.load(f)
133+
90134
extract_data = debian.extract_vulnerabilities(test_data)
91135
debian_dump(extract_data)
92136

137+
def test_package_serializer(self):
93138
pk = Package.objects.filter(name="mimetex")
94139
response = PackageSerializer(pk, many=True).data
95140

@@ -101,3 +146,7 @@ def test_serializers(self):
101146
versions = {r['version'] for r in response}
102147
self.assertIn('1.50-1.1', versions)
103148
self.assertIn('1.74-1', versions)
149+
150+
purls = {r['package_url'] for r in response}
151+
self.assertIn('pkg:deb/debian/mimetex@1.50-1.1?distro=jessie', purls)
152+
self.assertIn('pkg:deb/debian/mimetex@1.74-1?distro=jessie', purls)

0 commit comments

Comments
 (0)