@portll/cobolwork 0.5.0 → 0.7.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 +89 -26
- package/STABILITY.md +76 -0
- package/bin/cobolwork.mjs +56 -14
- package/lib/baseline.mjs +9 -0
- package/lib/bms.mjs +21 -7
- package/lib/build.mjs +133 -53
- package/lib/capabilities.mjs +13 -11
- package/lib/cics-commands.mjs +501 -9
- package/lib/compliance.mjs +11 -0
- package/lib/consequence.mjs +17 -0
- package/lib/control-reuse.mjs +113 -0
- package/lib/control-workers.mjs +161 -0
- package/lib/control.mjs +77 -4
- package/lib/dataflow.mjs +402 -152
- package/lib/db2/cursor.mjs +15 -0
- package/lib/db2/read.mjs +170 -0
- package/lib/db2/rules.mjs +77 -0
- package/lib/db2/stmt/alter.mjs +473 -0
- package/lib/db2/stmt/grant.mjs +125 -0
- package/lib/db2/stmt/index.mjs +141 -0
- package/lib/db2/stmt/misc.mjs +325 -0
- package/lib/db2/stmt/routine.mjs +564 -0
- package/lib/db2/stmt/storage.mjs +146 -0
- package/lib/db2/stmt/table.mjs +540 -0
- package/lib/db2/stmt/view.mjs +146 -0
- package/lib/diff.mjs +17 -5
- package/lib/equivalence.mjs +44 -5
- package/lib/evidence/cli.mjs +26 -7
- package/lib/evidence/record.mjs +16 -6
- package/lib/evidence/seal.mjs +17 -0
- package/lib/evidence/store.mjs +44 -15
- package/lib/evidence/timestamp.mjs +83 -0
- package/lib/evidence/verify.mjs +120 -42
- package/lib/exec-reading.mjs +51 -0
- package/lib/execution.mjs +3 -2
- package/lib/explain.mjs +2 -0
- package/lib/exploitability.mjs +11 -2
- package/lib/hlasm/asm/data.mjs +128 -0
- package/lib/hlasm/asm/listing.mjs +43 -0
- package/lib/hlasm/asm/output.mjs +45 -0
- package/lib/hlasm/asm/sections.mjs +161 -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 +73 -0
- package/lib/hlasm/locate.mjs +333 -0
- package/lib/hlasm/macro/authorization.mjs +146 -0
- package/lib/hlasm/macro/datasets.mjs +107 -0
- package/lib/hlasm/macro/io.mjs +162 -0
- package/lib/hlasm/macro/le.mjs +53 -0
- package/lib/hlasm/macro/linkage.mjs +143 -0
- package/lib/hlasm/macro/operator.mjs +77 -0
- package/lib/hlasm/macro/program.mjs +184 -0
- package/lib/hlasm/macro/recovery.mjs +85 -0
- package/lib/hlasm/macro/storage.mjs +167 -0
- package/lib/hlasm/macro/structured.mjs +131 -0
- package/lib/hlasm/model.mjs +97 -0
- package/lib/hlasm/mvs38.mjs +47 -0
- package/lib/hlasm/operands.mjs +59 -0
- package/lib/hlasm/optable.mjs +61 -0
- package/lib/hlasm/read.mjs +130 -0
- package/lib/hlasm.mjs +44 -7
- package/lib/ims/dli.mjs +37 -0
- package/lib/ims/macro/dbd.mjs +299 -0
- package/lib/ims/macro/psb.mjs +286 -0
- package/lib/ims/model.mjs +149 -0
- package/lib/ims/operands.mjs +23 -0
- package/lib/ims/read.mjs +37 -0
- package/lib/ims/rules.mjs +135 -0
- package/lib/inventory.mjs +10 -7
- package/lib/ironwork-ids.mjs +24 -0
- package/lib/ironwork.mjs +20 -13
- package/lib/kernel/pds-archive.mjs +256 -0
- package/lib/kernel/registry.mjs +32 -23
- package/lib/kernel/shared-pass.mjs +163 -0
- package/lib/kernel/source-tree.mjs +104 -36
- package/lib/kernel/version-key.mjs +17 -0
- package/lib/layout.mjs +26 -31
- package/lib/options.mjs +26 -4
- package/lib/parser.mjs +136 -17
- package/lib/pli/cursor.mjs +15 -0
- package/lib/pli/expr.mjs +101 -0
- package/lib/pli/include.mjs +82 -0
- package/lib/pli/layout.mjs +125 -0
- package/lib/pli/lex.mjs +198 -0
- package/lib/pli/program.mjs +280 -0
- package/lib/pli/rules/based.mjs +95 -0
- package/lib/pli/rules/conditions.mjs +68 -0
- package/lib/pli/rules/entry.mjs +130 -0
- package/lib/pli/rules/index.mjs +24 -0
- package/lib/pli/rules/preprocessor.mjs +55 -0
- package/lib/pli/statements.mjs +130 -0
- package/lib/pli/stmt/alloc.mjs +45 -0
- package/lib/pli/stmt/assignment.mjs +56 -0
- package/lib/pli/stmt/call.mjs +104 -0
- package/lib/pli/stmt/conditions.mjs +94 -0
- package/lib/pli/stmt/control.mjs +219 -0
- package/lib/pli/stmt/declare.mjs +149 -0
- package/lib/pli/stmt/exec.mjs +55 -0
- package/lib/pli/stmt/io.mjs +239 -0
- package/lib/pli/stmt/misc.mjs +4 -0
- package/lib/pli/stmt/preprocessor.mjs +242 -0
- package/lib/pli/stmt/procedure.mjs +258 -0
- package/lib/pli/stmt/stream.mjs +283 -0
- package/lib/pli/storage.mjs +129 -0
- package/lib/policy.mjs +6 -0
- package/lib/precompile-check.mjs +124 -0
- package/lib/precompile-cics.mjs +8 -4
- package/lib/reach.mjs +11 -2
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +44 -3
- package/lib/scan.mjs +28 -12
- package/lib/sets/abend.mjs +82 -8
- package/lib/sets/build.mjs +43 -37
- package/lib/sets/cics.mjs +20 -38
- package/lib/sets/compile.mjs +46 -18
- package/lib/sets/copybook.mjs +5 -3
- package/lib/sets/crypto.mjs +5 -3
- package/lib/sets/ddl.mjs +36 -0
- package/lib/sets/flow.mjs +32 -8
- package/lib/sets/hidden.mjs +5 -3
- package/lib/sets/hlasm.mjs +124 -8
- package/lib/sets/ims.mjs +139 -0
- package/lib/sets/log.mjs +11 -9
- package/lib/sets/opaque.mjs +27 -7
- package/lib/sets/pli.mjs +40 -0
- package/lib/sets/priv.mjs +5 -3
- package/lib/sets/recon.mjs +5 -3
- package/lib/sets/secrets.mjs +112 -0
- package/lib/sets/semantics.mjs +8 -3
- package/lib/sets/web.mjs +39 -23
- package/lib/site.mjs +10 -0
- package/lib/sources.mjs +80 -20
- package/lib/statement-cursor.mjs +67 -0
- package/lib/verify.mjs +3 -2
- package/lib/version.mjs +6 -0
- package/package.json +3 -2
- package/rules/compliance-cobit2019.json +520 -5
- package/rules/compliance-dora.json +509 -5
- package/rules/compliance-ffiec.json +505 -1
- package/rules/compliance-nist80053.json +557 -1
- package/rules/gitleaks-mainframe.toml +46 -15
- package/rules/hlasm-instructions.json +2396 -0
- package/rules/hlasm-optables.json +8024 -0
- package/schema/cobolwork-baseline.schema.json +36 -0
- package/schema/cobolwork-build-provenance.schema.json +187 -0
- package/schema/cobolwork-build.schema.json +382 -0
- package/schema/cobolwork-capabilities.schema.json +239 -0
- package/schema/cobolwork-diff.schema.json +217 -0
- package/schema/cobolwork-evidence.schema.json +161 -0
- package/schema/cobolwork-execution.schema.json +53 -0
- package/schema/cobolwork-explain.schema.json +360 -0
- package/schema/cobolwork-finding.schema.json +465 -0
- package/schema/cobolwork-flow.schema.json +465 -0
- package/schema/cobolwork-gate.schema.json +211 -0
- package/schema/cobolwork-inventory.schema.json +206 -0
- package/schema/cobolwork-parse.schema.json +105 -0
- package/schema/cobolwork-reach.schema.json +74 -0
- package/schema/cobolwork-report.schema.json +559 -0
- package/schema/cobolwork-witness.schema.json +107 -0
- package/schema/cobolwork.baseline.schema.json +101 -0
- package/schema/cobolwork.policy.schema.json +7 -0
- package/schema/cobolwork.site.schema.json +116 -0
|
@@ -0,0 +1,112 @@
|
|
|
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 { drive, loopOver } from '../kernel/shared-pass.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* scanSecretsSteps(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 = yield loopOver(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
|
+
}
|
|
111
|
+
|
|
112
|
+
export const scanSecrets = (root, opts = {}) => drive(scanSecretsSteps(root, opts));
|
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);
|
|
@@ -256,6 +256,9 @@ export function scanSemantics(root, opts = {}) {
|
|
|
256
256
|
const narrowed = [];
|
|
257
257
|
|
|
258
258
|
for (const st of p.statements) {
|
|
259
|
+
// Without TRUNC(OPT) only a COMPUTE is judged, and the slice below runs to the end of the
|
|
260
|
+
// program for a statement whose end the parse did not mark.
|
|
261
|
+
if (st.verb !== 'COMPUTE' && !truncOpt) continue;
|
|
259
262
|
const seg = toks.slice(st.at + 1, st.end ?? toks.length);
|
|
260
263
|
const sizeError = seg.some((t, k) => t.t === 'word' && t.u === 'SIZE' && seg[k + 1] && seg[k + 1].u === 'ERROR');
|
|
261
264
|
|
|
@@ -359,7 +362,7 @@ export function scanSemantics(root, opts = {}) {
|
|
|
359
362
|
}
|
|
360
363
|
}
|
|
361
364
|
|
|
362
|
-
const run =
|
|
365
|
+
const run = yield loopOver(files, (f) => {
|
|
363
366
|
let src;
|
|
364
367
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
365
368
|
let r;
|
|
@@ -375,3 +378,5 @@ export function scanSemantics(root, opts = {}) {
|
|
|
375
378
|
|
|
376
379
|
return report('semantics', { rules: SEMANTICS_RULES, findings, stats, run });
|
|
377
380
|
}
|
|
381
|
+
|
|
382
|
+
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, loopsOver } 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);
|
|
@@ -302,11 +302,12 @@ export function scanWeb(root, opts = {}) {
|
|
|
302
302
|
// is named whatever the estate named it, and filtering on an extension is how a BMS count once
|
|
303
303
|
// came out as zero.
|
|
304
304
|
// Client URIMAPs come from the same files: WEB OPEN URIMAP(name) takes its scheme from one.
|
|
305
|
+
// Read in the pass the program loop shares, so a URIMAP is looked up only once every file is read.
|
|
305
306
|
const urimaps = new Map();
|
|
306
|
-
|
|
307
|
+
const definitions = loopOver(all, (f) => {
|
|
307
308
|
let src;
|
|
308
|
-
try { src = tree.text(f).text; } catch {
|
|
309
|
-
if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src))
|
|
309
|
+
try { src = tree.text(f).text; } catch { return; }
|
|
310
|
+
if (!/^\s*DEFINE\s+(TCPIPSERVICE|URIMAP)\s*\(/im.test(src)) return;
|
|
310
311
|
const path = relPath(root, f);
|
|
311
312
|
const csd = parseCsd(src);
|
|
312
313
|
for (const [name, u] of csd.urimaps) if (!urimaps.has(name)) urimaps.set(name, { ...u, path });
|
|
@@ -321,9 +322,11 @@ export function scanWeb(root, opts = {}) {
|
|
|
321
322
|
+ ', so everything the session carries crosses the network in the clear',
|
|
322
323
|
});
|
|
323
324
|
}
|
|
324
|
-
}
|
|
325
|
+
}, { label: 'web' });
|
|
325
326
|
|
|
326
|
-
|
|
327
|
+
// A program's findings in its own order, an open through a URIMAP held as the function that judges it.
|
|
328
|
+
const fromPrograms = [];
|
|
329
|
+
const programs = loopOver(files, (f) => {
|
|
327
330
|
let src;
|
|
328
331
|
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
329
332
|
// The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
|
|
@@ -348,23 +351,25 @@ export function scanWeb(root, opts = {}) {
|
|
|
348
351
|
const to = o.host ? ` to ${o.host}` : '';
|
|
349
352
|
if (o.https) continue;
|
|
350
353
|
if (o.http) {
|
|
351
|
-
|
|
354
|
+
fromPrograms.push({ rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
|
|
352
355
|
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
356
|
continue;
|
|
354
357
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
358
|
+
fromPrograms.push(() => {
|
|
359
|
+
const u = o.urimap ? urimaps.get(o.urimap.toUpperCase()) : null;
|
|
360
|
+
if (u && /^HTTP$/i.test(u.scheme || '') && !/^AWARE$/i.test(u.attls || '')) {
|
|
361
|
+
return { rule: 'web-client-opens-cleartext', ...o.at, program: v.id,
|
|
362
|
+
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` };
|
|
363
|
+
}
|
|
364
|
+
// A scheme in a variable, or a URIMAP the tree does not define, is decided where this cannot see.
|
|
365
|
+
if (!u || !u.scheme) stats.opensUndecided++;
|
|
366
|
+
return null;
|
|
367
|
+
});
|
|
363
368
|
}
|
|
364
369
|
|
|
365
370
|
for (const r of v.receives) {
|
|
366
371
|
if (r.max.bytes <= r.size) continue;
|
|
367
|
-
|
|
372
|
+
fromPrograms.push({ rule: 'web-receive-length-exceeds-area', ...r.at, program: v.id,
|
|
368
373
|
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
374
|
}
|
|
370
375
|
stats.headersWritten += v.headers.length;
|
|
@@ -378,7 +383,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
378
383
|
if (!/;\s*httponly\b/i.test(c.value)) missing.push('HttpOnly');
|
|
379
384
|
if (!/;\s*samesite\s*=/i.test(c.value)) missing.push('SameSite');
|
|
380
385
|
if (!missing.length) continue;
|
|
381
|
-
|
|
386
|
+
fromPrograms.push({
|
|
382
387
|
rule: 'web-cookie-without-secure-attributes', ...c.at, program: v.id,
|
|
383
388
|
detail: `${v.id} writes Set-Cookie without ${missing.join(', ')}`
|
|
384
389
|
+ (missing.includes('Secure') ? ', so the browser will send this cookie over a cleartext hop' : ''),
|
|
@@ -386,7 +391,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
386
391
|
}
|
|
387
392
|
|
|
388
393
|
for (const h of v.headers.filter((x) => TELLS.test(x.name))) {
|
|
389
|
-
|
|
394
|
+
fromPrograms.push({
|
|
390
395
|
rule: 'web-response-tells-the-caller-what-it-runs', ...h.at, program: v.id,
|
|
391
396
|
detail: `${v.id} writes the header ${h.name}${h.value ? ` with the value '` + h.value + `'` : ''}, which names the software answering rather than anything the client needs`,
|
|
392
397
|
});
|
|
@@ -397,14 +402,14 @@ export function scanWeb(root, opts = {}) {
|
|
|
397
402
|
? { line: v.built[0].line, why: 'STRINGs ' + v.built[0].name + ' into a value it sends, and the name says it holds a credential' }
|
|
398
403
|
: (v.uris.length ? { line: 1, why: 'builds a URI whose query names a credential: ' + v.uris[0] } : null);
|
|
399
404
|
if (credInUri) {
|
|
400
|
-
|
|
405
|
+
fromPrograms.push({
|
|
401
406
|
rule: 'web-uri-carries-a-credential', path, line: credInUri.line, program: v.id,
|
|
402
407
|
detail: v.id + ' ' + credInUri.why,
|
|
403
408
|
});
|
|
404
409
|
}
|
|
405
410
|
if (v.webRequest && v.changes.length && !v.comparesToken) {
|
|
406
411
|
const what = [...new Set(v.changes.map((c) => c.what))];
|
|
407
|
-
|
|
412
|
+
fromPrograms.push({
|
|
408
413
|
rule: 'web-request-changes-state-without-a-token', ...where(v.changes[0].e, path), program: v.id,
|
|
409
414
|
detail: `${v.id} changes state (${what.slice(0, 3).join(', ')}${what.length > 3 ? ', …' : ''}) on a request it received from the web and compares no token with another field`
|
|
410
415
|
+ (v.testsMethod ? '; it tests the HTTP method, which a forged request sets too' : '')
|
|
@@ -420,7 +425,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
420
425
|
// A program that wrote none of the three has said nothing about any of this; one that wrote
|
|
421
426
|
// two of three made a choice about the third. Both are reported, and the detail says which.
|
|
422
427
|
if (absent.length) {
|
|
423
|
-
|
|
428
|
+
fromPrograms.push({
|
|
424
429
|
rule: 'web-response-without-protective-headers', ...at, program: v.id,
|
|
425
430
|
detail: `${v.id} sends HTML and writes no ${absent.map((h) => h.header.source.replace(/[^a-z-]/gi, '')).join(', no ')} header`
|
|
426
431
|
+ (v.computedHeaders ? `. ${v.computedHeaders} header name or value in this program is computed, so what it sets was not read` : ''),
|
|
@@ -431,7 +436,7 @@ export function scanWeb(root, opts = {}) {
|
|
|
431
436
|
// the document is built from.
|
|
432
437
|
for (const m of v.docText.matchAll(/<a\b[^>]*target\s*=\s*["']?_blank[^>]*>/gi)) {
|
|
433
438
|
if (/\brel\s*=\s*["'][^"']*noopener/i.test(m[0])) continue;
|
|
434
|
-
|
|
439
|
+
fromPrograms.push({
|
|
435
440
|
rule: 'web-link-opens-without-noopener', ...at, program: v.id,
|
|
436
441
|
detail: `${v.id} writes a link with target="_blank" and no rel="noopener", so the page it opens can navigate this one`,
|
|
437
442
|
});
|
|
@@ -442,5 +447,16 @@ export function scanWeb(root, opts = {}) {
|
|
|
442
447
|
return src.length;
|
|
443
448
|
}, { label: 'web', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
444
449
|
|
|
450
|
+
// A pass the memory guard stopped after the last program but before a later definition is no
|
|
451
|
+
// more complete than one stopped among the programs.
|
|
452
|
+
const [defined, read] = yield loopsOver(definitions, programs);
|
|
453
|
+
const run = read.complete && !defined.complete ? defined : read;
|
|
454
|
+
for (const x of fromPrograms) {
|
|
455
|
+
const finding = typeof x === 'function' ? x() : x;
|
|
456
|
+
if (finding) findings.push(finding);
|
|
457
|
+
}
|
|
458
|
+
|
|
445
459
|
return report('web', { rules: WEB_RULES, findings, stats, run });
|
|
446
460
|
}
|
|
461
|
+
|
|
462
|
+
export const scanWeb = (root, opts = {}) => drive(scanWebSteps(root, opts));
|
package/lib/site.mjs
CHANGED
|
@@ -12,8 +12,10 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { resolvesInside } from './kernel/source-tree.mjs';
|
|
14
14
|
import { printable } from './kernel/printable.mjs';
|
|
15
|
+
import { versionProblem, unknownKeys, ignoredKey } from './kernel/version-key.mjs';
|
|
15
16
|
|
|
16
17
|
export const SITE_FILE = 'cobolwork.site.json';
|
|
18
|
+
export const SITE_VERSION = 1;
|
|
17
19
|
|
|
18
20
|
const EMPTY = {
|
|
19
21
|
present: false,
|
|
@@ -38,8 +40,11 @@ const EMPTY = {
|
|
|
38
40
|
privilegedTransactions: [],
|
|
39
41
|
privilegedJobs: [],
|
|
40
42
|
problems: [],
|
|
43
|
+
warnings: [],
|
|
41
44
|
};
|
|
42
45
|
|
|
46
|
+
const KNOWN_KEYS = new Set(['$schema', 'version', ...Object.keys(EMPTY).filter((k) => !['present', 'problems', 'warnings'].includes(k))]);
|
|
47
|
+
|
|
43
48
|
// A configuration that is absent is not a configuration that says "nothing is production". The
|
|
44
49
|
// difference is the whole point: without it the recon rules have not looked, and a scan must say
|
|
45
50
|
// it has not looked rather than report a clean result.
|
|
@@ -65,6 +70,10 @@ export function loadSite(root, explicit = null, tree = null) {
|
|
|
65
70
|
}
|
|
66
71
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...EMPTY, path, problems: [`${SITE_FILE} is not a JSON object`] };
|
|
67
72
|
|
|
73
|
+
const unreadable = versionProblem(raw, SITE_VERSION);
|
|
74
|
+
if (unreadable) return { ...EMPTY, path, problems: [`${SITE_FILE} ${unreadable}, so it was not read`] };
|
|
75
|
+
const warnings = unknownKeys(raw, KNOWN_KEYS).map((k) => ignoredKey(SITE_FILE, k));
|
|
76
|
+
|
|
68
77
|
const problems = [];
|
|
69
78
|
const list = (name) => {
|
|
70
79
|
const v = raw[name];
|
|
@@ -123,6 +132,7 @@ export function loadSite(root, explicit = null, tree = null) {
|
|
|
123
132
|
privilegedTransactions: list('privilegedTransactions').map((s) => s.toUpperCase()),
|
|
124
133
|
privilegedJobs: list('privilegedJobs').map((s) => s.toUpperCase()),
|
|
125
134
|
problems,
|
|
135
|
+
warnings,
|
|
126
136
|
};
|
|
127
137
|
|
|
128
138
|
if (site.present && !site.productionQualifiers.length && !site.systemNames.length) {
|
package/lib/sources.mjs
CHANGED
|
@@ -17,6 +17,9 @@ export const BMS_EXT = ['.bms'];
|
|
|
17
17
|
// Assembler source. A BMS map or an IMS DBD or PSB is also assembler macros, and is often named .asm;
|
|
18
18
|
// lib/hlasm.mjs tells them apart by the macros they hold.
|
|
19
19
|
export const HLASM_EXT = ['.asm', '.mac', '.mlc', '.hlasm', '.assemble'];
|
|
20
|
+
export const IMS_EXT = ['.dbd', '.psb'];
|
|
21
|
+
export const DB2_EXT = ['.sql', '.ddl', '.db2'];
|
|
22
|
+
export const PLI_EXT = ['.pli', '.pl1', '.plx'];
|
|
20
23
|
|
|
21
24
|
const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
22
25
|
|
|
@@ -26,19 +29,22 @@ const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
|
26
29
|
// of it and reporting a clean zero, which is the failure this project exists to refuse.
|
|
27
30
|
//
|
|
28
31
|
// So a file with no extension is classified by what is in it. Only the first few kilobytes are
|
|
29
|
-
// read and only
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// life of the process.
|
|
32
|
+
// read, and more only when they read like assembler cards that have not yet said so; only files
|
|
33
|
+
// without an extension are sniffed. The tree a path belongs to classifies it and keeps the answer
|
|
34
|
+
// for as long as the tree lives: this runs over every file in the tree and must not cost a parse,
|
|
35
|
+
// and a cache that outlives the scan grows by one entry per file for the life of the process.
|
|
33
36
|
const SNIFF_BYTES = 4096;
|
|
37
|
+
const WIDE_BYTES = 65536;
|
|
34
38
|
|
|
35
39
|
// Names that are certainly not source, so the commonest extensionless files cost nothing.
|
|
36
40
|
const NOT_SOURCE = /^(LICEN[CS]E|README|MAKEFILE|DOCKERFILE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS|VERSION|COPYING|Jenkinsfile|Procfile|\..*)$/i;
|
|
37
41
|
const notSource = (path) => NOT_SOURCE.test(path.split(/[\\/]/).pop());
|
|
38
42
|
|
|
43
|
+
const textOf = (buf) => (looksEbcdic(buf) ? decodeEbcdic(buf) : buf.toString('latin1'));
|
|
44
|
+
|
|
39
45
|
function kindOfHead(head) {
|
|
40
46
|
// An EBCDIC member is decoded before it is read, or every test below sees mojibake.
|
|
41
|
-
const text =
|
|
47
|
+
const text = textOf(head);
|
|
42
48
|
// A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
|
|
43
49
|
// NUL in source is exactly the character an editor or a patch is most likely to eat.
|
|
44
50
|
if (head.includes(0)) return null;
|
|
@@ -49,25 +55,62 @@ function kindOfHead(head) {
|
|
|
49
55
|
// A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
|
|
50
56
|
// classified before classifies differently.
|
|
51
57
|
if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
|
|
58
|
+
// An IMS DBD or PSB is assembler macros too: a DBD statement and a SEGM, or a PCB and a PSBGEN.
|
|
59
|
+
if (/^[^*\s]*[ \t]+(?:DBD|PCB)(?=\s|$)/m.test(text) && /^[^*\s]*[ \t]+(?:SEGM|DBDGEN|SENSEG|PSBGEN)(?=\s|$)/m.test(text)) return 'ims';
|
|
52
60
|
// Assembler source defines a section or a macro, and declares storage or a base register, in the
|
|
53
61
|
// operation field; either alone is a word a text file can hold. Tested after BMS, which is
|
|
54
62
|
// assembler macros too.
|
|
55
63
|
if (/^[^*\s]*[ \t]+(?:CSECT|RSECT|START|MACRO)(?=\s|$)/m.test(text)
|
|
56
64
|
&& /^[^*\s]*[ \t]+(?:USING|DC|DS|BALR|BASR|STM|STMG|MEND)(?=\s|$)/m.test(text)) return 'hlasm';
|
|
65
|
+
// Db2 DDL: a statement that creates an object or grants a privilege, at the start of a line and
|
|
66
|
+
// ended by a semicolon. Tested last, after the program and JCL that also carry SQL.
|
|
67
|
+
if (/^\s*(?:CREATE\s+(?:TABLE|TABLESPACE|DATABASE|STOGROUP|(?:UNIQUE\s+)?INDEX|VIEW|PROCEDURE|FUNCTION|ALIAS|SYNONYM)|GRANT\s)/im.test(text) && /;\s*$/m.test(text)) return 'db2';
|
|
57
68
|
return null;
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
// A head of 4 KB misses assembler whose prologue runs past it, a macro whose first DS or MEND
|
|
72
|
+
// comes later, and a program whose section a macro generates. When the head is mostly comment
|
|
73
|
+
// cards and statements in the card's fields, up to 64 KB is read and the member is assembler if
|
|
74
|
+
// it holds a section-level and a statement-level operation from the wider lists, or five distinct
|
|
75
|
+
// common machine instructions with two operands.
|
|
76
|
+
const CARD = /^(?:\*|\.\*|(?:[A-Z$#@&.][A-Z0-9$#@_&.]{0,62})?[ \t]+[A-Z$#@][A-Z0-9$#@]{0,7}(?:[ \t]|$))/i;
|
|
77
|
+
const inOperationField = (ops) => new RegExp(String.raw`^[^*\s]*[ \t]+(?:${ops})(?=\s|$)`, 'm');
|
|
78
|
+
const WIDE_SECTION = inOperationField('CSECT|RSECT|START|MACRO|DSECT|TITLE|ENTRY|AMODE|RMODE');
|
|
79
|
+
const WIDE_STATEMENT = inOperationField('USING|DC|DS|BALR|BASR|STM|STMG|MEND|AIF|AGO|SETA|SETB|SETC|GBLA|GBLB|GBLC|LCLA|LCLB|LCLC|ANOP|MNOTE|EQU|LTORG|EJECT|PRINT');
|
|
80
|
+
const INSTRUCTION = /^[^*\s]*[ \t]+(?:L|LA|LR|LH|ST|STH|STM|LM|MVC|MVI|CLC|CLI|CL|CR|CH|C|LTR|TM|IC|STC|SR|AR|A|S|N|O|X|NI|OI|XC|NC|OC|ZAP|AP|SP|CVD|CVB|PACK|UNPK|ED|EX|BAL|BALR|BAS|BASR|BCT|BXLE|BXH|SLL|SRL|SLA|SRA|SLDL|SRDL|ICM|STCM|CLM|TR|TRT|MVCL|CLCL)[ \t]+(?:R?\d{1,2}|[A-Z$#@][A-Z0-9$#@_]*\(?)[^ \t,]*,\S/gim;
|
|
81
|
+
|
|
82
|
+
function cardShaped(text) {
|
|
83
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
|
84
|
+
return lines.length >= 10 && lines.filter((l) => CARD.test(l)).length / lines.length >= 0.8;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assemblerFurtherIn(text) {
|
|
88
|
+
if (WIDE_SECTION.test(text) && WIDE_STATEMENT.test(text)) return true;
|
|
89
|
+
return new Set(text.match(INSTRUCTION) || []).size >= 5;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The head decides; a card-shaped head it leaves undecided is read on through `wider`.
|
|
93
|
+
function kindOfMember(head, wider) {
|
|
94
|
+
const kind = kindOfHead(head);
|
|
95
|
+
if (kind || head.includes(0) || !cardShaped(textOf(head))) return kind;
|
|
96
|
+
return assemblerFurtherIn(textOf(wider())) ? 'hlasm' : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
60
99
|
// What a member's bytes say it is, for a tree that holds them.
|
|
61
|
-
export const kindOfBytes = (buf) =>
|
|
100
|
+
export const kindOfBytes = (buf) => kindOfMember(buf.subarray(0, SNIFF_BYTES), () => buf.subarray(0, WIDE_BYTES));
|
|
101
|
+
|
|
102
|
+
function readHead(path, bytes) {
|
|
103
|
+
const fd = openSync(path, 'r');
|
|
104
|
+
const buf = Buffer.alloc(bytes);
|
|
105
|
+
let n;
|
|
106
|
+
try { n = readSync(fd, buf, 0, bytes, 0); } finally { closeSync(fd); }
|
|
107
|
+
return buf.subarray(0, n);
|
|
108
|
+
}
|
|
62
109
|
|
|
63
110
|
function sniffDisk(path) {
|
|
64
111
|
if (notSource(path)) return null;
|
|
65
112
|
try {
|
|
66
|
-
|
|
67
|
-
const buf = Buffer.alloc(SNIFF_BYTES);
|
|
68
|
-
let n;
|
|
69
|
-
try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
|
|
70
|
-
return kindOfHead(buf.subarray(0, n));
|
|
113
|
+
return kindOfMember(readHead(path, SNIFF_BYTES), () => readHead(path, WIDE_BYTES));
|
|
71
114
|
} catch { return null; }
|
|
72
115
|
}
|
|
73
116
|
|
|
@@ -125,11 +168,15 @@ export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
|
|
|
125
168
|
export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
|
|
126
169
|
export const isBms = byExtOrContent(BMS_EXT, 'bms');
|
|
127
170
|
export const isAssembler = byExtOrContent(HLASM_EXT, 'hlasm');
|
|
128
|
-
|
|
129
|
-
|
|
171
|
+
export const isIms = byExtOrContent(IMS_EXT, 'ims');
|
|
172
|
+
export const isDb2 = byExtOrContent(DB2_EXT, 'db2');
|
|
173
|
+
// PL/I is known by its extension only: no content sniff reads an extensionless member as PL/I.
|
|
174
|
+
export const isPli = (p) => PLI_EXT.includes(extname(p).toLowerCase());
|
|
175
|
+
// Not BMS, assembler, an IMS definition or Db2 DDL, by either route: the sets that read every source
|
|
176
|
+
// file have not chosen to read those.
|
|
130
177
|
export const isSource = (p) => (extname(p)
|
|
131
178
|
? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
|
|
132
|
-
: ![null, 'bms', 'hlasm'].includes(sniffKind(p)));
|
|
179
|
+
: ![null, 'bms', 'hlasm', 'ims', 'db2'].includes(sniffKind(p)));
|
|
133
180
|
|
|
134
181
|
// `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
|
|
135
182
|
// files on it and no others.
|
|
@@ -199,14 +246,19 @@ export function looksEbcdic(buf) {
|
|
|
199
246
|
}
|
|
200
247
|
|
|
201
248
|
export function decodeEbcdic(buf) {
|
|
202
|
-
let
|
|
203
|
-
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x15 || buf[i] === 0x25)
|
|
249
|
+
let newlines = 0;
|
|
250
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x15 || buf[i] === 0x25) newlines++;
|
|
204
251
|
const chars = new Array(buf.length);
|
|
205
252
|
for (let i = 0; i < buf.length; i++) chars[i] = E2A[buf[i]];
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
253
|
+
// A member copied off the mainframe in binary keeps its fixed 80-byte records and no line ends. A
|
|
254
|
+
// constant can hold the newline bytes X'15' and X'25' as data, so a few of them, fewer than one
|
|
255
|
+
// record in two, do not make the member text with line ends.
|
|
256
|
+
if (buf.length % 80 === 0 && newlines < buf.length / 160) {
|
|
257
|
+
const records = [];
|
|
258
|
+
for (let i = 0; i < chars.length; i += 80) records.push(chars.slice(i, i + 80).join('').replace(/[\n\r]/g, ' '));
|
|
259
|
+
return records.join('\n');
|
|
260
|
+
}
|
|
261
|
+
return chars.join('');
|
|
210
262
|
}
|
|
211
263
|
|
|
212
264
|
// Every rule set reads source through here, so an EBCDIC member is decoded once, the same way,
|
|
@@ -214,7 +266,15 @@ export function decodeEbcdic(buf) {
|
|
|
214
266
|
export const readSource = (path) => decodeSource(readFileSync(path));
|
|
215
267
|
|
|
216
268
|
// The same decoding for bytes that did not come from a file: a blob read out of a git revision.
|
|
269
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
270
|
+
|
|
271
|
+
// A character beyond ASCII takes two to four bytes in UTF-8, and read a byte at a time it takes as
|
|
272
|
+
// many columns, which moves everything after it on a fixed-format line: column 72, the sequence
|
|
273
|
+
// area, the end of a card. A file that is valid UTF-8 and holds such characters is read as UTF-8.
|
|
217
274
|
export function decodeSource(buf) {
|
|
218
275
|
if (looksEbcdic(buf)) return { text: decodeEbcdic(buf), encoding: 'ebcdic' };
|
|
276
|
+
if (buf.some((b) => b >= 0x80)) {
|
|
277
|
+
try { return { text: UTF8.decode(buf), encoding: 'utf-8' }; } catch { /* not UTF-8: one byte, one character */ }
|
|
278
|
+
}
|
|
219
279
|
return { text: buf.toString('latin1'), encoding: 'latin1' };
|
|
220
280
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A cursor over one statement's tokens, shared by the PL/I and Db2 statement parsers so they read
|
|
3
|
+
// tokens the same way; each language fails with its own syntax error.
|
|
4
|
+
|
|
5
|
+
const isOpen = (t) => t && t.t === 'op' && t.v === '(';
|
|
6
|
+
const isClose = (t) => t && t.t === 'op' && t.v === ')';
|
|
7
|
+
|
|
8
|
+
export function tokenCursor(toks, Syntax) {
|
|
9
|
+
let i = 0;
|
|
10
|
+
const c = {
|
|
11
|
+
get pos() { return i; },
|
|
12
|
+
set pos(v) { i = v; },
|
|
13
|
+
peek: (k = 0) => toks[i + k] || null,
|
|
14
|
+
next: () => toks[i++] || null,
|
|
15
|
+
done: () => i >= toks.length,
|
|
16
|
+
rest: () => toks.slice(i),
|
|
17
|
+
drain: () => { const r = toks.slice(i); i = toks.length; return r; },
|
|
18
|
+
slice: (from, to = i) => toks.slice(from, to),
|
|
19
|
+
isWord: (u, k = 0) => { const t = toks[i + k]; return !!t && t.t === 'word' && (u === undefined || (Array.isArray(u) ? u.includes(t.u) : t.u === u)); },
|
|
20
|
+
isOp: (v, k = 0) => { const t = toks[i + k]; return !!t && t.t === 'op' && (Array.isArray(v) ? v.includes(t.v) : t.v === v); },
|
|
21
|
+
word(u) { return c.isWord(u) ? toks[i++] : null; },
|
|
22
|
+
op(v) { return c.isOp(v) ? toks[i++] : null; },
|
|
23
|
+
expectWord(u) { const t = c.word(u); if (!t) c.fail(u === undefined ? 'a name' : Array.isArray(u) ? u.join(' or ') : u); return t; },
|
|
24
|
+
expectOp(v) { const t = c.op(v); if (!t) c.fail(`'${v}'`); return t; },
|
|
25
|
+
fail(what) { const t = toks[i]; throw new Syntax(`expected ${what}${t ? ` at '${t.v ?? t.t}'` : ' at end of statement'}`, t || toks[toks.length - 1]); },
|
|
26
|
+
// The tokens between a '(' at the cursor and its closing ')', the cursor left after the ')'.
|
|
27
|
+
group() {
|
|
28
|
+
if (!isOpen(toks[i])) c.fail("'('");
|
|
29
|
+
const start = i;
|
|
30
|
+
let depth = 0;
|
|
31
|
+
for (; i < toks.length; i++) {
|
|
32
|
+
if (isOpen(toks[i])) depth++;
|
|
33
|
+
else if (isClose(toks[i]) && --depth === 0) { i++; return toks.slice(start + 1, i - 1); }
|
|
34
|
+
}
|
|
35
|
+
i = start;
|
|
36
|
+
c.fail("')'");
|
|
37
|
+
},
|
|
38
|
+
// A parenthesised list as its comma-separated members, each a token array.
|
|
39
|
+
items() { return split(c.group(), ','); },
|
|
40
|
+
// Tokens up to, not including, the first at depth 0 that `stop` accepts.
|
|
41
|
+
until(stop) {
|
|
42
|
+
const start = i;
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (; i < toks.length; i++) {
|
|
45
|
+
const t = toks[i];
|
|
46
|
+
if (depth === 0 && stop(t)) break;
|
|
47
|
+
if (isOpen(t)) depth++;
|
|
48
|
+
else if (isClose(t)) depth--;
|
|
49
|
+
}
|
|
50
|
+
return toks.slice(start, i);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Token arrays split at depth-0 tokens of op `sep`.
|
|
57
|
+
export function split(toks, sep) {
|
|
58
|
+
const parts = [[]];
|
|
59
|
+
let depth = 0;
|
|
60
|
+
for (const t of toks) {
|
|
61
|
+
if (isOpen(t)) depth++;
|
|
62
|
+
else if (isClose(t)) depth--;
|
|
63
|
+
if (depth === 0 && t.t === 'op' && t.v === sep) { parts.push([]); continue; }
|
|
64
|
+
parts[parts.length - 1].push(t);
|
|
65
|
+
}
|
|
66
|
+
return parts.length === 1 && !parts[0].length ? [] : parts;
|
|
67
|
+
}
|
package/lib/verify.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const SINKS = {
|
|
|
29
29
|
'record-update': (x) => ({ value: 'the key of a test record set up for a different test user', reads: `${x.item} holding that key`, run: 'the other user\'s test record is changed or deleted: set the record up for this test alone' }),
|
|
30
30
|
'dynamic-program-load': (x) => ({ value: ABSENT, reads: `${MARKER} as the program the CALL loads, from ${x.item}`, run: `the CALL fails to load ${MARKER} (an S806 abend in batch)` }),
|
|
31
31
|
'cics-dynamic-transfer': (x) => ({ value: ABSENT, reads: `${MARKER} as the program or transaction the command names, from ${x.item}`, run: `the command fails with PGMIDERR, or TRANSIDERR for a START` }),
|
|
32
|
-
'dynamic-file-path': (x) => ({ value: ABSENT, reads: `${MARKER} as the file the program assigns, from ${x.item}`, run: 'the OPEN fails with file status 35' }),
|
|
32
|
+
'dynamic-file-path': (x) => ({ value: ABSENT, reads: `${MARKER} as the file the program ${x.cics ? 'names' : 'assigns'}, from ${x.item}`, run: x.cics ? 'the command fails with FILENOTFOUND' : 'the OPEN fails with file status 35' }),
|
|
33
33
|
'cics-sysid': (x) => ({ value: ABSENT, reads: `${MARKER} as the SYSID the command is shipped to, from ${x.item}`, run: 'the command fails with SYSIDERR' }),
|
|
34
34
|
'queue-name': (x) => marker(x, 'as the queue the command acts on', 'the command would act on a queue the input chose'),
|
|
35
35
|
'web-response': (x) => ({ value: `${MARKER}<>`, reads: `${MARKER}<> in ${x.item}`, run: `the response body carries ${MARKER}<> as sent, not ${MARKER}<>` }),
|
|
@@ -73,6 +73,7 @@ function enterAt(source, f, src, loc) {
|
|
|
73
73
|
case 'jcl-parm': return `${src?.detail || 'the PARM'}, in a copy of the job`;
|
|
74
74
|
case 'jcl-instream': return `${src?.detail || 'the in-stream data'}, in a copy of the job`;
|
|
75
75
|
case 'file-record': return `a test record in a test copy of the file read at ${loc}`;
|
|
76
|
+
case 'cics-queue': return `a test item in the queue read at ${loc}, written by a test task`;
|
|
76
77
|
case 'database': return `a test row in a test copy of the table read at ${loc}`;
|
|
77
78
|
default: return null;
|
|
78
79
|
}
|
|
@@ -95,7 +96,7 @@ export function verificationPlan(f) {
|
|
|
95
96
|
const loc = src ? `${src.path}:${src.line}` : 'the source';
|
|
96
97
|
const last = f.trace?.length ? f.trace[f.trace.length - 1] : null;
|
|
97
98
|
const table = Number(/a table of (\d+)$/.exec(f.detail || '')?.[1]) || null;
|
|
98
|
-
const x = { item: last?.item || 'the value', table, ssrange: !!f.ssrange };
|
|
99
|
+
const x = { item: last?.item || 'the value', table, ssrange: !!f.ssrange, cics: / reaches EXEC CICS /.test(f.detail || '') };
|
|
99
100
|
const sink = `${f.path}:${f.line}`;
|
|
100
101
|
const tool = toolFor(kinds.source, entries, f.program || 'the program');
|
|
101
102
|
const start = entries.length
|
package/lib/version.mjs
CHANGED
|
@@ -6,6 +6,12 @@ import { readFileSync } from 'node:fs';
|
|
|
6
6
|
// 3: findings carry `evidence`, and setsIncomplete entries a `kind`.
|
|
7
7
|
export const SCHEMA_VERSION = 3;
|
|
8
8
|
|
|
9
|
+
// The smaller documents each command writes, versioned apart from the report.
|
|
10
|
+
export const EXPLAIN_SCHEMA_VERSION = 1;
|
|
11
|
+
export const PARSE_SCHEMA_VERSION = 1;
|
|
12
|
+
export const BASELINE_RESULT_SCHEMA_VERSION = 1;
|
|
13
|
+
export const EVIDENCE_RESULT_SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
9
15
|
// Names how taint moves through groups, so a finding that appears or disappears between two reports
|
|
10
16
|
// can be attributed to the model rather than to the code scanned. 'byte-range': a tainted field
|
|
11
17
|
// taints those bytes of its group, and the bytes are followed through MOVE, REDEFINES and CALL.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portll/cobolwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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>",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"NOTICE",
|
|
34
34
|
"LICENSING.md",
|
|
35
35
|
"THIRD-PARTY-NOTICES.md",
|
|
36
|
-
"README.md"
|
|
36
|
+
"README.md",
|
|
37
|
+
"STABILITY.md"
|
|
37
38
|
],
|
|
38
39
|
"scripts": {
|
|
39
40
|
"test": "node --test",
|