@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
@@ -12,47 +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
31
  import { scanHlasm, HLASM_RULES } from '../sets/hlasm.mjs';
32
32
  import { scanCrypto, CRYPTO_RULES } from '../sets/crypto.mjs';
33
+ import { scanSecrets, SECRETS_RULES } from '../sets/secrets.mjs';
33
34
  import { toolName } from './ruleset.mjs';
34
35
 
35
36
  // The order is the order a scan runs them and the order --only lists them. It is not significant
36
- // 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).
37
39
  export const REGISTRY = [
38
- { name: 'flow', scan: scanFlow, rules: FLOW_RULES },
39
- { 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 },
40
42
  { name: 'hidden', scan: scanHidden, rules: HIDDEN_RULES },
41
- { name: 'copybook', scan: scanCopybooks, rules: COPYBOOK_RULES },
43
+ { name: 'copybook', scan: scanCopybooks, steps: scanCopybooksSteps, rules: COPYBOOK_RULES },
42
44
  { name: 'jcl', scan: scanJcl, rules: JCL_RULES },
43
45
  { name: 'build', scan: scanBuild, rules: BUILD_RULES },
44
46
  { name: 'recon', scan: scanRecon, rules: RECON_RULES },
45
47
  { name: 'vendor', scan: scanVendor, rules: VENDOR_RULES },
46
48
  { name: 'opaque', scan: scanOpaque, rules: OPAQUE_RULES },
47
- { name: 'web', scan: scanWeb, rules: WEB_RULES },
48
- { name: 'compile', scan: scanCompile, rules: COMPILE_RULES },
49
- { name: 'priv', scan: scanPriv, rules: PRIV_RULES },
50
- { name: 'log', scan: scanLog, rules: LOG_RULES },
51
- { 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 },
52
54
  { name: 'zowe', scan: scanZowe, rules: ZOWE_RULES },
53
55
  { name: 'abend', scan: scanAbend, rules: ABEND_RULES },
54
56
  { name: 'hlasm', scan: scanHlasm, rules: HLASM_RULES },
55
57
  { name: 'crypto', scan: scanCrypto, rules: CRYPTO_RULES },
58
+ { name: 'secrets', scan: scanSecrets, rules: SECRETS_RULES },
56
59
  ];
57
60
 
58
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":"2144d560676dda84c8ae6546dfb7b858399f0b7d"}
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;
@@ -10,6 +10,7 @@ import { createHash } from 'node:crypto';
10
10
  import { delimiter, join, posix, basename } from 'node:path';
11
11
  import { report } from '../kernel/ruleset.mjs';
12
12
  import { verifyEvidence } from '../evidence/verify.mjs';
13
+ import { NOT_RUN_ABENDS } from '../ironwork-ids.mjs';
13
14
 
