@jacebenson/jsn 4.3.13-dev.100.1 → 4.3.13-dev.101.1

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/README.md CHANGED
@@ -32,6 +32,46 @@ under npm's `dev` tag. Versions include the workflow run number and attempt
32
32
  The npm trusted publisher for `publish-dev.yml` must authorize the repository's
33
33
  `dev` GitHub environment, alongside the production publishing configuration.
34
34
 
35
+ ### Instance Scan
36
+
37
+ `jsn scan` manages checks and suites and runs scans against an explicit target.
38
+ Use `jsn scan --help` and each subcommand's `--help` to discover the options.
39
+
40
+ ```bash
41
+ jsn scan checks list --profile dev
42
+ jsn scan checks create --type table --data-file check.json --profile dev
43
+ jsn scan checks activate <check-id> --profile dev
44
+ jsn scan suites add-check <suite-id> --check <check-id> --profile dev
45
+ jsn scan run --update-set "My change" --suite "My suite" --wait --profile dev
46
+ jsn scan run --table sys_script_include --record <record-id> --profile dev
47
+ jsn scan results <execution-id> --profile dev
48
+ ```
49
+
50
+ Check types are `table`, `column`, `linter`, and `script`. Definitions are created
51
+ inactive unless the payload explicitly sets `active: true`; activation never
52
+ starts a scan. Both checks and suites support list/show/create/update/delete and
53
+ activate/deactivate. Suites also support add-check/remove-check.
54
+
55
+ Runs require an update set, scope, record plus table, or explicit `--full`.
56
+ Instance-wide scans may traverse many tables even when a check's script only
57
+ examines a few records. Starting and cancelling a scan require confirmation;
58
+ use `--force` only after approving that operation. `--wait --timeout 120` bounds
59
+ the wait, not the scan's lifetime. A timed-out wait keeps the execution id and
60
+ does not cancel or resubmit the scan. Use `status`, `results`, or `cancel` next.
61
+
62
+ Update-set scans examine current instance records, not the historical XML
63
+ versions. Inactive records may be skipped. Script-only checks do not participate
64
+ in record/update-set scans; use an explicitly instance-wide suite run to execute
65
+ them. Execution success does not mean zero findings or complete test coverage:
66
+ results report recorded check executions, finding counts, unreadable sources,
67
+ and truncated lists separately. A result's rerun hint preserves the previous
68
+ target combination and requires `--full` again for instance-wide scans.
69
+
70
+ Every command returns next-step breadcrumbs in human and JSON output, retaining
71
+ an explicitly selected profile or instance. `updatesets show` also offers a scan
72
+ hint. Recommendation/generation of new checks is not part of this command;
73
+ JSN contains no AI and does not infer missing business requirements.
74
+
35
75
  ### Supported versions
36
76
 
37
77
  Only the current release and the two preceding releases in each channel are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.100.1",
3
+ "version": "4.3.13-dev.101.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@ For development:
24
24
  1. Choose or create the application scope and update set.
25
25
  2. Do the work, keeping related changes together.
26
26
  3. Verify the results on the instance and report where changes were captured.
27
- 4. Offer to create ATFs and instance scans to verify the outcomes.
27
+ 4. Offer to run relevant existing ATFs and instance scans, or create missing checks.
28
28
  5. Ask whether anything else belongs in the update set. If the user confirms
29
29
  it is finished, complete it with notes.
30
30
 
package/src/cli.js CHANGED
@@ -72,6 +72,7 @@ import { logsCmd } from './commands/logs.js';
72
72
  import { scrapiCmd } from './commands/scrapi.js';
73
73
  import { b4rulesCmd } from './commands/b4rules.js';
74
74
  import { atfCmd } from './commands/atf.js';
75
+ import { scanCmd } from './commands/scan.js';
75
76
  import { approvalsCmd } from './commands/approvals.js';
76
77
  import { historyCmd } from './commands/history.js';
77
78
  import { perfCmd } from './commands/perf.js';
