Skip to content

Commit 1d4692e

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 1d4692e

File tree

3 files changed

+273
-2
lines changed

3 files changed

+273
-2
lines changed

devel/management/commands/reposec.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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, repo=repo)
107+
pkgsec = PackageSecurity(pkg=pkg, is_pie=elf.pie, is_relro=elf.relro, is_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]
130+
results = [r for r in results if not r.is_pie or not r.is_relro or not r.is_canary]
131+
132+
with transaction.atomic():
133+
PackageSecurity.objects.all().delete()
134+
PackageSecurity.objects.bulk_create(results)
135+
136+
137+
class Elf:
138+
def __init__(self, fileobj):
139+
self.fileobj = fileobj
140+
self._elffile = None
141+
142+
@property
143+
def elffile(self):
144+
if not self._elffile:
145+
self._elffile = ELFFile(self.fileobj)
146+
return self._elffile
147+
148+
def _file_has_magic(self, fileobj, magic_bytes):
149+
length = len(magic_bytes)
150+
magic = fileobj.read(length)
151+
fileobj.seek(0)
152+
return magic == magic_bytes
153+
154+
def is_elf(self):
155+
"Take file object, peek at the magic bytes to check if ELF file."
156+
return self._file_has_magic(self.fileobj, b"\x7fELF")
157+
158+
def dynamic_tags(self, key):
159+
for section in self.elffile.iter_sections():
160+
if not isinstance(section, DynamicSection):
161+
continue
162+
for tag in section.iter_tags():
163+
if tag.entry.d_tag == key:
164+
return tag
165+
return None
166+
167+
def rpath(self, key="DT_RPATH", verbose=False):
168+
tag = self.dynamic_tags(key)
169+
if tag and verbose:
170+
return tag.rpath
171+
if tag:
172+
return 'RPATH'
173+
return ''
174+
175+
def runpath(self, key="DT_RUNPATH", verbose=False):
176+
tag = self.dynamic_tags(key)
177+
if tag and verbose:
178+
return tag.runpath
179+
if tag:
180+
return 'RUNPATH'
181+
182+
return ''
183+
184+
@property
185+
def relro(self):
186+
if self.elffile.num_segments() == 0:
187+
return "Disabled"
188+
189+
have_relro = False
190+
for segment in self.elffile.iter_segments():
191+
if re.search("GNU_RELRO", str(segment['p_type'])):
192+
have_relro = True
193+
break
194+
195+
if self.dynamic_tags("DT_BIND_NOW") and have_relro:
196+
return True
197+
if have_relro: # partial
198+
return False
199+
return False
200+
201+
@property
202+
def pie(self):
203+
header = self.elffile.header
204+
if self.dynamic_tags("EXEC"):
205+
return "Disabled"
206+
if "ET_DYN" in header['e_type']:
207+
if self.dynamic_tags("DT_DEBUG"):
208+
return True
209+
return True # DSO is PIE
210+
return False
211+
212+
@property
213+
def canary(self):
214+
for section in self.elffile.iter_sections():
215+
if not isinstance(section, SymbolTableSection):
216+
continue
217+
if section['sh_entsize'] == 0:
218+
continue
219+
for _, symbol in enumerate(section.iter_symbols()):
220+
if symbol.name in STACK_CHK:
221+
return True
222+
return False
223+
224+
def program_headers(self):
225+
pflags = P_FLAGS()
226+
if self.elffile.num_segments() == 0:
227+
return ""
228+
229+
found = False
230+
for segment in self.elffile.iter_segments():
231+
if search("GNU_STACK", str(segment['p_type'])):
232+
found = True
233+
if segment['p_flags'] & pflags.PF_X:
234+
return "Disabled"
235+
if found:
236+
return "Enabled"
237+
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.is_pie else 'No PIE'
177+
package.pkg.relro = 'Full RELRO' if package.is_relro else 'None'
178+
package.pkg.canary = 'Canary found' if package.is_canary else 'No canary found'
179+
package.pkg.fortify = 'Yes' if package.is_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, )

main/models.py

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

446446

447+
class PackageSecurity(models.Model):
448+
pkg = models.ForeignKey(Package, on_delete=models.CASCADE)
449+
is_pie = models.BooleanField(default=False)
450+
is_relro = models.BooleanField(default=False)
451+
is_canary = models.BooleanField(default=False)
452+
is_fortify = models.BooleanField(default=False)
453+
454+
class Meta:
455+
db_table = 'package_security'
456+
457+
447458
from django.db.models.signals import pre_save
448459

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

0 commit comments

Comments
 (0)