@portll/cobolwork 0.3.0 → 0.4.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 +19 -9
- package/lib/cics-commands.mjs +308 -42
- package/lib/consequence.mjs +6 -0
- package/lib/dataflow.mjs +54 -23
- package/lib/explain.mjs +31 -5
- package/lib/exploitability.mjs +49 -4
- package/lib/gate.mjs +31 -9
- package/lib/kernel/findings.mjs +2 -0
- package/lib/kernel/registry.mjs +2 -0
- package/lib/kernel/source-tree.mjs +1 -0
- package/lib/options.mjs +6 -2
- package/lib/packs.mjs +4 -0
- package/lib/parser.mjs +67 -12
- package/lib/precompile-cics.mjs +9 -3
- package/lib/precompile.mjs +3 -2
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +3 -2
- package/lib/scan.mjs +2 -1
- package/lib/sets/abend.mjs +148 -0
- package/lib/sets/compile.mjs +2 -1
- package/lib/sets/hidden.mjs +1 -1
- package/lib/sets/jcl.mjs +1 -0
- package/lib/sets/recon.mjs +1 -0
- package/lib/utilities.mjs +2 -0
- package/lib/verify.mjs +2 -1
- package/lib/words.mjs +377 -17
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +48 -0
- package/rules/compliance-dora.json +52 -0
- package/rules/compliance-ffiec.json +52 -0
- package/rules/compliance-nist80053.json +52 -0
package/lib/dataflow.mjs
CHANGED
|
@@ -87,6 +87,7 @@ function literalNames(prog) {
|
|
|
87
87
|
// The value of the last NAME(value) option in a list: the last one wins, at either level.
|
|
88
88
|
function lastOption(name, list) {
|
|
89
89
|
for (let k = (list || []).length - 1; k >= 0; k--) {
|
|
90
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- name is a literal option name
|
|
90
91
|
const m = new RegExp(`^${name}\\(([A-Z]+)\\)$`).exec(list[k]);
|
|
91
92
|
if (m) return m[1];
|
|
92
93
|
}
|
|
@@ -215,11 +216,13 @@ export function analyze(root, opts = {}) {
|
|
|
215
216
|
const rel = (p) => { let r = relOf.get(p); if (r === undefined) { r = relPath(root, p); relOf.set(p, r); } return r; };
|
|
216
217
|
// `at` is the statement an edge that is not a statement's own is read at: the CALL or LINK that
|
|
217
218
|
// passes an argument, the PUT or GET of a container.
|
|
218
|
-
|
|
219
|
+
// `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }).
|
|
220
|
+
const edge = (a, b, why, dir, at, link) => {
|
|
219
221
|
if (!a || !b || a === b) return;
|
|
220
|
-
a.edgesOut.push(
|
|
222
|
+
a.edgesOut.push({ to: b, why, dir, ...(at != null ? { at } : {}), ...link });
|
|
221
223
|
stats.edges++;
|
|
222
224
|
};
|
|
225
|
+
let callSites = 0;
|
|
223
226
|
|
|
224
227
|
// Everything the graph needs from one program is taken while its parse tree is in hand, and the
|
|
225
228
|
// tree is then released. Holding every tree until the end is what exhausted a 4 GB heap on an
|
|
@@ -236,7 +239,9 @@ export function analyze(root, opts = {}) {
|
|
|
236
239
|
let repeats = false;
|
|
237
240
|
let table = null;
|
|
238
241
|
for (let a = item; a; a = a.parent) if ((a.occurs || 1) > 1) { repeats = true; table = a; }
|
|
239
|
-
|
|
242
|
+
let top = item;
|
|
243
|
+
while (top.parent) top = top.parent;
|
|
244
|
+
n = { id: nodes.length, pk, program: prog.id, file, name: item.name, off: item.offset ?? null, size: item.size ?? null, repeats, edgesOut: [], sources: [], sinks: [], ...(top.section === 'LINKAGE' ? { linkage: true } : {}) };
|
|
240
245
|
// Where the outermost table holding this item sits in the record: every occurrence of the
|
|
241
246
|
// item is somewhere inside it. A group's size already counts its occurrences; an
|
|
242
247
|
// elementary item's is one element.
|
|
@@ -1040,21 +1045,23 @@ export function analyze(root, opts = {}) {
|
|
|
1040
1045
|
for (const p of programs) {
|
|
1041
1046
|
for (const t of p.transfers) {
|
|
1042
1047
|
const r = calleesOf(t.callee, p.file);
|
|
1048
|
+
const site = ++callSites;
|
|
1043
1049
|
for (const callee of r.list) {
|
|
1044
1050
|
if (!callee.commarea) continue;
|
|
1045
|
-
edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point);
|
|
1046
|
-
if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}
|
|
1051
|
+
edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point, { call: site });
|
|
1052
|
+
if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`, undefined, undefined, { ret: site });
|
|
1047
1053
|
}
|
|
1048
1054
|
}
|
|
1049
1055
|
for (const c of p.callSites) {
|
|
1050
1056
|
const r = calleesOf(c.name, p.file);
|
|
1057
|
+
const site = ++callSites;
|
|
1051
1058
|
for (const callee of r.list) {
|
|
1052
1059
|
if (!callee.params) continue;
|
|
1053
1060
|
c.args.forEach((arg, i) => {
|
|
1054
1061
|
const param = callee.params[i];
|
|
1055
1062
|
if (!param || !arg.node || !param.node) return;
|
|
1056
|
-
edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point);
|
|
1057
|
-
if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}
|
|
1063
|
+
edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point, { call: site });
|
|
1064
|
+
if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`, undefined, undefined, { ret: site });
|
|
1058
1065
|
});
|
|
1059
1066
|
}
|
|
1060
1067
|
const callee = r.list.length === 1 ? r.list[0] : null;
|
|
@@ -1347,6 +1354,7 @@ export function analyze(root, opts = {}) {
|
|
|
1347
1354
|
// last moved - at a point where the credit reaches `level`.
|
|
1348
1355
|
function refuse(start, reach, credited, level, kind, shift = 0) {
|
|
1349
1356
|
walk++;
|
|
1357
|
+
stampCtx.clear();
|
|
1350
1358
|
const want = credited.filter((c) => c.level >= level && c.sink.kind === kind && (c.sink.offset || 0) === shift);
|
|
1351
1359
|
// Past the budget no route can be ruled out, so every credit in question is lost: a finding
|
|
1352
1360
|
// may be reported unchecked, never dropped.
|
|
@@ -1380,17 +1388,15 @@ export function analyze(root, opts = {}) {
|
|
|
1380
1388
|
// stand in for it.
|
|
1381
1389
|
const guarded = since.some((n) => n.checks || n.guard);
|
|
1382
1390
|
if (t.partial) {
|
|
1383
|
-
const key = `${to.id}|${guarded}`;
|
|
1391
|
+
const key = `${to.id}|${guarded}|${t.ctx}`;
|
|
1384
1392
|
const held = parts.get(key);
|
|
1385
1393
|
if (held && held.some((r) => (r[2] ? t.vague : !t.vague && r[0] <= t.lo && t.hi <= r[1]))) continue;
|
|
1386
1394
|
if (held) held.push([t.lo, t.hi, t.vague]); else parts.set(key, [[t.lo, t.hi, t.vague]]);
|
|
1387
1395
|
} else if (guarded) {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
stampWhole[to.id] = walk;
|
|
1393
|
-
}
|
|
1396
|
+
const dk = `${to.id}|${t.ctx}`;
|
|
1397
|
+
if (stampWhole[to.id] === walk || dirty.get(dk) === walk) continue;
|
|
1398
|
+
dirty.set(dk, walk);
|
|
1399
|
+
} else if (!firstVisit(to.id, t.ctx)) continue;
|
|
1394
1400
|
open.push({ node: to, ...t, since });
|
|
1395
1401
|
}
|
|
1396
1402
|
if (edgesWalked - began > walkBudget) { stats.creditUndecided++; return new Set(want); }
|
|
@@ -1409,6 +1415,16 @@ export function analyze(root, opts = {}) {
|
|
|
1409
1415
|
// Visit stamps instead of a per-walk Set: one walk per source, and a Set of string keys per walk
|
|
1410
1416
|
// was most of the time spent on large repositories.
|
|
1411
1417
|
const stampWhole = new Int32Array(nodes.length);
|
|
1418
|
+
// A whole node reached with a call context, per walk. Reached with none, it stands in for every context.
|
|
1419
|
+
const stampCtx = new Map();
|
|
1420
|
+
const firstVisit = (id, ctx) => {
|
|
1421
|
+
if (stampWhole[id] === walk) return false;
|
|
1422
|
+
if (!ctx) { stampWhole[id] = walk; return true; }
|
|
1423
|
+
const key = `${id}|${ctx}`;
|
|
1424
|
+
if (stampCtx.get(key) === walk) return false;
|
|
1425
|
+
stampCtx.set(key, walk);
|
|
1426
|
+
return true;
|
|
1427
|
+
};
|
|
1412
1428
|
let walk = 0;
|
|
1413
1429
|
// Every walk is bounded, and so is the analysis. One source of an estate whose programs share
|
|
1414
1430
|
// their records through thousands of copybooks reached millions of nodes, and 22,905 such walks
|
|
@@ -1425,9 +1441,25 @@ export function analyze(root, opts = {}) {
|
|
|
1425
1441
|
// it does not. Both walks below cross edges through this, so neither can take a route the other
|
|
1426
1442
|
// would refuse.
|
|
1427
1443
|
const known = (n) => n.off != null && n.size != null && !n.repeats;
|
|
1444
|
+
// The call sites a value entered its callees through, innermost last, as a string; empty where any
|
|
1445
|
+
// caller may be returned to: a value born in the callee, or one that rested in storage a program
|
|
1446
|
+
// keeps between calls, which a later call from any caller can read.
|
|
1447
|
+
const CONTEXT_DEPTH = 4;
|
|
1448
|
+
function contextOver(state, e) {
|
|
1449
|
+
const ctx = state.ctx || '';
|
|
1450
|
+
if (e.ret != null) {
|
|
1451
|
+
if (!ctx) return '';
|
|
1452
|
+
const sites = ctx.split('/');
|
|
1453
|
+
return Number(sites[sites.length - 1]) === e.ret ? sites.slice(0, -1).join('/') : null;
|
|
1454
|
+
}
|
|
1455
|
+
if (e.call != null) return [...(ctx ? ctx.split('/') : []), String(e.call)].slice(-CONTEXT_DEPTH).join('/');
|
|
1456
|
+
return e.to.linkage ? ctx : '';
|
|
1457
|
+
}
|
|
1428
1458
|
function cross(state, e) {
|
|
1429
1459
|
const cur = state.node;
|
|
1430
1460
|
const to = e.to;
|
|
1461
|
+
const ctx = contextOver(state, e);
|
|
1462
|
+
if (ctx === null) return null;
|
|
1431
1463
|
let partial = false, lo = 0, hi = 0, vague = false;
|
|
1432
1464
|
if (opts.legacyGroupEdges) { /* every edge carries the whole item */ }
|
|
1433
1465
|
else if (e.dir === 'up') {
|
|
@@ -1464,7 +1496,7 @@ export function analyze(root, opts = {}) {
|
|
|
1464
1496
|
if (!vague && to.size != null) { hi = Math.min(hi, to.size); if (hi <= lo) return null; }
|
|
1465
1497
|
}
|
|
1466
1498
|
}
|
|
1467
|
-
return { partial, lo, hi, vague };
|
|
1499
|
+
return { partial, lo, hi, vague, ctx };
|
|
1468
1500
|
}
|
|
1469
1501
|
for (const start of nodes) {
|
|
1470
1502
|
if (!start.sources.length || !canReach[start.id]) continue;
|
|
@@ -1473,6 +1505,7 @@ export function analyze(root, opts = {}) {
|
|
|
1473
1505
|
if (!(canReach[start.id] & reach)) continue;
|
|
1474
1506
|
if (edgesWalked >= totalBudget) { stats.sourcesNotWalked++; continue; }
|
|
1475
1507
|
walk++;
|
|
1508
|
+
stampCtx.clear();
|
|
1476
1509
|
const credited = [];
|
|
1477
1510
|
// A state is a node plus which of its bytes are tainted. Going up from a child taints only
|
|
1478
1511
|
// that child's bytes of the group; going down reaches a child only if those bytes overlap it,
|
|
@@ -1539,16 +1572,14 @@ export function analyze(root, opts = {}) {
|
|
|
1539
1572
|
const to = e.to;
|
|
1540
1573
|
const crossed = cross(state, e);
|
|
1541
1574
|
if (!crossed) continue;
|
|
1542
|
-
const { partial, lo, hi, vague } = crossed;
|
|
1575
|
+
const { partial, lo, hi, vague, ctx } = crossed;
|
|
1543
1576
|
if (partial) {
|
|
1544
|
-
const
|
|
1577
|
+
const pk = `${to.id}|${ctx}`;
|
|
1578
|
+
const held = partSeen.get(pk);
|
|
1545
1579
|
if (held && held.some(r => (r[2] ? vague : !vague && r[0] <= lo && hi <= r[1]))) continue;
|
|
1546
|
-
if (held) held.push([lo, hi, vague]); else partSeen.set(
|
|
1547
|
-
} else
|
|
1548
|
-
|
|
1549
|
-
stampWhole[to.id] = walk;
|
|
1550
|
-
}
|
|
1551
|
-
queue.push({ node: to, partial, lo, hi, vague, prev: state, why: e.why, dir: e.dir, point: pointOfEdge(e), moves: moves(e) });
|
|
1580
|
+
if (held) held.push([lo, hi, vague]); else partSeen.set(pk, [[lo, hi, vague]]);
|
|
1581
|
+
} else if (!firstVisit(to.id, ctx)) continue;
|
|
1582
|
+
queue.push({ node: to, partial, lo, hi, vague, ctx, prev: state, why: e.why, dir: e.dir, point: pointOfEdge(e), moves: moves(e) });
|
|
1552
1583
|
}
|
|
1553
1584
|
}
|
|
1554
1585
|
if (cut) stats.walksCut++;
|
package/lib/explain.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { resolve, relative, sep, dirname } from 'node:path';
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
4
|
import { readSource, isProgram, isCopybook, isJcl } from './sources.mjs';
|
|
5
|
-
import { parseFile, buildFileIndex, detectFormat } from './parser.mjs';
|
|
5
|
+
import { parseFile, buildFileIndex, detectFormat, normalize, tokenize, segmentEnd } from './parser.mjs';
|
|
6
6
|
import { EVIDENCE } from './kernel/findings.mjs';
|
|
7
7
|
import { REGISTRY } from './kernel/registry.mjs';
|
|
8
8
|
import { WHO_ACTS } from './tui/model.mjs';
|
|
@@ -34,6 +34,21 @@ function contained(root, rel) {
|
|
|
34
34
|
return { abs };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// The last line of the COBOL statement that starts on `line`, its period or END- terminator included,
|
|
38
|
+
// so a drafter shown one line of a statement knows where the statement ends. Past MAX_EXTENT lines
|
|
39
|
+
// nothing is claimed.
|
|
40
|
+
const MAX_EXTENT = 30;
|
|
41
|
+
function statementEnd(file, line) {
|
|
42
|
+
file.tokens ??= (() => { try { return tokenize(normalize(file.text, file.format), '').tokens; } catch { return []; } })();
|
|
43
|
+
const t = file.tokens;
|
|
44
|
+
const i = t.findIndex((x) => x.line === line);
|
|
45
|
+
if (i < 0 || t[i].t !== 'word') return line;
|
|
46
|
+
const e = segmentEnd(t, i, t.length);
|
|
47
|
+
const next = t[e];
|
|
48
|
+
const end = next && next.line >= line && (next.t === 'period' || (next.t === 'word' && next.u === `END-${t[i].u}`)) ? next.line : t[e - 1].line;
|
|
49
|
+
return end > line && end - line < MAX_EXTENT ? end : line;
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
// A hop's location: from `via` ("MOVE at pos/P1.cbl:9"), else the related entry for its file.
|
|
38
53
|
function hopAt(hop, finding) {
|
|
39
54
|
const m = /\bat (.+):(\d+)$/.exec(hop.via || '');
|
|
@@ -62,7 +77,7 @@ export function explainFinding(report, fingerprint, { root }) {
|
|
|
62
77
|
else {
|
|
63
78
|
try {
|
|
64
79
|
const text = readSource(c.abs).text;
|
|
65
|
-
file = { kind: kindOf(c.abs), format: detectFormat(text), lines: text.split(/\r?\n/) };
|
|
80
|
+
file = { kind: kindOf(c.abs), format: detectFormat(text), text, lines: text.split(/\r?\n/) };
|
|
66
81
|
} catch (e) { unread.push({ path, why: `it could not be read: ${e.message}` }); }
|
|
67
82
|
}
|
|
68
83
|
files.set(path, file);
|
|
@@ -72,15 +87,26 @@ export function explainFinding(report, fingerprint, { root }) {
|
|
|
72
87
|
for (const x of [...report.findings, ...(report.suppressed || [])]) {
|
|
73
88
|
if (HIDDEN_RULES.has(x.rule) && x.path && x.line) flagged.set(`${x.path}:${x.line}`, x.rule);
|
|
74
89
|
}
|
|
75
|
-
const
|
|
90
|
+
const quoteLine = (path, file, line) => {
|
|
76
91
|
const withheld = flagged.get(`${path}:${line}`);
|
|
77
92
|
if (withheld) return { code: null, withheld };
|
|
78
|
-
const file = fileOf(path);
|
|
79
|
-
if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
|
|
80
93
|
const area = codeArea(file.lines[line - 1], file.kind, file.format);
|
|
81
94
|
const dropped = area.rest.trim() !== '' || /[^\d\s]/.test(area.seq);
|
|
82
95
|
return { code: area.text.trimEnd(), ...(dropped ? { dropped } : {}) };
|
|
83
96
|
};
|
|
97
|
+
// A statement that runs on past its first line carries endLine and the lines after the first.
|
|
98
|
+
const quote = (path, line) => {
|
|
99
|
+
const withheld = flagged.get(`${path}:${line}`);
|
|
100
|
+
if (withheld) return { code: null, withheld };
|
|
101
|
+
const file = fileOf(path);
|
|
102
|
+
if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
|
|
103
|
+
const first = quoteLine(path, file, line);
|
|
104
|
+
const endLine = file.kind === 'cobol' ? statementEnd(file, line) : line;
|
|
105
|
+
if (endLine === line) return first;
|
|
106
|
+
const rest = [];
|
|
107
|
+
for (let n = line + 1; n <= endLine; n++) rest.push({ line: n, ...quoteLine(path, file, n) });
|
|
108
|
+
return { ...first, endLine, rest };
|
|
109
|
+
};
|
|
84
110
|
|
|
85
111
|
const hops = (f.trace || []).map((h, i) => {
|
|
86
112
|
if (h.elided) return { n: i + 1, elided: h.elided, via: h.via };
|
package/lib/exploitability.mjs
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// running system confirms one, so a verdict says confirmed only where the estate brings the result
|
|
7
7
|
// of its own test as a witness feed. See docs/spec/reach.md §9.
|
|
8
8
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
9
|
-
import { basename, sep, delimiter } from 'node:path';
|
|
9
|
+
import { basename, dirname, join, resolve, sep, delimiter } from 'node:path';
|
|
10
10
|
import { ALL_RULES } from './kernel/registry.mjs';
|
|
11
11
|
import { EXPLOITABILITY } from './kernel/findings.mjs';
|
|
12
12
|
import { kindsOf } from './consequence.mjs';
|
|
13
13
|
import { WHO } from './sets/flow.mjs';
|
|
14
14
|
import { entryName, accessOfEntry, accessFromEntry, privilegedFromEntry } from './reach.mjs';
|
|
15
|
+
import { verifyEvidence } from './evidence/verify.mjs';
|
|
15
16
|
|
|
16
17
|
export const HANDLING = 'This report joins routes an attacker can drive with the estate\'s own access facts. It holds no source '
|
|
17
18
|
+ 'text, but it is a list of what to attack first: keep it where the access records it was built from may go, and do not '
|
|
@@ -62,9 +63,22 @@ const WITHOUT_A_TEST = {
|
|
|
62
63
|
// response code is not input, so no test on it clears a route; the fix is not to send it.
|
|
63
64
|
function fixAt(f, kinds) {
|
|
64
65
|
const last = f.trace?.length ? f.trace[f.trace.length - 1] : null;
|
|
66
|
+
const instead = WITHOUT_A_TEST[kinds.sink];
|
|
67
|
+
// A value a STRING built holds the input as one part among literals, where no allow-list or class
|
|
68
|
+
// test describes the whole. The test belongs on the field folded in, before the STRING.
|
|
69
|
+
const built = f.trace ? f.trace.findLastIndex((h) => /^STRING at /.test(h.via || '')) : -1;
|
|
70
|
+
const part = built > 0 ? f.trace[built - 1] : null;
|
|
71
|
+
const where = part?.item && /\bat (.+):(\d+)$/.exec(f.trace[built].via);
|
|
72
|
+
if (where && kinds.source !== 'system-response' && TESTS[kinds.sink]) {
|
|
73
|
+
const [, path, line] = where;
|
|
74
|
+
return {
|
|
75
|
+
program: f.trace[built].program || f.program || null, path, line: Number(line), item: part.item,
|
|
76
|
+
builtInto: { item: last.item, path: f.path, line: f.line },
|
|
77
|
+
test: `before the STRING at ${path}:${line} that builds ${last.item}, on every route to it, test ${TESTS[kinds.sink](part.item)}, and let only a value that passes reach it${instead ? `; ${instead} needs no test` : ''}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
65
80
|
const item = last?.item || 'the value';
|
|
66
81
|
const test = kinds.source === 'system-response' ? null : TESTS[kinds.sink]?.(item) || null;
|
|
67
|
-
const instead = WITHOUT_A_TEST[kinds.sink];
|
|
68
82
|
return {
|
|
69
83
|
program: f.program || null, path: f.path, line: f.line, item,
|
|
70
84
|
...(test ? { test: `before the operation at ${f.path}:${f.line}, on every route to it, test ${test}, and let only a value that passes reach it${instead ? `; ${instead} needs no test` : ''}` }
|
|
@@ -156,6 +170,28 @@ const OUTCOMES = new Set(['reproduced', 'not-reproduced']);
|
|
|
156
170
|
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
157
171
|
const isoDate = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
158
172
|
|
|
173
|
+
// What an ironwork run a result cites shows: each operation the marker reached and the abend the run
|
|
174
|
+
// ended with, from a journal in an evidence directory that verifies. A string says why it shows nothing.
|
|
175
|
+
function witnessedRun(ev, base, verdicts) {
|
|
176
|
+
if (!ev || typeof ev !== 'object' || !str(ev.dir) || !str(ev.run)) return 'cites evidence without a directory and a run';
|
|
177
|
+
if (!/^[0-9TZ]+-[0-9a-f]{16}$/.test(ev.run)) return `cites run ${ev.run}, which is not a run id`;
|
|
178
|
+
const dir = resolve(base, ev.dir);
|
|
179
|
+
if (!verdicts.has(dir)) verdicts.set(dir, verifyEvidence(dir));
|
|
180
|
+
const v = verdicts.get(dir);
|
|
181
|
+
if (!v.verified) return `cites evidence in ${ev.dir} that does not verify`;
|
|
182
|
+
if (v.unrecorded.includes(ev.run)) return `cites run ${ev.run}, which the ledger in ${ev.dir} does not record`;
|
|
183
|
+
let text;
|
|
184
|
+
try { text = readFileSync(join(dir, 'runs', `${ev.run}.jsonl`), 'utf8'); } catch { return `cites run ${ev.run}, which ${ev.dir} does not hold`; }
|
|
185
|
+
const records = [];
|
|
186
|
+
for (const line of text.split('\n')) {
|
|
187
|
+
if (!line) continue;
|
|
188
|
+
const rec = JSON.parse(line);
|
|
189
|
+
if (rec.kind === 'sink' && rec.reached === true) records.push({ kind: 'sink', sink: rec.sink, file: rec.file, line: rec.line });
|
|
190
|
+
else if (rec.kind === 'abend' && str(rec.file) && Number.isInteger(rec.line)) records.push({ kind: 'abend', code: rec.code, file: rec.file, line: rec.line });
|
|
191
|
+
}
|
|
192
|
+
return records.length ? { run: ev.run, records } : `cites run ${ev.run}, whose journal shows no operation the marker reached and no abend at a line`;
|
|
193
|
+
}
|
|
194
|
+
|
|
159
195
|
// The results of the estate's own tests, keyed by the fingerprint of the finding each tried, loaded
|
|
160
196
|
// for one scan and refused from inside the scanned tree the way a reach feed is: a list of what was
|
|
161
197
|
// reproduced is a list of what to attack. Shape:
|
|
@@ -176,6 +212,7 @@ export function loadWitnessFeed(path, { root = null } = {}) {
|
|
|
176
212
|
if (!doc.results || typeof doc.results !== 'object' || Array.isArray(doc.results)) { feed.problem = 'holds no results'; return feed; }
|
|
177
213
|
feed.witness = doc.witness;
|
|
178
214
|
feed.recorded = doc.recorded;
|
|
215
|
+
const verdicts = new Map();
|
|
179
216
|
for (const [fingerprint, r] of Object.entries(doc.results)) {
|
|
180
217
|
const why = !r || typeof r !== 'object' ? 'is not an object'
|
|
181
218
|
: !OUTCOMES.has(r.outcome) ? `says outcome ${JSON.stringify(r.outcome)}, not "reproduced" or "not-reproduced"`
|
|
@@ -183,7 +220,9 @@ export function loadWitnessFeed(path, { root = null } = {}) {
|
|
|
183
220
|
: !isoDate(r.on) ? 'does not say when the test ran'
|
|
184
221
|
: !str(r.system) ? 'does not say which system it ran on' : null;
|
|
185
222
|
if (why) { feed.refused.push({ fingerprint, why: `the result for ${fingerprint} ${why}` }); continue; }
|
|
186
|
-
|
|
223
|
+
const evidence = r.evidence === undefined ? null : witnessedRun(r.evidence, dirname(real), verdicts);
|
|
224
|
+
if (typeof evidence === 'string') { feed.refused.push({ fingerprint, why: `the result for ${fingerprint} ${evidence}` }); continue; }
|
|
225
|
+
feed.results.set(fingerprint, { outcome: r.outcome, by: r.by, on: r.on, system: r.system, ...(str(r.reference) ? { reference: r.reference } : {}), ...(evidence ? { evidence } : {}) });
|
|
187
226
|
}
|
|
188
227
|
return feed;
|
|
189
228
|
}
|
|
@@ -214,8 +253,11 @@ export function applyWitness(findings, checked, feeds) {
|
|
|
214
253
|
const x = f.exploitability;
|
|
215
254
|
const hit = x && f.fingerprint ? newest(good, f.fingerprint) : null;
|
|
216
255
|
if (!hit) return false;
|
|
217
|
-
matched.add(f.fingerprint);
|
|
218
256
|
const { r, feed } = hit;
|
|
257
|
+
// A cited run witnesses this finding only where its journal places the operation or the abend at the finding's line.
|
|
258
|
+
const at = r.evidence ? r.evidence.records.find((e) => e.line === f.line && basename(e.file) === basename(f.path)) : null;
|
|
259
|
+
if (r.evidence && !at) return false;
|
|
260
|
+
matched.add(f.fingerprint);
|
|
219
261
|
const cite = `${feed.file} (${feed.witness}, recorded ${feed.recorded})`;
|
|
220
262
|
x.witness = { ...r, from: cite };
|
|
221
263
|
byWitness[r.outcome]++;
|
|
@@ -227,6 +269,9 @@ export function applyWitness(findings, checked, feeds) {
|
|
|
227
269
|
const was = x.verdict;
|
|
228
270
|
x.verdict = 'confirmed';
|
|
229
271
|
x.because.push(`reproduced on ${r.system} by ${r.by} on ${r.on}${r.reference ? ` (${r.reference})` : ''}, per ${cite}`);
|
|
272
|
+
if (at) x.because.push(at.kind === 'sink'
|
|
273
|
+
? `run ${r.evidence.run}'s verified journal records the marker reaching the ${at.sink} operation at ${at.file}:${at.line}`
|
|
274
|
+
: `run ${r.evidence.run}'s verified journal records the run ending with ${at.code} at ${at.file}:${at.line}`);
|
|
230
275
|
delete x.unknown;
|
|
231
276
|
if (!x.fixAt) x.fixAt = fixAt(f, kindsOf(f.rule));
|
|
232
277
|
if (f.guardedFrom) f.sev = f.guardedFrom;
|
package/lib/gate.mjs
CHANGED
|
@@ -20,7 +20,9 @@ const MAX_EDIT = 2000;
|
|
|
20
20
|
const MAX_LINES = 200000;
|
|
21
21
|
const MAX_COMPILES = 50;
|
|
22
22
|
const COMPILE_BUDGET_MS = 10 * 60 * 1000;
|
|
23
|
-
|
|
23
|
+
// A fix keeps the program's statements and stops the route: deleting the flagged statement or the
|
|
24
|
+
// source statement removes what the program did, and fails.
|
|
25
|
+
const PASSING = new Set(['cleared-by-check', 'no-longer-fires']);
|
|
24
26
|
const UNDECIDED = new Set(['lowered-by-check', 'gone-unexplained']);
|
|
25
27
|
|
|
26
28
|
const pathText = (p) => printable(String(p ?? ''), 120);
|
|
@@ -115,15 +117,18 @@ export function findCobc({ explicit = null, repo, env = process.env, platform =
|
|
|
115
117
|
return found.path ? found : { path: null, why: `--cobc ${pathText(resolve(explicit))} is not a file outside the repository` };
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
// A compiler run as `cobc -fsyntax-only`, from outside the tree, for a bounded time.
|
|
120
|
+
// A compiler run as `cobc -fsyntax-only`, from outside the tree, for a bounded time. Its error lines
|
|
121
|
+
// are kept so a drafter whose patch stopped a program compiling is told where.
|
|
119
122
|
export function cobcCompiler(cobc) {
|
|
120
123
|
return (file, { includeDirs = [], free = false } = {}) => {
|
|
121
124
|
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;
|
|
125
|
+
{ cwd: tmpdir(), timeout: 60000, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'latin1', maxBuffer: 1 << 20 });
|
|
126
|
+
return { ok: r.status === 0, messages: String(r.stderr || '').split(/\r?\n/).filter((l) => /: error: /.test(l)) };
|
|
124
127
|
};
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
const MAX_COMPILER_LINES = 3;
|
|
131
|
+
|
|
127
132
|
// Level 1 matches across both lists first; each looser level pairs only what the level above left.
|
|
128
133
|
export function pairLists(base, head, baseKeys, headKeys) {
|
|
129
134
|
const headOf = new Array(base.length).fill(-1);
|
|
@@ -222,6 +227,10 @@ function targetReason(t, r) {
|
|
|
222
227
|
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
228
|
case 'program-removed':
|
|
224
229
|
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`;
|
|
230
|
+
case 'statement-removed':
|
|
231
|
+
return `the patch deletes the target's statement at ${where(t)}; a fix keeps the statement and stops the route to it, so deleting it is not a fix the gate accepts`;
|
|
232
|
+
case 'source-removed':
|
|
233
|
+
return `the patch deletes the statement the target's input comes from; a fix keeps it and stops the route from it, so deleting it is not a fix the gate accepts`;
|
|
225
234
|
case 'gone-unexplained':
|
|
226
235
|
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
236
|
default:
|
|
@@ -246,16 +255,29 @@ function compileCheck({ compiler, baseRoot, headRoot, baseProgs, headProgs, chan
|
|
|
246
255
|
try { free = detectFormat(readFileSync(join(root, file), 'latin1')) === 'free'; } catch { /* unreadable: compiled as fixed */ }
|
|
247
256
|
return { includeDirs: [...dirs].sort(), free };
|
|
248
257
|
};
|
|
258
|
+
// A compiler answers true or false, or { ok, messages }.
|
|
259
|
+
const compile = (root, progs, file) => {
|
|
260
|
+
const r = compiler.run(join(root, file), optsFor(root, progs, file));
|
|
261
|
+
return r && typeof r === 'object' ? { ok: !!r.ok, messages: r.messages || [] } : { ok: !!r, messages: [] };
|
|
262
|
+
};
|
|
249
263
|
const regressed = [], neverCompiled = [];
|
|
250
264
|
const deadline = Date.now() + (compiler.budgetMs ?? COMPILE_BUDGET_MS);
|
|
251
265
|
for (const file of [...files].sort()) {
|
|
252
266
|
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 =
|
|
254
|
-
if (now) continue;
|
|
255
|
-
const had = existsSync(join(baseRoot, file)) &&
|
|
256
|
-
(had
|
|
267
|
+
const now = compile(headRoot, headProgs, file);
|
|
268
|
+
if (now.ok) continue;
|
|
269
|
+
const had = existsSync(join(baseRoot, file)) && compile(baseRoot, baseProgs, file).ok;
|
|
270
|
+
if (had) regressed.push({ file, messages: now.messages });
|
|
271
|
+
else neverCompiled.push(file);
|
|
272
|
+
}
|
|
273
|
+
// The compiler names the file by where the head was written; the reader knows it by its path.
|
|
274
|
+
const asInRepo = (m) => m.split(join(headRoot, sep)).join('');
|
|
275
|
+
if (regressed.length) {
|
|
276
|
+
return { ok: false, compiled: `${regressed.length} program(s) compiled before the patch and do not after`, reasons: regressed.flatMap(({ file, messages }) => [
|
|
277
|
+
`${pathText(file)} compiled before the patch and does not after`,
|
|
278
|
+
...messages.slice(0, MAX_COMPILER_LINES).map((m) => `the compiler: ${asInRepo(m).trim()}`),
|
|
279
|
+
]) };
|
|
257
280
|
}
|
|
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
281
|
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
282
|
return { ok: true, compiled: `${files.size} program(s) compile`, reasons: [] };
|
|
261
283
|
}
|
package/lib/kernel/findings.mjs
CHANGED
|
@@ -38,6 +38,7 @@ export const EVIDENCE = Object.freeze({
|
|
|
38
38
|
advisory: 'a component version matches a published advisory against it',
|
|
39
39
|
exposure: 'information about the estate is written into the source',
|
|
40
40
|
change: 'a change moves an interface or adds a call target, which a reviewer has to look at',
|
|
41
|
+
execution: 'a run of the program on a recorded input ended this way, and the run\'s verified journal is the record of it',
|
|
41
42
|
coverage: 'the analysis stopped following here; a limit of the tool, not a defect in the code',
|
|
42
43
|
context: 'describes the estate - an entry point, a product in use - and asserts no defect',
|
|
43
44
|
});
|
|
@@ -60,6 +61,7 @@ export const WHO_ACTS = Object.freeze({
|
|
|
60
61
|
advisory: 'whoever owns the build',
|
|
61
62
|
exposure: 'the owner',
|
|
62
63
|
change: 'the reviewer of that change',
|
|
64
|
+
execution: "the program's owner",
|
|
63
65
|
coverage: "nobody's code: read more, or accept the limit",
|
|
64
66
|
context: 'nobody: it asserts no defect',
|
|
65
67
|
});
|
package/lib/kernel/registry.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { scanLog, LOG_RULES } from '../sets/log.mjs';
|
|
|
27
27
|
import { scanCompile, COMPILE_RULES } from '../sets/compile.mjs';
|
|
28
28
|
import { scanSemantics, SEMANTICS_RULES } from '../sets/semantics.mjs';
|
|
29
29
|
import { scanZowe, ZOWE_RULES } from '../sets/zowe.mjs';
|
|
30
|
+
import { scanAbend, ABEND_RULES } from '../sets/abend.mjs';
|
|
30
31
|
import { toolName } from './ruleset.mjs';
|
|
31
32
|
|
|
32
33
|
// The order is the order a scan runs them and the order --only lists them. It is not significant
|
|
@@ -47,6 +48,7 @@ export const REGISTRY = [
|
|
|
47
48
|
{ name: 'log', scan: scanLog, rules: LOG_RULES },
|
|
48
49
|
{ name: 'semantics', scan: scanSemantics, rules: SEMANTICS_RULES },
|
|
49
50
|
{ name: 'zowe', scan: scanZowe, rules: ZOWE_RULES },
|
|
51
|
+
{ name: 'abend', scan: scanAbend, rules: ABEND_RULES },
|
|
50
52
|
];
|
|
51
53
|
|
|
52
54
|
// Re-exported from the kernel's ruleset module, which is where it has to live: this file imports
|
|
@@ -154,6 +154,7 @@ function heldTree({ kind, root, store, rel, systemDirs = [], copyDirs = null, cl
|
|
|
154
154
|
// Buffer. Paths are used exactly as given, so a test reads the way it writes.
|
|
155
155
|
export function memoryTree(files, { root = '/memory', systemDirs = [] } = {}) {
|
|
156
156
|
const store = new Map(Object.entries(files).map(([p, v]) => [p, Buffer.isBuffer(v) ? v : Buffer.from(v, 'latin1')]));
|
|
157
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- root is escaped
|
|
157
158
|
const rel = (p) => String(p).replace(/\\/g, '/').replace(new RegExp('^' + root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/?'), '');
|
|
158
159
|
return heldTree({ kind: 'memory', root, store, rel, systemDirs });
|
|
159
160
|
}
|
package/lib/options.mjs
CHANGED
|
@@ -433,7 +433,11 @@ export function compilerTasks(text) {
|
|
|
433
433
|
};
|
|
434
434
|
const valueOf = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v.value : v);
|
|
435
435
|
const folder = (v) => String(v).replace(/\$\{(?:workspaceFolder|workspaceRoot)(?::[^}]*)?\}/g, '.').replace(/\$\{(?:\/|pathSeparator)\}/g, '/');
|
|
436
|
-
const quoted = (a) => {
|
|
436
|
+
const quoted = (a, dialect) => {
|
|
437
|
+
const s = String(valueOf(a) ?? '');
|
|
438
|
+
const esc = ESCAPE[dialect];
|
|
439
|
+
return /[\s"]/.test(s) || s === '' ? `"${s.replace(/./gs, (c) => (c === '"' || c === esc ? esc + c : c))}"` : s;
|
|
440
|
+
};
|
|
437
441
|
const out = [];
|
|
438
442
|
const tasks = [doc, ...(Array.isArray(doc && doc.tasks) ? doc.tasks : [])];
|
|
439
443
|
for (const task of tasks) {
|
|
@@ -448,7 +452,7 @@ export function compilerTasks(text) {
|
|
|
448
452
|
const line = lineOf(JSON.stringify(command).slice(1, 41), task.label && JSON.stringify(task.label));
|
|
449
453
|
const state = { dir };
|
|
450
454
|
const found = [];
|
|
451
|
-
commandLine(folder([command, ...args.map(quoted)].join(' ')), dialect, state, line, found);
|
|
455
|
+
commandLine(folder([command, ...args.map((a) => quoted(a, dialect))].join(' ')), dialect, state, line, found);
|
|
452
456
|
out.push(...found);
|
|
453
457
|
}
|
|
454
458
|
}
|
package/lib/packs.mjs
CHANGED
|
@@ -113,8 +113,10 @@ export function packProblems(pack) {
|
|
|
113
113
|
const scopes = r.appliesTo || ['jcl-instream'];
|
|
114
114
|
for (const sc of scopes) if (!SCOPES.includes(sc)) problems.push(`${pack.name}:${r.id}: unknown scope '${sc}'`);
|
|
115
115
|
if (!scopes.length) problems.push(`${pack.name}:${r.id}: no scope, so it can never match anything`);
|
|
116
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- a shipped pack's pattern, checked here to compile
|
|
116
117
|
try { new RegExp(r.pattern, r.flags || 'i'); } catch (e) { problems.push(`${pack.name}:${r.id}: ${e.message}`); }
|
|
117
118
|
if (r.setsContext && !r.setsContextPattern) problems.push(`${pack.name}:${r.id}: setsContext without a pattern that sets it`);
|
|
119
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- a shipped pack's pattern, checked here to compile
|
|
118
120
|
if (r.setsContextPattern) { try { new RegExp(r.setsContextPattern, 'i'); } catch (e) { problems.push(`${pack.name}:${r.id}: setsContextPattern ${e.message}`); } }
|
|
119
121
|
// A rule that waits for a context nothing in the pack sets can never fire, which is the same
|
|
120
122
|
// failure as an unknown scope and is caught the same way.
|
|
@@ -139,10 +141,12 @@ export function compilePack(pack) {
|
|
|
139
141
|
pack: pack.name,
|
|
140
142
|
vendor: pack.vendor,
|
|
141
143
|
product: pack.product,
|
|
144
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- a shipped pack's pattern; test/hostile times each one
|
|
142
145
|
re: new RegExp(r.pattern, r.flags || 'i'),
|
|
143
146
|
appliesTo: r.appliesTo || ['jcl-instream'],
|
|
144
147
|
// A rule may declare that it only means what it says once an earlier line has put the stream
|
|
145
148
|
// into a mode, and a rule may be the thing that sets that mode.
|
|
149
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- a shipped pack's pattern; test/hostile times each one
|
|
146
150
|
setsContextRe: r.setsContext && r.setsContextPattern ? new RegExp(r.setsContextPattern, 'i') : null,
|
|
147
151
|
}));
|
|
148
152
|
}
|