@portll/cobolwork 0.0.1 → 0.2.76
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 +325 -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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { detectFormat, expandTabs } from '../parser.mjs';
|
|
3
|
+
import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
|
|
4
|
+
import { report } from '../kernel/ruleset.mjs';
|
|
5
|
+
import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
|
|
6
|
+
import { eachWithinMemory } from '../kernel/memory.mjs';
|
|
7
|
+
|
|
8
|
+
// Content a compiler ignores and an editor often hides, but an agent reading the source will see:
|
|
9
|
+
// the sequence area, the identification area past column 72, comment lines, and characters that
|
|
10
|
+
// change how text reads. A modernisation or review agent is the reader these would target.
|
|
11
|
+
//
|
|
12
|
+
// Every rule here was tightened against a measured false positive, each named at its test:
|
|
13
|
+
// licence text running past column 72 in a file that is not really fixed format; a banner of
|
|
14
|
+
// asterisks filling the sequence area; a build note mentioning chmod; a binary file named .cbl.
|
|
15
|
+
|
|
16
|
+
export const HIDDEN_RULES = {
|
|
17
|
+
'hidden-payload-in-identification-area': {
|
|
18
|
+
sev: 'high', evidence: 'tampering', cwe: 'CWE-1427',
|
|
19
|
+
text: 'Columns 73 to 80, which the compiler ignores, carry a payload rather than a change tag',
|
|
20
|
+
impact: 'Text in the columns the compiler ignores is invisible to a casual reader but read by an agent or tool processing the source',
|
|
21
|
+
remedy: 'Remove the content from the identification/columns-73+ area; nothing a program needs belongs there',
|
|
22
|
+
},
|
|
23
|
+
'hidden-payload-in-sequence-area': {
|
|
24
|
+
sev: 'high', evidence: 'tampering', cwe: 'CWE-1427',
|
|
25
|
+
text: 'The sequence area carries varying text rather than line numbers or a change tag',
|
|
26
|
+
impact: 'Text in the sequence-number columns the compiler ignores is invisible to a casual reader but read by an agent or tool',
|
|
27
|
+
remedy: 'Remove the content from the sequence area (columns 1-6); it is not part of the program',
|
|
28
|
+
},
|
|
29
|
+
'agent-directive-in-comment': {
|
|
30
|
+
sev: 'high', evidence: 'tampering', cwe: 'CWE-1427',
|
|
31
|
+
text: 'A comment tells its reader to ignore instructions, or to fetch and run something',
|
|
32
|
+
impact: 'A comment instructs the agent or tool reading the code to ignore instructions or fetch and run something',
|
|
33
|
+
remedy: 'Delete the directive; treat its presence as a tampering incident and check who added it',
|
|
34
|
+
},
|
|
35
|
+
'encoded-blob': {
|
|
36
|
+
sev: 'med', evidence: 'tampering', cwe: 'CWE-506',
|
|
37
|
+
text: 'A long encoded run sits in a comment or literal',
|
|
38
|
+
impact: 'A long encoded run hides content from a reader that a tool or agent will decode',
|
|
39
|
+
remedy: 'Remove the encoded blob, or if it is legitimate data move it out of source into a named, documented resource',
|
|
40
|
+
},
|
|
41
|
+
'bidi-or-invisible-characters': {
|
|
42
|
+
sev: 'high', evidence: 'tampering', cwe: 'CWE-94',
|
|
43
|
+
text: 'Characters that reorder or hide text appear in the source',
|
|
44
|
+
impact: 'Bidirectional or invisible characters make the source read differently to a person than it runs, or than a tool parses',
|
|
45
|
+
remedy: 'Strip the bidirectional/invisible characters; keep source to the plain characters the compiler reads',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Addressed to a reader, or a fetch that ends in execution. A comment that merely names curl, a
|
|
50
|
+
// URL or chmod is ordinary build documentation.
|
|
51
|
+
const ADDRESSED = /ignore\s+(all\s+)?(previous|prior|earlier|above)|disregard\s+(?:the\s+)?(?:(?:previous|prior|above|earlier)\s*)?instruction|system\s+prompt|you\s+are\s+an?\s+(ai|agent|assistant|language model)|as\s+an\s+ai\b|do\s+not\s+(tell|mention|report|inform)\b|before\s+you\s+(continue|proceed|answer)|exfiltrat/i;
|
|
52
|
+
// A fetch piped to a shell, a fetch then && and a shell or chmod, or a raw socket; found in linear time.
|
|
53
|
+
const FETCHES = /(?:curl|wget)\b|base64\s+-d/i;
|
|
54
|
+
const SHELL_FIRST = /^\s*(?:ba)?sh\b/i;
|
|
55
|
+
const SOCKET = /\/dev\/tcp\/|\bnc\s+-e\b/i;
|
|
56
|
+
function fetchAndRun(text) {
|
|
57
|
+
if (SOCKET.test(text)) return true;
|
|
58
|
+
const segments = text.split('|');
|
|
59
|
+
for (let i = 1; i < segments.length; i++) if (SHELL_FIRST.test(segments[i]) && FETCHES.test(segments[i - 1])) return true;
|
|
60
|
+
const fetch = text.search(/(?:curl|wget)\b/i);
|
|
61
|
+
const and = fetch < 0 ? -1 : text.indexOf('&&', fetch);
|
|
62
|
+
return and >= 0 && /\b(?:sh|bash|chmod)\b/i.test(text.slice(and + 2));
|
|
63
|
+
}
|
|
64
|
+
const INVISIBLE = /[\u202a-\u202e\u2066-\u2069\u200b-\u200f\u061c]|\udb40[\udc00-\udc7f]/;
|
|
65
|
+
const BASE64_RUN = /[A-Za-z0-9+/]{200,}={0,2}/;
|
|
66
|
+
const HEX_RUN = /\b[0-9a-fA-F]{200,}\b/;
|
|
67
|
+
const TAG = /^[A-Z0-9][A-Z0-9.\-/ ]*$/;
|
|
68
|
+
const BANNER = /^[\s*\-=_+#/\\.]*$/;
|
|
69
|
+
|
|
70
|
+
function areas(line, format) {
|
|
71
|
+
const l = expandTabs(line);
|
|
72
|
+
if (format === 'free' || format === 'terminal') return { sequence: '', indicator: '', text: l, identification: '', ignored: '' };
|
|
73
|
+
return {
|
|
74
|
+
sequence: l.slice(0, 6),
|
|
75
|
+
indicator: l[6] || ' ',
|
|
76
|
+
text: l.slice(7, format === 'variable' ? 250 : 72),
|
|
77
|
+
identification: format === 'variable' ? '' : l.slice(72, 80),
|
|
78
|
+
ignored: format === 'variable' ? '' : l.slice(72),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A floating comment, from the *> that starts it outside a literal to the end of the line.
|
|
83
|
+
function inlineComment(text) {
|
|
84
|
+
let quote = null;
|
|
85
|
+
for (let i = 0; i < text.length - 1; i++) {
|
|
86
|
+
const c = text[i];
|
|
87
|
+
if (quote) { if (c === quote) quote = null; continue; }
|
|
88
|
+
if (c === '"' || c === "'") quote = c;
|
|
89
|
+
else if (c === '*' && text[i + 1] === '>') return text.slice(i);
|
|
90
|
+
}
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fixed format is claimed, not assumed: a file whose lines simply run past column 72 is free-format
|
|
95
|
+
// text, and its tail is prose rather than a payload.
|
|
96
|
+
function looksFixed(lines) {
|
|
97
|
+
let sequenced = 0;
|
|
98
|
+
let bodied = 0;
|
|
99
|
+
for (const raw of lines.slice(0, 400)) {
|
|
100
|
+
const l = expandTabs(raw);
|
|
101
|
+
if (!l.trim()) continue;
|
|
102
|
+
bodied++;
|
|
103
|
+
if (/^[A-Z\d ]{6}[ */\-Dd$]/.test(l)) sequenced++;
|
|
104
|
+
}
|
|
105
|
+
return bodied > 0 && sequenced / bodied > 0.6;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A change tag repeats down the file and is short. A payload varies line to line and looks like
|
|
109
|
+
// data: no spaces, no sentence punctuation. Prose fails that test, which is what separates a
|
|
110
|
+
// payload from the tail of a long comment in a file that only looks fixed-format.
|
|
111
|
+
const DATA_LIKE = /^[A-Za-z0-9+/=_-]{6,8}$/;
|
|
112
|
+
|
|
113
|
+
function identificationPayload(lines, format) {
|
|
114
|
+
const runs = [];
|
|
115
|
+
let run = [];
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
const id = areas(lines[i], format).identification.trim();
|
|
118
|
+
if (id && DATA_LIKE.test(id) && !TAG.test(id) && !BANNER.test(id)) run.push({ line: i + 1, id });
|
|
119
|
+
else { if (run.length >= 4) runs.push(run); run = []; }
|
|
120
|
+
}
|
|
121
|
+
if (run.length >= 4) runs.push(run);
|
|
122
|
+
return runs.filter(r => new Set(r.map(x => x.id)).size >= Math.max(3, r.length * 0.6));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A payload in the sequence area sits on a line the compiler reads as fixed-format code - a valid
|
|
126
|
+
// indicator in column 7 - and fills all six columns with data. A floating *> comment, JCL or prose
|
|
127
|
+
// pasted into a program, and a file in another language start in column 1 and fail that; so does a
|
|
128
|
+
// sequence number with a short letter prefix, in either case.
|
|
129
|
+
const SEQUENCE_DATA = /^[A-Za-z0-9+/=_-]{6}$/;
|
|
130
|
+
const SEQUENCE_NUMBER = /^[A-Za-z]{0,4}\d{2,6}$/;
|
|
131
|
+
const FIXED_INDICATOR = /^[ */\-Dd$]$/;
|
|
132
|
+
const NOT_CODE = /^(\*>|\/\/|\/\*)/;
|
|
133
|
+
// A directive that makes the rest of the file free format, where columns 1-6 are ordinary text.
|
|
134
|
+
const TO_FREE = />>\s*SOURCE\s+(?:FORMAT\s+)?(?:IS\s+)?FREE\b|SOURCEFORMAT\s*\(?\s*["']?FREE/i;
|
|
135
|
+
|
|
136
|
+
function sequencePayload(lines, format) {
|
|
137
|
+
const rows = [];
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
if (TO_FREE.test(lines[i])) break;
|
|
140
|
+
const a = areas(lines[i], format);
|
|
141
|
+
const s = a.sequence;
|
|
142
|
+
if (!FIXED_INDICATOR.test(a.indicator) || NOT_CODE.test(s)) continue;
|
|
143
|
+
if (!SEQUENCE_DATA.test(s) || SEQUENCE_NUMBER.test(s) || TAG.test(s)) continue;
|
|
144
|
+
rows.push({ line: i + 1, s });
|
|
145
|
+
}
|
|
146
|
+
return new Set(rows.map(r => r.s)).size >= 4 ? rows : [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const looksBinary = (buf) => {
|
|
150
|
+
const n = Math.min(buf.length, 4096);
|
|
151
|
+
if (!n) return false;
|
|
152
|
+
let odd = 0;
|
|
153
|
+
for (let i = 0; i < n; i++) {
|
|
154
|
+
const b = buf[i];
|
|
155
|
+
if (b === 0) return true;
|
|
156
|
+
if (b < 9 || (b > 13 && b < 32)) odd++;
|
|
157
|
+
}
|
|
158
|
+
return odd / n > 0.05;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function scanHidden(root, opts = {}) {
|
|
162
|
+
const tree = treeFor(root, opts);
|
|
163
|
+
const files = tree.list().filter(isSource).filter(inScope(opts));
|
|
164
|
+
const findings = [];
|
|
165
|
+
const stats = { filesScanned: 0, filesUnreadable: 0, filesBinary: 0, filesEbcdic: 0 };
|
|
166
|
+
// This set reads every source file in the tree as raw bytes, which is the widest reading of the
|
|
167
|
+
// nine, so it is the one most worth walking inside the guard.
|
|
168
|
+
const run = eachWithinMemory(files, (f) => {
|
|
169
|
+
let buf;
|
|
170
|
+
try { buf = tree.bytes(f); } catch (e) { noteUnread(stats, tree, f, e); return 0; }
|
|
171
|
+
// An EBCDIC member is decoded before any rule reads it; read as bytes it looks binary, and a
|
|
172
|
+
// payload in it would be skipped along with the file.
|
|
173
|
+
const ebcdic = looksEbcdic(buf);
|
|
174
|
+
if (!ebcdic && looksBinary(buf)) { stats.filesBinary++; return buf.length; }
|
|
175
|
+
if (ebcdic) stats.filesEbcdic++;
|
|
176
|
+
stats.filesScanned++;
|
|
177
|
+
const path = relPath(root, f);
|
|
178
|
+
// A byte-order mark at the very start is an encoding marker, not hidden content.
|
|
179
|
+
const text = ebcdic ? decodeEbcdic(buf) : buf.toString('utf8').replace(/^\ufeff/, '');
|
|
180
|
+
const src = ebcdic ? text : buf.toString('latin1');
|
|
181
|
+
const lines = src.split(/\r?\n/);
|
|
182
|
+
const jcl = isJcl(f);
|
|
183
|
+
const detected = detectFormat(src);
|
|
184
|
+
const format = jcl ? 'free' : detected === 'fixed' && !looksFixed(lines) ? 'free' : detected;
|
|
185
|
+
|
|
186
|
+
for (const run of identificationPayload(lines, format)) {
|
|
187
|
+
findings.push({ rule: 'hidden-payload-in-identification-area', path, line: run[0].line, detail: `${run.length} consecutive lines carry varying data in columns 73 to 80, which the compiler ignores` });
|
|
188
|
+
}
|
|
189
|
+
// Data planted in columns 1-6 is exactly what makes a file stop looking fixed-format, so this
|
|
190
|
+
// test takes the format as detected and judges each line by its indicator instead.
|
|
191
|
+
const seq = sequencePayload(lines, jcl ? 'free' : detected);
|
|
192
|
+
if (seq.length >= 4) findings.push({ rule: 'hidden-payload-in-sequence-area', path, line: seq[0].line, detail: `${seq.length} lines carry varying text in the sequence area, which the compiler ignores` });
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < lines.length; i++) {
|
|
195
|
+
const a = areas(lines[i], format);
|
|
196
|
+
const isComment = a.indicator === '*' || a.indicator === '/' || /^\s*\*>/.test(a.text) || /^\s*\*/.test(lines[i]) || /^\/\/\*/.test(lines[i]);
|
|
197
|
+
// Text no compiler reads: a whole comment line, or an inline comment and anything past column 72.
|
|
198
|
+
const inline = isComment || jcl ? '' : inlineComment(a.text);
|
|
199
|
+
const unread = isComment ? lines[i] : `${inline} ${a.ignored}`;
|
|
200
|
+
if (ADDRESSED.test(unread) || fetchAndRun(unread)) {
|
|
201
|
+
const where = isComment ? 'a comment' : (ADDRESSED.test(a.ignored) || fetchAndRun(a.ignored)) ? 'text past column 72, which the compiler ignores,' : 'an inline comment';
|
|
202
|
+
findings.push({ rule: 'agent-directive-in-comment', path, line: i + 1, detail: ADDRESSED.test(unread) ? `${where} addresses its reader with an instruction about instructions` : `${where} gives a command that fetches and runs something` });
|
|
203
|
+
}
|
|
204
|
+
if (BASE64_RUN.test(lines[i]) || HEX_RUN.test(lines[i])) {
|
|
205
|
+
findings.push({ rule: 'encoded-blob', path, line: i + 1, detail: 'a run of at least 200 encoded characters' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (INVISIBLE.test(text)) {
|
|
209
|
+
const line = text.split(/\r?\n/).findIndex(l => INVISIBLE.test(l)) + 1;
|
|
210
|
+
findings.push({ rule: 'bidi-or-invisible-characters', path, line: line || 1, detail: 'the source contains characters that reorder or hide text' });
|
|
211
|
+
}
|
|
212
|
+
return buf.length;
|
|
213
|
+
}, { label: 'hidden', maxBytes: opts.maxSourceBytes ?? Infinity });
|
|
214
|
+
|
|
215
|
+
return report('hidden', { rules: HIDDEN_RULES, findings, stats, run });
|
|
216
|
+
}
|