@portll/cobolwork 0.2.140 → 0.3.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 +14 -2
- package/THIRD-PARTY-NOTICES.md +3 -2
- package/bin/cobolwork.mjs +16 -1
- package/lib/baseline.mjs +8 -6
- package/lib/bms.mjs +1 -1
- package/lib/capabilities.mjs +2 -1
- package/lib/cards.mjs +66 -0
- package/lib/compliance.mjs +6 -5
- package/lib/control.mjs +402 -19
- package/lib/dataflow.mjs +33 -9
- package/lib/diff.mjs +65 -89
- package/lib/equivalence.mjs +30 -12
- package/lib/evidence/record.mjs +3 -1
- package/lib/evidence/slsa.mjs +17 -2
- package/lib/execution.mjs +77 -0
- package/lib/explain.mjs +1 -1
- package/lib/ftp.mjs +1 -1
- package/lib/inventory.mjs +8 -9
- package/lib/jcl.mjs +3 -48
- package/lib/kernel/git.mjs +60 -0
- package/lib/kernel/identity.mjs +7 -6
- package/lib/kernel/source-tree.mjs +204 -34
- package/lib/parser.mjs +31 -15
- package/lib/revision.json +1 -1
- package/lib/sbom.mjs +33 -9
- package/lib/scan.mjs +11 -4
- package/lib/sets/build.mjs +1 -1
- package/lib/sets/cics.mjs +27 -3
- package/lib/sets/copybook.mjs +1 -1
- package/lib/sets/hidden.mjs +1 -1
- package/lib/sets/jcl.mjs +1 -1
- package/lib/sets/priv.mjs +1 -1
- package/lib/sets/recon.mjs +2 -2
- package/lib/sets/semantics.mjs +1 -1
- package/lib/sets/vendor.mjs +1 -1
- package/lib/site.mjs +17 -7
- package/lib/sources.mjs +82 -38
- package/lib/utilities.mjs +1 -1
- package/package.json +2 -2
- package/rules/compliance-cobit2019.json +2438 -0
- package/lib/card.mjs +0 -18
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ follows. To run from a checkout instead:
|
|
|
34
34
|
|
|
35
35
|
git clone https://github.com/Portll/cobolwork && cd cobolwork && npm link
|
|
36
36
|
|
|
37
|
-
Node
|
|
37
|
+
Node 22 or later. No dependencies, runtime or development. `npm link` puts `cobolwork` on your PATH;
|
|
38
38
|
adding `bin/` to PATH does the same thing. GnuCOBOL is needed only to regrade the parser or validate
|
|
39
39
|
benchmark cases. In a GitHub workflow, `uses: Portll/cobolwork@main` runs the build gate on a pull
|
|
40
40
|
request and writes SARIF for code scanning: [docs/github-action.md](docs/github-action.md).
|
|
@@ -187,6 +187,11 @@ since a test that missed the route does not show it safe.
|
|
|
187
187
|
`summary.handling`, because it is then a list of what to attack first. Upload it only where the RACF
|
|
188
188
|
facts behind it may go. [docs/spec/reach.md](docs/spec/reach.md) §9 is the full specification.
|
|
189
189
|
|
|
190
|
+
`COBOLWORK_EXECUTION` names reports `ironwork run --coverage` wrote from runs of the estate's own
|
|
191
|
+
tests. A finding inside a paragraph they cover carries `executed`, the paragraph and how often the
|
|
192
|
+
runs entered it, and `summary.byExecution` counts the findings in paragraphs entered and never
|
|
193
|
+
entered ([docs/spec/evidence.md](docs/spec/evidence.md) §13.5).
|
|
194
|
+
|
|
190
195
|
### What each finding lets someone do, and the fix
|
|
191
196
|
|
|
192
197
|
A defect finding carries two more facts, the same for every finding of its rule: what someone can do
|
|
@@ -339,13 +344,20 @@ the guard off, it just stops the host's load from deciding. A spawned scan inher
|
|
|
339
344
|
|
|
340
345
|
## Compliance
|
|
341
346
|
|
|
342
|
-
Every rule is mapped to the clause of each framework that makes it an obligation, quoted verbatim
|
|
347
|
+
Every rule is mapped to the clause of each framework that makes it an obligation, quoted verbatim,
|
|
348
|
+
and to a COBIT 2019 practice by identifier alone:
|
|
343
349
|
|
|
344
350
|
| File | Instrument | Rules mapped |
|
|
345
351
|
|---|---|---|
|
|
346
352
|
| `rules/compliance-dora.json` | Regulation (EU) 2022/2554 (DORA) | 203 |
|
|
347
353
|
| `rules/compliance-ffiec.json` | FFIEC IT Examination Handbook | 201, and 2 recorded as unmapped |
|
|
348
354
|
| `rules/compliance-nist80053.json` | NIST SP 800-53 Rev. 5.2.0 | 201, and 2 recorded as unmapped |
|
|
355
|
+
| `rules/compliance-cobit2019.json` | COBIT 2019 (ISACA), identifiers only | 201, and 2 recorded as unmapped |
|
|
356
|
+
|
|
357
|
+
A COBIT 2019 row names the objective or practice and gives this project's own rationale; no ISACA
|
|
358
|
+
text is reproduced, so reading what a practice says needs a copy of the framework. The practice is
|
|
359
|
+
chosen at the NIST control, through a crosswalk in `diag/map-compliance.mjs`, so it cannot drift
|
|
360
|
+
from the rule's NIST clause.
|
|
349
361
|
|
|
350
362
|
`node diag/map-compliance.mjs` refuses to write a quote the cached instrument does not contain, and
|
|
351
363
|
every scan carries the mapping as `ruleCompliance`. The clause choice is a judgement that no
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -89,8 +89,9 @@ its source URL and retrieval date:
|
|
|
89
89
|
- **NIST SP 800-53 Rev. 5** — a work of the US Government.
|
|
90
90
|
- **FFIEC IT Examination Handbook** — a work of the US Government.
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
COBIT 2019 appears as objective and practice identifiers only, with this project's own rationale;
|
|
93
|
+
no text of COBIT, which is ISACA's, is reproduced. PCI DSS is deliberately absent: it may not be
|
|
94
|
+
redistributed. Full instrument texts are never
|
|
94
95
|
committed (`feed/sources/` is ignored); the feed ships citations, not text.
|
|
95
96
|
|
|
96
97
|
## Vulnerability data
|
package/bin/cobolwork.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { build, buildSarif, buildSummaryLine } from '../lib/build.mjs';
|
|
|
16
16
|
import { capabilities } from '../lib/capabilities.mjs';
|
|
17
17
|
import { commitAt, revisionOf, toolRevision } from '../lib/revision.mjs';
|
|
18
18
|
import { stampFingerprints } from '../lib/kernel/identity.mjs';
|
|
19
|
+
import { pdsExportTree } from '../lib/kernel/source-tree.mjs';
|
|
20
|
+
import { SITE_FILE } from '../lib/site.mjs';
|
|
19
21
|
import { tally } from '../lib/kernel/findings.mjs';
|
|
20
22
|
import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, SUPPRESSING } from '../lib/baseline.mjs';
|
|
21
23
|
import { readReport } from '../lib/tui/model.mjs';
|
|
@@ -86,6 +88,9 @@ Options
|
|
|
86
88
|
baseline: the judgement each new entry records, and which rules it covers
|
|
87
89
|
--advisories <file>[,<file>] a customer's own advisory extract (JSON), loaded for this scan only;
|
|
88
90
|
never kept in the tree
|
|
91
|
+
--pds-export scan: the directory holds partitioned data sets' members as files, as
|
|
92
|
+
zowe zos-files download all-members writes them (hlq/llq/member.txt) or in a
|
|
93
|
+
directory named for each data set; findings name DATA.SET/MEMBER
|
|
89
94
|
--copylib <dir>[,<dir>] copy libraries the estate keeps outside the repository, searched after the
|
|
90
95
|
tree's own copybooks, as COBCPY is; a copybook found there is read, not reported missing
|
|
91
96
|
--report <file> tui, explain: read a stored scan report instead of scanning
|
|
@@ -150,6 +155,7 @@ function parseArgs(argv) {
|
|
|
150
155
|
else if (a === '--action') opts.action = value();
|
|
151
156
|
else if (a === '--rule') opts.rule = list();
|
|
152
157
|
else if (a === '--advisories') opts.advisoryFeeds = [...new Set(list().map(x => resolve(x)))];
|
|
158
|
+
else if (a === '--pds-export') opts.pdsExport = true;
|
|
153
159
|
else if (a === '--copylib') opts.copylib = [...new Set(list().map(x => resolve(x)))];
|
|
154
160
|
else if (a === '--report') opts.report = value();
|
|
155
161
|
else if (a === '--keys') opts.keys = value();
|
|
@@ -198,6 +204,7 @@ function warnCoverage(report) {
|
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
207
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
201
208
|
|
|
202
209
|
let journal = null;
|
|
203
210
|
|
|
@@ -262,6 +269,8 @@ if (opts.copylib && opts._.length && !COPYLIB_COMMANDS.includes(opts._[0])) {
|
|
|
262
269
|
const notDirs = (opts.copylib || []).filter((d) => { try { return !statSync(d).isDirectory(); } catch { return true; } });
|
|
263
270
|
if (notDirs.length) { process.stderr.write(`cobolwork: --copylib ${notDirs.join(', ')} is not a directory\n`); process.exit(2); }
|
|
264
271
|
const systemDirs = opts.copylib || [];
|
|
272
|
+
if (opts.pdsExport && opts._.length && opts._[0] !== 'scan') { process.stderr.write('cobolwork: --pds-export is for scan only\n'); process.exit(2); }
|
|
273
|
+
if (opts.pdsExport && opts.repos) { process.stderr.write('cobolwork: --pds-export reads one export; --repos does not apply\n'); process.exit(2); }
|
|
265
274
|
if (opts.json && opts._.length && opts._[0] !== 'capabilities') {
|
|
266
275
|
process.stderr.write(`cobolwork: --json is for capabilities; every other command writes JSON unless --format says otherwise\n`);
|
|
267
276
|
process.exit(2);
|
|
@@ -332,7 +341,13 @@ try {
|
|
|
332
341
|
process.exitCode = evidenceCommand(target, opts, { toolVersion: VERSION, write: (s) => process.stdout.write(s) });
|
|
333
342
|
} else if (command === 'scan' || command === 'flow') {
|
|
334
343
|
const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
|
|
335
|
-
|
|
344
|
+
// The site file sits beside the members and is not one of them, so it is named rather than found.
|
|
345
|
+
const site = resolve(root, SITE_FILE);
|
|
346
|
+
const pds = opts.pdsExport ? pdsExportTree(root, { systemDirs }) : null;
|
|
347
|
+
const report = command === 'flow' ? scanFlow(root, flowOpts)
|
|
348
|
+
: pds ? scanAll(pds.root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds, tree: pds, feedRoot: root, site: isFile(site) && !isLink(site) ? site : null })
|
|
349
|
+
: scanAll(root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds });
|
|
350
|
+
if (pds) report.summary.pdsExport = pds.export;
|
|
336
351
|
if (command === 'flow') {
|
|
337
352
|
report.summary.identity = stampFingerprints(report.findings, { root });
|
|
338
353
|
stampFingerprints(report.checked, { root });
|
package/lib/baseline.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// because the finding it would hide is evidence that the tree is not to be trusted. A baseline
|
|
17
17
|
// passed from outside the tree can.
|
|
18
18
|
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
-
import { join } from 'node:path';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
20
|
import { tally } from './kernel/findings.mjs';
|
|
21
21
|
import { resolvesInside } from './kernel/source-tree.mjs';
|
|
22
22
|
import { printable } from './kernel/printable.mjs';
|
|
@@ -54,22 +54,24 @@ export function validateEntry(e) {
|
|
|
54
54
|
|
|
55
55
|
// { path, source, entries, problems }. An absent file is an empty baseline, not an error; an
|
|
56
56
|
// unreadable one is a problem the report carries, never a silent empty.
|
|
57
|
-
|
|
57
|
+
// A source tree held in memory (a git revision) supplies its own baseline file.
|
|
58
|
+
export function loadBaseline(root, { explicit = null, use = true, mayBeAbsent = false, tree = null } = {}) {
|
|
58
59
|
if (!use) return null;
|
|
59
|
-
const
|
|
60
|
-
|
|
60
|
+
const held = !explicit && tree && tree.kind !== 'directory';
|
|
61
|
+
const path = explicit || (held ? resolve(tree.root, BASELINE_FILE) : join(root, BASELINE_FILE));
|
|
62
|
+
if (held ? !tree.contains(path) : !existsSync(path)) {
|
|
61
63
|
if (!explicit) return null;
|
|
62
64
|
if (mayBeAbsent) return { path, source: 'explicit', entries: [], problems: [] };
|
|
63
65
|
return { path, source: 'explicit', entries: [], problems: [`${path} does not exist, so nothing was suppressed`] };
|
|
64
66
|
}
|
|
65
67
|
const out = { path, source: explicit ? 'explicit' : 'tree', entries: [], problems: [] };
|
|
66
68
|
// The tree's own baseline is the tree's, and a link out of it is not followed.
|
|
67
|
-
if (!explicit && !resolvesInside(root, path)) {
|
|
69
|
+
if (!explicit && !held && !resolvesInside(root, path)) {
|
|
68
70
|
out.problems.push(`${BASELINE_FILE} is a link that leads outside the tree, so nothing was suppressed`);
|
|
69
71
|
return out;
|
|
70
72
|
}
|
|
71
73
|
let raw;
|
|
72
|
-
try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (err) {
|
|
74
|
+
try { raw = JSON.parse(held ? tree.bytes(path).toString('utf8') : readFileSync(path, 'utf8')); } catch (err) {
|
|
73
75
|
out.problems.push(`${path} is not readable as JSON: ${printable(err.message, 120)}`);
|
|
74
76
|
return out;
|
|
75
77
|
}
|
package/lib/bms.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// This reads text and opens nothing; the caller reads the file through the source tree, so
|
|
17
17
|
// containment stays where it is enforced.
|
|
18
|
-
import { parseOperands, splitOperands } from './
|
|
18
|
+
import { parseOperands, splitOperands } from './cards.mjs';
|
|
19
19
|
|
|
20
20
|
// Column 72 marks a continuation and 73 to 80 are the sequence field, so a statement ends at 71.
|
|
21
21
|
const END_COLUMN = 71;
|
package/lib/capabilities.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const CAPABILITIES_SCHEMA_VERSION = 1;
|
|
|
17
17
|
|
|
18
18
|
// Each command, what it takes and what it writes.
|
|
19
19
|
export const COMMANDS = {
|
|
20
|
-
scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--evidence'], documents: ['cobolwork', 'sarif'] },
|
|
20
|
+
scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
|
|
21
21
|
flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
|
|
22
22
|
inventory: { args: ['<path>'], options: ['--out', '--quiet', '--copylib', '--evidence'], documents: ['cobolwork-inventory'] },
|
|
23
23
|
parse: { args: ['<file>'], options: ['--out'], documents: ['cobolwork-parse'] },
|
|
@@ -68,6 +68,7 @@ export function capabilities() {
|
|
|
68
68
|
jcl: JCL_EXT,
|
|
69
69
|
bms: BMS_EXT,
|
|
70
70
|
extensionless: 'sniffed by content: a file with no extension is classified by what it holds',
|
|
71
|
+
pdsExport: 'scan --pds-export: every member classified by content, whatever extension the download gave it; text, --binary and --record downloads',
|
|
71
72
|
zowe: ['zowe.config.json', 'zowe.config.user.json', '.mcp.json', '.vscode/mcp.json', '.cursor/mcp.json', '.claude/settings.json', '.claude/settings.local.json', '.gemini/settings.json', 'claude_desktop_config.json', '.vscode/settings.json', 'systems.json under a mock directory'],
|
|
72
73
|
},
|
|
73
74
|
evidenceKinds: Object.keys(EVIDENCE),
|
package/lib/cards.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Card-image source: where a line's fields sit on the card, and how an operand field splits. COBOL
|
|
3
|
+
// in fixed or variable reference format has a sequence area (columns 1-6), an indicator (7), program
|
|
4
|
+
// text (8-72, or 8-250 in variable format) and what follows, which the compiler ignores. A JCL or
|
|
5
|
+
// utility control statement is columns 1-72, with its sequence number after. Free-format COBOL is
|
|
6
|
+
// text from end to end.
|
|
7
|
+
|
|
8
|
+
export const TEXT_END = { fixed: 72, variable: 250 };
|
|
9
|
+
export const STATEMENT_END = 72;
|
|
10
|
+
|
|
11
|
+
export function cobolCard(line, format) {
|
|
12
|
+
const end = TEXT_END[format];
|
|
13
|
+
if (!end) return { sequence: '', indicator: '', text: line, ignored: '' };
|
|
14
|
+
return { sequence: line.slice(0, 6), indicator: line[6] || ' ', text: line.slice(7, end), ignored: line.slice(end) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function statementCard(line) {
|
|
18
|
+
return { text: line.slice(0, STATEMENT_END), sequence: line.slice(STATEMENT_END) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Splits an operand field on commas that are not inside parentheses or quotes. JCL, BMS and HLASM nest
|
|
22
|
+
// both, and a naive split on comma turns DISP=(NEW,CATLG,DELETE) into three operands.
|
|
23
|
+
export function splitOperands(s) {
|
|
24
|
+
const out = [];
|
|
25
|
+
let depth = 0, quoted = false, start = 0;
|
|
26
|
+
for (let i = 0; i < s.length; i++) {
|
|
27
|
+
const c = s[i];
|
|
28
|
+
if (quoted) { if (c === "'") { if (s[i + 1] === "'") i++; else quoted = false; } continue; }
|
|
29
|
+
if (c === "'") quoted = true;
|
|
30
|
+
else if (c === '(') depth++;
|
|
31
|
+
else if (c === ')') depth = Math.max(0, depth - 1);
|
|
32
|
+
else if (c === ',' && depth === 0) { out.push(s.slice(start, i)); start = i + 1; }
|
|
33
|
+
}
|
|
34
|
+
out.push(s.slice(start));
|
|
35
|
+
return out.filter((x, i, a) => x.length || i < a.length - 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Operands are positional until the first KEY=VALUE, and keyword after it: JCL's EXEC takes its
|
|
39
|
+
// procedure name positionally, and everything interesting on a DD is a keyword.
|
|
40
|
+
export function parseOperands(field) {
|
|
41
|
+
const positional = [];
|
|
42
|
+
const keywords = new Map();
|
|
43
|
+
for (const raw of splitOperands(field)) {
|
|
44
|
+
const part = raw.trim();
|
|
45
|
+
if (!part) continue;
|
|
46
|
+
const eq = keywordSplit(part);
|
|
47
|
+
if (eq < 0) positional.push(part);
|
|
48
|
+
else keywords.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
|
|
49
|
+
}
|
|
50
|
+
return { positional, keywords };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The first '=' that is not inside parentheses or quotes. DCB=(RECFM=FB,LRECL=80) is one keyword
|
|
54
|
+
// whose value happens to contain more of them.
|
|
55
|
+
function keywordSplit(part) {
|
|
56
|
+
let depth = 0, quoted = false;
|
|
57
|
+
for (let i = 0; i < part.length; i++) {
|
|
58
|
+
const c = part[i];
|
|
59
|
+
if (quoted) { if (c === "'") { if (part[i + 1] === "'") i++; else quoted = false; } continue; }
|
|
60
|
+
if (c === "'") quoted = true;
|
|
61
|
+
else if (c === '(') depth++;
|
|
62
|
+
else if (c === ')') depth = Math.max(0, depth - 1);
|
|
63
|
+
else if (c === '=' && depth === 0) return i;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
package/lib/compliance.mjs
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// against each. The difference between them is this file, and it is the difference between a
|
|
6
6
|
// tooling budget and a compliance budget.
|
|
7
7
|
//
|
|
8
|
-
// DORA, NIST SP 800-53 and the FFIEC IT Examination Handbook ship
|
|
9
|
-
// may be reproduced: one is EU law, the other two US Government works.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// DORA, NIST SP 800-53 and the FFIEC IT Examination Handbook ship with quotes because their
|
|
9
|
+
// instruments may be reproduced: one is EU law, the other two US Government works. COBIT 2019 ships
|
|
10
|
+
// as identifiers with this project's own rationale and no ISACA text. PCI DSS may not be
|
|
11
|
+
// redistributed and is not mapped.
|
|
12
12
|
//
|
|
13
13
|
// A framework may decline to cover a rule. Neither NIST nor FFIEC has a control that genuinely
|
|
14
14
|
// covers committing an LPAR name to a repository, and that is recorded as an unmapped rule with a
|
|
@@ -19,6 +19,7 @@ const FRAMEWORKS = [
|
|
|
19
19
|
{ id: 'dora', file: '../rules/compliance-dora.json' },
|
|
20
20
|
{ id: 'nist-800-53r5', file: '../rules/compliance-nist80053.json' },
|
|
21
21
|
{ id: 'ffiec', file: '../rules/compliance-ffiec.json' },
|
|
22
|
+
{ id: 'cobit-2019', file: '../rules/compliance-cobit2019.json' },
|
|
22
23
|
];
|
|
23
24
|
|
|
24
25
|
// A framework that will not parse is a framework this scan cannot map against, which is the same
|
|
@@ -64,7 +65,7 @@ const byRule = new Map();
|
|
|
64
65
|
for (const d of loaded) {
|
|
65
66
|
for (const r of d.rows) {
|
|
66
67
|
if (!byRule.has(r.ruleId)) byRule.set(r.ruleId, []);
|
|
67
|
-
byRule.get(r.ruleId).push({ framework: r.framework, clause: r.clause, title: r.title });
|
|
68
|
+
byRule.get(r.ruleId).push({ framework: r.framework, clause: r.clause, ...(r.title ? { title: r.title } : {}) });
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|