@portll/cobolwork 0.0.1 → 0.2.75
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/LICENSE +661 -0
- package/LICENSING.md +93 -0
- package/NOTICE +9 -0
- package/README.md +323 -3
- package/THIRD-PARTY-NOTICES.md +118 -0
- package/bin/cobolwork.mjs +354 -0
- package/lib/advisories.mjs +133 -0
- package/lib/baseline.mjs +154 -0
- package/lib/bms.mjs +453 -0
- package/lib/build.mjs +402 -0
- package/lib/capabilities.mjs +79 -0
- package/lib/cics-commands.mjs +281 -0
- package/lib/compliance.mjs +81 -0
- package/lib/consequence.mjs +139 -0
- package/lib/control.mjs +1515 -0
- package/lib/csd.mjs +77 -0
- package/lib/dataflow.mjs +1506 -0
- package/lib/diff.mjs +344 -0
- package/lib/explain.mjs +145 -0
- package/lib/gate.mjs +383 -0
- package/lib/index.mjs +6 -0
- package/lib/inventory.mjs +79 -0
- package/lib/jcl.mjs +478 -0
- package/lib/kernel/findings.mjs +94 -0
- package/lib/kernel/identity.mjs +216 -0
- package/lib/kernel/memory.mjs +217 -0
- package/lib/kernel/printable.mjs +6 -0
- package/lib/kernel/registry.mjs +79 -0
- package/lib/kernel/ruleset.mjs +72 -0
- package/lib/kernel/source-tree.mjs +159 -0
- package/lib/kev.mjs +27 -0
- package/lib/options.mjs +512 -0
- package/lib/packs.mjs +148 -0
- package/lib/parser.mjs +2055 -0
- package/lib/policy.mjs +163 -0
- package/lib/precompile-cics.mjs +169 -0
- package/lib/precompile.mjs +544 -0
- package/lib/reach.mjs +122 -0
- package/lib/revision.json +1 -0
- package/lib/revision.mjs +89 -0
- package/lib/sarif.mjs +222 -0
- package/lib/scan.mjs +272 -0
- package/lib/sets/build.mjs +234 -0
- package/lib/sets/cics.mjs +306 -0
- package/lib/sets/compile.mjs +187 -0
- package/lib/sets/copybook.mjs +174 -0
- package/lib/sets/flow.mjs +487 -0
- package/lib/sets/hidden.mjs +216 -0
- package/lib/sets/jcl.mjs +440 -0
- package/lib/sets/log.mjs +406 -0
- package/lib/sets/opaque.mjs +102 -0
- package/lib/sets/priv.mjs +322 -0
- package/lib/sets/recon.mjs +267 -0
- package/lib/sets/vendor.mjs +117 -0
- package/lib/sets/web.mjs +327 -0
- package/lib/site.mjs +164 -0
- package/lib/sources.mjs +156 -0
- package/lib/tui/app.mjs +325 -0
- package/lib/tui/keys.mjs +39 -0
- package/lib/tui/model.mjs +96 -0
- package/lib/tui/run.mjs +38 -0
- package/lib/tui/screen.mjs +59 -0
- package/lib/tui/terminal.mjs +46 -0
- package/lib/utilities.mjs +296 -0
- package/lib/version.mjs +15 -0
- package/lib/words.mjs +318 -0
- package/package.json +45 -6
- package/rules/advisories.json +264 -0
- package/rules/compliance-dora.json +2151 -0
- package/rules/compliance-ffiec.json +2134 -0
- package/rules/compliance-nist80053.json +2134 -0
- package/rules/gitleaks-mainframe.toml +57 -0
- package/rules/kev-ids.json +1729 -0
- package/rules/packs/broadcom.json +124 -0
- package/rules/packs/connectdirect.json +116 -0
- package/rules/packs/controlm.json +114 -0
- package/rules/system-layouts.json +28 -0
- package/schema/cobolwork-coverage.schema.json +65 -0
- package/schema/cobolwork.policy.schema.json +54 -0
package/lib/sources.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { readFileSync, openSync, readSync, closeSync } from 'node:fs';
|
|
3
|
+
import { extname, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// One definition of what each kind of file is, used by every rule set. They disagreed: the flow
|
|
6
|
+
// and CICS rules skipped .sqb and .pco (embedded SQL, where the SQL rules matter most), and the
|
|
7
|
+
// resolver could not find a .copy or .inc copybook that the inventory counted.
|
|
8
|
+
// A path in a report is POSIX-shaped on every platform. A report is stored, diffed and compared
|
|
9
|
+
// against git output, which uses forward slashes, so a native separator would make one file read
|
|
10
|
+
// as two. On a platform whose separator already is '/', this is the identity.
|
|
11
|
+
export const relPath = (root, p) => relative(root, p).split(sep).join('/');
|
|
12
|
+
|
|
13
|
+
export const PROGRAM_EXT = ['.cbl', '.cob', '.cobol', '.sqb', '.pco'];
|
|
14
|
+
export const COPY_EXT = ['.cpy', '.copy', '.inc'];
|
|
15
|
+
export const JCL_EXT = ['.jcl', '.job', '.proc', '.prc', '.cntl'];
|
|
16
|
+
export const BMS_EXT = ['.bms'];
|
|
17
|
+
|
|
18
|
+
const has = (list) => (p) => list.includes(extname(p).toLowerCase());
|
|
19
|
+
|
|
20
|
+
// A member of a partitioned data set has no extension. That is not an edge case - it is what a
|
|
21
|
+
// real estate looks like, because the mainframe has no such thing as a file extension, and an
|
|
22
|
+
// estate exported from a PDS arrives as bare names. Reading by extension alone means opening none
|
|
23
|
+
// of it and reporting a clean zero, which is the failure this project exists to refuse.
|
|
24
|
+
//
|
|
25
|
+
// So a file with no extension is classified by what is in it. Only the first few kilobytes are
|
|
26
|
+
// read, only files without an extension are sniffed, and the answer is cached: this runs over
|
|
27
|
+
// every file in the tree and must not cost a parse.
|
|
28
|
+
const SNIFF_BYTES = 4096;
|
|
29
|
+
const sniffed = new Map();
|
|
30
|
+
|
|
31
|
+
// Names that are certainly not source, so the commonest extensionless files cost nothing.
|
|
32
|
+
const NOT_SOURCE = /^(LICEN[CS]E|README|MAKEFILE|DOCKERFILE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS|VERSION|COPYING|Jenkinsfile|Procfile|\..*)$/i;
|
|
33
|
+
|
|
34
|
+
export function sniffKind(path) {
|
|
35
|
+
if (sniffed.has(path)) return sniffed.get(path);
|
|
36
|
+
let kind = null;
|
|
37
|
+
const name = path.split(/[\\/]/).pop();
|
|
38
|
+
if (!NOT_SOURCE.test(name)) {
|
|
39
|
+
try {
|
|
40
|
+
const fd = openSync(path, 'r');
|
|
41
|
+
const buf = Buffer.alloc(SNIFF_BYTES);
|
|
42
|
+
let n;
|
|
43
|
+
try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
|
|
44
|
+
const head = buf.subarray(0, n);
|
|
45
|
+
// An EBCDIC member is decoded before it is read, or every test below sees mojibake.
|
|
46
|
+
const text = looksEbcdic(head) ? decodeEbcdic(head) : head.toString('latin1');
|
|
47
|
+
// A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
|
|
48
|
+
// NUL in source is exactly the character an editor or a patch is most likely to eat.
|
|
49
|
+
if (head.includes(0)) kind = null;
|
|
50
|
+
else if (/^\s*\/\/\S*\s+(?:JOB|EXEC|PROC|DD)\b/m.test(text)) kind = 'jcl';
|
|
51
|
+
else if (/\bIDENTIFICATION\s+DIVISION|\bPROGRAM-ID\s*\./i.test(text)) kind = 'program';
|
|
52
|
+
// A copybook is a data description with no divisions: level numbers and picture clauses.
|
|
53
|
+
else if (/^[^*\n]{0,10}\s*\d\d\s+[A-Z][A-Z0-9-]*\s+(?:PIC|PICTURE|OCCURS|REDEFINES|USAGE)\b/im.test(text)) kind = 'copybook';
|
|
54
|
+
// A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
|
|
55
|
+
// classified before classifies differently.
|
|
56
|
+
else if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) kind = 'bms';
|
|
57
|
+
} catch { kind = null; }
|
|
58
|
+
}
|
|
59
|
+
sniffed.set(path, kind);
|
|
60
|
+
return kind;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extension first, because it is free and it is right whenever it is present.
|
|
64
|
+
const byExtOrContent = (list, kind) => (p) => (extname(p) ? list.includes(extname(p).toLowerCase()) : sniffKind(p) === kind);
|
|
65
|
+
|
|
66
|
+
export const isProgram = byExtOrContent(PROGRAM_EXT, 'program');
|
|
67
|
+
export const isCopybook = byExtOrContent(COPY_EXT, 'copybook');
|
|
68
|
+
export const isJcl = byExtOrContent(JCL_EXT, 'jcl');
|
|
69
|
+
export const isBms = byExtOrContent(BMS_EXT, 'bms');
|
|
70
|
+
// Not BMS, by either route: the sets that read every source file have not chosen to read maps.
|
|
71
|
+
export const isSource = (p) => (extname(p)
|
|
72
|
+
? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
|
|
73
|
+
: ![null, 'bms'].includes(sniffKind(p)));
|
|
74
|
+
|
|
75
|
+
// For a report that wants to say how much of what it read had no extension to go on.
|
|
76
|
+
export const sniffedCount = () => {
|
|
77
|
+
let n = 0;
|
|
78
|
+
for (const v of sniffed.values()) if (v) n++;
|
|
79
|
+
return n;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
|
|
83
|
+
// files on it and no others.
|
|
84
|
+
//
|
|
85
|
+
// `deny` is the other direction, and it exists for measurement rather than for scanning. A corpus
|
|
86
|
+
// run over public COBOL is dominated by files that are not code anybody runs: a repository holding
|
|
87
|
+
// the NIST conformance suite fires SET ADDRESS OF thousands of times because exercising every
|
|
88
|
+
// construct exhaustively is what those files are for. Counting them tells you about conformance
|
|
89
|
+
// suites, not about COBOL. Substrings are matched against the POSIX-shaped path, so 'tests/' and
|
|
90
|
+
// '/fixtures/' read the way someone would write them.
|
|
91
|
+
//
|
|
92
|
+
// It is off unless asked for. A scan of a real estate should read the test code too - test JCL
|
|
93
|
+
// carries real credentials as often as anything else does.
|
|
94
|
+
export const inScope = (opts = {}) => {
|
|
95
|
+
const allow = opts.allow ? (p) => opts.allow.has(p) : () => true;
|
|
96
|
+
if (!opts.deny || !opts.deny.length) return allow;
|
|
97
|
+
const deny = opts.deny.map((d) => d.replace(/\\/g, '/').toLowerCase());
|
|
98
|
+
return (p) => {
|
|
99
|
+
if (!allow(p)) return false;
|
|
100
|
+
const norm = String(p).replace(/\\/g, '/').toLowerCase();
|
|
101
|
+
return !deny.some((d) => norm.includes(d));
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// IBM code page 037, the printable range. Positions outside it decode to U+FFFD so an unknown byte
|
|
106
|
+
// is visible as unknown rather than guessed at. 037 and 1047 differ only in brackets, caret and
|
|
107
|
+
// not-sign, none of which COBOL needs; both bracket positions are mapped so either reads.
|
|
108
|
+
const E2A = (() => {
|
|
109
|
+
const t = new Array(256).fill('�');
|
|
110
|
+
const set = (at, chars) => { for (let i = 0; i < chars.length; i++) t[at + i] = chars[i]; };
|
|
111
|
+
t[0x05] = '\t'; t[0x0D] = '\r'; t[0x15] = '\n'; t[0x25] = '\n'; t[0x40] = ' ';
|
|
112
|
+
set(0x4A, '¢.<(+|&'); set(0x5A, '!$*);¬-/');
|
|
113
|
+
set(0x6A, '¦,%_>?'); set(0x79, '`:#@\'="');
|
|
114
|
+
set(0x81, 'abcdefghi'); set(0x91, 'jklmnopqr'); set(0xA1, '~stuvwxyz');
|
|
115
|
+
set(0xC0, '{ABCDEFGHI'); set(0xD0, '}JKLMNOPQR'); set(0xE0, '\\'); set(0xE2, 'STUVWXYZ');
|
|
116
|
+
set(0xF0, '0123456789');
|
|
117
|
+
t[0xAD] = '['; t[0xBA] = '['; t[0xBD] = ']'; t[0xBB] = ']'; t[0x5F] = '¬'; t[0xB0] = '^';
|
|
118
|
+
return t;
|
|
119
|
+
})();
|
|
120
|
+
|
|
121
|
+
const ASCII_ALNUM = (b) => (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A);
|
|
122
|
+
const EBCDIC_ALNUM = (b) => (b >= 0x81 && b <= 0xA9) || (b >= 0xC1 && b <= 0xE9) || (b >= 0xF0 && b <= 0xF9);
|
|
123
|
+
|
|
124
|
+
// EBCDIC source has a space at 0x40 and letters above 0x80; ASCII has almost none of either. A NUL
|
|
125
|
+
// byte means binary, which is neither.
|
|
126
|
+
export function looksEbcdic(buf) {
|
|
127
|
+
const n = Math.min(buf.length, 8192);
|
|
128
|
+
if (n < 16) return false;
|
|
129
|
+
let eb = 0; let as = 0;
|
|
130
|
+
for (let i = 0; i < n; i++) {
|
|
131
|
+
const b = buf[i];
|
|
132
|
+
if (b === 0) return false;
|
|
133
|
+
if (b === 0x40 || EBCDIC_ALNUM(b)) eb++;
|
|
134
|
+
if (b === 0x20 || ASCII_ALNUM(b)) as++;
|
|
135
|
+
}
|
|
136
|
+
return eb / n > 0.6 && as / n < 0.25;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function decodeEbcdic(buf) {
|
|
140
|
+
let hasNewline = false;
|
|
141
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x15 || buf[i] === 0x25) { hasNewline = true; break; }
|
|
142
|
+
const chars = new Array(buf.length);
|
|
143
|
+
for (let i = 0; i < buf.length; i++) chars[i] = E2A[buf[i]];
|
|
144
|
+
const text = chars.join('');
|
|
145
|
+
// A member copied off the mainframe in binary keeps its fixed 80-byte records and no line ends.
|
|
146
|
+
if (!hasNewline && buf.length % 80 === 0) return text.match(/[\s\S]{80}/g).join('\n');
|
|
147
|
+
return text;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Every rule set reads source through here, so an EBCDIC member is decoded once, the same way,
|
|
151
|
+
// and reported as EBCDIC instead of silently parsing as noise.
|
|
152
|
+
export function readSource(path) {
|
|
153
|
+
const buf = readFileSync(path);
|
|
154
|
+
if (looksEbcdic(buf)) return { text: decodeEbcdic(buf), encoding: 'ebcdic' };
|
|
155
|
+
return { text: buf.toString('latin1'), encoding: 'latin1' };
|
|
156
|
+
}
|
package/lib/tui/app.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { createScreen, put, styleRow } from './screen.mjs';
|
|
3
|
+
import { ACTIONS, KEYMAPS, LEGEND, actionFor } from './keys.mjs';
|
|
4
|
+
import { EVIDENCE } from '../kernel/findings.mjs';
|
|
5
|
+
import {
|
|
6
|
+
banner, countsLine, rows as findingRows, viewLabel, DEFAULT_VIEW, SORT_NAMES, EVIDENCE_KINDS, WHO_ACTS, SEV_LETTER,
|
|
7
|
+
} from './model.mjs';
|
|
8
|
+
|
|
9
|
+
export const PANELS = Object.freeze(['home', 'findings', 'finding', 'help']);
|
|
10
|
+
export const MIN_COLS = 80;
|
|
11
|
+
export const MIN_ROWS = 24;
|
|
12
|
+
|
|
13
|
+
export function initialState({ report, keymap = 'ispf', cols = MIN_COLS, rows = MIN_ROWS }) {
|
|
14
|
+
return {
|
|
15
|
+
report, keymap: KEYMAPS[keymap] ? keymap : 'ispf', cols, rows,
|
|
16
|
+
stack: [{ panel: 'home' }], view: { ...DEFAULT_VIEW }, sel: 0, top: 0,
|
|
17
|
+
cmd: '', cmdOpen: false, message: '',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const current = (s) => s.stack[s.stack.length - 1];
|
|
22
|
+
const done = (state, effects = []) => ({ state, effects });
|
|
23
|
+
const replaceTop = (s, entry) => ({ ...s, stack: [...s.stack.slice(0, -1), entry] });
|
|
24
|
+
|
|
25
|
+
export function update(state, event) {
|
|
26
|
+
if (event.type === 'resize') return done({ ...state, cols: event.cols, rows: event.rows });
|
|
27
|
+
if (event.type !== 'key') return done(state);
|
|
28
|
+
const t = event.token;
|
|
29
|
+
const s = { ...state, message: '' };
|
|
30
|
+
// Typing edits the command line: always under ISPF, under modern only after ':' or '/'.
|
|
31
|
+
if (s.keymap === 'ispf' || s.cmdOpen) {
|
|
32
|
+
if (t.startsWith('char:')) return done({ ...s, cmd: s.cmd + t.slice(5) });
|
|
33
|
+
if (t === 'Backspace') return done({ ...s, cmd: s.cmd.slice(0, -1) });
|
|
34
|
+
if (t === 'Enter' && s.cmd.trim()) return command({ ...s, cmd: '', cmdOpen: false }, s.cmd.trim());
|
|
35
|
+
if ((t === 'Escape' || t === 'Enter') && s.cmdOpen) return done({ ...s, cmd: '', cmdOpen: false });
|
|
36
|
+
}
|
|
37
|
+
const action = actionFor(s.keymap, t);
|
|
38
|
+
return action ? act(s, action, t) : done(s);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function act(s, action, token) {
|
|
42
|
+
switch (action) {
|
|
43
|
+
case 'quit': return done(s, [{ type: 'quit' }]);
|
|
44
|
+
case 'end': return end(s);
|
|
45
|
+
case 'help': return help(s);
|
|
46
|
+
case 'command': return done({ ...s, cmdOpen: true, cmd: token === 'char:/' ? 'FIND ' : s.cmd });
|
|
47
|
+
case 'open': return open(s);
|
|
48
|
+
default: return move(s, action);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function end(s) {
|
|
53
|
+
if (s.stack.length === 1) return done(s, [{ type: 'quit' }]);
|
|
54
|
+
return done({ ...s, stack: s.stack.slice(0, -1) });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function help(s) {
|
|
58
|
+
const t = current(s);
|
|
59
|
+
if (t.panel === 'help') return done(replaceTop(s, { ...t, keys: !t.keys, scroll: 0 }));
|
|
60
|
+
return done({ ...s, stack: [...s.stack, { panel: 'help', of: t.panel, keys: false, scroll: 0 }] });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function open(s) {
|
|
64
|
+
const t = current(s);
|
|
65
|
+
if (t.panel === 'home') return done({ ...s, stack: [...s.stack, { panel: 'findings' }] });
|
|
66
|
+
if (t.panel === 'findings') {
|
|
67
|
+
const list = findingRows(s.report, s.view);
|
|
68
|
+
if (!list.length) return done({ ...s, message: 'There is no finding to open in this view.' });
|
|
69
|
+
return done({ ...s, stack: [...s.stack, { panel: 'finding', f: list[s.sel].f, scroll: 0 }] });
|
|
70
|
+
}
|
|
71
|
+
return done(s);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function move(s, action) {
|
|
75
|
+
const t = current(s);
|
|
76
|
+
if (t.panel === 'findings') {
|
|
77
|
+
const n = findingRows(s.report, s.view).length;
|
|
78
|
+
const page = Math.max(1, findingsLayout(s).height);
|
|
79
|
+
const to = { up: s.sel - 1, down: s.sel + 1, pageUp: s.sel - page, pageDown: s.sel + page, top: 0, bottom: n - 1 }[action];
|
|
80
|
+
const sel = Math.max(0, Math.min(n - 1, to ?? s.sel));
|
|
81
|
+
let top = s.top;
|
|
82
|
+
if (action === 'pageDown' || action === 'pageUp') top = Math.max(0, Math.min(Math.max(0, n - page), s.top + (to - s.sel)));
|
|
83
|
+
if (sel < top) top = sel;
|
|
84
|
+
if (sel >= top + page) top = sel - page + 1;
|
|
85
|
+
return done({ ...s, sel: Math.max(0, sel), top: Math.max(0, top) });
|
|
86
|
+
}
|
|
87
|
+
if (t.panel === 'finding' || t.panel === 'help') {
|
|
88
|
+
const page = Math.max(1, s.rows - 4);
|
|
89
|
+
const delta = { up: -1, down: 1, pageUp: -page, pageDown: page, top: -Infinity, bottom: Infinity }[action] || 0;
|
|
90
|
+
const max = Math.max(0, bodyLines(s, t).length - page);
|
|
91
|
+
return done(replaceTop(s, { ...t, scroll: Math.max(0, Math.min(max, (t.scroll || 0) + delta)) }));
|
|
92
|
+
}
|
|
93
|
+
return done(s);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SORT_WORDS = { SEV: 'severity', SEVERITY: 'severity', RULE: 'rule', PROGRAM: 'program', PGM: 'program', WHERE: 'where', FILE: 'where', PATH: 'where', EVIDENCE: 'evidence', EVID: 'evidence' };
|
|
97
|
+
|
|
98
|
+
function command(s, text) {
|
|
99
|
+
const [word, ...rest] = text.split(/\s+/);
|
|
100
|
+
const verb = word.toUpperCase();
|
|
101
|
+
const arg = rest.join(' ');
|
|
102
|
+
const fresh = (view) => done({ ...s, view, sel: 0, top: 0 });
|
|
103
|
+
switch (verb) {
|
|
104
|
+
case 'HELP': return help(s);
|
|
105
|
+
case 'END': return end(s);
|
|
106
|
+
case '=X': case 'EXIT': case 'QUIT': return done(s, [{ type: 'quit' }]);
|
|
107
|
+
case '1': case 'FINDINGS': {
|
|
108
|
+
const at = s.stack.findIndex((t) => t.panel === 'findings');
|
|
109
|
+
return done({ ...s, stack: at < 0 ? [...s.stack, { panel: 'findings' }] : s.stack.slice(0, at + 1) });
|
|
110
|
+
}
|
|
111
|
+
case 'TOP': return move(s, 'top');
|
|
112
|
+
case 'BOTTOM': return move(s, 'bottom');
|
|
113
|
+
case 'RESET': return fresh({ ...DEFAULT_VIEW });
|
|
114
|
+
case 'SORT': {
|
|
115
|
+
const key = SORT_WORDS[arg.toUpperCase()];
|
|
116
|
+
return key ? fresh({ ...s.view, sort: key }) : done({ ...s, message: `SORT takes ${SORT_NAMES.join(', ')}.` });
|
|
117
|
+
}
|
|
118
|
+
case 'FILTER': {
|
|
119
|
+
if (/^off$/i.test(arg)) return fresh({ ...s.view, evidence: null });
|
|
120
|
+
const kinds = arg.toLowerCase().split(/[,\s]+/).filter(Boolean);
|
|
121
|
+
const bad = kinds.filter((k) => !EVIDENCE_KINDS.includes(k));
|
|
122
|
+
if (!kinds.length || bad.length) return done({ ...s, message: `FILTER takes ${EVIDENCE_KINDS.join(', ')} or OFF${bad.length ? `; not ${bad.join(', ')}` : ''}.` });
|
|
123
|
+
return fresh({ ...s.view, evidence: kinds });
|
|
124
|
+
}
|
|
125
|
+
case 'FIND':
|
|
126
|
+
return arg ? fresh({ ...s.view, find: arg }) : done({ ...s, message: 'FIND needs the text to look for.' });
|
|
127
|
+
case 'KEYS': {
|
|
128
|
+
const k = arg.toLowerCase();
|
|
129
|
+
return KEYMAPS[k] ? done({ ...s, keymap: k, message: `Keys are now ${k}.` }) : done({ ...s, message: `KEYS takes ${Object.keys(KEYMAPS).join(' or ')}.` });
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
return done({ ...s, message: `Command "${word}" not recognised. F1 lists the commands.` });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function view(state) {
|
|
137
|
+
const { cols, rows } = state;
|
|
138
|
+
const scr = createScreen(cols, rows);
|
|
139
|
+
if (cols < MIN_COLS || rows < MIN_ROWS) {
|
|
140
|
+
put(scr, 0, 1, 'cobolwork needs a terminal of at least 80x24;', 'warn');
|
|
141
|
+
put(scr, 1, 1, `this one is ${cols}x${rows}. Enlarge it, or press Ctrl-C to leave.`);
|
|
142
|
+
return scr;
|
|
143
|
+
}
|
|
144
|
+
const t = current(state);
|
|
145
|
+
const drawn = DRAW[t.panel](scr, state, t);
|
|
146
|
+
put(scr, 0, 0, ` COBOLWORK ${drawn.title}`, 'title');
|
|
147
|
+
if (drawn.right) put(scr, 0, cols - drawn.right.length - 1, drawn.right, 'title');
|
|
148
|
+
styleRow(scr, 0, 'title');
|
|
149
|
+
put(scr, 1, 0, ` Command ===> ${state.cmd}${state.cmdOpen ? '_' : ''}`);
|
|
150
|
+
if (state.keymap === 'ispf') put(scr, 1, cols - 17, 'Scroll ===> PAGE');
|
|
151
|
+
if (state.message) put(scr, rows - 2, 0, ` ${state.message}`, 'hi');
|
|
152
|
+
else if (drawn.hint) put(scr, rows - 2, 0, ` ${drawn.hint}`, 'dim');
|
|
153
|
+
put(scr, rows - 1, 0, LEGEND[state.keymap], 'dim');
|
|
154
|
+
return scr;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function drawBanner(scr, report, row, max) {
|
|
158
|
+
const b = banner(report);
|
|
159
|
+
let shown = b.lines;
|
|
160
|
+
if (shown.length > max) shown = [...b.lines.slice(0, max - 1), `... and ${b.lines.length - max + 1} more: Home lists every one`];
|
|
161
|
+
shown.forEach((line, i) => {
|
|
162
|
+
const text = i > 0 ? ` ${line}` : b.complete ? ` ${line}` : ` ! ${line}`;
|
|
163
|
+
put(scr, row + i, 0, text, b.complete ? 'dim' : 'warn');
|
|
164
|
+
});
|
|
165
|
+
return row + shown.length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findingsLayout(s) {
|
|
169
|
+
const b = banner(s.report);
|
|
170
|
+
const bannerLines = Math.min(b.lines.length, 4);
|
|
171
|
+
const listTop = 2 + bannerLines + 2;
|
|
172
|
+
return { bannerLines, listTop, height: s.rows - 2 - listTop };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const pad = (text, width) => {
|
|
176
|
+
const str = String(text ?? '');
|
|
177
|
+
return str.length > width ? str.slice(0, width - 1) + '>' : str.padEnd(width);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const DRAW = {
|
|
181
|
+
home(scr, s) {
|
|
182
|
+
const r0 = drawBanner(scr, s.report, 2, s.rows - 12);
|
|
183
|
+
const sum = s.report.summary;
|
|
184
|
+
const accepted = sum.baseline && sum.baseline.suppressed ? `, and ${sum.baseline.suppressed} accepted by a baseline` : '';
|
|
185
|
+
let r = r0 + 1;
|
|
186
|
+
put(scr, r++, 1, `Findings ${sum.findings ?? s.report.findings.length}${accepted}`);
|
|
187
|
+
put(scr, r++, 1, countsLine(s.report));
|
|
188
|
+
const kinds = [['programs', sum.programFiles], ['copybooks', sum.copybookFiles], ['JCL', sum.jclFiles]].filter(([, n]) => n != null);
|
|
189
|
+
put(scr, r++, 1, `Read ${sum.filesScanned ?? '?'} file(s)${kinds.length ? `: ${kinds.map(([k, n]) => `${n} ${k}`).join(', ')}` : ''}`);
|
|
190
|
+
r++;
|
|
191
|
+
const sets = Object.entries(sum.bySet || {});
|
|
192
|
+
if (sets.length && r < s.rows - 3) {
|
|
193
|
+
put(scr, r++, 1, 'Rule set Read Unreadable Stopped', 'dim');
|
|
194
|
+
for (const [name, x] of sets) {
|
|
195
|
+
if (r >= s.rows - 2) break;
|
|
196
|
+
put(scr, r++, 1, `${pad(name, 12)}${String(x.filesScanned ?? '').padStart(6)}${String(x.filesUnreadable ?? '').padStart(12)} ${x.stoppedBy || ''}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { title: 'Home', right: `${s.report.findings.length} finding(s)`, hint: 'Enter or 1: Findings. F1: Help. F3: Leave.' };
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
findings(scr, s) {
|
|
203
|
+
const layout = findingsLayout(s);
|
|
204
|
+
let r = drawBanner(scr, s.report, 2, 4);
|
|
205
|
+
put(scr, r++, 1, countsLine(s.report));
|
|
206
|
+
const whereW = 17, progW = 9;
|
|
207
|
+
const ruleW = s.cols - 18 - progW - whereW - 1;
|
|
208
|
+
put(scr, r++, 0, ` Sv Evidence X ${pad('Rule', ruleW)} ${pad('Program', progW)}${'Where'}`, 'dim');
|
|
209
|
+
const list = findingRows(s.report, s.view);
|
|
210
|
+
if (!list.length) put(scr, layout.listTop, 1, s.report.findings.length ? 'No finding matches this view. RESET shows them all.' : 'No findings.');
|
|
211
|
+
for (let i = 0; i < layout.height && s.top + i < list.length; i++) {
|
|
212
|
+
const at = s.top + i;
|
|
213
|
+
const x = list[at];
|
|
214
|
+
const row = layout.listTop + i;
|
|
215
|
+
const mark = at === s.sel ? ' > ' : ' ';
|
|
216
|
+
put(scr, row, 0, `${mark}${x.letter} ${pad(x.evidence, 9)} ${x.cross ? 'X' : ' '} ${pad(x.rule, ruleW)} ${pad(x.program || '-', progW)}${pad(x.where, whereW)}`);
|
|
217
|
+
if (at === s.sel) styleRow(scr, row, 'sel');
|
|
218
|
+
else if (x.sev === 'crit' || x.sev === 'high') put(scr, row, 3, x.letter, x.sev);
|
|
219
|
+
}
|
|
220
|
+
const label = viewLabel(s.view);
|
|
221
|
+
return {
|
|
222
|
+
title: `Findings${label ? ` (${label})` : ''}`,
|
|
223
|
+
right: list.length ? `Row ${s.sel + 1} of ${list.length}` : 'No rows',
|
|
224
|
+
hint: 'Enter: open. SORT, FILTER, FIND, RESET on the command line.',
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
finding(scr, s, t) {
|
|
229
|
+
const body = bodyLines(s, t);
|
|
230
|
+
body.slice(t.scroll || 0, (t.scroll || 0) + s.rows - 4).forEach((line, i) => put(scr, 2 + i, 0, line));
|
|
231
|
+
const list = findingRows(s.report, s.view);
|
|
232
|
+
const at = list.findIndex((r) => r.f === t.f);
|
|
233
|
+
return { title: 'Finding', right: at < 0 ? `not in this view of ${list.length}` : `${at + 1} of ${list.length}`, hint: 'F3: back to the list. F7/F8: scroll.' };
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
help(scr, s, t) {
|
|
237
|
+
const body = bodyLines(s, t);
|
|
238
|
+
body.slice(t.scroll || 0, (t.scroll || 0) + s.rows - 4).forEach((line, i) => put(scr, 2 + i, 0, line));
|
|
239
|
+
const of = t.keys ? `${s.keymap} keys` : PANEL_NAME[t.of];
|
|
240
|
+
return { title: `Help: ${of}`, right: '', hint: t.keys ? 'F1: help for the panel. F3: back.' : 'F1: the keys. F3: back.' };
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const PANEL_NAME = { home: 'Home', findings: 'Findings', finding: 'Finding', help: 'Help' };
|
|
245
|
+
|
|
246
|
+
function wrap(text, width, indent = '') {
|
|
247
|
+
const out = [];
|
|
248
|
+
let line = '';
|
|
249
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
250
|
+
if (line && (line + ' ' + word).length > width) { out.push(line); line = indent + word; }
|
|
251
|
+
else line = line ? `${line} ${word}` : word;
|
|
252
|
+
}
|
|
253
|
+
if (line) out.push(line);
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function bodyLines(s, t) {
|
|
258
|
+
const w = s.cols - 2;
|
|
259
|
+
if (t.panel === 'help') {
|
|
260
|
+
if (t.keys) {
|
|
261
|
+
const map = KEYMAPS[s.keymap];
|
|
262
|
+
return [` The ${s.keymap} keys. KEYS ISPF or KEYS MODERN switches.`, '',
|
|
263
|
+
...ACTIONS.map((a) => ` ${a.padEnd(10)} ${Object.entries(map).filter(([, v]) => v === a).map(([k]) => k.replace(/^char:/, '')).join(' ')}`)];
|
|
264
|
+
}
|
|
265
|
+
return HELP[t.of].flatMap((p) => (p ? wrap(p, w).map((l) => ` ${l}`) : ['']));
|
|
266
|
+
}
|
|
267
|
+
const { f } = t;
|
|
268
|
+
const field = (label, value) => wrap(value, w - 11).map((l, i) => ` ${i ? ''.padEnd(10) : label.padEnd(10)}${l}`);
|
|
269
|
+
const lines = [
|
|
270
|
+
...field('Rule', f.rule),
|
|
271
|
+
...field('Severity', `${f.sev} (${SEV_LETTER[f.sev] || '?'})${f.cwe ? ` ${f.cwe}` : ''}`),
|
|
272
|
+
...(f.guardedFrom ? field('Checked', `lowered from ${f.guardedFrom} by the check on ${f.guard.item} at ${f.guard.file}:${f.guard.line}`) : []),
|
|
273
|
+
...field('Evidence', `${f.evidence}: ${EVIDENCE[f.evidence] || ''}`),
|
|
274
|
+
...field('Who acts', WHO_ACTS[f.evidence] || 'not stated'),
|
|
275
|
+
...(f.program ? field('Program', `${f.program}${f.crossProgram ? ' crosses programs' : ''}${f.hops ? ` ${f.hops} hop(s)` : ''}`) : []),
|
|
276
|
+
...field('Where', `${f.path}:${f.line}`),
|
|
277
|
+
...field('Identity', f.fingerprint || 'none: this report predates fingerprints'),
|
|
278
|
+
'',
|
|
279
|
+
...wrap(f.detail || '', w).map((l) => ` ${l}`),
|
|
280
|
+
];
|
|
281
|
+
if (f.related && f.related.length) {
|
|
282
|
+
lines.push('');
|
|
283
|
+
for (const x of f.related) lines.push(...field('Related', `${x.path}:${x.line} ${x.detail || ''}`));
|
|
284
|
+
}
|
|
285
|
+
if (f.trace && f.trace.length) {
|
|
286
|
+
lines.push('', ' Trace', ...f.trace.map((h, i) => (h.elided ? ` ${h.via}` : ` ${String(i + 1).padStart(2)} ${pad(h.program, 9)}${pad(h.item, 18)}${h.via}`)));
|
|
287
|
+
lines.push('', ` Source for each hop: cobolwork explain <path> ${f.fingerprint || '<fingerprint>'}`);
|
|
288
|
+
}
|
|
289
|
+
return lines;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const HELP = {
|
|
293
|
+
home: [
|
|
294
|
+
'Home shows what the scan read before what it found.',
|
|
295
|
+
'',
|
|
296
|
+
'The lines under the command line are coverage: each rule set that fell short, and why. A count over files nobody read is not a clean result, so read them first.',
|
|
297
|
+
'',
|
|
298
|
+
'Enter, or the command 1, opens Findings.',
|
|
299
|
+
'',
|
|
300
|
+
'Commands: FINDINGS (or 1), KEYS ISPF or KEYS MODERN, HELP, END, and =X to leave.',
|
|
301
|
+
],
|
|
302
|
+
findings: [
|
|
303
|
+
'Findings lists every finding, critical first, then by rule.',
|
|
304
|
+
'',
|
|
305
|
+
'Sv is severity as a letter: C critical, H high, M medium, L low, I info. Evidence is the kind of claim a finding makes. X marks a path that crosses programs.',
|
|
306
|
+
'',
|
|
307
|
+
'Enter opens the selected finding. F7 and F8, or Page Up and Page Down, move a page.',
|
|
308
|
+
'',
|
|
309
|
+
'Commands:',
|
|
310
|
+
' SORT SEVERITY, RULE, PROGRAM, WHERE or EVIDENCE',
|
|
311
|
+
` FILTER one or more of ${EVIDENCE_KINDS.join(', ')}; FILTER OFF`,
|
|
312
|
+
' FIND text: rule, program, path or detail containing it',
|
|
313
|
+
' RESET: the whole list, by severity',
|
|
314
|
+
' TOP, BOTTOM, END, and =X to leave',
|
|
315
|
+
],
|
|
316
|
+
finding: [
|
|
317
|
+
'One finding: what the rule claims, who acts on it, and where.',
|
|
318
|
+
'',
|
|
319
|
+
'Evidence says what the tool established. A path was traced by reading the code, not by running it. Severity says how urgent it is; a check on the route lowers it one step and is named here.',
|
|
320
|
+
'',
|
|
321
|
+
'Identity is the fingerprint. It does not change when code moves above the finding, so a baseline or a later report can refer to it.',
|
|
322
|
+
'',
|
|
323
|
+
'cobolwork explain <path> <fingerprint> prints the trace with the source line of every hop.',
|
|
324
|
+
],
|
|
325
|
+
};
|
package/lib/tui/keys.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
export const ACTIONS = Object.freeze([
|
|
3
|
+
'help', 'end', 'quit', 'up', 'down', 'pageUp', 'pageDown', 'top', 'bottom', 'open', 'command',
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
const COMMON = {
|
|
7
|
+
F1: 'help', 'C-c': 'quit', Up: 'up', Down: 'down', PageUp: 'pageUp', PageDown: 'pageDown',
|
|
8
|
+
Home: 'top', End: 'bottom', Enter: 'open',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const ISPF = { ...COMMON, F3: 'end', F12: 'end', F7: 'pageUp', F8: 'pageDown', Tab: 'command' };
|
|
12
|
+
|
|
13
|
+
const MODERN = {
|
|
14
|
+
...COMMON, F3: 'end', Escape: 'end', 'char:q': 'end', 'char:?': 'help', 'char:j': 'down', 'char:k': 'up',
|
|
15
|
+
'char: ': 'pageDown', 'char:b': 'pageUp', 'char:g': 'top', 'char:G': 'bottom', 'char::': 'command', 'char:/': 'command',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const KEYMAPS = Object.freeze({ ispf: Object.freeze(ISPF), modern: Object.freeze(MODERN) });
|
|
19
|
+
|
|
20
|
+
export const actionFor = (keymap, token) => (KEYMAPS[keymap] || KEYMAPS.ispf)[token] || null;
|
|
21
|
+
|
|
22
|
+
export const LEGEND = Object.freeze({
|
|
23
|
+
ispf: ' F1=Help F3=End F7=Up F8=Down F12=Cancel Enter=Open Type a command, then Enter',
|
|
24
|
+
modern: ' F1=Help ?=Help q/Esc=Back j/k=Move Space/b=Page Enter=Open :=Command /=Find',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const NAMED = {
|
|
28
|
+
up: 'Up', down: 'Down', left: 'Left', right: 'Right', pageup: 'PageUp', pagedown: 'PageDown',
|
|
29
|
+
home: 'Home', end: 'End', return: 'Enter', enter: 'Enter', escape: 'Escape', tab: 'Tab', backspace: 'Backspace',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// readline keypress → 'F1'..'F12', 'Up', 'Enter', ..., 'char:<c>', or 'C-c'.
|
|
33
|
+
export function keyToken(str, key = {}) {
|
|
34
|
+
if (key.ctrl && key.name === 'c') return 'C-c';
|
|
35
|
+
if (key.name && /^f([1-9]|1[0-2])$/.test(key.name)) return key.name.toUpperCase();
|
|
36
|
+
if (key.name && NAMED[key.name] && !key.ctrl && !key.meta) return NAMED[key.name];
|
|
37
|
+
if (typeof str === 'string' && str.length === 1 && str >= ' ' && str !== '\x7f') return `char:${str}`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { EVIDENCE } from '../kernel/findings.mjs';
|
|
3
|
+
|
|
4
|
+
export { WHO_ACTS } from '../kernel/findings.mjs';
|
|
5
|
+
export const SEVERITIES = Object.freeze(['crit', 'high', 'med', 'low', 'info']);
|
|
6
|
+
export const SEV_LETTER = Object.freeze({ crit: 'C', high: 'H', med: 'M', low: 'L', info: 'I' });
|
|
7
|
+
export const EVIDENCE_KINDS = Object.freeze(Object.keys(EVIDENCE));
|
|
8
|
+
|
|
9
|
+
export function readReport(doc) {
|
|
10
|
+
const refuse = (why) => { throw new Error(`not a cobolwork findings report: ${why}`); };
|
|
11
|
+
if (!doc || typeof doc !== 'object') refuse('it is not a JSON object');
|
|
12
|
+
if (typeof doc.tool !== 'string' || !doc.tool.startsWith('cobolwork')) refuse(`its tool is ${JSON.stringify(doc.tool ?? null)}`);
|
|
13
|
+
if (!Array.isArray(doc.findings)) refuse('it has no findings list');
|
|
14
|
+
if (!doc.summary || typeof doc.summary !== 'object') refuse('it has no summary');
|
|
15
|
+
const problem = doc.findings.map(findingProblem).find(Boolean);
|
|
16
|
+
if (problem) refuse(problem);
|
|
17
|
+
const sets = doc.summary.setsIncomplete;
|
|
18
|
+
if (sets !== undefined && (!Array.isArray(sets) || !sets.every((x) => isEntry(x) && typeof x.set === 'string'))) refuse('its incomplete sets are not named');
|
|
19
|
+
return doc;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isEntry = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
23
|
+
|
|
24
|
+
function findingProblem(f, i) {
|
|
25
|
+
const n = `finding ${i + 1}`;
|
|
26
|
+
if (!isEntry(f)) return `${n} is not an object`;
|
|
27
|
+
for (const k of ['rule', 'path', 'detail']) if (f[k] != null && typeof f[k] !== 'string') return `${n} has a ${k} that is not text`;
|
|
28
|
+
for (const k of ['related', 'trace']) if (f[k] != null && !(Array.isArray(f[k]) && f[k].every(isEntry))) return `${n} has a ${k} that is not a list of entries`;
|
|
29
|
+
if (f.guardedFrom != null && !isEntry(f.guard)) return `${n} says a check lowered it but names no check`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function banner(report) {
|
|
34
|
+
const s = report.summary;
|
|
35
|
+
if (s.nosrc) return { complete: false, lines: ['COVERAGE: nothing was read - no COBOL, JCL or copybook was found'] };
|
|
36
|
+
const sets = (s.setsIncomplete || []).map((x) => `${x.set.padEnd(9)}${x.kind === 'configuration' ? 'not run: ' : ''}${x.why}`);
|
|
37
|
+
if (!s.coverageIncomplete) {
|
|
38
|
+
return { complete: true, lines: [`Coverage complete: every file was read (${s.filesScanned ?? '?'})`, ...sets] };
|
|
39
|
+
}
|
|
40
|
+
const lines = ['COVERAGE INCOMPLETE - read this before the counts', ...sets];
|
|
41
|
+
if (s.copiesMissing) lines.push(`copy ${s.copiesMissing} COPY statement(s) name a copybook that is not in the tree`);
|
|
42
|
+
if (s.filesUnreadable) lines.push(`files ${s.filesUnreadable} file(s) could not be read`);
|
|
43
|
+
if (s.filesOverBudget) lines.push(`budget ${s.filesOverBudget} file(s) were past the source budget`);
|
|
44
|
+
return { complete: false, lines };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const countsLine = (report) => {
|
|
48
|
+
const bs = report.summary.bySeverity || {};
|
|
49
|
+
const sev = SEVERITIES.map((k) => `${SEV_LETTER[k]} ${bs[k] || 0}`).join(' ');
|
|
50
|
+
const be = report.summary.byEvidence || {};
|
|
51
|
+
const ev = Object.entries(be).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(' ');
|
|
52
|
+
return `Sev ${sev} Evidence ${ev || 'none'}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const SORTS = {
|
|
56
|
+
severity: (a, b) => SEVERITIES.indexOf(a.sev) - SEVERITIES.indexOf(b.sev) || cmp(a.rule, b.rule) || cmpWhere(a, b),
|
|
57
|
+
rule: (a, b) => cmp(a.rule, b.rule) || SEVERITIES.indexOf(a.sev) - SEVERITIES.indexOf(b.sev) || cmpWhere(a, b),
|
|
58
|
+
program: (a, b) => cmp(a.program, b.program) || SORTS.severity(a, b),
|
|
59
|
+
where: (a, b) => cmpWhere(a, b) || cmp(a.rule, b.rule),
|
|
60
|
+
evidence: (a, b) => cmp(a.evidence, b.evidence) || SORTS.severity(a, b),
|
|
61
|
+
};
|
|
62
|
+
export const SORT_NAMES = Object.freeze(Object.keys(SORTS));
|
|
63
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
64
|
+
const cmpWhere = (a, b) => cmp(a.path, b.path) || (a.line || 0) - (b.line || 0);
|
|
65
|
+
|
|
66
|
+
export const DEFAULT_VIEW = Object.freeze({ sort: 'severity', evidence: null, find: '' });
|
|
67
|
+
|
|
68
|
+
export function rows(report, view = DEFAULT_VIEW) {
|
|
69
|
+
const find = (view.find || '').toUpperCase();
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const f of report.findings) {
|
|
72
|
+
if (view.evidence && !view.evidence.includes(f.evidence)) continue;
|
|
73
|
+
if (find && ![f.rule, f.program, f.path, f.detail].some((x) => x && String(x).toUpperCase().includes(find))) continue;
|
|
74
|
+
out.push({
|
|
75
|
+
f,
|
|
76
|
+
sev: f.sev,
|
|
77
|
+
letter: SEV_LETTER[f.sev] || '?',
|
|
78
|
+
evidence: f.evidence || '',
|
|
79
|
+
rule: f.rule || '',
|
|
80
|
+
program: f.program || '',
|
|
81
|
+
path: f.path || '',
|
|
82
|
+
line: f.line || 0,
|
|
83
|
+
where: f.path ? `${f.path.split('/').pop()}:${f.line ?? ''}` : '',
|
|
84
|
+
cross: !!f.crossProgram,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return out.sort(SORTS[view.sort] || SORTS.severity);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function viewLabel(view) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (view.sort && view.sort !== 'severity') parts.push(`sorted by ${view.sort}`);
|
|
93
|
+
if (view.evidence) parts.push(`evidence ${view.evidence.join(',')}`);
|
|
94
|
+
if (view.find) parts.push(`find ${view.find}`);
|
|
95
|
+
return parts.join(', ');
|
|
96
|
+
}
|
package/lib/tui/run.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import * as core from './app.mjs';
|
|
3
|
+
import { redraw } from './screen.mjs';
|
|
4
|
+
|
|
5
|
+
export function runTui({ report, terminal, keymap = 'ispf', color = true, app = core }) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
let state = app.initialState({ report, keymap, ...terminal.size() });
|
|
8
|
+
let shown = null;
|
|
9
|
+
let finished = false;
|
|
10
|
+
const finish = (err) => {
|
|
11
|
+
if (finished) return;
|
|
12
|
+
finished = true;
|
|
13
|
+
terminal.stop();
|
|
14
|
+
if (err) reject(err); else resolve(state);
|
|
15
|
+
};
|
|
16
|
+
const paint = () => {
|
|
17
|
+
const next = app.view(state);
|
|
18
|
+
terminal.write(redraw(shown, next, { color }));
|
|
19
|
+
shown = next;
|
|
20
|
+
};
|
|
21
|
+
const handle = (event) => {
|
|
22
|
+
if (finished) return;
|
|
23
|
+
try {
|
|
24
|
+
const r = app.update(state, event);
|
|
25
|
+
state = r.state;
|
|
26
|
+
if (r.effects.some((e) => e.type === 'quit')) return finish();
|
|
27
|
+
if (event.type === 'resize') shown = null;
|
|
28
|
+
paint();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
finish(e);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
terminal.onKey((token) => handle({ type: 'key', token }));
|
|
34
|
+
terminal.onResize(({ cols, rows }) => handle({ type: 'resize', cols, rows }));
|
|
35
|
+
terminal.start();
|
|
36
|
+
try { paint(); } catch (e) { finish(e); }
|
|
37
|
+
});
|
|
38
|
+
}
|