@portll/cobolwork 0.3.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 +35 -9
- package/bin/cobolwork.mjs +7 -1
- package/lib/bms.mjs +8 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/cics-commands.mjs +308 -42
- package/lib/consequence.mjs +19 -0
- package/lib/constants.mjs +60 -0
- package/lib/control.mjs +61 -17
- package/lib/csd.mjs +6 -1
- package/lib/dataflow.mjs +107 -27
- package/lib/explain.mjs +31 -5
- package/lib/exploitability.mjs +49 -4
- package/lib/gate.mjs +31 -9
- package/lib/hlasm.mjs +79 -0
- package/lib/kernel/findings.mjs +2 -0
- package/lib/kernel/registry.mjs +6 -0
- package/lib/kernel/source-tree.mjs +1 -0
- package/lib/options.mjs +6 -2
- package/lib/packs.mjs +4 -0
- package/lib/parser.mjs +67 -12
- package/lib/precompile-cics.mjs +9 -3
- package/lib/precompile.mjs +3 -2
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +3 -2
- package/lib/scan.mjs +2 -1
- package/lib/sets/abend.mjs +186 -0
- package/lib/sets/compile.mjs +2 -1
- package/lib/sets/crypto.mjs +110 -0
- package/lib/sets/flow.mjs +18 -0
- package/lib/sets/hidden.mjs +1 -1
- package/lib/sets/hlasm.mjs +121 -0
- package/lib/sets/jcl.mjs +16 -1
- package/lib/sets/recon.mjs +1 -0
- package/lib/sets/web.mjs +99 -4
- package/lib/sources.mjs +12 -2
- package/lib/utilities.mjs +2 -0
- package/lib/verify.mjs +2 -1
- package/lib/words.mjs +377 -17
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +180 -0
- package/rules/compliance-dora.json +195 -0
- package/rules/compliance-ffiec.json +195 -0
- package/rules/compliance-nist80053.json +195 -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/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/lib/utilities.mjs
CHANGED
|
@@ -118,6 +118,7 @@ function commands(lines) {
|
|
|
118
118
|
// The values of the first of `names` given as a keyword in a command, as in INFILE(IN) or
|
|
119
119
|
// OUTDDNAME(TAPE1,TAPE2). A name only counts at the start of a word, so INDD is not LOGINDD.
|
|
120
120
|
function param(text, names) {
|
|
121
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- names are literal keyword lists
|
|
121
122
|
const m = new RegExp(`(?:^|[\\s,])(?:${names.join('|')})\\s*\\(([^)]*)\\)`, 'i').exec(text);
|
|
122
123
|
return m ? m[1].split(/[\s,]+/).filter(Boolean).map((v) => upper(v.replace(/^'|'$/g, ''))) : null;
|
|
123
124
|
}
|
|
@@ -341,6 +342,7 @@ function icetoolCopies(step, dds) {
|
|
|
341
342
|
for (const st of statements) {
|
|
342
343
|
if (!['COPY', 'SORT', 'MERGE'].includes(st.op)) continue;
|
|
343
344
|
const named = (kw) => st.operands.flatMap((o) => {
|
|
345
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- kw is a literal keyword
|
|
344
346
|
const m = new RegExp(`^${kw}\\((.*)\\)$`, 'i').exec(o);
|
|
345
347
|
return m ? m[1].split(',').map((n) => upper(n.trim())).filter(Boolean) : [];
|
|
346
348
|
});
|
package/lib/verify.mjs
CHANGED
|
@@ -18,7 +18,8 @@ const END_TASK = 'end the task from CEDF or CEDX, or stop the program in the deb
|
|
|
18
18
|
// What goes in, and what reading it at the operation shows. `run` says what letting the operation run
|
|
19
19
|
// shows; null means it would act on something, so the plan stops before it (`stopBefore`).
|
|
20
20
|
const SINKS = {
|
|
21
|
-
|
|
21
|
+
// A letter's low half is 1 to 9 and its zone a valid sign, so zoned arithmetic reads it as a digit.
|
|
22
|
+
arithmetic: (x) => ({ value: `an asterisk in every position of ${x.item}, whose digits it expects: a letter will not do, since zoned decimal reads a letter as a digit`, reads: `${x.item} holding the asterisks`, run: 'the task abends ASRA, a data exception (S0C7 in batch), at the operation' }),
|
|
22
23
|
subscript: (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}, or 0`),
|
|
23
24
|
'loop-bound': (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}`),
|
|
24
25
|
'reference-modification': (x) => bounds(x, 'one more than the length of the field, or 0'),
|