@portll/cobolwork 0.5.0 → 0.7.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 (163) hide show
  1. package/README.md +89 -26
  2. package/STABILITY.md +76 -0
  3. package/bin/cobolwork.mjs +56 -14
  4. package/lib/baseline.mjs +9 -0
  5. package/lib/bms.mjs +21 -7
  6. package/lib/build.mjs +133 -53
  7. package/lib/capabilities.mjs +13 -11
  8. package/lib/cics-commands.mjs +501 -9
  9. package/lib/compliance.mjs +11 -0
  10. package/lib/consequence.mjs +17 -0
  11. package/lib/control-reuse.mjs +113 -0
  12. package/lib/control-workers.mjs +161 -0
  13. package/lib/control.mjs +77 -4
  14. package/lib/dataflow.mjs +402 -152
  15. package/lib/db2/cursor.mjs +15 -0
  16. package/lib/db2/read.mjs +170 -0
  17. package/lib/db2/rules.mjs +77 -0
  18. package/lib/db2/stmt/alter.mjs +473 -0
  19. package/lib/db2/stmt/grant.mjs +125 -0
  20. package/lib/db2/stmt/index.mjs +141 -0
  21. package/lib/db2/stmt/misc.mjs +325 -0
  22. package/lib/db2/stmt/routine.mjs +564 -0
  23. package/lib/db2/stmt/storage.mjs +146 -0
  24. package/lib/db2/stmt/table.mjs +540 -0
  25. package/lib/db2/stmt/view.mjs +146 -0
  26. package/lib/diff.mjs +17 -5
  27. package/lib/equivalence.mjs +44 -5
  28. package/lib/evidence/cli.mjs +26 -7
  29. package/lib/evidence/record.mjs +16 -6
  30. package/lib/evidence/seal.mjs +17 -0
  31. package/lib/evidence/store.mjs +44 -15
  32. package/lib/evidence/timestamp.mjs +83 -0
  33. package/lib/evidence/verify.mjs +120 -42
  34. package/lib/exec-reading.mjs +51 -0
  35. package/lib/execution.mjs +3 -2
  36. package/lib/explain.mjs +2 -0
  37. package/lib/exploitability.mjs +11 -2
  38. package/lib/hlasm/asm/data.mjs +128 -0
  39. package/lib/hlasm/asm/listing.mjs +43 -0
  40. package/lib/hlasm/asm/output.mjs +45 -0
  41. package/lib/hlasm/asm/sections.mjs +161 -0
  42. package/lib/hlasm/asm/symbols.mjs +78 -0
  43. package/lib/hlasm/exec.mjs +27 -0
  44. package/lib/hlasm/expr.mjs +167 -0
  45. package/lib/hlasm/instr.mjs +73 -0
  46. package/lib/hlasm/locate.mjs +333 -0
  47. package/lib/hlasm/macro/authorization.mjs +146 -0
  48. package/lib/hlasm/macro/datasets.mjs +107 -0
  49. package/lib/hlasm/macro/io.mjs +162 -0
  50. package/lib/hlasm/macro/le.mjs +53 -0
  51. package/lib/hlasm/macro/linkage.mjs +143 -0
  52. package/lib/hlasm/macro/operator.mjs +77 -0
  53. package/lib/hlasm/macro/program.mjs +184 -0
  54. package/lib/hlasm/macro/recovery.mjs +85 -0
  55. package/lib/hlasm/macro/storage.mjs +167 -0
  56. package/lib/hlasm/macro/structured.mjs +131 -0
  57. package/lib/hlasm/model.mjs +97 -0
  58. package/lib/hlasm/mvs38.mjs +47 -0
  59. package/lib/hlasm/operands.mjs +59 -0
  60. package/lib/hlasm/optable.mjs +61 -0
  61. package/lib/hlasm/read.mjs +130 -0
  62. package/lib/hlasm.mjs +44 -7
  63. package/lib/ims/dli.mjs +37 -0
  64. package/lib/ims/macro/dbd.mjs +299 -0
  65. package/lib/ims/macro/psb.mjs +286 -0
  66. package/lib/ims/model.mjs +149 -0
  67. package/lib/ims/operands.mjs +23 -0
  68. package/lib/ims/read.mjs +37 -0
  69. package/lib/ims/rules.mjs +135 -0
  70. package/lib/inventory.mjs +10 -7
  71. package/lib/ironwork-ids.mjs +24 -0
  72. package/lib/ironwork.mjs +20 -13
  73. package/lib/kernel/pds-archive.mjs +256 -0
  74. package/lib/kernel/registry.mjs +32 -23
  75. package/lib/kernel/shared-pass.mjs +163 -0
  76. package/lib/kernel/source-tree.mjs +104 -36
  77. package/lib/kernel/version-key.mjs +17 -0
  78. package/lib/layout.mjs +26 -31
  79. package/lib/options.mjs +26 -4
  80. package/lib/parser.mjs +136 -17
  81. package/lib/pli/cursor.mjs +15 -0
  82. package/lib/pli/expr.mjs +101 -0
  83. package/lib/pli/include.mjs +82 -0
  84. package/lib/pli/layout.mjs +125 -0
  85. package/lib/pli/lex.mjs +198 -0
  86. package/lib/pli/program.mjs +280 -0
  87. package/lib/pli/rules/based.mjs +95 -0
  88. package/lib/pli/rules/conditions.mjs +68 -0
  89. package/lib/pli/rules/entry.mjs +130 -0
  90. package/lib/pli/rules/index.mjs +24 -0
  91. package/lib/pli/rules/preprocessor.mjs +55 -0
  92. package/lib/pli/statements.mjs +130 -0
  93. package/lib/pli/stmt/alloc.mjs +45 -0
  94. package/lib/pli/stmt/assignment.mjs +56 -0
  95. package/lib/pli/stmt/call.mjs +104 -0
  96. package/lib/pli/stmt/conditions.mjs +94 -0
  97. package/lib/pli/stmt/control.mjs +219 -0
  98. package/lib/pli/stmt/declare.mjs +149 -0
  99. package/lib/pli/stmt/exec.mjs +55 -0
  100. package/lib/pli/stmt/io.mjs +239 -0
  101. package/lib/pli/stmt/misc.mjs +4 -0
  102. package/lib/pli/stmt/preprocessor.mjs +242 -0
  103. package/lib/pli/stmt/procedure.mjs +258 -0
  104. package/lib/pli/stmt/stream.mjs +283 -0
  105. package/lib/pli/storage.mjs +129 -0
  106. package/lib/policy.mjs +6 -0
  107. package/lib/precompile-check.mjs +124 -0
  108. package/lib/precompile-cics.mjs +8 -4
  109. package/lib/reach.mjs +11 -2
  110. package/lib/revision.json +1 -1
  111. package/lib/sarif.mjs +44 -3
  112. package/lib/scan.mjs +28 -12
  113. package/lib/sets/abend.mjs +82 -8
  114. package/lib/sets/build.mjs +43 -37
  115. package/lib/sets/cics.mjs +20 -38
  116. package/lib/sets/compile.mjs +46 -18
  117. package/lib/sets/copybook.mjs +5 -3
  118. package/lib/sets/crypto.mjs +5 -3
  119. package/lib/sets/ddl.mjs +36 -0
  120. package/lib/sets/flow.mjs +32 -8
  121. package/lib/sets/hidden.mjs +5 -3
  122. package/lib/sets/hlasm.mjs +124 -8
  123. package/lib/sets/ims.mjs +139 -0
  124. package/lib/sets/log.mjs +11 -9
  125. package/lib/sets/opaque.mjs +27 -7
  126. package/lib/sets/pli.mjs +40 -0
  127. package/lib/sets/priv.mjs +5 -3
  128. package/lib/sets/recon.mjs +5 -3
  129. package/lib/sets/secrets.mjs +112 -0
  130. package/lib/sets/semantics.mjs +8 -3
  131. package/lib/sets/web.mjs +39 -23
  132. package/lib/site.mjs +10 -0
  133. package/lib/sources.mjs +80 -20
  134. package/lib/statement-cursor.mjs +67 -0
  135. package/lib/verify.mjs +3 -2
  136. package/lib/version.mjs +6 -0
  137. package/package.json +3 -2
  138. package/rules/compliance-cobit2019.json +520 -5
  139. package/rules/compliance-dora.json +509 -5
  140. package/rules/compliance-ffiec.json +505 -1
  141. package/rules/compliance-nist80053.json +557 -1
  142. package/rules/gitleaks-mainframe.toml +46 -15
  143. package/rules/hlasm-instructions.json +2396 -0
  144. package/rules/hlasm-optables.json +8024 -0
  145. package/schema/cobolwork-baseline.schema.json +36 -0
  146. package/schema/cobolwork-build-provenance.schema.json +187 -0
  147. package/schema/cobolwork-build.schema.json +382 -0
  148. package/schema/cobolwork-capabilities.schema.json +239 -0
  149. package/schema/cobolwork-diff.schema.json +217 -0
  150. package/schema/cobolwork-evidence.schema.json +161 -0
  151. package/schema/cobolwork-execution.schema.json +53 -0
  152. package/schema/cobolwork-explain.schema.json +360 -0
  153. package/schema/cobolwork-finding.schema.json +465 -0
  154. package/schema/cobolwork-flow.schema.json +465 -0
  155. package/schema/cobolwork-gate.schema.json +211 -0
  156. package/schema/cobolwork-inventory.schema.json +206 -0
  157. package/schema/cobolwork-parse.schema.json +105 -0
  158. package/schema/cobolwork-reach.schema.json +74 -0
  159. package/schema/cobolwork-report.schema.json +559 -0
  160. package/schema/cobolwork-witness.schema.json +107 -0
  161. package/schema/cobolwork.baseline.schema.json +101 -0
  162. package/schema/cobolwork.policy.schema.json +7 -0
  163. package/schema/cobolwork.site.schema.json +116 -0
