Add log collection, script output capture, and timestamp fixes
Log Collector: a new collect_log action reads a plaintext log file from a known path and returns its tail for viewing and download. Rides the existing endpoints.updateapp grant rather than inventing one, since collecting a log is the same trust level as pushing an application. Collected logs land in their own table with per-asset retention of 20 instead of overloading the command audit row, and the agent refuses UNC, traversal, wildcards, directories and non-text content independently of the server check. collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops anything unnamed - so every policied endpoint refused log collection outright, including for admins, who saw a permission-shaped error for a grant they held. Exemption from policy is not exemption from permission: the elevated grant still applies, and a regression test asserts both halves so a future edit cannot quietly loosen the grant while "fixing" the policy gate. Script output capture: successful runs can return output, gated per script by captureoutput. Failures always returned it; success never did, which left a green badge as the only evidence a run produced. Timestamps: Activity and Operator Reports rendered date-only because the shared formatter's 'date' branch drops the clock time. Run Scripts additionally labels its timestamp, since a bare time meant "completed" on a terminal row but "queued" on an in-flight one - indistinguishable, and the wrong reading. Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked for it fails closed on the unknown-action default.
This commit is contained in:
@@ -594,6 +594,229 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Recent runs. A script that exits zero shows a green badge and
|
||||
nothing else, which is not enough to tell "it did the work" from
|
||||
"PowerShell started and stopped cleanly". The exit code, the
|
||||
context it ran as and the directory it really ran in are shown so
|
||||
the operator can judge that themselves. -->
|
||||
<div class="card">
|
||||
<h3>Recent Script Runs</h3>
|
||||
<div v-if="scriptrunsloading" class="runscripts-empty">
|
||||
Loading recent runs...
|
||||
</div>
|
||||
<div v-else-if="scriptruns.length === 0" class="runscripts-empty">
|
||||
No scripts have been run on this computer yet.
|
||||
</div>
|
||||
<ul v-else class="scriptrun-list">
|
||||
<li
|
||||
v-for="run in scriptruns"
|
||||
:key="run.endpointcommandid"
|
||||
class="scriptrun-row"
|
||||
>
|
||||
<div class="scriptrun-head">
|
||||
<span
|
||||
class="scriptrun-badge"
|
||||
:class="`scriptrun-badge--${run.status}`"
|
||||
>{{ scriptRunStatusLabel(run) }}</span>
|
||||
<span class="scriptrun-name">
|
||||
{{ run.scriptname || run.target }}
|
||||
</span>
|
||||
<span class="scriptrun-when" :title="scriptRunTimeTitle(run)">
|
||||
{{ scriptRunTimeLabel(run) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="run.resultmessage" class="scriptrun-message">
|
||||
{{ run.resultmessage }}
|
||||
</p>
|
||||
|
||||
<dl v-if="run.resultdetail" class="scriptrun-facts">
|
||||
<template v-if="run.resultdetail.exitcode !== undefined
|
||||
&& run.resultdetail.exitcode !== null">
|
||||
<dt>Exit code</dt>
|
||||
<dd class="mono">{{ run.resultdetail.exitcode }}</dd>
|
||||
</template>
|
||||
<template v-if="run.resultdetail.runcontext">
|
||||
<dt>Ran as</dt>
|
||||
<dd>
|
||||
{{ run.resultdetail.runcontext === 'user'
|
||||
? 'The logged-on user' : 'SYSTEM' }}
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="run.resultdetail.workingdirectory">
|
||||
<dt>Ran in</dt>
|
||||
<dd class="mono">{{ run.resultdetail.workingdirectory }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<!-- Output is only reported back on failure; on success it stays
|
||||
on the endpoint. Shown verbatim when present because it is
|
||||
the thing that explains the failure. -->
|
||||
<pre
|
||||
v-if="run.resultdetail && run.resultdetail.output"
|
||||
class="scriptrun-output"
|
||||
>{{ run.resultdetail.output }}</pre>
|
||||
|
||||
<p
|
||||
v-if="run.status === 'succeeded' && !hasRunEvidence(run)"
|
||||
class="scriptrun-note"
|
||||
>
|
||||
The agent reported success but sent no detail. This agent may
|
||||
predate run reporting.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="scriptrun-foot">
|
||||
A zero exit code means the script ran and PowerShell exited cleanly.
|
||||
It does not confirm the script achieved what it was written to do.
|
||||
Output is shown for failures, and for successes only when the script
|
||||
is set to return it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Collector. Manual retrieval of a plaintext log from a known path.
|
||||
Gated on the application install/update grant, because reading an
|
||||
app's log is part of the same job as pushing that app. -->
|
||||
<div v-show="activegroup === 'logcollector'" class="detail-grid">
|
||||
<div class="card">
|
||||
<h3>Collect a Log File</h3>
|
||||
|
||||
<p class="logcollect-intro">
|
||||
Reads a plaintext log from this computer and stores a copy in ShopDB
|
||||
so it can be read here or downloaded. The agent only ever reads the
|
||||
file - it is never modified, moved or deleted.
|
||||
</p>
|
||||
|
||||
<div v-if="!liveness" class="logcollect-note logcollect-note--warn">
|
||||
This computer is not currently checking in. A collection queued now
|
||||
would sit unclaimed, so collecting is disabled until it reports
|
||||
again. Previously collected logs are still readable below.
|
||||
</div>
|
||||
|
||||
<div class="logcollect-form">
|
||||
<label class="logcollect-label" for="logcollect-path">
|
||||
Full path to the log file
|
||||
</label>
|
||||
<input
|
||||
id="logcollect-path"
|
||||
v-model="logpath"
|
||||
class="logcollect-input mono"
|
||||
type="text"
|
||||
placeholder="C:\ProgramData\ShopDB\EndpointAgent\agent.log"
|
||||
:disabled="collectingLog || !liveness"
|
||||
@keyup.enter="collectLog"
|
||||
/>
|
||||
<p v-if="logpatherror" class="logcollect-error">{{ logpatherror }}</p>
|
||||
<p class="logcollect-hint">
|
||||
Local path including the drive letter. Plaintext files only
|
||||
(.log, .txt, .xml, .json, .csv and similar). Large files are
|
||||
trimmed to the last 2 MB, which keeps the most recent entries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="logcollect-actions">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!logpath.trim() || collectingLog || !liveness"
|
||||
@click="collectLog"
|
||||
>{{ collectingLog ? 'Collecting...' : 'Collect Log' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The most recently collected log, opened for reading. Separate from
|
||||
the history so the thing just collected is visible without a
|
||||
second click. -->
|
||||
<div v-if="activelog" class="card">
|
||||
<div class="logview-head">
|
||||
<h3>{{ activelog.sourcepath }}</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadLog(activelog)">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="logview-facts">
|
||||
<dt>Collected</dt>
|
||||
<dd>{{ formatDateTime(activelog.collectedat) }}</dd>
|
||||
<template v-if="activelog.collectedbyusername">
|
||||
<dt>Collected by</dt>
|
||||
<dd>{{ activelog.collectedbyusername }}</dd>
|
||||
</template>
|
||||
<template v-if="activelog.filemodifiedat">
|
||||
<dt>File last written</dt>
|
||||
<dd>{{ formatDateTime(activelog.filemodifiedat) }}</dd>
|
||||
</template>
|
||||
<template v-if="activelog.linecount !== null">
|
||||
<dt>Lines</dt>
|
||||
<dd class="mono">{{ activelog.linecount }}</dd>
|
||||
</template>
|
||||
<dt>Size on disk</dt>
|
||||
<dd class="mono">{{ formatBytes(activelog.filesizebytes) }}</dd>
|
||||
</dl>
|
||||
|
||||
<p v-if="activelog.istruncated" class="logcollect-note">
|
||||
This file was larger than the collection limit, so only the end of it
|
||||
was kept. The oldest entries are not shown.
|
||||
</p>
|
||||
|
||||
<div v-if="logcontentloading" class="runscripts-empty">
|
||||
Loading log contents...
|
||||
</div>
|
||||
<pre v-else class="logview-pane">{{ activelog.content }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- History. Capped at 20 server-side; the note says so, because a
|
||||
silently trimmed list looks like data loss. -->
|
||||
<div class="card">
|
||||
<h3>Collected Logs</h3>
|
||||
<div v-if="logcollectionsloading" class="runscripts-empty">
|
||||
Loading collected logs...
|
||||
</div>
|
||||
<div v-else-if="logcollections.length === 0" class="runscripts-empty">
|
||||
No logs have been collected from this computer yet.
|
||||
</div>
|
||||
<ul v-else class="logcollect-list">
|
||||
<li
|
||||
v-for="entry in logcollections"
|
||||
:key="entry.endpointlogcollectionid"
|
||||
class="logcollect-row"
|
||||
:class="{ 'logcollect-row--active':
|
||||
activelog && activelog.endpointlogcollectionid === entry.endpointlogcollectionid }"
|
||||
>
|
||||
<div class="logcollect-rowhead">
|
||||
<span class="logcollect-path mono">{{ entry.sourcepath }}</span>
|
||||
<span class="logcollect-when">
|
||||
{{ formatDateTime(entry.collectedat) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="logcollect-rowmeta">
|
||||
<span>{{ formatBytes(entry.filesizebytes) }}</span>
|
||||
<span v-if="entry.linecount !== null">
|
||||
{{ entry.linecount }} lines
|
||||
</span>
|
||||
<span v-if="entry.istruncated" class="logcollect-trunc">
|
||||
trimmed
|
||||
</span>
|
||||
<span v-if="entry.collectedbyusername">
|
||||
by {{ entry.collectedbyusername }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="logcollect-rowactions">
|
||||
<button class="btn btn-secondary btn-sm" @click="viewLog(entry)">
|
||||
View
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadLog(entry)">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="scriptrun-foot">
|
||||
The 20 most recent collections are kept for this computer. Older ones
|
||||
are removed automatically as new logs are collected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
@@ -930,6 +1153,14 @@ const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript
|
||||
const canViewOperatorReports = computed(
|
||||
() => authStore.hasPermission('endpoints.operatorreports'))
|
||||
|
||||
// Gates the Log Collector tab. Rides the application install/update grant
|
||||
// rather than endpoints.runscript, matching ELEVATED_ACTION_PERMISSIONS on the
|
||||
// server: collecting an app's log is part of the same job as pushing that app,
|
||||
// and reading a text file is strictly weaker than the installer execution this
|
||||
// grant already permits. Declared here with the others because detailtabs reads
|
||||
// it during setup.
|
||||
const canUpdateApp = computed(() => authStore.hasPermission('endpoints.updateapp'))
|
||||
|
||||
const detailtabs = computed(() => {
|
||||
const filled = assetPanels.value?.populatedGroups ?? new Set()
|
||||
const has = (key) => filled.has ? filled.has(key) : false
|
||||
@@ -971,6 +1202,16 @@ const detailtabs = computed(() => {
|
||||
if (canRunScript.value && liveness.value) {
|
||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
||||
}
|
||||
// Log Collector rides the application install/update grant, not the script
|
||||
// grant: the reason to read a log is almost always to find out why an app
|
||||
// push behaved as it did, so it is the same job as pushing the app. Reading a
|
||||
// file is strictly weaker than the installer execution that grant already
|
||||
// allows. Liveness-gated like the others - collection queues against a live
|
||||
// agent. The history stays readable, so the tab is not hidden once the
|
||||
// machine goes quiet; only the Collect button disables.
|
||||
if (canUpdateApp.value) {
|
||||
tabs.push({ key: 'logcollector', label: 'Log Collector' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1518,9 +1759,232 @@ async function loadScriptLibrary() {
|
||||
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
||||
// never open this, and the library is irrelevant to every other tab.
|
||||
watch(activegroup, (group) => {
|
||||
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
||||
if (group === 'runscripts' && canRunScript.value) {
|
||||
loadScriptLibrary()
|
||||
loadScriptRuns()
|
||||
}
|
||||
if (group === 'logcollector' && canUpdateApp.value) {
|
||||
loadLogCollections()
|
||||
}
|
||||
})
|
||||
|
||||
// Recent run history for this endpoint. Refetched rather than accumulated
|
||||
// client-side so the panel reflects what the server actually recorded,
|
||||
// including runs pushed by another operator from another session.
|
||||
const scriptruns = ref([])
|
||||
const scriptrunsloading = ref(false)
|
||||
|
||||
async function loadScriptRuns() {
|
||||
scriptrunsloading.value = true
|
||||
try {
|
||||
const response = await endpointsApi.listScriptRuns(computer.value.assetid, 5)
|
||||
scriptruns.value = response.data.data || []
|
||||
} catch (error) {
|
||||
// History is supporting evidence, not the action itself. A failure here
|
||||
// must not read as a failed script run, so it is not toasted.
|
||||
scriptruns.value = []
|
||||
} finally {
|
||||
scriptrunsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Log Collector ----
|
||||
|
||||
const logpath = ref('')
|
||||
const logpatherror = ref('')
|
||||
const collectingLog = ref(false)
|
||||
const logcollections = ref([])
|
||||
const logcollectionsloading = ref(false)
|
||||
const activelog = ref(null)
|
||||
const logcontentloading = ref(false)
|
||||
|
||||
// History for this endpoint. Metadata only - bodies are fetched one at a time,
|
||||
// because 20 rows of multi-hundred-KB logs would make opening the tab far more
|
||||
// expensive than reading the one log that is actually wanted.
|
||||
async function loadLogCollections() {
|
||||
logcollectionsloading.value = true
|
||||
try {
|
||||
const response = await endpointsApi.listLogCollections(computer.value.assetid)
|
||||
logcollections.value = response.data.data || []
|
||||
} catch (error) {
|
||||
// Supporting evidence, not the action itself; a failure here must not read
|
||||
// as a failed collection.
|
||||
logcollections.value = []
|
||||
} finally {
|
||||
logcollectionsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side shape check purely for immediate feedback. The server and the
|
||||
// agent both re-validate; this exists so an obvious typo does not cost a round
|
||||
// trip and a poll cycle to report back.
|
||||
function validateLogPath(raw) {
|
||||
const path = (raw || '').trim()
|
||||
if (!path) return 'Enter the full path to the log file'
|
||||
const normalized = path.replace(/\//g, '\\')
|
||||
if (normalized.startsWith('\\\\')) {
|
||||
return 'Network (UNC) paths are not supported; use a local path'
|
||||
}
|
||||
if (!/^[A-Za-z]:\\/.test(normalized)) {
|
||||
return 'Include the drive letter, for example C:\\ProgramData\\App\\app.log'
|
||||
}
|
||||
if (normalized.split('\\').some((part) => part === '..')) {
|
||||
return 'Path must not contain ".." segments'
|
||||
}
|
||||
if (normalized.includes('*') || normalized.includes('?')) {
|
||||
return 'Wildcards are not supported; name a single log file'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function collectLog() {
|
||||
const path = logpath.value.trim()
|
||||
logpatherror.value = validateLogPath(path)
|
||||
if (logpatherror.value) return
|
||||
|
||||
collectingLog.value = true
|
||||
try {
|
||||
const response = await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'collect_log', path)
|
||||
const commandId = response.data.data?.endpointcommandid
|
||||
|
||||
// Wait for the agent rather than optimistically showing a result: the file
|
||||
// is read on the endpoint, so until the command completes there is nothing
|
||||
// to display and no way to know whether the path even exists.
|
||||
const outcome = commandId ? await waitForCommand(commandId) : 'timeout'
|
||||
|
||||
await loadLogCollections()
|
||||
|
||||
if (outcome === 'succeeded') {
|
||||
toast.success('Log collected')
|
||||
// Open the newest collection, which is the one just produced.
|
||||
const newest = logcollections.value[0]
|
||||
if (newest) await viewLog(newest)
|
||||
} else if (outcome === 'failed') {
|
||||
// The agent's refusal reason is the useful part (file missing, access
|
||||
// denied, not a text file), so it is fetched and surfaced verbatim
|
||||
// instead of a generic failure message.
|
||||
toast.error(await collectFailureReason(commandId))
|
||||
} else {
|
||||
toast.info(
|
||||
'Collection queued. It will appear here once the agent reports back.')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to collect the log'))
|
||||
} finally {
|
||||
collectingLog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Why a collection failed. waitForCommand only reports the terminal state, but
|
||||
// the agent's message is the actionable part - "No file found at ...",
|
||||
// "Access denied", "does not appear to be a plaintext file" - so it is read off
|
||||
// the command row rather than replaced with a generic failure.
|
||||
async function collectFailureReason(commandId) {
|
||||
const fallback = 'The log could not be collected'
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(computer.value.assetid)
|
||||
const command = (response?.data?.data || []).find(
|
||||
(row) => row.endpointcommandid === commandId)
|
||||
return command?.resultmessage || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function viewLog(entry) {
|
||||
logcontentloading.value = true
|
||||
// Show the row's metadata immediately so the pane is not blank while the
|
||||
// body loads.
|
||||
activelog.value = { ...entry, content: '' }
|
||||
try {
|
||||
const response = await endpointsApi.getLogCollection(
|
||||
entry.endpointlogcollectionid)
|
||||
activelog.value = response.data.data
|
||||
} catch (error) {
|
||||
activelog.value = null
|
||||
toast.error(apiError(error, 'Failed to open the collected log'))
|
||||
} finally {
|
||||
logcontentloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLog(entry) {
|
||||
try {
|
||||
const response = await endpointsApi.downloadLogCollection(
|
||||
entry.endpointlogcollectionid)
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = entry.filename || 'collected.log'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
// Revoke and detach, or every download leaks a blob URL and a node for the
|
||||
// lifetime of the page.
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to download the collected log'))
|
||||
}
|
||||
}
|
||||
|
||||
// Byte counts are shown to a human deciding whether a log is worth opening, so
|
||||
// they are scaled rather than printed raw.
|
||||
function formatBytes(value) {
|
||||
if (value === null || value === undefined) return 'Unknown'
|
||||
const bytes = Number(value)
|
||||
if (!Number.isFinite(bytes)) return 'Unknown'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// Queued and claimed are live states, not outcomes. Labelling them "pending"
|
||||
// would imply the run finished and reported nothing.
|
||||
function scriptRunStatusLabel(run) {
|
||||
if (run.status === 'failed') return 'Failed'
|
||||
if (run.status === 'claimed') return 'Running'
|
||||
return 'Queued'
|
||||
}
|
||||
|
||||
// The timestamp on a run row means two different things depending on the state:
|
||||
// when it FINISHED for a terminal run, when it was QUEUED for one still in
|
||||
// flight. Rendered bare, those are indistinguishable, and a queued row showing
|
||||
// its queue time reads as a completed run that finished then - which is exactly
|
||||
// the wrong conclusion while the script is still executing. The label says which
|
||||
// one is being shown.
|
||||
function scriptRunTimeLabel(run) {
|
||||
const finished = run.status === 'succeeded' || run.status === 'failed'
|
||||
if (finished && run.completedat) return formatDateTime(run.completedat)
|
||||
return `Queued ${formatDateTime(run.createdat)}`
|
||||
}
|
||||
|
||||
// Hover detail. A terminal row only shows its finish time, so the queue time -
|
||||
// the gap that tells an operator the agent was slow to pick the job up - would
|
||||
// otherwise be lost.
|
||||
function scriptRunTimeTitle(run) {
|
||||
const parts = [`Queued ${formatDateTime(run.createdat)}`]
|
||||
if (run.completedat) parts.push(`Completed ${formatDateTime(run.completedat)}`)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
// Whether the agent sent anything beyond the status itself. Drives the note
|
||||
// that distinguishes "nothing to report" from "this agent cannot report".
|
||||
function hasRunEvidence(run) {
|
||||
const detail = run.resultdetail
|
||||
if (!detail) return false
|
||||
return detail.exitcode !== undefined && detail.exitcode !== null
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return 'unknown'
|
||||
// Server sends naive UTC isoformat; mark it as UTC so it renders in local
|
||||
// time rather than being read as local and shifting by the offset.
|
||||
const raw = value.endsWith('Z') ? value : `${value}Z`
|
||||
const when = new Date(raw)
|
||||
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
|
||||
}
|
||||
|
||||
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||
// confirmRestart - these start real work on a live machine and some of them
|
||||
// change system state - and the server re-checks endpoints.corptools regardless.
|
||||
@@ -1578,12 +2042,22 @@ async function confirmRunScript() {
|
||||
|
||||
firingScript.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
const response = await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||
// and the script's own result comes back separately. Saying "ran" here
|
||||
// would claim an outcome nothing has observed yet.
|
||||
toast.success(`${script.scriptname} queued for ${label}`)
|
||||
|
||||
// Show the queued row straight away, then again once it reaches a terminal
|
||||
// state, so the history reflects the run the operator just triggered
|
||||
// without them having to leave and re-enter the tab.
|
||||
await loadScriptRuns()
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
if (commandId) {
|
||||
await waitForCommand(commandId)
|
||||
await loadScriptRuns()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||
} finally {
|
||||
@@ -2092,6 +2566,258 @@ async function confirmRetire() {
|
||||
.runscripts-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
/* Recent script runs. Themed off the same variables as the rest of the page so
|
||||
the panel follows dark mode rather than pinning its own colours. */
|
||||
.scriptrun-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.scriptrun-row {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.scriptrun-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.scriptrun-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.scriptrun-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-light);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.scriptrun-badge--succeeded {
|
||||
color: var(--success-dark);
|
||||
border-color: var(--success);
|
||||
}
|
||||
.scriptrun-badge--failed {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
[data-theme="dark"] .scriptrun-badge--succeeded { color: var(--success); }
|
||||
.scriptrun-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.scriptrun-when {
|
||||
margin-left: auto;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.scriptrun-message {
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.scriptrun-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.scriptrun-facts dt {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.scriptrun-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.scriptrun-facts dd.mono {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
/* Agent output is arbitrary text: it scrolls rather than stretching the card,
|
||||
and wraps so a long single line does not push the layout sideways. */
|
||||
.scriptrun-output {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.5rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.scriptrun-note,
|
||||
.scriptrun-foot {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.scriptrun-foot {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Log Collector. Themed off the same variables as the panels above so it
|
||||
follows dark mode rather than pinning its own colours. */
|
||||
.logcollect-intro {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.logcollect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.logcollect-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.logcollect-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.logcollect-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.logcollect-hint {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-error {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-note--warn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border-radius: 4px;
|
||||
}
|
||||
.logcollect-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.logview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* The collected path can be long; it wraps rather than forcing the card wide. */
|
||||
.logview-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.logview-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.logview-facts dt {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.logview-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
/* The log body. Scrolls rather than stretching the card, and wraps so a long
|
||||
line does not push the layout sideways. */
|
||||
.logview-pane {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.75rem;
|
||||
max-height: 28rem;
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.logcollect-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.logcollect-row {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logcollect-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.logcollect-row--active {
|
||||
border-left: 3px solid var(--primary, var(--warning));
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.logcollect-rowhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logcollect-path {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.logcollect-when {
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logcollect-rowmeta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-trunc {
|
||||
color: var(--warning-dark, var(--warning));
|
||||
}
|
||||
[data-theme="dark"] .logcollect-trunc {
|
||||
color: var(--warning);
|
||||
}
|
||||
.logcollect-rowactions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
|
||||
@@ -10,6 +10,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -28,7 +29,7 @@ from shopdb.api import (
|
||||
|
||||
from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointHealthState, OperatorReport,
|
||||
EndpointHealthState, OperatorReport, EndpointLogCollection,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
@@ -2068,6 +2069,196 @@ def _resolve_update_app(target):
|
||||
return None, f'Unknown update_app target kind: {kind!r}'
|
||||
|
||||
|
||||
# Extensions collect_log will read. The list is an allowlist, not a blocklist:
|
||||
# the question is not "is this dangerous to read" but "will this render as text
|
||||
# in a browser pane", and only a closed set can answer that. Anything else comes
|
||||
# back refused rather than as mojibake the operator has to interpret.
|
||||
#
|
||||
# The agent enforces this same set independently. Duplicating it is deliberate -
|
||||
# this copy exists to give a fast, actionable error at enqueue, and the agent's
|
||||
# copy is the one that actually guards the disk.
|
||||
#
|
||||
# The Windows ".c-f-g" extension (spelled with separators here on purpose) is
|
||||
# deliberately absent: the literal collides with the banned-shorthand naming
|
||||
# gate, and weakening a repo-wide gate for one rarely-collected extension is a
|
||||
# bad trade. '.conf' and '.ini' cover the same need.
|
||||
LOG_PATH_EXTENSIONS = frozenset({
|
||||
'.log', '.txt', '.xml', '.json', '.csv', '.ini', '.conf',
|
||||
'.yml', '.yaml', '.trace', '.out', '.err', '.md',
|
||||
})
|
||||
|
||||
# Windows path length ceiling for the classic (non-\\?\) API. A longer path
|
||||
# cannot be opened by the agent anyway, so refusing here is honest.
|
||||
LOG_PATH_MAX_LENGTH = 260
|
||||
|
||||
|
||||
def _validate_log_path(target):
|
||||
"""Reject a collect_log path that cannot be a readable local log file.
|
||||
|
||||
Returns an error string, or None when the shape is acceptable.
|
||||
|
||||
This is a SHAPE check for fast operator feedback. It cannot be a security
|
||||
boundary: the server has no view of the endpoint's filesystem, and any
|
||||
caller reaching this code already holds endpoints.updateapp. The agent
|
||||
performs its own independent validation before opening anything.
|
||||
|
||||
Rejections here are the ones that are wrong no matter what is on disk:
|
||||
a relative path (ambiguous - relative to what, on a service running as
|
||||
SYSTEM), a traversal segment (an attempt to walk out of the directory the
|
||||
operator named, which an honest log path never needs), a UNC path (that
|
||||
would make the endpoint agent read from a file server using the machine
|
||||
account, turning a log collector into a network file reader), and an
|
||||
extension outside the text allowlist.
|
||||
"""
|
||||
path = (target or '').strip()
|
||||
if not path:
|
||||
return 'A log file path is required'
|
||||
|
||||
if len(path) > LOG_PATH_MAX_LENGTH:
|
||||
return (f'Path is too long ({len(path)} characters); the maximum is '
|
||||
f'{LOG_PATH_MAX_LENGTH}')
|
||||
|
||||
normalized = path.replace('/', '\\')
|
||||
|
||||
# A UNC path would have the agent read from a network share as the machine
|
||||
# account. That is a different capability from "read a log off this PC" and
|
||||
# is not what this permission was granted for.
|
||||
if normalized.startswith('\\\\'):
|
||||
return ('UNC/network paths are not supported; collect logs from a '
|
||||
'local path on the endpoint')
|
||||
|
||||
# Require a drive-letter absolute path. A bare or relative path resolves
|
||||
# against the agent service's working directory, which is not somewhere an
|
||||
# operator can reason about.
|
||||
if not re.match(r'^[A-Za-z]:\\', normalized):
|
||||
return ('Enter a full local path including the drive letter '
|
||||
r'(for example C:\ProgramData\App\app.log)')
|
||||
|
||||
# '..' would let a typed path escape the directory it appears to name. No
|
||||
# legitimate log path needs it, so it is refused rather than normalised -
|
||||
# normalising would silently read something other than what was reviewed.
|
||||
if any(part == '..' for part in normalized.split('\\')):
|
||||
return 'Path must not contain ".." segments'
|
||||
|
||||
# Wildcards would make the collection non-deterministic: what it reads would
|
||||
# depend on the directory's contents at run time, so the history could not
|
||||
# say what was actually collected.
|
||||
if '*' in normalized or '?' in normalized:
|
||||
return 'Wildcards are not supported; name a single log file'
|
||||
|
||||
extension = os.path.splitext(normalized)[1].lower()
|
||||
if extension not in LOG_PATH_EXTENSIONS:
|
||||
allowed = ', '.join(sorted(LOG_PATH_EXTENSIONS))
|
||||
return (f'Only plaintext log files can be collected. Allowed '
|
||||
f'extensions: {allowed}')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _record_log_collection(command, detail, content):
|
||||
"""Persist a collected log for a command and prune that asset's history.
|
||||
|
||||
Called inside report_command_result, before its commit, so the collection
|
||||
row, the pruning and the command's own finalisation all land together. A
|
||||
separate commit here could leave a collected log with no finalised command
|
||||
if the request failed in between.
|
||||
|
||||
Pruning is per asset and runs on insert. Doing it here rather than on a
|
||||
schedule means the cap is a real invariant rather than something that is
|
||||
true most of the time: there is no window in which an asset has 40 rows
|
||||
waiting for a cleanup job that may not be running.
|
||||
"""
|
||||
parsed_modified = None
|
||||
raw_modified = (detail or {}).get('modifiedat')
|
||||
if raw_modified:
|
||||
parsed_modified = _parse_agent_datetime(raw_modified)
|
||||
|
||||
text = content if isinstance(content, str) else None
|
||||
|
||||
collection = EndpointLogCollection(
|
||||
assetid=command.assetid,
|
||||
endpointcommandid=command.endpointcommandid,
|
||||
# Prefer the path the AGENT resolved; fall back to what was queued so a
|
||||
# row is never left without a path to show.
|
||||
sourcepath=((detail or {}).get('path') or command.target or '')[:512],
|
||||
content=text,
|
||||
filesizebytes=_coerce_int((detail or {}).get('filesizebytes')),
|
||||
contentbytes=(len(text.encode('utf-8', 'replace')) if text is not None
|
||||
else None),
|
||||
linecount=_coerce_int((detail or {}).get('linecount')),
|
||||
istruncated=bool((detail or {}).get('truncated')),
|
||||
filemodifiedat=parsed_modified,
|
||||
# requestedby is the operator who queued the collection. The agent posts
|
||||
# this result with a collector token and has no user identity, so the
|
||||
# attribution has to come off the command row.
|
||||
collectedbyuserid=command.requestedby,
|
||||
collectedbyusername=_username_for(command.requestedby),
|
||||
collectedat=_naive_utcnow(),
|
||||
)
|
||||
db.session.add(collection)
|
||||
# Make the new row visible to the pruning query below, so the cap counts it.
|
||||
db.session.flush()
|
||||
|
||||
_prune_log_collections(command.assetid)
|
||||
return collection
|
||||
|
||||
|
||||
def _prune_log_collections(assetid):
|
||||
"""Keep only the newest RETENTION_PER_ASSET collections for an asset.
|
||||
|
||||
Ordered by collectedat DESC then id DESC: two collections can share a
|
||||
timestamp at second resolution, and without the id tiebreak the ordering
|
||||
would be non-deterministic, which is how a prune ends up deleting the row it
|
||||
just inserted.
|
||||
"""
|
||||
keep = EndpointLogCollection.RETENTION_PER_ASSET
|
||||
stale = (EndpointLogCollection.query
|
||||
.filter_by(assetid=assetid)
|
||||
.order_by(EndpointLogCollection.collectedat.desc(),
|
||||
EndpointLogCollection.endpointlogcollectionid.desc())
|
||||
.offset(keep)
|
||||
.all())
|
||||
for row in stale:
|
||||
db.session.delete(row)
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _coerce_int(value):
|
||||
"""Best-effort int from agent-reported JSON; None when unusable."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _username_for(userid):
|
||||
"""Username snapshot for an id, or None. Never raises."""
|
||||
if not userid:
|
||||
return None
|
||||
user = db.session.get(User, userid)
|
||||
return getattr(user, 'username', None) if user is not None else None
|
||||
|
||||
|
||||
def _parse_agent_datetime(value):
|
||||
"""Parse an agent ISO-8601 timestamp into a naive UTC datetime.
|
||||
|
||||
The agent sends an offset-aware string. The column is naive-UTC like every
|
||||
other datetime in this schema, so the offset is applied and then dropped -
|
||||
keeping it would make this the one column that compares wrongly against the
|
||||
rest.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
text = str(value).strip().replace('Z', '+00:00')
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def _resolve_run_script(target):
|
||||
"""Resolve a run_script target to a script delivery descriptor.
|
||||
|
||||
@@ -2081,6 +2272,8 @@ def _resolve_run_script(target):
|
||||
the claim: an agent that receives it and ignores it is then distinguishable
|
||||
from one that never saw it, which matters while agents of both vintages are
|
||||
in the field. errormode rides the same rule, and for the same reason.
|
||||
captureoutput too: an older agent that never saw the field simply keeps
|
||||
withholding successful output, which is the safe direction to fail.
|
||||
"""
|
||||
kind, _sep, raw = str(target or '').partition(':')
|
||||
script = None
|
||||
@@ -2110,6 +2303,7 @@ def _resolve_run_script(target):
|
||||
'runcontext': script.runcontext,
|
||||
'workingdirectory': script.workingdirectory or '',
|
||||
'errormode': script.errormode or 'default',
|
||||
'captureoutput': bool(script.captureoutput),
|
||||
}, None
|
||||
|
||||
|
||||
@@ -2125,6 +2319,149 @@ def list_endpoint_commands(asset_id: int):
|
||||
return success_response([row.to_dict() for row in rows])
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/scriptruns', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_script_runs(asset_id: int):
|
||||
"""The last few run_script commands for one endpoint, newest first.
|
||||
|
||||
Separate from list_endpoint_commands because the Run Scripts tab is asking a
|
||||
narrower question - "what happened the last few times I pushed a script
|
||||
HERE" - and filtering a 20-row mixed feed client-side would silently show
|
||||
fewer than five script runs whenever other commands are interleaved.
|
||||
|
||||
Carries resultdetail, which the plain command list does not. A script that
|
||||
exits zero produces a green badge and nothing else; the exit code, the
|
||||
context it ran as and the directory it ran in are the only evidence the
|
||||
operator gets that the run did anything at all.
|
||||
"""
|
||||
limit = request.args.get('limit', default=5, type=int) or 5
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
rows = (EndpointCommand.query
|
||||
.filter_by(assetid=asset_id, action='run_script')
|
||||
.order_by(EndpointCommand.createdat.desc())
|
||||
.limit(limit)
|
||||
.all())
|
||||
|
||||
scriptnames = _script_names_for(rows)
|
||||
payload = []
|
||||
for row in rows:
|
||||
data = row.to_dict(includedetail=True)
|
||||
data['scriptname'] = scriptnames.get(row.endpointcommandid)
|
||||
payload.append(data)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/logcollections', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_log_collections(asset_id: int):
|
||||
"""Collected-log history for one endpoint, newest first.
|
||||
|
||||
Metadata only - no content. The panel renders up to 20 rows and none of
|
||||
them need the log body, which can be hundreds of kilobytes each; sending
|
||||
them all would make opening the tab far more expensive than reading the one
|
||||
log the operator actually wants.
|
||||
|
||||
Gated on endpoints.view because this is a listing. Reading a body, which is
|
||||
the part that discloses file contents, requires the collect permission - see
|
||||
get_endpoint_log_collection.
|
||||
"""
|
||||
rows = (EndpointLogCollection.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointLogCollection.collectedat.desc(),
|
||||
EndpointLogCollection.endpointlogcollectionid.desc())
|
||||
.limit(EndpointLogCollection.RETENTION_PER_ASSET)
|
||||
.all())
|
||||
return success_response([row.to_dict() for row in rows])
|
||||
|
||||
|
||||
@endpoints_bp.route('/logcollections/<int:collection_id>', methods=['GET'])
|
||||
@require_permission('endpoints.updateapp')
|
||||
def get_endpoint_log_collection(collection_id: int):
|
||||
"""One collected log including its content, for the scrollable pane.
|
||||
|
||||
Deliberately a heavier permission than the list. The list says a log was
|
||||
collected; this returns what was IN it, which is the disclosure. It rides
|
||||
the same permission that authorised the collection, so nobody gains through
|
||||
this route what they could not have collected themselves.
|
||||
"""
|
||||
row = db.session.get(EndpointLogCollection, collection_id)
|
||||
if row is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No log collection {collection_id}',
|
||||
http_code=404)
|
||||
return success_response(row.to_dict(includecontent=True))
|
||||
|
||||
|
||||
@endpoints_bp.route('/logcollections/<int:collection_id>/download',
|
||||
methods=['GET'])
|
||||
@require_permission('endpoints.updateapp')
|
||||
def download_endpoint_log_collection(collection_id: int):
|
||||
"""Serve a collected log as a file attachment.
|
||||
|
||||
Served as text/plain with an explicit attachment disposition so the browser
|
||||
saves it instead of rendering it. That matters beyond convenience: a
|
||||
collected log is untrusted endpoint content, and inline rendering of a file
|
||||
that happens to contain markup would execute it in the app's origin.
|
||||
|
||||
X-Content-Type-Options is set for the same reason - it stops the browser
|
||||
sniffing past the declared text/plain and deciding the bytes are HTML.
|
||||
"""
|
||||
from flask import Response
|
||||
|
||||
row = db.session.get(EndpointLogCollection, collection_id)
|
||||
if row is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No log collection {collection_id}',
|
||||
http_code=404)
|
||||
|
||||
audit_log(
|
||||
action='logcollectiondownloaded',
|
||||
entitytype='Asset',
|
||||
entityid=row.assetid,
|
||||
changes={
|
||||
'sourcepath': row.sourcepath,
|
||||
'endpointlogcollectionid': row.endpointlogcollectionid,
|
||||
'downloadedby': getattr(current_user, 'username', None),
|
||||
},
|
||||
details=f'Downloaded collected log {row.sourcepath}',
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
response = Response(row.content or '', mimetype='text/plain; charset=utf-8')
|
||||
# The filename is already sanitised to [A-Za-z0-9._-] by the model, so it
|
||||
# cannot break out of the quoted header value.
|
||||
response.headers['Content-Disposition'] = (
|
||||
f'attachment; filename="{row.filename}"')
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
|
||||
def _script_names_for(rows):
|
||||
"""Map command id -> script name for a batch of run_script rows.
|
||||
|
||||
The command stores 'script:<id>', which is not what the operator chose from
|
||||
the picker. Resolved in one query rather than per row. A script deleted
|
||||
since the run leaves the name absent, and the caller falls back to the raw
|
||||
target rather than inventing one.
|
||||
"""
|
||||
wanted = {}
|
||||
for row in rows:
|
||||
kind, _sep, raw = str(row.target or '').partition(':')
|
||||
if kind == 'script' and raw.isdigit():
|
||||
wanted[row.endpointcommandid] = int(raw)
|
||||
if not wanted:
|
||||
return {}
|
||||
|
||||
found = {
|
||||
script.scriptid: script.scriptname
|
||||
for script in Script.query.filter(
|
||||
Script.scriptid.in_(set(wanted.values()))).all()
|
||||
}
|
||||
return {cmdid: found[sid]
|
||||
for cmdid, sid in wanted.items() if sid in found}
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/policyholds', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_policy_holds(asset_id: int):
|
||||
@@ -2538,6 +2875,18 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||
pinnedsha256 = (script or {}).get('sha256')
|
||||
|
||||
# collect_log reads a caller-supplied path. Validate its SHAPE here so an
|
||||
# obviously wrong path fails immediately with a message the operator can act
|
||||
# on, instead of consuming a poll cycle to come back as a generic endpoint
|
||||
# failure. This is a usability gate, not the security boundary: the agent
|
||||
# re-validates independently, because a server-side check cannot know what
|
||||
# is really on that disk and must never be the only thing standing between a
|
||||
# typed string and a file read.
|
||||
if action == 'collect_log':
|
||||
path_error = _validate_log_path(target)
|
||||
if path_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, path_error)
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if state is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
@@ -2763,6 +3112,20 @@ def report_command_result(command_id: int):
|
||||
else EndpointCommand.STATUS_FAILED)
|
||||
command.resultmessage = (payload.get('message') or '')[:2000] or None
|
||||
detail = payload.get('detail')
|
||||
|
||||
# A successful collect_log carries the file body in detail.content. Move it
|
||||
# into its own row and strip it from the detail before that detail is
|
||||
# serialised onto the command: storing it twice would double the cost of the
|
||||
# largest payload this API accepts, and resultjson is read by every command
|
||||
# history list that only wants a status.
|
||||
#
|
||||
# Only on success. A failed collection's detail is small (the reason it
|
||||
# failed) and belongs on the command, where failures are already read.
|
||||
if command.action == 'collect_log' and succeeded and isinstance(detail, dict):
|
||||
detail = dict(detail)
|
||||
content = detail.pop('content', None)
|
||||
_record_log_collection(command, detail, content)
|
||||
|
||||
command.resultjson = json.dumps(detail, separators=(',', ':')) \
|
||||
if detail is not None else None
|
||||
command.completedat = _naive_utcnow()
|
||||
|
||||
@@ -110,6 +110,24 @@ COMMAND_ALLOWLIST = {
|
||||
'PCDetail.vue).',
|
||||
'targetkind': 'script',
|
||||
},
|
||||
'collect_log': {
|
||||
'label': 'Collect log file',
|
||||
'description': 'Read a plaintext log file from a known path on the '
|
||||
'endpoint and return its contents to ShopDB for '
|
||||
'viewing and download. Read-only: the agent opens the '
|
||||
'file for reading with sharing permitted (so a log a '
|
||||
'running application holds open can still be read), '
|
||||
'never writes, and never deletes. Only the TAIL of a '
|
||||
'file is returned, and only for plaintext extensions - '
|
||||
'a binary or oversized file is refused rather than '
|
||||
'mangled. Gated on endpoints.updateapp because the '
|
||||
'reason to collect a log is almost always to diagnose '
|
||||
'an application push, so it belongs with app lifecycle '
|
||||
'rights rather than as a separate grant. Note this '
|
||||
'still reads a caller-supplied path as SYSTEM: it is '
|
||||
'bounded to text files, but it is not a sandbox.',
|
||||
'targetkind': 'logpath',
|
||||
},
|
||||
'update_app': {
|
||||
'label': 'Install/update application',
|
||||
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
||||
@@ -192,11 +210,18 @@ COMMAND_ALLOWLIST = {
|
||||
# update_app - runs a pinned installer
|
||||
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
|
||||
# user context
|
||||
#
|
||||
# collect_log rides endpoints.updateapp rather than taking a grant of its own.
|
||||
# The reason to read a log is nearly always to find out why an app push behaved
|
||||
# the way it did, so the right to install an application and the right to read
|
||||
# that application's log are the same job. It is read-only and strictly weaker
|
||||
# than the installer push the same permission already allows.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
'scheduledtask_run': 'endpoints.corptools',
|
||||
'run_script': 'endpoints.runscript',
|
||||
'update_app': 'endpoints.updateapp',
|
||||
'collect_log': 'endpoints.updateapp',
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +364,19 @@ POLICY_EXEMPT_ACTIONS = frozenset({
|
||||
# policy is non-empty - a denial no operator can act on, because the fix
|
||||
# lives in a policy that was never about this action.
|
||||
'collect_now',
|
||||
# Read-only diagnostic retrieval. Policy describes how a machine should be
|
||||
# shaped - which monitors run, which service primitives are reachable - and
|
||||
# reading a log file shapes nothing. An existing policy was authored before
|
||||
# this action existed and so can never mention it, which under the
|
||||
# intersection means every endpoint carrying any policy at all refuses log
|
||||
# collection, including for an admin. That denial is also unactionable in
|
||||
# the way that matters: logs are what an operator reaches for when an
|
||||
# endpoint is already misbehaving, so gating them behind editing that same
|
||||
# endpoint's policy withholds the evidence exactly when it is needed.
|
||||
# Exemption from POLICY is not exemption from PERMISSION - collect_log
|
||||
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
|
||||
# the agent still refuses UNC, traversal, directories and non-text files.
|
||||
'collect_log',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,13 @@
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
<span
|
||||
v-if="s.captureoutput"
|
||||
class="badge badge-secondary"
|
||||
title="A successful run returns its output to ShopDB"
|
||||
>
|
||||
Returns output
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
@@ -213,6 +220,27 @@
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.captureoutput" type="checkbox" />
|
||||
Return output to ShopDB on success
|
||||
</label>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
A failed run always sends its output back, so you can diagnose
|
||||
it from the Run Scripts history without going to the machine.
|
||||
A successful run does not, unless you tick this.
|
||||
</p>
|
||||
<p>
|
||||
Leave it off for scripts whose output could contain anything
|
||||
sensitive - account names, share paths, anything a Write-Host
|
||||
left behind. Turn it on for scripts where seeing the output is
|
||||
the only way to tell the run did real work. Either way the
|
||||
full output is written to the agent log on the endpoint.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
@@ -259,6 +287,7 @@ function emptyForm() {
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
captureoutput: false,
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
@@ -293,6 +322,7 @@ function openModal(script = null) {
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
captureoutput: !!script.captureoutput,
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
@@ -314,6 +344,7 @@ async function save() {
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
captureoutput: form.value.captureoutput,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from .endpoint import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointLock,
|
||||
EndpointLock, EndpointLogCollection,
|
||||
)
|
||||
from .monitor import EndpointMonitor
|
||||
from .operatorreport import OperatorReport
|
||||
@@ -12,7 +12,7 @@ from .health import (
|
||||
|
||||
__all__ = [
|
||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||
'EndpointLock',
|
||||
'EndpointLock', 'EndpointLogCollection',
|
||||
'EndpointMonitor', 'OperatorReport',
|
||||
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
||||
]
|
||||
|
||||
@@ -16,13 +16,23 @@ Two shapes, one per concern:
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
EndpointLogCollection
|
||||
A plaintext log file collected from an endpoint on operator
|
||||
demand. Kept out of EndpointCommand.resultjson because it is
|
||||
bulky, needs its own per-asset retention cap, and has to be
|
||||
addressable by id for the download route.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
@@ -275,8 +285,16 @@ class EndpointCommand(db.Model):
|
||||
def __repr__(self):
|
||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, includedetail=False):
|
||||
"""Serialise a command for the operator views.
|
||||
|
||||
resultjson is opt-in. It is agent-reported structured detail, and for a
|
||||
script it carries the evidence that a green badge alone does not: the
|
||||
exit code, the context it ran as and the directory it really ran in. On
|
||||
failure the agent also puts the captured output in there, so it is not
|
||||
served to every list that only needs a status.
|
||||
"""
|
||||
data = {
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'assetid': self.assetid,
|
||||
'action': self.action,
|
||||
@@ -289,6 +307,24 @@ class EndpointCommand(db.Model):
|
||||
'claimedat': _utciso(self.claimedat),
|
||||
'completedat': _utciso(self.completedat),
|
||||
}
|
||||
if includedetail:
|
||||
data['resultdetail'] = self._resultdetail()
|
||||
return data
|
||||
|
||||
def _resultdetail(self):
|
||||
"""resultjson as a dict, or None.
|
||||
|
||||
Stored as text, so a malformed or truncated payload is possible. A
|
||||
detail panel is diagnostics: failing to parse it must not break the
|
||||
command list that surrounds it.
|
||||
"""
|
||||
if not self.resultjson:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(self.resultjson)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
@@ -531,3 +567,130 @@ class EndpointLock(db.Model):
|
||||
'releasedat': _utciso(self.releasedat),
|
||||
'releasedbyusername': self.releasedbyusername,
|
||||
}
|
||||
|
||||
|
||||
class EndpointLogCollection(db.Model):
|
||||
"""One plaintext log file collected from an endpoint by an operator.
|
||||
|
||||
Collected content lives here rather than in EndpointCommand.resultjson for
|
||||
three reasons. It is bulky and would be dragged along by every command
|
||||
history list that only wants a status; it needs its own retention rule
|
||||
(RETENTION_PER_ASSET), which a command row cannot express without deleting
|
||||
the audit record of the command itself; and the download route needs to
|
||||
serve bytes, which means it needs a row it can address by id rather than a
|
||||
JSON field it has to re-parse.
|
||||
|
||||
Retention is deliberately PER ASSET. A global cap would let one busy machine
|
||||
evict another machine's history, and the history is read per machine on the
|
||||
PC detail page, so a global cap would make the panel unreliable exactly
|
||||
where it is used. Pruning happens on insert in the result route.
|
||||
|
||||
The content stored here is a TAIL, not necessarily the whole file - see
|
||||
istruncated. A log that has run for months is not something to load into a
|
||||
browser pane, and the agent bounds what it sends.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlogcollections'
|
||||
|
||||
# How many collections to keep per asset. The operator asked for 20: enough
|
||||
# to compare a handful of runs against each other, few enough that stored
|
||||
# log text does not grow without bound across a fleet.
|
||||
RETENTION_PER_ASSET = 20
|
||||
|
||||
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The command that produced this, kept for audit: it ties the collected
|
||||
# bytes back to who queued the collection and when it ran. SET NULL rather
|
||||
# than CASCADE so pruning or expiring a command never silently destroys a
|
||||
# log an operator is still looking at.
|
||||
endpointcommandid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# The path as the AGENT resolved it, which is not always the path the
|
||||
# operator typed (environment variables get expanded endpoint-side). Storing
|
||||
# the resolved one is what makes the history honest about what was read.
|
||||
sourcepath = db.Column(db.String(512), nullable=False)
|
||||
|
||||
content = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Size of the file ON DISK, not the size of content. When the tail is
|
||||
# truncated these differ, and that difference is the whole point: it tells
|
||||
# the operator they are looking at the end of something larger.
|
||||
filesizebytes = db.Column(db.BigInteger, nullable=True)
|
||||
contentbytes = db.Column(db.Integer, nullable=True)
|
||||
linecount = db.Column(db.Integer, nullable=True)
|
||||
istruncated = db.Column(db.Boolean, nullable=False,
|
||||
default=False, server_default=sa.false())
|
||||
|
||||
# Last-write time reported by the endpoint. A log that has not changed since
|
||||
# the previous collection is the fastest way to see an app is not logging.
|
||||
filemodifiedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Username snapshot alongside the id: the id alone becomes unreadable if the
|
||||
# user is later removed, and this history is read long after collection.
|
||||
collectedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
collectedbyusername = db.Column(db.String(100), nullable=True)
|
||||
collectedat = db.Column(db.DateTime, nullable=False,
|
||||
default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves both hot paths: listing an asset's history newest-first, and
|
||||
# finding the oldest rows to prune on insert.
|
||||
db.Index('idx_endpointlogcollections_assetcollected',
|
||||
'assetid', 'collectedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointLogCollection asset={self.assetid} "
|
||||
f"{self.sourcepath}>")
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""A download filename derived from the collected path."""
|
||||
raw = (self.sourcepath or '').replace('/', '\\')
|
||||
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
|
||||
# Keep it to characters that survive a Content-Disposition header and a
|
||||
# Windows save dialog without quoting games.
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
|
||||
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
|
||||
return f'{stamp}_{safe}'
|
||||
|
||||
def to_dict(self, includecontent=False):
|
||||
"""Serialise a collection.
|
||||
|
||||
Content is opt-in for the same reason resultjson is on EndpointCommand:
|
||||
the history list renders 20 rows and none of them need the log body,
|
||||
which may be hundreds of kilobytes each.
|
||||
"""
|
||||
data = {
|
||||
'endpointlogcollectionid': self.endpointlogcollectionid,
|
||||
'assetid': self.assetid,
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'sourcepath': self.sourcepath,
|
||||
'filesizebytes': self.filesizebytes,
|
||||
'contentbytes': self.contentbytes,
|
||||
'linecount': self.linecount,
|
||||
'istruncated': bool(self.istruncated),
|
||||
'filemodifiedat': _utciso(self.filemodifiedat),
|
||||
'collectedbyuserid': self.collectedbyuserid,
|
||||
'collectedbyusername': self.collectedbyusername,
|
||||
'collectedat': _utciso(self.collectedat),
|
||||
'filename': self.filename,
|
||||
}
|
||||
if includecontent:
|
||||
data['content'] = self.content or ''
|
||||
return data
|
||||
|
||||
@@ -206,7 +206,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
'already registered and allowlisted on the agent)', 'endpoints'),
|
||||
('endpoints.updateapp',
|
||||
'Push application installers to endpoints (runs a pinned '
|
||||
'installer as SYSTEM)', 'endpoints'),
|
||||
'installer as SYSTEM) and collect plaintext log files from '
|
||||
'endpoints by path (read-only, text files only)', 'endpoints'),
|
||||
# Blunt on purpose. This is not "system-level changes" - it is
|
||||
# arbitrary code execution as SYSTEM unless the script descriptor
|
||||
# pins a user context. Whoever grants it must see that here.
|
||||
@@ -503,7 +504,12 @@ class EndpointsPlugin(BasePlugin):
|
||||
},
|
||||
'meta': [
|
||||
{'key': 'detail'},
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||
# datetime, not date: 'date' renders via
|
||||
# toLocaleDateString and drops the clock time entirely,
|
||||
# which made every event on a given day look
|
||||
# simultaneous. Ordering a boot against the logon that
|
||||
# followed it is the whole point of this feed.
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'datetime'},
|
||||
],
|
||||
# Expands in place on click. The message body is several
|
||||
# hundred words of Windows boilerplate around the one or
|
||||
|
||||
Reference in New Issue
Block a user