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-tools/
# Python wheels for offline install (built by download-packages.sh)
pip-wheels/
# Deployment images (imported via webapp or USB)
geastandardpbr/

View File

@@ -185,6 +185,15 @@ if [ -d "$WEBAPP_DIR" ]; then
echo " Copied webapp/"
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
BOOT_TOOLS_DIR="$SCRIPT_DIR/boot-tools"
if [ -d "$BOOT_TOOLS_DIR" ]; then

View File

@@ -26,6 +26,9 @@ PLAYBOOK_PACKAGES=(
ufw
cron
wimtools
python3-pip
python3-venv
p7zip-full
)
# 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)"
# Download all packages
echo "[3/3] Downloading packages to $OUT_DIR..."
echo "[3/4] Downloading .deb packages to $OUT_DIR..."
cd "$OUT_DIR"
apt-get download $DEPS 2>&1 | tail -5
DEB_COUNT=$(ls -1 *.deb 2>/dev/null | wc -l)
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 "Download complete!"
echo "============================================"
echo " Packages: $DEB_COUNT"
echo " Total size: $TOTAL_SIZE"
echo " Location: $OUT_DIR/"
echo ""
echo "Next: copy these into your ubuntu_playbook/ directory"
echo " cp $OUT_DIR/*.deb /path/to/ubuntu_playbook/"
echo " .deb packages: $DEB_COUNT ($TOTAL_SIZE) in $OUT_DIR/"
echo " Python wheels: $WHL_COUNT in $PIP_DIR/"
echo ""

View File

@@ -336,7 +336,7 @@
- name: "Enable UFW firewall"
ufw:
state: enabled
policy: allow
policy: deny
- name: "Schedule dnsmasq restart 15s after reboot"
cron:
@@ -346,11 +346,6 @@
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
# --- 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"
file:
path: /opt/pxe-webapp
@@ -369,10 +364,13 @@
args:
creates: /opt/pxe-webapp/venv/bin/python
- name: "Install webapp Python dependencies"
pip:
requirements: /opt/pxe-webapp/requirements.txt
virtualenv: /opt/pxe-webapp/venv
- name: "Install webapp Python dependencies (offline wheels)"
shell: >
/opt/pxe-webapp/venv/bin/pip install --no-index
--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"
copy:
@@ -390,6 +388,7 @@
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
Environment=WEB_ROOT={{ web_root }}
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
Restart=always
RestartSec=5

View File

@@ -1,10 +1,12 @@
#!/usr/bin/env python3
"""Flask web application for managing a GE Aerospace PXE server."""
import copy
import logging
import os
import shutil
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
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.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
# ---------------------------------------------------------------------------
@@ -190,7 +208,8 @@ def parse_unattend(xml_path):
data["raw_xml"] = UNATTEND_TEMPLATE
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
try:
@@ -480,6 +499,12 @@ def images_import():
flash("Invalid target image type.", "danger")
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):
flash(f"Source path does not exist: {source}", "danger")
return redirect(url_for("images_import"))
@@ -497,6 +522,7 @@ def images_import():
shutil.copytree(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
audit("IMAGE_IMPORT", f"{source} -> {target}")
flash(
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
"success",
@@ -553,6 +579,7 @@ def unattend_editor(image_type):
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
with open(xml_file, "w", encoding="utf-8") as fh:
fh.write(xml_content)
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
flash("unattend.xml saved successfully.", "success")
except Exception as exc:
flash(f"Failed to save: {exc}", "danger")
@@ -615,6 +642,7 @@ def clonezilla_upload():
os.makedirs(CLONEZILLA_SHARE, exist_ok=True)
dest = os.path.join(CLONEZILLA_SHARE, filename)
f.save(dest)
audit("BACKUP_UPLOAD", filename)
flash(f"Uploaded {filename} successfully.", "success")
return redirect(url_for("clonezilla_backups"))
@@ -635,6 +663,7 @@ def clonezilla_delete(filename):
fpath = os.path.join(CLONEZILLA_SHARE, filename)
if os.path.isfile(fpath):
os.remove(fpath)
audit("BACKUP_DELETE", filename)
flash(f"Deleted {filename}.", "success")
else:
flash(f"Backup not found: {filename}", "danger")
@@ -684,6 +713,7 @@ def blancco_delete_report(filename):
fpath = os.path.join(BLANCCO_REPORTS, filename)
if os.path.isfile(fpath):
os.remove(fpath)
audit("REPORT_DELETE", filename)
flash(f"Deleted {filename}.", "success")
else:
flash(f"Report not found: {filename}", "danger")
@@ -696,7 +726,6 @@ def blancco_delete_report(filename):
def _wim_extract_startnet(wim_path):
"""Extract startnet.cmd from a WIM file using wimextract."""
import tempfile
tmpdir = tempfile.mkdtemp()
try:
result = subprocess.run(
@@ -707,8 +736,8 @@ def _wim_extract_startnet(wim_path):
)
startnet_path = os.path.join(tmpdir, "startnet.cmd")
if result.returncode == 0 and os.path.isfile(startnet_path):
content = open(startnet_path, "r", encoding="utf-8", errors="replace").read()
return content
with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
return None
except Exception:
return None
@@ -718,7 +747,6 @@ def _wim_extract_startnet(wim_path):
def _wim_update_startnet(wim_path, content):
"""Update startnet.cmd inside a WIM file using wimupdate."""
import tempfile
tmpdir = tempfile.mkdtemp()
try:
startnet_path = os.path.join(tmpdir, "startnet.cmd")
@@ -796,12 +824,33 @@ def startnet_save():
content = request.form.get("content", "")
ok, err = _wim_update_startnet(BOOT_WIM, content)
if ok:
audit("STARTNET_SAVE", "boot.wim updated")
flash("startnet.cmd updated successfully in boot.wim.", "success")
else:
flash(f"Failed to update boot.wim: {err}", "danger")
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
# ---------------------------------------------------------------------------
@@ -849,6 +898,7 @@ def api_save_unattend(image_type):
except Exception as exc:
return jsonify({"error": f"Failed to write file: {exc}"}), 500
audit("UNATTEND_SAVE_API", image_type)
return jsonify({"status": "ok", "path": xml_file})
@@ -859,7 +909,6 @@ def api_save_unattend(image_type):
@app.template_filter("timestamp_fmt")
def timestamp_fmt(ts):
"""Format a Unix timestamp to a human-readable date string."""
from datetime import datetime
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
@@ -880,4 +929,4 @@ def inject_globals():
# ---------------------------------------------------------------------------
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">
<title>{% block title %}PXE Server Manager{% endblock %}</title>
<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"
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">
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='bootstrap-icons.min.css') }}" rel="stylesheet">
<style>
:root {
--sidebar-width: 280px;
@@ -155,12 +151,18 @@
<i class="bi bi-shield-check"></i> Blancco Reports
</a>
</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>
<div class="nav-section-divider"></div>
<div class="sidebar-heading">GE Aerospace Images</div>
<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">
<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) }}">
@@ -173,7 +175,7 @@
<div class="nav-section-divider"></div>
<div class="sidebar-heading">GE Legacy Images</div>
<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">
<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) }}">
@@ -200,9 +202,7 @@
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>

View File

@@ -43,7 +43,7 @@
<h2 class="mb-1">{{ friendly_name }}</h2>
<small class="text-muted">
<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>
</div>
<div>