@portll/cobolwork 0.2.76 → 0.2.117
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 +66 -14
- package/bin/cobolwork.mjs +126 -9
- package/lib/arith.mjs +235 -0
- package/lib/baseline.mjs +5 -0
- package/lib/build.mjs +112 -30
- package/lib/capabilities.mjs +12 -7
- package/lib/consequence.mjs +29 -0
- package/lib/control.mjs +1052 -91
- package/lib/csd.mjs +17 -2
- package/lib/dataflow.mjs +245 -78
- package/lib/enterprise-options.mjs +255 -0
- package/lib/equivalence.mjs +124 -0
- package/lib/evidence/cli.mjs +65 -0
- package/lib/evidence/journal.mjs +85 -0
- package/lib/evidence/record.mjs +110 -0
- package/lib/evidence/run.mjs +111 -0
- package/lib/evidence/seal.mjs +177 -0
- package/lib/evidence/slsa.mjs +64 -0
- package/lib/evidence/sshsig.mjs +172 -0
- package/lib/evidence/store.mjs +170 -0
- package/lib/evidence/verify.mjs +167 -0
- package/lib/explain.mjs +4 -1
- package/lib/exploitability.mjs +263 -0
- package/lib/ironwork.mjs +111 -0
- package/lib/kernel/findings.mjs +11 -0
- package/lib/kernel/registry.mjs +4 -0
- package/lib/option-diff.mjs +49 -0
- package/lib/options.mjs +109 -46
- package/lib/parser.mjs +29 -7
- package/lib/policy.mjs +8 -3
- package/lib/precompile.mjs +4 -0
- package/lib/reach.mjs +29 -12
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +1 -0
- package/lib/sbom.mjs +147 -0
- package/lib/scan.mjs +93 -27
- package/lib/sets/cics.mjs +114 -0
- package/lib/sets/flow.mjs +82 -2
- package/lib/sets/jcl.mjs +67 -0
- package/lib/sets/semantics.mjs +377 -0
- package/lib/sets/web.mjs +34 -10
- package/lib/sets/zowe.mjs +220 -0
- package/lib/sources.mjs +10 -0
- package/lib/tui/app.mjs +31 -13
- package/lib/tui/model.mjs +17 -2
- package/lib/utilities.mjs +85 -13
- package/lib/verify.mjs +128 -0
- package/package.json +1 -1
- package/rules/compliance-dora.json +507 -0
- package/rules/compliance-ffiec.json +507 -0
- package/rules/compliance-nist80053.json +507 -0
- package/schema/cobolwork.policy.schema.json +2 -1
package/lib/sets/web.mjs
CHANGED
|
@@ -121,6 +121,25 @@ const TELLS = /^(server|x-powered-by|x-aspnet-version|x-generator|via)$/i;
|
|
|
121
121
|
// STRING from a field whose name says what it holds - lib/sets/log.mjs already judges the name.
|
|
122
122
|
const CREDENTIAL_PARAM = /[?&/][a-z0-9_-]*(password|passwd|pwd|token|apikey|api_key|secret|credential)[a-z0-9_-]*=/i;
|
|
123
123
|
|
|
124
|
+
// What a request can make a program change: a file record, a row, or a transaction started. A
|
|
125
|
+
// temporary-storage queue is where a web program keeps its own conversation, so it is not counted.
|
|
126
|
+
function stateChange(e) {
|
|
127
|
+
const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
128
|
+
if (e.kind === 'SQL') return ['UPDATE', 'INSERT', 'DELETE', 'MERGE'].includes(w[0]) ? `EXEC SQL ${w[0]}` : null;
|
|
129
|
+
if (e.kind !== 'CICS') return null;
|
|
130
|
+
if (['WRITE', 'REWRITE', 'DELETE'].includes(w[0]) && ['FILE', 'DATASET'].includes(w[1])) return `EXEC CICS ${w[0]} ${w[1]}`;
|
|
131
|
+
if (w[0] === 'START' && w.includes('TRANSID')) return 'EXEC CICS START TRANSID';
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const CONDITION_VERBS = new Set(['IF', 'EVALUATE', 'WHEN']);
|
|
135
|
+
// A WHEN's statement runs to the next verb; its condition is the few words after it.
|
|
136
|
+
const WHEN_WINDOW = 12;
|
|
137
|
+
const CONDITION_WORDS = new Set(['IS', 'NOT', 'EQUAL', 'EQUALS', 'TO', 'THAN', 'GREATER', 'LESS', 'AND', 'OR', 'THEN', 'OF', 'IN', 'TRUE', 'FALSE', 'ALSO', 'OTHER', 'ANY']);
|
|
138
|
+
const FIGURATIVE = /^(SPACES?|ZEROS?|ZEROES|LOW-VALUES?|HIGH-VALUES?|NULLS?|QUOTES?)$/;
|
|
139
|
+
const TOKEN_WORDS = new Set(['TOKEN', 'NONCE', 'CSRF', 'ANTIFORGERY', 'XSRF']);
|
|
140
|
+
const namesToken = (t) => String(t.u).split('-').some((w) => TOKEN_WORDS.has(w));
|
|
141
|
+
const HTTP_METHOD = /^(GET|POST|PUT|PATCH|DELETE)$/i;
|
|
142
|
+
|
|
124
143
|
// A definition that says nothing about SSL accepts cleartext, because CICS defaults it to NO. So
|
|
125
144
|
// the absence and the explicit NO are one finding, and PROTOCOL only changes what is in the clear.
|
|
126
145
|
const encrypted = (svc) => /^(YES|CLIENTAUTH|CLIENTCERT)$/i.test(String(svc.ssl || ''));
|
|
@@ -198,16 +217,18 @@ export function scanWeb(root, opts = {}) {
|
|
|
198
217
|
const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
|
|
199
218
|
return w[0] === 'WEB' && (w[1] === 'RECEIVE' || w[1] === 'READ');
|
|
200
219
|
});
|
|
201
|
-
const changes = p.execs.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
const changes = p.execs.map((e) => ({ e, what: stateChange(e) })).filter((c) => c.what);
|
|
221
|
+
// A token counts where a condition compares it with another field: moved, displayed or tested
|
|
222
|
+
// against SPACES, it verifies nothing.
|
|
223
|
+
const conditions = (p.statements || []).filter((st) => CONDITION_VERBS.has(st.verb))
|
|
224
|
+
.map((st) => (p.proc ? p.proc.tokens.slice(st.at + 1, st.end ?? st.at + 1 + WHEN_WINDOW) : []));
|
|
225
|
+
const comparesToken = conditions.some((c) => {
|
|
226
|
+
const words = c.filter((t) => t.t === 'word' && !CONDITION_WORDS.has(t.u) && !FIGURATIVE.test(t.u));
|
|
227
|
+
return words.some(namesToken) && words.length >= 2;
|
|
205
228
|
});
|
|
206
|
-
const
|
|
207
|
-
const comparesToken = (p.statements || []).some((st) => (st.sources || [])
|
|
208
|
-
.some((t) => t.t === 'word' && String(t.u).split('-').some((w) => tokenWords.has(w))));
|
|
229
|
+
const testsMethod = conditions.some((c) => c.some((t) => t.t === 'lit' && HTTP_METHOD.test(String(t.v).trim())));
|
|
209
230
|
|
|
210
|
-
return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken };
|
|
231
|
+
return { headers, sends, cookies, computedHeaders, docText, looksHtml, id: p.id, uris, built, webRequest, changes, comparesToken, testsMethod };
|
|
211
232
|
}
|
|
212
233
|
|
|
213
234
|
// The listener that accepts the request these responses answer. It is defined in a CSD rather
|
|
@@ -287,9 +308,12 @@ export function scanWeb(root, opts = {}) {
|
|
|
287
308
|
});
|
|
288
309
|
}
|
|
289
310
|
if (v.webRequest && v.changes.length && !v.comparesToken) {
|
|
311
|
+
const what = [...new Set(v.changes.map((c) => c.what))];
|
|
290
312
|
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
|
|
313
|
+
rule: 'web-request-changes-state-without-a-token', ...where(v.changes[0].e, path), program: v.id,
|
|
314
|
+
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`
|
|
315
|
+
+ (v.testsMethod ? '; it tests the HTTP method, which a forged request sets too' : '')
|
|
316
|
+
+ '; nothing here follows a token checked in another program reached by LINK, so that is what this could not rule out',
|
|
293
317
|
});
|
|
294
318
|
}
|
|
295
319
|
const html = v.sends.filter((s) => s.html === true).length > 0 || (v.looksHtml && v.sends.length > 0);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Zowe team configuration and the MCP client configuration that launches the Zowe MCP server: where
|
|
3
|
+
// a mainframe credential, a TLS setting or an agent's authority is written into the repository.
|
|
4
|
+
// docs/spec/evidence.md §13.1. A finding names the file, the profile or server and the key, never
|
|
5
|
+
// the value.
|
|
6
|
+
import { basename } from 'node:path';
|
|
7
|
+
import { 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 ZOWE_RULES = {
|
|
13
|
+
'zowe-config-secret-in-clear': {
|
|
14
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-256',
|
|
15
|
+
text: 'A Zowe team configuration holds a password or token in the file rather than in the credential vault',
|
|
16
|
+
impact: 'Anyone who can read the repository or a copy of it can log on to the mainframe as that user',
|
|
17
|
+
remedy: 'Remove the value from properties and list the key in the profile\'s secure array, so Zowe keeps it in the operating system\'s credential vault',
|
|
18
|
+
},
|
|
19
|
+
'zowe-config-tls-verify-off': {
|
|
20
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-295',
|
|
21
|
+
text: 'A Zowe profile turns off TLS certificate verification',
|
|
22
|
+
impact: 'Anyone on the network path can present their own certificate and read or change the traffic to the mainframe, logon included',
|
|
23
|
+
remedy: 'Set rejectUnauthorized to true and install the certificate authority that signed the mainframe\'s certificate',
|
|
24
|
+
},
|
|
25
|
+
'zowe-config-cleartext': {
|
|
26
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-319',
|
|
27
|
+
text: 'A Zowe profile for z/OSMF, API ML or RSE connects over plain HTTP',
|
|
28
|
+
impact: 'The logon and the data cross the network in cleartext',
|
|
29
|
+
remedy: 'Set protocol to https',
|
|
30
|
+
},
|
|
31
|
+
'zowe-mcp-password-in-config': {
|
|
32
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-798',
|
|
33
|
+
text: 'An MCP client configuration sets a Zowe MCP password, credential map or key passphrase to a literal value',
|
|
34
|
+
impact: 'Anyone who can read the configuration can log on to the mainframe as the user the agent acts for',
|
|
35
|
+
remedy: 'Replace the literal with a reference such as ${env:NAME} and keep the value in the environment or a secrets manager',
|
|
36
|
+
},
|
|
37
|
+
'zowe-mcp-tier-writes': {
|
|
38
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-250',
|
|
39
|
+
text: 'A Zowe MCP server runs at a capability tier that lets the agent change or delete mainframe data',
|
|
40
|
+
impact: 'The agent can write, update or delete data sets and members as the user it logs on as, without a person approving each change',
|
|
41
|
+
remedy: 'Run at read-strict or read unless write access is reviewed and needed; grant update or delete for the task that needs it',
|
|
42
|
+
},
|
|
43
|
+
'zowe-mcp-tier-full': {
|
|
44
|
+
sev: 'high', evidence: 'construct', cwe: 'CWE-250',
|
|
45
|
+
text: 'A Zowe MCP server runs at the full capability tier',
|
|
46
|
+
impact: 'The agent can submit jobs and run commands on the mainframe as the user it logs on as, so text an attacker places where the agent reads it can run work on the system',
|
|
47
|
+
remedy: 'Run at read-strict or read; give job submission to a separate, reviewed path',
|
|
48
|
+
},
|
|
49
|
+
'zowe-mcp-data-marking-off': {
|
|
50
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-1427',
|
|
51
|
+
text: 'A Zowe MCP server has its untrusted-data marking turned off',
|
|
52
|
+
impact: 'Data read from the mainframe reaches the agent unmarked, so instructions planted in a data set, job output or member are not flagged as data',
|
|
53
|
+
remedy: 'Remove ZOWE_MCP_DATA_MARKING=0 so the default marking applies',
|
|
54
|
+
},
|
|
55
|
+
'zowe-mcp-unpinned': {
|
|
56
|
+
sev: 'low', evidence: 'construct', cwe: 'CWE-829',
|
|
57
|
+
text: 'A Zowe MCP server is launched by npx with no pinned version',
|
|
58
|
+
impact: 'Whatever version the registry serves next runs with the agent\'s mainframe credentials',
|
|
59
|
+
remedy: 'Pin the version: npx @zowe/mcp-server@<version>',
|
|
60
|
+
},
|
|
61
|
+
'zowe-mock-credentials': {
|
|
62
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-798',
|
|
63
|
+
text: 'A Zowe MCP mock systems.json holds a password',
|
|
64
|
+
impact: 'A password in mock data is often a real one copied in, and anyone who can read the repository has it',
|
|
65
|
+
remedy: 'Remove the password from the mock data or replace it with a placeholder',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// JSON with comments and trailing commas, as VS Code settings and Zowe team configurations allow.
|
|
70
|
+
function stripJsonc(text) {
|
|
71
|
+
let out = '';
|
|
72
|
+
let inStr = false;
|
|
73
|
+
for (let i = 0; i < text.length; i++) {
|
|
74
|
+
const c = text[i];
|
|
75
|
+
if (inStr) {
|
|
76
|
+
out += c;
|
|
77
|
+
if (c === '\\') out += text[++i] ?? '';
|
|
78
|
+
else if (c === '"') inStr = false;
|
|
79
|
+
} else if (c === '"') { inStr = true; out += c; }
|
|
80
|
+
else if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; }
|
|
81
|
+
else if (c === '/' && text[i + 1] === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') out += '\n'; i++; } i++; }
|
|
82
|
+
else if (c === ',') {
|
|
83
|
+
let j = i + 1;
|
|
84
|
+
while (j < text.length && /\s/.test(text[j])) j++;
|
|
85
|
+
if (text[j] !== '}' && text[j] !== ']') out += c;
|
|
86
|
+
} else out += c;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The line of `"key"` after the first `"anchor"`, or of the first `"key"` in the file.
|
|
92
|
+
function lineOf(text, key, anchor = null) {
|
|
93
|
+
const from = anchor ? Math.max(0, text.indexOf(`"${anchor}"`)) : 0;
|
|
94
|
+
let idx = text.indexOf(`"${key}"`, from);
|
|
95
|
+
if (idx < 0) idx = text.indexOf(`"${key}"`);
|
|
96
|
+
return idx < 0 ? 1 : text.slice(0, idx).split('\n').length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isReference = (v) => typeof v === 'string' && /^\$\{(?:env:|input:)?[A-Za-z_][A-Za-z0-9_]*\}$/.test(v);
|
|
100
|
+
|
|
101
|
+
function kindOf(rel) {
|
|
102
|
+
const base = basename(rel);
|
|
103
|
+
const parts = rel.split('/');
|
|
104
|
+
const parent = parts.length > 1 ? parts[parts.length - 2] : '';
|
|
105
|
+
if (base === 'zowe.config.json' || base === 'zowe.config.user.json') return 'team';
|
|
106
|
+
if (base === '.mcp.json' || base === 'claude_desktop_config.json') return 'mcp';
|
|
107
|
+
if (base === 'mcp.json' && (parent === '.vscode' || parent === '.cursor')) return 'mcp';
|
|
108
|
+
if ((base === 'settings.json' || base === 'settings.local.json') && parent === '.claude') return 'mcp';
|
|
109
|
+
if (base === 'settings.json' && parent === '.gemini') return 'mcp';
|
|
110
|
+
if (base === 'settings.json' && parent === '.vscode') return 'vscode';
|
|
111
|
+
if (base === 'systems.json' && parts.slice(0, -1).some((p) => /mock/i.test(p))) return 'mock';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function eachProfile(profiles, prefix, fn) {
|
|
116
|
+
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) return;
|
|
117
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
118
|
+
if (!profile || typeof profile !== 'object') continue;
|
|
119
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
120
|
+
fn(profile, path, name);
|
|
121
|
+
eachProfile(profile.profiles, path, fn);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function teamConfig(config, raw, rel, findings) {
|
|
126
|
+
eachProfile(config.profiles, '', (profile, name, leaf) => {
|
|
127
|
+
const props = profile.properties && typeof profile.properties === 'object' ? profile.properties : {};
|
|
128
|
+
const secure = Array.isArray(profile.secure) ? profile.secure : [];
|
|
129
|
+
for (const key of ['password', 'tokenValue', 'authToken']) {
|
|
130
|
+
if (typeof props[key] === 'string' && props[key] && !secure.includes(key)) {
|
|
131
|
+
findings.push({ rule: 'zowe-config-secret-in-clear', path: rel, line: lineOf(raw, key, leaf), detail: `profile ${name} holds properties.${key} in the file, and its secure array does not list it` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (props.rejectUnauthorized === false) findings.push({ rule: 'zowe-config-tls-verify-off', path: rel, line: lineOf(raw, 'rejectUnauthorized', leaf), detail: `profile ${name} sets rejectUnauthorized to false` });
|
|
135
|
+
if (['zosmf', 'apiml', 'rse'].includes(String(profile.type)) && String(props.protocol).toLowerCase() === 'http') {
|
|
136
|
+
findings.push({ rule: 'zowe-config-cleartext', path: rel, line: lineOf(raw, 'protocol', leaf), detail: `profile ${name} (type ${profile.type}) connects over http` });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isZoweServer = (s) => s && typeof s === 'object' && /@zowe\/mcp-server|zowe-mcp/.test([s.command, ...(Array.isArray(s.args) ? s.args : [])].map(String).join(' '));
|
|
142
|
+
|
|
143
|
+
function tierOf(server) {
|
|
144
|
+
const args = Array.isArray(server.args) ? server.args.map(String) : [];
|
|
145
|
+
for (let i = 0; i < args.length; i++) {
|
|
146
|
+
if (args[i] === '--capability-tier' && i + 1 < args.length) return args[i + 1];
|
|
147
|
+
if (args[i].startsWith('--capability-tier=')) return args[i].slice('--capability-tier='.length);
|
|
148
|
+
}
|
|
149
|
+
const env = server.env && typeof server.env === 'object' ? server.env : {};
|
|
150
|
+
return typeof env.ZOWE_MCP_CAPABILITY_TIER === 'string' ? env.ZOWE_MCP_CAPABILITY_TIER : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tierFinding(tier, rel, line, where) {
|
|
154
|
+
if (tier === 'full') return { rule: 'zowe-mcp-tier-full', path: rel, line, detail: `${where} runs at capability tier full` };
|
|
155
|
+
if (tier === 'update' || tier === 'delete') return { rule: 'zowe-mcp-tier-writes', path: rel, line, detail: `${where} runs at capability tier ${tier}` };
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mcpConfig(config, raw, rel, findings) {
|
|
160
|
+
const servers = config.mcpServers || config.servers || {};
|
|
161
|
+
if (!servers || typeof servers !== 'object') return;
|
|
162
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
163
|
+
if (!isZoweServer(server)) continue;
|
|
164
|
+
const env = server.env && typeof server.env === 'object' ? server.env : {};
|
|
165
|
+
for (const [key, value] of Object.entries(env)) {
|
|
166
|
+
if ((key === 'ZOWE_MCP_CREDENTIALS' || key.startsWith('ZOWE_MCP_PASSWORD_') || key.startsWith('ZOWE_MCP_KEY_PASSPHRASE_')) && value !== '' && !isReference(value)) {
|
|
167
|
+
findings.push({ rule: 'zowe-mcp-password-in-config', path: rel, line: lineOf(raw, key, name), detail: `server ${name} sets ${key} to a literal value` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const t = tierFinding(tierOf(server), rel, lineOf(raw, 'capability-tier', name) || lineOf(raw, 'ZOWE_MCP_CAPABILITY_TIER', name), `server ${name}`);
|
|
171
|
+
if (t) findings.push(t);
|
|
172
|
+
if (String(env.ZOWE_MCP_DATA_MARKING) === '0') findings.push({ rule: 'zowe-mcp-data-marking-off', path: rel, line: lineOf(raw, 'ZOWE_MCP_DATA_MARKING', name), detail: `server ${name} sets ZOWE_MCP_DATA_MARKING to 0` });
|
|
173
|
+
if (String(server.command) === 'npx') {
|
|
174
|
+
const pkg = (Array.isArray(server.args) ? server.args.map(String) : []).find((a) => a.startsWith('@zowe/mcp-server'));
|
|
175
|
+
if (pkg && (pkg === '@zowe/mcp-server' || pkg === '@zowe/mcp-server@latest')) findings.push({ rule: 'zowe-mcp-unpinned', path: rel, line: lineOf(raw, pkg, name), detail: `server ${name} runs ${pkg} through npx` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function vscodeSettings(config, raw, rel, findings) {
|
|
181
|
+
const tier = config['zoweMCP.capabilityTier'];
|
|
182
|
+
const t = typeof tier === 'string' ? tierFinding(tier, rel, lineOf(raw, 'zoweMCP.capabilityTier'), 'the VS Code setting zoweMCP.capabilityTier') : null;
|
|
183
|
+
if (t) findings.push(t);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function passwordPaths(value, path, out) {
|
|
187
|
+
if (Array.isArray(value)) value.forEach((v, i) => passwordPaths(v, `${path}[${i}]`, out));
|
|
188
|
+
else if (value && typeof value === 'object') {
|
|
189
|
+
for (const [k, v] of Object.entries(value)) {
|
|
190
|
+
const p = path ? `${path}.${k}` : k;
|
|
191
|
+
if (/^password$/i.test(k) && typeof v === 'string' && v) out.push(p);
|
|
192
|
+
else passwordPaths(v, p, out);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mockSystems(config, raw, rel, findings) {
|
|
199
|
+
for (const p of passwordPaths(config, '', [])) findings.push({ rule: 'zowe-mock-credentials', path: rel, line: lineOf(raw, 'password'), detail: `the mock systems file holds a password at ${p}` });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const READERS = { team: teamConfig, mcp: mcpConfig, vscode: vscodeSettings, mock: mockSystems };
|
|
203
|
+
|
|
204
|
+
export function scanZowe(root, opts = {}) {
|
|
205
|
+
const tree = treeFor(root, opts);
|
|
206
|
+
const findings = [];
|
|
207
|
+
const stats = { filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0 };
|
|
208
|
+
const files = tree.list().filter((f) => kindOf(relPath(root, f)));
|
|
209
|
+
const run = eachWithinMemory(files, (f) => {
|
|
210
|
+
const rel = relPath(root, f);
|
|
211
|
+
let raw;
|
|
212
|
+
try { raw = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
213
|
+
let config;
|
|
214
|
+
try { config = JSON.parse(stripJsonc(raw)); } catch { stats.filesUnparsed++; return raw.length; }
|
|
215
|
+
stats.filesScanned++;
|
|
216
|
+
if (config && typeof config === 'object') READERS[kindOf(rel)](config, raw, rel, findings);
|
|
217
|
+
return raw.length;
|
|
218
|
+
}, { label: 'zowe', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
219
|
+
return report('zowe', { rules: ZOWE_RULES, findings, stats, run });
|
|
220
|
+
}
|
package/lib/sources.mjs
CHANGED
|
@@ -118,6 +118,16 @@ const E2A = (() => {
|
|
|
118
118
|
return t;
|
|
119
119
|
})();
|
|
120
120
|
|
|
121
|
+
// A printable character's EBCDIC position, for ordering. Brackets, caret and not-sign move between
|
|
122
|
+
// 037, 1047 and 1140, so they have no one position and give null, as does anything off the table.
|
|
123
|
+
const A2E = (() => {
|
|
124
|
+
const at = new Map();
|
|
125
|
+
E2A.forEach((ch, b) => { if (b >= 0x40 && ch !== '�') at.set(ch, at.has(ch) ? null : b); });
|
|
126
|
+
for (const ch of '[]^¬') at.set(ch, null);
|
|
127
|
+
return at;
|
|
128
|
+
})();
|
|
129
|
+
export const ebcdicByte = (ch) => A2E.get(ch) ?? null;
|
|
130
|
+
|
|
121
131
|
const ASCII_ALNUM = (b) => (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A);
|
|
122
132
|
const EBCDIC_ALNUM = (b) => (b >= 0x81 && b <= 0xA9) || (b >= 0xC1 && b <= 0xE9) || (b >= 0xF0 && b <= 0xF9);
|
|
123
133
|
|
package/lib/tui/app.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { createScreen, put, styleRow } from './screen.mjs';
|
|
3
3
|
import { ACTIONS, KEYMAPS, LEGEND, actionFor } from './keys.mjs';
|
|
4
|
-
import { EVIDENCE } from '../kernel/findings.mjs';
|
|
4
|
+
import { EVIDENCE, EXPLOITABILITY } from '../kernel/findings.mjs';
|
|
5
5
|
import {
|
|
6
|
-
banner, countsLine, rows as findingRows, viewLabel, DEFAULT_VIEW, SORT_NAMES, EVIDENCE_KINDS, WHO_ACTS, SEV_LETTER,
|
|
6
|
+
banner, countsLine, exploitLine, rows as findingRows, viewLabel, DEFAULT_VIEW, SORT_NAMES, EVIDENCE_KINDS, VERDICTS, WHO_ACTS, SEV_LETTER,
|
|
7
7
|
} from './model.mjs';
|
|
8
8
|
|
|
9
9
|
export const PANELS = Object.freeze(['home', 'findings', 'finding', 'help']);
|
|
@@ -93,7 +93,7 @@ function move(s, action) {
|
|
|
93
93
|
return done(s);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
const SORT_WORDS = { SEV: 'severity', SEVERITY: 'severity', RULE: 'rule', PROGRAM: 'program', PGM: 'program', WHERE: 'where', FILE: 'where', PATH: 'where', EVIDENCE: 'evidence', EVID: 'evidence' };
|
|
96
|
+
const SORT_WORDS = { SEV: 'severity', SEVERITY: 'severity', RULE: 'rule', PROGRAM: 'program', PGM: 'program', WHERE: 'where', FILE: 'where', PATH: 'where', EVIDENCE: 'evidence', EVID: 'evidence', EXPLOIT: 'exploit', EXPLOITABILITY: 'exploit' };
|
|
97
97
|
|
|
98
98
|
function command(s, text) {
|
|
99
99
|
const [word, ...rest] = text.split(/\s+/);
|
|
@@ -116,11 +116,13 @@ function command(s, text) {
|
|
|
116
116
|
return key ? fresh({ ...s.view, sort: key }) : done({ ...s, message: `SORT takes ${SORT_NAMES.join(', ')}.` });
|
|
117
117
|
}
|
|
118
118
|
case 'FILTER': {
|
|
119
|
-
if (/^off$/i.test(arg)) return fresh({ ...s.view, evidence: null });
|
|
120
|
-
const
|
|
121
|
-
const bad =
|
|
122
|
-
if (!
|
|
123
|
-
|
|
119
|
+
if (/^off$/i.test(arg)) return fresh({ ...s.view, evidence: null, exploit: null });
|
|
120
|
+
const words = arg.toLowerCase().split(/[,\s]+/).filter(Boolean);
|
|
121
|
+
const bad = words.filter((k) => !EVIDENCE_KINDS.includes(k) && !VERDICTS.includes(k));
|
|
122
|
+
if (!words.length || bad.length) return done({ ...s, message: `FILTER takes an evidence kind or an exploitability verdict (F1 lists them), or OFF${bad.length ? `; not ${bad.join(', ')}` : ''}.` });
|
|
123
|
+
const kinds = words.filter((k) => EVIDENCE_KINDS.includes(k));
|
|
124
|
+
const verdicts = words.filter((k) => VERDICTS.includes(k));
|
|
125
|
+
return fresh({ ...s.view, evidence: kinds.length ? kinds : null, exploit: verdicts.length ? verdicts : null });
|
|
124
126
|
}
|
|
125
127
|
case 'FIND':
|
|
126
128
|
return arg ? fresh({ ...s.view, find: arg }) : done({ ...s, message: 'FIND needs the text to look for.' });
|
|
@@ -185,6 +187,8 @@ const DRAW = {
|
|
|
185
187
|
let r = r0 + 1;
|
|
186
188
|
put(scr, r++, 1, `Findings ${sum.findings ?? s.report.findings.length}${accepted}`);
|
|
187
189
|
put(scr, r++, 1, countsLine(s.report));
|
|
190
|
+
const exploit = exploitLine(s.report);
|
|
191
|
+
if (exploit) put(scr, r++, 1, exploit, sum.byExploitability.exploitable ? 'warn' : undefined);
|
|
188
192
|
const kinds = [['programs', sum.programFiles], ['copybooks', sum.copybookFiles], ['JCL', sum.jclFiles]].filter(([, n]) => n != null);
|
|
189
193
|
put(scr, r++, 1, `Read ${sum.filesScanned ?? '?'} file(s)${kinds.length ? `: ${kinds.map(([k, n]) => `${n} ${k}`).join(', ')}` : ''}`);
|
|
190
194
|
r++;
|
|
@@ -204,8 +208,11 @@ const DRAW = {
|
|
|
204
208
|
let r = drawBanner(scr, s.report, 2, 4);
|
|
205
209
|
put(scr, r++, 1, countsLine(s.report));
|
|
206
210
|
const whereW = 17, progW = 9;
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
// The verdict needs room the rule name cannot give up at 80 columns, so only a wider terminal shows it.
|
|
212
|
+
const exploitW = s.cols >= 100 ? 16 : 0;
|
|
213
|
+
const ruleW = s.cols - 18 - progW - whereW - 1 - exploitW;
|
|
214
|
+
const exploitCol = (text) => (exploitW ? `${pad(text, exploitW - 1)} ` : '');
|
|
215
|
+
put(scr, r++, 0, ` Sv Evidence ${exploitCol('Exploit')}X ${pad('Rule', ruleW)} ${pad('Program', progW)}${'Where'}`, 'dim');
|
|
209
216
|
const list = findingRows(s.report, s.view);
|
|
210
217
|
if (!list.length) put(scr, layout.listTop, 1, s.report.findings.length ? 'No finding matches this view. RESET shows them all.' : 'No findings.');
|
|
211
218
|
for (let i = 0; i < layout.height && s.top + i < list.length; i++) {
|
|
@@ -213,7 +220,7 @@ const DRAW = {
|
|
|
213
220
|
const x = list[at];
|
|
214
221
|
const row = layout.listTop + i;
|
|
215
222
|
const mark = at === s.sel ? ' > ' : ' ';
|
|
216
|
-
put(scr, row, 0, `${mark}${x.letter} ${pad(x.evidence, 9)} ${x.cross ? 'X' : ' '} ${pad(x.rule, ruleW)} ${pad(x.program || '-', progW)}${pad(x.where, whereW)}`);
|
|
223
|
+
put(scr, row, 0, `${mark}${x.letter} ${pad(x.evidence, 9)} ${exploitCol(x.exploit)}${x.cross ? 'X' : ' '} ${pad(x.rule, ruleW)} ${pad(x.program || '-', progW)}${pad(x.where, whereW)}`);
|
|
217
224
|
if (at === s.sel) styleRow(scr, row, 'sel');
|
|
218
225
|
else if (x.sev === 'crit' || x.sev === 'high') put(scr, row, 3, x.letter, x.sev);
|
|
219
226
|
}
|
|
@@ -265,6 +272,7 @@ function bodyLines(s, t) {
|
|
|
265
272
|
return HELP[t.of].flatMap((p) => (p ? wrap(p, w).map((l) => ` ${l}`) : ['']));
|
|
266
273
|
}
|
|
267
274
|
const { f } = t;
|
|
275
|
+
const x = f.exploitability;
|
|
268
276
|
const field = (label, value) => wrap(value, w - 11).map((l, i) => ` ${i ? ''.padEnd(10) : label.padEnd(10)}${l}`);
|
|
269
277
|
const lines = [
|
|
270
278
|
...field('Rule', f.rule),
|
|
@@ -272,9 +280,17 @@ function bodyLines(s, t) {
|
|
|
272
280
|
...(f.guardedFrom ? field('Checked', `lowered from ${f.guardedFrom} by the check on ${f.guard.item} at ${f.guard.file}:${f.guard.line}`) : []),
|
|
273
281
|
...field('Evidence', `${f.evidence}: ${EVIDENCE[f.evidence] || ''}`),
|
|
274
282
|
...field('Who acts', WHO_ACTS[f.evidence] || 'not stated'),
|
|
283
|
+
...(x ? field('Exploit', `${x.verdict}${x.drivenBy ? `, driven by ${x.drivenBy[0].toLowerCase()}${x.drivenBy.slice(1)}` : ''}`) : []),
|
|
275
284
|
...(f.program ? field('Program', `${f.program}${f.crossProgram ? ' crosses programs' : ''}${f.hops ? ` ${f.hops} hop(s)` : ''}`) : []),
|
|
276
285
|
...field('Where', `${f.path}:${f.line}`),
|
|
277
286
|
...field('Identity', f.fingerprint || 'none: this report predates fingerprints'),
|
|
287
|
+
...(x ? [
|
|
288
|
+
'',
|
|
289
|
+
...field('Verdict', EXPLOITABILITY[x.verdict] || x.verdict),
|
|
290
|
+
...(x.because || []).flatMap((b, i) => wrap(`- ${b}`, w - 11, ' ').map((l, j) => ` ${(i || j ? '' : 'Because').padEnd(10)}${l}`)),
|
|
291
|
+
...(x.unknown ? field('Unknown', x.unknown) : []),
|
|
292
|
+
...(x.fixAt ? field('Fix at', x.fixAt.test || x.fixAt.why) : []),
|
|
293
|
+
] : []),
|
|
278
294
|
'',
|
|
279
295
|
...wrap(f.detail || '', w).map((l) => ` ${l}`),
|
|
280
296
|
];
|
|
@@ -307,8 +323,8 @@ const HELP = {
|
|
|
307
323
|
'Enter opens the selected finding. F7 and F8, or Page Up and Page Down, move a page.',
|
|
308
324
|
'',
|
|
309
325
|
'Commands:',
|
|
310
|
-
' SORT SEVERITY, RULE, PROGRAM, WHERE or
|
|
311
|
-
` FILTER one or more of ${EVIDENCE_KINDS.join(', ')}; FILTER OFF`,
|
|
326
|
+
' SORT SEVERITY, RULE, PROGRAM, WHERE, EVIDENCE or EXPLOIT',
|
|
327
|
+
` FILTER one or more of ${EVIDENCE_KINDS.join(', ')}, or of ${VERDICTS.join(', ')}; FILTER OFF`,
|
|
312
328
|
' FIND text: rule, program, path or detail containing it',
|
|
313
329
|
' RESET: the whole list, by severity',
|
|
314
330
|
' TOP, BOTTOM, END, and =X to leave',
|
|
@@ -318,6 +334,8 @@ const HELP = {
|
|
|
318
334
|
'',
|
|
319
335
|
'Evidence says what the tool established. A path was traced by reading the code, not by running it. Severity says how urgent it is; a check on the route lowers it one step and is named here.',
|
|
320
336
|
'',
|
|
337
|
+
'Exploit is whether an attacker can use a path: exploitable needs the estate to declare an entry that reaches it open to any user. Because lists the facts the verdict rests on; Unknown names the one that would move it.',
|
|
338
|
+
'',
|
|
321
339
|
'Identity is the fingerprint. It does not change when code moves above the finding, so a baseline or a later report can refer to it.',
|
|
322
340
|
'',
|
|
323
341
|
'cobolwork explain <path> <fingerprint> prints the trace with the source line of every hop.',
|
package/lib/tui/model.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
import { EVIDENCE } from '../kernel/findings.mjs';
|
|
2
|
+
import { EVIDENCE, EXPLOITABILITY } from '../kernel/findings.mjs';
|
|
3
3
|
|
|
4
4
|
export { WHO_ACTS } from '../kernel/findings.mjs';
|
|
5
5
|
export const SEVERITIES = Object.freeze(['crit', 'high', 'med', 'low', 'info']);
|
|
6
6
|
export const SEV_LETTER = Object.freeze({ crit: 'C', high: 'H', med: 'M', low: 'L', info: 'I' });
|
|
7
7
|
export const EVIDENCE_KINDS = Object.freeze(Object.keys(EVIDENCE));
|
|
8
|
+
export const VERDICTS = Object.freeze(Object.keys(EXPLOITABILITY));
|
|
8
9
|
|
|
9
10
|
export function readReport(doc) {
|
|
10
11
|
const refuse = (why) => { throw new Error(`not a cobolwork findings report: ${why}`); };
|
|
@@ -52,30 +53,43 @@ export const countsLine = (report) => {
|
|
|
52
53
|
return `Sev ${sev} Evidence ${ev || 'none'}`;
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
// The path findings by verdict, or null for a report that carries none.
|
|
57
|
+
export const exploitLine = (report) => {
|
|
58
|
+
const by = report.summary.byExploitability;
|
|
59
|
+
if (!by) return null;
|
|
60
|
+
const counts = VERDICTS.filter((v) => by[v]).map((v) => `${v} ${by[v]}`);
|
|
61
|
+
return `Exploit ${counts.join(' ') || 'no path finding'}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const verdictRank = (f) => { const i = VERDICTS.indexOf(f.exploitability?.verdict); return i < 0 ? VERDICTS.length : i; };
|
|
65
|
+
|
|
55
66
|
const SORTS = {
|
|
56
67
|
severity: (a, b) => SEVERITIES.indexOf(a.sev) - SEVERITIES.indexOf(b.sev) || cmp(a.rule, b.rule) || cmpWhere(a, b),
|
|
57
68
|
rule: (a, b) => cmp(a.rule, b.rule) || SEVERITIES.indexOf(a.sev) - SEVERITIES.indexOf(b.sev) || cmpWhere(a, b),
|
|
58
69
|
program: (a, b) => cmp(a.program, b.program) || SORTS.severity(a, b),
|
|
59
70
|
where: (a, b) => cmpWhere(a, b) || cmp(a.rule, b.rule),
|
|
60
71
|
evidence: (a, b) => cmp(a.evidence, b.evidence) || SORTS.severity(a, b),
|
|
72
|
+
exploit: (a, b) => verdictRank(a.f) - verdictRank(b.f) || SORTS.severity(a, b),
|
|
61
73
|
};
|
|
62
74
|
export const SORT_NAMES = Object.freeze(Object.keys(SORTS));
|
|
63
75
|
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
64
76
|
const cmpWhere = (a, b) => cmp(a.path, b.path) || (a.line || 0) - (b.line || 0);
|
|
65
77
|
|
|
66
|
-
export const DEFAULT_VIEW = Object.freeze({ sort: 'severity', evidence: null, find: '' });
|
|
78
|
+
export const DEFAULT_VIEW = Object.freeze({ sort: 'severity', evidence: null, exploit: null, find: '' });
|
|
67
79
|
|
|
68
80
|
export function rows(report, view = DEFAULT_VIEW) {
|
|
69
81
|
const find = (view.find || '').toUpperCase();
|
|
70
82
|
const out = [];
|
|
71
83
|
for (const f of report.findings) {
|
|
72
84
|
if (view.evidence && !view.evidence.includes(f.evidence)) continue;
|
|
85
|
+
if (view.exploit && !view.exploit.includes(f.exploitability?.verdict)) continue;
|
|
73
86
|
if (find && ![f.rule, f.program, f.path, f.detail].some((x) => x && String(x).toUpperCase().includes(find))) continue;
|
|
74
87
|
out.push({
|
|
75
88
|
f,
|
|
76
89
|
sev: f.sev,
|
|
77
90
|
letter: SEV_LETTER[f.sev] || '?',
|
|
78
91
|
evidence: f.evidence || '',
|
|
92
|
+
exploit: f.exploitability?.verdict || '',
|
|
79
93
|
rule: f.rule || '',
|
|
80
94
|
program: f.program || '',
|
|
81
95
|
path: f.path || '',
|
|
@@ -91,6 +105,7 @@ export function viewLabel(view) {
|
|
|
91
105
|
const parts = [];
|
|
92
106
|
if (view.sort && view.sort !== 'severity') parts.push(`sorted by ${view.sort}`);
|
|
93
107
|
if (view.evidence) parts.push(`evidence ${view.evidence.join(',')}`);
|
|
108
|
+
if (view.exploit) parts.push(`exploit ${view.exploit.join(',')}`);
|
|
94
109
|
if (view.find) parts.push(`find ${view.find}`);
|
|
95
110
|
return parts.join(', ');
|
|
96
111
|
}
|