@portll/cobolwork 0.3.0 → 0.5.0

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 (46) hide show
  1. package/README.md +35 -9
  2. package/bin/cobolwork.mjs +7 -1
  3. package/lib/bms.mjs +8 -0
  4. package/lib/capabilities.mjs +2 -2
  5. package/lib/cics-commands.mjs +308 -42
  6. package/lib/consequence.mjs +19 -0
  7. package/lib/constants.mjs +60 -0
  8. package/lib/control.mjs +61 -17
  9. package/lib/csd.mjs +6 -1
  10. package/lib/dataflow.mjs +107 -27
  11. package/lib/explain.mjs +31 -5
  12. package/lib/exploitability.mjs +49 -4
  13. package/lib/gate.mjs +31 -9
  14. package/lib/hlasm.mjs +79 -0
  15. package/lib/kernel/findings.mjs +2 -0
  16. package/lib/kernel/registry.mjs +6 -0
  17. package/lib/kernel/source-tree.mjs +1 -0
  18. package/lib/options.mjs +6 -2
  19. package/lib/packs.mjs +4 -0
  20. package/lib/parser.mjs +67 -12
  21. package/lib/precompile-cics.mjs +9 -3
  22. package/lib/precompile.mjs +3 -2
  23. package/lib/revision.json +1 -1
  24. package/lib/sarif.mjs +3 -2
  25. package/lib/scan.mjs +2 -1
  26. package/lib/sets/abend.mjs +186 -0
  27. package/lib/sets/compile.mjs +2 -1
  28. package/lib/sets/crypto.mjs +110 -0
  29. package/lib/sets/flow.mjs +18 -0
  30. package/lib/sets/hidden.mjs +1 -1
  31. package/lib/sets/hlasm.mjs +121 -0
  32. package/lib/sets/jcl.mjs +16 -1
  33. package/lib/sets/recon.mjs +1 -0
  34. package/lib/sets/web.mjs +99 -4
  35. package/lib/sources.mjs +12 -2
  36. package/lib/utilities.mjs +2 -0
  37. package/lib/verify.mjs +2 -1
  38. package/lib/words.mjs +377 -17
  39. package/package.json +1 -1
  40. package/rules/compliance-cobit2019.json +180 -0
  41. package/rules/compliance-dora.json +195 -0
  42. package/rules/compliance-ffiec.json +195 -0
  43. package/rules/compliance-nist80053.json +195 -0
  44. package/rules/gitleaks-mainframe.toml +24 -10
  45. package/rules/hlasm-operations.json +451 -0
  46. package/rules/icsf-services.json +1178 -0
package/lib/sets/web.mjs CHANGED
@@ -62,6 +62,18 @@ export const WEB_RULES = {
62
62
  impact: 'The TCPIPSERVICE takes connections on a port with SSL off, so everything the session carries - the 3270 data stream, a SOAP body, a credential on a sign-on screen - crosses the network in the clear and can be read or changed in flight',
63
63
  remedy: 'Set SSL(YES) and name a certificate on the TCPIPSERVICE, or front it with a TLS-terminating proxy the estate controls and restrict the port to that proxy',
64
64
  },
