@portll/cobolwork 0.0.1 → 0.2.76

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSING.md +93 -0
  3. package/NOTICE +9 -0
  4. package/README.md +325 -3
  5. package/THIRD-PARTY-NOTICES.md +118 -0
  6. package/bin/cobolwork.mjs +354 -0
  7. package/lib/advisories.mjs +133 -0
  8. package/lib/baseline.mjs +154 -0
  9. package/lib/bms.mjs +453 -0
  10. package/lib/build.mjs +402 -0
  11. package/lib/capabilities.mjs +79 -0
  12. package/lib/cics-commands.mjs +281 -0
  13. package/lib/compliance.mjs +81 -0
  14. package/lib/consequence.mjs +139 -0
  15. package/lib/control.mjs +1515 -0
  16. package/lib/csd.mjs +77 -0
  17. package/lib/dataflow.mjs +1506 -0
  18. package/lib/diff.mjs +344 -0
  19. package/lib/explain.mjs +145 -0
  20. package/lib/gate.mjs +383 -0
  21. package/lib/index.mjs +6 -0
  22. package/lib/inventory.mjs +79 -0
  23. package/lib/jcl.mjs +478 -0
  24. package/lib/kernel/findings.mjs +94 -0
  25. package/lib/kernel/identity.mjs +216 -0
  26. package/lib/kernel/memory.mjs +217 -0
  27. package/lib/kernel/printable.mjs +6 -0
  28. package/lib/kernel/registry.mjs +79 -0
  29. package/lib/kernel/ruleset.mjs +72 -0
  30. package/lib/kernel/source-tree.mjs +159 -0
  31. package/lib/kev.mjs +27 -0
  32. package/lib/options.mjs +512 -0
  33. package/lib/packs.mjs +148 -0
  34. package/lib/parser.mjs +2055 -0
  35. package/lib/policy.mjs +163 -0
  36. package/lib/precompile-cics.mjs +169 -0
  37. package/lib/precompile.mjs +544 -0
  38. package/lib/reach.mjs +122 -0
  39. package/lib/revision.json +1 -0
  40. package/lib/revision.mjs +89 -0
  41. package/lib/sarif.mjs +222 -0
  42. package/lib/scan.mjs +272 -0
  43. package/lib/sets/build.mjs +234 -0
  44. package/lib/sets/cics.mjs +306 -0
  45. package/lib/sets/compile.mjs +187 -0
  46. package/lib/sets/copybook.mjs +174 -0
  47. package/lib/sets/flow.mjs +487 -0
  48. package/lib/sets/hidden.mjs +216 -0
  49. package/lib/sets/jcl.mjs +440 -0
  50. package/lib/sets/log.mjs +406 -0
  51. package/lib/sets/opaque.mjs +102 -0
  52. package/lib/sets/priv.mjs +322 -0
  53. package/lib/sets/recon.mjs +267 -0
  54. package/lib/sets/vendor.mjs +117 -0
  55. package/lib/sets/web.mjs +327 -0
  56. package/lib/site.mjs +164 -0
  57. package/lib/sources.mjs +156 -0
  58. package/lib/tui/app.mjs +325 -0
  59. package/lib/tui/keys.mjs +39 -0
  60. package/lib/tui/model.mjs +96 -0
  61. package/lib/tui/run.mjs +38 -0
  62. package/lib/tui/screen.mjs +59 -0
  63. package/lib/tui/terminal.mjs +46 -0
  64. package/lib/utilities.mjs +296 -0
  65. package/lib/version.mjs +15 -0
  66. package/lib/words.mjs +318 -0
  67. package/package.json +45 -6
  68. package/rules/advisories.json +264 -0
  69. package/rules/compliance-dora.json +2151 -0
  70. package/rules/compliance-ffiec.json +2134 -0
  71. package/rules/compliance-nist80053.json +2134 -0
  72. package/rules/gitleaks-mainframe.toml +57 -0
  73. package/rules/kev-ids.json +1729 -0
  74. package/rules/packs/broadcom.json +124 -0
  75. package/rules/packs/connectdirect.json +116 -0
  76. package/rules/packs/controlm.json +114 -0
  77. package/rules/system-layouts.json +28 -0
  78. package/schema/cobolwork-coverage.schema.json +65 -0
  79. package/schema/cobolwork.policy.schema.json +54 -0
