├── tests ├── __init__.py ├── files │ ├── example_key.asc.malformed │ ├── testconfig.json.malformedfpr │ ├── testconfig.json.malformedonion │ └── testconfig.json ├── test_dom0_salt_config.py ├── test_vm_sys_usb.py ├── conftest.py ├── test_qubes_vms.py ├── vars │ └── sd-viewer.mimeapps ├── test_qubes_rpc.py ├── test_dom0_validate.py ├── test_vm_sd_app.py ├── test_vm_sd_gpg.py ├── test_vm_sd_devices.py ├── test_vm_sd_proxy.py ├── test_vm_sd_log.py ├── test_dom0_rpm_repo.py ├── test_vms_exist.py └── test_vm_sd_viewer.py ├── VERSION ├── rpm-build ├── BUILD │ └── .empty ├── RPMS │ └── .empty ├── SRPMS │ └── .empty ├── BUILDROOT │ └── .empty └── SOURCES │ └── .empty ├── sdw_notify ├── __init__.py ├── strings.py ├── NotifyApp.py └── Notify.py ├── sdw_updater ├── __init__.py └── strings.py ├── sdw_util ├── __init__.py └── Util.py ├── launcher ├── sdw_util ├── sdw_notify ├── sdw_updater ├── tests │ ├── fixtures │ │ ├── bad-os-release-file │ │ ├── os-release-qubes-4.1 │ │ └── os-release-ubuntu │ ├── conftest.py │ └── test_sources.py └── README.md ├── .github ├── CODEOWNERS ├── workstation-ci.yml ├── dependabot.yml ├── workflows │ ├── dependency-review.yml │ ├── ci.yml │ └── nightlies.yml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ ├── proposal.md │ └── release.md ├── files ├── 10-securedrop-logind_override.conf ├── securedrop-128x128.png ├── sdw-notify.service ├── press.freedom.SecureDropUpdater.desktop ├── sdw-notify.timer ├── securedrop-user-xfce-icon-size.service ├── 95-securedrop-systemd-user.preset ├── config.json.example ├── securedrop-logind-override-disable.service ├── securedrop-user-xfce-settings.service ├── sdw-login.py ├── clean-salt ├── sdw-updater.py ├── securedrop-scalable.svg ├── 31-securedrop-workstation.policy ├── destroy-vm.py ├── sdw-notify.py ├── 32-securedrop-workstation.policy └── update-xfce-settings ├── .flake8 ├── .git-blame-ignore-revs ├── docs └── images │ ├── qubes-generic-ui.png │ ├── data-flow-diagram.png │ └── historical │ ├── alpha-workflow │ ├── signin.png │ ├── client-with-messages.png │ └── client-with-documents.png │ ├── early-beta │ ├── client-01-login.png │ ├── client-02-loaded.png │ ├── client-with-documents.png │ ├── client-03-source-selected.png │ ├── client-04-disp-vm-loading.png │ ├── client-05-document-opened.png │ ├── client-10-export-completed.png │ ├── client-12-deleting-source.png │ ├── client-06-composing-response.png │ ├── client-09-export-enter-passphrase.png │ ├── client-11-viewing-different-source.png │ ├── client-07-export-with-no-usb-attached.png │ └── client-08-export-after-usb-attached.png │ └── pre-alpha-workflow │ ├── step6-view.png │ ├── step2-download.png │ ├── step3-decrypt.png │ ├── step5-nautilus.png │ ├── step6-view-cropped.png │ ├── step3-decrypt-cropped.png │ ├── step4-decryption-done.png │ ├── step2-download-cropped.png │ ├── step5-nautilus-cropped.png │ ├── step1-journalist-interface.png │ ├── step4-decryption-done-cropped.png │ └── step1-journalist-interface-cropped.png ├── securedrop_salt ├── sdlog.conf ├── sd-attach-export-device ├── sd-clean-default-dispvm.sls ├── dom0-xfce-desktop-file.j2 ├── sd-default-config.yml ├── sd-proxy-template-files.sls ├── 99-sd-devices.rules ├── sd-usb-autoattach-remove.sls ├── sd-devices-files.sls ├── sd-viewer-files.sls ├── sd-app-files.sls ├── sd-reset-whonix-prefs.sls ├── sd-logging-setup.sls ├── sd-base-template.sls ├── sd-remove-whonix-packages.sls ├── sd-remove-unused-qubes.sls ├── sd-upgrade-templates.sls ├── sd-remove-unused-templates.sls ├── remove-tags.py ├── sd-base-template-packages.sls ├── sd-default-config.sls ├── sd-gpg-files.sls ├── sd-gpg.sls ├── sd-workstation-template.sls ├── sd-usb-autoattach-add.sls ├── sd-workstation.top ├── sd-app.sls ├── sd-devices.sls ├── sd-viewer.sls ├── sd-dom0-files.sls ├── sd-proxy.sls ├── fpf-apt-repo.sls ├── sd-log.sls ├── sd-clean-all.sls ├── apt-test-pubkey.asc ├── apt-test_freedom_press.sources.j2 ├── securedrop-handle-upgrade └── sd-sys-vms.sls ├── MANIFEST.in ├── SECURITY.md ├── scripts ├── fake-setarch.py ├── common.sh ├── build-rpm.sh ├── shellcheck.sh ├── prep-dev ├── clone-to-dom0 ├── verify_rpm_mtime.py ├── container.sh ├── configure-environment.py ├── test_key.asc ├── bootstrap-keyring.py └── switch-apt-source.py ├── bootstrap └── Dockerfile ├── SOURCE_OFFER ├── setup.py ├── update_version.py ├── .gitignore └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.6.0rc1 2 | -------------------------------------------------------------------------------- /rpm-build/BUILD/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpm-build/RPMS/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpm-build/SRPMS/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sdw_notify/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sdw_updater/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sdw_util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /launcher/sdw_util: -------------------------------------------------------------------------------- 1 | ../sdw_util -------------------------------------------------------------------------------- /rpm-build/BUILDROOT/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpm-build/SOURCES/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /launcher/sdw_notify: -------------------------------------------------------------------------------- 1 | ../sdw_notify -------------------------------------------------------------------------------- /launcher/sdw_updater: -------------------------------------------------------------------------------- 1 | ../sdw_updater -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @freedomofpress/securedrop-maintainers 2 | -------------------------------------------------------------------------------- /files/10-securedrop-logind_override.conf: -------------------------------------------------------------------------------- 1 | [Login] 2 | HandleLidSwitch=poweroff -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203,W503 3 | max-line-length = 100 4 | extend-exclude = .venv 5 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 782a04e59ae10d40085b5d8807e255143e5d05c5 2 | 0d4c0144d01c24187c449593ca665e2f0141b507 3 | -------------------------------------------------------------------------------- /.github/workstation-ci.yml: -------------------------------------------------------------------------------- 1 | # for 2 | qubes: "4.2" 3 | -------------------------------------------------------------------------------- /tests/files/example_key.asc.malformed: -------------------------------------------------------------------------------- 1 | Version: GnuPG v2.0.19 (GNU/Linux) 2 | 3 | -----END PGP PRIVATE KEY BLOCK----- 4 | -------------------------------------------------------------------------------- /files/securedrop-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/files/securedrop-128x128.png -------------------------------------------------------------------------------- /docs/images/qubes-generic-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/qubes-generic-ui.png -------------------------------------------------------------------------------- /docs/images/data-flow-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/data-flow-diagram.png -------------------------------------------------------------------------------- /launcher/tests/fixtures/bad-os-release-file: -------------------------------------------------------------------------------- 1 | # No line 2 | VERSION= 3 | [we're doing toml now] 4 | RELEASES = [ ["gamma", "delta"], [1, 2] ] 5 | -------------------------------------------------------------------------------- /files/sdw-notify.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SecureDrop Updater GUI Notification 3 | 4 | [Service] 5 | Type=oneshot 6 | ExecStart=/usr/bin/sdw-notify 7 | -------------------------------------------------------------------------------- /docs/images/historical/alpha-workflow/signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/alpha-workflow/signin.png -------------------------------------------------------------------------------- /securedrop_salt/sdlog.conf: -------------------------------------------------------------------------------- 1 | module(load="omprog") 2 | action(type="omprog" 3 | binary="/usr/sbin/sd-rsyslog" 4 | template="RSYSLOG_TraditionalFileFormat") 5 | -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-01-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-01-login.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-02-loaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-02-loaded.png -------------------------------------------------------------------------------- /files/press.freedom.SecureDropUpdater.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Type=Application 4 | Terminal=false 5 | Icon=securedrop 6 | Name=SecureDrop 7 | Exec=sdw-updater 8 | -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step6-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step6-view.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-with-documents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-with-documents.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step2-download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step2-download.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step3-decrypt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step3-decrypt.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step5-nautilus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step5-nautilus.png -------------------------------------------------------------------------------- /docs/images/historical/alpha-workflow/client-with-messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/alpha-workflow/client-with-messages.png -------------------------------------------------------------------------------- /docs/images/historical/alpha-workflow/client-with-documents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/alpha-workflow/client-with-documents.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-03-source-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-03-source-selected.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-04-disp-vm-loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-04-disp-vm-loading.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-05-document-opened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-05-document-opened.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-10-export-completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-10-export-completed.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-12-deleting-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-12-deleting-source.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step6-view-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step6-view-cropped.png -------------------------------------------------------------------------------- /files/sdw-notify.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SecureDrop Updater GUI Notification 3 | 4 | [Timer] 5 | OnStartupSec=2h 6 | OnUnitActiveSec=2h 7 | 8 | [Install] 9 | WantedBy=default.target 10 | -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-06-composing-response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-06-composing-response.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step3-decrypt-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step3-decrypt-cropped.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step4-decryption-done.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step4-decryption-done.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step2-download-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step2-download-cropped.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step5-nautilus-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step5-nautilus-cropped.png -------------------------------------------------------------------------------- /launcher/tests/fixtures/os-release-qubes-4.1: -------------------------------------------------------------------------------- 1 | NAME=Qubes 2 | VERSION="4.1 (R4.1)" 3 | ID=qubes 4 | VERSION_ID=4.0 5 | PRETTY_NAME="Qubes 4.1 (R4.1)" 6 | ANSI_COLOR="0;31" 7 | CPE_NAME="cpe:/o:ITL:qubes:4.1" 8 | -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-09-export-enter-passphrase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-09-export-enter-passphrase.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-11-viewing-different-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-11-viewing-different-source.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step1-journalist-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step1-journalist-interface.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-07-export-with-no-usb-attached.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-07-export-with-no-usb-attached.png -------------------------------------------------------------------------------- /docs/images/historical/early-beta/client-08-export-after-usb-attached.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/early-beta/client-08-export-after-usb-attached.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step4-decryption-done-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step4-decryption-done-cropped.png -------------------------------------------------------------------------------- /docs/images/historical/pre-alpha-workflow/step1-journalist-interface-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedomofpress/securedrop-workstation/HEAD/docs/images/historical/pre-alpha-workflow/step1-journalist-interface-cropped.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include securedrop_salt/* 2 | include README.md 3 | include LICENSE 4 | include VERSION 5 | include sdw_updater/*.py 6 | include sdw_notify/*.py 7 | include sdw_util/*.py 8 | include files/* 9 | include setup.py 10 | -------------------------------------------------------------------------------- /securedrop_salt/sd-attach-export-device: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # udev action for attaching USB export devices to sd-devices 4 | 5 | QDEVNAME="$(basename "$DEVPATH")" 6 | echo sys-usb "$QDEVNAME" | qrexec-client-vm sd-devices qubes.USBAttach 7 | -------------------------------------------------------------------------------- /files/securedrop-user-xfce-icon-size.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Enlarge XFCE icon size for SDW 3 | 4 | [Service] 5 | Type=oneshot 6 | ExecStart=/usr/bin/securedrop/update-xfce-settings adjust-icon-size 7 | 8 | [Install] 9 | WantedBy=default.target -------------------------------------------------------------------------------- /securedrop_salt/sd-clean-default-dispvm.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | set-fedora-as-default-dispvm: 5 | cmd.run: 6 | - name: qvm-check default-dvm && qubes-prefs default_dispvm default-dvm || qubes-prefs default_dispvm '' 7 | -------------------------------------------------------------------------------- /securedrop_salt/dom0-xfce-desktop-file.j2: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Encoding=UTF-8 3 | Version=0.9.4 4 | Type=Application 5 | Name={{ desktop_name }} 6 | Comment={{ desktop_comment }} 7 | Exec={{ desktop_exec }} 8 | OnlyShowIn=XFCE; 9 | StartupNotify=false 10 | Terminal=false 11 | Hidden=false 12 | -------------------------------------------------------------------------------- /securedrop_salt/sd-default-config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Production variables, for use with real-world installs 3 | prod: 4 | apt_sources_filename: "apt_freedom_press.sources" 5 | # Staging and Dev variables, for QAing and local development 6 | test: 7 | apt_sources_filename: "apt-test_freedom_press.sources" 8 | -------------------------------------------------------------------------------- /files/95-securedrop-systemd-user.preset: -------------------------------------------------------------------------------- 1 | # Systemd user unit presets for production SDW configuration. 2 | # Don't override Qubes systemd settings (75-qubes-dom0-user.preset) 3 | # or systemd settings (90-systemd.preset). 4 | enable securedrop-user-xfce-icon-size.service 5 | enable securedrop-user-xfce-settings.service 6 | enable sdw-notify.timer -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | ### Reporting a Vulnerability 4 | 5 | If you have found a vulnerability, please **DO NOT** file a public issue. Please send us your report privately either via: 6 | 7 | - Email to security@freedom.press (Optionally GPG-encrypted to [734F6E707434ECA6C007E1AE82BD6C9616DABB79](https://securedrop.org/documents/6/fpf-email.asc)) 8 | -------------------------------------------------------------------------------- /files/config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "submission_key_fpr": "65A1B5FF195B56353CC63DFFCC40EF1228271441", 3 | "hidserv": { 4 | "hostname": "sdolvtfhatvsysc6l34d65ymdwxcujausv7k5jk4cy5ttzhjoi6fzvyd.onion", 5 | "key": "5U4JPYSZ34N2ZDSOUAL2YLEX2NPI5BLL2Y66QJW24KLSH7R3FEPQ" 6 | }, 7 | "environment": "prod", 8 | "vmsizes": { 9 | "sd_app": 10, 10 | "sd_log": 5 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /files/securedrop-logind-override-disable.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Disable logind power management customization (dev systems only) 3 | ConditionPathExists=/var/lib/securedrop-workstation/dev 4 | Before=systemd-logind.service 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=rm -f /etc/systemd/logind.conf.d/10-securedrop-logind_override.conf 9 | 10 | [Install] 11 | WantedBy=default.target 12 | -------------------------------------------------------------------------------- /tests/files/testconfig.json.malformedfpr: -------------------------------------------------------------------------------- 1 | { 2 | "submission_key_fpr": "65A1B5FF195B56353CC63DFFCC40EF1228271", 3 | "hidserv": { 4 | "hostname": "sdolvtfhatvsysc6l34d65ymdwxcujausv7k5jk4cy5ttzhjoi6fzvyd.onion", 5 | "key": "5U4JPYSZ34N2ZDSOUAL2YLEX2NPI5BLL2Y66QJW24KLSH7R3FEPQ" 6 | }, 7 | "environment": "prod", 8 | "vmsizes": { 9 | "sd_app": 10, 10 | "sd_log": 5 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scripts/fake-setarch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # In containers that cannot be run with seccomp=unconfined, setarch fails, 3 | # breaking reprotest. Symlinking setarch to this file makes reprotest believe 4 | # everything worked as expected without having to patch its code. 5 | from subprocess import run 6 | from sys import argv 7 | 8 | if "sh" in argv: 9 | run(argv[argv.index("sh") :], check=False) 10 | -------------------------------------------------------------------------------- /tests/files/testconfig.json.malformedonion: -------------------------------------------------------------------------------- 1 | { 2 | "submission_key_fpr": "65A1B5FF195B56353CC63DFFCC40EF1228271441", 3 | "hidserv": { 4 | "hostname": "sdolvtfhatvsysc6l34d65ymdwxcuj.onion", 5 | "key": "5U4JPYSZ34N2ZDSOUAL2YLEX2NPI5BLL2Y66QJW24KLSH7R3FEPQ" 6 | }, 7 | "environment": "prod", 8 | "vmsizes": { 9 | "sd_app": 10, 10 | "sd_log": 5 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/files/testconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "submission_key_fpr": "65A1B5FF195B56353CC63DFFCC40EF1228271441", 3 | "hidserv": { 4 | "hostname": "sdolvtfhatvsysc6l34d65ymdwxcujausv7k5jk4cy5ttzhjoi6fzvyd.onion", 5 | "key": "5U4JPYSZ34N2ZDSOUAL2YLEX2NPI5BLL2Y66QJW24KLSH7R3FEPQ" 6 | }, 7 | "environment": "prod", 8 | "vmsizes": { 9 | "sd_app": 10, 10 | "sd_log": 5 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /securedrop_salt/sd-proxy-template-files.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | include: 4 | - securedrop_salt.fpf-apt-repo 5 | - securedrop_salt.sd-logging-setup 6 | 7 | # Depends on FPF-controlled apt repo 8 | install-securedrop-proxy-package: 9 | pkg.installed: 10 | - pkgs: 11 | - securedrop-proxy 12 | - require: 13 | - sls: securedrop_salt.fpf-apt-repo 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | allow: 8 | - dependency-type: "all" 9 | ignore: 10 | - dependency-name: "pyqt*" 11 | groups: 12 | dependencies: 13 | patterns: ["*"] 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v6 13 | with: 14 | persist-credentials: false 15 | - name: 'Dependency Review' 16 | uses: actions/dependency-review-action@v4 17 | -------------------------------------------------------------------------------- /files/securedrop-user-xfce-settings.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Enable XFCE customizations for SDW 3 | ConditionPathExists=|!/var/lib/securedrop-workstation/dev 4 | ConditionPathExists=|/var/lib/securedrop-workstation/prod 5 | ConditionPathExists=|/var/lib/securedrop-workstation/staging 6 | 7 | [Service] 8 | Type=oneshot 9 | ExecStart=/usr/bin/securedrop/update-xfce-settings disable-unsafe-power-management 10 | 11 | [Install] 12 | WantedBy=default.target -------------------------------------------------------------------------------- /launcher/tests/fixtures/os-release-ubuntu: -------------------------------------------------------------------------------- 1 | NAME="Ubuntu" 2 | VERSION="18.04.5 LTS (Bionic Beaver)" 3 | ID=ubuntu 4 | ID_LIKE=debian 5 | PRETTY_NAME="Ubuntu 18.04.5 LTS" 6 | VERSION_ID="18.04" 7 | HOME_URL="https://www.ubuntu.com/" 8 | SUPPORT_URL="https://help.ubuntu.com/" 9 | BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" 10 | PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" 11 | VERSION_CODENAME=bionic 12 | UBUNTU_CODENAME=bionic 13 | -------------------------------------------------------------------------------- /securedrop_salt/99-sd-devices.rules: -------------------------------------------------------------------------------- 1 | # Class 08 == storage, subclass 06 == SCSI 2 | # Class 07 == printer, subclass 01 == printer 3 | # https://www.usb.org/defined-class-codes 4 | # https://www.usb.org/document-library/mass-storage-class-specification-overview-14 5 | # https://www.usb.org/document-library/printer-device-class-document-11 6 | ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{ID_USB_INTERFACES}=="*:0806??:*|*:0701??:*", RUN+="/usr/local/bin/sd-attach-export-device" 7 | -------------------------------------------------------------------------------- /launcher/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import tempfile 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def tmpdir(): 10 | """Run the test in a temporary directory""" 11 | cwd = os.getcwd() 12 | with tempfile.TemporaryDirectory(prefix="updater") as tmpdir: 13 | os.chdir(tmpdir) 14 | yield tmpdir 15 | os.chdir(cwd) 16 | 17 | 18 | skip_in_dom0 = pytest.mark.skipif( 19 | socket.gethostname() == "dom0", 20 | reason="Test cannot be run in dom0", 21 | ) 22 | -------------------------------------------------------------------------------- /securedrop_salt/sd-usb-autoattach-remove.sls: -------------------------------------------------------------------------------- 1 | remove-usb-autoattach: 2 | cmd.run: 3 | - name: | 4 | qvm-run sys-usb 'sudo rm -f /etc/udev/rules.d/99-sd-devices.rules' 5 | qvm-run sys-usb 'sudo rm -f /rw/config/sd/etc/udev/rules.d/99-sd-devices.rules' 6 | qvm-run sys-usb 'sudo rm -f /usr/local/bin/sd-attach-export-device' 7 | qvm-run sys-usb 'sudo udevadm control --reload' 8 | qvm-run sys-usb 'sudo perl -i -0pe "s/### BEGIN securedrop-workstation ###.*### END securedrop-workstation ###//gms" /rw/config/rc.local' 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | ## Test plan 4 | 5 | 6 | ## Checklist 7 | 8 | 10 | 11 | This change accounts for: 12 | - [ ] any necessary RPM packaging updates (e.g., added/removed files, see `MANIFEST.in` and `rpm-build/SPECS/securedrop-workstation-dom0-config.spec`) 13 | - [ ] any required documentation 14 | -------------------------------------------------------------------------------- /securedrop_salt/sd-devices-files.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # sd-devices-files 6 | # ======== 7 | # 8 | # Moves files into place on sd-devices 9 | # 10 | ## 11 | include: 12 | - securedrop_salt.fpf-apt-repo 13 | - securedrop_salt.sd-logging-setup 14 | 15 | # Install securedrop-export package https://github.com/freedomofpress/securedrop-export 16 | sd-devices-install-package: 17 | pkg.installed: 18 | - name: securedrop-export 19 | - require: 20 | - sls: securedrop_salt.fpf-apt-repo 21 | -------------------------------------------------------------------------------- /securedrop_salt/sd-viewer-files.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # sd-viewer-files 6 | # ======== 7 | # 8 | # Installs configuration packages specific to the Viewer DispVM, 9 | # used for opening submissions. 10 | # 11 | ## 12 | 13 | include: 14 | - securedrop_salt.fpf-apt-repo 15 | - securedrop_salt.sd-logging-setup 16 | 17 | sd-viewer-install-metapackage: 18 | pkg.installed: 19 | - pkgs: 20 | - securedrop-workstation-viewer 21 | - require: 22 | - sls: securedrop_salt.fpf-apt-repo 23 | -------------------------------------------------------------------------------- /bootstrap/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG FEDORA_VERSION=37 2 | FROM quay.io/fedora/fedora:${FEDORA_VERSION} 3 | 4 | ARG USER_NAME 5 | ENV USER_NAME ${USER_NAME:-root} 6 | ARG USER_ID 7 | ENV USER_ID ${USER_ID:-0} 8 | 9 | RUN dnf install -y make 10 | 11 | COPY Makefile Makefile 12 | COPY rpm-build/SPECS rpm-build/SPECS 13 | 14 | ARG DEPS=build-deps 15 | RUN make ${DEPS} 16 | 17 | # Cleanup 18 | RUN rm -rf rpm-build 19 | 20 | RUN if test $USER_NAME != root ; then useradd --no-create-home --home-dir /tmp --uid $USER_ID $USER_NAME && echo "$USER_NAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers ; fi 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve SecureDrop Workstation 4 | 5 | --- 6 | 7 | * [ ] I have searched for duplicates or related issues 8 | 9 | ## Description 10 | 11 | A short summary of the issue. 12 | 13 | ## Steps to Reproduce 14 | 15 | Please specify your environment if that is necessary to reproduce the bug (if in doubt, include it). 16 | 17 | ## Expected Behavior 18 | 19 | 20 | ## Actual Behavior 21 | 22 | Please provide screenshots where appropriate. 23 | 24 | ## Comments 25 | 26 | Suggestions to fix, any other relevant information. 27 | -------------------------------------------------------------------------------- /scripts/common.sh: -------------------------------------------------------------------------------- 1 | TOPLEVEL=$(git rev-parse --show-toplevel) 2 | export TOPLEVEL 3 | PROJECT="securedrop-workstation-dom0-config" 4 | export PROJECT 5 | export FEDORA_VERSION="${FEDORA_VERSION:-37}" 6 | 7 | OCI_RUN_ARGUMENTS="${OCI_RUN_ARGUMENTS:-}" 8 | export OCI_RUN_ARGUMENTS 9 | 10 | # Default to podman if available 11 | if which podman > /dev/null 2>&1; then 12 | OCI_BIN="podman" 13 | # Make sure host UID/GID are mapped into container, 14 | # see podman-run(1) manual. 15 | OCI_RUN_ARGUMENTS="${OCI_RUN_ARGUMENTS} --userns=keep-id" 16 | else 17 | OCI_BIN="docker" 18 | fi 19 | 20 | export OCI_BIN 21 | -------------------------------------------------------------------------------- /securedrop_salt/sd-app-files.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # sd-app-files 6 | # ======== 7 | # 8 | # Moves files into place on sd-small-$sdvars.distribution-template 9 | # 10 | ## 11 | include: 12 | - securedrop_salt.fpf-apt-repo 13 | - securedrop_salt.sd-logging-setup 14 | 15 | # FPF repo is setup in "securedrop-workstation-$sdvars.distribution" template, 16 | # and then cloned as "sd-small-$sdvars.distribution-template" 17 | install-securedrop-client-package: 18 | pkg.installed: 19 | - pkgs: 20 | - securedrop-client 21 | - require: 22 | - sls: securedrop_salt.fpf-apt-repo 23 | -------------------------------------------------------------------------------- /tests/test_dom0_salt_config.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import unittest 3 | 4 | 5 | class SD_Dom0_Salt_Config_Tests(unittest.TestCase): 6 | def setUp(self): 7 | # Enable full diff output in test report, to aid in debugging 8 | self.maxDiff = None 9 | 10 | def test_is_topfile_enabled(self): 11 | cmd = ["sudo", "qubesctl", "top.enabled"] 12 | wanted = "securedrop_salt.sd-workstation.top" 13 | 14 | try: 15 | all_topfiles = subprocess.check_output(cmd).decode("utf-8") 16 | assert wanted in all_topfiles 17 | 18 | except subprocess.CalledProcessError: 19 | self.fail("Error checking topfiles") 20 | -------------------------------------------------------------------------------- /sdw_notify/strings.py: -------------------------------------------------------------------------------- 1 | headline_notify_updates = "Security check recommended" 2 | 3 | description_notify_updates = ( 4 | "

The computer has not checked for security updates recently.

