mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
fix(monitor): clarify Lynis removal outcomes and threshold guidance
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
// Execute the actual Security handlers against inert UI state and transport.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
|
||||
const catalogs = Object.fromEntries(['en','de','es','fr','it','pt','sk','sv'].map(lang => [lang, require(path.join(root, `AppImage/messages/${lang}/common.json`)).securityPage]));
|
||||
const begin = source.indexOf(' const handleUninstallLynis = async () => {');
|
||||
const end = source.indexOf('\n const loadFail2banDetails =', begin);
|
||||
assert(begin !== -1 && end > begin);
|
||||
const eraseTypes = text => text.replace(/fetchApi<\{[^}]+\}>/g, 'fetchApi')
|
||||
.replace(/ as \{ partial\?: boolean \} \| undefined/g, '');
|
||||
const uninstall = eraseTypes(source.slice(begin, end));
|
||||
const anchor = source.indexOf('if (confirm(st("confirm.deleteAuditReport")))');
|
||||
assert(anchor !== -1);
|
||||
const clickBegin = source.lastIndexOf('onClick={(e) => {', anchor);
|
||||
const clickEnd = source.indexOf('\n className=', anchor);
|
||||
assert(clickBegin !== -1 && clickEnd > anchor);
|
||||
const click = eraseTypes(source.slice(clickBegin + 'onClick={'.length, clickEnd).trim().replace(/}\s*$/, ''));
|
||||
async function exercise(lang, action, reply, priorSuccess = '') {
|
||||
const state = {report: {id: 'inert'}, expanded: true, success: priorSuccess, error: '', loads: 0, calls: []};
|
||||
const st = (key) => key.split('.').reduce((v, k) => v?.[k], catalogs[lang]);
|
||||
const ctx = {
|
||||
st, Error, confirm: () => true,
|
||||
setLynisReport: v => {state.report = v}, setLynisShowReport: v => {state.expanded = v},
|
||||
setSuccess: v => {state.success = v}, setError: v => {state.error = v},
|
||||
setUninstallingLynis: () => {}, setShowLynisUninstallConfirm: () => {},
|
||||
loadSecurityTools: () => {state.loads++},
|
||||
fetchApi: (endpoint, options) => {
|
||||
assert.equal(endpoint, action === 'delete' ? '/api/security/lynis/report' : '/api/security/lynis/uninstall');
|
||||
assert.equal(options.method, action === 'delete' ? 'DELETE' : 'POST');
|
||||
state.calls.push(options.method);
|
||||
return reply instanceof Error ? Promise.reject(reply) : Promise.resolve(reply);
|
||||
},
|
||||
};
|
||||
const fn = vm.runInNewContext(action === 'delete' ? `(${click})` : `${uninstall}; handleUninstallLynis`, ctx);
|
||||
await fn({stopPropagation() {}});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return state;
|
||||
}
|
||||
(async () => {
|
||||
for (const lang of Object.keys(catalogs)) {
|
||||
for (const action of ['delete', 'uninstall']) {
|
||||
const success = await exercise(lang, action, {success:true, message:'inert'});
|
||||
assert.equal(success.report, null, `${lang} ${action} clean report`);
|
||||
assert(success.success, `${lang} ${action} clean success`);
|
||||
assert.equal(success.error, '', `${lang} ${action} clean no error`);
|
||||
assert.equal(success.loads, 1);
|
||||
for (const [kind, reply] of [
|
||||
['no_files', {success:false,outcome:'no_files',message:'No report files found to delete'}],
|
||||
['malformed_success', {success:'false',message:'inert malformed response'}],
|
||||
['first_failure', {success:false,partial:false,message:'inert denied'}],
|
||||
['partial_failure', {success:false,partial:true,message:'inert denied'}],
|
||||
['http_failure', Object.assign(new Error('inert denied'), {body:{success:false,partial:true,message:'inert denied'}})],
|
||||
['nonjson_failure', new Error('Invalid JSON response')],
|
||||
['network_failure', new Error('network failure')],
|
||||
]) {
|
||||
const state = await exercise(lang, action, reply);
|
||||
assert.notEqual(state.report, null, `${lang} ${action} ${kind}: report retained`);
|
||||
assert.equal(state.success, '', `${lang} ${action} ${kind}: no success`);
|
||||
assert.equal(state.loads, 0, `${lang} ${action} ${kind}: no success refresh`);
|
||||
assert(state.error, `${lang} ${action} ${kind}: error visible`);
|
||||
const expectedKey = kind === 'no_files' ? (action === 'delete' ? 'deleteReportNoFiles' : 'lynisUninstallNoFiles')
|
||||
: kind === 'partial_failure' || kind === 'http_failure' ? 'lynisRemovalPartial'
|
||||
: 'lynisRemovalUnconfirmed';
|
||||
assert.equal(state.error, catalogs[lang].errors[expectedKey], `${lang} ${action} ${kind}: native whole-message feedback`);
|
||||
if (action === 'delete' && kind === 'no_files') {
|
||||
const stale = await exercise(lang, action, reply, 'Earlier unrelated success');
|
||||
assert.equal(stale.success, '', `${lang} delete clears stale success before failure`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('actual Lynis handlers: eight locales, clean/no-files/pre-first/partial/HTTP/non-JSON/network state');
|
||||
})().catch(err => { console.error(err); process.exitCode = 1 });
|
||||
@@ -0,0 +1,57 @@
|
||||
// Execute the actual printable-report generator, without mounting the app.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
|
||||
const begin = source.indexOf(' const generatePrintableReport = (report: LynisReport) => {');
|
||||
const end = source.indexOf('\n const loadSslStatus =', begin);
|
||||
assert(begin > 0 && end > begin, 'print consumer not found');
|
||||
const body = source.slice(begin, end)
|
||||
.replace('(report: LynisReport)', '(report)')
|
||||
.replace('(raw: unknown): string', '(raw)');
|
||||
const lang = process.env.LYNIS_FIXTURE_LANG || 'en';
|
||||
assert(['en','de','es','fr','it','pt','sk','sv'].includes(lang));
|
||||
const catalog = require(path.join(root, `AppImage/messages/${lang}/common.json`));
|
||||
const translate = (key, params = {}) => {
|
||||
const value = key.split('.').reduce((v, k) => v?.[k], catalog.securityPage);
|
||||
assert.equal(typeof value, 'string', `missing ${lang} key ${key}`);
|
||||
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k]));
|
||||
};
|
||||
const context = {
|
||||
st: translate,
|
||||
window: { location: { origin: 'https://offline.invalid' } },
|
||||
document: { documentElement: { lang } },
|
||||
getLynisScoreState: () => ({rawScore: 80, displayScore: 80, reportComplete: true, hasAdjustment: false}),
|
||||
getActionableCount: (total, expected) => Math.max(0, total - expected),
|
||||
lynisCountText: (key, count) => translate(`lynis.counts.${key}.${count === 1 ? 'one' : 'many'}`, { count }),
|
||||
};
|
||||
const render = vm.runInNewContext(`${body}\ngeneratePrintableReport`, context);
|
||||
const report = {
|
||||
hostname: 'inert', os_name: 'Linux', os_version: '', os_fullname: 'Linux',
|
||||
kernel_version: 'test', lynis_version: 'test', datetime_start: '2026-01-01',
|
||||
hardening_index: 80, tests_performed: 1,
|
||||
warnings: [
|
||||
{test_id: 'EXPECTED-W', description: 'expected warning', solution: '', proxmox_expected: true},
|
||||
{test_id: 'ACTION-W', description: 'actionable warning', solution: '', proxmox_expected: false},
|
||||
],
|
||||
suggestions: [
|
||||
{test_id: 'EXPECTED-S', description: 'expected suggestion', solution: '', proxmox_expected: true},
|
||||
{test_id: 'ACTION-S', description: 'actionable suggestion', solution: '', proxmox_expected: false},
|
||||
],
|
||||
proxmox_expected_warnings: 1, proxmox_expected_suggestions: 1, sections: [],
|
||||
};
|
||||
const html = render(report);
|
||||
if (process.env.LYNIS_FIXTURE_HTML) fs.writeFileSync(process.env.LYNIS_FIXTURE_HTML, html);
|
||||
for (const id of ['EXPECTED-W', 'ACTION-W', 'EXPECTED-S', 'ACTION-S']) {
|
||||
assert(html.includes(id), `missing actual finding ${id}`);
|
||||
}
|
||||
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-W[\s\S]*?f-tag-pve/);
|
||||
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-S[\s\S]*?f-tag-pve/);
|
||||
for (const key of ['lynis.report.warningsDescription', 'lynis.report.suggestionsDescription']) {
|
||||
const description = translate(key);
|
||||
assert(!/hidden|nascosti|ocult|caché|skryt|dold|versteckt/i.test(description));
|
||||
assert(html.includes(description), `missing rendered description ${key}`);
|
||||
}
|
||||
console.log(`print HTML (${lang}): expected/actionable findings shown and badged`);
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Inert branch tests for the real Lynis deletion/uninstall producers."""
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
def extracted(path, name, namespace):
|
||||
tree = ast.parse((ROOT / path).read_text())
|
||||
fn = next(x for x in tree.body if isinstance(x, ast.FunctionDef) and x.name == name)
|
||||
fn.decorator_list = []
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), str(path), 'exec'), namespace)
|
||||
return namespace[name]
|
||||
|
||||
class LynisFeedbackProducer(unittest.TestCase):
|
||||
def test_actual_ui_feedback_handlers_all_shipped_locales(self):
|
||||
result = subprocess.run(['node', str(ROOT / '.github/scripts/tests/fixtures/lynis_feedback_contract.cjs')],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_uninstall_nothing_to_remove_is_not_reported_as_uninstalled(self):
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: False, join=lambda *p: '/'.join(p)), remove=lambda p: self.fail(p))
|
||||
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
|
||||
self.assertFalse(result[0])
|
||||
self.assertEqual(result[3], 'no_files')
|
||||
manager = SimpleNamespace(uninstall_lynis=lambda: result)
|
||||
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
|
||||
self.assertEqual(route()['outcome'], 'no_files')
|
||||
|
||||
def test_report_outcomes(self):
|
||||
paths = ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
|
||||
for fail_at in (None, 0, 1, 'missing'):
|
||||
with self.subTest(fail_at=fail_at):
|
||||
removed = []
|
||||
def remove(p):
|
||||
if fail_at in (0, 1) and p == paths[fail_at]:
|
||||
raise PermissionError('inert denial')
|
||||
removed.append(p)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: fail_at != 'missing'), remove=remove)
|
||||
ns = {'security_manager': object(), 'jsonify': lambda data: data}
|
||||
orig_import = __import__
|
||||
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_os if n == 'os' else orig_import(n, *a, **kw)):
|
||||
result = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_report_delete', ns)()
|
||||
body, status = result if isinstance(result, tuple) else (result, 200)
|
||||
self.assertEqual(removed, paths[:fail_at] if fail_at in (0, 1) else ([] if fail_at == 'missing' else paths))
|
||||
self.assertEqual(body['success'], fail_at is None)
|
||||
if fail_at == 'missing':
|
||||
self.assertEqual(body['outcome'], 'no_files')
|
||||
elif fail_at in (0, 1):
|
||||
self.assertEqual(status, 500)
|
||||
self.assertEqual(body['partial'], fail_at == 1)
|
||||
self.assertIn('inert denial', body['message'])
|
||||
|
||||
def test_uninstall_outcomes_and_route(self):
|
||||
targets = ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
|
||||
for fail_at in (None, 0, 2):
|
||||
with self.subTest(fail_at=fail_at):
|
||||
removed = []
|
||||
def remove(path):
|
||||
if fail_at is not None and path == targets[fail_at]: raise PermissionError('inert denial')
|
||||
removed.append(path)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p in targets, join=lambda *parts: '/'.join(parts)), remove=remove)
|
||||
fake_shutil = SimpleNamespace(rmtree=remove)
|
||||
orig_import = __import__
|
||||
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_shutil if n == 'shutil' else orig_import(n, *a, **kw)):
|
||||
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
|
||||
self.assertEqual(removed, targets[:fail_at] if fail_at is not None else targets)
|
||||
self.assertEqual(result[0], fail_at is None)
|
||||
self.assertEqual(result[2], fail_at == 2)
|
||||
manager = SimpleNamespace(uninstall_lynis=lambda: result)
|
||||
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
|
||||
self.assertEqual(route()['partial'], fail_at == 2)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Inert source-contract checks; never import operational modules or touch host paths."""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
APP = ROOT / 'AppImage'
|
||||
|
||||
|
||||
def extract_function(path, name, *, owner=None):
|
||||
tree = ast.parse(path.read_text())
|
||||
container = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) if owner else tree
|
||||
node = next(n for n in container.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
namespace = {}
|
||||
return node, ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[]))
|
||||
|
||||
|
||||
def leaf(pointer):
|
||||
data = json.loads((APP / 'messages/en/common.json').read_text())
|
||||
for segment in pointer.split('.'):
|
||||
data = data[segment]
|
||||
return data
|
||||
|
||||
|
||||
class LynisThresholdContracts(unittest.TestCase):
|
||||
def test_delete_confirmation_names_all_three_actual_targets(self):
|
||||
node, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
removed = []
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=removed.append)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == 'os':
|
||||
return fake_os
|
||||
return original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, str(APP / 'scripts/flask_security_routes.py'), 'exec'), namespace)
|
||||
result = namespace['lynis_report_delete']()
|
||||
self.assertEqual(result['success'], True)
|
||||
self.assertEqual(removed, ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
|
||||
text = leaf('securityPage.confirm.deleteAuditReport').lower()
|
||||
self.assertIn('report', text)
|
||||
self.assertIn('logs', text)
|
||||
self.assertNotIn('this audit report?', text)
|
||||
|
||||
def test_delete_route_partial_failure_is_not_reported_as_full_success(self):
|
||||
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
removed = []
|
||||
def remove(path):
|
||||
if path.endswith('lynis.log'):
|
||||
raise PermissionError('inert denial')
|
||||
removed.append(path)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=remove)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
|
||||
result, status = namespace['lynis_report_delete']()
|
||||
self.assertEqual(removed, ['/var/log/lynis-report.dat'])
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('inert denial', result['message'])
|
||||
|
||||
def test_delete_route_with_no_files_returns_success_false_not_a_clean_result(self):
|
||||
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: False), remove=lambda p: self.fail(p))
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
|
||||
result = namespace['lynis_report_delete']()
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('No report files', result['message'])
|
||||
|
||||
def test_uninstall_notice_describes_existing_cleanup_targets(self):
|
||||
_, module = extract_function(APP / 'scripts/security_manager.py', 'uninstall_lynis')
|
||||
removed = []
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p != '/usr/local/share/proxmenux/components_status.json', join=lambda *s: '/'.join(s)), remove=removed.append)
|
||||
class FakeShutil:
|
||||
@staticmethod
|
||||
def rmtree(path):
|
||||
removed.append(path)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return FakeShutil if name == 'shutil' else original_import(name, *args, **kwargs)
|
||||
namespace = {'os': fake_os}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis uninstall>', 'exec'), namespace)
|
||||
result = namespace['uninstall_lynis']()
|
||||
self.assertTrue(result[0])
|
||||
self.assertEqual(removed, ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
|
||||
for key in ('securityPage.lynis.removeReports', 'securityPage.lynis.uninstallConfirmDescription'):
|
||||
text = leaf(key).lower()
|
||||
self.assertIn('report', text)
|
||||
self.assertIn('logs', text)
|
||||
self.assertNotIn('monitor-created', text)
|
||||
|
||||
def test_swap_hint_qualifies_sampling_and_independent_ram_alarm(self):
|
||||
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
|
||||
# The method is bound to an inert class, not the production constructor.
|
||||
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
|
||||
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
||||
memory = SimpleNamespace(percent=50, available=10, total=100)
|
||||
swap = SimpleNamespace(percent=90, used=90, total=100)
|
||||
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
|
||||
'time': SimpleNamespace(time=lambda: 1000)}
|
||||
exec(compile(module, '<inert memory check>', 'exec'), namespace)
|
||||
first = namespace['_check_memory_comprehensive'](self_obj)
|
||||
second = namespace['_check_memory_comprehensive'](self_obj)
|
||||
self.assertEqual(first['status'], 'OK')
|
||||
self.assertEqual(second['checks']['swap_usage']['status'], 'CRITICAL')
|
||||
self_obj.state_history.clear()
|
||||
memory.percent = 91
|
||||
swap.percent = 0
|
||||
for _ in range(8):
|
||||
last = namespace['_check_memory_comprehensive'](self_obj)
|
||||
self.assertEqual(last['status'], 'CRITICAL')
|
||||
self.assertEqual(last['checks']['swap_usage']['status'], 'OK')
|
||||
hint = leaf('settings.healthThresholds.swapPressureHint').lower()
|
||||
self.assertIn('swap usage', hint)
|
||||
self.assertIn('repeated', hint)
|
||||
self.assertIn('ram', hint)
|
||||
self.assertNotIn('swap file', hint)
|
||||
self.assertNotIn('swap file', leaf('settings.healthThresholds.swapHighLabel').lower())
|
||||
self.assertNotIn('only when both', hint)
|
||||
|
||||
def test_print_generator_and_descriptions(self):
|
||||
node = ROOT / '.github/scripts/tests/fixtures/lynis_print_contract.cjs'
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
result = subprocess.run(['node', str(node)], cwd=ROOT, text=True, capture_output=True,
|
||||
env={**os.environ, 'LYNIS_FIXTURE_LANG': lang})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn(f'print HTML ({lang}):', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user