@portll/cobolwork 0.2.76 → 0.2.117

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 (52) 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/consequence.mjs +29 -0
  8. package/lib/control.mjs +1052 -91
  9. package/lib/csd.mjs +17 -2
  10. package/lib/dataflow.mjs +245 -78
  11. package/lib/enterprise-options.mjs +255 -0
  12. package/lib/equivalence.mjs +124 -0
  13. package/lib/evidence/cli.mjs +65 -0
  14. package/lib/evidence/journal.mjs +85 -0
  15. package/lib/evidence/record.mjs +110 -0
  16. package/lib/evidence/run.mjs +111 -0
  17. package/lib/evidence/seal.mjs +177 -0
  18. package/lib/evidence/slsa.mjs +64 -0
  19. package/lib/evidence/sshsig.mjs +172 -0
  20. package/lib/evidence/store.mjs +170 -0
  21. package/lib/evidence/verify.mjs +167 -0
  22. package/lib/explain.mjs +4 -1
  23. package/lib/exploitability.mjs +263 -0
  24. package/lib/ironwork.mjs +111 -0
  25. package/lib/kernel/findings.mjs +11 -0
  26. package/lib/kernel/registry.mjs +4 -0
  27. package/lib/option-diff.mjs +49 -0
  28. package/lib/options.mjs +109 -46
  29. package/lib/parser.mjs +29 -7
  30. package/lib/policy.mjs +8 -3
  31. package/lib/precompile.mjs +4 -0
  32. package/lib/reach.mjs +29 -12
  33. package/lib/revision.json +1 -1
  34. package/lib/sarif.mjs +1 -0
  35. package/lib/sbom.mjs +147 -0
  36. package/lib/scan.mjs +93 -27
  37. package/lib/sets/cics.mjs +114 -0
  38. package/lib/sets/flow.mjs +82 -2
  39. package/lib/sets/jcl.mjs +67 -0
  40. package/lib/sets/semantics.mjs +377 -0
  41. package/lib/sets/web.mjs +34 -10
  42. package/lib/sets/zowe.mjs +220 -0
  43. package/lib/sources.mjs +10 -0
  44. package/lib/tui/app.mjs +31 -13
  45. package/lib/tui/model.mjs +17 -2
  46. package/lib/utilities.mjs +85 -13
  47. package/lib/verify.mjs +128 -0
  48. package/package.json +1 -1
  49. package/rules/compliance-dora.json +507 -0
  50. package/rules/compliance-ffiec.json +507 -0
  51. package/rules/compliance-nist80053.json +507 -0
  52. package/schema/cobolwork.policy.schema.json +2 -1
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":"b144074728e1b40f1c6a230b06f1a6b8b0758d51"}
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,147 @@
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 { treeFor } from './kernel/source-tree.mjs';
14
+
15
+ // The namespace of cobolwork's version 5 UUIDs (RFC 4122 §4.3), fixed so a serial number repeats.
16
+ const NAMESPACE = Buffer.from('6f1d2b8e5c3a4e7f9a0b1c2d3e4f5a6b', 'hex');
17
+
18
+ function uuidv5(name) {
19
+ const h = createHash('sha1').update(NAMESPACE).update(name, 'utf8').digest().subarray(0, 16);
20
+ h[6] = (h[6] & 0x0f) | 0x50;
21
+ h[8] = (h[8] & 0x3f) | 0x80;
22
+ const x = h.toString('hex');
23
+ return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`;
24
+ }
25
+
26
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
27
+ const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
28
+
29
+ function platformsOf(src, calls) {
30
+ const s = new Set();
31
+ if (/EXEC\s+CICS\b/i.test(src)) s.add('CICS');
32
+ if (/EXEC\s+SQL\b/i.test(src)) s.add('Db2');
33
+ if (/EXEC\s+DLI\b/i.test(src)) s.add('IMS');
34
+ for (const c of calls) {
35
+ if (c.kind !== 'L') continue;
36
+ const n = String(c.name).toUpperCase();
37
+ if (n === 'CBLTDLI' || n === 'AIBTDLI') s.add('IMS');
38
+ if (/^MQ[A-Z]{2,}$/.test(n)) s.add('MQ');
39
+ if (/^CEE[A-Z0-9]+$/.test(n)) s.add('Language Environment');
40
+ }
41
+ return [...s];
42
+ }
43
+
44
+ function kindOf(file, src) {
45
+ if (isProgram(file)) return /PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src) ? 'program' : 'copybook';
46
+ if (isCopybook(file)) return 'copybook';
47
+ if (isJcl(file)) return /^\/\/\S*\s+PROC\b/m.test(src) && !/^\/\/\S*\s+JOB\b/m.test(src) ? 'proc' : 'jcl';
48
+ if (isBms(file)) return 'bms';
49
+ const ext = extname(file).toLowerCase();
50
+ if (ext === '.csd') return 'csd';
51
+ if (ext === '.ddl' || ext === '.sql') return 'ddl';
52
+ return null;
53
+ }
54
+
55
+ export function sbom(root, opts = {}) {
56
+ const tree = treeFor(root, opts);
57
+ const idx = tree.index;
58
+ const top = resolve(root);
59
+ const within = (p) => resolve(p).startsWith(top + sep);
60
+ const refOf = (p) => (within(p) ? relPath(root, p) : `copylib:${basename(p)}`);
61
+
62
+ const entries = [];
63
+ const programIds = new Map();
64
+ for (const f of tree.list().filter(inScope(opts)).sort()) {
65
+ let bytes;
66
+ let src;
67
+ try { bytes = readFileSync(f); src = readSource(f).text; } catch { continue; }
68
+ const kind = kindOf(f, src);
69
+ if (!kind) continue;
70
+ const entry = { file: f, ref: relPath(root, f), bytes, src, kind };
71
+ if (kind === 'program') {
72
+ try {
73
+ const res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
74
+ entry.programs = res.programs;
75
+ entry.copies = res.copies;
76
+ for (const p of res.programs) if (p.id) programIds.set(String(p.id).toUpperCase(), entry.ref);
77
+ } catch { entry.unparsed = true; }
78
+ } else if (kind === 'jcl' || kind === 'proc') {
79
+ try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
80
+ }
81
+ entries.push(entry);
82
+ }
83
+
84
+ const components = new Map();
85
+ const dependencies = new Map();
86
+ const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
87
+
88
+ for (const e of entries) {
89
+ const properties = [{ name: 'cobolwork:kind', value: e.kind }];
90
+ if (e.unparsed) properties.push({ name: 'cobolwork:unparsed', value: 'true' });
91
+ if (e.kind === 'program' && e.programs && e.programs.length) {
92
+ const ids = e.programs.map((p) => p.id).filter(Boolean);
93
+ if (ids.length) properties.push({ name: 'cobolwork:program-id', value: ids.join(',') });
94
+ properties.push({ name: 'cobolwork:format', value: detectFormat(e.src) });
95
+ const options = optionCards(e.src).flatMap((c) => c.options);
96
+ if (options.length) properties.push({ name: 'cobolwork:options', value: options.join(' ') });
97
+ for (const c of e.copies || []) {
98
+ if (c.status !== 'resolved' || !c.path) continue;
99
+ const ref = refOf(c.path);
100
+ depend(e.ref, ref);
101
+ if (!within(c.path) && !components.has(ref)) {
102
+ let b = null;
103
+ try { b = readFileSync(c.path); } catch { /* read at parse time, gone since */ }
104
+ components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }] });
105
+ }
106
+ }
107
+ const calls = e.programs.flatMap((p) => p.calls || []);
108
+ const unresolved = new Set();
109
+ for (const c of calls) {
110
+ if (c.kind !== 'L') { unresolved.add(String(c.name)); continue; }
111
+ const target = programIds.get(String(c.name).toUpperCase());
112
+ if (target && target !== e.ref) depend(e.ref, target);
113
+ }
114
+ if (unresolved.size) properties.push({ name: 'cobolwork:unresolved-call', value: [...unresolved].sort(order).join(',') });
115
+ for (const p of platformsOf(e.src, calls)) {
116
+ const ref = `platform:${p}`;
117
+ depend(e.ref, ref);
118
+ if (!components.has(ref)) components.set(ref, { 'bom-ref': ref, type: 'platform', name: p });
119
+ }
120
+ }
121
+ if (e.jcl) {
122
+ for (const step of e.jcl.steps || []) {
123
+ const target = step.pgm ? programIds.get(String(step.pgm).toUpperCase()) : null;
124
+ if (target) depend(e.ref, target);
125
+ }
126
+ }
127
+ components.set(e.ref, { 'bom-ref': e.ref, type: 'file', name: e.ref, hashes: [{ alg: 'SHA-256', content: sha256(e.bytes) }], properties });
128
+ }
129
+
130
+ const list = [...components.values()].sort((a, b) => order(a['bom-ref'], b['bom-ref']));
131
+ const digests = list.filter((c) => c.hashes).map((c) => c.hashes[0].content).sort(order);
132
+ const name = opts.name || basename(top);
133
+ const epoch = process.env.SOURCE_DATE_EPOCH;
134
+ return {
135
+ bomFormat: 'CycloneDX',
136
+ specVersion: '1.6',
137
+ serialNumber: `urn:uuid:${uuidv5(`${name}\n${digests.join('\n')}`)}`,
138
+ version: 1,
139
+ metadata: {
140
+ ...(epoch && /^\d+$/.test(epoch) ? { timestamp: new Date(Number(epoch) * 1000).toISOString() } : {}),
141
+ tools: { components: [{ type: 'application', name: 'cobolwork', version: TOOL_VERSION }] },
142
+ component: { 'bom-ref': `estate:${name}`, type: 'application', name },
143
+ },
144
+ components: list,
145
+ dependencies: [...dependencies.entries()].sort(([a], [b]) => order(a, b)).map(([ref, set]) => ({ ref, dependsOn: [...set].sort(order) })),
146
+ };
147
+ }
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)),
package/lib/sets/cics.mjs CHANGED
@@ -13,6 +13,24 @@ import { parseCsd } from '../csd.mjs';
13
13
  // must act on from one that only wants a look; `remedy` is the standard fix, the same for every
14
14
  // instance. Both ride with every finding of the rule (as ruleImpact/ruleRemedy).
15
15
  export const CICS_RULES = {
16
+ 'program-checks-stored-password': {
17
+ sev: 'med', evidence: 'construct', cwe: 'CWE-256',
18
+ text: 'A program compares a password it reads back from its own file or table',
19
+ impact: 'The password is kept where the application reads it, so whoever can read that file or table - a developer, a backup, an unload - holds every user\'s password, and the program, not the security manager, decides who signs on',
20
+ remedy: 'Verify the password with EXEC CICS VERIFY PASSWORD or SIGNON, so RACF checks it and the application never stores it',
21
+ },
22
+ 'password-case-folded-before-compare': {
23
+ sev: 'low', evidence: 'construct', cwe: 'CWE-178',
24
+ text: 'A sign-on folds a password\'s case before comparing it',
25
+ impact: 'A password that matches in either case has fewer values to guess, and the program accepts a password its user never typed',
26
+ remedy: 'Compare the password as typed, or leave the comparison to the security manager with EXEC CICS VERIFY PASSWORD',
27
+ },
28
+ 'cics-condition-ignored': {
29
+ sev: 'low', evidence: 'construct', cwe: 'CWE-252',
30
+ text: 'A program issues CICS commands whose failure it cannot see: NOHANDLE with no RESP, or IGNORE CONDITION',
31
+ impact: 'A command that fails - a record not found, a queue full, storage not available - returns to the next statement as if it had worked, so the program acts on an area the command never filled',
32
+ remedy: 'Give each command RESP and test it, or let the condition reach a HANDLE CONDITION that deals with it; keep NOHANDLE for commands whose failure is harmless',
33
+ },
16
34
  'cics-commarea-without-length-check': {
17
35
  sev: 'high', evidence: 'construct', cwe: 'CWE-1284',
18
36
  text: 'A program reads its communication area without checking EIBCALEN',
@@ -118,6 +136,38 @@ function execOptions(exec) {
118
136
 
119
137
  const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
120
138
 
139
+ // Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
140
+ // not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
141
+ // have gone, an ASSIGN, and the RETURN that ends the task anyway.
142
+ const QUIET_FAILURES = (words) => (words[0] === 'DELETEQ' && words[1] === 'TS') || (words[0] === 'WRITEQ' && words[1] === 'TD')
143
+ || ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(words[0]);
144
+ const SHOWN_IGNORED = 5;
145
+
146
+ // Commands whose outcome the program cannot see: NOHANDLE with no RESP and no EIBRESP test before the
147
+ // next command, and IGNORE CONDITION. One finding per program, at the first, since the count is what
148
+ // a reviewer weighs and the fix is the same at each.
149
+ function conditionsIgnored(p, path) {
150
+ const execs = p.execs.filter((e) => e.kind === 'CICS').sort((a, b) => a.line - b.line);
151
+ const hits = [];
152
+ execs.forEach((e, k) => {
153
+ const { opts: o, words } = execOptions(e);
154
+ if (words[0] === 'IGNORE' && words[1] === 'CONDITION') {
155
+ hits.push({ e, what: `IGNORE CONDITION ${words.slice(2).join(' ')}`.trim() });
156
+ return;
157
+ }
158
+ if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(words)) return;
159
+ const until = execs[k + 1] ? execs[k + 1].line : Infinity;
160
+ const tested = p.statements.some((st) => st.line > e.line && st.line < until && (st.sources || []).some((t) => t.t === 'word' && t.u === 'EIBRESP'));
161
+ if (!tested) hits.push({ e, what: `${words[0]}${words[1] && words[1] !== 'NOHANDLE' && !o.has(words[1]) ? ` ${words[1]}` : ''} NOHANDLE` });
162
+ });
163
+ if (!hits.length) return null;
164
+ const shown = hits.slice(0, SHOWN_IGNORED).map((h) => `${h.what} at line ${h.e.line}`).join(', ');
165
+ return {
166
+ rule: 'cics-condition-ignored', path, line: hits[0].e.line, program: p.id,
167
+ detail: `${p.id} leaves ${hits.length} place${hits.length === 1 ? '' : 's'} where a CICS command's failure goes unseen: ${shown}${hits.length > SHOWN_IGNORED ? `, and ${hits.length - SHOWN_IGNORED} more` : ''}`,
168
+ };
169
+ }
170
+
121
171
  // Sizes come from the parser's data-division layout, which is graded against the compiler, so a
