@portll/cobolwork 0.3.0 → 0.5.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 (46) hide show
  1. package/README.md +35 -9
  2. package/bin/cobolwork.mjs +7 -1
  3. package/lib/bms.mjs +8 -0
  4. package/lib/capabilities.mjs +2 -2
  5. package/lib/cics-commands.mjs +308 -42
  6. package/lib/consequence.mjs +19 -0
  7. package/lib/constants.mjs +60 -0
  8. package/lib/control.mjs +61 -17
  9. package/lib/csd.mjs +6 -1
  10. package/lib/dataflow.mjs +107 -27
  11. package/lib/explain.mjs +31 -5
  12. package/lib/exploitability.mjs +49 -4
  13. package/lib/gate.mjs +31 -9
  14. package/lib/hlasm.mjs +79 -0
  15. package/lib/kernel/findings.mjs +2 -0
  16. package/lib/kernel/registry.mjs +6 -0
  17. package/lib/kernel/source-tree.mjs +1 -0
  18. package/lib/options.mjs +6 -2
  19. package/lib/packs.mjs +4 -0
  20. package/lib/parser.mjs +67 -12
  21. package/lib/precompile-cics.mjs +9 -3
  22. package/lib/precompile.mjs +3 -2
  23. package/lib/revision.json +1 -1
  24. package/lib/sarif.mjs +3 -2
  25. package/lib/scan.mjs +2 -1
  26. package/lib/sets/abend.mjs +186 -0
  27. package/lib/sets/compile.mjs +2 -1
  28. package/lib/sets/crypto.mjs +110 -0
  29. package/lib/sets/flow.mjs +18 -0
  30. package/lib/sets/hidden.mjs +1 -1
  31. package/lib/sets/hlasm.mjs +121 -0
  32. package/lib/sets/jcl.mjs +16 -1
  33. package/lib/sets/recon.mjs +1 -0
  34. package/lib/sets/web.mjs +99 -4
  35. package/lib/sources.mjs +12 -2
  36. package/lib/utilities.mjs +2 -0
  37. package/lib/verify.mjs +2 -1
  38. package/lib/words.mjs +377 -17
  39. package/package.json +1 -1
  40. package/rules/compliance-cobit2019.json +180 -0
  41. package/rules/compliance-dora.json +195 -0
  42. package/rules/compliance-ffiec.json +195 -0
  43. package/rules/compliance-nist80053.json +195 -0
  44. package/rules/gitleaks-mainframe.toml +24 -10
  45. package/rules/hlasm-operations.json +451 -0
  46. package/rules/icsf-services.json +1178 -0
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // The literals that can be in a data item when a statement reads it, from the program's text: its
3
+ // VALUE clause and its subordinates', each MOVE and STRING into it or into a group holding it, and a
4
+ // hop or two back through an item those name. Anything else that can write it - a computation, a
5
+ // READ or ACCEPT INTO, a CALL it is passed to by reference, a MOVE from a field with no constant -
6
+ // leaves it open, which a caller reads as "and something this cannot see".
7
+ const FIGURATIVE = /^(LOW-VALUES?|HIGH-VALUES?|SPACES?|ZEROS?|ZEROES|QUOTES?|NULLS?)$/;
8
+ const WRITERS = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE', 'READ', 'ACCEPT', 'UNSTRING', 'INITIALIZE', 'SET', 'RETURN', 'INSPECT']);
9
+ const NOT_OPERANDS = new Set(['TO', 'INTO', 'DELIMITED', 'BY', 'SIZE', 'OF', 'IN', 'ALL']);
10
+
11
+ // The item, its subordinates and the groups that hold it.
12
+ function family(p, name) {
13
+ const self = p.items.find((x) => x.name === name);
14
+ if (!self) return null;
15
+ const under = [];
16
+ const down = (it) => { for (const c of it.children || []) { if (c.level === 88) continue; under.push(c); down(c); } };
17
+ down(self);
18
+ const over = [];
19
+ for (let up = self.parent; up; up = up.parent) over.push(up);
20
+ return { self, under, over };
21
+ }
22
+
23
+ const nameOf = (t) => (typeof t === 'string' ? t : t && t.u);
24
+
25
+ const valuesOf = (item) => (item.values || []).map((v) => (v.t === 'lit' ? String(v.v) : FIGURATIVE.test(v.u || '') ? v.u : null)).filter((v) => v !== null);
26
+
27
+ // `ignoreCall(call)` names calls that read the item and do not write it, such as the one being judged.
28
+ export function constantsReaching(p, name, { depth = 2, ignoreCall = () => false } = {}, seen = new Set()) {
29
+ const out = { values: [], open: false };
30
+ if (seen.has(name)) return out;
31
+ seen.add(name);
32
+ const f = family(p, name);
33
+ if (!f) return { values: [], open: true };
34
+ const names = new Set([name, ...f.under.map((x) => x.name), ...f.over.map((x) => x.name)]);
35
+ out.values.push(...valuesOf(f.self), ...f.under.flatMap(valuesOf));
36
+ for (const st of p.statements || []) {
37
+ if (!(st.targets || []).some((t) => names.has(nameOf(t)))) continue;
38
+ if (st.verb === 'MOVE' || st.verb === 'STRING') {
39
+ for (const t of st.sources || []) if (t && t.t === 'lit') out.values.push(String(t.v));
40
+ for (const l of st.literals || []) out.values.push(String(l && l.v !== undefined ? l.v : l));
41
+ for (const w of (st.sources || []).filter((t) => !(t && t.t === 'lit')).map(nameOf).filter((x) => x && !NOT_OPERANDS.has(x))) {
42
+ if (FIGURATIVE.test(w) || /^[+-]?\d/.test(w) || names.has(w)) { if (FIGURATIVE.test(w)) out.values.push(w); continue; }
43
+ if (depth === 0) { out.open = true; continue; }
44
+ const r = constantsReaching(p, w, { depth: depth - 1, ignoreCall }, seen);
45
+ out.values.push(...r.values);
46
+ if (r.open || !r.values.length) out.open = true;
47
+ }
48
+ continue;
49
+ }
50
+ if (WRITERS.has(st.verb)) out.open = true;
51
+ }
52
+ for (const c of p.calls || []) {
53
+ if (ignoreCall(c)) continue;
54
+ if ((c.using || []).some((u) => names.has(u.word) && u.mode !== 'CONTENT' && u.mode !== 'VALUE')) out.open = true;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // A rule array is eight-byte keywords side by side; one held in several fields reads the same.
60
+ export const keywordsOf = (values) => new Set(values.flatMap((v) => (FIGURATIVE.test(v) ? [] : String(v).match(/.{1,8}/g) || []).map((k) => k.trim().toUpperCase()).filter(Boolean)));
package/lib/control.mjs CHANGED
@@ -622,10 +622,14 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
622
622
 
623
623
  // ---- checks ----
624
624
  // The storage an item lies in: its record, or the record an 01 REDEFINES lays itself over.
625
+ const recordOf = new Map();
625
626
  const itemsInRecord = (item) => {
626
- let top = item;
627
+ let top = recordOf.get(item);
628
+ if (top) return top;
629
+ top = item;
627
630
  while (top.parent) top = top.parent;
628
631
  for (let hops = 0; top.redefinesItem && hops < 64; hops++) top = top.redefinesItem;
632
+ if (item.parent) recordOf.set(item, top);
629
633
  return top;
630
634
  };
631
635
  const fieldKey = (f) => (f.index ? `IX:${f.index}` : f);
@@ -639,6 +643,33 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
639
643
  const fEnd = f.offset + (f.contributes || f.size);
640
644
  return w.offset < fEnd && f.offset < wEnd;
641
645
  };
646
+ // Entries grouped by the storage their item lies in, so a lookup tries only what `overlaps` can
647
+ // accept: the same record, the same index, or a 66-level item, which overlaps any field.
648
+ const byStorage = (entries, itemOf) => {
649
+ const byRecord = new Map();
650
+ const byIndex = new Map();
651
+ const renames = [];
652
+ const plain = [];
653
+ const add = (map, k, e) => { const list = map.get(k); if (list) list.push(e); else map.set(k, [e]); };
654
+ for (const e of entries) {
655
+ const it = itemOf(e);
656
+ if (it.index) { add(byIndex, it.index, e); continue; }
657
+ plain.push(e);
658
+ if (it.level === 66) renames.push(e);
659
+ else add(byRecord, itemsInRecord(it), e);
660
+ }
661
+ return {
662
+ mayOverlap: (it) => {
663
+ if (it.index) return byIndex.get(it.index) || [];
664
+ if (it.level === 66) return plain;
665
+ const same = byRecord.get(itemsInRecord(it));
666
+ return same ? (renames.length ? same.concat(renames) : same) : renames;
667
+ },
668
+ };
669
+ };
670
+ // Arguments the CICS API documents as data-values, which the command reads and never fills: the
671
+ // program or transaction it transfers to or starts, and the system it ships the request to.
672
+ const CICS_READS = { LINK: ['PROGRAM', 'TRANSID', 'SYSID'], XCTL: ['PROGRAM'], START: ['TRANSID', 'SYSID'], RETURN: ['TRANSID'], LOAD: ['PROGRAM'], RELEASE: ['PROGRAM'] };
642
673
  const writtenBy = (n) => {
643
674
  const out = [];
644
675
  const add = (tok) => { const f = tok ? resolve(tok) : null; if (f) out.push(f.level === 88 && f.parent ? f.parent : f); };
@@ -667,28 +698,31 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
667
698
  }
668
699
  }