65
+ 'web-client-opens-cleartext': {
66
+ sev: 'med', evidence: 'construct', cwe: 'CWE-319',
67
+ text: 'A program opens an outbound HTTP connection without TLS',
68
+ impact: 'WEB OPEN asks for HTTP rather than HTTPS, so the request the program sends and the response it reads cross the network in the clear, readable and changeable in flight, unless an AT-TLS policy outside the program encrypts them',
69
+ remedy: 'Open the connection with HTTPS, or through a client URIMAP with SCHEME(HTTPS); where AT-TLS secures it, use a URIMAP with ATTLS(AWARE) so the program fails rather than send in the clear',
70
+ },
71
+ 'web-receive-length-exceeds-area': {
72
+ sev: 'high', evidence: 'construct', cwe: 'CWE-805',
73
+ text: 'A web receive lets CICS write more bytes than the area it writes into',
74
+ impact: 'CICS copies up to MAXLENGTH bytes of the body into the INTO area, so a body longer than the area, which the other end of the connection chooses, overwrites the storage after it',
75
+ remedy: 'Pass a MAXLENGTH no greater than the INTO area, or LENGTH OF that area',
76
+ },
65
77
  'web-link-opens-without-noopener': {
66
78
  sev: 'low', evidence: 'construct', cwe: 'CWE-1022',
67
79
  text: 'A program writes a link that opens a new window with a handle back to this one',
@@ -92,6 +104,18 @@ function execOptions(exec) {
92
104
  return { opts, words };
93
105
  }
94
106
 
107
+ // The options written outside any parentheses, which is where a CVDA keyword such as HTTP or HTTPS
108
+ // stands; a word inside an operand is a data name.
109
+ function keywords(exec) {
110
+ const out = new Set();
111
+ let depth = 0;
112
+ for (const t of exec.toks) {
113
+ if (t.t === 'sep') depth += t.v === '(' ? 1 : t.v === ')' ? -1 : 0;
114
+ else if (t.t === 'word' && depth === 0) out.add(t.u);
115
+ }
116
+ return out;
117
+ }
118
+
95
119
  // A header name or value is only knowable when the program wrote it as a literal. NAME(WS-HDR-NAME)
96
120
  // names a field whose contents this rule set cannot see, and a rule that guessed would be inventing
97
121
  // evidence, so the statement is counted as unreadable and reported as coverage, not as a pass.
@@ -152,6 +176,7 @@ export function scanWeb(root, opts = {}) {
152
176
  const stats = {
153
177
  filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0,
154
178
  webPrograms: 0, headersWritten: 0, headersComputed: 0, htmlResponses: 0, listenersDefined: 0,
179
+ outboundOpens: 0, opensUndecided: 0,
155
180
  };
156
181
  const where = (x, path) => ({ path: x.file ? relPath(root, x.file) : path, line: x.line || 1 });
157
182
 
@@ -161,12 +186,53 @@ export function scanWeb(root, opts = {}) {
161
186
  const cookies = [];
162
187
  let computedHeaders = 0;
163
188
 
189
+ const opens = []; // every outbound WEB OPEN, and what it says about TLS
190
+ const receives = []; // every receive into an area with a length it can be judged by
191
+ const item = (name) => p.items.find((i) => i.name === name);
192
+ // A variable naming a URIMAP or a host usually holds one literal from its VALUE clause.
193
+ const valueOf = (toks) => {
194
+ if (!toks || !toks.length) return null;
195
+ const lit = literalOf(toks);
196
+ if (lit !== null) return lit.trim();
197
+ const it = toks[0].t === 'word' ? item(toks[0].u) : null;
198
+ const v = it && it.values && it.values.find((x) => x.t === 'lit');
199
+ return v ? String(v.v).trim() : null;
200
+ };
201
+ // MAXLENGTH as a literal, or LENGTH OF an item. A data item's value can change before the
202
+ // command runs, so it is not judged.
203
+ const lengthOf = (toks) => {
204
+ if (!toks || !toks.length) return null;
205
+ if (toks.length === 1 && (toks[0].t === 'num' || /^\d+$/.test(String(toks[0].v)))) return { bytes: Number(toks[0].v), written: String(toks[0].v) };
206
+ if (toks.length >= 3 && toks[0].u === 'LENGTH' && toks[1].u === 'OF' && toks[2].t === 'word') {
207
+ const it = item(toks[2].u);
208
+ return it && it.size ? { bytes: it.size, written: `LENGTH OF ${toks[2].u}` } : null;
209
+ }
210
+ return null;
211
+ };
212
+
164
213
  for (const e of p.execs) {
165
214
  if (e.kind !== 'CICS') continue;
166
215
  const { opts: o, words: w } = execOptions(e);
167
216
  if (!w.includes('WEB') && !w.includes('DOCUMENT')) continue;
168
217
  const at = where(e, path);
169
218
 
219
+ if (w[0] === 'WEB' && w[1] === 'OPEN') {
220
+ const k = keywords(e);
221
+ // SCHEME(HTTPS) and SCHEME(DFHVALUE(HTTPS)) name the CVDA as the keyword does; any other
222
+ // operand is a field whose value is set at run time.
223
+ const named = (o.get('SCHEME') || []).map((t) => t.u);
224
+ const cvda = named.length && named.every((x) => ['DFHVALUE', 'HTTP', 'HTTPS'].includes(x)) ? named.find((x) => x !== 'DFHVALUE') : null;
225
+ opens.push({ at, https: k.has('HTTPS') || cvda === 'HTTPS', http: k.has('HTTP') || cvda === 'HTTP',
226
+ urimap: o.has('URIMAP') ? valueOf(o.get('URIMAP')) : undefined, host: o.has('HOST') ? valueOf(o.get('HOST')) : null });
227
+ continue;
228
+ }
229
+ if (w[0] === 'WEB' && (w[1] === 'RECEIVE' || w[1] === 'CONVERSE') && o.has('INTO')) {
230
+ const into = o.get('INTO')[0];
231
+ const area = into && into.t === 'word' ? item(into.u) : null;
232
+ const max = lengthOf(o.get('MAXLENGTH'));
233
+ if (area && area.size && max) receives.push({ at, verb: w[1], area: into.u, size: area.size, max });
234
+ }
235
+
170
236
  if (w.includes('WRITE') && w.includes('HTTPHEADER')) {
171
237
  const name = literalOf(o.get('HTTPHEADER') || o.get('NAME'));
172
238
  const value = literalOf(o.get('VALUE'));
@@ -228,19 +294,23 @@ export function scanWeb(root, opts = {}) {
228
294
  });
229
295
  const testsMethod = conditions.some((c) => c.some((t) => t.t === 'lit' && HTTP_METHOD.test(String(t.v).trim())));
230
296
 
231
- return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod };
297
+ return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod, opens, receives };
232
298
  }
233
299
 
234
300
  // The listener that accepts the request these responses answer. It is defined in a CSD rather
235
301
  // than in a program, so this reads every file the way lib/sets/priv.mjs does: a DFHCSDUP listing
236
302
  // is named whatever the estate named it, and filtering on an extension is how a BMS count once
237
303
  // came out as zero.
304
+ // Client URIMAPs come from the same files: WEB OPEN URIMAP(name) takes its scheme from one.
305
+ const urimaps = new Map();
238
306
  for (const f of all) {
239
307
  let src;
240
308
  try { src = tree.text(f).text; } catch { continue; }
241
- if (!/^\s*DEFINE\s+TCPIPSERVICE\s*\(/im.test(src)) continue;
309
+ if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src)) continue;
242
310
  const path = relPath(root, f);
243
- for (const [name, svc] of parseCsd(src).tcpipservices) {
311
+ const csd = parseCsd(src);
312
+ for (const [name, u] of csd.urimaps) if (!urimaps.has(name)) urimaps.set(name, { ...u, path });
313
+ for (const [name, svc] of csd.tcpipservices) {
244
314
  stats.listenersDefined++;
245
315
  if (encrypted(svc)) continue;
246
316
  const carries = svc.protocol ? `${/^[AEIOU]/i.test(svc.protocol) ? 'an' : 'a'} ${svc.protocol} ` : '';
@@ -269,9 +339,34 @@ export function scanWeb(root, opts = {}) {
269
339
  // outbound session or answers a web request has said something worth judging even when it
270
340
  // writes no header and sends no page.
271
341
  const anything = v.headers.length || v.sends.length || v.cookies.length || v.looksHtml
272
- || v.uris.length || v.built.length || (v.webRequest && v.changes.length);
342
+ || v.uris.length || v.built.length || (v.webRequest && v.changes.length) || v.opens.length || v.receives.length;
273
343
  if (!anything) continue;
274
344
  stats.webPrograms++;
345
+
346
+ for (const o of v.opens) {
347
+ stats.outboundOpens++;
348
+ const to = o.host ? ` to ${o.host}` : '';
349
+ if (o.https) continue;
350
+ if (o.http) {
351
+ findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
352
+ detail: `${v.id} opens a connection${to} asking for HTTP rather than HTTPS, so what it sends and reads crosses the network in the clear unless an AT-TLS policy outside the program encrypts it` });
353
+ continue;
354
+ }
355
+ const u = o.urimap ? urimaps.get(o.urimap.toUpperCase()) : null;
356
+ if (u && /^HTTP$/i.test(u.scheme || '') && !/^AWARE$/i.test(u.attls || '')) {
357
+ findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
358
+ detail: `${v.id} opens a connection through URIMAP ${o.urimap.toUpperCase()}, which ${u.path}:${u.line} defines with SCHEME(HTTP)${u.host ? ` to ${u.host}` : ''} and no ATTLS(AWARE), so what it sends and reads crosses the network in the clear unless an AT-TLS policy encrypts it` });
359
+ continue;
360
+ }
361
+ // A scheme in a variable, or a URIMAP the tree does not define, is decided where this cannot see.
362
+ if (!u || !u.scheme) stats.opensUndecided++;
363
+ }
364
+
365
+ for (const r of v.receives) {
366
+ if (r.max.bytes <= r.size) continue;
367
+ findings.push({ rule: 'web-receive-length-exceeds-area', ...r.at, program: v.id,
368
+ detail: `${v.id} has WEB ${r.verb} pass up to MAXLENGTH(${r.max.written}), ${r.max.bytes} bytes, into ${r.area}, which is ${r.size} bytes` });
369
+ }
275
370
  stats.headersWritten += v.headers.length;
