Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/instructions/snyk_rules.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
alwaysApply: true
always_on: true
trigger: always_on
applyTo: "**"
description: Snyk Security At Inception
---

# Project security best practices

- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
- Repeat this process until no new issues are found.
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
security:
name: Security audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Upgrade pip and install pip-audit
run: python -m pip install --upgrade pip pip-audit
- name: Run pip-audit
run: python -m pip_audit -r requirements.txt -f json
- name: Generate CycloneDX SBOM
run: |
python tools/generate_cyclonedx_sbom.py
- name: Upload SBOM artifacts
uses: actions/upload-artifact@v4
with:
name: sbom
path: |
sbom/pip-sbom.json
sbom/cyclonedx-bom.json

test:
name: Run tests
runs-on: ubuntu-latest
needs: security
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install runtime deps for tests
run: python -m pip install --upgrade pip Pillow pytest
- name: Run pytest
run: pytest -q

lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install linter
run: python -m pip install --upgrade pip flake8
- name: Run flake8 (non-failing)
run: flake8 --config .flake8 . --exit-zero
21 changes: 21 additions & 0 deletions contrib/bjorn.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[Unit]
Description=Bjorn daemon
After=network.target

[Service]
Type=simple
# Change User and Group to the dedicated account you created (e.g., 'bjorn')
User=bjorn
Group=bjorn
WorkingDirectory=/opt/bjorn
# Use the system python or a virtualenv python binary
ExecStart=/opt/bjorn/.venv/Scripts/python.exe /opt/bjorn/Bjorn.py
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

# Notes:
# - Adjust WorkingDirectory and ExecStart to match where you installed Bjorn.
# - On Linux systems using typical virtualenvs, ExecStart might be `/opt/bjorn/.venv/bin/python3 /opt/bjorn/Bjorn.py`.
83 changes: 83 additions & 0 deletions docs/nginx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Bjorn — Nginx reverse proxy + TLS (guide)

This document shows a minimal, secure way to expose Bjorn's web UI to the internet using
an Nginx reverse proxy with Let's Encrypt TLS. The Bjorn built-in web server should be
bound to `127.0.0.1:8000` (local only) — this repository already sets that by default.

Replace `example.com` with your real domain name.

## Nginx server block
Create `/etc/nginx/sites-available/bjorn` with the following content:

```nginx
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
server_name example.com;

ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;

location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
```

Enable the site and reload Nginx:

```bash
sudo ln -s /etc/nginx/sites-available/bjorn /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```

## Obtain TLS certificates (Certbot)
Install certbot and obtain a certificate using the Nginx plugin:

```bash
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
```

Follow prompts; certbot will modify the Nginx configuration and install certificates.

## Firewall
Allow only HTTP/HTTPS and SSH through the firewall, and ensure Bjorn's port is not publicly reachable:

```bash
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable
# If Bjorn is bound to 127.0.0.1 this is optional, otherwise explicitly deny:
sudo ufw deny 8000
```

## Bjorn configuration
- Ensure `shared_config.json` includes a strong `web_password` and `web_auth` set to `true`.
- The repository config already sets the web server to bind to `127.0.0.1` — recommended.

## Additional hardening
- Run Bjorn under a dedicated, unprivileged user (e.g., `bjorn`).
- Limit Nginx access by IP (if appropriate) with `allow`/`deny` directives.
- Monitor logs and set up automatic certificate renewal (certbot already creates a cron job/systemd timer).

