Skip to content

Commit 8f8b31d

Browse files
committed
devel: add reposec management command
Add a new command that scans pkg.tar.xz files with elf binaries in /usr/bin/ and checks for security hardening issues. This adds a new dashboard view which shows packages with these issues.
1 parent 767feb1 commit 8f8b31d

File tree

5 files changed

+311
-2
lines changed

5 files changed

+311
-2
lines changed

devel/management/commands/reposec.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""
2+
reposec command
3+
4+
Parses all packages in a given repo and creates PackageSecurity
5+
objects which check for PIE, RELRO, Stack Canary's and Fortify.
6+
7+
Usage: ./manage.py reposec ARCH PATH
8+
ARCH: architecture to check
9+
PATH: full path to the repository directory.
10+
11+
Example:
12+
./manage.py reposec x86_64 /srv/ftp/core
13+
"""
14+
15+
import io
16+
import os
17+
import re
18+
import sys
19+
import logging
20+
21+
from functools import partial
22+
from glob import glob
23+
from multiprocessing import Pool, cpu_count
24+
25+
from elftools.elf.constants import P_FLAGS
26+
from elftools.elf.dynamic import DynamicSection
27+
from elftools.elf.elffile import ELFFile
28+
from elftools.elf.sections import SymbolTableSection
29+
from libarchive import file_reader
30+
31+
from django.core.management.base import BaseCommand, CommandError
32+
from django.db import transaction
33+
34+
from main.models import Arch, Package, PackageSecurity, Repo
35+
36+
37+
PKG_EXT = '.tar.xz'
38+
STACK_CHK = set(["__stack_chk_fail", "__stack_smash_handler"])
39+
40+
41+
logging.basicConfig(
42+
level=logging.WARNING,
43+
format='%(asctime)s -> %(levelname)s: %(message)s',
44+
datefmt='%Y-%m-%d %H:%M:%S',
45+
stream=sys.stderr)
46+
TRACE = 5
47+
logging.addLevelName(TRACE, 'TRACE')
48+
logger = logging.getLogger()
49+
50+
class Command(BaseCommand):
51+
help = ""
52+
missing_args_message = 'missing arch and file.'
53+
54+
def add_arguments(self, parser):
55+
parser.add_argument('args', nargs='*', help='<arch> <filename>')
56+
parser.add_argument('--processes',
57+
action='store_true',
58+
dest='processes',
59+
default=cpu_count(),
60+
help=f'number of parallel processes (default: {cpu_count()})')
61+
62+
63+
def handle(self, arch=None, directory=None, processes=cpu_count(), **options):
64+
if not arch:
65+
raise CommandError('Architecture is required.')
66+
if not directory:
67+
raise CommandError('Repo location is required.')
68+
directory = os.path.normpath(directory)
69+
if not os.path.exists(directory):
70+
raise CommandError('Specified repository location does not exists.')
71+
72+
v = int(options.get('verbosity', 0))
73+
if v == 0:
74+
logger.level = logging.ERROR
75+
elif v == 1:
76+
logger.level = logging.INFO
77+
elif v >= 2:
78+
logger.level = logging.DEBUG
79+
80+
return read_repo(arch, directory, processes, options)
81+
82+
83+
def read_file(arch, repo, filename):
84+
pkgsec = None
85+
basename = os.path.basename(filename)
86+
pkgname = basename.rsplit('-', 3)[0]
87+
88+
with file_reader(filename) as pkg:
89+
for entry in pkg:
90+
if not entry.isfile:
91+
continue
92+
93+
# Retrieve pkgname
94+
if entry.name == '.PKGINFO':
95+
continue
96+
97+
if not entry.name.startswith('usr/bin/'):
98+
continue
99+
100+
fp = io.BytesIO(b''.join(entry.get_blocks()))
101+
elf = Elf(fp)
102+
103+
if not elf.is_elf():
104+
continue
105+
106+
pkg = Package.objects.get(pkgname=pkgname, arch=arch, repo=repo)
107+
pkgsec = PackageSecurity(pkg=pkg, pie=elf.pie, relro=elf.relro, canary=elf.canary)
108+
109+
return pkgsec
110+
111+
112+
113+
def read_repo(arch, source_dir, processes, options):
114+
tasks = []
115+
116+
directory = os.path.join(source_dir, 'os', arch)
117+
for filename in glob(os.path.join(directory, f'*{PKG_EXT}')):
118+
tasks.append((filename))
119+
120+
arch = Arch.objects.get(name=arch)
121+
122+
reponame = os.path.basename(source_dir).title()
123+
repo = Repo.objects.get(name=reponame)
124+
125+
126+
with Pool(processes=processes) as pool:
127+
results = pool.map(partial(read_file, arch, repo), tasks)
128+
129+
results = [r for r in results if r and (not r.pie or not r.relro or not r.canary)]
130+
131+
with transaction.atomic():
132+
PackageSecurity.objects.all().delete()
133+
PackageSecurity.objects.bulk_create(results)
134+
135+
136+
class Elf:
137+
def __init__(self, fileobj):
138+
self.fileobj = fileobj
139+
self._elffile = None
140+
141+
@property
142+
def elffile(self):
143+
if not self._elffile:
144+
self._elffile = ELFFile(self.fileobj)
145+
return self._elffile
146+
147+
def _file_has_magic(self, fileobj, magic_bytes):
148+
length = len(magic_bytes)
149+
magic = fileobj.read(length)
150+
fileobj.seek(0)
151+
return magic == magic_bytes
152+
153+
def is_elf(self):
154+
"Take file object, peek at the magic bytes to check if ELF file."
155+
return self._file_has_magic(self.fileobj, b"\x7fELF")
156+
157+
def dynamic_tags(self, key):
158+
for section in self.elffile.iter_sections():
159+
if not isinstance(section, DynamicSection):
160+
continue
161+
for tag in section.iter_tags():
162+
if tag.entry.d_tag == key:
163+
return tag
164+
return None
165+
166+
def rpath(self, key="DT_RPATH", verbose=False):
167+
tag = self.dynamic_tags(key)
168+
if tag and verbose:
169+
return tag.rpath
170+
if tag:
171+
return 'RPATH'
172+
return ''
173+
174+
def runpath(self, key="DT_RUNPATH", verbose=False):
175+
tag = self.dynamic_tags(key)
176+
if tag and verbose:
177+
return tag.runpath
178+
if tag:
179+
return 'RUNPATH'
180+
181+
return ''
182+
183+
@property
184+
def relro(self):
185+
if self.elffile.num_segments() == 0:
186+
return "Disabled"
187+
188+
have_relro = False
189+
for segment in self.elffile.iter_segments():
190+
if re.search("GNU_RELRO", str(segment['p_type'])):
191+
have_relro = True
192+
break
193+
194+
if self.dynamic_tags("DT_BIND_NOW") and have_relro:
195+
return True
196+
if have_relro: # partial
197+
return False
198+
return False
199+
200+
@property
201+
def pie(self):
202+
header = self.elffile.header
203+
if self.dynamic_tags("EXEC"):
204+
return "Disabled"
205+
if "ET_DYN" in header['e_type']:
206+
if self.dynamic_tags("DT_DEBUG"):
207+
return True
208+
return True # DSO is PIE
209+
return False
210+
211+
@property
212+
def canary(self):
213+
for section in self.elffile.iter_sections():
214+
if not isinstance(section, SymbolTableSection):
215+
continue
216+
if section['sh_entsize'] == 0:
217+
continue
218+
for _, symbol in enumerate(section.iter_symbols()):
219+
if symbol.name in STACK_CHK:
220+
return True
221+
return False
222+
223+
def program_headers(self):
224+
pflags = P_FLAGS()
225+
if self.elffile.num_segments() == 0:
226+
return ""
227+
228+
found = False
229+
for segment in self.elffile.iter_segments():
230+
if search("GNU_STACK", str(segment['p_type'])):
231+
found = True
232+
if segment['p_flags'] & pflags.PF_X:
233+
return "Disabled"
234+
if found:
235+
return "Enabled"
236+
return "Disabled"

devel/reports.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.template.defaultfilters import filesizeformat
66
from django.db import connection
77
from django.utils.timezone import now
8-
from main.models import Package, PackageFile
8+
from main.models import Package, PackageFile, PackageSecurity
99
from packages.models import Depend, PackageRelation
1010

1111
from .models import DeveloperKey
@@ -167,6 +167,20 @@ def non_existing_dependencies(packages):
167167
return packages
168168

169169

170+
def security_packages_overview(packages):
171+
filtered = []
172+
packages_ids = packages.values_list('id',
173+
flat=True).order_by().distinct()
174+
packages = PackageSecurity.objects.filter(id__in=set(packages_ids))
175+
for package in packages:
176+
package.pkg.pie = 'PIE enabled' if package.pie else 'No PIE'
177+
package.pkg.relro = 'Full RELRO' if package.relro else 'None'
178+
package.pkg.canary = 'Canary found' if package.canary else 'No canary found'
179+
package.pkg.fortify = 'Yes' if package.canary else 'No'
180+
filtered.append(package.pkg)
181+
182+
return filtered
183+
170184

171185
REPORT_OLD = DeveloperReport(
172186
'old', 'Old', 'Packages last built more than two years ago', old)
@@ -223,6 +237,14 @@ def non_existing_dependencies(packages):
223237
['nonexistingdep'],
224238
personal=False)
225239

240+
REPORT_SECURITY = DeveloperReport(
241+
'security-issue-packages',
242+
'Security of Packages',
243+
'Packages that have security issues',
244+
security_packages_overview,
245+
['PIE', 'RELRO', 'CANARY', 'FORTIFY'], ['pie', 'relro', 'canary', 'fortify'])
246+
247+
226248
def available_reports():
227249
return (REPORT_OLD,
228250
REPORT_OUTOFDATE,
@@ -233,4 +255,5 @@ def available_reports():
233255
REPORT_ORPHANS,
234256
REPORT_SIGNATURE,
235257
REPORT_SIG_TIME,
236-
NON_EXISTING_DEPENDENCIES, )
258+
NON_EXISTING_DEPENDENCIES,
259+
REPORT_SECURITY, )
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 2.2.5 on 2019-10-09 19:24
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('main', '0002_repo_public_testing'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='PackageSecurity',
16+
fields=[
17+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('pie', models.BooleanField(default=False)),
19+
('relro', models.PositiveIntegerField(choices=[(1, 'No RELRO'), (2, 'Partial RELRO'), (2, 'Full RELRO')], default=1)),
20+
('canary', models.BooleanField(default=False)),
21+
('fortify', models.BooleanField(default=False)),
22+
('pkg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Package')),
23+
],
24+
options={
25+
'db_table': 'package_security',
26+
},
27+
),
28+
]

main/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,26 @@ class Meta:
444444
db_table = 'package_files'
445445

446446

447+
class PackageSecurity(models.Model):
448+
NO_RELRO = 1
449+
PARTIAL_RELRO = 2
450+
FULL_RELRO = 2
451+
RELRO_CHOICES = (
452+
(NO_RELRO, 'No RELRO'),
453+
(PARTIAL_RELRO, 'Partial RELRO'),
454+
(FULL_RELRO, 'Full RELRO'),
455+
)
456+
457+
pkg = models.ForeignKey(Package, on_delete=models.CASCADE)
458+
pie = models.BooleanField(default=False)
459+
relro = models.PositiveIntegerField(choices=RELRO_CHOICES, default=NO_RELRO)
460+
canary = models.BooleanField(default=False)
461+
fortify = models.BooleanField(default=False)
462+
463+
class Meta:
464+
db_table = 'package_security'
465+
466+
447467
from django.db.models.signals import pre_save
448468

449469
# note: reporead sets the 'created' field on Package objects, so no signal

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ django-jinja==2.4.1
1313
sqlparse==0.3.0
1414
django-csp==3.5
1515
ptpython==2.0.4
16+
pyelftools==0.25
17+
libarchive-c==2.8

0 commit comments

Comments
 (0)