The script library was gated on applications.*, so anyone holding the software-delivery grant could rewrite the bytes of a script that a separately trusted operator later pushed to a bay. The push was gated on endpoints.runscript; the CONTENT of what got pushed was not. An installer is a vendor artefact, but a script here is arbitrary PowerShell this site wrote that the agent runs as SYSTEM, so the write and the push must require the same trust. All six script routes now require endpoints.runscript, including the body download - reading the bytes is not a lesser act than listing them. Core names the permission as a string rather than importing it from the endpoints plugin so the module stays importable with the plugin absent. This tightens access and there is deliberately no backfill: the operator scope reaches applications.create/edit, so operator-scope holders lose the library. Granting them endpoints.runscript on upgrade would undo the change and hand every operator remote SYSTEM code execution, since the same grant gates the push. A test pins that no prebuilt scope confers it. Adds the System Scripts settings page and card. The route requires the permission rather than admin, because the grant is meant for non-admin operators and the sibling admin gate would lock them out of the library the grant exists for. The settings icon map gains terminal and key; the agent-key card had been asking for key since it shipped and silently drawing a puzzle piece. The library had no test coverage before this.
253 lines
7.7 KiB
Vue
253 lines
7.7 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>
|
|
|
|
<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>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>
|
|
<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="6" 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>
|
|
<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: '',
|
|
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 || '',
|
|
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,
|
|
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, #b8860b);
|
|
border-left-width: 4px;
|
|
border-radius: 4px;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1rem;
|
|
background: rgba(184, 134, 11, 0.08);
|
|
}
|
|
</style>
|