@portll/cobolwork 0.4.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.
package/README.md CHANGED
@@ -63,6 +63,13 @@ request and writes SARIF for code scanning: [docs/github-action.md](docs/github-
63
63
  | JCL | credentials and security commands in in-stream data, destructive statements, `DLM=` tricks, FTP in cleartext or sending production data, production data touched by a test job |
64
64
  | The source | names nothing declares (code that cannot compile), shadowed copybooks, payloads hidden in columns 73-80 or aimed at AI readers |
65
65
  | The estate | production names outside production jobs, routable addresses, compiler and runtime versions with published advisories |
66
+ | Assembler | a switch to key zero or supervisor state, an instruction run through `EX`, cross-memory calls, the security product called directly, and the CSECT or ENTRY a COBOL `CALL` or a job step reaches |
67
+ | Cryptography | a single-length DES key, an MD5 or SHA-1 hash, or a fixed initialization vector asked of ICSF, read from IBM's parameter lists; an outbound CICS connection asking for HTTP |
68
+
69
+ HLASM is read, not assembled: its statements are read from the cards and its operations looked up
70
+ in a table that cites the IBM manual for each (`rules/hlasm-operations.json`). Macros are not
71
+ expanded and conditional assembly is not evaluated, so an operation a site macro issues is seen
72
+ where the macro is defined, and not where it is used.
66
73
 
67
74
  [docs/rule-sets.md](docs/rule-sets.md) describes each in full, with what it deliberately leaves out.
68
75
  Vendor packs for CA ACF2 and Top Secret, Control-M and Connect:Direct load only for estates that
@@ -107,6 +114,15 @@ that caller's storage. A value the subprogram keeps in its own storage can reach
107
114
  Findings carry no source text, so a report can be stored and passed on without carrying the code
108
115
  with it.
109
116
 
117
+ `trace` is one route, the shortest, and sources that reach one sink are merged into one finding,
118
+ counted in `sources`. With `--all-routes`, `scan` and `flow` give each path finding `routes`: every
119
+ source, and every statement on any route from one of them to the sink, by verb, file and line.
120
+ `complete` is false where the walk stopped at its budget or a step has no statement the engine can
121
+ place. It leaves out no statement on a route the engine follows, and may name one no route takes:
122
+ what a reader needs before saying a run covered every route (`docs/spec/reach.md` §9.8). Routes
123
+ through what the engine does not read, such as an unparsed program or a caller written in another
124
+ language, are not in it.
125
+
110
126
  `fingerprint` is what the finding is, rather than where it is printed today: the rule, the program
111
127
  and the paragraph or section it sits in (the job, step and DD for JCL), and the flagged statement's
112
128
  own text. No line number goes into it, so code added above a finding does not change it. `diff`
package/bin/cobolwork.mjs CHANGED
@@ -80,6 +80,7 @@ Options
80
80
  --rules-path gitleaks print the path of the mainframe credential rules and exit
81
81
  --quiet summary only
82
82
  --full-trace list every hop of a data flow path, not its two ends
83
+ --all-routes give each data flow finding every statement on any route from its sources (scan, flow)
83
84
  --base <ref> diff: the git revision to compare against
84
85
  --head <ref> diff: the revision under review (default: the working tree)
85
86
  --baseline <file> judgements to apply; without it, ${BASELINE_FILE} in the scanned tree
@@ -145,6 +146,7 @@ function parseArgs(argv) {
145
146
  else if (a === '--rules-path') opts.rulesPath = argv[++i] || 'gitleaks';
146
147
  else if (a === '--quiet') opts.quiet = true;
147
148
  else if (a === '--full-trace') opts.fullTrace = true;
149
+ else if (a === '--all-routes') opts.allRoutes = true;
148
150
  else if (a === '--base') opts.base = value();
149
151
  else if (a === '--head') opts.head = value();
150
152
  else if (a === '--baseline') opts.baseline = value();
@@ -250,6 +252,10 @@ if (opts.fullTrace && opts._.length && !TRACE_COMMANDS.includes(opts._[0])) {
250
252
  `);
251
253
  process.exit(2);
252
254
  }
255
+ if (opts.allRoutes && opts._.length && !['scan', 'flow'].includes(opts._[0])) {
256
+ process.stderr.write(`cobolwork: --all-routes is for scan and flow\n`);
257
+ process.exit(2);
258
+ }
253
259
  const ADVISORY_COMMANDS = ['scan', 'baseline', 'tui', 'explain', 'build'];
254
260
  if (opts.advisoryFeeds && opts._.length && !ADVISORY_COMMANDS.includes(opts._[0])) {
255
261
  process.stderr.write(`cobolwork: ${opts._[0]} reads no advisories; ${ADVISORY_COMMANDS.join(', ')} do\n`);
@@ -340,7 +346,7 @@ try {
340
346
  } else if (command === 'evidence') {
341
347
  process.exitCode = evidenceCommand(target, opts, { toolVersion: VERSION, write: (s) => process.stdout.write(s) });
342
348
  } else if (command === 'scan' || command === 'flow') {
343
- const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
349
+ const flowOpts = { repos, fullTrace: opts.fullTrace === true, allRoutes: opts.allRoutes === true, systemDirs };
344
350
  // The site file sits beside the members and is not one of them, so it is named rather than found.
345
351
  const site = resolve(root, SITE_FILE);
346
352
  const pds = opts.pdsExport ? pdsExportTree(root, { systemDirs }) : null;
package/lib/bms.mjs CHANGED
@@ -55,6 +55,13 @@ const sublist = (v) => {
55
55
  return (inner ? splitOperands(inner[1]) : [v]).map((x) => x.trim()).filter(Boolean);
56
56
  };
57
57
 
58
+ // L'NAME is NAME's length, and T', S', I', K', N', D' and O' its other attributes: the quote after the
59
+ // letter opens no string when the letter starts a term and a symbol follows. CL8'TEXT' and D'1.5'
60
+ // are constants, and their quotes do open one.
61
+ // https://www.ibm.com/docs/en/hla-and-tf/1.6.0?topic=instructions-data-attributes
62
+ const attributeReference = (text, i) => /[LTSIKNDO]/i.test(text[i - 1] || '')
63
+ && !/[A-Z0-9$#@_]/i.test(text[i - 2] || '') && /[A-Z$#@_&]/i.test(text[i + 1] || '');
64
+
58
65
  // Folds physical lines into statements. A map continues a statement in two ways, often both in one
59
66
  // file. An operand that fills the line to column 71 carries on in column 16 of the next line, which
60
67
  // is how a long INITIAL literal is split. A comma followed by a blank ends the line's operands and
@@ -78,6 +85,7 @@ export function foldStatements(src) {
78
85
  for (let i = from; i < text.length; i++) {
79
86
  const c = text[i];
80
87
  if (o.quoted) { o.st.field += c; if (c === "'") o.quoted = false; continue; }
88
+ if (c === "'" && attributeReference(text, i)) { o.st.field += c; continue; }
81
89
  if (c === "'") { o.quoted = true; o.st.field += c; continue; }
82
90
  if (c === ' ' || c === '\t') {
83
91
  o.reading = o.st.field.endsWith(',');
@@ -17,8 +17,8 @@ export const CAPABILITIES_SCHEMA_VERSION = 1;
17
17
 
18
18
  // Each command, what it takes and what it writes.
19
19
  export const COMMANDS = {
20
- scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
21
- flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
20
+ scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--all-routes', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
21
+ flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--all-routes', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
22
22
  inventory: { args: ['<path>'], options: ['--out', '--quiet', '--copylib', '--evidence'], documents: ['cobolwork-inventory'] },
23
23
  parse: { args: ['<file>'], options: ['--out'], documents: ['cobolwork-parse'] },
24
24
  sbom: { args: ['<path>'], options: ['--name', '--out', '--quiet', '--copylib', '--evidence'], documents: ['cyclonedx'] },
@@ -124,6 +124,19 @@ const RULE_CLASSES = {
124
124
  'input-causes-abend': [],
125
125
  'input-causes-abend-s0c4': [MUTATES],
126
126
  'input-causes-abend-subscript-range': [MUTATES],
127
+ // Key zero or supervisor state is the operating system's authority, taken by the code itself.
128
+ 'hlasm-supervisor-state-change': [ESCALATES],
129
+ // Nothing says input reaches EX's register, or which address space a cross-memory call reaches:
130
+ // the linkage tables grant that, and the source does not show them. Severity decides.
131
+ 'hlasm-executes-built-instruction': [],
132
+ 'hlasm-cross-memory-service': [],
133
+ // Disclosure in transit; severity decides.
134
+ 'web-client-opens-cleartext': [],
135
+ 'web-receive-length-exceeds-area': [MUTATES],
136
+ // Weak cryptography discloses or lets data be changed undetected; severity decides.
137
+ 'icsf-single-length-des-key': [],
138
+ 'icsf-weak-hash': [],
139
+ 'icsf-fixed-initialization-vector': [],
127
140
  };
128
141
 
129
142
  // Coverage and context assert no defect, so they carry no consequence.
@@ -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
@@ -189,6 +189,9 @@ export function analyze(root, opts = {}) {
189
189
  // Every hop of every trace, for the reader who is following one chain rather than counting
190
190
  // findings. Off by default: a chain-shaped program turns this quadratic in the chain length.
191
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;
192
195
  let held = 0;
193
196
 
194
197
  // Which files, not just how many. A count names nothing to go and look at, and it cannot tell
@@ -216,7 +219,8 @@ export function analyze(root, opts = {}) {
216
219
  const rel = (p) => { let r = relOf.get(p); if (r === undefined) { r = relPath(root, p); relOf.set(p, r); } return r; };
217
220
  // `at` is the statement an edge that is not a statement's own is read at: the CALL or LINK that
218
221
  // passes an argument, the PUT or GET of a container.
219
- // `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }).
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.
220
224
  const edge = (a, b, why, dir, at, link) => {
221
225
  if (!a || !b || a === b) return;
222
226
  a.edgesOut.push({ to: b, why, dir, ...(at != null ? { at } : {}), ...link });
@@ -797,7 +801,7 @@ export function analyze(root, opts = {}) {
797
801
  if (verb === 'PUT' && o.has('CONTAINER')) {
798
802
  const name = optionName(o, 'CONTAINER');
799
803
  const from = nodeOfToken((o.get('FROM') || [])[0]);
800
- 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 } });
801
805
  if (name) channelOf.set(name, optionName(o, 'CHANNEL'));
802
806
  }