276
371
  stats.headersComputed += v.computedHeaders;
277
372
 
package/lib/sources.mjs CHANGED
@@ -14,6 +14,9 @@ export const PROGRAM_EXT = ['.cbl', '.cob', '.cobol', '.sqb', '.pco'];
14
14
  export const COPY_EXT = ['.cpy', '.copy', '.inc'];
15
15
  export const JCL_EXT = ['.jcl', '.job', '.proc', '.prc', '.cntl'];
16
16
  export const BMS_EXT = ['.bms'];
17
+ // Assembler source. A BMS map or an IMS DBD or PSB is also assembler macros, and is often named .asm;
18
+ // lib/hlasm.mjs tells them apart by the macros they hold.
19
+ export const HLASM_EXT = ['.asm', '.mac', '.mlc', '.hlasm', '.assemble'];
17
20
 
18
21
  const has = (list) => (p) => list.includes(extname(p).toLowerCase());
19
22
 
@@ -46,6 +49,11 @@ function kindOfHead(head) {
46
49
  // A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
47
50
  // classified before classifies differently.
48
51
  if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
52
+ // Assembler source defines a section or a macro, and declares storage or a base register, in the
53
+ // operation field; either alone is a word a text file can hold. Tested after BMS, which is
54
+ // assembler macros too.
55
+ if (/^[^*\s]*[ \t]+(?:CSECT|RSECT|START|MACRO)(?=\s|$)/m.test(text)
56
+ && /^[^*\s]*[ \t]+(?:USING|DC|DS|BALR|BASR|STM|STMG|MEND)(?=\s|$)/m.test(text)) return 'hlasm';
49
57
  return null;
50
58
  }