```
37 changes: 37 additions & 0 deletions requirements-pinned.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
boolean.py==5.0
CacheControl==0.14.4
certifi==2026.1.4
charset-normalizer==3.4.4
colorama==0.4.6
cyclonedx-python-lib==11.6.0
defusedxml==0.7.1
filelock==3.21.2
flake8==7.3.0
idna==3.11
iniconfig==2.3.0
license-expression==30.4.4
markdown-it-py==4.0.0
mccabe==0.7.0
mdurl==0.1.2
msgpack==1.1.2
packageurl-python==0.17.6
packaging==26.0
pillow==12.1.1
pip-api==0.0.34
pip-requirements-parser==32.0.1
pip_audit==2.10.0
platformdirs==4.7.1
pluggy==1.6.0
py-serializable==2.1.0
pycodestyle==2.14.0
pyflakes==3.4.0
Pygments==2.19.2
pyparsing==3.3.2
pytest==9.0.2
requests==2.32.5
rich==14.3.2
sortedcontainers==2.4.0
tomli==2.4.0
tomli_w==1.2.0
typing_extensions==4.15.0
urllib3==2.6.3
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
RPi.GPIO==0.7.1
spidev==3.5
Pillow==9.4.0
Pillow>=10.3.0
numpy==2.1.3
rich==13.9.4
pandas==2.2.3
Expand Down
1 change: 1 addition & 0 deletions sbom/pip-sbom.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"name": "boolean.py", "version": "5.0"}, {"name": "CacheControl", "version": "0.14.4"}, {"name": "certifi", "version": "2026.1.4"}, {"name": "charset-normalizer", "version": "3.4.4"}, {"name": "colorama", "version": "0.4.6"}, {"name": "cyclonedx-python-lib", "version": "11.6.0"}, {"name": "defusedxml", "version": "0.7.1"}, {"name": "filelock", "version": "3.21.2"}, {"name": "flake8", "version": "7.3.0"}, {"name": "idna", "version": "3.11"}, {"name": "iniconfig", "version": "2.3.0"}, {"name": "license-expression", "version": "30.4.4"}, {"name": "markdown-it-py", "version": "4.0.0"}, {"name": "mccabe", "version": "0.7.0"}, {"name": "mdurl", "version": "0.1.2"}, {"name": "msgpack", "version": "1.1.2"}, {"name": "packageurl-python", "version": "0.17.6"}, {"name": "packaging", "version": "26.0"}, {"name": "pillow", "version": "12.1.1"}, {"name": "pip", "version": "26.0.1"}, {"name": "pip-api", "version": "0.0.34"}, {"name": "pip_audit", "version": "2.10.0"}, {"name": "pip-requirements-parser", "version": "32.0.1"}, {"name": "platformdirs", "version": "4.7.1"}, {"name": "pluggy", "version": "1.6.0"}, {"name": "py-serializable", "version": "2.1.0"}, {"name": "pycodestyle", "version": "2.14.0"}, {"name": "pyflakes", "version": "3.4.0"}, {"name": "Pygments", "version": "2.19.2"}, {"name": "pyparsing", "version": "3.3.2"}, {"name": "pytest", "version": "9.0.2"}, {"name": "requests", "version": "2.32.5"}, {"name": "rich", "version": "14.3.2"}, {"name": "sortedcontainers", "version": "2.4.0"}, {"name": "tomli", "version": "2.4.0"}, {"name": "tomli_w", "version": "1.2.0"}, {"name": "typing_extensions", "version": "4.15.0"}, {"name": "urllib3", "version": "2.6.3"}]
3 changes: 3 additions & 0 deletions shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ def get_default_config(self):
"image_display_delaymax": 8,
"scan_interval": 180,
"scan_vuln_interval": 900,
"web_auth": True,
"web_user": "bjorn",
"web_password": "", # set a strong password after install
"failed_retry_delay": 600,
"success_retry_delay": 900,
"ref_width" :122 ,
Expand Down
53 changes: 53 additions & 0 deletions tools/generate_cyclonedx_sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Generate a minimal CycloneDX SBOM (JSON) from pip list JSON (sbom/pip-sbom.json).

Writes output to `sbom/cyclonedx-bom.json`.
"""
import json
import os
from datetime import datetime


def main():
sbom_dir = os.path.join(os.path.dirname(__file__), '..', 'sbom')
pip_sbom_path = os.path.join(sbom_dir, 'pip-sbom.json')
out_path = os.path.join(sbom_dir, 'cyclonedx-bom.json')

if not os.path.exists(pip_sbom_path):
print(f"Input SBOM not found: {pip_sbom_path}")
return 1

with open(pip_sbom_path, 'r', encoding='utf-8') as f:
pkgs = json.load(f)

components = []
for p in pkgs:
comp = {
"type": "library",
"name": p.get('name'),
"version": p.get('version')
}
components.append(comp)

bom = {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": f"urn:uuid:{datetime.utcnow().isoformat()}",
"version": 1,
"metadata": {
"timestamp": datetime.utcnow().isoformat() + "Z",
"tools": [{"vendor": "local", "name": "generate_cyclonedx_sbom.py", "version": "1"}]
},
"components": components,
}

os.makedirs(sbom_dir, exist_ok=True)
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(bom, f, indent=2, ensure_ascii=False)

print(f"Wrote CycloneDX SBOM to {out_path}")
return 0


if __name__ == '__main__':
raise SystemExit(main())
40 changes: 39 additions & 1 deletion webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import sys
import signal
import base64
import os
import gzip
import io
Expand All @@ -23,6 +24,10 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
self.shared_data = shared_data
self.web_utils = WebUtils(shared_data, logger)
# Web auth config
self.web_auth_enabled = bool(self.shared_data.config.get('web_auth', False))
self.web_auth_user = self.shared_data.config.get('web_user', '')
self.web_auth_password = self.shared_data.config.get('web_password', '')
super().__init__(*args, **kwargs)

def log_message(self, format, *args):
Expand Down Expand Up @@ -57,6 +62,11 @@ def serve_file_gzipped(self, file_path, content_type):
self.send_gzipped_response(content, content_type)

def do_GET(self):
# Enforce Basic Auth if enabled
if self.web_auth_enabled:
if not self.check_auth():
self.request_auth()
return
# Handle GET requests. Serve the HTML interface and the EPD image.
if self.path == '/index.html' or self.path == '/':
self.serve_file_gzipped(os.path.join(self.shared_data.webdir, 'index.html'), 'text/html')
Expand Down Expand Up @@ -116,6 +126,11 @@ def do_GET(self):
super().do_GET()

def do_POST(self):
# Enforce Basic Auth for POST requests
if self.web_auth_enabled:
if not self.check_auth():
self.request_auth()
return
# Handle POST requests for saving configuration, connecting to Wi-Fi, clearing files, rebooting, and shutting down.
if self.path == '/save_config':
self.web_utils.save_configuration(self)
Expand Down Expand Up @@ -150,6 +165,29 @@ def do_POST(self):
self.send_response(404)
self.end_headers()

def check_auth(self):
"""Validate HTTP Basic Authorization header against configured credentials."""
auth_header = self.headers.get('Authorization')
if not auth_header:
return False
try:
scheme, encoded = auth_header.split(' ', 1)
if scheme.lower() != 'basic':
return False
decoded = base64.b64decode(encoded.strip()).decode('utf-8')
user, pwd = decoded.split(':', 1)
return user == self.web_auth_user and pwd == self.web_auth_password
except Exception:
return False

def request_auth(self):
"""Send a 401 response that enables Basic auth in the client."""
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm="Bjorn Web UI"')
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b'Authentication required')

class WebThread(threading.Thread):
"""
Thread to run the web server serving the EPD display interface.
Expand All @@ -167,7 +205,7 @@ def run(self):
"""
while not self.shared_data.webapp_should_exit:
try:
with socketserver.TCPServer(("", self.port), self.handler_class) as httpd:
with socketserver.TCPServer(("127.0.0.1", self.port), self.handler_class) as httpd:
self.httpd = httpd
logger.info(f"Serving at port {self.port}")
while not self.shared_data.webapp_should_exit:
Expand Down