@vibefoundry/vibe-shield 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +571 -3
- package/SECURITY.md +110 -0
- package/SKILL.md +124 -0
- package/dist/bin/vibe-shield.d.ts +59 -0
- package/dist/bin/vibe-shield.js +541 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +108 -0
- package/dist/src/init/scaffold.d.ts +122 -0
- package/dist/src/init/scaffold.js +573 -0
- package/dist/src/mcp/server.d.ts +127 -0
- package/dist/src/mcp/server.js +479 -0
- package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
- package/dist/src/mcp-guard/skill-auditor.js +1129 -0
- package/dist/src/reporter/console-reporter.d.ts +58 -0
- package/dist/src/reporter/console-reporter.js +254 -0
- package/dist/src/reporter/formatters.d.ts +38 -0
- package/dist/src/reporter/formatters.js +262 -0
- package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
- package/dist/src/reporter/patch-prompt-writer.js +238 -0
- package/dist/src/scanner/context.d.ts +51 -0
- package/dist/src/scanner/context.js +568 -0
- package/dist/src/scanner/engine.d.ts +130 -0
- package/dist/src/scanner/engine.js +722 -0
- package/dist/src/scanner/gitignore.d.ts +49 -0
- package/dist/src/scanner/gitignore.js +195 -0
- package/dist/src/scanner/lexicon.d.ts +56 -0
- package/dist/src/scanner/lexicon.js +357 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
- package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
- package/dist/src/scanner/rules/client-exposure.js +410 -0
- package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
- package/dist/src/scanner/rules/database-exposure.js +626 -0
- package/dist/src/scanner/rules/helpers.d.ts +31 -0
- package/dist/src/scanner/rules/helpers.js +85 -0
- package/dist/src/scanner/rules/index.d.ts +30 -0
- package/dist/src/scanner/rules/index.js +95 -0
- package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
- package/dist/src/scanner/rules/secret-entropy.js +338 -0
- package/dist/src/scanner/rules/shared.d.ts +100 -0
- package/dist/src/scanner/rules/shared.js +161 -0
- package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
- package/dist/src/scanner/rules/unguarded-routes.js +526 -0
- package/dist/src/scanner/text.d.ts +90 -0
- package/dist/src/scanner/text.js +472 -0
- package/dist/src/scanner/types.d.ts +340 -0
- package/dist/src/scanner/types.js +195 -0
- package/dist/src/secrets/patterns.d.ts +63 -0
- package/dist/src/secrets/patterns.js +392 -0
- package/package.json +94 -4
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: text primitives
|
|
4
|
+
* ------------------------------
|
|
5
|
+
* Zero-dependency helpers shared by every rule. The most important one is
|
|
6
|
+
* `maskComments`: it blanks out comments while preserving the exact character
|
|
7
|
+
* length of the file, so byte offsets (and therefore line/column numbers)
|
|
8
|
+
* stay identical between the raw source and the "code only" view that rules
|
|
9
|
+
* match against. That single trick removes the largest class of false
|
|
10
|
+
* positives (vulnerabilities that only exist inside a comment) without a
|
|
11
|
+
* real parser.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.looksDynamic = exports.nextBrace = exports.maskPythonComments = exports.alternation = exports.escapeRegExp = exports.alphabetSize = exports.splitLines = exports.LineIndex = void 0;
|
|
15
|
+
exports.forEachMatch = forEachMatch;
|
|
16
|
+
exports.collectMatches = collectMatches;
|
|
17
|
+
exports.shannonEntropy = shannonEntropy;
|
|
18
|
+
exports.isPlaceholderSecret = isPlaceholderSecret;
|
|
19
|
+
exports.redactSecret = redactSecret;
|
|
20
|
+
exports.maskCStyleComments = maskCStyleComments;
|
|
21
|
+
exports.maskSqlComments = maskSqlComments;
|
|
22
|
+
exports.maskHashComments = maskHashComments;
|
|
23
|
+
exports.maskComments = maskComments;
|
|
24
|
+
exports.trimSnippet = trimSnippet;
|
|
25
|
+
exports.contextWindow = contextWindow;
|
|
26
|
+
exports.balancedBlock = balancedBlock;
|
|
27
|
+
/** Incremental offset -> line/column resolver. */
|
|
28
|
+
class LineIndex {
|
|
29
|
+
content;
|
|
30
|
+
starts = [0];
|
|
31
|
+
constructor(content) {
|
|
32
|
+
this.content = content;
|
|
33
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
34
|
+
if (content.charCodeAt(i) === 10 /* \n */)
|
|
35
|
+
this.starts.push(i + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
get lineCount() {
|
|
39
|
+
return this.starts.length;
|
|
40
|
+
}
|
|
41
|
+
/** 1-based line and column for a character offset. */
|
|
42
|
+
locate(offset) {
|
|
43
|
+
const clamped = Math.max(0, Math.min(offset, this.content.length));
|
|
44
|
+
let low = 0;
|
|
45
|
+
let high = this.starts.length - 1;
|
|
46
|
+
while (low < high) {
|
|
47
|
+
const mid = (low + high + 1) >> 1;
|
|
48
|
+
const start = this.starts[mid] ?? 0;
|
|
49
|
+
if (start <= clamped)
|
|
50
|
+
low = mid;
|
|
51
|
+
else
|
|
52
|
+
high = mid - 1;
|
|
53
|
+
}
|
|
54
|
+
const lineStart = this.starts[low] ?? 0;
|
|
55
|
+
return { line: low + 1, column: clamped - lineStart + 1 };
|
|
56
|
+
}
|
|
57
|
+
/** Offset of the first character of a 1-based line. */
|
|
58
|
+
lineStart(line) {
|
|
59
|
+
return this.starts[Math.max(0, Math.min(line - 1, this.starts.length - 1))] ?? 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.LineIndex = LineIndex;
|
|
63
|
+
/** Split content into lines without the trailing newline characters. */
|
|
64
|
+
const splitLines = (content) => content.split(/\r?\n/);
|
|
65
|
+
exports.splitLines = splitLines;
|
|
66
|
+
/**
|
|
67
|
+
* Iterate every match of `regex` (which must be global) over `content`.
|
|
68
|
+
* Guards against zero-length infinite loops.
|
|
69
|
+
*/
|
|
70
|
+
function forEachMatch(content, regex, handler) {
|
|
71
|
+
if (!regex.global)
|
|
72
|
+
throw new Error('forEachMatch requires a global RegExp');
|
|
73
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
74
|
+
let match;
|
|
75
|
+
let guard = 0;
|
|
76
|
+
const limit = content.length * 4 + 1000;
|
|
77
|
+
while ((match = re.exec(content)) !== null) {
|
|
78
|
+
handler(match);
|
|
79
|
+
if (match[0].length === 0)
|
|
80
|
+
re.lastIndex += 1;
|
|
81
|
+
guard += 1;
|
|
82
|
+
if (guard > limit)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** All matches of a global regex, as `{ text, offset }`. */
|
|
87
|
+
function collectMatches(content, regex) {
|
|
88
|
+
const out = [];
|
|
89
|
+
forEachMatch(content, regex, (m) => out.push({ text: m[0], offset: m.index, match: m }));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Shannon entropy in bits per character. */
|
|
93
|
+
function shannonEntropy(value) {
|
|
94
|
+
if (!value.length)
|
|
95
|
+
return 0;
|
|
96
|
+
const frequencies = new Map();
|
|
97
|
+
for (const char of value)
|
|
98
|
+
frequencies.set(char, (frequencies.get(char) ?? 0) + 1);
|
|
99
|
+
let entropy = 0;
|
|
100
|
+
for (const count of frequencies.values()) {
|
|
101
|
+
const p = count / value.length;
|
|
102
|
+
entropy -= p * Math.log2(p);
|
|
103
|
+
}
|
|
104
|
+
return entropy;
|
|
105
|
+
}
|
|
106
|
+
/** Size of the character alphabet actually used (hex, base64, mixed-case...). */
|
|
107
|
+
const alphabetSize = (value) => new Set(value).size;
|
|
108
|
+
exports.alphabetSize = alphabetSize;
|
|
109
|
+
const PLACEHOLDER_TOKENS = [
|
|
110
|
+
'your',
|
|
111
|
+
'yours',
|
|
112
|
+
'xxxx',
|
|
113
|
+
'placeholder',
|
|
114
|
+
'example',
|
|
115
|
+
'sample',
|
|
116
|
+
'dummy',
|
|
117
|
+
'fake',
|
|
118
|
+
'changeme',
|
|
119
|
+
'change_me',
|
|
120
|
+
'redacted',
|
|
121
|
+
'replace',
|
|
122
|
+
'insert',
|
|
123
|
+
'todo',
|
|
124
|
+
'fixme',
|
|
125
|
+
'unknown',
|
|
126
|
+
'none',
|
|
127
|
+
'null',
|
|
128
|
+
'undefined',
|
|
129
|
+
'test',
|
|
130
|
+
'testing',
|
|
131
|
+
'vibeshield',
|
|
132
|
+
'notreal',
|
|
133
|
+
'not_real',
|
|
134
|
+
'masked',
|
|
135
|
+
'censored',
|
|
136
|
+
'sanitized',
|
|
137
|
+
'lorem',
|
|
138
|
+
'foobar',
|
|
139
|
+
'foo',
|
|
140
|
+
'bar',
|
|
141
|
+
'baz',
|
|
142
|
+
'hunter2',
|
|
143
|
+
'password123',
|
|
144
|
+
'secret123',
|
|
145
|
+
];
|
|
146
|
+
/**
|
|
147
|
+
* True when a candidate secret is obviously not a real credential: template
|
|
148
|
+
* syntax, environment references, masked output, repeated characters or
|
|
149
|
+
* documentation placeholders.
|
|
150
|
+
*/
|
|
151
|
+
function isPlaceholderSecret(value) {
|
|
152
|
+
const trimmed = value.trim();
|
|
153
|
+
if (trimmed.length < 6)
|
|
154
|
+
return true;
|
|
155
|
+
if (/^[\s'"`<[{(]/.test(trimmed))
|
|
156
|
+
return true;
|
|
157
|
+
if (/\$\{|\{\{|%s|%v|\{\d\}|<[a-z_-]+>|process\.env|import\.meta\.env|os\.environ|ENV\[/.test(trimmed))
|
|
158
|
+
return true;
|
|
159
|
+
if (/[*•·]{2,}|_{4,}|-{4,}|x{5,}|X{5,}|\.{3,}/.test(trimmed))
|
|
160
|
+
return true;
|
|
161
|
+
// A single repeated character (or two alternating) is never a secret.
|
|
162
|
+
if ((0, exports.alphabetSize)(trimmed.replace(/[^A-Za-z0-9]/g, '')) <= 2)
|
|
163
|
+
return true;
|
|
164
|
+
const lower = trimmed.toLowerCase();
|
|
165
|
+
if (PLACEHOLDER_TOKENS.some((token) => lower.includes(token)))
|
|
166
|
+
return true;
|
|
167
|
+
// Sequential runs like 1234567890 or abcdefgh
|
|
168
|
+
if (/(0123456789|9876543210|abcdefgh|abcdefghijklmnopqrstuvwxyz)/.test(lower))
|
|
169
|
+
return true;
|
|
170
|
+
return shannonEntropy(trimmed) < 2;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Mask a credential for terminal/JSON/SARIF output. Keeps a short prefix and
|
|
174
|
+
* the final two characters so a human can identify *which* key leaked without
|
|
175
|
+
* re-publishing the secret in a CI log.
|
|
176
|
+
*/
|
|
177
|
+
function redactSecret(value, keepPrefix = 6) {
|
|
178
|
+
const trimmed = value.trim();
|
|
179
|
+
if (trimmed.length <= keepPrefix + 4) {
|
|
180
|
+
return `${trimmed.slice(0, Math.min(3, trimmed.length))}${'•'.repeat(Math.max(4, trimmed.length - 3))}`;
|
|
181
|
+
}
|
|
182
|
+
const prefix = trimmed.slice(0, keepPrefix);
|
|
183
|
+
const suffix = trimmed.slice(-2);
|
|
184
|
+
const masked = '•'.repeat(Math.min(24, Math.max(6, trimmed.length - keepPrefix - 2)));
|
|
185
|
+
return `${prefix}${masked}${suffix}`;
|
|
186
|
+
}
|
|
187
|
+
/** Escape a string for safe inclusion in a RegExp. */
|
|
188
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
exports.escapeRegExp = escapeRegExp;
|
|
190
|
+
/** Build a case-insensitive "any of these words" regex source fragment. */
|
|
191
|
+
const alternation = (words) => words.map(exports.escapeRegExp).join('|');
|
|
192
|
+
exports.alternation = alternation;
|
|
193
|
+
const pushMasked = (state, source, from, to) => {
|
|
194
|
+
for (let i = from; i < to; i += 1) {
|
|
195
|
+
const char = source[i] ?? '';
|
|
196
|
+
// Preserve newlines so line numbers remain valid.
|
|
197
|
+
state.out.push(char === '\n' || char === '\r' ? char : ' ');
|
|
198
|
+
}
|
|
199
|
+
state.i = to;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Blank out comments for C-like languages (JS/TS/JSX/Java/Go/Rust/C#/PHP-ish),
|
|
203
|
+
* preserving string, template-literal and (best-effort) regex-literal bodies
|
|
204
|
+
* and the overall character length of the input.
|
|
205
|
+
*/
|
|
206
|
+
function maskCStyleComments(source) {
|
|
207
|
+
const state = { out: [], i: 0 };
|
|
208
|
+
let lastSignificant = '';
|
|
209
|
+
const n = source.length;
|
|
210
|
+
while (state.i < n) {
|
|
211
|
+
const i = state.i;
|
|
212
|
+
const char = source[i] ?? '';
|
|
213
|
+
const next = source[i + 1] ?? '';
|
|
214
|
+
if (char === '/' && next === '/') {
|
|
215
|
+
let end = source.indexOf('\n', i);
|
|
216
|
+
if (end === -1)
|
|
217
|
+
end = n;
|
|
218
|
+
pushMasked(state, source, i, end);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (char === '/' && next === '*') {
|
|
222
|
+
const end = source.indexOf('*/', i + 2);
|
|
223
|
+
const stop = end === -1 ? n : end + 2;
|
|
224
|
+
pushMasked(state, source, i, stop);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (char === '"' || char === "'") {
|
|
228
|
+
let j = i + 1;
|
|
229
|
+
while (j < n) {
|
|
230
|
+
const c = source[j];
|
|
231
|
+
if (c === '\\') {
|
|
232
|
+
j += 2;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (c === char || c === '\n')
|
|
236
|
+
break;
|
|
237
|
+
j += 1;
|
|
238
|
+
}
|
|
239
|
+
state.out.push(...source.slice(i, Math.min(j + 1, n)).split(''));
|
|
240
|
+
state.i = Math.min(j + 1, n);
|
|
241
|
+
lastSignificant = char;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (char === '`') {
|
|
245
|
+
let j = i + 1;
|
|
246
|
+
let depth = 0;
|
|
247
|
+
while (j < n) {
|
|
248
|
+
const c = source[j];
|
|
249
|
+
if (c === '\\') {
|
|
250
|
+
j += 2;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (c === '$' && source[j + 1] === '{') {
|
|
254
|
+
depth += 1;
|
|
255
|
+
j += 2;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (depth > 0 && c === '}') {
|
|
259
|
+
depth -= 1;
|
|
260
|
+
j += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (c === '`' && depth === 0)
|
|
264
|
+
break;
|
|
265
|
+
j += 1;
|
|
266
|
+
}
|
|
267
|
+
state.out.push(...source.slice(i, Math.min(j + 1, n)).split(''));
|
|
268
|
+
state.i = Math.min(j + 1, n);
|
|
269
|
+
lastSignificant = '`';
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// Best-effort regex-literal detection: `/` after an operator/keyword is a regex.
|
|
273
|
+
if (char === '/' && /[=(,:[!&|?{};+\-*%~^<>]|^$/.test(lastSignificant) && lastSignificant !== ')') {
|
|
274
|
+
let j = i + 1;
|
|
275
|
+
let inClass = false;
|
|
276
|
+
let closed = false;
|
|
277
|
+
while (j < n) {
|
|
278
|
+
const c = source[j];
|
|
279
|
+
if (c === '\\') {
|
|
280
|
+
j += 2;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (c === '\n')
|
|
284
|
+
break;
|
|
285
|
+
if (c === '[')
|
|
286
|
+
inClass = true;
|
|
287
|
+
else if (c === ']')
|
|
288
|
+
inClass = false;
|
|
289
|
+
else if (c === '/' && !inClass) {
|
|
290
|
+
closed = true;
|
|
291
|
+
j += 1;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
j += 1;
|
|
295
|
+
}
|
|
296
|
+
if (closed) {
|
|
297
|
+
while (j < n && /[a-z]/.test(source[j] ?? ''))
|
|
298
|
+
j += 1;
|
|
299
|
+
state.out.push(...source.slice(i, j).split(''));
|
|
300
|
+
state.i = j;
|
|
301
|
+
lastSignificant = '/';
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
state.out.push(char);
|
|
306
|
+
state.i += 1;
|
|
307
|
+
if (!/\s/.test(char))
|
|
308
|
+
lastSignificant = char;
|
|
309
|
+
}
|
|
310
|
+
return state.out.join('');
|
|
311
|
+
}
|
|
312
|
+
/** Blank out `--` and `/* *\/` comments in SQL / Prisma. */
|
|
313
|
+
function maskSqlComments(source) {
|
|
314
|
+
const state = { out: [], i: 0 };
|
|
315
|
+
const n = source.length;
|
|
316
|
+
while (state.i < n) {
|
|
317
|
+
const i = state.i;
|
|
318
|
+
const char = source[i] ?? '';
|
|
319
|
+
const next = source[i + 1] ?? '';
|
|
320
|
+
if (char === '-' && next === '-') {
|
|
321
|
+
let end = source.indexOf('\n', i);
|
|
322
|
+
if (end === -1)
|
|
323
|
+
end = n;
|
|
324
|
+
pushMasked(state, source, i, end);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (char === '/' && next === '*') {
|
|
328
|
+
const end = source.indexOf('*/', i + 2);
|
|
329
|
+
pushMasked(state, source, i, end === -1 ? n : end + 2);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
333
|
+
let j = i + 1;
|
|
334
|
+
while (j < n) {
|
|
335
|
+
const c = source[j];
|
|
336
|
+
if (c === '\\') {
|
|
337
|
+
j += 2;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (c === char) {
|
|
341
|
+
if (source[j + 1] === char) {
|
|
342
|
+
j += 2;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
j += 1;
|
|
348
|
+
}
|
|
349
|
+
state.out.push(...source.slice(i, Math.min(j + 1, n)).split(''));
|
|
350
|
+
state.i = Math.min(j + 1, n);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
state.out.push(char);
|
|
354
|
+
state.i += 1;
|
|
355
|
+
}
|
|
356
|
+
return state.out.join('');
|
|
357
|
+
}
|
|
358
|
+
/** Blank out `#` comments in shell / YAML / TOML / dotenv / Dockerfile. */
|
|
359
|
+
function maskHashComments(source) {
|
|
360
|
+
const state = { out: [], i: 0 };
|
|
361
|
+
const n = source.length;
|
|
362
|
+
while (state.i < n) {
|
|
363
|
+
const i = state.i;
|
|
364
|
+
const char = source[i] ?? '';
|
|
365
|
+
if (char === '#') {
|
|
366
|
+
let end = source.indexOf('\n', i);
|
|
367
|
+
if (end === -1)
|
|
368
|
+
end = n;
|
|
369
|
+
pushMasked(state, source, i, end);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (char === '"' || char === "'") {
|
|
373
|
+
let j = i + 1;
|
|
374
|
+
while (j < n) {
|
|
375
|
+
const c = source[j];
|
|
376
|
+
if (c === '\\') {
|
|
377
|
+
j += 2;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (c === char || c === '\n')
|
|
381
|
+
break;
|
|
382
|
+
j += 1;
|
|
383
|
+
}
|
|
384
|
+
state.out.push(...source.slice(i, Math.min(j + 1, n)).split(''));
|
|
385
|
+
state.i = Math.min(j + 1, n);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
state.out.push(char);
|
|
389
|
+
state.i += 1;
|
|
390
|
+
}
|
|
391
|
+
return state.out.join('');
|
|
392
|
+
}
|
|
393
|
+
/** Blank out Python/Ruby `#` comments (strings preserved). */
|
|
394
|
+
exports.maskPythonComments = maskHashComments;
|
|
395
|
+
/**
|
|
396
|
+
* Pick the right comment masker for a language. Markdown, HTML and JSON are
|
|
397
|
+
* returned untouched on purpose: hidden instructions inside HTML comments are
|
|
398
|
+
* exactly what the agent-integrity rules need to see.
|
|
399
|
+
*/
|
|
400
|
+
function maskComments(source, language) {
|
|
401
|
+
switch (language) {
|
|
402
|
+
case 'typescript':
|
|
403
|
+
case 'javascript':
|
|
404
|
+
case 'jsx':
|
|
405
|
+
case 'vue':
|
|
406
|
+
case 'svelte':
|
|
407
|
+
case 'java':
|
|
408
|
+
case 'go':
|
|
409
|
+
case 'rust':
|
|
410
|
+
case 'css':
|
|
411
|
+
return maskCStyleComments(source);
|
|
412
|
+
case 'sql':
|
|
413
|
+
case 'prisma':
|
|
414
|
+
return maskSqlComments(source);
|
|
415
|
+
case 'python':
|
|
416
|
+
case 'ruby':
|
|
417
|
+
case 'shell':
|
|
418
|
+
case 'yaml':
|
|
419
|
+
case 'toml':
|
|
420
|
+
case 'dotenv':
|
|
421
|
+
case 'terraform':
|
|
422
|
+
case 'dockerfile':
|
|
423
|
+
return maskHashComments(source);
|
|
424
|
+
case 'php':
|
|
425
|
+
return maskCStyleComments(source);
|
|
426
|
+
default:
|
|
427
|
+
return source;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Indentation-preserving trim used for snippets in reports. */
|
|
431
|
+
function trimSnippet(line, max = 200) {
|
|
432
|
+
const collapsed = line.replace(/\t/g, ' ').trimEnd();
|
|
433
|
+
const trimmed = collapsed.trimStart();
|
|
434
|
+
if (trimmed.length <= max)
|
|
435
|
+
return trimmed;
|
|
436
|
+
return `${trimmed.slice(0, max - 1)}…`;
|
|
437
|
+
}
|
|
438
|
+
/** Join a window of source lines around `line` (1-based) for context blocks. */
|
|
439
|
+
function contextWindow(lines, line, radius = 2) {
|
|
440
|
+
const start = Math.max(0, line - 1 - radius);
|
|
441
|
+
const end = Math.min(lines.length, line + radius);
|
|
442
|
+
return lines.slice(start, end);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Extract a balanced `{...}` / `(...)` block starting at `openIndex`.
|
|
446
|
+
* Returns the slice (inclusive of both braces) or an empty string.
|
|
447
|
+
*/
|
|
448
|
+
function balancedBlock(source, openIndex, open = '{', close = '}') {
|
|
449
|
+
if (source[openIndex] !== open)
|
|
450
|
+
return '';
|
|
451
|
+
let depth = 0;
|
|
452
|
+
for (let i = openIndex; i < source.length; i += 1) {
|
|
453
|
+
const char = source[i];
|
|
454
|
+
if (char === open)
|
|
455
|
+
depth += 1;
|
|
456
|
+
else if (char === close) {
|
|
457
|
+
depth -= 1;
|
|
458
|
+
if (depth === 0)
|
|
459
|
+
return source.slice(openIndex, i + 1);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return source.slice(openIndex, Math.min(source.length, openIndex + 400));
|
|
463
|
+
}
|
|
464
|
+
/** Find the offset of the first `{` at/after `from`. */
|
|
465
|
+
const nextBrace = (source, from) => {
|
|
466
|
+
const index = source.indexOf('{', from);
|
|
467
|
+
return index === -1 ? source.length : index;
|
|
468
|
+
};
|
|
469
|
+
exports.nextBrace = nextBrace;
|
|
470
|
+
/** Naive but effective "is this a dynamic string?" probe for injection rules. */
|
|
471
|
+
const looksDynamic = (fragment) => /\$\{|\+\s*[A-Za-z_$"']|[A-Za-z_$"']\s*\+\s*|%\s*\(|\.format\(|f"|f'|\{\s*\d+\s*\}/.test(fragment);
|
|
472
|
+
exports.looksDynamic = looksDynamic;
|