Build the plugin set a real site runs, and write down what shipped

The lean-build job proved the delete-a-plugin guarantee with two plugins and
four hand-picked marker components. It now builds a real twelve-plugin set, and
the assertions follow a rule instead of a hunch: one component from EVERY
omitted plugin must be absent, one from EVERY chosen plugin must be present, and
the backend tree is checked both ways. An omitted set that grows without its
check growing is how a leak ships.

CHANGELOG had not been touched across the whole range, so the largest
security-behaviour change in the product - per-PC endpoint credentials, the
shared-key refusal for enrolled PCs, server pinning, agent 1.20.1 and tray 1.7.1
- had no release record. An [Unreleased] section now carries it, written from
the commits rather than from memory.

CLAUDE.md and the agent quick-reference stated that ADR-016 bans storing
credentials in the registry. It does not, and this fleet depends on HKLM for its
collector key and server pins: the next person following that text removes them
and silently breaks reporting and pinning on every un-enrolled PC. Corrected,
along with two other statements that were false on this branch - the database is
MySQL, not PostgreSQL, and column names are lowercase concatenated, not
snake_case, which is the opposite of what the gate enforces.

The wiki drafts are updated against the code as it stands, each old finding
given a verdict with a file and line rather than left as an assertion from a
week ago, and a new page records the ADR conformance result - including that the
ADR set itself is not on this branch, so nothing here enforces it except the
naming and style script.

They are also scrubbed of the things that must not reach a published wiki: the
site name, the internal git server, developer home paths and the assistant
tooling. They now pass the repository's own publishability patterns, which is
what the scrub is for.
This commit is contained in:
cproudlock
2026-09-18 13:31:25 -04:00
parent 8fc6df8f0a
commit 69a7997dc4
10 changed files with 1946 additions and 746 deletions

View File

