@portll/cobolwork 0.5.0 → 0.6.0

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 (69) hide show
  1. package/README.md +43 -11
  2. package/bin/cobolwork.mjs +27 -8
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/build.mjs +33 -8
  5. package/lib/capabilities.mjs +4 -1
  6. package/lib/consequence.mjs +4 -0
  7. package/lib/control-reuse.mjs +113 -0
  8. package/lib/control.mjs +25 -1
  9. package/lib/dataflow.mjs +285 -86
  10. package/lib/equivalence.mjs +44 -5
  11. package/lib/evidence/cli.mjs +13 -5
  12. package/lib/evidence/record.mjs +16 -6
  13. package/lib/evidence/seal.mjs +17 -0
  14. package/lib/evidence/store.mjs +30 -14
  15. package/lib/evidence/timestamp.mjs +83 -0
  16. package/lib/evidence/verify.mjs +120 -42
  17. package/lib/hlasm/asm/data.mjs +116 -0
  18. package/lib/hlasm/asm/listing.mjs +43 -0
  19. package/lib/hlasm/asm/output.mjs +45 -0
  20. package/lib/hlasm/asm/sections.mjs +159 -0
  21. package/lib/hlasm/asm/symbols.mjs +78 -0
  22. package/lib/hlasm/exec.mjs +27 -0
  23. package/lib/hlasm/expr.mjs +167 -0
  24. package/lib/hlasm/instr.mjs +48 -0
  25. package/lib/hlasm/locate.mjs +333 -0
  26. package/lib/hlasm/macro/authorization.mjs +126 -0
  27. package/lib/hlasm/macro/datasets.mjs +94 -0
  28. package/lib/hlasm/macro/io.mjs +139 -0
  29. package/lib/hlasm/macro/le.mjs +53 -0
  30. package/lib/hlasm/macro/linkage.mjs +143 -0
  31. package/lib/hlasm/macro/operator.mjs +66 -0
  32. package/lib/hlasm/macro/program.mjs +142 -0
  33. package/lib/hlasm/macro/recovery.mjs +80 -0
  34. package/lib/hlasm/macro/storage.mjs +117 -0
  35. package/lib/hlasm/macro/structured.mjs +131 -0
  36. package/lib/hlasm/model.mjs +72 -0
  37. package/lib/hlasm/operands.mjs +49 -0
  38. package/lib/hlasm/read.mjs +116 -0
  39. package/lib/inventory.mjs +10 -7
  40. package/lib/ironwork-ids.mjs +24 -0
  41. package/lib/ironwork.mjs +4 -1
  42. package/lib/kernel/registry.mjs +20 -17
  43. package/lib/kernel/shared-pass.mjs +138 -0
  44. package/lib/options.mjs +26 -4
  45. package/lib/policy.mjs +6 -0
  46. package/lib/precompile-cics.mjs +1 -1
  47. package/lib/revision.json +1 -1
  48. package/lib/sarif.mjs +3 -0
  49. package/lib/scan.mjs +21 -11
  50. package/lib/sets/abend.mjs +71 -7
  51. package/lib/sets/build.mjs +43 -37
  52. package/lib/sets/cics.mjs +5 -3
  53. package/lib/sets/compile.mjs +5 -3
  54. package/lib/sets/copybook.mjs +5 -3
  55. package/lib/sets/flow.mjs +14 -7
  56. package/lib/sets/hlasm.mjs +60 -4
  57. package/lib/sets/log.mjs +5 -3
  58. package/lib/sets/priv.mjs +5 -3
  59. package/lib/sets/secrets.mjs +110 -0
  60. package/lib/sets/semantics.mjs +5 -3
  61. package/lib/sets/web.mjs +5 -3
  62. package/package.json +1 -1
  63. package/rules/compliance-cobit2019.json +88 -4
  64. package/rules/compliance-dora.json +95 -4
  65. package/rules/compliance-ffiec.json +91 -0
  66. package/rules/compliance-nist80053.json +91 -0
  67. package/rules/gitleaks-mainframe.toml +46 -15
  68. package/rules/hlasm-instructions.json +2396 -0
  69. package/schema/cobolwork.policy.schema.json +7 -0
package/lib/sets/cics.mjs CHANGED
@@ -4,7 +4,7 @@ import { report } from '../kernel/ruleset.mjs';
4
4
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { parseSource } from '../parser.mjs';
7
- import { eachWithinMemory } from '../kernel/memory.mjs';
7
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
8
8
  import { buildControl, hasFact } from '../control.mjs';
