@portll/cobolwork 0.2.117 → 0.2.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/THIRD-PARTY-NOTICES.md +3 -2
- package/bin/cobolwork.mjs +16 -1
- package/lib/baseline.mjs +8 -6
- package/lib/bms.mjs +1 -1
- package/lib/capabilities.mjs +2 -1
- package/lib/cards.mjs +66 -0
- package/lib/cics-commands.mjs +136 -125
- package/lib/compliance.mjs +6 -5
- package/lib/control.mjs +295 -6
- package/lib/dataflow.mjs +72 -88
- package/lib/diff.mjs +65 -89
- package/lib/embedded-sql.mjs +110 -0
- package/lib/equivalence.mjs +30 -12
- package/lib/evidence/journal.mjs +11 -2
- package/lib/evidence/record.mjs +3 -1
- package/lib/evidence/slsa.mjs +17 -2
- package/lib/execution.mjs +77 -0
- package/lib/explain.mjs +5 -4
- package/lib/ftp.mjs +136 -0
- package/lib/inventory.mjs +8 -9
- package/lib/ironwork.mjs +4 -6
- package/lib/jcl.mjs +29 -54
- package/lib/kernel/git.mjs +60 -0
- package/lib/kernel/identity.mjs +7 -6
- package/lib/kernel/source-tree.mjs +204 -34
- package/lib/layout.mjs +202 -0
- package/lib/parser.mjs +58 -207
- package/lib/precompile.mjs +5 -88
- package/lib/revision.json +1 -1
- package/lib/sbom.mjs +125 -13
- package/lib/scan.mjs +11 -4
- package/lib/sets/build.mjs +1 -1
- package/lib/sets/cics.mjs +32 -5
- package/lib/sets/copybook.mjs +2 -1
- package/lib/sets/hidden.mjs +4 -8
- package/lib/sets/jcl.mjs +17 -120
- package/lib/sets/priv.mjs +1 -1
- package/lib/sets/recon.mjs +3 -2
- package/lib/sets/semantics.mjs +1 -1
- package/lib/sets/vendor.mjs +1 -1
- package/lib/sets/zowe.mjs +34 -8
- package/lib/site.mjs +17 -7
- package/lib/sources.mjs +82 -38
- package/lib/utilities.mjs +146 -4
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +2438 -0
- package/rules/compliance-dora.json +1 -1
- package/rules/compliance-ffiec.json +1 -1
- package/rules/compliance-nist80053.json +1 -1
- package/schema/cobolwork.policy.schema.json +94 -17
package/lib/sbom.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { parseJcl } from './jcl.mjs';
|
|
|
10
10
|
import { optionCards } from './options.mjs';
|
|
11
11
|
import { inScope, isBms, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
|
|
12
12
|
import { TOOL_VERSION } from './version.mjs';
|
|
13
|
+
import { revisionOf } from './revision.mjs';
|
|
13
14
|
import { treeFor } from './kernel/source-tree.mjs';
|
|
14
15
|
|
|
15
16
|
// The namespace of cobolwork's version 5 UUIDs (RFC 4122 §4.3), fixed so a serial number repeats.
|
|
@@ -26,6 +27,50 @@ function uuidv5(name) {
|
|
|
26
27
|
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
27
28
|
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
28
29
|
|
|
30
|
+
// A routine a platform provides, which its platform component already stands for.
|
|
31
|
+
const platformRoutine = (n) => n === 'CBLTDLI' || n === 'AIBTDLI' || /^MQ[A-Z0-9]{2,}$/.test(n) || /^CEE[A-Z0-9]+$/.test(n);
|
|
32
|
+
|
|
33
|
+
// IBM programs a job step runs rather than one the estate builds.
|
|
34
|
+
const SYSTEM_PROGRAMS = new Set(['ADRDSSU', 'AMASPZAP', 'ASMA90', 'BPXBATCH', 'DFHCSDUP', 'DFHEAP1$', 'DFHECP1$', 'DFSRRC00', 'DFSORT', 'DSNTEP2', 'DSNTEP4',
|
|
35
|
+
'DSNHPC', 'DSNTIAD', 'DSNTIAUL', 'DSNUTILB', 'FTP', 'HEWL', 'IEWBLINK', 'IEWL', 'ICEGENER', 'ICEMAN', 'ICETOOL', 'IDCAMS', 'IEBCOMPR',
|
|
36
|
+
'IEBCOPY', 'IEBDG', 'IEBGENER', 'IEBUPDTE', 'IEFBR14', 'IEHLIST', 'IEHPROGM', 'IGYCRCTL', 'IKJEFT01', 'IKJEFT1A', 'IKJEFT1B',
|
|
37
|
+
'SDSF', 'SORT']);
|
|
38
|
+
const TSO_BATCH = new Set(['IKJEFT01', 'IKJEFT1A', 'IKJEFT1B']);
|
|
39
|
+
// The IMS region types whose PARM names the application program second: PARM='BMP,PGM,PSB'.
|
|
40
|
+
const IMS_REGIONS = new Set(['BMP', 'DLI', 'DBB', 'IFP', 'JBP', 'JMP']);
|
|
41
|
+
|
|
42
|
+
// What a step runs beyond its PGM: the program a TSO batch step's DSN RUN names, or an IMS region's
|
|
43
|
+
// application program.
|
|
44
|
+
function programsRunBy(step) {
|
|
45
|
+
const pgm = String(step.pgm || '').toUpperCase();
|
|
46
|
+
const out = [];
|
|
47
|
+
if (TSO_BATCH.has(pgm)) {
|
|
48
|
+
for (const dd of step.dds || []) {
|
|
49
|
+
if (dd.name !== 'SYSTSIN') continue;
|
|
50
|
+
const text = (dd.inStream || []).map((l) => l.text).join(' ');
|
|
51
|
+
for (const m of text.matchAll(/\bRUN\s+PROG(?:RAM)?\s*\(\s*([A-Z0-9#@$]{1,8})\s*\)/gi)) out.push(m[1].toUpperCase());
|
|
52
|
+
}
|
|
53
|
+
} else if (pgm === 'DFSRRC00' && step.parm) {
|
|
54
|
+
const [region, app] = String(step.parm).replace(/^['(]|[')]$/g, '').split(',').map((x) => x.trim().toUpperCase());
|
|
55
|
+
if (IMS_REGIONS.has(region) && /^[A-Z0-9#@$]{1,8}$/.test(app || '')) out.push(app);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The literal operand of a CICS option, `PROGRAM('X')`; the field's name where a field is given.
|
|
61
|
+
function cicsOperand(toks, option) {
|
|
62
|
+
const at = toks.findIndex((t, i) => t.t === 'word' && t.u === option && toks[i + 1] && toks[i + 1].v === '(');
|
|
63
|
+
if (at < 0) return null;
|
|
64
|
+
const inner = [];
|
|
65
|
+
for (let i = at + 2, depth = 1; i < toks.length; i++) {
|
|
66
|
+
if (toks[i].v === '(') depth++;
|
|
67
|
+
else if (toks[i].v === ')' && --depth === 0) break;
|
|
68
|
+
inner.push(toks[i]);
|
|
69
|
+
}
|
|
70
|
+
if (inner.length === 1 && inner[0].t === 'lit') return { literal: String(inner[0].v).trim().toUpperCase() };
|
|
71
|
+
return inner.length ? { field: inner.map((t) => t.v).join('') } : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
29
74
|
function platformsOf(src, calls) {
|
|
30
75
|
const s = new Set();
|
|
31
76
|
if (/EXEC\s+CICS\b/i.test(src)) s.add('CICS');
|
|
@@ -35,7 +80,7 @@ function platformsOf(src, calls) {
|
|
|
35
80
|
if (c.kind !== 'L') continue;
|
|
36
81
|
const n = String(c.name).toUpperCase();
|
|
37
82
|
if (n === 'CBLTDLI' || n === 'AIBTDLI') s.add('IMS');
|
|
38
|
-
if (/^MQ[A-
|
|
83
|
+
if (/^MQ[A-Z0-9]{2,}$/.test(n)) s.add('MQ');
|
|
39
84
|
if (/^CEE[A-Z0-9]+$/.test(n)) s.add('Language Environment');
|
|
40
85
|
}
|
|
41
86
|
return [...s];
|
|
@@ -57,26 +102,55 @@ export function sbom(root, opts = {}) {
|
|
|
57
102
|
const idx = tree.index;
|
|
58
103
|
const top = resolve(root);
|
|
59
104
|
const within = (p) => resolve(p).startsWith(top + sep);
|
|
60
|
-
|
|
105
|
+
// A member outside the tree is named by its file name, and by its directory's digest as well where
|
|
106
|
+
// another library holds a member of the same name.
|
|
107
|
+
const copylibRefs = new Map();
|
|
108
|
+
const claimed = new Map();
|
|
109
|
+
const refOf = (p) => {
|
|
110
|
+
if (within(p)) return relPath(root, p);
|
|
111
|
+
if (!copylibRefs.has(p)) {
|
|
112
|
+
let ref = `copylib:${basename(p)}`;
|
|
113
|
+
if (claimed.has(ref) && claimed.get(ref) !== p) ref = `copylib:${basename(p)}#${sha256(dirname(resolve(p))).slice(0, 8)}`;
|
|
114
|
+
claimed.set(ref, p);
|
|
115
|
+
copylibRefs.set(p, ref);
|
|
116
|
+
}
|
|
117
|
+
return copylibRefs.get(p);
|
|
118
|
+
};
|
|
61
119
|
|
|
62
120
|
const entries = [];
|
|
63
121
|
const programIds = new Map();
|
|
122
|
+
const mapsets = new Map();
|
|
123
|
+
const procIds = new Map();
|
|
64
124
|
for (const f of tree.list().filter(inScope(opts)).sort()) {
|
|
65
125
|
let bytes;
|
|
66
|
-
let src;
|
|
67
|
-
try { bytes = readFileSync(f);
|
|
68
|
-
|
|
126
|
+
let src = null;
|
|
127
|
+
try { bytes = readFileSync(f); } catch { continue; }
|
|
128
|
+
try { src = readSource(f).text; } catch { /* its bytes are listed; its text could not be decoded */ }
|
|
129
|
+
const kind = src === null ? (isProgram(f) ? 'program' : isCopybook(f) ? 'copybook' : isJcl(f) ? 'jcl' : isBms(f) ? 'bms' : null) : kindOf(f, src);
|
|
69
130
|
if (!kind) continue;
|
|
70
|
-
const entry = { file: f, ref: relPath(root, f), bytes, src, kind };
|
|
131
|
+
const entry = { file: f, ref: relPath(root, f), bytes, src: src ?? '', kind, ...(src === null ? { unparsed: true, undecodable: true } : {}) };
|
|
132
|
+
if (src === null) { entries.push(entry); continue; }
|
|
71
133
|
if (kind === 'program') {
|
|
72
134
|
try {
|
|
73
135
|
const res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
|
|
74
136
|
entry.programs = res.programs;
|
|
75
137
|
entry.copies = res.copies;
|
|
76
|
-
for (const p of res.programs)
|
|
138
|
+
for (const p of res.programs) {
|
|
139
|
+
if (!p.id) continue;
|
|
140
|
+
const id = String(p.id).toUpperCase();
|
|
141
|
+
if (!programIds.has(id)) programIds.set(id, []);
|
|
142
|
+
if (!programIds.get(id).includes(entry.ref)) programIds.get(id).push(entry.ref);
|
|
143
|
+
}
|
|
77
144
|
} catch { entry.unparsed = true; }
|
|
78
145
|
} else if (kind === 'jcl' || kind === 'proc') {
|
|
79
146
|
try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
|
|
147
|
+
if (kind === 'proc') {
|
|
148
|
+
const m = /^\/\/([A-Z0-9#@$]{1,8})\s+PROC\b/im.exec(src);
|
|
149
|
+
procIds.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
|
|
150
|
+
}
|
|
151
|
+
} else if (kind === 'bms') {
|
|
152
|
+
const m = /^([A-Z0-9#@$]{1,8})\s+DFHMSD\b/im.exec(src);
|
|
153
|
+
mapsets.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
|
|
80
154
|
}
|
|
81
155
|
entries.push(entry);
|
|
82
156
|
}
|
|
@@ -84,13 +158,28 @@ export function sbom(root, opts = {}) {
|
|
|
84
158
|
const components = new Map();
|
|
85
159
|
const dependencies = new Map();
|
|
86
160
|
const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
|
|
161
|
+
// A program the estate does not hold is a component of its own, so an edge to it is not lost.
|
|
162
|
+
// A PROGRAM-ID two files hold is an edge to each: which one a run loads is the load library's order.
|
|
163
|
+
const runs = (from, name) => {
|
|
164
|
+
const targets = programIds.get(name);
|
|
165
|
+
if (targets) { for (const t of targets) if (t !== from) depend(from, t); return; }
|
|
166
|
+
if (platformRoutine(name)) return;
|
|
167
|
+
const ref = `program:${name}`;
|
|
168
|
+
depend(from, ref);
|
|
169
|
+
if (!components.has(ref)) {
|
|
170
|
+
components.set(ref, { 'bom-ref': ref, type: 'application', name, properties: [{ name: 'cobolwork:kind', value: SYSTEM_PROGRAMS.has(name) ? 'system-program' : 'external-program' }] });
|
|
171
|
+
}
|
|
172
|
+
};
|
|
87
173
|
|
|
88
174
|
for (const e of entries) {
|
|
89
175
|
const properties = [{ name: 'cobolwork:kind', value: e.kind }];
|
|
90
176
|
if (e.unparsed) properties.push({ name: 'cobolwork:unparsed', value: 'true' });
|
|
177
|
+
if (e.undecodable) properties.push({ name: 'cobolwork:undecodable', value: 'true' });
|
|
91
178
|
if (e.kind === 'program' && e.programs && e.programs.length) {
|
|
92
179
|
const ids = e.programs.map((p) => p.id).filter(Boolean);
|
|
93
180
|
if (ids.length) properties.push({ name: 'cobolwork:program-id', value: ids.join(',') });
|
|
181
|
+
const twins = [...new Set(ids.flatMap((id) => (programIds.get(String(id).toUpperCase()) || []).filter((r) => r !== e.ref)))].sort(order);
|
|
182
|
+
if (twins.length) properties.push({ name: 'cobolwork:program-id-also-in', value: twins.join(',') });
|
|
94
183
|
properties.push({ name: 'cobolwork:format', value: detectFormat(e.src) });
|
|
95
184
|
const options = optionCards(e.src).flatMap((c) => c.options);
|
|
96
185
|
if (options.length) properties.push({ name: 'cobolwork:options', value: options.join(' ') });
|
|
@@ -101,15 +190,26 @@ export function sbom(root, opts = {}) {
|
|
|
101
190
|
if (!within(c.path) && !components.has(ref)) {
|
|
102
191
|
let b = null;
|
|
103
192
|
try { b = readFileSync(c.path); } catch { /* read at parse time, gone since */ }
|
|
104
|
-
components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }] });
|
|
193
|
+
components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }, ...(b ? [] : [{ name: 'cobolwork:unread', value: 'true' }])] });
|
|
105
194
|
}
|
|
106
195
|
}
|
|
107
196
|
const calls = e.programs.flatMap((p) => p.calls || []);
|
|
108
197
|
const unresolved = new Set();
|
|
109
198
|
for (const c of calls) {
|
|
110
199
|
if (c.kind !== 'L') { unresolved.add(String(c.name)); continue; }
|
|
111
|
-
|
|
112
|
-
|
|
200
|
+
runs(e.ref, String(c.name).toUpperCase());
|
|
201
|
+
}
|
|
202
|
+
for (const x of e.programs.flatMap((p) => p.execs || [])) {
|
|
203
|
+
if (x.kind !== 'CICS') continue;
|
|
204
|
+
const words = x.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
205
|
+
if (words[0] === 'LINK' || words[0] === 'XCTL') {
|
|
206
|
+
const op = cicsOperand(x.toks, 'PROGRAM');
|
|
207
|
+
if (op && op.literal) runs(e.ref, op.literal); else if (op) unresolved.add(op.field);
|
|
208
|
+
} else if ((words[0] === 'SEND' || words[0] === 'RECEIVE') && words[1] === 'MAP') {
|
|
209
|
+
const op = cicsOperand(x.toks, 'MAPSET') || cicsOperand(x.toks, 'MAP');
|
|
210
|
+
const target = op && op.literal ? mapsets.get(op.literal) : null;
|
|
211
|
+
if (target) depend(e.ref, target);
|
|
212
|
+
}
|
|
113
213
|
}
|
|
114
214
|
if (unresolved.size) properties.push({ name: 'cobolwork:unresolved-call', value: [...unresolved].sort(order).join(',') });
|
|
115
215
|
for (const p of platformsOf(e.src, calls)) {
|
|
@@ -119,10 +219,17 @@ export function sbom(root, opts = {}) {
|
|
|
119
219
|
}
|
|
120
220
|
}
|
|
121
221
|
if (e.jcl) {
|
|
222
|
+
const inStream = new Set((e.jcl.procs || []).map((p) => String(p.name || '').toUpperCase()));
|
|
223
|
+
const missing = new Set();
|
|
122
224
|
for (const step of e.jcl.steps || []) {
|
|
123
|
-
|
|
124
|
-
|
|
225
|
+
if (step.pgm && /^[A-Z0-9#@$]{1,8}$/i.test(step.pgm)) runs(e.ref, String(step.pgm).toUpperCase());
|
|
226
|
+
for (const name of programsRunBy(step)) runs(e.ref, name);
|
|
227
|
+
if (!step.proc) continue;
|
|
228
|
+
const name = String(step.proc).toUpperCase();
|
|
229
|
+
const proc = procIds.get(name);
|
|
230
|
+
if (proc) { if (proc !== e.ref) depend(e.ref, proc); } else if (!inStream.has(name)) missing.add(name);
|
|
125
231
|
}
|
|
232
|
+
if (missing.size) properties.push({ name: 'cobolwork:unresolved-proc', value: [...missing].sort(order).join(',') });
|
|
126
233
|
}
|
|
127
234
|
components.set(e.ref, { 'bom-ref': e.ref, type: 'file', name: e.ref, hashes: [{ alg: 'SHA-256', content: sha256(e.bytes) }], properties });
|
|
128
235
|
}
|
|
@@ -130,6 +237,8 @@ export function sbom(root, opts = {}) {
|
|
|
130
237
|
const list = [...components.values()].sort((a, b) => order(a['bom-ref'], b['bom-ref']));
|
|
131
238
|
const digests = list.filter((c) => c.hashes).map((c) => c.hashes[0].content).sort(order);
|
|
132
239
|
const name = opts.name || basename(top);
|
|
240
|
+
let revision = null;
|
|
241
|
+
try { revision = revisionOf(top); } catch { /* not a repository, or git is missing */ }
|
|
133
242
|
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
134
243
|
return {
|
|
135
244
|
bomFormat: 'CycloneDX',
|
|
@@ -139,7 +248,10 @@ export function sbom(root, opts = {}) {
|
|
|
139
248
|
metadata: {
|
|
140
249
|
...(epoch && /^\d+$/.test(epoch) ? { timestamp: new Date(Number(epoch) * 1000).toISOString() } : {}),
|
|
141
250
|
tools: { components: [{ type: 'application', name: 'cobolwork', version: TOOL_VERSION }] },
|
|
142
|
-
component: {
|
|
251
|
+
component: {
|
|
252
|
+
'bom-ref': `estate:${name}`, type: 'application', name,
|
|
253
|
+
...(revision ? { version: revision.commit, properties: [{ name: 'cobolwork:dirty', value: String(revision.dirty) }] } : {}),
|
|
254
|
+
},
|
|
143
255
|
},
|
|
144
256
|
components: list,
|
|
145
257
|
dependencies: [...dependencies.entries()].sort(([a], [b]) => order(a, b)).map(([ref, set]) => ({ ref, dependsOn: [...set].sort(order) })),
|
package/lib/scan.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { sortFindings, tally, evidenceMap, EVIDENCE, EXPLOITABILITY } from './kernel/findings.mjs';
|
|
4
4
|
import { directoryTree } from './kernel/source-tree.mjs';
|
|
5
5
|
import { REGISTRY, RULE_SETS, ALL_RULES, reportKey } from './kernel/registry.mjs';
|
|
@@ -13,6 +13,7 @@ import { rollup } from './sets/flow.mjs';
|
|
|
13
13
|
import { loadSite } from './site.mjs';
|
|
14
14
|
import { reachFeedPaths, loadReachFeed, reachResolver, stampReach, stampEffect } from './reach.mjs';
|
|
15
15
|
import { stampExploitability, byVerdict, HANDLING, witnessFeedPaths, loadWitnessFeed, applyWitness } from './exploitability.mjs';
|
|
16
|
+
import { executionFeedPaths, loadExecutionFeed, applyExecution } from './execution.mjs';
|
|
16
17
|
|
|
17
18
|
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
18
19
|
|
|
@@ -133,7 +134,7 @@ function scanEach(root, opts) {
|
|
|
133
134
|
// Reach and effect annotate rather than re-rank; where no fact is declared, a note says so rather
|
|
134
135
|
// than the report reading as a clean bill.
|
|
135
136
|
export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
136
|
-
const site = loadSite(root, opts.site || null);
|
|
137
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
137
138
|
const feedRoot = opts.feedRoot || root;
|
|
138
139
|
const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root: feedRoot }));
|
|
139
140
|
const reach = reachResolver({ feeds: reachFeeds, site });
|
|
@@ -151,6 +152,8 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
|
151
152
|
const witnessed = applyWitness(findings, checked, witnessFeeds);
|
|
152
153
|
if (witnessed.overruled.length) sortFindings(findings);
|
|
153
154
|
const byExploitability = byVerdict(findings);
|
|
155
|
+
const executionFeeds = executionFeedPaths(opts).map((p) => loadExecutionFeed(p));
|
|
156
|
+
const { byExecution } = applyExecution(findings, root, executionFeeds);
|
|
154
157
|
const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
|
|
155
158
|
const loaded = witnessFeeds.filter((f) => !f.problem);
|
|
156
159
|
return {
|
|
@@ -164,6 +167,8 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
|
164
167
|
...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
|
|
165
168
|
...(loaded.length ? { witnessFeeds: loaded.map((f) => ({ file: f.file, witness: f.witness, recorded: f.recorded })), byWitness: witnessed.byWitness } : {}),
|
|
166
169
|
...(witnessFeeds.some((f) => f.problem || f.refused.length) ? { witnessFeedProblems: witnessFeeds.flatMap((f) => (f.problem ? [`${f.file}: ${f.problem}`] : f.refused.map((r) => `${f.file}: ${r.why}`))) } : {}),
|
|
170
|
+
...(byExecution ? { executionFeeds: executionFeeds.filter((f) => !f.problem).map((f) => ({ file: f.file, programs: f.programs.size })), byExecution } : {}),
|
|
171
|
+
...(executionFeeds.some((f) => f.problem) ? { executionFeedProblems: executionFeeds.filter((f) => f.problem).map((f) => `${f.file}: ${f.problem}`) } : {}),
|
|
167
172
|
// A result no path finding carries: the code changed since the test, or the route is gone.
|
|
168
173
|
...(witnessed.unmatched.length ? { witnessUnmatched: witnessed.unmatched } : {}),
|
|
169
174
|
// Routes the reading refuted that the estate reproduced: the check model was wrong about them.
|
|
@@ -234,10 +239,12 @@ export function scanAll(root, opts = {}) {
|
|
|
234
239
|
const startedBy = parts.find((p) => p.tool === 'cobolwork-flow')?.startedBy || {};
|
|
235
240
|
for (const p of parts) for (const f of p.findings) findings.push(!f.startedBy && f.program && startedBy[f.program] ? { ...f, startedBy: startedBy[f.program] } : { ...f });
|
|
236
241
|
sortFindings(findings);
|
|
237
|
-
|
|
242
|
+
// A tree held in memory is read for the fingerprint's line text as it was for the finding.
|
|
243
|
+
const read = tree.kind === 'directory' ? null : (path) => tree.text(resolve(tree.root, path)).text;
|
|
244
|
+
const identity = stampFingerprints(findings, { root, repo: opts.repoName || '', read });
|
|
238
245
|
// Routes a check stops keep their identity too, so a finding a fix clears can be found again here.
|
|
239
246
|
const checked = parts.find((p) => p.tool === 'cobolwork-flow')?.checked || [];
|
|
240
|
-
stampFingerprints(checked, { root, repo: opts.repoName || '' });
|
|
247
|
+
stampFingerprints(checked, { root, repo: opts.repoName || '', read });
|
|
241
248
|
|
|
242
249
|
const estate = applyEstateFacts(findings, checked, root, opts);
|
|
243
250
|
|
package/lib/sets/build.mjs
CHANGED
|
@@ -132,7 +132,7 @@ function withFeeds(coverage, feeds) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
export function scanBuild(root, opts = {}) {
|
|
135
|
-
const site = loadSite(root, opts.site || null);
|
|
135
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
136
136
|
const tree = treeFor(root, opts);
|
|
137
137
|
const files = tree.list().filter(inScope(opts)).filter(isBuildFile);
|
|
138
138
|
const findings = [];
|
package/lib/sets/cics.mjs
CHANGED
|
@@ -136,6 +136,31 @@ function execOptions(exec) {
|
|
|
136
136
|
|
|
137
137
|
const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
|
|
138
138
|
|
|
139
|
+
// Whether EIBCALEN bounds what the program reads: a condition tests it, or a field it was moved or
|
|
140
|
+
// computed into; it is the start or length of a reference modification, or what an OCCURS DEPENDING
|
|
141
|
+
// ON counts by. A CALL handed it, or the whole EIB, may test it where this program cannot see.
|
|
142
|
+
// Named anywhere else - logged, passed on to a LINK - it checks nothing.
|
|
143
|
+
function testsLength(p) {
|
|
144
|
+
// A statement's own tokens, since its sources leave out what a condition or an expression wraps
|
|
145
|
+
// in parentheses: IF (EIBCALEN > 0).
|
|
146
|
+
const tokens = (p.proc && p.proc.tokens) || [];
|
|
147
|
+
const own = (st) => (st.at != null ? tokens.slice(st.at + 1, st.end) : st.sources || []);
|
|
148
|
+
const carriers = new Set(['EIBCALEN']);
|
|
149
|
+
for (let grew = true; grew;) {
|
|
150
|
+
grew = false;
|
|
151
|
+
for (const st of p.statements || []) {
|
|
152
|
+
if (!own(st).some((t) => carriers.has(t.u))) continue;
|
|
153
|
+
for (const t of st.targets || []) if (!carriers.has(t.u)) { carriers.add(t.u); grew = true; }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const carries = (toks) => (toks || []).some((t) => t && carriers.has(t.u));
|
|
157
|
+
return p.items.some((i) => i.dependingOn === 'EIBCALEN') || (p.statements || []).some((st) =>
|
|
158
|
+
((st.verb === 'IF' || st.verb === 'EVALUATE' || st.verb === 'WHEN') && carries(own(st)))
|
|
159
|
+
|| (st.loops || []).some((l) => carries(l.until))
|
|
160
|
+
|| (st.indexes || []).some((x) => x.kind.startsWith('refmod-') && carries([x.tok]))
|
|
161
|
+
|| (st.verb === 'CALL' && (st.sources || []).some((t) => t.u === 'EIBCALEN' || t.u === 'DFHEIBLK')));
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
// Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
|
|
140
165
|
// not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
|
|
141
166
|
// have gone, an ASSIGN, and the RETURN that ends the task anyway.
|
|
@@ -191,7 +216,6 @@ export function scanCics(root, opts = {}) {
|
|
|
191
216
|
// The whole of what the second pass reads, computed once, while the tree is still here.
|
|
192
217
|
function summarise(p, path) {
|
|
193
218
|
const isSignOn = [...new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))])].some((w) => PASSWORD.test(w));
|
|
194
|
-
const words = new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))]);
|
|
195
219
|
const commarea = p.items.find(i => i.name === 'DFHCOMMAREA' && i.section === 'LINKAGE');
|
|
196
220
|
// A variable naming the program usually holds one literal from its VALUE clause.
|
|
197
221
|
const valueOf = (name) => {
|
|
@@ -221,19 +245,22 @@ export function scanCics(root, opts = {}) {
|
|
|
221
245
|
declared,
|
|
222
246
|
});
|
|
223
247
|
}
|
|
224
|
-
// A program that points DFHCOMMAREA at storage of its own is not reading a caller's area.
|
|
248
|
+
// A program that points DFHCOMMAREA at storage of its own is not reading a caller's area. Only
|
|
249
|
+
// the receiving side of SET, before TO, repoints it: SET P TO ADDRESS OF DFHCOMMAREA reads the
|
|
250
|
+
// caller's area's address.
|
|
225
251
|
const tokens = (p.proc && p.proc.tokens) || [];
|
|
226
252
|
const pointsItself = (p.statements || []).some((st) => {
|
|
227
253
|
if (st.verb !== 'SET') return false;
|
|
228
254
|
const w = tokens.slice(st.at + 1, st.end).filter((t) => t.t === 'word').map((t) => t.u);
|
|
229
|
-
|
|
255
|
+
const receiving = w.includes('TO') ? w.slice(0, w.indexOf('TO')) : w;
|
|
256
|
+
return receiving.some((x, k) => x === 'ADDRESS' && receiving[k + 1] === 'OF' && receiving[k + 2] === 'DFHCOMMAREA');
|
|
230
257
|
});
|
|
231
258
|
return {
|
|
232
259
|
id: p.id, path,
|
|
233
260
|
ownCics: p.execs.some((e) => e.kind === 'CICS'),
|
|
234
261
|
pointsItself,
|
|
235
262
|
commarea: commarea ? { ...where(commarea, path), size: commarea.size, read: subtreeRead(commarea) } : null,
|
|
236
|
-
checksLength:
|
|
263
|
+
checksLength: testsLength(p),
|
|
237
264
|
transfers,
|
|
238
265
|
signOnBypasses: isSignOn ? signOnBypasses(p, path) : [],
|
|
239
266
|
// Gated the same way: a program with no password-named field is not answering a sign-on,
|
|
@@ -390,7 +417,7 @@ export function scanCics(root, opts = {}) {
|
|
|
390
417
|
findings.push({
|
|
391
418
|
rule: 'cics-commarea-without-length-check', path: s.commarea.path, line: s.commarea.line, program: s.id,
|
|
392
419
|
...(covered ? { sev: 'low' } : {}),
|
|
393
|
-
detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never
|
|
420
|
+
detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never tests EIBCALEN, so a shorter or absent communication area is read as if it were whole${covered ? `; every caller in the tree passes at least ${s.commarea.size} bytes, and no transaction starts it` : ''}`,
|
|
394
421
|
});
|
|
395
422
|
}
|
|
396
423
|
}
|
package/lib/sets/copybook.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { finish } from '../kernel/findings.mjs';
|
|
|
7
7
|
import { report } from '../kernel/ruleset.mjs';
|
|
8
8
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
9
9
|
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
10
|
+
import { cobolCard } from '../cards.mjs';
|
|
10
11
|
|
|
11
12
|
// A copybook is resolved by name along a search path: the program's own directory first, then the
|
|
12
13
|
// include directories, then the system library. Two files answering to one name mean the record
|
|
@@ -37,7 +38,7 @@ const SYSTEM_NAME = /^(DFHAID|DFHBMSCA|DFHEIBLK|DFHCOMMAREA|DFHRESP|SQLCA|SQLDA|
|
|
|
37
38
|
function layout(src) {
|
|
38
39
|
const fixed = detectFormat(src) === 'fixed';
|
|
39
40
|
return src.split(/\r?\n/)
|
|
40
|
-
.map(l => (fixed ?
|
|
41
|
+
.map(l => { const c = cobolCard(l, fixed ? 'fixed' : 'free'); return (c.indicator + c.text).trimEnd(); })
|
|
41
42
|
.filter(l => l.trim() && !/^[*/]/.test(l.trimStart()))
|
|
42
43
|
.join('\n')
|
|
43
44
|
.toUpperCase();
|
package/lib/sets/hidden.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { detectFormat, expandTabs } from '../parser.mjs';
|
|
3
|
+
import { cobolCard } from '../cards.mjs';
|
|
3
4
|
import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
|
|
4
5
|
import { report } from '../kernel/ruleset.mjs';
|
|
5
6
|
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
|
@@ -69,14 +70,9 @@ const BANNER = /^[\s*\-=_+#/\\.]*$/;
|
|
|
69
70
|
|
|
70
71
|
function areas(line, format) {
|
|
71
72
|
const l = expandTabs(line);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
indicator: l[6] || ' ',
|
|
76
|
-
text: l.slice(7, format === 'variable' ? 250 : 72),
|
|
77
|
-
identification: format === 'variable' ? '' : l.slice(72, 80),
|
|
78
|
-
ignored: format === 'variable' ? '' : l.slice(72),
|
|
79
|
-
};
|
|
73
|
+
const card = cobolCard(l, format);
|
|
74
|
+
const fixed = format === 'fixed';
|
|
75
|
+
return { ...card, identification: fixed ? card.ignored.slice(0, 8) : '', ignored: fixed ? card.ignored : '' };
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
// A floating comment, from the *> that starts it outside a literal to the end of the line.
|
package/lib/sets/jcl.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { inScope, isJcl, isProgram, relPath } from '../sources.mjs';
|
|
|
7
7
|
import { report } from '../kernel/ruleset.mjs';
|
|
8
8
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
9
9
|
import { parseJcl, dispositionOf } from '../jcl.mjs';
|
|
10
|
-
import { ddGroups } from '../utilities.mjs';
|
|
10
|
+
import { ddGroups, tsoCommands } from '../utilities.mjs';
|
|
11
|
+
import { ftpSession } from '../ftp.mjs';
|
|
11
12
|
import { loadSite, productionQualifierOf, SITE_FILE } from '../site.mjs';
|
|
12
13
|
import { loadPacks, programsOf } from '../packs.mjs';
|
|
13
14
|
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
@@ -84,11 +85,16 @@ const SORTS = new Set(['SORT', 'ICEMAN', 'SYNCSORT']);
|
|
|
84
85
|
const CONTROL_DDS = [
|
|
85
86
|
[TSO, ['SYSTSIN']], [new Set(['BPXBATCH']), ['STDPARM']], [new Set(['IDCAMS']), ['SYSIN']],
|
|
86
87
|
[SORTS, ['SYSIN', 'DFSPARM']], [new Set(['ICETOOL']), ['TOOLIN', 'DFSPARM']],
|
|
87
|
-
[new Set(['DSNTEP2', 'DSNTEP4', 'DSNTIAD']), ['SYSIN']],
|
|
88
88
|
];
|
|
89
89
|
const TSO_RUNS = /^\s*(CALL|EXEC|EX|SUBMIT|ISPSTART|RUN)\b/i;
|
|
90
|
-
//
|
|
91
|
-
|
|
90
|
+
// Db2's sample programs read SQL statements from SYSIN, run as the step's program or under TSO by
|
|
91
|
+
// DSN RUN PROGRAM. Those statements are the program's input, not commands to TSO or a security product.
|
|
92
|
+
const SQL_PROGRAMS = new Set(['DSNTEP2', 'DSNTEP4', 'DSNTIAD']);
|
|
93
|
+
const sqlInput = (step, groups) => {
|
|
94
|
+
const pgm = step.pgm ? step.pgm.toUpperCase() : null;
|
|
95
|
+
const runsSql = SQL_PROGRAMS.has(pgm) || tsoCommands(step).runs.some((x) => SQL_PROGRAMS.has(x.program));
|
|
96
|
+
return runsSql ? groups.get('SYSIN') || [] : [];
|
|
97
|
+
};
|
|
92
98
|
|
|
93
99
|
// Utilities whose commands come from a DD: a cataloged data set there is commands nobody reviewed
|
|
94
100
|
// with the job; a MODS exit is a load module the sort runs; TSO commands say what else runs.
|
|
@@ -99,7 +105,7 @@ function controlCardFindings(r, path, findings) {
|
|
|
99
105
|
const groups = ddGroups(step);
|
|
100
106
|
const cards = (name) => (groups.get(name) || []).flatMap((dd) => dd.inStream || []);
|
|
101
107
|
const ddNames = new Set(CONTROL_DDS.filter(([pgms]) => pgms.has(pgm)).flatMap(([, dds]) => dds));
|
|
102
|
-
if (
|
|
108
|
+
if (sqlInput(step, groups).length) ddNames.add('SYSIN');
|
|
103
109
|
for (const name of ddNames) {
|
|
104
110
|
for (const dd of groups.get(name) || []) {
|
|
105
111
|
if (dd.dsn && !dd.inStream && !dd.temporary) {
|
|
@@ -127,116 +133,6 @@ function controlCardFindings(r, path, findings) {
|
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
// IBM's batch FTP client takes the host and its options on PARM, or the host as the first line of
|
|
131
|
-
// its input, reads subcommands from INPUT, and logs on from NETRC or from its input. CardDemo's job
|
|
132
|
-
// gives the same lines on SYSIN, which is read here when there is no INPUT, though the page below
|
|
133
|
-
// does not say the client falls back to it.
|
|
134
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halu001/ftpreq.htm
|
|
135
|
-
// A session is in cleartext unless something asks for TLS: -a TLS or -r TLS on PARM, or
|
|
136
|
-
// SECURE_MECHANISM TLS in an in-stream SYSFTPD. A SYSFTPD naming a data set is configuration this
|
|
137
|
-
// reader cannot see, so that step is not called cleartext either way.
|
|
138
|
-
// https://www.ibm.com/docs/en/zos/2.1.0?topic=commands-ftp-command
|
|
139
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halz002/ftp_cust_client_for_tls.htm
|
|
140
|
-
// PUT, MPUT and APPEND send local files, and IBM's capitals - PUt, MPut, APpend, LCd - are the
|
|
141
|
-
// shortest spellings. A local name in quotes is a whole data set name, //DD: names a DD of the
|
|
142
|
-
// step, and anything else hangs off the local working directory, which only a quoted LCD fixes.
|
|
143
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/ftpsubcmd.htm
|
|
144
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/put.htm
|
|
145
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mput.htm
|
|
146
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/append.htm
|
|
147
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/lcd.htm
|
|
148
|
-
// https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/dd_name_support.htm
|
|
149
|
-
const FTP_SENDS = [[/^PUT?$/i, 'PUT'], [/^MP(?:UT?)?$/i, 'MPUT'], [/^AP(?:P(?:E(?:ND?)?)?)?$/i, 'APPEND']];
|
|
150
|
-
|
|
151
|
-
// Every subcommand on the page above, in IBM's spelling.
|
|
152
|
-
const FTP_SUBCOMMANDS = ['?', '!', 'ACCount', 'APpend', 'AUth', 'AScii', 'BIG5', 'BINary', 'BLock', 'CCc', 'CD',
|
|
153
|
-
'CDUp', 'CLEar', 'CLose', 'COMpress', 'CProtect', 'CWd', 'DEBug', 'DELEte', 'DELImit', 'DIr', 'DUMP', 'EBcdic',
|
|
154
|
-
'EUckanji', 'FEature', 'FIle', 'Get', 'GLob', 'HAngeul', 'HElp', 'Ibmkanji', 'JIS78kj', 'JIS83kj', 'Ksc5601',
|
|
155
|
-
'LANGuage', 'LCd', 'LOCSIte', 'LOCSTat', 'LMkdir', 'LPwd', 'LS', 'MDelete', 'MGet', 'MKdir', 'MKFifo', 'MOde',
|
|
156
|
-
'MPut', 'MVSGet', 'MVSPut', 'NOop', 'Open', 'PAss', 'PRIvate', 'PROMpt', 'PROTect', 'PROXy', 'PUt', 'PWd', 'QUIt',
|
|
157
|
-
'QUOte', 'RECord', 'REName', 'REStart', 'RMdir', 'SAfe', 'SChinese', 'SENDPort', 'SENDSite', 'SIte', 'SJiskanji',
|
|
158
|
-
'SRestart', 'STAtus', 'STREam', 'STRucture', 'SUnique', 'SYstem', 'TChinese', 'TSO', 'TYpe', 'UCs2', 'User', 'Verbose']
|
|
159
|
-
.map((name) => ({ full: name.toUpperCase(), min: name.match(/^[^a-z]*/)[0].length }));
|
|
160
|
-
const subcommandOf = (word) => {
|
|
161
|
-
const w = word.toUpperCase();
|
|
162
|
-
const hit = FTP_SUBCOMMANDS.find((c) => w.length >= c.min && c.full.startsWith(w));
|
|
163
|
-
return hit ? hit.full : null;
|
|
164
|
-
};
|
|
165
|
-
// A value that stands for a password rather than being one: a symbolic, a mask, or the word itself.
|
|
166
|
-
const PLACEHOLDER = /^(&.*|\*+|X+|\?+|<.*>|\{.*\}|PASSWORD|PASSWD|PWD|PASS|SECRET)$/i;
|
|
167
|
-
|
|
168
|
-
function ftpSession(step) {
|
|
169
|
-
const dds = ddGroups(step);
|
|
170
|
-
let host = null, tls = null;
|
|
171
|
-
const words = (step.parm || '').split('(')[0].trim().split(/\s+/).filter(Boolean);
|
|
172
|
-
for (let i = 0; i < words.length; i++) {
|
|
173
|
-
if (/^-[ar]$/i.test(words[i])) {
|
|
174
|
-
const mechanism = (words[++i] || '').toUpperCase();
|
|
175
|
-
if (mechanism === 'TLS') tls = 'asked';
|
|
176
|
-
else if (mechanism === 'NEVER' && !tls) tls = 'never';
|
|
177
|
-
} else if (!words[i].startsWith('-') && !host) host = words[i];
|
|
178
|
-
}
|
|
179
|
-
let configUnread = null;
|
|
180
|
-
for (const dd of dds.get('SYSFTPD') || []) {
|
|
181
|
-
if (dd.inStream) { if (tls !== 'never' && dd.inStream.some((l) => /^\s*SECURE_MECHANISM\s+TLS\b/i.test(l.text))) tls = 'asked'; }
|
|
182
|
-
else if (dd.dsn && !configUnread) configUnread = dd.dsn;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const input = dds.get('INPUT') || dds.get('SYSIN') || [];
|
|
186
|
-
const hostLine = !host && input[0]?.inStream;
|
|
187
|
-
let inputUnread = null, lcd = null, unixDir = false;
|
|
188
|
-
const sends = [];
|
|
189
|
-
// With no NETRC to answer for it, the client asks for a user ID and a password and in batch reads
|
|
190
|
-
// them from its input after the host: "user password" on one line, or each on its own. A first
|
|
191
|
-
// line that is a subcommand means something else answered, a NETRC the client found for itself.
|
|
192
|
-
// USER and PASS give the same answers later in a session.
|
|
193
|
-
let expect = dds.has('NETRC') ? null : 'logon', user = null;
|
|
194
|
-
const passwords = [];
|
|
195
|
-
const password = (dd, line, value) => { if (!PLACEHOLDER.test(value)) passwords.push({ dd, line, user }); };
|
|
196
|
-
const local = (name) => {
|
|
197
|
-
const dd = /^\/\/DD:([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(name);
|
|
198
|
-
if (dd) return { dd: dd[1].toUpperCase(), dsns: (dds.get(dd[1].toUpperCase()) || []).map((d) => d.dsn).filter(Boolean) };
|
|
199
|
-
const quoted = /^'(.+)'$/.exec(name);
|
|
200
|
-
if (quoted) return { dsns: [quoted[1].toUpperCase()] };
|
|
201
|
-
if (name.startsWith('/') || unixDir) return { dsns: [] };
|
|
202
|
-
return lcd ? { dsns: [`${lcd}.${name.toUpperCase()}`] } : { dsns: [], relative: name };
|
|
203
|
-
};
|
|
204
|
-
for (const dd of input) {
|
|
205
|
-
if (!dd.inStream) { if (dd.dsn && !inputUnread) inputUnread = dd.dsn; continue; }
|
|
206
|
-
for (const { line, text } of dd.inStream) {
|
|
207
|
-
const [verb, ...args] = text.slice(0, 72).trim().split(/\s+/);
|
|
208
|
-
if (!verb) continue;
|
|
209
|
-
if (hostLine && !host) { host = verb; continue; }
|
|
210
|
-
const sub = subcommandOf(verb);
|
|
211
|
-
if (expect && !sub) {
|
|
212
|
-
if (expect === 'logon') user = verb;
|
|
213
|
-
if (expect === 'password') password(dd, line, verb);
|
|
214
|
-
else if (args[0]) password(dd, line, args[0]);
|
|
215
|
-
expect = expect === 'logon' && !args[0] ? 'password' : null;
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
expect = null;
|
|
219
|
-
if (sub === 'USER' && args[0]) {
|
|
220
|
-
user = args[0];
|
|
221
|
-
if (args[1]) password(dd, line, args[1]); else expect = 'password';
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (sub === 'PASS' && args[0]) { password(dd, line, args[0]); continue; }
|
|
225
|
-
if (/^LCD?$/i.test(verb) && args[0]) {
|
|
226
|
-
const quoted = /^'(.+)'$/.exec(args[0]);
|
|
227
|
-
unixDir = args[0].startsWith('/');
|
|
228
|
-
lcd = quoted ? quoted[1].toUpperCase() : lcd && !unixDir ? `${lcd}.${args[0].toUpperCase()}` : null;
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
const sent = FTP_SENDS.find(([re]) => re.test(verb));
|
|
232
|
-
if (!sent) continue;
|
|
233
|
-
for (const name of sent[1] === 'MPUT' ? args : args.slice(0, 1)) sends.push({ line, verb: sent[1], name, ...local(name) });
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
const cleartext = tls === 'never' || (!tls && !configUnread);
|
|
237
|
-
return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, passwords, input };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
136
|
// Credentials, which are the reason this rule set is critical rather than merely interesting.
|
|
241
137
|
// Each run of blanks follows the ( or = that separates it from the next, which keeps matching linear.
|
|
242
138
|
const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*)?['"]?([A-Z0-9@#$_.-]{3,})/i;
|
|
@@ -247,7 +143,7 @@ const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*
|
|
|
247
143
|
const SECURITY_COMMAND = /^\s*(?:TSS\s+)?(ADDUSER|ALTUSER|DELUSER|ADDGROUP|ALTGROUP|CONNECT|REMOVE|PERMIT|SETROPTS|RDEFINE|RALTER|RDELETE|ADDSD|ALTDSD|DELDSD|PASSWORD|ACFNRULE)\b/i;
|
|
248
144
|
|
|
249
145
|
// ACF2 speaks differently: a mode change followed by INSERT or CHANGE against a logonid.
|
|
250
|
-
const ACF2_COMMAND = /^\s*(?:SET\s+(?:LID|RULE|PROFILE)|INSERT\s+[A-Z0-9$#@]+|CHANGE\s+[A-Z0-9$#@]+\s)/i;
|
|
146
|
+
const ACF2_COMMAND = /^\s*(?:SET\s+(?:LID|RULE|PROFILE)|INSERT\s+(?!INTO\b)[A-Z0-9$#@]+|CHANGE\s+[A-Z0-9$#@]+\s)/i;
|
|
251
147
|
|
|
252
148
|
// Destruction in-stream. DELETE with a dataset name, or a REPRO that replaces its target. SQL's
|
|
253
149
|
// DELETE FROM removes rows through a database utility, not a catalogued dataset.
|
|
@@ -341,7 +237,7 @@ export function scanJcl(root, opts = {}) {
|
|
|
341
237
|
// A vendor product supplies its own utilities. An estate that has loaded the pack for a product
|
|
342
238
|
// is telling us those programs exist, so reporting them as undefined would be a false positive
|
|
343
239
|
// created by the customer having told us the truth.
|
|
344
|
-
const site = loadSite(root, opts.site || null);
|
|
240
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
345
241
|
const { loaded } = loadPacks(opts.packs || site.vendorPacks || [], { allowUnvalidated: true });
|
|
346
242
|
const vendorPrograms = programsOf(loaded);
|
|
347
243
|
|
|
@@ -370,9 +266,10 @@ export function scanJcl(root, opts = {}) {
|
|
|
370
266
|
if (d && !stats.firstReadInPart) stats.firstReadInPart = `${path}${d.line ? `:${d.line}` : ''}: ${d.text}`;
|
|
371
267
|
}
|
|
372
268
|
|
|
373
|
-
const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s)]));
|
|
269
|
+
const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s, ddGroups(s))]));
|
|
374
270
|
const ftpInput = new Map();
|
|
375
271
|
for (const ftp of ftpSteps.values()) for (const dd of ftp.input) ftpInput.set(dd, ftp);
|
|
272
|
+
const sqlDds = new Set(r.steps.flatMap((s) => sqlInput(s, ddGroups(s))));
|
|
376
273
|
|
|
377
274
|
for (const dd of r.dds) {
|
|
378
275
|
if (!dd.inStream) continue;
|
|
@@ -398,13 +295,13 @@ export function scanJcl(root, opts = {}) {
|
|
|
398
295
|
detail: `in-stream FTP input gives the password ${logon.user ? `for ${logon.user} ` : ''}that step ${dd.step || '(none)'} logs on ${ftp.host ? `to ${ftp.host} ` : ''}with, in a file anyone who can read the repository can read` });
|
|
399
296
|
continue;
|
|
400
297
|
}
|
|
401
|
-
const sec = text.match(SECURITY_COMMAND);
|
|
298
|
+
const sec = !sqlDds.has(dd) && text.match(SECURITY_COMMAND);
|
|
402
299
|
if (sec) {
|
|
403
300
|
findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
|
|
404
301
|
detail: `in-stream data issues ${sec[1].toUpperCase()}, which grants or alters authority, from step ${dd.step || '(none)'}` });
|
|
405
302
|
continue;
|
|
406
303
|
}
|
|
407
|
-
if (ACF2_COMMAND.test(text)) {
|
|
304
|
+
if (!sqlDds.has(dd) && ACF2_COMMAND.test(text)) {
|
|
408
305
|
findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
|
|
409
306
|
detail: `in-stream data issues an ACF2 administration command from step ${dd.step || '(none)'}` });
|
|
410
307
|
continue;
|
package/lib/sets/priv.mjs
CHANGED
|
@@ -102,7 +102,7 @@ function under(dsn, prefixes) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
export function scanPriv(root, opts = {}) {
|
|
105
|
-
const site = loadSite(root, opts.site || null);
|
|
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));
|
|
108
108
|
const findings = [];
|