@portll/cobolwork 0.2.117 → 0.2.150
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 +13 -1
- package/THIRD-PARTY-NOTICES.md +3 -2
- package/bin/cobolwork.mjs +16 -1
- package/lib/baseline.mjs +8 -6
- package/lib/bms.mjs +1 -1
- package/lib/capabilities.mjs +2 -1
- package/lib/cards.mjs +66 -0
- package/lib/cics-commands.mjs +136 -125
- package/lib/compliance.mjs +6 -5
- package/lib/control.mjs +295 -6
- package/lib/dataflow.mjs +72 -88
- package/lib/diff.mjs +65 -89
- package/lib/embedded-sql.mjs +110 -0
- package/lib/equivalence.mjs +30 -12
- package/lib/evidence/journal.mjs +11 -2
- package/lib/evidence/record.mjs +3 -1
- package/lib/evidence/slsa.mjs +17 -2
- package/lib/execution.mjs +77 -0
- package/lib/explain.mjs +5 -4
- package/lib/ftp.mjs +136 -0
- package/lib/inventory.mjs +8 -9
- package/lib/ironwork.mjs +4 -6
- package/lib/jcl.mjs +29 -54
- package/lib/kernel/git.mjs +60 -0
- package/lib/kernel/identity.mjs +7 -6
- package/lib/kernel/source-tree.mjs +204 -34
- package/lib/layout.mjs +202 -0
- package/lib/parser.mjs +58 -207
- package/lib/precompile.mjs +5 -88
- package/lib/revision.json +1 -1
- package/lib/sbom.mjs +125 -13
- package/lib/scan.mjs +11 -4
- package/lib/sets/build.mjs +1 -1
- package/lib/sets/cics.mjs +32 -5
- package/lib/sets/copybook.mjs +2 -1
- package/lib/sets/hidden.mjs +4 -8
- package/lib/sets/jcl.mjs +17 -120
- package/lib/sets/priv.mjs +1 -1
- package/lib/sets/recon.mjs +3 -2
- package/lib/sets/semantics.mjs +1 -1
- package/lib/sets/vendor.mjs +1 -1
- package/lib/sets/zowe.mjs +34 -8
- package/lib/site.mjs +17 -7
- package/lib/sources.mjs +82 -38
- package/lib/utilities.mjs +146 -4
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +2438 -0
- package/rules/compliance-dora.json +1 -1
- package/rules/compliance-ffiec.json +1 -1
- package/rules/compliance-nist80053.json +1 -1
- package/schema/cobolwork.policy.schema.json +94 -17
package/lib/sets/recon.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
|
17
17
|
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
18
18
|
import { loadSite, classifyPath, productionQualifierOf, SITE_FILE } from '../site.mjs';
|
|
19
19
|
import { parseJcl, dispositionOf } from '../jcl.mjs';
|
|
20
|
+
import { statementCard } from '../cards.mjs';
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
// The production-dataset rules claim more than the name rule: that the job acts on production, not
|
|
@@ -93,7 +94,7 @@ function routableAddresses(text, jcl) {
|
|
|
93
94
|
if (jcl) {
|
|
94
95
|
for (const line of text.split(/\r?\n/)) {
|
|
95
96
|
if (/^\/\/\*/.test(line)) continue;
|
|
96
|
-
collect(line.
|
|
97
|
+
collect(statementCard(line).text, out);
|
|
97
98
|
}
|
|
98
99
|
return out;
|
|
99
100
|
}
|
|
@@ -160,7 +161,7 @@ function productionTouches(job, qualifiers) {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
export function scanRecon(root, opts = {}) {
|
|
163
|
-
const site = loadSite(root, opts.site || null);
|
|
164
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
164
165
|
const tree = treeFor(root, opts);
|
|
165
166
|
const files = tree.list().filter(inScope(opts))
|
|
166
167
|
.filter((f) => isJcl(f) || isProgram(f) || isCopybook(f));
|
package/lib/sets/semantics.mjs
CHANGED
|
@@ -216,7 +216,7 @@ export function scanSemantics(root, opts = {}) {
|
|
|
216
216
|
truncOptPrograms: 0, truncUnknownPrograms: 0, arithExtendPrograms: 0, beyondEnterprisePrograms: 0, computesRead: 0, computesUnread: 0,
|
|
217
217
|
rangesRead: 0, collatingDeclaredPrograms: 0,
|
|
218
218
|
};
|
|
219
|
-
const site = loadSite(root, opts.site || null).compilerOptions || [];
|
|
219
|
+
const site = loadSite(root, opts.site || null, opts.tree).compilerOptions || [];
|
|
220
220
|
const parsedJcl = [];
|
|
221
221
|
for (const f of all.filter(isJcl)) {
|
|
222
222
|
try { parsedJcl.push({ ...parseJcl(tree.text(f).text, f), file: relPath(root, f) }); } catch { /* the jcl set reports it */ }
|
package/lib/sets/vendor.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export const VENDOR_RULES = {
|
|
|
26
26
|
const evidenceOf = (r) => (r.severity === 'info' ? { evidence: 'context' } : {});
|
|
27
27
|
|
|
28
28
|
export function scanVendor(root, opts = {}) {
|
|
29
|
-
const site = loadSite(root, opts.site || null);
|
|
29
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
30
30
|
const wanted = opts.packs || site.vendorPacks || [];
|
|
31
31
|
const { loaded, refused, problems, caveats } = loadPacks(wanted, {
|
|
32
32
|
allowUnvalidated: !!(opts.allowUnvalidatedPacks ?? site.allowUnvalidatedPacks),
|
package/lib/sets/zowe.mjs
CHANGED
|
@@ -54,9 +54,9 @@ export const ZOWE_RULES = {
|
|
|
54
54
|
},
|
|
55
55
|
'zowe-mcp-unpinned': {
|
|
56
56
|
sev: 'low', evidence: 'construct', cwe: 'CWE-829',
|
|
57
|
-
text: 'A Zowe MCP server is launched
|
|
57
|
+
text: 'A Zowe MCP server is launched from the registry with no pinned version',
|
|
58
58
|
impact: 'Whatever version the registry serves next runs with the agent\'s mainframe credentials',
|
|
59
|
-
remedy: 'Pin the version:
|
|
59
|
+
remedy: 'Pin the version: @zowe/mcp-server@<version>',
|
|
60
60
|
},
|
|
61
61
|
'zowe-mock-credentials': {
|
|
62
62
|
sev: 'med', evidence: 'construct', cwe: 'CWE-798',
|
|
@@ -81,7 +81,12 @@ function stripJsonc(text) {
|
|
|
81
81
|
else if (c === '/' && text[i + 1] === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') out += '\n'; i++; } i++; }
|
|
82
82
|
else if (c === ',') {
|
|
83
83
|
let j = i + 1;
|
|
84
|
-
|
|
84
|
+
for (;;) {
|
|
85
|
+
while (j < text.length && /\s/.test(text[j])) j++;
|
|
86
|
+
if (text[j] === '/' && text[j + 1] === '/') { while (j < text.length && text[j] !== '\n') j++; }
|
|
87
|
+
else if (text[j] === '/' && text[j + 1] === '*') { j += 2; while (j < text.length && !(text[j] === '*' && text[j + 1] === '/')) j++; j += 2; }
|
|
88
|
+
else break;
|
|
89
|
+
}
|
|
85
90
|
if (text[j] !== '}' && text[j] !== ']') out += c;
|
|
86
91
|
} else out += c;
|
|
87
92
|
}
|
|
@@ -109,6 +114,7 @@ function kindOf(rel) {
|
|
|
109
114
|
if (base === 'settings.json' && parent === '.gemini') return 'mcp';
|
|
110
115
|
if (base === 'settings.json' && parent === '.vscode') return 'vscode';
|
|
111
116
|
if (base === 'systems.json' && parts.slice(0, -1).some((p) => /mock/i.test(p))) return 'mock';
|
|
117
|
+
if (base === 'native-config.json') return 'native';
|
|
112
118
|
return null;
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -138,7 +144,10 @@ function teamConfig(config, raw, rel, findings) {
|
|
|
138
144
|
});
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
|
|
147
|
+
// The Zowe MCP server by its package or command, or by the ZOWE_MCP_ variables only it reads.
|
|
148
|
+
const isZoweServer = (s) => s && typeof s === 'object' && (/@zowe\/mcp-server|zowe-mcp/.test([s.command, ...(Array.isArray(s.args) ? s.args : [])].map(String).join(' ')) || Object.keys(s.env && typeof s.env === 'object' ? s.env : {}).some((k) => k.startsWith('ZOWE_MCP_')));
|
|
149
|
+
// Commands that run whatever version of a package the registry serves.
|
|
150
|
+
const REGISTRY_RUNNERS = new Set(['npx', 'bunx', 'pnpx', 'pnpm', 'yarn', 'npm']);
|
|
142
151
|
|
|
143
152
|
function tierOf(server) {
|
|
144
153
|
const args = Array.isArray(server.args) ? server.args.map(String) : [];
|
|
@@ -170,9 +179,9 @@ function mcpConfig(config, raw, rel, findings) {
|
|
|
170
179
|
const t = tierFinding(tierOf(server), rel, lineOf(raw, 'capability-tier', name) || lineOf(raw, 'ZOWE_MCP_CAPABILITY_TIER', name), `server ${name}`);
|
|
171
180
|
if (t) findings.push(t);
|
|
172
181
|
if (String(env.ZOWE_MCP_DATA_MARKING) === '0') findings.push({ rule: 'zowe-mcp-data-marking-off', path: rel, line: lineOf(raw, 'ZOWE_MCP_DATA_MARKING', name), detail: `server ${name} sets ZOWE_MCP_DATA_MARKING to 0` });
|
|
173
|
-
if (String(server.command)
|
|
182
|
+
if (REGISTRY_RUNNERS.has(String(server.command))) {
|
|
174
183
|
const pkg = (Array.isArray(server.args) ? server.args.map(String) : []).find((a) => a.startsWith('@zowe/mcp-server'));
|
|
175
|
-
if (pkg && (pkg === '@zowe/mcp-server' || pkg === '@zowe/mcp-server@latest')) findings.push({ rule: 'zowe-mcp-unpinned', path: rel, line: lineOf(raw, pkg, name), detail: `server ${name} runs ${pkg} through
|
|
184
|
+
if (pkg && (pkg === '@zowe/mcp-server' || pkg === '@zowe/mcp-server@latest')) findings.push({ rule: 'zowe-mcp-unpinned', path: rel, line: lineOf(raw, pkg, name), detail: `server ${name} runs ${pkg} through ${server.command}` });
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
187
|
}
|
|
@@ -188,7 +197,7 @@ function passwordPaths(value, path, out) {
|
|
|
188
197
|
else if (value && typeof value === 'object') {
|
|
189
198
|
for (const [k, v] of Object.entries(value)) {
|
|
190
199
|
const p = path ? `${path}.${k}` : k;
|
|
191
|
-
if (/^password$/i.test(k) && typeof v === 'string' && v) out.push(p);
|
|
200
|
+
if (/^password$/i.test(k) && ((typeof v === 'string' && v && !isReference(v)) || typeof v === 'number')) out.push(p);
|
|
192
201
|
else passwordPaths(v, p, out);
|
|
193
202
|
}
|
|
194
203
|
}
|
|
@@ -199,7 +208,24 @@ function mockSystems(config, raw, rel, findings) {
|
|
|
199
208
|
for (const p of passwordPaths(config, '', [])) findings.push({ rule: 'zowe-mock-credentials', path: rel, line: lineOf(raw, 'password'), detail: `the mock systems file holds a password at ${p}` });
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
|
|
211
|
+
// The server's own configuration, read without assuming its layout: a literal password, and TLS
|
|
212
|
+
// verification turned off, wherever they are.
|
|
213
|
+
function nativeConfig(config, raw, rel, findings) {
|
|
214
|
+
for (const p of passwordPaths(config, '', [])) findings.push({ rule: 'zowe-config-secret-in-clear', path: rel, line: lineOf(raw, 'password'), detail: `the Zowe MCP server configuration holds a password at ${p}` });
|
|
215
|
+
const walk = (v, path) => {
|
|
216
|
+
if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${path}[${i}]`));
|
|
217
|
+
else if (v && typeof v === 'object') {
|
|
218
|
+
for (const [k, x] of Object.entries(v)) {
|
|
219
|
+
const p = path ? `${path}.${k}` : k;
|
|
220
|
+
if (k === 'rejectUnauthorized' && x === false) findings.push({ rule: 'zowe-config-tls-verify-off', path: rel, line: lineOf(raw, 'rejectUnauthorized'), detail: `the Zowe MCP server configuration turns TLS verification off at ${p}` });
|
|
221
|
+
else walk(x, p);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
walk(config, '');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const READERS = { team: teamConfig, mcp: mcpConfig, vscode: vscodeSettings, mock: mockSystems, native: nativeConfig };
|
|
203
229
|
|
|
204
230
|
export function scanZowe(root, opts = {}) {
|
|
205
231
|
const tree = treeFor(root, opts);
|
package/lib/site.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// It is also the only part of cobolwork that is inherently per-customer and inherently recurring:
|
|
10
10
|
// conventions drift, environments are renamed, estates are acquired.
|
|
11
11
|
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
13
|
import { resolvesInside } from './kernel/source-tree.mjs';
|
|
14
14
|
import { printable } from './kernel/printable.mjs';
|
|
15
15
|
|
|
@@ -43,14 +43,24 @@ const EMPTY = {
|
|
|
43
43
|
// A configuration that is absent is not a configuration that says "nothing is production". The
|
|
44
44
|
// difference is the whole point: without it the recon rules have not looked, and a scan must say
|
|
45
45
|
// it has not looked rather than report a clean result.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
// `tree` is the scan's source tree: one held in memory (a git revision) supplies the site file
|
|
47
|
+
// itself, and on disk the file is read from `root`.
|
|
48
|
+
export function loadSite(root, explicit = null, tree = null) {
|
|
49
|
+
const held = !explicit && tree && tree.kind !== 'directory';
|
|
50
|
+
const path = explicit || (held ? resolve(tree.root, SITE_FILE) : join(root, SITE_FILE));
|
|
51
|
+
let read;
|
|
52
|
+
if (held) {
|
|
53
|
+
if (!tree.contains(path)) return { ...EMPTY, path };
|
|
54
|
+
read = () => tree.bytes(path).toString('utf8');
|
|
55
|
+
} else {
|
|
56
|
+
if (!existsSync(path)) return { ...EMPTY, path };
|
|
57
|
+
// A site file in the tree is not read through a link out of it; one the caller names is.
|
|
58
|
+
if (!explicit && !resolvesInside(root, path)) return { ...EMPTY, path, problems: [`${SITE_FILE} is a link that leads outside the tree, so it was not read`] };
|
|
59
|
+
read = () => readFileSync(path, 'utf8');
|
|
60
|
+
}
|
|
51
61
|
|
|
52
62
|
let raw;
|
|
53
|
-
try { raw = JSON.parse(
|
|
63
|
+
try { raw = JSON.parse(read()); } catch (e) {
|
|
54
64
|
return { ...EMPTY, path, problems: [`${SITE_FILE} is not readable as JSON: ${printable(e.message, 120)}`] };
|
|
55
65
|
}
|
|
56
66
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...EMPTY, path, problems: [`${SITE_FILE} is not a JSON object`] };
|
package/lib/sources.mjs
CHANGED
|
@@ -23,41 +23,90 @@ const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
|
23
23
|
// of it and reporting a clean zero, which is the failure this project exists to refuse.
|
|
24
24
|
//
|
|
25
25
|
// So a file with no extension is classified by what is in it. Only the first few kilobytes are
|
|
26
|
-
// read
|
|
27
|
-
//
|
|
26
|
+
// read and only files without an extension are sniffed. The tree a path belongs to classifies it
|
|
27
|
+
// and keeps the answer for as long as the tree lives: this runs over every file in the tree and
|
|
28
|
+
// must not cost a parse, and a cache that outlives the scan grows by one entry per file for the
|
|
29
|
+
// life of the process.
|
|
28
30
|
const SNIFF_BYTES = 4096;
|
|
29
|
-
const sniffed = new Map();
|
|
30
31
|
|
|
31
32
|
// Names that are certainly not source, so the commonest extensionless files cost nothing.
|
|
32
33
|
const NOT_SOURCE = /^(LICEN[CS]E|README|MAKEFILE|DOCKERFILE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS|VERSION|COPYING|Jenkinsfile|Procfile|\..*)$/i;
|
|
34
|
+
const notSource = (path) => NOT_SOURCE.test(path.split(/[\\/]/).pop());
|
|
35
|
+
|
|
36
|
+
function kindOfHead(head) {
|
|
37
|
+
// An EBCDIC member is decoded before it is read, or every test below sees mojibake.
|
|
38
|
+
const text = looksEbcdic(head) ? decodeEbcdic(head) : head.toString('latin1');
|
|
39
|
+
// A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
|
|
40
|
+
// NUL in source is exactly the character an editor or a patch is most likely to eat.
|
|
41
|
+
if (head.includes(0)) return null;
|
|
42
|
+
if (/^\s*\/\/\S*\s+(?:JOB|EXEC|PROC|DD)\b/m.test(text)) return 'jcl';
|
|
43
|
+
if (/\bIDENTIFICATION\s+DIVISION|\bPROGRAM-ID\s*\./i.test(text)) return 'program';
|
|
44
|
+
// A copybook is a data description with no divisions: level numbers and picture clauses.
|
|
45
|
+
if (/^[^*\n]{0,10}\s*\d\d\s+[A-Z][A-Z0-9-]*\s+(?:PIC|PICTURE|OCCURS|REDEFINES|USAGE)\b/im.test(text)) return 'copybook';
|
|
46
|
+
// A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
|
|
47
|
+
// classified before classifies differently.
|
|
48
|
+
if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
// What a member's bytes say it is, for a tree that holds them.
|
|
53
|
+
export const kindOfBytes = (buf) => kindOfHead(buf.subarray(0, SNIFF_BYTES));
|
|
54
|
+
|
|
55
|
+
function sniffDisk(path) {
|
|
56
|
+
if (notSource(path)) return null;
|
|
57
|
+
try {
|
|
58
|
+
const fd = openSync(path, 'r');
|
|
59
|
+
const buf = Buffer.alloc(SNIFF_BYTES);
|
|
60
|
+
let n;
|
|
61
|
+
try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
|
|
62
|
+
return kindOfHead(buf.subarray(0, n));
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A tree's classifier for the paths under its root, remembered for as long as the tree lives.
|
|
67
|
+
const classifiers = new Map();
|
|
68
|
+
|
|
69
|
+
export const classifyUnder = (root, kindOf) => classifiers.set(root.replace(/[\\/]+$/, ''), new WeakRef(kindOf));
|
|
70
|
+
|
|
71
|
+
// The classifier of the deepest live tree whose root the path sits under.
|
|
72
|
+
function classifierFor(path) {
|
|
73
|
+
let best = null;
|
|
74
|
+
let depth = -1;
|
|
75
|
+
for (const [root, ref] of classifiers) {
|
|
76
|
+
if (root.length <= depth || !path.startsWith(root + sep)) continue;
|
|
77
|
+
const kindOf = ref.deref();
|
|
78
|
+
if (!kindOf) { classifiers.delete(root); continue; }
|
|
79
|
+
best = kindOf;
|
|
80
|
+
depth = root.length;
|
|
58
81
|
}
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
return best;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A directory tree's classifier: sniffed from disk once per path.
|
|
86
|
+
export function diskClassifier() {
|
|
87
|
+
const seen = new Map();
|
|
88
|
+
return (path) => {
|
|
89
|
+
if (!seen.has(path)) seen.set(path, sniffDisk(path));
|
|
90
|
+
return seen.get(path);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A held tree's classifier: sniffed from what the tree holds, never from disk.
|
|
95
|
+
export function heldClassifier(bytesOf) {
|
|
96
|
+
const seen = new Map();
|
|
97
|
+
return (path) => {
|
|
98
|
+
if (!seen.has(path)) {
|
|
99
|
+
let kind = null;
|
|
100
|
+
if (!notSource(path)) try { kind = kindOfBytes(bytesOf(path)); } catch { kind = null; }
|
|
101
|
+
seen.set(path, kind);
|
|
102
|
+
}
|
|
103
|
+
return seen.get(path);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sniffKind(path) {
|
|
108
|
+
const kindOf = classifierFor(path);
|
|
109
|
+
return kindOf ? kindOf(path) : sniffDisk(path);
|
|
61
110
|
}
|
|
62
111
|
|
|
63
112
|
// Extension first, because it is free and it is right whenever it is present.
|
|
@@ -72,13 +121,6 @@ export const isSource = (p) => (extname(p)
|
|
|
72
121
|
? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
|
|
73
122
|
: ![null, 'bms'].includes(sniffKind(p)));
|
|
74
123
|
|
|
75
|
-
// For a report that wants to say how much of what it read had no extension to go on.
|
|
76
|
-
export const sniffedCount = () => {
|
|
77
|
-
let n = 0;
|
|
78
|
-
for (const v of sniffed.values()) if (v) n++;
|
|
79
|
-
return n;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
124
|
// `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
|
|
83
125
|
// files on it and no others.
|
|
84
126
|
//
|
|
@@ -159,8 +201,10 @@ export function decodeEbcdic(buf) {
|
|
|
159
201
|
|
|
160
202
|
// Every rule set reads source through here, so an EBCDIC member is decoded once, the same way,
|
|
161
203
|
// and reported as EBCDIC instead of silently parsing as noise.
|
|
162
|
-
export
|
|
163
|
-
|
|
204
|
+
export const readSource = (path) => decodeSource(readFileSync(path));
|
|
205
|
+
|
|
206
|
+
// The same decoding for bytes that did not come from a file: a blob read out of a git revision.
|
|
207
|
+
export function decodeSource(buf) {
|
|
164
208
|
if (looksEbcdic(buf)) return { text: decodeEbcdic(buf), encoding: 'ebcdic' };
|
|
165
209
|
return { text: buf.toString('latin1'), encoding: 'latin1' };
|
|
166
210
|
}
|
package/lib/utilities.mjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// statements are not in the job - SYSIN naming a library member - says so, because its copies are
|
|
9
9
|
// then only the ones its DD names decide on their own.
|
|
10
10
|
|
|
11
|
+
import { ftpSession } from './ftp.mjs';
|
|
12
|
+
import { statementCard } from './cards.mjs';
|
|
13
|
+
|
|
11
14
|
const upper = (s) => String(s).toUpperCase();
|
|
12
15
|
|
|
13
16
|
// The DDs of a step by name, each followed by the datasets concatenated to it.
|
|
@@ -93,7 +96,7 @@ function commands(lines) {
|
|
|
93
96
|
let comment = false;
|
|
94
97
|
for (const { line, text } of lines) {
|
|
95
98
|
let t = '';
|
|
96
|
-
const s = text.
|
|
99
|
+
const s = statementCard(text).text;
|
|
97
100
|
for (let i = 0; i < s.length; i++) {
|
|
98
101
|
if (comment) { if (s[i] === '*' && s[i + 1] === '/') { comment = false; i++; } continue; }
|
|
99
102
|
if (s[i] === '/' && s[i + 1] === '*') { comment = true; i++; continue; }
|
|
@@ -189,11 +192,42 @@ function sortCopies(step, dds) {
|
|
|
189
192
|
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i211.htm
|
|
190
193
|
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/import.htm
|
|
191
194
|
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2221.htm
|
|
195
|
+
// PRINT reads INFILE(ddname) or INDATASET(entryname) and writes the listing to OUTFILE(ddname),
|
|
196
|
+
// SYSPRINT when it names none, so a production data set printed to SYSOUT leaves by that DD.
|
|
197
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/print.htm
|
|
198
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2244.htm
|
|
199
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2245.htm
|
|
200
|
+
// ALTER entryname NEWNAME(newname), abbreviated NEWNM, gives the entry a new name, so the data the
|
|
201
|
+
// old name held is read under the new one. A generic name (both must then be generic) or a member
|
|
202
|
+
// of a partitioned data set leaves the data set where it was, and is not followed.
|
|
203
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2052.htm
|
|
204
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2055.htm
|
|
205
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.3.0/com.ibm.zos.v2r3.idai200/da6i2056.htm
|
|
206
|
+
function renamed(cmd) {
|
|
207
|
+
const m = /^ALTER\s+'?([^\s(),'*]+)'?(?=[\s,]|$)/i.exec(cmd.text);
|
|
208
|
+
const to = m && param(cmd.text, ['NEWNAME', 'NEWNM']);
|
|
209
|
+
if (!to || /[*(]/.test(to[0])) return null;
|
|
210
|
+
return { from: [{ dsn: upper(m[1]) }], to: [{ dsn: to[0] }], line: cmd.line };
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
function reproCopies(step, dds) {
|
|
193
214
|
const { lines, unread } = cards(dds.get('SYSIN'));
|
|
194
|
-
const notes = unread ? [{ line: step.line, text: `${who(step)} runs IDCAMS with commands in ${unread}, which this reader cannot see, so any REPRO, EXPORT or
|
|
215
|
+
const notes = unread ? [{ line: step.line, text: `${who(step)} runs IDCAMS with commands in ${unread}, which this reader cannot see, so any REPRO, EXPORT, IMPORT, PRINT or rename it runs is not known` }] : [];
|
|
195
216
|
const moves = [];
|
|
196
217
|
for (const cmd of commands(lines)) {
|
|
218
|
+
if (/^ALTER\b/i.test(cmd.text)) {
|
|
219
|
+
const move = renamed(cmd);
|
|
220
|
+
if (move) moves.push(move);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (/^PRINT\b/i.test(cmd.text)) {
|
|
224
|
+
const input = param(cmd.text, ['INFILE', 'IFILE']);
|
|
225
|
+
const ds = !input && param(cmd.text, ['INDATASET', 'IDS']);
|
|
226
|
+
const out = param(cmd.text, ['OUTFILE', 'OFILE']);
|
|
227
|
+
const from = input ? [{ dd: input[0] }] : ds ? [{ dsn: ds[0] }] : [];
|
|
228
|
+
moves.push({ from, to: [{ dd: out ? out[0] : 'SYSPRINT' }], line: cmd.line });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
197
231
|
const verb = /^(REPRO|EXPORT|EXP|IMPORT)\b/i.exec(cmd.text);
|
|
198
232
|
if (!verb) continue;
|
|
199
233
|
const entry = /^EXP/i.test(verb[1]) && /^\S+\s+([^\s(),]+)/.exec(cmd.text);
|
|
@@ -293,7 +327,7 @@ function icetoolCopies(step, dds) {
|
|
|
293
327
|
const statements = [];
|
|
294
328
|
let cur = null;
|
|
295
329
|
for (const { line, text } of lines) {
|
|
296
|
-
const s = text.
|
|
330
|
+
const s = statementCard(text).text;
|
|
297
331
|
if (!cur && (/^\*/.test(s) || !s.trim())) continue;
|
|
298
332
|
const tokens = s.trim().split(/\s+/);
|
|
299
333
|
const hyphen = tokens.findIndex((t) => t.endsWith('-'));
|
|
@@ -321,6 +355,21 @@ function icetoolCopies(step, dds) {
|
|
|
321
355
|
return { moves, notes };
|
|
322
356
|
}
|
|
323
357
|
|
|
358
|
+
// An FTP step's transfers, from the session lib/ftp.mjs reads: what PUt, MPut and APpend send leaves
|
|
359
|
+
// for the remote host, and what Get and MGet fetch is written locally. The remote end names the host
|
|
360
|
+
// and file, and the FILETYPE a SIte gave it.
|
|
361
|
+
function ftpCopies(step, dds) {
|
|
362
|
+
const s = ftpSession(step, dds);
|
|
363
|
+
const localEnds = (x) => (x.dd ? [{ dd: x.dd }] : x.dsns.map((dsn) => ({ dsn })));
|
|
364
|
+
const remote = (name, x) => ({ remote: { host: s.host, name, ...(x.filetype ? { filetype: x.filetype } : {}) } });
|
|
365
|
+
const moves = [
|
|
366
|
+
...s.sends.map((x) => ({ from: localEnds(x), to: [remote(x.foreign, x)], line: x.line })),
|
|
367
|
+
...s.gets.map((x) => ({ from: [remote(x.name, x)], to: localEnds(x), line: x.line })),
|
|
368
|
+
].sort((a, b) => a.line - b.line);
|
|
369
|
+
const notes = s.inputUnread ? [{ line: step.line, text: `${who(step)} runs FTP with subcommands in ${s.inputUnread}, which this reader cannot see, so what it sends or fetches is not known` }] : [];
|
|
370
|
+
return { moves, notes };
|
|
371
|
+
}
|
|
372
|
+
|
|
324
373
|
const TABLE = [
|
|
325
374
|
[['IEBGENER', 'ICEGENER', 'IEBPTPCH', 'IEBUPDTE'], generCopy],
|
|
326
375
|
[['SORT', 'ICEMAN', 'DFSORT', 'SYNCSORT'], sortCopies],
|
|
@@ -328,6 +377,7 @@ const TABLE = [
|
|
|
328
377
|
[['IEBCOPY'], iebcopyCopies],
|
|
329
378
|
[['ADRDSSU'], dssCopies],
|
|
330
379
|
[['ICETOOL'], icetoolCopies],
|
|
380
|
+
[['FTP'], ftpCopies],
|
|
331
381
|
];
|
|
332
382
|
const BY_PROGRAM = new Map(TABLE.flatMap(([names, fn]) => names.map((n) => [n, fn])));
|
|
333
383
|
|
|
@@ -346,7 +396,7 @@ export function copiesOf(step) {
|
|
|
346
396
|
// An end naming a DD stands for every dataset concatenated under that name. A DD the step does
|
|
347
397
|
// not define is kept, with no dataset: a statement naming it still says where the data went.
|
|
348
398
|
const expand = (end) => {
|
|
349
|
-
if (!end.dd) return [{ dd: null, dsn: end.dsn }];
|
|
399
|
+
if (!end.dd) return [{ dd: null, dsn: end.dsn ?? null, ...(end.remote ? { remote: end.remote } : {}) }];
|
|
350
400
|
const group = dds.get(end.dd);
|
|
351
401
|
if (!group) return [{ dd: end.dd, dsn: null }];
|
|
352
402
|
return group.map((dd) => ({ dd: end.dd, dsn: dd.dsn || null, ...(dd.inStream ? { inStream: true } : {}) }));
|
|
@@ -366,3 +416,95 @@ export function copiesOf(step) {
|
|
|
366
416
|
}
|
|
367
417
|
return { copies, uses, notes };
|
|
368
418
|
}
|
|
419
|
+
|
|
420
|
+
// A TSO command's operands: KEYWORD(value) with the value's parentheses and quotes kept, a quoted
|
|
421
|
+
// string, or a bare word, split on blanks and commas. Blanks may stand between a keyword and its
|
|
422
|
+
// parenthesis, as in PROGRAM (GETTAB).
|
|
423
|
+
function tsoOperands(text) {
|
|
424
|
+
const out = [];
|
|
425
|
+
let i = 0;
|
|
426
|
+
while (i < text.length) {
|
|
427
|
+
if (/[\s,]/.test(text[i])) { i++; continue; }
|
|
428
|
+
const start = i;
|
|
429
|
+
let depth = 0, quoted = false;
|
|
430
|
+
for (; i < text.length; i++) {
|
|
431
|
+
const c = text[i];
|
|
432
|
+
if (c === "'") quoted = !quoted;
|
|
433
|
+
else if (quoted) continue;
|
|
434
|
+
else if (c === '(') depth++;
|
|
435
|
+
else if (c === ')') depth = Math.max(0, depth - 1);
|
|
436
|
+
else if (depth === 0 && /[\s,]/.test(c)) {
|
|
437
|
+
const next = /^\s*\(/.exec(text.slice(i));
|
|
438
|
+
if (next && /^[A-Z][A-Z0-9$#@]*$/i.test(text.slice(start, i))) { text = text.slice(0, i) + text.slice(i + next[0].length - 1); i--; continue; }
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const tok = text.slice(start, i);
|
|
443
|
+
const kw = /^([A-Z][A-Z0-9$#@]*)\((.*)\)$/is.exec(tok);
|
|
444
|
+
out.push(kw ? { key: upper(kw[1]), value: kw[2] } : { word: tok });
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const unquote = (s) => s.replace(/^'(.*)'$/s, '$1').replace(/''/g, "'");
|
|
450
|
+
|
|
451
|
+
// ALLOCATE's keywords, abbreviated as far as IBM's rule allows: to any leading part that no other
|
|
452
|
+
// ALLOCATE keyword shares. FILE shares F, FI and FIL with FILEDATA, FCB, FLASH and FORMS, and DSNAME
|
|
453
|
+
// shares DS and DSN with DSNTYPE and DSORG.
|
|
454
|
+
const ALLOC_DATASET = /^(DA|DAT|DATA|DATAS|DATASE|DATASET|DSNA|DSNAM|DSNAME)$/;
|
|
455
|
+
const ALLOC_FILE = /^(FILE|DD|DDN|DDNA|DDNAM|DDNAME)$/;
|
|
456
|
+
const ALLOC_STATUS = new Set(['OLD', 'SHR', 'MOD', 'NEW']);
|
|
457
|
+
|
|
458
|
+
// What a TSO batch step (IKJEFT01, IKJEFT1A, IKJEFT1B) does in its commands, from the step's PARM,
|
|
459
|
+
// which TSO runs as its first command, and the in-stream SYSTSIN:
|
|
460
|
+
// allocations: ALLOCATE's DDs, which the programs the step runs open as if the JCL defined them.
|
|
461
|
+
// A data set name in quotes is the whole name; one without has the user's prefix added, which
|
|
462
|
+
// the job does not say, so it is kept as written with no dsn.
|
|
463
|
+
// runs: the programs the step starts, by TSO CALL, whose parameter string reaches the program as
|
|
464
|
+
// EXEC PARM does, and by the DSN subcommand RUN PROGRAM, with PARMS.
|
|
465
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.1.0/com.ibm.zos.v2r1.ikjc500/allocsyn.htm
|
|
466
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_3.2.0/com.ibm.zos.v3r2.ikjc500/alloccmd.htm
|
|
467
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.ikjp100/ikjp100114.htm
|
|
468
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.5.0/com.ibm.zos.v2r5.ikjc200/ikj2o20039.htm
|
|
469
|
+
// https://www.ibm.com/docs/api/v1/content/SSLTBW_3.1.0/com.ibm.zos.v3r1.ikjc500/ikj2l2_CALL_command_operands.htm
|
|
470
|
+
// https://www.ibm.com/docs/api/v1/content/SSEPEK_13.0.0/comref/src/tpc/db2z_cmd_run.html
|
|
471
|
+
export const TSO_BATCH = new Set(['IKJEFT01', 'IKJEFT1A', 'IKJEFT1B']);
|
|
472
|
+
|
|
473
|
+
export function tsoCommands(step) {
|
|
474
|
+
const out = { allocations: [], runs: [], notes: [] };
|
|
475
|
+
if (!step.pgm || !TSO_BATCH.has(upper(step.pgm))) return out;
|
|
476
|
+
const { lines, unread } = cards(ddGroups(step).get('SYSTSIN'));
|
|
477
|
+
if (unread) out.notes.push({ line: step.line, text: `${who(step)} runs TSO with commands in ${unread}, which this reader cannot see, so any ALLOCATE, CALL or RUN it runs is not known` });
|
|
478
|
+
// PARM keeps JCL's doubled apostrophes; TSO sees one.
|
|
479
|
+
const cmds = [...(step.parm ? [{ line: step.line, text: step.parm.replace(/''/g, "'") }] : []), ...commands(lines)];
|
|
480
|
+
for (const cmd of cmds) {
|
|
481
|
+
const verb = upper(/^\S*/.exec(cmd.text)[0]);
|
|
482
|
+
const ops = tsoOperands(cmd.text.slice(verb.length));
|
|
483
|
+
if (verb === 'ALLOC' || verb === 'ALLOCATE') {
|
|
484
|
+
const file = ops.find((o) => o.key && ALLOC_FILE.test(o.key));
|
|
485
|
+
if (!file) continue;
|
|
486
|
+
const names = ops.find((o) => o.key && ALLOC_DATASET.test(o.key));
|
|
487
|
+
const words = ops.filter((o) => o.word).map((o) => upper(o.word));
|
|
488
|
+
const sysout = ops.find((o) => o.key === 'SYSOUT') || (words.includes('SYSOUT') ? { value: '' } : null);
|
|
489
|
+
const datasets = names ? tsoOperands(names.value).map((o) => o.word).filter((w) => w && w !== '*') : [];
|
|
490
|
+
if (!datasets.length && !sysout) continue;
|
|
491
|
+
out.allocations.push({
|
|
492
|
+
dd: upper(unquote(file.value)), line: cmd.line,
|
|
493
|
+
status: words.find((w) => ALLOC_STATUS.has(w)) || null,
|
|
494
|
+
sysout: sysout ? upper(sysout.value) || '*' : null,
|
|
495
|
+
datasets: datasets.map((w) => ({ written: w, dsn: /^'.*'$/.test(w) ? upper(unquote(w)) : null })),
|
|
496
|
+
});
|
|
497
|
+
} else if (verb === 'CALL') {
|
|
498
|
+
const target = ops[0]?.word || (ops[0]?.key ? `${ops[0].key}(${ops[0].value})` : '');
|
|
499
|
+
const member = /\(([A-Z0-9$#@]{1,8})\)'?$/i.exec(target);
|
|
500
|
+
const parm = ops[1]?.word && /^'.*'$/s.test(ops[1].word) ? unquote(ops[1].word) : null;
|
|
501
|
+
out.runs.push({ program: member ? upper(member[1]) : 'TEMPNAME', parm, line: cmd.line, via: 'TSO CALL' });
|
|
502
|
+
} else if (verb === 'RUN') {
|
|
503
|
+
const program = ops.find((o) => o.key === 'PROGRAM');
|
|
504
|
+
if (!program || !/^[A-Z0-9$#@]{1,8}$/i.test(program.value.trim())) continue;
|
|
505
|
+
const parms = ops.find((o) => o.key === 'PARMS');
|
|
506
|
+
out.runs.push({ program: upper(program.value.trim()), parm: parms ? unquote(parms.value.trim()) : null, line: cmd.line, via: 'DSN RUN' });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portll/cobolwork",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.150",
|
|
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>",
|