dsh-server-connector 0.1.0 → 0.2.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/CHANGELOG.md +46 -0
- package/README.md +573 -456
- package/lib/audit.js +14 -3
- package/lib/client.js +1716 -1376
- package/lib/policy.js +167 -6
- package/lib/registry.js +59 -0
- package/lib/routes.js +1527 -1272
- package/lib/tools.js +42 -10
- package/package.json +3 -2
package/lib/policy.js
CHANGED
|
@@ -278,6 +278,162 @@ export function compareDecisions(a, b) {
|
|
|
278
278
|
return RANK[a] - RANK[b]
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ------------------------------------------------- custom rules (doc §4.4) --
|
|
282
|
+
|
|
283
|
+
/** Longest accepted `pattern` source. Past this the rule is almost certainly a paste accident. */
|
|
284
|
+
export const MAX_CUSTOM_PATTERN_LENGTH = 512
|
|
285
|
+
|
|
286
|
+
/** Most custom rules one server may carry. Bounds both the UI and the regex sweep. */
|
|
287
|
+
export const MAX_CUSTOM_RULES = 50
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Shapes that invite catastrophic backtracking: a quantified group whose body is
|
|
291
|
+
* itself quantified (`(a+)+`, `(a*)*`, `(.*)+`). Conservative on purpose — it
|
|
292
|
+
* rejects a few harmless forms rather than admitting a ReDoS.
|
|
293
|
+
*/
|
|
294
|
+
const NESTED_QUANTIFIER = /\(\s*[^()]*[+*]\s*\)\s*[+*]/
|
|
295
|
+
|
|
296
|
+
/** One custom rule as stored on a server record (doc §3.1). */
|
|
297
|
+
/**
|
|
298
|
+
* @typedef {object} CustomRule
|
|
299
|
+
* @property {string} id
|
|
300
|
+
* @property {string} label
|
|
301
|
+
* @property {string} pattern - regex source, compiled on read
|
|
302
|
+
* @property {Decision} defaultDecision - `confirm` or `block`; never `allow`
|
|
303
|
+
* @property {string} description
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A custom rule that failed validation. The route layer turns this into a 400.
|
|
308
|
+
*/
|
|
309
|
+
export class RuleError extends Error {
|
|
310
|
+
/**
|
|
311
|
+
* @param {string} code - machine-readable reason
|
|
312
|
+
* @param {string} message - human-readable, shown in the panel
|
|
313
|
+
*/
|
|
314
|
+
constructor(code, message) {
|
|
315
|
+
super(message)
|
|
316
|
+
this.name = 'RuleError'
|
|
317
|
+
this.code = code
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Validate and compile one custom rule (doc §4.4.2). Every check runs before the
|
|
323
|
+
* rule is allowed to touch `servers.json`, so a rule that cannot be honoured is
|
|
324
|
+
* never stored.
|
|
325
|
+
*
|
|
326
|
+
* `samples` is optional: when given, at least one must match, which is the
|
|
327
|
+
* check that catches "I wrote a regex that never fires".
|
|
328
|
+
* @param {unknown} value - the rule as it arrived (already id-assigned)
|
|
329
|
+
* @param {{samples?: string[], takenIds?: Iterable<string>}} [options]
|
|
330
|
+
* @returns {CustomRule}
|
|
331
|
+
*/
|
|
332
|
+
export function validateCustomRule(value, options = {}) {
|
|
333
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
334
|
+
throw new RuleError('INVALID_RULE', 'a custom rule must be an object.')
|
|
335
|
+
}
|
|
336
|
+
const rule = /** @type {Record<string, unknown>} */ (value)
|
|
337
|
+
const label = typeof rule.label === 'string' ? rule.label.trim() : ''
|
|
338
|
+
if (label === '') throw new RuleError('INVALID_RULE', 'label must be a non-empty string.')
|
|
339
|
+
if (label.length > 80) throw new RuleError('INVALID_RULE', 'label must be at most 80 characters.')
|
|
340
|
+
const description = typeof rule.description === 'string' ? rule.description.trim() : ''
|
|
341
|
+
if (description.length > 400) throw new RuleError('INVALID_RULE', 'description must be at most 400 characters.')
|
|
342
|
+
if (rule.defaultDecision !== 'confirm' && rule.defaultDecision !== 'block') {
|
|
343
|
+
// `allow` is refused outright: a rule that permits what the engine already
|
|
344
|
+
// permits buys nothing, and it is the only way to fake a loosening.
|
|
345
|
+
throw new RuleError('INVALID_RULE', 'defaultDecision must be "confirm" or "block".')
|
|
346
|
+
}
|
|
347
|
+
if (typeof rule.pattern !== 'string' || rule.pattern.trim() === '') {
|
|
348
|
+
throw new RuleError('INVALID_RULE', 'pattern must be a non-empty regex source.')
|
|
349
|
+
}
|
|
350
|
+
const pattern = rule.pattern.trim()
|
|
351
|
+
if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
352
|
+
throw new RuleError('INVALID_RULE', `pattern must be at most ${MAX_CUSTOM_PATTERN_LENGTH} characters.`)
|
|
353
|
+
}
|
|
354
|
+
if (NESTED_QUANTIFIER.test(pattern)) {
|
|
355
|
+
throw new RuleError(
|
|
356
|
+
'INVALID_RULE',
|
|
357
|
+
'pattern contains a nested quantifier like (a+)+, which can backtrack catastrophically. Rewrite it without the inner repetition.',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
/** @type {RegExp} */
|
|
361
|
+
let compiled
|
|
362
|
+
try {
|
|
363
|
+
compiled = new RegExp(pattern, 'i')
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw new RuleError('INVALID_RULE', `pattern is not a valid regular expression: ${/** @type {Error} */ (error).message}`)
|
|
366
|
+
}
|
|
367
|
+
// A pattern that matches the empty string matches every command, which would
|
|
368
|
+
// block or confirm everything on that server.
|
|
369
|
+
if (compiled.test('')) {
|
|
370
|
+
throw new RuleError('INVALID_RULE', 'pattern matches the empty string, so it would match every command.')
|
|
371
|
+
}
|
|
372
|
+
const id = typeof rule.id === 'string' ? rule.id.trim() : ''
|
|
373
|
+
if (id === '') throw new RuleError('INVALID_RULE', 'id must be a non-empty string.')
|
|
374
|
+
const taken = new Set(options.takenIds ?? [])
|
|
375
|
+
if (RULES_BY_ID.has(id) || taken.has(id)) {
|
|
376
|
+
throw new RuleError('DUPLICATE_RULE', `rule id "${id}" is already in use on this server.`)
|
|
377
|
+
}
|
|
378
|
+
const samples = Array.isArray(options.samples) ? options.samples.filter((s) => typeof s === 'string' && s.trim() !== '') : []
|
|
379
|
+
if (samples.length > 0 && !samples.some((sample) => compiled.test(sample))) {
|
|
380
|
+
throw new RuleError(
|
|
381
|
+
'RULE_NEVER_MATCHES',
|
|
382
|
+
'none of the sample commands matched this pattern; it would never fire.',
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
return Object.freeze({
|
|
386
|
+
id,
|
|
387
|
+
label,
|
|
388
|
+
pattern,
|
|
389
|
+
defaultDecision: /** @type {Decision} */ (rule.defaultDecision),
|
|
390
|
+
description,
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Compile a stored custom rule for matching. A record that fails to compile is
|
|
396
|
+
* skipped rather than thrown: `servers.json` may predate a stricter validator,
|
|
397
|
+
* and one bad rule must not take down every command on that server.
|
|
398
|
+
* @param {CustomRule} rule
|
|
399
|
+
* @returns {Rule|null}
|
|
400
|
+
*/
|
|
401
|
+
function compileCustomRule(rule) {
|
|
402
|
+
/** @type {RegExp} */
|
|
403
|
+
let pattern
|
|
404
|
+
try {
|
|
405
|
+
pattern = new RegExp(rule.pattern, 'i')
|
|
406
|
+
} catch {
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
return Object.freeze({
|
|
410
|
+
id: rule.id,
|
|
411
|
+
label: rule.label,
|
|
412
|
+
defaultDecision: rule.defaultDecision,
|
|
413
|
+
description: rule.description,
|
|
414
|
+
pattern,
|
|
415
|
+
custom: true,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The rule set that applies to one server: the built-in catalog, then that
|
|
421
|
+
* server's custom rules (doc §4.2.5). Custom rules enjoy no privilege — they run
|
|
422
|
+
* through the same match and strictest-wins logic.
|
|
423
|
+
* @param {ReadonlyArray<CustomRule>|undefined} customRules
|
|
424
|
+
* @returns {ReadonlyArray<Rule>}
|
|
425
|
+
*/
|
|
426
|
+
export function rulesFor(customRules) {
|
|
427
|
+
if (!Array.isArray(customRules) || customRules.length === 0) return RULES
|
|
428
|
+
const extra = []
|
|
429
|
+
for (const rule of customRules) {
|
|
430
|
+
if (rule === null || typeof rule !== 'object') continue
|
|
431
|
+
const compiled = compileCustomRule(/** @type {CustomRule} */ (rule))
|
|
432
|
+
if (compiled !== null) extra.push(compiled)
|
|
433
|
+
}
|
|
434
|
+
return extra.length === 0 ? RULES : [...RULES, ...extra]
|
|
435
|
+
}
|
|
436
|
+
|
|
281
437
|
/**
|
|
282
438
|
* Split a command line into the subcommands every rule must be checked against.
|
|
283
439
|
*
|
|
@@ -300,24 +456,27 @@ export function splitSubcommands(command) {
|
|
|
300
456
|
/**
|
|
301
457
|
* Separate the two accepted second-argument shapes into overrides plus disabled
|
|
302
458
|
* rule ids:
|
|
303
|
-
* - a server record (`{ policy, disabledRules }`, doc §3.1), or
|
|
459
|
+
* - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
|
|
304
460
|
* - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
|
|
305
461
|
*
|
|
306
462
|
* A third argument may carry `disabledRules` alongside a bare override map.
|
|
307
463
|
* @param {Record<string, unknown>|undefined} serverPolicy
|
|
308
464
|
* @param {string[]|undefined} disabledRules
|
|
309
|
-
* @returns {{overrides: Record<string, unknown>, disabled: Set<string
|
|
465
|
+
* @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
|
|
310
466
|
*/
|
|
311
467
|
function normalizeServerPolicy(serverPolicy, disabledRules) {
|
|
312
468
|
const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
|
|
313
469
|
if (serverPolicy === undefined || serverPolicy === null) {
|
|
314
|
-
return { overrides: {}, disabled }
|
|
470
|
+
return { overrides: {}, disabled, customRules: [] }
|
|
315
471
|
}
|
|
316
472
|
if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
|
|
317
473
|
if (Array.isArray(serverPolicy.disabledRules)) {
|
|
318
474
|
for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
|
|
319
475
|
}
|
|
320
476
|
const fields = /** @type {Record<string, unknown>} */ (serverPolicy)
|
|
477
|
+
const customRules = Array.isArray(fields.customRules)
|
|
478
|
+
? /** @type {CustomRule[]} */ (fields.customRules.filter((rule) => rule !== null && typeof rule === 'object'))
|
|
479
|
+
: []
|
|
321
480
|
const record = 'policy' in fields || 'disabledRules' in fields
|
|
322
481
|
const policy = fields.policy
|
|
323
482
|
/** @type {Record<string, unknown>} */
|
|
@@ -326,7 +485,7 @@ function normalizeServerPolicy(serverPolicy, disabledRules) {
|
|
|
326
485
|
? /** @type {Record<string, unknown>} */ (policy)
|
|
327
486
|
: {}
|
|
328
487
|
: fields
|
|
329
|
-
return { overrides, disabled }
|
|
488
|
+
return { overrides, disabled, customRules }
|
|
330
489
|
}
|
|
331
490
|
|
|
332
491
|
/**
|
|
@@ -390,10 +549,12 @@ function ruleMatches(rule, command, segments) {
|
|
|
390
549
|
*/
|
|
391
550
|
export function check(command, serverPolicy = {}, disabledRules) {
|
|
392
551
|
const segments = splitSubcommands(command)
|
|
393
|
-
const { overrides, disabled } = normalizeServerPolicy(serverPolicy, disabledRules)
|
|
552
|
+
const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
|
|
394
553
|
|
|
395
554
|
const matches = []
|
|
396
|
-
|
|
555
|
+
// Built-ins first, then this server's custom rules (doc §4.2.5): the custom
|
|
556
|
+
// set is part of the same sweep, so strictest-wins needs no special case.
|
|
557
|
+
for (const rule of rulesFor(customRules)) {
|
|
397
558
|
if (disabled.has(rule.id)) continue
|
|
398
559
|
if (!ruleMatches(rule, command, segments)) continue
|
|
399
560
|
matches.push({
|
package/lib/registry.js
CHANGED
|
@@ -21,6 +21,8 @@ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
import { dirname, join, resolve } from 'node:path'
|
|
23
23
|
|
|
24
|
+
import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
|
|
25
|
+
|
|
24
26
|
/** Schema version written to, and required from, `servers.json`. */
|
|
25
27
|
export const REGISTRY_VERSION = 1
|
|
26
28
|
|
|
@@ -57,8 +59,10 @@ export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
|
|
|
57
59
|
* @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
|
|
58
60
|
* @property {'accept-new'|'verify'|'off'} hostKeyMode
|
|
59
61
|
* @property {string|null} defaultWorkDir
|
|
62
|
+
* @property {boolean} enabled
|
|
60
63
|
* @property {Record<string, 'allow'|'confirm'|'block'>} policy
|
|
61
64
|
* @property {string[]} disabledRules
|
|
65
|
+
* @property {import('./policy.js').CustomRule[]} customRules
|
|
62
66
|
* @property {string} createdAt
|
|
63
67
|
* @property {string|null} lastConnectedAt
|
|
64
68
|
*/
|
|
@@ -133,9 +137,11 @@ const CREATE_FIELDS = new Set([
|
|
|
133
137
|
'username',
|
|
134
138
|
'auth',
|
|
135
139
|
'hostKeyMode',
|
|
140
|
+
'enabled',
|
|
136
141
|
'defaultWorkDir',
|
|
137
142
|
'policy',
|
|
138
143
|
'disabledRules',
|
|
144
|
+
'customRules',
|
|
139
145
|
])
|
|
140
146
|
|
|
141
147
|
/** Fields an update() call accepts; `id` and `createdAt` are immutable. */
|
|
@@ -286,6 +292,22 @@ function normalizeWorkDir(value, errorCode) {
|
|
|
286
292
|
return trimmed === '' ? null : trimmed
|
|
287
293
|
}
|
|
288
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The enable switch (doc §3.1, enable-switch spec).
|
|
297
|
+
*
|
|
298
|
+
* Absent means enabled: files written before the switch existed carry no
|
|
299
|
+
* `enabled` key, and silently disabling every server on load would be a
|
|
300
|
+
* destructive surprise.
|
|
301
|
+
* @param {unknown} value
|
|
302
|
+
* @param {RegistryErrorCode} errorCode
|
|
303
|
+
* @returns {boolean}
|
|
304
|
+
*/
|
|
305
|
+
function normalizeEnabled(value, errorCode) {
|
|
306
|
+
if (value === undefined || value === null) return true
|
|
307
|
+
if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
|
|
308
|
+
return value
|
|
309
|
+
}
|
|
310
|
+
|
|
289
311
|
/**
|
|
290
312
|
* @param {unknown} value
|
|
291
313
|
* @param {string} field
|
|
@@ -387,6 +409,41 @@ function normalizeDisabledRules(value, errorCode) {
|
|
|
387
409
|
return ids
|
|
388
410
|
}
|
|
389
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Validate the custom-rule list of one record (doc §4.4.2).
|
|
414
|
+
*
|
|
415
|
+
* Ids are unique per server and must not collide with a built-in id. The
|
|
416
|
+
* decision may only be `confirm` or `block` — a rule that permits what the
|
|
417
|
+
* engine already permits buys nothing.
|
|
418
|
+
* @param {unknown} value
|
|
419
|
+
* @param {RegistryErrorCode} errorCode
|
|
420
|
+
* @returns {import('./policy.js').CustomRule[]}
|
|
421
|
+
*/
|
|
422
|
+
function normalizeCustomRules(value, errorCode) {
|
|
423
|
+
if (value === undefined) return []
|
|
424
|
+
if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
|
|
425
|
+
if (value.length > MAX_CUSTOM_RULES) {
|
|
426
|
+
throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
|
|
427
|
+
}
|
|
428
|
+
/** @type {import('./policy.js').CustomRule[]} */
|
|
429
|
+
const rules = []
|
|
430
|
+
/** @type {string[]} */
|
|
431
|
+
const seen = []
|
|
432
|
+
for (const item of value) {
|
|
433
|
+
/** @type {import('./policy.js').CustomRule} */
|
|
434
|
+
let rule
|
|
435
|
+
try {
|
|
436
|
+
rule = validateCustomRule(item, { takenIds: seen })
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
|
|
439
|
+
throw error
|
|
440
|
+
}
|
|
441
|
+
seen.push(rule.id)
|
|
442
|
+
rules.push(rule)
|
|
443
|
+
}
|
|
444
|
+
return rules
|
|
445
|
+
}
|
|
446
|
+
|
|
390
447
|
/**
|
|
391
448
|
* Validate and canonicalise one complete record. Create, update and load all
|
|
392
449
|
* go through this function, so a stored record and a freshly created one have
|
|
@@ -418,8 +475,10 @@ function normalizeRecord(source, options) {
|
|
|
418
475
|
auth: normalizeAuth(source.auth, id, errorCode),
|
|
419
476
|
hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
|
|
420
477
|
defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
|
|
478
|
+
enabled: normalizeEnabled(source.enabled, errorCode),
|
|
421
479
|
policy: normalizePolicy(source.policy, errorCode),
|
|
422
480
|
disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
|
|
481
|
+
customRules: normalizeCustomRules(source.customRules, errorCode),
|
|
423
482
|
createdAt,
|
|
424
483
|
lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
|
|
425
484
|
}
|