" 5 | "

Would you like to check for updates now?

" 6 | ) 7 | 8 | description_notify_updates_sdapp_running = ( 9 | "

The computer has not checked for security updates recently.

" 10 | "

Warning: Checking for updates " 11 | "will interrupt your session and restart the application.

" 12 | "

Would you like to check for updates now?

" 13 | ) 14 | 15 | button_check_for_updates = "Check for updates" 16 | 17 | button_defer_check = "Remind me later" 18 | -------------------------------------------------------------------------------- /files/sdw-login.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Utility script for SecureDrop Workstation. Launches the SecureDrop Workstation 4 | updater on boot. It will prompt users to apply template and dom0 updates 5 | """ 6 | 7 | import logging 8 | import os 9 | import subprocess 10 | import time 11 | 12 | SCRIPT_NAME = os.path.basename(__file__) 13 | logger = logging.getLogger(SCRIPT_NAME) 14 | logging.basicConfig(level=logging.INFO) 15 | 16 | 17 | if __name__ == "__main__": 18 | # Wait for the dom0 GUI widgets to load 19 | # If we don't wait, a "Houston, we have a problem..." message is displayed 20 | # to the user. 21 | time.sleep(5) 22 | 23 | subprocess.check_call(["sdw-updater"]) 24 | -------------------------------------------------------------------------------- /tests/test_vm_sys_usb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sys-usb" VM and related functionality. 4 | """ 5 | 6 | import pytest 7 | 8 | from tests.base import ( 9 | QubeWrapper, 10 | ) 11 | from tests.base import ( 12 | Test_SD_VM_Common as Test_SD_SysUSB_Common, # noqa: F401 [HACK: import so base tests run] 13 | ) 14 | 15 | 16 | @pytest.fixture(scope="module") 17 | def qube(): 18 | return QubeWrapper("sys-usb", linux_security_modules="selinux") 19 | 20 | 21 | def test_files_are_properly_copied(qube): 22 | assert qube.fileExists("/etc/udev/rules.d/99-sd-devices.rules") 23 | assert qube.fileExists("/usr/local/bin/sd-attach-export-device") 24 | -------------------------------------------------------------------------------- /securedrop_salt/sd-reset-whonix-prefs.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # NOTE: this file may be removed after Qubes 4.2 is EOL (Whonix 17 assumed non-existent) 5 | 6 | # Disable apparmor on workstation and gateway templates. Originally Whonix 7 | # shipped without kernel parameters. This reverts a previous workstation change 8 | # that added AppArmor 9 | 10 | {% for (vm, component) in [('sys-whonix', 'gateway'), ('anon-whonix', 'workstation')] %} 11 | whonix-{{ component }}-17-remove-apparmor: 12 | qvm.vm: 13 | - name: whonix-{{ component }}-17 14 | - prefs: 15 | - kernelopts: "*default*" 16 | - onlyif: 17 | - qvm-check --quiet {{ vm }} 18 | {% endfor %} 19 | -------------------------------------------------------------------------------- /securedrop_salt/sd-logging-setup.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # TODO: parametrise this 5 | {% if grains['id'] in ["sd-small-bookworm-template", "sd-large-bookworm-template"] %} 6 | include: 7 | - securedrop_salt.fpf-apt-repo 8 | 9 | # Install securedrop-log package in TemplateVMs only 10 | install-securedrop-log-package: 11 | pkg.installed: 12 | - pkgs: 13 | - securedrop-log 14 | - require: 15 | - sls: securedrop_salt.fpf-apt-repo 16 | 17 | {% endif %} 18 | 19 | {% if grains['id'] == "sd-small-{}-template".format(grains['oscodename']) %} 20 | install-redis-for-sd-log-template: 21 | pkg.installed: 22 | - pkgs: 23 | - redis-server 24 | - redis 25 | 26 | {% endif %} 27 | -------------------------------------------------------------------------------- /SOURCE_OFFER: -------------------------------------------------------------------------------- 1 | Freedom of the Press Foundation (FPF) maintains custom-compiled Linux 2 | kernel images for use in SecureDrop and SecureDrop Workstation. 3 | The kernel source has been patched with the grsecurity modifications, 4 | and only binaries are available in the FPF apt repository. 5 | 6 | Under the terms of the GPL, any user in possession of this offer has a right 7 | to request the source code used to compile those binaries. 8 | You may submit a written request to: 9 | 10 | Freedom of the Press Foundation 11 | 49 Flatbush Ave, #1017 12 | Brooklyn, NY 11217 13 | 14 | Or via email to: 15 | 16 | source-offer@freedom.press 17 | 18 | A full copy of this message can be found online at 19 | https://github.com/freedomofpress/securedrop-workstation/blob/main/SOURCE_OFFER 20 | -------------------------------------------------------------------------------- /securedrop_salt/sd-base-template.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # Imports "sdvars" for environment config 5 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 6 | 7 | include: 8 | - securedrop_salt.sd-dom0-files 9 | 10 | # Clones a base templateVM from debian-12-minimal 11 | sd-base-template: 12 | qvm.vm: 13 | - name: sd-base-{{ sdvars.distribution }}-template 14 | - clone: 15 | - source: debian-12-minimal 16 | - label: red 17 | - prefs: 18 | - default_dispvm: "" 19 | - tags: 20 | - add: 21 | - sd-workstation 22 | - sd-{{ sdvars.distribution }} 23 | - features: 24 | - enable: 25 | - service.paxctld 26 | - require: 27 | - qvm: dom0-install-debian-minimal-template 28 | -------------------------------------------------------------------------------- /securedrop_salt/sd-remove-whonix-packages.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # Remove workstation components in whonix-gateway-17 6 | # 7 | # NOTE: this file should be safe to remove after a few releases. Even if some 8 | # Workstations miss some updates (due to not being in use for a while), the 9 | # mere presence of the repo/package, does not affect regular system behavior. 10 | ## 11 | 12 | # NOTE: 'cmd.run' necessary since 'pkg.del_repo' or 'pkg.purge' fail due to: 13 | # "The pkg module could not be loaded: unsupported OS family" 14 | sd-cleanup-whonix-gateway: 15 | cmd.run: 16 | - names: 17 | - "sudo apt-get purge --yes securedrop-keyring securedrop-qubesdb-tools securedrop-whonix-config ||:" 18 | - "sudo rm -f /etc/apt/sources.list.d/apt_freedom_press.sources ||:" 19 | -------------------------------------------------------------------------------- /securedrop_salt/sd-remove-unused-qubes.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | 5 | # WARNING: only remove when complete reinstall is assumed (e.g. 1.0.0 release) 6 | # This is because the workstation may have been offline for a while 7 | # and skipped some salt updates. 8 | {% for qube_name in ["sd-retain-logvm", "sd-whonix"] %} 9 | 10 | poweroff-before-removal-{{ qube_name }}: 11 | qvm.shutdown: 12 | - name: {{ qube_name }} 13 | - flags: 14 | - force 15 | - wait 16 | - onlyif: 17 | - qvm-check --quiet {{ qube_name }} 18 | - order: last 19 | 20 | remove-{{ qube_name }}: 21 | qvm.absent: 22 | - name: {{ qube_name }} 23 | - require: 24 | - qvm: poweroff-before-removal-{{ qube_name }} 25 | - onlyif: 26 | - qvm-check --quiet {{ qube_name }} 27 | - order: last 28 | 29 | {% endfor %} 30 | -------------------------------------------------------------------------------- /files/clean-salt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | # Utility script to clean Saltstack config 3 | # files for the SecureDrop Workstation. 4 | set -e 5 | set -u 6 | set -o pipefail 7 | 8 | 9 | # Hardcoded location of SecureDrop Workstation salt config files 10 | SDW_SALT_DIR="/srv/salt/securedrop_salt" 11 | SALT_DIR="/srv/salt" 12 | 13 | echo "Purging Salt config..." 14 | 15 | # If SDW Salt config dir already exists, delete all SecureDrop Workstation 16 | # related Salt files. In production scenarios, most of these will be provisioned 17 | # by the RPM package, but the top files and configs will not, so we should use a 18 | # common script to ensure all config is removed. 19 | 20 | if [[ ! -d "$SDW_SALT_DIR" ]]; then 21 | sudo rm -rf ${SDW_SALT_DIR} 22 | 23 | # Can be removed in future 24 | sudo rm -rf ${SALT_DIR}/launcher 25 | 26 | sudo find ${SALT_DIR}/_tops -lname '/srv/salt/securedrop_salt*' -delete 27 | 28 | fi 29 | -------------------------------------------------------------------------------- /securedrop_salt/sd-upgrade-templates.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # Prepare for a template migration by shutting down or removing AppVMs using 5 | # older template versions. In the absence of older templates, this should be a 6 | # noop. 7 | # TODO: a script that checked for pre-consolidation templates and dropped a 8 | # migration flag for sdw-admin --apply was run as a prerequisite here. This was 9 | # intended to account for situations where a migration was required but a flag 10 | # would not be present in the latest dom-config RPM (IE the system had the old 11 | # templates but had skipped the RPM update where the new templates were introduced.) 12 | # A simpler method of detecting when base templates change is required. 13 | 14 | run-prep-upgrade-scripts: 15 | cmd.script: 16 | - name: salt://securedrop_salt/securedrop-handle-upgrade 17 | - args: prepare 18 | -------------------------------------------------------------------------------- /securedrop_salt/sd-remove-unused-templates.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | 5 | # Make sure the "prepare" step has run first, otherwise there's 6 | # a race between migration and removal. 7 | include: 8 | - securedrop_salt.sd-upgrade-templates 9 | - securedrop_salt.sd-log 10 | - securedrop_salt.sd-devices 11 | - securedrop_salt.sd-gpg 12 | - securedrop_salt.sd-proxy 13 | - securedrop_salt.sd-viewer 14 | - securedrop_salt.sd-app 15 | 16 | run-remove-upgrade-scripts: 17 | cmd.script: 18 | - name: salt://securedrop_salt/securedrop-handle-upgrade 19 | - args: remove 20 | - require: 21 | - sls: securedrop_salt.sd-upgrade-templates 22 | - sls: securedrop_salt.sd-log 23 | - sls: securedrop_salt.sd-devices 24 | - sls: securedrop_salt.sd-gpg 25 | - sls: securedrop_salt.sd-proxy 26 | - sls: securedrop_salt.sd-viewer 27 | - sls: securedrop_salt.sd-app 28 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from qubesadmin import Qubes 6 | 7 | from tests.base import SD_TAG 8 | 9 | PROJ_ROOT = Path(__file__).parent.parent 10 | 11 | 12 | @pytest.fixture 13 | def dom0_config(): 14 | """Make the dom0 "config.json" available to tests.""" 15 | with open(PROJ_ROOT / "config.json") as config_file: 16 | return json.load(config_file) 17 | 18 | 19 | @pytest.fixture 20 | def all_vms(): 21 | """Obtain all qubes present in the system""" 22 | return Qubes().domains 23 | 24 | 25 | @pytest.fixture 26 | def sdw_tagged_vms(all_vms): 27 | """Obtain all SecureDrop Workstation-exclusive qubes""" 28 | return [vm for vm in all_vms if SD_TAG in vm.tags] 29 | 30 | 31 | @pytest.fixture 32 | def config(): 33 | with open("config.json") as c: 34 | config = json.load(c) 35 | if "environment" not in config: 36 | config["environment"] = "dev" 37 | return config 38 | -------------------------------------------------------------------------------- /securedrop_salt/remove-tags.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Removes tags used for exempting VMs from default SecureDrop Workstation 4 | RPC policies from all VMs (including non-SecureDrop ones). 5 | """ 6 | 7 | import sys 8 | 9 | import qubesadmin 10 | 11 | q = qubesadmin.Qubes() 12 | 13 | TAGS_TO_REMOVE = ["sd-send-app-clipboard", "sd-receive-app-clipboard", "sd-receive-logs"] 14 | 15 | 16 | def main(): 17 | tags_removed = False 18 | for vm in q.domains: 19 | for tag in TAGS_TO_REMOVE: 20 | if tag in q.domains[vm].tags: 21 | print(f"Removing tag '{tag}' from VM '{vm}'.") 22 | try: 23 | q.domains[vm].tags.remove(tag) 24 | except Exception as error: 25 | print(f"Error removing tag: '{error}'") 26 | print("Aborting.") 27 | sys.exit(1) 28 | tags_removed = True 29 | 30 | if tags_removed is False: 31 | print(f"Tags {TAGS_TO_REMOVE} not set on any VMs, nothing removed.") 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | with open("VERSION", encoding="utf-8") as f: 7 | version = f.read().strip() 8 | 9 | setuptools.setup( 10 | name="securedrop-workstation-dom0-config", 11 | author="SecureDrop Team", 12 | version=version, 13 | author_email="securedrop@freedom.press", 14 | description="SecureDrop Workstation", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | license="AGPLv3", 18 | python_requires=">=3.5", 19 | url="https://github.com/freedomofpress/securdrop-workstation", 20 | packages=setuptools.find_packages(exclude=["tests", "dom0"]), 21 | classifiers=[ 22 | "Development Status :: 3 - Alpha", 23 | "Programming Language :: Python :: 3", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | "License :: OSI Approved :: " "GNU Affero General Public License v3 (AGPLv3)", 26 | "Intended Audience :: Developers", 27 | "Operating System :: OS Independent", 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for SecureDrop Workstation 4 | 5 | --- 6 | 7 | * [ ] I have searched for duplicates or related issues 8 | 9 | ## Description 10 | 11 | A short summary of the idea. 12 | 13 | ## How will this impact [SecureDrop/SecureDrop Workstation users](https://github.com/freedomofpress/securedrop-ux/wiki/Users)? 14 | 15 | 16 | ## How would this affect the SecureDrop Workstation [threat model](https://github.com/freedomofpress/securedrop-workstation/#threat-model)? 17 | 18 | 19 | ## User Stories 20 | 21 | -------------------------------------------------------------------------------- /securedrop_salt/sd-base-template-packages.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | include: 4 | - securedrop_salt.fpf-apt-repo 5 | 6 | # install recommended Qubes VM packages for core functionality. 7 | # Note: additional system packages are installed as dependencies 8 | # of securedrop-workstation-config. 9 | # See https://github.com/freedomofpress/securedrop-client/blob/main/debian/control 10 | install-qubes-vm-recommended: 11 | pkg.installed: 12 | - pkgs: 13 | - qubes-vm-recommended 14 | 15 | # install workstation-config and grsec kernel 16 | sd-base-template-install-securedrop-packages: 17 | pkg.installed: 18 | - pkgs: 19 | - securedrop-workstation-config 20 | - securedrop-workstation-grsec 21 | - require: 22 | - sls: securedrop_salt.fpf-apt-repo 23 | 24 | # Ensure that paxctld starts immediately. For AppVMs, 25 | # use qvm.features.enabled = ["paxctld"] to ensure service start. 26 | sd-workstation-template-enable-paxctld: 27 | service.running: 28 | - name: paxctld 29 | - enable: True 30 | - reload: True 31 | - require: 32 | - pkg: sd-base-template-install-securedrop-packages -------------------------------------------------------------------------------- /scripts/build-rpm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | # Helper script for fully reproducible RPMs 3 | set -e 4 | set -u 5 | set -o pipefail 6 | 7 | source "$(dirname "$0")/common.sh" 8 | 9 | # Prepare tarball, rpmbuild will use it 10 | mkdir -p dist/ 11 | git clean -fdX rpm-build/ dist/ 12 | # touch everything to a date in the future, so that way 13 | # rpm will clamp the mtimes down to the SOURCE_DATE_EPOCH 14 | find . -type f -exec touch -m -d "+1 day" {} \; 15 | 16 | # set a trap to reset the file mtimes to present time, otherwise 17 | # the `make clone` operation will spew tar errors about timestamps from the future. 18 | trap 'find . -type f -exec touch -m {} \;' EXIT 19 | 20 | /usr/bin/python3 setup.py sdist 21 | 22 | # Place tarball where rpmbuild will find it 23 | cp dist/*.tar.gz rpm-build/SOURCES/ 24 | 25 | rpmbuild \ 26 | --define "_topdir $PWD/rpm-build" \ 27 | -bb --clean "rpm-build/SPECS/${PROJECT}.spec" 28 | 29 | # Check reproducibility 30 | python3 scripts/verify_rpm_mtime.py 31 | 32 | printf '\nBuild complete! RPMs and their checksums are:\n\n' 33 | find rpm-build/ -type f -iname "${PROJECT}-$(cat "${TOPLEVEL}/VERSION")*.rpm" -print0 | sort -zV | xargs -0 sha256sum 34 | -------------------------------------------------------------------------------- /tests/test_qubes_vms.py: -------------------------------------------------------------------------------- 1 | from tests.base import CURRENT_FEDORA_DVM, CURRENT_FEDORA_TEMPLATE 2 | 3 | """ 4 | Ensures that the upstream, Qubes-maintained VMs are 5 | sufficiently up to date. 6 | """ 7 | 8 | 9 | def test_current_fedora_for_sys_vms(all_vms): 10 | """ 11 | Checks that all sys-* VMs are configured to use 12 | an up-to-date version of Fedora. 13 | """ 14 | sys_vms = ["sys-firewall", "sys-net", "sys-usb", "default-mgmt-dvm"] 15 | sys_vms_maybe_disp = ["sys-firewall", "sys-usb"] 16 | sys_vms_custom_disp = ["sys-usb"] 17 | 18 | for sys_vm in sys_vms: 19 | vm = all_vms[sys_vm] 20 | wanted_templates = [CURRENT_FEDORA_TEMPLATE] 21 | if sys_vm in sys_vms_maybe_disp: 22 | if sys_vm in sys_vms_custom_disp: 23 | wanted_templates.append(f"sd-{CURRENT_FEDORA_DVM}") 24 | else: 25 | wanted_templates.append(CURRENT_FEDORA_DVM) 26 | 27 | assert vm.template.name in wanted_templates, ( 28 | f"Unexpected template for {sys_vm}\n" 29 | + f"Current: {vm.template.name}\n" 30 | + "Expected: {}".format(", ".join(wanted_templates)) 31 | ) 32 | -------------------------------------------------------------------------------- /update_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import datetime 5 | from pathlib import Path 6 | 7 | spec = Path("rpm-build/SPECS/securedrop-workstation-dom0-config.spec") 8 | author = "SecureDrop Team " 9 | message = "See changelog.md" 10 | 11 | parser = argparse.ArgumentParser() 12 | parser.add_argument("version", help="new version") 13 | args = parser.parse_args() 14 | 15 | # We want the Python and RPM versions to match, so we'll use a PEP 440 16 | # compatible version, e.g. 0.9.0rc1 or 0.9.0. 17 | new_version = args.version.replace("-", "").replace("~", "") 18 | 19 | # Update the version in the spec file and VERSION. 20 | Path("VERSION").write_text(new_version + "\n") 21 | spec_lines = spec.read_text().splitlines() 22 | for i, line in enumerate(spec_lines): 23 | if line.startswith("Version:"): 24 | spec_lines[i] = f"Version:\t{new_version}" 25 | elif line.startswith("%changelog"): 26 | current_date = datetime.datetime.now().strftime("%a %b %d %Y") 27 | changelog_entry = f"* {current_date} {author} - {new_version}\n- {message}\n" 28 | spec_lines.insert(i + 1, changelog_entry) 29 | 30 | spec.write_text("\n".join(spec_lines) + "\n") 31 | 32 | print(f"Updated version to {new_version}") 33 | -------------------------------------------------------------------------------- /scripts/shellcheck.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | set -e 4 | 5 | source "$(dirname "$0")/common.sh" 6 | 7 | function run_native_or_in_container () { 8 | EXCLUDE_RULES="SC1090,SC1091" 9 | if [ "$(command -v shellcheck)" ]; then 10 | shellcheck -x --exclude="$EXCLUDE_RULES" "$@" 11 | else 12 | $OCI_BIN run --rm -v "$(pwd):/sd:Z" -w /sd \ 13 | -t docker.io/koalaman/shellcheck:stable \ 14 | -x --exclude=$EXCLUDE_RULES "$@" 15 | fi 16 | } 17 | 18 | # Omitting: 19 | # - the `.git/` directory since its hooks won't pass # validation, and 20 | # we don't maintain those scripts. 21 | # - Python, JavaScript, YAML, HTML, SASS, PNG files because they're not shell scripts. 22 | # - Cache directories of mypy, or Tox. 23 | readarray -t FILES <<<"$(find "." \ 24 | \( \ 25 | -path '*.mo' \ 26 | -o -path '*.png' \ 27 | -o -path '*.po' \ 28 | -o -path '*.py' \ 29 | -o -path '*.yml' \ 30 | -o -path '*/.mypy_cache/*' \ 31 | -o -path '*/.tox/*' \ 32 | -o -path '*/.venv' \ 33 | -o -path './.git' \ 34 | \) -prune \ 35 | -o -type f \ 36 | -exec file --mime {} + \ 37 | | awk '$2 ~ /x-shellscript/ { print $1 }' \ 38 | | sed 's/://')" 39 | 40 | run_native_or_in_container "${FILES[@]}" 41 | -------------------------------------------------------------------------------- /securedrop_salt/sd-default-config.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | # 4 | ## 5 | # Handles loading of config variables, via environment-specific 6 | # setting in the config file. 7 | # These settings apply only to apt repo components used in 8 | # individual VMs. dom0 rpm settings are managed by the 9 | # securedrop-workstation-keyring package. 10 | 11 | # Load YAML vars file 12 | {% load_yaml as sdvars_defaults %} 13 | {% include "securedrop_salt/sd-default-config.yml" %} 14 | {% endload %} 15 | 16 | # Load JSON config file 17 | {% import_json "securedrop_salt/config.json" as d %} 18 | 19 | # Respect "dev" env if provided, default to "prod" 20 | {% if d.environment == "dev" %} 21 | # use apt-test and nightlies 22 | {% set sdvars = sdvars_defaults["test"] %} 23 | {% set _ = sdvars.update({"component": "main nightlies"}) %} 24 | {% elif d.environment == "staging" %} 25 | # use apt-test and main (RC/test builds) 26 | {% set sdvars = sdvars_defaults["test"] %} 27 | {% set _ = sdvars.update({"component": "main"}) %} 28 | {% else %} 29 | {% set sdvars = sdvars_defaults["prod"] %} 30 | {% set _ = sdvars.update({"component": "main"}) %} 31 | {% endif %} 32 | 33 | # Append repo URL with appropriate distribution 34 | {% set _ = sdvars.update({"distribution": "bookworm"}) %} 35 | -------------------------------------------------------------------------------- /securedrop_salt/sd-gpg-files.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # sd-gpg-files 6 | # ======== 7 | # 8 | # Does hots config for sd-gpg split gpg AppVM 9 | # 10 | ## 11 | 12 | sd-gpg-increase-keyring-access-timeout: 13 | file.blockreplace: 14 | - name: /home/user/.profile 15 | - append_if_not_found: True 16 | - marker_start: "### BEGIN securedrop-workstation ###" 17 | - marker_end: "### END securedrop-workstation ###" 18 | - content: | 19 | # hides GPG prompt (max epoch) 20 | export QUBES_GPG_AUTOACCEPT=2147483647 21 | 22 | sd-gpg-create-keyring-directory: 23 | file.directory: 24 | - name: /home/user/.gnupg 25 | - user: user 26 | - group: user 27 | - mode: 700 28 | 29 | sd-gpg-import-submission-key: 30 | file.managed: 31 | - name: /home/user/.gnupg/sd-journalist.sec 32 | - source: salt://securedrop_salt/sd-journalist.sec 33 | - user: user 34 | - group: user 35 | - mode: 600 36 | # Don't print private key to stdout 37 | - show_changes: False 38 | - require: 39 | - file: sd-gpg-create-keyring-directory 40 | cmd.run: 41 | - name: sudo -u user gpg --import /home/user/.gnupg/sd-journalist.sec 42 | - require: 43 | - file: sd-gpg-import-submission-key 44 | - onchanges: 45 | - file: sd-gpg-import-submission-key 46 | -------------------------------------------------------------------------------- /securedrop_salt/sd-gpg.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # sd-gpg 6 | # ======== 7 | # 8 | # Installs 'sd-gpg' AppVM, to implement split GPG for SecureDrop 9 | # This VM has no network configured. 10 | ## 11 | 12 | # Imports "sdvars" for environment config 13 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 14 | 15 | # Check environment 16 | {% import_json "securedrop_salt/config.json" as d %} 17 | 18 | include: 19 | - securedrop_salt.sd-workstation-template 20 | - securedrop_salt.sd-upgrade-templates 21 | 22 | sd-gpg: 23 | qvm.vm: 24 | - name: sd-gpg 25 | - present: 26 | # Sets attributes if creating VM for the first time, 27 | # otherwise `prefs` must be used. 28 | # Label color is set during initial configuration but 29 | # not enforced on every Salt run, in case of user customization. 30 | - label: purple 31 | - template: sd-small-{{ sdvars.distribution }}-template 32 | - prefs: 33 | - template: sd-small-{{ sdvars.distribution }}-template 34 | - netvm: "" 35 | - autostart: true 36 | - default_dispvm: "" 37 | - features: 38 | - enable: 39 | - service.securedrop-logging-disabled 40 | - set: 41 | - internal: "" 42 | - tags: 43 | - add: 44 | - sd-workstation 45 | - require: 46 | - sls: securedrop_salt.sd-workstation-template 47 | - sls: securedrop_salt.sd-upgrade-templates 48 | -------------------------------------------------------------------------------- /scripts/prep-dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | # Developer-oriented utility script for deploying Saltstack config 3 | # files for the SecureDrop Workstation dev env. Installs the latest 4 | # locally built RPM in order to deploy the Salt config. 5 | set -e 6 | set -u 7 | set -o pipefail 8 | 9 | 10 | # The dest directory in dom0 is not customizable. 11 | dom0_dev_dir="$HOME/securedrop-workstation" 12 | 13 | function find_latest_rpm() { 14 | # Look up which version of dom0 we're using. 15 | # Qubes 4.1 is fedora-32, Qubes 4.2 fedora-37. 16 | fedora_version="$(rpm --eval '%{fedora}')" 17 | find "${dom0_dev_dir}/rpm-build/RPMS/" -type f -iname "*fc${fedora_version}.noarch.rpm" -print0 | xargs -r -0 ls -t | head -n 1 18 | } 19 | 20 | function find_any_rpm() { 21 | # Fallback for CI, which renames rpms. Try installing 22 | # any rpm present in the RPMS directory. 23 | find "${dom0_dev_dir}/rpm-build/RPMS/" -type f -iname "*.rpm" -print0 | xargs -r -0 ls -t | head -n 1 24 | } 25 | 26 | 27 | latest_rpm="$(find_latest_rpm)" 28 | if [[ -z "$latest_rpm" ]]; then 29 | echo "No exact match, try any rpm in build directory" 30 | fi 31 | 32 | latest_rpm="$(find_any_rpm)" 33 | if [[ -z "$latest_rpm" ]]; then 34 | echo "Could not find RPM!" 35 | exit 1 36 | fi 37 | 38 | echo "Deploying Salt config..." 39 | echo "Uninstalling any previous RPM versions..." 40 | sudo dnf clean all 41 | sudo dnf remove -y securedrop-workstation-dom0-config || true 42 | echo "Installing RPM at $latest_rpm ..." 43 | sudo dnf install -y "$latest_rpm" 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Technical proposal 3 | about: propose a major technical or architectural change 4 | 5 | --- 6 | 7 | 8 | 9 | ## Proposal: 10 | 11 | ### Affected components 12 | 18 | 19 | ### People and roles 20 | 21 | 22 | ### Problem Statement 23 | 24 | 25 | ### Solution impact 26 | 27 | 28 | ### Requirements and constraints 29 | 30 | 31 | ### Exploration 32 | 33 | 34 | ### Initial proposal 35 | 36 | 37 | ### Selected proposal 38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/vars/sd-viewer.mimeapps: -------------------------------------------------------------------------------- 1 | [Default Applications] 2 | text/plain=org.gnome.gedit.desktop 3 | text/csv=libreoffice-base.desktop 4 | application/vnd.oasis.opendocument.text=libreoffice-base.desktop 5 | application/vnd.oasis.opendocument.spreadsheet=libreoffice-base.desktop 6 | application/vnd.oasis.opendocument.presentation=libreoffice-base.desktop 7 | application/msword=libreoffice-base.desktop 8 | application/vnd.ms-excel=libreoffice-base.desktop 9 | application/vnd.ms-powerpoint=libreoffice-base.desktop 10 | application/vnd.openxmlformats-officedocument.wordprocessingml.document=libreoffice-base.desktop 11 | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet=libreoffice-base.desktop 12 | application/vnd.openxmlformats-officedocument.presentationml.presentation=libreoffice-base.desktop 13 | application/pdf=org.gnome.Evince.desktop 14 | application/x-desktop=org.gnome.gedit.desktop 15 | audio/mp4=audacious.desktop 16 | audio/mpeg=audacious.desktop 17 | audio/x-vorbis+ogg=audacious.desktop 18 | audio/x-wav=audacious.desktop 19 | video/quicktime=org.gnome.Totem.desktop 20 | video/x-theora+ogg=org.gnome.Totem.desktop 21 | video/mp4=org.gnome.Totem.desktop 22 | video/x-msvideo=org.gnome.Totem.desktop 23 | video/x-ms-wmv=org.gnome.Totem.desktop 24 | image/jpeg=org.gnome.eog.desktop 25 | image/gif=org.gnome.eog.desktop 26 | image/tiff=org.gnome.Evince.desktop 27 | image/png=org.gnome.eog.desktop 28 | image/svg+xml=org.gnome.eog.desktop 29 | image/vnd.djvu=org.gnome.Evince.desktop 30 | application/vnd.rar=org.gnome.FileRoller.desktop 31 | application/zip=org.gnome.FileRoller.desktop 32 | application/x-7z-compressed=org.gnome.FileRoller.desktop 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/release.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | about: Create a release planning issue for SecureDrop Workstation 4 | title: "Release SecureDrop Workstation [VERSION]" 5 | labels: ["release"] 6 | projects: ["projects/17"] 7 | --- 8 | 9 | ## Description 10 | Release [SecureDrop Workstation [VERSION]](https://github.com/freedomofpress/securedrop-workstation/milestone/[MILESTONE_NUMBER]) 11 | 12 | Refer to the [SecureDrop Workstation RM docs](https://developers.securedrop.org/en/latest/workstation_release_management.html) for detailed instructions. 13 | 14 | **Release Roles:** 15 | - RM: 16 | - Deputy RM: 17 | - Comms: 18 | 19 | ### Pre-release tasks 20 | - [ ] Merge remaining PRs: 21 | - [ ] #[PR_NUMBER] 22 | - [ ] Update docs 23 | 24 | ### Release tasks 25 | - [ ] Assign roles 26 | - [ ] RC version + changelog bump 27 | - [ ] Publish packages on yum-test 28 | - [ ] QA against yum-test 29 | - [ ] Prepare release commms 30 | - [ ] Production version bump, changelog, and signed tag 31 | - [ ] Production QA (yum-qa) 32 | - [ ] Production release 33 | - [ ] Forward `securedrop-workstation-docs` stable tag to publish docs updates 34 | - [ ] Publish release commms 35 | 36 | ### Post-release tasks 37 | - [ ] Run the updater on a production setup and perform smoke tests 38 | - [ ] Backport changelog updates and bump to RC1 of next minor version in `main` 39 | 40 | ## Test Plan 41 | 42 | ### Common tests 43 | 44 | ### Scenario: Fresh install 45 | 46 | - `config.json` environment: 47 | - Other QA setup notes: 48 | 49 | ### Scenario: Upgrade testing 50 | 51 | - `config.json` environment: 52 | - Other QA setup notes: 53 | 54 | ### Additional testing (if applicable) 55 | -------------------------------------------------------------------------------- /securedrop_salt/sd-workstation-template.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # Imports "sdvars" for environment config 5 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 6 | 7 | include: 8 | - securedrop_salt.sd-base-template 9 | 10 | # Installs consolidated templateVMs: 11 | # Sets virt_mode and kernel to use custom hardened kernel. 12 | # - sd-small-{{ sdvars.distribution }}-template, to be used for 13 | # sd-app, sd-gpg, sd-log, and sd-proxy 14 | # - sd-large-{{ sdvars.distribution }}-template, to be used for 15 | # sd-export and sd-viewer 16 | sd-small-{{ sdvars.distribution }}-template: 17 | qvm.vm: 18 | - name: sd-small-{{ sdvars.distribution }}-template 19 | - clone: 20 | - source: sd-base-{{ sdvars.distribution }}-template 21 | - label: red 22 | - prefs: 23 | - virt-mode: pvh 24 | - kernel: 'pvgrub2-pvh' 25 | - default_dispvm: "" 26 | - tags: 27 | - add: 28 | - sd-workstation 29 | - sd-{{ sdvars.distribution }} 30 | - features: 31 | - enable: 32 | - service.paxctld 33 | - require: 34 | - sls: securedrop_salt.sd-base-template 35 | 36 | sd-large-{{ sdvars.distribution }}-template: 37 | qvm.vm: 38 | - name: sd-large-{{ sdvars.distribution }}-template 39 | - clone: 40 | - source: sd-base-{{ sdvars.distribution }}-template 41 | - label: red 42 | - prefs: 43 | - virt-mode: pvh 44 | - kernel: 'pvgrub2-pvh' 45 | - default_dispvm: "" 46 | - tags: 47 | - add: 48 | - sd-workstation 49 | - sd-{{ sdvars.distribution }} 50 | - features: 51 | - enable: 52 | - service.paxctld 53 | - require: 54 | - sls: securedrop_salt.sd-base-template 55 | -------------------------------------------------------------------------------- /securedrop_salt/sd-usb-autoattach-add.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # Installs udev configuration in a USB Qube for automatically attaching 6 | # USB devices to sd-devices. 7 | ## 8 | 9 | # If sys-usb is disposable, we have already set up sd-{supported-fedora-version}-dvm to make our 10 | # modifications in, so we only want to modify sys-usb if it is a regular AppVM 11 | 12 | {% set apply = True %} 13 | {% if grains['id'] == 'sys-usb' and salt['pillar.get']('qvm:sys-usb:disposable', true) %} 14 | {% set apply = False %} 15 | {% endif %} 16 | 17 | {% if apply %} 18 | sd-udev-rules: 19 | file.managed: 20 | - name: /rw/config/sd/etc/udev/rules.d/99-sd-devices.rules 21 | - source: salt://securedrop_salt/99-sd-devices.rules 22 | - user: root 23 | - group: root 24 | - mode: 0444 25 | - makedirs: True 26 | 27 | sd-rc-local-udev-rules: 28 | file.blockreplace: 29 | - name: /rw/config/rc.local 30 | - append_if_not_found: True 31 | - marker_start: "### BEGIN securedrop-workstation ###" 32 | - marker_end: "### END securedrop-workstation ###" 33 | - content: | 34 | # Add udev rules for export devices 35 | ln -sf /rw/config/sd/etc/udev/rules.d/99-sd-devices.rules /etc/udev/rules.d/ 36 | udevadm control --reload 37 | - require: 38 | - file: sd-udev-rules 39 | cmd.run: 40 | - name: ln -sf /rw/config/sd/etc/udev/rules.d/99-sd-devices.rules /etc/udev/rules.d/ && udevadm control --reload 41 | - require: 42 | - file: sd-rc-local-udev-rules 43 | 44 | sd-attach-export-device: 45 | file.managed: 46 | - name: /usr/local/bin/sd-attach-export-device 47 | - source: salt://securedrop_salt/sd-attach-export-device 48 | - user: root 49 | - group: root 50 | - mode: 0555 51 | {% endif %} 52 | -------------------------------------------------------------------------------- /securedrop_salt/sd-workstation.top: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | {% import "qvm/whonix.jinja" as whonix %} 5 | 6 | base: 7 | dom0: 8 | - securedrop_salt.sd-sys-vms 9 | - securedrop_salt.sd-dom0-files 10 | - securedrop_salt.sd-base-template 11 | - securedrop_salt.sd-workstation-template 12 | - securedrop_salt.sd-upgrade-templates 13 | - securedrop_salt.sd-log 14 | - securedrop_salt.sd-devices 15 | - securedrop_salt.sd-gpg 16 | - securedrop_salt.sd-proxy 17 | - securedrop_salt.sd-viewer 18 | - securedrop_salt.sd-app 19 | - securedrop_salt.sd-remove-unused-qubes 20 | - securedrop_salt.sd-remove-unused-templates 21 | 22 | {# Whonix > 17 does not need cleanup #} 23 | {% if whonix.whonix_version == '17' %} 24 | - securedrop_salt.sd-reset-whonix-prefs 25 | {% endif %} 26 | 27 | sd-base-bookworm-template: 28 | - securedrop_salt.sd-base-template-packages 29 | sd-small-bookworm-template: 30 | - securedrop_salt.sd-logging-setup 31 | - securedrop_salt.sd-app-files 32 | - securedrop_salt.sd-proxy-template-files 33 | sd-large-bookworm-template: 34 | - securedrop_salt.sd-logging-setup 35 | - securedrop_salt.sd-devices-files 36 | - securedrop_salt.sd-viewer-files 37 | sd-gpg: 38 | - securedrop_salt.sd-gpg-files 39 | 'sd-fedora-42-dvm,sys-usb': 40 | - match: list 41 | - securedrop_salt.sd-usb-autoattach-add 42 | 43 | {# Whonix > 17 does not need cleanup #} 44 | {% if whonix.whonix_version == '17' %} 45 | whonix-gateway-17: 46 | - securedrop_salt.sd-remove-whonix-packages 47 | {% endif %} 48 | 49 | # "Placeholder" config to trigger TemplateVM boots, 50 | # so upgrades can be applied automatically via cron. 51 | qubes:type:template: 52 | - match: pillar 53 | - topd 54 | -------------------------------------------------------------------------------- /scripts/clone-to-dom0: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | # Utility to copy workstation config files from target AppVM 3 | # to dom0, for use in testing the salt logic for VM configuration. 4 | # Should only be run in dom0! 5 | set -e 6 | set -u 7 | set -o pipefail 8 | 9 | # Ensure we're running in dom0, otherwise clone action could destroy 10 | # active work in AppVM. 11 | if [[ "$(hostname)" != "dom0" ]]; then 12 | echo 'Clone action must be run from dom0!' 13 | exit 1 14 | fi 15 | 16 | # Support environment variable overrides, but provide sane defaults. 17 | dev_vm="${SECUREDROP_DEV_VM:-sd-dev}" 18 | dev_dir="${SECUREDROP_DEV_DIR:-/home/user/securedrop-workstation}" 19 | 20 | # The dest directory in dom0 is not customizable. 21 | dom0_dev_dir="$HOME/securedrop-workstation" 22 | 23 | # Call out to target AppVM, to build an RPM containing 24 | # the latest Salt config for dom0. The RPM will be included 25 | # in the subsequent tarball, which is fetched to dom0. 26 | function build-dom0-rpm() { 27 | printf "Building RPM on %s ...\n" "${dev_vm}" 28 | qvm-run --pass-io "$dev_vm" "make -C $dev_dir build-rpm" 29 | } 30 | 31 | # Call out to target AppVM to create a tarball in dom0 32 | function create-tarball() { 33 | printf "Cloning code from %s:%s ...\n" "${dev_vm}" "${dev_dir}" 34 | qvm-run --pass-io "$dev_vm" \ 35 | "tar -c --exclude-vcs \ 36 | -C '$(dirname "$dev_dir")' \ 37 | '$(basename "$dev_dir")'" > /tmp/sd-proj.tar 38 | } 39 | 40 | function unpack-tarball() { 41 | rm -rf "${dom0_dev_dir:?}/"* 42 | tar xf /tmp/sd-proj.tar -C "${dom0_dev_dir}" --strip-components=1 43 | } 44 | 45 | # By default we build the RPM, but the step can be skipped, which is useful 46 | # for certain development tasks 47 | if [[ "${BUILD_RPM:-true}" == "true" ]]; then 48 | build-dom0-rpm 49 | fi 50 | 51 | create-tarball 52 | unpack-tarball 53 | -------------------------------------------------------------------------------- /scripts/verify_rpm_mtime.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import UTC, datetime, timedelta 3 | from pathlib import Path 4 | 5 | import rpm 6 | 7 | 8 | def check_rpm(filename: Path): 9 | """verify every file in the RPM has the same mtime as changelog/build date""" 10 | with filename.open("rb") as f: 11 | ts = rpm.TransactionSet() 12 | header = ts.hdrFromFdno(f.fileno()) 13 | build_date = datetime.fromtimestamp(header[rpm.RPMTAG_BUILDTIME], tz=UTC) 14 | filenames = header[rpm.RPMTAG_FILENAMES] 15 | filemtimes = header[rpm.RPMTAG_FILEMTIMES] 16 | changetimes = header[rpm.RPMTAG_CHANGELOGTIME] 17 | 18 | # I don't understand why, but the changelog time is consistently 12 hours off of the 19 | # build date (which is always 00:00:00 UTC) 20 | changelog_date = datetime.fromtimestamp(changetimes[0], tz=UTC) - timedelta(hours=12) 21 | 22 | result = [] 23 | 24 | if changelog_date != build_date: 25 | result.append(("Build Date", build_date)) 26 | for i, rpm_filename in enumerate(filenames): 27 | mtime = datetime.fromtimestamp(filemtimes[i], tz=UTC) 28 | if mtime != build_date: 29 | result.append((rpm_filename, mtime)) 30 | 31 | return changelog_date, result 32 | 33 | 34 | def main(): 35 | exit_code = 0 36 | for filename in Path("rpm-build/RPMS/noarch").glob("*.rpm"): 37 | changelog_date, result = check_rpm(filename) 38 | print(f"checking mtimes in {filename.name}: {changelog_date}") 39 | if result: 40 | print("The following files have an incorrect mtime:") 41 | for fname, mtime in result: 42 | print(f"* {fname}: {mtime}") 43 | exit_code = 1 44 | 45 | sys.exit(exit_code) 46 | 47 | 48 | if __name__ == "__main__": 49 | main() 50 | -------------------------------------------------------------------------------- /launcher/tests/test_sources.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test apt sources files 3 | 4 | Strictly speaking this doesn't have to do with the launcher, but 5 | it needs dependencies installed and to be run under pytest 6 | """ 7 | 8 | import socket 9 | from pathlib import Path 10 | 11 | if socket.gethostname() != "dom0": 12 | import pysequoia 13 | from debian import deb822 14 | 15 | from conftest import skip_in_dom0 16 | 17 | # A couple of tests are skipped in dom0 since: 18 | # - they rely on poetry-installed dependencies (not trival to get in dom0) 19 | # - This test may go away later once the .sources files in the keyring package 20 | 21 | 22 | SECUREDROP_SALT = Path(__file__).parent.parent.parent / "securedrop_salt" 23 | 24 | 25 | @skip_in_dom0 26 | def test_prod_sources(): 27 | """Verify the key in the aptsources file is our prod signing key""" 28 | path = SECUREDROP_SALT / "apt_freedom_press.sources.j2" 29 | 30 | sources = deb822.Sources(path.read_text()) 31 | key = pysequoia.Cert.from_bytes(sources["Signed-By"].encode()) 32 | assert key.fingerprint.upper() == "2359E6538C0613E652955E6C188EDD3B7B22E6A3" 33 | assert len(key.user_ids) == 1 34 | assert ( 35 | str(key.user_ids[0]) 36 | == "SecureDrop Release Signing Key " 37 | ) 38 | assert key.expiration.year == 2027 39 | 40 | 41 | @skip_in_dom0 42 | def test_test_sources(): 43 | """Verify the key in the apt-test sources file is our dev signing key""" 44 | path = SECUREDROP_SALT / "apt-test_freedom_press.sources.j2" 45 | 46 | sources = deb822.Sources(path.read_text()) 47 | key = pysequoia.Cert.from_bytes(sources["Signed-By"].encode()) 48 | assert key.fingerprint.upper() == "83127F68BABB04F3FE9A69AA545E94503FAB65AB" 49 | assert len(key.user_ids) == 1 50 | assert str(key.user_ids[0]) == "SecureDrop TESTING key " 51 | assert key.expiration is None 52 | -------------------------------------------------------------------------------- /launcher/README.md: -------------------------------------------------------------------------------- 1 | # securedrop-updater 2 | 3 | The Updater ensures that the [SecureDrop Workstation](https://github.com/freedomofpress/securedrop-workstation/) is up-to-date by checking for and applying any necessary VM updates, which may prompt a reboot. 4 | 5 | ## Layout 6 | 7 | On the one hand, the launcher uses a different development-time virtual 8 | environment and requirements than the rest of 9 | `securedrop-workstation-dom0-config`. On the other hand, we want the launcher 10 | to be included in both the intermediate Python package and the final RPM 11 | package for `securedrop-dom0-config`. 12 | 13 | This layout satisfies both conditions: 14 | 15 | ``` 16 | ├── dev-requirements.in # Launcher-specific requirements... 17 | ├── dev-requirements.txt # ... 18 | ├── Makefile # ...and Makefile for development and testing. 19 | ├── README.md 20 | ├── sdw_notify -> ../sdw_notify # Symlinks to directories one level up that are what actually 21 | ├── sdw_updater -> ../sdw_updater # get packaged by Python and RPM. 22 | ├── sdw_util -> ../sdw_util 23 | └── tests 24 | ``` 25 | 26 | The caveat is that you may need to prefix commands with 27 | `PYTHONPATH=..:$PYTHONPATH` to interact with these packages inside this 28 | virtual environment. 29 | 30 | ## Running the Updater 31 | 32 | Qubes 4.1.1 uses an end-of-life Fedora template in dom0 (fedora-32). See rationale here: https://www.qubes-os.org/doc/supported-releases/#note-on-dom0-and-eol. 33 | 34 | If you installed SecureDrop Updater on your Qubes machine's `dom0`, you can run the updater like this: 35 | 1. Open a `dom0` terminal 36 | 2. Run `sdw-updater --skip-delta 0` 37 | 38 | To run the notifier that pops up if `/proc/uptime` (how long the system has been on since its last restart) is greater than 30 seconds and `~/.securedrop_updater/sdw-last-updated` (how long it's been since the Updater last ran) is greater than 5 days: 39 | 1. Open a `dom0` terminal 40 | 2. Run `sdw-notify` 41 | -------------------------------------------------------------------------------- /securedrop_salt/sd-app.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # Installs 'sd-app' AppVM, to persistently store SD data 6 | # This VM has no network configured. 7 | ## 8 | 9 | # Imports "sdvars" for environment config 10 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 11 | 12 | # Check environment 13 | {% import_json "securedrop_salt/config.json" as d %} 14 | 15 | include: 16 | - securedrop_salt.sd-workstation-template 17 | - securedrop_salt.sd-viewer 18 | 19 | sd-app: 20 | qvm.vm: 21 | - name: sd-app 22 | - present: 23 | # Sets attributes if creating VM for the first time, 24 | # otherwise `prefs` must be used. 25 | # Label color is set during initial configuration but 26 | # not enforced on every Salt run, in case of user customization. 27 | - label: yellow 28 | - template: sd-small-{{ sdvars.distribution }}-template 29 | - prefs: 30 | - template: sd-small-{{ sdvars.distribution }}-template 31 | - netvm: "" 32 | - default_dispvm: "sd-viewer" 33 | - tags: 34 | - add: 35 | - sd-client 36 | - sd-workstation 37 | - features: 38 | - set: 39 | - vm-config.SD_MIME_HANDLING: sd-app 40 | - internal: "" 41 | - enable: 42 | - service.paxctld 43 | - service.securedrop-mime-handling 44 | - require: 45 | - qvm: sd-small-{{ sdvars.distribution }}-template 46 | - sls: securedrop_salt.sd-viewer 47 | 48 | sd-app-config: 49 | qvm.features: 50 | - name: sd-app 51 | - set: 52 | - vm-config.QUBES_GPG_DOMAIN: sd-gpg 53 | - vm-config.SD_SUBMISSION_KEY_FPR: {{ d.submission_key_fpr }} 54 | 55 | # The private volume size should be defined in the config.json 56 | sd-app-private-volume-size: 57 | cmd.run: 58 | - name: > 59 | qvm-volume resize sd-app:private {{ d.vmsizes.sd_app }}GiB 60 | - require: 61 | - qvm: sd-app 62 | -------------------------------------------------------------------------------- /files/sdw-updater.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import sys 4 | 5 | try: 6 | from PyQt6.QtWidgets import QApplication 7 | except ImportError: 8 | from PyQt5.QtWidgets import QApplication # type: ignore [no-redef] 9 | 10 | 11 | from sdw_updater import Updater 12 | from sdw_updater.Updater import should_launch_updater 13 | from sdw_updater.UpdaterApp import UpdaterApp, launch_securedrop_client 14 | from sdw_util import Util 15 | 16 | DEFAULT_INTERVAL = 28800 # 8hr default for update interval 17 | 18 | 19 | def parse_argv(argv): 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument("--skip-delta", type=int) 22 | parser.add_argument("--skip-netcheck", action="store_true") 23 | return parser.parse_args(argv) 24 | 25 | 26 | def launch_updater(should_skip_netcheck: bool = False): 27 | """ 28 | Start the updater GUI. 29 | """ 30 | 31 | app = QApplication(sys.argv) 32 | form = UpdaterApp(should_skip_netcheck) 33 | form.show() 34 | sys.exit(app.exec()) 35 | 36 | 37 | def main(argv): 38 | Util.configure_logging(Updater.LOG_FILE) 39 | Util.configure_logging(Updater.DETAIL_LOG_FILE, Updater.DETAIL_LOGGER_PREFIX, backup_count=10) 40 | sdlog = Util.get_logger() 41 | lock_handle = Util.obtain_lock(Updater.LOCK_FILE) 42 | if lock_handle is None: 43 | # Preflight updater already running or problems accessing lockfile. 44 | # Logged. 45 | sys.exit(1) 46 | sdlog.info("Starting SecureDrop Launcher") 47 | 48 | args = parse_argv(argv) 49 | 50 | try: 51 | args.skip_delta 52 | except NameError: 53 | args.skip_delta = DEFAULT_INTERVAL 54 | 55 | if args.skip_delta is None: 56 | args.skip_delta = DEFAULT_INTERVAL 57 | 58 | interval = int(args.skip_delta) 59 | 60 | if should_launch_updater(interval): 61 | launch_updater(args.skip_netcheck) 62 | else: 63 | launch_securedrop_client() 64 | 65 | 66 | if __name__ == "__main__": 67 | main(sys.argv[1:]) 68 | -------------------------------------------------------------------------------- /securedrop_salt/sd-devices.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # 5 | # Installs 'sd-devices' AppVM, to persistently store SD data 6 | # This VM has no network configured. 7 | ## 8 | 9 | # Imports "sdvars" for environment config 10 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 11 | 12 | include: 13 | - securedrop_salt.sd-workstation-template 14 | 15 | sd-devices-dvm: 16 | qvm.vm: 17 | - name: sd-devices-dvm 18 | - present: 19 | # Sets attributes if creating VM for the first time, 20 | # otherwise `prefs` must be used. 21 | # Label color is set during initial configuration but 22 | # not enforced on every Salt run, in case of user customization. 23 | - label: red 24 | - template: sd-large-{{ sdvars.distribution }}-template 25 | - prefs: 26 | - template: sd-large-{{ sdvars.distribution }}-template 27 | - netvm: "" 28 | - template_for_dispvms: True 29 | - default_dispvm: "" 30 | - tags: 31 | - add: 32 | - sd-workstation 33 | - sd-{{ sdvars.distribution }} 34 | - features: 35 | - enable: 36 | - service.paxctld 37 | - service.cups 38 | - require: 39 | - qvm: sd-large-{{ sdvars.distribution }}-template 40 | 41 | sd-devices-create-named-dispvm: 42 | qvm.vm: 43 | - name: sd-devices 44 | - present: 45 | # Sets attributes if creating VM for the first time, 46 | # otherwise `prefs` must be used. 47 | - label: red 48 | - template: sd-devices-dvm 49 | - class: DispVM 50 | - prefs: 51 | - template: sd-devices-dvm 52 | - default_dispvm: "" 53 | - netvm: "" 54 | - tags: 55 | - add: 56 | - sd-workstation 57 | - features: 58 | - enable: 59 | - service.securedrop-mime-handling 60 | - service.avahi 61 | - set: 62 | - vm-config.SD_MIME_HANDLING: sd-devices 63 | - menu-items: "org.gnome.Nautilus.desktop org.gnome.DiskUtility.desktop" 64 | - require: 65 | - qvm: sd-devices-dvm 66 | -------------------------------------------------------------------------------- /scripts/container.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | # shellcheck disable=SC2086 3 | # we ignore SC2086 because ${OCI_BUILD_ARGUMENTS:-} is intended to 4 | # be evaluated into multiple strings, not a single argument. 5 | 6 | set -eu 7 | 8 | source "$(dirname "$0")/common.sh" 9 | 10 | OCI_BUILD_ARGUMENTS="${OCI_BUILD_ARGUMENTS:-} --build-arg=FEDORA_VERSION=${FEDORA_VERSION}" 11 | 12 | # Whenever we're not on the platform we expect, explicitly tell the container 13 | # runtime what platform we need 14 | if [[ "$(uname -sm)" != "Linux x86_64" ]]; then 15 | OCI_RUN_ARGUMENTS="${OCI_RUN_ARGUMENTS} --platform linux/amd64" 16 | OCI_BUILD_ARGUMENTS="${OCI_BUILD_ARGUMENTS} --platform linux/amd64" 17 | fi 18 | 19 | # Pass -it if we're a tty 20 | if test -t 0; then 21 | OCI_RUN_ARGUMENTS="${OCI_RUN_ARGUMENTS} -it" 22 | fi 23 | 24 | # Use a smaller container with just build dependencies or 25 | # a larger container with test dependencies too. 26 | if [[ -z "${USE_BUILD_CONTAINER:-}" ]]; then 27 | DEPS="test-deps" 28 | SUFFIX="-dev" 29 | else 30 | DEPS="build-deps" 31 | SUFFIX="" 32 | fi 33 | 34 | 35 | function oci_image() { 36 | NAME="${1}${FEDORA_VERSION}${SUFFIX}" 37 | 38 | $OCI_BIN build \ 39 | ${OCI_BUILD_ARGUMENTS} \ 40 | --build-arg=USER_ID="$(id -u)" \ 41 | --build-arg=USER_NAME="${USER:-root}" \ 42 | --build-arg=DEPS="${DEPS}" \ 43 | -t "${NAME}" \ 44 | --file "${TOPLEVEL}/bootstrap/Dockerfile" \ 45 | "${TOPLEVEL}" 46 | } 47 | 48 | function oci_run() { 49 | find . \( -name '*.pyc' -o -name __pycache__ \) -delete 50 | 51 | NAME="${1}${FEDORA_VERSION}${SUFFIX}" 52 | 53 | $OCI_BIN run \ 54 | --rm \ 55 | -e LC_ALL=C.UTF-8 \ 56 | -e LANG=C.UTF-8 \ 57 | --user "${USER:-root}" \ 58 | --volume "${TOPLEVEL}:${TOPLEVEL}:Z" \ 59 | --workdir "${TOPLEVEL}" \ 60 | --name "${NAME}" \ 61 | --hostname "${NAME}" \ 62 | $OCI_RUN_ARGUMENTS "${NAME}" "${@:2}" 63 | } 64 | 65 | oci_image "${PROJECT}" 66 | 67 | oci_run "${PROJECT}" "$@" 68 | -------------------------------------------------------------------------------- /securedrop_salt/sd-viewer.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # sd-viewer 6 | # ======== 7 | # 8 | # Configures the 'sd-viewer' template VM, which will be used as the 9 | # base dispvm for the SVS vm (will be used to open all submissions 10 | # after processing). 11 | # This VM has no network configured. 12 | ## 13 | 14 | # Imports "sdvars" for environment config 15 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 16 | 17 | # Check environment 18 | {% import_json "securedrop_salt/config.json" as d %} 19 | 20 | include: 21 | - securedrop_salt.sd-workstation-template 22 | 23 | sd-viewer: 24 | qvm.vm: 25 | - name: sd-viewer 26 | - present: 27 | # Sets attributes if creating VM for the first time, 28 | # otherwise `prefs` must be used. 29 | # Label color is set during initial configuration but 30 | # not enforced on every Salt run, in case of user customization. 31 | - label: green 32 | - template: sd-large-{{ sdvars.distribution }}-template 33 | - prefs: 34 | - template: sd-large-{{ sdvars.distribution }}-template 35 | - netvm: "" 36 | - template_for_dispvms: True 37 | - default_dispvm: "" 38 | - tags: 39 | - add: 40 | - sd-workstation 41 | - sd-viewer-vm 42 | - sd-{{ sdvars.distribution }} 43 | - features: 44 | - set: 45 | - vm-config.SD_MIME_HANDLING: sd-viewer 46 | {% if d.environment == "prod" %} 47 | - internal: 1 48 | {% else %} 49 | - internal: "" 50 | {% endif %} 51 | - enable: 52 | - service.paxctld 53 | - service.securedrop-mime-handling 54 | - require: 55 | - qvm: sd-large-{{ sdvars.distribution }}-template 56 | 57 | # Set sd-viewer as the global default_dispvm 58 | # While all of our VMs have explit default_dispvm set, this is a better default 59 | # than the stock fedora-XX-dvm in case someone creates their own VMs. 60 | sd-viewer-default-dispvm: 61 | cmd.run: 62 | - name: qubes-prefs default_dispvm sd-viewer 63 | - require: 64 | - qvm: sd-viewer 65 | -------------------------------------------------------------------------------- /files/securedrop-scalable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /securedrop_salt/sd-dom0-files.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # Installs dom0 config scripts specific to tracking updates 6 | # over time. These scripts should be ported to an RPM package. 7 | ## 8 | 9 | # Ensure debian-12-minimal is present for use as base template 10 | dom0-install-debian-minimal-template: 11 | qvm.template_installed: 12 | - name: debian-12-minimal 13 | 14 | {% set gui_user = salt['cmd.shell']('groupmems -l -g qubes') %} 15 | 16 | dom0-login-autostart-directory: 17 | file.directory: 18 | - name: /home/{{ gui_user }}/.config/autostart 19 | - user: {{ gui_user }} 20 | - group: {{ gui_user }} 21 | - mode: 700 22 | - makedirs: True 23 | 24 | dom0-login-autostart-desktop-file: 25 | file.managed: 26 | - name: /home/{{ gui_user }}/.config/autostart/press.freedom.SecureDropUpdater.desktop 27 | - source: "salt://securedrop_salt/dom0-xfce-desktop-file.j2" 28 | - template: jinja 29 | - context: 30 | desktop_name: SDWLogin 31 | desktop_comment: Updates SecureDrop Workstation DispVMs at login 32 | desktop_exec: /usr/bin/sdw-login 33 | - user: {{ gui_user }} 34 | - group: {{ gui_user }} 35 | - mode: 664 36 | - require: 37 | - file: dom0-login-autostart-directory 38 | 39 | dom0-securedrop-launcher-desktop-shortcut: 40 | file.managed: 41 | - name: /home/{{ gui_user }}/Desktop/press.freedom.SecureDropUpdater.desktop 42 | - source: "salt://securedrop_salt/press.freedom.SecureDropUpdater.desktop" 43 | - user: {{ gui_user }} 44 | - group: {{ gui_user }} 45 | - mode: 755 46 | 47 | {% import_json "securedrop_salt/config.json" as d %} 48 | dom0-environment-directory: 49 | file.directory: 50 | - name: /var/lib/securedrop-workstation/ 51 | - mode: 755 52 | - makedirs: true 53 | 54 | dom0-remove-old-environment-flag: 55 | file.tidied: 56 | - name: /var/lib/securedrop-workstation/ 57 | - require: 58 | - file: dom0-environment-directory 59 | 60 | dom0-write-environment-flag: 61 | file.managed: 62 | - name: /var/lib/securedrop-workstation/{{ d.environment }} 63 | - mode: 644 64 | - replace: False 65 | - require: 66 | - file: dom0-remove-old-environment-flag 67 | -------------------------------------------------------------------------------- /sdw_updater/strings.py: -------------------------------------------------------------------------------- 1 | headline_introduction = "Preflight security updates" 2 | description_introduction = ( 3 | "

