@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.
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 +323 -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,512 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Whether the options a program is compiled with generate the run-time checks a policy requires:
3
+ // docs/spec/build-gate.md §7. Enterprise COBOL's options and what they mean are from IBM's
4
+ // documentation; the GnuCOBOL flags are recorded in provenance/compiler-options.json with how each
5
+ // was confirmed by compiling. Nothing here is taken from GnuCOBOL's source (spec §16).
6
+ import { basename, posix } from 'node:path';
7
+ import { PROGRAM_EXT } from './sources.mjs';
8
+
9
+ // Split on blanks and commas outside parentheses: SSRANGE(NOZLEN,ABD) is one option.
10
+ export function optionTokens(text) {
11
+ const out = [];
12
+ let depth = 0;
13
+ let cur = '';
14
+ for (const ch of String(text)) {
15
+ if (ch === '(') depth++;
16
+ if (ch === ')') depth = Math.max(0, depth - 1);
17
+ if (depth === 0 && /[\s,]/.test(ch)) { if (cur) out.push(cur); cur = ''; continue; }
18
+ cur += ch;
19
+ }
20
+ if (cur) out.push(cur);
21
+ return out.map((t) => t.toUpperCase());
22
+ }
23
+
24
+ // The option cards before a program's first line of code, each with the line it is on.
25
+ export function optionCards(text) {
26
+ const cards = [];
27
+ const lines = String(text).split(/\r?\n/);
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const l = lines[i];
30
+ const trimmed = l.trim();
31
+ if (!trimmed) continue;
32
+ const body = /^(CBL|PROCESS)\b/i.test(trimmed) ? trimmed : l.slice(7).trim();
33
+ const m = /^(CBL|PROCESS)\b(.*)$/i.exec(body);
34
+ if (m) { cards.push({ level: m[1].toUpperCase(), line: i + 1, options: optionTokens(m[2]) }); continue; }
35
+ if (trimmed.startsWith('*') || l[6] === '*' || l[6] === '/') continue;
36
+ break;
37
+ }
38
+ return cards;
39
+ }
40
+
41
+ const parse = (token) => {
42
+ const m = /^([A-Z0-9-]+)(?:\((.*)\))?$/.exec(token);
43
+ return m ? { name: m[1], subs: m[2] === undefined ? [] : optionTokens(m[2]) } : { name: token, subs: [] };
44
+ };
45
+
46
+ // Each family's spellings. An abbreviation IBM does not document is not accepted, so an option the
47
+ // gate cannot read counts as absent, which fails closed.
48
+ const FAMILIES = {
49
+ SSRANGE: { on: ['SSRANGE', 'SSR'], off: ['NOSSRANGE', 'NOSSR'] },
50
+ NUMCHECK: { on: ['NUMCHECK'], off: ['NONUMCHECK'] },
51
+ PARMCHECK: { on: ['PARMCHECK', 'PC'], off: ['NOPARMCHECK', 'NOPC'] },
52
+ };
53
+ const FAMILY_OF_CHECK = { subscript: 'SSRANGE', 'reference-modification': 'SSRANGE', 'numeric-data': 'NUMCHECK', 'argument-length': 'PARMCHECK' };
54
+
55
+ // Whether one family's setting abends on a failed check. A bare SSRANGE is SSRANGE(NOZLEN,ABD); a
56
+ // bare NUMCHECK or PARMCHECK is MSG, which reports and carries on.
57
+ function abends(family, subs) {
58
+ if (subs.includes('MSG')) return false;
59
+ if (family === 'SSRANGE') return true;
60
+ if (!subs.includes('ABD')) return false;
61
+ if (family === 'NUMCHECK') return !subs.some((s) => s === 'NOZON' || s === 'NOPAC');
62
+ return true;
63
+ }
64
+
65
+ // Whether the last SSRANGE setting in `tokens` abends on a bad index: true, false, or null where
66
+ // none is set. Tokens split at the parenthesis's commas, as the parser's option list is, are rejoined.
67
+ export function ssrangeAbends(tokens) {
68
+ const whole = optionTokens(tokens.join(','));
69
+ const f = FAMILIES.SSRANGE;
70
+ for (let k = whole.length - 1; k >= 0; k--) {
71
+ const { name, subs } = parse(whole[k]);
72
+ if (f.off.includes(name)) return false;
73
+ if (f.on.includes(name)) return abends('SSRANGE', subs);
74
+ }
75
+ return null;
76
+ }
77
+
78
+ const levelText = (o) => (o.level === 'site' ? 'compilerOptions in cobolwork.site.json'
79
+ : o.level === 'JCL' ? `the compile step at ${o.file}:${o.line}` : `the ${o.level} statement at line ${o.line}`);
80
+
81
+ // One Enterprise COBOL program: `site` is the estate's declared defaults, `cards` its own CBL and
82
+ // PROCESS statements. Returns { checks: [{ check, ok, why }], forbidden: [why] }; ok is null where
83
+ // no level names the option and the estate has not declared its defaults.
84
+ export function enterpriseChecks({ required, site = [], steps = [], cards = [], forbid = [] }) {
85
+ const settings = [
86
+ ...site.flatMap((s) => optionTokens(s).map((token) => ({ token, level: 'site' }))),
87
+ ...steps.flatMap((s) => s.options.map((token) => ({ token, level: 'JCL', file: s.file, line: s.line }))),
88
+ ...cards.flatMap((c) => c.options.map((token) => ({ token, level: c.level, line: c.line }))),
89
+ ];
90
+ // What no level sets is the installation default, which an installation can change from IBM's.
91
+ // Only the estate's declared defaults say what it is; a compile step that is silent does not.
92
+ const declared = site.length > 0;
93
+ const lastOf = (names) => settings.filter((s) => names.includes(parse(s.token).name)).pop() || null;
94
+ const checks = required.map((check) => {
95
+ const family = FAMILY_OF_CHECK[check];
96
+ const f = FAMILIES[family];
97
+ const last = lastOf([...f.on, ...f.off]);
98
+ if (!last) {
99
+ return declared
100
+ ? { check, ok: false, why: `${check} needs ${family}, which no level sets, and Enterprise COBOL's default is NO${family}` }
101
+ : { check, ok: null, why: `${check} needs ${family}, and no level this gate can read sets it: declare the estate's defaults as compilerOptions in cobolwork.site.json` };
102
+ }
103
+ const { name, subs } = parse(last.token);
104
+ if (f.off.includes(name)) return { check, ok: false, why: `${check} needs ${family}, and ${last.token} is set by ${levelText(last)}` };
105
+ if (!abends(family, subs)) return { check, ok: false, why: `${check} needs ${family} to abend, and ${last.token} set by ${levelText(last)} reports and carries on (MSG)` };
106
+ return { check, ok: true, why: `${last.token} by ${levelText(last)}` };
107
+ });
108
+ const forbidden = [];
109
+ for (const entry of forbid.map((x) => x.toUpperCase())) {
110
+ const want = parse(entry);
111
+ const last = settings.filter((s) => parse(s.token).name === want.name).pop();
112
+ if (last && last.token === entry) forbidden.push(`${entry} is set by ${levelText(last)}, and the policy forbids it`);
113
+ }
114
+ return { checks, forbidden };
115
+ }
116
+
117
+ const EXCEPTION_OF_CHECK = {
118
+ subscript: 'EC-BOUND-SUBSCRIPT',
119
+ 'reference-modification': 'EC-BOUND-REF-MOD',
120
+ 'numeric-data': 'EC-DATA-INCOMPATIBLE',
121
+ 'argument-length': 'EC-PROGRAM-ARG-MISMATCH',
122
+ };
123
+
124
+ export const isCobc = (command) => /^cobc(\.exe)?$/i.test(basename(String(command || '')));
125
+
126
+ const exceptionName = (v) => {
127
+ const u = String(v).toUpperCase();
128
+ return u.startsWith('EC-') ? u : `EC-${u}`;
129
+ };
130
+ // A condition name covers itself and every name below it: EC-BOUND covers EC-BOUND-SUBSCRIPT.
131
+ const covers = (name, target) => name === 'EC-ALL' || target === name || target.startsWith(`${name}-`);
132
+
133
+ // IBM's compile procedures name their compile step COBOL; a site's own procedure is found by the
134
+ // step in it that runs IGYCRCTL.
135
+ const IBM_COMPILE_PROC = /^IGYW[CP]/;
136
+ const memberOf = (dsn) => { const m = /\(([A-Z0-9@#$]{1,8})\)\s*$/i.exec(String(dsn || '')); return m ? m[1].toUpperCase() : null; };
137
+
138
+ // A PARM as JCL writes it: quoted, or a parenthesised list, or both.
139
+ const parmText = (v) => String(v || '').trim().replace(/^\((.*)\)$/s, '$1').replace(/^'(.*)'$/s, '$1');
140
+ // A procedure's symbols filled from its caller's keywords, as JCL substitutes them.
141
+ const withSymbols = (text, keywords) => String(text || '').replace(/&([A-Z@#$][A-Z0-9@#$]{0,7})\.?/gi, (m, name) => {
142
+ const v = keywords && keywords.get(name.toUpperCase());
143
+ return v === undefined || v === null ? m : String(v).replace(/^'(.*)'$/, '$1');
144
+ });
145
+
146
+ // The options each JCL compile step gives the member it compiles: PARM on EXEC PGM=IGYCRCTL, or on
147
+ // the EXEC of a compile procedure, PARM.<step> or an unqualified PARM, which JCL gives the first
148
+ // step. The procedure's own PARM applies where the caller gives none. The member is named on the
149
+ // compile step's SYSIN: <step>.SYSIN, or an unqualified SYSIN, which JCL adds to the first step, or
150
+ // the SYSIN a procedure in the tree declares, with the caller's symbols filled in. `parsed` is
151
+ // parseJcl's output for every file; a member whose name is still symbolic is not attributed.
152
+ export function compileStepOptions(parsed) {
153
+ const procs = new Map();
154
+ for (const p of parsed) for (const proc of p.procs || []) {
155
+ const at = proc.steps.findIndex((s) => String(s.pgm || '').toUpperCase() === 'IGYCRCTL');
156
+ if (at < 0) continue;
157
+ const compile = proc.steps[at];
158
+ const sysin = compile.dds.find((d) => String(d.name || '').toUpperCase() === 'SYSIN');
159
+ procs.set(String(proc.name).toUpperCase(), { step: compile.name, first: at === 0, parm: compile.parm, sysin: sysin ? sysin.rawDsn || sysin.dsn : null });
160
+ }
161
+ const out = [];
162
+ for (const p of parsed) for (const step of p.steps || []) {
163
+ const pgm = String(step.pgm || '').toUpperCase();
164
+ const proc = String(step.proc || '').toUpperCase();
165
+ if (pgm === 'IGYCRCTL' && !step.inProc) {
166
+ const sysin = step.dds.find((d) => String(d.name || '').toUpperCase() === 'SYSIN');
167
+ const member = sysin && memberOf(sysin.dsn);
168
+ if (member) out.push({ member, options: optionTokens(parmText(step.parm)), file: p.file, line: step.line });
169
+ continue;
170
+ }
171
+ const own = procs.get(proc);
172
+ if (!own && !IBM_COMPILE_PROC.test(proc)) continue;
173
+ const compileStep = own ? String(own.step).toUpperCase() : 'COBOL';
174
+ const firstIsCompile = own ? own.first : true;
175
+ const dd = (name) => step.dds.find((d) => String(d.name || '').toUpperCase() === name);
176
+ const sysin = dd(`${compileStep}.SYSIN`) || (firstIsCompile ? dd('SYSIN') : null);
177
+ const kw = step.keywords || new Map();
178
+ const member = sysin ? memberOf(sysin.dsn) : own && own.sysin ? memberOf(withSymbols(own.sysin, kw)) : null;
179
+ if (!member) continue;
180
+ const given = kw.get(`PARM.${compileStep}`) ?? (firstIsCompile ? kw.get('PARM') : undefined) ?? null;
181
+ const parm = given !== null ? parmText(given) : parmText(own && own.parm);
182
+ out.push({ member, options: optionTokens(parm), file: p.file, line: step.line });
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // Shell text read at its top level: outside quotes, $( ), ${ } and backquotes, and past an escaped
188
+ // character. Returns the words, quotes removed and substitutions kept as written, and the separators
189
+ // between simple commands. A batch or PowerShell file takes the backslash as a path separator.
190
+ const ESCAPE = { sh: '\\', batch: '^', powershell: '`' };
191
+ export function shellTokens(text, dialect = 'sh') {
192
+ const esc = ESCAPE[dialect] || ESCAPE.sh;
193
+ const quotes = dialect === 'batch' ? '"' : '"\'';
194
+ const s = String(text);
195
+ const out = [];
196
+ const stack = [];
197
+ let word = null;
198
+ const add = (c) => { word = (word ?? '') + c; };
199
+ const flush = () => { if (word !== null) out.push({ word }); word = null; };
200
+ const inSubstitution = () => stack.some((c) => c === ')' || c === '}');
201
+ for (let i = 0; i < s.length; i++) {
202
+ const ch = s[i];
203
+ const open = stack[stack.length - 1];
204
+ if (open === "'" || open === '`') {
205
+ if (ch === open) { stack.pop(); add(stack.length ? ch : ''); } else add(ch);
206
+ continue;
207
+ }
208
+ if (ch === esc && i + 1 < s.length) { add(inSubstitution() ? ch + s[i + 1] : s[i + 1]); i++; continue; }
209
+ if (ch === '$' && (s[i + 1] === '(' || s[i + 1] === '{')) { stack.push(s[i + 1] === '(' ? ')' : '}'); add(ch + s[i + 1]); i++; continue; }
210
+ if (open === '"') {
211
+ if (ch === '"') { stack.pop(); add(stack.length ? ch : ''); } else add(ch);
212
+ continue;
213
+ }
214
+ if (quotes.includes(ch) || (ch === '`' && dialect === 'sh')) { add(stack.length ? ch : ''); stack.push(ch); continue; }
215
+ if (open) {
216
+ if (open === ')' && ch === '(') stack.push(')');
217
+ else if (ch === open) stack.pop();
218
+ add(ch);
219
+ continue;
220
+ }
221
+ if (/\s/.test(ch)) { flush(); continue; }
222
+ if (ch === '#' && word === null && dialect !== 'batch') break;
223
+ const two = s.slice(i, i + 2);
224
+ if (two === '&&' || two === '||') { flush(); out.push({ sep: two }); i++; continue; }
225
+ if (ch === ';' || ch === '|' || (ch === '&' && !/[<>]/.test(s[i - 1] || '') && s[i + 1] !== '>')) { flush(); out.push({ sep: ch }); continue; }
226
+ add(ch);
227
+ }
228
+ flush();
229
+ return out;
230
+ }
231
+
232
+ const commandsOf = (tokens) => {
233
+ const out = [[]];
234
+ for (const t of tokens) { if (t.sep) out.push([]); else out[out.length - 1].push(t.word); }
235
+ return out.filter((words) => words.length);
236
+ };
237
+
238
+ // Words that come before the command they run: a CI step's key, a list marker, a Dockerfile
239
+ // instruction, a wrapper, a shell keyword. A Makefile recipe line may open with @, - or +.
240
+ const LEAD = /^(?:-|[A-Za-z_][\w-]*:|run|cmd|entrypoint|sudo|exec|time|nohup|nice|env|then|do|else|if|elif|while|until|!|\{|\(|call|&|@)$/i;
241
+ const PREFIX = /^[@+\-(]+/;
242
+ // A command that prints its arguments: a message naming cobc runs nothing.
243
+ const MESSAGE = /^(?:echo|printf|print|say|write-(?:host|output|error|warning|verbose|information)|info|warn|warning|error|die|fail|log|rem|:)$/i;
244
+ const leadOf = (words) => words.findIndex((w) => !LEAD.test(w) && !/^[A-Za-z_]\w*=/.test(w));
245
+ const argsOf = (words) => {
246
+ const out = [];
247
+ for (const w of words) {
248
+ if (/^(?:\d*[<>]|&>)/.test(w) || /^(?:[;)}\]]|\]\]|fi|done|then)$/.test(w)) break;
249
+ out.push(w);
250
+ }
251
+ return out;
252
+ };
253
+
254
+ // The argument vector of each cobc command in one simple command. A command held in one word -
255
+ // sh -c "…", an alias, a CMD's string - is read as a command line of its own.
256
+ function cobcCommands(words, dialect, depth = 0) {
257
+ const at = leadOf(words);
258
+ if (at < 0 || MESSAGE.test(words[at].replace(PREFIX, ''))) return [];
259
+ for (let i = at; i < words.length; i++) {
260
+ const w = words[i];
261
+ if (isCobc(w.replace(PREFIX, ''))) return [argsOf(words.slice(i + 1))];
262
+ const inner = w.replace(/^[A-Za-z_]\w*=/, '');
263
+ if (depth < 2 && /\s/.test(inner) && /cobc/i.test(inner)) {
264
+ const found = commandsOf(shellTokens(inner, dialect)).flatMap((ws) => cobcCommands(ws, dialect, depth + 1));
265
+ if (found.length) return found;
266
+ }
267
+ }
268
+ return [];
269
+ }
270
+
271
+ // A directory a cd names, relative to where the script runs; null where it is not a plain path.
272
+ const changeDir = (dir, to) => (!to || /[$%~`]/.test(to) || /^(?:\/|[A-Za-z]:[\\/]|-$)/.test(to) ? null
273
+ : posix.normalize(posix.join(dir || '.', to.replace(/\\/g, '/'))));
274
+
275
+ // The cobc commands in a command line, each with the directory a cd before it on the line moved to.
276
+ // `state.dir` carries a cd forward to later lines, for a script whose lines one shell runs.
277
+ function commandLine(text, dialect, state, line, out) {
278
+ for (const words of commandsOf(Array.isArray(text) ? text.map((word) => ({ word })) : shellTokens(text, dialect))) {
279
+ const at = leadOf(words);
280
+ const lead = at >= 0 ? words[at].replace(PREFIX, '').toLowerCase() : '';
281
+ if (lead === 'cd' || lead === 'pushd' || lead === 'set-location') { state.dir = changeDir(state.dir, words.slice(at + 1).find((w) => !/^-./.test(w))); continue; }
282
+ for (const args of cobcCommands(words, dialect)) {
283
+ if (cobcOperands(args).length) out.push({ line, args, ...(state.dir && state.dir !== '.' ? { dir: state.dir } : {}) });
284
+ }
285
+ }
286
+ }
287
+
288
+ const unquote = (v) => String(v).trim().replace(/^(["'])(.*)\1$/s, '$2');
289
+
290
+ // A variable a build script assigns, as [name, operator, value]; null for a line that runs something.
291
+ function assignmentOf(line, kind) {
292
+ if (kind === 'make') {
293
+ const m = /^\s*(?:(?:export|override)\s+)*([A-Za-z_]\w*)\s*(:{1,3}=|[?+!]?=)(?!=)\s*(.*)$/.exec(line);
294
+ return m ? [m[1], m[2], m[3].trim()] : null;
295
+ }
296
+ if (kind === 'batch') {
297
+ const m = /^\s*@?set\s+(?:\/a\s+)?"?([A-Za-z_][\w.-]*)=(.*?)"?\s*$/i.exec(line);
298
+ return m && !/^\s*@?set\s+\/p/i.test(line) ? [m[1].toUpperCase(), '=', m[2]] : null;
299
+ }
300
+ if (kind === 'powershell') {
301
+ const m = /^\s*\$([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/.exec(line);
302
+ return m ? [m[1].toUpperCase(), '=', m[2].replace(/^@\(|\)$/g, '').split(/\s*,\s*/).map(unquote).join(' ')] : null;
303
+ }
304
+ if (kind === 'docker') {
305
+ const m = /^\s*(?:ENV|ARG)\s+([A-Za-z_]\w*)(?:\s*=\s*|\s+)(.*)$/i.exec(line);
306
+ if (m) return [m[1], '=', unquote(m[2])];
307
+ }
308
+ const m = /^\s*(?:(?:export|local|readonly|declare|typeset)(?:\s+-\w+)*\s+)?([A-Za-z_]\w*)(\+?=)(.*)$/.exec(line);
309
+ if (!m) return null;
310
+ const rest = shellTokens(m[3], 'sh');
311
+ // NAME=value cmd … runs cmd with NAME in its environment: the line is a command.
312
+ if (rest.length > 1 && !rest[1].sep) return null;
313
+ return [m[1], m[2], rest.length && !rest[0].sep ? rest[0].word : ''];
314
+ }
315
+
316
+ // The variables a line refers to, replaced by the values the file assigned them, until none is left
317
+ // that the file assigns. Make's $(wildcard …) becomes its pattern.
318
+ function expanded(text, vars, kind) {
319
+ const get = (n) => vars.get(kind === 'batch' || kind === 'powershell' ? n.toUpperCase() : n);
320
+ const once = (t) => {
321
+ if (kind === 'make') return t.replace(/\$\(wildcard\s+([^()]*)\)|\$\(([A-Za-z_]\w*)\)|\$\{([A-Za-z_]\w*)\}/g, (m, glob, a, b) => (glob !== undefined ? glob.trim() : get(a || b) ?? m));
322
+ if (kind === 'batch') return t.replace(/%([A-Za-z_][\w.-]*)%|!([A-Za-z_][\w.-]*)!/g, (m, a, b) => get(a || b) ?? m);
323
+ if (kind === 'powershell') return t.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)(?!:)/g, (m, a, b) => get(a || b) ?? m);
324
+ return t.replace(/\$\{([A-Za-z_]\w*)(?:(:?[-=])([^}]*))?\}|\$\(([A-Za-z_]\w*)\)|\$([A-Za-z_]\w*)/g, (m, a, op, fallback, b, c) => get(a || b || c) ?? (op ? fallback : m));
325
+ };
326
+ let t = text;
327
+ for (let k = 0; k < 8; k++) { const next = once(t); if (next === t) break; t = next; }
328
+ return t;
329
+ }
330
+
331
+ // Every cobc command a build script runs that names a source file, with the variables the same file
332
+ // assigns substituted: [{ line, args, dir? }]. `kind` is make, sh, yaml, docker, batch or powershell.
333
+ // A command whose options come from a variable set elsewhere keeps the reference, which reads as no
334
+ // option; one that names no source - cobc --version, which cobc - compiles nothing and is left out.
335
+ export function cobcInvocations(text, { kind = 'sh' } = {}) {
336
+ const dialect = kind === 'batch' || kind === 'powershell' ? kind : 'sh';
337
+ const vars = new Map();
338
+ const continuation = { batch: /\^$/, powershell: /`$/ }[kind] || /\\$/;
339
+ // A continued line is one command, and keeps the line it starts on.
340
+ const lines = [];
341
+ let held = null;
342
+ String(text).split(/\r?\n/).forEach((raw, i) => {
343
+ const continues = continuation.test(raw);
344
+ const part = continues ? raw.slice(0, -1) : raw;
345
+ held = held ? { line: held.line, text: `${held.text} ${part}` } : { line: i + 1, text: part };
346
+ if (!continues) { lines.push(held); held = null; }
347
+ });
348
+ if (held) lines.push(held);
349
+ const out = [];
350
+ // One shell runs a script's lines in turn, so a cd holds; each line of a recipe or a CI step's
351
+ // run list, and each Dockerfile instruction, starts where the file is.
352
+ const state = { dir: null };
353
+ const carries = kind === 'sh' || kind === 'batch' || kind === 'powershell';
354
+ let recipe = false;
355
+ for (const { line, text: raw } of lines) {
356
+ if (!carries) state.dir = null;
357
+ if (!raw.trim() || (kind === 'batch' && /^\s*@?(?:rem\b|::)/i.test(raw)) || (kind !== 'batch' && /^\s*#/.test(raw))) continue;
358
+ // In a Makefile a tab opens a recipe line when a rule is open; the rule stays open until a line
359
+ // that is neither a recipe line nor a conditional.
360
+ const recipeLine = kind === 'make' && recipe && /^\t/.test(raw);
361
+ const assigned = recipeLine ? null : assignmentOf(raw, kind);
362
+ if (assigned) {
363
+ const [name, op, value] = assigned;
364
+ if (op === '?=' && vars.has(name)) continue;
365
+ if (op === '!=') continue;
366
+ // A shell assigns the value as it stands; make's = and ?= defer it to where it is used.
367
+ const v = kind !== 'make' || /^:+=$/.test(op) ? expanded(value, vars, kind) : value;
368
+ vars.set(name, op.startsWith('+') && vars.has(name) ? `${vars.get(name)} ${v}` : v);
369
+ if (kind === 'make' && !/^\t/.test(raw)) recipe = false;
370
+ continue;
371
+ }
372
+ if (kind === 'make' && !/^\t/.test(raw) && !/^\s*(?:ifn?eq|ifn?def|else|endif)\b/.test(raw)) recipe = /^[^\s#:=][^:=]*::?(?!=)/.test(raw);
373
+ // A recipe line's @, - and + are make's; the shell reads what follows them.
374
+ let l = expanded(kind === 'make' ? raw.replace(/^\t[@+-]+/, '\t') : raw, vars, kind);
375
+ if (kind === 'make') l = l.replace(/\$\$/g, '$');
376
+ const exec = kind === 'docker' && /^\s*(?:RUN|CMD|ENTRYPOINT)\s+(\[.*\])\s*$/i.exec(l);
377
+ let words = null;
378
+ if (exec) { try { const list = JSON.parse(exec[1]); if (Array.isArray(list)) words = list.map(String); } catch { /* the shell form after all */ } }
379
+ commandLine(words || l, dialect, state, line, out);
380
+ }
381
+ return out;
382
+ }
383
+
384
+ // JSON with the comments and trailing commas VS Code accepts in its settings files.
385
+ function looseJson(text) {
386
+ const s = String(text);
387
+ let out = '';
388
+ for (let i = 0; i < s.length; i++) {
389
+ const ch = s[i];
390
+ if (ch === '"') {
391
+ let j = i + 1;
392
+ while (j < s.length && s[j] !== '"') j += s[j] === '\\' ? 2 : 1;
393
+ out += s.slice(i, j + 1);
394
+ i = j;
395
+ } else if (ch === '/' && s[i + 1] === '/') {
396
+ while (i < s.length && s[i] !== '\n') i++;
397
+ out += '\n';
398
+ } else if (ch === '/' && s[i + 1] === '*') {
399
+ const end = s.indexOf('*/', i + 2);
400
+ i = end < 0 ? s.length : end + 1;
401
+ } else out += ch;
402
+ }
403
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
404
+ }
405
+
406
+ // The cobc commands an editor's task file runs: VS Code's tasks.json. A task's command line is its
407
+ // command and its arguments, read as its shell reads them, with the variants it gives for Windows,
408
+ // Linux and macOS; it runs in the workspace folder, or in its options.cwd. ${workspaceFolder} is that
409
+ // folder, and a variable that names the open file stands for any file.
410
+ export function cobcTasks(text) {
411
+ let doc;
412
+ try { doc = looseJson(text); } catch { return []; }
413
+ const lines = String(text).split(/\r?\n/);
414
+ const lineOf = (...needles) => {
415
+ for (const n of needles) {
416
+ const at = n ? lines.findIndex((l) => l.includes(n)) : -1;
417
+ if (at >= 0) return at + 1;
418
+ }
419
+ return 1;
420
+ };
421
+ const valueOf = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v.value : v);
422
+ const folder = (v) => String(v).replace(/\$\{(?:workspaceFolder|workspaceRoot)(?::[^}]*)?\}/g, '.').replace(/\$\{(?:\/|pathSeparator)\}/g, '/');
423
+ const quoted = (a) => { const s = String(valueOf(a) ?? ''); return /[\s"]/.test(s) || s === '' ? `"${s.replace(/"/g, '\\"')}"` : s; };
424
+ const out = [];
425
+ const tasks = [doc, ...(Array.isArray(doc && doc.tasks) ? doc.tasks : [])];
426
+ for (const task of tasks) {
427
+ if (!task || typeof task !== 'object') continue;
428
+ for (const [variant, dialect] of [[task, 'sh'], [task.linux, 'sh'], [task.osx, 'sh'], [task.windows, 'powershell']]) {
429
+ if (!variant || typeof variant !== 'object') continue;
430
+ const command = valueOf(variant.command);
431
+ if (typeof command !== 'string' || !/cobc/i.test(`${command} ${JSON.stringify(variant.args || task.args || [])}`)) continue;
432
+ const args = Array.isArray(variant.args) ? variant.args : Array.isArray(task.args) ? task.args : [];
433
+ const cwd = (variant.options && variant.options.cwd) || (task.options && task.options.cwd);
434
+ const dir = typeof cwd === 'string' ? changeDir(null, folder(cwd)) : null;
435
+ const line = lineOf(JSON.stringify(command).slice(1, 41), task.label && JSON.stringify(task.label));
436
+ const state = { dir };
437
+ const found = [];
438
+ commandLine(folder([command, ...args.map(quoted)].join(' ')), dialect, state, line, found);
439
+ out.push(...found);
440
+ }
441
+ }
442
+ return out;
443
+ }
444
+
445
+ // Options whose value is the next argument, which is not a source file: the output file, include and
446
+ // library directories, a library, and options for the C compiler and the linker. Each was observed to
447
+ // take the next argument (provenance/compiler-options.json).
448
+ const TAKES_VALUE = new Set(['-o', '-I', '-L', '-l', '-A', '-Q', '-fec', '-fno-ec']);
449
+ const SOURCE_FILE = new RegExp(`(?:${PROGRAM_EXT.map((e) => e.replace('.', '\\.')).join('|')})$`, 'i');
450
+ // A variable, a batch argument, a glob or find's {}: a file the command names only when it runs.
451
+ const UNNAMED = /[$%*?!]|\{\}/;
452
+
453
+ // The operands of a cobc command that name source files, as written.
454
+ export function cobcOperands(args) {
455
+ const out = [];
456
+ for (let i = 0; i < args.length; i++) {
457
+ const a = String(args[i]);
458
+ if (TAKES_VALUE.has(a)) { i++; continue; }
459
+ if (a.startsWith('-')) continue;
460
+ if (SOURCE_FILE.test(a) || UNNAMED.test(a)) out.push(a);
461
+ }
462
+ return out;
463
+ }
464
+
465
+ // A cobc argument vector. Returns { checks, forbidden, add }: `add` holds the -fec options that
466
+ // supply a required check nothing turned on, and `forbidden` the options the policy's forbid list
467
+ // names. A check turned off is a failed check, not overridden: appending past it would hide a build
468
+ // configured against the policy.
469
+ export function cobcChecks(args, { required, forbid = [] }) {
470
+ const state = new Map();
471
+ const turnedOff = new Map();
472
+ const set = (name, on, arg) => {
473
+ for (const check of required) {
474
+ if (!covers(name, EXCEPTION_OF_CHECK[check])) continue;
475
+ state.set(check, on);
476
+ if (on) turnedOff.delete(check); else turnedOff.set(check, arg);
477
+ }
478
+ };
479
+ for (let i = 0; i < args.length; i++) {
480
+ const a = String(args[i]);
481
+ if (a === '-debug' || a === '-d') { set('EC-ALL', true, a); continue; }
482
+ const m = /^-f(no-)?ec(?:=(.*))?$/.exec(a);
483
+ if (!m) continue;
484
+ const value = m[2] !== undefined ? m[2] : args[++i];
485
+ const shown = m[2] !== undefined ? a : `${a} ${value ?? ''}`.trim();
486
+ if (value === undefined) continue;
487
+ set(exceptionName(value), !m[1], shown);
488
+ }
489
+ const forbidden = [];
490
+ for (const entry of forbid) {
491
+ const hit = args.find((a) => a === entry || String(a).startsWith(`${entry}=`));
492
+ if (hit) forbidden.push(`${hit} is on the command line, and the policy forbids it`);
493
+ }
494
+ const add = [];
495
+ const checks = required.map((check) => {
496
+ if (state.get(check) === true) return { check, ok: true, why: 'turned on by the command line' };
497
+ if (turnedOff.has(check)) return { check, ok: false, why: `${turnedOff.get(check)} turns off the ${check} check` };
498
+ const flag = `-fec=${EXCEPTION_OF_CHECK[check]}`;
499
+ if (!add.includes(flag)) add.push(flag);
500
+ return { check, ok: true, added: true, why: `added as ${flag}` };
501
+ });
502
+ return { checks, forbidden, add };
503
+ }
504
+
505
+ // The same, for a command the gate reads rather than runs: a check nothing turned on is missing.
506
+ export function scriptedCobcChecks(args, opts) {
507
+ const r = cobcChecks(args, opts);
508
+ return {
509
+ checks: r.checks.map((c) => (c.added ? { check: c.check, ok: false, why: `${c.check} needs -fec=${EXCEPTION_OF_CHECK[c.check]} or -debug, and the command gives neither` } : c)),
510
+ forbidden: r.forbidden,
511
+ };
512
+ }
package/lib/packs.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Vendor rule packs: the verbs a particular product brings to an estate, loaded only by the shops
3
+ // that run it.
4
+ //
5
+ // Per-product loading is the feature. The commonest complaint about enterprise static analysis is
6
+ // noise from rules for things the shop does not have, so a pack that nobody names does not load,
7
+ // and a scan without it is identical to a scan from before it existed.
8
+ //
9
+ // The second control is about honesty rather than noise, and it has two halves that are commonly
10
+ // confused:
11
+ //
12
+ // corpus measurement answers "is this quiet?" - run the pack over hundreds of real
13
+ // repositories and count how often each rule fires. An over-broad
14
+ // pattern shows up immediately as a rule that fires everywhere.
15
+ //
16
+ // practitioner review answers "is this true?" - someone who has administered the product
17
+ // confirms the verb does what the rationale says it does.
18
+ //
19
+ // A corpus cannot establish the second. If a rule asserts that a GSO record is an installation-wide
20
+ // option and that is wrong, a thousand repositories will report it quietly and wrongly all day. A
21
+ // practitioner cannot cheaply establish the first. So a pack records both, loads on either, and
22
+ // the summary always names which one it is missing.
23
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
24
+ import { join, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { printable } from './kernel/printable.mjs';
27
+
28
+ const PACK_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'rules', 'packs');
29
+
30
+ // Where packs are read from. Overridable, and the reason is worth stating: the tests for the
31
+ // validation gate used the shipped packs as their fixture, so the day a pack was measured and
32
+ // became validated, three tests of the refusal mechanism failed - not because the mechanism broke
33
+ // but because its fixture had graduated. A gate has to be testable independently of what happens
34
+ // to be standing in front of it.
35
+ export function availablePacks(packDir = PACK_DIR) {
36
+ if (!existsSync(packDir)) return [];
37
+ return readdirSync(packDir).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, '')).sort();
38
+ }
39
+
40
+ // The site file naming a pack is the tree's to write, so a name must be a file in the pack directory.
41
+ export function readPack(name, packDir = PACK_DIR) {
42
+ const path = join(packDir, name + '.json');
43
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(name) || !existsSync(path)) return { name, problems: [`no pack named '${printable(name, 60)}'; available: ${availablePacks(packDir).join(', ') || 'none'}`] };
44
+ try {
45
+ const pack = JSON.parse(readFileSync(path, 'utf8'));
46
+ if (!pack || typeof pack !== 'object' || Array.isArray(pack)) return { name, problems: [`pack '${name}' is not a JSON object`] };
47
+ return { ...pack, name, problems: [] };
48
+ } catch (e) {
49
+ return { name, problems: [`pack '${name}' is not readable as JSON: ${printable(e.message, 120)}`] };
50
+ }
51
+ }
52
+
53
+ // What a pack has been through, in the terms that matter. Neither form of validation implies the
54
+ // other, so both are reported and the missing one is always named.
55
+ export function validationOf(pack) {
56
+ const v = pack.validation || {};
57
+ const corpus = v.corpus && v.corpus.repositories > 0 ? v.corpus : null;
58
+ const practitioner = v.practitioner && v.practitioner.by ? v.practitioner : null;
59
+ const missing = [];
60
+ if (!corpus) missing.push('no corpus measurement: how often these rules fire on real code is unknown, so their noise is unknown');
61
+ if (!practitioner) missing.push(`no practitioner review: nobody who has administered ${pack.product || pack.name} has confirmed the risk statements, so their correctness is unknown`);
62
+ return { corpus, practitioner, missing, any: !!(corpus || practitioner) };
63
+ }
64
+
65
+ // What loaded, what did not, and why. A caller that asked for a pack and silently got nothing
66
+ // would scan less than it thinks it scanned, which is the failure this whole project is about.
67
+ export function loadPacks(names = [], { allowUnvalidated = false, packDir = PACK_DIR } = {}) {
68
+ const loaded = [];
69
+ const refused = [];
70
+ const problems = [];
71
+ const caveats = [];
72
+
73
+ for (const name of names) {
74
+ const pack = readPack(name, packDir);
75
+ if (pack.problems.length) { problems.push(...pack.problems); continue; }
76
+ const bad = packProblems(pack);
77
+ if (bad.length) { problems.push(...bad); continue; }
78
+ const v = validationOf(pack);
79
+ if (!v.any && !allowUnvalidated) {
80
+ refused.push({
81
+ name,
82
+ why: `pack '${name}' has had neither a corpus measurement nor a practitioner review, so nothing is known about either its noise or its correctness. `
83
+ + 'Set allowUnvalidatedPacks in the site configuration to load it anyway.',
84
+ });
85
+ continue;
86
+ }
87
+ // Loading on partial validation is allowed, and never silent.
88
+ for (const m of v.missing) caveats.push(`${name}: ${m}`);
89
+ loaded.push({ ...pack, validation: v });
90
+ }
91
+ return { loaded, refused, problems, caveats };
92
+ }
93
+
94
+ // Where a pack rule may match. A utility that reaches the product is named on the EXEC statement;
95
+ // what it is told to do is in the stream below it. Both are needed, and a rule scoped to only one
96
+ // of them cannot match the other.
97
+ export const SCOPES = ['jcl-step', 'jcl-instream'];
98
+
99
+ // A rule scoped to something no scanner reads can never fire, and a rule that never fires looks
100
+ // exactly like a rule that is admirably quiet. One shipped that way already - a pattern matching
101
+ // the batch interface program, scoped to in-stream data where a program name never appears - so
102
+ // the scope is checked rather than trusted.
103
+ export function packProblems(pack) {
104
+ const problems = [];
105
+ if (pack.rules !== undefined && !(Array.isArray(pack.rules) && pack.rules.every((r) => r && typeof r === 'object' && typeof r.pattern === 'string'
106
+ && (r.appliesTo === undefined || (Array.isArray(r.appliesTo) && r.appliesTo.every((x) => typeof x === 'string')))))) {
107
+ return [`${pack.name}: rules must be an array of objects, each with a pattern`];
108
+ }
109
+ if (pack.programs !== undefined && !(Array.isArray(pack.programs) && pack.programs.every((x) => typeof x === 'string'))) {
110
+ return [`${pack.name}: programs must be an array of names`];
111
+ }
112
+ for (const r of pack.rules || []) {
113
+ const scopes = r.appliesTo || ['jcl-instream'];
114
+ for (const sc of scopes) if (!SCOPES.includes(sc)) problems.push(`${pack.name}:${r.id}: unknown scope '${sc}'`);
115
+ if (!scopes.length) problems.push(`${pack.name}:${r.id}: no scope, so it can never match anything`);
116
+ try { new RegExp(r.pattern, r.flags || 'i'); } catch (e) { problems.push(`${pack.name}:${r.id}: ${e.message}`); }
117
+ if (r.setsContext && !r.setsContextPattern) problems.push(`${pack.name}:${r.id}: setsContext without a pattern that sets it`);
118
+ if (r.setsContextPattern) { try { new RegExp(r.setsContextPattern, 'i'); } catch (e) { problems.push(`${pack.name}:${r.id}: setsContextPattern ${e.message}`); } }
119
+ // A rule that waits for a context nothing in the pack sets can never fire, which is the same
120
+ // failure as an unknown scope and is caught the same way.
121
+ if (r.requiresContext && !(pack.rules || []).some(x => x.setsContext === r.requiresContext)) {
122
+ problems.push(`${pack.name}:${r.id}: requires context '${r.requiresContext}' that no rule in this pack sets`);
123
+ }
124
+ }
125
+ return problems;
126
+ }
127
+
128
+ // The programs a loaded pack's product supplies. A step running one of these is running the
129
+ // vendor's own utility, not a program the repository forgot to include, so the unresolved-program
130
+ // rule should be quiet about it - but only for an estate that actually loaded the pack.
131
+ export function programsOf(packs) {
132
+ return new Set(packs.flatMap((p) => (p.programs || []).map((x) => x.toUpperCase())));
133
+ }
134
+
135
+ // A pack's rules compiled once.
136
+ export function compilePack(pack) {
137
+ return (pack.rules || []).map((r) => ({
138
+ ...r,
139
+ pack: pack.name,
140
+ vendor: pack.vendor,
141
+ product: pack.product,
142
+ re: new RegExp(r.pattern, r.flags || 'i'),
143
+ appliesTo: r.appliesTo || ['jcl-instream'],
144
+ // A rule may declare that it only means what it says once an earlier line has put the stream
145
+ // into a mode, and a rule may be the thing that sets that mode.
146
+ setsContextRe: r.setsContext && r.setsContextPattern ? new RegExp(r.setsContextPattern, 'i') : null,
147
+ }));
148
+ }