@portll/cobolwork 0.0.1 → 0.2.75

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSING.md +93 -0
  3. package/NOTICE +9 -0
  4. package/README.md +323 -3
  5. package/THIRD-PARTY-NOTICES.md +118 -0
  6. package/bin/cobolwork.mjs +354 -0
  7. package/lib/advisories.mjs +133 -0
  8. package/lib/baseline.mjs +154 -0
  9. package/lib/bms.mjs +453 -0
  10. package/lib/build.mjs +402 -0
  11. package/lib/capabilities.mjs +79 -0
  12. package/lib/cics-commands.mjs +281 -0
  13. package/lib/compliance.mjs +81 -0
  14. package/lib/consequence.mjs +139 -0
  15. package/lib/control.mjs +1515 -0
  16. package/lib/csd.mjs +77 -0
  17. package/lib/dataflow.mjs +1506 -0
  18. package/lib/diff.mjs +344 -0
  19. package/lib/explain.mjs +145 -0
  20. package/lib/gate.mjs +383 -0
  21. package/lib/index.mjs +6 -0
  22. package/lib/inventory.mjs +79 -0
  23. package/lib/jcl.mjs +478 -0
  24. package/lib/kernel/findings.mjs +94 -0
  25. package/lib/kernel/identity.mjs +216 -0
  26. package/lib/kernel/memory.mjs +217 -0
  27. package/lib/kernel/printable.mjs +6 -0
  28. package/lib/kernel/registry.mjs +79 -0
  29. package/lib/kernel/ruleset.mjs +72 -0
  30. package/lib/kernel/source-tree.mjs +159 -0
  31. package/lib/kev.mjs +27 -0
  32. package/lib/options.mjs +512 -0
  33. package/lib/packs.mjs +148 -0
  34. package/lib/parser.mjs +2055 -0
  35. package/lib/policy.mjs +163 -0
  36. package/lib/precompile-cics.mjs +169 -0
  37. package/lib/precompile.mjs +544 -0
  38. package/lib/reach.mjs +122 -0
  39. package/lib/revision.json +1 -0
  40. package/lib/revision.mjs +89 -0
  41. package/lib/sarif.mjs +222 -0
  42. package/lib/scan.mjs +272 -0
  43. package/lib/sets/build.mjs +234 -0
  44. package/lib/sets/cics.mjs +306 -0
  45. package/lib/sets/compile.mjs +187 -0
  46. package/lib/sets/copybook.mjs +174 -0
  47. package/lib/sets/flow.mjs +487 -0
  48. package/lib/sets/hidden.mjs +216 -0
  49. package/lib/sets/jcl.mjs +440 -0
  50. package/lib/sets/log.mjs +406 -0
  51. package/lib/sets/opaque.mjs +102 -0
  52. package/lib/sets/priv.mjs +322 -0
  53. package/lib/sets/recon.mjs +267 -0
  54. package/lib/sets/vendor.mjs +117 -0
  55. package/lib/sets/web.mjs +327 -0
  56. package/lib/site.mjs +164 -0
  57. package/lib/sources.mjs +156 -0
  58. package/lib/tui/app.mjs +325 -0
  59. package/lib/tui/keys.mjs +39 -0
  60. package/lib/tui/model.mjs +96 -0
  61. package/lib/tui/run.mjs +38 -0
  62. package/lib/tui/screen.mjs +59 -0
  63. package/lib/tui/terminal.mjs +46 -0
  64. package/lib/utilities.mjs +296 -0
  65. package/lib/version.mjs +15 -0
  66. package/lib/words.mjs +318 -0
  67. package/package.json +45 -6
  68. package/rules/advisories.json +264 -0
  69. package/rules/compliance-dora.json +2151 -0
  70. package/rules/compliance-ffiec.json +2134 -0
  71. package/rules/compliance-nist80053.json +2134 -0
  72. package/rules/gitleaks-mainframe.toml +57 -0
  73. package/rules/kev-ids.json +1729 -0
  74. package/rules/packs/broadcom.json +124 -0
  75. package/rules/packs/connectdirect.json +116 -0
  76. package/rules/packs/controlm.json +114 -0
  77. package/rules/system-layouts.json +28 -0
  78. package/schema/cobolwork-coverage.schema.json +65 -0
  79. package/schema/cobolwork.policy.schema.json +54 -0
