Created
July 3, 2018 17:21
-
-
Save cryzed/3931fc8363af9e14fd8e8657b7f4c47b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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