@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
@@ -55,7 +55,7 @@ const COMPILERS = new Set(['gnucobol', 'opentext-cobol', 'ibm-enterprise-cobol']
55
55
 
56
56
  // Files that decide what the build runs. A repository states this in one of a small number of
57
57
  // conventional places, and reading only those keeps the rule from matching prose in a README.
58
- function isBuildFile(path) {
58
+ export function isBuildFile(path) {
59
59
  const name = basename(path);
60
60
  const p = path.replace(/\\/g, '/').toLowerCase();
61
61
  if (/^(dockerfile|containerfile)(\..+)?$/i.test(name)) return true;
@@ -98,6 +98,21 @@ const PINS = [
98
98
  { product: 'cics-transaction-gateway', re: /from\s+\S*cics[-_]?transaction[-_]?gateway\S*:v?(\d+(?:\.\d+)+)/gi },
99
99
  ];
100
100
 
101
+ // Every product version the text pins, once each, with the text that pins it.
102
+ export function pinsIn(src) {
103
+ const out = [];
104
+ const seen = new Set();
105
+ for (const { product, re } of PINS) {
106
+ for (const m of src.matchAll(re)) {
107
+ const key = `${product}@${m[1]}`;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ out.push({ product, version: m[1], text: m[0] });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
101
116
  function withMavenProperties(src) {
102
117
  const block = src.match(/<properties>([\s\S]*?)<\/properties>/i);
103
118
  if (!block) return src;
@@ -189,42 +204,33 @@ export function scanBuild(root, opts = {}) {
189
204
  if (/pom\.xml$/i.test(path)) src = withMavenProperties(src);
190
205
  const lines = src.split(/\r?\n/);
191
206
 
192
- const seen = new Set();
193
- for (const { product, re } of PINS) {
194
- re.lastIndex = 0;
195
- let m;
196
- while ((m = re.exec(src)) !== null) {
197
- const version = m[1];
198
- const key = product + '@' + version;
199
- if (seen.has(key)) continue;
200
- seen.add(key);
201
- stats.pinsFound++;
202
-
203
- const hits = advisoriesFor(product, version, extra);
204
- if (!hits.length) continue;
205
-
206
- // A pin written across lines, as Maven writes one, is reported at its version.
207
- const needle = m[0].includes('\n') ? m[0].split('\n').pop().trim() : m[0];
208
- const line = lines.findIndex((l) => l.includes(needle)) + 1 || 1;
209
- const exploited = hits.filter((a) => isKnownExploited(a.id));
210
- const ids = hits.map((a) => a.id).sort(byText);
211
-
212
- const kind = COMPILERS.has(product) ? 'compiler' : 'component';
213
- if (exploited.length) {
214
- findings.push({
215
- rule: `build-pins-exploited-${kind}`, path, line,
216
- detail: `${path} pins ${product} ${version}, which ${exploited.map((a) => a.id).sort(byText).join(', ')} affects and CISA lists as known to be exploited${fromFeeds(hits)}`,
217
- related: hits.map(relatedOf),
218
- knownExploited: exploited.map((a) => a.id).sort(byText),
219
- });
220
- } else {
221
- findings.push({
222
- rule: `build-pins-vulnerable-${kind}`, path, line,
223
- detail: `${path} pins ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}` +
224
- (age.stale ? `. The KEV snapshot is ${age.days} days old, so whether any of these is now known to be exploited was not checked against a current catalogue` : ''),
225
- related: hits.map(relatedOf),
226
- });
227
- }
207
+ for (const { product, version, text } of pinsIn(src)) {
208
+ stats.pinsFound++;
209
+
210
+ const hits = advisoriesFor(product, version, extra);
211
+ if (!hits.length) continue;
212
+
213
+ // A pin written across lines, as Maven writes one, is reported at its version.
214
+ const needle = text.includes('\n') ? text.split('\n').pop().trim() : text;
215
+ const line = lines.findIndex((l) => l.includes(needle)) + 1 || 1;
216
+ const exploited = hits.filter((a) => isKnownExploited(a.id));
217
+ const ids = hits.map((a) => a.id).sort(byText);
218
+
219
+ const kind = COMPILERS.has(product) ? 'compiler' : 'component';
220
+ if (exploited.length) {
221
+ findings.push({
222
+ rule: `build-pins-exploited-${kind}`, path, line,
223
+ detail: `${path} pins ${product} ${version}, which ${exploited.map((a) => a.id).sort(byText).join(', ')} affects and CISA lists as known to be exploited${fromFeeds(hits)}`,
224
+ related: hits.map(relatedOf),
225
+ knownExploited: exploited.map((a) => a.id).sort(byText),
226
+ });
227
+ } else {
228
+ findings.push({
229
+ rule: `build-pins-vulnerable-${kind}`, path, line,
230
+ detail: `${path} pins ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}` +
231
+ (age.stale ? `. The KEV snapshot is ${age.days} days old, so whether any of these is now known to be exploited was not checked against a current catalogue` : ''),
232
+ related: hits.map(relatedOf),
233
+ });
228
234
  }
229
235
  }
230
236
  return src.length;
package/lib/sets/cics.mjs CHANGED
@@ -4,7 +4,7 @@ import { report } from '../kernel/ruleset.mjs';
4
4
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { parseSource } from '../parser.mjs';
7
- import { eachWithinMemory } from '../kernel/memory.mjs';
7
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
8
8
  import { buildControl, hasFact } from '../control.mjs';
9
9
  import { parseCsd } from '../csd.mjs';
10
10
 
@@ -201,7 +201,7 @@ function conditionsIgnored(p, path) {
201
201
  // away from, and this set kept doing it: on a 100,000-program repository it exhausted an 8 GB heap
202
202
  // and took a corpus run with it. What survives per program is an identifier, a communication area's
203
203
  // size, one boolean, and a short list of transfers.
204
- export function scanCics(root, opts = {}) {
204
+ export function* scanCicsSteps(root, opts = {}) {
205
205
  const tree = treeFor(root, opts);
206
206
  const files = tree.list().filter(isProgram).filter(inScope(opts));
207
207
  // Identifier to the size of that program's DFHCOMMAREA, which is all a caller needs of a callee.
@@ -364,7 +364,7 @@ export function scanCics(root, opts = {}) {
364
364
  // Releasing each tree bounds what one program costs; it does not bound what a hundred thousand of
365
365
  // them cost together. The shared loop watches the heap and stops before V8 cannot collect, and
366
366
  // names every file it did not reach.
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
  if (!/EXEC\s+CICS/i.test(src) && !/(?:^|[\s.])COPY\s+/i.test(src)) return src.length;
@@ -445,3 +445,5 @@ export function scanCics(root, opts = {}) {
445
445
 
446
446
  return report('cics', { rules: CICS_RULES, findings, stats, run });
447
447
  }
448
+
449
+ export const scanCics = (root, opts = {}) => drive(scanCicsSteps(root, opts));
@@ -13,7 +13,7 @@ import { basename, extname } from 'node:path';
13
13
  import { inScope, isProgram, relPath, readSource } from '../sources.mjs';
14
14
  import { report } from '../kernel/ruleset.mjs';
15
15
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
16
- import { eachWithinMemory } from '../kernel/memory.mjs';
16
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
17
17
 
18
18
  // construct: no input has to reach it. med: certain once every copybook is found, and it means the
19
19
  // tree does not hold what runs, but it is not exploitable in itself. CWE-1127: MITRE discourages
@@ -102,7 +102,7 @@ function truncated(ref, lines) {
102
102
  return any && !whole;
103
103
  }
104
104
 
105
- export function scanCompile(root, opts = {}) {
105
+ export function* scanCompileSteps(root, opts = {}) {
106
106
  const tree = treeFor(root, opts);
107
107
  const files = tree.list().filter(isProgram).filter(inScope(opts));
108
108
  const findings = [];
@@ -117,7 +117,7 @@ export function scanCompile(root, opts = {}) {
117
117
  const textOf = (p) => (tree.contains(p) ? tree.text(p) : readSource(p)).text;
118
118
  const candidates = [];
119
119
 
120
- const run = eachWithinMemory(files, (f) => {
120
+ const run = yield loopOver(files, (f) => {
121
121
  let src;
122
122
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
123
123
  if (LISTING.test(src)) { stats.filesScanned++; stats.listingsSkipped = (stats.listingsSkipped || 0) + 1; return src.length; }
@@ -186,3 +186,5 @@ export function scanCompile(root, opts = {}) {
186
186
  if (stats.filesUnparsed) stats.coverageIncomplete = true;
187
187
  return report('compile', { rules: COMPILE_RULES, findings, stats, run });
188
188
  }
189
+
190
+ export const scanCompile = (root, opts = {}) => drive(scanCompileSteps(root, opts));
@@ -6,7 +6,7 @@ import { inScope, isCopybook, isProgram, relPath } from '../sources.mjs';
6
6
  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
- import { eachWithinMemory } from '../kernel/memory.mjs';
9
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
10
10
  import { cobolCard } from '../cards.mjs';
11
11
 
12
12
  // A copybook is resolved by name along a search path: the program's own directory first, then the
@@ -71,7 +71,7 @@ export function systemLayoutDeparture(name, text) {
71
71
  return null;
72
72
  }
73
73
 
74
- export function scanCopybooks(root, opts = {}) {
74
+ export function* scanCopybooksSteps(root, opts = {}) {
75
75
  const tree = treeFor(root, opts);
76
76
  const all = tree.list().filter(inScope(opts));
77
77
  const findings = [];
@@ -100,7 +100,7 @@ export function scanCopybooks(root, opts = {}) {
100
100
  // is what stops the reading before the heap does.
101
101
  let run = { skipped: [], stoppedBy: null, peakHeapBytes: 0, note: null };
102
102
  if (candidates.size) {
103
- run = eachWithinMemory(all.filter(isProgram), (p) => {
103
+ run = yield loopOver(all.filter(isProgram), (p) => {
104
104
  let src;
105
105
  try { src = tree.text(p).text; } catch (e) { noteUnread(stats, tree, p, e); return 0; }
106
106
  let r;
@@ -173,3 +173,5 @@ export function scanCopybooks(root, opts = {}) {
173
173
  // that looks unshadowed may only look that way.
174
174
  return report('copybook', { rules: COPYBOOK_RULES, findings, stats, run });
175
175
  }
176
+
177
+ export const scanCopybooks = (root, opts = {}) => drive(scanCopybooksSteps(root, opts));
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // What a program asks ICSF for. A COBOL program reaches z/OS cryptography by CALLing an ICSF
3
+ // callable service with positional arguments, and the strength it gets is in those arguments: the
4
+ // key length it generates, the hash it names in a rule array, the initialization vector it passes.
5
+ // rules/icsf-services.json holds each service's parameters in order, from IBM's manual, and which
6
+ // keywords each rule reports and why.
7
+ //
8
+ // An argument is judged by the literals that can reach it (lib/constants.mjs). One a computation, a
9
+ // read or another program fills is not judged, and is counted as undecided rather than passed.
10
+ import { readFileSync } from 'node:fs';
11
+ import { inScope, isProgram, relPath } from '../sources.mjs';
12
+ import { report } from '../kernel/ruleset.mjs';
13
+ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
14
+ import { eachWithinMemory } from '../kernel/memory.mjs';
15
+ import { constantsReaching, keywordsOf } from '../constants.mjs';
16
+
17
+ const TABLE = JSON.parse(readFileSync(new URL('../../rules/icsf-services.json', import.meta.url), 'utf8'));
18
+
19
+ export const CRYPTO_RULES = {
20
+ 'icsf-single-length-des-key': {
21
+ sev: 'high', evidence: 'construct', cwe: 'CWE-327',
22
+ text: 'A program generates a single-length DES key through ICSF',
23
+ impact: 'The key is 56 bits, which exhaustive search recovers, and every later encipher with it runs single DES, so the data it protects is readable by anyone who takes the ciphertext',
24
+ remedy: 'Generate an AES key (key_type_1 AESDATA with KEYLN16 or longer, or CSNBKGN2) and encipher with CSNBSAE or CSNBSYE and AES',
25
+ },
26
+ 'icsf-weak-hash': {
27
+ sev: 'med', evidence: 'construct', cwe: 'CWE-328',
28
+ text: 'A program hashes with MD5 or SHA-1 through ICSF',
29
+ impact: 'Collisions are practical for both, so a digest no longer shows the text was not changed, and an MD5 or SHA-1 of a password is quick to reverse for a short one',
30
+ remedy: 'Name SHA-256 or longer in the rule array, or BCRYPT for a password',
31
+ },
32
+ 'icsf-fixed-initialization-vector': {
33
+ sev: 'med', evidence: 'construct', cwe: 'CWE-1204',
34
+ text: 'A program enciphers through ICSF with an initialization vector that never changes',
35
+ impact: 'Equal messages encipher to equal ciphertext, and under CUSP or IPS a message shorter than a block is XORed with the same keystream every time, so whoever sees the ciphertexts learns which are equal and can recover short ones',
36
+ remedy: 'Pass a vector generated for each message (CSNBRNG, or CSNBRNGL) and send it with the ciphertext',
37
+ },
38
+ };
39
+
40
+ // Every name a service answers to, 31- and 64-bit, CSF and ALET forms, to its entry.
41
+ const SERVICES = new Map();
42
+ for (const s of TABLE.services) {
43
+ const a = s.entry_points || {};
44
+ const names = [s.name, ...(a['31-bit'] || []), ...(a['64-bit'] || []), ...((a.alet && a.alet['31-bit']) || []), ...((a.alet && a.alet['64-bit']) || [])];
45
+ for (const n of names) SERVICES.set(n.toUpperCase(), s);
46
+ }
47
+ const F = TABLE.findings;
48
+ const ruleFor = { 'single-length-des-key': 'icsf-single-length-des-key', 'weak-hash': 'icsf-weak-hash' };
49
+ const isIcsf = (c) => SERVICES.has(String(c.name || '').toUpperCase());
50
+
51
+ const argument = (call, service, parameter) => {
52
+ const i = service.parameters.indexOf(parameter);
53
+ return i >= 0 && call.using[i] && call.using[i].word ? call.using[i].word : null;
54
+ };
55
+ const shown = (values) => [...new Set(values.map((v) => (/^[A-Z-]+$/.test(v) ? v : `'${v.trim()}'`)))].join(', ');
56
+
57
+ export function scanCrypto(root, opts = {}) {
58
+ const tree = treeFor(root, opts);
59
+ const files = tree.list().filter(isProgram).filter(inScope(opts));
60
+ const findings = [];
61
+ const stats = { filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0, icsfCalls: 0, argumentsUndecided: 0 };
62
+
63
+ const run = eachWithinMemory(files, (f) => {
64
+ let src;
65
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
66
+ if (!/CALL\s+['"]?CS[NF]/i.test(src)) return src.length;
67
+ let r;
68
+ try { r = tree.parse(f, src); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
69
+ stats.filesScanned++;
70
+ const path = relPath(root, f);
71
+ for (const p of r.programs) {
72
+ for (const c of p.calls || []) {
73
+ const service = SERVICES.get(String(c.name || '').toUpperCase());
74
+ if (!service || c.kind !== 'L') continue;
75
+ stats.icsfCalls++;
76
+ const at = { path: c.file ? relPath(root, c.file) : path, line: c.line || 1, program: p.id };
77
+ const read = (parameter) => {
78
+ const name = argument(c, service, parameter);
79
+ if (!name) return null;
80
+ const got = constantsReaching(p, name, { ignoreCall: isIcsf });
81
+ return { name, ...got, keywords: keywordsOf(got.values) };
82
+ };
83
+
84
+ for (const [key, rule] of Object.entries(ruleFor)) {
85
+ const fd = F[key];
86
+ if (!fd.services.includes(service.name)) continue;
87
+ const arg = read(fd.parameter);
88
+ if (!arg) continue;
89
+ const weak = fd.keywords.filter((k) => arg.keywords.has(k));
90
+ if (!weak.length) { if (arg.open || !arg.values.length) stats.argumentsUndecided++; continue; }
91
+ findings.push({ rule, ...at,
92
+ detail: `CALL '${c.name}' passes ${arg.name} as ${fd.parameter}, and ${arg.name} can hold ${weak.join(' or ')}` });
93
+ }
94
+
95
+ const iv = F['fixed-initialization-vector'];
96
+ if (!iv.services.includes(service.name)) continue;
97
+ const rules = read('rule_array');
98
+ if (rules && iv.withoutVector.some((k) => rules.keywords.has(k))) continue;
99
+ const vector = read(iv.parameter);
100
+ if (!vector) continue;
101
+ if (vector.open || !vector.values.length) { stats.argumentsUndecided++; continue; }
102
+ findings.push({ rule: 'icsf-fixed-initialization-vector', ...at,
103
+ detail: `CALL '${c.name}' passes ${vector.name} as its initialization vector, and nothing but ${shown(vector.values)} is ever put in it` });
104
+ }
105
+ }
106
+ return src.length;
107
+ }, { label: 'crypto', maxBytes: opts.maxSourceBytes ?? Infinity });
108
+
109
+ return report('crypto', { rules: CRYPTO_RULES, findings, stats, run });
110
+ }
package/lib/sets/flow.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { analyze } from '../dataflow.mjs';
2
+ import { resolve } from 'node:path';
3
+ import { directoryTree } from '../kernel/source-tree.mjs';
4
+ import { analyzeSteps } from '../dataflow.mjs';
5
+ import { drive } from '../kernel/shared-pass.mjs';
3
6
  import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from '../version.mjs';
4
7
  import { finish, withMeta, sortFindings } from '../kernel/findings.mjs';
5
8
 
@@ -349,12 +352,27 @@ const DEFAULT = { sev: 'med', evidence: 'path', cwe: null, text: 'Untrusted inpu
349
352
  // is safe for the sink - one of a list of literals, digits where digits are needed, a bound where an
350
353
  // index is - is not a finding at all, and is listed under `checked` with the check that stops it.
351
354
  const LOWER = { crit: 'high', high: 'med', med: 'low', low: 'info', info: 'info' };
355
+
356
+ // The routes of two sources that reach one sink, as one finding carries them: every source, every
357
+ // statement either route runs, and complete only where both are.
358
+ const routeKey = (s) => `${s.file}:${s.line}:${s.verb ?? s.kind}`;
359
+ function mergeRoutes(a, b) {
360
+ const union = (x, y) => [...new Map([...x, ...y].map((s) => [routeKey(s), s])).values()];
361
+ const order = (p, q) => (p.file < q.file ? -1 : p.file > q.file ? 1 : p.line - q.line);
362
+ const unplaced = (a.unplaced || 0) + (b.unplaced || 0);
363
+ return {
364
+ sources: union(a.sources, b.sources).sort(order),
365
+ statements: union(a.statements, b.statements).sort(order),
366
+ ...(unplaced ? { unplaced } : {}),
367
+ complete: a.complete && b.complete,
368
+ };
369
+ }
352
370
  // One graph per repository, for the reason scanAll gives: names are only unique within one.
353
- function analyzeEach(root, opts) {
354
- if (!opts.repos || opts.repos.length < 2) return analyze(root, opts);
371
+ function* analyzeEach(root, opts) {
372
+ if (!opts.repos || opts.repos.length < 2) return yield* analyzeSteps(root, opts);
355
373
  const merged = { findings: [], constructs: [], unstarted: [], startedBy: {}, stats: {} };
356
374
  for (const repo of opts.repos) {
357
- const one = analyze(root, { ...opts, repos: [repo] });
375
+ const one = yield* analyzeSteps(root, { ...opts, repos: [repo] });
358
376
  merged.findings.push(...one.findings);
359
377
  merged.constructs.push(...one.constructs);
360
378
  merged.unstarted.push(...one.unstarted);
@@ -371,8 +389,9 @@ function analyzeEach(root, opts) {
371
389
  return merged;
372
390
  }
373
391
 
374
- export function scan(root, opts = {}) {
375
- const res = analyzeEach(root, opts);
392
+ export function* scanSteps(root, opts = {}) {
393
+ root = resolve(root);
394
+ const res = yield* analyzeEach(root, opts);
376
395
  const findings = [];
377
396
  for (const f of res.findings) {
378
397
  const meta = RULES[f.rule] || DEFAULT;
@@ -404,6 +423,7 @@ export function scan(root, opts = {}) {
404
423
  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 } : {}) })),
405
424
  related: [{ path: f.source.file, line: f.source.line, detail: f.source.detail }],
406
425
  ...(f.also ? { also: f.also } : {}),
426
+ ...(f.routes ? { routes: f.routes } : {}),
407
427
  });
408
428
  }
409
429
  // One sink reached by many sources is one finding, not many: a report where a single statement
@@ -417,9 +437,11 @@ export function scan(root, opts = {}) {
417
437
  // A sink is credited only as far as the least-credited source that reaches it.
418
438
  const level = Math.min(held.level, f.level);
419
439
  const also = [...(held.also || []), ...(f.also || [])];
440
+ const routes = held.routes && f.routes ? mergeRoutes(held.routes, f.routes) : null;
420
441
  if (f.hops < held.hops) Object.assign(held, f, { sources: held.sources });
421
442
  held.level = level;
422
443
  if (also.length) held.also = also;
444
+ if (routes) held.routes = routes;
423
445
  }
424
446
  // A use one source's route reaches unchecked, and the report leaves out for another use of the same
425
447
  // index, still limits how far a finding at that use is credited.
@@ -513,7 +535,8 @@ export function scan(root, opts = {}) {
513
535
  // happened to compute the right answer for the whole report - it ORs filesOverBudget in
514
536
  // separately - so nothing was wrong downstream, and nothing would have been until someone
515
537
  // called this set on its own and believed a silence.
516
- coverageIncomplete: (res.stats.overBudget || 0) > 0
538
+ coverageIncomplete: (res.stats.files === 0 && directoryTree(root, opts).list().length === 0)
539
+ || (res.stats.overBudget || 0) > 0
517
540
  || (res.stats.filesNotReached || 0) > 0
518
541
  || (res.stats.unreadable || 0) > 0
519
542
  || (res.stats.threw || 0) > 0
@@ -552,6 +575,8 @@ export function scan(root, opts = {}) {
552
575
  return report;
553
576
  }
554
577
 
578
+ export const scan = (root, opts = {}) => drive(scanSteps(root, opts));
579
+
555
580
  // Findings counted by the transaction or job that reaches them: the unit a mainframe team triages
556
581
  // in. A finding several entries reach counts under each.
557
582
  export function rollup(findings) {
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Assembler stubs are where a COBOL estate does what COBOL cannot: switch to supervisor state, reach
3
+ // another address space, run an instruction whose length or mask is decided at run time. This set
4
+ // reads HLASM source as cards and reports those operations where they are written. It does not
5
+ // assemble: macros are not expanded and conditional assembly is not evaluated (lib/hlasm.mjs).
6
+ //
7
+ // It also answers the question the COBOL side cannot: a CALL 'SUBRTN' with no COBOL program of that
8
+ // name may be an assembler module, and a CSECT or ENTRY of that name here is where it is.
9
+ import { basename, extname } from 'node:path';
10
+ import { inScope, isAssembler, isProgram, relPath, HLASM_EXT, COPY_EXT } from '../sources.mjs';
11
+ import { report } from '../kernel/ruleset.mjs';
12
+ import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
13
+ import { eachWithinMemory } from '../kernel/memory.mjs';
14
+ import { readHlasm, OPERATIONS } from '../hlasm.mjs';
15
+ import { readHlasmStatements, parseHlasmStatement, NAMED_MACROS } from '../hlasm/read.mjs';
16
+ import { macroOperands, splitOperands } from '../hlasm/operands.mjs';
17
+
18
+ export const HLASM_RULES = {
19
+ 'hlasm-supervisor-state-change': {
20
+ sev: 'crit', evidence: 'construct', cwe: 'CWE-250',
21
+ text: 'Assembler source switches to PSW key zero or supervisor state',
22
+ impact: 'The code after the switch runs with the operating system\'s authority: it can read and change any storage, so a flaw in it is a flaw in the system rather than in one job',
23
+ remedy: 'Keep the authorized code to the instructions that need it, return to the caller\'s key and problem state straight after (MODESET KEY=NZERO, MODE=PROB), and APF-authorize the module only where it has to be',
24
+ },
25
+ 'hlasm-executes-built-instruction': {
26
+ sev: 'high', evidence: 'construct', cwe: 'CWE-119',
27
+ text: 'Assembler source runs an instruction whose length or mask comes from a register at run time',
28
+ impact: 'EX and EXRL run their target instruction with its second byte taken from a register, which for a move is its length: a length from input moves past the end of the field it was meant for',
29
+ remedy: 'Bound the register against the target field\'s length before the EX, and keep the target instruction out of the code a caller can reach any other way',
30
+ },
31
+ 'hlasm-cross-memory-service': {
32
+ sev: 'high', evidence: 'construct', cwe: 'CWE-668',
33
+ text: 'Assembler source uses a cross-memory instruction, reaching another address space or another authority',
34
+ impact: 'A program call, a program transfer or a change of secondary address space moves execution or data access across address spaces, under the authority the linkage tables grant rather than the caller\'s own',
35
+ remedy: 'Confirm the entry tables and authorization indexes grant only the address spaces and keys the service needs, and that every PC routine validates what its caller passes',
36
+ },
37
+ 'hlasm-runtime-module-name': {
38
+ sev: 'med', evidence: 'construct', cwe: 'CWE-470',
39
+ text: 'Assembler source loads or calls a module whose name it reads from storage at run time',
40
+ impact: 'EPLOC= and DE= give LINK, XCTL, LOAD and ATTACH the address of the name rather than the name, so whatever writes that storage chooses the program that runs, with the caller\'s authority',
41
+ remedy: 'Name the module with EP= where it is fixed; where it must vary, check the name against a list of the modules the program may run before the call',
42
+ },
43
+ 'hlasm-calls-security-product': { sev: 'info', evidence: 'context', cwe: null, text: 'Assembler source calls the security product directly' },
44
+ 'hlasm-provides-called-module': { sev: 'info', evidence: 'context', cwe: null, text: 'An assembler CSECT or ENTRY is a module a COBOL program in this tree CALLs' },
45
+ };
46
+
47
+ const RULE_OF_CLASS = {
48
+ 'cross-memory': 'hlasm-cross-memory-service',
49
+ 'built-instruction': 'hlasm-executes-built-instruction',
50
+ 'security-product': 'hlasm-calls-security-product',
51
+ };
52
+
53
+ const STATE = { KEY: 'PSW key zero', EXTKEY: 'PSW key zero', MODE: 'supervisor state' };
54
+
55
+ const where = (use) => (use.inMacro ? ' It is in a macro definition, so it is in every program that uses the macro.' : '');
56
+
57
+ // EX and EXRL name their target instruction; when it is labelled in the same file, the finding says
58
+ // which instruction runs with the register's length or mask.
59
+ function executed(use, labels) {
60
+ if (use.class !== 'built-instruction') return '';
61
+ const target = (splitOperands(use.field || '')[1] || '').trim().toUpperCase();
62
+ const t = /^[A-Z$#@_][A-Z0-9$#@_]*$/.test(target) ? labels.get(target) : null;
63
+ return t ? ` runs ${t.operation}${t.field ? ` ${t.field}` : ''}` : '';
64
+ }
65
+
66
+ function detail(use, labels) {
67
+ const written = `${use.name}${use.field ? ` ${use.field}` : ''}${executed(use, labels)}`;
68
+ if (use.class === 'security-product') {
69
+ const request = /\bREQUEST=([A-Z]+)/i.exec(use.field || '');
70
+ return `${request ? `${use.name} REQUEST=${request[1].toUpperCase()}` : use.name} calls the security product from assembler rather than through a service that checks on the program's behalf.${where(use)}`;
71
+ }
72
+ return `${written}: ${OPERATIONS.get(use.name).summary}${where(use)}`;
73
+ }
74
+
75
+ // A COBOL CALL of a literal name, outside comment lines. A membership question over every program,
76
+ // answered with a regular expression as lib/sets/jcl.mjs answers PROGRAM-ID.
77
+ const CALL = /\bCALL\s+['"]([A-Z0-9$#@_-]{1,30})['"]/gi;
78
+
79
+ function cobolCalls(source) {
80
+ const calls = [];
81
+ source.split(/\r?\n/).forEach((text, i) => {
82
+ if (text.length > 6 && '*/'.includes(text[6])) return;
83
+ for (const m of text.matchAll(CALL)) calls.push({ name: m[1].toUpperCase(), line: i + 1 });
84
+ });
85
+ return calls;
86
+ }
87
+
88
+ export function scanHlasm(root, opts = {}) {
89
+ const tree = treeFor(root, opts);
90
+ const all = tree.list().filter(inScope(opts));
91
+ const findings = [];
92
+ const stats = { filesScanned: 0, filesUnreadable: 0, assemblerFiles: 0, bmsFiles: 0, imsFiles: 0, unrecognisedFiles: 0, readNotAssembled: true };
93
+ const members = new Set(all.filter((f) => [...HLASM_EXT, ...COPY_EXT].some((e) => f.toLowerCase().endsWith(e))).map((f) => basename(f, extname(f)).toUpperCase()));
94
+ const refused = {};
95
+ const macroCalls = new Map();
96
+ const copyNotFound = new Set();
97
+ // Counts, for the coverage it reports, what the statement reader refused and which macros and COPY
98
+ // members a file uses.
99
+ function readStatements(src, path) {
100
+ const labels = new Map();
101
+ for (const st of readHlasmStatements(src).statements) {
102
+ if (st.inMacro) continue;
103
+ if (st.kind === 'statement' && st.name && !st.name.startsWith('&') && !st.name.startsWith('.')) labels.set(st.name.toUpperCase(), st);
104
+ const res = parseHlasmStatement(st);
105
+ if (res.status === 'unparsed' || res.status === 'unknown') (refused[res.kind] ||= { count: 0, first: `${path}:${st.line}` }).count++;
106
+ const name = res.kind === 'MACRO CALL' ? st.operation : NAMED_MACROS.has(res.kind) ? res.kind : null;
107
+ if (name) {
108
+ const m = macroCalls.get(name) || { name, calls: 0, definedInTree: members.has(name) };
109
+ m.calls++;
110
+ macroCalls.set(name, m);
111
+ }
112
+ if (res.kind === 'COPY') {
113
+ const member = String(res.status === 'parsed' ? res.node.member : (/^\s*([A-Z$#@][A-Z0-9$#@_]*)/i.exec(st.field || '') || [])[1] || '').toUpperCase();
114
+ if (member && !members.has(member)) copyNotFound.add(member);
115
+ }
116
+ }
117
+ return labels;
118
+ }
119
+ const defines = new Map();
120
+
121
+ const run = eachWithinMemory(all.filter(isAssembler), (f) => {
122
+ let src;
123
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
124
+ stats.filesScanned++;
125
+ const r = readHlasm(src);
126
+ if (r.kind === 'bms') { stats.bmsFiles++; return src.length; }
127
+ if (r.kind === 'ims') { stats.imsFiles++; return src.length; }
128
+ if (r.kind === 'unrecognised') { stats.unrecognisedFiles++; return src.length; }
129
+ stats.assemblerFiles++;
130
+ const path = relPath(root, f);
131
+ for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
132
+ const labels = readStatements(src, path);
133
+ for (const use of r.operations) {
134
+ if (use.class === 'module-load') {
135
+ const { keywords } = macroOperands(use.field || '');
136
+ const at = keywords.has('EPLOC') ? 'EPLOC' : keywords.has('DE') ? 'DE' : null;
137
+ if (at) findings.push({ rule: 'hlasm-runtime-module-name', path, line: use.line, detail: `${use.name} ${at}=${keywords.get(at)} names the module by the address of its name, so the program that runs is whatever name that storage holds.${where(use)}` });
138
+ continue;
139
+ }
140
+ if (use.name === 'MODESET') {
141
+ if (use.stateChange) findings.push({ rule: 'hlasm-supervisor-state-change', path, line: use.line, detail: `MODESET ${use.stateChange} switches to ${STATE[use.stateChange.split('=')[0]]}.${where(use)}` });
142
+ continue;
143
+ }
144
+ const rule = RULE_OF_CLASS[use.class];
145
+ if (rule) findings.push({ rule, path, line: use.line, detail: detail(use, labels) });
146
+ }
147
+ return src.length;
148
+ }, { label: 'hlasm', maxBytes: opts.maxSourceBytes ?? Infinity });
149
+
150
+ if (defines.size) {
151
+ const callers = new Map();
152
+ const programs = eachWithinMemory(all.filter(isProgram), (f) => {
153
+ let src;
154
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
155
+ for (const c of cobolCalls(src)) {
156
+ if (!defines.has(c.name)) continue;
157
+ if (!callers.has(c.name)) callers.set(c.name, []);
158
+ callers.get(c.name).push({ path: relPath(root, f), line: c.line });
159
+ }
160
+ return src.length;
161
+ }, { label: 'hlasm-callers', maxBytes: opts.maxSourceBytes ?? Infinity });
162
+ if (programs.skipped.length) stats.callersNotRead = programs.skipped.length;
163
+ for (const [name, calls] of callers) {
164
+ const d = defines.get(name);
165
+ const first = calls[0];
166
+ findings.push({ rule: 'hlasm-provides-called-module', path: d.path, line: d.line,
167
+ detail: `${d.how} ${name} is the module CALL '${name}' reaches from ${first.path}:${first.line}${calls.length > 1 ? ` and ${calls.length - 1} other CALL${calls.length > 2 ? 's' : ''}` : ''}` });
168
+ }
169
+ }
170
+
171
+ // Each statement the reader refused, each macro whose expansion was not read and each COPY member
172
+ // the tree does not hold is named: an operation inside any of them is one this set did not see.
173
+ if (Object.keys(refused).length) { stats.statementsNotRead = refused; stats.coverageIncomplete = true; }
174
+ if (copyNotFound.size) { stats.copyNotFound = [...copyNotFound].sort(); stats.coverageIncomplete = true; }
175
+ if (macroCalls.size) stats.macrosNotExpanded = [...macroCalls.values()].sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)).slice(0, 50);
176
+ return report('hlasm', { rules: HLASM_RULES, findings, stats, run });
177
+ }
package/lib/sets/jcl.mjs CHANGED
@@ -3,10 +3,11 @@
3
3
  // authority, against which dataset; an in-stream SYSIN carries commands that never appear in any
4
4
  // program the scanner reads. The 2026-09-18 evaluation counted 27 JCL files carrying RACF or
5
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';
6
+ import { inScope, isAssembler, 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 { readHlasm } from '../hlasm.mjs';
10
11
  import { ddGroups, tsoCommands } from '../utilities.mjs';
11
12
  import { ftpSession } from '../ftp.mjs';
12
13
  import { loadSite, productionQualifierOf, SITE_FILE } from '../site.mjs';
@@ -228,12 +229,25 @@ function programIds(tree, files, unread) {
228
229
  return ids;
229
230
  }
230
231
 
232
+ // The modules assembler source in the tree defines: each CSECT and ENTRY name, which a step can run
233
+ // as it runs a COBOL program.
234
+ function assemblerModules(tree, files, unread) {
235
+ const names = [];
236
+ for (const f of files) {
237
+ let src;
238
+ try { src = tree.text(f).text; } catch (e) { unread.push(`${tree.rel(f)}: ${e.code || e.name}`); continue; }
239
+ for (const d of readHlasm(src).defines) names.push(d.name);
240
+ }
241
+ return names;
242
+ }
243
+
231
244
  export function scanJcl(root, opts = {}) {
232
245
  const tree = treeFor(root, opts);
233
246
  const all = tree.list().filter(inScope(opts));
234
247
  const jclFiles = all.filter(isJcl);
235
248
  const programsUnread = [];
236
249
  const defined = programIds(tree, all.filter(isProgram), programsUnread);
250
+ for (const name of assemblerModules(tree, all.filter(isAssembler), programsUnread)) defined.add(name);
237
251
 
238
252
  // A vendor product supplies its own utilities. An estate that has loaded the pack for a product
239
253
  // is telling us those programs exist, so reporting them as undefined would be a false positive