@portll/cobolwork 0.3.0 → 0.5.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 +35 -9
- package/bin/cobolwork.mjs +7 -1
- package/lib/bms.mjs +8 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/cics-commands.mjs +308 -42
- package/lib/consequence.mjs +19 -0
- package/lib/constants.mjs +60 -0
- package/lib/control.mjs +61 -17
- package/lib/csd.mjs +6 -1
- package/lib/dataflow.mjs +107 -27
- package/lib/explain.mjs +31 -5
- package/lib/exploitability.mjs +49 -4
- package/lib/gate.mjs +31 -9
- package/lib/hlasm.mjs +79 -0
- package/lib/kernel/findings.mjs +2 -0
- package/lib/kernel/registry.mjs +6 -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 +186 -0
- package/lib/sets/compile.mjs +2 -1
- package/lib/sets/crypto.mjs +110 -0
- package/lib/sets/flow.mjs +18 -0
- package/lib/sets/hidden.mjs +1 -1
- package/lib/sets/hlasm.mjs +121 -0
- package/lib/sets/jcl.mjs +16 -1
- package/lib/sets/recon.mjs +1 -0
- package/lib/sets/web.mjs +99 -4
- package/lib/sources.mjs +12 -2
- 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 +180 -0
- package/rules/compliance-dora.json +195 -0
- package/rules/compliance-ffiec.json +195 -0
- package/rules/compliance-nist80053.json +195 -0
- package/rules/gitleaks-mainframe.toml +24 -10
- package/rules/hlasm-operations.json +451 -0
- package/rules/icsf-services.json +1178 -0
|
@@ -0,0 +1,186 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
10
|
+
import { delimiter, join, posix, basename } from 'node:path';
|
|
11
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
12
|
+
import { verifyEvidence } from '../evidence/verify.mjs';
|
|
13
|
+
|
|
14
|
+
export const ABEND_RULES = {
|
|
15
|
+
'input-causes-abend-s0c7': {
|
|
16
|
+
sev: 'med', evidence: 'execution', cwe: 'CWE-20',
|
|
17
|
+
text: 'An input the program accepts ends its run with a data exception (S0C7)',
|
|
18
|
+
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',
|
|
19
|
+
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',
|
|
20
|
+
},
|
|
21
|
+
'input-causes-abend-s0c4': {
|
|
22
|
+
sev: 'high', evidence: 'execution', cwe: 'CWE-119',
|
|
23
|
+
text: 'An input the program accepts ends its run with a protection exception (S0C4)',
|
|
24
|
+
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',
|
|
25
|
+
remedy: 'Find the subscript, reference modification, pointer or parameter length the input controls at the line the journal names, and bound it before use',
|
|
26
|
+
},
|
|
27
|
+
'input-causes-abend-subscript-range': {
|
|
28
|
+
sev: 'high', evidence: 'execution', cwe: 'CWE-129',
|
|
29
|
+
text: 'An input the program accepts drives a subscript or reference modification out of range, and the run abends',
|
|
30
|
+
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',
|
|
31
|
+
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',
|
|
32
|
+
},
|
|
33
|
+
'input-causes-abend': {
|
|
34
|
+
sev: 'med', evidence: 'execution', cwe: 'CWE-248',
|
|
35
|
+
text: 'An input the program accepts ends its run with an abend',
|
|
36
|
+
impact: 'Whoever supplies that input can stop the program at will, and the abend code says what the program failed to handle',
|
|
37
|
+
remedy: 'Read the abend code and the line the journal names, and handle the condition the input raises before it ends the run',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// IBM's messages for an SSRANGE failure: a subscript or index, an OCCURS DEPENDING ON object, and a
|
|
42
|
+
// reference modification's start, length, and start plus length.
|
|
43
|
+
const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
|
|
44
|
+
|
|
45
|
+
export function abendRunPaths(opts = {}) {
|
|
46
|
+
if (opts.abendRuns) return opts.abendRuns;
|
|
47
|
+
return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function abendRule(abend) {
|
|
51
|
+
if (abend.code === 'S0C7') return 'input-causes-abend-s0c7';
|
|
52
|
+
if (abend.code === 'S0C4') return 'input-causes-abend-s0c4';
|
|
53
|
+
if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
|
|
54
|
+
return 'input-causes-abend';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The abend a kept run's own journal records, with the sources it read and the programs it CALLed,
|
|
58
|
+
// or null.
|
|
59
|
+
function journalAbend(evidenceDir, runId) {
|
|
60
|
+
if (typeof runId !== 'string' || !/^[0-9TZ]+-[0-9a-f]{16}$/.test(runId)) return null;
|
|
61
|
+
const file = join(evidenceDir, 'runs', `${runId}.jsonl`);
|
|
62
|
+
if (!existsSync(file)) return null;
|
|
63
|
+
let abend = null;
|
|
64
|
+
const inputs = [];
|
|
65
|
+
const calls = [];
|
|
66
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
const rec = JSON.parse(line);
|
|
69
|
+
if (rec.kind === 'abend') abend ??= rec;
|
|
70
|
+
else if (rec.kind === 'input') inputs.push(rec);
|
|
71
|
+
else if (rec.kind === 'call') calls.push(rec);
|
|
72
|
+
}
|
|
73
|
+
return abend && { ...abend, inputs, calls };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isString = (x) => typeof x === 'string' && x.length > 0;
|
|
77
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
78
|
+
|
|
79
|
+
// A manifest root as a directory of the scanned tree, or null when it is not one.
|
|
80
|
+
function treeDir(dir) {
|
|
81
|
+
if (typeof dir !== 'string') return null;
|
|
82
|
+
const norm = posix.normalize(dir.replace(/\\/g, '/'));
|
|
83
|
+
return norm === '..' || norm.startsWith('../') || posix.isAbsolute(norm) ? null : norm;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The directory in the scanned tree of the root the run read `file` from, located by the manifest's
|
|
87
|
+
// roots: the root the journal's input record for that file names, or for a CALLed program's source,
|
|
88
|
+
// which its call record names by path and digest alone, the root under which the tree holds that
|
|
89
|
+
// file with that digest. Undefined where neither record names the file, null where the root cannot
|
|
90
|
+
// be told or lies outside the tree.
|
|
91
|
+
function rootDirectory(roots, journal, file, tree) {
|
|
92
|
+
if (!Array.isArray(roots)) return undefined;
|
|
93
|
+
const held = [...new Set(journal.inputs.filter((i) => i.path === file && Number.isInteger(i.root)).map((i) => i.root))];
|
|
94
|
+
if (held.length) return held.length === 1 ? treeDir(roots[held[0]]) : null;
|
|
95
|
+
const digests = new Set(journal.calls.filter((c) => c.from === file).map((c) => c.sha256));
|
|
96
|
+
if (!digests.size || !tree) return undefined;
|
|
97
|
+
const holding = [...new Set(roots.map(treeDir).filter((d) => d !== null && existsSync(join(tree, d, file))))];
|
|
98
|
+
const same = holding.filter((d) => digests.has(sha256(readFileSync(join(tree, d, file)))));
|
|
99
|
+
const found = same.length ? same : holding;
|
|
100
|
+
return found.length === 1 ? found[0] : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One fuzz run: its findings, and what kept any of it from counting. `tree` is the scanned tree,
|
|
104
|
+
// where a CALLed program's source is looked for.
|
|
105
|
+
export function loadAbendRun(dir, tree) {
|
|
106
|
+
const out = { dir: basename(dir), program: null, findings: [], problems: [], counts: null, notModelled: 0 };
|
|
107
|
+
let doc;
|
|
108
|
+
try { doc = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); } catch (e) {
|
|
109
|
+
out.problems.push(e.code ? `manifest.json could not be read (${e.code})` : `manifest.json is not JSON (${e.message})`);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
if (!doc || doc.tool !== 'ironwork-fuzz' || !doc.program || !isString(doc.program.file) || !Array.isArray(doc.inputs) || !Array.isArray(doc.runs)) {
|
|
113
|
+
out.problems.push('manifest.json is not an ironwork-fuzz manifest with a program, inputs and runs');
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
const programFile = posix.normalize(doc.program.file.replace(/\\/g, '/'));
|
|
117
|
+
if (programFile.startsWith('../') || posix.isAbsolute(programFile)) {
|
|
118
|
+
out.problems.push(`the program ${doc.program.file} is outside the scanned tree`);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
out.program = { file: programFile, id: isString(doc.program.id) ? doc.program.id : null };
|
|
122
|
+
out.counts = doc.counts && typeof doc.counts === 'object' ? doc.counts : null;
|
|
123
|
+
|
|
124
|
+
const evidenceDir = join(dir, 'evidence');
|
|
125
|
+
const verdict = verifyEvidence(evidenceDir);
|
|
126
|
+
if (!verdict.verified) {
|
|
127
|
+
out.problems.push(`its evidence does not verify: ${verdict.broken.map((b) => `${b.file}:${b.line} ${b.check}`).join(', ') || 'nothing is recorded'}`);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
const inputs = new Map(doc.inputs.filter((i) => i && isString(i.id)).map((i) => [i.id, i]));
|
|
131
|
+
const seen = new Map();
|
|
132
|
+
for (const run of doc.runs) {
|
|
133
|
+
if (!run || run.outcome !== 'abend' || !run.abend || !isString(run.abend.code)) continue;
|
|
134
|
+
if (run.abend.code === 'IRONWORK') { out.notModelled++; continue; }
|
|
135
|
+
const recorded = journalAbend(evidenceDir, run.journal);
|
|
136
|
+
if (!recorded || recorded.code !== run.abend.code) {
|
|
137
|
+
out.problems.push(`run ${run.journal ?? '(unnamed)'}: its journal does not record the abend ${run.abend.code} the manifest gives`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// Where the journal names the abend's place, that is the place, and a manifest that says otherwise is not believed.
|
|
141
|
+
const claimed = { file: run.abend.file, line: run.abend.line };
|
|
142
|
+
const place = isString(recorded.file) && Number.isInteger(recorded.line) ? { file: recorded.file, line: recorded.line } : claimed;
|
|
143
|
+
if (place !== claimed && ((isString(claimed.file) && claimed.file !== place.file) || (claimed.line != null && claimed.line !== place.line))) {
|
|
144
|
+
out.problems.push(`run ${run.journal}: its journal records the abend at ${place.file}:${place.line}, not where the manifest puts it`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const rootDir = isString(place.file) ? rootDirectory(doc.roots, recorded, place.file, tree) : undefined;
|
|
148
|
+
if (rootDir === null) {
|
|
149
|
+
out.problems.push(`run ${run.journal}: the abend is in ${place.file}, which its journal and manifest do not place in the scanned tree`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
|
|
153
|
+
const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
|
|
154
|
+
const rule = abendRule(run.abend);
|
|
155
|
+
const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
|
|
156
|
+
.map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}) }));
|
|
157
|
+
const key = `${rule}|${where}|${line}`;
|
|
158
|
+
const size = used.reduce((n, i) => n + String(i.bytes || '').length, 0);
|
|
159
|
+
const held = seen.get(key);
|
|
160
|
+
if (held && held.size <= size) continue;
|
|
161
|
+
const finding = {
|
|
162
|
+
rule, path: where, line, program: out.program.id,
|
|
163
|
+
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`,
|
|
164
|
+
abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}) },
|
|
165
|
+
input: used, run: { dir: out.dir, journal: run.journal },
|
|
166
|
+
};
|
|
167
|
+
seen.set(key, { size, finding });
|
|
168
|
+
}
|
|
169
|
+
out.findings = [...seen.values()].map((s) => s.finding);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function scanAbend(root, opts = {}) {
|
|
174
|
+
const dirs = abendRunPaths(opts);
|
|
175
|
+
const runs = dirs.map((d) => loadAbendRun(d, root));
|
|
176
|
+
const findings = runs.flatMap((r) => r.findings.filter((f) => existsSync(join(root, f.path))));
|
|
177
|
+
const stats = {
|
|
178
|
+
filesScanned: 0, filesUnreadable: 0,
|
|
179
|
+
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 } : {}) })),
|
|
180
|
+
...(runs.some((r) => r.problems.length) ? { abendRunProblems: runs.flatMap((r) => r.problems.map((p) => `${r.dir}: ${p}`)) } : {}),
|
|
181
|
+
...(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}`)) } : {}),
|
|
182
|
+
setIncomplete: runs.some((r) => r.problems.length > 0),
|
|
183
|
+
};
|
|
184
|
+
return report('abend', { rules: ABEND_RULES, findings, stats, run: null });
|
|
185
|
+
}
|
|
186
|
+
|
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;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// What a program asks ICSF for. A COBOL program reaches z/OS cryptography by CALLing an ICSF
|
|
3
|
+
// callable service with positional arguments, and the strength it gets is in those arguments: the
|
|
4
|
+
// key length it generates, the hash it names in a rule array, the initialization vector it passes.
|
|
5
|
+
// rules/icsf-services.json holds each service's parameters in order, from IBM's manual, and which
|
|
6
|
+
// keywords each rule reports and why.
|
|
7
|
+
//
|
|
8
|
+
// An argument is judged by the literals that can reach it (lib/constants.mjs). One a computation, a
|
|
9
|
+
// read or another program fills is not judged, and is counted as undecided rather than passed.
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { inScope, isProgram, relPath } from '../sources.mjs';
|
|
12
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
13
|
+
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
14
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
15
|
+
import { constantsReaching, keywordsOf } from '../constants.mjs';
|
|
16
|
+
|
|
17
|
+
const TABLE = JSON.parse(readFileSync(new URL('../../rules/icsf-services.json', import.meta.url), 'utf8'));
|
|
18
|
+
|
|
19
|
+
export const CRYPTO_RULES = {
|
|
20
|
+
'icsf-single-length-des-key': {
|
|
21
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-327',
|
|
22
|
+
text: 'A program generates a single-length DES key through ICSF',
|
|
23
|
+
impact: 'The key is 56 bits, which exhaustive search recovers, and every later encipher with it runs single DES, so the data it protects is readable by anyone who takes the ciphertext',
|
|
24
|
+
remedy: 'Generate an AES key (key_type_1 AESDATA with KEYLN16 or longer, or CSNBKGN2) and encipher with CSNBSAE or CSNBSYE and AES',
|
|
25
|
+
},
|
|
26
|
+
'icsf-weak-hash': {
|
|
27
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-328',
|
|
28
|
+
text: 'A program hashes with MD5 or SHA-1 through ICSF',
|
|
29
|
+
impact: 'Collisions are practical for both, so a digest no longer shows the text was not changed, and an MD5 or SHA-1 of a password is quick to reverse for a short one',
|
|
30
|
+
remedy: 'Name SHA-256 or longer in the rule array, or BCRYPT for a password',
|
|
31
|
+
},
|
|
32
|
+
'icsf-fixed-initialization-vector': {
|
|
33
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-1204',
|
|
34
|
+
text: 'A program enciphers through ICSF with an initialization vector that never changes',
|
|
35
|
+
impact: 'Equal messages encipher to equal ciphertext, and under CUSP or IPS a message shorter than a block is XORed with the same keystream every time, so whoever sees the ciphertexts learns which are equal and can recover short ones',
|
|
36
|
+
remedy: 'Pass a vector generated for each message (CSNBRNG, or CSNBRNGL) and send it with the ciphertext',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Every name a service answers to, 31- and 64-bit, CSF and ALET forms, to its entry.
|
|
41
|
+
const SERVICES = new Map();
|
|
42
|
+
for (const s of TABLE.services) {
|
|
43
|
+
const a = s.entry_points || {};
|
|
44
|
+
const names = [s.name, ...(a['31-bit'] || []), ...(a['64-bit'] || []), ...((a.alet && a.alet['31-bit']) || []), ...((a.alet && a.alet['64-bit']) || [])];
|
|
45
|
+
for (const n of names) SERVICES.set(n.toUpperCase(), s);
|
|
46
|
+
}
|
|
47
|
+
const F = TABLE.findings;
|
|
48
|
+
const ruleFor = { 'single-length-des-key': 'icsf-single-length-des-key', 'weak-hash': 'icsf-weak-hash' };
|
|
49
|
+
const isIcsf = (c) => SERVICES.has(String(c.name || '').toUpperCase());
|
|
50
|
+
|
|
51
|
+
const argument = (call, service, parameter) => {
|
|
52
|
+
const i = service.parameters.indexOf(parameter);
|
|
53
|
+
return i >= 0 && call.using[i] && call.using[i].word ? call.using[i].word : null;
|
|
54
|
+
};
|
|
55
|
+
const shown = (values) => [...new Set(values.map((v) => (/^[A-Z-]+$/.test(v) ? v : `'${v.trim()}'`)))].join(', ');
|
|
56
|
+
|
|
57
|
+
export function scanCrypto(root, opts = {}) {
|
|
58
|
+
const tree = treeFor(root, opts);
|
|
59
|
+
const files = tree.list().filter(isProgram).filter(inScope(opts));
|
|
60
|
+
const findings = [];
|
|
61
|
+
const stats = { filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0, icsfCalls: 0, argumentsUndecided: 0 };
|
|
62
|
+
|
|
63
|
+
const run = eachWithinMemory(files, (f) => {
|
|
64
|
+
let src;
|
|
65
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
66
|
+
if (!/CALL\s+['"]?CS[NF]/i.test(src)) return src.length;
|
|
67
|
+
let r;
|
|
68
|
+
try { r = tree.parse(f, src); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
|
|
69
|
+
stats.filesScanned++;
|
|
70
|
+
const path = relPath(root, f);
|
|
71
|
+
for (const p of r.programs) {
|
|
72
|
+
for (const c of p.calls || []) {
|
|
73
|
+
const service = SERVICES.get(String(c.name || '').toUpperCase());
|
|
74
|
+
if (!service || c.kind !== 'L') continue;
|
|
75
|
+
stats.icsfCalls++;
|
|
76
|
+
const at = { path: c.file ? relPath(root, c.file) : path, line: c.line || 1, program: p.id };
|
|
77
|
+
const read = (parameter) => {
|
|
78
|
+
const name = argument(c, service, parameter);
|
|
79
|
+
if (!name) return null;
|
|
80
|
+
const got = constantsReaching(p, name, { ignoreCall: isIcsf });
|
|
81
|
+
return { name, ...got, keywords: keywordsOf(got.values) };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const [key, rule] of Object.entries(ruleFor)) {
|
|
85
|
+
const fd = F[key];
|
|
86
|
+
if (!fd.services.includes(service.name)) continue;
|
|
87
|
+
const arg = read(fd.parameter);
|
|
88
|
+
if (!arg) continue;
|
|
89
|
+
const weak = fd.keywords.filter((k) => arg.keywords.has(k));
|
|
90
|
+
if (!weak.length) { if (arg.open || !arg.values.length) stats.argumentsUndecided++; continue; }
|
|
91
|
+
findings.push({ rule, ...at,
|
|
92
|
+
detail: `CALL '${c.name}' passes ${arg.name} as ${fd.parameter}, and ${arg.name} can hold ${weak.join(' or ')}` });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const iv = F['fixed-initialization-vector'];
|
|
96
|
+
if (!iv.services.includes(service.name)) continue;
|
|
97
|
+
const rules = read('rule_array');
|
|
98
|
+
if (rules && iv.withoutVector.some((k) => rules.keywords.has(k))) continue;
|
|
99
|
+
const vector = read(iv.parameter);
|
|
100
|
+
if (!vector) continue;
|
|
101
|
+
if (vector.open || !vector.values.length) { stats.argumentsUndecided++; continue; }
|
|
102
|
+
findings.push({ rule: 'icsf-fixed-initialization-vector', ...at,
|
|
103
|
+
detail: `CALL '${c.name}' passes ${vector.name} as its initialization vector, and nothing but ${shown(vector.values)} is ever put in it` });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return src.length;
|
|
107
|
+
}, { label: 'crypto', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
108
|
+
|
|
109
|
+
return report('crypto', { rules: CRYPTO_RULES, findings, stats, run });
|
|
110
|
+
}
|
package/lib/sets/flow.mjs
CHANGED
|
@@ -349,6 +349,21 @@ const DEFAULT = { sev: 'med', evidence: 'path', cwe: null, text: 'Untrusted inpu
|
|
|
349
349
|
// is safe for the sink - one of a list of literals, digits where digits are needed, a bound where an
|
|
350
350
|
// index is - is not a finding at all, and is listed under `checked` with the check that stops it.
|
|
351
351
|
const LOWER = { crit: 'high', high: 'med', med: 'low', low: 'info', info: 'info' };
|
|
352
|
+
|
|
353
|
+
// The routes of two sources that reach one sink, as one finding carries them: every source, every
|
|
354
|
+
// statement either route runs, and complete only where both are.
|
|
355
|
+
const routeKey = (s) => `${s.file}:${s.line}:${s.verb ?? s.kind}`;
|
|
356
|
+
function mergeRoutes(a, b) {
|
|
357
|
+
const union = (x, y) => [...new Map([...x, ...y].map((s) => [routeKey(s), s])).values()];
|
|
358
|
+
const order = (p, q) => (p.file < q.file ? -1 : p.file > q.file ? 1 : p.line - q.line);
|
|
359
|
+
const unplaced = (a.unplaced || 0) + (b.unplaced || 0);
|
|
360
|
+
return {
|
|
361
|
+
sources: union(a.sources, b.sources).sort(order),
|
|
362
|
+
statements: union(a.statements, b.statements).sort(order),
|
|
363
|
+
...(unplaced ? { unplaced } : {}),
|
|
364
|
+
complete: a.complete && b.complete,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
352
367
|
// One graph per repository, for the reason scanAll gives: names are only unique within one.
|
|
353
368
|
function analyzeEach(root, opts) {
|
|
354
369
|
if (!opts.repos || opts.repos.length < 2) return analyze(root, opts);
|
|
@@ -404,6 +419,7 @@ export function scan(root, opts = {}) {
|
|
|
404
419
|
trace: f.path.map(p => ({ program: p.program, item: p.item, file: p.file, via: p.via, ...(p.dir ? { dir: p.dir } : {}), ...(p.elided ? { elided: p.elided } : {}) })),
|
|
405
420
|
related: [{ path: f.source.file, line: f.source.line, detail: f.source.detail }],
|
|
406
421
|
...(f.also ? { also: f.also } : {}),
|
|
422
|
+
...(f.routes ? { routes: f.routes } : {}),
|
|
407
423
|
});
|
|
408
424
|
}
|
|
409
425
|
// One sink reached by many sources is one finding, not many: a report where a single statement
|
|
@@ -417,9 +433,11 @@ export function scan(root, opts = {}) {
|
|
|
417
433
|
// A sink is credited only as far as the least-credited source that reaches it.
|
|
418
434
|
const level = Math.min(held.level, f.level);
|
|
419
435
|
const also = [...(held.also || []), ...(f.also || [])];
|
|
436
|
+
const routes = held.routes && f.routes ? mergeRoutes(held.routes, f.routes) : null;
|
|
420
437
|
if (f.hops < held.hops) Object.assign(held, f, { sources: held.sources });
|
|
421
438
|
held.level = level;
|
|
422
439
|
if (also.length) held.also = also;
|
|
440
|
+
if (routes) held.routes = routes;
|
|
423
441
|
}
|
|
424
442
|
// A use one source's route reaches unchecked, and the report leaves out for another use of the same
|
|
425
443
|
// index, still limits how far a finding at that use is credited.
|
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 = [];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Assembler stubs are where a COBOL estate does what COBOL cannot: switch to supervisor state, reach
|
|
3
|
+
// another address space, run an instruction whose length or mask is decided at run time. This set
|
|
4
|
+
// reads HLASM source as cards and reports those operations where they are written. It does not
|
|
5
|
+
// assemble: macros are not expanded and conditional assembly is not evaluated (lib/hlasm.mjs).
|
|
6
|
+
//
|
|
7
|
+
// It also answers the question the COBOL side cannot: a CALL 'SUBRTN' with no COBOL program of that
|
|
8
|
+
// name may be an assembler module, and a CSECT or ENTRY of that name here is where it is.
|
|
9
|
+
import { inScope, isAssembler, isProgram, relPath } from '../sources.mjs';
|
|
10
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
11
|
+
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
|
12
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
13
|
+
import { readHlasm, OPERATIONS } from '../hlasm.mjs';
|
|
14
|
+
|
|
15
|
+
export const HLASM_RULES = {
|
|
16
|
+
'hlasm-supervisor-state-change': {
|
|
17
|
+
sev: 'crit', evidence: 'construct', cwe: 'CWE-250',
|
|
18
|
+
text: 'Assembler source switches to PSW key zero or supervisor state',
|
|
19
|
+
impact: 'The code after the switch runs with the operating system\'s authority: it can read and change any storage, so a flaw in it is a flaw in the system rather than in one job',
|
|
20
|
+
remedy: 'Keep the authorized code to the instructions that need it, return to the caller\'s key and problem state straight after (MODESET KEY=NZERO, MODE=PROB), and APF-authorize the module only where it has to be',
|
|
21
|
+
},
|
|
22
|
+
'hlasm-executes-built-instruction': {
|
|
23
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-119',
|
|
24
|
+
text: 'Assembler source runs an instruction whose length or mask comes from a register at run time',
|
|
25
|
+
impact: 'EX and EXRL run their target instruction with its second byte taken from a register, which for a move is its length: a length from input moves past the end of the field it was meant for',
|
|
26
|
+
remedy: 'Bound the register against the target field\'s length before the EX, and keep the target instruction out of the code a caller can reach any other way',
|
|
27
|
+
},
|
|
28
|
+
'hlasm-cross-memory-service': {
|
|
29
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-668',
|
|
30
|
+
text: 'Assembler source uses a cross-memory instruction, reaching another address space or another authority',
|
|
31
|
+
impact: 'A program call, a program transfer or a change of secondary address space moves execution or data access across address spaces, under the authority the linkage tables grant rather than the caller\'s own',
|
|
32
|
+
remedy: 'Confirm the entry tables and authorization indexes grant only the address spaces and keys the service needs, and that every PC routine validates what its caller passes',
|
|
33
|
+
},
|
|
34
|
+
'hlasm-calls-security-product': { sev: 'info', evidence: 'context', cwe: null, text: 'Assembler source calls the security product directly' },
|
|
35
|
+
'hlasm-provides-called-module': { sev: 'info', evidence: 'context', cwe: null, text: 'An assembler CSECT or ENTRY is a module a COBOL program in this tree CALLs' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const RULE_OF_CLASS = {
|
|
39
|
+
'cross-memory': 'hlasm-cross-memory-service',
|
|
40
|
+
'built-instruction': 'hlasm-executes-built-instruction',
|
|
41
|
+
'security-product': 'hlasm-calls-security-product',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STATE = { KEY: 'PSW key zero', EXTKEY: 'PSW key zero', MODE: 'supervisor state' };
|
|
45
|
+
|
|
46
|
+
const where = (use) => (use.inMacro ? ' It is in a macro definition, so it is in every program that uses the macro.' : '');
|
|
47
|
+
|
|
48
|
+
function detail(use) {
|
|
49
|
+
const written = `${use.name}${use.field ? ` ${use.field}` : ''}`;
|
|
50
|
+
if (use.class === 'security-product') {
|
|
51
|
+
const request = /\bREQUEST=([A-Z]+)/i.exec(use.field || '');
|
|
52
|
+
return `${request ? `${use.name} REQUEST=${request[1].toUpperCase()}` : use.name} calls the security product from assembler rather than through a service that checks on the program's behalf.${where(use)}`;
|
|
53
|
+
}
|
|
54
|
+
return `${written}: ${OPERATIONS.get(use.name).summary}${where(use)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A COBOL CALL of a literal name, outside comment lines. A membership question over every program,
|
|
58
|
+
// answered with a regular expression as lib/sets/jcl.mjs answers PROGRAM-ID.
|
|
59
|
+
const CALL = /\bCALL\s+['"]([A-Z0-9$#@_-]{1,30})['"]/gi;
|
|
60
|
+
|
|
61
|
+
function cobolCalls(source) {
|
|
62
|
+
const calls = [];
|
|
63
|
+
source.split(/\r?\n/).forEach((text, i) => {
|
|
64
|
+
if (text.length > 6 && '*/'.includes(text[6])) return;
|
|
65
|
+
for (const m of text.matchAll(CALL)) calls.push({ name: m[1].toUpperCase(), line: i + 1 });
|
|
66
|
+
});
|
|
67
|
+
return calls;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function scanHlasm(root, opts = {}) {
|
|
71
|
+
const tree = treeFor(root, opts);
|
|
72
|
+
const all = tree.list().filter(inScope(opts));
|
|
73
|
+
const findings = [];
|
|
74
|
+
const stats = { filesScanned: 0, filesUnreadable: 0, assemblerFiles: 0, bmsFiles: 0, imsFiles: 0, unrecognisedFiles: 0, readNotAssembled: true };
|
|
75
|
+
const defines = new Map();
|
|
76
|
+
|
|
77
|
+
const run = eachWithinMemory(all.filter(isAssembler), (f) => {
|
|
78
|
+
let src;
|
|
79
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
80
|
+
stats.filesScanned++;
|
|
81
|
+
const r = readHlasm(src);
|
|
82
|
+
if (r.kind === 'bms') { stats.bmsFiles++; return src.length; }
|
|
83
|
+
if (r.kind === 'ims') { stats.imsFiles++; return src.length; }
|
|
84
|
+
if (r.kind === 'unrecognised') { stats.unrecognisedFiles++; return src.length; }
|
|
85
|
+
stats.assemblerFiles++;
|
|
86
|
+
const path = relPath(root, f);
|
|
87
|
+
for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
|
|
88
|
+
for (const use of r.operations) {
|
|
89
|
+
if (use.name === 'MODESET') {
|
|
90
|
+
if (use.stateChange) findings.push({ rule: 'hlasm-supervisor-state-change', path, line: use.line, detail: `MODESET ${use.stateChange} switches to ${STATE[use.stateChange.split('=')[0]]}.${where(use)}` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const rule = RULE_OF_CLASS[use.class];
|
|
94
|
+
if (rule) findings.push({ rule, path, line: use.line, detail: detail(use) });
|
|
95
|
+
}
|
|
96
|
+
return src.length;
|
|
97
|
+
}, { label: 'hlasm', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
98
|
+
|
|
99
|
+
if (defines.size) {
|
|
100
|
+
const callers = new Map();
|
|
101
|
+
const programs = eachWithinMemory(all.filter(isProgram), (f) => {
|
|
102
|
+
let src;
|
|
103
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
104
|
+
for (const c of cobolCalls(src)) {
|
|
105
|
+
if (!defines.has(c.name)) continue;
|
|
106
|
+
if (!callers.has(c.name)) callers.set(c.name, []);
|
|
107
|
+
callers.get(c.name).push({ path: relPath(root, f), line: c.line });
|
|
108
|
+
}
|
|
109
|
+
return src.length;
|
|
110
|
+
}, { label: 'hlasm-callers', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
111
|
+
if (programs.skipped.length) stats.callersNotRead = programs.skipped.length;
|
|
112
|
+
for (const [name, calls] of callers) {
|
|
113
|
+
const d = defines.get(name);
|
|
114
|
+
const first = calls[0];
|
|
115
|
+
findings.push({ rule: 'hlasm-provides-called-module', path: d.path, line: d.line,
|
|
116
|
+
detail: `${d.how} ${name} is the module CALL '${name}' reaches from ${first.path}:${first.line}${calls.length > 1 ? ` and ${calls.length - 1} other CALL${calls.length > 2 ? 's' : ''}` : ''}` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return report('hlasm', { rules: HLASM_RULES, findings, stats, run });
|
|
121
|
+
}
|
package/lib/sets/jcl.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// authority, against which dataset; an in-stream SYSIN carries commands that never appear in any
|
|
4
4
|
// program the scanner reads. The 2026-09-18 evaluation counted 27 JCL files carrying RACF or
|
|
5
5
|
// IDCAMS commands in-stream, so this is where a repository's privileged surface actually lives.
|
|
6
|
-
import { inScope, isJcl, isProgram, relPath } from '../sources.mjs';
|
|
6
|
+
import { inScope, isAssembler, isJcl, isProgram, relPath } from '../sources.mjs';
|
|
7
7
|
import { report } from '../kernel/ruleset.mjs';
|
|
8
8
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
9
9
|
import { parseJcl, dispositionOf } from '../jcl.mjs';
|
|
10
|
+
import { readHlasm } from '../hlasm.mjs';
|
|
10
11
|
import { ddGroups, tsoCommands } from '../utilities.mjs';
|
|
11
12
|
import { ftpSession } from '../ftp.mjs';
|
|
12
13
|
import { loadSite, productionQualifierOf, SITE_FILE } from '../site.mjs';
|
|
@@ -154,6 +155,7 @@ const DESTRUCTIVE = /^\s*(DELETE(?!\s+FROM\b)|ALTER\s+\S+\s+NEWNAME|REPRO\b[^\n]
|
|
|
154
155
|
// symbol ends it. An empty qualifier is a symbol the reader already filled with an empty default.
|
|
155
156
|
const dsnKey = (dsn) => String(dsn || '').replace(/^['(]+|[')]+$/g, '').replace(/\(.*$/, '').toUpperCase()
|
|
156
157
|
.replace(/&[A-Z0-9@#$]+\.?/g, '*').replace(/<[^>]*>/g, '*').replace(/\.(?=\.)/g, '.*');
|
|
158
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- k is escaped, and * widened to one qualifier
|
|
157
159
|
const dsnPattern = (k) => new RegExp(`^${k.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^.]+')}$`);
|
|
158
160
|
|
|
159
161
|
// The programs whose in-stream data can delete a catalogued dataset. A step running another known
|
|
@@ -227,12 +229,25 @@ function programIds(tree, files, unread) {
|
|
|
227
229
|
return ids;
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
// The modules assembler source in the tree defines: each CSECT and ENTRY name, which a step can run
|
|
233
|
+
// as it runs a COBOL program.
|
|
234
|
+
function assemblerModules(tree, files, unread) {
|
|
235
|
+
const names = [];
|
|
236
|
+
for (const f of files) {
|
|
237
|
+
let src;
|
|
238
|
+
try { src = tree.text(f).text; } catch (e) { unread.push(`${tree.rel(f)}: ${e.code || e.name}`); continue; }
|
|
239
|
+
for (const d of readHlasm(src).defines) names.push(d.name);
|
|
240
|
+
}
|
|
241
|
+
return names;
|
|
242
|
+
}
|
|
243
|
+
|
|
230
244
|
export function scanJcl(root, opts = {}) {
|
|
231
245
|
const tree = treeFor(root, opts);
|
|
232
246
|
const all = tree.list().filter(inScope(opts));
|
|
233
247
|
const jclFiles = all.filter(isJcl);
|
|
234
248
|
const programsUnread = [];
|
|
235
249
|
const defined = programIds(tree, all.filter(isProgram), programsUnread);
|
|
250
|
+
for (const name of assemblerModules(tree, all.filter(isAssembler), programsUnread)) defined.add(name);
|
|
236
251
|
|
|
237
252
|
// A vendor product supplies its own utilities. An estate that has loaded the pack for a product
|
|
238
253
|
// is telling us those programs exist, so reporting them as undefined would be a false positive
|
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
|