@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.
Files changed (61) hide show
  1. package/README.md +66 -14
  2. package/bin/cobolwork.mjs +126 -9
  3. package/lib/arith.mjs +235 -0
  4. package/lib/baseline.mjs +5 -0
  5. package/lib/build.mjs +112 -30
  6. package/lib/capabilities.mjs +12 -7
  7. package/lib/card.mjs +18 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/consequence.mjs +29 -0
  10. package/lib/control.mjs +1169 -91
  11. package/lib/csd.mjs +17 -2
  12. package/lib/dataflow.mjs +276 -126
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/enterprise-options.mjs +255 -0
  15. package/lib/equivalence.mjs +124 -0
  16. package/lib/evidence/cli.mjs +65 -0
  17. package/lib/evidence/journal.mjs +94 -0
  18. package/lib/evidence/record.mjs +110 -0
  19. package/lib/evidence/run.mjs +111 -0
  20. package/lib/evidence/seal.mjs +177 -0
  21. package/lib/evidence/slsa.mjs +64 -0
  22. package/lib/evidence/sshsig.mjs +172 -0
  23. package/lib/evidence/store.mjs +170 -0
  24. package/lib/evidence/verify.mjs +167 -0
  25. package/lib/explain.mjs +9 -5
  26. package/lib/exploitability.mjs +263 -0
  27. package/lib/ftp.mjs +136 -0
  28. package/lib/ironwork.mjs +109 -0
  29. package/lib/jcl.mjs +27 -7
  30. package/lib/kernel/findings.mjs +11 -0
  31. package/lib/kernel/registry.mjs +4 -0
  32. package/lib/layout.mjs +202 -0
  33. package/lib/option-diff.mjs +49 -0
  34. package/lib/options.mjs +109 -46
  35. package/lib/parser.mjs +80 -209
  36. package/lib/policy.mjs +8 -3
  37. package/lib/precompile.mjs +5 -84
  38. package/lib/reach.mjs +29 -12
  39. package/lib/revision.json +1 -1
  40. package/lib/sarif.mjs +1 -0
  41. package/lib/sbom.mjs +235 -0
  42. package/lib/scan.mjs +93 -27
  43. package/lib/sets/cics.mjs +119 -2
  44. package/lib/sets/copybook.mjs +2 -1
  45. package/lib/sets/flow.mjs +82 -2
  46. package/lib/sets/hidden.mjs +4 -8
  47. package/lib/sets/jcl.mjs +70 -106
  48. package/lib/sets/recon.mjs +2 -1
  49. package/lib/sets/semantics.mjs +377 -0
  50. package/lib/sets/web.mjs +34 -10
  51. package/lib/sets/zowe.mjs +246 -0
  52. package/lib/sources.mjs +10 -0
  53. package/lib/tui/app.mjs +31 -13
  54. package/lib/tui/model.mjs +17 -2
  55. package/lib/utilities.mjs +229 -15
  56. package/lib/verify.mjs +128 -0
  57. package/package.json +1 -1
  58. package/rules/compliance-dora.json +507 -0
  59. package/rules/compliance-ffiec.json +507 -0
  60. package/rules/compliance-nist80053.json +507 -0
  61. 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 a consumer combines, kept apart so each names the fact that
14
- // moved it. Neither ever becomes "exploitable" - that word needs a witness on the running system,
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
- return { declared, effectDeclared, accessOf, privilegedOf };
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.transaction ? resolver.accessOf('transaction', e.transaction) : (e.job !== undefined ? resolver.accessOf('job', e.job || '') : null));
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, `restricted` if every entry that resolves is restricted and none
93
- // is open, `undeclared` if none of its entries is a declared fact. A finding no entry reaches
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
- let open = false, restricted = false;
101
- for (const e of entries) { const a = accessOfEntry(e, resolver); if (a === 'open') open = true; else if (a === 'restricted') restricted = true; }
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.transaction ? resolver.privilegedOf('transaction', e.transaction) : (e.job !== undefined ? resolver.privilegedOf('job', e.job || '') : false)))) {
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":"13889f659145667b2c02bd2c4b2f18092fd64071","tag":"v0.2.76"}
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
- const reports = opts.repos.map(repo => ({ repo, r: scanAll(join(root, repo), { ...opts, repos: null, repoName: repo }) }));
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 prefixed = (repo, f) => ({ ...f, path: underRepo(repo, f.path), ...(f.related ? { related: f.related.map(x => ({ ...x, path: underRepo(repo, x.path) })) } : {}) });
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 perRepo = Object.fromEntries(reports.map(({ repo, r }) => [repo, { findings: r.summary.findings, filesScanned: r.summary.filesScanned, coverageIncomplete: r.summary.coverageIncomplete, nosrc: r.summary.nosrc }]));
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
- // Who can reach a finding: whether the transactions and jobs that start it are open to any user
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
- // Reach annotates, it does not re-rank: severity is urgency, reach is who can drive it. When
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)),