@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/diff.mjs
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { parseSource } from './parser.mjs';
|
|
7
|
+
import { inScope, isProgram, readSource, relPath } from './sources.mjs';
|
|
8
|
+
import { scanAll } from './scan.mjs';
|
|
9
|
+
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
10
|
+
// A diff finding is located by program rather than by line, so it orders on its own key. The text
|
|
11
|
+
// comparison underneath it is the shared one.
|
|
12
|
+
import { byText, evidenceMap } from './kernel/findings.mjs';
|
|
13
|
+
import { directoryTree } from './kernel/source-tree.mjs';
|
|
14
|
+
import { printable } from './kernel/printable.mjs';
|
|
15
|
+
import { loadSite, SITE_FILE } from './site.mjs';
|
|
16
|
+
import { loadBaseline, BASELINE_FILE, SUPPRESSING } from './baseline.mjs';
|
|
17
|
+
|
|
18
|
+
// What a change REACHES, not what it edits. A one-line copybook edit rewrites the record layout of
|
|
19
|
+
// every program that COPYs it while leaving each of those programs' own source untouched, so a
|
|
20
|
+
// reviewer reading the diff sees one file and ships forty. This compares the two trees the way the
|
|
21
|
+
// compiler would see them: per program, every field's size and offset, every call and transfer
|
|
22
|
+
// target, and every security finding.
|
|
23
|
+
export const DIFF_RULES = {
|
|
24
|
+
'diff-interface-layout-changed': { sev: 'high', evidence: 'change', cwe: 'CWE-1284', text: 'A record this program passes to or receives from another program changed size or position' },
|
|
25
|
+
'diff-layout-changed-unedited-program': { sev: 'med', evidence: 'change', cwe: 'CWE-1284', text: 'Fields in this program changed size or position although its own source did not change' },
|
|
26
|
+
'diff-layout-changed': { sev: 'low', evidence: 'change', cwe: 'CWE-1284', text: 'Fields in this program changed size or position' },
|
|
27
|
+
'diff-new-dynamic-call': { sev: 'high', evidence: 'change', cwe: 'CWE-470', text: 'The change adds a call or transfer whose target is a variable' },
|
|
28
|
+
'diff-new-call-target': { sev: 'med', evidence: 'change', cwe: 'CWE-470', text: 'The change makes this program call or transfer to a program it did not before' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MAX_LISTED = 8;
|
|
32
|
+
|
|
33
|
+
// A reviewed repository's .git/config can name commands; with fsmonitor off and plumbing only, git runs none.
|
|
34
|
+
const GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
|
|
35
|
+
const git = (repo, args, opts = {}) => spawnSync('git', ['-c', 'core.fsmonitor=false', '-C', repo, ...args], { env: GIT_ENV, maxBuffer: 256 * 1024 * 1024, ...opts });
|
|
36
|
+
const refused = (what, ref, why) => Object.assign(new Error(`${what} ${ref} failed: ${why}`), { code: 'EDIFFREF' });
|
|
37
|
+
|
|
38
|
+
function treeOf(repo, ref) {
|
|
39
|
+
if (!ref || String(ref).startsWith('-')) throw refused('git rev-parse', ref, 'a revision cannot be empty or start with "-"');
|
|
40
|
+
const r = git(repo, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{tree}`], { encoding: 'utf8' });
|
|
41
|
+
const oid = String(r.stdout || '').trim();
|
|
42
|
+
if (r.status !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(oid)) throw refused('git rev-parse', ref, String(r.stderr || '').trim() || 'not a revision');
|
|
43
|
+
return oid;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A tree path as a file under `dir`, or null if a part is empty, climbs, names a drive or stream, or is .git.
|
|
47
|
+
function placeIn(dir, path) {
|
|
48
|
+
const parts = path.split('/');
|
|
49
|
+
if (parts.some((p) => !p || p === '.' || p === '..' || /[\\:\0]/.test(p) || p.toLowerCase() === '.git')) return null;
|
|
50
|
+
const to = resolve(dir, ...parts);
|
|
51
|
+
return to.startsWith(dir + sep) ? to : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The revision as committed: blobs as stored, with no filter or line-ending conversion; links and submodules left out.
|
|
55
|
+
function materialise(repo, ref) {
|
|
56
|
+
const tree = treeOf(repo, ref);
|
|
57
|
+
const listed = git(repo, ['ls-tree', '-r', '-z', '--full-tree', tree]);
|
|
58
|
+
if (listed.status !== 0) throw refused('git ls-tree', ref, String(listed.stderr || '').trim() || `exit ${listed.status}`);
|
|
59
|
+
const blobs = [];
|
|
60
|
+
for (const entry of listed.stdout.toString('utf8').split('\0')) {
|
|
61
|
+
const m = /^(\d{6}) blob ([0-9a-f]+)\t(.+)$/s.exec(entry);
|
|
62
|
+
if (m && m[1] !== '120000') blobs.push({ oid: m[2], path: m[3] });
|
|
63
|
+
}
|
|
64
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'cobolwork-diff-')));
|
|
65
|
+
try {
|
|
66
|
+
for (let i = 0; i < blobs.length; i += 500) {
|
|
67
|
+
const batch = blobs.slice(i, i + 500);
|
|
68
|
+
const r = git(repo, ['cat-file', '--batch'], { input: batch.map((b) => b.oid).join('\n') + '\n' });
|
|
69
|
+
if (r.status !== 0) throw refused('git cat-file', ref, String(r.stderr || '').trim() || `exit ${r.status}`);
|
|
70
|
+
let at = 0;
|
|
71
|
+
for (const b of batch) {
|
|
72
|
+
const nl = r.stdout.indexOf(0x0a, at);
|
|
73
|
+
const head = r.stdout.toString('utf8', at, nl).split(' ');
|
|
74
|
+
if (head[1] !== 'blob') throw refused('git cat-file', ref, `${b.oid} is ${head[1] || 'missing'}`);
|
|
75
|
+
const size = Number(head[2]);
|
|
76
|
+
const to = placeIn(dir, b.path);
|
|
77
|
+
if (to) {
|
|
78
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
79
|
+
writeFileSync(to, r.stdout.subarray(nl + 1, nl + 1 + size));
|
|
80
|
+
}
|
|
81
|
+
at = nl + 1 + size + 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
return dir;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Whether two revisions of one file say the same thing. Compared as raw bytes instead, a program
|
|
92
|
+
// nobody edited reads as edited whenever the two sides were written under different line-ending
|
|
93
|
+
// rules: on Windows git checks the base out through core.autocrlf as CRLF while the working tree
|
|
94
|
+
// under review is already CRLF, or the reverse once a revision is materialised. Pinning the
|
|
95
|
+
// checkout would only move the mismatch to the other side, so the comparison forgives the one
|
|
96
|
+
// difference a checkout can introduce, and nothing else: whitespace a person actually typed
|
|
97
|
+
// still counts as an edit, on every platform.
|
|
98
|
+
const norm = (t) => t.split('\r\n').join('\n');
|
|
99
|
+
const sameSource = (a, b) => norm(a) === norm(b);
|
|
100
|
+
|
|
101
|
+
const qualified = (it) => { const names = []; for (let x = it; x; x = x.parent) names.unshift(x.name); return names.join('.'); };
|
|
102
|
+
|
|
103
|
+
// A program that cannot be read or parsed is named in `unread`, never dropped: dropped, it would
|
|
104
|
+
// read as removed from the tree, and every change inside it would go unreported.
|
|
105
|
+
function facts(root, opts = {}) {
|
|
106
|
+
// Each side of a diff is its own tree: the base is a revision materialised into a temporary
|
|
107
|
+
// directory, the head is the working tree, and they are walked separately.
|
|
108
|
+
//
|
|
109
|
+
// This used to build its own parse options with systemDirs: [], where every other caller passed
|
|
110
|
+
// the caller's. Nobody decided that - it is what five copies of one object literal do. The
|
|
111
|
+
// consequence was that diff could not resolve a system copybook that scan could, so a layout
|
|
112
|
+
// change inside one was invisible to the command whose whole job is what a change reaches. Both
|
|
113
|
+
// sides now take the same configuration, which for a caller that passes no systemDirs is
|
|
114
|
+
// exactly the old behaviour.
|
|
115
|
+
const tree = directoryTree(root, opts);
|
|
116
|
+
const idx = tree.index;
|
|
117
|
+
const out = new Map();
|
|
118
|
+
out.unread = [];
|
|
119
|
+
for (const f of tree.list().filter(isProgram).filter(inScope(opts))) {
|
|
120
|
+
let src;
|
|
121
|
+
try { src = readSource(f).text; } catch (e) { out.unread.push(`${relPath(root, f)}: ${e.code || e.name}`); continue; }
|
|
122
|
+
if (!/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) continue;
|
|
123
|
+
let r;
|
|
124
|
+
try {
|
|
125
|
+
r = tree.parse(f, src);
|
|
126
|
+
} catch (e) { out.unread.push(`${relPath(root, f)}: ${e.code || e.name}`); continue; }
|
|
127
|
+
const file = relPath(root, f);
|
|
128
|
+
for (const p of r.programs) {
|
|
129
|
+
const items = new Map();
|
|
130
|
+
for (const it of p.items) {
|
|
131
|
+
if (!it.name || it.name === 'FILLER' || it.level === 88 || it.level === 66 || it.level === 78) continue;
|
|
132
|
+
items.set(qualified(it), { size: it.size, offset: it.offset, from: it.file ? relPath(root, it.file) : file, section: it.section, level: it.level });
|
|
133
|
+
}
|
|
134
|
+
const calls = new Set();
|
|
135
|
+
const dynamic = new Set();
|
|
136
|
+
for (const c of p.calls) (c.kind === 'L' ? calls : dynamic).add(c.kind === 'L' ? String(c.name).toUpperCase() : `CALL ${c.name}`);
|
|
137
|
+
const commareas = new Set();
|
|
138
|
+
for (const e of p.execs) {
|
|
139
|
+
if (e.kind !== 'CICS') continue;
|
|
140
|
+
const words = e.toks.filter(t => t.t === 'word').map(t => t.u);
|
|
141
|
+
if (words[0] === 'RETURN') { const rc = e.toks.findIndex(t => t.t === 'word' && t.u === 'COMMAREA'); const ra = rc >= 0 ? e.toks.slice(rc + 1).find(t => t.t === 'word') : null; if (ra) commareas.add(ra.u); continue; }
|
|
142
|
+
if (!['LINK', 'XCTL', 'START'].includes(words[0])) continue;
|
|
143
|
+
const at = e.toks.findIndex(t => t.t === 'word' && (t.u === 'PROGRAM' || t.u === 'TRANSID'));
|
|
144
|
+
const target = at >= 0 ? e.toks.slice(at + 1).find(t => t.t === 'lit' || t.t === 'word') : null;
|
|
145
|
+
if (target && target.t === 'lit') calls.add(String(target.v).trim().toUpperCase());
|
|
146
|
+
else if (target) dynamic.add(`EXEC CICS ${words[0]} ${target.u}`);
|
|
147
|
+
const ca = e.toks.findIndex(t => t.t === 'word' && t.u === 'COMMAREA');
|
|
148
|
+
const arg = ca >= 0 ? e.toks.slice(ca + 1).find(t => t.t === 'word') : null;
|
|
149
|
+
if (arg) commareas.add(arg.u);
|
|
150
|
+
}
|
|
151
|
+
const using = new Set(p.calls.flatMap(c => c.using.filter(a => a.word).map(a => a.word)));
|
|
152
|
+
const copied = new Set(r.copies.filter((c) => c.status === 'resolved' && c.path).map((c) => relPath(root, c.path)));
|
|
153
|
+
out.set(`${file}#${p.id}`, { id: p.id, file, text: src, items, calls, dynamic, commareas, using, copied });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The key a finding keeps across two trees: its fingerprint, which never includes its line. The
|
|
160
|
+
// fallback, for a report stamped by nothing, is the coarser key this used before there was one:
|
|
161
|
+
// under it, fixing one finding and adding another of the same rule in the same program read as no
|
|
162
|
+
// change at all.
|
|
163
|
+
const findingKey = (f) => f.fingerprint || [f.rule, f.path, f.program || '', f.name || ''].join('|');
|
|
164
|
+
|
|
165
|
+
function countByKey(findings) {
|
|
166
|
+
const m = new Map();
|
|
167
|
+
for (const f of findings) m.set(findingKey(f), (m.get(findingKey(f)) || 0) + 1);
|
|
168
|
+
return m;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function diffTrees(baseRoot, headRoot, opts = {}) {
|
|
172
|
+
// The allow list describes the working tree, so it never applies to a revision checked out into
|
|
173
|
+
// a temporary directory: applied there it would match nothing and the base would read as empty.
|
|
174
|
+
const baseOpts = { ...opts, allow: null };
|
|
175
|
+
const base = facts(baseRoot, baseOpts);
|
|
176
|
+
const head = facts(headRoot, opts);
|
|
177
|
+
const findings = [];
|
|
178
|
+
const reach = { programsCompared: 0, programsAdded: 0, programsRemoved: 0, programsWithLayoutChange: 0, reachedOnlyThroughCopybooks: 0, copybooksImplicated: new Set() };
|
|
179
|
+
|
|
180
|
+
for (const [key, h] of head) {
|
|
181
|
+
const b = base.get(key);
|
|
182
|
+
if (!b) { reach.programsAdded++; continue; }
|
|
183
|
+
reach.programsCompared++;
|
|
184
|
+
const changed = [];
|
|
185
|
+
for (const [name, now] of h.items) {
|
|
186
|
+
const was = b.items.get(name);
|
|
187
|
+
if (!was) continue;
|
|
188
|
+
if (was.size !== now.size || was.offset !== now.offset) changed.push({ name, was, now });
|
|
189
|
+
}
|
|
190
|
+
if (changed.length) {
|
|
191
|
+
reach.programsWithLayoutChange++;
|
|
192
|
+
const unedited = sameSource(b.text, h.text);
|
|
193
|
+
if (unedited) reach.reachedOnlyThroughCopybooks++;
|
|
194
|
+
for (const c of changed) if (c.now.from !== h.file) reach.copybooksImplicated.add(c.now.from);
|
|
195
|
+
// An interface record is one another program also reads: the linkage section, a CICS
|
|
196
|
+
// communication area, or an argument passed on a CALL. A size change there is a mismatch
|
|
197
|
+
// between two programs unless both were rebuilt against the same copybook.
|
|
198
|
+
// A field inside a record passed by name crosses the boundary too, so any segment of its
|
|
199
|
+
// qualified name counts, not only the record's own.
|
|
200
|
+
const passed = new Set([...h.commareas, ...h.using]);
|
|
201
|
+
const iface = changed.filter(c => c.now.section === 'LINKAGE' || c.name.split('.').some(seg => passed.has(seg)));
|
|
202
|
+
const rule = iface.length ? 'diff-interface-layout-changed' : unedited ? 'diff-layout-changed-unedited-program' : 'diff-layout-changed';
|
|
203
|
+
const listed = (iface.length ? iface : changed).slice(0, MAX_LISTED)
|
|
204
|
+
.map(c => `${c.name} ${c.was.size}→${c.now.size} bytes${c.was.offset !== c.now.offset ? ` at offset ${c.was.offset}→${c.now.offset}` : ''}${c.now.from !== h.file ? ` (from ${c.now.from})` : ''}`);
|
|
205
|
+
findings.push({ rule, path: h.file, line: 1, program: h.id,
|
|
206
|
+
detail: `${changed.length} field(s) changed${unedited ? ' although this program\'s source is unchanged' : ''}: ${listed.join('; ')}${changed.length > MAX_LISTED ? '; …' : ''}` });
|
|
207
|
+
}
|
|
208
|
+
for (const d of h.dynamic) if (!b.dynamic.has(d)) findings.push({ rule: 'diff-new-dynamic-call', path: h.file, line: 1, program: h.id, detail: `${d} is new in this change` });
|
|
209
|
+
const added = [...h.calls].filter(c => !b.calls.has(c));
|
|
210
|
+
if (added.length) findings.push({ rule: 'diff-new-call-target', path: h.file, line: 1, program: h.id, detail: `now calls or transfers to ${added.sort().join(', ')}` });
|
|
211
|
+
}
|
|
212
|
+
const unreadHead = new Set(head.unread.map(u => u.split(': ')[0]));
|
|
213
|
+
for (const [key, b] of base) if (!head.has(key) && !unreadHead.has(b.file)) reach.programsRemoved++;
|
|
214
|
+
|
|
215
|
+
const listing = { listSinks: opts.listSinks === true, listSources: opts.listSources === true };
|
|
216
|
+
const shared = { only: opts.only, fullTrace: opts.fullTrace, systemDirs: opts.systemDirs || [], ...listing };
|
|
217
|
+
const before = scanAll(baseRoot, shared);
|
|
218
|
+
const after = scanAll(headRoot, { ...shared, allow: opts.allow });
|
|
219
|
+
// A file neither tree could read cannot be compared. Its findings are neither introduced nor
|
|
220
|
+
// resolved, and calling them resolved would report a scan failure as a fix.
|
|
221
|
+
const unreadable = new Set([...base.unread, ...head.unread].map(u => u.split(': ')[0]));
|
|
222
|
+
const comparable = (f) => !unreadable.has(f.path);
|
|
223
|
+
const beforeCounts = countByKey(before.findings.filter(comparable));
|
|
224
|
+
const afterCounts = countByKey(after.findings.filter(comparable));
|
|
225
|
+
const seenIntroduced = new Map();
|
|
226
|
+
const introduced = after.findings.filter(comparable).filter((f) => {
|
|
227
|
+
const k = findingKey(f);
|
|
228
|
+
const n = (seenIntroduced.get(k) || 0) + 1;
|
|
229
|
+
seenIntroduced.set(k, n);
|
|
230
|
+
return n > (beforeCounts.get(k) || 0);
|
|
231
|
+
});
|
|
232
|
+
const seenResolved = new Map();
|
|
233
|
+
const resolved = before.findings.filter(comparable).filter((f) => {
|
|
234
|
+
const k = findingKey(f);
|
|
235
|
+
const n = (seenResolved.get(k) || 0) + 1;
|
|
236
|
+
seenResolved.set(k, n);
|
|
237
|
+
return n > (afterCounts.get(k) || 0);
|
|
238
|
+
});
|
|
239
|
+
const uncomparable = [...new Set([...before.findings, ...after.findings].filter(f => !comparable(f)).map(f => f.path))];
|
|
240
|
+
const configurationChanged = configurationChanges(baseRoot, headRoot);
|
|
241
|
+
|
|
242
|
+
for (const f of findings) { const m = DIFF_RULES[f.rule]; f.sev = m.sev; f.cwe = m.cwe; f.evidence = m.evidence; }
|
|
243
|
+
findings.sort((a, b) => byText(a.rule, b.rule) || byText(a.path, b.path) || byText(String(a.program), String(b.program)));
|
|
244
|
+
const byRule = {};
|
|
245
|
+
for (const f of findings) byRule[f.rule] = (byRule[f.rule] || 0) + 1;
|
|
246
|
+
const res = {
|
|
247
|
+
tool: 'cobolwork-diff',
|
|
248
|
+
schemaVersion: SCHEMA_VERSION,
|
|
249
|
+
summary: {
|
|
250
|
+
flowModel: FLOW_MODEL,
|
|
251
|
+
toolVersion: TOOL_VERSION,
|
|
252
|
+
findings: findings.length, byRule,
|
|
253
|
+
introduced: introduced.length, resolved: resolved.length,
|
|
254
|
+
...reach, copybooksImplicated: [...reach.copybooksImplicated].sort(),
|
|
255
|
+
unread: { base: base.unread, head: head.unread, findingsNotCompared: uncomparable },
|
|
256
|
+
...(configurationChanged.length ? { configurationChanged } : {}),
|
|
257
|
+
coverageIncomplete: !!(before.summary.coverageIncomplete || after.summary.coverageIncomplete || base.unread.length || head.unread.length),
|
|
258
|
+
nosrc: head.size === 0 && base.size === 0,
|
|
259
|
+
},
|
|
260
|
+
findings,
|
|
261
|
+
introduced,
|
|
262
|
+
resolved,
|
|
263
|
+
ruleText: Object.fromEntries(Object.entries(DIFF_RULES).map(([k, v]) => [k, v.text])),
|
|
264
|
+
ruleEvidence: evidenceMap(DIFF_RULES),
|
|
265
|
+
};
|
|
266
|
+
// Both scans and both sides' programs, for a caller that judges the change; never printed.
|
|
267
|
+
if (opts.keepScans) Object.defineProperty(res, 'scans', { value: { before, after, base, head }, enumerable: false });
|
|
268
|
+
return res;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Site-file and baseline changes, which move findings between introduced and resolved with no source changed.
|
|
272
|
+
const SITE_LISTS = ['productionQualifiers', 'productionJobPaths', 'nonProductionJobPaths', 'systemNames', 'vendorPacks',
|
|
273
|
+
'internalReaderDds', 'internalReaderQueues', 'compilerOptions', 'apfLibraries', 'restrictedDatasets', 'surrogateUsers'];
|
|
274
|
+
|
|
275
|
+
function siteChange(baseRoot, headRoot) {
|
|
276
|
+
const was = existsSync(join(baseRoot, SITE_FILE));
|
|
277
|
+
const now = existsSync(join(headRoot, SITE_FILE));
|
|
278
|
+
if (!was && !now) return null;
|
|
279
|
+
if (!now) return { file: SITE_FILE, change: 'removed, so the rules that need it do not run' };
|
|
280
|
+
const b = loadSite(baseRoot);
|
|
281
|
+
const h = loadSite(headRoot);
|
|
282
|
+
const parts = was ? [] : ['added'];
|
|
283
|
+
for (const k of SITE_LISTS) {
|
|
284
|
+
const gone = b[k].filter((x) => !h[k].includes(x)).map((x) => `-${x}`);
|
|
285
|
+
const came = h[k].filter((x) => !b[k].includes(x)).map((x) => `+${x}`);
|
|
286
|
+
if (gone.length || came.length) parts.push(`${k} ${[...gone, ...came].join(' ')}`);
|
|
287
|
+
}
|
|
288
|
+
if (b.allowUnvalidatedPacks !== h.allowUnvalidatedPacks) parts.push(`allowUnvalidatedPacks ${h.allowUnvalidatedPacks}`);
|
|
289
|
+
if (JSON.stringify(b.runtimeVersions) !== JSON.stringify(h.runtimeVersions)) parts.push('runtimeVersions');
|
|
290
|
+
if (h.problems.length && h.problems.join() !== b.problems.join()) parts.push(`now reports: ${h.problems[0]}`);
|
|
291
|
+
return parts.length ? { file: SITE_FILE, change: printable(parts.join('; '), 400) } : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const configurationChanges = (baseRoot, headRoot) =>
|
|
295
|
+
[siteChange(baseRoot, headRoot), baselineChange(baseRoot, headRoot)].filter(Boolean);
|
|
296
|
+
|
|
297
|
+
function baselineChange(baseRoot, headRoot) {
|
|
298
|
+
const was = existsSync(join(baseRoot, BASELINE_FILE));
|
|
299
|
+
const now = existsSync(join(headRoot, BASELINE_FILE));
|
|
300
|
+
if (!was && !now) return null;
|
|
301
|
+
const held = (root, there) => new Map((there ? loadBaseline(root)?.entries || [] : []).map((e) => [`${e.fingerprint}|${e.rule}`, e]));
|
|
302
|
+
const b = held(baseRoot, was);
|
|
303
|
+
const h = held(headRoot, now);
|
|
304
|
+
const added = [...h.keys()].filter((k) => !b.has(k));
|
|
305
|
+
const suppressing = added.filter((k) => SUPPRESSING.includes(h.get(k).action)).length;
|
|
306
|
+
const removed = [...b.keys()].filter((k) => !h.has(k)).length;
|
|
307
|
+
const changed = [...h.keys()].filter((k) => b.has(k) && JSON.stringify(b.get(k)) !== JSON.stringify(h.get(k))).length;
|
|
308
|
+
if (!added.length && !removed && !changed && was === now) return null;
|
|
309
|
+
return { file: BASELINE_FILE, change: `${was ? (now ? 'edited' : 'removed') : 'added'}: ${added.length} entr${added.length === 1 ? 'y' : 'ies'} added, ${suppressing} of them suppressing; ${removed} removed; ${changed} changed` };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The files git would show in a commit: tracked, plus untracked ones that are not ignored. A
|
|
313
|
+
// working tree also holds build output and vendored copies that no revision contains, and comparing
|
|
314
|
+
// those against a revision reports a change in a file nobody changed.
|
|
315
|
+
function gitScope(repo) {
|
|
316
|
+
const r = git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { encoding: 'utf8' });
|
|
317
|
+
if (r.status !== 0) throw Object.assign(new Error(`git ls-files failed: ${String(r.stderr || '').trim()}`), { code: 'EDIFFREF' });
|
|
318
|
+
return new Set(r.stdout.split('\0').filter(Boolean).map(p => resolve(repo, p)));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const WORKING_TREE = 'working tree, tracked and untracked files git does not ignore';
|
|
322
|
+
|
|
323
|
+
// Calls fn(baseDir, headDir, opts) with both revisions on disk, the head being the working tree when
|
|
324
|
+
// headRef is null, and removes what it materialised however fn ends.
|
|
325
|
+
export function withRefs(repo, baseRef, headRef, opts, fn) {
|
|
326
|
+
const baseDir = materialise(repo, baseRef);
|
|
327
|
+
let headDir = null;
|
|
328
|
+
try {
|
|
329
|
+
headDir = headRef ? materialise(repo, headRef) : repo;
|
|
330
|
+
return fn(baseDir, headDir, headRef ? opts : { ...opts, allow: gitScope(repo) });
|
|
331
|
+
} finally {
|
|
332
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
333
|
+
if (headRef && headDir) rmSync(headDir, { recursive: true, force: true });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function diffRefs(repo, baseRef, headRef = null, opts = {}) {
|
|
338
|
+
return withRefs(repo, baseRef, headRef, opts, (baseDir, headDir, scoped) => {
|
|
339
|
+
const res = diffTrees(baseDir, headDir, scoped);
|
|
340
|
+
res.summary.base = baseRef;
|
|
341
|
+
res.summary.head = headRef || WORKING_TREE;
|
|
342
|
+
return res;
|
|
343
|
+
});
|
|
344
|
+
}
|
package/lib/explain.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { resolve, relative, sep, dirname } from 'node:path';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { readSource, isProgram, isCopybook, isJcl } from './sources.mjs';
|
|
5
|
+
import { parseFile, buildFileIndex, detectFormat } from './parser.mjs';
|
|
6
|
+
import { EVIDENCE } from './kernel/findings.mjs';
|
|
7
|
+
import { REGISTRY } from './kernel/registry.mjs';
|
|
8
|
+
import { WHO_ACTS } from './tui/model.mjs';
|
|
9
|
+
|
|
10
|
+
const HIDDEN_RULES = new Set(Object.keys(REGISTRY.find((s) => s.name === 'hidden').rules));
|
|
11
|
+
|
|
12
|
+
// The columns quoted: COBOL's indicator and program text, JCL's statement field, or the whole line.
|
|
13
|
+
function codeArea(line, kind, format) {
|
|
14
|
+
if (kind === 'cobol' && format === 'fixed') return { text: line.slice(6, 72), seq: line.slice(0, 6), rest: line.slice(72) };
|
|
15
|
+
if (kind === 'cobol' && format === 'variable') return { text: line.slice(6, 250), seq: line.slice(0, 6), rest: line.slice(250) };
|
|
16
|
+
if (kind === 'jcl') return { text: line.slice(0, 72), seq: '', rest: line.slice(72) };
|
|
17
|
+
return { text: line, seq: '', rest: '' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NOTE = 'This packet contains source text from the files it names. A cobolwork report never does: '
|
|
21
|
+
+ 'treat the packet as you would the source, and do not store or send it where the source may not go.';
|
|
22
|
+
|
|
23
|
+
function contained(root, rel) {
|
|
24
|
+
const base = resolve(root);
|
|
25
|
+
const abs = resolve(base, rel);
|
|
26
|
+
if (abs !== base && !abs.startsWith(base + sep)) return { why: 'the path resolves outside the root' };
|
|
27
|
+
let real;
|
|
28
|
+
try { real = realpathSync(abs); } catch { return { why: 'the file is not there' }; }
|
|
29
|
+
const realBase = realpathSync(base);
|
|
30
|
+
if (real !== realBase && !real.startsWith(realBase + sep)) return { why: 'the path is a link that leads outside the root' };
|
|
31
|
+
return { abs };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A hop's location: from `via` ("MOVE at pos/P1.cbl:9"), else the related entry for its file.
|
|
35
|
+
function hopAt(hop, finding) {
|
|
36
|
+
const m = /\bat (.+):(\d+)$/.exec(hop.via || '');
|
|
37
|
+
if (m) return { path: m[1], line: Number(m[2]) };
|
|
38
|
+
const rel = (finding.related || []).find((r) => r.path === hop.file);
|
|
39
|
+
return rel ? { path: rel.path, line: rel.line } : { path: hop.file, line: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function explainFinding(report, fingerprint, { root }) {
|
|
43
|
+
const active = report.findings.filter((f) => f.fingerprint === fingerprint);
|
|
44
|
+
// A baseline moves a judged finding out of `findings` into `suppressed`. Explain still reaches it,
|
|
45
|
+
// and the packet carries the judgement rather than showing it as live.
|
|
46
|
+
const matches = active.length ? active : (report.suppressed || []).filter((f) => f.fingerprint === fingerprint);
|
|
47
|
+
if (!matches.length) return null;
|
|
48
|
+
const f = matches[0];
|
|
49
|
+
const files = new Map();
|
|
50
|
+
const unread = [];
|
|
51
|
+
const kindOf = (abs) => (isJcl(abs) ? 'jcl' : isProgram(abs) || isCopybook(abs) ? 'cobol' : null);
|
|
52
|
+
const fileOf = (path) => {
|
|
53
|
+
if (!path) return null;
|
|
54
|
+
if (files.has(path)) return files.get(path);
|
|
55
|
+
const c = contained(root, path);
|
|
56
|
+
let file = null;
|
|
57
|
+
if (!c.abs) unread.push({ path, why: c.why });
|
|
58
|
+
else if (!kindOf(c.abs)) unread.push({ path, why: 'not a source file cobolwork reads' });
|
|
59
|
+
else {
|
|
60
|
+
try {
|
|
61
|
+
const text = readSource(c.abs).text;
|
|
62
|
+
file = { kind: kindOf(c.abs), format: detectFormat(text), lines: text.split(/\r?\n/) };
|
|
63
|
+
} catch (e) { unread.push({ path, why: `it could not be read: ${e.message}` }); }
|
|
64
|
+
}
|
|
65
|
+
files.set(path, file);
|
|
66
|
+
return file;
|
|
67
|
+
};
|
|
68
|
+
const flagged = new Map();
|
|
69
|
+
for (const x of [...report.findings, ...(report.suppressed || [])]) {
|
|
70
|
+
if (HIDDEN_RULES.has(x.rule) && x.path && x.line) flagged.set(`${x.path}:${x.line}`, x.rule);
|
|
71
|
+
}
|
|
72
|
+
const quote = (path, line) => {
|
|
73
|
+
const withheld = flagged.get(`${path}:${line}`);
|
|
74
|
+
if (withheld) return { code: null, withheld };
|
|
75
|
+
const file = fileOf(path);
|
|
76
|
+
if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
|
|
77
|
+
const area = codeArea(file.lines[line - 1], file.kind, file.format);
|
|
78
|
+
const dropped = area.rest.trim() !== '' || /[^\d\s]/.test(area.seq);
|
|
79
|
+
return { code: area.text.trimEnd(), ...(dropped ? { dropped } : {}) };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const hops = (f.trace || []).map((h, i) => {
|
|
83
|
+
if (h.elided) return { n: i + 1, elided: h.elided, via: h.via };
|
|
84
|
+
const at = hopAt(h, f);
|
|
85
|
+
return { n: i + 1, program: h.program, item: h.item, file: h.file, via: h.via, path: at.path, line: at.line, ...quote(at.path, at.line) };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const parsed = new Map();
|
|
89
|
+
let index = null;
|
|
90
|
+
const programIn = (file, id) => {
|
|
91
|
+
if (!parsed.has(file)) {
|
|
92
|
+
const c = contained(root, file);
|
|
93
|
+
let r = null;
|
|
94
|
+
if (c.abs && kindOf(c.abs) === 'cobol') {
|
|
95
|
+
index ??= buildFileIndex(resolve(root));
|
|
96
|
+
try {
|
|
97
|
+
r = parseFile(c.abs, { format: 'auto', copyFormat: 'auto', fileIndex: index.index, includeDirs: index.copyDirs, mainDir: dirname(c.abs) });
|
|
98
|
+
} catch { r = null; }
|
|
99
|
+
}
|
|
100
|
+
parsed.set(file, r);
|
|
101
|
+
}
|
|
102
|
+
const r = parsed.get(file);
|
|
103
|
+
return r && r.programs.find((p) => String(p.id).toUpperCase() === String(id).toUpperCase());
|
|
104
|
+
};
|
|
105
|
+
const declarations = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const subjects = [...hops.filter((h) => h.item), ...(f.guard ? [{ program: f.guard.program, item: f.guard.item, file: f.guard.file }] : [])];
|
|
108
|
+
for (const h of subjects) {
|
|
109
|
+
const key = `${h.program}.${h.item}`;
|
|
110
|
+
if (seen.has(key)) continue;
|
|
111
|
+
seen.add(key);
|
|
112
|
+
const p = programIn(h.file, h.program);
|
|
113
|
+
const it = p && p.items.find((x) => String(x.name).toUpperCase() === String(h.item).toUpperCase());
|
|
114
|
+
if (!it) continue;
|
|
115
|
+
declarations.push({
|
|
116
|
+
program: h.program, item: h.item, level: it.level, picture: it.picture, usage: it.usage, size: it.size,
|
|
117
|
+
offset: it.offset, section: it.section, occurs: it.occurs, redefines: it.redefines,
|
|
118
|
+
path: it.file ? relative(resolve(root), resolve(it.file)).split(sep).join('/') : h.file,
|
|
119
|
+
line: it.line,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
tool: 'cobolwork-explain',
|
|
125
|
+
carriesSource: true,
|
|
126
|
+
note: NOTE,
|
|
127
|
+
fingerprint,
|
|
128
|
+
shared: matches.length,
|
|
129
|
+
...(f.suppressed ? { suppressed: f.suppressed } : {}),
|
|
130
|
+
finding: {
|
|
131
|
+
rule: f.rule, sev: f.sev, ...(f.guardedFrom ? { guardedFrom: f.guardedFrom } : {}), cwe: f.cwe,
|
|
132
|
+
evidence: f.evidence, claim: EVIDENCE[f.evidence] || null, whoActs: WHO_ACTS[f.evidence] || null,
|
|
133
|
+
// What the finding lets someone do and the standard fix, from the finding if it carries its
|
|
134
|
+
// own (a site-gated rule that became a defect), else the rule's, else null for an info kind.
|
|
135
|
+
impact: f.impact ?? report.ruleImpact?.[f.rule] ?? null, remedy: f.remedy ?? report.ruleRemedy?.[f.rule] ?? null,
|
|
136
|
+
program: f.program, path: f.path, line: f.line, crossProgram: !!f.crossProgram, detail: f.detail,
|
|
137
|
+
},
|
|
138
|
+
hops,
|
|
139
|
+
sink: { path: f.path, line: f.line, ...quote(f.path, f.line) },
|
|
140
|
+
guard: f.guard ? { ...f.guard, ...quote(f.guard.file, f.guard.line) } : null,
|
|
141
|
+
related: (f.related || []).map((r) => ({ ...r, ...quote(r.path, r.line) })),
|
|
142
|
+
declarations,
|
|
143
|
+
unread,
|
|
144
|
+
};
|
|
145
|
+
}
|