@portll/cobolwork 0.4.0 → 0.6.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 (78) hide show
  1. package/README.md +53 -5
  2. package/bin/cobolwork.mjs +34 -9
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/bms.mjs +8 -0
  5. package/lib/build.mjs +33 -8
  6. package/lib/capabilities.mjs +6 -3
  7. package/lib/consequence.mjs +17 -0
  8. package/lib/constants.mjs +60 -0
  9. package/lib/control-reuse.mjs +113 -0
  10. package/lib/control.mjs +86 -18
  11. package/lib/csd.mjs +6 -1
  12. package/lib/dataflow.mjs +343 -95
  13. package/lib/equivalence.mjs +44 -5
  14. package/lib/evidence/cli.mjs +13 -5
  15. package/lib/evidence/record.mjs +16 -6
  16. package/lib/evidence/seal.mjs +17 -0
  17. package/lib/evidence/store.mjs +30 -14
  18. package/lib/evidence/timestamp.mjs +83 -0
  19. package/lib/evidence/verify.mjs +120 -42
  20. package/lib/hlasm/asm/data.mjs +116 -0
  21. package/lib/hlasm/asm/listing.mjs +43 -0
  22. package/lib/hlasm/asm/output.mjs +45 -0
  23. package/lib/hlasm/asm/sections.mjs +159 -0
  24. package/lib/hlasm/asm/symbols.mjs +78 -0
  25. package/lib/hlasm/exec.mjs +27 -0
  26. package/lib/hlasm/expr.mjs +167 -0
  27. package/lib/hlasm/instr.mjs +48 -0
  28. package/lib/hlasm/locate.mjs +333 -0
  29. package/lib/hlasm/macro/authorization.mjs +126 -0
  30. package/lib/hlasm/macro/datasets.mjs +94 -0
  31. package/lib/hlasm/macro/io.mjs +139 -0
  32. package/lib/hlasm/macro/le.mjs +53 -0
  33. package/lib/hlasm/macro/linkage.mjs +143 -0
  34. package/lib/hlasm/macro/operator.mjs +66 -0
  35. package/lib/hlasm/macro/program.mjs +142 -0
  36. package/lib/hlasm/macro/recovery.mjs +80 -0
  37. package/lib/hlasm/macro/storage.mjs +117 -0
  38. package/lib/hlasm/macro/structured.mjs +131 -0
  39. package/lib/hlasm/model.mjs +72 -0
  40. package/lib/hlasm/operands.mjs +49 -0
  41. package/lib/hlasm/read.mjs +116 -0
  42. package/lib/hlasm.mjs +79 -0
  43. package/lib/inventory.mjs +10 -7
  44. package/lib/ironwork-ids.mjs +24 -0
  45. package/lib/ironwork.mjs +4 -1
  46. package/lib/kernel/registry.mjs +24 -17
  47. package/lib/kernel/shared-pass.mjs +138 -0
  48. package/lib/options.mjs +26 -4
  49. package/lib/policy.mjs +6 -0
  50. package/lib/precompile-cics.mjs +1 -1
  51. package/lib/revision.json +1 -1
  52. package/lib/sarif.mjs +3 -0
  53. package/lib/scan.mjs +21 -11
  54. package/lib/sets/abend.mjs +115 -13
  55. package/lib/sets/build.mjs +43 -37
  56. package/lib/sets/cics.mjs +5 -3
  57. package/lib/sets/compile.mjs +5 -3
  58. package/lib/sets/copybook.mjs +5 -3
  59. package/lib/sets/crypto.mjs +110 -0
  60. package/lib/sets/flow.mjs +32 -7
  61. package/lib/sets/hlasm.mjs +177 -0
  62. package/lib/sets/jcl.mjs +15 -1
  63. package/lib/sets/log.mjs +5 -3
  64. package/lib/sets/priv.mjs +5 -3
  65. package/lib/sets/secrets.mjs +110 -0
  66. package/lib/sets/semantics.mjs +5 -3
  67. package/lib/sets/web.mjs +104 -7
  68. package/lib/sources.mjs +12 -2
  69. package/package.json +1 -1
  70. package/rules/compliance-cobit2019.json +216 -0
  71. package/rules/compliance-dora.json +234 -0
  72. package/rules/compliance-ffiec.json +234 -0
  73. package/rules/compliance-nist80053.json +234 -0
  74. package/rules/gitleaks-mainframe.toml +61 -16
  75. package/rules/hlasm-instructions.json +2396 -0
  76. package/rules/hlasm-operations.json +451 -0
  77. package/rules/icsf-services.json +1178 -0
  78. package/schema/cobolwork.policy.schema.json +7 -0
