@portll/cobolwork 0.2.76 → 0.2.140
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 +66 -14
- package/bin/cobolwork.mjs +126 -9
- package/lib/arith.mjs +235 -0
- package/lib/baseline.mjs +5 -0
- package/lib/build.mjs +112 -30
- package/lib/capabilities.mjs +12 -7
- package/lib/card.mjs +18 -0
- package/lib/cics-commands.mjs +136 -125
- package/lib/consequence.mjs +29 -0
- package/lib/control.mjs +1169 -91
- package/lib/csd.mjs +17 -2
- package/lib/dataflow.mjs +276 -126
- package/lib/embedded-sql.mjs +110 -0
- package/lib/enterprise-options.mjs +255 -0
- package/lib/equivalence.mjs +124 -0
- package/lib/evidence/cli.mjs +65 -0
- package/lib/evidence/journal.mjs +94 -0
- package/lib/evidence/record.mjs +110 -0
- package/lib/evidence/run.mjs +111 -0
- package/lib/evidence/seal.mjs +177 -0
- package/lib/evidence/slsa.mjs +64 -0
- package/lib/evidence/sshsig.mjs +172 -0
- package/lib/evidence/store.mjs +170 -0
- package/lib/evidence/verify.mjs +167 -0
- package/lib/explain.mjs +9 -5
- package/lib/exploitability.mjs +263 -0
- package/lib/ftp.mjs +136 -0
- package/lib/ironwork.mjs +109 -0
- package/lib/jcl.mjs +27 -7
- package/lib/kernel/findings.mjs +11 -0
- package/lib/kernel/registry.mjs +4 -0
- package/lib/layout.mjs +202 -0
- package/lib/option-diff.mjs +49 -0
- package/lib/options.mjs +109 -46
- package/lib/parser.mjs +80 -209
- package/lib/policy.mjs +8 -3
- package/lib/precompile.mjs +5 -84
- package/lib/reach.mjs +29 -12
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +1 -0
- package/lib/sbom.mjs +235 -0
- package/lib/scan.mjs +93 -27
- package/lib/sets/cics.mjs +119 -2
- package/lib/sets/copybook.mjs +2 -1
- package/lib/sets/flow.mjs +82 -2
- package/lib/sets/hidden.mjs +4 -8
- package/lib/sets/jcl.mjs +70 -106
- package/lib/sets/recon.mjs +2 -1
- package/lib/sets/semantics.mjs +377 -0
- package/lib/sets/web.mjs +34 -10
- package/lib/sets/zowe.mjs +246 -0
- package/lib/sources.mjs +10 -0
- package/lib/tui/app.mjs +31 -13
- package/lib/tui/model.mjs +17 -2
- package/lib/utilities.mjs +229 -15
- package/lib/verify.mjs +128 -0
- package/package.json +1 -1
- package/rules/compliance-dora.json +507 -0
- package/rules/compliance-ffiec.json +507 -0
- package/rules/compliance-nist80053.json +507 -0
- package/schema/cobolwork.policy.schema.json +94 -16
package/lib/reach.mjs
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
// effect is unstated, and the scan says so rather than ranking anything. See docs/spec/reach.md.
|
|
11
11
|
//
|
|
12
12
|
// Both are annotations, not re-rankings: severity is urgency, reach is who can drive it, effect is
|
|
13
|
-
// what driving it runs as. Three axes
|
|
14
|
-
//
|
|
15
|
-
// which is not what reading source and records produces.
|
|
13
|
+
// what driving it runs as. Three axes kept apart so each names the fact that moved it;
|
|
14
|
+
// lib/exploitability.mjs joins them with the route into a verdict.
|
|
16
15
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
17
16
|
import { basename, sep, delimiter } from 'node:path';
|
|
18
17
|
|
|
@@ -82,24 +81,42 @@ export function reachResolver({ feeds = [], site = {} } = {}) {
|
|
|
82
81
|
return null;
|
|
83
82
|
};
|
|
84
83
|
const privilegedOf = (kind, name) => (kind === 'transaction' ? privTx.has(up(name)) : kind === 'job' ? privJob.has(up(name)) : false);
|
|
85
|
-
|
|
84
|
+
// Which record said so, for a verdict to cite: the feed with its extract and date, or the site file.
|
|
85
|
+
const siteName = site.path ? basename(site.path) : 'the site file';
|
|
86
|
+
const cite = (f) => `${f.file} (${f.extract}, retrieved ${f.retrieved})`;
|
|
87
|
+
const accessFrom = (kind, name) => {
|
|
88
|
+
const f = good.find((x) => x[`${kind}s`][up(name)]);
|
|
89
|
+
return f ? cite(f) : accessOf(kind, name) ? siteName : null;
|
|
90
|
+
};
|
|
91
|
+
const privilegedFrom = (kind, name) => {
|
|
92
|
+
const f = good.find((x) => x.privileged[`${kind}s`].includes(up(name)));
|
|
93
|
+
return f ? cite(f) : privilegedOf(kind, name) ? siteName : null;
|
|
94
|
+
};
|
|
95
|
+
return { declared, effectDeclared, accessOf, privilegedOf, accessFrom, privilegedFrom };
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
export const entryKind = (e) => (e.transaction ? 'transaction' : e.job !== undefined ? 'job' : null);
|
|
99
|
+
const entryKey = (e) => (e.transaction ? e.transaction : e.job || '');
|
|
100
|
+
export const entryName = (e) => (e.transaction
|
|
101
|
+
? `transaction ${e.transaction}${e.urimap ? ` through URIMAP ${e.urimap}${e.uri ? ` (${e.uri})` : ''}` : ''}`
|
|
102
|
+
: `job ${e.job || '?'} step ${e.step || '(unnamed)'}`);
|
|
103
|
+
|
|
88
104
|
// The access of one startedBy entry, whichever kind it is.
|
|
89
|
-
const accessOfEntry = (e, resolver) => (e
|
|
105
|
+
export const accessOfEntry = (e, resolver) => (entryKind(e) ? resolver.accessOf(entryKind(e), entryKey(e)) : null);
|
|
106
|
+
export const accessFromEntry = (e, resolver) => (entryKind(e) ? resolver.accessFrom(entryKind(e), entryKey(e)) : null);
|
|
107
|
+
export const privilegedFromEntry = (e, resolver) => (entryKind(e) ? resolver.privilegedFrom(entryKind(e), entryKey(e)) : null);
|
|
90
108
|
|
|
91
109
|
// Stamp `reach` on every finding an entry point reaches, and count the three ways. `open` if any
|
|
92
|
-
// entry that reaches it is open
|
|
93
|
-
//
|
|
94
|
-
// carries no reach - there is nothing to say.
|
|
110
|
+
// entry that reaches it is open; `restricted` only if every entry is restricted and none went
|
|
111
|
+
// unlisted, since an entry nobody declared may be open; otherwise `undeclared`. A finding no entry
|
|
112
|
+
// reaches carries no reach - there is nothing to say.
|
|
95
113
|
export function stampReach(findings, resolver) {
|
|
96
114
|
const by = { open: 0, restricted: 0, undeclared: 0 };
|
|
97
115
|
for (const f of findings) {
|
|
98
116
|
const entries = f.startedBy || [];
|
|
99
117
|
if (!entries.length) continue;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
f.reach = open ? 'open' : restricted ? 'restricted' : 'undeclared';
|
|
118
|
+
const access = entries.map((e) => accessOfEntry(e, resolver));
|
|
119
|
+
f.reach = access.includes('open') ? 'open' : !f.startedByMore && access.every((a) => a === 'restricted') ? 'restricted' : 'undeclared';
|
|
103
120
|
by[f.reach]++;
|
|
104
121
|
}
|
|
105
122
|
return by;
|
|
@@ -113,7 +130,7 @@ export function stampEffect(findings, resolver) {
|
|
|
113
130
|
for (const f of findings) {
|
|
114
131
|
const entries = f.startedBy || [];
|
|
115
132
|
if (!entries.length) continue;
|
|
116
|
-
if (entries.some((e) => (e
|
|
133
|
+
if (entries.some((e) => entryKind(e) && resolver.privilegedOf(entryKind(e), entryKey(e)))) {
|
|
117
134
|
f.effect = 'privileged';
|
|
118
135
|
by.privileged++;
|
|
119
136
|
}
|
package/lib/revision.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"54c9654b8c13bc22c8d5683b02551362d95f0d0a","tag":"v0.2.140"}
|
package/lib/sarif.mjs
CHANGED
|
@@ -211,6 +211,7 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
|
|
|
211
211
|
// Who can drive the finding and what it runs as, when the estate declared the facts.
|
|
212
212
|
...(f.reach ? { reach: f.reach } : {}),
|
|
213
213
|
...(f.effect ? { effect: f.effect } : {}),
|
|
214
|
+
...(f.exploitability ? { exploitability: f.exploitability } : {}),
|
|
214
215
|
} } : {}),
|
|
215
216
|
locations: [{ physicalLocation: { artifactLocation: { uri: uriOf(f.path), uriBaseId: '%SRCROOT%' }, region: { startLine: f.line || 1 } } }],
|
|
216
217
|
...(f.related ? { relatedLocations: f.related.map((r) => ({ physicalLocation: { artifactLocation: { uri: uriOf(r.path), uriBaseId: '%SRCROOT%' }, region: { startLine: r.line || 1 } }, message: { text: textOf(r.detail) } })) } : {}),
|
package/lib/sbom.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A CycloneDX 1.6 bill of materials for a COBOL estate (docs/spec/evidence.md §11): every source
|
|
3
|
+
// file by digest, what each program copies and calls, what each job runs, and the platforms a
|
|
4
|
+
// program uses, without versions the source does not state.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, extname, resolve, sep } from 'node:path';
|
|
8
|
+
import { parseSource, detectFormat } from './parser.mjs';
|
|
9
|
+
import { parseJcl } from './jcl.mjs';
|
|
10
|
+
import { optionCards } from './options.mjs';
|
|
11
|
+
import { inScope, isBms, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
|
|
12
|
+
import { TOOL_VERSION } from './version.mjs';
|
|
13
|
+
import { revisionOf } from './revision.mjs';
|
|
14
|
+
import { treeFor } from './kernel/source-tree.mjs';
|
|
15
|
+
|
|
16
|
+
// The namespace of cobolwork's version 5 UUIDs (RFC 4122 §4.3), fixed so a serial number repeats.
|
|
17
|
+
const NAMESPACE = Buffer.from('6f1d2b8e5c3a4e7f9a0b1c2d3e4f5a6b', 'hex');
|
|
18
|
+
|
|
19
|
+
function uuidv5(name) {
|
|
20
|
+
const h = createHash('sha1').update(NAMESPACE).update(name, 'utf8').digest().subarray(0, 16);
|
|
21
|
+
h[6] = (h[6] & 0x0f) | 0x50;
|
|
22
|
+
h[8] = (h[8] & 0x3f) | 0x80;
|
|
23
|
+
const x = h.toString('hex');
|
|
24
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
28
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
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
|
+
|
|
74
|
+
function platformsOf(src, calls) {
|
|
75
|
+
const s = new Set();
|
|
76
|
+
if (/EXEC\s+CICS\b/i.test(src)) s.add('CICS');
|
|
77
|
+
if (/EXEC\s+SQL\b/i.test(src)) s.add('Db2');
|
|
78
|
+
if (/EXEC\s+DLI\b/i.test(src)) s.add('IMS');
|
|
79
|
+
for (const c of calls) {
|
|
80
|
+
if (c.kind !== 'L') continue;
|
|
81
|
+
const n = String(c.name).toUpperCase();
|
|
82
|
+
if (n === 'CBLTDLI' || n === 'AIBTDLI') s.add('IMS');
|
|
83
|
+
if (/^MQ[A-Z0-9]{2,}$/.test(n)) s.add('MQ');
|
|
84
|
+
if (/^CEE[A-Z0-9]+$/.test(n)) s.add('Language Environment');
|
|
85
|
+
}
|
|
86
|
+
return [...s];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function kindOf(file, src) {
|
|
90
|
+
if (isProgram(file)) return /PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src) ? 'program' : 'copybook';
|
|
91
|
+
if (isCopybook(file)) return 'copybook';
|
|
92
|
+
if (isJcl(file)) return /^\/\/\S*\s+PROC\b/m.test(src) && !/^\/\/\S*\s+JOB\b/m.test(src) ? 'proc' : 'jcl';
|
|
93
|
+
if (isBms(file)) return 'bms';
|
|
94
|
+
const ext = extname(file).toLowerCase();
|
|
95
|
+
if (ext === '.csd') return 'csd';
|
|
96
|
+
if (ext === '.ddl' || ext === '.sql') return 'ddl';
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function sbom(root, opts = {}) {
|
|
101
|
+
const tree = treeFor(root, opts);
|
|
102
|
+
const idx = tree.index;
|
|
103
|
+
const top = resolve(root);
|
|
104
|
+
const within = (p) => resolve(p).startsWith(top + sep);
|
|
105
|
+
const refOf = (p) => (within(p) ? relPath(root, p) : `copylib:${basename(p)}`);
|
|
106
|
+
|
|
107
|
+
const entries = [];
|
|
108
|
+
const programIds = new Map();
|
|
109
|
+
const mapsets = new Map();
|
|
110
|
+
const procIds = new Map();
|
|
111
|
+
for (const f of tree.list().filter(inScope(opts)).sort()) {
|
|
112
|
+
let bytes;
|
|
113
|
+
let src;
|
|
114
|
+
try { bytes = readFileSync(f); src = readSource(f).text; } catch { continue; }
|
|
115
|
+
const kind = kindOf(f, src);
|
|
116
|
+
if (!kind) continue;
|
|
117
|
+
const entry = { file: f, ref: relPath(root, f), bytes, src, kind };
|
|
118
|
+
if (kind === 'program') {
|
|
119
|
+
try {
|
|
120
|
+
const res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
|
|
121
|
+
entry.programs = res.programs;
|
|
122
|
+
entry.copies = res.copies;
|
|
123
|
+
for (const p of res.programs) if (p.id) programIds.set(String(p.id).toUpperCase(), entry.ref);
|
|
124
|
+
} catch { entry.unparsed = true; }
|
|
125
|
+
} else if (kind === 'jcl' || kind === 'proc') {
|
|
126
|
+
try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
|
|
127
|
+
if (kind === 'proc') {
|
|
128
|
+
const m = /^\/\/([A-Z0-9#@$]{1,8})\s+PROC\b/im.exec(src);
|
|
129
|
+
procIds.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
|
|
130
|
+
}
|
|
131
|
+
} else if (kind === 'bms') {
|
|
132
|
+
const m = /^([A-Z0-9#@$]{1,8})\s+DFHMSD\b/im.exec(src);
|
|
133
|
+
mapsets.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
|
|
134
|
+
}
|
|
135
|
+
entries.push(entry);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const components = new Map();
|
|
139
|
+
const dependencies = new Map();
|
|
140
|
+
const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
|
|
141
|
+
// A program the estate does not hold is a component of its own, so an edge to it is not lost.
|
|
142
|
+
const runs = (from, name) => {
|
|
143
|
+
const target = programIds.get(name);
|
|
144
|
+
if (target) { if (target !== from) depend(from, target); return; }
|
|
145
|
+
if (platformRoutine(name)) return;
|
|
146
|
+
const ref = `program:${name}`;
|
|
147
|
+
depend(from, ref);
|
|
148
|
+
if (!components.has(ref)) {
|
|
149
|
+
components.set(ref, { 'bom-ref': ref, type: 'application', name, properties: [{ name: 'cobolwork:kind', value: SYSTEM_PROGRAMS.has(name) ? 'system-program' : 'external-program' }] });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const e of entries) {
|
|
154
|
+
const properties = [{ name: 'cobolwork:kind', value: e.kind }];
|
|
155
|
+
if (e.unparsed) properties.push({ name: 'cobolwork:unparsed', value: 'true' });
|
|
156
|
+
if (e.kind === 'program' && e.programs && e.programs.length) {
|
|
157
|
+
const ids = e.programs.map((p) => p.id).filter(Boolean);
|
|
158
|
+
if (ids.length) properties.push({ name: 'cobolwork:program-id', value: ids.join(',') });
|
|
159
|
+
properties.push({ name: 'cobolwork:format', value: detectFormat(e.src) });
|
|
160
|
+
const options = optionCards(e.src).flatMap((c) => c.options);
|
|
161
|
+
if (options.length) properties.push({ name: 'cobolwork:options', value: options.join(' ') });
|
|
162
|
+
for (const c of e.copies || []) {
|
|
163
|
+
if (c.status !== 'resolved' || !c.path) continue;
|
|
164
|
+
const ref = refOf(c.path);
|
|
165
|
+
depend(e.ref, ref);
|
|
166
|
+
if (!within(c.path) && !components.has(ref)) {
|
|
167
|
+
let b = null;
|
|
168
|
+
try { b = readFileSync(c.path); } catch { /* read at parse time, gone since */ }
|
|
169
|
+
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' }] });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const calls = e.programs.flatMap((p) => p.calls || []);
|
|
173
|
+
const unresolved = new Set();
|
|
174
|
+
for (const c of calls) {
|
|
175
|
+
if (c.kind !== 'L') { unresolved.add(String(c.name)); continue; }
|
|
176
|
+
runs(e.ref, String(c.name).toUpperCase());
|
|
177
|
+
}
|
|
178
|
+
for (const x of e.programs.flatMap((p) => p.execs || [])) {
|
|
179
|
+
if (x.kind !== 'CICS') continue;
|
|
180
|
+
const words = x.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
181
|
+
if (words[0] === 'LINK' || words[0] === 'XCTL') {
|
|
182
|
+
const op = cicsOperand(x.toks, 'PROGRAM');
|
|
183
|
+
if (op && op.literal) runs(e.ref, op.literal); else if (op) unresolved.add(op.field);
|
|
184
|
+
} else if ((words[0] === 'SEND' || words[0] === 'RECEIVE') && words[1] === 'MAP') {
|
|
185
|
+
const op = cicsOperand(x.toks, 'MAPSET') || cicsOperand(x.toks, 'MAP');
|
|
186
|
+
const target = op && op.literal ? mapsets.get(op.literal) : null;
|
|
187
|
+
if (target) depend(e.ref, target);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (unresolved.size) properties.push({ name: 'cobolwork:unresolved-call', value: [...unresolved].sort(order).join(',') });
|
|
191
|
+
for (const p of platformsOf(e.src, calls)) {
|
|
192
|
+
const ref = `platform:${p}`;
|
|
193
|
+
depend(e.ref, ref);
|
|
194
|
+
if (!components.has(ref)) components.set(ref, { 'bom-ref': ref, type: 'platform', name: p });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (e.jcl) {
|
|
198
|
+
const inStream = new Set((e.jcl.procs || []).map((p) => String(p.name || '').toUpperCase()));
|
|
199
|
+
const missing = new Set();
|
|
200
|
+
for (const step of e.jcl.steps || []) {
|
|
201
|
+
if (step.pgm && /^[A-Z0-9#@$]{1,8}$/i.test(step.pgm)) runs(e.ref, String(step.pgm).toUpperCase());
|
|
202
|
+
for (const name of programsRunBy(step)) runs(e.ref, name);
|
|
203
|
+
if (!step.proc) continue;
|
|
204
|
+
const name = String(step.proc).toUpperCase();
|
|
205
|
+
const proc = procIds.get(name);
|
|
206
|
+
if (proc) { if (proc !== e.ref) depend(e.ref, proc); } else if (!inStream.has(name)) missing.add(name);
|
|
207
|
+
}
|
|
208
|
+
if (missing.size) properties.push({ name: 'cobolwork:unresolved-proc', value: [...missing].sort(order).join(',') });
|
|
209
|
+
}
|
|
210
|
+
components.set(e.ref, { 'bom-ref': e.ref, type: 'file', name: e.ref, hashes: [{ alg: 'SHA-256', content: sha256(e.bytes) }], properties });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const list = [...components.values()].sort((a, b) => order(a['bom-ref'], b['bom-ref']));
|
|
214
|
+
const digests = list.filter((c) => c.hashes).map((c) => c.hashes[0].content).sort(order);
|
|
215
|
+
const name = opts.name || basename(top);
|
|
216
|
+
let revision = null;
|
|
217
|
+
try { revision = revisionOf(top); } catch { /* not a repository, or git is missing */ }
|
|
218
|
+
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
219
|
+
return {
|
|
220
|
+
bomFormat: 'CycloneDX',
|
|
221
|
+
specVersion: '1.6',
|
|
222
|
+
serialNumber: `urn:uuid:${uuidv5(`${name}\n${digests.join('\n')}`)}`,
|
|
223
|
+
version: 1,
|
|
224
|
+
metadata: {
|
|
225
|
+
...(epoch && /^\d+$/.test(epoch) ? { timestamp: new Date(Number(epoch) * 1000).toISOString() } : {}),
|
|
226
|
+
tools: { components: [{ type: 'application', name: 'cobolwork', version: TOOL_VERSION }] },
|
|
227
|
+
component: {
|
|
228
|
+
'bom-ref': `estate:${name}`, type: 'application', name,
|
|
229
|
+
...(revision ? { version: revision.commit, properties: [{ name: 'cobolwork:dirty', value: String(revision.dirty) }] } : {}),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
components: list,
|
|
233
|
+
dependencies: [...dependencies.entries()].sort(([a], [b]) => order(a, b)).map(([ref, set]) => ({ ref, dependsOn: [...set].sort(order) })),
|
|
234
|
+
};
|
|
235
|
+
}
|
package/lib/scan.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { sortFindings, tally, evidenceMap, EVIDENCE } from './kernel/findings.mjs';
|
|
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';
|
|
6
6
|
import { inventory } from './inventory.mjs';
|
|
@@ -12,6 +12,7 @@ import { printable } from './kernel/printable.mjs';
|
|
|
12
12
|
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
|
+
import { stampExploitability, byVerdict, HANDLING, witnessFeedPaths, loadWitnessFeed, applyWitness } from './exploitability.mjs';
|
|
15
16
|
|
|
16
17
|
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
17
18
|
|
|
@@ -34,11 +35,46 @@ const underRepo = (repo, p) => (repo ? `${repo}/${p}` : p);
|
|
|
34
35
|
// Frameworks that did not load; a reason can quote the unparsed file.
|
|
35
36
|
const COMPLIANCE_UNLOADED = FRAMEWORKS_UNLOADED.map((f) => ({ id: f.id, why: printable(f.why) }));
|
|
36
37
|
|
|
38
|
+
// The estate facts of several repositories' reports as one summary: counts add, feeds are the same
|
|
39
|
+
// for every repository so the first names them, and a note says which repositories it covers.
|
|
40
|
+
function mergeEstate(reports) {
|
|
41
|
+
const all = reports.map(({ repo, r }) => ({ repo, s: r.summary }));
|
|
42
|
+
const addUp = (k) => {
|
|
43
|
+
const have = all.filter((x) => x.s[k]);
|
|
44
|
+
if (!have.length) return null;
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const { s } of have) for (const [f, n] of Object.entries(s[k])) out[f] = (out[f] || 0) + n;
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
const union = (k) => [...new Set(all.flatMap((x) => x.s[k] || []))];
|
|
50
|
+
const first = (k) => all.find((x) => x.s[k])?.s[k];
|
|
51
|
+
const without = (k) => all.filter((x) => x.s[k]).map((x) => x.repo);
|
|
52
|
+
const of = (names) => `${names.length} of ${all.length} repositories (${names.join(', ')})`;
|
|
53
|
+
const reachless = without('reachNote');
|
|
54
|
+
const effectless = without('effectNote');
|
|
55
|
+
const driven = all.filter((x) => x.s.reachNote).reduce((n, x) => n + (x.s.byExploitability?.['attacker-driven'] || 0), 0);
|
|
56
|
+
// A result is unmatched only where no repository's finding carries it.
|
|
57
|
+
const unmatchedIn = all.map((x) => new Set(x.s.witnessUnmatched || []));
|
|
58
|
+
const unmatched = first('witnessFeeds') ? [...unmatchedIn[0]].filter((fp) => unmatchedIn.every((u) => u.has(fp))) : [];
|
|
59
|
+
const overruled = all.flatMap((x) => (x.s.witnessOverruled || []).map((o) => ({ ...o, path: underRepo(x.repo, o.path) })));
|
|
60
|
+
const merged = {
|
|
61
|
+
byReach: addUp('byReach'), byEffect: addUp('byEffect'),
|
|
62
|
+
reachFeeds: first('reachFeeds'), reachFeedProblems: union('reachFeedProblems'),
|
|
63
|
+
reachNote: reachless.length ? `no reachability facts declared in ${of(reachless)}, so no finding there was judged reachable${driven ? `, and the ${driven} an attacker drives are labelled attacker-driven rather than exploitable` : ''}: name open/restricted transactions and jobs in each repository's cobolwork.site.json, or bring a COBOLWORK_REACH extract` : null,
|
|
64
|
+
effectNote: effectless.length ? `no authority facts declared in ${of(effectless)}, so no finding there was judged to run privileged: name privilegedTransactions/privilegedJobs in each repository's cobolwork.site.json, or bring them in the COBOLWORK_REACH extract` : null,
|
|
65
|
+
witnessFeeds: first('witnessFeeds'), byWitness: addUp('byWitness'), witnessFeedProblems: union('witnessFeedProblems'),
|
|
66
|
+
witnessUnmatched: unmatched, witnessOverruled: overruled,
|
|
67
|
+
};
|
|
68
|
+
return Object.fromEntries(Object.entries(merged).filter(([, v]) => (Array.isArray(v) ? v.length : v)));
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
function scanEach(root, opts) {
|
|
38
|
-
|
|
72
|
+
// A feed is refused anywhere inside the scanned root, not only inside the repository being read.
|
|
73
|
+
const reports = opts.repos.map(repo => ({ repo, r: scanAll(join(root, repo), { ...opts, repos: null, repoName: repo, feedRoot: opts.feedRoot || root }) }));
|
|
39
74
|
const findings = [];
|
|
40
75
|
const checked = [];
|
|
41
|
-
const
|
|
76
|
+
const fixedAt = (repo, x) => (x?.fixAt ? { exploitability: { ...x, fixAt: { ...x.fixAt, path: underRepo(repo, x.fixAt.path) } } } : {});
|
|
77
|
+
const prefixed = (repo, f) => ({ ...f, path: underRepo(repo, f.path), ...(f.related ? { related: f.related.map(x => ({ ...x, path: underRepo(repo, x.path) })) } : {}), ...fixedAt(repo, f.exploitability) });
|
|
42
78
|
for (const { repo, r } of reports) {
|
|
43
79
|
for (const f of r.findings) findings.push(prefixed(repo, f));
|
|
44
80
|
for (const f of r.checked || []) checked.push(prefixed(repo, f));
|
|
@@ -51,7 +87,9 @@ function scanEach(root, opts) {
|
|
|
51
87
|
for (const f of findings) bySeverity[f.sev] = (bySeverity[f.sev] || 0) + 1;
|
|
52
88
|
const byEvidence = {};
|
|
53
89
|
for (const f of findings) byEvidence[f.evidence] = (byEvidence[f.evidence] || 0) + 1;
|
|
54
|
-
const
|
|
90
|
+
const byExploitability = byVerdict(findings);
|
|
91
|
+
const perRepo = Object.fromEntries(reports.map(({ repo, r }) => [repo, { findings: r.summary.findings, filesScanned: r.summary.filesScanned, coverageIncomplete: r.summary.coverageIncomplete, nosrc: r.summary.nosrc,
|
|
92
|
+
...(r.summary.byExploitability ? { byExploitability: r.summary.byExploitability } : {}) }]));
|
|
55
93
|
const filesScanned = sum('filesScanned');
|
|
56
94
|
return {
|
|
57
95
|
tool: 'cobolwork',
|
|
@@ -68,6 +106,9 @@ function scanEach(root, opts) {
|
|
|
68
106
|
...(reports[0]?.r.summary.advisoryCoverage ? { advisoryCoverage: reports[0].r.summary.advisoryCoverage } : {}),
|
|
69
107
|
// The schema has no repository field for a set, so the repository leads the reason.
|
|
70
108
|
setsIncomplete: reports.flatMap(({ repo, r }) => (r.summary.setsIncomplete || []).map((x) => ({ ...x, why: `${repo}: ${x.why}` }))),
|
|
109
|
+
...mergeEstate(reports),
|
|
110
|
+
...(byExploitability ? { byExploitability } : {}),
|
|
111
|
+
...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
|
|
71
112
|
},
|
|
72
113
|
findings,
|
|
73
114
|
...(checked.length ? { checked } : {}),
|
|
@@ -77,6 +118,7 @@ function scanEach(root, opts) {
|
|
|
77
118
|
ruleImpact: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.impact).map(([k, v]) => [k, v.impact])),
|
|
78
119
|
ruleRemedy: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.remedy).map(([k, v]) => [k, v.remedy])),
|
|
79
120
|
evidence: EVIDENCE,
|
|
121
|
+
exploitabilityVerdicts: EXPLOITABILITY,
|
|
80
122
|
// Which clause each rule bears on, and what the mapping does not claim. A reader who takes
|
|
81
123
|
// this to an auditor needs both halves.
|
|
82
124
|
ruleCompliance: complianceMap(Object.keys(ALL_RULES)),
|
|
@@ -85,6 +127,50 @@ function scanEach(root, opts) {
|
|
|
85
127
|
};
|
|
86
128
|
}
|
|
87
129
|
|
|
130
|
+
// Who can reach each finding and what it runs as, from the estate's own facts - a brought
|
|
131
|
+
// COBOLWORK_REACH extract, or the keys in cobolwork.site.json - never from the source, and the
|
|
132
|
+
// exploitability verdict those join into with the route. Returns the summary keys that report them.
|
|
133
|
+
// Reach and effect annotate rather than re-rank; where no fact is declared, a note says so rather
|
|
134
|
+
// than the report reading as a clean bill.
|
|
135
|
+
export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
136
|
+
const site = loadSite(root, opts.site || null);
|
|
137
|
+
const feedRoot = opts.feedRoot || root;
|
|
138
|
+
const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root: feedRoot }));
|
|
139
|
+
const reach = reachResolver({ feeds: reachFeeds, site });
|
|
140
|
+
const withEntries = findings.some((f) => (f.startedBy || []).length);
|
|
141
|
+
const byReach = reach.declared ? stampReach(findings, reach) : null;
|
|
142
|
+
const byEffect = reach.effectDeclared ? stampEffect(findings, reach) : null;
|
|
143
|
+
// A rule that escalates only under elevated authority takes its higher severity where the estate
|
|
144
|
+
// says the entry that reaches it runs privileged.
|
|
145
|
+
for (const f of findings) {
|
|
146
|
+
const raise = ALL_RULES[f.rule]?.whenPrivileged;
|
|
147
|
+
if (raise && f.effect === 'privileged') f.sev = raise;
|
|
148
|
+
}
|
|
149
|
+
stampExploitability(findings, reach, checked);
|
|
150
|
+
const witnessFeeds = witnessFeedPaths(opts).map((p) => loadWitnessFeed(p, { root: feedRoot }));
|
|
151
|
+
const witnessed = applyWitness(findings, checked, witnessFeeds);
|
|
152
|
+
if (witnessed.overruled.length) sortFindings(findings);
|
|
153
|
+
const byExploitability = byVerdict(findings);
|
|
154
|
+
const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
|
|
155
|
+
const loaded = witnessFeeds.filter((f) => !f.problem);
|
|
156
|
+
return {
|
|
157
|
+
...(byReach ? { byReach } : {}),
|
|
158
|
+
...(byEffect ? { byEffect } : {}),
|
|
159
|
+
...(reachFeeds.some(f => !f.problem) ? { reachFeeds: reachFeeds.filter(f => !f.problem).map(f => ({ file: f.file, extract: f.extract, retrieved: f.retrieved })) } : {}),
|
|
160
|
+
...(reachFeeds.some(f => f.problem) ? { reachFeedProblems: reachFeeds.filter(f => f.problem).map(f => `${f.file}: ${f.problem}`) } : {}),
|
|
161
|
+
...(!reach.declared && withEntries ? { reachNote: `no reachability facts declared, so no finding was judged reachable${driven ? `, and the ${driven} an attacker drives are labelled attacker-driven rather than exploitable` : ''}: name open/restricted transactions and jobs in cobolwork.site.json, or bring a COBOLWORK_REACH extract` } : {}),
|
|
162
|
+
...(!reach.effectDeclared && withEntries ? { effectNote: 'no authority facts declared, so no finding was judged to run privileged: name privilegedTransactions/privilegedJobs in cobolwork.site.json, or bring them in the COBOLWORK_REACH extract' } : {}),
|
|
163
|
+
...(byExploitability ? { byExploitability } : {}),
|
|
164
|
+
...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
|
|
165
|
+
...(loaded.length ? { witnessFeeds: loaded.map((f) => ({ file: f.file, witness: f.witness, recorded: f.recorded })), byWitness: witnessed.byWitness } : {}),
|
|
166
|
+
...(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}`))) } : {}),
|
|
167
|
+
// A result no path finding carries: the code changed since the test, or the route is gone.
|
|
168
|
+
...(witnessed.unmatched.length ? { witnessUnmatched: witnessed.unmatched } : {}),
|
|
169
|
+
// Routes the reading refuted that the estate reproduced: the check model was wrong about them.
|
|
170
|
+
...(witnessed.overruled.length ? { witnessOverruled: witnessed.overruled } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
88
174
|
// A set that throws is reported as not having run, and the other sets' findings stand.
|
|
89
175
|
function runSet(set, root, o) {
|
|
90
176
|
try { return set.scan(root, o); } catch (e) {
|
|
@@ -153,21 +239,7 @@ export function scanAll(root, opts = {}) {
|
|
|
153
239
|
const checked = parts.find((p) => p.tool === 'cobolwork-flow')?.checked || [];
|
|
154
240
|
stampFingerprints(checked, { root, repo: opts.repoName || '' });
|
|
155
241
|
|
|
156
|
-
|
|
157
|
-
// or restricted, from the estate's own facts - a brought COBOLWORK_REACH extract, or the
|
|
158
|
-
// open/restricted keys in cobolwork.site.json - never from the source. Undeclared until it says.
|
|
159
|
-
const site = loadSite(root, opts.site || null);
|
|
160
|
-
const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root }));
|
|
161
|
-
const reach = reachResolver({ feeds: reachFeeds, site });
|
|
162
|
-
const withEntries = findings.some((f) => (f.startedBy || []).length);
|
|
163
|
-
const byReach = reach.declared ? stampReach(findings, reach) : null;
|
|
164
|
-
const byEffect = reach.effectDeclared ? stampEffect(findings, reach) : null;
|
|
165
|
-
// A rule that escalates only under elevated authority takes its higher severity where the estate
|
|
166
|
-
// says the entry that reaches it runs privileged.
|
|
167
|
-
for (const f of findings) {
|
|
168
|
-
const raise = ALL_RULES[f.rule]?.whenPrivileged;
|
|
169
|
-
if (raise && f.effect === 'privileged') f.sev = raise;
|
|
170
|
-
}
|
|
242
|
+
const estate = applyEstateFacts(findings, checked, root, opts);
|
|
171
243
|
|
|
172
244
|
const byRule = tally(findings);
|
|
173
245
|
const bySeverity = {};
|
|
@@ -200,14 +272,7 @@ export function scanAll(root, opts = {}) {
|
|
|
200
272
|
...(parts.find(p => p.tool === 'cobolwork-build')?.summary.advisoryCoverage ? { advisoryCoverage: parts.find(p => p.tool === 'cobolwork-build').summary.advisoryCoverage } : {}),
|
|
201
273
|
crossProgram: parts.find(p => p.tool === 'cobolwork-flow')?.summary.crossProgram ?? 0,
|
|
202
274
|
...(parts.find(p => p.tool === 'cobolwork-flow')?.summary.entryPoints?.roots ? rollup(findings) : {}),
|
|
203
|
-
|
|
204
|
-
// no fact is declared, the report says so rather than reading as a clean bill.
|
|
205
|
-
...(byReach ? { byReach } : {}),
|
|
206
|
-
...(byEffect ? { byEffect } : {}),
|
|
207
|
-
...(reachFeeds.some(f => !f.problem) ? { reachFeeds: reachFeeds.filter(f => !f.problem).map(f => ({ file: f.file, extract: f.extract, retrieved: f.retrieved })) } : {}),
|
|
208
|
-
...(reachFeeds.some(f => f.problem) ? { reachFeedProblems: reachFeeds.filter(f => f.problem).map(f => `${f.file}: ${f.problem}`) } : {}),
|
|
209
|
-
...(!reach.declared && withEntries ? { reachNote: 'no reachability facts declared, so no finding was judged reachable: name open/restricted transactions and jobs in cobolwork.site.json, or bring a COBOLWORK_REACH extract' } : {}),
|
|
210
|
-
...(!reach.effectDeclared && withEntries ? { effectNote: 'no authority facts declared, so no finding was judged to run privileged: name privilegedTransactions/privilegedJobs in cobolwork.site.json, or bring them in the COBOLWORK_REACH extract' } : {}),
|
|
275
|
+
...estate,
|
|
211
276
|
ruleSets: only,
|
|
212
277
|
// Which flow model produced these rows, and which cobolwork. A finding that appears or
|
|
213
278
|
// disappears between two reports is attributable to the model rather than to the code scanned.
|
|
@@ -260,6 +325,7 @@ export function scanAll(root, opts = {}) {
|
|
|
260
325
|
ruleImpact: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.impact).map(([k, v]) => [k, v.impact])),
|
|
261
326
|
ruleRemedy: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.remedy).map(([k, v]) => [k, v.remedy])),
|
|
262
327
|
evidence: EVIDENCE,
|
|
328
|
+
exploitabilityVerdicts: EXPLOITABILITY,
|
|
263
329
|
// Which clause each rule bears on, and what the mapping does not claim. A reader who takes
|
|
264
330
|
// this to an auditor needs both halves.
|
|
265
331
|
ruleCompliance: complianceMap(Object.keys(ALL_RULES)),
|