@portll/cobolwork 0.6.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 (140) hide show
  1. package/README.md +49 -18
  2. package/STABILITY.md +76 -0
  3. package/bin/cobolwork.mjs +30 -7
  4. package/lib/bms.mjs +21 -7
  5. package/lib/build.mjs +108 -53
  6. package/lib/capabilities.mjs +9 -10
  7. package/lib/cics-commands.mjs +501 -9
  8. package/lib/compliance.mjs +11 -0
  9. package/lib/consequence.mjs +13 -0
  10. package/lib/control-workers.mjs +161 -0
  11. package/lib/control.mjs +52 -3
  12. package/lib/dataflow.mjs +176 -125
  13. package/lib/db2/cursor.mjs +15 -0
  14. package/lib/db2/read.mjs +170 -0
  15. package/lib/db2/rules.mjs +77 -0
  16. package/lib/db2/stmt/alter.mjs +473 -0
  17. package/lib/db2/stmt/grant.mjs +125 -0
  18. package/lib/db2/stmt/index.mjs +141 -0
  19. package/lib/db2/stmt/misc.mjs +325 -0
  20. package/lib/db2/stmt/routine.mjs +564 -0
  21. package/lib/db2/stmt/storage.mjs +146 -0
  22. package/lib/db2/stmt/table.mjs +540 -0
  23. package/lib/db2/stmt/view.mjs +146 -0
  24. package/lib/diff.mjs +17 -5
  25. package/lib/evidence/cli.mjs +14 -3
  26. package/lib/evidence/record.mjs +1 -1
  27. package/lib/evidence/store.mjs +44 -31
  28. package/lib/exec-reading.mjs +51 -0
  29. package/lib/execution.mjs +3 -2
  30. package/lib/explain.mjs +2 -0
  31. package/lib/exploitability.mjs +11 -2
  32. package/lib/hlasm/asm/data.mjs +13 -1
  33. package/lib/hlasm/asm/sections.mjs +3 -1
  34. package/lib/hlasm/instr.mjs +25 -0
  35. package/lib/hlasm/macro/authorization.mjs +24 -4
  36. package/lib/hlasm/macro/datasets.mjs +30 -17
  37. package/lib/hlasm/macro/io.mjs +68 -45
  38. package/lib/hlasm/macro/le.mjs +2 -2
  39. package/lib/hlasm/macro/operator.mjs +32 -21
  40. package/lib/hlasm/macro/program.mjs +126 -84
  41. package/lib/hlasm/macro/recovery.mjs +10 -5
  42. package/lib/hlasm/macro/storage.mjs +64 -14
  43. package/lib/hlasm/macro/structured.mjs +1 -1
  44. package/lib/hlasm/model.mjs +35 -10
  45. package/lib/hlasm/mvs38.mjs +47 -0
  46. package/lib/hlasm/operands.mjs +10 -0
  47. package/lib/hlasm/optable.mjs +61 -0
  48. package/lib/hlasm/read.mjs +22 -8
  49. package/lib/hlasm.mjs +44 -7
  50. package/lib/ims/dli.mjs +37 -0
  51. package/lib/ims/macro/dbd.mjs +299 -0
  52. package/lib/ims/macro/psb.mjs +286 -0
  53. package/lib/ims/model.mjs +149 -0
  54. package/lib/ims/operands.mjs +23 -0
  55. package/lib/ims/read.mjs +37 -0
  56. package/lib/ims/rules.mjs +135 -0
  57. package/lib/ironwork.mjs +17 -13
  58. package/lib/kernel/pds-archive.mjs +256 -0
  59. package/lib/kernel/registry.mjs +14 -8
  60. package/lib/kernel/shared-pass.mjs +34 -9
  61. package/lib/kernel/source-tree.mjs +104 -36
  62. package/lib/kernel/version-key.mjs +17 -0
  63. package/lib/layout.mjs +26 -31
  64. package/lib/parser.mjs +136 -17
  65. package/lib/pli/cursor.mjs +15 -0
  66. package/lib/pli/expr.mjs +101 -0
  67. package/lib/pli/include.mjs +82 -0
  68. package/lib/pli/layout.mjs +125 -0
  69. package/lib/pli/lex.mjs +198 -0
  70. package/lib/pli/program.mjs +280 -0
  71. package/lib/pli/rules/based.mjs +95 -0
  72. package/lib/pli/rules/conditions.mjs +68 -0
  73. package/lib/pli/rules/entry.mjs +130 -0
  74. package/lib/pli/rules/index.mjs +24 -0
  75. package/lib/pli/rules/preprocessor.mjs +55 -0
  76. package/lib/pli/statements.mjs +130 -0
  77. package/lib/pli/stmt/alloc.mjs +45 -0
  78. package/lib/pli/stmt/assignment.mjs +56 -0
  79. package/lib/pli/stmt/call.mjs +104 -0
  80. package/lib/pli/stmt/conditions.mjs +94 -0
  81. package/lib/pli/stmt/control.mjs +219 -0
  82. package/lib/pli/stmt/declare.mjs +149 -0
  83. package/lib/pli/stmt/exec.mjs +55 -0
  84. package/lib/pli/stmt/io.mjs +239 -0
  85. package/lib/pli/stmt/misc.mjs +4 -0
  86. package/lib/pli/stmt/preprocessor.mjs +242 -0
  87. package/lib/pli/stmt/procedure.mjs +258 -0
  88. package/lib/pli/stmt/stream.mjs +283 -0
  89. package/lib/pli/storage.mjs +129 -0
  90. package/lib/precompile-check.mjs +124 -0
  91. package/lib/precompile-cics.mjs +7 -3
  92. package/lib/reach.mjs +11 -2
  93. package/lib/revision.json +1 -1
  94. package/lib/sarif.mjs +41 -3
  95. package/lib/scan.mjs +7 -1
  96. package/lib/sets/abend.mjs +16 -6
  97. package/lib/sets/cics.mjs +15 -35
  98. package/lib/sets/compile.mjs +41 -15
  99. package/lib/sets/crypto.mjs +5 -3
  100. package/lib/sets/ddl.mjs +36 -0
  101. package/lib/sets/flow.mjs +18 -1
  102. package/lib/sets/hidden.mjs +5 -3
  103. package/lib/sets/hlasm.mjs +72 -12
  104. package/lib/sets/ims.mjs +139 -0
  105. package/lib/sets/log.mjs +6 -6
  106. package/lib/sets/opaque.mjs +27 -7
  107. package/lib/sets/pli.mjs +40 -0
  108. package/lib/sets/recon.mjs +5 -3
  109. package/lib/sets/secrets.mjs +5 -3
  110. package/lib/sets/semantics.mjs +3 -0
  111. package/lib/sets/web.mjs +36 -22
  112. package/lib/site.mjs +10 -0
  113. package/lib/sources.mjs +80 -20
  114. package/lib/statement-cursor.mjs +67 -0
  115. package/lib/verify.mjs +3 -2
  116. package/lib/version.mjs +6 -0
  117. package/package.json +3 -2
  118. package/rules/compliance-cobit2019.json +432 -1
  119. package/rules/compliance-dora.json +414 -1
  120. package/rules/compliance-ffiec.json +414 -1
  121. package/rules/compliance-nist80053.json +466 -1
  122. package/rules/hlasm-optables.json +8024 -0
  123. package/schema/cobolwork-baseline.schema.json +36 -0
  124. package/schema/cobolwork-build-provenance.schema.json +187 -0
  125. package/schema/cobolwork-build.schema.json +382 -0
  126. package/schema/cobolwork-capabilities.schema.json +239 -0
  127. package/schema/cobolwork-diff.schema.json +217 -0
  128. package/schema/cobolwork-evidence.schema.json +161 -0
  129. package/schema/cobolwork-execution.schema.json +53 -0
  130. package/schema/cobolwork-explain.schema.json +360 -0
  131. package/schema/cobolwork-finding.schema.json +465 -0
  132. package/schema/cobolwork-flow.schema.json +465 -0
  133. package/schema/cobolwork-gate.schema.json +211 -0
  134. package/schema/cobolwork-inventory.schema.json +206 -0
  135. package/schema/cobolwork-parse.schema.json +105 -0
  136. package/schema/cobolwork-reach.schema.json +74 -0
  137. package/schema/cobolwork-report.schema.json +559 -0
  138. package/schema/cobolwork-witness.schema.json +107 -0
  139. package/schema/cobolwork.baseline.schema.json +101 -0
  140. package/schema/cobolwork.site.schema.json +116 -0
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Db2 for z/OS DDL: what a GRANT opens to PUBLIC or lets a grantee pass on, system and database
3
+ // authorities granted, and the exit routines and external load modules that run inside Db2
4
+ // (lib/db2/rules.mjs). A statement in another dialect is refused by the reader and counted as not
5
+ // read, never matched as if it were Db2.
6
+ import { inScope, isDb2, relPath } from '../sources.mjs';
7
+ import { report } from '../kernel/ruleset.mjs';
8
+ import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
9
+ import { eachWithinMemory } from '../kernel/memory.mjs';
10
+ import { readDb2, parseDb2Statement } from '../db2/read.mjs';
11
+ import { DB2_RULES, statementFindings } from '../db2/rules.mjs';
12
+
13
+ export { DB2_RULES };
14
+
15
+ export function scanDb2(root, opts = {}) {
16
+ const tree = treeFor(root, opts);
17
+ const files = tree.list().filter(inScope(opts)).filter(isDb2);
18
+ const stats = { filesScanned: 0, filesUnreadable: 0, statementsRead: 0 };
19
+ const findings = [];
20
+ const unread = {};
21
+ const run = eachWithinMemory(files, (f) => {
22
+ let src;
23
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
24
+ stats.filesScanned++;
25
+ const path = relPath(root, f);
26
+ for (const st of readDb2(src).statements) {
27
+ const r = parseDb2Statement(st);
28
+ if (r.status === 'parsed') { stats.statementsRead++; findings.push(...statementFindings(path, st, r)); }
29
+ else (unread[r.kind] ||= { count: 0, first: `${path}:${st.line}` }).count++;
30
+ }
31
+ return src.length;
32
+ }, { label: 'ddl', maxBytes: opts.maxSourceBytes ?? Infinity });
33
+
34
+ if (Object.keys(unread).length) { stats.statementsNotRead = unread; stats.coverageIncomplete = true; }
35
+ return report('ddl', { rules: DB2_RULES, findings, stats, run });
36
+ }
package/lib/sets/flow.mjs CHANGED
@@ -14,21 +14,25 @@ export const RULES = {
14
14
  'cics-terminal-to-os-command': { sev: 'crit', evidence: 'path', cwe: 'CWE-78', text: 'Terminal input reaches an operating-system command routine' },
15
15
  'cics-web-to-os-command': { sev: 'crit', evidence: 'path', cwe: 'CWE-78', text: 'Web input reaches an operating-system command routine' },
16
16
  'file-record-to-os-command': { sev: 'high', evidence: 'path', cwe: 'CWE-78', text: 'A file record reaches an operating-system command routine' },
17
+ 'cics-queue-to-os-command': { sev: 'high', evidence: 'path', measured: false, cwe: 'CWE-78', text: 'A CICS queue item reaches an operating-system command routine' },
17
18
  'database-to-os-command': { sev: 'high', evidence: 'path', cwe: 'CWE-78', text: 'A database value reaches an operating-system command routine' },
18
19
  'argv-or-env-to-dynamic-sql': { sev: 'high', evidence: 'path', cwe: 'CWE-89', text: 'Command-line or environment input reaches dynamic SQL' },
19
20
  'cics-terminal-to-dynamic-sql': { sev: 'crit', evidence: 'path', cwe: 'CWE-89', text: 'Terminal input reaches dynamic SQL' },
20
21
  'cics-web-to-dynamic-sql': { sev: 'crit', evidence: 'path', cwe: 'CWE-89', text: 'Web input reaches dynamic SQL' },
21
22
  'file-record-to-dynamic-sql': { sev: 'high', evidence: 'path', cwe: 'CWE-89', text: 'A file record reaches dynamic SQL' },
23
+ 'cics-queue-to-dynamic-sql': { sev: 'high', evidence: 'path', measured: false, cwe: 'CWE-89', text: 'A CICS queue item reaches dynamic SQL' },
22
24
  'database-to-dynamic-sql': { sev: 'high', evidence: 'path', cwe: 'CWE-89', text: 'A database value reaches dynamic SQL' },
23
25
  'argv-or-env-to-cics-dynamic-transfer': { sev: 'high', evidence: 'path', cwe: 'CWE-470', text: 'Command-line or environment input decides which program CICS transfers to' },
24
26
  'cics-terminal-to-cics-dynamic-transfer': { sev: 'crit', evidence: 'path', cwe: 'CWE-470', text: 'Terminal input decides which program CICS transfers to' },
25
27
  'cics-web-to-cics-dynamic-transfer': { sev: 'crit', evidence: 'path', cwe: 'CWE-470', text: 'Web input decides which program CICS transfers to' },
26
28
  'file-record-to-cics-dynamic-transfer': { sev: 'high', evidence: 'path', cwe: 'CWE-470', text: 'A file record decides which program CICS transfers to' },
29
+ 'cics-queue-to-cics-dynamic-transfer': { sev: 'high', evidence: 'path', measured: false, cwe: 'CWE-470', text: 'A CICS queue item decides which program CICS transfers to' },
27
30
  'database-to-cics-dynamic-transfer': { sev: 'high', evidence: 'path', cwe: 'CWE-470', text: 'A database value decides which program CICS transfers to' },
28
31
  'argv-or-env-to-dynamic-program-load': { sev: 'high', evidence: 'path', cwe: 'CWE-470', text: 'Command-line or environment input decides which program is called' },
29
32
  'cics-terminal-to-dynamic-program-load': { sev: 'crit', evidence: 'path', cwe: 'CWE-470', text: 'Terminal input decides which program is called' },
30
33
  'cics-web-to-dynamic-program-load': { sev: 'crit', evidence: 'path', cwe: 'CWE-470', text: 'Web input decides which program is called' },
31
34
  'file-record-to-dynamic-program-load': { sev: 'high', evidence: 'path', cwe: 'CWE-470', text: 'A file record decides which program is called' },
35
+ 'cics-queue-to-dynamic-program-load': { sev: 'high', evidence: 'path', measured: false, cwe: 'CWE-470', text: 'A CICS queue item decides which program is called' },
32
36
  'database-to-dynamic-program-load': { sev: 'high', evidence: 'path', cwe: 'CWE-470', text: 'A database value decides which program is called' },
33
37
  // A program opening the file its own user named acts with that user's authority, which is every
34
38
  // one read in the 500-repository corpus; it escalates only where the estate says the entry that
@@ -37,6 +41,7 @@ export const RULES = {
37
41
  'cics-terminal-to-dynamic-file-path': { sev: 'high', evidence: 'path', cwe: 'CWE-73', text: 'Terminal input decides which file is opened' },
38
42
  'cics-web-to-dynamic-file-path': { sev: 'high', evidence: 'path', cwe: 'CWE-73', text: 'Web input decides which file is opened' },
39
43
  'file-record-to-dynamic-file-path': { sev: 'med', evidence: 'path', cwe: 'CWE-73', text: 'A file record decides which file is opened' },
44
+ 'cics-queue-to-dynamic-file-path': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-73', text: 'A CICS queue item decides which file is opened' },
40
45
  'database-to-dynamic-file-path': { sev: 'med', evidence: 'path', cwe: 'CWE-73', text: 'A database value decides which file is opened' },
41
46
  // A JCL step is the entry point above the program. PARM is the mainframe's argv, and in-stream
42
47
  // data is a payload sitting in the repository: both are edited by anyone who can commit, so they
@@ -60,6 +65,7 @@ export const RULES = {
60
65
  'cics-terminal-to-internal-reader': { sev: 'crit', evidence: 'path', cwe: 'CWE-77', text: 'Terminal input is written into a job the internal reader submits' },
61
66
  'cics-web-to-internal-reader': { sev: 'crit', evidence: 'path', cwe: 'CWE-77', text: 'Web input is written into a job the internal reader submits' },
62
67
  'file-record-to-internal-reader': { sev: 'med', evidence: 'path', cwe: 'CWE-77', text: 'A file record is written into a job the internal reader submits' },
68
+ 'cics-queue-to-internal-reader': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-77', text: 'A CICS queue item is written into a job the internal reader submits' },
63
69
  'database-to-internal-reader': { sev: 'med', evidence: 'path', cwe: 'CWE-77', text: 'A database value is written into a job the internal reader submits' },
64
70
  'jcl-parm-to-internal-reader': { sev: 'crit', evidence: 'path', cwe: 'CWE-77', text: 'A JCL PARM is written into a job the internal reader submits' },
65
71
  'jcl-instream-to-internal-reader': { sev: 'crit', evidence: 'path', cwe: 'CWE-77', text: 'In-stream job data is written into a job the internal reader submits' },
@@ -68,18 +74,22 @@ export const RULES = {
68
74
  'cics-web-to-web-response': { sev: 'high', evidence: 'path', cwe: 'CWE-79', text: 'Web input is written into the response this program returns' },
69
75
  'cics-terminal-to-web-response': { sev: 'high', evidence: 'path', cwe: 'CWE-79', text: 'Terminal input is written into the response this program returns' },
70
76
  'file-record-to-web-response': { sev: 'med', evidence: 'path', cwe: 'CWE-79', text: 'A file record is written into the response this program returns' },
77
+ 'cics-queue-to-web-response': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-79', text: 'A CICS queue item is written into the response this program returns' },
71
78
  'database-to-web-response': { sev: 'med', evidence: 'path', cwe: 'CWE-79', text: 'A database value is written into the response this program returns' },
72
79
  'cics-web-to-http-header': { sev: 'high', evidence: 'path', cwe: 'CWE-113', text: 'Web input is written into a header of the response' },
73
80
  'cics-terminal-to-http-header': { sev: 'high', evidence: 'path', cwe: 'CWE-113', text: 'Terminal input is written into a header of the response' },
74
81
  'file-record-to-http-header': { sev: 'med', evidence: 'path', cwe: 'CWE-113', text: 'A file record is written into a header of the response' },
82
+ 'cics-queue-to-http-header': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-113', text: 'A CICS queue item is written into a header of the response' },
75
83
  'database-to-http-header': { sev: 'med', evidence: 'path', cwe: 'CWE-113', text: 'A database value is written into a header of the response' },
76
84
  'cics-web-to-outbound-host': { sev: 'high', evidence: 'path', cwe: 'CWE-918', text: 'Web input decides the host or path this program sends a request to' },
77
85
  'cics-terminal-to-outbound-host': { sev: 'high', evidence: 'path', cwe: 'CWE-918', text: 'Terminal input decides the host or path this program sends a request to' },
78
86
  'file-record-to-outbound-host': { sev: 'med', evidence: 'path', cwe: 'CWE-918', text: 'A file record decides the host or path this program sends a request to' },
87
+ 'cics-queue-to-outbound-host': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-918', text: 'A CICS queue item decides the host or path this program sends a request to' },
79
88
  'database-to-outbound-host': { sev: 'med', evidence: 'path', cwe: 'CWE-918', text: 'A database value decides the host or path this program sends a request to' },
80
89
  'cics-web-to-queue-name': { sev: 'high', evidence: 'path', cwe: 'CWE-99', text: 'Web input names the queue a command reads, writes or deletes' },
81
90
  'cics-terminal-to-queue-name': { sev: 'high', evidence: 'path', cwe: 'CWE-99', text: 'Terminal input names the queue a command reads, writes or deletes' },
82
91
  'file-record-to-queue-name': { sev: 'med', evidence: 'path', cwe: 'CWE-99', text: 'A file record names the queue a command reads, writes or deletes' },
92
+ 'cics-queue-to-queue-name': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-99', text: 'A CICS queue item names the queue a command reads, writes or deletes' },
83
93
  'database-to-queue-name': { sev: 'med', evidence: 'path', cwe: 'CWE-99', text: 'A database value names the queue a command reads, writes or deletes' },
84
94
  // Where a statement reads or writes, decided by input from outside. Without SSRANGE, which is not
85
95
  // the Enterprise COBOL default, an index out of range reads or writes the storage beside the
@@ -133,10 +143,12 @@ export const RULES = {
133
143
  'cics-terminal-to-cics-sysid': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'Terminal input decides which region a CICS command is shipped to' },
134
144
  'cics-web-to-cics-sysid': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'Web input decides which region a CICS command is shipped to' },
135
145
  'file-record-to-cics-sysid': { sev: 'low', evidence: 'path', cwe: 'CWE-15', text: 'A file record decides which region a CICS command is shipped to' },
146
+ 'cics-queue-to-cics-sysid': { sev: 'low', evidence: 'path', measured: false, cwe: 'CWE-15', text: 'A CICS queue item decides which region a CICS command is shipped to' },
136
147
  'database-to-cics-sysid': { sev: 'low', evidence: 'path', cwe: 'CWE-15', text: 'A database value decides which region a CICS command is shipped to' },
137
148
  'cics-terminal-to-cics-system-resource': { sev: 'high', evidence: 'path', cwe: 'CWE-15', text: 'Terminal input names the resource an EXEC CICS SET changes' },
138
149
  'cics-web-to-cics-system-resource': { sev: 'high', evidence: 'path', cwe: 'CWE-15', text: 'Web input names the resource an EXEC CICS SET changes' },
139
150
  'file-record-to-cics-system-resource': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'A file record names the resource an EXEC CICS SET changes' },
151
+ 'cics-queue-to-cics-system-resource': { sev: 'med', evidence: 'path', measured: false, cwe: 'CWE-15', text: 'A CICS queue item names the resource an EXEC CICS SET changes' },
140
152
  'database-to-cics-system-resource': { sev: 'med', evidence: 'path', cwe: 'CWE-15', text: 'A database value names the resource an EXEC CICS SET changes' },
141
153
  // A document from outside parsed as XML. Whether a DTD in it is honoured - an external entity
142
154
  // fetched, an internal one expanded - is the parser's, chosen by the XMLPARSE compiler option, and
@@ -168,12 +180,16 @@ export const RULES = {
168
180
  // way mainframe systems hand data to each other and is listed so the route is visible.
169
181
  'database-to-outbound-http': { sev: 'high', evidence: 'path', cwe: 'CWE-201', text: 'A database value is sent to a remote server over HTTP' },
170
182
  'file-record-to-outbound-http': { sev: 'high', evidence: 'path', cwe: 'CWE-201', text: 'A file record is sent to a remote server over HTTP' },
183
+ 'cics-queue-to-outbound-http': { sev: 'high', evidence: 'path', measured: false, cwe: 'CWE-201', text: 'A CICS queue item is sent to a remote server over HTTP' },
171
184
  'database-to-socket-send': { sev: 'high', evidence: 'path', cwe: 'CWE-201', text: 'A database value is written to a network socket' },
172
185
  'file-record-to-socket-send': { sev: 'high', evidence: 'path', cwe: 'CWE-201', text: 'A file record is written to a network socket' },
186
+ 'cics-queue-to-socket-send': { sev: 'high', evidence: 'path', measured: false, cwe: 'CWE-201', text: 'A CICS queue item is written to a network socket' },
173
187
  'database-to-message-queue': { sev: 'low', evidence: 'path', cwe: 'CWE-201', text: 'A database value is put on a message queue' },
174
188
  'database-to-extrapartition-queue': { sev: 'low', evidence: 'path', cwe: 'CWE-201', text: 'A database value is written to a transient-data queue that leaves the region' },
175
189
  'file-record-to-extrapartition-queue': { sev: 'low', evidence: 'path', cwe: 'CWE-201', text: 'A file record is written to a transient-data queue that leaves the region' },
190
+ 'cics-queue-to-extrapartition-queue': { sev: 'low', evidence: 'path', measured: false, cwe: 'CWE-201', text: 'A CICS queue item is written to a transient-data queue that leaves the region' },
176
191
  'file-record-to-message-queue': { sev: 'low', evidence: 'path', cwe: 'CWE-201', text: 'A file record is put on a message queue' },
192
+ 'cics-queue-to-message-queue': { sev: 'low', evidence: 'path', measured: false, cwe: 'CWE-201', text: 'A CICS queue item is put on a message queue' },
177
193
  // The CALL interface itself, from the same bound pairs the paths above cross. No input has to
178
194
  // reach these: a callee that declares more than it is passed reaches past it on every call.
179
195
  'call-parameter-exceeds-caller-record': { sev: 'high', evidence: 'construct', cwe: 'CWE-805', text: 'A called program declares a parameter longer than the whole record its caller passes' },
@@ -193,6 +209,7 @@ export const WHO = {
193
209
  'cics-terminal': 'A terminal user',
194
210
  'cics-web': 'A web caller',
195
211
  'file-record': 'Whoever can write the file record',
212
+ 'cics-queue': 'Whoever can write the queue, or start the transaction with data',
196
213
  'database': 'Whoever can write the database row',
197
214
  'jcl-parm': "Whoever can edit the job's PARM",
198
215
  'jcl-instream': "Whoever can edit the job's in-stream data",
@@ -219,7 +236,7 @@ const SINK = {
219
236
  },
220
237
  'dynamic-file-path': {
221
238
  clause: 'chooses which dataset the program opens',
222
- remedy: 'Map input to a fixed set of DDs or dataset names; do not build the ASSIGN or dynamic-allocation name from input',
239
+ remedy: 'Map input to a fixed set of DDs, dataset names or CICS file names; do not build the ASSIGN, dynamic-allocation or FILE name from input',
223
240
  },
224
241
  'internal-reader': {
225
242
  clause: "submits an arbitrary job, for a CICS program under the region's authority",
@@ -4,7 +4,7 @@ import { cobolCard } from '../cards.mjs';
4
4
  import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
5
5
  import { report } from '../kernel/ruleset.mjs';
6
6
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
7
- import { eachWithinMemory } from '../kernel/memory.mjs';
7
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
8
8
 
9
9
  // Content a compiler ignores and an editor often hides, but an agent reading the source will see:
10
10
  // the sequence area, the identification area past column 72, comment lines, and characters that
@@ -154,14 +154,14 @@ const looksBinary = (buf) => {
154
154
  return odd / n > 0.05;
155
155
  };
156
156
 
157
- export function scanHidden(root, opts = {}) {
157
+ export function* scanHiddenSteps(root, opts = {}) {
158
158
  const tree = treeFor(root, opts);
159
159
  const files = tree.list().filter(isSource).filter(inScope(opts));
160
160
  const findings = [];
161
161
  const stats = { filesScanned: 0, filesUnreadable: 0, filesBinary: 0, filesEbcdic: 0 };
162
162
  // This set reads every source file in the tree as raw bytes, which is the widest reading of the
163
163
  // nine, so it is the one most worth walking inside the guard.
164
- const run = eachWithinMemory(files, (f) => {
164
+ const run = yield loopOver(files, (f) => {
165
165
  let buf;
166
166
  try { buf = tree.bytes(f); } catch (e) { noteUnread(stats, tree, f, e); return 0; }
167
167
  // An EBCDIC member is decoded before any rule reads it; read as bytes it looks binary, and a
@@ -210,3 +210,5 @@ export function scanHidden(root, opts = {}) {
210
210
 
211
211
  return report('hidden', { rules: HIDDEN_RULES, findings, stats, run });
212
212
  }
213
+
214
+ export const scanHidden = (root, opts = {}) => drive(scanHiddenSteps(root, opts));
@@ -7,18 +7,20 @@
7
7
  // It also answers the question the COBOL side cannot: a CALL 'SUBRTN' with no COBOL program of that
8
8
  // name may be an assembler module, and a CSECT or ENTRY of that name here is where it is.
9
9
  import { basename, extname } from 'node:path';
10
- import { inScope, isAssembler, isProgram, relPath, HLASM_EXT, COPY_EXT } from '../sources.mjs';
10
+ import { inScope, isAssembler, isJcl, isProgram, relPath, HLASM_EXT, COPY_EXT } from '../sources.mjs';
11
+ import { parseJcl } from '../jcl.mjs';
12
+ import { optableIn, processOptions } from '../hlasm/optable.mjs';
11
13
  import { report } from '../kernel/ruleset.mjs';
12
14
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
13
15
  import { eachWithinMemory } from '../kernel/memory.mjs';
14
- import { readHlasm, OPERATIONS } from '../hlasm.mjs';
16
+ import { readHlasm, OPERATIONS, isDocument } from '../hlasm.mjs';
15
17
  import { readHlasmStatements, parseHlasmStatement, NAMED_MACROS } from '../hlasm/read.mjs';
16
18
  import { macroOperands, splitOperands } from '../hlasm/operands.mjs';
17
19
 
18
20
  export const HLASM_RULES = {
19
21
  'hlasm-supervisor-state-change': {
20
22
  sev: 'crit', evidence: 'construct', cwe: 'CWE-250',
21
- text: 'Assembler source switches to PSW key zero or supervisor state',
23
+ text: 'Assembler source switches to PSW key zero, another system key, or supervisor state',
22
24
  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
25
  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
26
  },
@@ -85,23 +87,77 @@ function cobolCalls(source) {
85
87
  return calls;
86
88
  }
87
89
 
90
+ // The repository's own definitions of macros the reader knows by name (IF, LINK, SAVE, ...): a
91
+ // member so named whose first statement is MACRO. Only those names are read, so the cost is a few
92
+ // files at most.
93
+ function repositoryMacros(tree, files) {
94
+ const found = new Set();
95
+ for (const f of files) {
96
+ const name = basename(f, extname(f)).toUpperCase();
97
+ if (!NAMED_MACROS.has(name) || found.has(name)) continue;
98
+ let text;
99
+ try { text = tree.text(f).text; } catch { continue; }
100
+ const first = readHlasmStatements(text).statements.find((s) => s.kind === 'statement');
101
+ if (first?.operation === 'MACRO') found.add(name);
102
+ }
103
+ return found;
104
+ }
105
+
106
+ // The OPTABLE the estate's assembly steps give in PARM (ASMA90, the old IEV90, or a procedure such as
107
+ // ASMACL), with where each was written. One level is the estate's; several leave each file to its own.
108
+ function jclOptables(root, tree, files) {
109
+ const levels = new Map();
110
+ for (const f of files) {
111
+ let job;
112
+ try { job = parseJcl(tree.text(f).text, f); } catch { continue; }
113
+ for (const step of job.steps) {
114
+ if (!/^(?:ASMA90|IEV90)$/i.test(step.pgm || '') && !/^(?:ASMA|HLASM|IEV)/i.test(step.proc || '')) continue;
115
+ for (const [k, v] of step.keywords) {
116
+ const found = /^PARM(?:\.|$)/.test(k) ? optableIn(v) : null;
117
+ if (found && !levels.has(found.level)) levels.set(found.level, `${relPath(root, f)}:${step.line}`);
118
+ }
119
+ }
120
+ }
121
+ return levels;
122
+ }
123
+
88
124
  export function scanHlasm(root, opts = {}) {
89
125
  const tree = treeFor(root, opts);
90
126
  const all = tree.list().filter(inScope(opts));
91
127
  const findings = [];
92
128
  const stats = { filesScanned: 0, filesUnreadable: 0, assemblerFiles: 0, bmsFiles: 0, imsFiles: 0, unrecognisedFiles: 0, readNotAssembled: true };
93
129
  const members = new Set(all.filter((f) => [...HLASM_EXT, ...COPY_EXT].some((e) => f.toLowerCase().endsWith(e))).map((f) => basename(f, extname(f)).toUpperCase()));
130
+ const libraryMacros = repositoryMacros(tree, all.filter(isAssembler));
131
+ const forms = { mvs38: opts.mvs38Forms !== false };
132
+ const fromJcl = opts.hlasmOptable ? new Map() : jclOptables(root, tree, all.filter(isJcl));
133
+ const estateOptable = opts.hlasmOptable ?? (fromJcl.size === 1 ? [...fromJcl.keys()][0] : null);
134
+ if (opts.hlasmOptable) stats.hlasmOptable = { level: opts.hlasmOptable, from: 'option' };
135
+ else if (fromJcl.size === 1) stats.hlasmOptable = { level: estateOptable, from: [...fromJcl.values()][0] };
136
+ else if (fromJcl.size > 1) stats.hlasmOptableConflict = Object.fromEntries(fromJcl);
137
+ // The table a file is assembled with: the option given to the scan, then a *PROCESS OVERRIDE in
138
+ // the file, the estate's PARM, and the file's own *PROCESS, as HLASM ranks them.
139
+ const optableOf = (src) => {
140
+ if (opts.hlasmOptable) return opts.hlasmOptable;
141
+ const own = optableIn(processOptions(src));
142
+ return own?.override ? own.level : estateOptable ?? own?.level ?? null;
143
+ };
94
144
  const refused = {};
95
145
  const macroCalls = new Map();
96
146
  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.
147
+ // Reads a file's statements: its labels, what the reader refused, and the macros and COPY members it
148
+ // uses, which count toward the coverage report unless the file is a document.
99
149
  function readStatements(src, path) {
100
150
  const labels = new Map();
101
- for (const st of readHlasmStatements(src).statements) {
102
- if (st.inMacro) continue;
151
+ const read = readHlasmStatements(src, { libraryMacros }).statements;
152
+ const statements = read.filter((st) => !st.inMacro);
153
+ const fileForms = { ...forms, optable: optableOf(src) };
154
+ const results = statements.map((st) => parseHlasmStatement(st, fileForms));
155
+ const refusals = results.filter((res) => res.status === 'unparsed' || res.status === 'unknown').length;
156
+ if (isDocument(read.filter((st) => st.kind === 'statement'), statements.length, refusals)) return null;
157
+ statements.forEach((st, i) => {
158
+ const res = results[i];
103
159
  if (st.kind === 'statement' && st.name && !st.name.startsWith('&') && !st.name.startsWith('.')) labels.set(st.name.toUpperCase(), st);
104
- const res = parseHlasmStatement(st);
160
+ if (res.node?.mvs38) stats.mvs38Forms = (stats.mvs38Forms || 0) + 1;
105
161
  if (res.status === 'unparsed' || res.status === 'unknown') (refused[res.kind] ||= { count: 0, first: `${path}:${st.line}` }).count++;
106
162
  const name = res.kind === 'MACRO CALL' ? st.operation : NAMED_MACROS.has(res.kind) ? res.kind : null;
107
163
  if (name) {
@@ -113,7 +169,7 @@ export function scanHlasm(root, opts = {}) {
113
169
  const member = String(res.status === 'parsed' ? res.node.member : (/^\s*([A-Z$#@][A-Z0-9$#@_]*)/i.exec(st.field || '') || [])[1] || '').toUpperCase();
114
170
  if (member && !members.has(member)) copyNotFound.add(member);
115
171
  }
116
- }
172
+ });
117
173
  return labels;
118
174
  }
119
175
  const defines = new Map();
@@ -122,14 +178,16 @@ export function scanHlasm(root, opts = {}) {
122
178
  let src;
123
179
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
124
180
  stats.filesScanned++;
125
- const r = readHlasm(src);
181
+ const r = readHlasm(src, forms);
126
182
  if (r.kind === 'bms') { stats.bmsFiles++; return src.length; }
127
183
  if (r.kind === 'ims') { stats.imsFiles++; return src.length; }
128
184
  if (r.kind === 'unrecognised') { stats.unrecognisedFiles++; return src.length; }
129
- stats.assemblerFiles++;
185
+ if (r.kind === 'listing') { stats.listingFiles = (stats.listingFiles || 0) + 1; return src.length; }
130
186
  const path = relPath(root, f);
131
- for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
132
187
  const labels = readStatements(src, path);
188
+ if (!labels) { stats.documentFiles = (stats.documentFiles || 0) + 1; return src.length; }
189
+ stats.assemblerFiles++;
190
+ for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
133
191
  for (const use of r.operations) {
134
192
  if (use.class === 'module-load') {
135
193
  const { keywords } = macroOperands(use.field || '');
@@ -139,6 +197,8 @@ export function scanHlasm(root, opts = {}) {
139
197
  }
140
198
  if (use.name === 'MODESET') {
141
199
  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)}` });
200
+ // A system key writes storage problem-state keys cannot, short of key zero's reach.
201
+ else if (use.systemKey) findings.push({ rule: 'hlasm-supervisor-state-change', sev: 'high', path, line: use.line, detail: `MODESET ${use.systemKey.written} switches to PSW key ${use.systemKey.key}, a system key.${where(use)}` });
142
202
  continue;
143
203
  }
144
204
  const rule = RULE_OF_CLASS[use.class];
@@ -0,0 +1,139 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // IMS database and program definitions: what a PSB lets a program change, SENSEGs that name segments
3
+ // their DBD does not define, and what DBDGEN, PSBGEN or ACBGEN refuses (lib/ims/rules.mjs). DBDs and
4
+ // PSBs are assembler macros and are often named .asm, so assembler files are read too, and kept only
5
+ // where they hold a DBD or PSB.
6
+ import { inScope, isAssembler, isIms, isJcl, isProgram, relPath } from '../sources.mjs';
7
+ import { report } from '../kernel/ruleset.mjs';
8
+ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
+ import { eachWithinMemory } from '../kernel/memory.mjs';
10
+ import { modelOf } from '../ims/model.mjs';
11
+ import { IMS_RULES, checkModels, checkProcoptAgainstCalls } from '../ims/rules.mjs';
12
+ import { DLI_ROUTINE, DLI_FUNCTIONS, DLI_SERVICES, psbOfParm } from '../ims/dli.mjs';
13
+ import { parseJcl } from '../jcl.mjs';
14
+ import { constantsReaching } from '../constants.mjs';
15
+ import { execReading } from '../exec-reading.mjs';
16
+
17
+ export { IMS_RULES };
18
+
19
+ export function scanIms(root, opts = {}) {
20
+ const tree = treeFor(root, opts);
21
+ const files = tree.list().filter(inScope(opts)).filter((f) => isIms(f) || isAssembler(f));
22
+ const stats = { filesScanned: 0, filesUnreadable: 0, dbdFiles: 0, psbFiles: 0 };
23
+ const models = [];
24
+ const unread = {};
25
+ const run = eachWithinMemory(files, (f) => {
26
+ let src;
27
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
28
+ const model = modelOf(src);
29
+ if (!model) return src.length;
30
+ stats.filesScanned++;
31
+ stats[model.kind === 'DBD' ? 'dbdFiles' : 'psbFiles']++;
32
+ const path = relPath(root, f);
33
+ for (const u of model.unread) (unread[u.operation || 'UNREADABLE'] ||= { count: 0, first: `${path}:${u.line}` }).count++;
34
+ models.push({ path, model });
35
+ return src.length;
36
+ }, { label: 'ims', maxBytes: opts.maxSourceBytes ?? Infinity });
37
+
38
+ // A statement the reader refused may hold a segment, a field or a PCB the rules never saw.
39
+ if (Object.keys(unread).length) { stats.statementsNotRead = unread; stats.coverageIncomplete = true; }
40
+
41
+ // Programs are read only when a PSB could be named for one, and only those that issue DL/I.
42
+ const programs = [];
43
+ const links = [];
44
+ let calls = null;
45
+ if (models.some(({ model }) => model.kind === 'PSB')) {
46
+ const sources = tree.list().filter(inScope(opts)).filter((f) => isProgram(f) || isJcl(f));
47
+ calls = eachWithinMemory(sources, (f) => {
48
+ let src;
49
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
50
+ const path = relPath(root, f);
51
+ if (isJcl(f)) {
52
+ if (!/DFSRRC00/i.test(src)) return src.length;
53
+ let jcl;
54
+ try { jcl = parseJcl(src); } catch { return src.length; }
55
+ for (const step of jcl.steps || []) {
56
+ const l = String(step.pgm || '').toUpperCase() === 'DFSRRC00' ? psbOfParm(step.parm) : null;
57
+ if (l) links.push({ ...l, at: `${path}:${step.line}` });
58
+ }
59
+ return src.length;
60
+ }
61
+ if (!/CBLTDLI|AIBTDLI|EXEC\s+DLI/i.test(src)) return src.length;
62
+ let r;
63
+ try { r = tree.parse(f, src); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
64
+ for (const prog of r.programs) {
65
+ const read = dliCallsOf(prog);
66
+ const id = String(prog.id).toUpperCase();
67
+ programs.push({ id, path, line: prog.line || 1, functions: read.functions });
68
+ for (const psb of read.schedules) links.push({ program: id, psb, at: `${path}, its own scheduling call` });
69
+ }
70
+ return src.length;
71
+ }, { label: 'ims programs', maxBytes: opts.maxSourceBytes ?? Infinity });
72
+ stats.programsWithDli = programs.length;
73
+ }
74
+ const findings = [...checkModels(models), ...checkProcoptAgainstCalls(models, programs, links)];
75
+ return report('ims', { rules: IMS_RULES, findings, stats, run: calls ? bothRuns(run, calls) : run });
76
+ }
77
+
78
+ // The DL/I database functions a program issues itself, null when they cannot all be told: a function
79
+ // code no constant decides, a CALL that passes on a PCB or a LINKAGE item, which may hold one, or a
80
+ // CICS LINK or XCTL from a program that schedules a PSB, whose target can issue DL/I against it. And
81
+ // the PSBs it schedules itself, by EXEC DLI SCHD or a PCB call, where a constant names one.
82
+ function dliCallsOf(prog) {
83
+ let functions = new Set();
84
+ const schedules = new Set();
85
+ const ignore = { ignoreCall: (x) => DLI_ROUTINE.test(String(x.name)) };
86
+ const valuesOf = (a) => {
87
+ if (!a) return { values: [], open: true };
88
+ if (a.lit != null) return { values: [String(a.lit)], open: false };
89
+ return a.word ? constantsReaching(prog, a.word, ignore) : { values: [], open: true };
90
+ };
91
+ const named = (k) => (k.open ? null : k.values.map((v) => String(v).trim().toUpperCase()));
92
+ const known = (v) => Object.hasOwn(DLI_FUNCTIONS, v) || DLI_SERVICES.has(v);
93
+ const byName = new Map((prog.items || []).map((it) => [it.name, it]));
94
+ const topOf = (it) => { while (it && it.parent) it = it.parent; return it; };
95
+ // The function code is the first of the two arguments whose constants name one; the PCB or AIB follows it.
96
+ const functionAt = (using) => [0, 1].find((i) => valuesOf(using[i]).values.some((v) => known(String(v).trim().toUpperCase())));
97
+ const pcbs = new Set();
98
+ for (const c of prog.calls || []) {
99
+ if (c.kind !== 'L' || !DLI_ROUTINE.test(String(c.name))) continue;
100
+ const at = functionAt(c.using || []);
101
+ const pcb = at === undefined ? null : (c.using || [])[at + 1];
102
+ if (pcb && pcb.word) pcbs.add(pcb.word);
103
+ }
104
+ const passesOn = (a) => a.word && (pcbs.has(a.word) || (topOf(byName.get(a.word)) || {}).section === 'LINKAGE');
105
+ for (const c of prog.calls || []) {
106
+ if (c.kind !== 'L') { functions = null; continue; }
107
+ if (!DLI_ROUTINE.test(String(c.name))) {
108
+ if ((c.using || []).some(passesOn)) functions = null;
109
+ continue;
110
+ }
111
+ const using = c.using || [];
112
+ const at = functionAt(using);
113
+ const codes = at === undefined ? null : named(valuesOf(using[at]));
114
+ if (!codes || !codes.every(known)) { functions = null; continue; }
115
+ if (codes.includes('PCB')) for (const n of named(valuesOf(using[at + 1])) || []) schedules.add(n);
116
+ if (functions) for (const fn of codes) if (Object.hasOwn(DLI_FUNCTIONS, fn)) functions.add(fn);
117
+ }
118
+ for (const e of prog.execs || []) {
119
+ if (e.kind !== 'DLI') continue;
120
+ const { verb, opts } = execReading(e);
121
+ if (verb === 'SCHD') {
122
+ const tok = (opts.get('PSB') || []).find((t) => t.t === 'word' || t.t === 'lit');
123
+ const k = !tok ? { values: [], open: true } : tok.t === 'lit' ? { values: [tok.v], open: false } : constantsReaching(prog, tok.v, ignore);
124
+ for (const n of named(k) || []) schedules.add(n);
125
+ } else if (functions && Object.hasOwn(DLI_FUNCTIONS, verb)) functions.add(verb);
126
+ }
127
+ const transfers = (prog.execs || []).some((e) => e.kind === 'CICS' && ['LINK', 'XCTL'].includes(execReading(e).verb));
128
+ if (schedules.size && transfers) functions = null;
129
+ return { functions: functions && functions.size ? functions : null, schedules: [...schedules] };
130
+ }
131
+
132
+ function bothRuns(a, b) {
133
+ return {
134
+ skipped: [...a.skipped, ...b.skipped],
135
+ stoppedBy: a.stoppedBy || b.stoppedBy,
136
+ peakHeapBytes: Math.max(a.peakHeapBytes || 0, b.peakHeapBytes || 0) || undefined,
137
+ note: [a.note, b.note].filter(Boolean).join(' ') || undefined,
138
+ };
139
+ }
package/lib/sets/log.mjs CHANGED
@@ -24,6 +24,7 @@ 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
26
  import { drive, loopOver } from '../kernel/shared-pass.mjs';
27
+ import { execReading } from '../exec-reading.mjs';
27
28
 
28
29
 
29
30
  export const LOG_RULES = {
@@ -107,17 +108,16 @@ function logSinks(p, where, path) {
107
108
  }
108
109
  for (const e of p.execs || []) {
109
110
  if (e.kind !== 'CICS') continue;
110
- const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
111
- const isTd = w[0] === 'WRITEQ' && w[1] === 'TD';
112
- const isJournal = w[0] === 'WRITE' && w.includes('JOURNALNAME');
111
+ const { verb, sub, words, opts } = execReading(e);
112
+ const isTd = verb === 'WRITEQ' && sub === 'TD';
113
+ const isJournal = verb === 'WRITE' && words.includes('JOURNALNAME');
113
114
  // The console, read by whoever is watching the region. A credential on it is as exposed as one
114
115
  // in SYSOUT, and it outlives the screen in the system log.
115
- const isOperator = w[0] === 'WRITE' && w[1] === 'OPERATOR';
116
+ const isOperator = verb === 'WRITE' && sub === 'OPERATOR';
116
117
  if (!isTd && !isJournal && !isOperator) continue;
117
118
  // FROM names the area written, TEXT names the console message; that name is what this reads.
118
119
  const opt = isOperator ? 'TEXT' : 'FROM';
119
- const i = e.toks.findIndex((t) => t.t === 'word' && t.u === opt);
120
- const arg = i >= 0 ? e.toks.slice(i + 1).find((t) => t.t === 'word') : null;
120
+ const arg = (opts.get(opt) || []).find((t) => t.t === 'word') || null;
121
121
  const kind = isTd ? 'WRITEQ TD' : isOperator ? 'WRITE OPERATOR' : 'WRITE JOURNALNAME';
122
122
  if (arg) out.push({ kind, name: arg.u, ...where(e, path) });
123
123
  }
@@ -74,21 +74,41 @@ export function scanOpaque(root, opts = {}) {
74
74
  const path = relPath(root, f);
75
75
 
76
76
  // Which names were declared as pointers, so a CALL through one can be told from an ordinary
77
- // CALL of a variable - which the flow rules already report as a dynamic program load.
78
- const pointers = new Set();
79
- for (const m of text.matchAll(/\b(?:\d\d\s+)?([A-Z0-9-]+)\b[^.\n]{0,80}\bUSAGE\s+(?:IS\s+)?(?:PROCEDURE-POINTER|POINTER)\b/gi)) {
80
- pointers.add(m[1].toUpperCase());
81
- }
77
+ // CALL of a variable - which the flow rules already report as a dynamic program load. Found
78
+ // only once a CALL needs it, since the search tries every word of the program.
79
+ let pointers = null;
80
+ const pointerNames = () => {
81
+ if (pointers) return pointers;
82
+ pointers = new Set();
83
+ for (const m of text.matchAll(/\b(?:\d\d\s+)?([A-Z0-9-]+)\b[^.\n]{0,80}\bUSAGE\s+(?:IS\s+)?(?:PROCEDURE-POINTER|POINTER)\b/gi)) {
84
+ pointers.add(m[1].toUpperCase());
85
+ }
86
+ return pointers;
87
+ };
88
+
89
+ // How many newlines of the joined text come before an offset, which is the normalised entry
90
+ // holding it.
91
+ const starts = [0];
92
+ for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
93
+ const entryAt = (offset) => {
94
+ let lo = 0;
95
+ let hi = starts.length - 1;
96
+ while (lo < hi) {
97
+ const mid = (lo + hi + 1) >> 1;
98
+ if (starts[mid] <= offset) lo = mid; else hi = mid - 1;
99
+ }
100
+ return lo;
101
+ };
82
102
 
83
103
  let hit = false;
84
104
  for (const p of PATTERNS) {
85
105
  p.re.lastIndex = 0;
86
106
  let m;
87
107
  while ((m = p.re.exec(text)) !== null) {
88
- if (p.needsPointerOperand && !pointers.has(String(m[1]).toUpperCase())) continue;
108
+ if (p.needsPointerOperand && !pointerNames().has(String(m[1]).toUpperCase())) continue;
89
109
  // The line number is the normalised entry's own, so a construct that came from a copybook
90
110
  // is reported where the compiler would see it.
91
- const before = text.slice(0, m.index).split('\n').length - 1;
111
+ const before = entryAt(m.index);
92
112
  const line = norm.entries[before] ? norm.entries[before].line : 1;
93
113
  findings.push({ rule: p.rule, path, line, detail: p.detail(m) });
94
114
  hit = true;
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // PL/I programs read statement by statement (lib/pli): where they address storage through a pointer,
3
+ // call through an entry variable or use the preprocessor, which a static reading cannot follow, and
4
+ // ON units that swallow their condition. Data flow through PL/I is the flow set's; this set reports
5
+ // what the program text itself shows, and counts every statement the reader could not parse.
6
+ import { inScope, isPli, relPath } from '../sources.mjs';
7
+ import { report } from '../kernel/ruleset.mjs';
8
+ import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
9
+ import { eachWithinMemory } from '../kernel/memory.mjs';
10
+ import { readPli } from '../pli/lex.mjs';
11
+ import { parseStatement } from '../pli/statements.mjs';
12
+ import { PLI_RULES, checkProgram } from '../pli/rules/index.mjs';
13
+
14
+ export { PLI_RULES };
15
+
16
+ export function scanPli(root, opts = {}) {
17
+ const tree = treeFor(root, opts);
18
+ const files = tree.list().filter(inScope(opts)).filter(isPli);
19
+ const stats = { filesScanned: 0, filesUnreadable: 0, statements: 0, statementsParsed: 0 };
20
+ const notParsed = {};
21
+ const findings = [];
22
+ const run = eachWithinMemory(files, (f) => {
23
+ let src;
24
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
25
+ const path = relPath(root, f);
26
+ const statements = readPli(src, { file: path }).statements.map((st) => ({ ...st, parsed: parseStatement(st) }));
27
+ stats.filesScanned++;
28
+ for (const st of statements) {
29
+ stats.statements++;
30
+ if (st.parsed.status === 'parsed') { stats.statementsParsed++; continue; }
31
+ (notParsed[st.parsed.kind || 'UNKNOWN'] ||= { count: 0, first: `${path}:${st.line}` }).count++;
32
+ }
33
+ findings.push(...checkProgram({ path, statements }));
34
+ return src.length;
35
+ }, { label: 'pli', maxBytes: opts.maxSourceBytes ?? Infinity });
36
+
37
+ // A statement the reader could not parse may hold a pointer, an entry call or an ON unit.
38
+ if (Object.keys(notParsed).length) { stats.statementsNotParsed = notParsed; stats.coverageIncomplete = true; }
39
+ return report('pli', { rules: PLI_RULES, findings, stats, run });
40
+ }
@@ -14,7 +14,7 @@
14
14
  import { inScope, isJcl, isProgram, isCopybook, relPath } from '../sources.mjs';
15
15
  import { report } from '../kernel/ruleset.mjs';
16
16
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
17
- import { eachWithinMemory } from '../kernel/memory.mjs';
17
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
18
18
  import { loadSite, classifyPath, productionQualifierOf, SITE_FILE } from '../site.mjs';
19
19
  import { parseJcl, dispositionOf } from '../jcl.mjs';
20
20
  import { statementCard } from '../cards.mjs';
@@ -161,7 +161,7 @@ function productionTouches(job, qualifiers) {
161
161
  return { found, claimed };
162
162
  }
163
163
 
164
- export function scanRecon(root, opts = {}) {
164
+ export function* scanReconSteps(root, opts = {}) {
165
165
  const site = loadSite(root, opts.site || null, opts.tree);
166
166
  const tree = treeFor(root, opts);
167
167
  const files = tree.list().filter(inScope(opts))
@@ -192,7 +192,7 @@ export function scanRecon(root, opts = {}) {
192
192
 
193
193
  // This set reads every program, copybook and job in the tree, which is the widest reading any
194
194
  // set does, so it is walked inside the guard like the rest.
195
- const run = eachWithinMemory(files, (f) => {
195
+ const run = yield loopOver(files, (f) => {
196
196
  let src;
197
197
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
198
198
  stats.filesScanned++;
@@ -267,3 +267,5 @@ export function scanRecon(root, opts = {}) {
267
267
  // production means, as against files going unread. Both travel, and they are not merged.
268
268
  return report('recon', { rules: RECON_RULES, findings, stats, run });
269
269
  }
270
+
271
+ export const scanRecon = (root, opts = {}) => drive(scanReconSteps(root, opts));