@portll/cobolwork 0.6.0 → 0.7.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 (140) hide show
  1. package/README.md +49 -18
  2. package/STABILITY.md +76 -0
  3. package/bin/cobolwork.mjs +30 -7
  4. package/lib/bms.mjs +21 -7
  5. package/lib/build.mjs +108 -53
  6. package/lib/capabilities.mjs +9 -10
  7. package/lib/cics-commands.mjs +501 -9
  8. package/lib/compliance.mjs +11 -0
  9. package/lib/consequence.mjs +13 -0
  10. package/lib/control-workers.mjs +161 -0
  11. package/lib/control.mjs +52 -3
  12. package/lib/dataflow.mjs +176 -125
  13. package/lib/db2/cursor.mjs +15 -0
  14. package/lib/db2/read.mjs +170 -0
  15. package/lib/db2/rules.mjs +77 -0
  16. package/lib/db2/stmt/alter.mjs +473 -0
  17. package/lib/db2/stmt/grant.mjs +125 -0
  18. package/lib/db2/stmt/index.mjs +141 -0
  19. package/lib/db2/stmt/misc.mjs +325 -0
  20. package/lib/db2/stmt/routine.mjs +564 -0
  21. package/lib/db2/stmt/storage.mjs +146 -0
  22. package/lib/db2/stmt/table.mjs +540 -0
  23. package/lib/db2/stmt/view.mjs +146 -0
  24. package/lib/diff.mjs +17 -5
  25. package/lib/evidence/cli.mjs +14 -3
  26. package/lib/evidence/record.mjs +1 -1
  27. package/lib/evidence/store.mjs +44 -31
  28. package/lib/exec-reading.mjs +51 -0
  29. package/lib/execution.mjs +3 -2
  30. package/lib/explain.mjs +2 -0
  31. package/lib/exploitability.mjs +11 -2
  32. package/lib/hlasm/asm/data.mjs +13 -1
  33. package/lib/hlasm/asm/sections.mjs +3 -1
  34. package/lib/hlasm/instr.mjs +25 -0
  35. package/lib/hlasm/macro/authorization.mjs +24 -4
  36. package/lib/hlasm/macro/datasets.mjs +30 -17
  37. package/lib/hlasm/macro/io.mjs +68 -45
  38. package/lib/hlasm/macro/le.mjs +2 -2
  39. package/lib/hlasm/macro/operator.mjs +32 -21
  40. package/lib/hlasm/macro/program.mjs +126 -84
  41. package/lib/hlasm/macro/recovery.mjs +10 -5
  42. package/lib/hlasm/macro/storage.mjs +64 -14
  43. package/lib/hlasm/macro/structured.mjs +1 -1
  44. package/lib/hlasm/model.mjs +35 -10
  45. package/lib/hlasm/mvs38.mjs +47 -0
  46. package/lib/hlasm/operands.mjs +10 -0
  47. package/lib/hlasm/optable.mjs +61 -0
  48. package/lib/hlasm/read.mjs +22 -8
  49. package/lib/hlasm.mjs +44 -7
  50. package/lib/ims/dli.mjs +37 -0
  51. package/lib/ims/macro/dbd.mjs +299 -0
  52. package/lib/ims/macro/psb.mjs +286 -0
  53. package/lib/ims/model.mjs +149 -0
  54. package/lib/ims/operands.mjs +23 -0
  55. package/lib/ims/read.mjs +37 -0
  56. package/lib/ims/rules.mjs +135 -0
  57. package/lib/ironwork.mjs +17 -13
  58. package/lib/kernel/pds-archive.mjs +256 -0
  59. package/lib/kernel/registry.mjs +14 -8
  60. package/lib/kernel/shared-pass.mjs +34 -9
  61. package/lib/kernel/source-tree.mjs +104 -36
  62. package/lib/kernel/version-key.mjs +17 -0
  63. package/lib/layout.mjs +26 -31
  64. package/lib/parser.mjs +136 -17
  65. package/lib/pli/cursor.mjs +15 -0
  66. package/lib/pli/expr.mjs +101 -0
  67. package/lib/pli/include.mjs +82 -0
  68. package/lib/pli/layout.mjs +125 -0
  69. package/lib/pli/lex.mjs +198 -0
  70. package/lib/pli/program.mjs +280 -0
  71. package/lib/pli/rules/based.mjs +95 -0
  72. package/lib/pli/rules/conditions.mjs +68 -0
  73. package/lib/pli/rules/entry.mjs +130 -0
  74. package/lib/pli/rules/index.mjs +24 -0
  75. package/lib/pli/rules/preprocessor.mjs +55 -0
  76. package/lib/pli/statements.mjs +130 -0
  77. package/lib/pli/stmt/alloc.mjs +45 -0
  78. package/lib/pli/stmt/assignment.mjs +56 -0
  79. package/lib/pli/stmt/call.mjs +104 -0
  80. package/lib/pli/stmt/conditions.mjs +94 -0
  81. package/lib/pli/stmt/control.mjs +219 -0
  82. package/lib/pli/stmt/declare.mjs +149 -0
  83. package/lib/pli/stmt/exec.mjs +55 -0
  84. package/lib/pli/stmt/io.mjs +239 -0
  85. package/lib/pli/stmt/misc.mjs +4 -0
  86. package/lib/pli/stmt/preprocessor.mjs +242 -0
  87. package/lib/pli/stmt/procedure.mjs +258 -0
  88. package/lib/pli/stmt/stream.mjs +283 -0
  89. package/lib/pli/storage.mjs +129 -0
  90. package/lib/precompile-check.mjs +124 -0
  91. package/lib/precompile-cics.mjs +7 -3
  92. package/lib/reach.mjs +11 -2
  93. package/lib/revision.json +1 -1
  94. package/lib/sarif.mjs +41 -3
  95. package/lib/scan.mjs +7 -1
  96. package/lib/sets/abend.mjs +16 -6
  97. package/lib/sets/cics.mjs +15 -35
  98. package/lib/sets/compile.mjs +41 -15
  99. package/lib/sets/crypto.mjs +5 -3
  100. package/lib/sets/ddl.mjs +36 -0
  101. package/lib/sets/flow.mjs +18 -1
  102. package/lib/sets/hidden.mjs +5 -3
  103. package/lib/sets/hlasm.mjs +72 -12
  104. package/lib/sets/ims.mjs +139 -0
  105. package/lib/sets/log.mjs +6 -6
  106. package/lib/sets/opaque.mjs +27 -7
  107. package/lib/sets/pli.mjs +40 -0
  108. package/lib/sets/recon.mjs +5 -3
  109. package/lib/sets/secrets.mjs +5 -3
  110. package/lib/sets/semantics.mjs +3 -0
  111. package/lib/sets/web.mjs +36 -22
  112. package/lib/site.mjs +10 -0
  113. package/lib/sources.mjs +80 -20
  114. package/lib/statement-cursor.mjs +67 -0
  115. package/lib/verify.mjs +3 -2
  116. package/lib/version.mjs +6 -0
  117. package/package.json +3 -2
  118. package/rules/compliance-cobit2019.json +432 -1
  119. package/rules/compliance-dora.json +414 -1
  120. package/rules/compliance-ffiec.json +414 -1
  121. package/rules/compliance-nist80053.json +466 -1
  122. package/rules/hlasm-optables.json +8024 -0
  123. package/schema/cobolwork-baseline.schema.json +36 -0
  124. package/schema/cobolwork-build-provenance.schema.json +187 -0
  125. package/schema/cobolwork-build.schema.json +382 -0
  126. package/schema/cobolwork-capabilities.schema.json +239 -0
  127. package/schema/cobolwork-diff.schema.json +217 -0
  128. package/schema/cobolwork-evidence.schema.json +161 -0
  129. package/schema/cobolwork-execution.schema.json +53 -0
  130. package/schema/cobolwork-explain.schema.json +360 -0
  131. package/schema/cobolwork-finding.schema.json +465 -0
  132. package/schema/cobolwork-flow.schema.json +465 -0
  133. package/schema/cobolwork-gate.schema.json +211 -0
  134. package/schema/cobolwork-inventory.schema.json +206 -0
  135. package/schema/cobolwork-parse.schema.json +105 -0
  136. package/schema/cobolwork-reach.schema.json +74 -0
  137. package/schema/cobolwork-report.schema.json +559 -0
  138. package/schema/cobolwork-witness.schema.json +107 -0
  139. package/schema/cobolwork.baseline.schema.json +101 -0
  140. package/schema/cobolwork.site.schema.json +116 -0
