@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
package/lib/gate.mjs
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Whether a patch fixed one finding and moved nothing else: the deterministic half of remediation,
|
|
3
|
+
// specified in docs/spec/remediation-gate.md. A finding can leave a report without being fixed, so a
|
|
4
|
+
// disappearance passes only where the engine itself says why.
|
|
5
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
9
|
+
import { withRefs, diffTrees, DIFF_RULES, WORKING_TREE } from './diff.mjs';
|
|
10
|
+
import { pairingKeys, codeLines } from './kernel/identity.mjs';
|
|
11
|
+
import { printable } from './kernel/printable.mjs';
|
|
12
|
+
import { SOURCE_KINDS, SINK_KINDS } from './dataflow.mjs';
|
|
13
|
+
import { detectFormat } from './parser.mjs';
|
|
14
|
+
import { ALL_RULES } from './kernel/registry.mjs';
|
|
15
|
+
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
16
|
+
|
|
17
|
+
const SEV = { info: 0, low: 1, med: 2, high: 3, crit: 4 };
|
|
18
|
+
const MAX_REASONS = 20;
|
|
19
|
+
const MAX_EDIT = 2000;
|
|
20
|
+
const MAX_LINES = 200000;
|
|
21
|
+
const MAX_COMPILES = 50;
|
|
22
|
+
const COMPILE_BUDGET_MS = 10 * 60 * 1000;
|
|
23
|
+
const PASSING = new Set(['cleared-by-check', 'statement-removed', 'source-removed', 'no-longer-fires']);
|
|
24
|
+
const UNDECIDED = new Set(['lowered-by-check', 'gone-unexplained']);
|
|
25
|
+
|
|
26
|
+
const pathText = (p) => printable(String(p ?? ''), 120);
|
|
27
|
+
const where = (f) => `${pathText(f.path)}:${Number(f.line) || 0}`;
|
|
28
|
+
const ruleText = (rule) => (ALL_RULES[rule] || DIFF_RULES[rule] || {}).text || rule;
|
|
29
|
+
|
|
30
|
+
// The one source kind and sink kind a path rule's id spells, or null.
|
|
31
|
+
export function kindsOf(rule) {
|
|
32
|
+
for (const source of Object.keys(SOURCE_KINDS)) {
|
|
33
|
+
if (!rule.startsWith(`${source}-to-`)) continue;
|
|
34
|
+
const sink = rule.slice(source.length + 4);
|
|
35
|
+
if (Object.hasOwn(SINK_KINDS, sink)) return { source, sink };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Myers' diff over two lists of lines, after common ends are set aside. Returns the 1-based lines
|
|
41
|
+
// deleted from `a` and inserted in `b`, or null past `maxEdit` edits or MAX_LINES lines.
|
|
42
|
+
export function lineDiff(a, b, maxEdit = MAX_EDIT) {
|
|
43
|
+
if (a.length + b.length > MAX_LINES) return null;
|
|
44
|
+
let s = 0;
|
|
45
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
46
|
+
let ea = a.length, eb = b.length;
|
|
47
|
+
while (ea > s && eb > s && a[ea - 1] === b[eb - 1]) { ea--; eb--; }
|
|
48
|
+
const A = a.slice(s, ea), B = b.slice(s, eb);
|
|
49
|
+
const n = A.length, m = B.length;
|
|
50
|
+
const deleted = new Set(), inserted = new Set();
|
|
51
|
+
if (!n || !m) {
|
|
52
|
+
for (let i = 0; i < n; i++) deleted.add(s + i + 1);
|
|
53
|
+
for (let j = 0; j < m; j++) inserted.add(s + j + 1);
|
|
54
|
+
return n + m > maxEdit ? null : { deleted, inserted };
|
|
55
|
+
}
|
|
56
|
+
const off = n + m + 1;
|
|
57
|
+
const v = new Int32Array(2 * off + 1);
|
|
58
|
+
// v before each step d, kept only over the diagonals -d..d that step can read back.
|
|
59
|
+
const trace = [];
|
|
60
|
+
let found = -1;
|
|
61
|
+
for (let d = 0; d <= Math.min(maxEdit, n + m) && found < 0; d++) {
|
|
62
|
+
trace.push(v.slice(off - d, off + d + 1));
|
|
63
|
+
for (let k = -d; k <= d; k += 2) {
|
|
64
|
+
let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1;
|
|
65
|
+
let y = x - k;
|
|
66
|
+
while (x < n && y < m && A[x] === B[y]) { x++; y++; }
|
|
67
|
+
v[off + k] = x;
|
|
68
|
+
if (x >= n && y >= m) { found = d; break; }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (found < 0) return null;
|
|
72
|
+
let x = n, y = m;
|
|
73
|
+
for (let d = found; d > 0; d--) {
|
|
74
|
+
const w = trace[d];
|
|
75
|
+
const at = (k) => w[k + d];
|
|
76
|
+
const k = x - y;
|
|
77
|
+
const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1;
|
|
78
|
+
const px = at(prevK), py = px - prevK;
|
|
79
|
+
while (x > px && y > py) { x--; y--; }
|
|
80
|
+
if (x === px) inserted.add(s + py + 1);
|
|
81
|
+
else deleted.add(s + px + 1);
|
|
82
|
+
x = px; y = py;
|
|
83
|
+
}
|
|
84
|
+
return { deleted, inserted };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A program to run, as an absolute file outside the reviewed tree: a name with a directory in it is
|
|
88
|
+
// taken as a path, and a bare name is the first match on PATH, skipping relative entries and every
|
|
89
|
+
// entry inside the tree. A bare name is looked for in the working directory first on Windows.
|
|
90
|
+
export function findExecutable(command, { repo, env = process.env, platform = process.platform } = {}) {
|
|
91
|
+
const inside = (p) => {
|
|
92
|
+
let real;
|
|
93
|
+
try { real = realpathSync(p); } catch { return false; }
|
|
94
|
+
const top = (() => { try { return realpathSync(repo); } catch { return resolve(repo); } })();
|
|
95
|
+
return real === top || real.startsWith(top + sep);
|
|
96
|
+
};
|
|
97
|
+
const usable = (p) => { try { return statSync(p).isFile() && !inside(p); } catch { return false; } };
|
|
98
|
+
const name = String(command);
|
|
99
|
+
if (isAbsolute(name) || /[\\/]/.test(name)) {
|
|
100
|
+
const p = resolve(name);
|
|
101
|
+
return usable(p) ? { path: p } : { path: null, why: `${pathText(p)} is not a file outside the repository` };
|
|
102
|
+
}
|
|
103
|
+
const names = platform === 'win32' && !/\.[a-z]+$/i.test(name) ? [`${name}.exe`, `${name}.com`] : [name];
|
|
104
|
+
const pathVar = env.PATH ?? env.Path ?? '';
|
|
105
|
+
for (const dir of String(pathVar).split(delimiter)) {
|
|
106
|
+
if (!dir || !isAbsolute(dir) || inside(dir)) continue;
|
|
107
|
+
for (const n of names) if (usable(join(dir, n))) return { path: join(dir, n) };
|
|
108
|
+
}
|
|
109
|
+
return { path: null, why: `no ${printable(name, 60)} on PATH outside the repository` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function findCobc({ explicit = null, repo, env = process.env, platform = process.platform } = {}) {
|
|
113
|
+
if (!explicit) return findExecutable('cobc', { repo, env, platform });
|
|
114
|
+
const found = findExecutable(resolve(explicit), { repo, env, platform });
|
|
115
|
+
return found.path ? found : { path: null, why: `--cobc ${pathText(resolve(explicit))} is not a file outside the repository` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A compiler run as `cobc -fsyntax-only`, from outside the tree, for a bounded time.
|
|
119
|
+
export function cobcCompiler(cobc) {
|
|
120
|
+
return (file, { includeDirs = [], free = false } = {}) => {
|
|
121
|
+
const r = spawnSync(cobc, ['-fsyntax-only', ...(free ? ['-free'] : []), ...includeDirs.flatMap((d) => ['-I', d]), file],
|
|
122
|
+
{ cwd: tmpdir(), timeout: 60000, windowsHide: true, stdio: 'ignore' });
|
|
123
|
+
return r.status === 0;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Level 1 matches across both lists first; each looser level pairs only what the level above left.
|
|
128
|
+
export function pairLists(base, head, baseKeys, headKeys) {
|
|
129
|
+
const headOf = new Array(base.length).fill(-1);
|
|
130
|
+
const baseOf = new Array(head.length).fill(-1);
|
|
131
|
+
const level = new Array(base.length).fill(null);
|
|
132
|
+
const levels = [
|
|
133
|
+
['fingerprint', (i) => base[i].fingerprint, (j) => head[j].fingerprint],
|
|
134
|
+
['scope', (i) => baseKeys[i].scope, (j) => headKeys[j].scope],
|
|
135
|
+
['route', (i) => baseKeys[i].route, (j) => headKeys[j].route],
|
|
136
|
+
];
|
|
137
|
+
for (const [name, keyB, keyH] of levels) {
|
|
138
|
+
const open = new Map();
|
|
139
|
+
for (let i = 0; i < base.length; i++) {
|
|
140
|
+
if (headOf[i] >= 0) continue;
|
|
141
|
+
const k = keyB(i);
|
|
142
|
+
if (!k) continue;
|
|
143
|
+
if (!open.has(k)) open.set(k, []);
|
|
144
|
+
open.get(k).push(i);
|
|
145
|
+
}
|
|
146
|
+
for (let j = 0; j < head.length; j++) {
|
|
147
|
+
if (baseOf[j] >= 0) continue;
|
|
148
|
+
const k = keyH(j);
|
|
149
|
+
const q = k && open.get(k);
|
|
150
|
+
if (!q || !q.length) continue;
|
|
151
|
+
const i = q.shift();
|
|
152
|
+
headOf[i] = j; baseOf[j] = i; level[i] = name;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { headOf, baseOf, level };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Changed lines of one file between the trees, cached; null when the pair is too large to attribute.
|
|
159
|
+
function changes(baseRoot, headRoot, cache) {
|
|
160
|
+
return (path) => {
|
|
161
|
+
if (!path) return null;
|
|
162
|
+
if (cache.has(path)) return cache.get(path);
|
|
163
|
+
const raw = (root) => { try { return readFileSync(join(root, path)); } catch { return null; } };
|
|
164
|
+
const was = raw(baseRoot), now = raw(headRoot);
|
|
165
|
+
if (was && now && was.equals(now)) { const same = { deleted: new Set(), inserted: new Set() }; cache.set(path, same); return same; }
|
|
166
|
+
const a = codeLines(baseRoot, path) || [];
|
|
167
|
+
const b = codeLines(headRoot, path) || [];
|
|
168
|
+
const d = lineDiff(a, b);
|
|
169
|
+
cache.set(path, d);
|
|
170
|
+
return d;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function targetOutcome(ctx) {
|
|
175
|
+
const { t, ti, pairs, after, afterKeysChecked, baseKeys, changed, headProgs, headRoot, allow, listed } = ctx;
|
|
176
|
+
const j = pairs.headOf[ti];
|
|
177
|
+
if (j >= 0) {
|
|
178
|
+
const h = after.findings[j];
|
|
179
|
+
if (SEV[h.sev] >= SEV[t.sev]) return { outcome: 'still-reported', pairedBy: pairs.level[ti], head: h };
|
|
180
|
+
const g = h.guard;
|
|
181
|
+
const d = g ? changed(g.file) : null;
|
|
182
|
+
if (g && (!d || d.inserted.has(Number(g.line)))) return { outcome: 'lowered-by-check', pairedBy: pairs.level[ti], head: h, guard: g };
|
|
183
|
+
return { outcome: 'still-reported', pairedBy: pairs.level[ti], head: h };
|
|
184
|
+
}
|
|
185
|
+
const inHead = (p) => existsSync(join(headRoot, p)) && (!allow || allow.has(resolve(headRoot, p)));
|
|
186
|
+
const programGone = t.program && ctx.baseProgs.has(`${t.path}#${t.program}`) && !headProgs.has(`${t.path}#${t.program}`);
|
|
187
|
+
if (!inHead(t.path) || programGone) return { outcome: 'program-removed' };
|
|
188
|
+
const checked = after.checked || [];
|
|
189
|
+
for (let k = 0; k < checked.length; k++) {
|
|
190
|
+
const c = checked[k];
|
|
191
|
+
if (c.rule !== t.rule || !c.guard || !c.guard.stops) continue;
|
|
192
|
+
if (c.fingerprint === t.fingerprint || afterKeysChecked[k].scope === baseKeys[ti].scope || (baseKeys[ti].route && afterKeysChecked[k].route === baseKeys[ti].route)) {
|
|
193
|
+
return { outcome: 'cleared-by-check', guard: c.guard };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (t.evidence === 'path') {
|
|
197
|
+
const kinds = kindsOf(t.rule);
|
|
198
|
+
if (!kinds) return { outcome: 'gone-unexplained' };
|
|
199
|
+
const writtenBack = (list, kind, program) => list.some((x) => x.kind === kind && x.program === program && (() => {
|
|
200
|
+
const d = changed(x.file);
|
|
201
|
+
return !d || d.inserted.has(Number(x.line));
|
|
202
|
+
})());
|
|
203
|
+
const d = changed(t.path);
|
|
204
|
+
if (d && d.deleted.has(Number(t.line)) && !writtenBack(listed.sinks, kinds.sink, t.program)) return { outcome: 'statement-removed' };
|
|
205
|
+
const src = t.related && t.related[0];
|
|
206
|
+
const srcProgram = (t.trace && t.trace[0] && t.trace[0].program) || t.program;
|
|
207
|
+
const ds = src ? changed(src.path) : null;
|
|
208
|
+
if (ds && ds.deleted.has(Number(src.line)) && !writtenBack(listed.sources, kinds.source, srcProgram)) return { outcome: 'source-removed' };
|
|
209
|
+
return { outcome: 'gone-unexplained' };
|
|
210
|
+
}
|
|
211
|
+
const d = changed(t.path);
|
|
212
|
+
if (d && (d.deleted.size || d.inserted.size)) return { outcome: 'no-longer-fires' };
|
|
213
|
+
return { outcome: 'gone-unexplained' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function targetReason(t, r) {
|
|
217
|
+
const at = `${t.rule} at ${where(t)}`;
|
|
218
|
+
switch (r.outcome) {
|
|
219
|
+
case 'still-reported':
|
|
220
|
+
return `the target, ${at}, is still reported at ${r.head.sev}${r.pairedBy === 'fingerprint' ? '' : ' after its line was edited or moved'}: ${ruleText(t.rule)}`;
|
|
221
|
+
case 'lowered-by-check':
|
|
222
|
+
return `the target is still reported, lowered from ${t.sev} to ${r.head.sev} by a check this patch added at ${where({ path: r.guard.file, line: r.guard.line })}; a check that lowers rather than stops may not turn away everything it should, so a person decides`;
|
|
223
|
+
case 'program-removed':
|
|
224
|
+
return `the patch removes ${pathText(t.path)}${t.program ? ` or its program ${printable(t.program, 40)}` : ''}, which held the target; removing the program is not a fix the gate accepts`;
|
|
225
|
+
case 'gone-unexplained':
|
|
226
|
+
return `the target, ${at}, is gone, but the patch neither removed its statement or its source nor added a check that stops its route; the route may be cut between its ends, or sent through something the engine does not follow, and a person decides`;
|
|
227
|
+
default:
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The compile check: every program the patch changed, or whose resolved COPYs it changed, must
|
|
233
|
+
// still compile if it compiled before.
|
|
234
|
+
function compileCheck({ compiler, baseRoot, headRoot, baseProgs, headProgs, changedFiles }) {
|
|
235
|
+
if (!compiler.run) return { ok: null, compiled: `not compiled: ${compiler.why}`, reasons: [] };
|
|
236
|
+
const files = new Set();
|
|
237
|
+
for (const p of headProgs.values()) {
|
|
238
|
+
if (changedFiles.has(p.file) || [...p.copied].some((c) => changedFiles.has(c))) files.add(p.file);
|
|
239
|
+
}
|
|
240
|
+
if (!files.size) return { ok: true, compiled: 'nothing to compile: the patch changed no program or anything one copies', reasons: [] };
|
|
241
|
+
if (files.size > MAX_COMPILES) return { ok: null, compiled: `not compiled: ${files.size} programs, past the ${MAX_COMPILES} the gate compiles`, reasons: [] };
|
|
242
|
+
const optsFor = (root, progs, file) => {
|
|
243
|
+
const dirs = new Set([dirname(join(root, file))]);
|
|
244
|
+
for (const p of progs.values()) if (p.file === file) for (const c of p.copied) dirs.add(dirname(join(root, c)));
|
|
245
|
+
let free = false;
|
|
246
|
+
try { free = detectFormat(readFileSync(join(root, file), 'latin1')) === 'free'; } catch { /* unreadable: compiled as fixed */ }
|
|
247
|
+
return { includeDirs: [...dirs].sort(), free };
|
|
248
|
+
};
|
|
249
|
+
const regressed = [], neverCompiled = [];
|
|
250
|
+
const deadline = Date.now() + (compiler.budgetMs ?? COMPILE_BUDGET_MS);
|
|
251
|
+
for (const file of [...files].sort()) {
|
|
252
|
+
if (Date.now() > deadline) return { ok: null, compiled: `not compiled: the compiler ran past the gate's ${Math.round((compiler.budgetMs ?? COMPILE_BUDGET_MS) / 60000)}-minute budget`, reasons: [] };
|
|
253
|
+
const now = compiler.run(join(headRoot, file), optsFor(headRoot, headProgs, file));
|
|
254
|
+
if (now) continue;
|
|
255
|
+
const had = existsSync(join(baseRoot, file)) && compiler.run(join(baseRoot, file), optsFor(baseRoot, baseProgs, file));
|
|
256
|
+
(had ? regressed : neverCompiled).push(file);
|
|
257
|
+
}
|
|
258
|
+
if (regressed.length) return { ok: false, compiled: `${regressed.length} program(s) compiled before the patch and do not after`, reasons: regressed.map((f) => `${pathText(f)} compiled before the patch and does not after`) };
|
|
259
|
+
if (neverCompiled.length) return { ok: null, compiled: `${neverCompiled.length} program(s) do not compile before or after the patch, or are new and do not compile, which the gate cannot tell from a missing copy library`, reasons: [] };
|
|
260
|
+
return { ok: true, compiled: `${files.size} program(s) compile`, reasons: [] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Judges two trees already on disk. `compiler` is { run(file, opts) -> boolean } or { why }.
|
|
264
|
+
export function gateTrees(baseRoot, headRoot, fingerprint, opts = {}) {
|
|
265
|
+
const d = diffTrees(baseRoot, headRoot, { ...opts, keepScans: true, listSinks: true, listSources: true });
|
|
266
|
+
const { before, after, base: baseProgs, head: headProgs } = d.scans;
|
|
267
|
+
const ti = before.findings.findIndex((f) => f.fingerprint === fingerprint);
|
|
268
|
+
if (ti < 0) {
|
|
269
|
+
const short = before.summary.coverageIncomplete ? '; the base scan was incomplete, so it may not have reached that finding' : '';
|
|
270
|
+
throw Object.assign(new Error(`no finding with fingerprint ${printable(fingerprint, 80)} in the base${short}`), { code: 'EGATETARGET' });
|
|
271
|
+
}
|
|
272
|
+
const t = before.findings[ti];
|
|
273
|
+
const baseKeys = pairingKeys(before.findings, { root: baseRoot });
|
|
274
|
+
const headKeys = pairingKeys(after.findings, { root: headRoot });
|
|
275
|
+
const pairs = pairLists(before.findings, after.findings, baseKeys, headKeys);
|
|
276
|
+
// Every base finding sharing the target's fingerprint is the target; one still paired keeps it reported.
|
|
277
|
+
const sameTarget = before.findings.map((f, i) => (f.fingerprint === fingerprint ? i : -1)).filter((i) => i >= 0);
|
|
278
|
+
const stillPaired = sameTarget.find((i) => pairs.headOf[i] >= 0);
|
|
279
|
+
const cache = new Map();
|
|
280
|
+
const changed = changes(baseRoot, headRoot, cache);
|
|
281
|
+
const listed = (after.listed) || { sinks: [], sources: [] };
|
|
282
|
+
const r = targetOutcome({
|
|
283
|
+
t, ti: stillPaired ?? ti, pairs, after, baseKeys, changed, baseProgs, headProgs, headRoot, allow: opts.allow || null, listed,
|
|
284
|
+
afterKeysChecked: pairingKeys(after.checked || [], { root: headRoot }),
|
|
285
|
+
});
|
|
286
|
+
const reasons = [];
|
|
287
|
+
const checks = {};
|
|
288
|
+
checks.target = PASSING.has(r.outcome) ? true : UNDECIDED.has(r.outcome) ? null : false;
|
|
289
|
+
const tr = targetReason(t, r);
|
|
290
|
+
if (tr) reasons.push(tr);
|
|
291
|
+
|
|
292
|
+
const summary = d.summary;
|
|
293
|
+
const fullGate = !opts.targetOnly;
|
|
294
|
+
if (fullGate) {
|
|
295
|
+
const targetHead = stillPaired !== undefined ? pairs.headOf[stillPaired] : -1;
|
|
296
|
+
const added = after.findings.filter((h, j) => {
|
|
297
|
+
if (j === targetHead) return false;
|
|
298
|
+
const i = pairs.baseOf[j];
|
|
299
|
+
return i < 0 || SEV[h.sev] > SEV[before.findings[i].sev];
|
|
300
|
+
});
|
|
301
|
+
checks.added = added.length === 0;
|
|
302
|
+
for (const h of added) reasons.push(`the patch adds ${h.rule} (${h.sev}) at ${where(h)}: ${ruleText(h.rule)}`);
|
|
303
|
+
|
|
304
|
+
const layout = d.findings.filter((f) => f.rule === 'diff-interface-layout-changed' || f.rule === 'diff-layout-changed-unedited-program');
|
|
305
|
+
checks.layout = layout.length === 0;
|
|
306
|
+
for (const f of layout) reasons.push(`the patch moves fields in ${printable(f.program, 40)} (${pathText(f.path)}): ${ruleText(f.rule)}`);
|
|
307
|
+
|
|
308
|
+
const calls = d.findings.filter((f) => f.rule === 'diff-new-call-target' || f.rule === 'diff-new-dynamic-call');
|
|
309
|
+
checks.calls = calls.length === 0;
|
|
310
|
+
for (const f of calls) reasons.push(`the patch changes what ${printable(f.program, 40)} (${pathText(f.path)}) calls: ${ruleText(f.rule)}`);
|
|
311
|
+
|
|
312
|
+
const config = summary.configurationChanged || [];
|
|
313
|
+
checks.configuration = config.length === 0;
|
|
314
|
+
for (const c of config) reasons.push(`the patch changes ${c.file}, which decides which rules run and which findings are accepted; a fix may not`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const setKey = (s) => `${s.set}|${s.kind}`;
|
|
318
|
+
const had = new Set((before.summary.setsIncomplete || []).map(setKey));
|
|
319
|
+
const newlyShort = (after.summary.setsIncomplete || []).filter((s) => !had.has(setKey(s)));
|
|
320
|
+
const incomplete = summary.coverageIncomplete || newlyShort.length > 0;
|
|
321
|
+
checks.coverage = incomplete ? null : true;
|
|
322
|
+
if (incomplete) {
|
|
323
|
+
const sets = [...new Set([...(before.summary.setsIncomplete || []).filter((s) => s.kind === 'coverage'), ...(after.summary.setsIncomplete || []).filter((s) => s.kind === 'coverage'), ...newlyShort].map((s) => `${s.set} (${s.kind})`))].sort();
|
|
324
|
+
reasons.push(`coverage is incomplete${sets.length ? ` in ${sets.join(', ')}` : ''}, so what the patch removed cannot be told from what was not read`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Whether a file changed is a byte comparison; only the target's own files need the line diff.
|
|
328
|
+
const changedFiles = new Set();
|
|
329
|
+
const bytes = (root, p) => { try { return readFileSync(join(root, p)); } catch { return null; } };
|
|
330
|
+
const programs = [...baseProgs.values(), ...headProgs.values()];
|
|
331
|
+
for (const p of new Set([...programs.map((x) => x.file), ...programs.flatMap((x) => [...x.copied])])) {
|
|
332
|
+
const was = bytes(baseRoot, p), now = bytes(headRoot, p);
|
|
333
|
+
if (!was || !now || !was.equals(now)) changedFiles.add(p);
|
|
334
|
+
}
|
|
335
|
+
let compiled = null;
|
|
336
|
+
if (fullGate) {
|
|
337
|
+
const cc = compileCheck({ compiler: opts.compiler || { why: 'no compiler given' }, baseRoot, headRoot, baseProgs, headProgs, changedFiles });
|
|
338
|
+
checks.compile = cc.ok;
|
|
339
|
+
compiled = cc.compiled;
|
|
340
|
+
reasons.push(...cc.reasons);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const values = Object.entries(checks);
|
|
344
|
+
const verdict = values.some(([, v]) => v === false) ? 'fail'
|
|
345
|
+
: values.some(([k, v]) => v === null && k !== 'compile') ? 'undecided' : 'pass';
|
|
346
|
+
return {
|
|
347
|
+
tool: 'cobolwork-gate',
|
|
348
|
+
schemaVersion: SCHEMA_VERSION,
|
|
349
|
+
verdict,
|
|
350
|
+
target: { fingerprint: t.fingerprint, rule: t.rule, sev: t.sev, evidence: t.evidence, path: pathText(t.path), line: Number(t.line) || 0, ...(t.program ? { program: printable(t.program, 40) } : {}) },
|
|
351
|
+
outcome: r.outcome,
|
|
352
|
+
...(r.pairedBy ? { pairedBy: r.pairedBy } : {}),
|
|
353
|
+
checks,
|
|
354
|
+
...(compiled !== null ? { compiled } : {}),
|
|
355
|
+
reasons: reasons.slice(0, MAX_REASONS).map((x) => printable(x, 400)),
|
|
356
|
+
...(reasons.length > MAX_REASONS ? { reasonsNotShown: reasons.length - MAX_REASONS } : {}),
|
|
357
|
+
repositoryText: ['target.path', 'target.program', 'reasons'],
|
|
358
|
+
summary: {
|
|
359
|
+
mode: fullGate ? 'gate' : 'target-only',
|
|
360
|
+
flowModel: FLOW_MODEL,
|
|
361
|
+
toolVersion: TOOL_VERSION,
|
|
362
|
+
introduced: summary.introduced,
|
|
363
|
+
resolved: summary.resolved,
|
|
364
|
+
changedFiles: changedFiles.size,
|
|
365
|
+
coverageIncomplete: !!incomplete,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Materialises the revisions as diff does, and judges the patch between them.
|
|
371
|
+
export function gateRefs(repo, baseRef, headRef, fingerprint, opts = {}) {
|
|
372
|
+
const found = opts.compiler ? null : findCobc({ explicit: opts.cobc || null, repo });
|
|
373
|
+
const compiler = opts.compiler || (found.path ? { run: cobcCompiler(found.path) } : { why: found.why });
|
|
374
|
+
return withRefs(repo, baseRef, headRef || null, {}, (baseDir, headDir, scoped) => {
|
|
375
|
+
const res = gateTrees(baseDir, headDir, fingerprint, { ...scoped, compiler, targetOnly: opts.targetOnly === true });
|
|
376
|
+
res.summary.base = baseRef;
|
|
377
|
+
res.summary.head = headRef || WORKING_TREE;
|
|
378
|
+
return res;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --exit-code: the verdict as the process's status.
|
|
383
|
+
export const VERDICT_EXIT = { pass: 0, fail: 1, undecided: 3 };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
export { parseSource, parseFile, buildFileIndex, detectFormat, normalize, tokenize } from './parser.mjs';
|
|
3
|
+
export { analyze } from './dataflow.mjs';
|
|
4
|
+
export { scan, RULES } from './sets/flow.mjs';
|
|
5
|
+
export { inventory } from './inventory.mjs';
|
|
6
|
+
export { toSarif } from './sarif.mjs';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { parseSource, buildFileIndex, detectFormat } from './parser.mjs';
|
|
4
|
+
import { inScope, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
|
|
5
|
+
import { SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
6
|
+
import { treeFor } from './kernel/source-tree.mjs';
|
|
7
|
+
|
|
8
|
+
const SYSTEM_COPY = /^(?:(?:DFH|DSN|CEE|IGZ|EZA|BPX|CSQ|CMQ|DLI)[A-Z0-9$#@]{0,5}|SQLCA|SQLDA|ATTRIB)$/i;
|
|
9
|
+
|
|
10
|
+
// What the repository contains and, as importantly, what could not be read: a scan over a tree
|
|
11
|
+
// whose copybooks are missing has not examined what it appears to have examined.
|
|
12
|
+
export function inventory(root, opts = {}) {
|
|
13
|
+
const tree = treeFor(root, opts);
|
|
14
|
+
const idx = tree.index;
|
|
15
|
+
const files = tree.list().filter(inScope(opts));
|
|
16
|
+
const out = {
|
|
17
|
+
tool: 'cobolwork-inventory',
|
|
18
|
+
schemaVersion: SCHEMA_VERSION,
|
|
19
|
+
summary: {
|
|
20
|
+
toolVersion: TOOL_VERSION,
|
|
21
|
+
filesScanned: 0, programs: 0, programFiles: 0, copybookFiles: 0, jclFiles: 0,
|
|
22
|
+
copiesResolved: 0, copiesMissing: 0, copiesSystem: 0, copiesRefused: 0, copiesRecursive: 0, filesUnreadable: 0, filesEbcdic: 0,
|
|
23
|
+
notPrograms: 0, dirsUnreadable: idx.unreadableDirs.length, symlinks: idx.symlinks,
|
|
24
|
+
withEmbeddedSql: 0, withCics: 0, withDli: 0, formats: {}, nosrc: false,
|
|
25
|
+
},
|
|
26
|
+
missingCopybooks: {},
|
|
27
|
+
refusedCopies: [],
|
|
28
|
+
unreadable: idx.unreadableDirs.map(d => `${relPath(root, d) || '.'}/: directory not readable`),
|
|
29
|
+
ebcdic: [],
|
|
30
|
+
recursiveCopybooks: {},
|
|
31
|
+
};
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
if (isCopybook(f)) out.summary.copybookFiles++;
|
|
34
|
+
if (isJcl(f)) out.summary.jclFiles++;
|
|
35
|
+
if (!isProgram(f)) continue;
|
|
36
|
+
out.summary.programFiles++;
|
|
37
|
+
let src;
|
|
38
|
+
try {
|
|
39
|
+
const s = readSource(f);
|
|
40
|
+
src = s.text;
|
|
41
|
+
if (s.encoding === 'ebcdic') { out.summary.filesEbcdic++; out.ebcdic.push(relPath(root, f)); }
|
|
42
|
+
} catch (e) { out.summary.filesUnreadable++; out.unreadable.push(`${relPath(root, f)}: ${e.code || e.name}`); continue; }
|
|
43
|
+
// A file with a program extension and no program in it is usually a copybook named .cbl; it is
|
|
44
|
+
// counted so the difference between program files and programs read is accounted for.
|
|
45
|
+
if (!/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) { out.summary.notPrograms++; continue; }
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
out.summary.filesUnreadable++;
|
|
51
|
+
out.unreadable.push(`${relPath(root, f)}: ${e.code || e.name}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out.summary.filesScanned++;
|
|
55
|
+
out.summary.programs += res.programs.length;
|
|
56
|
+
const fmt = detectFormat(src);
|
|
57
|
+
out.summary.formats[fmt] = (out.summary.formats[fmt] || 0) + 1;
|
|
58
|
+
if (/EXEC\s+SQL/i.test(src)) out.summary.withEmbeddedSql++;
|
|
59
|
+
if (/EXEC\s+CICS/i.test(src)) out.summary.withCics++;
|
|
60
|
+
if (/EXEC\s+DLI/i.test(src)) out.summary.withDli++;
|
|
61
|
+
for (const c of res.copies) {
|
|
62
|
+
if (c.status === 'resolved') out.summary.copiesResolved++;
|
|
63
|
+
else if (String(c.status).startsWith('refused')) { out.summary.copiesRefused++; out.refusedCopies.push({ name: c.name, from: relPath(root, c.file), line: c.line, why: c.status }); }
|
|
64
|
+
// Before the system names, which a copybook in the tree is free to take.
|
|
65
|
+
else if (c.status === 'expansion-limit') out.summary.copiesOverLimit = (out.summary.copiesOverLimit || 0) + 1;
|
|
66
|
+
else if (c.status === 'system' || SYSTEM_COPY.test(c.name)) out.summary.copiesSystem++;
|
|
67
|
+
// The copybook exists and includes itself: a loop to break, not a file to go and find.
|
|
68
|
+
else if (c.status === 'recursive') { out.summary.copiesRecursive++; const k = c.name.toUpperCase(); out.recursiveCopybooks[k] = (out.recursiveCopybooks[k] || 0) + 1; }
|
|
69
|
+
else {
|
|
70
|
+
out.summary.copiesMissing++;
|
|
71
|
+
const k = c.name.toUpperCase();
|
|
72
|
+
out.missingCopybooks[k] = (out.missingCopybooks[k] || 0) + 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
out.summary.nosrc = out.summary.filesScanned === 0;
|
|
77
|
+
out.summary.coverageIncomplete = out.summary.copiesMissing > 0 || out.summary.copiesRecursive > 0 || (out.summary.copiesOverLimit || 0) > 0 || out.summary.filesUnreadable > 0 || out.summary.copiesRefused > 0 || out.summary.dirsUnreadable > 0 || out.summary.symlinks.outside > 0;
|
|
78
|
+
return out;
|
|
79
|
+
}
|