@portll/cobolwork 0.2.76 → 0.2.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +66 -14
  2. package/bin/cobolwork.mjs +126 -9
  3. package/lib/arith.mjs +235 -0
  4. package/lib/baseline.mjs +5 -0
  5. package/lib/build.mjs +112 -30
  6. package/lib/capabilities.mjs +12 -7
  7. package/lib/card.mjs +18 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/consequence.mjs +29 -0
  10. package/lib/control.mjs +1169 -91
  11. package/lib/csd.mjs +17 -2
  12. package/lib/dataflow.mjs +276 -126
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/enterprise-options.mjs +255 -0
  15. package/lib/equivalence.mjs +124 -0
  16. package/lib/evidence/cli.mjs +65 -0
  17. package/lib/evidence/journal.mjs +94 -0
  18. package/lib/evidence/record.mjs +110 -0
  19. package/lib/evidence/run.mjs +111 -0
  20. package/lib/evidence/seal.mjs +177 -0
  21. package/lib/evidence/slsa.mjs +64 -0
  22. package/lib/evidence/sshsig.mjs +172 -0
  23. package/lib/evidence/store.mjs +170 -0
  24. package/lib/evidence/verify.mjs +167 -0
  25. package/lib/explain.mjs +9 -5
  26. package/lib/exploitability.mjs +263 -0
  27. package/lib/ftp.mjs +136 -0
  28. package/lib/ironwork.mjs +109 -0
  29. package/lib/jcl.mjs +27 -7
  30. package/lib/kernel/findings.mjs +11 -0
  31. package/lib/kernel/registry.mjs +4 -0
  32. package/lib/layout.mjs +202 -0
  33. package/lib/option-diff.mjs +49 -0
  34. package/lib/options.mjs +109 -46
  35. package/lib/parser.mjs +80 -209
  36. package/lib/policy.mjs +8 -3
  37. package/lib/precompile.mjs +5 -84
  38. package/lib/reach.mjs +29 -12
  39. package/lib/revision.json +1 -1
  40. package/lib/sarif.mjs +1 -0
  41. package/lib/sbom.mjs +235 -0
  42. package/lib/scan.mjs +93 -27
  43. package/lib/sets/cics.mjs +119 -2
  44. package/lib/sets/copybook.mjs +2 -1
  45. package/lib/sets/flow.mjs +82 -2
  46. package/lib/sets/hidden.mjs +4 -8
  47. package/lib/sets/jcl.mjs +70 -106
  48. package/lib/sets/recon.mjs +2 -1
  49. package/lib/sets/semantics.mjs +377 -0
  50. package/lib/sets/web.mjs +34 -10
  51. package/lib/sets/zowe.mjs +246 -0
  52. package/lib/sources.mjs +10 -0
  53. package/lib/tui/app.mjs +31 -13
  54. package/lib/tui/model.mjs +17 -2
  55. package/lib/utilities.mjs +229 -15
  56. package/lib/verify.mjs +128 -0
  57. package/package.json +1 -1
  58. package/rules/compliance-dora.json +507 -0
  59. package/rules/compliance-ffiec.json +507 -0
  60. package/rules/compliance-nist80053.json +507 -0
  61. package/schema/cobolwork.policy.schema.json +94 -16
package/lib/sets/cics.mjs CHANGED
@@ -13,6 +13,24 @@ import { parseCsd } from '../csd.mjs';
13
13
  // must act on from one that only wants a look; `remedy` is the standard fix, the same for every
14
14
  // instance. Both ride with every finding of the rule (as ruleImpact/ruleRemedy).
