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.
444 lines
15 KiB
Vue
444 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>System Scripts</h2>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
PowerShell the site agent can be told to run on an endpoint. A script is
|
|
stored here once, pinned by digest, and fetched by the agent at push time
|
|
so the bytes that run are the bytes that were reviewed.
|
|
</p>
|
|
|
|
<div class="warning-banner">
|
|
<strong>Every script here runs as SYSTEM.</strong>
|
|
There is no user context and no prompt: the agent is a service, so a
|
|
script cannot see a signed-in person's desktop, mapped drives, or
|
|
credentials. Scripts are intended to TRIGGER an already registered
|
|
scheduled task on the endpoint, which is what does the real work. Anything
|
|
written to act "as the logged-on user" will not behave the way it reads.
|
|
</div>
|
|
|
|
<details class="explainer">
|
|
<summary>How does this work?</summary>
|
|
<div class="explainer-body">
|
|
<h4>Delivery</h4>
|
|
<p>
|
|
The body is stored here and hashed. At push time the agent downloads it
|
|
with its own credential and checks the digest before running anything,
|
|
so the bytes that run are the bytes that were reviewed. The body never
|
|
travels on the command channel and is deleted from the endpoint after
|
|
the run.
|
|
</p>
|
|
|
|
<h4>Runs in</h4>
|
|
<p>
|
|
<strong>System</strong> is the agent's own service account: no desktop,
|
|
no mapped drives, no signed-in person's credentials.
|
|
<strong>User</strong> runs in the interactive session instead, and only
|
|
works when somebody is actually signed in.
|
|
</p>
|
|
|
|
<h4>Run in folder</h4>
|
|
<p>
|
|
The working directory for the run. Blank means the agent's staging
|
|
folder, which is right for a self-contained script. Set it when the
|
|
script calls a tool installed separately on the endpoint by relative
|
|
path. If the folder is missing the run fails outright rather than
|
|
running somewhere unintended.
|
|
</p>
|
|
|
|
<h4>When a line fails</h4>
|
|
<p>
|
|
This is the one worth understanding. PowerShell treats most errors as
|
|
<em>non-terminating</em>: it writes the error, carries on to the next
|
|
line, and still exits zero. Because the command's verdict comes from
|
|
that exit code, <strong>Continue</strong> means a script can report
|
|
success having accomplished nothing.
|
|
</p>
|
|
<p>
|
|
<strong>Stop</strong> makes the first error end the run, so the verdict
|
|
means what you would assume it means. It is not the default because it
|
|
changes how a script behaves: a body that tolerates a failing cleanup,
|
|
probes with a cmdlet that errors when something is absent, or loops
|
|
past bad items will start aborting partway through, leaving the
|
|
endpoint half-configured. Read the script first, then switch it on.
|
|
</p>
|
|
|
|
<h4>Where the output goes</h4>
|
|
<p>
|
|
Script output is always written to the agent log on the endpoint. It is
|
|
sent back to ShopDB only when the run fails, so routine output stays on
|
|
the machine that produced it. Long output is truncated and marked as
|
|
such.
|
|
</p>
|
|
<p class="hint">
|
|
In Stop mode the agent adds one line to the top of the script, so an
|
|
error reported on line N refers to line N-1 of the body shown here.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Description</th>
|
|
<th>Runs in</th>
|
|
<th>On error</th>
|
|
<th>Body</th>
|
|
<th>Digest</th>
|
|
<th>Active</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="s in scripts" :key="s.scriptid">
|
|
<td>{{ s.scriptname }}</td>
|
|
<td class="cell-truncate" :title="s.scriptdescription">
|
|
{{ s.scriptdescription || '-' }}
|
|
</td>
|
|
<td class="cell-truncate mono" :title="s.workingdirectory">
|
|
{{ s.workingdirectory || 'Agent staging folder' }}
|
|
</td>
|
|
<td>
|
|
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
|
|
Stop on first error
|
|
</span>
|
|
<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>
|
|
<span v-else class="badge badge-warning">No body - cannot run</span>
|
|
</td>
|
|
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
|
|
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="scripts.length === 0">
|
|
<td colspan="8" style="text-align:center; color: var(--text-light);">
|
|
No scripts
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="showModal" class="modal-overlay">
|
|
<div class="modal modal-lg">
|
|
<div class="modal-header">
|
|
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
|
|
</div>
|
|
<form @submit.prevent="save">
|
|
<div class="modal-body">
|
|
<div class="warning-banner">
|
|
<strong>This script will run as SYSTEM on every endpoint it is
|
|
pushed to.</strong>
|
|
Write it to trigger a scheduled task that already exists on the
|
|
endpoint. It has no user session, so anything depending on a
|
|
person being signed in will not work.
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Name *</label>
|
|
<input v-model="form.scriptname" type="text" class="form-control" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description <span class="hint">(what it triggers, and why)</span></label>
|
|
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
Script file <span class="hint">(.ps1, replaces the stored body)</span>
|
|
</label>
|
|
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
|
|
<p v-if="editing && editing.hasbody" class="hint">
|
|
Current: {{ editing.filename }}. Uploading replaces it and
|
|
restamps the digest.
|
|
</p>
|
|
<p v-else-if="editing" class="hint">
|
|
No body uploaded yet. A script without a body cannot be pushed.
|
|
</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
Run in folder
|
|
<span class="hint">(optional, endpoint-local absolute path)</span>
|
|
</label>
|
|
<input
|
|
v-model="form.workingdirectory"
|
|
type="text"
|
|
class="form-control mono"
|
|
placeholder="C:\Program Files\CorporateTools"
|
|
/>
|
|
<details class="field-help">
|
|
<summary>How this works</summary>
|
|
<p>
|
|
Leave blank to run from the agent's staging folder. Set this only
|
|
when the script calls a tool already installed on the endpoint by
|
|
relative path. The folder must exist on the endpoint or the push
|
|
fails rather than running somewhere else.
|
|
</p>
|
|
</details>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
When a line fails
|
|
<span class="hint">(how the run is judged)</span>
|
|
</label>
|
|
<select v-model="form.errormode" class="form-control">
|
|
<option value="default">Continue - report success if the script reaches the end</option>
|
|
<option value="strict">Stop on the first error and fail the command</option>
|
|
</select>
|
|
<details class="field-help">
|
|
<summary>How this works</summary>
|
|
<p>
|
|
PowerShell normally writes an error and carries on, and still
|
|
exits zero, so a script can report success having done none of
|
|
its work. Stop makes the first failure end the run and fail the
|
|
command. Leave this on Continue unless you have read the script
|
|
and know it does not rely on a step being allowed to fail.
|
|
</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" />
|
|
Active
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { scriptsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const toast = useToast()
|
|
|
|
const scripts = ref([])
|
|
const loading = ref(true)
|
|
|
|
const showModal = ref(false)
|
|
const editing = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
const pendingfile = ref(null)
|
|
|
|
const form = ref(emptyForm())
|
|
|
|
function emptyForm() {
|
|
return {
|
|
scriptname: '',
|
|
scriptdescription: '',
|
|
workingdirectory: '',
|
|
errormode: 'default',
|
|
captureoutput: false,
|
|
isactive: true
|
|
}
|
|
}
|
|
|
|
function shortdigest(s) {
|
|
return s.sha256 ? s.sha256.slice(0, 12) : '-'
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const response = await scriptsApi.list({ active: 'false' })
|
|
scripts.value = response.data.data || []
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to load scripts'))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function pickfile(event) {
|
|
pendingfile.value = event.target.files[0] || null
|
|
}
|
|
|
|
function openModal(script = null) {
|
|
error.value = ''
|
|
pendingfile.value = null
|
|
editing.value = script
|
|
form.value = script
|
|
? {
|
|
scriptname: script.scriptname,
|
|
scriptdescription: script.scriptdescription || '',
|
|
workingdirectory: script.workingdirectory || '',
|
|
errormode: script.errormode || 'default',
|
|
captureoutput: !!script.captureoutput,
|
|
isactive: script.isactive
|
|
}
|
|
: emptyForm()
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editing.value = null
|
|
pendingfile.value = null
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
error.value = ''
|
|
try {
|
|
const payload = {
|
|
scriptname: form.value.scriptname,
|
|
scriptdescription: form.value.scriptdescription,
|
|
workingdirectory: form.value.workingdirectory,
|
|
errormode: form.value.errormode,
|
|
captureoutput: form.value.captureoutput,
|
|
isactive: form.value.isactive
|
|
}
|
|
let scriptid
|
|
if (editing.value) {
|
|
await scriptsApi.update(editing.value.scriptid, payload)
|
|
scriptid = editing.value.scriptid
|
|
} else {
|
|
const created = await scriptsApi.create(payload)
|
|
scriptid = created.data.data.scriptid
|
|
}
|
|
// Second call on purpose: the body is multipart and the catalog row is
|
|
// JSON. A failure here leaves a bodyless row, which the list marks as
|
|
// unrunnable rather than pretending it is ready to push.
|
|
if (pendingfile.value) {
|
|
await scriptsApi.uploadBody(scriptid, pendingfile.value)
|
|
}
|
|
closeModal()
|
|
loadData()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save script')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(s) {
|
|
if (!confirm(`Delete script "${s.scriptname}"?`)) return
|
|
try {
|
|
await scriptsApi.delete(s.scriptid)
|
|
loadData()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.cell-truncate {
|
|
max-width: 240px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.mono { font-family: monospace; }
|
|
.hint { color: var(--text-light); font-weight: normal; }
|
|
.warning-banner {
|
|
border: 1px solid var(--warning);
|
|
border-left-width: 4px;
|
|
border-radius: 4px;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1rem;
|
|
background: rgba(184, 134, 11, 0.08);
|
|
}
|
|
.explainer {
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
margin-bottom: 1rem;
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
}
|
|
.explainer summary {
|
|
cursor: pointer;
|
|
padding: 0.6rem 1rem;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.explainer-body {
|
|
padding: 0 1rem 0.75rem;
|
|
}
|
|
.explainer-body h4 {
|
|
margin: 0.9rem 0 0.25rem;
|
|
font-size: 0.95rem;
|
|
color: var(--text);
|
|
}
|
|
.explainer-body p {
|
|
margin: 0 0 0.5rem;
|
|
color: var(--text-light);
|
|
line-height: 1.5;
|
|
}
|
|
.field-help {
|
|
margin-top: 0.4rem;
|
|
}
|
|
.field-help summary {
|
|
cursor: pointer;
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
}
|
|
.field-help p {
|
|
margin: 0.35rem 0 0;
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
line-height: 1.5;
|
|
}
|
|
</style>
|