Fix defects found in session review of GE-Enforce plugin
Consolidated fixes from a three-dimension adversarial review. Data-loss (HIGH): the manifest entry editor stripped fields the form did not expose, because PUT /entries is a full reset-then-apply. The form now captures everything - InUseCheck processes as structured name/ExePath/timeout rows (not just names), LogFile, and the three preinstall flags as checkboxes; the dead payload-source control (never wired) is removed. New regression test proves an edit preserves ExePath/timeout/LogFile/PreEnrollment/PCTypesStrict. Update-entry crash (found by that regression test): replacing an entry's one-to-one InUseCheck (unique entryid) collided with the old row mid-flush -> IntegrityError -> 400. update_entry now frees the old InUseCheck (delete+flush) before populate re-inserts it. Export truncation (MEDIUM): export_scope_to_share used a plain truncating open, so a failed/partial write left the live on-share manifest (every PC reads it) empty. Now writes a temp file in the same dir and os.replace() atomically. Report dedup case bug (MEDIUM, confirmed by scratch test): the iscurrent demote matched hostname case-sensitively while the read path uses ilike, so a PC reporting different casing left two iscurrent rows and double-counted. Demote is now case-insensitive; regression test added. Simulator fidelity (MEDIUM): PCTypesStrict was captured but ignored by the filter mirror, so the simulator wrongly matched a collections-only strict entry to a nocollections PC via the shared Standard alias group. matches_pctype now honors PCTypesStrict (disables alias expansion); test added. Hardening: removed the dead/unscoped GEENFORCE_API_KEY env fallback (never wired into config; tokens are the only path); create/update entry return 400 on a duplicate Name instead of 500; parity now asserts scope-level Version/Site; a new test guards real-manifest field lengths against column limits (the DB-free parity harness can't see truncation); error handling added to the previously unguarded editor + reports API calls. Full suite green; naming + frontend build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="enforcement-reports">
|
||||
<div class="page-header"><h1>Enforcement Reports</h1></div>
|
||||
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||
<p class="intro">
|
||||
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
||||
up the current published manifest; status shows self-heal and failures.
|
||||
@@ -75,6 +76,7 @@ import api from '../../api'
|
||||
|
||||
const reports = ref([])
|
||||
const detail = ref(null)
|
||||
const error = ref('')
|
||||
const filterHost = ref('')
|
||||
const filterScope = ref('')
|
||||
|
||||
@@ -84,11 +86,16 @@ async function load() {
|
||||
const params = {}
|
||||
if (filterHost.value) params.hostname = filterHost.value
|
||||
if (filterScope.value) params.scopename = filterScope.value
|
||||
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
||||
try {
|
||||
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
||||
error.value = ''
|
||||
} catch (e) { error.value = 'Failed to load reports' }
|
||||
}
|
||||
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
|
||||
async function openDetail(reportid) {
|
||||
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
||||
try {
|
||||
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
||||
} catch (e) { error.value = 'Failed to load report detail' }
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
@@ -106,6 +113,8 @@ load()
|
||||
<style scoped>
|
||||
.enforcement-reports { max-width: 1100px; }
|
||||
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
|
||||
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px; margin-bottom: 1rem; }
|
||||
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.dim { color: var(--text-light); }
|
||||
.fail-count { color: var(--danger); font-weight: 600; }
|
||||
|
||||
@@ -215,26 +215,39 @@
|
||||
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
|
||||
|
||||
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
|
||||
<label>Payload source
|
||||
<select v-model="entryForm.payloadsource">
|
||||
<option value="smb">smb</option>
|
||||
<option value="http">http</option>
|
||||
<option value="inline">inline</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Log file<input v-model="entryForm.LogFile" /></label>
|
||||
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
|
||||
|
||||
<details class="full advanced">
|
||||
<summary>Advanced (not yet enforced by the engine)</summary>
|
||||
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
|
||||
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
|
||||
<label>InUseCheck behavior
|
||||
<details class="full advanced" open>
|
||||
<summary>In-use handling + preinstall flags</summary>
|
||||
<label>Close running app before install (InUseCheck)
|
||||
<select v-model="entryForm.inuseBehavior">
|
||||
<option value="">(none)</option>
|
||||
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>InUseCheck processes (comma of names)<input v-model="entryForm.inuseProcesses" /></label>
|
||||
<div class="full" v-if="entryForm.inuseBehavior">
|
||||
<div class="proc-head">Processes to close
|
||||
<button type="button" class="btn btn-small" @click="addProcess">+ process</button>
|
||||
</div>
|
||||
<div v-for="(proc, i) in entryForm.inuseProcesses" :key="i" class="proc-row">
|
||||
<input v-model="proc.name" placeholder="Name (no .exe)" />
|
||||
<input v-model="proc.exepath" placeholder="ExePath (optional)" />
|
||||
<input type="number" v-model.number="proc.timeout" placeholder="timeout s" />
|
||||
<button type="button" class="btn-move" @click="removeProcess(i)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full preinstall-flags">
|
||||
<span class="flags-label">Preinstall flags (preinstall phase only):</span>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label>
|
||||
</div>
|
||||
</details>
|
||||
<details class="full advanced">
|
||||
<summary>Apply mode / update window (not yet enforced by the engine)</summary>
|
||||
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
|
||||
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
|
||||
</details>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
@@ -347,16 +360,19 @@ async function saveScope() {
|
||||
}
|
||||
async function deleteScope() {
|
||||
if (!confirm(`Delete ${detail.value.scopename}? This removes its manifest.`)) return
|
||||
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
|
||||
detail.value = null
|
||||
selectedId.value = null
|
||||
await loadScopes()
|
||||
try {
|
||||
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
|
||||
detail.value = null
|
||||
selectedId.value = null
|
||||
await loadScopes()
|
||||
} catch (e) { error.value = 'Delete failed' }
|
||||
}
|
||||
|
||||
// -- entries --
|
||||
function blankEntry() {
|
||||
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
||||
payloadsource: 'smb', inuseBehavior: '', inuseProcesses: '' }
|
||||
inuseBehavior: '', inuseProcesses: [],
|
||||
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
||||
}
|
||||
function openNewEntry() {
|
||||
entryForm.value = blankEntry()
|
||||
@@ -369,12 +385,24 @@ function openEditEntry(entry) {
|
||||
form.TargetMachineNumbers = (entry.TargetMachineNumbers || []).join(', ')
|
||||
form.RegValue = entry.RegValue !== undefined ? String(entry.RegValue) : ''
|
||||
form.DetectionMethod = entry.DetectionMethod || ''
|
||||
form.payloadsource = entry.payloadsource || 'smb'
|
||||
form.inuseBehavior = entry.InUseCheck?.Behavior || ''
|
||||
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => p.Name).join(', ')
|
||||
// Structured processes so ExePath + timeout survive an edit (not just Name).
|
||||
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => ({
|
||||
name: p.Name, exepath: p.ExePath || '',
|
||||
timeout: p.GracefulCloseTimeoutSec ?? null }))
|
||||
form.PreEnrollment = !!entry.PreEnrollment
|
||||
form.KillAfterDetection = !!entry.KillAfterDetection
|
||||
form.PCTypesStrict = !!entry.PCTypesStrict
|
||||
entryForm.value = form
|
||||
showEntry.value = true
|
||||
}
|
||||
function addProcess() {
|
||||
if (!Array.isArray(entryForm.value.inuseProcesses)) entryForm.value.inuseProcesses = []
|
||||
entryForm.value.inuseProcesses.push({ name: '', exepath: '', timeout: null })
|
||||
}
|
||||
function removeProcess(index) {
|
||||
entryForm.value.inuseProcesses.splice(index, 1)
|
||||
}
|
||||
function splitList(value) {
|
||||
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
@@ -384,7 +412,7 @@ function buildEntryPayload() {
|
||||
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
||||
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
||||
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
||||
'ApplyMode', 'UpdateWindow', '_comment', 'payloadsource', 'payloadref']
|
||||
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
|
||||
for (const key of scalars) {
|
||||
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key]
|
||||
}
|
||||
@@ -399,9 +427,21 @@ function buildEntryPayload() {
|
||||
if (hostnames.length) out.TargetHostnames = hostnames
|
||||
const machinenumbers = splitList(f.TargetMachineNumbers)
|
||||
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
||||
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
|
||||
if (f[flag]) out[flag] = true
|
||||
}
|
||||
if (f.inuseBehavior) {
|
||||
out.InUseCheck = { Behavior: f.inuseBehavior,
|
||||
Processes: splitList(f.inuseProcesses).map(name => ({ Name: name })) }
|
||||
out.InUseCheck = {
|
||||
Behavior: f.inuseBehavior,
|
||||
Processes: (f.inuseProcesses || []).filter(p => p.name).map(p => {
|
||||
const pd = { Name: p.name }
|
||||
if (p.exepath) pd.ExePath = p.exepath
|
||||
if (p.timeout !== null && p.timeout !== '' && p.timeout !== undefined) {
|
||||
pd.GracefulCloseTimeoutSec = Number(p.timeout)
|
||||
}
|
||||
return pd
|
||||
}),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -420,9 +460,11 @@ async function saveEntry() {
|
||||
}
|
||||
async function deleteEntry(entry) {
|
||||
if (!confirm(`Delete entry "${entry.Name}"?`)) return
|
||||
await api.delete(`/geenforce/entries/${entry.entryid}`)
|
||||
await selectScope(detail.value.scopeid)
|
||||
await loadScopes()
|
||||
try {
|
||||
await api.delete(`/geenforce/entries/${entry.entryid}`)
|
||||
await selectScope(detail.value.scopeid)
|
||||
await loadScopes()
|
||||
} catch (e) { error.value = 'Delete entry failed' }
|
||||
}
|
||||
async function moveEntry(index, direction) {
|
||||
const entries = detail.value.entries
|
||||
@@ -430,8 +472,10 @@ async function moveEntry(index, direction) {
|
||||
if (target < 0 || target >= entries.length) return
|
||||
const order = entries.map(e => e.entryid)
|
||||
;[order[index], order[target]] = [order[target], order[index]]
|
||||
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
|
||||
await selectScope(detail.value.scopeid)
|
||||
try {
|
||||
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
|
||||
await selectScope(detail.value.scopeid)
|
||||
} catch (e) { error.value = 'Reorder failed' }
|
||||
}
|
||||
|
||||
// -- publish / versions --
|
||||
@@ -449,25 +493,33 @@ async function toggleVersions() {
|
||||
if (showVersions.value) await loadVersions()
|
||||
}
|
||||
async function loadVersions() {
|
||||
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
|
||||
try {
|
||||
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
|
||||
} catch (e) { error.value = 'Failed to load versions' }
|
||||
}
|
||||
async function rollback(versionnumber) {
|
||||
if (!confirm(`Roll back to v${versionnumber}? PCs get this on their next cycle.`)) return
|
||||
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
|
||||
await loadVersions()
|
||||
await loadScopes()
|
||||
try {
|
||||
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
|
||||
await loadVersions()
|
||||
await loadScopes()
|
||||
} catch (e) { error.value = 'Rollback failed' }
|
||||
}
|
||||
|
||||
// -- simulate / preview --
|
||||
async function runSimulate() {
|
||||
const params = { ...sim.value }
|
||||
simResult.value = payload(await api.get(
|
||||
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
|
||||
try {
|
||||
const params = { ...sim.value }
|
||||
simResult.value = payload(await api.get(
|
||||
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
|
||||
} catch (e) { error.value = 'Simulate failed' }
|
||||
}
|
||||
async function loadPreview() {
|
||||
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
|
||||
previewText.value = JSON.stringify(data.manifest, null, 2)
|
||||
showPreview.value = true
|
||||
try {
|
||||
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
|
||||
previewText.value = JSON.stringify(data.manifest, null, 2)
|
||||
showPreview.value = true
|
||||
} catch (e) { error.value = 'Preview failed' }
|
||||
}
|
||||
|
||||
function filterSummary(entry) {
|
||||
@@ -544,6 +596,14 @@ loadConfig()
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.proc-head { display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 0.78rem; color: var(--text-light); margin: 0.25rem 0; }
|
||||
.proc-row { display: grid; grid-template-columns: 1fr 1.4fr 0.6fr auto; gap: 0.3rem;
|
||||
margin-bottom: 0.3rem; }
|
||||
.preinstall-flags { margin-top: 0.5rem; }
|
||||
.flags-label { display: block; font-size: 0.75rem; color: var(--text-light); margin-bottom: 0.25rem; }
|
||||
.inline { flex-direction: row !important; align-items: center; gap: 0.3rem !important;
|
||||
display: inline-flex !important; margin-right: 1rem; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.preview { background: var(--bg); padding: 0.75rem; border-radius: 6px; max-height: 60vh;
|
||||
overflow: auto; font-size: 0.78rem; }
|
||||
|
||||
Reference in New Issue
Block a user