@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/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]).
|
|
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
|
|
143
|
-
const m = body
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, '');
|
package/lib/precompile-cics.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/lib/precompile.mjs
CHANGED
|
@@ -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].
|
|
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].
|
|
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":"
|
|
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
|
-
|
|
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
|
+
|
package/lib/sets/compile.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/lib/sets/hidden.mjs
CHANGED
|
@@ -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
|
|
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
|
package/lib/sets/recon.mjs
CHANGED
|
@@ -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
|
-
|
|
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'),
|