@seanmozeik/tripwire 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -6
- package/dist/tripwire-cli.js +1 -1
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +50 -50
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/dispatch.ts +34 -10
- package/src/index.ts +2 -0
- package/src/lib/bash.ts +232 -0
- package/src/lib/config.ts +5 -1
- package/src/rules/config-custom.ts +120 -11
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/dispatch.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { BunRuntime } from '@effect/platform-bun';
|
|
|
19
19
|
import { Cause, Effect, Exit, Schema } from 'effect';
|
|
20
20
|
|
|
21
21
|
import { parseCommand } from './lib/bash';
|
|
22
|
-
import { loadConfig, type Config } from './lib/config';
|
|
22
|
+
import { getDefaultConfig, loadConfig, mergeWithDefaults, type Config } from './lib/config';
|
|
23
23
|
import { type Decision, allow, merge } from './lib/decision';
|
|
24
24
|
import {
|
|
25
25
|
type BashInput,
|
|
@@ -48,9 +48,6 @@ import { pathProtect } from './rules/path-protect';
|
|
|
48
48
|
import { postSecretScrub } from './rules/post-secret-scrub';
|
|
49
49
|
import { readProtect } from './rules/read-protect';
|
|
50
50
|
|
|
51
|
-
const RULE_TIMEOUT_MS = 250;
|
|
52
|
-
const POST_RULE_TIMEOUT_MS = 5000; // Betterleaks subprocess can take longer
|
|
53
|
-
|
|
54
51
|
const readStdin = async (): Promise<string> => {
|
|
55
52
|
const chunks: Buffer[] = [];
|
|
56
53
|
for await (const chunk of process.stdin) {
|
|
@@ -243,6 +240,25 @@ const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Deci
|
|
|
243
240
|
return merge(decisions);
|
|
244
241
|
});
|
|
245
242
|
|
|
243
|
+
const runRulesSync = (rules: readonly Rule[]): Decision => {
|
|
244
|
+
if (rules.length === 0) {
|
|
245
|
+
return allow('no-rules');
|
|
246
|
+
}
|
|
247
|
+
return merge(rules.map((r) => r.fn()));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision => {
|
|
251
|
+
const mergedConfig = mergeWithDefaults(config);
|
|
252
|
+
const tool = normalizeToolName(event.tool_name ?? '');
|
|
253
|
+
if (event.hook_event_name === 'PreToolUse') {
|
|
254
|
+
return runRulesSync(collectPreToolUseRules(tool, event.tool_input, mergedConfig));
|
|
255
|
+
}
|
|
256
|
+
if (event.hook_event_name === 'PostToolUse') {
|
|
257
|
+
return runRulesSync(collectPostToolUseRules(tool, event.tool_response));
|
|
258
|
+
}
|
|
259
|
+
return allow('no-rules');
|
|
260
|
+
};
|
|
261
|
+
|
|
246
262
|
const handleBashAllow = (event: HookEvent, decision: Decision, config: Config): void => {
|
|
247
263
|
// After the gate passes (allow or warn), apply rtk command-rewrite. If
|
|
248
264
|
// Rtk doesn't change the command, fall through to normal allow / warn.
|
|
@@ -302,11 +318,9 @@ const program = Effect.gen(function* () {
|
|
|
302
318
|
return;
|
|
303
319
|
}
|
|
304
320
|
const event = decodeExit.value;
|
|
305
|
-
const tool = normalizeToolName(event.tool_name ?? '');
|
|
306
321
|
|
|
307
322
|
if (event.hook_event_name === 'PreToolUse') {
|
|
308
|
-
const
|
|
309
|
-
const decision = yield* runRules(rules, RULE_TIMEOUT_MS);
|
|
323
|
+
const decision = decide(event, config);
|
|
310
324
|
if (decision.kind === 'deny' || decision.kind === 'ask') {
|
|
311
325
|
writePreToolGate(event.hook_event_name, decision);
|
|
312
326
|
return;
|
|
@@ -316,8 +330,7 @@ const program = Effect.gen(function* () {
|
|
|
316
330
|
}
|
|
317
331
|
|
|
318
332
|
if (event.hook_event_name === 'PostToolUse') {
|
|
319
|
-
const
|
|
320
|
-
const decision = yield* runRules(rules, POST_RULE_TIMEOUT_MS);
|
|
333
|
+
const decision = decide(event, config);
|
|
321
334
|
if (decision.kind === 'deny') {
|
|
322
335
|
writePostToolBlock(decision);
|
|
323
336
|
return;
|
|
@@ -337,4 +350,15 @@ const handled = program.pipe(
|
|
|
337
350
|
}),
|
|
338
351
|
);
|
|
339
352
|
|
|
340
|
-
|
|
353
|
+
if (import.meta.main) {
|
|
354
|
+
BunRuntime.runMain(handled);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export {
|
|
358
|
+
collectPostToolUseRules,
|
|
359
|
+
collectPreToolUseRules,
|
|
360
|
+
decide,
|
|
361
|
+
normalizeToolName,
|
|
362
|
+
runRules,
|
|
363
|
+
runRulesSync,
|
|
364
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,5 @@ export type { Decision } from './lib/decision.ts';
|
|
|
2
2
|
export type { HookEvent } from './lib/event.ts';
|
|
3
3
|
export type { Config } from './lib/config.ts';
|
|
4
4
|
export { allow, deny, ask, warn } from './lib/decision.ts';
|
|
5
|
+
export { decide } from './dispatch.ts';
|
|
6
|
+
export { getDefaultConfig, loadConfig, mergeWithDefaults } from './lib/config.ts';
|
package/src/lib/bash.ts
CHANGED
|
@@ -304,6 +304,220 @@ const extractInnerCommands = (cmd: string): string[] => {
|
|
|
304
304
|
return inner;
|
|
305
305
|
};
|
|
306
306
|
|
|
307
|
+
// ── Exec-flag extraction (fd -x, find -exec, etc.) ───────────────────
|
|
308
|
+
// Tools that take a subcommand on the same arg vector hide that
|
|
309
|
+
// Subcommand from rule analysis. Pull it out, substitute the user-
|
|
310
|
+
// Provided search root into the placeholder(s), and feed the
|
|
311
|
+
// Reconstructed command back through parseCommand so every existing
|
|
312
|
+
// Bash rule (deny / scoped-rm / redirect / etc.) sees it.
|
|
313
|
+
|
|
314
|
+
const HOME_VAR_RE = /^\$\{?HOME\}?(?:\/|$)/;
|
|
315
|
+
|
|
316
|
+
const pathLikeToken = (t: string): boolean => {
|
|
317
|
+
if (t === '' || t === '-') {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
if (t === '/' || t === '~' || t === '.' || t === '..') {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
t.startsWith('/') ||
|
|
325
|
+
t.startsWith('~') ||
|
|
326
|
+
t.startsWith('./') ||
|
|
327
|
+
t.startsWith('../') ||
|
|
328
|
+
HOME_VAR_RE.test(t)
|
|
329
|
+
) {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Rank candidate search roots by how dangerous a `cmd <root>` invocation
|
|
336
|
+
// Would be. Higher wins.
|
|
337
|
+
const pathDangerScore = (t: string): number => {
|
|
338
|
+
if (t === '/') {
|
|
339
|
+
return 100;
|
|
340
|
+
}
|
|
341
|
+
if (t === '~' || HOME_VAR_RE.test(t)) {
|
|
342
|
+
return 90;
|
|
343
|
+
}
|
|
344
|
+
if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(\/|$)/.test(t)) {
|
|
345
|
+
return 80;
|
|
346
|
+
}
|
|
347
|
+
if (t.startsWith('/Users/')) {
|
|
348
|
+
return 70;
|
|
349
|
+
}
|
|
350
|
+
if (t.startsWith('/') || t.startsWith('~')) {
|
|
351
|
+
return 60;
|
|
352
|
+
}
|
|
353
|
+
if (t.startsWith('../')) {
|
|
354
|
+
return 40;
|
|
355
|
+
}
|
|
356
|
+
if (t === '..' || t === '.' || t.startsWith('./')) {
|
|
357
|
+
return 10;
|
|
358
|
+
}
|
|
359
|
+
return 50;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
interface ExecSpec {
|
|
363
|
+
// Flag tokens that introduce a nested command, e.g. `-x` / `-exec`.
|
|
364
|
+
readonly execFlags: ReadonlySet<string>;
|
|
365
|
+
// Placeholder tokens the tool substitutes with each match path.
|
|
366
|
+
readonly placeholders: ReadonlySet<string>;
|
|
367
|
+
// Walk tokens[1..execFlagIdx) and return the most-suspicious search root
|
|
368
|
+
// The tool would feed into placeholders, or `.` if nothing path-shaped
|
|
369
|
+
// Is present.
|
|
370
|
+
readonly pickRoot: (tokens: readonly string[], execFlagIdx: number) => string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Fd's flag layout: flags can appear before or after the pattern/path
|
|
374
|
+
// Positionals, and some flags consume a value (-e ts, -t f, -d 3). We
|
|
375
|
+
// Need to skip those value tokens, otherwise `ts` is misread as a path.
|
|
376
|
+
const FD_VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
377
|
+
'-e',
|
|
378
|
+
'--extension',
|
|
379
|
+
'-t',
|
|
380
|
+
'--type',
|
|
381
|
+
'-E',
|
|
382
|
+
'--exclude',
|
|
383
|
+
'-d',
|
|
384
|
+
'--max-depth',
|
|
385
|
+
'--min-depth',
|
|
386
|
+
'--exact-depth',
|
|
387
|
+
'-c',
|
|
388
|
+
'--color',
|
|
389
|
+
'--changed-within',
|
|
390
|
+
'--changed-before',
|
|
391
|
+
'-S',
|
|
392
|
+
'--size',
|
|
393
|
+
'-o',
|
|
394
|
+
'--owner',
|
|
395
|
+
'-j',
|
|
396
|
+
'--threads',
|
|
397
|
+
'-g',
|
|
398
|
+
'--glob',
|
|
399
|
+
'--format',
|
|
400
|
+
'--max-results',
|
|
401
|
+
'--ignore-file',
|
|
402
|
+
'--search-path',
|
|
403
|
+
'--base-directory',
|
|
404
|
+
'--path-separator',
|
|
405
|
+
'--and',
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
409
|
+
const candidates: string[] = [];
|
|
410
|
+
let i = 1;
|
|
411
|
+
while (i < execFlagIdx) {
|
|
412
|
+
const t = tokens[i]!;
|
|
413
|
+
if (FD_VALUE_FLAGS.has(t)) {
|
|
414
|
+
i += 2;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (t.startsWith('-')) {
|
|
418
|
+
i++;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (pathLikeToken(t)) {
|
|
422
|
+
candidates.push(t);
|
|
423
|
+
}
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
if (candidates.length === 0) {
|
|
427
|
+
return '.';
|
|
428
|
+
}
|
|
429
|
+
candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
|
|
430
|
+
return candidates[0]!;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Find's grammar: PATHs come first, before any flag-shaped token. Once we
|
|
434
|
+
// Hit a `-`-prefixed token (a test predicate or action), no more paths.
|
|
435
|
+
// `find` defaults to cwd if no path is given. We collect everything
|
|
436
|
+
// Path-shaped in the prefix region as candidates.
|
|
437
|
+
const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
438
|
+
const candidates: string[] = [];
|
|
439
|
+
for (let i = 1; i < execFlagIdx; i++) {
|
|
440
|
+
const t = tokens[i]!;
|
|
441
|
+
if (t.startsWith('-')) {
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
if (pathLikeToken(t)) {
|
|
445
|
+
candidates.push(t);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (candidates.length === 0) {
|
|
449
|
+
return '.';
|
|
450
|
+
}
|
|
451
|
+
candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
|
|
452
|
+
return candidates[0]!;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const FD_SPEC: ExecSpec = {
|
|
456
|
+
execFlags: new Set(['-x', '-X', '--exec', '--exec-batch']),
|
|
457
|
+
placeholders: new Set(['{}', '{/}', '{//}', '{.}', '{/.}']),
|
|
458
|
+
pickRoot: pickFdSearchRoot,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const FIND_SPEC: ExecSpec = {
|
|
462
|
+
// `-ok` / `-okdir` prompt interactively per-match, but the executed
|
|
463
|
+
// Command is still constructed from agent-controlled input, so treat
|
|
464
|
+
// It the same as `-exec`.
|
|
465
|
+
execFlags: new Set(['-exec', '-execdir', '-ok', '-okdir']),
|
|
466
|
+
placeholders: new Set(['{}']),
|
|
467
|
+
pickRoot: pickFindSearchRoot,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = {
|
|
471
|
+
fd: FD_SPEC,
|
|
472
|
+
fdfind: FD_SPEC,
|
|
473
|
+
find: FIND_SPEC,
|
|
474
|
+
gfind: FIND_SPEC,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const substitutePlaceholders = (
|
|
478
|
+
tokens: readonly string[],
|
|
479
|
+
spec: ExecSpec,
|
|
480
|
+
root: string,
|
|
481
|
+
): string[] => tokens.map((t) => (spec.placeholders.has(t) ? root : t));
|
|
482
|
+
|
|
483
|
+
const extractExecCommands = (seg: Segment): string[] => {
|
|
484
|
+
const spec = EXEC_SPECS[seg.head];
|
|
485
|
+
if (spec === undefined) {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
const out: string[] = [];
|
|
489
|
+
const tokens = seg.tokens;
|
|
490
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
491
|
+
if (!spec.execFlags.has(tokens[i]!)) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Collect tokens until the exec terminator (`;` or `+`, both shared
|
|
495
|
+
// By fd and find) or end of segment. shell-quote turns `\;` into the
|
|
496
|
+
// Literal string token `;`.
|
|
497
|
+
const inner: string[] = [];
|
|
498
|
+
let j = i + 1;
|
|
499
|
+
while (j < tokens.length) {
|
|
500
|
+
const t = tokens[j]!;
|
|
501
|
+
if (t === ';' || t === '+') {
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
inner.push(t);
|
|
505
|
+
j++;
|
|
506
|
+
}
|
|
507
|
+
if (inner.length === 0) {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const head = inner[0]!;
|
|
511
|
+
if (spec.placeholders.has(head)) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const root = spec.pickRoot(tokens, i);
|
|
515
|
+
out.push(substitutePlaceholders(inner, spec, root).join(' '));
|
|
516
|
+
i = j;
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
};
|
|
520
|
+
|
|
307
521
|
const parseCommand = (cmd: string): Segment[] => {
|
|
308
522
|
let entries: ParseEntry[];
|
|
309
523
|
try {
|
|
@@ -343,6 +557,24 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
343
557
|
}
|
|
344
558
|
}
|
|
345
559
|
|
|
560
|
+
// Tools like `fd -x …` and `find -exec …` carry an inner subcommand
|
|
561
|
+
// On the same arg vector. Without extraction the executed command is
|
|
562
|
+
// Hidden and slips past every rule. Pull it out (with the user's
|
|
563
|
+
// Search root substituted into placeholders) and parse it as its own
|
|
564
|
+
// Segments so bash-deny et al. see it.
|
|
565
|
+
// Snapshot length: we push new segments into `out` from within the
|
|
566
|
+
// Loop, but should only scan the segments that existed pre-extraction
|
|
567
|
+
// To avoid re-processing extracted ones.
|
|
568
|
+
const preExtractLen = out.length;
|
|
569
|
+
for (let k = 0; k < preExtractLen; k++) {
|
|
570
|
+
const seg = out[k]!;
|
|
571
|
+
for (const sub of extractExecCommands(seg)) {
|
|
572
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
573
|
+
out.push(innerSeg);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
346
578
|
return out;
|
|
347
579
|
};
|
|
348
580
|
|
package/src/lib/config.ts
CHANGED
|
@@ -11,6 +11,10 @@ const BlockRuleSchema = Schema.Struct({
|
|
|
11
11
|
pattern: Schema.String,
|
|
12
12
|
message: Schema.String,
|
|
13
13
|
action: Schema.optional(Schema.Union([Schema.Literal('deny'), Schema.Literal('ask')])),
|
|
14
|
+
requiresFlags: Schema.optional(Schema.Array(Schema.String)),
|
|
15
|
+
forbidsFlagValues: Schema.optional(
|
|
16
|
+
Schema.Array(Schema.Struct({ flag: Schema.String, values: Schema.Array(Schema.String) })),
|
|
17
|
+
),
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
const RtkConfigSchema = Schema.Struct({
|
|
@@ -103,4 +107,4 @@ export type GitConfig = typeof GitConfigSchema.Type;
|
|
|
103
107
|
export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
|
|
104
108
|
export type Config = typeof ConfigSchema.Type;
|
|
105
109
|
|
|
106
|
-
export { CONFIG_PATH, ConfigSchema };
|
|
110
|
+
export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };
|
|
@@ -1,51 +1,160 @@
|
|
|
1
1
|
// Config-based custom blocking/allowing rules.
|
|
2
2
|
// Uses shell parsing utilities to match command patterns from config.
|
|
3
3
|
|
|
4
|
-
import { parseCommand, type Segment } from '../lib/bash';
|
|
4
|
+
import { hasBypass, parseCommand, type Segment } from '../lib/bash';
|
|
5
5
|
import type { BlockRule } from '../lib/config';
|
|
6
6
|
import { type Decision, allow, deny, ask } from '../lib/decision';
|
|
7
7
|
|
|
8
|
+
const BYPASS_HELP = 'If this is intentional, append ` # tripwire-allow: <reason>` to the command.';
|
|
9
|
+
|
|
10
|
+
const ALIASES: ReadonlyMap<string, string> = new Map([
|
|
11
|
+
['add', 'create'],
|
|
12
|
+
['new', 'create'],
|
|
13
|
+
['edit', 'update'],
|
|
14
|
+
['set', 'update'],
|
|
15
|
+
['rm', 'delete'],
|
|
16
|
+
['del', 'delete'],
|
|
17
|
+
['remove', 'delete'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const canonical = (token: string): string => ALIASES.get(token) ?? token;
|
|
21
|
+
|
|
22
|
+
// Strip directory prefix so an absolute or homebrew-style path matches its
|
|
23
|
+
// Basename — `/opt/homebrew/bin/gog` and `gog` are the same command for
|
|
24
|
+
// Policy purposes. shim's typed dispatcher resolves CLIs to absolute paths,
|
|
25
|
+
// So matchers that compare `seg.head` literally would otherwise miss every
|
|
26
|
+
// Rule for those invocations.
|
|
27
|
+
const basename = (token: string): string => {
|
|
28
|
+
const idx = token.lastIndexOf('/');
|
|
29
|
+
return idx === -1 ? token : token.slice(idx + 1);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const flagPresent = (tokens: readonly string[], flag: string): boolean =>
|
|
33
|
+
tokens.some((t) => t === flag || t.startsWith(`${flag}=`));
|
|
34
|
+
|
|
35
|
+
const flagValue = (tokens: readonly string[], flag: string): string | null => {
|
|
36
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
37
|
+
const t = tokens[i]!;
|
|
38
|
+
if (t === flag) {
|
|
39
|
+
return tokens[i + 1] ?? '';
|
|
40
|
+
}
|
|
41
|
+
if (t.startsWith(`${flag}=`)) {
|
|
42
|
+
return t.slice(flag.length + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const subcommandTokens = (seg: Segment): string[] => {
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
const tokens = seg.tokens.slice(1);
|
|
51
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
52
|
+
const t = tokens[i]!;
|
|
53
|
+
if (t.startsWith('-')) {
|
|
54
|
+
// Without per-CLI flag metadata, we conservatively treat
|
|
55
|
+
// `--flag value` / `-f value` as one option pair and `--flag=value`
|
|
56
|
+
// As one token. This keeps global selectors like `--account X`
|
|
57
|
+
// Out of the subcommand path, at the cost of not distinguishing
|
|
58
|
+
// Boolean flags that precede positional args.
|
|
59
|
+
if (!t.includes('=') && tokens[i + 1] !== undefined && !tokens[i + 1]!.startsWith('-')) {
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
out.push(t);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
};
|
|
68
|
+
|
|
8
69
|
// Match a pattern against parsed segments using shell parsing.
|
|
9
70
|
// This is more powerful than simple regex because it uses the same
|
|
10
71
|
// Parsing logic as the rest of tripwire.
|
|
11
|
-
const matchPattern = (segments: readonly Segment[],
|
|
72
|
+
const matchPattern = (segments: readonly Segment[], rule: BlockRule): boolean => {
|
|
73
|
+
const pattern = rule.pattern;
|
|
12
74
|
const patternSegs = parseCommand(pattern);
|
|
13
75
|
if (patternSegs.length === 0) {
|
|
14
76
|
return false;
|
|
15
77
|
}
|
|
16
78
|
|
|
17
|
-
const
|
|
79
|
+
const patternTokens = patternSegs[0]!.tokens;
|
|
80
|
+
const patternHead = patternTokens[0];
|
|
81
|
+
if (patternHead === undefined) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const patternSubcommands = patternTokens.slice(1);
|
|
18
85
|
|
|
19
|
-
// Simple head match for now - can be extended to match flags, args, etc.
|
|
20
86
|
for (const seg of segments) {
|
|
21
|
-
if (seg.head
|
|
87
|
+
if (basename(seg.head) !== basename(patternHead)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (patternSubcommands.length > 0) {
|
|
92
|
+
const actualSubcommands = subcommandTokens(seg);
|
|
93
|
+
const pathMatches = patternSubcommands.every(
|
|
94
|
+
(p, i) =>
|
|
95
|
+
actualSubcommands[i] !== undefined && canonical(actualSubcommands[i]) === canonical(p),
|
|
96
|
+
);
|
|
97
|
+
if (!pathMatches) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if ((rule.requiresFlags ?? []).some((flag) => !flagPresent(seg.tokens, flag))) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const valueChecks = rule.forbidsFlagValues ?? [];
|
|
107
|
+
const valuesMatch = valueChecks.every((check) => {
|
|
108
|
+
const value = flagValue(seg.tokens, check.flag);
|
|
109
|
+
return value !== null && check.values.includes(value);
|
|
110
|
+
});
|
|
111
|
+
if (!valuesMatch) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
patternSubcommands.length === 0 &&
|
|
117
|
+
rule.requiresFlags === undefined &&
|
|
118
|
+
rule.forbidsFlagValues === undefined
|
|
119
|
+
) {
|
|
22
120
|
return true;
|
|
23
121
|
}
|
|
122
|
+
|
|
123
|
+
return true;
|
|
24
124
|
}
|
|
25
125
|
return false;
|
|
26
126
|
};
|
|
27
127
|
|
|
28
128
|
export const configCustom = (
|
|
29
129
|
segments: readonly Segment[],
|
|
30
|
-
|
|
130
|
+
cmd: string,
|
|
31
131
|
blockedCommands: readonly BlockRule[],
|
|
32
132
|
allowedCommands: readonly BlockRule[],
|
|
33
133
|
): Decision => {
|
|
134
|
+
if (hasBypass(cmd)) {
|
|
135
|
+
return allow('config-custom');
|
|
136
|
+
}
|
|
137
|
+
|
|
34
138
|
// Check allowed first (overrides blocks)
|
|
35
139
|
for (const allowRule of allowedCommands) {
|
|
36
|
-
if (matchPattern(segments, allowRule
|
|
140
|
+
if (matchPattern(segments, allowRule)) {
|
|
37
141
|
return allow('config-custom');
|
|
38
142
|
}
|
|
39
143
|
}
|
|
40
144
|
|
|
41
145
|
// Then check blocked
|
|
42
146
|
for (const blockRule of blockedCommands) {
|
|
43
|
-
if (matchPattern(segments, blockRule
|
|
44
|
-
|
|
45
|
-
?
|
|
46
|
-
:
|
|
147
|
+
if (matchPattern(segments, blockRule)) {
|
|
148
|
+
const message = blockRule.message.includes('tripwire-allow')
|
|
149
|
+
? blockRule.message
|
|
150
|
+
: `${blockRule.message} ${BYPASS_HELP}`;
|
|
151
|
+
return blockRule.action === 'ask'
|
|
152
|
+
? ask('config-custom', message)
|
|
153
|
+
: deny('config-custom', message);
|
|
47
154
|
}
|
|
48
155
|
}
|
|
49
156
|
|
|
50
157
|
return allow('config-custom');
|
|
51
158
|
};
|
|
159
|
+
|
|
160
|
+
export { matchPattern };
|