package/lib/jcl.mjs ADDED
@@ -0,0 +1,478 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // JCL, read the way the reader does: statements folded across continuations, in-stream data
3
+ // bounded by its own delimiter, symbolic parameters substituted, and every step tied to the
4
+ // program it runs.
5
+ //
6
+ // Most of the mainframe's privileged surface is here rather than in COBOL. A COPY moves a record
7
+ // layout; a JCL step decides which program runs, with what parameter, against which dataset, under
8
+ // whose authority. Until this existed the flow engine began at the program boundary, one level
9
+ // below the actual entry point.
10
+ //
11
+ // What it will not do is guess. A statement it cannot read is reported in `diags` and kept in the
12
+ // output as `unreadable`, never dropped, because a step nobody parsed is not a step that is not
13
+ // there.
14
+ import { readSource } from './sources.mjs';
15
+ import { copiesOf } from './utilities.mjs';
16
+
17
+ // The operations a statement may carry. Anything else in the operation field is a statement this
18
+ // reader does not know, which is a diagnostic rather than a silent skip.
19
+ export const OPERATIONS = new Set(['JOB', 'EXEC', 'DD', 'PROC', 'PEND', 'SET', 'IF', 'THEN',
20
+ 'ELSE', 'ENDIF', 'INCLUDE', 'JCLLIB', 'OUTPUT', 'CNTL', 'ENDCNTL', 'XMIT', 'COMMAND', 'NOTIFY', 'EXPORT']);
21
+
22
+ // Columns 73 to 80 are the sequence area. They are not the statement, and a payload hidden there
23
+ // is the hidden-content rules' business, not this reader's.
24
+ const STATEMENT_COLUMNS = 72;
25
+
26
+ // A continued operand resumes in these columns. Outside them it is not a continuation, whatever
27
+ // the previous line ended with.
28
+ const CONTINUE_FROM = 4;
29
+ const CONTINUE_TO = 16;
30
+
31
+ const NAME = /^[A-Z$#@][A-Z0-9$#@]{0,7}$/;
32
+
33
+ // DDs that follow the JOB statement and serve every step, before any EXEC.
34
+ const JOB_LEVEL_DDS = new Set(['JOBLIB', 'JOBCAT']);
35
+
36
+ // Values the system supplies when a job is converted, which no repository can hold. JES sets
37
+ // &SYSUID to the user the job runs under (z/OS JES application programming, "JES system symbols");
38
+ // the rest are z/OS's static and dynamic system symbols (z/OS 3.1 MVS Initialization and Tuning
39
+ // Reference, "Static system symbols" and "Dynamic system symbols").
40
+ const SYSTEM_SYMBOLS = new Set(`SYSUID SYSALVL SYSCLONE SYSNAME SYSOSLVL SYSPLEX SYSR1
41
+ YYMMDD LYYMMDD HHMMSS LHHMMSS DAY HR MIN SEC JDAY MON YR2 YR4 WDAY
42
+ LDATE LDAY LHR LMIN LSEC LJDAY LMON LYR2 LYR4 LWDAY LTIME JOBNAME DS SEQ DATE TIME`.split(/\s+/));
43
+
44
+ // Splits an operand field on commas that are not inside parentheses or quotes. JCL nests both, and
45
+ // a naive split on comma turns DISP=(NEW,CATLG,DELETE) into three operands.
46
+ export function splitOperands(s) {
47
+ const out = [];
48
+ let depth = 0, quoted = false, start = 0;
49
+ for (let i = 0; i < s.length; i++) {
50
+ const c = s[i];
51
+ if (quoted) { if (c === "'") { if (s[i + 1] === "'") i++; else quoted = false; } continue; }
52
+ if (c === "'") quoted = true;
53
+ else if (c === '(') depth++;
54
+ else if (c === ')') depth = Math.max(0, depth - 1);
55
+ else if (c === ',' && depth === 0) { out.push(s.slice(start, i)); start = i + 1; }
56
+ }
57
+ out.push(s.slice(start));
58
+ return out.filter((x, i, a) => x.length || i < a.length - 1);
59
+ }
60
+
61
+ // Operands are positional until the first KEY=VALUE, and keyword after it. Both forms matter:
62
+ // EXEC takes its procedure name positionally, and everything interesting on DD is a keyword.
63
+ export function parseOperands(field) {
64
+ const positional = [];
65
+ const keywords = new Map();
66
+ for (const raw of splitOperands(field)) {
67
+ const part = raw.trim();
68
+ if (!part) continue;
69
+ const eq = keywordSplit(part);
70
+ if (eq < 0) positional.push(part);
71
+ else keywords.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
72
+ }
73
+ return { positional, keywords };
74
+ }
75
+
76
+ // The first '=' that is not inside parentheses or quotes. DCB=(RECFM=FB,LRECL=80) is one keyword
77
+ // whose value happens to contain more of them.
78
+ function keywordSplit(part) {
79
+ let depth = 0, quoted = false;
80
+ for (let i = 0; i < part.length; i++) {
81
+ const c = part[i];
82
+ if (quoted) { if (c === "'") { if (part[i + 1] === "'") i++; else quoted = false; } continue; }
83
+ if (c === "'") quoted = true;
84
+ else if (c === '(') depth++;
85
+ else if (c === ')') depth = Math.max(0, depth - 1);
86
+ else if (c === '=' && depth === 0) return i;
87
+ }
88
+ return -1;
89
+ }
90
+
91
+ // Symbolic substitution. &NAME ends at a non-name character, and a trailing dot is a separator
92
+ // that is consumed rather than kept: &PREFIX..DATA resolves to <prefix>.DATA.
93
+ export function substitute(text, symbols) {
94
+ if (!text.includes('&')) return { text, unresolved: [] };
95
+ const unresolved = [];
96
+ const out = text.replace(/&&|&([A-Z$#@][A-Z0-9$#@]{0,7})\.?/gi, (m, name) => {
97
+ if (m === '&&') return '&&'; // && is a literal ampersand, not a symbol
98
+ const key = name.toUpperCase();
99
+ if (!symbols.has(key)) { unresolved.push(key); return m; }
100
+ return symbols.get(key);
101
+ });
102
+ return { text: out, unresolved };
103
+ }
104
+
105
+ // The operand field ends at the first blank outside apostrophes; what follows is a comment, and a
106
+ // field ending in a comma continues whatever the comment says. An IF condition may hold blanks.
107
+ const BLANKS_IN_OPERANDS = new Set(['IF', 'THEN', 'ELSE', 'ENDIF']);
108
+ function operandField(s) {
109
+ let quoted = false;
110
+ for (let i = 0; i < s.length; i++) {
111
+ if (s[i] === "'") quoted = !quoted;
112
+ else if (s[i] === ' ' && !quoted) return s.slice(0, i);
113
+ }
114
+ return s;
115
+ }
116
+
117
+ // Folds physical lines into logical statements. Continuation is the part naive readers get wrong,
118
+ // and a payload split across a continuation boundary is exactly what hiding in JCL looks like.
119
+ export function foldStatements(src) {
120
+ // A DOS end-of-file byte (Ctrl-Z) closes files written on a PC.
121
+ const phys = src.replace(/\r\n?/g, '\n').replace(/\x1a[\s\x1a]*$/, '').split('\n');
122
+ const statements = [];
123
+ const diags = [];
124
+ let open = null; // the statement being continued
125
+ let stream = null; // the DD statement currently collecting in-stream data
126
+ let endedByComment = null; // a DD whose in-stream data a comment statement ended
127
+
128
+ // A statement is only complete once its continuations are in, and only then can it be known to
129
+ // open a data stream. Both happen here so that there is one place that decides what a line is.
130
+ const push = (st) => {
131
+ statements.push(st);
132
+ if (st.comments) { statements.push(...st.comments); delete st.comments; }
133
+ if (st.kind !== 'statement' || st.operation !== 'DD') return;
134
+ const { positional, keywords } = parseOperands(st.field);
135
+ if (!positional.some((p) => p === '*' || p.toUpperCase() === 'DATA')) return;
136
+ const dlm = keywords.get('DLM');
137
+ st.dlm = dlm ? dlm.replace(/^'|'$/g, '') : null;
138
+ st.inStream = [];
139
+ stream = st;
140
+ // A non-default delimiter conceals what follows from anything reading JCL line by line, which
141
+ // is worth seeing on its own.
142
+ if (st.dlm) diags.push({ sev: 'info', line: st.line, text: `in-stream data uses DLM=${st.dlm}, so it does not end at /*` });
143
+ };
144
+
145
+ for (let i = 0; i < phys.length; i++) {
146
+ const line = i + 1;
147
+ const raw = phys[i];
148
+ if (raw.length > 80) diags.push({ sev: 'warn', line, text: 'line is longer than 80 columns' });
149
+ const text = raw.slice(0, STATEMENT_COLUMNS).replace(/\s+$/, '');
150
+ const sequence = raw.slice(STATEMENT_COLUMNS);
151
+
152
+ if (stream) {
153
+ // A custom DLM ends the stream and nothing else does. The default ends at /* or at the next
154
+ // statement, which is why a job that omits its /* still parses.
155
+ const ends = stream.dlm ? text.startsWith(stream.dlm)
156
+ : (/^\/[*&]/.test(text) || /^\/\/\S/.test(text) || /^\/\/\s*$/.test(text));
157
+ if (!ends) { stream.inStream.push({ line, text: raw }); continue; }
158
+ stream.streamEndLine = line;
159
+ const wasDelimited = stream.dlm !== null || /^\/\*/.test(text);
160
+ if (!stream.dlm && /^\/\/\*/.test(text)) endedByComment = stream;
161
+ stream = null;
162
+ if (wasDelimited && !/^\/\/\S/.test(text) && !/^\/\/\s*$/.test(text)) continue; // the delimiter is not a statement
163
+ }
164
+
165
+ if (open) {
166
+ // A comment statement may stand between the lines of a continued statement (z/OS MVS JCL
167
+ // Reference, "Comment statement", location in the JCL). It is kept, and follows the statement.
168
+ if (/^\/\/\*/.test(text)) { (open.comments ||= []).push({ kind: 'comment', line, endLine: line, text: text.slice(3), lines: [line], sequence }); continue; }
169
+ const col = text.search(/\S/) + 1;
170
+ if (!/^\/\//.test(text)) {
171
+ diags.push({ sev: 'error', line, text: 'a continuation must begin with // in columns 1 and 2' });
172
+ open = null;
173
+ } else {
174
+ const body = text.slice(2);
175
+ const at = body.search(/\S/) + 3;
176
+ if (at < CONTINUE_FROM || at > CONTINUE_TO) {
177
+ diags.push({ sev: 'error', line, text: `a continued operand must resume between columns ${CONTINUE_FROM} and ${CONTINUE_TO}, not ${at}` });
178
+ }
179
+ const piece = BLANKS_IN_OPERANDS.has(open.operation) ? body.trim() : operandField(body.trim());
180
+ open.field += piece;
181
+ open.endLine = line;
182
+ open.lines.push(line);
183
+ if (!(BLANKS_IN_OPERANDS.has(open.operation) ? /,\s*$/.test(text) : piece.endsWith(','))) { push(open); open = null; }
184
+ continue;
185
+ }
186
+ }
187
+
188
+ if (!text.trim()) continue;
189
+ if (/^\/\/\*/.test(text)) { push({ kind: 'comment', line, endLine: line, text: text.slice(3), lines: [line], sequence }); continue; }
190
+ // A comment ends in-stream data as any statement does, and data after it goes to the SYSIN DD *
191
+ // the system supplies for data with no DD statement.
192
+ if (endedByComment && !/^\/[/*&]/.test(text)) {
193
+ diags.push({ sev: 'warn', line, text: `data after a comment that ended ${endedByComment.name || 'a DD'}'s in-stream data is read as an implicit SYSIN DD *` });
194
+ push({ kind: 'statement', name: 'SYSIN', operation: 'DD', field: '*', implicit: true, line, endLine: line, lines: [line], sequence: '' });
195
+ stream.inStream.push({ line, text: raw });
196
+ endedByComment = null;
197
+ continue;
198
+ }
199
+ endedByComment = null;
200
+ if (/^\/\*/.test(text)) { push({ kind: 'delimiter', line, endLine: line, lines: [line] }); continue; }
201
+ if (/^\/\/\s*$/.test(text)) { push({ kind: 'null', line, endLine: line, lines: [line] }); continue; }
202
+ if (/^\/&/.test(text)) { push({ kind: 'end-of-job', line, endLine: line, lines: [line] }); continue; }
203
+ if (!/^\/\//.test(text)) {
204
+ diags.push({ sev: 'error', line, text: 'a statement must begin with // in columns 1 and 2' });
205
+ push({ kind: 'unreadable', line, endLine: line, lines: [line], text });
206
+ continue;
207
+ }
208
+
209
+ const body = text.slice(2);
210
+ const m = body.match(/^(\S*)\s+(\S+)(?:\s+([\s\S]*))?$/);
211
+ if (!m) {
212
+ diags.push({ sev: 'error', line, text: 'no operation field' });
213
+ push({ kind: 'unreadable', line, endLine: line, lines: [line], text });
214
+ continue;
215
+ }
216
+ const [, name, op, field = ''] = m;
217
+ const operation = op.toUpperCase();
218
+ // A DD named PROCSTEP.DDNAME overrides or adds a DD in a step of the procedure its EXEC calls.
219
+ const override = operation === 'DD' && name.split('.').length === 2 && name.split('.').every((p) => NAME.test(p));
220
+ if (name && !NAME.test(name) && !override) {
221
+ diags.push({ sev: 'warn', line, text: `name field '${name}' is not 1 to 8 characters starting with a letter or national` });
222
+ }
223
+ if (!OPERATIONS.has(operation)) {
224
+ diags.push({ sev: 'warn', line, text: `operation '${operation}' is not a JCL statement this reader knows` });
225
+ }
226
+
227
+ const blanks = BLANKS_IN_OPERANDS.has(operation);
228
+ const operands = blanks ? field.trim() : operandField(field.trim());
229
+ const st = { kind: 'statement', name: name || null, operation, field: operands, line, endLine: line, lines: [line], sequence };
230
+
231
+ // An operand field ending in a comma continues, but only when something follows it.
232
+ if ((blanks ? /,\s*$/.test(text) : operands.endsWith(',')) && i + 1 < phys.length) { open = st; continue; }
233
+ push(st);
234
+ }
235
+
236
+ if (open) {
237
+ diags.push({ sev: 'error', line: open.line, text: 'statement ends on a continuation with nothing continuing it' });
238
+ push(open);
239
+ }
240
+ return { statements, diags };
241
+ }
242
+
243
+ // DISP=(status,normal,abnormal), any part omitted. An omitted status is NEW: IBM's own example
244
+ // codes DISP=(,PASS) for "a new data set" (https://www.ibm.com/docs/en/zos/2.1.0?topic=parameter-examples-disp).
245
+ export function dispositionOf(disp) {
246
+ if (!disp) return null;
247
+ const [status = '', normal = '', abnormal = ''] = String(disp).replace(/^\(|\)$/g, '').split(',')
248
+ .map((s) => s.trim().toUpperCase());
249
+ return { status: status || 'NEW', normal: normal || null, abnormal: abnormal || null };
250
+ }
251
+
252
+ // What a step does to a dataset, read from its disposition. A DISP is (status,normal,abnormal) and
253
+ // the status is the half that says whether this step expects the dataset to exist. Getting this
254
+ // wrong in either direction matters: a step that creates a dataset is where its content comes
255
+ // from, and a step that reads one is where that content goes.
256
+ export function accessOf(disp) {
257
+ if (!disp) return 'unknown';
258
+ const { status } = dispositionOf(disp);
259
+ if (status === 'NEW') return 'create';
260
+ if (status === 'MOD') return 'append';
261
+ if (status === 'SHR') return 'read';
262
+ // OLD is exclusive access and says nothing about direction on its own. Claiming either would be
263
+ // a guess, and a dataset flow built on guesses is worse than one that admits the gap.
264
+ if (status === 'OLD') return 'exclusive';
265
+ return 'unknown';
266
+ }
267
+
268
+ // A referback names an earlier DD rather than a dataset: DSN=*.STEP010.SYSUT2, or *.SYSUT2 within
269
+ // the same step. Left unresolved it reads as a dataset literally called "*.STEP010.SYSUT2", and
270
+ // every flow through it is lost.
271
+ export function resolveReferback(dsn, dds, currentStep) {
272
+ const m = /^\*\.(?:([A-Z$#@][A-Z0-9$#@]{0,7})\.)?([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(dsn || '');
273
+ if (!m) return { dsn, referback: null };
274
+ const [, stepName, ddName] = m;
275
+ const target = dds.find((d) => d.name && d.name.toUpperCase() === ddName.toUpperCase()
276
+ && (!stepName || (d.step || '').toUpperCase() === stepName.toUpperCase())
277
+ && (stepName || d.step === currentStep));
278
+ return { dsn: target?.dsn ?? dsn, referback: { step: stepName || currentStep, dd: ddName, resolved: !!target?.dsn } };
279
+ }
280
+
281
+ export function parseJcl(src, file, opts = {}) {
282
+ const { statements, diags } = foldStatements(src);
283
+
284
+ const symbols = new Map(Object.entries(opts.symbols || {}));
285
+ const jobs = [];
286
+ const steps = [];
287
+ const dds = [];
288
+ const procs = [];
289
+ const includes = [];
290
+ const unresolvedSymbols = new Set();
291
+ const stepOf = new Map();
292
+ // z/OS allows a symbolic value of at most 255 characters; a longer one is left unset.
293
+ const setSymbol = (k, v, line) => {
294
+ const value = v.replace(/^'|'$/g, '');
295
+ if (value.length <= 255) { symbols.set(k, value); return; }
296
+ diags.push({ sev: 'warn', line, text: `a symbolic parameter would be ${value.length} characters, more than JCL allows, so it was left unset` });
297
+ };
298
+
299
+ let job = null;
300
+ let step = null;
301
+ let proc = null;
302
+ // Whether the last DD belonged to the job rather than a step, so a DD concatenated to it does too.
303
+ let jobLevel = false;
304
+
305
+ const resolveField = (st) => {
306
+ const { text, unresolved } = substitute(st.field, symbols);
307
+ for (const u of unresolved) unresolvedSymbols.add(u);
308
+ return text;
309
+ };
310
+
311
+ for (const st of statements) {
312
+ if (st.kind !== 'statement') continue;
313
+ const field = resolveField(st);
314
+ const { positional, keywords } = parseOperands(field);
315
+
316
+ switch (st.operation) {
317
+ case 'JOB':
318
+ job = { name: st.name, field, keywords, steps: [], line: st.line };
319
+ jobs.push(job);
320
+ step = null;
321
+ jobLevel = false;
322
+ break;
323
+
324
+ case 'PROC':
325
+ // A PROC statement's operands are the symbolic defaults for the procedure body.
326
+ proc = { name: st.name, line: st.line, endLine: null, steps: [] };
327
+ procs.push(proc);
328
+ for (const [k, v] of keywords) if (!symbols.has(k)) setSymbol(k, v, st.line);
329
+ break;
330
+
331
+ case 'PEND':
332
+ if (proc) { proc.endLine = st.line; proc = null; }
333
+ break;
334
+
335
+ case 'SET':
336
+ for (const [k, v] of keywords) setSymbol(k, v, st.line);
337
+ break;
338
+
339
+ case 'EXEC': {
340
+ // PGM=&NAME takes the program from a symbolic. In a procedure that is usually the caller's to
341
+ // set, and a procedure that leaves it empty names no program of its own.
342
+ const rawPgm = parseOperands(st.field).keywords.get('PGM') || '';
343
+ const pgmSymbol = /^&([A-Z$#@][A-Z0-9$#@]{0,7})\.?$/i.exec(rawPgm)?.[1].toUpperCase() || null;
344
+ const named = keywords.get('PGM') || null;
345
+ const pgm = named && !named.startsWith('&') ? named : null;
346
+ // EXEC names either a program or a procedure; the procedure may be positional or PROC=.
347
+ const procName = pgm || pgmSymbol ? null : (keywords.get('PROC') || positional[0] || null);
348
+ step = {
349
+ name: st.name, pgm, ...(pgmSymbol ? { pgmSymbol } : {}), proc: procName,
350
+ parm: keywords.get('PARM') ? keywords.get('PARM').replace(/^'|'$/g, '') : null,
351
+ cond: keywords.get('COND') || null,
352
+ keywords, dds: [], line: st.line,
353
+ inProc: proc ? proc.name : null,
354
+ };
355
+ if (pgmSymbol && !pgm) diags.push({ sev: 'warn', line: st.line, text: `EXEC PGM=&${pgmSymbol} names no program in this file, which ${symbols.has(pgmSymbol) ? 'leaves the symbolic empty' : 'never sets the symbolic'}, so the caller decides what the step runs` });
356
+ else if (!pgm && !procName) diags.push({ sev: 'error', line: st.line, text: 'EXEC names neither PGM= nor a procedure' });
357
+ steps.push(step);
358
+ (proc ? proc.steps : job ? job.steps : []).push(step);
359
+ break;
360
+ }
361
+
362
+ case 'DD': {
363
+ const rawDsn = keywords.get('DSN') || keywords.get('DSNAME') || null;
364
+ const disp = keywords.get('DISP') || null;
365
+ const { dsn, referback } = resolveReferback(rawDsn, dds, step ? step.name : null);
366
+ // The name stays as written, since a finding's identity is built from it; procStep says which
367
+ // step of the called procedure a PROCSTEP.DDNAME override belongs to.
368
+ const procStep = st.name && st.name.includes('.') ? st.name.split('.')[0] : null;
369
+ const dd = {
370
+ name: st.name, procStep, dsn, rawDsn, referback,
371
+ disp, access: accessOf(disp),
372
+ temporary: /^&&/.test(rawDsn || ''),
373
+ sysout: keywords.get('SYSOUT') || null,
374
+ inStream: st.inStream || null, dlm: st.dlm || null,
375
+ concatenated: !st.name, keywords, line: st.line, endLine: st.endLine,
376
+ step: step ? step.name : null,
377
+ };
378
+ dds.push(dd);
379
+ if (step) { step.dds.push(dd); stepOf.set(dd, step); }
380
+ else if (job && (JOB_LEVEL_DDS.has((st.name || '').toUpperCase()) || (!st.name && jobLevel))) { (job.dds ||= []).push(dd); jobLevel = true; continue; }
381
+ else diags.push({ sev: 'warn', line: st.line, text: 'DD statement outside any step' });
382
+ jobLevel = false;
383
+ break;
384
+ }
385
+
386
+ case 'INCLUDE': {
387
+ const member = keywords.get('MEMBER') || positional[0] || null;
388
+ includes.push({ member, line: st.line, resolved: false });
389
+ // An unresolved INCLUDE is a coverage gap in the same sense an unresolved COPY is: the
390
+ // statements it would have contributed were never read.
391
+ diags.push({ sev: 'warn', line: st.line, text: `INCLUDE MEMBER=${member} was not resolved, so its statements were not read` });
392
+ break;
393
+ }
394
+
395
+ default:
396
+ break;
397
+ }
398
+ }
399
+
400
+ for (const s of unresolvedSymbols) {
401
+ if (SYSTEM_SYMBOLS.has(s)) diags.push({ sev: 'info', line: 0, text: `&${s} is a system symbol, given its value when the job is converted, so the operands using it were read with the symbol in place` });
402
+ else diags.push({ sev: 'warn', line: 0, text: `symbolic parameter &${s} has no value, so the operands using it were read unsubstituted` });
403
+ }
404
+
405
+ // What each step's program copies, from lib/utilities.mjs. A program that table does not know
406
+ // copies nothing here, which is not the same as copying nothing.
407
+ const copies = [];
408
+ const copiesOfStep = new Map();
409
+ const uses = new Map();
410
+ for (const s of steps) {
411
+ const found = copiesOf(s);
412
+ if (!found) continue;
413
+ copies.push(...found.copies);
414
+ copiesOfStep.set(s, found.copies);
415
+ for (const [dd, use] of found.uses) uses.set(dd, use);
416
+ for (const n of found.notes) diags.push({ sev: 'info', ...n });
417
+ }
418
+
419
+ // Which steps touch each dataset, in order, and how. What a utility does with a DD outranks its
420
+ // disposition, since SYSUT2 opened SHR is still written; a dataset a control statement names
421
+ // itself, as REPRO OUTDATASET does, is touched with no DD.
422
+ const byDataset = new Map();
423
+ const touchedIn = new Map();
424
+ const touch = (dsn, temporary, t, s) => {
425
+ if (!byDataset.has(dsn)) byDataset.set(dsn, { dsn, temporary, touches: [] });
426
+ byDataset.get(dsn).touches.push(t);
427
+ touchedIn.set(t, s);
428
+ };
429
+ for (const dd of dds) {
430
+ if (!dd.dsn || dd.sysout) continue;
431
+ const s = stepOf.get(dd);
432
+ touch(dd.dsn, dd.temporary, { step: dd.step, dd: dd.name, access: dd.access, use: uses.get(dd) || null, program: s?.pgm || null, line: dd.line }, s);
433
+ }
434
+ for (const [s, found] of copiesOfStep) {
435
+ const seen = new Set();
436
+ for (const c of found) {
437
+ for (const [end, use] of [[c.from, 'read'], [c.to, 'write']]) {
438
+ const key = `${c.line}|${end.dsn}|${use}`;
439
+ if (end.dd || !end.dsn || seen.has(key)) continue;
440
+ seen.add(key);
441
+ touch(end.dsn, false, { step: s.name, dd: null, access: 'unknown', use, program: c.utility, line: c.line }, s);
442
+ }
443
+ }
444
+ }
445
+ for (const d of byDataset.values()) d.touches.sort((a, b) => a.line - b.line);
446
+
447
+ const direction = (t) => t.use || (t.access === 'create' || t.access === 'append' ? 'write' : t.access === 'read' ? 'read' : null);
448
+ const datasetFlow = [...byDataset.values()]
449
+ .filter((d) => new Set(d.touches.map((t) => t.step)).size > 1)
450
+ .map((d) => {
451
+ const writes = d.touches.filter((t) => direction(t) === 'write');
452
+ return {
453
+ ...d,
454
+ writtenBy: writes.map((t) => t.step),
455
+ readBy: d.touches.filter((t) => direction(t) === 'read').map((t) => t.step),
456
+ undetermined: d.touches.filter((t) => !direction(t)).map((t) => t.step),
457
+ // Who wrote it: the step, the program it ran, and what that program copied in, when it is
458
+ // a utility the table knows.
459
+ writers: writes.map((t) => ({
460
+ step: t.step, program: t.program, dd: t.dd, line: t.line,
461
+ copiedFrom: (copiesOfStep.get(touchedIn.get(t)) || [])
462
+ .filter((c) => c.to.dsn === d.dsn && c.to.dd === t.dd).map((c) => c.from),
463
+ })),
464
+ };
465
+ });
466
+
467
+ return {
468
+ file, jobs, steps, dds, procs, includes, statements, copies, datasets: [...byDataset.values()], datasetFlow,
469
+ symbols: Object.fromEntries(symbols),
470
+ unresolvedSymbols: [...unresolvedSymbols],
471
+ diags,
472
+ coverageIncomplete: includes.length > 0 || [...unresolvedSymbols].some((s) => !SYSTEM_SYMBOLS.has(s)) || diags.some((d) => d.sev === 'error'),
473
+ };
474
+ }
475
+
476
+ export function parseJclFile(file, opts = {}) {
477
+ return parseJcl(readSource(file).text, file, opts);
478
+ }
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // What every rule set does with a finding once it has one: put it in order, stamp it with the
3
+ // severity its rule declares, and count it.
4
+ //
5
+ // These were twelve copies of a comparator, thirteen sort sites in five variants and eight tally
6
+ // loops. The variants were not deliberate. One set sorted without the rule in the key, so two
7
+ // findings at the same place came back in whichever order they were pushed; another dropped the
8
+ // line entirely. Neither was wrong on purpose, and neither would ever have been noticed, because a
9
+ // report that is stably sorted the wrong way looks exactly like a report that is sorted.
10
+ //
11
+ // The severity stamping mattered more. It did not live in a rule set at all: lib/scan.mjs imputed
12
+ // it while merging, defaulting to 'med' for anything it did not recognise. So a set called on its
13
+ // own returned findings with no severity, and a rule id with a typo in it became a medium finding
14
+ // nobody had written. toSarif maps an absent severity to 'warning', which meant a crit credential
15
+ // finding could leave the tool as a warning.
16
+
17
+ export const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
18
+
19
+ // Rule, then path, then line. A finding carrying no line sorts as line 0 rather than NaN, which is
20
+ // what `undefined - undefined` produces and what silently makes a sort do nothing at all.
21
+ export const compareFindings = (a, b) =>
22
+ byText(a.rule, b.rule) || byText(a.path, b.path) || (a.line || 0) - (b.line || 0);
23
+
24
+ // Sorts in place and returns the same array, so it reads as the last step of building one.
25
+ export const sortFindings = (findings) => findings.sort(compareFindings);
26
+
27
+ export function tally(findings) {
28
+ const byRule = {};
29
+ for (const f of findings) byRule[f.rule] = (byRule[f.rule] || 0) + 1;
30
+ return byRule;
31
+ }
32
+
33
+ // What kind of claim a finding makes. `coverage` and `context` are not defects.
34
+ export const EVIDENCE = Object.freeze({
35
+ path: 'untrusted input was traced to a sensitive operation, and the route it took is recorded',
36
+ construct: 'the construct is a defect wherever it appears; no input has to reach it',
37
+ tampering: 'the source is arranged so a reader or a resolver sees something other than what runs',
38
+ advisory: 'a component version matches a published advisory against it',
39
+ exposure: 'information about the estate is written into the source',
40
+ change: 'a change moves an interface or adds a call target, which a reviewer has to look at',
41
+ coverage: 'the analysis stopped following here; a limit of the tool, not a defect in the code',
42
+ context: 'describes the estate - an entry point, a product in use - and asserts no defect',
43
+ });
44
+
45
+ export const WHO_ACTS = Object.freeze({
46
+ path: "the program's owner",
47
+ construct: 'the owner, or whoever can rotate a credential',
48
+ tampering: 'a reviewer, before merge',
49
+ advisory: 'whoever owns the build',
50
+ exposure: 'the owner',
51
+ change: 'the reviewer of that change',
52
+ coverage: "nobody's code: read more, or accept the limit",
53
+ context: 'nobody: it asserts no defect',
54
+ });
55
+
56
+ // Severity, CWE and evidence come from the table that declares the rule, and from nowhere else.
57
+ //
58
+ // A finding that already carries any of them keeps it: a vendor pack rule brings its own severity,
59
+ // and the copybook set demotes a shadowing that is latent rather than live. Anything else is stamped
60
+ // from the table.
61
+ //
62
+ // A rule id the table does not declare is refused rather than defaulted. The id is almost always a
63
+ // typo, and the alternative is publishing a finding whose severity nobody chose.
64
+ export function withMeta(rules, findings, set = 'rule set') {
65
+ for (const f of findings) {
66
+ const meta = rules[f.rule];
67
+ if (!meta) {
68
+ throw Object.assign(
69
+ new Error(`${set}: a finding names rule '${f.rule}', which this rule set does not declare`),
70
+ { code: 'ERULEID', rule: f.rule, set },
71
+ );
72
+ }
73
+ if (f.sev === undefined || f.sev === null) f.sev = meta.sev;
74
+ if (f.cwe === undefined) f.cwe = meta.cwe ?? null;
75
+ if (f.evidence === undefined) f.evidence = meta.evidence;
76
+ if (!Object.hasOwn(EVIDENCE, f.evidence)) {
77
+ throw Object.assign(
78
+ new Error(`${set}: rule '${f.rule}' declares evidence '${f.evidence}', which is not one of ${Object.keys(EVIDENCE).join(', ')}`),
79
+ { code: 'EEVIDENCE', rule: f.rule, set },
80
+ );
81
+ }
82
+ }
83
+ return findings;
84
+ }
85
+
86
+ export const evidenceMap = (rules) =>
87
+ Object.fromEntries(Object.entries(rules).map(([k, v]) => [k, v.evidence]));
88
+
89
+ // The three steps in the order every rule set takes them.
90
+ export function finish(rules, findings, set) {
91
+ withMeta(rules, findings, set);
92
+ sortFindings(findings);
93
+ return { findings, byRule: tally(findings) };
94
+ }