package/lib/sarif.mjs CHANGED
@@ -45,13 +45,23 @@ const DECLARED = new Map([...Object.entries(ALL_RULES), ...Object.entries(DIFF_R
45
45
  const SET_OF = new Map();
46
46
  for (const s of REGISTRY) for (const id of Object.keys(s.rules)) SET_OF.set(id, s.name);
47
47
 
48
+ // A related entry without a path is an advisory the finding rests on, not a place in the tree.
49
+ const located = (f) => (f.related || []).filter((r) => r.path);
50
+
51
+ const cweTaxon = (cwe) => /^CWE-(\d+)$/.exec(cwe || '')?.[1] ?? null;
52
+
53
+ // The CWE taxonomy is run.taxonomies[0] whenever any rule names a CWE.
54
+ const CWE_COMPONENT = { name: 'CWE', index: 0 };
55
+
48
56
  // SARIF's level cannot say what kind of claim a result is, so evidence rides in the property bags.
49
57
  const descriptorFor = (report) => (id) => {
50
58
  const properties = {
51
59
  ...(report.ruleCwe?.[id] ? { cwe: report.ruleCwe[id] } : {}),
52
60
  ...(report.ruleEvidence?.[id] ? { evidence: report.ruleEvidence[id] } : {}),
61
+ ...(report.ruleCompliance?.[id] ? { compliance: report.ruleCompliance[id] } : {}),
53
62
  ...(SECURITY_SEVERITY[DECLARED.get(id)] ? { 'security-severity': SECURITY_SEVERITY[DECLARED.get(id)] } : {}),
54
63
  };
64
+ const taxon = cweTaxon(report.ruleCwe?.[id]);
55
65
  return {
56
66
  id,
57
67
  shortDescription: { text: report.ruleText?.[id] || id },
@@ -59,10 +69,23 @@ const descriptorFor = (report) => (id) => {
59
69
  // rule. `help` is where GitHub code scanning shows a recommendation.
60
70
  ...(report.ruleImpact?.[id] ? { fullDescription: { text: textOf(report.ruleImpact[id]) } } : {}),
61
71
  ...(report.ruleRemedy?.[id] ? { help: { text: textOf(report.ruleRemedy[id]) } } : {}),
72
+ ...(taxon ? { relationships: [{ target: { id: taxon, toolComponent: CWE_COMPONENT } }] } : {}),
62
73
  ...(Object.keys(properties).length ? { properties } : {}),
63
74
  };
64
75
  };
65
76
 
77
+ function cweTaxonomy(report, ruleIds) {
78
+ const taxa = [...new Set(ruleIds.map((id) => cweTaxon(report.ruleCwe?.[id])).filter(Boolean))].sort((a, b) => a - b);
79
+ if (!taxa.length) return [];
80
+ return [{
81
+ name: CWE_COMPONENT.name,
82
+ organization: 'MITRE',
83
+ shortDescription: { text: 'Common Weakness Enumeration' },
84
+ informationUri: 'https://cwe.mitre.org/',
85
+ taxa: taxa.map((id) => ({ id, helpUri: `https://cwe.mitre.org/data/definitions/${id}.html` })),
86
+ }];
87
+ }
88
+
66
89
  function notifications(report) {
67
90
  const bySet = report.summary?.bySet || {};
68
91
  const execution = [];
@@ -111,6 +134,9 @@ function notifications(report) {
111
134
  properties: { set },
112
135
  });
113
136
  }
137
+ for (const [id, warnings] of [['cobolwork/site-key-ignored', report.summary?.siteWarnings], ['cobolwork/feed-key-ignored', report.summary?.feedWarnings]]) {
138
+ for (const w of warnings || []) configuration.push({ descriptor: { id }, level: 'warning', message: { text: textOf(w) } });
139
+ }
114
140
  // A diff whose two sides were scanned under different configuration.
115
141
  for (const { file, change } of report.summary?.configurationChanged || []) {
116
142
  configuration.push({
@@ -144,6 +170,16 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
144
170
 
145
171
  const { execution, configuration } = notifications(report);
146
172
  const s = report.summary || {};
173
+ const taxonomies = cweTaxonomy(report, present);
174
+
175
+ // Every file a result or related location names, each listed once; locations point at it by index.
176
+ const results = [...report.findings, ...suppressed];
177
+ const uris = [...new Set(results.flatMap((f) => [f.path, ...located(f).map((r) => r.path)]).map(uriOf))].sort();
178
+ const artifactIndex = new Map(uris.map((uri, i) => [uri, i]));
179
+ const artifactLocation = (path) => {
180
+ const uri = uriOf(path);
181
+ return { uri, uriBaseId: '%SRCROOT%', index: artifactIndex.get(uri) };
182
+ };
147
183
 
148
184
  return {
149
185
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
@@ -158,6 +194,7 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
158
194
  },
159
195
  ...(extensions.length ? { extensions } : {}),
160
196
  },
197
+ ...(taxonomies.length ? { taxonomies } : {}),
161
198
  invocations: [{
162
199
  // The verdict, where a consumer that reads nothing else will still see it.
163
200
  executionSuccessful: s.nosrc !== true,
@@ -183,7 +220,8 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
183
220
  },
184
221
  },
185
222
  }],
186
- results: [...report.findings, ...suppressed].map((f) => {
223
+ ...(uris.length ? { artifacts: uris.map((uri) => ({ location: { uri, uriBaseId: '%SRCROOT%' } })) } : {}),
224
+ results: results.map((f) => {
187
225
  const set = SET_OF.get(f.rule);
188
226
  const component = set !== undefined ? indexOfSet.get(set) : undefined;
189
227
  const within = component === undefined ? null : extensions[component].rules.findIndex((r) => r.id === f.rule);
@@ -213,9 +251,12 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
213
251
  ...(f.reach ? { reach: f.reach } : {}),
214
252
  ...(f.effect ? { effect: f.effect } : {}),
215
253
  ...(f.exploitability ? { exploitability: f.exploitability } : {}),
254
+ // How an abend's input reached the program: through its own entry, or as arguments to
255
+ // a subprogram no caller run is shown to pass.
256
+ ...(f.abend?.inputFrom ? { inputFrom: f.abend.inputFrom } : {}),
216
257
  } } : {}),
217
- locations: [{ physicalLocation: { artifactLocation: { uri: uriOf(f.path), uriBaseId: '%SRCROOT%' }, region: { startLine: f.line || 1 } } }],
218
- ...(f.related ? { relatedLocations: f.related.map((r) => ({ physicalLocation: { artifactLocation: { uri: uriOf(r.path), uriBaseId: '%SRCROOT%' }, region: { startLine: r.line || 1 } }, message: { text: textOf(r.detail) } })) } : {}),
258
+ locations: [{ physicalLocation: { artifactLocation: artifactLocation(f.path), region: { startLine: f.line || 1 } } }],
259
+ ...(located(f).length ? { relatedLocations: located(f).map((r) => ({ physicalLocation: { artifactLocation: artifactLocation(r.path), region: { startLine: r.line || 1 } }, message: { text: textOf(r.detail) } })) } : {}),
219
260
  ...(f.suppressed ? { suppressions: [{ kind: 'external', status: 'accepted', justification: `${f.suppressed.action} by ${f.suppressed.who} until ${f.suppressed.expires}: ${f.suppressed.reason}` }] } : {}),
220
261
  };
221
262
  }),
package/lib/scan.mjs CHANGED
@@ -3,7 +3,8 @@ import { join, resolve } from 'node:path';
3
3
  import { sortFindings, tally, evidenceMap, EVIDENCE, EXPLOITABILITY } from './kernel/findings.mjs';
4
4
  import { directoryTree } from './kernel/source-tree.mjs';
5
5
  import { REGISTRY, RULE_SETS, ALL_RULES, reportKey } from './kernel/registry.mjs';
6
- import { inventory } from './inventory.mjs';
6
+ import { inventorySteps } from './inventory.mjs';
7
+ import { driveTogether, sharingTree } from './kernel/shared-pass.mjs';
7
8
  import { stoppedBecause } from './kernel/memory.mjs';
8
9
  import { complianceMap, FRAMEWORKS_LOADED, FRAMEWORKS_UNLOADED } from './compliance.mjs';
9
10
  import { stampFingerprints, FINGERPRINT_VERSION } from './kernel/identity.mjs';
@@ -65,6 +66,9 @@ function mergeEstate(reports) {
65
66
  effectNote: effectless.length ? `no authority facts declared in ${of(effectless)}, so no finding there was judged to run privileged: name privilegedTransactions/privilegedJobs in each repository's cobolwork.site.json, or bring them in the COBOLWORK_REACH extract` : null,
66
67
  witnessFeeds: first('witnessFeeds'), byWitness: addUp('byWitness'), witnessFeedProblems: union('witnessFeedProblems'),
67
68
  witnessUnmatched: unmatched, witnessOverruled: overruled,
69
+ executionFeeds: first('executionFeeds'), byExecution: addUp('byExecution'), executionFeedProblems: union('executionFeedProblems'),
70
+ siteWarnings: all.flatMap((x) => (x.s.siteWarnings || []).map((w) => `${x.repo}/${w}`)),
71
+ feedWarnings: union('feedWarnings'),
68
72
  };
69
73
  return Object.fromEntries(Object.entries(merged).filter(([, v]) => (Array.isArray(v) ? v.length : v)));
70
74
  }
