@portll/cobolwork 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +43 -11
  2. package/bin/cobolwork.mjs +27 -8
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/build.mjs +33 -8
  5. package/lib/capabilities.mjs +4 -1
  6. package/lib/consequence.mjs +4 -0
  7. package/lib/control-reuse.mjs +113 -0
  8. package/lib/control.mjs +25 -1
  9. package/lib/dataflow.mjs +285 -86
  10. package/lib/equivalence.mjs +44 -5
  11. package/lib/evidence/cli.mjs +13 -5
  12. package/lib/evidence/record.mjs +16 -6
  13. package/lib/evidence/seal.mjs +17 -0
  14. package/lib/evidence/store.mjs +30 -14
  15. package/lib/evidence/timestamp.mjs +83 -0
  16. package/lib/evidence/verify.mjs +120 -42
  17. package/lib/hlasm/asm/data.mjs +116 -0
  18. package/lib/hlasm/asm/listing.mjs +43 -0
  19. package/lib/hlasm/asm/output.mjs +45 -0
  20. package/lib/hlasm/asm/sections.mjs +159 -0
  21. package/lib/hlasm/asm/symbols.mjs +78 -0
  22. package/lib/hlasm/exec.mjs +27 -0
  23. package/lib/hlasm/expr.mjs +167 -0
  24. package/lib/hlasm/instr.mjs +48 -0
  25. package/lib/hlasm/locate.mjs +333 -0
  26. package/lib/hlasm/macro/authorization.mjs +126 -0
  27. package/lib/hlasm/macro/datasets.mjs +94 -0
  28. package/lib/hlasm/macro/io.mjs +139 -0
  29. package/lib/hlasm/macro/le.mjs +53 -0
  30. package/lib/hlasm/macro/linkage.mjs +143 -0
  31. package/lib/hlasm/macro/operator.mjs +66 -0
  32. package/lib/hlasm/macro/program.mjs +142 -0
  33. package/lib/hlasm/macro/recovery.mjs +80 -0
  34. package/lib/hlasm/macro/storage.mjs +117 -0
  35. package/lib/hlasm/macro/structured.mjs +131 -0
  36. package/lib/hlasm/model.mjs +72 -0
  37. package/lib/hlasm/operands.mjs +49 -0
  38. package/lib/hlasm/read.mjs +116 -0
  39. package/lib/inventory.mjs +10 -7
  40. package/lib/ironwork-ids.mjs +24 -0
  41. package/lib/ironwork.mjs +4 -1
  42. package/lib/kernel/registry.mjs +20 -17
  43. package/lib/kernel/shared-pass.mjs +138 -0
  44. package/lib/options.mjs +26 -4
  45. package/lib/policy.mjs +6 -0
  46. package/lib/precompile-cics.mjs +1 -1
  47. package/lib/revision.json +1 -1
  48. package/lib/sarif.mjs +3 -0
  49. package/lib/scan.mjs +21 -11
  50. package/lib/sets/abend.mjs +71 -7
  51. package/lib/sets/build.mjs +43 -37
  52. package/lib/sets/cics.mjs +5 -3
  53. package/lib/sets/compile.mjs +5 -3
  54. package/lib/sets/copybook.mjs +5 -3
  55. package/lib/sets/flow.mjs +14 -7
  56. package/lib/sets/hlasm.mjs +60 -4
  57. package/lib/sets/log.mjs +5 -3
  58. package/lib/sets/priv.mjs +5 -3
  59. package/lib/sets/secrets.mjs +110 -0
  60. package/lib/sets/semantics.mjs +5 -3
  61. package/lib/sets/web.mjs +5 -3
  62. package/package.json +1 -1
  63. package/rules/compliance-cobit2019.json +88 -4
  64. package/rules/compliance-dora.json +95 -4
  65. package/rules/compliance-ffiec.json +91 -0
  66. package/rules/compliance-nist80053.json +91 -0
  67. package/rules/gitleaks-mainframe.toml +46 -15
  68. package/rules/hlasm-instructions.json +2396 -0
  69. package/schema/cobolwork.policy.schema.json +7 -0
@@ -50,6 +50,34 @@ function closure(root, file, idx, systemDirs) {
50
50
  return out;
51
51
  }
52
52
 
53
+ // The first program's paragraphs, by name, each as its own statements read with every position
54
+ // left out, so a paragraph that only moved is the same paragraph (as ironwork compare reads them).
55
+ function paragraphPrints(file) {
56
+ let program;
57
+ try { program = parseSource(readSource(file).text, file, { format: 'auto', mainDir: dirname(file) }).programs[0]; } catch { return null; }
58
+ if (!program) return null;
59
+ const positionFree = (x) => (Array.isArray(x) ? x.map(positionFree)
60
+ : x && typeof x === 'object' ? Object.fromEntries(Object.entries(x).filter(([k]) => !['line', 'file', 'at', 'end', 'col', 'fmt'].includes(k)).map(([k, v]) => [k, positionFree(v)]))
61
+ : x);
62
+ const labels = [...program.labels].sort((a, b) => a.at - b.at);
63
+ const prints = new Map();
64
+ labels.forEach((label, i) => {
65
+ if (label.kind !== 'P') return;
66
+ const end = i + 1 < labels.length ? labels[i + 1].at : Infinity;
67
+ const body = program.statements.filter((s) => s.at > label.at && s.at < end).map(positionFree);
68
+ prints.set(label.name, [...(prints.get(label.name) || []), JSON.stringify(body)]);
69
+ });
70
+ return prints;
71
+ }
72
+
73
+ // The head's paragraphs whose own statements the change edits or adds.
74
+ function editedParagraphs(baseFile, headFile) {
75
+ const before = paragraphPrints(baseFile);
76
+ const after = paragraphPrints(headFile);
77
+ if (!before || !after) return [];
78
+ return [...after].filter(([name, prints]) => String(before.get(name)) !== String(prints)).map(([name]) => name).sort();
79
+ }
80
+
53
81
  // Programs the change edits, adds or deletes, or whose copybooks it edits: [{ path, base, head, via }]
