runspec-node 0.31.0 → 0.33.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/dist/become.d.ts +36 -0
- package/dist/become.d.ts.map +1 -0
- package/dist/become.js +103 -0
- package/dist/become.js.map +1 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +13 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/loader.js +4 -1
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +7 -7
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +11 -13
- package/dist/logging_setup.js.map +1 -1
- package/dist/models.d.ts +9 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +2 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +26 -24
- package/dist/parser.js.map +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +5 -15
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/src/become.ts +101 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +1 -1
- package/src/loader.ts +4 -1
- package/src/logging_setup.ts +11 -15
- package/src/models.ts +9 -0
- package/src/parser.ts +29 -26
- package/src/testing.ts +5 -11
- package/tests/test_run_as_enforce.test.ts +170 -0
- package/tests/test_run_summary.test.ts +2 -24
package/src/testing.ts
CHANGED
|
@@ -314,18 +314,12 @@ export class ParseHarness {
|
|
|
314
314
|
return argv;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// `suppressSummary` is retained for back-compat but is now a no-op: the
|
|
318
|
+
// run-summary terminal echo is opt-in (`summary_console`, default off), so a
|
|
319
|
+
// parse never leaks the closing line into test output, and the audit record
|
|
320
|
+
// is written to the log file only — never the console.
|
|
317
321
|
private withSummaryPinned<T>(fn: () => T): T {
|
|
318
|
-
|
|
319
|
-
const prefix = this.scriptName.toUpperCase().replace(/-/g, '_');
|
|
320
|
-
const key = `RUNSPEC_${prefix}_ARG_NO_SUMMARY`;
|
|
321
|
-
const prev = process.env[key];
|
|
322
|
-
process.env[key] = '1';
|
|
323
|
-
try {
|
|
324
|
-
return fn();
|
|
325
|
-
} finally {
|
|
326
|
-
if (prev === undefined) delete process.env[key];
|
|
327
|
-
else process.env[key] = prev;
|
|
328
|
-
}
|
|
322
|
+
return fn();
|
|
329
323
|
}
|
|
330
324
|
}
|
|
331
325
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { parse, loadSpec } from '../src/parser';
|
|
5
|
+
import { resolveRunAs, verifyRunAsIdentity } from '../src/become';
|
|
6
|
+
|
|
7
|
+
function makeTmpConfig(toml: string): string {
|
|
8
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-run-as-test-'));
|
|
9
|
+
const configPath = path.join(dir, 'runspec.toml');
|
|
10
|
+
fs.writeFileSync(configPath, toml);
|
|
11
|
+
return configPath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// POSIX-only: the uid comparison needs process.geteuid + `id`.
|
|
15
|
+
const posixIt = process.geteuid ? test : test.skip;
|
|
16
|
+
|
|
17
|
+
// ── resolveRunAs ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe('resolveRunAs', () => {
|
|
20
|
+
test('null/undefined/empty → no escalation', () => {
|
|
21
|
+
expect(resolveRunAs(undefined, 'host1')).toBe('');
|
|
22
|
+
expect(resolveRunAs(null, 'host1')).toBe('');
|
|
23
|
+
expect(resolveRunAs('', 'host1')).toBe('');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('simple string', () => {
|
|
27
|
+
expect(resolveRunAs('oracle', 'anything')).toBe('oracle');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('$ENV reference', () => {
|
|
31
|
+
process.env['ORACLE_RUN_AS'] = 'orasvc';
|
|
32
|
+
expect(resolveRunAs('$ORACLE_RUN_AS', 'h')).toBe('orasvc');
|
|
33
|
+
delete process.env['ORACLE_RUN_AS'];
|
|
34
|
+
expect(resolveRunAs('$ORACLE_RUN_AS', 'h')).toBe('');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('per-host exact match, then default', () => {
|
|
38
|
+
const spec = { default: 'oracle', hosts: { 'box-01': 'dba', 'box-02': '' } };
|
|
39
|
+
expect(resolveRunAs(spec, 'box-01')).toBe('dba');
|
|
40
|
+
expect(resolveRunAs(spec, 'box-02')).toBe(''); // explicit no-escalation
|
|
41
|
+
expect(resolveRunAs(spec, 'other')).toBe('oracle');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('patterns, full-match, first wins', () => {
|
|
45
|
+
const spec = { default: 'oracle', patterns: { '[lg]pexp[0-9]*': 'orasvc', 'prod[0-9]*': 'produser' } };
|
|
46
|
+
expect(resolveRunAs(spec, 'lpexp01')).toBe('orasvc');
|
|
47
|
+
expect(resolveRunAs(spec, 'prod7')).toBe('produser');
|
|
48
|
+
expect(resolveRunAs(spec, 'lpexp')).toBe('orasvc');
|
|
49
|
+
expect(resolveRunAs(spec, 'xlpexp01')).toBe('oracle'); // not a full match
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── verifyRunAsIdentity ───────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe('verifyRunAsIdentity', () => {
|
|
56
|
+
test('empty target is a no-op', () => {
|
|
57
|
+
expect(verifyRunAsIdentity('')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
posixIt('current uid (by number) matches', () => {
|
|
61
|
+
expect(verifyRunAsIdentity(String(process.geteuid!()))).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
posixIt('nonexistent user is reported', () => {
|
|
65
|
+
const detail = verifyRunAsIdentity('nope-user-xyz');
|
|
66
|
+
expect(detail).toContain('does not exist');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
posixIt('uid mismatch is reported', () => {
|
|
70
|
+
const realUid = process.geteuid!();
|
|
71
|
+
const spy = jest.spyOn(process, 'geteuid').mockReturnValue(999999);
|
|
72
|
+
try {
|
|
73
|
+
const detail = verifyRunAsIdentity(String(realUid));
|
|
74
|
+
expect(detail).toContain('999999');
|
|
75
|
+
expect(detail).toContain(`uid ${realUid}`);
|
|
76
|
+
} finally {
|
|
77
|
+
spy.mockRestore();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('no POSIX uid model (Windows) is a no-op', () => {
|
|
82
|
+
const orig = process.geteuid;
|
|
83
|
+
(process as { geteuid?: () => number }).geteuid = undefined;
|
|
84
|
+
try {
|
|
85
|
+
expect(verifyRunAsIdentity('root')).toBeNull();
|
|
86
|
+
} finally {
|
|
87
|
+
(process as { geteuid?: () => number }).geteuid = orig;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── enforce_run_as wired into the parser ──────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function body(opts: { enforce?: string; configEnforce?: string } = {}): string {
|
|
95
|
+
return [
|
|
96
|
+
'[config]',
|
|
97
|
+
opts.configEnforce ?? '',
|
|
98
|
+
'',
|
|
99
|
+
'[tool]',
|
|
100
|
+
'description = "x"',
|
|
101
|
+
'run_as = "nope-user-xyz"',
|
|
102
|
+
opts.enforce ?? '',
|
|
103
|
+
'',
|
|
104
|
+
].join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function doParse(opts: { enforce?: string; configEnforce?: string } = {}) {
|
|
108
|
+
return parse({ scriptName: 'tool', argv: [], configPath: makeTmpConfig(body(opts)) });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('enforce_run_as gate', () => {
|
|
112
|
+
posixIt('error is the default', () => {
|
|
113
|
+
expect(() => doParse()).toThrow(/Refusing to run 'tool'/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
posixIt('explicit error throws', () => {
|
|
117
|
+
expect(() => doParse({ enforce: 'enforce_run_as = "error"' })).toThrow(/Refusing to run/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
posixIt('warn continues and writes stderr', () => {
|
|
121
|
+
const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
122
|
+
try {
|
|
123
|
+
const args = doParse({ enforce: 'enforce_run_as = "warn"' });
|
|
124
|
+
expect(args.__runspec_script__).toBe('tool');
|
|
125
|
+
const written = spy.mock.calls.map((c) => String(c[0])).join('');
|
|
126
|
+
expect(written).toContain('⚠');
|
|
127
|
+
expect(written).toContain('nope-user-xyz');
|
|
128
|
+
} finally {
|
|
129
|
+
spy.mockRestore();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
posixIt('off is silent', () => {
|
|
134
|
+
const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
135
|
+
try {
|
|
136
|
+
const args = doParse({ enforce: 'enforce_run_as = "off"' });
|
|
137
|
+
expect(args.__runspec_script__).toBe('tool');
|
|
138
|
+
expect(spy.mock.calls.length).toBe(0);
|
|
139
|
+
} finally {
|
|
140
|
+
spy.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
posixIt('config default applies when runnable omits enforce_run_as', () => {
|
|
145
|
+
const spy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
146
|
+
try {
|
|
147
|
+
doParse({ configEnforce: 'enforce_run_as = "warn"' });
|
|
148
|
+
const written = spy.mock.calls.map((c) => String(c[0])).join('');
|
|
149
|
+
expect(written).toContain('⚠');
|
|
150
|
+
} finally {
|
|
151
|
+
spy.mockRestore();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
posixIt('runnable value overrides a permissive config default', () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
doParse({ enforce: 'enforce_run_as = "error"', configEnforce: 'enforce_run_as = "off"' }),
|
|
158
|
+
).toThrow(/Refusing to run/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('no run_as means no check', () => {
|
|
162
|
+
const args = parse({ scriptName: 'tool', argv: [], configPath: makeTmpConfig('[tool]\ndescription = "x"\n') });
|
|
163
|
+
expect(args.__runspec_script__).toBe('tool');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
posixIt('loadSpec skips the check', () => {
|
|
167
|
+
const args = loadSpec({ scriptName: 'tool', configPath: makeTmpConfig(body({ enforce: 'enforce_run_as = "error"' })) });
|
|
168
|
+
expect(args.__runspec_script__).toBe('tool');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -20,15 +20,14 @@ function tmpDir(): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
|
|
23
|
-
const { debug,
|
|
24
|
-
debug?: boolean;
|
|
23
|
+
const { debug, autonomy, agent, commandPath, ...logOverrides } = overrides as {
|
|
24
|
+
debug?: boolean; autonomy?: string; agent?: boolean; commandPath?: string[];
|
|
25
25
|
} & Record<string, unknown>;
|
|
26
26
|
return {
|
|
27
27
|
logCfg: { rotate: 'midnight', keep: 7, summary: true, ...logOverrides },
|
|
28
28
|
runnableName: 'myscript',
|
|
29
29
|
configPath: path.join(dir, 'runspec.toml'),
|
|
30
30
|
debug,
|
|
31
|
-
noSummary,
|
|
32
31
|
autonomy,
|
|
33
32
|
agent,
|
|
34
33
|
commandPath,
|
|
@@ -46,7 +45,6 @@ function captureStderr() {
|
|
|
46
45
|
|
|
47
46
|
beforeEach(() => {
|
|
48
47
|
_resetForTest();
|
|
49
|
-
delete process.env['RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY'];
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
afterAll(() => {
|
|
@@ -168,16 +166,6 @@ test('summary emit is idempotent', () => {
|
|
|
168
166
|
|
|
169
167
|
// ── disable switches ─────────────────────────────────────────────────────────
|
|
170
168
|
|
|
171
|
-
test('noSummary option disables summary', () => {
|
|
172
|
-
const dir = tmpDir();
|
|
173
|
-
configureLogging(makeCfg(dir, { noSummary: true }));
|
|
174
|
-
const cap = captureStderr();
|
|
175
|
-
emitRunSummary(); // no-op because state is null
|
|
176
|
-
cap.restore();
|
|
177
|
-
expect(cap.lines.join('')).not.toContain('runspec: ');
|
|
178
|
-
expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(false);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
169
|
test('summary=false in config disables the audit record', () => {
|
|
182
170
|
const dir = tmpDir();
|
|
183
171
|
configureLogging(makeCfg(dir, { summary: false }));
|
|
@@ -210,16 +198,6 @@ test('summary_console works independently of summary (line on, record off)', ()
|
|
|
210
198
|
expect(records.find(o => o.logger === RUN_SUMMARY_LOGGER)).toBeUndefined();
|
|
211
199
|
});
|
|
212
200
|
|
|
213
|
-
test('RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY=1 disables summary', () => {
|
|
214
|
-
process.env['RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY'] = '1';
|
|
215
|
-
const dir = tmpDir();
|
|
216
|
-
configureLogging(makeCfg(dir));
|
|
217
|
-
const cap = captureStderr();
|
|
218
|
-
emitRunSummary();
|
|
219
|
-
cap.restore();
|
|
220
|
-
expect(cap.lines.join('')).not.toContain('runspec: ');
|
|
221
|
-
});
|
|
222
|
-
|
|
223
201
|
// ── stderr line shape ────────────────────────────────────────────────────────
|
|
224
202
|
|
|
225
203
|
test('success line uses abbreviated "warn"/"err" (no "warning"/"error")', () => {
|