@portll/cobolwork 0.4.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 (78) hide show
  1. package/README.md +53 -5
  2. package/bin/cobolwork.mjs +34 -9
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/bms.mjs +8 -0
  5. package/lib/build.mjs +33 -8
  6. package/lib/capabilities.mjs +6 -3
  7. package/lib/consequence.mjs +17 -0
  8. package/lib/constants.mjs +60 -0
  9. package/lib/control-reuse.mjs +113 -0
  10. package/lib/control.mjs +86 -18
  11. package/lib/csd.mjs +6 -1
  12. package/lib/dataflow.mjs +343 -95
  13. package/lib/equivalence.mjs +44 -5
  14. package/lib/evidence/cli.mjs +13 -5
  15. package/lib/evidence/record.mjs +16 -6
  16. package/lib/evidence/seal.mjs +17 -0
  17. package/lib/evidence/store.mjs +30 -14
  18. package/lib/evidence/timestamp.mjs +83 -0
  19. package/lib/evidence/verify.mjs +120 -42
  20. package/lib/hlasm/asm/data.mjs +116 -0
  21. package/lib/hlasm/asm/listing.mjs +43 -0
  22. package/lib/hlasm/asm/output.mjs +45 -0
  23. package/lib/hlasm/asm/sections.mjs +159 -0
  24. package/lib/hlasm/asm/symbols.mjs +78 -0
  25. package/lib/hlasm/exec.mjs +27 -0
  26. package/lib/hlasm/expr.mjs +167 -0
  27. package/lib/hlasm/instr.mjs +48 -0
  28. package/lib/hlasm/locate.mjs +333 -0
  29. package/lib/hlasm/macro/authorization.mjs +126 -0
  30. package/lib/hlasm/macro/datasets.mjs +94 -0
  31. package/lib/hlasm/macro/io.mjs +139 -0
  32. package/lib/hlasm/macro/le.mjs +53 -0
  33. package/lib/hlasm/macro/linkage.mjs +143 -0
  34. package/lib/hlasm/macro/operator.mjs +66 -0
  35. package/lib/hlasm/macro/program.mjs +142 -0
  36. package/lib/hlasm/macro/recovery.mjs +80 -0
  37. package/lib/hlasm/macro/storage.mjs +117 -0
  38. package/lib/hlasm/macro/structured.mjs +131 -0
  39. package/lib/hlasm/model.mjs +72 -0
  40. package/lib/hlasm/operands.mjs +49 -0
  41. package/lib/hlasm/read.mjs +116 -0
  42. package/lib/hlasm.mjs +79 -0
  43. package/lib/inventory.mjs +10 -7
  44. package/lib/ironwork-ids.mjs +24 -0
  45. package/lib/ironwork.mjs +4 -1
  46. package/lib/kernel/registry.mjs +24 -17
  47. package/lib/kernel/shared-pass.mjs +138 -0
  48. package/lib/options.mjs +26 -4
  49. package/lib/policy.mjs +6 -0
  50. package/lib/precompile-cics.mjs +1 -1
  51. package/lib/revision.json +1 -1
  52. package/lib/sarif.mjs +3 -0
  53. package/lib/scan.mjs +21 -11
  54. package/lib/sets/abend.mjs +115 -13
  55. package/lib/sets/build.mjs +43 -37
  56. package/lib/sets/cics.mjs +5 -3
  57. package/lib/sets/compile.mjs +5 -3
  58. package/lib/sets/copybook.mjs +5 -3
  59. package/lib/sets/crypto.mjs +110 -0
  60. package/lib/sets/flow.mjs +32 -7
  61. package/lib/sets/hlasm.mjs +177 -0
  62. package/lib/sets/jcl.mjs +15 -1
  63. package/lib/sets/log.mjs +5 -3
  64. package/lib/sets/priv.mjs +5 -3
  65. package/lib/sets/secrets.mjs +110 -0
  66. package/lib/sets/semantics.mjs +5 -3
  67. package/lib/sets/web.mjs +104 -7
  68. package/lib/sources.mjs +12 -2
  69. package/package.json +1 -1
  70. package/rules/compliance-cobit2019.json +216 -0
  71. package/rules/compliance-dora.json +234 -0
  72. package/rules/compliance-ffiec.json +234 -0
  73. package/rules/compliance-nist80053.json +234 -0
  74. package/rules/gitleaks-mainframe.toml +61 -16
  75. package/rules/hlasm-instructions.json +2396 -0
  76. package/rules/hlasm-operations.json +451 -0
  77. package/rules/icsf-services.json +1178 -0
  78. package/schema/cobolwork.policy.schema.json +7 -0
package/lib/ironwork.mjs CHANGED
@@ -15,6 +15,9 @@ import { EIB_FIELDS, DIB_FIELDS, SQLCA_FIELDS } from './words.mjs';
15
15
  // 12 S, 16 U. Only 0 and 4 produce a program.
16
16
  const COMPILED = new Set([0, 4]);
17
17
  const REJECTED = new Set([8, 12, 16]);
18
+ // ironwork grades a refusal by name as severe (12), so a run that ends at 8 holds only IBM-graded
19
+ // errors in the program, whatever their wording says; the wording decides only at 12 and 16.
20
+ const GRADED_ERROR = 8;
18
21
  // Warnings and informational lines follow the errors, and say nothing about why a program failed.
19
22
  const NOT_AN_ERROR = /^(?:informational:|.*?: warning: )/;
20
23
  const PER_PROGRAM_MS = 60000;