15
15
  export const CICS_RULES = {
16
+ 'program-checks-stored-password': {
17
+ sev: 'med', evidence: 'construct', cwe: 'CWE-256',
18
+ text: 'A program compares a password it reads back from its own file or table',
19
+ impact: 'The password is kept where the application reads it, so whoever can read that file or table - a developer, a backup, an unload - holds every user\'s password, and the program, not the security manager, decides who signs on',
20
+ remedy: 'Verify the password with EXEC CICS VERIFY PASSWORD or SIGNON, so RACF checks it and the application never stores it',
21
+ },
22
+ 'password-case-folded-before-compare': {
23
+ sev: 'low', evidence: 'construct', cwe: 'CWE-178',
24
+ text: 'A sign-on folds a password\'s case before comparing it',
25
+ impact: 'A password that matches in either case has fewer values to guess, and the program accepts a password its user never typed',
26
+ remedy: 'Compare the password as typed, or leave the comparison to the security manager with EXEC CICS VERIFY PASSWORD',
27
+ },
28
+ 'cics-condition-ignored': {
29
+ sev: 'low', evidence: 'construct', cwe: 'CWE-252',
30
+ text: 'A program issues CICS commands whose failure it cannot see: NOHANDLE with no RESP, or IGNORE CONDITION',
31
+ impact: 'A command that fails - a record not found, a queue full, storage not available - returns to the next statement as if it had worked, so the program acts on an area the command never filled',
32
+ remedy: 'Give each command RESP and test it, or let the condition reach a HANDLE CONDITION that deals with it; keep NOHANDLE for commands whose failure is harmless',
33
+ },
16
34
  'cics-commarea-without-length-check': {
17
35
  sev: 'high', evidence: 'construct', cwe: 'CWE-1284',
18
36
  text: 'A program reads its communication area without checking EIBCALEN',
@@ -118,6 +136,38 @@ function execOptions(exec) {
118
136
 
119
137
  const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
120
138
 
139
+ // Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
140
+ // not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
141
+ // have gone, an ASSIGN, and the RETURN that ends the task anyway.
142
+ const QUIET_FAILURES = (words) => (words[0] === 'DELETEQ' && words[1] === 'TS') || (words[0] === 'WRITEQ' && words[1] === 'TD')
143
+ || ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(words[0]);
144
+ const SHOWN_IGNORED = 5;
145
+
146
+ // Commands whose outcome the program cannot see: NOHANDLE with no RESP and no EIBRESP test before the
147
+ // next command, and IGNORE CONDITION. One finding per program, at the first, since the count is what
148
+ // a reviewer weighs and the fix is the same at each.
149
+ function conditionsIgnored(p, path) {
150
+ const execs = p.execs.filter((e) => e.kind === 'CICS').sort((a, b) => a.line - b.line);
151
+ const hits = [];
152
+ execs.forEach((e, k) => {
153
+ const { opts: o, words } = execOptions(e);
154
+ if (words[0] === 'IGNORE' && words[1] === 'CONDITION') {
155
+ hits.push({ e, what: `IGNORE CONDITION ${words.slice(2).join(' ')}`.trim() });
156
+ return;
157
+ }
158
+ if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(words)) return;
159
+ const until = execs[k + 1] ? execs[k + 1].line : Infinity;
160
+ const tested = p.statements.some((st) => st.line > e.line && st.line < until && (st.sources || []).some((t) => t.t === 'word' && t.u === 'EIBRESP'));
161
+ if (!tested) hits.push({ e, what: `${words[0]}${words[1] && words[1] !== 'NOHANDLE' && !o.has(words[1]) ? ` ${words[1]}` : ''} NOHANDLE` });
162
+ });
163
+ if (!hits.length) return null;
164
+ const shown = hits.slice(0, SHOWN_IGNORED).map((h) => `${h.what} at line ${h.e.line}`).join(', ');
165
+ return {
166
+ rule: 'cics-condition-ignored', path, line: hits[0].e.line, program: p.id,
167
+ detail: `${p.id} leaves ${hits.length} place${hits.length === 1 ? '' : 's'} where a CICS command's failure goes unseen: ${shown}${hits.length > SHOWN_IGNORED ? `, and ${hits.length - SHOWN_IGNORED} more` : ''}`,
168
+ };
169
+ }
170
+
121
171
  // Sizes come from the parser's data-division layout, which is graded against the compiler, so a
122
172
  // length compared here is the length the compiler would compute.
123
173
  //
@@ -171,12 +221,15 @@ export function scanCics(root, opts = {}) {
171
221
  declared,
172
222
  });
173
223
  }
174
- // 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.
175
227
  const tokens = (p.proc && p.proc.tokens) || [];
176
228
  const pointsItself = (p.statements || []).some((st) => {
177
229
  if (st.verb !== 'SET') return false;
178
230
  const w = tokens.slice(st.at + 1, st.end).filter((t) => t.t === 'word').map((t) => t.u);
179
- 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');
180
233
  });