To keep your Workstation safe, daily software updates are required.

" 4 | "

This typically takes between 10 and 30 minutes. You cannot use the SecureDrop " 5 | "Client or any of its VMs while the updater is running.

" 6 | "

Interrupting software updates may break " 7 | "the Workstation. Please cancel and return later if you are pressed " 8 | "for time.

" 9 | ) 10 | 11 | headline_applying_updates = "Updates in progress…" 12 | description_status_applying_updates = ( 13 | "

You will see a flood of Qubes notifications as VMs are restarted. " 14 | "Network and USB device access will be interrupted briefly.

" 15 | "

Do not close this window, shut down the " 16 | "computer, or close the laptop lid until the process is done. " 17 | "The screensaver will not affect it.

" 18 | ) 19 | 20 | headline_status_updates_complete = "All updates complete!" 21 | description_status_updates_complete = ( 22 | "Click Continue to launch the SecureDrop Client. No reboot is necessary." 23 | ) 24 | 25 | headline_status_updates_failed = "Security updates failed" 26 | description_status_updates_failed = ( 27 | "There was an error downloading or installing updates for your workstation. " 28 | "The SecureDrop Client cannot be started at this time. Please contact your administrator." 29 | ) 30 | # Post-update actions (launching client, reboot) 31 | headline_status_reboot_required = "All updates complete!" 32 | description_status_reboot_required = ( 33 | "You need to reboot the computer for updates to be completed. " 34 | "Please click the Reboot button to continue." 35 | ) 36 | 37 | headline_status_rebooting = "Rebooting Workstation" 38 | description_status_rebooting = "" 39 | 40 | headline_status_error_reboot = "Error rebooting Workstation" 41 | description_error_reboot = "

