@portll/cobolwork 0.0.1 → 0.2.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSING.md +93 -0
- package/NOTICE +9 -0
- package/README.md +325 -3
- package/THIRD-PARTY-NOTICES.md +118 -0
- package/bin/cobolwork.mjs +354 -0
- package/lib/advisories.mjs +133 -0
- package/lib/baseline.mjs +154 -0
- package/lib/bms.mjs +453 -0
- package/lib/build.mjs +402 -0
- package/lib/capabilities.mjs +79 -0
- package/lib/cics-commands.mjs +281 -0
- package/lib/compliance.mjs +81 -0
- package/lib/consequence.mjs +139 -0
- package/lib/control.mjs +1515 -0
- package/lib/csd.mjs +77 -0
- package/lib/dataflow.mjs +1506 -0
- package/lib/diff.mjs +344 -0
- package/lib/explain.mjs +145 -0
- package/lib/gate.mjs +383 -0
- package/lib/index.mjs +6 -0
- package/lib/inventory.mjs +79 -0
- package/lib/jcl.mjs +478 -0
- package/lib/kernel/findings.mjs +94 -0
- package/lib/kernel/identity.mjs +216 -0
- package/lib/kernel/memory.mjs +217 -0
- package/lib/kernel/printable.mjs +6 -0
- package/lib/kernel/registry.mjs +79 -0
- package/lib/kernel/ruleset.mjs +72 -0
- package/lib/kernel/source-tree.mjs +159 -0
- package/lib/kev.mjs +27 -0
- package/lib/options.mjs +512 -0
- package/lib/packs.mjs +148 -0
- package/lib/parser.mjs +2055 -0
- package/lib/policy.mjs +163 -0
- package/lib/precompile-cics.mjs +169 -0
- package/lib/precompile.mjs +544 -0
- package/lib/reach.mjs +122 -0
- package/lib/revision.json +1 -0
- package/lib/revision.mjs +89 -0
- package/lib/sarif.mjs +222 -0
- package/lib/scan.mjs +272 -0
- package/lib/sets/build.mjs +234 -0
- package/lib/sets/cics.mjs +306 -0
- package/lib/sets/compile.mjs +187 -0
- package/lib/sets/copybook.mjs +174 -0
- package/lib/sets/flow.mjs +487 -0
- package/lib/sets/hidden.mjs +216 -0
- package/lib/sets/jcl.mjs +440 -0
- package/lib/sets/log.mjs +406 -0
- package/lib/sets/opaque.mjs +102 -0
- package/lib/sets/priv.mjs +322 -0
- package/lib/sets/recon.mjs +267 -0
- package/lib/sets/vendor.mjs +117 -0
- package/lib/sets/web.mjs +327 -0
- package/lib/site.mjs +164 -0
- package/lib/sources.mjs +156 -0
- package/lib/tui/app.mjs +325 -0
- package/lib/tui/keys.mjs +39 -0
- package/lib/tui/model.mjs +96 -0
- package/lib/tui/run.mjs +38 -0
- package/lib/tui/screen.mjs +59 -0
- package/lib/tui/terminal.mjs +46 -0
- package/lib/utilities.mjs +296 -0
- package/lib/version.mjs +15 -0
- package/lib/words.mjs +318 -0
- package/package.json +45 -6
- package/rules/advisories.json +264 -0
- package/rules/compliance-dora.json +2151 -0
- package/rules/compliance-ffiec.json +2134 -0
- package/rules/compliance-nist80053.json +2134 -0
- package/rules/gitleaks-mainframe.toml +57 -0
- package/rules/kev-ids.json +1729 -0
- package/rules/packs/broadcom.json +124 -0
- package/rules/packs/connectdirect.json +116 -0
- package/rules/packs/controlm.json +114 -0
- package/rules/system-layouts.json +28 -0
- package/schema/cobolwork-coverage.schema.json +65 -0
- package/schema/cobolwork.policy.schema.json +54 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Privilege: what a transaction, a job or a library is allowed to do that nothing in the repository
|
|
3
|
+
// says it needs.
|
|
4
|
+
//
|
|
5
|
+
// The trap this set is built around is that CICS' defaults are permissive. RESSEC(NO) and
|
|
6
|
+
// CMDSEC(NO) are what a transaction gets when nobody says otherwise, so reporting them on their own
|
|
7
|
+
// reports 1,330 of the corpus' 1,330 transaction definitions and means nothing. What makes a
|
|
8
|
+
// missing CMDSEC a finding is that the transaction's program actually issues the commands CMDSEC
|
|
9
|
+
// governs - the same lesson the BMS rule learned, that the discriminator is the use and not the
|
|
10
|
+
// declaration.
|
|
11
|
+
//
|
|
12
|
+
// Two of the checks here can never be witnessed by a repository, and that is a property of the
|
|
13
|
+
// facts rather than of this corpus. Whether a library is APF-authorised lives in a running system's
|
|
14
|
+
// PROGxx member; whether a dataset is readable by everyone lives in RACF. No amount of public COBOL
|
|
15
|
+
// will ever show either. They are therefore written as `context` - which asserts no defect - until
|
|
16
|
+
// cobolwork.site.json supplies the fact, at which point the same finding becomes a defect with a
|
|
17
|
+
// severity. A rule that cannot be wrong until someone supplies ground truth has a false-positive
|
|
18
|
+
// rate of zero by construction, which is a better answer than not writing it.
|
|
19
|
+
import { inScope, isProgram, relPath } from '../sources.mjs';
|
|
20
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
21
|
+
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
22
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
23
|
+
import { parseCsd } from '../csd.mjs';
|
|
24
|
+
import { parseJcl } from '../jcl.mjs';
|
|
25
|
+
import { loadSite } from '../site.mjs';
|
|
26
|
+
import { basename } from 'node:path';
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export const PRIV_RULES = {
|
|
30
|
+
'csd-defines-diagnostic-transaction': {
|
|
31
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-250',
|
|
32
|
+
text: 'A definition in this repository installs one of the CICS diagnostic transactions',
|
|
33
|
+
impact: 'This repository installs a CICS diagnostic transaction (CECI, CEMT, CEDA, ...) that runs commands a terminal types rather than a program the estate wrote, so anyone who can reach it drives the region',
|
|
34
|
+
remedy: 'Do not install the diagnostic transactions in an application region, or restrict them by transaction security to named administrators',
|
|
35
|
+
},
|
|
36
|
+
'csd-transaction-without-command-security': {
|
|
37
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-862',
|
|
38
|
+
text: 'A transaction whose program issues system commands runs without command security',
|
|
39
|
+
impact: "The transaction's program issues CICS system commands and its definition does not set CMDSEC(YES), so the region checks nobody's authority to issue them",
|
|
40
|
+
remedy: "Set CMDSEC(YES) on the transaction, so the region checks the user's authority for the system commands the program issues",
|
|
41
|
+
},
|
|
42
|
+
'job-writes-diagnostic-output-unrestricted': { sev: 'info', evidence: 'context', cwe: 'CWE-532', text: 'A job writes a dump, trace, log or unload to a dataset the estate has not called restricted' },
|
|
43
|
+
'job-reaches-unix-system-services': { sev: 'info', evidence: 'context', cwe: 'CWE-250', text: 'A job runs a shell through UNIX System Services' },
|
|
44
|
+
'job-runs-under-another-user': { sev: 'info', evidence: 'context', cwe: 'CWE-269', text: 'A job names the user it runs as' },
|
|
45
|
+
'job-unloads-the-security-database': { sev: 'info', evidence: 'context', cwe: 'CWE-522', text: 'A job unloads the security database' },
|
|
46
|
+
// Unlike its two neighbours this one is declared as what it is rather than as an observer. A job
|
|
47
|
+
// unloading the security database, or naming the user it runs as, is worth reporting on its own;
|
|
48
|
+
// a job loading from a STEPLIB is not, because every job does. So this check says nothing at all
|
|
49
|
+
// until apfLibraries is declared, and `notLooked` reports that it did not judge - which is a
|
|
50
|
+
// different claim from observing without asserting, and needs a different declaration.
|
|
51
|
+
'job-loads-from-an-authorised-library': {
|
|
52
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-250',
|
|
53
|
+
text: 'A job loads from a library the estate calls authorised',
|
|
54
|
+
impact: 'The job loads programs from a library the estate names as APF-authorised, so anything written into that library runs with supervisor authority and can bypass every check RACF makes',
|
|
55
|
+
remedy: 'Restrict write access to the authorised library to the change process that owns it, and load from an unauthorised copy where the job does not need authority',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// CECI runs any CICS command the terminal types, CEMT changes the region's state, CEDA installs
|
|
60
|
+
// definitions, CEDF steps another transaction and CESF signs a terminal off. Every published
|
|
61
|
+
// account of breaking out of a CICS application reaches one of these five.
|
|
62
|
+
const DIAGNOSTIC = new Set(['CECI', 'CEMT', 'CEDA', 'CEDF', 'CESF', 'CECS', 'CEBR']);
|
|
63
|
+
|
|
64
|
+
// The commands CMDSEC governs. A transaction whose program issues none of them loses nothing by
|
|
65
|
+
// running with CMDSEC(NO), which is why the flag alone is not the finding.
|
|
66
|
+
const SPI_VERBS = new Set(['SET', 'DISCARD', 'PERFORM', 'COLLECT', 'CREATE', 'INQUIRE', 'ENABLE', 'DISABLE', 'RESYNC']);
|
|
67
|
+
|
|
68
|
+
const looksLikeCsd = (src) => /^\s*DEFINE\s+(TRANSACTION|PROGRAM|TDQUEUE|FILE|TCPIPSERVICE)\s*\(/im.test(src);
|
|
69
|
+
// A DEFINE quoted in a README is documentation, not an estate's configuration. Prose files are
|
|
70
|
+
// counted and not reported, so the difference is visible rather than silently folded in.
|
|
71
|
+
const isProse = (path) => /\.(md|markdown|rst|adoc|txt)$/i.test(path) && !/\bcsd\b/i.test(path);
|
|
72
|
+
|
|
73
|
+
// JCL is read through lib/jcl.mjs rather than by regex here. The regexes this replaced anchored
|
|
74
|
+
// DSN= to the same physical line as its //DD, which standard continuation breaks, and bounded a
|
|
75
|
+
// step's DDs by byte offset rather than by step - so a job whose output sat on a continuation card
|
|
76
|
+
// was affirmatively cleared, and one whose next step wrote a report had that report named as where
|
|
77
|
+
// the security database went. diag/propose-site.mjs reads the same facts the same way.
|
|
78
|
+
const HOUSEKEEPING = /^(STEPLIB|JOBLIB|SYSPRINT|SYSIN|SYSOUT|SYSUDUMP|SYSABEND|SYSTSPRT|SYSTSIN)$/;
|
|
79
|
+
// The utilities that read the security database and write it out somewhere ordinary.
|
|
80
|
+
const SECURITY_UTILITY = /^(IRRDBU00|IRRUT200|IRRUT400|IRRXUTIL|ICHUT400)$/;
|
|
81
|
+
// A dataset whose own name says it holds diagnostic output. The name is the estate saying what
|
|
82
|
+
// is in it, which is better evidence than guessing from the step that wrote it.
|
|
83
|
+
const DIAGNOSTIC_DSN = /(^|\.)(DUMP|TRACE|LOG|LOGS|AUDIT|AUDITLG|UNLOAD|UNLOADED|SNAP|ABEND)(\.|$)/i;
|
|
84
|
+
// Reaching the shell. BPXBATCH runs it as a step; the rest are how a program gets there.
|
|
85
|
+
const USS_PROGRAM = /^(BPXBATCH|BPXBATSL)$/;
|
|
86
|
+
const USS_CALL = /\b(BPXWUNIX|BPX1SPN|BPX1EXC)\b/;
|
|
87
|
+
// DISP says which way a DD goes. SHR and OLD read; NEW and MOD write. A DD with no DISP at all is
|
|
88
|
+
// not claimed either way, because guessing is what produced the sentence this replaced.
|
|
89
|
+
const written = (dd) => /^\(?\s*(NEW|MOD)\b/i.test(String(dd.disp || ''));
|
|
90
|
+
|
|
91
|
+
const isJcl = (path) => /\.(jcl|job|prc|proc|cntl)$/i.test(path);
|
|
92
|
+
const upper = (s) => String(s || '').toUpperCase();
|
|
93
|
+
|
|
94
|
+
// Is this dataset under one of the prefixes the estate named, matched as whole components so
|
|
95
|
+
// PRODUCTS does not match PROD?
|
|
96
|
+
function under(dsn, prefixes) {
|
|
97
|
+
const parts = upper(dsn).split('.');
|
|
98
|
+
return prefixes.some((p) => {
|
|
99
|
+
const want = upper(p).split('.');
|
|
100
|
+
return want.every((w, i) => parts[i] === w);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function scanPriv(root, opts = {}) {
|
|
105
|
+
const site = loadSite(root, opts.site || null);
|
|
106
|
+
const tree = treeFor(root, opts);
|
|
107
|
+
const files = tree.list().filter(inScope(opts));
|
|
108
|
+
const findings = [];
|
|
109
|
+
const stats = {
|
|
110
|
+
filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0,
|
|
111
|
+
// Every file in the tree is opened, because a CSD is a DFHCSDUP listing and nothing says what
|
|
112
|
+
// it must be called - the same reason a BMS count came out as zero once. But opening a file is
|
|
113
|
+
// not scanning it: filesScanned counts what this set could find something in, because the
|
|
114
|
+
// whole-scan coverage number is the largest filesScanned of any set, and a set that claimed
|
|
115
|
+
// every README and PNG in the tree would inflate it. That number reads as "how much did you
|
|
116
|
+
// look at", and inflating it errs in the flattering direction.
|
|
117
|
+
filesOpened: 0,
|
|
118
|
+
csdFiles: 0, csdInProse: 0, transactionsDefined: 0, jobsRead: 0,
|
|
119
|
+
// What the estate has not said. Two of the five checks assert nothing without these, and a
|
|
120
|
+
// reader has to be able to tell "nothing to report" from "nothing to report it against".
|
|
121
|
+
factsNotDeclared: ['apfLibraries', 'restrictedDatasets', 'surrogateUsers']
|
|
122
|
+
.filter((k) => !(site[k] || []).length),
|
|
123
|
+
// A fact written in the wrong shape is not a fact nobody wrote. Without this line an estate
|
|
124
|
+
// that put a string where an array belongs is told its facts are undeclared, which sends it
|
|
125
|
+
// looking for the wrong mistake, and the three checks above silently stay at `context`.
|
|
126
|
+
siteConfigured: site.present,
|
|
127
|
+
siteProblems: site.problems,
|
|
128
|
+
setIncomplete: site.problems.length > 0,
|
|
129
|
+
notLooked: site.problems.length
|
|
130
|
+
? [`cobolwork.site.json has ${site.problems.length} problem(s), so the privilege facts were not all read: ${site.problems[0]}`]
|
|
131
|
+
: [],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Transaction name to the program it runs, across every CSD in the tree.
|
|
135
|
+
const runs = new Map();
|
|
136
|
+
const csdSeen = [];
|
|
137
|
+
const jobUsers = [];
|
|
138
|
+
const authorisedLoads = [];
|
|
139
|
+
const securityUnloads = [];
|
|
140
|
+
const diagnostics = [];
|
|
141
|
+
const uss = [];
|
|
142
|
+
|
|
143
|
+
const run = eachWithinMemory(files, (f) => {
|
|
144
|
+
let src;
|
|
145
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
146
|
+
const path = relPath(root, f);
|
|
147
|
+
stats.filesOpened++;
|
|
148
|
+
const csdish = looksLikeCsd(src);
|
|
149
|
+
const jcl = isJcl(path);
|
|
150
|
+
if (csdish || jcl) stats.filesScanned++;
|
|
151
|
+
|
|
152
|
+
if (csdish) {
|
|
153
|
+
if (isProse(path)) { stats.csdInProse++; } else {
|
|
154
|
+
stats.csdFiles++;
|
|
155
|
+
const csd = parseCsd(src);
|
|
156
|
+
stats.transactionsDefined += csd.transactions.size;
|
|
157
|
+
for (const [name, t] of csd.transactions) {
|
|
158
|
+
csdSeen.push({ name, path, line: t.line, program: t.program, cmdsec: t.cmdsec });
|
|
159
|
+
if (t.program) runs.set(name, upper(t.program));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (jcl) {
|
|
165
|
+
stats.jobsRead++;
|
|
166
|
+
let job;
|
|
167
|
+
try { job = parseJcl(src, f); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
|
|
168
|
+
|
|
169
|
+
for (const j of job.jobs) {
|
|
170
|
+
const user = j.keywords && j.keywords.get('USER');
|
|
171
|
+
if (user) jobUsers.push({ path, line: j.line || 1, user: upper(user) });
|
|
172
|
+
}
|
|
173
|
+
for (const step of job.steps) {
|
|
174
|
+
const pgm = upper(step.pgm);
|
|
175
|
+
if (!SECURITY_UTILITY.test(pgm)) continue;
|
|
176
|
+
// The step's own DDs, and only the ones it writes. Reading the rest of the file from the
|
|
177
|
+
// step's byte offset named a STEPLIB, the database the utility reads and a later step's
|
|
178
|
+
// output as places the unloaded security database was written, at high severity.
|
|
179
|
+
const outputs = (step.dds || [])
|
|
180
|
+
.filter((d) => d.dsn && !HOUSEKEEPING.test(upper((d.name || '').split('.').pop())))
|
|
181
|
+
.filter((d) => written(d))
|
|
182
|
+
.map((d) => upper(d.dsn));
|
|
183
|
+
securityUnloads.push({ path, line: step.line || 1, pgm, outputs: [...new Set(outputs)] });
|
|
184
|
+
}
|
|
185
|
+
for (const step of job.steps) {
|
|
186
|
+
const pgm = upper(step.pgm);
|
|
187
|
+
if (USS_PROGRAM.test(pgm)) uss.push({ path, line: step.line || 1, how: `runs ${pgm}` });
|
|
188
|
+
for (const d of step.dds || []) {
|
|
189
|
+
if (!d.dsn || d.temporary || !written(d)) continue;
|
|
190
|
+
if (!DIAGNOSTIC_DSN.test(upper(d.dsn))) continue;
|
|
191
|
+
diagnostics.push({ path, line: d.line || step.line || 1, dsn: upper(d.dsn) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// A library a step loads from, wherever the DD sits and however its DSN is continued.
|
|
195
|
+
for (const step of job.steps) {
|
|
196
|
+
for (const d of step.dds || []) {
|
|
197
|
+
const name = upper((d.name || '').split('.').pop());
|
|
198
|
+
if (!/^(STEPLIB|JOBLIB)$/.test(name) || !d.dsn) continue;
|
|
199
|
+
authorisedLoads.push({ path, line: d.line || step.line || 1, dsn: upper(d.dsn) });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return src.length;
|
|
204
|
+
}, { label: 'priv', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
205
|
+
|
|
206
|
+
// Which programs issue the commands CMDSEC governs. Only the programs a CSD actually names are
|
|
207
|
+
// parsed, which is a handful rather than the tree.
|
|
208
|
+
const wanted = new Set([...runs.values()]);
|
|
209
|
+
const issuesSpi = new Set();
|
|
210
|
+
if (wanted.size) {
|
|
211
|
+
for (const f of files.filter(isProgram)) {
|
|
212
|
+
const name = upper(basename(f).replace(/\.[^.]+$/, ''));
|
|
213
|
+
if (!wanted.has(name)) continue;
|
|
214
|
+
let src;
|
|
215
|
+
try { src = tree.text(f).text; } catch { continue; }
|
|
216
|
+
if (!/EXEC\s+CICS/i.test(src)) continue;
|
|
217
|
+
let r;
|
|
218
|
+
try { r = tree.parse(f, src); } catch (e) { noteUnparsed(stats, tree, f, e); continue; }
|
|
219
|
+
for (const p of r.programs) {
|
|
220
|
+
for (const e of p.execs) {
|
|
221
|
+
if (e.kind !== 'CICS') continue;
|
|
222
|
+
const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
223
|
+
if (w.length >= 2 && SPI_VERBS.has(w[0]) && /^[A-Z]+$/.test(w[1])) { issuesSpi.add(upper(p.id) || name); issuesSpi.add(name); }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const t of csdSeen) {
|
|
230
|
+
if (DIAGNOSTIC.has(upper(t.name))) {
|
|
231
|
+
findings.push({
|
|
232
|
+
rule: 'csd-defines-diagnostic-transaction', path: t.path, line: t.line,
|
|
233
|
+
detail: `this repository installs ${upper(t.name)}, which runs CICS commands a terminal types rather than a program the estate wrote`,
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const pgm = runs.get(t.name);
|
|
238
|
+
if (!pgm || !issuesSpi.has(pgm)) continue;
|
|
239
|
+
// CMDSEC(NO) is also what a definition gets when it says nothing, so the absent case counts.
|
|
240
|
+
if (upper(t.cmdsec) === 'YES') continue;
|
|
241
|
+
findings.push({
|
|
242
|
+
rule: 'csd-transaction-without-command-security', path: t.path, line: t.line,
|
|
243
|
+
detail: `transaction ${upper(t.name)} runs ${pgm}, which issues CICS system commands, and its definition does not set CMDSEC(YES) - so the region checks nobody's authority to issue them`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The two the repository cannot witness. Without the estate's facts these assert nothing; with
|
|
248
|
+
// them the same observation is a defect, and the severity comes from the table below.
|
|
249
|
+
for (const j of jobUsers) {
|
|
250
|
+
const declared = (site.surrogateUsers || []).includes(j.user);
|
|
251
|
+
findings.push({
|
|
252
|
+
rule: 'job-runs-under-another-user', path: j.path, line: j.line,
|
|
253
|
+
...(site.surrogateUsers?.length && !declared ? { sev: 'med', evidence: 'construct' } : {}),
|
|
254
|
+
detail: site.surrogateUsers?.length
|
|
255
|
+
? (declared
|
|
256
|
+
? `this job runs as ${j.user}, which the estate names as a surrogate it permits`
|
|
257
|
+
: `this job runs as ${j.user}, which the estate does not name among the users a job may run as`)
|
|
258
|
+
: `this job runs as ${j.user}. Name the users a job may run as in cobolwork.site.json as surrogateUsers, and this becomes a finding or a clearance rather than an observation`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const u of securityUnloads) {
|
|
263
|
+
const restricted = (site.restrictedDatasets || []).length;
|
|
264
|
+
const bad = u.outputs.filter((d) => !under(d, site.restrictedDatasets || []));
|
|
265
|
+
findings.push({
|
|
266
|
+
rule: 'job-unloads-the-security-database', path: u.path, line: u.line,
|
|
267
|
+
...(restricted && bad.length ? { sev: 'high', evidence: 'construct' } : {}),
|
|
268
|
+
detail: restricted
|
|
269
|
+
? (bad.length
|
|
270
|
+
? `this job runs ${u.pgm} and writes the unloaded security database to ${bad.join(', ')}, which is not under any prefix the estate calls restricted`
|
|
271
|
+
: `this job runs ${u.pgm} and writes only to prefixes the estate calls restricted`)
|
|
272
|
+
: `this job runs ${u.pgm}, which unloads the security database. Name the prefixes only a privileged user may read in cobolwork.site.json as restrictedDatasets, and where its output lands becomes a finding or a clearance`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const d of diagnostics) {
|
|
277
|
+
const restricted = (site.restrictedDatasets || []).length;
|
|
278
|
+
const covered = restricted && under(d.dsn, site.restrictedDatasets);
|
|
279
|
+
if (restricted && covered) continue;
|
|
280
|
+
findings.push({
|
|
281
|
+
rule: 'job-writes-diagnostic-output-unrestricted', path: d.path, line: d.line,
|
|
282
|
+
...(restricted ? { sev: 'med', evidence: 'construct' } : {}),
|
|
283
|
+
detail: restricted
|
|
284
|
+
? `this job writes ${d.dsn}, whose name says it holds diagnostic output, to a prefix the estate does not call restricted`
|
|
285
|
+
: `this job writes ${d.dsn}, whose name says it holds diagnostic output. Name the prefixes only a privileged user may read in cobolwork.site.json as restrictedDatasets, and where it lands becomes a finding or a clearance`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const u of uss) {
|
|
290
|
+
const declared = (site.superuserIds || []).length;
|
|
291
|
+
findings.push({
|
|
292
|
+
rule: 'job-reaches-unix-system-services', path: u.path, line: u.line,
|
|
293
|
+
detail: declared
|
|
294
|
+
? `this job ${u.how}, and the estate names ${site.superuserIds.length} id(s) it treats as privileged: check the one this step runs under is not among them`
|
|
295
|
+
: `this job ${u.how}, so a shell runs with whatever UID the step carries. Name the ids the estate treats as privileged in cobolwork.site.json as superuserIds`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const a of authorisedLoads) {
|
|
300
|
+
if (!(site.apfLibraries || []).length) continue; // with no fact there is nothing to observe
|
|
301
|
+
if (!under(a.dsn, site.apfLibraries)) continue;
|
|
302
|
+
findings.push({
|
|
303
|
+
rule: 'job-loads-from-an-authorised-library', path: a.path, line: a.line,
|
|
304
|
+
detail: `this job loads from ${a.dsn}, which the estate names as APF-authorised, so anything written into that library runs with authority`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// A check that had something to judge and no fact to judge it by has not run as a defect check.
|
|
309
|
+
// Each finding says so, but a reader deciding whether the scan came back clean reads the summary,
|
|
310
|
+
// and the APF check, which has nothing to observe without its fact, says nothing anywhere else.
|
|
311
|
+
const unjudged = [
|
|
312
|
+
[jobUsers.length, 'surrogateUsers', 'job(s) name the user they run as'],
|
|
313
|
+
[securityUnloads.length, 'restrictedDatasets', 'step(s) unload the security database'],
|
|
314
|
+
[authorisedLoads.length, 'apfLibraries', 'STEPLIB or JOBLIB statement(s) load programs'],
|
|
315
|
+
].filter(([n, fact]) => n && !(site[fact] || []).length);
|
|
316
|
+
if (unjudged.length) {
|
|
317
|
+
stats.setIncomplete = true;
|
|
318
|
+
stats.notLooked.push(`${unjudged.map(([n, fact, what]) => `${n} ${what} and no ${fact} are declared`).join('; ')}, so ${unjudged.length === 1 ? 'that check was' : 'those checks were'} not judged: name them in cobolwork.site.json`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return report('priv', { rules: PRIV_RULES, findings, stats, run });
|
|
322
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// What an attacker would want to know first. None of it is a credential, and that is the point:
|
|
3
|
+
// an LPAR name, a production dataset qualifier, a VTAM applid or a routable address tells someone
|
|
4
|
+
// where to aim before they have anything to aim with.
|
|
5
|
+
//
|
|
6
|
+
// The rules answer to different authorities. The production rules need the estate to say what
|
|
7
|
+
// production means and are silent without it. The address rule is true everywhere and needs
|
|
8
|
+
// nothing: a routable address written into source is a routable address whoever reads it.
|
|
9
|
+
//
|
|
10
|
+
// The silence matters. Without cobolwork.site.json this rule set has not looked, and the summary
|
|
11
|
+
// says so rather than reporting a clean result. It says it as setIncomplete rather than
|
|
12
|
+
// coverageIncomplete, because "nobody told us what production means" and "a copybook could not be
|
|
13
|
+
// read" call for different actions from different people.
|
|
14
|
+
import { inScope, isJcl, isProgram, isCopybook, relPath } from '../sources.mjs';
|
|
15
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
16
|
+
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
17
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
18
|
+
import { loadSite, classifyPath, productionQualifierOf, SITE_FILE } from '../site.mjs';
|
|
19
|
+
import { parseJcl, dispositionOf } from '../jcl.mjs';
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
// The production-dataset rules claim more than the name rule: that the job acts on production, not
|
|
23
|
+
// only that it names it. A test job that creates, extends, overwrites or deletes production data
|
|
24
|
+
// changes the system of record from outside production's controls; one that reads it carries the
|
|
25
|
+
// data out. Hence two rules, high and medium. Both are constructs, because the site file has
|
|
26
|
+
// already said which side of the line the job and the dataset are on, and both are CWE-653: the
|
|
27
|
+
// defect is that test and production are not kept apart. MITRE discourages mapping to CWE-668, the
|
|
28
|
+
// other candidate. Neither is critical, since naming a dataset is not proof the security product
|
|
29
|
+
// lets the job open it.
|
|
30
|
+
export const RECON_RULES = {
|
|
31
|
+
'recon-production-name-outside-production': {
|
|
32
|
+
sev: 'med', evidence: 'exposure', cwe: 'CWE-497',
|
|
33
|
+
text: 'A name this estate calls production appears in a file that is not a production job',
|
|
34
|
+
impact: 'A production qualifier or system name appears in a job the estate does not run in production, telling a reader what to aim at',
|
|
35
|
+
remedy: 'Keep production dataset names and system names out of non-production jobs; reference them through a symbolic the environment sets',
|
|
36
|
+
},
|
|
37
|
+
'recon-routable-address-committed': {
|
|
38
|
+
sev: 'low', evidence: 'exposure', cwe: 'CWE-497',
|
|
39
|
+
text: 'A routable network address is written into source',
|
|
40
|
+
impact: 'A routable address written into source tells a reader a real host to reach, and is the first thing someone mapping the estate wants',
|
|
41
|
+
remedy: 'Remove the address from source; reference the host through configuration the environment supplies',
|
|
42
|
+
},
|
|
43
|
+
'recon-nonproduction-job-writes-production-dataset': {
|
|
44
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-653',
|
|
45
|
+
text: 'A job the estate calls non-production creates, extends, overwrites or deletes a production dataset',
|
|
46
|
+
impact: 'A job the estate calls non-production creates, extends, holds or deletes a production dataset, so test-change authority reaches production data',
|
|
47
|
+
remedy: 'Point the job at non-production datasets, or move it under production change control if it must touch production data',
|
|
48
|
+
},
|
|
49
|
+
'recon-nonproduction-job-reads-production-dataset': {
|
|
50
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-653',
|
|
51
|
+
text: 'A job the estate calls non-production reads a production dataset',
|
|
52
|
+
impact: 'A job the estate calls non-production reads a production dataset, so production data is exposed to a non-production environment',
|
|
53
|
+
remedy: 'Copy the data through a controlled, de-identified extract, or run the job under production controls',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Addresses that say nothing about anyone's network. Private and loopback ranges are the ordinary
|
|
58
|
+
// contents of a test fixture; the documentation ranges exist precisely to be written down.
|
|
59
|
+
function isReserved(a, b) {
|
|
60
|
+
if (a === 10 || a === 127 || a === 0 || a >= 224) return true;
|
|
61
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
62
|
+
if (a === 192 && b === 168) return true;
|
|
63
|
+
if (a === 169 && b === 254) return true;
|
|
64
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const DOCUMENTATION = /^(192\.0\.2|198\.51\.100|203\.0\.113)\./;
|
|
68
|
+
|
|
69
|
+
// In COBOL an address only counts inside a literal: four dotted numbers in open text are as likely
|
|
70
|
+
// to be a version or a record layout.
|
|
71
|
+
//
|
|
72
|
+
// In JCL the whole statement counts, because a dataset name qualifier cannot be purely numeric -
|
|
73
|
+
// it must begin with a letter or a national character - so a dotted quad of digits in a job is not
|
|
74
|
+
// a dataset name. An address on an in-stream FTP control card is exactly what this rule is for,
|
|
75
|
+
// and that is data rather than an operand. Only comments are skipped.
|
|
76
|
+
const IN_LITERAL = /'([^']{1,200})'|"([^"]{1,200})"/g;
|
|
77
|
+
const IPV4 = /\b((?:\d{1,3})\.(?:\d{1,3})\.(?:\d{1,3})\.(?:\d{1,3}))\b/g;
|
|
78
|
+
|
|
79
|
+
function collect(value, out) {
|
|
80
|
+
IPV4.lastIndex = 0;
|
|
81
|
+
let ip;
|
|
82
|
+
while ((ip = IPV4.exec(value)) !== null) {
|
|
83
|
+
const parts = ip[1].split('.').map(Number);
|
|
84
|
+
if (parts.some((n) => n > 255)) continue;
|
|
85
|
+
if (isReserved(parts[0], parts[1])) continue;
|
|
86
|
+
if (DOCUMENTATION.test(ip[1])) continue;
|
|
87
|
+
out.push({ address: ip[1] });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function routableAddresses(text, jcl) {
|
|
92
|
+
const out = [];
|
|
93
|
+
if (jcl) {
|
|
94
|
+
for (const line of text.split(/\r?\n/)) {
|
|
95
|
+
if (/^\/\/\*/.test(line)) continue;
|
|
96
|
+
collect(line.slice(0, 72), out);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
IN_LITERAL.lastIndex = 0;
|
|
101
|
+
let m;
|
|
102
|
+
while ((m = IN_LITERAL.exec(text)) !== null) collect(m[1] ?? m[2] ?? '', out);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A qualifier matches on a whole dotted component, never on a substring: PROD matches PROD.MASTER
|
|
107
|
+
// and PRODLIB.X only if PRODLIB was itself listed. Matching on substrings is how a rule like this
|
|
108
|
+
// starts reporting every line in the estate.
|
|
109
|
+
const qualifierPattern = (q) => new RegExp('(^|[^A-Z0-9$#@.])' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=[.\\s,)\'"]|$)', 'i');
|
|
110
|
+
|
|
111
|
+
// JOBLIB and STEPLIB are where the system looks for the program, so what they hold is code rather
|
|
112
|
+
// than data. A test job running production load modules is left to the name rule.
|
|
113
|
+
const PROGRAM_LIBRARIES = new Set(['JOBLIB', 'STEPLIB']);
|
|
114
|
+
|
|
115
|
+
const WRITES = 'recon-nonproduction-job-writes-production-dataset';
|
|
116
|
+
const READS = 'recon-nonproduction-job-reads-production-dataset';
|
|
117
|
+
|
|
118
|
+
// What the disposition says the step does, in the words the finding uses. SHR is a shared open,
|
|
119
|
+
// and a program that writes a dataset it opened SHR - a member through SYSUT2 or SYSLMOD - reads as
|
|
120
|
+
// a read here: which DDs a program writes is a property of the program, not of the disposition.
|
|
121
|
+
// A disposition that decides nothing is left to the name rule.
|
|
122
|
+
const DOES = {
|
|
123
|
+
create: [WRITES, (d, at) => `creates ${d} ${at}`],
|
|
124
|
+
append: [WRITES, (d, at) => `adds records to ${d} ${at}`],
|
|
125
|
+
exclusive: [WRITES, (d, at) => `holds ${d} exclusively ${at}, which is how a step overwrites it or updates it in place`],
|
|
126
|
+
read: [READS, (d, at) => `reads ${d} ${at}`],
|
|
127
|
+
};
|
|
128
|
+
const REMOVES = { DELETE: 'deletes', UNCATLG: 'uncatalogs' };
|
|
129
|
+
|
|
130
|
+
function touchOf(dd) {
|
|
131
|
+
const disp = dispositionOf(dd.disp);
|
|
132
|
+
if (!disp) return null;
|
|
133
|
+
const at = `(DISP=${dd.disp})`;
|
|
134
|
+
// On a dataset the step creates, DELETE only removes what the step itself made.
|
|
135
|
+
if (dd.access !== 'create') {
|
|
136
|
+
if (REMOVES[disp.normal]) return { rule: WRITES, verb: `${REMOVES[disp.normal]} ${dd.dsn} when the step ends ${at}` };
|
|
137
|
+
if (REMOVES[disp.abnormal]) return { rule: WRITES, verb: `${REMOVES[disp.abnormal]} ${dd.dsn} if the step fails ${at}` };
|
|
138
|
+
}
|
|
139
|
+
const does = DOES[dd.access];
|
|
140
|
+
return does ? { rule: does[0], verb: does[1](dd.dsn, at) } : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The DD statements of a non-production job that act on a production dataset, and the lines each
|
|
144
|
+
// takes up, so the name rule does not report the same statement a second time.
|
|
145
|
+
function productionTouches(job, qualifiers) {
|
|
146
|
+
const found = [];
|
|
147
|
+
const claimed = new Set();
|
|
148
|
+
let library = null;
|
|
149
|
+
for (const dd of job.dds) {
|
|
150
|
+
if (dd.name) library = PROGRAM_LIBRARIES.has(dd.name.toUpperCase()) ? dd.name : null;
|
|
151
|
+
if (!dd.dsn || dd.temporary || library) continue;
|
|
152
|
+
const qualifier = productionQualifierOf(dd.dsn, qualifiers);
|
|
153
|
+
if (!qualifier) continue;
|
|
154
|
+
const touch = touchOf(dd);
|
|
155
|
+
if (!touch) continue;
|
|
156
|
+
found.push({ ...touch, dd, qualifier });
|
|
157
|
+
for (let l = dd.line; l <= dd.endLine; l++) claimed.add(l);
|
|
158
|
+
}
|
|
159
|
+
return { found, claimed };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function scanRecon(root, opts = {}) {
|
|
163
|
+
const site = loadSite(root, opts.site || null);
|
|
164
|
+
const tree = treeFor(root, opts);
|
|
165
|
+
const files = tree.list().filter(inScope(opts))
|
|
166
|
+
.filter((f) => isJcl(f) || isProgram(f) || isCopybook(f));
|
|
167
|
+
|
|
168
|
+
const findings = [];
|
|
169
|
+
const stats = {
|
|
170
|
+
filesScanned: 0, filesUnreadable: 0,
|
|
171
|
+
siteConfigured: site.present,
|
|
172
|
+
siteProblems: site.problems,
|
|
173
|
+
productionQualifiers: site.productionQualifiers.length,
|
|
174
|
+
systemNames: site.systemNames.length,
|
|
175
|
+
pathsUndecided: 0,
|
|
176
|
+
undecidedNamingProduction: 0,
|
|
177
|
+
// Without a site file the production rule has not run at all. That is not the same claim as
|
|
178
|
+
// "a file could not be read", so it travels as setIncomplete rather than coverageIncomplete:
|
|
179
|
+
// the difference is between "we looked and found nothing" and "nothing told us what to look
|
|
180
|
+
// for", and a reader needs to be able to tell them apart.
|
|
181
|
+
setIncomplete: !site.present || site.problems.length > 0,
|
|
182
|
+
notLooked: site.present ? [] : [`no ${SITE_FILE}: the production-name and production-dataset rules did not run, because nothing declares what production means in this estate`],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const names = [
|
|
186
|
+
...site.productionQualifiers.map((q) => ({ value: q, kind: 'production qualifier' })),
|
|
187
|
+
...site.systemNames.map((q) => ({ value: q, kind: 'system name' })),
|
|
188
|
+
];
|
|
189
|
+
const patterns = names.map((n) => ({ ...n, re: qualifierPattern(n.value) }));
|
|
190
|
+
|
|
191
|
+
// This set reads every program, copybook and job in the tree, which is the widest reading any
|
|
192
|
+
// set does, so it is walked inside the guard like the rest.
|
|
193
|
+
const run = eachWithinMemory(files, (f) => {
|
|
194
|
+
let src;
|
|
195
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
196
|
+
stats.filesScanned++;
|
|
197
|
+
const path = relPath(root, f);
|
|
198
|
+
const lines = src.split(/\r?\n/);
|
|
199
|
+
const jcl = isJcl(f);
|
|
200
|
+
|
|
201
|
+
if (patterns.length) {
|
|
202
|
+
const where = classifyPath(site, path);
|
|
203
|
+
if (where === 'undecided') {
|
|
204
|
+
stats.pathsUndecided++;
|
|
205
|
+
if (lines.some((l) => !(jcl && /^\/\/\*/.test(l)) && patterns.some((p) => p.re.test(l)))) stats.undecidedNamingProduction++;
|
|
206
|
+
}
|
|
207
|
+
// A production job naming production is the job doing its work. Only a file that is known
|
|
208
|
+
// not to be a production job is a finding: an undecided path is left alone, because the
|
|
209
|
+
// alternative is reporting the entire estate on the first run.
|
|
210
|
+
if (where === 'non-production') {
|
|
211
|
+
let claimed = new Set();
|
|
212
|
+
if (jcl && site.productionQualifiers.length) {
|
|
213
|
+
let job = null;
|
|
214
|
+
try { job = parseJcl(src, f, { symbols: opts.symbols || {} }); } catch (e) { noteUnparsed(stats, tree, f, e); }
|
|
215
|
+
if (job) {
|
|
216
|
+
const touches = productionTouches(job, site.productionQualifiers);
|
|
217
|
+
claimed = touches.claimed;
|
|
218
|
+
for (const t of touches.found) {
|
|
219
|
+
findings.push({
|
|
220
|
+
rule: t.rule, path, line: t.dd.line, step: t.dd.step,
|
|
221
|
+
detail: `${t.dd.step ? `step ${t.dd.step}` : 'the job'} ${t.verb}; ${t.qualifier} is a production qualifier, and ${SITE_FILE} lists ${path} as not a production job`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
lines.forEach((line, i) => {
|
|
228
|
+
if (claimed.has(i + 1)) return;
|
|
229
|
+
if (jcl && /^\/\/\*/.test(line)) return;
|
|
230
|
+
for (const p of patterns) {
|
|
231
|
+
if (seen.has(p.value)) continue;
|
|
232
|
+
if (!p.re.test(line)) continue;
|
|
233
|
+
seen.add(p.value);
|
|
234
|
+
findings.push({
|
|
235
|
+
rule: 'recon-production-name-outside-production', path, line: i + 1,
|
|
236
|
+
detail: `${p.kind} ${p.value} appears in ${path}, which ${site.path ? SITE_FILE : 'the site configuration'} lists as not a production job`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const seenAddr = new Set();
|
|
244
|
+
for (const a of routableAddresses(src, jcl)) {
|
|
245
|
+
if (seenAddr.has(a.address)) continue;
|
|
246
|
+
seenAddr.add(a.address);
|
|
247
|
+
const line = lines.findIndex((l) => l.includes(a.address)) + 1 || 1;
|
|
248
|
+
findings.push({
|
|
249
|
+
rule: 'recon-routable-address-committed', path, line,
|
|
250
|
+
detail: `${a.address} is a routable address written into ${path}; it is not a credential, and it is the first thing someone would want to know`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return src.length;
|
|
254
|
+
}, { label: 'recon', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
255
|
+
|
|
256
|
+
// A file that names production and that the site lists as neither kind of job is the one the name
|
|
257
|
+
// rule leaves alone, so it was not judged. Files that name nothing are not counted: their
|
|
258
|
+
// classification could not have changed a finding.
|
|
259
|
+
if (stats.undecidedNamingProduction) {
|
|
260
|
+
stats.setIncomplete = true;
|
|
261
|
+
stats.notLooked.push(`${stats.undecidedNamingProduction} file(s) name a production qualifier or system name and match neither productionJobPaths nor nonProductionJobPaths in ${SITE_FILE}, so whether each may was not judged`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// setIncomplete, set above, is a different claim from coverageIncomplete: nothing declared what
|
|
265
|
+
// production means, as against files going unread. Both travel, and they are not merged.
|
|
266
|
+
return report('recon', { rules: RECON_RULES, findings, stats, run });
|
|
267
|
+
}
|