Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6e483df

Browse files
authored
Merge pull request #5 from git-stunts/v2.8.0
feat(sanitizer): add per-command flag allowlists for show and log
2 parents 3251539 + 61ab36d commit 6e483df

3 files changed

Lines changed: 239 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@git-stunts/plumbing",
3-
"version": "2.7.0",
3+
"version": "2.7.1",
44
"description": "Robust async, stream-first Git plumbing for Node, Bun, and Deno.",
55
"type": "module",
66
"main": "index.js",

src/domain/services/CommandSanitizer.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export default class CommandSanitizer {
4343
'check-attr',
4444
'init',
4545
'config',
46-
'log'
46+
'log',
47+
'show'
4748
]);
4849

4950
/**
@@ -72,6 +73,81 @@ export default class CommandSanitizer {
7273
'--ext-cmd'
7374
];
7475

76+
/**
77+
* Per-command flag allowlists for commands with restricted flag sets.
78+
* Only flags listed here are permitted for these commands.
79+
* Commands not in this map have no additional restrictions beyond global prohibitions.
80+
* @private
81+
*/
82+
static _COMMAND_FLAG_ALLOWLISTS = {
83+
'show': new Set([
84+
'--format', '--pretty', '-s', '--no-patch', '--quiet', '-q',
85+
'--name-only', '--name-status', '--stat', '--numstat', '--shortstat',
86+
'--oneline', '--abbrev-commit', '--no-abbrev-commit', '--date', '--no-notes'
87+
]),
88+
'log': new Set([
89+
'--format', '--pretty', '-z', '--oneline',
90+
'-n', '--max-count', '-1', '-2', '-3', '-4', '-5', '-10', '-20', '-50', '-100',
91+
'--skip', '--since', '--until', '--after', '--before',
92+
'--author', '--committer', '--grep', '--all-match', '--invert-grep',
93+
'--regexp-ignore-case', '-i', '-E', '-F', '-P',
94+
'--ancestry-path', '--first-parent', '--no-merges', '--merges',
95+
'--reverse', '--date-order', '--author-date-order', '--topo-order',
96+
'--abbrev-commit', '--no-abbrev-commit', '--abbrev', '--date',
97+
'--relative-date', '--parents', '--children', '--left-right',
98+
'--graph', '--decorate', '--no-decorate', '--source',
99+
'--no-walk', '--stdin', '--cherry', '--cherry-pick', '--cherry-mark',
100+
'--boundary', '--simplify-by-decoration'
101+
])
102+
};
103+
104+
/**
105+
* Validates command-specific flags against the allowlist.
106+
* @param {string} command - The git subcommand (e.g., 'show', 'log')
107+
* @param {string[]} args - All arguments including flags
108+
* @param {number} commandIndex - Index of the command in args array
109+
* @throws {ProhibitedFlagError} If a flag is not in the allowlist
110+
* @private
111+
*/
112+
static _validateCommandFlags(command, args, commandIndex) {
113+
const allowlist = CommandSanitizer._COMMAND_FLAG_ALLOWLISTS[command];
114+
if (!allowlist) {
115+
return; // No allowlist = no additional restrictions
116+
}
117+
118+
for (let i = commandIndex + 1; i < args.length; i++) {
119+
const arg = args[i];
120+
121+
// Stop flag validation at end-of-options marker
122+
// Everything after '--' is a pathspec or ref, not a flag
123+
if (arg === '--') {
124+
break;
125+
}
126+
127+
// Skip non-flag arguments (refs, paths, etc.)
128+
if (!arg.startsWith('-')) {
129+
continue;
130+
}
131+
132+
// Handle numeric short forms: -n10, -15 (equivalent to -n 10, -n 15)
133+
// These are valid for commands like 'log' that have -n in their allowlist
134+
if (/^-n?\d+$/.test(arg)) {
135+
if (allowlist.has('-n')) {
136+
continue; // Numeric form allowed if -n is in allowlist
137+
}
138+
}
139+
140+
// Handle --flag=value format: extract just the flag portion
141+
const flagPart = arg.includes('=') ? arg.split('=')[0] : arg;
142+
143+
if (!allowlist.has(flagPart)) {
144+
throw new ProhibitedFlagError(arg, 'CommandSanitizer.sanitize', {
145+
message: `Flag '${flagPart}' is not allowed for '${command}' command`
146+
});
147+
}
148+
}
149+
}
150+
75151
/**
76152
* Dynamically allows a command.
77153
* @param {string} commandName
@@ -158,6 +234,9 @@ export default class CommandSanitizer {
158234
throw new ValidationError(`Prohibited git command detected: ${command}`, 'CommandSanitizer.sanitize', { command });
159235
}
160236

237+
// Validate per-command flag allowlists
238+
CommandSanitizer._validateCommandFlags(command, args, subcommandIndex !== -1 ? subcommandIndex : 0);
239+
161240
let totalLength = 0;
162241
for (const arg of args) {
163242
totalLength += arg.length;

test/domain/services/CommandSanitizer.test.js

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ describe('CommandSanitizer', () => {
1313
expect(() => sanitizer.sanitize(['rev-parse', 'HEAD'])).not.toThrow();
1414
});
1515

16+
it('allows log command for commit history traversal', () => {
17+
expect(() => sanitizer.sanitize(['log', '--format=%H', '-z', 'HEAD'])).not.toThrow();
18+
});
19+
20+
it('allows show command for reading commit messages', () => {
21+
expect(() => sanitizer.sanitize(['show', '--format=%B', '-s', 'HEAD'])).not.toThrow();
22+
});
23+
1624
it('throws ValidationError for unlisted commands', () => {
1725
expect(() => sanitizer.sanitize(['push', 'origin', 'main'])).toThrow(ValidationError);
1826
});
@@ -46,13 +54,161 @@ describe('CommandSanitizer', () => {
4654

4755
it('handles cache eviction', () => {
4856
const smallSanitizer = new CommandSanitizer({ maxCacheSize: 2 });
49-
57+
5058
const args1 = ['rev-parse', 'HEAD'];
5159
const args2 = ['cat-file', '-p', '4b825dc642cb6eb9a060e54bf8d69288fbee4904'];
5260
const args3 = ['ls-tree', 'HEAD'];
53-
61+
5462
smallSanitizer.sanitize(args1);
5563
smallSanitizer.sanitize(args2);
5664
smallSanitizer.sanitize(args3);
5765
});
66+
67+
describe('Per-command flag allowlists', () => {
68+
describe('show command', () => {
69+
it('allows whitelisted flags for show', () => {
70+
expect(() => sanitizer.sanitize(['show', '--format=%B', '-s', 'HEAD'])).not.toThrow();
71+
expect(() => sanitizer.sanitize(['show', '--pretty=oneline', 'HEAD'])).not.toThrow();
72+
expect(() => sanitizer.sanitize(['show', '--no-patch', 'HEAD'])).not.toThrow();
73+
expect(() => sanitizer.sanitize(['show', '--quiet', 'HEAD'])).not.toThrow();
74+
});
75+
76+
it('rejects non-whitelisted flags for show', () => {
77+
expect(() => sanitizer.sanitize(['show', '--diff-filter=A', 'HEAD'])).toThrow(ProhibitedFlagError);
78+
expect(() => sanitizer.sanitize(['show', '--follow', 'HEAD'])).toThrow(ProhibitedFlagError);
79+
expect(() => sanitizer.sanitize(['show', '-p', 'HEAD'])).toThrow(ProhibitedFlagError);
80+
});
81+
82+
it('allows show with no ref (defaults to HEAD)', () => {
83+
expect(() => sanitizer.sanitize(['show'])).not.toThrow();
84+
expect(() => sanitizer.sanitize(['show', '--format=%B'])).not.toThrow();
85+
});
86+
});
87+
88+
describe('log command', () => {
89+
it('allows whitelisted flags for log', () => {
90+
expect(() => sanitizer.sanitize(['log', '--format=%H', '-z', 'HEAD'])).not.toThrow();
91+
expect(() => sanitizer.sanitize(['log', '-n', '10', 'HEAD'])).not.toThrow();
92+
expect(() => sanitizer.sanitize(['log', '--max-count=50', 'HEAD'])).not.toThrow();
93+
expect(() => sanitizer.sanitize(['log', '--oneline', 'HEAD'])).not.toThrow();
94+
expect(() => sanitizer.sanitize(['log', '--first-parent', 'HEAD'])).not.toThrow();
95+
});
96+
97+
it('rejects non-whitelisted flags for log', () => {
98+
expect(() => sanitizer.sanitize(['log', '--diff-filter=M', 'HEAD'])).toThrow(ProhibitedFlagError);
99+
expect(() => sanitizer.sanitize(['log', '--follow', 'HEAD'])).toThrow(ProhibitedFlagError);
100+
expect(() => sanitizer.sanitize(['log', '-p', 'HEAD'])).toThrow(ProhibitedFlagError);
101+
});
102+
103+
it('allows log with no arguments', () => {
104+
expect(() => sanitizer.sanitize(['log'])).not.toThrow();
105+
});
106+
107+
it('allows combined numeric short forms (-n10, -15)', () => {
108+
// -n10 is equivalent to -n 10
109+
expect(() => sanitizer.sanitize(['log', '-n10', 'HEAD'])).not.toThrow();
110+
// -15 is equivalent to -n 15 (git shorthand)
111+
expect(() => sanitizer.sanitize(['log', '-15', 'HEAD'])).not.toThrow();
112+
// Combined with other flags
113+
expect(() => sanitizer.sanitize(['log', '-n5', '--format=%H', 'HEAD'])).not.toThrow();
114+
expect(() => sanitizer.sanitize(['log', '-3', '--oneline'])).not.toThrow();
115+
});
116+
});
117+
118+
describe('other commands have no additional restrictions', () => {
119+
it('allows any flags for rev-parse', () => {
120+
expect(() => sanitizer.sanitize(['rev-parse', '--show-toplevel'])).not.toThrow();
121+
expect(() => sanitizer.sanitize(['rev-parse', '--abbrev-ref', 'HEAD'])).not.toThrow();
122+
});
123+
124+
it('allows any flags for cat-file', () => {
125+
expect(() => sanitizer.sanitize(['cat-file', '-p', 'HEAD'])).not.toThrow();
126+
expect(() => sanitizer.sanitize(['cat-file', '-t', 'HEAD'])).not.toThrow();
127+
});
128+
});
129+
});
130+
131+
describe('Argument injection protection (spawn safety)', () => {
132+
it('safely handles shell metacharacters in flag values', () => {
133+
// spawn() passes args directly - no shell parsing
134+
expect(() => sanitizer.sanitize(['log', '--format=%; rm -rf /', 'HEAD'])).not.toThrow();
135+
expect(() => sanitizer.sanitize(['log', '--author=foo; cat /etc/passwd', 'HEAD'])).not.toThrow();
136+
});
137+
138+
it('safely handles backticks in arguments', () => {
139+
expect(() => sanitizer.sanitize(['log', '--format=`whoami`', 'HEAD'])).not.toThrow();
140+
});
141+
142+
it('safely handles $() command substitution in arguments', () => {
143+
expect(() => sanitizer.sanitize(['log', '--format=$(id)', 'HEAD'])).not.toThrow();
144+
});
145+
146+
it('safely handles pipe characters in arguments', () => {
147+
expect(() => sanitizer.sanitize(['log', '--format=%s | malicious', 'HEAD'])).not.toThrow();
148+
});
149+
150+
it('safely handles newlines in arguments', () => {
151+
expect(() => sanitizer.sanitize(['log', '--format=%s\n%b', 'HEAD'])).not.toThrow();
152+
});
153+
154+
it('safely handles quotes in arguments', () => {
155+
expect(() => sanitizer.sanitize(['log', '--author="John Doe"', 'HEAD'])).not.toThrow();
156+
expect(() => sanitizer.sanitize(['log', "--author='Jane Doe'", 'HEAD'])).not.toThrow();
157+
});
158+
});
159+
160+
describe('NUL-terminated output (log -z)', () => {
161+
it('allows log with -z flag', () => {
162+
expect(() => sanitizer.sanitize(['log', '-z', '--format=%H', 'HEAD'])).not.toThrow();
163+
});
164+
165+
it('allows log with -z and multiple format specifiers', () => {
166+
expect(() => sanitizer.sanitize(['log', '-z', '--format=%H%x00%s%x00%b'])).not.toThrow();
167+
});
168+
169+
it('allows log with -z combined with -n', () => {
170+
expect(() => sanitizer.sanitize(['log', '-z', '-n', '10', 'HEAD'])).not.toThrow();
171+
});
172+
173+
it('allows log with -z and --max-count', () => {
174+
expect(() => sanitizer.sanitize(['log', '-z', '--max-count=50', 'main..HEAD'])).not.toThrow();
175+
});
176+
177+
it('allows log with -z and ancestry path traversal', () => {
178+
expect(() => sanitizer.sanitize(['log', '-z', '--ancestry-path', '--format=%H', 'main..HEAD'])).not.toThrow();
179+
});
180+
181+
it('allows log with -z and first-parent for linear history', () => {
182+
expect(() => sanitizer.sanitize(['log', '-z', '--first-parent', '--format=%H%x00%P', 'HEAD'])).not.toThrow();
183+
});
184+
185+
it('allows log with -z and reverse for chronological order', () => {
186+
expect(() => sanitizer.sanitize(['log', '-z', '--reverse', '--format=%H', 'HEAD~10..HEAD'])).not.toThrow();
187+
});
188+
});
189+
190+
describe('Edge cases', () => {
191+
it('throws for empty array', () => {
192+
expect(() => sanitizer.sanitize([])).toThrow(ValidationError);
193+
});
194+
195+
it('allows commands with only the command name (no flags or refs)', () => {
196+
expect(() => sanitizer.sanitize(['show'])).not.toThrow();
197+
expect(() => sanitizer.sanitize(['log'])).not.toThrow();
198+
expect(() => sanitizer.sanitize(['rev-parse'])).not.toThrow();
199+
});
200+
201+
it('handles flags with = values correctly', () => {
202+
expect(() => sanitizer.sanitize(['log', '--format=%H'])).not.toThrow();
203+
expect(() => sanitizer.sanitize(['log', '--max-count=10'])).not.toThrow();
204+
});
205+
206+
it('stops flag validation at end-of-options marker (--)', () => {
207+
// Args after '--' are pathspecs/refs, not flags - should not be validated
208+
expect(() => sanitizer.sanitize(['show', '--format=%B', '--', '-weird-ref-name'])).not.toThrow();
209+
expect(() => sanitizer.sanitize(['log', '--oneline', '--', '--not-a-flag'])).not.toThrow();
210+
// Disallowed flag BEFORE '--' should still throw
211+
expect(() => sanitizer.sanitize(['show', '-p', '--', 'file.txt'])).toThrow(ProhibitedFlagError);
212+
});
213+
});
58214
});

0 commit comments

Comments
 (0)