Fix review findings: offline assets, security, audit logging

- Bundle Bootstrap CSS/JS/icons locally for air-gapped operation
- Add path traversal validation on image import source
- Disable Flask debug mode in production
- Fix file handle leaks, remove unused import
- Add python3-pip, python3-venv, p7zip-full to offline packages
- Add pip wheel download/bundling for offline Flask install
- Change UFW default policy from allow to deny
- Fix wrong path displayed in unattend editor template
- Dynamic sidebar image lists from all_image_types
- Add audit logging for all write operations
- Audit log viewer page with activity history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-06 16:50:20 -05:00
parent ef7583920b
commit 92c9b0f762
13 changed files with 187 additions and 37 deletions

3
.gitignore vendored
View File

@@ -25,6 +25,9 @@ offline-packages/
# Boot tool binaries (built by prepare-boot-tools.sh) # Boot tool binaries (built by prepare-boot-tools.sh)
boot-tools/ boot-tools/
# Python wheels for offline install (built by download-packages.sh)
pip-wheels/
# Deployment images (imported via webapp or USB) # Deployment images (imported via webapp or USB)
geastandardpbr/ geastandardpbr/

View File

@@ -185,6 +185,15 @@ if [ -d "$WEBAPP_DIR" ]; then
echo " Copied webapp/" echo " Copied webapp/"
fi fi
# Copy pip wheels for offline Flask install
PIP_WHEELS_DIR="$SCRIPT_DIR/pip-wheels"
if [ -d "$PIP_WHEELS_DIR" ]; then
cp -r "$PIP_WHEELS_DIR" "$MOUNT_POINT/pip-wheels"
echo " Copied pip-wheels/"
else
echo " No pip-wheels/ found (run download-packages.sh first)"
fi
# Copy boot tools (Clonezilla, Blancco, Memtest) if prepared # Copy boot tools (Clonezilla, Blancco, Memtest) if prepared
BOOT_TOOLS_DIR="$SCRIPT_DIR/boot-tools" BOOT_TOOLS_DIR="$SCRIPT_DIR/boot-tools"
if [ -d "$BOOT_TOOLS_DIR" ]; then if [ -d "$BOOT_TOOLS_DIR" ]; then

View File

