Files
spencermandClaude Opus 5.5 6adaf7bf25
ci / frontend (pull_request) Successful in 1m0s
ci / server (pull_request) Successful in 13m45s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m49s
Archive copy must succeed before a packet counts as filed (Gitea issue 57)
FILE checks the archive folder and copies the packet inside the filing
transaction. A failed check or copy rolls the filing back, so the packet
stays in its previous stage; the person filing gets 503 archive_failed,
and in its own transaction the server writes an archive.filing_blocked
audit row and raises the critical archive_failed condition to Admin for
that packet (who tried, folder, error). The next successful filing clears
it. The archive-copy job now only catches up filings made while the
archive was off. PLAN question 53, CHANGELOG, FEED and SECURITY updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:52:09 -07:00

58 KiB

Packet Station security

How the server build of Packet Station is protected, and where each production requirement stands. The old browser build's security document is in the old repo (C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md). Its Part 3 is the source of the requirement IDs. The design comes from BRIEF.md and PLAN.md.

Status: phase 10. Every requirement ID is closed, superseded, deferred with a hook left, or owned by IT. Phase 10 closes O3 (paper fallback) and O4 (incident notes), closes D6 with the archive, tunes S1 (the CSP pass) and moves the host rows to the native VMs. S4 (the penetration test) stays IT's, before go-live.

Contents:

  • Part 1: the requirement IDs, with the status and owner of each.
  • Part 2: the 20 old layers and where each one lives now.
  • Part 3: the controls, area by area, with the phase that built each.

Owners: Dev builds and keeps Packet Station (Spencer, with Claude Code). IT is PESCO IT (Spencer is IT too, so "IT" here means the work done as host and domain admin, not in the app). Eng is Seth and engineering. Q.C. is the Q.C. department.

Part 1: requirement IDs

The IDs and the requirement wording are from Part 3 of the old SECURITY.md. The status follows PLAN.md, "SECURITY.md Part 3", with phase 10 applied.

Hosting and network

ID Requirement Status Owner
H1 Decide production hosting Closed. Production and test each run natively on their own Linux VM inside the PESCO network, no Docker (Spencer, 2026-10-27, Gitea issue 37; built in phase 10, see "Hosts"). docker-1 keeps CI, Gitea and the proxy. The VM names, addresses and size are still open on issue 37 (Spencer decides). Dev + IT
H2 Internal-only hosting, no internet exposure Closed, phase 0, kept in phase 10. Internal DNS only (pescoinc.biz AD-integrated zone). The app is reached only through Nginx Proxy Manager on docker-1. On the VMs, PostgreSQL listens on its local socket only, and an nftables rule written by deploy/vm/install.sh lets only NPM on docker-1 reach the app port. Limiting SSH and anything else on the VMs is IT's. IT
H3 HTTPS only, internal certificate, HSTS, TLS 1.2+ Closed at the proxy, phase 0: NPM terminates TLS with the internal certificate, the app sends HSTS. The hop from NPM on docker-1 to the VM crosses the LAN. Whether it is plain HTTP or TLS to the VM is open on Gitea issue 37 (Spencer decides). IT
H4 Separate test and production environments Closed, phase 0 (two stacks). Phase 10 moves them to two VMs, test first, so the test VM rehearses the production install, backups and restore. The docker-1 test stack runs until the test VM is ready. The test stack never points at VISUAL production. IT + Dev
H5 Server hardening and patching schedule IT's: patching and hardening of both VMs, docker-1 and the QNAP. The app's part is built: the services run as an unprivileged user under systemd hardening (DEPLOY.md, "Native VM install"). IT

Identity and sign-in

ID Requirement Status Owner
I1 Entra ID single sign-on for office users Partly closed, phase 1: sign-in with the AD domain password. No SSO. Entra SSO is deferred; the session layer does not change if it comes. Dev + IT
I2 MFA for office and IT accounts Deferred (MFA with Entra). IT
I3 Kiosk tablets: managed device, device certificate, PIN checked on the server Closed for "PIN checked on the server", phase 1. Registered devices use a device cookie. No device certificate is planned (Spencer, 2026-10-18, Gitea issue 3). Dev + IT
I4 Server-side lockout and rate limits on PIN attempts Closed, phase 1. Dev
I5 Separate IT admin accounts with MFA; second-admin approval to grant admin Superseded for the app: there is no in-app admin grant, so second-admin approval has nothing to approve. Separate IT admin accounts with MFA remain IT's. IT
I6 Joiner, mover, leaver; quarterly access review App part closed, phase 9: roles only from AD groups; the nightly ad-reconcile marks disabled and gone users inactive, ends their sessions and flags employee ID problems; session-refresh re-reads every live session's groups and account state every 15 minutes and ends the sessions of anyone disabled, gone or left with no role. The quarterly access review is IT's. IT + supervisors
I7 PIN re-entry (electronic signature) at sign-off steps, verified on the server Closed, phase 1. Dev
I8 Screen lock on idle and when backgrounded; kiosk device lock policy App part closed, phase 1. The kiosk lock policy is IT's (Intune or GPO). Dev + IT

The brief's wording for the identity IDs, which the table follows: I4 and I7 are closed by this build. I8 is closed for the app's part. I6 is closed for the app's part by the AD groups, the nightly reconcile and the 15-minute role refresh, and the quarterly access review is still IT's. I3 is closed for "PIN checked on the server" and open for "managed device, device certificate". I1 is partly closed by AD sign-in. I5 is superseded.

Authorization

ID Requirement Status Owner
A1 Server checks department and level on every API call Closed, phase 2 (server policy on every write; every read scoped by the session). Dev
A2 Server enforces workflow steps Closed, phase 2 (workflow steps as compare-and-set, the filed lock). Dev
A3 Server-side validation of every field Closed, phase 2 (cleaning, limits and closed sets on the server). Dev
A4 Least-privilege database account for the app Closed, phase 0: ps_app cannot change the schema or update or delete the audit; migrations run as ps_migrate. The same roles are made on the VMs. Dev + IT

Data protection

