@portll/cobolwork 0.0.1 → 0.2.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSING.md +93 -0
  3. package/NOTICE +9 -0
  4. package/README.md +323 -3
  5. package/THIRD-PARTY-NOTICES.md +118 -0
  6. package/bin/cobolwork.mjs +354 -0
  7. package/lib/advisories.mjs +133 -0
  8. package/lib/baseline.mjs +154 -0
  9. package/lib/bms.mjs +453 -0
  10. package/lib/build.mjs +402 -0
  11. package/lib/capabilities.mjs +79 -0
  12. package/lib/cics-commands.mjs +281 -0
  13. package/lib/compliance.mjs +81 -0
  14. package/lib/consequence.mjs +139 -0
  15. package/lib/control.mjs +1515 -0
  16. package/lib/csd.mjs +77 -0
  17. package/lib/dataflow.mjs +1506 -0
  18. package/lib/diff.mjs +344 -0
  19. package/lib/explain.mjs +145 -0
  20. package/lib/gate.mjs +383 -0
  21. package/lib/index.mjs +6 -0
  22. package/lib/inventory.mjs +79 -0
  23. package/lib/jcl.mjs +478 -0
  24. package/lib/kernel/findings.mjs +94 -0
  25. package/lib/kernel/identity.mjs +216 -0
  26. package/lib/kernel/memory.mjs +217 -0
  27. package/lib/kernel/printable.mjs +6 -0
  28. package/lib/kernel/registry.mjs +79 -0
  29. package/lib/kernel/ruleset.mjs +72 -0
  30. package/lib/kernel/source-tree.mjs +159 -0
  31. package/lib/kev.mjs +27 -0
  32. package/lib/options.mjs +512 -0
  33. package/lib/packs.mjs +148 -0
  34. package/lib/parser.mjs +2055 -0
  35. package/lib/policy.mjs +163 -0
  36. package/lib/precompile-cics.mjs +169 -0
  37. package/lib/precompile.mjs +544 -0
  38. package/lib/reach.mjs +122 -0
  39. package/lib/revision.json +1 -0
  40. package/lib/revision.mjs +89 -0
  41. package/lib/sarif.mjs +222 -0
  42. package/lib/scan.mjs +272 -0
  43. package/lib/sets/build.mjs +234 -0
  44. package/lib/sets/cics.mjs +306 -0
  45. package/lib/sets/compile.mjs +187 -0
  46. package/lib/sets/copybook.mjs +174 -0
  47. package/lib/sets/flow.mjs +487 -0
  48. package/lib/sets/hidden.mjs +216 -0
  49. package/lib/sets/jcl.mjs +440 -0
  50. package/lib/sets/log.mjs +406 -0
  51. package/lib/sets/opaque.mjs +102 -0
  52. package/lib/sets/priv.mjs +322 -0
  53. package/lib/sets/recon.mjs +267 -0
  54. package/lib/sets/vendor.mjs +117 -0
  55. package/lib/sets/web.mjs +327 -0
  56. package/lib/site.mjs +164 -0
  57. package/lib/sources.mjs +156 -0
  58. package/lib/tui/app.mjs +325 -0
  59. package/lib/tui/keys.mjs +39 -0
  60. package/lib/tui/model.mjs +96 -0
  61. package/lib/tui/run.mjs +38 -0
  62. package/lib/tui/screen.mjs +59 -0
  63. package/lib/tui/terminal.mjs +46 -0
  64. package/lib/utilities.mjs +296 -0
  65. package/lib/version.mjs +15 -0
  66. package/lib/words.mjs +318 -0
  67. package/package.json +45 -6
  68. package/rules/advisories.json +264 -0
  69. package/rules/compliance-dora.json +2151 -0
  70. package/rules/compliance-ffiec.json +2134 -0
  71. package/rules/compliance-nist80053.json +2134 -0
  72. package/rules/gitleaks-mainframe.toml +57 -0
  73. package/rules/kev-ids.json +1729 -0
  74. package/rules/packs/broadcom.json +124 -0
  75. package/rules/packs/connectdirect.json +116 -0
  76. package/rules/packs/controlm.json +114 -0
  77. package/rules/system-layouts.json +28 -0
  78. package/schema/cobolwork-coverage.schema.json +65 -0
  79. package/schema/cobolwork.policy.schema.json +54 -0