@@ -421,6 +422,7 @@ export function buildCLI() {
421
422
  .command(codesearchCmd(wrap))
422
423
  // DEVELOPER
423
424
  .command(atfCmd(wrap))
425
+ .command(scanCmd(wrap))
424
426
  .command(approvalsCmd(wrap))
425
427
  .command(skippedUpdatesCmd(wrap))
426
428
  .command(perfCmd(wrap))
@@ -0,0 +1,234 @@
1
+ import { buildRecordPayload, confirmDelete, getStringField, quoteShellArg, verifyWriteBack } from '../helpers.js';
2
+ import { createRecord, updateRecord, deleteRecord } from '../records-engine.js';
3
+ import { unwrapSysId } from '../resolve-record.js';
4
+ import { getCurrentApplication, getCurrentUser } from '../context.js';
5
+ import { declareCapabilities } from '../capabilities.js';
6
+ import { errAPI, errUsage } from '../errors.js';
7
+ import { CHECK_TABLES, active, raw, referenceId, boundedLimit, scanList, resolveScanRecord,
8
+ validateScanPayload, checkKind, prepareScan, startScan, scanStatus, scanResults, cancelScan } from '../scan.js';
9
+
10
+ declareCapabilities('scan', { mutationSubcommands: [
11
+ ...['checks', 'suites'].flatMap(group => ['create', 'update', 'delete', 'activate', 'deactivate'].map(verb => `${group} ${verb}`)),
12
+ 'suites add-check', 'suites remove-check', 'run', 'cancel',
13
+ ] });
14
+
15
+ const hint = (action, cmd, description) => ({ action, cmd, description });
16
+ function report(app, argv, data, options) {
17
+ const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}`
18
+ : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
19
+ app.ok(data, { ...options, breadcrumbs: options.breadcrumbs.map(item => ({ ...item, cmd: item.cmd + connection })) });
20
+ }
21
+ const rootHints = () => [hint('checks', 'jsn scan checks list', 'Browse available checks'),
22
+ hint('suites', 'jsn scan suites list', 'Browse suites'), hint('run', 'jsn scan run --help', 'Choose an explicit scan target')];
23
+ const itemHints = (group, record) => {
24
+ const id = unwrapSysId(record);
25
+ return [hint('list', `jsn scan ${group} list`, `Browse ${group}`),
26
+ hint('show', `jsn scan ${group} show ${id}`, 'Inspect this definition'),
27
+ hint('update', `jsn scan ${group} update ${id} --data-file ${group === 'checks' ? 'check' : 'suite'}.json`, 'Update selected fields'),
28
+ hint(active(record) ? 'deactivate' : 'activate', `jsn scan ${group} ${active(record) ? 'deactivate' : 'activate'} ${id}`, 'Change eligibility without starting a scan'),
29
+ ...(group === 'suites' ? [hint('add-check', `jsn scan suites add-check ${id} --check '<check-id-or-name>'`, 'Add an existing check'),
30
+ hint('run', `jsn scan run --suite ${id} --update-set '<update-set>'`, 'Run against an explicit update set')] :
31
+ [hint('suites', 'jsn scan suites list', 'Choose a suite for this check')]),
32
+ hint('delete', `jsn scan ${group} delete ${id}`, 'Delete this definition after confirmation')];
33
+ };
34
+
35
+ function executionHints(data) {
36
+ const id = data.execution_id;
37
+ return [hint('status', `jsn scan status ${id}`, 'Read execution progress'),
38
+ hint('results', `jsn scan results ${id}`, 'Inspect findings and recorded check executions'),
39
+ ...(!data.terminal ? [hint('cancel', `jsn scan cancel ${id}`, 'Request cancellation')] : []),
40
+ ...(data.reusable_target ? [hint('rerun', `jsn scan run --previous ${id}${data.rerun_full ? ' --full' : ''}`, 'Rerun the previous target with current check definitions')] : []),
41
+ hint('checks', 'jsn scan checks list', 'Review available checks')];
42
+ }
43
+
44
+ const payloadOptions = y => y.option('data', { type: 'string', describe: 'JSON fields' })
45
+ .option('data-file', { type: 'string', describe: 'Read JSON fields from a file (recommended for scripts)' })
46
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON fields from stdin' })
47
+ .option('field', { alias: 'f', type: 'array', describe: 'Set field=value; repeatable' });
48
+ const lookupOptions = y => y.positional('identifier', { type: 'string', describe: 'Unique name or sys_id' });
49
+
50
+ async function guardScope(app, record) {
51
+ const scopeId = referenceId(record.sys_scope) || raw(record.sys_scope);
52
+ if (!scopeId) throw errUsage('The definition scope is unreadable; inspect permissions before changing it.');
53
+ const user = await getCurrentUser(app.sdk);
54
+ if (!user) throw errUsage('Could not verify the current scope.');
55
+ const scope = await getCurrentApplication(app.sdk, user.sys_id, { unknownWhenMissing: true });
56
+ if (scope.appSysId !== scopeId && scope.scope !== scopeId) {
57
+ throw errUsage(`Select the definition scope before changing it: jsn scopes set ${quoteShellArg(scopeId)}`);
58
+ }
59
+ }
60
+
61
+ async function verify(app, table, id, data) {
62
+ if (!id) throw errAPI(200, 'The write returned no sys_id. Inspect the instance before retrying.');
63
+ const mismatches = await verifyWriteBack(app, table, id, data);
64
+ if (mismatches.length) throw errAPI(200, `Write-back verification failed for ${table}/${id}: ${mismatches.map(item => item.field).join(', ')}. The write may have succeeded partially; inspect before retrying.`);
65
+ }
66
+
67
+ function definitions(group, wrap) {
68
+ const suite = group === 'suites';
69
+ const baseTable = suite ? 'scan_check_suite' : 'scan_check';
70
+ const emit = (app, argv, record, summary) => report(app, argv, record, { summary, breadcrumbs: itemHints(group, record) });
71
+ return {
72
+ command: `${group} [subcommand]`,
73
+ describe: suite ? 'Manage scan suites and their check memberships' : 'Manage typed Instance Scan check definitions',
74
+ builder: y => {
75
+ y.command({ command: 'list', describe: `List ${group}`,
76
+ builder: p => {
77
+ p.option('query', { type: 'string', describe: 'Encoded query' })
78
+ .option('limit', { type: 'number', default: 50, describe: 'Maximum records (1–200)' })
79
+ .option('offset', { type: 'number', default: 0, describe: 'Pagination offset' });
80
+ return suite ? p : p.option('type', { choices: Object.keys(CHECK_TABLES), type: 'string', describe: 'Filter by check type' });
81
+ }, handler: wrap(async (argv, app) => {
82
+ const table = suite ? baseTable : (argv.type ? CHECK_TABLES[argv.type] : baseTable);
83
+ const records = await scanList(app.sdk, table, { limit: argv.limit, offset: argv.offset, query: argv.query,
84
+ fields: ['sys_id', 'name', 'active', ...(suite ? ['description'] : ['sys_class_name', 'short_description'])] });
85
+ report(app, argv, { table, records, count: records.length, limit: argv.limit, offset: argv.offset,
86
+ more_available: records.length === argv.limit ? null : false }, {
87
+ summary: `${records.length} scan ${group}`, breadcrumbs: [
88
+ ...records.slice(0, 5).map(record => hint('show', `jsn scan ${group} show ${unwrapSysId(record)}`, `Inspect ${getStringField(record, 'name')}`)),
89
+ hint('create', `jsn scan ${group} create${suite ? '' : ' --type table'} --data-file ${suite ? 'suite' : 'check'}.json`, 'Create an inactive definition'),
90
+ ...(records.length === argv.limit ? [hint('next', `jsn scan ${group} list --offset ${argv.offset + argv.limit} --limit ${argv.limit}${argv.type ? ` --type ${argv.type}` : ''}${argv.query ? ` --query ${quoteShellArg(argv.query)}` : ''}`, 'Next page')] : []),
91
+ ],
92
+ });
93
+ }) });
94
+ y.command({ command: 'show <identifier>', describe: `Inspect a ${suite ? 'suite and its members' : 'check'}`, builder: lookupOptions,
95
+ handler: wrap(async (argv, app) => {
96
+ const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
97
+ if (suite) {
98
+ const members = await scanList(app.sdk, 'scan_check_suite_check', { limit: 200, query: `suite=${unwrapSysId(record)}^ORDERBYsys_id`, fields: ['sys_id', 'check'] });
99
+ record.members = members;
100
+ record.members_may_be_truncated = members.length === 200;
101
+ }
102
+ emit(app, argv, record, `Scan ${suite ? 'suite' : 'check'}: ${getStringField(record, 'name')}`);
103
+ }) });
104
+ y.command({ command: 'create', describe: `Create an inactive ${suite ? 'suite' : 'typed check'} unless active is explicitly supplied`,
105
+ builder: p => suite ? payloadOptions(p) : payloadOptions(p).option('type', { choices: Object.keys(CHECK_TABLES), type: 'string', demandOption: true, describe: 'Check type' }),
106
+ handler: wrap(async (argv, app) => {
107
+ const data = validateScanPayload(argv.type, buildRecordPayload(argv), { create: true, suite });
108
+ const table = suite ? baseTable : CHECK_TABLES[argv.type];
109
+ const record = await createRecord(app.sdk, { table, data });
110
+ await verify(app, table, unwrapSysId(record), data);
111
+ emit(app, argv, record, `Created scan ${suite ? 'suite' : 'check'}`);
112
+ }) });
113
+ for (const operation of ['update', 'activate', 'deactivate']) y.command({
114
+ command: `${operation} <identifier>`, describe: `${operation} a scan ${suite ? 'suite' : 'check'} without running it`,
115
+ builder: p => operation === 'update' ? payloadOptions(lookupOptions(p)) : lookupOptions(p),
116
+ handler: wrap(async (argv, app) => {
117
+ const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
118
+ const kind = suite ? null : checkKind(record);
119
+ const patch = operation === 'update' ? validateScanPayload(kind, buildRecordPayload(argv), { suite, current: record }) : { active: operation === 'activate' };
120
+ if (!suite && operation === 'activate') validateScanPayload(kind, {
121
+ name: raw(record.name), script: raw(record.script), ...(kind === 'table' ? { table: raw(record.table), advanced: raw(record.advanced) } : {}),
122
+ ...(kind === 'column' ? { column_type: raw(record.column_type) } : {}),
123
+ }, { create: true });
124
+ await guardScope(app, record);
125
+ const table = suite ? baseTable : CHECK_TABLES[kind];
126
+ const updated = await updateRecord(app.sdk, { table, record, data: patch });
127
+ await verify(app, table, unwrapSysId(record), patch);
128
+ emit(app, argv, updated, `Updated scan ${suite ? 'suite' : 'check'}`);
129
+ }),
130
+ });
131
+ y.command({ command: 'delete <identifier>', describe: `Delete a ${suite ? 'suite' : 'check'} definition after confirmation`, builder: lookupOptions,
132
+ handler: wrap(async (argv, app) => {
133
+ const record = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
134
+ await guardScope(app, record);
135
+ await confirmDelete(app, argv, `Delete scan ${suite ? 'suite' : 'check'} "${getStringField(record, 'name')}"?`);
136
+ const id = await deleteRecord(app.sdk, { table: suite ? baseTable : CHECK_TABLES[checkKind(record)], record });
137
+ report(app, argv, { sys_id: id, deleted: true }, { summary: 'Deleted scan definition', breadcrumbs: [hint('list', `jsn scan ${group} list`, 'Browse remaining definitions')] });
138
+ }) });
139
+ if (suite) for (const operation of ['add-check', 'remove-check']) y.command({
140
+ command: `${operation} <identifier>`, describe: `${operation === 'add-check' ? 'Add a check to' : 'Remove a check from'} a suite`,
141
+ builder: p => lookupOptions(p).option('check', { type: 'string', demandOption: true, describe: 'Check name or sys_id' }),
142
+ handler: wrap(async (argv, app) => {
143
+ const suiteRecord = await resolveScanRecord(app.sdk, baseTable, argv.identifier);
144
+ const check = await resolveScanRecord(app.sdk, 'scan_check', argv.check);
145
+ await guardScope(app, suiteRecord);
146
+ const data = { suite: unwrapSysId(suiteRecord), check: unwrapSysId(check) };
147
+ const table = 'scan_check_suite_check';
148
+ const members = await scanList(app.sdk, table, { query: `suite=${data.suite}^check=${data.check}`, limit: 2 });
149
+ if (members.length > 1) throw errUsage('Duplicate suite memberships exist; inspect and reconcile them before changing membership.');
150
+ let changed = false;
151
+ if (operation === 'add-check' && !members.length) {
152
+ const member = await createRecord(app.sdk, { table, data });
153
+ await verify(app, table, unwrapSysId(member), data);
154
+ changed = true;
155
+ } else if (operation === 'remove-check' && members.length) {
156
+ await confirmDelete(app, argv, 'Remove this check from the suite? The check definition will be retained.');
157
+ await deleteRecord(app.sdk, { table, record: members[0] });
158
+ changed = true;
159
+ }
160
+ report(app, argv, { ...data, changed, member: operation === 'add-check' }, { summary: 'Suite membership reconciled', breadcrumbs: itemHints(group, suiteRecord) });
161
+ }),
162
+ });
163
+ return y;
164
+ },
165
+ handler: wrap(async (argv, app) => report(app, argv, { commands: ['list', 'show', 'create', 'update', 'delete', 'activate', 'deactivate', ...(suite ? ['add-check', 'remove-check'] : [])] }, {
166
+ summary: `Scan ${group}`, breadcrumbs: [hint('list', `jsn scan ${group} list`, `Browse ${group}`)],
167
+ })),
168
+ };
169
+ }
170
+
171
+ export function scanCmd(wrap) {
172
+ return {
173
+ command: 'scan [subcommand]', describe: 'Instance Scan checks, suites, explicit targets, progress and findings',
174
+ builder: y => y.command(definitions('checks', wrap)).command(definitions('suites', wrap))
175
+ .command({ command: 'run', describe: 'Start an explicit target scan (confirmation required; no implicit full scan)',
176
+ builder: p => p.option('update-set', { type: 'string', describe: 'Update set name or sys_id; scans current records' })
177
+ .option('scope', { type: 'string', describe: 'Application scope value or sys_id' })
178
+ .option('record', { type: 'string', describe: 'Record sys_id; requires --table' })
179
+ .option('table', { type: 'string', describe: 'Table for --record' })
180
+ .option('full', { type: 'boolean', describe: 'Explicitly allow an instance-wide scan' })
181
+ .option('previous', { type: 'string', describe: 'Rerun a previous execution target; full scans also require --full' })
182
+ .option('suite', { type: 'string', describe: 'Optional active suite name or sys_id' })
183
+ .option('wait', { type: 'boolean', default: false, describe: 'Wait for completion; timeout does not cancel the scan' })
184
+ .option('timeout', { type: 'number', default: 120, describe: 'Maximum wait in seconds (1–3600)' }),
185
+ handler: wrap(async (argv, app) => {
186
+ if (!Number.isFinite(argv.timeout) || argv.timeout < 1 || argv.timeout > 3600) throw errUsage('--timeout must be from 1 to 3600 seconds.');
187
+ const plan = await prepareScan(app.sdk, argv);
188
+ await confirmDelete(app, argv, `Start scan of ${plan.target.kind} ${plan.target.name || plan.target.sys_id || plan.target.execution_id || 'the full instance'}${plan.suite_id ? ` using suite ${plan.suite_id}` : ''}?`);
189
+ const scheduled = await startScan(app.sdk, plan);
190
+ let data = { ...scheduled, status: 'scheduled', terminal: false };
191
+ if (argv.wait) {
192
+ try {
193
+ const deadline = Date.now() + argv.timeout * 1000;
194
+ while (Date.now() < deadline) {
195
+ try {
196
+ data = { ...scheduled, ...await scanStatus(app.sdk, scheduled.execution_id, { allowInitializing: true }) };
197
+ } catch (error) {
198
+ if (error.code !== 'not_found' && error.status !== 404) throw error;
199
+ // The processor can return before its execution tracker is visible.
200
+ data = { ...scheduled, status: 'scheduled', terminal: false };
201
+ }
202
+ if (data.terminal) { data = { ...scheduled, ...await scanResults(app.sdk, scheduled.execution_id) }; break; }
203
+ await new Promise(resolve => setTimeout(resolve, Math.min(2000, Math.max(0, deadline - Date.now()))));
204
+ }
205
+ if (!data.terminal) data.wait_timed_out = true;
206
+ } catch (error) {
207
+ throw errAPI(error.status || 0, `Scan ${scheduled.execution_id} was scheduled, but progress could not be read: ${error.message}. Use jsn scan status ${scheduled.execution_id}; do not resubmit automatically.`);
208
+ }
209
+ }
210
+ const breadcrumbs = executionHints(data);
211
+ if (data.terminal && !data.reusable_target) breadcrumbs.push(hint('rerun', 'jsn scan run --help', 'Choose a target for another scan'));
212
+ report(app, argv, data, { summary: `Scan ${data.execution_id}: ${data.status}${data.wait_timed_out ? ' (still running; wait timed out)' : ''}`, breadcrumbs });
213
+ }) })
214
+ .command({ command: 'status <id>', describe: 'Read scan execution progress',
215
+ handler: wrap(async (argv, app) => {
216
+ const data = await scanStatus(app.sdk, argv.id);
217
+ report(app, argv, data, { summary: `Scan ${data.status}: ${data.message}`, breadcrumbs: executionHints(data) });
218
+ }) })
219
+ .command({ command: 'results <id>', describe: 'Read findings and check execution coverage; unreadable data is not zero',
220
+ builder: p => p.option('limit', { type: 'number', default: 50, describe: 'Maximum findings/check rows (1–200); truncation is reported' }),
221
+ handler: wrap(async (argv, app) => {
222
+ const data = await scanResults(app.sdk, argv.id, boundedLimit(argv.limit));
223
+ report(app, argv, data, { summary: `Scan ${data.status}; ${data.findings_count ?? 'unknown'} findings; ${data.checks_executed ?? 'unknown'} recorded check executions${data.incomplete ? '; incomplete data' : ''}`, breadcrumbs: executionHints(data) });
224
+ }) })
225
+ .command({ command: 'cancel <id>', describe: 'Request cancellation of this scan after confirmation',
226
+ handler: wrap(async (argv, app) => {
227
+ const status = await scanStatus(app.sdk, argv.id);
228
+ if (!status.terminal) await confirmDelete(app, argv, `Cancel scan ${argv.id}?`);
229
+ const data = status.terminal ? { ...status, cancellation_requested: false } : await cancelScan(app.sdk, argv.id);
230
+ report(app, argv, data, { summary: data.cancellation_requested ? `Cancellation requested: ${argv.id}` : 'Scan already finished; no cancellation sent', breadcrumbs: executionHints(data) });
231
+ }) }),
232
+ handler: wrap(async (argv, app) => report(app, argv, { commands: ['checks', 'suites', 'run', 'status', 'results', 'cancel'] }, { summary: 'Instance Scan', breadcrumbs: rootHints() })),
233
+ };
234
+ }
@@ -365,6 +365,7 @@ export async function formatUpdateSetDetail(app, record) {
365
365
  const stateRaw = String(full.state?.value ?? full.state ?? '').toLowerCase();
366
366
  const isClosed = ['complete', 'committed'].includes(stateRaw);
367
367
  const hints = [];
368
+ hints.push({ action: 'scan', cmd: `jsn scan run --update-set ${sysID}${app.session?.profileName ? ` --profile ${quoteShellArg(app.session.profileName)}` : ''}`, description: 'Offer an explicit scan of current records before completing or promoting this set' });
368
369
  if (isClosed) {
369
370
  hints.push({
370
371
  action: 'set',
package/src/help.js CHANGED
@@ -132,6 +132,7 @@ const CONFIG_COMMANDS = [
132
132
  ];
133
133
 
134
134
  const DEVELOPER_COMMANDS = [
135
+ { name: 'scan', desc: 'Instance Scan checks, suites, explicit targets, progress and findings' },
135
136
  { name: 'atf', desc: 'Automated Test Framework (list, suites, run, results)' },
136
137
  { name: 'eval', desc: 'Run server-side JavaScript' },
137
138
  { name: 'rest', desc: 'Raw REST API calls' },
package/src/scan.js ADDED
@@ -0,0 +1,249 @@
1
+ import { SaxesParser } from 'saxes';
2
+ import { getRecord, recordListParams } from './records-engine.js';
3
+ import { isSysId, unwrapSysId } from './resolve-record.js';
4
+ import { errAPI, errNotFound, errUsage } from './errors.js';
5
+
6
+ export const CHECK_TABLES = {
7
+ table: 'scan_table_check', column: 'scan_column_type_check',
8
+ linter: 'scan_linter_check', script: 'scan_script_only_check',
9
+ };
10
+ export const raw = value => value && typeof value === 'object' ? (value.value ?? '') : value;
11
+ export const active = record => [true, 'true', '1', 1].includes(raw(record?.active));
12
+ export const referenceId = value => {
13
+ const id = raw(value) || value?.link?.split('/').at(-1);
14
+ return isSysId(id) ? id : '';
15
+ };
16
+
17
+ export function requireId(value, label = 'execution') {
18
+ if (!isSysId(value)) throw errUsage(`Use a full 32-character sys_id for ${label}.`);
19
+ return value;
20
+ }
21
+
22
+ export function boundedLimit(value = 50) {
23
+ if (!Number.isInteger(value) || value < 1 || value > 200) throw errUsage('--limit must be an integer from 1 to 200.');
24
+ return value;
25
+ }
26
+
27
+ export async function scanList(sdk, table, { limit = 50, offset = 0, fields, query } = {}) {
28
+ boundedLimit(limit);
29
+ if (!Number.isInteger(offset) || offset < 0) throw errUsage('--offset must be a nonnegative integer.');
30
+ const params = recordListParams({ limit, offset, fields, query, displayValue: 'false' });
31
+ return sdk.listStrict(table, params);
32
+ }
33
+
34
+ export async function resolveScanRecord(sdk, table, identifier, matchField = 'name') {
35
+ const record = await getRecord(sdk, { table, identifier, matchField, displayValues: 'raw', resource: table });
36
+ if (table !== 'scan_check') return record;
37
+ const subtype = String(raw(record.sys_class_name) || '');
38
+ if (!Object.values(CHECK_TABLES).includes(subtype)) return record;
39
+ const concrete = await sdk.get(subtype, unwrapSysId(record));
40
+ if (!concrete || unwrapSysId(concrete) !== unwrapSysId(record)) throw errUsage('The concrete check definition is unavailable; inspect its class and permissions before changing it.');
41
+ return { ...record, ...concrete };
42
+ }
43
+
44
+ const COMMON_FIELDS = ['name', 'description', 'short_description', 'active', 'category', 'priority',
45
+ 'script', 'run_condition', 'finding_type', 'resolution_details', 'documentation_url', 'score_min', 'score_max', 'score_scale'];
46
+
47
+ export function validateScanPayload(kind, input, { create = false, suite = false, current = {} } = {}) {
48
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw errUsage('The payload must be a JSON object.');
49
+ if (!suite && !CHECK_TABLES[kind]) throw errUsage('Choose --type table, column, linter, or script.');
50
+ const fields = suite ? ['name', 'description', 'active', 'private'] : [...COMMON_FIELDS,
51
+ ...(kind === 'table' ? ['table', 'conditions', 'advanced', 'use_manifest'] : []),
52
+ ...(kind === 'column' ? ['column_type'] : [])];
53
+ for (const field of Object.keys(input)) {
54
+ if (!fields.includes(field)) throw errUsage(`Unsupported ${suite ? 'suite' : kind + ' check'} field: ${field}`);
55
+ }
56
+ const data = { ...input };
57
+ for (const field of ['active', 'private', 'advanced', 'use_manifest']) {
58
+ if (data[field] === undefined) continue;
59
+ if (![true, false, 'true', 'false'].includes(data[field])) throw errUsage(`${field} must be true or false.`);
60
+ data[field] = data[field] === true || data[field] === 'true';
61
+ }
62
+ if ((create || data.name !== undefined) && (typeof data.name !== 'string' || !data.name.trim())) throw errUsage('A nonempty name is required.');
63
+ if (data.script !== undefined && typeof data.script !== 'string') throw errUsage('script must be a string.');
64
+ if (create && data.active === undefined) data.active = false;
65
+ if (data.table !== undefined && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(data.table)) throw errUsage('Invalid target table name.');
66
+ if (data.column_type !== undefined && !['script', 'xml', 'html'].includes(data.column_type)) throw errUsage('column_type must be script, xml, or html.');
67
+ if (!suite) {
68
+ const script = data.script ?? raw(current.script);
69
+ const advanced = data.advanced ?? active({ active: current.advanced });
70
+ if (create && kind === 'table' && !data.table) throw errUsage('Table checks require a table.');
71
+ if (create && kind === 'column' && !data.column_type) throw errUsage('Column checks require column_type.');
72
+ if ((create || Object.hasOwn(data, 'script') || Object.hasOwn(data, 'advanced'))
73
+ && (kind !== 'table' || advanced) && (typeof script !== 'string' || !script.trim())) {
74
+ throw errUsage('This check type requires a nonempty script.');
75
+ }
76
+ }
77
+ return data;
78
+ }
79
+
80
+ export function checkKind(record) {
81
+ const table = String(raw(record.sys_class_name) || '');
82
+ const kind = Object.keys(CHECK_TABLES).find(key => CHECK_TABLES[key] === table);
83
+ if (!kind) throw errUsage('Unknown or unreadable check class; inspect the check before changing it.');
84
+ return kind;
85
+ }
86
+
87
+ function decodeAjax(xml) {
88
+ let answer;
89
+ const parser = new SaxesParser();
90
+ parser.on('doctype', () => { throw errAPI(200, 'Unexpected document type in scan response.'); });
91
+ parser.on('opentag', node => { if (node.name === 'xml') answer = node.attributes.answer; });
92
+ try { parser.write(xml).close(); } catch { throw errAPI(200, 'Malformed scan processor XML response.'); }
93
+ if (typeof answer !== 'string') throw errAPI(200, 'Scan processor returned no answer; check endpoint access.');
94
+ return answer;
95
+ }
96
+
97
+ export async function scanAjax(sdk, method, parameters) {
98
+ const query = new URLSearchParams({ sysparm_processor: 'ScanAjaxProcessor', sysparm_name: method, ...parameters });
99
+ return decodeAjax(await sdk.rawRequest(`${sdk.baseURL}/xmlhttp.do?${query}`, { method: 'POST' }));
100
+ }
101
+
102
+ const TERMINAL = new Set(['successful', 'failed', 'cancelled']);
103
+ const STATES = { '0': 'pending', '1': 'running', '2': 'successful', '3': 'failed', '4': 'cancelled',
104
+ pending: 'pending', running: 'running', successful: 'successful', failed: 'failed',
105
+ cancelled: 'cancelled', canceled: 'cancelled' };
106
+
107
+ export async function scanStatus(sdk, id, { allowInitializing = false } = {}) {
108
+ requireId(id);
109
+ const tracker = await sdk.get('sys_execution_tracker', id);
110
+ if (!tracker) throw errNotFound('scan execution', id);
111
+ if (!raw(tracker.type) && allowInitializing) return { execution_id: id, status: 'pending', terminal: false,
112
+ percent_complete: null, message: 'Execution tracker is initializing', result_id: null };
113
+ if (raw(tracker.type) === 'sys.instance_scan.queue') return { execution_id: id, status: 'pending', terminal: false,
114
+ percent_complete: raw(tracker.percent_complete) ?? null, message: raw(tracker.message) || 'Scan is queued', result_id: null };
115
+ if (raw(tracker.type) !== 'instance_scan') throw errUsage('This execution tracker is not an Instance Scan.');
116
+ const state = STATES[String(raw(tracker.state)).toLowerCase()] || 'unknown';
117
+ let resultId = '';
118
+ if (raw(tracker.result)) {
119
+ try { resultId = referenceId(JSON.parse(raw(tracker.result)).result_id); } catch { /* reported as unavailable below */ }
120
+ }
121
+ return { execution_id: id, status: state, terminal: TERMINAL.has(state),
122
+ percent_complete: raw(tracker.percent_complete) ?? null,
123
+ message: raw(tracker.message) || '', result_id: resultId || null };
124
+ }
125
+
126
+ async function resolveSuite(sdk, identifier) {
127
+ const suite = await resolveScanRecord(sdk, 'scan_check_suite', identifier);
128
+ if (!active(suite)) throw errUsage('The selected suite is inactive or its active state is unreadable. Activate it explicitly first.');
129
+ return unwrapSysId(suite);
130
+ }
131
+
132
+ export async function prepareScan(sdk, argv) {
133
+ const previous = argv.previous;
134
+ const updateSet = argv.updateSet ?? argv['update-set'];
135
+ for (const value of [previous, updateSet, argv.scope, argv.record, argv.suite]) {
136
+ if (value !== undefined && (typeof value !== 'string' || !value.trim())) throw errUsage('Scan target and suite identifiers must be nonempty strings.');
137
+ }
138
+ const selectors = [updateSet, argv.scope, argv.record, previous].filter(value => value !== undefined).length;
139
+ if (selectors + Number(Boolean(argv.full)) !== 1 && !(previous && argv.full && selectors === 1)) {
140
+ throw errUsage('Choose exactly one target: --update-set, --scope, --record with --table, --full, or --previous.');
141
+ }
142
+ if (Boolean(argv.table) !== Boolean(argv.record)) throw errUsage('--record and --table must be supplied together.');
143
+ if (previous && argv.suite) throw errUsage('--previous preserves its source suite; do not combine it with --suite.');
144
+ let target;
145
+ if (previous) {
146
+ const status = await scanStatus(sdk, previous);
147
+ if (!status.terminal || !status.result_id) throw errUsage('The previous scan must have finished and have a readable result.');
148
+ const result = await sdk.get('scan_result', status.result_id);
149
+ const comboId = referenceId(result?.combo);
150
+ if (!comboId) throw errUsage('The previous scan has no reusable target combination. Choose an explicit target.');
151
+ const combo = await sdk.get('scan_combo', comboId);
152
+ if (!combo) throw errUsage('The previous scan combination is unavailable.');
153
+ if (!raw(combo.targets) && !argv.full) throw errUsage('The previous scan was instance-wide. Repeat with --full to approve that breadth.');
154
+ target = { kind: 'previous', execution_id: previous, combo_id: comboId, full: !raw(combo.targets) };
155
+ } else if (updateSet !== undefined) {
156
+ const record = await resolveScanRecord(sdk, 'sys_update_set', updateSet);
157
+ target = { kind: 'update_set', sys_id: unwrapSysId(record), name: raw(record.name),
158
+ note: 'Scans current instance records referenced by this update set, not historical XML versions. Script-only checks do not participate.' };
159
+ } else if (argv.scope !== undefined) {
160
+ const record = await resolveScanRecord(sdk, 'sys_scope', argv.scope, 'scope');
161
+ target = { kind: 'scope', sys_id: unwrapSysId(record), name: raw(record.scope) };
162
+ } else if (argv.record !== undefined) {
163
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(argv.table)) throw errUsage('Invalid target table.');
164
+ const record = await resolveScanRecord(sdk, argv.table, argv.record, 'sys_id');
165
+ target = { kind: 'record', table: argv.table, sys_id: unwrapSysId(record),
166
+ inactive: raw(record.active) === false || raw(record.active) === 'false',
167
+ note: 'Inactive target records may be skipped by the scan engine. Script-only checks do not participate.' };
168
+ } else target = { kind: 'full', note: 'Instance-wide scans can traverse many tables and take substantially longer.' };
169
+ if (!target.sys_id && !['full', 'previous'].includes(target.kind)) throw errUsage('The target has no readable sys_id.');
170
+ const suiteId = argv.suite ? await resolveSuite(sdk, argv.suite) : null;
171
+ return { target, suite_id: suiteId };
172
+ }
173
+
174
+ export async function startScan(sdk, plan) {
175
+ const { target, suite_id: suite } = plan;
176
+ let response;
177
+ if (suite && ['update_set', 'scope'].includes(target.kind)) {
178
+ const kind = target.kind === 'scope' ? 'scoped_apps' : 'update_sets';
179
+ const field = target.kind === 'scope' ? 'app_scope_sys_ids' : 'update_set_sys_ids';
180
+ const data = await sdk.request(`${sdk.baseURL}/api/sn_cicd/instance_scan/suite_scan/${requireId(suite, 'suite')}/${kind}`, {
181
+ method: 'POST', body: JSON.stringify({ [field]: [target.sys_id] }),
182
+ });
183
+ response = data?.result;
184
+ if (response?.error || !['0', '1', '2'].includes(String(response?.status))) throw errAPI(200, 'Scan dispatch was rejected by the provider.');
185
+ const executionId = response?.links?.progress?.id;
186
+ if (!isSysId(executionId)) throw errAPI(200, 'Scan dispatch returned no valid execution id. Inspect existing scans before retrying.');
187
+ return { execution_id: executionId, via: 'sn_cicd', ...plan };
188
+ }
189
+ let method, parameters;
190
+ if (target.kind === 'previous') { method = 'executeScanFromCombo'; parameters = { sysparm_combo_id: target.combo_id }; }
191
+ else if (suite && target.kind === 'full') { method = 'executeSuiteScan'; parameters = { sysparm_sys_id: suite }; }
192
+ else if (suite) {
193
+ method = 'executeSuiteScanWithTargets'; parameters = { sysparm_suite_sys_id: suite,
194
+ sysparm_target_table: target.table, sysparm_target_sys_ids: JSON.stringify([target.sys_id]) };
195
+ } else if (target.kind === 'update_set') { method = 'executeUpdateSetScan'; parameters = { sysparm_sys_id: target.sys_id }; }
196
+ else if (target.kind === 'scope') { method = 'executeAppScan'; parameters = { sysparm_scope_id: target.sys_id }; }
197
+ else if (target.kind === 'record') { method = 'executePointScan'; parameters = { sysparm_sys_id: target.sys_id, sysparm_table_name: target.table }; }
198
+ else { method = 'executeFullScan'; parameters = {}; }
199
+ try { response = JSON.parse(await scanAjax(sdk, method, parameters)); }
200
+ catch (error) { if (error.code) throw error; throw errAPI(200, 'Malformed scan dispatch answer. Inspect existing scans before retrying.'); }
201
+ if (response.status !== 0 || !isSysId(response.progressId)) throw errAPI(200, 'Scan dispatch failed or returned no valid execution id. Inspect existing scans before retrying.');
202
+ return { execution_id: response.progressId, via: 'ScanAjaxProcessor', ...plan };
203
+ }
204
+
205
+ export async function scanResults(sdk, id, limit = 50) {
206
+ boundedLimit(limit);
207
+ const status = await scanStatus(sdk, id);
208
+ const data = { ...status, checks_executed: null, findings_count: null, checks: [], findings: [],
209
+ incomplete: !status.terminal, unavailable: [], truncated: {}, rerun_full: false, reusable_target: false };
210
+ if (!status.result_id) { data.unavailable.push('scan_result'); data.incomplete = true; return data; }
211
+ let result;
212
+ try {
213
+ result = await sdk.get('scan_result', status.result_id);
214
+ if (!result) throw new Error('Result unavailable');
215
+ data.scan_number = raw(result.number);
216
+ const value = raw(result.finding_count);
217
+ const count = Number(value);
218
+ if ((typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) && Number.isInteger(count) && count >= 0) data.findings_count = count;
219
+ else { data.unavailable.push('scan_result.finding_count'); data.incomplete = true; }
220
+ } catch { data.unavailable.push('scan_result'); data.incomplete = true; }
221
+ for (const [key, table, fields] of [
222
+ ['checks', 'scan_check_execution', ['check', 'message', 'execution_time']],
223
+ ['findings', 'scan_finding', ['check', 'source_table', 'source', 'finding_details', 'count']],
224
+ ]) {
225
+ try {
226
+ const rows = await sdk.listStrict(table, recordListParams({ limit: limit + 1,
227
+ query: `result=${status.result_id}^ORDERBYsys_id`, fields, displayValue: 'all' }));
228
+ data[key] = rows.slice(0, limit);
229
+ data.truncated[key] = rows.length > limit;
230
+ if (key === 'checks' && !data.truncated[key] && status.terminal) data.checks_executed = rows.length;
231
+ } catch { data.unavailable.push(table); data.incomplete = true; }
232
+ }
233
+ const comboId = referenceId(result?.combo);
234
+ if (comboId) {
235
+ try { const combo = await sdk.get('scan_combo', comboId); if (combo) { data.reusable_target = true; data.rerun_full = !raw(combo.targets); } }
236
+ catch { /* Optional rerun hint is omitted when target metadata is inaccessible. */ }
237
+ }
238
+ data.coverage_note = 'Execution success does not prove functional correctness or complete coverage. Zero findings may mean records or checks were skipped.';
239
+ return data;
240
+ }
241
+
242
+ export async function cancelScan(sdk, id) {
243
+ const status = await scanStatus(sdk, id);
244
+ if (status.terminal) return { ...status, cancellation_requested: false };
245
+ const answer = await scanAjax(sdk, 'cancelScan', { sysparm_progress_id: id });
246
+ const after = await scanStatus(sdk, id);
247
+ if (after.status !== 'cancelled' && !/cancelled successfully/i.test(answer)) throw errAPI(200, 'Cancellation was not confirmed. Check the execution status before retrying.');
248
+ return { ...after, cancellation_requested: true };
249
+ }