@portll/cobolwork 0.2.76 → 0.2.140

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 (61) hide show
  1. package/README.md +66 -14
  2. package/bin/cobolwork.mjs +126 -9
  3. package/lib/arith.mjs +235 -0
  4. package/lib/baseline.mjs +5 -0
  5. package/lib/build.mjs +112 -30
  6. package/lib/capabilities.mjs +12 -7
  7. package/lib/card.mjs +18 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/consequence.mjs +29 -0
  10. package/lib/control.mjs +1169 -91
  11. package/lib/csd.mjs +17 -2
  12. package/lib/dataflow.mjs +276 -126
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/enterprise-options.mjs +255 -0
  15. package/lib/equivalence.mjs +124 -0
  16. package/lib/evidence/cli.mjs +65 -0
  17. package/lib/evidence/journal.mjs +94 -0
  18. package/lib/evidence/record.mjs +110 -0
  19. package/lib/evidence/run.mjs +111 -0
  20. package/lib/evidence/seal.mjs +177 -0
  21. package/lib/evidence/slsa.mjs +64 -0
  22. package/lib/evidence/sshsig.mjs +172 -0
  23. package/lib/evidence/store.mjs +170 -0
  24. package/lib/evidence/verify.mjs +167 -0
  25. package/lib/explain.mjs +9 -5
  26. package/lib/exploitability.mjs +263 -0
  27. package/lib/ftp.mjs +136 -0
  28. package/lib/ironwork.mjs +109 -0
  29. package/lib/jcl.mjs +27 -7
  30. package/lib/kernel/findings.mjs +11 -0
  31. package/lib/kernel/registry.mjs +4 -0
  32. package/lib/layout.mjs +202 -0
  33. package/lib/option-diff.mjs +49 -0
  34. package/lib/options.mjs +109 -46
  35. package/lib/parser.mjs +80 -209
  36. package/lib/policy.mjs +8 -3
  37. package/lib/precompile.mjs +5 -84
  38. package/lib/reach.mjs +29 -12
  39. package/lib/revision.json +1 -1
  40. package/lib/sarif.mjs +1 -0
  41. package/lib/sbom.mjs +235 -0
  42. package/lib/scan.mjs +93 -27
  43. package/lib/sets/cics.mjs +119 -2
  44. package/lib/sets/copybook.mjs +2 -1
  45. package/lib/sets/flow.mjs +82 -2
  46. package/lib/sets/hidden.mjs +4 -8
  47. package/lib/sets/jcl.mjs +70 -106
  48. package/lib/sets/recon.mjs +2 -1
  49. package/lib/sets/semantics.mjs +377 -0
  50. package/lib/sets/web.mjs +34 -10
  51. package/lib/sets/zowe.mjs +246 -0
  52. package/lib/sources.mjs +10 -0
  53. package/lib/tui/app.mjs +31 -13
  54. package/lib/tui/model.mjs +17 -2
  55. package/lib/utilities.mjs +229 -15
  56. package/lib/verify.mjs +128 -0
  57. package/package.json +1 -1
  58. package/rules/compliance-dora.json +507 -0
  59. package/rules/compliance-ffiec.json +507 -0
  60. package/rules/compliance-nist80053.json +507 -0
  61. package/schema/cobolwork.policy.schema.json +94 -16
package/lib/dataflow.mjs CHANGED
@@ -4,6 +4,9 @@ import { parseBms, symbolicNames } from './bms.mjs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { parseSource, buildFileIndex } from './parser.mjs';
6
6
  import { parseJcl } from './jcl.mjs';
7
+ import { holdsDecimal } from './layout.mjs';
8
+ import { hostVariablesIn } from './embedded-sql.mjs';
9
+ import { tsoCommands } from './utilities.mjs';
7
10
  import { parseCsd, ddOfQueue } from './csd.mjs';
8
11
  import { loadSite } from './site.mjs';
9
12
  import { ssrangeAbends } from './options.mjs';
@@ -12,7 +15,7 @@ import { eachWithinMemory, watchMemoryBuffer } from './kernel/memory.mjs';
12
15
  // A flow finding is located by source and sink rather than by one path and line, so it keeps its
13
16
  // own comparator. The text comparison underneath it is the shared one.
14
17
  import { byText } from './kernel/findings.mjs';
15
- import { buildControl, creditOf, hasFact } from './control.mjs';
18
+ import { buildControl, creditOf, hasFact, within } from './control.mjs';
16
19
  import { directoryTree } from './kernel/source-tree.mjs';
17
20
 
18
21
  const OS_COMMAND_ROUTINE = /^(SYSTEM|C\$SYSTEM|CBL_EXEC_RUN_UNIT|CBL_GC_HOSTED|BXPSYSTM)$/i;
@@ -51,6 +54,12 @@ export const SINK_KINDS = {
51
54
  'record-key': 'the RIDFLD of EXEC CICS READ, STARTBR or RESETBR, which decides which record is read',
52
55
  'record-update': 'the RIDFLD of a READ UPDATE whose record the program then rewrites or deletes, or of a DELETE',
53
56
  'log': 'what a program in a CICS region writes to a log: DISPLAY, EXEC CICS WRITEQ TD to a queue that neither starts a transaction nor feeds the internal reader, WRITE JOURNALNAME or WRITE OPERATOR',
57
+ 'storage-length': 'how much storage a program acquires: the FLENGTH or LENGTH of EXEC CICS GETMAIN, or the size CALL CEEGTST is given',
58
+ 'xml-document': 'the document XML PARSE reads',
59
+ 'connection-target': 'the database or queue manager a program connects to: the host variable EXEC SQL CONNECT TO or SET CONNECTION names, or the first argument of CALL MQCONN or MQCONNX',
60
+ 'screen': 'what a CICS program shows its terminal: EXEC CICS SEND TEXT FROM, SEND MAP FROM, or the map\'s output record',
61
+ 'cics-sysid': 'the SYSID of an EXEC CICS command, which decides the region the command is shipped to',
62
+ 'cics-system-resource': 'the resource an EXEC CICS SET names, which a system-programming command then changes',
54
63
  };
55
64
 
56
65
  const ARITHMETIC = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE']);