669
700
  } else if (n.e) {
670
- // A command may fill what any option is given directly, except what it sends FROM. A name
671
- // inside that argument's own parentheses - a subscript, a reference modification - is read.
701
+ // A command may fill what any option is given directly, except what it sends FROM and the
702
+ // data-values it reads. A name inside that argument's own parentheses - a subscript, a
703
+ // reference modification - is read.
704
+ const reads = (n.e.kind === 'CICS' && CICS_READS[n.e.toks[0]?.u]) || [];
672
705
  let depth = 0;
673
706
  let option = null;
674
707
  for (let i = 0; i < n.e.toks.length; i++) {
675
708
  const t = n.e.toks[i];
676
709
  if (t.t === 'sep') { depth += t.v === '(' ? 1 : -1; continue; }
677
710
  if (depth === 0) { option = t.t === 'word' ? t.u : option; continue; }
678
- if (depth === 1 && t.t === 'word' && option !== 'FROM') add(t);
711
+ if (depth === 1 && t.t === 'word' && option !== 'FROM' && !reads.includes(option)) add(t);
679
712
  }
680
713
  }
681
714
  return out;
682
715
  };
683
716
  // A VALUE holds for the whole run only if no statement writes storage that overlaps the item.
684
- let writtenItems = null;
717
+ let written = null;
685
718
  const neverWritten = (it) => {
686
719
  if (it.external) return false;
687
- if (!writtenItems) {
688
- writtenItems = [];
689
- for (const n of nodes) if (n.kind === 'stmt' || n.kind === 'exec' || n.kind === 'step' || n.kind === 'call') writtenItems.push(...writtenBy(n));
720
+ if (!written) {
721
+ const all = [];
722
+ for (const n of nodes) if (n.kind === 'stmt' || n.kind === 'exec' || n.kind === 'step' || n.kind === 'call') all.push(...writtenBy(n));
723
+ written = byStorage(all, (w) => w);
690
724
  }
691
- return !writtenItems.some((w) => overlaps(w, it));
725
+ return !written.mayOverlap(it).some((w) => overlaps(w, it));
692
726
  };
693
727
  const constValue = (toks) => {
694
728
  if (toks.length === 3 && WORD(toks[0], 'LENGTH') && WORD(toks[1], 'OF')) {
@@ -2373,17 +2407,17 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2373
2407
  for (const f of [c.x, c.t, c.f, c.rt, c.rf]) if (f != null) list.push(f);
2374
2408
  factsOfKey.set(c.key, list);
2375
2409
  }
2410
+ const checkedFields = byStorage([...factsOfKey.keys()].filter((key) => fields.get(key)), (key) => fields.get(key));
2376
2411
  const killCache = new Map();
2377
2412
  const killOf = (w) => {
2378
2413
  if (!w) return null;
2379
2414
  let bits = killCache.get(w);
2380
2415
  if (bits !== undefined) return bits;
2381
2416
  bits = null;
2382
- for (const [key, list] of factsOfKey) {
2383
- const f = fields.get(key);
2384
- if (!f || !overlaps(w, f)) continue;
2417
+ for (const key of checkedFields.mayOverlap(w)) {
2418
+ if (!overlaps(w, fields.get(key))) continue;
2385
2419
  bits ||= new Uint32Array(words);
2386
- for (const x of list) bits[x >>> 5] |= 1 << (x & 31);
2420
+ for (const x of factsOfKey.get(key)) bits[x >>> 5] |= 1 << (x & 31);
2387
2421
  }
2388
2422
  killCache.set(w, bits);
2389
2423
  return bits;
@@ -2629,10 +2663,15 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2629
2663
  // Every route from `entry` that reaches `stopAt` or the program's end, meeting at joins. A call
2630
2664
  // whose range never returns ends the route, as a STOP RUN does. `calls` collects the ranges the
2631
2665
  // routes perform, for whoever needs to know what depends on what.
2666
+ // A run holds a bitset per node it reaches. Those of a run nobody reads any more are handed back
2667
+ // and reused, since allocating them afresh for every run was most of the analysis's collection time.
2668
+ const spare = [];
2669
+ const copyOf = (bits) => { const a = spare.pop(); if (!a) return bits.slice(); a.set(bits); return a; };
2670
+ const release = (IN) => { for (const a of IN.values()) spare.push(a); };
2632
2671
  function run(entry, input, stopAt, calls = null) {
2633
2672
  const IN = new Map();
2634
2673
  let exitOut = null;
2635
- IN.set(entry, input.slice());
2674
+ IN.set(entry, copyOf(input));
2636
2675
  push(entry);
2637
2676
  while (heap.length) {
2638
2677
  const id = pop();
@@ -2643,7 +2682,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2643
2682
  if (id === stopAt) { if (exitOut) meetInto(exitOut, out); else exitOut = out.slice(); continue; }
2644
2683
  for (const s of n.succ) {
2645
2684
  const cur = IN.get(s);
2646
- if (!cur) { IN.set(s, out.slice()); push(s); } else if (meetInto(cur, out)) push(s);
2685
+ if (!cur) { IN.set(s, copyOf(out)); push(s); } else if (meetInto(cur, out)) push(s);
2647
2686
  }
2648
2687
  }
2649
2688
  return { IN, exitOut };
@@ -2675,7 +2714,10 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2675
2714
  for (const d of n.derive || []) if (d.needs.every((x) => has(bits, x))) approved.add(d);
2676
2715
  for (const entry of n.implies || []) if (entry[2] && has(bits, entry[0])) approved.add(entry);
2677
2716
  }
2678
- const top = run(r.entry, ONES, r.end, calls).exitOut;
2717
+ release(fromNone.IN);
2718
+ const fromAll = run(r.entry, ONES, r.end, calls);
2719
+ const top = fromAll.exitOut;
2720
+ release(fromAll.IN);
2679
2721
  approved = null;
2680
2722
  for (const k of calls) { if (!callers.has(k)) callers.set(k, new Set()); callers.get(k).add(r); }
2681
2723
  const old = summaries.get(r.key);
@@ -2704,7 +2746,9 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2704
2746
  waiting.delete(c.key);
2705
2747
  const input = c.stopAt == null ? ZERO : rangeIn.get(c.key);
2706
2748
  const res = run(c.entry, input, c.stopAt);
2749
+ const superseded = results.get(c.key);
2707
2750
  results.set(c.key, res.IN);
2751
+ if (superseded) release(superseded);
2708
2752
  for (const [id, bits] of res.IN) {
2709
2753
  const n = nodes[id];
2710
2754
  if (n.kind !== 'call' || !byKey.has(n.range.key)) continue;
@@ -2723,7 +2767,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2723
2767
  for (const IN of results.values()) {
2724
2768
  for (const [id, bits] of IN) {
2725
2769
  const had = facts.get(id);
2726
- if (had) meetInto(had, bits); else facts.set(id, bits.slice());
2770
+ if (had) meetInto(had, bits); else facts.set(id, bits);
2727
2771
  }
2728
2772
  }
2729
2773
  };
package/lib/csd.mjs CHANGED
@@ -14,7 +14,7 @@ const COMMAND = /^\s*(DEFINE|ADD|ALTER|APPEND|COPY|DELETE|INITIALIZE|LIST|REMOVE
14
14
  // { tdqueues: Map(name -> { type, ddname, indirect, line }),
15
15
  // transactions: Map(name -> { program, cmdsec, ressec, line }),
16
16
  // tcpipservices: Map(name -> { port, protocol, ssl, authenticate, transaction, line }),
17
- // urimaps: Map(name -> { usage, path, program, transaction, tcpipservice, line }) }
17
+ // urimaps: Map(name -> { usage, path, program, transaction, tcpipservice, scheme, host, attls, line }) }
18
18
  export function parseCsd(text) {
19
19
  const tdqueues = new Map();
20
20
  const transactions = new Map();
@@ -64,6 +64,11 @@ export function parseCsd(text) {
64
64
  program: attrs.get('PROGRAM') || null,
65
65
  transaction: attrs.get('TRANSACTION') || null,
66
66
  tcpipservice: attrs.get('TCPIPSERVICE') || null,
67
+ // A client URIMAP's scheme decides whether WEB OPEN URIMAP(name) asks for TLS; ATTLS(AWARE)
68
+ // has AT-TLS secure an HTTP scheme outside CICS.
69
+ scheme: attrs.get('SCHEME') || null,
70
+ host: attrs.get('HOST') || null,
71
+ attls: attrs.get('ATTLS') || null,
67
72
  line: b.line,
68
73
  });
69
74
  } else if (kind === 'TDQUEUE') {
package/lib/dataflow.mjs CHANGED
@@ -87,6 +87,7 @@ function literalNames(prog) {
87
87
  // The value of the last NAME(value) option in a list: the last one wins, at either level.
88
88
  function lastOption(name, list) {
89
89
  for (let k = (list || []).length - 1; k >= 0; k--) {
90
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- name is a literal option name
90
91
  const m = new RegExp(`^${name}\\(([A-Z]+)\\)$`).exec(list[k]);
91
92
  if (m) return m[1];
92
93
  }
@@ -188,6 +189,9 @@ export function analyze(root, opts = {}) {
188
189
  // Every hop of every trace, for the reader who is following one chain rather than counting
189
190
  // findings. Off by default: a chain-shaped program turns this quadratic in the chain length.
190
191
  const fullTrace = opts.fullTrace === true;
192
+ // Every statement on any route from a finding's source to its sink, not only the route the trace
193
+ // names: what a run must have executed before it can show a value does not arrive (reach.md §9.8).
194
+ const allRoutes = opts.allRoutes === true;
191
195
  let held = 0;
192
196
 
193
197
  // Which files, not just how many. A count names nothing to go and look at, and it cannot tell
@@ -215,11 +219,14 @@ export function analyze(root, opts = {}) {
215
219
  const rel = (p) => { let r = relOf.get(p); if (r === undefined) { r = relPath(root, p); relOf.set(p, r); } return r; };
216
220
  // `at` is the statement an edge that is not a statement's own is read at: the CALL or LINK that
217
221
  // passes an argument, the PUT or GET of a container.
218
- const edge = (a, b, why, dir, at) => {
222
+ // `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }), and
223
+ // `stmt` names the statement that carries an edge whose `why` is text.
224
+ const edge = (a, b, why, dir, at, link) => {
219
225
  if (!a || !b || a === b) return;
220
- a.edgesOut.push(at != null ? { to: b, why, dir, at } : { to: b, why, dir });
226
+ a.edgesOut.push({ to: b, why, dir, ...(at != null ? { at } : {}), ...link });
221
227
  stats.edges++;
222
228
  };
229
+ let callSites = 0;
223
230
 
224
231
  // Everything the graph needs from one program is taken while its parse tree is in hand, and the
225
232
  // tree is then released. Holding every tree until the end is what exhausted a 4 GB heap on an
@@ -236,7 +243,9 @@ export function analyze(root, opts = {}) {
236
243
  let repeats = false;
237
244
  let table = null;
238
245
  for (let a = item; a; a = a.parent) if ((a.occurs || 1) > 1) { repeats = true; table = a; }
239
- n = { id: nodes.length, pk, program: prog.id, file, name: item.name, off: item.offset ?? null, size: item.size ?? null, repeats, edgesOut: [], sources: [], sinks: [] };
246
+ let top = item;
247
+ while (top.parent) top = top.parent;
248
+ n = { id: nodes.length, pk, program: prog.id, file, name: item.name, off: item.offset ?? null, size: item.size ?? null, repeats, edgesOut: [], sources: [], sinks: [], ...(top.section === 'LINKAGE' ? { linkage: true } : {}) };
240
249
  // Where the outermost table holding this item sits in the record: every occurrence of the
241
250
  // item is somewhere inside it. A group's size already counts its occurrences; an
242
251
  // elementary item's is one element.
@@ -792,7 +801,7 @@ export function analyze(root, opts = {}) {
792
801
  if (verb === 'PUT' && o.has('CONTAINER')) {
793
802
  const name = optionName(o, 'CONTAINER');
794
803
  const from = nodeOfToken((o.get('FROM') || [])[0]);
795
- if (name && from) edge(from, containerNode(name), `EXEC CICS PUT CONTAINER(${name})`, undefined, at.point);
804
+ if (name && from) edge(from, containerNode(name), `EXEC CICS PUT CONTAINER(${name})`, undefined, at.point, { stmt: { verb: 'EXEC CICS PUT', file: rel(at.file), line: at.line } });
796
805
  if (name) channelOf.set(name, optionName(o, 'CHANNEL'));
797
806
  }
798
807
  // A service call sends every container on its channel, and its URI decides where they go.
@@ -806,7 +815,7 @@ export function analyze(root, opts = {}) {
806
815
  if (verb === 'GET' && o.has('CONTAINER')) {
807
816
  const name = optionName(o, 'CONTAINER');
808
817
  const into = nodeOfToken((o.get('INTO') || o.get('SET') || [])[0]);
809
- if (name && into) edge(containerNode(name), into, `EXEC CICS GET CONTAINER(${name})`, undefined, at.point);
818
+ if (name && into) edge(containerNode(name), into, `EXEC CICS GET CONTAINER(${name})`, undefined, at.point, { stmt: { verb: 'EXEC CICS GET', file: rel(at.file), line: at.line } });
810
819
  }
811
820
  // CONVERSE is always a client call; SEND is one only on a session this program opened. A
812
821
  // plain WEB SEND answers the request that started the program, which is its job.
@@ -896,9 +905,9 @@ export function analyze(root, opts = {}) {
896
905
  }
897
906
  const commarea = (o.get('COMMAREA') || o.get('FROM') || [])[0];
898
907
  const argNode = commarea ? nodeOfToken(commarea) : null;
899
- if (pgm && pgm.t === 'lit' && argNode) transfers.push({ verb, callee: pgm.v.toUpperCase(), argNode, point: at.point });
908
+ if (pgm && pgm.t === 'lit' && argNode) transfers.push({ verb, callee: pgm.v.toUpperCase(), argNode, point: at.point, file: at.file, line: at.line });
900
909
  else if (pgm && pgm.t === 'word' && argNode && o.has('PROGRAM')) {
901
- for (const callee of programsNamed(pgm, e)) { transfers.push({ verb, callee, argNode, point: at.point, through: pgm.u }); transfersTo.add(callee); }
910
+ for (const callee of programsNamed(pgm, e)) { transfers.push({ verb, callee, argNode, point: at.point, file: at.file, line: at.line, through: pgm.u }); transfersTo.add(callee); }
902
911
  }
903
912
  if (pgm && pgm.t === 'lit') (o.has('TRANSID') && !o.has('PROGRAM') ? startsTransactions : transfersTo).add(String(pgm.v).trim().toUpperCase());
904
913
  }
@@ -1040,21 +1049,25 @@ export function analyze(root, opts = {}) {
1040
1049
  for (const p of programs) {
1041
1050
  for (const t of p.transfers) {
1042
1051
  const r = calleesOf(t.callee, p.file);
1052
+ const site = ++callSites;
1053
+ const stmt = { verb: `EXEC CICS ${t.verb}`, file: rel(t.file), line: t.line };
1043
1054
  for (const callee of r.list) {
1044
1055
  if (!callee.commarea) continue;
1045
- edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point);
1046
- if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`);
1056
+ edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point, { call: site, stmt });
1057
+ if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`, undefined, undefined, { ret: site, stmt });
1047
1058
  }
1048
1059
  }
1049
1060
  for (const c of p.callSites) {
1050
1061
  const r = calleesOf(c.name, p.file);
1062
+ const site = ++callSites;
1063
+ const stmt = { verb: 'CALL', file: rel(c.file), line: c.line };
1051
1064
  for (const callee of r.list) {
1052
1065
  if (!callee.params) continue;
1053
1066
  c.args.forEach((arg, i) => {
1054
1067
  const param = callee.params[i];
1055
1068
  if (!param || !arg.node || !param.node) return;
1056
- edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point);
1057
- if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`);
1069
+ edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point, { call: site, stmt });
1070
+ if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`, undefined, undefined, { ret: site, stmt });
1058
1071
  });
1059
1072
  }
1060
1073
  const callee = r.list.length === 1 ? r.list[0] : null;
@@ -1347,6 +1360,7 @@ export function analyze(root, opts = {}) {
1347
1360
  // last moved - at a point where the credit reaches `level`.
1348
1361
  function refuse(start, reach, credited, level, kind, shift = 0) {
1349
1362
  walk++;
1363
+ stampCtx.clear();
1350
1364
  const want = credited.filter((c) => c.level >= level && c.sink.kind === kind && (c.sink.offset || 0) === shift);
1351
1365
  // Past the budget no route can be ruled out, so every credit in question is lost: a finding
1352
1366
  // may be reported unchecked, never dropped.
@@ -1380,17 +1394,15 @@ export function analyze(root, opts = {}) {
1380
1394
  // stand in for it.
1381
1395
  const guarded = since.some((n) => n.checks || n.guard);
1382
1396
  if (t.partial) {
1383
- const key = `${to.id}|${guarded}`;
1397
+ const key = `${to.id}|${guarded}|${t.ctx}`;
1384
1398
  const held = parts.get(key);
1385
1399
  if (held && held.some((r) => (r[2] ? t.vague : !t.vague && r[0] <= t.lo && t.hi <= r[1]))) continue;
1386
1400
  if (held) held.push([t.lo, t.hi, t.vague]); else parts.set(key, [[t.lo, t.hi, t.vague]]);
1387
1401
  } else if (guarded) {
1388
- if (stampWhole[to.id] === walk || dirty.get(to.id) === walk) continue;
1389
- dirty.set(to.id, walk);
1390
- } else {
1391
- if (stampWhole[to.id] === walk) continue;
1392
- stampWhole[to.id] = walk;
1393
- }
1402
+ const dk = `${to.id}|${t.ctx}`;
1403
+ if (stampWhole[to.id] === walk || dirty.get(dk) === walk) continue;
1404
+ dirty.set(dk, walk);
1405
+ } else if (!firstVisit(to.id, t.ctx)) continue;
1394
1406
  open.push({ node: to, ...t, since });
1395
1407
  }
1396
1408
  if (edgesWalked - began > walkBudget) { stats.creditUndecided++; return new Set(want); }
@@ -1409,6 +1421,16 @@ export function analyze(root, opts = {}) {
1409
1421
  // Visit stamps instead of a per-walk Set: one walk per source, and a Set of string keys per walk
1410
1422
  // was most of the time spent on large repositories.
1411
1423
  const stampWhole = new Int32Array(nodes.length);
1424
+ // A whole node reached with a call context, per walk. Reached with none, it stands in for every context.
1425
+ const stampCtx = new Map();
1426
+ const firstVisit = (id, ctx) => {
1427
+ if (stampWhole[id] === walk) return false;
1428
+ if (!ctx) { stampWhole[id] = walk; return true; }
1429
+ const key = `${id}|${ctx}`;
1430
+ if (stampCtx.get(key) === walk) return false;
1431
+ stampCtx.set(key, walk);
1432
+ return true;
1433
+ };
1412
1434
  let walk = 0;
1413
1435
  // Every walk is bounded, and so is the analysis. One source of an estate whose programs share
1414
1436
  // their records through thousands of copybooks reached millions of nodes, and 22,905 such walks
@@ -1425,9 +1447,25 @@ export function analyze(root, opts = {}) {
1425
1447
  // it does not. Both walks below cross edges through this, so neither can take a route the other
1426
1448
  // would refuse.
1427
1449
  const known = (n) => n.off != null && n.size != null && !n.repeats;
1450
+ // The call sites a value entered its callees through, innermost last, as a string; empty where any
1451
+ // caller may be returned to: a value born in the callee, or one that rested in storage a program
1452
+ // keeps between calls, which a later call from any caller can read.
1453
+ const CONTEXT_DEPTH = 4;
1454
+ function contextOver(state, e) {
1455
+ const ctx = state.ctx || '';
1456
+ if (e.ret != null) {
1457
+ if (!ctx) return '';
1458
+ const sites = ctx.split('/');
1459
+ return Number(sites[sites.length - 1]) === e.ret ? sites.slice(0, -1).join('/') : null;
1460
+ }
1461
+ if (e.call != null) return [...(ctx ? ctx.split('/') : []), String(e.call)].slice(-CONTEXT_DEPTH).join('/');
1462
+ return e.to.linkage ? ctx : '';
1463
+ }
1428
1464
  function cross(state, e) {
1429
1465
  const cur = state.node;
1430
1466
  const to = e.to;
1467
+ const ctx = contextOver(state, e);
1468
+ if (ctx === null) return null;
1431
1469
  let partial = false, lo = 0, hi = 0, vague = false;
1432
1470
  if (opts.legacyGroupEdges) { /* every edge carries the whole item */ }
1433
1471
  else if (e.dir === 'up') {
@@ -1464,7 +1502,45 @@ export function analyze(root, opts = {}) {
1464
1502
  if (!vague && to.size != null) { hi = Math.min(hi, to.size); if (hi <= lo) return null; }
1465
1503
  }
1466
1504
  }
1467
- return { partial, lo, hi, vague };
1505
+ return { partial, lo, hi, vague, ctx };
1506
+ }
1507
+ // The statement that carries an edge: null for storage that overlays storage, which runs nothing,
1508
+ // and undefined where the engine does not know which statement it is.
1509
+ const OVERLAYS = new Set(['group', 'redefines', 'renames']);
1510
+ const statementOf = (e) => {
1511
+ if (e.stmt) return e.stmt;
1512
+ if (e.why && typeof e.why === 'object') return { verb: e.why.verb, file: e.why.file, line: e.why.line };
1513
+ return OVERLAYS.has(e.why) ? null : undefined;
1514
+ };
1515
+ // Every route from one walk's source to each sink it reached: the edges the walk could cross that
1516
+ // lead on to the sink, taken or not, by node rather than by call context or bytes. That lists a
1517
+ // statement no route needs sooner than it leaves out one a route does.
1518
+ function attachRoutes(awaiting, crossed, src, cut) {
1519
+ const into = new Map();
1520
+ for (const c of crossed) {
1521
+ const list = into.get(c.to);
1522
+ if (list) list.push(c); else into.set(c.to, [c]);
1523
+ }
1524
+ for (const { finding, at } of awaiting) {
1525
+ const onRoute = new Set([at]);
1526
+ const stack = [at];
1527
+ const statements = new Map();
1528
+ let unplaced = 0;
1529
+ while (stack.length) {
1530
+ for (const c of into.get(stack.pop()) || []) {
1531
+ const s = statementOf(c.e);
1532
+ if (s) statements.set(`${s.file}:${s.line}:${s.verb}`, s);
1533
+ else if (s === undefined) unplaced++;
1534
+ if (!onRoute.has(c.from)) { onRoute.add(c.from); stack.push(c.from); }
1535
+ }
1536
+ }
1537
+ finding.routes = {
1538
+ sources: [{ kind: src.kind, file: rel(src.file), line: src.line }],
1539
+ statements: [...statements.values()].sort((a, b) => byText(a.file, b.file) || a.line - b.line || byText(a.verb, b.verb)),
1540
+ ...(unplaced ? { unplaced } : {}),
1541
+ complete: !cut && !unplaced,
1542
+ };
1543
+ }
1468
1544
  }
1469
1545
  for (const start of nodes) {
1470
1546
  if (!start.sources.length || !canReach[start.id]) continue;
@@ -1473,6 +1549,7 @@ export function analyze(root, opts = {}) {
1473
1549
  if (!(canReach[start.id] & reach)) continue;
1474
1550
  if (edgesWalked >= totalBudget) { stats.sourcesNotWalked++; continue; }
1475
1551
  walk++;
1552
+ stampCtx.clear();
1476
1553
  const credited = [];
1477
1554
  // A state is a node plus which of its bytes are tainted. Going up from a child taints only
1478
1555
  // that child's bytes of the group; going down reaches a child only if those bytes overlap it,
@@ -1482,6 +1559,8 @@ export function analyze(root, opts = {}) {
1482
1559
  // without a layout) partial taint does not descend at all.
1483
1560
  const queue = [{ node: start, partial: false, lo: 0, hi: 0, vague: false, prev: null, why: 'source' }];
1484
1561
  const partSeen = new Map();
1562
+ const crossedEdges = allRoutes ? [] : null;
1563
+ const awaiting = allRoutes ? [] : null;
1485
1564
  stampWhole[start.id] = walk;
1486
1565
  let cut = false;
1487
1566
  const began = edgesWalked;
@@ -1530,6 +1609,7 @@ export function analyze(root, opts = {}) {
1530
1609
  ...(best.level ? { guard: { program: by.program, item: by.name, file: rel(best.check.file), line: best.check.line, ...(best.level === 2 ? { stops: true } : {}) } } : {}),
1531
1610
  ...(!best.level && missed ? { checkElsewhere: { program: missed.node.program, item: missed.node.name, file: rel(missed.check.file), line: missed.check.line } } : {}),
1532
1611
  });
1612
+ if (awaiting) awaiting.push({ finding: findings[findings.length - 1], at: cur.id });
1533
1613
  if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1534
1614
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1535
1615
  }
@@ -1539,19 +1619,19 @@ export function analyze(root, opts = {}) {
1539
1619
  const to = e.to;
1540
1620
  const crossed = cross(state, e);
1541
1621
  if (!crossed) continue;
1542
- const { partial, lo, hi, vague } = crossed;
1622
+ if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
1623
+ const { partial, lo, hi, vague, ctx } = crossed;
1543
1624
  if (partial) {
1544
- const held = partSeen.get(to.id);
1625
+ const pk = `${to.id}|${ctx}`;
1626
+ const held = partSeen.get(pk);
1545
1627
  if (held && held.some(r => (r[2] ? vague : !vague && r[0] <= lo && hi <= r[1]))) continue;
1546
- if (held) held.push([lo, hi, vague]); else partSeen.set(to.id, [[lo, hi, vague]]);
1547
- } else {
1548
- if (stampWhole[to.id] === walk) continue;
1549
- stampWhole[to.id] = walk;
1550
- }
1551
- queue.push({ node: to, partial, lo, hi, vague, prev: state, why: e.why, dir: e.dir, point: pointOfEdge(e), moves: moves(e) });
1628
+ if (held) held.push([lo, hi, vague]); else partSeen.set(pk, [[lo, hi, vague]]);
1629
+ } else if (!firstVisit(to.id, ctx)) continue;
1630
+ queue.push({ node: to, partial, lo, hi, vague, ctx, prev: state, why: e.why, dir: e.dir, point: pointOfEdge(e), moves: moves(e) });
1552
1631
  }
1553
1632
  }
1554
1633
  if (cut) stats.walksCut++;
1634
+ if (awaiting?.length) attachRoutes(awaiting, crossedEdges, src, cut);
1555
1635
  // The walk keeps the shortest route to each sink, and credit on that route says nothing if
1556
1636
  // another reaches the sink without it. So the walk runs again, refusing to let the value leave
1557
1637
  // a node where the credit holds, and a sink it still reaches loses the credit: first for any
package/lib/explain.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { resolve, relative, sep, dirname } from 'node:path';
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { readSource, isProgram, isCopybook, isJcl } from './sources.mjs';
5
- import { parseFile, buildFileIndex, detectFormat } from './parser.mjs';
5
+ import { parseFile, buildFileIndex, detectFormat, normalize, tokenize, segmentEnd } from './parser.mjs';
6
6
  import { EVIDENCE } from './kernel/findings.mjs';
7
7
  import { REGISTRY } from './kernel/registry.mjs';
8
8
  import { WHO_ACTS } from './tui/model.mjs';
@@ -34,6 +34,21 @@ function contained(root, rel) {
34
34
  return { abs };
35
35
  }
36
36
 
37
+ // The last line of the COBOL statement that starts on `line`, its period or END- terminator included,
38
+ // so a drafter shown one line of a statement knows where the statement ends. Past MAX_EXTENT lines
39
+ // nothing is claimed.
40
+ const MAX_EXTENT = 30;
41
+ function statementEnd(file, line) {
42
+ file.tokens ??= (() => { try { return tokenize(normalize(file.text, file.format), '').tokens; } catch { return []; } })();
43
+ const t = file.tokens;
44
+ const i = t.findIndex((x) => x.line === line);
45
+ if (i < 0 || t[i].t !== 'word') return line;
46
+ const e = segmentEnd(t, i, t.length);
47
+ const next = t[e];
48
+ const end = next && next.line >= line && (next.t === 'period' || (next.t === 'word' && next.u === `END-${t[i].u}`)) ? next.line : t[e - 1].line;
49
+ return end > line && end - line < MAX_EXTENT ? end : line;
50
+ }
51
+
37
52
  // A hop's location: from `via` ("MOVE at pos/P1.cbl:9"), else the related entry for its file.
38
53
  function hopAt(hop, finding) {
39
54
  const m = /\bat (.+):(\d+)$/.exec(hop.via || '');
@@ -62,7 +77,7 @@ export function explainFinding(report, fingerprint, { root }) {
62
77
  else {
63
78
  try {
64
79
  const text = readSource(c.abs).text;
65
- file = { kind: kindOf(c.abs), format: detectFormat(text), lines: text.split(/\r?\n/) };
80
+ file = { kind: kindOf(c.abs), format: detectFormat(text), text, lines: text.split(/\r?\n/) };
66
81
  } catch (e) { unread.push({ path, why: `it could not be read: ${e.message}` }); }
67
82
  }
68
83
  files.set(path, file);
@@ -72,15 +87,26 @@ export function explainFinding(report, fingerprint, { root }) {
72
87
  for (const x of [...report.findings, ...(report.suppressed || [])]) {
73
88
  if (HIDDEN_RULES.has(x.rule) && x.path && x.line) flagged.set(`${x.path}:${x.line}`, x.rule);
74
89
  }
75
- const quote = (path, line) => {
90
+ const quoteLine = (path, file, line) => {
76
91
  const withheld = flagged.get(`${path}:${line}`);
77
92
  if (withheld) return { code: null, withheld };
78
- const file = fileOf(path);
79
- if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
80
93
  const area = codeArea(file.lines[line - 1], file.kind, file.format);
81
94
  const dropped = area.rest.trim() !== '' || /[^\d\s]/.test(area.seq);
82
95
  return { code: area.text.trimEnd(), ...(dropped ? { dropped } : {}) };
83
96
  };
97
+ // A statement that runs on past its first line carries endLine and the lines after the first.
98
+ const quote = (path, line) => {
99
+ const withheld = flagged.get(`${path}:${line}`);
100
+ if (withheld) return { code: null, withheld };
101
+ const file = fileOf(path);
102
+ if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
103
+ const first = quoteLine(path, file, line);
104
+ const endLine = file.kind === 'cobol' ? statementEnd(file, line) : line;
105
+ if (endLine === line) return first;
106
+ const rest = [];
107
+ for (let n = line + 1; n <= endLine; n++) rest.push({ line: n, ...quoteLine(path, file, n) });
108
+ return { ...first, endLine, rest };
109
+ };
84
110
 
85
111
  const hops = (f.trace || []).map((h, i) => {
86
112
  if (h.elided) return { n: i + 1, elided: h.elided, via: h.via };