@portll/cobolwork 0.4.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.
@@ -6,6 +6,7 @@
6
6
  // where the evidence verifies and the run's own journal records the abend the manifest claims, so
7
7
  // the finding rests on a record of the run rather than on the manifest's word.
8
8
  import { readFileSync, existsSync } from 'node:fs';
9
+ import { createHash } from 'node:crypto';
9
10
  import { delimiter, join, posix, basename } from 'node:path';
10
11
  import { report } from '../kernel/ruleset.mjs';
11
12
  import { verifyEvidence } from '../evidence/verify.mjs';
@@ -53,23 +54,55 @@ export function abendRule(abend) {
53
54
  return 'input-causes-abend';
54
55
  }
55
56
 
56
- // The abend a kept run's own journal records, or null.
57
+ // The abend a kept run's own journal records, with the sources it read and the programs it CALLed,
58
+ // or null.
57
59
  function journalAbend(evidenceDir, runId) {
58
60
  if (typeof runId !== 'string' || !/^[0-9TZ]+-[0-9a-f]{16}$/.test(runId)) return null;
59
61
  const file = join(evidenceDir, 'runs', `${runId}.jsonl`);
60
62
  if (!existsSync(file)) return null;
63
+ let abend = null;
64
+ const inputs = [];
65
+ const calls = [];
61
66
  for (const line of readFileSync(file, 'utf8').split('\n')) {
62
67
  if (!line) continue;
63
68
  const rec = JSON.parse(line);
64
- if (rec.kind === 'abend') return rec;
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);
65
72
  }
66
- return null;
73
+ return abend && { ...abend, inputs, calls };
67
74
  }
68
75
 
69
76
  const isString = (x) => typeof x === 'string' && x.length > 0;
77
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
70
78
 
