@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/README.md CHANGED
@@ -36,7 +36,8 @@ follows. To run from a checkout instead:
36
36
 
37
37
  Node 18 or later. No dependencies, runtime or development. `npm link` puts `cobolwork` on your PATH;
38
38
  adding `bin/` to PATH does the same thing. GnuCOBOL is needed only to regrade the parser or validate
39
- benchmark cases.
39
+ benchmark cases. In a GitHub workflow, `uses: Portll/cobolwork@main` runs the build gate on a pull
40
+ request and writes SARIF for code scanning: [docs/github-action.md](docs/github-action.md).
40
41
 
41
42
  ## What it does
42
43
 
@@ -44,7 +45,7 @@ benchmark cases.
44
45
  |---|---|
45
46
  | `cobolwork scan <path>` | every rule set below, as JSON or SARIF |
46
47
  | `cobolwork inventory <path>` | inventory (and what couldn't be read) |
47
- | `cobolwork flow <path>` | where untrusted data reaches a sensitive operation, with the path it took |
48
+ | `cobolwork flow <path>` | where untrusted data reaches a sensitive operation, with the path it took and whether an attacker can use it |
48
49
  | `cobolwork diff <repo> --base <ref>` | what a change reaches: layouts it moves in programs nobody edited, new call targets, findings it adds or removes |
49
50
  | `cobolwork build <repo> [--base <ref>] [-- <compiler> …]` | the build gate: every finding ranked LOW to KNOWN-EXPLOITABLE, the build stopped on the ones the policy blocks and on compiler options that let a bad index corrupt storage, and the compiler run only on a pass |
50
51
  | `cobolwork parse <file>` | one file's structure, for debugging |
@@ -128,12 +129,63 @@ decides who acts on it. Every rule declares one of eight finding types:
128
129
  `coverage` and `context` are exactly the `info` rules (not defects). A consumer that counts these
129
130
  findings should leave them out of its counts.
130
131
 
131
- None of the kinds says *exploitable*, on purpose. Whether a route can be used also depends on who may
132
- start the transaction or job that reaches it, and on what the running system enforces, and no
133
- repository holds those facts. A finding names the entry points that reach it (`startedBy`). `path`
134
- is the strongest claim the tool makes: a route found by reading the code, not by running it. Its
135
- precision has been measured on benchmark cases this project wrote, not yet on an independently
136
- labelled corpus.
132
+ `path` is a route found by reading the code, not by running it. Its precision has been measured on
133
+ benchmark cases this project wrote, not yet on an independently labelled corpus.
134
+
135
+ ### Can an attacker use it?
136
+
137
+ Every `path` finding carries a verdict, the facts it rests on, and the one fact that would change it:
138
+
139
+ | `exploitability` | What it means | What to do |
140
+ |---|---|---|
141
+ | `confirmed` | the estate's own test reproduced it, and brought the result as a witness feed | patch now |
142
+ | `exploitable` | input an attacker supplies reaches the operation with no check on every route, and the estate declares a transaction or job that carries it open to any user | patch first |
143
+ | `attacker-driven` | the same route, but nobody has declared who may start its entries | patch, or declare the entries |
144
+ | `restricted` | the same route, behind a control the estate named on every entry | patch; the control is not a check |
145
+ | `mitigated` | a check runs first but is not shown to stop the value, or SSRANGE turns an overrun into an abend | confirm the check |
146
+ | `upstream` | the value is a file record or database row, so whoever can write it drives it | review who writes the data |
147
+ | `refuted` | a check leaves only safe values, or no route reaches the operation | nothing |
148
+
149
+ For the terminal route to dynamic SQL in `test/fixtures/entry`, scanned with a site file that
150
+ declares its transaction open:
151
+
152
+ ```json
153
+ "exploitability": {
154
+ "verdict": "exploitable",
155
+ "drivenBy": "A terminal user",
156
+ "because": [
157
+ "A terminal user supplies it: EXEC CICS RECEIVE at INQUIRY.cbl:10",
158
+ "no check is shown to run on every route before the operation at INQUIRY.cbl:13",
159
+ "started by transaction INQ1: open to any user, per cobolwork.site.json"
160
+ ],
161
+ "unknown": "whether it reproduces: a test on a system the estate owns, under its own authorisation, is the only confirmation",
162
+ "fixAt": {
163
+ "program": "INQUIRY", "path": "INQUIRY.cbl", "line": 13, "item": "WS-STMT",
164
+ "test": "before the operation at INQUIRY.cbl:13, on every route to it, test WS-STMT against a list of the values allowed (...), and let only a value that passes reach it; a static statement with host variables needs no test"
165
+ }
166
+ }
167
+ ```
168
+
169
+ `fixAt` is where one patch covers every route, with a test the analysis credits: patch it there, scan
170
+ again, and the finding moves to `refuted`. Where no test makes the operation safe, `why` says so and
171
+ the rule's remedy is the fix.
172
+
173
+ Who may start a transaction lives in RACF, not in a repository, so without it the strongest verdict is
174
+ `attacker-driven`, and `summary.reachNote` says how many that is. Name the entries in
175
+ `cobolwork.site.json` (`openTransactions`, `restrictedTransactions`, `openJobs`, `restrictedJobs`), or
176
+ bring a reduced RACF unload as `COBOLWORK_REACH`, which is refused from inside the scanned tree.
177
+ `node diag/propose-site.mjs <path>` lists the transactions and jobs to ask about, most findings
178
+ first, with the program each runs and any listener or URI map that puts it on the network; it
179
+ proposes none of them as open or restricted.
180
+
181
+ The tool never confirms a finding from reading: that needs a test on the running system. The estate
182
+ brings its own results as `COBOLWORK_WITNESS`, keyed by fingerprint with the outcome, who ran the
183
+ test, when and on which system, and refused from inside the tree like `COBOLWORK_REACH`. A reproduced
184
+ finding becomes `confirmed`; one the estate's test did not reproduce keeps its verdict and says so,
185
+ since a test that missed the route does not show it safe.
186
+ `summary.byExploitability` counts the verdicts, and a report holding an `exploitable` finding carries
187
+ `summary.handling`, because it is then a list of what to attack first. Upload it only where the RACF
188
+ facts behind it may go. [docs/spec/reach.md](docs/spec/reach.md) §9 is the full specification.
137
189
 
138
190
  ### What each finding lets someone do, and the fix
139
191
 
@@ -191,7 +243,7 @@ A scan says what each rule set read, not only what it found:
191
243
  "why": "no cobolwork.site.json: the production-name and production-dataset rules did not run, because nothing declares what production means in this estate" }
