@portll/cobolwork 0.2.75 → 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 +69 -15
- 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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// The evidence directory on disk: where it may be, how its files are opened (never through a
|
|
3
|
+
// symbolic link, owner-only), the ledger lock, and reading a chain file back line by line.
|
|
4
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const { O_WRONLY, O_CREAT, O_EXCL, O_APPEND, O_NOFOLLOW } = constants;
|
|
8
|
+
|
|
9
|
+
export class EvidenceRefusal extends Error {
|
|
10
|
+
constructor(message) { super(message); this.code = 'EEVIDENCE'; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
14
|
+
|
|
15
|
+
// The directory named by --evidence or COBOLWORK_EVIDENCE, or null when neither is set.
|
|
16
|
+
export function evidenceDirFrom(opts, env = process.env) {
|
|
17
|
+
const named = opts.evidence || env.COBOLWORK_EVIDENCE || null;
|
|
18
|
+
return named ? resolve(named) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Creates the directory (0700) and its runs/ and seals/, and refuses a link anywhere on that
|
|
22
|
+
// path or a directory inside a tree this run reads.
|
|
23
|
+
export function prepareDir(dir, roots = []) {
|
|
24
|
+
// Checked before anything is created: a refused run leaves nothing inside the tree.
|
|
25
|
+
let existing = resolve(dir);
|
|
26
|
+
const rest = [];
|
|
27
|
+
while (!existsSync(existing)) {
|
|
28
|
+
rest.unshift(basename(existing));
|
|
29
|
+
const up = dirname(existing);
|
|
30
|
+
if (up === existing) break;
|
|
31
|
+
existing = up;
|
|
32
|
+
}
|
|
33
|
+
const intended = join(realpathSync(existing), ...rest);
|
|
34
|
+
for (const root of roots) {
|
|
35
|
+
let r;
|
|
36
|
+
try { r = realpathSync(root); } catch { continue; }
|
|
37
|
+
if (intended === r || intended.startsWith(r.endsWith(sep) ? r : r + sep)) {
|
|
38
|
+
throw new EvidenceRefusal(`the evidence directory ${dir} is inside ${root}, which this run reads; a tree under review must not supply its own evidence`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
for (const p of [dir, join(dir, 'runs'), join(dir, 'seals')]) {
|
|
43
|
+
if (isLink(p)) throw new EvidenceRefusal(`${p} is a symbolic link, which evidence is not written through`);
|
|
44
|
+
if (!existsSync(p)) mkdirSync(p, { mode: 0o700 });
|
|
45
|
+
else if (!statSync(p).isDirectory()) throw new EvidenceRefusal(`${p} is not a directory`);
|
|
46
|
+
}
|
|
47
|
+
return realpathSync(dir);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// O_NOFOLLOW guards the last component only, so the two directories above an evidence file
|
|
51
|
+
// (runs/ or seals/, and the evidence directory) are checked immediately before each open. Where
|
|
52
|
+
// the platform has no O_NOFOLLOW (Windows), the file itself is checked the same way.
|
|
53
|
+
function parentsNotLinked(path) {
|
|
54
|
+
const dir = dirname(path);
|
|
55
|
+
const inside = [dir, ...(/^(runs|seals)$/.test(basename(dir)) ? [dirname(dir)] : []), ...(O_NOFOLLOW ? [] : [path])];
|
|
56
|
+
for (const p of inside) {
|
|
57
|
+
if (isLink(p)) throw new EvidenceRefusal(`${p} is a symbolic link, which evidence is not written or read through`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const refuseLink = (path, e) => {
|
|
62
|
+
if (e.code === 'ELOOP' || (e.code === 'EEXIST' && isLink(path))) throw new EvidenceRefusal(`${path} is a symbolic link, which evidence is not written or read through`);
|
|
63
|
+
throw e;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function createExclusive(path) {
|
|
67
|
+
parentsNotLinked(path);
|
|
68
|
+
try { return openSync(path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600); } catch (e) { return refuseLink(path, e); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function openAppend(path) {
|
|
72
|
+
parentsNotLinked(path);
|
|
73
|
+
try { return openSync(path, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600); } catch (e) { return refuseLink(path, e); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function openRead(path) {
|
|
77
|
+
parentsNotLinked(path);
|
|
78
|
+
try { return openSync(path, constants.O_RDONLY | O_NOFOLLOW); } catch (e) { return refuseLink(path, e); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Everything the descriptor holds, sized from the descriptor, so the path is resolved once.
|
|
82
|
+
function readAll(fd) {
|
|
83
|
+
const size = fstatSync(fd).size;
|
|
84
|
+
const buf = Buffer.alloc(size);
|
|
85
|
+
let off = 0;
|
|
86
|
+
while (off < size) {
|
|
87
|
+
const n = readSync(fd, buf, off, size - off, off);
|
|
88
|
+
if (n === 0) break;
|
|
89
|
+
off += n;
|
|
90
|
+
}
|
|
91
|
+
return buf.subarray(0, off);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function writeAll(fd, text) {
|
|
95
|
+
const buf = Buffer.from(text, 'utf8');
|
|
96
|
+
let off = 0;
|
|
97
|
+
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function syncClose(fd) {
|
|
101
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Lines of a chain file, each without its newline, and whether the last one was cut short.
|
|
105
|
+
export function readLines(path) {
|
|
106
|
+
const fd = openRead(path);
|
|
107
|
+
let text;
|
|
108
|
+
try { text = readAll(fd).toString('utf8'); } finally { closeSync(fd); }
|
|
109
|
+
if (!text) return { lines: [], torn: false };
|
|
110
|
+
const torn = !text.endsWith('\n');
|
|
111
|
+
const lines = text.split('\n');
|
|
112
|
+
if (!torn) lines.pop();
|
|
113
|
+
return { lines, torn };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The last complete line of a chain file, read from its tail, or null for an empty file.
|
|
117
|
+
export function lastLine(path) {
|
|
118
|
+
if (!existsSync(path)) return { line: null, torn: false };
|
|
119
|
+
const fd = openRead(path);
|
|
120
|
+
try {
|
|
121
|
+
const size = fstatSync(fd).size;
|
|
122
|
+
if (!size) return { line: null, torn: false };
|
|
123
|
+
let window = Math.min(size, 65536);
|
|
124
|
+
for (;;) {
|
|
125
|
+
const buf = Buffer.alloc(window);
|
|
126
|
+
const n = readSync(fd, buf, 0, window, size - window);
|
|
127
|
+
if (n !== window) throw new EvidenceRefusal(`${path} changed while it was read`);
|
|
128
|
+
const text = buf.toString('utf8');
|
|
129
|
+
const torn = !text.endsWith('\n');
|
|
130
|
+
const body = torn ? text : text.slice(0, -1);
|
|
131
|
+
const nl = body.lastIndexOf('\n');
|
|
132
|
+
if (nl >= 0 || window === size) return { line: nl >= 0 ? body.slice(nl + 1) : body, torn };
|
|
133
|
+
window = Math.min(size, window * 4);
|
|
134
|
+
}
|
|
135
|
+
} finally { closeSync(fd); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
139
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
140
|
+
|
|
141
|
+
// Takes ledger.lock, breaking one that is older than staleMs and whose holder is gone. Returns
|
|
142
|
+
// { release, broken } or null when the lock could not be had within waitMs.
|
|
143
|
+
export function takeLock(path, { waitMs = 5000, staleMs = 60000, now = Date.now } = {}) {
|
|
144
|
+
const deadline = now() + waitMs;
|
|
145
|
+
let broken = null;
|
|
146
|
+
for (;;) {
|
|
147
|
+
try {
|
|
148
|
+
const fd = createExclusive(path);
|
|
149
|
+
writeAll(fd, `${process.pid} ${now()}\n`);
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
return { release: () => { try { unlinkSync(path); } catch { /* already gone */ } }, broken };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e.code !== 'EEXIST') throw e;
|
|
154
|
+
}
|
|
155
|
+
let pid = null;
|
|
156
|
+
let since = null;
|
|
157
|
+
try {
|
|
158
|
+
const [p, t] = readFileSync(path, 'utf8').trim().split(/\s+/).map(Number);
|
|
159
|
+
pid = Number.isSafeInteger(p) ? p : null;
|
|
160
|
+
since = Number.isSafeInteger(t) ? t : null;
|
|
161
|
+
} catch { /* removed between the open and the read */ }
|
|
162
|
+
const age = since === null ? null : now() - since;
|
|
163
|
+
if (pid !== null && age !== null && age > staleMs && !pidAlive(pid)) {
|
|
164
|
+
try { unlinkSync(path); broken = { holderPid: pid, ageMs: Math.max(0, Math.trunc(age)) }; } catch { /* a peer broke it first */ }
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (now() >= deadline) return null;
|
|
168
|
+
sleep(25);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Verifies an evidence directory and reports verified, broken, unrecorded, open, anchored, sealed,
|
|
3
|
+
// unsealedTail and signatures as separate facts. docs/spec/evidence.md §9.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { JOURNAL_KINDS, LEDGER_KINDS, checkLine } from './record.mjs';
|
|
8
|
+
import { readLines } from './store.mjs';
|
|
9
|
+
import { LEDGER } from './journal.mjs';
|
|
10
|
+
import { NAMESPACE, PAYLOAD_TYPE, SEAL_PREDICATE, STATEMENT_TYPE, listWitnessed, pae, readWitnessed, resolveRef, sealFiles } from './seal.mjs';
|
|
11
|
+
import { verifySshsig } from './sshsig.mjs';
|
|
12
|
+
|
|
13
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
14
|
+
|
|
15
|
+
// Every line of one chain file checked against the one before it. After a break the walk goes on
|
|
16
|
+
// from the record as read, so one edit is reported once and later breaks are still found.
|
|
17
|
+
export function verifyChain(path, label, kinds = LEDGER_KINDS) {
|
|
18
|
+
const out = { records: [], broken: [], torn: false };
|
|
19
|
+
if (!existsSync(path)) return out;
|
|
20
|
+
const { lines, torn } = readLines(path);
|
|
21
|
+
out.torn = torn;
|
|
22
|
+
let prev = null;
|
|
23
|
+
let chain = null;
|
|
24
|
+
lines.forEach((line, i) => {
|
|
25
|
+
if (i === 0) {
|
|
26
|
+
try { chain = JSON.parse(line).chain; } catch { chain = null; }
|
|
27
|
+
}
|
|
28
|
+
const { record, fail } = checkLine(line, prev, { chain, kinds });
|
|
29
|
+
if (fail) out.broken.push({ file: label, line: i + 1, check: fail });
|
|
30
|
+
if (record) { out.records.push(record); prev = record; }
|
|
31
|
+
});
|
|
32
|
+
if (torn) out.broken.push({ file: label, line: lines.length, check: 'torn' });
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decodeSeal(text) {
|
|
37
|
+
const env = JSON.parse(text);
|
|
38
|
+
if (env.payloadType !== PAYLOAD_TYPE || typeof env.payload !== 'string' || !Array.isArray(env.signatures)) throw new Error('not a DSSE envelope of an in-toto statement');
|
|
39
|
+
const payload = Buffer.from(env.payload, 'base64');
|
|
40
|
+
const st = JSON.parse(payload.toString('utf8'));
|
|
41
|
+
if (st._type !== STATEMENT_TYPE || st.predicateType !== SEAL_PREDICATE) throw new Error('not a cobolwork seal statement');
|
|
42
|
+
const subject = st.subject && st.subject[0];
|
|
43
|
+
const m = subject && /^ledger:([0-9a-f]{32})$/.exec(subject.name || '');
|
|
44
|
+
if (!m || !subject.digest || !/^[0-9a-f]{64}$/.test(subject.digest.sha256 || '')) throw new Error('the seal names no ledger tip');
|
|
45
|
+
const length = st.predicate && st.predicate.ledgerLength;
|
|
46
|
+
if (!Number.isSafeInteger(length) || length < 1) throw new Error('the seal names no ledger length');
|
|
47
|
+
return { envelope: env, payload, chain: m[1], tip: subject.digest.sha256, length, previous: st.predicate.previousSeal ? st.predicate.previousSeal.sha256 : null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Whether the ledger holds the state a seal names: same chain, and the record at that length has
|
|
51
|
+
// that hash.
|
|
52
|
+
function agrees(seal, ledger) {
|
|
53
|
+
const rec = ledger[seal.length - 1];
|
|
54
|
+
if (!ledger.length || ledger[0].chain !== seal.chain) return { ok: false, why: `the seal names ledger ${seal.chain}, and this ledger is ${ledger.length ? ledger[0].chain : 'empty'}` };
|
|
55
|
+
if (!rec) return { ok: false, why: `the seal names ${seal.length} records and the ledger holds ${ledger.length}` };
|
|
56
|
+
if (rec.hash !== seal.tip) return { ok: false, why: `record ${seal.length} of the ledger is not the one sealed` };
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function signaturesOf(seal, allowed) {
|
|
61
|
+
const message = pae(PAYLOAD_TYPE, seal.payload);
|
|
62
|
+
if (!seal.envelope.signatures.length) return [{ keyid: null, valid: null, reason: 'unsigned' }];
|
|
63
|
+
return seal.envelope.signatures.map((s) => {
|
|
64
|
+
if (!allowed) return { keyid: s.keyid || null, valid: null, reason: 'no allowed signers were given' };
|
|
65
|
+
let blob;
|
|
66
|
+
try { blob = Buffer.from(String(s.sig), 'base64'); } catch { return { keyid: s.keyid || null, valid: false, reason: 'the signature is not base64' }; }
|
|
67
|
+
return verifySshsig({ blob, message, namespace: NAMESPACE, allowed });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function verifyEvidence(dir, { allowed = null, anchorGit = null, anchorRef = null, maxUnsealed = 0, env = process.env } = {}) {
|
|
72
|
+
const verdict = { verified: true, broken: [], unrecorded: [], open: [], anchored: null, sealed: null, sealedReason: null, unsealedTail: null, signatures: [], seals: [] };
|
|
73
|
+
const ledger = verifyChain(join(dir, LEDGER), LEDGER);
|
|
74
|
+
verdict.broken.push(...ledger.broken);
|
|
75
|
+
const recs = ledger.records;
|
|
76
|
+
if (recs.length && recs[0].kind !== 'genesis') verdict.broken.push({ file: LEDGER, line: 1, check: 'genesis' });
|
|
77
|
+
|
|
78
|
+
const named = new Set();
|
|
79
|
+
recs.forEach((rec, i) => {
|
|
80
|
+
if (rec.kind !== 'run') return;
|
|
81
|
+
named.add(rec.run);
|
|
82
|
+
const file = `runs/${rec.run}.jsonl`;
|
|
83
|
+
if (!/^[0-9TZ]+-[0-9a-f]{16}$/.test(rec.run) || !existsSync(join(dir, file))) { verdict.broken.push({ file: LEDGER, line: i + 1, check: 'run-missing' }); return; }
|
|
84
|
+
const run = verifyChain(join(dir, file), file, JOURNAL_KINDS);
|
|
85
|
+
verdict.broken.push(...run.broken);
|
|
86
|
+
const last = run.records[run.records.length - 1];
|
|
87
|
+
if (!run.records.length || run.records[0].kind !== 'open' || run.records[0].chain !== rec.runChain) verdict.broken.push({ file, line: 1, check: 'run-chain' });
|
|
88
|
+
else if (run.records.length !== rec.runLength || last.hash !== rec.runTip) verdict.broken.push({ file, line: run.records.length, check: 'run-tip' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const runsDir = join(dir, 'runs');
|
|
92
|
+
for (const n of existsSync(runsDir) ? readdirSync(runsDir).sort() : []) {
|
|
93
|
+
const m = /^(.+)\.jsonl$/.exec(n);
|
|
94
|
+
if (!m) continue;
|
|
95
|
+
let records = [];
|
|
96
|
+
try { ({ records } = verifyChain(join(runsDir, n), `runs/${n}`, JOURNAL_KINDS)); } catch { /* reported below as unreadable only if named */ }
|
|
97
|
+
if (!named.has(m[1])) verdict.unrecorded.push(m[1]);
|
|
98
|
+
if (!records.some((r) => r.kind === 'close')) verdict.open.push(m[1]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The local seal chain: each seal names the digest of the one before it.
|
|
102
|
+
const local = [];
|
|
103
|
+
let prevDigest = null;
|
|
104
|
+
for (const f of sealFiles(dir)) {
|
|
105
|
+
const text = readFileSync(f.path, 'utf8');
|
|
106
|
+
let s;
|
|
107
|
+
try { s = decodeSeal(text); } catch (e) { verdict.broken.push({ file: `seals/${f.name}`, line: 1, check: 'seal', reason: e.message }); prevDigest = sha256(text); continue; }
|
|
108
|
+
if (s.previous !== prevDigest) verdict.broken.push({ file: `seals/${f.name}`, line: 1, check: 'seal-chain' });
|
|
109
|
+
prevDigest = sha256(text);
|
|
110
|
+
local.push({ ...s, seq: f.seq, name: f.name, text });
|
|
111
|
+
}
|
|
112
|
+
if (local.length) {
|
|
113
|
+
const newest = local[local.length - 1];
|
|
114
|
+
const a = agrees(newest, recs);
|
|
115
|
+
verdict.anchored = a.ok;
|
|
116
|
+
if (!a.ok) verdict.anchoredReason = a.why;
|
|
117
|
+
}
|
|
118
|
+
for (const s of local) {
|
|
119
|
+
const sigs = signaturesOf(s, allowed);
|
|
120
|
+
verdict.signatures.push({ seal: s.name, results: sigs });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (anchorGit) {
|
|
124
|
+
const ref = anchorRef || '@{upstream}';
|
|
125
|
+
if (!resolveRef(anchorGit, ref, env)) {
|
|
126
|
+
verdict.sealedReason = `${ref} does not resolve in ${anchorGit}`;
|
|
127
|
+
} else {
|
|
128
|
+
// The witness is the authority: every seal it holds for this ledger is checked, whether or
|
|
129
|
+
// not the directory still has its copy, so deleting local seals cannot hide a cut tail.
|
|
130
|
+
const held = listWitnessed(anchorGit, ref, env);
|
|
131
|
+
const chain = recs.length ? recs[0].chain : null;
|
|
132
|
+
const seqs = chain ? held.get(chain) || [] : [];
|
|
133
|
+
const localSeqs = new Set(local.filter((s) => s.chain === chain).map((s) => s.seq));
|
|
134
|
+
let newestOk = null;
|
|
135
|
+
if (!seqs.length && held.size) {
|
|
136
|
+
verdict.sealed = false;
|
|
137
|
+
verdict.sealedReason = `the witness holds seals for ledger ${[...held.keys()].join(', ')}, and this directory holds ledger ${chain || 'none'}`;
|
|
138
|
+
} else if (!seqs.length) verdict.sealedReason = `not on ${ref}: the witness holds no seal for ledger ${chain || 'none'}`;
|
|
139
|
+
for (const seq of verdict.sealed === false ? [] : seqs) {
|
|
140
|
+
const name = `${seq}.dsse.json`;
|
|
141
|
+
const w = readWitnessed(anchorGit, ref, `${chain}/${name}`, env);
|
|
142
|
+
let ws;
|
|
143
|
+
try { ws = decodeSeal(w.text); } catch (e) { verdict.sealed = false; verdict.sealedReason = `the witnessed ${name} is not a seal: ${e.message}`; break; }
|
|
144
|
+
const a = agrees(ws, recs);
|
|
145
|
+
if (!a.ok) { verdict.sealed = false; verdict.sealedReason = `witnessed ${name}: ${a.why}`; break; }
|
|
146
|
+
const sigs = signaturesOf(ws, allowed);
|
|
147
|
+
const good = sigs.some((x) => x.valid === true);
|
|
148
|
+
const bad = sigs.every((x) => x.valid === false);
|
|
149
|
+
verdict.seals.push({ seal: name, witnessed: true, local: localSeqs.has(seq), length: ws.length, signed: good });
|
|
150
|
+
if (bad) { verdict.sealed = false; verdict.sealedReason = `witnessed ${name} carries no signature from an allowed signer`; break; }
|
|
151
|
+
if (good) newestOk = ws;
|
|
152
|
+
}
|
|
153
|
+
for (const s of local) if (s.chain === chain && !seqs.includes(s.seq)) verdict.seals.push({ seal: s.name, witnessed: false, reason: `not on ${ref}` });
|
|
154
|
+
if (verdict.sealed !== false) {
|
|
155
|
+
if (newestOk) {
|
|
156
|
+
verdict.sealed = true;
|
|
157
|
+
verdict.unsealedTail = recs.length - newestOk.length;
|
|
158
|
+
} else if (!verdict.sealedReason) verdict.sealedReason = allowed ? 'no witnessed seal carries a valid signature' : 'no allowed signers were given';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} else verdict.sealedReason = 'no witness was named';
|
|
162
|
+
|
|
163
|
+
verdict.verified = verdict.broken.length === 0;
|
|
164
|
+
verdict.exit = !verdict.verified || verdict.sealed === false ? 1
|
|
165
|
+
: verdict.sealed === true && verdict.unsealedTail <= maxUnsealed ? 0 : 3;
|
|
166
|
+
return verdict;
|
|
167
|
+
}
|
package/lib/explain.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { parseFile, buildFileIndex, detectFormat } from './parser.mjs';
|
|
|
6
6
|
import { EVIDENCE } from './kernel/findings.mjs';
|
|
7
7
|
import { REGISTRY } from './kernel/registry.mjs';
|
|
8
8
|
import { WHO_ACTS } from './tui/model.mjs';
|
|
9
|
+
import { verificationPlan } from './verify.mjs';
|
|
9
10
|
|
|
10
11
|
const HIDDEN_RULES = new Set(Object.keys(REGISTRY.find((s) => s.name === 'hidden').rules));
|
|
11
12
|
|
|
@@ -18,7 +19,8 @@ function codeArea(line, kind, format) {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const NOTE = 'This packet contains source text from the files it names. A cobolwork report never does: '
|
|
21
|
-
+ 'treat the packet as you would the source, and do not store or send it where the source may not go.'
|
|
22
|
+
+ 'treat the packet as you would the source, and do not store or send it where the source may not go. '
|
|
23
|
+
+ 'Its verification plan is for a test region the estate owns and authorises the test in.';
|
|
22
24
|
|
|
23
25
|
function contained(root, rel) {
|
|
24
26
|
const base = resolve(root);
|
|
@@ -140,6 +142,7 @@ export function explainFinding(report, fingerprint, { root }) {
|
|
|
140
142
|
guard: f.guard ? { ...f.guard, ...quote(f.guard.file, f.guard.line) } : null,
|
|
141
143
|
related: (f.related || []).map((r) => ({ ...r, ...quote(r.path, r.line) })),
|
|
142
144
|
declarations,
|
|
145
|
+
verify: verificationPlan(f),
|
|
143
146
|
unread,
|
|
144
147
|
};
|
|
145
148
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Whether an attacker can use a path finding, as a verdict that lists the facts it rests on and names
|
|
3
|
+
// the fact it still lacks. It joins the route (what the check model credits), who supplies the input
|
|
4
|
+
// (the source), who may start the entries that carry it (reach) and what those run as (effect).
|
|
5
|
+
// Reading the source and the estate's records can show a route exploitable; only a test on the
|
|
6
|
+
// running system confirms one, so a verdict says confirmed only where the estate brings the result
|
|
7
|
+
// of its own test as a witness feed. See docs/spec/reach.md §9.
|
|
8
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { basename, sep, delimiter } from 'node:path';
|
|
10
|
+
import { ALL_RULES } from './kernel/registry.mjs';
|
|
11
|
+
import { EXPLOITABILITY } from './kernel/findings.mjs';
|
|
12
|
+
import { kindsOf } from './consequence.mjs';
|
|
13
|
+
import { WHO } from './sets/flow.mjs';
|
|
14
|
+
import { entryName, accessOfEntry, accessFromEntry, privilegedFromEntry } from './reach.mjs';
|
|
15
|
+
|
|
16
|
+
export const HANDLING = 'This report joins routes an attacker can drive with the estate\'s own access facts. It holds no source '
|
|
17
|
+
+ 'text, but it is a list of what to attack first: keep it where the access records it was built from may go, and do not '
|
|
18
|
+
+ 'upload it anywhere more people can read it than can read those records.';
|
|
19
|
+
|
|
20
|
+
// A system response is not input, but the caller who provokes the failure is the one who reads it back.
|
|
21
|
+
const DRIVER = { ...WHO, 'system-response': 'A web caller, who can provoke the failure and reads the code sent back' };
|
|
22
|
+
const AT_REST = { 'file-record': 'the file record', database: 'the database row' };
|
|
23
|
+
|
|
24
|
+
const at = (x) => `${x.file}:${x.line}`;
|
|
25
|
+
|
|
26
|
+
// The test on the value at the sink that the check model credits as clearing the route
|
|
27
|
+
// (stops() in lib/control.mjs), so a patch written to it moves the finding to refuted on the next
|
|
28
|
+
// scan. A sink no test on the value can make safe has none: the fix is the rule's remedy.
|
|
29
|
+
const ALLOW_LIST = (i) => `${i} against a list of the values allowed (an EVALUATE with a WHEN for each literal and WHEN OTHER turning the rest away, or a condition name)`;
|
|
30
|
+
const BOUND = (i) => `that ${i} is at least 1 and not above the number of entries in the table`;
|
|
31
|
+
const TESTS = {
|
|
32
|
+
arithmetic: (i) => `${i} IS NUMERIC`,
|
|
33
|
+
subscript: BOUND,
|
|
34
|
+
'reference-modification': (i) => `that ${i} is at least 1 and not above the length of the field`,
|
|
35
|
+
'occurs-depending-count': (i) => `that ${i} is within the OCCURS range it sets`,
|
|
36
|
+
'loop-bound': BOUND,
|
|
37
|
+
'storage-length': (i) => `that ${i} is not above the most storage the program means to acquire`,
|
|
38
|
+
'dynamic-sql': (i) => `${ALLOW_LIST(i)}, or ${i} IS NUMERIC where it is a number`,
|
|
39
|
+
'internal-reader': (i) => `${ALLOW_LIST(i)}, or ${i} IS NUMERIC where it is a number`,
|
|
40
|
+
'web-response': (i) => `${ALLOW_LIST(i)}, or ${i} IS NUMERIC or IS ALPHABETIC, which leave no markup`,
|
|
41
|
+
'http-header': (i) => `${ALLOW_LIST(i)}, or ${i} IS NUMERIC or IS ALPHABETIC, which leave no line break`,
|
|
42
|
+
log: (i) => `${ALLOW_LIST(i)}, or ${i} IS NUMERIC or IS ALPHABETIC, which leave no line break`,
|
|
43
|
+
'os-command': ALLOW_LIST,
|
|
44
|
+
'dynamic-program-load': ALLOW_LIST,
|
|
45
|
+
'cics-dynamic-transfer': ALLOW_LIST,
|
|
46
|
+
'dynamic-file-path': ALLOW_LIST,
|
|
47
|
+
'queue-name': ALLOW_LIST,
|
|
48
|
+
'connection-target': ALLOW_LIST,
|
|
49
|
+
'cics-sysid': ALLOW_LIST,
|
|
50
|
+
'cics-system-resource': ALLOW_LIST,
|
|
51
|
+
'outbound-host': ALLOW_LIST,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Where the rule's remedy takes input out of the operation altogether, which no test does.
|
|
55
|
+
const WITHOUT_A_TEST = {
|
|
56
|
+
'dynamic-sql': 'a static statement with host variables',
|
|
57
|
+
'internal-reader': 'a job built from a fixed skeleton',
|
|
58
|
+
'os-command': 'a command built only from literals',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Where one patch covers every route: immediately before the operation, on the value it uses. A
|
|
62
|
+
// response code is not input, so no test on it clears a route; the fix is not to send it.
|
|
63
|
+
function fixAt(f, kinds) {
|
|
64
|
+
const last = f.trace?.length ? f.trace[f.trace.length - 1] : null;
|
|
65
|
+
const item = last?.item || 'the value';
|
|
66
|
+
const test = kinds.source === 'system-response' ? null : TESTS[kinds.sink]?.(item) || null;
|
|
67
|
+
const instead = WITHOUT_A_TEST[kinds.sink];
|
|
68
|
+
return {
|
|
69
|
+
program: f.program || null, path: f.path, line: f.line, item,
|
|
70
|
+
...(test ? { test: `before the operation at ${f.path}:${f.line}, on every route to it, test ${test}, and let only a value that passes reach it${instead ? `; ${instead} needs no test` : ''}` }
|
|
71
|
+
: { test: null, why: `no test of ${item} makes the operation at ${f.path}:${f.line} safe: apply the rule's remedy there` }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Entries grouped by what the estate declared about them, each group citing the record that said so.
|
|
76
|
+
function entryFacts(f, resolver) {
|
|
77
|
+
const groups = new Map();
|
|
78
|
+
for (const e of f.startedBy) {
|
|
79
|
+
const access = resolver ? accessOfEntry(e, resolver) : null;
|
|
80
|
+
const state = access ? `${access === 'open' ? 'open to any user' : 'restricted'}, per ${accessFromEntry(e, resolver)}` : '';
|
|
81
|
+
if (!groups.has(state)) groups.set(state, []);
|
|
82
|
+
groups.get(state).push(entryName(e));
|
|
83
|
+
}
|
|
84
|
+
const more = f.startedByMore ? `; and ${f.startedByMore} more not listed` : '';
|
|
85
|
+
const said = (state, names) => state || `who may start ${names.length > 1 ? 'them' : 'it'} is not declared`;
|
|
86
|
+
const lines = [`started by ${[...groups].map(([state, names]) => `${names.join(', ')}: ${said(state, names)}`).join('; ')}${more}`];
|
|
87
|
+
const privileged = new Map();
|
|
88
|
+
for (const e of f.startedBy) {
|
|
89
|
+
const from = resolver && f.effect === 'privileged' ? privilegedFromEntry(e, resolver) : null;
|
|
90
|
+
if (!from) continue;
|
|
91
|
+
if (!privileged.has(from)) privileged.set(from, []);
|
|
92
|
+
privileged.get(from).push(entryName(e));
|
|
93
|
+
}
|
|
94
|
+
for (const [from, names] of privileged) lines.push(`${names.join(', ')} ${names.length > 1 ? 'run' : 'runs'} with elevated authority, per ${from}`);
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The verdict on one finding, or null for a finding that is not a path: a construct or a piece of
|
|
99
|
+
// context has no route for an attacker to drive. `checked` marks a route the check model stops.
|
|
100
|
+
export function judge(f, resolver = null, { checked = false } = {}) {
|
|
101
|
+
const kinds = ALL_RULES[f.rule]?.evidence === 'path' ? kindsOf(f.rule) : null;
|
|
102
|
+
if (!kinds) return null;
|
|
103
|
+
const drivenBy = DRIVER[kinds.source];
|
|
104
|
+
const sink = `${f.path}:${f.line}`;
|
|
105
|
+
const because = [];
|
|
106
|
+
const src = f.related?.[0];
|
|
107
|
+
if (src) because.push(`${drivenBy} supplies it: ${src.detail} at ${src.path}:${src.line}${f.screen ? `, field ${f.screen.field} of map ${f.screen.map}` : ''}${f.sources > 1 ? `, one of ${f.sources} such sources` : ''}`);
|
|
108
|
+
const verdict = (v, unknown) => ({ verdict: v, drivenBy, because, ...(unknown ? { unknown } : {}), ...(v === 'refuted' ? {} : { fixAt: fixAt(f, kinds) }) });
|
|
109
|
+
|
|
110
|
+
if (checked) {
|
|
111
|
+
because.push(`${f.guard ? `the check on ${f.guard.item} at ${at(f.guard)}` : 'a check'} runs first on every route and leaves only values the operation at ${sink} can take`);
|
|
112
|
+
return verdict('refuted');
|
|
113
|
+
}
|
|
114
|
+
if (f.unreached) {
|
|
115
|
+
because.push(`no route from the program's entries reaches ${sink}, so as the program stands it does not run`);
|
|
116
|
+
return verdict('refuted');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let mitigation = null;
|
|
120
|
+
if (f.guardedFrom && f.guard) {
|
|
121
|
+
because.push(`the check on ${f.guard.item} at ${at(f.guard)} runs first on every route, but what it leaves is not shown safe for the operation at ${sink}`);
|
|
122
|
+
mitigation = `whether the check on ${f.guard.item} turns away every value the operation at ${sink} cannot take`;
|
|
123
|
+
} else if (f.ssrange) {
|
|
124
|
+
because.push('the program is compiled with SSRANGE, so an index out of range abends it instead of reaching the storage beside the table');
|
|
125
|
+
mitigation = 'whether an abend is an outage the estate can bear: SSRANGE stops the overrun, not whoever drives it';
|
|
126
|
+
} else if (f.checkElsewhere) {
|
|
127
|
+
because.push(`the check on ${f.checkElsewhere.item} at ${at(f.checkElsewhere)} does not run on every route: another reaches ${sink} without it`);
|
|
128
|
+
} else {
|
|
129
|
+
because.push(`no check is shown to run on every route before the operation at ${sink}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entries = f.startedBy || [];
|
|
133
|
+
if (entries.length) because.push(...entryFacts(f, resolver));
|
|
134
|
+
else because.push(`nothing in this tree starts ${f.program || 'the program'}, so who can run it is not shown here`);
|
|
135
|
+
|
|
136
|
+
if (AT_REST[kinds.source]) return verdict('upstream', `who can write ${AT_REST[kinds.source]}: the tree does not show it`);
|
|
137
|
+
if (mitigation) return verdict('mitigated', mitigation);
|
|
138
|
+
if (ALL_RULES[f.rule].whenPrivileged && f.effect !== 'privileged') {
|
|
139
|
+
because.push('it acts with the authority of whoever runs it, and no entry that starts it is declared privileged');
|
|
140
|
+
return verdict('attacker-driven', 'whether an entry that starts it runs with more authority than its user: name it in privilegedTransactions or privilegedJobs, or in a COBOLWORK_REACH extract');
|
|
141
|
+
}
|
|
142
|
+
if (f.reach === 'open') return verdict('exploitable', 'whether it reproduces: a test on a system the estate owns, under its own authorisation, is the only confirmation');
|
|
143
|
+
if (f.reach === 'restricted') return verdict('restricted', 'whether everyone the control admits is trusted: the route is unchecked all the same');
|
|
144
|
+
return verdict('attacker-driven', entries.length
|
|
145
|
+
? 'who may start the entries above: declare them in openTransactions, restrictedTransactions, openJobs or restrictedJobs in cobolwork.site.json, or bring a COBOLWORK_REACH extract'
|
|
146
|
+
: `what starts ${f.program || 'the program'}: no transaction or job in this tree does`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The feeds a scan was given: the caller's list, or COBOLWORK_WITNESS read when the scan runs.
|
|
150
|
+
export function witnessFeedPaths(opts = {}) {
|
|
151
|
+
if (opts.witnessFeeds) return opts.witnessFeeds;
|
|
152
|
+
return (process.env.COBOLWORK_WITNESS || '').split(delimiter).filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const OUTCOMES = new Set(['reproduced', 'not-reproduced']);
|
|
156
|
+
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
157
|
+
const isoDate = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
158
|
+
|
|
159
|
+
// The results of the estate's own tests, keyed by the fingerprint of the finding each tried, loaded
|
|
160
|
+
// for one scan and refused from inside the scanned tree the way a reach feed is: a list of what was
|
|
161
|
+
// reproduced is a list of what to attack. Shape:
|
|
162
|
+
// { witness, recorded, results: { FINGERPRINT: { outcome: "reproduced"|"not-reproduced", by, on, system, reference? } } }
|
|
163
|
+
// Only those fields are read. What was sent to the system is not one of them.
|
|
164
|
+
export function loadWitnessFeed(path, { root = null } = {}) {
|
|
165
|
+
const feed = { file: basename(path), witness: null, recorded: null, results: new Map(), refused: [], problem: null };
|
|
166
|
+
let real;
|
|
167
|
+
try { real = realpathSync(path); } catch (e) { feed.problem = `could not be read (${e.code || e.name})`; return feed; }
|
|
168
|
+
if (root) {
|
|
169
|
+
const top = realpathSync(root);
|
|
170
|
+
if (real === top || real.startsWith(top + sep)) { feed.problem = 'is inside the tree being scanned, where anyone who can read the repository can read it, so it was not loaded'; return feed; }
|
|
171
|
+
}
|
|
172
|
+
let doc;
|
|
173
|
+
try { doc = JSON.parse(readFileSync(real, 'utf8')); } catch (e) { feed.problem = `is not JSON (${e.message})`; return feed; }
|
|
174
|
+
if (!str(doc.witness)) { feed.problem = 'does not say whose tests it holds'; return feed; }
|
|
175
|
+
if (!isoDate(doc.recorded)) { feed.problem = 'does not say when it was recorded'; return feed; }
|
|
176
|
+
if (!doc.results || typeof doc.results !== 'object' || Array.isArray(doc.results)) { feed.problem = 'holds no results'; return feed; }
|
|
177
|
+
feed.witness = doc.witness;
|
|
178
|
+
feed.recorded = doc.recorded;
|
|
179
|
+
for (const [fingerprint, r] of Object.entries(doc.results)) {
|
|
180
|
+
const why = !r || typeof r !== 'object' ? 'is not an object'
|
|
181
|
+
: !OUTCOMES.has(r.outcome) ? `says outcome ${JSON.stringify(r.outcome)}, not "reproduced" or "not-reproduced"`
|
|
182
|
+
: !str(r.by) ? 'does not say who ran the test'
|
|
183
|
+
: !isoDate(r.on) ? 'does not say when the test ran'
|
|
184
|
+
: !str(r.system) ? 'does not say which system it ran on' : null;
|
|
185
|
+
if (why) { feed.refused.push({ fingerprint, why: `the result for ${fingerprint} ${why}` }); continue; }
|
|
186
|
+
feed.results.set(fingerprint, { outcome: r.outcome, by: r.by, on: r.on, system: r.system, ...(str(r.reference) ? { reference: r.reference } : {}) });
|
|
187
|
+
}
|
|
188
|
+
return feed;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The newest result for a fingerprint across the feeds; on the same day a reproduction outweighs a
|
|
192
|
+
// test that did not reproduce it.
|
|
193
|
+
function newest(feeds, fingerprint) {
|
|
194
|
+
let best = null;
|
|
195
|
+
for (const f of feeds) {
|
|
196
|
+
const r = f.results.get(fingerprint);
|
|
197
|
+
if (!r) continue;
|
|
198
|
+
if (!best || r.on > best.r.on || (r.on === best.r.on && r.outcome === 'reproduced')) best = { r, feed: f };
|
|
199
|
+
}
|
|
200
|
+
return best;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Apply the estate's test results. A reproduced finding is confirmed, and takes back any severity a
|
|
204
|
+
// check or the reading took off it, since the test shows neither held. A route the check model
|
|
205
|
+
// cleared that the estate reproduced leaves `checked` and is reported as a finding again. A test
|
|
206
|
+
// that did not reproduce a finding is stated beside its verdict and changes nothing: a test that
|
|
207
|
+
// missed the route does not show it safe.
|
|
208
|
+
export function applyWitness(findings, checked, feeds) {
|
|
209
|
+
const good = feeds.filter((f) => !f.problem);
|
|
210
|
+
const byWitness = { reproduced: 0, 'not-reproduced': 0 };
|
|
211
|
+
const matched = new Set();
|
|
212
|
+
const overruled = [];
|
|
213
|
+
const apply = (f) => {
|
|
214
|
+
const x = f.exploitability;
|
|
215
|
+
const hit = x && f.fingerprint ? newest(good, f.fingerprint) : null;
|
|
216
|
+
if (!hit) return false;
|
|
217
|
+
matched.add(f.fingerprint);
|
|
218
|
+
const { r, feed } = hit;
|
|
219
|
+
const cite = `${feed.file} (${feed.witness}, recorded ${feed.recorded})`;
|
|
220
|
+
x.witness = { ...r, from: cite };
|
|
221
|
+
byWitness[r.outcome]++;
|
|
222
|
+
if (r.outcome === 'not-reproduced') {
|
|
223
|
+
x.because.push(`not reproduced on ${r.system} by ${r.by} on ${r.on}${r.reference ? ` (${r.reference})` : ''}, per ${cite}`);
|
|
224
|
+
x.unknown = 'why the test did not reproduce it: a test that misses a route does not show the route safe';
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const was = x.verdict;
|
|
228
|
+
x.verdict = 'confirmed';
|
|
229
|
+
x.because.push(`reproduced on ${r.system} by ${r.by} on ${r.on}${r.reference ? ` (${r.reference})` : ''}, per ${cite}`);
|
|
230
|
+
delete x.unknown;
|
|
231
|
+
if (!x.fixAt) x.fixAt = fixAt(f, kindsOf(f.rule));
|
|
232
|
+
if (f.guardedFrom) f.sev = f.guardedFrom;
|
|
233
|
+
if (f.unreached) { f.sev = ALL_RULES[f.rule].sev; delete f.unreached; }
|
|
234
|
+
return was === 'refuted';
|
|
235
|
+
};
|
|
236
|
+
for (const f of findings) if (apply(f)) overruled.push(f);
|
|
237
|
+
for (let i = checked.length - 1; i >= 0; i--) {
|
|
238
|
+
if (!apply(checked[i])) continue;
|
|
239
|
+
const [f] = checked.splice(i, 1);
|
|
240
|
+
overruled.push(f);
|
|
241
|
+
findings.push(f);
|
|
242
|
+
}
|
|
243
|
+
const unmatched = good.flatMap((feed) => [...feed.results.keys()]).filter((fp, i, all) => !matched.has(fp) && all.indexOf(fp) === i);
|
|
244
|
+
return {
|
|
245
|
+
byWitness,
|
|
246
|
+
overruled: overruled.map((f) => ({ fingerprint: f.fingerprint, rule: f.rule, path: f.path, line: f.line })),
|
|
247
|
+
unmatched,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function byVerdict(findings) {
|
|
252
|
+
const by = Object.fromEntries(Object.keys(EXPLOITABILITY).map((v) => [v, 0]));
|
|
253
|
+
let judged = 0;
|
|
254
|
+
for (const f of findings) if (f.exploitability) { by[f.exploitability.verdict]++; judged++; }
|
|
255
|
+
return judged ? by : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Stamp a verdict on every path finding and every route a check stopped, and count the findings.
|
|
259
|
+
export function stampExploitability(findings, resolver, checked = []) {
|
|
260
|
+
for (const f of findings) { const x = judge(f, resolver); if (x) f.exploitability = x; }
|
|
261
|
+
for (const f of checked) { const x = judge(f, resolver, { checked: true }); if (x) f.exploitability = x; }
|
|
262
|
+
return byVerdict(findings);
|
|
263
|
+
}
|