@@ -0,0 +1,440 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // What a job does that no COBOL source shows. A step decides which program runs, under whose
3
+ // authority, against which dataset; an in-stream SYSIN carries commands that never appear in any
4
+ // program the scanner reads. The 2026-09-18 evaluation counted 27 JCL files carrying RACF or
5
+ // IDCAMS commands in-stream, so this is where a repository's privileged surface actually lives.
6
+ import { inScope, isJcl, isProgram, relPath } from '../sources.mjs';
7
+ import { report } from '../kernel/ruleset.mjs';
8
+ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
+ import { parseJcl, dispositionOf } from '../jcl.mjs';
10
+ import { ddGroups } from '../utilities.mjs';
11
+ import { loadSite, productionQualifierOf, SITE_FILE } from '../site.mjs';
12
+ import { loadPacks, programsOf } from '../packs.mjs';
13
+ import { eachWithinMemory } from '../kernel/memory.mjs';
14
+
15
+
16
+ // FTP is an outbound channel the flow engine does not model, and a job can open it with no program
17
+ // in the tree. Two claims, on different lines. The session: plain FTP sends the logon and the data
18
+ // unencrypted, a construct and CWE-319, medium because the FTP.DATA the client finds for itself may
19
+ // ask for TLS where the job does not. The transfer: a dataset the site calls production leaving the
20
+ // system, CWE-201 as the flow set's other channels are, and high because it is worse than the
21
+ // channel it takes - though a transfer can be the job's whole purpose, which is why it is not
22
+ // critical.
23
+ export const JCL_RULES = {
24
+ 'jcl-instream-credential': {
25
+ sev: 'crit', evidence: 'construct', cwe: 'CWE-798',
26
+ text: 'A password or pass phrase is set in in-stream job data',
27
+ impact: 'Anyone who can read the repository reads the password, and it logs the job on wherever it is used',
28
+ remedy: 'Remove the password from the job; take the credential from a secured facility (a RACF keyring or a protected NETRC), never from in-stream JCL',
29
+ },
30
+ 'jcl-instream-security-command': {
31
+ sev: 'high', evidence: 'construct', cwe: 'CWE-284',
32
+ text: 'In-stream job data issues security product commands that grant or alter authority',
33
+ impact: "Anyone who can edit the job grants or alters authority when it runs, under the job's authority",
34
+ remedy: 'Keep RACF or security-product commands out of application job streams; run them through a controlled, reviewed administration process',
35
+ },
36
+ 'jcl-instream-destructive': {
37
+ sev: 'med', evidence: 'construct', cwe: 'CWE-284',
38
+ text: 'In-stream job data deletes or overwrites a catalogued dataset',
39
+ impact: 'Anyone who can edit the job deletes or overwrites a catalogued dataset when it runs',
40
+ remedy: 'Remove the in-stream delete/define of a catalogued dataset, or gate it behind a controlled change; do not carry it in an application job',
41
+ },
42
+ 'jcl-dlm-hides-instream': {
43
+ sev: 'med', evidence: 'tampering', cwe: 'CWE-1427',
44
+ text: 'In-stream data uses a non-default delimiter, so it does not end where a line-by-line reader expects',
45
+ impact: 'A non-default DLM carries the stream past the /* a line-by-line reader stops at, so a reviewer or scanner sees less than what runs',
46
+ remedy: 'Use the default delimiter, or make the DLM and where the stream really ends explicit to any reader',
47
+ },
48
+ 'jcl-parm-is-an-entry-point': { sev: 'info', evidence: 'context', cwe: 'CWE-20', text: 'A step passes PARM= to a program in this tree, which the flow rules follow as an untrusted source' },
49
+ 'jcl-exec-pgm-unresolved': { sev: 'info', evidence: 'coverage', cwe: null, text: 'A step runs a program that is neither a system utility nor defined by any source in this tree' },
50
+ 'jcl-ftp-cleartext': {
51
+ sev: 'med', evidence: 'construct', cwe: 'CWE-319',
52
+ text: 'A step runs FTP with no TLS option on its PARM or in its in-stream configuration, so its logon and data cross the network in cleartext',
53
+ impact: 'The logon and the data cross the network in cleartext, readable by anyone on the path',
54
+ remedy: 'Require TLS: -a TLS or -r TLS on the FTP PARM, or SECURE_MECHANISM TLS in the in-stream SYSFTPD',
55
+ },
56
+ 'jcl-ftp-sends-production-dataset': {
57
+ sev: 'high', evidence: 'construct', cwe: 'CWE-201',
58
+ text: 'A step sends a dataset the estate calls production off the system by FTP',
59
+ impact: 'A dataset the estate calls production leaves the system to the FTP partner',
60
+ remedy: 'Confirm the transfer and its partner are authorized for production data, and send over a TLS session',
61
+ },
62
+ };
63
+
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;
98
+ };
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
+
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;
151
+ }
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;
157
+ }
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;
164
+ }
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
+ }
169
+ }
170
+ const cleartext = tls === 'never' || (!tls && !configUnread);
171
+ return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, passwords, input };
172
+ }
173
+
174
+ // Credentials, which are the reason this rule set is critical rather than merely interesting.
175
+ // Each run of blanks follows the ( or = that separates it from the next, which keeps matching linear.
176
+ const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*)?['"]?([A-Z0-9@#$_.-]{3,})/i;
177
+
178
+ // Commands that grant, alter or remove authority, across the three security products in use on
179
+ // z/OS. Matching is on the command word at the start of a statement, because ADDUSER appearing in
180
+ // a comment or a report heading is not a command.
181
+ 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
+
183
+ // 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;
185
+
186
+ // Destruction in-stream. DELETE with a dataset name, or a REPRO that replaces its target. SQL's
187
+ // DELETE FROM removes rows through a database utility, not a catalogued dataset.
188
+ const DESTRUCTIVE = /^\s*(DELETE(?!\s+FROM\b)|ALTER\s+\S+\s+NEWNAME|REPRO\b[^\n]*\bREPLACE)\b/i;
189
+
190
+ // A dataset name as written in IDCAMS or on a DD. A symbol (&SYSUID, a procedure's &RLE) or a
191
+ // template's <USRHLQ> stands for a qualifier the job fills in, so it matches any one; the dot after a
192
+ // symbol ends it. An empty qualifier is a symbol the reader already filled with an empty default.
193
+ const dsnKey = (dsn) => String(dsn || '').replace(/^['(]+|[')]+$/g, '').replace(/\(.*$/, '').toUpperCase()
194
+ .replace(/&[A-Z0-9@#$]+\.?/g, '*').replace(/<[^>]*>/g, '*').replace(/\.(?=\.)/g, '.*');
195
+ const dsnPattern = (k) => new RegExp(`^${k.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^.]+')}$`);
196
+
197
+ // The programs whose in-stream data can delete a catalogued dataset. A step running another known
198
+ // program reads its own control cards: DFHCSDUP's DELETE GROUP removes CICS definitions, and a
199
+ // database utility's DELETE FROM removes rows.
200
+ const DATASET_UTILITIES = new Set(['IDCAMS', 'IKJEFT01', 'IKJEFT1A', 'IKJEFT1B', 'ADRDSSU']);
201
+ const sameDsn = (a, b) => !!a && !!b && a !== '*' && b !== '*' && (a === b || dsnPattern(a).test(b) || dsnPattern(b).test(a));
202
+
203
+ const DSN_CHARS = "[A-Z0-9$#@&<>.\\-]";
204
+ // The dataset a destructive statement names: DELETE's first operand, which IDCAMS lets a
205
+ // continuation carry to the next line, ALTER's entry, or a REPRO's OUTDATASET, or the dataset on the
206
+ // DD its OUTFILE names.
207
+ function destroyedDsn(text, stepDds, next = '') {
208
+ const del = new RegExp(`^\\s*DELETE\\s+\\(?\\s*'?(${DSN_CHARS}+)`, 'i').exec(text);
209
+ if (del && del[1] !== '-') return dsnKey(del[1]);
210
+ if (del || /^\s*DELETE\s*-?\s*$/i.test(text)) {
211
+ const cont = new RegExp(`^\\s*\\(?\\s*'?(${DSN_CHARS}+)`, 'i').exec(next);
212
+ return cont ? dsnKey(cont[1]) : null;
213
+ }
214
+ const alter = new RegExp(`^\\s*ALTER\\s+'?(${DSN_CHARS}+)`, 'i').exec(text);
215
+ if (alter) return dsnKey(alter[1]);
216
+ const ods = new RegExp(`\\b(?:OUTDATASET|ODS)\\s*\\(\\s*'?(${DSN_CHARS}+)`, 'i').exec(text);
217
+ if (ods) return dsnKey(ods[1]);
218
+ const ofile = /\b(?:OUTFILE|OFILE)\s*\(\s*([A-Z0-9$#@]+)/i.exec(text);
219
+ const dd = ofile && stepDds.find((d) => String(d.name || '').toUpperCase() === ofile[1].toUpperCase());
220
+ return dd && dd.dsn ? dsnKey(dd.dsn) : null;
221
+ }
222
+
223
+ // Whether the job that destroys a dataset makes it again: a clear-down before a rebuild or a reset
224
+ // of its own dataset, not the destruction of data the job does not own. Either a step other than
225
+ // IEFBR14 allocates it new, or in-stream IDCAMS defines it again under the same name.
226
+ function rebuilds(r, dsn) {
227
+ if (!dsn) return false;
228
+ for (const dd of r.dds) {
229
+ const step = r.steps.find((s) => s.name === dd.step);
230
+ const pgm = String(step?.pgm || '').toUpperCase();
231
+ if (pgm && pgm !== 'IEFBR14' && dispositionOf(dd.disp)?.status === 'NEW' && sameDsn(dsnKey(dd.dsn), dsn)) return true;
232
+ }
233
+ const text = r.dds.filter((d) => d.inStream).flatMap((d) => d.inStream.map((l) => l.text)).join('\n');
234
+ return [...text.matchAll(new RegExp(`\\bNAME\\s*\\(\\s*'?(${DSN_CHARS}+)`, 'gi'))].some((m) => sameDsn(dsnKey(m[1]), dsn));
235
+ }
236
+
237
+ // Programs the system supplies. A step naming one of these is ordinary, and reporting every
238
+ // IEFBR14 as an unresolved program would bury the finding this rule exists for.
239
+ const SYSTEM_PROGRAMS = new Set([
240
+ 'IEFBR14', 'IEBGENER', 'IEBCOPY', 'IEBUPDTE', 'IEBCOMPR', 'IEBPTPCH', 'IEBDG', 'IEBEDIT',
241
+ 'IDCAMS', 'IKJEFT01', 'IKJEFT1A', 'IKJEFT1B', 'IEHLIST', 'IEHPROGM', 'IEHINITT', 'IEHMOVE',
242
+ 'SORT', 'ICEMAN', 'ICETOOL', 'SYNCSORT', 'DFSORT', 'ADRDSSU', 'IEWL', 'IEWBLINK', 'HEWL',
243
+ 'IGYCRCTL', 'IGYWCL', 'ASMA90', 'IEV90', 'IKJEFTSR', 'IRRUT100', 'IRRUT200', 'IRRUT400',
244
+ 'IRRDBU00', 'ICKDSF', 'IEFBR14', 'FTP', 'DSNUTILB', 'DSNTEP2', 'DSNTIAD', 'DFHCSDUP',
245
+ 'CSQUTIL', 'CSQJU003', 'EZACFSM1', 'IOEAGFMT', 'BPXBATCH', 'JVMLDM86', 'PGM=*.DD',
246
+ ]);
247
+
248
+ // The program names a tree defines, by PROGRAM-ID rather than by file name, because a member is
249
+ // called by the name it declares. Read with a regular expression rather than the full parser: this
250
+ // is a membership question over every program in the tree and does not need a data division.
251
+ // The `unread` list is the caller's: a program this cannot read is a program whose PROGRAM-ID is
252
+ // not in the set, so a step running it reads as unresolved. That is a claim about the tree, and it
253
+ // has to be attributable to a file rather than inferred from a silence. This catch previously
254
+ // discarded the error, and when a refactor left `tree` out of scope it turned a ReferenceError
255
+ // into an empty result.
256
+ function programIds(tree, files, unread) {
257
+ const ids = new Set();
258
+ for (const f of files) {
259
+ let src;
260
+ try { src = tree.text(f).text; } catch (e) { unread.push(`${tree.rel(f)}: ${e.code || e.name}`); continue; }
261
+ for (const m of src.matchAll(/^[^*\n]{0,6}\s*PROGRAM-ID\s*\.\s*['"]?([A-Z0-9$#@_-]+)/gim)) {
262
+ ids.add(m[1].toUpperCase());
263
+ }
264
+ }
265
+ return ids;
266
+ }
267
+
268
+ export function scanJcl(root, opts = {}) {
269
+ const tree = treeFor(root, opts);
270
+ const all = tree.list().filter(inScope(opts));
271
+ const jclFiles = all.filter(isJcl);
272
+ const programsUnread = [];
273
+ const defined = programIds(tree, all.filter(isProgram), programsUnread);
274
+
275
+ // A vendor product supplies its own utilities. An estate that has loaded the pack for a product
276
+ // is telling us those programs exist, so reporting them as undefined would be a false positive
277
+ // created by the customer having told us the truth.
278
+ const site = loadSite(root, opts.site || null);
279
+ const { loaded } = loadPacks(opts.packs || site.vendorPacks || [], { allowUnvalidated: true });
280
+ const vendorPrograms = programsOf(loaded);
281
+
282
+ const findings = [];
283
+ const stats = { filesScanned: 0, filesUnreadable: 0, jobs: 0, steps: 0, instreamStreams: 0,
284
+ vendorProgramsKnown: vendorPrograms.size, coverageIncomplete: false,
285
+ // A program whose PROGRAM-ID could not be read is a program a step may resolve to and this set
286
+ // will not know it. Named, because the alternative is a jcl-exec-pgm-unresolved finding whose
287
+ // real cause was a file nobody could open.
288
+ ...(programsUnread.length ? { programsUnread, coverageIncomplete: true } : {}) };
289
+
290
+ const run = eachWithinMemory(jclFiles, (f) => {
291
+ let src;
292
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
293
+ const path = relPath(root, f);
294
+
295
+ let r;
296
+ try { r = parseJcl(src, f, { symbols: opts.symbols || {} }); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
297
+ stats.filesScanned++;
298
+ stats.jobs += r.jobs.length;
299
+ stats.steps += r.steps.length;
300
+ if (r.coverageIncomplete) {
301
+ stats.coverageIncomplete = true;
302
+ stats.filesReadInPart = (stats.filesReadInPart || 0) + 1;
303
+ const d = r.diags.find((x) => x.sev === 'error') || r.diags.find((x) => x.sev === 'warn' && /INCLUDE|symbolic/.test(x.text));
304
+ if (d && !stats.firstReadInPart) stats.firstReadInPart = `${path}${d.line ? `:${d.line}` : ''}: ${d.text}`;
305
+ }
306
+
307
+ const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s)]));
308
+ const ftpInput = new Map();
309
+ for (const ftp of ftpSteps.values()) for (const dd of ftp.input) ftpInput.set(dd, ftp);
310
+
311
+ for (const dd of r.dds) {
312
+ if (!dd.inStream) continue;
313
+ stats.instreamStreams++;
314
+
315
+ if (dd.dlm) {
316
+ findings.push({ rule: 'jcl-dlm-hides-instream', path, line: dd.line, step: dd.step,
317
+ detail: `${dd.name || 'a DD'} in step ${dd.step || '(none)'} uses DLM=${dd.dlm}, so its ${dd.inStream.length} lines do not end at /*` });
318
+ }
319
+
320
+ const ftp = ftpInput.get(dd);
321
+ for (const [at, l] of dd.inStream.entries()) {
322
+ const text = l.text;
323
+ const cred = text.match(CREDENTIAL);
324
+ if (cred) {
325
+ findings.push({ rule: 'jcl-instream-credential', path, line: l.line, step: dd.step,
326
+ detail: `in-stream data sets ${cred[0].split(/[\s(=]/)[0].toUpperCase()} in ${dd.name || 'a DD'}, in a file anyone who can read the repository can read` });
327
+ continue; // one line is one finding, and the credential is the more serious reading
328
+ }
329
+ const logon = ftp && ftp.passwords.find((p) => p.dd === dd && p.line === l.line);
330
+ if (logon) {
331
+ findings.push({ rule: 'jcl-instream-credential', path, line: l.line, step: dd.step,
332
+ 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
+ continue;
334
+ }
335
+ const sec = text.match(SECURITY_COMMAND);
336
+ if (sec) {
337
+ findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
338
+ detail: `in-stream data issues ${sec[1].toUpperCase()}, which grants or alters authority, from step ${dd.step || '(none)'}` });
339
+ continue;
340
+ }
341
+ if (ACF2_COMMAND.test(text)) {
342
+ findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
343
+ detail: `in-stream data issues an ACF2 administration command from step ${dd.step || '(none)'}` });
344
+ continue;
345
+ }
346
+ // FTP's DELETE removes a file on the remote host, not a catalogued dataset.
347
+ const stepPgm = String(r.steps.find((s) => s.name === dd.step)?.pgm || '').toUpperCase();
348
+ const del = !ftp && (!stepPgm || DATASET_UTILITIES.has(stepPgm)) && text.match(DESTRUCTIVE);
349
+ if (del) {
350
+ const target = destroyedDsn(text, r.dds.filter((d) => d.step === dd.step), dd.inStream[at + 1]?.text || '');
351
+ const clearDown = rebuilds(r, target);
352
+ const production = !clearDown && target && !target.includes('*') && productionQualifierOf(target, site.productionQualifiers);
353
+ // The verb, never the rest of the line: a REPRO's line can carry anything, keys included.
354
+ const verb = /^REPRO/i.test(del[1]) ? 'REPRO ... REPLACE' : /^ALTER/i.test(del[1]) ? 'ALTER ... NEWNAME' : 'DELETE';
355
+ findings.push({ rule: 'jcl-instream-destructive', path, line: l.line, step: dd.step,
356
+ ...(clearDown ? { sev: 'info', clearDown: true } : production ? { sev: 'high' } : {}),
357
+ detail: `in-stream data runs ${verb} against a catalogued dataset in step ${dd.step || '(none)'}${clearDown ? ', and the same job makes it again: a clear-down before a rebuild'
358
+ : production ? `, in the production qualifier ${production}` : ''}` });
359
+ }
360
+ }
361
+ }
362
+
363
+ for (const s of r.steps) {
364
+ if (!s.pgm) continue;
365
+ const pgm = s.pgm.toUpperCase();
366
+
367
+ // PARM is the mainframe's argv. The flow engine now follows it into the program's first
368
+ // LINKAGE item, so this row is not the finding - it is the inventory of where untrusted data
369
+ // enters, which is worth having whether or not any of it reaches a sink.
370
+ if (s.parm && defined.has(pgm)) {
371
+ findings.push({ rule: 'jcl-parm-is-an-entry-point', path, line: s.line, step: s.name,
372
+ detail: `step ${s.name} passes PARM= to ${pgm}, whose source is in this tree, so the flow rules treat that parameter as untrusted` });
373
+ }
374
+
375
+ if (!defined.has(pgm) && !SYSTEM_PROGRAMS.has(pgm) && !vendorPrograms.has(pgm) && !pgm.startsWith('*.')) {
376
+ findings.push({ rule: 'jcl-exec-pgm-unresolved', path, line: s.line, step: s.name,
377
+ detail: `step ${s.name} runs ${pgm}, which no source in this tree defines, which is not a system utility, and which no loaded vendor pack supplies` });
378
+ }
379
+
380
+ const ftp = ftpSteps.get(s);
381
+ if (ftp) {
382
+ const who = `step ${s.name || '(unnamed)'}`;
383
+ const to = ftp.host ? `to ${ftp.host}` : 'to the host its input names';
384
+ stats.ftpSteps = (stats.ftpSteps || 0) + 1;
385
+ if (ftp.inputUnread) stats.ftpInputUnread = (stats.ftpInputUnread || 0) + 1;
386
+ if (ftp.configUnread) stats.ftpConfigUnread = (stats.ftpConfigUnread || 0) + 1;
387
+ const undecided = ftp.sends.filter((x) => x.relative).length;
388
+ if (undecided) stats.ftpSendsUndecided = (stats.ftpSendsUndecided || 0) + undecided;
389
+
390
+ if (ftp.cleartext) {
391
+ const sent = ftp.sends.flatMap((x) => x.dsns);
392
+ const what = sent.length ? `; it sends ${sent.join(', ')}`
393
+ : ftp.inputUnread ? `; its subcommands are in ${ftp.inputUnread}, which this reader cannot see` : '';
394
+ findings.push({ rule: 'jcl-ftp-cleartext', path, line: s.line, step: s.name,
395
+ detail: `${who} runs FTP ${to} with no TLS option on its PARM or in an in-stream SYSFTPD, so its logon, ${ftp.logon ? 'from NETRC' : 'from its input'}, and its data cross the network in cleartext unless the FTP.DATA the client finds for itself requires TLS${what}` });
396
+ }
397
+
398
+ // One finding for each line that sends production data, however many datasets it names.
399
+ const byLine = new Map();
400
+ for (const x of ftp.sends) {
401
+ if (!x.dsns.length) continue;
402
+ if (!site.productionQualifiers.length) { stats.ftpSendsNotChecked = (stats.ftpSendsNotChecked || 0) + 1; continue; }
403
+ for (const dsn of x.dsns) {
404
+ const q = productionQualifierOf(dsn, site.productionQualifiers);
405
+ if (!q) continue;
406
+ if (!byLine.has(x.line)) byLine.set(x.line, { verb: x.verb, dsns: [], qualifiers: new Set() });
407
+ byLine.get(x.line).dsns.push(dsn);
408
+ byLine.get(x.line).qualifiers.add(q);
409
+ }
410
+ }
411
+ for (const [line, hit] of byLine) {
412
+ findings.push({ rule: 'jcl-ftp-sends-production-dataset', path, line, step: s.name,
413
+ detail: `${who} sends ${hit.dsns.join(', ')} ${to} by FTP ${hit.verb}, and ${[...hit.qualifiers].join(', ')} is a production qualifier${ftp.cleartext ? ', over a session with no TLS option' : ''}` });
414
+ }
415
+ }
416
+ }
417
+ return src.length;
418
+ }, { label: 'jcl', maxBytes: opts.maxSourceBytes ?? Infinity });
419
+
420
+ // Without production qualifiers the transfer rule has not looked, which is not a clean result.
421
+ if (stats.ftpSendsNotChecked) {
422
+ stats.setIncomplete = true;
423
+ stats.notLooked = [`${stats.ftpSendsNotChecked} FTP transfer(s) send a named dataset, and ${SITE_FILE} names no production qualifier, so the production-data rule did not run on them`];
424
+ }
425
+ // With qualifiers declared, a local name relative to a prefix the job does not show names no
426
+ // dataset, so it is the one send the rule cannot judge. The case above needs no qualifiers, so
427
+ // the two never both apply.
428
+ if (stats.ftpSendsUndecided && site.productionQualifiers.length) {
429
+ stats.setIncomplete = true;
430
+ stats.notLooked = [`${stats.ftpSendsUndecided} FTP transfer(s) name the local dataset relative to a prefix the job does not show, so whether each sends production data was not judged`];
431
+ }
432
+
433
+ // coverageIncomplete is already true here when the JCL parser could not resolve an INCLUDE or
434
+ // a symbolic. report() ORs rather than overwrites, so that claim survives.
435
+ if (stats.filesReadInPart) {
436
+ stats.readInPart = `${stats.filesReadInPart} JCL file(s) were read in part - an INCLUDE not resolved, a symbolic with no value, or a statement that did not parse`
437
+ + (stats.firstReadInPart ? `; the first is ${stats.firstReadInPart}` : '');
438
+ }
439
+ return report('jcl', { rules: JCL_RULES, findings, stats, run });
440
+ }