@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.
- package/README.md +69 -15
- package/bin/cobolwork.mjs +126 -9
- package/lib/arith.mjs +235 -0
- package/lib/baseline.mjs +5 -0
- package/lib/build.mjs +112 -30
- package/lib/capabilities.mjs +12 -7
- package/lib/consequence.mjs +29 -0
- package/lib/control.mjs +1052 -91
- package/lib/csd.mjs +17 -2
- package/lib/dataflow.mjs +245 -78
- package/lib/enterprise-options.mjs +255 -0
- package/lib/equivalence.mjs +124 -0
- package/lib/evidence/cli.mjs +65 -0
- package/lib/evidence/journal.mjs +85 -0
- package/lib/evidence/record.mjs +110 -0
- package/lib/evidence/run.mjs +111 -0
- package/lib/evidence/seal.mjs +177 -0
- package/lib/evidence/slsa.mjs +64 -0
- package/lib/evidence/sshsig.mjs +172 -0
- package/lib/evidence/store.mjs +170 -0
- package/lib/evidence/verify.mjs +167 -0
- package/lib/explain.mjs +4 -1
- package/lib/exploitability.mjs +263 -0
- package/lib/ironwork.mjs +111 -0
- package/lib/kernel/findings.mjs +11 -0
- package/lib/kernel/registry.mjs +4 -0
- package/lib/option-diff.mjs +49 -0
- package/lib/options.mjs +109 -46
- package/lib/parser.mjs +29 -7
- package/lib/policy.mjs +8 -3
- package/lib/precompile.mjs +4 -0
- package/lib/reach.mjs +29 -12
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +1 -0
- package/lib/sbom.mjs +147 -0
- package/lib/scan.mjs +93 -27
- package/lib/sets/cics.mjs +114 -0
- package/lib/sets/flow.mjs +82 -2
- package/lib/sets/jcl.mjs +67 -0
- package/lib/sets/semantics.mjs +377 -0
- package/lib/sets/web.mjs +34 -10
- package/lib/sets/zowe.mjs +220 -0
- package/lib/sources.mjs +10 -0
- package/lib/tui/app.mjs +31 -13
- package/lib/tui/model.mjs +17 -2
- package/lib/utilities.mjs +85 -13
- package/lib/verify.mjs +128 -0
- package/package.json +1 -1
- package/rules/compliance-dora.json +507 -0
- package/rules/compliance-ffiec.json +507 -0
- package/rules/compliance-nist80053.json +507 -0
- package/schema/cobolwork.policy.schema.json +2 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// What a CLI run records in its journal: the command, the files it read by digest, its findings and
|
|
3
|
+
// suppressions by fingerprint, what it wrote by digest, and how it ended. docs/spec/evidence.md §5.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { basename, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { directoryTree } from '../kernel/source-tree.mjs';
|
|
8
|
+
import { isSource, relPath } from '../sources.mjs';
|
|
9
|
+
import { openJournal } from './journal.mjs';
|
|
10
|
+
import { evidenceDirFrom } from './store.mjs';
|
|
11
|
+
|
|
12
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
13
|
+
|
|
14
|
+
// Option values that are names, refs or dates. Anything else (a reason, a path, a person) is
|
|
15
|
+
// recorded as present, not as what it said.
|
|
16
|
+
const SAFE_VALUE = new Set(['--format', '--only', '--base', '--head', '--target', '--keys', '--action', '--rule', '--expires', '--provenance-format', '--max-unsealed', '--ref']);
|
|
17
|
+
|
|
18
|
+
export function recordedArgv(argv) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a === '--') { out.push('--', '<compiler argv>'); break; }
|
|
23
|
+
out.push(a.startsWith('-') ? a : i === 0 ? a : '<operand>');
|
|
24
|
+
if (a.startsWith('--') && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && VALUED.has(a)) {
|
|
25
|
+
out.push(SAFE_VALUE.has(a) ? String(argv[i + 1]) : '<value>');
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const VALUED = new Set(['--format', '--out', '--only', '--base', '--head', '--baseline', '--reason', '--who', '--expires', '--action', '--rule', '--advisories', '--copylib', '--report', '--keys', '--target', '--cobc', '--policy', '--provenance', '--provenance-format', '--ironwork', '--evidence', '--ssh-key', '--signer', '--allowed-signers', '--anchor-git', '--ref', '--max-unsealed', '--artifact', '--equivalence', '--name', '--tsq', '--tsr', '--expect-key']);
|
|
33
|
+
|
|
34
|
+
// A journal for this run, or null when no evidence directory is named.
|
|
35
|
+
export function startEvidence(opts, { command, argv, roots, toolVersion, toolRevision = null, env = process.env }) {
|
|
36
|
+
const dir = evidenceDirFrom(opts, env);
|
|
37
|
+
if (!dir) return null;
|
|
38
|
+
const journal = openJournal(dir, { command, argv: recordedArgv(argv), roots: roots.map((r) => basename(r)), readRoots: roots, toolVersion, toolRevision });
|
|
39
|
+
journal.roots = roots.map((r) => resolve(r));
|
|
40
|
+
return journal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function recordInputs(journal, rootIndex, root, files = null) {
|
|
44
|
+
if (!journal) return;
|
|
45
|
+
const list = files || directoryTree(root).list().filter(isSource).sort();
|
|
46
|
+
for (const p of list) {
|
|
47
|
+
let bytes;
|
|
48
|
+
try { bytes = readFileSync(p); } catch { continue; }
|
|
49
|
+
journal.append('input', { root: rootIndex, path: relPath(root, p), sha256: sha256(bytes), bytes: bytes.length });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Inputs already hashed by the caller (the build's provenance record): [{ path, sha256 }].
|
|
54
|
+
export function recordHashed(journal, rootIndex, hashes) {
|
|
55
|
+
if (!journal) return;
|
|
56
|
+
for (const h of hashes || []) journal.append('input', { root: rootIndex, path: h.path, sha256: h.sha256 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function recordFindings(journal, report) {
|
|
60
|
+
if (!journal || !report) return;
|
|
61
|
+
for (const f of report.findings || []) {
|
|
62
|
+
if (!f.fingerprint) continue;
|
|
63
|
+
journal.append('finding', { fingerprint: f.fingerprint, rule: f.rule, tier: f.tier || f.sev || undefined, path: f.path || undefined, line: Number.isSafeInteger(f.line) ? f.line : undefined });
|
|
64
|
+
}
|
|
65
|
+
for (const f of report.suppressed || []) {
|
|
66
|
+
if (!f.fingerprint || !f.suppressed) continue;
|
|
67
|
+
const s = f.suppressed;
|
|
68
|
+
journal.append('suppressed', { fingerprint: f.fingerprint, by: 'baseline', who: s.who ?? null, expires: s.expires ?? null, reasonSha256: s.reason ? sha256(String(s.reason)) : null });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function recordOutput(journal, name, text, path = null) {
|
|
73
|
+
if (!journal) return;
|
|
74
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
75
|
+
const rel = path ? relativeName(path, journal.roots) : null;
|
|
76
|
+
journal.append('output', { name, sha256: sha256(bytes), bytes: bytes.length, ...(rel ? { path: rel } : { stdout: true }) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A written file named relative to the root it sits under, or by its base name.
|
|
80
|
+
function relativeName(path, roots = []) {
|
|
81
|
+
const abs = resolve(path);
|
|
82
|
+
for (const r of roots) if (abs === r || abs.startsWith(r + sep)) return relative(r, abs).split(sep).join('/');
|
|
83
|
+
return basename(abs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function recordVerdict(journal, doc, exit) {
|
|
87
|
+
if (!journal || !doc) return;
|
|
88
|
+
const checks = doc.checks && typeof doc.checks === 'object' ? Object.fromEntries(Object.entries(doc.checks).filter(([, v]) => v === null || typeof v === 'boolean')) : undefined;
|
|
89
|
+
journal.append('verdict', { verdict: String(doc.verdict), checks, relaxed: Array.isArray(doc.relaxed) ? doc.relaxed.map(String) : undefined, exit: Number.isSafeInteger(exit) ? exit : undefined });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function recordBaselineWrite(journal, { before, after, who, expires, reason, path, text }) {
|
|
93
|
+
if (!journal) return;
|
|
94
|
+
const held = new Set(before.map((e) => e.fingerprint));
|
|
95
|
+
const kept = new Set(after.map((e) => e.fingerprint));
|
|
96
|
+
journal.append('baseline-write', {
|
|
97
|
+
added: [...kept].filter((f) => !held.has(f)).sort(),
|
|
98
|
+
removed: [...held].filter((f) => !kept.has(f)).sort(),
|
|
99
|
+
who: who ?? null, expires: expires ?? null, reasonSha256: reason ? sha256(String(reason)) : null,
|
|
100
|
+
path: relativeName(path, journal.roots), sha256: sha256(Buffer.from(text, 'utf8')),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Closes the journal; a ledger that could not be written is said on standard error, and the
|
|
105
|
+
// command's own exit status stands.
|
|
106
|
+
export function finishEvidence(journal, exit) {
|
|
107
|
+
if (!journal) return null;
|
|
108
|
+
const r = journal.close({ exit: Number.isSafeInteger(exit) ? exit : null });
|
|
109
|
+
if (r.ledger !== 'recorded') process.stderr.write(`cobolwork: evidence run ${journal.id} was not recorded in the ledger: ${r.reason}\n`);
|
|
110
|
+
return r;
|
|
111
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Seals: an in-toto statement over the ledger's tip, in a DSSE envelope, signed by ssh-keygen or a
|
|
3
|
+
// program that signs as it does, kept in seals/ and handed to witnesses. docs/spec/evidence.md §7-8.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { canonical } from './record.mjs';
|
|
9
|
+
import { dearmor, fingerprint, parseSshsig, verifySshsig } from './sshsig.mjs';
|
|
10
|
+
import { EvidenceRefusal, createExclusive, prepareDir, syncClose, takeLock, writeAll } from './store.mjs';
|
|
11
|
+
import { LEDGER, LOCK } from './journal.mjs';
|
|
12
|
+
import { verifyChain } from './verify.mjs';
|
|
13
|
+
|
|
14
|
+
export const NAMESPACE = 'cobolwork-evidence';
|
|
15
|
+
export const PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
|
16
|
+
export const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
|
|
17
|
+
export const SEAL_PREDICATE = 'https://github.com/Portll/cobolwork/blob/main/docs/spec/evidence.md#seal-v1';
|
|
18
|
+
|
|
19
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
20
|
+
|
|
21
|
+
// DSSE pre-authentication encoding.
|
|
22
|
+
export function pae(type, body) {
|
|
23
|
+
const t = Buffer.from(type, 'utf8');
|
|
24
|
+
return Buffer.concat([Buffer.from(`DSSEv1 ${t.length} `), t, Buffer.from(` ${body.length} `), body]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sealFiles(dir) {
|
|
28
|
+
const d = join(dir, 'seals');
|
|
29
|
+
if (!existsSync(d)) return [];
|
|
30
|
+
if (lstatSync(d).isSymbolicLink()) throw new EvidenceRefusal(`${d} is a symbolic link, which evidence is not read through`);
|
|
31
|
+
return readdirSync(d).map((n) => /^(\d+)\.dsse\.json$/.exec(n)).filter(Boolean)
|
|
32
|
+
.map((m) => ({ seq: Number(m[1]), name: m[0], path: join(d, m[0]) })).sort((a, b) => a.seq - b.seq);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BOUNDED = { timeout: 30000, maxBuffer: 1 << 20, windowsHide: true };
|
|
36
|
+
|
|
37
|
+
function sign(bytes, { sshKey, signer, env, expectKeyid }) {
|
|
38
|
+
const r = sshKey
|
|
39
|
+
? spawnSync('ssh-keygen', ['-Y', 'sign', '-f', sshKey, '-n', NAMESPACE], { ...BOUNDED, input: bytes, env })
|
|
40
|
+
: spawnSync(signer, [NAMESPACE], { ...BOUNDED, input: bytes, env });
|
|
41
|
+
if (r.error) throw new EvidenceRefusal(`the signer could not be run: ${r.error.message}`);
|
|
42
|
+
if (r.status !== 0) throw new EvidenceRefusal(`the signer exited ${r.status}: ${String(r.stderr).trim().slice(0, 300)}`);
|
|
43
|
+
const blob = dearmor(r.stdout);
|
|
44
|
+
const sig = parseSshsig(blob);
|
|
45
|
+
const self = verifySshsig({ blob, message: bytes, namespace: NAMESPACE, allowed: [{ principals: ['signer'], keyBlob: sig.publicKey, certAuthority: false, namespaces: null }] });
|
|
46
|
+
if (self.valid === false) throw new EvidenceRefusal(`the signer's signature does not verify over what was sealed: ${self.reason}`);
|
|
47
|
+
const keyid = fingerprint(sig.publicKey);
|
|
48
|
+
if (expectKeyid && keyid !== expectKeyid) throw new EvidenceRefusal(`the signer signed with ${keyid}, not the expected ${expectKeyid}`);
|
|
49
|
+
return { keyid, sig: blob.toString('base64') };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A DSSE envelope over any in-toto statement, signed as seals are.
|
|
53
|
+
export function envelopeFor(payload, { sshKey = null, signer = null, expectKeyid = null, env = process.env } = {}) {
|
|
54
|
+
if (!sshKey && !signer) throw new EvidenceRefusal('signing needs --ssh-key or --signer');
|
|
55
|
+
return { payloadType: PAYLOAD_TYPE, payload: payload.toString('base64'), signatures: [sign(pae(PAYLOAD_TYPE, payload), { sshKey, signer, env, expectKeyid })] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Seals the ledger at its tip. Refuses a ledger that does not verify: a seal over a broken chain
|
|
59
|
+
// would attest the break.
|
|
60
|
+
export function seal(dir, { sshKey = null, signer = null, expectKeyid = null, toolVersion, now = () => new Date().toISOString(), env = process.env } = {}) {
|
|
61
|
+
const real = prepareDir(dir);
|
|
62
|
+
const held = takeLock(join(real, LOCK));
|
|
63
|
+
if (!held) throw new EvidenceRefusal('the ledger lock could not be had');
|
|
64
|
+
try {
|
|
65
|
+
const ledger = verifyChain(join(real, LEDGER), 'ledger');
|
|
66
|
+
if (ledger.broken.length) throw new EvidenceRefusal(`the ledger does not verify (${ledger.broken[0].check} at line ${ledger.broken[0].line}); nothing is sealed`);
|
|
67
|
+
if (!ledger.records.length) throw new EvidenceRefusal('the ledger is empty; nothing is sealed');
|
|
68
|
+
const tip = ledger.records[ledger.records.length - 1];
|
|
69
|
+
const seals = sealFiles(real);
|
|
70
|
+
const last = seals[seals.length - 1];
|
|
71
|
+
if (last) {
|
|
72
|
+
try {
|
|
73
|
+
const prior = JSON.parse(readFileSync(last.path, 'utf8'));
|
|
74
|
+
if (prior.payloadType !== PAYLOAD_TYPE || typeof prior.payload !== 'string') throw new Error('not a DSSE envelope');
|
|
75
|
+
} catch (e) { throw new EvidenceRefusal(`seals/${last.name} is not a seal (${e.message}); verify the directory before sealing again`); }
|
|
76
|
+
}
|
|
77
|
+
const statement = {
|
|
78
|
+
_type: STATEMENT_TYPE,
|
|
79
|
+
subject: [{ name: `ledger:${tip.chain}`, digest: { sha256: tip.hash } }],
|
|
80
|
+
predicateType: SEAL_PREDICATE,
|
|
81
|
+
predicate: {
|
|
82
|
+
ledgerLength: ledger.records.length,
|
|
83
|
+
sealedAt: now(),
|
|
84
|
+
previousSeal: last ? { sha256: sha256(readFileSync(last.path)) } : null,
|
|
85
|
+
tool: { name: 'cobolwork', version: toolVersion },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const payload = Buffer.from(canonical(statement), 'utf8');
|
|
89
|
+
const signatures = sshKey || signer ? [sign(pae(PAYLOAD_TYPE, payload), { sshKey, signer, env, expectKeyid })] : [];
|
|
90
|
+
const envelope = { payloadType: PAYLOAD_TYPE, payload: payload.toString('base64'), signatures };
|
|
91
|
+
const seq = last ? last.seq + 1 : 0;
|
|
92
|
+
const path = join(real, 'seals', `${seq}.dsse.json`);
|
|
93
|
+
const fd = createExclusive(path);
|
|
94
|
+
const text = `${canonical(envelope)}\n`;
|
|
95
|
+
try { writeAll(fd, text); } finally { syncClose(fd); }
|
|
96
|
+
return { seq, path, statement, envelope, digest: sha256(text), signed: signatures.length > 0 };
|
|
97
|
+
} finally { held.release(); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const git = (repo, args, env) => spawnSync('git', ['-C', repo, ...args], { ...BOUNDED, env: cleanGitEnv(env) });
|
|
101
|
+
|
|
102
|
+
// A git that reads this repository and no other: no inherited GIT_DIR, index or object redirection.
|
|
103
|
+
export function cleanGitEnv(env = process.env) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [k, v] of Object.entries(env)) if (!/^GIT_/.test(k)) out[k] = v;
|
|
106
|
+
return { ...out, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Copies a seal into the anchor repository and commits it there; pushes with `push`.
|
|
110
|
+
export function anchorGit(sealPath, { repo, chain, seq, push = false, env = process.env }) {
|
|
111
|
+
if (!/^[0-9a-f]{32}$/.test(String(chain)) || !Number.isSafeInteger(seq) || seq < 0) throw new EvidenceRefusal(`a seal is anchored under a 32-hex ledger chain and a whole-number sequence, not ${chain}/${seq}`);
|
|
112
|
+
const rel = `${chain}/${seq}.dsse.json`;
|
|
113
|
+
const dir = join(repo, chain);
|
|
114
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
115
|
+
const target = join(repo, rel);
|
|
116
|
+
if (existsSync(target)) {
|
|
117
|
+
if (!readFileSync(target).equals(readFileSync(sealPath))) throw new EvidenceRefusal(`${rel} already holds a different seal in the anchor repository`);
|
|
118
|
+
} else {
|
|
119
|
+
const fd = createExclusive(target);
|
|
120
|
+
try { writeAll(fd, readFileSync(sealPath, 'utf8')); } finally { syncClose(fd); }
|
|
121
|
+
}
|
|
122
|
+
const steps = [['add', '--', rel], ['-c', 'core.hooksPath=/dev/null', 'commit', '-q', '--no-verify', '-m', `evidence seal ${rel}`, '--', rel]];
|
|
123
|
+
for (const args of steps) {
|
|
124
|
+
const r = git(repo, args, env);
|
|
125
|
+
if (r.status !== 0 && !(args.includes('commit') && /nothing to commit|no changes added/.test(String(r.stdout) + String(r.stderr)))) {
|
|
126
|
+
throw new EvidenceRefusal(`git ${args.includes('commit') ? 'commit' : args[0]} in the anchor repository: ${String(r.stderr).trim().slice(0, 300)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const head = String(git(repo, ['rev-parse', 'HEAD'], env).stdout).trim();
|
|
130
|
+
if (!push) return { committed: head, pushed: false, path: rel };
|
|
131
|
+
const p = git(repo, ['-c', 'core.hooksPath=/dev/null', 'push', '-q', '--no-verify'], env);
|
|
132
|
+
return { committed: head, pushed: p.status === 0, path: rel, ...(p.status === 0 ? {} : { reason: p.error ? p.error.message : String(p.stderr).trim().slice(0, 300) || `exit ${p.status}` }) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A seal as the witness holds it, or a reason it cannot be read.
|
|
136
|
+
export function readWitnessed(repo, ref, rel, env = process.env) {
|
|
137
|
+
const r = git(repo, ['cat-file', 'blob', `${ref}:${rel}`], env);
|
|
138
|
+
if (r.status !== 0) return { text: null, reason: `not on ${ref}` };
|
|
139
|
+
return { text: String(r.stdout) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Every seal the witness holds at `ref`, by ledger chain: Map(chain -> [seq ascending]).
|
|
143
|
+
export function listWitnessed(repo, ref, env = process.env) {
|
|
144
|
+
const r = git(repo, ['ls-tree', '-r', '--name-only', ref], env);
|
|
145
|
+
const out = new Map();
|
|
146
|
+
if (r.status !== 0) return out;
|
|
147
|
+
for (const line of String(r.stdout).split('\n')) {
|
|
148
|
+
const m = /^([0-9a-f]{32})\/(\d+)\.dsse\.json$/.exec(line.trim());
|
|
149
|
+
if (!m) continue;
|
|
150
|
+
if (!out.has(m[1])) out.set(m[1], []);
|
|
151
|
+
out.get(m[1]).push(Number(m[2]));
|
|
152
|
+
}
|
|
153
|
+
for (const seqs of out.values()) seqs.sort((a, b) => a - b);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resolveRef(repo, ref, env = process.env) {
|
|
158
|
+
const r = git(repo, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], env);
|
|
159
|
+
return r.status === 0 ? String(r.stdout).trim() : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A DER TimeStampReq (RFC 3161) over SHA-256 of `bytes`, with a random nonce and certReq true.
|
|
163
|
+
export function timeStampRequest(bytes) {
|
|
164
|
+
const der = (tag, body) => {
|
|
165
|
+
const len = body.length < 128 ? Buffer.from([body.length]) : (() => { const b = []; let n = body.length; while (n) { b.unshift(n & 0xff); n >>= 8; } return Buffer.from([0x80 | b.length, ...b]); })();
|
|
166
|
+
return Buffer.concat([Buffer.from([tag]), len, body]);
|
|
167
|
+
};
|
|
168
|
+
const oidSha256 = Buffer.from([0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]);
|
|
169
|
+
const algorithm = der(0x30, Buffer.concat([oidSha256, Buffer.from([0x05, 0x00])]));
|
|
170
|
+
const digest = createHash('sha256').update(bytes).digest();
|
|
171
|
+
const imprint = der(0x30, Buffer.concat([algorithm, der(0x04, digest)]));
|
|
172
|
+
let nonce = randomBytes(8);
|
|
173
|
+
nonce[0] &= 0x7f;
|
|
174
|
+
if (nonce[0] === 0) nonce[0] = 1;
|
|
175
|
+
const body = Buffer.concat([Buffer.from([0x02, 0x01, 0x01]), imprint, der(0x02, nonce), Buffer.from([0x01, 0x01, 0xff])]);
|
|
176
|
+
return { der: der(0x30, body), digest: digest.toString('hex'), nonce: nonce.toString('hex') };
|
|
177
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// The build provenance record as an in-toto statement with the SLSA Provenance v1 predicate
|
|
3
|
+
// (docs/spec/evidence.md §10). Unsigned: the platform that signs it earns any SLSA level.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { basename, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { STATEMENT_TYPE } from './seal.mjs';
|
|
8
|
+
|
|
9
|
+
export const SLSA_PREDICATE = 'https://slsa.dev/provenance/v1';
|
|
10
|
+
export const BUILD_TYPE = 'https://github.com/Portll/cobolwork/blob/main/docs/spec/evidence.md#build-v1';
|
|
11
|
+
export const LOCAL_BUILDER = 'https://github.com/Portll/cobolwork/local';
|
|
12
|
+
|
|
13
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
14
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
15
|
+
|
|
16
|
+
// A path under `root` by its relative name, anything else by its base name.
|
|
17
|
+
function named(path, root) {
|
|
18
|
+
const abs = resolve(path);
|
|
19
|
+
const r = resolve(root);
|
|
20
|
+
return abs.startsWith(r + sep) ? relative(r, abs).split(sep).join('/') : basename(abs);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function slsaStatement({ provenance, docBytes, root, artifacts = [], runId = null, runTip = null, builderId = null, startedOn = null, finishedOn = null }) {
|
|
24
|
+
const subject = [{ name: 'build.json', digest: { sha256: sha256(docBytes) } },
|
|
25
|
+
...artifacts.map((p) => ({ name: named(p, root), digest: { sha256: sha256(readFileSync(p)) } }))];
|
|
26
|
+
|
|
27
|
+
const resolvedDependencies = (provenance.sources || []).map((s) => ({ uri: `file:${s.path}`, name: s.path, digest: { sha256: s.sha256 } }));
|
|
28
|
+
if (provenance.compiler && provenance.compiler.sha256) {
|
|
29
|
+
const c = provenance.compiler;
|
|
30
|
+
const name = c.tool === 'ironwork' ? 'ironwork' : basename(c.path);
|
|
31
|
+
resolvedDependencies.push({ uri: `file:${basename(c.path)}`, name, digest: { sha256: c.sha256 }, ...(c.version ? { annotations: { version: String(c.version) } } : {}) });
|
|
32
|
+
}
|
|
33
|
+
resolvedDependencies.sort(byName);
|
|
34
|
+
|
|
35
|
+
const externalParameters = {
|
|
36
|
+
revisions: provenance.revisions,
|
|
37
|
+
policy: { sha256: provenance.policy.sha256, setBy: provenance.policy.setBy },
|
|
38
|
+
...(provenance.copylibs && provenance.copylibs.length ? { copylibs: provenance.copylibs.map((d) => basename(d)) } : {}),
|
|
39
|
+
...(provenance.compiler && provenance.compiler.argv ? { compilerArguments: provenance.compiler.argv } : {}),
|
|
40
|
+
};
|
|
41
|
+
const internalParameters = {
|
|
42
|
+
tool: 'cobolwork', toolVersion: provenance.toolVersion, flowModel: provenance.flowModel,
|
|
43
|
+
...(provenance.toolRevision ? { toolRevision: provenance.toolRevision } : {}),
|
|
44
|
+
verdict: provenance.verdict, relaxed: provenance.relaxed,
|
|
45
|
+
};
|
|
46
|
+
const metadata = {
|
|
47
|
+
...(runId ? { invocationId: runId } : {}),
|
|
48
|
+
...(startedOn ? { startedOn } : {}),
|
|
49
|
+
...(finishedOn ? { finishedOn } : {}),
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
_type: STATEMENT_TYPE,
|
|
53
|
+
subject,
|
|
54
|
+
predicateType: SLSA_PREDICATE,
|
|
55
|
+
predicate: {
|
|
56
|
+
buildDefinition: { buildType: BUILD_TYPE, externalParameters, internalParameters, resolvedDependencies },
|
|
57
|
+
runDetails: {
|
|
58
|
+
builder: { id: builderId || LOCAL_BUILDER },
|
|
59
|
+
metadata,
|
|
60
|
+
byproducts: runTip ? [{ name: `evidence:run:${runId}`, digest: { sha256: runTip } }] : [],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// SSHSIG signatures (OpenSSH PROTOCOL.sshsig) checked with node:crypto against an allowed_signers
|
|
3
|
+
// file, for ssh-ed25519 and ecdsa-sha2-nistp256 keys. Anything else is reported, not guessed at.
|
|
4
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const MAGIC = Buffer.from('SSHSIG');
|
|
7
|
+
const VERIFIED_TYPES = new Set(['ssh-ed25519', 'ecdsa-sha2-nistp256']);
|
|
8
|
+
const KEY_TYPE = /^(?:ssh-(?:ed25519|rsa|dss)|ecdsa-sha2-nistp(?:256|384|521)|sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|[a-z0-9-]+-cert-v01@openssh\.com)$/;
|
|
9
|
+
|
|
10
|
+
class Reader {
|
|
11
|
+
constructor(buf) { this.buf = buf; this.at = 0; }
|
|
12
|
+
u32() {
|
|
13
|
+
if (this.at + 4 > this.buf.length) throw new Error('truncated');
|
|
14
|
+
const v = this.buf.readUInt32BE(this.at);
|
|
15
|
+
this.at += 4;
|
|
16
|
+
return v;
|
|
17
|
+
}
|
|
18
|
+
bytes(n) {
|
|
19
|
+
if (this.at + n > this.buf.length) throw new Error('truncated');
|
|
20
|
+
const out = this.buf.subarray(this.at, this.at + n);
|
|
21
|
+
this.at += n;
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
string() { return this.bytes(this.u32()); }
|
|
25
|
+
text() { return this.string().toString('utf8'); }
|
|
26
|
+
get done() { return this.at === this.buf.length; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sshString = (b) => {
|
|
30
|
+
const buf = Buffer.isBuffer(b) ? b : Buffer.from(b, 'utf8');
|
|
31
|
+
const len = Buffer.alloc(4);
|
|
32
|
+
len.writeUInt32BE(buf.length);
|
|
33
|
+
return Buffer.concat([len, buf]);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const fingerprint = (keyBlob) => `SHA256:${createHash('sha256').update(keyBlob).digest('base64').replace(/=+$/, '')}`;
|
|
37
|
+
|
|
38
|
+
export function dearmor(text) {
|
|
39
|
+
const m = /-----BEGIN SSH SIGNATURE-----([\s\S]*?)-----END SSH SIGNATURE-----/.exec(String(text));
|
|
40
|
+
if (!m) throw new Error('no SSH SIGNATURE armour');
|
|
41
|
+
const text64 = m[1].replace(/\s+/g, '');
|
|
42
|
+
const blob = Buffer.from(text64, 'base64');
|
|
43
|
+
if (blob.toString('base64') !== text64) throw new Error('the SSH SIGNATURE armour is not canonical base64');
|
|
44
|
+
return blob;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseSshsig(blob) {
|
|
48
|
+
const r = new Reader(blob);
|
|
49
|
+
if (!r.bytes(6).equals(MAGIC)) throw new Error('not an SSHSIG blob');
|
|
50
|
+
const version = r.u32();
|
|
51
|
+
if (version !== 1) throw new Error(`SSHSIG version ${version}`);
|
|
52
|
+
const publicKey = Buffer.from(r.string());
|
|
53
|
+
const namespace = r.text();
|
|
54
|
+
r.string();
|
|
55
|
+
const hashAlg = r.text();
|
|
56
|
+
const signature = Buffer.from(r.string());
|
|
57
|
+
if (!r.done) throw new Error('trailing bytes after the SSHSIG signature');
|
|
58
|
+
return { publicKey, namespace, hashAlg, signature };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What the signer signed: the preamble, the namespace, the hash algorithm and H(message).
|
|
62
|
+
export function signedData(namespace, hashAlg, message) {
|
|
63
|
+
if (hashAlg !== 'sha256' && hashAlg !== 'sha512') throw new Error(`SSHSIG hash ${hashAlg}`);
|
|
64
|
+
const digest = createHash(hashAlg).update(message).digest();
|
|
65
|
+
return Buffer.concat([MAGIC, sshString(namespace), sshString(''), sshString(hashAlg), sshString(digest)]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const b64url = (b) => Buffer.from(b).toString('base64url');
|
|
69
|
+
|
|
70
|
+
function publicKeyOf(blob) {
|
|
71
|
+
const r = new Reader(blob);
|
|
72
|
+
const type = r.text();
|
|
73
|
+
if (type === 'ssh-ed25519') {
|
|
74
|
+
const pk = r.string();
|
|
75
|
+
if (pk.length !== 32 || !r.done) throw new Error('malformed ssh-ed25519 key');
|
|
76
|
+
return { type, key: createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: b64url(pk) }, format: 'jwk' }) };
|
|
77
|
+
}
|
|
78
|
+
if (type === 'ecdsa-sha2-nistp256') {
|
|
79
|
+
if (r.text() !== 'nistp256') throw new Error('malformed ecdsa-sha2-nistp256 key');
|
|
80
|
+
const q = r.string();
|
|
81
|
+
if (q.length !== 65 || q[0] !== 4 || !r.done) throw new Error('malformed ecdsa-sha2-nistp256 point');
|
|
82
|
+
return { type, key: createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: b64url(q.subarray(1, 33)), y: b64url(q.subarray(33)) }, format: 'jwk' }) };
|
|
83
|
+
}
|
|
84
|
+
return { type, key: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// An SSH mpint to a fixed-width unsigned big-endian integer.
|
|
88
|
+
function unsignedFixed(mpint, width) {
|
|
89
|
+
let b = mpint;
|
|
90
|
+
if (b.length && (b[0] & 0x80)) throw new Error('ECDSA component is negative');
|
|
91
|
+
while (b.length > 1 && b[0] === 0) b = b.subarray(1);
|
|
92
|
+
if (b.length > width) throw new Error('ECDSA component too wide');
|
|
93
|
+
return Buffer.concat([Buffer.alloc(width - b.length), b]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkSignature(type, key, sigBlob, data) {
|
|
97
|
+
const r = new Reader(sigBlob);
|
|
98
|
+
const sigType = r.text();
|
|
99
|
+
const sig = r.string();
|
|
100
|
+
if (!r.done) return false;
|
|
101
|
+
if (type === 'ssh-ed25519') return sigType === 'ssh-ed25519' && sig.length === 64 && cryptoVerify(null, data, key, sig);
|
|
102
|
+
if (type === 'ecdsa-sha2-nistp256') {
|
|
103
|
+
if (sigType !== 'ecdsa-sha2-nistp256') return false;
|
|
104
|
+
const rs = new Reader(sig);
|
|
105
|
+
const p1363 = Buffer.concat([unsignedFixed(rs.string(), 32), unsignedFixed(rs.string(), 32)]);
|
|
106
|
+
if (!rs.done) return false;
|
|
107
|
+
return cryptoVerify('sha256', data, { key, dsaEncoding: 'ieee-p1363' }, p1363);
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Splits an allowed_signers line on blanks outside double quotes.
|
|
113
|
+
function fields(line) {
|
|
114
|
+
const out = [];
|
|
115
|
+
let cur = '';
|
|
116
|
+
let quoted = false;
|
|
117
|
+
for (const ch of line) {
|
|
118
|
+
if (ch === '"') { quoted = !quoted; cur += ch; } else if (!quoted && (ch === ' ' || ch === '\t')) { if (cur) { out.push(cur); cur = ''; } } else cur += ch;
|
|
119
|
+
}
|
|
120
|
+
if (cur) out.push(cur);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseAllowedSigners(text) {
|
|
125
|
+
const entries = [];
|
|
126
|
+
for (const raw of String(text).split('\n')) {
|
|
127
|
+
const line = raw.trim();
|
|
128
|
+
if (!line || line.startsWith('#')) continue;
|
|
129
|
+
const f = fields(line);
|
|
130
|
+
if (f.length < 3) continue;
|
|
131
|
+
const principals = f[0].split(',');
|
|
132
|
+
let i = 1;
|
|
133
|
+
const optionFields = [];
|
|
134
|
+
while (i < f.length && !KEY_TYPE.test(f[i])) optionFields.push(f[i++]);
|
|
135
|
+
const options = optionFields.join(',');
|
|
136
|
+
const keyType = f[i];
|
|
137
|
+
let keyBlob;
|
|
138
|
+
try { keyBlob = Buffer.from(f[i + 1] || '', 'base64'); } catch { continue; }
|
|
139
|
+
if (!keyType || !keyBlob.length) continue;
|
|
140
|
+
const opts = new Map();
|
|
141
|
+
for (const o of fields(options.replace(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/g, ' '))) {
|
|
142
|
+
const eq = o.indexOf('=');
|
|
143
|
+
opts.set((eq < 0 ? o : o.slice(0, eq)).toLowerCase(), eq < 0 ? true : o.slice(eq + 1).replace(/^"|"$/g, ''));
|
|
144
|
+
}
|
|
145
|
+
entries.push({
|
|
146
|
+
principals,
|
|
147
|
+
keyType,
|
|
148
|
+
keyBlob,
|
|
149
|
+
certAuthority: opts.has('cert-authority'),
|
|
150
|
+
namespaces: typeof opts.get('namespaces') === 'string' ? opts.get('namespaces').split(',') : null,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return entries;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// { valid: true|false|null, reason, keyType, keyid, principals } for one SSHSIG blob over `message`.
|
|
157
|
+
export function verifySshsig({ blob, message, namespace, allowed }) {
|
|
158
|
+
let sig;
|
|
159
|
+
try { sig = parseSshsig(blob); } catch (e) { return { valid: false, reason: `not a signature: ${e.message}` }; }
|
|
160
|
+
const keyid = fingerprint(sig.publicKey);
|
|
161
|
+
let pk;
|
|
162
|
+
try { pk = publicKeyOf(sig.publicKey); } catch (e) { return { valid: false, keyid, reason: e.message }; }
|
|
163
|
+
const base = { keyType: pk.type, keyid };
|
|
164
|
+
if (sig.namespace !== namespace) return { ...base, valid: false, reason: `signed for namespace ${sig.namespace}, not ${namespace}` };
|
|
165
|
+
const matches = (allowed || []).filter((a) => !a.certAuthority && a.keyBlob.equals(sig.publicKey) && (!a.namespaces || a.namespaces.includes(namespace)));
|
|
166
|
+
if (!matches.length) return { ...base, valid: false, reason: 'the signing key is not in allowed signers for this namespace' };
|
|
167
|
+
const principals = matches.flatMap((m) => m.principals);
|
|
168
|
+
if (!VERIFIED_TYPES.has(pk.type) || !pk.key) return { ...base, principals, valid: null, reason: `${pk.type} signatures are not verified here` };
|
|
169
|
+
let ok = false;
|
|
170
|
+
try { ok = checkSignature(pk.type, pk.key, sig.signature, signedData(namespace, sig.hashAlg, message)); } catch (e) { return { ...base, principals, valid: false, reason: e.message }; }
|
|
171
|
+
return { ...base, principals, valid: ok, reason: ok ? null : 'the signature does not match the signed bytes' };
|
|
172
|
+
}
|