@@ -62,6 +71,7 @@ STANDARD-DEVIATION RANDOM SQRT FACTORIAL LOG LOG10 EXP EXP10 PI E ACOS ASIN ATAN
62
71
  SECONDS-PAST-MIDNIGHT`.split(/\s+/));
63
72
  const computes = (st) => ARITHMETIC.has(st.verb) || st.counts === true || (st.fns || []).some((f) => NUMERIC_FUNCTIONS.has(f));
64
73
  const computedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.why && s.why.computes) return true; return false; };
74
+ const typedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.node.typed) return true; return false; };
65
75
 
66
76
  // Every literal a program holds that could be a program name: its VALUE clauses and the literals its
67
77
  // statements and commands use. A menu that XCTLs through a table of names starts every one of them.
@@ -83,15 +93,6 @@ function lastOption(name, list) {
83
93
  return null;
84
94
  }
85
95
 
86
- // Whether an item's bytes are read as decimal digits: zoned (DISPLAY with a numeric picture) or
87
- // packed. A group, an edited picture, binary and floating point are not.
88
- function holdsDecimal(it) {
89
- if ((it.children || []).some((c) => c.level !== 88)) return false;
90
- const usage = String(it.effectiveUsage || 'DISPLAY').replace('COMPUTATIONAL', 'COMP');
91
- if (usage === 'COMP-3' || usage === 'PACKED-DECIMAL' || usage === 'COMP-6') return true;
92
- return usage === 'DISPLAY' && !!it.picture && /9/.test(it.picture) && /^[S9VP()0-9]+$/i.test(it.picture);
93
- }
94
-
95
96
  const RELATION = new Set(['>', '<', '>=', '<=', '=', '<>']);
96
97
  const RELATION_WORDS = new Set(['GREATER', 'LESS', 'EQUAL']);
97
98
  const FILLER_WORDS = new Set(['IS', 'NOT', 'THAN', 'TO', 'OR', 'GREATER', 'LESS', 'EQUAL', 'OF', 'IN']);
@@ -126,7 +127,7 @@ const IN_CICS = ['cics-web', 'cics-terminal', 'file-record', 'database'];
126
127
  const REFLECTED = ['cics-web', 'cics-terminal'];
127
128
  // What the system says about a failure: a command's RESP, the EIB's copy of it, the SQLCA. Sent to a
128
129
  // web client it describes the system behind the program (CWE-209); written anywhere else it is the
129
- // program's own business, so these reach only a response.
130
+ // program's own business, so these reach only a response, and Db2's message text the terminal too.
130
131
  const SYSTEM_FIELDS = new Set(['EIBRESP', 'EIBRESP2', 'SQLCODE', 'SQLSTATE', 'SQLERRMC']);
131
132
  const TO_CLIENT = ['web-response', 'http-header'];
132
133
 
@@ -172,27 +173,6 @@ function execOptions(exec) {
172
173
  return { opts, words };
173
174
  }
174
175
 
175
- function sqlHostVars(exec) {
176
- const out = [];
177
- for (let i = 0; i < exec.toks.length; i++) {
178
- const t = exec.toks[i];
179
- if (t.t === 'op' && t.v === ':' && exec.toks[i + 1] && exec.toks[i + 1].t === 'word') out.push({ tok: exec.toks[i + 1], at: i + 1 });
180
- }
181
- return out;
182
- }
183
-
184
- // The host variables a SELECT or FETCH fills are the ones in its INTO list, which ends at FROM.
185
- // Taking every variable at or after the INTO line made the input parameters in a WHERE clause read
186
- // as database values — and they are whatever the caller sent, under a rule that says data at rest.
187
- function sqlIntoRange(exec) {
188
- const words = exec.toks.map(t => (t.t === 'word' ? t.u : ''));
189
- const into = words.indexOf('INTO');
190
- if (into < 0) return null;
191
- let end = exec.toks.length;
192
- for (let i = into + 1; i < words.length; i++) if (words[i] === 'FROM') { end = i; break; }
193
- return [into, end];
194
- }
195
-
196
176
  export function analyze(root, opts = {}) {
197
177
  const repos = opts.repos || [''];
198
178
  const stats = { files: 0, programs: 0, edges: 0, nodes: 0, threw: 0, overBudget: 0, unreadable: 0, ebcdic: 0,
@@ -219,11 +199,12 @@ export function analyze(root, opts = {}) {
219
199
  const jclPending = [];
220
200
  // CICS definitions, from CSD extracts and from DFHCSDUP job input, and the estate's own statement
221
201
  // of which DDs and queues reach the internal reader.
222
- const csd = { tdqueues: new Map(), transactions: new Map() };
202
+ const csd = { tdqueues: new Map(), transactions: new Map(), urimaps: new Map() };
223
203
  const addCsd = (text, file, lineBase = 0) => {
224
204
  const c = parseCsd(text);
225
205
  for (const [k, v] of c.tdqueues) if (!csd.tdqueues.has(k)) csd.tdqueues.set(k, v);
226
206
  for (const [k, v] of c.transactions) if (!csd.transactions.has(k)) csd.transactions.set(k, { ...v, file, line: v.line + lineBase });
207
+ for (const [k, v] of c.urimaps) if (!csd.urimaps.has(k)) csd.urimaps.set(k, { ...v, file, line: v.line + lineBase });
227
208
  };
228
209
  const jobSteps = [];
229
210
  // The maps of the repository being read, by MAPSET/MAP and by MAP alone.
@@ -286,8 +267,11 @@ export function analyze(root, opts = {}) {
286
267
  // A system field the program never declares - the EIB's, or an SQLCA the precompiler supplies -
287
268
  // still holds a value, so it needs a node of its own, as an index name does.
288
269
  const systemNodes = new Map();
270
+ // A code on a terminal's error line is ordinary; Db2's message text names tables, columns and
271
+ // constraints, so only SQLERRMC reaches the screen sink.
289
272
  const systemSource = (n, detail, at) => {
290
- if (!n.sources.some((x) => x.kind === 'system-response')) n.sources.push({ kind: 'system-response', onlyTo: TO_CLIENT, noCredit: true, ...at, detail });
273
+ const onlyTo = /^SQLERRMC\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
274
+ if (!n.sources.some((x) => x.kind === 'system-response')) n.sources.push({ kind: 'system-response', onlyTo, noCredit: true, ...at, detail });
291
275
  };
292
276
  const systemNode = (tok) => {
293
277
  let n = systemNodes.get(tok.u);
@@ -332,6 +316,13 @@ export function analyze(root, opts = {}) {
332
316
  if (n) n.sinks.push({ kind: 'log', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `DISPLAY ${n.name}${device}` });
333
317
  }
334
318
  }
319
+ // The document XML PARSE reads is the operand after PARSE; what the parser does with a DTD in
320
+ // it is the compiler's XMLPARSE option, which nothing here reads.
321
+ for (const st of prog.statements) {
322
+ if (st.verb !== 'XML' || !st.sources[0] || st.sources[0].u !== 'PARSE') continue;
323
+ const n = st.sources[1] ? nodeOfToken(st.sources[1]) : null;
324
+ if (n) n.sinks.push({ kind: 'xml-document', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `XML PARSE ${n.name}` });
325
+ }
335
326
  for (const it of prog.items) {
336
327
  const n = nodeOf(it);
337
328
  // A tainted child makes its group partly tainted; a tainted group taints every child. The
@@ -366,7 +357,7 @@ export function analyze(root, opts = {}) {
366
357
  for (const it of prog.items) { const top = recordOf(it); if (!byRecord.has(top)) byRecord.set(top, []); byRecord.get(top).push(it); }
367
358
  const extent = (it) => (it.offset != null && it.size != null ? [it.offset, it.offset + (it.contributes || it.size)] : null);
368
359
  for (const c of ctl.checks) {
369
- const entry = { x: c.x, t: c.t, f: c.f, tCons: c.tCons, fCons: c.fCons, file: c.file, line: c.line, item: c.field.index || c.field.name, ...(c.bounds ? { bounds: c.bounds } : {}) };
360
+ const entry = { x: c.x, t: c.t, f: c.f, tCons: c.tCons, fCons: c.fCons, file: c.file, line: c.line, item: c.field.index || c.field.name, ...(c.bounds ? { bounds: c.bounds } : {}), ...(c.mono ? { mono: true } : {}) };
370
361
  if (c.field.index) { (indexNode(c.field.index).checks ||= []).push(entry); continue; }
371
362
  const span = extent(c.field);
372
363
  for (const it of byRecord.get(recordOf(c.field)) || [c.field]) {
@@ -411,9 +402,16 @@ export function analyze(root, opts = {}) {
411
402
  if (host.size == null || variable(host)) return null;
412
403
  return (host.children || []).some((c) => c.level !== 88) ? host.size / Math.max(1, host.occurs || 1) : host.size;
413
404
  };
405
+ // A whole number no sign is written on, which no value put in it can leave below 0.
406
+ const unsignedWhole = (it) => it.level !== 88 && !it.index && !(it.children || []).length
407
+ && /^9+$/.test(String(it.picture || '').toUpperCase().replace(/(\w)\((\d+)\)/g, (_, ch, n) => ch.repeat(Number(n))));
414
408
  const indexed = new Set();
415
409
  const subscripting = new Map();
410
+ // Each subscript use of a name, with the other indices of the same reference: the loop-bound
411
+ // credit needs every one of them kept in range.
412
+ const usesOf = new Map();
416
413
  for (const st of prog.statements) {
414
+ const refs = new Map();
417
415
  for (const x of st.indexes || []) {
418
416
  const host = itemOfToken(x.host);
419
417
  const idx = itemOfToken(x.tok);
@@ -426,17 +424,34 @@ export function analyze(root, opts = {}) {
426
424
  const key = `${kind}|${host.name}|${at.name}`;
427
425
  if (table && !subscripting.has(at.name)) subscripting.set(at.name, { host, table });
428
426
  const point = placeOf(x.tok, st);
429
- const use = `${key}|${point ?? `s${st.at}`}`;
427
+ if (table) {
428
+ const rec = { at: st.at, node: at, point, limit: limitOf(host, table), offset: x.offset || 0, mates: refs.get(x.host) || (refs.set(x.host, []), refs.get(x.host)) };
429
+ rec.mates.push(rec);
430
+ if (!usesOf.has(at.name)) usesOf.set(at.name, []);
431
+ usesOf.get(at.name).push(rec);
432
+ }
433
+ const offset = x.offset || 0;
434
+ // A constant length past the start, or a constant start before the length, takes that many bytes off the top.
435
+ const base = limitOf(host, table);
436
+ const limit = kind === 'reference-modification' && base != null && x.span ? base - x.span + 1 : base;
437
+ const use = `${key}|${point ?? `s${st.at}`}|${offset}`;
430
438
  if (indexed.has(use)) continue;
431
439
  indexed.add(use);
432
440
  at.sinks.push({
433
- kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit: limitOf(host, table), ...(ssrange ? { ssrange } : {}),
441
+ kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit, ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
434
442
  detail: kind === 'subscript'
435
443
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
436
444
  : `${at.name} sets the ${x.kind === 'refmod-offset' ? 'start' : 'length'} of a reference to ${host.name}, which is ${host.size} bytes`,
437
445
  });
438
446
  }
439
447
  }
448
+ // The subscript uses a loop's body makes; every use of the name when the body can run code
449
+ // elsewhere, and none for a loop whose body is a paragraph.
450
+ const bodyUses = (st, uses) => {
451
+ const body = ctl && ctl.loopBody.get(st);
452
+ if (!body || !uses) return null;
453
+ return body.leaves ? uses : uses.filter((u) => u.at >= body.from && u.at < body.to);
454
+ };
440
455
  // A counter that subscripts a table walks as far as its loop's condition lets it. PERFORM
441
456
  // VARYING I ... UNTIL I > N with N from outside puts the table's bound in the caller's hands, and
442
457
  // no subscript sink sees it: I itself is only ever a literal plus one. The subscript may be
@@ -454,7 +469,7 @@ export function analyze(root, opts = {}) {
454
469
  if (indexed.has(use)) continue;
455
470
  indexed.add(use);
456
471
  nodeOf(it).sinks.push({
457
- kind: 'loop-bound', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|${key}`, limit: limitOf(walks.host, walks.table), ...(ssrange ? { ssrange } : {}),
472
+ kind: 'loop-bound', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|${key}`, limit: limitOf(walks.host, walks.table), counterUses: bodyUses(st, usesOf.get(counter)), ...(ssrange ? { ssrange } : {}),
458
473
  detail: `${it.name} decides where PERFORM VARYING ${counter} stops, and ${counter} subscripts ${walks.host.name}, a table of ${walks.table.occurs}`,
459
474
  });
460
475
  }
@@ -480,14 +495,27 @@ export function analyze(root, opts = {}) {
480
495
  });
481
496
  }
482
497
  }
483
- for (const it of prog.items) {
484
- if (!it.dependingOn) continue;
498
+ // The count matters wherever a statement names the table, a part of it, or a group holding it,
499
+ // so it is judged at each such statement; a table no statement names is judged where it is declared.
500
+ const odoTables = prog.items.filter((it) => it.dependingOn && byName.get(it.dependingOn));
501
+ const touching = new Map(odoTables.map((it) => [it, []]));
502
+ if (odoTables.length) {
503
+ const inside = (a, b) => { for (let x = a; x; x = x.parent) if (x === b) return true; return false; };
504
+ for (const st of prog.statements) {
505
+ const named = [...st.sources, ...st.targets, ...(st.indexes || []).map((x) => x.host)].map(itemOfToken).filter(Boolean);
506
+ for (const it of odoTables) if (named.some((n) => inside(n, it) || inside(it, n))) touching.get(it).push(st);
507
+ }
508
+ }
509
+ for (const it of odoTables) {
485
510
  const count = byName.get(it.dependingOn);
486
- if (!count) continue;
487
- nodeOf(count).sinks.push({
488
- kind: 'occurs-depending-count', onlyFrom: FROM_OUTSIDE, file: it.file, line: it.line, ...(ssrange ? { ssrange } : {}),
489
- detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
490
- });
511
+ const sts = touching.get(it);
512
+ for (const st of sts.length ? sts : [null]) {
513
+ const point = st ? pointOf(st) : undefined;
514
+ nodeOf(count).sinks.push({
515
+ kind: 'occurs-depending-count', onlyFrom: FROM_OUTSIDE, file: it.file, line: it.line, ...(st ? { point, group: `${pk}|odo|${it.name}` } : {}), top: it.occurs, ...(ssrange ? { ssrange } : {}),
516
+ detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
517
+ });
518
+ }
491
519
  }
492
520
  for (const st of prog.statements) {
493
521
  if (st.verb !== 'READ' && st.verb !== 'RETURN') continue;
@@ -572,6 +600,16 @@ export function analyze(root, opts = {}) {
572
600
  const a = c.using[MQ_BUFFER_ARG[String(c.name).toUpperCase()]];
573
601
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
574
602
  if (n) n.sinks.push({ kind: 'message-queue', onlyFrom: DATA_AT_REST, file: c.file, line: c.line, point, detail: `CALL '${c.name}' with buffer ${a.word}` });
603
+ } else if (/^MQCONNX?$/i.test(String(c.name))) {
604
+ // MQCONN(QMgrName, Hconn, CompCode, Reason); MQCONNX puts its options second.
605
+ const a = c.using[0];
606
+ const n = a && a.tok ? nodeOfToken(a.tok) : null;
607
+ if (n) n.sinks.push({ kind: 'connection-target', onlyFrom: FROM_OUTSIDE, file: c.file, line: c.line, point, detail: `CALL '${c.name}' with queue manager ${a.word}, which decides the queue manager the program connects to` });
608
+ } else if (String(c.name).toUpperCase() === 'CEEGTST') {
609
+ // CEEGTST(heap id, size, address, feedback code): the size is the second argument.
610
+ const a = c.using[1];
611
+ const n = a && a.tok ? nodeOfToken(a.tok) : null;
612
+ if (n) n.sinks.push({ kind: 'storage-length', onlyFrom: FROM_OUTSIDE, file: c.file, line: c.line, point, detail: `CALL 'CEEGTST' with size ${a.word}, which decides how much heap storage is acquired` });
575
613
  }
576
614
  }
577
615
  const transfers = [];
@@ -663,14 +701,16 @@ export function analyze(root, opts = {}) {
663
701
  walk(record);
664
702
  for (const field of map.fields) {
665
703
  if (!field.name) continue;
666
- const marks = ['PROT', 'ASKIP', 'DRK', 'NUM'].filter((a) => field.effective.has(a));
667
- if (!marks.length) continue;
668
704
  const iname = symbolicNames(map, field).find((n) => n.suffix === 'I');
669
705
  const item = iname && within.get(iname.name);
670
706
  if (!item) continue;
707
+ const marks = ['PROT', 'ASKIP', 'DRK', 'NUM'].filter((a) => field.effective.has(a));
708
+ const typed = !marks.includes('PROT') && !marks.includes('ASKIP');
709
+ if (typed) nodeOf(item).typed = true;
710
+ if (!marks.length) continue;
671
711
  const n = nodeOf(item);
672
712
  n.screen = { item: item.name, field: field.name, map: map.name, mapset: mapset.name, marks, declared: !!field.attrb };
673
- if (!marks.includes('PROT') && !marks.includes('ASKIP')) continue;
713
+ if (typed) continue;
674
714
  stats.protectedFields++;
675
715
  const back = field.effective.has('FSET') ? ' with FSET' : '';
676
716
  n.sources.push({ kind: 'cics-protected-field', onlyTo: ['record-key', 'record-update'], ...at,
@@ -714,6 +754,14 @@ export function analyze(root, opts = {}) {
714
754
  }
715
755
  if (!isWeb && o.has('MAP')) receiveMap(e, o, itemOfToken(intoTok), at);
716
756
  }
757
+ // What the terminal shows: SEND TEXT's FROM, or the map's output record, <map>O without FROM.
758
+ if (verb === 'SEND' && (words[1] === 'TEXT' || words[1] === 'MAP')) {
759
+ const from = nodeOfToken((o.get('FROM') || [])[0]);
760
+ const map = words[1] === 'MAP' ? optionName(o, 'MAP') : null;
761
+ const sym = !from && map ? byName.get(`${map}O`) : null;
762
+ const n = from || (sym ? nodeOf(sym) : null);
763
+ if (n) n.sinks.push({ kind: 'screen', onlyFrom: ['system-response'], ...at, detail: `EXEC CICS SEND ${words[1]}${map ? `(${map})` : ''}${from ? ` FROM(${n.name})` : ', from its symbolic map'}, to the terminal` });
764
+ }
717
765
  // RIDFLD decides which record a READ or DELETE reaches, or where a browse starts. A key the
718
766
  // user types is how a lookup works; a key the program put in a protected field and read back
719
767
  // is its own state, held where a modified client can change it.
@@ -726,6 +774,12 @@ export function analyze(root, opts = {}) {
726
774
  : `EXEC CICS ${verb} RIDFLD(${k && k.name}), which decides which record it reaches`;
727
775
  if (k) k.sinks.push({ kind: verb === 'DELETE' || then ? 'record-update' : 'record-key', onlyFrom: ['cics-protected-field'], noCredit: true, ...at, detail });
728
776
  }
777
+ if (verb === 'GETMAIN') {
778
+ for (const opt of ['FLENGTH', 'LENGTH']) {
779
+ const n = nodeOfToken((o.get(opt) || [])[0]);
780
+ if (n) n.sinks.push({ kind: 'storage-length', onlyFrom: REFLECTED, ...at, detail: `EXEC CICS GETMAIN ${opt}(${n.name}), which decides how much storage is acquired` });
781
+ }
782
+ }
729
783
  if (verb === 'PUT' && o.has('CONTAINER')) {
730
784
  const name = optionName(o, 'CONTAINER');
731
785
  const from = nodeOfToken((o.get('FROM') || [])[0]);
@@ -794,6 +848,17 @@ export function analyze(root, opts = {}) {
794
848
  if (n) n.sinks.push({ kind: 'queue-name', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${ts ? 'TS' : 'TD'} ${opt}(...) names the queue` });