@@ -702,6 +702,187 @@ ADR-007 and ADR-002.
`plugins/tools/frontend/labelPages.js`, where the column contract and the
two-sided page order are covered by tests.
### Added
- **Every PC gets its own endpoint credential, and enrolls to get it.** The
fleet authenticated with one shared collector key: every bay held the same
secret, one of them leaking meant reissuing all of them, and a PC could post
a check-in naming any asset it liked. A PC now enrolls once and is
authenticated as itself thereafter. `POST /api/endpoints/enroll` issues a
credential to a PC that matches exactly one computer on hostname and BIOS
serial and holds none yet; anything else - unknown hostname, a duplicate, a
serial that disagrees, a PC already enrolled - is recorded as a request an
administrator approves or rejects, and the agent's persisted nonce is what
binds an approved request back to the PC that asked. Enroll never refuses, so
a PC installed with no key at all still turns up in the queue instead of
vanishing. One auth helper serves every agent route: with
`X-Endpoint-Credential` the asset comes from the credential, so a request
naming a different PC is refused with `ASSET_MISMATCH` rather than believed,
and a revoked credential answers `CREDENTIAL_INVALID` so the agent
re-enrolls rather than going quiet. The core collector takes the credential
for the computers, printers and endpoints collectors through two hooks on
`shopdb.api` (contract 0.33.0).
Settings > Endpoints > Enrollment is where this is run. The Migration tab
shows readiness counts, each PC's state with the reasons it is blocked,
"Upgrade selected" and "Upgrade next 25", and live wave progress; a wave
packages the agent once and queues the existing per-PC upgrade. The
Credentials tab lists enrolled PCs with revoke, pending requests with
approve and reject, and the two switches. The fleet list gained a Credential
column and filter, and a PC's detail page shows its credential state, with
Revoke and Allow re-enrollment for an administrator. Migration 0023 creates
`endpointcredentials`, `endpointenrollrequests` and
`endpointmigrationwaves`.
- **The agent pins the server it talks to, and the registry owns the pins.**
An air-gapped site runs its own certificate, so "trusted chain" says very
little; the agent now checks the server's SubjectPublicKeyInfo SHA-256
against a pinned list before it will send a credential anywhere, and a
credential is only ever sent back to the server that issued it. Pins live in
`HKLM\SOFTWARE\GE\ShopDB` under `ServerPins`, with `appsettings.json` as the
fallback. The registry is AUTHORITATIVE whenever that value exists, even
when it is empty: the two used to be merged, so removing a pin from HKLM
never took effect anywhere. Two pins are carried at once, current and next,
so a certificate can be rotated without a flag day.
`Set-ShopDBServerPins.ps1` is the GE-Enforce entry point (`-Clear` deletes
the value, `-None` writes an empty one) and `Get-ShopDBServerPin.ps1` reads
what a PC has. `GET /settings/server-cert` returns the pin to put in that
entry, the Server Certificate page shows it, and the page also compares the
certificate the site actually presents at its public address with the
certificate file, warning when the two disagree.
- **An agent on a one-off PC from a single command.** A PC outside GE-Enforce -
a one-off, a loaner, something rebuilt in a hurry - had no path to an agent
short of doing the whole managed install by hand. An administrator now
generates a short-lived token in Settings > Endpoints > Endpoints and hands a
technician one elevated PowerShell line. The script refuses to run
unelevated, refuses a PC that already has an agent (a managed bay gets its
agent from GE-Enforce), checks both binaries against hashes the server baked
into the package, and installs with NO shared key - the PC then asks to
enroll and the script watches locally for two minutes for an administrator to
approve it. The token routes answer 404 to anything wrong rather than
confirming a token exists, are rate limited per address and are audited with
the caller, and a bootstrap package can never carry a collector key.
Migration `0024_endpoint_bootstrap_tokens` adds the token table, which stores
a hash, an expiry and a use count.
- **A kiosk admits a caller by where it is on the network, not only by its
token.** The device token assumes a handful of fixed screens provisioned
once, and that is not how these kiosks are used: most people open the page on
their own PC, there is no login, and per-browser provisioning would mean
touching every PC on the floor and losing it again at the next reimage.
Both the USB and printed-parts kiosks now admit a caller on either ground -
the per-device token, or a source address inside an allowlist - the same
shape the GE-Enforce client endpoints already use. `shopdb.api` gained
`ip_in_cidrs` and `request_ip_allowlisted` (contract 0.35.0) so the two
plugins share one gate rather than two copies of a CIDR parser; they fail
closed on an empty setting, an unparseable caller address and a malformed
entry, and they read `remote_addr`, so the IIS rewrite rule is what makes
them meaningful. The allowlists are separate per kiosk
(`usb_kiosk_allowed_cidrs`, `printedparts_kiosk_allowed_cidrs`) for the same
reason the tokens are, and both SHIP EMPTY: nothing opens by itself, and a
site that enters no ranges keeps exactly today's token-only behaviour. Both
settings pages get a picker listing the network plugin's subnets with their
VLANs, storing resolved CIDR strings rather than subnet ids so the setting
still works at a site that does not run that plugin. Stated plainly in the
admin UI: anyone on an allowed network can check a drive in or out against a
badge number they guess, and badges are attribution, not authorization.
- **A kiosk says when it is not set up, and an administrator can copy the setup
link.** The settings API masks every key whose name contains `token`, so the
real kiosk token is on screen for exactly one moment - after Generate, before
Save - and a screen provisioned with the literal mask answers 404, which is
also how the route answers a caller it has never heard of. Each kiosk gained
a permission-gated provisioning-link route that returns the real setup URL to
somebody who already holds `settings.edit`, and both settings pages a "Copy
setup link" button that falls back to showing the link when the clipboard is
refused. Revealing a token writes an audit row. The USB kiosk now asks the
server on load whether this screen may be used and says plainly when it may
not, instead of rendering a keypad that only fails once somebody has typed
their SSO.
- **The USB audit says whether a returned drive came back wiped and scanned.**
Removable-media custody is CMMC evidence, and custody without the state of
the media on return is half the record. Both stores already held the values -
`sanitized` and `scanned_viruses` in the external `checkinoutlog`,
`waswiped` and `scannedviruses` in the self-hosted `usbcheckouts` - and
neither mode selected them. Both now report them in the JSON, the CSV and the
column list. Only a return carries them, so a check-out row shows blanks
rather than asserting something about an event that had not happened yet, and
unknown stays blank rather than rendering as "No": both stores keep these
nullable deliberately, and "nobody wrote it down" is a different claim from
"it came back unwiped".
### Changed
- **Endpoint agent 1.20.1 and tray 1.7.1. Ship them together.** Every HTTP
client in the agent, script and package downloads included, is now built by
one `AgentHttpFactory`, so there is a single place the pins and the
credential are applied rather than one per caller. `EnrollmentService`
enrolls once using whatever shared key the PC already has, stores the
credential DPAPI-protected in a SYSTEM-only directory and swaps to it without
a restart; an old server with no enroll route simply leaves the agent on the
shared key. A named pipe lets the interactive user ask the agent to send
status, send a report, list alerts and acknowledge one, so `--once` and
`--report` run as a user become pipe clients, and `appsettings.json` is
restricted to SYSTEM and Administrators once the pipe is up. The tray holds
no key and reads no agent configuration at all - everything it does goes
through that pipe, an acknowledgement is remembered only once the agent
confirms it, and when the agent is not running the menu says so instead of
failing quietly.
The version numbers moved from 1.20.0 and 1.7.0 with no behaviour change of
their own: those binaries were built and shipped to the display scope before
two fixes landed, so a rebuild carried different bytes under the same version
number. Detection is by hash and copes with that, but nobody reading a
version number can tell two 1.20.0.0 files apart, and the runbook asks for one
build across the share, the agent store and the display profile.
- **A PC that has enrolled is refused the shared fleet key.** Once a PC holds
its own credential, presenting the fleet key is either a misconfiguration or
somebody else using a leaked secret, and either way the right answer is not
to accept it: that call is refused with `SHARED_KEY_REFUSED`. This only ever
applies to a PC that has already enrolled. `plugin.endpoints.sharedkeyallowed`
still DEFAULTS TO ON, so every un-migrated bay goes on reporting exactly as it
does today; a site turns it off deliberately once its fleet has moved, and the
page asks for confirmation and says how many PCs are not enrolled yet.
`plugin.endpoints.autoenroll` also defaults to on, so a clean hostname and
serial match is issued a credential without an administrator waiting on it.
- **The shared-key page now reads as the migration state it is.** Settings >
Endpoints presents the key as what PCs use UNTIL they enroll, with enrollment
progress, a link to Enrollment, and rotate and revoke confirmations that say
how many PCs would lose their only credential. The settings cards describe
the server certificate as what PCs actually receive, on IIS or Docker.
`AGENT-CONTRACT.md` documents per-PC credentials, and the rollout runbook
covers the phases, the gates, rollback, the pin and wave entries, and every
place the shared key lives and how to clear it at cutover.
### Removed
- **Client certificate authentication, the 2026-09-14 attempt.** `client_cert.py`,
certificate renewal, expiry monitoring, the unauthenticated
`/test/client-cert` route, the nginx client-certificate forwarding and the
agent certificate script are all gone. The header carrying the client
certificate could be forged on IIS, the private key was readable by users,
and the shared key remained a fallback underneath it all, so it bought
nothing the per-PC credential above does not buy properly. Migration 0023
drops the certificate columns from `assets` where a test database has them;
0021 and 0022 keep their ids but no longer touch the core table.
### Security
- **The tray no longer holds a secret.** It ran as the interactive user and
carried enough configuration to talk to the server on its own. Everything it
needs now comes over the agent's pipe, which means a standard user on a bay
can read nothing out of it.
- **`collect_log` refuses to collect the things that hold credentials.** Setup
answer files, `appsettings.json`, agent directories and key files are refused
at enqueue rather than shipped to the server inside a log bundle. The setup
token is no longer printed to the log either, and two VM notes that carried a
live collector key and advised plain http were deleted rather than corrected.
## [0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a