@@ -101,7 +105,7 @@ function scanEach(root, opts) {
101
105
  filesScanned, programs: sum('programs'), programFiles: sum('programFiles'), copybookFiles: sum('copybookFiles'),
102
106
  jclFiles: sum('jclFiles'), copiesMissing: sum('copiesMissing'), filesUnreadable: sum('filesUnreadable'),
103
107
  filesEbcdic: sum('filesEbcdic'), filesOverBudget: sum('filesOverBudget'), crossProgram: sum('crossProgram'),
104
- ruleSets: opts.only || RULE_SETS, nosrc: filesScanned === 0,
108
+ ruleSets: opts.only || RULE_SETS, flowModel: FLOW_MODEL, toolVersion: TOOL_VERSION, nosrc: filesScanned === 0,
105
109
  coverageIncomplete: reports.some(({ r }) => r.summary.coverageIncomplete),
106
110
  identity: { version: FINGERPRINT_VERSION, shared },
107
111
  ...(reports[0]?.r.summary.advisoryFeeds ? { advisoryFeeds: reports[0].r.summary.advisoryFeeds } : {}),
@@ -157,7 +161,10 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
157
161
  const { byExecution } = applyExecution(findings, root, executionFeeds);
158
162
  const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
159
163
  const loaded = witnessFeeds.filter((f) => !f.problem);
164
+ const feedWarnings = [...reachFeeds, ...witnessFeeds].flatMap((f) => f.warnings).sort();
160
165
  return {
166
+ ...(site.warnings.length ? { siteWarnings: site.warnings } : {}),
167
+ ...(feedWarnings.length ? { feedWarnings } : {}),
161
168
  ...(byReach ? { byReach } : {}),
162
169
  ...(byEffect ? { byEffect } : {}),
163
170
  ...(reachFeeds.some(f => !f.problem) ? { reachFeeds: reachFeeds.filter(f => !f.problem).map(f => ({ file: f.file, extract: f.extract, retrieved: f.retrieved })) } : {}),
@@ -178,13 +185,12 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
178
185
  }
179
186
 
180
187
  // A set that throws is reported as not having run, and the other sets' findings stand.
188
+ const notRun = (set, e) => report(set.name, { rules: set.rules, findings: [], stats: {
189
+ coverageIncomplete: true,
190
+ notRead: `the rule set stopped on an error and reported nothing: ${printable(e?.message ?? e)}`,
191
+ } });
181
192
  function runSet(set, root, o) {
182
- try { return set.scan(root, o); } catch (e) {
183
- return report(set.name, { rules: set.rules, findings: [], stats: {
184
- coverageIncomplete: true,
185
- notRead: `the rule set stopped on an error and reported nothing: ${printable(e?.message ?? e)}`,
186
- } });
187
- }
193
+ try { return set.scan(root, o); } catch (e) { return notRun(set, e); }
188
194
  }
189
195
 
190
196
  export function scanAll(root, opts = {}) {
@@ -193,12 +199,22 @@ export function scanAll(root, opts = {}) {
193
199
  // One walk of the tree for the whole scan. Every rule set used to call buildFileIndex(root) for
194
200
  // itself, so a scan read every directory entry and resolved every symlink nine times over, and
195
201
  // added a tenth walk each time a rule set was added.
196
- const tree = opts.tree || directoryTree(root, opts);
202
+ const tree = sharingTree(opts.tree || directoryTree(root, opts));
197
203
  const o = { ...opts, tree };
198
- const inv = inventory(root, o);
199
204
  // One loop over the registry, in its order. A set that is not registered cannot run, and
200
- // cannot report a silent zero on its own behalf either.
201
- const parts = REGISTRY.filter((set) => only.includes(set.name)).map((set) => runSet(set, root, o));
205
+ // cannot report a silent zero on its own behalf either. The inventory and the sets that read
206
+ // programs share one pass, so each program is read and parsed once.
207
+ const chosen = REGISTRY.filter((set) => only.includes(set.name));
208
+ const stepped = chosen.filter((set) => set.steps);
209
+ const [invResult, ...stepResults] = driveTogether([inventorySteps(root, o), ...stepped.map((set) => set.steps(root, o))], tree);
210
+ if (invResult && invResult.error) throw invResult.error;
211
+ const inv = invResult;
212
+ const byName = new Map(stepped.map((set, k) => [set.name, stepResults[k]]));
213
+ const parts = chosen.map((set) => {
214
+ if (!byName.has(set.name)) return runSet(set, root, o);
215
+ const r = byName.get(set.name);
216
+ return r && r.error ? notRun(set, r.error) : r;
217
+ });
202
218
 
203
219
  const flowPart = parts.find(p => p.tool === 'cobolwork-flow');
204
220
  const overBudget = flowPart ? flowPart.summary.filesOverBudget || 0 : 0;
@@ -10,6 +10,7 @@ import { createHash } from 'node:crypto';
10
10
  import { delimiter, join, posix, basename } from 'node:path';
11
11
  import { report } from '../kernel/ruleset.mjs';
12
12
  import { verifyEvidence } from '../evidence/verify.mjs';
13
+ import { NOT_RUN_ABENDS } from '../ironwork-ids.mjs';
13
14
 
14
15
  export const ABEND_RULES = {
15
16
  'input-causes-abend-s0c7': {
@@ -30,6 +31,18 @@ export const ABEND_RULES = {
30
31
  impact: 'SSRANGE caught the overrun and ended the run; compiled without SSRANGE, as production builds often are, the same input reads or writes storage beside the table or field',
31
32
  remedy: 'Check the index against both ends of the table or field before the statement the journal names, and keep SSRANGE on for the build',
32
33
  },
34
+ 'input-causes-hang': {
35
+ sev: 'med', evidence: 'execution', cwe: 'CWE-834',
36
+ text: 'An input the program accepts keeps a loop running past the statement limit that stands in for its time limit (S322)',
37
+ impact: 'Whoever supplies that input decides how long the loop runs; on z/OS a long enough run holds the step and its processor until TIME= ends it, and the jobs behind it wait. The run shows the loop passed the limit, not that it would never end',
38
+ remedy: 'Find the loop at the line the journal names and bound its iterations: check the count or the exit condition the input supplies before the loop starts, or stop it at a counter and reject the input that would pass it',
39
+ },
40
+ 'input-selects-program': {
41
+ sev: 'high', evidence: 'execution', cwe: 'CWE-470',
42
+ text: 'An input the program accepts chooses the program a CALL loads',
43
+ impact: "Whoever supplies that input picks which load module runs with this program's authority and data; the fuzz run named one the library does not hold, so the run ended in S806, but a name the library does hold runs",
44
+ remedy: 'Choose the called program from a fixed set of names checked against the input, or call it by a literal name, at the CALL the journal names',
45
+ },
33
46
  'input-causes-abend': {
34
47
  sev: 'med', evidence: 'execution', cwe: 'CWE-248',
35
48
  text: 'An input the program accepts ends its run with an abend',
@@ -38,19 +51,44 @@ export const ABEND_RULES = {
38
51
  },
39
52
  };
40
53
 
54
+ // What the manifest's `optimized` says of an abend: whether the same input ends the same way with
55
+ // the program compiled at OPTIMIZE(2), where IBM may compare invalid data by its bytes rather than
56
+ // end in a data exception at OPTIMIZE(0), its default.
57
+ const OPTIMIZED = {
58
+ true: '; compiled with OPTIMIZE(2) it ends the same way',
59
+ false: '; it holds at OPTIMIZE(0), IBM\'s default: compiled with OPTIMIZE(2) the same input does not end there',
60
+ };
61
+
41
62
  // IBM's messages for an SSRANGE failure: a subscript or index, an OCCURS DEPENDING ON object, and a
42
63
  // reference modification's start, length, and start plus length.
43
64
  const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
44
65
 
66
+ // Language Environment's message for a dynamic CALL of a module no library holds, which ironwork
67
+ // ends U4038 (its assumption C450); earlier ironwork releases ended the same CALL S806.
68
+ const MODULE_NOT_FOUND = /^CEE3501S The module .* was not found\.$/;
69
+
45
70
  export function abendRunPaths(opts = {}) {
46
71
  if (opts.abendRuns) return opts.abendRuns;
47
72
  return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
48
73
  }
49
74
 
75
+ // How ironwork ends the message of a data or protection exception in a CICS task, whose abend
76
+ // code CICS reports as ASRA.
77
+ const CICS_PROGRAM_CHECK = /\((S0C7|S0C4), which CICS reports as ASRA\)$/;
78
+
79
+ // How ironwork ends the message of a Language Environment condition in a CICS task, U4038 in
80
+ // batch, which CICS reports as transaction abend 4038 (its assumption C454).
81
+ const CICS_LE_CONDITION = / \(U4038, which CICS reports as transaction abend 4038\)$/;
82
+
50
83
  export function abendRule(abend) {
51
- if (abend.code === 'S0C7') return 'input-causes-abend-s0c7';
52
- if (abend.code === 'S0C4') return 'input-causes-abend-s0c4';
53
- if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
84
+ const cicsLe = abend.code === '4038' && CICS_LE_CONDITION.test(abend.message || '');
85
+ const message = cicsLe ? abend.message.replace(CICS_LE_CONDITION, '') : abend.message || '';
86
+ const code = cicsLe ? 'U4038' : (abend.code === 'ASRA' && CICS_PROGRAM_CHECK.exec(message)?.[1]) || abend.code;
87
+ if (code === 'S0C7') return 'input-causes-abend-s0c7';
88
+ if (code === 'S0C4') return 'input-causes-abend-s0c4';
89
+ if (code === 'S322') return 'input-causes-hang';
90
+ if (code === 'S806' || (code === 'U4038' && MODULE_NOT_FOUND.test(message))) return 'input-selects-program';
91
+ if (RANGE_MESSAGES.test(message)) return 'input-causes-abend-subscript-range';
54
92
  return 'input-causes-abend';
55
93
  }
56
94
 
@@ -63,16 +101,35 @@ function journalAbend(evidenceDir, runId) {
63
101
  let abend = null;
64
102
  const inputs = [];
65
103
  const calls = [];
104
+ const sinks = [];
66
105
  for (const line of readFileSync(file, 'utf8').split('\n')) {
67
106
  if (!line) continue;
68
107
  const rec = JSON.parse(line);
69
108
  if (rec.kind === 'abend') abend ??= rec;
70
109
  else if (rec.kind === 'input') inputs.push(rec);
71
110
  else if (rec.kind === 'call') calls.push(rec);
111
+ else if (rec.kind === 'sink') sinks.push(rec);
72
112
  }
73
- return abend && { ...abend, inputs, calls };
113
+ return abend && { ...abend, inputs, calls, sinks };
74
114
  }
75
115
 
116
+ const MANIFEST_FORMAT = 'ironwork-fuzz/v1';
117
+ // A subprogram fuzzed at its interface: its inputs are arguments, and a caller passing them is
118
+ // not shown to exist (ironwork docs/evidence.md §5.2).
119
+ const INTERFACE_FORMAT = 'ironwork-fuzz-interface/v1';
120
+ const FORMATS = [MANIFEST_FORMAT, INTERFACE_FORMAT];
121
+
122
+ // What a finding from an interface run says of the caller its input needs.
123
+ function interfaceClause(callers) {
124
+ const where = callers.length
125
+ ? `with arguments shaped by the CALL${callers.length === 1 ? '' : 's'} at ${callers.map((c) => `${c.file}:${c.line}`).join(', ')}`
126
+ : 'on generated arguments, with no CALL to it in the scanned tree';
127
+ return `; it ran as a subprogram, ${where}, on its first call in its initial state: a caller passing these bytes ends it, and no caller run shows one does`;
128
+ }
129
+
130
+ // ironwork's own abend codes for a construct it does not run: no IBM system would raise them.
131
+ const NOT_MODELLED = new Set(NOT_RUN_ABENDS);
132
+
76
133
  const isString = (x) => typeof x === 'string' && x.length > 0;
77
134
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
78
135
 
@@ -113,6 +170,15 @@ export function loadAbendRun(dir, tree) {
113
170
  out.problems.push('manifest.json is not an ironwork-fuzz manifest with a program, inputs and runs');
114
171
  return out;
115
172
  }
173
+ // A manifest from before ironwork wrote `format` has the first format's shape.
174
+ if (doc.format !== undefined && !FORMATS.includes(doc.format)) {
175
+ out.problems.push(`manifest.json is in format ${JSON.stringify(doc.format)}, and this cobolwork reads ${FORMATS.join(' and ')}`);
176
+ return out;
177
+ }
178
+ const inputFrom = doc.format === INTERFACE_FORMAT ? 'interface' : 'entry';
179
+ const callers = inputFrom === 'interface' && Array.isArray(doc.callers)
180
+ ? doc.callers.filter((c) => c && isString(c.file) && Number.isInteger(c.line)).map((c) => ({ file: posix.normalize(c.file.replace(/\\/g, '/')), line: c.line }))
181
+ : [];
116
182
  const programFile = posix.normalize(doc.program.file.replace(/\\/g, '/'));
117
183
  if (programFile.startsWith('../') || posix.isAbsolute(programFile)) {
118
184
  out.problems.push(`the program ${doc.program.file} is outside the scanned tree`);
@@ -131,7 +197,7 @@ export function loadAbendRun(dir, tree) {
131
197
  const seen = new Map();
132
198
  for (const run of doc.runs) {
133
199
  if (!run || run.outcome !== 'abend' || !run.abend || !isString(run.abend.code)) continue;
134
- if (run.abend.code === 'IRONWORK') { out.notModelled++; continue; }
200
+ if (NOT_MODELLED.has(run.abend.code)) { out.notModelled++; continue; }
135
201
  const recorded = journalAbend(evidenceDir, run.journal);
136
202
  if (!recorded || recorded.code !== run.abend.code) {
137
203
  out.problems.push(`run ${run.journal ?? '(unnamed)'}: its journal does not record the abend ${run.abend.code} the manifest gives`);
@@ -152,16 +218,23 @@ export function loadAbendRun(dir, tree) {
152
218
  const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
153
219
  const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
154
220
  const rule = abendRule(run.abend);
221
+ // A CALL of a missing program is input choosing the program only where the run's own journal
222
+ // records the marker fuzz put in the input reaching that CALL's program load.
223
+ if (rule === 'input-selects-program' && !recorded.sinks.some((k) => k.sink === 'dynamic-program-load' && k.reached === true && k.file === place.file && k.line === place.line)) {
224
+ out.problems.push(`run ${run.journal}: its journal does not record the input reaching the CALL at ${place.file}:${place.line}, so its missing-program ending is not reported`);
225
+ continue;
226
+ }
155
227
  const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
156
- .map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}) }));
228
+ .map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}), ...(Number.isInteger(i.position) ? { position: i.position } : {}), ...(i.omitted === true ? { omitted: true } : {}) }));
157
229
  const key = `${rule}|${where}|${line}`;