795
849
  }
796
850
  }
851
+ // SYSID ships a command to the region it names; SET's first option names the resource a
852
+ // system-programming command changes. A literal in either is the program's own choice. ASSIGN,
853
+ // INQUIRE and EXTRACT return SYSID into the field rather than read it.
854
+ if (o.has('SYSID') && !['ASSIGN', 'INQUIRE', 'EXTRACT'].includes(verb)) {
855
+ const n = nodeOfToken((o.get('SYSID') || [])[0]);
856
+ if (n) n.sinks.push({ kind: 'cics-sysid', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} SYSID(${n.name}), which decides the region the command is shipped to` });
857
+ }
858
+ if (verb === 'SET' && words[1] && o.has(words[1])) {
859
+ const n = nodeOfToken((o.get(words[1]) || [])[0]);
860
+ if (n) n.sinks.push({ kind: 'cics-system-resource', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS SET ${words[1]}(${n.name}), which decides the resource the command changes` });
861
+ }
797
862
  if (verb === 'READ' || verb === 'READNEXT' || verb === 'READPREV') {
798
863
  const n = nodeOfToken(intoTok);
799
864
  if (n && (o.has('FILE') || o.has('DATASET'))) n.sources.push({ kind: 'file-record', ...at, detail: `EXEC CICS ${verb} FILE ... INTO` });
@@ -835,15 +900,22 @@ export function analyze(root, opts = {}) {
835
900
  }
836
901
  }
