@portll/cobolwork 0.2.140 → 0.3.0
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/README.md +14 -2
- package/THIRD-PARTY-NOTICES.md +3 -2
- package/bin/cobolwork.mjs +16 -1
- package/lib/baseline.mjs +8 -6
- package/lib/bms.mjs +1 -1
- package/lib/capabilities.mjs +2 -1
- package/lib/cards.mjs +66 -0
- package/lib/compliance.mjs +6 -5
- package/lib/control.mjs +402 -19
- package/lib/dataflow.mjs +33 -9
- package/lib/diff.mjs +65 -89
- package/lib/equivalence.mjs +30 -12
- package/lib/evidence/record.mjs +3 -1
- package/lib/evidence/slsa.mjs +17 -2
- package/lib/execution.mjs +77 -0
- package/lib/explain.mjs +1 -1
- package/lib/ftp.mjs +1 -1
- package/lib/inventory.mjs +8 -9
- package/lib/jcl.mjs +3 -48
- package/lib/kernel/git.mjs +60 -0
- package/lib/kernel/identity.mjs +7 -6
- package/lib/kernel/source-tree.mjs +204 -34
- package/lib/parser.mjs +31 -15
- package/lib/revision.json +1 -1
- package/lib/sbom.mjs +33 -9
- package/lib/scan.mjs +11 -4
- package/lib/sets/build.mjs +1 -1
- package/lib/sets/cics.mjs +27 -3
- package/lib/sets/copybook.mjs +1 -1
- package/lib/sets/hidden.mjs +1 -1
- package/lib/sets/jcl.mjs +1 -1
- package/lib/sets/priv.mjs +1 -1
- package/lib/sets/recon.mjs +2 -2
- package/lib/sets/semantics.mjs +1 -1
- package/lib/sets/vendor.mjs +1 -1
- package/lib/site.mjs +17 -7
- package/lib/sources.mjs +82 -38
- package/lib/utilities.mjs +1 -1
- package/package.json +2 -2
- package/rules/compliance-cobit2019.json +2438 -0
- package/lib/card.mjs +0 -18
package/lib/sbom.mjs
CHANGED
|
@@ -102,7 +102,20 @@ export function sbom(root, opts = {}) {
|
|
|
102
102
|
const idx = tree.index;
|
|
103
103
|
const top = resolve(root);
|
|
104
104
|
const within = (p) => resolve(p).startsWith(top + sep);
|
|
105
|
-
|
|
105
|
+
// A member outside the tree is named by its file name, and by its directory's digest as well where
|
|
106
|
+
// another library holds a member of the same name.
|
|
107
|
+
const copylibRefs = new Map();
|
|
108
|
+
const claimed = new Map();
|
|
109
|
+
const refOf = (p) => {
|
|
110
|
+
if (within(p)) return relPath(root, p);
|
|
111
|
+
if (!copylibRefs.has(p)) {
|
|
112
|
+
let ref = `copylib:${basename(p)}`;
|
|
113
|
+
if (claimed.has(ref) && claimed.get(ref) !== p) ref = `copylib:${basename(p)}#${sha256(dirname(resolve(p))).slice(0, 8)}`;
|
|
114
|
+
claimed.set(ref, p);
|
|
115
|
+
copylibRefs.set(p, ref);
|
|
116
|
+
}
|
|
117
|
+
return copylibRefs.get(p);
|
|
118
|
+
};
|
|
106
119
|
|
|
107
120
|
const entries = [];
|
|
108
121
|
const programIds = new Map();
|
|
@@ -110,17 +123,24 @@ export function sbom(root, opts = {}) {
|
|
|
110
123
|
const procIds = new Map();
|
|
111
124
|
for (const f of tree.list().filter(inScope(opts)).sort()) {
|
|
112
125
|
let bytes;
|
|
113
|
-
let src;
|
|
114
|
-
try { bytes = readFileSync(f);
|
|
115
|
-
|
|
126
|
+
let src = null;
|
|
127
|
+
try { bytes = readFileSync(f); } catch { continue; }
|
|
128
|
+
try { src = readSource(f).text; } catch { /* its bytes are listed; its text could not be decoded */ }
|
|
129
|
+
const kind = src === null ? (isProgram(f) ? 'program' : isCopybook(f) ? 'copybook' : isJcl(f) ? 'jcl' : isBms(f) ? 'bms' : null) : kindOf(f, src);
|
|
116
130
|
if (!kind) continue;
|
|
117
|
-
const entry = { file: f, ref: relPath(root, f), bytes, src, kind };
|
|
131
|
+
const entry = { file: f, ref: relPath(root, f), bytes, src: src ?? '', kind, ...(src === null ? { unparsed: true, undecodable: true } : {}) };
|
|
132
|
+
if (src === null) { entries.push(entry); continue; }
|
|
118
133
|
if (kind === 'program') {
|
|
119
134
|
try {
|
|
120
135
|
const res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
|
|
121
136
|
entry.programs = res.programs;
|
|
122
137
|
entry.copies = res.copies;
|
|
123
|
-
for (const p of res.programs)
|
|
138
|
+
for (const p of res.programs) {
|
|
139
|
+
if (!p.id) continue;
|
|
140
|
+
const id = String(p.id).toUpperCase();
|
|
141
|
+
if (!programIds.has(id)) programIds.set(id, []);
|
|
142
|
+
if (!programIds.get(id).includes(entry.ref)) programIds.get(id).push(entry.ref);
|
|
143
|
+
}
|
|
124
144
|
} catch { entry.unparsed = true; }
|
|
125
145
|
} else if (kind === 'jcl' || kind === 'proc') {
|
|
126
146
|
try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
|
|
@@ -139,9 +159,10 @@ export function sbom(root, opts = {}) {
|
|
|
139
159
|
const dependencies = new Map();
|
|
140
160
|
const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
|
|
141
161
|
// A program the estate does not hold is a component of its own, so an edge to it is not lost.
|
|
162
|
+
// A PROGRAM-ID two files hold is an edge to each: which one a run loads is the load library's order.
|
|
142
163
|
const runs = (from, name) => {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
164
|
+
const targets = programIds.get(name);
|
|
165
|
+
if (targets) { for (const t of targets) if (t !== from) depend(from, t); return; }
|
|
145
166
|
if (platformRoutine(name)) return;
|
|
146
167
|
const ref = `program:${name}`;
|
|
147
168
|
depend(from, ref);
|
|
@@ -153,9 +174,12 @@ export function sbom(root, opts = {}) {
|
|
|
153
174
|
for (const e of entries) {
|
|
154
175
|
const properties = [{ name: 'cobolwork:kind', value: e.kind }];
|
|
155
176
|
if (e.unparsed) properties.push({ name: 'cobolwork:unparsed', value: 'true' });
|
|
177
|
+
if (e.undecodable) properties.push({ name: 'cobolwork:undecodable', value: 'true' });
|
|
156
178
|
if (e.kind === 'program' && e.programs && e.programs.length) {
|
|
157
179
|
const ids = e.programs.map((p) => p.id).filter(Boolean);
|
|
158
180
|
if (ids.length) properties.push({ name: 'cobolwork:program-id', value: ids.join(',') });
|
|
181
|
+
const twins = [...new Set(ids.flatMap((id) => (programIds.get(String(id).toUpperCase()) || []).filter((r) => r !== e.ref)))].sort(order);
|
|
182
|
+
if (twins.length) properties.push({ name: 'cobolwork:program-id-also-in', value: twins.join(',') });
|
|
159
183
|
properties.push({ name: 'cobolwork:format', value: detectFormat(e.src) });
|
|
160
184
|
const options = optionCards(e.src).flatMap((c) => c.options);
|
|
161
185
|
if (options.length) properties.push({ name: 'cobolwork:options', value: options.join(' ') });
|
|
@@ -166,7 +190,7 @@ export function sbom(root, opts = {}) {
|
|
|
166
190
|
if (!within(c.path) && !components.has(ref)) {
|
|
167
191
|
let b = null;
|
|
168
192
|
try { b = readFileSync(c.path); } catch { /* read at parse time, gone since */ }
|
|
169
|
-
components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }] });
|
|
193
|
+
components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }, ...(b ? [] : [{ name: 'cobolwork:unread', value: 'true' }])] });
|
|
170
194
|
}
|
|
171
195
|
}
|
|
172
196
|
const calls = e.programs.flatMap((p) => p.calls || []);
|
package/lib/scan.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { sortFindings, tally, evidenceMap, EVIDENCE, EXPLOITABILITY } from './kernel/findings.mjs';
|
|
4
4
|
import { directoryTree } from './kernel/source-tree.mjs';
|
|
5
5
|
import { REGISTRY, RULE_SETS, ALL_RULES, reportKey } from './kernel/registry.mjs';
|
|
@@ -13,6 +13,7 @@ import { rollup } from './sets/flow.mjs';
|
|
|
13
13
|
import { loadSite } from './site.mjs';
|
|
14
14
|
import { reachFeedPaths, loadReachFeed, reachResolver, stampReach, stampEffect } from './reach.mjs';
|
|
15
15
|
import { stampExploitability, byVerdict, HANDLING, witnessFeedPaths, loadWitnessFeed, applyWitness } from './exploitability.mjs';
|
|
16
|
+
import { executionFeedPaths, loadExecutionFeed, applyExecution } from './execution.mjs';
|
|
16
17
|
|
|
17
18
|
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
18
19
|
|
|
@@ -133,7 +134,7 @@ function scanEach(root, opts) {
|
|
|
133
134
|
// Reach and effect annotate rather than re-rank; where no fact is declared, a note says so rather
|
|
134
135
|
// than the report reading as a clean bill.
|
|
135
136
|
export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
136
|
-
const site = loadSite(root, opts.site || null);
|
|
137
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
137
138
|
const feedRoot = opts.feedRoot || root;
|
|
138
139
|
const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root: feedRoot }));
|
|
139
140
|
const reach = reachResolver({ feeds: reachFeeds, site });
|
|
@@ -151,6 +152,8 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
|
151
152
|
const witnessed = applyWitness(findings, checked, witnessFeeds);
|
|
152
153
|
if (witnessed.overruled.length) sortFindings(findings);
|
|
153
154
|
const byExploitability = byVerdict(findings);
|
|
155
|
+
const executionFeeds = executionFeedPaths(opts).map((p) => loadExecutionFeed(p));
|
|
156
|
+
const { byExecution } = applyExecution(findings, root, executionFeeds);
|
|
154
157
|
const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
|
|
155
158
|
const loaded = witnessFeeds.filter((f) => !f.problem);
|
|
156
159
|
return {
|
|
@@ -164,6 +167,8 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
|
164
167
|
...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
|
|
165
168
|
...(loaded.length ? { witnessFeeds: loaded.map((f) => ({ file: f.file, witness: f.witness, recorded: f.recorded })), byWitness: witnessed.byWitness } : {}),
|
|
166
169
|
...(witnessFeeds.some((f) => f.problem || f.refused.length) ? { witnessFeedProblems: witnessFeeds.flatMap((f) => (f.problem ? [`${f.file}: ${f.problem}`] : f.refused.map((r) => `${f.file}: ${r.why}`))) } : {}),
|
|
170
|
+
...(byExecution ? { executionFeeds: executionFeeds.filter((f) => !f.problem).map((f) => ({ file: f.file, programs: f.programs.size })), byExecution } : {}),
|
|
171
|
+
...(executionFeeds.some((f) => f.problem) ? { executionFeedProblems: executionFeeds.filter((f) => f.problem).map((f) => `${f.file}: ${f.problem}`) } : {}),
|
|
167
172
|
// A result no path finding carries: the code changed since the test, or the route is gone.
|
|
168
173
|
...(witnessed.unmatched.length ? { witnessUnmatched: witnessed.unmatched } : {}),
|
|
169
174
|
// Routes the reading refuted that the estate reproduced: the check model was wrong about them.
|
|
@@ -234,10 +239,12 @@ export function scanAll(root, opts = {}) {
|
|
|
234
239
|
const startedBy = parts.find((p) => p.tool === 'cobolwork-flow')?.startedBy || {};
|
|
235
240
|
for (const p of parts) for (const f of p.findings) findings.push(!f.startedBy && f.program && startedBy[f.program] ? { ...f, startedBy: startedBy[f.program] } : { ...f });
|
|
236
241
|
sortFindings(findings);
|
|
237
|
-
|
|
242
|
+
// A tree held in memory is read for the fingerprint's line text as it was for the finding.
|
|
243
|
+
const read = tree.kind === 'directory' ? null : (path) => tree.text(resolve(tree.root, path)).text;
|
|
244
|
+
const identity = stampFingerprints(findings, { root, repo: opts.repoName || '', read });
|
|
238
245
|
// Routes a check stops keep their identity too, so a finding a fix clears can be found again here.
|
|
239
246
|
const checked = parts.find((p) => p.tool === 'cobolwork-flow')?.checked || [];
|
|
240
|
-
stampFingerprints(checked, { root, repo: opts.repoName || '' });
|
|
247
|
+
stampFingerprints(checked, { root, repo: opts.repoName || '', read });
|
|
241
248
|
|
|
242
249
|
const estate = applyEstateFacts(findings, checked, root, opts);
|
|
243
250
|
|
package/lib/sets/build.mjs
CHANGED
|
@@ -132,7 +132,7 @@ function withFeeds(coverage, feeds) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
export function scanBuild(root, opts = {}) {
|
|
135
|
-
const site = loadSite(root, opts.site || null);
|
|
135
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
136
136
|
const tree = treeFor(root, opts);
|
|
137
137
|
const files = tree.list().filter(inScope(opts)).filter(isBuildFile);
|
|
138
138
|
const findings = [];
|
package/lib/sets/cics.mjs
CHANGED
|
@@ -136,6 +136,31 @@ function execOptions(exec) {
|
|
|
136
136
|
|
|
137
137
|
const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
|
|
138
138
|
|
|
139
|
+
// Whether EIBCALEN bounds what the program reads: a condition tests it, or a field it was moved or
|
|
140
|
+
// computed into; it is the start or length of a reference modification, or what an OCCURS DEPENDING
|
|
141
|
+
// ON counts by. A CALL handed it, or the whole EIB, may test it where this program cannot see.
|
|
142
|
+
// Named anywhere else - logged, passed on to a LINK - it checks nothing.
|
|
143
|
+
function testsLength(p) {
|
|
144
|
+
// A statement's own tokens, since its sources leave out what a condition or an expression wraps
|
|
145
|
+
// in parentheses: IF (EIBCALEN > 0).
|
|
146
|
+
const tokens = (p.proc && p.proc.tokens) || [];
|
|
147
|
+
const own = (st) => (st.at != null ? tokens.slice(st.at + 1, st.end) : st.sources || []);
|
|
148
|
+
const carriers = new Set(['EIBCALEN']);
|
|
149
|
+
for (let grew = true; grew;) {
|
|
150
|
+
grew = false;
|
|
151
|
+
for (const st of p.statements || []) {
|
|
152
|
+
if (!own(st).some((t) => carriers.has(t.u))) continue;
|
|
153
|
+
for (const t of st.targets || []) if (!carriers.has(t.u)) { carriers.add(t.u); grew = true; }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const carries = (toks) => (toks || []).some((t) => t && carriers.has(t.u));
|
|
157
|
+
return p.items.some((i) => i.dependingOn === 'EIBCALEN') || (p.statements || []).some((st) =>
|
|
158
|
+
((st.verb === 'IF' || st.verb === 'EVALUATE' || st.verb === 'WHEN') && carries(own(st)))
|
|
159
|
+
|| (st.loops || []).some((l) => carries(l.until))
|
|
160
|
+
|| (st.indexes || []).some((x) => x.kind.startsWith('refmod-') && carries([x.tok]))
|
|
161
|
+
|| (st.verb === 'CALL' && (st.sources || []).some((t) => t.u === 'EIBCALEN' || t.u === 'DFHEIBLK')));
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
// Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
|
|
140
165
|
// not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
|
|
141
166
|
// have gone, an ASSIGN, and the RETURN that ends the task anyway.
|
|
@@ -191,7 +216,6 @@ export function scanCics(root, opts = {}) {
|
|
|
191
216
|
// The whole of what the second pass reads, computed once, while the tree is still here.
|
|
192
217
|
function summarise(p, path) {
|
|
193
218
|
const isSignOn = [...new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))])].some((w) => PASSWORD.test(w));
|
|
194
|
-
const words = new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))]);
|
|
195
219
|
const commarea = p.items.find(i => i.name === 'DFHCOMMAREA' && i.section === 'LINKAGE');
|
|
196
220
|
// A variable naming the program usually holds one literal from its VALUE clause.
|
|
197
221
|
const valueOf = (name) => {
|
|
@@ -236,7 +260,7 @@ export function scanCics(root, opts = {}) {
|
|
|
236
260
|
ownCics: p.execs.some((e) => e.kind === 'CICS'),
|
|
237
261
|
pointsItself,
|
|
238
262
|
commarea: commarea ? { ...where(commarea, path), size: commarea.size, read: subtreeRead(commarea) } : null,
|
|
239
|
-
checksLength:
|
|
263
|
+
checksLength: testsLength(p),
|
|
240
264
|
transfers,
|
|
241
265
|
signOnBypasses: isSignOn ? signOnBypasses(p, path) : [],
|
|
242
266
|
// Gated the same way: a program with no password-named field is not answering a sign-on,
|
|
@@ -393,7 +417,7 @@ export function scanCics(root, opts = {}) {
|
|
|
393
417
|
findings.push({
|
|
394
418
|
rule: 'cics-commarea-without-length-check', path: s.commarea.path, line: s.commarea.line, program: s.id,
|
|
395
419
|
...(covered ? { sev: 'low' } : {}),
|
|
396
|
-
detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never
|
|
420
|
+
detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never tests EIBCALEN, so a shorter or absent communication area is read as if it were whole${covered ? `; every caller in the tree passes at least ${s.commarea.size} bytes, and no transaction starts it` : ''}`,
|
|
397
421
|
});
|
|
398
422
|
}
|
|
399
423
|
}
|
package/lib/sets/copybook.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { finish } from '../kernel/findings.mjs';
|
|
|
7
7
|
import { report } from '../kernel/ruleset.mjs';
|
|
8
8
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
9
9
|
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
10
|
-
import { cobolCard } from '../
|
|
10
|
+
import { cobolCard } from '../cards.mjs';
|
|
11
11
|
|
|
12
12
|
// A copybook is resolved by name along a search path: the program's own directory first, then the
|
|
13
13
|
// include directories, then the system library. Two files answering to one name mean the record
|
package/lib/sets/hidden.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { detectFormat, expandTabs } from '../parser.mjs';
|
|
3
|
-
import { cobolCard } from '../
|
|
3
|
+
import { cobolCard } from '../cards.mjs';
|
|
4
4
|
import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
|
|
5
5
|
import { report } from '../kernel/ruleset.mjs';
|
|
6
6
|
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
package/lib/sets/jcl.mjs
CHANGED
|
@@ -237,7 +237,7 @@ export function scanJcl(root, opts = {}) {
|
|
|
237
237
|
// A vendor product supplies its own utilities. An estate that has loaded the pack for a product
|
|
238
238
|
// is telling us those programs exist, so reporting them as undefined would be a false positive
|
|
239
239
|
// created by the customer having told us the truth.
|
|
240
|
-
const site = loadSite(root, opts.site || null);
|
|
240
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
241
241
|
const { loaded } = loadPacks(opts.packs || site.vendorPacks || [], { allowUnvalidated: true });
|
|
242
242
|
const vendorPrograms = programsOf(loaded);
|
|
243
243
|
|
package/lib/sets/priv.mjs
CHANGED
|
@@ -102,7 +102,7 @@ function under(dsn, prefixes) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
export function scanPriv(root, opts = {}) {
|
|
105
|
-
const site = loadSite(root, opts.site || null);
|
|
105
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
106
106
|
const tree = treeFor(root, opts);
|
|
107
107
|
const files = tree.list().filter(inScope(opts));
|
|
108
108
|
const findings = [];
|
package/lib/sets/recon.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
|
17
17
|
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
18
18
|
import { loadSite, classifyPath, productionQualifierOf, SITE_FILE } from '../site.mjs';
|
|
19
19
|
import { parseJcl, dispositionOf } from '../jcl.mjs';
|
|
20
|
-
import { statementCard } from '../
|
|
20
|
+
import { statementCard } from '../cards.mjs';
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
// The production-dataset rules claim more than the name rule: that the job acts on production, not
|
|
@@ -161,7 +161,7 @@ function productionTouches(job, qualifiers) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
export function scanRecon(root, opts = {}) {
|
|
164
|
-
const site = loadSite(root, opts.site || null);
|
|
164
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
165
165
|
const tree = treeFor(root, opts);
|
|
166
166
|
const files = tree.list().filter(inScope(opts))
|
|
167
167
|
.filter((f) => isJcl(f) || isProgram(f) || isCopybook(f));
|
package/lib/sets/semantics.mjs
CHANGED
|
@@ -216,7 +216,7 @@ export function scanSemantics(root, opts = {}) {
|
|
|
216
216
|
truncOptPrograms: 0, truncUnknownPrograms: 0, arithExtendPrograms: 0, beyondEnterprisePrograms: 0, computesRead: 0, computesUnread: 0,
|
|
217
217
|
rangesRead: 0, collatingDeclaredPrograms: 0,
|
|
218
218
|
};
|
|
219
|
-
const site = loadSite(root, opts.site || null).compilerOptions || [];
|
|
219
|
+
const site = loadSite(root, opts.site || null, opts.tree).compilerOptions || [];
|
|
220
220
|
const parsedJcl = [];
|
|
221
221
|
for (const f of all.filter(isJcl)) {
|
|
222
222
|
try { parsedJcl.push({ ...parseJcl(tree.text(f).text, f), file: relPath(root, f) }); } catch { /* the jcl set reports it */ }
|
package/lib/sets/vendor.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export const VENDOR_RULES = {
|
|
|
26
26
|
const evidenceOf = (r) => (r.severity === 'info' ? { evidence: 'context' } : {});
|
|
27
27
|
|
|
28
28
|
export function scanVendor(root, opts = {}) {
|
|
29
|
-
const site = loadSite(root, opts.site || null);
|
|
29
|
+
const site = loadSite(root, opts.site || null, opts.tree);
|
|
30
30
|
const wanted = opts.packs || site.vendorPacks || [];
|
|
31
31
|
const { loaded, refused, problems, caveats } = loadPacks(wanted, {
|
|
32
32
|
allowUnvalidated: !!(opts.allowUnvalidatedPacks ?? site.allowUnvalidatedPacks),
|
package/lib/site.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// It is also the only part of cobolwork that is inherently per-customer and inherently recurring:
|
|
10
10
|
// conventions drift, environments are renamed, estates are acquired.
|
|
11
11
|
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
13
|
import { resolvesInside } from './kernel/source-tree.mjs';
|
|
14
14
|
import { printable } from './kernel/printable.mjs';
|
|
15
15
|
|
|
@@ -43,14 +43,24 @@ const EMPTY = {
|
|
|
43
43
|
// A configuration that is absent is not a configuration that says "nothing is production". The
|
|
44
44
|
// difference is the whole point: without it the recon rules have not looked, and a scan must say
|
|
45
45
|
// it has not looked rather than report a clean result.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
// `tree` is the scan's source tree: one held in memory (a git revision) supplies the site file
|
|
47
|
+
// itself, and on disk the file is read from `root`.
|
|
48
|
+
export function loadSite(root, explicit = null, tree = null) {
|
|
49
|
+
const held = !explicit && tree && tree.kind !== 'directory';
|
|
50
|
+
const path = explicit || (held ? resolve(tree.root, SITE_FILE) : join(root, SITE_FILE));
|
|
51
|
+
let read;
|
|
52
|
+
if (held) {
|
|
53
|
+
if (!tree.contains(path)) return { ...EMPTY, path };
|
|
54
|
+
read = () => tree.bytes(path).toString('utf8');
|
|
55
|
+
} else {
|
|
56
|
+
if (!existsSync(path)) return { ...EMPTY, path };
|
|
57
|
+
// A site file in the tree is not read through a link out of it; one the caller names is.
|
|
58
|
+
if (!explicit && !resolvesInside(root, path)) return { ...EMPTY, path, problems: [`${SITE_FILE} is a link that leads outside the tree, so it was not read`] };
|
|
59
|
+
read = () => readFileSync(path, 'utf8');
|
|
60
|
+
}
|
|
51
61
|
|
|
52
62
|
let raw;
|
|
53
|
-
try { raw = JSON.parse(
|
|
63
|
+
try { raw = JSON.parse(read()); } catch (e) {
|
|
54
64
|
return { ...EMPTY, path, problems: [`${SITE_FILE} is not readable as JSON: ${printable(e.message, 120)}`] };
|
|
55
65
|
}
|
|
56
66
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...EMPTY, path, problems: [`${SITE_FILE} is not a JSON object`] };
|
package/lib/sources.mjs
CHANGED
|
@@ -23,41 +23,90 @@ const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
|
23
23
|
// of it and reporting a clean zero, which is the failure this project exists to refuse.
|
|
24
24
|
//
|
|
25
25
|
// So a file with no extension is classified by what is in it. Only the first few kilobytes are
|
|
26
|
-
// read
|
|
27
|
-
//
|
|
26
|
+
// read and only files without an extension are sniffed. The tree a path belongs to classifies it
|
|
27
|
+
// and keeps the answer for as long as the tree lives: this runs over every file in the tree and
|
|
28
|
+
// must not cost a parse, and a cache that outlives the scan grows by one entry per file for the
|
|
29
|
+
// life of the process.
|
|
28
30
|
const SNIFF_BYTES = 4096;
|
|
29
|
-
const sniffed = new Map();
|
|
30
31
|
|
|
31
32
|
// Names that are certainly not source, so the commonest extensionless files cost nothing.
|
|
32
33
|
const NOT_SOURCE = /^(LICEN[CS]E|README|MAKEFILE|DOCKERFILE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS|VERSION|COPYING|Jenkinsfile|Procfile|\..*)$/i;
|
|
34
|
+
const notSource = (path) => NOT_SOURCE.test(path.split(/[\\/]/).pop());
|
|
35
|
+
|
|
36
|
+
function kindOfHead(head) {
|
|
37
|
+
// An EBCDIC member is decoded before it is read, or every test below sees mojibake.
|
|
38
|
+
const text = looksEbcdic(head) ? decodeEbcdic(head) : head.toString('latin1');
|
|
39
|
+
// A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
|
|
40
|
+
// NUL in source is exactly the character an editor or a patch is most likely to eat.
|
|
41
|
+
if (head.includes(0)) return null;
|
|
42
|
+
if (/^\s*\/\/\S*\s+(?:JOB|EXEC|PROC|DD)\b/m.test(text)) return 'jcl';
|
|
43
|
+
if (/\bIDENTIFICATION\s+DIVISION|\bPROGRAM-ID\s*\./i.test(text)) return 'program';
|
|
44
|
+
// A copybook is a data description with no divisions: level numbers and picture clauses.
|
|
45
|
+
if (/^[^*\n]{0,10}\s*\d\d\s+[A-Z][A-Z0-9-]*\s+(?:PIC|PICTURE|OCCURS|REDEFINES|USAGE)\b/im.test(text)) return 'copybook';
|
|
46
|
+
// A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
|
|
47
|
+
// classified before classifies differently.
|
|
48
|
+
if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
// What a member's bytes say it is, for a tree that holds them.
|
|
53
|
+
export const kindOfBytes = (buf) => kindOfHead(buf.subarray(0, SNIFF_BYTES));
|
|
54
|
+
|
|
55
|
+
function sniffDisk(path) {
|
|
56
|
+
if (notSource(path)) return null;
|
|
57
|
+
try {
|
|
58
|
+
const fd = openSync(path, 'r');
|
|
59
|
+
const buf = Buffer.alloc(SNIFF_BYTES);
|
|
60
|
+
let n;
|
|
61
|
+
try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
|
|
62
|
+
return kindOfHead(buf.subarray(0, n));
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A tree's classifier for the paths under its root, remembered for as long as the tree lives.
|
|
67
|
+
const classifiers = new Map();
|
|
68
|
+
|
|
69
|
+
export const classifyUnder = (root, kindOf) => classifiers.set(root.replace(/[\\/]+$/, ''), new WeakRef(kindOf));
|
|
70
|
+
|
|
71
|
+
// The classifier of the deepest live tree whose root the path sits under.
|
|
72
|
+
function classifierFor(path) {
|
|
73
|
+
let best = null;
|
|
74
|
+
let depth = -1;
|
|
75
|
+
for (const [root, ref] of classifiers) {
|
|
76
|
+
if (root.length <= depth || !path.startsWith(root + sep)) continue;
|
|
77
|
+
const kindOf = ref.deref();
|
|
78
|
+
if (!kindOf) { classifiers.delete(root); continue; }
|
|
79
|
+
best = kindOf;
|
|
80
|
+
depth = root.length;
|
|
58
81
|
}
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
return best;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A directory tree's classifier: sniffed from disk once per path.
|
|
86
|
+
export function diskClassifier() {
|
|
87
|
+
const seen = new Map();
|
|
88
|
+
return (path) => {
|
|
89
|
+
if (!seen.has(path)) seen.set(path, sniffDisk(path));
|
|
90
|
+
return seen.get(path);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A held tree's classifier: sniffed from what the tree holds, never from disk.
|
|
95
|
+
export function heldClassifier(bytesOf) {
|
|
96
|
+
const seen = new Map();
|
|
97
|
+
return (path) => {
|
|
98
|
+
if (!seen.has(path)) {
|
|
99
|
+
let kind = null;
|
|
100
|
+
if (!notSource(path)) try { kind = kindOfBytes(bytesOf(path)); } catch { kind = null; }
|
|
101
|
+
seen.set(path, kind);
|
|
102
|
+
}
|
|
103
|
+
return seen.get(path);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sniffKind(path) {
|
|
108
|
+
const kindOf = classifierFor(path);
|
|
109
|
+
return kindOf ? kindOf(path) : sniffDisk(path);
|
|
61
110
|
}
|
|
62
111
|
|
|
63
112
|
// Extension first, because it is free and it is right whenever it is present.
|
|
@@ -72,13 +121,6 @@ export const isSource = (p) => (extname(p)
|
|
|
72
121
|
? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
|
|
73
122
|
: ![null, 'bms'].includes(sniffKind(p)));
|
|
74
123
|
|
|
75
|
-
// For a report that wants to say how much of what it read had no extension to go on.
|
|
76
|
-
export const sniffedCount = () => {
|
|
77
|
-
let n = 0;
|
|
78
|
-
for (const v of sniffed.values()) if (v) n++;
|
|
79
|
-
return n;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
124
|
// `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
|
|
83
125
|
// files on it and no others.
|
|
84
126
|
//
|
|
@@ -159,8 +201,10 @@ export function decodeEbcdic(buf) {
|
|
|
159
201
|
|
|
160
202
|
// Every rule set reads source through here, so an EBCDIC member is decoded once, the same way,
|
|
161
203
|
// and reported as EBCDIC instead of silently parsing as noise.
|
|
162
|
-
export
|
|
163
|
-
|
|
204
|
+
export const readSource = (path) => decodeSource(readFileSync(path));
|
|
205
|
+
|
|
206
|
+
// The same decoding for bytes that did not come from a file: a blob read out of a git revision.
|
|
207
|
+
export function decodeSource(buf) {
|
|
164
208
|
if (looksEbcdic(buf)) return { text: decodeEbcdic(buf), encoding: 'ebcdic' };
|
|
165
209
|
return { text: buf.toString('latin1'), encoding: 'latin1' };
|
|
166
210
|
}
|
package/lib/utilities.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portll/cobolwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"author": "Portll <john@portll.net>",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=22"
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
20
|
"cobolwork": "bin/cobolwork.mjs"
|