158
230
  const size = used.reduce((n, i) => n + String(i.bytes || '').length, 0);
159
231
  const held = seen.get(key);
160
232
  if (held && held.size <= size) continue;
233
+ const optimized = typeof run.abend.optimized === 'boolean' ? run.abend.optimized : undefined;
161
234
  const finding = {
162
235
  rule, path: where, line, program: out.program.id,
163
- detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept`,
164
- abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}) },
236
+ detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept${OPTIMIZED[optimized] ?? ''}${inputFrom === 'interface' ? interfaceClause(callers) : ''}`,
237
+ abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}), ...(optimized === undefined ? {} : { optimized }), inputFrom, ...(inputFrom === 'interface' ? { callers } : {}) },
165
238
  input: used, run: { dir: out.dir, journal: run.journal },
166
239
  };
167
240
  seen.set(key, { size, finding });
@@ -179,6 +252,7 @@ export function scanAbend(root, opts = {}) {
179
252
  abendRuns: runs.map((r) => ({ dir: r.dir, program: r.program ? r.program.file : null, findings: r.findings.length, ...(r.notModelled ? { notModelled: r.notModelled } : {}), ...(r.counts ? { counts: r.counts } : {}) })),
180
253
  ...(runs.some((r) => r.problems.length) ? { abendRunProblems: runs.flatMap((r) => r.problems.map((p) => `${r.dir}: ${p}`)) } : {}),
181
254
  ...(runs.some((r) => r.findings.some((f) => !existsSync(join(root, f.path)))) ? { abendRunsElsewhere: runs.flatMap((r) => r.findings.filter((f) => !existsSync(join(root, f.path))).map((f) => `${r.dir}: ${f.path}`)) } : {}),
255
+ byInputFrom: { entry: findings.filter((f) => f.abend.inputFrom === 'entry').length, interface: findings.filter((f) => f.abend.inputFrom === 'interface').length },
182
256
  setIncomplete: runs.some((r) => r.problems.length > 0),
183
257
  };
