@portll/cobolwork 0.0.1 → 0.2.75

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSING.md +93 -0
  3. package/NOTICE +9 -0
  4. package/README.md +323 -3
  5. package/THIRD-PARTY-NOTICES.md +118 -0
  6. package/bin/cobolwork.mjs +354 -0
  7. package/lib/advisories.mjs +133 -0
  8. package/lib/baseline.mjs +154 -0
  9. package/lib/bms.mjs +453 -0
  10. package/lib/build.mjs +402 -0
  11. package/lib/capabilities.mjs +79 -0
  12. package/lib/cics-commands.mjs +281 -0
  13. package/lib/compliance.mjs +81 -0
  14. package/lib/consequence.mjs +139 -0
  15. package/lib/control.mjs +1515 -0
  16. package/lib/csd.mjs +77 -0
  17. package/lib/dataflow.mjs +1506 -0
  18. package/lib/diff.mjs +344 -0
  19. package/lib/explain.mjs +145 -0
  20. package/lib/gate.mjs +383 -0
  21. package/lib/index.mjs +6 -0
  22. package/lib/inventory.mjs +79 -0
  23. package/lib/jcl.mjs +478 -0
  24. package/lib/kernel/findings.mjs +94 -0
  25. package/lib/kernel/identity.mjs +216 -0
  26. package/lib/kernel/memory.mjs +217 -0
  27. package/lib/kernel/printable.mjs +6 -0
  28. package/lib/kernel/registry.mjs +79 -0
  29. package/lib/kernel/ruleset.mjs +72 -0
  30. package/lib/kernel/source-tree.mjs +159 -0
  31. package/lib/kev.mjs +27 -0
  32. package/lib/options.mjs +512 -0
  33. package/lib/packs.mjs +148 -0
  34. package/lib/parser.mjs +2055 -0
  35. package/lib/policy.mjs +163 -0
  36. package/lib/precompile-cics.mjs +169 -0
  37. package/lib/precompile.mjs +544 -0
  38. package/lib/reach.mjs +122 -0
  39. package/lib/revision.json +1 -0
  40. package/lib/revision.mjs +89 -0
  41. package/lib/sarif.mjs +222 -0
  42. package/lib/scan.mjs +272 -0
  43. package/lib/sets/build.mjs +234 -0
  44. package/lib/sets/cics.mjs +306 -0
  45. package/lib/sets/compile.mjs +187 -0
  46. package/lib/sets/copybook.mjs +174 -0
  47. package/lib/sets/flow.mjs +487 -0
  48. package/lib/sets/hidden.mjs +216 -0
  49. package/lib/sets/jcl.mjs +440 -0
  50. package/lib/sets/log.mjs +406 -0
  51. package/lib/sets/opaque.mjs +102 -0
  52. package/lib/sets/priv.mjs +322 -0
  53. package/lib/sets/recon.mjs +267 -0
  54. package/lib/sets/vendor.mjs +117 -0
  55. package/lib/sets/web.mjs +327 -0
  56. package/lib/site.mjs +164 -0
  57. package/lib/sources.mjs +156 -0
  58. package/lib/tui/app.mjs +325 -0
  59. package/lib/tui/keys.mjs +39 -0
  60. package/lib/tui/model.mjs +96 -0
  61. package/lib/tui/run.mjs +38 -0
  62. package/lib/tui/screen.mjs +59 -0
  63. package/lib/tui/terminal.mjs +46 -0
  64. package/lib/utilities.mjs +296 -0
  65. package/lib/version.mjs +15 -0
  66. package/lib/words.mjs +318 -0
  67. package/package.json +45 -6
  68. package/rules/advisories.json +264 -0
  69. package/rules/compliance-dora.json +2151 -0
  70. package/rules/compliance-ffiec.json +2134 -0
  71. package/rules/compliance-nist80053.json +2134 -0
  72. package/rules/gitleaks-mainframe.toml +57 -0
  73. package/rules/kev-ids.json +1729 -0
  74. package/rules/packs/broadcom.json +124 -0
  75. package/rules/packs/connectdirect.json +116 -0
  76. package/rules/packs/controlm.json +114 -0
  77. package/rules/system-layouts.json +28 -0
  78. package/schema/cobolwork-coverage.schema.json +65 -0
  79. package/schema/cobolwork.policy.schema.json +54 -0