@@ -26,6 +26,9 @@ PLAYBOOK_PACKAGES=(
ufw ufw
cron cron
wimtools wimtools
python3-pip
python3-venv
p7zip-full
) )
# Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.) # Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.)
@@ -64,21 +67,28 @@ DEP_COUNT=$(echo "$DEPS" | wc -l)
echo " Found $DEP_COUNT packages (including dependencies)" echo " Found $DEP_COUNT packages (including dependencies)"
# Download all packages # Download all packages
echo "[3/3] Downloading packages to $OUT_DIR..." echo "[3/4] Downloading .deb packages to $OUT_DIR..."
cd "$OUT_DIR" cd "$OUT_DIR"
apt-get download $DEPS 2>&1 | tail -5 apt-get download $DEPS 2>&1 | tail -5
DEB_COUNT=$(ls -1 *.deb 2>/dev/null | wc -l) DEB_COUNT=$(ls -1 *.deb 2>/dev/null | wc -l)
TOTAL_SIZE=$(du -sh . | cut -f1) TOTAL_SIZE=$(du -sh . | cut -f1)
echo " $DEB_COUNT packages ($TOTAL_SIZE)"
# Download pip wheels for Flask webapp (offline install)
echo "[4/4] Downloading Python wheels for webapp..."
PIP_DIR="$(dirname "$OUT_DIR")/pip-wheels"
mkdir -p "$PIP_DIR"
pip3 download -d "$PIP_DIR" flask lxml 2>&1 | tail -5
WHL_COUNT=$(ls -1 "$PIP_DIR"/*.whl "$PIP_DIR"/*.tar.gz 2>/dev/null | wc -l)
echo " $WHL_COUNT Python packages downloaded to pip-wheels/"
echo "" echo ""
echo "============================================" echo "============================================"
echo "Download complete!" echo "Download complete!"
echo "============================================" echo "============================================"
echo " Packages: $DEB_COUNT" echo " .deb packages: $DEB_COUNT ($TOTAL_SIZE) in $OUT_DIR/"
echo " Total size: $TOTAL_SIZE" echo " Python wheels: $WHL_COUNT in $PIP_DIR/"
echo " Location: $OUT_DIR/"
echo ""
echo "Next: copy these into your ubuntu_playbook/ directory"
echo " cp $OUT_DIR/*.deb /path/to/ubuntu_playbook/"
echo "" echo ""

View File

@@ -336,7 +336,7 @@
- name: "Enable UFW firewall" - name: "Enable UFW firewall"
ufw: ufw:
state: enabled state: enabled
policy: allow policy: deny
- name: "Schedule dnsmasq restart 15s after reboot" - name: "Schedule dnsmasq restart 15s after reboot"
cron: cron:
@@ -346,11 +346,6 @@
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service" job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
# --- Web Management App (Flask) --- # --- Web Management App (Flask) ---
- name: "Install pip for Python package management"
command: apt-get install -y python3-pip python3-venv
args:
creates: /usr/bin/pip3
- name: "Create webapp directory" - name: "Create webapp directory"
file: file:
path: /opt/pxe-webapp path: /opt/pxe-webapp
@@ -369,10 +364,13 @@
args: args:
creates: /opt/pxe-webapp/venv/bin/python creates: /opt/pxe-webapp/venv/bin/python
- name: "Install webapp Python dependencies" - name: "Install webapp Python dependencies (offline wheels)"
pip: shell: >
requirements: /opt/pxe-webapp/requirements.txt /opt/pxe-webapp/venv/bin/pip install --no-index
virtualenv: /opt/pxe-webapp/venv --find-links="{{ usb_mount }}/../pip-wheels/"
--find-links="{{ usb_mount }}/pip-wheels/"
-r /opt/pxe-webapp/requirements.txt 2>/dev/null ||
/opt/pxe-webapp/venv/bin/pip install -r /opt/pxe-webapp/requirements.txt
- name: "Create systemd service for PXE webapp" - name: "Create systemd service for PXE webapp"
copy: copy:
@@ -390,6 +388,7 @@
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
Environment=WEB_ROOT={{ web_root }} Environment=WEB_ROOT={{ web_root }}
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
ExecStart=/opt/pxe-webapp/venv/bin/python app.py ExecStart=/opt/pxe-webapp/venv/bin/python app.py
Restart=always Restart=always
RestartSec=5 RestartSec=5

View File

@@ -1,10 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Flask web application for managing a GE Aerospace PXE server.""" """Flask web application for managing a GE Aerospace PXE server."""
import copy import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from flask import ( from flask import (
@@ -24,6 +26,22 @@ app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod") app.secret_key = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 * 1024 # 16 GB max upload app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 * 1024 # 16 GB max upload
# ---------------------------------------------------------------------------
# Audit logging
# ---------------------------------------------------------------------------
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
audit_logger = logging.getLogger("pxe_audit")
audit_logger.setLevel(logging.INFO)
_audit_handler = logging.FileHandler(AUDIT_LOG, mode="a")
_audit_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
audit_logger.addHandler(_audit_handler)
def audit(action, detail=""):
"""Write an entry to the audit log."""
ip = request.remote_addr if request else "system"
audit_logger.info(f"[{ip}] {action}: {detail}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration # Configuration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -190,7 +208,8 @@ def parse_unattend(xml_path):
data["raw_xml"] = UNATTEND_TEMPLATE data["raw_xml"] = UNATTEND_TEMPLATE
return data return data
raw = open(xml_path, "r", encoding="utf-8").read() with open(xml_path, "r", encoding="utf-8") as fh:
raw = fh.read()
data["raw_xml"] = raw data["raw_xml"] = raw
try: try:
@@ -480,6 +499,12 @@ def images_import():
flash("Invalid target image type.", "danger") flash("Invalid target image type.", "danger")
return redirect(url_for("images_import")) return redirect(url_for("images_import"))
# Validate source is under an allowed USB mount path
allowed = find_usb_mounts()
if not any(source == m or source.startswith(m + "/") for m in allowed):
flash("Source path is not on a mounted USB device.", "danger")
return redirect(url_for("images_import"))
if not os.path.isdir(source): if not os.path.isdir(source):
flash(f"Source path does not exist: {source}", "danger") flash(f"Source path does not exist: {source}", "danger")
return redirect(url_for("images_import")) return redirect(url_for("images_import"))
@@ -497,6 +522,7 @@ def images_import():
shutil.copytree(src_item, dst_item) shutil.copytree(src_item, dst_item)
else: else:
shutil.copy2(src_item, dst_item) shutil.copy2(src_item, dst_item)
audit("IMAGE_IMPORT", f"{source} -> {target}")
flash( flash(
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.", f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
"success", "success",
@@ -553,6 +579,7 @@ def unattend_editor(image_type):
os.makedirs(os.path.dirname(xml_file), exist_ok=True) os.makedirs(os.path.dirname(xml_file), exist_ok=True)
with open(xml_file, "w", encoding="utf-8") as fh: with open(xml_file, "w", encoding="utf-8") as fh:
fh.write(xml_content) fh.write(xml_content)
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
flash("unattend.xml saved successfully.", "success") flash("unattend.xml saved successfully.", "success")
except Exception as exc: except Exception as exc:
flash(f"Failed to save: {exc}", "danger") flash(f"Failed to save: {exc}", "danger")
@@ -615,6 +642,7 @@ def clonezilla_upload():
os.makedirs(CLONEZILLA_SHARE, exist_ok=True) os.makedirs(CLONEZILLA_SHARE, exist_ok=True)
dest = os.path.join(CLONEZILLA_SHARE, filename) dest = os.path.join(CLONEZILLA_SHARE, filename)
f.save(dest) f.save(dest)
audit("BACKUP_UPLOAD", filename)
flash(f"Uploaded {filename} successfully.", "success") flash(f"Uploaded {filename} successfully.", "success")
return redirect(url_for("clonezilla_backups")) return redirect(url_for("clonezilla_backups"))
@@ -635,6 +663,7 @@ def clonezilla_delete(filename):
fpath = os.path.join(CLONEZILLA_SHARE, filename) fpath = os.path.join(CLONEZILLA_SHARE, filename)
if os.path.isfile(fpath): if os.path.isfile(fpath):
os.remove(fpath) os.remove(fpath)
audit("BACKUP_DELETE", filename)
flash(f"Deleted {filename}.", "success") flash(f"Deleted {filename}.", "success")
else: else:
flash(f"Backup not found: {filename}", "danger") flash(f"Backup not found: {filename}", "danger")
@@ -684,6 +713,7 @@ def blancco_delete_report(filename):
fpath = os.path.join(BLANCCO_REPORTS, filename) fpath = os.path.join(BLANCCO_REPORTS, filename)
if os.path.isfile(fpath): if os.path.isfile(fpath):
os.remove(fpath) os.remove(fpath)
audit("REPORT_DELETE", filename)
flash(f"Deleted {filename}.", "success") flash(f"Deleted {filename}.", "success")
else: else:
flash(f"Report not found: {filename}", "danger") flash(f"Report not found: {filename}", "danger")
@@ -696,7 +726,6 @@ def blancco_delete_report(filename):
def _wim_extract_startnet(wim_path): def _wim_extract_startnet(wim_path):
"""Extract startnet.cmd from a WIM file using wimextract.""" """Extract startnet.cmd from a WIM file using wimextract."""
import tempfile
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()
try: try:
result = subprocess.run( result = subprocess.run(
@@ -707,8 +736,8 @@ def _wim_extract_startnet(wim_path):
) )
startnet_path = os.path.join(tmpdir, "startnet.cmd") startnet_path = os.path.join(tmpdir, "startnet.cmd")
if result.returncode == 0 and os.path.isfile(startnet_path): if result.returncode == 0 and os.path.isfile(startnet_path):
content = open(startnet_path, "r", encoding="utf-8", errors="replace").read() with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh:
return content return fh.read()
return None return None
except Exception: except Exception:
return None return None
@@ -718,7 +747,6 @@ def _wim_extract_startnet(wim_path):
def _wim_update_startnet(wim_path, content): def _wim_update_startnet(wim_path, content):
"""Update startnet.cmd inside a WIM file using wimupdate.""" """Update startnet.cmd inside a WIM file using wimupdate."""
import tempfile
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()
try: try:
startnet_path = os.path.join(tmpdir, "startnet.cmd") startnet_path = os.path.join(tmpdir, "startnet.cmd")
@@ -796,12 +824,33 @@ def startnet_save():
content = request.form.get("content", "") content = request.form.get("content", "")
ok, err = _wim_update_startnet(BOOT_WIM, content) ok, err = _wim_update_startnet(BOOT_WIM, content)
if ok: if ok:
audit("STARTNET_SAVE", "boot.wim updated")
flash("startnet.cmd updated successfully in boot.wim.", "success") flash("startnet.cmd updated successfully in boot.wim.", "success")
else: else:
flash(f"Failed to update boot.wim: {err}", "danger") flash(f"Failed to update boot.wim: {err}", "danger")
return redirect(url_for("startnet_editor")) return redirect(url_for("startnet_editor"))
# ---------------------------------------------------------------------------
# Routes — Audit Log
# ---------------------------------------------------------------------------
@app.route("/audit")
def audit_log():
entries = []
if os.path.isfile(AUDIT_LOG):
with open(AUDIT_LOG, "r") as fh:
for line in fh:
entries.append(line.strip())
entries.reverse() # newest first
return render_template(
"audit.html",
entries=entries,
image_types=IMAGE_TYPES,
friendly_names=FRIENDLY_NAMES,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes — API # Routes — API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -849,6 +898,7 @@ def api_save_unattend(image_type):
except Exception as exc: except Exception as exc:
return jsonify({"error": f"Failed to write file: {exc}"}), 500 return jsonify({"error": f"Failed to write file: {exc}"}), 500
audit("UNATTEND_SAVE_API", image_type)
return jsonify({"status": "ok", "path": xml_file}) return jsonify({"status": "ok", "path": xml_file})
@@ -859,7 +909,6 @@ def api_save_unattend(image_type):
@app.template_filter("timestamp_fmt") @app.template_filter("timestamp_fmt")
def timestamp_fmt(ts): def timestamp_fmt(ts):
"""Format a Unix timestamp to a human-readable date string.""" """Format a Unix timestamp to a human-readable date string."""
from datetime import datetime
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
@@ -880,4 +929,4 @@ def inject_globals():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=False)

5
webapp/static/bootstrap-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

7
webapp/static/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
webapp/static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Audit Log - PXE Server Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h2>
<span class="badge bg-secondary fs-6">{{ entries|length }} entries</span>
</div>
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="bi bi-clock-history me-2"></i> Activity History
</div>
<div class="card-body p-0">
{% if entries %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width: 180px;">Timestamp</th>
<th style="width: 130px;">Source</th>
<th style="width: 180px;">Action</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
{% set parts = entry.split(' ', 1) %}
{% if parts|length == 2 %}
{% set meta = parts[1].split('] ', 1) %}
<td><small class="text-muted">{{ parts[0] }}</small></td>
{% if meta|length == 2 %}
<td><code>{{ meta[0].lstrip('[') }}</code></td>
{% set action_detail = meta[1].split(': ', 1) %}
{% if action_detail|length == 2 %}
<td><span class="badge bg-primary">{{ action_detail[0] }}</span></td>
<td>{{ action_detail[1] }}</td>
{% else %}
<td colspan="2">{{ meta[1] }}</td>
{% endif %}
{% else %}
<td colspan="3">{{ parts[1] }}</td>
{% endif %}
{% else %}
<td colspan="4">{{ entry }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-journal-text" style="font-size: 3rem;"></i>
<p class="mt-2">No audit log entries yet.</p>
<p class="small">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -5,12 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PXE Server Manager{% endblock %}</title> <title>{% block title %}PXE Server Manager{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
rel="stylesheet" <link href="{{ url_for('static', filename='bootstrap-icons.min.css') }}" rel="stylesheet">
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS49cn91B2HOwP4cMpe1bBMnos9GBsYl7a"
crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet">
<style> <style>
:root { :root {
--sidebar-width: 280px; --sidebar-width: 280px;
@@ -155,12 +151,18 @@
<i class="bi bi-shield-check"></i> Blancco Reports <i class="bi bi-shield-check"></i> Blancco Reports
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
href="{{ url_for('audit_log') }}">
<i class="bi bi-journal-text"></i> Audit Log
</a>
</li>
</ul> </ul>
<div class="nav-section-divider"></div> <div class="nav-section-divider"></div>
<div class="sidebar-heading">GE Aerospace Images</div> <div class="sidebar-heading">GE Aerospace Images</div>
<ul class="nav flex-column"> <ul class="nav flex-column">
{% for it in ['gea-standard', 'gea-engineer', 'gea-shopfloor'] %} {% for it in all_image_types if it.startswith('gea-') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('unattend_editor', image_type=it) }}"> href="{{ url_for('unattend_editor', image_type=it) }}">
@@ -173,7 +175,7 @@
<div class="nav-section-divider"></div> <div class="nav-section-divider"></div>
<div class="sidebar-heading">GE Legacy Images</div> <div class="sidebar-heading">GE Legacy Images</div>
<ul class="nav flex-column"> <ul class="nav flex-column">
{% for it in ['ge-standard', 'ge-engineer', 'ge-shopfloor-lockdown', 'ge-shopfloor-mce'] %} {% for it in all_image_types if it.startswith('ge-') and not it.startswith('gea-') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('unattend_editor', image_type=it) }}"> href="{{ url_for('unattend_editor', image_type=it) }}">
@@ -200,9 +202,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" <script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>

View File

@@ -43,7 +43,7 @@
<h2 class="mb-1">{{ friendly_name }}</h2> <h2 class="mb-1">{{ friendly_name }}</h2>
<small class="text-muted"> <small class="text-muted">
<i class="bi bi-file-earmark-code me-1"></i> <i class="bi bi-file-earmark-code me-1"></i>
<code>{{ image_type }}/Deploy/Control/unattend.xml</code> <code>{{ image_type }}/Deploy/FlatUnattendW10.xml</code>
</small> </small>
</div> </div>
<div> <div>