@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/parser.mjs CHANGED
@@ -8,7 +8,8 @@ import {
8
8
  EIB_FIELDS, DIB_FIELDS, SQLCA_FIELDS,
9
9
  } from './words.mjs';
10
10
  import { CICS_COMMANDS, CICS_EVERY_COMMAND } from './cics-commands.mjs';
11
- import { cicsCommand } from './precompile-cics.mjs';
11
+ import { cicsCommand, symbolicMapCopybook } from './precompile-cics.mjs';
12
+ import { parseBms } from './bms.mjs';
12
13
  import { BINARY_SIZE, literalBytes, computeSizes, layoutReport } from './layout.mjs';
13
14
  import { hostVariablesIn } from './embedded-sql.mjs';
14
15
  import { join, dirname, basename, resolve, isAbsolute, sep, delimiter } from 'node:path';
@@ -88,7 +89,7 @@ export function detectFormat(src) {
88
89
  function codePastColumn72(src) {
89
90
  const lines = src.split(/\r?\n/, 2001);
90
91
  for (let i = 0; i < Math.min(lines.length, 2000); i++) {
91
- const l = expandTabs(lines[i]).replace(/\s+$/, '');
92
+ const l = expandTabs(lines[i]).trimEnd();
92
93
  if (l.length <= 72 || l[6] === '*' || l[6] === '/' || /^\s*\*>/.test(l) || COMMENT_ENTRY.test(l.slice(7))) continue;
93
94
  const { quote, comment } = stateAtColumn72(l);
94
95
  if (comment) continue;
@@ -138,9 +139,42 @@ function scanQuotes(text, open) {
138
139
 
139
140
  const PREDEFINED = new Map([['P64', 'SET']]);
140
141
 
142
+ // Cuts a floating comment where /\*>.*$/ would: at the first *> after the last line break.
143
+ function withoutInlineComment(s) {
144
+ let from = 0;
145
+ for (const b of ['\n', '\r', '\u2028', '\u2029']) from = Math.max(from, s.lastIndexOf(b) + 1);
146
+ const at = s.indexOf('*>', from);
147
+ return at < 0 ? s : s.slice(0, at);
148
+ }
149
+
150
+ // The name, value and OVERRIDE of a >>DEFINE body as /^(?:CONSTANT\s+)?([A-Za-z0-9_-]+)\s+(?:AS\s+)?(.*?)\s*(OVERRIDE)?\s*$/i
151
+ // reads them, in time linear in the body.
152
+ function defineParts(body) {
153
+ const space = (c) => c !== undefined && /\s/.test(c);
154
+ const skip = (i) => { while (space(body[i])) i++; return i; };
155
+ const from = (start) => {
156
+ let j = start;
157
+ while (j < body.length && /[A-Za-z0-9_-]/.test(body[j])) j++;
158
+ if (j === start || !space(body[j])) return null;
159
+ let v = skip(j);
160
+ if (/^AS$/i.test(body.slice(v, v + 2)) && space(body[v + 2])) v = skip(v + 2);
161
+ let end = body.length;
162
+ while (end > v && space(body[end - 1])) end--;
163
+ let override;
164
+ if (end - 8 >= v && /^OVERRIDE$/i.test(body.slice(end - 8, end))) {
165
+ override = body.slice(end - 8, end);
166
+ end -= 8;
167
+ while (end > v && space(body[end - 1])) end--;
168
+ }
169
+ const value = body.slice(v, end);
170
+ return /[\n\r\u2028\u2029]/.test(value) ? null : [body, body.slice(start, j), value, override];
171
+ };
172
+ return (/^CONSTANT$/i.test(body.slice(0, 8)) && space(body[8]) && from(skip(8))) || from(0);
173
+ }
174
+
141
175
  function defineDirective(directive, defines) {
142
- const body = directive.replace(/\*>.*$/, '').replace(/^>>\s*DEFINE\s+/i, '').trim();
143
- const m = body.match(/^(?:CONSTANT\s+)?([A-Za-z0-9_-]+)\s+(?:AS\s+)?(.*?)\s*(OVERRIDE)?\s*$/i);
176
+ const body = withoutInlineComment(directive).replace(/^>>\s*DEFINE\s+/i, '').trim();
177
+ const m = defineParts(body);
144
178
  if (!m) return;
145
179
  const name = m[1].toUpperCase();
146
180
  const raw = m[2].trim();
@@ -153,17 +187,17 @@ function defineDirective(directive, defines) {
153
187
  function setDirective(text, defines) {
154
188
  const c = /\bCONSTANT\s+([A-Za-z0-9_-]+)\s+(?:(["'])(.*?)\2|(\S+))/i.exec(text);
155
189
  if (c && defines) defines.set(c[1].toUpperCase(), c[3] !== undefined ? c[3] : c[4]);
156
- const m = text.match(/SOURCEFORMAT\s*\(?\s*["']?(FREE|FIXED|VARIABLE)/i);
190
+ const m = text.match(/SOURCEFORMAT\s*(?:\(\s*)?["']?(FREE|FIXED|VARIABLE)/i);
157
191
  return m ? m[1].toLowerCase() : null;
158
192
  }
159
193
 
160
194
  function evaluateCondition(text, defines) {
161
- const t = text.replace(/\*>.*$/, '').trim();
195
+ const t = withoutInlineComment(text).trim();
162
196
  const known = (n) => (defines && defines.has(n)) || PREDEFINED.has(n);
163
197
  const valueOf = (n) => (defines && defines.has(n) ? defines.get(n) : PREDEFINED.get(n));
164
198
  let m = t.match(/^([A-Za-z0-9_-]+)\s+(?:IS\s+)?(NOT\s+)?(DEFINED|SET)$/i);
165
199
  if (m) { const r = known(m[1].toUpperCase()); return m[2] ? !r : r; }
166
- m = t.match(/^([A-Za-z0-9_-]+)\s*(<=|>=|<>|=|<|>)\s*(['"]?)([^'"]*)\3$/);
200
+ m = t.match(/^([A-Za-z0-9_-]+)\s*(<=|>=|<>|=|<|>)\s*(?!\s)(['"]?)([^'"]*)\3$/);
167
201
  if (m) {
168
202
  const name = m[1].toUpperCase();
169
203
  if (!known(name)) return null;
@@ -583,7 +617,8 @@ function applyReplacing(tokens, pairs, maxGrowth = Infinity, byCopy = false) {
583
617
  if (p.mode || p.partial || p.from.toks.length !== 1 || p.to.toks.length > 1) continue;
584
618
  const pat = p.from.toks[0].v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
585
619
  const rep = p.to.toks[0] ? p.to.toks[0].v : '';
586
- const v = tk.v.replace(new RegExp(`\\(${pat}\\)`, 'gi'), `(${rep})`);
620
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pat is escaped
621
+ const v = tk.v.replace(new RegExp(`\\(${pat}\\)`, 'gi'), () => `(${rep})`);
587
622
  if (v !== tk.v) tk = { ...tk, v, u: v };
588
623
  }
589
624
  }
@@ -790,15 +825,31 @@ function expand(tokens, ctx, stack, format) {
790
825
  return out;
791
826
  }
792
827
 
828
+ // The symbolic map BMS would generate for a COBOL mapset the tree holds only as BMS source, each
829
+ // line placed at the BMS statement it comes from.
830
+ function symbolicMapFor(name, ctx) {
831
+ const want = name.toUpperCase();
832
+ for (const p of filesByName(ctx.fileIndex).get(`${name}.bms`.toLowerCase()) || []) {
833
+ let mapsets;
834
+ try { ({ mapsets } = parseBms(ctx.readText(p))); } catch { continue; }
835
+ const mapset = mapsets.find((m) => m.name?.toUpperCase() === want && String(m.options?.lang || 'COBOL').toUpperCase() === 'COBOL');
836
+ const copybook = mapset && symbolicMapCopybook(mapset);
837
+ if (copybook) return { path: p, ...copybook };
838
+ }
839
+ return null;
840
+ }
841
+
793
842
  function includeCopy(name, lib, pairs, at, via, ctx, stack, inheritedFormat) {
794
- const path = resolveCopy(name, lib, ctx);
795
843
  const refused = copyRefusal(name, ctx);
796
- const record = { name, lib, via, file: at.file, line: at.line, status: path ? 'resolved' : refused || (SYSTEM_COPY.test(name) ? 'system' : 'missing'), path };
844
+ let path = resolveCopy(name, lib, ctx);
845
+ const generated = !path && !refused && !lib && via === 'copy' && ctx.fileIndex ? symbolicMapFor(name, ctx) : null;
846
+ if (generated) path = generated.path;
847
+ const record = { name, lib, via, file: at.file, line: at.line, status: path ? 'resolved' : refused || (SYSTEM_COPY.test(name) ? 'system' : 'missing'), path, ...(generated ? { generatedFrom: 'bms' } : {}) };
797
848
  ctx.copies.push(record);
798
849
  if (!path) return [];
799
850
  if (stack.includes(path) || stack.length > 40) { record.status = 'recursive'; return []; }
800
851
  if (++ctx.inclusions > MAX_INCLUSIONS || ctx.copyTokens > MAX_COPY_TOKENS) { record.status = 'expansion-limit'; return []; }
801
- const src = ctx.readText(path);
852
+ const src = generated ? generated.text : ctx.readText(path);
802
853
  // A copybook is read in the format in force where the COPY statement sits, which a >>SOURCE
803
854
  // directive earlier in the including file may have changed from the file's starting format.
804
855
  const fmt = detectFormat(src) === 'terminal' && ctx.copyFormat === 'auto' ? 'terminal' : (at.fmt || (ctx.copyFormat !== 'auto' ? ctx.copyFormat : (inheritedFormat || ctx.mainFormat)));
@@ -812,10 +863,13 @@ function includeCopy(name, lib, pairs, at, via, ctx, stack, inheritedFormat) {
812
863
  const f = p.from.toks;
813
864
  if (p.mode || !f.length) continue;
814
865
  const pat = f.map(t => t.v).join('');
866
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- escaped
815
867
  if (p.from.pseudo && /^[:(][A-Za-z0-9_-]*[:)]$/.test(pat)) { partial.push([p, new RegExp(escape(pat), 'gi')]); continue; }
816
868
  if (p.from.pseudo || f.length !== 1 || f[0].t !== 'lit') continue;
817
869
  const lit = `(['"])${escape(f[0].v)}\\1`;
870
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- lit is escaped
818
871
  const touching = new RegExp(`${lit}(?=[A-Za-z0-9-])|(?<=[A-Za-z0-9-])${lit}`);
872
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- lit is escaped
819
873
  if (norm.entries.some(e => touching.test(e.text))) partial.push([p, new RegExp(lit, 'g')]);
820
874
  }
821
875
  if (partial.length) {
@@ -832,6 +886,7 @@ function includeCopy(name, lib, pairs, at, via, ctx, stack, inheritedFormat) {
832
886
  for (const [p] of partial) p.partial = true;
833
887
  }
834
888
  const { tokens, diags } = tokenize(norm, path);
889
+ if (generated) for (const t of tokens) t.line = generated.lines[t.line - 1] ?? t.line;
835
890
  ctx.copyTokens += tokens.length;
836
891
  ctx.diags.push(...diags, ...norm.diags.map(d => ({ ...d, file: path })));
837
892
  const expanded = expand(tokens, ctx, [...stack, path], norm.finalFormat);
@@ -1855,7 +1910,7 @@ function collectCopybookDefines(src, format, ctx, depth) {
1855
1910
  const sw = /(?:^|\s)>>\s*SOURCE\s+(?:FORMAT\s+)?(?:IS\s+)?(FREE|FIXED|VARIABLE|TERMINAL)/i.exec(l);
1856
1911
  if (sw) { current = sw[1].toLowerCase(); continue; }
1857
1912
  if (current !== 'free' && current !== 'terminal' && l.length > 6 && '*/'.includes(l[6])) continue;
1858
- const code = l.replace(/\*>.*$/, '');
1913
+ const code = withoutInlineComment(l);
1859
1914
  const m = /(?:^|[\s.])COPY\s+("[^"]+"|'[^']+'|[A-Za-z0-9_-]+)/i.exec(code);
1860
1915
  if (!m) continue;
1861
1916
  const name = m[1].replace(/^["']|["']$/g, '');
@@ -122,12 +122,14 @@ export const constantsCopybook = (names) => `${names.map((n) => ` 01 ${n}
122
122
  // prefix when TIOAPFX=YES, then for each named field its length, flag or attribute byte, extended
123
123
  // attribute bytes and data, the output record redefining the input. Grouped (GRPNAME) and OCCURS
124
124
  // fields are laid out differently, and a mapset holding either is not written. Returns the copybook
125
- // text and the names it declares, or null.
125
+ // text, the names it declares and, for each of its lines, the BMS line it comes from; or null.
126
126
  const ATTRIBUTE_LETTER = { COLOR: 'C', PS: 'P', HILIGHT: 'H', VALIDN: 'V', OUTLINE: 'U', SOSI: 'M', TRANSP: 'T' };
127
127
  export function symbolicMapCopybook(mapset) {
128
128
  const out = [];
129
129
  const names = [];
130
- const line = (s) => out.push(` ${s}`);
130
+ const lines = [];
131
+ let from = mapset.line;
132
+ const line = (s) => { out.push(` ${s}`); lines.push(from); };
131
133
  const declare = (n, s) => { names.push(n); line(s); };
132
134
  // The assembler writes FILLER; each gets a name here, the layout unchanged, so the grade can tell
133
135
  // the stand-in's items from the program's.
@@ -138,6 +140,7 @@ export function symbolicMapCopybook(mapset) {
138
140
  if (fields.some((f) => f.grpname || f.occurs > 1)) return null;
139
141
  const { input, output, attributes } = map.symbolic;
140
142
  if (!input && !output) continue;
143
+ from = map.line;
141
144
  const prefix = String(map.operands?.get?.('TIOAPFX') ?? mapset.options.tioapfx ?? '').toUpperCase() === 'YES';
142
145
  const pic = (f, which) => f[which] || `X(${f.length})`;
143
146
  if (input) {
@@ -145,6 +148,7 @@ export function symbolicMapCopybook(mapset) {
145
148
  if (prefix) line(` 02 ${filler(input)} PIC X(12).`);
146
149
  for (const f of fields) {
147
150
  const n = f.name.toUpperCase();
151
+ from = f.line;
148
152
  declare(`${n}L`, ` 02 ${n}L COMP PIC S9(4).`);
149
153
  declare(`${n}F`, ` 02 ${n}F PIC X.`);
150
154
  line(` 02 ${filler(input)} REDEFINES ${n}F.`);
@@ -154,10 +158,12 @@ export function symbolicMapCopybook(mapset) {
154
158
  }
155
159
  }
156
160
  if (output) {
161
+ from = map.line;
157
162
  declare(output, input ? `01 ${output} REDEFINES ${input}.` : `01 ${output}.`);
158
163
  if (prefix) line(` 02 ${filler(output)} PIC X(12).`);
159
164
  for (const f of fields) {
160
165
  const n = f.name.toUpperCase();
166
+ from = f.line;
161
167
  if (input) line(` 02 ${filler(output)} PIC X(3).`);
162
168
  else { line(` 02 ${filler(output)} PIC X(2).`); declare(`${n}A`, ` 02 ${n}A PIC X.`); }
163
169
  for (const a of attributes) declare(`${n}${ATTRIBUTE_LETTER[a]}`, ` 02 ${n}${ATTRIBUTE_LETTER[a]} PIC X.`);
@@ -165,5 +171,5 @@ export function symbolicMapCopybook(mapset) {
165
171
  }
166
172
  }
167
173
  }
168
- return out.length ? { text: `${out.join('\n')}\n`, names } : null;
174
+ return out.length ? { text: `${out.join('\n')}\n`, names, lines } : null;
169
175
  }
@@ -188,7 +188,7 @@ function place(lines, block, format, text) {
188
188
  while (w < words.length && writeFrom + out.length + (out ? 1 : 0) + words[w].length <= to) out += (out ? ' ' : '') + words[w++];
189
189
  const line = lines[li].padEnd(to);
190
190
  lines[li] = line.slice(0, blankFrom) + ' '.repeat(Math.max(0, writeFrom - blankFrom)) + out.padEnd(Math.max(0, to - Math.max(writeFrom, blankFrom))) + line.slice(to);
191
- if (li !== block.endLine) lines[li] = lines[li].replace(/\s+$/, '');
191
+ if (li !== block.endLine) lines[li] = lines[li].trimEnd();
192
192
  }
193
193
  return words.slice(w);
194
194
  }
@@ -208,6 +208,7 @@ function spill(words, format) {
208
208
  }
209
209
 
210
210
  // A division or section header, or a word, standing alone: not part of a longer name.
211
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- callers pass literal patterns
211
212
  const word = (re) => new RegExp(`(?<![A-Z0-9-])${re}(?![A-Z0-9-])`, 'i');
212
213
  const HEADERS = [['procedure', word('PROCEDURE\\s+DIVISION')], ['linkage', word('LINKAGE\\s+SECTION')], ['data', word('DATA\\s+DIVISION')], ['after', word('(?:REPORT|SCREEN)\\s+SECTION')]];
213
214
  const PROGRAM_ID = word('PROGRAM-ID');
@@ -373,7 +374,7 @@ function translateText(t, shared, ctx, before = new Map(), after = new Map()) {
373
374
  }
374
375
  const rest = place(lines, b, format, text);
375
376
  if (periodOnly) {
376
- lines[b.endLine] = lines[b.endLine].replace(/\s+$/, '');
377
+ lines[b.endLine] = lines[b.endLine].trimEnd();
377
378
  if (rest.length) { add(after, b.endLine, spill(rest, format)); state.spilled++; }
378
379
  } else if (rest.length) {
379
380
  // What followed END-EXEC - a period, or the rest of a sentence - follows the whole translation.
package/lib/revision.json CHANGED
@@ -1 +1 @@
1
- {"commit":"b242a380e1b8b7d8bb8ff2d6e03e2c8a30242967","tag":"v0.3.0"}
1
+ {"commit":"371041fbffe625df6b0a872249cf071268fcb6a6"}
package/lib/sarif.mjs CHANGED
@@ -32,8 +32,9 @@ const LEVEL = { crit: 'error', high: 'error', med: 'warning', low: 'note', info:
32
32
  // Locations are URI references: each segment is encoded, lone surrogates from Windows names replaced first.
33
33
  const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
34
34
  const uriOf = (p) => String(p).split('/').map((seg) => encodeURIComponent(seg.replace(LONE_SURROGATE, '\uFFFD'))).join('/');
35
- // Square brackets in a SARIF message are link syntax, and a finding's text can quote the tree.
36
- const textOf = (s) => (typeof s === 'string' ? s.replace(/[[\]]/g, '\\$&') : s);
35
+ // Square brackets in a SARIF message are link syntax, and a finding's text can quote the tree. A
36
+ // backslash escapes the character after it, so one before a bracket or a backslash is escaped too.
37
+ const textOf = (s) => (typeof s === 'string' ? s.replace(/\\(?=[\\[\]])|[[\]]/g, '\\$&') : s);
37
38
  // SARIF's three levels fold critical into high and info into low, so the severity also travels as
38
39
  // the number GitHub code scanning ranks by. Info is coverage and context, which rank as nothing.
39
40
  const SECURITY_SEVERITY = { crit: '9.5', high: '8.0', med: '5.5', low: '3.0' };
package/lib/scan.mjs CHANGED
@@ -74,7 +74,8 @@ function scanEach(root, opts) {
74
74
  const reports = opts.repos.map(repo => ({ repo, r: scanAll(join(root, repo), { ...opts, repos: null, repoName: repo, feedRoot: opts.feedRoot || root }) }));
75
75
  const findings = [];
76
76
  const checked = [];
77
- const fixedAt = (repo, x) => (x?.fixAt ? { exploitability: { ...x, fixAt: { ...x.fixAt, path: underRepo(repo, x.fixAt.path) } } } : {});
77
+ const fixedAt = (repo, x) => (x?.fixAt ? { exploitability: { ...x, fixAt: { ...x.fixAt, path: underRepo(repo, x.fixAt.path),
78
+ ...(x.fixAt.builtInto ? { builtInto: { ...x.fixAt.builtInto, path: underRepo(repo, x.fixAt.builtInto.path) } } : {}) } } } : {});
78
79
  const prefixed = (repo, f) => ({ ...f, path: underRepo(repo, f.path), ...(f.related ? { related: f.related.map(x => ({ ...x, path: underRepo(repo, x.path) })) } : {}), ...fixedAt(repo, f.exploitability) });
79
80
  for (const { repo, r } of reports) {
80
81
  for (const f of r.findings) findings.push(prefixed(repo, f));
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Abends an input caused, from the runs ironwork's fuzzing harness kept (docs/spec/evidence.md §13.6).
3
+ //
4
+ // A fuzz run is a directory: manifest.json names the program, the inputs and each run that ended in
5
+ // an abend, and evidence/ holds each kept run's hash-chained journal. A finding is reported only
6
+ // where the evidence verifies and the run's own journal records the abend the manifest claims, so
7
+ // the finding rests on a record of the run rather than on the manifest's word.
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { delimiter, join, posix, basename } from 'node:path';
10
+ import { report } from '../kernel/ruleset.mjs';
11
+ import { verifyEvidence } from '../evidence/verify.mjs';
12
+
13
+ export const ABEND_RULES = {
14
+ 'input-causes-abend-s0c7': {
15
+ sev: 'med', evidence: 'execution', cwe: 'CWE-20',
16
+ text: 'An input the program accepts ends its run with a data exception (S0C7)',
17
+ impact: 'Whoever supplies that input can stop the program at will: a batch step abends and the job behind it does not finish, a transaction abends at the terminal',
18
+ remedy: 'Test the field with IS NUMERIC, or validate the record, before the arithmetic or MOVE the journal names, and reject the input rather than letting the data exception end the run',
19
+ },
20
+ 'input-causes-abend-s0c4': {
21
+ sev: 'high', evidence: 'execution', cwe: 'CWE-119',
22
+ text: 'An input the program accepts ends its run with a protection exception (S0C4)',
23
+ impact: 'The input made the program address storage it does not own; the same input under another layout can read or overwrite other data before anything stops it',
24
+ remedy: 'Find the subscript, reference modification, pointer or parameter length the input controls at the line the journal names, and bound it before use',
25
+ },
26
+ 'input-causes-abend-subscript-range': {
27
+ sev: 'high', evidence: 'execution', cwe: 'CWE-129',
28
+ text: 'An input the program accepts drives a subscript or reference modification out of range, and the run abends',
29
+ impact: 'SSRANGE caught the overrun and ended the run; compiled without SSRANGE, as production builds often are, the same input reads or writes storage beside the table or field',
30
+ remedy: 'Check the index against both ends of the table or field before the statement the journal names, and keep SSRANGE on for the build',
31
+ },
32
+ 'input-causes-abend': {
33
+ sev: 'med', evidence: 'execution', cwe: 'CWE-248',
34
+ text: 'An input the program accepts ends its run with an abend',
35
+ impact: 'Whoever supplies that input can stop the program at will, and the abend code says what the program failed to handle',
36
+ remedy: 'Read the abend code and the line the journal names, and handle the condition the input raises before it ends the run',
37
+ },
38
+ };
39
+
40
+ // IBM's messages for an SSRANGE failure: a subscript or index, an OCCURS DEPENDING ON object, and a
41
+ // reference modification's start, length, and start plus length.
42
+ const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
43
+
44
+ export function abendRunPaths(opts = {}) {
45
+ if (opts.abendRuns) return opts.abendRuns;
46
+ return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
47
+ }
48
+
49
+ export function abendRule(abend) {
50
+ if (abend.code === 'S0C7') return 'input-causes-abend-s0c7';
51
+ if (abend.code === 'S0C4') return 'input-causes-abend-s0c4';
52
+ if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
53
+ return 'input-causes-abend';
54
+ }
55
+
56
+ // The abend a kept run's own journal records, or null.
57
+ function journalAbend(evidenceDir, runId) {
58
+ if (typeof runId !== 'string' || !/^[0-9TZ]+-[0-9a-f]{16}$/.test(runId)) return null;
59
+ const file = join(evidenceDir, 'runs', `${runId}.jsonl`);
60
+ if (!existsSync(file)) return null;
61
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
62
+ if (!line) continue;
63
+ const rec = JSON.parse(line);
64
+ if (rec.kind === 'abend') return rec;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ const isString = (x) => typeof x === 'string' && x.length > 0;
70
+
71
+ // One fuzz run: its findings, and what kept any of it from counting.
72
+ export function loadAbendRun(dir) {
73
+ const out = { dir: basename(dir), program: null, findings: [], problems: [], counts: null, notModelled: 0 };
74
+ let doc;
75
+ try { doc = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); } catch (e) {
76
+ out.problems.push(e.code ? `manifest.json could not be read (${e.code})` : `manifest.json is not JSON (${e.message})`);
77
+ return out;
78
+ }
79
+ if (!doc || doc.tool !== 'ironwork-fuzz' || !doc.program || !isString(doc.program.file) || !Array.isArray(doc.inputs) || !Array.isArray(doc.runs)) {
80
+ out.problems.push('manifest.json is not an ironwork-fuzz manifest with a program, inputs and runs');
81
+ return out;
82
+ }
83
+ const programFile = posix.normalize(doc.program.file.replace(/\\/g, '/'));
84
+ if (programFile.startsWith('../') || posix.isAbsolute(programFile)) {
85
+ out.problems.push(`the program ${doc.program.file} is outside the scanned tree`);
86
+ return out;
87
+ }
88
+ out.program = { file: programFile, id: isString(doc.program.id) ? doc.program.id : null };
89
+ out.counts = doc.counts && typeof doc.counts === 'object' ? doc.counts : null;
90
+
91
+ const evidenceDir = join(dir, 'evidence');
92
+ const verdict = verifyEvidence(evidenceDir);
93
+ if (!verdict.verified) {
94
+ out.problems.push(`its evidence does not verify: ${verdict.broken.map((b) => `${b.file}:${b.line} ${b.check}`).join(', ') || 'nothing is recorded'}`);
95
+ return out;
96
+ }
97
+ const inputs = new Map(doc.inputs.filter((i) => i && isString(i.id)).map((i) => [i.id, i]));
98
+ const seen = new Map();
99
+ for (const run of doc.runs) {
100
+ if (!run || run.outcome !== 'abend' || !run.abend || !isString(run.abend.code)) continue;
101
+ if (run.abend.code === 'IRONWORK') { out.notModelled++; continue; }
102
+ const recorded = journalAbend(evidenceDir, run.journal);
103
+ if (!recorded || recorded.code !== run.abend.code) {
104
+ out.problems.push(`run ${run.journal ?? '(unnamed)'}: its journal does not record the abend ${run.abend.code} the manifest gives`);
105
+ continue;
106
+ }
107
+ // Where the journal names the abend's place, that is the place, and a manifest that says otherwise is not believed.
108
+ const claimed = { file: run.abend.file, line: run.abend.line };
109
+ const place = isString(recorded.file) && Number.isInteger(recorded.line) ? { file: recorded.file, line: recorded.line } : claimed;
110
+ if (place !== claimed && ((isString(claimed.file) && claimed.file !== place.file) || (claimed.line != null && claimed.line !== place.line))) {
111
+ out.problems.push(`run ${run.journal}: its journal records the abend at ${place.file}:${place.line}, not where the manifest puts it`);
112
+ continue;
113
+ }
114
+ const where = isString(place.file) ? posix.normalize(posix.join(posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
115
+ const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
116
+ const rule = abendRule(run.abend);
117
+ const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
118
+ .map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}) }));
119
+ const key = `${rule}|${where}|${line}`;
120
+ const size = used.reduce((n, i) => n + String(i.bytes || '').length, 0);
121
+ const held = seen.get(key);
122
+ if (held && held.size <= size) continue;
123
+ const finding = {
124
+ rule, path: where, line, program: out.program.id,
125
+ detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept`,
126
+ abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}) },
127
+ input: used, run: { dir: out.dir, journal: run.journal },
128
+ };
129
+ seen.set(key, { size, finding });
130
+ }
131
+ out.findings = [...seen.values()].map((s) => s.finding);
132
+ return out;
133
+ }
134
+
135
+ export function scanAbend(root, opts = {}) {
136
+ const dirs = abendRunPaths(opts);
137
+ const runs = dirs.map((d) => loadAbendRun(d));
138
+ const findings = runs.flatMap((r) => r.findings.filter((f) => existsSync(join(root, f.path))));
139
+ const stats = {
140
+ filesScanned: 0, filesUnreadable: 0,
141
+ abendRuns: runs.map((r) => ({ dir: r.dir, program: r.program ? r.program.file : null, findings: r.findings.length, ...(r.notModelled ? { notModelled: r.notModelled } : {}), ...(r.counts ? { counts: r.counts } : {}) })),
142
+ ...(runs.some((r) => r.problems.length) ? { abendRunProblems: runs.flatMap((r) => r.problems.map((p) => `${r.dir}: ${p}`)) } : {}),
143
+ ...(runs.some((r) => r.findings.some((f) => !existsSync(join(root, f.path)))) ? { abendRunsElsewhere: runs.flatMap((r) => r.findings.filter((f) => !existsSync(join(root, f.path))).map((f) => `${r.dir}: ${f.path}`)) } : {}),
144
+ setIncomplete: runs.some((r) => r.problems.length > 0),
145
+ };
146
+ return report('abend', { rules: ABEND_RULES, findings, stats, run: null });
147
+ }
148
+
@@ -93,7 +93,8 @@ function declaredIn(text, names, into) {
93
93
  // A reference cut at column 72 names a word the source does not hold.
94
94
  function truncated(ref, lines) {
95
95
  const line = (lines.get(ref.file) || [])[ref.line - 1] || '';
96
- const re = new RegExp(`(?<![A-Za-z0-9-])${ref.name.replace(/[$#@]/g, '\\$&')}`, 'gi');
96
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- the name is escaped
97
+ const re = new RegExp(`(?<![A-Za-z0-9-])${ref.name.replace(/[.*+?^${}()|[\]\\#@]/g, '\\$&')}`, 'gi');
97
98
  let whole = false;
98
99
  let any = false;
99
100
  let m;
@@ -127,7 +127,7 @@ const SEQUENCE_NUMBER = /^[A-Za-z]{0,4}\d{2,6}$/;
127
127
  const FIXED_INDICATOR = /^[ */\-Dd$]$/;
128
128
  const NOT_CODE = /^(\*>|\/\/|\/\*)/;
129
129
  // A directive that makes the rest of the file free format, where columns 1-6 are ordinary text.
130
- const TO_FREE = />>\s*SOURCE\s+(?:FORMAT\s+)?(?:IS\s+)?FREE\b|SOURCEFORMAT\s*\(?\s*["']?FREE/i;
130
+ const TO_FREE = />>\s*SOURCE\s+(?:FORMAT\s+)?(?:IS\s+)?FREE\b|SOURCEFORMAT\s*(?:\(\s*)?["']?FREE/i;
131
131
 
132
132
  function sequencePayload(lines, format) {
133
133
  const rows = [];
package/lib/sets/jcl.mjs CHANGED
@@ -154,6 +154,7 @@ const DESTRUCTIVE = /^\s*(DELETE(?!\s+FROM\b)|ALTER\s+\S+\s+NEWNAME|REPRO\b[^\n]
154
154
  // symbol ends it. An empty qualifier is a symbol the reader already filled with an empty default.
155
155
  const dsnKey = (dsn) => String(dsn || '').replace(/^['(]+|[')]+$/g, '').replace(/\(.*$/, '').toUpperCase()
156
156
  .replace(/&[A-Z0-9@#$]+\.?/g, '*').replace(/<[^>]*>/g, '*').replace(/\.(?=\.)/g, '.*');
157
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- k is escaped, and * widened to one qualifier
157
158
  const dsnPattern = (k) => new RegExp(`^${k.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^.]+')}$`);
158
159
 
159
160
  // The programs whose in-stream data can delete a catalogued dataset. A step running another known
@@ -107,6 +107,7 @@ function routableAddresses(text, jcl) {
107
107
  // A qualifier matches on a whole dotted component, never on a substring: PROD matches PROD.MASTER
108
108
  // and PRODLIB.X only if PRODLIB was itself listed. Matching on substrings is how a rule like this
109
109
  // starts reporting every line in the estate.
110
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- q is escaped
110
111
  const qualifierPattern = (q) => new RegExp('(^|[^A-Z0-9$#@.])' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=[.\\s,)\'"]|$)', 'i');
111
112
 
112
113
  // JOBLIB and STEPLIB are where the system looks for the program, so what they hold is code rather
package/lib/utilities.mjs CHANGED
@@ -118,6 +118,7 @@ function commands(lines) {
118
118
  // The values of the first of `names` given as a keyword in a command, as in INFILE(IN) or
119
119
  // OUTDDNAME(TAPE1,TAPE2). A name only counts at the start of a word, so INDD is not LOGINDD.
120
120
  function param(text, names) {
121
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- names are literal keyword lists
121
122
  const m = new RegExp(`(?:^|[\\s,])(?:${names.join('|')})\\s*\\(([^)]*)\\)`, 'i').exec(text);
122
123
  return m ? m[1].split(/[\s,]+/).filter(Boolean).map((v) => upper(v.replace(/^'|'$/g, ''))) : null;
123
124
  }
@@ -341,6 +342,7 @@ function icetoolCopies(step, dds) {
341
342
  for (const st of statements) {
342
343
  if (!['COPY', 'SORT', 'MERGE'].includes(st.op)) continue;
343
344
  const named = (kw) => st.operands.flatMap((o) => {
345
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- kw is a literal keyword
344
346
  const m = new RegExp(`^${kw}\\((.*)\\)$`, 'i').exec(o);
345
347
  return m ? m[1].split(',').map((n) => upper(n.trim())).filter(Boolean) : [];
346
348
  });
package/lib/verify.mjs CHANGED
@@ -18,7 +18,8 @@ const END_TASK = 'end the task from CEDF or CEDX, or stop the program in the deb
18
18
  // What goes in, and what reading it at the operation shows. `run` says what letting the operation run
19
19
  // shows; null means it would act on something, so the plan stops before it (`stopBefore`).
20
20
  const SINKS = {
21
- arithmetic: (x) => ({ value: `a letter where ${x.item} expects a digit, such as A in its first position`, reads: `${x.item} holding the letter`, run: 'the task abends ASRA, a data exception (S0C7 in batch), at the operation' }),
21
+ // A letter's low half is 1 to 9 and its zone a valid sign, so zoned arithmetic reads it as a digit.
22
+ arithmetic: (x) => ({ value: `an asterisk in every position of ${x.item}, whose digits it expects: a letter will not do, since zoned decimal reads a letter as a digit`, reads: `${x.item} holding the asterisks`, run: 'the task abends ASRA, a data exception (S0C7 in batch), at the operation' }),
22
23
  subscript: (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}, or 0`),
23
24
  'loop-bound': (x) => bounds(x, `${x.table ? `${x.table + 1}, one more than the ${x.table} entries in the table` : 'one more than the entries in the table'}`),
24
25
  'reference-modification': (x) => bounds(x, 'one more than the length of the field, or 0'),