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