ID Requirement Status Owner
D1, D2, D4 Envelope encryption, Key Vault, key rotation and recovery Deferred. The hooks are left. The seal key and the session key are files in the secret folder (see "Secrets" and the key rotation steps in GO-LIVE.md). Dev + IT
D3 Disk encryption on servers and backups IT's: disk encryption on the two VMs, the QNAP archive share and the Proxmox Backup Server store. IT
D5 Upload checks: type, size, malware scan Closed, phase 4 (optional ClamAV: a clamav compose profile; when CLAMAV_HOST is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set; left off in both stacks by IT on 2026-10-18, Gitea issue 19, with Microsoft Defender on the workstations). Dev + IT
D6 Record retention for data books, MTRs and logs Closed, phase 10. All data is kept and nothing is purged (SethD, Gitea issue 7, 2026-10-28). Packet Station has no delete path for filed records, documents or the audit. At filing, the sealed data book, the packet's documents and a manifest are copied to the archive folder on the QNAP share (see "Archive"). No minimum period was set, because nothing is ever removed. The share's own permissions and backup are IT's. Eng + Q.C. + IT
D7 Customer-facing data book copy Closed 2026-10-30 (Gitea issue 8, change C6): the one data book, the customer copy, is built without the history pages. The history stays in Packet Station and the audit. Eng

Audit and monitoring

ID Requirement Status Owner
M1 Append-only audit written only by the server Closed, phase 2 (append-only audit and packet log; every packet write carries both). Dev
M2 Server-signed filing seals Closed, phase 7 (HMAC-SHA256 with the server seal key from the secret folder; a reopen keeps the broken seal and its data book). Dev
M3 Scheduled integrity check with an alert Closed, phase 9 (the nightly integrity job re-verifies seals, rehashes asset files, finds missing and orphan files and walks the audit hash chain; findings are kept in the append-only integrity_findings and raise integrity_failure, which nobody can opt out of). Dev
M4, M5 Logs to the SIEM with alerts; log retention matched to D6 Deferred. The app and the worker log JSON lines to stdout (the systemd journal on the VMs). Shipping them to a SIEM and their retention are IT's. The audit itself lives in PostgreSQL and is kept with everything else. IT

Application security

ID Requirement Status Owner
S1 CSP, X-Frame-Options and related headers Closed, phase 0, tuned phase 10 (the CSP pass; see "Headers"; every e2e test fails on a CSP violation and one checks the served policy exactly). Dev
S2 CSRF protection on every state-changing request Closed, phase 1 (SameSite=Strict cookie, the Origin check, the X-Requested-With header). Dev
S3 Dependency update and vulnerability scan Closed, phase 9 (CI scanning on every run: pip-audit over every pin in server/requirements.txt fails on any known vulnerability; npm audit --audit-level=high fails on a high or critical one). Phase 10 bumped pypdf to 6.21.4, certifi to 2026.10.23 and pyasn1 to 0.6.5, and the scan is clean. Dev
S4 Security review or penetration test before go-live Deferred to IT: a penetration test of the production VM and the proxy before go-live, by IT or an outside firm. Who does it and when is TBD (Spencer decides). It is on the go-live checklist. If go-live goes ahead without it, Spencer records that decision on the checklist. IT
S5 Output escaping on every screen and in the PDFs Closed, phase 2 (React escapes output; server messages are plain text); phase 7 adds escaping in the PDF builders. Dev
S6 CSV formula-injection guard Closed, phase 7 (the guard on every export). Dev
S7 Controlled-copy stamp and export logging Closed, phase 7 (the stamp on every exported PDF, signed with the seal key; PDF metadata; every export in print_log and the audit). Dev
S8 Printed-copy stamp and print logging Closed, phase 7 (the PRINTED COPY stamp with "verify revision before use" on every printed page, signed with the seal key; every print in print_log and the audit). PLAN.md lists S8 under phase 5, but the print stamps came with the PDFs in phase 7. Dev

Operations and continuity

ID Requirement Status Owner
O1, O2 Nightly backups with an offsite copy; restore test at least quarterly Closed, phase 9 (nightly pg_dump at 02:00 Denver time from a systemd timer, checked with pg_restore --list, 7 kept, before the PBS run; the backup-check job alerts on a missing, empty, stale or failed backup; restore rehearsed on the test stack on 2026-10-21 with every row count and asset hash matching). Phase 10 rewrites the backup and restore for the VMs, and the restore is rehearsed again on the test VM before go-live. The quarterly restore test is IT's. IT
O3 Downtime procedure (paper fallback) Closed, phase 10: PAPER-FALLBACK.md. The forms, who declares an outage, what each department does, and how paper is entered back and checked. The parts Seth and the shop still have to settle are marked TBD there. Eng + shop
O4 Incident response: who to call, lock accounts, rotate keys, run the integrity check Closed, phase 10: GO-LIVE.md, "Incident notes". Every step names a real Admin action or host command. IT
O5 Data migration from the M: install Superseded. Nothing is migrated. The new system starts empty behind the go-live date, and M: is kept read-only as an archive. Dev

Out of scope

Out of scope for this build, with hooks left: writing to VISUAL or M-Files (never), Entra ID sign-in and MFA (I1, I2), envelope encryption with Key Vault (D1, D2), true .xlsx exports, scheduled report emails.

Part 2: the old layers, moved to the server

The M: drive build had 20 layers of protection, most of them enforced in the browser. Every layer that still applies moved to the server. The rest are dropped or replaced.

