diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index ac4737b..1dd4edd 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -368,6 +368,7 @@ def simulate_scope(scopeid): 'hostname': request.args.get('hostname'), 'machinenumber': request.args.get('machinenumber'), 'cmmversion': request.args.get('cmmversion'), + 'phase': scope.phase, } applied, filtered = [], [] for entry in scope.entries: @@ -376,7 +377,9 @@ def simulate_scope(scopeid): applied.append(entry.name) else: reasons = [] - if not matches_pctype(entry_dict, profile['pctype'], profile['subtype']): + strict_allowed = profile.get('phase') == 'preinstall' + if not matches_pctype(entry_dict, profile['pctype'], + profile['subtype'], strict_allowed): reasons.append('PCTypes') if not matches_hostname(entry_dict, profile['hostname']): reasons.append('TargetHostnames') diff --git a/plugins/geenforce/filters.py b/plugins/geenforce/filters.py index 71c2b06..7e1e3ca 100644 --- a/plugins/geenforce/filters.py +++ b/plugins/geenforce/filters.py @@ -42,21 +42,24 @@ def _alias_sets(name): return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)] -def matches_pctype(entry, pctype, subtype=None): +def matches_pctype(entry, pctype, subtype=None, strict_allowed=False): """Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection. PCTypesStrict=true disables alias expansion (both the PC's identity and the - manifest values are compared literally), so e.g. a 'collections'-only strict - entry does NOT match a 'nocollections' PC via their shared 'Standard' alias - group. The preinstall runner honors this flag (see preinstall.json UDC entry); - mirroring it keeps the "what would this PC get" simulator correct. + manifest values are compared literally), so a 'collections'-only strict entry + does NOT match a 'nocollections' PC via their shared 'Standard' alias group. + IMPORTANT: only the PREINSTALL runner honors this flag - the runtime engine + (Install-FromManifest.ps1's Test-PCTypeMatches) has no strict handling. So + strict is applied only when strict_allowed (i.e. phase == 'preinstall'), + else a runtime entry with PCTypesStrict still alias-expands, matching the + real engine. """ values = entry.get('PCTypes') or [] if not values: return True if not pctype: return True - strict = bool(entry.get('PCTypesStrict')) + strict = strict_allowed and bool(entry.get('PCTypesStrict')) # Names the current PC matches: bare type, "type-subtype", and (unless strict) # all aliases of either. mynames = set() @@ -125,9 +128,12 @@ def matches_cmmversion(entry, cmmversion): def entry_applies(entry, profile): """All four filters ANDed, exactly as the engine's main loop applies them. - `profile` keys: pctype, subtype, hostname, machinenumber, cmmversion. + `profile` keys: pctype, subtype, hostname, machinenumber, cmmversion, and + optional phase ('preinstall' enables PCTypesStrict, matching the runners). """ - return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype')) + strict_allowed = profile.get('phase') == 'preinstall' + return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'), + strict_allowed) and matches_hostname(entry, profile.get('hostname')) and matches_machinenumber(entry, profile.get('machinenumber')) and matches_cmmversion(entry, profile.get('cmmversion'))) diff --git a/plugins/geenforce/parity.py b/plugins/geenforce/parity.py index 9cae823..ba7ca5a 100644 --- a/plugins/geenforce/parity.py +++ b/plugins/geenforce/parity.py @@ -59,9 +59,11 @@ def check_scope(scopename, phase, original, fixtures): k for k in set(co) | set(cr) if co.get(k) != cr.get(k)) firstdiff = f'entry "{name}" differs on {diffkeys}' - # Check 2: same entries fire in the same order for each profile. + # Check 2: same entries fire in the same order for each profile. Inject the + # scope phase so PCTypesStrict is honored only for preinstall (see filters). profiles_same = 0 - for profile in fixtures: + for base_profile in fixtures: + profile = {**base_profile, 'phase': phase} if (applicable_entry_names(orig_apps, profile) == applicable_entry_names(rebuilt_apps, profile)): profiles_same += 1 diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py index e90a6ec..633e782 100644 --- a/plugins/geenforce/plugin.py +++ b/plugins/geenforce/plugin.py @@ -178,27 +178,6 @@ class GeEnforcePlugin(BasePlugin): db.session.commit() click.echo(f"Published {scopename}/{phase} as v{version}.") - @geenforce_cli.command('seed-applications') - @click.option('--shareroot', required=True) - @click.option('--preinstall', default=None) - def seed_applications_cmd(shareroot, preinstall): - """Track the apps the manifests install in the core Applications catalog.""" - from flask import current_app - from .importer import discover_share, load_manifest_file - from .service import seed_applications_from_manifests - - with current_app.app_context(): - sources = list(discover_share(shareroot)) - if preinstall: - sources.append(('preinstall', 'preinstall', - load_manifest_file(preinstall))) - result = seed_applications_from_manifests(sources) - db.session.commit() - click.echo(f"Applications: {len(result['created'])} created, " - f"{len(result['existing'])} already tracked.") - for name in result['created']: - click.echo(f" + {name}") - @geenforce_cli.command('export-share') @click.argument('scopename') @click.option('--shareroot', required=True) diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py index b7f552c..23f2bc0 100644 --- a/plugins/geenforce/service.py +++ b/plugins/geenforce/service.py @@ -94,45 +94,6 @@ def rollback_scope(scopename, phase, versionnumber): return versionnumber -# Entry types that install actual applications (vs File/Registry/PS1/INF config). -INSTALLER_TYPES = {'MSI', 'EXE', 'CMD', 'BAT'} - - -def seed_applications_from_manifests(manifests): - """Populate the core Applications catalog from what the manifests install. - - For every installer entry (MSI/EXE/CMD/BAT) across the given manifests, - create an Application (idempotent, deduped by appname) so shopdb tracks the - apps GE-Enforce deploys. `manifests` is a list of (scopename, phase, dict). - Returns {'created': [...], 'existing': [...]} (uncommitted). - """ - from shopdb.api import Application - - created, existing, seen = [], [], set() - for _scopename, _phase, manifest in manifests: - for entry in manifest.get('Applications', []): - if entry.get('Type') not in INSTALLER_TYPES: - continue - name = (entry.get('Name') or '').strip() - if not name or name.lower() in seen: - continue - seen.add(name.lower()) - if Application.query.filter(Application.appname.ilike(name)).first(): - existing.append(name) - continue - description = None - comment = entry.get('_comment') - if comment: - description = comment.split('.')[0].strip()[:255] - db.session.add(Application( - appname=name[:100], - appdescription=description, - installpath=(entry.get('Installer') or '')[:255] or None, - isinstallable=True)) - created.append(name) - return {'created': created, 'existing': existing} - - def record_enforcement_report(payload): """Record one PC's enforcement cycle (observed state). Upserts the latest report per (hostname, scopename, phase) and keeps prior ones as history. @@ -154,10 +115,14 @@ def record_enforcement_report(payload): failed = int(counts.get('failed', 0)) installed = int(counts.get('installed', 0)) - # Derive status: any failure wins; else drift-corrected installs = selfhealed. + # Derive status from EXPLICIT self-heal flags only, never the raw installed + # count: every healthy cycle runs Always/no-detection scripts (asset report, + # VNC firewall, EventSaver) that the engine counts as "installed", so keying + # self-heal off installed>0 would mark the common scope selfhealed forever + # and make 'ok' unreachable. A drift correction is one the client flags. if failed > 0: status = 'failed' - elif installed > 0 or any(r.get('selfhealed') for r in results): + elif any(r.get('selfhealed') for r in results): status = 'selfhealed' else: status = 'ok' @@ -190,8 +155,10 @@ def record_enforcement_report(payload): report.results.append(ManifestEnforcementResult( entryname=item.get('name', ''), action=item.get('action', ''), - selfhealed=bool(item.get('selfhealed', - item.get('action') == 'installed')), + # Explicit flag only; do NOT infer from action == 'installed' + # (Always/no-detection scripts install every cycle without being a + # drift correction). + selfhealed=bool(item.get('selfhealed', False)), exitcode=item.get('exitcode'), message=item.get('message'))) db.session.add(report) diff --git a/tests/test_plugins/test_geenforce_crud.py b/tests/test_plugins/test_geenforce_crud.py index ba328fa..573d3b0 100644 --- a/tests/test_plugins/test_geenforce_crud.py +++ b/tests/test_plugins/test_geenforce_crud.py @@ -155,8 +155,13 @@ def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers): def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers): """A collections-only strict entry must NOT match a nocollections PC via the - shared Standard alias group (mirrors the preinstall UDC entry).""" - scopeid = _create_scope(client, auth_headers, 'preinstall') + shared Standard alias group. PCTypesStrict is preinstall-only, so the scope + must be the preinstall phase (the runtime engine ignores strict).""" + resp = client.post('/api/geenforce/scopes', + json={'scopename': 'preinstall', 'phase': 'preinstall'}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + scopeid = resp.get_json()['data']['scopeid'] _add_entry(client, auth_headers, scopeid, {'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe', 'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True}) diff --git a/tests/test_plugins/test_geenforce_seed_apps.py b/tests/test_plugins/test_geenforce_seed_apps.py deleted file mode 100644 index e1b9580..0000000 --- a/tests/test_plugins/test_geenforce_seed_apps.py +++ /dev/null @@ -1,54 +0,0 @@ -"""GE-Enforce seeds the core Applications catalog from what the manifests install. - -Only installer entries (MSI/EXE/CMD/BAT) become Applications; File/Registry/PS1/ -INF config entries do not. Idempotent + deduped by appname. -""" - -from plugins.geenforce import service -from shopdb.core.models import Application - - -MANIFEST = { - 'Version': '2.6', - 'Applications': [ - {'Name': 'eDNC', 'Type': 'MSI', 'Installer': 'apps/edns.msi', - '_comment': 'eDNC (Universal Data Collection). Bundles NTLARS.'}, - {'Name': 'UDC', 'Type': 'EXE', 'Installer': 'apps/udc.exe'}, - {'Name': 'Config drop', 'Type': 'File', 'Source': 'configs/x.json', - 'Destination': 'C:\\x.json'}, - {'Name': 'Firewall rule', 'Type': 'PS1', 'Script': 'scripts/fw.ps1'}, - {'Name': 'FMS host pin', 'Type': 'Registry', 'RegPath': 'HKLM:\\x', - 'RegName': 'y', 'RegValue': 1, 'RegType': 'DWord'}, - ], -} - - -def test_seed_only_installer_apps(db): - result = service.seed_applications_from_manifests( - [('gea-shopfloor-collections', 'runtime', MANIFEST)]) - service.db.session.commit() - assert set(result['created']) == {'eDNC', 'UDC'} - names = {a.appname for a in Application.query.all()} - assert 'eDNC' in names and 'UDC' in names - # config / script / registry entries are not applications - assert 'Config drop' not in names - assert 'Firewall rule' not in names - assert 'FMS host pin' not in names - edns = Application.query.filter_by(appname='eDNC').first() - assert edns.isinstallable is True - assert edns.installpath == 'apps/edns.msi' - assert edns.appdescription.startswith('eDNC (Universal Data Collection)') - - -def test_seed_is_idempotent_and_deduped(db): - manifests = [('a', 'runtime', MANIFEST), ('b', 'runtime', MANIFEST)] - first = service.seed_applications_from_manifests(manifests) - service.db.session.commit() - # deduped across the two manifests: 2 created, not 4 - assert len(first['created']) == 2 - # re-run: nothing new, both already tracked - second = service.seed_applications_from_manifests(manifests) - service.db.session.commit() - assert second['created'] == [] - assert set(second['existing']) == {'eDNC', 'UDC'} - assert Application.query.filter(Application.appname.in_(['eDNC', 'UDC'])).count() == 2