@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/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
- const edge = (a, b, why, dir, at) => {
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(at != null ? { to: b, why, dir, at } : { to: b, why, dir });
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
- n = { id: nodes.length, pk, program: prog.id, file, name: item.name, off: item.offset ?? null, size: item.size ?? null, repeats, edgesOut: [], sources: [], sinks: [] };
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
- if (stampWhole[to.id] === walk || dirty.get(to.id) === walk) continue;
1389
- dirty.set(to.id, walk);
1390
- } else {
1391
- if (stampWhole[to.id] === walk) continue;
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 held = partSeen.get(to.id);
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(to.id, [[lo, hi, vague]]);
1547
- } else {
1548
- if (stampWhole[to.id] === walk) continue;
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 quote = (path, line) => {
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 };
@@ -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
- feed.results.set(fingerprint, { outcome: r.outcome, by: r.by, on: r.on, system: r.system, ...(str(r.reference) ? { reference: r.reference } : {}) });
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
- const PASSING = new Set(['cleared-by-check', 'statement-removed', 'source-removed', 'no-longer-fires']);
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 = compiler.run(join(headRoot, file), optsFor(headRoot, headProgs, file));
254
- if (now) continue;
255
- const had = existsSync(join(baseRoot, file)) && compiler.run(join(baseRoot, file), optsFor(baseRoot, baseProgs, file));
256
- (had ? regressed : neverCompiled).push(file);
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
  }
@@ -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
  });
@@ -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) => { const s = String(valueOf(a) ?? ''); return /[\s"]/.test(s) || s === '' ? `"${s.replace(/"/g, '\\"')}"` : s; };
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
  }