192
244
  ],
193
245
  "flowModel": "byte-range",
194
- "toolVersion": "0.2.0"
246
+ "toolVersion": "0.2.90"
195
247
  }
196
248
  ```
197
249
 
@@ -236,7 +288,7 @@ gate. An incomplete scan is undecided, never a pass.
236
288
 
237
289
  The compiler options are held to the policy too. A program compiled without `SSRANGE`, or with
238
290
  `SSRANGE(MSG)`, which reports a bad subscript and carries on, fails; for `cobc` the missing `-fec`
239
- checks are added to the command. `cobolwork.policy.json` changes any of this, and `--policy <file>`
291
+ checks are added to the command, and for GCC's `gcobol` the missing `-fcobol-exceptions`. `cobolwork.policy.json` changes any of this, and `--policy <file>`
240
292
  names an organisation's floor, which a repository can tighten and never loosen.
241
293
  [docs/spec/build-gate.md](docs/spec/build-gate.md) is the full specification.
242
294
 
@@ -261,7 +313,7 @@ stand-in in `diag/precompiler.mjs`, which rewrites what the parser would otherwi
261
313
  tests compare the parser with the compiler's answers kept in `test/fixtures/parser/*.golden.json`,
262
314
  so they run without GnuCOBOL.
263
315
 
264
- `bench/cases/` holds 93 CWE-labelled cases, each paired with a near-miss negative: the same shape
316
+ `bench/cases/` holds 102 CWE-labelled cases, each paired with a near-miss negative: the same shape
265
317
  with the flaw removed. `node bench/run.mjs` scores any scanner's findings against them, by rule and
266
318
  file, never by line, and `npm test` fails if any case scores differently from its declaration.
267
319
  `--validate` compiles every COBOL case with GnuCOBOL and checks every JCL case against the
@@ -291,9 +343,9 @@ Every rule is mapped to the clause of each framework that makes it an obligation
291
343
 
292
344
  | File | Instrument | Rules mapped |
293
345
  |---|---|---|
294
- | `rules/compliance-dora.json` | Regulation (EU) 2022/2554 (DORA) | 156 |
295
- | `rules/compliance-ffiec.json` | FFIEC IT Examination Handbook | 154, and 2 recorded as unmapped |
296
- | `rules/compliance-nist80053.json` | NIST SP 800-53 Rev. 5.2.0 | 154, and 2 recorded as unmapped |
346
+ | `rules/compliance-dora.json` | Regulation (EU) 2022/2554 (DORA) | 203 |
347
+ | `rules/compliance-ffiec.json` | FFIEC IT Examination Handbook | 201, and 2 recorded as unmapped |
348
+ | `rules/compliance-nist80053.json` | NIST SP 800-53 Rev. 5.2.0 | 201, and 2 recorded as unmapped |
297
349
 
298
350
  `node diag/map-compliance.mjs` refuses to write a quote the cached instrument does not contain, and
299
351
  every scan carries the mapping as `ruleCompliance`. The clause choice is a judgement that no
package/bin/cobolwork.mjs CHANGED
@@ -4,9 +4,10 @@ import { writeFileSync, readFileSync, lstatSync, statSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { readdirSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { scanAll, RULE_SETS } from '../lib/scan.mjs';
7
+ import { scanAll, applyEstateFacts, RULE_SETS } from '../lib/scan.mjs';
8
8
  import { scan as scanFlow } from '../lib/sets/flow.mjs';
9
9
  import { inventory } from '../lib/inventory.mjs';
10
+ import { sbom } from '../lib/sbom.mjs';
10
11
  import { parseFile } from '../lib/parser.mjs';
11
12
  import { toSarif } from '../lib/sarif.mjs';
12
13
  import { diffRefs } from '../lib/diff.mjs';
@@ -15,12 +16,19 @@ import { build, buildSarif, buildSummaryLine } from '../lib/build.mjs';
15
16
  import { capabilities } from '../lib/capabilities.mjs';
16
17
  import { commitAt, revisionOf, toolRevision } from '../lib/revision.mjs';
17
18
  import { stampFingerprints } from '../lib/kernel/identity.mjs';
19
+ import { tally } from '../lib/kernel/findings.mjs';
18
20
  import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, SUPPRESSING } from '../lib/baseline.mjs';
19
21
  import { readReport } from '../lib/tui/model.mjs';
20
22
  import { nodeTerminal } from '../lib/tui/terminal.mjs';
21
23
  import { runTui } from '../lib/tui/run.mjs';
22
24
  import { explainFinding } from '../lib/explain.mjs';
23
25
  import { printable } from '../lib/kernel/printable.mjs';
26
+ import { startEvidence, recordInputs, recordHashed, recordFindings, recordOutput, recordVerdict, recordBaselineWrite, finishEvidence } from '../lib/evidence/run.mjs';
27
+ import { evidenceCommand } from '../lib/evidence/cli.mjs';
28
+ import { slsaStatement } from '../lib/evidence/slsa.mjs';
29
+ import { parseAllowedSigners } from '../lib/evidence/sshsig.mjs';
30
+
31
+ const STARTED = new Date().toISOString();
24
32
 
25
33
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
34
 
@@ -28,6 +36,9 @@ const USAGE = `cobolwork ${VERSION} — COBOL, JCL and CICS security analysis, n
28
36
 
29
37
  cobolwork scan <path> every rule set: data flow, CICS, JCL, hidden content, copybooks, build pins
30
38
  cobolwork inventory <path> what is there and what could not be read (copybooks, dialects)
39
+ cobolwork sbom <path> [--name <estate>]
40
+ a CycloneDX 1.6 bill of materials: every source by SHA-256, what each
41
+ program copies and calls, what each job runs, and the platforms used
31
42
  cobolwork flow <path> data-flow findings with the path the data took, as JSON
32
43
  cobolwork parse <file> parse one source file and print a summary
33
44
  cobolwork diff <repo> --base <ref> [--head <ref>]
@@ -47,11 +58,17 @@ const USAGE = `cobolwork ${VERSION} — COBOL, JCL and CICS security analysis, n
47
58
  what this cobolwork can do: commands and options, the version of every
48
59
  document it writes, its fingerprint version, the file kinds it reads, and
49
60
  the commit it runs from
50
- cobolwork build <repo> [--base <ref>] [--policy <file>] [--provenance <file>] [-- <compiler> <arg>...]
61
+ cobolwork build <repo> [--base <ref>] [--policy <file>] [--provenance <file>] [--ironwork <path> | -- <compiler> <arg>...]
51
62
  the build gate: every finding ranked LOW to KNOWN-EXPLOITABLE, the
52
63
  policy's blocking findings and compiler options checked, and the
53
64
  compiler run only on a pass. Exits 0 pass, 1 fail, 3 undecided, 4 the
54
65
  compiler failed after a pass, 2 could not run
66
+ cobolwork evidence verify|seal|anchor --evidence <dir>
67
+ the evidence a run records: verify its chains, seals and witnesses;
68
+ seal the ledger (--ssh-key <file> or --signer <program>); anchor a seal
69
+ in a git witness (--anchor-git <repo> [--push]) or write an RFC 3161
70
+ request (--tsq <file>). verify exits 0 sealed, 1 broken or contradicted,
71
+ 3 undetermined
55
72
 
56
73
  Options
57
74
  --format json|sarif output format (default json)
@@ -81,6 +98,22 @@ Options
81
98
  --policy <file> build: an organisation's floor policy, from outside the repository; the
82
99
  repository's cobolwork.policy.json can tighten it and never loosen it
83
100
  --provenance <file> build: write what was scanned, under which policy, and what was compiled
101
+ --provenance-format cobolwork|slsa build: the record as written today, or an in-toto statement with
102
+ the SLSA Provenance v1 predicate (unsigned; the pipeline signs it)
103
+ --artifact <path>[,<path>] build: what the compiler produced, named as subjects of the SLSA statement
104
+ --equivalence <file>[,<file>] build --base: ironwork equivalence statements for the programs the
105
+ change edits; with --allowed-signers each must be signed by one. The policy's
106
+ requireEquivalence (never, machineAuthored, always) says when one is required
107
+ --ironwork <path> build: after a pass, run ironwork check on every program, for an estate that
108
+ compiles with IBM Enterprise COBOL; a program ironwork rejects exits 4, one it
109
+ does not model yet leaves the build undecided
110
+ --evidence <dir> scan, flow, diff, gate, build, baseline, inventory: record this run in a
111
+ hash-chained journal and ledger there (or COBOLWORK_EVIDENCE); never inside
112
+ the tree being read
113
+ --allowed-signers <file> evidence verify: OpenSSH allowed_signers for namespace cobolwork-evidence
114
+ --anchor-git <repo>, --ref <ref>, --push, --max-unsealed <n>
115
+ evidence: the git witness, the ref that counts (default @{upstream}), whether
116
+ anchor pushes, and how many ledger records may follow the newest seal
84
117
 
85
118
  Exit codes: 0 the command ran, 2 it could not run. A run that examined nothing says so in
86
119
  summary.filesScanned and summary.nosrc rather than reporting a clean zero.
@@ -127,6 +160,21 @@ function parseArgs(argv) {
127
160
  else if (a === '--json') opts.json = true;
128
161
  else if (a === '--policy') opts.policy = value();
129
162
  else if (a === '--provenance') opts.provenance = value();
163
+ else if (a === '--provenance-format') opts.provenanceFormat = value();
164
+ else if (a === '--equivalence') opts.equivalence = [...(opts.equivalence || []), ...list().map((x) => resolve(x))];
165
+ else if (a === '--artifact') opts.artifact = [...(opts.artifact || []), ...list().map((x) => resolve(x))];
166
+ else if (a === '--ironwork') opts.ironwork = value();
167
+ else if (a === '--evidence') opts.evidence = value();
168
+ else if (a === '--name') opts.name = value();
169
+ else if (a === '--ssh-key') opts.sshKey = value();
170
+ else if (a === '--signer') opts.signer = value();
171
+ else if (a === '--allowed-signers') opts.allowedSigners = value();
172
+ else if (a === '--anchor-git') opts.anchorGit = value();
173
+ else if (a === '--ref') opts.ref = value();
174
+ else if (a === '--push') opts.push = true;
175
+ else if (a === '--max-unsealed') opts.maxUnsealed = value();
176
+ else if (a === '--tsq') opts.tsq = value();
177
+ else if (a === '--expect-key') opts.expectKey = value();
130
178
  else if (a === '--help' || a === '-h') opts.help = true;
131
179
  else if (a === '--version' || a === '-v') opts.version = true;
132
180
  else if (a.startsWith('-')) { opts.unknown = a; }
@@ -151,8 +199,11 @@ function warnCoverage(report) {
151
199
 
152
200
  const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
153
201
 
202
+ let journal = null;
203
+
154
204
  function emit(obj, opts) {
155
205
  const text = JSON.stringify(obj, null, opts.quiet ? 0 : 1);
206
+ recordOutput(journal, 'report', text + '\n', opts.out || null);
156
207
  if (opts.out) { writeFileSync(opts.out, text + '\n'); return; }
157
208
  // A pipe drains asynchronously and process.exit discards what has not drained: past 64 KB the
158
209
  // reader got a truncated document and exit 0. The process ends on its own once stdout is flushed.
@@ -202,7 +253,7 @@ if (gateFlag && opts._.length && opts._[0] !== 'gate') {
202
253
  process.stderr.write(`cobolwork: --${gateFlag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} is for gate only\n`);
203
254
  process.exit(2);
204
255
  }
205
- const COPYLIB_COMMANDS = ['scan', 'flow', 'inventory', 'diff', 'build', 'baseline', 'tui', 'explain'];
256
+ const COPYLIB_COMMANDS = ['scan', 'flow', 'inventory', 'diff', 'build', 'baseline', 'tui', 'explain', 'sbom'];
206
257
  if (opts.copylib && opts._.length && !COPYLIB_COMMANDS.includes(opts._[0])) {
207
258
  process.stderr.write(`cobolwork: ${opts._[0]} reads no copybooks; ${COPYLIB_COMMANDS.join(', ')} do\n`);
208
259
  process.exit(2);
@@ -215,7 +266,9 @@ if (opts.json && opts._.length && opts._[0] !== 'capabilities') {
215
266
  process.stderr.write(`cobolwork: --json is for capabilities; every other command writes JSON unless --format says otherwise\n`);
216
267
  process.exit(2);
217
268
  }
218
- const buildFlag = ['policy', 'provenance'].find((k) => opts[k] !== undefined) || (compilerArgv ? '' : null);
269
+ const buildFlag = ['policy', 'provenance', 'provenanceFormat', 'artifact', 'equivalence', 'ironwork'].find((k) => opts[k] !== undefined) || (compilerArgv ? '' : null);
270
+ if (opts.provenanceFormat !== undefined && !['cobolwork', 'slsa'].includes(opts.provenanceFormat)) { process.stderr.write(`cobolwork: --provenance-format takes cobolwork or slsa; got ${opts.provenanceFormat}\n`); process.exit(2); }
271
+ if ((opts.provenanceFormat !== undefined || opts.artifact) && !opts.provenance) { process.stderr.write('cobolwork: --provenance-format and --artifact describe the --provenance file; name it\n'); process.exit(2); }
219
272
  if (buildFlag !== null && opts._.length && opts._[0] !== 'build') {
220
273
  process.stderr.write(`cobolwork: ${buildFlag ? `--${buildFlag}` : '--'} is for build only\n`);
221
274
  process.exit(2);
@@ -223,11 +276,32 @@ if (buildFlag !== null && opts._.length && opts._[0] !== 'build') {
223
276
  // A misspelled set would run nothing and report a clean zero.
224
277
  const badSets = (opts.only || []).filter(s => !RULE_SETS.includes(s));
225
278
  if (badSets.length || (opts.only && !opts.only.length)) { process.stderr.write(`cobolwork: --only takes ${RULE_SETS.join(',')}; got ${badSets.join(',') || 'nothing'}\n`); process.exit(2); }
279
+ const JOURNALED = ['scan', 'flow', 'diff', 'gate', 'build', 'baseline', 'inventory', 'sbom'];
280
+ if (opts.name !== undefined && opts._.length && opts._[0] !== 'sbom') { process.stderr.write('cobolwork: --name is for sbom only\n'); process.exit(2); }
281
+ if (opts.evidence !== undefined && opts._.length && ![...JOURNALED, 'evidence'].includes(opts._[0])) {
282
+ process.stderr.write(`cobolwork: ${opts._[0]} records no evidence; ${JOURNALED.join(', ')} and evidence do\n`);
283
+ process.exit(2);
284
+ }
285
+ const evidenceFlag = ['sshKey', 'signer', 'allowedSigners', 'anchorGit', 'ref', 'push', 'maxUnsealed', 'tsq', 'expectKey'].find((k) => opts[k] !== undefined);
286
+ if (opts.equivalence && !opts.base) { process.stderr.write('cobolwork: --equivalence judges a change; it needs --base\n'); process.exit(2); }
287
+ if (evidenceFlag && opts._.length && opts._[0] !== 'evidence' && !(evidenceFlag === 'allowedSigners' && opts._[0] === 'build')) {
288
+ process.stderr.write(`cobolwork: --${evidenceFlag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} is for evidence only\n`);
289
+ process.exit(2);
290
+ }
226
291
 
227
292
  const [command, target] = opts._;
228
293
  const root = resolve(target || '.');
229
294
  const repos = opts.repos ? readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name) : [''];
230
295
 
296
+ if (JOURNALED.includes(command)) {
297
+ try {
298
+ journal = startEvidence(opts, { command, argv, roots: [root], toolVersion: VERSION });
299
+ } catch (e) {
300
+ process.stderr.write(`cobolwork: ${printable(e && e.message ? e.message : e)}\n`);
301
+ process.exit(2);
302
+ }
303
+ }
304
+
231
305
  function storedReport(file) {
232
306
  const path = resolve(file);
233
307
  let doc;
@@ -254,12 +328,22 @@ function baselinedScan() {
254
328
  try {
255
329
  if (command === 'capabilities') {
256
330
  emit(capabilities(), opts);
331
+ } else if (command === 'evidence') {
332
+ process.exitCode = evidenceCommand(target, opts, { toolVersion: VERSION, write: (s) => process.stdout.write(s) });
257
333
  } else if (command === 'scan' || command === 'flow') {
258
334
  const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
259
335
  const report = command === 'flow' ? scanFlow(root, flowOpts) : scanAll(root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds });
260
- if (command === 'flow') report.summary.identity = stampFingerprints(report.findings, { root });
336
+ if (command === 'flow') {
337
+ report.summary.identity = stampFingerprints(report.findings, { root });
338
+ stampFingerprints(report.checked, { root });
339
+ Object.assign(report.summary, applyEstateFacts(report.findings, report.checked, root));
340
+ // A route the estate reproduced moves from checked back to findings.
341
+ Object.assign(report.summary, { findings: report.findings.length, byRule: tally(report.findings), checked: report.checked.length });
342
+ }
261
343
  applyBaseline(report, loadBaseline(root, { explicit: opts.baseline ? resolve(opts.baseline) : null, use: !opts.noBaseline }));
262
344
  stampRevisions(report.summary);
345
+ recordInputs(journal, 0, root);
346
+ recordFindings(journal, report);
263
347
  if (command === 'flow') emit(report, opts);
264
348
  else if (opts.format === 'sarif') emit(toSarif(report, { toolVersion: VERSION }), opts);
265
349
  else emit(opts.quiet ? { tool: report.tool, schemaVersion: report.schemaVersion, summary: report.summary } : report, opts);
@@ -268,6 +352,7 @@ try {
268
352
  if (!opts.base) { process.stderr.write(`cobolwork: diff needs --base <ref>\n`); process.exit(2); }
269
353
  const report = diffRefs(root, opts.base, opts.head || null, { only: opts.only, fullTrace: opts.fullTrace === true, systemDirs });
270
354
  stampRevisions(report.summary, opts.head || null);
355
+ recordFindings(journal, { findings: [...report.findings, ...(report.introduced || [])] });
271
356
  if (opts.format === 'sarif') emit(toSarif({ ...report, findings: [...report.findings, ...report.introduced] }, { toolVersion: VERSION }), opts);
272
357
  else emit(opts.quiet ? { tool: report.tool, schemaVersion: report.schemaVersion, summary: report.summary } : report, opts);
273
358
  } else if (command === 'gate') {
@@ -280,13 +365,14 @@ try {
280
365
  stampRevisions(doc.summary, opts.head || null);
281
366
  emit(doc, opts);
282
367
  if (opts.exitCode) process.exitCode = VERDICT_EXIT[doc.verdict];
368
+ recordVerdict(journal, doc, process.exitCode ?? 0);
283
369
  } else if (command === 'build') {
284
370
  // A rule set left out is findings not seen, and a waiver file from elsewhere is not the one the
285
371
  // change was reviewed against.
286
372
  if (opts.only || opts.repos) { process.stderr.write('cobolwork: build judges one repository with every rule set; --only and --repos do not apply\n'); process.exit(2); }
287
373
  if (opts.baseline) { process.stderr.write('cobolwork: build reads the baseline the change was written against; --baseline does not apply, --no-baseline does\n'); process.exit(2); }
288
374
  if (opts.head && !opts.base) { process.stderr.write('cobolwork: build --head needs --base\n'); process.exit(2); }
289
- const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs });
375
+ const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, ironwork: opts.ironwork || null, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs, equivalence: opts.equivalence || [], allowed: opts.allowedSigners ? parseAllowedSigners(readFileSync(resolve(opts.allowedSigners), 'utf8')) : null });
290
376
  stampRevisions(result.doc.summary, opts.head || null);
291
377
  Object.assign(result.report.summary, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
292
378
  Object.assign(result.provenance, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
@@ -294,9 +380,25 @@ try {
294
380
  const report = result.report;
295
381
  emit(buildSarif(toSarif({ ...report, findings: report.findings }, { toolVersion: VERSION }), result.doc), opts);
296
382
  } else emit(result.doc, opts);
297
- if (opts.provenance) writeFileSync(resolve(opts.provenance), JSON.stringify(result.provenance, null, 1) + '\n');
383
+ recordHashed(journal, 0, result.provenance.sources);
384
+ recordFindings(journal, result.report);
385
+ if (opts.provenance) {
386
+ const record = opts.provenanceFormat === 'slsa'
387
+ ? slsaStatement({
388
+ provenance: result.provenance, root, artifacts: opts.artifact || [],
389
+ docBytes: Buffer.from(JSON.stringify(result.doc, null, opts.quiet ? 0 : 1) + '\n'),
390
+ runId: journal ? journal.id : null, runTip: journal && journal.tip ? journal.tip.hash : null,
391
+ builderId: process.env.COBOLWORK_BUILDER_ID || null,
392
+ ...(process.env.SOURCE_DATE_EPOCH ? {} : { startedOn: STARTED, finishedOn: new Date().toISOString() }),
393
+ })
394
+ : result.provenance;
395
+ const text = JSON.stringify(record, null, 1) + '\n';
396
+ writeFileSync(resolve(opts.provenance), text);
397
+ recordOutput(journal, 'provenance', text, resolve(opts.provenance));
398
+ }
298
399
  process.stderr.write(`${buildSummaryLine(result.doc)}\n`);
299
400
  process.exitCode = result.exit;
401
+ recordVerdict(journal, result.doc, result.exit);
300
402
  } else if (command === 'baseline') {
301
403
  // A suppression nobody dated is never looked at again, so every part of the judgement is asked for.
302
404
  const missing = ['reason', 'who', 'expires'].filter((k) => !opts[k]);
@@ -313,8 +415,14 @@ try {
313
415
  if (held.problems.length) { process.stderr.write(`cobolwork: ${path} holds entries that do not validate, so it is left as it is:\n ${held.problems.join('\n ')}\n`); process.exit(2); }
314
416
  const report = scanAll(root, { repos, only: opts.only, advisoryFeeds: opts.advisoryFeeds, systemDirs });
315
417
  const { entries, added } = baselineEntries(report.findings, held.entries, { action, reason: opts.reason, who: opts.who, expires: expires.toISOString(), at, rules: opts.rule || null });
316
- writeFileSync(path, JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', entries }, null, 1) + '\n');
317
- process.stdout.write(`${JSON.stringify({ tool: 'cobolwork-baseline', path, added, entries: entries.length })}\n`);
418
+ const written = JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', entries }, null, 1) + '\n';
419
+ writeFileSync(path, written);
420
+ recordInputs(journal, 0, root);
421
+ recordFindings(journal, report);
422
+ recordBaselineWrite(journal, { before: held.entries, after: entries, who: opts.who, expires: expires.toISOString(), reason: opts.reason, path, text: written });
423
+ const line = `${JSON.stringify({ tool: 'cobolwork-baseline', path, added, entries: entries.length })}\n`;
424
+ recordOutput(journal, 'baseline', line);
425
+ process.stdout.write(line);
318
426
  } else if (command === 'tui') {
319
427
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
320
428
  process.stderr.write('cobolwork: tui needs a terminal on standard input and output; cobolwork scan writes the same report without one\n');
@@ -332,8 +440,15 @@ try {
332
440
  const packet = explainFinding({ ...report, findings: [...report.findings, ...(report.suppressed || [])] }, fingerprint, { root });
333
441
  if (!packet) { process.stderr.write(`cobolwork: no finding with fingerprint ${fingerprint} in this report\n`); process.exit(2); }
334
442
  emit(packet, opts);
443
+ } else if (command === 'sbom') {
444
+ const bom = sbom(root, { systemDirs, name: opts.name });
445
+ recordInputs(journal, 0, root);
446
+ const text = JSON.stringify(bom, null, opts.quiet ? 0 : 1) + '\n';
447
+ recordOutput(journal, 'sbom', text, opts.out || null);
448
+ if (opts.out) writeFileSync(opts.out, text); else process.stdout.write(text);
335
449
  } else if (command === 'inventory') {
336
450
  const inv = inventory(root, { systemDirs });
451
+ recordInputs(journal, 0, root);
337
452
  emit(inv, opts);
338
453
  warnCoverage(inv);
339
454
  } else if (command === 'parse') {
@@ -348,7 +463,9 @@ try {
348
463
  process.stderr.write(`cobolwork: unknown command ${command}\n${USAGE}`);
349
464
  process.exit(2);
350
465
  }
466
+ finishEvidence(journal, process.exitCode ?? 0);
351
467
  } catch (e) {
352
468
  process.stderr.write(`cobolwork: ${printable(e && e.message ? e.message : e)}\n`);
469
+ finishEvidence(journal, 2);
353
470
  process.exit(2);
354
471
  }
package/lib/arith.mjs ADDED
@@ -0,0 +1,235 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Interval arithmetic for the check model: what an arithmetic statement can leave in an integer
3
+ // field, given what its operands can hold. Endpoints are exact rationals, so a quotient's lower end
4
+ // is not lost to rounding, and every interval is closed.
5
+ //
6
+ // An expression is parsed once into a tree whose leaves name fields; `candidates` then reads it
7
+ // against the facts the caller can offer for each field. Each candidate is an interval together with
8
+ // the facts it needs to hold, so the caller can tie the result to the same facts.
9
+
10
+ const gcd = (a, b) => { a = a < 0n ? -a : a; b = b < 0n ? -b : b; while (b) [a, b] = [b, a % b]; return a; };
11
+ export const q = (n, d = 1n) => {
12
+ if (d < 0n) { n = -n; d = -d; }
13
+ const g = gcd(n, d) || 1n;
14
+ return { n: n / g, d: d / g };
15
+ };
16
+ const cmp = (a, b) => { const x = a.n * b.d - b.n * a.d; return x < 0n ? -1 : x > 0n ? 1 : 0; };
17
+ const qAdd = (a, b) => q(a.n * b.d + b.n * a.d, a.d * b.d);
18
+ const qSub = (a, b) => q(a.n * b.d - b.n * a.d, a.d * b.d);
19
+ const qMul = (a, b) => q(a.n * b.n, a.d * b.d);
20
+ const qDiv = (a, b) => q(a.n * b.d, a.d * b.n);
21
+ const qMin = (list) => list.reduce((m, x) => (cmp(x, m) < 0 ? x : m));
22
+ const qMax = (list) => list.reduce((m, x) => (cmp(x, m) > 0 ? x : m));
23
+ export const floorQ = (a) => { const t = a.n / a.d; return a.n % a.d !== 0n && a.n < 0n ? t - 1n : t; };
24
+ export const ceilQ = (a) => { const t = a.n / a.d; return a.n % a.d !== 0n && a.n > 0n ? t + 1n : t; };
25
+ const truncQ = (a) => a.n / a.d;
26
+
27
+ // A division is exact when its divisor is a whole number with no prime factor but 2 and 5, which the
28
+ // decimal arithmetic of every compiler carries to the end; any other quotient is cut short of its
29
+ // last digit, in either direction, and the interval says so.
30
+ const terminating = (b) => {
31
+ if (b.d !== 1n) return false;
32
+ let n = b.n < 0n ? -b.n : b.n;
33
+ for (const p of [2n, 5n]) while (n % p === 0n) n /= p;
34
+ return n === 1n;
35
+ };
36
+ const EPSILON = q(1n, 10n ** 30n);
37
+
38
+ const iv = (lo, hi, inexact = false) => ({ lo, hi, inexact });
39
+ const ivAdd = (a, b) => iv(qAdd(a.lo, b.lo), qAdd(a.hi, b.hi), a.inexact || b.inexact);
40
+ const ivSub = (a, b) => iv(qSub(a.lo, b.hi), qSub(a.hi, b.lo), a.inexact || b.inexact);
41
+ function ivMul(a, b) {
42
+ if (a.inexact || b.inexact) return null;
43
+ const p = [qMul(a.lo, b.lo), qMul(a.lo, b.hi), qMul(a.hi, b.lo), qMul(a.hi, b.hi)];
44
+ return iv(qMin(p), qMax(p));
45
+ }
46
+ function ivDiv(a, b) {
47
+ if (a.inexact || b.inexact) return null;
48
+ if (!(cmp(b.lo, q(0n)) > 0 || cmp(b.hi, q(0n)) < 0)) return null;
49
+ const p = [qDiv(a.lo, b.lo), qDiv(a.lo, b.hi), qDiv(a.hi, b.lo), qDiv(a.hi, b.hi)];
50
+ const point = cmp(b.lo, b.hi) === 0;
51
+ return iv(qMin(p), qMax(p), !(point && terminating(b.lo)));
52
+ }
53
+ const ivNeg = (a) => iv(qSub(q(0n), a.hi), qSub(q(0n), a.lo), a.inexact);
54
+ // FUNCTION MOD takes the sign of its divisor, so a positive divisor leaves 0 up to one below it.
55
+ function ivMod(a, b) {
56
+ if (a.inexact || b.inexact || b.lo.d !== 1n || b.hi.d !== 1n || cmp(b.lo, q(1n)) < 0) return null;
57
+ return iv(q(0n), qSub(b.hi, q(1n)));
58
+ }
59
+
60
+ // What lands in an integer field: a truncated value moves toward zero, a rounded one to either
61
+ // neighbour, whatever the rounding mode.
62
+ export function storedRange(r, rounded) {
63
+ let { lo, hi } = r;
64
+ if (r.inexact) { lo = qSub(lo, EPSILON); hi = qAdd(hi, EPSILON); }
65
+ return rounded ? { lo: floorQ(lo), hi: ceilQ(hi) } : { lo: truncQ(lo), hi: truncQ(hi) };
66
+ }
67
+
68
+ // ---- parsing ----------------------------------------------------------------------------------
69
+
70
+ const isWord = (t, u) => t && t.t === 'word' && t.u === u;
71
+ const isSep = (t, v) => t && t.t === 'sep' && t.v === v;
72
+ const isOp = (t, v) => t && t.t === 'op' && t.v === v;
73
+ const decimal = (s) => {
74
+ const m = /^(\d*)(?:\.(\d+))?$/.exec(s);
75
+ if (!m || (!m[1] && !m[2])) return null;
76
+ const frac = m[2] || '';
77
+ return q(BigInt((m[1] || '0') + frac), 10n ** BigInt(frac.length));
78
+ };
79
+ const TRIMS = new Set(['TRIM', 'UPPER-CASE', 'LOWER-CASE', 'REVERSE']);
80
+
81
+ // The token after a balanced pair of parentheses that opens at `i`, or -1.
82
+ function closeOf(toks, i) {
83
+ let depth = 0;
84
+ for (let j = i; j < toks.length; j++) {
85
+ if (isSep(toks[j], '(')) depth++;
86
+ else if (isSep(toks[j], ')') && --depth === 0) return j;
87
+ }
88
+ return -1;
89
+ }
90
+
91
+ // `env.field(tok, subscripted)` gives a leaf for an integer field or null; `env.sizeOf(tokens)` the
92
+ // most bytes an item, subscripted or not, can be, or null.
93
+ export function parser(toks, env) {
94
+ let i = 0;
95
+ const fail = () => { throw parser; };
96
+ function lengthArg(inner) {
97
+ if (inner.length === 1 && inner[0].t === 'lit') return { k: 'range', lo: q(BigInt(String(inner[0].v).length)), hi: q(BigInt(String(inner[0].v).length)) };
98
+ let span = inner;
99
+ if (isWord(span[0], 'FUNCTION') && span[1] && TRIMS.has(span[1].u) && isSep(span[2], '(') && closeOf(span, 2) === span.length - 1) {
100
+ span = span.slice(3, -1).filter((t) => !isWord(t, 'LEADING') && !isWord(t, 'TRAILING'));
101
+ }
102
+ const size = span.length && span[0].t === 'word' ? env.sizeOf(span) : null;
103
+ return size == null ? fail() : { k: 'range', lo: q(0n), hi: q(BigInt(size)) };
104
+ }
105
+ function primary() {
106
+ const t = toks[i];
107
+ if (!t) fail();
108
+ if (isOp(t, '-') || isOp(t, '+')) { i++; const a = primary(); return t.v === '-' ? { k: 'neg', a } : a; }
109
+ if (isSep(t, '(')) {
110
+ i++;
111
+ const a = expr();
112
+ if (!isSep(toks[i], ')')) fail();
113
+ i++;
114
+ return a;
115
+ }
116
+ if (t.t === 'num') { const v = decimal(t.v); if (!v) fail(); i++; return { k: 'const', v }; }
117
+ if (t.t !== 'word') fail();
118
+ if (/^\d+$/.test(t.v)) { i++; return { k: 'const', v: q(BigInt(t.v)) }; }
119
+ if (t.u === 'LENGTH' && isWord(toks[i + 1], 'OF') && toks[i + 2] && toks[i + 2].t === 'word') {
120
+ const size = env.sizeOf([toks[i + 2]]);
121
+ if (size == null) fail();
122
+ i += 3;
123
+ return { k: 'range', lo: q(BigInt(size)), hi: q(BigInt(size)) };
124
+ }
125
+ if (t.u === 'FUNCTION') {
126
+ const name = toks[i + 1] && toks[i + 1].u;
127
+ if (!name || !isSep(toks[i + 2], '(')) fail();
128
+ const close = closeOf(toks, i + 2);
129
+ if (close < 0) fail();
130
+ const inner = toks.slice(i + 3, close);
131
+ let node;
132
+ if (name === 'LENGTH') node = lengthArg(inner);
133
+ else if (name === 'ORD') node = { k: 'range', lo: q(1n), hi: q(256n) };
134
+ else if (name === 'MOD') {
135
+ const sub = parser(inner, env);
136
+ const a = sub.expr();
137
+ const b = sub.expr();
138
+ if (!sub.done()) fail();
139
+ node = { k: 'mod', a, b };
140
+ } else fail();
141
+ i = close + 1;
142
+ return node;
143
+ }
144
+ // An identifier, with the subscripts of a table element; a qualified name or a reference
145
+ // modification is not read.
146
+ if (isWord(toks[i + 1], 'OF') || isWord(toks[i + 1], 'IN')) fail();
147
+ let subscripted = false;
148
+ let end = i + 1;
149
+ if (isSep(toks[end], '(')) {
150
+ const close = closeOf(toks, end);
151
+ if (close < 0 || toks.slice(end, close).some((x) => isOp(x, ':'))) fail();
152
+ subscripted = true;
153
+ end = close + 1;
154
+ }
155
+ const leaf = env.field(t, subscripted);
156
+ if (!leaf) fail();
157
+ i = end;
158
+ return leaf;
159
+ }
160
+ function term() {
161
+ let a = primary();
162
+ while (isOp(toks[i], '*') || isOp(toks[i], '/')) { const op = toks[i++].v; a = { k: 'bin', op, a, b: primary() }; }
163
+ return a;
164
+ }
165
+ function expr() {
166
+ let a = term();
167
+ while (isOp(toks[i], '+') || isOp(toks[i], '-')) { const op = toks[i++].v; a = { k: 'bin', op, a, b: term() }; }
168
+ return a;
169
+ }
170
+ return { expr, primary, done: () => i >= toks.length, at: () => i };
171
+ }
172
+
173
+ // Runs a parse, giving null where the tokens are not something the model reads.
174
+ export function attempt(fn) {
175
+ try { return fn(); } catch (e) { if (e === parser) return null; throw e; }
176
+ }
177
+
178
+ // ---- reading a tree against facts ---------------------------------------------------------------
179
+
180
+ const union = (a, b) => [...new Set([...a, ...b])].sort((x, y) => x - y);
181
+ const within = (a, b) => cmp(b.lo, a.lo) <= 0 && cmp(a.hi, b.hi) <= 0;
182
+
183
+ // Drops what another candidate does at least as well: fewer facts needed and an interval inside.
184
+ function prune(list, cap) {
185
+ const kept = [];
186
+ for (const c of list) {
187
+ if (kept.some((k) => k.needs.every((x) => c.needs.includes(x)) && within(k.iv, c.iv) && (!k.iv.inexact || c.iv.inexact))) continue;
188
+ for (let j = kept.length - 1; j >= 0; j--) {
189
+ const k = kept[j];
190
+ if (c.needs.every((x) => k.needs.includes(x)) && within(c.iv, k.iv) && (!c.iv.inexact || k.iv.inexact)) kept.splice(j, 1);
191
+ }
192
+ kept.push(c);
193
+ }
194
+ if (kept.length <= cap) return kept;
195
+ const plain = kept.filter((c) => !c.needs.length);
196
+ const rest = kept.filter((c) => c.needs.length).sort((a, b) => cmp(qSub(a.iv.hi, a.iv.lo), qSub(b.iv.hi, b.iv.lo)) || a.needs.length - b.needs.length);
197
+ return [...plain, ...rest].slice(0, cap);
198
+ }
199
+
200
+ // Every interval the tree can take, with the facts each needs. `field(leaf)` gives a leaf's own
201
+ // candidates as { needs, lo, hi } with BigInt ends, the widest of them needing nothing.
202
+ export function candidates(tree, field, cap = 64) {
203
+ const both = (a, b, fn) => {
204
+ const out = [];
205
+ for (const x of a) {
206
+ for (const y of b) {
207
+ const r = fn(x.iv, y.iv);
208
+ if (r) out.push({ needs: union(x.needs, y.needs), iv: r });
209
+ }
210
+ }
211
+ return prune(out, cap);
212
+ };
213
+ const walk = (t) => {
214
+ switch (t.k) {
215
+ case 'const': return [{ needs: [], iv: iv(t.v, t.v) }];
216
+ case 'range': return [{ needs: [], iv: iv(t.lo, t.hi) }];
217
+ case 'field': return prune((field(t) || []).map((c) => ({ needs: c.needs, iv: iv(q(c.lo), q(c.hi)) })), cap);
218
+ case 'neg': return walk(t.a).map((c) => ({ needs: c.needs, iv: ivNeg(c.iv) }));
219
+ case 'mod': return both(walk(t.a), walk(t.b), (a, b) => ivMod(a, b));
220
+ case 'bin': {
221
+ const fn = t.op === '+' ? ivAdd : t.op === '-' ? ivSub : t.op === '*' ? ivMul : ivDiv;
222
+ return both(walk(t.a), walk(t.b), fn);
223
+ }
224
+ default: return [];
225
+ }
226
+ };
227
+ return walk(tree);
228
+ }
229
+
230
+ // The fields a tree reads.
231
+ export function leaves(tree, out = []) {
232
+ if (tree.k === 'field') out.push(tree);
233
+ else if (tree.a) { leaves(tree.a, out); if (tree.b) leaves(tree.b, out); }
234
+ return out;
235
+ }