@seanmozeik/tripwire 0.6.7 → 0.7.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 +152 -141
- package/dist/tripwire +0 -0
- package/dist/tripwire-pi.js +4 -0
- package/package.json +50 -22
- package/scripts/tripwire-cli +12 -0
- package/src/cli.ts +64 -57
- package/src/dispatch.ts +322 -114
- package/src/index.ts +1 -1
- package/src/lib/bash.ts +106 -62
- package/src/lib/config.ts +93 -46
- package/src/lib/cursor.ts +336 -0
- package/src/lib/diff.ts +5 -2
- package/src/lib/event.ts +20 -21
- package/src/lib/install.ts +508 -136
- package/src/lib/log.ts +2 -3
- package/src/lib/secrets.ts +151 -88
- package/src/main.ts +31 -0
- package/src/pi-extension.ts +337 -0
- package/src/rules/bash-deny.ts +13 -3
- package/src/rules/bash-git.ts +32 -45
- package/src/rules/bash-network-install.ts +6 -3
- package/src/rules/bash-redirect.ts +5 -5
- package/src/rules/bash-scoped-rm.ts +1 -1
- package/src/rules/bash-tar-explosion.ts +16 -15
- package/src/rules/config-custom.ts +21 -15
- package/src/rules/lazy-code.ts +1 -1
- package/src/rules/path-protect.ts +73 -10
- package/src/rules/post-secret-scrub.ts +17 -6
- package/src/rules/read-protect.ts +5 -15
- package/src/rules/tool-policy.ts +54 -0
- package/dist/tripwire-cli.js +0 -11
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +0 -96
- package/dist/tripwire.js.jsc +0 -0
- package/src/rules/bash-tool-policy.ts +0 -146
package/src/lib/bash.ts
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
// Shell operators (`;`, `&&`, `||`, `|`, `&`, newline). Each segment
|
|
8
8
|
// Records its head token, positional args, flags, and redirect targets.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// - Glob expansion is not performed.
|
|
10
|
+
// Parsing notes:
|
|
11
|
+
// - Shell variables stay literal. Path rules handle known home references.
|
|
12
|
+
// - Command substitutions are inspected as nested commands. The outer
|
|
13
|
+
// command keeps an opaque marker for safe path classification.
|
|
14
|
+
// - Glob entries are expanded with Bun.Glob before path classification.
|
|
16
15
|
|
|
17
16
|
import { parse, quote, type ParseEntry } from 'shell-quote';
|
|
18
17
|
|
|
@@ -181,7 +180,7 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
|
|
|
181
180
|
const last = tokens.at(-1);
|
|
182
181
|
if (last !== undefined && /^[0-9]+$/.test(last) && fdBudget.remaining > 0) {
|
|
183
182
|
tokens.pop();
|
|
184
|
-
fdBudget.remaining
|
|
183
|
+
fdBudget.remaining -= 1;
|
|
185
184
|
}
|
|
186
185
|
const target = entries[i + 1];
|
|
187
186
|
if (target !== undefined && isStringToken(target)) {
|
|
@@ -189,19 +188,19 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
|
|
|
189
188
|
i += 2;
|
|
190
189
|
continue;
|
|
191
190
|
}
|
|
192
|
-
i
|
|
191
|
+
i += 1;
|
|
193
192
|
continue;
|
|
194
193
|
}
|
|
195
194
|
for (const t of entryToTokens(e)) {
|
|
196
195
|
tokens.push(t);
|
|
197
196
|
}
|
|
198
|
-
i
|
|
197
|
+
i += 1;
|
|
199
198
|
}
|
|
200
199
|
|
|
201
200
|
if (tokens.length === 0) {
|
|
202
201
|
return null;
|
|
203
202
|
}
|
|
204
|
-
for (let j = 1; j < tokens.length; j
|
|
203
|
+
for (let j = 1; j < tokens.length; j += 1) {
|
|
205
204
|
const t = tokens[j]!;
|
|
206
205
|
if (t.startsWith('-') && t !== '-') {
|
|
207
206
|
flags.push(t);
|
|
@@ -209,13 +208,9 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
|
|
|
209
208
|
args.push(t);
|
|
210
209
|
}
|
|
211
210
|
}
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// This fixes the containment hole where `/bin/rm <unsafe>` bypassed every
|
|
216
|
-
// Rule that compared `seg.head === 'rm'`. The `tokens` array is left
|
|
217
|
-
// Unchanged (raw reconstruction stays accurate); only the canonical `head`
|
|
218
|
-
// Used for matching is normalised.
|
|
211
|
+
// Use the executable basename for command rules. Keep the original tokens
|
|
212
|
+
// so an absolute or relative command path cannot bypass a rule and raw
|
|
213
|
+
// reconstruction stays accurate.
|
|
219
214
|
const rawHead = tokens[0]!;
|
|
220
215
|
const slashIdx = rawHead.lastIndexOf('/');
|
|
221
216
|
const head = slashIdx === -1 ? rawHead : rawHead.slice(slashIdx + 1);
|
|
@@ -233,13 +228,13 @@ const PRESERVE_ENV = (key: string): string => `$${key}`;
|
|
|
233
228
|
// `&>` / `&>>` before segment splitting.
|
|
234
229
|
const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
|
|
235
230
|
const out: ParseEntry[] = [];
|
|
236
|
-
for (let i = 0; i < entries.length; i
|
|
231
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
237
232
|
const e = entries[i]!;
|
|
238
233
|
const next = entries[i + 1];
|
|
239
234
|
if (getOp(e) === '&' && next !== undefined && (getOp(next) === '>' || getOp(next) === '>>')) {
|
|
240
235
|
const merged = { op: getOp(next) === '>' ? '&>' : '&>>' } as unknown as ParseEntry;
|
|
241
236
|
out.push(merged);
|
|
242
|
-
i
|
|
237
|
+
i += 1;
|
|
243
238
|
continue;
|
|
244
239
|
}
|
|
245
240
|
// `>|file` is bash's noclobber-override redirect. shell-quote splits
|
|
@@ -249,7 +244,7 @@ const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
|
|
|
249
244
|
// It's a write redirect to the following target).
|
|
250
245
|
if (getOp(e) === '>' && next !== undefined && getOp(next) === '|') {
|
|
251
246
|
out.push({ op: '>' } as unknown as ParseEntry);
|
|
252
|
-
i
|
|
247
|
+
i += 1;
|
|
253
248
|
continue;
|
|
254
249
|
}
|
|
255
250
|
out.push(e);
|
|
@@ -284,20 +279,19 @@ const heredocFeedsShell = (line: string): boolean => SHELL_STDIN_HEAD_RE.test(li
|
|
|
284
279
|
const maskLiteralHeredocBodies = (cmd: string): string => {
|
|
285
280
|
const lines = cmd.split('\n');
|
|
286
281
|
const out: string[] = [];
|
|
287
|
-
for (let i = 0; i < lines.length; i
|
|
282
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
288
283
|
const line = lines[i]!;
|
|
289
284
|
out.push(line);
|
|
290
285
|
const delimiter = heredocDelimiterFromLine(line);
|
|
291
286
|
if (delimiter === null || heredocFeedsShell(line)) {
|
|
292
287
|
continue;
|
|
293
288
|
}
|
|
294
|
-
i
|
|
289
|
+
i += 1;
|
|
295
290
|
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
296
|
-
i
|
|
291
|
+
i += 1;
|
|
297
292
|
}
|
|
298
293
|
if (i < lines.length) {
|
|
299
|
-
out.push('__HEREDOC_BODY__');
|
|
300
|
-
out.push(lines[i]!);
|
|
294
|
+
out.push('__HEREDOC_BODY__', lines[i]!);
|
|
301
295
|
}
|
|
302
296
|
}
|
|
303
297
|
return out.join('\n');
|
|
@@ -313,17 +307,17 @@ const maskLiteralHeredocBodies = (cmd: string): string => {
|
|
|
313
307
|
const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
|
|
314
308
|
const map = new Map<string, string>();
|
|
315
309
|
const lines = cmd.split('\n');
|
|
316
|
-
for (let i = 0; i < lines.length; i
|
|
310
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
317
311
|
const line = lines[i]!;
|
|
318
312
|
const delimiter = heredocDelimiterFromLine(line);
|
|
319
313
|
if (delimiter === null || heredocFeedsShell(line)) {
|
|
320
314
|
continue;
|
|
321
315
|
}
|
|
322
316
|
const body: string[] = [];
|
|
323
|
-
i
|
|
317
|
+
i += 1;
|
|
324
318
|
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
325
319
|
body.push(lines[i]!);
|
|
326
|
-
i
|
|
320
|
+
i += 1;
|
|
327
321
|
}
|
|
328
322
|
if (!map.has(delimiter)) {
|
|
329
323
|
map.set(delimiter, body.join('\n'));
|
|
@@ -335,17 +329,17 @@ const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
|
|
|
335
329
|
const extractShellHeredocCommands = (cmd: string): string[] => {
|
|
336
330
|
const lines = cmd.split('\n');
|
|
337
331
|
const out: string[] = [];
|
|
338
|
-
for (let i = 0; i < lines.length; i
|
|
332
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
339
333
|
const line = lines[i]!;
|
|
340
334
|
const delimiter = heredocDelimiterFromLine(line);
|
|
341
335
|
if (delimiter === null || !heredocFeedsShell(line)) {
|
|
342
336
|
continue;
|
|
343
337
|
}
|
|
344
338
|
const body: string[] = [];
|
|
345
|
-
i
|
|
339
|
+
i += 1;
|
|
346
340
|
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
347
341
|
body.push(lines[i]!);
|
|
348
|
-
i
|
|
342
|
+
i += 1;
|
|
349
343
|
}
|
|
350
344
|
if (body.length > 0) {
|
|
351
345
|
out.push(body.join('\n'));
|
|
@@ -365,10 +359,10 @@ const extractShellHeredocCommands = (cmd: string): string[] => {
|
|
|
365
359
|
// A literal). Process/command substitutions can nest arbitrarily — a depth
|
|
366
360
|
// Counter handles the balanced parens.
|
|
367
361
|
const findBacktickEnd = (cmd: string, start: number): number | null => {
|
|
368
|
-
for (let i = start; i < cmd.length; i
|
|
362
|
+
for (let i = start; i < cmd.length; i += 1) {
|
|
369
363
|
const ch = cmd[i]!;
|
|
370
364
|
if (ch === '\\') {
|
|
371
|
-
i
|
|
365
|
+
i += 1;
|
|
372
366
|
continue;
|
|
373
367
|
}
|
|
374
368
|
if (ch === '`') {
|
|
@@ -381,10 +375,10 @@ const findBacktickEnd = (cmd: string, start: number): number | null => {
|
|
|
381
375
|
const findSubstitutionEnd = (cmd: string, start: number): number | null => {
|
|
382
376
|
let depth = 1;
|
|
383
377
|
let quote: 'single' | 'double' | null = null;
|
|
384
|
-
for (let j = start; j < cmd.length; j
|
|
378
|
+
for (let j = start; j < cmd.length; j += 1) {
|
|
385
379
|
const cj = cmd[j]!;
|
|
386
380
|
if (cj === '\\') {
|
|
387
|
-
j
|
|
381
|
+
j += 1;
|
|
388
382
|
continue;
|
|
389
383
|
}
|
|
390
384
|
if (quote === 'single') {
|
|
@@ -402,11 +396,11 @@ const findSubstitutionEnd = (cmd: string, start: number): number | null => {
|
|
|
402
396
|
continue;
|
|
403
397
|
}
|
|
404
398
|
if (cj === '(') {
|
|
405
|
-
depth
|
|
399
|
+
depth += 1;
|
|
406
400
|
continue;
|
|
407
401
|
}
|
|
408
402
|
if (cj === ')') {
|
|
409
|
-
depth
|
|
403
|
+
depth -= 1;
|
|
410
404
|
if (depth === 0) {
|
|
411
405
|
return j;
|
|
412
406
|
}
|
|
@@ -418,10 +412,10 @@ const findSubstitutionEnd = (cmd: string, start: number): number | null => {
|
|
|
418
412
|
const extractInnerCommands = (cmd: string): string[] => {
|
|
419
413
|
const inner: string[] = [];
|
|
420
414
|
let quote: 'single' | 'double' | null = null;
|
|
421
|
-
for (let i = 0; i < cmd.length; i
|
|
415
|
+
for (let i = 0; i < cmd.length; i += 1) {
|
|
422
416
|
const ch = cmd[i]!;
|
|
423
417
|
if (ch === '\\') {
|
|
424
|
-
i
|
|
418
|
+
i += 1;
|
|
425
419
|
continue;
|
|
426
420
|
}
|
|
427
421
|
if (quote === 'single') {
|
|
@@ -572,13 +566,13 @@ const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): strin
|
|
|
572
566
|
continue;
|
|
573
567
|
}
|
|
574
568
|
if (t.startsWith('-')) {
|
|
575
|
-
i
|
|
569
|
+
i += 1;
|
|
576
570
|
continue;
|
|
577
571
|
}
|
|
578
572
|
if (pathLikeToken(t)) {
|
|
579
573
|
candidates.push(t);
|
|
580
574
|
}
|
|
581
|
-
i
|
|
575
|
+
i += 1;
|
|
582
576
|
}
|
|
583
577
|
if (candidates.length === 0) {
|
|
584
578
|
return '.';
|
|
@@ -593,7 +587,7 @@ const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): strin
|
|
|
593
587
|
// Path-shaped in the prefix region as candidates.
|
|
594
588
|
const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
595
589
|
const candidates: string[] = [];
|
|
596
|
-
for (let i = 1; i < execFlagIdx; i
|
|
590
|
+
for (let i = 1; i < execFlagIdx; i += 1) {
|
|
597
591
|
const t = tokens[i]!;
|
|
598
592
|
if (t.startsWith('-')) {
|
|
599
593
|
break;
|
|
@@ -641,8 +635,8 @@ const extractExecCommands = (seg: Segment): string[] => {
|
|
|
641
635
|
return [];
|
|
642
636
|
}
|
|
643
637
|
const out: string[] = [];
|
|
644
|
-
const tokens = seg
|
|
645
|
-
for (let i = 1; i < tokens.length; i
|
|
638
|
+
const { tokens } = seg;
|
|
639
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
646
640
|
if (!spec.execFlags.has(tokens[i]!)) {
|
|
647
641
|
continue;
|
|
648
642
|
}
|
|
@@ -657,7 +651,7 @@ const extractExecCommands = (seg: Segment): string[] => {
|
|
|
657
651
|
break;
|
|
658
652
|
}
|
|
659
653
|
inner.push(t);
|
|
660
|
-
j
|
|
654
|
+
j += 1;
|
|
661
655
|
}
|
|
662
656
|
if (inner.length === 0) {
|
|
663
657
|
continue;
|
|
@@ -739,8 +733,8 @@ const extractShellWrappedCommands = (seg: Segment): string[] => {
|
|
|
739
733
|
if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
|
|
740
734
|
return [];
|
|
741
735
|
}
|
|
742
|
-
const tokens = seg
|
|
743
|
-
for (let i = 1; i < tokens.length; i
|
|
736
|
+
const { tokens } = seg;
|
|
737
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
744
738
|
const t = tokens[i]!;
|
|
745
739
|
if (t === '-c' && i + 1 < tokens.length) {
|
|
746
740
|
return [tokens[i + 1]!];
|
|
@@ -854,7 +848,7 @@ const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
|
|
|
854
848
|
while (i < tokens.length) {
|
|
855
849
|
const token = tokens[i]!;
|
|
856
850
|
if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
|
|
857
|
-
i
|
|
851
|
+
i += 1;
|
|
858
852
|
continue;
|
|
859
853
|
}
|
|
860
854
|
if (valueFlags.has(token)) {
|
|
@@ -862,15 +856,15 @@ const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
|
|
|
862
856
|
continue;
|
|
863
857
|
}
|
|
864
858
|
if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
|
|
865
|
-
i
|
|
859
|
+
i += 1;
|
|
866
860
|
continue;
|
|
867
861
|
}
|
|
868
862
|
if (token.startsWith('--') && token !== '--') {
|
|
869
|
-
i
|
|
863
|
+
i += 1;
|
|
870
864
|
continue;
|
|
871
865
|
}
|
|
872
866
|
if (token.startsWith('-') && token !== '-') {
|
|
873
|
-
i
|
|
867
|
+
i += 1;
|
|
874
868
|
continue;
|
|
875
869
|
}
|
|
876
870
|
break;
|
|
@@ -883,7 +877,7 @@ const extractHeadRenamingCommands = (seg: Segment): string[] => {
|
|
|
883
877
|
return [];
|
|
884
878
|
}
|
|
885
879
|
if (seg.head === 'script') {
|
|
886
|
-
for (let i = 1; i < seg.tokens.length - 1; i
|
|
880
|
+
for (let i = 1; i < seg.tokens.length - 1; i += 1) {
|
|
887
881
|
const token = seg.tokens[i]!;
|
|
888
882
|
if (token === '-c' || token === '--command') {
|
|
889
883
|
return [seg.tokens[i + 1]!];
|
|
@@ -947,7 +941,7 @@ const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
|
|
|
947
941
|
while (i < tokens.length) {
|
|
948
942
|
const t = tokens[i]!;
|
|
949
943
|
if (t.startsWith('-') && t !== '-' && t !== '--') {
|
|
950
|
-
i
|
|
944
|
+
i += 1;
|
|
951
945
|
continue;
|
|
952
946
|
}
|
|
953
947
|
break;
|
|
@@ -956,7 +950,7 @@ const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
|
|
|
956
950
|
};
|
|
957
951
|
|
|
958
952
|
const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
|
|
959
|
-
for (let k = start; k < tokens.length; k
|
|
953
|
+
for (let k = start; k < tokens.length; k += 1) {
|
|
960
954
|
const t = tokens[k]!;
|
|
961
955
|
if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
|
|
962
956
|
return tokens[k + 1]!;
|
|
@@ -989,11 +983,11 @@ const extractRtkCommands = (seg: Segment): string[] => {
|
|
|
989
983
|
while (j < seg.tokens.length) {
|
|
990
984
|
const t = seg.tokens[j]!;
|
|
991
985
|
if (t === '--') {
|
|
992
|
-
j
|
|
986
|
+
j += 1;
|
|
993
987
|
break;
|
|
994
988
|
}
|
|
995
989
|
if (t.startsWith('-') && t !== '-') {
|
|
996
|
-
j
|
|
990
|
+
j += 1;
|
|
997
991
|
continue;
|
|
998
992
|
}
|
|
999
993
|
break;
|
|
@@ -1063,15 +1057,15 @@ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
|
|
|
1063
1057
|
continue;
|
|
1064
1058
|
}
|
|
1065
1059
|
if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
|
|
1066
|
-
i
|
|
1060
|
+
i += 1;
|
|
1067
1061
|
continue;
|
|
1068
1062
|
}
|
|
1069
1063
|
if (t === '--') {
|
|
1070
|
-
i
|
|
1064
|
+
i += 1;
|
|
1071
1065
|
break;
|
|
1072
1066
|
}
|
|
1073
1067
|
if (t.startsWith('-') && t !== '-') {
|
|
1074
|
-
i
|
|
1068
|
+
i += 1;
|
|
1075
1069
|
continue;
|
|
1076
1070
|
}
|
|
1077
1071
|
break;
|
|
@@ -1081,6 +1075,28 @@ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
|
|
|
1081
1075
|
return inner === '' ? [] : [inner];
|
|
1082
1076
|
};
|
|
1083
1077
|
|
|
1078
|
+
// Shell control keywords keep an executable command on the same token vector.
|
|
1079
|
+
// For example, `then rm -rf /` arrives as one segment headed by `then`, which
|
|
1080
|
+
// hides `rm` from every policy rule. Reparse the tail as a command so nested
|
|
1081
|
+
// conditions and loop bodies pass through the normal rule pipeline.
|
|
1082
|
+
const COMPOUND_COMMAND_HEADS: ReadonlySet<string> = new Set([
|
|
1083
|
+
'!',
|
|
1084
|
+
'if',
|
|
1085
|
+
'elif',
|
|
1086
|
+
'then',
|
|
1087
|
+
'else',
|
|
1088
|
+
'while',
|
|
1089
|
+
'until',
|
|
1090
|
+
'do',
|
|
1091
|
+
]);
|
|
1092
|
+
|
|
1093
|
+
const extractCompoundKeywordCommand = (seg: Segment): string[] => {
|
|
1094
|
+
if (!COMPOUND_COMMAND_HEADS.has(seg.head) || seg.tokens.length < 2) {
|
|
1095
|
+
return [];
|
|
1096
|
+
}
|
|
1097
|
+
return [quote(seg.tokens.slice(1))];
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1084
1100
|
// Each extractor pulls the inner command(s) a wrapper hides on its own arg
|
|
1085
1101
|
// Vector, to be re-parsed as additional segments so every rule sees what
|
|
1086
1102
|
// Actually runs. Order is irrelevant — all results are unioned into `out`.
|
|
@@ -1091,8 +1107,31 @@ const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
|
|
|
1091
1107
|
extractEvalCommands,
|
|
1092
1108
|
extractRtkCommands,
|
|
1093
1109
|
extractPrefixWrapperCommands,
|
|
1110
|
+
extractCompoundKeywordCommand,
|
|
1094
1111
|
];
|
|
1095
1112
|
|
|
1113
|
+
const UNSUPPORTED_SHELL_HEAD = '__tripwire_unsupported_shell__';
|
|
1114
|
+
|
|
1115
|
+
const unsupportedShellSegment = (raw: string): Segment => ({
|
|
1116
|
+
head: UNSUPPORTED_SHELL_HEAD,
|
|
1117
|
+
tokens: [UNSUPPORTED_SHELL_HEAD],
|
|
1118
|
+
args: [],
|
|
1119
|
+
flags: [],
|
|
1120
|
+
redirects: [],
|
|
1121
|
+
raw,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
const containsUnsupportedShellStructure = (segments: readonly Segment[]): boolean =>
|
|
1125
|
+
segments.some((segment) => {
|
|
1126
|
+
if (segment.head === 'case' || segment.head === 'function' || segment.head === '{') {
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
if (segment.tokens.includes('__op_(__') || segment.tokens.includes('__op_)__')) {
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
return COMPOUND_COMMAND_HEADS.has(segment.head) && segment.tokens[1] === '{';
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1096
1135
|
const normalizeTopLevelNewlines = (cmd: string): string => {
|
|
1097
1136
|
let out = '';
|
|
1098
1137
|
let inSingle = false;
|
|
@@ -1131,7 +1170,7 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
1131
1170
|
try {
|
|
1132
1171
|
entries = parse(cmdForParsing, PRESERVE_ENV);
|
|
1133
1172
|
} catch {
|
|
1134
|
-
return [];
|
|
1173
|
+
return [unsupportedShellSegment(cmdForParsing)];
|
|
1135
1174
|
}
|
|
1136
1175
|
entries = mergeAmpRedirects(entries);
|
|
1137
1176
|
const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
|
|
@@ -1155,6 +1194,10 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
1155
1194
|
out.push(seg);
|
|
1156
1195
|
}
|
|
1157
1196
|
|
|
1197
|
+
if (containsUnsupportedShellStructure(out)) {
|
|
1198
|
+
out.push(unsupportedShellSegment(cmdForParsing));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1158
1201
|
// Recursively analyze any embedded commands as additional segments. The
|
|
1159
1202
|
// Outer segment's args are already opaque sentinels (safe-path-failing);
|
|
1160
1203
|
// This catches dangerous inner commands the outer call would otherwise
|
|
@@ -1174,7 +1217,7 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
1174
1217
|
// Loop, but should only scan the segments that existed pre-extraction
|
|
1175
1218
|
// To avoid re-processing extracted ones.
|
|
1176
1219
|
const preExtractLen = out.length;
|
|
1177
|
-
for (let k = 0; k < preExtractLen; k
|
|
1220
|
+
for (let k = 0; k < preExtractLen; k += 1) {
|
|
1178
1221
|
const seg = out[k]!;
|
|
1179
1222
|
for (const extract of SEGMENT_EXTRACTORS) {
|
|
1180
1223
|
for (const sub of extract(seg)) {
|
|
@@ -1270,11 +1313,12 @@ const safeScopesSummary = (
|
|
|
1270
1313
|
// A legitimate bypass marker sits on the actual command line, which the
|
|
1271
1314
|
// Mask leaves intact.
|
|
1272
1315
|
const hasBypass = (cmd: string): boolean =>
|
|
1273
|
-
/(?<prefix>^|\s)#\s*tripwire-allow\
|
|
1316
|
+
/(?<prefix>^|\s)#\s*tripwire-allow:[ \t]*\S[^\r\n]*/.test(maskLiteralHeredocBodies(cmd));
|
|
1274
1317
|
|
|
1275
1318
|
export type { Redirect, Segment };
|
|
1276
1319
|
export {
|
|
1277
1320
|
EXEC_SPECS,
|
|
1321
|
+
UNSUPPORTED_SHELL_HEAD,
|
|
1278
1322
|
collectHeredocBodies,
|
|
1279
1323
|
hasBypass,
|
|
1280
1324
|
isSafePathTarget,
|
package/src/lib/config.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Config system using Effect Schema for validation and Effect for safe loading.
|
|
2
2
|
// Config file: ~/.config/tripwire/config.json
|
|
3
|
-
// Falls back to defaults if file
|
|
3
|
+
// Falls back to defaults only if the file does not exist.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
|
|
8
|
-
import { Cause, Effect, Schema } from 'effect';
|
|
8
|
+
import { Cause, Data, Effect, Schema } from 'effect';
|
|
9
9
|
|
|
10
10
|
const BlockRuleSchema = Schema.Struct({
|
|
11
11
|
pattern: Schema.String,
|
|
@@ -27,73 +27,102 @@ const SafePathsConfigSchema = Schema.Struct({
|
|
|
27
27
|
absolute: Schema.optional(Schema.Array(Schema.String)),
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
const ToolPolicyMatchSchema = Schema.Struct({
|
|
31
|
+
argumentsIncludeAll: Schema.optional(Schema.Array(Schema.String)),
|
|
32
|
+
argumentsStartWith: Schema.optional(Schema.Array(Schema.String)),
|
|
33
|
+
shortFlagsIncludeAll: Schema.optional(Schema.Array(Schema.String)),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const ToolPolicySchema = Schema.Struct({
|
|
37
|
+
rule: Schema.String,
|
|
38
|
+
executables: Schema.Array(Schema.String),
|
|
39
|
+
action: Schema.Union([Schema.Literal('deny'), Schema.Literal('warn')]),
|
|
40
|
+
message: Schema.String,
|
|
41
|
+
match: Schema.optional(ToolPolicyMatchSchema),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const SecretScannerConfigSchema = Schema.Struct({
|
|
45
|
+
executable: Schema.String,
|
|
46
|
+
timeoutMs: Schema.Finite.check(Schema.isGreaterThan(0)),
|
|
47
|
+
});
|
|
48
|
+
|
|
30
49
|
const ConfigSchema = Schema.Struct({
|
|
31
50
|
git: Schema.optional(GitConfigSchema),
|
|
32
51
|
safePaths: Schema.optional(SafePathsConfigSchema),
|
|
52
|
+
toolPolicies: Schema.optional(Schema.Array(ToolPolicySchema)),
|
|
33
53
|
blockedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
|
|
34
54
|
allowedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
|
|
55
|
+
secretScanner: Schema.optional(SecretScannerConfigSchema),
|
|
35
56
|
});
|
|
36
57
|
|
|
37
58
|
const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
|
|
38
59
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
accessSync(path, constants.R_OK);
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
});
|
|
60
|
+
class ConfigReadError extends Data.TaggedError('ConfigReadError')<{ readonly cause: unknown }> {}
|
|
61
|
+
|
|
62
|
+
class ConfigParseError extends Data.TaggedError('ConfigParseError')<{ readonly cause: unknown }> {}
|
|
48
63
|
|
|
49
|
-
const
|
|
50
|
-
|
|
64
|
+
const isMissingFile = (cause: unknown): boolean =>
|
|
65
|
+
cause instanceof Error && 'code' in cause && cause.code === 'ENOENT';
|
|
66
|
+
|
|
67
|
+
const readConfigFile = (path: string): Effect.Effect<string | null, ConfigReadError> =>
|
|
68
|
+
Effect.try({
|
|
69
|
+
try: () => readFileSync(path, 'utf8'),
|
|
70
|
+
catch: (cause) => new ConfigReadError({ cause }),
|
|
71
|
+
}).pipe(
|
|
72
|
+
Effect.catchTag('ConfigReadError', (error) =>
|
|
73
|
+
isMissingFile(error.cause) ? Effect.succeed(null) : Effect.fail(error),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
51
76
|
|
|
52
77
|
const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
|
|
53
|
-
Effect.try({
|
|
78
|
+
Effect.try({
|
|
79
|
+
try: () => JSON.parse(raw) as unknown,
|
|
80
|
+
catch: (cause) => new ConfigParseError({ cause }),
|
|
81
|
+
});
|
|
54
82
|
|
|
55
|
-
//
|
|
56
|
-
// Silently strip them — a typo'd `blockedComands` would vanish unnoticed, the
|
|
57
|
-
// Same silent-policy-drop class this whole change exists to kill). A stray key
|
|
58
|
-
// Now fails loud, e.g. the `rtk` block that triggered MTA-137.
|
|
83
|
+
// Reject unknown keys so a misspelled policy cannot disappear silently.
|
|
59
84
|
const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
|
|
60
85
|
Schema.decodeUnknownEffect(ConfigSchema)(unknown, { onExcessProperty: 'error' });
|
|
61
86
|
|
|
62
|
-
const getDefaultConfig = ():
|
|
63
|
-
git: {
|
|
64
|
-
protectedBranches: ['main', 'master', 'develop', 'production', 'release'],
|
|
65
|
-
enforceConventionalCommits: true,
|
|
66
|
-
},
|
|
87
|
+
const getDefaultConfig = (): ResolvedConfig => ({
|
|
88
|
+
git: { protectedBranches: [], enforceConventionalCommits: false },
|
|
67
89
|
safePaths: {},
|
|
90
|
+
toolPolicies: [],
|
|
68
91
|
blockedCommands: [],
|
|
69
92
|
allowedCommands: [],
|
|
93
|
+
secretScanner: { executable: 'betterleaks', timeoutMs: 5000 },
|
|
70
94
|
});
|
|
71
95
|
|
|
72
|
-
const mergeWithDefaults = (partial: Config):
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
const mergeWithDefaults = (partial: Config): ResolvedConfig => {
|
|
97
|
+
const defaults = getDefaultConfig();
|
|
98
|
+
return {
|
|
99
|
+
git: {
|
|
100
|
+
protectedBranches: partial.git?.protectedBranches ?? defaults.git.protectedBranches,
|
|
101
|
+
enforceConventionalCommits:
|
|
102
|
+
partial.git?.enforceConventionalCommits ?? defaults.git.enforceConventionalCommits,
|
|
103
|
+
},
|
|
104
|
+
safePaths: { ...defaults.safePaths, ...partial.safePaths },
|
|
105
|
+
toolPolicies: partial.toolPolicies ?? defaults.toolPolicies,
|
|
106
|
+
blockedCommands: partial.blockedCommands ?? defaults.blockedCommands,
|
|
107
|
+
allowedCommands: partial.allowedCommands ?? defaults.allowedCommands,
|
|
108
|
+
secretScanner: partial.secretScanner ?? defaults.secretScanner,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// A present but invalid config must not fall back to defaults because that
|
|
113
|
+
// would drop custom safety policy. Only a missing file selects defaults.
|
|
84
114
|
type ConfigLoad =
|
|
85
|
-
| { readonly ok: true; readonly config:
|
|
115
|
+
| { readonly ok: true; readonly config: ResolvedConfig }
|
|
86
116
|
| { readonly ok: false; readonly error: string };
|
|
87
117
|
|
|
88
118
|
export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<ConfigLoad> =>
|
|
89
|
-
Effect.gen(function* () {
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
119
|
+
Effect.gen(function* loadConfigResultEffect() {
|
|
120
|
+
const raw = yield* readConfigFile(path);
|
|
121
|
+
if (raw === null) {
|
|
92
122
|
const result: ConfigLoad = { ok: true, config: getDefaultConfig() };
|
|
93
123
|
return result;
|
|
94
124
|
}
|
|
95
125
|
|
|
96
|
-
const raw = yield* readConfigFile(path);
|
|
97
126
|
const parsed = yield* parseConfigJson(raw);
|
|
98
127
|
const config = yield* decodeConfig(parsed);
|
|
99
128
|
const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
|
|
@@ -106,10 +135,8 @@ export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<Conf
|
|
|
106
135
|
}),
|
|
107
136
|
);
|
|
108
137
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
// Consumer fails closed visibly until the file is fixed.
|
|
112
|
-
export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<Config> =>
|
|
138
|
+
// Library consumers get a loud failure instead of an unconfigured fallback.
|
|
139
|
+
export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<ResolvedConfig> =>
|
|
113
140
|
loadConfigResult(path).pipe(
|
|
114
141
|
Effect.flatMap((result) =>
|
|
115
142
|
result.ok
|
|
@@ -121,7 +148,27 @@ export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<Config> =>
|
|
|
121
148
|
export type BlockRule = typeof BlockRuleSchema.Type;
|
|
122
149
|
export type GitConfig = typeof GitConfigSchema.Type;
|
|
123
150
|
export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
|
|
151
|
+
export type ToolPolicy = typeof ToolPolicySchema.Type;
|
|
152
|
+
export type SecretScannerConfig = typeof SecretScannerConfigSchema.Type;
|
|
124
153
|
export type Config = typeof ConfigSchema.Type;
|
|
125
154
|
|
|
155
|
+
export interface ResolvedConfig {
|
|
156
|
+
readonly git: {
|
|
157
|
+
readonly protectedBranches: readonly string[];
|
|
158
|
+
readonly enforceConventionalCommits: boolean;
|
|
159
|
+
};
|
|
160
|
+
readonly safePaths: SafePathsConfig;
|
|
161
|
+
readonly toolPolicies: readonly ToolPolicy[];
|
|
162
|
+
readonly blockedCommands: readonly BlockRule[];
|
|
163
|
+
readonly allowedCommands: readonly BlockRule[];
|
|
164
|
+
readonly secretScanner: SecretScannerConfig;
|
|
165
|
+
}
|
|
166
|
+
|
|
126
167
|
export type { ConfigLoad };
|
|
127
|
-
export {
|
|
168
|
+
export {
|
|
169
|
+
CONFIG_PATH,
|
|
170
|
+
ConfigSchema,
|
|
171
|
+
SecretScannerConfigSchema,
|
|
172
|
+
getDefaultConfig,
|
|
173
|
+
mergeWithDefaults,
|
|
174
|
+
};
|