@portll/cobolwork 0.5.0 → 0.6.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 +43 -11
- package/bin/cobolwork.mjs +27 -8
- package/lib/baseline.mjs +9 -0
- package/lib/build.mjs +33 -8
- package/lib/capabilities.mjs +4 -1
- package/lib/consequence.mjs +4 -0
- package/lib/control-reuse.mjs +113 -0
- package/lib/control.mjs +25 -1
- package/lib/dataflow.mjs +285 -86
- package/lib/equivalence.mjs +44 -5
- package/lib/evidence/cli.mjs +13 -5
- package/lib/evidence/record.mjs +16 -6
- package/lib/evidence/seal.mjs +17 -0
- package/lib/evidence/store.mjs +30 -14
- package/lib/evidence/timestamp.mjs +83 -0
- package/lib/evidence/verify.mjs +120 -42
- package/lib/hlasm/asm/data.mjs +116 -0
- package/lib/hlasm/asm/listing.mjs +43 -0
- package/lib/hlasm/asm/output.mjs +45 -0
- package/lib/hlasm/asm/sections.mjs +159 -0
- package/lib/hlasm/asm/symbols.mjs +78 -0
- package/lib/hlasm/exec.mjs +27 -0
- package/lib/hlasm/expr.mjs +167 -0
- package/lib/hlasm/instr.mjs +48 -0
- package/lib/hlasm/locate.mjs +333 -0
- package/lib/hlasm/macro/authorization.mjs +126 -0
- package/lib/hlasm/macro/datasets.mjs +94 -0
- package/lib/hlasm/macro/io.mjs +139 -0
- package/lib/hlasm/macro/le.mjs +53 -0
- package/lib/hlasm/macro/linkage.mjs +143 -0
- package/lib/hlasm/macro/operator.mjs +66 -0
- package/lib/hlasm/macro/program.mjs +142 -0
- package/lib/hlasm/macro/recovery.mjs +80 -0
- package/lib/hlasm/macro/storage.mjs +117 -0
- package/lib/hlasm/macro/structured.mjs +131 -0
- package/lib/hlasm/model.mjs +72 -0
- package/lib/hlasm/operands.mjs +49 -0
- package/lib/hlasm/read.mjs +116 -0
- package/lib/inventory.mjs +10 -7
- package/lib/ironwork-ids.mjs +24 -0
- package/lib/ironwork.mjs +4 -1
- package/lib/kernel/registry.mjs +20 -17
- package/lib/kernel/shared-pass.mjs +138 -0
- package/lib/options.mjs +26 -4
- package/lib/policy.mjs +6 -0
- package/lib/precompile-cics.mjs +1 -1
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +3 -0
- package/lib/scan.mjs +21 -11
- package/lib/sets/abend.mjs +71 -7
- package/lib/sets/build.mjs +43 -37
- package/lib/sets/cics.mjs +5 -3
- package/lib/sets/compile.mjs +5 -3
- package/lib/sets/copybook.mjs +5 -3
- package/lib/sets/flow.mjs +14 -7
- package/lib/sets/hlasm.mjs +60 -4
- package/lib/sets/log.mjs +5 -3
- package/lib/sets/priv.mjs +5 -3
- package/lib/sets/secrets.mjs +110 -0
- package/lib/sets/semantics.mjs +5 -3
- package/lib/sets/web.mjs +5 -3
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +88 -4
- package/rules/compliance-dora.json +95 -4
- package/rules/compliance-ffiec.json +91 -0
- package/rules/compliance-nist80053.json +91 -0
- package/rules/gitleaks-mainframe.toml +46 -15
- package/rules/hlasm-instructions.json +2396 -0
- package/schema/cobolwork.policy.schema.json +7 -0
package/lib/sets/cics.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { report } from '../kernel/ruleset.mjs';
|
|
|
4
4
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { parseSource } from '../parser.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
8
8
|
import { buildControl, hasFact } from '../control.mjs';
|
|
9
9
|
import { parseCsd } from '../csd.mjs';
|
|
10
10
|
|
|
@@ -201,7 +201,7 @@ function conditionsIgnored(p, path) {
|
|
|
201
201
|
// away from, and this set kept doing it: on a 100,000-program repository it exhausted an 8 GB heap
|
|
202
202
|
// and took a corpus run with it. What survives per program is an identifier, a communication area's
|
|
203
203
|
// size, one boolean, and a short list of transfers.
|
|
204
|
-
export function
|
|
204
|
+
export function* scanCicsSteps(root, opts = {}) {
|
|
205
205
|
const tree = treeFor(root, opts);
|
|
206
206
|
const files = tree.list().filter(isProgram).filter(inScope(opts));
|
|
207
207
|
// Identifier to the size of that program's DFHCOMMAREA, which is all a caller needs of a callee.
|
|
@@ -364,7 +364,7 @@ export function scanCics(root, opts = {}) {
|
|
|
364
364
|
// Releasing each tree bounds what one program costs; it does not bound what a hundred thousand of
|
|
365
365
|
// them cost together. The shared loop watches the heap and stops before V8 cannot collect, and
|
|
366
366
|
// names every file it did not reach.
|
|
367
|
-
const run =
|
|
367
|
+
const run = yield loopOver(files, (f) => {
|
|
368
368
|
let src;
|
|
369
369
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
370
370
|
if (!/EXEC\s+CICS/i.test(src) && !/(?:^|[\s.])COPY\s+/i.test(src)) return src.length;
|
|
@@ -445,3 +445,5 @@ export function scanCics(root, opts = {}) {
|
|
|
445
445
|
|
|
446
446
|
return report('cics', { rules: CICS_RULES, findings, stats, run });
|
|
447
447
|
}
|
|
448
|
+
|
|
449
|
+
export const scanCics = (root, opts = {}) => drive(scanCicsSteps(root, opts));
|
package/lib/sets/compile.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { basename, extname } from 'node:path';
|
|
|
13
13
|
import { inScope, isProgram, relPath, readSource } from '../sources.mjs';
|
|
14
14
|
import { report } from '../kernel/ruleset.mjs';
|
|
15
15
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
17
17
|
|
|
18
18
|
// construct: no input has to reach it. med: certain once every copybook is found, and it means the
|
|
19
19
|
// tree does not hold what runs, but it is not exploitable in itself. CWE-1127: MITRE discourages
|
|
@@ -102,7 +102,7 @@ function truncated(ref, lines) {
|
|
|
102
102
|
return any && !whole;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function
|
|
105
|
+
export function* scanCompileSteps(root, opts = {}) {
|
|
106
106
|
const tree = treeFor(root, opts);
|
|
107
107
|
const files = tree.list().filter(isProgram).filter(inScope(opts));
|
|
108
108
|
const findings = [];
|
|
@@ -117,7 +117,7 @@ export function scanCompile(root, opts = {}) {
|
|
|
117
117
|
const textOf = (p) => (tree.contains(p) ? tree.text(p) : readSource(p)).text;
|
|
118
118
|
const candidates = [];
|
|
119
119
|
|
|
120
|
-
const run =
|
|
120
|
+
const run = yield loopOver(files, (f) => {
|
|
121
121
|
let src;
|
|
122
122
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
123
123
|
if (LISTING.test(src)) { stats.filesScanned++; stats.listingsSkipped = (stats.listingsSkipped || 0) + 1; return src.length; }
|
|
@@ -186,3 +186,5 @@ export function scanCompile(root, opts = {}) {
|
|
|
186
186
|
if (stats.filesUnparsed) stats.coverageIncomplete = true;
|
|
187
187
|
return report('compile', { rules: COMPILE_RULES, findings, stats, run });
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
export const scanCompile = (root, opts = {}) => drive(scanCompileSteps(root, opts));
|
package/lib/sets/copybook.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { inScope, isCopybook, isProgram, relPath } from '../sources.mjs';
|
|
|
6
6
|
import { finish } from '../kernel/findings.mjs';
|
|
7
7
|
import { report } from '../kernel/ruleset.mjs';
|
|
8
8
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
10
10
|
import { cobolCard } from '../cards.mjs';
|
|
11
11
|
|
|
12
12
|
// A copybook is resolved by name along a search path: the program's own directory first, then the
|
|
@@ -71,7 +71,7 @@ export function systemLayoutDeparture(name, text) {
|
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export function
|
|
74
|
+
export function* scanCopybooksSteps(root, opts = {}) {
|
|
75
75
|
const tree = treeFor(root, opts);
|
|
76
76
|
const all = tree.list().filter(inScope(opts));
|
|
77
77
|
const findings = [];
|
|
@@ -100,7 +100,7 @@ export function scanCopybooks(root, opts = {}) {
|
|
|
100
100
|
// is what stops the reading before the heap does.
|
|
101
101
|
let run = { skipped: [], stoppedBy: null, peakHeapBytes: 0, note: null };
|
|
102
102
|
if (candidates.size) {
|
|
103
|
-
run =
|
|
103
|
+
run = yield loopOver(all.filter(isProgram), (p) => {
|
|
104
104
|
let src;
|
|
105
105
|
try { src = tree.text(p).text; } catch (e) { noteUnread(stats, tree, p, e); return 0; }
|
|
106
106
|
let r;
|
|
@@ -173,3 +173,5 @@ export function scanCopybooks(root, opts = {}) {
|
|
|
173
173
|
// that looks unshadowed may only look that way.
|
|
174
174
|
return report('copybook', { rules: COPYBOOK_RULES, findings, stats, run });
|
|
175
175
|
}
|
|
176
|
+
|
|
177
|
+
export const scanCopybooks = (root, opts = {}) => drive(scanCopybooksSteps(root, opts));
|
package/lib/sets/flow.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { directoryTree } from '../kernel/source-tree.mjs';
|
|
4
|
+
import { analyzeSteps } from '../dataflow.mjs';
|
|
5
|
+
import { drive } from '../kernel/shared-pass.mjs';
|
|
3
6
|
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from '../version.mjs';
|
|
4
7
|
import { finish, withMeta, sortFindings } from '../kernel/findings.mjs';
|
|
5
8
|
|
|
@@ -365,11 +368,11 @@ function mergeRoutes(a, b) {
|
|
|
365
368
|
};
|
|
366
369
|
}
|
|
367
370
|
// One graph per repository, for the reason scanAll gives: names are only unique within one.
|
|
368
|
-
function analyzeEach(root, opts) {
|
|
369
|
-
if (!opts.repos || opts.repos.length < 2) return
|
|
371
|
+
function* analyzeEach(root, opts) {
|
|
372
|
+
if (!opts.repos || opts.repos.length < 2) return yield* analyzeSteps(root, opts);
|
|
370
373
|
const merged = { findings: [], constructs: [], unstarted: [], startedBy: {}, stats: {} };
|
|
371
374
|
for (const repo of opts.repos) {
|
|
372
|
-
const one =
|
|
375
|
+
const one = yield* analyzeSteps(root, { ...opts, repos: [repo] });
|
|
373
376
|
merged.findings.push(...one.findings);
|
|
374
377
|
merged.constructs.push(...one.constructs);
|
|
375
378
|
merged.unstarted.push(...one.unstarted);
|
|
@@ -386,8 +389,9 @@ function analyzeEach(root, opts) {
|
|
|
386
389
|
return merged;
|
|
387
390
|
}
|
|
388
391
|
|
|
389
|
-
export function
|
|
390
|
-
|
|
392
|
+
export function* scanSteps(root, opts = {}) {
|
|
393
|
+
root = resolve(root);
|
|
394
|
+
const res = yield* analyzeEach(root, opts);
|
|
391
395
|
const findings = [];
|
|
392
396
|
for (const f of res.findings) {
|
|
393
397
|
const meta = RULES[f.rule] || DEFAULT;
|
|
@@ -531,7 +535,8 @@ export function scan(root, opts = {}) {
|
|
|
531
535
|
// happened to compute the right answer for the whole report - it ORs filesOverBudget in
|
|
532
536
|
// separately - so nothing was wrong downstream, and nothing would have been until someone
|
|
533
537
|
// called this set on its own and believed a silence.
|
|
534
|
-
coverageIncomplete: (res.stats.
|
|
538
|
+
coverageIncomplete: (res.stats.files === 0 && directoryTree(root, opts).list().length === 0)
|
|
539
|
+
|| (res.stats.overBudget || 0) > 0
|
|
535
540
|
|| (res.stats.filesNotReached || 0) > 0
|
|
536
541
|
|| (res.stats.unreadable || 0) > 0
|
|
537
542
|
|| (res.stats.threw || 0) > 0
|
|
@@ -570,6 +575,8 @@ export function scan(root, opts = {}) {
|
|
|
570
575
|
return report;
|
|
571
576
|
}
|
|
572
577
|
|
|
578
|
+
export const scan = (root, opts = {}) => drive(scanSteps(root, opts));
|
|
579
|
+
|
|
573
580
|
// Findings counted by the transaction or job that reaches them: the unit a mainframe team triages
|
|
574
581
|
// in. A finding several entries reach counts under each.
|
|
575
582
|
export function rollup(findings) {
|
package/lib/sets/hlasm.mjs
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
//
|
|
7
7
|
// It also answers the question the COBOL side cannot: a CALL 'SUBRTN' with no COBOL program of that
|
|
8
8
|
// name may be an assembler module, and a CSECT or ENTRY of that name here is where it is.
|
|
9
|
-
import {
|
|
9
|
+
import { basename, extname } from 'node:path';
|
|
10
|
+
import { inScope, isAssembler, isProgram, relPath, HLASM_EXT, COPY_EXT } from '../sources.mjs';
|
|
10
11
|
import { report } from '../kernel/ruleset.mjs';
|
|
11
12
|
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
|
12
13
|
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
13
14
|
import { readHlasm, OPERATIONS } from '../hlasm.mjs';
|
|
15
|
+
import { readHlasmStatements, parseHlasmStatement, NAMED_MACROS } from '../hlasm/read.mjs';
|
|
16
|
+
import { macroOperands, splitOperands } from '../hlasm/operands.mjs';
|
|
14
17
|
|
|
15
18
|
export const HLASM_RULES = {
|
|
16
19
|
'hlasm-supervisor-state-change': {
|
|
@@ -31,6 +34,12 @@ export const HLASM_RULES = {
|
|
|
31
34
|
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
35
|
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
36
|
},
|
|
37
|
+
'hlasm-runtime-module-name': {
|
|
38
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-470',
|
|
39
|
+
text: 'Assembler source loads or calls a module whose name it reads from storage at run time',
|
|
40
|
+
impact: 'EPLOC= and DE= give LINK, XCTL, LOAD and ATTACH the address of the name rather than the name, so whatever writes that storage chooses the program that runs, with the caller\'s authority',
|
|
41
|
+
remedy: 'Name the module with EP= where it is fixed; where it must vary, check the name against a list of the modules the program may run before the call',
|
|
42
|
+
},
|
|
34
43
|
'hlasm-calls-security-product': { sev: 'info', evidence: 'context', cwe: null, text: 'Assembler source calls the security product directly' },
|
|
35
44
|
'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
45
|
};
|
|
@@ -45,8 +54,17 @@ const STATE = { KEY: 'PSW key zero', EXTKEY: 'PSW key zero', MODE: 'supervisor s
|
|
|
45
54
|
|
|
46
55
|
const where = (use) => (use.inMacro ? ' It is in a macro definition, so it is in every program that uses the macro.' : '');
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
// EX and EXRL name their target instruction; when it is labelled in the same file, the finding says
|
|
58
|
+
// which instruction runs with the register's length or mask.
|
|
59
|
+
function executed(use, labels) {
|
|
60
|
+
if (use.class !== 'built-instruction') return '';
|
|
61
|
+
const target = (splitOperands(use.field || '')[1] || '').trim().toUpperCase();
|
|
62
|
+
const t = /^[A-Z$#@_][A-Z0-9$#@_]*$/.test(target) ? labels.get(target) : null;
|
|
63
|
+
return t ? ` runs ${t.operation}${t.field ? ` ${t.field}` : ''}` : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detail(use, labels) {
|
|
67
|
+
const written = `${use.name}${use.field ? ` ${use.field}` : ''}${executed(use, labels)}`;
|
|
50
68
|
if (use.class === 'security-product') {
|
|
51
69
|
const request = /\bREQUEST=([A-Z]+)/i.exec(use.field || '');
|
|
52
70
|
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)}`;
|
|
@@ -72,6 +90,32 @@ export function scanHlasm(root, opts = {}) {
|
|
|
72
90
|
const all = tree.list().filter(inScope(opts));
|
|
73
91
|
const findings = [];
|
|
74
92
|
const stats = { filesScanned: 0, filesUnreadable: 0, assemblerFiles: 0, bmsFiles: 0, imsFiles: 0, unrecognisedFiles: 0, readNotAssembled: true };
|
|
93
|
+
const members = new Set(all.filter((f) => [...HLASM_EXT, ...COPY_EXT].some((e) => f.toLowerCase().endsWith(e))).map((f) => basename(f, extname(f)).toUpperCase()));
|
|
94
|
+
const refused = {};
|
|
95
|
+
const macroCalls = new Map();
|
|
96
|
+
const copyNotFound = new Set();
|
|
97
|
+
// Counts, for the coverage it reports, what the statement reader refused and which macros and COPY
|
|
98
|
+
// members a file uses.
|
|
99
|
+
function readStatements(src, path) {
|
|
100
|
+
const labels = new Map();
|
|
101
|
+
for (const st of readHlasmStatements(src).statements) {
|
|
102
|
+
if (st.inMacro) continue;
|
|
103
|
+
if (st.kind === 'statement' && st.name && !st.name.startsWith('&') && !st.name.startsWith('.')) labels.set(st.name.toUpperCase(), st);
|
|
104
|
+
const res = parseHlasmStatement(st);
|
|
105
|
+
if (res.status === 'unparsed' || res.status === 'unknown') (refused[res.kind] ||= { count: 0, first: `${path}:${st.line}` }).count++;
|
|
106
|
+
const name = res.kind === 'MACRO CALL' ? st.operation : NAMED_MACROS.has(res.kind) ? res.kind : null;
|
|
107
|
+
if (name) {
|
|
108
|
+
const m = macroCalls.get(name) || { name, calls: 0, definedInTree: members.has(name) };
|
|
109
|
+
m.calls++;
|
|
110
|
+
macroCalls.set(name, m);
|
|
111
|
+
}
|
|
112
|
+
if (res.kind === 'COPY') {
|
|
113
|
+
const member = String(res.status === 'parsed' ? res.node.member : (/^\s*([A-Z$#@][A-Z0-9$#@_]*)/i.exec(st.field || '') || [])[1] || '').toUpperCase();
|
|
114
|
+
if (member && !members.has(member)) copyNotFound.add(member);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return labels;
|
|
118
|
+
}
|
|
75
119
|
const defines = new Map();
|
|
76
120
|
|
|
77
121
|
const run = eachWithinMemory(all.filter(isAssembler), (f) => {
|
|
@@ -85,13 +129,20 @@ export function scanHlasm(root, opts = {}) {
|
|
|
85
129
|
stats.assemblerFiles++;
|
|
86
130
|
const path = relPath(root, f);
|
|
87
131
|
for (const d of r.defines) if (!defines.has(d.name)) defines.set(d.name, { ...d, path });
|
|
132
|
+
const labels = readStatements(src, path);
|
|
88
133
|
for (const use of r.operations) {
|
|
134
|
+
if (use.class === 'module-load') {
|
|
135
|
+
const { keywords } = macroOperands(use.field || '');
|
|
136
|
+
const at = keywords.has('EPLOC') ? 'EPLOC' : keywords.has('DE') ? 'DE' : null;
|
|
137
|
+
if (at) findings.push({ rule: 'hlasm-runtime-module-name', path, line: use.line, detail: `${use.name} ${at}=${keywords.get(at)} names the module by the address of its name, so the program that runs is whatever name that storage holds.${where(use)}` });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
89
140
|
if (use.name === 'MODESET') {
|
|
90
141
|
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
142
|
continue;
|
|
92
143
|
}
|
|
93
144
|
const rule = RULE_OF_CLASS[use.class];
|
|
94
|
-
if (rule) findings.push({ rule, path, line: use.line, detail: detail(use) });
|
|
145
|
+
if (rule) findings.push({ rule, path, line: use.line, detail: detail(use, labels) });
|
|
95
146
|
}
|
|
96
147
|
return src.length;
|
|
97
148
|
}, { label: 'hlasm', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
@@ -117,5 +168,10 @@ export function scanHlasm(root, opts = {}) {
|
|
|
117
168
|
}
|
|
118
169
|
}
|
|
119
170
|
|
|
171
|
+
// Each statement the reader refused, each macro whose expansion was not read and each COPY member
|
|
172
|
+
// the tree does not hold is named: an operation inside any of them is one this set did not see.
|
|
173
|
+
if (Object.keys(refused).length) { stats.statementsNotRead = refused; stats.coverageIncomplete = true; }
|
|
174
|
+
if (copyNotFound.size) { stats.copyNotFound = [...copyNotFound].sort(); stats.coverageIncomplete = true; }
|
|
175
|
+
if (macroCalls.size) stats.macrosNotExpanded = [...macroCalls.values()].sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)).slice(0, 50);
|
|
120
176
|
return report('hlasm', { rules: HLASM_RULES, findings, stats, run });
|
|
121
177
|
}
|
package/lib/sets/log.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { parseJcl } from '../jcl.mjs';
|
|
|
23
23
|
import { compileStepOptions } from '../options.mjs';
|
|
24
24
|
import { report } from '../kernel/ruleset.mjs';
|
|
25
25
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
26
|
-
import {
|
|
26
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
export const LOG_RULES = {
|
|
@@ -337,7 +337,7 @@ const memberOf = (file) => basename(String(file || ''), extname(String(file || '
|
|
|
337
337
|
const runByJob = (batch, p, file) => batch.unsure
|
|
338
338
|
|| [String(p.id || '').toUpperCase(), memberOf(file)].some((n) => n && (batch.runs.has(n) || batch.runs.has(n.slice(0, 8))));
|
|
339
339
|
|
|
340
|
-
export function
|
|
340
|
+
export function* scanLogSteps(root, opts = {}) {
|
|
341
341
|
const tree = treeFor(root, opts);
|
|
342
342
|
const files = tree.list().filter(isProgram).filter(inScope(opts));
|
|
343
343
|
const findings = [];
|
|
@@ -364,7 +364,7 @@ export function scanLog(root, opts = {}) {
|
|
|
364
364
|
return { rule: 'display-echoes-a-credential', detail: `${p.id} shows ${w.name} on the terminal to the person who typed it: not a log, but the screen and its scrollback keep it` };
|
|
365
365
|
};
|
|
366
366
|
|
|
367
|
-
const run =
|
|
367
|
+
const run = yield loopOver(files, (f) => {
|
|
368
368
|
let src;
|
|
369
369
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
370
370
|
// Every EXEC CICS WRITE form, not the two this set started with. WRITE OPERATOR was added as a
|
|
@@ -404,3 +404,5 @@ export function scanLog(root, opts = {}) {
|
|
|
404
404
|
|
|
405
405
|
return report('log', { rules: LOG_RULES, findings, stats, run });
|
|
406
406
|
}
|
|
407
|
+
|
|
408
|
+
export const scanLog = (root, opts = {}) => drive(scanLogSteps(root, opts));
|
package/lib/sets/priv.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { inScope, isProgram, relPath } from '../sources.mjs';
|
|
20
20
|
import { report } from '../kernel/ruleset.mjs';
|
|
21
21
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
22
|
-
import {
|
|
22
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
23
23
|
import { parseCsd } from '../csd.mjs';
|
|
24
24
|
import { parseJcl } from '../jcl.mjs';
|
|
25
25
|
import { loadSite } from '../site.mjs';
|
|
@@ -101,7 +101,7 @@ function under(dsn, prefixes) {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export function
|
|
104
|
+
export function* scanPrivSteps(root, opts = {}) {
|
|
105
105
|
const site = loadSite(root, opts.site || null, opts.tree);
|
|
106
106
|
const tree = treeFor(root, opts);
|
|
107
107
|
const files = tree.list().filter(inScope(opts));
|
|
@@ -140,7 +140,7 @@ export function scanPriv(root, opts = {}) {
|
|
|
140
140
|
const diagnostics = [];
|
|
141
141
|
const uss = [];
|
|
142
142
|
|
|
143
|
-
const run =
|
|
143
|
+
const run = yield loopOver(files, (f) => {
|
|
144
144
|
let src;
|
|
145
145
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
146
146
|
const path = relPath(root, f);
|
|
@@ -320,3 +320,5 @@ export function scanPriv(root, opts = {}) {
|
|
|
320
320
|
|
|
321
321
|
return report('priv', { rules: PRIV_RULES, findings, stats, run });
|
|
322
322
|
}
|
|
323
|
+
|
|
324
|
+
export const scanPriv = (root, opts = {}) => drive(scanPrivSteps(root, opts));
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Credentials written into COBOL source. The shapes are the ones rules/gitleaks-mainframe.toml gives
|
|
3
|
+
// gitleaks, read from that file, so one list serves both and the build gate does not depend on
|
|
4
|
+
// gitleaks being installed. Go's RE2 syntax is taken only where it means the same in JavaScript; any
|
|
5
|
+
// other construct is refused when the file is read.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { inScope, isProgram, isCopybook, relPath } from '../sources.mjs';
|
|
8
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
9
|
+
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
|
10
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
11
|
+
|
|
12
|
+
export const SECRETS_RULES = {
|
|
13
|
+
'credential-in-source': {
|
|
14
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-798', measured: false,
|
|
15
|
+
text: 'A credential is written into the source',
|
|
16
|
+
impact: 'Everyone who can read the source, its history or a listing can sign on with it, and changing it means changing and redeploying the program',
|
|
17
|
+
remedy: 'Read the credential at run time from where the site keeps them (a RACF-protected data set, a PassTicket, a Db2 trusted context or a CICS-supplied identity), and change the one already exposed',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The TOML ids of the shapes that occur in COBOL programs and copybooks, and how a finding names each.
|
|
22
|
+
const SHAPES = {
|
|
23
|
+
'cobol-value-credential': (m) => `${dataName(m) || 'a data item named for a credential'} has a literal VALUE`,
|
|
24
|
+
'embedded-sql-connect-password': () => 'EXEC SQL CONNECT gives a literal password',
|
|
25
|
+
'cics-signon-password': (m) => `EXEC CICS ${/\b(SIGNON|VERIFY|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal PASSWORD`,
|
|
26
|
+
'cics-new-password': (m) => `EXEC CICS ${/\b(SIGNON|CHANGE)\b/i.exec(m)[1].toUpperCase()} gives a literal NEWPASSWORD`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const dataName = (m) => (/\b(?:0?[1-9]|[1-4][0-9]|77)\s+([A-Z0-9][A-Z0-9-]*)/i.exec(m) || [])[1]?.toUpperCase();
|
|
30
|
+
|
|
31
|
+
const value = (raw) => {
|
|
32
|
+
const t = raw.trim();
|
|
33
|
+
if (t.startsWith("'''")) return t.slice(3, t.lastIndexOf("'''"));
|
|
34
|
+
if (t.startsWith('"')) return JSON.parse(t);
|
|
35
|
+
if (t.startsWith('[')) return [...t.matchAll(/'''(.*?)'''|"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1] ?? JSON.parse(`"${x[2]}"`));
|
|
36
|
+
if (/^\d+$/.test(t)) return Number(t);
|
|
37
|
+
if (t === 'true' || t === 'false') return t === 'true';
|
|
38
|
+
throw new Error(`gitleaks-mainframe.toml: value not read: ${t}`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The [[rules]] tables of the file, each with its allowlist folded in.
|
|
42
|
+
export function readToml(text) {
|
|
43
|
+
const rules = [];
|
|
44
|
+
let into = null;
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const l = line.trim();
|
|
47
|
+
if (!l || l.startsWith('#')) continue;
|
|
48
|
+
if (l === '[[rules]]') { rules.push((into = { allowlist: {} })); continue; }
|
|
49
|
+
if (l === '[rules.allowlist]') { into = rules.at(-1).allowlist; continue; }
|
|
50
|
+
if (l.startsWith('[')) { into = null; continue; }
|
|
51
|
+
const kv = /^([A-Za-z]+)\s*=\s*(.+)$/.exec(l);
|
|
52
|
+
if (kv && into) into[kv[1]] = value(kv[2]);
|
|
53
|
+
}
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A gitleaks regex as a JavaScript one: a leading (?flags) group becomes the flags.
|
|
58
|
+
export function jsRegex(re, extra = '') {
|
|
59
|
+
const lead = /^\(\?([ims]+)\)/.exec(re);
|
|
60
|
+
const body = lead ? re.slice(lead[0].length) : re;
|
|
61
|
+
if (/\(\?[a-zA-Z]|\(\?P<|\\[AzZpPQE]/.test(body)) throw new Error(`gitleaks-mainframe.toml: no JavaScript equivalent for ${re}`);
|
|
62
|
+
return new RegExp(body, [...new Set([...(lead ? lead[1] : ''), ...extra])].join(''));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const secretOf = (m, group) => (group ? m[group] : m.slice(1).find((g) => g !== undefined)) ?? m[0];
|
|
66
|
+
|
|
67
|
+
export const SHAPE_RULES = readToml(readFileSync(new URL('../../rules/gitleaks-mainframe.toml', import.meta.url), 'utf8'))
|
|
68
|
+
.filter((r) => r.id in SHAPES)
|
|
69
|
+
.map((r) => ({
|
|
70
|
+
id: r.id,
|
|
71
|
+
pattern: jsRegex(r.regex, 'g'),
|
|
72
|
+
secretGroup: r.secretGroup || 0,
|
|
73
|
+
keywords: (r.keywords || []).map((k) => k.toLowerCase()),
|
|
74
|
+
allow: (r.allowlist.regexes || []).map((x) => jsRegex(x)),
|
|
75
|
+
allowTarget: r.allowlist.regexTarget || 'secret',
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// Every shape in the text, at the line its first non-blank character is on.
|
|
79
|
+
export function credentialsIn(text) {
|
|
80
|
+
const lower = text.toLowerCase();
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const s of SHAPE_RULES) {
|
|
83
|
+
if (s.keywords.length && !s.keywords.some((k) => lower.includes(k))) continue;
|
|
84
|
+
for (const m of text.matchAll(s.pattern)) {
|
|
85
|
+
const target = s.allowTarget === 'match' ? m[0] : secretOf(m, s.secretGroup);
|
|
86
|
+
if (s.allow.some((a) => a.test(target))) continue;
|
|
87
|
+
const start = m.index + (m[0].length - m[0].trimStart().length);
|
|
88
|
+
out.push({ shape: s.id, line: text.slice(0, start).split('\n').length, detail: SHAPES[s.id](m[0]) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function scanSecrets(root, opts = {}) {
|
|
95
|
+
const tree = treeFor(root, opts);
|
|
96
|
+
const files = tree.list().filter((f) => isProgram(f) || isCopybook(f)).filter(inScope(opts));
|
|
97
|
+
const findings = [];
|
|
98
|
+
const stats = { filesScanned: 0, filesUnreadable: 0 };
|
|
99
|
+
|
|
100
|
+
const run = eachWithinMemory(files, (f) => {
|
|
101
|
+
let src;
|
|
102
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
103
|
+
stats.filesScanned++;
|
|
104
|
+
const path = relPath(root, f);
|
|
105
|
+
for (const c of credentialsIn(src)) findings.push({ rule: 'credential-in-source', path, line: c.line, detail: `${c.detail} (${c.shape})` });
|
|
106
|
+
return src.length;
|
|
107
|
+
}, { label: 'secrets', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
108
|
+
|
|
109
|
+
return report('secrets', { rules: SECRETS_RULES, findings, stats, run });
|
|
110
|
+
}
|
package/lib/sets/semantics.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { basename, extname } from 'node:path';
|
|
|
9
9
|
import { inScope, isProgram, isJcl, relPath, ebcdicByte } from '../sources.mjs';
|
|
10
10
|
import { report } from '../kernel/ruleset.mjs';
|
|
11
11
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
13
13
|
import { loadSite } from '../site.mjs';
|
|
14
14
|
import { parseJcl } from '../jcl.mjs';
|
|
15
15
|
import { optionCards, optionTokens, compileStepOptions } from '../options.mjs';
|
|
@@ -206,7 +206,7 @@ function thruPairs(toks) {
|
|
|
206
206
|
}
|
|
207
207
|
const WHEN_WORDS = new Set(['THRU', 'THROUGH', 'ALSO', 'OR', 'NOT', 'ANY', 'OTHER', 'TRUE', 'FALSE']);
|
|
208
208
|
|
|
209
|
-
export function
|
|
209
|
+
export function* scanSemanticsSteps(root, opts = {}) {
|
|
210
210
|
const tree = treeFor(root, opts);
|
|
211
211
|
const all = tree.list().filter(inScope(opts));
|
|
212
212
|
const files = all.filter(isProgram);
|
|
@@ -359,7 +359,7 @@ export function scanSemantics(root, opts = {}) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
const run =
|
|
362
|
+
const run = yield loopOver(files, (f) => {
|
|
363
363
|
let src;
|
|
364
364
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
365
365
|
let r;
|
|
@@ -375,3 +375,5 @@ export function scanSemantics(root, opts = {}) {
|
|
|
375
375
|
|
|
376
376
|
return report('semantics', { rules: SEMANTICS_RULES, findings, stats, run });
|
|
377
377
|
}
|
|
378
|
+
|
|
379
|
+
export const scanSemantics = (root, opts = {}) => drive(scanSemanticsSteps(root, opts));
|
package/lib/sets/web.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { parseCsd } from '../csd.mjs';
|
|
|
22
22
|
import { classify } from './log.mjs';
|
|
23
23
|
import { report } from '../kernel/ruleset.mjs';
|
|
24
24
|
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { drive, loopOver } from '../kernel/shared-pass.mjs';
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
export const WEB_RULES = {
|
|
@@ -168,7 +168,7 @@ const HTTP_METHOD = /^(GET|POST|PUT|PATCH|DELETE)$/i;
|
|
|
168
168
|
// the absence and the explicit NO are one finding, and PROTOCOL only changes what is in the clear.
|
|
169
169
|
const encrypted = (svc) => /^(YES|CLIENTAUTH|CLIENTCERT)$/i.test(String(svc.ssl || ''));
|
|
170
170
|
|
|
171
|
-
export function
|
|
171
|
+
export function* scanWebSteps(root, opts = {}) {
|
|
172
172
|
const tree = treeFor(root, opts);
|
|
173
173
|
const all = tree.list().filter(inScope(opts));
|
|
174
174
|
const files = all.filter(isProgram);
|
|
@@ -323,7 +323,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
const run =
|
|
326
|
+
const run = yield loopOver(files, (f) => {
|
|
327
327
|
let src;
|
|
328
328
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
329
329
|
// The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
|
|
@@ -444,3 +444,5 @@ export function scanWeb(root, opts = {}) {
|
|
|
444
444
|
|
|
445
445
|
return report('web', { rules: WEB_RULES, findings, stats, run });
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
export const scanWeb = (root, opts = {}) => drive(scanWebSteps(root, opts));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portll/cobolwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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>",
|