From 61fae8f6f75b30840c65f28f660728653e61c701 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:20:40 +0000 Subject: [PATCH] fix: filter native discovery helper services --- AppImage/scripts/native_app_discovery.py | 43 ++++++++++++++-- .../tests/test_native_app_discovery.py | 49 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 169fd4a2..35bb4f07 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -69,7 +69,13 @@ _SYSTEM = { 'elasticsearch', 'opensearch', 'fail2ban', 'named', 'bind9', 'dnsmasq', 'avahi-daemon', 'smbd', 'nmbd', 'samba', 'vsftpd', 'proftpd', 'cups', 'tor', } -_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|sh|bash|dash|env|java|ruby|perl)\Z') +_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|npm|pnpm|yarn|bun|sh|bash|dash|env|java|ruby|perl)\Z') +_PACKAGE_ROOT_RUNTIMES = {'node', 'nodejs', 'npm', 'pnpm', 'yarn', 'bun'} +_HELPER_ROLES = { + 'backup', 'bootstrap', 'bridge', 'cleanup', 'housekeeping', 'maintenance', + 'migrate', 'migration', 'restore', 'rotate', 'update', 'updater', 'upgrade', + 'watchdog', 'worker', +} class ProbeError(Exception): @@ -218,8 +224,23 @@ def _system(name): 'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-'))) +def _helper(name): + """Reject service roles that support an application but are not one themselves.""" + raw = name.lower().removesuffix('.service') + if raw.endswith('@'): + return True # A systemd template is not a concrete running application. + return any(part in _HELPER_ROLES for part in re.split(r'[._-]+', raw) if part) + + def _custom(path): - return _path(path) and path.startswith(('/opt/', '/srv/', '/usr/local/')) + if not _path(path): + return False + if path.startswith(('/opt/', '/srv/', '/usr/local/')): + return True + # Native apps commonly keep their runtime below /home//. + # Do not accept a home directory itself: a second path component is required. + parts = path.split('/') + return len(parts) >= 4 and parts[1] == 'home' and bool(parts[2]) and bool(parts[3]) def parse_records(text, kind): @@ -240,10 +261,21 @@ def parse_records(text, kind): if service in seen_services: continue # /etc overrides vendor units; no duplicate installations. seen_services.add(service) - if not _NAME.fullmatch(service) or _system(service) or not _path(exe): + if (not _NAME.fullmatch(service) or _system(service) or _helper(service) + or not _path(exe)): continue - runtime = bool(_RUNTIMES.fullmatch(Path(exe).name)) - install = script if runtime else exe + runtime_name = Path(exe).name + runtime = bool(_RUNTIMES.fullmatch(runtime_name)) + if runtime and not _path(script): + # A Node/npm wrapper may keep its entry point relative to an + # operator-owned working directory. `package.json` is a bounded, + # declarative footprint that proves the application root without + # reading command arguments, environment or service secrets. + if runtime_name not in _PACKAGE_ROOT_RUNTIMES or not _custom(directory): + continue + install = str(Path(directory) / 'package.json') + else: + install = script if runtime else exe if (not _path(install) or _system(Path(install).name) or install.startswith(('/usr/lib/systemd/', '/lib/systemd/', '/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))): @@ -399,6 +431,7 @@ def scan(vmid, run=bounded_run): related[0].setdefault('listening_ports', []).extend(ports) elif not related and (_custom(exe) and _custom(directory) and not _system(Path(exe).name) + and not _helper(Path(exe).name) and not _RUNTIMES.fullmatch(Path(exe).name) and exe.startswith(directory.rstrip('/') + '/') and _NAME.fullmatch(Path(exe).name)): diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 21962223..661abd95 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -119,6 +119,55 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_helper_services_and_templates_have_zero_footprint_probes(self): + cases = [ + ('netpulse-stable-update', '/bin/bash', '/opt/netpulse', '/opt/netpulse/stable-apply.sh'), + ('rent-tool-backup', '/bin/bash', '/home/app/rent-tool', '/home/app/rent-tool/scripts/backup-db.sh'), + ('keynest-update', '/bin/bash', '/opt/keynest', '/opt/keynest/keynest-update.sh'), + ('cortendesk-bootstrap-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/bootstrap-worker.sh'), + ('cortendesk-source-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/source-worker.sh'), + ('cortendesk-managed-password@', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/password-bridge.sh'), + ] + for name, exe, directory, script in cases: + with self.subTest(name=name): + self.calls.clear() + result = native.scan(101, self.runner(service( + name=name, exe=exe, directory=directory, script=script))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_node_and_npm_wrappers_use_only_custom_package_roots(self): + cases = [ + ('keynest', '/usr/bin/node', '/opt/keynest'), + ('rent-tool', '/usr/local/bin/npm', '/home/app/rent-tool'), + ] + for name, exe, directory in cases: + with self.subTest(name=name): + self.calls.clear() + result = native.scan(101, self.runner( + service(name=name, exe=exe, directory=directory), + footprint='FILE\n{"version":"1.2.3"}')) + item, = result['candidates'] + self.assertEqual(item['name'], name) + self.assertEqual(item['installation_path'], directory + '/package.json') + self.assertEqual(item['installed_version'], '1.2.3') + footprint_calls = [call for call in self.calls if native.FOOTPRINT_SCRIPT in call] + self.assertEqual(len(footprint_calls), 1) + self.assertEqual(footprint_calls[0][-2:], [directory + '/package.json', directory]) + + self.calls.clear() + result = native.scan(101, self.runner(service( + name='ambiguous-home-runtime', exe='/usr/bin/node', directory='/home/app'))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_helper_process_fallback_has_zero_footprint_probes(self): + self.calls.clear() + result = native.scan(101, self.runner( + services='END\n', processes='P\t123\t/opt/cortendesk/run-worker\t/opt/cortendesk\nEND\n')) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self): scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged'] for scenario in scenarios: