@portll/cobolwork 0.0.1 → 0.2.76

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.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSING.md +93 -0
  3. package/NOTICE +9 -0
  4. package/README.md +325 -3
  5. package/THIRD-PARTY-NOTICES.md +118 -0
  6. package/bin/cobolwork.mjs +354 -0
  7. package/lib/advisories.mjs +133 -0
  8. package/lib/baseline.mjs +154 -0
  9. package/lib/bms.mjs +453 -0
  10. package/lib/build.mjs +402 -0
  11. package/lib/capabilities.mjs +79 -0
  12. package/lib/cics-commands.mjs +281 -0
  13. package/lib/compliance.mjs +81 -0
  14. package/lib/consequence.mjs +139 -0
  15. package/lib/control.mjs +1515 -0
  16. package/lib/csd.mjs +77 -0
  17. package/lib/dataflow.mjs +1506 -0
  18. package/lib/diff.mjs +344 -0
  19. package/lib/explain.mjs +145 -0
  20. package/lib/gate.mjs +383 -0
  21. package/lib/index.mjs +6 -0
  22. package/lib/inventory.mjs +79 -0
  23. package/lib/jcl.mjs +478 -0
  24. package/lib/kernel/findings.mjs +94 -0
  25. package/lib/kernel/identity.mjs +216 -0
  26. package/lib/kernel/memory.mjs +217 -0
  27. package/lib/kernel/printable.mjs +6 -0
  28. package/lib/kernel/registry.mjs +79 -0
  29. package/lib/kernel/ruleset.mjs +72 -0
  30. package/lib/kernel/source-tree.mjs +159 -0
  31. package/lib/kev.mjs +27 -0
  32. package/lib/options.mjs +512 -0
  33. package/lib/packs.mjs +148 -0
  34. package/lib/parser.mjs +2055 -0
  35. package/lib/policy.mjs +163 -0
  36. package/lib/precompile-cics.mjs +169 -0
  37. package/lib/precompile.mjs +544 -0
  38. package/lib/reach.mjs +122 -0
  39. package/lib/revision.json +1 -0
  40. package/lib/revision.mjs +89 -0
  41. package/lib/sarif.mjs +222 -0
  42. package/lib/scan.mjs +272 -0
  43. package/lib/sets/build.mjs +234 -0
  44. package/lib/sets/cics.mjs +306 -0
  45. package/lib/sets/compile.mjs +187 -0
  46. package/lib/sets/copybook.mjs +174 -0
  47. package/lib/sets/flow.mjs +487 -0
  48. package/lib/sets/hidden.mjs +216 -0
  49. package/lib/sets/jcl.mjs +440 -0
  50. package/lib/sets/log.mjs +406 -0
  51. package/lib/sets/opaque.mjs +102 -0
  52. package/lib/sets/priv.mjs +322 -0
  53. package/lib/sets/recon.mjs +267 -0
  54. package/lib/sets/vendor.mjs +117 -0
  55. package/lib/sets/web.mjs +327 -0
  56. package/lib/site.mjs +164 -0
  57. package/lib/sources.mjs +156 -0
  58. package/lib/tui/app.mjs +325 -0
  59. package/lib/tui/keys.mjs +39 -0
  60. package/lib/tui/model.mjs +96 -0
  61. package/lib/tui/run.mjs +38 -0
  62. package/lib/tui/screen.mjs +59 -0
  63. package/lib/tui/terminal.mjs +46 -0
  64. package/lib/utilities.mjs +296 -0
  65. package/lib/version.mjs +15 -0
  66. package/lib/words.mjs +318 -0
  67. package/package.json +45 -6
  68. package/rules/advisories.json +264 -0
  69. package/rules/compliance-dora.json +2151 -0
  70. package/rules/compliance-ffiec.json +2134 -0
  71. package/rules/compliance-nist80053.json +2134 -0
  72. package/rules/gitleaks-mainframe.toml +57 -0
  73. package/rules/kev-ids.json +1729 -0
  74. package/rules/packs/broadcom.json +124 -0
  75. package/rules/packs/connectdirect.json +116 -0
  76. package/rules/packs/controlm.json +114 -0
  77. package/rules/system-layouts.json +28 -0
  78. package/schema/cobolwork-coverage.schema.json +65 -0
  79. package/schema/cobolwork.policy.schema.json +54 -0