71
- // One fuzz run: its findings, and what kept any of it from counting.
72
- export function loadAbendRun(dir) {
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) {
73
106
  const out = { dir: basename(dir), program: null, findings: [], problems: [], counts: null, notModelled: 0 };
74
107
  let doc;
75
108
  try { doc = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); } catch (e) {
@@ -111,7 +144,12 @@ export function loadAbendRun(dir) {
111
144
  out.problems.push(`run ${run.journal}: its journal records the abend at ${place.file}:${place.line}, not where the manifest puts it`);
112
145
  continue;
113
146
  }
114
- const where = isString(place.file) ? posix.normalize(posix.join(posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
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;
115
153
  const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
116
154
  const rule = abendRule(run.abend);
117
155
  const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
@@ -134,7 +172,7 @@ export function loadAbendRun(dir) {
134
172
 
135
173
  export function scanAbend(root, opts = {}) {
136
174
  const dirs = abendRunPaths(opts);
137
- const runs = dirs.map((d) => loadAbendRun(d));
175
+ const runs = dirs.map((d) => loadAbendRun(d, root));
138
176
  const findings = runs.flatMap((r) => r.findings.filter((f) => existsSync(join(root, f.path))));
139
177
  const stats = {
140
178
  filesScanned: 0, filesUnreadable: 0,
@@ -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.
@@ -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';
@@ -228,12 +229,25 @@ function programIds(tree, files, unread) {
228
229
  return ids;
229
230
  }
230
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
+
231
244
  export function scanJcl(root, opts = {}) {
232
245
  const tree = treeFor(root, opts);
233
246
  const all = tree.list().filter(inScope(opts));
234
247
  const jclFiles = all.filter(isJcl);
235
248
  const programsUnread = [];
236
249
  const defined = programIds(tree, all.filter(isProgram), programsUnread);
250
+ for (const name of assemblerModules(tree, all.filter(isAssembler), programsUnread)) defined.add(name);
237
251
 
238
252
  // A vendor product supplies its own utilities. An estate that has loaded the pack for a product
239
253
  // is telling us those programs exist, so reporting them as undefined would be a false positive
package/lib/sets/web.mjs CHANGED
@@ -62,6 +62,18 @@ export const WEB_RULES = {
62
62
  impact: 'The TCPIPSERVICE takes connections on a port with SSL off, so everything the session carries - the 3270 data stream, a SOAP body, a credential on a sign-on screen - crosses the network in the clear and can be read or changed in flight',
63
63
  remedy: 'Set SSL(YES) and name a certificate on the TCPIPSERVICE, or front it with a TLS-terminating proxy the estate controls and restrict the port to that proxy',
64
64
  },
65
+ 'web-client-opens-cleartext': {
66
+ sev: 'med', evidence: 'construct', cwe: 'CWE-319',
67
+ text: 'A program opens an outbound HTTP connection without TLS',
68
+ impact: 'WEB OPEN asks for HTTP rather than HTTPS, so the request the program sends and the response it reads cross the network in the clear, readable and changeable in flight, unless an AT-TLS policy outside the program encrypts them',
69
+ remedy: 'Open the connection with HTTPS, or through a client URIMAP with SCHEME(HTTPS); where AT-TLS secures it, use a URIMAP with ATTLS(AWARE) so the program fails rather than send in the clear',
70
+ },
71
+ 'web-receive-length-exceeds-area': {
72
+ sev: 'high', evidence: 'construct', cwe: 'CWE-805',
73
+ text: 'A web receive lets CICS write more bytes than the area it writes into',
74
+ impact: 'CICS copies up to MAXLENGTH bytes of the body into the INTO area, so a body longer than the area, which the other end of the connection chooses, overwrites the storage after it',
75
+ remedy: 'Pass a MAXLENGTH no greater than the INTO area, or LENGTH OF that area',
76
+ },
65
77
  'web-link-opens-without-noopener': {
66
78
  sev: 'low', evidence: 'construct', cwe: 'CWE-1022',
67
79
  text: 'A program writes a link that opens a new window with a handle back to this one',
@@ -92,6 +104,18 @@ function execOptions(exec) {
92
104
  return { opts, words };
93
105
  }
94
106
 
107
+ // The options written outside any parentheses, which is where a CVDA keyword such as HTTP or HTTPS
108
+ // stands; a word inside an operand is a data name.
109
+ function keywords(exec) {
110
+ const out = new Set();
111
+ let depth = 0;
112
+ for (const t of exec.toks) {
113
+ if (t.t === 'sep') depth += t.v === '(' ? 1 : t.v === ')' ? -1 : 0;
114
+ else if (t.t === 'word' && depth === 0) out.add(t.u);
115
+ }
116
+ return out;
117
+ }
118
+
95
119
  // A header name or value is only knowable when the program wrote it as a literal. NAME(WS-HDR-NAME)
96
120
  // names a field whose contents this rule set cannot see, and a rule that guessed would be inventing
97
121
  // evidence, so the statement is counted as unreadable and reported as coverage, not as a pass.
@@ -152,6 +176,7 @@ export function scanWeb(root, opts = {}) {
152
176
  const stats = {
153
177
  filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0,
154
178
  webPrograms: 0, headersWritten: 0, headersComputed: 0, htmlResponses: 0, listenersDefined: 0,
179
+ outboundOpens: 0, opensUndecided: 0,
155
180
  };
156
181
  const where = (x, path) => ({ path: x.file ? relPath(root, x.file) : path, line: x.line || 1 });
157
182
 
@@ -161,12 +186,53 @@ export function scanWeb(root, opts = {}) {
161
186
  const cookies = [];
162
187
  let computedHeaders = 0;
163
188
 
189
+ const opens = []; // every outbound WEB OPEN, and what it says about TLS
190
+ const receives = []; // every receive into an area with a length it can be judged by
191
+ const item = (name) => p.items.find((i) => i.name === name);
192
+ // A variable naming a URIMAP or a host usually holds one literal from its VALUE clause.
193
+ const valueOf = (toks) => {
194
+ if (!toks || !toks.length) return null;
195
+ const lit = literalOf(toks);
196
+ if (lit !== null) return lit.trim();
197
+ const it = toks[0].t === 'word' ? item(toks[0].u) : null;
198
+ const v = it && it.values && it.values.find((x) => x.t === 'lit');
199
+ return v ? String(v.v).trim() : null;
200
+ };
201
+ // MAXLENGTH as a literal, or LENGTH OF an item. A data item's value can change before the
202
+ // command runs, so it is not judged.
203
+ const lengthOf = (toks) => {
204
+ if (!toks || !toks.length) return null;
205
+ if (toks.length === 1 && (toks[0].t === 'num' || /^\d+$/.test(String(toks[0].v)))) return { bytes: Number(toks[0].v), written: String(toks[0].v) };
206
+ if (toks.length >= 3 && toks[0].u === 'LENGTH' && toks[1].u === 'OF' && toks[2].t === 'word') {
207
+ const it = item(toks[2].u);
208
+ return it && it.size ? { bytes: it.size, written: `LENGTH OF ${toks[2].u}` } : null;
209
+ }
210
+ return null;
211
+ };
212
+
164
213
  for (const e of p.execs) {
165
214
  if (e.kind !== 'CICS') continue;
166
215
  const { opts: o, words: w } = execOptions(e);
167
216
  if (!w.includes('WEB') && !w.includes('DOCUMENT')) continue;
168
217
  const at = where(e, path);
169
218
 
219
+ if (w[0] === 'WEB' && w[1] === 'OPEN') {
220
+ const k = keywords(e);
221
+ // SCHEME(HTTPS) and SCHEME(DFHVALUE(HTTPS)) name the CVDA as the keyword does; any other
222
+ // operand is a field whose value is set at run time.
223
+ const named = (o.get('SCHEME') || []).map((t) => t.u);
224
+ const cvda = named.length && named.every((x) => ['DFHVALUE', 'HTTP', 'HTTPS'].includes(x)) ? named.find((x) => x !== 'DFHVALUE') : null;
225
+ opens.push({ at, https: k.has('HTTPS') || cvda === 'HTTPS', http: k.has('HTTP') || cvda === 'HTTP',
226
+ urimap: o.has('URIMAP') ? valueOf(o.get('URIMAP')) : undefined, host: o.has('HOST') ? valueOf(o.get('HOST')) : null });
227
+ continue;
228
+ }
229
+ if (w[0] === 'WEB' && (w[1] === 'RECEIVE' || w[1] === 'CONVERSE') && o.has('INTO')) {
230
+ const into = o.get('INTO')[0];
231
+ const area = into && into.t === 'word' ? item(into.u) : null;
232
+ const max = lengthOf(o.get('MAXLENGTH'));
233
+ if (area && area.size && max) receives.push({ at, verb: w[1], area: into.u, size: area.size, max });
234
+ }
235
+
170
236
  if (w.includes('WRITE') && w.includes('HTTPHEADER')) {
171
237
  const name = literalOf(o.get('HTTPHEADER') || o.get('NAME'));
172
238
  const value = literalOf(o.get('VALUE'));
@@ -228,19 +294,23 @@ export function scanWeb(root, opts = {}) {
228
294
  });
229
295
  const testsMethod = conditions.some((c) => c.some((t) => t.t === 'lit' && HTTP_METHOD.test(String(t.v).trim())));
230
296
 
231
- return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod };
297
+ return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod, opens, receives };
232
298
  }
233
299
 
234
300
  // The listener that accepts the request these responses answer. It is defined in a CSD rather
235
301
  // than in a program, so this reads every file the way lib/sets/priv.mjs does: a DFHCSDUP listing
236
302
  // is named whatever the estate named it, and filtering on an extension is how a BMS count once
237
303
  // came out as zero.
304
+ // Client URIMAPs come from the same files: WEB OPEN URIMAP(name) takes its scheme from one.
305
+ const urimaps = new Map();
238
306
  for (const f of all) {
239
307
  let src;
240
308
  try { src = tree.text(f).text; } catch { continue; }
241
- if (!/^\s*DEFINE\s+TCPIPSERVICE\s*\(/im.test(src)) continue;
309
+ if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src)) continue;
242
310
  const path = relPath(root, f);
243
- for (const [name, svc] of parseCsd(src).tcpipservices) {
311
+ const csd = parseCsd(src);
312
+ for (const [name, u] of csd.urimaps) if (!urimaps.has(name)) urimaps.set(name, { ...u, path });
313
+ for (const [name, svc] of csd.tcpipservices) {
244
314
  stats.listenersDefined++;
245
315
  if (encrypted(svc)) continue;
246
316
  const carries = svc.protocol ? `${/^[AEIOU]/i.test(svc.protocol) ? 'an' : 'a'} ${svc.protocol} ` : '';
@@ -269,9 +339,34 @@ export function scanWeb(root, opts = {}) {
269
339
  // outbound session or answers a web request has said something worth judging even when it
270
340
  // writes no header and sends no page.
271
341
  const anything = v.headers.length || v.sends.length || v.cookies.length || v.looksHtml
272
- || v.uris.length || v.built.length || (v.webRequest && v.changes.length);
342
+ || v.uris.length || v.built.length || (v.webRequest && v.changes.length) || v.opens.length || v.receives.length;
273
343
  if (!anything) continue;
274
344
  stats.webPrograms++;
345
+
346
+ for (const o of v.opens) {
347
+ stats.outboundOpens++;
348
+ const to = o.host ? ` to ${o.host}` : '';
349
+ if (o.https) continue;
350
+ if (o.http) {
351
+ findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
352
+ detail: `${v.id} opens a connection${to} asking for HTTP rather than HTTPS, so what it sends and reads crosses the network in the clear unless an AT-TLS policy outside the program encrypts it` });
353
+ continue;
354
+ }
355
+ const u = o.urimap ? urimaps.get(o.urimap.toUpperCase()) : null;
356
+ if (u && /^HTTP$/i.test(u.scheme || '') && !/^AWARE$/i.test(u.attls || '')) {
357
+ findings.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
358
+ detail: `${v.id} opens a connection through URIMAP ${o.urimap.toUpperCase()}, which ${u.path}:${u.line} defines with SCHEME(HTTP)${u.host ? ` to ${u.host}` : ''} and no ATTLS(AWARE), so what it sends and reads crosses the network in the clear unless an AT-TLS policy encrypts it` });
359
+ continue;
360
+ }
361
+ // A scheme in a variable, or a URIMAP the tree does not define, is decided where this cannot see.
362
+ if (!u || !u.scheme) stats.opensUndecided++;
363
+ }
364
+
365
+ for (const r of v.receives) {
366
+ if (r.max.bytes <= r.size) continue;
367
+ findings.push({ rule: 'web-receive-length-exceeds-area', ...r.at, program: v.id,
368
+ detail: `${v.id} has WEB ${r.verb} pass up to MAXLENGTH(${r.max.written}), ${r.max.bytes} bytes, into ${r.area}, which is ${r.size} bytes` });
369
+ }
275
370
  stats.headersWritten += v.headers.length;
276
371
  stats.headersComputed += v.computedHeaders;
277
372
 
package/lib/sources.mjs CHANGED
@@ -14,6 +14,9 @@ export const PROGRAM_EXT = ['.cbl', '.cob', '.cobol', '.sqb', '.pco'];
14
14
  export const COPY_EXT = ['.cpy', '.copy', '.inc'];
15
15
  export const JCL_EXT = ['.jcl', '.job', '.proc', '.prc', '.cntl'];
16
16
  export const BMS_EXT = ['.bms'];
17
+ // Assembler source. A BMS map or an IMS DBD or PSB is also assembler macros, and is often named .asm;
18
+ // lib/hlasm.mjs tells them apart by the macros they hold.
19
+ export const HLASM_EXT = ['.asm', '.mac', '.mlc', '.hlasm', '.assemble'];
17
20
 
18
21
  const has = (list) => (p) => list.includes(extname(p).toLowerCase());
19
22
 
@@ -46,6 +49,11 @@ function kindOfHead(head) {
46
49
  // A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
47
50
  // classified before classifies differently.
48
51
  if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
52
+ // Assembler source defines a section or a macro, and declares storage or a base register, in the
53
+ // operation field; either alone is a word a text file can hold. Tested after BMS, which is
54
+ // assembler macros too.
55
+ if (/^[^*\s]*[ \t]+(?:CSECT|RSECT|START|MACRO)(?=\s|$)/m.test(text)
56
+ && /^[^*\s]*[ \t]+(?:USING|DC|DS|BALR|BASR|STM|STMG|MEND)(?=\s|$)/m.test(text)) return 'hlasm';
49
57
  return null;
50
58
  }
51
59
 
@@ -116,10 +124,12 @@ export const isProgram = byExtOrContent(PROGRAM_EXT, 'program');
116
124
  export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
117
125
  export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
118
126
  export const isBms = byExtOrContent(BMS_EXT, 'bms');
119
- // Not BMS, by either route: the sets that read every source file have not chosen to read maps.
127
+ export const isAssembler = byExtOrContent(HLASM_EXT, 'hlasm');
128
+ // Not BMS or assembler, by either route: the sets that read every source file have not chosen to
129
+ // read maps or assembler.
120
130
  export const isSource = (p) => (extname(p)
121
131
  ? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
122
- : ![null, 'bms'].includes(sniffKind(p)));
132
+ : ![null, 'bms', 'hlasm'].includes(sniffKind(p)));
123
133
 
124
134
  // `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
125
135
  // files on it and no others.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Portll <john@portll.net>",