GE-Enforce: compliance view, inline payload upload, frontend test harness

Fleet-install compliance for app-linked manifest entries: new service
compliance_for_scope + GET /geenforce/scopes/<id>/compliance count active
ComputerInstalledApp rows by curated appid (null-safe when computers plugin
absent). ManifestEditor gains a compliance panel. Curated appid stays shopdb
metadata and never enters manifest JSON, so behavioral parity is unaffected.

Inline manifest payloads: store_inline_payload (sha256, 1MB cap,
payloadsource='inline') + POST/GET /geenforce/entries/<id>/payload; editor
gains an upload control. Entry payload metadata surfaced in _entry_payload.

Frontend test harness: extract the editor's entry-form logic into pure
entryForm.js (buildEntryPayload, describeEntry, availableEntryTypes, scope
gates, ...) and cover it with 45 vitest tests. ManifestEditor now imports
those helpers, so the tests exercise the shipped code path (no duplication).

908 backend tests pass; vitest 45 pass; frontend build green; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 07:24:51 -04:00
parent 3355436fcd
commit 4e5b4228c1
10 changed files with 3411 additions and 145 deletions

View File

@@ -218,6 +218,48 @@
</div>
</div>
</div>
<!-- Compliance: install coverage for tracked-application entries -->
<div class="section-card">
<h3 class="section-title">Compliance: who has these installed?</h3>
<p class="setting-description">
Install counts for entries linked to a tracked application, from the
computers collector. Only entries with a tracked app appear.
</p>
<div v-if="compliance && compliance.computersplugin === false" class="muted compliance-note">
Install counts require the computers plugin.
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Entry</th>
<th>Tracked app</th>
<th>Expected version</th>
<th>Installed</th>
<th>On expected version</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr v-for="row in complianceRows" :key="row.entryid">
<td>{{ row.entryname }}</td>
<td>{{ row.appname }}</td>
<td class="muted">{{ row.expectedversion || '-' }}</td>
<td>{{ row.installedcount === null ? '-' : row.installedcount }}</td>
<td>
<span v-if="row.versionmatchcount === null" class="muted">-</span>
<span v-else>{{ row.versionmatchcount }}</span>
</td>
<td class="muted">{{ row.coveragenote }}</td>
</tr>
<tr v-if="!complianceRows.length">
<td colspan="6" class="empty">No entries are linked to a tracked application yet.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-else class="section-card empty-detail">
@@ -334,6 +376,30 @@
</div>
</div>
<!-- inline payload upload (only for a saved entry) -->
<div v-if="entryForm.entryid" class="setting-group">
<h3>Inline payload</h3>
<p class="setting-description">
Attach a small file (installer config, .reg, script) stored in
shopdb and shipped with this entry.
</p>
<div v-if="entryForm.haspayload" class="payload-current muted">
Current: {{ entryForm.payloadref }}
<span v-if="entryForm.payloadsha256">(sha256 {{ entryForm.payloadsha256.slice(0, 12) }})</span>
</div>
<div v-else class="payload-current muted">No payload attached.</div>
<div class="payload-upload">
<input type="file" @change="onPayloadFileChange" />
<button
type="button"
class="btn btn-sm btn-secondary"
:disabled="!payloadFile || payloadUploading"
@click="uploadPayload"
>{{ payloadUploading ? 'Uploading...' : 'Upload' }}</button>
</div>
<small class="input-hint">Inline payloads are capped at 1 MB and stored in shopdb.</small>
</div>
<!-- detection -->
<div class="setting-row">
<label>
@@ -353,7 +419,11 @@
<label><span>Detection name</span><input v-model="entryForm.DetectionName" /></label>
</div>
<div class="setting-row">
<label><span>Detection value</span><input v-model="entryForm.DetectionValue" /></label>
<label>
<span>Detection value</span>
<input v-model="entryForm.DetectionValue" />
<small class="input-hint">For FileVersion detection this is the expected version used by the Compliance panel.</small>
</label>
</div>
<div v-if="entryForm.DetectionMethod === 'pnputil'" class="setting-row">
<label><span>Detection pattern</span><input v-model="entryForm.DetectionPattern" /></label>
@@ -491,12 +561,16 @@
<script setup>
import { ref, computed } from 'vue'
import api from '../../api'
const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always']
const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
// Pure entry-form logic lives in entryForm.js (unit-tested by entryForm.spec.js);
// this component wires it to reactive state so the tests cover the shipped code.
import {
ENTRY_TYPES, REG_TYPES, DETECTION_METHODS, INUSE_BEHAVIORS,
blankEntry, buildEntryPayload as buildEntryPayloadPure,
describeEntry, availableEntryTypes as availableEntryTypesFor,
availableDetectionMethods as availableDetectionMethodsFor,
targetingGates as targetingGatesFor, targetingHint as targetingHintFor,
scopeSummary as scopeSummaryFor,
} from './entryForm'
const scopes = ref([])
const selectedId = ref(null)
@@ -510,66 +584,14 @@ const shareRoot = ref('')
// and only preinstall honors the preinstall flags. Restrict the form for a
// preinstall scope so an author cannot pick an option that would do nothing.
const isPreinstallScope = computed(() => detail.value?.phase === 'preinstall')
const availableEntryTypes = computed(() => {
if (!isPreinstallScope.value) return ENTRY_TYPES
const allowed = ['MSI', 'EXE']
// keep the current value visible even if it is out of the allowed set
const current = entryForm.value?.Type
return current && !allowed.includes(current) ? [...allowed, current] : allowed
})
const availableDetectionMethods = computed(() => {
if (!isPreinstallScope.value) return DETECTION_METHODS
const allowed = ['Registry', 'File']
const current = entryForm.value?.DetectionMethod
return current && !allowed.includes(current) ? [...allowed, current] : allowed
})
// One-line summary of what a scope installs (installer entries only).
const scopeSummary = computed(() => {
const scope = detail.value
if (!scope || !scope.entries || !scope.entries.length) return ''
const installers = scope.entries
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
.map(e => e.Name)
const shown = installers.slice(0, 6).join(', ')
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
return `${apps}${scope.entries.length} entries; runs after common.`
})
// Show only the targeting gates a scope actually uses (a per-type manifest
// already runs on its own type, so PCTypes is noise there; CMM shows a version
// gate; collections shows machine numbers; the common/preinstall manifests use
// PCTypes to target subsets). "Show all" reveals every gate.
const availableEntryTypes = computed(() =>
availableEntryTypesFor(isPreinstallScope.value, entryForm.value?.Type))
const availableDetectionMethods = computed(() =>
availableDetectionMethodsFor(isPreinstallScope.value, entryForm.value?.DetectionMethod))
const scopeSummary = computed(() => scopeSummaryFor(detail.value))
const showAllTargeting = ref(false)
const targetingGates = computed(() => {
const scope = detail.value
if (!scope) {
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
}
const name = (scope.scopename || '').toLowerCase()
const entries = scope.entries || []
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
return {
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
// Data-driven (a loose name match would wrongly flag 'nocollections').
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
hostnames: entries.some(e => (e.TargetHostnames || []).length),
}
})
const targetingHint = computed(() => {
const scope = detail.value
if (!scope) return ''
if (scope.iscommon || scope.scopename === 'common') {
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
}
if (scope.phase === 'preinstall') {
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
}
return `This manifest already runs only on ${scope.scopename} PCs. `
+ 'Targeting below narrows within that (version, bay, or subtype).'
})
const targetingGates = computed(() => targetingGatesFor(detail.value))
const targetingHint = computed(() => targetingHintFor(detail.value))
const showNewScope = ref(false)
const newScope = ref({ scopename: '', phase: 'runtime' })
@@ -587,6 +609,12 @@ const previewText = ref('')
const sim = ref({ subtype: '', hostname: '', machinenumber: '', cmmversion: '' })
const simResult = ref(null)
const compliance = ref(null)
const complianceRows = computed(() => compliance.value?.rows || [])
const payloadFile = ref(null)
const payloadUploading = ref(false)
function payload(response) { return response.data.data }
function flash(message) { notice.value = message; setTimeout(() => { notice.value = '' }, 4000) }
@@ -616,10 +644,17 @@ async function selectScope(id) {
selectedId.value = id
showVersions.value = false
simResult.value = null
compliance.value = null
try {
detail.value = payload(await api.get(`/geenforce/scopes/${id}`))
loadCompliance(id)
} catch (e) { error.value = 'Failed to load PC type' }
}
async function loadCompliance(id) {
try {
compliance.value = payload(await api.get(`/geenforce/scopes/${id}/compliance`))
} catch (e) { compliance.value = null }
}
function openNewScope() {
newScope.value = { scopename: '', phase: 'runtime' }
@@ -654,13 +689,9 @@ async function deleteScope() {
}
// -- entries --
function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
inuseBehavior: '', inuseProcesses: [], appid: null,
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
}
function openNewEntry() {
entryForm.value = blankEntry()
payloadFile.value = null
showEntry.value = true
}
function openEditEntry(entry) {
@@ -679,6 +710,7 @@ function openEditEntry(entry) {
form.KillAfterDetection = !!entry.KillAfterDetection
form.PCTypesStrict = !!entry.PCTypesStrict
entryForm.value = form
payloadFile.value = null
showEntry.value = true
}
function addProcess() {
@@ -688,49 +720,8 @@ function addProcess() {
function removeProcess(index) {
entryForm.value.inuseProcesses.splice(index, 1)
}
function splitList(value) {
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
}
function buildEntryPayload() {
const form = entryForm.value
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
// off the manifest JSON.
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) {
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
}
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
}
const pctypes = splitList(form.PCTypes)
if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(form.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(form.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (form[flag]) out[flag] = true
}
if (form.inuseBehavior) {
out.InUseCheck = {
Behavior: form.inuseBehavior,
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
const processData = { Name: process.name }
if (process.exepath) processData.ExePath = process.exepath
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
processData.GracefulCloseTimeoutSec = Number(process.timeout)
}
return processData
}),
}
}
return out
return buildEntryPayloadPure(entryForm.value)
}
async function saveEntry() {
const body = buildEntryPayload()
@@ -745,6 +736,28 @@ async function saveEntry() {
await loadScopes()
} catch (e) { error.value = e.response?.data?.message || 'Save entry failed' }
}
function onPayloadFileChange(event) {
payloadFile.value = event.target.files?.[0] || null
}
async function uploadPayload() {
if (!entryForm.value.entryid || !payloadFile.value) return
payloadUploading.value = true
try {
const form = new FormData()
form.append('file', payloadFile.value)
const updated = payload(await api.post(
`/geenforce/entries/${entryForm.value.entryid}/payload`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } }))
entryForm.value.payloadsource = updated.payloadsource
entryForm.value.payloadref = updated.payloadref
entryForm.value.payloadsha256 = updated.payloadsha256
entryForm.value.haspayload = updated.haspayload
payloadFile.value = null
flash('Payload uploaded.')
await selectScope(detail.value.scopeid)
} catch (e) { error.value = e.response?.data?.message || 'Payload upload failed' }
finally { payloadUploading.value = false }
}
async function deleteEntry(entry) {
if (!confirm(`Delete entry "${entry.Name}"?`)) return
try {
@@ -820,30 +833,6 @@ function filterSummary(entry) {
// Plain-English one-liner: what this entry does, when it self-heals, who it
// applies to. Turns "eDNC | MSI | Registry | -" into intent an IT tech reads.
const ACTION_VERB = {
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
PS1: 'Runs a script for', INF: 'Installs a driver for',
File: 'Copies a file for', Registry: 'Sets a registry value for',
}
function describeEntry(entry) {
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
const method = entry.DetectionMethod
if (!method || method === 'Always') text += '; runs every cycle'
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
else if (method === 'Hash') text += '; re-copies if the file changed'
else if (method === 'MarkerFile') text += '; installs once'
else text += '; reinstalls if not detected'
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
text += '; specific hostname(s)'
} else if (entry.PCTypes && entry.PCTypes.length) {
text += `; ${entry.PCTypes.length} PC type(s)`
}
if (entry.appname) text += `; tracked: ${entry.appname}`
return text
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : ''
}
@@ -1033,4 +1022,18 @@ loadApplications()
/* Reorder / disabled buttons */
.btn:disabled { opacity: 0.4; cursor: default; }
/* Inline payload upload */
.payload-current { font-size: 0.85rem; margin-bottom: 0.5rem; }
.payload-upload {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.payload-upload input[type="file"] { min-width: 0; font-size: 0.85rem; }
/* Compliance panel */
.compliance-note { margin-bottom: 0.75rem; }
</style>