837
902
  if (e.kind === 'SQL') {
838
- // The parser's host variables name the item a qualified :G.F resolves to, where the first
839
- // word after the colon would name the group G and so every field in it.
840
- const hv = e.hostVariables || sqlHostVars(e);
903
+ // A host variable names the item a qualified :G.F resolves to; the statement fills the ones
904
+ // Db2 writes, and those hold what the database held.
905
+ const hv = e.hostVariables || hostVariablesIn(e.toks);
841
906
  const verb = words[0];
842
- const range = e.hostVariables ? null : ['SELECT', 'FETCH'].includes(verb) && sqlIntoRange(e);
843
907
  for (const h of hv) {
844
- if (e.hostVariables ? !h.written : !range || h.at < range[0] || h.at > range[1]) continue;
908
+ if (!h.written) continue;
845
909
  const n = nodeOfToken(h.tok);
846
- if (n) n.sources.push({ kind: 'database', ...at, detail: `EXEC SQL ${verb} INTO host variable` });
910
+ if (n) n.sources.push({ kind: 'database', ...at, detail: `EXEC SQL ${verb} ${verb === 'SELECT' || verb === 'FETCH' ? 'INTO' : 'writes'} host variable` });
911
+ }
912
+ // The location CONNECT TO or SET CONNECTION names, where a host variable holds it; what
913
+ // follows USER and USING is the credential, not the target.
914
+ if (verb === 'CONNECT' || (verb === 'SET' && words[1] === 'CONNECTION')) {
915
+ const to = e.toks.findIndex((t) => t.t === 'word' && (t.u === 'TO' || t.u === 'CONNECTION'));
916
+ const colon = to >= 0 ? e.toks[to + 1] : null;
917
+ const n = colon && colon.t === 'op' && colon.v === ':' && e.toks[to + 2] ? nodeOfToken(e.toks[to + 2]) : null;
918
+ if (n) n.sinks.push({ kind: 'connection-target', onlyFrom: FROM_OUTSIDE, ...at, detail: `EXEC SQL ${verb === 'SET' ? 'SET CONNECTION' : 'CONNECT TO'} :${n.name}, which decides the database the program connects to` });
847
919
  }
848
920
  const dynamic = words.some((w, i) => w === 'PREPARE' || (w === 'EXECUTE' && words[i + 1] === 'IMMEDIATE'));
849
921
  if (dynamic) for (const h of hv) {
@@ -860,7 +932,7 @@ export function analyze(root, opts = {}) {
860
932
  }
861
933
  const commareaItem = byName.get('DFHCOMMAREA');
862
934
  return {
863
- id: prog.id, pk, callSites, transfers, ddFiles, tdWrites,
935
+ id: prog.id, pk, file, callSites, transfers, ddFiles, tdWrites,
864
936
  params: prog.paramTokens ? prog.paramTokens.map(p => {
865
937
  const it = itemOfToken(p.tok);
866
938
  const extent = extentOf(it);
@@ -929,27 +1001,55 @@ export function analyze(root, opts = {}) {
929
1001
 
930
1002
  const byId = new Map();
931
1003
  const idCount = new Map();
1004
+ const allById = new Map();
932
1005
  for (const p of programs) {
933
1006
  if (p.id && !byId.has(p.id)) byId.set(p.id, p);
934
- if (p.id) idCount.set(p.id, (idCount.get(p.id) || 0) + 1);
1007
+ if (p.id) { idCount.set(p.id, (idCount.get(p.id) || 0) + 1); (allById.get(p.id) || allById.set(p.id, []).get(p.id)).push(p); }
935
1008
  }
1009
+ // The programs a CALL or LINK may reach. A program id the repository holds once is that program.
1010
+ // Held several times, the one in the caller's own source file is the one its executable links;
1011
+ // with none there, the holders nearest in the directory tree are, since a copy of a program tree
1012
+ // or a sibling executable's copy is farther than the caller's own build directory. Ties keep every
1013
+ // holder, and the edge says how many candidates there were.
1014
+ const sharedDirs = (a, b) => {
1015
+ const x = a.split(/[\\/]/), y = b.split(/[\\/]/);
1016
+ let n = 0;
1017
+ while (n < x.length - 1 && n < y.length - 1 && x[n] === y[n]) n++;
1018
+ return n;
1019
+ };
1020
+ const calleesOf = (name, fromFile) => {
1021
+ const all = allById.get(name) || [];
1022
+ if (all.length < 2) return { list: all, of: 0 };
1023
+ const here = all.filter((q) => q.file === fromFile);
1024
+ if (here.length) return { list: here, of: 0 };
1025
+ const depth = all.map((q) => sharedDirs(q.file, fromFile));
1026
+ const best = Math.max(...depth);
1027
+ return { list: all.filter((_, i) => depth[i] === best), of: all.length };
1028
+ };
1029
+ const ofN = (r, name) => (r.of ? `, ${r.list.length === r.of ? 'any' : 'nearest'} of ${r.of} programs named ${name}` : '');
936
1030
  const constructs = [];
937
1031
  for (const p of programs) {
938
1032
  for (const t of p.transfers) {
939
- const callee = byId.get(t.callee);
940
- if (!callee || !callee.commarea) continue;
941
- edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}`, undefined, t.point);
942
- if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}`);
1033
+ const r = calleesOf(t.callee, p.file);
1034
+ for (const callee of r.list) {
1035
+ if (!callee.commarea) continue;
1036
+ 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);
1037
+ if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`);
1038
+ }
943
1039
  }
944
1040
  for (const c of p.callSites) {
945
- const callee = byId.get(c.name);
1041
+ const r = calleesOf(c.name, p.file);
1042
+ for (const callee of r.list) {
1043
+ if (!callee.params) continue;
1044
+ c.args.forEach((arg, i) => {
1045
+ const param = callee.params[i];
1046
+ if (!param || !arg.node || !param.node) return;
1047
+ edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point);
1048
+ if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`);
1049
+ });
1050
+ }
1051
+ const callee = r.list.length === 1 ? r.list[0] : null;
946
1052
  if (!callee || !callee.params) continue;
947
- c.args.forEach((arg, i) => {
948
- const param = callee.params[i];
949
- if (!param || !arg.node || !param.node) return;
950
- edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}`, undefined, c.point);
951
- if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back`);
952
- });
953
1053
  // A callee that declares more bytes than its caller passes reads and writes whatever follows
