@portll/cobolwork 0.2.117 → 0.2.150

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 (51) hide show
  1. package/README.md +13 -1
  2. package/THIRD-PARTY-NOTICES.md +3 -2
  3. package/bin/cobolwork.mjs +16 -1
  4. package/lib/baseline.mjs +8 -6
  5. package/lib/bms.mjs +1 -1
  6. package/lib/capabilities.mjs +2 -1
  7. package/lib/cards.mjs +66 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/compliance.mjs +6 -5
  10. package/lib/control.mjs +295 -6
  11. package/lib/dataflow.mjs +72 -88
  12. package/lib/diff.mjs +65 -89
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/equivalence.mjs +30 -12
  15. package/lib/evidence/journal.mjs +11 -2
  16. package/lib/evidence/record.mjs +3 -1
  17. package/lib/evidence/slsa.mjs +17 -2
  18. package/lib/execution.mjs +77 -0
  19. package/lib/explain.mjs +5 -4
  20. package/lib/ftp.mjs +136 -0
  21. package/lib/inventory.mjs +8 -9
  22. package/lib/ironwork.mjs +4 -6
  23. package/lib/jcl.mjs +29 -54
  24. package/lib/kernel/git.mjs +60 -0
  25. package/lib/kernel/identity.mjs +7 -6
  26. package/lib/kernel/source-tree.mjs +204 -34
  27. package/lib/layout.mjs +202 -0
  28. package/lib/parser.mjs +58 -207
  29. package/lib/precompile.mjs +5 -88
  30. package/lib/revision.json +1 -1
  31. package/lib/sbom.mjs +125 -13
  32. package/lib/scan.mjs +11 -4
  33. package/lib/sets/build.mjs +1 -1
  34. package/lib/sets/cics.mjs +32 -5
  35. package/lib/sets/copybook.mjs +2 -1
  36. package/lib/sets/hidden.mjs +4 -8
  37. package/lib/sets/jcl.mjs +17 -120
  38. package/lib/sets/priv.mjs +1 -1
  39. package/lib/sets/recon.mjs +3 -2
  40. package/lib/sets/semantics.mjs +1 -1
  41. package/lib/sets/vendor.mjs +1 -1
  42. package/lib/sets/zowe.mjs +34 -8
  43. package/lib/site.mjs +17 -7
  44. package/lib/sources.mjs +82 -38
  45. package/lib/utilities.mjs +146 -4
  46. package/package.json +1 -1
  47. package/rules/compliance-cobit2019.json +2438 -0
  48. package/rules/compliance-dora.json +1 -1
  49. package/rules/compliance-ffiec.json +1 -1
  50. package/rules/compliance-nist80053.json +1 -1
  51. package/schema/cobolwork.policy.schema.json +94 -17
package/lib/sbom.mjs CHANGED
@@ -10,6 +10,7 @@ import { parseJcl } from './jcl.mjs';
10
10
  import { optionCards } from './options.mjs';
