@portll/cobolwork 0.0.1 → 0.2.75
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/LICENSE +661 -0
- package/LICENSING.md +93 -0
- package/NOTICE +9 -0
- package/README.md +323 -3
- package/THIRD-PARTY-NOTICES.md +118 -0
- package/bin/cobolwork.mjs +354 -0
- package/lib/advisories.mjs +133 -0
- package/lib/baseline.mjs +154 -0
- package/lib/bms.mjs +453 -0
- package/lib/build.mjs +402 -0
- package/lib/capabilities.mjs +79 -0
- package/lib/cics-commands.mjs +281 -0
- package/lib/compliance.mjs +81 -0
- package/lib/consequence.mjs +139 -0
- package/lib/control.mjs +1515 -0
- package/lib/csd.mjs +77 -0
- package/lib/dataflow.mjs +1506 -0
- package/lib/diff.mjs +344 -0
- package/lib/explain.mjs +145 -0
- package/lib/gate.mjs +383 -0
- package/lib/index.mjs +6 -0
- package/lib/inventory.mjs +79 -0
- package/lib/jcl.mjs +478 -0
- package/lib/kernel/findings.mjs +94 -0
- package/lib/kernel/identity.mjs +216 -0
- package/lib/kernel/memory.mjs +217 -0
- package/lib/kernel/printable.mjs +6 -0
- package/lib/kernel/registry.mjs +79 -0
- package/lib/kernel/ruleset.mjs +72 -0
- package/lib/kernel/source-tree.mjs +159 -0
- package/lib/kev.mjs +27 -0
- package/lib/options.mjs +512 -0
- package/lib/packs.mjs +148 -0
- package/lib/parser.mjs +2055 -0
- package/lib/policy.mjs +163 -0
- package/lib/precompile-cics.mjs +169 -0
- package/lib/precompile.mjs +544 -0
- package/lib/reach.mjs +122 -0
- package/lib/revision.json +1 -0
- package/lib/revision.mjs +89 -0
- package/lib/sarif.mjs +222 -0
- package/lib/scan.mjs +272 -0
- package/lib/sets/build.mjs +234 -0
- package/lib/sets/cics.mjs +306 -0
- package/lib/sets/compile.mjs +187 -0
- package/lib/sets/copybook.mjs +174 -0
- package/lib/sets/flow.mjs +487 -0
- package/lib/sets/hidden.mjs +216 -0
- package/lib/sets/jcl.mjs +440 -0
- package/lib/sets/log.mjs +406 -0
- package/lib/sets/opaque.mjs +102 -0
- package/lib/sets/priv.mjs +322 -0
- package/lib/sets/recon.mjs +267 -0
- package/lib/sets/vendor.mjs +117 -0
- package/lib/sets/web.mjs +327 -0
- package/lib/site.mjs +164 -0
- package/lib/sources.mjs +156 -0
- package/lib/tui/app.mjs +325 -0
- package/lib/tui/keys.mjs +39 -0
- package/lib/tui/model.mjs +96 -0
- package/lib/tui/run.mjs +38 -0
- package/lib/tui/screen.mjs +59 -0
- package/lib/tui/terminal.mjs +46 -0
- package/lib/utilities.mjs +296 -0
- package/lib/version.mjs +15 -0
- package/lib/words.mjs +318 -0
- package/package.json +45 -6
- package/rules/advisories.json +264 -0
- package/rules/compliance-dora.json +2151 -0
- package/rules/compliance-ffiec.json +2134 -0
- package/rules/compliance-nist80053.json +2134 -0
- package/rules/gitleaks-mainframe.toml +57 -0
- package/rules/kev-ids.json +1729 -0
- package/rules/packs/broadcom.json +124 -0
- package/rules/packs/connectdirect.json +116 -0
- package/rules/packs/controlm.json +114 -0
- package/rules/system-layouts.json +28 -0
- package/schema/cobolwork-coverage.schema.json +65 -0
- package/schema/cobolwork.policy.schema.json +54 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Verbs that belong to a product rather than to the platform, from packs the estate has asked for.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here loads unless cobolwork.site.json names the pack, so a shop that does not run the
|
|
5
|
+
// product sees no rules for it. That is the feature: the commonest complaint about enterprise
|
|
6
|
+
// static analysis is noise from things the shop does not have.
|
|
7
|
+
import { inScope, isJcl, relPath } from '../sources.mjs';
|
|
8
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
9
|
+
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
10
|
+
import { parseJcl } from '../jcl.mjs';
|
|
11
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
12
|
+
import { loadPacks, compilePack, availablePacks } from '../packs.mjs';
|
|
13
|
+
import { loadSite } from '../site.mjs';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export const VENDOR_RULES = {
|
|
17
|
+
'vendor-privileged-command': {
|
|
18
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-284',
|
|
19
|
+
text: 'A job issues a vendor product command that creates, grants or alters authority',
|
|
20
|
+
impact: "An in-stream command or step invokes a vendor product's privileged function (ACF2/Top Secret, Control-M, Connect:Direct), so anyone who can edit the job exercises that authority when it runs",
|
|
21
|
+
remedy: "Move the privileged product command out of the application job into the product's controlled administration path, or restrict who may submit the job",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// An info pack rule says a product is in use, not that anything is wrong.
|
|
26
|
+
const evidenceOf = (r) => (r.severity === 'info' ? { evidence: 'context' } : {});
|
|
27
|
+
|
|
28
|
+
export function scanVendor(root, opts = {}) {
|
|
29
|
+
const site = loadSite(root, opts.site || null);
|
|
30
|
+
const wanted = opts.packs || site.vendorPacks || [];
|
|
31
|
+
const { loaded, refused, problems, caveats } = loadPacks(wanted, {
|
|
32
|
+
allowUnvalidated: !!(opts.allowUnvalidatedPacks ?? site.allowUnvalidatedPacks),
|
|
33
|
+
...(opts.packDir ? { packDir: opts.packDir } : {}),
|
|
34
|
+
});
|
|
35
|
+
const rules = loaded.flatMap(compilePack);
|
|
36
|
+
|
|
37
|
+
const stats = {
|
|
38
|
+
filesScanned: 0, filesUnreadable: 0,
|
|
39
|
+
packsRequested: wanted, packsLoaded: loaded.map((p) => p.name),
|
|
40
|
+
packsRefused: refused, packsAvailable: availablePacks(), problems,
|
|
41
|
+
// A pack that loaded on partial validation says which half it is missing, every time.
|
|
42
|
+
packCaveats: caveats,
|
|
43
|
+
rulesLoaded: rules.length,
|
|
44
|
+
// A pack asked for and not loaded means this rule set ran less than the caller believes. It
|
|
45
|
+
// is a configuration gap rather than an unread file, so it is reported as one.
|
|
46
|
+
setIncomplete: refused.length > 0 || problems.length > 0 || caveats.length > 0,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// No pack loaded means no rule can fire, but the shape of what is returned does not change:
|
|
50
|
+
// a consumer reading this report should not have to know why it is empty to read it.
|
|
51
|
+
if (!rules.length) return report('vendor', { rules: VENDOR_RULES, findings: [], stats, run: null });
|
|
52
|
+
|
|
53
|
+
const tree = treeFor(root, opts);
|
|
54
|
+
const files = tree.list().filter(inScope(opts)).filter(isJcl);
|
|
55
|
+
const findings = [];
|
|
56
|
+
|
|
57
|
+
// A job that fails to parse has still been read, so its bytes count against the budget even
|
|
58
|
+
// though nothing came of them. Only a file that could not be opened at all costs nothing.
|
|
59
|
+
const run = eachWithinMemory(files, (f) => {
|
|
60
|
+
let src;
|
|
61
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
62
|
+
let job;
|
|
63
|
+
try { job = parseJcl(src, f); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
|
|
64
|
+
stats.filesScanned++;
|
|
65
|
+
const path = relPath(root, f);
|
|
66
|
+
|
|
67
|
+
// A pack rule matches either the program a step runs or a line of in-stream data. Both are
|
|
68
|
+
// needed: the utility that reaches the product is named on the EXEC statement, and what it is
|
|
69
|
+
// told to do is in the stream below it. A rule scoped only to the stream can never match a
|
|
70
|
+
// program name, which is a rule that never fires at all.
|
|
71
|
+
for (const step of job.steps) {
|
|
72
|
+
if (!step.pgm) continue;
|
|
73
|
+
for (const r of rules) {
|
|
74
|
+
if (!r.appliesTo.includes('jcl-step')) continue;
|
|
75
|
+
if (!r.re.test(step.pgm)) continue;
|
|
76
|
+
findings.push({
|
|
77
|
+
rule: 'vendor-privileged-command', path, line: step.line, step: step.name,
|
|
78
|
+
sev: r.severity, ...evidenceOf(r),
|
|
79
|
+
detail: `${r.vendor} ${r.product}: step ${step.name || '(unnamed)'} runs ${step.pgm}, which ${r.risk}`,
|
|
80
|
+
pack: r.pack, packRule: r.id,
|
|
81
|
+
});
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const dd of job.dds) {
|
|
87
|
+
if (!dd.inStream) continue;
|
|
88
|
+
// Some of these command languages are mode-based: ACF2's SET LID puts the session into
|
|
89
|
+
// logonid maintenance, and the INSERT statements that follow mean something different from
|
|
90
|
+
// an INSERT anywhere else. Without that context the rule matched SQL INSERT INTO in DB2
|
|
91
|
+
// create scripts - 322 times across a 125-repository corpus, which is how it was found.
|
|
92
|
+
const context = new Set();
|
|
93
|
+
for (const line of dd.inStream) {
|
|
94
|
+
for (const r of rules) {
|
|
95
|
+
if (r.setsContext && r.setsContextRe.test(line.text)) context.add(r.setsContext);
|
|
96
|
+
}
|
|
97
|
+
for (const r of rules) {
|
|
98
|
+
if (!r.appliesTo.includes('jcl-instream')) continue;
|
|
99
|
+
if (r.requiresContext && !context.has(r.requiresContext)) continue;
|
|
100
|
+
if (!r.re.test(line.text)) continue;
|
|
101
|
+
findings.push({
|
|
102
|
+
rule: 'vendor-privileged-command', path, line: line.line, step: dd.step,
|
|
103
|
+
sev: r.severity, ...evidenceOf(r),
|
|
104
|
+
detail: `${r.vendor} ${r.product}: ${r.verb} ${r.risk}, from step ${dd.step || '(none)'} in //${dd.name || 'a DD'}`,
|
|
105
|
+
pack: r.pack, packRule: r.id,
|
|
106
|
+
});
|
|
107
|
+
break; // one line is one finding, as elsewhere
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return src.length;
|
|
112
|
+
}, { label: 'vendor', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
113
|
+
|
|
114
|
+
// setIncomplete above means a pack was asked for and not loaded, a different shortfall from
|
|
115
|
+
// jobs the scan never opened. Both travel, and they call for different actions.
|
|
116
|
+
return report('vendor', { rules: VENDOR_RULES, findings, stats, run });
|
|
117
|
+
}
|
package/lib/sets/web.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// What a CICS program says about its own HTTP response, and what it leaves unsaid.
|
|
3
|
+
//
|
|
4
|
+
// Every other rule set here asks where a value came from. These two ask what is absent: a cookie
|
|
5
|
+
// written without the attributes that keep it off a cleartext hop, and an HTML response sent without
|
|
6
|
+
// the headers that decide who may frame it or reinterpret its type. Absence needs no taint, no
|
|
7
|
+
// graph and no parser beyond the one that already reads EXEC CICS, which is why these are the first
|
|
8
|
+
// two rules of the twelve in docs/spec/z-sibling-rules.md.
|
|
9
|
+
//
|
|
10
|
+
// They are siblings of eleven published defects - CVE-2022-34307, CVE-2022-34311, CVE-2022-34313,
|
|
11
|
+
// CVE-2023-33847, CVE-2023-33849, CVE-2023-38363 for the cookie; CVE-2022-34162, CVE-2022-34318,
|
|
12
|
+
// CVE-2022-34329, CVE-2022-33955, CVE-2022-38705 for the headers. IBM shipped each of them in a
|
|
13
|
+
// product; a customer program that writes its own headers can ship every one of them again, and
|
|
14
|
+
// nothing looks.
|
|
15
|
+
//
|
|
16
|
+
// The unit is the program, not the statement. A header written in one paragraph protects a response
|
|
17
|
+
// sent in another, so a response is judged against every header the same program writes. That is
|
|
18
|
+
// deliberately generous: the failure worth avoiding is telling someone their response is unprotected
|
|
19
|
+
// when the header is eight paragraphs up.
|
|
20
|
+
import { inScope, isProgram, relPath } from '../sources.mjs';
|
|
21
|
+
import { parseCsd } from '../csd.mjs';
|
|
22
|
+
import { classify } from './log.mjs';
|
|
23
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
24
|
+
import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
|
|
25
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export const WEB_RULES = {
|
|
29
|
+
'web-cookie-without-secure-attributes': {
|
|
30
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-1004',
|
|
31
|
+
text: 'A program sets a cookie without the attributes that keep it off a cleartext hop',
|
|
32
|
+
impact: 'A cookie is set without Secure/HttpOnly/SameSite, so it can cross the network in cleartext or be read by script',
|
|
33
|
+
remedy: 'Set Secure, HttpOnly and SameSite on the Set-Cookie header the program writes',
|
|
34
|
+
},
|
|
35
|
+
'web-response-without-protective-headers': {
|
|
36
|
+
sev: 'low', evidence: 'construct', cwe: 'CWE-1021',
|
|
37
|
+
text: 'A program sends HTML without the headers that say who may frame or reinterpret it',
|
|
38
|
+
impact: "An HTML response goes out without Content-Security-Policy, X-Frame-Options or X-Content-Type-Options, leaving the browser's defaults to decide who may frame or script it",
|
|
39
|
+
remedy: 'Write Content-Security-Policy, X-Frame-Options and X-Content-Type-Options headers on the HTML response (or have the front-end proxy add them, declared in cobolwork.site.json)',
|
|
40
|
+
},
|
|
41
|
+
'web-response-tells-the-caller-what-it-runs': {
|
|
42
|
+
sev: 'low', evidence: 'construct', cwe: 'CWE-200',
|
|
43
|
+
text: 'A program writes a header naming the software behind it',
|
|
44
|
+
impact: 'The response carries a Server, X-Powered-By or version header, so anyone asking for a page learns which product and level to look up advisories for before trying anything',
|
|
45
|
+
remedy: 'Do not write the header, or write a value that names nothing: the client does not need it and the advisory record is public',
|
|
46
|
+
},
|
|
47
|
+
'web-uri-carries-a-credential': {
|
|
48
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-598',
|
|
49
|
+
text: 'A program puts a credential in a URI rather than in the request body',
|
|
50
|
+
impact: 'A query string is written to every proxy log, browser history and Referer header it passes, so the credential outlives the request in places nobody is watching',
|
|
51
|
+
remedy: 'Send the credential in the body of a POST, or in an Authorization header, and never in the path or query of a URI',
|
|
52
|
+
},
|
|
53
|
+
'web-request-changes-state-without-a-token': {
|
|
54
|
+
sev: 'med', evidence: 'advisory', cwe: 'CWE-352',
|
|
55
|
+
text: 'A program changes state on a web request without comparing anything the browser could not forge',
|
|
56
|
+
impact: 'Any page the user visits can make their browser send this request with their credentials attached, and the program will act on it',
|
|
57
|
+
remedy: 'Compare a token the program generated and stored against one the request carries, and refuse the change when they differ',
|
|
58
|
+
},
|
|
59
|
+
'cics-listener-accepts-cleartext': {
|
|
60
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-319',
|
|
61
|
+
text: 'A CICS listener accepts connections without TLS',
|
|
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
|
+
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
|
+
},
|
|
65
|
+
'web-link-opens-without-noopener': {
|
|
66
|
+
sev: 'low', evidence: 'construct', cwe: 'CWE-1022',
|
|
67
|
+
text: 'A program writes a link that opens a new window with a handle back to this one',
|
|
68
|
+
impact: 'A target=_blank link is emitted without rel=noopener, so the opened page can reach back into the opener (reverse tabnabbing)',
|
|
69
|
+
remedy: 'Add rel="noopener" (and noreferrer) to any target="_blank" link the program emits',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Reading an option's operand out of an EXEC block, the same way lib/sets/cics.mjs does.
|
|
74
|
+
function execOptions(exec) {
|
|
75
|
+
const opts = new Map();
|
|
76
|
+
const words = [];
|
|
77
|
+
const toks = exec.toks;
|
|
78
|
+
for (let i = 0; i < toks.length; i++) {
|
|
79
|
+
const t = toks[i];
|
|
80
|
+
if (t.t !== 'word') continue;
|
|
81
|
+
words.push(t.u);
|
|
82
|
+
if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
|
|
83
|
+
const inner = [];
|
|
84
|
+
let depth = 1;
|
|
85
|
+
for (let k = i + 2; k < toks.length && depth > 0; k++) {
|
|
86
|
+
if (toks[k].t === 'sep') { depth += toks[k].v === '(' ? 1 : -1; if (depth === 0) break; continue; }
|
|
87
|
+
inner.push(toks[k]);
|
|
88
|
+
}
|
|
89
|
+
if (!opts.has(t.u)) opts.set(t.u, inner);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { opts, words };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A header name or value is only knowable when the program wrote it as a literal. NAME(WS-HDR-NAME)
|
|
96
|
+
// names a field whose contents this rule set cannot see, and a rule that guessed would be inventing
|
|
97
|
+
// evidence, so the statement is counted as unreadable and reported as coverage, not as a pass.
|
|
98
|
+
const literalOf = (toks) => {
|
|
99
|
+
if (!toks || !toks.length) return null;
|
|
100
|
+
const lit = toks.find((t) => t.t === 'lit');
|
|
101
|
+
return lit ? String(lit.v) : null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const PROTECTIVE = [
|
|
105
|
+
{ header: /^content-security-policy$/i, why: 'which script and frame sources the browser may load' },
|
|
106
|
+
{ header: /^x-frame-options$/i, why: 'who may put this response in a frame' },
|
|
107
|
+
{ header: /^x-content-type-options$/i, why: 'whether the browser may re-sniff the type' },
|
|
108
|
+
// A back-and-refresh attack replays a page the browser kept. CVE-2022-33955 is that shape.
|
|
109
|
+
{ header: /^cache-control$/i, why: 'whether the browser may keep this page after the session ends' },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// What makes a response HTML, and so worth asking these questions about. A response with a media
|
|
113
|
+
// type the program chose and that is not markup - a JSON payload, a CSV extract - is not framed,
|
|
114
|
+
// not sniffed into script, and carries no links.
|
|
115
|
+
const MARKUP = /(text\/html|application\/xhtml|text\/xml|application\/xml)/i;
|
|
116
|
+
|
|
117
|
+
// A header whose value names the software answering. The absence of a protective header is one
|
|
118
|
+
// finding; the presence of an informative one is the other, and it is the same statement.
|
|
119
|
+
const TELLS = /^(server|x-powered-by|x-aspnet-version|x-generator|via)$/i;
|
|
120
|
+
// A URI that carries a credential in its query or path. Written as a literal, or built by a
|
|
121
|
+
// STRING from a field whose name says what it holds - lib/sets/log.mjs already judges the name.
|
|
122
|
+
const CREDENTIAL_PARAM = /[?&/][a-z0-9_-]*(password|passwd|pwd|token|apikey|api_key|secret|credential)[a-z0-9_-]*=/i;
|
|
123
|
+
|
|
124
|
+
// A definition that says nothing about SSL accepts cleartext, because CICS defaults it to NO. So
|
|
125
|
+
// the absence and the explicit NO are one finding, and PROTOCOL only changes what is in the clear.
|
|
126
|
+
const encrypted = (svc) => /^(YES|CLIENTAUTH|CLIENTCERT)$/i.test(String(svc.ssl || ''));
|
|
127
|
+
|
|
128
|
+
export function scanWeb(root, opts = {}) {
|
|
129
|
+
const tree = treeFor(root, opts);
|
|
130
|
+
const all = tree.list().filter(inScope(opts));
|
|
131
|
+
const files = all.filter(isProgram);
|
|
132
|
+
const findings = [];
|
|
133
|
+
const stats = {
|
|
134
|
+
filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0,
|
|
135
|
+
webPrograms: 0, headersWritten: 0, headersComputed: 0, htmlResponses: 0, listenersDefined: 0,
|
|
136
|
+
};
|
|
137
|
+
const where = (x, path) => ({ path: x.file ? relPath(root, x.file) : path, line: x.line || 1 });
|
|
138
|
+
|
|
139
|
+
function judge(p, path) {
|
|
140
|
+
const headers = []; // every WRITE HTTPHEADER this program issues
|
|
141
|
+
const sends = []; // every place it sends a response
|
|
142
|
+
const cookies = [];
|
|
143
|
+
let computedHeaders = 0;
|
|
144
|
+
|
|
145
|
+
for (const e of p.execs) {
|
|
146
|
+
if (e.kind !== 'CICS') continue;
|
|
147
|
+
const { opts: o, words: w } = execOptions(e);
|
|
148
|
+
if (!w.includes('WEB') && !w.includes('DOCUMENT')) continue;
|
|
149
|
+
const at = where(e, path);
|
|
150
|
+
|
|
151
|
+
if (w.includes('WRITE') && w.includes('HTTPHEADER')) {
|
|
152
|
+
const name = literalOf(o.get('HTTPHEADER') || o.get('NAME'));
|
|
153
|
+
const value = literalOf(o.get('VALUE'));
|
|
154
|
+
if (name === null) { computedHeaders++; continue; }
|
|
155
|
+
headers.push({ at, name, value });
|
|
156
|
+
if (/^set-cookie$/i.test(name)) cookies.push({ at, name, value });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// SEND and DOCUMENT RETRIEVE both put a response on the wire; CREATE and INSERT build the
|
|
161
|
+
// document that one of them will send.
|
|
162
|
+
if ((w.includes('SEND') || w.includes('CONVERSE')) && w.includes('WEB')) {
|
|
163
|
+
const media = literalOf(o.get('MEDIATYPE'));
|
|
164
|
+
sends.push({ at, media, html: media === null ? null : MARKUP.test(media) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A document built with HTML in it is an HTML response even where the send never states a
|
|
169
|
+
// media type, which is the common case: the media type defaults from the document template.
|
|
170
|
+
//
|
|
171
|
+
// The page is usually NOT built inside the EXEC CICS block. A COBOL program MOVEs and STRINGs
|
|
172
|
+
// its markup into working storage and sends that, so reading only the command's own literals
|
|
173
|
+
// misses the ordinary shape of a CICS web program. No corpus measurement could have caught
|
|
174
|
+
// that: no public repository uses the CICS web API at all.
|
|
175
|
+
const docText = [
|
|
176
|
+
...p.execs.filter((e) => e.kind === 'CICS').flatMap((e) => e.toks.filter((t) => t.t === 'lit').map((t) => String(t.v))),
|
|
177
|
+
...(p.statements || []).flatMap((st) => (st.literals || []).map((l) => String(l && l.v !== undefined ? l.v : l))),
|
|
178
|
+
].join('\n');
|
|
179
|
+
const looksHtml = /<\s*(html|body|table|div|form|a\s|script)/i.test(docText);
|
|
180
|
+
|
|
181
|
+
// A URI the program builds for an outbound call, or writes into its own page.
|
|
182
|
+
const uris = [];
|
|
183
|
+
for (const lit of docText.split(String.fromCharCode(10))) {
|
|
184
|
+
if (CREDENTIAL_PARAM.test(lit)) uris.push(lit.trim().slice(0, 90));
|
|
185
|
+
}
|
|
186
|
+
// A credential-named field STRINGed into something: the name is the evidence, not the value.
|
|
187
|
+
const built = [];
|
|
188
|
+
for (const st of p.statements || []) {
|
|
189
|
+
if (st.verb !== 'STRING') continue;
|
|
190
|
+
for (const tok of st.sources || []) {
|
|
191
|
+
if (tok.t !== 'word') continue;
|
|
192
|
+
if (classify(tok.u) === 'credential') built.push({ name: tok.u, line: st.line || 1 });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// A state change on a request from the web, and whether anything unforgeable guards it.
|
|
196
|
+
const webRequest = p.execs.some((e) => {
|
|
197
|
+
if (e.kind !== 'CICS') return false;
|
|
198
|
+
const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
199
|
+
return w[0] === 'WEB' && (w[1] === 'RECEIVE' || w[1] === 'READ');
|
|
200
|
+
});
|
|
201
|
+
const changes = p.execs.filter((e) => {
|
|
202
|
+
if (e.kind !== 'CICS') return false;
|
|
203
|
+
const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
204
|
+
return ['WRITE', 'REWRITE', 'DELETE'].includes(w[0]) && ['FILE', 'DATASET'].includes(w[1]);
|
|
205
|
+
});
|
|
206
|
+
const tokenWords = new Set(['TOKEN', 'NONCE', 'CSRF', 'ANTIFORGERY', 'XSRF']);
|
|
207
|
+
const comparesToken = (p.statements || []).some((st) => (st.sources || [])
|
|
208
|
+
.some((t) => t.t === 'word' && String(t.u).split('-').some((w) => tokenWords.has(w))));
|
|
209
|
+
|
|
210
|
+
return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The listener that accepts the request these responses answer. It is defined in a CSD rather
|
|
214
|
+
// than in a program, so this reads every file the way lib/sets/priv.mjs does: a DFHCSDUP listing
|
|
215
|
+
// is named whatever the estate named it, and filtering on an extension is how a BMS count once
|
|
216
|
+
// came out as zero.
|
|
217
|
+
for (const f of all) {
|
|
218
|
+
let src;
|
|
219
|
+
try { src = tree.text(f).text; } catch { continue; }
|
|
220
|
+
if (!/^\s*DEFINE\s+TCPIPSERVICE\s*\(/im.test(src)) continue;
|
|
221
|
+
const path = relPath(root, f);
|
|
222
|
+
for (const [name, svc] of parseCsd(src).tcpipservices) {
|
|
223
|
+
stats.listenersDefined++;
|
|
224
|
+
if (encrypted(svc)) continue;
|
|
225
|
+
const carries = svc.protocol ? `${/^[AEIOU]/i.test(svc.protocol) ? 'an' : 'a'} ${svc.protocol} ` : '';
|
|
226
|
+
findings.push({
|
|
227
|
+
rule: 'cics-listener-accepts-cleartext', path, line: svc.line,
|
|
228
|
+
detail: `TCPIPSERVICE ${name} accepts ${carries}connection${svc.port ? ` on port ${svc.port}` : ''} with `
|
|
229
|
+
+ (svc.ssl ? `SSL(${svc.ssl})` : 'no SSL attribute, which CICS defaults to NO')
|
|
230
|
+
+ ', so everything the session carries crosses the network in the clear',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const run = eachWithinMemory(files, (f) => {
|
|
236
|
+
let src;
|
|
237
|
+
try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
238
|
+
// The whole set is about EXEC CICS WEB and EXEC CICS DOCUMENT. Nothing else needs parsing.
|
|
239
|
+
if (!/EXEC\s+CICS\s+(WEB|DOCUMENT)/i.test(src)) return src.length;
|
|
240
|
+
let r;
|
|
241
|
+
try { r = tree.parse(f, src); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
|
|
242
|
+
stats.filesScanned++;
|
|
243
|
+
const path = relPath(root, f);
|
|
244
|
+
|
|
245
|
+
for (const p of r.programs) {
|
|
246
|
+
const v = judge(p, path);
|
|
247
|
+
// Every signal this set reads, not only the ones it started with: a program that opens an
|
|
248
|
+
// outbound session or answers a web request has said something worth judging even when it
|
|
249
|
+
// writes no header and sends no page.
|
|
250
|
+
const anything = v.headers.length || v.sends.length || v.cookies.length || v.looksHtml
|
|
251
|
+
|| v.uris.length || v.built.length || (v.webRequest && v.changes.length);
|
|
252
|
+
if (!anything) continue;
|
|
253
|
+
stats.webPrograms++;
|
|
254
|
+
stats.headersWritten += v.headers.length;
|
|
255
|
+
stats.headersComputed += v.computedHeaders;
|
|
256
|
+
|
|
257
|
+
for (const c of v.cookies) {
|
|
258
|
+
// A Set-Cookie whose value the program computed is unreadable, not safe.
|
|
259
|
+
if (c.value === null) { stats.headersComputed++; continue; }
|
|
260
|
+
const missing = [];
|
|
261
|
+
if (!/;\s*secure\b/i.test(c.value)) missing.push('Secure');
|
|
262
|
+
if (!/;\s*httponly\b/i.test(c.value)) missing.push('HttpOnly');
|
|
263
|
+
if (!/;\s*samesite\s*=/i.test(c.value)) missing.push('SameSite');
|
|
264
|
+
if (!missing.length) continue;
|
|
265
|
+
findings.push({
|
|
266
|
+
rule: 'web-cookie-without-secure-attributes', ...c.at, program: v.id,
|
|
267
|
+
detail: `${v.id} writes Set-Cookie without ${missing.join(', ')}`
|
|
268
|
+
+ (missing.includes('Secure') ? ', so the browser will send this cookie over a cleartext hop' : ''),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const h of v.headers.filter((x) => TELLS.test(x.name))) {
|
|
273
|
+
findings.push({
|
|
274
|
+
rule: 'web-response-tells-the-caller-what-it-runs', ...h.at, program: v.id,
|
|
275
|
+
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`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// One per program: a URI literal and the field STRINGed into it are the same credential in
|
|
279
|
+
// the same place, and the STRING carries the line worth reading.
|
|
280
|
+
const credInUri = v.built.length
|
|
281
|
+
? { line: v.built[0].line, why: 'STRINGs ' + v.built[0].name + ' into a value it sends, and the name says it holds a credential' }
|
|
282
|
+
: (v.uris.length ? { line: 1, why: 'builds a URI whose query names a credential: ' + v.uris[0] } : null);
|
|
283
|
+
if (credInUri) {
|
|
284
|
+
findings.push({
|
|
285
|
+
rule: 'web-uri-carries-a-credential', path, line: credInUri.line, program: v.id,
|
|
286
|
+
detail: v.id + ' ' + credInUri.why,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (v.webRequest && v.changes.length && !v.comparesToken) {
|
|
290
|
+
findings.push({
|
|
291
|
+
rule: 'web-request-changes-state-without-a-token', ...where(v.changes[0], path), program: v.id,
|
|
292
|
+
detail: `${v.id} changes state on a request it received from the web and compares no token; nothing here follows a token checked in another program reached by LINK, so that is what this could not rule out`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const html = v.sends.filter((s) => s.html === true).length > 0 || (v.looksHtml && v.sends.length > 0);
|
|
296
|
+
if (!html) continue;
|
|
297
|
+
stats.htmlResponses++;
|
|
298
|
+
const at = (v.sends.find((s) => s.html === true) || v.sends[0]).at;
|
|
299
|
+
|
|
300
|
+
const absent = PROTECTIVE.filter((h) => !v.headers.some((x) => h.header.test(x.name)));
|
|
301
|
+
// A program that wrote none of the three has said nothing about any of this; one that wrote
|
|
302
|
+
// two of three made a choice about the third. Both are reported, and the detail says which.
|
|
303
|
+
if (absent.length) {
|
|
304
|
+
findings.push({
|
|
305
|
+
rule: 'web-response-without-protective-headers', ...at, program: v.id,
|
|
306
|
+
detail: `${v.id} sends HTML and writes no ${absent.map((h) => h.header.source.replace(/[^a-z-]/gi, '')).join(', no ')} header`
|
|
307
|
+
+ (v.computedHeaders ? `. ${v.computedHeaders} header name or value in this program is computed, so what it sets was not read` : ''),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// rel="noopener" is the response's own text, not a header, so it is read from the literals
|
|
312
|
+
// the document is built from.
|
|
313
|
+
for (const m of v.docText.matchAll(/<a\b[^>]*target\s*=\s*["']?_blank[^>]*>/gi)) {
|
|
314
|
+
if (/\brel\s*=\s*["'][^"']*noopener/i.test(m[0])) continue;
|
|
315
|
+
findings.push({
|
|
316
|
+
rule: 'web-link-opens-without-noopener', ...at, program: v.id,
|
|
317
|
+
detail: `${v.id} writes a link with target="_blank" and no rel="noopener", so the page it opens can navigate this one`,
|
|
318
|
+
});
|
|
319
|
+
break; // one per program: the fix is the same edit wherever it appears
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
r = null;
|
|
323
|
+
return src.length;
|
|
324
|
+
}, { label: 'web', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
325
|
+
|
|
326
|
+
return report('web', { rules: WEB_RULES, findings, stats, run });
|
|
327
|
+
}
|
package/lib/site.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// What this particular installation calls production.
|
|
3
|
+
//
|
|
4
|
+
// No rule about reconnaissance leakage can be written without this. A dataset high-level qualifier
|
|
5
|
+
// appears on every JCL line in every estate; whether `PAYR.PROD.MASTER` is a production name is a
|
|
6
|
+
// fact about the customer's naming convention, not about COBOL. Ship the mechanism open, and let
|
|
7
|
+
// the estate supply the facts.
|
|
8
|
+
//
|
|
9
|
+
// It is also the only part of cobolwork that is inherently per-customer and inherently recurring:
|
|
10
|
+
// conventions drift, environments are renamed, estates are acquired.
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { resolvesInside } from './kernel/source-tree.mjs';
|
|
14
|
+
import { printable } from './kernel/printable.mjs';
|
|
15
|
+
|
|
16
|
+
export const SITE_FILE = 'cobolwork.site.json';
|
|
17
|
+
|
|
18
|
+
const EMPTY = {
|
|
19
|
+
present: false,
|
|
20
|
+
productionQualifiers: [],
|
|
21
|
+
productionJobPaths: [],
|
|
22
|
+
nonProductionJobPaths: [],
|
|
23
|
+
systemNames: [],
|
|
24
|
+
vendorPacks: [],
|
|
25
|
+
allowUnvalidatedPacks: false,
|
|
26
|
+
runtimeVersions: {},
|
|
27
|
+
internalReaderDds: [],
|
|
28
|
+
internalReaderQueues: [],
|
|
29
|
+
compilerOptions: [],
|
|
30
|
+
apfLibraries: [],
|
|
31
|
+
restrictedDatasets: [],
|
|
32
|
+
surrogateUsers: [],
|
|
33
|
+
superuserIds: [],
|
|
34
|
+
openTransactions: [],
|
|
35
|
+
restrictedTransactions: [],
|
|
36
|
+
openJobs: [],
|
|
37
|
+
restrictedJobs: [],
|
|
38
|
+
privilegedTransactions: [],
|
|
39
|
+
privilegedJobs: [],
|
|
40
|
+
problems: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// A configuration that is absent is not a configuration that says "nothing is production". The
|
|
44
|
+
// difference is the whole point: without it the recon rules have not looked, and a scan must say
|
|
45
|
+
// it has not looked rather than report a clean result.
|
|
46
|
+
export function loadSite(root, explicit = null) {
|
|
47
|
+
const path = explicit || join(root, SITE_FILE);
|
|
48
|
+
if (!existsSync(path)) return { ...EMPTY, path };
|
|
49
|
+
// A site file in the tree is not read through a link out of it; one the caller names is.
|
|
50
|
+
if (!explicit && !resolvesInside(root, path)) return { ...EMPTY, path, problems: [`${SITE_FILE} is a link that leads outside the tree, so it was not read`] };
|
|
51
|
+
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (e) {
|
|
54
|
+
return { ...EMPTY, path, problems: [`${SITE_FILE} is not readable as JSON: ${printable(e.message, 120)}`] };
|
|
55
|
+
}
|
|
56
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...EMPTY, path, problems: [`${SITE_FILE} is not a JSON object`] };
|
|
57
|
+
|
|
58
|
+
const problems = [];
|
|
59
|
+
const list = (name) => {
|
|
60
|
+
const v = raw[name];
|
|
61
|
+
if (v === undefined) return [];
|
|
62
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) {
|
|
63
|
+
problems.push(`${name}: expected an array of strings`);
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return v;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const site = {
|
|
70
|
+
present: true,
|
|
71
|
+
path,
|
|
72
|
+
productionQualifiers: list('productionQualifiers').map((s) => s.toUpperCase()),
|
|
73
|
+
productionJobPaths: list('productionJobPaths'),
|
|
74
|
+
nonProductionJobPaths: list('nonProductionJobPaths'),
|
|
75
|
+
systemNames: list('systemNames').map((s) => s.toUpperCase()),
|
|
76
|
+
vendorPacks: list('vendorPacks'),
|
|
77
|
+
allowUnvalidatedPacks: raw.allowUnvalidatedPacks === true,
|
|
78
|
+
// Which version of each runtime this estate runs. A compiler is pinned in a build file; a
|
|
79
|
+
// transaction manager or a database is not, so the only way an advisory against one becomes
|
|
80
|
+
// actionable is if someone writes down what is installed.
|
|
81
|
+
runtimeVersions: (raw.runtimeVersions && typeof raw.runtimeVersions === 'object' && !Array.isArray(raw.runtimeVersions)
|
|
82
|
+
&& Object.values(raw.runtimeVersions).every((v) => typeof v === 'string' || typeof v === 'number'))
|
|
83
|
+
? raw.runtimeVersions : (raw.runtimeVersions === undefined ? {} : (problems.push('runtimeVersions: expected an object of product to version'), {})),
|
|
84
|
+
// Which DDs in the CICS region's own JCL are SYSOUT=(class,INTRDR), and which transient-data
|
|
85
|
+
// queues reach the internal reader when no CSD extract is in the repository. The region's
|
|
86
|
+
// startup JCL is rarely committed beside the programs it runs, so this is a fact the estate
|
|
87
|
+
// states rather than one the source can show.
|
|
88
|
+
internalReaderDds: list('internalReaderDds').map((s) => s.toUpperCase()),
|
|
89
|
+
internalReaderQueues: list('internalReaderQueues').map((s) => s.toUpperCase()),
|
|
90
|
+
// A program sets its own options on a CBL or PROCESS card; the estate's default lives in a
|
|
91
|
+
// compile PROC, applied to whatever members its callers pass it, which is not in the source.
|
|
92
|
+
compilerOptions: list('compilerOptions').map((s) => s.toUpperCase()),
|
|
93
|
+
// Which libraries are APF-authorised, which dataset prefixes only a privileged user may read,
|
|
94
|
+
// and which users a job may legitimately run as. None of these is in any source file: APF
|
|
95
|
+
// authorisation is a property of a running system's PROGxx member, and a repository cannot
|
|
96
|
+
// show it. Without them the privilege rules report what a job does and assert no defect; with
|
|
97
|
+
// them the same finding becomes one.
|
|
98
|
+
apfLibraries: list('apfLibraries').map((s) => s.toUpperCase()),
|
|
99
|
+
restrictedDatasets: list('restrictedDatasets').map((s) => s.toUpperCase()),
|
|
100
|
+
surrogateUsers: list('surrogateUsers').map((s) => s.toUpperCase()),
|
|
101
|
+
// Which ids the estate treats as privileged in UNIX System Services: UID 0, or a holder of
|
|
102
|
+
// BPX.SUPERUSER. A repository shows a step reaching the shell, never the UID it runs under.
|
|
103
|
+
superuserIds: list('superuserIds').map((s) => s.toUpperCase()),
|
|
104
|
+
// The fallback for reachability: which transactions and jobs an unrestricted user can start,
|
|
105
|
+
// and which a control restricts. A brought reachability extract (COBOLWORK_REACH) is
|
|
106
|
+
// authoritative over these; see lib/reach.mjs and docs/spec/reach.md.
|
|
107
|
+
openTransactions: list('openTransactions').map((s) => s.toUpperCase()),
|
|
108
|
+
restrictedTransactions: list('restrictedTransactions').map((s) => s.toUpperCase()),
|
|
109
|
+
openJobs: list('openJobs').map((s) => s.toUpperCase()),
|
|
110
|
+
restrictedJobs: list('restrictedJobs').map((s) => s.toUpperCase()),
|
|
111
|
+
// Which entries run with elevated authority - the effect axis (lib/reach.mjs). A brought
|
|
112
|
+
// COBOLWORK_REACH extract can name these too, and is authoritative over these keys.
|
|
113
|
+
privilegedTransactions: list('privilegedTransactions').map((s) => s.toUpperCase()),
|
|
114
|
+
privilegedJobs: list('privilegedJobs').map((s) => s.toUpperCase()),
|
|
115
|
+
problems,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (site.present && !site.productionQualifiers.length && !site.systemNames.length) {
|
|
119
|
+
problems.push('the file names neither a production qualifier nor a system name, so the recon rules still have nothing to compare against');
|
|
120
|
+
}
|
|
121
|
+
return site;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Does this path look like a job that is allowed to touch production? A path listed as production
|
|
125
|
+
// is; a path listed as non-production is not; anything else is undecided, and an undecided path is
|
|
126
|
+
// not a finding, because guessing here is what makes this rule set noisy.
|
|
127
|
+
// The production qualifier a dataset name falls under, matched as whole leading components so
|
|
128
|
+
// PRODUCTS is not PROD, or null.
|
|
129
|
+
export function productionQualifierOf(dsn, qualifiers) {
|
|
130
|
+
const name = dsn.replace(/\(.*$/, '').toUpperCase();
|
|
131
|
+
return qualifiers.find((q) => name === q || name.startsWith(q + '.')) || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Whole-path glob match by dynamic programming; `**` crosses directories, `*` and `?` do not.
|
|
135
|
+
function globMatches(glob, path) {
|
|
136
|
+
const toks = [];
|
|
137
|
+
for (let i = 0; i < glob.length; i++) {
|
|
138
|
+
if (glob[i] === '*' && glob[i + 1] === '*') { toks.push('**'); i++; } else toks.push(glob[i]);
|
|
139
|
+
}
|
|
140
|
+
let prev = new Array(path.length + 1).fill(false);
|
|
141
|
+
prev[0] = true;
|
|
142
|
+
for (const t of toks) {
|
|
143
|
+
const cur = new Array(path.length + 1).fill(false);
|
|
144
|
+
let run = false;
|
|
145
|
+
for (let j = 0; j <= path.length; j++) {
|
|
146
|
+
if (t === '**') run = cur[j] = run || prev[j];
|
|
147
|
+
else if (t === '*') run = cur[j] = prev[j] || (j > 0 && run && path[j - 1] !== '/');
|
|
148
|
+
else cur[j] = j > 0 && prev[j - 1] && (t === '?' ? path[j - 1] !== '/' : path[j - 1] === t);
|
|
149
|
+
}
|
|
150
|
+
prev = cur;
|
|
151
|
+
}
|
|
152
|
+
return prev[path.length];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function classifyPath(site, path) {
|
|
156
|
+
const p = path.replace(/\\/g, '/').toLowerCase();
|
|
157
|
+
const hit = (globs) => globs.some((g) => {
|
|
158
|
+
const glob = g.replace(/\\/g, '/').toLowerCase();
|
|
159
|
+
return globMatches(glob, p) || p.includes(glob.replace(/\*/g, ''));
|
|
160
|
+
});
|
|
161
|
+
if (hit(site.productionJobPaths)) return 'production';
|
|
162
|
+
if (hit(site.nonProductionJobPaths)) return 'non-production';
|
|
163
|
+
return 'undecided';
|
|
164
|
+
}
|