@@ -0,0 +1,234 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // What the build pins, and whether anyone has published an advisory against it.
3
+ //
4
+ // A crafted source file in a pull request targets the compiler that CI runs on it. Every GnuCOBOL
5
+ // advisory on record is exactly that shape - "via crafted COBOL source code" - so the version a
6
+ // repository's build pins is part of its attack surface, and it is written down in files no COBOL
7
+ // rule set reads.
8
+ import { inScope, relPath } from '../sources.mjs';
9
+ import { byText } from '../kernel/findings.mjs';
10
+ import { report } from '../kernel/ruleset.mjs';
11
+ import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
12
+ import { eachWithinMemory } from '../kernel/memory.mjs';
13
+ import { advisoriesFor, advisoryFeedPaths, loadAdvisoryFeed, COVERAGE } from '../advisories.mjs';
14
+ import { isKnownExploited, kevAge } from '../kev.mjs';
15
+ import { loadSite } from '../site.mjs';
16
+ import { basename } from 'node:path';
17
+
18
+
19
+ export const BUILD_RULES = {
20
+ 'build-pins-vulnerable-compiler': {
21
+ sev: 'high', evidence: 'advisory', cwe: 'CWE-1395',
22
+ text: 'The build pins a compiler version with a published advisory against it',
23
+ impact: 'The build compiles every pull request with a compiler version a published advisory affects, so a crafted source file can exploit it in CI',
24
+ remedy: 'Upgrade the pinned compiler to a fixed version, or pin one no listed advisory affects',
25
+ },
26
+ 'build-pins-exploited-compiler': {
27
+ sev: 'crit', evidence: 'advisory', cwe: 'CWE-1395',
28
+ text: 'The build pins a compiler version with a vulnerability CISA lists as known to be exploited',
29
+ impact: 'The pinned compiler has a vulnerability CISA lists as known to be exploited, and CI compiles untrusted source with it',
30
+ remedy: 'Upgrade the pinned compiler to a fixed version now; this one is being exploited in the wild',
31
+ },
32
+ 'site-declares-vulnerable-runtime': {
33
+ sev: 'high', evidence: 'advisory', cwe: 'CWE-1395',
34
+ text: 'The estate declares a runtime version with a published advisory against it',
35
+ impact: 'The estate runs a runtime version a published advisory affects',
36
+ remedy: "Upgrade the runtime to a fixed version, or apply the vendor's fix for the named advisory",
37
+ },
38
+ 'build-pins-vulnerable-component': {
39
+ sev: 'high', evidence: 'advisory', cwe: 'CWE-1395',
40
+ text: 'The build pins a component version with a published advisory against it',
41
+ impact: 'The build pins a component version a published advisory affects',
42
+ remedy: 'Upgrade the pinned component to a fixed version, or pin one no listed advisory affects',
43
+ },
44
+ 'build-pins-exploited-component': {
45
+ sev: 'crit', evidence: 'advisory', cwe: 'CWE-1395',
46
+ text: 'The build pins a component version with a vulnerability CISA lists as known to be exploited',
47
+ impact: 'The pinned component has a vulnerability CISA lists as known to be exploited',
48
+ remedy: 'Upgrade the pinned component to a fixed version now; this one is being exploited in the wild',
49
+ },
50
+ };
51
+
52
+ // A compiler turns the source into what runs; a component is something the estate talks to the
53
+ // mainframe through. The advisory arithmetic is the same, the person who acts on it is not.
54
+ const COMPILERS = new Set(['gnucobol', 'opentext-cobol', 'ibm-enterprise-cobol']);
55
+
56
+ // Files that decide what the build runs. A repository states this in one of a small number of
57
+ // conventional places, and reading only those keeps the rule from matching prose in a README.
58
+ function isBuildFile(path) {
59
+ const name = basename(path);
60
+ const p = path.replace(/\\/g, '/').toLowerCase();
61
+ if (/^(dockerfile|containerfile)(\..+)?$/i.test(name)) return true;
62
+ if (/^(gnu)?makefile$/i.test(name) || /\.mk$/i.test(name)) return true;
63
+ if (/^(\.tool-versions|\.gnucobol-version|\.cobc-version)$/i.test(name)) return true;
64
+ if (/^(jenkinsfile|azure-pipelines\.ya?ml|\.gitlab-ci\.ya?ml|docker-compose\.ya?ml)$/i.test(name)) return true;
65
+ if (p.includes('/.github/workflows/') && /\.ya?ml$/i.test(name)) return true;
66
+ if (/\.(sh|bash)$/i.test(name)) return true;
67
+ // Where the off-mainframe half of the estate is pinned: the driver, gateway and tooling a COBOL
68
+ // repository talks to the mainframe through.
69
+ if (/^(package\.json|pom\.xml)$/i.test(name) || /^build\.gradle(\.kts)?$/i.test(name)) return true;
70
+ return false;
71
+ }
72
+
73
+ // How a pinned version is written. Each pattern names the product, so a version number on its own
74
+ // is never a finding: this rule reports what the build pins, not every number in a Makefile.
75
+ const PINS = [
76
+ // ARG GNUCOBOL_VERSION=3.2 / gnucobol-version: 3.2 / apt-get install gnucobol=3.2
77
+ { product: 'gnucobol', re: /gnucobol[-_ ]?(?:version)?\s*[:=]+\s*["']?v?(\d+(?:\.\d+)+)/gi },
78
+ // gnucobol-3.2.tar.gz, gnucobol_3.2_amd64.deb
79
+ { product: 'gnucobol', re: /gnucobol[-_](\d+(?:\.\d+)+)[._-]/gi },
80
+ // FROM ghcr.io/example/gnucobol:3.2
81
+ { product: 'gnucobol', re: /from\s+\S*gnucobol\S*:v?(\d+(?:\.\d+)+)/gi },
82
+ // .tool-versions: "gnucobol 3.2"
83
+ { product: 'gnucobol', re: /^gnucobol\s+v?(\d+(?:\.\d+)+)\s*$/gim },
84
+
85
+ // The OpenText line shares one version across Visual COBOL, COBOL Server, Enterprise Developer
86
+ // and Enterprise Server, which is why an advisory against one names all of them.
87
+ { product: 'opentext-cobol', re: /(?:visual[-_ ]?cobol|cobol[-_ ]?server|enterprise[-_ ](?:server|developer|test[-_ ]server))[-_ ]?(?:version)?\s*[:=]+\s*["']?v?(\d+(?:\.\d+)+)/gi },
88
+ { product: 'opentext-cobol', re: /from\s+\S*(?:microfocus|opentext)\S*:v?(\d+(?:\.\d+)+)/gi },
89
+
90
+ // "@zowe/cli": "^7.18.0" in package.json, or a CLI version pinned in a pipeline.
91
+ { product: 'zowe', re: /"@zowe\/[\w-]+"\s*:\s*"[^\d"]*(\d+(?:\.\d+)+)/g },
92
+ { product: 'zowe', re: /zowe[-_ ]?cli[-_ ]?(?:version)?\s*[:=]+\s*["']?v?(\d+(?:\.\d+)+)/gi },
93
+
94
+ // ctgclient.jar is the gateway's client; Maven and Gradle name it beside its version.
95
+ { product: 'cics-transaction-gateway', re: /ctgclient[-_](\d+(?:\.\d+)+)\.jar/gi },
96
+ { product: 'cics-transaction-gateway', re: /com\.ibm\.ctg[\s\S]{0,200}?<version>\s*v?(\d+(?:\.\d+)+)/gi },
97
+ { product: 'cics-transaction-gateway', re: /com\.ibm\.ctg:[\w-]+:v?(\d+(?:\.\d+)+)/gi },
98
+ { product: 'cics-transaction-gateway', re: /from\s+\S*cics[-_]?transaction[-_]?gateway\S*:v?(\d+(?:\.\d+)+)/gi },
99
+ ];
100
+
101
+ function withMavenProperties(src) {
102
+ const block = src.match(/<properties>([\s\S]*?)<\/properties>/i);
103
+ if (!block) return src;
104
+ const props = new Map();
105
+ for (const m of block[1].matchAll(/<([\w.-]+)>\s*([^<\s][^<]*?)\s*<\/\1>/g)) props.set(m[1], m[2]);
106
+ return src.replace(/\$\{([\w.-]+)\}/g, (all, name) => (props.has(name) ? props.get(name) : all));
107
+ }
108
+
109
+ // A customer feed's rows are named as the customer's, never as published: their source is an
110
+ // extract nobody else can check.
111
+ const relatedOf = (a) => (a.feed ? { id: a.id, feed: a.feed.extract } : { id: a.id, url: a.source.url });
112
+ function fromFeeds(hits) {
113
+ const byFeed = new Map();
114
+ for (const a of hits) if (a.feed) { const k = `${a.feed.extract}, retrieved ${a.feed.retrieved}`; byFeed.set(k, [...(byFeed.get(k) || []), a.id]); }
115
+ return [...byFeed].map(([k, ids]) => `; ${ids.sort(byText).join(', ')} ${ids.length === 1 ? 'is' : 'are'} from the customer feed ${k}`).join('');
116
+ }
117
+ // "Published" only when every advisory is: a customer extract is not.
118
+ const countOf = (hits) => { const p = hits.some((a) => a.feed) ? '' : 'published '; return hits.length === 1 ? (p ? 'has a published advisory' : 'has an advisory') : `has ${hits.length} ${p}advisories`; };
119
+ const feedSummary = (f) => ({ file: f.file, extract: f.extract, retrieved: f.retrieved, loaded: f.advisories.length, refused: f.refused.length, ...(f.problem ? { problem: f.problem } : {}) });
120
+ // What each loaded feed says it covered, after what the published record covers.
121
+ function withFeeds(coverage, feeds) {
122
+ const out = { ...coverage };
123
+ for (const f of feeds) {
124
+ if (f.problem) continue;
125
+ const products = new Set([...Object.keys(f.coverage), ...f.advisories.map((a) => a.product)]);
126
+ for (const p of products) {
127
+ const said = f.coverage[p] || `${f.advisories.filter((a) => a.product === p).length} advisories`;
128
+ out[p] = `${out[p] ? `${out[p]}; ` : ''}customer feed ${f.extract}, retrieved ${f.retrieved}: ${said}`;
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ export function scanBuild(root, opts = {}) {
135
+ const site = loadSite(root, opts.site || null);
136
+ const tree = treeFor(root, opts);
137
+ const files = tree.list().filter(inScope(opts)).filter(isBuildFile);
138
+ const findings = [];
139
+ const age = kevAge();
140
+ const feeds = advisoryFeedPaths(opts).map((p) => loadAdvisoryFeed(p, { root }));
141
+ const extra = feeds.flatMap((f) => f.advisories);
142
+ const stats = {
143
+ filesScanned: 0, filesUnreadable: 0, pinsFound: 0,
144
+ kevCatalogVersion: age.catalogVersion, kevAgeDays: age.days, kevStale: age.stale,
145
+ // Which products this scan could have found anything for. A clean result over a product
146
+ // nobody has searched advisories for is not a clean result.
147
+ advisoryCoverage: withFeeds(COVERAGE, feeds),
148
+ ...(feeds.length ? { advisoryFeeds: feeds.map(feedSummary) } : {}),
149
+ runtimesDeclared: Object.keys(site.runtimeVersions || {}).length,
150
+ // CICS and Db2 are not pinned in a build file - they are what the code runs on, not what
151
+ // compiles it - so an advisory against them can only be acted on once the estate says which
152
+ // version it runs. Without that declaration these advisories are inert, and the summary says
153
+ // so rather than letting an empty result read as a clean one.
154
+ runtimesNotDeclared: ['cics-ts', 'db2-zos', 'ibm-enterprise-cobol']
155
+ .filter((p) => !(site.runtimeVersions || {})[p]),
156
+ };
157
+
158
+ // A feed asked for and not loaded, or a row the gate refused, is an advisory this scan was told to
159
+ // check and did not.
160
+ const notLoaded = feeds.flatMap((f) => (f.problem ? [`the advisory feed ${f.file} ${f.problem}`]
161
+ : f.refused.length ? [`the advisory feed ${f.file} (${f.extract}) had ${f.refused.length} of ${f.refused.length + f.advisories.length} rows refused by the advisory gate, the first ${f.refused[0].id || 'unnamed'}: ${f.refused[0].problems[0]}`] : []));
162
+ if (notLoaded.length) { stats.setIncomplete = true; stats.notLooked = notLoaded; }
163
+
164
+ // What the estate says it runs, checked against the same advisory table the build pins are.
165
+ for (const [product, version] of Object.entries(site.runtimeVersions || {})) {
166
+ const hits = advisoriesFor(product, String(version), extra);
167
+ if (!hits.length) continue;
168
+ const ids = hits.map((a) => a.id).sort(byText);
169
+ const exploited = ids.filter(isKnownExploited);
170
+ findings.push({
171
+ rule: 'site-declares-vulnerable-runtime', path: site.path ? 'cobolwork.site.json' : '(site configuration)', line: 1,
172
+ detail: `the estate declares ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}`,
173
+ related: hits.map(relatedOf),
174
+ sev: exploited.length ? 'crit' : undefined,
175
+ ...(exploited.length ? { knownExploited: exploited } : {}),
176
+ });
177
+ }
178
+
179
+ // Build files are small and few, so this set is the least likely of the nine to exhaust a heap.
180
+ // It walks inside the guard anyway: the budget is a property of the scan, not of one rule set's
181
+ // opinion of its own appetite, and a set that opts out is a set nobody notices has opted out.
182
+ const run = eachWithinMemory(files, (f) => {
183
+ let src;
184
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
185
+ stats.filesScanned++;
186
+ const path = relPath(root, f);
187
+ // Maven states a version as a property more often than inline, and the pin is what it resolves
188
+ // to. Substituting in place keeps every line where it was.
189
+ if (/pom\.xml$/i.test(path)) src = withMavenProperties(src);
190
+ const lines = src.split(/\r?\n/);
191
+
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
+ }
228
+ }
229
+ }
230
+ return src.length;
231
+ }, { label: 'build', maxBytes: opts.maxSourceBytes ?? Infinity });
232
+
233
+ return report('build', { rules: BUILD_RULES, findings, stats, run });
234
+ }
@@ -0,0 +1,306 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { inScope, isProgram, relPath } from '../sources.mjs';
3
+ import { report } from '../kernel/ruleset.mjs';
4
+ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
5
+ import { dirname, join } from 'node:path';
6
+ import { parseSource } from '../parser.mjs';
7
+ import { eachWithinMemory } from '../kernel/memory.mjs';
8
+ import { buildControl, hasFact } from '../control.mjs';
9
+ import { parseCsd } from '../csd.mjs';
10
+
11
+
12
+ // `impact` says who can do what once the construct is present, so a reader can tell a defect they
13
+ // must act on from one that only wants a look; `remedy` is the standard fix, the same for every
14
+ // instance. Both ride with every finding of the rule (as ruleImpact/ruleRemedy).
15
+ export const CICS_RULES = {
16
+ 'cics-commarea-without-length-check': {
17
+ sev: 'high', evidence: 'construct', cwe: 'CWE-1284',
18
+ text: 'A program reads its communication area without checking EIBCALEN',
19
+ impact: 'A caller that passes no communication area, or a shorter one than the program reads, makes it read the storage past the area - and a transaction a terminal starts carries none',
20
+ remedy: 'Test EIBCALEN before reading DFHCOMMAREA, and return when it is zero or shorter than the record the program expects',
21
+ },
22
+ 'cics-transfer-to-variable-program': {
23
+ sev: 'med', evidence: 'construct', cwe: 'CWE-470',
24
+ text: 'CICS transfers control to a program named by a variable',
25
+ impact: 'Whoever controls the value the program name is read from chooses which program the region runs next',
26
+ remedy: 'Transfer only to a name chosen from a fixed table of literals, never one taken from input, a record or the communication area',
27
+ },
28
+ 'cics-commarea-length-exceeds-area': {
29
+ sev: 'high', evidence: 'construct', cwe: 'CWE-805',
30
+ text: 'A CICS call passes a length longer than the area it passes',
31
+ impact: 'The call reads past the passed area into the storage beside it on every invocation',
32
+ remedy: 'Pass a LENGTH no greater than the passed area, or LENGTH OF that area',
33
+ },
34
+ 'cics-commarea-length-exceeds-callee': {
35
+ sev: 'high', evidence: 'construct', cwe: 'CWE-805',
36
+ text: 'A CICS call passes a length longer than the callee declares',
37
+ impact: 'The callee reads past what the caller passed, into unrelated storage, on every call',
38
+ remedy: "Pass a LENGTH no greater than the callee's declared communication area",
39
+ },
40
+ // A program that checks a password itself sends a successful sign-on somewhere. Another route to
41
+ // the same place that does not pass the check - a PF key the sign-on screen does not expect, a
42
+ // missing communication area - is a way in without a password (NetSPI, "Conquering CICS", way 6).
43
+ // Which comparison is the sign-on is read from field names containing PWD, PSWD or PASSW, which is
44
+ // a heuristic, and a sign-on that only sets a flag for a later test is not followed.
45
+ 'cics-signon-says-which-half-failed': {
46
+ sev: 'med', evidence: 'construct', cwe: 'CWE-204',
47
+ text: 'A sign-on answers a bad user and a bad password differently',
48
+ impact: 'The two failures carry different messages, so anyone can try a name and learn from the reply whether it exists - which turns guessing a password on one account into collecting the list of accounts first',
49
+ remedy: 'Answer both failures with the same message and the same timing, as the programs that get this right do: one "Incorrect username or password" for either',
50
+ },
51
+ 'cics-signon-bypassed': {
52
+ sev: 'high', evidence: 'construct', cwe: 'CWE-288',
53
+ text: 'A route reaches the program a sign-on grants without passing the password check',
54
+ impact: 'An entry the sign-on screen does not expect - an AID key it does not handle, a missing communication area - reaches the guarded program without the comparison, so it runs without a password',
55
+ remedy: 'Reach the granted program only from the path where the password comparison succeeded; on any other entry, re-drive the sign-on',
56
+ },
57
+ };
58
+
59
+ const PASSWORD = /PWD|PSWD|PASSW|PASSCODE|PASSPHRASE/;
60
+ // A message that says a sign-on failed. Which half it names is the whole question: a reply that
61
+ // distinguishes "no such user" from "wrong password" answers the attacker's first question for them.
62
+ const FAILED = /(not found|invalid|incorrect|unknown|wrong|does not exist|not match|mismatch|no such)/i;
63
+ // Which half the message names, by the words in it rather than by a regex: a word-boundary escape
64
+ // written into this file became a literal backspace byte once, and the regex then matched nothing.
65
+ const wordsOf = (text) => String(text).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
66
+ const NAMES_USER = new Set(['user', 'userid', 'username', 'logon', 'signon']);
67
+ const NAMES_PASSWORD = new Set(['password', 'passwd', 'pwd', 'passcode', 'passphrase']);
68
+ const names = (set) => (text) => wordsOf(text).some((w) => set.has(w));
69
+ const namesUser = names(NAMES_USER);
70
+ const namesPassword = names(NAMES_PASSWORD);
71
+ export function tellsWhichHalfFailed(literals) {
72
+ const failures = [...new Set(literals.map((l) => String(l).trim()))].filter((l) => l.length >= 6 && FAILED.test(l));
73
+ const user = failures.filter((l) => namesUser(l) && !namesPassword(l));
74
+ const password = failures.filter((l) => namesPassword(l) && !namesUser(l));
75
+ if (!user.length || !password.length) return null;
76
+ return { user: user[0], password: password[0] };
77
+ }
78
+ const FIGURATIVE = /^(SPACES?|ZEROS?|ZEROES|LOW-VALUES?|HIGH-VALUES?|QUOTES?|NULLS?)$/;
79
+ const RELOPS = new Set(['=', '<>']);
80
+ // A test that compares one password-named field with another field, which is what checking a password
81
+ // looks like: equal on one outcome, not equal on the other. A comparison with a literal or SPACES
82
+ // checks that one was typed, not that it is right.
83
+ export function signOnTest(test) {
84
+ const toks = test.cond || (test.evaluate && test.evaluate.subjects.length === 1 && test.evaluate.subjects[0].u === 'TRUE' && test.evaluate.conds.length === 1 ? test.evaluate.conds[0] : null);
85
+ if (!toks) return null;
86
+ const at = toks.findIndex((t) => (t.t === 'op' && RELOPS.has(t.v)) || (t.t === 'word' && (t.u === 'EQUAL' || t.u === 'EQUALS')));
87
+ if (at < 0 || toks.some((t) => t.t === 'word' && (t.u === 'AND' || t.u === 'OR'))) return null;
88
+ const names = (side) => side.filter((t) => t.t === 'word' && !['NOT', 'IS', 'TO', 'OF', 'IN', 'EQUAL', 'EQUALS', 'FUNCTION', 'UPPER-CASE', 'LOWER-CASE', 'TRIM'].includes(t.u));
89
+ const left = toks.slice(0, at), right = toks.slice(at + 1);
90
+ const l = names(left), r = names(right);
91
+ if (!l.length || !r.length || [...left, ...right].some((t) => t.t === 'lit' || t.t === 'num') || [...l, ...r].some((t) => FIGURATIVE.test(t.u))) return null;
92
+ if (![...l, ...r].some((t) => PASSWORD.test(t.u))) return null;
93
+ const negated = (toks[at].t === 'op' && toks[at].v === '<>') || left.some((t) => t.t === 'word' && t.u === 'NOT');
94
+ return negated ? { false: 'SIGNON' } : { true: 'SIGNON' };
95
+ }
96
+
97
+
98
+ function execOptions(exec) {
99
+ const opts = new Map();
100
+ const words = [];
101
+ const toks = exec.toks;
102
+ for (let i = 0; i < toks.length; i++) {
103
+ const t = toks[i];
104
+ if (t.t !== 'word') continue;
105
+ words.push(t.u);
106
+ if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
107
+ const inner = [];
108
+ let depth = 1;
109
+ for (let k = i + 2; k < toks.length && depth > 0; k++) {
110
+ if (toks[k].t === 'sep') { depth += toks[k].v === '(' ? 1 : -1; if (depth === 0) break; continue; }
111
+ inner.push(toks[k]);
112
+ }
113
+ if (!opts.has(t.u)) opts.set(t.u, inner);
114
+ }
115
+ }
116
+ return { opts, words };
117
+ }
118
+
119
+ const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
120
+
121
+ // Sizes come from the parser's data-division layout, which is graded against the compiler, so a
122
+ // length compared here is the length the compiler would compute.
123
+ //
124
+ // Everything this rule set needs from one program is taken while its parse tree is in hand, and the
125
+ // tree is then released. Holding every tree until the end is what the flow engine was refactored
126
+ // away from, and this set kept doing it: on a 100,000-program repository it exhausted an 8 GB heap
127
+ // and took a corpus run with it. What survives per program is an identifier, a communication area's
128
+ // size, one boolean, and a short list of transfers.
129
+ export function scanCics(root, opts = {}) {
130
+ const tree = treeFor(root, opts);
131
+ const files = tree.list().filter(isProgram).filter(inScope(opts));
132
+ // Identifier to the size of that program's DFHCOMMAREA, which is all a caller needs of a callee.
133
+ const commareaSize = new Map();
134
+ const summaries = [];
135
+ const findings = [];
136
+ const stats = { filesScanned: 0, cicsPrograms: 0, withCommarea: 0, commareaRead: 0, filesUnreadable: 0 };
137
+ // Where a statement really is. One that came from a copybook carries the copybook's path and
138
+ // line; reporting it at the program's path with the copybook's line points at an unrelated line.
139
+ const where = (x, path) => ({ path: x.file ? relPath(root, x.file) : path, line: x.line || 1 });
140
+
141
+ // The whole of what the second pass reads, computed once, while the tree is still here.
142
+ function summarise(p, path) {
143
+ const isSignOn = [...new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))])].some((w) => PASSWORD.test(w));
144
+ const words = new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))]);
145
+ const commarea = p.items.find(i => i.name === 'DFHCOMMAREA' && i.section === 'LINKAGE');
146
+ // A variable naming the program usually holds one literal from its VALUE clause.
147
+ const valueOf = (name) => {
148
+ const it = p.items.find(i => i.name === name);
149
+ const lit = it && it.values && it.values.find(v => v.t === 'lit');
150
+ return lit ? String(lit.v).trim().toUpperCase() : null;
151
+ };
152
+ const transfers = [];
153
+ for (const e of p.execs) {
154
+ if (e.kind !== 'CICS') continue;
155
+ const { opts: o, words: w } = execOptions(e);
156
+ const verb = w[0];
157
+ if (!['LINK', 'XCTL', 'START'].includes(verb)) continue;
158
+ const pgm = (o.get('PROGRAM') || o.get('TRANSID') || [])[0];
159
+ const areaTok = (o.get('COMMAREA') || o.get('FROM') || [])[0];
160
+ const lenTok = (o.get('LENGTH') || [])[0];
161
+ const declared = lenTok && (lenTok.t === 'num' || /^\d+$/.test(lenTok.v)) ? Number(lenTok.v) : null;
162
+ const area = areaTok ? p.items.find(i => i.name === areaTok.u) : null;
163
+ const variableProgram = pgm && pgm.t === 'word' ? pgm.u : null;
164
+ transfers.push({
165
+ at: where(e, path), verb,
166
+ variableProgram,
167
+ calleeName: pgm && pgm.t === 'lit' ? String(pgm.v).trim().toUpperCase() : null,
168
+ callee: pgm && pgm.t === 'lit' ? String(pgm.v).trim().toUpperCase() : variableProgram ? valueOf(variableProgram) : null,
169
+ areaName: areaTok ? areaTok.u : null,
170
+ areaSize: area && area.size ? area.size : null,
171
+ declared,
172
+ });
173
+ }
174
+ // A program that points DFHCOMMAREA at storage of its own is not reading a caller's area.
175
+ const tokens = (p.proc && p.proc.tokens) || [];
176
+ const pointsItself = (p.statements || []).some((st) => {
177
+ if (st.verb !== 'SET') return false;
178
+ const w = tokens.slice(st.at + 1, st.end).filter((t) => t.t === 'word').map((t) => t.u);
179
+ return w.some((x, k) => x === 'ADDRESS' && w[k + 1] === 'OF' && w[k + 2] === 'DFHCOMMAREA');
180
+ });
181
+ return {
182
+ id: p.id, path,
183
+ ownCics: p.execs.some((e) => e.kind === 'CICS'),
184
+ pointsItself,
185
+ commarea: commarea ? { ...where(commarea, path), size: commarea.size, read: subtreeRead(commarea) } : null,
186
+ checksLength: words.has('EIBCALEN'),
187
+ transfers,
188
+ signOnBypasses: isSignOn ? signOnBypasses(p, path) : [],
189
+ // Gated the same way: a program with no password-named field is not answering a sign-on,
190
+ // and 'User not found' in a lookup screen is not telling anyone which accounts exist.
191
+ tellsWhichHalf: isSignOn ? tellsWhichHalfFailed((p.statements || []).flatMap((st) => (st.literals || []).map((l) => (l && l.v !== undefined ? l.v : l)))) : null,
192
+ lineOf: (text) => { const st = (p.statements || []).find((x) => (x.literals || []).some((l) => String(l && l.v !== undefined ? l.v : l).trim() === text)); return (st && st.line) || 1; },
193
+ };
194
+ }
195
+
196
+ // Where a program sends control after its sign-on succeeds, and every other route there.
197
+ function signOnBypasses(p, path) {
198
+ let ctl;
199
+ const byName = new Map();
200
+ for (const it of p.items) if (!byName.has(it.name)) byName.set(it.name, it);
201
+ const resolve = (tok) => (tok && tok.t === 'word' ? p.resolved.get(tok) || byName.get(tok.u) || null : null);
202
+ try { ctl = buildControl(p, resolve, { extra: signOnTest }); } catch { return []; }
203
+ const bit = ctl.extraFacts && ctl.extraFacts.get('SIGNON');
204
+ if (bit == null) return [];
205
+ const sends = [];
206
+ for (const e of p.execs) {
207
+ if (e.kind !== 'CICS') continue;
208
+ const { opts: o, words: w } = execOptions(e);
209
+ const opt = w[0] === 'XCTL' || w[0] === 'LINK' ? 'PROGRAM' : (w[0] === 'RETURN' || w[0] === 'START') ? 'TRANSID' : null;
210
+ const t = opt && (o.get(opt) || [])[0];
211
+ if (!t || t.t !== 'lit') continue;
212
+ const target = `${opt === 'PROGRAM' ? '' : 'transaction '}${String(t.v).trim().toUpperCase()}`;
213
+ if (target === p.id) continue;
214
+ const bits = ctl.facts.get(ctl.nodeOf.get(e));
215
+ if (!bits) continue;
216
+ sends.push({ e, verb: w[0], target, signedOn: hasFact(bits, bit) });
217
+ }
218
+ const granted = new Set(sends.filter((x) => x.signedOn).map((x) => x.target));
219
+ const aid = p.refs.some((r) => r.tok.u === 'EIBAID');
220
+ return sends.filter((x) => !x.signedOn && granted.has(x.target)).map((x) => ({
221
+ ...where(x.e, path),
222
+ detail: `${p.id} runs EXEC CICS ${x.verb} to ${x.target} on a route that does not pass its password check, and ${x.target} is where a successful sign-on goes${aid ? '; the program dispatches on EIBAID, so a key the sign-on screen does not expect may take this route' : ''}`,
223
+ }));
224
+ }
225
+
226
+ // Releasing each tree bounds what one program costs; it does not bound what a hundred thousand of
227
+ // them cost together. The shared loop watches the heap and stops before V8 cannot collect, and
228
+ // names every file it did not reach.
229
+ const run = eachWithinMemory(files, (f) => {
230
+ let src;
231
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
232
+ if (!/EXEC\s+CICS/i.test(src) && !/(?:^|[\s.])COPY\s+/i.test(src)) return src.length;
233
+ let r;
234
+ try {
235
+ r = tree.parse(f, src);
236
+ } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
237
+ if (!r.programs.some(p => p.execs.some(e => e.kind === 'CICS'))) return src.length;
238
+ stats.filesScanned++;
239
+ const path = relPath(root, f);
240
+ for (const p of r.programs) {
241
+ const sum = summarise(p, path);
242
+ summaries.push(sum);
243
+ if (p.id && !commareaSize.has(p.id)) commareaSize.set(p.id, sum.commarea ? sum.commarea.size : null);
244
+ }
245
+ r = null; // the tree is not needed past this point
246
+ return src.length;
247
+ }, { label: 'cics', maxBytes: opts.maxSourceBytes ?? Infinity });
248
+
249
+
250
+ // What every caller in the tree passes each program, and which programs a transaction starts: a
251
+ // terminal starts a transaction with no communication area at all.
252
+ const callersOf = new Map();
253
+ for (const s of summaries) for (const t of s.transfers) {
254
+ if (t.verb === 'START' || !t.callee) continue;
255
+ if (!callersOf.has(t.callee)) callersOf.set(t.callee, []);
256
+ callersOf.get(t.callee).push(t.declared ?? t.areaSize ?? 0);
257
+ }
258
+ const startable = new Set();
259
+ for (const f of tree.list().filter((p) => /\.csd$/i.test(p)).filter(inScope(opts))) {
260
+ try { for (const t of parseCsd(tree.text(f).text).transactions.values()) if (t.program) startable.add(String(t.program).toUpperCase()); } catch { /* unreadable: nothing is known to start from it */ }
261
+ }
262
+
263
+ const reported = new Set();
264
+ for (const s of summaries) {
265
+ stats.cicsPrograms++;
266
+ if (s.commarea) stats.withCommarea++;
267
+ if (s.commarea && s.commarea.read) {
268
+ stats.commareaRead++;
269
+ // A zero-length area is a parse that read a program as a copybook, and a program with no
270
+ // EXEC CICS of its own, or one that points DFHCOMMAREA at storage itself, has no caller's area.
271
+ const key = `${s.commarea.path}|${s.commarea.line}|${s.id}`;
272
+ if (!s.checksLength && s.ownCics && !s.pointsItself && s.commarea.size > 0 && !reported.has(key)) {
273
+ reported.add(key);
274
+ const callers = callersOf.get(String(s.id || '').toUpperCase()) || [];
275
+ const covered = callers.length > 0 && callers.every((n) => n >= s.commarea.size) && !startable.has(String(s.id || '').toUpperCase());
276
+ findings.push({
277
+ rule: 'cics-commarea-without-length-check', path: s.commarea.path, line: s.commarea.line, program: s.id,
278
+ ...(covered ? { sev: 'low' } : {}),
279
+ detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never references EIBCALEN, so a shorter or absent communication area is read as if it were whole${covered ? `; every caller in the tree passes at least ${s.commarea.size} bytes, and no transaction starts it` : ''}`,
280
+ });
281
+ }
282
+ }
283
+ for (const b of s.signOnBypasses) findings.push({ rule: 'cics-signon-bypassed', path: b.path, line: b.line, program: s.id, detail: b.detail });
284
+ if (s.tellsWhichHalf) {
285
+ findings.push({
286
+ rule: 'cics-signon-says-which-half-failed', path: s.path, line: s.lineOf(s.tellsWhichHalf.user), program: s.id,
287
+ detail: `${s.id} answers a bad user with '${s.tellsWhichHalf.user}' and a bad password with '${s.tellsWhichHalf.password}', so the reply says which of the two was wrong`,
288
+ });
289
+ }
290
+ for (const t of s.transfers) {
291
+ if (t.variableProgram) {
292
+ findings.push({ rule: 'cics-transfer-to-variable-program', ...t.at, program: s.id, detail: `${s.id} runs EXEC CICS ${t.verb} with the program name taken from ${t.variableProgram}` });
293
+ }
294
+ if (t.declared == null || !t.areaName) continue;
295
+ if (t.areaSize && t.declared > t.areaSize) {
296
+ findings.push({ rule: 'cics-commarea-length-exceeds-area', ...t.at, program: s.id, detail: `${s.id} passes LENGTH(${t.declared}) with ${t.areaName}, which is ${t.areaSize} bytes` });
297
+ }
298
+ const targetSize = t.calleeName ? commareaSize.get(t.calleeName) : undefined;
299
+ if (targetSize && t.declared > targetSize) {
300
+ findings.push({ rule: 'cics-commarea-length-exceeds-callee', ...t.at, program: s.id, detail: `${s.id} passes LENGTH(${t.declared}) to ${t.calleeName}, whose DFHCOMMAREA is ${targetSize} bytes` });
301
+ }
302
+ }
303
+ }
304
+
305
+ return report('cics', { rules: CICS_RULES, findings, stats, run });
306
+ }