184
258
  return report('abend', { rules: ABEND_RULES, findings, stats, run: null });
@@ -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,9 +4,10 @@ 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
+ import { execReading } from '../exec-reading.mjs';
10
11
 
11
12
 
12
13
  // `impact` says who can do what once the construct is present, so a reader can tell a defect they
@@ -113,27 +114,6 @@ export function signOnTest(test) {
113
114
  }
114
115
 
115
116
 
116
- function execOptions(exec) {
117
- const opts = new Map();
118
- const words = [];
119
- const toks = exec.toks;
120
- for (let i = 0; i < toks.length; i++) {
121
- const t = toks[i];
122
- if (t.t !== 'word') continue;
123
- words.push(t.u);
124
- if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
125
- const inner = [];
126
- let depth = 1;
127
- for (let k = i + 2; k < toks.length && depth > 0; k++) {
128
- if (toks[k].t === 'sep') { depth += toks[k].v === '(' ? 1 : -1; if (depth === 0) break; continue; }
129
- inner.push(toks[k]);
130
- }
131
- if (!opts.has(t.u)) opts.set(t.u, inner);
132
- }
133
- }
134
- return { opts, words };
135
- }
136
-
137
117
  const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
138
118
 
139
119
  // Whether EIBCALEN bounds what the program reads: a condition tests it, or a field it was moved or