# Old layer Now Phase
1 PESCO network and firewall Kept. The app runs inside the PESCO network, reached only through Nginx Proxy Manager on docker-1. In Docker (the CI stack, and the test stack until the test VM is ready) it publishes no port and PostgreSQL is on the stack's internal network only. On the VMs PostgreSQL listens on its local socket only, and an nftables rule lets only docker-1 reach the app port. 0, 10
2 Windows folder rights on the M: folder Dropped. Replaced by database roles: the app role cannot change the schema or update or delete the audit; migrations run as a separate role. Files sit in an assets folder only the service user reaches (a volume only the containers see, in Docker). 0, 10
3 Employee ID + PIN sign-in Replaced by AD sign-in with the domain password, plus an optional Packet Station PIN checked on the server, for registered shop devices only. 1
4 Department and level screens A static role-to-screen map in the browser, plus server policy on every request. The browser only decides what to show. 1, 2
5 Per-PC access log Replaced by the server audit. Each row carries the AD account, session, client IP and user agent. No per-PC browser ids. 1
6 Filing seals and filed-packet lock HMAC seals computed on the server with a server key. The filed lock is a server policy rule. 2, 7
7 Record integrity check A worker job every night (seals, asset hashes, missing and orphan files, the audit hash chain) with an email alert. 9
8 Self-hosted libraries Kept: no CDN. The CSP allows nothing external. 0
9 Output escaping React escapes output by default. The PDF builders clean and escape every text on the server. 1, 7
10 Central permission policy Server policy, checked on every write, including writes that do not come from the screens. 2
11 PIN sign-off (electronic signature) Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. 1
12 IT edit reasons Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. 2
13 Screen lock Server-enforced sessions. The browser reports idle and background state; the server records it and refuses an idle or locked session. Unlock is a step-up check on the server. 1
14 Input and upload validation On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets folder. 2, 4
15 Second-admin approval Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. 1
16 Security alerts and automatic integrity check The Admin Security tab (alerts from the audit and the sign-in attempts, the last integrity run), the worker jobs and the Notifications tab (email through app-03). 9
17 Controlled-copy stamp on data books Applied to server-built PDFs and signed with the seal key. 7
18 CSV formula guard Applied to every server export. 7
19 Update lock and stale-version block Replaced by the build version endpoint and a banner. A screen on an older build shows "Packet Station was updated. Reload to get the new version." with a Reload button. It never reloads by itself, so an open form is not lost. 0 (endpoint), 10 (banner)
20 VISUAL helper write guard Dropped. There is no helper. VisualKit and MFilesKit are read-only by design and connect as read-only accounts. 3

Part 3: the controls

