@portll/cobolwork 0.0.1 → 0.2.76
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/LICENSE +661 -0
- package/LICENSING.md +93 -0
- package/NOTICE +9 -0
- package/README.md +325 -3
- package/THIRD-PARTY-NOTICES.md +118 -0
- package/bin/cobolwork.mjs +354 -0
- package/lib/advisories.mjs +133 -0
- package/lib/baseline.mjs +154 -0
- package/lib/bms.mjs +453 -0
- package/lib/build.mjs +402 -0
- package/lib/capabilities.mjs +79 -0
- package/lib/cics-commands.mjs +281 -0
- package/lib/compliance.mjs +81 -0
- package/lib/consequence.mjs +139 -0
- package/lib/control.mjs +1515 -0
- package/lib/csd.mjs +77 -0
- package/lib/dataflow.mjs +1506 -0
- package/lib/diff.mjs +344 -0
- package/lib/explain.mjs +145 -0
- package/lib/gate.mjs +383 -0
- package/lib/index.mjs +6 -0
- package/lib/inventory.mjs +79 -0
- package/lib/jcl.mjs +478 -0
- package/lib/kernel/findings.mjs +94 -0
- package/lib/kernel/identity.mjs +216 -0
- package/lib/kernel/memory.mjs +217 -0
- package/lib/kernel/printable.mjs +6 -0
- package/lib/kernel/registry.mjs +79 -0
- package/lib/kernel/ruleset.mjs +72 -0
- package/lib/kernel/source-tree.mjs +159 -0
- package/lib/kev.mjs +27 -0
- package/lib/options.mjs +512 -0
- package/lib/packs.mjs +148 -0
- package/lib/parser.mjs +2055 -0
- package/lib/policy.mjs +163 -0
- package/lib/precompile-cics.mjs +169 -0
- package/lib/precompile.mjs +544 -0
- package/lib/reach.mjs +122 -0
- package/lib/revision.json +1 -0
- package/lib/revision.mjs +89 -0
- package/lib/sarif.mjs +222 -0
- package/lib/scan.mjs +272 -0
- package/lib/sets/build.mjs +234 -0
- package/lib/sets/cics.mjs +306 -0
- package/lib/sets/compile.mjs +187 -0
- package/lib/sets/copybook.mjs +174 -0
- package/lib/sets/flow.mjs +487 -0
- package/lib/sets/hidden.mjs +216 -0
- package/lib/sets/jcl.mjs +440 -0
- package/lib/sets/log.mjs +406 -0
- package/lib/sets/opaque.mjs +102 -0
- package/lib/sets/priv.mjs +322 -0
- package/lib/sets/recon.mjs +267 -0
- package/lib/sets/vendor.mjs +117 -0
- package/lib/sets/web.mjs +327 -0
- package/lib/site.mjs +164 -0
- package/lib/sources.mjs +156 -0
- package/lib/tui/app.mjs +325 -0
- package/lib/tui/keys.mjs +39 -0
- package/lib/tui/model.mjs +96 -0
- package/lib/tui/run.mjs +38 -0
- package/lib/tui/screen.mjs +59 -0
- package/lib/tui/terminal.mjs +46 -0
- package/lib/utilities.mjs +296 -0
- package/lib/version.mjs +15 -0
- package/lib/words.mjs +318 -0
- package/package.json +45 -6
- package/rules/advisories.json +264 -0
- package/rules/compliance-dora.json +2151 -0
- package/rules/compliance-ffiec.json +2134 -0
- package/rules/compliance-nist80053.json +2134 -0
- package/rules/gitleaks-mainframe.toml +57 -0
- package/rules/kev-ids.json +1729 -0
- package/rules/packs/broadcom.json +124 -0
- package/rules/packs/connectdirect.json +116 -0
- package/rules/packs/controlm.json +114 -0
- package/rules/system-layouts.json +28 -0
- package/schema/cobolwork-coverage.schema.json +65 -0
- 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
|
+
}
|
package/lib/baseline.mjs
ADDED
|
@@ -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
|
+
}
|