@@ -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