Hosts (phase 10)

  • Production and test each run on their own Linux VM (Debian 12, the same base as the image), natively, with no Docker (Gitea issue 37). The test VM is built first from the same steps. The VMs do not exist yet. Their names, addresses and size are open on issue 37 (Spencer decides). Spencer runs every step that needs root.
  • The app, the worker and the one-shot migrate run as systemd services under the unprivileged service user packetstation, with systemd hardening (no new privileges, a read-only system, write access only to the folders they need). The exact unit settings are in deploy/vm/ and DEPLOY.md, "Native VM install".
  • PostgreSQL 16 runs on the same VM and listens on its local socket only. The roles are the same as in Docker (ps_migrate, ps_app).
  • NPM on docker-1 (192.168.101.12) proxies packetstation.pescoinc.biz and packetstation-test.pescoinc.biz to their VMs over the LAN. The app trusts X-Forwarded-For and X-Forwarded-Proto only from the host named by TRUSTED_PROXY_HOST (see "Rate limits"), which is NPM's IP address on docker-1 on the VMs.
  • Port 8000 (the app) answers only NPM and the VM itself: install.sh writes an nftables table from TRUSTED_PROXY_HOST (which must be NPM's IP address), loaded by packetstation-firewall.service. Without it, a machine on the LAN could talk to the app without TLS. Nothing else is filtered by that table; limiting SSH to IT's machines is IT's.
  • The docker-1 test stack keeps running until the test VM is ready, then it is stopped. CI stays on docker-1 in Docker, with its own throwaway domain controller.
  • Host patching and hardening, disk encryption and the VM backups by Proxmox Backup Server are IT's (H5, D3, O1).

Sessions (phase 1)

  • HttpOnly, SameSite=Strict session cookie. The session row on the server decides.
  • Password sessions: idle sign-out after 15 minutes, absolute 12 hours. PIN shop sessions: absolute 10 hours, idle 15 minutes. Admin and Clerical sessions wait 60 idle minutes (issue 45). All are settings, and from phase 9 an Admin can change them within bounds on the Security tab (see "Security settings").
  • Idle and background lock are reported by the browser and enforced by the server. Unlock is a step-up check with the PIN or the domain password.
  • Origin check on every state-changing request: the Origin header (or the Referer origin) must match the request host, and X-Requested-With: PacketStation must be present. Otherwise 403.
  • Admin is a password session only. A PIN session cannot open Admin or change Account settings.
  • Roles are resolved from AD at sign-in (nested groups through LDAP_MATCHING_RULE_IN_CHAIN), cached on the session, and re-resolved every 15 minutes on the session, by the session-refresh job and by the nightly AD reconcile (see "AD reconcile and role refresh"). A disabled AD account is always refused. A session whose user has lost every role is ended at the next refresh.
  • The session cookie token is stored only as an HMAC with the session key. The AD password is used for the bind and discarded.
  • The group-to-role map is derived from the department table and the PS- naming convention at startup. No screen edits it, so nobody can promote a group or themselves inside the app.
  • The browser reports hidden tabs and idle time; the server records locked_since and refuses a locked session with 423 until a step-up unlock. The server also locks a session itself when no request arrived for the lock interval (3 minutes, as the old build's screen lock; 60 for Admin and Clerical, issue 45), and when a tab stayed hidden for over a minute (the old build's background lock). A reload or an in-app navigation never locks.
  • A person can end their own other sessions on Account settings (password session, audited as auth.signout_others). An Admin has no button that ends another person's sessions. That is done in AD (see "Incident notes" in GO-LIVE.md).

PIN (phase 1)

  • Optional, per user, set by the user in Account settings after a password sign-in. Setting or changing it needs the domain password in the same request.
  • 6 to 12 digits, hashed with Argon2id on the server. Never visible to Admin. Admin can clear a PIN with a reason (audited as pin.cleared), not set one. Clearing a PIN does not end the person's sessions.
  • PIN sign-in uses the employee ID and works only on registered devices. Devices are registered by an Admin or a shop department Supervisor in a password session, on Account settings, "Register this device".
  • Five wrong PIN attempts in a row, across sign-in, unlock and signature, disable the PIN. Three wrong attempts at unlock or signature end the session. A guessed PIN never touches AD.
  • A PIN sign-in still checks AD with the service account: a disabled account, or one removed from every group, is refused.
  • The device cookie is a random token stored as an HMAC with the session key. Revoking a device on the Admin Devices tab ends every PIN session on it. A password session on the same device is not ended by the revoke.

Rate limits (phase 1)

  • Checked before any bind to AD, so the app can never lock a domain account out.
  • Defaults: 5 failed password sign-ins per account per 5 minutes, then a 10-minute cooldown. 30 failed attempts per client address per 5 minutes. Successful sign-ins never count.
  • The domain's own lockout (20 attempts in 5 minutes) stays the last line. From phase 9 the limits can be changed on the Security tab, and the per-account limit is capped at 15, always under AD's 20.
  • The client address comes from X-Forwarded-For only when the request comes from the host named by TRUSTED_PROXY_HOST, re-resolved every 60 seconds. In Docker that is npm_app_1 itself; other containers on npm_default are not trusted. On the VMs it is the IP address of docker-1, where NPM runs.
  • A refused attempt answers 429 with Retry-After, is recorded as rate_limited and never counts toward the limits itself.
  • Failed sign-ins answer one message for an unknown account, a wrong password and a disabled account, so accounts cannot be enumerated. The audit and signin_attempts keep the real reason.

Policy (phase 2)

  • One policy in Python on the server (server/packetstation/policy/), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
  • Every packet read returns can: [...], blocked: {...} and holder, so the browser enables controls from the server's answer. The server re-checks on the write.
  • A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
  • Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, an NCR number on the accept after a reject without one, every short received and approved before the Q.C. accept to complete and before filing, the unit's ITP complete before filing, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
  • Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (it_reason). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal.
  • Signatures (PIN or domain password, verified in the same transaction; the PIN is enough everywhere, Q.C. accept and filing included, confirmed by SethD on 2026-10-26, Gitea issue 2) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval, hand drawing uploads and the supervisor's approval of a received short.
  • Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused.
  • The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
  • Details in POLICY.md.

Uploads and storage (phase 4)

  • Every upload names a kind (purpose): drawing, mtr, document or bom. No purpose means document. An unknown kind is 422.
  • The type is read from the content, never from the file name or the browser's type. A drawing is a PDF. An MTR or a document is a PDF, PNG or JPEG. A BOM is an XLSX or a CSV. A wrong type for the kind is 415 unsupported_type with a message that names the allowed types.
  • An XLSX must be a zip that lists [Content_Types].xml and xl/workbook.xml; a plain zip is refused. A CSV has no magic, so it is accepted only as a BOM, and only when its first 64 KB decode as UTF-8 with no NUL and no control character other than tab, CR and LF.
  • Limits per kind: 25 MB (upload_max_bytes) for drawing, mtr and document, 5 MB (upload_max_bytes_bom) for a BOM. The Content-Length is checked first, then the bytes as they are read. Over the limit is 413 too_large.
  • Every file is read in chunks, hashed with sha256 and stored by id in the assets folder. It is served only with a session, with Content-Disposition and X-Content-Type-Options: nosniff. XLSX and CSV are always served as an attachment, never inline.
  • Optional ClamAV (requirement D5). When CLAMAV_HOST is set, every upload and every file the worker stores (drawings from M-Files) is streamed to clamd with zINSTREAM after it is read in full and before it is deduped or placed. A hit is 422 infected, nothing is stored, and an asset.rejected audit row keeps the signature, size and type. A scanner that cannot be reached refuses the upload with 503 scan_unavailable (fail closed). With CLAMAV_HOST empty there is no scan. IT decided on 2026-10-18 (Gitea issue 19) to leave the scan off in both stacks: every workstation that uploads runs Microsoft Defender. Drawings the worker fetches from M-Files are not scanned on the way in. They come from PESCO's own vault.
  • Quota alert. After every stored file and in the housekeeping job, the assets usage is checked against ASSETS_QUOTA_WARN_PCT (85 by default). Over it, one open notification_events row of kind assets_quota is written and audited as storage.quota_warning. Back under, the row is resolved. A failed check never fails an upload. Admin sees usage, the alert and the scanner on the Storage tab and can acknowledge an alert (alert.acknowledged). From phase 9 the alert and its "Cleared" notice are mailed through the assets_quota rule, to PS-Admins by default (Gitea issue 20).

Archive (phase 10)

The archive closes D6 (Gitea issue 7). It is a copy of every filed packet on the QNAP share, outside Packet Station. Nothing in Packet Station is deleted because of it.

  • The share is mounted by NFS on the production VM at a fixed folder, ARCHIVE_ROOT. With ARCHIVE_ROOT unset the archive is off: the API says so, nothing is queued, and doctor reports SKIP. The mount, its owner and the export settings are in DEPLOY.md. Spencer sets up the share and the mount.
  • An Admin picks the archive folder in the File locations section of the Admin page (setting files.archive_dir, versioned with compare and set like the security settings). The folder is a path under ARCHIVE_ROOT. The server refuses an empty path, .., an absolute path outside the root, a NUL, a path whose real location is outside the real root (a symlink out), and a folder that does not exist. It never creates folders.
  • "Check" writes a probe file in the folder, syncs it to disk, reads it back, compares it and deletes it. It shows the result, the resolved path and the free space. A save runs the same check first and is refused when it fails. doctor runs the check on the saved folder.
  • Every setting change (old and new value), every check (passed or not) and every copy (done or failed) writes an audit row. Admin writes need a password session and pass the Origin check, as every Admin write does.
  • At filing, FILE copies the sealed data book, every document of the packet and a manifest.json into <project>/<unit>/<data book name>-seal<seal id>/. The manifest names the packet, project, unit, seal id, seal hash, key id, who sealed and filed it and when, the build, and for every file its name, kind, sha256, size and source asset. Anyone can check the copy against the manifest without Packet Station.
  • Each file is written under a temporary name, synced and renamed. A different file is never overwritten; an identical file counts as done. Nothing in the archive is ever deleted by Packet Station. A refile after a reopen gets a new seal and a new folder, so both copies stay.
  • The copy must succeed before the packet counts as filed (Spencer, 2026-11-03, Gitea issue 57; open question 53 in PLAN.md). FILE checks the folder and copies inside the filing transaction. A failed check or copy rolls the filing back, so the packet stays in its previous stage. The server then writes an archive.filing_blocked audit row and raises the critical Admin alert archive_failed for that packet, with who tried, the folder and the error. Filing it again once the archive works clears the alert.
  • When the archive is turned on later, the worker job archive-copy copies every earlier filing that has no copy yet. It runs every 15 minutes, retries a failed copy after 5, 15 and 60 minutes, then hourly, and raises archive_failed when a copy has failed 3 times or the folder check fails. "Retry now" on the File locations section asks for a run at once.
  • The archive is a plain copy outside the app's access control. The files are not encrypted by the app (D1 is deferred). Who can read or change the share, the NFS export rule (the production VM only), snapshots and the share's own backup are IT's.

Warehouse rules (phase 4)

  • Release to the supervisor is refused on the server until every line is pulled or short and every pulled line that issued pieces has a heat (N/A and sub-assembly lines excepted). Heat numbers are checked against the old pattern.
  • Hold reasons and short reasons are closed sets on the server.
  • Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else.
  • An MTR on a line must be a PDF, PNG or JPEG asset.

Shop rules (phase 5)

  • The server is the clock of record. The station clock is kept in packet_stages with the database time. Only the server writes the clock columns. No request field sets the elapsed time.
  • Weld and heat tag numbers (W<n>, H<n>) are assigned by the server from the packet counters, under the packet row lock. Two people marking at once never get the same number.
  • ASSIGN checks on the server that the welder belongs to the department and that a limited worker's station is one of theirs.
  • A change-order verification needs a signature and is insert-only. A second one for the same department is refused. It cannot be withdrawn.
  • VOID sheets are never edited. A voided page is kept, and new marks or moves on it are refused (422 void_page).
  • A borrowed heat map is read-only. Nothing on the sibling packet can be written through the borrowing packet.
  • Presence over SSE carries display names, never other personal data (see "Project Management, find bar and presence").

Q.C. rules (phase 6)

  • Accept is refused on the server until the Q.C. record is complete (qc_missing_empty: every check item, a visual result on every weld and none rejected, an acceptable NDE result when a method is set, the inspector name). The screen only shows the reason.
  • A block signature is refused until every line of the block is initialed or N/A. The sign-off is refused until every block is signed. Both are server rules. A signed-off ITP refuses every write but the reopen, which needs a Q.C. Supervisor, or an admin with an IT reason, and a reason.
  • An ITP line rejected without an NCR number cannot be approved (server check). Line initials and block signatures can be removed only by the person who made them, or an admin.
  • NCR numbers are checked against the pattern NCR-YYYY-NNNN on the server. Checking them against pesco-ncr is reserved for a later phase (domain/ncr.py, validate_ncr). The NCR link opens in a new tab with rel="noopener noreferrer".
  • Documents are never deleted. A packet document is taken out of the data book with a reason and the row is kept. A Q.C. folder document is replaced by a new revision; the old row stays and points at the new one. Packet document kinds and Q.C. document types are closed sets, checked on the server and by database CHECKs. Every document file is a PDF, PNG or JPEG, typed by content.
  • itp_log is append-only, like packet_log: the app role has SELECT and INSERT only, and a trigger refuses updates, deletes and truncate. itp_rejects stays append-only, so every reject and its NCR number survive a later approval.
  • Signatures verified on the server, with a content hash: Q.C. accept, Q.C. accept and route, Q.C. reject, the ITP block signature and the ITP sign-off.
  • The ITP and the Q.C. folder are checked with check_scoped(): the same system, admin bypass, who and precondition steps as a packet write. An admin outside Q.C. and Clerical needs an IT reason, recorded in itp_log and the audit row.
  • Every ITP write and every Q.C. folder document writes an audit row (itp.<action>, qc_doc.add, qc_doc.revise).

Worker (phase 3)

  • The worker listens on no port. It reaches out to VISUAL, M-Files, AD, the mail relay and the archive share only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (job_requests, the feed settings row) and NOTIFY.
  • It reads VISUAL through VisualKit as visualkit_ro and M-Files through MFilesKit as mfileskitRO. Both libraries are read-only in their own code. Nothing in Packet Station writes to either.
  • Stack guard at start and again before every feed run: the test stack refuses a production VISUAL target, production refuses an implicit one, and production refuses a fixture source.
  • One run per job at a time, by a PostgreSQL advisory lock per job name. Feed writes run in one transaction with a lock timeout and skip rows a person has locked.
  • Job errors are redacted (connection strings, password=, Authorization: and the VISUAL and M-Files passwords themselves) and cut to 20 entries of 500 characters before they are stored or shown.
  • Every feed write goes through the server policy as the system principal, which can take only the vis_* actions. Each one writes a packet log row and an audit row with role system. Each run writes a feed.run audit row with its counts, and each drawing fetch a drawing.fetched row with the sha256. Feed settings and resource map changes are audited with before and after.
  • Downloaded drawings go through the same checks as uploads: PDF magic, size limit, streamed sha256, stored by id.
  • The Feed tab's pause switch stops the scheduled feed, drawing and auto-release runs at once, without a restart.

Audit (phase 1)

  • Append-only audit_events, with a hash chain (prev_hash, row_hash).
  • The app role may insert and select, never update or delete. A trigger also refuses updates and deletes. Migrations run as a separate role.
  • packetstation doctor checks that an audit UPDATE fails.
  • Every identity event is a row: sign-in, failed sign-in with a reason, sign-out, lock, unlock, session ended, PIN set, disabled or cleared, device registered or revoked, signature, AD refresh. Phase 9 adds auth.lockout, auth.signin_burst, policy.denied, auth.session_ended with by for sessions the reconcile and role refresh end, and the admin.* rows for every Admin change. Each carries the user, session, roles, client address and a small detail object, never a secret.
  • signin_attempts and signatures are append-only in the same way.
  • Phase 4 audit actions: asset.rejected (an upload the virus scan refused, with reason, signature, size and mime), storage.quota_warning (the assets folder went over the warning threshold, with the usage) and alert.acknowledged (an Admin acknowledged an open alert). Every warehouse write is a packet.<action> row with detail.item, the warehouse stage before and after, and detail.qty on short writes.

CI identity fixtures (phase 1)

  • CI runs a throwaway Samba Active Directory domain controller (PSTEST.LOCAL) built from deploy/samba/, with a generated CA so the LDAPS path is real. It is seeded with the 50 groups (one missing and one extra on purpose) and test users for every role, including nested, disabled, multi-role, missing and duplicate employee ID cases. Passwords are random per run and exported to a volume the tests read. PESCO's AD is never used by CI.

Seals (phase 7)

  • Filing writes an HMAC-SHA256 seal over the packet's read model in seal mode, with the server seal key (SEAL_KEY_FILE) and its key_id. Nobody without the key can recompute it (M2). GET /api/packets/{id}/seal rebuilds the content and says whether the seal still matches.
  • The data book is built on the server inside the filing transaction and stored as an asset on the seal. A data book that cannot be built fails the filing; nothing is filed half way.
  • Filing a VISUAL packet needs the feed's own observation that VISUAL closed every work order (change C5). It is a block, not a warning. The work orders are kept on the seal and in the packet log.
  • A reopen by Admin needs a reason and a signature. It marks the seal broken with who, when and why, and keeps the row and its data book. Filing again writes a new seal beside it.
  • The integrity job re-verifies every seal of a filed packet every night (see "Integrity job").
  • There is one seal key at a time. A seal made under an earlier key cannot be checked by the app after the key changes; the integrity job reports it as "sealed with another key". The rotation steps and their effect are in GO-LIVE.md, "Incident notes".

PDFs, prints and exports (phase 7)

  • Every PDF and the CSV are built on the server. The browser only opens the URL. Reads need a signed-in session; every role that can open the packet may read them, PM included.
  • Controlled-copy stamp (S7): every exported page carries Controlled copy - exported by Name (emp) <UTC time>, the seal's first 12 hex or not yet filed, and - stamp <sig>. The filed data book is the stored copy, served as it was built.
  • Printed-copy stamp (S8): every printed page carries PRINTED COPY - <what> - printed by Name (emp) <UTC time> - <project> Lot <unit> dwg <drawing> R<rev> - verify revision before use and the stamp signature, in a white box at the bottom of the page.
  • Stamp signature: 16 hex of HMAC-SHA256 with the seal key over the packet, the kind (a printed copy signs print:<kind>, so a print and an export of the same thing in the same minute never share a signature), the seal hash, the user and the time. A copy can be checked against the server with verify_stamp; from phase 9 the Admin Stamp check tab does it from a pasted line or a PDF, and from 2026-10-30 Q.C. and Clerical can too, on the Stamp check screen (Gitea issue 32, change C9).
  • Logging (S7, S8): every print and export writes one print_log row (who, when, session, packet, unit or ITP, kind, what, pages, the stamp line, the stamp signature, the client address) and one audit row (print or export). print_log is append-only: the app role has SELECT and INSERT only, and a trigger refuses updates, deletes and truncate.
  • CSV formula guard (S6): a cell that starts with =, +, -, @, a tab or a CR gets a leading '. A cell with a quote, a comma or a line break is quoted with the quotes doubled.
  • PDF metadata: title, subject and keywords name the packet and the stamp signature; Author is PESCO and Producer is Packet Station.
  • Output escaping (S5): every text drawn into a PDF goes through clean (control characters removed, characters outside Latin-1 replaced) and is XML-escaped where a Paragraph renders it, so data from VISUAL, M-Files or a person cannot inject markup.
  • Responses carry Content-Disposition (inline, or attachment with ?download=1), X-Content-Type-Options: nosniff and Cache-Control: no-store. A print of a document checks that the asset belongs to the packet, its unit or project folder, or its filed data book; anything else is 404.
  • Prints go to the browser's print dialog from the page (change C4): the stamped PDF is rendered with the bundled pdf.js into a hidden print area. The stamped pages are the same as before.
  • The signed ITP PDF is built and filed in the sign-off transaction. A PDF that cannot be built fails the sign-off.

Project Management, find bar and presence (phase 8)

  • Project Management reads only. Every endpoint under /api/pm is a GET and needs a PS-ProjectMgmt or Admin session; every other role is 403 (Gitea issue 33, change C10). The PM role passes no write action anywhere. The reports read PostgreSQL only, never VISUAL or M-Files.
  • Every PM export is built on the server and logged: one print_log row (kind report-csv or report-pdf, no packet, the scope label in what, the page count for the PDF) and one audit row with the action report exported and the detail {name, format, scope, period}. print_log stays append-only.
  • The PM CSV has the formula guard on every cell (S6), a BOM and CRLF line ends. The PDF report carries the controlled-copy stamp on every page and the PDF metadata. Both answer with Content-Disposition, nosniff and no-store, as the phase 7 exports do.
  • Filters never widen access. The search, the status, flag, station, worker and Mine filters are applied by the server inside each list's and board's own scope (a supervisor's departments, the stall's own assignments). A filter can only narrow what the person may already read.
  • Search words are bound parameters, matched with ILIKE and the LIKE escape. A query keeps at most 8 words of at most 60 characters each. IDs are matched as text, never cast.
  • Mine is decided on the server from the person's AD roles and the screen they name. Nothing the browser keeps can widen it.
  • Presence shows the display name and the role only (the user id, screen, packet id and since travel with it). No employee ID, no email, no other personal data. The server builds it from the session, not from what the browser says about itself.
  • A locked session drops out of presence at once, and comes back on unlock. A revoked session drops out at once and its stream ends. A closed tab drops out when the stream ends, within the 25-second ping cycle.
  • Saved filters live in the browser's localStorage (ps.flt, ps.proj, ps.pmtab, ps.pmper, ps.motion). They hold filter choices only. The search text is never saved. Nothing in them is trusted by the server.

AD reconcile and role refresh (phase 9)

  • ad-reconcile runs every night at 01:30 site time, and when an Admin presses "Refresh from AD now" on the Group map tab (a job request; the button answers 202 at once). It lists the PacketStation OU, reports mapped groups missing from it and extra groups in it, reads every mapped group's members through LDAP_MATCHING_RULE_IN_CHAIN (nested) in pages of 500, and upserts users on their objectGUID. AD is read first, then everything is written in one transaction.
  • A user disabled in AD, or gone from AD, is marked inactive and every session they have is ended (ad_disabled, ad_gone). A user still in AD but in no mapped group loses their roles and their sessions (roles_lost). Each ended session writes an auth.session_ended audit row with the reason and by: ad-reconcile.
  • Missing and duplicate employee IDs are flagged on the user (directory_flags) and listed on the Admin Profiles tab. A PIN sign-in by a duplicate employee ID is refused, as in phase 1.
  • Safety guard: the reconcile refuses and changes nothing when no mapped group is found in the OU (a wrong LDAP_GROUP_BASE_DN) or when every mapped group is empty while active users exist (PLAN.md open question 44). AD unreachable fails the run and changes nothing.
  • session-refresh runs every 15 minutes (the roles_refresh_minutes setting, 5 to 60). For every user with a live session it reads the account by objectGUID and its groups again, rebuilds the roles and each session's role snapshot, and ends the sessions of anyone disabled, gone or left with no role. AD unreachable makes the run degraded and ends nothing.
  • A request still re-reads the roles when its session's snapshot is older than the refresh interval, as in phase 1. Between the two, a person removed from a group loses the role within 15 minutes, and a person disabled in AD is signed out within 15 minutes, or at once after "Refresh from AD now".
  • Together these close the app's part of I6 (joiner, mover, leaver). The quarterly access review stays IT's.

Integrity job (phase 9)

  • integrity runs every night at 04:00 site time. It re-verifies every unbroken seal of a filed packet with the seal key (a seal under another key id is a finding), rehashes asset files (uploads of the last 7 days every night, every asset on Sundays) and records hash_checked_at and hash_ok, finds asset rows with no file and files in the assets folder with no asset row (older than an hour; reported, never deleted), and walks the audit hash chain.
  • Each finding is a row in the append-only integrity_findings (at most 500 per kind per run, the full count in the run). Any finding raises the integrity_failure alert, a security event that nobody can opt out of. A clean run clears it with a "Cleared" message. The Admin Security tab shows the last run and its findings.
  • A missing seal key makes the run degraded and leaves the seals unchecked, never "clean".
  • To run it at once, outside the schedule: sudo ps-run job run integrity on the VM (ps-run runs a packetstation command as the service user with the stack's settings; see DEPLOY.md), or POST /api/jobs/integrity/run as an Admin in a password session. The Admin page has no button for it.
  • This closes M3 (tamper detection on records, files and the audit).

Notifications (phase 9)

  • Mail goes through SMTP_HOST (app-03.pesco.com, port 25) with STARTTLS, verified against SMTP_CA_BUNDLE (the PESCO CA). There is no plain-text fallback: a relay without STARTTLS, a TLS error or a wrong certificate fails the message, and the failure is recorded. There is no SMTP login.
  • Each message is addressed To SMTP_FROM ([email protected]). The recipients are in the envelope only, so nobody sees the list.
  • Recipients are resolved when the message goes. Group members must be enabled accounts with a mail address. An inactive user gets nothing. Every message writes one append-only notifications_sent row with each address, the user and why it was chosen.
  • Bodies are plain text from the server. No link carries a token or a session. Raising an event never fails the action that raised it.
  • The test stack runs with NOTIFY_DRY_RUN=true: messages are recorded as dry_run and not sent. A test send from the Admin tab always sends. Subjects start [Packet Station] on production and [Packet Station TEST] on any other stack.
  • The security event types (security_alert, signin_burst, integrity_failure) cannot be opted out of on Account settings, and an Admin cannot exclude anyone from them.

Security alerts (phase 9)

The Admin Security tab lists, for the last 24 hours by default (up to 168):

Kind From
Account cooldowns (lockout) auth.lockout audit rows, written when an account goes over its failure limit
Wrong PINs (pin_failures) Three or more wrong PINs for one person in the window, at any step
Wrong unlock or signature (stepup_wrong_pin) A wrong PIN or password at unlock or at a signature
Denied actions (denied) policy.denied audit rows: any 403 after the person was known (method, path and code; never a body)
Sign-in bursts (signin_burst) auth.signin_burst audit rows: an address over its limit, or failed sign-ins across accounts in 5 minutes at or over the burst threshold
Admin changes (admin_change) admin.* audit rows
IT edits (it_edit) Audit rows with an IT reason
Integrity (integrity) integrity_findings rows

Account cooldowns, a PIN disabled, a session ended after wrong PINs and sign-in bursts are also mailed (security_alert, signin_burst).

Security settings (phase 9)

The Admin Security tab overrides these environment defaults, stored in the security settings row with compare and set, audited with before and after. A value out of bounds is refused; a stored value out of bounds is ignored and the default applies. The app reads them per request, cached for at most 30 seconds.

Setting Default Bounds
session_lock_minutes (lock after idle) 3 1 to 30
session_idle_minutes (sign out after idle) 15 1 to 60
session_desk_minutes (Admin and Clerical idle) 60 5 to 240
session_password_hours 12 1 to 24
session_pin_hours 10 1 to 24
roles_refresh_minutes 15 5 to 60
signin_account_limit (failed sign-ins per account before a cooldown) 5 1 to 15
signin_account_window_minutes 5 1 to 60
signin_account_cooldown_minutes 10 0 to 120
signin_ip_limit (failures per address) 30 5 to 200
signin_ip_window_minutes 5 1 to 60
signin_burst_threshold (failures across accounts in 5 minutes that raise an alert) 20 5 to 200

The account limit is capped at 15 so the app always stops before AD's own lockout at 20 attempts in 5 minutes. The PIN limit (5) and the step-up limit (3) stay environment settings.

Admin (phase 9)

  • Every Admin endpoint needs PS-Admins in a password session. Every Admin change is audited with before and after. Tests try every endpoint as every role.
  • Roles still come only from AD. The Admin edits departments (name, stations), profiles (stamp, profile active, station limits), the checklists (any department's, and the default lists by kind), the security settings, the notification rules and, from phase 10, the archive folder. None of these grants a role. Turning a profile off hides the person from pickers and changes no right.
  • Department codes and short names never change (a trigger, and 422 on the API). Stations are never deleted.
  • The access log CSV has the formula guard, at most 50,000 rows, and is logged in print_log and the audit.
  • The stamp check recomputes the stamp signature with the seal key and compares the text with the print log (S7, S8). It reads a pasted line or a PDF of up to 25 MB. From 2026-10-30 it is open to Q.C. and Clerical as well (see below).

SethD's changes (built 2026-10-30)

  • Stamp check (Gitea issue 32, change C9): POST /api/stamp-verify and /api/stamp-verify/pdf (and the /api/admin aliases) are for Admin, every Q.C. role and every Clerical role, in a password session. A PIN session is 403 password_session_required; every other role is 403. Each check is audited (admin.stamp_verified). The seal key never leaves the server, and the check never trusts the pasted text.
  • Project Management (Gitea issue 33, change C10): every /api/pm/* endpoint, the CSV and PDF exports included, is for PS-ProjectMgmt and Admin only. Tests try every endpoint as Clerical, worker, Q.C., Warehouse and shop supervisor and expect 403.
  • Department checklists (Gitea issue 23, change C8): any session reads them. A save needs that department's Supervisor or an Admin, in a password session (a PIN session is 403 password_session_required), with the version it started from. A lead, Q.C. and the office roles cannot save. Each save writes an audit row checklists.dept_updated with the list before and after. A stage keeps the list frozen at its START, so an edit cannot change what an open stage must tick.
  • Short approvals (Gitea issue 18, change C7): the department supervisor approves each receipt with a signature. short_approvals is append-only (triggers, and ps_app has SELECT and INSERT only), one row per receipt by a unique key.
  • Filing (Gitea issues 25 and 31, changes C3 and C5): FILE needs the unit's ITP complete and, on a VISUAL packet, the feed's own observation that VISUAL closed every work order. Clerical cannot mark a VISUAL packet closed by hand. Neither can be bypassed, Admin included.
  • Q.C. (Gitea issue 24, change C2): a reject always needs a reason of 8 characters; the accept after a reject without an NCR number needs one, checked by pattern on the server.
  • The data book without the history pages (D7; Gitea issue 8, change C6): the one data book goes to the customer and leaves out the packet log. The history stays in Packet Station, readable by anyone who can open the packet, and in the audit.
  • The Stall screen (Gitea issue 36, change C11): /api/stall/mine lists only the packets assigned to the signed-in person. The request names no station; the session decides.

Backups (phase 9, rewritten for the VMs in phase 10)

  • A nightly backup script runs as root from a systemd timer at 02:00 Denver time, an hour before the Proxmox Backup Server run. It dumps the database with pg_dump -Fc over the local socket, with no password read or passed, checks each dump with pg_restore --list, keeps 7, and writes a last-success or last-failure marker. On the VMs it runs as the postgres OS user, with no docker exec. The docker-1 test stack keeps the Docker version until it is stopped. The scripts and the install steps are in scripts/ and DEPLOY.md.
  • The roles are dumped with --no-role-passwords. A password hash in a file is an offline cracking target, and the file lands in PBS. The restore script refuses a globals file that holds a password hash. The role passwords stay in the secret files.
  • Dumps and globals are mode 0600. The backup folder is closed to other users; the worker only reads it.
  • The assets folder, the env file and the secret files are not in the dump. The PBS backup of the VM covers them (IT's). IT also keeps a copy of the secret files apart from the VM, so a restore onto a new VM does not depend on the old one. Without the seal key, the seals and stamps cannot be checked after a restore.
  • The worker's backup-check job raises backup_stale when the newest dump is missing, empty, older than 26 hours, or followed by a failure.
  • The restore was rehearsed on the test stack on 2026-10-21 into a separate database: every table's row count matched and all 189 asset files matched their recorded sha256 (DEPLOY.md, "Restore rehearsal 2026-10-21"). This closed O1 and O2 for the first rehearsal. The restore is rehearsed again on the test VM before go-live (go-live checklist). The quarterly restore test is IT's.

Dependency scanning (phase 9)

  • Every CI run scans the dependencies. pip-audit (pinned, in its own virtual environment) checks every pin in server/requirements.txt without resolving and fails on any known vulnerability, which is stricter than high or critical. npm audit --audit-level=high fails on a high or critical finding in the frontend, dev dependencies included.
  • visualkit and mfileskit come from PESCO's own index and are not audited. Their dependencies that we install are pinned in requirements.txt and are.
  • No finding is ignored. If one ever has no fix, the way out is --ignore-vuln <ID> with a comment and a date.
  • The first scan (2026-10-21) found pypdf 6.21.0 (GHSA-5hx5-vpf9-5wcv) and certifi 2026.7.22 (PYSEC-2026-4320). Both were bumped. This closed S3. Phase 10 bumped pypdf to 6.21.4, certifi to 2026.10.23 and pyasn1 to 0.6.5.
  • The VMs install from the same pins: public packages from PyPI, visualkit and mfileskit from the Gitea index with --index-url, never --extra-index-url.

Headers (phase 0, CSP pass in phase 10)

  • The app sets HSTS, CSP and the other security headers itself (server/packetstation/security.py). NPM's own HSTS is off.
  • Other headers: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: same-origin (not no-referrer, under which Firefox sends Origin: null and the Origin check refuses it), Permissions-Policy with camera, microphone and geolocation off, Cross-Origin-Opener-Policy: same-origin, and Cache-Control: no-store on every API answer.
  • The CSP allows nothing external: default-src 'self', script-src 'self', style-src 'self', img-src 'self' blob:, font-src 'self', connect-src 'self', worker-src 'self', frame-src 'none', object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'.
  • The phase 10 pass tightened it. style-src has no 'unsafe-inline': React style props and pdf.js set CSSOM properties, which CSP allows, and pdf.js adds fonts through the FontFace API. img-src dropped data: and keeps blob: for the page images the print path draws. worker-src dropped blob:, since the pdf.js worker is a file from the build. frame-src 'none' and object-src 'none' were added: PDFs are drawn on a canvas by pdf.js, never in a frame.
  • One exception: a PDF opened in its own tab gets the same policy with object-src 'self', because Chrome checks the PDF's own URL against object-src and would show a blank tab. The PDF runs none of the app's script. security.py is the record, with a comment on each directive.
  • The Playwright bench fails a test on any CSP violation, so a page that breaks the policy fails CI.

Build version banner (phase 10)

  • GET /api/version reports the build. A screen remembers the build it loaded and asks again when the window gets focus, when the event stream reconnects, and every 5 minutes.
  • When the build changed, a banner across the top says "Packet Station was updated. Reload to get the new version." with a Reload button. The screen never reloads by itself, so an open form is not lost. The server rules apply to every request whatever build the screen is on, so an old screen can never do more than the server allows.

Secrets (phase 0, VMs in phase 10)

  • On the VMs: root-owned files in /etc/packetstation/secrets/, readable by the service group only, read through the *_PASSWORD_FILE, SESSION_KEY_FILE and SEAL_KEY_FILE settings. A small launcher exports MFilesKit's password as MFILES_PASSWORD from its file, as the image's entrypoint does. Settings without secrets are in /etc/packetstation/packetstation.env.
  • In Docker (CI, and the test stack on docker-1 until the test VM is ready): Docker secrets. Test: files under /home/spencerm/packetstation/test/secrets, mode 0444, behind two 0750 folders owned by spencerm (Gitea issue 38).
  • The secrets: the PostgreSQL role passwords, the visualkit_ro, mfileskitRO and svc-packetstation passwords, the session key (session and device tokens) and the seal key (seals and stamps). Each key is at least 16 characters; a shorter one is refused at start.
  • Never in the repo, an env file, a log or chat. See DEPLOY.md for the files and GO-LIVE.md, "Incident notes", for rotation.