54
82
  // with each side's source digest (head null for a deleted program) and the copybooks that changed
55
83
  // under an unchanged program.
@@ -112,14 +140,21 @@ export function checkEquivalence({ baseDir, headDir, allow, files = [], allowed
112
140
  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`);
113
141
  }
114
142
  }
143
+ const authorship = mode === 'machineAuthored' && repo && base ? machineAuthored(repo, base, head || 'HEAD', env) : null;
144
+ const required = mode === 'always' ? true : mode === 'machineAuthored' ? (authorship && authorship.known ? authorship.commits.length > 0 : null) : false;
115
145
  // Every statement that names a program is judged, and the program passes only if each does.
146
+ // Where equivalence may be required, a statement no allowed signer signed could have been written
147
+ // by anyone who can commit.
116
148
  const programs = changed.map((c) => {
117
149
  if (c.deleted) return { path: c.path, deleted: true, statement: null, ok: false, because: 'the change deletes this program, and no equivalence statement can show what its callers do now' };
118
150
  const matching = statements.filter((x) => x.statement && subjectDigest(x.statement, 'head') === c.head && subjectDigest(x.statement, 'base') === c.base);
119
151
  const via = c.via ? { via: c.via } : {};
120
152
  if (!matching.length) 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' };
121
153
  const judged = matching.map((s) => judge(c, s));
122
- return judged.find((j) => !j.ok) || judged[0];
154
+ const failed = judged.find((j) => !j.ok);
155
+ if (failed) return failed;
156
+ if (!allowed && required !== false) return { ...judged[0], ok: false, because: 'no --allowed-signers were given, so nothing shows who wrote the statement' };
157
+ return judged[0];
123
158
  });
124
159
  function judge(c, s) {
125
160
  const via = c.via ? { via: c.via } : {};
@@ -128,15 +163,19 @@ export function checkEquivalence({ baseDir, headDir, allow, files = [], allowed
128
163
  const covered = !!p.coverage && typeof p.coverage === 'object' && Array.isArray(p.coverage.unreached) && p.coverage.unreached.every((u) => typeof u === 'string');
129
164
  const ranOn = new Set(((p.closure && p.closure.head) || []).map((x) => x.sha256));
130
165
  const missing = (c.viaDigests || []).filter((d) => !ranOn.has(d));
166
+ // ironwork decides which paragraphs changed; a paragraph whose statements this diff shows
167
+ // edited and the statement does not list as changed was never held to coverage.
168
+ const claimed = covered && Array.isArray(p.coverage.changed) ? new Set(p.coverage.changed.map((n) => String(n).toUpperCase())) : null;
169
+ const edited = covered && c.base ? editedParagraphs(join(baseDir, c.path), join(headDir, c.path)) : [];
170
+ const unclaimed = edited.filter((n) => !claimed || !claimed.has(n));
131
171
  const because = !PASSING.has(verdict) ? `the statement's verdict is ${verdict}${Array.isArray(p.inconclusive) && p.inconclusive.length ? ` (${p.inconclusive.join('; ')})` : ''}`
132
172
  : missing.length ? `the statement does not show the head run read the edited copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')}`
133
173
  : !covered ? 'the statement measured no coverage of the changed paragraphs, so it is inconclusive'
134
- : allowed && !s.signed ? `the statement is ${s.reason}`
135
- : null;
174
+ : unclaimed.length ? `the statement's coverage does not list ${unclaimed.join(', ')} as changed, and the change edits ${unclaimed.length > 1 ? 'them' : 'it'}`
175
+ : allowed && !s.signed ? `the statement is ${s.reason}`
176
+ : null;
136
177
  const unreached = covered ? p.coverage.unreached : [];
137
178
  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(', ')}` } : {}) };
138
179
  }
139
- const authorship = mode === 'machineAuthored' && repo && base ? machineAuthored(repo, base, head || 'HEAD', env) : null;
140
- const required = mode === 'always' ? true : mode === 'machineAuthored' ? (authorship && authorship.known ? authorship.commits.length > 0 : null) : false;
141
180
  return { mode, required, ...(authorship ? { machineAuthored: authorship } : {}), programs, problems };
142
181
  }
@@ -1,11 +1,11 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // `cobolwork evidence verify|seal|anchor`: the command half of docs/spec/evidence.md §7-9. Returns
3
3
  // the exit status; writes one JSON document through `write`.
4
- import { readFileSync, writeFileSync } from 'node:fs';
5
- import { resolve } from 'node:path';
4
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
6
  import { anchorGit, envelopeFor, seal, sealFiles, timeStampRequest } from './seal.mjs';
7
7
  import { parseAllowedSigners } from './sshsig.mjs';
8
- import { EvidenceRefusal, evidenceDirFrom } from './store.mjs';
8
+ import { EvidenceRefusal, createExclusive, evidenceDirFrom, syncClose, writeAll } from './store.mjs';
9
9
  import { verifyEvidence } from './verify.mjs';
10
10
 
11
11
  const VERBS = ['verify', 'seal', 'anchor', 'sign'];
@@ -22,7 +22,12 @@ function anchorNewest(dir, opts) {
22
22
  if (opts.tsq) {
23
23
  const req = timeStampRequest(Buffer.from(text, 'utf8'));
24
24
  writeFileSync(resolve(opts.tsq), req.der);
25
- out.tsq = { path: resolve(opts.tsq), sha256: req.digest, nonce: req.nonce };
25
+ // The request is kept beside its seal, so verify can check the response's nonce against it.
26
+ const kept = join(dir, 'seals', `${newest.seq}.tsq`);
27
+ if (existsSync(kept)) unlinkSync(kept);
28
+ const fd = createExclusive(kept);
29
+ try { writeAll(fd, req.der); } finally { syncClose(fd); }
30
+ out.tsq = { path: resolve(opts.tsq), sha256: req.digest, nonce: req.nonce, kept: `seals/${newest.seq}.tsq` };
26
31
  }
27
32
  return out;
28
33
  }
@@ -47,7 +52,10 @@ export function evidenceCommand(verb, opts, { toolVersion, write, env = process.
47
52
  const allowed = opts.allowedSigners ? parseAllowedSigners(readFileSync(resolve(opts.allowedSigners), 'utf8')) : null;
48
53
  const max = opts.maxUnsealed === undefined ? 0 : Number(opts.maxUnsealed);
49
54
  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 });
55
+ if (opts.anchorPin && !opts.anchorGit) throw new EvidenceRefusal('--anchor-pin names a commit of the --anchor-git witness');
56
+ const file = (x) => (x ? resolve(x) : null);
57
+ const cosign = opts.cosignBundle ? { bundle: file(opts.cosignBundle), key: file(opts.cosignKey), identity: opts.certificateIdentity || null, issuer: opts.certificateOidcIssuer || null, trustedRoot: file(opts.trustedRoot), ignoreTlog: opts.insecureIgnoreTlog === true } : null;
58
+ const verdict = verifyEvidence(dir, { allowed, anchorGit: file(opts.anchorGit), anchorRef: opts.ref || null, anchorPin: opts.anchorPin || null, tsr: file(opts.tsr), tsaCa: file(opts.tsaCa), cosign, maxUnsealed: max, env });
51
59
  emit(verdict);
52
60
  return verdict.exit;
53
61
  }
@@ -21,7 +21,8 @@ const strOrNull = (x) => x === null || str(x);
21
21
  const any = () => true;
22
22
  const strings = (x) => Array.isArray(x) && x.every(str);
23
23
 
24
- // kind -> { field: check }; a field listed as required must be present.
24
+ // kind -> { field: check }; a field listed as required must be present, and `check`, where a kind
25
+ // has one, names what its fields fail together.
25
26
  export const KINDS = {
26
27
  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
28
  input: { fields: { root: int, path: str, sha256: hex64, bytes: int }, required: ['root', 'path', 'sha256'] },
@@ -31,19 +32,26 @@ export const KINDS = {
31
32
  witness: { fields: { fingerprint: str, outcome: str, who: strOrNull, when: strOrNull, system: strOrNull, sourceSha256: hex64 }, required: ['fingerprint', 'outcome'] },
32
33
  verdict: { fields: { verdict: str, checks: any, relaxed: strings, exit: int }, required: ['verdict'] },
33
34
  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
+ // ironwork's run journal closes with the program's true RETURN-CODE, which may be negative.
36
+ close: { fields: { exit: (x) => x === null || Number.isSafeInteger(x), counts: any, durationMs: int, ledger: str }, required: ['exit'] },
35
37
  // Written by ironwork's run journal, in this format, so one verifier reads both tools.
36
38
  dd: { fields: { dd: str, event: (x) => ['open', 'close', 'end'].includes(x), mode: str, sha256: hex64, bytes: int }, required: ['dd', 'event'] },
37
39
  call: { fields: { program: str, from: str, sha256: hex64 }, required: ['program'] },
38
40
  abend: { fields: { code: str, file: str, line: int }, required: ['code'] },
39
41
  step: { fields: { step: str, pgm: str, outcome: str }, required: ['step', 'pgm', 'outcome'] },
40
- sink: { fields: { sink: str, file: str, line: int, marker: str, reached: (x) => typeof x === 'boolean' }, required: ['sink', 'file', 'line', 'marker', 'reached'] },
41
- genesis: { fields: { createdAt: str, rotatedFrom: any }, required: ['createdAt'] },
42
+ sink: {
43
+ fields: { sink: str, file: str, line: int, marker: str, reached: (x) => typeof x === 'boolean', input: (x) => x === null || typeof x === 'boolean' },
44
+ required: ['sink', 'file', 'line'],
45
+ check: (f) => ((f.marker === undefined) !== (f.reached === undefined) ? 'marker and reached come together'
46
+ : f.marker === undefined && f.input === undefined ? 'needs marker and reached, or input' : null),
47
+ },
48
+ statement: { fields: { file: str, line: int, capped: (x) => x === true }, required: ['file', 'line'] },
49
+ genesis: { fields: { createdAt: str }, required: ['createdAt'] },
42
50
  run: { fields: { run: str, runChain: hex32, runLength: int, runTip: hex64 }, required: ['run', 'runChain', 'runLength', 'runTip'] },
43
- 'lock-broken': { fields: { holderPid: int, ageMs: int }, required: ['holderPid', 'ageMs'] },
51
+ 'lock-broken': { fields: { holderPid: (x) => x === null || int(x), ageMs: int }, required: ['holderPid', 'ageMs'] },
44
52
  };
45
53
 
46
- export const JOURNAL_KINDS = new Set(['open', 'input', 'finding', 'suppressed', 'baseline-write', 'witness', 'verdict', 'output', 'close', 'dd', 'call', 'abend', 'step', 'sink']);
54
+ export const JOURNAL_KINDS = new Set(['open', 'input', 'finding', 'suppressed', 'baseline-write', 'witness', 'verdict', 'output', 'close', 'dd', 'call', 'abend', 'step', 'sink', 'statement']);
47
55
  export const LEDGER_KINDS = new Set(['genesis', 'run', 'lock-broken']);
48
56
 
49
57
  export const newChain = () => randomBytes(16).toString('hex');
@@ -80,6 +88,8 @@ export function checkFields(kind, fields) {
80
88
  if (v !== undefined && !check(v)) throw new TypeError(`${kind}: ${k} does not hold what the spec says it holds`);
81
89
  }
82
90
  for (const k of spec.required) if (fields[k] === undefined) throw new TypeError(`${kind}: ${k} is required`);
91
+ const fault = spec.check?.(fields);
92
+ if (fault) throw new TypeError(`${kind}: ${fault}`);
83
93
  }
84
94
 
85
95
  // The next record after `prev` ({ chain, seq, hash } or null for the first), as its line.
@@ -159,6 +159,23 @@ export function resolveRef(repo, ref, env = process.env) {
159
159
  return r.status === 0 ? String(r.stdout).trim() : null;
160
160
  }
161
161
 
162
+ // Whether commit `older` is in the history of `newer`.
163
+ export function isAncestor(repo, older, newer, env = process.env) {
164
+ return git(repo, ['merge-base', '--is-ancestor', older, newer], env).status === 0;
165
+ }
166
+
167
+ // The blob of every seal the witness holds at `ref`, by its path: Map(`<chain>/<seq>.dsse.json` -> blob id).
168
+ export function witnessedBlobs(repo, ref, env = process.env) {
169
+ const r = git(repo, ['ls-tree', '-r', ref], env);
170
+ const out = new Map();
171
+ if (r.status !== 0) return out;
172
+ for (const line of String(r.stdout).split('\n')) {
173
+ const m = /^\d+ blob ([0-9a-f]+)\t([0-9a-f]{32}\/\d+\.dsse\.json)$/.exec(line.trim());
174
+ if (m) out.set(m[2], m[1]);
175
+ }
176
+ return out;
177
+ }
178
+
162
179
  // A DER TimeStampReq (RFC 3161) over SHA-256 of `bytes`, with a random nonce and certReq true.
163
180
  export function timeStampRequest(bytes) {
164
181
  const der = (tag, body) => {
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // The evidence directory on disk: where it may be, how its files are opened (never through a
3
3
  // symbolic link, owner-only), the ledger lock, and reading a chain file back line by line.
4
- import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeSync } from 'node:fs';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs';
5
6
  import { basename, dirname, join, resolve, sep } from 'node:path';
6
7
 
7
8
  const { O_WRONLY, O_CREAT, O_EXCL, O_APPEND, O_NOFOLLOW } = constants;
@@ -138,8 +139,22 @@ export function lastLine(path) {
138
139
  const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
139
140
  const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
140
141
 
141
- // Takes ledger.lock, breaking one that is older than staleMs and whose holder is gone. Returns
142
- // { release, broken } or null when the lock could not be had within waitMs.
142
+ // Moves the stale lock `seen` aside before removing it, so a lock a peer took after it was read is
143
+ // put back instead of removed. Returns whether the stale lock was the one broken.
144
+ export function breakStale(path, seen) {
145
+ const aside = `${path}.${process.pid}.${randomBytes(4).toString('hex')}`;
146
+ try { renameSync(path, aside); } catch { return false; }
147
+ let same = false;
148
+ try { same = statSync(aside).ino === seen.ino && readFileSync(aside, 'utf8') === seen.text; } catch { /* not the same */ }
149
+ if (!same) { try { linkSync(aside, path); } catch { /* a newer lock is in place */ } }
150
+ try { unlinkSync(aside); } catch { /* already gone */ }
151
+ return same;
152
+ }
153
+
154
+ // Takes ledger.lock, breaking one that is older than staleMs and whose holder is gone. A lock whose
155
+ // pid and time cannot be read was left by a writer that died between creating it and writing them;
156
+ // it is aged from its modification time. Returns { release, broken } or null when the lock could
157
+ // not be had within waitMs.
143
158
  export function takeLock(path, { waitMs = 5000, staleMs = 60000, now = Date.now } = {}) {
144
159
  const deadline = now() + waitMs;
145
160
  let broken = null;
@@ -152,17 +167,18 @@ export function takeLock(path, { waitMs = 5000, staleMs = 60000, now = Date.now
152
167
  } catch (e) {
153
168
  if (e.code !== 'EEXIST') throw e;
154
169
  }
155
- let pid = null;
156
- let since = null;
157
- try {
158
- const [p, t] = readFileSync(path, 'utf8').trim().split(/\s+/).map(Number);
159
- pid = Number.isSafeInteger(p) ? p : null;
160
- since = Number.isSafeInteger(t) ? t : null;
161
- } catch { /* removed between the open and the read */ }
162
- const age = since === null ? null : now() - since;
163
- if (pid !== null && age !== null && age > staleMs && !pidAlive(pid)) {
164
- try { unlinkSync(path); broken = { holderPid: pid, ageMs: Math.max(0, Math.trunc(age)) }; } catch { /* a peer broke it first */ }
165
- continue;
170
+ let st = null;
171
+ let text = null;
172
+ try { st = statSync(path); text = readFileSync(path, 'utf8'); } catch { /* removed between the open and the read */ }
173
+ if (text !== null) {
174
+ const [p, t] = text.trim().split(/\s+/).map(Number);
175
+ const pid = Number.isSafeInteger(p) && p > 0 ? p : null;
176
+ const since = Number.isSafeInteger(t) && t > 0 ? t : null;
177
+ const age = pid !== null && since !== null ? now() - since : now() - st.mtimeMs;
178
+ if (age > staleMs && (pid === null || !pidAlive(pid)) && breakStale(path, { text, ino: st.ino })) {
179
+ broken = { holderPid: pid, ageMs: Math.max(0, Math.trunc(age)) };
180
+ continue;
181
+ }
166
182
  }
167
183
  if (now() >= deadline) return null;
168
184
  sleep(25);
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // RFC 3161 as the git witness's sibling (docs/spec/evidence.md §8): the request `anchor --tsq`
3
+ // keeps beside a seal, and the facts of a time-stamp response cobolwork checks itself, its status,
4
+ // message imprint, nonce and time. The TSA's signature is OpenSSL's to check.
5
+
6
+ const SHA256_OID = Buffer.from([0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]);
7
+
8
+ // One DER element at `at`: its tag and where its contents start and end.
9
+ function element(buf, at, end = buf.length) {
10
+ if (at + 2 > end) throw new Error('a DER element runs past the end');
11
+ const tag = buf[at];
12
+ let length = buf[at + 1];
13
+ let start = at + 2;
14
+ if (length & 0x80) {
15
+ const n = length & 0x7f;
16
+ if (n < 1 || n > 4 || start + n > end) throw new Error('a DER length is not one this reads');
17
+ length = 0;
18
+ for (let i = 0; i < n; i++) length = length * 256 + buf[start + i];
19
+ start += n;
20
+ }
21
+ if (start + length > end) throw new Error('a DER element runs past the end');
22
+ return { tag, start, end: start + length };
23
+ }
24
+
25
+ function children(buf, parent) {
26
+ const out = [];
27
+ for (let at = parent.start; at < parent.end;) {
28
+ const c = element(buf, at, parent.end);
29
+ out.push(c);
30
+ at = c.end;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const contents = (buf, el) => buf.subarray(el.start, el.end);
36
+ const expect = (el, tag, what) => {
37
+ if (!el || el.tag !== tag) throw new Error(`${what} is not where RFC 3161 puts it`);
38
+ return el;
39
+ };
40
+ // An INTEGER's magnitude as hex, leading zero bytes dropped, so 00 80 and 80 compare equal.
41
+ const integerHex = (bytes) => {
42
+ let i = 0;
43
+ while (i < bytes.length - 1 && bytes[i] === 0) i++;
44
+ return Buffer.from(bytes.subarray(i)).toString('hex');
45
+ };
46
+
47
+ function imprintOf(buf, el, what) {
48
+ const [algorithm, hashed] = children(buf, expect(el, 0x30, `${what}'s message imprint`));
49
+ const oid = expect(children(buf, expect(algorithm, 0x30, `${what}'s hash algorithm`))[0], 0x06, `${what}'s hash algorithm`);
50
+ if (!contents(buf, oid).equals(SHA256_OID)) throw new Error(`${what} is not over SHA-256`);
51
+ return Buffer.from(contents(buf, expect(hashed, 0x04, `${what}'s hashed message`))).toString('hex');
52
+ }
53
+
54
+ // The message imprint and nonce of a TimeStampReq that `anchor --tsq` wrote.
55
+ export function readRequest(der) {
56
+ const [version, imprint, ...rest] = children(der, expect(element(der, 0), 0x30, 'the request'));
57
+ expect(version, 0x02, "the request's version");
58
+ const nonce = rest.find((e) => e.tag === 0x02);
59
+ return { digest: imprintOf(der, imprint, 'the request'), nonce: nonce ? integerHex(contents(der, nonce)) : null };
60
+ }
61
+
62
+ // status, digest (hex), nonce (hex or null) and genTime of a TimeStampResp; throws on a response
63
+ // whose shape is not RFC 3161's.
64
+ export function readResponse(der) {
65
+ const [statusInfo, token] = children(der, expect(element(der, 0), 0x30, 'the response'));
66
+ const status = parseInt(integerHex(contents(der, expect(children(der, expect(statusInfo, 0x30, 'the status'))[0], 0x02, 'the status'))), 16);
67
+ if (status > 1 || !token) return { status, digest: null, nonce: null, genTime: null };
68
+ const [, signedWrapper] = children(der, expect(token, 0x30, 'the time-stamp token'));
69
+ const signedData = children(der, expect(signedWrapper, 0xa0, 'the signed data'))[0];
70
+ const encapsulated = children(der, expect(signedData, 0x30, 'the signed data'))[2];
71
+ const [, contentWrapper] = children(der, expect(encapsulated, 0x30, 'the encapsulated content'));
72
+ const octets = children(der, expect(contentWrapper, 0xa0, 'the TSTInfo'))[0];
73
+ const tst = element(der, expect(octets, 0x04, 'the TSTInfo').start, octets.end);
74
+ const fields = children(der, expect(tst, 0x30, 'the TSTInfo'));
75
+ const genTime = expect(fields[4], 0x18, 'genTime');
76
+ const nonce = fields.slice(5).find((e) => e.tag === 0x02);
77
+ return {
78
+ status,
79
+ digest: imprintOf(der, fields[2], 'the response'),
80
+ nonce: nonce ? integerHex(contents(der, nonce)) : null,
81
+ genTime: contents(der, genTime).toString('latin1'),
82
+ };
83
+ }
@@ -1,14 +1,16 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Verifies an evidence directory and reports verified, broken, unrecorded, open, anchored, sealed,
3
3
  // unsealedTail and signatures as separate facts. docs/spec/evidence.md §9.
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { createHash } from 'node:crypto';
5
6
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
7
  import { join } from 'node:path';