954
1054
  // the argument in the caller's storage. Measured over 8,640 bound pairs in 125 repositories:
955
1055
  // where the extra bytes stay inside the caller's own record they are its neighbouring fields,
@@ -1001,53 +1101,60 @@ export function analyze(root, opts = {}) {
1001
1101
  for (const dd of step.dds) if (dd.name && dd.name.toUpperCase() === 'SYSIN' && dd.inStream) addCsd(dd.inStream.map((l) => l.text).join('\n'), f, dd.inStream[0].line - 1);
1002
1102
  }
1003
1103
  jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: step.line, pgm: step.pgm.toUpperCase() });
1004
- // A TSO batch step starts a program by name in its commands: DSN ... RUN PROGRAM(X).
1005
- if (/^IKJEFT(01|1A|1B)$/i.test(step.pgm)) {
1006
- for (const dd of step.dds) for (const l of dd.inStream || []) {
1007
- const run = /\bRUN\s+PROGRAM\s*\(\s*([A-Z0-9$#@]{1,8})\s*\)/i.exec(l.text);
1008
- if (run) jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: l.line, pgm: run[1].toUpperCase() });
1009
- }
1010
- }
1011
- const callee = byId.get(step.pgm.toUpperCase());
1012
- if (!callee) continue; // a system utility, or a program not in this tree
1013
- stats.jclStepsResolved++;
1014
-
1015
- // PARM arrives in the first PROCEDURE DIVISION USING item: on z/OS a halfword length
1016
- // followed by the text. A program with no USING cannot receive one, and saying it does
1017
- // would be a path nobody could follow.
1018
- if (step.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1019
- callee.params[0].node.sources.push({
1020
- kind: 'jcl-parm', file: f, line: step.line,
1021
- detail: `PARM= on step ${step.name || '(unnamed)'} of ${rel(f)}, which runs ${step.pgm}`,
1022
- });
1023
- stats.jclCrossings++;
1104
+ // A TSO batch step starts programs by name in its commands, TSO CALL and DSN RUN PROGRAM, and
1105
+ // each is handed the step's DDs and its own parameter as EXEC PGM= would be.
1106
+ const runs = [{ pgm: step.pgm.toUpperCase(), parm: step.parm, line: step.line, how: `PARM= on step ${step.name || '(unnamed)'} of ${rel(f)}` }];
1107
+ for (const r of tsoCommands(step).runs) {
1108
+ jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: r.line, pgm: r.program });
1109
+ runs.push({ pgm: r.program, parm: r.parm, line: r.line, how: `the parameter ${r.via} passes in step ${step.name || '(unnamed)'} of ${rel(f)}` });
1024
1110
  }