package/lib/sets/log.mjs CHANGED
@@ -23,7 +23,7 @@ import { parseJcl } from '../jcl.mjs';
23
23
  import { compileStepOptions } from '../options.mjs';
24
24
  import { report } from '../kernel/ruleset.mjs';
25
25
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
26
- import { eachWithinMemory } from '../kernel/memory.mjs';
26
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
27
27
 
28
28
 
29
29
  export const LOG_RULES = {
@@ -337,7 +337,7 @@ const memberOf = (file) => basename(String(file || ''), extname(String(file || '
337
337
  const runByJob = (batch, p, file) => batch.unsure
338
338
  || [String(p.id || '').toUpperCase(), memberOf(file)].some((n) => n && (batch.runs.has(n) || batch.runs.has(n.slice(0, 8))));
339
339
 
340
- export function scanLog(root, opts = {}) {
340
+ export function* scanLogSteps(root, opts = {}) {
341
341
  const tree = treeFor(root, opts);
342
342
  const files = tree.list().filter(isProgram).filter(inScope(opts));
343
343
  const findings = [];
@@ -364,7 +364,7 @@ export function scanLog(root, opts = {}) {
364
364
  return { rule: 'display-echoes-a-credential', detail: `${p.id} shows ${w.name} on the terminal to the person who typed it: not a log, but the screen and its scrollback keep it` };
365
365
  };
366
366
 
367
- const run = eachWithinMemory(files, (f) => {
367
+ const run = yield loopOver(files, (f) => {
368
368
  let src;
369
369
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
370
370
  // Every EXEC CICS WRITE form, not the two this set started with. WRITE OPERATOR was added as a
@@ -404,3 +404,5 @@ export function scanLog(root, opts = {}) {
404
404
 
405
405
  return report('log', { rules: LOG_RULES, findings, stats, run });
406
406
  }
407
+
408
+ export const scanLog = (root, opts = {}) => drive(scanLogSteps(root, opts));
package/lib/sets/priv.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { inScope, isProgram, relPath } from '../sources.mjs';
20
20
  import { report } from '../kernel/ruleset.mjs';
21
21
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
22
- import { eachWithinMemory } from '../kernel/memory.mjs';
22
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
23
23
  import { parseCsd } from '../csd.mjs';
24
24
  import { parseJcl } from '../jcl.mjs';
25
25
  import { loadSite } from '../site.mjs';
@@ -101,7 +101,7 @@ function under(dsn, prefixes) {
101
101
  });
102
102
  }
103
103
 
104
- export function scanPriv(root, opts = {}) {
104
+ export function* scanPrivSteps(root, opts = {}) {
105
105
  const site = loadSite(root, opts.site || null, opts.tree);
106
106
  const tree = treeFor(root, opts);
107
107
  const files = tree.list().filter(inScope(opts));
@@ -140,7 +140,7 @@ export function scanPriv(root, opts = {}) {
140
140
  const diagnostics = [];
141
141
  const uss = [];
142
142
 
143
- const run = eachWithinMemory(files, (f) => {
143
+ const run = yield loopOver(files, (f) => {
144
144
  let src;
145
145
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
146
146
  const path = relPath(root, f);
@@ -320,3 +320,5 @@ export function scanPriv(root, opts = {}) {
320
320
 
321
321
  return report('priv', { rules: PRIV_RULES, findings, stats, run });
322
322
  }
323
+
324
+ export const scanPriv = (root, opts = {}) => drive(scanPrivSteps(root, opts));
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Credentials written into COBOL source. The shapes are the ones rules/gitleaks-mainframe.toml gives
3
+ // gitleaks, read from that file, so one list serves both and the build gate does not depend on
4
+ // gitleaks being installed. Go's RE2 syntax is taken only where it means the same in JavaScript; any
5
+ // other construct is refused when the file is read.
6
+ import { readFileSync } from 'node:fs';
7
+ import { inScope, isProgram, isCopybook, relPath } from '../sources.mjs';
8
+ import { report } from '../kernel/ruleset.mjs';
9
+ import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
10
+ import { eachWithinMemory } from '../kernel/memory.mjs';
11
+
12
+ export const SECRETS_RULES = {
13
+ 'credential-in-source': {
14
+ sev: 'high', evidence: 'construct', cwe: 'CWE-798', measured: false,
15
+ text: 'A credential is written into the source',
16
+ impact: 'Everyone who can read the source, its history or a listing can sign on with it, and changing it means changing and redeploying the program',
17
+ remedy: 'Read the credential at run time from where the site keeps them (a RACF-protected data set, a PassTicket, a Db2 trusted context or a CICS-supplied identity), and change the one already exposed',
18
+ },
19
+ };
20
+
21
+ // The TOML ids of the shapes that occur in COBOL programs and copybooks, and how a finding names each.
22
+ const SHAPES = {
23
+ 'cobol-value-credential': (m) => `${dataName(m) || 'a data item named for a credential'} has a literal VALUE`,
24
+ 'embedded-sql-connect-password': () => 'EXEC SQL CONNECT gives a literal password',
25
+ 'cics-signon-password': (m) => `EXEC CICS ${/\b(SIGNON|VERIFY|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal PASSWORD`,
26
+ 'cics-new-password': (m) => `EXEC CICS ${/\b(SIGNON|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal NEWPASSWORD`,
27
+ };
28
+
29
+ const dataName = (m) => (/\b(?:0?[1-9]|[1-4][0-9]|77)\s+([A-Z0-9][A-Z0-9-]*)/i.exec(m) || [])[1]?.toUpperCase();
30
+
31
+ const value = (raw) => {
32
+ const t = raw.trim();
33
+ if (t.startsWith("'''")) return t.slice(3, t.lastIndexOf("'''"));
34
+ if (t.startsWith('"')) return JSON.parse(t);
35
+ if (t.startsWith('[')) return [...t.matchAll(/'''(.*?)'''|"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1] ?? JSON.parse(`"${x[2]}"`));
36
+ if (/^\d+$/.test(t)) return Number(t);
37
+ if (t === 'true' || t === 'false') return t === 'true';
38
+ throw new Error(`gitleaks-mainframe.toml: value not read: ${t}`);
39
+ };
40
+
41
+ // The [[rules]] tables of the file, each with its allowlist folded in.
42
+ export function readToml(text) {
43
+ const rules = [];
44
+ let into = null;
45
+ for (const line of text.split('\n')) {
46
+ const l = line.trim();
47
+ if (!l || l.startsWith('#')) continue;
48
+ if (l === '[[rules]]') { rules.push((into = { allowlist: {} })); continue; }
49
+ if (l === '[rules.allowlist]') { into = rules.at(-1).allowlist; continue; }
50
+ if (l.startsWith('[')) { into = null; continue; }
51
+ const kv = /^([A-Za-z]+)\s*=\s*(.+)$/.exec(l);
52
+ if (kv && into) into[kv[1]] = value(kv[2]);
53
+ }
54
+ return rules;
55
+ }
56
+
57
+ // A gitleaks regex as a JavaScript one: a leading (?flags) group becomes the flags.
58
+ export function jsRegex(re, extra = '') {
59
+ const lead = /^\(\?([ims]+)\)/.exec(re);
60
+ const body = lead ? re.slice(lead[0].length) : re;
61
+ if (/\(\?[a-zA-Z]|\(\?P<|\\[AzZpPQE]/.test(body)) throw new Error(`gitleaks-mainframe.toml: no JavaScript equivalent for ${re}`);
62
+ return new RegExp(body, [...new Set([...(lead ? lead[1] : ''), ...extra])].join(''));
63
+ }
64
+
65
+ const secretOf = (m, group) => (group ? m[group] : m.slice(1).find((g) => g !== undefined)) ?? m[0];
66
+
67
+ export const SHAPE_RULES = readToml(readFileSync(new URL('../../rules/gitleaks-mainframe.toml', import.meta.url), 'utf8'))
68
+ .filter((r) => r.id in SHAPES)
69
+ .map((r) => ({
70
+ id: r.id,
71
+ pattern: jsRegex(r.regex, 'g'),
72
+ secretGroup: r.secretGroup || 0,
73
+ keywords: (r.keywords || []).map((k) => k.toLowerCase()),
74
+ allow: (r.allowlist.regexes || []).map((x) => jsRegex(x)),
75
+ allowTarget: r.allowlist.regexTarget || 'secret',
76
+ }));
77
+
78
+ // Every shape in the text, at the line its first non-blank character is on.
79
+ export function credentialsIn(text) {
80
+ const lower = text.toLowerCase();
81
+ const out = [];
82
+ for (const s of SHAPE_RULES) {
83
+ if (s.keywords.length && !s.keywords.some((k) => lower.includes(k))) continue;
84
+ for (const m of text.matchAll(s.pattern)) {
85
+ const target = s.allowTarget === 'match' ? m[0] : secretOf(m, s.secretGroup);
86
+ if (s.allow.some((a) => a.test(target))) continue;
87
+ const start = m.index + (m[0].length - m[0].trimStart().length);
88
+ out.push({ shape: s.id, line: text.slice(0, start).split('\n').length, detail: SHAPES[s.id](m[0]) });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export function scanSecrets(root, opts = {}) {
95
+ const tree = treeFor(root, opts);
96
+ const files = tree.list().filter((f) => isProgram(f) || isCopybook(f)).filter(inScope(opts));
97
+ const findings = [];
98
+ const stats = { filesScanned: 0, filesUnreadable: 0 };
99
+
100
+ const run = eachWithinMemory(files, (f) => {
101
+ let src;
102
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
103
+ stats.filesScanned++;
104
+ const path = relPath(root, f);
105
+ for (const c of credentialsIn(src)) findings.push({ rule: 'credential-in-source', path, line: c.line, detail: `${c.detail} (${c.shape})` });
106
+ return src.length;
107
+ }, { label: 'secrets', maxBytes: opts.maxSourceBytes ?? Infinity });
108
+
109
+ return report('secrets', { rules: SECRETS_RULES, findings, stats, run });
110
+ }
@@ -9,7 +9,7 @@ import { basename, extname } from 'node:path';
9
9
  import { inScope, isProgram, isJcl, relPath, ebcdicByte } from '../sources.mjs';
10
10
  import { report } from '../kernel/ruleset.mjs';
11
11
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
12
- import { eachWithinMemory } from '../kernel/memory.mjs';
12
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
13
13
  import { loadSite } from '../site.mjs';
14
14
  import { parseJcl } from '../jcl.mjs';
15
15
  import { optionCards, optionTokens, compileStepOptions } from '../options.mjs';
@@ -206,7 +206,7 @@ function thruPairs(toks) {
206
206
  }
207
207
  const WHEN_WORDS = new Set(['THRU', 'THROUGH', 'ALSO', 'OR', 'NOT', 'ANY', 'OTHER', 'TRUE', 'FALSE']);
208
208
 
209
- export function scanSemantics(root, opts = {}) {
209
+ export function* scanSemanticsSteps(root, opts = {}) {
210
210
  const tree = treeFor(root, opts);
211
211
  const all = tree.list().filter(inScope(opts));
212
212
  const files = all.filter(isProgram);
@@ -359,7 +359,7 @@ export function scanSemantics(root, opts = {}) {
359
359
  }
360
360
  }
361
361
 
362
- const run = eachWithinMemory(files, (f) => {
362
+ const run = yield loopOver(files, (f) => {
363
363
  let src;
364
364
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
365
365
  let r;
@@ -375,3 +375,5 @@ export function scanSemantics(root, opts = {}) {
375
375
 
376
376
  return report('semantics', { rules: SEMANTICS_RULES, findings, stats, run });
377
377
  }
378
+
379
+ export const scanSemantics = (root, opts = {}) => drive(scanSemanticsSteps(root, opts));
package/lib/sets/web.mjs CHANGED
@@ -22,7 +22,7 @@ import { parseCsd } from '../csd.mjs';
22
22
  import { classify } from './log.mjs';
23
23
  import { report } from '../kernel/ruleset.mjs';
24
24
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
25
- import { eachWithinMemory } from '../kernel/memory.mjs';
25
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
26
26
 
27
27
 
28
28
  export const WEB_RULES = {
@@ -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.
@@ -144,7 +168,7 @@ const HTTP_METHOD = /^(GET|POST|PUT|PATCH|DELETE)$/i;
144
168
  // the absence and the explicit NO are one finding, and PROTOCOL only changes what is in the clear.
145
169
  const encrypted = (svc) => /^(YES|CLIENTAUTH|CLIENTCERT)$/i.test(String(svc.ssl || ''));
146
170
 
147
- export function scanWeb(root, opts = {}) {
171
+ export function* scanWebSteps(root, opts = {}) {
148
172
  const tree = treeFor(root, opts);
149
173
  const all = tree.list().filter(inScope(opts));
150
174
  const files = all.filter(isProgram);
@@ -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} ` : '';
@@ -253,7 +323,7 @@ export function scanWeb(root, opts = {}) {
253
323
  }
254
324
  }
255
325
 
256
- const run = eachWithinMemory(files, (f) => {
326
+ const run = yield loopOver(files, (f) => {
257
327
  let src;
258
328
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
259
329
  // The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
@@ -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
 
@@ -349,3 +444,5 @@ export function scanWeb(root, opts = {}) {
349
444
 
350
445
  return report('web', { rules: WEB_RULES, findings, stats, run });
351
446
  }
447
+
448
+ export const scanWeb = (root, opts = {}) => drive(scanWebSteps(root, opts));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Portll <john@portll.net>",