@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.
- package/lib/card.mjs +18 -0
- package/lib/cics-commands.mjs +136 -125
- package/lib/control.mjs +118 -1
- package/lib/dataflow.mjs +67 -84
- package/lib/embedded-sql.mjs +110 -0
- package/lib/evidence/journal.mjs +11 -2
- package/lib/explain.mjs +5 -4
- package/lib/ftp.mjs +136 -0
- package/lib/ironwork.mjs +4 -6
- package/lib/jcl.mjs +27 -7
- package/lib/layout.mjs +202 -0
- package/lib/parser.mjs +52 -203
- package/lib/precompile.mjs +5 -88
- package/lib/revision.json +1 -1
- package/lib/sbom.mjs +94 -6
- package/lib/sets/cics.mjs +5 -2
- package/lib/sets/copybook.mjs +2 -1
- package/lib/sets/hidden.mjs +4 -8
- package/lib/sets/jcl.mjs +16 -119
- package/lib/sets/recon.mjs +2 -1
- package/lib/sets/zowe.mjs +34 -8
- package/lib/utilities.mjs +146 -4
- package/package.json +1 -1
- package/rules/compliance-dora.json +1 -1
- package/rules/compliance-ffiec.json +1 -1
- package/rules/compliance-nist80053.json +1 -1
- package/schema/cobolwork.policy.schema.json +94 -17
package/lib/precompile.mjs
CHANGED
|
@@ -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 =
|
|
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],
|
|
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],
|
|
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 } =
|
|
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":"
|
|
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-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
package/lib/sets/copybook.mjs
CHANGED
|
@@ -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 ?
|
|
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();
|
package/lib/sets/hidden.mjs
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
91
|
-
|
|
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 (
|
|
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;
|
package/lib/sets/recon.mjs
CHANGED
|
@@ -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.
|
|
97
|
+
collect(statementCard(line).text, out);
|
|
97
98
|
}
|
|
98
99
|
return out;
|
|
99
100
|
}
|