@@ -164,8 +144,8 @@ function testsLength(p) {
164
144
  // Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
165
145
  // not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
166
146
  // have gone, an ASSIGN, and the RETURN that ends the task anyway.
167
- const QUIET_FAILURES = (words) => (words[0] === 'DELETEQ' && words[1] === 'TS') || (words[0] === 'WRITEQ' && words[1] === 'TD')
168
- || ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(words[0]);
147
+ const QUIET_FAILURES = ({ verb, sub }) => (verb === 'DELETEQ' && sub === 'TS') || (verb === 'WRITEQ' && sub === 'TD')
148
+ || ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(verb);
169
149
  const SHOWN_IGNORED = 5;
170
150
 
171
151
  // Commands whose outcome the program cannot see: NOHANDLE with no RESP and no EIBRESP test before the
@@ -175,15 +155,16 @@ function conditionsIgnored(p, path) {
175
155
  const execs = p.execs.filter((e) => e.kind === 'CICS').sort((a, b) => a.line - b.line);
176
156
  const hits = [];
177
157
  execs.forEach((e, k) => {
178
- const { opts: o, words } = execOptions(e);
179
- if (words[0] === 'IGNORE' && words[1] === 'CONDITION') {
158
+ const reading = execReading(e);
159
+ const { opts: o, words, verb, sub } = reading;
160
+ if (verb === 'IGNORE' && sub === 'CONDITION') {
180
161
  hits.push({ e, what: `IGNORE CONDITION ${words.slice(2).join(' ')}`.trim() });
181
162
  return;
182
163
  }
183
- if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(words)) return;
164
+ if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(reading)) return;
184
165
  const until = execs[k + 1] ? execs[k + 1].line : Infinity;
185
166
  const tested = p.statements.some((st) => st.line > e.line && st.line < until && (st.sources || []).some((t) => t.t === 'word' && t.u === 'EIBRESP'));
186
- if (!tested) hits.push({ e, what: `${words[0]}${words[1] && words[1] !== 'NOHANDLE' && !o.has(words[1]) ? ` ${words[1]}` : ''} NOHANDLE` });
167
+ if (!tested) hits.push({ e, what: `${verb}${sub && sub !== 'NOHANDLE' && !o.has(sub) ? ` ${sub}` : ''} NOHANDLE` });
187
168
  });
188
169
  if (!hits.length) return null;
189
170
  const shown = hits.slice(0, SHOWN_IGNORED).map((h) => `${h.what} at line ${h.e.line}`).join(', ');
@@ -201,7 +182,7 @@ function conditionsIgnored(p, path) {
201
182
  // away from, and this set kept doing it: on a 100,000-program repository it exhausted an 8 GB heap
202
183
  // and took a corpus run with it. What survives per program is an identifier, a communication area's
203
184
  // size, one boolean, and a short list of transfers.
204
- export function scanCics(root, opts = {}) {
185
+ export function* scanCicsSteps(root, opts = {}) {
205
186
  const tree = treeFor(root, opts);
206
187
  const files = tree.list().filter(isProgram).filter(inScope(opts));
207
188
  // Identifier to the size of that program's DFHCOMMAREA, which is all a caller needs of a callee.
@@ -226,8 +207,7 @@ export function scanCics(root, opts = {}) {
226
207
  const transfers = [];
227
208
  for (const e of p.execs) {
228
209
  if (e.kind !== 'CICS') continue;
229
- const { opts: o, words: w } = execOptions(e);
230
- const verb = w[0];
210
+ const { opts: o, verb } = execReading(e);
231
211
  if (!['LINK', 'XCTL', 'START'].includes(verb)) continue;
232
212
  const pgm = (o.get('PROGRAM') || o.get('TRANSID') || [])[0];
233
213
  const areaTok = (o.get('COMMAREA') || o.get('FROM') || [])[0];
@@ -275,7 +255,7 @@ export function scanCics(root, opts = {}) {
275
255
  // asking the security manager, and one that folds a password's case first. Passwords are recognised
276
256
  // by field name, as the sign-on rules above recognise them, and each finding says so.
277
257
  function passwordChecks(p, path) {
278
- if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execOptions(e).words[0] || ''))) return [];
258
+ if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execReading(e).verb || ''))) return [];
279
259
  const byName = new Map();
280
260
  for (const it of p.items) if (!byName.has(it.name)) byName.set(it.name, it);
281
261
  const resolve = (tok) => (tok && tok.t === 'word' ? p.resolved.get(tok) || byName.get(tok.u) || null : null);
@@ -286,8 +266,8 @@ export function scanCics(root, opts = {}) {
286
266
  // FETCH fills, and everything under any of them. A copy made by MOVE first is not followed.
287
267
  const read = new Set();
288
268
  for (const e of p.execs) {
289
- const { opts: o, words } = execOptions(e);
290
- if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(words[0] || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
269
+ const { opts: o, verb } = execReading(e);
270
+ if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(verb || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
291
271
  if (e.kind === 'SQL' && (e.hostVariables || []).length) for (const h of e.hostVariables) if (h.written && h.tok) read.add(h.tok.u);
292
272
  }
293
273
  const stored = (name) => {
@@ -343,15 +323,15 @@ export function scanCics(root, opts = {}) {
343
323
  const sends = [];
344
324
  for (const e of p.execs) {
345
325
  if (e.kind !== 'CICS') continue;
346
- const { opts: o, words: w } = execOptions(e);
347
- const opt = w[0] === 'XCTL' || w[0] === 'LINK' ? 'PROGRAM' : (w[0] === 'RETURN' || w[0] === 'START') ? 'TRANSID' : null;
326
+ const { opts: o, verb } = execReading(e);
327
+ const opt = verb === 'XCTL' || verb === 'LINK' ? 'PROGRAM' : (verb === 'RETURN' || verb === 'START') ? 'TRANSID' : null;
348
328
  const t = opt && (o.get(opt) || [])[0];
349
329
  if (!t || t.t !== 'lit') continue;
350
330
  const target = `${opt === 'PROGRAM' ? '' : 'transaction '}${String(t.v).trim().toUpperCase()}`;
351
331
  if (target === p.id) continue;
352
332
  const bits = ctl.facts.get(ctl.nodeOf.get(e));
353
333
  if (!bits) continue;
354
- sends.push({ e, verb: w[0], target, signedOn: hasFact(bits, bit) });
334
+ sends.push({ e, verb, target, signedOn: hasFact(bits, bit) });
355
335
  }
356
336
  const granted = new Set(sends.filter((x) => x.signedOn).map((x) => x.target));
357
337
  const aid = p.refs.some((r) => r.tok.u === 'EIBAID');
@@ -364,7 +344,7 @@ export function scanCics(root, opts = {}) {
364
344
  // Releasing each tree bounds what one program costs; it does not bound what a hundred thousand of
365
345
  // them cost together. The shared loop watches the heap and stops before V8 cannot collect, and
366
346
  // names every file it did not reach.
367
- const run = eachWithinMemory(files, (f) => {
347
+ const run = yield loopOver(files, (f) => {
368
348
  let src;
369
349
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
370
350
  if (!/EXEC\s+CICS/i.test(src) && !/(?:^|[\s.])COPY\s+/i.test(src)) return src.length;
@@ -445,3 +425,5 @@ export function scanCics(root, opts = {}) {
445
425
 
446
426
  return report('cics', { rules: CICS_RULES, findings, stats, run });
447
427
  }
428
+
429
+ export const scanCics = (root, opts = {}) => drive(scanCicsSteps(root, opts));