@@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs';
7
7
  import { inScope, isProgram, isCopybook, relPath } from '../sources.mjs';
8
8
  import { report } from '../kernel/ruleset.mjs';
9
9
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
10
- import { eachWithinMemory } from '../kernel/memory.mjs';
10
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
11
11
 
12
12
  export const SECRETS_RULES = {
13
13
  'credential-in-source': {
@@ -91,13 +91,13 @@ export function credentialsIn(text) {
91
91
  return out;
92
92
  }
93
93
 
94
- export function scanSecrets(root, opts = {}) {
94
+ export function* scanSecretsSteps(root, opts = {}) {
95
95
  const tree = treeFor(root, opts);
96
96
  const files = tree.list().filter((f) => isProgram(f) || isCopybook(f)).filter(inScope(opts));
97
97
  const findings = [];
98
98
  const stats = { filesScanned: 0, filesUnreadable: 0 };
99
99
 
100
- const run = eachWithinMemory(files, (f) => {
100
+ const run = yield loopOver(files, (f) => {
101
101
  let src;
102
102
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
103
103
  stats.filesScanned++;
@@ -108,3 +108,5 @@ export function scanSecrets(root, opts = {}) {
108
108
 
109
109
  return report('secrets', { rules: SECRETS_RULES, findings, stats, run });
110
110
  }
111
+
112
+ export const scanSecrets = (root, opts = {}) => drive(scanSecretsSteps(root, opts));
@@ -256,6 +256,9 @@ export function* scanSemanticsSteps(root, opts = {}) {
256
256
  const narrowed = [];
257
257
 
258
258
  for (const st of p.statements) {
259
+ // Without TRUNC(OPT) only a COMPUTE is judged, and the slice below runs to the end of the
260
+ // program for a statement whose end the parse did not mark.
261
+ if (st.verb !== 'COMPUTE' && !truncOpt) continue;
259
262
  const seg = toks.slice(st.at + 1, st.end ?? toks.length);
260
263
  const sizeError = seg.some((t, k) => t.t === 'word' && t.u === 'SIZE' && seg[k + 1] && seg[k + 1].u === 'ERROR');
261
264
 
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 { drive, loopOver } from '../kernel/shared-pass.mjs';
25
+ import { drive, loopOver, loopsOver } from '../kernel/shared-pass.mjs';
26
26
 
27
27
 
28
28
  export const WEB_RULES = {
@@ -302,11 +302,12 @@ export function* scanWebSteps(root, opts = {}) {
302
302
  // is named whatever the estate named it, and filtering on an extension is how a BMS count once
303
303
  // came out as zero.
304
304
  // Client URIMAPs come from the same files: WEB OPEN URIMAP(name) takes its scheme from one.
305
+ // Read in the pass the program loop shares, so a URIMAP is looked up only once every file is read.
305
306
  const urimaps = new Map();
306
- for (const f of all) {
307
+ const definitions = loopOver(all, (f) => {
307
308
  let src;
308
- try { src = tree.text(f).text; } catch { continue; }
309
- if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src)) continue;
309
+ try { src = tree.text(f).text; } catch { return; }
310
+ if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src)) return;
310
311
  const path = relPath(root, f);
311
312
  const csd = parseCsd(src);
312
313
  for (const [name, u] of csd.urimaps) if (!urimaps.has(name)) urimaps.set(name, { ...u, path });
@@ -321,9 +322,11 @@ export function* scanWebSteps(root, opts = {}) {
321
322
  + ', so everything the session carries crosses the network in the clear',
322
323
  });
323
324
  }
324
- }
325
+ }, { label: 'web' });
325
326
 
326
- const run = yield loopOver(files, (f) => {
327
+ // A program's findings in its own order, an open through a URIMAP held as the function that judges it.
328
+ const fromPrograms = [];
329
+ const programs = loopOver(files, (f) => {
327
330
  let src;
328
331
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
329
332
  // The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
@@ -348,23 +351,25 @@ export function* scanWebSteps(root, opts = {}) {
348
351
  const to = o.host ? ` to ${o.host}` : '';
349
352
  if (o.https) continue;
350
353
  if (o.http) {
351
- findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
354
+ fromPrograms.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
352
355
  detail: `${v.id} opens a connection${to} asking for HTTP rather than HTTPS, so what it sends and reads crosses the network in the clear unless an AT-TLS policy outside the program encrypts it` });
353
356
  continue;
354
357
  }
355
- const u = o.urimap ? urimaps.get(o.urimap.toUpperCase()) : null;
356
- if (u && /^HTTP$/i.test(u.scheme || '') && !/^AWARE$/i.test(u.attls || '')) {
357
- findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
358
- detail: `${v.id} opens a connection through URIMAP ${o.urimap.toUpperCase()}, which ${u.path}:${u.line} defines with SCHEME(HTTP)${u.host ? ` to ${u.host}` : ''} and no ATTLS(AWARE), so what it sends and reads crosses the network in the clear unless an AT-TLS policy encrypts it` });
359
- continue;
360
- }
361
- // A scheme in a variable, or a URIMAP the tree does not define, is decided where this cannot see.
362
- if (!u || !u.scheme) stats.opensUndecided++;
358
+ fromPrograms.push(() => {
359
+ const u = o.urimap ? urimaps.get(o.urimap.toUpperCase()) : null;
360
+ if (u && /^HTTP$/i.test(u.scheme || '') && !/^AWARE$/i.test(u.attls || '')) {
361
+ return { rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
362
+ detail: `${v.id} opens a connection through URIMAP ${o.urimap.toUpperCase()}, which ${u.path}:${u.line} defines with SCHEME(HTTP)${u.host ? ` to ${u.host}` : ''} and no ATTLS(AWARE), so what it sends and reads crosses the network in the clear unless an AT-TLS policy encrypts it` };
363
+ }
364
+ // A scheme in a variable, or a URIMAP the tree does not define, is decided where this cannot see.
365
+ if (!u || !u.scheme) stats.opensUndecided++;
366
+ return null;
367
+ });
363
368
  }
364
369
 
365
370
  for (const r of v.receives) {
366
371
  if (r.max.bytes <= r.size) continue;
367
- findings.push({ rule: 'web-receive-length-exceeds-area', ...r.at, program: v.id,
372
+ fromPrograms.push({ rule: 'web-receive-length-exceeds-area', ...r.at, program: v.id,
368
373
  detail: `${v.id} has WEB ${r.verb} pass up to MAXLENGTH(${r.max.written}), ${r.max.bytes} bytes, into ${r.area}, which is ${r.size} bytes` });
369
374
  }
370
375
  stats.headersWritten += v.headers.length;
@@ -378,7 +383,7 @@ export function* scanWebSteps(root, opts = {}) {
378
383
  if (!/;\s*httponly\b/i.test(c.value)) missing.push('HttpOnly');
379
384
  if (!/;\s*samesite\s*=/i.test(c.value)) missing.push('SameSite');
380
385
  if (!missing.length) continue;
381
- findings.push({
386
+ fromPrograms.push({
382
387
  rule: 'web-cookie-without-secure-attributes', ...c.at, program: v.id,
383
388
  detail: `${v.id} writes Set-Cookie without ${missing.join(', ')}`
384
389
  + (missing.includes('Secure') ? ', so the browser will send this cookie over a cleartext hop' : ''),
@@ -386,7 +391,7 @@ export function* scanWebSteps(root, opts = {}) {
386
391
  }
387
392
 
388
393
  for (const h of v.headers.filter((x) => TELLS.test(x.name))) {
389
- findings.push({
394
+ fromPrograms.push({
390
395
  rule: 'web-response-tells-the-caller-what-it-runs', ...h.at, program: v.id,
391
396
  detail: `${v.id} writes the header ${h.name}${h.value ? ` with the value '` + h.value + `'` : ''}, which names the software answering rather than anything the client needs`,
392
397
  });
@@ -397,14 +402,14 @@ export function* scanWebSteps(root, opts = {}) {
397
402
  ? { line: v.built[0].line, why: 'STRINGs ' + v.built[0].name + ' into a value it sends, and the name says it holds a credential' }
398
403
  : (v.uris.length ? { line: 1, why: 'builds a URI whose query names a credential: ' + v.uris[0] } : null);
399
404
  if (credInUri) {
400
- findings.push({
405
+ fromPrograms.push({
401
406
  rule: 'web-uri-carries-a-credential', path, line: credInUri.line, program: v.id,
402
407
  detail: v.id + ' ' + credInUri.why,
403
408
  });
404
409
  }
405
410
  if (v.webRequest && v.changes.length && !v.comparesToken) {
406
411
  const what = [...new Set(v.changes.map((c) => c.what))];
407
- findings.push({
412
+ fromPrograms.push({
408
413
  rule: 'web-request-changes-state-without-a-token', ...where(v.changes[0].e, path), program: v.id,
409
414
  detail: `${v.id} changes state (${what.slice(0, 3).join(', ')}${what.length > 3 ? ', …' : ''}) on a request it received from the web and compares no token with another field`
410
415
  + (v.testsMethod ? '; it tests the HTTP method, which a forged request sets too' : '')
@@ -420,7 +425,7 @@ export function* scanWebSteps(root, opts = {}) {
420
425
  // A program that wrote none of the three has said nothing about any of this; one that wrote
421
426
  // two of three made a choice about the third. Both are reported, and the detail says which.
422
427
  if (absent.length) {
423
- findings.push({
428
+ fromPrograms.push({
424
429
  rule: 'web-response-without-protective-headers', ...at, program: v.id,
425
430
  detail: `${v.id} sends HTML and writes no ${absent.map((h) => h.header.source.replace(/[^a-z-]/gi, '')).join(', no ')} header`
426
431
  + (v.computedHeaders ? `. ${v.computedHeaders} header name or value in this program is computed, so what it sets was not read` : ''),
@@ -431,7 +436,7 @@ export function* scanWebSteps(root, opts = {}) {
431
436
  // the document is built from.
432
437
  for (const m of v.docText.matchAll(/<a\b[^>]*target\s*=\s*["']?_blank[^>]*>/gi)) {
433
438
  if (/\brel\s*=\s*["'][^"']*noopener/i.test(m[0])) continue;
434
- findings.push({
439
+ fromPrograms.push({
435
440
  rule: 'web-link-opens-without-noopener', ...at, program: v.id,
436
441
  detail: `${v.id} writes a link with target="_blank" and no rel="noopener", so the page it opens can navigate this one`,
437
442
  });
@@ -442,6 +447,15 @@ export function* scanWebSteps(root, opts = {}) {
442
447
  return src.length;
443
448
  }, { label: 'web', maxBytes: opts.maxSourceBytes ?? Infinity });
444
449
 
450
+ // A pass the memory guard stopped after the last program but before a later definition is no
451
+ // more complete than one stopped among the programs.
452
+ const [defined, read] = yield loopsOver(definitions, programs);
453
+ const run = read.complete && !defined.complete ? defined : read;
454
+ for (const x of fromPrograms) {
455
+ const finding = typeof x === 'function' ? x() : x;
456
+ if (finding) findings.push(finding);
457
+ }
458
+
445
459
  return report('web', { rules: WEB_RULES, findings, stats, run });
446
460
  }
447
461
 
package/lib/site.mjs CHANGED
@@ -12,8 +12,10 @@ import { readFileSync, existsSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { resolvesInside } from './kernel/source-tree.mjs';
14
14
  import { printable } from './kernel/printable.mjs';
15
+ import { versionProblem, unknownKeys, ignoredKey } from './kernel/version-key.mjs';
15
16
 
16
17
  export const SITE_FILE = 'cobolwork.site.json';
18
+ export const SITE_VERSION = 1;
17
19
 
18
20
  const EMPTY = {
19
21
  present: false,
@@ -38,8 +40,11 @@ const EMPTY = {
38
40
  privilegedTransactions: [],
39
41
  privilegedJobs: [],
40
42
  problems: [],
43
+ warnings: [],
41
44
  };
42
45
 
46
+ const KNOWN_KEYS = new Set(['$schema', 'version', ...Object.keys(EMPTY).filter((k) => !['present', 'problems', 'warnings'].includes(k))]);
47
+
43
48
  // A configuration that is absent is not a configuration that says "nothing is production". The
44
49
  // difference is the whole point: without it the recon rules have not looked, and a scan must say
45
50
  // it has not looked rather than report a clean result.
@@ -65,6 +70,10 @@ export function loadSite(root, explicit = null, tree = null) {
65
70
  }
66
71
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...EMPTY, path, problems: [`${SITE_FILE} is not a JSON object`] };
67
72
 
73
+ const unreadable = versionProblem(raw, SITE_VERSION);
74
+ if (unreadable) return { ...EMPTY, path, problems: [`${SITE_FILE} ${unreadable}, so it was not read`] };
75
+ const warnings = unknownKeys(raw, KNOWN_KEYS).map((k) => ignoredKey(SITE_FILE, k));
76
+
68
77
  const problems = [];
69
78
  const list = (name) => {
70
79
  const v = raw[name];
@@ -123,6 +132,7 @@ export function loadSite(root, explicit = null, tree = null) {
123
132
  privilegedTransactions: list('privilegedTransactions').map((s) => s.toUpperCase()),
124
133
  privilegedJobs: list('privilegedJobs').map((s) => s.toUpperCase()),
125
134
  problems,
135
+ warnings,
126
136
  };
127
137
 
128
138
  if (site.present && !site.productionQualifiers.length && !site.systemNames.length) {
package/lib/sources.mjs CHANGED
@@ -17,6 +17,9 @@ export const BMS_EXT = ['.bms'];
17
17
  // Assembler source. A BMS map or an IMS DBD or PSB is also assembler macros, and is often named .asm;
18
18
  // lib/hlasm.mjs tells them apart by the macros they hold.
19
19
  export const HLASM_EXT = ['.asm', '.mac', '.mlc', '.hlasm', '.assemble'];
20
+ export const IMS_EXT = ['.dbd', '.psb'];
21
+ export const DB2_EXT = ['.sql', '.ddl', '.db2'];
22
+ export const PLI_EXT = ['.pli', '.pl1', '.plx'];
20
23
 
21
24
  const has = (list) => (p) => list.includes(extname(p).toLowerCase());
22
25
 
@@ -26,19 +29,22 @@ const has = (list) => (p) => list.includes(extname(p).toLowerCase());
26
29
  // of it and reporting a clean zero, which is the failure this project exists to refuse.
27
30
  //
28
31
  // So a file with no extension is classified by what is in it. Only the first few kilobytes are
29
- // read and only files without an extension are sniffed. The tree a path belongs to classifies it
30
- // and keeps the answer for as long as the tree lives: this runs over every file in the tree and
31
- // must not cost a parse, and a cache that outlives the scan grows by one entry per file for the
32
- // life of the process.
32
+ // read, and more only when they read like assembler cards that have not yet said so; only files
33
+ // without an extension are sniffed. The tree a path belongs to classifies it and keeps the answer
34
+ // for as long as the tree lives: this runs over every file in the tree and must not cost a parse,
35
+ // and a cache that outlives the scan grows by one entry per file for the life of the process.
33
36
  const SNIFF_BYTES = 4096;
37
+ const WIDE_BYTES = 65536;
34
38
 
35
39
  // Names that are certainly not source, so the commonest extensionless files cost nothing.
36
40
  const NOT_SOURCE = /^(LICEN[CS]E|README|MAKEFILE|DOCKERFILE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS|VERSION|COPYING|Jenkinsfile|Procfile|\..*)$/i;
37
41
  const notSource = (path) => NOT_SOURCE.test(path.split(/[\\/]/).pop());
38
42
 
43
+ const textOf = (buf) => (looksEbcdic(buf) ? decodeEbcdic(buf) : buf.toString('latin1'));
44
+
39
45
  function kindOfHead(head) {
40
46
  // An EBCDIC member is decoded before it is read, or every test below sees mojibake.
41
- const text = looksEbcdic(head) ? decodeEbcdic(head) : head.toString('latin1');
47
+ const text = textOf(head);
42
48
  // A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
43
49
  // NUL in source is exactly the character an editor or a patch is most likely to eat.
44
50
  if (head.includes(0)) return null;
@@ -49,25 +55,62 @@ function kindOfHead(head) {
49
55
  // A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
50
56
  // classified before classifies differently.
51
57
  if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
58
+ // An IMS DBD or PSB is assembler macros too: a DBD statement and a SEGM, or a PCB and a PSBGEN.
59
+ if (/^[^*\s]*[ \t]+(?:DBD|PCB)(?=\s|$)/m.test(text) && /^[^*\s]*[ \t]+(?:SEGM|DBDGEN|SENSEG|PSBGEN)(?=\s|$)/m.test(text)) return 'ims';
52
60
  // Assembler source defines a section or a macro, and declares storage or a base register, in the
53
61
  // operation field; either alone is a word a text file can hold. Tested after BMS, which is
54
62
  // assembler macros too.
55
63
  if (/^[^*\s]*[ \t]+(?:CSECT|RSECT|START|MACRO)(?=\s|$)/m.test(text)
56
64
  && /^[^*\s]*[ \t]+(?:USING|DC|DS|BALR|BASR|STM|STMG|MEND)(?=\s|$)/m.test(text)) return 'hlasm';
65
+ // Db2 DDL: a statement that creates an object or grants a privilege, at the start of a line and
66
+ // ended by a semicolon. Tested last, after the program and JCL that also carry SQL.
67
+ if (/^\s*(?:CREATE\s+(?:TABLE|TABLESPACE|DATABASE|STOGROUP|(?:UNIQUE\s+)?INDEX|VIEW|PROCEDURE|FUNCTION|ALIAS|SYNONYM)|GRANT\s)/im.test(text) && /;\s*$/m.test(text)) return 'db2';
57
68
  return null;
58
69
  }
59
70
 
71
+ // A head of 4 KB misses assembler whose prologue runs past it, a macro whose first DS or MEND
72
+ // comes later, and a program whose section a macro generates. When the head is mostly comment
73
+ // cards and statements in the card's fields, up to 64 KB is read and the member is assembler if
74
+ // it holds a section-level and a statement-level operation from the wider lists, or five distinct
75
+ // common machine instructions with two operands.
76
+ const CARD = /^(?:\*|\.\*|(?:[A-Z$#@&.][A-Z0-9$#@_&.]{0,62})?[ \t]+[A-Z$#@][A-Z0-9$#@]{0,7}(?:[ \t]|$))/i;
77
+ const inOperationField = (ops) => new RegExp(String.raw`^[^*\s]*[ \t]+(?:${ops})(?=\s|$)`, 'm');
78
+ const WIDE_SECTION = inOperationField('CSECT|RSECT|START|MACRO|DSECT|TITLE|ENTRY|AMODE|RMODE');
79
+ const WIDE_STATEMENT = inOperationField('USING|DC|DS|BALR|BASR|STM|STMG|MEND|AIF|AGO|SETA|SETB|SETC|GBLA|GBLB|GBLC|LCLA|LCLB|LCLC|ANOP|MNOTE|EQU|LTORG|EJECT|PRINT');
80
+ const INSTRUCTION = /^[^*\s]*[ \t]+(?:L|LA|LR|LH|ST|STH|STM|LM|MVC|MVI|CLC|CLI|CL|CR|CH|C|LTR|TM|IC|STC|SR|AR|A|S|N|O|X|NI|OI|XC|NC|OC|ZAP|AP|SP|CVD|CVB|PACK|UNPK|ED|EX|BAL|BALR|BAS|BASR|BCT|BXLE|BXH|SLL|SRL|SLA|SRA|SLDL|SRDL|ICM|STCM|CLM|TR|TRT|MVCL|CLCL)[ \t]+(?:R?\d{1,2}|[A-Z$#@][A-Z0-9$#@_]*\(?)[^ \t,]*,\S/gim;
81
+
82
+ function cardShaped(text) {
83
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
84
+ return lines.length >= 10 && lines.filter((l) => CARD.test(l)).length / lines.length >= 0.8;
85
+ }
86
+
87
+ function assemblerFurtherIn(text) {
88
+ if (WIDE_SECTION.test(text) && WIDE_STATEMENT.test(text)) return true;
89
+ return new Set(text.match(INSTRUCTION) || []).size >= 5;
90
+ }
91
+
92
+ // The head decides; a card-shaped head it leaves undecided is read on through `wider`.
93
+ function kindOfMember(head, wider) {
94
+ const kind = kindOfHead(head);
95
+ if (kind || head.includes(0) || !cardShaped(textOf(head))) return kind;
96
+ return assemblerFurtherIn(textOf(wider())) ? 'hlasm' : null;
97
+ }
98
+
60
99
  // What a member's bytes say it is, for a tree that holds them.
61
- export const kindOfBytes = (buf) => kindOfHead(buf.subarray(0, SNIFF_BYTES));
100
+ export const kindOfBytes = (buf) => kindOfMember(buf.subarray(0, SNIFF_BYTES), () => buf.subarray(0, WIDE_BYTES));
101
+
102
+ function readHead(path, bytes) {
103
+ const fd = openSync(path, 'r');
104
+ const buf = Buffer.alloc(bytes);
105
+ let n;
106
+ try { n = readSync(fd, buf, 0, bytes, 0); } finally { closeSync(fd); }
107
+ return buf.subarray(0, n);
108
+ }
62
109
 
63
110
  function sniffDisk(path) {
64
111
  if (notSource(path)) return null;
65
112
  try {
66
- const fd = openSync(path, 'r');
67
- const buf = Buffer.alloc(SNIFF_BYTES);
68
- let n;
69
- try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
70
- return kindOfHead(buf.subarray(0, n));
113
+ return kindOfMember(readHead(path, SNIFF_BYTES), () => readHead(path, WIDE_BYTES));
71
114
  } catch { return null; }
72
115
  }
73
116
 
@@ -125,11 +168,15 @@ export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
125
168
  export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
126
169
  export const isBms = byExtOrContent(BMS_EXT, 'bms');
127
170
  export const isAssembler = byExtOrContent(HLASM_EXT, 'hlasm');
128
- // Not BMS or assembler, by either route: the sets that read every source file have not chosen to
129
- // read maps or assembler.
171
+ export const isIms = byExtOrContent(IMS_EXT, 'ims');
172
+ export const isDb2 = byExtOrContent(DB2_EXT, 'db2');
173
+ // PL/I is known by its extension only: no content sniff reads an extensionless member as PL/I.
174
+ export const isPli = (p) => PLI_EXT.includes(extname(p).toLowerCase());
175
+ // Not BMS, assembler, an IMS definition or Db2 DDL, by either route: the sets that read every source
176
+ // file have not chosen to read those.
130
177
  export const isSource = (p) => (extname(p)
131
178
  ? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
132
- : ![null, 'bms', 'hlasm'].includes(sniffKind(p)));
179
+ : ![null, 'bms', 'hlasm', 'ims', 'db2'].includes(sniffKind(p)));
133
180
 
134
181
  // `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
135
182
  // files on it and no others.
@@ -199,14 +246,19 @@ export function looksEbcdic(buf) {
199
246
  }
200
247
 
201
248
  export function decodeEbcdic(buf) {
202
- let hasNewline = false;
203
- for (let i = 0; i < buf.length; i++) if (buf[i] === 0x15 || buf[i] === 0x25) { hasNewline = true; break; }
249
+ let newlines = 0;
250
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 0x15 || buf[i] === 0x25) newlines++;
204
251
  const chars = new Array(buf.length);
205
252
  for (let i = 0; i < buf.length; i++) chars[i] = E2A[buf[i]];
206
- const text = chars.join('');
207
- // A member copied off the mainframe in binary keeps its fixed 80-byte records and no line ends.
208
- if (!hasNewline && buf.length % 80 === 0) return text.match(/[\s\S]{80}/g).join('\n');
209
- return text;
253
+ // A member copied off the mainframe in binary keeps its fixed 80-byte records and no line ends. A
254
+ // constant can hold the newline bytes X'15' and X'25' as data, so a few of them, fewer than one
255
+ // record in two, do not make the member text with line ends.
256
+ if (buf.length % 80 === 0 && newlines < buf.length / 160) {
257
+ const records = [];
258
+ for (let i = 0; i < chars.length; i += 80) records.push(chars.slice(i, i + 80).join('').replace(/[\n\r]/g, ' '));
259
+ return records.join('\n');
260
+ }
261
+ return chars.join('');
210
262
  }
211
263
 
212
264
  // Every rule set reads source through here, so an EBCDIC member is decoded once, the same way,
@@ -214,7 +266,15 @@ export function decodeEbcdic(buf) {
214
266
  export const readSource = (path) => decodeSource(readFileSync(path));
215
267
 
216
268
  // The same decoding for bytes that did not come from a file: a blob read out of a git revision.
269
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
270
+
271
+ // A character beyond ASCII takes two to four bytes in UTF-8, and read a byte at a time it takes as
272
+ // many columns, which moves everything after it on a fixed-format line: column 72, the sequence
273
+ // area, the end of a card. A file that is valid UTF-8 and holds such characters is read as UTF-8.
217
274
  export function decodeSource(buf) {
218
275
  if (looksEbcdic(buf)) return { text: decodeEbcdic(buf), encoding: 'ebcdic' };
276
+ if (buf.some((b) => b >= 0x80)) {
277
+ try { return { text: UTF8.decode(buf), encoding: 'utf-8' }; } catch { /* not UTF-8: one byte, one character */ }
278
+ }
219
279
  return { text: buf.toString('latin1'), encoding: 'latin1' };
220
280
  }
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // A cursor over one statement's tokens, shared by the PL/I and Db2 statement parsers so they read
3
+ // tokens the same way; each language fails with its own syntax error.
4
+
5
+ const isOpen = (t) => t && t.t === 'op' && t.v === '(';
6
+ const isClose = (t) => t && t.t === 'op' && t.v === ')';
7
+
8
+ export function tokenCursor(toks, Syntax) {
9
+ let i = 0;
10
+ const c = {
11
+ get pos() { return i; },
12
+ set pos(v) { i = v; },
13
+ peek: (k = 0) => toks[i + k] || null,
14
+ next: () => toks[i++] || null,
15
+ done: () => i >= toks.length,
16
+ rest: () => toks.slice(i),
17
+ drain: () => { const r = toks.slice(i); i = toks.length; return r; },
18
+ slice: (from, to = i) => toks.slice(from, to),
19
+ isWord: (u, k = 0) => { const t = toks[i + k]; return !!t && t.t === 'word' && (u === undefined || (Array.isArray(u) ? u.includes(t.u) : t.u === u)); },
20
+ isOp: (v, k = 0) => { const t = toks[i + k]; return !!t && t.t === 'op' && (Array.isArray(v) ? v.includes(t.v) : t.v === v); },
21
+ word(u) { return c.isWord(u) ? toks[i++] : null; },
22
+ op(v) { return c.isOp(v) ? toks[i++] : null; },
23
+ expectWord(u) { const t = c.word(u); if (!t) c.fail(u === undefined ? 'a name' : Array.isArray(u) ? u.join(' or ') : u); return t; },
24
+ expectOp(v) { const t = c.op(v); if (!t) c.fail(`'${v}'`); return t; },
25
+ fail(what) { const t = toks[i]; throw new Syntax(`expected ${what}${t ? ` at '${t.v ?? t.t}'` : ' at end of statement'}`, t || toks[toks.length - 1]); },
26
+ // The tokens between a '(' at the cursor and its closing ')', the cursor left after the ')'.
27
+ group() {
28
+ if (!isOpen(toks[i])) c.fail("'('");
29
+ const start = i;
30
+ let depth = 0;
31
+ for (; i < toks.length; i++) {
32
+ if (isOpen(toks[i])) depth++;
33
+ else if (isClose(toks[i]) && --depth === 0) { i++; return toks.slice(start + 1, i - 1); }
34
+ }
35
+ i = start;
36
+ c.fail("')'");
37
+ },
38
+ // A parenthesised list as its comma-separated members, each a token array.
39
+ items() { return split(c.group(), ','); },
40
+ // Tokens up to, not including, the first at depth 0 that `stop` accepts.
41
+ until(stop) {
42
+ const start = i;
43
+ let depth = 0;
44
+ for (; i < toks.length; i++) {
45
+ const t = toks[i];
46
+ if (depth === 0 && stop(t)) break;
47
+ if (isOpen(t)) depth++;
48
+ else if (isClose(t)) depth--;
49
+ }
50
+ return toks.slice(start, i);
51
+ },
52
+ };
53
+ return c;
54
+ }
55
+
56
+ // Token arrays split at depth-0 tokens of op `sep`.
57
+ export function split(toks, sep) {
58
+ const parts = [[]];
59
+ let depth = 0;
60
+ for (const t of toks) {
61
+ if (isOpen(t)) depth++;
62
+ else if (isClose(t)) depth--;
63
+ if (depth === 0 && t.t === 'op' && t.v === sep) { parts.push([]); continue; }
64
+ parts[parts.length - 1].push(t);
65
+ }
66
+ return parts.length === 1 && !parts[0].length ? [] : parts;
67
+ }
package/lib/verify.mjs CHANGED
@@ -29,7 +29,7 @@ const SINKS = {
29
29
  'record-update': (x) => ({ value: 'the key of a test record set up for a different test user', reads: `${x.item} holding that key`, run: 'the other user\'s test record is changed or deleted: set the record up for this test alone' }),
30
30
  'dynamic-program-load': (x) => ({ value: ABSENT, reads: `${MARKER} as the program the CALL loads, from ${x.item}`, run: `the CALL fails to load ${MARKER} (an S806 abend in batch)` }),
31
31
  'cics-dynamic-transfer': (x) => ({ value: ABSENT, reads: `${MARKER} as the program or transaction the command names, from ${x.item}`, run: `the command fails with PGMIDERR, or TRANSIDERR for a START` }),
32
- 'dynamic-file-path': (x) => ({ value: ABSENT, reads: `${MARKER} as the file the program assigns, from ${x.item}`, run: 'the OPEN fails with file status 35' }),
32
+ 'dynamic-file-path': (x) => ({ value: ABSENT, reads: `${MARKER} as the file the program ${x.cics ? 'names' : 'assigns'}, from ${x.item}`, run: x.cics ? 'the command fails with FILENOTFOUND' : 'the OPEN fails with file status 35' }),
33
33
  'cics-sysid': (x) => ({ value: ABSENT, reads: `${MARKER} as the SYSID the command is shipped to, from ${x.item}`, run: 'the command fails with SYSIDERR' }),
34
34
  'queue-name': (x) => marker(x, 'as the queue the command acts on', 'the command would act on a queue the input chose'),
35
35
  'web-response': (x) => ({ value: `${MARKER}<>`, reads: `${MARKER}<> in ${x.item}`, run: `the response body carries ${MARKER}<> as sent, not ${MARKER}&lt;&gt;` }),
@@ -73,6 +73,7 @@ function enterAt(source, f, src, loc) {
73
73
  case 'jcl-parm': return `${src?.detail || 'the PARM'}, in a copy of the job`;
74
74
  case 'jcl-instream': return `${src?.detail || 'the in-stream data'}, in a copy of the job`;
75
75
  case 'file-record': return `a test record in a test copy of the file read at ${loc}`;
76
+ case 'cics-queue': return `a test item in the queue read at ${loc}, written by a test task`;
76
77
  case 'database': return `a test row in a test copy of the table read at ${loc}`;
77
78
  default: return null;
78
79
  }
@@ -95,7 +96,7 @@ export function verificationPlan(f) {
95
96
  const loc = src ? `${src.path}:${src.line}` : 'the source';
96
97
  const last = f.trace?.length ? f.trace[f.trace.length - 1] : null;
97
98
  const table = Number(/a table of (\d+)$/.exec(f.detail || '')?.[1]) || null;
98
- const x = { item: last?.item || 'the value', table, ssrange: !!f.ssrange };
99
+ const x = { item: last?.item || 'the value', table, ssrange: !!f.ssrange, cics: / reaches EXEC CICS /.test(f.detail || '') };
99
100
  const sink = `${f.path}:${f.line}`;
100
101
  const tool = toolFor(kinds.source, entries, f.program || 'the program');
101
102
  const start = entries.length
package/lib/version.mjs CHANGED
@@ -6,6 +6,12 @@ import { readFileSync } from 'node:fs';
6
6
  // 3: findings carry `evidence`, and setsIncomplete entries a `kind`.
7
7
  export const SCHEMA_VERSION = 3;
8
8
 
9
+ // The smaller documents each command writes, versioned apart from the report.
10
+ export const EXPLAIN_SCHEMA_VERSION = 1;
11
+ export const PARSE_SCHEMA_VERSION = 1;
12
+ export const BASELINE_RESULT_SCHEMA_VERSION = 1;
13
+ export const EVIDENCE_RESULT_SCHEMA_VERSION = 1;
14
+
9
15
  // Names how taint moves through groups, so a finding that appears or disappears between two reports
10
16
  // can be attributed to the model rather than to the code scanned. 'byte-range': a tainted field
11
17
  // taints those bytes of its group, and the bytes are followed through MOVE, REDEFINES and CALL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.6.0",
3
+ "version": "0.7.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>",
@@ -33,7 +33,8 @@
33
33
  "NOTICE",
34
34
  "LICENSING.md",
35
35
  "THIRD-PARTY-NOTICES.md",
36
- "README.md"
36
+ "README.md",
37
+ "STABILITY.md"
37
38
  ],
38
39
  "scripts": {
39
40
  "test": "node --test",