1111
+ for (const run of runs) {
1112
+ const holders = allById.get(run.pgm) || [];
1113
+ if (!holders.length) continue; // a system utility, or a program not in this tree
1114
+ stats.jclStepsResolved++;
1115
+ // A program id held several times has no caller file to choose by, so each holder is a candidate.
1116
+ const amb = holders.length > 1 ? `, any of ${holders.length} programs named ${run.pgm}` : '';
1117
+ for (const callee of holders) {
1025
1118
 
1026
- // In-stream data reaches whatever record the program reads from that DD. The COBOL says
1027
- // ASSIGN TO SYSIN and the job says //SYSIN DD *; neither half names the other, and the
1028
- // join is the whole point of reading both.
1029
- for (const dd of step.dds) {
1030
- // The same join in the other direction: what the program writes through SELECT ... ASSIGN
1031
- // TO a DD the job sends to the internal reader is submitted as a job.
1032
- if (dd.name && dd.sysout && /\bINTRDR\b/i.test(dd.sysout)) {
1033
- for (const m of callee.ddFiles || []) {
1034
- if (m.dd !== dd.name.toUpperCase()) continue;
1035
- for (const n of m.nodes) {
1036
- n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1037
- detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader` });
1038
- }
1039
- }
1040
- }
1041
- if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1042
- for (const m of callee.ddFiles || []) {
1043
- if (m.dd !== dd.name.toUpperCase()) continue;
1044
- for (const n of m.nodes) {
1045
- n.sources.push({
1046
- kind: 'jcl-instream', file: f, line: dd.line,
1047
- detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}`,
1119
+ // PARM arrives in the first PROCEDURE DIVISION USING item: on z/OS a halfword length
1120
+ // followed by the text. A program with no USING cannot receive one, and saying it does
1121
+ // would be a path nobody could follow.
1122
+ if (run.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1123
+ callee.params[0].node.sources.push({
1124
+ kind: 'jcl-parm', file: f, line: run.line,
1125
+ detail: `${run.how}, which runs ${run.pgm}${amb}`,
1048
1126
  });
1049
1127
  stats.jclCrossings++;
1050
1128
  }
1129
+
1130
+ // In-stream data reaches whatever record the program reads from that DD. The COBOL says
1131
+ // ASSIGN TO SYSIN and the job says //SYSIN DD *; neither half names the other, and the
1132
+ // join is the whole point of reading both.
1133
+ for (const dd of step.dds) {
1134
+ // The same join in the other direction: what the program writes through SELECT ... ASSIGN
1135
+ // TO a DD the job sends to the internal reader is submitted as a job.
1136
+ if (dd.name && dd.sysout && /\bINTRDR\b/i.test(dd.sysout)) {
1137
+ for (const m of callee.ddFiles || []) {
1138
+ if (m.dd !== dd.name.toUpperCase()) continue;
1139
+ for (const n of m.nodes) {
1140
+ n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1141
+ detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader${amb}` });
1142
+ }
1143
+ }
1144
+ }
1145
+ if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1146
+ for (const m of callee.ddFiles || []) {
1147
+ if (m.dd !== dd.name.toUpperCase()) continue;
1148
+ for (const n of m.nodes) {
1149
+ n.sources.push({
1150
+ kind: 'jcl-instream', file: f, line: dd.line,
1151
+ detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1152
+ });
1153
+ stats.jclCrossings++;
1154
+ }
1155
+ }
1156
+
1157
+ }
1051
1158
  }
1052
1159
  }
1053
1160
  }
@@ -1059,6 +1166,12 @@ export function analyze(root, opts = {}) {
1059
1166
  // in this tree reaches.
1060
1167
  const roots = [];
1061
1168
  for (const [name, t] of csd.transactions) if (t.program && byId.has(t.program)) roots.push({ transaction: name, file: rel(t.file), line: t.line, program: t.program });
1169
+ // A URI map serves its program to an HTTP request under an alias transaction, CWBA unless it names
1170
+ // one, and that transaction is what the region checks the caller may start.
1171
+ for (const [name, u] of csd.urimaps) {
1172
+ if ((u.usage && u.usage !== 'SERVER') || !u.program || !byId.has(u.program)) continue;
1173
+ roots.push({ transaction: u.transaction || 'CWBA', urimap: name, ...(u.path ? { uri: u.path } : {}), file: rel(u.file), line: u.line, program: u.program });
1174
+ }
1062
1175
  for (const j of jobSteps) if (byId.has(j.pgm)) roots.push({ job: j.job, step: j.step, file: rel(j.file), line: j.line, program: j.pgm });
1063
1176
  const STARTED_BY_LISTED = 8;
1064
1177
  for (const r of roots) {
@@ -1162,25 +1275,45 @@ export function analyze(root, opts = {}) {
1162
1275
  const moves = (e) => !(typeof e.why === 'string' && STRUCTURAL.has(e.why));
1163
1276
  const NONE = { level: 0, check: null };
1164
1277
  // `limit` is how many entries or bytes the sink's index may name, where one number says it.
1165
- function creditAt(n, point, kind, limit = null) {
1278
+ function creditAt(n, point, kind, limit = null, offset = 0, nonNeg = false) {
1166
1279
  const p = programs[n.pk];
1167
1280
  if (!p || !p.ordered) return n.guard ? { level: 1, check: n.guard } : NONE;
1168
1281
  if (!n.checks || point == null) return NONE;
1169
1282
  const bits = p.facts.get(point);
1170
- return bits ? creditOf(n.checks, bits, kind, limit) : NONE;
1283
+ return bits ? creditOf(n.checks, bits, kind, limit, offset, nonNeg) : NONE;
1284
+ }
1285
+ // A loop bound cannot push its counter out of the table when the loop's own condition or the
1286
+ // body's checks keep every subscript the counter makes, and each index beside it, in range.
1287
+ // That holds whatever the bound is, so it does not depend on the route the input took.
1288
+ function counterCredit(sink) {
1289
+ const uses = sink.kind === 'loop-bound' ? sink.counterUses : null;
1290
+ if (!uses || !uses.length) return null;
1291
+ let first = null;
1292
+ for (const u of uses) {
1293
+ for (const m of u.mates) {
1294
+ const c = creditAt(m.node, m.point, 'subscript', m.limit, m.offset || 0);
1295
+ if (c.level < 2 || m.limit == null || !within(c.cons, m.limit)) return null;
1296
+ if (m === u && !first) first = { credit: c, node: m.node };
1297
+ }
1298
+ }
1299
+ return first;
1171
1300
  }
1172
1301
  // The best credit on a route. Each node is judged at the statement its value leaves by, which for
1173
1302
  // the last is the sink's own. A structural hop - into a group, across a REDEFINES - moves no value
1174
1303
  // in time, so the node before it is judged where the value next moves.
1175
1304
  function routeCredit(hops, sink, src) {
1176
1305
  if (sink.noCredit || (src && src.noCredit)) return { best: NONE, by: null, missed: null };
1306
+ const own = counterCredit(sink);
1307
+ if (own) return { best: own.credit, by: own.node, missed: null, own: true };
1177
1308
  let best = NONE;
1178
1309
  let by = null;
1179
1310
  let missed = null;
1180
1311
  let point = sink.point;
1181
1312
  for (let h = hops.length - 1; h >= 0; h--) {
1182
1313
  const n = hops[h].node;
1183
- const c = creditAt(n, point, sink.kind, sink.limit);
1314
+ let c = creditAt(n, point, sink.kind, sink.limit, sink.offset, sink.nonNeg);
1315
+ // A count checked against a number above the table's maximum has been checked, not kept in range.
1316
+ if (c.level === 2 && sink.top != null && !within(c.cons, sink.top, false)) c = { level: 1, check: c.check };
1184
1317
  if (c.level > best.level) { best = c; by = n; }
1185
1318
  const any = (n.checks && n.checks.find((c) => c.x != null)) || n.guard;
1186
1319
  if (!c.level && any && !missed) missed = { node: n, check: any };
@@ -1191,16 +1324,16 @@ export function analyze(root, opts = {}) {
1191
1324
  // The credited findings of one source whose sink can be reached without the credit: walking from
1192
1325
  // the source, a value may not leave a node - or any node it reached by a structural hop since it
1193
1326
  // last moved - at a point where the credit reaches `level`.
1194
- function refuse(start, reach, credited, level, kind) {
1327
+ function refuse(start, reach, credited, level, kind, shift = 0) {
1195
1328
  walk++;
1196
- const want = credited.filter((c) => c.level >= level && c.sink.kind === kind);
1329
+ const want = credited.filter((c) => c.level >= level && c.sink.kind === kind && (c.sink.offset || 0) === shift);
1197
1330
  // Past the budget no route can be ruled out, so every credit in question is lost: a finding
1198
1331
  // may be reported unchecked, never dropped.
1199
1332
  if (edgesWalked >= totalBudget) { stats.creditUndecided++; return new Set(want); }
1200
1333
  const at = new Map();
1201
1334
  for (const c of want) { if (!at.has(c.node.id)) at.set(c.node.id, []); at.get(c.node.id).push(c); }
1202
1335
  // Along the way no sink's limit applies, so a bound that holds only against one blocks nothing.
1203
- const holds = (since, point, k, limit = null) => since.some((n) => creditAt(n, point, k, limit).level >= level);
1336
+ const holds = (since, point, k, limit = null, offset = 0, nonNeg = false) => since.some((n) => creditAt(n, point, k, limit, offset, nonNeg).level >= level);
1204
1337
  const lost = new Set();
1205
1338
  const dirty = new Map();
1206
1339
  const parts = new Map();
@@ -1209,13 +1342,13 @@ export function analyze(root, opts = {}) {
1209
1342
  const began = edgesWalked;
1210
1343
  for (let i = 0; i < open.length; i++) {
1211
1344
  const st = open[i];
1212
- for (const c of at.get(st.node.id) || []) if (!holds(st.since, c.sink.point, c.sink.kind, c.sink.limit)) lost.add(c);
1345
+ for (const c of at.get(st.node.id) || []) if (!holds(st.since, c.sink.point, c.sink.kind, c.sink.limit, c.sink.offset, c.sink.nonNeg)) lost.add(c);
1213
1346
  for (const e of st.node.edgesOut) {
1214
1347
  edgesWalked++;
1215
1348
  const to = e.to;
1216
1349
  if (!(canReach[to.id] & reach)) continue;
1217
1350
  const mv = moves(e);
1218
- if (mv && holds(st.since, pointOfEdge(e), kind)) continue;
1351
+ if (mv && holds(st.since, pointOfEdge(e), kind, null, shift)) continue;
1219
1352
  const t = cross(st, e);
1220
1353
  if (!t) continue;
1221
1354
  const since = mv ? [to] : [...st.since, to];
@@ -1341,6 +1474,9 @@ export function analyze(root, opts = {}) {
1341
1474
  // arithmetic or a numeric function carries no bad bytes to the next one. Only this route is
1342
1475
  // dropped: a later one that copies the bytes still reports.
1343
1476
  if (sink.kind === 'arithmetic' && computedOnRoute(state)) continue;
1477
+ // A protected field's value carried through a field the terminal may type into reaches the
1478
+ // key as typed input, which the user could have entered anyway: the protection was no control.
1479
+ if (src.kind === 'cics-protected-field' && typedOnRoute(state)) continue;
1344
1480
  seen.add(key);
1345
1481
  // The ends of a long path are what a reader uses: where the value came from, and what it
1346
1482
  // reached. Keeping every hop of a thousand-hop chain, for a thousand findings, is the
@@ -1354,7 +1490,7 @@ export function analyze(root, opts = {}) {
1354
1490
  : [...hops.slice(0, TRACE_KEEP).map(hop),
1355
1491
  { program: null, item: null, file: null, via: `… ${hops.length - TRACE_KEEP * 2} hops not listed`, elided: hops.length - TRACE_KEEP * 2 },
1356
1492
  ...hops.slice(-TRACE_KEEP).map(hop)];
1357
- const { best, by, missed } = routeCredit(hops, sink, src);
1493
+ const { best, by, missed, own } = routeCredit(hops, sink, src);
1358
1494
  const screen = hops.find((h) => h.node.screen)?.node.screen;
1359
1495
  findings.push({
1360
1496
  rule: `${src.kind}-to-${sink.kind}`,
@@ -1370,7 +1506,7 @@ export function analyze(root, opts = {}) {
1370
1506
  ...(best.level ? { guard: { program: by.program, item: by.name, file: rel(best.check.file), line: best.check.line, ...(best.level === 2 ? { stops: true } : {}) } } : {}),
1371
1507
  ...(!best.level && missed ? { checkElsewhere: { program: missed.node.program, item: missed.node.name, file: rel(missed.check.file), line: missed.check.line } } : {}),
1372
1508
  });
1373
- if (best.level) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1509
+ if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1374
1510
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1375
1511
  }
1376
1512
  for (const e of cur.edgesOut) {
@@ -1399,16 +1535,23 @@ export function analyze(root, opts = {}) {
1399
1535
  // cross() like the first walk, so it cannot take a route the first would refuse.
1400
1536
  if (credited.length) {
1401
1537
  // Per kind of sink, because what a check leaves may stop one kind and say nothing to another.
1538
+ // And per shift, because a value that stops a bare index does not stop one moved down by a constant.
1539
+ const shiftsOf = (list, kind) => new Set(list.filter((c) => c.sink.kind === kind).map((c) => c.sink.offset || 0));
1402
1540
  for (const kind of new Set(credited.map((c) => c.sink.kind))) {
1403
- for (const c of refuse(start, reach, credited, 1, kind)) {
1404
- const g = c.finding.guard;
1405
- delete c.finding.guard;
1406
- c.finding.checkElsewhere = { program: g.program, item: g.item, file: g.file, line: g.line };
1407
- c.level = 0;
1541
+ for (const shift of shiftsOf(credited, kind)) {
1542
+ for (const c of refuse(start, reach, credited, 1, kind, shift)) {
1543
+ const g = c.finding.guard;
1544
+ delete c.finding.guard;
1545
+ c.finding.checkElsewhere = { program: g.program, item: g.item, file: g.file, line: g.line };
1546
+ c.level = 0;
1547
+ }
1408
1548
  }
1409
1549
  }
1410
1550
  for (const kind of new Set(credited.filter((c) => c.level === 2).map((c) => c.sink.kind))) {
1411
- for (const c of refuse(start, reach, credited, 2, kind)) if (c.level === 2) { delete c.finding.guard.stops; c.level = 1; }
1551
+ const two = credited.filter((c) => c.level === 2);
1552
+ for (const shift of shiftsOf(two, kind)) {
1553
+ for (const c of refuse(start, reach, credited, 2, kind, shift)) if (c.level === 2) { delete c.finding.guard.stops; c.level = 1; }
1554
+ }
1412
1555
  }
1413
1556
  }
1414
1557
  }
@@ -1423,6 +1566,13 @@ export function analyze(root, opts = {}) {
1423
1566
  if (!k || rank(f) < rank(k) || (rank(f) === rank(k) && (byText(f.sink.file, k.sink.file) || f.sink.line - k.sink.line) < 0)) kept.set(key, f);
1424
1567
  }
1425
1568
  const keep = new Set(kept.values());
1569
+ // The uses left out are named on the one kept, with how far each is checked, so a reader can see them.
1570
+ const level = (f) => (f.guard ? (f.guard.stops ? 2 : 1) : 0);
1571
+ for (const [f, key] of useGroup) {
1572
+ if (keep.has(f)) continue;
1573
+ const k = kept.get(key);
1574
+ (k.also ||= []).push({ file: f.sink.file, line: f.sink.line, program: f.sink.program, detail: f.sink.detail, level: level(f), unreached: !!f.unreached, ...(f.guard ? { guardLine: f.guard.line } : {}) });
1575
+ }
1426
1576
  let w = 0;
1427
1577
  for (const f of findings) if (!useGroup.has(f) || keep.has(f)) findings[w++] = f;
1428
1578
  findings.length = w;
@@ -1455,7 +1605,7 @@ export function analyze(root, opts = {}) {
1455
1605
  }
1456
1606
  }
1457
1607
  for (const c of constructs) { Object.assign(c, startedByOf(c.pk)); delete c.pk; }
1458
- stats.entryPoints = { transactions: csd.transactions.size, jobSteps: jobSteps.length, roots: roots.length, programsStarted: programs.filter((p) => p.startedBy).length, programsWithoutEntry: unstarted.length };
1608
+ stats.entryPoints = { transactions: csd.transactions.size, uriMaps: csd.urimaps.size, jobSteps: jobSteps.length, roots: roots.length, programsStarted: programs.filter((p) => p.startedBy).length, programsWithoutEntry: unstarted.length };
1459
1609
  const startedBy = {};
1460
1610
  for (const p of programs) if (p.id && p.startedBy && !startedBy[p.id]) startedBy[p.id] = p.startedBy;
1461
1611
  return { findings, constructs, unstarted, startedBy, stats, sourceKinds: SOURCE_KINDS, sinkKinds: SINK_KINDS, ...(sinks ? { sinks } : {}), ...(sources ? { sources } : {}) };