@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
@@ -0,0 +1,255 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Enterprise COBOL's compiler options: every spelling each one answers to, and where it may be given.
3
+ //
4
+ // GENERATED - do not edit by hand. Run `node diag/generate-options.mjs` to rebuild it from
5
+ // provenance/enterprise-options.json, which names the IBM table and page behind each row.
6
+
7
+ // Each spelling's option, and whether the spelling turns the option off.
8
+ export const OPTION_SPELLINGS = {
9
+ "APOST": ["APOST/QUOTE", false],
10
+ "QUOTE": ["APOST/QUOTE", false],
11
+ "Q": ["APOST/QUOTE", false],
12
+ "ARITH": ["ARITH", false],
13
+ "AR": ["ARITH", false],
14
+ "CICS": ["CICS", false],
15
+ "NOCICS": ["CICS", true],
16
+ "CODEPAGE": ["CODEPAGE", false],
17
+ "CP": ["CODEPAGE", false],
18
+ "CURRENCY": ["CURRENCY", false],
19
+ "NOCURRENCY": ["CURRENCY", true],
20
+ "CURR": ["CURRENCY", false],
21
+ "NOCURR": ["CURRENCY", true],
22
+ "DBCS": ["DBCS", false],
23
+ "NODBCS": ["DBCS", true],
24
+ "NSYMBOL": ["NSYMBOL", false],
25
+ "NS": ["NSYMBOL", false],
26
+ "NUMBER": ["NUMBER", false],
27
+ "NONUMBER": ["NUMBER", true],
28
+ "NUM": ["NUMBER", false],
29
+ "NONUM": ["NUMBER", true],
30
+ "QUALIFY": ["QUALIFY", false],
31
+ "QUA": ["QUALIFY", false],
32
+ "SEQUENCE": ["SEQUENCE", false],
33
+ "NOSEQUENCE": ["SEQUENCE", true],
34
+ "SEQ": ["SEQUENCE", false],
35
+ "NOSEQ": ["SEQUENCE", true],
36
+ "SQL": ["SQL", false],
37
+ "NOSQL": ["SQL", true],
38
+ "SQLCCSID": ["SQLCCSID", false],
39
+ "NOSQLCCSID": ["SQLCCSID", true],
40
+ "SQLC": ["SQLCCSID", false],
41
+ "NOSQLC": ["SQLCCSID", true],
42
+ "SQLIMS": ["SQLIMS", false],
43
+ "NOSQLIMS": ["SQLIMS", true],
44
+ "SUPPRESS": ["SUPPRESS", false],
45
+ "NOSUPPRESS": ["SUPPRESS", true],
46
+ "SUPP": ["SUPPRESS", false],
47
+ "NOSUPP": ["SUPPRESS", true],
48
+ "WORD": ["WORD", false],
49
+ "NOWORD": ["WORD", true],
50
+ "WD": ["WORD", false],
51
+ "NOWD": ["WORD", true],
52
+ "XMLPARSE": ["XMLPARSE", false],
53
+ "XP": ["XMLPARSE", false],
54
+ "INTDATE": ["INTDATE", false],
55
+ "CONDCOMP": ["CONDCOMP", false],
56
+ "CC": ["CONDCOMP", false],
57
+ "LANGUAGE": ["LANGUAGE", false],
58
+ "LANG": ["LANGUAGE", false],
59
+ "LINECOUNT": ["LINECOUNT", false],
60
+ "LC": ["LINECOUNT", false],
61
+ "LIST": ["LIST", false],
62
+ "NOLIST": ["LIST", true],
63
+ "MAP": ["MAP", false],
64
+ "NOMAP": ["MAP", true],
65
+ "OFFSET": ["OFFSET", false],
66
+ "NOOFFSET": ["OFFSET", true],
67
+ "OFF": ["OFFSET", false],
68
+ "NOOFF": ["OFFSET", true],
69
+ "SOURCE": ["SOURCE", false],
70
+ "NOSOURCE": ["SOURCE", true],
71
+ "S": ["SOURCE", false],
72
+ "NOS": ["SOURCE", true],
73
+ "SPACE": ["SPACE", false],
74
+ "TERMINAL": ["TERMINAL", false],
75
+ "NOTERMINAL": ["TERMINAL", true],
76
+ "TERM": ["TERMINAL", false],
77
+ "NOTERM": ["TERMINAL", true],
78
+ "VBREF": ["VBREF", false],
79
+ "NOVBREF": ["VBREF", true],
80
+ "XREF": ["XREF", false],
81
+ "NOXREF": ["XREF", true],
82
+ "X": ["XREF", false],
83
+ "NOX": ["XREF", true],
84
+ "COMPILE": ["COMPILE", false],
85
+ "NOCOMPILE": ["COMPILE", true],
86
+ "C": ["COMPILE", false],
87
+ "NOC": ["COMPILE", true],
88
+ "COPYRIGHT": ["COPYRIGHT", false],
89
+ "NOCOPYRIGHT": ["COPYRIGHT", true],
90
+ "CPYR": ["COPYRIGHT", false],
91
+ "NOCPYR": ["COPYRIGHT", true],
92
+ "DECK": ["DECK", false],
93
+ "NODECK": ["DECK", true],
94
+ "D": ["DECK", false],
95
+ "NOD": ["DECK", true],
96
+ "NAME": ["NAME", false],
97
+ "NONAME": ["NAME", true],
98
+ "OBJECT": ["OBJECT", false],
99
+ "NOOBJECT": ["OBJECT", true],
100
+ "OBJ": ["OBJECT", false],
101
+ "NOOBJ": ["OBJECT", true],
102
+ "PGMNAME": ["PGMNAME", false],
103
+ "PGMN": ["PGMNAME", false],
104
+ "SERVICE": ["SERVICE", false],
105
+ "NOSERVICE": ["SERVICE", true],
106
+ "SERV": ["SERVICE", false],
107
+ "NOSERV": ["SERVICE", true],
108
+ "ADV": ["ADV", false],
109
+ "NOADV": ["ADV", true],
110
+ "AFP": ["AFP", false],
111
+ "ARCH": ["ARCH", false],
112
+ "AWO": ["AWO", false],
113
+ "NOAWO": ["AWO", true],
114
+ "BLOCK0": ["BLOCK0", false],
115
+ "NOBLOCK0": ["BLOCK0", true],
116
+ "DEFINE": ["DEFINE", false],
117
+ "NODEFINE": ["DEFINE", true],
118
+ "DEF": ["DEFINE", false],
119
+ "NODEF": ["DEFINE", true],
120
+ "DISPSIGN": ["DISPSIGN", false],
121
+ "DS": ["DISPSIGN", false],
122
+ "DLL": ["DLL", false],
123
+ "NODLL": ["DLL", true],
124
+ "EXPORTALL": ["EXPORTALL", false],
125
+ "NOEXPORTALL": ["EXPORTALL", true],
126
+ "EXP": ["EXPORTALL", false],
127
+ "NOEXP": ["EXPORTALL", true],
128
+ "FASTSRT": ["FASTSRT", false],
129
+ "NOFASTSRT": ["FASTSRT", true],
130
+ "FSRT": ["FASTSRT", false],
131
+ "NOFSRT": ["FASTSRT", true],
132
+ "HGPR": ["HGPR", false],
133
+ "INITIAL": ["INITIAL", false],
134
+ "NOINITIAL": ["INITIAL", true],
135
+ "INLINE": ["INLINE", false],
136
+ "NOINLINE": ["INLINE", true],
137
+ "INL": ["INLINE", false],
138
+ "NOINL": ["INLINE", true],
139
+ "INVDATA": ["INVDATA", false],
140
+ "NOINVDATA": ["INVDATA", true],
141
+ "NOINV": ["INVDATA", true],
142
+ "INVD": ["INVDATA", false],
143
+ "NOINVD": ["INVDATA", true],
144
+ "JAVAIOP": ["JAVAIOP", false],
145
+ "NOJAVAIOP": ["JAVAIOP", true],
146
+ "JIOP": ["JAVAIOP", false],
147
+ "NOJIOP": ["JAVAIOP", true],
148
+ "LP": ["LP", false],
149
+ "MAXPCF": ["MAXPCF", false],
150
+ "NUMCHECK": ["NUMCHECK", false],
151
+ "NONUMCHECK": ["NUMCHECK", true],
152
+ "NC": ["NUMCHECK", false],
153
+ "NONC": ["NUMCHECK", true],
154
+ "NUMPROC": ["NUMPROC", false],
155
+ "OPTIMIZE": ["OPTIMIZE", false],
156
+ "NOOPTIMIZE": ["OPTIMIZE", true],
157
+ "OPT": ["OPTIMIZE", false],
158
+ "OUTDD": ["OUTDD", false],
159
+ "OUT": ["OUTDD", false],
160
+ "PARMCHECK": ["PARMCHECK", false],
161
+ "NOPARMCHECK": ["PARMCHECK", true],
162
+ "PC": ["PARMCHECK", false],
163
+ "NOPC": ["PARMCHECK", true],
164
+ "TRUNC": ["TRUNC", false],
165
+ "TUNE": ["TUNE", false],
166
+ "VLR": ["VLR", false],
167
+ "ZONECHECK": ["ZONECHECK", false],
168
+ "NOZONECHECK": ["ZONECHECK", true],
169
+ "NOZC": ["ZONECHECK", true],
170
+ "ZC": ["ZONECHECK", false],
171
+ "ZONEDATA": ["ZONEDATA", false],
172
+ "ZD": ["ZONEDATA", false],
173
+ "ZWB": ["ZWB", false],
174
+ "NOZWB": ["ZWB", true],
175
+ "BUFSIZE": ["BUFSIZE", false],
176
+ "BUF": ["BUFSIZE", false],
177
+ "DATA": ["DATA", false],
178
+ "DYNAM": ["DYNAM", false],
179
+ "NODYNAM": ["DYNAM", true],
180
+ "DYN": ["DYNAM", false],
181
+ "NODYN": ["DYNAM", true],
182
+ "RENT": ["RENT", false],
183
+ "NORENT": ["RENT", true],
184
+ "RMODE": ["RMODE", false],
185
+ "STGOPT": ["STGOPT", false],
186
+ "NOSTGOPT": ["STGOPT", true],
187
+ "SO": ["STGOPT", false],
188
+ "NOSO": ["STGOPT", true],
189
+ "DIAGTRUNC": ["DIAGTRUNC", false],
190
+ "NODIAGTRUNC": ["DIAGTRUNC", true],
191
+ "DTR": ["DIAGTRUNC", false],
192
+ "NODTR": ["DIAGTRUNC", true],
193
+ "DUMP": ["DUMP", false],
194
+ "NODUMP": ["DUMP", true],
195
+ "DU": ["DUMP", false],
196
+ "NODU": ["DUMP", true],
197
+ "FLAG": ["FLAG", false],
198
+ "NOFLAG": ["FLAG", true],
199
+ "F": ["FLAG", false],
200
+ "NOF": ["FLAG", true],
201
+ "FLAGSTD": ["FLAGSTD", false],
202
+ "NOFLAGSTD": ["FLAGSTD", true],
203
+ "INITCHECK": ["INITCHECK", false],
204
+ "NOINITCHECK": ["INITCHECK", true],
205
+ "IC": ["INITCHECK", false],
206
+ "NOIC": ["INITCHECK", true],
207
+ "RULES": ["RULES", false],
208
+ "NORULES": ["RULES", true],
209
+ "SSRANGE": ["SSRANGE", false],
210
+ "NOSSRANGE": ["SSRANGE", true],
211
+ "SSR": ["SSRANGE", false],
212
+ "NOSSR": ["SSRANGE", true],
213
+ "TEST": ["TEST", false],
214
+ "NOTEST": ["TEST", true],
215
+ "ADATA": ["ADATA", false],
216
+ "NOADATA": ["ADATA", true],
217
+ "COPYLOC": ["COPYLOC", false],
218
+ "NOCOPYLOC": ["COPYLOC", true],
219
+ "CPLC": ["COPYLOC", false],
220
+ "NOCPLC": ["COPYLOC", true],
221
+ "EXIT": ["EXIT", false],
222
+ "NOEXIT": ["EXIT", true],
223
+ "NOEX": ["EXIT", true],
224
+ "EX": ["EXIT", false],
225
+ "MDECK": ["MDECK", false],
226
+ "NOMDECK": ["MDECK", true],
227
+ "NOMD": ["MDECK", true],
228
+ "MD": ["MDECK", false],
229
+ "OPTFILE": ["OPTFILE", false],
230
+ "SMARTBIN": ["SMARTBIN", false],
231
+ "NOSMARTBIN": ["SMARTBIN", true],
232
+ "THREAD": ["THREAD", false],
233
+ "NOTHREAD": ["THREAD", true],
234
+ "VSAMOPENFS": ["VSAMOPENFS", false],
235
+ "VS": ["VSAMOPENFS", false],
236
+ };
237
+
238
+ // The options with a rule on where they may be given: not in a CBL or PROCESS statement (process),
239
+ // in one only before a batch compilation's first program (firstProgramOnly), or not as an
240
+ // installation default (installationDefault). Every other option may be given anywhere.
241
+ export const OPTION_PLACEMENT = {
242
+ "CICS": { firstProgramOnly: true },
243
+ "SQL": { firstProgramOnly: true },
244
+ "SQLIMS": { firstProgramOnly: true },
245
+ "DEFINE": { firstProgramOnly: true },
246
+ "JAVAIOP": { firstProgramOnly: true },
247
+ "LP": { firstProgramOnly: true },
248
+ "OUTDD": { firstProgramOnly: true },
249
+ "BUFSIZE": { firstProgramOnly: true },
250
+ "ADATA": { process: false },
251
+ "COPYLOC": { firstProgramOnly: true },
252
+ "EXIT": { process: false },
253
+ "MDECK": { process: false },
254
+ "OPTFILE": { installationDefault: false },
255
+ };
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // The build's change-assurance check (docs/spec/evidence.md §12): an ironwork equivalence
3
+ // statement for each program the change edits, matched to the base and head bytes by digest,
4
+ // verdict equivalent, coverage measured, and signed by an allowed signer where signers are named.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { basename, dirname, join } from 'node:path';
9
+ import { directoryTree } from './kernel/source-tree.mjs';
10
+ import { parseSource } from './parser.mjs';
11
+ import { isProgram, readSource, relPath } from './sources.mjs';
12
+ import { NAMESPACE, PAYLOAD_TYPE, cleanGitEnv, pae } from './evidence/seal.mjs';
13
+ import { verifySshsig } from './evidence/sshsig.mjs';
14
+
15
+ export const EQUIVALENCE_PREDICATE = 'https://github.com/Portll/ironwork/blob/main/docs/evidence.md#equivalence-v1';
16
+ const PASSING = new Set(['equivalent', 'equivalent-as-declared']);
17
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
18
+
19
+ // { statement, signed, signer, reason } for one file holding a statement or a DSSE envelope of one.
20
+ export function readStatement(path, allowed = null) {
21
+ let doc;
22
+ try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { statement: null, reason: `${basename(path)} is not JSON (${e.message})` }; }
23
+ if (doc && doc.payloadType === PAYLOAD_TYPE && typeof doc.payload === 'string') {
24
+ const payload = Buffer.from(doc.payload, 'base64');
25
+ let statement;
26
+ try { statement = JSON.parse(payload.toString('utf8')); } catch { return { statement: null, reason: `${basename(path)} carries no statement` }; }
27
+ const checks = (doc.signatures || []).map((s) => (allowed ? verifySshsig({ blob: Buffer.from(String(s.sig), 'base64'), message: pae(PAYLOAD_TYPE, payload), namespace: NAMESPACE, allowed }) : { valid: null }));
28
+ const good = checks.find((c) => c.valid === true);
29
+ return { statement, signed: Boolean(good), signer: good ? good.principals : null, reason: good ? null : allowed ? 'no signature from an allowed signer' : 'no allowed signers were given' };
30
+ }
31
+ return { statement: doc, signed: false, signer: null, reason: 'the statement is not signed' };
32
+ }
33
+
34
+ const subjectDigest = (st, side) => {
35
+ const s = (st.subject || []).find((x) => String(x.name || '').startsWith(`${side}:`));
36
+ return s && s.digest ? s.digest.sha256 : null;
37
+ };
38
+
39
+ // The copybooks a program resolves, by path relative to its tree and digest.
40
+ function closure(root, file, idx, systemDirs) {
41
+ let res;
42
+ try {
43
+ res = parseSource(readSource(file).text, file, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(file), copyFormat: 'auto', systemDirs });
44
+ } catch { return null; }
45
+ const out = new Map();
46
+ for (const c of res.copies) {
47
+ if (c.status !== 'resolved' || !c.path) continue;
48
+ try { out.set(relPath(root, c.path), sha256(readFileSync(c.path))); } catch { out.set(relPath(root, c.path), null); }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Programs the change edits or adds, or whose copybooks it edits: [{ path, base, head, via }] with
54
+ // each side's source digest and the copybooks that changed under an unchanged program.
55
+ export function changedPrograms(baseDir, headDir, allow = null, systemDirs = []) {
56
+ const out = [];
57
+ const headTree = directoryTree(headDir);
58
+ const baseIdx = directoryTree(baseDir).index;
59
+ for (const head of headTree.list().filter((p) => isProgram(p) && (!allow || allow.has(p))).sort()) {
60
+ const path = relPath(headDir, head);
61
+ const h = sha256(readFileSync(head));
62
+ const baseFile = join(baseDir, path);
63
+ const b = existsSync(baseFile) ? sha256(readFileSync(baseFile)) : null;
64
+ if (b !== h) { out.push({ path, base: b, head: h }); continue; }
65
+ const after = closure(headDir, head, headTree.index, systemDirs);
66
+ const before = closure(baseDir, baseFile, baseIdx, systemDirs);
67
+ if (!after || !before) continue;
68
+ const via = [...new Set([...after.keys(), ...before.keys()])].filter((k) => after.get(k) !== before.get(k)).sort();
69
+ if (via.length) out.push({ path, base: b, head: h, via, viaDigests: via.map((k) => after.get(k)).filter(Boolean) });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // Whether a commit in base..head reads as authored by a tool, from its identities and trailers.
75
+ const TOOL = /\[bot\]|(^|[^a-z])bot@|copilot|claude|anthropic|openai|codex|devin|cursor|gemini|aider|sweep-ai|dependabot|renovate/i;
76
+ export function machineAuthored(repo, base, head, env = process.env) {
77
+ const r = spawnSync('git', ['-C', repo, 'rev-list', '--format=%x1e%an%x00%ae%x00%cn%x00%ce%x00%B', `${base}..${head}`], { encoding: 'utf8', timeout: 30000, maxBuffer: 16 << 20, env: cleanGitEnv(env) });
78
+ if (r.status !== 0) return { known: false, commits: [] };
79
+ const commits = [];
80
+ for (const chunk of r.stdout.split('\x1e').slice(1)) {
81
+ const [an, ae, cn, ce, body = ''] = chunk.split('\x00');
82
+ const trailers = body.split('\n').filter((l) => /^(co-authored-by|generated-by|assisted-by):/i.test(l.trim()));
83
+ const signals = [an, ae, cn, ce, ...trailers, ...(/generated with/i.test(body) ? ['generated with'] : [])].filter((x) => x && TOOL.test(x));
84
+ if (signals.length) commits.push({ signals: [...new Set(signals.map((s) => s.trim().slice(0, 80)))] });
85
+ }
86
+ return { known: true, commits };
87
+ }
88
+
89
+ // The equivalence check for one build: which changed programs a statement covers, and whether the
90
+ // policy's requirement is met. `required` is true, false, or null when machine authorship is unknown.
91
+ export function checkEquivalence({ baseDir, headDir, allow, files = [], allowed = null, mode = 'never', repo = null, base = null, head = null, systemDirs = [], env = process.env }) {
92
+ const changed = changedPrograms(baseDir, headDir, allow, systemDirs);
93
+ const statements = files.map((f) => ({ file: basename(f), ...readStatement(f, allowed) }));
94
+ const problems = [];
95
+ for (const s of statements) {
96
+ if (!s.statement) { problems.push(s.reason); continue; }
97
+ if (s.statement.predicateType !== EQUIVALENCE_PREDICATE) { problems.push(`${s.file} is not an ironwork equivalence statement`); continue; }
98
+ const h = subjectDigest(s.statement, 'head');
99
+ const b = subjectDigest(s.statement, 'base');
100
+ if (!changed.some((c) => c.head === h && (c.base === b || (c.base === null && b === null)))) {
101
+ problems.push(`${s.file} names head ${h ? h.slice(0, 12) : 'nothing'} and base ${b ? b.slice(0, 12) : 'nothing'}, which match no program this change edits`);
102
+ }
103
+ }
104
+ const programs = changed.map((c) => {
105
+ const s = statements.find((x) => x.statement && subjectDigest(x.statement, 'head') === c.head && subjectDigest(x.statement, 'base') === c.base);
106
+ const via = c.via ? { via: c.via } : {};
107
+ if (!s) return { path: c.path, ...via, statement: null, ok: false, because: c.via ? `no equivalence statement names this program, whose copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')} the change edits` : 'no equivalence statement names this change' };
108
+ const p = s.statement.predicate || {};
109
+ const verdict = p.verdict || null;
110
+ const covered = p.coverage !== null && p.coverage !== undefined;
111
+ const ranOn = new Set(((p.closure && p.closure.head) || []).map((x) => x.sha256));
112
+ const missing = (c.viaDigests || []).filter((d) => !ranOn.has(d));
113
+ const because = !PASSING.has(verdict) ? `the statement's verdict is ${verdict}${Array.isArray(p.inconclusive) && p.inconclusive.length ? ` (${p.inconclusive.join('; ')})` : ''}`
114
+ : missing.length ? `the statement does not show the head run read the edited copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')}`
115
+ : !covered ? 'the statement measured no coverage of the changed paragraphs, so it is inconclusive'
116
+ : allowed && !s.signed ? `the statement is ${s.reason}`
117
+ : null;
118
+ const unreached = covered && Array.isArray(p.coverage.unreached) ? p.coverage.unreached : [];
119
+ return { path: c.path, ...via, statement: s.file, verdict, signed: s.signed, signer: s.signer, coverage: covered, ...(unreached.length ? { unreached } : {}), ok: because === null && !unreached.length, ...(because || unreached.length ? { because: because || `the inputs never reached ${unreached.join(', ')}` } : {}) };
120
+ });
121
+ const authorship = mode === 'machineAuthored' && repo && base ? machineAuthored(repo, base, head || 'HEAD', env) : null;
122
+ const required = mode === 'always' ? true : mode === 'machineAuthored' ? (authorship && authorship.known ? authorship.commits.length > 0 : null) : false;
123
+ return { mode, required, ...(authorship ? { machineAuthored: authorship } : {}), programs, problems };
124
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // `cobolwork evidence verify|seal|anchor`: the command half of docs/spec/evidence.md §7-9. Returns
3
+ // the exit status; writes one JSON document through `write`.
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { anchorGit, envelopeFor, seal, sealFiles, timeStampRequest } from './seal.mjs';
7
+ import { parseAllowedSigners } from './sshsig.mjs';
8
+ import { EvidenceRefusal, evidenceDirFrom } from './store.mjs';
9
+ import { verifyEvidence } from './verify.mjs';
10
+
11
+ const VERBS = ['verify', 'seal', 'anchor', 'sign'];
12
+
13
+ function anchorNewest(dir, opts) {
14
+ const seals = sealFiles(dir);
15
+ if (!seals.length) throw new EvidenceRefusal('there is no seal to anchor; run evidence seal first');
16
+ const newest = seals[seals.length - 1];
17
+ const text = readFileSync(newest.path, 'utf8');
18
+ const payload = JSON.parse(Buffer.from(JSON.parse(text).payload, 'base64').toString('utf8'));
19
+ const chain = /^ledger:([0-9a-f]{32})$/.exec(payload.subject[0].name)[1];
20
+ const out = { seal: newest.name };
21
+ if (opts.anchorGit) out.git = anchorGit(newest.path, { repo: resolve(opts.anchorGit), chain, seq: newest.seq, push: opts.push === true });
22
+ if (opts.tsq) {
23
+ const req = timeStampRequest(Buffer.from(text, 'utf8'));
24
+ writeFileSync(resolve(opts.tsq), req.der);
25
+ out.tsq = { path: resolve(opts.tsq), sha256: req.digest, nonce: req.nonce };
26
+ }
27
+ return out;
28
+ }
29
+
30
+ export function evidenceCommand(verb, opts, { toolVersion, write, env = process.env }) {
31
+ if (!VERBS.includes(verb)) throw new EvidenceRefusal(`evidence takes ${VERBS.join(', ')}; got ${verb || 'nothing'}`);
32
+ if (verb === 'sign') {
33
+ const file = opts._[2];
34
+ if (!file) throw new EvidenceRefusal('evidence sign needs the statement file to sign');
35
+ const payload = readFileSync(resolve(file));
36
+ JSON.parse(payload.toString('utf8'));
37
+ const env2 = envelopeFor(payload, { sshKey: opts.sshKey ? resolve(opts.sshKey) : null, signer: opts.signer ? resolve(opts.signer) : null, expectKeyid: opts.expectKey || null, env });
38
+ const text = `${JSON.stringify(env2)}\n`;
39
+ if (opts.out) writeFileSync(resolve(opts.out), text); else write(text);
40
+ return 0;
41
+ }
42
+ const dir = evidenceDirFrom(opts, env);
43
+ if (!dir) throw new EvidenceRefusal('evidence needs --evidence <dir> or COBOLWORK_EVIDENCE');
44
+ const emit = (doc) => write(`${JSON.stringify({ tool: 'cobolwork-evidence', verb, ...doc }, null, opts.quiet ? 0 : 1)}\n`);
45
+
46
+ if (verb === 'verify') {
47
+ const allowed = opts.allowedSigners ? parseAllowedSigners(readFileSync(resolve(opts.allowedSigners), 'utf8')) : null;
48
+ const max = opts.maxUnsealed === undefined ? 0 : Number(opts.maxUnsealed);
49
+ if (!Number.isSafeInteger(max) || max < 0) throw new EvidenceRefusal(`--max-unsealed takes a whole number; got ${opts.maxUnsealed}`);
50
+ const verdict = verifyEvidence(dir, { allowed, anchorGit: opts.anchorGit ? resolve(opts.anchorGit) : null, anchorRef: opts.ref || null, maxUnsealed: max, env });
51
+ emit(verdict);
52
+ return verdict.exit;
53
+ }
54
+ if (verb === 'seal') {
55
+ if (opts.sshKey && opts.signer) throw new EvidenceRefusal('one signer per seal: --ssh-key or --signer');
56
+ const s = seal(dir, { sshKey: opts.sshKey ? resolve(opts.sshKey) : null, signer: opts.signer ? resolve(opts.signer) : null, expectKeyid: opts.expectKey || null, toolVersion, env });
57
+ const out = { seq: s.seq, path: s.path, sha256: s.digest, signed: s.signed, ledgerLength: s.statement.predicate.ledgerLength, tip: s.statement.subject[0].digest.sha256 };
58
+ if (opts.anchorGit || opts.tsq) out.anchor = anchorNewest(dir, opts);
59
+ emit(out);
60
+ return 0;
61
+ }
62
+ if (!opts.anchorGit && !opts.tsq) throw new EvidenceRefusal('anchor needs --anchor-git <repo> or --tsq <file>');
63
+ emit(anchorNewest(dir, opts));
64
+ return 0;
65
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // A run journal (runs/<runId>.jsonl) and the ledger (ledger.jsonl) that chains the runs' tips.
3
+ // docs/spec/evidence.md §5-6.
4
+ import { randomBytes } from 'node:crypto';
5
+ import { closeSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { makeRecord, newChain } from './record.mjs';
8
+ import { EvidenceRefusal, createExclusive, lastLine, openAppend, prepareDir, syncClose, takeLock, writeAll } from './store.mjs';
9
+
10
+ export const LEDGER = 'ledger.jsonl';
11
+ export const LOCK = 'ledger.lock';
12
+
13
+ const isoNow = () => new Date().toISOString();
14
+ const runStamp = (iso) => iso.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
15
+
16
+ // Appends to the ledger; the caller holds the lock. A torn ledger is refused rather than extended.
17
+ function appendLedger(dir, entries, now) {
18
+ const path = join(dir, LEDGER);
19
+ const { line, torn } = lastLine(path);
20
+ if (torn) throw new EvidenceRefusal(`${path} ends in a partial line; verify it before anything is appended`);
21
+ let prev = line ? JSON.parse(line) : null;
22
+ const fd = openAppend(path);
23
+ try {
24
+ const out = [];
25
+ if (!prev) entries = [{ kind: 'genesis', fields: { createdAt: now() } }, ...entries];
26
+ const chain = prev ? prev.chain : newChain();
27
+ for (const { kind, fields } of entries) {
28
+ const { record, line: text } = makeRecord({ chain, prev, kind, fields, at: now() });
29
+ writeAll(fd, text);
30
+ out.push(record);
31
+ prev = record;
32
+ }
33
+ return out;
34
+ } finally { syncClose(fd); }
35
+ }
36
+
37
+ // `roots` is what the journal records; `readRoots` are the trees the run reads, which the evidence
38
+ // directory must not be inside.
39
+ export function openJournal(dir, { command, argv = [], roots = [], readRoots = [], toolVersion, toolRevision = null, now = isoNow, lock = {} }) {
40
+ const real = prepareDir(dir, readRoots);
41
+ const started = Date.now();
42
+ const openedAt = now();
43
+ const id = `${runStamp(openedAt)}-${randomBytes(8).toString('hex')}`;
44
+ const path = join(real, 'runs', `${id}.jsonl`);
45
+ const fd = createExclusive(path);
46
+ const chain = newChain();
47
+ const counts = {};
48
+ let prev = null;
49
+ let closed = false;
50
+
51
+ const append = (kind, fields) => {
52
+ if (closed) throw new EvidenceRefusal(`run ${id} is closed`);
53
+ const { record, line } = makeRecord({ chain, prev, kind, fields, at: prev ? now() : openedAt });
54
+ writeAll(fd, line);
55
+ prev = record;
56
+ counts[kind] = (counts[kind] || 0) + 1;
57
+ return record;
58
+ };
59
+
60
+ append('open', { tool: 'cobolwork', toolVersion, toolRevision, command, argv, roots, node: process.version, platform: process.platform });
61
+
62
+ // Writes close, then the ledger record carrying this journal's tip. Returns the ledger outcome.
63
+ const close = ({ exit }) => {
64
+ const held = takeLock(join(real, LOCK), lock);
65
+ const ledger = held ? 'recorded' : 'unrecorded';
66
+ try {
67
+ append('close', { exit, counts: { ...counts }, durationMs: Math.max(0, Date.now() - started), ledger });
68
+ closed = true;
69
+ syncClose(fd);
70
+ if (!held) return { ledger, reason: 'the ledger lock could not be had' };
71
+ const entries = [];
72
+ if (held.broken) entries.push({ kind: 'lock-broken', fields: held.broken });
73
+ entries.push({ kind: 'run', fields: { run: id, runChain: chain, runLength: prev.seq + 1, runTip: prev.hash } });
74
+ appendLedger(real, entries, now);
75
+ return { ledger };
76
+ } catch (e) {
77
+ if (!closed) { closed = true; try { closeSync(fd); } catch { /* already closed */ } }
78
+ return { ledger: 'unrecorded', reason: e.message };
79
+ } finally {
80
+ if (held) held.release();
81
+ }
82
+ };
83
+
84
+ return { id, path, dir: real, chain, append, close, get tip() { return prev; } };
85
+ }
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // One evidence record: its canonical JSON, its SHA-256 under a domain tag, and the fields each kind
3
+ // may carry (docs/spec/evidence.md §4-6). The writer refuses anything outside these tables, so a
4
+ // new field is a change to the spec rather than a silent addition.
5
+ import { createHash, randomBytes } from 'node:crypto';
6
+
7
+ export const DOMAIN = 'cobolwork-evidence/v1\n';
8
+ export const ZERO = '0'.repeat(64);
9
+ export const VERSION = 1;
10
+
11
+ const HEX64 = /^[0-9a-f]{64}$/;
12
+ const HEX32 = /^[0-9a-f]{32}$/;
13
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
14
+ const COMMON = ['v', 'chain', 'seq', 'at', 'kind', 'prev', 'hash'];
15
+
16
+ const str = (x) => typeof x === 'string';
17
+ const int = (x) => Number.isSafeInteger(x) && x >= 0;
18
+ const hex64 = (x) => str(x) && HEX64.test(x);
19
+ const hex32 = (x) => str(x) && HEX32.test(x);
20
+ const strOrNull = (x) => x === null || str(x);
21
+ const any = () => true;
22
+ const strings = (x) => Array.isArray(x) && x.every(str);
23
+
24
+ // kind -> { field: check }; a field listed as required must be present.
25
+ export const KINDS = {
26
+ open: { fields: { tool: str, toolVersion: str, toolRevision: any, command: str, argv: strings, roots: strings, node: str, platform: str }, required: ['tool', 'toolVersion', 'command', 'argv', 'roots'] },
27
+ input: { fields: { root: int, path: str, sha256: hex64, bytes: int }, required: ['root', 'path', 'sha256'] },
28
+ finding: { fields: { fingerprint: str, rule: str, tier: str, path: str, line: int }, required: ['fingerprint', 'rule'] },
29
+ suppressed: { fields: { fingerprint: str, by: str, who: strOrNull, expires: strOrNull, reasonSha256: (x) => x === null || hex64(x) }, required: ['fingerprint', 'by'] },
30
+ 'baseline-write': { fields: { added: strings, removed: strings, who: strOrNull, expires: strOrNull, reasonSha256: (x) => x === null || hex64(x), path: str, sha256: hex64 }, required: ['added', 'removed', 'sha256'] },
31
+ witness: { fields: { fingerprint: str, outcome: str, who: strOrNull, when: strOrNull, system: strOrNull, sourceSha256: hex64 }, required: ['fingerprint', 'outcome'] },
32
+ verdict: { fields: { verdict: str, checks: any, relaxed: strings, exit: int }, required: ['verdict'] },
33
+ output: { fields: { name: str, sha256: hex64, bytes: int, path: str, stdout: (x) => x === true }, required: ['name', 'sha256'] },
34
+ close: { fields: { exit: (x) => x === null || int(x), counts: any, durationMs: int, ledger: str }, required: ['exit'] },
35
+ // Written by ironwork's run journal, in this format, so one verifier reads both tools.
36
+ dd: { fields: { dd: str, event: (x) => ['open', 'close', 'end'].includes(x), mode: str, sha256: hex64, bytes: int }, required: ['dd', 'event'] },
37
+ call: { fields: { program: str, from: str, sha256: hex64 }, required: ['program'] },
38
+ abend: { fields: { code: str, file: str, line: int }, required: ['code'] },
39
+ genesis: { fields: { createdAt: str, rotatedFrom: any }, required: ['createdAt'] },
40
+ run: { fields: { run: str, runChain: hex32, runLength: int, runTip: hex64 }, required: ['run', 'runChain', 'runLength', 'runTip'] },
41
+ 'lock-broken': { fields: { holderPid: int, ageMs: int }, required: ['holderPid', 'ageMs'] },
42
+ };
43
+
44
+ export const JOURNAL_KINDS = new Set(['open', 'input', 'finding', 'suppressed', 'baseline-write', 'witness', 'verdict', 'output', 'close', 'dd', 'call', 'abend']);
45
+ export const LEDGER_KINDS = new Set(['genesis', 'run', 'lock-broken']);
46
+
47
+ export const newChain = () => randomBytes(16).toString('hex');
48
+
49
+ // Keys sorted by UTF-16 code unit at every depth, integers only, no undefined.
50
+ export function canonical(value) {
51
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
52
+ if (typeof value === 'number') {
53
+ if (!Number.isSafeInteger(value)) throw new TypeError(`an evidence record holds integers only, not ${value}`);
54
+ return String(value);
55
+ }
56
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
57
+ if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
58
+ return `{${Object.keys(value).sort().map((k) => {
59
+ if (value[k] === undefined) throw new TypeError(`an evidence record holds no undefined value (${k})`);
60
+ return `${JSON.stringify(k)}:${canonical(value[k])}`;
61
+ }).join(',')}}`;
62
+ }
63
+ throw new TypeError(`an evidence record holds JSON values only, not ${typeof value}`);
64
+ }
65
+
66
+ export function recordHash(record) {
67
+ const { hash, ...body } = record;
68
+ return createHash('sha256').update(DOMAIN).update(canonical(body)).digest('hex');
69
+ }
70
+
71
+ export function checkFields(kind, fields) {
72
+ const spec = KINDS[kind];
73
+ if (!spec) throw new TypeError(`no evidence record kind ${kind}`);
74
+ for (const [k, v] of Object.entries(fields)) {
75
+ if (COMMON.includes(k)) throw new TypeError(`${kind}: ${k} is set by the writer`);
76
+ const check = spec.fields[k];
77
+ if (!check) throw new TypeError(`${kind}: no field ${k} (docs/spec/evidence.md §5)`);
78
+ if (v !== undefined && !check(v)) throw new TypeError(`${kind}: ${k} does not hold what the spec says it holds`);
79
+ }
80
+ for (const k of spec.required) if (fields[k] === undefined) throw new TypeError(`${kind}: ${k} is required`);
81
+ }
82
+
83
+ // The next record after `prev` ({ chain, seq, hash } or null for the first), as its line.
84
+ export function makeRecord({ chain, prev, kind, fields, at }) {
85
+ checkFields(kind, fields);
86
+ const clean = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
87
+ const record = { ...clean, v: VERSION, chain, seq: prev ? prev.seq + 1 : 0, at, kind, prev: prev ? prev.hash : ZERO };
88
+ record.hash = recordHash(record);
89
+ return { record, line: `${canonical(record)}\n` };
90
+ }
91
+
92
+ // A line as read back: which check it fails, if any, against the record before it.
93
+ export function checkLine(line, prev, { chain, kinds }) {
94
+ let record;
95
+ try { record = JSON.parse(line); } catch { return { record: null, fail: 'json' }; }
96
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return { record: null, fail: 'json' };
97
+ let text;
98
+ try { text = canonical(record); } catch { return { record, fail: 'canonical' }; }
99
+ if (text !== line) return { record, fail: 'canonical' };
100
+ if (record.v !== VERSION) return { record, fail: 'version' };
101
+ if (!ISO.test(record.at)) return { record, fail: 'at' };
102
+ if (!hex64(record.hash) || recordHash(record) !== record.hash) return { record, fail: 'hash' };
103
+ if (record.chain !== chain) return { record, fail: 'chain' };
104
+ if (record.seq !== (prev ? prev.seq + 1 : 0)) return { record, fail: 'seq' };
105
+ if (record.prev !== (prev ? prev.hash : ZERO)) return { record, fail: 'prev' };
106
+ if (!kinds.has(record.kind)) return { record, fail: 'kind' };
107
+ const { v, chain: c, seq, at, kind, prev: p, hash, ...fields } = record;
108
+ try { checkFields(kind, fields); } catch { return { record, fail: 'fields' }; }
109
+ return { record, fail: null };
110
+ }