@portll/cobolwork 0.5.0 → 0.6.0
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 +43 -11
- package/bin/cobolwork.mjs +27 -8
- package/lib/baseline.mjs +9 -0
- package/lib/build.mjs +33 -8
- package/lib/capabilities.mjs +4 -1
- package/lib/consequence.mjs +4 -0
- package/lib/control-reuse.mjs +113 -0
- package/lib/control.mjs +25 -1
- package/lib/dataflow.mjs +285 -86
- package/lib/equivalence.mjs +44 -5
- package/lib/evidence/cli.mjs +13 -5
- package/lib/evidence/record.mjs +16 -6
- package/lib/evidence/seal.mjs +17 -0
- package/lib/evidence/store.mjs +30 -14
- package/lib/evidence/timestamp.mjs +83 -0
- package/lib/evidence/verify.mjs +120 -42
- package/lib/hlasm/asm/data.mjs +116 -0
- package/lib/hlasm/asm/listing.mjs +43 -0
- package/lib/hlasm/asm/output.mjs +45 -0
- package/lib/hlasm/asm/sections.mjs +159 -0
- package/lib/hlasm/asm/symbols.mjs +78 -0
- package/lib/hlasm/exec.mjs +27 -0
- package/lib/hlasm/expr.mjs +167 -0
- package/lib/hlasm/instr.mjs +48 -0
- package/lib/hlasm/locate.mjs +333 -0
- package/lib/hlasm/macro/authorization.mjs +126 -0
- package/lib/hlasm/macro/datasets.mjs +94 -0
- package/lib/hlasm/macro/io.mjs +139 -0
- package/lib/hlasm/macro/le.mjs +53 -0
- package/lib/hlasm/macro/linkage.mjs +143 -0
- package/lib/hlasm/macro/operator.mjs +66 -0
- package/lib/hlasm/macro/program.mjs +142 -0
- package/lib/hlasm/macro/recovery.mjs +80 -0
- package/lib/hlasm/macro/storage.mjs +117 -0
- package/lib/hlasm/macro/structured.mjs +131 -0
- package/lib/hlasm/model.mjs +72 -0
- package/lib/hlasm/operands.mjs +49 -0
- package/lib/hlasm/read.mjs +116 -0
- package/lib/inventory.mjs +10 -7
- package/lib/ironwork-ids.mjs +24 -0
- package/lib/ironwork.mjs +4 -1
- package/lib/kernel/registry.mjs +20 -17
- package/lib/kernel/shared-pass.mjs +138 -0
- package/lib/options.mjs +26 -4
- package/lib/policy.mjs +6 -0
- package/lib/precompile-cics.mjs +1 -1
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +3 -0
- package/lib/scan.mjs +21 -11
- package/lib/sets/abend.mjs +71 -7
- package/lib/sets/build.mjs +43 -37
- package/lib/sets/cics.mjs +5 -3
- package/lib/sets/compile.mjs +5 -3
- package/lib/sets/copybook.mjs +5 -3
- package/lib/sets/flow.mjs +14 -7
- package/lib/sets/hlasm.mjs +60 -4
- package/lib/sets/log.mjs +5 -3
- package/lib/sets/priv.mjs +5 -3
- package/lib/sets/secrets.mjs +110 -0
- package/lib/sets/semantics.mjs +5 -3
- package/lib/sets/web.mjs +5 -3
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +88 -4
- package/rules/compliance-dora.json +95 -4
- package/rules/compliance-ffiec.json +91 -0
- package/rules/compliance-nist80053.json +91 -0
- package/rules/gitleaks-mainframe.toml +46 -15
- package/rules/hlasm-instructions.json +2396 -0
- package/schema/cobolwork.policy.schema.json +7 -0
package/lib/kernel/registry.mjs
CHANGED
|
@@ -12,47 +12,50 @@
|
|
|
12
12
|
// literal key `undefined` in every report for several commits. lib/scan.mjs grew an ESETNAME throw
|
|
13
13
|
// to make the next rename fail loudly instead. The throw stays - see `reportKey` below - but the
|
|
14
14
|
// map it guarded is gone, because a name derived from one place cannot disagree with itself.
|
|
15
|
-
import { scan as scanFlow, RULES as FLOW_RULES } from '../sets/flow.mjs';
|
|
16
|
-
import { scanCics, CICS_RULES } from '../sets/cics.mjs';
|
|
15
|
+
import { scan as scanFlow, scanSteps as scanFlowSteps, RULES as FLOW_RULES } from '../sets/flow.mjs';
|
|
16
|
+
import { scanCics, scanCicsSteps, CICS_RULES } from '../sets/cics.mjs';
|
|
17
17
|
import { scanHidden, HIDDEN_RULES } from '../sets/hidden.mjs';
|
|
18
|
-
import { scanCopybooks, COPYBOOK_RULES } from '../sets/copybook.mjs';
|
|
18
|
+
import { scanCopybooks, scanCopybooksSteps, COPYBOOK_RULES } from '../sets/copybook.mjs';
|
|
19
19
|
import { scanJcl, JCL_RULES } from '../sets/jcl.mjs';
|
|
20
20
|
import { scanBuild, BUILD_RULES } from '../sets/build.mjs';
|
|
21
21
|
import { scanRecon, RECON_RULES } from '../sets/recon.mjs';
|
|
22
22
|
import { scanVendor, VENDOR_RULES } from '../sets/vendor.mjs';
|
|
23
23
|
import { scanOpaque, OPAQUE_RULES } from '../sets/opaque.mjs';
|
|
24
|
-
import { scanWeb, WEB_RULES } from '../sets/web.mjs';
|
|
25
|
-
import { scanPriv, PRIV_RULES } from '../sets/priv.mjs';
|
|
26
|
-
import { scanLog, LOG_RULES } from '../sets/log.mjs';
|
|
27
|
-
import { scanCompile, COMPILE_RULES } from '../sets/compile.mjs';
|
|
28
|
-
import { scanSemantics, SEMANTICS_RULES } from '../sets/semantics.mjs';
|
|
24
|
+
import { scanWeb, scanWebSteps, WEB_RULES } from '../sets/web.mjs';
|
|
25
|
+
import { scanPriv, scanPrivSteps, PRIV_RULES } from '../sets/priv.mjs';
|
|
26
|
+
import { scanLog, scanLogSteps, LOG_RULES } from '../sets/log.mjs';
|
|
27
|
+
import { scanCompile, scanCompileSteps, COMPILE_RULES } from '../sets/compile.mjs';
|
|
28
|
+
import { scanSemantics, scanSemanticsSteps, SEMANTICS_RULES } from '../sets/semantics.mjs';
|
|
29
29
|
import { scanZowe, ZOWE_RULES } from '../sets/zowe.mjs';
|
|
30
30
|
import { scanAbend, ABEND_RULES } from '../sets/abend.mjs';
|
|
31
31
|
import { scanHlasm, HLASM_RULES } from '../sets/hlasm.mjs';
|
|
32
32
|
import { scanCrypto, CRYPTO_RULES } from '../sets/crypto.mjs';
|
|
33
|
+
import { scanSecrets, SECRETS_RULES } from '../sets/secrets.mjs';
|
|
33
34
|
import { toolName } from './ruleset.mjs';
|
|
34
35
|
|
|
35
36
|
// The order is the order a scan runs them and the order --only lists them. It is not significant
|
|
36
|
-
// to correctness; it is significant to a report being comparable with yesterday's.
|
|
37
|
+
// to correctness; it is significant to a report being comparable with yesterday's. A set with
|
|
38
|
+
// `steps` reads programs through the scan's one shared pass (lib/kernel/shared-pass.mjs).
|
|
37
39
|
export const REGISTRY = [
|
|
38
|
-
{ name: 'flow', scan: scanFlow, rules: FLOW_RULES },
|
|
39
|
-
{ name: 'cics', scan: scanCics, rules: CICS_RULES },
|
|
40
|
+
{ name: 'flow', scan: scanFlow, steps: scanFlowSteps, rules: FLOW_RULES },
|
|
41
|
+
{ name: 'cics', scan: scanCics, steps: scanCicsSteps, rules: CICS_RULES },
|
|
40
42
|
{ name: 'hidden', scan: scanHidden, rules: HIDDEN_RULES },
|
|
41
|
-
{ name: 'copybook', scan: scanCopybooks, rules: COPYBOOK_RULES },
|
|
43
|
+
{ name: 'copybook', scan: scanCopybooks, steps: scanCopybooksSteps, rules: COPYBOOK_RULES },
|
|
42
44
|
{ name: 'jcl', scan: scanJcl, rules: JCL_RULES },
|
|
43
45
|
{ name: 'build', scan: scanBuild, rules: BUILD_RULES },
|
|
44
46
|
{ name: 'recon', scan: scanRecon, rules: RECON_RULES },
|
|
45
47
|
{ name: 'vendor', scan: scanVendor, rules: VENDOR_RULES },
|
|
46
48
|
{ name: 'opaque', scan: scanOpaque, rules: OPAQUE_RULES },
|
|
47
|
-
{ name: 'web', scan: scanWeb, rules: WEB_RULES },
|
|
48
|
-
{ name: 'compile', scan: scanCompile, rules: COMPILE_RULES },
|
|
49
|
-
{ name: 'priv', scan: scanPriv, rules: PRIV_RULES },
|
|
50
|
-
{ name: 'log', scan: scanLog, rules: LOG_RULES },
|
|
51
|
-
{ name: 'semantics', scan: scanSemantics, rules: SEMANTICS_RULES },
|
|
49
|
+
{ name: 'web', scan: scanWeb, steps: scanWebSteps, rules: WEB_RULES },
|
|
50
|
+
{ name: 'compile', scan: scanCompile, steps: scanCompileSteps, rules: COMPILE_RULES },
|
|
51
|
+
{ name: 'priv', scan: scanPriv, steps: scanPrivSteps, rules: PRIV_RULES },
|
|
52
|
+
{ name: 'log', scan: scanLog, steps: scanLogSteps, rules: LOG_RULES },
|
|
53
|
+
{ name: 'semantics', scan: scanSemantics, steps: scanSemanticsSteps, rules: SEMANTICS_RULES },
|
|
52
54
|
{ name: 'zowe', scan: scanZowe, rules: ZOWE_RULES },
|
|
53
55
|
{ name: 'abend', scan: scanAbend, rules: ABEND_RULES },
|
|
54
56
|
{ name: 'hlasm', scan: scanHlasm, rules: HLASM_RULES },
|
|
55
57
|
{ name: 'crypto', scan: scanCrypto, rules: CRYPTO_RULES },
|
|
58
|
+
{ name: 'secrets', scan: scanSecrets, rules: SECRETS_RULES },
|
|
56
59
|
];
|
|
57
60
|
|
|
58
61
|
// Re-exported from the kernel's ruleset module, which is where it has to live: this file imports
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// One pass over the tree for every rule set that reads programs, so that a file is read and parsed
|
|
3
|
+
// once per scan rather than once per set.
|
|
4
|
+
//
|
|
5
|
+
// A set's scan is a generator. Where it would run its guarded loop it yields `loopOver(...)` and is
|
|
6
|
+
// resumed with the loop's account, exactly what eachWithinMemory would have returned. `drive` runs
|
|
7
|
+
// one set on its own, its loop as it always ran. `driveTogether` runs several: it gathers the loop
|
|
8
|
+
// each asks for, walks the files of all of them once in the tree's order, gives each set its own
|
|
9
|
+
// files in its own order, and resumes each with its own account. While a file is being handed round,
|
|
10
|
+
// the tree answers text() and parse() for it from what the first set to ask was given.
|
|
11
|
+
//
|
|
12
|
+
// Each set's per-file work is unchanged, so its findings are the ones it reported alone. What is
|
|
13
|
+
// shared is the reading and the parse, which is why no set may change a parse result it is given.
|
|
14
|
+
import { eachWithinMemory, memoryStatus, stoppedBecause, watchMemoryBuffer, MEMORY_RESERVE } from './memory.mjs';
|
|
15
|
+
|
|
16
|
+
const LOOP = Symbol('loop');
|
|
17
|
+
|
|
18
|
+
// What a set yields in place of `eachWithinMemory(items, work, opts)`. `guarded: false` asks for a
|
|
19
|
+
// plain loop: every item, no memory check and no byte budget.
|
|
20
|
+
export const loopOver = (items, work, opts = {}) => ({ [LOOP]: true, items, work, opts });
|
|
21
|
+
|
|
22
|
+
function plainLoop(items, work, label) {
|
|
23
|
+
for (let i = 0; i < items.length; i++) work(items[i], i);
|
|
24
|
+
return { label, processed: items.length, bytes: 0, skipped: [], complete: true, stoppedBy: null, peakHeapBytes: 0, note: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const runAlone = (req) => (req.opts.guarded === false
|
|
28
|
+
? plainLoop(req.items, req.work, req.opts.label || 'scan')
|
|
29
|
+
: eachWithinMemory(req.items, req.work, req.opts));
|
|
30
|
+
|
|
31
|
+
export function drive(steps) {
|
|
32
|
+
let step = steps.next();
|
|
33
|
+
while (!step.done) step = steps.next(runAlone(step.value));
|
|
34
|
+
return step.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A tree that, while `focus` names a file, answers text() and parse() for that file once and hands
|
|
38
|
+
// every later caller the same answer, its error included.
|
|
39
|
+
export function sharingTree(base) {
|
|
40
|
+
let focus = null;
|
|
41
|
+
let text = null;
|
|
42
|
+
let parsed = null;
|
|
43
|
+
const tree = {
|
|
44
|
+
...base,
|
|
45
|
+
focusOn(f) { focus = f; text = null; parsed = null; },
|
|
46
|
+
text(p) {
|
|
47
|
+
if (p !== focus) return base.text(p);
|
|
48
|
+
if (!text) { try { text = { value: base.text(p) }; } catch (e) { text = { error: e }; } }
|
|
49
|
+
if (text.error) throw text.error;
|
|
50
|
+
return text.value;
|
|
51
|
+
},
|
|
52
|
+
parse(p, src) {
|
|
53
|
+
if (p !== focus) return base.parse(p, src);
|
|
54
|
+
const own = src ?? tree.text(p).text;
|
|
55
|
+
if (!parsed || parsed.src !== own) {
|
|
56
|
+
try { parsed = { src: own, value: base.parse(p, own) }; } catch (e) { parsed = { src: own, error: e }; }
|
|
57
|
+
}
|
|
58
|
+
if (parsed.error) throw parsed.error;
|
|
59
|
+
return parsed.value;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
return tree;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Runs every generator to completion and returns their results in the order given. A generator
|
|
66
|
+
// that throws, before its loop or inside it, yields { error } in its place, as a set that throws
|
|
67
|
+
// alone does; the others carry on.
|
|
68
|
+
export function driveTogether(stepsList, tree) {
|
|
69
|
+
const live = stepsList.map((steps) => ({ steps, step: null, result: undefined, error: null }));
|
|
70
|
+
const advance = (g, input, thrown) => {
|
|
71
|
+
try { g.step = thrown ? g.steps.throw(thrown) : g.steps.next(input); } catch (e) { g.error = e; g.step = { done: true }; return; }
|
|
72
|
+
if (g.step.done) g.result = g.step.value;
|
|
73
|
+
};
|
|
74
|
+
for (const g of live) advance(g);
|
|
75
|
+
for (;;) {
|
|
76
|
+
const waiting = live.filter((g) => !g.step.done);
|
|
77
|
+
if (!waiting.length) break;
|
|
78
|
+
const runs = mergedLoop(waiting.map((g) => g.step.value), tree);
|
|
79
|
+
waiting.forEach((g, k) => advance(g, runs[k].run, runs[k].error));
|
|
80
|
+
}
|
|
81
|
+
return live.map((g) => (g.error ? { error: g.error } : g.result));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The files of every request, once each, in the order of the tree's list. A request whose items are
|
|
85
|
+
// not in that order runs alone first, so no set sees its files in an order its own loop would not use.
|
|
86
|
+
function mergedLoop(requests, tree) {
|
|
87
|
+
const rank = new Map(tree.list().map((p, i) => [p, i]));
|
|
88
|
+
const inOrder = (items) => items.every((p, i) => rank.has(p) && (i === 0 || rank.get(items[i - 1]) < rank.get(p)));
|
|
89
|
+
const states = requests.map((req) => ({
|
|
90
|
+
req, guarded: req.opts.guarded !== false, maxBytes: req.opts.maxBytes ?? Infinity, label: req.opts.label || 'scan',
|
|
91
|
+
next: 0, processed: 0, bytes: 0, skipped: [], stoppedBy: null, error: null, alone: null,
|
|
92
|
+
}));
|
|
93
|
+
for (const s of states) if (!inOrder(s.req.items)) {
|
|
94
|
+
try { s.alone = runAlone(s.req); } catch (e) { s.error = e; s.alone = {}; }
|
|
95
|
+
}
|
|
96
|
+
const shared = states.filter((s) => !s.alone);
|
|
97
|
+
const w = shared.map((s) => s.req.opts.watcher).find(Boolean) || watchMemoryBuffer();
|
|
98
|
+
const every = Math.min(...shared.map((s) => s.req.opts.every ?? 8), 8);
|
|
99
|
+
const files = [...new Set(shared.flatMap((s) => s.req.items))].sort((a, b) => rank.get(a) - rank.get(b));
|
|
100
|
+
let stoppedBy = null;
|
|
101
|
+
for (let i = 0; i < files.length; i++) {
|
|
102
|
+
const f = files[i];
|
|
103
|
+
const wanting = shared.filter((s) => s.req.items[s.next] === f);
|
|
104
|
+
if (!stoppedBy && wanting.some((s) => s.guarded && !s.error && s.bytes < s.maxBytes)
|
|
105
|
+
&& (i % every === 0 || memoryStatus().fractionUsed > 1 - MEMORY_RESERVE)) {
|
|
106
|
+
const state = w.check();
|
|
107
|
+
if ((state === 'exhausted' || state === 'tight') && (!w.collect() || w.stillTight())) {
|
|
108
|
+
stoppedBy = state === 'exhausted' ? 'memory exhausted' : 'memory reserve';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
tree.focusOn?.(f);
|
|
112
|
+
for (const s of wanting) {
|
|
113
|
+
const index = s.next++;
|
|
114
|
+
if (s.error) continue;
|
|
115
|
+
if (s.guarded) {
|
|
116
|
+
if (s.stoppedBy) { s.skipped.push(f); continue; }
|
|
117
|
+
if (s.bytes >= s.maxBytes) { s.stoppedBy = 'byte budget'; s.skipped.push(f); continue; }
|
|
118
|
+
if (stoppedBy) { s.stoppedBy = stoppedBy; s.skipped.push(f); continue; }
|
|
119
|
+
}
|
|
120
|
+
let took;
|
|
121
|
+
try { took = s.req.work(f, index); } catch (e) { s.error = e; continue; }
|
|
122
|
+
if (typeof took === 'number') s.bytes += took;
|
|
123
|
+
s.processed++;
|
|
124
|
+
}
|
|
125
|
+
tree.focusOn?.(null);
|
|
126
|
+
}
|
|
127
|
+
return states.map((s) => {
|
|
128
|
+
if (s.alone) return { run: s.alone, error: s.error };
|
|
129
|
+
const { items } = s.req;
|
|
130
|
+
const run = s.guarded
|
|
131
|
+
? { label: s.label, processed: s.processed, bytes: s.bytes, skipped: s.skipped, complete: s.skipped.length === 0, stoppedBy: s.stoppedBy, peakHeapBytes: w.peak,
|
|
132
|
+
note: s.skipped.length === 0 ? null
|
|
133
|
+
: `${s.label}: ${s.skipped.length} of ${items.length} files were not read because ${stoppedBecause(s.stoppedBy)}. `
|
|
134
|
+
+ 'The findings below are over what was read, and are not a result for this repository as a whole.' }
|
|
135
|
+
: { label: s.label, processed: s.processed, bytes: 0, skipped: [], complete: true, stoppedBy: null, peakHeapBytes: 0, note: null };
|
|
136
|
+
return { run, error: s.error };
|
|
137
|
+
});
|
|
138
|
+
}
|
package/lib/options.mjs
CHANGED
|
@@ -485,17 +485,27 @@ export function sourceOperands(args, compiler = 'gnucobol') {
|
|
|
485
485
|
return out;
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
// The first GnuCOBOL release with the -fec option, and with each condition added after it.
|
|
489
|
+
const FEC_SINCE = '3.1';
|
|
490
|
+
const CONDITION_SINCE = { 'EC-PROGRAM-ARG-MISMATCH': '3.2' };
|
|
491
|
+
export const olderThan = (version, since) => {
|
|
492
|
+
const [a, b] = [version, since].map((v) => String(v).split('.').map(Number));
|
|
493
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
|
|
494
|
+
return false;
|
|
495
|
+
};
|
|
496
|
+
|
|
488
497
|
// A cobc argument vector. Returns { checks, forbidden, add }: `add` holds the -fec options that
|
|
489
498
|
// supply a required check nothing turned on, and `forbidden` the options the policy's forbid list
|
|
490
499
|
// names. A check turned off is a failed check, not overridden: appending past it would hide a build
|
|
491
|
-
// configured against the policy.
|
|
492
|
-
|
|
500
|
+
// configured against the policy. With `pinned` ({ version, where }), a check that GnuCOBOL version
|
|
501
|
+
// cannot generate fails, however the command asks for it.
|
|
502
|
+
export function cobcChecks(args, { required, forbid = [], pinned = null }) {
|
|
493
503
|
const state = new Map();
|
|
494
504
|
const turnedOff = new Map();
|
|
495
505
|
const set = (name, on, arg) => {
|
|
496
506
|
for (const check of required) {
|
|
497
507
|
if (!covers(name, EXCEPTION_OF_CHECK[check])) continue;
|
|
498
|
-
state.set(check, on);
|
|
508
|
+
state.set(check, on ? arg : false);
|
|
499
509
|
if (on) turnedOff.delete(check); else turnedOff.set(check, arg);
|
|
500
510
|
}
|
|
501
511
|
};
|
|
@@ -509,11 +519,23 @@ export function cobcChecks(args, { required, forbid = [] }) {
|
|
|
509
519
|
if (value === undefined) continue;
|
|
510
520
|
set(exceptionName(value), !m[1], shown);
|
|
511
521
|
}
|
|
522
|
+
const tooOld = (check, by) => {
|
|
523
|
+
if (!pinned) return null;
|
|
524
|
+
const condition = EXCEPTION_OF_CHECK[check];
|
|
525
|
+
const since = CONDITION_SINCE[condition];
|
|
526
|
+
const pin = `${pinned.where} pins ${pinned.version}`;
|
|
527
|
+
if (since && olderThan(pinned.version, since)) return `${check} needs ${condition}, which is in GnuCOBOL ${since} and later; ${pin}`;
|
|
528
|
+
if (!/^-(debug|d)$/.test(by) && olderThan(pinned.version, FEC_SINCE)) return `${check} needs -fec, which is in GnuCOBOL ${FEC_SINCE} and later; ${pin}`;
|
|
529
|
+
return null;
|
|
530
|
+
};
|
|
512
531
|
const add = [];
|
|
513
532
|
const checks = required.map((check) => {
|
|
514
|
-
if (state.get(check) === true) return { check, ok: true, why: 'turned on by the command line' };
|
|
515
533
|
if (turnedOff.has(check)) return { check, ok: false, why: `${turnedOff.get(check)} turns off the ${check} check` };
|
|
516
534
|
const flag = `-fec=${EXCEPTION_OF_CHECK[check]}`;
|
|
535
|
+
const by = state.get(check) || flag;
|
|
536
|
+
const old = tooOld(check, by);
|
|
537
|
+
if (old) return { check, ok: false, why: old };
|
|
538
|
+
if (state.get(check)) return { check, ok: true, why: 'turned on by the command line' };
|
|
517
539
|
if (!add.includes(flag)) add.push(flag);
|
|
518
540
|
return { check, ok: true, added: true, why: `added as ${flag}` };
|
|
519
541
|
});
|
package/lib/policy.mjs
CHANGED
|
@@ -26,9 +26,13 @@ export const DEFAULT_POLICY = Object.freeze({
|
|
|
26
26
|
checks: ['subscript', 'reference-modification'],
|
|
27
27
|
forbid: { enterprise: [], gnucobol: [], gcobol: [] },
|
|
28
28
|
requireEquivalence: 'never',
|
|
29
|
+
interface: 'warn',
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
export const EQUIVALENCE_MODES = ['never', 'machineAuthored', 'always'];
|
|
33
|
+
// How an abend from a subprogram fuzzed at its interface, with no caller run shown to pass the input,
|
|
34
|
+
// is gated: `warn` never blocks on it, `tier` gates it as any other finding.
|
|
35
|
+
export const INTERFACE_MODES = ['warn', 'tier'];
|
|
32
36
|
|
|
33
37
|
const KEYS = Object.keys(DEFAULT_POLICY);
|
|
34
38
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -64,6 +68,7 @@ export function validatePolicy(raw) {
|
|
|
64
68
|
else for (const c of raw.checks) if (!CHECKS.includes(c)) errs.push(`checks: ${printable(c, 40)} is not one of ${CHECKS.join(', ')}`);
|
|
65
69
|
}
|
|
66
70
|
if ('requireEquivalence' in raw && !EQUIVALENCE_MODES.includes(raw.requireEquivalence)) errs.push(`requireEquivalence takes ${EQUIVALENCE_MODES.join(', ')}`);
|
|
71
|
+
if ('interface' in raw && !INTERFACE_MODES.includes(raw.interface)) errs.push(`interface takes ${INTERFACE_MODES.join(', ')}`);
|
|
67
72
|
if ('forbid' in raw) {
|
|
68
73
|
if (!isObject(raw.forbid)) errs.push(`forbid must be an object keyed by ${COMPILERS.join(', ')}`);
|
|
69
74
|
else for (const [c, list] of Object.entries(raw.forbid)) {
|
|
@@ -142,6 +147,7 @@ export function combinePolicies({ floor = null, repository = null } = {}) {
|
|
|
142
147
|
checks: checks.value,
|
|
143
148
|
forbid: Object.fromEntries(COMPILERS.map((c) => [c, union((f.forbid || {})[c] || [], (r.forbid || {})[c] || [])])),
|
|
144
149
|
requireEquivalence: pick('requireEquivalence', (a, b) => (EQUIVALENCE_MODES.indexOf(a) >= EQUIVALENCE_MODES.indexOf(b) ? a : b)),
|
|
150
|
+
interface: pick('interface', (a, b) => (a === 'tier' || b === 'tier' ? 'tier' : 'warn')),
|
|
145
151
|
rules: {},
|
|
146
152
|
};
|
|
147
153
|
setBy.checks = checks.by;
|
package/lib/precompile-cics.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const builtinValue = (fn, name) => (fn === 'RESP' ? DFHRESP : DFHVALUE)[n
|
|
|
12
12
|
const squeeze = (text) => text.replace(/('[^']*'|"[^"]*")|\s+/g, (_, lit) => lit || ' ').trim();
|
|
13
13
|
|
|
14
14
|
// The options of an EXEC CICS command in order: each word, with its argument's text when it has one.
|
|
15
|
-
function cicsOptions(text) {
|
|
15
|
+
export function cicsOptions(text) {
|
|
16
16
|
const out = [];
|
|
17
17
|
let i = 0;
|
|
18
18
|
while (i < text.length) {
|
package/lib/revision.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"67c696c3d6f74d20137372b5f0a756e0b27753dd","tag":"v0.6.0"}
|
package/lib/sarif.mjs
CHANGED
|
@@ -213,6 +213,9 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
|
|
|
213
213
|
...(f.reach ? { reach: f.reach } : {}),
|
|
214
214
|
...(f.effect ? { effect: f.effect } : {}),
|
|
215
215
|
...(f.exploitability ? { exploitability: f.exploitability } : {}),
|
|
216
|
+
// How an abend's input reached the program: through its own entry, or as arguments to
|
|
217
|
+
// a subprogram no caller run is shown to pass.
|
|
218
|
+
...(f.abend?.inputFrom ? { inputFrom: f.abend.inputFrom } : {}),
|
|
216
219
|
} } : {}),
|
|
217
220
|
locations: [{ physicalLocation: { artifactLocation: { uri: uriOf(f.path), uriBaseId: '%SRCROOT%' }, region: { startLine: f.line || 1 } } }],
|
|
218
221
|
...(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/scan.mjs
CHANGED
|
@@ -3,7 +3,8 @@ 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';
|
|
6
|
-
import {
|
|
6
|
+
import { inventorySteps } from './inventory.mjs';
|
|
7
|
+
import { driveTogether, sharingTree } from './kernel/shared-pass.mjs';
|
|
7
8
|
import { stoppedBecause } from './kernel/memory.mjs';
|
|
8
9
|
import { complianceMap, FRAMEWORKS_LOADED, FRAMEWORKS_UNLOADED } from './compliance.mjs';
|
|
9
10
|
import { stampFingerprints, FINGERPRINT_VERSION } from './kernel/identity.mjs';
|
|
@@ -178,13 +179,12 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
// A set that throws is reported as not having run, and the other sets' findings stand.
|
|
182
|
+
const notRun = (set, e) => report(set.name, { rules: set.rules, findings: [], stats: {
|
|
183
|
+
coverageIncomplete: true,
|
|
184
|
+
notRead: `the rule set stopped on an error and reported nothing: ${printable(e?.message ?? e)}`,
|
|
185
|
+
} });
|
|
181
186
|
function runSet(set, root, o) {
|
|
182
|
-
try { return set.scan(root, o); } catch (e) {
|
|
183
|
-
return report(set.name, { rules: set.rules, findings: [], stats: {
|
|
184
|
-
coverageIncomplete: true,
|
|
185
|
-
notRead: `the rule set stopped on an error and reported nothing: ${printable(e?.message ?? e)}`,
|
|
186
|
-
} });
|
|
187
|
-
}
|
|
187
|
+
try { return set.scan(root, o); } catch (e) { return notRun(set, e); }
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
export function scanAll(root, opts = {}) {
|
|
@@ -193,12 +193,22 @@ export function scanAll(root, opts = {}) {
|
|
|
193
193
|
// One walk of the tree for the whole scan. Every rule set used to call buildFileIndex(root) for
|
|
194
194
|
// itself, so a scan read every directory entry and resolved every symlink nine times over, and
|
|
195
195
|
// added a tenth walk each time a rule set was added.
|
|
196
|
-
const tree = opts.tree || directoryTree(root, opts);
|
|
196
|
+
const tree = sharingTree(opts.tree || directoryTree(root, opts));
|
|
197
197
|
const o = { ...opts, tree };
|
|
198
|
-
const inv = inventory(root, o);
|
|
199
198
|
// One loop over the registry, in its order. A set that is not registered cannot run, and
|
|
200
|
-
// cannot report a silent zero on its own behalf either.
|
|
201
|
-
|
|
199
|
+
// cannot report a silent zero on its own behalf either. The inventory and the sets that read
|
|
200
|
+
// programs share one pass, so each program is read and parsed once.
|
|
201
|
+
const chosen = REGISTRY.filter((set) => only.includes(set.name));
|
|
202
|
+
const stepped = chosen.filter((set) => set.steps);
|
|
203
|
+
const [invResult, ...stepResults] = driveTogether([inventorySteps(root, o), ...stepped.map((set) => set.steps(root, o))], tree);
|
|
204
|
+
if (invResult && invResult.error) throw invResult.error;
|
|
205
|
+
const inv = invResult;
|
|
206
|
+
const byName = new Map(stepped.map((set, k) => [set.name, stepResults[k]]));
|
|
207
|
+
const parts = chosen.map((set) => {
|
|
208
|
+
if (!byName.has(set.name)) return runSet(set, root, o);
|
|
209
|
+
const r = byName.get(set.name);
|
|
210
|
+
return r && r.error ? notRun(set, r.error) : r;
|
|
211
|
+
});
|
|
202
212
|
|
|
203
213
|
const flowPart = parts.find(p => p.tool === 'cobolwork-flow');
|
|
204
214
|
const overBudget = flowPart ? flowPart.summary.filesOverBudget || 0 : 0;
|
package/lib/sets/abend.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { createHash } from 'node:crypto';
|
|
|
10
10
|
import { delimiter, join, posix, basename } from 'node:path';
|
|
11
11
|
import { report } from '../kernel/ruleset.mjs';
|
|
12
12
|
import { verifyEvidence } from '../evidence/verify.mjs';
|
|
13
|
+
import { NOT_RUN_ABENDS } from '../ironwork-ids.mjs';
|
|
13
14
|
|
|
14
15
|
export const ABEND_RULES = {
|
|
15
16
|
'input-causes-abend-s0c7': {
|
|
@@ -30,6 +31,18 @@ export const ABEND_RULES = {
|
|
|
30
31
|
impact: 'SSRANGE caught the overrun and ended the run; compiled without SSRANGE, as production builds often are, the same input reads or writes storage beside the table or field',
|
|
31
32
|
remedy: 'Check the index against both ends of the table or field before the statement the journal names, and keep SSRANGE on for the build',
|
|
32
33
|
},
|
|
34
|
+
'input-causes-hang': {
|
|
35
|
+
sev: 'med', evidence: 'execution', cwe: 'CWE-834',
|
|
36
|
+
text: 'An input the program accepts keeps a loop running past the statement limit that stands in for its time limit (S322)',
|
|
37
|
+
impact: 'Whoever supplies that input decides how long the loop runs; on z/OS a long enough run holds the step and its processor until TIME= ends it, and the jobs behind it wait. The run shows the loop passed the limit, not that it would never end',
|
|
38
|
+
remedy: 'Find the loop at the line the journal names and bound its iterations: check the count or the exit condition the input supplies before the loop starts, or stop it at a counter and reject the input that would pass it',
|
|
39
|
+
},
|
|
40
|
+
'input-selects-program': {
|
|
41
|
+
sev: 'high', evidence: 'execution', cwe: 'CWE-470',
|
|
42
|
+
text: 'An input the program accepts chooses the program a CALL loads',
|
|
43
|
+
impact: "Whoever supplies that input picks which load module runs with this program's authority and data; the fuzz run named one the library does not hold, so the run ended in S806, but a name the library does hold runs",
|
|
44
|
+
remedy: 'Choose the called program from a fixed set of names checked against the input, or call it by a literal name, at the CALL the journal names',
|
|
45
|
+
},
|
|
33
46
|
'input-causes-abend': {
|
|
34
47
|
sev: 'med', evidence: 'execution', cwe: 'CWE-248',
|
|
35
48
|
text: 'An input the program accepts ends its run with an abend',
|
|
@@ -38,6 +51,14 @@ export const ABEND_RULES = {
|
|
|
38
51
|
},
|
|
39
52
|
};
|
|
40
53
|
|
|
54
|
+
// What the manifest's `optimized` says of an abend: whether the same input ends the same way with
|
|
55
|
+
// the program compiled at OPTIMIZE(2), where IBM may compare invalid data by its bytes rather than
|
|
56
|
+
// end in a data exception at OPTIMIZE(0), its default.
|
|
57
|
+
const OPTIMIZED = {
|
|
58
|
+
true: '; compiled with OPTIMIZE(2) it ends the same way',
|
|
59
|
+
false: '; it holds at OPTIMIZE(0), IBM\'s default: compiled with OPTIMIZE(2) the same input does not end there',
|
|
60
|
+
};
|
|
61
|
+
|
|
41
62
|
// IBM's messages for an SSRANGE failure: a subscript or index, an OCCURS DEPENDING ON object, and a
|
|
42
63
|
// reference modification's start, length, and start plus length.
|
|
43
64
|
const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
|
|
@@ -47,9 +68,16 @@ export function abendRunPaths(opts = {}) {
|
|
|
47
68
|
return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
|
|
48
69
|
}
|
|
49
70
|
|
|
71
|
+
// How ironwork ends the message of a data or protection exception in a CICS task, whose abend
|
|
72
|
+
// code CICS reports as ASRA.
|
|
73
|
+
const CICS_PROGRAM_CHECK = /\((S0C7|S0C4), which CICS reports as ASRA\)$/;
|
|
74
|
+
|
|
50
75
|
export function abendRule(abend) {
|
|
51
|
-
|
|
52
|
-
if (
|
|
76
|
+
const code = (abend.code === 'ASRA' && CICS_PROGRAM_CHECK.exec(abend.message || '')?.[1]) || abend.code;
|
|
77
|
+
if (code === 'S0C7') return 'input-causes-abend-s0c7';
|
|
78
|
+
if (code === 'S0C4') return 'input-causes-abend-s0c4';
|
|
79
|
+
if (code === 'S322') return 'input-causes-hang';
|
|
80
|
+
if (code === 'S806') return 'input-selects-program';
|
|
53
81
|
if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
|
|
54
82
|
return 'input-causes-abend';
|
|
55
83
|
}
|
|
@@ -63,16 +91,35 @@ function journalAbend(evidenceDir, runId) {
|
|
|
63
91
|
let abend = null;
|
|
64
92
|
const inputs = [];
|
|
65
93
|
const calls = [];
|
|
94
|
+
const sinks = [];
|
|
66
95
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
67
96
|
if (!line) continue;
|
|
68
97
|
const rec = JSON.parse(line);
|
|
69
98
|
if (rec.kind === 'abend') abend ??= rec;
|
|
70
99
|
else if (rec.kind === 'input') inputs.push(rec);
|
|
71
100
|
else if (rec.kind === 'call') calls.push(rec);
|
|
101
|
+
else if (rec.kind === 'sink') sinks.push(rec);
|
|
72
102
|
}
|
|
73
|
-
return abend && { ...abend, inputs, calls };
|
|
103
|
+
return abend && { ...abend, inputs, calls, sinks };
|
|
74
104
|
}
|
|
75
105
|
|
|
106
|
+
const MANIFEST_FORMAT = 'ironwork-fuzz/v1';
|
|
107
|
+
// A subprogram fuzzed at its interface: its inputs are arguments, and a caller passing them is
|
|
108
|
+
// not shown to exist (ironwork docs/evidence.md §5.2).
|
|
109
|
+
const INTERFACE_FORMAT = 'ironwork-fuzz-interface/v1';
|
|
110
|
+
const FORMATS = [MANIFEST_FORMAT, INTERFACE_FORMAT];
|
|
111
|
+
|
|
112
|
+
// What a finding from an interface run says of the caller its input needs.
|
|
113
|
+
function interfaceClause(callers) {
|
|
114
|
+
const where = callers.length
|
|
115
|
+
? `with arguments shaped by the CALL${callers.length === 1 ? '' : 's'} at ${callers.map((c) => `${c.file}:${c.line}`).join(', ')}`
|
|
116
|
+
: 'on generated arguments, with no CALL to it in the scanned tree';
|
|
117
|
+
return `; it ran as a subprogram, ${where}, on its first call in its initial state: a caller passing these bytes ends it, and no caller run shows one does`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ironwork's own abend codes for a construct it does not run: no IBM system would raise them.
|
|
121
|
+
const NOT_MODELLED = new Set(NOT_RUN_ABENDS);
|
|
122
|
+
|
|
76
123
|
const isString = (x) => typeof x === 'string' && x.length > 0;
|
|
77
124
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
78
125
|
|
|
@@ -113,6 +160,15 @@ export function loadAbendRun(dir, tree) {
|
|
|
113
160
|
out.problems.push('manifest.json is not an ironwork-fuzz manifest with a program, inputs and runs');
|
|
114
161
|
return out;
|
|
115
162
|
}
|
|
163
|
+
// A manifest from before ironwork wrote `format` has the first format's shape.
|
|
164
|
+
if (doc.format !== undefined && !FORMATS.includes(doc.format)) {
|
|
165
|
+
out.problems.push(`manifest.json is in format ${JSON.stringify(doc.format)}, and this cobolwork reads ${FORMATS.join(' and ')}`);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
const inputFrom = doc.format === INTERFACE_FORMAT ? 'interface' : 'entry';
|
|
169
|
+
const callers = inputFrom === 'interface' && Array.isArray(doc.callers)
|
|
170
|
+
? doc.callers.filter((c) => c && isString(c.file) && Number.isInteger(c.line)).map((c) => ({ file: posix.normalize(c.file.replace(/\\/g, '/')), line: c.line }))
|
|
171
|
+
: [];
|
|
116
172
|
const programFile = posix.normalize(doc.program.file.replace(/\\/g, '/'));
|
|
117
173
|
if (programFile.startsWith('../') || posix.isAbsolute(programFile)) {
|
|
118
174
|
out.problems.push(`the program ${doc.program.file} is outside the scanned tree`);
|
|
@@ -131,7 +187,7 @@ export function loadAbendRun(dir, tree) {
|
|
|
131
187
|
const seen = new Map();
|
|
132
188
|
for (const run of doc.runs) {
|
|
133
189
|
if (!run || run.outcome !== 'abend' || !run.abend || !isString(run.abend.code)) continue;
|
|
134
|
-
if (run.abend.code
|
|
190
|
+
if (NOT_MODELLED.has(run.abend.code)) { out.notModelled++; continue; }
|
|
135
191
|
const recorded = journalAbend(evidenceDir, run.journal);
|
|
136
192
|
if (!recorded || recorded.code !== run.abend.code) {
|
|
137
193
|
out.problems.push(`run ${run.journal ?? '(unnamed)'}: its journal does not record the abend ${run.abend.code} the manifest gives`);
|
|
@@ -152,16 +208,23 @@ export function loadAbendRun(dir, tree) {
|
|
|
152
208
|
const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
|
|
153
209
|
const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
|
|
154
210
|
const rule = abendRule(run.abend);
|
|
211
|
+
// An S806 is input choosing the program only where the run's own journal records the marker
|
|
212
|
+
// fuzz put in the input reaching that CALL's program load.
|
|
213
|
+
if (rule === 'input-selects-program' && !recorded.sinks.some((k) => k.sink === 'dynamic-program-load' && k.reached === true && k.file === place.file && k.line === place.line)) {
|
|
214
|
+
out.problems.push(`run ${run.journal}: its journal does not record the input reaching the CALL at ${place.file}:${place.line}, so its S806 is not reported`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
155
217
|
const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
|
|
156
|
-
.map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}) }));
|
|
218
|
+
.map((i) => ({ kind: i.kind, name: i.name, bytes: i.bytes, ...(i.minimized ? { minimized: true } : {}), ...(Number.isInteger(i.position) ? { position: i.position } : {}), ...(i.omitted === true ? { omitted: true } : {}) }));
|
|
157
219
|
const key = `${rule}|${where}|${line}`;
|
|
158
220
|
const size = used.reduce((n, i) => n + String(i.bytes || '').length, 0);
|
|
159
221
|
const held = seen.get(key);
|
|
160
222
|
if (held && held.size <= size) continue;
|
|
223
|
+
const optimized = typeof run.abend.optimized === 'boolean' ? run.abend.optimized : undefined;
|
|
161
224
|
const finding = {
|
|
162
225
|
rule, path: where, line, program: out.program.id,
|
|
163
|
-
detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept`,
|
|
164
|
-
abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}) },
|
|
226
|
+
detail: `${out.program.id || programFile} ended with ${run.abend.code}${isString(run.abend.message) ? ` (${run.abend.message})` : ''} on an input the fuzz run ${out.dir} kept${OPTIMIZED[optimized] ?? ''}${inputFrom === 'interface' ? interfaceClause(callers) : ''}`,
|
|
227
|
+
abend: { code: run.abend.code, ...(isString(run.abend.message) ? { message: run.abend.message } : {}), ...(optimized === undefined ? {} : { optimized }), inputFrom, ...(inputFrom === 'interface' ? { callers } : {}) },
|
|
165
228
|
input: used, run: { dir: out.dir, journal: run.journal },
|
|
166
229
|
};
|
|
167
230
|
seen.set(key, { size, finding });
|
|
@@ -179,6 +242,7 @@ export function scanAbend(root, opts = {}) {
|
|
|
179
242
|
abendRuns: runs.map((r) => ({ dir: r.dir, program: r.program ? r.program.file : null, findings: r.findings.length, ...(r.notModelled ? { notModelled: r.notModelled } : {}), ...(r.counts ? { counts: r.counts } : {}) })),
|
|
180
243
|
...(runs.some((r) => r.problems.length) ? { abendRunProblems: runs.flatMap((r) => r.problems.map((p) => `${r.dir}: ${p}`)) } : {}),
|
|
181
244
|
...(runs.some((r) => r.findings.some((f) => !existsSync(join(root, f.path)))) ? { abendRunsElsewhere: runs.flatMap((r) => r.findings.filter((f) => !existsSync(join(root, f.path))).map((f) => `${r.dir}: ${f.path}`)) } : {}),
|
|
245
|
+
byInputFrom: { entry: findings.filter((f) => f.abend.inputFrom === 'entry').length, interface: findings.filter((f) => f.abend.inputFrom === 'interface').length },
|
|
182
246
|
setIncomplete: runs.some((r) => r.problems.length > 0),
|
|
183
247
|
};
|
|
184
248
|
return report('abend', { rules: ABEND_RULES, findings, stats, run: null });
|
package/lib/sets/build.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const COMPILERS = new Set(['gnucobol', 'opentext-cobol', 'ibm-enterprise-cobol']
|
|
|
55
55
|
|
|
56
56
|
// Files that decide what the build runs. A repository states this in one of a small number of
|
|
57
57
|
// conventional places, and reading only those keeps the rule from matching prose in a README.
|
|
58
|
-
function isBuildFile(path) {
|
|
58
|
+
export function isBuildFile(path) {
|
|
59
59
|
const name = basename(path);
|
|
60
60
|
const p = path.replace(/\\/g, '/').toLowerCase();
|
|
61
61
|
if (/^(dockerfile|containerfile)(\..+)?$/i.test(name)) return true;
|
|
@@ -98,6 +98,21 @@ const PINS = [
|
|
|
98
98
|
{ product: 'cics-transaction-gateway', re: /from\s+\S*cics[-_]?transaction[-_]?gateway\S*:v?(\d+(?:\.\d+)+)/gi },
|
|
99
99
|
];
|
|
100
100
|
|
|
101
|
+
// Every product version the text pins, once each, with the text that pins it.
|
|
102
|
+
export function pinsIn(src) {
|
|
103
|
+
const out = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const { product, re } of PINS) {
|
|
106
|
+
for (const m of src.matchAll(re)) {
|
|
107
|
+
const key = `${product}@${m[1]}`;
|
|
108
|
+
if (seen.has(key)) continue;
|
|
109
|
+
seen.add(key);
|
|
110
|
+
out.push({ product, version: m[1], text: m[0] });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
function withMavenProperties(src) {
|
|
102
117
|
const block = src.match(/<properties>([\s\S]*?)<\/properties>/i);
|
|
103
118
|
if (!block) return src;
|
|
@@ -189,42 +204,33 @@ export function scanBuild(root, opts = {}) {
|
|
|
189
204
|
if (/pom\.xml$/i.test(path)) src = withMavenProperties(src);
|
|
190
205
|
const lines = src.split(/\r?\n/);
|
|
191
206
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
});
|
|
220
|
-
} else {
|
|
221
|
-
findings.push({
|
|
222
|
-
rule: `build-pins-vulnerable-${kind}`, path, line,
|
|
223
|
-
detail: `${path} pins ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}` +
|
|
224
|
-
(age.stale ? `. The KEV snapshot is ${age.days} days old, so whether any of these is now known to be exploited was not checked against a current catalogue` : ''),
|
|
225
|
-
related: hits.map(relatedOf),
|
|
226
|
-
});
|
|
227
|
-
}
|
|
207
|
+
for (const { product, version, text } of pinsIn(src)) {
|
|
208
|
+
stats.pinsFound++;
|
|
209
|
+
|
|
210
|
+
const hits = advisoriesFor(product, version, extra);
|
|
211
|
+
if (!hits.length) continue;
|
|
212
|
+
|
|
213
|
+
// A pin written across lines, as Maven writes one, is reported at its version.
|
|
214
|
+
const needle = text.includes('\n') ? text.split('\n').pop().trim() : text;
|
|
215
|
+
const line = lines.findIndex((l) => l.includes(needle)) + 1 || 1;
|
|
216
|
+
const exploited = hits.filter((a) => isKnownExploited(a.id));
|
|
217
|
+
const ids = hits.map((a) => a.id).sort(byText);
|
|
218
|
+
|
|
219
|
+
const kind = COMPILERS.has(product) ? 'compiler' : 'component';
|
|
220
|
+
if (exploited.length) {
|
|
221
|
+
findings.push({
|
|
222
|
+
rule: `build-pins-exploited-${kind}`, path, line,
|
|
223
|
+
detail: `${path} pins ${product} ${version}, which ${exploited.map((a) => a.id).sort(byText).join(', ')} affects and CISA lists as known to be exploited${fromFeeds(hits)}`,
|
|
224
|
+
related: hits.map(relatedOf),
|
|
225
|
+
knownExploited: exploited.map((a) => a.id).sort(byText),
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
findings.push({
|
|
229
|
+
rule: `build-pins-vulnerable-${kind}`, path, line,
|
|
230
|
+
detail: `${path} pins ${product} ${version}, which ${countOf(hits)}: ${ids.join(', ')}${fromFeeds(hits)}` +
|
|
231
|
+
(age.stale ? `. The KEV snapshot is ${age.days} days old, so whether any of these is now known to be exploited was not checked against a current catalogue` : ''),
|
|
232
|
+
related: hits.map(relatedOf),
|
|
233
|
+
});
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
236
|
return src.length;
|