9
9
  import { parseCsd } from '../csd.mjs';
10
10
 
@@ -201,7 +201,7 @@ function conditionsIgnored(p, path) {
201
201
  // away from, and this set kept doing it: on a 100,000-program repository it exhausted an 8 GB heap
202
202
  // and took a corpus run with it. What survives per program is an identifier, a communication area's
203
203
  // size, one boolean, and a short list of transfers.
204
- export function scanCics(root, opts = {}) {
204
+ export function* scanCicsSteps(root, opts = {}) {
205
205
  const tree = treeFor(root, opts);
206
206
  const files = tree.list().filter(isProgram).filter(inScope(opts));
207
207
  // Identifier to the size of that program's DFHCOMMAREA, which is all a caller needs of a callee.
@@ -364,7 +364,7 @@ export function scanCics(root, opts = {}) {
364
364
  // Releasing each tree bounds what one program costs; it does not bound what a hundred thousand of
365
365
  // them cost together. The shared loop watches the heap and stops before V8 cannot collect, and
366
366
  // names every file it did not reach.
367
- const run = eachWithinMemory(files, (f) => {
367
+ const run = yield loopOver(files, (f) => {
368
368
  let src;
369
369
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
370
370
  if (!/EXEC\s+CICS/i.test(src) && !/(?:^|[\s.])COPY\s+/i.test(src)) return src.length;
@@ -445,3 +445,5 @@ export function scanCics(root, opts = {}) {
445
445
 
446
446
  return report('cics', { rules: CICS_RULES, findings, stats, run });
447
447
  }
448
+
449
+ export const scanCics = (root, opts = {}) => drive(scanCicsSteps(root, opts));
@@ -13,7 +13,7 @@ import { basename, extname } from 'node:path';
13
13
  import { inScope, isProgram, relPath, readSource } from '../sources.mjs';
14
14
  import { report } from '../kernel/ruleset.mjs';
15
15
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
16
- import { eachWithinMemory } from '../kernel/memory.mjs';
16
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
17
17
 
18
18
  // construct: no input has to reach it. med: certain once every copybook is found, and it means the
19
19
  // tree does not hold what runs, but it is not exploitable in itself. CWE-1127: MITRE discourages
@@ -102,7 +102,7 @@ function truncated(ref, lines) {
102
102
  return any && !whole;
103
103
  }
104
104
 
105
- export function scanCompile(root, opts = {}) {
105
+ export function* scanCompileSteps(root, opts = {}) {
106
106
  const tree = treeFor(root, opts);
107
107
  const files = tree.list().filter(isProgram).filter(inScope(opts));
108
108
  const findings = [];
@@ -117,7 +117,7 @@ export function scanCompile(root, opts = {}) {
117
117
  const textOf = (p) => (tree.contains(p) ? tree.text(p) : readSource(p)).text;
118
118
  const candidates = [];
119
119
 
120
- const run = eachWithinMemory(files, (f) => {
120
+ const run = yield loopOver(files, (f) => {
121
121
  let src;
122
122
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
123
123
  if (LISTING.test(src)) { stats.filesScanned++; stats.listingsSkipped = (stats.listingsSkipped || 0) + 1; return src.length; }
@@ -186,3 +186,5 @@ export function scanCompile(root, opts = {}) {
186
186
  if (stats.filesUnparsed) stats.coverageIncomplete = true;
187
187
  return report('compile', { rules: COMPILE_RULES, findings, stats, run });
188
188
  }
189
+
190
+ export const scanCompile = (root, opts = {}) => drive(scanCompileSteps(root, opts));
@@ -6,7 +6,7 @@ import { inScope, isCopybook, isProgram, relPath } from '../sources.mjs';
6
6
  import { finish } from '../kernel/findings.mjs';
7
7
  import { report } from '../kernel/ruleset.mjs';
8
8
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
- import { eachWithinMemory } from '../kernel/memory.mjs';
9
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
10
10
  import { cobolCard } from '../cards.mjs';
11
11
 
12
12
  // A copybook is resolved by name along a search path: the program's own directory first, then the
@@ -71,7 +71,7 @@ export function systemLayoutDeparture(name, text) {
71
71
  return null;
72
72
  }
73
73
 
74
- export function scanCopybooks(root, opts = {}) {
74
+ export function* scanCopybooksSteps(root, opts = {}) {
75
75
  const tree = treeFor(root, opts);
76
76
  const all = tree.list().filter(inScope(opts));
77
77
  const findings = [];
@@ -100,7 +100,7 @@ export function scanCopybooks(root, opts = {}) {
100
100
  // is what stops the reading before the heap does.
101
101
  let run = { skipped: [], stoppedBy: null, peakHeapBytes: 0, note: null };
102
102
  if (candidates.size) {
103
- run = eachWithinMemory(all.filter(isProgram), (p) => {
103
+ run = yield loopOver(all.filter(isProgram), (p) => {
104
104
  let src;
105
105
  try { src = tree.text(p).text; } catch (e) { noteUnread(stats, tree, p, e); return 0; }
106
106
  let r;
@@ -173,3 +173,5 @@ export function scanCopybooks(root, opts = {}) {
173
173
  // that looks unshadowed may only look that way.
174
174
  return report('copybook', { rules: COPYBOOK_RULES, findings, stats, run });
175
175
  }
176
+
177
+ export const scanCopybooks = (root, opts = {}) => drive(scanCopybooksSteps(root, opts));
package/lib/sets/flow.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { analyze } from '../dataflow.mjs';
2
+ import { resolve } from 'node:path';
3
+ import { directoryTree } from '../kernel/source-tree.mjs';
4
+ import { analyzeSteps } from '../dataflow.mjs';
5
+ import { drive } from '../kernel/shared-pass.mjs';
3
6
  import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from '../version.mjs';
4
7
  import { finish, withMeta, sortFindings } from '../kernel/findings.mjs';
5
8
 
@@ -365,11 +368,11 @@ function mergeRoutes(a, b) {
365
368
  };
366
369
  }
367
370
  // One graph per repository, for the reason scanAll gives: names are only unique within one.
368
- function analyzeEach(root, opts) {
369
- if (!opts.repos || opts.repos.length < 2) return analyze(root, opts);
371
+ function* analyzeEach(root, opts) {
372
+ if (!opts.repos || opts.repos.length < 2) return yield* analyzeSteps(root, opts);
370
373
  const merged = { findings: [], constructs: [], unstarted: [], startedBy: {}, stats: {} };
371
374
  for (const repo of opts.repos) {
372
- const one = analyze(root, { ...opts, repos: [repo] });
375
+ const one = yield* analyzeSteps(root, { ...opts, repos: [repo] });
373
376
  merged.findings.push(...one.findings);
374
377
  merged.constructs.push(...one.constructs);
375
378
  merged.unstarted.push(...one.unstarted);
@@ -386,8 +389,9 @@ function analyzeEach(root, opts) {
386
389
  return merged;
387
390
  }
388
391
 
389
- export function scan(root, opts = {}) {
390
- const res = analyzeEach(root, opts);
392
+ export function* scanSteps(root, opts = {}) {
393
+ root = resolve(root);
394
+ const res = yield* analyzeEach(root, opts);
391
395
  const findings = [];
392
396
  for (const f of res.findings) {
393
397
  const meta = RULES[f.rule] || DEFAULT;
@@ -531,7 +535,8 @@ export function scan(root, opts = {}) {
531
535
  // happened to compute the right answer for the whole report - it ORs filesOverBudget in
532
536
  // separately - so nothing was wrong downstream, and nothing would have been until someone
533
537
  // called this set on its own and believed a silence.
534
- coverageIncomplete: (res.stats.overBudget || 0) > 0
538
+ coverageIncomplete: (res.stats.files === 0 && directoryTree(root, opts).list().length === 0)
539
+ || (res.stats.overBudget || 0) > 0
535
540
  || (res.stats.filesNotReached || 0) > 0
536
541
  || (res.stats.unreadable || 0) > 0
537
542
  || (res.stats.threw || 0) > 0
@@ -570,6 +575,8 @@ export function scan(root, opts = {}) {
570
575
  return report;
571
576
  }
572
577
 
578
+ export const scan = (root, opts = {}) => drive(scanSteps(root, opts));
579
+
573
580
  // Findings counted by the transaction or job that reaches them: the unit a mainframe team triages
574
581
  // in. A finding several entries reach counts under each.
575
582
  export function rollup(findings) {
@@ -6,11 +6,14 @@
6
6
  //
7
7
  // It also answers the question the COBOL side cannot: a CALL 'SUBRTN' with no COBOL program of that
8
8
  // name may be an assembler module, and a CSECT or ENTRY of that name here is where it is.
9
- import { inScope, isAssembler, isProgram, relPath } from '../sources.mjs';
9
+ import { basename, extname } from 'node:path';
10
+ import { inScope, isAssembler, isProgram, relPath, HLASM_EXT, COPY_EXT } from '../sources.mjs';
10
11
  import { report } from '../kernel/ruleset.mjs';
11
12
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
12
13
  import { eachWithinMemory } from '../kernel/memory.mjs';
13
14
  import { readHlasm, OPERATIONS } from '../hlasm.mjs';
15
+ import { readHlasmStatements, parseHlasmStatement, NAMED_MACROS } from '../hlasm/read.mjs';
16
+ import { macroOperands, splitOperands } from '../hlasm/operands.mjs';
14
17
 
15
18
  export const HLASM_RULES = {
16
19
  'hlasm-supervisor-state-change': {
@@ -31,6 +34,12 @@ export const HLASM_RULES = {
31
34
  impact: 'A program call, a program transfer or a change of secondary address space moves execution or data access across address spaces, under the authority the linkage tables grant rather than the caller\'s own',
32
35
  remedy: 'Confirm the entry tables and authorization indexes grant only the address spaces and keys the service needs, and that every PC routine validates what its caller passes',
33
36
  },
37
+ 'hlasm-runtime-module-name': {
38
+ sev: 'med', evidence: 'construct', cwe: 'CWE-470',
39
+ text: 'Assembler source loads or calls a module whose name it reads from storage at run time',
40
+ impact: 'EPLOC= and DE= give LINK, XCTL, LOAD and ATTACH the address of the name rather than the name, so whatever writes that storage chooses the program that runs, with the caller\'s authority',
41
+ remedy: 'Name the module with EP= where it is fixed; where it must vary, check the name against a list of the modules the program may run before the call',
42
+ },
34
43
  'hlasm-calls-security-product': { sev: 'info', evidence: 'context', cwe: null, text: 'Assembler source calls the security product directly' },
35
44
  'hlasm-provides-called-module': { sev: 'info', evidence: 'context', cwe: null, text: 'An assembler CSECT or ENTRY is a module a COBOL program in this tree CALLs' },
36
45
  };
@@ -45,8 +54,17 @@ const STATE = { KEY: 'PSW key zero', EXTKEY: 'PSW key zero', MODE: 'supervisor s
45
54
 
46
55
  const where = (use) => (use.inMacro ? ' It is in a macro definition, so it is in every program that uses the macro.' : '');
47
56
 
48
- function detail(use) {
49
- const written = `${use.name}${use.field ? ` ${use.field}` : ''}`;
57
+ // EX and EXRL name their target instruction; when it is labelled in the same file, the finding says
58
+ // which instruction runs with the register's length or mask.
59
+ function executed(use, labels) {
60
+ if (use.class !== 'built-instruction') return '';
61
+ const target = (splitOperands(use.field || '')[1] || '').trim().toUpperCase();
62
+ const t = /^[A-Z$#@_][A-Z0-9$#@_]*$/.test(target) ? labels.get(target) : null;
63
+ return t ? ` runs ${t.operation}${t.field ? ` ${t.field}` : ''}` : '';
64
+ }
65
+
66
+ function detail(use, labels) {
67
+ const written = `${use.name}${use.field ? ` ${use.field}` : ''}${executed(use, labels)}`;
50
68
  if (use.class === 'security-product') {
51
69
  const request = /\bREQUEST=([A-Z]+)/i.exec(use.field || '');
52
70
  return `${request ? `${use.name} REQUEST=${request[1].toUpperCase()}` : use.name} calls the security product from assembler rather than through a service that checks on the program's behalf.${where(use)}`;
@@ -72,6 +90,32 @@ export function scanHlasm(root, opts = {}) {
72
90
  const all = tree.list().filter(inScope(opts));
73
91
  const findings = [];
74
92
  const stats = { filesScanned: 0, filesUnreadable: 0, assemblerFiles: 0, bmsFiles: 0, imsFiles: 0, unrecognisedFiles: 0, readNotAssembled: true };
93
+ const members = new Set(all.filter((f) => [...HLASM_EXT, ...COPY_EXT].some((e) => f.toLowerCase().endsWith(e))).map((f) => basename(f, extname(f)).toUpperCase()));
94
+ const refused = {};
95
+ const macroCalls = new Map();
96
+ const copyNotFound = new Set();
97
+ // Counts, for the coverage it reports, what the statement reader refused and which macros and COPY
98
+ // members a file uses.
99
+ function readStatements(src, path) {
100
+ const labels = new Map();
101
+ for (const st of readHlasmStatements(src).statements) {
102
+ if (st.inMacro) continue;
103
+ if (st.kind === 'statement' && st.name && !st.name.startsWith('&') && !st.name.startsWith('.')) labels.set(st.name.toUpperCase(), st);
104
+ const res = parseHlasmStatement(st);
105
+ if (res.status === 'unparsed' || res.status === 'unknown') (refused[res.kind] ||= { count: 0, first: `${path}:${st.line}` }).count++;
106
+ const name = res.kind === 'MACRO CALL' ? st.operation : NAMED_MACROS.has(res.kind) ? res.kind : null;
107
+ if (name) {
108
+ const m = macroCalls.get(name) || { name, calls: 0, definedInTree: members.has(name) };
109
+ m.calls++;
110
+ macroCalls.set(name, m);
111
+ }
112
+ if (res.kind === 'COPY') {
113
+ const member = String(res.status === 'parsed' ? res.node.member : (/^\s*([A-Z$#@][A-Z0-9$#@_]*)/i.exec(st.field || '') || [])[1] || '').toUpperCase();
114
+ if (member && !members.has(member)) copyNotFound.add(member);
115
+ }
116
+ }
117
+ return labels;
118
+ }
75
119
  const defines = new Map();
76
120
 
77
121
  const run = eachWithinMemory(all.filter(isAssembler), (f) => {
@@ -85,13 +129,20 @@ export function scanHlasm(root, opts = {}) {
85
129
  stats.assemblerFiles++;
86
130
  const path = relPath(root, f);
87
131
  for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
132
+ const labels = readStatements(src, path);
88
133
  for (const use of r.operations) {
134
+ if (use.class === 'module-load') {
135
+ const { keywords } = macroOperands(use.field || '');
136
+ const at = keywords.has('EPLOC') ? 'EPLOC' : keywords.has('DE') ? 'DE' : null;
137
+ if (at) findings.push({ rule: 'hlasm-runtime-module-name', path, line: use.line, detail: `${use.name} ${at}=${keywords.get(at)} names the module by the address of its name, so the program that runs is whatever name that storage holds.${where(use)}` });
138
+ continue;
139
+ }
89
140
  if (use.name === 'MODESET') {
90
141
  if (use.stateChange) findings.push({ rule: 'hlasm-supervisor-state-change', path, line: use.line, detail: `MODESET ${use.stateChange} switches to ${STATE[use.stateChange.split('=')[0]]}.${where(use)}` });
91
142
  continue;
92
143
  }
93
144
  const rule = RULE_OF_CLASS[use.class];
94
- if (rule) findings.push({ rule, path, line: use.line, detail: detail(use) });
145
+ if (rule) findings.push({ rule, path, line: use.line, detail: detail(use, labels) });
95
146
  }
96
147
  return src.length;
97
148
  }, { label: 'hlasm', maxBytes: opts.maxSourceBytes ?? Infinity });
@@ -117,5 +168,10 @@ export function scanHlasm(root, opts = {}) {
117
168
  }
118
169
  }
119
170
 
171
+ // Each statement the reader refused, each macro whose expansion was not read and each COPY member
172
+ // the tree does not hold is named: an operation inside any of them is one this set did not see.
173
+ if (Object.keys(refused).length) { stats.statementsNotRead = refused; stats.coverageIncomplete = true; }
174
+ if (copyNotFound.size) { stats.copyNotFound = [...copyNotFound].sort(); stats.coverageIncomplete = true; }
175
+ if (macroCalls.size) stats.macrosNotExpanded = [...macroCalls.values()].sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)).slice(0, 50);
120
176
  return report('hlasm', { rules: HLASM_RULES, findings, stats, run });
121
177
  }
package/lib/sets/log.mjs CHANGED
@@ -23,7 +23,7 @@ import { parseJcl } from '../jcl.mjs';
23
23
  import { compileStepOptions } from '../options.mjs';
24
24
  import { report } from '../kernel/ruleset.mjs';
25
25
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
26
- import { eachWithinMemory } from '../kernel/memory.mjs';
26
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
27
27
 
28
28
 
29
29
  export const LOG_RULES = {
@@ -337,7 +337,7 @@ const memberOf = (file) => basename(String(file || ''), extname(String(file || '
337
337
  const runByJob = (batch, p, file) => batch.unsure
338
338
  || [String(p.id || '').toUpperCase(), memberOf(file)].some((n) => n && (batch.runs.has(n) || batch.runs.has(n.slice(0, 8))));
339
339
 
340
- export function scanLog(root, opts = {}) {
340
+ export function* scanLogSteps(root, opts = {}) {
341
341
  const tree = treeFor(root, opts);
342
342
  const files = tree.list().filter(isProgram).filter(inScope(opts));
343
343
  const findings = [];
@@ -364,7 +364,7 @@ export function scanLog(root, opts = {}) {
364
364
  return { rule: 'display-echoes-a-credential', detail: `${p.id} shows ${w.name} on the terminal to the person who typed it: not a log, but the screen and its scrollback keep it` };
365
365
  };
366
366
 
367
- const run = eachWithinMemory(files, (f) => {
367
+ const run = yield loopOver(files, (f) => {
368
368
  let src;
369
369
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
370
370
  // Every EXEC CICS WRITE form, not the two this set started with. WRITE OPERATOR was added as a
@@ -404,3 +404,5 @@ export function scanLog(root, opts = {}) {
404
404
 
405
405
  return report('log', { rules: LOG_RULES, findings, stats, run });
406
406
  }
407
+
408
+ export const scanLog = (root, opts = {}) => drive(scanLogSteps(root, opts));
package/lib/sets/priv.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { inScope, isProgram, relPath } from '../sources.mjs';
20
20
  import { report } from '../kernel/ruleset.mjs';
21
21
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
22
- import { eachWithinMemory } from '../kernel/memory.mjs';
22
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
23
23
  import { parseCsd } from '../csd.mjs';
24
24
  import { parseJcl } from '../jcl.mjs';
25
25
  import { loadSite } from '../site.mjs';
@@ -101,7 +101,7 @@ function under(dsn, prefixes) {
101
101
  });
102
102
  }
103
103
 
104
- export function scanPriv(root, opts = {}) {
104
+ export function* scanPrivSteps(root, opts = {}) {
105
105
  const site = loadSite(root, opts.site || null, opts.tree);
106
106
  const tree = treeFor(root, opts);
107
107
  const files = tree.list().filter(inScope(opts));
@@ -140,7 +140,7 @@ export function scanPriv(root, opts = {}) {
140
140
  const diagnostics = [];
141
141
  const uss = [];
142
142
 
143
- const run = eachWithinMemory(files, (f) => {
143
+ const run = yield loopOver(files, (f) => {
144
144
  let src;
145
145
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
146
146
  const path = relPath(root, f);
@@ -320,3 +320,5 @@ export function scanPriv(root, opts = {}) {
320
320
 
321
321
  return report('priv', { rules: PRIV_RULES, findings, stats, run });
322
322
  }
323
+
324
+ export const scanPriv = (root, opts = {}) => drive(scanPrivSteps(root, opts));
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Credentials written into COBOL source. The shapes are the ones rules/gitleaks-mainframe.toml gives
3
+ // gitleaks, read from that file, so one list serves both and the build gate does not depend on
4
+ // gitleaks being installed. Go's RE2 syntax is taken only where it means the same in JavaScript; any
5
+ // other construct is refused when the file is read.
6
+ import { readFileSync } from 'node:fs';
7
+ import { inScope, isProgram, isCopybook, relPath } from '../sources.mjs';
8
+ import { report } from '../kernel/ruleset.mjs';
9
+ import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
10
+ import { eachWithinMemory } from '../kernel/memory.mjs';
11
+
12
+ export const SECRETS_RULES = {
13
+ 'credential-in-source': {
14
+ sev: 'high', evidence: 'construct', cwe: 'CWE-798', measured: false,
15
+ text: 'A credential is written into the source',
16
+ impact: 'Everyone who can read the source, its history or a listing can sign on with it, and changing it means changing and redeploying the program',
17
+ remedy: 'Read the credential at run time from where the site keeps them (a RACF-protected data set, a PassTicket, a Db2 trusted context or a CICS-supplied identity), and change the one already exposed',
18
+ },
19
+ };
20
+
21
+ // The TOML ids of the shapes that occur in COBOL programs and copybooks, and how a finding names each.
22
+ const SHAPES = {
23
+ 'cobol-value-credential': (m) => `${dataName(m) || 'a data item named for a credential'} has a literal VALUE`,
24
+ 'embedded-sql-connect-password': () => 'EXEC SQL CONNECT gives a literal password',
25
+ 'cics-signon-password': (m) => `EXEC CICS ${/\b(SIGNON|VERIFY|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal PASSWORD`,
26
+ 'cics-new-password': (m) => `EXEC CICS ${/\b(SIGNON|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal NEWPASSWORD`,
27
+ };
28
+
29
+ const dataName = (m) => (/\b(?:0?[1-9]|[1-4][0-9]|77)\s+([A-Z0-9][A-Z0-9-]*)/i.exec(m) || [])[1]?.toUpperCase();
30
+
31
+ const value = (raw) => {
32
+ const t = raw.trim();
33
+ if (t.startsWith("'''")) return t.slice(3, t.lastIndexOf("'''"));
34
+ if (t.startsWith('"')) return JSON.parse(t);
35
+ if (t.startsWith('[')) return [...t.matchAll(/'''(.*?)'''|"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1] ?? JSON.parse(`"${x[2]}"`));
36
+ if (/^\d+$/.test(t)) return Number(t);
37
+ if (t === 'true' || t === 'false') return t === 'true';
38
+ throw new Error(`gitleaks-mainframe.toml: value not read: ${t}`);
39
+ };
40
+
41
+ // The [[rules]] tables of the file, each with its allowlist folded in.
42
+ export function readToml(text) {
43
+ const rules = [];
44
+ let into = null;
45
+ for (const line of text.split('\n')) {
46
+ const l = line.trim();
47
+ if (!l || l.startsWith('#')) continue;
48
+ if (l === '[[rules]]') { rules.push((into = { allowlist: {} })); continue; }
49
+ if (l === '[rules.allowlist]') { into = rules.at(-1).allowlist; continue; }
50
+ if (l.startsWith('[')) { into = null; continue; }
51
+ const kv = /^([A-Za-z]+)\s*=\s*(.+)$/.exec(l);
52
+ if (kv && into) into[kv[1]] = value(kv[2]);
53
+ }
54
+ return rules;
55
+ }
56
+
57
+ // A gitleaks regex as a JavaScript one: a leading (?flags) group becomes the flags.
58
+ export function jsRegex(re, extra = '') {
59
+ const lead = /^\(\?([ims]+)\)/.exec(re);
60
+ const body = lead ? re.slice(lead[0].length) : re;
61
+ if (/\(\?[a-zA-Z]|\(\?P<|\\[AzZpPQE]/.test(body)) throw new Error(`gitleaks-mainframe.toml: no JavaScript equivalent for ${re}`);
62
+ return new RegExp(body, [...new Set([...(lead ? lead[1] : ''), ...extra])].join(''));
63
+ }
64
+
65
+ const secretOf = (m, group) => (group ? m[group] : m.slice(1).find((g) => g !== undefined)) ?? m[0];
66
+
67
+ export const SHAPE_RULES = readToml(readFileSync(new URL('../../rules/gitleaks-mainframe.toml', import.meta.url), 'utf8'))
68
+ .filter((r) => r.id in SHAPES)
69
+ .map((r) => ({
70
+ id: r.id,
71
+ pattern: jsRegex(r.regex, 'g'),
72
+ secretGroup: r.secretGroup || 0,
73
+ keywords: (r.keywords || []).map((k) => k.toLowerCase()),
74
+ allow: (r.allowlist.regexes || []).map((x) => jsRegex(x)),
75
+ allowTarget: r.allowlist.regexTarget || 'secret',
76
+ }));
77
+
78
+ // Every shape in the text, at the line its first non-blank character is on.
79
+ export function credentialsIn(text) {
80
+ const lower = text.toLowerCase();
81
+ const out = [];
82
+ for (const s of SHAPE_RULES) {
83
+ if (s.keywords.length && !s.keywords.some((k) => lower.includes(k))) continue;
84
+ for (const m of text.matchAll(s.pattern)) {
85
+ const target = s.allowTarget === 'match' ? m[0] : secretOf(m, s.secretGroup);
86
+ if (s.allow.some((a) => a.test(target))) continue;
87
+ const start = m.index + (m[0].length - m[0].trimStart().length);
88
+ out.push({ shape: s.id, line: text.slice(0, start).split('\n').length, detail: SHAPES[s.id](m[0]) });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export function scanSecrets(root, opts = {}) {
95
+ const tree = treeFor(root, opts);
96
+ const files = tree.list().filter((f) => isProgram(f) || isCopybook(f)).filter(inScope(opts));
97
+ const findings = [];
98
+ const stats = { filesScanned: 0, filesUnreadable: 0 };
99
+
100
+ const run = eachWithinMemory(files, (f) => {
101
+ let src;
102
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
103
+ stats.filesScanned++;
104
+ const path = relPath(root, f);
105
+ for (const c of credentialsIn(src)) findings.push({ rule: 'credential-in-source', path, line: c.line, detail: `${c.detail} (${c.shape})` });
106
+ return src.length;
107
+ }, { label: 'secrets', maxBytes: opts.maxSourceBytes ?? Infinity });
108
+
109
+ return report('secrets', { rules: SECRETS_RULES, findings, stats, run });
110
+ }
@@ -9,7 +9,7 @@ import { basename, extname } from 'node:path';
9
9
  import { inScope, isProgram, isJcl, relPath, ebcdicByte } from '../sources.mjs';
10
10
  import { report } from '../kernel/ruleset.mjs';
11
11
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
12
- import { eachWithinMemory } from '../kernel/memory.mjs';
12
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
13
13
  import { loadSite } from '../site.mjs';
14
14
  import { parseJcl } from '../jcl.mjs';
15
15
  import { optionCards, optionTokens, compileStepOptions } from '../options.mjs';
@@ -206,7 +206,7 @@ function thruPairs(toks) {
206
206
  }
207
207
  const WHEN_WORDS = new Set(['THRU', 'THROUGH', 'ALSO', 'OR', 'NOT', 'ANY', 'OTHER', 'TRUE', 'FALSE']);
208
208
 
209
- export function scanSemantics(root, opts = {}) {
209
+ export function* scanSemanticsSteps(root, opts = {}) {
210
210
  const tree = treeFor(root, opts);
211
211
  const all = tree.list().filter(inScope(opts));
212
212
  const files = all.filter(isProgram);
@@ -359,7 +359,7 @@ export function scanSemantics(root, opts = {}) {
359
359
  }
360
360
  }
361
361
 
362
- const run = eachWithinMemory(files, (f) => {
362
+ const run = yield loopOver(files, (f) => {
363
363
  let src;
364
364
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
365
365
  let r;
@@ -375,3 +375,5 @@ export function scanSemantics(root, opts = {}) {
375
375
 
376
376
  return report('semantics', { rules: SEMANTICS_RULES, findings, stats, run });
377
377
  }
378
+
379
+ export const scanSemantics = (root, opts = {}) => drive(scanSemanticsSteps(root, opts));
package/lib/sets/web.mjs CHANGED
@@ -22,7 +22,7 @@ import { parseCsd } from '../csd.mjs';
22
22
  import { classify } from './log.mjs';
23
23
  import { report } from '../kernel/ruleset.mjs';
24
24
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
25
- import { eachWithinMemory } from '../kernel/memory.mjs';
25
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
26
26
 
27
27
 
28
28
  export const WEB_RULES = {
@@ -168,7 +168,7 @@ const HTTP_METHOD = /^(GET|POST|PUT|PATCH|DELETE)$/i;
168
168
  // the absence and the explicit NO are one finding, and PROTOCOL only changes what is in the clear.
169
169
  const encrypted = (svc) => /^(YES|CLIENTAUTH|CLIENTCERT)$/i.test(String(svc.ssl || ''));
170
170
 
171
- export function scanWeb(root, opts = {}) {
171
+ export function* scanWebSteps(root, opts = {}) {
172
172
  const tree = treeFor(root, opts);
173
173
  const all = tree.list().filter(inScope(opts));
174
174
  const files = all.filter(isProgram);
@@ -323,7 +323,7 @@ export function scanWeb(root, opts = {}) {
323
323
  }
324
324
  }
325
325
 
326
- const run = eachWithinMemory(files, (f) => {
326
+ const run = yield loopOver(files, (f) => {
327
327
  let src;
328
328
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
329
329
  // The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
@@ -444,3 +444,5 @@ export function scanWeb(root, opts = {}) {
444
444
 
445
445
  return report('web', { rules: WEB_RULES, findings, stats, run });
446
446
  }
447
+
448
+ export const scanWeb = (root, opts = {}) => drive(scanWebSteps(root, opts));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Portll <john@portll.net>",