@portll/cobolwork 0.4.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 +53 -5
- package/bin/cobolwork.mjs +34 -9
- package/lib/baseline.mjs +9 -0
- package/lib/bms.mjs +8 -0
- package/lib/build.mjs +33 -8
- package/lib/capabilities.mjs +6 -3
- package/lib/consequence.mjs +17 -0
- package/lib/constants.mjs +60 -0
- package/lib/control-reuse.mjs +113 -0
- package/lib/control.mjs +86 -18
- package/lib/csd.mjs +6 -1
- package/lib/dataflow.mjs +343 -95
- 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/hlasm.mjs +79 -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 +24 -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 +115 -13
- 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/crypto.mjs +110 -0
- package/lib/sets/flow.mjs +32 -7
- package/lib/sets/hlasm.mjs +177 -0
- package/lib/sets/jcl.mjs +15 -1
- 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 +104 -7
- package/lib/sources.mjs +12 -2
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +216 -0
- package/rules/compliance-dora.json +234 -0
- package/rules/compliance-ffiec.json +234 -0
- package/rules/compliance-nist80053.json +234 -0
- package/rules/gitleaks-mainframe.toml +61 -16
- package/rules/hlasm-instructions.json +2396 -0
- package/rules/hlasm-operations.json +451 -0
- package/rules/icsf-services.json +1178 -0
- package/schema/cobolwork.policy.schema.json +7 -0
package/README.md
CHANGED
|
@@ -63,6 +63,19 @@ request and writes SARIF for code scanning: [docs/github-action.md](docs/github-
|
|
|
63
63
|
| JCL | credentials and security commands in in-stream data, destructive statements, `DLM=` tricks, FTP in cleartext or sending production data, production data touched by a test job |
|
|
64
64
|
| The source | names nothing declares (code that cannot compile), shadowed copybooks, payloads hidden in columns 73-80 or aimed at AI readers |
|
|
65
65
|
| The estate | production names outside production jobs, routable addresses, compiler and runtime versions with published advisories |
|
|
66
|
+
| Assembler | a switch to key zero or supervisor state, an instruction run through `EX`, cross-memory calls, a module named at run time, the security product called directly, and the CSECT or ENTRY a COBOL `CALL` or a job step reaches |
|
|
67
|
+
| Cryptography | a single-length DES key, an MD5 or SHA-1 hash, or a fixed initialization vector asked of ICSF, read from IBM's parameter lists; an outbound CICS connection asking for HTTP |
|
|
68
|
+
| Secrets | a credential written into a program or copybook: a literal `VALUE` on an item named for one, or a literal password in `EXEC SQL CONNECT` or `EXEC CICS SIGNON` |
|
|
69
|
+
|
|
70
|
+
HLASM is read, not assembled. Each statement is parsed by what its operation is: a machine
|
|
71
|
+
instruction against IBM's operand syntax for its mnemonic (`rules/hlasm-instructions.json`, every
|
|
72
|
+
z/Architecture mnemonic with its page in the Principles of Operation), an assembler instruction,
|
|
73
|
+
or a z/OS, Language Environment or HLASM Toolkit macro against the keywords IBM lists. The location
|
|
74
|
+
and length of each symbol is worked out as the assembler would, and nothing after a statement of
|
|
75
|
+
unknown length is placed. Macros are not expanded and conditional assembly is not evaluated, so an
|
|
76
|
+
operation a site macro issues is seen where the macro is defined, and not where it is used. A scan
|
|
77
|
+
names each macro it did not expand, each COPY member the tree does not hold, and each statement it
|
|
78
|
+
could not read.
|
|
66
79
|
|
|
67
80
|
[docs/rule-sets.md](docs/rule-sets.md) describes each in full, with what it deliberately leaves out.
|
|
68
81
|
Vendor packs for CA ACF2 and Top Secret, Control-M and Connect:Direct load only for estates that
|
|
@@ -107,6 +120,15 @@ that caller's storage. A value the subprogram keeps in its own storage can reach
|
|
|
107
120
|
Findings carry no source text, so a report can be stored and passed on without carrying the code
|
|
108
121
|
with it.
|
|
109
122
|
|
|
123
|
+
`trace` is one route, the shortest, and sources that reach one sink are merged into one finding,
|
|
124
|
+
counted in `sources`. With `--all-routes`, `scan` and `flow` give each path finding `routes`: every
|
|
125
|
+
source, and every statement on any route from one of them to the sink, by verb, file and line.
|
|
126
|
+
`complete` is false where the walk stopped at its budget or a step has no statement the engine can
|
|
127
|
+
place. It leaves out no statement on a route the engine follows, and may name one no route takes:
|
|
128
|
+
what a reader needs before saying a run covered every route (`docs/spec/reach.md` §9.8). Routes
|
|
129
|
+
through what the engine does not read, such as an unparsed program or a caller written in another
|
|
130
|
+
language, are not in it.
|
|
131
|
+
|
|
110
132
|
`fingerprint` is what the finding is, rather than where it is printed today: the rule, the program
|
|
111
133
|
and the paragraph or section it sits in (the job, step and DD for JCL), and the flagged statement's
|
|
112
134
|
own text. No line number goes into it, so code added above a finding does not change it. `diff`
|
|
@@ -199,8 +221,13 @@ entered ([docs/spec/evidence.md](docs/spec/evidence.md) §13.5).
|
|
|
199
221
|
|
|
200
222
|
`COBOLWORK_ABENDS` names fuzz runs ironwork's harness wrote. Each abend an input caused becomes a
|
|
201
223
|
finding at the line it happened, with the input and the run's journal, once that journal verifies:
|
|
202
|
-
`input-causes-abend-s0c7`, `-s0c4
|
|
203
|
-
(
|
|
224
|
+
`input-causes-abend-s0c7`, `-s0c4` (in a CICS task, the ASRA whose message names that check),
|
|
225
|
+
`-subscript-range`, `input-causes-hang` for an input that keeps a loop running past the statement limit (S322), `input-selects-program`
|
|
226
|
+
for an S806 whose journal shows the input reaching the CALL, or `input-causes-abend` for any other
|
|
227
|
+
code ([docs/spec/evidence.md](docs/spec/evidence.md) §13.6). It reads manifests in format
|
|
228
|
+
`ironwork-fuzz/v1`, and `ironwork-fuzz-interface/v1` from a subprogram fuzzed with generated
|
|
229
|
+
arguments, whose findings say no caller run shows a caller passes them and warn by default; it
|
|
230
|
+
reports one in any other format as a problem instead of guessing at it.
|
|
204
231
|
|
|
205
232
|
### What each finding lets someone do, and the fix
|
|
206
233
|
|
|
@@ -328,11 +355,32 @@ stand-in in `diag/precompiler.mjs`, which rewrites what the parser would otherwi
|
|
|
328
355
|
tests compare the parser with the compiler's answers kept in `test/fixtures/parser/*.golden.json`,
|
|
329
356
|
so they run without GnuCOBOL.
|
|
330
357
|
|
|
331
|
-
|
|
358
|
+
HLASM is graded against z390, an HLASM-compatible assembler, by `diag/hlasm-oracle.mjs` and
|
|
359
|
+
`diag/hlasm-grade-z390.mjs`. Each file is assembled twice, the second time with sixteen bytes after
|
|
360
|
+
every macro call, and only values the two runs agree on are graded. None of them depends on how
|
|
361
|
+
z390's macros, or IBM's, expand. Measured 2026-10-03 over the files z390 assembles:
|
|
362
|
+
|
|
363
|
+
| Corpus | Files | Symbol locations | Statement locations | Instruction lengths | External names |
|
|
364
|
+
|---|---|---|---|---|---|
|
|
365
|
+
| 12 repositories, dev | 48 | 935 agree of 936, 0 differ | 2,511 of 2,546, 0 differ | 2,273 of 2,273 | 55 of 55 |
|
|
366
|
+
| 17 repositories, held out | 76 | 546 agree of 557, 0 differ | 1,631 of 1,675, 0 differ | 1,710 of 1,710 | 90 of 92 |
|
|
367
|
+
|
|
368
|
+
A value not agreeing was not placed, never placed differently: it follows a statement of unknown
|
|
369
|
+
length. Two of z390's values are not graded, because it differs from the Language Reference, which
|
|
370
|
+
the reader follows: an EQU's length, where z390 gives 1 and the reference the length of the
|
|
371
|
+
leftmost term; and anything after a literal pool where z390 pads a literal that the reference packs.
|
|
372
|
+
A file with an error that can move a location is not graded either, such as an undefined symbol in
|
|
373
|
+
a statement holding a literal, which z390 then leaves out of the pool. Of the statements outside conditional
|
|
374
|
+
assembly, 99.5% parse on the dev corpus and 99.7% on the held-out one; the rest are counted by
|
|
375
|
+
kind. `bench/hlasm-locate/` holds small programs, one assembler feature each, with z390's answers
|
|
376
|
+
recorded beside them, so the tests check the locator without z390.
|
|
377
|
+
|
|
378
|
+
`bench/cases/` holds 137 CWE-labelled cases, each paired with a near-miss negative: the same shape
|
|
332
379
|
with the flaw removed. `node bench/run.mjs` scores any scanner's findings against them, by rule and
|
|
333
380
|
file, never by line, and `npm test` fails if any case scores differently from its declaration.
|
|
334
|
-
`--validate` compiles every COBOL case with GnuCOBOL
|
|
335
|
-
|
|
381
|
+
`--validate` compiles every COBOL case with GnuCOBOL, assembles every HLASM case with z390 when
|
|
382
|
+
`Z390` names a release (stubbing the macros z390 does not ship), and checks every JCL case against
|
|
383
|
+
the statement grammar, a weaker witness, and says so.
|
|
336
384
|
|
|
337
385
|
## Coverage on a busy machine
|
|
338
386
|
|
package/bin/cobolwork.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { stampFingerprints } from '../lib/kernel/identity.mjs';
|
|
|
19
19
|
import { pdsExportTree } from '../lib/kernel/source-tree.mjs';
|
|
20
20
|
import { SITE_FILE } from '../lib/site.mjs';
|
|
21
21
|
import { tally } from '../lib/kernel/findings.mjs';
|
|
22
|
-
import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, SUPPRESSING } from '../lib/baseline.mjs';
|
|
22
|
+
import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, BASELINE_VERSION, SUPPRESSING } from '../lib/baseline.mjs';
|
|
23
23
|
import { readReport } from '../lib/tui/model.mjs';
|
|
24
24
|
import { nodeTerminal } from '../lib/tui/terminal.mjs';
|
|
25
25
|
import { runTui } from '../lib/tui/run.mjs';
|
|
@@ -28,7 +28,6 @@ import { printable } from '../lib/kernel/printable.mjs';
|
|
|
28
28
|
import { startEvidence, recordInputs, recordHashed, recordFindings, recordOutput, recordVerdict, recordBaselineWrite, finishEvidence } from '../lib/evidence/run.mjs';
|
|
29
29
|
import { evidenceCommand } from '../lib/evidence/cli.mjs';
|
|
30
30
|
import { slsaStatement } from '../lib/evidence/slsa.mjs';
|
|
31
|
-
import { parseAllowedSigners } from '../lib/evidence/sshsig.mjs';
|
|
32
31
|
|
|
33
32
|
const STARTED = new Date().toISOString();
|
|
34
33
|
|
|
@@ -80,6 +79,7 @@ Options
|
|
|
80
79
|
--rules-path gitleaks print the path of the mainframe credential rules and exit
|
|
81
80
|
--quiet summary only
|
|
82
81
|
--full-trace list every hop of a data flow path, not its two ends
|
|
82
|
+
--all-routes give each data flow finding every statement on any route from its sources (scan, flow)
|
|
83
83
|
--base <ref> diff: the git revision to compare against
|
|
84
84
|
--head <ref> diff: the revision under review (default: the working tree)
|
|
85
85
|
--baseline <file> judgements to apply; without it, ${BASELINE_FILE} in the scanned tree
|
|
@@ -107,18 +107,29 @@ Options
|
|
|
107
107
|
the SLSA Provenance v1 predicate (unsigned; the pipeline signs it)
|
|
108
108
|
--artifact <path>[,<path>] build: what the compiler produced, named as subjects of the SLSA statement
|
|
109
109
|
--equivalence <file>[,<file>] build --base: ironwork equivalence statements for the programs the
|
|
110
|
-
change edits
|
|
111
|
-
requireEquivalence (never, machineAuthored, always) says when one is required
|
|
110
|
+
change edits, each signed by one of --allowed-signers. The policy's
|
|
111
|
+
requireEquivalence (never, machineAuthored, always) says when one is required;
|
|
112
|
+
where one may be, a build with no --allowed-signers fails the check
|
|
112
113
|
--ironwork <path> build: after a pass, run ironwork check on every program, for an estate that
|
|
113
114
|
compiles with IBM Enterprise COBOL; a program ironwork rejects exits 4, one it
|
|
114
115
|
does not model yet leaves the build undecided
|
|
115
116
|
--evidence <dir> scan, flow, diff, gate, build, baseline, inventory: record this run in a
|
|
116
117
|
hash-chained journal and ledger there (or COBOLWORK_EVIDENCE); never inside
|
|
117
118
|
the tree being read
|
|
118
|
-
--allowed-signers <file> evidence verify: OpenSSH allowed_signers for namespace
|
|
119
|
+
--allowed-signers <file> evidence verify and build: OpenSSH allowed_signers for namespace
|
|
120
|
+
cobolwork-evidence; build refuses one inside the repository
|
|
119
121
|
--anchor-git <repo>, --ref <ref>, --push, --max-unsealed <n>
|
|
120
122
|
evidence: the git witness, the ref that counts (default @{upstream}), whether
|
|
121
123
|
anchor pushes, and how many ledger records may follow the newest seal
|
|
124
|
+
--anchor-pin <commit> evidence verify: the witness commit an earlier verify reported as
|
|
125
|
+
witnessCommit; the witness must still hold every seal it held
|
|
126
|
+
--tsq <file>, --tsr <file>, --tsa-ca <file>
|
|
127
|
+
evidence anchor writes an RFC 3161 request (and keeps it beside the seal);
|
|
128
|
+
verify reads the response, checking imprint and nonce, and has OpenSSL check
|
|
129
|
+
the authority's signature against the CA certificate
|
|
130
|
+
--cosign-bundle <file> with --cosign-key <file> or --certificate-identity <id> and
|
|
131
|
+
--certificate-oidc-issuer <url>, [--trusted-root <file>] [--insecure-ignore-tlog]
|
|
132
|
+
evidence verify: a transparency-log bundle over a seal, checked by cosign
|
|
122
133
|
|
|
123
134
|
Exit codes: 0 the command ran, 2 it could not run. A run that examined nothing says so in
|
|
124
135
|
summary.filesScanned and summary.nosrc rather than reporting a clean zero.
|
|
@@ -145,6 +156,7 @@ function parseArgs(argv) {
|
|
|
145
156
|
else if (a === '--rules-path') opts.rulesPath = argv[++i] || 'gitleaks';
|
|
146
157
|
else if (a === '--quiet') opts.quiet = true;
|
|
147
158
|
else if (a === '--full-trace') opts.fullTrace = true;
|
|
159
|
+
else if (a === '--all-routes') opts.allRoutes = true;
|
|
148
160
|
else if (a === '--base') opts.base = value();
|
|
149
161
|
else if (a === '--head') opts.head = value();
|
|
150
162
|
else if (a === '--baseline') opts.baseline = value();
|
|
@@ -180,6 +192,15 @@ function parseArgs(argv) {
|
|
|
180
192
|
else if (a === '--push') opts.push = true;
|
|
181
193
|
else if (a === '--max-unsealed') opts.maxUnsealed = value();
|
|
182
194
|
else if (a === '--tsq') opts.tsq = value();
|
|
195
|
+
else if (a === '--anchor-pin') opts.anchorPin = value();
|
|
196
|
+
else if (a === '--tsr') opts.tsr = value();
|
|
197
|
+
else if (a === '--tsa-ca') opts.tsaCa = value();
|
|
198
|
+
else if (a === '--cosign-bundle') opts.cosignBundle = value();
|
|
199
|
+
else if (a === '--certificate-identity') opts.certificateIdentity = value();
|
|
200
|
+
else if (a === '--certificate-oidc-issuer') opts.certificateOidcIssuer = value();
|
|
201
|
+
else if (a === '--cosign-key') opts.cosignKey = value();
|
|
202
|
+
else if (a === '--trusted-root') opts.trustedRoot = value();
|
|
203
|
+
else if (a === '--insecure-ignore-tlog') opts.insecureIgnoreTlog = true;
|
|
183
204
|
else if (a === '--expect-key') opts.expectKey = value();
|
|
184
205
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
185
206
|
else if (a === '--version' || a === '-v') opts.version = true;
|
|
@@ -250,6 +271,10 @@ if (opts.fullTrace && opts._.length && !TRACE_COMMANDS.includes(opts._[0])) {
|
|
|
250
271
|
`);
|
|
251
272
|
process.exit(2);
|
|
252
273
|
}
|
|
274
|
+
if (opts.allRoutes && opts._.length && !['scan', 'flow'].includes(opts._[0])) {
|
|
275
|
+
process.stderr.write(`cobolwork: --all-routes is for scan and flow\n`);
|
|
276
|
+
process.exit(2);
|
|
277
|
+
}
|
|
253
278
|
const ADVISORY_COMMANDS = ['scan', 'baseline', 'tui', 'explain', 'build'];
|
|
254
279
|
if (opts.advisoryFeeds && opts._.length && !ADVISORY_COMMANDS.includes(opts._[0])) {
|
|
255
280
|
process.stderr.write(`cobolwork: ${opts._[0]} reads no advisories; ${ADVISORY_COMMANDS.join(', ')} do\n`);
|
|
@@ -291,7 +316,7 @@ if (opts.evidence !== undefined && opts._.length && ![...JOURNALED, 'evidence'].
|
|
|
291
316
|
process.stderr.write(`cobolwork: ${opts._[0]} records no evidence; ${JOURNALED.join(', ')} and evidence do\n`);
|
|
292
317
|
process.exit(2);
|
|
293
318
|
}
|
|
294
|
-
const evidenceFlag = ['sshKey', 'signer', 'allowedSigners', 'anchorGit', 'ref', 'push', 'maxUnsealed', 'tsq', 'expectKey'].find((k) => opts[k] !== undefined);
|
|
319
|
+
const evidenceFlag = ['sshKey', 'signer', 'allowedSigners', 'anchorGit', 'ref', 'push', 'maxUnsealed', 'tsq', 'expectKey', 'anchorPin', 'tsr', 'tsaCa', 'cosignBundle', 'certificateIdentity', 'certificateOidcIssuer', 'cosignKey', 'trustedRoot', 'insecureIgnoreTlog'].find((k) => opts[k] !== undefined);
|
|
295
320
|
if (opts.equivalence && !opts.base) { process.stderr.write('cobolwork: --equivalence judges a change; it needs --base\n'); process.exit(2); }
|
|
296
321
|
if (evidenceFlag && opts._.length && opts._[0] !== 'evidence' && !(evidenceFlag === 'allowedSigners' && opts._[0] === 'build')) {
|
|
297
322
|
process.stderr.write(`cobolwork: --${evidenceFlag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} is for evidence only\n`);
|
|
@@ -340,7 +365,7 @@ try {
|
|
|
340
365
|
} else if (command === 'evidence') {
|
|
341
366
|
process.exitCode = evidenceCommand(target, opts, { toolVersion: VERSION, write: (s) => process.stdout.write(s) });
|
|
342
367
|
} else if (command === 'scan' || command === 'flow') {
|
|
343
|
-
const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
|
|
368
|
+
const flowOpts = { repos, fullTrace: opts.fullTrace === true, allRoutes: opts.allRoutes === true, systemDirs };
|
|
344
369
|
// The site file sits beside the members and is not one of them, so it is named rather than found.
|
|
345
370
|
const site = resolve(root, SITE_FILE);
|
|
346
371
|
const pds = opts.pdsExport ? pdsExportTree(root, { systemDirs }) : null;
|
|
@@ -387,7 +412,7 @@ try {
|
|
|
387
412
|
if (opts.only || opts.repos) { process.stderr.write('cobolwork: build judges one repository with every rule set; --only and --repos do not apply\n'); process.exit(2); }
|
|
388
413
|
if (opts.baseline) { process.stderr.write('cobolwork: build reads the baseline the change was written against; --baseline does not apply, --no-baseline does\n'); process.exit(2); }
|
|
389
414
|
if (opts.head && !opts.base) { process.stderr.write('cobolwork: build --head needs --base\n'); process.exit(2); }
|
|
390
|
-
const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, ironwork: opts.ironwork || null, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs, equivalence: opts.equivalence || [],
|
|
415
|
+
const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, ironwork: opts.ironwork || null, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs, equivalence: opts.equivalence || [], allowedSigners: opts.allowedSigners || null });
|
|
391
416
|
stampRevisions(result.doc.summary, opts.head || null);
|
|
392
417
|
Object.assign(result.report.summary, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
|
|
393
418
|
Object.assign(result.provenance, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
|
|
@@ -430,7 +455,7 @@ try {
|
|
|
430
455
|
if (held.problems.length) { process.stderr.write(`cobolwork: ${path} holds entries that do not validate, so it is left as it is:\n ${held.problems.join('\n ')}\n`); process.exit(2); }
|
|
431
456
|
const report = scanAll(root, { repos, only: opts.only, advisoryFeeds: opts.advisoryFeeds, systemDirs });
|
|
432
457
|
const { entries, added } = baselineEntries(report.findings, held.entries, { action, reason: opts.reason, who: opts.who, expires: expires.toISOString(), at, rules: opts.rule || null });
|
|
433
|
-
const written = JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', entries }, null, 1) + '\n';
|
|
458
|
+
const written = JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', version: BASELINE_VERSION, entries }, null, 1) + '\n';
|
|
434
459
|
writeFileSync(path, written);
|
|
435
460
|
recordInputs(journal, 0, root);
|
|
436
461
|
recordFindings(journal, report);
|
package/lib/baseline.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { resolvesInside } from './kernel/source-tree.mjs';
|
|
|
22
22
|
import { printable } from './kernel/printable.mjs';
|
|
23
23
|
|
|
24
24
|
export const BASELINE_FILE = 'cobolwork.baseline.json';
|
|
25
|
+
export const BASELINE_VERSION = 1;
|
|
25
26
|
export const SUPPRESSING = ['accept', 'false-positive', 'wont-fix', 'incorrect-scan-result'];
|
|
26
27
|
export const ACTIONS = [...SUPPRESSING, 'note', 'resolved'];
|
|
27
28
|
|
|
@@ -75,6 +76,14 @@ export function loadBaseline(root, { explicit = null, use = true, mayBeAbsent =
|
|
|
75
76
|
out.problems.push(`${path} is not readable as JSON: ${printable(err.message, 120)}`);
|
|
76
77
|
return out;
|
|
77
78
|
}
|
|
79
|
+
// A file with no version predates versioning and is read as version 0, which holds the same entries.
|
|
80
|
+
const version = raw && typeof raw === 'object' && raw.version !== undefined ? raw.version : 0;
|
|
81
|
+
if (!Number.isInteger(version) || version < 0) { out.problems.push(`${path} has version ${printable(JSON.stringify(version), 40)}, which is not a whole number from 0`); return out; }
|
|
82
|
+
if (version > BASELINE_VERSION) {
|
|
83
|
+
out.problems.push(`${path} is baseline version ${version}, and this cobolwork reads up to version ${BASELINE_VERSION}; upgrade cobolwork, so nothing was suppressed`);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
out.version = BASELINE_VERSION;
|
|
78
87
|
const entries = Array.isArray(raw?.entries) ? raw.entries : null;
|
|
79
88
|
if (!entries) { out.problems.push(`${path} has no entries array`); return out; }
|
|
80
89
|
entries.forEach((e, i) => {
|
package/lib/bms.mjs
CHANGED
|
@@ -55,6 +55,13 @@ const sublist = (v) => {
|
|
|
55
55
|
return (inner ? splitOperands(inner[1]) : [v]).map((x) => x.trim()).filter(Boolean);
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// L'NAME is NAME's length, and T', S', I', K', N', D' and O' its other attributes: the quote after the
|
|
59
|
+
// letter opens no string when the letter starts a term and a symbol follows. CL8'TEXT' and D'1.5'
|
|
60
|
+
// are constants, and their quotes do open one.
|
|
61
|
+
// https://www.ibm.com/docs/en/hla-and-tf/1.6.0?topic=instructions-data-attributes
|
|
62
|
+
const attributeReference = (text, i) => /[LTSIKNDO]/i.test(text[i - 1] || '')
|
|
63
|
+
&& !/[A-Z0-9$#@_]/i.test(text[i - 2] || '') && /[A-Z$#@_&]/i.test(text[i + 1] || '');
|
|
64
|
+
|
|
58
65
|
// Folds physical lines into statements. A map continues a statement in two ways, often both in one
|
|
59
66
|
// file. An operand that fills the line to column 71 carries on in column 16 of the next line, which
|
|
60
67
|
// is how a long INITIAL literal is split. A comma followed by a blank ends the line's operands and
|
|
@@ -78,6 +85,7 @@ export function foldStatements(src) {
|
|
|
78
85
|
for (let i = from; i < text.length; i++) {
|
|
79
86
|
const c = text[i];
|
|
80
87
|
if (o.quoted) { o.st.field += c; if (c === "'") o.quoted = false; continue; }
|
|
88
|
+
if (c === "'" && attributeReference(text, i)) { o.st.field += c; continue; }
|
|
81
89
|
if (c === "'") { o.quoted = true; o.st.field += c; continue; }
|
|
82
90
|
if (c === ' ' || c === '\t') {
|
|
83
91
|
o.reading = o.st.field.endsWith(',');
|
package/lib/build.mjs
CHANGED
|
@@ -7,12 +7,13 @@ import { optionChanges } from './option-diff.mjs';
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
-
import { basename, extname, join } from 'node:path';
|
|
10
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
11
|
+
import { parseAllowedSigners } from './evidence/sshsig.mjs';
|
|
11
12
|
import { scanAll } from './scan.mjs';
|
|
12
13
|
import { withRefs, configurationChanges, WORKING_TREE } from './diff.mjs';
|
|
13
14
|
import { pairLists, findExecutable } from './gate.mjs';
|
|
14
15
|
import { pairingKeys } from './kernel/identity.mjs';
|
|
15
|
-
import { directoryTree } from './kernel/source-tree.mjs';
|
|
16
|
+
import { directoryTree, resolvesInside } from './kernel/source-tree.mjs';
|
|
16
17
|
import { printable } from './kernel/printable.mjs';
|
|
17
18
|
import { ALL_RULES } from './kernel/registry.mjs';
|
|
18
19
|
import { loadBaseline, applyBaseline, SUPPRESSING } from './baseline.mjs';
|
|
@@ -21,7 +22,8 @@ import { isProgram, isJcl, isSource, readSource, relPath } from './sources.mjs';
|
|
|
21
22
|
import { parseJclFile } from './jcl.mjs';
|
|
22
23
|
import { POLICY_FILE, treePolicy, floorPolicy, combinePolicies, canonical, policyHash } from './policy.mjs';
|
|
23
24
|
import { classesOf, tierOf, TIERS, TIER_RANK } from './consequence.mjs';
|
|
24
|
-
import { optionCards, enterpriseChecks, compilerChecks, scriptedChecks, compilerInvocations, compilerTasks, compileStepOptions, compilerOf } from './options.mjs';
|
|
25
|
+
import { optionCards, enterpriseChecks, compilerChecks, scriptedChecks, compilerInvocations, compilerTasks, compileStepOptions, compilerOf, olderThan } from './options.mjs';
|
|
26
|
+
import { isBuildFile, pinsIn } from './sets/build.mjs';
|
|
25
27
|
import { FLOW_MODEL, TOOL_VERSION } from './version.mjs';
|
|
26
28
|
import { checkWithIronwork, ironworkVerdict, ironworkReasons, ironworkVersion, listed } from './ironwork.mjs';
|
|
27
29
|
import { buildFileIndex } from './parser.mjs';
|
|
@@ -62,8 +64,11 @@ function introducedIn(before, after, baseRoot, headRoot) {
|
|
|
62
64
|
// Why a finding blocks, or null. `renewed` is a finding whose waiver expired or ran past maxDays: in
|
|
63
65
|
// ratchet mode it is judged as new, because the promise that carried it has lapsed.
|
|
64
66
|
export function blockingReason(f, { policy, ratchet, introduced, renewed }) {
|
|
67
|
+
if (f.abend?.inputFrom === 'interface' && policy.interface !== 'tier') return null;
|
|
65
68
|
const setting = policy.rules[f.rule];
|
|
66
69
|
if (setting === 'warn') return null;
|
|
70
|
+
// A rule whose firing rate the corpus has not recorded blocks only where the policy names it.
|
|
71
|
+
if (ALL_RULES[f.rule]?.measured === false) return setting === 'block' ? 'rule' : null;
|
|
67
72
|
const tier = tierOf(f);
|
|
68
73
|
if (ratchet && TIER_RANK[tier] >= TIER_RANK[policy.always]) return 'tier';
|
|
69
74
|
if (ratchet && !introduced.has(f) && !renewed.has(f.fingerprint)) return null;
|
|
@@ -115,6 +120,19 @@ function declaredCompiles(root, files) {
|
|
|
115
120
|
|
|
116
121
|
const memberName = (file) => basename(file).replace(/\.[^.]*$/, '').toUpperCase();
|
|
117
122
|
|
|
123
|
+
// The GnuCOBOL version the repository's build files pin, the oldest where they pin several.
|
|
124
|
+
function pinnedGnucobol(root, files) {
|
|
125
|
+
let oldest = null;
|
|
126
|
+
for (const file of files.filter(isBuildFile)) {
|
|
127
|
+
let text;
|
|
128
|
+
try { text = readSource(file).text; } catch { continue; }
|
|
129
|
+
for (const p of pinsIn(text)) {
|
|
130
|
+
if (p.product === 'gnucobol' && (!oldest || olderThan(p.version, oldest.version))) oldest = { version: p.version, where: relPath(root, file) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return oldest;
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
// The options every program in the tree is compiled with, held to the policy. A program's options
|
|
119
137
|
// come from the estate's declared defaults, the JCL step that compiles it and its own option cards;
|
|
120
138
|
// where it has none of those, from the cobc and gcobol commands the repository's build scripts run.
|
|
@@ -127,13 +145,14 @@ function optionsCheck({ root, allow, site, policy, compiler }) {
|
|
|
127
145
|
if (compiler && !kind) {
|
|
128
146
|
return { ok: null, forbidden: 0, reasons: [`the gate reads the options of cobc, of gcobol and of Enterprise COBOL option cards; for ${printable(compiler[0], 60)} it cannot tell what reaches the compiler`], add: [], generated: new Map() };
|
|
129
147
|
}
|
|
148
|
+
const tree = directoryTree(root);
|
|
149
|
+
const files = [...new Set(tree.list())].filter((p) => !allow || allow.has(p)).sort();
|
|
150
|
+
const pinned = pinnedGnucobol(root, files);
|
|
130
151
|
if (compiler) {
|
|
131
|
-
const r = compilerChecks(kind, compiler.slice(1), { required, forbid: policy.forbid[kind] });
|
|
152
|
+
const r = compilerChecks(kind, compiler.slice(1), { required, forbid: policy.forbid[kind], pinned });
|
|
132
153
|
const missing = r.checks.filter((c) => c.ok === false).map((c) => c.why);
|
|
133
154
|
return { ok: missing.length || r.forbidden.length ? false : true, forbidden: r.forbidden.length, reasons: [...r.forbidden, ...missing], add: r.forbidden.length ? [] : r.add, compiler: kind, generated: new Map() };
|
|
134
155
|
}
|
|
135
|
-
const tree = directoryTree(root);
|
|
136
|
-
const files = [...new Set(tree.list())].filter((p) => !allow || allow.has(p)).sort();
|
|
137
156
|
const programs = files.filter(isProgram);
|
|
138
157
|
const declared = declaredCompiles(root, files);
|
|
139
158
|
let ok = true;
|
|
@@ -150,7 +169,7 @@ function optionsCheck({ root, allow, site, policy, compiler }) {
|
|
|
150
169
|
};
|
|
151
170
|
|
|
152
171
|
for (const inv of declared.invocations) {
|
|
153
|
-
const r = scriptedChecks(inv.compiler, inv.args, { required, forbid: policy.forbid[inv.compiler] });
|
|
172
|
+
const r = scriptedChecks(inv.compiler, inv.args, { required, forbid: policy.forbid[inv.compiler], pinned });
|
|
154
173
|
record(inv.file, r.checks);
|
|
155
174
|
forbidden += r.forbidden.length;
|
|
156
175
|
for (const why of [...r.checks.filter((c) => c.ok === false).map((c) => c.why), ...r.forbidden]) fail(`${inv.file}:${inv.line}: ${why}`);
|
|
@@ -364,9 +383,15 @@ function policyChanged(baseDir, headDir) {
|
|
|
364
383
|
|
|
365
384
|
// Runs the gate over `repo`. `compiler` is the argument vector after `--`, or null. Returns
|
|
366
385
|
// { doc, report, provenance, exit }; throws with code EBUILD when it could not run.
|
|
367
|
-
export function build(repo, { base = null, head = null, policy = null, noBaseline = false, compiler = null, ironwork = null, advisoryFeeds = null, copylibs = [], equivalence = [], allowed = null, now = new Date().toISOString(), env = process.env } = {}) {
|
|
386
|
+
export function build(repo, { base = null, head = null, policy = null, noBaseline = false, compiler = null, ironwork = null, advisoryFeeds = null, copylibs = [], equivalence = [], allowed: given = null, allowedSigners = null, now = new Date().toISOString(), env = process.env } = {}) {
|
|
368
387
|
const floor = policy ? floorPolicy(policy, repo) : null;
|
|
369
388
|
if (floor && floor.problems.length) throw refusal(floor.problems.join('; '));
|
|
389
|
+
let allowed = given;
|
|
390
|
+
if (allowedSigners) {
|
|
391
|
+
const path = resolve(allowedSigners);
|
|
392
|
+
if (resolvesInside(repo, path)) throw refusal(`--allowed-signers ${path} is inside the repository; signers the change can edit vouch for nothing`);
|
|
393
|
+
allowed = parseAllowedSigners(readFileSync(path, 'utf8'));
|
|
394
|
+
}
|
|
370
395
|
if (compiler && ironwork) throw refusal('one compiler per build: -- names one and --ironwork another');
|
|
371
396
|
let compilerPath = null;
|
|
372
397
|
if (compiler) {
|
package/lib/capabilities.mjs
CHANGED
|
@@ -12,13 +12,14 @@ import { VERDICT_EXIT } from './gate.mjs';
|
|
|
12
12
|
import { CHECKS, COMPILERS, DEFAULT_POLICY } from './policy.mjs';
|
|
13
13
|
import { CLASSES, TIERS } from './consequence.mjs';
|
|
14
14
|
import { toolRevision } from './revision.mjs';
|
|
15
|
+
import { IRONWORK_FORMATS, IRONWORK_MINIMUM, NOT_RUN_ABENDS, RUN_ENDINGS } from './ironwork-ids.mjs';
|
|
15
16
|
|
|
16
17
|
export const CAPABILITIES_SCHEMA_VERSION = 1;
|
|
17
18
|
|
|
18
19
|
// Each command, what it takes and what it writes.
|
|
19
20
|
export const COMMANDS = {
|
|
20
|
-
scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
|
|
21
|
-
flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
|
|
21
|
+
scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--all-routes', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
|
|
22
|
+
flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--all-routes', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
|
|
22
23
|
inventory: { args: ['<path>'], options: ['--out', '--quiet', '--copylib', '--evidence'], documents: ['cobolwork-inventory'] },
|
|
23
24
|
parse: { args: ['<file>'], options: ['--out'], documents: ['cobolwork-parse'] },
|
|
24
25
|
sbom: { args: ['<path>'], options: ['--name', '--out', '--quiet', '--copylib', '--evidence'], documents: ['cyclonedx'] },
|
|
@@ -28,7 +29,7 @@ export const COMMANDS = {
|
|
|
28
29
|
explain: { args: ['<path>', '<fingerprint>'], options: ['--report', '--out', '--baseline', '--no-baseline', '--advisories', '--copylib'], documents: ['cobolwork-explain'] },
|
|
29
30
|
gate: { args: ['<repo>'], options: ['--base', '--head', '--target', '--cobc', '--exit-code', '--target-only', '--out', '--evidence'], documents: ['cobolwork-gate'] },
|
|
30
31
|
build: { args: ['<repo>', '[-- <compiler> <arg>...]'], options: ['--base', '--head', '--policy', '--provenance', '--provenance-format', '--artifact', '--equivalence', '--allowed-signers', '--ironwork', '--advisories', '--copylib', '--no-baseline', '--format', '--out', '--evidence'], documents: ['cobolwork-build', 'cobolwork-build-provenance', 'sarif'] },
|
|
31
|
-
evidence: { args: ['verify|seal|anchor|sign', '[statement]'], options: ['--evidence', '--ssh-key', '--signer', '--expect-key', '--allowed-signers', '--anchor-git', '--ref', '--push', '--max-unsealed', '--tsq', '--out', '--quiet'], documents: ['cobolwork-evidence'] },
|
|
32
|
+
evidence: { args: ['verify|seal|anchor|sign', '[statement]'], options: ['--evidence', '--ssh-key', '--signer', '--expect-key', '--allowed-signers', '--anchor-git', '--ref', '--anchor-pin', '--push', '--max-unsealed', '--tsq', '--tsr', '--tsa-ca', '--cosign-bundle', '--cosign-key', '--certificate-identity', '--certificate-oidc-issuer', '--trusted-root', '--insecure-ignore-tlog', '--out', '--quiet'], documents: ['cobolwork-evidence'] },
|
|
32
33
|
capabilities: { args: [], options: ['--json'], documents: ['cobolwork-capabilities'] },
|
|
33
34
|
};
|
|
34
35
|
|
|
@@ -81,5 +82,7 @@ export function capabilities() {
|
|
|
81
82
|
exit: { ...BUILD_EXIT, couldNotRun: 2 },
|
|
82
83
|
},
|
|
83
84
|
gate: { exit: { ...VERDICT_EXIT, couldNotRun: 2 } },
|
|
85
|
+
// The ironwork releases cobolwork reads: from `minimum` on, each format from the release it names.
|
|
86
|
+
ironwork: { minimum: IRONWORK_MINIMUM, formats: IRONWORK_FORMATS, runEndings: RUN_ENDINGS, notRunAbends: NOT_RUN_ABENDS },
|
|
84
87
|
};
|
|
85
88
|
}
|
package/lib/consequence.mjs
CHANGED
|
@@ -69,6 +69,7 @@ const RULE_CLASSES = {
|
|
|
69
69
|
'vendor-privileged-command': [ESCALATES],
|
|
70
70
|
'jcl-instream-security-command': [ESCALATES],
|
|
71
71
|
'jcl-instream-credential': [ESCALATES],
|
|
72
|
+
'credential-in-source': [ESCALATES],
|
|
72
73
|
'web-uri-carries-a-credential': [ESCALATES],
|
|
73
74
|
'log-writes-a-credential': [ESCALATES],
|
|
74
75
|
'zowe-config-secret-in-clear': [ESCALATES],
|
|
@@ -122,8 +123,24 @@ const RULE_CLASSES = {
|
|
|
122
123
|
// have landed, where the input chose.
|
|
123
124
|
'input-causes-abend-s0c7': [],
|
|
124
125
|
'input-causes-abend': [],
|
|
126
|
+
'input-causes-hang': [],
|
|
127
|
+
'input-selects-program': [ESCALATES],
|
|
125
128
|
'input-causes-abend-s0c4': [MUTATES],
|
|
126
129
|
'input-causes-abend-subscript-range': [MUTATES],
|
|
130
|
+
// Key zero or supervisor state is the operating system's authority, taken by the code itself.
|
|
131
|
+
'hlasm-supervisor-state-change': [ESCALATES],
|
|
132
|
+
// Nothing says input reaches EX's register, or which address space a cross-memory call reaches:
|
|
133
|
+
// the linkage tables grant that, and the source does not show them. Severity decides.
|
|
134
|
+
'hlasm-executes-built-instruction': [],
|
|
135
|
+
'hlasm-cross-memory-service': [],
|
|
136
|
+
'hlasm-runtime-module-name': [ESCALATES],
|
|
137
|
+
// Disclosure in transit; severity decides.
|
|
138
|
+
'web-client-opens-cleartext': [],
|
|
139
|
+
'web-receive-length-exceeds-area': [MUTATES],
|
|
140
|
+
// Weak cryptography discloses or lets data be changed undetected; severity decides.
|
|
141
|
+
'icsf-single-length-des-key': [],
|
|
142
|
+
'icsf-weak-hash': [],
|
|
143
|
+
'icsf-fixed-initialization-vector': [],
|
|
127
144
|
};
|
|
128
145
|
|
|
129
146
|
// Coverage and context assert no defect, so they carry no consequence.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// The literals that can be in a data item when a statement reads it, from the program's text: its
|
|
3
|
+
// VALUE clause and its subordinates', each MOVE and STRING into it or into a group holding it, and a
|
|
4
|
+
// hop or two back through an item those name. Anything else that can write it - a computation, a
|
|
5
|
+
// READ or ACCEPT INTO, a CALL it is passed to by reference, a MOVE from a field with no constant -
|
|
6
|
+
// leaves it open, which a caller reads as "and something this cannot see".
|
|
7
|
+
const FIGURATIVE = /^(LOW-VALUES?|HIGH-VALUES?|SPACES?|ZEROS?|ZEROES|QUOTES?|NULLS?)$/;
|
|
8
|
+
const WRITERS = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE', 'READ', 'ACCEPT', 'UNSTRING', 'INITIALIZE', 'SET', 'RETURN', 'INSPECT']);
|
|
9
|
+
const NOT_OPERANDS = new Set(['TO', 'INTO', 'DELIMITED', 'BY', 'SIZE', 'OF', 'IN', 'ALL']);
|
|
10
|
+
|
|
11
|
+
// The item, its subordinates and the groups that hold it.
|
|
12
|
+
function family(p, name) {
|
|
13
|
+
const self = p.items.find((x) => x.name === name);
|
|
14
|
+
if (!self) return null;
|
|
15
|
+
const under = [];
|
|
16
|
+
const down = (it) => { for (const c of it.children || []) { if (c.level === 88) continue; under.push(c); down(c); } };
|
|
17
|
+
down(self);
|
|
18
|
+
const over = [];
|
|
19
|
+
for (let up = self.parent; up; up = up.parent) over.push(up);
|
|
20
|
+
return { self, under, over };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const nameOf = (t) => (typeof t === 'string' ? t : t && t.u);
|
|
24
|
+
|
|
25
|
+
const valuesOf = (item) => (item.values || []).map((v) => (v.t === 'lit' ? String(v.v) : FIGURATIVE.test(v.u || '') ? v.u : null)).filter((v) => v !== null);
|
|
26
|
+
|
|
27
|
+
// `ignoreCall(call)` names calls that read the item and do not write it, such as the one being judged.
|
|
28
|
+
export function constantsReaching(p, name, { depth = 2, ignoreCall = () => false } = {}, seen = new Set()) {
|
|
29
|
+
const out = { values: [], open: false };
|
|
30
|
+
if (seen.has(name)) return out;
|
|
31
|
+
seen.add(name);
|
|
32
|
+
const f = family(p, name);
|
|
33
|
+
if (!f) return { values: [], open: true };
|
|
34
|
+
const names = new Set([name, ...f.under.map((x) => x.name), ...f.over.map((x) => x.name)]);
|
|
35
|
+
out.values.push(...valuesOf(f.self), ...f.under.flatMap(valuesOf));
|
|
36
|
+
for (const st of p.statements || []) {
|
|
37
|
+
if (!(st.targets || []).some((t) => names.has(nameOf(t)))) continue;
|
|
38
|
+
if (st.verb === 'MOVE' || st.verb === 'STRING') {
|
|
39
|
+
for (const t of st.sources || []) if (t && t.t === 'lit') out.values.push(String(t.v));
|
|
40
|
+
for (const l of st.literals || []) out.values.push(String(l && l.v !== undefined ? l.v : l));
|
|
41
|
+
for (const w of (st.sources || []).filter((t) => !(t && t.t === 'lit')).map(nameOf).filter((x) => x && !NOT_OPERANDS.has(x))) {
|
|
42
|
+
if (FIGURATIVE.test(w) || /^[+-]?\d/.test(w) || names.has(w)) { if (FIGURATIVE.test(w)) out.values.push(w); continue; }
|
|
43
|
+
if (depth === 0) { out.open = true; continue; }
|
|
44
|
+
const r = constantsReaching(p, w, { depth: depth - 1, ignoreCall }, seen);
|
|
45
|
+
out.values.push(...r.values);
|
|
46
|
+
if (r.open || !r.values.length) out.open = true;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (WRITERS.has(st.verb)) out.open = true;
|
|
51
|
+
}
|
|
52
|
+
for (const c of p.calls || []) {
|
|
53
|
+
if (ignoreCall(c)) continue;
|
|
54
|
+
if ((c.using || []).some((u) => names.has(u.word) && u.mode !== 'CONTENT' && u.mode !== 'VALUE')) out.open = true;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A rule array is eight-byte keywords side by side; one held in several fields reads the same.
|
|
60
|
+
export const keywordsOf = (values) => new Set(values.flatMap((v) => (FIGURATIVE.test(v) ? [] : String(v).match(/.{1,8}/g) || []).map((k) => k.trim().toUpperCase()).filter(Boolean)));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A program's control analysis, kept so that a byte-identical copy of it elsewhere in the repository
|
|
3
|
+
// is not analysed again.
|
|
4
|
+
//
|
|
5
|
+
// The analysis refers to objects of the program's parse: statements, items, tokens. Kept, each such
|
|
6
|
+
// reference is the object's position in a walk of the parse that visits it in one fixed order, so the
|
|
7
|
+
// copy's parse, walked the same way, supplies the copy's own objects. A string equal to one of the
|
|
8
|
+
// program's file paths is kept as that file's place in the program's list of files, and becomes the
|
|
9
|
+
// copy's path. Anything else the analysis holds is copied as it is. A path inside a longer string, or
|
|
10
|
+
// a value that is not plain data, makes the analysis not kept at all: the copy is analysed afresh.
|
|
11
|
+
|
|
12
|
+
const FAIL = Symbol('not kept');
|
|
13
|
+
|
|
14
|
+
// The objects reachable from root, breadth first: array elements, Map keys and values, Set values and
|
|
15
|
+
// own enumerable properties, in their order. Typed arrays are not entered.
|
|
16
|
+
export function parseOrder(root) {
|
|
17
|
+
const order = [root];
|
|
18
|
+
const seen = new Set(order);
|
|
19
|
+
for (let i = 0; i < order.length; i++) {
|
|
20
|
+
const o = order[i];
|
|
21
|
+
const visit = (v) => { if (v !== null && typeof v === 'object' && !seen.has(v)) { seen.add(v); order.push(v); } };
|
|
22
|
+
if (ArrayBuffer.isView(o)) continue;
|
|
23
|
+
if (Array.isArray(o)) for (const v of o) visit(v);
|
|
24
|
+
else if (o instanceof Map) for (const [k, v] of o) { visit(k); visit(v); }
|
|
25
|
+
else if (o instanceof Set) for (const v of o) visit(v);
|
|
26
|
+
else for (const k of Object.keys(o)) visit(o[k]);
|
|
27
|
+
}
|
|
28
|
+
return order;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const plain = (o) => {
|
|
32
|
+
const proto = Object.getPrototypeOf(o);
|
|
33
|
+
return (proto === Object.prototype || proto === null)
|
|
34
|
+
&& Object.getOwnPropertySymbols(o).length === 0
|
|
35
|
+
&& Object.getOwnPropertyNames(o).length === Object.keys(o).length;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// `files` lists the program's file and its resolved copybooks, in the parse's order.
|
|
39
|
+
export function keep(value, order, files) {
|
|
40
|
+
const at = new Map(order.map((o, i) => [o, i]));
|
|
41
|
+
const fileAt = new Map(files.map((f, i) => [f, i]));
|
|
42
|
+
const table = [];
|
|
43
|
+
const index = new Map();
|
|
44
|
+
const enc = (v) => {
|
|
45
|
+
if (typeof v === 'string') {
|
|
46
|
+
if (fileAt.has(v)) return { f: fileAt.get(v) };
|
|
47
|
+
for (const f of files) if (f && v.includes(f)) throw FAIL;
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
if (v === null || typeof v !== 'object') {
|
|
51
|
+
if (typeof v === 'function' || typeof v === 'symbol') throw FAIL;
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
if (at.has(v)) return { p: at.get(v) };
|
|
55
|
+
if (index.has(v)) return { r: index.get(v) };
|
|
56
|
+
const i = table.length;
|
|
57
|
+
index.set(v, i);
|
|
58
|
+
const entry = {};
|
|
59
|
+
table.push(entry);
|
|
60
|
+
if (ArrayBuffer.isView(v)) { entry.t = v; return { r: i }; }
|
|
61
|
+
if (Array.isArray(v)) {
|
|
62
|
+
if (Object.keys(v).length !== v.length || !plainArray(v)) throw FAIL;
|
|
63
|
+
entry.a = v.map(enc);
|
|
64
|
+
} else if (v instanceof Map) entry.m = [...v].map(([k, x]) => [enc(k), enc(x)]);
|
|
65
|
+
else if (v instanceof Set) entry.s = [...v].map(enc);
|
|
66
|
+
else {
|
|
67
|
+
if (!plain(v)) throw FAIL;
|
|
68
|
+
entry.o = Object.keys(v).map((k) => [k, enc(v[k])]);
|
|
69
|
+
if (Object.getPrototypeOf(v) === null) entry.n = true;
|
|
70
|
+
}
|
|
71
|
+
return { r: i };
|
|
72
|
+
};
|
|
73
|
+
try { return { root: enc(value), table }; } catch (e) { if (e === FAIL) return null; throw e; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const plainArray = (a) => Object.getPrototypeOf(a) === Array.prototype && Object.getOwnPropertySymbols(a).length === 0;
|
|
77
|
+
|
|
78
|
+
export function restore(kept, order, files) {
|
|
79
|
+
const made = kept.table.map((e) => (e.t ? e.t : e.a ? [] : e.m ? new Map() : e.s ? new Set() : e.n ? Object.create(null) : {}));
|
|
80
|
+
const dec = (x) => {
|
|
81
|
+
if (x === null || typeof x !== 'object') return x;
|
|
82
|
+
if ('p' in x) return order[x.p];
|
|
83
|
+
if ('f' in x) return files[x.f];
|
|
84
|
+
return made[x.r];
|
|
85
|
+
};
|
|
86
|
+
kept.table.forEach((e, i) => {
|
|
87
|
+
const o = made[i];
|
|
88
|
+
if (e.a) for (const x of e.a) o.push(dec(x));
|
|
89
|
+
else if (e.m) for (const [k, x] of e.m) o.set(dec(k), dec(x));
|
|
90
|
+
else if (e.s) for (const x of e.s) o.add(dec(x));
|
|
91
|
+
else if (e.o) for (const [k, x] of e.o) o[k] = dec(x);
|
|
92
|
+
});
|
|
93
|
+
return dec(kept.root);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Whether two analyses are the same, objects of the parse compared as themselves.
|
|
97
|
+
export function sameAnalysis(a, b, parse) {
|
|
98
|
+
const pairs = new Map();
|
|
99
|
+
const eq = (x, y) => {
|
|
100
|
+
if (x === y) return true;
|
|
101
|
+
if (x === null || y === null || typeof x !== 'object' || typeof y !== 'object') return Object.is(x, y);
|
|
102
|
+
if (parse.has(x) || parse.has(y)) return false;
|
|
103
|
+
if (pairs.has(x)) return pairs.get(x) === y;
|
|
104
|
+
pairs.set(x, y);
|
|
105
|
+
if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
|
|
106
|
+
if (ArrayBuffer.isView(x)) return x.length === y.length && x.every((v, i) => v === y[i]);
|
|
107
|
+
if (x instanceof Map) { if (x.size !== y.size) return false; const ys = [...y]; return [...x].every(([k, v], i) => eq(k, ys[i][0]) && eq(v, ys[i][1])); }
|
|
108
|
+
if (x instanceof Set) { if (x.size !== y.size) return false; const ys = [...y]; return [...x].every((v, i) => eq(v, ys[i])); }
|
|
109
|
+
const kx = Object.keys(x), ky = Object.keys(y);
|
|
110
|
+
return kx.length === ky.length && kx.every((k, i) => k === ky[i] && eq(x[k], y[k]));
|
|
111
|
+
};
|
|
112
|
+
return eq(a, b);
|
|
113
|
+
}
|