122
172
  // length compared here is the length the compiler would compute.
123
173
  //
@@ -194,6 +244,67 @@ export function scanCics(root, opts = {}) {
194
244
  }
195
245
 
196
246
  // Where a program sends control after its sign-on succeeds, and every other route there.
247
+ // A sign-on that compares a password the program read back from its own file or table, rather than
248
+ // asking the security manager, and one that folds a password's case first. Passwords are recognised
249
+ // by field name, as the sign-on rules above recognise them, and each finding says so.
250
+ function passwordChecks(p, path) {
251
+ if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execOptions(e).words[0] || ''))) return [];
252
+ const byName = new Map();
253
+ for (const it of p.items) if (!byName.has(it.name)) byName.set(it.name, it);
254
+ const resolve = (tok) => (tok && tok.t === 'word' ? p.resolved.get(tok) || byName.get(tok.u) || null : null);
255
+ const tests = [];
256
+ try { buildControl(p, resolve, { extra: (t) => { const r = signOnTest(t); if (r) tests.push(t); return r; } }); } catch { return []; }
257
+ if (!tests.length) return [];
258
+ // What the program reads back: an FD record, the INTO of a CICS READ, a host variable a SELECT or
259
+ // FETCH fills, and everything under any of them. A copy made by MOVE first is not followed.
260
+ const read = new Set();
261
+ for (const e of p.execs) {
262
+ const { opts: o, words } = execOptions(e);
263
+ if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(words[0] || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
264
+ if (e.kind === 'SQL' && (e.hostVariables || []).length) for (const h of e.hostVariables) if (h.written && h.tok) read.add(h.tok.u);
265
+ }
266
+ const stored = (name) => {
267
+ for (let it = byName.get(name); it; it = it.parent) if (it.section === 'FILE' || read.has(it.name)) return true;
268
+ return false;
269
+ };
270
+ const out = [];
271
+ const FOLD = new Set(['UPPER-CASE', 'LOWER-CASE']);
272
+ for (const t of tests) {
273
+ const cond = t.cond || (t.evaluate && t.evaluate.conds[0]) || [];
274
+ const names = cond.filter((x, k) => x.t === 'word' && byName.has(x.u) && !(cond[k - 1] && /^(OF|IN)$/.test(cond[k - 1].u))).map((x) => x.u);
275
+ const kept = names.find(stored);
276
+ const line = t.st ? t.st.line : 1;
277
+ if (kept) {
278
+ out.push({
279
+ rule: 'program-checks-stored-password', path, line, program: p.id,
280
+ detail: `${p.id} compares ${names.join(' with ')}, and ${kept} is read back from the program's own file or table, so the password is stored where the application can read it; the password is recognised by its field name`,
281
+ });
282
+ }
283
+ if (cond.some((x) => x.t === 'word' && FOLD.has(x.u))) {
284
+ out.push({
285
+ rule: 'password-case-folded-before-compare', path, line, program: p.id,
286
+ detail: `${p.id} folds the case of ${names.join(' or ')} inside its sign-on comparison, so a password matches in either case; the password is recognised by its field name`,
287
+ });
288
+ }
289
+ }
290
+ // Case folded before the comparison: a MOVE of FUNCTION UPPER-CASE or LOWER-CASE of a password
291
+ // field, or an INSPECT that converts one.
292
+ const toks = (p.proc && p.proc.tokens) || [];
293
+ for (const st of p.statements || []) {
294
+ const seg = toks.slice(st.at + 1, st.end ?? st.at + 1);
295
+ const folds = st.verb === 'MOVE' ? seg.some((x) => x.t === 'word' && FOLD.has(x.u))
296
+ : st.verb === 'INSPECT' && seg.some((x) => x.t === 'word' && x.u === 'CONVERTING');
297
+ if (!folds) continue;
298
+ const pw = seg.find((x) => x.t === 'word' && PASSWORD.test(x.u) && byName.has(x.u));
299
+ if (!pw || out.some((o) => o.rule === 'password-case-folded-before-compare' && o.line === st.line)) continue;
300
+ out.push({
301
+ rule: 'password-case-folded-before-compare', path, line: st.line, program: p.id,
302
+ detail: `${p.id} folds the case of ${pw.u} with ${st.verb === 'MOVE' ? 'FUNCTION UPPER-CASE or LOWER-CASE' : 'INSPECT CONVERTING'} in a program that compares passwords, so a password matches in either case; the password is recognised by its field name`,
303
+ });
304
+ }
305
+ return out;
306
+ }
307
+
197
308
  function signOnBypasses(p, path) {
198
309
  let ctl;
199
310
  const byName = new Map();
@@ -238,6 +349,9 @@ export function scanCics(root, opts = {}) {
238
349
  stats.filesScanned++;
239
350
  const path = relPath(root, f);
240
351
  for (const p of r.programs) {
352
+ const ignored = conditionsIgnored(p, path);
353
+ if (ignored) findings.push(ignored);
354
+ if (p.refs.some((x) => PASSWORD.test(x.tok.u))) findings.push(...passwordChecks(p, path));
241
355
  const sum = summarise(p, path);
242
356
  summaries.push(sum);
243
357
  if (p.id && !commareaSize.has(p.id)) commareaSize.set(p.id, sum.commarea ? sum.commarea.size : null);