@portll/cobolwork 0.2.76 → 0.2.140

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.
Files changed (61) hide show
  1. package/README.md +66 -14
  2. package/bin/cobolwork.mjs +126 -9
  3. package/lib/arith.mjs +235 -0
  4. package/lib/baseline.mjs +5 -0
  5. package/lib/build.mjs +112 -30
  6. package/lib/capabilities.mjs +12 -7
  7. package/lib/card.mjs +18 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/consequence.mjs +29 -0
  10. package/lib/control.mjs +1169 -91
  11. package/lib/csd.mjs +17 -2
  12. package/lib/dataflow.mjs +276 -126
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/enterprise-options.mjs +255 -0
  15. package/lib/equivalence.mjs +124 -0
  16. package/lib/evidence/cli.mjs +65 -0
  17. package/lib/evidence/journal.mjs +94 -0
  18. package/lib/evidence/record.mjs +110 -0
  19. package/lib/evidence/run.mjs +111 -0
  20. package/lib/evidence/seal.mjs +177 -0
  21. package/lib/evidence/slsa.mjs +64 -0
  22. package/lib/evidence/sshsig.mjs +172 -0
  23. package/lib/evidence/store.mjs +170 -0
  24. package/lib/evidence/verify.mjs +167 -0
  25. package/lib/explain.mjs +9 -5
  26. package/lib/exploitability.mjs +263 -0
  27. package/lib/ftp.mjs +136 -0
  28. package/lib/ironwork.mjs +109 -0
  29. package/lib/jcl.mjs +27 -7
  30. package/lib/kernel/findings.mjs +11 -0
  31. package/lib/kernel/registry.mjs +4 -0
  32. package/lib/layout.mjs +202 -0
  33. package/lib/option-diff.mjs +49 -0
  34. package/lib/options.mjs +109 -46
  35. package/lib/parser.mjs +80 -209
  36. package/lib/policy.mjs +8 -3
  37. package/lib/precompile.mjs +5 -84
  38. package/lib/reach.mjs +29 -12
  39. package/lib/revision.json +1 -1
  40. package/lib/sarif.mjs +1 -0
  41. package/lib/sbom.mjs +235 -0
  42. package/lib/scan.mjs +93 -27
  43. package/lib/sets/cics.mjs +119 -2
  44. package/lib/sets/copybook.mjs +2 -1
  45. package/lib/sets/flow.mjs +82 -2
  46. package/lib/sets/hidden.mjs +4 -8
  47. package/lib/sets/jcl.mjs +70 -106
  48. package/lib/sets/recon.mjs +2 -1
  49. package/lib/sets/semantics.mjs +377 -0
  50. package/lib/sets/web.mjs +34 -10
  51. package/lib/sets/zowe.mjs +246 -0
  52. package/lib/sources.mjs +10 -0
  53. package/lib/tui/app.mjs +31 -13
  54. package/lib/tui/model.mjs +17 -2
  55. package/lib/utilities.mjs +229 -15
  56. package/lib/verify.mjs +128 -0
  57. package/package.json +1 -1
  58. package/rules/compliance-dora.json +507 -0
  59. package/rules/compliance-ffiec.json +507 -0
  60. package/rules/compliance-nist80053.json +507 -0
  61. package/schema/cobolwork.policy.schema.json +94 -16
package/lib/utilities.mjs CHANGED
@@ -8,6 +8,9 @@
8
8
  // statements are not in the job - SYSIN naming a library member - says so, because its copies are
9
9
  // then only the ones its DD names decide on their own.
10
10
 
11
+ import { ftpSession } from './ftp.mjs';
12
+ import { statementCard } from './card.mjs';
13
+
11
14
  const upper = (s) => String(s).toUpperCase();
12
15
 
13
16
  // The DDs of a step by name, each followed by the datasets concatenated to it.
