@portll/cobolwork 0.2.150 → 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 +20 -10
- package/lib/cics-commands.mjs +308 -42
- package/lib/consequence.mjs +6 -0
- package/lib/control.mjs +225 -14
- package/lib/dataflow.mjs +82 -28
- 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 +92 -23
- 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 +2 -2
- 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
|
@@ -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'),
|