@portll/cobolwork 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -5
- package/bin/cobolwork.mjs +34 -9
- package/lib/baseline.mjs +9 -0
- package/lib/bms.mjs +8 -0
- package/lib/build.mjs +33 -8
- package/lib/capabilities.mjs +6 -3
- package/lib/consequence.mjs +17 -0
- package/lib/constants.mjs +60 -0
- package/lib/control-reuse.mjs +113 -0
- package/lib/control.mjs +86 -18
- package/lib/csd.mjs +6 -1
- package/lib/dataflow.mjs +343 -95
- package/lib/equivalence.mjs +44 -5
- package/lib/evidence/cli.mjs +13 -5
- package/lib/evidence/record.mjs +16 -6
- package/lib/evidence/seal.mjs +17 -0
- package/lib/evidence/store.mjs +30 -14
- package/lib/evidence/timestamp.mjs +83 -0
- package/lib/evidence/verify.mjs +120 -42
- package/lib/hlasm/asm/data.mjs +116 -0
- package/lib/hlasm/asm/listing.mjs +43 -0
- package/lib/hlasm/asm/output.mjs +45 -0
- package/lib/hlasm/asm/sections.mjs +159 -0
- package/lib/hlasm/asm/symbols.mjs +78 -0
- package/lib/hlasm/exec.mjs +27 -0
- package/lib/hlasm/expr.mjs +167 -0
- package/lib/hlasm/instr.mjs +48 -0
- package/lib/hlasm/locate.mjs +333 -0
- package/lib/hlasm/macro/authorization.mjs +126 -0
- package/lib/hlasm/macro/datasets.mjs +94 -0
- package/lib/hlasm/macro/io.mjs +139 -0
- package/lib/hlasm/macro/le.mjs +53 -0
- package/lib/hlasm/macro/linkage.mjs +143 -0
- package/lib/hlasm/macro/operator.mjs +66 -0
- package/lib/hlasm/macro/program.mjs +142 -0
- package/lib/hlasm/macro/recovery.mjs +80 -0
- package/lib/hlasm/macro/storage.mjs +117 -0
- package/lib/hlasm/macro/structured.mjs +131 -0
- package/lib/hlasm/model.mjs +72 -0
- package/lib/hlasm/operands.mjs +49 -0
- package/lib/hlasm/read.mjs +116 -0
- package/lib/hlasm.mjs +79 -0
- package/lib/inventory.mjs +10 -7
- package/lib/ironwork-ids.mjs +24 -0
- package/lib/ironwork.mjs +4 -1
- package/lib/kernel/registry.mjs +24 -17
- package/lib/kernel/shared-pass.mjs +138 -0
- package/lib/options.mjs +26 -4
- package/lib/policy.mjs +6 -0
- package/lib/precompile-cics.mjs +1 -1
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +3 -0
- package/lib/scan.mjs +21 -11
- package/lib/sets/abend.mjs +115 -13
- package/lib/sets/build.mjs +43 -37
- package/lib/sets/cics.mjs +5 -3
- package/lib/sets/compile.mjs +5 -3
- package/lib/sets/copybook.mjs +5 -3
- package/lib/sets/crypto.mjs +110 -0
- package/lib/sets/flow.mjs +32 -7
- package/lib/sets/hlasm.mjs +177 -0
- package/lib/sets/jcl.mjs +15 -1
- package/lib/sets/log.mjs +5 -3
- package/lib/sets/priv.mjs +5 -3
- package/lib/sets/secrets.mjs +110 -0
- package/lib/sets/semantics.mjs +5 -3
- package/lib/sets/web.mjs +104 -7
- package/lib/sources.mjs +12 -2
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +216 -0
- package/rules/compliance-dora.json +234 -0
- package/rules/compliance-ffiec.json +234 -0
- package/rules/compliance-nist80053.json +234 -0
- package/rules/gitleaks-mainframe.toml +61 -16
- package/rules/hlasm-instructions.json +2396 -0
- package/rules/hlasm-operations.json +451 -0
- package/rules/icsf-services.json +1178 -0
- package/schema/cobolwork.policy.schema.json +7 -0
package/lib/sets/log.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { parseJcl } from '../jcl.mjs';
|
|
|
23
23
|
import { compileStepOptions } from '../options.mjs';
|
|
24
24
|
import { report } from '../kernel/ruleset.mjs';
|
|
25
25
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
26
|
-
import {
|
|
26
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
export const LOG_RULES = {
|
|
@@ -337,7 +337,7 @@ const memberOf = (file) => basename(String(file || ''), extname(String(file || '
|
|
|
337
337
|
const runByJob = (batch, p, file) => batch.unsure
|
|
338
338
|
|| [String(p.id || '').toUpperCase(), memberOf(file)].some((n) => n && (batch.runs.has(n) || batch.runs.has(n.slice(0, 8))));
|
|
339
339
|
|
|
340
|
-
export function
|
|
340
|
+
export function* scanLogSteps(root, opts = {}) {
|
|
341
341
|
const tree = treeFor(root, opts);
|
|
342
342
|
const files = tree.list().filter(isProgram).filter(inScope(opts));
|
|
343
343
|
const findings = [];
|
|
@@ -364,7 +364,7 @@ export function scanLog(root, opts = {}) {
|
|
|
364
364
|
return { rule: 'display-echoes-a-credential', detail: `${p.id} shows ${w.name} on the terminal to the person who typed it: not a log, but the screen and its scrollback keep it` };
|
|
365
365
|
};
|
|
366
366
|
|
|
367
|
-
const run =
|
|
367
|
+
const run = yield loopOver(files, (f) => {
|
|
368
368
|
let src;
|
|
369
369
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
370
370
|
// Every EXEC CICS WRITE form, not the two this set started with. WRITE OPERATOR was added as a
|
|
@@ -404,3 +404,5 @@ export function scanLog(root, opts = {}) {
|
|
|
404
404
|
|
|
405
405
|
return report('log', { rules: LOG_RULES, findings, stats, run });
|
|
406
406
|
}
|
|
407
|
+
|
|
408
|
+
export const scanLog = (root, opts = {}) => drive(scanLogSteps(root, opts));
|
package/lib/sets/priv.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { inScope, isProgram, relPath } from '../sources.mjs';
|
|
20
20
|
import { report } from '../kernel/ruleset.mjs';
|
|
21
21
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
22
|
-
import {
|
|
22
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
23
23
|
import { parseCsd } from '../csd.mjs';
|
|
24
24
|
import { parseJcl } from '../jcl.mjs';
|
|
25
25
|
import { loadSite } from '../site.mjs';
|
|
@@ -101,7 +101,7 @@ function under(dsn, prefixes) {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export function
|
|
104
|
+
export function* scanPrivSteps(root, opts = {}) {
|
|
105
105
|
const site = loadSite(root, opts.site || null, opts.tree);
|
|
106
106
|
const tree = treeFor(root, opts);
|
|
107
107
|
const files = tree.list().filter(inScope(opts));
|
|
@@ -140,7 +140,7 @@ export function scanPriv(root, opts = {}) {
|
|
|
140
140
|
const diagnostics = [];
|
|
141
141
|
const uss = [];
|
|
142
142
|
|
|
143
|
-
const run =
|
|
143
|
+
const run = yield loopOver(files, (f) => {
|
|
144
144
|
let src;
|
|
145
145
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
146
146
|
const path = relPath(root, f);
|
|
@@ -320,3 +320,5 @@ export function scanPriv(root, opts = {}) {
|
|
|
320
320
|
|
|
321
321
|
return report('priv', { rules: PRIV_RULES, findings, stats, run });
|
|
322
322
|
}
|
|
323
|
+
|
|
324
|
+
export const scanPriv = (root, opts = {}) => drive(scanPrivSteps(root, opts));
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Credentials written into COBOL source. The shapes are the ones rules/gitleaks-mainframe.toml gives
|
|
3
|
+
// gitleaks, read from that file, so one list serves both and the build gate does not depend on
|
|
4
|
+
// gitleaks being installed. Go's RE2 syntax is taken only where it means the same in JavaScript; any
|
|
5
|
+
// other construct is refused when the file is read.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { inScope, isProgram, isCopybook, relPath } from '../sources.mjs';
|
|
8
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
9
|
+
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
|
10
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
11
|
+
|
|
12
|
+
export const SECRETS_RULES = {
|
|
13
|
+
'credential-in-source': {
|
|
14
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-798', measured: false,
|
|
15
|
+
text: 'A credential is written into the source',
|
|
16
|
+
impact: 'Everyone who can read the source, its history or a listing can sign on with it, and changing it means changing and redeploying the program',
|
|
17
|
+
remedy: 'Read the credential at run time from where the site keeps them (a RACF-protected data set, a PassTicket, a Db2 trusted context or a CICS-supplied identity), and change the one already exposed',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The TOML ids of the shapes that occur in COBOL programs and copybooks, and how a finding names each.
|
|
22
|
+
const SHAPES = {
|
|
23
|
+
'cobol-value-credential': (m) => `${dataName(m) || 'a data item named for a credential'} has a literal VALUE`,
|
|
24
|
+
'embedded-sql-connect-password': () => 'EXEC SQL CONNECT gives a literal password',
|
|
25
|
+
'cics-signon-password': (m) => `EXEC CICS ${/\b(SIGNON|VERIFY|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal PASSWORD`,
|
|
26
|
+
'cics-new-password': (m) => `EXEC CICS ${/\b(SIGNON|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal NEWPASSWORD`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const dataName = (m) => (/\b(?:0?[1-9]|[1-4][0-9]|77)\s+([A-Z0-9][A-Z0-9-]*)/i.exec(m) || [])[1]?.toUpperCase();
|
|
30
|
+
|
|
31
|
+
const value = (raw) => {
|
|
32
|
+
const t = raw.trim();
|
|
33
|
+
if (t.startsWith("'''")) return t.slice(3, t.lastIndexOf("'''"));
|
|
34
|
+
if (t.startsWith('"')) return JSON.parse(t);
|
|
35
|
+
if (t.startsWith('[')) return [...t.matchAll(/'''(.*?)'''|"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1] ?? JSON.parse(`"${x[2]}"`));
|
|
36
|
+
if (/^\d+$/.test(t)) return Number(t);
|
|
37
|
+
if (t === 'true' || t === 'false') return t === 'true';
|
|
38
|
+
throw new Error(`gitleaks-mainframe.toml: value not read: ${t}`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The [[rules]] tables of the file, each with its allowlist folded in.
|
|
42
|
+
export function readToml(text) {
|
|
43
|
+
const rules = [];
|
|
44
|
+
let into = null;
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const l = line.trim();
|
|
47
|
+
if (!l || l.startsWith('#')) continue;
|
|
48
|
+
if (l === '[[rules]]') { rules.push((into = { allowlist: {} })); continue; }
|
|
49
|
+
if (l === '[rules.allowlist]') { into = rules.at(-1).allowlist; continue; }
|
|
50
|
+
if (l.startsWith('[')) { into = null; continue; }
|
|
51
|
+
const kv = /^([A-Za-z]+)\s*=\s*(.+)$/.exec(l);
|
|
52
|
+
if (kv && into) into[kv[1]] = value(kv[2]);
|
|
53
|
+
}
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A gitleaks regex as a JavaScript one: a leading (?flags) group becomes the flags.
|
|
58
|
+
export function jsRegex(re, extra = '') {
|
|
59
|
+
const lead = /^\(\?([ims]+)\)/.exec(re);
|
|
60
|
+
const body = lead ? re.slice(lead[0].length) : re;
|
|
61
|
+
if (/\(\?[a-zA-Z]|\(\?P<|\\[AzZpPQE]/.test(body)) throw new Error(`gitleaks-mainframe.toml: no JavaScript equivalent for ${re}`);
|
|
62
|
+
return new RegExp(body, [...new Set([...(lead ? lead[1] : ''), ...extra])].join(''));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const secretOf = (m, group) => (group ? m[group] : m.slice(1).find((g) => g !== undefined)) ?? m[0];
|
|
66
|
+
|
|
67
|
+
export const SHAPE_RULES = readToml(readFileSync(new URL('../../rules/gitleaks-mainframe.toml', import.meta.url), 'utf8'))
|
|
68
|
+
.filter((r) => r.id in SHAPES)
|
|
69
|
+
.map((r) => ({
|
|
70
|
+
id: r.id,
|
|
71
|
+
pattern: jsRegex(r.regex, 'g'),
|
|
72
|
+
secretGroup: r.secretGroup || 0,
|
|
73
|
+
keywords: (r.keywords || []).map((k) => k.toLowerCase()),
|
|
74
|
+
allow: (r.allowlist.regexes || []).map((x) => jsRegex(x)),
|
|
75
|
+
allowTarget: r.allowlist.regexTarget || 'secret',
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// Every shape in the text, at the line its first non-blank character is on.
|
|
79
|
+
export function credentialsIn(text) {
|
|
80
|
+
const lower = text.toLowerCase();
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const s of SHAPE_RULES) {
|
|
83
|
+
if (s.keywords.length && !s.keywords.some((k) => lower.includes(k))) continue;
|
|
84
|
+
for (const m of text.matchAll(s.pattern)) {
|
|
85
|
+
const target = s.allowTarget === 'match' ? m[0] : secretOf(m, s.secretGroup);
|
|
86
|
+
if (s.allow.some((a) => a.test(target))) continue;
|
|
87
|
+
const start = m.index + (m[0].length - m[0].trimStart().length);
|
|
88
|
+
out.push({ shape: s.id, line: text.slice(0, start).split('\n').length, detail: SHAPES[s.id](m[0]) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function scanSecrets(root, opts = {}) {
|
|
95
|
+
const tree = treeFor(root, opts);
|
|
96
|
+
const files = tree.list().filter((f) => isProgram(f) || isCopybook(f)).filter(inScope(opts));
|
|
97
|
+
const findings = [];
|
|
98
|
+
const stats = { filesScanned: 0, filesUnreadable: 0 };
|
|
99
|
+
|
|
100
|
+
const run = eachWithinMemory(files, (f) => {
|
|
101
|
+
let src;
|
|
102
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
103
|
+
stats.filesScanned++;
|
|
104
|
+
const path = relPath(root, f);
|
|
105
|
+
for (const c of credentialsIn(src)) findings.push({ rule: 'credential-in-source', path, line: c.line, detail: `${c.detail} (${c.shape})` });
|
|
106
|
+
return src.length;
|
|
107
|
+
}, { label: 'secrets', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
108
|
+
|
|
109
|
+
return report('secrets', { rules: SECRETS_RULES, findings, stats, run });
|
|
110
|
+
}
|
package/lib/sets/semantics.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { basename, extname } from 'node:path';
|
|
|
9
9
|
import { inScope, isProgram, isJcl, relPath, ebcdicByte } from '../sources.mjs';
|
|
10
10
|
import { report } from '../kernel/ruleset.mjs';
|
|
11
11
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
13
13
|
import { loadSite } from '../site.mjs';
|
|
14
14
|
import { parseJcl } from '../jcl.mjs';
|
|
15
15
|
import { optionCards, optionTokens, compileStepOptions } from '../options.mjs';
|
|
@@ -206,7 +206,7 @@ function thruPairs(toks) {
|
|
|
206
206
|
}
|
|
207
207
|
const WHEN_WORDS = new Set(['THRU', 'THROUGH', 'ALSO', 'OR', 'NOT', 'ANY', 'OTHER', 'TRUE', 'FALSE']);
|
|
208
208
|
|
|
209
|
-
export function
|
|
209
|
+
export function* scanSemanticsSteps(root, opts = {}) {
|
|
210
210
|
const tree = treeFor(root, opts);
|
|
211
211
|
const all = tree.list().filter(inScope(opts));
|
|
212
212
|
const files = all.filter(isProgram);
|
|
@@ -359,7 +359,7 @@ export function scanSemantics(root, opts = {}) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
const run =
|
|
362
|
+
const run = yield loopOver(files, (f) => {
|
|
363
363
|
let src;
|
|
364
364
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
365
365
|
let r;
|
|
@@ -375,3 +375,5 @@ export function scanSemantics(root, opts = {}) {
|
|
|
375
375
|
|
|
376
376
|
return report('semantics', { rules: SEMANTICS_RULES, findings, stats, run });
|
|
377
377
|
}
|
|
378
|
+
|
|
379
|
+
export const scanSemantics = (root, opts = {}) => drive(scanSemanticsSteps(root, opts));
|
package/lib/sets/web.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { parseCsd } from '../csd.mjs';
|
|
|
22
22
|
import { classify } from './log.mjs';
|
|
23
23
|
import { report } from '../kernel/ruleset.mjs';
|
|
24
24
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
export const WEB_RULES = {
|
|
@@ -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.
|
|
@@ -144,7 +168,7 @@ const HTTP_METHOD = /^(GET|POST|PUT|PATCH|DELETE)$/i;
|
|
|
144
168
|
// the absence and the explicit NO are one finding, and PROTOCOL only changes what is in the clear.
|
|
145
169
|
const encrypted = (svc) => /^(YES|CLIENTAUTH|CLIENTCERT)$/i.test(String(svc.ssl || ''));
|
|
146
170
|
|
|
147
|
-
export function
|
|
171
|
+
export function* scanWebSteps(root, opts = {}) {
|
|
148
172
|
const tree = treeFor(root, opts);
|
|
149
173
|
const all = tree.list().filter(inScope(opts));
|
|
150
174
|
const files = all.filter(isProgram);
|
|
@@ -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} ` : '';
|
|
@@ -253,7 +323,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
253
323
|
}
|
|
254
324
|
}
|
|
255
325
|
|
|
256
|
-
const run =
|
|
326
|
+
const run = yield loopOver(files, (f) => {
|
|
257
327
|
let src;
|
|
258
328
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
259
329
|
// The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
|
|
@@ -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
|
|
|
@@ -349,3 +444,5 @@ export function scanWeb(root, opts = {}) {
|
|
|
349
444
|
|
|
350
445
|
return report('web', { rules: WEB_RULES, findings, stats, run });
|
|
351
446
|
}
|
|
447
|
+
|
|
448
|
+
export const scanWeb = (root, opts = {}) => drive(scanWebSteps(root, opts));
|
package/lib/sources.mjs
CHANGED
|
@@ -14,6 +14,9 @@ export const PROGRAM_EXT = ['.cbl', '.cob', '.cobol', '.sqb', '.pco'];
|
|
|
14
14
|
export const COPY_EXT = ['.cpy', '.copy', '.inc'];
|
|
15
15
|
export const JCL_EXT = ['.jcl', '.job', '.proc', '.prc', '.cntl'];
|
|
16
16
|
export const BMS_EXT = ['.bms'];
|
|
17
|
+
// Assembler source. A BMS map or an IMS DBD or PSB is also assembler macros, and is often named .asm;
|
|
18
|
+
// lib/hlasm.mjs tells them apart by the macros they hold.
|
|
19
|
+
export const HLASM_EXT = ['.asm', '.mac', '.mlc', '.hlasm', '.assemble'];
|
|
17
20
|
|
|
18
21
|
const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
19
22
|
|
|
@@ -46,6 +49,11 @@ function kindOfHead(head) {
|
|
|
46
49
|
// A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
|
|
47
50
|
// classified before classifies differently.
|
|
48
51
|
if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
|
|
52
|
+
// Assembler source defines a section or a macro, and declares storage or a base register, in the
|
|
53
|
+
// operation field; either alone is a word a text file can hold. Tested after BMS, which is
|
|
54
|
+
// assembler macros too.
|
|
55
|
+
if (/^[^*\s]*[ \t]+(?:CSECT|RSECT|START|MACRO)(?=\s|$)/m.test(text)
|
|
56
|
+
&& /^[^*\s]*[ \t]+(?:USING|DC|DS|BALR|BASR|STM|STMG|MEND)(?=\s|$)/m.test(text)) return 'hlasm';
|
|
49
57
|
return null;
|
|
50
58
|
}
|
|
51
59
|
|
|
@@ -116,10 +124,12 @@ export const isProgram = byExtOrContent(PROGRAM_EXT, 'program');
|
|
|
116
124
|
export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
|
|
117
125
|
export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
|
|
118
126
|
export const isBms = byExtOrContent(BMS_EXT, 'bms');
|
|
119
|
-
|
|
127
|
+
export const isAssembler = byExtOrContent(HLASM_EXT, 'hlasm');
|
|
128
|
+
// Not BMS or assembler, by either route: the sets that read every source file have not chosen to
|
|
129
|
+
// read maps or assembler.
|
|
120
130
|
export const isSource = (p) => (extname(p)
|
|
121
131
|
? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
|
|
122
|
-
: ![null, 'bms'].includes(sniffKind(p)));
|
|
132
|
+
: ![null, 'bms', 'hlasm'].includes(sniffKind(p)));
|
|
123
133
|
|
|
124
134
|
// `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
|
|
125
135
|
// files on it and no others.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portll/cobolwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"author": "Portll <john@portll.net>",
|