domma-cms 0.82.0 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/login.html +24 -0
- package/admin/js/views/login.js +7 -7
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +46 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +64 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +135 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +184 -39
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - the facts the health check judges (admin/lib/health.js does the
|
|
3
|
+
* judging). Everything here is read-only and time-boxed: a check that cannot
|
|
4
|
+
* be answered in a few seconds is reported as unknown, never as a pass.
|
|
5
|
+
*
|
|
6
|
+
* Slow answers (the latest engine version, the certificate) are cached.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import tls from 'node:tls';
|
|
11
|
+
import {getConfig} from '../../../server/config.js';
|
|
12
|
+
import {listUsers} from '../../../server/services/users.js';
|
|
13
|
+
import {getEffectiveLevel} from '../../../server/services/userRoles.js';
|
|
14
|
+
import {getEntitlementStates} from '../../../server/services/plugins.js';
|
|
15
|
+
|
|
16
|
+
const CMS_ROOT = process.cwd();
|
|
17
|
+
const TIMEOUT = 5000;
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
|
|
20
|
+
async function cached(key, ms, fn) {
|
|
21
|
+
const hit = cache.get(key);
|
|
22
|
+
if (hit && hit.until > Date.now()) return hit.value;
|
|
23
|
+
const value = await fn();
|
|
24
|
+
cache.set(key, {value, until: Date.now() + ms});
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
29
|
+
|
|
30
|
+
/** The engine's own version, and npm's latest (cached 12 h; null when unreachable). */
|
|
31
|
+
async function engine() {
|
|
32
|
+
// The engine's version: .domma's cmsVersion (the updater stamps it). A site's own package.json
|
|
33
|
+
// carries the SITE's version; only the engine's repo has no .domma.
|
|
34
|
+
const installed = readJson(path.join(CMS_ROOT, '.domma'))?.cmsVersion || readJson(path.join(CMS_ROOT, 'package.json'))?.version || null;
|
|
35
|
+
const latest = await cached('latest', 12 * 3_600_000, async () => {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch('https://registry.npmjs.org/domma-cms/latest', {signal: AbortSignal.timeout(TIMEOUT)});
|
|
38
|
+
return res.ok ? (await res.json()).version || null : null;
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
});
|
|
41
|
+
return {installed, latest};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Days left on the certificate the site serves (cached 6 h). */
|
|
45
|
+
function certificate(host) {
|
|
46
|
+
return cached(`cert:${host}`, 6 * 3_600_000, () => new Promise((resolve) => {
|
|
47
|
+
const done = (v) => { try { socket.destroy(); } catch { /* gone */ } resolve(v); };
|
|
48
|
+
const socket = tls.connect({host, port: 443, servername: host, rejectUnauthorized: false, timeout: TIMEOUT}, () => {
|
|
49
|
+
const c = socket.getPeerCertificate();
|
|
50
|
+
if (!c?.valid_to) return done({daysLeft: null, error: 'no certificate'});
|
|
51
|
+
done({daysLeft: Math.floor((Date.parse(c.valid_to) - Date.now()) / 86_400_000), issuer: c.issuer?.O || c.issuer?.CN || ''});
|
|
52
|
+
});
|
|
53
|
+
socket.on('timeout', () => done({daysLeft: null, error: 'timed out'}));
|
|
54
|
+
socket.on('error', (e) => done({daysLeft: null, error: e.code || e.message}));
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The security headers the public home page is served with. */
|
|
59
|
+
async function headers(origin) {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${origin}/`, {redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT), headers: {'user-agent': 'Domma Security health check'}});
|
|
62
|
+
const h = (n) => res.headers.get(n) || '';
|
|
63
|
+
const csp = h('content-security-policy');
|
|
64
|
+
return {
|
|
65
|
+
https: origin.startsWith('https:'),
|
|
66
|
+
csp: Boolean(csp),
|
|
67
|
+
nosniff: /nosniff/i.test(h('x-content-type-options')),
|
|
68
|
+
frame: Boolean(h('x-frame-options')) || /frame-ancestors/i.test(csp),
|
|
69
|
+
referrer: Boolean(h('referrer-policy')),
|
|
70
|
+
hsts: Boolean(h('strict-transport-security'))
|
|
71
|
+
};
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return {error: e.name === 'TimeoutError' ? 'timed out' : (e.cause?.code || e.message)};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Private paths asked of the CMS itself (locally, so Security's own probe filter does not hide them). */
|
|
78
|
+
const PRIVATE = ['/.env', '/.git/config', '/config/server.json', '/content/users/', '/config/auth.json', '/.env.example'];
|
|
79
|
+
async function probeFiles(port) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const p of PRIVATE) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`http://127.0.0.1:${port}${p}`, {redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT)});
|
|
84
|
+
out.push({path: p, status: res.status});
|
|
85
|
+
} catch {
|
|
86
|
+
out.push({path: p, status: 0});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isLocalHost = (h) => !h || h === 'localhost' || /^[\d.]+$/.test(h) || h.includes(':') || h.endsWith('.local') || h.endsWith('.test');
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Everything the health check needs.
|
|
96
|
+
*
|
|
97
|
+
* @param {import('fastify').FastifyRequest} request - the admin asking (its host is the site's)
|
|
98
|
+
* @param {{settings: object, checks: boolean, twoFactor?: boolean}} ctx
|
|
99
|
+
*/
|
|
100
|
+
export async function gatherFacts(request, {settings, checks, twoFactor = false}) {
|
|
101
|
+
const server = readJson(path.join(CMS_ROOT, 'config', 'server.json')) || {};
|
|
102
|
+
const port = Number(process.env.PORT) || server.port || 4096;
|
|
103
|
+
const host = String(request.hostname || '').split(':')[0];
|
|
104
|
+
const proto = request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
105
|
+
const origin = isLocalHost(host) ? `http://127.0.0.1:${port}` : `${proto}://${host}`;
|
|
106
|
+
|
|
107
|
+
const users = (await listUsers()).map(u => ({name: u.name, email: u.email, role: u.role, lastLogin: u.lastLogin || null,
|
|
108
|
+
createdAt: u.createdAt || null, isActive: u.isActive !== false, level: getEffectiveLevel(u)}));
|
|
109
|
+
const trustRaw = String(process.env.TRUST_PROXY ?? '').trim();
|
|
110
|
+
const trust = !trustRaw || /^(0|false|no|off)$/i.test(trustRaw) ? false : /^(1|true|yes|on)$/i.test(trustRaw) ? true : trustRaw;
|
|
111
|
+
|
|
112
|
+
const [eng, cert, head, probes] = await Promise.all([
|
|
113
|
+
engine(),
|
|
114
|
+
isLocalHost(host) ? Promise.resolve(null) : certificate(host),
|
|
115
|
+
headers(origin),
|
|
116
|
+
probeFiles(port)
|
|
117
|
+
]);
|
|
118
|
+
const smtp = getConfig('site')?.smtp || {};
|
|
119
|
+
return {
|
|
120
|
+
admins: users.filter(u => u.isActive && u.level <= 1),
|
|
121
|
+
users: users.filter(u => u.isActive),
|
|
122
|
+
engine: eng,
|
|
123
|
+
cert,
|
|
124
|
+
headers: head,
|
|
125
|
+
probeFiles: probes,
|
|
126
|
+
nodeEnv: process.env.NODE_ENV || '',
|
|
127
|
+
proxy: {via: Boolean(request.headers['x-forwarded-for']), trust},
|
|
128
|
+
bind: server.host || '',
|
|
129
|
+
smtp: Boolean(smtp.host),
|
|
130
|
+
licences: Object.entries(getEntitlementStates()).map(([name, state]) => ({name, state})),
|
|
131
|
+
protections: {checks, lockout: checks && settings.lockout.enabled, policy: checks && settings.policy.enabled},
|
|
132
|
+
policyMin: settings.policy.minLength,
|
|
133
|
+
twoFactor
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - what it remembers, in the plugin's data/ (kept by every update and
|
|
3
|
+
* copy; never in a catalogue bundle):
|
|
4
|
+
*
|
|
5
|
+
* state.json - lockouts (lockout.js), blocks: manual rules and temporary
|
|
6
|
+
* blocks, addresses recently signed in from, today's counters
|
|
7
|
+
* signins.jsonl - one line per sign-in attempt, for the Sign-ins screen
|
|
8
|
+
*
|
|
9
|
+
* state.json is held in memory and written (debounced) on change. The file is
|
|
10
|
+
* re-read when it changes underneath - bin/unlock.js edits it while the site
|
|
11
|
+
* runs, and that must win.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const EMPTY = () => ({lockouts: {}, rules: [], temp: {}, trusted: {}, counters: {day: '', probes: 0, blocks: 0, failed: 0}});
|
|
17
|
+
|
|
18
|
+
export function createState(dataDir) {
|
|
19
|
+
fs.mkdirSync(dataDir, {recursive: true});
|
|
20
|
+
const file = path.join(dataDir, 'state.json');
|
|
21
|
+
const logFile = path.join(dataDir, 'signins.jsonl');
|
|
22
|
+
let state = EMPTY();
|
|
23
|
+
let ownMtime = 0;
|
|
24
|
+
let timer = null;
|
|
25
|
+
|
|
26
|
+
function read() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
state = {...EMPTY(), ...raw, counters: {...EMPTY().counters, ...(raw.counters || {})}};
|
|
30
|
+
ownMtime = fs.statSync(file).mtimeMs;
|
|
31
|
+
} catch { /* first run */ }
|
|
32
|
+
}
|
|
33
|
+
function write() {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
timer = null;
|
|
36
|
+
try {
|
|
37
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
38
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
39
|
+
fs.renameSync(tmp, file);
|
|
40
|
+
ownMtime = fs.statSync(file).mtimeMs;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.error('[security] could not save state:', e.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
read();
|
|
46
|
+
|
|
47
|
+
// Someone else (the unlock CLI) wrote it: theirs wins.
|
|
48
|
+
const watch = setInterval(() => {
|
|
49
|
+
try {
|
|
50
|
+
const m = fs.statSync(file).mtimeMs;
|
|
51
|
+
if (m !== ownMtime && !timer) read();
|
|
52
|
+
} catch { /* not written yet */ }
|
|
53
|
+
}, 3000);
|
|
54
|
+
watch.unref?.();
|
|
55
|
+
|
|
56
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
57
|
+
return {
|
|
58
|
+
get: () => state,
|
|
59
|
+
/** Change it; saved within a second. */
|
|
60
|
+
change(fn) {
|
|
61
|
+
const out = fn(state);
|
|
62
|
+
if (!timer) {
|
|
63
|
+
timer = setTimeout(write, 800);
|
|
64
|
+
timer.unref?.();
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
},
|
|
68
|
+
flush: write,
|
|
69
|
+
count(name) {
|
|
70
|
+
this.change((s) => {
|
|
71
|
+
if (s.counters.day !== today()) s.counters = {day: today(), probes: 0, blocks: 0, failed: 0};
|
|
72
|
+
s.counters[name] = (s.counters[name] || 0) + 1;
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
/** One sign-in attempt, appended. */
|
|
76
|
+
logSignin(entry) {
|
|
77
|
+
fs.appendFile(logFile, `${JSON.stringify(entry)}\n`, () => {});
|
|
78
|
+
},
|
|
79
|
+
/** Sign-in attempts newer than `sinceMs`, newest first. */
|
|
80
|
+
readSignins(sinceMs) {
|
|
81
|
+
let raw = '';
|
|
82
|
+
try { raw = fs.readFileSync(logFile, 'utf8'); } catch { return []; }
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const line of raw.split('\n')) {
|
|
85
|
+
if (!line) continue;
|
|
86
|
+
try {
|
|
87
|
+
const e = JSON.parse(line);
|
|
88
|
+
if (Date.parse(e.at) >= sinceMs) out.push(e);
|
|
89
|
+
} catch { /* a torn line */ }
|
|
90
|
+
}
|
|
91
|
+
return out.reverse();
|
|
92
|
+
},
|
|
93
|
+
/** Keep `days` of sign-ins. */
|
|
94
|
+
pruneSignins(days) {
|
|
95
|
+
const keep = this.readSignins(Date.now() - days * 86_400_000).reverse();
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(`${logFile}.tmp`, keep.map(e => JSON.stringify(e)).join('\n') + (keep.length ? '\n' : ''));
|
|
98
|
+
fs.renameSync(`${logFile}.tmp`, logFile);
|
|
99
|
+
} catch { /* next time */ }
|
|
100
|
+
},
|
|
101
|
+
close() {
|
|
102
|
+
clearInterval(watch);
|
|
103
|
+
if (timer) write();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Security - the pure parts: probes, lockout, blocks, password rules, settings, health. */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {isProbe} from '../admin/lib/probes.js';
|
|
5
|
+
import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from '../admin/lib/lockout.js';
|
|
6
|
+
import {blockedBy, isTrusted, pruneBlocks, recordProbe} from '../admin/lib/blocks.js';
|
|
7
|
+
import {ipMatches, ipRuleValid, isLoopbackIp} from '../admin/lib/ip.js';
|
|
8
|
+
import {passwordProblems} from '../admin/lib/policy.js';
|
|
9
|
+
import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../admin/lib/settings.js';
|
|
10
|
+
import {evaluate} from '../admin/lib/health.js';
|
|
11
|
+
|
|
12
|
+
const MIN = 60_000;
|
|
13
|
+
|
|
14
|
+
test('probes: what scanners ask for, not what a site serves', () => {
|
|
15
|
+
for (const p of ['/wp-login.php', '/wp-admin/install.php', '/.env', '/.git/config', '/xmlrpc.php', '/shop/index.php', '/a/../../etc/passwd', '/%2e%65nv'.replace('%2e%65', '.e'), '/phpmyadmin/', '/backup.sql']) {
|
|
16
|
+
assert.equal(isProbe(p), true, p);
|
|
17
|
+
}
|
|
18
|
+
for (const p of ['/', '/about', '/blog/my-post', '/media/photo.jpg', '/wordpress-tips', '/environment', '/admin']) {
|
|
19
|
+
assert.equal(isProbe(p), false, p);
|
|
20
|
+
}
|
|
21
|
+
assert.equal(isProbe('/secret-thing', {extra: ['/secret-thing']}), true);
|
|
22
|
+
assert.equal(isProbe('/legacy/index.php', {allow: ['/legacy/']}), false, 'a site that does serve it says so');
|
|
23
|
+
assert.equal(isProbe('/.ENV'), true, 'case does not matter');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('lockout: 5 wrong on an account locks it, each lock twice as long, a success clears it', () => {
|
|
27
|
+
const s = {};
|
|
28
|
+
const k = accountKey('Sam@X.com');
|
|
29
|
+
let now = 1_000_000;
|
|
30
|
+
for (let i = 0; i < 4; i++) assert.equal(recordFailure(s, k, {now: now + i}), 0);
|
|
31
|
+
const first = recordFailure(s, k, {now: now + 5});
|
|
32
|
+
assert.equal(first, now + 5 + MIN);
|
|
33
|
+
assert.equal(lockedUntil(s, k, now + 10), first);
|
|
34
|
+
now += 2 * MIN;
|
|
35
|
+
assert.equal(lockedUntil(s, k, now), 0, 'the lock ended');
|
|
36
|
+
for (let i = 0; i < 5; i++) recordFailure(s, k, {now: now + i});
|
|
37
|
+
assert.equal(lockedUntil(s, k, now + 5), now + 4 + 2 * MIN, 'the second lock is twice as long');
|
|
38
|
+
recordSuccess(s, k);
|
|
39
|
+
assert.equal(lockedUntil(s, k, now + 5), 0);
|
|
40
|
+
// An address takes 20 (offices share one), and failures outside the window do not add up.
|
|
41
|
+
const a = addressKey('81.2.69.142');
|
|
42
|
+
for (let i = 0; i < 19; i++) recordFailure(s, a, {now: now + i});
|
|
43
|
+
assert.equal(lockedUntil(s, a, now + 20), 0);
|
|
44
|
+
assert.ok(recordFailure(s, a, {now: now + 20}) > 0);
|
|
45
|
+
const w = accountKey('w@x');
|
|
46
|
+
for (let i = 0; i < 4; i++) recordFailure(s, w, {now: now + i});
|
|
47
|
+
assert.equal(recordFailure(s, w, {now: now + 16 * MIN}), 0, 'the old four were outside the window');
|
|
48
|
+
assert.equal(activeLocks(s, now + 30).length, 1);
|
|
49
|
+
assert.equal(retryText(now + 90 * MIN, now), 'in 2 hours');
|
|
50
|
+
assert.equal(retryText(now + 30_000, now), 'in 1 minute');
|
|
51
|
+
pruneLockouts(s, {now: now + 3 * 86_400_000});
|
|
52
|
+
assert.deepEqual(Object.keys(s), []);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('blocks: rules and temporary blocks; loopback never; recently signed-in addresses are trusted', () => {
|
|
56
|
+
const now = Date.parse('2026-09-26T12:00:00Z');
|
|
57
|
+
const state = {rules: [{rule: '10.0.0.0/8', note: 'office VPN'}], temp: {'81.2.69.142': {until: now + MIN, reason: 'Probing'}}, trusted: {'2.2.2.2': now - 86_400_000}};
|
|
58
|
+
assert.equal(blockedBy(state, '10.1.2.3', now).kind, 'rule');
|
|
59
|
+
assert.equal(blockedBy(state, '::ffff:81.2.69.142', now).kind, 'temp');
|
|
60
|
+
assert.equal(blockedBy(state, '81.2.69.142', now + 2 * MIN), null, 'ended');
|
|
61
|
+
assert.equal(blockedBy({rules: [{rule: '127.0.0.1'}]}, '127.0.0.1', now), null, 'loopback is never blocked');
|
|
62
|
+
assert.equal(isTrusted(state, '2.2.2.2', now), true);
|
|
63
|
+
assert.equal(isTrusted(state, '3.3.3.3', now), false);
|
|
64
|
+
const recent = new Map();
|
|
65
|
+
for (let i = 0; i < 4; i++) assert.equal(recordProbe(recent, '9.9.9.9', {now: now + i}), false);
|
|
66
|
+
assert.equal(recordProbe(recent, '9.9.9.9', {now: now + 5}), true);
|
|
67
|
+
pruneBlocks(state, now + 2 * MIN);
|
|
68
|
+
assert.deepEqual(state.temp, {});
|
|
69
|
+
assert.ok(ipRuleValid('192.168.1.*') && ipRuleValid('2a00:23c7:*') && !ipRuleValid('nope'));
|
|
70
|
+
assert.ok(ipMatches('192.168.1.7', ['192.168.1.*']));
|
|
71
|
+
assert.ok(isLoopbackIp('::1') && isLoopbackIp('::ffff:127.0.0.1') && !isLoopbackIp('10.0.0.1'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('password rules: length, common, personal, repetitive', () => {
|
|
75
|
+
const common = new Set(['password', 'qwerty123', 'letmein']);
|
|
76
|
+
const p = (pw, who = {}) => passwordProblems(pw, who, {minLength: 10, common});
|
|
77
|
+
assert.deepEqual(p('correct horse battery'), []);
|
|
78
|
+
assert.equal(p('short').length >= 1, true);
|
|
79
|
+
assert.match(p('Password').join(' '), /most common/);
|
|
80
|
+
assert.match(p('letmein2024!!').join(' '), /most common/, 'a common word with digits on the end');
|
|
81
|
+
assert.match(p('samsmith-rules-ok', {email: 'samsmith@x.com'}).join(' '), /name and email/);
|
|
82
|
+
assert.match(p('Joanna-Bloggs-99', {name: 'Joanna Bloggs'}).join(' '), /name and email/);
|
|
83
|
+
assert.match(p('aaaaaaaaaaaa').join(' '), /more than one or two/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('settings: checked, merged, bad values named', () => {
|
|
87
|
+
const {errors, settings} = checkSettings({lockout: {accountThreshold: '7', enabled: 'false'}, probes: {extra: '/old-app/\n/x.cgi'}, signinDays: 60});
|
|
88
|
+
assert.deepEqual(errors, {});
|
|
89
|
+
assert.equal(settings.lockout.accountThreshold, 7);
|
|
90
|
+
assert.equal(settings.lockout.enabled, false);
|
|
91
|
+
assert.deepEqual(settings.probes.extra, ['/old-app/', '/x.cgi']);
|
|
92
|
+
assert.equal(settings.policy.minLength, DEFAULT_SETTINGS.policy.minLength, 'untouched values keep what was stored');
|
|
93
|
+
const bad = checkSettings({lockout: {accountThreshold: 1, baseMin: 30, maxMin: 5}, probes: {allow: 'no-slash'}, signinDays: 2});
|
|
94
|
+
assert.deepEqual(Object.keys(bad.errors).sort(), ['lockout.accountThreshold', 'lockout.maxMin', 'probes.allow', 'signinDays']);
|
|
95
|
+
assert.equal(mergeDefaults({lockout: {enabled: false}}).lockout.accountThreshold, 5, 'an old, partial setting gets the new defaults');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('health: facts become checks; unknown is info, never a pass; the score counts only what was answered', () => {
|
|
99
|
+
const now = Date.parse('2026-09-26T12:00:00Z');
|
|
100
|
+
const good = evaluate({
|
|
101
|
+
admins: [{name: 'Sam', lastLogin: '2026-09-25T10:00:00Z'}],
|
|
102
|
+
users: [{name: 'Sam', lastLogin: '2026-09-25T10:00:00Z', createdAt: '2026-01-01'}],
|
|
103
|
+
engine: {installed: '0.83.0', latest: '0.83.0'}, nodeEnv: 'production', proxy: {via: true, trust: 'loopback'},
|
|
104
|
+
bind: '127.0.0.1', smtp: true, cert: {daysLeft: 60}, headers: {https: true, csp: true, nosniff: true, frame: true, referrer: true, hsts: true},
|
|
105
|
+
probeFiles: [{path: '/.env', status: 404}], licences: [{name: 'x', state: 'valid'}],
|
|
106
|
+
protections: {checks: true, lockout: true, policy: true}, policyMin: 10, twoFactor: false
|
|
107
|
+
}, {now});
|
|
108
|
+
assert.equal(good.score, 100);
|
|
109
|
+
assert.equal(good.failing, 0);
|
|
110
|
+
assert.equal(good.checks.find(c => c.id === 'two-factor').status, 'info');
|
|
111
|
+
|
|
112
|
+
const bad = evaluate({
|
|
113
|
+
admins: [{name: 'Old', lastLogin: '2025-01-01T00:00:00Z'}, {name: 'Never', lastLogin: null}],
|
|
114
|
+
users: [{name: 'Never', lastLogin: null, createdAt: '2026-01-01'}],
|
|
115
|
+
engine: {installed: '0.80.2', latest: '0.83.0'}, nodeEnv: 'development', proxy: {via: true, trust: false}, bind: '0.0.0.0',
|
|
116
|
+
smtp: false, cert: {daysLeft: 3}, headers: {https: false, csp: false, nosniff: true, frame: true, referrer: false},
|
|
117
|
+
probeFiles: [{path: '/.env', status: 200}], licences: [{name: 'invoice', state: 'expired'}],
|
|
118
|
+
protections: {checks: false, lockout: false, policy: false}, policyMin: 10
|
|
119
|
+
}, {now});
|
|
120
|
+
const st = Object.fromEntries(bad.checks.map(c => [c.id, c.status]));
|
|
121
|
+
assert.equal(st['admins-stale'], 'warn');
|
|
122
|
+
assert.equal(st.exposed, 'fail');
|
|
123
|
+
assert.equal(st.certificate, 'fail');
|
|
124
|
+
assert.equal(st.proxy, 'fail');
|
|
125
|
+
assert.equal(st.lockout, 'fail');
|
|
126
|
+
assert.equal(st.engine, 'warn');
|
|
127
|
+
assert.equal(st.headers, 'warn');
|
|
128
|
+
assert.ok(bad.critical >= 3);
|
|
129
|
+
assert.ok(bad.score <= 40);
|
|
130
|
+
const unknown = evaluate({engine: {installed: '0.83.0', latest: null}, headers: {error: 'timed out'}, cert: {daysLeft: null, error: 'timed out'}}, {now});
|
|
131
|
+
assert.equal(unknown.checks.find(c => c.id === 'engine').status, 'info');
|
|
132
|
+
assert.equal(unknown.checks.find(c => c.id === 'certificate').status, 'info');
|
|
133
|
+
assert.equal(unknown.checks.find(c => c.id === 'headers').status, 'info');
|
|
134
|
+
});
|