Fix defects found in session review of GE-Enforce plugin
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s

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:
cproudlock
2026-07-12 20:46:09 -04:00
parent d894f054ac
commit 0dcd186820
9 changed files with 314 additions and 62 deletions

View File

@@ -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; }

View File

@@ -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; }