aa8028f96ea4672e8bb52247e268a2835a143715
806 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aa8028f96e |
Let the row refuse the write both kiosks were deciding in Python
Wave 1 of the remediation plan for the 2026-09-03 feature test run. Two kiosks, one shape of defect, one critical consequence. Both kiosks read the state, decided in Python, and then wrote. Nothing stops two requests interleaving between the read and the write, so both see the same pre-state and both proceed. Reproduced on MySQL 5.6 over HTTP, five concurrent takes of 5 from a bin holding 5: all five were accepted and quantityonhand landed on -20, because each request wrote its own arithmetic. The ledger had recorded every one of them, so the cached quantity and the ledger disagreed permanently, which is the number the shop floor actually reads. The USB kiosk had the same shape: concurrent scans of one drive all succeeded and inserted several open custody rows, so one drive was recorded out to two people and the documented 409 never fired. The comparison now lives in the WHERE clause, where the database settles it at write time. printedparts._move_quantity issues UPDATE ... WHERE quantityonhand >= :n and treats rowcount 0 as the refusal, raising InsufficientStock, which take and adjust turn back into the message they already gave - carrying the quantity the database holds now, not the stale one the request read. The low-stock alert derives its before-value from what the write produced for the same reason. Both USB paths claim the device with ischeckedout (self-hosted) or status (external cmmc_usb) in the WHERE clause; the external one claims BEFORE writing the log line, so a refused claim leaves nothing to undo. Check-in is guarded the same way. After the fix, the same five concurrent takes: one accepted, four refused, quantityonhand 0 and the ledger sum 0. Check-in also closes EVERY open row for the device rather than the newest. One device can only be out once, so a second open row is corruption, and closing only the newest left the older ones open forever - the device then read as returned while its custody history said otherwise. Separately, usbcheckouts now records who handed a drive back (checkinsso/checkinname, migration usb0004checkinidentity). Returning for a colleague is a supported flow that the kiosk asks to confirm, but the row it wrote attributed the check-in to the HOLDER, so the custody history and the audit report both named somebody who was not there. Nullable and deliberately not backfilled: copying the holder's badge into old rows would manufacture evidence for the exact claim this stops making. Four tests. Both guards verified by planting: strip the condition out of either UPDATE and its test fails. Scope: product |
||
|
|
377cc69264 |
Make a partly-undone import retryable, and refuse a plan it cannot deliver
Five findings from the review of the WINC import, plus the rendering each fix needed to be usable. An undo that hit a conflict wrote status 'undone' anyway, and undo_batch returns immediately for an undone batch. So the one entry that needed a retry was the only one that could never get it: the human resolves the conflict, clicks Undo, and gets a no-op. A conflicted undo is now 'partially-undone', undoneat is stamped only when the batch is genuinely finished, and the batches table offers "Retry undo" for that status. The column was String(16) and the new value is exactly 16 characters, so migration 7d38 widens it to 32 rather than rely on an exact fit. _UNDO_REFERENCES listed only assets, so a business unit this batch created was hard-deleted even when something else still pointed at it. Two of the three missing referrers are real FKs (dashboarddefaults.businessunitid, businessunits.parentid): MySQL raises and the whole undo rolls back, so the batch cannot be undone at all. The third, notifications.businessunitid, has no FK, so the delete succeeded and left a notification pointing at an id that no longer exists - silently, on both engines. All four are listed and the guard is tested through the route. The reference guard also queried tables that need not exist. A lean site drops the tables of plugins it does not install (ADR-014), and notifications is plugin-owned, so on such a site the query raised 1146, rolled the undo back and left the batch permanently un-undoable. Absent tables are skipped; a table that does not exist cannot hold a reference. _PACKET_GROUPS reports on location, but apply_plan only ever creates business units - it sets locationid only when the location already resolved. A plan needing a new location asked the admin to acknowledge it, got the tick, wrote nothing, and reported nothing. build_packet now takes the groups the writer can actually create; anything else needed is 'uncreatable' and blocks at plan time, where the admin can act on it, rather than dying at commit on a message about acknowledging something the screen had no control for. The writer refuses the same case independently. Both the console packet and the import page name the blocking groups: setting blocking without printing the reason would leave the banner pointing at nothing. The web undo route caught ValueError only, so refusing a batch belonging to another importer surfaced as a 500. That refusal is new too: the journal is a general primitive and undo_batch does not filter on kind, so any other importer's batches were reversible through this endpoint, carrying WINC's references map - which lists none of that importer's tables, making every reference check pass and every delete unconditional. Nine tests. The two guards were verified by planting violations: dropping dashboarddefaults from the references map, and passing creatable=None, each fail the test written for them. Scope: product |
||
|
|
405a2ef919 |
Cap request bodies, close a hole in the gate, fix to_dict and two settings pages
Six fixes that were ours regardless of what the sister site does. Four came out of our own reviews; two you reported from the settings pages. MAX_CONTENT_LENGTH was set nowhere. Every upload route caps its own payload, but each of those checks runs after the body has arrived: the first touch of request.form or request.files makes Werkzeug parse the whole multipart and spool the file to a temp file, so a route can reject the content and still have paid for the transfer and the disk. Three separate reviews found that shape in three different routes, which makes it a property of the app rather than of any handler. 128 MB, clearing the 64 MB agent binary with room for the envelope. The naming gate excluded devnotes with --exclude-dir, which matches a directory of that NAME at any depth - so a force-added plugins/<plugin>/devnotes/ was invisible to every check in the file, ADR-015 included. Replaced with a path anchored post-filter. Verified by planting the same site literal in root devnotes/ AND in plugins/endpoints/devnotes/: the gate now reports exactly one, the nested one. Before, it reported none. BaseModel.to_dict walked __table__.columns and published every one, so a model without an override published whatever it carried - including columns added later by someone not thinking about this method. User has no override, so User.to_dict() returned its bcrypt hash. Nothing calls it in a response path today, which is luck rather than design. A blanket allowlist would empty the output of all 34 models relying on the default, so this pins a floor instead: named secret-bearing columns are never emitted, and a model may extend the set. Eight tests, including a sweep over every mapped model. ActionPicker's Esc handlers had .prevent but not .stop, so the event bubbled to the wrapper and closed the whole picker instead of returning to the monitor list. The branding page reported "error loading image" on the site and badge logos. Two bugs stacked. The load failure was real: the preview used the stored value as a raw src, and that value is root-relative, so under /shopdb or /ops it resolved against the SERVER root and 404d - siteSettings.js wraps every logo in withBase() and this page was the one place that did not. With that fixed two of three were still invisible, because the shipped GE mark is fill="white" for the dark sidebar and was rendering white-on-white. The preview tile now sits on a mid-tone checker so artwork of any colour reads against it. The floor-map levels table ran off the right with its cells crushed - nine columns of inputs, no scroll wrapper, and .data-table is styled nowhere. My first attempt reached for the global .table-container and made it WORSE: that class also sets white-space: nowrap, and a full sentence of hint text lives in one of the cells, so nowrap forced the table wider still. It now scrolls in a local wrapper, the hint wraps, and the name input has a width instead of collapsing to one character. Suite 2377, frontend 324, gate green. Scope: product |
||
|
|
28269e429a |
Import the WINC batches CLI and its README
One commit from the sister site, purely additive: a read-only `batches` command
so an operator working from the shell can find a batchid to undo, and a README
covering the four WINC commands. Two new files under scripts/site_imports/winc/,
nothing existing touched.
Verified rather than assumed: it changes none of the files fixed in
|
||
|
|
13be0ef90f |
Import the wilm agent store and WINC adoption work, with four fixes
Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption import, a versioned agent binary store with upload and rollback, remote-BuildKit CI, tray version reporting, and UI fixes. Applied as a patch again - the published mirror rewrites hashes, so the branches have unrelated histories and git refuses to merge them. Reviewed by three agents before merging. Four things fixed here. THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in publish-images.yml sits at column 0 instead of indented under steps:, so the whole file is invalid YAML and GitHub would refuse it. Their entire GHCR distribution channel - which Start-ShopDBDev.ps1 and the compose files consume - was broken at the source and clearly never run. Indented; all five workflows now parse. THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the approval packet from the same upload and then asked the writer whether they matched, so packet_matches_plan compared a hash against itself. The documented contract - "a human approves a specific, frozen plan, and the writer can prove nothing drifted" - did not exist in the web path: an admin could preview file A and commit file B, gated only by the needs-create group NAMES they ticked. The preview's planhash is now required at commit and checked against a freshly rebuilt plan. Their tests committed without previewing, which is what let this through; they go through the real flow now, and three new tests pin it - all confirmed to fail with the check stubbed out. endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a ZIP"; with the upload routes it also means "choose the executable every future package installs and runs as SYSTEM". Before this, staging needed filesystem access to the server. The permission description still said only "generate and download a package", so an admin granting it could not see what they were granting. Reworded. NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size and timestamp; re-uploading a version replaces the bytes in place under the same label. A sha256 of the stored file is now recorded, and the upload is audit-logged with it - after an incident there was previously no answer to who put that binary on the fleet. Also documented the nine new routes in docs/api-inventory.json (that gate cannot run on the mirror, so it falls to us at promotion time again), regenerated the spec and the project map, and kept our own docs where they conflicted: docs/ is scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file rather than our 737-line canonical one. Their journal section is appended to ours. Contract version comes across as 0.30.0 for a genuinely additive surface. Suite 2369, frontend 324, gate green. Scope: product |
||
|
|
7f6aaf2e64 |
Gate the USB kiosk the same way, and make an unreachable setting a test failure
The USB kiosk had a feature flag and nothing else. A flag is a decision, not a credential: with the kiosk on, anyone who could reach the app could move a drive between checked-out and checked-in and sign it with a badge they guessed. Same reasoning that closed the parts kiosk, smaller blast radius - which is why Darkwing did not raise it and why it was worth asking about anyway. Both kiosks now share one mechanism. The device token moved out of the printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token by URL prefix, with one localStorage key per kiosk - they are separate screens in separate rooms, and a shared token would mean provisioning one grants the other. claimKioskToken() is shared too, so both views take the token from a ?token= URL once and strip it from the address bar identically. Then the part that matters more than either fix. I told you to set the new settings in the UI, and that was impossible: every plugin settings page carries a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled, printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read correctly, and rendered by no page - changeable only by writing SQL. tests/test_plugin_settings_reachable.py now fails when any declared setting appears nowhere in its plugin's frontend. It found seven MORE that were already in that state, all pre-existing: collector_corporate_ranges computers_configpolicies_enabled computers_machinelink_alerts computers_quietreporthours geenforce_reportstaleminutes printers_dashboardpercent warranty_expiringdays All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then fixed them and deleted the ratchet - a backlog list in a new check is a way of agreeing the check does not apply yet. There is no backlog; the check is absolute. Two of my own mistakes on the way, both caught by tests rather than by reading: the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so it called itself until the stack ran out; and the first version of the reachable check skipped all 17 plugins because it looked for get_settings() when the method is get_settings_defaults() - it passed by testing nothing, which is the exact shape it exists to catch. Suite 2336, frontend 316, build clean. Scope: product |
||
|
|
e4db112296 |
Use a valuetype the settings column actually documents
printedparts_kiosk_token declared valuetype 'secret'. The column documents string, boolean, integer and json, and that was the only 'secret' in the codebase - it worked only because Setting.get falls through to the raw string when it recognises nothing. Masking never depended on it: settings.py::_is_secret keys on the NAME containing 'token', so the value is still hidden in API reads and in the audit log. Changed to string, which is what it is. Scope: product |
||
|
|
744bc4341a |
Shut the parts kiosk behind a device token, and stop the health probe leaking
Both from the second Darkwing scan. Every claim in it was verified against the
code before acting.
The kiosk was the product's only unauthenticated write, justified by a decision
record saying it was "physically rate-limited: it serves a touch screen on the
shop floor". Nothing in code enforced the physical half, and an HTTP route is
reachable from wherever the app is. What that left open:
- _kiosk_find_item falls back to matching bare digits against the numeric
tail of every active item, so 1, 2, 3 walk the catalogue without knowing a
barcode;
- a badge is a short numeric SSO, and the route answered 422 for an unknown
one and 200 for a real one - an oracle for sorting guesses into employees;
- the write then decremented stock and stamped the ledger, which IS the audit
trail, with whatever SSO the caller supplied;
- quantity was bounded only by what was on hand, so one request emptied a bin.
A badge is attribution, never authorization. Both kiosk routes now need the
site's kiosk flag AND a per-device token, and 404 rather than 403 without them
so a caller does not learn the door is there. The READ is gated too: gating only
the write would have left the enumeration oracle running against a kiosk a site
had switched off.
The token is per-DEVICE, not per-page, because a kiosk is a browser on a touch
screen and a token served inside the page is readable by anyone who can load it.
An admin opens the screen once as /parts-kiosk?token=..., the page moves it into
that browser's localStorage and strips it from the address bar.
Also: quantity is capped per take, JSON true no longer passes as 1
(isinstance(True, int) is True), and every badge failure on the anonymous route
returns one message. The gated restock and adjust handlers keep the specific
message - there the person is known and the detail is help, not an oracle. I
changed the wrong one of those three handlers first; caught by the test.
/api/dashboard/health formatted str(e) straight into the body of a route
PUBLIC_ENDPOINTS declares open because "monitoring has no account". A pymysql
OperationalError names the database account, the app host's address, the driver
and the failing statement. It is logged server-side now and the body carries a
coarse status. The version goes with it - it told an anonymous caller which
release to look up. Status stays 200 either way: a monitor keyed on the body's
status field is the contract here, and turning a degraded database into a
transport failure is a separate decision from not leaking the exception.
Nine tests. The three gate tests were confirmed to fail with the gate stubbed
out, rather than assumed to cover it. Suite 2325, frontend 316, build clean.
Scope: product
|
||
|
|
852aec5b7f |
Verify the launch-path hardening on Windows, and fix what that found
The previous commit said the link-resolving change had never met a real filesystem. Run in the win11 VM against real fixtures, it turned out to reject EVERY legitimate launch path. ResolveDirectoryChain walked up looking for a junction and eventually asked whether "C:\" was a link. That throws DirectoryNotFoundException, and the caller treats a resolve failure as a rejection, so the first legitimate case came back "cannot resolve the real path: Could not find a part of the path 'C:\'". The agent would have failed closed on everything - an outage wearing a security fix's clothes. It compiled with zero warnings and read correctly. Stop at the volume root, and never let "cannot tell whether this is a link" turn into a rejection: TryResolveLink returns null on any failure, and the assembled path is re-checked against the roots regardless, so an unresolvable component still cannot widen what is accepted. One expectation in the probe was mine being wrong rather than the code. 8.3 short names used to be rejected because the lexical prefix check could not match C:\PROGRA~1 against C:\Program Files - fail closed, but by accident of not understanding the path rather than by intent. Resolution now expands it to the real directory, which is inside the approved root, so accepting it is correct. Verified on Windows, all ten cases: accept a real exe under the approved root accept 8.3 short name resolving into the approved root reject exe reached through a junction pointing out of the root reject alternate data stream on a file in the root reject traversal back out of the root reject UNC path reject sibling directory sharing the root's prefix reject wrong extension reject file that does not exist reject empty path The probe is committed as agent/ShopDbEndpointAgent.PolicyProbe so this is repeatable. It is NOT in CI and its README says why: junctions, alternate data streams and 8.3 names do not exist on Linux, and a suite that skipped every case it exists for would report green and mean nothing. This repo has been bitten by that shape more than once already. Scope: product |
||
|
|
e98e15259e |
Unpick one site's conventions from the shipped bundle, and bind the launch-path
check to the file it actually runs Two clusters the five-agent review found. Both are about a product that another plant has to be able to install. ADR-015: the shipped monitor bundle was the originating plant's fleet convention frozen into product seed. The business policy was seeded as "RPCA Shopfloor App Monitors". RPCA is that plant's facility designator, the row is source='shipped', and the API refused to rename it - so every adopting site would carry another plant's label at the top of its policy list forever. 0007 now seeds the neutral name, and 0010 renames it on installs that already stamped 0007, guarded so it cannot collide with a policy a site made itself. Downgrade is one-way on purpose: putting another site's designator back is not a state worth reaching. Which PC type the bundle attaches to was the literal 'Shopfloor PC'. A plant whose type is "Shop Floor Workstation" silently got no shipped monitors at all, and one using that name for something else got all of them. It is a setting now, defaulting to the seeded name so nothing changes here, and a blank value attaches the bundle to nothing - the right neutral answer for a site that does not want it. Shipped rows refused every edit, including isactive and launchpath. A plant whose eDNC is on D:, or that runs no UDC, could not correct the path or switch the monitor off, and then carried permanent false drift for it. ADR-015 allows seed data BECAUSE a site can edit it afterwards. Paths and the active flag are now editable; kind and displayname stay fixed, because that pair is the identity 0007's bundle matches on. Also corrected two false claims in 0007's docstring: the policy tables belong to the computers plugin, not the core baseline, and a guarded no-op does NOT reconcile later - Alembic stamps the revision either way, so the bundle would simply never seed. LaunchPathPolicy was purely lexical. GetFullPath is string normalization that never touches the disk, so the root check constrained the NAME while File.Exists and CreateProcess followed reparse points to somewhere else. A junction under a writable directory in an approved root - creating one needs no privilege, only write access to the parent, and vendor trees under Program Files are not reliably locked down - turned WebUI monitor-edit rights into code execution as the console operator. It now resolves the real target, leaf and directory chain both, and re-checks THAT against the roots, rejecting if resolution fails. Alternate data streams passed the whole policy on net8.0: an interior colon no longer throws in GetFullPath, so "readme.txt:payload.exe" reported an .exe extension, passed the root check, and existed. Rejected explicitly now. Six tests added for the shipped-row and pctype behaviour. The Core project compiles clean. The launch-path change has NOT been exercised against real junctions, ADS or 8.3 names - that needs the win11 VM, and per the reviewer it wants a planted-violation suite there rather than my say-so. Still open from the review: monitor revocation does not take effect until agent restart, ActionPicker is keyboard-inoperable, the lightbox has no focus trap, one attached monitor grants generic any-target process control, and screenshots are retained unencrypted in the user profile. Scope: product |
||
|
|
88a683dad3 |
Act on the five-agent review of the wilm import
Findings the reviewers verified, fixed here. The larger cluster they found is NOT fixed and is listed at the end. Frontend. reportImageUrl is called from the template, so it ran on every render with no in-flight guard: a report whose fetch was pending was re-requested, and because each resolution reassigns the map the whole grid re-rendered and re-fired the rest, costing up to N-squared requests for N thumbnails. Two blobs resolving for one key orphaned the first object URL where revoke could not reach it, and a failure was never recorded so a broken image retried forever. Guarded, deduplicated, failures remembered. Three CSS variables did not exist, so their hardcoded fallbacks always won: --bg-alt (the "No screenshot" tile, near-black in light mode), and --warning-text/-bg/-border (dark khaki on a dark modal). Replaced with real tokens. The DEV badge's literal #c62828 is var(--danger). Installer and launcher. The generated site installer ran docker compose with no -p, so the project name defaulted to the package folder - which carries the version. Installing the next version created a new project with new empty volumes and the site came up looking wiped, old data stranded under the old project. Pinned to shopdb-<channel>. Start-ShopDBDev deleted any existing -TargetDir that lacked a .git, recursively and without asking. For a script handed round as "save it anywhere and run it", a mistyped -TargetDir pointed at Documents would have destroyed it. It now only reclaims a directory that is empty or carries this launcher's own marker, and refuses otherwise. Dev secrets came from Get-Random, which is System.Random: a 32-bit seed, and all four values off one stream, so recovering one recovers the rest. They sign JWTs for a stack a pilot may point at real imported data. CSPRNG now, and the API port binds to loopback like the DB rather than every interface. Two of my own from earlier in this session. The operator-report size check sat below request.form, and the first access to that parses and spools the whole multipart - so it ran after the thing it prevents. Moved above. And the channel remap missed build-channel-installer.ps1 entirely, where it set BOTH channels to main: a "stable" package built from the trunk, the exact mislabel the remap was for. stable now resolves the newest v* tag. Also removed a zero-byte docs/ShopDB-Planner-Tasks-v2.csv that rode in. NOT FIXED - needs a decision, and it is the biggest thing the review found. Migrations 0006/0007 freeze the originating site's fleet convention into product seed: a shipped policy literally named "RPCA Shopfloor App Monitors", a hardcoded 'Shopfloor PC' type deciding who gets the shipped watch-list, and shipped rows the API refuses to edit or deactivate - including their launch paths. A site whose eDNC is on another drive cannot correct it and gets permanent false drift. Those are stamped migrations, so this gets harder to undo at every install. See the review notes before anyone upgrades a second site through 0006-0007. Suite 2314 passing, frontend 316, all three PowerShell files parse. Scope: product |
||
|
|
b5540e631c |
Merge the wilm endpoint-monitor and channel-distribution work into main
Six commits from wilm-import: the squashed import, route documentation, the channel remap onto our tag-and-trunk model, the project map, parallel test support, and the four smaller review findings. Merged rather than cherry-picked: the published mirror rewrites hashes, so the commits do not apply one at a time against our history even though the squashed diff applies clean. Scope: product # Conflicts: # docs/PROJECT-MAP.md |
||
|
|
720f4ddbb2 |
Close the four smaller findings from the wilm import review
None blocked the merge; all four were mechanisms that did not mechanise. _RUNNING_STATES existed twice, in api/routes.py and services/reconciler.py, with a comment asking a human to keep them in lockstep. The panel and the reconciler read the same agent snapshot, so a drift between the copies would paint a service green and flag it as drift on the same page. One definition now, owned by the reconciler and imported by routes - api -> services is the direction the plugin layout already runs in, and the reconciler's import of routes is deferred, so there is no cycle. The operator-report upload checked its 10 MB cap AFTER upload.read() had already pulled the whole body into memory, so the check could not prevent the thing it was there to prevent. content_length is now an early out and the read itself is bounded to cap+1, which is enough to know the cap was exceeded without taking a body that lied about its length. The multipart envelope carries the other form fields too, so the early out allows 64 KB of slack rather than rejecting an image that is just under. The same handler forced the extension to .png and the mimetype to image/png whatever the bytes were, then served the file back under that type. The upload headers (CSP sandbox + nosniff) make the document case inert, so this was never the hole it looks like, but nothing is gained by storing a file we already know is not what it claims. The PNG signature is now checked. The lightbox bound Escape and the arrow keys to a div and never focused it, so every one of those bindings was dead until the user happened to click the backdrop. It takes focus on open and hands it back to the thumbnail on close. On the two upload tests, honestly: the PNG one was confirmed to fail with the check removed. The oversize one passes either way, because the old code did reject an oversized image - it just did so after reading all of it. That test pins the response, not the fix. Whether the read is bounded is not observable through the response, so nothing here proves it; the change stands on reading the handler. Suite 2311 passing, frontend 316 passing, build clean. Scope: product |
||
|
|
d45a0d15b4 |
Make the full suite runnable in parallel, and close the dev/prod pin drift
The suite had grown to ~2300 tests and 5m30s. Profiling says there is no hot
spot to fix: the 25 slowest tests sum to about 60s of 331s, and the rest is
per-test fixture cost. The db fixture drops and recreates all 94 tables for
every test, measured at 0.058s a cycle, which is roughly 40% of the wall clock
on its own.
That shape parallelises almost linearly, so pytest-xdist buys more than any
rewrite would:
pytest -q 2309 passed in 331s
pytest -q -n auto 2309 passed in 103s (8 cores)
Deliberately NOT in pytest.ini addopts. Worker startup is dead weight on the
targeted single-file runs that make up most of a working day; the whole-suite
run before a push is where it pays. Opt in with -n auto.
A run distributed across 8 workers also has to be order-independent to pass at
all, so this is incidental evidence that the suite has no inter-test coupling.
Recompiling requirements-dev.txt to add xdist also moved idna, packaging and
pyjwt. That is the -c requirements.txt constraint doing its job rather than a
drift: all three ARE shipped, and the dev file had fallen BEHIND production
(idna 3.13 in dev against 3.19 in prod). CI was testing against older versions
than sites install, which is the exact failure the header comment on that file
was written about. Now matched.
Scope: product
|
||
|
|
28bf7ea1bf |
Regenerate the project map for the imported plugins and tests
Scope: product |
||
|
|
2e42972f44 |
Map the image channels to how this repo ships, and disarm auto-publish
The channel work assumed the branch model it was written against: upstream, main is a release branch and dev-be is the trunk. Neither holds here. main IS our trunk - 212 commits past v0.11.3 - and a release is a tag (ADR-007). dev-be does not exist at all, so the edge half would never have fired and the launchers would have cloned a missing branch. Taken literally it would have published our unreleased trunk to every site as :stable, which is the opposite of what the word promises to someone who has no way to check. Remapped throughout: tag v* -> :stable the thing that was actually released main -> :edge the trunk, DEV badge lit publish-images.yml keys the channel on github.ref_type = tag rather than on a branch name, so the mapping cannot silently drift back. Start-ShopDBDev.ps1, dev-bootstrap.ps1 and build-channel-installer.ps1 resolve stable to the newest v* tag via git ls-remote --sort=-v:refname, warning and falling back to main if a repo has no tags yet. DEV-BUILD-TEST.md follows. The push: trigger is commented out. Automatic publishing waits on someone confirming the GHCR package exists under this owner and deciding who may pull it; workflow_dispatch works meanwhile. Turning it on is one uncomment. All three scripts parse clean under pwsh, and the tag-selection expression was run against this repo rather than eyeballed. Per the standing rule, the runtime paths still want a pass in the win11 VM before anyone leans on them. Scope: product |
||
|
|
30b7a5a8bf |
Correct the claim that the two kiosks are gated to the same bar
Darkwing's reconnaissance flagged POST /api/printedparts/kiosk/take as an unauthenticated write reaching a ledger commit and an outbound mail/webhook trigger. Analysis dropped it, correctly: it is a declared exemption with a decision record. Nothing to fix in the code. The comment beside it was wrong, though. It described the USB kiosk writes as open at the "same bar as the parts kiosk" while listing a gate the parts kiosk does not have: usb_kiosk_enabled, off by default, every route a 404 until a site sets it. grep -rn kiosk_enabled plugins/printedparts/ returns nothing. So a site that adopts the product and has no parts kiosk still serves a live unauthenticated inventory-decrement endpoint with no switch to turn it off, and the registry that is supposed to be the record of these decisions said the opposite. Whether printedparts should gain the same flag is a behaviour change for a kiosk that is in daily use here, so it is left as a decision rather than made silently; this only stops the comment misleading the next reader. Scope: product |
||
|
|
f3a291a28d |
Stop a request-supplied host reaching the generated installer script
Found by the Darkwing scan of main (run 33654922078). Its own review dismissed
the finding as not remotely exploitable and it is right about that: nothing can
make a victim's browser attach a custom header, and deploy/windows/web.config
does no output caching. It still recommended the hardening, and the reasoning
for the dismissal is a property of OUR IIS config, not of the product - every
site runs its own instance behind its own proxy under ADR-004.
_batch_base_url() interpolated a caller-supplied host into the single-quoted
PowerShell -Uri literal of the .bat this route hands out. site_base_url ships
blank, so that fallback is the default path on a fresh install, not an edge
case, and GET /api/printers/install-batch is public.
Three rules now:
- the host is validated as a hostname before it is interpolated, so the quote
that would end the literal never reaches it,
- X-Forwarded-Host is only believed when TRUSTED_PROXY_COUNT declares a proxy
-- the rule shopdb/utils/clientip.py already applies to X-Forwarded-For.
That was centralised for this exact reason and did not cover the Host
variant,
- a request supplying neither usable header gets a relative base, which still
downloads correctly from the browser that fetched the script.
Three tests pin it, and all three were confirmed to fail with the old body
restored rather than assumed to cover it.
Scope: product
|
||
|
|
56ed89bb7b |
Document the ten new endpoint routes and undo the stray re-indent
Two of the three things holding the wilm import out of main. The monitor CRUD, the two resolver reads and the three operator-report routes had no entry in docs/api-inventory.json, so test_api_inventory_parity failed. The gate cannot run on the published mirror - the test ships but its data file is scrubbed, and it skips on the file's absence - which is why the branch that added the routes never saw it. Cost lands here at promotion time instead. /api/endpoints is registered both with and without a trailing slash and both forms are served, so both are described rather than guessing which one counts. Spec regenerated: 353 paths, 504 operations. plugins/endpoints/plugin.py had a contiguous run of continuation lines shifted by one space. Python accepted it and git diff -w showed nothing, but it put deletions in a diff that is otherwise purely additive. Restored; the file now differs from the published main by additions only. Scope: product |
||
|
|
302a309b9d |
Add the Darkwing vulnerability scan workflows
GE Aerospace now expects a Darkwing check on every repo. Two files rather than one, because they answer different questions and only one of them is ours. scan-darkwing-portable.yml is copied byte-for-byte from the ge-aero/darkwing README so it keeps matching the sanctioned integration. Manual dispatch only. scan-darkwing-pr.yml runs on pull requests and is ours to change. It exists because the README workflow cannot simply be given a pull_request trigger: it resolves its target as `inputs.target_ref || 'main'`, and a pull_request event carries no inputs, so the expression falls through to 'main' and the job scans the default branch instead of the code under review. That is a check that is green because it never looked. This one resolves the PR head SHA explicitly. Neither file gates a merge. Darkwing exits 0 whatever it finds, and its results vary between runs on identical code, so a passing job means the scan ran and nothing more. The SARIF-parsing step that would turn a finding into a failure is written but commented out, to be switched on once the false-positive rate on this codebase is known. Scope: product |
||
|
|
3587a35ad6 |
Regenerate the project map
Test count had drifted from the tree. The map is generated; a stale one fails scripts/gen_project_map.py --check in CI. Scope: product |
||
|
|
27b2817fd7 |
Import the wilm endpoint-monitor and channel-distribution work
Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.
Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.
NOT READY FOR main. Open items:
- 10 new routes are absent from docs/api-inventory.json, so
test_api_inventory_parity fails. That gate SKIPS on the published mirror
(the test ships, its data file is scrubbed), which is why wilm never saw it.
- dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
- plugins/endpoints/plugin.py carries a whitespace-only re-indent.
Scope: product
|
||
|
|
1480b30e8c |
Let a person edit the asset fields the pages already display
Eight fields across three asset types were shown on the detail page and accepted by the API, but had no input on the form. A wrong value therefore needed database access to correct, which is not a thing anybody should need for a device category or a maintenance date. The shape is the same in each case: a column was added, the detail card was written, the API allowlist was updated, and the form was missed. Nothing was broken - the values arrive by import or collector - so nothing complained. PCs gain azureobjectid and azuredevicecategory. azuredeviceid was already there, which is what made the omission easy to miss: the page showed two ids and the form offered one. The two are not interchangeable and the pairing is counterintuitive - the DEVICE id opens Intune, the OBJECT id opens Entra - so each input says where it goes. Machines gain the three maintenance fields. These matter beyond the immediate fix: they are the columns a measuring instrument's calibration dates map onto when metrology moves to machines, and until now there was no way to enter a calibration date on a machine at all. Printers gain iscolor, isduplex and isnetwork as a capability group. isnetwork defaults to true in the form to match the API's create default; a new printer claiming otherwise would be wrong for nearly all of them. Checked before making any of these editable that nothing writes them automatically - a hand edit silently reverted by the next collector poll would be worse than no input at all. The printers collector reports queues and supplies, not capabilities, and nothing writes machine maintenance dates, which is exactly why they are all empty. The two genuinely collector-owned computer fields, lastreporteddate and lastboottime, are deliberately still not editable. Scope: product |
||
|
|
c8e0de4dc1 |
Let the server decide which NIC is a PC's address, and keep the rest
A CMM reported 100.0.0.1 as its address and never reported 10.48.255.210 at all. Both halves of that were the same mistake. THE BAY WAS CHOOSING. The collector schema took a single `ipaddress`, so the script picked one NIC and dropped the others - preferring an address inside a configured range, else whichever interface carried a default route. A bay cannot make that judgement: it does not know the site's addressing. On this CMM the Marvell AQtion 10Gbit link to its controller is a real hardware NIC, up, and carries a default route of its own, so it satisfied every test the script had. It won, and the corporate address was discarded before the request was even built - which is why it is not in ShopDB even as a second row. So the bay now reports EVERY connected hardware NIC with a routable IPv4, and takes no view. The server picks the primary from collector_corporate_ranges, a new per-site setting, and writes a Communication row for each of the others. Naming the ranges is what turns the answer from a guess into a decision; with none set it still falls back to the default-route flag and then to the first reported, both guesses, both better than before only because nothing is thrown away now. Rows are keyed on the MAC where the bay sends one, because an address moves between adapters and a MAC does not. A NIC that is down at check-in is left alone rather than deleted - being down is not evidence the record is wrong, and hand-entered gateway, DNS and serial settings live on those same rows. Exactly one row ends up flagged primary, and only a row this report touched can claim it, so a check-in cannot silently demote an address set by hand. -AllowedRanges and the CollectorRanges registry value are now accepted and ignored, logging a line that says where the setting moved. The parameter stays because a scheduled task still passing it would otherwise fail on an unknown argument. A bay on an older collector sends one address and no interfaces; that keeps working and is pinned by a test. Scope: product |
||
|
|
241d8f40fc |
Add two commands for cleaning up computer types
Twenty computer types existed and six had a machine on them. Three of the empty ones were near-duplicates of live types - Shopfloor beside Shopfloor PC, CMM beside CMM PC, Engineer Workstation beside Engineer - which is the kind of list that grows because deleting from it by hand is nerve-wracking. retype-by-role types a PC by WHAT IT CONTROLS. A metrology box has an active relationship to a measuring_tool asset, so the fact is already recorded and does not need inferring from a hostname or a tool's name. The imaging scope is the authoritative source and stays so, but only 17 of roughly 292 PCs have ever reported one, so it cannot answer for the fleet yet. Relationships can: 49 PCs drive a measuring tool, and 36 of them are currently typed as ordinary shopfloor machines. prune-types deletes a type only when nothing is on it AND the live pctype map does not name it. The second guard is the one worth having: the map is what the collector assigns from at check-in, so deleting a mapped type would leave every future check-in logging 'mapped ComputerType not found' instead of typing the machine. Both report by default and write only with --apply, and prune prints why it is keeping each type it keeps rather than only what it removes. Also fixes `computers stats`, which counted the computer type named 'Shopfloor' - the EMPTY duplicate - rather than 'Shopfloor PC'. It has been reporting 0 shopfloor PCs for as long as both rows existed, which is the same duplication these commands clear up. Scope: product |
||
|
|
1bf4efa9e1 |
Link the Entra ids on a PC to the Azure portal
The Entra Device Info card showed two GUIDs and nothing to do with them.
Copying one into the portal by hand is the kind of errand a detail page exists
to remove.
Each link sits BESIDE the id it is built from rather than in a row at the top
of the card, because the pairing is counterintuitive: the DEVICE id opens
Intune and the OBJECT id opens Entra. A separate action row would have to
re-label which is which, and would still leave the reader matching them up.
BOTH URLS ARE SETTINGS WITH AN EMPTY DEFAULT, and that is the whole design.
The host is a tenant fact, not a product one - this site is on the US
Government cloud, portal.azure.us, where a commercial-cloud default would send
people to a page that does not exist. Microsoft also moves these blade paths,
and a site that hits that can fix it by editing a setting rather than waiting
for a release. Empty means no link and the id still renders as text, so a site
that configures nothing is unchanged. The example values live in each
setting's description, per cloud.
Same shape as the ServiceNow URL templates already in integrations, down to
the {token} placeholder, so there is one idiom for "deep link into a system we
do not own" rather than two.
Scope: product
|
||
|
|
259fe0de8f |
Read a numeric natural key as a name before reading it as an id
An Azure device import died part-way with a foreign-key error, and the crash was the lucky outcome. The Azure adapter routes a device that already exists to its real asset by writing that asset's assetnumber into the assetid column of the CSV it generates, expecting the engine to look it up. The resolver instead takes any all-digit string at face value as a literal id. This site numbers operations 0NNN, so numeric assetnumbers are ordinary here: `0704` was read as id 704, which does not exist, hence the error. `0600` would have been read as id 600 - a printer - and bound a PC to it silently, reporting success. Two changes, both needed. The resolver now tries the label first for an all-digit string and only falls back to reading it as an id when nothing is named that. The face-value behaviour exists for a real reason - exports round-trip and carry real ids, as its original comment says - so it stays as the fallback rather than being removed. Somebody writing `0600` in a column keyed on assetnumber means the asset called 0600, and binding the row to an unrelated record is far worse than failing. _existing_hostname_map now hands over the assetid rather than the assetnumber. Giving the resolver the id it is going to treat the value as makes the passthrough correct by construction instead of correct by luck. Verified against a restored copy of the production database: 234 devices, 221 matching an existing computer and updating in place, 13 new, no problems. The same run previously failed on the foreign key. Neither half was wrong alone. The passthrough dates from the original CSV import and the hostname map from the wilm merge months later; they only meet at a site whose natural keys are numeric. Scope: product |
||
|
|
2feb569686 |
Rename Metrics to Monitors and move it second
"Metrics" promised numbers. The tab holds tracked services, tracked processes and network interfaces - things being WATCHED and their current state, only one of which is a measurement. Monitors is what the panels actually are, and it matches the word the agent's own config already uses for them. Moved to second, ahead of Software. What is being watched on this box is the question asked right after "what is it", and well before its application inventory - which is reference data somebody goes looking for deliberately. Tab order is now Details, Monitors, Software, Activity. Contract 0.29.0 keeps its number. It is unreleased, so the group vocabulary it introduces is still being settled and nothing can yet depend on the old name. Both the server and the client fall back to 'details' for a group they do not recognise, so a site that deploys these halves out of step gets a misplaced card rather than a missing one. Scope: product |
||
|
|
c71183375f |
Fix four things found by looking at the tabbed page
SOFTWARE OPENED COLLAPSED. The installed-software panel was collapsible and started that way, because it used to be low-priority reference data near the bottom of one long page. It is now the Software tab's whole reason to exist, and somebody who clicked that tab has already said what they came for. Made it neither collapsible nor collapsed. TRACKED SERVICES VANISHED FROM METRICS. The group is decided by the SERVER, so a new frontend against a server still serving the old vocabulary puts a panel in a group the build does not render, and it is fetched, held, and shown nowhere - with nothing logged. Moving services out of 'actions' is exactly that shape. The client now falls back to 'details' for any group it does not render, the same way the server does, so a version-skewed pair degrades to a misplaced card instead of a missing one. A TABLE PANEL WITH NOTHING IN IT HID ITSELF. panelVisible honoured `empty` for list panels and ignored it for tables, for no reason anybody wrote down. A declared empty message is an answer - "No installed software reported." - where an absent card is a question. It matters more now that a panel can be the only thing in a tab, because the tab disappears with it. THE TAB STRIP HAD A VERTICAL SCROLLBAR. Per the overflow spec, when one axis is not `visible` the other computes from `visible` to `auto`, so `overflow-x: auto` alone made the strip scrollable vertically too and the -1px margin the tabs use to sit on the border was enough to trip it. Also moved .detail-full into the global stylesheet next to .content-grid, per frontend/CLAUDE.md: it is detail-page layout, and the other four detail pages need it when they adopt tabs. Scope: product |
||
|
|
f4b2ed7613 |
Drop the Actions tab, and give wide panels the width instead of a scrollbar
Four changes from looking at the built page. ACTIONS HELD ONE CARD. Tracked services was the only thing in it, and it was the wrong split anyway: a service row reads as state first and a Start/Stop second, so separating the buttons from the state they act on made a tab that answered no question on its own. Services move to Metrics, beside the rest of what the endpoint reports, and the group is gone from the contract. WIDE PANELS SPAN, THEY DO NOT SCROLL. Network interfaces carries adapter, address, MAC, speed and state; at half page width in the two-column flow it either squeezed every cell to three lines or offered a horizontal scrollbar. A scrollbar for data that just needs room is a layout failure. Table panels now take column-span, which is the multi-column layout's own answer, so short fact cards stay two-up around them. The wrapper keeps overflow-x only as a floor for a genuinely narrow viewport. SOFTWARE AND ACTIVITY RUN FULL WIDTH. Both are single long lists, and a list split into two balanced columns reads as two unrelated lists with no way to tell where the first ended. Details and Metrics keep the two-column flow because they are short cards, which is what that flow is good at. Contract 0.29.0 keeps its number: it is unreleased, and the group vocabulary it introduces has simply lost one value before anybody could depend on it. Scope: product |
||
|
|
2122c3b45c |
Tab the PC detail page, and let a panel say which tab it belongs to
The page had grown to roughly ten cards in one flow, and three more surfaces were wanted on it: installed software, live metrics, and the service controls. Stacking those into the same column would have buried what the page is for. Tabs were mocked and rejected on 2026-08-28, on the grounds that the strip duplicated the sidebar and cost hero space. That objection is answered by where the strip sits: the hero and the triage band stay above it and never move, so "is this machine OK" is still answered without a click, and the strip partitions the detail underneath rather than navigating anywhere. WHY A PANEL DECLARES ITS OWN GROUP Most cards on a detail page are not the page's. They arrive through the ADR-010 get_asset_panels hook, so a page that sorted panels into tabs by knowing their ids would defeat the hook it is built on. Panels now carry an optional group instead, and the page only knows the five group names. Resolution happens server-side, in the asset-panels route, so that an absent or misspelled group becomes 'details' before the client ever sees it. The client renders known groups only; an unrecognised value that survived to it would be a panel that exists and is displayed nowhere. Contract 0.28.0 -> 0.29.0, additive - every existing plugin keeps working untouched, and the endpoints status panel is deliberately left ungrouped so the default path is exercised by a shipped plugin and not only by tests. A tab is offered only when something fills it. An empty tab reads as broken rather than as "this site does not collect that", so the strip is built from the groups that actually returned data, and a tab that disappears on refetch falls back to Details rather than leaving the page blank. AssetDetailTabs is core, not a PCDetail component: machines, printers, network devices and measuring tools all have the same shape, and the shared asset-detail shell they need starts here. Scope: product |
||
|
|
ae7b30a056 |
Stop naming remote-access schemes as if they were constants
A link is built by formatting that protocol's own linktemplate, and the
templates are rows in accessprotocols that a site can edit. So no scheme
is a fixed fact about the product, and writing one down as though it were
misleads whoever reads it next.
Two places did. The guest-grant docstring said the detail route resolves
"working rdp:// and vnc:// links", which is wrong for the row we actually
ship: VNC is com.realvnc.vncviewer.connect://{host}:{port}, the RealVNC
handler, not vnc://. And the protocol admin form offered vnc://{host}:{port}
as its placeholder, in the one screen where somebody authors a new template,
so the example contradicted the seeded data it sits next to.
Neither changes behaviour. _computer_access_links already reads the
template from the database and the frontend only ever renders the resolved
link, so nothing in the code assumed a scheme - only the prose did.
Scope: product
|
||
|
|
df083776b3 |
Offer the search box to whoever can actually search
The sidebar box was drawn for anyone signed in, on the reasoning that offering it to a signed-out reader gives them a control that can only answer 401. That stopped being the right test when the guest role became able to hold assets.view: the box was hidden from exactly the visitor the grant was reopened for, and a guest could reach search only by typing /search?q= by hand. The client cannot decide this. Its hasPermission getter reads the signed-in user's permission list and a guest has no user at all, so asking it about a guest answers no for everyone. get_navigation already had the problem and already solved it by letting the server decide, so capabilities does the same thing and reports what the CALLER holds. It reports booleans about the caller and never what the guest role holds in general, so it tells an anonymous reader nothing the rendered page does not. Public because it is the pre-login call that decides whether the box is drawn at all. Members who lack assets.view now lose a box that only ever 401'd for them. On a failed call the box stays hidden rather than falling back to the old signed-in test, which would put it back in front of those same members. Scope: product |
||
|
|
723a4ff19b |
Declare the guest grants that already shipped
Inverting the grantable rule from the .view suffix to explicit declaration dropped every permission that had qualified by how it was spelled. Two of them were shipped guest features with grants already in the field. guides.view: the nav item and list_guides both reason in comments about a signed-out reader holding it, and the route is written around one - drafts are filtered out, search matches the published revision so draft wording cannot leak through a hit, and the author branches cannot open for a caller with no user id. tools.view: without it a guest holding tools.secrets has permission to use Secure Send and no way to reach the page, because tools.view is what puts the entry in the nav. The tools themselves run in the browser and touch no data. The failure was worse than a missing checkbox. update_role refuses the WHOLE request when any submitted permission is undeclared, and the role grid resubmits what the role already holds, so a site that granted either of these under the old rule could no longer save the guest role at all - including to tick assets.view, which is the grant the shop floor needs to look up an asset number without signing in. The test asserts both stay grantable, so narrowing the set again means looking at what is already granted in the field rather than only at what the catalogue declares. Scope: product |
||
|
|
d9eb7a74db | Regenerate the project map after promotion | ||
|
|
4d0baaff77 |
Gate each search domain on its own permission
Global search is gated on assets.view, and that was the whole check. Behind it the route queried twelve domains: applications, the knowledge base, guides, the staff directory, assets, measuring tools, USB devices, printed parts, custom fields, notifications, hostnames, and vendor and model reference data, plus addresses and subnets for an IP-shaped query. None of them consulted their own permission. So anyone who could look up an asset number could also read the staff directory out of the HR database, the USB checkout history and who holds which drive, and the network inventory. The SEARCH_DOMAINS settings look like they bound this but they are site feature toggles, not authorization. Each domain now answers only to a caller who holds its permission. Assets, custom fields and vendor/model stay on assets.view because all three emit asset rows routed to asset pages. caller_holds rather than require_permission: refusing the whole request because one domain is off-limits would make search useless to anyone holding less than everything. A domain the caller cannot see contributes nothing and the rest still answers. This is what makes assets.view safe to grant the guest role again. It left the grantable set in |
||
|
|
dd52666d6b |
Order the supplies card by how empty the cartridge is
Rows sorted on iscritical then on printer NAME. Critical is everything
at or below 5%, so a cartridge at 5% and one at 0% tied on the first key
and the alphabet decided which came first.
Wrong order on its own, and worse than it looks: the dashboard queue
keeps only the first few rows per source, so a printer that was
genuinely empty was truncated off the card while a fuller one held its
place. Reported from the floor as "starts at 5% and hides one at 0%",
which is exactly the two bugs compounding.
remaining is already normalised for waste cartridges - a full waste bin
reads as low remaining - so one ascending sort is worst-first for every
supply type, and iscritical falls out of it rather than needing its own
key. Name only breaks ties.
worstremaining is carried on the row because the supplies list is
rendered to text ("Black 0%") before it reaches the client, so the
number the sort needs is gone by then.
The toner forecast report was already worst-first (it sorts on daysleft)
and is unchanged.
Scope: product
(cherry picked from commit
|
||
|
|
b3cbeca1bf |
Keep local scratch notes out of the publication tree
The export rsyncs the WORKING TREE, so .gitignore does not apply to it:
anything ignored-but-present reaches the publication repo unless it is
named as an exclude. devnotes/ is declared never-committed and holds
review writeups and chat handoffs, and it went straight through.
The scrub gate stopped it, which is what that gate is for, and nothing
was committed or bundled. But a gate failure is a stop, not a filter,
and the fix a hurried person reaches for is deleting their notes to get
a bundle out.
Same shape as the stray .env.bak that reached this tree once before, so
the rsync now carries a note saying why excludes live there rather than
in .gitignore.
Scope: product
(cherry picked from commit
|
||
|
|
dbc43162c5 |
Pin the approved content digest on an endpoint command
Delivery descriptors for run_script and update_app are resolved when the
agent claims a command, not when an operator queues it, and the command
row recorded no digest: the enqueue path resolved the descriptor purely
to validate it and threw the result away.
So what an operator approved and what an endpoint runs were two separate
lookups against mutable content, minutes or hours apart. Anyone able to
replace a script body or an installer package could do so in the gap.
The claim then re-resolves, the descriptor faithfully describes the new
bytes, the agent verifies correctly, and it runs as LocalSystem. The
audit trail names the operator who queued the original.
The gate on the verb and the gate on the payload were also different
grants: pushing needs endpoints.deploy, but replacing what gets pushed
needs only applications.edit.
pinnedsha256 records the digest as it stood at enqueue, and the claim
refuses a descriptor that has moved, failing the push with an audit row
instead of shipping it. Null for actions that deliver no content, and
for rows queued before this migration, which claim as before. A
descriptor that vanished entirely is not treated as tampering: that path
already sends the command through undescribed so the agent fails closed.
Separately, a result may now only finalize a command that is actually
running. That needs no agent change and closes two things: reporting on
a command that was never claimed, and overwriting a recorded failure
with a success.
The agent now sends its hostname on the result callback so the server
can check it owns the command it is finalizing. The shared collector key
does not say who is calling, so without it any agent can post a result
for any command id. The server treats the parameter as optional for
agents that predate this and enforces it when present, so there is no
flag day. This is a floor, not a fix: only a per-endpoint credential
makes the caller's identity provable, and that is tracked separately.
The .NET side is unbuilt here and needs a build plus a win11 VM run.
Scope: product
(cherry picked from commit
|
||
|
|
ccd8221adf |
Key the client address on the connection, not a header the caller sends
client_ip() read the first X-Forwarded-For hop unconditionally. The
login rate limit and the account lockout are keyed on that value and the
audit log records it, so rotating the header per request bought an
unbounded budget and an untraceable trail.
This walked around TRUSTED_PROXY_COUNT, which exists to decide exactly
this. The app leaves ProxyFix off by default and says why, ten lines of
comment away: ProxyFix trusts X-Forwarded-*, a client can send those
itself, and the login limiter is keyed on the address. Then client_ip
read the header anyway, so the stated protection never applied.
Resolution is now from remote_addr, which ProxyFix has already filled
from the header wherever a proxy is genuinely configured. Both cases end
at the same trustworthy source, and the port still comes off because ARR
forwards clientip:port and ProxyFix passes it through verbatim.
geenforce and tools had each hand-rolled the same mistake for their own
limiters. Rather than duplicate security-relevant logic a third time,
client_ip and strip_port join the shopdb.api surface (contract 0.28.0).
geenforce's _trusted_client_ip, which already refused the raw header for
its IP allowlist, is untouched and was the model for this.
The stability table was also missing rows for 0.26.0 and 0.27.0.
INSTALL-WINDOWS-IIS.md never mentioned TRUSTED_PROXY_COUNT, so a site
installed from it runs behind IIS with the setting at 0: every request
looks like it came from IIS, the whole site shares one rate-limit
bucket, and the audit log records the proxy for every action. It now
says so, and says not to set it when nothing is in front.
Three test modules simulated separate callers by setting
X-Forwarded-For, which is the header a real attacker rotates to escape
the limit. One of them, test_client_ip_prefers_forwarded_first_hop_
stripped, asserted the defect outright. The suite could not have caught
this. They now drive REMOTE_ADDR, with a regression guard that forging
the header buys nothing.
Scope: product
(cherry picked from commit
|
||
|
|
d1a7907a2c |
End a user's sessions when the account is deactivated
There is no token denylist in this product, so the JWT user loader is
the only place a deactivated account can be stopped, and it did a bare
lookup by id. Somebody deactivated at 09:00 kept a working access token
until it expired, and a refresh token that minted fresh ones for thirty
days.
/api/auth/refresh and the API-token path already checked isactive. This
makes the loader consistent with them rather than inventing policy.
Scope: product
(cherry picked from commit
|
||
|
|
85e8a7b877 |
Decide guest grants explicitly instead of by permission name
guest_grantable_permissions() opened any permission whose name ended
'.view' or '.read' to the guest role, on the reasoning that reading is
the safe default. That is true of a permission gating a list of asset
numbers and false of one gating bytes, and a suffix cannot tell them
apart. Twenty-four of eighty-five qualified and none had been decided:
applications.view reached the SYSTEM-context PowerShell bodies and
the installer binaries pushed to bays
backups.view returned the full payload that the separate
backups.download permission exists to protect
assets.view gated a global search that queries the staff
directory, USB history and network inventory with
no per-domain check of its own
audit.view usernames, addresses, the before/after of every
settings.view change, and every setting row - both admin
category, opened by naming alone
users.view gates nothing at all; list_users is
require_role('admin')
Every entry is now declared, by CORE_GUEST_GRANTABLE or by the plugin
that owns the permission, having read what its routes actually return.
Adding one is a code change, which is the point: it puts the decision
in a diff rather than in a suffix.
The two routes serving script bodies and installer packages move to a
new applications.download permission, which is not grantable, so a site
can still open the catalogue metadata to its floor without the bytes
going with it.
The declared set preserves the grants this site had already chosen:
applications.view, computers.view, endpoints.view, reports.view,
tools.secrets, warranty.view.
Two known gaps are recorded on the hooks that expose them rather than
silently carried: the computer serializer publishes every column plus
resolved rdp:// and vnc:// links, and the per-asset endpoint panels are
keyed on a small integer so walking ids reconstructs much of the roster
that endpoints.fleet exists to protect. Neither is widened here.
A shipped test was granting guests audit.view and passing.
Scope: product
(cherry picked from commit
|
||
|
|
5f27f81e88 |
Clear the review blockers on the merged wilm work
A multi-agent review of the merged branch produced 29 findings that survived
adversarial verification. This clears the blockers and the four worth fixing
before it reaches main. The design held up well: path-traversal defence on all
four serving routes is clean, the agent's SHA-256 check fails closed, enqueue
authority is proportionate and audited, and no site literals or credential leaks
in the new C#.
THE TESTS NEVER RAN
pytest.ini sets testpaths = tests, so plugins/endpoints/tests/ was collected ZERO
times. Fifty-five lines of delivery assertions were dead code, and run directly
five of them failed. plugins/printedparts/tests/ had been invisible for longer.
That is the worst shape a gap can take: the suite is green, the coverage is
imaginary, and the collector auth on a download route could be deleted without
anything going red.
Both files moved under tests/. tests/test_no_hidden_tests.py now fails if a test
file appears anywhere pytest will not look; it caught a staged copy inside the
installer bundle on its first run, and was verified by planting a violation.
The five failures were stale expectations, and they split by cause. His: the
applications panel made six panels where his test said five, and 'applications'
belongs in KNOWN_PAYLOAD_KEYS now that it is persisted - his own test contradicted
his own feature. Ours: we renamed the events card to activity, moved the
fleet-wide cards to endpoints.fleet, and made the list route a bare route('')
which Flask rejects unless the blueprint is mounted under a prefix.
FOUR FIXES
The snapshot table's key was a 32-bit autoincrement on a table rewritten
wholesale every check-in, so it climbs at (endpoints x apps) per report and
exhausts on MySQL. Widened while the table is unreleased. Widening it broke every
insert on SQLite, which only autoincrements a column typed exactly INTEGER
PRIMARY KEY, so it carries a dialect variant - and nothing caught either fault,
because no test inserted into that table at all. One does now.
Ingest truncated displayname but not displayversion or publisher. On strict MySQL
an over-long registry value fails the whole check-in transaction, so one machine
with a malformed Uninstall entry goes silent entirely. SQLite does not enforce
lengths, so the suite would never have shown it.
update_app's version: branch pinned AppVersion.sha256, but that route resolves the
version to its APPLICATION and serves application.installpath - so the digest
described an artifact the route never sends, and where it was null the agent had
nothing to verify and failed open. It now pins the file actually served.
The same branch ended the delivery URL at the id, so an MSI arrived with no
extension and could not be routed through msiexec. It carries the filename now.
Agent packaging moved off require_role('admin') to a new endpoints.agentpackage
permission. The generated ZIP carries a live collector key, so this is a heavier
grant than deploying to one endpoint - and a role cannot be given to one person
for one job the way a permission can.
Full suite 2231 passed (2198 before, plus the 22 that never ran and 11 new),
frontend 314.
|
||
|
|
7dc1f0bee8 |
Bump plugin contract to 0.27.0 for the Script surface addition
Commit
|
||
|
|
7c09f1e4ca |
Add a standalone endpoint agent uninstaller
Reverses everything the installer and site-agent generator create: stops and deletes the service, kills agent/tray processes, removes the tray Run key and the HKLM\SOFTWARE\GE\ShopDB credential hive in both registry views, drops the Defender path exclusion, and deletes the install directory and ProgramData tree. Every step is best-effort and idempotent so it is safe to re-run and safe on a partially installed host. |
||
|
|
4f01c3d2d4 |
Serve the dev/test stack over HTTPS with a mounted self-signed cert
Terminate TLS in gunicorn using a host-generated cert mounted read-only under ./certs, so the browser sees a secure context. Secure-context-only web APIs (navigator.clipboard and others) refuse to run over plain http on a non-localhost origin, which the FQDN-accessed test box hits. Healthcheck now probes https with -k to match the TLS listener. A site swaps in its own cert or fronts this with its own IIS/nginx proxy. |
||
|
|
84f9cfe13d |
Agent: execute run_script and update_app delivery pushes
Bumps the agent to 1.4.1. run_script fetches a ShopDB-hosted script by digest and runs it in the script's configured context (LocalSystem or the interactive logged-on user), replacing the local scriptpaths allowlist; user-context runs wait for exit and report the real exit code instead of fire-and-forget. update_app resolves an app-level or version-level install source, fetches the pinned bytes, verifies sha256, and runs silently. Both stage into a fixed Defender-excludable folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem temp path. Adds the DeliveryClaim contract for the claim payload. |
||
|
|
4590156f30 |
Add endpoint software delivery: run_script and update_app push
Adds the two delivery actions to the endpoints command allowlist, each gated on a new endpoints.deploy permission ON TOP OF endpoints.command so pushing software is a heavier grant than a routine toggle. Enqueue validates update_app appversionid ownership and installer presence before queueing. New collector-authenticated download routes let the agent fetch installer packages and script bodies by digest rather than pre-staging them or sending bodies over the command wire. Wires the scriptsApi client, the siteagent generator routes, and a temporary dev-only Delivery test bench (view/route/nav) for exercising the path while the production UI is built. |
||
|
|
7ac1772881 |
Add installer/script delivery metadata to the core software model
AppVersion gains sha256 and silentargs so the endpoint update_app push can pin exact installer bytes and carry per-version silent switches; Application gains installersha256 for the app-level install source. New ShopDB-hosted script library (Script model plus scripts API) backs run_script delivery, with the body fetched by digest on push rather than pre-staged or sent over the command wire. Two idempotent migrations with real downgrades. Script added to the shopdb.api contract surface for plugin use. |
||
|
|
5f8061c55d |
Self-heal endpoint command queue wedged by a dead in-flight command
The command queue is at-most-one-in-flight, but nothing ever expired a stuck command. When an agent claimed a command (RUNNING) then died before reporting a result - killed, uninstalled, or offline mid-flight - the row stayed in-flight forever and every operator action for that endpoint returned 409 'already in flight'. Reap in-flight rows past a staleness ceiling (15m for RUNNING, 60m for PENDING) to a new STATUS_EXPIRED at enqueue time, inside the same transaction, so the queue self-heals lazily the next time someone queues work. No background job and no schema migration - status is free-text. |