@@ -93,7 +96,7 @@ function commands(lines) {
93
96
  let comment = false;
94
97
  for (const { line, text } of lines) {
95
98
  let t = '';
96
- const s = text.slice(0, 72);
99
+ const s = statementCard(text).text;
97
100
  for (let i = 0; i < s.length; i++) {
98
101
  if (comment) { if (s[i] === '*' && s[i + 1] === '/') { comment = false; i++; } continue; }
99
102
  if (s[i] === '/' && s[i + 1] === '*') { comment = true; i++; continue; }
@@ -129,6 +132,12 @@ const who = (step) => `step ${step.name || '(unnamed)'}`;
129
132
  // Control statements only edit records or split members, so the copy is the DDs' alone.
130
133
  // https://www.ibm.com/docs/api/v1/content/zosbasics/com.ibm.zos.zdatamgmt/zsysprogc_utilities_IEBGENER.htm
131
134
  // https://www.ibm.com/docs/en/zos/3.1.0?topic=performance-use-icegener-instead-iebgener
135
+ // IEBPTPCH reads SYSUT1 and writes its print or punch output to SYSUT2, and IEBUPDTE reads its old
136
+ // master from SYSUT1 and writes SYSUT2. IEBUPDTE's control data set may also carry input data for the
137
+ // new master, which is not followed here.
138
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.idau100/u1367.htm
139
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.idau100/u1427.htm
140
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.idau100/u1425.htm
132
141
  function generCopy(step, dds) {
133
142
  if (!dds.has('SYSUT1') || !dds.has('SYSUT2')) return { moves: [], notes: [] };
134
143
  return { moves: [{ from: [{ dd: 'SYSUT1' }], to: [{ dd: 'SYSUT2' }], line: step.line }], notes: [] };
@@ -174,29 +183,74 @@ function sortCopies(step, dds) {
174
183
  // https://www.ibm.com/docs/en/zos/3.1.0?topic=commands-repro
175
184
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2253.htm
176
185
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.idai200/continu.htm
186
+ // EXPORT writes the cluster its first parameter names, or the one INFILE(ddname) identifies, to the
187
+ // portable data set OUTFILE(ddname) or OUTDATASET(entryname) gives, and may be abbreviated EXP.
188
+ // IMPORT reads the portable data set INFILE(ddname) or INDATASET(entryname) names and writes the
189
+ // cluster OUTFILE(ddname) or OUTDATASET(entryname) names.
190
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/export.htm
191
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2210.htm
192
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i211.htm
193
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/import.htm
194
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2221.htm
195
+ // PRINT reads INFILE(ddname) or INDATASET(entryname) and writes the listing to OUTFILE(ddname),
196
+ // SYSPRINT when it names none, so a production data set printed to SYSOUT leaves by that DD.
197
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/print.htm
198
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2244.htm
199
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2245.htm
200
+ // ALTER entryname NEWNAME(newname), abbreviated NEWNM, gives the entry a new name, so the data the
201
+ // old name held is read under the new one. A generic name (both must then be generic) or a member
202
+ // of a partitioned data set leaves the data set where it was, and is not followed.
203
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2052.htm
204
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2055.htm
205
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2056.htm
206
+ function renamed(cmd) {
207
+ const m = /^ALTER\s+'?([^\s(),'*]+)'?(?=[\s,]|$)/i.exec(cmd.text);
208
+ const to = m && param(cmd.text, ['NEWNAME', 'NEWNM']);
209
+ if (!to || /[*(]/.test(to[0])) return null;
210
+ return { from: [{ dsn: upper(m[1]) }], to: [{ dsn: to[0] }], line: cmd.line };
211
+ }
212
+
177
213
  function reproCopies(step, dds) {
178
214
  const { lines, unread } = cards(dds.get('SYSIN'));
179
- const notes = unread ? [{ line: step.line, text: `${who(step)} runs IDCAMS with commands in ${unread}, which this reader cannot see, so any REPRO it runs is not known` }] : [];
215
+ const notes = unread ? [{ line: step.line, text: `${who(step)} runs IDCAMS with commands in ${unread}, which this reader cannot see, so any REPRO, EXPORT, IMPORT, PRINT or rename it runs is not known` }] : [];
180
216
  const moves = [];
181
217
  for (const cmd of commands(lines)) {
182
- if (!/^REPRO\b/i.test(cmd.text)) continue;
183
- const end = (ddNames, dsNames) => {
218
+ if (/^ALTER\b/i.test(cmd.text)) {
219
+ const move = renamed(cmd);
220
+ if (move) moves.push(move);
221
+ continue;
222
+ }
223
+ if (/^PRINT\b/i.test(cmd.text)) {
224
+ const input = param(cmd.text, ['INFILE', 'IFILE']);
225
+ const ds = !input && param(cmd.text, ['INDATASET', 'IDS']);
226
+ const out = param(cmd.text, ['OUTFILE', 'OFILE']);
227
+ const from = input ? [{ dd: input[0] }] : ds ? [{ dsn: ds[0] }] : [];
228
+ moves.push({ from, to: [{ dd: out ? out[0] : 'SYSPRINT' }], line: cmd.line });
229
+ continue;
230
+ }
231
+ const verb = /^(REPRO|EXPORT|EXP|IMPORT)\b/i.exec(cmd.text);
232
+ if (!verb) continue;
233
+ const entry = /^EXP/i.test(verb[1]) && /^\S+\s+([^\s(),]+)/.exec(cmd.text);
234
+ const end = (ddNames, dsNames, fallback) => {
184
235
  const dd = param(cmd.text, ddNames);
185
236
  if (dd) return [{ dd: dd[0] }];
186
237
  const ds = param(cmd.text, dsNames);
187
- return ds ? [{ dsn: ds[0] }] : [];
238
+ if (ds) return [{ dsn: ds[0] }];
239
+ return fallback ? [{ dsn: upper(fallback.replace(/^'|'$/g, '')) }] : [];
188
240
  };
189
- moves.push({ from: end(['INFILE', 'IFILE'], ['INDATASET', 'IDS']), to: end(['OUTFILE', 'OFILE'], ['OUTDATASET', 'ODS']), line: cmd.line });
241
+ moves.push({ from: end(['INFILE', 'IFILE'], ['INDATASET', 'IDS'], entry && entry[1]), to: end(['OUTFILE', 'OFILE'], ['OUTDATASET', 'ODS']), line: cmd.line });
190
242
  }
191
243
  return { moves, notes };
192
244
  }
193
245
 
194
- // IEBCOPY COPY and COPYGRP name their output with OUTDD and their inputs with INDD, where an input
246
+ // IEBCOPY COPY, COPYGRP and COPYMOD name their output with OUTDD and their inputs with INDD, where an input
195
247
  // written (ddname,R) replaces members of the same name. An INDD= on a record of its own begins
196
248
  // another step of the COPY before it. A statement continues after a comma or a mark in column 72.
197
249
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.idau100/copy.htm
198
250
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.idau100/copygrp.htm
199
251
  // https://www.ibm.com/docs/en/zos/2.4.0?topic=ie-example-15-copy-groups-from-pdse-pdse-replace
252
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.idau100/copymd.htm
253
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.idau100/u1055.htm
200
254
  function iebcopyCopies(step, dds) {
201
255
  const { lines, unread } = cards(dds.get('SYSIN'));
202
256
  const notes = unread ? [{ line: step.line, text: `${who(step)} runs IEBCOPY with statements in ${unread}, which this reader cannot see, so what it copies is not known` }] : [];
@@ -206,7 +260,7 @@ function iebcopyCopies(step, dds) {
206
260
  for (const st of statements) {
207
261
  // A record holding only INDD= has no operation, so what reads as its operation is its operand.
208
262
  const alone = /^INDD=/.test(st.op);
209
- if (st.op === 'COPY' || st.op === 'COPYGRP') out = null;
263
+ if (st.op === 'COPY' || st.op === 'COPYGRP' || st.op === 'COPYMOD') out = null;
210
264
  else if (!alone) {
211
265
  if (st.op !== 'SELECT' && st.op !== 'EXCLUDE') out = null;
212
266
  continue;
@@ -227,35 +281,103 @@ function iebcopyCopies(step, dds) {
227
281
  // each OUTDDNAME, up to 255 of them. RESTORE reads the dump data set INDDNAME names and writes the
228
282
  // OUTDDNAME volumes. With no input DD a logical dump chooses its datasets by filter, and with no
229
283
  // output DD a logical restore puts them where the catalogue says, neither of which the job shows.
284
+ // COPY reads the volumes INDDNAME or LOGINDDNAME name and writes the DASD volumes OUTDDNAME names.
230
285
  // https://www.ibm.com/docs/en/zos/3.1.0?topic=commands-command-syntax
231
286
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.adru000/r2319.htm
232
287
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.adru000/r2321.htm
233
288
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.adru000/r2327.htm
234
289
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.adru000/dgt3u2165.htm
235
290
  // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.adru000/dgt3u2174.htm
291
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.adru000/r2172.htm
292
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.adru000/r2204.htm
293
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.adru000/r2206.htm
294
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.adru000/r2216.htm
236
295
  function dssCopies(step, dds) {
237
296
  const { lines, unread } = cards(dds.get('SYSIN'));
238
- const notes = unread ? [{ line: step.line, text: `${who(step)} runs ADRDSSU with commands in ${unread}, which this reader cannot see, so what it dumps or restores is not known` }] : [];
297
+ const notes = unread ? [{ line: step.line, text: `${who(step)} runs ADRDSSU with commands in ${unread}, which this reader cannot see, so what it dumps, restores or copies is not known` }] : [];
239
298
  const moves = [];
240
299
  for (const cmd of commands(lines)) {
241
- const op = /^(DUMP|RESTORE)\b/i.exec(cmd.text);
300
+ const op = /^(DUMP|RESTORE|COPY)\b/i.exec(cmd.text);
242
301
  if (!op) continue;
243
- const dump = upper(op[1]) === 'DUMP';
244
- const ins = param(cmd.text, ['INDDNAME', 'INDD', 'IDD']) || (dump && param(cmd.text, ['LOGINDDNAME', 'LOGINDD', 'LIDD'])) || [];
302
+ const verb = upper(op[1]);
303
+ const dump = verb === 'DUMP';
304
+ const ins = param(cmd.text, ['INDDNAME', 'INDD', 'IDD']) || (verb !== 'RESTORE' && param(cmd.text, ['LOGINDDNAME', 'LOGINDD', 'LIDD'])) || [];
245
305
  const outs = param(cmd.text, ['OUTDDNAME', 'OUTDD', 'ODD']) || [];
246
306
  if (dump && !ins.length) notes.push({ line: cmd.line, text: `${who(step)} dumps datasets chosen by filter from the catalogue, so which ones it copies is not known here` });
247
- if (!dump && !outs.length) notes.push({ line: cmd.line, text: `${who(step)} restores datasets to where the catalogue puts them, so what it writes is not known here` });
307
+ if (verb === 'COPY' && !ins.length) notes.push({ line: cmd.line, text: `${who(step)} copies datasets whose input volume the command names no DD for, so where they come from is not known here` });
308
+ if (verb === 'COPY' && !outs.length) notes.push({ line: cmd.line, text: `${who(step)} copies datasets to an output volume the command names no DD for, so what it writes is not known here` });
309
+ if (verb === 'RESTORE' && !outs.length) notes.push({ line: cmd.line, text: `${who(step)} restores datasets to where the catalogue puts them, so what it writes is not known here` });
248
310
  moves.push({ from: ins.map((dd) => ({ dd })), to: outs.map((dd) => ({ dd })), line: cmd.line });
249
311
  }
250
312
  return { moves, notes };
251
313
  }
252
314
 
315
+ // ICETOOL runs the operators in TOOLIN. COPY, SORT and MERGE read the DDs their FROM operands name
316
+ // and write the DDs their TO operands name, several ddnames to an operand. A statement's operands
317
+ // continue on the next line after a hyphen, and what follows the hyphen is ignored. USING(xxxx) reads
318
+ // DFSORT control statements from the xxxxCNTL DD, whose OUTFIL statements can write further data sets.
319
+ // https://www.ibm.com/docs/en/zos/3.1.0?topic=icetool-copy-operator
320
+ // https://www.ibm.com/docs/en/zos/3.1.0?topic=icetool-sort-operator
321
+ // https://www.ibm.com/docs/en/zos/3.1.0?topic=icetool-merge-operator
322
+ // https://www.ibm.com/docs/en/zos/2.3.0?topic=icetool-job-control-language
323
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.icea100/ice2ca_General_coding_rules.htm
324
+ function icetoolCopies(step, dds) {
325
+ const { lines, unread } = cards(dds.get('TOOLIN'));
326
+ const notes = unread ? [{ line: step.line, text: `${who(step)} runs ICETOOL with statements in ${unread}, which this reader cannot see, so what it copies is not known` }] : [];
327
+ const statements = [];
328
+ let cur = null;
329
+ for (const { line, text } of lines) {
330
+ const s = statementCard(text).text;
331
+ if (!cur && (/^\*/.test(s) || !s.trim())) continue;
332
+ const tokens = s.trim().split(/\s+/);
333
+ const hyphen = tokens.findIndex((t) => t.endsWith('-'));
334
+ const used = hyphen < 0 ? tokens : tokens.slice(0, hyphen + 1);
335
+ if (!cur) cur = { line, op: upper(used.shift()), operands: [] };
336
+ cur.operands.push(...used.map((t) => t.replace(/-$/, '')).filter(Boolean));
337
+ if (hyphen < 0) { statements.push(cur); cur = null; }
338
+ }
339
+ if (cur) statements.push(cur);
340
+ const moves = [];
341
+ for (const st of statements) {
342
+ if (!['COPY', 'SORT', 'MERGE'].includes(st.op)) continue;
343
+ const named = (kw) => st.operands.flatMap((o) => {
344
+ const m = new RegExp(`^${kw}\\((.*)\\)$`, 'i').exec(o);
345
+ return m ? m[1].split(',').map((n) => upper(n.trim())).filter(Boolean) : [];
346
+ });
347
+ moves.push({ from: named('FROM').map((dd) => ({ dd })), to: named('TO').map((dd) => ({ dd })), line: st.line });
348
+ for (const using of named('USING')) {
349
+ const control = cards(dds.get(`${using}CNTL`));
350
+ if (control.unread || utilityStatements(control.lines, (ops) => /[,;:]$/.test(ops)).some((c) => c.op === 'OUTFIL')) {
351
+ notes.push({ line: st.line, text: `${who(step)} runs ICETOOL ${st.op} with control statements in ${using}CNTL that may write OUTFIL data sets, which are not followed here` });
352
+ }
353
+ }
354
+ }
355
+ return { moves, notes };
356
+ }
357
+
358
+ // An FTP step's transfers, from the session lib/ftp.mjs reads: what PUt, MPut and APpend send leaves
359
+ // for the remote host, and what Get and MGet fetch is written locally. The remote end names the host
360
+ // and file, and the FILETYPE a SIte gave it.
361
+ function ftpCopies(step, dds) {
362
+ const s = ftpSession(step, dds);
363
+ const localEnds = (x) => (x.dd ? [{ dd: x.dd }] : x.dsns.map((dsn) => ({ dsn })));
364
+ const remote = (name, x) => ({ remote: { host: s.host, name, ...(x.filetype ? { filetype: x.filetype } : {}) } });
365
+ const moves = [
366
+ ...s.sends.map((x) => ({ from: localEnds(x), to: [remote(x.foreign, x)], line: x.line })),
367
+ ...s.gets.map((x) => ({ from: [remote(x.name, x)], to: localEnds(x), line: x.line })),
368
+ ].sort((a, b) => a.line - b.line);
369
+ const notes = s.inputUnread ? [{ line: step.line, text: `${who(step)} runs FTP with subcommands in ${s.inputUnread}, which this reader cannot see, so what it sends or fetches is not known` }] : [];
370
+ return { moves, notes };
371
+ }
372
+
253
373
  const TABLE = [
254
- [['IEBGENER', 'ICEGENER'], generCopy],
374
+ [['IEBGENER', 'ICEGENER', 'IEBPTPCH', 'IEBUPDTE'], generCopy],
255
375
  [['SORT', 'ICEMAN', 'DFSORT', 'SYNCSORT'], sortCopies],
256
376
  [['IDCAMS'], reproCopies],
257
377
  [['IEBCOPY'], iebcopyCopies],
258
378
  [['ADRDSSU'], dssCopies],
379
+ [['ICETOOL'], icetoolCopies],
380
+ [['FTP'], ftpCopies],
259
381
  ];
260
382
  const BY_PROGRAM = new Map(TABLE.flatMap(([names, fn]) => names.map((n) => [n, fn])));
261
383
 
@@ -274,7 +396,7 @@ export function copiesOf(step) {
274
396
  // An end naming a DD stands for every dataset concatenated under that name. A DD the step does
275
397
  // not define is kept, with no dataset: a statement naming it still says where the data went.
276
398
  const expand = (end) => {
277
- if (!end.dd) return [{ dd: null, dsn: end.dsn }];
399
+ if (!end.dd) return [{ dd: null, dsn: end.dsn ?? null, ...(end.remote ? { remote: end.remote } : {}) }];
278
400
  const group = dds.get(end.dd);
279
401
  if (!group) return [{ dd: end.dd, dsn: null }];
280
402
  return group.map((dd) => ({ dd: end.dd, dsn: dd.dsn || null, ...(dd.inStream ? { inStream: true } : {}) }));
@@ -294,3 +416,95 @@ export function copiesOf(step) {
294
416
  }
295
417
  return { copies, uses, notes };
296
418
  }
419
+
420
+ // A TSO command's operands: KEYWORD(value) with the value's parentheses and quotes kept, a quoted
421
+ // string, or a bare word, split on blanks and commas. Blanks may stand between a keyword and its
422
+ // parenthesis, as in PROGRAM (GETTAB).
423
+ function tsoOperands(text) {
424
+ const out = [];
425
+ let i = 0;
426
+ while (i < text.length) {
427
+ if (/[\s,]/.test(text[i])) { i++; continue; }
428
+ const start = i;
429
+ let depth = 0, quoted = false;
430
+ for (; i < text.length; i++) {
431
+ const c = text[i];
432
+ if (c === "'") quoted = !quoted;
433
+ else if (quoted) continue;
434
+ else if (c === '(') depth++;
435
+ else if (c === ')') depth = Math.max(0, depth - 1);
436
+ else if (depth === 0 && /[\s,]/.test(c)) {
437
+ const next = /^\s*\(/.exec(text.slice(i));
438
+ if (next && /^[A-Z][A-Z0-9$#@]*$/i.test(text.slice(start, i))) { text = text.slice(0, i) + text.slice(i + next[0].length - 1); i--; continue; }
439
+ break;
440
+ }
441
+ }
442
+ const tok = text.slice(start, i);
443
+ const kw = /^([A-Z][A-Z0-9$#@]*)\((.*)\)$/is.exec(tok);
444
+ out.push(kw ? { key: upper(kw[1]), value: kw[2] } : { word: tok });
445
+ }
446
+ return out;
447
+ }
448
+
449
+ const unquote = (s) => s.replace(/^'(.*)'$/s, '$1').replace(/''/g, "'");
450
+
451
+ // ALLOCATE's keywords, abbreviated as far as IBM's rule allows: to any leading part that no other
452
+ // ALLOCATE keyword shares. FILE shares F, FI and FIL with FILEDATA, FCB, FLASH and FORMS, and DSNAME
453
+ // shares DS and DSN with DSNTYPE and DSORG.
454
+ const ALLOC_DATASET = /^(DA|DAT|DATA|DATAS|DATASE|DATASET|DSNA|DSNAM|DSNAME)$/;
455
+ const ALLOC_FILE = /^(FILE|DD|DDN|DDNA|DDNAM|DDNAME)$/;
456
+ const ALLOC_STATUS = new Set(['OLD', 'SHR', 'MOD', 'NEW']);
457
+
458
+ // What a TSO batch step (IKJEFT01, IKJEFT1A, IKJEFT1B) does in its commands, from the step's PARM,
459
+ // which TSO runs as its first command, and the in-stream SYSTSIN:
460
+ // allocations: ALLOCATE's DDs, which the programs the step runs open as if the JCL defined them.
461
+ // A data set name in quotes is the whole name; one without has the user's prefix added, which
462
+ // the job does not say, so it is kept as written with no dsn.
463
+ // runs: the programs the step starts, by TSO CALL, whose parameter string reaches the program as
464
+ // EXEC PARM does, and by the DSN subcommand RUN PROGRAM, with PARMS.
465
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.1.0/com.ibm.zos.v2r1.ikjc500/allocsyn.htm
466
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.2.0/com.ibm.zos.v3r2.ikjc500/alloccmd.htm
467
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.ikjp100/ikjp100114.htm
468
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.5.0/com.ibm.zos.v2r5.ikjc200/ikj2o20039.htm
469
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.ikjc500/ikj2l2_CALL_command_operands.htm
470
+ // https://www.ibm.com/docs/api/v1/content/SSEPEK_13.0.0/comref/src/tpc/db2z_cmd_run.html
471
+ export const TSO_BATCH = new Set(['IKJEFT01', 'IKJEFT1A', 'IKJEFT1B']);
472
+
473
+ export function tsoCommands(step) {
474
+ const out = { allocations: [], runs: [], notes: [] };
475
+ if (!step.pgm || !TSO_BATCH.has(upper(step.pgm))) return out;
476
+ const { lines, unread } = cards(ddGroups(step).get('SYSTSIN'));
477
+ if (unread) out.notes.push({ line: step.line, text: `${who(step)} runs TSO with commands in ${unread}, which this reader cannot see, so any ALLOCATE, CALL or RUN it runs is not known` });
478
+ // PARM keeps JCL's doubled apostrophes; TSO sees one.
479
+ const cmds = [...(step.parm ? [{ line: step.line, text: step.parm.replace(/''/g, "'") }] : []), ...commands(lines)];
480
+ for (const cmd of cmds) {
481
+ const verb = upper(/^\S*/.exec(cmd.text)[0]);
482
+ const ops = tsoOperands(cmd.text.slice(verb.length));
483
+ if (verb === 'ALLOC' || verb === 'ALLOCATE') {
484
+ const file = ops.find((o) => o.key && ALLOC_FILE.test(o.key));
485
+ if (!file) continue;
486
+ const names = ops.find((o) => o.key && ALLOC_DATASET.test(o.key));
487
+ const words = ops.filter((o) => o.word).map((o) => upper(o.word));
488
+ const sysout = ops.find((o) => o.key === 'SYSOUT') || (words.includes('SYSOUT') ? { value: '' } : null);
489
+ const datasets = names ? tsoOperands(names.value).map((o) => o.word).filter((w) => w && w !== '*') : [];
490
+ if (!datasets.length && !sysout) continue;
491
+ out.allocations.push({
492
+ dd: upper(unquote(file.value)), line: cmd.line,
493
+ status: words.find((w) => ALLOC_STATUS.has(w)) || null,
494
+ sysout: sysout ? upper(sysout.value) || '*' : null,
495
+ datasets: datasets.map((w) => ({ written: w, dsn: /^'.*'$/.test(w) ? upper(unquote(w)) : null })),
496
+ });
497
+ } else if (verb === 'CALL') {
498
+ const target = ops[0]?.word || (ops[0]?.key ? `${ops[0].key}(${ops[0].value})` : '');
499
+ const member = /\(([A-Z0-9$#@]{1,8})\)'?$/i.exec(target);
500
+ const parm = ops[1]?.word && /^'.*'$/s.test(ops[1].word) ? unquote(ops[1].word) : null;
501
+ out.runs.push({ program: member ? upper(member[1]) : 'TEMPNAME', parm, line: cmd.line, via: 'TSO CALL' });
502
+ } else if (verb === 'RUN') {
503
+ const program = ops.find((o) => o.key === 'PROGRAM');
504
+ if (!program || !/^[A-Z0-9$#@]{1,8}$/i.test(program.value.trim())) continue;
505
+ const parms = ops.find((o) => o.key === 'PARMS');
506
+ out.runs.push({ program: upper(program.value.trim()), parm: parms ? unquote(parms.value.trim()) : null, line: cmd.line, via: 'DSN RUN' });
507
+ }
508
+ }
509
+ return out;
510
+ }
package/lib/verify.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // A verification plan for one path finding, for the estate to run in a test region it owns: the
3
+ // entry to start, where the value goes in, a harmless value that shows the defect, and what to watch
4
+ // for. Where letting the operation run would act - a command, a statement, a job, a connection - the
5
+ // plan stops at the operation and reads a marker there, and never supplies a value that would run.
6
+ // It is built only for `cobolwork explain`, never for a scan report. See docs/spec/reach.md §8.
7
+ import { kindsOf } from './consequence.mjs';
8
+ import { ALL_RULES } from './kernel/registry.mjs';
9
+ import { entryName } from './reach.mjs';
10
+
11
+ export const MARKER = 'CWVRFY01';
12
+
13
+ const WHERE = 'a test region the estate owns, holding test data, under its own authorisation: never a production region, and never a system the estate does not own';
14
+
15
+ const ABSENT = `${MARKER}, after checking that nothing by that name is defined where the program looks`;
16
+ const END_TASK = 'end the task from CEDF or CEDX, or stop the program in the debugger, before the operation runs';
17
+
18
+ // What goes in, and what reading it at the operation shows. `run` says what letting the operation run
19
+ // shows; null means it would act on something, so the plan stops before it (`stopBefore`).
20
+ const SINKS = {
21
+ arithmetic: (x) => ({ value: `a letter where ${x.item} expects a digit, such as A in its first position`, reads: `${x.item} holding the letter`, run: 'the task abends ASRA, a data exception (S0C7 in batch), at the operation' }),
22
+ subscript: (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}, or 0`),
23
+ 'loop-bound': (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}`),
24
+ 'reference-modification': (x) => bounds(x, 'one more than the length of the field, or 0'),
25
+ 'occurs-depending-count': (x) => bounds(x, 'one more than the most entries the OCCURS clause allows, or 0'),
26
+ 'storage-length': (x) => ({ value: 'a length above what the program means to acquire', reads: `the LENGTH or FLENGTH the command is given, from ${x.item}`, run: null, stopBefore: `acquiring that much storage can take the region short on storage: ${END_TASK}` }),
27
+ 'record-key': (x) => ({ value: 'the key of a test record set up for a different test user', reads: `${x.item} holding that key`, run: 'the other user\'s test record comes back to the user who asked for it' }),
28
+ 'record-update': (x) => ({ value: 'the key of a test record set up for a different test user', reads: `${x.item} holding that key`, run: 'the other user\'s test record is changed or deleted: set the record up for this test alone' }),
29
+ 'dynamic-program-load': (x) => ({ value: ABSENT, reads: `${MARKER} as the program the CALL loads, from ${x.item}`, run: `the CALL fails to load ${MARKER} (an S806 abend in batch)` }),
30
+ 'cics-dynamic-transfer': (x) => ({ value: ABSENT, reads: `${MARKER} as the program or transaction the command names, from ${x.item}`, run: `the command fails with PGMIDERR, or TRANSIDERR for a START` }),
31
+ 'dynamic-file-path': (x) => ({ value: ABSENT, reads: `${MARKER} as the file the program assigns, from ${x.item}`, run: 'the OPEN fails with file status 35' }),
32
+ 'cics-sysid': (x) => ({ value: ABSENT, reads: `${MARKER} as the SYSID the command is shipped to, from ${x.item}`, run: 'the command fails with SYSIDERR' }),
33
+ 'queue-name': (x) => marker(x, 'as the queue the command acts on', 'the command would act on a queue the input chose'),
34
+ 'web-response': (x) => ({ value: `${MARKER}<>`, reads: `${MARKER}<> in ${x.item}`, run: `the response body carries ${MARKER}<> as sent, not ${MARKER}&lt;&gt;` }),
35
+ screen: (x) => ({ value: MARKER, reads: `${MARKER} in ${x.item}`, run: `${MARKER} on the screen` }),
36
+ log: (x) => ({ value: MARKER, reads: `${MARKER} in ${x.item}`, run: `${MARKER} in the log as sent, which shows a line break would go in the same way` }),
37
+ 'http-header': (x) => marker(x, 'in the header value', 'the header would go to the caller'),
38
+ 'os-command': (x) => marker(x, 'in the command', 'the command would run'),
39
+ 'dynamic-sql': (x) => marker(x, 'in the statement text', 'the statement would run'),
40
+ 'internal-reader': (x) => marker(x, 'in the record written', 'the job would be submitted'),
41
+ 'cics-system-resource': (x) => marker(x, 'as the resource the SET names', 'the command would change a region resource'),
42
+ 'connection-target': (x) => marker(x, 'as the database or queue manager named', 'the connection would be made'),
43
+ 'outbound-host': (x) => marker(x, 'as the host or path of the request', 'the request would go out'),
44
+ 'outbound-http': (x) => marker(x, 'in what the request sends', 'the request would go out'),
45
+ 'socket-send': (x) => marker(x, 'in what the socket sends', 'the data would leave the program'),
46
+ 'message-queue': (x) => marker(x, 'in the message put', 'the message would be put'),
47
+ 'extrapartition-queue': (x) => marker(x, 'in the record written', 'the record would leave the region through its DD'),
48
+ 'xml-document': (x) => marker(x, 'in the document parsed', 'the parser would read it'),
49
+ };
50
+
51
+ function bounds(x, value) {
52
+ return {
53
+ value,
54
+ reads: `${x.item} out of range at the operation`,
55
+ ...(x.ssrange ? { run: 'the task abends with a Language Environment range message (IGZ0006S for a subscript), since the program is compiled with SSRANGE' }
56
+ : { run: null, stopBefore: `without SSRANGE the operation reaches the storage beside the table or field: ${END_TASK}` }),
57
+ };
58
+ }
59
+
60
+ function marker(x, where, acts) {
61
+ return { value: MARKER, reads: `${MARKER} ${where}, from ${x.item}`, run: null, stopBefore: `${acts}: ${END_TASK}` };
62
+ }
63
+
64
+ // Where the value goes in, by where the finding says the input comes from.
65
+ function enterAt(source, f, src, loc) {
66
+ const field = f.screen ? `field ${f.screen.field} of map ${f.screen.map}${f.screen.mapset ? ` in mapset ${f.screen.mapset}` : ''}` : null;
67
+ switch (source) {
68
+ case 'cics-terminal': return field ? `${field}, received at ${loc}` : `the terminal input received at ${loc}`;
69
+ case 'cics-protected-field': return `${field || 'the protected field'}: the map protects it, so change it under CEDF in the data the RECEIVE MAP at ${loc} returns, not with a modified client`;
70
+ case 'cics-web': return `the web request read at ${loc}`;
71
+ case 'argv-or-env': return `the command line or environment of the run, read at ${loc}`;
72
+ case 'jcl-parm': return `${src?.detail || 'the PARM'}, in a copy of the job`;
73
+ case 'jcl-instream': return `${src?.detail || 'the in-stream data'}, in a copy of the job`;
74
+ case 'file-record': return `a test record in a test copy of the file read at ${loc}`;
75
+ case 'database': return `a test row in a test copy of the table read at ${loc}`;
76
+ default: return null;
77
+ }
78
+ }
79
+
80
+ // CEDF and CEDX stop at EXEC CICS and EXEC SQL; a CALL, a COMPUTE or a subscript needs a debugger.
81
+ function toolFor(source, entries, program) {
82
+ const tran = entries.find((e) => e.transaction)?.transaction;
83
+ const debug = 'or a debugger such as z/OS Debugger where the operation is not an EXEC command';
84
+ if (source === 'cics-web') return `CEDX ${tran || `on the transaction that runs ${program}`}, since a web request has no terminal, ${debug}`;
85
+ if (tran || source === 'cics-terminal' || source === 'cics-protected-field') return `CEDF at the terminal that runs ${tran || program}, ${debug}`;
86
+ return 'a debugger such as z/OS Debugger';
87
+ }
88
+
89
+ export function verificationPlan(f) {
90
+ const kinds = ALL_RULES[f.rule]?.evidence === 'path' ? kindsOf(f.rule) : null;
91
+ if (!kinds || !SINKS[kinds.sink]) return null;
92
+ const entries = f.startedBy || [];
93
+ const src = f.related?.[0];
94
+ const loc = src ? `${src.path}:${src.line}` : 'the source';
95
+ const last = f.trace?.length ? f.trace[f.trace.length - 1] : null;
96
+ const table = Number(/a table of (\d+)$/.exec(f.detail || '')?.[1]) || null;
97
+ const x = { item: last?.item || 'the value', table, ssrange: !!f.ssrange };
98
+ const sink = `${f.path}:${f.line}`;
99
+ const tool = toolFor(kinds.source, entries, f.program || 'the program');
100
+ const start = entries.length
101
+ ? `${entries.map(entryName).join('; ')}${f.startedByMore ? `; or one of ${f.startedByMore} more not listed` : ''}`
102
+ : `nothing in this tree starts ${f.program || 'the program'}: start it the way the estate does`;
103
+
104
+ if (kinds.source === 'system-response') {
105
+ return {
106
+ where: WHERE, start, tool,
107
+ enter: `nothing: provoke the failure the program reports at ${loc}, such as a key that matches no test record`,
108
+ value: null,
109
+ observe: `stop at ${sink} and read ${x.item}: the response code or error text is in it`,
110
+ run: 'the code or error text reaches the caller in what the program sends back',
111
+ record: record(f),
112
+ };
113
+ }
114
+
115
+ const s = SINKS[kinds.sink](x);
116
+ return {
117
+ where: WHERE, start, tool,
118
+ enter: enterAt(kinds.source, f, src, loc),
119
+ value: s.value,
120
+ observe: `stop at ${sink} and read ${s.reads}: that is the defect, whatever the operation does next`,
121
+ run: s.run,
122
+ ...(s.stopBefore ? { stopBefore: s.stopBefore } : {}),
123
+ ...(f.guardedFrom && f.guard ? { check: `if the check on ${f.guard.item} at ${f.guard.file}:${f.guard.line} turns the value away first, record not-reproduced: the check stops it, and the finding can be judged a false positive` } : {}),
124
+ record: record(f),
125
+ };
126
+ }
127
+
128
+ const record = (f) => `bring the result in a COBOLWORK_WITNESS feed under "${f.fingerprint}": outcome "reproduced" or "not-reproduced", who ran it, the date and the system; what was entered is not recorded there`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.2.76",
3
+ "version": "0.2.140",
4
4
  "description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Portll <john@portll.net>",