11
11
  import { inScope, isBms, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
12
12
  import { TOOL_VERSION } from './version.mjs';
13
+ import { revisionOf } from './revision.mjs';
13
14
  import { treeFor } from './kernel/source-tree.mjs';
14
15
 
15
16
  // The namespace of cobolwork's version 5 UUIDs (RFC 4122 §4.3), fixed so a serial number repeats.
@@ -26,6 +27,50 @@ function uuidv5(name) {
26
27
  const sha256 = (b) => createHash('sha256').update(b).digest('hex');
27
28
  const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
28
29
 
30
+ // A routine a platform provides, which its platform component already stands for.
31
+ const platformRoutine = (n) => n === 'CBLTDLI' || n === 'AIBTDLI' || /^MQ[A-Z0-9]{2,}$/.test(n) || /^CEE[A-Z0-9]+$/.test(n);
32
+
33
+ // IBM programs a job step runs rather than one the estate builds.
34
+ const SYSTEM_PROGRAMS = new Set(['ADRDSSU', 'AMASPZAP', 'ASMA90', 'BPXBATCH', 'DFHCSDUP', 'DFHEAP1$', 'DFHECP1$', 'DFSRRC00', 'DFSORT', 'DSNTEP2', 'DSNTEP4',
35
+ 'DSNHPC', 'DSNTIAD', 'DSNTIAUL', 'DSNUTILB', 'FTP', 'HEWL', 'IEWBLINK', 'IEWL', 'ICEGENER', 'ICEMAN', 'ICETOOL', 'IDCAMS', 'IEBCOMPR',
36
+ 'IEBCOPY', 'IEBDG', 'IEBGENER', 'IEBUPDTE', 'IEFBR14', 'IEHLIST', 'IEHPROGM', 'IGYCRCTL', 'IKJEFT01', 'IKJEFT1A', 'IKJEFT1B',
37
+ 'SDSF', 'SORT']);
38
+ const TSO_BATCH = new Set(['IKJEFT01', 'IKJEFT1A', 'IKJEFT1B']);
39
+ // The IMS region types whose PARM names the application program second: PARM='BMP,PGM,PSB'.
40
+ const IMS_REGIONS = new Set(['BMP', 'DLI', 'DBB', 'IFP', 'JBP', 'JMP']);
41
+
42
+ // What a step runs beyond its PGM: the program a TSO batch step's DSN RUN names, or an IMS region's
43
+ // application program.
44
+ function programsRunBy(step) {
45
+ const pgm = String(step.pgm || '').toUpperCase();
46
+ const out = [];
47
+ if (TSO_BATCH.has(pgm)) {
48
+ for (const dd of step.dds || []) {
49
+ if (dd.name !== 'SYSTSIN') continue;
50
+ const text = (dd.inStream || []).map((l) => l.text).join(' ');
51
+ for (const m of text.matchAll(/\bRUN\s+PROG(?:RAM)?\s*\(\s*([A-Z0-9#@$]{1,8})\s*\)/gi)) out.push(m[1].toUpperCase());
52
+ }
53
+ } else if (pgm === 'DFSRRC00' && step.parm) {
54
+ const [region, app] = String(step.parm).replace(/^['(]|[')]$/g, '').split(',').map((x) => x.trim().toUpperCase());
55
+ if (IMS_REGIONS.has(region) && /^[A-Z0-9#@$]{1,8}$/.test(app || '')) out.push(app);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // The literal operand of a CICS option, `PROGRAM('X')`; the field's name where a field is given.
61
+ function cicsOperand(toks, option) {
62
+ const at = toks.findIndex((t, i) => t.t === 'word' && t.u === option && toks[i + 1] && toks[i + 1].v === '(');
63
+ if (at < 0) return null;
64
+ const inner = [];
65
+ for (let i = at + 2, depth = 1; i < toks.length; i++) {
66
+ if (toks[i].v === '(') depth++;
67
+ else if (toks[i].v === ')' && --depth === 0) break;
68
+ inner.push(toks[i]);
69
+ }
70
+ if (inner.length === 1 && inner[0].t === 'lit') return { literal: String(inner[0].v).trim().toUpperCase() };
71
+ return inner.length ? { field: inner.map((t) => t.v).join('') } : null;
72
+ }
73
+
29
74
  function platformsOf(src, calls) {
30
75
  const s = new Set();
31
76
  if (/EXEC\s+CICS\b/i.test(src)) s.add('CICS');
@@ -35,7 +80,7 @@ function platformsOf(src, calls) {
35
80
  if (c.kind !== 'L') continue;
36
81
  const n = String(c.name).toUpperCase();
37
82
  if (n === 'CBLTDLI' || n === 'AIBTDLI') s.add('IMS');
38
- if (/^MQ[A-Z]{2,}$/.test(n)) s.add('MQ');
83
+ if (/^MQ[A-Z0-9]{2,}$/.test(n)) s.add('MQ');
39
84
  if (/^CEE[A-Z0-9]+$/.test(n)) s.add('Language Environment');
40
85
  }
41
86
  return [...s];
@@ -57,26 +102,55 @@ export function sbom(root, opts = {}) {
57
102
  const idx = tree.index;
58
103
  const top = resolve(root);
59
104
  const within = (p) => resolve(p).startsWith(top + sep);
60
- const refOf = (p) => (within(p) ? relPath(root, p) : `copylib:${basename(p)}`);
105
+ // A member outside the tree is named by its file name, and by its directory's digest as well where
106
+ // another library holds a member of the same name.
107
+ const copylibRefs = new Map();
108
+ const claimed = new Map();
109
+ const refOf = (p) => {
110
+ if (within(p)) return relPath(root, p);
111
+ if (!copylibRefs.has(p)) {
112
+ let ref = `copylib:${basename(p)}`;
113
+ if (claimed.has(ref) && claimed.get(ref) !== p) ref = `copylib:${basename(p)}#${sha256(dirname(resolve(p))).slice(0, 8)}`;
114
+ claimed.set(ref, p);
115
+ copylibRefs.set(p, ref);
116
+ }
117
+ return copylibRefs.get(p);
118
+ };
61
119
 
62
120
  const entries = [];
63
121
  const programIds = new Map();
122
+ const mapsets = new Map();
123
+ const procIds = new Map();
64
124
  for (const f of tree.list().filter(inScope(opts)).sort()) {
65
125
  let bytes;
66
- let src;
67
- try { bytes = readFileSync(f); src = readSource(f).text; } catch { continue; }
68
- const kind = kindOf(f, src);
126
+ let src = null;
127
+ try { bytes = readFileSync(f); } catch { continue; }
128
+ try { src = readSource(f).text; } catch { /* its bytes are listed; its text could not be decoded */ }
129
+ const kind = src === null ? (isProgram(f) ? 'program' : isCopybook(f) ? 'copybook' : isJcl(f) ? 'jcl' : isBms(f) ? 'bms' : null) : kindOf(f, src);
69
130
  if (!kind) continue;
70
- const entry = { file: f, ref: relPath(root, f), bytes, src, kind };
131
+ const entry = { file: f, ref: relPath(root, f), bytes, src: src ?? '', kind, ...(src === null ? { unparsed: true, undecodable: true } : {}) };
132
+ if (src === null) { entries.push(entry); continue; }
71
133
  if (kind === 'program') {
72
134
  try {
73
135
  const res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
74
136
  entry.programs = res.programs;
75
137
  entry.copies = res.copies;
76
- for (const p of res.programs) if (p.id) programIds.set(String(p.id).toUpperCase(), entry.ref);
138
+ for (const p of res.programs) {
139
+ if (!p.id) continue;
140
+ const id = String(p.id).toUpperCase();
141
+ if (!programIds.has(id)) programIds.set(id, []);
142
+ if (!programIds.get(id).includes(entry.ref)) programIds.get(id).push(entry.ref);
143
+ }
77
144
  } catch { entry.unparsed = true; }
78
145
  } else if (kind === 'jcl' || kind === 'proc') {
79
146
  try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
147
+ if (kind === 'proc') {
148
+ const m = /^\/\/([A-Z0-9#@$]{1,8})\s+PROC\b/im.exec(src);
149
+ procIds.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
150
+ }
151
+ } else if (kind === 'bms') {
152
+ const m = /^([A-Z0-9#@$]{1,8})\s+DFHMSD\b/im.exec(src);
153
+ mapsets.set((m ? m[1] : basename(f, extname(f))).toUpperCase(), entry.ref);
80
154
  }
81
155
  entries.push(entry);
82
156
  }
@@ -84,13 +158,28 @@ export function sbom(root, opts = {}) {
84
158
  const components = new Map();
85
159
  const dependencies = new Map();
86
160
  const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
161
+ // A program the estate does not hold is a component of its own, so an edge to it is not lost.
162
+ // A PROGRAM-ID two files hold is an edge to each: which one a run loads is the load library's order.
163
+ const runs = (from, name) => {
164
+ const targets = programIds.get(name);
165
+ if (targets) { for (const t of targets) if (t !== from) depend(from, t); return; }
166
+ if (platformRoutine(name)) return;
167
+ const ref = `program:${name}`;
168
+ depend(from, ref);
169
+ if (!components.has(ref)) {
170
+ components.set(ref, { 'bom-ref': ref, type: 'application', name, properties: [{ name: 'cobolwork:kind', value: SYSTEM_PROGRAMS.has(name) ? 'system-program' : 'external-program' }] });
171
+ }
172
+ };
87
173
 
88
174
  for (const e of entries) {
89
175
  const properties = [{ name: 'cobolwork:kind', value: e.kind }];
90
176
  if (e.unparsed) properties.push({ name: 'cobolwork:unparsed', value: 'true' });
177
+ if (e.undecodable) properties.push({ name: 'cobolwork:undecodable', value: 'true' });
91
178
  if (e.kind === 'program' && e.programs && e.programs.length) {
92
179
  const ids = e.programs.map((p) => p.id).filter(Boolean);
93
180
  if (ids.length) properties.push({ name: 'cobolwork:program-id', value: ids.join(',') });
181
+ const twins = [...new Set(ids.flatMap((id) => (programIds.get(String(id).toUpperCase()) || []).filter((r) => r !== e.ref)))].sort(order);
182
+ if (twins.length) properties.push({ name: 'cobolwork:program-id-also-in', value: twins.join(',') });
94
183
  properties.push({ name: 'cobolwork:format', value: detectFormat(e.src) });
95
184
  const options = optionCards(e.src).flatMap((c) => c.options);
96
185
  if (options.length) properties.push({ name: 'cobolwork:options', value: options.join(' ') });
@@ -101,15 +190,26 @@ export function sbom(root, opts = {}) {
101
190
  if (!within(c.path) && !components.has(ref)) {
102
191
  let b = null;
103
192
  try { b = readFileSync(c.path); } catch { /* read at parse time, gone since */ }
104
- components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }] });
193
+ components.set(ref, { 'bom-ref': ref, type: 'file', name: ref, ...(b ? { hashes: [{ alg: 'SHA-256', content: sha256(b) }] } : {}), properties: [{ name: 'cobolwork:kind', value: 'copybook' }, { name: 'cobolwork:source', value: 'copy library' }, ...(b ? [] : [{ name: 'cobolwork:unread', value: 'true' }])] });
105
194
  }
106
195
  }
107
196
  const calls = e.programs.flatMap((p) => p.calls || []);
108
197
  const unresolved = new Set();
109
198
  for (const c of calls) {
110
199
  if (c.kind !== 'L') { unresolved.add(String(c.name)); continue; }
111
- const target = programIds.get(String(c.name).toUpperCase());
112
- if (target && target !== e.ref) depend(e.ref, target);
200
+ runs(e.ref, String(c.name).toUpperCase());
201
+ }
202
+ for (const x of e.programs.flatMap((p) => p.execs || [])) {
203
+ if (x.kind !== 'CICS') continue;
204
+ const words = x.toks.filter((t) => t.t === 'word').map((t) => t.u);
205
+ if (words[0] === 'LINK' || words[0] === 'XCTL') {
206
+ const op = cicsOperand(x.toks, 'PROGRAM');
207
+ if (op && op.literal) runs(e.ref, op.literal); else if (op) unresolved.add(op.field);
208
+ } else if ((words[0] === 'SEND' || words[0] === 'RECEIVE') && words[1] === 'MAP') {
209
+ const op = cicsOperand(x.toks, 'MAPSET') || cicsOperand(x.toks, 'MAP');
210
+ const target = op && op.literal ? mapsets.get(op.literal) : null;
211
+ if (target) depend(e.ref, target);
212
+ }
113
213
  }
114
214
  if (unresolved.size) properties.push({ name: 'cobolwork:unresolved-call', value: [...unresolved].sort(order).join(',') });
115
215
  for (const p of platformsOf(e.src, calls)) {
@@ -119,10 +219,17 @@ export function sbom(root, opts = {}) {
119
219
  }
120
220
  }
121
221
  if (e.jcl) {
222
+ const inStream = new Set((e.jcl.procs || []).map((p) => String(p.name || '').toUpperCase()));
223
+ const missing = new Set();
122
224
  for (const step of e.jcl.steps || []) {
123
- const target = step.pgm ? programIds.get(String(step.pgm).toUpperCase()) : null;
124
- if (target) depend(e.ref, target);
225
+ if (step.pgm && /^[A-Z0-9#@$]{1,8}$/i.test(step.pgm)) runs(e.ref, String(step.pgm).toUpperCase());
226
+ for (const name of programsRunBy(step)) runs(e.ref, name);
227
+ if (!step.proc) continue;
228
+ const name = String(step.proc).toUpperCase();
229
+ const proc = procIds.get(name);
230
+ if (proc) { if (proc !== e.ref) depend(e.ref, proc); } else if (!inStream.has(name)) missing.add(name);
125
231
  }
232
+ if (missing.size) properties.push({ name: 'cobolwork:unresolved-proc', value: [...missing].sort(order).join(',') });
126
233
  }
127
234
  components.set(e.ref, { 'bom-ref': e.ref, type: 'file', name: e.ref, hashes: [{ alg: 'SHA-256', content: sha256(e.bytes) }], properties });
128
235
  }
@@ -130,6 +237,8 @@ export function sbom(root, opts = {}) {
130
237
  const list = [...components.values()].sort((a, b) => order(a['bom-ref'], b['bom-ref']));
131
238
  const digests = list.filter((c) => c.hashes).map((c) => c.hashes[0].content).sort(order);
132
239
  const name = opts.name || basename(top);
240
+ let revision = null;
241
+ try { revision = revisionOf(top); } catch { /* not a repository, or git is missing */ }
133
242
  const epoch = process.env.SOURCE_DATE_EPOCH;
134
243
  return {
135
244
  bomFormat: 'CycloneDX',
@@ -139,7 +248,10 @@ export function sbom(root, opts = {}) {
139
248
  metadata: {
140
249
  ...(epoch && /^\d+$/.test(epoch) ? { timestamp: new Date(Number(epoch) * 1000).toISOString() } : {}),
141
250
  tools: { components: [{ type: 'application', name: 'cobolwork', version: TOOL_VERSION }] },
142
- component: { 'bom-ref': `estate:${name}`, type: 'application', name },
251
+ component: {
252
+ 'bom-ref': `estate:${name}`, type: 'application', name,
253
+ ...(revision ? { version: revision.commit, properties: [{ name: 'cobolwork:dirty', value: String(revision.dirty) }] } : {}),
254
+ },
143
255
  },
144
256
  components: list,
145
257
  dependencies: [...dependencies.entries()].sort(([a], [b]) => order(a, b)).map(([ref, set]) => ({ ref, dependsOn: [...set].sort(order) })),
package/lib/scan.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
  import { sortFindings, tally, evidenceMap, EVIDENCE, EXPLOITABILITY } from './kernel/findings.mjs';
4
4
  import { directoryTree } from './kernel/source-tree.mjs';
5
5
  import { REGISTRY, RULE_SETS, ALL_RULES, reportKey } from './kernel/registry.mjs';
@@ -13,6 +13,7 @@ import { rollup } from './sets/flow.mjs';
13
13
  import { loadSite } from './site.mjs';
14
14
  import { reachFeedPaths, loadReachFeed, reachResolver, stampReach, stampEffect } from './reach.mjs';
15
15
  import { stampExploitability, byVerdict, HANDLING, witnessFeedPaths, loadWitnessFeed, applyWitness } from './exploitability.mjs';
16
+ import { executionFeedPaths, loadExecutionFeed, applyExecution } from './execution.mjs';
16
17
 
17
18
  import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
18
19
 
@@ -133,7 +134,7 @@ function scanEach(root, opts) {
133
134
  // Reach and effect annotate rather than re-rank; where no fact is declared, a note says so rather
134
135
  // than the report reading as a clean bill.
135
136
  export function applyEstateFacts(findings, checked, root, opts = {}) {
136
- const site = loadSite(root, opts.site || null);
137
+ const site = loadSite(root, opts.site || null, opts.tree);
137
138
  const feedRoot = opts.feedRoot || root;
138
139
  const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root: feedRoot }));
139
140
  const reach = reachResolver({ feeds: reachFeeds, site });
@@ -151,6 +152,8 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
151
152
  const witnessed = applyWitness(findings, checked, witnessFeeds);
152
153
  if (witnessed.overruled.length) sortFindings(findings);
153
154
  const byExploitability = byVerdict(findings);
155
+ const executionFeeds = executionFeedPaths(opts).map((p) => loadExecutionFeed(p));
156
+ const { byExecution } = applyExecution(findings, root, executionFeeds);
154
157
  const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
155
158
  const loaded = witnessFeeds.filter((f) => !f.problem);
156
159
  return {
@@ -164,6 +167,8 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
164
167
  ...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
165
168
  ...(loaded.length ? { witnessFeeds: loaded.map((f) => ({ file: f.file, witness: f.witness, recorded: f.recorded })), byWitness: witnessed.byWitness } : {}),
166
169
  ...(witnessFeeds.some((f) => f.problem || f.refused.length) ? { witnessFeedProblems: witnessFeeds.flatMap((f) => (f.problem ? [`${f.file}: ${f.problem}`] : f.refused.map((r) => `${f.file}: ${r.why}`))) } : {}),
170
+ ...(byExecution ? { executionFeeds: executionFeeds.filter((f) => !f.problem).map((f) => ({ file: f.file, programs: f.programs.size })), byExecution } : {}),
171
+ ...(executionFeeds.some((f) => f.problem) ? { executionFeedProblems: executionFeeds.filter((f) => f.problem).map((f) => `${f.file}: ${f.problem}`) } : {}),
167
172
  // A result no path finding carries: the code changed since the test, or the route is gone.
168
173
  ...(witnessed.unmatched.length ? { witnessUnmatched: witnessed.unmatched } : {}),
169
174
  // Routes the reading refuted that the estate reproduced: the check model was wrong about them.
@@ -234,10 +239,12 @@ export function scanAll(root, opts = {}) {
234
239
  const startedBy = parts.find((p) => p.tool === 'cobolwork-flow')?.startedBy || {};
235
240
  for (const p of parts) for (const f of p.findings) findings.push(!f.startedBy && f.program && startedBy[f.program] ? { ...f, startedBy: startedBy[f.program] } : { ...f });
236
241
  sortFindings(findings);
237
- const identity = stampFingerprints(findings, { root, repo: opts.repoName || '' });
242
+ // A tree held in memory is read for the fingerprint's line text as it was for the finding.
243
+ const read = tree.kind === 'directory' ? null : (path) => tree.text(resolve(tree.root, path)).text;
244
+ const identity = stampFingerprints(findings, { root, repo: opts.repoName || '', read });
238
245
  // Routes a check stops keep their identity too, so a finding a fix clears can be found again here.
239
246
  const checked = parts.find((p) => p.tool === 'cobolwork-flow')?.checked || [];
240
- stampFingerprints(checked, { root, repo: opts.repoName || '' });
247
+ stampFingerprints(checked, { root, repo: opts.repoName || '', read });
241
248
 
242
249
  const estate = applyEstateFacts(findings, checked, root, opts);
243
250
 
@@ -132,7 +132,7 @@ function withFeeds(coverage, feeds) {
132
132
  }
133
133
 
134
134
  export function scanBuild(root, opts = {}) {
135
- const site = loadSite(root, opts.site || null);
135
+ const site = loadSite(root, opts.site || null, opts.tree);
136
136
  const tree = treeFor(root, opts);
137
137
  const files = tree.list().filter(inScope(opts)).filter(isBuildFile);
138
138
  const findings = [];
package/lib/sets/cics.mjs CHANGED
@@ -136,6 +136,31 @@ function execOptions(exec) {
136
136
 
137
137
  const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
138
138
 
139
+ // Whether EIBCALEN bounds what the program reads: a condition tests it, or a field it was moved or
140
+ // computed into; it is the start or length of a reference modification, or what an OCCURS DEPENDING
141
+ // ON counts by. A CALL handed it, or the whole EIB, may test it where this program cannot see.
142
+ // Named anywhere else - logged, passed on to a LINK - it checks nothing.
143
+ function testsLength(p) {
144
+ // A statement's own tokens, since its sources leave out what a condition or an expression wraps
145
+ // in parentheses: IF (EIBCALEN > 0).
146
+ const tokens = (p.proc && p.proc.tokens) || [];
147
+ const own = (st) => (st.at != null ? tokens.slice(st.at + 1, st.end) : st.sources || []);
148
+ const carriers = new Set(['EIBCALEN']);
149
+ for (let grew = true; grew;) {
150
+ grew = false;
151
+ for (const st of p.statements || []) {
152
+ if (!own(st).some((t) => carriers.has(t.u))) continue;
153
+ for (const t of st.targets || []) if (!carriers.has(t.u)) { carriers.add(t.u); grew = true; }
154
+ }
155
+ }
156
+ const carries = (toks) => (toks || []).some((t) => t && carriers.has(t.u));
157
+ return p.items.some((i) => i.dependingOn === 'EIBCALEN') || (p.statements || []).some((st) =>
158
+ ((st.verb === 'IF' || st.verb === 'EVALUATE' || st.verb === 'WHEN') && carries(own(st)))
159
+ || (st.loops || []).some((l) => carries(l.until))
160
+ || (st.indexes || []).some((x) => x.kind.startsWith('refmod-') && carries([x.tok]))
161
+ || (st.verb === 'CALL' && (st.sources || []).some((t) => t.u === 'EIBCALEN' || t.u === 'DFHEIBLK')));
162
+ }
163
+
139
164
  // Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
140
165
  // not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
141
166
  // have gone, an ASSIGN, and the RETURN that ends the task anyway.
@@ -191,7 +216,6 @@ export function scanCics(root, opts = {}) {
191
216
  // The whole of what the second pass reads, computed once, while the tree is still here.
192
217
  function summarise(p, path) {
193
218
  const isSignOn = [...new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))])].some((w) => PASSWORD.test(w));
194
- const words = new Set([...p.refs.map(x => x.tok.u), ...p.execs.flatMap(e => e.toks.filter(t => t.t === 'word').map(t => t.u))]);
195
219
  const commarea = p.items.find(i => i.name === 'DFHCOMMAREA' && i.section === 'LINKAGE');
196
220
  // A variable naming the program usually holds one literal from its VALUE clause.
197
221
  const valueOf = (name) => {
@@ -221,19 +245,22 @@ export function scanCics(root, opts = {}) {
221
245
  declared,
222
246
  });
223
247
  }
224
- // A program that points DFHCOMMAREA at storage of its own is not reading a caller's area.
248
+ // A program that points DFHCOMMAREA at storage of its own is not reading a caller's area. Only
249
+ // the receiving side of SET, before TO, repoints it: SET P TO ADDRESS OF DFHCOMMAREA reads the
250
+ // caller's area's address.
225
251
  const tokens = (p.proc && p.proc.tokens) || [];
226
252
  const pointsItself = (p.statements || []).some((st) => {
227
253
  if (st.verb !== 'SET') return false;
228
254
  const w = tokens.slice(st.at + 1, st.end).filter((t) => t.t === 'word').map((t) => t.u);
229
- return w.some((x, k) => x === 'ADDRESS' && w[k + 1] === 'OF' && w[k + 2] === 'DFHCOMMAREA');
255
+ const receiving = w.includes('TO') ? w.slice(0, w.indexOf('TO')) : w;
256
+ return receiving.some((x, k) => x === 'ADDRESS' && receiving[k + 1] === 'OF' && receiving[k + 2] === 'DFHCOMMAREA');
230
257
  });
231
258
  return {
232
259
  id: p.id, path,
233
260
  ownCics: p.execs.some((e) => e.kind === 'CICS'),
234
261
  pointsItself,
235
262
  commarea: commarea ? { ...where(commarea, path), size: commarea.size, read: subtreeRead(commarea) } : null,
236
- checksLength: words.has('EIBCALEN'),
263
+ checksLength: testsLength(p),
237
264
  transfers,
238
265
  signOnBypasses: isSignOn ? signOnBypasses(p, path) : [],
239
266
  // Gated the same way: a program with no password-named field is not answering a sign-on,
@@ -390,7 +417,7 @@ export function scanCics(root, opts = {}) {
390
417
  findings.push({
391
418
  rule: 'cics-commarea-without-length-check', path: s.commarea.path, line: s.commarea.line, program: s.id,
392
419
  ...(covered ? { sev: 'low' } : {}),
393
- detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never references EIBCALEN, so a shorter or absent communication area is read as if it were whole${covered ? `; every caller in the tree passes at least ${s.commarea.size} bytes, and no transaction starts it` : ''}`,
420
+ detail: `${s.id} reads DFHCOMMAREA (${s.commarea.size} bytes declared) and never tests EIBCALEN, so a shorter or absent communication area is read as if it were whole${covered ? `; every caller in the tree passes at least ${s.commarea.size} bytes, and no transaction starts it` : ''}`,
394
421
  });
395
422
  }
396
423
  }
@@ -7,6 +7,7 @@ import { finish } from '../kernel/findings.mjs';
7
7
  import { report } from '../kernel/ruleset.mjs';
8
8
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
9
  import { eachWithinMemory } from '../kernel/memory.mjs';
10
+ import { cobolCard } from '../cards.mjs';
10
11
 
11
12
  // A copybook is resolved by name along a search path: the program's own directory first, then the
12
13
  // include directories, then the system library. Two files answering to one name mean the record
@@ -37,7 +38,7 @@ const SYSTEM_NAME = /^(DFHAID|DFHBMSCA|DFHEIBLK|DFHCOMMAREA|DFHRESP|SQLCA|SQLDA|
37
38
  function layout(src) {
38
39
  const fixed = detectFormat(src) === 'fixed';
39
40
  return src.split(/\r?\n/)
40
- .map(l => (fixed ? l.slice(6, 72) : l).trimEnd())
41
+ .map(l => { const c = cobolCard(l, fixed ? 'fixed' : 'free'); return (c.indicator + c.text).trimEnd(); })
41
42
  .filter(l => l.trim() && !/^[*/]/.test(l.trimStart()))
42
43
  .join('\n')
43
44
  .toUpperCase();
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  import { detectFormat, expandTabs } from '../parser.mjs';
3
+ import { cobolCard } from '../cards.mjs';
3
4
  import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
4
5
  import { report } from '../kernel/ruleset.mjs';
5
6
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
@@ -69,14 +70,9 @@ const BANNER = /^[\s*\-=_+#/\\.]*$/;
69
70
 
70
71
  function areas(line, format) {
71
72
  const l = expandTabs(line);
72
- if (format === 'free' || format === 'terminal') return { sequence: '', indicator: '', text: l, identification: '', ignored: '' };
73
- return {
74
- sequence: l.slice(0, 6),
75
- indicator: l[6] || ' ',
76
- text: l.slice(7, format === 'variable' ? 250 : 72),
77
- identification: format === 'variable' ? '' : l.slice(72, 80),
78
- ignored: format === 'variable' ? '' : l.slice(72),
79
- };
73
+ const card = cobolCard(l, format);
74
+ const fixed = format === 'fixed';
75
+ return { ...card, identification: fixed ? card.ignored.slice(0, 8) : '', ignored: fixed ? card.ignored : '' };
80
76
  }
81
77
 
82
78
  // A floating comment, from the *> that starts it outside a literal to the end of the line.
package/lib/sets/jcl.mjs CHANGED
@@ -7,7 +7,8 @@ import { inScope, isJcl, isProgram, relPath } from '../sources.mjs';
7
7
  import { report } from '../kernel/ruleset.mjs';
8
8
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
9
9
  import { parseJcl, dispositionOf } from '../jcl.mjs';
10
- import { ddGroups } from '../utilities.mjs';
10
+ import { ddGroups, tsoCommands } from '../utilities.mjs';
11
+ import { ftpSession } from '../ftp.mjs';
11
12
  import { loadSite, productionQualifierOf, SITE_FILE } from '../site.mjs';
12
13
  import { loadPacks, programsOf } from '../packs.mjs';
13
14
  import { eachWithinMemory } from '../kernel/memory.mjs';
@@ -84,11 +85,16 @@ const SORTS = new Set(['SORT', 'ICEMAN', 'SYNCSORT']);
84
85
  const CONTROL_DDS = [
85
86
  [TSO, ['SYSTSIN']], [new Set(['BPXBATCH']), ['STDPARM']], [new Set(['IDCAMS']), ['SYSIN']],
86
87
  [SORTS, ['SYSIN', 'DFSPARM']], [new Set(['ICETOOL']), ['TOOLIN', 'DFSPARM']],
87
- [new Set(['DSNTEP2', 'DSNTEP4', 'DSNTIAD']), ['SYSIN']],
88
88
  ];
89
89
  const TSO_RUNS = /^\s*(CALL|EXEC|EX|SUBMIT|ISPSTART|RUN)\b/i;
90
- // RUN PROGRAM(DSNTEP2) under the DSN command processor makes that step's SYSIN a program's SQL.
91
- const RUNS_SQL_PROGRAM = /\bRUN\s+PROGRAM\s*\(\s*(DSNTEP2|DSNTEP4|DSNTIAD)\s*\)/i;
90
+ // Db2's sample programs read SQL statements from SYSIN, run as the step's program or under TSO by
91
+ // DSN RUN PROGRAM. Those statements are the program's input, not commands to TSO or a security product.
92
+ const SQL_PROGRAMS = new Set(['DSNTEP2', 'DSNTEP4', 'DSNTIAD']);
93
+ const sqlInput = (step, groups) => {
94
+ const pgm = step.pgm ? step.pgm.toUpperCase() : null;
95
+ const runsSql = SQL_PROGRAMS.has(pgm) || tsoCommands(step).runs.some((x) => SQL_PROGRAMS.has(x.program));
96
+ return runsSql ? groups.get('SYSIN') || [] : [];
97
+ };
92
98
 
93
99
  // Utilities whose commands come from a DD: a cataloged data set there is commands nobody reviewed
94
100
  // with the job; a MODS exit is a load module the sort runs; TSO commands say what else runs.
@@ -99,7 +105,7 @@ function controlCardFindings(r, path, findings) {
99
105
  const groups = ddGroups(step);
100
106
  const cards = (name) => (groups.get(name) || []).flatMap((dd) => dd.inStream || []);
101
107
  const ddNames = new Set(CONTROL_DDS.filter(([pgms]) => pgms.has(pgm)).flatMap(([, dds]) => dds));
102
- if (TSO.has(pgm) && cards('SYSTSIN').some((l) => RUNS_SQL_PROGRAM.test(l.text))) ddNames.add('SYSIN');
108
+ if (sqlInput(step, groups).length) ddNames.add('SYSIN');
103
109
  for (const name of ddNames) {
104
110
  for (const dd of groups.get(name) || []) {
105
111
  if (dd.dsn && !dd.inStream && !dd.temporary) {
@@ -127,116 +133,6 @@ function controlCardFindings(r, path, findings) {
127
133
  }
128
134
  }
129
135
 
130
- // IBM's batch FTP client takes the host and its options on PARM, or the host as the first line of
131
- // its input, reads subcommands from INPUT, and logs on from NETRC or from its input. CardDemo's job
132
- // gives the same lines on SYSIN, which is read here when there is no INPUT, though the page below
133
- // does not say the client falls back to it.
134
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halu001/ftpreq.htm
135
- // A session is in cleartext unless something asks for TLS: -a TLS or -r TLS on PARM, or
136
- // SECURE_MECHANISM TLS in an in-stream SYSFTPD. A SYSFTPD naming a data set is configuration this
137
- // reader cannot see, so that step is not called cleartext either way.
138
- // https://www.ibm.com/docs/en/zos/2.1.0?topic=commands-ftp-command
139
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halz002/ftp_cust_client_for_tls.htm
140
- // PUT, MPUT and APPEND send local files, and IBM's capitals - PUt, MPut, APpend, LCd - are the
141
- // shortest spellings. A local name in quotes is a whole data set name, //DD: names a DD of the
142
- // step, and anything else hangs off the local working directory, which only a quoted LCD fixes.
143
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/ftpsubcmd.htm
144
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/put.htm
145
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mput.htm
146
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/append.htm
147
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/lcd.htm
148
- // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/dd_name_support.htm
149
- const FTP_SENDS = [[/^PUT?$/i, 'PUT'], [/^MP(?:UT?)?$/i, 'MPUT'], [/^AP(?:P(?:E(?:ND?)?)?)?$/i, 'APPEND']];
150
-
151
- // Every subcommand on the page above, in IBM's spelling.
152
- const FTP_SUBCOMMANDS = ['?', '!', 'ACCount', 'APpend', 'AUth', 'AScii', 'BIG5', 'BINary', 'BLock', 'CCc', 'CD',
153
- 'CDUp', 'CLEar', 'CLose', 'COMpress', 'CProtect', 'CWd', 'DEBug', 'DELEte', 'DELImit', 'DIr', 'DUMP', 'EBcdic',
154
- 'EUckanji', 'FEature', 'FIle', 'Get', 'GLob', 'HAngeul', 'HElp', 'Ibmkanji', 'JIS78kj', 'JIS83kj', 'Ksc5601',
155
- 'LANGuage', 'LCd', 'LOCSIte', 'LOCSTat', 'LMkdir', 'LPwd', 'LS', 'MDelete', 'MGet', 'MKdir', 'MKFifo', 'MOde',
156
- 'MPut', 'MVSGet', 'MVSPut', 'NOop', 'Open', 'PAss', 'PRIvate', 'PROMpt', 'PROTect', 'PROXy', 'PUt', 'PWd', 'QUIt',
157
- 'QUOte', 'RECord', 'REName', 'REStart', 'RMdir', 'SAfe', 'SChinese', 'SENDPort', 'SENDSite', 'SIte', 'SJiskanji',
158
- 'SRestart', 'STAtus', 'STREam', 'STRucture', 'SUnique', 'SYstem', 'TChinese', 'TSO', 'TYpe', 'UCs2', 'User', 'Verbose']
159
- .map((name) => ({ full: name.toUpperCase(), min: name.match(/^[^a-z]*/)[0].length }));
160
- const subcommandOf = (word) => {
161
- const w = word.toUpperCase();
162
- const hit = FTP_SUBCOMMANDS.find((c) => w.length >= c.min && c.full.startsWith(w));
163
- return hit ? hit.full : null;
164
- };
165
- // A value that stands for a password rather than being one: a symbolic, a mask, or the word itself.
166
- const PLACEHOLDER = /^(&.*|\*+|X+|\?+|<.*>|\{.*\}|PASSWORD|PASSWD|PWD|PASS|SECRET)$/i;
167
-
168
- function ftpSession(step) {
169
- const dds = ddGroups(step);
170
- let host = null, tls = null;
171
- const words = (step.parm || '').split('(')[0].trim().split(/\s+/).filter(Boolean);
172
- for (let i = 0; i < words.length; i++) {
173
- if (/^-[ar]$/i.test(words[i])) {
174
- const mechanism = (words[++i] || '').toUpperCase();
175
- if (mechanism === 'TLS') tls = 'asked';
176
- else if (mechanism === 'NEVER' && !tls) tls = 'never';
177
- } else if (!words[i].startsWith('-') && !host) host = words[i];
178
- }
179
- let configUnread = null;
180
- for (const dd of dds.get('SYSFTPD') || []) {
181
- if (dd.inStream) { if (tls !== 'never' && dd.inStream.some((l) => /^\s*SECURE_MECHANISM\s+TLS\b/i.test(l.text))) tls = 'asked'; }
182
- else if (dd.dsn && !configUnread) configUnread = dd.dsn;
183
- }
184
-
185
- const input = dds.get('INPUT') || dds.get('SYSIN') || [];
186
- const hostLine = !host && input[0]?.inStream;
187
- let inputUnread = null, lcd = null, unixDir = false;
188
- const sends = [];
189
- // With no NETRC to answer for it, the client asks for a user ID and a password and in batch reads
190
- // them from its input after the host: "user password" on one line, or each on its own. A first
191
- // line that is a subcommand means something else answered, a NETRC the client found for itself.
192
- // USER and PASS give the same answers later in a session.
193
- let expect = dds.has('NETRC') ? null : 'logon', user = null;
194
- const passwords = [];
195
- const password = (dd, line, value) => { if (!PLACEHOLDER.test(value)) passwords.push({ dd, line, user }); };
196
- const local = (name) => {
197
- const dd = /^\/\/DD:([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(name);
198
- if (dd) return { dd: dd[1].toUpperCase(), dsns: (dds.get(dd[1].toUpperCase()) || []).map((d) => d.dsn).filter(Boolean) };
199
- const quoted = /^'(.+)'$/.exec(name);
200
- if (quoted) return { dsns: [quoted[1].toUpperCase()] };
201
- if (name.startsWith('/') || unixDir) return { dsns: [] };
202
- return lcd ? { dsns: [`${lcd}.${name.toUpperCase()}`] } : { dsns: [], relative: name };
203
- };
204
- for (const dd of input) {
205
- if (!dd.inStream) { if (dd.dsn && !inputUnread) inputUnread = dd.dsn; continue; }
206
- for (const { line, text } of dd.inStream) {
207
- const [verb, ...args] = text.slice(0, 72).trim().split(/\s+/);
208
- if (!verb) continue;
209
- if (hostLine && !host) { host = verb; continue; }
210
- const sub = subcommandOf(verb);
211
- if (expect && !sub) {
212
- if (expect === 'logon') user = verb;
213
- if (expect === 'password') password(dd, line, verb);
214
- else if (args[0]) password(dd, line, args[0]);
215
- expect = expect === 'logon' && !args[0] ? 'password' : null;
216
- continue;
217
- }
218
- expect = null;
219
- if (sub === 'USER' && args[0]) {
220
- user = args[0];
221
- if (args[1]) password(dd, line, args[1]); else expect = 'password';
222
- continue;
223
- }
224
- if (sub === 'PASS' && args[0]) { password(dd, line, args[0]); continue; }
225
- if (/^LCD?$/i.test(verb) && args[0]) {
226
- const quoted = /^'(.+)'$/.exec(args[0]);
227
- unixDir = args[0].startsWith('/');
228
- lcd = quoted ? quoted[1].toUpperCase() : lcd && !unixDir ? `${lcd}.${args[0].toUpperCase()}` : null;
229
- continue;
230
- }
231
- const sent = FTP_SENDS.find(([re]) => re.test(verb));
232
- if (!sent) continue;
233
- for (const name of sent[1] === 'MPUT' ? args : args.slice(0, 1)) sends.push({ line, verb: sent[1], name, ...local(name) });
234
- }
235
- }
236
- const cleartext = tls === 'never' || (!tls && !configUnread);
237
- return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, passwords, input };
238
- }
239
-
240
136
  // Credentials, which are the reason this rule set is critical rather than merely interesting.
241
137
  // Each run of blanks follows the ( or = that separates it from the next, which keeps matching linear.
242
138
  const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*)?['"]?([A-Z0-9@#$_.-]{3,})/i;
@@ -247,7 +143,7 @@ const CREDENTIAL = /\b(?:PASSWORD|PASSWRD|PHRASE|PASSPHRASE)\s*(?:\(\s*)?(?:=\s*
247
143
  const SECURITY_COMMAND = /^\s*(?:TSS\s+)?(ADDUSER|ALTUSER|DELUSER|ADDGROUP|ALTGROUP|CONNECT|REMOVE|PERMIT|SETROPTS|RDEFINE|RALTER|RDELETE|ADDSD|ALTDSD|DELDSD|PASSWORD|ACFNRULE)\b/i;
248
144
 
249
145
  // ACF2 speaks differently: a mode change followed by INSERT or CHANGE against a logonid.
250
- const ACF2_COMMAND = /^\s*(?:SET\s+(?:LID|RULE|PROFILE)|INSERT\s+[A-Z0-9$#@]+|CHANGE\s+[A-Z0-9$#@]+\s)/i;
146
+ const ACF2_COMMAND = /^\s*(?:SET\s+(?:LID|RULE|PROFILE)|INSERT\s+(?!INTO\b)[A-Z0-9$#@]+|CHANGE\s+[A-Z0-9$#@]+\s)/i;
251
147
 
252
148
  // Destruction in-stream. DELETE with a dataset name, or a REPRO that replaces its target. SQL's
253
149
  // DELETE FROM removes rows through a database utility, not a catalogued dataset.
@@ -341,7 +237,7 @@ export function scanJcl(root, opts = {}) {
341
237
  // A vendor product supplies its own utilities. An estate that has loaded the pack for a product
342
238
  // is telling us those programs exist, so reporting them as undefined would be a false positive
343
239
  // created by the customer having told us the truth.
344
- const site = loadSite(root, opts.site || null);
240
+ const site = loadSite(root, opts.site || null, opts.tree);
345
241
  const { loaded } = loadPacks(opts.packs || site.vendorPacks || [], { allowUnvalidated: true });
346
242
  const vendorPrograms = programsOf(loaded);
347
243
 
@@ -370,9 +266,10 @@ export function scanJcl(root, opts = {}) {
370
266
  if (d && !stats.firstReadInPart) stats.firstReadInPart = `${path}${d.line ? `:${d.line}` : ''}: ${d.text}`;
371
267
  }
372
268
 
373
- const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s)]));
269
+ const ftpSteps = new Map(r.steps.filter((s) => s.pgm && s.pgm.toUpperCase() === 'FTP' && !defined.has('FTP')).map((s) => [s, ftpSession(s, ddGroups(s))]));
374
270
  const ftpInput = new Map();
375
271
  for (const ftp of ftpSteps.values()) for (const dd of ftp.input) ftpInput.set(dd, ftp);
272
+ const sqlDds = new Set(r.steps.flatMap((s) => sqlInput(s, ddGroups(s))));
376
273
 
377
274
  for (const dd of r.dds) {
378
275
  if (!dd.inStream) continue;
@@ -398,13 +295,13 @@ export function scanJcl(root, opts = {}) {
398
295
  detail: `in-stream FTP input gives the password ${logon.user ? `for ${logon.user} ` : ''}that step ${dd.step || '(none)'} logs on ${ftp.host ? `to ${ftp.host} ` : ''}with, in a file anyone who can read the repository can read` });
399
296
  continue;
400
297
  }
401
- const sec = text.match(SECURITY_COMMAND);
298
+ const sec = !sqlDds.has(dd) && text.match(SECURITY_COMMAND);
402
299
  if (sec) {
403
300
  findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
404
301
  detail: `in-stream data issues ${sec[1].toUpperCase()}, which grants or alters authority, from step ${dd.step || '(none)'}` });
405
302
  continue;
406
303
  }
407
- if (ACF2_COMMAND.test(text)) {
304
+ if (!sqlDds.has(dd) && ACF2_COMMAND.test(text)) {
408
305
  findings.push({ rule: 'jcl-instream-security-command', path, line: l.line, step: dd.step,
409
306
  detail: `in-stream data issues an ACF2 administration command from step ${dd.step || '(none)'}` });
410
307
  continue;
package/lib/sets/priv.mjs CHANGED
@@ -102,7 +102,7 @@ function under(dsn, prefixes) {
102
102
  }
103
103
 
104
104
  export function scanPriv(root, opts = {}) {
105
- const site = loadSite(root, opts.site || null);
105
+ const site = loadSite(root, opts.site || null, opts.tree);
106
106
  const tree = treeFor(root, opts);
107
107
  const files = tree.list().filter(inScope(opts));
108
108
  const findings = [];