@portll/cobolwork 0.4.0 → 0.5.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 +16 -0
- package/bin/cobolwork.mjs +7 -1
- package/lib/bms.mjs +8 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/consequence.mjs +13 -0
- package/lib/constants.mjs +60 -0
- package/lib/control.mjs +61 -17
- package/lib/csd.mjs +6 -1
- package/lib/dataflow.mjs +58 -9
- package/lib/hlasm.mjs +79 -0
- package/lib/kernel/registry.mjs +4 -0
- package/lib/revision.json +1 -1
- package/lib/sets/abend.mjs +45 -7
- package/lib/sets/crypto.mjs +110 -0
- package/lib/sets/flow.mjs +18 -0
- package/lib/sets/hlasm.mjs +121 -0
- package/lib/sets/jcl.mjs +15 -1
- package/lib/sets/web.mjs +99 -4
- package/lib/sources.mjs +12 -2
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +132 -0
- package/rules/compliance-dora.json +143 -0
- package/rules/compliance-ffiec.json +143 -0
- package/rules/compliance-nist80053.json +143 -0
- package/rules/gitleaks-mainframe.toml +24 -10
- package/rules/hlasm-operations.json +451 -0
- package/rules/icsf-services.json +1178 -0
package/lib/sets/abend.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// where the evidence verifies and the run's own journal records the abend the manifest claims, so
|
|
7
7
|
// the finding rests on a record of the run rather than on the manifest's word.
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
9
10
|
import { delimiter, join, posix, basename } from 'node:path';
|
|
10
11
|
import { report } from '../kernel/ruleset.mjs';
|
|
11
12
|
import { verifyEvidence } from '../evidence/verify.mjs';
|
|
@@ -53,23 +54,55 @@ export function abendRule(abend) {
|
|
|
53
54
|
return 'input-causes-abend';
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
// The abend a kept run's own journal records,
|
|
57
|
+
// The abend a kept run's own journal records, with the sources it read and the programs it CALLed,
|
|
58
|
+
// or null.
|
|
57
59
|
function journalAbend(evidenceDir, runId) {
|
|
58
60
|
if (typeof runId !== 'string' || !/^[0-9TZ]+-[0-9a-f]{16}$/.test(runId)) return null;
|
|
59
61
|
const file = join(evidenceDir, 'runs', `${runId}.jsonl`);
|
|
60
62
|
if (!existsSync(file)) return null;
|
|
63
|
+
let abend = null;
|
|
64
|
+
const inputs = [];
|
|
65
|
+
const calls = [];
|
|
61
66
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
62
67
|
if (!line) continue;
|
|
63
68
|
const rec = JSON.parse(line);
|
|
64
|
-
if (rec.kind === 'abend')
|
|
69
|
+
if (rec.kind === 'abend') abend ??= rec;
|
|
70
|
+
else if (rec.kind === 'input') inputs.push(rec);
|
|
71
|
+
else if (rec.kind === 'call') calls.push(rec);
|
|
65
72
|
}
|
|
66
|
-
return
|
|
73
|
+
return abend && { ...abend, inputs, calls };
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
const isString = (x) => typeof x === 'string' && x.length > 0;
|
|
77
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
70
78
|
|
|
71
|
-
//
|
|
72
|
-
|
|
79
|
+
// A manifest root as a directory of the scanned tree, or null when it is not one.
|
|
80
|
+
function treeDir(dir) {
|
|
81
|
+
if (typeof dir !== 'string') return null;
|
|
82
|
+
const norm = posix.normalize(dir.replace(/\\/g, '/'));
|
|
83
|
+
return norm === '..' || norm.startsWith('../') || posix.isAbsolute(norm) ? null : norm;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The directory in the scanned tree of the root the run read `file` from, located by the manifest's
|
|
87
|
+
// roots: the root the journal's input record for that file names, or for a CALLed program's source,
|
|
88
|
+
// which its call record names by path and digest alone, the root under which the tree holds that
|
|
89
|
+
// file with that digest. Undefined where neither record names the file, null where the root cannot
|
|
90
|
+
// be told or lies outside the tree.
|
|
91
|
+
function rootDirectory(roots, journal, file, tree) {
|
|
92
|
+
if (!Array.isArray(roots)) return undefined;
|
|
93
|
+
const held = [...new Set(journal.inputs.filter((i) => i.path === file && Number.isInteger(i.root)).map((i) => i.root))];
|
|
94
|
+
if (held.length) return held.length === 1 ? treeDir(roots[held[0]]) : null;
|
|
95
|
+
const digests = new Set(journal.calls.filter((c) => c.from === file).map((c) => c.sha256));
|
|
96
|
+
if (!digests.size || !tree) return undefined;
|
|
97
|
+
const holding = [...new Set(roots.map(treeDir).filter((d) => d !== null && existsSync(join(tree, d, file))))];
|
|
98
|
+
const same = holding.filter((d) => digests.has(sha256(readFileSync(join(tree, d, file)))));
|
|
99
|
+
const found = same.length ? same : holding;
|
|
100
|
+
return found.length === 1 ? found[0] : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One fuzz run: its findings, and what kept any of it from counting. `tree` is the scanned tree,
|
|
104
|
+
// where a CALLed program's source is looked for.
|
|
105
|
+
export function loadAbendRun(dir, tree) {
|
|
73
106
|
const out = { dir: basename(dir), program: null, findings: [], problems: [], counts: null, notModelled: 0 };
|
|
74
107
|
let doc;
|
|
75
108
|
try { doc = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); } catch (e) {
|
|
@@ -111,7 +144,12 @@ export function loadAbendRun(dir) {
|
|
|
111
144
|
out.problems.push(`run ${run.journal}: its journal records the abend at ${place.file}:${place.line}, not where the manifest puts it`);
|
|
112
145
|
continue;
|
|
113
146
|
}
|
|
114
|
-
const
|
|
147
|
+
const rootDir = isString(place.file) ? rootDirectory(doc.roots, recorded, place.file, tree) : undefined;
|
|
148
|
+
if (rootDir === null) {
|
|
149
|
+
out.problems.push(`run ${run.journal}: the abend is in ${place.file}, which its journal and manifest do not place in the scanned tree`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
|
|
115
153
|
const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
|
|
116
154
|
const rule = abendRule(run.abend);
|
|
117
155
|
const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
|
|
@@ -134,7 +172,7 @@ export function loadAbendRun(dir) {
|
|
|
134
172
|
|
|
135
173
|
export function scanAbend(root, opts = {}) {
|
|
136
174
|
const dirs = abendRunPaths(opts);
|
|
137
|
-
const runs = dirs.map((d) => loadAbendRun(d));
|
|
175
|
+
const runs = dirs.map((d) => loadAbendRun(d, root));
|
|
138
176
|
const findings = runs.flatMap((r) => r.findings.filter((f) => existsSync(join(root, f.path))));
|
|
139
177
|
const stats = {
|
|
140
178
|
filesScanned: 0, filesUnreadable: 0,
|
|
@@ -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
|
@@ -349,6 +349,21 @@ const DEFAULT = { sev: 'med', evidence: 'path', cwe: null, text: 'Untrusted inpu
|
|
|
349
349
|
// is safe for the sink - one of a list of literals, digits where digits are needed, a bound where an
|
|
350
350
|
// index is - is not a finding at all, and is listed under `checked` with the check that stops it.
|
|
351
351
|
const LOWER = { crit: 'high', high: 'med', med: 'low', low: 'info', info: 'info' };
|
|
352
|
+
|
|
353
|
+
// The routes of two sources that reach one sink, as one finding carries them: every source, every
|
|
354
|
+
// statement either route runs, and complete only where both are.
|
|
355
|
+
const routeKey = (s) => `${s.file}:${s.line}:${s.verb ?? s.kind}`;
|
|
356
|
+
function mergeRoutes(a, b) {
|
|
357
|
+
const union = (x, y) => [...new Map([...x, ...y].map((s) => [routeKey(s), s])).values()];
|
|
358
|
+
const order = (p, q) => (p.file < q.file ? -1 : p.file > q.file ? 1 : p.line - q.line);
|
|
359
|
+
const unplaced = (a.unplaced || 0) + (b.unplaced || 0);
|
|
360
|
+
return {
|
|
361
|
+
sources: union(a.sources, b.sources).sort(order),
|
|
362
|
+
statements: union(a.statements, b.statements).sort(order),
|
|
363
|
+
...(unplaced ? { unplaced } : {}),
|
|
364
|
+
complete: a.complete && b.complete,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
352
367
|
// One graph per repository, for the reason scanAll gives: names are only unique within one.
|
|
353
368
|
function analyzeEach(root, opts) {
|
|
354
369
|
if (!opts.repos || opts.repos.length < 2) return analyze(root, opts);
|
|
@@ -404,6 +419,7 @@ export function scan(root, opts = {}) {
|
|
|
404
419
|
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
420
|
related: [{ path: f.source.file, line: f.source.line, detail: f.source.detail }],
|
|
406
421
|
...(f.also ? { also: f.also } : {}),
|
|
422
|
+
...(f.routes ? { routes: f.routes } : {}),
|
|
407
423
|
});
|
|
408
424
|
}
|
|
409
425
|
// One sink reached by many sources is one finding, not many: a report where a single statement
|
|
@@ -417,9 +433,11 @@ export function scan(root, opts = {}) {
|
|
|
417
433
|
// A sink is credited only as far as the least-credited source that reaches it.
|
|
418
434
|
const level = Math.min(held.level, f.level);
|
|
419
435
|
const also = [...(held.also || []), ...(f.also || [])];
|
|
436
|
+
const routes = held.routes && f.routes ? mergeRoutes(held.routes, f.routes) : null;
|
|
420
437
|
if (f.hops < held.hops) Object.assign(held, f, { sources: held.sources });
|
|
421
438
|
held.level = level;
|
|
422
439
|
if (also.length) held.also = also;
|
|
440
|
+
if (routes) held.routes = routes;
|
|
423
441
|
}
|
|
424
442
|
// A use one source's route reaches unchecked, and the report leaves out for another use of the same
|
|
425
443
|
// index, still limits how far a finding at that use is credited.
|
|
@@ -0,0 +1,121 @@
|
|
|
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 { inScope, isAssembler, isProgram, relPath } from '../sources.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 { readHlasm, OPERATIONS } from '../hlasm.mjs';
|
|
14
|
+
|
|
15
|
+
export const HLASM_RULES = {
|
|
16
|
+
'hlasm-supervisor-state-change': {
|
|
17
|
+
sev: 'crit', evidence: 'construct', cwe: 'CWE-250',
|
|
18
|
+
text: 'Assembler source switches to PSW key zero or supervisor state',
|
|
19
|
+
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',
|
|
20
|
+
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',
|
|
21
|
+
},
|
|
22
|
+
'hlasm-executes-built-instruction': {
|
|
23
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-119',
|
|
24
|
+
text: 'Assembler source runs an instruction whose length or mask comes from a register at run time',
|
|
25
|
+
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',
|
|
26
|
+
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',
|
|
27
|
+
},
|
|
28
|
+
'hlasm-cross-memory-service': {
|
|
29
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-668',
|
|
30
|
+
text: 'Assembler source uses a cross-memory instruction, reaching another address space or another authority',
|
|
31
|
+
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',
|
|
32
|
+
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',
|
|
33
|
+
},
|
|
34
|
+
'hlasm-calls-security-product': { sev: 'info', evidence: 'context', cwe: null, text: 'Assembler source calls the security product directly' },
|
|
35
|
+
'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' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const RULE_OF_CLASS = {
|
|
39
|
+
'cross-memory': 'hlasm-cross-memory-service',
|
|
40
|
+
'built-instruction': 'hlasm-executes-built-instruction',
|
|
41
|
+
'security-product': 'hlasm-calls-security-product',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STATE = { KEY: 'PSW key zero', EXTKEY: 'PSW key zero', MODE: 'supervisor state' };
|
|
45
|
+
|
|
46
|
+
const where = (use) => (use.inMacro ? ' It is in a macro definition, so it is in every program that uses the macro.' : '');
|
|
47
|
+
|
|
48
|
+
function detail(use) {
|
|
49
|
+
const written = `${use.name}${use.field ? ` ${use.field}` : ''}`;
|
|
50
|
+
if (use.class === 'security-product') {
|
|
51
|
+
const request = /\bREQUEST=([A-Z]+)/i.exec(use.field || '');
|
|
52
|
+
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)}`;
|
|
53
|
+
}
|
|
54
|
+
return `${written}: ${OPERATIONS.get(use.name).summary}${where(use)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A COBOL CALL of a literal name, outside comment lines. A membership question over every program,
|
|
58
|
+
// answered with a regular expression as lib/sets/jcl.mjs answers PROGRAM-ID.
|
|
59
|
+
const CALL = /\bCALL\s+['"]([A-Z0-9$#@_-]{1,30})['"]/gi;
|
|
60
|
+
|
|
61
|
+
function cobolCalls(source) {
|
|
62
|
+
const calls = [];
|
|
63
|
+
source.split(/\r?\n/).forEach((text, i) => {
|
|
64
|
+
if (text.length > 6 && '*/'.includes(text[6])) return;
|
|
65
|
+
for (const m of text.matchAll(CALL)) calls.push({ name: m[1].toUpperCase(), line: i + 1 });
|
|
66
|
+
});
|
|
67
|
+
return calls;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function scanHlasm(root, opts = {}) {
|
|
71
|
+
const tree = treeFor(root, opts);
|
|
72
|
+
const all = tree.list().filter(inScope(opts));
|
|
73
|
+
const findings = [];
|
|
74
|
+
const stats = { filesScanned: 0, filesUnreadable: 0, assemblerFiles: 0, bmsFiles: 0, imsFiles: 0, unrecognisedFiles: 0, readNotAssembled: true };
|
|
75
|
+
const defines = new Map();
|
|
76
|
+
|
|
77
|
+
const run = eachWithinMemory(all.filter(isAssembler), (f) => {
|
|
78
|
+
let src;
|
|
79
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
80
|
+
stats.filesScanned++;
|
|
81
|
+
const r = readHlasm(src);
|
|
82
|
+
if (r.kind === 'bms') { stats.bmsFiles++; return src.length; }
|
|
83
|
+
if (r.kind === 'ims') { stats.imsFiles++; return src.length; }
|
|
84
|
+
if (r.kind === 'unrecognised') { stats.unrecognisedFiles++; return src.length; }
|
|
85
|
+
stats.assemblerFiles++;
|
|
86
|
+
const path = relPath(root, f);
|
|
87
|
+
for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
|
|
88
|
+
for (const use of r.operations) {
|
|
89
|
+
if (use.name === 'MODESET') {
|
|
90
|
+
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)}` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const rule = RULE_OF_CLASS[use.class];
|
|
94
|
+
if (rule) findings.push({ rule, path, line: use.line, detail: detail(use) });
|
|
95
|
+
}
|
|
96
|
+
return src.length;
|
|
97
|
+
}, { label: 'hlasm', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
98
|
+
|
|
99
|
+
if (defines.size) {
|
|
100
|
+
const callers = new Map();
|
|
101
|
+
const programs = eachWithinMemory(all.filter(isProgram), (f) => {
|
|
102
|
+
let src;
|
|
103
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
104
|
+
for (const c of cobolCalls(src)) {
|
|
105
|
+
if (!defines.has(c.name)) continue;
|
|
106
|
+
if (!callers.has(c.name)) callers.set(c.name, []);
|
|
107
|
+
callers.get(c.name).push({ path: relPath(root, f), line: c.line });
|
|
108
|
+
}
|
|
109
|
+
return src.length;
|
|
110
|
+
}, { label: 'hlasm-callers', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
111
|
+
if (programs.skipped.length) stats.callersNotRead = programs.skipped.length;
|
|
112
|
+
for (const [name, calls] of callers) {
|
|
113
|
+
const d = defines.get(name);
|
|
114
|
+
const first = calls[0];
|
|
115
|
+
findings.push({ rule: 'hlasm-provides-called-module', path: d.path, line: d.line,
|
|
116
|
+
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' : ''}` : ''}` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return report('hlasm', { rules: HLASM_RULES, findings, stats, run });
|
|
121
|
+
}
|
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
|
package/lib/sets/web.mjs
CHANGED
|
@@ -62,6 +62,18 @@ export const WEB_RULES = {
|
|
|
62
62
|
impact: 'The TCPIPSERVICE takes connections on a port with SSL off, so everything the session carries - the 3270 data stream, a SOAP body, a credential on a sign-on screen - crosses the network in the clear and can be read or changed in flight',
|
|
63
63
|
remedy: 'Set SSL(YES) and name a certificate on the TCPIPSERVICE, or front it with a TLS-terminating proxy the estate controls and restrict the port to that proxy',
|
|
64
64
|
},
|
|
65
|
+
'web-client-opens-cleartext': {
|
|
66
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-319',
|
|
67
|
+
text: 'A program opens an outbound HTTP connection without TLS',
|
|
68
|
+
impact: 'WEB OPEN asks for HTTP rather than HTTPS, so the request the program sends and the response it reads cross the network in the clear, readable and changeable in flight, unless an AT-TLS policy outside the program encrypts them',
|
|
69
|
+
remedy: 'Open the connection with HTTPS, or through a client URIMAP with SCHEME(HTTPS); where AT-TLS secures it, use a URIMAP with ATTLS(AWARE) so the program fails rather than send in the clear',
|
|
70
|
+
},
|
|
71
|
+
'web-receive-length-exceeds-area': {
|
|
72
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-805',
|
|
73
|
+
text: 'A web receive lets CICS write more bytes than the area it writes into',
|
|
74
|
+
impact: 'CICS copies up to MAXLENGTH bytes of the body into the INTO area, so a body longer than the area, which the other end of the connection chooses, overwrites the storage after it',
|
|
75
|
+
remedy: 'Pass a MAXLENGTH no greater than the INTO area, or LENGTH OF that area',
|
|
76
|
+
},
|
|
65
77
|
'web-link-opens-without-noopener': {
|
|
66
78
|
sev: 'low', evidence: 'construct', cwe: 'CWE-1022',
|
|
67
79
|
text: 'A program writes a link that opens a new window with a handle back to this one',
|
|
@@ -92,6 +104,18 @@ function execOptions(exec) {
|
|
|
92
104
|
return { opts, words };
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
// The options written outside any parentheses, which is where a CVDA keyword such as HTTP or HTTPS
|
|
108
|
+
// stands; a word inside an operand is a data name.
|
|
109
|
+
function keywords(exec) {
|
|
110
|
+
const out = new Set();
|
|
111
|
+
let depth = 0;
|
|
112
|
+
for (const t of exec.toks) {
|
|
113
|
+
if (t.t === 'sep') depth += t.v === '(' ? 1 : t.v === ')' ? -1 : 0;
|
|
114
|
+
else if (t.t === 'word' && depth === 0) out.add(t.u);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
95
119
|
// A header name or value is only knowable when the program wrote it as a literal. NAME(WS-HDR-NAME)
|
|
96
120
|
// names a field whose contents this rule set cannot see, and a rule that guessed would be inventing
|
|
97
121
|
// evidence, so the statement is counted as unreadable and reported as coverage, not as a pass.
|
|
@@ -152,6 +176,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
152
176
|
const stats = {
|
|
153
177
|
filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0,
|
|
154
178
|
webPrograms: 0, headersWritten: 0, headersComputed: 0, htmlResponses: 0, listenersDefined: 0,
|
|
179
|
+
outboundOpens: 0, opensUndecided: 0,
|
|
155
180
|
};
|
|
156
181
|
const where = (x, path) => ({ path: x.file ? relPath(root, x.file) : path, line: x.line || 1 });
|
|
157
182
|
|
|
@@ -161,12 +186,53 @@ export function scanWeb(root, opts = {}) {
|
|
|
161
186
|
const cookies = [];
|
|
162
187
|
let computedHeaders = 0;
|
|
163
188
|
|
|
189
|
+
const opens = []; // every outbound WEB OPEN, and what it says about TLS
|
|
190
|
+
const receives = []; // every receive into an area with a length it can be judged by
|
|
191
|
+
const item = (name) => p.items.find((i) => i.name === name);
|
|
192
|
+
// A variable naming a URIMAP or a host usually holds one literal from its VALUE clause.
|
|
193
|
+
const valueOf = (toks) => {
|
|
194
|
+
if (!toks || !toks.length) return null;
|
|
195
|
+
const lit = literalOf(toks);
|
|
196
|
+
if (lit !== null) return lit.trim();
|
|
197
|
+
const it = toks[0].t === 'word' ? item(toks[0].u) : null;
|
|
198
|
+
const v = it && it.values && it.values.find((x) => x.t === 'lit');
|
|
199
|
+
return v ? String(v.v).trim() : null;
|
|
200
|
+
};
|
|
201
|
+
// MAXLENGTH as a literal, or LENGTH OF an item. A data item's value can change before the
|
|
202
|
+
// command runs, so it is not judged.
|
|
203
|
+
const lengthOf = (toks) => {
|
|
204
|
+
if (!toks || !toks.length) return null;
|
|
205
|
+
if (toks.length === 1 && (toks[0].t === 'num' || /^\d+$/.test(String(toks[0].v)))) return { bytes: Number(toks[0].v), written: String(toks[0].v) };
|
|
206
|
+
if (toks.length >= 3 && toks[0].u === 'LENGTH' && toks[1].u === 'OF' && toks[2].t === 'word') {
|
|
207
|
+
const it = item(toks[2].u);
|
|
208
|
+
return it && it.size ? { bytes: it.size, written: `LENGTH OF ${toks[2].u}` } : null;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
};
|
|
212
|
+
|
|
164
213
|
for (const e of p.execs) {
|
|
165
214
|
if (e.kind !== 'CICS') continue;
|
|
166
215
|
const { opts: o, words: w } = execOptions(e);
|
|
167
216
|
if (!w.includes('WEB') && !w.includes('DOCUMENT')) continue;
|
|
168
217
|
const at = where(e, path);
|
|
169
218
|
|
|
219
|
+
if (w[0] === 'WEB' && w[1] === 'OPEN') {
|
|
220
|
+
const k = keywords(e);
|
|
221
|
+
// SCHEME(HTTPS) and SCHEME(DFHVALUE(HTTPS)) name the CVDA as the keyword does; any other
|
|
222
|
+
// operand is a field whose value is set at run time.
|
|
223
|
+
const named = (o.get('SCHEME') || []).map((t) => t.u);
|
|
224
|
+
const cvda = named.length && named.every((x) => ['DFHVALUE', 'HTTP', 'HTTPS'].includes(x)) ? named.find((x) => x !== 'DFHVALUE') : null;
|
|
225
|
+
opens.push({ at, https: k.has('HTTPS') || cvda === 'HTTPS', http: k.has('HTTP') || cvda === 'HTTP',
|
|
226
|
+
urimap: o.has('URIMAP') ? valueOf(o.get('URIMAP')) : undefined, host: o.has('HOST') ? valueOf(o.get('HOST')) : null });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (w[0] === 'WEB' && (w[1] === 'RECEIVE' || w[1] === 'CONVERSE') && o.has('INTO')) {
|
|
230
|
+
const into = o.get('INTO')[0];
|
|
231
|
+
const area = into && into.t === 'word' ? item(into.u) : null;
|
|
232
|
+
const max = lengthOf(o.get('MAXLENGTH'));
|
|
233
|
+
if (area && area.size && max) receives.push({ at, verb: w[1], area: into.u, size: area.size, max });
|
|
234
|
+
}
|
|
235
|
+
|
|
170
236
|
if (w.includes('WRITE') && w.includes('HTTPHEADER')) {
|
|
171
237
|
const name = literalOf(o.get('HTTPHEADER') || o.get('NAME'));
|
|
172
238
|
const value = literalOf(o.get('VALUE'));
|
|
@@ -228,19 +294,23 @@ export function scanWeb(root, opts = {}) {
|
|
|
228
294
|
});
|
|
229
295
|
const testsMethod = conditions.some((c) => c.some((t) => t.t === 'lit' && HTTP_METHOD.test(String(t.v).trim())));
|
|
230
296
|
|
|
231
|
-
return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod };
|
|
297
|
+
return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod, opens, receives };
|
|
232
298
|
}
|
|
233
299
|
|
|
234
300
|
// The listener that accepts the request these responses answer. It is defined in a CSD rather
|
|
235
301
|
// than in a program, so this reads every file the way lib/sets/priv.mjs does: a DFHCSDUP listing
|
|
236
302
|
// is named whatever the estate named it, and filtering on an extension is how a BMS count once
|
|
237
303
|
// came out as zero.
|
|
304
|
+
// Client URIMAPs come from the same files: WEB OPEN URIMAP(name) takes its scheme from one.
|
|
305
|
+
const urimaps = new Map();
|
|
238
306
|
for (const f of all) {
|
|
239
307
|
let src;
|
|
240
308
|
try { src = tree.text(f).text; } catch { continue; }
|
|
241
|
-
if (!/^\s*DEFINE\s+TCPIPSERVICE\s*\(/im.test(src)) continue;
|
|
309
|
+
if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src)) continue;
|
|
242
310
|
const path = relPath(root, f);
|
|
243
|
-
|
|
311
|
+
const csd = parseCsd(src);
|
|
312
|
+
for (const [name, u] of csd.urimaps) if (!urimaps.has(name)) urimaps.set(name, { ...u, path });
|
|
313
|
+
for (const [name, svc] of csd.tcpipservices) {
|
|
244
314
|
stats.listenersDefined++;
|
|
245
315
|
if (encrypted(svc)) continue;
|
|
246
316
|
const carries = svc.protocol ? `${/^[AEIOU]/i.test(svc.protocol) ? 'an' : 'a'} ${svc.protocol} ` : '';
|
|
@@ -269,9 +339,34 @@ export function scanWeb(root, opts = {}) {
|
|
|
269
339
|
// outbound session or answers a web request has said something worth judging even when it
|
|
270
340
|
// writes no header and sends no page.
|
|
271
341
|
const anything = v.headers.length || v.sends.length || v.cookies.length || v.looksHtml
|
|
272
|
-
|| v.uris.length || v.built.length || (v.webRequest && v.changes.length);
|
|
342
|
+
|| v.uris.length || v.built.length || (v.webRequest && v.changes.length) || v.opens.length || v.receives.length;
|
|
273
343
|
if (!anything) continue;
|
|
274
344
|
stats.webPrograms++;
|
|
345
|
+
|
|
346
|
+
for (const o of v.opens) {
|
|
347
|
+
stats.outboundOpens++;
|
|
348
|
+
const to = o.host ? ` to ${o.host}` : '';
|
|
349
|
+
if (o.https) continue;
|
|
350
|
+
if (o.http) {
|
|
351
|
+
findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
|
|
352
|
+
detail: `${v.id} opens a connection${to} asking for HTTP rather than HTTPS, so what it sends and reads crosses the network in the clear unless an AT-TLS policy outside the program encrypts it` });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const u = o.urimap ? urimaps.get(o.urimap.toUpperCase()) : null;
|
|
356
|
+
if (u && /^HTTP$/i.test(u.scheme || '') && !/^AWARE$/i.test(u.attls || '')) {
|
|
357
|
+
findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
|
|
358
|
+
detail: `${v.id} opens a connection through URIMAP ${o.urimap.toUpperCase()}, which ${u.path}:${u.line} defines with SCHEME(HTTP)${u.host ? ` to ${u.host}` : ''} and no ATTLS(AWARE), so what it sends and reads crosses the network in the clear unless an AT-TLS policy encrypts it` });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// A scheme in a variable, or a URIMAP the tree does not define, is decided where this cannot see.
|
|
362
|
+
if (!u || !u.scheme) stats.opensUndecided++;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const r of v.receives) {
|
|
366
|
+
if (r.max.bytes <= r.size) continue;
|
|
367
|
+
findings.push({ rule: 'web-receive-length-exceeds-area', ...r.at, program: v.id,
|
|
368
|
+
detail: `${v.id} has WEB ${r.verb} pass up to MAXLENGTH(${r.max.written}), ${r.max.bytes} bytes, into ${r.area}, which is ${r.size} bytes` });
|
|
369
|
+
}
|
|
275
370
|
stats.headersWritten += v.headers.length;
|
|
276
371
|
stats.headersComputed += v.computedHeaders;
|
|
277
372
|
|
package/lib/sources.mjs
CHANGED
|
@@ -14,6 +14,9 @@ export const PROGRAM_EXT = ['.cbl', '.cob', '.cobol', '.sqb', '.pco'];
|
|
|
14
14
|
export const COPY_EXT = ['.cpy', '.copy', '.inc'];
|
|
15
15
|
export const JCL_EXT = ['.jcl', '.job', '.proc', '.prc', '.cntl'];
|
|
16
16
|
export const BMS_EXT = ['.bms'];
|
|
17
|
+
// Assembler source. A BMS map or an IMS DBD or PSB is also assembler macros, and is often named .asm;
|
|
18
|
+
// lib/hlasm.mjs tells them apart by the macros they hold.
|
|
19
|
+
export const HLASM_EXT = ['.asm', '.mac', '.mlc', '.hlasm', '.assemble'];
|
|
17
20
|
|
|
18
21
|
const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
19
22
|
|
|
@@ -46,6 +49,11 @@ function kindOfHead(head) {
|
|
|
46
49
|
// A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
|
|
47
50
|
// classified before classifies differently.
|
|
48
51
|
if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
|
|
52
|
+
// Assembler source defines a section or a macro, and declares storage or a base register, in the
|
|
53
|
+
// operation field; either alone is a word a text file can hold. Tested after BMS, which is
|
|
54
|
+
// assembler macros too.
|
|
55
|
+
if (/^[^*\s]*[ \t]+(?:CSECT|RSECT|START|MACRO)(?=\s|$)/m.test(text)
|
|
56
|
+
&& /^[^*\s]*[ \t]+(?:USING|DC|DS|BALR|BASR|STM|STMG|MEND)(?=\s|$)/m.test(text)) return 'hlasm';
|
|
49
57
|
return null;
|
|
50
58
|
}
|
|
51
59
|
|
|
@@ -116,10 +124,12 @@ export const isProgram = byExtOrContent(PROGRAM_EXT, 'program');
|
|
|
116
124
|
export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
|
|
117
125
|
export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
|
|
118
126
|
export const isBms = byExtOrContent(BMS_EXT, 'bms');
|
|
119
|
-
|
|
127
|
+
export const isAssembler = byExtOrContent(HLASM_EXT, 'hlasm');
|
|
128
|
+
// Not BMS or assembler, by either route: the sets that read every source file have not chosen to
|
|
129
|
+
// read maps or assembler.
|
|
120
130
|
export const isSource = (p) => (extname(p)
|
|
121
131
|
? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
|
|
122
|
-
: ![null, 'bms'].includes(sniffKind(p)));
|
|
132
|
+
: ![null, 'bms', 'hlasm'].includes(sniffKind(p)));
|
|
123
133
|
|
|
124
134
|
// `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
|
|
125
135
|
// files on it and no others.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portll/cobolwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"author": "Portll <john@portll.net>",
|