@portll/cobolwork 0.2.76 → 0.2.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -14
- package/bin/cobolwork.mjs +126 -9
- package/lib/arith.mjs +235 -0
- package/lib/baseline.mjs +5 -0
- package/lib/build.mjs +112 -30
- package/lib/capabilities.mjs +12 -7
- package/lib/consequence.mjs +29 -0
- package/lib/control.mjs +1052 -91
- package/lib/csd.mjs +17 -2
- package/lib/dataflow.mjs +245 -78
- package/lib/enterprise-options.mjs +255 -0
- package/lib/equivalence.mjs +124 -0
- package/lib/evidence/cli.mjs +65 -0
- package/lib/evidence/journal.mjs +85 -0
- package/lib/evidence/record.mjs +110 -0
- package/lib/evidence/run.mjs +111 -0
- package/lib/evidence/seal.mjs +177 -0
- package/lib/evidence/slsa.mjs +64 -0
- package/lib/evidence/sshsig.mjs +172 -0
- package/lib/evidence/store.mjs +170 -0
- package/lib/evidence/verify.mjs +167 -0
- package/lib/explain.mjs +4 -1
- package/lib/exploitability.mjs +263 -0
- package/lib/ironwork.mjs +111 -0
- package/lib/kernel/findings.mjs +11 -0
- package/lib/kernel/registry.mjs +4 -0
- package/lib/option-diff.mjs +49 -0
- package/lib/options.mjs +109 -46
- package/lib/parser.mjs +29 -7
- package/lib/policy.mjs +8 -3
- package/lib/precompile.mjs +4 -0
- package/lib/reach.mjs +29 -12
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +1 -0
- package/lib/sbom.mjs +147 -0
- package/lib/scan.mjs +93 -27
- package/lib/sets/cics.mjs +114 -0
- package/lib/sets/flow.mjs +82 -2
- package/lib/sets/jcl.mjs +67 -0
- package/lib/sets/semantics.mjs +377 -0
- package/lib/sets/web.mjs +34 -10
- package/lib/sets/zowe.mjs +220 -0
- package/lib/sources.mjs +10 -0
- package/lib/tui/app.mjs +31 -13
- package/lib/tui/model.mjs +17 -2
- package/lib/utilities.mjs +85 -13
- package/lib/verify.mjs +128 -0
- package/package.json +1 -1
- package/rules/compliance-dora.json +507 -0
- package/rules/compliance-ffiec.json +507 -0
- package/rules/compliance-nist80053.json +507 -0
- package/schema/cobolwork.policy.schema.json +2 -1
package/lib/reach.mjs
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
// effect is unstated, and the scan says so rather than ranking anything. See docs/spec/reach.md.
|
|
11
11
|
//
|
|
12
12
|
// Both are annotations, not re-rankings: severity is urgency, reach is who can drive it, effect is
|
|
13
|
-
// what driving it runs as. Three axes
|
|
14
|
-
//
|
|
15
|
-
// which is not what reading source and records produces.
|
|
13
|
+
// what driving it runs as. Three axes kept apart so each names the fact that moved it;
|
|
14
|
+
// lib/exploitability.mjs joins them with the route into a verdict.
|
|
16
15
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
17
16
|
import { basename, sep, delimiter } from 'node:path';
|
|
18
17
|
|
|
@@ -82,24 +81,42 @@ export function reachResolver({ feeds = [], site = {} } = {}) {
|
|
|
82
81
|
return null;
|
|
83
82
|
};
|
|
84
83
|
const privilegedOf = (kind, name) => (kind === 'transaction' ? privTx.has(up(name)) : kind === 'job' ? privJob.has(up(name)) : false);
|
|
85
|
-
|
|
84
|
+
// Which record said so, for a verdict to cite: the feed with its extract and date, or the site file.
|
|
85
|
+
const siteName = site.path ? basename(site.path) : 'the site file';
|
|
86
|
+
const cite = (f) => `${f.file} (${f.extract}, retrieved ${f.retrieved})`;
|
|
87
|
+
const accessFrom = (kind, name) => {
|
|
88
|
+
const f = good.find((x) => x[`${kind}s`][up(name)]);
|
|
89
|
+
return f ? cite(f) : accessOf(kind, name) ? siteName : null;
|
|
90
|
+
};
|
|
91
|
+
const privilegedFrom = (kind, name) => {
|
|
92
|
+
const f = good.find((x) => x.privileged[`${kind}s`].includes(up(name)));
|
|
93
|
+
return f ? cite(f) : privilegedOf(kind, name) ? siteName : null;
|
|
94
|
+
};
|
|
95
|
+
return { declared, effectDeclared, accessOf, privilegedOf, accessFrom, privilegedFrom };
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
export const entryKind = (e) => (e.transaction ? 'transaction' : e.job !== undefined ? 'job' : null);
|
|
99
|
+
const entryKey = (e) => (e.transaction ? e.transaction : e.job || '');
|
|
100
|
+
export const entryName = (e) => (e.transaction
|
|
101
|
+
? `transaction ${e.transaction}${e.urimap ? ` through URIMAP ${e.urimap}${e.uri ? ` (${e.uri})` : ''}` : ''}`
|
|
102
|
+
: `job ${e.job || '?'} step ${e.step || '(unnamed)'}`);
|
|
103
|
+
|
|
88
104
|
// The access of one startedBy entry, whichever kind it is.
|
|
89
|
-
const accessOfEntry = (e, resolver) => (e
|
|
105
|
+
export const accessOfEntry = (e, resolver) => (entryKind(e) ? resolver.accessOf(entryKind(e), entryKey(e)) : null);
|
|
106
|
+
export const accessFromEntry = (e, resolver) => (entryKind(e) ? resolver.accessFrom(entryKind(e), entryKey(e)) : null);
|
|
107
|
+
export const privilegedFromEntry = (e, resolver) => (entryKind(e) ? resolver.privilegedFrom(entryKind(e), entryKey(e)) : null);
|
|
90
108
|
|
|
91
109
|
// Stamp `reach` on every finding an entry point reaches, and count the three ways. `open` if any
|
|
92
|
-
// entry that reaches it is open
|
|
93
|
-
//
|
|
94
|
-
// carries no reach - there is nothing to say.
|
|
110
|
+
// entry that reaches it is open; `restricted` only if every entry is restricted and none went
|
|
111
|
+
// unlisted, since an entry nobody declared may be open; otherwise `undeclared`. A finding no entry
|
|
112
|
+
// reaches carries no reach - there is nothing to say.
|
|
95
113
|
export function stampReach(findings, resolver) {
|
|
96
114
|
const by = { open: 0, restricted: 0, undeclared: 0 };
|
|
97
115
|
for (const f of findings) {
|
|
98
116
|
const entries = f.startedBy || [];
|
|
99
117
|
if (!entries.length) continue;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
f.reach = open ? 'open' : restricted ? 'restricted' : 'undeclared';
|
|
118
|
+
const access = entries.map((e) => accessOfEntry(e, resolver));
|
|
119
|
+
f.reach = access.includes('open') ? 'open' : !f.startedByMore && access.every((a) => a === 'restricted') ? 'restricted' : 'undeclared';
|
|
103
120
|
by[f.reach]++;
|
|
104
121
|
}
|
|
105
122
|
return by;
|
|
@@ -113,7 +130,7 @@ export function stampEffect(findings, resolver) {
|
|
|
113
130
|
for (const f of findings) {
|
|
114
131
|
const entries = f.startedBy || [];
|
|
115
132
|
if (!entries.length) continue;
|
|
116
|
-
if (entries.some((e) => (e
|
|
133
|
+
if (entries.some((e) => entryKind(e) && resolver.privilegedOf(entryKind(e), entryKey(e)))) {
|
|
117
134
|
f.effect = 'privileged';
|
|
118
135
|
by.privileged++;
|
|
119
136
|
}
|
package/lib/revision.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"b144074728e1b40f1c6a230b06f1a6b8b0758d51"}
|
package/lib/sarif.mjs
CHANGED
|
@@ -211,6 +211,7 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
|
|
|
211
211
|
// Who can drive the finding and what it runs as, when the estate declared the facts.
|
|
212
212
|
...(f.reach ? { reach: f.reach } : {}),
|
|
213
213
|
...(f.effect ? { effect: f.effect } : {}),
|
|
214
|
+
...(f.exploitability ? { exploitability: f.exploitability } : {}),
|
|
214
215
|
} } : {}),
|
|
215
216
|
locations: [{ physicalLocation: { artifactLocation: { uri: uriOf(f.path), uriBaseId: '%SRCROOT%' }, region: { startLine: f.line || 1 } } }],
|
|
216
217
|
...(f.related ? { relatedLocations: f.related.map((r) => ({ physicalLocation: { artifactLocation: { uri: uriOf(r.path), uriBaseId: '%SRCROOT%' }, region: { startLine: r.line || 1 } }, message: { text: textOf(r.detail) } })) } : {}),
|
package/lib/sbom.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A CycloneDX 1.6 bill of materials for a COBOL estate (docs/spec/evidence.md §11): every source
|
|
3
|
+
// file by digest, what each program copies and calls, what each job runs, and the platforms a
|
|
4
|
+
// program uses, without versions the source does not state.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, extname, resolve, sep } from 'node:path';
|
|
8
|
+
import { parseSource, detectFormat } from './parser.mjs';
|
|
9
|
+
import { parseJcl } from './jcl.mjs';
|
|
10
|
+
import { optionCards } from './options.mjs';
|
|
11
|
+
import { inScope, isBms, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
|
|
12
|
+
import { TOOL_VERSION } from './version.mjs';
|
|
13
|
+
import { treeFor } from './kernel/source-tree.mjs';
|
|
14
|
+
|
|
15
|
+
// The namespace of cobolwork's version 5 UUIDs (RFC 4122 §4.3), fixed so a serial number repeats.
|
|
16
|
+
const NAMESPACE = Buffer.from('6f1d2b8e5c3a4e7f9a0b1c2d3e4f5a6b', 'hex');
|
|
17
|
+
|
|
18
|
+
function uuidv5(name) {
|
|
19
|
+
const h = createHash('sha1').update(NAMESPACE).update(name, 'utf8').digest().subarray(0, 16);
|
|
20
|
+
h[6] = (h[6] & 0x0f) | 0x50;
|
|
21
|
+
h[8] = (h[8] & 0x3f) | 0x80;
|
|
22
|
+
const x = h.toString('hex');
|
|
23
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
27
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
28
|
+
|
|
29
|
+
function platformsOf(src, calls) {
|
|
30
|
+
const s = new Set();
|
|
31
|
+
if (/EXEC\s+CICS\b/i.test(src)) s.add('CICS');
|
|
32
|
+
if (/EXEC\s+SQL\b/i.test(src)) s.add('Db2');
|
|
33
|
+
if (/EXEC\s+DLI\b/i.test(src)) s.add('IMS');
|
|
34
|
+
for (const c of calls) {
|
|
35
|
+
if (c.kind !== 'L') continue;
|
|
36
|
+
const n = String(c.name).toUpperCase();
|
|
37
|
+
if (n === 'CBLTDLI' || n === 'AIBTDLI') s.add('IMS');
|
|
38
|
+
if (/^MQ[A-Z]{2,}$/.test(n)) s.add('MQ');
|
|
39
|
+
if (/^CEE[A-Z0-9]+$/.test(n)) s.add('Language Environment');
|
|
40
|
+
}
|
|
41
|
+
return [...s];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function kindOf(file, src) {
|
|
45
|
+
if (isProgram(file)) return /PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src) ? 'program' : 'copybook';
|
|
46
|
+
if (isCopybook(file)) return 'copybook';
|
|
47
|
+
if (isJcl(file)) return /^\/\/\S*\s+PROC\b/m.test(src) && !/^\/\/\S*\s+JOB\b/m.test(src) ? 'proc' : 'jcl';
|
|
48
|
+
if (isBms(file)) return 'bms';
|
|
49
|
+
const ext = extname(file).toLowerCase();
|
|
50
|
+
if (ext === '.csd') return 'csd';
|
|
51
|
+
if (ext === '.ddl' || ext === '.sql') return 'ddl';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sbom(root, opts = {}) {
|
|
56
|
+
const tree = treeFor(root, opts);
|
|
57
|
+
const idx = tree.index;
|
|
58
|
+
const top = resolve(root);
|
|
59
|
+
const within = (p) => resolve(p).startsWith(top + sep);
|
|
60
|
+
const refOf = (p) => (within(p) ? relPath(root, p) : `copylib:${basename(p)}`);
|
|
61
|
+
|
|
62
|
+
const entries = [];
|
|
63
|
+
const programIds = new Map();
|
|
64
|
+
for (const f of tree.list().filter(inScope(opts)).sort()) {
|
|
65
|
+
let bytes;
|
|
66
|
+
let src;
|
|
67
|
+
try { bytes = readFileSync(f); src = readSource(f).text; } catch { continue; }
|
|
68
|
+
const kind = kindOf(f, src);
|
|
69
|
+
if (!kind) continue;
|
|
70
|
+
const entry = { file: f, ref: relPath(root, f), bytes, src, kind };
|
|
71
|
+
if (kind === 'program') {
|
|
72
|
+
try {
|
|
73
|
+
const res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
|
|
74
|
+
entry.programs = res.programs;
|
|
75
|
+
entry.copies = res.copies;
|
|
76
|
+
for (const p of res.programs) if (p.id) programIds.set(String(p.id).toUpperCase(), entry.ref);
|
|
77
|
+
} catch { entry.unparsed = true; }
|
|
78
|
+
} else if (kind === 'jcl' || kind === 'proc') {
|
|
79
|
+
try { entry.jcl = parseJcl(src, f); } catch { entry.unparsed = true; }
|
|
80
|
+
}
|
|
81
|
+
entries.push(entry);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const components = new Map();
|
|
85
|
+
const dependencies = new Map();
|
|
86
|
+
const depend = (from, to) => { if (!dependencies.has(from)) dependencies.set(from, new Set()); dependencies.get(from).add(to); };
|
|
87
|
+
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
const properties = [{ name: 'cobolwork:kind', value: e.kind }];
|
|
90
|
+
if (e.unparsed) properties.push({ name: 'cobolwork:unparsed', value: 'true' });
|
|
91
|
+
if (e.kind === 'program' && e.programs && e.programs.length) {
|
|
92
|
+
const ids = e.programs.map((p) => p.id).filter(Boolean);
|
|
93
|
+
if (ids.length) properties.push({ name: 'cobolwork:program-id', value: ids.join(',') });
|
|
94
|
+
properties.push({ name: 'cobolwork:format', value: detectFormat(e.src) });
|
|
95
|
+
const options = optionCards(e.src).flatMap((c) => c.options);
|
|
96
|
+
if (options.length) properties.push({ name: 'cobolwork:options', value: options.join(' ') });
|
|
97
|
+
for (const c of e.copies || []) {
|
|
98
|
+
if (c.status !== 'resolved' || !c.path) continue;
|
|
99
|
+
const ref = refOf(c.path);
|
|
100
|
+
depend(e.ref, ref);
|
|
101
|
+
if (!within(c.path) && !components.has(ref)) {
|
|
102
|
+
let b = null;
|
|
103
|
+
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' }] });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const calls = e.programs.flatMap((p) => p.calls || []);
|
|
108
|
+
const unresolved = new Set();
|
|
109
|
+
for (const c of calls) {
|
|
110
|
+
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);
|
|
113
|
+
}
|
|
114
|
+
if (unresolved.size) properties.push({ name: 'cobolwork:unresolved-call', value: [...unresolved].sort(order).join(',') });
|
|
115
|
+
for (const p of platformsOf(e.src, calls)) {
|
|
116
|
+
const ref = `platform:${p}`;
|
|
117
|
+
depend(e.ref, ref);
|
|
118
|
+
if (!components.has(ref)) components.set(ref, { 'bom-ref': ref, type: 'platform', name: p });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (e.jcl) {
|
|
122
|
+
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);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
components.set(e.ref, { 'bom-ref': e.ref, type: 'file', name: e.ref, hashes: [{ alg: 'SHA-256', content: sha256(e.bytes) }], properties });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const list = [...components.values()].sort((a, b) => order(a['bom-ref'], b['bom-ref']));
|
|
131
|
+
const digests = list.filter((c) => c.hashes).map((c) => c.hashes[0].content).sort(order);
|
|
132
|
+
const name = opts.name || basename(top);
|
|
133
|
+
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
134
|
+
return {
|
|
135
|
+
bomFormat: 'CycloneDX',
|
|
136
|
+
specVersion: '1.6',
|
|
137
|
+
serialNumber: `urn:uuid:${uuidv5(`${name}\n${digests.join('\n')}`)}`,
|
|
138
|
+
version: 1,
|
|
139
|
+
metadata: {
|
|
140
|
+
...(epoch && /^\d+$/.test(epoch) ? { timestamp: new Date(Number(epoch) * 1000).toISOString() } : {}),
|
|
141
|
+
tools: { components: [{ type: 'application', name: 'cobolwork', version: TOOL_VERSION }] },
|
|
142
|
+
component: { 'bom-ref': `estate:${name}`, type: 'application', name },
|
|
143
|
+
},
|
|
144
|
+
components: list,
|
|
145
|
+
dependencies: [...dependencies.entries()].sort(([a], [b]) => order(a, b)).map(([ref, set]) => ({ ref, dependsOn: [...set].sort(order) })),
|
|
146
|
+
};
|
|
147
|
+
}
|
package/lib/scan.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { sortFindings, tally, evidenceMap, EVIDENCE } from './kernel/findings.mjs';
|
|
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';
|
|
6
6
|
import { inventory } from './inventory.mjs';
|
|
@@ -12,6 +12,7 @@ import { printable } from './kernel/printable.mjs';
|
|
|
12
12
|
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
|
+
import { stampExploitability, byVerdict, HANDLING, witnessFeedPaths, loadWitnessFeed, applyWitness } from './exploitability.mjs';
|
|
15
16
|
|
|
16
17
|
import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
|
|
17
18
|
|
|
@@ -34,11 +35,46 @@ const underRepo = (repo, p) => (repo ? `${repo}/${p}` : p);
|
|
|
34
35
|
// Frameworks that did not load; a reason can quote the unparsed file.
|
|
35
36
|
const COMPLIANCE_UNLOADED = FRAMEWORKS_UNLOADED.map((f) => ({ id: f.id, why: printable(f.why) }));
|
|
36
37
|
|
|
38
|
+
// The estate facts of several repositories' reports as one summary: counts add, feeds are the same
|
|
39
|
+
// for every repository so the first names them, and a note says which repositories it covers.
|
|
40
|
+
function mergeEstate(reports) {
|
|
41
|
+
const all = reports.map(({ repo, r }) => ({ repo, s: r.summary }));
|
|
42
|
+
const addUp = (k) => {
|
|
43
|
+
const have = all.filter((x) => x.s[k]);
|
|
44
|
+
if (!have.length) return null;
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const { s } of have) for (const [f, n] of Object.entries(s[k])) out[f] = (out[f] || 0) + n;
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
const union = (k) => [...new Set(all.flatMap((x) => x.s[k] || []))];
|
|
50
|
+
const first = (k) => all.find((x) => x.s[k])?.s[k];
|
|
51
|
+
const without = (k) => all.filter((x) => x.s[k]).map((x) => x.repo);
|
|
52
|
+
const of = (names) => `${names.length} of ${all.length} repositories (${names.join(', ')})`;
|
|
53
|
+
const reachless = without('reachNote');
|
|
54
|
+
const effectless = without('effectNote');
|
|
55
|
+
const driven = all.filter((x) => x.s.reachNote).reduce((n, x) => n + (x.s.byExploitability?.['attacker-driven'] || 0), 0);
|
|
56
|
+
// A result is unmatched only where no repository's finding carries it.
|
|
57
|
+
const unmatchedIn = all.map((x) => new Set(x.s.witnessUnmatched || []));
|
|
58
|
+
const unmatched = first('witnessFeeds') ? [...unmatchedIn[0]].filter((fp) => unmatchedIn.every((u) => u.has(fp))) : [];
|
|
59
|
+
const overruled = all.flatMap((x) => (x.s.witnessOverruled || []).map((o) => ({ ...o, path: underRepo(x.repo, o.path) })));
|
|
60
|
+
const merged = {
|
|
61
|
+
byReach: addUp('byReach'), byEffect: addUp('byEffect'),
|
|
62
|
+
reachFeeds: first('reachFeeds'), reachFeedProblems: union('reachFeedProblems'),
|
|
63
|
+
reachNote: reachless.length ? `no reachability facts declared in ${of(reachless)}, so no finding there was judged reachable${driven ? `, and the ${driven} an attacker drives are labelled attacker-driven rather than exploitable` : ''}: name open/restricted transactions and jobs in each repository's cobolwork.site.json, or bring a COBOLWORK_REACH extract` : null,
|
|
64
|
+
effectNote: effectless.length ? `no authority facts declared in ${of(effectless)}, so no finding there was judged to run privileged: name privilegedTransactions/privilegedJobs in each repository's cobolwork.site.json, or bring them in the COBOLWORK_REACH extract` : null,
|
|
65
|
+
witnessFeeds: first('witnessFeeds'), byWitness: addUp('byWitness'), witnessFeedProblems: union('witnessFeedProblems'),
|
|
66
|
+
witnessUnmatched: unmatched, witnessOverruled: overruled,
|
|
67
|
+
};
|
|
68
|
+
return Object.fromEntries(Object.entries(merged).filter(([, v]) => (Array.isArray(v) ? v.length : v)));
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
function scanEach(root, opts) {
|
|
38
|
-
|
|
72
|
+
// A feed is refused anywhere inside the scanned root, not only inside the repository being read.
|
|
73
|
+
const reports = opts.repos.map(repo => ({ repo, r: scanAll(join(root, repo), { ...opts, repos: null, repoName: repo, feedRoot: opts.feedRoot || root }) }));
|
|
39
74
|
const findings = [];
|
|
40
75
|
const checked = [];
|
|
41
|
-
const
|
|
76
|
+
const fixedAt = (repo, x) => (x?.fixAt ? { exploitability: { ...x, fixAt: { ...x.fixAt, path: underRepo(repo, x.fixAt.path) } } } : {});
|
|
77
|
+
const prefixed = (repo, f) => ({ ...f, path: underRepo(repo, f.path), ...(f.related ? { related: f.related.map(x => ({ ...x, path: underRepo(repo, x.path) })) } : {}), ...fixedAt(repo, f.exploitability) });
|
|
42
78
|
for (const { repo, r } of reports) {
|
|
43
79
|
for (const f of r.findings) findings.push(prefixed(repo, f));
|
|
44
80
|
for (const f of r.checked || []) checked.push(prefixed(repo, f));
|
|
@@ -51,7 +87,9 @@ function scanEach(root, opts) {
|
|
|
51
87
|
for (const f of findings) bySeverity[f.sev] = (bySeverity[f.sev] || 0) + 1;
|
|
52
88
|
const byEvidence = {};
|
|
53
89
|
for (const f of findings) byEvidence[f.evidence] = (byEvidence[f.evidence] || 0) + 1;
|
|
54
|
-
const
|
|
90
|
+
const byExploitability = byVerdict(findings);
|
|
91
|
+
const perRepo = Object.fromEntries(reports.map(({ repo, r }) => [repo, { findings: r.summary.findings, filesScanned: r.summary.filesScanned, coverageIncomplete: r.summary.coverageIncomplete, nosrc: r.summary.nosrc,
|
|
92
|
+
...(r.summary.byExploitability ? { byExploitability: r.summary.byExploitability } : {}) }]));
|
|
55
93
|
const filesScanned = sum('filesScanned');
|
|
56
94
|
return {
|
|
57
95
|
tool: 'cobolwork',
|
|
@@ -68,6 +106,9 @@ function scanEach(root, opts) {
|
|
|
68
106
|
...(reports[0]?.r.summary.advisoryCoverage ? { advisoryCoverage: reports[0].r.summary.advisoryCoverage } : {}),
|
|
69
107
|
// The schema has no repository field for a set, so the repository leads the reason.
|
|
70
108
|
setsIncomplete: reports.flatMap(({ repo, r }) => (r.summary.setsIncomplete || []).map((x) => ({ ...x, why: `${repo}: ${x.why}` }))),
|
|
109
|
+
...mergeEstate(reports),
|
|
110
|
+
...(byExploitability ? { byExploitability } : {}),
|
|
111
|
+
...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
|
|
71
112
|
},
|
|
72
113
|
findings,
|
|
73
114
|
...(checked.length ? { checked } : {}),
|
|
@@ -77,6 +118,7 @@ function scanEach(root, opts) {
|
|
|
77
118
|
ruleImpact: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.impact).map(([k, v]) => [k, v.impact])),
|
|
78
119
|
ruleRemedy: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.remedy).map(([k, v]) => [k, v.remedy])),
|
|
79
120
|
evidence: EVIDENCE,
|
|
121
|
+
exploitabilityVerdicts: EXPLOITABILITY,
|
|
80
122
|
// Which clause each rule bears on, and what the mapping does not claim. A reader who takes
|
|
81
123
|
// this to an auditor needs both halves.
|
|
82
124
|
ruleCompliance: complianceMap(Object.keys(ALL_RULES)),
|
|
@@ -85,6 +127,50 @@ function scanEach(root, opts) {
|
|
|
85
127
|
};
|
|
86
128
|
}
|
|
87
129
|
|
|
130
|
+
// Who can reach each finding and what it runs as, from the estate's own facts - a brought
|
|
131
|
+
// COBOLWORK_REACH extract, or the keys in cobolwork.site.json - never from the source, and the
|
|
132
|
+
// exploitability verdict those join into with the route. Returns the summary keys that report them.
|
|
133
|
+
// Reach and effect annotate rather than re-rank; where no fact is declared, a note says so rather
|
|
134
|
+
// than the report reading as a clean bill.
|
|
135
|
+
export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
136
|
+
const site = loadSite(root, opts.site || null);
|
|
137
|
+
const feedRoot = opts.feedRoot || root;
|
|
138
|
+
const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root: feedRoot }));
|
|
139
|
+
const reach = reachResolver({ feeds: reachFeeds, site });
|
|
140
|
+
const withEntries = findings.some((f) => (f.startedBy || []).length);
|
|
141
|
+
const byReach = reach.declared ? stampReach(findings, reach) : null;
|
|
142
|
+
const byEffect = reach.effectDeclared ? stampEffect(findings, reach) : null;
|
|
143
|
+
// A rule that escalates only under elevated authority takes its higher severity where the estate
|
|
144
|
+
// says the entry that reaches it runs privileged.
|
|
145
|
+
for (const f of findings) {
|
|
146
|
+
const raise = ALL_RULES[f.rule]?.whenPrivileged;
|
|
147
|
+
if (raise && f.effect === 'privileged') f.sev = raise;
|
|
148
|
+
}
|
|
149
|
+
stampExploitability(findings, reach, checked);
|
|
150
|
+
const witnessFeeds = witnessFeedPaths(opts).map((p) => loadWitnessFeed(p, { root: feedRoot }));
|
|
151
|
+
const witnessed = applyWitness(findings, checked, witnessFeeds);
|
|
152
|
+
if (witnessed.overruled.length) sortFindings(findings);
|
|
153
|
+
const byExploitability = byVerdict(findings);
|
|
154
|
+
const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
|
|
155
|
+
const loaded = witnessFeeds.filter((f) => !f.problem);
|
|
156
|
+
return {
|
|
157
|
+
...(byReach ? { byReach } : {}),
|
|
158
|
+
...(byEffect ? { byEffect } : {}),
|
|
159
|
+
...(reachFeeds.some(f => !f.problem) ? { reachFeeds: reachFeeds.filter(f => !f.problem).map(f => ({ file: f.file, extract: f.extract, retrieved: f.retrieved })) } : {}),
|
|
160
|
+
...(reachFeeds.some(f => f.problem) ? { reachFeedProblems: reachFeeds.filter(f => f.problem).map(f => `${f.file}: ${f.problem}`) } : {}),
|
|
161
|
+
...(!reach.declared && withEntries ? { reachNote: `no reachability facts declared, so no finding was judged reachable${driven ? `, and the ${driven} an attacker drives are labelled attacker-driven rather than exploitable` : ''}: name open/restricted transactions and jobs in cobolwork.site.json, or bring a COBOLWORK_REACH extract` } : {}),
|
|
162
|
+
...(!reach.effectDeclared && withEntries ? { effectNote: 'no authority facts declared, so no finding was judged to run privileged: name privilegedTransactions/privilegedJobs in cobolwork.site.json, or bring them in the COBOLWORK_REACH extract' } : {}),
|
|
163
|
+
...(byExploitability ? { byExploitability } : {}),
|
|
164
|
+
...(byExploitability?.exploitable || byExploitability?.confirmed ? { handling: HANDLING } : {}),
|
|
165
|
+
...(loaded.length ? { witnessFeeds: loaded.map((f) => ({ file: f.file, witness: f.witness, recorded: f.recorded })), byWitness: witnessed.byWitness } : {}),
|
|
166
|
+
...(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}`))) } : {}),
|
|
167
|
+
// A result no path finding carries: the code changed since the test, or the route is gone.
|
|
168
|
+
...(witnessed.unmatched.length ? { witnessUnmatched: witnessed.unmatched } : {}),
|
|
169
|
+
// Routes the reading refuted that the estate reproduced: the check model was wrong about them.
|
|
170
|
+
...(witnessed.overruled.length ? { witnessOverruled: witnessed.overruled } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
88
174
|
// A set that throws is reported as not having run, and the other sets' findings stand.
|
|
89
175
|
function runSet(set, root, o) {
|
|
90
176
|
try { return set.scan(root, o); } catch (e) {
|
|
@@ -153,21 +239,7 @@ export function scanAll(root, opts = {}) {
|
|
|
153
239
|
const checked = parts.find((p) => p.tool === 'cobolwork-flow')?.checked || [];
|
|
154
240
|
stampFingerprints(checked, { root, repo: opts.repoName || '' });
|
|
155
241
|
|
|
156
|
-
|
|
157
|
-
// or restricted, from the estate's own facts - a brought COBOLWORK_REACH extract, or the
|
|
158
|
-
// open/restricted keys in cobolwork.site.json - never from the source. Undeclared until it says.
|
|
159
|
-
const site = loadSite(root, opts.site || null);
|
|
160
|
-
const reachFeeds = reachFeedPaths(opts).map((p) => loadReachFeed(p, { root }));
|
|
161
|
-
const reach = reachResolver({ feeds: reachFeeds, site });
|
|
162
|
-
const withEntries = findings.some((f) => (f.startedBy || []).length);
|
|
163
|
-
const byReach = reach.declared ? stampReach(findings, reach) : null;
|
|
164
|
-
const byEffect = reach.effectDeclared ? stampEffect(findings, reach) : null;
|
|
165
|
-
// A rule that escalates only under elevated authority takes its higher severity where the estate
|
|
166
|
-
// says the entry that reaches it runs privileged.
|
|
167
|
-
for (const f of findings) {
|
|
168
|
-
const raise = ALL_RULES[f.rule]?.whenPrivileged;
|
|
169
|
-
if (raise && f.effect === 'privileged') f.sev = raise;
|
|
170
|
-
}
|
|
242
|
+
const estate = applyEstateFacts(findings, checked, root, opts);
|
|
171
243
|
|
|
172
244
|
const byRule = tally(findings);
|
|
173
245
|
const bySeverity = {};
|
|
@@ -200,14 +272,7 @@ export function scanAll(root, opts = {}) {
|
|
|
200
272
|
...(parts.find(p => p.tool === 'cobolwork-build')?.summary.advisoryCoverage ? { advisoryCoverage: parts.find(p => p.tool === 'cobolwork-build').summary.advisoryCoverage } : {}),
|
|
201
273
|
crossProgram: parts.find(p => p.tool === 'cobolwork-flow')?.summary.crossProgram ?? 0,
|
|
202
274
|
...(parts.find(p => p.tool === 'cobolwork-flow')?.summary.entryPoints?.roots ? rollup(findings) : {}),
|
|
203
|
-
|
|
204
|
-
// no fact is declared, the report says so rather than reading as a clean bill.
|
|
205
|
-
...(byReach ? { byReach } : {}),
|
|
206
|
-
...(byEffect ? { byEffect } : {}),
|
|
207
|
-
...(reachFeeds.some(f => !f.problem) ? { reachFeeds: reachFeeds.filter(f => !f.problem).map(f => ({ file: f.file, extract: f.extract, retrieved: f.retrieved })) } : {}),
|
|
208
|
-
...(reachFeeds.some(f => f.problem) ? { reachFeedProblems: reachFeeds.filter(f => f.problem).map(f => `${f.file}: ${f.problem}`) } : {}),
|
|
209
|
-
...(!reach.declared && withEntries ? { reachNote: 'no reachability facts declared, so no finding was judged reachable: name open/restricted transactions and jobs in cobolwork.site.json, or bring a COBOLWORK_REACH extract' } : {}),
|
|
210
|
-
...(!reach.effectDeclared && withEntries ? { effectNote: 'no authority facts declared, so no finding was judged to run privileged: name privilegedTransactions/privilegedJobs in cobolwork.site.json, or bring them in the COBOLWORK_REACH extract' } : {}),
|
|
275
|
+
...estate,
|
|
211
276
|
ruleSets: only,
|
|
212
277
|
// Which flow model produced these rows, and which cobolwork. A finding that appears or
|
|
213
278
|
// disappears between two reports is attributable to the model rather than to the code scanned.
|
|
@@ -260,6 +325,7 @@ export function scanAll(root, opts = {}) {
|
|
|
260
325
|
ruleImpact: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.impact).map(([k, v]) => [k, v.impact])),
|
|
261
326
|
ruleRemedy: Object.fromEntries(Object.entries(ALL_RULES).filter(([, v]) => v.remedy).map(([k, v]) => [k, v.remedy])),
|
|
262
327
|
evidence: EVIDENCE,
|
|
328
|
+
exploitabilityVerdicts: EXPLOITABILITY,
|
|
263
329
|
// Which clause each rule bears on, and what the mapping does not claim. A reader who takes
|
|
264
330
|
// this to an auditor needs both halves.
|
|
265
331
|
ruleCompliance: complianceMap(Object.keys(ALL_RULES)),
|
package/lib/sets/cics.mjs
CHANGED
|
@@ -13,6 +13,24 @@ import { parseCsd } from '../csd.mjs';
|
|
|
13
13
|
// must act on from one that only wants a look; `remedy` is the standard fix, the same for every
|
|
14
14
|
// instance. Both ride with every finding of the rule (as ruleImpact/ruleRemedy).
|
|
15
15
|
export const CICS_RULES = {
|
|
16
|
+
'program-checks-stored-password': {
|
|
17
|
+
sev: 'med', evidence: 'construct', cwe: 'CWE-256',
|
|
18
|
+
text: 'A program compares a password it reads back from its own file or table',
|
|
19
|
+
impact: 'The password is kept where the application reads it, so whoever can read that file or table - a developer, a backup, an unload - holds every user\'s password, and the program, not the security manager, decides who signs on',
|
|
20
|
+
remedy: 'Verify the password with EXEC CICS VERIFY PASSWORD or SIGNON, so RACF checks it and the application never stores it',
|
|
21
|
+
},
|
|
22
|
+
'password-case-folded-before-compare': {
|
|
23
|
+
sev: 'low', evidence: 'construct', cwe: 'CWE-178',
|
|
24
|
+
text: 'A sign-on folds a password\'s case before comparing it',
|
|
25
|
+
impact: 'A password that matches in either case has fewer values to guess, and the program accepts a password its user never typed',
|
|
26
|
+
remedy: 'Compare the password as typed, or leave the comparison to the security manager with EXEC CICS VERIFY PASSWORD',
|
|
27
|
+
},
|
|
28
|
+
'cics-condition-ignored': {
|
|
29
|
+
sev: 'low', evidence: 'construct', cwe: 'CWE-252',
|
|
30
|
+
text: 'A program issues CICS commands whose failure it cannot see: NOHANDLE with no RESP, or IGNORE CONDITION',
|
|
31
|
+
impact: 'A command that fails - a record not found, a queue full, storage not available - returns to the next statement as if it had worked, so the program acts on an area the command never filled',
|
|
32
|
+
remedy: 'Give each command RESP and test it, or let the condition reach a HANDLE CONDITION that deals with it; keep NOHANDLE for commands whose failure is harmless',
|
|
33
|
+
},
|
|
16
34
|
'cics-commarea-without-length-check': {
|
|
17
35
|
sev: 'high', evidence: 'construct', cwe: 'CWE-1284',
|
|
18
36
|
text: 'A program reads its communication area without checking EIBCALEN',
|
|
@@ -118,6 +136,38 @@ function execOptions(exec) {
|
|
|
118
136
|
|
|
119
137
|
const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
|
|
120
138
|
|
|
139
|
+
// Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
|
|
140
|
+
// not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
|
|
141
|
+
// have gone, an ASSIGN, and the RETURN that ends the task anyway.
|
|
142
|
+
const QUIET_FAILURES = (words) => (words[0] === 'DELETEQ' && words[1] === 'TS') || (words[0] === 'WRITEQ' && words[1] === 'TD')
|
|
143
|
+
|| ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(words[0]);
|
|
144
|
+
const SHOWN_IGNORED = 5;
|
|
145
|
+
|
|
146
|
+
// Commands whose outcome the program cannot see: NOHANDLE with no RESP and no EIBRESP test before the
|
|
147
|
+
// next command, and IGNORE CONDITION. One finding per program, at the first, since the count is what
|
|
148
|
+
// a reviewer weighs and the fix is the same at each.
|
|
149
|
+
function conditionsIgnored(p, path) {
|
|
150
|
+
const execs = p.execs.filter((e) => e.kind === 'CICS').sort((a, b) => a.line - b.line);
|
|
151
|
+
const hits = [];
|
|
152
|
+
execs.forEach((e, k) => {
|
|
153
|
+
const { opts: o, words } = execOptions(e);
|
|
154
|
+
if (words[0] === 'IGNORE' && words[1] === 'CONDITION') {
|
|
155
|
+
hits.push({ e, what: `IGNORE CONDITION ${words.slice(2).join(' ')}`.trim() });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(words)) return;
|
|
159
|
+
const until = execs[k + 1] ? execs[k + 1].line : Infinity;
|
|
160
|
+
const tested = p.statements.some((st) => st.line > e.line && st.line < until && (st.sources || []).some((t) => t.t === 'word' && t.u === 'EIBRESP'));
|
|
161
|
+
if (!tested) hits.push({ e, what: `${words[0]}${words[1] && words[1] !== 'NOHANDLE' && !o.has(words[1]) ? ` ${words[1]}` : ''} NOHANDLE` });
|
|
162
|
+
});
|
|
163
|
+
if (!hits.length) return null;
|
|
164
|
+
const shown = hits.slice(0, SHOWN_IGNORED).map((h) => `${h.what} at line ${h.e.line}`).join(', ');
|
|
165
|
+
return {
|
|
166
|
+
rule: 'cics-condition-ignored', path, line: hits[0].e.line, program: p.id,
|
|
167
|
+
detail: `${p.id} leaves ${hits.length} place${hits.length === 1 ? '' : 's'} where a CICS command's failure goes unseen: ${shown}${hits.length > SHOWN_IGNORED ? `, and ${hits.length - SHOWN_IGNORED} more` : ''}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
121
171
|
// Sizes come from the parser's data-division layout, which is graded against the compiler, so a
|
|
122
172
|
// length compared here is the length the compiler would compute.
|
|
123
173
|
//
|
|
@@ -194,6 +244,67 @@ export function scanCics(root, opts = {}) {
|
|
|
194
244
|
}
|
|
195
245
|
|
|
196
246
|
// Where a program sends control after its sign-on succeeds, and every other route there.
|
|
247
|
+
// A sign-on that compares a password the program read back from its own file or table, rather than
|
|
248
|
+
// asking the security manager, and one that folds a password's case first. Passwords are recognised
|
|
249
|
+
// by field name, as the sign-on rules above recognise them, and each finding says so.
|
|
250
|
+
function passwordChecks(p, path) {
|
|
251
|
+
if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execOptions(e).words[0] || ''))) return [];
|
|
252
|
+
const byName = new Map();
|
|
253
|
+
for (const it of p.items) if (!byName.has(it.name)) byName.set(it.name, it);
|
|
254
|
+
const resolve = (tok) => (tok && tok.t === 'word' ? p.resolved.get(tok) || byName.get(tok.u) || null : null);
|
|
255
|
+
const tests = [];
|
|
256
|
+
try { buildControl(p, resolve, { extra: (t) => { const r = signOnTest(t); if (r) tests.push(t); return r; } }); } catch { return []; }
|
|
257
|
+
if (!tests.length) return [];
|
|
258
|
+
// What the program reads back: an FD record, the INTO of a CICS READ, a host variable a SELECT or
|
|
259
|
+
// FETCH fills, and everything under any of them. A copy made by MOVE first is not followed.
|
|
260
|
+
const read = new Set();
|
|
261
|
+
for (const e of p.execs) {
|
|
262
|
+
const { opts: o, words } = execOptions(e);
|
|
263
|
+
if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(words[0] || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
|
|
264
|
+
if (e.kind === 'SQL' && (e.hostVariables || []).length) for (const h of e.hostVariables) if (h.written && h.tok) read.add(h.tok.u);
|
|
265
|
+
}
|
|
266
|
+
const stored = (name) => {
|
|
267
|
+
for (let it = byName.get(name); it; it = it.parent) if (it.section === 'FILE' || read.has(it.name)) return true;
|
|
268
|
+
return false;
|
|
269
|
+
};
|
|
270
|
+
const out = [];
|
|
271
|
+
const FOLD = new Set(['UPPER-CASE', 'LOWER-CASE']);
|
|
272
|
+
for (const t of tests) {
|
|
273
|
+
const cond = t.cond || (t.evaluate && t.evaluate.conds[0]) || [];
|
|
274
|
+
const names = cond.filter((x, k) => x.t === 'word' && byName.has(x.u) && !(cond[k - 1] && /^(OF|IN)$/.test(cond[k - 1].u))).map((x) => x.u);
|
|
275
|
+
const kept = names.find(stored);
|
|
276
|
+
const line = t.st ? t.st.line : 1;
|
|
277
|
+
if (kept) {
|
|
278
|
+
out.push({
|
|
279
|
+
rule: 'program-checks-stored-password', path, line, program: p.id,
|
|
280
|
+
detail: `${p.id} compares ${names.join(' with ')}, and ${kept} is read back from the program's own file or table, so the password is stored where the application can read it; the password is recognised by its field name`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (cond.some((x) => x.t === 'word' && FOLD.has(x.u))) {
|
|
284
|
+
out.push({
|
|
285
|
+
rule: 'password-case-folded-before-compare', path, line, program: p.id,
|
|
286
|
+
detail: `${p.id} folds the case of ${names.join(' or ')} inside its sign-on comparison, so a password matches in either case; the password is recognised by its field name`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Case folded before the comparison: a MOVE of FUNCTION UPPER-CASE or LOWER-CASE of a password
|
|
291
|
+
// field, or an INSPECT that converts one.
|
|
292
|
+
const toks = (p.proc && p.proc.tokens) || [];
|
|
293
|
+
for (const st of p.statements || []) {
|
|
294
|
+
const seg = toks.slice(st.at + 1, st.end ?? st.at + 1);
|
|
295
|
+
const folds = st.verb === 'MOVE' ? seg.some((x) => x.t === 'word' && FOLD.has(x.u))
|
|
296
|
+
: st.verb === 'INSPECT' && seg.some((x) => x.t === 'word' && x.u === 'CONVERTING');
|
|
297
|
+
if (!folds) continue;
|
|
298
|
+
const pw = seg.find((x) => x.t === 'word' && PASSWORD.test(x.u) && byName.has(x.u));
|
|
299
|
+
if (!pw || out.some((o) => o.rule === 'password-case-folded-before-compare' && o.line === st.line)) continue;
|
|
300
|
+
out.push({
|
|
301
|
+
rule: 'password-case-folded-before-compare', path, line: st.line, program: p.id,
|
|
302
|
+
detail: `${p.id} folds the case of ${pw.u} with ${st.verb === 'MOVE' ? 'FUNCTION UPPER-CASE or LOWER-CASE' : 'INSPECT CONVERTING'} in a program that compares passwords, so a password matches in either case; the password is recognised by its field name`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
197
308
|
function signOnBypasses(p, path) {
|
|
198
309
|
let ctl;
|
|
199
310
|
const byName = new Map();
|
|
@@ -238,6 +349,9 @@ export function scanCics(root, opts = {}) {
|
|
|
238
349
|
stats.filesScanned++;
|
|
239
350
|
const path = relPath(root, f);
|
|
240
351
|
for (const p of r.programs) {
|
|
352
|
+
const ignored = conditionsIgnored(p, path);
|
|
353
|
+
if (ignored) findings.push(ignored);
|
|
354
|
+
if (p.refs.some((x) => PASSWORD.test(x.tok.u))) findings.push(...passwordChecks(p, path));
|
|
241
355
|
const sum = summarise(p, path);
|
|
242
356
|
summaries.push(sum);
|
|
243
357
|
if (p.id && !commareaSize.has(p.id)) commareaSize.set(p.id, sum.commarea ? sum.commarea.size : null);
|