181
234
  return {
182
235
  id: p.id, path,
@@ -194,6 +247,67 @@ export function scanCics(root, opts = {}) {
194
247
  }
195
248
 
196
249
  // Where a program sends control after its sign-on succeeds, and every other route there.
250
+ // A sign-on that compares a password the program read back from its own file or table, rather than
251
+ // asking the security manager, and one that folds a password's case first. Passwords are recognised
252
+ // by field name, as the sign-on rules above recognise them, and each finding says so.
253
+ function passwordChecks(p, path) {
254
+ if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execOptions(e).words[0] || ''))) return [];
255
+ const byName = new Map();
256
+ for (const it of p.items) if (!byName.has(it.name)) byName.set(it.name, it);
257
+ const resolve = (tok) => (tok && tok.t === 'word' ? p.resolved.get(tok) || byName.get(tok.u) || null : null);
258
+ const tests = [];
259
+ try { buildControl(p, resolve, { extra: (t) => { const r = signOnTest(t); if (r) tests.push(t); return r; } }); } catch { return []; }
260
+ if (!tests.length) return [];
261
+ // What the program reads back: an FD record, the INTO of a CICS READ, a host variable a SELECT or
262
+ // FETCH fills, and everything under any of them. A copy made by MOVE first is not followed.
263
+ const read = new Set();
264
+ for (const e of p.execs) {
265
+ const { opts: o, words } = execOptions(e);
266
+ if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(words[0] || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
267
+ if (e.kind === 'SQL' && (e.hostVariables || []).length) for (const h of e.hostVariables) if (h.written && h.tok) read.add(h.tok.u);
268
+ }
269
+ const stored = (name) => {
270
+ for (let it = byName.get(name); it; it = it.parent) if (it.section === 'FILE' || read.has(it.name)) return true;
271
+ return false;
272
+ };
273
+ const out = [];
274
+ const FOLD = new Set(['UPPER-CASE', 'LOWER-CASE']);
275
+ for (const t of tests) {
276
+ const cond = t.cond || (t.evaluate && t.evaluate.conds[0]) || [];
277
+ const names = cond.filter((x, k) => x.t === 'word' && byName.has(x.u) && !(cond[k - 1] && /^(OF|IN)$/.test(cond[k - 1].u))).map((x) => x.u);
278
+ const kept = names.find(stored);
279
+ const line = t.st ? t.st.line : 1;
280
+ if (kept) {
281
+ out.push({
282
+ rule: 'program-checks-stored-password', path, line, program: p.id,
283
+ detail: `${p.id} compares ${names.join(' with ')}, and ${kept} is read back from the program's own file or table, so the password is stored where the application can read it; the password is recognised by its field name`,
284
+ });
285
+ }
286
+ if (cond.some((x) => x.t === 'word' && FOLD.has(x.u))) {
287
+ out.push({
288
+ rule: 'password-case-folded-before-compare', path, line, program: p.id,
289
+ detail: `${p.id} folds the case of ${names.join(' or ')} inside its sign-on comparison, so a password matches in either case; the password is recognised by its field name`,
290
+ });
291
+ }
292
+ }
293
+ // Case folded before the comparison: a MOVE of FUNCTION UPPER-CASE or LOWER-CASE of a password
294
+ // field, or an INSPECT that converts one.
295
+ const toks = (p.proc && p.proc.tokens) || [];
296
+ for (const st of p.statements || []) {
297
+ const seg = toks.slice(st.at + 1, st.end ?? st.at + 1);
298
+ const folds = st.verb === 'MOVE' ? seg.some((x) => x.t === 'word' && FOLD.has(x.u))
299
+ : st.verb === 'INSPECT' && seg.some((x) => x.t === 'word' && x.u === 'CONVERTING');
300
+ if (!folds) continue;
301
+ const pw = seg.find((x) => x.t === 'word' && PASSWORD.test(x.u) && byName.has(x.u));
302
+ if (!pw || out.some((o) => o.rule === 'password-case-folded-before-compare' && o.line === st.line)) continue;
303
+ out.push({
304
+ rule: 'password-case-folded-before-compare', path, line: st.line, program: p.id,
305
+ detail: `${p.id} folds the case of ${pw.u} with ${st.verb === 'MOVE' ? 'FUNCTION UPPER-CASE or LOWER-CASE' : 'INSPECT CONVERTING'} in a program that compares passwords, so a password matches in either case; the password is recognised by its field name`,
306
+ });
307
+ }
308
+ return out;
309
+ }
310
+
197
311
  function signOnBypasses(p, path) {
198
312
  let ctl;
199
313
  const byName = new Map();
@@ -238,6 +352,9 @@ export function scanCics(root, opts = {}) {
238
352
  stats.filesScanned++;
239
353
  const path = relPath(root, f);
240
354
  for (const p of r.programs) {
355
+ const ignored = conditionsIgnored(p, path);
356
+ if (ignored) findings.push(ignored);
357
+ if (p.refs.some((x) => PASSWORD.test(x.tok.u))) findings.push(...passwordChecks(p, path));
241
358
  const sum = summarise(p, path);
242
359
  summaries.push(sum);
243
360
  if (p.id && !commareaSize.has(p.id)) commareaSize.set(p.id, sum.commarea ? sum.commarea.size : null);
@@ -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();
package/lib/sets/flow.mjs CHANGED
@@ -3,6 +3,9 @@ import { analyze } from '../dataflow.mjs';
3
3
  import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from '../version.mjs';
4
4
  import { finish, withMeta, sortFindings } from '../kernel/findings.mjs';
5
5
 
6
+ // Other uses of the same index listed on a finding; the count of the rest is alsoUses.
7
+ const ALSO_LISTED = 25;
8
+
6
9
  export const RULES = {
7
10
  'argv-or-env-to-os-command': { sev: 'crit', evidence: 'path', cwe: 'CWE-78', text: 'Command-line or environment input reaches an operating-system command routine' },
8
11
  'cics-terminal-to-os-command': { sev: 'crit', evidence: 'path', cwe: 'CWE-78', text: 'Terminal input reaches an operating-system command routine' },
@@ -108,10 +111,42 @@ export const RULES = {
108
111
  // misleads whoever reads the log rather than changing what the program does.
109
112
  'cics-terminal-to-log': { sev: 'low', evidence: 'path', cwe: 'CWE-117', text: 'Terminal input is written to a log unchecked, where control characters in it can start a line the program did not write' },
110
113
  'cics-web-to-log': { sev: 'low', evidence: 'path', cwe: 'CWE-117', text: 'Web request data is written to a log unchecked, where control characters in it can start a line the program did not write' },
114
+ // Storage acquired with a length the caller chose: in a CICS region the storage is shared by every
115
+ // task, where a job's own PARM or command line exhausts only its own address space.
116
+ 'cics-terminal-to-storage-length': { sev: 'med', evidence: 'path', cwe: 'CWE-770', text: 'Terminal input decides how much storage the program acquires' },
117
+ 'cics-web-to-storage-length': { sev: 'med', evidence: 'path', cwe: 'CWE-770', text: 'Web input decides how much storage the program acquires' },
118
+ 'argv-or-env-to-storage-length': { sev: 'low', evidence: 'path', cwe: 'CWE-770', text: 'Command-line or environment input decides how much storage the program acquires' },
119
+ 'jcl-parm-to-storage-length': { sev: 'low', evidence: 'path', cwe: 'CWE-770', text: 'A JCL PARM decides how much storage the program acquires' },
120
+ 'jcl-instream-to-storage-length': { sev: 'low', evidence: 'path', cwe: 'CWE-770', text: 'In-stream job data decides how much storage the program acquires' },
121
+ // The database or queue manager a program connects to, chosen by its caller: CVE-2024-52899 is the
122
+ // JDBC shape of this in an IBM product. A job's own PARM reaches only what its submitter could.
123
+ 'cics-terminal-to-connection-target': { sev: 'med', evidence: 'path', cwe: 'CWE-99', text: 'Terminal input names the database or queue manager the program connects to' },
124
+ 'cics-web-to-connection-target': { sev: 'med', evidence: 'path', cwe: 'CWE-99', text: 'Web input names the database or queue manager the program connects to' },
125
+ 'argv-or-env-to-connection-target': { sev: 'low', evidence: 'path', cwe: 'CWE-99', text: 'Command-line or environment input names the database or queue manager the program connects to' },
126
+ 'jcl-parm-to-connection-target': { sev: 'low', evidence: 'path', cwe: 'CWE-99', text: 'A JCL PARM names the database or queue manager the program connects to' },
127
+ 'jcl-instream-to-connection-target': { sev: 'low', evidence: 'path', cwe: 'CWE-99', text: 'In-stream job data names the database or queue manager the program connects to' },
128
+ // Routing by input. A SYSID ships the request to another region, which applies its own link
129
+ // security; an EXEC CICS SET changes a region's resources with system-programming authority.
130
+ 'cics-terminal-to-cics-sysid': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'Terminal input decides which region a CICS command is shipped to' },
131
+ 'cics-web-to-cics-sysid': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'Web input decides which region a CICS command is shipped to' },
132
+ 'file-record-to-cics-sysid': { sev: 'low', evidence: 'path', cwe: 'CWE-15', text: 'A file record decides which region a CICS command is shipped to' },
133
+ 'database-to-cics-sysid': { sev: 'low', evidence: 'path', cwe: 'CWE-15', text: 'A database value decides which region a CICS command is shipped to' },
134
+ 'cics-terminal-to-cics-system-resource': { sev: 'high', evidence: 'path', cwe: 'CWE-15', text: 'Terminal input names the resource an EXEC CICS SET changes' },
135
+ 'cics-web-to-cics-system-resource': { sev: 'high', evidence: 'path', cwe: 'CWE-15', text: 'Web input names the resource an EXEC CICS SET changes' },
136
+ 'file-record-to-cics-system-resource': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'A file record names the resource an EXEC CICS SET changes' },
137
+ 'database-to-cics-system-resource': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'A database value names the resource an EXEC CICS SET changes' },
138
+ // A document from outside parsed as XML. Whether a DTD in it is honoured - an external entity
139
+ // fetched, an internal one expanded - is the parser's, chosen by the XMLPARSE compiler option, and
140
+ // no public repository parses XML from outside, so it is low until graded against the compiler.
141
+ 'cics-web-to-xml-document': { sev: 'low', evidence: 'path', cwe: 'CWE-611', text: 'Web input is parsed as an XML document' },
142
+ 'cics-terminal-to-xml-document': { sev: 'low', evidence: 'path', cwe: 'CWE-611', text: 'Terminal input is parsed as an XML document' },
111
143
  // A response code in a reply tells the client which failure it caused and something of the system
112
144
  // behind the program, which is what an attacker probing it wants.
113
145
  'system-response-to-web-response': { sev: 'low', evidence: 'path', cwe: 'CWE-209', text: 'A response code or SQL error the system set is sent in a web response' },
114
146
  'system-response-to-http-header': { sev: 'low', evidence: 'path', cwe: 'CWE-209', text: 'A response code or SQL error the system set is sent in a response header' },
147
+ // Db2's message text shown on a terminal names the tables, columns and constraints behind the
148
+ // program. A RESP or SQLCODE on an error line is ordinary and is not followed there.
149
+ 'system-response-to-screen': { sev: 'low', evidence: 'path', cwe: 'CWE-209', text: 'Db2\'s error message text is shown on the terminal' },
115
150
  // A field the program filled and the map protects, read back and used to choose a record: the 3270
116
151
  // hidden form field. The terminal enforces PROT and ASKIP, CICS does not (NetSPI 'Conquering CICS',
117
152
  // ways 3 and 4). No check lowers it: a key that is well formed is still someone else's key.
@@ -150,7 +185,7 @@ export const RULES = {
150
185
  // from one to note; `remedy` is the standard fix, one per sink. Both ride with every finding as
151
186
  // ruleImpact/ruleRemedy. Composed from the source's actor and the sink's guidance rather than
152
187
  // written out for each path rule.
153
- const WHO = {
188
+ export const WHO = {
154
189
  'argv-or-env': "Whoever sets the program's command line or environment",
155
190
  'cics-terminal': 'A terminal user',
156
191
  'cics-web': 'A web caller',
@@ -247,6 +282,26 @@ const SINK = {
247
282
  clause: 'updates or deletes a record chosen by a value a modified 3270 client controls',
248
283
  remedy: 'Re-derive the key from server state and authorize the change; do not trust a protected or hidden screen field as a key',
249
284
  },
285
+ 'cics-sysid': {
286
+ clause: 'chooses the region a CICS command is shipped to',
287
+ remedy: 'Ship to a region chosen from a fixed table, or name the SYSID as a literal; never take it from input or a record',
288
+ },
289
+ 'cics-system-resource': {
290
+ clause: 'chooses the program, transaction, file or queue a system-programming command enables, disables or changes',
291
+ remedy: 'Name the resource an EXEC CICS SET changes from a fixed table, and keep SP commands out of programs input reaches',
292
+ },
293
+ 'connection-target': {
294
+ clause: 'chooses the database or queue manager the program connects to, with the program\'s own credentials',
295
+ remedy: 'Connect only to a location or queue manager chosen from a fixed table, never one named by input',
296
+ },
297
+ 'xml-document': {
298
+ clause: 'supplies the document XML PARSE reads, where a DTD it carries can name entities for the parser to fetch or expand, if the parser the program is compiled with honours them',
299
+ remedy: 'Refuse a document that carries a DTD (<!DOCTYPE) before XML PARSE, and bound its length; check what the XMLPARSE option the program is compiled with does with entities',
300
+ },
301
+ 'storage-length': {
302
+ clause: 'chooses how much storage the program acquires, so a large enough value fails the request or uses up storage it shares',
303
+ remedy: 'Test the length against the most the program means to acquire before GETMAIN or CEEGTST, and refuse anything larger',
304
+ },
250
305
  };
251
306
  // Exfiltration over HTTP reads the same as the host case.
252
307
  SINK['outbound-http'] = SINK['outbound-host'];
@@ -294,7 +349,6 @@ const DEFAULT = { sev: 'med', evidence: 'path', cwe: null, text: 'Untrusted inpu
294
349
  // is safe for the sink - one of a list of literals, digits where digits are needed, a bound where an
295
350
  // index is - is not a finding at all, and is listed under `checked` with the check that stops it.
296
351
  const LOWER = { crit: 'high', high: 'med', med: 'low', low: 'info', info: 'info' };
297
-
298
352
  // One graph per repository, for the reason scanAll gives: names are only unique within one.
299
353
  function analyzeEach(root, opts) {
300
354
  if (!opts.repos || opts.repos.length < 2) return analyze(root, opts);
@@ -349,6 +403,7 @@ export function scan(root, opts = {}) {
349
403
  // how many hops are missing cannot tell a truncated trace from one that mentions hops.
350
404
  trace: f.path.map(p => ({ program: p.program, item: p.item, file: p.file, via: p.via, ...(p.dir ? { dir: p.dir } : {}), ...(p.elided ? { elided: p.elided } : {}) })),
351
405
  related: [{ path: f.source.file, line: f.source.line, detail: f.source.detail }],
406
+ ...(f.also ? { also: f.also } : {}),
352
407
  });
353
408
  }
354
409
  // One sink reached by many sources is one finding, not many: a report where a single statement
@@ -361,8 +416,33 @@ export function scan(root, opts = {}) {
361
416
  held.sources++;
362
417
  // A sink is credited only as far as the least-credited source that reaches it.
363
418
  const level = Math.min(held.level, f.level);
419
+ const also = [...(held.also || []), ...(f.also || [])];
364
420
  if (f.hops < held.hops) Object.assign(held, f, { sources: held.sources });
365
421
  held.level = level;
422
+ if (also.length) held.also = also;
423
+ }
424
+ // A use one source's route reaches unchecked, and the report leaves out for another use of the same
425
+ // index, still limits how far a finding at that use is credited.
426
+ for (const f of findings) {
427
+ for (const a of f.also || []) {
428
+ const held = a.unreached ? null : perSink.get(`${f.rule}|${a.file}:${a.line}|${a.program || ''}`);
429
+ if (held && a.level < held.level) held.level = a.level;
430
+ }
431
+ }
432
+ for (const held of perSink.values()) {
433
+ if (!held.also) continue;
434
+ const seenAt = new Set();
435
+ const left = held.also.filter((a) => {
436
+ const at = `${a.file}:${a.line}`;
437
+ if (seenAt.has(at) || perSink.has(`${held.rule}|${at}|${a.program || ''}`)) return false;
438
+ seenAt.add(at);
439
+ return true;
440
+ }).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
441
+ delete held.also;
442
+ if (!left.length) continue;
443
+ const how = (a) => (a.unreached ? 'no route from the entries reaches it' : a.level === 2 ? `stopped by the check at line ${a.guardLine}` : a.level === 1 ? `checked at line ${a.guardLine}, not on every route` : 'not checked');
444
+ held.related = [...held.related, ...left.slice(0, ALSO_LISTED).map((a) => ({ path: a.file, line: a.line, detail: `also ${a.detail}: ${how(a)}` }))];
445
+ held.alsoUses = left.length;
366
446
  }
367
447
  findings.length = 0;
368
448
  const checked = [];
@@ -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';
@@ -59,116 +60,77 @@ export const JCL_RULES = {
59
60
  impact: 'A dataset the estate calls production leaves the system to the FTP partner',
60
61
  remedy: 'Confirm the transfer and its partner are authorized for production data, and send over a TLS session',
61
62
  },
63
+ 'control-cards-from-dataset': {
64
+ sev: 'low', evidence: 'construct', cwe: 'CWE-829',
65
+ text: 'A utility step reads the commands it runs from a cataloged data set, so what it runs is not in the reviewed JCL',
66
+ impact: 'Whoever can update that data set decides what the step runs the next time the job does, with the job\'s authority',
67
+ remedy: 'Keep the commands in-stream where they are reviewed with the job, or put the data set under the same change control and write access as the job',
68
+ },
69
+ 'sort-exit-named': {
70
+ sev: 'med', evidence: 'construct', cwe: 'CWE-829',
71
+ text: 'A DFSORT or ICETOOL step names an exit routine on a MODS statement, a load module the sort loads and runs',
72
+ impact: 'The exit runs inside the sort with the step\'s authority and sees every record; whoever can replace that load module runs code in the job',
73
+ remedy: 'Confirm the exit\'s load library is under change control with restricted write access, or remove the exit',
74
+ },
75
+ 'tso-batch-runs-program': {
76
+ sev: 'low', evidence: 'construct', cwe: 'CWE-78',
77
+ text: 'A TSO batch step runs another program, CLIST, REXX exec or job from its in-stream commands',
78
+ impact: 'What the step runs is decided by the commands, not the PGM= the job names, and runs with the job\'s TSO authority',
79
+ remedy: 'Review what the commands run; name a program with PGM= where the step exists to run one',
80
+ },
62
81
  };
63
82
 
64
- // IBM's batch FTP client takes the host and its options on PARM, or the host as the first line of
65
- // its input, reads subcommands from INPUT, and logs on from NETRC or from its input. CardDemo's job
66
- // gives the same lines on SYSIN, which is read here when there is no INPUT, though the page below
67
- // does not say the client falls back to it.
68
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halu001/ftpreq.htm
69
- // A session is in cleartext unless something asks for TLS: -a TLS or -r TLS on PARM, or
70
- // SECURE_MECHANISM TLS in an in-stream SYSFTPD. A SYSFTPD naming a data set is configuration this
71
- // reader cannot see, so that step is not called cleartext either way.
72
- // https://www.ibm.com/docs/en/zos/2.1.0?topic=commands-ftp-command
73
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halz002/ftp_cust_client_for_tls.htm
74
- // PUT, MPUT and APPEND send local files, and IBM's capitals - PUt, MPut, APpend, LCd - are the
75
- // shortest spellings. A local name in quotes is a whole data set name, //DD: names a DD of the
76
- // step, and anything else hangs off the local working directory, which only a quoted LCD fixes.
77
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/ftpsubcmd.htm
78
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/put.htm
79
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mput.htm
80
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/append.htm
81
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/lcd.htm
82
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/dd_name_support.htm
83
- const FTP_SENDS = [[/^PUT?$/i, 'PUT'], [/^MP(?:UT?)?$/i, 'MPUT'], [/^AP(?:P(?:E(?:ND?)?)?)?$/i, 'APPEND']];
84
-
85
- // Every subcommand on the page above, in IBM's spelling.
86
- const FTP_SUBCOMMANDS = ['?', '!', 'ACCount', 'APpend', 'AUth', 'AScii', 'BIG5', 'BINary', 'BLock', 'CCc', 'CD',
87
- 'CDUp', 'CLEar', 'CLose', 'COMpress', 'CProtect', 'CWd', 'DEBug', 'DELEte', 'DELImit', 'DIr', 'DUMP', 'EBcdic',
88
- 'EUckanji', 'FEature', 'FIle', 'Get', 'GLob', 'HAngeul', 'HElp', 'Ibmkanji', 'JIS78kj', 'JIS83kj', 'Ksc5601',
89
- 'LANGuage', 'LCd', 'LOCSIte', 'LOCSTat', 'LMkdir', 'LPwd', 'LS', 'MDelete', 'MGet', 'MKdir', 'MKFifo', 'MOde',
90
- 'MPut', 'MVSGet', 'MVSPut', 'NOop', 'Open', 'PAss', 'PRIvate', 'PROMpt', 'PROTect', 'PROXy', 'PUt', 'PWd', 'QUIt',
91
- 'QUOte', 'RECord', 'REName', 'REStart', 'RMdir', 'SAfe', 'SChinese', 'SENDPort', 'SENDSite', 'SIte', 'SJiskanji',
92
- 'SRestart', 'STAtus', 'STREam', 'STRucture', 'SUnique', 'SYstem', 'TChinese', 'TSO', 'TYpe', 'UCs2', 'User', 'Verbose']
93
- .map((name) => ({ full: name.toUpperCase(), min: name.match(/^[^a-z]*/)[0].length }));
94
- const subcommandOf = (word) => {
95
- const w = word.toUpperCase();
96
- const hit = FTP_SUBCOMMANDS.find((c) => w.length >= c.min && c.full.startsWith(w));
97
- return hit ? hit.full : null;
83
+ const TSO = new Set(['IKJEFT01', 'IKJEFT1A', 'IKJEFT1B']);
84
+ const SORTS = new Set(['SORT', 'ICEMAN', 'SYNCSORT']);
85
+ const CONTROL_DDS = [
86
+ [TSO, ['SYSTSIN']], [new Set(['BPXBATCH']), ['STDPARM']], [new Set(['IDCAMS']), ['SYSIN']],
87
+ [SORTS, ['SYSIN', 'DFSPARM']], [new Set(['ICETOOL']), ['TOOLIN', 'DFSPARM']],
88
+ ];
89
+ const TSO_RUNS = /^\s*(CALL|EXEC|EX|SUBMIT|ISPSTART|RUN)\b/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') || [] : [];
98
97
  };
99
- // A value that stands for a password rather than being one: a symbolic, a mask, or the word itself.
100
- const PLACEHOLDER = /^(&.*|\*+|X+|\?+|<.*>|\{.*\}|PASSWORD|PASSWD|PWD|PASS|SECRET)$/i;
101
98
 
102
- function ftpSession(step) {
103
- const dds = ddGroups(step);
104
- let host = null, tls = null;
105
- const words = (step.parm || '').split('(')[0].trim().split(/\s+/).filter(Boolean);
106
- for (let i = 0; i < words.length; i++) {
107
- if (/^-[ar]$/i.test(words[i])) {
108
- const mechanism = (words[++i] || '').toUpperCase();
109
- if (mechanism === 'TLS') tls = 'asked';
110
- else if (mechanism === 'NEVER' && !tls) tls = 'never';
111
- } else if (!words[i].startsWith('-') && !host) host = words[i];
112
- }
113
- let configUnread = null;
114
- for (const dd of dds.get('SYSFTPD') || []) {
115
- if (dd.inStream) { if (tls !== 'never' && dd.inStream.some((l) => /^\s*SECURE_MECHANISM\s+TLS\b/i.test(l.text))) tls = 'asked'; }
116
- else if (dd.dsn && !configUnread) configUnread = dd.dsn;
117
- }
118
-
119
- const input = dds.get('INPUT') || dds.get('SYSIN') || [];
120
- const hostLine = !host && input[0]?.inStream;
121
- let inputUnread = null, lcd = null, unixDir = false;
122
- const sends = [];
123
- // With no NETRC to answer for it, the client asks for a user ID and a password and in batch reads
124
- // them from its input after the host: "user password" on one line, or each on its own. A first
125
- // line that is a subcommand means something else answered, a NETRC the client found for itself.
126
- // USER and PASS give the same answers later in a session.
127
- let expect = dds.has('NETRC') ? null : 'logon', user = null;
128
- const passwords = [];
129
- const password = (dd, line, value) => { if (!PLACEHOLDER.test(value)) passwords.push({ dd, line, user }); };
130
- const local = (name) => {
131
- const dd = /^\/\/DD:([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(name);
132
- if (dd) return { dd: dd[1].toUpperCase(), dsns: (dds.get(dd[1].toUpperCase()) || []).map((d) => d.dsn).filter(Boolean) };
133
- const quoted = /^'(.+)'$/.exec(name);
134
- if (quoted) return { dsns: [quoted[1].toUpperCase()] };
135
- if (name.startsWith('/') || unixDir) return { dsns: [] };
136
- return lcd ? { dsns: [`${lcd}.${name.toUpperCase()}`] } : { dsns: [], relative: name };
137
- };
138
- for (const dd of input) {
139
- if (!dd.inStream) { if (dd.dsn && !inputUnread) inputUnread = dd.dsn; continue; }
140
- for (const { line, text } of dd.inStream) {
141
- const [verb, ...args] = text.slice(0, 72).trim().split(/\s+/);
142
- if (!verb) continue;
143
- if (hostLine && !host) { host = verb; continue; }
144
- const sub = subcommandOf(verb);
145
- if (expect && !sub) {
146
- if (expect === 'logon') user = verb;
147
- if (expect === 'password') password(dd, line, verb);
148
- else if (args[0]) password(dd, line, args[0]);
149
- expect = expect === 'logon' && !args[0] ? 'password' : null;
150
- continue;
99
+ // Utilities whose commands come from a DD: a cataloged data set there is commands nobody reviewed
100
+ // with the job; a MODS exit is a load module the sort runs; TSO commands say what else runs.
101
+ function controlCardFindings(r, path, findings) {
102
+ for (const step of r.steps) {
103
+ const pgm = step.pgm ? step.pgm.toUpperCase() : null;
104
+ if (!pgm) continue;
105
+ const groups = ddGroups(step);
106
+ const cards = (name) => (groups.get(name) || []).flatMap((dd) => dd.inStream || []);
107
+ const ddNames = new Set(CONTROL_DDS.filter(([pgms]) => pgms.has(pgm)).flatMap(([, dds]) => dds));
108
+ if (sqlInput(step, groups).length) ddNames.add('SYSIN');
109
+ for (const name of ddNames) {
110
+ for (const dd of groups.get(name) || []) {
111
+ if (dd.dsn && !dd.inStream && !dd.temporary) {
112
+ findings.push({ rule: 'control-cards-from-dataset', path, line: dd.line, step: step.name,
113
+ detail: `step ${step.name || '(unnamed)'} runs ${pgm} with its ${name} commands in ${dd.dsn}, which is not part of this job` });
114
+ }
151
115
  }
152
- expect = null;
153
- if (sub === 'USER' && args[0]) {
154
- user = args[0];
155
- if (args[1]) password(dd, line, args[1]); else expect = 'password';
156
- continue;
116
+ }
117
+ if (SORTS.has(pgm) || pgm === 'ICETOOL') {
118
+ for (const l of [...cards('SYSIN'), ...cards('DFSPARM')]) {
119
+ const mods = /^\s*MODS\s+(.*)$/i.exec(l.text);
120
+ if (!mods) continue;
121
+ for (const ex of mods[1].matchAll(/\b(E\d\d)\s*=\s*\(\s*([A-Z$#@][A-Z0-9$#@]{0,7})/gi)) {
122
+ findings.push({ rule: 'sort-exit-named', path, line: l.line, step: step.name, detail: `step ${step.name || '(unnamed)'} names ${ex[1].toUpperCase()} exit ${ex[2].toUpperCase()} on a MODS statement` });
123
+ }
157
124
  }
158
- if (sub === 'PASS' && args[0]) { password(dd, line, args[0]); continue; }
159
- if (/^LCD?$/i.test(verb) && args[0]) {
160
- const quoted = /^'(.+)'$/.exec(args[0]);
161
- unixDir = args[0].startsWith('/');
162
- lcd = quoted ? quoted[1].toUpperCase() : lcd && !unixDir ? `${lcd}.${args[0].toUpperCase()}` : null;
163
- continue;
125
+ }
126
+ if (TSO.has(pgm)) {
127
+ const runs = cards('SYSTSIN').map((l) => TSO_RUNS.exec(l.text)).filter(Boolean).map((m) => m[1].toUpperCase());
128
+ if (runs.length) {
129
+ findings.push({ rule: 'tso-batch-runs-program', path, line: step.line, step: step.name,
130
+ detail: `step ${step.name || '(unnamed)'} runs ${runs.length} TSO command(s) that start other work: ${[...new Set(runs)].join(', ')}` });
164
131
  }
165
- const sent = FTP_SENDS.find(([re]) => re.test(verb));
166
- if (!sent) continue;
167
- for (const name of sent[1] === 'MPUT' ? args : args.slice(0, 1)) sends.push({ line, verb: sent[1], name, ...local(name) });
168
132
  }
169
133
  }
170
- const cleartext = tls === 'never' || (!tls && !configUnread);
171
- return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, passwords, input };
172
134
  }
173
135
 
174
136
  // Credentials, which are the reason this rule set is critical rather than merely interesting.
@@ -181,7 +143,7 @@ const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*
181
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;
182
144
 
183
145
  // ACF2 speaks differently: a mode change followed by INSERT or CHANGE against a logonid.
184
- 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;
185
147
 
186
148
  // Destruction in-stream. DELETE with a dataset name, or a REPRO that replaces its target. SQL's
187
149
  // DELETE FROM removes rows through a database utility, not a catalogued dataset.
@@ -304,9 +266,10 @@ export function scanJcl(root, opts = {}) {
304
266
  if (d && !stats.firstReadInPart) stats.firstReadInPart = `${path}${d.line ? `:${d.line}` : ''}: ${d.text}`;
305
267
  }
306
268
 
307
- 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))]));
308
270
  const ftpInput = new Map();
309
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))));
310
273
 
311
274
  for (const dd of r.dds) {
312
275
  if (!dd.inStream) continue;
@@ -332,13 +295,13 @@ export function scanJcl(root, opts = {}) {
332
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` });
333
296
  continue;
334
297
  }
335
- const sec = text.match(SECURITY_COMMAND);
298
+ const sec = !sqlDds.has(dd) && text.match(SECURITY_COMMAND);
336
299
  if (sec) {
337
300
  findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
338
301
  detail: `in-stream data issues ${sec[1].toUpperCase()}, which grants or alters authority, from step ${dd.step || '(none)'}` });
339
302
  continue;
340
303
  }
341
- if (ACF2_COMMAND.test(text)) {
304
+ if (!sqlDds.has(dd) && ACF2_COMMAND.test(text)) {
342
305
  findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
343
306
  detail: `in-stream data issues an ACF2 administration command from step ${dd.step || '(none)'}` });
344
307
  continue;
@@ -414,6 +377,7 @@ export function scanJcl(root, opts = {}) {
414
377
  }
415
378
  }
416
379
  }
380
+ controlCardFindings(r, path, findings);
417
381
  return src.length;
418
382
  }, { label: 'jcl', maxBytes: opts.maxSourceBytes ?? Infinity });
419
383
 
@@ -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
  }