@@ -73,7 +76,7 @@ export function checkWithIronwork(path, root, { allow = null, copylibs = [], env
73
76
  continue;
74
77
  }
75
78
  const found = String(r.stderr || '').split(/\r?\n/).filter((l) => l && !NOT_AN_ERROR.test(l)).map((l) => diagnostic(l, resolve(file), rel));
76
- const errors = found.filter((d) => !notModelled(d.message) && !UNRESOLVED.test(d.message));
79
+ const errors = found.filter((d) => !(r.status !== GRADED_ERROR && notModelled(d.message)) && !UNRESOLVED.test(d.message));
77
80
  if (errors.length) out.failed.push({ ...errors[0], errors: errors.length });
78
81
  else if (found.some((d) => UNRESOLVED.test(d.message))) out.unresolved.push(found.find((d) => UNRESOLVED.test(d.message)));
79
82
  else out.notModelled.push(found[0] || { path: rel, message: `ironwork exited ${r.status} and gave no error` });
@@ -12,43 +12,50 @@
12
12
  // literal key `undefined` in every report for several commits. lib/scan.mjs grew an ESETNAME throw
13
13
  // to make the next rename fail loudly instead. The throw stays - see `reportKey` below - but the
14
14
  // map it guarded is gone, because a name derived from one place cannot disagree with itself.
15
- import { scan as scanFlow, RULES as FLOW_RULES } from '../sets/flow.mjs';
16
- import { scanCics, CICS_RULES } from '../sets/cics.mjs';
15
+ import { scan as scanFlow, scanSteps as scanFlowSteps, RULES as FLOW_RULES } from '../sets/flow.mjs';
16
+ import { scanCics, scanCicsSteps, CICS_RULES } from '../sets/cics.mjs';
17
17
  import { scanHidden, HIDDEN_RULES } from '../sets/hidden.mjs';
18
- import { scanCopybooks, COPYBOOK_RULES } from '../sets/copybook.mjs';
18
+ import { scanCopybooks, scanCopybooksSteps, COPYBOOK_RULES } from '../sets/copybook.mjs';
19
19
  import { scanJcl, JCL_RULES } from '../sets/jcl.mjs';
20
20
  import { scanBuild, BUILD_RULES } from '../sets/build.mjs';
21
21
  import { scanRecon, RECON_RULES } from '../sets/recon.mjs';
22
22
  import { scanVendor, VENDOR_RULES } from '../sets/vendor.mjs';
23
23
  import { scanOpaque, OPAQUE_RULES } from '../sets/opaque.mjs';
24
- import { scanWeb, WEB_RULES } from '../sets/web.mjs';
25
- import { scanPriv, PRIV_RULES } from '../sets/priv.mjs';
26
- import { scanLog, LOG_RULES } from '../sets/log.mjs';
27
- import { scanCompile, COMPILE_RULES } from '../sets/compile.mjs';
28
- import { scanSemantics, SEMANTICS_RULES } from '../sets/semantics.mjs';
24
+ import { scanWeb, scanWebSteps, WEB_RULES } from '../sets/web.mjs';
25
+ import { scanPriv, scanPrivSteps, PRIV_RULES } from '../sets/priv.mjs';
26
+ import { scanLog, scanLogSteps, LOG_RULES } from '../sets/log.mjs';
27
+ import { scanCompile, scanCompileSteps, COMPILE_RULES } from '../sets/compile.mjs';
28
+ import { scanSemantics, scanSemanticsSteps, SEMANTICS_RULES } from '../sets/semantics.mjs';
29
29
  import { scanZowe, ZOWE_RULES } from '../sets/zowe.mjs';
30
30
  import { scanAbend, ABEND_RULES } from '../sets/abend.mjs';
31
+ import { scanHlasm, HLASM_RULES } from '../sets/hlasm.mjs';
32
+ import { scanCrypto, CRYPTO_RULES } from '../sets/crypto.mjs';
33
+ import { scanSecrets, SECRETS_RULES } from '../sets/secrets.mjs';
31
34
  import { toolName } from './ruleset.mjs';
32
35
 
33
36
  // The order is the order a scan runs them and the order --only lists them. It is not significant
34
- // to correctness; it is significant to a report being comparable with yesterday's.
37
+ // to correctness; it is significant to a report being comparable with yesterday's. A set with
38
+ // `steps` reads programs through the scan's one shared pass (lib/kernel/shared-pass.mjs).
35
39
  export const REGISTRY = [
36
- { name: 'flow', scan: scanFlow, rules: FLOW_RULES },
37
- { name: 'cics', scan: scanCics, rules: CICS_RULES },
40
+ { name: 'flow', scan: scanFlow, steps: scanFlowSteps, rules: FLOW_RULES },
41
+ { name: 'cics', scan: scanCics, steps: scanCicsSteps, rules: CICS_RULES },
38
42
  { name: 'hidden', scan: scanHidden, rules: HIDDEN_RULES },
39
- { name: 'copybook', scan: scanCopybooks, rules: COPYBOOK_RULES },
43
+ { name: 'copybook', scan: scanCopybooks, steps: scanCopybooksSteps, rules: COPYBOOK_RULES },
40
44
  { name: 'jcl', scan: scanJcl, rules: JCL_RULES },
41
45
  { name: 'build', scan: scanBuild, rules: BUILD_RULES },
42
46
  { name: 'recon', scan: scanRecon, rules: RECON_RULES },
43
47
  { name: 'vendor', scan: scanVendor, rules: VENDOR_RULES },
44
48
  { name: 'opaque', scan: scanOpaque, rules: OPAQUE_RULES },
45
- { name: 'web', scan: scanWeb, rules: WEB_RULES },
46
- { name: 'compile', scan: scanCompile, rules: COMPILE_RULES },
47
- { name: 'priv', scan: scanPriv, rules: PRIV_RULES },
48
- { name: 'log', scan: scanLog, rules: LOG_RULES },
49
- { name: 'semantics', scan: scanSemantics, rules: SEMANTICS_RULES },
49
+ { name: 'web', scan: scanWeb, steps: scanWebSteps, rules: WEB_RULES },
50
+ { name: 'compile', scan: scanCompile, steps: scanCompileSteps, rules: COMPILE_RULES },
51
+ { name: 'priv', scan: scanPriv, steps: scanPrivSteps, rules: PRIV_RULES },
52
+ { name: 'log', scan: scanLog, steps: scanLogSteps, rules: LOG_RULES },
53
+ { name: 'semantics', scan: scanSemantics, steps: scanSemanticsSteps, rules: SEMANTICS_RULES },
50
54
  { name: 'zowe', scan: scanZowe, rules: ZOWE_RULES },
51
55
  { name: 'abend', scan: scanAbend, rules: ABEND_RULES },
56
+ { name: 'hlasm', scan: scanHlasm, rules: HLASM_RULES },
57
+ { name: 'crypto', scan: scanCrypto, rules: CRYPTO_RULES },
58
+ { name: 'secrets', scan: scanSecrets, rules: SECRETS_RULES },
52
59
  ];
53
60
 
54
61
  // Re-exported from the kernel's ruleset module, which is where it has to live: this file imports
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // One pass over the tree for every rule set that reads programs, so that a file is read and parsed
3
+ // once per scan rather than once per set.
4
+ //
5
+ // A set's scan is a generator. Where it would run its guarded loop it yields `loopOver(...)` and is
6
+ // resumed with the loop's account, exactly what eachWithinMemory would have returned. `drive` runs
7
+ // one set on its own, its loop as it always ran. `driveTogether` runs several: it gathers the loop
8
+ // each asks for, walks the files of all of them once in the tree's order, gives each set its own
9
+ // files in its own order, and resumes each with its own account. While a file is being handed round,
10
+ // the tree answers text() and parse() for it from what the first set to ask was given.
11
+ //
12
+ // Each set's per-file work is unchanged, so its findings are the ones it reported alone. What is
13
+ // shared is the reading and the parse, which is why no set may change a parse result it is given.
14
+ import { eachWithinMemory, memoryStatus, stoppedBecause, watchMemoryBuffer, MEMORY_RESERVE } from './memory.mjs';
15
+
16
+ const LOOP = Symbol('loop');
17
+
18
+ // What a set yields in place of `eachWithinMemory(items, work, opts)`. `guarded: false` asks for a
19
+ // plain loop: every item, no memory check and no byte budget.
20
+ export const loopOver = (items, work, opts = {}) => ({ [LOOP]: true, items, work, opts });
21
+
22
+ function plainLoop(items, work, label) {
23
+ for (let i = 0; i < items.length; i++) work(items[i], i);
24
+ return { label, processed: items.length, bytes: 0, skipped: [], complete: true, stoppedBy: null, peakHeapBytes: 0, note: null };
25
+ }
26
+
27
+ const runAlone = (req) => (req.opts.guarded === false
28
+ ? plainLoop(req.items, req.work, req.opts.label || 'scan')
29
+ : eachWithinMemory(req.items, req.work, req.opts));
30
+
31
+ export function drive(steps) {
32
+ let step = steps.next();
33
+ while (!step.done) step = steps.next(runAlone(step.value));
34
+ return step.value;
35
+ }
36
+
37
+ // A tree that, while `focus` names a file, answers text() and parse() for that file once and hands
38
+ // every later caller the same answer, its error included.
39
+ export function sharingTree(base) {
40
+ let focus = null;
41
+ let text = null;
42
+ let parsed = null;
43
+ const tree = {
44
+ ...base,
45
+ focusOn(f) { focus = f; text = null; parsed = null; },
46
+ text(p) {
47
+ if (p !== focus) return base.text(p);
48
+ if (!text) { try { text = { value: base.text(p) }; } catch (e) { text = { error: e }; } }
49
+ if (text.error) throw text.error;
50
+ return text.value;
51
+ },
52
+ parse(p, src) {
53
+ if (p !== focus) return base.parse(p, src);
54
+ const own = src ?? tree.text(p).text;
55
+ if (!parsed || parsed.src !== own) {
56
+ try { parsed = { src: own, value: base.parse(p, own) }; } catch (e) { parsed = { src: own, error: e }; }
57
+ }
58
+ if (parsed.error) throw parsed.error;
59
+ return parsed.value;
60
+ },
61
+ };
62
+ return tree;
63
+ }
64
+
65
+ // Runs every generator to completion and returns their results in the order given. A generator
66
+ // that throws, before its loop or inside it, yields { error } in its place, as a set that throws
67
+ // alone does; the others carry on.
68
+ export function driveTogether(stepsList, tree) {
69
+ const live = stepsList.map((steps) => ({ steps, step: null, result: undefined, error: null }));
70
+ const advance = (g, input, thrown) => {
71
+ try { g.step = thrown ? g.steps.throw(thrown) : g.steps.next(input); } catch (e) { g.error = e; g.step = { done: true }; return; }
72
+ if (g.step.done) g.result = g.step.value;
73
+ };
74
+ for (const g of live) advance(g);
75
+ for (;;) {
76
+ const waiting = live.filter((g) => !g.step.done);
77
+ if (!waiting.length) break;
78
+ const runs = mergedLoop(waiting.map((g) => g.step.value), tree);
79
+ waiting.forEach((g, k) => advance(g, runs[k].run, runs[k].error));
80
+ }
81
+ return live.map((g) => (g.error ? { error: g.error } : g.result));
82
+ }
83
+
84
+ // The files of every request, once each, in the order of the tree's list. A request whose items are
85
+ // not in that order runs alone first, so no set sees its files in an order its own loop would not use.
86
+ function mergedLoop(requests, tree) {
87
+ const rank = new Map(tree.list().map((p, i) => [p, i]));
88
+ const inOrder = (items) => items.every((p, i) => rank.has(p) && (i === 0 || rank.get(items[i - 1]) < rank.get(p)));
89
+ const states = requests.map((req) => ({
90
+ req, guarded: req.opts.guarded !== false, maxBytes: req.opts.maxBytes ?? Infinity, label: req.opts.label || 'scan',
91
+ next: 0, processed: 0, bytes: 0, skipped: [], stoppedBy: null, error: null, alone: null,
92
+ }));
93
+ for (const s of states) if (!inOrder(s.req.items)) {
94
+ try { s.alone = runAlone(s.req); } catch (e) { s.error = e; s.alone = {}; }
95
+ }
96
+ const shared = states.filter((s) => !s.alone);
97
+ const w = shared.map((s) => s.req.opts.watcher).find(Boolean) || watchMemoryBuffer();
98
+ const every = Math.min(...shared.map((s) => s.req.opts.every ?? 8), 8);
99
+ const files = [...new Set(shared.flatMap((s) => s.req.items))].sort((a, b) => rank.get(a) - rank.get(b));
100
+ let stoppedBy = null;
101
+ for (let i = 0; i < files.length; i++) {
102
+ const f = files[i];
103
+ const wanting = shared.filter((s) => s.req.items[s.next] === f);
104
+ if (!stoppedBy && wanting.some((s) => s.guarded && !s.error && s.bytes < s.maxBytes)
105
+ && (i % every === 0 || memoryStatus().fractionUsed > 1 - MEMORY_RESERVE)) {
106
+ const state = w.check();
107
+ if ((state === 'exhausted' || state === 'tight') && (!w.collect() || w.stillTight())) {
108
+ stoppedBy = state === 'exhausted' ? 'memory exhausted' : 'memory reserve';
109
+ }
110
+ }
111
+ tree.focusOn?.(f);
112
+ for (const s of wanting) {
113
+ const index = s.next++;
114
+ if (s.error) continue;
115
+ if (s.guarded) {
116
+ if (s.stoppedBy) { s.skipped.push(f); continue; }
117
+ if (s.bytes >= s.maxBytes) { s.stoppedBy = 'byte budget'; s.skipped.push(f); continue; }
118
+ if (stoppedBy) { s.stoppedBy = stoppedBy; s.skipped.push(f); continue; }
119
+ }
120
+ let took;
121
+ try { took = s.req.work(f, index); } catch (e) { s.error = e; continue; }
122
+ if (typeof took === 'number') s.bytes += took;
123
+ s.processed++;
124
+ }
125
+ tree.focusOn?.(null);
126
+ }
127
+ return states.map((s) => {
128
+ if (s.alone) return { run: s.alone, error: s.error };
129
+ const { items } = s.req;
130
+ const run = s.guarded
131
+ ? { label: s.label, processed: s.processed, bytes: s.bytes, skipped: s.skipped, complete: s.skipped.length === 0, stoppedBy: s.stoppedBy, peakHeapBytes: w.peak,
132
+ note: s.skipped.length === 0 ? null
133
+ : `${s.label}: ${s.skipped.length} of ${items.length} files were not read because ${stoppedBecause(s.stoppedBy)}. `
134
+ + 'The findings below are over what was read, and are not a result for this repository as a whole.' }
135
+ : { label: s.label, processed: s.processed, bytes: 0, skipped: [], complete: true, stoppedBy: null, peakHeapBytes: 0, note: null };
136
+ return { run, error: s.error };
137
+ });
138
+ }
package/lib/options.mjs CHANGED
@@ -485,17 +485,27 @@ export function sourceOperands(args, compiler = 'gnucobol') {
485
485
  return out;
486
486
  }
487
487
 
488
+ // The first GnuCOBOL release with the -fec option, and with each condition added after it.
489
+ const FEC_SINCE = '3.1';
490
+ const CONDITION_SINCE = { 'EC-PROGRAM-ARG-MISMATCH': '3.2' };
491
+ export const olderThan = (version, since) => {
492
+ const [a, b] = [version, since].map((v) => String(v).split('.').map(Number));
493
+ for (let i = 0; i < Math.max(a.length, b.length); i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
494
+ return false;
495
+ };
496
+
488
497
  // A cobc argument vector. Returns { checks, forbidden, add }: `add` holds the -fec options that
489
498
  // supply a required check nothing turned on, and `forbidden` the options the policy's forbid list
490
499
  // names. A check turned off is a failed check, not overridden: appending past it would hide a build
491
- // configured against the policy.
492
- export function cobcChecks(args, { required, forbid = [] }) {
500
+ // configured against the policy. With `pinned` ({ version, where }), a check that GnuCOBOL version
501
+ // cannot generate fails, however the command asks for it.
502
+ export function cobcChecks(args, { required, forbid = [], pinned = null }) {
493
503
  const state = new Map();
494
504
  const turnedOff = new Map();
495
505
  const set = (name, on, arg) => {
496
506
  for (const check of required) {
497
507
  if (!covers(name, EXCEPTION_OF_CHECK[check])) continue;
498
- state.set(check, on);
508
+ state.set(check, on ? arg : false);
499
509
  if (on) turnedOff.delete(check); else turnedOff.set(check, arg);
500
510
  }
501
511
  };
@@ -509,11 +519,23 @@ export function cobcChecks(args, { required, forbid = [] }) {
509
519
  if (value === undefined) continue;
510
520
  set(exceptionName(value), !m[1], shown);
511
521
  }
522
+ const tooOld = (check, by) => {
523
+ if (!pinned) return null;
524
+ const condition = EXCEPTION_OF_CHECK[check];
525
+ const since = CONDITION_SINCE[condition];
526
+ const pin = `${pinned.where} pins ${pinned.version}`;
527
+ if (since && olderThan(pinned.version, since)) return `${check} needs ${condition}, which is in GnuCOBOL ${since} and later; ${pin}`;
528
+ if (!/^-(debug|d)$/.test(by) && olderThan(pinned.version, FEC_SINCE)) return `${check} needs -fec, which is in GnuCOBOL ${FEC_SINCE} and later; ${pin}`;
529
+ return null;
530
+ };
512
531
  const add = [];
513
532
  const checks = required.map((check) => {
514
- if (state.get(check) === true) return { check, ok: true, why: 'turned on by the command line' };
515
533
  if (turnedOff.has(check)) return { check, ok: false, why: `${turnedOff.get(check)} turns off the ${check} check` };
516
534
  const flag = `-fec=${EXCEPTION_OF_CHECK[check]}`;
535
+ const by = state.get(check) || flag;
536
+ const old = tooOld(check, by);
537
+ if (old) return { check, ok: false, why: old };
538
+ if (state.get(check)) return { check, ok: true, why: 'turned on by the command line' };
517
539
  if (!add.includes(flag)) add.push(flag);
518
540
  return { check, ok: true, added: true, why: `added as ${flag}` };
519
541
  });
package/lib/policy.mjs CHANGED
@@ -26,9 +26,13 @@ export const DEFAULT_POLICY = Object.freeze({
26
26
  checks: ['subscript', 'reference-modification'],
27
27
  forbid: { enterprise: [], gnucobol: [], gcobol: [] },
28
28
  requireEquivalence: 'never',
29
+ interface: 'warn',
29
30
  });
30
31
 
31
32
  export const EQUIVALENCE_MODES = ['never', 'machineAuthored', 'always'];
33
+ // How an abend from a subprogram fuzzed at its interface, with no caller run shown to pass the input,
34
+ // is gated: `warn` never blocks on it, `tier` gates it as any other finding.
35
+ export const INTERFACE_MODES = ['warn', 'tier'];
32
36
 
33
37
  const KEYS = Object.keys(DEFAULT_POLICY);
34
38
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -64,6 +68,7 @@ export function validatePolicy(raw) {
64
68
  else for (const c of raw.checks) if (!CHECKS.includes(c)) errs.push(`checks: ${printable(c, 40)} is not one of ${CHECKS.join(', ')}`);
65
69
  }
66
70
  if ('requireEquivalence' in raw && !EQUIVALENCE_MODES.includes(raw.requireEquivalence)) errs.push(`requireEquivalence takes ${EQUIVALENCE_MODES.join(', ')}`);
71
+ if ('interface' in raw && !INTERFACE_MODES.includes(raw.interface)) errs.push(`interface takes ${INTERFACE_MODES.join(', ')}`);
67
72
  if ('forbid' in raw) {
68
73
  if (!isObject(raw.forbid)) errs.push(`forbid must be an object keyed by ${COMPILERS.join(', ')}`);
69
74
  else for (const [c, list] of Object.entries(raw.forbid)) {
@@ -142,6 +147,7 @@ export function combinePolicies({ floor = null, repository = null } = {}) {
142
147
  checks: checks.value,
143
148
  forbid: Object.fromEntries(COMPILERS.map((c) => [c, union((f.forbid || {})[c] || [], (r.forbid || {})[c] || [])])),
144
149
  requireEquivalence: pick('requireEquivalence', (a, b) => (EQUIVALENCE_MODES.indexOf(a) >= EQUIVALENCE_MODES.indexOf(b) ? a : b)),
150
+ interface: pick('interface', (a, b) => (a === 'tier' || b === 'tier' ? 'tier' : 'warn')),
145
151
  rules: {},
146
152
  };
147
153
  setBy.checks = checks.by;
@@ -12,7 +12,7 @@ export const builtinValue = (fn, name) => (fn === 'RESP' ? DFHRESP : DFHVALUE)[n
12
12
  const squeeze = (text) => text.replace(/('[^']*'|"[^"]*")|\s+/g, (_, lit) => lit || ' ').trim();
13
13
 
14
14
  // The options of an EXEC CICS command in order: each word, with its argument's text when it has one.
15
- function cicsOptions(text) {
15
+ export function cicsOptions(text) {
16
16
  const out = [];
17
17
  let i = 0;
18
18
  while (i < text.length) {
package/lib/revision.json CHANGED
@@ -1 +1 @@
1
- {"commit":"371041fbffe625df6b0a872249cf071268fcb6a6"}
1
+ {"commit":"67c696c3d6f74d20137372b5f0a756e0b27753dd","tag":"v0.6.0"}
package/lib/sarif.mjs CHANGED
@@ -213,6 +213,9 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
213
213
  ...(f.reach ? { reach: f.reach } : {}),
214
214
  ...(f.effect ? { effect: f.effect } : {}),
215
215
  ...(f.exploitability ? { exploitability: f.exploitability } : {}),
216
+ // How an abend's input reached the program: through its own entry, or as arguments to
217
+ // a subprogram no caller run is shown to pass.
218
+ ...(f.abend?.inputFrom ? { inputFrom: f.abend.inputFrom } : {}),
216
219
  } } : {}),
217
220
  locations: [{ physicalLocation: { artifactLocation: { uri: uriOf(f.path), uriBaseId: '%SRCROOT%' }, region: { startLine: f.line || 1 } } }],
218
221
  ...(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/scan.mjs CHANGED
@@ -3,7 +3,8 @@ import { join, resolve } from 'node:path';
3
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
- import { inventory } from './inventory.mjs';
6
+ import { inventorySteps } from './inventory.mjs';
7
+ import { driveTogether, sharingTree } from './kernel/shared-pass.mjs';
7
8
  import { stoppedBecause } from './kernel/memory.mjs';
8
9
  import { complianceMap, FRAMEWORKS_LOADED, FRAMEWORKS_UNLOADED } from './compliance.mjs';
9
10
  import { stampFingerprints, FINGERPRINT_VERSION } from './kernel/identity.mjs';
@@ -178,13 +179,12 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
178
179
  }
179
180
 
180
181
  // A set that throws is reported as not having run, and the other sets' findings stand.
182
+ const notRun = (set, e) => report(set.name, { rules: set.rules, findings: [], stats: {
183
+ coverageIncomplete: true,
184
+ notRead: `the rule set stopped on an error and reported nothing: ${printable(e?.message ?? e)}`,
185
+ } });
181
186
  function runSet(set, root, o) {
182
- try { return set.scan(root, o); } catch (e) {
183
- return report(set.name, { rules: set.rules, findings: [], stats: {
184
- coverageIncomplete: true,
185
- notRead: `the rule set stopped on an error and reported nothing: ${printable(e?.message ?? e)}`,
186
- } });
187
- }
187
+ try { return set.scan(root, o); } catch (e) { return notRun(set, e); }
188
188
  }
189
189
 
190
190
  export function scanAll(root, opts = {}) {
@@ -193,12 +193,22 @@ export function scanAll(root, opts = {}) {
193
193
  // One walk of the tree for the whole scan. Every rule set used to call buildFileIndex(root) for
194
194
  // itself, so a scan read every directory entry and resolved every symlink nine times over, and
195
195
  // added a tenth walk each time a rule set was added.
196
- const tree = opts.tree || directoryTree(root, opts);
196
+ const tree = sharingTree(opts.tree || directoryTree(root, opts));
197
197
  const o = { ...opts, tree };
198
- const inv = inventory(root, o);
199
198
  // One loop over the registry, in its order. A set that is not registered cannot run, and
200
- // cannot report a silent zero on its own behalf either.
201
- const parts = REGISTRY.filter((set) => only.includes(set.name)).map((set) => runSet(set, root, o));
199
+ // cannot report a silent zero on its own behalf either. The inventory and the sets that read
200
+ // programs share one pass, so each program is read and parsed once.
201
+ const chosen = REGISTRY.filter((set) => only.includes(set.name));
202
+ const stepped = chosen.filter((set) => set.steps);
203
+ const [invResult, ...stepResults] = driveTogether([inventorySteps(root, o), ...stepped.map((set) => set.steps(root, o))], tree);
204
+ if (invResult && invResult.error) throw invResult.error;
205
+ const inv = invResult;
206
+ const byName = new Map(stepped.map((set, k) => [set.name, stepResults[k]]));
207
+ const parts = chosen.map((set) => {
208
+ if (!byName.has(set.name)) return runSet(set, root, o);
209
+ const r = byName.get(set.name);
210
+ return r && r.error ? notRun(set, r.error) : r;
211
+ });
202
212
 
203
213
  const flowPart = parts.find(p => p.tool === 'cobolwork-flow');
204
214
  const overBudget = flowPart ? flowPart.summary.filesOverBudget || 0 : 0;
@@ -6,9 +6,11 @@
6
6
  // where the evidence verifies and the run's own journal records the abend the manifest claims, so
7
7
  // the finding rests on a record of the run rather than on the manifest's word.
8
8
  import { readFileSync, existsSync } from 'node:fs';
9
+ import { createHash } from 'node:crypto';
9
10
  import { delimiter, join, posix, basename } from 'node:path';
10
11
  import { report } from '../kernel/ruleset.mjs';
11
12
  import { verifyEvidence } from '../evidence/verify.mjs';
13
+ import { NOT_RUN_ABENDS } from '../ironwork-ids.mjs';
12
14
 
13
15
  export const ABEND_RULES = {
14
16
  'input-causes-abend-s0c7': {
@@ -29,6 +31,18 @@ export const ABEND_RULES = {
29
31
  impact: 'SSRANGE caught the overrun and ended the run; compiled without SSRANGE, as production builds often are, the same input reads or writes storage beside the table or field',
30
32
  remedy: 'Check the index against both ends of the table or field before the statement the journal names, and keep SSRANGE on for the build',
31
33
  },
34
+ 'input-causes-hang': {
35
+ sev: 'med', evidence: 'execution', cwe: 'CWE-834',
36
+ text: 'An input the program accepts keeps a loop running past the statement limit that stands in for its time limit (S322)',
37
+ impact: 'Whoever supplies that input decides how long the loop runs; on z/OS a long enough run holds the step and its processor until TIME= ends it, and the jobs behind it wait. The run shows the loop passed the limit, not that it would never end',
38
+ remedy: 'Find the loop at the line the journal names and bound its iterations: check the count or the exit condition the input supplies before the loop starts, or stop it at a counter and reject the input that would pass it',
39
+ },
40
+ 'input-selects-program': {
41
+ sev: 'high', evidence: 'execution', cwe: 'CWE-470',
42
+ text: 'An input the program accepts chooses the program a CALL loads',
43
+ impact: "Whoever supplies that input picks which load module runs with this program's authority and data; the fuzz run named one the library does not hold, so the run ended in S806, but a name the library does hold runs",
44
+ remedy: 'Choose the called program from a fixed set of names checked against the input, or call it by a literal name, at the CALL the journal names',
45
+ },
32
46
  'input-causes-abend': {
33
47
  sev: 'med', evidence: 'execution', cwe: 'CWE-248',
34
48
  text: 'An input the program accepts ends its run with an abend',
@@ -37,6 +51,14 @@ export const ABEND_RULES = {
37
51
  },
38
52
  };
39
53
 
54
+ // What the manifest's `optimized` says of an abend: whether the same input ends the same way with
55
+ // the program compiled at OPTIMIZE(2), where IBM may compare invalid data by its bytes rather than
56
+ // end in a data exception at OPTIMIZE(0), its default.
57
+ const OPTIMIZED = {
58
+ true: '; compiled with OPTIMIZE(2) it ends the same way',
59
+ false: '; it holds at OPTIMIZE(0), IBM\'s default: compiled with OPTIMIZE(2) the same input does not end there',
60
+ };
61
+
40
62
  // IBM's messages for an SSRANGE failure: a subscript or index, an OCCURS DEPENDING ON object, and a
41
63
  // reference modification's start, length, and start plus length.
42
64
  const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
@@ -46,30 +68,88 @@ export function abendRunPaths(opts = {}) {
46
68
  return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
47
69
  }
48
70
 
71
+ // How ironwork ends the message of a data or protection exception in a CICS task, whose abend
72
+ // code CICS reports as ASRA.
73
+ const CICS_PROGRAM_CHECK = /\((S0C7|S0C4), which CICS reports as ASRA\)$/;
74
+
49
75
  export function abendRule(abend) {
50
- if (abend.code === 'S0C7') return 'input-causes-abend-s0c7';
51
- if (abend.code === 'S0C4') return 'input-causes-abend-s0c4';
76
+ const code = (abend.code === 'ASRA' && CICS_PROGRAM_CHECK.exec(abend.message || '')?.[1]) || abend.code;
77
+ if (code === 'S0C7') return 'input-causes-abend-s0c7';
78
+ if (code === 'S0C4') return 'input-causes-abend-s0c4';
79
+ if (code === 'S322') return 'input-causes-hang';
80
+ if (code === 'S806') return 'input-selects-program';
52
81
  if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
53
82
  return 'input-causes-abend';
54
83
  }
55
84
 
56
- // The abend a kept run's own journal records, or null.
85
+ // The abend a kept run's own journal records, with the sources it read and the programs it CALLed,
86
+ // or null.
57
87
  function journalAbend(evidenceDir, runId) {
58
88
  if (typeof runId !== 'string' || !/^[0-9TZ]+-[0-9a-f]{16}$/.test(runId)) return null;
59
89
  const file = join(evidenceDir, 'runs', `${runId}.jsonl`);
60
90
  if (!existsSync(file)) return null;
91
+ let abend = null;
92
+ const inputs = [];
93
+ const calls = [];
94
+ const sinks = [];
61
95
  for (const line of readFileSync(file, 'utf8').split('\n')) {
62
96
  if (!line) continue;
63
97
  const rec = JSON.parse(line);
64
- if (rec.kind === 'abend') return rec;
98
+ if (rec.kind === 'abend') abend ??= rec;
99
+ else if (rec.kind === 'input') inputs.push(rec);
100
+ else if (rec.kind === 'call') calls.push(rec);
101
+ else if (rec.kind === 'sink') sinks.push(rec);
65
102
  }
66
- return null;
103
+ return abend && { ...abend, inputs, calls, sinks };
67
104
  }
68
105
 
106
+ const MANIFEST_FORMAT = 'ironwork-fuzz/v1';
107
+ // A subprogram fuzzed at its interface: its inputs are arguments, and a caller passing them is
108
+ // not shown to exist (ironwork docs/evidence.md §5.2).
109
+ const INTERFACE_FORMAT = 'ironwork-fuzz-interface/v1';
110
+ const FORMATS = [MANIFEST_FORMAT, INTERFACE_FORMAT];
111
+
112
+ // What a finding from an interface run says of the caller its input needs.
113
+ function interfaceClause(callers) {
114
+ const where = callers.length
115
+ ? `with arguments shaped by the CALL${callers.length === 1 ? '' : 's'} at ${callers.map((c) => `${c.file}:${c.line}`).join(', ')}`
116
+ : 'on generated arguments, with no CALL to it in the scanned tree';
117
+ return `; it ran as a subprogram, ${where}, on its first call in its initial state: a caller passing these bytes ends it, and no caller run shows one does`;
118
+ }
119
+
120
+ // ironwork's own abend codes for a construct it does not run: no IBM system would raise them.
121
+ const NOT_MODELLED = new Set(NOT_RUN_ABENDS);
122
+
69
123
  const isString = (x) => typeof x === 'string' && x.length > 0;
124
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
70
125
 
71
- // One fuzz run: its findings, and what kept any of it from counting.
72
- export function loadAbendRun(dir) {
126
+ // A manifest root as a directory of the scanned tree, or null when it is not one.
127
+ function treeDir(dir) {
128
+ if (typeof dir !== 'string') return null;
129
+ const norm = posix.normalize(dir.replace(/\\/g, '/'));
130
+ return norm === '..' || norm.startsWith('../') || posix.isAbsolute(norm) ? null : norm;
131
+ }
132
+
133
+ // The directory in the scanned tree of the root the run read `file` from, located by the manifest's
134
+ // roots: the root the journal's input record for that file names, or for a CALLed program's source,
135
+ // which its call record names by path and digest alone, the root under which the tree holds that
136
+ // file with that digest. Undefined where neither record names the file, null where the root cannot
137
+ // be told or lies outside the tree.
138
+ function rootDirectory(roots, journal, file, tree) {
139
+ if (!Array.isArray(roots)) return undefined;
140
+ const held = [...new Set(journal.inputs.filter((i) => i.path === file && Number.isInteger(i.root)).map((i) => i.root))];
141
+ if (held.length) return held.length === 1 ? treeDir(roots[held[0]]) : null;
142
+ const digests = new Set(journal.calls.filter((c) => c.from === file).map((c) => c.sha256));
143
+ if (!digests.size || !tree) return undefined;
144
+ const holding = [...new Set(roots.map(treeDir).filter((d) => d !== null && existsSync(join(tree, d, file))))];
145
+ const same = holding.filter((d) => digests.has(sha256(readFileSync(join(tree, d, file)))));
146
+ const found = same.length ? same : holding;
147
+ return found.length === 1 ? found[0] : null;
148
+ }
149
+
150
+ // One fuzz run: its findings, and what kept any of it from counting. `tree` is the scanned tree,
151
+ // where a CALLed program's source is looked for.
152
+ export function loadAbendRun(dir, tree) {
73
153
  const out = { dir: basename(dir), program: null, findings: [], problems: [], counts: null, notModelled: 0 };
74
154
  let doc;
75
155
  try { doc = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); } catch (e) {
@@ -80,6 +160,15 @@ export function loadAbendRun(dir) {
80
160
  out.problems.push('manifest.json is not an ironwork-fuzz manifest with a program, inputs and runs');
81
161
  return out;
82
162
  }
163
+ // A manifest from before ironwork wrote `format` has the first format's shape.
164
+ if (doc.format !== undefined && !FORMATS.includes(doc.format)) {
165
+ out.problems.push(`manifest.json is in format ${JSON.stringify(doc.format)}, and this cobolwork reads ${FORMATS.join(' and ')}`);
166
+ return out;
167
+ }
168
+ const inputFrom = doc.format === INTERFACE_FORMAT ? 'interface' : 'entry';
169
+ const callers = inputFrom === 'interface' && Array.isArray(doc.callers)
170
+ ? doc.callers.filter((c) => c && isString(c.file) && Number.isInteger(c.line)).map((c) => ({ file: posix.normalize(c.file.replace(/\\/g, '/')), line: c.line }))
171
+ : [];
83
172
  const programFile = posix.normalize(doc.program.file.replace(/\\/g, '/'));
84
173
  if (programFile.startsWith('../') || posix.isAbsolute(programFile)) {
85
174
  out.problems.push(`the program ${doc.program.file} is outside the scanned tree`);
@@ -98,7 +187,7 @@ export function loadAbendRun(dir) {
98
187
  const seen = new Map();
99
188
  for (const run of doc.runs) {
100
189
  if (!run || run.outcome !== 'abend' || !run.abend || !isString(run.abend.code)) continue;
101
- if (run.abend.code === 'IRONWORK') { out.notModelled++; continue; }
190
+ if (NOT_MODELLED.has(run.abend.code)) { out.notModelled++; continue; }
102
191
  const recorded = journalAbend(evidenceDir, run.journal);
103
192
  if (!recorded || recorded.code !== run.abend.code) {
104
193
  out.problems.push(`run ${run.journal ?? '(unnamed)'}: its journal does not record the abend ${run.abend.code} the manifest gives`);
@@ -111,19 +200,31 @@ export function loadAbendRun(dir) {
111
200
  out.problems.push(`run ${run.journal}: its journal records the abend at ${place.file}:${place.line}, not where the manifest puts it`);
112
201
  continue;
113
202
  }
114
- const where = isString(place.file) ? posix.normalize(posix.join(posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
203
+ const rootDir = isString(place.file) ? rootDirectory(doc.roots, recorded, place.file, tree) : undefined;
204
+ if (rootDir === null) {
205
+ out.problems.push(`run ${run.journal}: the abend is in ${place.file}, which its journal and manifest do not place in the scanned tree`);
206
+ continue;
207
+ }
208
+ const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
115
209
  const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
116
210
  const rule = abendRule(run.abend);
211
+ // An S806 is input choosing the program only where the run's own journal records the marker
212
+ // fuzz put in the input reaching that CALL's program load.
213
+ if (rule === 'input-selects-program' && !recorded.sinks.some((k) => k.sink === 'dynamic-program-load' && k.reached === true && k.file === place.file && k.line === place.line)) {
214
+ out.problems.push(`run ${run.journal}: its journal does not record the input reaching the CALL at ${place.file}:${place.line}, so its S806 is not reported`);
215
+ continue;
216
+ }
117
217
  const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
118
- .map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}) }));
218
+ .map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}), ...(Number.isInteger(i.position) ? { position: i.position } : {}), ...(i.omitted === true ? { omitted: true } : {}) }));
119
219
  const key = `${rule}|${where}|${line}`;
120
220
  const size = used.reduce((n, i) => n + String(i.bytes || '').length, 0);
121
221
  const held = seen.get(key);
122
222
  if (held && held.size <= size) continue;
223
+ const optimized = typeof run.abend.optimized === 'boolean' ? run.abend.optimized : undefined;
123
224
  const finding = {
124
225
  rule, path: where, line, program: out.program.id,
125
- detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept`,
126
- abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}) },
226
+ detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept${OPTIMIZED[optimized] ?? ''}${inputFrom === 'interface' ? interfaceClause(callers) : ''}`,
227
+ abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}), ...(optimized === undefined ? {} : { optimized }), inputFrom, ...(inputFrom === 'interface' ? { callers } : {}) },
127
228
  input: used, run: { dir: out.dir, journal: run.journal },
128
229
  };
129
230
  seen.set(key, { size, finding });
@@ -134,13 +235,14 @@ export function loadAbendRun(dir) {
134
235
 
135
236
  export function scanAbend(root, opts = {}) {
136
237
  const dirs = abendRunPaths(opts);
137
- const runs = dirs.map((d) => loadAbendRun(d));
238
+ const runs = dirs.map((d) => loadAbendRun(d, root));
138
239
  const findings = runs.flatMap((r) => r.findings.filter((f) => existsSync(join(root, f.path))));
139
240
  const stats = {
140
241
  filesScanned: 0, filesUnreadable: 0,
141
242
  abendRuns: runs.map((r) => ({ dir: r.dir, program: r.program ? r.program.file : null, findings: r.findings.length, ...(r.notModelled ? { notModelled: r.notModelled } : {}), ...(r.counts ? { counts: r.counts } : {}) })),
142
243
  ...(runs.some((r) => r.problems.length) ? { abendRunProblems: runs.flatMap((r) => r.problems.map((p) => `${r.dir}: ${p}`)) } : {}),
143
244
  ...(runs.some((r) => r.findings.some((f) => !existsSync(join(root, f.path)))) ? { abendRunsElsewhere: runs.flatMap((r) => r.findings.filter((f) => !existsSync(join(root, f.path))).map((f) => `${r.dir}: ${f.path}`)) } : {}),
245
+ byInputFrom: { entry: findings.filter((f) => f.abend.inputFrom === 'entry').length, interface: findings.filter((f) => f.abend.inputFrom === 'interface').length },
144
246
  setIncomplete: runs.some((r) => r.problems.length > 0),
145
247
  };
146
248
  return report('abend', { rules: ABEND_RULES, findings, stats, run: null });