@@ -0,0 +1,406 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // What a program writes into a log, judged by what the field is called and declared as.
3
+ //
4
+ // A log is read by people who were never entitled to the record. CWE-532 is the eighth most
5
+ // published weakness in the mainframe advisory record this project swept, and the sibling in a
6
+ // customer's own COBOL is a DISPLAY of the field the program was trusted with.
7
+ //
8
+ // This is the half of N-LOG that needs no taint: a field named PASSWORD reaching SYSOUT is a defect
9
+ // wherever it appears, which is what `construct` evidence means here. Two things within the one
10
+ // program set a write aside: a value the person at the terminal typed, shown back to them, and a
11
+ // CGI program's standard output, which is its HTTP response. The other half - input reaching a log
12
+ // unescaped so CR/LF can forge a line, and a RESP or SQLCODE reaching a web response - is a pair of
13
+ // sinks on the flow engine and lives there.
14
+ //
15
+ // The whole rule is the matcher, and the matcher is why this file exists rather than a regex. A
16
+ // COBOL name is hyphen-delimited, and a token only counts as a whole component. Measured over the
17
+ // corpus, a substring match scored six hits of which four were COMPANY-NAME and
18
+ // SS-COMPANY-LIABILITY-DATA, both matched on PAN inside COMPANY. A 67% false-positive rate from a
19
+ // pattern that read as obviously correct.
20
+ import { basename, extname } from 'node:path';
21
+ import { inScope, isJcl, isProgram, relPath } from '../sources.mjs';
22
+ import { parseJcl } from '../jcl.mjs';
23
+ import { compileStepOptions } from '../options.mjs';
24
+ import { report } from '../kernel/ruleset.mjs';
25
+ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
26
+ import { eachWithinMemory } from '../kernel/memory.mjs';
27
+
28
+
29
+ export const LOG_RULES = {
30
+ 'log-writes-a-credential': {
31
+ sev: 'high', evidence: 'construct', cwe: 'CWE-532',
32
+ text: 'A program writes a field named as a credential into a log',
33
+ impact: 'A field named as a credential is written to a log, where people the record was never for can read it',
34
+ remedy: 'Do not log credential-shaped fields; mask or omit them before DISPLAY or any journal/log write',
35
+ },
36
+ 'log-writes-personal-data': {
37
+ sev: 'med', evidence: 'construct', cwe: 'CWE-532',
38
+ text: 'A program writes a field named as personal data into a log',
39
+ impact: 'A field named as personal data is written to a log, exposing it to everyone who can read the log',
40
+ remedy: 'Do not log personal-data fields; mask, tokenize or omit them before the log write',
41
+ },
42
+ // Not a log: the terminal of the person who typed it, or the response to the request that posted
43
+ // it. Still exposure, so reported, and below the tier a build refuses.
44
+ 'display-echoes-a-credential': {
45
+ sev: 'low', evidence: 'construct', cwe: 'CWE-200',
46
+ text: 'A program displays a credential back to the terminal or HTTP request that supplied it',
47
+ impact: 'A credential shown back to whoever supplied it can be read off the screen and its scrollback, or out of a proxy, cache or browser history that keeps the response',
48
+ remedy: 'Acknowledge the credential without repeating it, or mask it before it is displayed',
49
+ },
50
+ };
51
+
52
+ // Words that, standing alone as a component of a name, say what the field holds. Kept narrow on
53
+ // purpose: these are the words a maintenance programmer chooses when the field really is one of
54
+ // these things, and the rule's error rate is the rate at which they choose them for something else.
55
+ const SECRET = new Set(['PASSWORD', 'PASSWORDS', 'PASSWD', 'PASSWRD', 'PSWD', 'PWD', 'SECRET',
56
+ 'APIKEY', 'TOKEN', 'CREDENTIAL', 'CREDENTIALS', 'PASSPHRASE', 'PIN']);
57
+ // Components that say the field holds something about a credential: a message about it, whether it
58
+ // is valid, how long it is or how many there are.
59
+ const ABOUT = new Set(['PROMPT', 'MSG', 'MESSAGE', 'ERROR', 'ERR', 'VALID', 'INVALID', 'FLAG', 'SW', 'SWITCH',
60
+ 'COUNT', 'CNT', 'CTR', 'LEN', 'LENGTH', 'ATTEMPTS', 'TRIES']);
61
+ // Ordinary programming words that are credentials only in authentication: a lexer's token, a language
62
+ // model's token count. The log rule asks for a word from QUALIFIER or for where the value came from.
63
+ const HOMONYM = new Set(['TOKEN']);
64
+ const QUALIFIER = new Set(['AUTH', 'ACCESS', 'BEARER', 'API', 'SESSION', 'REFRESH', 'OAUTH', 'JWT', 'SSO',
65
+ 'LOGIN', 'LOGON', 'SIGNON', 'SECURITY']);
66
+ const PERSONAL = new Set(['SSN', 'NINO', 'DOB', 'CVV', 'CVC', 'PAN', 'IBAN', 'SALARY', 'TAXID']);
67
+ // Components that mean nothing alone and something together. CARD is a card game, NUM is a number;
68
+ // CARD-NUM is a card number.
69
+ //
70
+ // ACCT-NO is deliberately absent, and it is the most expensive thing measured here. Over the corpus
71
+ // it matched 83 of the 90 personal-data hits, every one of them a general-ledger account:
72
+ // DED-FICA-ACCT-NO, DED-CO-SUI-ACCT-NO, DED-FWT-ACCT-NO. An account number in COBOL is
73
+ // overwhelmingly a chart-of-accounts code, not a person's bank account, and including it made the
74
+ // rule 92% wrong on the only repository that exercised it.
75
+ const PAIRS = [['CARD', 'NUM'], ['CARD', 'NUMBER'],
76
+ ['SOC', 'SEC'], ['SOCIAL', 'SECURITY'], ['BIRTH', 'DATE'], ['BIRTH', 'DT'], ['SORT', 'CODE'], ['TAX', 'ID']];
77
+ // A name that says the value has already been made safe. Masking is the fix, so a masked field is
78
+ // not the defect, and a rule that reported it would train its reader to ignore it.
79
+ const SAFE = new Set(['MASK', 'MASKED', 'HASH', 'HASHED', 'ENCRYPTED', 'REDACTED', 'LAST4', 'SUFFIX', 'TRUNC', 'TRUNCATED']);
80
+
81
+ export function classify(name) {
82
+ const parts = String(name).toUpperCase().split('-').filter(Boolean);
83
+ if (parts.some((p) => SAFE.has(p))) return null;
84
+ const set = new Set(parts);
85
+ if (parts.some((p) => SECRET.has(p))) return parts.some((p) => ABOUT.has(p)) ? null : 'credential';
86
+ if (parts.some((p) => PERSONAL.has(p))) return 'personal';
87
+ for (const pair of PAIRS) if (pair.every((w) => set.has(w))) return 'personal';
88
+ return null;
89
+ }
90
+
91
+ // Where a value leaves the program for somewhere a person reads later. WRITEQ TS is deliberately
92
+ // absent: a temporary-storage queue is working state a program reads back, and calling it a log
93
+ // would put the whole pseudo-conversational idiom in scope.
94
+ function logSinks(p, where, path) {
95
+ const out = [];
96
+ for (const s of p.statements || []) {
97
+ if (s.verb !== 'DISPLAY') continue;
98
+ const plain = !(s.sources || []).some((tok) => tok.t === 'word' && tok.u === 'UPON');
99
+ const cookie = (s.literals || []).some((l) => /^\s*set-cookie\s*:/i.test(String(l.v)));
100
+ // Everything from UPON onwards names the destination, not the value. The parser hands both
101
+ // back as operands, so a DISPLAY UPON CONSOLE offers CONSOLE as though it were a field, and a
102
+ // mnemonic named after one of the words below would be judged instead of what was displayed.
103
+ for (const tok of s.sources || []) {
104
+ if (tok.t === 'word' && tok.u === 'UPON') break;
105
+ if (tok.t === 'word') out.push({ kind: 'DISPLAY', name: tok.u, tok, plain, cookie, ...where(tok, path) });
106
+ }
107
+ }
108
+ for (const e of p.execs || []) {
109
+ if (e.kind !== 'CICS') continue;
110
+ const w = e.toks.filter((t) => t.t === 'word').map((t) => t.u);
111
+ const isTd = w[0] === 'WRITEQ' && w[1] === 'TD';
112
+ const isJournal = w[0] === 'WRITE' && w.includes('JOURNALNAME');
113
+ // The console, read by whoever is watching the region. A credential on it is as exposed as one
114
+ // in SYSOUT, and it outlives the screen in the system log.
115
+ const isOperator = w[0] === 'WRITE' && w[1] === 'OPERATOR';
116
+ if (!isTd && !isJournal && !isOperator) continue;
117
+ // FROM names the area written, TEXT names the console message; that name is what this reads.
118
+ const opt = isOperator ? 'TEXT' : 'FROM';
119
+ const i = e.toks.findIndex((t) => t.t === 'word' && t.u === opt);
120
+ const arg = i >= 0 ? e.toks.slice(i + 1).find((t) => t.t === 'word') : null;
121
+ const kind = isTd ? 'WRITEQ TD' : isOperator ? 'WRITE OPERATOR' : 'WRITE JOURNALNAME';
122
+ if (arg) out.push({ kind, name: arg.u, ...where(e, path) });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ const words = (s) => (s.sources || []).filter((t) => t.t === 'word').map((t) => t.u);
128
+ const FIGURATIVE = new Set(['TO', 'ALL', 'SPACE', 'SPACES', 'ZERO', 'ZEROS', 'ZEROES', 'LOW-VALUE', 'LOW-VALUES',
129
+ 'HIGH-VALUE', 'HIGH-VALUES', 'QUOTE', 'QUOTES', 'NULL', 'NULLS']);
130
+ const CREDENTIAL_TEXT = /pass|pwd|\bpin\b|token|secret|api.?key|cred/i;
131
+ const CICS_CREDENTIAL = new Set(['PASSWORD', 'NEWPASSWORD', 'PASSPHRASE', 'NEWPASSPHRASE', 'PASSTICKET']);
132
+
133
+ const rootOf = (it) => { let r = it; while (r.parent) r = r.parent; return r; };
134
+ const inTable = (it) => { for (let x = it; x; x = x.parent) if ((x.occurs || 1) > 1 || x.dependingOn) return true; return false; };
135
+ function baseOf(p, root) {
136
+ const seen = new Set();
137
+ let r = root;
138
+ while (r.redefines && !seen.has(r)) {
139
+ seen.add(r);
140
+ const t = p.items.find((x) => !x.parent && x.name === r.redefines && x.section === r.section);
141
+ if (!t) break;
142
+ r = t;
143
+ }
144
+ return r;
145
+ }
146
+ // Whether two items share bytes: the same record or a redefinition of it, at offsets that meet.
147
+ function overlaps(p, a, b) {
148
+ if (a === b) return true;
149
+ if (baseOf(p, rootOf(a)) !== baseOf(p, rootOf(b))) return false;
150
+ if (inTable(a) || inTable(b) || a.offset == null || b.offset == null) return true;
151
+ return a.offset < b.offset + (b.size || 0) && b.offset < a.offset + (a.size || 0);
152
+ }
153
+
154
+ // Every statement that can put a value into the item's bytes, with the statement before it.
155
+ function storesInto(p, item) {
156
+ const hits = (tok) => { const t = p.resolved.get(tok); return !!t && t.level !== 88 && !!t.section && overlaps(p, t, item); };
157
+ const st = p.statements || [];
158
+ const out = [];
159
+ for (let i = 0; i < st.length; i++) {
160
+ const s = st[i];
161
+ const byReference = s.verb === 'CALL' && (s.sources || []).some(hits);
162
+ if (byReference || (s.targets || []).some(hits)) out.push({ s, prev: st[i - 1] });
163
+ }
164
+ return out;
165
+ }
166
+ const sharing = (p, item) => p.items.filter((x) => x.level !== 88 && overlaps(p, x, item));
167
+ const namesOf = (items) => new Set(items.filter((x) => x.name !== 'FILLER').map((x) => x.name));
168
+
169
+ const prompted = (prev) => prev && prev.verb === 'DISPLAY' && (prev.literals || []).length > 0;
170
+ const fromTerminal = (s, prev) => s.verb === 'ACCEPT' && !words(s).includes('FROM')
171
+ && (words(s).includes('SECURE') || words(s).includes('NO-ECHO') || prompted(prev));
172
+ const blank = (v) => (v.t === 'lit' ? String(v.v).trim() === ''
173
+ : v.t === 'word' && (FIGURATIVE.has(v.u) || v.u === 'IS' || /^[+-]?[0.]+$/.test(v.u)));
174
+ const inPlace = (s) => s.verb === 'INSPECT' || s.verb === 'INITIALIZE'
175
+ || (s.verb === 'MOVE' && [...(s.sources || []), ...(s.literals || [])].every(blank));
176
+
177
+ // Whether everything the item can hold was typed at this program's terminal, so a plain DISPLAY of
178
+ // it shows the person at the terminal what they typed.
179
+ function typedHere(p, item) {
180
+ if (!['WORKING-STORAGE', 'LOCAL-STORAGE'].includes(item.section)) return false;
181
+ const shared = sharing(p, item);
182
+ if (shared.some((x) => (x.values || []).some((v) => !blank(v)))) return false;
183
+ const names = namesOf(shared);
184
+ if ((p.execs || []).some((e) => e.toks.some((t) => t.t === 'word' && names.has(t.u)))) return false;
185
+ const stores = storesInto(p, item);
186
+ return stores.some(({ s, prev }) => fromTerminal(s, prev))
187
+ && stores.every(({ s, prev }) => fromTerminal(s, prev) || inPlace(s));
188
+ }
189
+
190
+ // Where a value came from that only a credential comes from: a credential-named environment variable,
191
+ // a masked or credential-prompted terminal entry, a CICS command that takes a credential, or a file
192
+ // named as holding credentials.
193
+ function sourcedAsCredential(p, item) {
194
+ for (const { s, prev } of storesInto(p, item)) {
195
+ if (s.verb !== 'ACCEPT') continue;
196
+ const w = words(s);
197
+ if (w.includes('ENVIRONMENT') && (s.literals || []).some((l) => CREDENTIAL_TEXT.test(l.v))) return true;
198
+ if (w.includes('ENVIRONMENT-VALUE') && prev && (prev.literals || []).some((l) => CREDENTIAL_TEXT.test(l.v))) return true;
199
+ if (!w.includes('FROM') && (w.includes('SECURE') || w.includes('NO-ECHO'))) return true;
200
+ if (!w.includes('FROM') && prompted(prev) && prev.literals.some((l) => CREDENTIAL_TEXT.test(l.v))) return true;
201
+ }
202
+ const names = namesOf(sharing(p, item));
203
+ for (const e of p.execs || []) {
204
+ if (e.kind !== 'CICS') continue;
205
+ const t = e.toks;
206
+ const verb = (t.find((x) => x.t === 'word') || {}).u;
207
+ for (let i = 0; i + 2 < t.length; i++) {
208
+ if (t[i].t !== 'word' || (t[i + 1].v ?? t[i + 1].u) !== '(') continue;
209
+ const takes = CICS_CREDENTIAL.has(t[i].u) || (t[i].u === 'TOKEN' && (verb === 'SIGNON' || verb === 'VERIFY'));
210
+ if (takes && names.has(t[i + 2].u)) return true;
211
+ }
212
+ }
213
+ const fd = rootOf(item).fd;
214
+ if (item.section === 'FILE' && fd) {
215
+ const f = (p.files || []).find((x) => x.name === fd.name);
216
+ const assigned = f && f.assign ? String(f.assign.v ?? f.assign.u ?? '') : '';
217
+ if (CREDENTIAL_TEXT.test(fd.name) || CREDENTIAL_TEXT.test(assigned)) return true;
218
+ }
219
+ return false;
220
+ }
221
+
222
+ const expand = (pic) => String(pic).toUpperCase().replace(/(.)\((\d+)\)/g, (_, c, n) => c.repeat(Math.min(Number(n), 256)));
223
+ const NUMERIC_USAGE = /^(COMP(UTATIONAL)?-[12]|FLOAT|INDEX|POINTER|PROGRAM-POINTER|PROCEDURE-POINTER)/;
224
+ // Digits a numeric field holds, or null for text or a group.
225
+ function digitsOf(item) {
226
+ if (item.picture) {
227
+ const pic = expand(item.picture);
228
+ return /[XANGU]/.test(pic) ? null : (pic.match(/[9Z*]/g) || []).length;
229
+ }
230
+ return item.usage && NUMERIC_USAGE.test(String(item.usage).toUpperCase()) ? 0 : null;
231
+ }
232
+
233
+ // Whether a field whose name says credential holds one. A numeric field holds a count, a length or a
234
+ // computed answer; the one numeric credential is a PIN, which has at least four digits.
235
+ function holdsCredential(p, item, name) {
236
+ const parts = String(name).toUpperCase().split('-').filter(Boolean);
237
+ if (item) {
238
+ const digits = digitsOf(item);
239
+ if (digits !== null && !(parts.includes('PIN') && digits >= 4)) return false;
240
+ }
241
+ const onlyHomonyms = parts.every((x) => !SECRET.has(x) || HOMONYM.has(x));
242
+ if (onlyHomonyms && !parts.some((x) => QUALIFIER.has(x)) && !(item && sourcedAsCredential(p, item))) return false;
243
+ return true;
244
+ }
245
+
246
+ function itemFor(p, w) {
247
+ if (w.tok) return p.resolved.get(w.tok) || null;
248
+ const named = p.items.filter((x) => x.name === w.name && x.level !== 88);
249
+ return named.length === 1 ? named[0] : null;
250
+ }
251
+
252
+ // A program that writes a Content-Type header on standard output is a CGI program: its plain DISPLAY
253
+ // is the HTTP response, and its standard input and request variables are the request.
254
+ const isCgi = (p) => (p.statements || []).some((s) => s.verb === 'DISPLAY'
255
+ && (s.literals || []).some((l) => /^\s*content-type\s*:/i.test(String(l.v))));
256
+ const REQUEST_NAME = /^(QUERY_STRING|PATH_INFO|REQUEST_[A-Z_]+|CONTENT_[A-Z_]+|HTTP_[A-Z0-9_]+|POST|GET)$/i;
257
+ const literalsOf = (s) => (s.literals || []).map((l) => String(l.v).trim());
258
+
259
+ // Whether a CALL reads the request: a request variable or method is named in the call, or moved
260
+ // into one of the areas it is passed.
261
+ function callReadsRequest(p, call) {
262
+ if (literalsOf(call).slice(1).some((v) => REQUEST_NAME.test(v))) return true;
263
+ const args = (call.sources || []).map((t) => p.resolved.get(t)).filter((x) => x && x.section && x.level !== 88);
264
+ return (p.statements || []).some((s) => s.verb === 'MOVE' && literalsOf(s).some((v) => REQUEST_NAME.test(v))
265
+ && (s.targets || []).some((t) => { const to = p.resolved.get(t); return to && args.some((a) => overlaps(p, a, to)); }));
266
+ }
267
+
268
+ // Whether the item's value came from the HTTP request, traced back through the moves that fill it.
269
+ // Anything that could have put a stored value there instead - a file, a database, another
270
+ // environment variable, a routine not reading the request - leaves the answer no.
271
+ function fromRequest(p, item) {
272
+ const seen = new Set();
273
+ const queue = [item];
274
+ let request = false;
275
+ while (queue.length) {
276
+ const it = queue.shift();
277
+ if (seen.has(it)) continue;
278
+ if (seen.size >= 64 || it.section === 'FILE' || it.section === 'LINKAGE') return false;
279
+ seen.add(it);
280
+ const names = namesOf(sharing(p, it));
281
+ if ((p.execs || []).some((e) => e.toks.some((t) => t.t === 'word' && names.has(t.u)))) return false;
282
+ for (const { s, prev } of storesInto(p, it)) {
283
+ const w = words(s);
284
+ if (s.verb === 'ACCEPT') {
285
+ const named = w.includes('ENVIRONMENT') ? literalsOf(s)[0]
286
+ : w.includes('ENVIRONMENT-VALUE') && prev && prev.verb === 'DISPLAY' ? literalsOf(prev)[0] : null;
287
+ if (!w.includes('FROM') || (named && REQUEST_NAME.test(named))) { request = true; continue; }
288
+ return false;
289
+ }
290
+ if (s.verb === 'CALL') { if (callReadsRequest(p, s)) { request = true; continue; } return false; }
291
+ if (!['MOVE', 'STRING', 'UNSTRING', 'INSPECT', 'INITIALIZE'].includes(s.verb)) return false;
292
+ for (const t of s.sources || []) {
293
+ const from = p.resolved.get(t);
294
+ if (from && from.section && from.level !== 88) queue.push(from);
295
+ }
296
+ }
297
+ }
298
+ return request;
299
+ }
300
+
301
+ // The programs a job in the repository runs, by the name a step gives, and the members it compiles,
302
+ // which are z/OS batch programs whatever their ACCEPT reads. `unsure` when a job or a procedure
303
+ // could run a program nothing here names: a file that would not parse, or a symbolic PGM= no
304
+ // caller fills.
305
+ function batchPrograms(tree) {
306
+ const runs = new Set();
307
+ let unsure = false;
308
+ const parsed = [];
309
+ for (const f of tree.list().filter(isJcl)) {
310
+ try { parsed.push(parseJcl(tree.text(f).text, f)); } catch { unsure = true; }
311
+ }
312
+ const symbolsOf = new Map();
313
+ for (const j of parsed) for (const proc of j.procs || []) {
314
+ for (const s of proc.steps) if (!s.pgm && s.pgmSymbol) symbolsOf.set(String(proc.name).toUpperCase(), [...(symbolsOf.get(String(proc.name).toUpperCase()) || []), s.pgmSymbol]);
315
+ }
316
+ const filled = new Set();
317
+ for (const j of parsed) for (const s of j.steps || []) {
318
+ if (s.pgm) runs.add(s.pgm.toUpperCase());
319
+ else if (s.pgmSymbol && !s.inProc) unsure = true;
320
+ for (const sym of symbolsOf.get(String(s.proc || '').toUpperCase()) || []) {
321
+ const v = s.keywords && s.keywords.get(sym);
322
+ if (v) { runs.add(String(v).replace(/^'|'$/g, '').toUpperCase()); filled.add(sym); }
323
+ }
324
+ if (/^IKJEFT(01|1A|1B)$/i.test(s.pgm || '')) {
325
+ for (const dd of s.dds || []) for (const l of dd.inStream || []) {
326
+ const m = /\bRUN\s+PROGRAM\s*\(\s*([A-Z0-9$#@]{1,8})\s*\)/i.exec(l.text);
327
+ if (m) runs.add(m[1].toUpperCase());
328
+ }
329
+ }
330
+ }
331
+ for (const syms of symbolsOf.values()) if (syms.some((sym) => !filled.has(sym))) unsure = true;
332
+ try { for (const c of compileStepOptions(parsed)) runs.add(c.member); } catch { unsure = true; }
333
+ return { runs, unsure };
334
+ }
335
+
336
+ const memberOf = (file) => basename(String(file || ''), extname(String(file || ''))).toUpperCase();
337
+ const runByJob = (batch, p, file) => batch.unsure
338
+ || [String(p.id || '').toUpperCase(), memberOf(file)].some((n) => n && (batch.runs.has(n) || batch.runs.has(n.slice(0, 8))));
339
+
340
+ export function scanLog(root, opts = {}) {
341
+ const tree = treeFor(root, opts);
342
+ const files = tree.list().filter(isProgram).filter(inScope(opts));
343
+ const findings = [];
344
+ const stats = { filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0, logWrites: 0, sensitiveWrites: 0 };
345
+ const where = (x, path) => ({ path: x.file ? relPath(root, x.file) : path, line: x.line || 1 });
346
+ let batch = null;
347
+
348
+ // How a plain DISPLAY of a credential is reported: at the high tier as a log write, at the low
349
+ // tier as a credential shown back to whoever supplied it, or not at all where a CGI program issues
350
+ // a session token in a Set-Cookie header. Every other DISPLAY is a log write.
351
+ const judge = (p, w, item, f) => {
352
+ if (!w.plain || !item) return { rule: 'log-writes-a-credential' };
353
+ const parts = String(w.name).toUpperCase().split('-');
354
+ if (isCgi(p)) {
355
+ if (w.cookie && parts.every((x) => !SECRET.has(x) || HOMONYM.has(x))) return null;
356
+ if (fromRequest(p, item)) {
357
+ return { rule: 'display-echoes-a-credential', detail: `${p.id} puts ${w.name}, which the request posted, into its HTTP response: not a log, but proxies, caches and browser history keep what a response carries` };
358
+ }
359
+ return { rule: 'log-writes-a-credential' };
360
+ }
361
+ if (!typedHere(p, item)) return { rule: 'log-writes-a-credential' };
362
+ batch ||= batchPrograms(tree);
363
+ if (runByJob(batch, p, f)) return { rule: 'log-writes-a-credential' };
364
+ return { rule: 'display-echoes-a-credential', detail: `${p.id} shows ${w.name} on the terminal to the person who typed it: not a log, but the screen and its scrollback keep it` };
365
+ };
366
+
367
+ const run = eachWithinMemory(files, (f) => {
368
+ let src;
369
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
370
+ // Every EXEC CICS WRITE form, not the two this set started with. WRITE OPERATOR was added as a
371
+ // sink and this line was not, so a program whose only sink was the console was skipped before
372
+ // filesScanned counted it, and the set reported nosrc: a clean result over source nobody read.
373
+ if (!/\bDISPLAY\b/i.test(src) && !/EXEC\s+CICS\s+WRITE/i.test(src)) return src.length;
374
+ let r;
375
+ try { r = tree.parse(f, src); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
376
+ stats.filesScanned++;
377
+ const path = relPath(root, f);
378
+
379
+ for (const p of r.programs) {
380
+ const seen = new Set();
381
+ for (const w of logSinks(p, where, path)) {
382
+ stats.logWrites++;
383
+ const kind = classify(w.name);
384
+ if (!kind) continue;
385
+ const item = itemFor(p, w);
386
+ if (kind === 'credential' && !holdsCredential(p, item, w.name)) continue;
387
+ const verdict = kind === 'credential' ? judge(p, w, item, f) : { rule: 'log-writes-personal-data' };
388
+ if (!verdict) continue;
389
+ // One finding per field per program and rule: the fix is the same edit wherever it is repeated.
390
+ const key = `${verdict.rule}:${p.id}:${w.name}`;
391
+ if (seen.has(key)) continue;
392
+ seen.add(key);
393
+ stats.sensitiveWrites++;
394
+ findings.push({
395
+ rule: verdict.rule,
396
+ path: w.path, line: w.line, program: p.id,
397
+ detail: verdict.detail || `${p.id} writes ${w.name} with ${w.kind}, and its name says it holds ${kind === 'credential' ? 'a credential' : 'personal data'}`,
398
+ });
399
+ }
400
+ }
401
+ r = null;
402
+ return src.length;
403
+ }, { label: 'log', maxBytes: opts.maxSourceBytes ?? Infinity });
404
+
405
+ return report('log', { rules: LOG_RULES, findings, stats, run });
406
+ }
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Constructs that defeat the analysis rather than being defects in themselves.
3
+ //
4
+ // Every other rule set answers "what is wrong here". This one answers "how much should you trust
5
+ // the answer". A program that takes the address of a record, alters a GO TO at run time, calls
6
+ // through a procedure pointer or is entered somewhere other than its beginning is a program whose
7
+ // data flow the engine has followed less of than it appears to have. Reporting a clean flow result
8
+ // over it without saying so would be the same failure as reporting a clean scan over files nobody
9
+ // read, which this project refuses everywhere else.
10
+ //
11
+ // None of these is a vulnerability. They are severity `info` and they exist so that a reviewer
12
+ // knows which programs deserve a person, and so that a precision number measured over a corpus can
13
+ // say how much of that corpus was analysable at all.
14
+ import { inScope, isProgram, relPath } from '../sources.mjs';
15
+ import { report } from '../kernel/ruleset.mjs';
16
+ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
17
+ import { detectFormat, normalize } from '../parser.mjs';
18
+ import { eachWithinMemory } from '../kernel/memory.mjs';
19
+
20
+
21
+ export const OPAQUE_RULES = {
22
+ 'opaque-pointer-addressing': { sev: 'info', evidence: 'coverage', cwe: 'CWE-119', text: 'A program addresses storage through a pointer, so data flow through it is not followed' },
23
+ 'opaque-altered-control-flow': { sev: 'info', evidence: 'coverage', cwe: 'CWE-691', text: 'A program changes where a GO TO goes at run time, so its control flow is not followed' },
24
+ 'opaque-procedure-pointer': { sev: 'info', evidence: 'coverage', cwe: 'CWE-470', text: 'A program calls through a procedure pointer, so the callee is not known statically' },
25
+ 'opaque-alternate-entry': { sev: 'info', evidence: 'coverage', cwe: 'CWE-1419', text: 'A program declares an alternate entry point, which the flow rules do not start from' },
26
+ };
27
+
28
+ // Matched against normalised source: comments are gone, the sequence and identification areas are
29
+ // gone, and continuations are joined. Matching raw text would fire on a commented-out ALTER and on
30
+ // the word ENTRY inside a literal.
31
+ const PATTERNS = [
32
+ // SET ADDRESS OF makes a linkage item point at arbitrary storage. Whatever that storage holds
33
+ // did not arrive through any statement the engine can see.
34
+ { rule: 'opaque-pointer-addressing', re: /\bSET\s+ADDRESS\s+OF\s+([A-Z0-9-]+)/gi,
35
+ detail: (m) => `SET ADDRESS OF ${m[1]} repoints storage, so what ${m[1]} holds afterwards did not arrive through any statement this engine reads` },
36
+ { rule: 'opaque-pointer-addressing', re: /\bSET\s+([A-Z0-9-]+)\s+TO\s+ADDRESS\s+OF\b/gi,
37
+ detail: (m) => `${m[1]} is set to the address of another item, and an address is followed by nothing here` },
38
+ // ALTER rewrites the target of a GO TO while the program runs. It is obsolete in every standard
39
+ // since 1985 and still present in code that predates them.
40
+ { rule: 'opaque-altered-control-flow', re: /\bALTER\s+([A-Z0-9-]+)\s+TO\b/gi,
41
+ detail: (m) => `ALTER ${m[1]} changes where that paragraph's GO TO leads while the program runs` },
42
+ { rule: 'opaque-altered-control-flow', re: /\bGO\s+TO\b[^.]{0,200}?\bDEPENDING\s+ON\s+([A-Z0-9-]+)/gi,
43
+ detail: (m) => `GO TO ... DEPENDING ON ${m[1]} branches to one of several paragraphs chosen at run time` },
44
+ { rule: 'opaque-procedure-pointer', re: /\bUSAGE\s+(?:IS\s+)?PROCEDURE-POINTER\b/gi,
45
+ detail: () => 'a procedure pointer holds the address of code, and which code is not decided until it runs' },
46
+ { rule: 'opaque-procedure-pointer', re: /\bCALL\s+([A-Z0-9-]+)\s+(?:USING|RETURNING|END-CALL|\.)/gi,
47
+ // Only counts when the operand was declared a procedure pointer, which the caller checks.
48
+ needsPointerOperand: true,
49
+ detail: (m) => `CALL ${m[1]} dispatches through a pointer, so the callee is not known from the source` },
50
+ { rule: 'opaque-alternate-entry', re: /^\s*ENTRY\s+['"]([^'"]+)['"]/gim,
51
+ // An entry name is a program name; anything else in the literal is not repeated.
52
+ detail: (m) => `ENTRY ${/^[A-Z0-9$#@-]{1,30}$/i.test(m[1]) ? `'${m[1]}'` : 'with a literal that is not a program name'} is a second way into this program, and the flow rules start only at its PROCEDURE DIVISION` },
53
+ ];
54
+
55
+ export function scanOpaque(root, opts = {}) {
56
+ const tree = treeFor(root, opts);
57
+ const files = tree.list().filter(isProgram).filter(inScope(opts));
58
+ const findings = [];
59
+ const stats = { filesScanned: 0, filesUnreadable: 0, programsOpaque: 0 };
60
+
61
+ const run = eachWithinMemory(files, (f) => {
62
+ let src;
63
+ try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
64
+ // Counted before the cheap filter below, because filesScanned is what a report uses to say how
65
+ // much it read, and a file skipped for holding none of these words was still opened and read.
66
+ stats.filesScanned++;
67
+ if (!/\b(?:SET\s+ADDRESS|ADDRESS\s+OF|ALTER|DEPENDING|PROCEDURE-POINTER|ENTRY)\b/i.test(src)) return src.length;
68
+
69
+ let norm;
70
+ try { norm = normalize(src, detectFormat(src), new Map()); } catch (e) { noteUnparsed(stats, tree, f, e); return src.length; }
71
+ // normalize returns per-line entries with the comment and sequence areas already removed.
72
+ const lines = norm.entries.map((e) => e.text || '');
73
+ const text = lines.join('\n');
74
+ const path = relPath(root, f);
75
+
76
+ // Which names were declared as pointers, so a CALL through one can be told from an ordinary
77
+ // CALL of a variable - which the flow rules already report as a dynamic program load.
78
+ const pointers = new Set();
79
+ for (const m of text.matchAll(/\b(?:\d\d\s+)?([A-Z0-9-]+)\b[^.\n]{0,80}\bUSAGE\s+(?:IS\s+)?(?:PROCEDURE-POINTER|POINTER)\b/gi)) {
80
+ pointers.add(m[1].toUpperCase());
81
+ }
82
+
83
+ let hit = false;
84
+ for (const p of PATTERNS) {
85
+ p.re.lastIndex = 0;
86
+ let m;
87
+ while ((m = p.re.exec(text)) !== null) {
88
+ if (p.needsPointerOperand && !pointers.has(String(m[1]).toUpperCase())) continue;
89
+ // The line number is the normalised entry's own, so a construct that came from a copybook
90
+ // is reported where the compiler would see it.
91
+ const before = text.slice(0, m.index).split('\n').length - 1;
92
+ const line = norm.entries[before] ? norm.entries[before].line : 1;
93
+ findings.push({ rule: p.rule, path, line, detail: p.detail(m) });
94
+ hit = true;
95
+ }
96
+ }
97
+ if (hit) stats.programsOpaque++;
98
+ return src.length;
99
+ }, { label: 'opaque', maxBytes: opts.maxSourceBytes ?? Infinity });
100
+
101
+ return report('opaque', { rules: OPAQUE_RULES, findings, stats, run });
102
+ }