@portll/cobolwork 0.0.1 → 0.2.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSING.md +93 -0
- package/NOTICE +9 -0
- package/README.md +323 -3
- package/THIRD-PARTY-NOTICES.md +118 -0
- package/bin/cobolwork.mjs +354 -0
- package/lib/advisories.mjs +133 -0
- package/lib/baseline.mjs +154 -0
- package/lib/bms.mjs +453 -0
- package/lib/build.mjs +402 -0
- package/lib/capabilities.mjs +79 -0
- package/lib/cics-commands.mjs +281 -0
- package/lib/compliance.mjs +81 -0
- package/lib/consequence.mjs +139 -0
- package/lib/control.mjs +1515 -0
- package/lib/csd.mjs +77 -0
- package/lib/dataflow.mjs +1506 -0
- package/lib/diff.mjs +344 -0
- package/lib/explain.mjs +145 -0
- package/lib/gate.mjs +383 -0
- package/lib/index.mjs +6 -0
- package/lib/inventory.mjs +79 -0
- package/lib/jcl.mjs +478 -0
- package/lib/kernel/findings.mjs +94 -0
- package/lib/kernel/identity.mjs +216 -0
- package/lib/kernel/memory.mjs +217 -0
- package/lib/kernel/printable.mjs +6 -0
- package/lib/kernel/registry.mjs +79 -0
- package/lib/kernel/ruleset.mjs +72 -0
- package/lib/kernel/source-tree.mjs +159 -0
- package/lib/kev.mjs +27 -0
- package/lib/options.mjs +512 -0
- package/lib/packs.mjs +148 -0
- package/lib/parser.mjs +2055 -0
- package/lib/policy.mjs +163 -0
- package/lib/precompile-cics.mjs +169 -0
- package/lib/precompile.mjs +544 -0
- package/lib/reach.mjs +122 -0
- package/lib/revision.json +1 -0
- package/lib/revision.mjs +89 -0
- package/lib/sarif.mjs +222 -0
- package/lib/scan.mjs +272 -0
- package/lib/sets/build.mjs +234 -0
- package/lib/sets/cics.mjs +306 -0
- package/lib/sets/compile.mjs +187 -0
- package/lib/sets/copybook.mjs +174 -0
- package/lib/sets/flow.mjs +487 -0
- package/lib/sets/hidden.mjs +216 -0
- package/lib/sets/jcl.mjs +440 -0
- package/lib/sets/log.mjs +406 -0
- package/lib/sets/opaque.mjs +102 -0
- package/lib/sets/priv.mjs +322 -0
- package/lib/sets/recon.mjs +267 -0
- package/lib/sets/vendor.mjs +117 -0
- package/lib/sets/web.mjs +327 -0
- package/lib/site.mjs +164 -0
- package/lib/sources.mjs +156 -0
- package/lib/tui/app.mjs +325 -0
- package/lib/tui/keys.mjs +39 -0
- package/lib/tui/model.mjs +96 -0
- package/lib/tui/run.mjs +38 -0
- package/lib/tui/screen.mjs +59 -0
- package/lib/tui/terminal.mjs +46 -0
- package/lib/utilities.mjs +296 -0
- package/lib/version.mjs +15 -0
- package/lib/words.mjs +318 -0
- package/package.json +45 -6
- package/rules/advisories.json +264 -0
- package/rules/compliance-dora.json +2151 -0
- package/rules/compliance-ffiec.json +2134 -0
- package/rules/compliance-nist80053.json +2134 -0
- package/rules/gitleaks-mainframe.toml +57 -0
- package/rules/kev-ids.json +1729 -0
- package/rules/packs/broadcom.json +124 -0
- package/rules/packs/connectdirect.json +116 -0
- package/rules/packs/controlm.json +114 -0
- package/rules/system-layouts.json +28 -0
- package/schema/cobolwork-coverage.schema.json +65 -0
- package/schema/cobolwork.policy.schema.json +54 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A precompiler for embedded SQL and CICS: each EXEC SQL or EXEC CICS block becomes the CALL it
|
|
3
|
+
// amounts to, named by its verb, with the data it sends BY CONTENT and the data it receives BY
|
|
4
|
+
// REFERENCE, so a compiler accepts the program and a reader sees which fields the statement writes.
|
|
5
|
+
// docs/spec/precompile.md. The SQL forms are Db2's, from IBM's SQL reference, and the CICS commands
|
|
6
|
+
// are translated by lib/precompile-cics.mjs; nothing is taken from another precompiler. The CALLs
|
|
7
|
+
// name routines nothing implements: the goal is a program that compiles.
|
|
8
|
+
import { detectFormat, expandTabs } from './parser.mjs';
|
|
9
|
+
import { EIB_LAYOUT } from './words.mjs';
|
|
10
|
+
import { builtinValue, translateCics, eibCopybook, CONSTANT_COPYBOOKS, constantsCopybook, symbolicMapCopybook } from './precompile-cics.mjs';
|
|
11
|
+
|
|
12
|
+
const isFree = (format) => format === 'free' || format === 'terminal';
|
|
13
|
+
|
|
14
|
+
// Where a line's code sits: fixed format keeps columns 8 to 72, free format the whole line.
|
|
15
|
+
function codeArea(line, format) {
|
|
16
|
+
if (isFree(format)) return { start: 0, end: line.length, comment: /^\s*\*>/.test(line) };
|
|
17
|
+
const indicator = line[6];
|
|
18
|
+
return { start: Math.min(7, line.length), end: Math.min(72, line.length), comment: indicator === '*' || indicator === '/' || /^\s{0,6}\*>/.test(line.slice(0, 9)) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The line and column just past SQL where EXEC ends a line and SQL opens the next code line, as
|
|
22
|
+
// Db2's coprocessor allows.
|
|
23
|
+
function sqlOnNextLine(lines, from, format) {
|
|
24
|
+
for (let li = from + 1; li < lines.length; li++) {
|
|
25
|
+
const { start, end, comment } = codeArea(lines[li], format);
|
|
26
|
+
if (comment) continue;
|
|
27
|
+
const code = lines[li].slice(start, end);
|
|
28
|
+
if (!code.trim()) continue;
|
|
29
|
+
const m = /^(\s*)SQL\b/i.exec(code);
|
|
30
|
+
return m ? { line: li, col: start + m[0].length } : null;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every EXEC SQL or EXEC CICS ... END-EXEC in the code areas, outside literals and comments: its
|
|
36
|
+
// kind, first line and column, its last line and the column after END-EXEC, and its text with the
|
|
37
|
+
// lines joined. Also every DFHRESP(name) and DFHVALUE(name) outside the blocks, which the translator
|
|
38
|
+
// replaces with the number it stands for. EXEC DLI is left as written.
|
|
39
|
+
function findBlocks(lines, format) {
|
|
40
|
+
const blocks = [];
|
|
41
|
+
const builtins = [];
|
|
42
|
+
let open = null;
|
|
43
|
+
let quote = null;
|
|
44
|
+
let resume = null;
|
|
45
|
+
for (let li = 0; li < lines.length; li++) {
|
|
46
|
+
const { start, end, comment } = codeArea(lines[li], format);
|
|
47
|
+
if (comment) continue;
|
|
48
|
+
const line = lines[li];
|
|
49
|
+
let c = start;
|
|
50
|
+
if (resume && resume.line === li) { c = resume.col; resume = null; }
|
|
51
|
+
// A literal left open runs on only into a fixed-format continuation line, after its first quote.
|
|
52
|
+
if (quote) {
|
|
53
|
+
const at = !isFree(format) && line[6] === '-' ? line.indexOf(quote, start) : -1;
|
|
54
|
+
if (at < 0 || at >= end) quote = null;
|
|
55
|
+
else c = at + 1;
|
|
56
|
+
}
|
|
57
|
+
for (; c < end; c++) {
|
|
58
|
+
const ch = line[c];
|
|
59
|
+
if (quote) { if (ch === quote) quote = null; if (open) open.text += ch; continue; }
|
|
60
|
+
if (!open && (ch === '"' || ch === "'")) { quote = ch; continue; }
|
|
61
|
+
if (ch === '*' && line[c + 1] === '>') break;
|
|
62
|
+
const rest = line.slice(c, end);
|
|
63
|
+
if (!open) {
|
|
64
|
+
if (c !== start && !/[\s.]/.test(line[c - 1])) {
|
|
65
|
+
const f = !/[A-Za-z0-9-]/.test(line[c - 1]) && /^DFH(RESP|VALUE)\s*\(\s*([A-Z0-9-]+)\s*\)/i.exec(rest);
|
|
66
|
+
if (f) { builtins.push({ line: li, col: c, length: f[0].length, fn: f[1].toUpperCase(), name: f[2].toUpperCase() }); c += f[0].length - 1; }
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const m = /^EXEC\s+(SQL|CICS)\b/i.exec(rest);
|
|
70
|
+
if (m) { open = { kind: m[1].toUpperCase(), line: li, col: c, text: '' }; c += m[0].length - 1; continue; }
|
|
71
|
+
const split = /^EXEC\s*$/i.test(rest) && sqlOnNextLine(lines, li, format);
|
|
72
|
+
if (split) { open = { kind: 'SQL', line: li, col: c, text: '' }; resume = split; break; }
|
|
73
|
+
const f = /^DFH(RESP|VALUE)\s*\(\s*([A-Z0-9-]+)\s*\)/i.exec(rest);
|
|
74
|
+
if (f) { builtins.push({ line: li, col: c, length: f[0].length, fn: f[1].toUpperCase(), name: f[2].toUpperCase() }); c += f[0].length - 1; }
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === '"' || ch === "'") { quote = ch; open.text += ch; continue; }
|
|
78
|
+
const e = /^END-EXEC\b/i.exec(rest);
|
|
79
|
+
if (e) { blocks.push({ ...open, endLine: li, endCol: c + e[0].length, text: open.text.trim() }); open = null; c += e[0].length - 1; continue; }
|
|
80
|
+
open.text += ch;
|
|
81
|
+
}
|
|
82
|
+
if (open) open.text += ' ';
|
|
83
|
+
}
|
|
84
|
+
return { blocks, builtins };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The words, host variables and literals of a statement. A host variable is :NAME, qualified as
|
|
88
|
+
// :GROUP.NAME, and may carry an indicator variable, :NAME:IND or :NAME INDICATOR :IND.
|
|
89
|
+
function tokenize(sql) {
|
|
90
|
+
const toks = [];
|
|
91
|
+
const re = /'(?:[^']|'')*'|"(?:[^"]|"")*"|:\s*[A-Z0-9_$#@-]+(?:\.[A-Z0-9_$#@-]+)*|[A-Z0-9_$#@-]+|[(),=<>+*/;.]/gi;
|
|
92
|
+
for (const m of sql.matchAll(re)) {
|
|
93
|
+
const t = m[0];
|
|
94
|
+
if (t.startsWith(':')) toks.push({ host: t.slice(1).trim().toUpperCase() });
|
|
95
|
+
else if (/^['"]/.test(t)) toks.push({ lit: t });
|
|
96
|
+
else toks.push({ word: t.toUpperCase() });
|
|
97
|
+
}
|
|
98
|
+
return toks;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// How many subscripts a host variable needs: one for each OCCURS on it or on a group holding it.
|
|
102
|
+
function subscriptCounter(items = []) {
|
|
103
|
+
const byName = new Map();
|
|
104
|
+
for (const it of items) byName.set(it.name, [...(byName.get(it.name) || []), it]);
|
|
105
|
+
const holds = (it, group) => { for (let p = it.parent; p; p = p.parent) if (p.name === group) return true; return false; };
|
|
106
|
+
return (host) => {
|
|
107
|
+
const [name, ...groups] = host.split('.').reverse();
|
|
108
|
+
const item = (byName.get(name) || []).find((it) => groups.every((g) => holds(it, g)));
|
|
109
|
+
let n = 0;
|
|
110
|
+
for (let p = item; p; p = p.parent) if (p.occursDeclared) n++;
|
|
111
|
+
return n;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// :GROUP.NAME is NAME OF GROUP in COBOL. A host-variable array passes its first element, which is
|
|
116
|
+
// where the array starts.
|
|
117
|
+
const cobolName = (host, subscripts = 0) => host.split('.').reverse().join(' OF ') + (subscripts ? ` (${Array(subscripts).fill(1).join(' ')})` : '');
|
|
118
|
+
|
|
119
|
+
// Clauses that end an INTO list.
|
|
120
|
+
const AFTER_INTO = new Set(['FROM', 'WHERE', 'GROUP', 'HAVING', 'ORDER', 'FETCH', 'FOR', 'WITH', 'OPTIMIZE', 'QUERYNO', 'SKIP', 'UNION', 'USING', 'VALUES']);
|
|
121
|
+
|
|
122
|
+
// Which host variables a statement reads and which it writes, as Db2's SQL reference gives them. A
|
|
123
|
+
// variable both read and written is in both lists. INTO a host variable writes it (INSERT INTO and
|
|
124
|
+
// MERGE INTO name a table); so do SET's targets, GET DIAGNOSTICS' and ASSOCIATE LOCATORS' list. A
|
|
125
|
+
// procedure's argument that is a lone variable may be IN, OUT or INOUT, which only the server knows.
|
|
126
|
+
function roles(toks) {
|
|
127
|
+
const sending = [];
|
|
128
|
+
const receiving = [];
|
|
129
|
+
const read = (h) => { if (!sending.includes(h)) sending.push(h); };
|
|
130
|
+
const write = (h) => { if (!receiving.includes(h)) receiving.push(h); };
|
|
131
|
+
const verb = toks.find((t) => t.word)?.word;
|
|
132
|
+
if (verb === 'CALL') return callRoles(toks, read, write), { sending, receiving };
|
|
133
|
+
// DESCRIBE and PREPARE read the SQLDA's SQLN and write the rest of it.
|
|
134
|
+
const intoBoth = verb === 'DESCRIBE' || verb === 'PREPARE';
|
|
135
|
+
let into = false;
|
|
136
|
+
let assigning = verb === 'SET';
|
|
137
|
+
let depth = 0;
|
|
138
|
+
for (let i = 0; i < toks.length; i++) {
|
|
139
|
+
const t = toks[i];
|
|
140
|
+
const next = toks[i + 1];
|
|
141
|
+
if (t.word === '(') depth++;
|
|
142
|
+
else if (t.word === ')') depth--;
|
|
143
|
+
else if (verb === 'SET' && t.word === '=' && depth === 0) assigning = false;
|
|
144
|
+
else if (verb === 'SET' && t.word === ',' && depth === 0) assigning = true;
|
|
145
|
+
if (t.word === 'INTO' && next && (next.host || next.word === 'DESCRIPTOR')) { into = true; continue; }
|
|
146
|
+
if (into && t.word && AFTER_INTO.has(t.word)) into = false;
|
|
147
|
+
if (t.word === 'DESCRIPTOR' && next?.host) {
|
|
148
|
+
// FETCH ... INTO DESCRIPTOR and USING DESCRIPTOR are synonyms: the program fills the SQLDA and Db2 writes where it points.
|
|
149
|
+
if (verb === 'FETCH' || intoBoth) { read(next.host); write(next.host); } else read(next.host);
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!t.host) continue;
|
|
154
|
+
const written = into || assigning
|
|
155
|
+
|| (verb === 'GET' && next?.word === '=')
|
|
156
|
+
|| (verb === 'ASSOCIATE' && !toks.slice(0, i).some((x) => x.word === 'WITH'));
|
|
157
|
+
if (!written || (into && intoBoth)) read(t.host);
|
|
158
|
+
if (written) write(t.host);
|
|
159
|
+
}
|
|
160
|
+
return { sending, receiving };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// CALL :name reads the name. Each argument that is a lone variable, with or without its indicator,
|
|
164
|
+
// is read and may be written; any other argument is an expression, IN only.
|
|
165
|
+
function callRoles(toks, read, write) {
|
|
166
|
+
let depth = 0;
|
|
167
|
+
let arg = [];
|
|
168
|
+
const close = () => {
|
|
169
|
+
const hosts = arg.filter((t) => t.host);
|
|
170
|
+
const lone = hosts.length && arg.every((t) => t.host || t.word === 'INDICATOR');
|
|
171
|
+
for (const h of hosts) { read(h.host); if (lone) write(h.host); }
|
|
172
|
+
arg = [];
|
|
173
|
+
};
|
|
174
|
+
for (let i = 1; i < toks.length; i++) {
|
|
175
|
+
const t = toks[i];
|
|
176
|
+
if (t.word === 'DESCRIPTOR' && toks[i + 1]?.host) { read(toks[i + 1].host); write(toks[i + 1].host); i++; continue; }
|
|
177
|
+
if (t.word === '(') { if (depth++ > 0) arg.push(t); continue; }
|
|
178
|
+
if (t.word === ')') { if (--depth > 0) arg.push(t); else close(); continue; }
|
|
179
|
+
if (depth === 0) { if (t.host) read(t.host); continue; }
|
|
180
|
+
if (depth === 1 && t.word === ',') { close(); continue; }
|
|
181
|
+
arg.push(t);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The WHENEVER tests after each executable statement, from Db2's definitions of the conditions. IBM
|
|
186
|
+
// does not document which wins when two hold at once, so the order is fixed, not the source's.
|
|
187
|
+
const WHENEVER_TEST = {
|
|
188
|
+
SQLERROR: 'SQLCODE < 0',
|
|
189
|
+
'NOT FOUND': 'SQLCODE = 100',
|
|
190
|
+
SQLWARNING: "SQLWARN0 = 'W' OR (SQLCODE > 0 AND SQLCODE NOT = 100)",
|
|
191
|
+
};
|
|
192
|
+
// Without an SQLCA there is no SQLWARN0; the warning test falls back to its SQLCODE half.
|
|
193
|
+
const WHENEVER_TEST_SQLCODE = { ...WHENEVER_TEST, SQLWARNING: 'SQLCODE > 0 AND SQLCODE NOT = 100' };
|
|
194
|
+
const NOT_EXECUTABLE = new Set(['WHENEVER', 'INCLUDE', 'BEGIN', 'END']);
|
|
195
|
+
// DECLARE GLOBAL TEMPORARY TABLE is executable; DECLARE CURSOR, STATEMENT, TABLE and VARIABLE are not.
|
|
196
|
+
const declarative = (words) => words[0] === 'DECLARE' && words[1] !== 'GLOBAL';
|
|
197
|
+
|
|
198
|
+
// What one block becomes, as COBOL text on one logical line, or '' to blank it.
|
|
199
|
+
function translate(block, state) {
|
|
200
|
+
const toks = tokenize(block.text);
|
|
201
|
+
const words = toks.filter((t) => t.word).map((t) => t.word);
|
|
202
|
+
const verb = words[0] || '';
|
|
203
|
+
if (verb === 'INCLUDE') return words[1] ? `COPY ${words[1]}` : '';
|
|
204
|
+
if (!state.procedure) {
|
|
205
|
+
if (declarative(words) && words.includes('CURSOR')) state.cursors.set(words[1], roles(toks).sending);
|
|
206
|
+
return '';
|
|
207
|
+
}
|
|
208
|
+
if (verb === 'WHENEVER') {
|
|
209
|
+
const cond = words[1] === 'NOT' ? 'NOT FOUND' : words[1];
|
|
210
|
+
let go = toks.findIndex((t) => t.word === 'GO' || t.word === 'GOTO');
|
|
211
|
+
if (go >= 0) {
|
|
212
|
+
if (toks[go + 1]?.word === 'TO') go++;
|
|
213
|
+
// The label may be written with a colon, as a host variable is.
|
|
214
|
+
state.whenever.set(cond, toks[go + 1]?.word || toks[go + 1]?.host);
|
|
215
|
+
} else state.whenever.delete(cond);
|
|
216
|
+
return 'CONTINUE';
|
|
217
|
+
}
|
|
218
|
+
if (declarative(words)) {
|
|
219
|
+
if (words.includes('CURSOR')) state.cursors.set(words[1], roles(toks).sending);
|
|
220
|
+
return 'CONTINUE';
|
|
221
|
+
}
|
|
222
|
+
if (NOT_EXECUTABLE.has(verb)) return 'CONTINUE';
|
|
223
|
+
let { sending, receiving } = roles(toks);
|
|
224
|
+
let name = verb;
|
|
225
|
+
if (verb === 'OPEN' && state.cursors.has(words[1])) sending = [...new Set([...state.cursors.get(words[1]), ...sending])];
|
|
226
|
+
if (verb === 'EXECUTE' && words[1] === 'IMMEDIATE') name = 'EXECUTE-IMMEDIATE';
|
|
227
|
+
if (verb === 'DECLARE') name = 'DECLARE-GLOBAL-TEMPORARY';
|
|
228
|
+
if (verb === 'WITH') name = 'SELECT';
|
|
229
|
+
const status = state.status ? [state.status] : [];
|
|
230
|
+
const args = [
|
|
231
|
+
...(sending.length ? ['BY', 'CONTENT', ...sending.map((h) => cobolName(h, state.subscripts(h)))] : []),
|
|
232
|
+
...(receiving.length || status.length ? ['BY', 'REFERENCE', ...receiving.map((h) => cobolName(h, state.subscripts(h))), ...status] : []),
|
|
233
|
+
];
|
|
234
|
+
const call = [`CALL 'CW-SQL-${name}'`, ...(args.length ? ['USING', ...args] : [])];
|
|
235
|
+
const test = state.status === 'SQLCA' ? WHENEVER_TEST : WHENEVER_TEST_SQLCODE;
|
|
236
|
+
const tests = state.status ? Object.keys(test).filter((c) => state.whenever.has(c)).map((c) => `IF ${test[c]} GO TO ${state.whenever.get(c)} END-IF`) : [];
|
|
237
|
+
state.translated++;
|
|
238
|
+
return [...call, ...tests].join(' ');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Where a program's SQL status goes: an SQLCA, whether included, copied or written out, or else a
|
|
242
|
+
// standalone SQLCODE, as STDSQL(YES) and SQLCA-less programs declare.
|
|
243
|
+
function statusArea(lines, format, items, blocks) {
|
|
244
|
+
if (blocks.some((b) => /^INCLUDE\s+SQLCA\b/i.test(b.text))) return 'SQLCA';
|
|
245
|
+
const names = new Set((items || []).map((it) => it.name));
|
|
246
|
+
const code = lines.map((l) => { const a = codeArea(l, format); return a.comment ? '' : l.slice(a.start, a.end); }).join('\n');
|
|
247
|
+
if (names.has('SQLCA') || /^\s*0?1\s+SQLCA\b|\bCOPY\s+["']?SQLCA\b/im.test(code)) return 'SQLCA';
|
|
248
|
+
if (names.has('SQLCODE') || /^\s*\d{1,2}\s+SQLCODE\b/im.test(code)) return 'SQLCODE';
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Writes `text` over the block's code area on its own lines, word by word, blank-padding the rest.
|
|
253
|
+
// Returns the words that did not fit, for lines added after the block.
|
|
254
|
+
function place(lines, block, format, text) {
|
|
255
|
+
const words = text ? text.match(/(?:'[^']*'|"[^"]*"|[^\s'"])+/g) || [] : [];
|
|
256
|
+
const free = isFree(format);
|
|
257
|
+
let w = 0;
|
|
258
|
+
for (let li = block.line; li <= block.endLine; li++) {
|
|
259
|
+
const area = codeArea(lines[li], format);
|
|
260
|
+
// The code area runs to column 72 however short the line is; free format has no margin.
|
|
261
|
+
const limit = free ? Math.max(lines[li].length, 72) : 72;
|
|
262
|
+
const blankFrom = li === block.line ? block.col : area.start;
|
|
263
|
+
// A statement's continuation belongs in area B, column 12 of a fixed-format line.
|
|
264
|
+
const writeFrom = li === block.line ? block.col : free ? area.start : Math.max(area.start, 11);
|
|
265
|
+
const to = li === block.endLine ? block.endCol : limit;
|
|
266
|
+
let out = '';
|
|
267
|
+
while (w < words.length && writeFrom + out.length + (out ? 1 : 0) + words[w].length <= to) out += (out ? ' ' : '') + words[w++];
|
|
268
|
+
const line = lines[li].padEnd(to);
|
|
269
|
+
lines[li] = line.slice(0, blankFrom) + ' '.repeat(Math.max(0, writeFrom - blankFrom)) + out.padEnd(Math.max(0, to - Math.max(writeFrom, blankFrom))) + line.slice(to);
|
|
270
|
+
if (li !== block.endLine) lines[li] = lines[li].replace(/\s+$/, '');
|
|
271
|
+
}
|
|
272
|
+
return words.slice(w);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Lines for the words that did not fit, indented to area B.
|
|
276
|
+
function spill(words, format) {
|
|
277
|
+
const indent = isFree(format) ? ' ' : ' '.repeat(11);
|
|
278
|
+
const width = isFree(format) ? 120 : 72 - indent.length;
|
|
279
|
+
const out = [];
|
|
280
|
+
let cur = '';
|
|
281
|
+
for (const word of words) {
|
|
282
|
+
if (cur && cur.length + 1 + word.length > width) { out.push(indent + cur); cur = ''; }
|
|
283
|
+
cur += (cur ? ' ' : '') + word;
|
|
284
|
+
}
|
|
285
|
+
if (cur) out.push(indent + cur);
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A division or section header, or a word, standing alone: not part of a longer name.
|
|
290
|
+
const word = (re) => new RegExp(`(?<![A-Z0-9-])${re}(?![A-Z0-9-])`, 'i');
|
|
291
|
+
const HEADERS = [['procedure', word('PROCEDURE\\s+DIVISION')], ['linkage', word('LINKAGE\\s+SECTION')], ['data', word('DATA\\s+DIVISION')], ['after', word('(?:REPORT|SCREEN)\\s+SECTION')]];
|
|
292
|
+
const PROGRAM_ID = word('PROGRAM-ID');
|
|
293
|
+
|
|
294
|
+
// The program units of a source, each from its PROGRAM-ID to the next: where its DATA DIVISION,
|
|
295
|
+
// LINKAGE SECTION, REPORT or SCREEN SECTION and PROCEDURE DIVISION headers are, and its code.
|
|
296
|
+
function programUnits(lines, format) {
|
|
297
|
+
const units = [{ start: 0, code: [] }];
|
|
298
|
+
lines.forEach((l, i) => {
|
|
299
|
+
const a = codeArea(l, format);
|
|
300
|
+
if (a.comment) return;
|
|
301
|
+
const code = l.slice(a.start, a.end);
|
|
302
|
+
let unit = units[units.length - 1];
|
|
303
|
+
if (PROGRAM_ID.test(code) && (unit.programId !== undefined || unit.procedure !== undefined)) units.push(unit = { start: i, code: [] });
|
|
304
|
+
if (PROGRAM_ID.test(code)) unit.programId = i;
|
|
305
|
+
unit.code.push(code);
|
|
306
|
+
for (const [key, re] of HEADERS) if (unit[key] === undefined && (key === 'procedure' || unit.procedure === undefined) && re.test(code)) unit[key] = i;
|
|
307
|
+
});
|
|
308
|
+
units.forEach((u, k) => { u.end = k + 1 < units.length ? units[k + 1].start : lines.length; u.code = u.code.join('\n'); });
|
|
309
|
+
return units;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const EIB_REFERENCE = word(`(?:${EIB_LAYOUT.map(([n]) => n).join('|')}|DFHEIBLK|DFHCOMMAREA)`);
|
|
313
|
+
const COMMAREA = word('DFHCOMMAREA');
|
|
314
|
+
|
|
315
|
+
// The SQLCA a program's INCLUDE SQLCA needs: Db2 for z/OS's 136-byte COBOL layout, with the
|
|
316
|
+
// STDSQL(YES) names SQLCADE and SQLSTAT over the fields they stand for.
|
|
317
|
+
export function sqlcaCopybook() {
|
|
318
|
+
return [
|
|
319
|
+
' 01 SQLCA.',
|
|
320
|
+
' 05 SQLCAID PIC X(8).',
|
|
321
|
+
' 05 SQLCABC PIC S9(9) COMP-5.',
|
|
322
|
+
' 05 SQLCODE PIC S9(9) COMP-5.',
|
|
323
|
+
' 05 SQLCADE REDEFINES SQLCODE PIC S9(9) COMP-5.',
|
|
324
|
+
' 05 SQLERRM.',
|
|
325
|
+
' 49 SQLERRML PIC S9(4) COMP-5.',
|
|
326
|
+
' 49 SQLERRMC PIC X(70).',
|
|
327
|
+
' 05 SQLERRP PIC X(8).',
|
|
328
|
+
' 05 SQLERRD PIC S9(9) COMP-5 OCCURS 6.',
|
|
329
|
+
' 05 SQLWARN.',
|
|
330
|
+
...[0, 1, 2, 3, 4, 5, 6, 7].map((n) => ` 10 SQLWARN${n} PIC X.`),
|
|
331
|
+
' 05 SQLEXT.',
|
|
332
|
+
...['8', '9', 'A'].map((n) => ` 10 SQLWARN${n} PIC X.`),
|
|
333
|
+
' 10 SQLSTATE PIC X(5).',
|
|
334
|
+
' 10 SQLSTAT REDEFINES SQLSTATE PIC X(5).',
|
|
335
|
+
'',
|
|
336
|
+
].join('\n');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The SQLDA a program's INCLUDE SQLDA needs: Db2 for z/OS's COBOL field names and pictures.
|
|
340
|
+
export function sqldaCopybook() {
|
|
341
|
+
return [
|
|
342
|
+
' 01 SQLDA.',
|
|
343
|
+
' 05 SQLDAID PIC X(8).',
|
|
344
|
+
' 05 SQLDABC PIC S9(9) BINARY.',
|
|
345
|
+
' 05 SQLN PIC S9(4) BINARY.',
|
|
346
|
+
' 05 SQLD PIC S9(4) BINARY.',
|
|
347
|
+
' 05 SQLVAR OCCURS 0 TO 750 TIMES DEPENDING ON SQLN.',
|
|
348
|
+
' 10 SQLVAR1.',
|
|
349
|
+
' 15 SQLTYPE PIC S9(4) BINARY.',
|
|
350
|
+
' 15 SQLLEN PIC S9(4) BINARY.',
|
|
351
|
+
' 15 FILLER REDEFINES SQLLEN.',
|
|
352
|
+
' 20 SQLPRECISION PIC X.',
|
|
353
|
+
' 20 SQLSCALE PIC X.',
|
|
354
|
+
' 15 SQLDATA POINTER.',
|
|
355
|
+
' 15 SQLIND POINTER.',
|
|
356
|
+
' 15 SQLNAME.',
|
|
357
|
+
' 49 SQLNAMEL PIC S9(4) BINARY.',
|
|
358
|
+
' 49 SQLNAMEC PIC X(30).',
|
|
359
|
+
'',
|
|
360
|
+
].join('\n');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The COPY statements in a text's code areas: the line and column each starts at, and its member.
|
|
364
|
+
function copyStatements(lines, format) {
|
|
365
|
+
const out = [];
|
|
366
|
+
lines.forEach((l, i) => {
|
|
367
|
+
const a = codeArea(l, format);
|
|
368
|
+
if (a.comment) return;
|
|
369
|
+
for (const m of l.slice(a.start, a.end).matchAll(/(?<![A-Z0-9-])COPY\s+(["']?)([A-Z0-9$#@_-]+)\1(?=[\s.]|$)/gi)) out.push({ line: i, col: a.start + m.index, name: m[2].toUpperCase() });
|
|
370
|
+
});
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// A text's lines as a compiler reads them: a fixed-format line's columns after expanding its tabs.
|
|
375
|
+
const linesOf = (text, format) => text.split(/\r?\n/).map((l) => (isFree(format) ? l : expandTabs(l)));
|
|
376
|
+
|
|
377
|
+
// What the precompiler needs of a text: its blocks, built-ins, COPY statements and program units.
|
|
378
|
+
function survey(lines, format) {
|
|
379
|
+
return { lines, ...findBlocks(lines, format), copies: copyStatements(lines, format), units: programUnits(lines, format) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// A COPY member the caller's resolver finds, surveyed, with whether it or a member it copies needs the
|
|
383
|
+
// EIB and whether one of them holds the PROCEDURE DIVISION header. null when it cannot be read.
|
|
384
|
+
function memberOf(shared, name) {
|
|
385
|
+
if (shared.members.has(name)) return shared.members.get(name);
|
|
386
|
+
shared.members.set(name, null);
|
|
387
|
+
const text = shared.copybook ? shared.copybook(name) : null;
|
|
388
|
+
if (typeof text !== 'string') return null;
|
|
389
|
+
const m = { name, ...survey(linesOf(text, shared.format), shared.format) };
|
|
390
|
+
const nested = m.copies.map((c) => (c.member = memberOf(shared, c.name))).filter(Boolean);
|
|
391
|
+
const code = m.units.map((u) => u.code).join('\n');
|
|
392
|
+
m.eib = m.blocks.some((b) => b.kind === 'CICS') || EIB_REFERENCE.test(code) || nested.some((n) => n.eib);
|
|
393
|
+
m.commarea = COMMAREA.test(code) || nested.some((n) => n.commarea);
|
|
394
|
+
m.procedure = m.units.some((u) => u.procedure !== undefined) || nested.some((n) => n.procedure);
|
|
395
|
+
shared.members.set(name, m);
|
|
396
|
+
return m;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Translates the blocks and built-ins of one text, and of the members it copies at the point each is
|
|
400
|
+
// copied, since WHENEVER applies in listing order through them. `ctx` says whether the text is the
|
|
401
|
+
// program or a member, and for a member whether it is copied into the procedure division and whether
|
|
402
|
+
// the EIB and the selector are in scope there. Returns the text's lines with those `before` and
|
|
403
|
+
// `after` it inserts, the source line each came from, and whether anything changed.
|
|
404
|
+
function translateText(t, shared, ctx, before = new Map(), after = new Map()) {
|
|
405
|
+
const { format, state } = shared;
|
|
406
|
+
const free = isFree(format);
|
|
407
|
+
const lines = t.lines;
|
|
408
|
+
const add = (at, li, text) => at.set(li, [...(at.get(li) || []), ...text]);
|
|
409
|
+
for (const b of t.builtins) {
|
|
410
|
+
const value = builtinValue(b.fn, b.name);
|
|
411
|
+
if (value === undefined) state.unresolved++;
|
|
412
|
+
else lines[b.line] = lines[b.line].slice(0, b.col) + String(value).padEnd(b.length) + lines[b.line].slice(b.col + b.length);
|
|
413
|
+
}
|
|
414
|
+
for (const b of t.blocks) { const inc = b.kind === 'SQL' && /^INCLUDE\s+(SQLCA|SQLDA)\b/i.exec(b.text); if (inc) shared.includes.add(inc[1].toUpperCase()); }
|
|
415
|
+
const events = [...t.blocks.map((block) => ({ line: block.line, col: block.col, block })), ...t.copies.map((copy) => ({ line: copy.line, col: copy.col, copy }))]
|
|
416
|
+
.sort((x, y) => x.line - y.line || x.col - y.col);
|
|
417
|
+
for (const e of events) {
|
|
418
|
+
const unit = t.units.findLast((u) => u.start <= e.line);
|
|
419
|
+
const eib = ctx.top ? !!unit.eib : ctx.eib;
|
|
420
|
+
const selector = ctx.top ? !!unit.selector : ctx.selector;
|
|
421
|
+
const inProcedure = unit.procedure !== undefined && e.line >= unit.procedure;
|
|
422
|
+
if (e.copy) {
|
|
423
|
+
const m = e.copy.member;
|
|
424
|
+
if (!m || m.done) continue;
|
|
425
|
+
m.done = true;
|
|
426
|
+
// The COPY that brings in the PROCEDURE DIVISION header is not itself in the procedure division.
|
|
427
|
+
const procedure = ctx.procedure || (inProcedure && !(e.line === unit.procedure && m.procedure));
|
|
428
|
+
const r = translateText(m, shared, { procedure, eib, selector });
|
|
429
|
+
if (r.changed) shared.translated[m.name] = r.lines.join('\n');
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
let b = e.block;
|
|
433
|
+
state.procedure = ctx.procedure || inProcedure;
|
|
434
|
+
state.eib = eib;
|
|
435
|
+
state.selector = selector;
|
|
436
|
+
let text = b.kind === 'CICS' ? translateCics(b, state) : translate(b, state);
|
|
437
|
+
const area = codeArea(lines[b.endLine], format);
|
|
438
|
+
const tail = lines[b.endLine].slice(b.endCol, area.end);
|
|
439
|
+
// Where only a period follows END-EXEC, the rest of the code area is the translation's too.
|
|
440
|
+
const periodOnly = text && /^\s*(\.)?\s*$/.exec(tail);
|
|
441
|
+
if (periodOnly) {
|
|
442
|
+
const limit = free ? Math.max(lines[b.endLine].length, 72) : 72;
|
|
443
|
+
lines[b.endLine] = lines[b.endLine].slice(0, b.endCol) + ' '.repeat(Math.max(0, area.end - b.endCol)) + lines[b.endLine].slice(area.end);
|
|
444
|
+
b = { ...b, endCol: limit };
|
|
445
|
+
if (periodOnly[1]) text += ' .';
|
|
446
|
+
}
|
|
447
|
+
if (!text) {
|
|
448
|
+
state.blanked++;
|
|
449
|
+
// A blanked declaration takes its own period with it; left alone it is a stray full stop.
|
|
450
|
+
const period = /^\s*\./.exec(tail);
|
|
451
|
+
if (period) lines[b.endLine] = lines[b.endLine].slice(0, b.endCol) + ' '.repeat(period[0].length) + lines[b.endLine].slice(b.endCol + period[0].length);
|
|
452
|
+
}
|
|
453
|
+
const rest = place(lines, b, format, text);
|
|
454
|
+
if (periodOnly) {
|
|
455
|
+
lines[b.endLine] = lines[b.endLine].replace(/\s+$/, '');
|
|
456
|
+
if (rest.length) { add(after, b.endLine, spill(rest, format)); state.spilled++; }
|
|
457
|
+
} else if (rest.length) {
|
|
458
|
+
// What followed END-EXEC - a period, or the rest of a sentence - follows the whole translation.
|
|
459
|
+
lines[b.endLine] = lines[b.endLine].slice(0, b.endCol) + ' '.repeat(tail.length) + lines[b.endLine].slice(area.end);
|
|
460
|
+
add(after, b.endLine, spill([...rest, ...(tail.trim() ? [tail.trim()] : [])], format));
|
|
461
|
+
state.spilled++;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const out = [];
|
|
465
|
+
const map = [];
|
|
466
|
+
lines.forEach((l, i) => {
|
|
467
|
+
for (const s of [...(before.get(i) || []), l, ...(after.get(i) || [])]) { out.push(s); map.push(i + 1); }
|
|
468
|
+
});
|
|
469
|
+
return { lines: out, map, changed: t.blocks.length > 0 || t.builtins.length > 0 };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Translates a program's EXEC SQL and EXEC CICS blocks, in its text and, as IBM's integrated
|
|
473
|
+
// translator does, in the COPY members `copybook(name)` returns the text of. `items` are the program's
|
|
474
|
+
// data items as lib/parser.mjs reads them, for the subscripts a host-variable array needs and the
|
|
475
|
+
// declarations its copybooks make; `mapsets` are BMS mapsets as lib/bms.mjs parses them, whose symbolic
|
|
476
|
+
// maps the program copies and its repository lacks. Returns { text, map, copybooks, handlers, stats }:
|
|
477
|
+
// `map[i]` is the 1-based source line output line i+1 came from; `copybooks` holds the stand-ins the
|
|
478
|
+
// COPY statements need that no repository holds and each member that needed translating, to be found
|
|
479
|
+
// ahead of the repository's copy; `handlers` are the HANDLE, IGNORE, PUSH and POP HANDLE commands.
|
|
480
|
+
export function precompile(src, { format = detectFormat(src), items, mapsets = [], copybook } = {}) {
|
|
481
|
+
const free = isFree(format);
|
|
482
|
+
const program = survey(linesOf(src, format), format);
|
|
483
|
+
const shared = {
|
|
484
|
+
format, copybook, members: new Map(), translated: {}, includes: new Set(),
|
|
485
|
+
state: {
|
|
486
|
+
procedure: false, cursors: new Map(), whenever: new Map(), translated: 0, subscripts: subscriptCounter(items),
|
|
487
|
+
status: statusArea(program.lines, format, items, program.blocks),
|
|
488
|
+
eib: false, selector: false, handlers: [], unknown: 0, undirected: 0, unresolved: 0, blanked: 0, spilled: 0,
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
const { units, copies } = program;
|
|
492
|
+
for (const c of copies) c.member = memberOf(shared, c.name);
|
|
493
|
+
const within = (u, line) => line >= u.start && line < u.end;
|
|
494
|
+
for (const c of copies) {
|
|
495
|
+
const u = units.findLast((x) => x.start <= c.line);
|
|
496
|
+
if (u.procedure === undefined && c.member?.procedure) u.procedure = c.line;
|
|
497
|
+
}
|
|
498
|
+
const before = new Map();
|
|
499
|
+
const after = new Map();
|
|
500
|
+
const add = (at, li, text) => at.set(li, [...(at.get(li) || []), ...text]);
|
|
501
|
+
// A unit with CICS in it gets DFHEIBLK in its LINKAGE SECTION, as IBM's translator declares it,
|
|
502
|
+
// unless it declares it itself, and DFHCOMMAREA where it names one it does not declare; one without
|
|
503
|
+
// a LINKAGE SECTION gets one.
|
|
504
|
+
const pad = format === 'terminal' ? ' ' : free ? ' ' : ' ';
|
|
505
|
+
const declared = (unit, name) => word(`(?:0?1\\s+|COPY\\s+)${name}`).test(unit.code) || (items || []).some((it) => it.name === name && it.level === 1);
|
|
506
|
+
let eibCopied = false;
|
|
507
|
+
for (const unit of units) {
|
|
508
|
+
const inUnit = copies.filter((c) => c.member && within(unit, c.line)).map((c) => c.member);
|
|
509
|
+
const cics = program.blocks.some((b) => b.kind === 'CICS' && within(unit, b.line)) || inUnit.some((m) => m.eib) || EIB_REFERENCE.test(unit.code);
|
|
510
|
+
if (unit.procedure === undefined || !cics) continue;
|
|
511
|
+
const eib = !declared(unit, 'DFHEIBLK');
|
|
512
|
+
const commarea = (COMMAREA.test(unit.code) || inUnit.some((m) => m.commarea)) && !declared(unit, 'DFHCOMMAREA');
|
|
513
|
+
unit.eib = true;
|
|
514
|
+
unit.selector = eib || word('COPY\\s+DFHEIBLK').test(unit.code);
|
|
515
|
+
eibCopied ||= unit.selector;
|
|
516
|
+
const decls = [...(eib ? [`${pad}COPY DFHEIBLK.`] : []), ...(commarea ? [`${pad}01 DFHCOMMAREA PIC X.`] : [])];
|
|
517
|
+
if (!decls.length) continue;
|
|
518
|
+
if (unit.linkage !== undefined) add(after, unit.linkage, decls);
|
|
519
|
+
else add(before, Math.min(unit.procedure, unit.after ?? Infinity), [...(unit.data === undefined ? [`${pad}DATA DIVISION.`] : []), `${pad}LINKAGE SECTION.`, ...decls]);
|
|
520
|
+
}
|
|
521
|
+
const { lines, map } = translateText(program, shared, { top: true, procedure: false }, before, after);
|
|
522
|
+
const { state, includes } = shared;
|
|
523
|
+
const copied = new Set([...copies.map((c) => c.name), ...[...shared.members.values()].filter(Boolean).flatMap((m) => m.copies.map((c) => c.name))]);
|
|
524
|
+
const copybooks = {
|
|
525
|
+
...(includes.has('SQLCA') ? { SQLCA: sqlcaCopybook() } : {}),
|
|
526
|
+
...(includes.has('SQLDA') ? { SQLDA: sqldaCopybook() } : {}),
|
|
527
|
+
};
|
|
528
|
+
if (eibCopied) copybooks.DFHEIBLK = eibCopybook();
|
|
529
|
+
for (const [member, names] of Object.entries(CONSTANT_COPYBOOKS)) if (copied.has(member)) copybooks[member] = constantsCopybook(names);
|
|
530
|
+
for (const mapset of mapsets) {
|
|
531
|
+
const member = mapset.name?.toUpperCase();
|
|
532
|
+
const cb = member && copied.has(member) && !copybooks[member] && symbolicMapCopybook(mapset);
|
|
533
|
+
if (cb) copybooks[member] = cb.text;
|
|
534
|
+
}
|
|
535
|
+
for (const [member, text] of Object.entries(shared.translated)) copybooks[member] ??= text;
|
|
536
|
+
const blocks = program.blocks.length + [...shared.members.values()].filter((m) => m?.done).reduce((n, m) => n + m.blocks.length, 0);
|
|
537
|
+
return {
|
|
538
|
+
text: lines.join('\n'),
|
|
539
|
+
map,
|
|
540
|
+
copybooks,
|
|
541
|
+
handlers: state.handlers,
|
|
542
|
+
stats: { blocks, translated: state.translated, blanked: state.blanked, spilled: state.spilled, unknown: state.unknown, undirected: state.undirected, unresolved: state.unresolved, members: Object.keys(shared.translated).length },
|
|
543
|
+
};
|
|
544
|
+
}
|
package/lib/reach.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Who can reach a finding, and what reaching it runs as - the halves a repository cannot show.
|
|
3
|
+
//
|
|
4
|
+
// A path finding names a route. Whether an attacker can drive it is who may start the transaction
|
|
5
|
+
// or submit the job that carries the input (reach); what driving it gives them is the authority
|
|
6
|
+
// that entry runs under (effect). Both live in RACF and the region's SIT, never in the source. The
|
|
7
|
+
// estate supplies them: authoritatively, as an extract it brings for one scan and the tool never
|
|
8
|
+
// keeps - a RACF unload reduced to who-starts-what and who-runs-as-what - or, as a fallback, by
|
|
9
|
+
// naming the entries in cobolwork.site.json. Until it supplies either, reach is undeclared and
|
|
10
|
+
// effect is unstated, and the scan says so rather than ranking anything. See docs/spec/reach.md.
|
|
11
|
+
//
|
|
12
|
+
// Both are annotations, not re-rankings: severity is urgency, reach is who can drive it, effect is
|
|
13
|
+
// what driving it runs as. Three axes a consumer combines, kept apart so each names the fact that
|
|
14
|
+
// moved it. Neither ever becomes "exploitable" - that word needs a witness on the running system,
|
|
15
|
+
// which is not what reading source and records produces.
|
|
16
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { basename, sep, delimiter } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
20
|
+
const isoDate = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
21
|
+
const up = (s) => String(s || '').toUpperCase();
|
|
22
|
+
const ACCESS = new Set(['open', 'restricted']);
|
|
23
|
+
const arr = (v) => (Array.isArray(v) ? v.filter(str).map(up) : []);
|
|
24
|
+
|
|
25
|
+
// The feeds a scan was given: the caller's list, or COBOLWORK_REACH read when the scan runs.
|
|
26
|
+
export function reachFeedPaths(opts = {}) {
|
|
27
|
+
if (opts.reachFeeds) return opts.reachFeeds;
|
|
28
|
+
return (process.env.COBOLWORK_REACH || '').split(delimiter).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A customer's own reachability extract, loaded for one scan and never kept, refused from inside the
|
|
32
|
+
// scanned tree the way an advisory feed is. Shape:
|
|
33
|
+
// { extract, retrieved, transactions: { NAME: "open"|"restricted" }, jobs: { NAME: ... },
|
|
34
|
+
// privileged: { transactions: [NAME], jobs: [NAME] } }
|
|
35
|
+
// derived from a RACF unload (who may start each transaction, and which run with elevated authority)
|
|
36
|
+
// and the SIT (which face a network).
|
|
37
|
+
export function loadReachFeed(path, { root = null } = {}) {
|
|
38
|
+
const feed = { file: basename(path), extract: null, retrieved: null, transactions: {}, jobs: {}, privileged: { transactions: [], jobs: [] }, refused: [], problem: null };
|
|
39
|
+
let real;
|
|
40
|
+
try { real = realpathSync(path); } catch (e) { feed.problem = `could not be read (${e.code || e.name})`; return feed; }
|
|
41
|
+
if (root) {
|
|
42
|
+
const top = realpathSync(root);
|
|
43
|
+
if (real === top || real.startsWith(top + sep)) { feed.problem = 'is inside the tree being scanned, where anyone who can read the repository can read it, so it was not loaded'; return feed; }
|
|
44
|
+
}
|
|
45
|
+
let doc;
|
|
46
|
+
try { doc = JSON.parse(readFileSync(real, 'utf8')); } catch (e) { feed.problem = `is not JSON (${e.message})`; return feed; }
|
|
47
|
+
if (!str(doc.extract)) { feed.problem = 'does not say which extract it is'; return feed; }
|
|
48
|
+
if (!isoDate(doc.retrieved)) { feed.problem = 'does not say when it was retrieved'; return feed; }
|
|
49
|
+
feed.extract = doc.extract;
|
|
50
|
+
feed.retrieved = doc.retrieved;
|
|
51
|
+
for (const kind of ['transactions', 'jobs']) {
|
|
52
|
+
const m = doc[kind];
|
|
53
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) continue;
|
|
54
|
+
for (const [name, access] of Object.entries(m)) {
|
|
55
|
+
if (ACCESS.has(access)) feed[kind][up(name)] = access;
|
|
56
|
+
else feed.refused.push({ name: up(name), why: `${kind.slice(0, -1)} ${up(name)} says access ${JSON.stringify(access)}, not "open" or "restricted"` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const pv = doc.privileged;
|
|
60
|
+
if (pv && typeof pv === 'object' && !Array.isArray(pv)) { feed.privileged.transactions = arr(pv.transactions); feed.privileged.jobs = arr(pv.jobs); }
|
|
61
|
+
return feed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve an entry point to its access ('open' | 'restricted' | null) and its authority (privileged
|
|
65
|
+
// or not). A loaded feed is authoritative; the site keys fill what no feed names. `declared` and
|
|
66
|
+
// `effectDeclared` are whether the estate has stated anything at all - the difference between
|
|
67
|
+
// "nothing is reachable / privileged" and "nobody said".
|
|
68
|
+
export function reachResolver({ feeds = [], site = {} } = {}) {
|
|
69
|
+
const good = feeds.filter((f) => !f.problem);
|
|
70
|
+
const set = (k) => new Set((site[k] || []).map(up));
|
|
71
|
+
const openTx = set('openTransactions'), resTx = set('restrictedTransactions');
|
|
72
|
+
const openJob = set('openJobs'), resJob = set('restrictedJobs');
|
|
73
|
+
const privTx = new Set([...set('privilegedTransactions'), ...good.flatMap((f) => f.privileged.transactions)]);
|
|
74
|
+
const privJob = new Set([...set('privilegedJobs'), ...good.flatMap((f) => f.privileged.jobs)]);
|
|
75
|
+
const declared = good.some((f) => Object.keys(f.transactions).length + Object.keys(f.jobs).length) || openTx.size + resTx.size + openJob.size + resJob.size > 0;
|
|
76
|
+
const effectDeclared = privTx.size + privJob.size > 0;
|
|
77
|
+
const accessOf = (kind, name) => {
|
|
78
|
+
const n = up(name);
|
|
79
|
+
for (const f of good) { const a = f[`${kind}s`][n]; if (a) return a; }
|
|
80
|
+
if (kind === 'transaction') return openTx.has(n) ? 'open' : resTx.has(n) ? 'restricted' : null;
|
|
81
|
+
if (kind === 'job') return openJob.has(n) ? 'open' : resJob.has(n) ? 'restricted' : null;
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
const privilegedOf = (kind, name) => (kind === 'transaction' ? privTx.has(up(name)) : kind === 'job' ? privJob.has(up(name)) : false);
|
|
85
|
+
return { declared, effectDeclared, accessOf, privilegedOf };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The access of one startedBy entry, whichever kind it is.
|
|
89
|
+
const accessOfEntry = (e, resolver) => (e.transaction ? resolver.accessOf('transaction', e.transaction) : (e.job !== undefined ? resolver.accessOf('job', e.job || '') : null));
|
|
90
|
+
|
|
91
|
+
// Stamp `reach` on every finding an entry point reaches, and count the three ways. `open` if any
|
|
92
|
+
// entry that reaches it is open, `restricted` if every entry that resolves is restricted and none
|
|
93
|
+
// is open, `undeclared` if none of its entries is a declared fact. A finding no entry reaches
|
|
94
|
+
// carries no reach - there is nothing to say.
|
|
95
|
+
export function stampReach(findings, resolver) {
|
|
96
|
+
const by = { open: 0, restricted: 0, undeclared: 0 };
|
|
97
|
+
for (const f of findings) {
|
|
98
|
+
const entries = f.startedBy || [];
|
|
99
|
+
if (!entries.length) continue;
|
|
100
|
+
let open = false, restricted = false;
|
|
101
|
+
for (const e of entries) { const a = accessOfEntry(e, resolver); if (a === 'open') open = true; else if (a === 'restricted') restricted = true; }
|
|
102
|
+
f.reach = open ? 'open' : restricted ? 'restricted' : 'undeclared';
|
|
103
|
+
by[f.reach]++;
|
|
104
|
+
}
|
|
105
|
+
return by;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Stamp `effect: 'privileged'` on a finding an entry the estate flags as elevated-authority reaches.
|
|
109
|
+
// Only the positive is claimed: an entry a privileged allow-list does not name is unknown, not
|
|
110
|
+
// ordinary, so it is left unstamped rather than called safe.
|
|
111
|
+
export function stampEffect(findings, resolver) {
|
|
112
|
+
const by = { privileged: 0 };
|
|
113
|
+
for (const f of findings) {
|
|
114
|
+
const entries = f.startedBy || [];
|
|
115
|
+
if (!entries.length) continue;
|
|
116
|
+
if (entries.some((e) => (e.transaction ? resolver.privilegedOf('transaction', e.transaction) : (e.job !== undefined ? resolver.privilegedOf('job', e.job || '') : false)))) {
|
|
117
|
+
f.effect = 'privileged';
|
|
118
|
+
by.privileged++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return by;
|
|
122
|
+
}
|