51
59
 
@@ -116,10 +124,12 @@ export const isProgram = byExtOrContent(PROGRAM_EXT, 'program');
116
124
  export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
117
125
  export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
118
126
  export const isBms = byExtOrContent(BMS_EXT, 'bms');
119
- // Not BMS, by either route: the sets that read every source file have not chosen to read maps.
127
+ export const isAssembler = byExtOrContent(HLASM_EXT, 'hlasm');
128
+ // Not BMS or assembler, by either route: the sets that read every source file have not chosen to
129
+ // read maps or assembler.
120
130
  export const isSource = (p) => (extname(p)
121
131
  ? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
122
- : ![null, 'bms'].includes(sniffKind(p)));
132
+ : ![null, 'bms', 'hlasm'].includes(sniffKind(p)));
123
133
 
124
134
  // `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
125
135
  // files on it and no others.
package/lib/utilities.mjs CHANGED
@@ -118,6 +118,7 @@ function commands(lines) {
118
118
  // The values of the first of `names` given as a keyword in a command, as in INFILE(IN) or
119
119
  // OUTDDNAME(TAPE1,TAPE2). A name only counts at the start of a word, so INDD is not LOGINDD.
120
120
  function param(text, names) {
121
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- names are literal keyword lists
121
122
  const m = new RegExp(`(?:^|[\\s,])(?:${names.join('|')})\\s*\\(([^)]*)\\)`, 'i').exec(text);
122
123
  return m ? m[1].split(/[\s,]+/).filter(Boolean).map((v) => upper(v.replace(/^'|'$/g, ''))) : null;
123
124
  }
@@ -341,6 +342,7 @@ function icetoolCopies(step, dds) {
341
342
  for (const st of statements) {
342
343
  if (!['COPY', 'SORT', 'MERGE'].includes(st.op)) continue;
343
344
  const named = (kw) => st.operands.flatMap((o) => {
345
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- kw is a literal keyword
344
346
  const m = new RegExp(`^${kw}\\((.*)\\)$`, 'i').exec(o);
345
347
  return m ? m[1].split(',').map((n) => upper(n.trim())).filter(Boolean) : [];
346
348
  });
package/lib/verify.mjs CHANGED
@@ -18,7 +18,8 @@ const END_TASK = 'end the task from CEDF or CEDX, or stop the program in the deb
18
18
  // What goes in, and what reading it at the operation shows. `run` says what letting the operation run
19
19
  // shows; null means it would act on something, so the plan stops before it (`stopBefore`).
20
20
  const SINKS = {
21
- arithmetic: (x) => ({ value: `a letter where ${x.item} expects a digit, such as A in its first position`, reads: `${x.item} holding the letter`, run: 'the task abends ASRA, a data exception (S0C7 in batch), at the operation' }),
21
+ // A letter's low half is 1 to 9 and its zone a valid sign, so zoned arithmetic reads it as a digit.
22
+ arithmetic: (x) => ({ value: `an asterisk in every position of ${x.item}, whose digits it expects: a letter will not do, since zoned decimal reads a letter as a digit`, reads: `${x.item} holding the asterisks`, run: 'the task abends ASRA, a data exception (S0C7 in batch), at the operation' }),
22
23
  subscript: (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}, or 0`),
23
24
  'loop-bound': (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}`),
24
25
  'reference-modification': (x) => bounds(x, 'one more than the length of the field, or 0'),