@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
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { writeFileSync, readFileSync, lstatSync, statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { readdirSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { scanAll, RULE_SETS } from '../lib/scan.mjs';
8
+ import { scan as scanFlow } from '../lib/sets/flow.mjs';
9
+ import { inventory } from '../lib/inventory.mjs';
10
+ import { parseFile } from '../lib/parser.mjs';
11
+ import { toSarif } from '../lib/sarif.mjs';
12
+ import { diffRefs } from '../lib/diff.mjs';
13
+ import { gateRefs, VERDICT_EXIT } from '../lib/gate.mjs';
14
+ import { build, buildSarif, buildSummaryLine } from '../lib/build.mjs';
15
+ import { capabilities } from '../lib/capabilities.mjs';
16
+ import { commitAt, revisionOf, toolRevision } from '../lib/revision.mjs';
17
+ import { stampFingerprints } from '../lib/kernel/identity.mjs';
18
+ import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, SUPPRESSING } from '../lib/baseline.mjs';
19
+ import { readReport } from '../lib/tui/model.mjs';
20
+ import { nodeTerminal } from '../lib/tui/terminal.mjs';
21
+ import { runTui } from '../lib/tui/run.mjs';
22
+ import { explainFinding } from '../lib/explain.mjs';
23
+ import { printable } from '../lib/kernel/printable.mjs';
24
+
25
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
+
27
+ const USAGE = `cobolwork ${VERSION} — COBOL, JCL and CICS security analysis, no runtime dependencies
28
+
29
+ cobolwork scan <path> every rule set: data flow, CICS, JCL, hidden content, copybooks, build pins
30
+ cobolwork inventory <path> what is there and what could not be read (copybooks, dialects)
31
+ cobolwork flow <path> data-flow findings with the path the data took, as JSON
32
+ cobolwork parse <file> parse one source file and print a summary
33
+ cobolwork diff <repo> --base <ref> [--head <ref>]
34
+ what a change reaches: layouts it moves in programs it never
35
+ edited, new call targets, and findings it adds or removes
36
+ cobolwork baseline <path> --reason <text> --who <name> --expires <date>
37
+ accept what a scan reports now, until a date, in ${BASELINE_FILE};
38
+ entries already there are kept as they were
39
+ cobolwork tui [path] read a scan in the terminal: coverage first, then the findings
40
+ cobolwork explain <path> <fingerprint>
41
+ one finding with the source line of every hop its trace kept and the
42
+ declaration of every item on its path; unlike a report, this carries source
43
+ cobolwork gate <repo> --base <ref> [--head <ref>] --target <fingerprint>
44
+ whether a patch fixed that finding and moved nothing else: pass, fail
45
+ or undecided, with the reason for each check that did not pass
46
+ cobolwork capabilities [--json]
47
+ what this cobolwork can do: commands and options, the version of every
48
+ document it writes, its fingerprint version, the file kinds it reads, and
49
+ the commit it runs from
50
+ cobolwork build <repo> [--base <ref>] [--policy <file>] [--provenance <file>] [-- <compiler> <arg>...]
51
+ the build gate: every finding ranked LOW to KNOWN-EXPLOITABLE, the
52
+ policy's blocking findings and compiler options checked, and the
53
+ compiler run only on a pass. Exits 0 pass, 1 fail, 3 undecided, 4 the
54
+ compiler failed after a pass, 2 could not run
55
+
56
+ Options
57
+ --format json|sarif output format (default json)
58
+ --out <file> write to a file instead of stdout
59
+ --repos treat each immediate subdirectory as its own repository
60
+ --only <sets> comma-separated subset of ${RULE_SETS.join(',')}
61
+ --rules-path gitleaks print the path of the mainframe credential rules and exit
62
+ --quiet summary only
63
+ --full-trace list every hop of a data flow path, not its two ends
64
+ --base <ref> diff: the git revision to compare against
65
+ --head <ref> diff: the revision under review (default: the working tree)
66
+ --baseline <file> judgements to apply; without it, ${BASELINE_FILE} in the scanned tree
67
+ --no-baseline apply no baseline, the tree's own included
68
+ --reason, --who, --expires <date>, --action accept|false-positive|wont-fix, --rule <ids>
69
+ baseline: the judgement each new entry records, and which rules it covers
70
+ --advisories <file>[,<file>] a customer's own advisory extract (JSON), loaded for this scan only;
71
+ never kept in the tree
72
+ --copylib <dir>[,<dir>] copy libraries the estate keeps outside the repository, searched after the
73
+ tree's own copybooks, as COBCPY is; a copybook found there is read, not reported missing
74
+ --report <file> tui, explain: read a stored scan report instead of scanning
75
+ --keys ispf|modern tui: F3 and a command line, or Esc and letters (default ispf)
76
+ --target <fingerprint> gate: the finding the patch is meant to fix, as the base reports it
77
+ --cobc <path> gate: the COBOL compiler to check the patch with; by default the first cobc on
78
+ PATH outside the repository, and without one the document says not compiled
79
+ --exit-code gate: exit 0 on pass, 1 on fail, 3 on undecided rather than 0 whenever it ran
80
+ --target-only gate: judge only the target and coverage, for a revision others have changed since
81
+ --policy <file> build: an organisation's floor policy, from outside the repository; the
82
+ repository's cobolwork.policy.json can tighten it and never loosen it
83
+ --provenance <file> build: write what was scanned, under which policy, and what was compiled
84
+
85
+ Exit codes: 0 the command ran, 2 it could not run. A run that examined nothing says so in
86
+ summary.filesScanned and summary.nosrc rather than reporting a clean zero.
87
+ `;
88
+
89
+ function parseArgs(argv) {
90
+ const opts = { format: 'json', out: null, repos: false, quiet: false, _: [] };
91
+ for (let i = 0; i < argv.length; i++) {
92
+ const a = argv[i];
93
+ const value = () => {
94
+ const v = argv[++i];
95
+ if (v === undefined || v === '' || v.startsWith('--')) { opts.needsValue ||= a; return undefined; }
96
+ return v;
97
+ };
98
+ const list = () => {
99
+ const items = [...new Set(String(value() ?? '').split(',').map(x => x.trim()).filter(Boolean))];
100
+ if (!items.length) opts.needsValue ||= a;
101
+ return items;
102
+ };
103
+ if (a === '--format') opts.format = value();
104
+ else if (a === '--out') opts.out = value();
105
+ else if (a === '--repos') opts.repos = true;
106
+ else if (a === '--only') opts.only = list();
107
+ else if (a === '--rules-path') opts.rulesPath = argv[++i] || 'gitleaks';
108
+ else if (a === '--quiet') opts.quiet = true;
109
+ else if (a === '--full-trace') opts.fullTrace = true;
110
+ else if (a === '--base') opts.base = value();
111
+ else if (a === '--head') opts.head = value();
112
+ else if (a === '--baseline') opts.baseline = value();
113
+ else if (a === '--no-baseline') opts.noBaseline = true;
114
+ else if (a === '--reason') opts.reason = value();
115
+ else if (a === '--who') opts.who = value();
116
+ else if (a === '--expires') opts.expires = value();
117
+ else if (a === '--action') opts.action = value();
118
+ else if (a === '--rule') opts.rule = list();
119
+ else if (a === '--advisories') opts.advisoryFeeds = [...new Set(list().map(x => resolve(x)))];
120
+ else if (a === '--copylib') opts.copylib = [...new Set(list().map(x => resolve(x)))];
121
+ else if (a === '--report') opts.report = value();
122
+ else if (a === '--keys') opts.keys = value();
123
+ else if (a === '--target') opts.target = value();
124
+ else if (a === '--cobc') opts.cobc = value();
125
+ else if (a === '--exit-code') opts.exitCode = true;
126
+ else if (a === '--target-only') opts.targetOnly = true;
127
+ else if (a === '--json') opts.json = true;
128
+ else if (a === '--policy') opts.policy = value();
129
+ else if (a === '--provenance') opts.provenance = value();
130
+ else if (a === '--help' || a === '-h') opts.help = true;
131
+ else if (a === '--version' || a === '-v') opts.version = true;
132
+ else if (a.startsWith('-')) { opts.unknown = a; }
133
+ else opts._.push(a);
134
+ }
135
+ return opts;
136
+ }
137
+
138
+ // A consumer that reads logs rather than the report still has to learn that the reading was short.
139
+ // Written to stderr, so it lands in the lane log beside whatever else the run said.
140
+ function warnCoverage(report) {
141
+ const s = report && report.summary;
142
+ if (!s || !s.coverageIncomplete) return;
143
+ const why = [
144
+ s.copiesMissing ? `${s.copiesMissing} unresolved COPY` : "",
145
+ ((report.inventory && report.inventory.refusedCopies) || []).length ? `${report.inventory.refusedCopies.length} refused COPY` : "",
146
+ s.filesUnreadable ? `${s.filesUnreadable} unreadable file(s)` : "",
147
+ s.filesOverBudget ? `${s.filesOverBudget} file(s) past the source budget` : "",
148
+ ].filter(Boolean).join(", ");
149
+ process.stderr.write(`cobolwork: coverage incomplete${why ? `: ${why}` : ""}\n`);
150
+ }
151
+
152
+ const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
153
+
154
+ function emit(obj, opts) {
155
+ const text = JSON.stringify(obj, null, opts.quiet ? 0 : 1);
156
+ if (opts.out) { writeFileSync(opts.out, text + '\n'); return; }
157
+ // A pipe drains asynchronously and process.exit discards what has not drained: past 64 KB the
158
+ // reader got a truncated document and exit 0. The process ends on its own once stdout is flushed.
159
+ process.stdout.write(text + '\n');
160
+ }
161
+
162
+ // Everything after `--` is a compiler command for build, never options of ours: -fec would otherwise
163
+ // read as an unknown flag.
164
+ const argv = process.argv.slice(2);
165
+ const dashes = argv.indexOf('--');
166
+ const opts = parseArgs(dashes < 0 ? argv : argv.slice(0, dashes));
167
+ const compilerArgv = dashes < 0 ? null : argv.slice(dashes + 1);
168
+ if (opts.help || (!opts._.length && !opts.version && !opts.rulesPath)) { process.stdout.write(USAGE); process.exit(opts.help ? 0 : 2); }
169
+ if (opts.version) { process.stdout.write(`${VERSION}\n`); process.exit(0); }
170
+ if (opts.rulesPath) {
171
+ if (opts.rulesPath !== 'gitleaks') { process.stderr.write(`cobolwork: no rules for ${opts.rulesPath}\n`); process.exit(2); }
172
+ process.stdout.write(`${fileURLToPath(new URL('../rules/gitleaks-mainframe.toml', import.meta.url))}\n`);
173
+ process.exit(0);
174
+ }
175
+ if (opts.unknown) { process.stderr.write(`cobolwork: unknown option ${opts.unknown}\n${USAGE}`); process.exit(2); }
176
+ if (opts.needsValue) { process.stderr.write(`cobolwork: ${opts.needsValue} needs a value\n`); process.exit(2); }
177
+ // A format nobody implements would print JSON and exit 0, which is the same shape of quiet wrong
178
+ // answer as a misspelled rule set. SARIF is a findings document, so the commands that do not
179
+ // produce findings say so rather than ignoring the flag.
180
+ const FORMATS = ['json', 'sarif'];
181
+ if (!FORMATS.includes(opts.format)) { process.stderr.write(`cobolwork: --format takes ${FORMATS.join(',')}; got ${opts.format}\n`); process.exit(2); }
182
+ const SARIF_COMMANDS = ['scan', 'diff', 'build'];
183
+ if (opts.format === 'sarif' && opts._.length && !SARIF_COMMANDS.includes(opts._[0])) {
184
+ process.stderr.write(`cobolwork: ${opts._[0]} has no SARIF form; ${SARIF_COMMANDS.join(' and ')} do\n`);
185
+ process.exit(2);
186
+ }
187
+ // A trace only exists where data flow ran, so the commands that never produce one refuse the flag
188
+ // rather than accepting it and changing nothing.
189
+ const TRACE_COMMANDS = ['scan', 'flow', 'diff'];
190
+ if (opts.fullTrace && opts._.length && !TRACE_COMMANDS.includes(opts._[0])) {
191
+ process.stderr.write(`cobolwork: ${opts._[0]} has no data-flow trace; ${TRACE_COMMANDS.join(', ')} do
192
+ `);
193
+ process.exit(2);
194
+ }
195
+ const ADVISORY_COMMANDS = ['scan', 'baseline', 'tui', 'explain', 'build'];
196
+ if (opts.advisoryFeeds && opts._.length && !ADVISORY_COMMANDS.includes(opts._[0])) {
197
+ process.stderr.write(`cobolwork: ${opts._[0]} reads no advisories; ${ADVISORY_COMMANDS.join(', ')} do\n`);
198
+ process.exit(2);
199
+ }
200
+ const gateFlag = ['target', 'cobc', 'exitCode', 'targetOnly'].find((k) => opts[k] !== undefined);
201
+ if (gateFlag && opts._.length && opts._[0] !== 'gate') {
202
+ process.stderr.write(`cobolwork: --${gateFlag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} is for gate only\n`);
203
+ process.exit(2);
204
+ }
205
+ const COPYLIB_COMMANDS = ['scan', 'flow', 'inventory', 'diff', 'build', 'baseline', 'tui', 'explain'];
206
+ if (opts.copylib && opts._.length && !COPYLIB_COMMANDS.includes(opts._[0])) {
207
+ process.stderr.write(`cobolwork: ${opts._[0]} reads no copybooks; ${COPYLIB_COMMANDS.join(', ')} do\n`);
208
+ process.exit(2);
209
+ }
210
+ // A library that is not there would resolve nothing and report every copybook missing as before.
211
+ const notDirs = (opts.copylib || []).filter((d) => { try { return !statSync(d).isDirectory(); } catch { return true; } });
212
+ if (notDirs.length) { process.stderr.write(`cobolwork: --copylib ${notDirs.join(', ')} is not a directory\n`); process.exit(2); }
213
+ const systemDirs = opts.copylib || [];
214
+ if (opts.json && opts._.length && opts._[0] !== 'capabilities') {
215
+ process.stderr.write(`cobolwork: --json is for capabilities; every other command writes JSON unless --format says otherwise\n`);
216
+ process.exit(2);
217
+ }
218
+ const buildFlag = ['policy', 'provenance'].find((k) => opts[k] !== undefined) || (compilerArgv ? '' : null);
219
+ if (buildFlag !== null && opts._.length && opts._[0] !== 'build') {
220
+ process.stderr.write(`cobolwork: ${buildFlag ? `--${buildFlag}` : '--'} is for build only\n`);
221
+ process.exit(2);
222
+ }
223
+ // A misspelled set would run nothing and report a clean zero.
224
+ const badSets = (opts.only || []).filter(s => !RULE_SETS.includes(s));
225
+ if (badSets.length || (opts.only && !opts.only.length)) { process.stderr.write(`cobolwork: --only takes ${RULE_SETS.join(',')}; got ${badSets.join(',') || 'nothing'}\n`); process.exit(2); }
226
+
227
+ const [command, target] = opts._;
228
+ const root = resolve(target || '.');
229
+ const repos = opts.repos ? readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name) : [''];
230
+
231
+ function storedReport(file) {
232
+ const path = resolve(file);
233
+ let doc;
234
+ try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch (e) {
235
+ throw new Error(`--report ${path}: ${e.code === 'ENOENT' ? 'no such file' : e instanceof SyntaxError ? `not JSON (${e.message})` : e.message}`);
236
+ }
237
+ return readReport(doc);
238
+ }
239
+
240
+ // Which cobolwork wrote a document and which commit it read. Over --repos the root is many
241
+ // repositories, and no one commit is what was read.
242
+ function stampRevisions(summary, head = null) {
243
+ summary.toolRevision = toolRevision();
244
+ if (!opts.repos) summary.revision = head ? commitAt(root, head) : revisionOf(root);
245
+ return summary;
246
+ }
247
+
248
+ function baselinedScan() {
249
+ const report = scanAll(root, { repos, only: opts.only, advisoryFeeds: opts.advisoryFeeds, systemDirs });
250
+ applyBaseline(report, loadBaseline(root, { explicit: opts.baseline ? resolve(opts.baseline) : null, use: !opts.noBaseline }));
251
+ return report;
252
+ }
253
+
254
+ try {
255
+ if (command === 'capabilities') {
256
+ emit(capabilities(), opts);
257
+ } else if (command === 'scan' || command === 'flow') {
258
+ const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
259
+ const report = command === 'flow' ? scanFlow(root, flowOpts) : scanAll(root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds });
260
+ if (command === 'flow') report.summary.identity = stampFingerprints(report.findings, { root });
261
+ applyBaseline(report, loadBaseline(root, { explicit: opts.baseline ? resolve(opts.baseline) : null, use: !opts.noBaseline }));
262
+ stampRevisions(report.summary);
263
+ if (command === 'flow') emit(report, opts);
264
+ else if (opts.format === 'sarif') emit(toSarif(report, { toolVersion: VERSION }), opts);
265
+ else emit(opts.quiet ? { tool: report.tool, schemaVersion: report.schemaVersion, summary: report.summary } : report, opts);
266
+ warnCoverage(report);
267
+ } else if (command === 'diff') {
268
+ if (!opts.base) { process.stderr.write(`cobolwork: diff needs --base <ref>\n`); process.exit(2); }
269
+ const report = diffRefs(root, opts.base, opts.head || null, { only: opts.only, fullTrace: opts.fullTrace === true, systemDirs });
270
+ stampRevisions(report.summary, opts.head || null);
271
+ if (opts.format === 'sarif') emit(toSarif({ ...report, findings: [...report.findings, ...report.introduced] }, { toolVersion: VERSION }), opts);
272
+ else emit(opts.quiet ? { tool: report.tool, schemaVersion: report.schemaVersion, summary: report.summary } : report, opts);
273
+ } else if (command === 'gate') {
274
+ if (!opts.base || !opts.target) { process.stderr.write('cobolwork: gate needs --base <ref> and --target <fingerprint>\n'); process.exit(2); }
275
+ // Every rule set runs on both sides, or a finding the patch adds in a set left out goes unseen.
276
+ if (opts.only || opts.repos) { process.stderr.write('cobolwork: gate judges one repository with every rule set; --only and --repos do not apply\n'); process.exit(2); }
277
+ // A suppression is not a fix, so the gate reads findings as the engine reports them.
278
+ if (opts.baseline || opts.noBaseline) { process.stderr.write('cobolwork: gate applies no baseline; --baseline and --no-baseline do not apply\n'); process.exit(2); }
279
+ const doc = gateRefs(root, opts.base, opts.head || null, opts.target, { cobc: opts.cobc, targetOnly: opts.targetOnly === true });
280
+ stampRevisions(doc.summary, opts.head || null);
281
+ emit(doc, opts);
282
+ if (opts.exitCode) process.exitCode = VERDICT_EXIT[doc.verdict];
283
+ } else if (command === 'build') {
284
+ // A rule set left out is findings not seen, and a waiver file from elsewhere is not the one the
285
+ // change was reviewed against.
286
+ if (opts.only || opts.repos) { process.stderr.write('cobolwork: build judges one repository with every rule set; --only and --repos do not apply\n'); process.exit(2); }
287
+ if (opts.baseline) { process.stderr.write('cobolwork: build reads the baseline the change was written against; --baseline does not apply, --no-baseline does\n'); process.exit(2); }
288
+ if (opts.head && !opts.base) { process.stderr.write('cobolwork: build --head needs --base\n'); process.exit(2); }
289
+ const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs });
290
+ stampRevisions(result.doc.summary, opts.head || null);
291
+ Object.assign(result.report.summary, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
292
+ Object.assign(result.provenance, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
293
+ if (opts.format === 'sarif') {
294
+ const report = result.report;
295
+ emit(buildSarif(toSarif({ ...report, findings: report.findings }, { toolVersion: VERSION }), result.doc), opts);
296
+ } else emit(result.doc, opts);
297
+ if (opts.provenance) writeFileSync(resolve(opts.provenance), JSON.stringify(result.provenance, null, 1) + '\n');
298
+ process.stderr.write(`${buildSummaryLine(result.doc)}\n`);
299
+ process.exitCode = result.exit;
300
+ } else if (command === 'baseline') {
301
+ // A suppression nobody dated is never looked at again, so every part of the judgement is asked for.
302
+ const missing = ['reason', 'who', 'expires'].filter((k) => !opts[k]);
303
+ if (missing.length) { process.stderr.write(`cobolwork: baseline needs ${missing.map((k) => `--${k}`).join(', ')}\n`); process.exit(2); }
304
+ const at = new Date().toISOString();
305
+ const expires = new Date(opts.expires);
306
+ if (Number.isNaN(expires.getTime()) || expires.toISOString() <= at) { process.stderr.write(`cobolwork: --expires must be a date in the future; got ${opts.expires}\n`); process.exit(2); }
307
+ const action = opts.action || 'accept';
308
+ if (!['accept', 'false-positive', 'wont-fix'].includes(action)) { process.stderr.write(`cobolwork: --action takes accept, false-positive or wont-fix; got ${action}\n`); process.exit(2); }
309
+ const path = opts.out ? resolve(opts.out) : resolve(root, BASELINE_FILE);
310
+ // The file in the tree is the tree's, and a link there would aim this write anywhere on disk.
311
+ if (!opts.out && isLink(path)) { process.stderr.write(`cobolwork: ${path} is a symbolic link, which is not written through; name the file with --out\n`); process.exit(2); }
312
+ const held = loadBaseline(root, { explicit: path, mayBeAbsent: true });
313
+ if (held.problems.length) { process.stderr.write(`cobolwork: ${path} holds entries that do not validate, so it is left as it is:\n ${held.problems.join('\n ')}\n`); process.exit(2); }
314
+ const report = scanAll(root, { repos, only: opts.only, advisoryFeeds: opts.advisoryFeeds, systemDirs });
315
+ const { entries, added } = baselineEntries(report.findings, held.entries, { action, reason: opts.reason, who: opts.who, expires: expires.toISOString(), at, rules: opts.rule || null });
316
+ writeFileSync(path, JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', entries }, null, 1) + '\n');
317
+ process.stdout.write(`${JSON.stringify({ tool: 'cobolwork-baseline', path, added, entries: entries.length })}\n`);
318
+ } else if (command === 'tui') {
319
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
320
+ process.stderr.write('cobolwork: tui needs a terminal on standard input and output; cobolwork scan writes the same report without one\n');
321
+ process.exit(2);
322
+ }
323
+ if (opts.keys && !['ispf', 'modern'].includes(opts.keys)) { process.stderr.write(`cobolwork: --keys takes ispf or modern; got ${opts.keys}\n`); process.exit(2); }
324
+ const report = opts.report ? storedReport(opts.report) : baselinedScan();
325
+ const terminal = nodeTerminal();
326
+ process.once('exit', () => terminal.stop());
327
+ await runTui({ report, terminal, keymap: opts.keys || 'ispf', color: !process.env.NO_COLOR });
328
+ } else if (command === 'explain') {
329
+ const fingerprint = opts._[2];
330
+ if (!fingerprint) { process.stderr.write('cobolwork: explain needs <path> <fingerprint>\n'); process.exit(2); }
331
+ const report = opts.report ? storedReport(opts.report) : baselinedScan();
332
+ const packet = explainFinding({ ...report, findings: [...report.findings, ...(report.suppressed || [])] }, fingerprint, { root });
333
+ if (!packet) { process.stderr.write(`cobolwork: no finding with fingerprint ${fingerprint} in this report\n`); process.exit(2); }
334
+ emit(packet, opts);
335
+ } else if (command === 'inventory') {
336
+ const inv = inventory(root, { systemDirs });
337
+ emit(inv, opts);
338
+ warnCoverage(inv);
339
+ } else if (command === 'parse') {
340
+ const r = parseFile(root, { format: 'auto' });
341
+ emit({
342
+ tool: 'cobolwork-parse', file: r.file, format: r.format, finalFormat: r.finalFormat,
343
+ programs: r.programs.map(p => ({ id: p.id, items: p.items.length, labels: p.labels.length, calls: p.calls.length, execs: p.execs.length, diagnostics: p.diags.length })),
344
+ copies: r.copies.map(c => ({ name: c.name, status: c.status })),
345
+ diagnostics: r.diags.length,
346
+ }, opts);
347
+ } else {
348
+ process.stderr.write(`cobolwork: unknown command ${command}\n${USAGE}`);
349
+ process.exit(2);
350
+ }
351
+ } catch (e) {
352
+ process.stderr.write(`cobolwork: ${printable(e && e.message ? e.message : e)}\n`);
353
+ process.exit(2);
354
+ }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Published advisories against the compilers and runtimes a repository might build with, and the
3
+ // version arithmetic to decide whether a pinned version is one of them.
4
+ //
5
+ // A crafted source file in a pull request targets the compiler that CI runs on it, so the version
6
+ // the build pins is part of the repository's attack surface. Every row here was retrieved from the
7
+ // issuing advisory and carries the URL it came from: `diag/verify-advisories.mjs` re-queries NVD
8
+ // and fails if a row no longer matches, because a fabricated identifier in this file would be
9
+ // worse than an empty one.
10
+ //
11
+ // What the file does not cover is stated in it. An absence of published advisories for a product
12
+ // is not an absence of defects in it, and the two must not read the same.
13
+ import { readFileSync, realpathSync } from 'node:fs';
14
+ import { basename, delimiter, sep } from 'node:path';
15
+
16
+ export const ADVISORIES = JSON.parse(readFileSync(new URL('../rules/advisories.json', import.meta.url), 'utf8'));
17
+
18
+ const SEVERITIES = ['crit', 'high', 'med', 'low', 'info'];
19
+ const str = (v) => typeof v === 'string' && v.trim().length > 0;
20
+ const isoDate = (v) => str(v) && /^\d{4}-\d{2}-\d{2}$/.test(v);
21
+
22
+ // What makes an advisory row one the version arithmetic can use, whoever supplied it. The feed gate
23
+ // (feed/schema.mjs) holds a public row to this, to a product it knows and to a quote found in a
24
+ // cached copy of its source; a customer's own extract is held to this alone.
25
+ export function advisoryProblems(row, { products = null } = {}) {
26
+ const problems = [];
27
+ if (products) { if (!products.includes(row.product)) problems.push('product: not one of ' + products.join(', ')); }
28
+ else if (!/^[a-z0-9][a-z0-9-]*$/.test(row.product || '')) problems.push('product: not a product name in lower case');
29
+ if (!str(row.id)) problems.push('id: missing');
30
+ if (!str(row.affected)) problems.push('affected: missing');
31
+ if (row.fixedIn !== null && !str(row.fixedIn)) problems.push('fixedIn: must be a version or null');
32
+ if (!SEVERITIES.includes(row.severity)) problems.push('severity: unknown');
33
+ if (!str(row.summary)) problems.push('summary: missing');
34
+ return problems;
35
+ }
36
+
37
+ // A version range the advisory rule can actually evaluate. Deliberately small: a list, a
38
+ // comparison, or a closed interval.
39
+ export function versionRangeProblems(expr) {
40
+ const ok = expr.split('||').every((part) => /^\s*(<=|>=|<|>|=)?\s*\d+(?:\.\d+)*(?:-[a-z0-9.-]*|[a-z][a-z0-9.-]*)?\s*$/i.test(part) ||
41
+ /^\s*\[\s*\d+(\.\d+)*\s*,\s*\d+(\.\d+)*\s*[\])]\s*$/.test(part));
42
+ return ok ? [] : ["affected: '" + expr + "' is not a version range this rule can evaluate"];
43
+ }
44
+
45
+ // Dotted numeric comparison. Missing components are zero, so 3 and 3.0 are the same version, and a
46
+ // non-numeric tail ("3.1-rc1") sorts before the release it precedes.
47
+ export function compareVersions(a, b) {
48
+ const parts = (v) => String(v).split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
49
+ const pa = parts(a);
50
+ const pb = parts(b);
51
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
52
+ const x = pa[i] ?? 0;
53
+ const y = pb[i] ?? 0;
54
+ if (x === y) continue;
55
+ // A numeric component always outranks a textual one: 3.1 is later than 3.1-rc1.
56
+ if (typeof x === 'number' && typeof y === 'string') return 1;
57
+ if (typeof x === 'string' && typeof y === 'number') return -1;
58
+ return x < y ? -1 : 1;
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ // Does `version` fall in `expr`? The grammar is deliberately small, because a range the rule cannot
64
+ // evaluate is not a rule input: an exact version, a comparison, a closed or half-open interval, or
65
+ // several of those joined by ||.
66
+ export function versionMatches(version, expr) {
67
+ return String(expr).split('||').some((raw) => {
68
+ const part = raw.trim();
69
+ const interval = part.match(/^([[(])\s*([\w.-]+)\s*,\s*([\w.-]+)\s*([\])])$/);
70
+ if (interval) {
71
+ const [, lo, from, to, hi] = interval;
72
+ const lower = compareVersions(version, from);
73
+ const upper = compareVersions(version, to);
74
+ return (lo === '[' ? lower >= 0 : lower > 0) && (hi === ']' ? upper <= 0 : upper < 0);
75
+ }
76
+ const cmp = part.match(/^(<=|>=|<|>|=)?\s*([\w.-]+)$/);
77
+ if (!cmp) return false;
78
+ const [, op = '=', v] = cmp;
79
+ const c = compareVersions(version, v);
80
+ return op === '=' ? c === 0 : op === '<' ? c < 0 : op === '<=' ? c <= 0 : op === '>' ? c > 0 : c >= 0;
81
+ });
82
+ }
83
+
84
+ // Every advisory against this product that names this version, with any a customer feed added.
85
+ export function advisoriesFor(product, version, extra = []) {
86
+ return [...ADVISORIES.advisories, ...extra].filter((a) => a.product === product && versionMatches(version, a.affected));
87
+ }
88
+
89
+ // What this file admits it has not looked at, so a scan that finds nothing can say why.
90
+ export const COVERAGE = ADVISORIES.coverage;
91
+
92
+ // The feeds a scan was given: the caller's list, or COBOLWORK_ADVISORIES read when the scan runs.
93
+ export function advisoryFeedPaths(opts = {}) {
94
+ if (opts.advisoryFeeds) return opts.advisoryFeeds;
95
+ return (process.env.COBOLWORK_ADVISORIES || '').split(delimiter).filter(Boolean);
96
+ }
97
+
98
+ // A customer's own advisory extract - the IBM Z and LinuxONE Security Portal's, or any other it may
99
+ // not redistribute - loaded for one scan and never kept. It is refused from inside the scanned
100
+ // tree, where everyone who can read the repository could read it. A row meets the public rows'
101
+ // shape; the quote a public row carries cannot be checked against a source nobody may cache, so the
102
+ // extract's provenance stands in for it, and the summary names the extract so a scan that used one
103
+ // never reads like one that did not.
104
+ //
105
+ // { schemaVersion, extract, retrieved, coverage: { product: text }, advisories: [row] }, each row
106
+ // as in rules/advisories.json with source { doc } naming the bulletin in the extract.
107
+ export function loadAdvisoryFeed(path, { root = null } = {}) {
108
+ const feed = { file: basename(path), extract: null, retrieved: null, coverage: {}, advisories: [], refused: [], problem: null };
109
+ let real;
110
+ try { real = realpathSync(path); } catch (e) { feed.problem = `could not be read (${e.code || e.name})`; return feed; }
111
+ if (root) {
112
+ const top = realpathSync(root);
113
+ if (real === top || real.startsWith(top + sep)) { feed.problem = 'is inside the tree being scanned, where anyone who can read the repository can read it, so it was not loaded'; return feed; }
114
+ }
115
+ let doc;
116
+ try { doc = JSON.parse(readFileSync(real, 'utf8')); } catch (e) { feed.problem = `is not JSON (${e.message})`; return feed; }
117
+ if (!str(doc.extract)) feed.problem = 'does not say which extract it is';
118
+ else if (!isoDate(doc.retrieved)) feed.problem = 'does not say when it was retrieved';
119
+ else if (!Array.isArray(doc.advisories)) feed.problem = 'holds no advisories';
120
+ if (feed.problem) return feed;
121
+ feed.extract = doc.extract;
122
+ feed.retrieved = doc.retrieved;
123
+ feed.coverage = doc.coverage && typeof doc.coverage === 'object' ? doc.coverage : {};
124
+ for (const row of doc.advisories) {
125
+ if (!row || typeof row !== 'object') { feed.refused.push({ id: null, problems: ['row: not an object'] }); continue; }
126
+ const problems = advisoryProblems(row);
127
+ if (str(row.affected)) problems.push(...versionRangeProblems(row.affected));
128
+ if (!row.source || !str(row.source.doc)) problems.push('source.doc: missing');
129
+ if (problems.length) feed.refused.push({ id: str(row.id) ? row.id : null, problems });
130
+ else feed.advisories.push({ ...row, feed: { extract: doc.extract, retrieved: doc.retrieved } });
131
+ }
132
+ return feed;
133
+ }
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Findings a person has already judged, and until when.
3
+ //
4
+ // An accepted finding that comes back on every run trains its reader to skip the report. A
5
+ // suppression that never lapses is worse: the judgement was about the code as it was, and nothing
6
+ // asks again once the code or the threat has moved on. So every suppression here carries a reason,
7
+ // a person, a time and an expiry, and one without an expiry is refused rather than applied. The
8
+ // vocabulary is commitwork's annotation contract (monitor/annotate-lib.mjs), so a judgement can
9
+ // move between the two without being reworded.
10
+ //
11
+ // A suppressed finding is not deleted. It moves out of `findings` and every count into
12
+ // `suppressed`, carrying the judgement that moved it, so a report says what it is not showing.
13
+ //
14
+ // The baseline found inside the scanned tree is the tree's own claim about itself, which is worth
15
+ // exactly as much as the rest of the tree. It may accept a defect; it may not hide tampering,
16
+ // because the finding it would hide is evidence that the tree is not to be trusted. A baseline
17
+ // passed from outside the tree can.
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { tally } from './kernel/findings.mjs';
21
+ import { resolvesInside } from './kernel/source-tree.mjs';
22
+ import { printable } from './kernel/printable.mjs';
23
+
24
+ export const BASELINE_FILE = 'cobolwork.baseline.json';
25
+ export const SUPPRESSING = ['accept', 'false-positive', 'wont-fix', 'incorrect-scan-result'];
26
+ export const ACTIONS = [...SUPPRESSING, 'note', 'resolved'];
27
+
28
+ const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
29
+ // ISO 8601 only: other date forms parse differently across engines and readers.
30
+ const ISO = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
31
+ const isTime = (v) => typeof v === 'string' && ISO.test(v) && !Number.isNaN(Date.parse(v));
32
+ const time = (v) => Date.parse(v);
33
+
34
+ // What is wrong with one entry, or nothing.
35
+ export function validateEntry(e) {
36
+ const errs = [];
37
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return ['entry is not an object'];
38
+ if (!/^[0-9a-f]{32}$/.test(String(e.fingerprint || ''))) errs.push('fingerprint must be the 32 hex digits a report prints');
39
+ if (!nonEmpty(e.rule)) errs.push('missing rule');
40
+ if (!ACTIONS.includes(e.action)) errs.push(`action must be one of ${ACTIONS.join(', ')}`);
41
+ if (!nonEmpty(e.reason)) errs.push('missing reason');
42
+ if (!nonEmpty(e.who)) errs.push('missing who');
43
+ if (!isTime(e.at)) errs.push('missing at, or not an ISO 8601 date such as 2027-01-31');
44
+ const hasExpiry = e.expires !== undefined && e.expires !== null && e.expires !== '';
45
+ if (hasExpiry && !isTime(e.expires)) errs.push('expires is not an ISO 8601 date such as 2027-01-31');
46
+ else if (!hasExpiry && SUPPRESSING.includes(e.action)) {
47
+ errs.push('a suppression needs an expires date: one that never lapses is never looked at again');
48
+ }
49
+ if (e.action === 'incorrect-scan-result' && !(e.defect && nonEmpty(e.defect.detail))) {
50
+ errs.push('incorrect-scan-result must say what the scanner got wrong, in defect.detail');
51
+ }
52
+ return errs;
53
+ }
54
+
55
+ // { path, source, entries, problems }. An absent file is an empty baseline, not an error; an
56
+ // unreadable one is a problem the report carries, never a silent empty.
57
+ export function loadBaseline(root, { explicit = null, use = true, mayBeAbsent = false } = {}) {
58
+ if (!use) return null;
59
+ const path = explicit || join(root, BASELINE_FILE);
60
+ if (!existsSync(path)) {
61
+ if (!explicit) return null;
62
+ if (mayBeAbsent) return { path, source: 'explicit', entries: [], problems: [] };
63
+ return { path, source: 'explicit', entries: [], problems: [`${path} does not exist, so nothing was suppressed`] };
64
+ }
65
+ const out = { path, source: explicit ? 'explicit' : 'tree', entries: [], problems: [] };
66
+ // The tree's own baseline is the tree's, and a link out of it is not followed.
67
+ if (!explicit && !resolvesInside(root, path)) {
68
+ out.problems.push(`${BASELINE_FILE} is a link that leads outside the tree, so nothing was suppressed`);
69
+ return out;
70
+ }
71
+ let raw;
72
+ try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (err) {
73
+ out.problems.push(`${path} is not readable as JSON: ${printable(err.message, 120)}`);
74
+ return out;
75
+ }
76
+ const entries = Array.isArray(raw?.entries) ? raw.entries : null;
77
+ if (!entries) { out.problems.push(`${path} has no entries array`); return out; }
78
+ entries.forEach((e, i) => {
79
+ const errs = validateEntry(e);
80
+ if (errs.length) out.problems.push(`entry ${i + 1}${/^[0-9a-f]{32}$/.test(String(e?.fingerprint)) ? ` (${e.fingerprint})` : ''}: ${errs.join('; ')}`);
81
+ else out.entries.push(e);
82
+ });
83
+ return out;
84
+ }
85
+
86
+ // Moves what the baseline suppresses out of `findings`, recounts, and says what it did.
87
+ export function applyBaseline(report, baseline, { now = new Date().toISOString() } = {}) {
88
+ if (!baseline) return report;
89
+ // Compared as times: as strings, 2026-01-01T12:00-12:00 sorts before 2026-01-01T13:00Z.
90
+ const at = time(now);
91
+ const byPrint = new Map();
92
+ for (const e of baseline.entries) {
93
+ if (!byPrint.has(e.fingerprint)) byPrint.set(e.fingerprint, []);
94
+ byPrint.get(e.fingerprint).push(e);
95
+ }
96
+ const open = [];
97
+ const suppressed = [];
98
+ const expired = [];
99
+ const refused = [];
100
+ const matched = new Set();
101
+ for (const f of report.findings) {
102
+ const mine = (byPrint.get(f.fingerprint) || []).filter((e) => e.rule === f.rule);
103
+ for (const e of mine) matched.add(e);
104
+ const live = mine.find((e) => SUPPRESSING.includes(e.action) && time(e.at) <= at && time(e.expires) > at);
105
+ if (!live) {
106
+ const lapsed = mine.find((e) => SUPPRESSING.includes(e.action) && time(e.expires) <= at);
107
+ if (lapsed) expired.push({ fingerprint: f.fingerprint, rule: f.rule, path: f.path, line: f.line, expired: lapsed.expires, reason: lapsed.reason, who: lapsed.who });
108
+ open.push(f);
109
+ continue;
110
+ }
111
+ if (baseline.source === 'tree' && f.evidence === 'tampering') {
112
+ refused.push({ fingerprint: f.fingerprint, rule: f.rule, path: f.path, line: f.line });
113
+ open.push(f);
114
+ continue;
115
+ }
116
+ suppressed.push({ ...f, suppressed: { action: live.action, reason: live.reason, who: live.who, at: live.at, expires: live.expires, ...(live.defect ? { defect: live.defect } : {}) } });
117
+ }
118
+ const stale = baseline.entries.filter((e) => !matched.has(e)).length;
119
+ report.findings = open;
120
+ report.suppressed = suppressed;
121
+ const s = report.summary || (report.summary = {});
122
+ s.findings = open.length;
123
+ s.byRule = tally(open);
124
+ if (s.bySeverity) { s.bySeverity = {}; for (const f of open) s.bySeverity[f.sev] = (s.bySeverity[f.sev] || 0) + 1; }
125
+ if (s.byEvidence) { s.byEvidence = {}; for (const f of open) s.byEvidence[f.evidence] = (s.byEvidence[f.evidence] || 0) + 1; }
126
+ s.baseline = {
127
+ path: baseline.path,
128
+ source: baseline.source,
129
+ suppressed: suppressed.length,
130
+ // Back in the report because the judgement lapsed; each names the judgement that did.
131
+ expired: expired.length,
132
+ // Entries no finding matched: fixed, or changed enough to be a different finding.
133
+ stale,
134
+ ...(refused.length ? { refused: refused.length, refusedWhy: 'a baseline inside the scanned tree cannot hide tampering; pass one from outside the tree with --baseline' } : {}),
135
+ ...(baseline.problems.length ? { problems: baseline.problems } : {}),
136
+ };
137
+ if (expired.length) report.baselineExpired = expired;
138
+ return report;
139
+ }
140
+
141
+ // Entries accepting every finding in a report, for `cobolwork baseline`. Ones already in the
142
+ // baseline are kept as they were, so running it again never rewrites a judgement someone made.
143
+ export function baselineEntries(findings, existing = [], { action = 'accept', reason, who, expires, at = new Date().toISOString(), rules = null } = {}) {
144
+ const have = new Set(existing.map((e) => `${e.fingerprint}|${e.rule}`));
145
+ const added = [];
146
+ for (const f of findings) {
147
+ if (!f.fingerprint || (rules && !rules.includes(f.rule))) continue;
148
+ const key = `${f.fingerprint}|${f.rule}`;
149
+ if (have.has(key)) continue;
150
+ have.add(key);
151
+ added.push({ fingerprint: f.fingerprint, rule: f.rule, path: f.path, action, reason, who, at, expires });
152
+ }
153
+ return { entries: [...existing, ...added], added: added.length };
154
+ }