Skip to content

Instantly share code, notes, and snippets.

@cryzed
Created July 3, 2018 17:21
Show Gist options
  • Save cryzed/3931fc8363af9e14fd8e8657b7f4c47b to your computer and use it in GitHub Desktop.
Save cryzed/3931fc8363af9e14fd8e8657b7f4c47b to your computer and use it in GitHub Desktop.
#!/usr/bin/env python
import argparse
import collections
import itertools
import os
import shlex
import stat
import subprocess
import sys
import tempfile
import time
import xmlrpc.client
import xmlrpc.server
import appdirs
SUCCESS_EXIT_CODE = 0
ERROR_EXIT_CODE = 1
ENCODING = 'UTF-8'
FIREJAIL_DEFAULT_PROFILE_PATH = '/etc/firejail/default.profile'
# Instructions with relative path options that are not needed in a whitelist-based approach
FIREJAIL_PATH_INSTRUCTIONS = {'blacklist', 'blacklist-nolog', 'read-only'}
USER_CONFIG_DIR = appdirs.user_config_dir()
FIREJAIL_USER_PROFILES_PATH = os.path.join(USER_CONFIG_DIR, 'firejail')
GLASSBOX_PROFILE_PATH = os.path.join(FIREJAIL_USER_PROFILES_PATH, '_glassbox.profile')
GLASSBOX_EXECUTORS_PATH = os.getenv('GLASSBOX_EXECUTORS_PATH', '/usr/local/bin')
GLASSBOX_EXECUTOR_TEMPLATE = '''#!/bin/sh
firejail --name={name} {path} "$@"
'''
GLASSBOX_PROFILE_TEMPLATE = f'''#include ~/.config/firejail/_whitelist-desktop.profile
#include ~/.config/firejail/_whitelist-documents.profile
#include ~/.config/firejail/_whitelist-downloads.profile
#include ~/.config/firejail/_whitelist-dropbox.profile
#include ~/.config/firejail/_whitelist-music.profile
#include ~/.config/firejail/_whitelist-other.profile
#include ~/.config/firejail/_whitelist-pictures.profile
#include ~/.config/firejail/_whitelist-stuff.profile
#include ~/.config/firejail/_whitelist-videos.profile
include {GLASSBOX_PROFILE_PATH.replace(os.environ['HOME'], '~')}
'''
argument_parser = argparse.ArgumentParser()
sub_parsers = argument_parser.add_subparsers(dest='command')
sub_parsers.required = True
create_parser = sub_parsers.add_parser('create')
create_parser.add_argument('path')
create_parser.add_argument('--executors-path', default=GLASSBOX_EXECUTORS_PATH)
create_parser.add_argument('--whitelist-skeleton-profile', default=FIREJAIL_DEFAULT_PROFILE_PATH)
remove_parser = sub_parsers.add_parser('remove')
remove_parser.add_argument('name')
update_parser = sub_parsers.add_parser('update')
daemon_parser = sub_parsers.add_parser('daemon')
daemon_parser.add_argument('--port', type=int, default=8084)
daemon_parser.add_argument('--whitelist', nargs='+', default=[])
execute_parser = sub_parsers.add_parser('execute')
execute_parser.add_argument('command', nargs='*')
execute_parser.add_argument('--port', type=int, default=8084)
class FirejailProfile:
_Instruction = collections.namedtuple('Instruction', ['key', 'value', 'raw'])
def __init__(self):
self.instructions = []
self.path = None
@classmethod
def from_path(cls, path, encoding=ENCODING):
with open(path, encoding=encoding) as file:
lines = file.readlines()
profile = cls()
profile.extend(lines)
profile.path = path
return profile
def append(self, line):
# Normalize newline characters
line = line.rstrip('\r\n')
# If the line only consists of whitespace don't do any splitting
if not line.strip():
instruction = self._Instruction('', '', line)
self.instructions.append(instruction)
return
# Split line at most once, into a key-value pair. If the line is an instruction without arguments, value will be
# an empty list.
key, *value = line.split(None, 1)
value = value[0] if value else ''
# Special handling for comments
instruction = self._Instruction('#' if key.startswith('#') else key, value, line)
self.instructions.append(instruction)
def extend(self, lines):
for line in lines:
self.append(line)
def __getitem__(self, key):
return [instruction.value for instruction in self.instructions if instruction.key == key]
def _delete_key(self, key):
indices = []
for index, instruction in enumerate(self.instructions):
if instruction.key == key:
indices.append(index)
# Start deleting from the back to preserve correct indices without keeping track of an offset
for index in reversed(indices):
del self.instructions[index]
# Accept instruction key or line index
def __delitem__(self, key):
if isinstance(key, str):
self._delete_key(key)
else:
del self.instructions[key]
def __iter__(self):
return iter(instruction.raw for instruction in self.instructions)
def __str__(self):
return '\n'.join([instruction.raw for instruction in self.instructions])
def __repr__(self):
return f'<FirejailProfile {self.path!r}>'
def __len__(self):
return len(self.instructions)
def find_paths(path, predicate=lambda path: True, recursive=True, follow_symlinks=False):
for root, directories, filenames in os.walk(path, followlinks=follow_symlinks):
for name in itertools.chain([root], directories, filenames):
path = os.path.join(root, name)
if predicate(path):
yield path
if not recursive:
break
def remove(path):
try:
os.remove(path)
except PermissionError:
subprocess.run(['sudo', 'rm', path])
def create_command(arguments):
if not os.path.isabs(arguments.path):
print(f'Path to executable must be absolute!', file=sys.stderr)
return ERROR_EXIT_CODE
name = os.path.basename(arguments.path)
# Run application once in a private home to figure out paths for the whitelist
private_home = tempfile.mkdtemp()
subprocess.run(
['firejail', '--shell=none', f'--private={private_home}',
f'--profile={arguments.whitelist_skeleton_profile}', arguments.path])
created_paths = {f'~{path[len(private_home):]}' for path in find_paths(private_home)}
created_paths.difference_update({'~', '~/.config', '~/.cache', '~/.local', '~/.local/share', '~/.config/pulse'})
created_paths = sorted(created_paths)
# Create profile
profile_path = os.path.join(FIREJAIL_USER_PROFILES_PATH, f'{name}.profile')
print(f'- Creating profile {profile_path}')
with open(profile_path, 'w', encoding=ENCODING) as file:
file.write(GLASSBOX_PROFILE_TEMPLATE)
file.write('# Created paths:\n\n')
file.write('\n'.join(f'# {path}' for path in created_paths))
# Create executor
executor_path = os.path.join(arguments.executors_path, name)
print(f'- Creating executor {executor_path}')
executor_code = GLASSBOX_EXECUTOR_TEMPLATE.format(name=name, path=arguments.path)
temp_executor_fd, temp_executor_path = tempfile.mkstemp()
with os.fdopen(temp_executor_fd, 'w', encoding=ENCODING) as file:
file.write(executor_code)
# Make executor executable
stat_ = os.stat(temp_executor_path)
os.chmod(
temp_executor_path,
# Read/Write/Execute for owner, read and execute for group and others
stat_.st_mode | stat.S_IRWXU | stat.S_IXGRP | stat.S_IRGRP | stat.S_IXOTH | stat.S_IROTH)
subprocess.run(['sudo', 'chown', 'root:root', temp_executor_path])
subprocess.run(['sudo', 'mv', temp_executor_path, executor_path])
editor = os.getenv('EDITOR', os.getenv('VISUAL'))
if editor:
subprocess.run(shlex.split(editor) + [profile_path, executor_path])
return SUCCESS_EXIT_CODE
def remove_command(arguments):
profile_path = os.path.join(FIREJAIL_USER_PROFILES_PATH, f'{arguments.name}.profile')
executor_path = os.path.join(GLASSBOX_EXECUTORS_PATH, arguments.name)
if os.path.exists(profile_path):
print(f'- Deleting profile {profile_path}')
remove(profile_path)
if os.path.exists(executor_path):
print(f'- Deleting executor {executor_path}')
remove(executor_path)
return SUCCESS_EXIT_CODE
def load_firejail_profile_hierarchy(profile):
profiles = [profile]
includes = profile['include']
while includes:
path = includes.pop(0)
if not os.path.exists(path):
continue
profile = FirejailProfile.from_path(path)
profiles.append(profile)
includes.extend(profile['include'])
return profiles
def generate_glassbox_profile():
default_profile = FirejailProfile.from_path(FIREJAIL_DEFAULT_PROFILE_PATH)
profiles = load_firejail_profile_hierarchy(default_profile)
glassbox_profile = FirejailProfile()
glassbox_profile.append('# Glassbox profile')
glassbox_profile.append('')
glassbox_profile.append('# Included by Glassbox')
glassbox_profile.append('disable-mnt')
glassbox_profile.append('include /etc/firejail/whitelist-common.inc')
glassbox_profile.append('whitelist ~/.local/share/applications')
glassbox_profile.append('read-only ~/.local/share/applications')
glassbox_profile.append('whitelist ~/.local/share/Trash')
glassbox_profile.append('# Fix for: https://github.com/netblue30/firejail/issues/1282')
glassbox_profile.append('shell none')
for profile in profiles:
glassbox_profile.append('')
glassbox_profile.append(f'# Included from: {profile.path}')
# Remove all includes, comments and empty lines
del profile['include']
del profile['#']
del profile['']
# Remove all (no-)blacklist/read-only instructions with relative paths
indices = []
for index, instruction in enumerate(profile.instructions):
if instruction.key in FIREJAIL_PATH_INSTRUCTIONS and not instruction.value.startswith('/'):
indices.append(index)
for index in reversed(indices):
del profile[index]
glassbox_profile.extend(profile)
# Add trailing newline
glassbox_profile.append('')
return glassbox_profile
# TODO: Adjust desktop files
def update_command(arguments):
print(f'- Creating Glassbox profile...')
profile = generate_glassbox_profile()
os.makedirs(os.path.dirname(GLASSBOX_PROFILE_PATH), exist_ok=True)
if os.path.exists(GLASSBOX_PROFILE_PATH):
backup_path = f'{GLASSBOX_PROFILE_PATH}.{int(time.time())}'
print(f'- Renaming existing Glassbox profile to: {backup_path}')
os.rename(GLASSBOX_PROFILE_PATH, backup_path)
with open(GLASSBOX_PROFILE_PATH, 'w', encoding=ENCODING) as file:
file.write(str(profile))
print(f'- Glassbox profile written to {GLASSBOX_PROFILE_PATH}.')
return SUCCESS_EXIT_CODE
def daemon_execute(command, whitelist):
path = command[0]
if path not in whitelist:
raise PermissionError(f'{path} is not whitelisted')
subprocess.Popen(command, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
return SUCCESS_EXIT_CODE
def daemon_command(arguments):
# Only allow daemon to run on localhost
server = xmlrpc.server.SimpleXMLRPCServer(('127.0.0.1', arguments.port), allow_none=True, logRequests=False)
server.register_function(lambda command: daemon_execute(command, arguments.whitelist), 'execute')
server.serve_forever()
return SUCCESS_EXIT_CODE
def execute_command(arguments):
client = xmlrpc.client.ServerProxy(f'http://127.0.0.1:{arguments.port}')
try:
client.execute(arguments.command)
except ConnectionRefusedError:
print(f'Connection refused on port {arguments.port}.', file=sys.stderr)
return ERROR_EXIT_CODE
except xmlrpc.client.Fault as exception:
print(exception.faultString, file=sys.stderr)
return ERROR_EXIT_CODE
return SUCCESS_EXIT_CODE
create_parser.set_defaults(function=create_command)
remove_parser.set_defaults(function=remove_command)
update_parser.set_defaults(function=update_command)
daemon_parser.set_defaults(function=daemon_command)
execute_parser.set_defaults(function=execute_command)
def main():
arguments = argument_parser.parse_args()
argument_parser.exit(arguments.function(arguments))
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment