diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 410ab4a8..95aa54ca 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -8,22 +8,31 @@ # A single image pull is one resumable HTTPS stream, far more robust through # the proxy than hundreds of npm requests. # -# Channels map to branches: -# main -> ghcr.io//shopdb-flask:stable (badge off) -# dev-be -> ghcr.io//shopdb-flask:edge (SHOPDB_ENV_LABEL=DEV badge) +# Channels are mapped to how THIS repo actually ships, which is not how the +# branch this came from ships. Upstream, main is a release branch and dev-be is +# 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 -# `edge` stays genuinely bleeding edge. The image is also tagged with the short -# commit SHA for traceability and rollback. +# tag v* -> ghcr.io//shopdb-flask:stable (badge off) the release +# main -> ghcr.io//shopdb-flask:edge (DEV badge) the trunk +# +# 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 on: - push: - branches: - - main - - dev-be - # Allow a manual rebuild/publish of the current branch's channel image. + # push: + # tags: + # - 'v*' + # branches: + # - main + # Manual rebuild/publish of the current ref's channel image. workflow_dispatch: # One in-flight publish per branch; a newer push supersedes an older one. @@ -48,7 +57,10 @@ jobs: run: | owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" 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" else channel="edge" @@ -64,18 +76,18 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Connect to a persistent, out-of-cluster BuildKit deployment instead of - # 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 - # privileged DinD sidecar under cluster policy, so we point Buildx at a - # 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 - # hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment. - - name: Set up Buildx (remote BuildKit) - uses: docker/setup-buildx-action@v3 - with: - driver: remote - endpoint: ${{ secrets.BUILDKIT_ENDPOINT }} +# Connect to a persistent, out-of-cluster BuildKit deployment instead of +# 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 +# privileged DinD sidecar under cluster policy, so we point Buildx at a +# 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 +# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment. +- name: Set up Buildx (remote BuildKit) + uses: docker/setup-buildx-action@v3 + with: + driver: remote + endpoint: ${{ secrets.BUILDKIT_ENDPOINT }} - name: Build and push channel image uses: docker/build-push-action@v6 diff --git a/.github/workflows/scan-darkwing-portable.yml b/.github/workflows/scan-darkwing-portable.yml new file mode 100644 index 00000000..2c82d729 --- /dev/null +++ b/.github/workflows/scan-darkwing-portable.yml @@ -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' }} diff --git a/.github/workflows/scan-darkwing-pr.yml b/.github/workflows/scan-darkwing-pr.yml new file mode 100644 index 00000000..12ed8d89 --- /dev/null +++ b/.github/workflows/scan-darkwing-pr.yml @@ -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 diff --git a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs index 4976dc38..94755761 100644 --- a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs +++ b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs @@ -63,6 +63,19 @@ public sealed class LaunchPathPolicy 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); if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) { @@ -82,10 +95,112 @@ public sealed class LaunchPathPolicy 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; } + // 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) { foreach (var root in approvedRoots) diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs new file mode 100644 index 00000000..bee73158 --- /dev/null +++ b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs @@ -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; diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/README.md b/agent/ShopDbEndpointAgent.PolicyProbe/README.md new file mode 100644 index 00000000..390a67bb --- /dev/null +++ b/agent/ShopDbEndpointAgent.PolicyProbe/README.md @@ -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. diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj new file mode 100644 index 00000000..9112673f --- /dev/null +++ b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + lpptest + LppTest + true + + + + + diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx index e58da209..1855a388 100644 --- a/agent/ShopDbEndpointAgent.slnx +++ b/agent/ShopDbEndpointAgent.slnx @@ -3,4 +3,6 @@ + + diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml index 1d04ec1b..947cd047 100644 --- a/docker-compose.pull.yml +++ b/docker-compose.pull.yml @@ -83,7 +83,10 @@ services: environment: <<: *app-env 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: - instance_data:/app/instance diff --git a/docs/openapi.json b/docs/openapi.json index 3d20663c..180739c7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -21826,8 +21826,8 @@ "tags": [ "plugin-endpoints" ], - "summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state", - "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```", + "summary": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here", + "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": [ { "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": { "get": { "tags": [ diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 335fd2ed..b199931e 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -186,10 +186,11 @@ page rather than a single card. -->