14
15
  export const ABEND_RULES = {
15
16
  'input-causes-abend-s0c7': {
@@ -30,6 +31,18 @@ export const ABEND_RULES = {
30
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',
31
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',
32
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
+ },
33
46
  'input-causes-abend': {
34
47
  sev: 'med', evidence: 'execution', cwe: 'CWE-248',
35
48
  text: 'An input the program accepts ends its run with an abend',
@@ -38,6 +51,14 @@ export const ABEND_RULES = {
38
51
  },
39
52
  };
40
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
+
41
62
  // IBM's messages for an SSRANGE failure: a subscript or index, an OCCURS DEPENDING ON object, and a
42
63
  // reference modification's start, length, and start plus length.
43
64
  const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
@@ -47,9 +68,16 @@ export function abendRunPaths(opts = {}) {
47
68
  return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
48
69
  }
49
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
+
50
75
  export function abendRule(abend) {
51
- if (abend.code === 'S0C7') return 'input-causes-abend-s0c7';
52
- 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';
53
81
  if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
54
82
  return 'input-causes-abend';
55
83
  }
@@ -63,16 +91,35 @@ function journalAbend(evidenceDir, runId) {
63
91
  let abend = null;
64
92
  const inputs = [];
65
93
  const calls = [];
94
+ const sinks = [];
66
95
  for (const line of readFileSync(file, 'utf8').split('\n')) {
67
96
  if (!line) continue;
68
97
  const rec = JSON.parse(line);
69
98
  if (rec.kind === 'abend') abend ??= rec;
70
99
  else if (rec.kind === 'input') inputs.push(rec);
71
100
  else if (rec.kind === 'call') calls.push(rec);
101
+ else if (rec.kind === 'sink') sinks.push(rec);
72
102
  }
73
- return abend && { ...abend, inputs, calls };
103
+ return abend && { ...abend, inputs, calls, sinks };
74
104
  }
75
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
+
76
123
  const isString = (x) => typeof x === 'string' && x.length > 0;
77
124
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
78
125
 
@@ -113,6 +160,15 @@ export function loadAbendRun(dir, tree) {
113
160
  out.problems.push('manifest.json is not an ironwork-fuzz manifest with a program, inputs and runs');
114
161
  return out;
115
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
+ : [];
116
172
  const programFile = posix.normalize(doc.program.file.replace(/\\/g, '/'));
117
173
  if (programFile.startsWith('../') || posix.isAbsolute(programFile)) {
118
174
  out.problems.push(`the program ${doc.program.file} is outside the scanned tree`);
@@ -131,7 +187,7 @@ export function loadAbendRun(dir, tree) {
131
187
  const seen = new Map();
132
188
  for (const run of doc.runs) {
133
189
  if (!run || run.outcome !== 'abend' || !run.abend || !isString(run.abend.code)) continue;
134
- if (run.abend.code === 'IRONWORK') { out.notModelled++; continue; }
190
+ if (NOT_MODELLED.has(run.abend.code)) { out.notModelled++; continue; }
135
191
  const recorded = journalAbend(evidenceDir, run.journal);
136
192
  if (!recorded || recorded.code !== run.abend.code) {
137
193
  out.problems.push(`run ${run.journal ?? '(unnamed)'}: its journal does not record the abend ${run.abend.code} the manifest gives`);
@@ -152,16 +208,23 @@ export function loadAbendRun(dir, tree) {
152
208
  const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
153
209
  const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
154
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
+ }
155
217
  const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
156
- .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 } : {}) }));
157
219
  const key = `${rule}|${where}|${line}`;
158
220
  const size = used.reduce((n, i) => n + String(i.bytes || '').length, 0);
159
221
  const held = seen.get(key);
160
222
  if (held && held.size <= size) continue;
223
+ const optimized = typeof run.abend.optimized === 'boolean' ? run.abend.optimized : undefined;
161
224
  const finding = {
162
225
  rule, path: where, line, program: out.program.id,
163
- 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`,
164
- 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 } : {}) },
165
228
  input: used, run: { dir: out.dir, journal: run.journal },
166
229
  };
167
230
  seen.set(key, { size, finding });
@@ -179,6 +242,7 @@ export function scanAbend(root, opts = {}) {
179
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 } : {}) })),
180
243
  ...(runs.some((r) => r.problems.length) ? { abendRunProblems: runs.flatMap((r) => r.problems.map((p) => `${r.dir}: ${p}`)) } : {}),
181
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 },
182
246
  setIncomplete: runs.some((r) => r.problems.length > 0),
183
247
  };
184
248
  return report('abend', { rules: ABEND_RULES, findings, stats, run: null });
@@ -55,7 +55,7 @@ const COMPILERS = new Set(['gnucobol', 'opentext-cobol', 'ibm-enterprise-cobol']
55
55
 
56
56
  // Files that decide what the build runs. A repository states this in one of a small number of
57
57
  // conventional places, and reading only those keeps the rule from matching prose in a README.
58
- function isBuildFile(path) {
58
+ export function isBuildFile(path) {
59
59
  const name = basename(path);
60
60
  const p = path.replace(/\\/g, '/').toLowerCase();
61
61
  if (/^(dockerfile|containerfile)(\..+)?$/i.test(name)) return true;
@@ -98,6 +98,21 @@ const PINS = [
98
98
  { product: 'cics-transaction-gateway', re: /from\s+\S*cics[-_]?transaction[-_]?gateway\S*:v?(\d+(?:\.\d+)+)/gi },
99
99
  ];
100
100
 
101
+ // Every product version the text pins, once each, with the text that pins it.
102
+ export function pinsIn(src) {
103
+ const out = [];
104
+ const seen = new Set();
105
+ for (const { product, re } of PINS) {
106
+ for (const m of src.matchAll(re)) {
107
+ const key = `${product}@${m[1]}`;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ out.push({ product, version: m[1], text: m[0] });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
101
116
  function withMavenProperties(src) {
102
117
  const block = src.match(/<properties>([\s\S]*?)<\/properties>/i);
103
118
  if (!block) return src;
@@ -189,42 +204,33 @@ export function scanBuild(root, opts = {}) {
189
204
  if (/pom\.xml$/i.test(path)) src = withMavenProperties(src);
190
205
  const lines = src.split(/\r?\n/);
191
206
 
192
- const seen = new Set();
193
- for (const { product, re } of PINS) {
194
- re.lastIndex = 0;
195
- let m;
196
- while ((m = re.exec(src)) !== null) {
197
- const version = m[1];
198
- const key = product + '@' + version;
199
- if (seen.has(key)) continue;
200
- seen.add(key);
201
- stats.pinsFound++;
202
-
203
- const hits = advisoriesFor(product, version, extra);
204
- if (!hits.length) continue;
205
-
206
- // A pin written across lines, as Maven writes one, is reported at its version.
207
- const needle = m[0].includes('\n') ? m[0].split('\n').pop().trim() : m[0];
208
- const line = lines.findIndex((l) => l.includes(needle)) + 1 || 1;
209
- const exploited = hits.filter((a) => isKnownExploited(a.id));
210
- const ids = hits.map((a) => a.id).sort(byText);
211
-
212
- const kind = COMPILERS.has(product) ? 'compiler' : 'component';
213
- if (exploited.length) {
214
- findings.push({
215
- rule: `build-pins-exploited-${kind}`, path, line,
216
- detail: `${path} pins ${product} ${version}, which ${exploited.map((a) => a.id).sort(byText).join(', ')} affects and CISA lists as known to be exploited${fromFeeds(hits)}`,
217
- related: hits.map(relatedOf),
218
- knownExploited: exploited.map((a) => a.id).sort(byText),
219
- });
220
- } else {
221
- findings.push({
222
- rule: `build-pins-vulnerable-${kind}`, path, line,
223
- detail: `${path} pins ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}` +
224
- (age.stale ? `. The KEV snapshot is ${age.days} days old, so whether any of these is now known to be exploited was not checked against a current catalogue` : ''),
225
- related: hits.map(relatedOf),
226
- });
227
- }
207
+ for (const { product, version, text } of pinsIn(src)) {
208
+ stats.pinsFound++;
209
+
210
+ const hits = advisoriesFor(product, version, extra);
211
+ if (!hits.length) continue;
212
+
213
+ // A pin written across lines, as Maven writes one, is reported at its version.
214
+ const needle = text.includes('\n') ? text.split('\n').pop().trim() : text;
215
+ const line = lines.findIndex((l) => l.includes(needle)) + 1 || 1;
216
+ const exploited = hits.filter((a) => isKnownExploited(a.id));
217
+ const ids = hits.map((a) => a.id).sort(byText);
218
+
219
+ const kind = COMPILERS.has(product) ? 'compiler' : 'component';
220
+ if (exploited.length) {
221
+ findings.push({
222
+ rule: `build-pins-exploited-${kind}`, path, line,
223
+ detail: `${path} pins ${product} ${version}, which ${exploited.map((a) => a.id).sort(byText).join(', ')} affects and CISA lists as known to be exploited${fromFeeds(hits)}`,
224
+ related: hits.map(relatedOf),
225
+ knownExploited: exploited.map((a) => a.id).sort(byText),
226
+ });
227
+ } else {
228
+ findings.push({
229
+ rule: `build-pins-vulnerable-${kind}`, path, line,
230
+ detail: `${path} pins ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}` +
231
+ (age.stale ? `. The KEV snapshot is ${age.days} days old, so whether any of these is now known to be exploited was not checked against a current catalogue` : ''),
232
+ related: hits.map(relatedOf),
233
+ });
228
234
  }
229
235
  }
230
236
  return src.length;