@portll/cobolwork 0.2.117 → 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.
@@ -7,6 +7,7 @@
7
7
  // name routines nothing implements: the goal is a program that compiles.
8
8
  import { detectFormat, expandTabs } from './parser.mjs';
9
9
  import { EIB_LAYOUT } from './words.mjs';
10
+ import { sqlTokens, sqlRoles } from './embedded-sql.mjs';
10
11
  import { builtinValue, translateCics, eibCopybook, CONSTANT_COPYBOOKS, constantsCopybook, symbolicMapCopybook } from './precompile-cics.mjs';
11
12
 
12
13
  const isFree = (format) => format === 'free' || format === 'terminal';
@@ -84,20 +85,6 @@ function findBlocks(lines, format) {
84
85
  return { blocks, builtins };
85
86
  }
86
87
 
87
- // The words, host variables and literals of a statement. A host variable is :NAME, qualified as
88
- // :GROUP.NAME, and may carry an indicator variable, :NAME:IND or :NAME INDICATOR :IND.
89
- function tokenize(sql) {
90
- const toks = [];
91
- const re = /'(?:[^']|'')*'|"(?:[^"]|"")*"|:\s*[A-Z0-9_$#@-]+(?:\.[A-Z0-9_$#@-]+)*|[A-Z0-9_$#@-]+|[(),=<>+*/;.]/gi;
92
- for (const m of sql.matchAll(re)) {
93
- const t = m[0];
94
- if (t.startsWith(':')) toks.push({ host: t.slice(1).trim().toUpperCase() });
95
- else if (/^['"]/.test(t)) toks.push({ lit: t });
96
- else toks.push({ word: t.toUpperCase() });
97
- }
98
- return toks;
99
- }
100
-
101
88
  // How many subscripts a host variable needs: one for each OCCURS on it or on a group holding it.
102
89
  function subscriptCounter(items = []) {
103
90
  const byName = new Map();
@@ -116,76 +103,6 @@ function subscriptCounter(items = []) {
116
103
  // where the array starts.
117
104
  const cobolName = (host, subscripts = 0) => host.split('.').reverse().join(' OF ') + (subscripts ? ` (${Array(subscripts).fill(1).join(' ')})` : '');
118
105
 
119
- // Clauses that end an INTO list.
120
- const AFTER_INTO = new Set(['FROM', 'WHERE', 'GROUP', 'HAVING', 'ORDER', 'FETCH', 'FOR', 'WITH', 'OPTIMIZE', 'QUERYNO', 'SKIP', 'UNION', 'USING', 'VALUES']);
121
-
122
- // Which host variables a statement reads and which it writes, as Db2's SQL reference gives them. A
123
- // variable both read and written is in both lists. INTO a host variable writes it (INSERT INTO and
124
- // MERGE INTO name a table); so do SET's targets, GET DIAGNOSTICS' and ASSOCIATE LOCATORS' list. A
125
- // procedure's argument that is a lone variable may be IN, OUT or INOUT, which only the server knows.
126
- function roles(toks) {
127
- const sending = [];
128
- const receiving = [];
129
- const read = (h) => { if (!sending.includes(h)) sending.push(h); };
130
- const write = (h) => { if (!receiving.includes(h)) receiving.push(h); };
131
- const verb = toks.find((t) => t.word)?.word;
132
- if (verb === 'CALL') return callRoles(toks, read, write), { sending, receiving };
133
- // DESCRIBE and PREPARE read the SQLDA's SQLN and write the rest of it.
134
- const intoBoth = verb === 'DESCRIBE' || verb === 'PREPARE';
135
- let into = false;
136
- let assigning = verb === 'SET';
137
- let depth = 0;
138
- for (let i = 0; i < toks.length; i++) {
139
- const t = toks[i];
140
- const next = toks[i + 1];
141
- if (t.word === '(') depth++;
142
- else if (t.word === ')') depth--;
143
- else if (verb === 'SET' && t.word === '=' && depth === 0) assigning = false;
144
- else if (verb === 'SET' && t.word === ',' && depth === 0) assigning = true;
145
- if (t.word === 'INTO' && next && (next.host || next.word === 'DESCRIPTOR')) { into = true; continue; }
146
- if (into && t.word && AFTER_INTO.has(t.word)) into = false;
147
- if (t.word === 'DESCRIPTOR' && next?.host) {
148
- // FETCH ... INTO DESCRIPTOR and USING DESCRIPTOR are synonyms: the program fills the SQLDA and Db2 writes where it points.
149
- if (verb === 'FETCH' || intoBoth) { read(next.host); write(next.host); } else read(next.host);
150
- i++;
151
- continue;
152
- }
153
- if (!t.host) continue;
154
- const written = into || assigning
155
- || (verb === 'GET' && next?.word === '=')
156
- || (verb === 'ASSOCIATE' && !toks.slice(0, i).some((x) => x.word === 'WITH'));
157
- if (!written || (into && intoBoth)) read(t.host);
158
- if (written) write(t.host);
159
- }
160
- return { sending, receiving };
161
- }
162
-
163
- // CALL :name reads the name. Each argument that is a lone variable, with or without its indicator,
164
- // is read and may be written; any other argument is an expression, IN only.
165
- function callRoles(toks, read, write) {
166
- let depth = 0;
167
- let arg = [];
168
- const close = () => {
169
- const hosts = arg.filter((t) => t.host);
170
- const lone = hosts.length && arg.every((t) => t.host || t.word === 'INDICATOR');
171
- for (const h of hosts) { read(h.host); if (lone) write(h.host); }
172
- arg = [];
173
- };
174
- for (let i = 1; i < toks.length; i++) {
175
- const t = toks[i];
176
- if (t.word === 'DESCRIPTOR' && toks[i + 1]?.host) { read(toks[i + 1].host); write(toks[i + 1].host); i++; continue; }
177
- if (t.word === '(') { if (depth++ > 0) arg.push(t); continue; }
178
- if (t.word === ')') { if (--depth > 0) arg.push(t); else close(); continue; }
179
- if (depth === 0) { if (t.host) read(t.host); continue; }
180
- if (depth === 1 && t.word === ',') { close(); continue; }
181
- arg.push(t);
182
- }
183
- }
184
-
185
- export function hostVariableRoles(sql) {
186
- return roles(tokenize(sql));
187
- }
188
-
189
106
  // The WHENEVER tests after each executable statement, from Db2's definitions of the conditions. IBM
190
107
  // does not document which wins when two hold at once, so the order is fixed, not the source's.
191
108
  const WHENEVER_TEST = {
@@ -201,12 +118,12 @@ const declarative = (words) => words[0] === 'DECLARE' && words[1] !== 'GLOBAL';
201
118
 
202
119
  // What one block becomes, as COBOL text on one logical line, or '' to blank it.
203
120
  function translate(block, state) {
204
- const toks = tokenize(block.text);
121
+ const toks = sqlTokens(block.text);
205
122
  const words = toks.filter((t) => t.word).map((t) => t.word);
206
123
  const verb = words[0] || '';
207
124
  if (verb === 'INCLUDE') return words[1] ? `COPY ${words[1]}` : '';
208
125
  if (!state.procedure) {
209
- if (declarative(words) && words.includes('CURSOR')) state.cursors.set(words[1], roles(toks).sending);
126
+ if (declarative(words) && words.includes('CURSOR')) state.cursors.set(words[1], sqlRoles(toks).sending);
210
127
  return '';
211
128
  }
212
129
  if (verb === 'WHENEVER') {
@@ -220,11 +137,11 @@ function translate(block, state) {
220
137
  return 'CONTINUE';
221
138
  }
222
139
  if (declarative(words)) {
223
- if (words.includes('CURSOR')) state.cursors.set(words[1], roles(toks).sending);
140
+ if (words.includes('CURSOR')) state.cursors.set(words[1], sqlRoles(toks).sending);
224
141
  return 'CONTINUE';
225
142
  }
226
143
  if (NOT_EXECUTABLE.has(verb)) return 'CONTINUE';
227
- let { sending, receiving } = roles(toks);
144
+ let { sending, receiving } = sqlRoles(toks);
228
145
  let name = verb;
229
146
  if (verb === 'OPEN' && state.cursors.has(words[1])) sending = [...new Set([...state.cursors.get(words[1]), ...sending])];
230
147
  if (verb === 'EXECUTE' && words[1] === 'IMMEDIATE') name = 'EXECUTE-IMMEDIATE';
package/lib/revision.json CHANGED
@@ -1 +1 @@
1
- {"commit":"b144074728e1b40f1c6a230b06f1a6b8b0758d51"}
1
+ {"commit":"54c9654b8c13bc22c8d5683b02551362d95f0d0a","tag":"v0.2.140"}
package/lib/sbom.mjs CHANGED
@@ -10,6 +10,7 @@ import { parseJcl } from './jcl.mjs';
10
10
  import { optionCards } from './options.mjs';
11
11
  import { inScope, isBms, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
12
12
  import { TOOL_VERSION } from './version.mjs';
13
+ import { revisionOf } from './revision.mjs';
13
14
  import { treeFor } from './kernel/source-tree.mjs';
14
15
 
15
16
  // The namespace of cobolwork's version 5 UUIDs (RFC 4122 §4.3), fixed so a serial number repeats.
@@ -26,6 +27,50 @@ function uuidv5(name) {
26
27
  const sha256 = (b) => createHash('sha256').update(b).digest('hex');
27
28
  const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
28
29
 
30
+ // A routine a platform provides, which its platform component already stands for.
31
+ const platformRoutine = (n) => n === 'CBLTDLI' || n === 'AIBTDLI' || /^MQ[A-Z0-9]{2,}$/.test(n) || /^CEE[A-Z0-9]+$/.test(n);
32
+
33
+ // IBM programs a job step runs rather than one the estate builds.
34
+ const SYSTEM_PROGRAMS = new Set(['ADRDSSU', 'AMASPZAP', 'ASMA90', 'BPXBATCH', 'DFHCSDUP', 'DFHEAP1$', 'DFHECP1$', 'DFSRRC00', 'DFSORT', 'DSNTEP2', 'DSNTEP4',
35
+ 'DSNHPC', 'DSNTIAD', 'DSNTIAUL', 'DSNUTILB', 'FTP', 'HEWL', 'IEWBLINK', 'IEWL', 'ICEGENER', 'ICEMAN', 'ICETOOL', 'IDCAMS', 'IEBCOMPR',
36
+ 'IEBCOPY', 'IEBDG', 'IEBGENER', 'IEBUPDTE', 'IEFBR14', 'IEHLIST', 'IEHPROGM', 'IGYCRCTL', 'IKJEFT01', 'IKJEFT1A', 'IKJEFT1B',
37
+ 'SDSF', 'SORT']);
38
+ const TSO_BATCH = new Set(['IKJEFT01', 'IKJEFT1A', 'IKJEFT1B']);
39
+ // The IMS region types whose PARM names the application program second: PARM='BMP,PGM,PSB'.
40
+ const IMS_REGIONS = new Set(['BMP', 'DLI', 'DBB', 'IFP', 'JBP', 'JMP']);
41
+
42
+ // What a step runs beyond its PGM: the program a TSO batch step's DSN RUN names, or an IMS region's
43
+ // application program.
44
+ function programsRunBy(step) {
45
+ const pgm = String(step.pgm || '').toUpperCase();
46
+ const out = [];
47
+ if (TSO_BATCH.has(pgm)) {
48
+ for (const dd of step.dds || []) {
49
+ if (dd.name !== 'SYSTSIN') continue;
50
+ const text = (dd.inStream || []).map((l) => l.text).join(' ');
51
+ for (const m of text.matchAll(/\bRUN\s+PROG(?:RAM)?\s*\(\s*([A-Z0-9#@$]{1,8})\s*\)/gi)) out.push(m[1].toUpperCase());
52
+ }
53
+ } else if (pgm === 'DFSRRC00' && step.parm) {
54
+ const [region, app] = String(step.parm).replace(/^['(]|[')]$/g, '').split(',').map((x) => x.trim().toUpperCase());
55
+ if (IMS_REGIONS.has(region) && /^[A-Z0-9#@$]{1,8}$/.test(app || '')) out.push(app);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // The literal operand of a CICS option, `PROGRAM('X')`; the field's name where a field is given.
61
+ function cicsOperand(toks, option) {
62
+ const at = toks.findIndex((t, i) => t.t === 'word' && t.u === option && toks[i + 1] && toks[i + 1].v === '(');
63
+ if (at < 0) return null;
64
+ const inner = [];
65
+ for (let i = at + 2, depth = 1; i < toks.length; i++) {
66
+ if (toks[i].v === '(') depth++;
67
+ else if (toks[i].v === ')' && --depth === 0) break;
68
+ inner.push(toks[i]);
69
+ }
70
+ if (inner.length === 1 && inner[0].t === 'lit') return { literal: String(inner[0].v).trim().toUpperCase() };
71
+ return inner.length ? { field: inner.map((t) => t.v).join('') } : null;
72
+ }
73
+
29
74
  function platformsOf(src, calls) {
30
75
  const s = new Set();
31
76
  if (/EXEC\s+CICS\b/i.test(src)) s.add('CICS');
@@ -35,7 +80,7 @@ function platformsOf(src, calls) {
35
80
  if (c.kind !== 'L') continue;
36
81
  const n = String(c.name).toUpperCase();
37
82
  if (n === 'CBLTDLI' || n === 'AIBTDLI') s.add('IMS');
38
- if (/^MQ[A-Z]{2,}$/.test(n)) s.add('MQ');
83
+ if (/^MQ[A-Z0-9]{2,}$/.test(n)) s.add('MQ');
39
84
  if (/^CEE[A-Z0-9]+$/.test(n)) s.add('Language Environment');
40
85
  }
41
86
  return [...s];
@@ -61,6 +106,8 @@ export function sbom(root, opts = {}) {
61
106
 
62
107
  const entries = [];
63
108
  const programIds = new Map();
109
+ const mapsets = new Map();
110
+ const procIds = new Map();
64
111
  for (const f of tree.list().filter(inScope(opts)).sort()) {
65
112
  let bytes;
66
113
  let src;
@@ -77,6 +124,13 @@ export function sbom(root, opts = {}) {
77
124
  } catch { entry.unparsed = true; }
78
125
  } else if (kind === 'jcl' || kind === 'proc') {
79
126
  try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
127
+ if (kind === 'proc') {
128
+ const m = /^\/\/([A-Z0-9#@$]{1,8})\s+PROC\b/im.exec(src);
129
+ procIds.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
130
+ }
131
+ } else if (kind === 'bms') {
132
+ const m = /^([A-Z0-9#@$]{1,8})\s+DFHMSD\b/im.exec(src);
133
+ mapsets.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
80
134
  }
81
135
  entries.push(entry);
82
136
  }
@@ -84,6 +138,17 @@ export function sbom(root, opts = {}) {
84
138
  const components = new Map();
85
139
  const dependencies = new Map();
86
140
  const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
141
+ // A program the estate does not hold is a component of its own, so an edge to it is not lost.
142
+ const runs = (from, name) => {
143
+ const target = programIds.get(name);
144
+ if (target) { if (target !== from) depend(from, target); return; }
145
+ if (platformRoutine(name)) return;
146
+ const ref = `program:${name}`;
147
+ depend(from, ref);
148
+ if (!components.has(ref)) {
149
+ components.set(ref, { 'bom-ref': ref, type: 'application', name, properties: [{ name: 'cobolwork:kind', value: SYSTEM_PROGRAMS.has(name) ? 'system-program' : 'external-program' }] });
150
+ }
151
+ };
87
152
 
88
153
  for (const e of entries) {
89
154
  const properties = [{ name: 'cobolwork:kind', value: e.kind }];
@@ -108,8 +173,19 @@ export function sbom(root, opts = {}) {
108
173
  const unresolved = new Set();
109
174
  for (const c of calls) {
110
175
  if (c.kind !== 'L') { unresolved.add(String(c.name)); continue; }
111
- const target = programIds.get(String(c.name).toUpperCase());
112
- if (target && target !== e.ref) depend(e.ref, target);
176
+ runs(e.ref, String(c.name).toUpperCase());
177
+ }
178
+ for (const x of e.programs.flatMap((p) => p.execs || [])) {
179
+ if (x.kind !== 'CICS') continue;
180
+ const words = x.toks.filter((t) => t.t === 'word').map((t) => t.u);
181
+ if (words[0] === 'LINK' || words[0] === 'XCTL') {
182
+ const op = cicsOperand(x.toks, 'PROGRAM');
183
+ if (op && op.literal) runs(e.ref, op.literal); else if (op) unresolved.add(op.field);
184
+ } else if ((words[0] === 'SEND' || words[0] === 'RECEIVE') && words[1] === 'MAP') {
185
+ const op = cicsOperand(x.toks, 'MAPSET') || cicsOperand(x.toks, 'MAP');
186
+ const target = op && op.literal ? mapsets.get(op.literal) : null;
187
+ if (target) depend(e.ref, target);
188
+ }
113
189
  }
114
190
  if (unresolved.size) properties.push({ name: 'cobolwork:unresolved-call', value: [...unresolved].sort(order).join(',') });
115
191
  for (const p of platformsOf(e.src, calls)) {
@@ -119,10 +195,17 @@ export function sbom(root, opts = {}) {
119
195
  }
120
196
  }
121
197
  if (e.jcl) {
198
+ const inStream = new Set((e.jcl.procs || []).map((p) => String(p.name || '').toUpperCase()));
199
+ const missing = new Set();
122
200
  for (const step of e.jcl.steps || []) {
123
- const target = step.pgm ? programIds.get(String(step.pgm).toUpperCase()) : null;
124
- if (target) depend(e.ref, target);
201
+ if (step.pgm && /^[A-Z0-9#@$]{1,8}$/i.test(step.pgm)) runs(e.ref, String(step.pgm).toUpperCase());
202
+ for (const name of programsRunBy(step)) runs(e.ref, name);
203
+ if (!step.proc) continue;
204
+ const name = String(step.proc).toUpperCase();
205
+ const proc = procIds.get(name);
206
+ if (proc) { if (proc !== e.ref) depend(e.ref, proc); } else if (!inStream.has(name)) missing.add(name);
125
207
  }
208
+ if (missing.size) properties.push({ name: 'cobolwork:unresolved-proc', value: [...missing].sort(order).join(',') });
126
209
  }
127
210
  components.set(e.ref, { 'bom-ref': e.ref, type: 'file', name: e.ref, hashes: [{ alg: 'SHA-256', content: sha256(e.bytes) }], properties });
128
211
  }
@@ -130,6 +213,8 @@ export function sbom(root, opts = {}) {
130
213
  const list = [...components.values()].sort((a, b) => order(a['bom-ref'], b['bom-ref']));
131
214
  const digests = list.filter((c) => c.hashes).map((c) => c.hashes[0].content).sort(order);
132
215
  const name = opts.name || basename(top);
216
+ let revision = null;
217
+ try { revision = revisionOf(top); } catch { /* not a repository, or git is missing */ }
133
218
  const epoch = process.env.SOURCE_DATE_EPOCH;
134
219
  return {
135
220
  bomFormat: 'CycloneDX',
@@ -139,7 +224,10 @@ export function sbom(root, opts = {}) {
139
224
  metadata: {
140
225
  ...(epoch && /^\d+$/.test(epoch) ? { timestamp: new Date(Number(epoch) * 1000).toISOString() } : {}),
141
226
  tools: { components: [{ type: 'application', name: 'cobolwork', version: TOOL_VERSION }] },
142
- component: { 'bom-ref': `estate:${name}`, type: 'application', name },
227
+ component: {
228
+ 'bom-ref': `estate:${name}`, type: 'application', name,
229
+ ...(revision ? { version: revision.commit, properties: [{ name: 'cobolwork:dirty', value: String(revision.dirty) }] } : {}),
230
+ },
143
231
  },
144
232
  components: list,
145
233
  dependencies: [...dependencies.entries()].sort(([a], [b]) => order(a, b)).map(([ref, set]) => ({ ref, dependsOn: [...set].sort(order) })),
package/lib/sets/cics.mjs CHANGED
@@ -221,12 +221,15 @@ export function scanCics(root, opts = {}) {
221
221
  declared,
222
222
  });
223
223
  }
224
- // A program that points DFHCOMMAREA at storage of its own is not reading a caller's area.
224
+ // A program that points DFHCOMMAREA at storage of its own is not reading a caller's area. Only
225
+ // the receiving side of SET, before TO, repoints it: SET P TO ADDRESS OF DFHCOMMAREA reads the
226
+ // caller's area's address.
225
227
  const tokens = (p.proc && p.proc.tokens) || [];
226
228
  const pointsItself = (p.statements || []).some((st) => {
227
229
  if (st.verb !== 'SET') return false;
228
230
  const w = tokens.slice(st.at + 1, st.end).filter((t) => t.t === 'word').map((t) => t.u);
229
- return w.some((x, k) => x === 'ADDRESS' && w[k + 1] === 'OF' && w[k + 2] === 'DFHCOMMAREA');
231
+ const receiving = w.includes('TO') ? w.slice(0, w.indexOf('TO')) : w;
232
+ return receiving.some((x, k) => x === 'ADDRESS' && receiving[k + 1] === 'OF' && receiving[k + 2] === 'DFHCOMMAREA');
230
233
  });
231
234
  return {
232
235
  id: p.id, path,
@@ -7,6 +7,7 @@ import { finish } from '../kernel/findings.mjs';
7
7
  import { report } from '../kernel/ruleset.mjs';
8
8
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
9
  import { eachWithinMemory } from '../kernel/memory.mjs';
10
+ import { cobolCard } from '../card.mjs';
10
11
 
11
12
  // A copybook is resolved by name along a search path: the program's own directory first, then the
12
13
  // include directories, then the system library. Two files answering to one name mean the record
@@ -37,7 +38,7 @@ const SYSTEM_NAME = /^(DFHAID|DFHBMSCA|DFHEIBLK|DFHCOMMAREA|DFHRESP|SQLCA|SQLDA|
37
38
  function layout(src) {
38
39
  const fixed = detectFormat(src) === 'fixed';
39
40
  return src.split(/\r?\n/)
40
- .map(l => (fixed ? l.slice(6, 72) : l).trimEnd())
41
+ .map(l => { const c = cobolCard(l, fixed ? 'fixed' : 'free'); return (c.indicator + c.text).trimEnd(); })
41
42
  .filter(l => l.trim() && !/^[*/]/.test(l.trimStart()))
42
43
  .join('\n')
43
44
  .toUpperCase();
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  import { detectFormat, expandTabs } from '../parser.mjs';
3
+ import { cobolCard } from '../card.mjs';
3
4
  import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
4
5
  import { report } from '../kernel/ruleset.mjs';
5
6
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
@@ -69,14 +70,9 @@ const BANNER = /^[\s*\-=_+#/\\.]*$/;
69
70
 
70
71
  function areas(line, format) {
71
72
  const l = expandTabs(line);
72
- if (format === 'free' || format === 'terminal') return { sequence: '', indicator: '', text: l, identification: '', ignored: '' };
73
- return {
74
- sequence: l.slice(0, 6),
75
- indicator: l[6] || ' ',
76
- text: l.slice(7, format === 'variable' ? 250 : 72),
77
- identification: format === 'variable' ? '' : l.slice(72, 80),
78
- ignored: format === 'variable' ? '' : l.slice(72),
79
- };
73
+ const card = cobolCard(l, format);
74
+ const fixed = format === 'fixed';
75
+ return { ...card, identification: fixed ? card.ignored.slice(0, 8) : '', ignored: fixed ? card.ignored : '' };
80
76
  }
81
77
 
82
78
  // A floating comment, from the *> that starts it outside a literal to the end of the line.
package/lib/sets/jcl.mjs CHANGED
@@ -7,7 +7,8 @@ import { inScope, isJcl, isProgram, relPath } from '../sources.mjs';
7
7
  import { report } from '../kernel/ruleset.mjs';
8
8
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
9
  import { parseJcl, dispositionOf } from '../jcl.mjs';
10
- import { ddGroups } from '../utilities.mjs';
10
+ import { ddGroups, tsoCommands } from '../utilities.mjs';
11
+ import { ftpSession } from '../ftp.mjs';
11
12
  import { loadSite, productionQualifierOf, SITE_FILE } from '../site.mjs';
12
13
  import { loadPacks, programsOf } from '../packs.mjs';
13
14
  import { eachWithinMemory } from '../kernel/memory.mjs';
@@ -84,11 +85,16 @@ const SORTS = new Set(['SORT', 'ICEMAN', 'SYNCSORT']);
84
85
  const CONTROL_DDS = [
85
86
  [TSO, ['SYSTSIN']], [new Set(['BPXBATCH']), ['STDPARM']], [new Set(['IDCAMS']), ['SYSIN']],
86
87
  [SORTS, ['SYSIN', 'DFSPARM']], [new Set(['ICETOOL']), ['TOOLIN', 'DFSPARM']],
87
- [new Set(['DSNTEP2', 'DSNTEP4', 'DSNTIAD']), ['SYSIN']],
88
88
  ];
89
89
  const TSO_RUNS = /^\s*(CALL|EXEC|EX|SUBMIT|ISPSTART|RUN)\b/i;
90
- // RUN PROGRAM(DSNTEP2) under the DSN command processor makes that step's SYSIN a program's SQL.
91
- const RUNS_SQL_PROGRAM = /\bRUN\s+PROGRAM\s*\(\s*(DSNTEP2|DSNTEP4|DSNTIAD)\s*\)/i;
90
+ // Db2's sample programs read SQL statements from SYSIN, run as the step's program or under TSO by
91
+ // DSN RUN PROGRAM. Those statements are the program's input, not commands to TSO or a security product.
92
+ const SQL_PROGRAMS = new Set(['DSNTEP2', 'DSNTEP4', 'DSNTIAD']);
93
+ const sqlInput = (step, groups) => {
94
+ const pgm = step.pgm ? step.pgm.toUpperCase() : null;
95
+ const runsSql = SQL_PROGRAMS.has(pgm) || tsoCommands(step).runs.some((x) => SQL_PROGRAMS.has(x.program));
96
+ return runsSql ? groups.get('SYSIN') || [] : [];
97
+ };
92
98
 
93
99
  // Utilities whose commands come from a DD: a cataloged data set there is commands nobody reviewed
94
100
  // with the job; a MODS exit is a load module the sort runs; TSO commands say what else runs.
@@ -99,7 +105,7 @@ function controlCardFindings(r, path, findings) {
99
105
  const groups = ddGroups(step);
100
106
  const cards = (name) => (groups.get(name) || []).flatMap((dd) => dd.inStream || []);
101
107
  const ddNames = new Set(CONTROL_DDS.filter(([pgms]) => pgms.has(pgm)).flatMap(([, dds]) => dds));
102
- if (TSO.has(pgm) && cards('SYSTSIN').some((l) => RUNS_SQL_PROGRAM.test(l.text))) ddNames.add('SYSIN');
108
+ if (sqlInput(step, groups).length) ddNames.add('SYSIN');
103
109
  for (const name of ddNames) {
104
110
  for (const dd of groups.get(name) || []) {
105
111
  if (dd.dsn && !dd.inStream && !dd.temporary) {
@@ -127,116 +133,6 @@ function controlCardFindings(r, path, findings) {
127
133
  }
128
134
  }
129
135
 
130
- // IBM's batch FTP client takes the host and its options on PARM, or the host as the first line of
131
- // its input, reads subcommands from INPUT, and logs on from NETRC or from its input. CardDemo's job
132
- // gives the same lines on SYSIN, which is read here when there is no INPUT, though the page below
133
- // does not say the client falls back to it.
134
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halu001/ftpreq.htm
135
- // A session is in cleartext unless something asks for TLS: -a TLS or -r TLS on PARM, or
136
- // SECURE_MECHANISM TLS in an in-stream SYSFTPD. A SYSFTPD naming a data set is configuration this
137
- // reader cannot see, so that step is not called cleartext either way.
138
- // https://www.ibm.com/docs/en/zos/2.1.0?topic=commands-ftp-command
139
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halz002/ftp_cust_client_for_tls.htm
140
- // PUT, MPUT and APPEND send local files, and IBM's capitals - PUt, MPut, APpend, LCd - are the
141
- // shortest spellings. A local name in quotes is a whole data set name, //DD: names a DD of the
142
- // step, and anything else hangs off the local working directory, which only a quoted LCD fixes.
143
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/ftpsubcmd.htm
144
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/put.htm
145
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mput.htm
146
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/append.htm
147
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/lcd.htm
148
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/dd_name_support.htm
149
- const FTP_SENDS = [[/^PUT?$/i, 'PUT'], [/^MP(?:UT?)?$/i, 'MPUT'], [/^AP(?:P(?:E(?:ND?)?)?)?$/i, 'APPEND']];
150
-
151
- // Every subcommand on the page above, in IBM's spelling.
152
- const FTP_SUBCOMMANDS = ['?', '!', 'ACCount', 'APpend', 'AUth', 'AScii', 'BIG5', 'BINary', 'BLock', 'CCc', 'CD',
153
- 'CDUp', 'CLEar', 'CLose', 'COMpress', 'CProtect', 'CWd', 'DEBug', 'DELEte', 'DELImit', 'DIr', 'DUMP', 'EBcdic',
154
- 'EUckanji', 'FEature', 'FIle', 'Get', 'GLob', 'HAngeul', 'HElp', 'Ibmkanji', 'JIS78kj', 'JIS83kj', 'Ksc5601',
155
- 'LANGuage', 'LCd', 'LOCSIte', 'LOCSTat', 'LMkdir', 'LPwd', 'LS', 'MDelete', 'MGet', 'MKdir', 'MKFifo', 'MOde',
156
- 'MPut', 'MVSGet', 'MVSPut', 'NOop', 'Open', 'PAss', 'PRIvate', 'PROMpt', 'PROTect', 'PROXy', 'PUt', 'PWd', 'QUIt',
157
- 'QUOte', 'RECord', 'REName', 'REStart', 'RMdir', 'SAfe', 'SChinese', 'SENDPort', 'SENDSite', 'SIte', 'SJiskanji',
158
- 'SRestart', 'STAtus', 'STREam', 'STRucture', 'SUnique', 'SYstem', 'TChinese', 'TSO', 'TYpe', 'UCs2', 'User', 'Verbose']
159
- .map((name) => ({ full: name.toUpperCase(), min: name.match(/^[^a-z]*/)[0].length }));
160
- const subcommandOf = (word) => {
161
- const w = word.toUpperCase();
162
- const hit = FTP_SUBCOMMANDS.find((c) => w.length >= c.min && c.full.startsWith(w));
163
- return hit ? hit.full : null;
164
- };
165
- // A value that stands for a password rather than being one: a symbolic, a mask, or the word itself.
166
- const PLACEHOLDER = /^(&.*|\*+|X+|\?+|<.*>|\{.*\}|PASSWORD|PASSWD|PWD|PASS|SECRET)$/i;
167
-
168
- function ftpSession(step) {
169
- const dds = ddGroups(step);
170
- let host = null, tls = null;
171
- const words = (step.parm || '').split('(')[0].trim().split(/\s+/).filter(Boolean);
172
- for (let i = 0; i < words.length; i++) {
173
- if (/^-[ar]$/i.test(words[i])) {
174
- const mechanism = (words[++i] || '').toUpperCase();
175
- if (mechanism === 'TLS') tls = 'asked';
176
- else if (mechanism === 'NEVER' && !tls) tls = 'never';
177
- } else if (!words[i].startsWith('-') && !host) host = words[i];
178
- }
179
- let configUnread = null;
180
- for (const dd of dds.get('SYSFTPD') || []) {
181
- if (dd.inStream) { if (tls !== 'never' && dd.inStream.some((l) => /^\s*SECURE_MECHANISM\s+TLS\b/i.test(l.text))) tls = 'asked'; }
182
- else if (dd.dsn && !configUnread) configUnread = dd.dsn;
183
- }
184
-
185
- const input = dds.get('INPUT') || dds.get('SYSIN') || [];
186
- const hostLine = !host && input[0]?.inStream;
187
- let inputUnread = null, lcd = null, unixDir = false;
188
- const sends = [];
189
- // With no NETRC to answer for it, the client asks for a user ID and a password and in batch reads
190
- // them from its input after the host: "user password" on one line, or each on its own. A first
191
- // line that is a subcommand means something else answered, a NETRC the client found for itself.
192
- // USER and PASS give the same answers later in a session.
193
- let expect = dds.has('NETRC') ? null : 'logon', user = null;
194
- const passwords = [];
195
- const password = (dd, line, value) => { if (!PLACEHOLDER.test(value)) passwords.push({ dd, line, user }); };
196
- const local = (name) => {
197
- const dd = /^\/\/DD:([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(name);
198
- if (dd) return { dd: dd[1].toUpperCase(), dsns: (dds.get(dd[1].toUpperCase()) || []).map((d) => d.dsn).filter(Boolean) };
199
- const quoted = /^'(.+)'$/.exec(name);
200
- if (quoted) return { dsns: [quoted[1].toUpperCase()] };
201
- if (name.startsWith('/') || unixDir) return { dsns: [] };
202
- return lcd ? { dsns: [`${lcd}.${name.toUpperCase()}`] } : { dsns: [], relative: name };
203
- };
204
- for (const dd of input) {
205
- if (!dd.inStream) { if (dd.dsn && !inputUnread) inputUnread = dd.dsn; continue; }
206
- for (const { line, text } of dd.inStream) {
207
- const [verb, ...args] = text.slice(0, 72).trim().split(/\s+/);
208
- if (!verb) continue;
209
- if (hostLine && !host) { host = verb; continue; }
210
- const sub = subcommandOf(verb);
211
- if (expect && !sub) {
212
- if (expect === 'logon') user = verb;
213
- if (expect === 'password') password(dd, line, verb);
214
- else if (args[0]) password(dd, line, args[0]);
215
- expect = expect === 'logon' && !args[0] ? 'password' : null;
216
- continue;
217
- }
218
- expect = null;
219
- if (sub === 'USER' && args[0]) {
220
- user = args[0];
221
- if (args[1]) password(dd, line, args[1]); else expect = 'password';
222
- continue;
223
- }
224
- if (sub === 'PASS' && args[0]) { password(dd, line, args[0]); continue; }
225
- if (/^LCD?$/i.test(verb) && args[0]) {
226
- const quoted = /^'(.+)'$/.exec(args[0]);
227
- unixDir = args[0].startsWith('/');
228
- lcd = quoted ? quoted[1].toUpperCase() : lcd && !unixDir ? `${lcd}.${args[0].toUpperCase()}` : null;
229
- continue;
230
- }
231
- const sent = FTP_SENDS.find(([re]) => re.test(verb));
232
- if (!sent) continue;
233
- for (const name of sent[1] === 'MPUT' ? args : args.slice(0, 1)) sends.push({ line, verb: sent[1], name, ...local(name) });
234
- }
235
- }
236
- const cleartext = tls === 'never' || (!tls && !configUnread);
237
- return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, passwords, input };
238
- }
239
-
240
136
  // Credentials, which are the reason this rule set is critical rather than merely interesting.
241
137
  // Each run of blanks follows the ( or = that separates it from the next, which keeps matching linear.
242
138
  const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*)?['"]?([A-Z0-9@#$_.-]{3,})/i;
@@ -247,7 +143,7 @@ const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*
247
143
  const SECURITY_COMMAND = /^\s*(?:TSS\s+)?(ADDUSER|ALTUSER|DELUSER|ADDGROUP|ALTGROUP|CONNECT|REMOVE|PERMIT|SETROPTS|RDEFINE|RALTER|RDELETE|ADDSD|ALTDSD|DELDSD|PASSWORD|ACFNRULE)\b/i;
248
144
 
249
145
  // ACF2 speaks differently: a mode change followed by INSERT or CHANGE against a logonid.
250
- const ACF2_COMMAND = /^\s*(?:SET\s+(?:LID|RULE|PROFILE)|INSERT\s+[A-Z0-9$#@]+|CHANGE\s+[A-Z0-9$#@]+\s)/i;
146
+ const ACF2_COMMAND = /^\s*(?:SET\s+(?:LID|RULE|PROFILE)|INSERT\s+(?!INTO\b)[A-Z0-9$#@]+|CHANGE\s+[A-Z0-9$#@]+\s)/i;
251
147
 
252
148
  // Destruction in-stream. DELETE with a dataset name, or a REPRO that replaces its target. SQL's
253
149
  // DELETE FROM removes rows through a database utility, not a catalogued dataset.
@@ -370,9 +266,10 @@ export function scanJcl(root, opts = {}) {
370
266
  if (d && !stats.firstReadInPart) stats.firstReadInPart = `${path}${d.line ? `:${d.line}` : ''}: ${d.text}`;
371
267
  }
372
268
 
373
- const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s)]));
269
+ const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s, ddGroups(s))]));
374
270
  const ftpInput = new Map();
375
271
  for (const ftp of ftpSteps.values()) for (const dd of ftp.input) ftpInput.set(dd, ftp);
272
+ const sqlDds = new Set(r.steps.flatMap((s) => sqlInput(s, ddGroups(s))));
376
273
 
377
274
  for (const dd of r.dds) {
378
275
  if (!dd.inStream) continue;
@@ -398,13 +295,13 @@ export function scanJcl(root, opts = {}) {
398
295
  detail: `in-stream FTP input gives the password ${logon.user ? `for ${logon.user} ` : ''}that step ${dd.step || '(none)'} logs on ${ftp.host ? `to ${ftp.host} ` : ''}with, in a file anyone who can read the repository can read` });
399
296
  continue;
400
297
  }
401
- const sec = text.match(SECURITY_COMMAND);
298
+ const sec = !sqlDds.has(dd) && text.match(SECURITY_COMMAND);
402
299
  if (sec) {
403
300
  findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
404
301
  detail: `in-stream data issues ${sec[1].toUpperCase()}, which grants or alters authority, from step ${dd.step || '(none)'}` });
405
302
  continue;
406
303
  }
407
- if (ACF2_COMMAND.test(text)) {
304
+ if (!sqlDds.has(dd) && ACF2_COMMAND.test(text)) {
408
305
  findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
409
306
  detail: `in-stream data issues an ACF2 administration command from step ${dd.step || '(none)'}` });
410
307
  continue;
@@ -17,6 +17,7 @@ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
17
17
  import { eachWithinMemory } from '../kernel/memory.mjs';
18
18
  import { loadSite, classifyPath, productionQualifierOf, SITE_FILE } from '../site.mjs';
19
19
  import { parseJcl, dispositionOf } from '../jcl.mjs';
20
+ import { statementCard } from '../card.mjs';
20
21
 
21
22
 
22
23
  // The production-dataset rules claim more than the name rule: that the job acts on production, not
@@ -93,7 +94,7 @@ function routableAddresses(text, jcl) {
93
94
  if (jcl) {
94
95
  for (const line of text.split(/\r?\n/)) {
95
96
  if (/^\/\/\*/.test(line)) continue;
96
- collect(line.slice(0, 72), out);
97
+ collect(statementCard(line).text, out);
97
98
  }
98
99
  return out;
99
100
  }