803
807
  // A service call sends every container on its channel, and its URI decides where they go.
@@ -811,7 +815,7 @@ export function analyze(root, opts = {}) {
811
815
  if (verb === 'GET' && o.has('CONTAINER')) {
812
816
  const name = optionName(o, 'CONTAINER');
813
817
  const into = nodeOfToken((o.get('INTO') || o.get('SET') || [])[0]);
814
- 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 } });
815
819
  }
816
820
  // CONVERSE is always a client call; SEND is one only on a session this program opened. A
817
821
  // plain WEB SEND answers the request that started the program, which is its job.
@@ -901,9 +905,9 @@ export function analyze(root, opts = {}) {
901
905
  }
902
906
  const commarea = (o.get('COMMAREA') || o.get('FROM') || [])[0];
903
907
  const argNode = commarea ? nodeOfToken(commarea) : null;
904
- 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 });
905
909
  else if (pgm && pgm.t === 'word' && argNode && o.has('PROGRAM')) {
906
- 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); }
907
911
  }
908
912
  if (pgm && pgm.t === 'lit') (o.has('TRANSID') && !o.has('PROGRAM') ? startsTransactions : transfersTo).add(String(pgm.v).trim().toUpperCase());
909
913
  }
@@ -1046,22 +1050,24 @@ export function analyze(root, opts = {}) {
1046
1050
  for (const t of p.transfers) {
1047
1051
  const r = calleesOf(t.callee, p.file);
1048
1052
  const site = ++callSites;
1053
+ const stmt = { verb: `EXEC CICS ${t.verb}`, file: rel(t.file), line: t.line };
1049
1054
  for (const callee of r.list) {
1050
1055
  if (!callee.commarea) continue;
1051
- 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 });
1052
- if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`, undefined, undefined, { ret: site });
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 });
1053
1058
  }
1054
1059
  }
1055
1060
  for (const c of p.callSites) {
1056
1061
  const r = calleesOf(c.name, p.file);
1057
1062
  const site = ++callSites;
1063
+ const stmt = { verb: 'CALL', file: rel(c.file), line: c.line };
1058
1064
  for (const callee of r.list) {
1059
1065
  if (!callee.params) continue;
1060
1066
  c.args.forEach((arg, i) => {
1061
1067
  const param = callee.params[i];
1062
1068
  if (!param || !arg.node || !param.node) return;
1063
- 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 });
1064
- 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 });
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 });
1065
1071
  });
1066
1072
  }
1067
1073
  const callee = r.list.length === 1 ? r.list[0] : null;
@@ -1498,6 +1504,44 @@ export function analyze(root, opts = {}) {
1498
1504
  }
1499
1505
  return { partial, lo, hi, vague, ctx };
1500
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
+ }
1544
+ }
1501
1545
  for (const start of nodes) {
1502
1546
  if (!start.sources.length || !canReach[start.id]) continue;
1503
1547
  for (const src of start.sources) {
@@ -1515,6 +1559,8 @@ export function analyze(root, opts = {}) {
1515
1559
  // without a layout) partial taint does not descend at all.
1516
1560
  const queue = [{ node: start, partial: false, lo: 0, hi: 0, vague: false, prev: null, why: 'source' }];
1517
1561
  const partSeen = new Map();
1562
+ const crossedEdges = allRoutes ? [] : null;
1563
+ const awaiting = allRoutes ? [] : null;
1518
1564
  stampWhole[start.id] = walk;
1519
1565
  let cut = false;
1520
1566
  const began = edgesWalked;
@@ -1563,6 +1609,7 @@ export function analyze(root, opts = {}) {
1563
1609
  ...(best.level ? { guard: { program: by.program, item: by.name, file: rel(best.check.file), line: best.check.line, ...(best.level === 2 ? { stops: true } : {}) } } : {}),
1564
1610
  ...(!best.level && missed ? { checkElsewhere: { program: missed.node.program, item: missed.node.name, file: rel(missed.check.file), line: missed.check.line } } : {}),
1565
1611
  });
1612
+ if (awaiting) awaiting.push({ finding: findings[findings.length - 1], at: cur.id });
1566
1613
  if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1567
1614
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1568
1615
  }
@@ -1572,6 +1619,7 @@ export function analyze(root, opts = {}) {
1572
1619
  const to = e.to;
1573
1620
  const crossed = cross(state, e);
1574
1621
  if (!crossed) continue;
1622
+ if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
1575
1623
  const { partial, lo, hi, vague, ctx } = crossed;
1576
1624
  if (partial) {
1577
1625
  const pk = `${to.id}|${ctx}`;
@@ -1583,6 +1631,7 @@ export function analyze(root, opts = {}) {
1583
1631
  }
1584
1632
  }
1585
1633
  if (cut) stats.walksCut++;
1634
+ if (awaiting?.length) attachRoutes(awaiting, crossedEdges, src, cut);
1586
1635
  // The walk keeps the shortest route to each sink, and credit on that route says nothing if
1587
1636
  // another reaches the sink without it. So the walk runs again, refusing to let the value leave
1588
1637
  // a node where the credit holds, and a sink it still reaches loses the credit: first for any
package/lib/hlasm.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // HLASM source, read and not assembled. The cards fold into statements as the assembler folds them
3
+ // (lib/bms.mjs), and each statement's operation is looked up in rules/hlasm-operations.json, the
4
+ // operations that matter to a reviewer, each with the IBM document that defines it. Macros are not
5
+ // expanded and conditional assembly is not evaluated, so an operation is reported where it is
6
+ // written: in a macro definition, or in a branch AIF jumps over, as much as anywhere else.
7
+ import { readFileSync } from 'node:fs';
8
+ import { foldStatements } from './bms.mjs';
9
+ import { parseOperands, splitOperands } from './cards.mjs';
10
+
11
+ const TABLE = JSON.parse(readFileSync(new URL('../rules/hlasm-operations.json', import.meta.url), 'utf8'));
12
+ export const OPERATIONS = new Map(TABLE.operations.map((o) => [o.name, o]));
13
+
14
+ // Operations only assembler source holds: sections, base registers, the instructions every
15
+ // program's linkage uses, and macro definitions. x86 or 6502 source written in a .asm file has none.
16
+ // The weak ones are words another assembler's source can hold (a 6502 `zproc start`), so it takes
17
+ // two different ones to count. USING counts only with the comma between its base and register: a
18
+ // comment line written `C Using a ...`, as GMP's are, reads as label C and operation USING.
19
+ const ASSEMBLER = new Set(['CSECT', 'DSECT', 'RSECT', 'USING', 'BALR', 'BASR', 'STM', 'STMG', 'LM', 'LMG', 'MVC',
20
+ 'MACRO', 'MEND', 'AMODE', 'RMODE', 'LTORG', 'CNOP']);
21
+ const WEAK = new Set(['START', 'DC', 'DS', 'BAL', 'BAS', 'DROP']);
22
+ const strong = (s) => ASSEMBLER.has(s.operation) && (s.operation !== 'USING' || s.field.includes(','));
23
+
24
+ // GNU assembler source, run through m4 as GMP's is, holds s390 instructions too, and is not HLASM.
25
+ const GNU = /^dnl\b|^\s*\.(?:text|globl|global|section|align|file)\b|\b(?:PROLOGUE|EPILOGUE|ASM_START)\(/m;
26
+
27
+ // The macros that make a file a BMS map or an IMS database or program definition, which their own
28
+ // readers take.
29
+ const BMS = new Set(['DFHMSD', 'DFHMDI', 'DFHMDF']);
30
+ const IMS = new Set(['DBD', 'DBDGEN', 'SEGM', 'PSBGEN', 'PCB', 'SENSEG']);
31
+
32
+ // MODESET changes the PSW key or the state; these operand values make it key zero or supervisor
33
+ // state, and the opposite values (KEY=NZERO, MODE=PROB) return from it. EXTKEY is the inline form,
34
+ // which sets only the PSW key.
35
+ const STATE_CHANGE = { KEY: ['ZERO'], MODE: ['SUP'], EXTKEY: ['ZERO'] };
36
+
37
+ const nameList = (field) => splitOperands(field || '').map((s) => s.trim().toUpperCase()).filter((s) => /^[A-Z$#@_][A-Z0-9$#@_]*$/.test(s));
38
+
39
+ function stateChange(field) {
40
+ let ops;
41
+ try { ops = parseOperands(field || ''); } catch { return null; }
42
+ for (const [key, values] of Object.entries(STATE_CHANGE)) {
43
+ const v = ops.keywords.get(key);
44
+ if (v && values.includes(String(v).trim().toUpperCase())) return `${key}=${String(v).trim().toUpperCase()}`;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // What the file is, and in it: each operation the table names with where it is written; the
50
+ // sections and entry points it defines, which a CALL or a job step can name; and the names it
51
+ // leaves to the binder.
52
+ export function readHlasm(text) {
53
+ const { statements, diags } = foldStatements(text);
54
+ const ops = statements.filter((s) => s.kind === 'statement');
55
+ const has = (set) => ops.some((s) => set.has(s.operation));
56
+ const weak = new Set(ops.filter((s) => WEAK.has(s.operation)).map((s) => s.operation)).size;
57
+ const assembler = !GNU.test(text) && (ops.some(strong) || weak >= 2);
58
+ const kind = has(BMS) ? 'bms' : has(IMS) ? 'ims' : assembler ? 'hlasm' : 'unrecognised';
59
+ const out = { kind, statements: ops.length, operations: [], defines: [], external: [], diags };
60
+ if (kind !== 'hlasm') return out;
61
+
62
+ let macroDepth = 0;
63
+ for (const s of ops) {
64
+ if (s.operation === 'MACRO') { macroDepth++; continue; }
65
+ if (s.operation === 'MEND') { macroDepth = Math.max(0, macroDepth - 1); continue; }
66
+ const inMacro = macroDepth > 0;
67
+ if (!inMacro && ['CSECT', 'RSECT', 'START'].includes(s.operation) && s.name && !s.name.startsWith('&')) {
68
+ out.defines.push({ name: s.name.toUpperCase(), how: s.operation, line: s.line });
69
+ }
70
+ if (!inMacro && s.operation === 'ENTRY') for (const name of nameList(s.field)) out.defines.push({ name, how: 'ENTRY', line: s.line });
71
+ if (!inMacro && (s.operation === 'EXTRN' || s.operation === 'WXTRN')) for (const name of nameList(s.field)) out.external.push({ name, how: s.operation, line: s.line });
72
+ const op = OPERATIONS.get(s.operation);
73
+ if (!op || op.class === 'section' || op.class === 'linkage') continue;
74
+ const use = { name: op.name, class: op.class, line: s.line, inMacro, field: s.field };
75
+ if (op.name === 'MODESET') use.stateChange = stateChange(s.field);
76
+ out.operations.push(use);
77
+ }
78
+ return out;
79
+ }
@@ -28,6 +28,8 @@ import { scanCompile, COMPILE_RULES } from '../sets/compile.mjs';
28
28
  import { scanSemantics, SEMANTICS_RULES } from '../sets/semantics.mjs';
29
29
  import { scanZowe, ZOWE_RULES } from '../sets/zowe.mjs';
30
30
  import { scanAbend, ABEND_RULES } from '../sets/abend.mjs';
31
+ import { scanHlasm, HLASM_RULES } from '../sets/hlasm.mjs';
32
+ import { scanCrypto, CRYPTO_RULES } from '../sets/crypto.mjs';
31
33
  import { toolName } from './ruleset.mjs';
32
34
 
33
35
  // The order is the order a scan runs them and the order --only lists them. It is not significant
@@ -49,6 +51,8 @@ export const REGISTRY = [
49
51
  { name: 'semantics', scan: scanSemantics, rules: SEMANTICS_RULES },
50
52
  { name: 'zowe', scan: scanZowe, rules: ZOWE_RULES },
51
53
  { name: 'abend', scan: scanAbend, rules: ABEND_RULES },
54
+ { name: 'hlasm', scan: scanHlasm, rules: HLASM_RULES },
55
+ { name: 'crypto', scan: scanCrypto, rules: CRYPTO_RULES },
52
56
  ];
53
57
 
54
58
  // Re-exported from the kernel's ruleset module, which is where it has to live: this file imports
package/lib/revision.json CHANGED
@@ -1 +1 @@
1
- {"commit":"371041fbffe625df6b0a872249cf071268fcb6a6"}
1
+ {"commit":"2144d560676dda84c8ae6546dfb7b858399f0b7d"}