package/lib/build.mjs ADDED
@@ -0,0 +1,402 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // The build gate, specified in docs/spec/build-gate.md: scan, apply the policy and the baseline,
3
+ // hold the compiler's options to the policy, and run the compiler only on a pass. Every check is the
4
+ // engine's reading of the tree; nothing here asks a model anything.
5
+ import { createHash } from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { basename, extname, join } from 'node:path';
9
+ import { scanAll } from './scan.mjs';
10
+ import { withRefs, configurationChanges, WORKING_TREE } from './diff.mjs';
11
+ import { pairLists, findExecutable } from './gate.mjs';
12
+ import { pairingKeys } from './kernel/identity.mjs';
13
+ import { directoryTree } from './kernel/source-tree.mjs';
14
+ import { printable } from './kernel/printable.mjs';
15
+ import { ALL_RULES } from './kernel/registry.mjs';
16
+ import { loadBaseline, applyBaseline, SUPPRESSING } from './baseline.mjs';
17
+ import { loadSite, SITE_FILE } from './site.mjs';
18
+ import { isProgram, isJcl, isSource, readSource, relPath } from './sources.mjs';
19
+ import { parseJclFile } from './jcl.mjs';
20
+ import { POLICY_FILE, treePolicy, floorPolicy, combinePolicies, canonical, policyHash } from './policy.mjs';
21
+ import { classesOf, tierOf, TIERS, TIER_RANK } from './consequence.mjs';
22
+ import { optionCards, enterpriseChecks, cobcChecks, scriptedCobcChecks, cobcInvocations, cobcTasks, compileStepOptions, isCobc } from './options.mjs';
23
+ import { FLOW_MODEL, TOOL_VERSION } from './version.mjs';
24
+
25
+ export const BUILD_SCHEMA_VERSION = 1;
26
+ export const BUILD_EXIT = { pass: 0, fail: 1, undecided: 3, compilerFailed: 4 };
27
+ const MAX_REASONS = 20;
28
+ const MAX_PROGRAM_REASONS = 10;
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+
31
+ const where = (f) => `${printable(String(f.path ?? ''), 120)}:${Number(f.line) || 0}`;
32
+ const ruleText = (rule) => (ALL_RULES[rule] || {}).text || rule;
33
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
34
+ const refusal = (message) => Object.assign(new Error(message), { code: 'EBUILD' });
35
+
36
+ // Entries written with an expiry further off than the policy allows cover nothing.
37
+ function boundBaseline(baseline, maxDays) {
38
+ if (!baseline) return { baseline: null, overlong: [] };
39
+ const entries = [];
40
+ const overlong = [];
41
+ for (const e of baseline.entries) {
42
+ if (SUPPRESSING.includes(e.action) && Date.parse(e.expires) - Date.parse(e.at) > maxDays * DAY_MS) overlong.push(e);
43
+ else entries.push(e);
44
+ }
45
+ return { baseline: { ...baseline, entries }, overlong };
46
+ }
47
+
48
+ // Findings in the head the base did not hold at the same severity or higher, by gate's pairing.
49
+ function introducedIn(before, after, baseRoot, headRoot) {
50
+ const pairs = pairLists(before.findings, after.findings, pairingKeys(before.findings, { root: baseRoot }), pairingKeys(after.findings, { root: headRoot }));
51
+ const rank = (f) => TIER_RANK[tierOf(f)] ?? 0;
52
+ return new Set(after.findings.filter((f, j) => {
53
+ const i = pairs.baseOf[j];
54
+ return i < 0 || rank(f) > rank(before.findings[i]);
55
+ }));
56
+ }
57
+
58
+ // Why a finding blocks, or null. `renewed` is a finding whose waiver expired or ran past maxDays: in
59
+ // ratchet mode it is judged as new, because the promise that carried it has lapsed.
60
+ export function blockingReason(f, { policy, ratchet, introduced, renewed }) {
61
+ const setting = policy.rules[f.rule];
62
+ if (setting === 'warn') return null;
63
+ const tier = tierOf(f);
64
+ if (ratchet && TIER_RANK[tier] >= TIER_RANK[policy.always]) return 'tier';
65
+ if (ratchet && !introduced.has(f) && !renewed.has(f.fingerprint)) return null;
66
+ if (TIER_RANK[tier] >= TIER_RANK[policy.block]) return 'tier';
67
+ if (classesOf(f).some((c) => policy.classes.includes(c))) return 'class';
68
+ return setting === 'block' ? 'rule' : null;
69
+ }
70
+
71
+ // Build scripts that can run cobc: a Makefile, a shell or batch script, a CI workflow, a Dockerfile,
72
+ // an editor's task file, or a small script with no extension.
73
+ const BUILD_SCRIPT = /(^|\/)(makefile|gnumakefile|dockerfile|[^/]*\.(mk|sh|bash|bat|cmd|ps1|ya?ml))$/i;
74
+ const EDITOR_TASKS = /(^|\/)\.vscode\/tasks\.json$/i;
75
+ const MAX_SCRIPT_BYTES = 1 << 20;
76
+ const MAX_BARE_SCRIPT_BYTES = 64 << 10;
77
+
78
+ const kindOf = (rel) => (/(^|\/)(gnu)?makefile$|\.mk$/i.test(rel) ? 'make' : /(^|\/)dockerfile$/i.test(rel) ? 'docker'
79
+ : /\.(bat|cmd)$/i.test(rel) ? 'batch' : /\.ps1$/i.test(rel) ? 'powershell' : /\.ya?ml$/i.test(rel) ? 'yaml' : 'sh');
80
+
81
+ function scriptInvocations(file, rel) {
82
+ if (EDITOR_TASKS.test(rel)) return cobcTasks(readSource(file).text);
83
+ const size = statSync(file).size;
84
+ if (BUILD_SCRIPT.test(rel) && size <= MAX_SCRIPT_BYTES) return cobcInvocations(readSource(file).text, { kind: kindOf(rel) });
85
+ if (!extname(file) && size <= MAX_BARE_SCRIPT_BYTES) {
86
+ const bytes = readFileSync(file);
87
+ return bytes.includes(0) ? [] : cobcInvocations(bytes.toString('latin1'), { kind: 'sh' });
88
+ }
89
+ return [];
90
+ }
91
+
92
+ // How the repository says it compiles its programs: the cobc commands its build scripts run, and
93
+ // the Enterprise COBOL compile steps its JCL holds.
94
+ function declaredCompiles(root, files) {
95
+ const invocations = [];
96
+ const parsedJcl = [];
97
+ for (const f of files) {
98
+ const rel = relPath(root, f);
99
+ try {
100
+ if (isJcl(f)) parsedJcl.push({ ...parseJclFile(f), file: rel });
101
+ else if (!isProgram(f)) for (const inv of scriptInvocations(f, rel)) invocations.push({ ...inv, file: rel });
102
+ } catch { /* unreadable: the scan counts it, and it declares nothing here */ }
103
+ }
104
+ const steps = new Map();
105
+ for (const s of compileStepOptions(parsedJcl)) {
106
+ if (!steps.has(s.member)) steps.set(s.member, []);
107
+ steps.get(s.member).push(s);
108
+ }
109
+ return { invocations, steps };
110
+ }
111
+
112
+ const memberName = (file) => basename(file).replace(/\.[^.]*$/, '').toUpperCase();
113
+
114
+ // The options every program in the tree is compiled with, held to the policy. A program's options
115
+ // come from the estate's declared defaults, the JCL step that compiles it and its own option cards;
116
+ // where it has none of those, from the cobc commands the repository's build scripts run. `ok` is
117
+ // false where a required check is not generated and null where no one can tell; `forbidden` counts
118
+ // options the policy's forbid list names. `generated` maps each program and build script to the
119
+ // checks it generates, for telling a change that removes one.
120
+ function optionsCheck({ root, allow, site, policy, compiler }) {
121
+ const required = policy.checks;
122
+ if (compiler && !isCobc(compiler[0])) {
123
+ return { ok: null, forbidden: 0, reasons: [`the gate reads the options of cobc and of Enterprise COBOL option cards; for ${printable(compiler[0], 60)} it cannot tell what reaches the compiler`], add: [], generated: new Map() };
124
+ }
125
+ if (compiler) {
126
+ const r = cobcChecks(compiler.slice(1), { required, forbid: policy.forbid.gnucobol });
127
+ const missing = r.checks.filter((c) => c.ok === false).map((c) => c.why);
128
+ return { ok: missing.length || r.forbidden.length ? false : true, forbidden: r.forbidden.length, reasons: [...r.forbidden, ...missing], add: r.forbidden.length ? [] : r.add, compiler: 'gnucobol', generated: new Map() };
129
+ }
130
+ const tree = directoryTree(root);
131
+ const files = [...new Set(tree.list())].filter((p) => !allow || allow.has(p)).sort();
132
+ const programs = files.filter(isProgram);
133
+ const declared = declaredCompiles(root, files);
134
+ let ok = true;
135
+ let forbidden = 0;
136
+ const reasons = [];
137
+ const generated = new Map();
138
+ const fail = (why) => { ok = false; reasons.push(printable(why, 300)); };
139
+ const open = () => { if (ok !== false) ok = null; };
140
+ // A script generates a check only if every cobc command in it does.
141
+ const record = (key, checks) => {
142
+ const was = generated.get(key) || new Map();
143
+ for (const c of checks) was.set(c.check, was.has(c.check) ? was.get(c.check) && c.ok : c.ok);
144
+ generated.set(key, was);
145
+ };
146
+
147
+ for (const inv of declared.invocations) {
148
+ const r = scriptedCobcChecks(inv.args, { required, forbid: policy.forbid.gnucobol });
149
+ record(inv.file, r.checks);
150
+ forbidden += r.forbidden.length;
151
+ for (const why of [...r.checks.filter((c) => c.ok === false).map((c) => c.why), ...r.forbidden]) fail(`${inv.file}:${inv.line}: ${why}`);
152
+ }
153
+ let unknown = 0;
154
+ for (const file of programs) {
155
+ let text;
156
+ try { text = readSource(file).text; } catch { open(); reasons.push(`${printable(relPath(root, file), 120)} could not be read, so its options are not known`); continue; }
157
+ const cards = optionCards(text);
158
+ const steps = declared.steps.get(memberName(file)) || [];
159
+ const siteOptions = site.compilerOptions || [];
160
+ if (!cards.length && !steps.length && !siteOptions.length) {
161
+ if (!declared.invocations.length) { unknown++; open(); }
162
+ continue;
163
+ }
164
+ const r = enterpriseChecks({ required, site: siteOptions, steps, cards, forbid: policy.forbid.enterprise });
165
+ record(relPath(root, file), r.checks);
166
+ forbidden += r.forbidden.length;
167
+ const failed = r.checks.filter((c) => c.ok === false).map((c) => c.why);
168
+ for (const why of [...failed, ...r.forbidden]) fail(`${relPath(root, file)}: ${why}`);
169
+ if (!failed.length && !r.forbidden.length && r.checks.some((c) => c.ok === null)) { unknown++; open(); }
170
+ }
171
+ if (unknown) reasons.push(`${unknown} program(s) have no option this gate can read for ${required.join(', ')}: no compile step or build script names them; declare the estate's defaults as compilerOptions in cobolwork.site.json`);
172
+ return {
173
+ ok, forbidden, generated, reasons: reasons.slice(0, MAX_PROGRAM_REASONS), add: [], compiler: declared.invocations.length ? 'gnucobol' : 'enterprise',
174
+ programs: programs.length, sources: { cobcCommands: declared.invocations.length, compileSteps: [...declared.steps.values()].flat().length },
175
+ };
176
+ }
177
+
178
+ // The checks the base's build generated that the head's no longer does, program by program and
179
+ // script by script.
180
+ function optionsRemoved(before, after) {
181
+ const removed = [];
182
+ for (const [key, checks] of after.generated) {
183
+ const was = before.generated.get(key);
184
+ if (!was) continue;
185
+ for (const [check, now] of checks) if (was.get(check) === true && now === false) removed.push(`${key}: the ${check} check was generated before the change and is not after it`);
186
+ }
187
+ return removed;
188
+ }
189
+
190
+ // The files a scan of `root` reads, hashed, for the provenance record.
191
+ function sourceHashes(root, allow) {
192
+ const tree = directoryTree(root);
193
+ return [...new Set(tree.list())].filter((p) => isSource(p) && (!allow || allow.has(p))).sort()
194
+ .map((p) => ({ path: relPath(root, p), sha256: sha256(readFileSync(p)) }))
195
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
196
+ }
197
+
198
+ function judge({ baseDir, headDir, allow, policyRoot, floor, noBaseline, compiler, compilerPath, now, advisoryFeeds, copylibs }) {
199
+ const repoPolicy = treePolicy(policyRoot);
200
+ if (repoPolicy && repoPolicy.problems.length) throw refusal(`the repository policy does not validate: ${repoPolicy.problems.join('; ')}`);
201
+ const { policy, setBy, ignored } = combinePolicies({ floor: floor ? floor.raw : null, repository: repoPolicy ? repoPolicy.raw : null });
202
+ const ratchet = baseDir !== null;
203
+ // In ratchet mode the change is judged by its base's site file, not by one it wrote itself.
204
+ const scanOpts = { ...(ratchet ? { site: join(baseDir, SITE_FILE) } : {}), ...(advisoryFeeds ? { advisoryFeeds } : {}), systemDirs: copylibs };
205
+ const before = ratchet ? scanAll(baseDir, scanOpts) : null;
206
+ const after = scanAll(headDir, { ...scanOpts, allow });
207
+ const introduced = ratchet ? introducedIn(before, after, baseDir, headDir) : new Set();
208
+
209
+ const bounded = boundBaseline(noBaseline ? null : loadBaseline(policyRoot), policy.waivers.maxDays);
210
+ applyBaseline(after, bounded.baseline, { now });
211
+ const expired = after.baselineExpired || [];
212
+ const overlong = bounded.overlong.map((e) => ({ fingerprint: e.fingerprint, rule: e.rule, who: e.who, at: e.at, expires: e.expires }));
213
+ const renewed = new Set([...expired, ...overlong].map((e) => e.fingerprint));
214
+
215
+ const findings = after.findings.map((f) => {
216
+ const because = blockingReason(f, { policy, ratchet, introduced, renewed });
217
+ return {
218
+ fingerprint: f.fingerprint, rule: f.rule, tier: tierOf(f), sev: f.sev, classes: classesOf(f).filter((c) => policy.classes.includes(c)),
219
+ path: f.path, line: Number(f.line) || 0, ...(ratchet ? { introduced: introduced.has(f) } : {}),
220
+ blocking: because !== null, ...(because ? { because } : {}),
221
+ };
222
+ });
223
+ const blocking = findings.filter((f) => f.blocking);
224
+ const waived = (after.suppressed || []).map((f) => ({ fingerprint: f.fingerprint, rule: f.rule, tier: tierOf(f), who: f.suppressed.who, reason: f.suppressed.reason, expires: f.suppressed.expires }));
225
+
226
+ const reasons = blocking.map((f) => `${f.rule} (${f.tier}${f.classes.length ? `; ${f.classes.join(', ')}` : ''}) at ${where(f)}${f.introduced ? ', introduced by the change' : ''}: ${ruleText(f.rule)}`);
227
+ for (const e of overlong) reasons.push(`the waiver for ${e.rule} (${e.fingerprint}) runs past the policy's ${policy.waivers.maxDays} days, so it covers nothing`);
228
+
229
+ const incomplete = !!(after.summary.coverageIncomplete || (before && before.summary.coverageIncomplete));
230
+ if (incomplete) {
231
+ const sets = [...new Set([...(after.summary.setsIncomplete || []), ...((before && before.summary.setsIncomplete) || [])].map((s) => `${s.set} (${s.kind})`))].sort();
232
+ reasons.push(`coverage is incomplete${sets.length ? ` in ${sets.join(', ')}` : ''}${policy.coverage === 'block' ? ', and the policy says block, so what was not read cannot be told from what is clean' : '; the policy says warn'}`);
233
+ }
234
+ const site = loadSite(policyRoot);
235
+ const options = optionsCheck({ root: headDir, allow, site, policy, compiler });
236
+ reasons.push(...options.reasons);
237
+ // A missing run-time check is a weaker build, not a defect: warn passes it relaxed. What the
238
+ // policy forbids by name, and a check the change itself removed, still fail.
239
+ const removed = ratchet && !compiler ? optionsRemoved(optionsCheck({ root: baseDir, allow: null, site, policy, compiler }), options) : [];
240
+ reasons.push(...removed.slice(0, MAX_PROGRAM_REASONS));
241
+ const relaxOptions = policy.options === 'warn' && options.ok !== true && !options.forbidden && !removed.length;
242
+ if (relaxOptions) reasons.push(options.ok === null ? 'the options of some programs are unknown, and the policy says warn' : 'some programs are built without a check the policy names, and the policy says warn');
243
+
244
+ const checks = {
245
+ findings: blocking.length === 0,
246
+ coverage: incomplete && policy.coverage === 'block' ? null : true,
247
+ options: options.forbidden || removed.length ? false : relaxOptions ? true : options.ok,
248
+ compile: null,
249
+ };
250
+ const relaxed = [...(incomplete && policy.coverage === 'warn' ? ['coverage'] : []), ...(relaxOptions ? ['options'] : [])];
251
+ const decided = [checks.findings, checks.coverage, checks.options];
252
+ const verdict = decided.includes(false) ? 'fail' : decided.includes(null) ? 'undecided' : 'pass';
253
+
254
+ const configurationChanged = ratchet ? [
255
+ ...configurationChanges(baseDir, headDir).map((c) => c.file),
256
+ ...(policyChanged(baseDir, headDir) ? [POLICY_FILE] : []),
257
+ ] : [];
258
+
259
+ const byTier = Object.fromEntries(TIERS.map((t) => [t, 0]));
260
+ for (const f of findings) byTier[f.tier]++;
261
+ const doc = {
262
+ tool: 'cobolwork-build',
263
+ schemaVersion: BUILD_SCHEMA_VERSION,
264
+ verdict,
265
+ relaxed,
266
+ mode: ratchet ? 'ratchet' : 'absolute',
267
+ checks,
268
+ blocking,
269
+ findings,
270
+ waived,
271
+ expired: expired.map((e) => ({ fingerprint: e.fingerprint, rule: e.rule, who: e.who, expired: e.expired })),
272
+ overlong,
273
+ optionsAdded: options.add,
274
+ configurationChanged,
275
+ policy: { sha256: policyHash(policy), setBy, ignored, applied: policy },
276
+ compiled: null,
277
+ reasons: reasons.slice(0, MAX_REASONS).map((x) => printable(x, 400)),
278
+ ...(reasons.length > MAX_REASONS ? { reasonsNotShown: reasons.length - MAX_REASONS } : {}),
279
+ repositoryText: ['blocking[].path', 'findings[].path', 'waived[].who', 'waived[].reason', 'expired[].who', 'overlong[].who', 'reasons'],
280
+ summary: {
281
+ flowModel: FLOW_MODEL,
282
+ toolVersion: TOOL_VERSION,
283
+ findings: findings.length,
284
+ blocking: blocking.length,
285
+ waived: waived.length,
286
+ byTier,
287
+ coverageIncomplete: incomplete,
288
+ ...(after.summary.baseline && after.summary.baseline.problems ? { baselineProblems: after.summary.baseline.problems } : {}),
289
+ },
290
+ };
291
+ const hashes = sourceHashes(headDir, allow);
292
+ return { doc, report: after, policy, setBy, hashes, add: options.add, compilerPath };
293
+ }
294
+
295
+ function policyChanged(baseDir, headDir) {
296
+ const bytes = (root) => { const p = join(root, POLICY_FILE); return existsSync(p) ? readFileSync(p) : null; };
297
+ const was = bytes(baseDir);
298
+ const now = bytes(headDir);
299
+ return !(was === null && now === null) && !(was && now && was.equals(now));
300
+ }
301
+
302
+ // Runs the gate over `repo`. `compiler` is the argument vector after `--`, or null. Returns
303
+ // { doc, report, provenance, exit }; throws with code EBUILD when it could not run.
304
+ export function build(repo, { base = null, head = null, policy = null, noBaseline = false, compiler = null, advisoryFeeds = null, copylibs = [], now = new Date().toISOString(), env = process.env } = {}) {
305
+ const floor = policy ? floorPolicy(policy, repo) : null;
306
+ if (floor && floor.problems.length) throw refusal(floor.problems.join('; '));
307
+ let compilerPath = null;
308
+ if (compiler) {
309
+ if (!compiler.length) throw refusal('nothing follows --, so there is no compiler to run');
310
+ const found = findExecutable(compiler[0], { repo, env });
311
+ if (!found.path) throw refusal(`the compiler: ${found.why}`);
312
+ compilerPath = found.path;
313
+ }
314
+ const run = (baseDir, headDir, allow, policyRoot) => judge({ baseDir, headDir, allow, policyRoot, floor, noBaseline, compiler, compilerPath, now, advisoryFeeds, copylibs });
315
+ const judged = base
316
+ ? withRefs(repo, base, head, {}, (baseDir, headDir, scoped) => {
317
+ const r = run(baseDir, headDir, scoped.allow || null, baseDir);
318
+ r.doc.summary.base = base;
319
+ r.doc.summary.head = head || WORKING_TREE;
320
+ return r;
321
+ })
322
+ : run(null, repo, null, repo);
323
+
324
+ const { doc } = judged;
325
+ let exit = BUILD_EXIT[doc.verdict];
326
+ if (compiler && doc.verdict === 'pass') {
327
+ const argv = [...compiler.slice(1), ...judged.add];
328
+ // The compiler's own output goes to standard error: standard output carries the document.
329
+ const r = spawnSync(compilerPath, argv, { stdio: ['ignore', 2, 2], env, windowsHide: true });
330
+ const status = r.error ? null : r.status;
331
+ doc.compiled = { path: compilerPath, argv, status, ...(r.error ? { error: printable(r.error.message, 200) } : {}), ...(r.signal ? { signal: r.signal } : {}) };
332
+ doc.checks.compile = status === 0;
333
+ if (status !== 0) {
334
+ exit = BUILD_EXIT.compilerFailed;
335
+ doc.reasons.push(printable(`the compiler exited ${status === null ? `without a status${r.signal ? ` (${r.signal})` : ''}` : status}`, 400));
336
+ }
337
+ }
338
+ return { doc, report: judged.report, provenance: provenanceRecord(judged, { base, head, env, copylibs }), exit };
339
+ }
340
+
341
+ function provenanceRecord({ doc, policy, setBy, hashes, compilerPath }, { base, head, env, copylibs }) {
342
+ const epoch = env.SOURCE_DATE_EPOCH;
343
+ const builtAt = epoch && /^\d+$/.test(epoch) ? new Date(Number(epoch) * 1000).toISOString() : null;
344
+ let compiler = null;
345
+ if (doc.compiled) {
346
+ let digest = null;
347
+ try { digest = sha256(readFileSync(compilerPath)); } catch { /* unreadable binary: recorded as null */ }
348
+ compiler = { path: doc.compiled.path, sha256: digest, argv: doc.compiled.argv, status: doc.compiled.status };
349
+ }
350
+ return {
351
+ tool: 'cobolwork-build-provenance',
352
+ schemaVersion: BUILD_SCHEMA_VERSION,
353
+ toolVersion: TOOL_VERSION,
354
+ flowModel: FLOW_MODEL,
355
+ ...(builtAt ? { builtAt } : {}),
356
+ revisions: { base: base || null, head: base ? head || WORKING_TREE : WORKING_TREE },
357
+ policy: { canonical: canonical(policy), sha256: policyHash(policy), setBy },
358
+ sources: hashes,
359
+ ...(copylibs.length ? { copylibs } : {}),
360
+ waivers: doc.waived.map((w) => ({ fingerprint: w.fingerprint, rule: w.rule, who: w.who, reason: w.reason, expires: w.expires })),
361
+ compiler,
362
+ verdict: doc.verdict,
363
+ relaxed: doc.relaxed,
364
+ };
365
+ }
366
+
367
+ // One line for a CI log: the verdict, what blocked it, and what is advisory. INFO is context and
368
+ // coverage, which assert no defect, so it is counted in the document and not here.
369
+ export function buildSummaryLine(doc) {
370
+ const count = (list) => {
371
+ const by = {};
372
+ for (const f of list) by[f.tier] = (by[f.tier] || 0) + 1;
373
+ return [...TIERS].reverse().filter((t) => by[t]).map((t) => `${by[t]} ${t.toUpperCase()}`).join(', ');
374
+ };
375
+ const advisory = doc.findings.filter((f) => !f.blocking && f.tier !== 'info');
376
+ const relaxed = doc.relaxed && doc.relaxed.length ? doc.relaxed.join(', ') : '';
377
+ const parts = [`cobolwork build: ${doc.verdict}${relaxed && doc.verdict === 'pass' ? ` (relaxed: ${relaxed})` : ''}`];
378
+ if (relaxed && doc.verdict !== 'pass') parts.push(`relaxed: ${relaxed}`);
379
+ if (doc.blocking.length) parts.push(`blocking ${count(doc.blocking)}`);
380
+ if (advisory.length) parts.push(`advisory ${count(advisory)}`);
381
+ if (doc.checks.coverage === null) parts.push('coverage incomplete');
382
+ if (doc.checks.options !== true) parts.push(`options ${doc.checks.options === false ? 'refused' : 'unknown'}`);
383
+ if (doc.compiled && doc.compiled.status !== 0) parts.push(`compiler exited ${doc.compiled.status}`);
384
+ return parts.join('; ');
385
+ }
386
+
387
+ // The scan's SARIF, with a blocking finding at error and every other one no higher than warning.
388
+ export function buildSarif(sarif, doc) {
389
+ const blocking = new Set(doc.blocking.map((f) => f.fingerprint));
390
+ const byPrint = new Map(doc.findings.map((f) => [f.fingerprint, f]));
391
+ for (const run of sarif.runs || []) {
392
+ for (const r of run.results || []) {
393
+ const fp = r.fingerprints && Object.values(r.fingerprints)[0];
394
+ const f = byPrint.get(fp);
395
+ if (blocking.has(fp)) r.level = 'error';
396
+ else if (r.level === 'error') r.level = 'warning';
397
+ if (f) r.properties = { ...(r.properties || {}), tier: f.tier, classes: f.classes, blocking: f.blocking };
398
+ }
399
+ run.invocations = (run.invocations || []).map((inv) => ({ ...inv, properties: { ...(inv.properties || {}), 'cobolwork/build': { verdict: doc.verdict, relaxed: doc.relaxed, checks: doc.checks, policy: doc.policy.sha256 } } }));
400
+ }
401
+ return sarif;
402
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // What this cobolwork can do, as one document a caller reads instead of probing: `cobolwork
3
+ // capabilities --json`. The commands and options here are held to bin/cobolwork.mjs by a test, and
4
+ // every version is read from where the document that carries it is built.
5
+ import { RULE_SETS, ALL_RULES } from './kernel/registry.mjs';
6
+ import { EVIDENCE } from './kernel/findings.mjs';
7
+ import { FINGERPRINT_VERSION } from './kernel/identity.mjs';
8
+ import { PROGRAM_EXT, COPY_EXT, JCL_EXT, BMS_EXT } from './sources.mjs';
9
+ import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
10
+ import { BUILD_SCHEMA_VERSION, BUILD_EXIT } from './build.mjs';
11
+ import { VERDICT_EXIT } from './gate.mjs';
12
+ import { CHECKS, COMPILERS, DEFAULT_POLICY } from './policy.mjs';
13
+ import { CLASSES, TIERS } from './consequence.mjs';
14
+ import { toolRevision } from './revision.mjs';
15
+
16
+ export const CAPABILITIES_SCHEMA_VERSION = 1;
17
+
18
+ // Each command, what it takes and what it writes.
19
+ export const COMMANDS = {
20
+ scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib'], documents: ['cobolwork', 'sarif'] },
21
+ flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--copylib'], documents: ['cobolwork-flow'] },
22
+ inventory: { args: ['<path>'], options: ['--out', '--quiet', '--copylib'], documents: ['cobolwork-inventory'] },
23
+ parse: { args: ['<file>'], options: ['--out'], documents: ['cobolwork-parse'] },
24
+ diff: { args: ['<repo>'], options: ['--base', '--head', '--format', '--out', '--only', '--quiet', '--full-trace', '--copylib'], documents: ['cobolwork-diff', 'sarif'] },
25
+ baseline: { args: ['<path>'], options: ['--reason', '--who', '--expires', '--action', '--rule', '--out', '--repos', '--only', '--advisories', '--copylib'], documents: ['cobolwork-baseline'] },
26
+ tui: { args: ['[path]'], options: ['--report', '--keys', '--baseline', '--no-baseline', '--advisories', '--copylib'], documents: [] },
27
+ explain: { args: ['<path>', '<fingerprint>'], options: ['--report', '--out', '--baseline', '--no-baseline', '--advisories', '--copylib'], documents: ['cobolwork-explain'] },
28
+ gate: { args: ['<repo>'], options: ['--base', '--head', '--target', '--cobc', '--exit-code', '--target-only', '--out'], documents: ['cobolwork-gate'] },
29
+ build: { args: ['<repo>', '[-- <compiler> <arg>...]'], options: ['--base', '--head', '--policy', '--provenance', '--advisories', '--copylib', '--no-baseline', '--format', '--out'], documents: ['cobolwork-build', 'cobolwork-build-provenance', 'sarif'] },
30
+ capabilities: { args: [], options: ['--json'], documents: ['cobolwork-capabilities'] },
31
+ };
32
+
33
+ // Options every command takes, or that end the run before a command is chosen.
34
+ export const GLOBAL_OPTIONS = ['--help', '--version', '--rules-path'];
35
+
36
+ export function capabilities() {
37
+ return {
38
+ tool: 'cobolwork-capabilities',
39
+ schemaVersion: CAPABILITIES_SCHEMA_VERSION,
40
+ toolVersion: TOOL_VERSION,
41
+ toolRevision: toolRevision(),
42
+ commands: COMMANDS,
43
+ globalOptions: GLOBAL_OPTIONS,
44
+ // A document whose schemaVersion is null carries none yet; a reader should key on `tool`.
45
+ documents: {
46
+ cobolwork: SCHEMA_VERSION,
47
+ 'cobolwork-flow': SCHEMA_VERSION,
48
+ 'cobolwork-inventory': SCHEMA_VERSION,
49
+ 'cobolwork-diff': SCHEMA_VERSION,
50
+ 'cobolwork-gate': SCHEMA_VERSION,
51
+ 'cobolwork-build': BUILD_SCHEMA_VERSION,
52
+ 'cobolwork-build-provenance': BUILD_SCHEMA_VERSION,
53
+ 'cobolwork-capabilities': CAPABILITIES_SCHEMA_VERSION,
54
+ 'cobolwork-explain': null,
55
+ 'cobolwork-baseline': null,
56
+ 'cobolwork-parse': null,
57
+ sarif: '2.1.0',
58
+ },
59
+ identity: { version: FINGERPRINT_VERSION },
60
+ analysis: { flowModel: FLOW_MODEL, ruleSets: RULE_SETS, rules: Object.keys(ALL_RULES).length },
61
+ sourceKinds: {
62
+ program: PROGRAM_EXT,
63
+ copybook: COPY_EXT,
64
+ jcl: JCL_EXT,
65
+ bms: BMS_EXT,
66
+ extensionless: 'sniffed by content: a file with no extension is classified by what it holds',
67
+ },
68
+ evidenceKinds: Object.keys(EVIDENCE),
69
+ build: {
70
+ tiers: TIERS,
71
+ classes: CLASSES,
72
+ checks: CHECKS,
73
+ compilers: COMPILERS,
74
+ defaultPolicy: DEFAULT_POLICY,
75
+ exit: { ...BUILD_EXIT, couldNotRun: 2 },
76
+ },
77
+ gate: { exit: { ...VERDICT_EXIT, couldNotRun: 2 } },
78
+ };
79
+ }