@portll/cobolwork 0.2.75 → 0.2.117

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 (52) hide show
  1. package/README.md +69 -15
  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/consequence.mjs +29 -0
  8. package/lib/control.mjs +1052 -91
  9. package/lib/csd.mjs +17 -2
  10. package/lib/dataflow.mjs +245 -78
  11. package/lib/enterprise-options.mjs +255 -0
  12. package/lib/equivalence.mjs +124 -0
  13. package/lib/evidence/cli.mjs +65 -0
  14. package/lib/evidence/journal.mjs +85 -0
  15. package/lib/evidence/record.mjs +110 -0
  16. package/lib/evidence/run.mjs +111 -0
  17. package/lib/evidence/seal.mjs +177 -0
  18. package/lib/evidence/slsa.mjs +64 -0
  19. package/lib/evidence/sshsig.mjs +172 -0
  20. package/lib/evidence/store.mjs +170 -0
  21. package/lib/evidence/verify.mjs +167 -0
  22. package/lib/explain.mjs +4 -1
  23. package/lib/exploitability.mjs +263 -0
  24. package/lib/ironwork.mjs +111 -0
  25. package/lib/kernel/findings.mjs +11 -0
  26. package/lib/kernel/registry.mjs +4 -0
  27. package/lib/option-diff.mjs +49 -0
  28. package/lib/options.mjs +109 -46
  29. package/lib/parser.mjs +29 -7
  30. package/lib/policy.mjs +8 -3
  31. package/lib/precompile.mjs +4 -0
  32. package/lib/reach.mjs +29 -12
  33. package/lib/revision.json +1 -1
  34. package/lib/sarif.mjs +1 -0
  35. package/lib/sbom.mjs +147 -0
  36. package/lib/scan.mjs +93 -27
  37. package/lib/sets/cics.mjs +114 -0
  38. package/lib/sets/flow.mjs +82 -2
  39. package/lib/sets/jcl.mjs +67 -0
  40. package/lib/sets/semantics.mjs +377 -0
  41. package/lib/sets/web.mjs +34 -10
  42. package/lib/sets/zowe.mjs +220 -0
  43. package/lib/sources.mjs +10 -0
  44. package/lib/tui/app.mjs +31 -13
  45. package/lib/tui/model.mjs +17 -2
  46. package/lib/utilities.mjs +85 -13
  47. package/lib/verify.mjs +128 -0
  48. package/package.json +1 -1
  49. package/rules/compliance-dora.json +507 -0
  50. package/rules/compliance-ffiec.json +507 -0
  51. package/rules/compliance-nist80053.json +507 -0
  52. package/schema/cobolwork.policy.schema.json +2 -1
package/README.md CHANGED
@@ -23,8 +23,10 @@ COBOL, exits zero and reports a clean result.
23
23
  ## Install
24
24
 
25
25
  npm install -g @portll/cobolwork
26
+ pip install cobolwork
26
27
 
27
- The command it installs is `cobolwork`. The same package is attached to each
28
+ Either installs the command `cobolwork`; the PyPI package runs the same files with the Node.js on
29
+ your PATH. The same package is attached to each
28
30
  [release](https://github.com/Portll/cobolwork/releases) as `cobolwork-<version>.tgz`, which is the
29
31
  one to pin, and as `cobolwork.tgz`, which
30
32
  `npm install -g https://github.com/Portll/cobolwork/releases/latest/download/cobolwork.tgz`
@@ -34,7 +36,8 @@ follows. To run from a checkout instead:
34
36
 
35
37
  Node 18 or later. No dependencies, runtime or development. `npm link` puts `cobolwork` on your PATH;
36
38
  adding `bin/` to PATH does the same thing. GnuCOBOL is needed only to regrade the parser or validate
37
- 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).
38
41
 
39
42
  ## What it does
40
43
 
@@ -42,7 +45,7 @@ benchmark cases.
42
45
  |---|---|
43
46
  | `cobolwork scan <path>` | every rule set below, as JSON or SARIF |
44
47
  | `cobolwork inventory <path>` | inventory (and what couldn't be read) |
45
- | `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 |
46
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 |
47
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 |
48
51
  | `cobolwork parse <file>` | one file's structure, for debugging |
@@ -126,12 +129,63 @@ decides who acts on it. Every rule declares one of eight finding types:
126
129
  `coverage` and `context` are exactly the `info` rules (not defects). A consumer that counts these
127
130
  findings should leave them out of its counts.
128
131
 
129
- None of the kinds says *exploitable*, on purpose. Whether a route can be used also depends on who may
130
- start the transaction or job that reaches it, and on what the running system enforces, and no
131
- repository holds those facts. A finding names the entry points that reach it (`startedBy`). `path`
132
- is the strongest claim the tool makes: a route found by reading the code, not by running it. Its
133
- precision has been measured on benchmark cases this project wrote, not yet on an independently
134
- 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.
135
189
 
136
190
  ### What each finding lets someone do, and the fix
137
191
 
@@ -189,7 +243,7 @@ A scan says what each rule set read, not only what it found:
189
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" }
190
244
  ],
191
245
  "flowModel": "byte-range",
192
- "toolVersion": "0.2.0"
246
+ "toolVersion": "0.2.90"
193
247
  }
194
248
  ```
195
249
 
@@ -234,7 +288,7 @@ gate. An incomplete scan is undecided, never a pass.
234
288
 
235
289
  The compiler options are held to the policy too. A program compiled without `SSRANGE`, or with
236
290
  `SSRANGE(MSG)`, which reports a bad subscript and carries on, fails; for `cobc` the missing `-fec`
237
- 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>`
238
292
  names an organisation's floor, which a repository can tighten and never loosen.
239
293
  [docs/spec/build-gate.md](docs/spec/build-gate.md) is the full specification.
240
294
 
@@ -259,7 +313,7 @@ stand-in in `diag/precompiler.mjs`, which rewrites what the parser would otherwi
259
313
  tests compare the parser with the compiler's answers kept in `test/fixtures/parser/*.golden.json`,
260
314
  so they run without GnuCOBOL.
261
315
 
262
- `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
263
317
  with the flaw removed. `node bench/run.mjs` scores any scanner's findings against them, by rule and
264
318
  file, never by line, and `npm test` fails if any case scores differently from its declaration.
265
319
  `--validate` compiles every COBOL case with GnuCOBOL and checks every JCL case against the
@@ -289,9 +343,9 @@ Every rule is mapped to the clause of each framework that makes it an obligation
289
343
 
290
344
  | File | Instrument | Rules mapped |
291
345
  |---|---|---|
292
- | `rules/compliance-dora.json` | Regulation (EU) 2022/2554 (DORA) | 156 |
293
- | `rules/compliance-ffiec.json` | FFIEC IT Examination Handbook | 154, and 2 recorded as unmapped |
294
- | `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 |
295
349
 
296
350
  `node diag/map-compliance.mjs` refuses to write a quote the cached instrument does not contain, and
297
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
  }