Please contact your administrator.

" 42 | 43 | headline_error_network = "Network Unavailable" 44 | description_error_network = ( 45 | "

A network error was encountered while attempting to update.

" 46 | "

Please check network settings and try again.

" 47 | ) 48 | -------------------------------------------------------------------------------- /scripts/configure-environment.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Updates the config.json in-place in dom0 to set the environment to 'dev' or 4 | 'staging'. 5 | """ 6 | 7 | import argparse 8 | import json 9 | import os 10 | import subprocess 11 | import sys 12 | from pathlib import Path 13 | 14 | 15 | def parse_args(): 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument( 18 | "--config", 19 | default="config.json", 20 | required=False, 21 | action="store", 22 | help="Path to JSON configuration file", 23 | ) 24 | parser.add_argument( 25 | "--environment", 26 | default="dev", 27 | required=False, 28 | action="store", 29 | help="Target deploy strategy, i.e. 'dev', or 'staging'", 30 | ) 31 | args = parser.parse_args() 32 | if not os.path.exists(args.config): 33 | msg = f"Config file not found: {args.config}\n" 34 | sys.stderr.write(msg) 35 | parser.print_help(sys.stderr) 36 | sys.exit(1) 37 | 38 | if args.environment not in ("dev", "staging"): 39 | parser.print_help(sys.stderr) 40 | sys.exit(2) 41 | return args 42 | 43 | 44 | def set_env_in_config(args): 45 | with open(args.config) as f: 46 | old_config = json.load(f) 47 | 48 | new_config = dict(old_config) 49 | new_config["environment"] = args.environment 50 | 51 | if new_config != old_config: 52 | msg = f"Updated config environment to '{args.environment}'...\n" 53 | sys.stderr.write(msg) 54 | 55 | with open(args.config, "w") as f: 56 | json.dump(new_config, f) 57 | 58 | 59 | def apply_config(config_path): 60 | """Copying config secrets into place""" 61 | config_source = Path(config_path).parent 62 | 63 | for file in ["config.json", "sd-journalist.sec"]: 64 | for target_dir in [ 65 | Path("/usr/share/securedrop-workstation-dom0-config/"), 66 | Path("/srv/salt/securedrop_salt/"), 67 | ]: 68 | subprocess.run(["sudo", "cp", "-v", config_source / file, target_dir], check=True) 69 | subprocess.run(["sudo", "chmod", "ugo+r", target_dir / file], check=True) 70 | 71 | 72 | if __name__ == "__main__": 73 | args = parse_args() 74 | 75 | set_env_in_config(args) 76 | apply_config(args.config) 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | *.swp 3 | builder/build/* 4 | sd-journalist.sec 5 | build-log/ 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | *.pytest_cache 12 | *_pycache_ 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | .venv-* 92 | env/ 93 | venv/ 94 | ENV/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | # deb package build artifacts 110 | builder/packages/securedrop-workstation-grsec_* 111 | builder/packages/securedrop-workstation-grsec/debian/debhelper-build-stamp 112 | builder/packages/securedrop-workstation-grsec/debian/securedrop-workstation-grsec.substvars 113 | builder/packages/securedrop-workstation-grsec/debian/securedrop-workstation-grsec 114 | builder/packages/securedrop-workstation-grsec/debian/files 115 | 116 | # rpm package build artifacts 117 | *.rpm 118 | *.tar.gz 119 | rpm-build/BUILD/ 120 | rpm-build/BUILDROOT/ 121 | 122 | # IDEs 123 | .vscode 124 | -------------------------------------------------------------------------------- /files/31-securedrop-workstation.policy: -------------------------------------------------------------------------------- 1 | ## Configure Qubes RPC "allow" policies for SecureDrop Workstation. 2 | # 3 | # This file is provisioned by secureDrop-workstation-dom0-config. 4 | # Do not modify this file! 5 | # 6 | # Qubes suggests the allow policies be evaluated after (with a higher file 7 | # number than) the deny policies, but due to the way SDW policies are stacked at 8 | # the moment, we reverse this suggested order. 9 | # 10 | # We also want SDW policies in the new format to be evaluated before the legacy 11 | # compatibility policies (`/etc/qubes/policy.d/35-compat.policy`), to avoid 12 | # having to maintain two sets of policies. We therefore choose policy file numbers 13 | # between 30 (used by system, `/etc/qubes/policy.d/30-qubesctl-salt.policy) and 35 14 | # (legacy compatibility, as above). This way, if users have legacy compatibility 15 | # policies defined for non-SecureDrop Workstation qubes, they will be evaluated 16 | # normally and will not be broken by SecureDrop Workstation, but will not be 17 | # evaluated before our own policies. 18 | 19 | # required to suppress unsupported loopback error notifications 20 | securedrop.Log * sd-log sd-log deny notify=no 21 | securedrop.Log * @tag:sd-workstation sd-log allow 22 | 23 | securedrop.Proxy * sd-app sd-proxy allow 24 | 25 | qubes.Gpg * @tag:sd-client sd-gpg allow 26 | qubes.GpgImportKey * @tag:sd-client sd-gpg allow 27 | 28 | # Future: qubes-app-linux-split-gpg2 29 | qubes.Gpg2 * @tag:sd-client sd-gpg allow target=sd-gpg 30 | 31 | qubes.USBAttach * sys-usb sd-devices allow user=root 32 | qubes.USBAttach * @anyvm @anyvm ask 33 | 34 | qubes.USB * sd-devices sys-usb allow 35 | 36 | # TODO: should this be handled with the new Global Config UI instead? 37 | qubes.ClipboardPaste * @tag:sd-send-app-clipboard sd-app ask 38 | qubes.ClipboardPaste * sd-app @tag:sd-receive-app-clipboard ask 39 | 40 | qubes.Filecopy * sd-log @default ask 41 | qubes.Filecopy * sd-log @tag:sd-receive-logs ask 42 | 43 | qubes.OpenInVM * @tag:sd-client @dispvm:sd-viewer allow 44 | qubes.OpenInVM * @tag:sd-client sd-devices allow 45 | qubes.OpenInVM * sd-devices @dispvm:sd-viewer allow 46 | -------------------------------------------------------------------------------- /securedrop_salt/sd-proxy.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | ## 5 | # Installs 'sd-proxy' AppVM, for managing connection between SecureDrop Client 6 | # and the SecureDrop servers. 7 | ## 8 | 9 | # Imports "sdvars" for environment config 10 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 11 | {% import_json "securedrop_salt/config.json" as d %} 12 | 13 | include: 14 | - securedrop_salt.sd-workstation-template 15 | 16 | sd-proxy-dvm: 17 | qvm.vm: 18 | - name: sd-proxy-dvm 19 | - present: 20 | # Sets attributes if creating VM for the first time, 21 | # otherwise `prefs` must be used. 22 | # Label color is set during initial configuration but 23 | # not enforced on every Salt run, in case of user customization. 24 | - label: blue 25 | - template: sd-small-{{ sdvars.distribution }}-template 26 | - prefs: 27 | - template: sd-small-{{ sdvars.distribution }}-template 28 | - netvm: sys-firewall 29 | - template_for_dispvms: True 30 | - default_dispvm: "" 31 | - features: 32 | - set: 33 | {% if d.environment == "prod" %} 34 | - internal: 1 35 | {% else %} 36 | - internal: "" 37 | {% endif %} 38 | - tags: 39 | - add: 40 | - sd-workstation 41 | - sd-{{ sdvars.distribution }} 42 | - require: 43 | - qvm: sd-small-{{ sdvars.distribution }}-template 44 | 45 | sd-proxy-create-named-dispvm: 46 | qvm.vm: 47 | - name: sd-proxy 48 | - present: 49 | - label: blue 50 | - template: sd-proxy-dvm 51 | - class: DispVM 52 | - prefs: 53 | - template: sd-proxy-dvm 54 | - netvm: sys-firewall 55 | - autostart: true 56 | - default_dispvm: "" 57 | - features: 58 | - enable: 59 | - service.securedrop-mime-handling 60 | - service.securedrop-arti 61 | - set: 62 | - vm-config.SD_MIME_HANDLING: default 63 | - servicevm: 1 64 | - internal: "" 65 | - tags: 66 | - add: 67 | - sd-workstation 68 | - sd-{{ sdvars.distribution }} 69 | - require: 70 | - qvm: sd-proxy-dvm 71 | 72 | sd-proxy-config: 73 | qvm.features: 74 | - name: sd-proxy 75 | - set: 76 | - vm-config.SD_PROXY_ORIGIN: http://{{ d.hidserv.hostname }} 77 | - vm-config.SD_PROXY_ORIGIN_KEY: {{ d.hidserv.key }} 78 | - require: 79 | - qvm: sd-proxy-create-named-dispvm 80 | -------------------------------------------------------------------------------- /files/destroy-vm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Utility to quickly destroy a Qubes VM managed by the Workstation 4 | salt config, for use in repeated builds during development. 5 | """ 6 | 7 | import argparse 8 | import subprocess 9 | import sys 10 | 11 | import qubesadmin 12 | 13 | SDW_DEFAULT_TAG = "sd-workstation" 14 | 15 | 16 | def parse_args(): 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument( 19 | "--all-tagged", 20 | default=False, 21 | required=False, 22 | action="store_true", 23 | help="Destroys all SDW VMs (except untagged-ones)", 24 | ) 25 | parser.add_argument( 26 | "targets", 27 | help="List of SDW VMs to destroy", 28 | nargs=argparse.REMAINDER, 29 | ) 30 | args = parser.parse_args() 31 | if not args.all_tagged and len(args.targets) < 1: 32 | parser.print_help(sys.stderr) 33 | sys.exit(1) 34 | return args 35 | 36 | 37 | def destroy_vm(vm): 38 | """ 39 | Destroys a single VM instance. Requires arg to be 40 | QubesVM object. 41 | """ 42 | if SDW_DEFAULT_TAG not in vm.tags: 43 | raise Exception("VM does not have the 'sd-workstation' tag") 44 | if vm.is_running(): 45 | vm.kill() 46 | print(f"Destroying VM '{vm.name}'... ", end="") 47 | subprocess.check_call(["qvm-remove", "-f", vm.name]) 48 | print("OK") 49 | 50 | 51 | def destroy_all_tagged(): 52 | """ 53 | Destroys all VMs marked with the 'sd-workstation' tag, in the following order: 54 | DispVMs, AppVMs, then TemplateVMs. Excludes VMs for which 55 | installed_by_rpm=true. 56 | """ 57 | # Remove DispVMs first, then AppVMs, then TemplateVMs last. 58 | sdw_vms = [vm for vm in q.domains if SDW_DEFAULT_TAG in vm.tags] 59 | sdw_template_vms = [ 60 | vm for vm in sdw_vms if vm.klass == "TemplateVM" and not vm.installed_by_rpm 61 | ] 62 | sdw_disp_vms = [vm for vm in sdw_vms if vm.klass == "DispVM"] 63 | sdw_app_vms = [vm for vm in sdw_vms if vm.klass == "AppVM"] 64 | 65 | for vm in sdw_disp_vms: 66 | destroy_vm(vm) 67 | 68 | for vm in sdw_app_vms: 69 | destroy_vm(vm) 70 | 71 | for vm in sdw_template_vms: 72 | destroy_vm(vm) 73 | 74 | 75 | if __name__ == "__main__": 76 | args = parse_args() 77 | q = qubesadmin.Qubes() 78 | if args.all_tagged: 79 | destroy_all_tagged() 80 | else: 81 | for t in args.targets: 82 | vm = q.domains[t] 83 | destroy_vm(vm) 84 | -------------------------------------------------------------------------------- /tests/test_qubes_rpc.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import subprocess 4 | 5 | 6 | @functools.cache 7 | def qrexec_policy_graph(service): 8 | cmd = ["qrexec-policy-graph", "--service", service] 9 | p = subprocess.run(cmd, capture_output=True, text=True, check=False) 10 | return p.stdout 11 | 12 | 13 | def policy_exists(source, target, service): 14 | service_policy_graph = qrexec_policy_graph(service) 15 | policy_str = f'"{source}" -> "{target}" [label="{service}"' 16 | return policy_str in service_policy_graph 17 | 18 | 19 | def test_policy_files_exist(): 20 | """verify the policies are installed""" 21 | assert os.path.exists("/etc/qubes/policy.d/31-securedrop-workstation.policy") 22 | assert os.path.exists("/etc/qubes/policy.d/32-securedrop-workstation.policy") 23 | 24 | 25 | # securedrop.Log from @tag:sd-workstation to sd-log should be allowed 26 | def test_sdlog_from_sdw_to_sdlog_allowed(sdw_tagged_vms): 27 | for vm in sdw_tagged_vms: 28 | if vm.name != "sd-log": 29 | assert policy_exists(vm, "sd-log", "securedrop.Log") 30 | 31 | 32 | # securedrop.Log from anything else to sd-log should be denied 33 | def test_sdlog_from_other_to_sdlog_denied(all_vms, sdw_tagged_vms): 34 | non_sd_workstation_vms = set(all_vms).difference(set(sdw_tagged_vms)) 35 | for vm in non_sd_workstation_vms: 36 | if vm.name != "sd-log": 37 | assert not policy_exists(vm, "sd-log", "securedrop.Log") 38 | 39 | 40 | # securedrop.Proxy from sd-app to sd-proxy should be allowed 41 | def test_sdproxy_from_sdapp_to_sdproxy_allowed(): 42 | assert policy_exists("sd-app", "sd-proxy", "securedrop.Proxy") 43 | 44 | 45 | # securedrop.Proxy from anything else to sd-proxy should be denied 46 | def test_sdproxy_from_other_to_sdproxy_denied(): 47 | assert not policy_exists("sys-net", "sd-proxy", "securedrop.Proxy") 48 | assert not policy_exists("sys-firewall", "sd-proxy", "securedrop.Proxy") 49 | 50 | 51 | # qubes.Gpg, qubes.GpgImportKey, and qubes.Gpg2 from anything else to sd-gpg should be denied 52 | def test_qubesgpg_from_other_to_sdgpg_denied(): 53 | assert not policy_exists("sys-net", "sd-gpg", "qubes.Gpg") 54 | assert not policy_exists("sys-firewall", "sd-gpg", "qubes.Gpg") 55 | assert not policy_exists("sys-net", "sd-gpg", "qubes.GpgImportKey") 56 | assert not policy_exists("sys-firewall", "sd-gpg", "qubes.GpgImportKey") 57 | assert not policy_exists("sys-net", "sd-gpg", "qubes.Gpg2") 58 | assert not policy_exists("sys-firewall", "sd-gpg", "qubes.Gpg2") 59 | -------------------------------------------------------------------------------- /securedrop_salt/fpf-apt-repo.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | # 4 | 5 | # Don't start with the Qubes-maintained Salt logic for upgrading VM packages: 6 | # 7 | # dom0:/srv/formulas/base/update-formula/update/qubes-vm.sls 8 | # 9 | # We want to make sure that certain maintenance tasks like cleaning out 10 | # old packages and updating apt lists are handled first, otherwise 11 | # the subsequent tasks will fail. For reference 12 | # include: 13 | # - update.qubes-vm 14 | # - securedrop_salt.sd-default-config 15 | 16 | # Imports "sdvars" for environment config 17 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 18 | 19 | # Using apt-get requires manual approval when releaseinfo changes, 20 | # just get it over with in the beginning 21 | update-apt-cache-with-stable-change: 22 | cmd.run: 23 | - name: apt-get update --allow-releaseinfo-change 24 | 25 | autoremove-old-packages: 26 | cmd.run: 27 | - name: apt-get autoremove -y 28 | - require: 29 | - cmd: update-apt-cache-with-stable-change 30 | 31 | # If we're on a prod environment, ensure there isn't a test .sources 32 | # file. (Should never happen in real usage, but may in testing) 33 | {% import_json "securedrop_salt/config.json" as d %} 34 | {% if d.environment == "prod" %} 35 | clean-old-test-sources: 36 | file.absent: 37 | - name: "/etc/apt/sources.list.d/apt-test_freedom_press.sources" 38 | {% endif %} 39 | 40 | # Install the relevant .sources file based on our environment. 41 | configure-fpf-apt-repo: 42 | file.managed: 43 | - name: "/etc/apt/sources.list.d/{{ sdvars.apt_sources_filename }}" 44 | - source: "salt://securedrop_salt/{{ sdvars.apt_sources_filename }}.j2" 45 | - template: jinja 46 | - context: 47 | codename: {{ grains['oscodename'] }} 48 | component: {{ sdvars.component }} 49 | - require: 50 | - cmd: autoremove-old-packages 51 | {% if d.environment == "prod" %} 52 | - file: clean-old-test-sources 53 | {% endif %} 54 | 55 | upgrade-all-packages: 56 | pkg.uptodate: 57 | # Update apt lists again, since they were updated before FPF repo was added. 58 | - refresh: True 59 | - dist_upgrade: True 60 | - require: 61 | - file: configure-fpf-apt-repo 62 | - cmd: update-apt-cache-with-stable-change 63 | 64 | # Install production keyring package, which will overwrite prod .sources file 65 | install-securedrop-keyring-package: 66 | pkg.installed: 67 | - pkgs: 68 | - securedrop-keyring 69 | - require: 70 | - file: configure-fpf-apt-repo 71 | -------------------------------------------------------------------------------- /securedrop_salt/sd-log.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | # 5 | # Installs 'sd-log' AppVM for collecting and storing logs 6 | # from all SecureDrop related VMs. 7 | # This VM has no network configured. 8 | ## 9 | 10 | # Imports "sdvars" for environment config 11 | {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %} 12 | 13 | # Check environment 14 | {% import_json "securedrop_salt/config.json" as d %} 15 | 16 | # Set "install epoch". Bump this number to backup and rebuild this vm. 17 | # This is more of a tag than a numerical value, and should not 18 | # be used for anything other than an equality check. 19 | {% set sdlog_epoch = '1001' %} 20 | 21 | include: 22 | - securedrop_salt.sd-workstation-template 23 | 24 | sd-log-epoch-bump-shutdown: 25 | qvm.shutdown: 26 | - name: sd-log 27 | - flags: 28 | - force 29 | - wait 30 | - onlyif: 31 | - qvm-check --quiet sd-log 32 | - unless: 33 | - (( `qvm-features sd-log sd-install-epoch` == {{ sdlog_epoch }} )) 34 | 35 | sd-log-epoch-bump-remove: 36 | qvm.absent: 37 | - name: sd-log 38 | - require: 39 | - qvm: sd-log-epoch-bump-shutdown 40 | - onlyif: 41 | - qvm-check --quiet sd-log 42 | - unless: 43 | - (( `qvm-features sd-log sd-install-epoch` == {{ sdlog_epoch }} )) 44 | 45 | install-sd-log: 46 | qvm.vm: 47 | - name: sd-log 48 | - present: 49 | # Sets attributes if creating VM for the first time, 50 | # otherwise `prefs` must be used. 51 | # Label color is set during initial configuration but 52 | # not enforced on every Salt run, in case of user customization. 53 | - label: red 54 | - template: sd-small-{{ sdvars.distribution }}-template 55 | - prefs: 56 | - template: sd-small-{{ sdvars.distribution }}-template 57 | - netvm: "" 58 | - autostart: true 59 | - default_dispvm: "" 60 | - tags: 61 | - add: 62 | - sd-workstation 63 | - features: 64 | - enable: 65 | - service.paxctld 66 | - service.redis 67 | - service.securedrop-logging-disabled 68 | - service.securedrop-log-server 69 | - set: 70 | - sd-install-epoch: {{ sdlog_epoch }} 71 | - menu-items: "org.gnome.Nautilus.desktop" 72 | - require: 73 | - qvm: sd-small-{{ sdvars.distribution }}-template 74 | 75 | # The private volume size should be set in config.json 76 | sd-log-private-volume-size: 77 | cmd.run: 78 | - name: > 79 | qvm-volume resize sd-log:private {{ d.vmsizes.sd_log }}GiB 80 | - onchanges: 81 | - qvm: install-sd-log 82 | -------------------------------------------------------------------------------- /tests/test_dom0_validate.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import tempfile 3 | import unittest 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from files.validate_config import SDWConfigValidator, ValidationError 9 | 10 | 11 | class SD_Dom0_Validate_Tests(unittest.TestCase): 12 | def setUp(self): 13 | # Enable full diff output in test report, to aid in debugging 14 | self.maxDiff = None 15 | self.test_resources = Path(__file__).parent.resolve() 16 | 17 | def test_good_config(self): 18 | with tempfile.TemporaryDirectory() as dir: 19 | shutil.copy(f"{self.test_resources}/files/testconfig.json", f"{dir}/config.json") 20 | shutil.copy(f"{self.test_resources}/files/example_key.asc", f"{dir}/sd-journalist.sec") 21 | 22 | # Validator currently runs checks in constructor 23 | SDWConfigValidator(dir) 24 | 25 | def test_missing_config(self): 26 | with tempfile.TemporaryDirectory() as dir: 27 | with pytest.raises(ValidationError) as exc_info: 28 | SDWConfigValidator(dir) 29 | 30 | assert "Config file does not exist" in exc_info.exconly() 31 | 32 | def test_config_malformed_key(self): 33 | with tempfile.TemporaryDirectory() as dir: 34 | shutil.copy(f"{self.test_resources}/files/testconfig.json", f"{dir}/config.json") 35 | shutil.copy( 36 | f"{self.test_resources}/files/example_key.asc.malformed", f"{dir}/sd-journalist.sec" 37 | ) 38 | 39 | with pytest.raises(ValidationError) as exc_info: 40 | SDWConfigValidator(dir) 41 | 42 | assert ( 43 | "PGP secret key file provided is not an armored private key" in exc_info.exconly() 44 | ) 45 | 46 | def test_config_malformed_onion_json(self): 47 | with tempfile.TemporaryDirectory() as dir: 48 | shutil.copy( 49 | f"{self.test_resources}/files/testconfig.json.malformedonion", f"{dir}/config.json" 50 | ) 51 | shutil.copy(f"{self.test_resources}/files/example_key.asc", f"{dir}/sd-journalist.sec") 52 | 53 | with pytest.raises(ValidationError) as exc_info: 54 | SDWConfigValidator(dir) 55 | 56 | assert "Invalid hidden service hostname specified" in exc_info.exconly() 57 | 58 | def test_config_malformed_fpr_json(self): 59 | with tempfile.TemporaryDirectory() as dir: 60 | shutil.copy( 61 | f"{self.test_resources}/files/testconfig.json.malformedfpr", f"{dir}/config.json" 62 | ) 63 | shutil.copy(f"{self.test_resources}/files/example_key.asc", f"{dir}/sd-journalist.sec") 64 | 65 | with pytest.raises(ValidationError) as exc_info: 66 | SDWConfigValidator(dir) 67 | 68 | assert "Invalid PGP key fingerprint specified" in exc_info.exconly() 69 | -------------------------------------------------------------------------------- /sdw_notify/NotifyApp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Notification dialog that appears when user has not applied security updates 3 | recently. Prompts user to check for updates or defer reminder. 4 | """ 5 | 6 | from enum import Enum 7 | 8 | try: 9 | from PyQt6.QtWidgets import QMessageBox 10 | except ImportError: 11 | from PyQt5.QtWidgets import QMessageBox # type: ignore [no-redef] 12 | 13 | from sdw_notify import Notify, strings 14 | from sdw_util import Util 15 | 16 | sdlog = Util.get_logger(Notify.LOG_FILE) 17 | 18 | 19 | class NotifyStatus(Enum): 20 | """ 21 | Supported exit states from Notify dialog. 22 | """ 23 | 24 | CHECK_UPDATES = "0" 25 | DEFER_UPDATES = "1" 26 | ERROR_UNKNOWN = "2" 27 | 28 | 29 | class NotifyDialog(QMessageBox): 30 | """ 31 | Shows notification advising user that they have not checked for updates 32 | recently, and offering option to check now or defer the check. 33 | 34 | Constructor takes a boolean parameter, `is_sdapp_stopped`, which determines 35 | whether a longer error message indicating the updater's impact on a 36 | currently-running client session will be shown. 37 | """ 38 | 39 | def __init__(self, is_sdapp_stopped: bool): 40 | super().__init__() 41 | self._is_sdapp_stopped = is_sdapp_stopped 42 | self._ui() 43 | 44 | def _ui(self): 45 | self.setWindowTitle(strings.headline_notify_updates) 46 | self.setIcon(QMessageBox.StandardButton.Warning) 47 | self.setStandardButtons(QMessageBox.StandardButton.No | QMessageBox.StandardButton.Ok) 48 | self.setDefaultButton(QMessageBox.StandardButton.Ok) 49 | self.setEscapeButton(QMessageBox.StandardButton.No) 50 | button_check_now = self.button(QMessageBox.StandardButton.Ok) 51 | button_check_now.setText(strings.button_check_for_updates) 52 | button_defer = self.button(QMessageBox.StandardButton.No) 53 | button_defer.setText(strings.button_defer_check) 54 | 55 | if self._is_sdapp_stopped: 56 | self.setText(strings.description_notify_updates) 57 | else: 58 | self.setText(strings.description_notify_updates_sdapp_running) 59 | 60 | def run(self) -> NotifyStatus: 61 | """ 62 | Launch dialog and return user selection. 63 | """ 64 | result = self.exec() 65 | 66 | if result == QMessageBox.StandardButton.Ok: 67 | sdlog.info(f"NotfyDialog returned {result}, user has opted to check for updates") 68 | return NotifyStatus.CHECK_UPDATES 69 | if result == QMessageBox.StandardButton.No: 70 | sdlog.info(f"NotfyDialog returned {result}, user has opted to defer updates") 71 | return NotifyStatus.DEFER_UPDATES 72 | 73 | # Should not occur, as this dialog which can only return 74 | # one of two states. 75 | sdlog.error( 76 | f"NotfyDialog returned unexpected value {result}; consult " 77 | "QMessageBox API for more information" 78 | ) 79 | return NotifyStatus.ERROR_UNKNOWN 80 | -------------------------------------------------------------------------------- /securedrop_salt/sd-clean-all.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | 4 | {% import_json "securedrop_salt/config.json" as d %} 5 | 6 | set-fedora-as-default-dispvm: 7 | cmd.run: 8 | - name: qvm-check default-dvm && qubes-prefs default_dispvm default-dvm || qubes-prefs default_dispvm '' 9 | 10 | {% set gui_user = salt['cmd.shell']('groupmems -l -g qubes') %} 11 | {% set gui_user_id = salt['cmd.shell']('id -u ' + gui_user) %} 12 | 13 | {% if salt['pillar.get']('qvm:sys-usb:disposable', true) %} 14 | restore-sys-usb-dispvm-halt: 15 | qvm.kill: 16 | - name: sys-usb 17 | 18 | restore-sys-usb-dispvm-halt-wait: 19 | cmd.run: 20 | - name: sleep 5 21 | - require: 22 | - qvm: restore-sys-usb-dispvm-halt 23 | 24 | restore-sys-usb-dispvm: 25 | qvm.prefs: 26 | - name: sys-usb 27 | - template: default-dvm 28 | - require: 29 | - cmd: restore-sys-usb-dispvm-halt-wait 30 | - cmd: set-fedora-as-default-dispvm 31 | 32 | restore-sys-usb-dispvm-start: 33 | qvm.start: 34 | - name: sys-usb 35 | - require: 36 | - qvm: restore-sys-usb-dispvm 37 | 38 | # autoattach modifications are only present in sd-fedora-42-dvm 39 | # so no more sd-usb-autoattach-remove necessary 40 | remove-sd-fedora-dispvm: 41 | qvm.absent: 42 | - name: sd-fedora-42-dvm 43 | - require: 44 | - qvm: restore-sys-usb-dispvm 45 | {% else %} 46 | # If sys-usb is not disposable, clean up after ourselves 47 | include: 48 | - securedrop_salt.sd-usb-autoattach-remove 49 | {% endif %} 50 | 51 | # Removes all salt-provisioned files (if these files are also provisioned via 52 | # RPM, they should be removed as part of remove-dom0-sdw-config-files-dev) 53 | remove-dom0-sdw-config-files: 54 | file.absent: 55 | - names: 56 | - /home/{{ gui_user }}/.config/autostart/press.freedom.SecureDropUpdater.desktop 57 | - /home/{{ gui_user }}/Desktop/press.freedom.SecureDropUpdater.desktop 58 | - /home/{{ gui_user }}/.securedrop_updater 59 | - /var/lib/securedrop-workstation 60 | 61 | # Remove any custom RPC policy tags added to non-SecureDrop VMs by the user 62 | remove-rpc-policy-tags: 63 | cmd.script: 64 | - name: salt://securedrop_salt/remove-tags.py 65 | 66 | sd-cleanup-sys-firewall: 67 | cmd.run: 68 | - names: 69 | - qvm-run sys-firewall 'sudo rm -f /rw/config/RPM-GPG-KEY-securedrop-workstation' 70 | - qvm-run sys-firewall 'sudo rm -f /rw/config/RPM-GPG-KEY-securedrop-workstation-test' 71 | - qvm-run sys-firewall 'sudo rm -f /etc/pki/rpm-gpg/RPM-GPG-KEY-securedrop-workstation' 72 | - qvm-run sys-firewall 'sudo rm -f /etc/pki/rpm-gpg/RPM-GPG-KEY-securedrop-workstation-test' 73 | 74 | # Reset desktop icon size to its original value 75 | dom0-reset-icon-size-xfce: 76 | cmd.script: 77 | - name: /usr/bin/securedrop/update-xfce-settings 78 | - args: reset-icon-size 79 | - runas: {{ gui_user }} 80 | 81 | # Reset power management options to their original values 82 | {% if d.environment == "prod" or d.environment == "staging" %} 83 | dom0-reset-power-management-xfce: 84 | cmd.script: 85 | - name: /usr/bin/securedrop/update-xfce-settings 86 | - args: reset-power-management 87 | - runas: {{ gui_user }} 88 | {% endif %} 89 | -------------------------------------------------------------------------------- /scripts/test_key.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | 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 50 | F4VrJxJS72cAX4+vPw== 51 | =szwx 52 | -----END PGP PUBLIC KEY BLOCK----- 53 | -------------------------------------------------------------------------------- /securedrop_salt/apt-test-pubkey.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | 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 50 | F4VrJxJS72cAX4+vPw== 51 | =szwx 52 | -----END PGP PUBLIC KEY BLOCK----- 53 | -------------------------------------------------------------------------------- /tests/test_vm_sd_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sd-app" VM and related functionality. 4 | """ 5 | 6 | import pytest 7 | 8 | from tests.base import ( 9 | SD_TAG, 10 | SD_TEMPLATE_SMALL, 11 | QubeWrapper, 12 | ) 13 | from tests.base import ( 14 | Test_SD_VM_Common as Test_SD_App_Common, # noqa: F401 [HACK: import so base tests run] 15 | ) 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def qube(): 20 | return QubeWrapper( 21 | "sd-app", 22 | expected_config_keys={ 23 | "QUBES_GPG_DOMAIN", 24 | "SD_SUBMISSION_KEY_FPR", 25 | "SD_MIME_HANDLING", 26 | }, 27 | enforced_apparmor_profiles={ 28 | "/usr/bin/securedrop-client", 29 | }, 30 | ) 31 | 32 | 33 | def test_open_in_dvm_desktop(qube): 34 | contents = qube.get_file_contents("/usr/share/applications/open-in-dvm.desktop") 35 | expected_contents = [ 36 | "TryExec=/usr/bin/qvm-open-in-vm", 37 | "Exec=/usr/bin/qvm-open-in-vm --view-only @dispvm:sd-viewer %f", 38 | ] 39 | for line in expected_contents: 40 | assert line in contents 41 | 42 | 43 | def test_mimeapps(qube): 44 | results = qube.run("cat /usr/share/applications/mimeapps.list") 45 | for line in results.splitlines(): 46 | if line.startswith(("#", "[Default")): 47 | # Skip comments and the leading [Default Applications] 48 | continue 49 | mime, target = line.split("=", 1) 50 | assert target == "open-in-dvm.desktop;" 51 | # Now functionally test it 52 | actual_app = qube.run(f"xdg-mime query default {mime}") 53 | assert actual_app == "open-in-dvm.desktop" 54 | 55 | 56 | def test_mailcap_hardened(qube): 57 | qube.mailcap_hardened() 58 | 59 | 60 | def test_sd_client_package_installed(qube): 61 | assert qube.package_is_installed("securedrop-client") 62 | 63 | 64 | def test_sd_client_dependencies_installed(qube): 65 | assert qube.package_is_installed("python3-pyqt5") 66 | assert qube.package_is_installed("python3-pyqt5.qtsvg") 67 | 68 | 69 | def test_sd_client_config(dom0_config, qube): 70 | assert dom0_config["submission_key_fpr"] == qube.vm_config_read("SD_SUBMISSION_KEY_FPR") 71 | 72 | 73 | def test_logging_configured(qube): 74 | qube.logging_configured() 75 | 76 | 77 | def test_sd_app_config(config, qube, all_vms): 78 | vm = all_vms["sd-app"] 79 | nvm = vm.netvm 80 | assert nvm is None 81 | assert vm.template.name == SD_TEMPLATE_SMALL 82 | assert not vm.provides_network 83 | assert not vm.template_for_dispvms 84 | assert "service.securedrop-log-server" not in vm.features 85 | assert SD_TAG in vm.tags 86 | assert "sd-client" in vm.tags 87 | # Check the size of the private volume 88 | # Should be 10GB 89 | # >>> 1024 * 1024 * 10 * 1024 90 | size = config["vmsizes"]["sd_app"] 91 | vol = vm.volumes["private"] 92 | assert vol.size == size * 1024 * 1024 * 1024 93 | 94 | # MIME handling 95 | assert vm.features["service.securedrop-mime-handling"] == "1" 96 | assert vm.features["vm-config.SD_MIME_HANDLING"] == "sd-app" 97 | assert qube.service_is_active("securedrop-mime-handling") 98 | 99 | # Arti should *not* be running 100 | assert not qube.service_is_active("securedrop-arti") 101 | -------------------------------------------------------------------------------- /tests/test_vm_sd_gpg.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sd-gpg" VM and related functionality. 4 | """ 5 | 6 | import re 7 | import subprocess 8 | import tempfile 9 | 10 | import pytest 11 | 12 | from tests.base import ( 13 | SD_TAG, 14 | SD_TEMPLATE_SMALL, 15 | QubeWrapper, 16 | ) 17 | from tests.base import ( 18 | Test_SD_VM_Common as Test_SD_Gpg_Common, # noqa: F401 [HACK: import so base tests run] 19 | ) 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def qube(): 24 | return QubeWrapper("sd-gpg") 25 | 26 | 27 | def _extract_fingerprints(gpg_output): 28 | """Helper method to extract fingerprints from GPG command output""" 29 | return re.findall(r"[A-F0-9]{40}", gpg_output.decode()) 30 | 31 | 32 | @pytest.fixture 33 | def config_fingerprint(dom0_config): 34 | """ 35 | Obtain the fingerprint explicitly configured in dom0 and injected into VMs. 36 | """ 37 | return dom0_config["submission_key_fpr"] 38 | 39 | 40 | @pytest.fixture 41 | def dom0_fingerprint(): 42 | """ 43 | Obtain the fingerprint of the key actually present in dom0. 44 | """ 45 | with tempfile.TemporaryDirectory() as d: 46 | gpg_env = {"GNUPGHOME": d} 47 | subprocess.check_call( 48 | ["gpg", "--import", "sd-journalist.sec"], 49 | env=gpg_env, 50 | stdout=subprocess.DEVNULL, 51 | stderr=subprocess.DEVNULL, 52 | ) 53 | local_results = subprocess.check_output(["gpg", "--list-secret-keys"], env=gpg_env) 54 | return _extract_fingerprints(local_results) 55 | 56 | 57 | @pytest.fixture 58 | def vm_fingerprint(qube): 59 | """ 60 | Obtain fingerprints for all keys actually present in GPG VM 61 | """ 62 | cmd = [ 63 | "qvm-run", 64 | "-p", 65 | qube.name, 66 | "/usr/bin/gpg --list-secret-keys", 67 | ] 68 | remote_results = subprocess.check_output(cmd) 69 | return _extract_fingerprints(remote_results) 70 | 71 | 72 | def test_sd_gpg_timeout(qube): 73 | line = "export QUBES_GPG_AUTOACCEPT=2147483647" 74 | qube.assertFileHasLine("/home/user/.profile", line) 75 | 76 | 77 | def test_local_key_in_remote_keyring(config_fingerprint, dom0_fingerprint, vm_fingerprint): 78 | """ 79 | Verify the key present in dom0 and sd-gpg matches what's configured in config.json 80 | 81 | This also verifies only one secret key is in the keyring. 82 | """ 83 | assert dom0_fingerprint == [config_fingerprint] 84 | assert vm_fingerprint == [config_fingerprint] 85 | 86 | 87 | def test_logging_disabled(qube): 88 | # Logging to sd-log should be disabled on sd-gpg 89 | assert not qube.fileExists("/etc/rsyslog.d/sdlog.conf") 90 | assert qube.fileExists("/var/run/qubes-service/securedrop-logging-disabled") 91 | 92 | 93 | def test_sd_gpg_config(all_vms): 94 | """ 95 | Confirm that qvm-prefs match expectations for the sd-gpg VM. 96 | """ 97 | vm = all_vms["sd-gpg"] 98 | nvm = vm.netvm 99 | assert nvm is None 100 | # No sd-gpg-template, since keyring is managed in $HOME 101 | assert vm.template.name == SD_TEMPLATE_SMALL 102 | assert vm.autostart 103 | assert not vm.provides_network 104 | assert not vm.template_for_dispvms 105 | assert vm.features["service.securedrop-logging-disabled"] == "1" 106 | assert SD_TAG in vm.tags 107 | -------------------------------------------------------------------------------- /securedrop_salt/apt-test_freedom_press.sources.j2: -------------------------------------------------------------------------------- 1 | Types: deb 2 | URIs: https://apt-test.freedom.press 3 | Suites: {{ codename }} 4 | Components: {{ component }} 5 | Signed-By: 6 | -----BEGIN PGP PUBLIC KEY BLOCK----- 7 | . 8 | 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 55 | F4VrJxJS72cAX4+vPw== 56 | =szwx 57 | -----END PGP PUBLIC KEY BLOCK----- 58 | -------------------------------------------------------------------------------- /files/sdw-notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Displays a warning to the user if the workstation has been running continuously 4 | for too long without checking for security updates. Writes output to a logfile, 5 | not stdout. All settings are in Notify utility module. 6 | """ 7 | 8 | import sys 9 | 10 | try: 11 | from PyQt6.QtWidgets import QApplication 12 | except ImportError: 13 | from PyQt5.QtWidgets import QApplication # type: ignore [no-redef] 14 | 15 | from sdw_notify import Notify, NotifyApp 16 | from sdw_updater import Updater, UpdaterApp 17 | from sdw_util import Util 18 | 19 | Util.configure_logging(Notify.LOG_FILE) 20 | log = Util.get_logger(Notify.LOG_FILE) 21 | 22 | 23 | def main(): 24 | """ 25 | Show security warning, if and only if a warning is not already displayed, 26 | the preflight updater is running, and certain checks suggest that the 27 | system has not been updated for a specified period 28 | """ 29 | 30 | if Util.is_conflicting_process_running(Notify.CONFLICTING_PROCESSES): 31 | # Conflicting system process may be running in dom0. Logged. 32 | sys.exit(1) 33 | 34 | if Util.can_obtain_lock(Updater.LOCK_FILE) is False: 35 | # Preflight updater is already running. Logged. 36 | sys.exit(1) 37 | 38 | # Hold on to lock handle during execution 39 | lock_handle = Util.obtain_lock(Notify.LOCK_FILE) 40 | if lock_handle is None: 41 | # Can't write to lockfile or notifier already running. Logged. 42 | sys.exit(1) 43 | 44 | warning_should_be_shown = Notify.is_update_check_necessary() 45 | if warning_should_be_shown is None: 46 | # Data integrity issue with update timestamp. Logged. 47 | sys.exit(1) 48 | elif warning_should_be_shown is True: 49 | show_update_warning() 50 | 51 | 52 | def show_update_warning(): 53 | """ 54 | Show a graphical warning reminding the user to check for security updates 55 | using the Preflight Updater. 56 | 57 | If the user opts to check for updates, launch the Preflight Updater. 58 | If the user opts to defer, they will be reminded again the next time the 59 | notify script runs. 60 | """ 61 | 62 | app = QApplication([]) 63 | dialog = NotifyApp.NotifyDialog(Util.is_sdapp_halted()) 64 | result = dialog.run() 65 | 66 | # Check results of Notify Dialog and launch the Preflight Updater if user 67 | # has opted to check for updates. 68 | if result == NotifyApp.NotifyStatus.CHECK_UPDATES: 69 | log.info("Launching Preflight Updater") 70 | updater = UpdaterApp.UpdaterApp() 71 | updater.show() 72 | sys.exit(app.exec()) 73 | elif result == NotifyApp.NotifyStatus.DEFER_UPDATES: 74 | # Currently, `DEFER_UPDATES` is a no-op, because the deferral period is 75 | # simply the period before the next run of the notify script (defined in 76 | # `securedrop-workstation/securedrop_salt/sd-dom0-crontab.sls`). 77 | log.info( 78 | "User has deferred update check. sdw-notify will run " 79 | "again at the next scheduled interval." 80 | ) 81 | sys.exit(0) 82 | else: 83 | # NotifyApp.NotifyStatus.ERROR_UNKNOWN, meaning the dialog returned an 84 | # unexpected state. The error is logged in NotifyDialog.run(). 85 | log.info( 86 | "Unexpected result from NotifyDialog. sdw-notify will run " 87 | "again at the next scheduled interval." 88 | ) 89 | sys.exit(result) 90 | 91 | 92 | if __name__ == "__main__": 93 | main() 94 | -------------------------------------------------------------------------------- /tests/test_vm_sd_devices.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sd-devices" VM and related functionality. 4 | """ 5 | 6 | import os 7 | 8 | import pytest 9 | 10 | from tests.base import ( 11 | SD_TAG, 12 | QubeWrapper, 13 | ) 14 | from tests.base import ( 15 | Test_SD_VM_Common as Test_SD_Devices_Common, # noqa: F401 [HACK: import so base tests run] 16 | ) 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def qube(): 21 | return QubeWrapper("sd-devices", expected_config_keys={"SD_MIME_HANDLING"}) 22 | 23 | 24 | def test_files_are_properly_copied(qube): 25 | assert qube.fileExists("/usr/bin/send-to-usb") 26 | assert qube.fileExists("/usr/share/applications/send-to-usb.desktop") 27 | assert qube.fileExists("/usr/share/mime/packages/application-x-sd-export.xml") 28 | 29 | 30 | def test_sd_export_package_installed(qube): 31 | assert qube.package_is_installed("udisks2") 32 | assert qube.package_is_installed("securedrop-export") 33 | assert qube.package_is_installed("gnome-disk-utility") 34 | 35 | 36 | def test_logging_configured(qube): 37 | qube.logging_configured() 38 | 39 | 40 | def test_mime_types(qube): 41 | filepath = os.path.join( 42 | os.path.dirname(os.path.abspath(__file__)), "vars", "sd-devices.mimeapps" 43 | ) 44 | with open(filepath) as f: 45 | lines = f.readlines() 46 | for line in lines: 47 | if line != "[Default Applications]\n" and not line.startswith("#"): 48 | mime_type = line.split("=")[0] 49 | expected_app = line.split("=")[1].split(";")[0] 50 | actual_app = qube.run(f"xdg-mime query default {mime_type}") 51 | assert actual_app == expected_app 52 | 53 | 54 | def test_mailcap_hardened(qube): 55 | qube.mailcap_hardened() 56 | 57 | 58 | def test_open_in_dvm_desktop(qube): 59 | contents = qube.get_file_contents("/usr/share/applications/open-in-dvm.desktop") 60 | expected_contents = [ 61 | "TryExec=/usr/bin/qvm-open-in-vm", 62 | "Exec=/usr/bin/qvm-open-in-vm --view-only @dispvm:sd-viewer %f", 63 | ] 64 | for line in expected_contents: 65 | assert line in contents 66 | 67 | 68 | def test_sd_devices_config(qube, all_vms): 69 | """ 70 | Confirm that qvm-prefs match expectations for this VM. 71 | """ 72 | vm = all_vms["sd-devices"] 73 | nvm = vm.netvm 74 | assert nvm is None 75 | vm_type = vm.klass 76 | assert vm_type == "DispVM" 77 | assert SD_TAG in vm.tags 78 | 79 | assert vm.features["service.avahi"] == "1" 80 | 81 | # MIME handling 82 | assert vm.features["service.securedrop-mime-handling"] == "1" 83 | assert vm.features["vm-config.SD_MIME_HANDLING"] == "sd-devices" 84 | assert qube.service_is_active("securedrop-mime-handling") 85 | 86 | 87 | def test_sd_devices_dvm_config(all_vms): 88 | """ 89 | Confirm that qvm-prefs match expectations for the sd-devices DispVM 90 | """ 91 | # N.B. Don't use fixture, which is hardcoded for "sd-devices" VM. 92 | dvm_qube = QubeWrapper("sd-devices-dvm") 93 | vm = all_vms[dvm_qube.name] 94 | nvm = vm.netvm 95 | assert nvm is None 96 | assert SD_TAG in vm.tags 97 | assert vm.template_for_dispvms 98 | 99 | assert "service.avahi" not in vm.features 100 | # MIME handling (dvm does NOT setup mime, only its disposables do) 101 | assert "service.securedrop-mime-handling" not in vm.features 102 | assert not dvm_qube.service_is_active("securedrop-mime-handling") 103 | -------------------------------------------------------------------------------- /securedrop_salt/securedrop-handle-upgrade: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | set -e 4 | set -u 5 | set -o pipefail 6 | 7 | TASK=${1:-default} 8 | 9 | # Helper function so that we (under "set -e") don't error out when 10 | # qvm-check returns nonzero for a domain that isn't running. 11 | function shutdown_if_running () { 12 | set +e 13 | qvm-check --running "$1" && qvm-shutdown --wait "$1" 14 | set -e 15 | } 16 | 17 | # To allow the template of an AppVM to be changed, the following two 18 | # conditions must be met: 19 | # 1. The AppVM must be powered off 20 | # 2. The AppVM must not be a DispVM template that used as the default DispVM 21 | # for an AppVM, nor the system default DispVM. 22 | if [[ $TASK == "prepare" ]]; then 23 | # sd-app, we simply shutdown the machine as we want to preserve the data 24 | if qvm-check sd-app --quiet; then 25 | BASE_TEMPLATE=$(qvm-prefs sd-app template) 26 | if [[ ! $BASE_TEMPLATE =~ "small-bookworm" ]]; then 27 | shutdown_if_running "sd-app" 28 | fi 29 | fi 30 | 31 | # For sd-viewer and sd-devices-dvm, DispVM templates. We can delete both 32 | # VMs since they contain no persistent data. The installer will re-create them 33 | # as part of the provisioning process. 34 | # We set the default DispVM to empty string to ensure nothing is opened in an 35 | # insecure (unmanaged or not yet updated) or networked vm, until the 36 | # provisioning process runs again and sets that value to sd-viewer 37 | if qvm-check --quiet sd-viewer; then 38 | BASE_TEMPLATE=$(qvm-prefs sd-viewer template) 39 | if [[ ! $BASE_TEMPLATE =~ "large-bookworm" ]]; then 40 | qubes-prefs default_dispvm '' 41 | shutdown_if_running "sd-viewer" 42 | qvm-remove -f sd-viewer 43 | fi 44 | fi 45 | 46 | if qvm-check --quiet sd-devices; then 47 | BASE_TEMPLATE=$(qvm-prefs sd-devices-dvm template) 48 | if [[ ! $BASE_TEMPLATE =~ "large-bookworm" ]]; then 49 | shutdown_if_running "sd-devices" 50 | shutdown_if_running "sd-devices-dvm" 51 | qvm-remove -f sd-devices 52 | qvm-remove -f sd-devices-dvm 53 | fi 54 | fi 55 | 56 | if qvm-check --quiet sd-proxy-dvm; then 57 | BASE_TEMPLATE=$(qvm-prefs sd-proxy-dvm template) 58 | if [[ ! $BASE_TEMPLATE =~ "large-bookworm" ]]; then 59 | shutdown_if_running "sd-proxy" 60 | fi 61 | fi 62 | 63 | # For sd-gpg, we simply shutdown the machine 64 | if qvm-check --quiet sd-gpg; then 65 | BASE_TEMPLATE=$(qvm-prefs sd-gpg template) 66 | if [[ ! $BASE_TEMPLATE =~ "small-bookworm" ]]; then 67 | shutdown_if_running "sd-gpg" 68 | fi 69 | fi 70 | 71 | # Shut down sd-log last, since other VMs will autostart it by sending logs 72 | if qvm-check --quiet sd-log; then 73 | BASE_TEMPLATE=$(qvm-prefs sd-log template) 74 | if [[ ! $BASE_TEMPLATE =~ "small-bookworm" ]]; then 75 | shutdown_if_running "sd-log" 76 | fi 77 | fi 78 | elif [[ $TASK == "remove" ]]; then 79 | # For each template, ensure the TemplateVM exists, that it is shut down 80 | # before deleting it. 81 | # TODO: clean this up, we don't have separate templates anymore and nobody 82 | # will be upgrading from the original setup 83 | for template in sd-small-bullseye-template sd-large-bullseye-template 84 | do 85 | if qvm-check "${template}" --quiet; then 86 | shutdown_if_running "${template}" 87 | qvm-remove -f "${template}" 88 | fi 89 | done 90 | else 91 | echo "Please specify prepare or remove" 92 | exit 1 93 | fi 94 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | requires-python = ">=3.11" 3 | name = "securedrop-workstation-dom0-config" 4 | dynamic = [ "version", "classifiers" ] 5 | description = "" 6 | authors = [ 7 | {name = "SecureDrop Team", email = "securedrop@freedom.press"} 8 | ] 9 | license = {text = "AGPLv3+"} 10 | readme = "README.md" 11 | 12 | [tool.poetry] 13 | package-mode = false 14 | requires-poetry = ">=2.1.0,<3.0.0" 15 | 16 | [tool.poetry.group.dev.dependencies] 17 | mypy = "^1.14.1" 18 | pytest = "^8.3.4" 19 | pytest-cov = "^6.0.0" 20 | types-setuptools = "^75.6.0" 21 | ruff = "^0.8.5" 22 | python-debian = "^0.1.49" 23 | pysequoia = "^0.1.25" 24 | zizmor = "*" 25 | 26 | [tool.poetry.group.system-package-equivalents.dependencies] 27 | # In production these are installed as a system package so match the 28 | # versions exactly. 29 | # NOTE: 'python_version' used as a proxy for the Qubes version: 30 | # - python 3.11 => Fedora 37 => Qubes 4.2 31 | # - python 3.13 => Fedora 41 => Qubes 4.3 32 | PyQt5 = {version = "=5.15.9", markers = "python_version <= '3.11'" } 33 | PyQt5-Qt5 = {version = "=5.15.2", markers = "python_version <= '3.11'" } 34 | PyQt5-sip = {version = "=12.11.0", markers = "python_version <= '3.11'" } 35 | PyQt6 = {version = "=6.8.1", markers = "python_version > '3.11'" } 36 | PyQt6-sip = {version = "=13.9.1", markers = "python_version > '3.11'" } 37 | 38 | [tool.ruff] 39 | line-length = 100 40 | 41 | [tool.ruff.lint] 42 | select = [ 43 | # pycodestyle errors 44 | "E", 45 | # pyflakes 46 | "F", 47 | # isort 48 | "I", 49 | # flake8-gettext 50 | "INT", 51 | # flake8-pie 52 | "PIE", 53 | # pylint 54 | "PL", 55 | # flake8-pytest-style 56 | "PT", 57 | # flake8-pyi 58 | "PYI", 59 | # flake8-return 60 | "RET", 61 | # flake8-bandit 62 | "S", 63 | # flake8-simplify 64 | "SIM", 65 | # pyupgrade 66 | "UP", 67 | # pycodestyle warnings 68 | "W", 69 | # Unused noqa directive 70 | "RUF100", 71 | ] 72 | ignore = [ 73 | # code complexity checks that we fail 74 | "PLR0911", 75 | "PLR0913", 76 | "PLR0915", 77 | # magic-value-comparison, too many violations for now 78 | "PLR2004", 79 | # hardcoded passwords, false positive 80 | "S105", 81 | # it's fine to use /tmp in dom0, since it's not a multiuser environment 82 | "S108", 83 | # flags every instance of subprocess 84 | "S603", 85 | # we trust $PATH isn't hijacked 86 | "S607", 87 | # superflous-else- rules, find they hurt readability 88 | "RET505", 89 | "RET506", 90 | "RET507", 91 | "RET508", 92 | ] 93 | 94 | [tool.ruff.lint.per-file-ignores] 95 | "**/tests/**.py" = [ 96 | # Use of `assert` detected 97 | "S101", 98 | # Tests use /tmp 99 | "S108", 100 | # Use a regular `assert` instead of unittest-style `assertEqual` 101 | "PT009", 102 | ] 103 | "sdw_util/Util.py" = [ 104 | # lock functions return file handles, so it's safe to ignore here 105 | "SIM115", 106 | ] 107 | 108 | [tool.mypy] 109 | python_version = "3.11" 110 | # No stubs for qubesadmin 111 | ignore_missing_imports = true 112 | # These are individual scripts, not a package/modules 113 | scripts_are_modules = true 114 | files = [ 115 | "*.py", 116 | "securedrop_salt/remove-tags.py", 117 | "securedrop_salt/securedrop-login", 118 | "scripts/*.py", 119 | "files/*.py", 120 | ] 121 | exclude = [ 122 | "launcher/", # Moving to sd-updater 123 | "tests/", 124 | ] 125 | 126 | [tool.pytest.ini_options] 127 | addopts = "--cov-report term-missing --cov=sdw_notify --cov=sdw_updater --cov=sdw_util --junitxml=test-data.xml" 128 | -------------------------------------------------------------------------------- /scripts/bootstrap-keyring.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import subprocess 4 | import sys 5 | import tempfile 6 | import time 7 | from pathlib import Path 8 | 9 | TEST_KEY_FILENAME = "test_key.asc" 10 | YUM_REPOS_DIR = "/etc/yum.repos.d" 11 | KEYRING_PACKAGENAME = "securedrop-workstation-keyring" 12 | 13 | # Test key: ID is deterministic and based on fpr + key creation timestamp 14 | TEST_KEY_RPMID = "gpg-pubkey-3fab65ab-660f2beb" 15 | 16 | # Only support test rpms; for prod, use official install instructions 17 | BASEURL = "https://yum-test.securedrop.org" 18 | 19 | 20 | def get_fedora_version(): 21 | return subprocess.check_output(["rpm", "--eval", "%{fedora}"]).decode().strip() 22 | 23 | 24 | def create_repo_file(env: str, repo_file_path: str, ver: str): 25 | """Create .repo file based on environment.""" 26 | repo_content = f""" 27 | [{KEYRING_PACKAGENAME}-{env}] 28 | enabled=0 29 | gpgcheck=1 30 | baseurl={BASEURL}/workstation/dom0/f{ver}{'-nightlies' if env == 'dev' else ''} 31 | name=SecureDrop Workstation Keyring ({env}) 32 | """ 33 | with open(repo_file_path, "w") as repo_file: 34 | repo_file.write(repo_content.lstrip()) 35 | 36 | 37 | def rpm_import(key_file: Path): 38 | """Import GPG key into rpmdb.""" 39 | subprocess.check_call(["sudo", "rpm", "--import", str(key_file)]) 40 | 41 | 42 | def is_key_imported(rpm_id: str): 43 | """Check rpmdb for key with givem rpm_id.""" 44 | try: 45 | subprocess.check_call(["rpm", "-q", rpm_id]) 46 | return True 47 | except subprocess.CalledProcessError: 48 | return False 49 | 50 | 51 | def dom0_install_keyring(env: str | None = None): 52 | """Use qubes-dom0-update to install keyring package.""" 53 | args = ["sudo", "qubes-dom0-update", "--clean", "-y"] 54 | 55 | if env: 56 | package_name = f"{KEYRING_PACKAGENAME}-{env}" 57 | args.append(f"--enablerepo={package_name}") 58 | else: 59 | package_name = KEYRING_PACKAGENAME 60 | args.append(package_name) 61 | subprocess.check_call(args) 62 | 63 | 64 | def main(): 65 | parser = argparse.ArgumentParser( 66 | description="Bootstrap SecureDrop Workstation keyring on QubesOS" 67 | ) 68 | parser.add_argument( 69 | "--env", 70 | choices=["dev", "staging"], 71 | required=True, 72 | help="Specify the environment ('dev' or 'staging')", 73 | ) 74 | 75 | args = parser.parse_args() 76 | 77 | script_dir = Path(__file__).resolve().parent 78 | key_file = script_dir / TEST_KEY_FILENAME 79 | if not key_file.exists(): 80 | print(f"'{key_file}' not found.") 81 | sys.exit(1) 82 | 83 | fedora_version = get_fedora_version() 84 | 85 | # Build .repo file, then install with correct permissions in /etc/yum.repos.d (owned by root) 86 | with tempfile.TemporaryDirectory() as temp_dir: 87 | repo_file_path = Path(temp_dir) / f"{KEYRING_PACKAGENAME}-{args.env}.repo" 88 | 89 | create_repo_file(args.env, repo_file_path, fedora_version) 90 | repo_dest_path = Path(YUM_REPOS_DIR) / f"{KEYRING_PACKAGENAME}-{args.env}.repo" 91 | subprocess.check_call( 92 | ["sudo", "install", "-m", "0644", str(repo_file_path), str(repo_dest_path)] 93 | ) 94 | 95 | # Install environment-specific keyring package 96 | rpm_import(key_file) 97 | 98 | if not is_key_imported(TEST_KEY_RPMID): 99 | print("Wait for key import ...") 100 | time.sleep(20) 101 | 102 | if not is_key_imported(TEST_KEY_RPMID): 103 | print(f"Key {TEST_KEY_RPMID} ({TEST_KEY_FILENAME}) not found in rpm db.") 104 | sys.exit(1) 105 | 106 | dom0_install_keyring(args.env) 107 | 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /files/32-securedrop-workstation.policy: -------------------------------------------------------------------------------- 1 | ## Configure Qubes RPC "deny" policies for SecureDrop Workstation. 2 | # 3 | # This file is provisioned by secureDrop-workstation-dom0-config. 4 | # Do not modify this file! 5 | # 6 | # As a general strategy, in addition to explicit grants, we provide 7 | # catch-all deny policies for SDW-provisioned VMs. 8 | # 9 | # Qubes suggests the allow policies be evaluated after (with a higher file 10 | # number than) the deny policies, but due to the way SDW policies are stacked at 11 | # the moment, we reverse this suggested order. 12 | # 13 | # We also want SDW policies in the new format to be evaluated before the legacy 14 | # compatibility policies (`/etc/qubes/policy.d/35-compat.policy`), to avoid 15 | # having to maintain two sets of policies. We therefore choose policy file numbers 16 | # between 30 (used by system, `/etc/qubes/policy.d/30-qubesctl-salt.policy) and 35 17 | # (legacy compatibility, as above). This way, if users have legacy compatibility 18 | # policies defined for non-SecureDrop Workstation qubes, they will be evaluated 19 | # normally and will not be broken by SecureDrop Workstation, but will not be 20 | # evaluated before our own policies. 21 | 22 | securedrop.Log * @anyvm @anyvm deny 23 | 24 | securedrop.Proxy * @anyvm @anyvm deny 25 | 26 | qubes.GpgImportKey * @anyvm @tag:sd-workstation deny 27 | qubes.GpgImportKey * @tag:sd-workstation @anyvm deny 28 | 29 | qubes.Gpg * @anyvm @tag:sd-workstation deny 30 | qubes.Gpg * @tag:sd-workstation @anyvm deny 31 | 32 | # Future: qubes-app-linux-split-gpg2 33 | qubes.Gpg2 * @anyvm @tag:sd-workstation deny 34 | qubes.Gpg2 * @tag:sd-workstation @anyvm deny 35 | 36 | qubes.USBAttach * @anyvm @tag:sd-workstation deny 37 | qubes.USBAttach * @tag:sd-workstation @anyvm deny 38 | 39 | qubes.USB * @anyvm @tag:sd-workstation deny 40 | qubes.USB * @tag:sd-workstation @anyvm deny 41 | 42 | qubes.PdfConvert * @anyvm @tag:sd-workstation deny 43 | qubes.PdfConvert * @tag:sd-workstation @anyvm deny 44 | 45 | # TODO: should this be handled with the new Global Config UI instead? 46 | qubes.ClipboardPaste * @anyvm @tag:sd-workstation deny 47 | qubes.ClipboardPaste * @tag:sd-workstation @anyvm deny 48 | 49 | qubes.FeaturesRequest * @anyvm @tag:sd-workstation deny 50 | qubes.FeaturesRequest * @tag:sd-workstation @anyvm deny 51 | 52 | qubes.Filecopy * @anyvm @tag:sd-workstation deny 53 | qubes.Filecopy * @tag:sd-workstation @anyvm deny 54 | 55 | qubes.GetImageRGBA * @anyvm @tag:sd-workstation deny 56 | qubes.GetImageRGBA * @tag:sd-workstation @anyvm deny 57 | 58 | qubes.OpenInVM * @anyvm @tag:sd-workstation deny 59 | qubes.OpenInVM * @tag:sd-workstation @anyvm deny 60 | 61 | qubes.OpenURL * @anyvm @tag:sd-workstation deny 62 | qubes.OpenURL * @tag:sd-workstation @anyvm deny 63 | 64 | qubes.StartApp * @anyvm @tag:sd-workstation deny 65 | qubes.StartApp * @tag:sd-workstation @anyvm deny 66 | 67 | qubes.VMRootShell * @anyvm @tag:sd-workstation deny 68 | qubes.VMRootShell * @tag:sd-workstation @anyvm deny 69 | 70 | qubes.VMShell * @anyvm @tag:sd-workstation deny 71 | qubes.VMShell * @tag:sd-workstation @anyvm deny 72 | 73 | qubes.VMExec * @anyvm @tag:sd-workstation deny 74 | qubes.VMExec * @tag:sd-workstation @anyvm deny 75 | 76 | qubes.VMExecGUI * @anyvm @tag:sd-workstation deny 77 | qubes.VMExecGUI * @tag:sd-workstation @anyvm deny 78 | -------------------------------------------------------------------------------- /files/update-xfce-settings: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # A maintenance script for altering the XFCE config for the currently logged in 4 | # user to settings more appropriate for SecureDrop Workstation, or resetting 5 | # them back to default values. Typically only called by provisioning logic. 6 | # 7 | # Note that all properties are initially unset if the user has never modified 8 | # them, so we have to consistently use the -n flag to create them if needed. 9 | # 10 | # This script must run as the user whose preferences are changed. 11 | 12 | set -e 13 | set -u 14 | set -o pipefail 15 | 16 | if [[ $EUID -eq 0 ]]; then 17 | echo "This script should not be run as root; it must be run as a user with an active login session." 18 | exit 1 19 | fi 20 | 21 | TASK=${1:-none} 22 | ICONSIZE=64 23 | 24 | if ! [ -x "$(command -v xfconf-query)" ]; then 25 | echo "Error: xfconf-query is not installed." >&2 26 | exit 1 27 | fi 28 | 29 | # This script requires a valid DBUS session to work. When run non-interactively, 30 | # we assume that a sesssion is running for the current user. 31 | DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u "$USER")/bus" 32 | export DBUS_SESSION_BUS_ADDRESS 33 | 34 | if [[ $TASK == "disable-unsafe-power-management" ]]; then 35 | echo "update-xfce-settings: Disabling unsafe power management options for user $USER" 36 | 37 | # Trim "Actions" menu (top right) to essentials. 38 | # 39 | # - Remove suspend/hibernate (unsafe with full-disk encryption) 40 | # - Remove "Switch user" (single user system) 41 | # - Remove "Log out" (saves sessions by default, which is not recommended) 42 | # - Add "Restart" so that it remains accessible 43 | # 44 | # TODO: Make this more resilient by querying the plugin list first. 45 | xfconf-query -c xfce4-panel -np '/plugins/plugin-2/items' \ 46 | -t 'string' -s '+lock-screen' \ 47 | -t 'string' -s '+separator' \ 48 | -t 'string' -s '+restart' \ 49 | -t 'string' -s '+shutdown' 50 | 51 | # "Log out" is still accessible via the application menu (top left), so we 52 | # remove suspend and hibernate there as well. 53 | xfconf-query -c xfce4-session -np '/shutdown/ShowSuspend' -t 'bool' -s 'false' 54 | xfconf-query -c xfce4-session -np '/shutdown/ShowHibernate' -t 'bool' -s 'false' 55 | 56 | elif [[ $TASK == "adjust-icon-size" ]]; then 57 | echo "update-xfce-settings: Adjusting icon size for user $USER to $ICONSIZE px" 58 | xfconf-query -c xfce4-desktop -np '/desktop-icons/icon-size' -t 'int' -s $ICONSIZE 59 | 60 | elif [[ $TASK == "reset-power-management" ]]; then 61 | echo "update-xfce-settings: Resetting power management options for user $USER" 62 | 63 | # Does not retain its default config, so resetting to Qubes default values 64 | xfconf-query -c xfce4-panel -p '/plugins/plugin-2/items' \ 65 | -t 'string' -s '+lock-screen' \ 66 | -t 'string' -s '+switch-user' \ 67 | -t 'string' -s '+separator' \ 68 | -t 'string' -s '+suspend' \ 69 | -t 'string' -s '-hibernate' \ 70 | -t 'string' -s '-separator' \ 71 | -t 'string' -s '+shutdown' \ 72 | -t 'string' -s '-restart' \ 73 | -t 'string' -s '+separator' \ 74 | -t 'string' -s '+logout' \ 75 | -t 'string' -s '-logout-dialog' 76 | 77 | xfconf-query -c xfce4-session -p '/shutdown/ShowSuspend' -r 78 | xfconf-query -c xfce4-session -p '/shutdown/ShowHibernate' -r 79 | 80 | elif [[ $TASK == "reset-icon-size" ]]; then 81 | echo "update-xfce-settings: Resetting icon size to default for user $USER" 82 | xfconf-query -c xfce4-desktop -p '/desktop-icons/icon-size' -r 83 | 84 | else 85 | echo "Syntax: update-xfce-settings [task]" 86 | echo 87 | echo "Task must be one of:" 88 | echo 89 | echo " disable-unsafe-power-management Disable suspend and hibernation buttons" 90 | echo " adjust-icon-size Increase the default desktop icon size" 91 | echo " reset-power-management Reset power management settings to default" 92 | echo " reset-icon-size Reset icon size to default" 93 | fi 94 | -------------------------------------------------------------------------------- /tests/test_vm_sd_proxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sd-proxy" VM and related functionality. 4 | """ 5 | 6 | import pytest 7 | 8 | from tests.base import ( 9 | SD_TAG, 10 | SD_TEMPLATE_SMALL, 11 | QubeWrapper, 12 | ) 13 | from tests.base import ( 14 | Test_SD_VM_Common as Test_SD_Proxy_Common, # noqa: F401 [HACK: import so base tests run] 15 | ) 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def qube(): 20 | return QubeWrapper( 21 | "sd-proxy", 22 | expected_config_keys={"SD_PROXY_ORIGIN", "SD_PROXY_ORIGIN_KEY", "SD_MIME_HANDLING"}, 23 | enforced_apparmor_profiles={"/usr/bin/securedrop-proxy"}, 24 | ) 25 | 26 | 27 | def test_do_not_open_here(qube): 28 | """ 29 | The do-not-open here script has been removed from sd-proxy. 30 | All VMs now default to using open-in-dvm. 31 | """ 32 | assert not qube.fileExists("/usr/bin/do-not-open-here") 33 | 34 | 35 | def test_sd_proxy_package_installed(qube): 36 | assert qube.package_is_installed("securedrop-proxy") 37 | 38 | 39 | def test_tor_hidserv_auth_url(qube, dom0_config): 40 | assert f"http://{dom0_config['hidserv']['hostname']}" == qube.vm_config_read("SD_PROXY_ORIGIN") 41 | 42 | 43 | def test_whonix_ws_repo_absent(qube): 44 | """ 45 | The sd-proxy VM was previously based on Whonix Workstation, 46 | but we've since moved to the standard SDW Debian-based template. 47 | Guard against regressions by ensuring the old Whonix apt list 48 | is missing. 49 | """ 50 | # Whonix project changed the repo filename ~2021-05, so check both. 51 | assert not qube.fileExists("/etc/apt/sources.list.d/whonix.list") 52 | assert not qube.fileExists("/etc/apt/sources.list.d/derivative.list") 53 | 54 | 55 | def test_logging_configured(qube): 56 | qube.logging_configured() 57 | 58 | 59 | def test_mimeapps(qube): 60 | results = qube.run("cat /usr/share/applications/mimeapps.list") 61 | for line in results.splitlines(): 62 | if line.startswith(("#", "[Default")): 63 | # Skip comments and the leading [Default Applications] 64 | continue 65 | mime, target = line.split("=", 1) 66 | assert target == "open-in-dvm.desktop;" 67 | # Now functionally test it 68 | actual_app = qube.run(f"xdg-mime query default {mime}") 69 | assert actual_app == "open-in-dvm.desktop" 70 | 71 | 72 | def test_mailcap_hardened(qube): 73 | qube.mailcap_hardened() 74 | 75 | 76 | def test_sd_proxy_config(all_vms, qube): 77 | """ 78 | Confirm that qvm-prefs for the VM match expectations. 79 | """ 80 | vm = all_vms["sd-proxy"] 81 | assert vm.template.name == "sd-proxy-dvm" 82 | assert vm.klass == "DispVM" 83 | assert vm.netvm.name == "sys-firewall" 84 | assert vm.autostart 85 | assert not vm.provides_network 86 | assert vm.default_dispvm is None 87 | assert SD_TAG in vm.tags 88 | assert vm.features["service.securedrop-mime-handling"] == "1" 89 | assert vm.features["service.securedrop-arti"] == "1" 90 | assert vm.features["vm-config.SD_MIME_HANDLING"] == "default" 91 | assert qube.service_is_active("securedrop-mime-handling") 92 | assert qube.service_is_active("securedrop-proxy-onion-config") 93 | assert qube.service_is_active("tor") 94 | 95 | 96 | def test_sd_proxy_dvm(all_vms): 97 | """ 98 | Confirm that qvm-prefs for the "sd-proxy" DispVM match expectations. 99 | """ 100 | dvm_qube = QubeWrapper("sd-proxy-dvm") 101 | vm = all_vms[dvm_qube.name] 102 | assert vm.template_for_dispvms 103 | assert vm.netvm.name == "sys-firewall" 104 | assert vm.template.name == SD_TEMPLATE_SMALL 105 | assert vm.default_dispvm is None 106 | assert SD_TAG in vm.tags 107 | assert not vm.autostart 108 | assert "service.securedrop-mime-handling" not in vm.features 109 | assert not dvm_qube.service_is_active("securedrop-mime-handling") 110 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ci 3 | 4 | on: 5 | - push 6 | - pull_request 7 | - merge_group 8 | 9 | # Only build for latest push/PR unless it's main or release/ 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith( github.ref, 'refs/heads/release/' ) && !startsWith( github.ref, 'refs/heads/gh-readonly-queue/' ) }} 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | container: 18 | image: quay.io/fedora/fedora:37 19 | steps: 20 | - run: dnf install -y git make 21 | - uses: actions/checkout@v6 22 | with: 23 | persist-credentials: false 24 | - name: Install dependencies 25 | run: | 26 | make test-deps 27 | pip install poetry==2.1.1 28 | poetry install --no-ansi 29 | - name: Run linters 30 | run: | 31 | git config --global --add safe.directory '*' 32 | make lint 33 | build-rpm: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | fedora_version: 39 | - 37 # Qubes 4.2 40 | - 41 # Qubes 4.3 41 | container: 42 | image: quay.io/fedora/fedora:${{ matrix.fedora_version }} 43 | steps: 44 | - run: dnf install -y git make 45 | - uses: actions/checkout@v6 46 | with: 47 | persist-credentials: false 48 | - name: Install dependencies 49 | run: | 50 | make build-deps 51 | - name: Build RPM 52 | run: | 53 | git config --global --add safe.directory '*' 54 | make build-rpm 55 | - name: Check reproducibility 56 | run: | 57 | make test-deps 58 | make reprotest 59 | launcher-tests: 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | qubes_release: 64 | - { fedora_ver: "37", python_ver: "python3.11" } # Qubes 4.2 65 | - { fedora_ver: "41", python_ver: "python3.13" } # Qubes 4.3 66 | container: 67 | image: quay.io/fedora/fedora:${{ matrix.qubes_release.fedora_ver }} 68 | steps: 69 | - run: dnf install -y make 70 | - uses: actions/checkout@v6 71 | with: 72 | persist-credentials: false 73 | - name: Install dependencies 74 | run: | 75 | make test-deps 76 | pip install poetry==2.1.1 77 | poetry env use ${{ matrix.qubes_release.python_ver }} 78 | poetry install --no-ansi 79 | - name: Run launcher tests 80 | run: | 81 | make test-launcher 82 | # If the most recent commit message contains "openqa" (case insensitive), run the openqa job 83 | check-openqa: 84 | runs-on: ubuntu-latest 85 | # Do not start until all other checks have passed 86 | needs: 87 | - lint 88 | - build-rpm 89 | - launcher-tests 90 | outputs: 91 | should-run: ${{ steps.check.outputs.should-run }} 92 | env: 93 | COMMIT_MSG: ${{ github.event.head_commit.message }} 94 | steps: 95 | - name: Check commit message 96 | id: check 97 | run: | 98 | if echo "${COMMIT_MSG}" | grep -iq "openqa"; then 99 | echo "running OpenQA job" 100 | echo "should-run=true" >> $GITHUB_OUTPUT 101 | else 102 | echo "skipping OpenQA job" 103 | echo "should-run=false" >> $GITHUB_OUTPUT 104 | fi 105 | openqa: 106 | uses: ./.github/workflows/openqa.yml 107 | with: 108 | environment: "dev" 109 | git_ref: ${{ github.event.head_commit.id }} 110 | secrets: inherit 111 | needs: [check-openqa] 112 | # Run only if: 113 | # 1. This is a push (not pull_request) 114 | # 2. We're not on main 115 | # 3. We're not on a merge queue branch 116 | # 4. The "openqa" string is present 117 | if: | 118 | github.event_name == 'push' && github.ref != 'refs/heads/main' && !startsWith( github.ref, 'refs/heads/gh-readonly-queue/' ) 119 | && needs.check-openqa.outputs.should-run == 'true' 120 | -------------------------------------------------------------------------------- /.github/workflows/nightlies.yml: -------------------------------------------------------------------------------- 1 | name: Nightlies 2 | on: 3 | schedule: 4 | - cron: "0 6 * * *" 5 | push: 6 | branches: 7 | - main 8 | 9 | # Only allow one job to run at a time because we're pushing to git repos; 10 | # the string value doesn't matter, just that it's a fixed string. 11 | concurrency: 12 | group: "just-one-please" 13 | 14 | defaults: 15 | run: 16 | shell: bash 17 | 18 | jobs: 19 | build-rpm: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | fedora_version: 25 | - 37 # Qubes 4.2 26 | - 41 # Qubes 4.3 27 | container: 28 | image: quay.io/fedora/fedora:${{ matrix.fedora_version }} 29 | steps: 30 | - run: dnf install -y make git 31 | - uses: actions/checkout@v6 32 | with: 33 | persist-credentials: false 34 | - name: Install dependencies 35 | run: make build-deps 36 | - name: Build RPM 37 | run: | 38 | git config --global --add safe.directory '*' 39 | # Version format is "${VERSION}-0.YYYYMMDDHHMMSS.fXX", which sorts lower than "${VERSION}-1" 40 | rpmdev-bumpspec --new="$(cat VERSION)-0.$(date +%Y%m%d%H%M%S)%{?dist}" rpm-build/SPECS/*.spec 41 | make build-rpm 42 | - uses: actions/upload-artifact@v6 43 | id: upload 44 | with: 45 | name: rpm-build-${{ matrix.fedora_version }} 46 | path: rpm-build/RPMS/noarch/*.rpm 47 | if-no-files-found: error 48 | 49 | commit-and-push: 50 | runs-on: ubuntu-latest 51 | container: debian:bookworm 52 | needs: 53 | - build-rpm 54 | steps: 55 | - name: Install dependencies 56 | run: | 57 | apt-get update && apt-get install --yes git git-lfs 58 | 59 | - uses: actions/download-artifact@v7 60 | with: 61 | pattern: "*" 62 | 63 | - uses: actions/checkout@v6 64 | with: 65 | repository: "freedomofpress/securedrop-yum-test" 66 | path: "securedrop-yum-test" 67 | lfs: true 68 | persist-credentials: false 69 | 70 | - uses: actions/create-github-app-token@v2 71 | id: app-token 72 | with: 73 | app-id: ${{ vars.FPF_BRANCH_UPDATER_APP_ID }} 74 | private-key: ${{ secrets.FPF_BRANCH_UPDATER_APP_PRIVKEY }} 75 | repositories: securedrop-yum-test 76 | 77 | - name: Commit and push 78 | env: 79 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 80 | TARGET_REPO: freedomofpress/securedrop-yum-test 81 | run: | 82 | git config --global user.email "securedrop@freedom.press" 83 | git config --global user.name "sdcibot-nightlies[bot]" 84 | cd securedrop-yum-test 85 | mkdir -p workstation/dom0/f37-nightlies 86 | cp -v ../rpm-build-37/*.rpm workstation/dom0/f37-nightlies/ 87 | mkdir -p workstation/dom0/f41-nightlies 88 | cp -v ../rpm-build-41/*.rpm workstation/dom0/f41-nightlies/ 89 | git add . 90 | git diff-index --quiet HEAD || git commit -m "Automated SecureDrop workstation build" 91 | git push https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git main 92 | 93 | get-main-commit: 94 | runs-on: ubuntu-latest 95 | outputs: 96 | main-commit: ${{ steps.get_main_sha.outputs.result }} 97 | steps: 98 | - name: Get SHA of main branch 99 | id: get_main_sha 100 | uses: actions/github-script@v8 101 | with: 102 | result-encoding: string 103 | script: | 104 | const { data: branch } = await github.rest.repos.getBranch({ 105 | owner: context.repo.owner, 106 | repo: context.repo.repo, 107 | branch: 'main' 108 | }); 109 | return branch.commit.sha; 110 | openqa-nightly-dev: 111 | uses: ./.github/workflows/openqa.yml 112 | secrets: inherit 113 | needs: ["get-main-commit"] 114 | with: 115 | environment: "dev" 116 | git_ref: ${{ needs.get-main-commit.outputs.main-commit}} 117 | -------------------------------------------------------------------------------- /tests/test_vm_sd_log.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sd-log" VM and related functionality. 4 | """ 5 | 6 | import secrets 7 | import string 8 | import subprocess 9 | 10 | import pytest 11 | 12 | from tests.base import ( 13 | DEBIAN_VERSION, 14 | SD_TAG, 15 | SD_TEMPLATE_SMALL, 16 | QubeWrapper, 17 | ) 18 | from tests.base import ( 19 | Test_SD_VM_Common as Test_SD_Log_Common, # noqa: F401 [HACK: import so base tests run] 20 | ) 21 | 22 | 23 | @pytest.fixture(scope="module") 24 | def qube(): 25 | return QubeWrapper("sd-log") 26 | 27 | 28 | def test_sd_log_package_installed(qube): 29 | assert qube.package_is_installed("securedrop-log") 30 | 31 | 32 | def test_sd_log_redis_is_installed(qube): 33 | assert qube.package_is_installed("redis") 34 | assert qube.package_is_installed("redis-server") 35 | 36 | 37 | def test_log_utility_installed(qube): 38 | assert qube.fileExists("/usr/sbin/securedrop-log-saver") 39 | assert qube.fileExists("/etc/qubes-rpc/securedrop.Log") 40 | 41 | 42 | def test_sd_log_has_no_custom_rsyslog(qube): 43 | assert not qube.fileExists("/etc/rsyslog.d/sdlog.conf") 44 | 45 | 46 | def test_sd_log_service_running(qube): 47 | assert qube.service_is_active("securedrop-log-server") 48 | 49 | 50 | def test_redis_service_running(qube): 51 | assert qube.service_is_active("redis") 52 | 53 | 54 | def test_logs_are_flowing(qube, sdw_tagged_vms): 55 | """ 56 | To test that logs work, we run a unique command in each VM we care 57 | about that gets logged, and then check for that string in the logs. 58 | """ 59 | # Random string, to avoid collisions with other test runs 60 | token = "".join(secrets.choice(string.ascii_uppercase) for _ in range(10)) 61 | 62 | # base template doesn't have sd-log configured 63 | # TODO: test a sd-viewer based dispVM 64 | skip = [f"sd-base-{DEBIAN_VERSION}-template", "sd-viewer"] 65 | # VMs we expect logs will not go to 66 | no_log_vms = ["sd-gpg", "sd-log"] 67 | 68 | # We first run the command in each VM, and then do a second loop to 69 | # look for the token in the log entry, so there's enough time for the 70 | # log entry to get written. 71 | for vm in sdw_tagged_vms: 72 | if vm.name in skip: 73 | continue 74 | # The sudo call will make it into syslog 75 | subprocess.check_call(["qvm-run", vm.name, f"sudo echo {token}"]) 76 | 77 | for vm in sdw_tagged_vms: 78 | if vm.name in skip: 79 | continue 80 | syslog = f"/home/user/QubesIncomingLogs/{vm.name}/syslog.log" 81 | if vm.name in no_log_vms: 82 | assert not qube.fileExists(syslog) 83 | else: 84 | assert token in qube.get_file_contents(syslog) 85 | 86 | 87 | def test_log_dirs_properly_named(qube): 88 | cmd_output = qube.run("ls -1 /home/user/QubesIncomingLogs") 89 | log_dirs = cmd_output.split("\n") 90 | # Confirm we don't have 'host' entries from Whonix VMs 91 | assert "host" not in log_dirs 92 | 93 | 94 | def test_sd_log_config(qube, config, all_vms): 95 | """ 96 | Confirm that qvm-prefs match expectations for the sd-log VM. 97 | """ 98 | vm = all_vms["sd-log"] 99 | nvm = vm.netvm 100 | assert nvm is None 101 | assert vm.template.name == SD_TEMPLATE_SMALL 102 | assert vm.autostart 103 | assert not vm.provides_network 104 | assert not vm.template_for_dispvms 105 | assert qube.service_is_active("securedrop-log-server") 106 | assert vm.features["service.securedrop-log-server"] == "1" 107 | assert vm.features["service.securedrop-logging-disabled"] == "1" 108 | # See sd-log.sls "sd-install-epoch" feature 109 | assert vm.features["sd-install-epoch"] == "1001" 110 | 111 | assert not vm.template_for_dispvms 112 | assert SD_TAG in vm.tags 113 | # Check the size of the private volume 114 | # Should be same of config.json 115 | # >>> 1024 * 1024 * 5 * 1024 116 | size = config["vmsizes"]["sd_log"] 117 | vol = vm.volumes["private"] 118 | assert vol.size == size * 1024 * 1024 * 1024 119 | -------------------------------------------------------------------------------- /tests/test_dom0_rpm_repo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | import unittest 4 | from string import Template 5 | 6 | FEDORA_VERSION = "f37" 7 | 8 | REPO_CONFIG = { 9 | "prod": { 10 | "signing_key": "/etc/pki/rpm-gpg/RPM-GPG-KEY-securedrop-workstation", 11 | "repo_file_name": "securedrop-workstation-dom0.repo", 12 | "yum_repo_url": "https://yum.securedrop.org/workstation/dom0/$FEDORA_VERSION/", 13 | }, 14 | "dev": { 15 | "signing_key": "/etc/pki/rpm-gpg/RPM-GPG-KEY-securedrop-workstation-test", 16 | "repo_file_name": "securedrop-workstation-dom0-dev.repo", 17 | "yum_repo_url": "https://yum-test.securedrop.org/workstation/dom0/$FEDORA_VERSION-nightlies/", 18 | }, 19 | "staging": { 20 | "signing_key": "/etc/pki/rpm-gpg/RPM-GPG-KEY-securedrop-workstation-test", 21 | "repo_file_name": "securedrop-workstation-dom0-staging.repo", 22 | "yum_repo_url": "https://yum-test.securedrop.org/workstation/dom0/$FEDORA_VERSION/", 23 | }, 24 | } 25 | 26 | 27 | class SD_Dom0_Rpm_Repo_Tests(unittest.TestCase): 28 | def setUp(self): 29 | # Enable full diff output in test report, to aid in debugging 30 | self.maxDiff = None 31 | 32 | with open("config.json") as c: 33 | config = json.load(c) 34 | self.env = config.get("environment") 35 | 36 | # Fall back to checking environment via keyring package 37 | # (will eventually replace config.json environment) 38 | if not self.env: 39 | self.env = self._get_env_by_package() 40 | 41 | self.config = REPO_CONFIG[self.env].copy() 42 | 43 | # Fedora version is a placeholder, so fix that 44 | self.config["yum_repo_url"] = Template(self.config["yum_repo_url"]).safe_substitute( 45 | {"FEDORA_VERSION": FEDORA_VERSION} 46 | ) 47 | 48 | def test_rpm_repo_config(self): 49 | repo = self.config["repo_file_name"] 50 | baseurl = self.config["yum_repo_url"] 51 | repo_file = f"/etc/yum.repos.d/{repo}" 52 | wanted_lines = [ 53 | "[securedrop-workstation-dom0]", 54 | "gpgcheck=1", 55 | "skip_if_unavailable=False", 56 | "gpgkey=file://{}".format(self.config.get("signing_key")), 57 | "enabled=1", 58 | f"baseurl={baseurl}", 59 | "name=SecureDrop Workstation Qubes dom0 repo", 60 | ] 61 | with open(repo_file) as f: 62 | found_lines = [x.strip() for x in f.readlines()] 63 | 64 | assert found_lines == wanted_lines 65 | 66 | def _get_env_by_package(self): 67 | """ 68 | Check which environment we are using by checking keyring package. 69 | The order matters; due to package versioning, an installed dev package 70 | means a dev setup, so check dev, staging, then prod. 71 | """ 72 | for env, suffix in [("dev", "-dev"), ("staging", "-staging"), ("prod", "")]: 73 | if self._is_installed(f"securedrop-workstation-keyring{suffix}"): 74 | return env 75 | self.fail("No keyring package") 76 | # Unreachable, but ruff was unhappy 77 | return None 78 | 79 | def _is_installed(self, pkg: str): 80 | """ 81 | Check if package is installed. 82 | """ 83 | try: 84 | subprocess.check_call( 85 | ["rpm", "-q", pkg], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL 86 | ) 87 | 88 | return True 89 | except subprocess.CalledProcessError: 90 | return False 91 | 92 | def test_dom0_has_keyring_package(self): 93 | # Prod keyring is always installed 94 | assert self._is_installed("securedrop-workstation-keyring") 95 | 96 | # TODO: remove this check when config.json does not specify 97 | # "environment" anymore. 98 | # Until then, the order matters; a dev setup may also have 99 | # a staging package, but not vice-versa. 100 | if self.env == "dev": 101 | assert self._is_installed("securedrop-workstation-keyring-dev") 102 | elif self.env == "staging": 103 | assert self._is_installed("securedrop-workstation-keyring-staging") 104 | -------------------------------------------------------------------------------- /sdw_notify/Notify.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility library for warning the user that security updates have not been applied 3 | in some time. 4 | """ 5 | 6 | import os 7 | from datetime import datetime 8 | 9 | from sdw_util import Util 10 | 11 | sdlog = Util.get_logger(module=__name__) 12 | 13 | # The directory where status files and logs are stored 14 | BASE_DIRECTORY = Util.BASE_DIRECTORY 15 | 16 | # The file and format that contains the timestamp of the last successful update 17 | LAST_UPDATED_FILE = os.path.join(BASE_DIRECTORY, "sdw-last-updated") 18 | LAST_UPDATED_FORMAT = "%Y-%m-%d %H:%M:%S" 19 | 20 | # The lockfile basename used to ensure this script can only be executed once. 21 | # Default path for lockfiles is specified in sdw_util 22 | LOCK_FILE = "sdw-notify.lock" 23 | 24 | # Log file name, base directories defined in sdw_util 25 | LOG_FILE = "sdw-notify.log" 26 | 27 | # Process names that should not be running while this script runs. We do not 28 | # want to encourage running the updater during provisioning or system updates. 29 | # Caution is advised in expanding this list; a more precise detection method 30 | # is generally preferable. 31 | CONFLICTING_PROCESSES = ["qubesctl", "make"] 32 | 33 | # The maximum uptime this script should permit (specified in seconds) before 34 | # showing a warning. This is to avoid situations where the user boots the 35 | # computer after several days and immediately sees a warning. 36 | UPTIME_GRACE_PERIOD = 1800 # 30 minutes 37 | 38 | # The amount of time without updates (specified in seconds) which this script 39 | # should permit before showing a warning to the user 40 | WARNING_THRESHOLD = 432000 # 5 days 41 | 42 | 43 | def is_update_check_necessary(): 44 | """ 45 | Perform a series of checks to determine if a security warning should be 46 | shown to the user, reminding them to check for available software updates 47 | using the preflight updater. 48 | """ 49 | last_updated_file_exists = os.path.exists(LAST_UPDATED_FILE) 50 | # For consistent logging 51 | grace_period_hours = UPTIME_GRACE_PERIOD / 60 / 60 52 | warning_threshold_hours = WARNING_THRESHOLD / 60 / 60 53 | 54 | # Get timestamp from last update (if it exists) 55 | if last_updated_file_exists: 56 | with open(LAST_UPDATED_FILE) as f: 57 | last_update_time = f.readline().splitlines()[0] 58 | try: 59 | last_update_time = datetime.strptime(last_update_time, LAST_UPDATED_FORMAT) 60 | except ValueError: 61 | sdlog.error( 62 | f"Data in {LAST_UPDATED_FILE} not in the expected format. " 63 | f"Expecting a timestamp in format '{LAST_UPDATED_FORMAT}'. " 64 | "Showing security warning." 65 | ) 66 | return True 67 | 68 | now = datetime.now() 69 | updated_seconds_ago = (now - last_update_time).total_seconds() 70 | updated_hours_ago = updated_seconds_ago / 60 / 60 71 | 72 | uptime_seconds = get_uptime_seconds() 73 | uptime_hours = uptime_seconds / 60 / 60 74 | 75 | if not last_updated_file_exists: 76 | sdlog.warning( 77 | f"Timestamp file '{LAST_UPDATED_FILE}' does not exist. " 78 | "Updater may never have run. Showing security warning." 79 | ) 80 | return True 81 | if updated_seconds_ago > WARNING_THRESHOLD: 82 | if uptime_seconds > UPTIME_GRACE_PERIOD: 83 | sdlog.warning( 84 | f"Last successful update ({updated_hours_ago:.1f} hours ago) is above " 85 | f"warning threshold ({warning_threshold_hours:.1f} hours). Uptime grace period of " 86 | f"{grace_period_hours:.1f} hours has elapsed (uptime: {uptime_hours:.1f} hours). " 87 | "Showing security warning." 88 | ) 89 | return True 90 | 91 | sdlog.info( 92 | f"Last successful update ({updated_hours_ago:.1f} hours ago) is above " 93 | f"warning threshold ({warning_threshold_hours:.1f} hours). Uptime grace period " 94 | f"of {grace_period_hours:.1f} hours has not elapsed yet (uptime: {uptime_hours:.1f} " 95 | "hours). Exiting without warning." 96 | ) 97 | return False 98 | 99 | sdlog.info( 100 | f"Last successful update ({updated_hours_ago:.1f} hours ago) " 101 | f"is below the warning threshold ({warning_threshold_hours:.1f} hours). " 102 | "Exiting without warning." 103 | ) 104 | return False 105 | 106 | 107 | def get_uptime_seconds(): 108 | # Obtain current uptime 109 | with open("/proc/uptime") as f: 110 | return float(f.readline().split()[0]) 111 | -------------------------------------------------------------------------------- /tests/test_vms_exist.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | import unittest 4 | 5 | from tests.base import ( 6 | SD_DVM_TEMPLATES, 7 | SD_TAG, 8 | SD_TEMPLATE_BASE, 9 | SD_TEMPLATE_LARGE, 10 | SD_TEMPLATE_SMALL, 11 | SD_TEMPLATES, 12 | SD_UNTAGGED_DEPRECATED_VMS, 13 | SD_VMS, 14 | ) 15 | 16 | with open("config.json") as f: 17 | CONFIG = json.load(f) 18 | 19 | 20 | def test_all_sdw_vms_present(all_vms, sdw_tagged_vms): 21 | """ 22 | Confirm that all SDW-managed VMs are present on the system. 23 | Seeks to detect errors in provisioning that result in VMs 24 | failing to be created. Compares to a hardcoded list in fixtures. 25 | """ 26 | sdw_tagged_vm_names = [vm.name for vm in sdw_tagged_vms] 27 | expected_vms = set(SD_VMS + SD_DVM_TEMPLATES + SD_TEMPLATES) 28 | 29 | # This integration test suite will create an ephemeral "sd-viewer-disposable" VM, 30 | # and then destroy it, post-test-run. We can't assume the VM exists for general tests, 31 | # so we exclude it from the general shared-state fixture. The sd-viewer test suite 32 | # will handle targeting it with the appropriate tests, then clean up the DispVM. 33 | sdw_tagged_vm_names = [vm for vm in sdw_tagged_vms if vm != "sd-viewer-disposable"] 34 | 35 | assert set(sdw_tagged_vm_names) == set(expected_vms) 36 | 37 | # Check for untagged VMs 38 | for vm_name in SD_UNTAGGED_DEPRECATED_VMS: 39 | assert vm_name not in all_vms 40 | 41 | 42 | @unittest.skipIf(CONFIG["environment"] != "prod", "Skipping on non-prod system") 43 | def test_internal(all_vms): 44 | assert all_vms["sd-proxy-dvm"].features.get("internal") == "1" 45 | assert all_vms["sd-viewer"].features.get("internal") == "1" 46 | 47 | 48 | def test_grsec_kernel(sdw_tagged_vms): 49 | """ 50 | Confirms expected grsecurity-patched kernel is running. 51 | """ 52 | # base doesn't have kernel configured 53 | # TODO: test in sd-viewer based dispVM 54 | exceptions = [SD_TEMPLATE_BASE, "sd-viewer"] 55 | 56 | for vm in sdw_tagged_vms: 57 | if vm.name in exceptions: 58 | continue 59 | # Running custom kernel in PVH mode requires pvgrub2-pvh 60 | assert vm.virt_mode == "pvh" 61 | assert vm.kernel == "pvgrub2-pvh" 62 | 63 | # Check running kernel is grsecurity-patched 64 | stdout, stderr = vm.run("uname -r") 65 | assert stdout.decode().strip().endswith("-grsec-workstation") 66 | check_service_running(vm, "paxctld") 67 | 68 | 69 | def check_service_running(vm, service, running=True): 70 | """ 71 | Ensures a given service is running inside a given VM. 72 | Uses systemctl is-active to query the service state. 73 | """ 74 | try: 75 | cmd = f"systemctl is-active {service}" 76 | stdout, stderr = vm.run(cmd) 77 | service_status = stdout.decode("utf-8").rstrip() 78 | except subprocess.CalledProcessError as e: 79 | if e.returncode == 3: 80 | service_status = "inactive" 81 | else: 82 | raise e 83 | assert service_status == "active" if running else "inactive" 84 | 85 | 86 | def test_default_dispvm(sdw_tagged_vms): 87 | """Verify the default DispVM is none for all except sd-app and sd-devices""" 88 | for vm in sdw_tagged_vms: 89 | if vm.name == "sd-app": 90 | assert vm.default_dispvm.name == "sd-viewer" 91 | else: 92 | assert vm.default_dispvm is None, f"{vm.name} has dispVM set" 93 | 94 | 95 | def test_sd_whonix_absent(all_vms): 96 | """ 97 | The sd-whonix once existed to proxy sd-proxy's traffic through Tor. 98 | But we've since removed it and included a Tor proxy in sd-proxy. 99 | """ 100 | assert "sd-whonix" not in all_vms 101 | 102 | 103 | def test_whonix_vms_reset(all_vms): 104 | """ 105 | Whonix templates used to be modified by the workstation (<=1.4.0). 106 | Ensure they were properly reset. 107 | """ 108 | 109 | whonix_qubes = [ 110 | "whonix-workstation-17", 111 | "whonix-gateway-17", 112 | "sys-whonix", 113 | "anon-whonix", 114 | "whonix-workstation-17-dvm", 115 | ] 116 | for qube_name in whonix_qubes: 117 | if qube_name not in all_vms: 118 | # skip check on existent qubes 119 | continue 120 | qube = all_vms[qube_name] 121 | assert qube.property_is_default("kernelopts") 122 | 123 | 124 | def test_sd_small_template(all_vms): 125 | # Kernel check is handled in test_grsec_kernel 126 | vm = all_vms[SD_TEMPLATE_SMALL] 127 | nvm = vm.netvm 128 | assert nvm is None 129 | assert SD_TAG in vm.tags 130 | 131 | 132 | def test_sd_large_template(all_vms): 133 | # Kernel check is handled in test_grsec_kernel 134 | vm = all_vms[SD_TEMPLATE_LARGE] 135 | nvm = vm.netvm 136 | assert nvm is None 137 | assert SD_TAG in vm.tags 138 | -------------------------------------------------------------------------------- /tests/test_vm_sd_viewer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for validating SecureDrop Workstation config, 3 | specifically for the "sd-viewer" VM and related functionality. 4 | """ 5 | 6 | import os 7 | import subprocess 8 | 9 | import pytest 10 | from qubesadmin import Qubes 11 | 12 | from tests.base import ( 13 | SD_TAG, 14 | SD_TEMPLATE_LARGE, 15 | QubeWrapper, 16 | ) 17 | from tests.base import ( 18 | Test_SD_VM_Common as Test_SD_Viewer_Common, # noqa: F401 [HACK: import so base tests run] 19 | ) 20 | 21 | 22 | def _create_test_qube(dispvm_template_name): 23 | """ 24 | Provision and boot a DispVM to target with integration tests. 25 | We don't want to test `sd-viewer`, because that's an AppVM; 26 | rather, we want to ensure that a DispVM based on that AppVM 27 | is configured correctly. 28 | """ 29 | # VM was running and needs a restart to test on the latest version 30 | if dispvm_template_name in Qubes().domains: 31 | _shutdown_test_qube(dispvm_template_name) 32 | 33 | # Create disposable based on specified template 34 | qube_name = f"{dispvm_template_name}-disposable" 35 | cmd_create_disp = ( 36 | f"qvm-create --disp --property auto_cleanup=True " 37 | f"--template {dispvm_template_name} {qube_name}" 38 | ) 39 | subprocess.run(cmd_create_disp.split(), check=True) 40 | 41 | return qube_name 42 | 43 | 44 | def _shutdown_test_qube(qube_name): 45 | """ 46 | Gracefully power off the DispVM created for testing. 47 | """ 48 | subprocess.run(["qvm-shutdown", "--wait", qube_name], check=True) 49 | 50 | 51 | @pytest.fixture(scope="module") 52 | def qube(): 53 | """ 54 | Handles the creation of disposable qubes based on the provided DVM template. 55 | Written as a fixture, so that the test suite handles both creation during 56 | loading of the test module, via yield, and cleanup after the execution of 57 | all tests in the module, via the post-yield teardown logic. 58 | """ 59 | temp_qube_name = _create_test_qube("sd-viewer") 60 | 61 | yield QubeWrapper( 62 | temp_qube_name, 63 | expected_config_keys={"SD_MIME_HANDLING"}, 64 | # this is not a comprehensive list, just a few that users are likely to use 65 | enforced_apparmor_profiles={ 66 | "/usr/bin/evince", 67 | "/usr/bin/evince-previewer", 68 | "/usr/bin/evince-previewer//sanitized_helper", 69 | "/usr/bin/evince-thumbnailer", 70 | "/usr/bin/totem", 71 | "/usr/bin/totem-audio-preview", 72 | "/usr/bin/totem-video-thumbnailer", 73 | "/usr/bin/totem//sanitized_helper", 74 | }, 75 | ) 76 | 77 | # Tear Down 78 | _shutdown_test_qube(temp_qube_name) 79 | 80 | 81 | def test_sd_viewer_metapackage_installed(qube): 82 | assert qube.package_is_installed("securedrop-workstation-viewer") 83 | assert not qube.package_is_installed("securedrop-workstation-svs-disp") 84 | 85 | 86 | def test_sd_viewer_evince_installed(qube): 87 | pkg = "evince" 88 | assert qube.package_is_installed(pkg) 89 | 90 | 91 | def test_sd_viewer_libreoffice_installed(qube): 92 | assert qube.package_is_installed("libreoffice") 93 | 94 | 95 | def test_logging_configured(qube): 96 | qube.logging_configured() 97 | 98 | 99 | def test_redis_packages_not_installed(qube): 100 | """ 101 | Only the log collector, i.e. sd-log, needs redis, so redis will be 102 | present in small template, but not in large. 103 | """ 104 | assert not qube.package_is_installed("redis") 105 | assert not qube.package_is_installed("redis-server") 106 | 107 | 108 | def test_mime_types(qube): 109 | filepath = os.path.join( 110 | os.path.dirname(os.path.abspath(__file__)), "vars", "sd-viewer.mimeapps" 111 | ) 112 | with open(filepath) as f: 113 | lines = f.readlines() 114 | for line in lines: 115 | if line != "[Default Applications]\n" and not line.startswith("#"): 116 | mime_type = line.split("=")[0] 117 | expected_app = line.split("=")[1].rstrip() 118 | actual_app = qube.run(f"xdg-mime query default {mime_type}") 119 | assert actual_app == expected_app 120 | 121 | 122 | def test_mimetypes_service(qube): 123 | qube.service_is_active("securedrop-mime-handling") 124 | 125 | 126 | def test_mailcap_hardened(qube): 127 | qube.mailcap_hardened() 128 | 129 | 130 | def test_mimetypes_symlink(qube): 131 | assert qube.fileExists(".local/share/applications/mimeapps.list") 132 | symlink_location = qube.get_symlink_location(".local/share/applications/mimeapps.list") 133 | assert symlink_location == "/opt/sdw/mimeapps.list.sd-viewer" 134 | 135 | 136 | def test_sd_viewer_config(all_vms): 137 | """ 138 | Confirm that qvm-prefs match expectations for the "sd-viewer" VM. 139 | """ 140 | vm = all_vms["sd-viewer"] 141 | nvm = vm.netvm 142 | assert nvm is None 143 | assert vm.template.name == SD_TEMPLATE_LARGE 144 | assert not vm.provides_network 145 | assert vm.template_for_dispvms 146 | assert SD_TAG in vm.tags 147 | 148 | # MIME handling 149 | assert vm.features["service.securedrop-mime-handling"] == "1" 150 | assert vm.features["vm-config.SD_MIME_HANDLING"] == "sd-viewer" 151 | -------------------------------------------------------------------------------- /scripts/switch-apt-source.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | Switch APT repositories in the Debian templates. 4 | 5 | This only switches the /etc/apt/sources.list.d files and does not on 6 | its own change any of the packages, it's expected you'll use the updater 7 | or some other mechanism for that. 8 | 9 | Usage: 10 | ./scripts/switch-apt-source.py [dev|staging|prod] 11 | """ 12 | 13 | import argparse 14 | import json 15 | import subprocess 16 | import sys 17 | from pathlib import Path 18 | 19 | CONFIG_JSON = Path("/usr/share/securedrop-workstation-dom0-config/config.json") 20 | SOURCES_DIR = Path(__file__).parent.parent / "securedrop_salt" 21 | 22 | OPTIONS = ["dev", "staging", "prod"] 23 | CODENAME = "bookworm" 24 | TEMPLATES = [ 25 | "sd-small-bookworm-template", 26 | "sd-large-bookworm-template", 27 | ] 28 | TEST_COMPONENTS = { 29 | "dev": "main nightlies", 30 | "staging": "main", 31 | } 32 | 33 | TEST_SOURCES_FILE = "apt-test_freedom_press.sources" 34 | TEST_SOURCES_TEMPLATE = SOURCES_DIR / f"{TEST_SOURCES_FILE}.j2" 35 | 36 | 37 | def parse_args(): 38 | """Parse command line arguments.""" 39 | parser = argparse.ArgumentParser( 40 | description="Switch apt sources between dev, staging, and prod" 41 | ) 42 | parser.add_argument( 43 | "environment", 44 | choices=OPTIONS, 45 | help="Target environment (dev, staging, or prod)", 46 | ) 47 | return parser.parse_args() 48 | 49 | 50 | def check_dom0(): 51 | """Verify we're running in dom0.""" 52 | hostname = subprocess.check_output(["hostname"], text=True).strip() 53 | if hostname != "dom0": 54 | print(f"Error: This script must be run in dom0, not {hostname}", file=sys.stderr) 55 | sys.exit(1) 56 | 57 | 58 | def update_config_json(environment): 59 | """Update the environment field in config.json.""" 60 | # Read existing config as root 61 | result = subprocess.run( 62 | ["sudo", "cat", str(CONFIG_JSON)], 63 | capture_output=True, 64 | text=True, 65 | check=True, 66 | ) 67 | config = json.loads(result.stdout) 68 | 69 | current_env = config["environment"] 70 | if current_env == environment: 71 | print(f"Config already set to environment: {environment}") 72 | return current_env 73 | 74 | config["environment"] = environment 75 | 76 | # Write updated config as root 77 | config_content = json.dumps(config, indent=2) + "\n" 78 | subprocess.run( 79 | ["sudo", "tee", str(CONFIG_JSON)], 80 | input=config_content, 81 | text=True, 82 | stdout=subprocess.DEVNULL, 83 | check=True, 84 | ) 85 | 86 | print(f"Updated config.json: {current_env} → {environment}") 87 | return current_env 88 | 89 | 90 | def render_test_sources_file(component: str) -> str: 91 | """Emulate jinja2 so we can render sources template.""" 92 | template = TEST_SOURCES_TEMPLATE.read_text() 93 | 94 | # Simple Jinja2 variable substitution 95 | return template.replace("{{ codename }}", CODENAME).replace("{{ component }}", component) 96 | 97 | 98 | def shutdown_template(template): 99 | print(f" Shutting down {template}...") 100 | subprocess.check_call( 101 | ["qvm-shutdown", template], 102 | stdout=subprocess.DEVNULL, 103 | stderr=subprocess.DEVNULL, 104 | ) 105 | 106 | 107 | def update_template_sources(template, environment): 108 | """Update apt sources in a template VM.""" 109 | test_sources_path = f"/etc/apt/sources.list.d/{TEST_SOURCES_FILE}" 110 | 111 | # Write apt-test sources file 112 | if environment in ("dev", "staging"): 113 | sources_content = render_test_sources_file(TEST_COMPONENTS[environment]) 114 | print(f" Writing {test_sources_path}...") 115 | proc = subprocess.Popen( 116 | ["qvm-run", "--pass-io", template, f"sudo tee {test_sources_path} > /dev/null"], 117 | stdin=subprocess.PIPE, 118 | text=True, 119 | ) 120 | proc.communicate(input=sources_content) 121 | proc.wait() 122 | 123 | # Remove apt-test sources file if we're on prod 124 | if environment == "prod": 125 | print(f" Removing {test_sources_path}...") 126 | subprocess.check_call( 127 | ["qvm-run", "--pass-io", template, f"sudo rm -f {test_sources_path}"], 128 | stdout=subprocess.DEVNULL, 129 | stderr=subprocess.DEVNULL, 130 | ) 131 | 132 | print(f" ✓ Successfully updated {template}") 133 | 134 | shutdown_template(template) 135 | 136 | 137 | def main(): 138 | """Main entry point.""" 139 | args = parse_args() 140 | check_dom0() 141 | 142 | print(f"\nSwitching apt sources to: {args.environment}") 143 | print() 144 | 145 | # Update config.json 146 | old_env = update_config_json(args.environment) 147 | 148 | # Check if we're moving down environments 149 | if OPTIONS.index(args.environment) > OPTIONS.index(old_env): 150 | print( 151 | f"Warning: moving from {old_env} to {args.environment}, " 152 | "which may not properly downgrade packages." 153 | ) 154 | 155 | # Start both templates 156 | print("\nStarting templates...") 157 | subprocess.check_call( 158 | ["qvm-start", "--skip-if-running"] + TEMPLATES, 159 | stdout=subprocess.DEVNULL, 160 | stderr=subprocess.DEVNULL, 161 | ) 162 | 163 | # Update templates 164 | print("\nUpdating templates...") 165 | for template in TEMPLATES: 166 | print(f"\n{template}:") 167 | update_template_sources(template, args.environment) 168 | 169 | print("\n" + "=" * 60) 170 | print("✓ All templates updated successfully") 171 | return 0 172 | 173 | 174 | if __name__ == "__main__": 175 | sys.exit(main()) 176 | -------------------------------------------------------------------------------- /securedrop_salt/sd-sys-vms.sls: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # vim: set syntax=yaml ts=2 sw=2 sts=2 et : 3 | # 4 | # Ensures that sys-* VMs (viz. sys-net, sys-firewall, sys-usb) use 5 | # an up-to-date version of Fedora, in order to receive security updates. 6 | 7 | include: 8 | # Import the upstream Qubes-maintained default-dispvm to ensure Fedora-based 9 | # DispVM is created 10 | - qvm.default-dispvm 11 | 12 | # 4.2 fedora template is fedora-NN-xfce, but let's keep the dvm names to 13 | # follow simple - like sd-fedora-NN-dvm 14 | {% set sd_supported_fedora_version = 'fedora-42' %} 15 | {% set sd_fedora_base_template = sd_supported_fedora_version + '-xfce' %} 16 | 17 | {% set gui_user = salt['cmd.shell']('groupmems -l -g qubes') %} 18 | 19 | # Install latest templates required for SDW VMs. 20 | dom0-install-fedora-template: 21 | qvm.template_installed: 22 | - name: {{ sd_fedora_base_template }} 23 | 24 | # Update the mgmt VM before updating the new Fedora VM. The order is required 25 | set-fedora-template-as-default-mgmt-dvm: 26 | cmd.run: 27 | - name: > 28 | qvm-shutdown --wait default-mgmt-dvm && 29 | qvm-prefs default-mgmt-dvm template {{ sd_fedora_base_template }} 30 | - require: 31 | - qvm: dom0-install-fedora-template 32 | 33 | # If the VM has just been installed via qvm-template, update it immediately. 34 | # This is to ensure management VMs are up-to-date. When this state is run via 35 | # the GUI updater (as part of its routine dom0 highstate run), it ensures that 36 | # updates are applied to a new template even if the running updater has a stale list 37 | # (see https://github.com/freedomofpress/securedrop-workstation/issues/758). 38 | update-fedora-template-if-new: 39 | cmd.wait: 40 | - name: qubes-vm-update --quiet --force-update --targets {{ sd_fedora_base_template }} 41 | - runas: {{ gui_user }} 42 | - require: 43 | - qvm: dom0-install-fedora-template 44 | # Update the mgmt-dvm setting first, to avoid problems during first update 45 | - cmd: set-fedora-template-as-default-mgmt-dvm 46 | - onchanges: 47 | - qvm: dom0-install-fedora-template 48 | 49 | # qvm.default-dispvm is not strictly required here, but we want it to be 50 | # updated as soon as possible to ensure make clean completes successfully, as 51 | # is sets the default_dispvm to the DispVM based on the wanted Fedora version. 52 | set-fedora-default-template-version: 53 | cmd.run: 54 | - name: qubes-prefs default_template {{ sd_fedora_base_template }} 55 | - require: 56 | - qvm: dom0-install-fedora-template 57 | - sls: qvm.default-dispvm 58 | 59 | # On 4.1, several sys qubes are disposable by default - since we also want to 60 | # upgrade the templates for those, we need to ensure that the respective dvms 61 | # exist, as just installing a new template won't create a DispVM template 62 | # automatically. 63 | # sys-usb is also disposable by default but a special case as we want to 64 | # customize the underlying DispVM template for usability purposes: we want to 65 | # consistently auto-attach USB devices to our sd-devices qube 66 | # 67 | {% set required_dispvms = [ sd_supported_fedora_version + '-dvm' ] %} 68 | {% if salt['pillar.get']('qvm:sys-usb:disposable', true) %} 69 | {% set _ = required_dispvms.append("sd-" + sd_supported_fedora_version + "-dvm") %} 70 | {% endif %} 71 | 72 | {% for required_dispvm in required_dispvms %} 73 | create-{{ required_dispvm }}: 74 | qvm.vm: 75 | - name: {{ required_dispvm }} 76 | - present: 77 | - label: red 78 | - template: {{ sd_fedora_base_template }} 79 | - prefs: 80 | - template: {{ sd_fedora_base_template }} 81 | - template_for_dispvms: True 82 | {% if required_dispvm == 'sd-' + sd_supported_fedora_version + '-dvm' %} 83 | - netvm: "" 84 | {% endif %} 85 | - require: 86 | - qvm: dom0-install-fedora-template 87 | {% endfor %} 88 | 89 | # Now proceed with rebooting all the sys-* VMs, since the new template is up to date. 90 | 91 | {% for sys_vm in ['sys-usb', 'sys-net', 'sys-firewall'] %} 92 | {% if salt['pillar.get']('qvm:' + sys_vm + ':disposable', false) %} 93 | # As of Qubes 4.1, certain sys-* VMs will be DispVMs by default. 94 | {% if sys_vm == 'sys-usb' %} 95 | # If sys-usb is disposable, we want it to use the template we just created so we 96 | # can customize it later in the process 97 | {% set sd_supported_fedora_template = 'sd-' + sd_supported_fedora_version + '-dvm' %} 98 | {% else %} 99 | {% set sd_supported_fedora_template = sd_supported_fedora_version + '-dvm' %} 100 | {% endif %} 101 | {% else %} 102 | {% set sd_supported_fedora_template = sd_fedora_base_template %} 103 | {% endif %} 104 | {% if salt['cmd.shell']('qvm-prefs ' + sys_vm + ' template') != sd_supported_fedora_template %} 105 | sd-{{ sys_vm }}-fedora-version-halt: 106 | qvm.kill: 107 | - name: {{ sys_vm }} 108 | - require: 109 | - qvm: dom0-install-fedora-template 110 | 111 | sd-{{ sys_vm }}-fedora-version-halt-wait: 112 | cmd.run: 113 | - name: sleep 5 114 | - require: 115 | - qvm: dom0-install-fedora-template 116 | 117 | sd-{{ sys_vm }}-fedora-version-update: 118 | qvm.vm: 119 | - name: {{ sys_vm }} 120 | - prefs: 121 | - template: {{ sd_supported_fedora_template }} 122 | - require: 123 | - cmd: sd-{{ sys_vm }}-fedora-version-halt-wait 124 | {% if sd_supported_fedora_template.endswith("-dvm") %} 125 | - qvm: create-{{ sd_supported_fedora_template }} 126 | {% endif %} 127 | 128 | # Finally, remove the old supported fedora DVM we created. We won't uninstall 129 | # the template, in case it's being used elsewhere, but the `sd-` VMs we can 130 | # reasonably manage (remove) ourselves. 131 | {% if sys_vm == "sys-usb" %} 132 | remove-sd-fedora-41-dvm: 133 | qvm.absent: 134 | - name: sd-fedora-41-dvm 135 | - require: 136 | - qvm: sd-sys-usb-fedora-version-update 137 | {% endif %} 138 | 139 | sd-{{ sys_vm }}-fedora-version-start: 140 | qvm.start: 141 | - name: {{ sys_vm }} 142 | - require: 143 | - qvm: sd-{{ sys_vm }}-fedora-version-update 144 | {% endif %} 145 | {% endfor %} 146 | 147 | -------------------------------------------------------------------------------- /sdw_util/Util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions used by both the updater and notifier scripts 3 | """ 4 | 5 | import fcntl 6 | import logging 7 | import os 8 | import re 9 | import subprocess 10 | from logging.handlers import TimedRotatingFileHandler 11 | 12 | # The directory where status files and logs are stored 13 | BASE_DIRECTORY = os.path.join(os.path.expanduser("~"), ".securedrop_updater") 14 | 15 | # Directory for lock files to avoid contention or multiple instantiation. 16 | LOCK_DIRECTORY = os.path.join("/run/user", str(os.getuid())) 17 | 18 | # Folder where logs are stored 19 | LOG_DIRECTORY = os.path.join(BASE_DIRECTORY, "logs") 20 | 21 | # File that contains Qubes version information (overridden by tests) 22 | OS_RELEASE_FILE = "/etc/os-release" 23 | 24 | # Shared error string 25 | LOCK_ERROR = "Error obtaining lock on '{}'. Process may already be running." 26 | 27 | # Format for those logs 28 | LOG_FORMAT = "%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) " "%(levelname)s: %(message)s" 29 | 30 | # Namespace for primary logger, additional namespaces should be defined by module user 31 | SD_LOGGER_PREFIX = "sd" 32 | 33 | sdlog = logging.getLogger(SD_LOGGER_PREFIX + "." + __name__) 34 | 35 | 36 | def configure_logging(log_file, logger_namespace=SD_LOGGER_PREFIX, backup_count=0): 37 | """ 38 | All logging related settings are set up by this function. 39 | 40 | `log_file` - the filename 41 | `logger_namespace - the prefix used for all log events by this logger 42 | `backup_count` - if nonzero, at most backup_count files will be kept 43 | """ 44 | 45 | if not os.path.exists(LOG_DIRECTORY): 46 | os.makedirs(LOG_DIRECTORY) 47 | 48 | formatter = logging.Formatter(LOG_FORMAT) 49 | 50 | handler = TimedRotatingFileHandler( 51 | os.path.join(LOG_DIRECTORY, log_file), backupCount=backup_count 52 | ) 53 | handler.setFormatter(formatter) 54 | handler.setLevel(logging.INFO) 55 | 56 | log = logging.getLogger(logger_namespace) 57 | log.setLevel(logging.INFO) 58 | log.addHandler(handler) 59 | 60 | 61 | def obtain_lock(basename): 62 | """ 63 | Obtain an exclusive lock during the execution of this process. 64 | """ 65 | lock_file = os.path.join(LOCK_DIRECTORY, basename) 66 | try: 67 | lh = open(lock_file, "w") 68 | except PermissionError: 69 | sdlog.error( 70 | f"Error writing to lock file '{lock_file}'. User may lack the required permissions." 71 | ) 72 | return None 73 | 74 | try: 75 | # Obtain an exclusive, nonblocking lock 76 | fcntl.lockf(lh, fcntl.LOCK_EX | fcntl.LOCK_NB) 77 | except OSError: 78 | sdlog.error(LOCK_ERROR.format(lock_file)) 79 | return None 80 | 81 | return lh 82 | 83 | 84 | def can_obtain_lock(basename): 85 | """ 86 | We temporarily obtain a shared, nonblocking lock to a lockfile to determine 87 | whether the associated process is currently running. Returns True if it is 88 | safe to continue execution (no lock conflict), False if not. 89 | 90 | `basename` is the basename of a lockfile situated in the LOCK_DIRECTORY. 91 | """ 92 | lock_file = os.path.join(LOCK_DIRECTORY, basename) 93 | try: 94 | lh = open(lock_file) 95 | except FileNotFoundError: 96 | # Process may not have run during this session, safe to continue 97 | return True 98 | 99 | try: 100 | # Obtain a nonblocking, shared lock 101 | fcntl.lockf(lh, fcntl.LOCK_SH | fcntl.LOCK_NB) 102 | except OSError: 103 | sdlog.error(LOCK_ERROR.format(lock_file)) 104 | return False 105 | 106 | return True 107 | 108 | 109 | def is_conflicting_process_running(list): 110 | """ 111 | Check if any process of the given name is currently running. Aborts on the 112 | first match. 113 | """ 114 | for name in list: 115 | result = subprocess.run( 116 | args=["pgrep", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False 117 | ) 118 | if result.returncode == 0: 119 | sdlog.error(f"Conflicting process '{name}' is currently running.") 120 | return True 121 | return False 122 | 123 | 124 | def get_qubes_version(): 125 | """ 126 | Helper function for checking the Qubes version. Returns None if not on Qubes. 127 | """ 128 | is_qubes = False 129 | version = None 130 | try: 131 | with open(OS_RELEASE_FILE) as f: 132 | for line in f: 133 | try: 134 | key, value = line.rstrip().split("=") 135 | except ValueError: 136 | continue 137 | 138 | if key == "NAME" and "qubes" in value.lower(): 139 | is_qubes = True 140 | if key == "VERSION": 141 | version = value 142 | except FileNotFoundError: 143 | return None 144 | 145 | if not is_qubes: 146 | return None 147 | 148 | return version 149 | 150 | 151 | def get_logger(prefix=SD_LOGGER_PREFIX, module=None): 152 | if module is None: 153 | return logging.getLogger(prefix) 154 | 155 | return logging.getLogger(prefix + "." + module) 156 | 157 | 158 | def strip_ansi_colors(str): 159 | """ 160 | Strip ANSI colors from command output 161 | """ 162 | return re.sub(r"\u001b\[.*?[@-~]", "", str) 163 | 164 | 165 | def is_sdapp_halted() -> bool: 166 | """ 167 | Helper fuction that returns True if `sd-app` VM is in a halted state 168 | and False if state is running, paused, or cannot be determined. 169 | 170 | Runs only if Qubes environment detected; otherwise returns False. 171 | """ 172 | 173 | if not get_qubes_version(): 174 | sdlog.error("QubesOS not detected, is_sdapp_halted will return False") 175 | return False 176 | 177 | try: 178 | output_bytes = subprocess.check_output(["qvm-ls", "sd-app"]) 179 | output_str = output_bytes.decode("utf-8") 180 | return "Halted" in output_str 181 | 182 | except subprocess.CalledProcessError as e: 183 | sdlog.error("Failed to return sd-app VM status via qvm-ls") 184 | sdlog.error(str(e)) 185 | return False 186 | --------------------------------------------------------------------------------