7
8
  import { JOURNAL_KINDS, LEDGER_KINDS, checkLine } from './record.mjs';
8
9
  import { readLines } from './store.mjs';
9
10
  import { LEDGER } from './journal.mjs';
10
- import { NAMESPACE, PAYLOAD_TYPE, SEAL_PREDICATE, STATEMENT_TYPE, listWitnessed, pae, readWitnessed, resolveRef, sealFiles } from './seal.mjs';
11
+ import { NAMESPACE, PAYLOAD_TYPE, SEAL_PREDICATE, STATEMENT_TYPE, isAncestor, listWitnessed, pae, readWitnessed, resolveRef, sealFiles, witnessedBlobs } from './seal.mjs';
11
12
  import { verifySshsig } from './sshsig.mjs';
13
+ import { readRequest, readResponse } from './timestamp.mjs';
12
14
 
13
15
  const sha256 = (b) => createHash('sha256').update(b).digest('hex');
14
16
 
@@ -68,7 +70,108 @@ function signaturesOf(seal, allowed) {
68
70
  });
69
71
  }
70
72
 
71
- export function verifyEvidence(dir, { allowed = null, anchorGit = null, anchorRef = null, maxUnsealed = 0, env = process.env } = {}) {
73
+ // With a pinned commit, the witness's history since then: the pin must still be an ancestor of the
74
+ // ref, and every seal it held must be at the ref unchanged. A rewritten branch or a later commit
75
+ // that deletes seals is a contradiction, which a fresh reading of the ref alone cannot see.
76
+ function pinProblem(repo, pin, ref, env) {
77
+ const pinned = resolveRef(repo, pin, env);
78
+ if (!pinned) return `the pinned commit ${pin} is not in the witness`;
79
+ const at = resolveRef(repo, ref, env);
80
+ if (!isAncestor(repo, pinned, at, env)) return `the witness was rewritten: the pinned commit ${pinned.slice(0, 12)} is not in the history of ${ref}`;
81
+ const now = witnessedBlobs(repo, at, env);
82
+ for (const [path, blob] of witnessedBlobs(repo, pinned, env)) {
83
+ if (now.get(path) !== blob) return `the witness held ${path} at the pinned commit, and ${now.has(path) ? 'holds another' : 'no longer holds it'} at ${ref}`;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // A seal a witness vouches for counts when the ledger holds the state it names and an allowed
89
+ // signer signed it.
90
+ function accepting(recs, allowed) {
91
+ return (ws, name) => {
92
+ const a = agrees(ws, recs);
93
+ if (!a.ok) return { sealed: false, reason: `${name}: ${a.why}` };
94
+ const sigs = signaturesOf(ws, allowed);
95
+ if (sigs.some((x) => x.valid === true)) return { sealed: true, seal: ws };
96
+ if (sigs.every((x) => x.valid === false)) return { sealed: false, unsigned: true, reason: `${name} carries no signature from an allowed signer` };
97
+ return { sealed: null, reason: allowed ? `${name} carries no valid signature` : 'no allowed signers were given' };
98
+ };
99
+ }
100
+
101
+ const firstLine = (text) => String(text || '').trim().split('\n')[0].slice(0, 300);
102
+ const BOUNDED = { timeout: 30000, maxBuffer: 1 << 20, windowsHide: true };
103
+
104
+ // The git witness is the authority for its ledger: every seal it holds is checked, whether or not
105
+ // the directory still has its copy, so deleting local seals cannot hide a cut tail.
106
+ function gitWitness({ repo, ref, pin, recs, local, accept, allowed, verdict, env }) {
107
+ const witnessCommit = resolveRef(repo, ref, env);
108
+ if (!witnessCommit) return { sealed: null, reason: `${ref} does not resolve in ${repo}` };
109
+ verdict.witnessCommit = witnessCommit;
110
+ const rewritten = pin ? pinProblem(repo, pin, ref, env) : null;
111
+ if (rewritten) return { sealed: false, reason: rewritten };
112
+ const held = listWitnessed(repo, ref, env);
113
+ const chain = recs.length ? recs[0].chain : null;
114
+ const seqs = chain ? held.get(chain) || [] : [];
115
+ const localSeqs = new Set(local.filter((s) => s.chain === chain).map((s) => s.seq));
116
+ let result = null;
117
+ let newest = null;
118
+ if (!seqs.length && held.size) result = { sealed: false, reason: `the witness holds seals for ledger ${[...held.keys()].join(', ')}, and this directory holds ledger ${chain || 'none'}` };
119
+ else if (!seqs.length) result = { sealed: null, reason: `not on ${ref}: the witness holds no seal for ledger ${chain || 'none'}` };
120
+ for (const seq of result ? [] : seqs) {
121
+ const name = `${seq}.dsse.json`;
122
+ let ws;
123
+ try { ws = decodeSeal(readWitnessed(repo, ref, `${chain}/${name}`, env).text); } catch (e) { result = { sealed: false, reason: `the witnessed ${name} is not a seal: ${e.message}` }; break; }
124
+ const r = accept(ws, `witnessed ${name}`);
125
+ if (r.sealed === false && !r.unsigned) { result = r; break; }
126
+ verdict.seals.push({ seal: name, witnessed: true, local: localSeqs.has(seq), length: ws.length, signed: r.sealed === true });
127
+ if (r.sealed === false) { result = r; break; }
128
+ if (r.sealed === true) newest = ws;
129
+ }
130
+ for (const s of local) if (s.chain === chain && !seqs.includes(s.seq)) verdict.seals.push({ seal: s.name, witnessed: false, reason: `not on ${ref}` });
131
+ if (result) return result;
132
+ if (newest) return { sealed: true, seal: newest };
133
+ return { sealed: null, reason: allowed ? 'no witnessed seal carries a valid signature' : 'no allowed signers were given' };
134
+ }
135
+
136
+ // An RFC 3161 response: its imprint names a seal, its nonce is the kept request's, and OpenSSL
137
+ // verifies the authority's signature against --tsa-ca.
138
+ function tsrWitness({ dir, tsr, ca, local, accept, verdict, env }) {
139
+ let resp;
140
+ try { resp = readResponse(readFileSync(tsr)); } catch (e) { return { sealed: null, reason: `--tsr ${tsr} is not a time-stamp response (${e.message})` }; }
141
+ if (resp.status > 1) return { sealed: null, reason: `the time-stamp authority refused the request (status ${resp.status})` };
142
+ const s = local.find((x) => sha256(x.text) === resp.digest);
143
+ if (!s) return { sealed: false, reason: `the time-stamp response's message imprint ${resp.digest.slice(0, 12)} matches no seal in seals/` };
144
+ verdict.timeStamp = { seal: s.name, genTime: resp.genTime };
145
+ const kept = join(dir, 'seals', `${s.seq}.tsq`);
146
+ if (!existsSync(kept)) return { sealed: null, reason: `seals/${s.seq}.tsq, the request for ${s.name}, is not kept, so the response's nonce cannot be checked` };
147
+ let req;
148
+ try { req = readRequest(readFileSync(kept)); } catch (e) { return { sealed: null, reason: `seals/${s.seq}.tsq is not a time-stamp request (${e.message})` }; }
149
+ if (req.digest !== resp.digest || !resp.nonce || req.nonce !== resp.nonce) return { sealed: false, reason: `the time-stamp response's nonce is not that of the request kept for ${s.name}` };
150
+ if (!ca) return { sealed: null, reason: '--tsr needs --tsa-ca, the certificate the time-stamp authority chains to' };
151
+ const r = spawnSync('openssl', ['ts', '-verify', '-in', tsr, '-data', s.path, '-CAfile', ca], { ...BOUNDED, env });
152
+ if (r.error) return { sealed: null, reason: `OpenSSL could not be run (${r.error.code || r.error.message}), so the authority's signature is unchecked` };
153
+ if (r.status !== 0) return { sealed: false, reason: `OpenSSL does not verify the time-stamp response: ${firstLine(r.stderr) || firstLine(r.stdout)}` };
154
+ return accept(s, `the time-stamped ${s.name}`);
155
+ }
156
+
157
+ // A transparency-log bundle, checked by cosign against each local seal, newest first.
158
+ function cosignWitness({ bundle, key, identity, issuer, trustedRoot, ignoreTlog, local, accept, verdict, env }) {
159
+ if (!key && !(identity && issuer)) return { sealed: null, reason: '--cosign-bundle needs --cosign-key, or --certificate-identity with --certificate-oidc-issuer' };
160
+ let last = '';
161
+ for (const s of [...local].reverse()) {
162
+ const who = key ? ['--key', key] : ['--certificate-identity', identity, '--certificate-oidc-issuer', issuer];
163
+ const r = spawnSync('cosign', ['verify-blob', '--bundle', bundle, ...who, ...(trustedRoot ? ['--trusted-root', trustedRoot] : []), ...(ignoreTlog ? ['--insecure-ignore-tlog=true'] : []), s.path], { ...BOUNDED, env });
164
+ if (r.error) return { sealed: null, reason: `cosign could not be run (${r.error.code || r.error.message})` };
165
+ if (r.status === 0) {
166
+ verdict.transparencyLog = { seal: s.name, tlogChecked: !ignoreTlog };
167
+ return accept(s, `the cosign-witnessed ${s.name}`);
168
+ }
169
+ last = firstLine(r.stderr);
170
+ }
171
+ return { sealed: false, reason: `the cosign bundle verifies against no seal in seals/${last ? ` (cosign: ${last})` : ''}` };
172
+ }
173
+
174
+ export function verifyEvidence(dir, { allowed = null, anchorGit = null, anchorRef = null, anchorPin = null, tsr = null, tsaCa = null, cosign = null, maxUnsealed = 0, env = process.env } = {}) {
72
175
  const verdict = { verified: true, broken: [], unrecorded: [], open: [], anchored: null, sealed: null, sealedReason: null, unsealedTail: null, signatures: [], seals: [] };
73
176
  const ledger = verifyChain(join(dir, LEDGER), LEDGER);
74
177
  verdict.broken.push(...ledger.broken);
@@ -107,7 +210,7 @@ export function verifyEvidence(dir, { allowed = null, anchorGit = null, anchorRe
107
210
  try { s = decodeSeal(text); } catch (e) { verdict.broken.push({ file: `seals/${f.name}`, line: 1, check: 'seal', reason: e.message }); prevDigest = sha256(text); continue; }
108
211
  if (s.previous !== prevDigest) verdict.broken.push({ file: `seals/${f.name}`, line: 1, check: 'seal-chain' });
109
212
  prevDigest = sha256(text);
110
- local.push({ ...s, seq: f.seq, name: f.name, text });
213
+ local.push({ ...s, seq: f.seq, name: f.name, path: f.path, text });
111
214
  }
112
215
  if (local.length) {
113
216
  const newest = local[local.length - 1];
@@ -120,45 +223,20 @@ export function verifyEvidence(dir, { allowed = null, anchorGit = null, anchorRe
120
223
  verdict.signatures.push({ seal: s.name, results: sigs });
121
224
  }
122
225
 
123
- if (anchorGit) {
124
- const ref = anchorRef || '@{upstream}';
125
- if (!resolveRef(anchorGit, ref, env)) {
126
- verdict.sealedReason = `${ref} does not resolve in ${anchorGit}`;
127
- } else {
128
- // The witness is the authority: every seal it holds for this ledger is checked, whether or
129
- // not the directory still has its copy, so deleting local seals cannot hide a cut tail.
130
- const held = listWitnessed(anchorGit, ref, env);
131
- const chain = recs.length ? recs[0].chain : null;
132
- const seqs = chain ? held.get(chain) || [] : [];
133
- const localSeqs = new Set(local.filter((s) => s.chain === chain).map((s) => s.seq));
134
- let newestOk = null;
135
- if (!seqs.length && held.size) {
136
- verdict.sealed = false;
137
- verdict.sealedReason = `the witness holds seals for ledger ${[...held.keys()].join(', ')}, and this directory holds ledger ${chain || 'none'}`;
138
- } else if (!seqs.length) verdict.sealedReason = `not on ${ref}: the witness holds no seal for ledger ${chain || 'none'}`;
139
- for (const seq of verdict.sealed === false ? [] : seqs) {
140
- const name = `${seq}.dsse.json`;
141
- const w = readWitnessed(anchorGit, ref, `${chain}/${name}`, env);
142
- let ws;
143
- try { ws = decodeSeal(w.text); } catch (e) { verdict.sealed = false; verdict.sealedReason = `the witnessed ${name} is not a seal: ${e.message}`; break; }
144
- const a = agrees(ws, recs);
145
- if (!a.ok) { verdict.sealed = false; verdict.sealedReason = `witnessed ${name}: ${a.why}`; break; }
146
- const sigs = signaturesOf(ws, allowed);
147
- const good = sigs.some((x) => x.valid === true);
148
- const bad = sigs.every((x) => x.valid === false);
149
- verdict.seals.push({ seal: name, witnessed: true, local: localSeqs.has(seq), length: ws.length, signed: good });
150
- if (bad) { verdict.sealed = false; verdict.sealedReason = `witnessed ${name} carries no signature from an allowed signer`; break; }
151
- if (good) newestOk = ws;
152
- }
153
- for (const s of local) if (s.chain === chain && !seqs.includes(s.seq)) verdict.seals.push({ seal: s.name, witnessed: false, reason: `not on ${ref}` });
154
- if (verdict.sealed !== false) {
155
- if (newestOk) {
156
- verdict.sealed = true;
157
- verdict.unsealedTail = recs.length - newestOk.length;
158
- } else if (!verdict.sealedReason) verdict.sealedReason = allowed ? 'no witnessed seal carries a valid signature' : 'no allowed signers were given';
159
- }
160
- }
161
- } else verdict.sealedReason = 'no witness was named';
226
+ const accept = accepting(recs, allowed);
227
+ const results = [];
228
+ if (anchorGit) results.push(gitWitness({ repo: anchorGit, ref: anchorRef || '@{upstream}', pin: anchorPin, recs, local, accept, allowed, verdict, env }));
229
+ if (tsr) results.push(tsrWitness({ dir, tsr, ca: tsaCa, local, accept, verdict, env }));
230
+ if (cosign) results.push(cosignWitness({ ...cosign, local, accept, verdict, env }));
231
+ const contradicted = results.find((r) => r.sealed === false);
232
+ const confirmed = results.filter((r) => r.sealed === true);
233
+ if (contradicted) {
234
+ verdict.sealed = false;
235
+ verdict.sealedReason = contradicted.reason;
236
+ } else if (confirmed.length) {
237
+ verdict.sealed = true;
238
+ verdict.unsealedTail = recs.length - Math.max(...confirmed.map((r) => r.seal.length));
239
+ } else verdict.sealedReason = results.length ? results.map((r) => r.reason).join('; ') : 'no witness was named';
162
240
 
163
241
  verdict.verified = verdict.broken.length === 0;
164
242
  verdict.exit = !verdict.verified || verdict.sealed === false ? 1