-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit_20260619.html
More file actions
761 lines (704 loc) · 261 KB
/
Copy pathgit_20260619.html
File metadata and controls
761 lines (704 loc) · 261 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Code Audit — Interactive</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;font-size:14px;line-height:1.5}
.app{max-width:1100px;margin:0 auto;padding:1.5rem 1rem}
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
h1{font-size:1.5rem;font-weight:700;color:#f1f5f9}
.meta{font-size:.8rem;color:#64748b;margin-top:.2rem}
.top-right{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.project-info{margin-bottom:1rem;padding:.75rem .9rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.project-info .pi-name{font-size:.95rem;font-weight:700;color:#f1f5f9}
.project-info .pi-desc{font-size:.82rem;color:#cbd5e1;margin-top:.25rem}
.project-info .pi-row{display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.45rem;font-size:.78rem;color:#94a3b8}
.project-info .pi-row .pi-k{color:#64748b}
.project-info a{color:#60a5fa;text-decoration:none}
.project-info a:hover{text-decoration:underline}
.project-info code{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.74rem;color:#93c5fd}
.pi-repo-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.18rem .5rem;background:#0f172a;border:1px solid #3b82f6;border-radius:6px;color:#60a5fa !important;font-weight:600}
.pi-repo-btn:hover{background:#1d283a;text-decoration:none !important}
#search{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:.4rem .75rem;border-radius:6px;font-size:.875rem;width:220px;outline:none}
#search:focus{border-color:#3b82f6}
.group-label{font-size:.8rem;color:#94a3b8;display:flex;align-items:center;gap:.4rem}
select{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:.35rem .6rem;border-radius:6px;font-size:.8rem;outline:none;cursor:pointer}
select:focus{border-color:#3b82f6}
.filterbar{display:flex;flex-direction:column;gap:.4rem;margin-bottom:.6rem;padding:.6rem .75rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.filter-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
.filter-lbl{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#475569;white-space:nowrap;margin-right:.25rem}
.filter-toggle{font-size:.65rem;font-weight:600;padding:.18rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#94a3b8;cursor:pointer;margin-left:auto;white-space:nowrap;letter-spacing:.04em}
.filter-toggle:hover{background:#1e293b;color:#e2e8f0;border-color:#475569}
.badge{font-size:.68rem;font-weight:700;padding:.18rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;letter-spacing:.03em;transition:opacity .12s,filter .12s;user-select:none}
.badge.off{opacity:.28;filter:grayscale(.5)}
.sev-critical{background:#450a0a;color:#fca5a5;border-color:#991b1b}
.sev-high {background:#431407;color:#fdba74;border-color:#9a3412}
.sev-medium {background:#422006;color:#fcd34d;border-color:#854d0e}
.sev-low {background:#0c1a3a;color:#93c5fd;border-color:#1d4ed8}
.sev-negligible{background:#1e293b;color:#64748b;border-color:#334155}
.type-badge{background:#0c1e33;color:#7dd3fc;border-color:#0369a1}
.easy-badge{background:#052e16;color:#86efac;border-color:#16a34a}
.st-open{background:#1e293b;color:#475569;border-color:#334155}
.st-fixed{background:#052e16;color:#4ade80;border-color:#16a34a}
.st-wont{background:#431407;color:#fdba74;border-color:#9a3412}
.st-fp{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
#progress-wrap{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem;flex-wrap:wrap}
#progress-bar{flex:1;min-width:120px;height:7px;background:#1e293b;border-radius:4px;overflow:hidden;border:1px solid #334155}
#progress-fill{height:100%;background:#22c55e;border-radius:4px;transition:width .35s ease;width:0}
#progress-text{font-size:.75rem;color:#64748b;white-space:nowrap}
.action-btn{font-size:.72rem;padding:.25rem .6rem;border-radius:5px;border:1px solid #334155;background:#1e293b;color:#94a3b8;cursor:pointer;white-space:nowrap}
.action-btn:hover{background:#334155;color:#e2e8f0}
#hidden-bar{margin-bottom:.6rem}
.hidden-inner{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
.hidden-lbl{font-size:.72rem;color:#475569}
.show-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#1e293b;color:#94a3b8;border:1px solid #334155;cursor:pointer}
.show-btn:hover{background:#334155;color:#e2e8f0}
.show-all-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#2563eb;color:#fff;border:none;cursor:pointer}
.show-all-btn:hover{background:#1d4ed8}
.group-section{margin-bottom:1.5rem}
.group-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;padding-bottom:.35rem;border-bottom:1px solid #334155}
.group-title{font-size:.95rem;font-weight:700;color:#cbd5e1}
.group-title.all-done{color:#4ade80}
.group-count{font-size:.72rem;color:#475569;background:#1e293b;padding:.1rem .4rem;border-radius:10px;white-space:nowrap}
.icon-btn{background:transparent;border:none;color:#334155;cursor:pointer;font-size:.85rem;padding:.05rem .2rem;line-height:1}
.icon-btn:hover{color:#64748b}
.hide-btn{margin-left:auto;font-size:.68rem;padding:.12rem .45rem;border-radius:4px;background:transparent;color:#334155;border:1px solid #1e293b;cursor:pointer}
.hide-btn:hover{background:#1e293b;color:#64748b}
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;margin-bottom:.6rem;overflow:hidden;transition:opacity .15s}
.card.resolved{opacity:.55;border-left:3px solid #16a34a}
.card.resolved:hover{opacity:.85}
.card-header{display:flex;align-items:center;gap:.5rem;padding:.45rem .85rem;background:#162032;border-bottom:1px solid #334155;flex-wrap:wrap;cursor:pointer;user-select:none}
.card-header:hover{background:#1a2840}
.card-arrow{font-size:.65rem;color:#334155;flex-shrink:0}
.card-id{font-size:.75rem;font-weight:700;color:#64748b;font-family:monospace;letter-spacing:.04em}
.card-loc{font-size:.72rem;color:#64748b;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.status-btn{margin-left:auto;font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;white-space:nowrap;transition:filter .1s}
.status-btn:hover{filter:brightness(1.25)}
.copy-btn{font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#94a3b8;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
.copy-btn:hover{background:#334155;color:#e2e8f0}
.card-body{padding:.85rem .9rem}
.field-lbl{font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#7dd3fc;margin:1rem 0 .35rem;padding-bottom:.18rem;border-bottom:1px solid #1e293b}
.field-lbl:first-child{margin-top:0}
.desc,.fix-txt{font-size:.875rem;color:#cbd5e1;line-height:1.55}
.desc p,.fix-txt p{margin:.4rem 0}
.desc p:first-child,.fix-txt p:first-child{margin-top:0}
.desc p:last-child,.fix-txt p:last-child{margin-bottom:0}
.loc-block{margin:.3rem 0 .6rem}
.loc-ref{font-size:.8rem;color:#38bdf8;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-weight:600;margin-bottom:.22rem}
.loc-lines{color:#475569;font-weight:400}
pre{background:#0f172a;color:#cdd6f4;border-radius:5px;padding:.6rem .8rem;overflow-x:auto;white-space:pre;font-size:.8rem;line-height:1.55;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;border:1px solid #1e293b;max-height:320px}
.desc pre.md-code,.fix-txt pre.md-code{margin:.5rem 0;font-size:.82rem;color:#e2e8f0;background:#0b1224;border:1px solid #1e293b;max-height:none}
code.md-inline{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.86em;background:#0b1224;color:#fca5a5;border:1px solid #1e293b;border-radius:3px;padding:.04rem .32rem;white-space:pre-wrap;word-break:break-word}
.not-found{font-size:.8rem;color:#334155;font-style:italic}
mark{background:#854d0e;color:#fef9c3;border-radius:2px;padding:0 .1rem}
.no-results{text-align:center;color:#334155;padding:3rem 1rem;font-size:.9rem}
</style>
</head>
<body>
<script type="application/json" id="D">[{"id":"PANIC_1","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"In `process_ack()` (the protocol-v2 negotiation path), an `ACK <oid>` line from the server is parsed and `lookup_commit(the_repository, common_oid)` is called, then its result is passed straight to `negotiator->ack(negotiator, commit)` with no NULL check. `lookup_commit()` returns NULL when the oid refers to an object that already exists in the local object database as a non-commit type (blob/tree), because `object_as_type(obj, OBJ_COMMIT, 0)` returns NULL in that case (object.c:165-180). Both real negotiator `ack` implementations dereference the commit immediately: `negotiator/default.c` and `negotiator/skipping.c` both do `c->object.flags & COMMON` as their first statement. A malicious or compromised upload-pack server can therefore send `ACK <oid>` for the oid of any non-commit object the client is known to have locally (e.g. a blob/tree from a prior clone), causing a NULL pointer dereference and crash (denial of service). The v0 path in `find_common()` guards exactly this case with `if (!commit) die(\"invalid commit ...\")` (fetch-pack.c:567-568); the v2 path lacks the equivalent guard. Note the sibling caller `negotiate_using_fetch()` does guard with `if (commit)` (fetch-pack.c:2316), which shows the v2 inline call is the inconsistent one.","fix":"In `process_ack()`, only call the negotiator when the lookup yields a real commit, mirroring the v0 path, e.g.:\n commit = lookup_commit(the_repository, common_oid);\n if (negotiator) {\n if (!commit)\n die(_(\"invalid commit %s\"), oid_to_hex(common_oid));\n negotiator->ack(negotiator, commit);\n }\n(or simply `if (negotiator && commit) negotiator->ack(...)` if silently ignoring a non-commit ACK is acceptable).","locations":[{"ref":"fetch-pack.c:1531-1538","code":" 1531 | \t\tif (skip_prefix(reader->line, \"ACK \", &arg)) {\n 1532 | \t\t\tif (!get_oid_hex(arg, common_oid)) {\n 1533 | \t\t\t\tstruct commit *commit;\n 1534 | \t\t\t\tcommit = lookup_commit(the_repository, common_oid);\n 1535 | \t\t\t\tif (negotiator)\n 1536 | \t\t\t\t\tnegotiator->ack(negotiator, commit);\n 1537 | \t\t\t}\n 1538 | \t\t\treturn 1;","lines":8},{"ref":"fetch-pack.c:567-568","code":" 567 | \t\t\t\t\tif (!commit)\n 568 | \t\t\t\t\t\tdie(_(\"invalid commit %s\"), oid_to_hex(result_oid));","lines":2},{"ref":"negotiator/default.c","code":" 1 | #define USE_THE_REPOSITORY_VARIABLE","lines":null},{"ref":"negotiator/skipping.c","code":" 1 | #define USE_THE_REPOSITORY_VARIABLE","lines":null}]},{"id":"PANIC_2","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"The main cache-entry loading loop reads every entry straight out of the mmap'd\nindex with NO per-entry bounds check that the running src_offset stays within\nmmap_size. do_read_index() reads cache_nr = ntohl(hdr->hdr_entries) (an\nattacker-controlled 32-bit count) and allocates that many slots, then\nload_all_cache_entries() -> load_cache_entry_block() loops cache_nr times calling\ncreate_from_disk(mmap + src_offset, ...). create_from_disk() does get_be16 /\nget_be32 / oidread / strlen / memcpy at mmap+src_offset with no check that the\nfixed header bytes, the (v4) varint, or the NUL-terminated name actually lie\nwithin [mmap, mmap+mmap_size). src_offset is only ever advanced by the consumed\nsize returned from create_from_disk; it is never compared against mmap_size.\n\nA truncated or corrupt index (e.g. hdr_entries far larger than the file actually\ncontains, or a final entry whose name runs off the end) therefore drives\nout-of-bounds reads past the end of the mapping. Because the EOIE/IEOT extension\nreaders in this same file DO carefully bound-check against mmap_size, the absence\nof any equivalent guard in the hot entry-parsing path is a real, defensible gap.\nReads landing on the next unmapped page fault (SIGBUS); reads within the final\nmmap page leak adjacent heap/file bytes into the parsed entry.","fix":"Thread mmap_size through load_all_cache_entries / load_cache_entry_block /\ncreate_from_disk and validate before each read: that the fixed ondisk header\n(offsetof(ondisk_cache_entry,data)+hashsz+flag bytes) fits in the remaining\nbytes, that the v4 varint decode stays in-bounds, and that the name's NUL is\nfound before mmap+mmap_size. On any violation, munmap and die(\"index file\ncorrupt\") as the extension readers do.","locations":[{"ref":"read-cache.c:2030-2049","code":" 2030 | static unsigned long load_cache_entry_block(struct index_state *istate,\n 2031 | \t\t\tstruct mem_pool *ce_mem_pool, int offset, int nr, const char *mmap,\n 2032 | \t\t\tunsigned long start_offset, const struct cache_entry *previous_ce)\n 2033 | {\n 2034 | \tint i;\n 2035 | \tunsigned long src_offset = start_offset;\n 2036 | \n 2037 | \tfor (i = offset; i < offset + nr; i++) {\n 2038 | \t\tstruct cache_entry *ce;\n 2039 | \t\tunsigned long consumed;\n 2040 | \n 2041 | \t\tce = create_from_disk(ce_mem_pool, istate->version,\n 2042 | \t\t\t\t mmap + src_offset,\n 2043 | \t\t\t\t &consumed, previous_ce);\n 2044 | \t\tset_index_entry(istate, i, ce);\n 2045 | \n 2046 | \t\tsrc_offset += consumed;\n 2047 | \t\tprevious_ce = ce;\n 2048 | \t}\n 2049 | \treturn src_offset - start_offset;","lines":20},{"ref":"read-cache.c:1781-1880","code":" 1781 | static struct cache_entry *create_from_disk(struct mem_pool *ce_mem_pool,\n 1782 | \t\t\t\t\t unsigned int version,\n 1783 | \t\t\t\t\t const char *ondisk,\n 1784 | \t\t\t\t\t unsigned long *ent_size,\n 1785 | \t\t\t\t\t const struct cache_entry *previous_ce)\n 1786 | {\n 1787 | \tstruct cache_entry *ce;\n 1788 | \tsize_t len;\n 1789 | \tconst char *name;\n 1790 | \tconst unsigned hashsz = the_hash_algo->rawsz;\n 1791 | \tconst char *flagsp = ondisk + offsetof(struct ondisk_cache_entry, data) + hashsz;\n 1792 | \tunsigned int flags;\n 1793 | \tsize_t copy_len = 0;\n 1794 | \t/*\n 1795 | \t * Adjacent cache entries tend to share the leading paths, so it makes\n 1796 | \t * sense to only store the differences in later entries. In the v4\n 1797 | \t * on-disk format of the index, each on-disk cache entry stores the\n 1798 | \t * number of bytes to be stripped from the end of the previous name,\n 1799 | \t * and the bytes to append to the result, to come up with its name.\n 1800 | \t */\n 1801 | \tint expand_name_field = version == 4;\n 1802 | \n 1803 | \t/* On-disk flags are just 16 bits */\n 1804 | \tflags = get_be16(flagsp);\n 1805 | \tlen = flags & CE_NAMEMASK;\n 1806 | \n 1807 | \tif (flags & CE_EXTENDED) {\n 1808 | \t\tint extended_flags;\n 1809 | \t\textended_flags = get_be16(flagsp + sizeof(uint16_t)) << 16;\n 1810 | \t\t/* We do not yet understand any bit out of CE_EXTENDED_FLAGS */\n 1811 | \t\tif (extended_flags & ~CE_EXTENDED_FLAGS)\n 1812 | \t\t\tdie(_(\"unknown index entry format 0x%08x\"), extended_flags);\n 1813 | \t\tflags |= extended_flags;\n 1814 | \t\tname = (const char *)(flagsp + 2 * sizeof(uint16_t));\n 1815 | \t}\n 1816 | \telse\n 1817 | \t\tname = (const char *)(flagsp + sizeof(uint16_t));\n 1818 | \n 1819 | \tif (expand_name_field) {\n 1820 | \t\tconst unsigned char *cp = (const unsigned char *)name;\n 1821 | \t\tuint64_t strip_len, previous_len;\n 1822 | \n 1823 | \t\t/* If we're at the beginning of a block, ignore the previous name */\n 1824 | \t\tstrip_len = decode_varint(&cp);\n 1825 | \t\tif (previous_ce) {\n 1826 | \t\t\tprevious_len = previous_ce->ce_namelen;\n 1827 | \t\t\tif (previous_len < strip_len)\n 1828 | \t\t\t\tdie(_(\"malformed name field in the index, near path '%s'\"),\n 1829 | \t\t\t\t\tprevious_ce->name);\n 1830 | \t\t\tcopy_len = previous_len - strip_len;\n 1831 | \t\t}\n 1832 | \t\tname = (const char *)cp;\n 1833 | \t}\n 1834 | \n 1835 | \tif (len == CE_NAMEMASK) {\n 1836 | \t\tlen = strlen(name);\n 1837 | \t\tif (expand_name_field)\n 1838 | \t\t\tlen += copy_len;\n 1839 | \t}\n 1840 | \n 1841 | \tce = mem_pool__ce_alloc(ce_mem_pool, len);\n 1842 | \n 1843 | \t/*\n 1844 | \t * NEEDSWORK: using 'offsetof()' is cumbersome and should be replaced\n 1845 | \t * with something more akin to 'load_bitmap_entries_v1()'s use of\n 1846 | \t * 'read_be16'/'read_be32'. For consistency with the corresponding\n 1847 | \t * ondisk entry write function ('copy_cache_entry_to_ondisk()'), this\n 1848 | \t * should be done at the same time as removing references to\n 1849 | \t * 'ondisk_cache_entry' there.\n 1850 | \t */\n 1851 | \tce->ce_stat_data.sd_ctime.sec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, ctime)\n 1852 | \t\t\t\t\t\t\t+ offsetof(struct cache_time, sec));\n 1853 | \tce->ce_stat_data.sd_mtime.sec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, mtime)\n 1854 | \t\t\t\t\t\t\t+ offsetof(struct cache_time, sec));\n 1855 | \tce->ce_stat_data.sd_ctime.nsec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, ctime)\n 1856 | \t\t\t\t\t\t\t + offsetof(struct cache_time, nsec));\n 1857 | \tce->ce_stat_data.sd_mtime.nsec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, mtime)\n 1858 | \t\t\t\t\t\t\t + offsetof(struct cache_time, nsec));\n 1859 | \tce->ce_stat_data.sd_dev = get_be32(ondisk + offsetof(struct ondisk_cache_entry, dev));\n 1860 | \tce->ce_stat_data.sd_ino = get_be32(ondisk + offsetof(struct ondisk_cache_entry, ino));\n 1861 | \tce->ce_mode = get_be32(ondisk + offsetof(struct ondisk_cache_entry, mode));\n 1862 | \tce->ce_stat_data.sd_uid = get_be32(ondisk + offsetof(struct ondisk_cache_entry, uid));\n 1863 | \tce->ce_stat_data.sd_gid = get_be32(ondisk + offsetof(struct ondisk_cache_entry, gid));\n 1864 | \tce->ce_stat_data.sd_size = get_be32(ondisk + offsetof(struct ondisk_cache_entry, size));\n 1865 | \tce->ce_flags = flags & ~CE_NAMEMASK;\n 1866 | \tce->ce_namelen = len;\n 1867 | \tce->index = 0;\n 1868 | \toidread(&ce->oid, (const unsigned char *)ondisk + offsetof(struct ondisk_cache_entry, data),\n 1869 | \t\tthe_repository->hash_algo);\n 1870 | \n 1871 | \tif (expand_name_field) {\n 1872 | \t\tif (copy_len)\n 1873 | \t\t\tmemcpy(ce->name, previous_ce->name, copy_len);\n 1874 | \t\tmemcpy(ce->name + copy_len, name, len + 1 - copy_len);\n 1875 | \t\t*ent_size = (name - ((char *)ondisk)) + len + 1 - copy_len;\n 1876 | \t} else {\n 1877 | \t\tmemcpy(ce->name, name, len + 1);\n 1878 | \t\t*ent_size = ondisk_ce_size(ce);\n 1879 | \t}\n 1880 | \treturn ce;","lines":100},{"ref":"read-cache.c:2246-2248","code":" 2246 | \tistate->cache_nr = ntohl(hdr->hdr_entries);\n 2247 | \tistate->cache_alloc = alloc_nr(istate->cache_nr);\n 2248 | \tCALLOC_ARRAY(istate->cache, istate->cache_alloc);","lines":3}]},{"id":"PANIC_3","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"read_ieot_extension() parses the untrusted IEOT extension and trusts both its\nentry count and the per-block offset/nr values without validating them against\nmmap_size or cache_nr. nr is computed as (extsize - 4) / 8 from the on-disk\nextsize; the loop then reads nr*8 bytes via get_be32 starting at `index`, but\nnothing checks that index + nr*8 stays within the mmap (the only loop guard,\noffset <= mmap_size - rawsz - 8, bounds the *search* for the IEOT signature, not\nthe subsequent entry reads). Worse, the resulting entries[].offset and\nentries[].nr are passed to load_cache_entries_threaded ->\nload_cache_entry_block() as the starting byte offset and entry count for each\nthread, again with no bounds check. A crafted IEOT can thus point a thread's\nstart_offset anywhere (OOB read in create_from_disk) and make the per-thread nr\ncounts sum to something inconsistent with cache_nr, corrupting offset bookkeeping\nacross threads. This is only reachable with index threading enabled (default when\nindex.threads != 1), but the values are fully attacker-controlled on disk.","fix":"After computing nr, verify index + nr * 8 <= mmap + mmap_size before the read\nloop. For each entry, validate entries[i].offset is within\n[sizeof(cache_header), mmap_size - rawsz) and that the sum of entries[i].nr\nequals istate->cache_nr; reject (return NULL, falling back to single-threaded\nload) otherwise. Bound the per-thread offsets in load_cache_entry_block as well.","locations":[{"ref":"read-cache.c:3648-3695","code":" 3648 | static struct index_entry_offset_table *read_ieot_extension(const char *mmap, size_t mmap_size, size_t offset)\n 3649 | {\n 3650 | \tconst char *index = NULL;\n 3651 | \tuint32_t extsize, ext_version;\n 3652 | \tstruct index_entry_offset_table *ieot;\n 3653 | \tint i, nr;\n 3654 | \n 3655 | \t/* find the IEOT extension */\n 3656 | \tif (!offset)\n 3657 | \t\treturn NULL;\n 3658 | \twhile (offset <= mmap_size - the_hash_algo->rawsz - 8) {\n 3659 | \t\textsize = get_be32(mmap + offset + 4);\n 3660 | \t\tif (CACHE_EXT((mmap + offset)) == CACHE_EXT_INDEXENTRYOFFSETTABLE) {\n 3661 | \t\t\tindex = mmap + offset + 4 + 4;\n 3662 | \t\t\tbreak;\n 3663 | \t\t}\n 3664 | \t\toffset += 8;\n 3665 | \t\toffset += extsize;\n 3666 | \t}\n 3667 | \tif (!index)\n 3668 | \t\treturn NULL;\n 3669 | \n 3670 | \t/* validate the version is IEOT_VERSION */\n 3671 | \text_version = get_be32(index);\n 3672 | \tif (ext_version != IEOT_VERSION) {\n 3673 | \t\terror(\"invalid IEOT version %d\", ext_version);\n 3674 | \t\treturn NULL;\n 3675 | \t}\n 3676 | \tindex += sizeof(uint32_t);\n 3677 | \n 3678 | \t/* extension size - version bytes / bytes per entry */\n 3679 | \tnr = (extsize - sizeof(uint32_t)) / (sizeof(uint32_t) + sizeof(uint32_t));\n 3680 | \tif (!nr) {\n 3681 | \t\terror(\"invalid number of IEOT entries %d\", nr);\n 3682 | \t\treturn NULL;\n 3683 | \t}\n 3684 | \tieot = xmalloc(sizeof(struct index_entry_offset_table)\n 3685 | \t\t + (nr * sizeof(struct index_entry_offset)));\n 3686 | \tieot->nr = nr;\n 3687 | \tfor (i = 0; i < nr; i++) {\n 3688 | \t\tieot->entries[i].offset = get_be32(index);\n 3689 | \t\tindex += sizeof(uint32_t);\n 3690 | \t\tieot->entries[i].nr = get_be32(index);\n 3691 | \t\tindex += sizeof(uint32_t);\n 3692 | \t}\n 3693 | \n 3694 | \treturn ieot;\n 3695 | }","lines":48},{"ref":"read-cache.c:2097-2108","code":" 2097 | static void *load_cache_entries_thread(void *_data)\n 2098 | {\n 2099 | \tstruct load_cache_entries_thread_data *p = _data;\n 2100 | \tint i;\n 2101 | \n 2102 | \t/* iterate across all ieot blocks assigned to this thread */\n 2103 | \tfor (i = p->ieot_start; i < p->ieot_start + p->ieot_blocks; i++) {\n 2104 | \t\tp->consumed += load_cache_entry_block(p->istate, p->ce_mem_pool,\n 2105 | \t\t\tp->offset, p->ieot->entries[i].nr, p->mmap, p->ieot->entries[i].offset, NULL);\n 2106 | \t\tp->offset += p->ieot->entries[i].nr;\n 2107 | \t}\n 2108 | \treturn NULL;","lines":12},{"ref":"read-cache.c:2030-2049","code":" 2030 | static unsigned long load_cache_entry_block(struct index_state *istate,\n 2031 | \t\t\tstruct mem_pool *ce_mem_pool, int offset, int nr, const char *mmap,\n 2032 | \t\t\tunsigned long start_offset, const struct cache_entry *previous_ce)\n 2033 | {\n 2034 | \tint i;\n 2035 | \tunsigned long src_offset = start_offset;\n 2036 | \n 2037 | \tfor (i = offset; i < offset + nr; i++) {\n 2038 | \t\tstruct cache_entry *ce;\n 2039 | \t\tunsigned long consumed;\n 2040 | \n 2041 | \t\tce = create_from_disk(ce_mem_pool, istate->version,\n 2042 | \t\t\t\t mmap + src_offset,\n 2043 | \t\t\t\t &consumed, previous_ce);\n 2044 | \t\tset_index_entry(istate, i, ce);\n 2045 | \n 2046 | \t\tsrc_offset += consumed;\n 2047 | \t\tprevious_ce = ce;\n 2048 | \t}\n 2049 | \treturn src_offset - start_offset;","lines":20}]},{"id":"PANIC_4","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"Out-of-bounds (buffer underflow) read in files_fsck_symref_target() when the\nreferent strbuf is empty. The function unconditionally reads the last byte of\nthe referent buffer:\n\n orig_len = referent->len;\n orig_last_byte = referent->buf[orig_len - 1];\n\nIf referent->len == 0, then `orig_len - 1` is (size_t)-1 (SIZE_MAX), so\n`referent->buf[SIZE_MAX]` is read - a wild out-of-bounds access.\n\nAn empty referent is reachable during `git fsck` from\nfiles_fsck_refs_content() in two ways (both verified):\n1. Non-symlink loose ref whose content parses as a symref: a ref file\n containing \"ref:\" followed only by whitespace/newline causes\n parse_loose_ref_contents() (files-backend.c:622-629) to skip the \"ref:\"\n prefix and all leading whitespace, then strbuf_addstr(referent, buf) appends\n an empty remainder and sets REF_ISSYMREF, returning 0. The caller then\n invokes files_fsck_symref_target(..., 0) with an empty referent.\n2. Symlink ref case: if strbuf_add_real_path(path) resolves to exactly the\n gitdir prefix, skip_prefix consumes the whole string and\n strbuf_addstr(&referent, relative_referent_path) where the relative path is\n \"\" leaves referent empty before files_fsck_symref_target(..., 1) is called.\n\nSince fsck runs over corruption- or attacker-influenced ref files, this is a\nreachable crash / UB read on malformed repositories.","fix":"Guard against an empty referent before indexing, e.g.:\n\n orig_len = referent->len;\n orig_last_byte = orig_len ? referent->buf[orig_len - 1] : '\\0';\n\nand have the callers in files_fsck_refs_content() report\nFSCK_MSG_BAD_REF_CONTENT and skip files_fsck_symref_target() entirely when\nreferent->len == 0, since an empty referent is itself invalid content.","locations":[{"ref":"refs/files-backend.c:3734-3735","code":" 3734 | \torig_len = referent->len;\n 3735 | \torig_last_byte = referent->buf[orig_len - 1];","lines":2},{"ref":"refs/files-backend.c:3789-3799","code":" 3789 | \t\tstrbuf_add_real_path(&ref_content, path);\n 3790 | \t\tskip_prefix(ref_content.buf, abs_gitdir.buf,\n 3791 | \t\t\t &relative_referent_path);\n 3792 | \n 3793 | \t\tif (relative_referent_path)\n 3794 | \t\t\tstrbuf_addstr(&referent, relative_referent_path);\n 3795 | \t\telse\n 3796 | \t\t\tstrbuf_addbuf(&referent, &ref_content);\n 3797 | \n 3798 | \t\tret |= files_fsck_symref_target(ref_store, o, &report,\n 3799 | \t\t\t\t\t\ttarget_name, &referent, 1);","lines":11},{"ref":"refs/files-backend.c:3840-3843","code":" 3840 | \t} else {\n 3841 | \t\tret = files_fsck_symref_target(ref_store, o, &report,\n 3842 | \t\t\t\t\t target_name, &referent, 0);\n 3843 | \t\tgoto cleanup;","lines":4}]},{"id":"PANIC_5","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"Out-of-bounds read (dir->entries[-1]) in cache_ref_iterator_seek() when the\ntop-level ref_dir is empty. When seeking to a concrete refname (not a prefix\nset), the first iteration of the do/while loop runs against the root ref_dir\nwithout any prior emptiness check. If that dir has dir->nr == 0 (a repository\nwhose loose-ref cache root is empty, e.g. all refs packed / no loose refs), the\nfor loop at lines 511-515 never executes and leaves idx == 0. Line 517 then\ncomputes:\n\n idx = idx >= dir->nr ? dir->nr - 1 : idx; // 0 >= 0 -> dir->nr - 1 == -1\n\nand line 523 dereferences dir->entries[-1] (and line 522 stores level->index =\n-1). This is an out-of-bounds read on entries, leading to undefined behavior /\ncrash or a bogus REF_DIR test.\n\nThe do/while terminator `while (slash && dir->nr)` only guards iterations after\nthe first, so the empty-root case on the very first iteration is unprotected.\nBy contrast, the prefix-set path (cache_ref_iterator_set_prefix) handles a\nmissing/empty dir by setting levels_nr = 0 and returning.","fix":"Bail out before indexing when the current dir is empty. Add a guard at the top\nof the loop body (or before the loop), e.g.:\n\n sort_ref_dir(dir);\n if (!dir->nr)\n break;\n\nbefore the `idx >= dir->nr ? dir->nr - 1 : idx` adjustment, so the function\nleaves level->index at a sane value and does not access entries[-1].","locations":[{"ref":"refs/ref-cache.c:501-543","code":" 501 | \t\tdo {\n 502 | \t\t\tint idx;\n 503 | \t\t\tsize_t len;\n 504 | \t\t\tint cmp = 0;\n 505 | \n 506 | \t\t\tsort_ref_dir(dir);\n 507 | \n 508 | \t\t\tslash = strchr(slash, '/');\n 509 | \t\t\tlen = slash ? (size_t)(slash - refname) : strlen(refname);\n 510 | \n 511 | \t\t\tfor (idx = 0; idx < dir->nr; idx++) {\n 512 | \t\t\t\tcmp = strncmp(refname, dir->entries[idx]->name, len);\n 513 | \t\t\t\tif (cmp <= 0)\n 514 | \t\t\t\t\tbreak;\n 515 | \t\t\t}\n 516 | \t\t\t/* don't overflow the index */\n 517 | \t\t\tidx = idx >= dir->nr ? dir->nr - 1 : idx;\n 518 | \n 519 | \t\t\tif (slash)\n 520 | \t\t\t\tslash = slash + 1;\n 521 | \n 522 | \t\t\tlevel->index = idx;\n 523 | \t\t\tif (dir->entries[idx]->flag & REF_DIR) {\n 524 | \t\t\t\t/* push down a level */\n 525 | \t\t\t\tdir = get_ref_dir(dir->entries[idx]);\n 526 | \n 527 | \t\t\t\tALLOC_GROW(iter->levels, iter->levels_nr + 1,\n 528 | \t\t\t\t\t iter->levels_alloc);\n 529 | \t\t\t\tlevel = &iter->levels[iter->levels_nr++];\n 530 | \t\t\t\tlevel->dir = dir;\n 531 | \t\t\t\tlevel->index = -1;\n 532 | \t\t\t\tlevel->prefix_state = PREFIX_CONTAINS_DIR;\n 533 | \t\t\t} else {\n 534 | \t\t\t\t/* reduce the index so the leaf node is iterated over */\n 535 | \t\t\t\tif (cmp <= 0 && !slash)\n 536 | \t\t\t\t\tlevel->index = idx - 1;\n 537 | \t\t\t\t/*\n 538 | \t\t\t\t * while the seek path may not be exhausted, our\n 539 | \t\t\t\t * match is exhausted at a leaf node.\n 540 | \t\t\t\t */\n 541 | \t\t\t\tbreak;\n 542 | \t\t\t}\n 543 | \t\t} while (slash && dir->nr);","lines":43}]},{"id":"PANIC_6","type":"panic","type_label":"Panic","severity":"high","easy":false,"description":"NULL pointer dereference in reftable_be_fsck(): the iterator returned by\nref_iterator_for_stack() is used without checking iter->err.\nref_iterator_for_stack() always returns a fully allocated iterator, even on\nfailure: if refs->err is set or reftable_stack_reload() fails, it jumps to\n`done` BEFORE calling reftable_stack_init_ref_iterator(), leaving iter->iter as\nthe zero-initialized struct from xcalloc (ops == NULL) and setting iter->err.\n\nreftable_be_fsck() only checks `if (!iter)` (which is dead, since the function\nnever returns NULL) and then immediately calls\nreftable_iterator_next_ref(&iter->iter, &ref). With iter->iter.ops == NULL this\ndereferences a NULL function pointer and crashes.\n\nEvery other consumer goes through reftable_ref_iterator_advance(), which checks\niter->err first; only reftable_be_fsck() pokes the raw reftable iterator and\nskips that check.","fix":"After obtaining the iterator, check its error status before using the raw\nreftable iterator:\n\n iter = ref_iterator_for_stack(refs, backend->stack, \"\", NULL, 0);\n if (iter->err) {\n ret = error(_(\"could not create iterator for worktree '%s'\"), wt->id);\n goto out;\n }\n\n(Replace the dead `if (!iter)` check.)","locations":[{"ref":"refs/reftable-backend.c:2727-2740","code":" 2727 | \titer = ref_iterator_for_stack(refs, backend->stack, \"\", NULL, 0);\n 2728 | \tif (!iter) {\n 2729 | \t\tret = error(_(\"could not create iterator for worktree '%s'\"), wt->id);\n 2730 | \t\tgoto out;\n 2731 | \t}\n 2732 | \n 2733 | \twhile (1) {\n 2734 | \t\tret = reftable_iterator_next_ref(&iter->iter, &ref);\n 2735 | \t\tif (ret > 0)\n 2736 | \t\t\tbreak;\n 2737 | \t\tif (ret < 0) {\n 2738 | \t\t\tret = error(_(\"could not read record for worktree '%s'\"), wt->id);\n 2739 | \t\t\tgoto out;\n 2740 | \t\t}","lines":14},{"ref":"refs/reftable-backend.c:803-822","code":" 803 | \tret = refs->err;\n 804 | \tif (ret)\n 805 | \t\tgoto done;\n 806 | \n 807 | \tret = reftable_stack_reload(stack);\n 808 | \tif (ret)\n 809 | \t\tgoto done;\n 810 | \n 811 | \tret = reftable_stack_init_ref_iterator(stack, &iter->iter);\n 812 | \tif (ret)\n 813 | \t\tgoto done;\n 814 | \n 815 | \tret = reftable_ref_iterator_seek(&iter->base, prefix,\n 816 | \t\t\t\t\t REF_ITERATOR_SEEK_SET_PREFIX);\n 817 | \tif (ret)\n 818 | \t\tgoto done;\n 819 | \n 820 | done:\n 821 | \titer->err = ret;\n 822 | \treturn iter;","lines":20}]},{"id":"PANIC_7","type":"panic","type_label":"Panic","severity":"medium","easy":false,"description":"NULL-pointer dereference in diffcore_merge_broken() / merge_broken inner loop. The\nouter loop explicitly guards against NULL queue entries (`if (!p) continue;`),\nproving the queue can contain NULL slots: a pair merged with its peer is removed by\nsetting `q->queue[j] = NULL`. However the inner scan loop reads `pp = q->queue[j]`\nand immediately dereferences it (`pp->broken_pair`, `pp->one->path`) with NO NULL\ncheck. A slot nulled out by an earlier merge whose index is greater than a later\nouter-loop index i will be revisited by the inner loop and dereferenced.\n\nReachable example: pairs at indices 1 and 3 are peers and get merged at i=1,j=3,\nsetting queue[3]=NULL. When the outer loop later reaches i=2 (another broken pair\nwhose peer lives elsewhere), the inner loop scans j=3 and dereferences NULL\nqueue[3]. A genuine crash on diffs with 3+ surviving broken pairs.","fix":"Add the same NULL guard the outer loop uses at the top of the inner loop body:\n`struct diff_filepair *pp = q->queue[j]; if (!pp) continue;` before touching\npp->broken_pair / pp->one->path.","locations":[{"ref":"diffcore-break.c:290-294","code":" 290 | \t\t\tfor (j = i + 1; j < q->nr; j++) {\n 291 | \t\t\t\tstruct diff_filepair *pp = q->queue[j];\n 292 | \t\t\t\tif (pp->broken_pair &&\n 293 | \t\t\t\t !strcmp(pp->one->path, pp->two->path) &&\n 294 | \t\t\t\t !strcmp(p->one->path, pp->two->path)) {","lines":5},{"ref":"diffcore-break.c:280-298","code":" 280 | \tfor (i = 0; i < q->nr; i++) {\n 281 | \t\tstruct diff_filepair *p = q->queue[i];\n 282 | \t\tif (!p)\n 283 | \t\t\t/* we already merged this with its peer */\n 284 | \t\t\tcontinue;\n 285 | \t\telse if (p->broken_pair &&\n 286 | \t\t\t !strcmp(p->one->path, p->two->path)) {\n 287 | \t\t\t/* If the peer also survived rename/copy, then\n 288 | \t\t\t * we merge them back together.\n 289 | \t\t\t */\n 290 | \t\t\tfor (j = i + 1; j < q->nr; j++) {\n 291 | \t\t\t\tstruct diff_filepair *pp = q->queue[j];\n 292 | \t\t\t\tif (pp->broken_pair &&\n 293 | \t\t\t\t !strcmp(pp->one->path, pp->two->path) &&\n 294 | \t\t\t\t !strcmp(p->one->path, pp->two->path)) {\n 295 | \t\t\t\t\t/* Peer survived. Merge them */\n 296 | \t\t\t\t\tmerge_broken(p, pp, &outq);\n 297 | \t\t\t\t\tq->queue[j] = NULL;\n 298 | \t\t\t\t\tgoto next;","lines":19}]},{"id":"PANIC_8","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"unpack_object_header_buffer() reads the first header byte `c = buf[used++]`\nunconditionally, before any check that len >= 1. If called with len == 0 it\nperforms a 1-byte out-of-bounds read of buf[0]; the `len <= used` guard only\nprotects continuation bytes inside the while loop, never the first byte. In-tree\ncallers (via use_pack, which guarantees rawsz bytes) do not hit this, but the\nfunction is a public API (declared in packfile.h) and the OSS-Fuzz harness\noss-fuzz/fuzz-pack-headers.c calls it directly with a size that can be 0, making\nthe OOB read reachable.","fix":"Add an early guard before the first read:\n`if (!len) { error(\"bad object header\"); *sizep = 0; return 0; }` then\n`c = buf[used++];`.","locations":[{"ref":"packfile.c:1142-1163","code":" 1142 | \tunsigned long used = 0;\n 1143 | \n 1144 | \tc = buf[used++];\n 1145 | \t*type = (c >> 4) & 7;\n 1146 | \tsize = c & 15;\n 1147 | \tshift = 4;\n 1148 | \twhile (c & 0x80) {\n 1149 | \t\t/*\n 1150 | \t\t * Each continuation byte adds 7 bits. Ensure shift won't\n 1151 | \t\t * overflow size_t (use size_t not long for 64-bit on Windows).\n 1152 | \t\t */\n 1153 | \t\tif (len <= used || (bitsizeof(size_t) - 7) < shift) {\n 1154 | \t\t\terror(\"bad object header\");\n 1155 | \t\t\tsize = used = 0;\n 1156 | \t\t\tbreak;\n 1157 | \t\t}\n 1158 | \t\tc = buf[used++];\n 1159 | \t\tsize = st_add(size, st_left_shift(c & 0x7f, shift));\n 1160 | \t\tshift += 7;\n 1161 | \t}\n 1162 | \t*sizep = size;\n 1163 | \treturn used;","lines":22}]},{"id":"PANIC_9","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"Out-of-bounds read in range-diff's commit-message whitespace-trimming loop. When\nparsing `git log -p` output, an indented commit-message line that is entirely\nwhitespace (e.g. a body line emitted as just the 4-space indent, \" \\n\") matches\n`starts_with(line, \" \")`. The trailing-whitespace strip loop tests `isspace(*p)`\nBEFORE the bounds guard `p >= line`:\n\n p = line + len - 2;\n while (isspace(*p) && p >= line)\n p--;\n\nBecause && short-circuits left-to-right, when `p` walks down to `line - 1` the loop\ndereferences `*p` (a 1-byte OOB read just before the buffer) before the `p >= line`\ncheck ever fails. The input is parsed line-by-line from `git log` output.","fix":"Swap the operand order so the bounds check short-circuits first:\n while (p >= line && isspace(*p))\n p--;","locations":[{"ref":"range-diff.c:188-190","code":" 188 | \t\t\t\tp = line + len - 2;\n 189 | \t\t\t\twhile (isspace(*p) && p >= line)\n 190 | \t\t\t\t\tp--;","lines":3}]},{"id":"PANIC_10","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"NULL-pointer dereference in range-diff hunk-header parsing. After matching the\n\"@@ \" prefix, the code searches for the closing \"@@\" and then dereferences the\nresult before its own NULL check:\n\n } else if (skip_prefix(line, \"@@ \", &p)) {\n p = strstr(p, \"@@\");\n strbuf_addstr(&buf, \"@@\");\n if (current_filename && p[2]) /* derefs p before checking it */\n strbuf_addf(&buf, \" %s:\", current_filename);\n if (p) /* NULL check arrives too late */\n strbuf_addstr(&buf, p + 2);\n\n`strstr()` returns NULL for a line that starts with \"@@ \" but contains no second\n\"@@\". Line 198 reads `p[2]` (NULL deref) before line 200 guards `if (p)`. The\npresence of the later `if (p)` guard shows NULL was anticipated; the earlier access\nis the bug. Reachable from malformed/edge-case diff context lines in `git log -p`\noutput.","fix":"Guard the dereference under the same NULL check, e.g.:\n p = strstr(p, \"@@\");\n strbuf_addstr(&buf, \"@@\");\n if (p) {\n if (current_filename && p[2])\n strbuf_addf(&buf, \" %s:\", current_filename);\n strbuf_addstr(&buf, p + 2);\n }","locations":[{"ref":"range-diff.c:196-201","code":" 196 | \t\t\tp = strstr(p, \"@@\");\n 197 | \t\t\tstrbuf_addstr(&buf, \"@@\");\n 198 | \t\t\tif (current_filename && p[2])\n 199 | \t\t\t\tstrbuf_addf(&buf, \" %s:\", current_filename);\n 200 | \t\t\tif (p)\n 201 | \t\t\t\tstrbuf_addstr(&buf, p + 2);","lines":6}]},{"id":"PANIC_11","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"In `handle_config()`, the `remote.<name>.tagopt` and `remote.<name>.followRemoteHEAD` config handlers dereference `value` with no NULL check. When the key is given in valueless boolean form (e.g. `[remote \"x\"]\\n\\ttagopt` or `\\tfollowRemoteHEAD`), git_config passes `value == NULL`, and `strcmp(value, \"--no-tags\")`, `strcmp(value, \"never\")`, and `skip_prefix(value, \"warn-if-not-\", ...)` dereference NULL, crashing (SIGSEGV). Every other string-valued subkey in this same function guards with `if (!value) return config_error_nonbool(key);` (url, pushurl, push, fetch, receivepack, uploadpack, branch.merge); these two were missed. The config is attacker-influenceable (malicious repo `.git/config`, submodule config), so it is a denial of service. Reproduced against stock git: `git remote get-url x` with such a valueless config crashes.","fix":"Add `if (!value) return config_error_nonbool(key);` at the start of both the `tagopt` and `followremotehead` branches, before any use of `value`, mirroring the other handlers in the same function.","locations":[{"ref":"remote.c:551-555","code":" 551 | \t} else if (!strcmp(subkey, \"tagopt\")) {\n 552 | \t\tif (!strcmp(value, \"--no-tags\"))\n 553 | \t\t\tremote->fetch_tags = -1;\n 554 | \t\telse if (!strcmp(value, \"--tags\"))\n 555 | \t\t\tremote->fetch_tags = 2;","lines":5},{"ref":"remote.c:576-593","code":" 576 | \t} else if (!strcmp(subkey, \"followremotehead\")) {\n 577 | \t\tconst char *no_warn_branch;\n 578 | \t\tif (!strcmp(value, \"never\"))\n 579 | \t\t\tremote->follow_remote_head = FOLLOW_REMOTE_NEVER;\n 580 | \t\telse if (!strcmp(value, \"create\"))\n 581 | \t\t\tremote->follow_remote_head = FOLLOW_REMOTE_CREATE;\n 582 | \t\telse if (!strcmp(value, \"warn\")) {\n 583 | \t\t\tremote->follow_remote_head = FOLLOW_REMOTE_WARN;\n 584 | \t\t\tremote->no_warn_branch = NULL;\n 585 | \t\t} else if (skip_prefix(value, \"warn-if-not-\", &no_warn_branch)) {\n 586 | \t\t\tremote->follow_remote_head = FOLLOW_REMOTE_WARN;\n 587 | \t\t\tremote->no_warn_branch = no_warn_branch;\n 588 | \t\t} else if (!strcmp(value, \"always\")) {\n 589 | \t\t\tremote->follow_remote_head = FOLLOW_REMOTE_ALWAYS;\n 590 | \t\t} else {\n 591 | \t\t\twarning(_(\"unrecognized followRemoteHEAD value '%s' ignored\"),\n 592 | \t\t\t\tvalue);\n 593 | \t\t}","lines":18}]},{"id":"PANIC_12","type":"panic","type_label":"Panic","severity":"medium","easy":true,"description":"allowed_bare_repo_cb() dereferences `value` via strcmp() without a NULL check.\nGit's config parser passes value == NULL for a valueless (implicit-boolean)\nkey. A user/system gitconfig containing a bare `[safe] bareRepository` (no\n`= ...`) therefore causes a NULL-pointer dereference / crash when\nget_allowed_bare_repo() runs git_protected_config() during repository setup.\nThe sibling callback safe_directory_cb() correctly guards with `if (!value)`,\nconfirming NULL is an expected input here. safe.bareRepository is read from\nprotected (system/global) config, so this is reachable in normal operation.","fix":"Guard against a NULL value before the strcmp() calls:\n\n if (strcasecmp(key, \"safe.bareRepository\"))\n return 0;\n if (!value)\n return config_error_nonbool(key);\n if (!strcmp(value, \"explicit\")) ...","locations":[{"ref":"setup.c:1464-1475","code":" 1464 | \tif (strcasecmp(key, \"safe.bareRepository\"))\n 1465 | \t\treturn 0;\n 1466 | \n 1467 | \tif (!strcmp(value, \"explicit\")) {\n 1468 | \t\t*allowed_bare_repo = ALLOWED_BARE_REPO_EXPLICIT;\n 1469 | \t\treturn 0;\n 1470 | \t}\n 1471 | \tif (!strcmp(value, \"all\")) {\n 1472 | \t\t*allowed_bare_repo = ALLOWED_BARE_REPO_ALL;\n 1473 | \t\treturn 0;\n 1474 | \t}\n 1475 | \treturn -1;","lines":12}]},{"id":"PANIC_13","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"In do_oneway_diff(), the pair returned by diff_unmerge() is dereferenced\n(pair->one via fill_filespec) without a NULL check. diff_unmerge() returns NULL when\nopt->prefix is set and the path does not match the prefix. Unlike run_diff_files()\n- which pre-filters paths by prefix before its diff_unmerge() call - do_oneway_diff()\nperforms no such prefix pre-filtering, so a prefix-mismatching unmerged index entry\nreaching this code with `tree` non-NULL would NULL-deref. Reachability is narrow\n(requires opt->prefix set together with a cached unmerged stage entry that fails\nthe prefix match yet survives pathspec matching), but the missing NULL check is a\ngenuine latent defect mirroring diff_unmerge's NULL-return contract.","fix":"Guard the dereference: capture the diff_unmerge() result and only fill the filespec\nwhen it is non-NULL, matching how diff_unmerge's NULL return is handled elsewhere.","locations":[{"ref":"diff-lib.c:467-473","code":" 467 | \tif (cached && idx && ce_stage(idx)) {\n 468 | \t\tstruct diff_filepair *pair;\n 469 | \t\tpair = diff_unmerge(&revs->diffopt, idx->name);\n 470 | \t\tif (tree)\n 471 | \t\t\tfill_filespec(pair->one, &tree->oid, 1,\n 472 | \t\t\t\t tree->ce_mode);\n 473 | \t\treturn;","lines":7}]},{"id":"PANIC_14","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"NULL dereference in repo_write_loose_object_map() because the\nshould_use_loose_object_map() guard is checked AFTER the map is already\ndereferenced.\n\nThe function dereferences files->loose->map->to_compat at the very top:\n\n struct odb_source_files *files = odb_source_files_downcast(repo->objects->sources);\n kh_oid_map_t *map = files->loose->map->to_compat; // line 131\n ...\n if (!should_use_loose_object_map(repo)) // line 137\n return 0;\n\nloose->map is only ever allocated by load_one_loose_object_map(), which is\nreached exclusively through repo_read_loose_object_map(), itself guarded by\nshould_use_loose_object_map(). Therefore, when the repository has no compat\nhash algorithm configured, loose->map stays NULL and line 131 dereferences\nNULL -> crash. The guard that is supposed to prevent this runs four lines too\nlate. (Currently latent: repo_write_loose_object_map has no in-tree callers,\nbut it is an exported API in loose.h.)","fix":"Move the should_use_loose_object_map() check to the top of the function,\nbefore any use of files->loose->map:\n\n int repo_write_loose_object_map(struct repository *repo)\n {\n if (!should_use_loose_object_map(repo))\n return 0;\n struct odb_source_files *files = odb_source_files_downcast(repo->objects->sources);\n kh_oid_map_t *map = files->loose->map->to_compat;\n ...","locations":[{"ref":"loose.c:128-138","code":" 128 | int repo_write_loose_object_map(struct repository *repo)\n 129 | {\n 130 | \tstruct odb_source_files *files = odb_source_files_downcast(repo->objects->sources);\n 131 | \tkh_oid_map_t *map = files->loose->map->to_compat;\n 132 | \tstruct lock_file lock;\n 133 | \tint fd;\n 134 | \tkhiter_t iter;\n 135 | \tstruct strbuf buf = STRBUF_INIT, path = STRBUF_INIT;\n 136 | \n 137 | \tif (!should_use_loose_object_map(repo))\n 138 | \t\treturn 0;","lines":11}]},{"id":"PANIC_15","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"The Display impl for ObjectID calls `self.as_slice().unwrap()`, which returns Err(InvalidHashAlgorithm) when the `algo` field is not 1 or 2. ObjectID is `#[repr(C)]` and is populated across the C FFI boundary (e.g. git_hash_final_oid writes into it), so an ObjectID carrying an unexpected algo value will panic the moment it is formatted with `{}`, rather than producing a diagnostic. The Debug impl already handles the invalid case gracefully; Display does not.","fix":"Make Display tolerate an invalid algorithm the way Debug does (fall back to formatting `self.hash` or emit a placeholder) instead of unwrapping.","locations":[{"ref":"src/hash.rs:80-89","code":" 80 | impl Display for ObjectID {\n 81 | /// Format this object ID as a hex object ID.\n 82 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n 83 | let hash = self.as_slice().unwrap();\n 84 | for x in hash {\n 85 | write!(f, \"{:02x}\", x)?;\n 86 | }\n 87 | Ok(())\n 88 | }\n 89 | }","lines":10}]},{"id":"PANIC_16","type":"panic","type_label":"Panic","severity":"low","easy":false,"description":"MmapedObjectMap::binary_search_slice divides by `len = wanted.len()` (`index % len`) without guarding against zero. The width comes from `params.shortened_len`, which MmapedObjectMap::new reads verbatim from the LMAP file (`u32_at_offset(slice, offset + 4)`) and never validates to be > 0 (it only feeds it through `checked_mul(nitems)`, which is 0 when shortened_len is 0). A crafted or corrupt LMAP map with a shortened name length of 0 therefore passes parsing and then triggers a divide-by-zero panic on the first lookup. The map reader is intended to operate on on-disk data, so this is attacker-influenced input.","fix":"Reject `shortened_len == 0` in MmapedObjectMap::new (return MmapedParseError), and/or early-return None from binary_search_slice / look_up_object when `wanted` is empty, before computing `index % len`.","locations":[{"ref":"src/loose.rs:391-405","code":" 391 | fn binary_search_slice(sl: &[u8], wanted: &[u8]) -> Option<usize> {\n 392 | let len = wanted.len();\n 393 | let res = sl.binary_search_by(|item| {\n 394 | // We would like element_offset, but that is currently nightly only. Instead, do a\n 395 | // pointer subtraction to find the index.\n 396 | let index = unsafe { (item as *const u8).offset_from(sl.as_ptr()) } as usize;\n 397 | // Now we have the index of this object. Round it down to the nearest full-sized\n 398 | // chunk to find the actual offset where this starts.\n 399 | let index = index - (index % len);\n 400 | // Compute the comparison of that value instead, which will provide the expected\n 401 | // result.\n 402 | sl[index..index + wanted.len()].cmp(wanted)\n 403 | });\n 404 | res.ok().map(|offset| offset / len)\n 405 | }","lines":15},{"ref":"src/loose.rs:284-300","code":" 284 | let format_id = Self::u32_at_offset(slice, offset);\n 285 | let shortened_len = Self::u32_at_offset(slice, offset + 4) as usize;\n 286 | let data_off = Self::u64_at_offset(slice, offset + 8);\n 287 | \n 288 | let algo = HashAlgorithm::from_format_id(format_id)\n 289 | .ok_or(MmapedParseError::UnknownAlgorithm)?;\n 290 | let data_off: usize = data_off\n 291 | .try_into()\n 292 | .map_err(|_| MmapedParseError::OffsetTooLarge)?;\n 293 | \n 294 | // Every object format must have these entries.\n 295 | let shortened_table_len = shortened_len\n 296 | .checked_mul(nitems)\n 297 | .ok_or(MmapedParseError::OffsetTooLarge)?;\n 298 | let full_off = data_off\n 299 | .checked_add(shortened_table_len)\n 300 | .ok_or(MmapedParseError::OffsetTooLarge)?;","lines":17}]},{"id":"PANIC_17","type":"panic","type_label":"Panic","severity":"negligible","easy":false,"description":"Latent NULL pointer dereference in get_mode() (diff-no-index). When\n`path == file_from_standard_input`, the code unconditionally writes\n`*special = SPECIAL_STDIN` without checking `special != NULL`. The later FIFO block\ncorrectly guards with `special &&`, but the stdin branch does not. Currently\nunreachable because the only caller passes special==NULL exactly when recursing,\nand recursing paths are directory-entry buffers that can never equal the stdin\nsentinel pointer. A real latent fragility if a future caller passes the stdin\nsentinel with special==NULL.","fix":"Guard the write: `if (special) *special = SPECIAL_STDIN;`, mirroring the FIFO guard.","locations":[{"ref":"diff-no-index.c:91-93","code":" 91 | \t} else if (path == file_from_standard_input) {\n 92 | \t\t*mode = create_ce_mode(0666);\n 93 | \t\t*special = SPECIAL_STDIN;","lines":3}]},{"id":"UAF_1","type":"use_after_free","type_label":"Use After Free","severity":"high","easy":false,"description":"In fetch_object(), after the active-slot loops complete, `req = obj_req->req` is\ndereferenced unconditionally (req->localfile, req->curl_result, etc.) before the\ncode checks `obj_req->state == ABORTED`. But obj_req->req can be NULL or dangling\nwhen the request was aborted, giving a remote-triggerable NULL deref / use-after-free.\n\nTwo abort paths leave obj_req->req in a bad state:\n\n1. start_object_request() -> new_http_object_request() returns NULL: obj_req->state\nis set to ABORTED and obj_req->req is left NULL. new_http_object_request() returns\nNULL on ordinary failures such as failing to create the local temp file, so this is\nreachable.\n\n2. start_object_request() -> start_active_slot() fails: obj_req->req was already\nassigned the request pointer, then release_http_object_request(&req) frees the struct\nand NULLs only the *local* `req` variable - obj_req->req is left dangling.\n\nBoth can be reached for the first request (prefetch()->fill_active_slot()->\nstart_object_request()) and again from process_object_response() when switching to the\nnext alternate (release_http_object_request(&obj_req->req) then start_object_request()).\nA malicious/broken HTTP server that makes objects \"missing\" so the walker rolls onto a\nconfigured alternate, combined with a local open() failure, triggers it. That the code\nchecks state==ABORTED *after* already dereferencing req shows the aborted state is\nexpected to be reachable here, but the NULL/dangling guard comes too late.","fix":"Handle the aborted/NULL case before dereferencing req. After the\n`while (state == ACTIVE)` loop:\n\n if (obj_req->state == ABORTED || !obj_req->req) {\n ret = error(\"Request for %s aborted\", hex);\n release_object_request(obj_req);\n return ret;\n }\n req = obj_req->req;\n ...\n\nAlso fix start_object_request()'s start_active_slot failure path to release via the\ncanonical pointer so it does not dangle:\n release_http_object_request(&obj_req->req);","locations":[{"ref":"http-walker.c:513-527","code":" 513 | \twhile (obj_req->state == ACTIVE)\n 514 | \t\trun_active_slot(obj_req->req->slot);\n 515 | \n 516 | \treq = obj_req->req;\n 517 | \n 518 | \tif (req->localfile != -1) {\n 519 | \t\tclose(req->localfile);\n 520 | \t\treq->localfile = -1;\n 521 | \t}\n 522 | \n 523 | \tnormalize_curl_result(&req->curl_result, req->http_code,\n 524 | \t\t\t req->errorstr, sizeof(req->errorstr));\n 525 | \n 526 | \tif (obj_req->state == ABORTED) {\n 527 | \t\tret = error(\"Request for %s aborted\", hex);","lines":15},{"ref":"http-walker.c:59-82","code":" 59 | static void start_object_request(struct object_request *obj_req)\n 60 | {\n 61 | \tstruct active_request_slot *slot;\n 62 | \tstruct http_object_request *req;\n 63 | \n 64 | \treq = new_http_object_request(obj_req->repo->base, &obj_req->oid);\n 65 | \tif (!req) {\n 66 | \t\tobj_req->state = ABORTED;\n 67 | \t\treturn;\n 68 | \t}\n 69 | \tobj_req->req = req;\n 70 | \n 71 | \tslot = req->slot;\n 72 | \tslot->callback_func = process_object_response;\n 73 | \tslot->callback_data = obj_req;\n 74 | \n 75 | \t/* Try to get the request started, abort the request on error */\n 76 | \tobj_req->state = ACTIVE;\n 77 | \tif (!start_active_slot(slot)) {\n 78 | \t\tobj_req->state = ABORTED;\n 79 | \t\trelease_http_object_request(&req);\n 80 | \t\treturn;\n 81 | \t}\n 82 | }","lines":24},{"ref":"http-walker.c:110-119","code":" 110 | \tif (missing_target(obj_req->req)) {\n 111 | \t\tfetch_alternates(walker, alt->base);\n 112 | \t\tif (obj_req->repo->next) {\n 113 | \t\t\tobj_req->repo =\n 114 | \t\t\t\tobj_req->repo->next;\n 115 | \t\t\trelease_http_object_request(&obj_req->req);\n 116 | \t\t\tstart_object_request(obj_req);\n 117 | \t\t\treturn;\n 118 | \t\t}\n 119 | \t}","lines":10}]},{"id":"UAF_2","type":"use_after_free","type_label":"Use After Free","severity":"medium","easy":false,"description":"In todo_list_rearrange_squash() the `subjects` array is allocated UNINITIALIZED with\n`ALLOC_ARRAY(subjects, todo_list->nr)` (ALLOC_ARRAY does not zero memory). Entries are\nfilled in one-by-one inside the loop. If an item with a fixup-ish command is encountered,\nthe code runs `error(...); goto cleanup;` BEFORE `subjects[i]` for the current index has\nbeen assigned, and every index > i was never written either. The cleanup block then does\n`for (i = 0; i < todo_list->nr; i++) free(subjects[i]);`, calling free() on garbage\npointers. This is an invalid free / undefined behavior (typically a crash or heap\ncorruption). This path is reachable: todo_list_rearrange_squash() is part of the public\nsequencer API and the explicit is_fixup() guard exists precisely because a pre-rearranged\nlist can be fed in.","fix":"Use CALLOC_ARRAY(subjects, todo_list->nr) so all entries are NULL-initialized and free(NULL) is a safe no-op on the early-exit path.","locations":[{"ref":"sequencer.c:6715","code":" 6715 | \tALLOC_ARRAY(subjects, todo_list->nr);","lines":1},{"ref":"sequencer.c:6730-6733","code":" 6730 | \t\tif (is_fixup(item->command)) {\n 6731 | \t\t\tret = error(_(\"the script was already rearranged.\"));\n 6732 | \t\t\tgoto cleanup;\n 6733 | \t\t}","lines":4},{"ref":"sequencer.c:6837-6839","code":" 6837 | \tfor (i = 0; i < todo_list->nr; i++)\n 6838 | \t\tfree(subjects[i]);\n 6839 | \tfree(subjects);","lines":3}]},{"id":"UAF_3","type":"use_after_free","type_label":"Use After Free","severity":"low","easy":false,"description":"The `remote.<name>.followRemoteHEAD = warn-if-not-<branch>` handler stores a raw, non-owned pointer into the config value buffer: `remote->no_warn_branch = no_warn_branch;` where `no_warn_branch` points into `value` (via `skip_prefix`). Every other string field of `struct remote` is independently owned (xstrdup) and freed in `remote_clear`; this one aliases storage owned by the config layer. In the common `repo_config` path the value lives in the configset cache and survives, so it works today (and `remote_clear` correctly does not free it), but the lifetime is now coupled to the config cache rather than the remote. If the config cache is cleared/reloaded while the `remote` is still in use - `no_warn_branch` is read much later (builtin/fetch.c) - it becomes a dangling pointer. It is also inconsistent with the ownership model of the surrounding fields.","fix":"Duplicate the string: `remote->no_warn_branch = xstrdup(no_warn_branch);`, FREE_AND_NULL it on reassignment, and free it in `remote_clear`, matching the other remote string fields.","locations":[{"ref":"remote.c:585-587","code":" 585 | \t\t} else if (skip_prefix(value, \"warn-if-not-\", &no_warn_branch)) {\n 586 | \t\t\tremote->follow_remote_head = FOLLOW_REMOTE_WARN;\n 587 | \t\t\tremote->no_warn_branch = no_warn_branch;","lines":3}]},{"id":"MEM_1","type":"memory_leak","type_label":"Memory Leak","severity":"high","easy":true,"description":"`HashFile` (Rust) wraps the C `struct hashfile *` returned by `hashfd()`/`hashfd_ext()`, which heap-allocates the struct plus `f->buffer` (and possibly `f->check_buffer`) and owns the file descriptor `fd`. The only Rust code that releases this is `HashFile::finalize`, which calls `finalize_hashfile` (csum-file.c:65-102 -> free_hashfile at csum-file.c:100). `HashFile` has NO `Drop` impl, so any `HashFile` that is created and then dropped without calling `finalize` (e.g. an error path, an early return, or simply never finalizing) leaks the `struct hashfile`, its buffer(s), and the underlying fd. The C API explicitly provides `free_hashfile()`/`discard_hashfile()` (csum-file.c:58-63, 104-111) for exactly this case, but Rust never declares or calls them.","fix":"Add a `Drop` impl for `HashFile` that calls `discard_hashfile(self.ptr)` (declare it in the extern block) when the file was not finalized. Because `finalize` consumes `self` by value and the C side already frees in that path, guard against double free by e.g. taking the pointer out (set to null / use an Option) in `finalize`, or wrap the pointer so `Drop` only runs when `finalize` was not called.","locations":[{"ref":"src/csum_file.rs:19-45","code":" 19 | pub struct HashFile {\n 20 | ptr: *mut c_void,\n 21 | algo: HashAlgorithm,\n 22 | }\n 23 | \n 24 | impl HashFile {\n 25 | /// Create a new HashFile.\n 26 | ///\n 27 | /// The hash used will be `algo`, its name should be in `name`, and an open file descriptor\n 28 | /// pointing to that file should be in `fd`.\n 29 | pub fn new(algo: HashAlgorithm, fd: i32, name: &CStr) -> HashFile {\n 30 | HashFile {\n 31 | ptr: unsafe { c::hashfd(algo.hash_algo_ptr(), fd, name.as_ptr()) },\n 32 | algo,\n 33 | }\n 34 | }\n 35 | \n 36 | /// Finalize this HashFile instance.\n 37 | ///\n 38 | /// Returns the hash computed over the data.\n 39 | pub fn finalize(self, component: u32, flags: u32) -> Vec<u8> {\n 40 | let mut result = vec![0u8; GIT_MAX_RAWSZ];\n 41 | unsafe { c::finalize_hashfile(self.ptr, result.as_mut_ptr(), component, flags) };\n 42 | result.truncate(self.algo.raw_len());\n 43 | result\n 44 | }\n 45 | }","lines":27},{"ref":"csum-file.c:58-111","code":" 58 | void free_hashfile(struct hashfile *f)\n 59 | {\n 60 | \tfree(f->buffer);\n 61 | \tfree(f->check_buffer);\n 62 | \tfree(f);\n 63 | }\n 64 | \n 65 | int finalize_hashfile(struct hashfile *f, unsigned char *result,\n 66 | \t\t enum fsync_component component, unsigned int flags)\n 67 | {\n 68 | \tint fd;\n 69 | \n 70 | \thashflush(f);\n 71 | \n 72 | \tif (f->skip_hash)\n 73 | \t\thashclr(f->buffer, f->algop);\n 74 | \telse\n 75 | \t\tgit_hash_final(f->buffer, &f->ctx);\n 76 | \n 77 | \tif (result)\n 78 | \t\thashcpy(result, f->buffer, f->algop);\n 79 | \tif (flags & CSUM_HASH_IN_STREAM)\n 80 | \t\tflush(f, f->buffer, f->algop->rawsz);\n 81 | \tif (flags & CSUM_FSYNC)\n 82 | \t\tfsync_component_or_die(component, f->fd, f->name);\n 83 | \tif (flags & CSUM_CLOSE) {\n 84 | \t\tif (close(f->fd))\n 85 | \t\t\tdie_errno(\"%s: sha1 file error on close\", f->name);\n 86 | \t\tfd = 0;\n 87 | \t} else\n 88 | \t\tfd = f->fd;\n 89 | \tif (0 <= f->check_fd) {\n 90 | \t\tchar discard;\n 91 | \t\tint cnt = read_in_full(f->check_fd, &discard, 1);\n 92 | \t\tif (cnt < 0)\n 93 | \t\t\tdie_errno(\"%s: error when reading the tail of sha1 file\",\n 94 | \t\t\t\t f->name);\n 95 | \t\tif (cnt)\n 96 | \t\t\tdie(\"%s: sha1 file has trailing garbage\", f->name);\n 97 | \t\tif (close(f->check_fd))\n 98 | \t\t\tdie_errno(\"%s: sha1 file error on close\", f->name);\n 99 | \t}\n 100 | \tfree_hashfile(f);\n 101 | \treturn fd;\n 102 | }\n 103 | \n 104 | void discard_hashfile(struct hashfile *f)\n 105 | {\n 106 | \tif (0 <= f->check_fd)\n 107 | \t\tclose(f->check_fd);\n 108 | \tif (0 <= f->fd)\n 109 | \t\tclose(f->fd);\n 110 | \tfree_hashfile(f);\n 111 | }","lines":54}]},{"id":"MEM_2","type":"memory_leak","type_label":"Memory Leak","severity":"medium","easy":false,"description":"setup_pending_objects CALLOCs tags (when info->tags), tagged_blobs and\ntagged_trees before they are inserted into ctx->paths_to_lists. When a\nsubsequent lookup_tag fails the function returns -1 immediately, before those\nthree lists (and any oids appended to them) are registered anywhere the caller\ncleans up. The caller's cleanup only iterates paths_to_lists, so all three\nallocations leak (and the caller itself also bails without cleanup, see the\nwalk_objects_by_path leak finding).","fix":"Before `return -1`, free the not-yet-registered lists and their oids, or\nrestructure so the lists are inserted into the strmap up front (so the normal\nclear_paths_to_lists cleanup covers them), using a goto-cleanup label.","locations":[{"ref":"path-walk.c:454-457","code":" 454 | \tif (info->tags)\n 455 | \t\tCALLOC_ARRAY(tags, 1);\n 456 | \tCALLOC_ARRAY(tagged_blobs, 1);\n 457 | \tCALLOC_ARRAY(tagged_trees, 1);","lines":4},{"ref":"path-walk.c:475-481","code":" 475 | \t\twhile (obj->type == OBJ_TAG) {\n 476 | \t\t\tstruct tag *tag = lookup_tag(info->revs->repo, &obj->oid);\n 477 | \t\t\tif (!tag) {\n 478 | \t\t\t\terror(_(\"failed to find tag %s\"),\n 479 | \t\t\t\t oid_to_hex(&obj->oid));\n 480 | \t\t\t\treturn -1;\n 481 | \t\t\t}","lines":7}]},{"id":"MEM_3","type":"memory_leak","type_label":"Memory Leak","severity":"medium","easy":false,"description":"In walk_objects_by_path, several error paths return without running the cleanup\nblock at the end of the function. By the time setup_pending_objects fails\n(return ret at ~path-walk.c:771) or a commit tree cannot be found (return -1 at\n~path-walk.c:791), commit_list, root_tree_list, ctx.paths_to_lists,\nctx.path_stack, the strset (ctx.path_stack_pushed) and the prio_queue have all\nbeen populated; none of them are freed on these early returns. clear_paths_to_lists,\nstrset_clear, clear_prio_queue and the commit_list free only run on the normal\nexit path.","fix":"Replace the bare `return` statements in the body with `goto cleanup;` where the\ncleanup label runs clear_paths_to_lists(&ctx.paths_to_lists), strset_clear,\nclear_prio_queue and frees commit_list (clearing its oids), then returns ret.","locations":[{"ref":"path-walk.c:766-771","code":" 766 | \ttrace2_region_enter(\"path-walk\", \"pending-walk\", info->revs->repo);\n 767 | \tret = setup_pending_objects(info, &ctx);\n 768 | \ttrace2_region_leave(\"path-walk\", \"pending-walk\", info->revs->repo);\n 769 | \n 770 | \tif (ret)\n 771 | \t\treturn ret;","lines":6},{"ref":"path-walk.c:786-792","code":" 786 | \t\toid = get_commit_tree_oid(c);\n 787 | \t\tt = lookup_tree(info->revs->repo, oid);\n 788 | \n 789 | \t\tif (!t) {\n 790 | \t\t\terror(\"could not find tree %s\", oid_to_hex(oid));\n 791 | \t\t\treturn -1;\n 792 | \t\t}","lines":7},{"ref":"path-walk.c:807-844","code":" 807 | \toid_array_clear(&commit_list->oids);\n 808 | \tfree(commit_list);\n 809 | \n 810 | \ttrace2_region_enter(\"path-walk\", \"path-walk\", info->revs->repo);\n 811 | \twhile (!ret && ctx.path_stack.nr) {\n 812 | \t\tchar *path = prio_queue_get(&ctx.path_stack);\n 813 | \t\tpaths_nr++;\n 814 | \n 815 | \t\tret = walk_path(&ctx, path);\n 816 | \n 817 | \t\tfree(path);\n 818 | \t}\n 819 | \n 820 | \t/* Are there paths remaining? Likely they are from indexed objects. */\n 821 | \tif (!strmap_empty(&ctx.paths_to_lists)) {\n 822 | \t\tstruct hashmap_iter iter;\n 823 | \t\tstruct strmap_entry *entry;\n 824 | \n 825 | \t\tstrmap_for_each_entry(&ctx.paths_to_lists, &iter, entry)\n 826 | \t\t\tpush_to_stack(&ctx, entry->key);\n 827 | \n 828 | \t\twhile (!ret && ctx.path_stack.nr) {\n 829 | \t\t\tchar *path = prio_queue_get(&ctx.path_stack);\n 830 | \t\t\tpaths_nr++;\n 831 | \n 832 | \t\t\tret = walk_path(&ctx, path);\n 833 | \n 834 | \t\t\tfree(path);\n 835 | \t\t}\n 836 | \t}\n 837 | \n 838 | \ttrace2_data_intmax(\"path-walk\", ctx.repo, \"paths\", paths_nr);\n 839 | \ttrace2_region_leave(\"path-walk\", \"path-walk\", info->revs->repo);\n 840 | \n 841 | \tclear_paths_to_lists(&ctx.paths_to_lists);\n 842 | \tstrset_clear(&ctx.path_stack_pushed);\n 843 | \tclear_prio_queue(&ctx.path_stack);\n 844 | \treturn ret;","lines":38}]},{"id":"MEM_4","type":"memory_leak","type_label":"Memory Leak","severity":"medium","easy":false,"description":"In do_update_ref(), when refs_read_ref(... \"HEAD\", &rec->after) fails, the function does\nan early `return -1` from inside the loop. This skips both write_update_refs_state(&list)\nand string_list_clear(&list, 1). The string_list was built with STRING_LIST_INIT_DUP and\neach item carries a `util` pointer (an update_ref_record allocated by\nsequencer_get_update_refs_state), so the entire state structure - all duped strings and all\nutil records - is leaked. This is the sole error path in the function and the normal path\ncorrectly clears the list.","fix":"Replace the bare `return -1;` with `res = -1; goto cleanup;` where a cleanup label runs string_list_clear(&list, 1) (and skips the write).","locations":[{"ref":"sequencer.c:4559-4579","code":" 4559 | static int do_update_ref(struct repository *r, const char *refname)\n 4560 | {\n 4561 | \tstruct string_list_item *item;\n 4562 | \tstruct string_list list = STRING_LIST_INIT_DUP;\n 4563 | \n 4564 | \tif (sequencer_get_update_refs_state(r->gitdir, &list))\n 4565 | \t\treturn -1;\n 4566 | \n 4567 | \tfor_each_string_list_item(item, &list) {\n 4568 | \t\tif (!strcmp(item->string, refname)) {\n 4569 | \t\t\tstruct update_ref_record *rec = item->util;\n 4570 | \t\t\tif (refs_read_ref(get_main_ref_store(the_repository), \"HEAD\", &rec->after))\n 4571 | \t\t\t\treturn -1;\n 4572 | \t\t\tbreak;\n 4573 | \t\t}\n 4574 | \t}\n 4575 | \n 4576 | \twrite_update_refs_state(&list);\n 4577 | \tstring_list_clear(&list, 1);\n 4578 | \treturn 0;\n 4579 | }","lines":21},{"ref":"sequencer.c:4570-4571","code":" 4570 | \t\t\tif (refs_read_ref(get_main_ref_store(the_repository), \"HEAD\", &rec->after))\n 4571 | \t\t\t\treturn -1;","lines":2}]},{"id":"MEM_5","type":"memory_leak","type_label":"Memory Leak","severity":"medium","easy":false,"description":"In merged_entry(), the freshly dup'd cache_entry `merge` is leaked on the\ncheck_submodule_move_head() error paths. `merge` is allocated by dup_cache_entry()\nand only handed off to the result index by do_add_entry() at the end of the\nfunction. On the two submodule branches, when check_submodule_move_head() returns\nnon-zero the function does `return ret;` directly without discard_cache_entry(merge).\nThe other error exits in the same function correctly call discard_cache_entry(merge),\nconfirming local ownership. check_submodule_move_head() returns -1 on a reachable\nfailure during submodule checkout/merge; the leaked entry is never inserted into\no->internal.result, so discard_index() cleanup does not reclaim it.","fix":"Call discard_cache_entry(merge) before returning ret on both submodule error\npaths, matching the other error exits in the function.","locations":[{"ref":"unpack-trees.c:2589-2595","code":" 2589 | \t\tif (submodule_from_ce(ce) && file_exists(ce->name)) {\n 2590 | \t\t\tint ret = check_submodule_move_head(ce, NULL,\n 2591 | \t\t\t\t\t\t\t oid_to_hex(&ce->oid),\n 2592 | \t\t\t\t\t\t\t o);\n 2593 | \t\t\tif (ret)\n 2594 | \t\t\t\treturn ret;\n 2595 | \t\t}","lines":7},{"ref":"unpack-trees.c:2618-2624","code":" 2618 | \t\tif (submodule_from_ce(ce) && file_exists(ce->name)) {\n 2619 | \t\t\tint ret = check_submodule_move_head(ce, oid_to_hex(&old->oid),\n 2620 | \t\t\t\t\t\t\t oid_to_hex(&ce->oid),\n 2621 | \t\t\t\t\t\t\t o);\n 2622 | \t\t\tif (ret)\n 2623 | \t\t\t\treturn ret;\n 2624 | \t\t}","lines":7}]},{"id":"MEM_6","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In download_https_uri_to_file(), the cleanup path closes child_in, then calls\nfinish_command(&cp), and only closes child_out *after* finish_command succeeds.\nIf finish_command() returns non-zero, the function returns early and never reaches\nfclose(child_out). This leaks the child_out FILE* (and its underlying file descriptor)\nevery time the git-remote-https child exits with a non-zero status. This sits on the\nremote bundle-uri download path, so a server that causes the helper to fail can\nrepeatedly leak descriptors (one fd per invocation).","fix":"Close child_out on every path before returning, e.g.:\n\ncleanup:\n\tif (child_in)\n\t\tfclose(child_in);\n\tif (child_out)\n\t\tfclose(child_out);\n\tif (finish_command(&cp))\n\t\treturn 1;\n\treturn result;","locations":[{"ref":"bundle-uri.c:377-384","code":" 377 | cleanup:\n 378 | \tif (child_in)\n 379 | \t\tfclose(child_in);\n 380 | \tif (finish_command(&cp))\n 381 | \t\treturn 1;\n 382 | \tif (child_out)\n 383 | \t\tfclose(child_out);\n 384 | \treturn result;","lines":8}]},{"id":"MEM_7","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In add_tree_entries, when a tree entry's object cannot be found the function\nreturns -1 without calling free_tree_buffer(tree) or strbuf_release(&path).\nBoth the parsed tree buffer and the path strbuf leak. The normal exit path does\nrelease both. Reachable on a corrupt or incomplete object store.","fix":"Before `return -1`, call free_tree_buffer(tree); strbuf_release(&path); (or goto\na cleanup label that does both and returns the error code).","locations":[{"ref":"path-walk.c:178-182","code":" 178 | \t\tif (!o) {\n 179 | \t\t\terror(_(\"failed to find object %s\"),\n 180 | \t\t\t oid_to_hex(&entry.oid));\n 181 | \t\t\treturn -1;\n 182 | \t\t}","lines":5},{"ref":"path-walk.c:280-281","code":" 280 | \tfree_tree_buffer(tree);\n 281 | \tstrbuf_release(&path);","lines":2}]},{"id":"MEM_8","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In grab_describe_values(), the error path for a failed pipe_command() leaks both\nthe `out` and `err` strbufs for that iteration. On success `out` is handed off\nvia strbuf_detach() and `err` is released with strbuf_release(&err), but on the\nfailure branch the code only does v->s = xstrdup(\"\") and `continue`, without\nreleasing `out` or `err`. Any bytes captured on stderr (and the `out` buffer)\nare leaked once per failing ref.","fix":"Release the buffers before continuing on the error path:\n\n if (pipe_command(&cmd, NULL, 0, &out, 0, &err, 0) < 0) {\n error(_(\"failed to run 'describe'\"));\n v->s = xstrdup(\"\");\n strbuf_release(&out);\n strbuf_release(&err);\n continue;\n }","locations":[{"ref":"ref-filter.c:2000-2004","code":" 2000 | \t\tif (pipe_command(&cmd, NULL, 0, &out, 0, &err, 0) < 0) {\n 2001 | \t\t\terror(_(\"failed to run 'describe'\"));\n 2002 | \t\t\tv->s = xstrdup(\"\");\n 2003 | \t\t\tcontinue;\n 2004 | \t\t}","lines":5}]},{"id":"MEM_9","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In repack_make_midx_compaction_plan() the local 'step' is set to a WRITE step and its string_list is initialized via string_list_init_dup(&step.u.write) at the top. The step is only appended to the 'steps' array near the end (the ALLOC_GROW at line 810-811). Several error paths jump to the 'out:' label before that append happens (the 'too many objects in MIDX compaction step' errors and the midx_preferred_pack failure). The 'out:' label only releases 'buf' and returns; it never releases step.u.write. The caller (write_midx_incremental) only releases the elements stored in 'steps', so on these error paths the WRITE step's string_list and all the duplicated '.idx' strings accumulated so far are leaked.","fix":"At the 'out:' label, release the not-yet-appended WRITE step if it was initialized but not added to 'steps' (e.g. track whether it was appended, or always string_list_clear(&step.u.write, 0) on the error path before returning).","locations":[{"ref":"repack-midx.c:648-649","code":" 648 | \tstep.type = MIDX_COMPACTION_STEP_WRITE;\n 649 | \tstring_list_init_dup(&step.u.write);","lines":2},{"ref":"repack-midx.c:662-665","code":" 662 | \t\tif (unsigned_add_overflows(step.objects_nr, p->num_objects)) {\n 663 | \t\t\tret = error(_(\"too many objects in MIDX compaction step\"));\n 664 | \t\t\tgoto out;\n 665 | \t\t}","lines":4},{"ref":"repack-midx.c:717-720","code":" 717 | \t\tif (unsigned_add_overflows(step.objects_nr, p->num_objects)) {\n 718 | \t\t\tret = error(_(\"too many objects in MIDX compaction step\"));\n 719 | \t\t\tgoto out;\n 720 | \t\t}","lines":4},{"ref":"repack-midx.c:772-776","code":" 772 | \t\tif (midx_preferred_pack(m, &preferred_pack_idx) < 0) {\n 773 | \t\t\tret = error(_(\"could not find preferred pack for MIDX \"\n 774 | \t\t\t\t \"%s\"), midx_get_checksum_hex(m));\n 775 | \t\t\tgoto out;\n 776 | \t\t}","lines":5},{"ref":"repack-midx.c:927-931","code":" 927 | out:\n 928 | \t*steps_p = steps;\n 929 | \t*steps_nr_p = steps_nr;\n 930 | \n 931 | \tstrbuf_release(&buf);","lines":5}]},{"id":"MEM_10","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In do_reset(), after fill_tree_descriptor() succeeds at line 4070 (allocating\ndesc.buffer), the repo_parse_tree_indirect() failure path at lines 4080-4082 does\n`return error(...)` directly instead of `goto cleanup`. This bypasses the cleanup label\nentirely, leaking desc.buffer, leaking ref_name (a strbuf populated by lookup_label),\nfailing to clear the unpack_tree_opts porcelain data (allocated by\nsetup_unpack_trees_porcelain at line 4054), and failing to roll back the held index lock.\nEvery other failure path in this function correctly uses `goto cleanup`; this one is\ninconsistent.","fix":"Replace with `ret = error(_(\"unable to read tree (%s)\"), oid_to_hex(&oid)); goto cleanup;`.","locations":[{"ref":"sequencer.c:4080-4082","code":" 4080 | \ttree = repo_parse_tree_indirect(the_repository, &oid);\n 4081 | \tif (!tree)\n 4082 | \t\treturn error(_(\"unable to read tree (%s)\"), oid_to_hex(&oid));","lines":3},{"ref":"sequencer.c:4093-4099","code":" 4093 | cleanup:\n 4094 | \tfree((void *)desc.buffer);\n 4095 | \tif (ret < 0)\n 4096 | \t\trollback_lock_file(&lock);\n 4097 | \tstrbuf_release(&ref_name);\n 4098 | \tclear_unpack_trees_porcelain(&unpack_tree_opts);\n 4099 | \treturn ret;","lines":7}]},{"id":"MEM_11","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In reread_todo_if_changed(), if read_populate_todo() fails the function does `return -1`\nwithout releasing `buf`, which was filled by strbuf_read_file_or_whine() and holds a heap\nallocation. The success path releases it correctly. This is the kind of error-path leak\nGit's leak-checker CI flags, even though in practice the caller propagates -1 and the\nrebase aborts shortly after.","fix":"strbuf_release(&buf) before the `return -1`, or restructure with a single cleanup/return.","locations":[{"ref":"sequencer.c:4913-4925","code":" 4913 | \tif (strbuf_read_file_or_whine(&buf, get_todo_path(opts)) < 0)\n 4914 | \t\treturn -1;\n 4915 | \toffset = get_item_line_offset(todo_list, todo_list->current + 1);\n 4916 | \tif (buf.len != todo_list->buf.len - offset ||\n 4917 | \t memcmp(buf.buf, todo_list->buf.buf + offset, buf.len)) {\n 4918 | \t\t/* Reread the todo file if it has changed. */\n 4919 | \t\ttodo_list_release(todo_list);\n 4920 | \t\tif (read_populate_todo(r, todo_list, opts))\n 4921 | \t\t\treturn -1; /* message was printed */\n 4922 | \t\t/* `current` will be incremented on return */\n 4923 | \t\ttodo_list->current = -1;\n 4924 | \t}\n 4925 | \tstrbuf_release(&buf);","lines":13}]},{"id":"MEM_12","type":"memory_leak","type_label":"Memory Leak","severity":"low","easy":false,"description":"In `push_update_ref_status()`'s `option` handling, a malicious/buggy remote helper can send the same per-report option key more than once for a single report (e.g. two `option refname X` lines, or repeated `option old-oid`/`option new-oid`). Each assignment `state->report->ref_name = xstrdup_or_null(val)` and the `oiddup(...)` assignments for old/new oid allocate fresh memory and overwrite the previous pointer without freeing it, leaking it. The number of duplicate option lines is fully attacker-controlled within a single push. The identical pattern exists in `send-pack.c:220-229` (`receive_status`, report-status-v2 parsing), where a remote server controls the report stream. Impact is bounded by the short-lived process and the per-line packet-size cap, so it is non-amplifiable, but it is a real attacker-driven leak.","fix":"Free the existing value before reassigning, e.g. `FREE_AND_NULL(report->ref_name)` before `report->ref_name = xstrdup_or_null(val)`, and `free(report->old_oid)` / `free(report->new_oid)` before the respective `oiddup` assignments (in both files).","locations":[{"ref":"transport-helper.c:826-835","code":" 826 | \t\tif (!strcmp(key, \"refname\"))\n 827 | \t\t\tstate->report->ref_name = xstrdup_or_null(val);\n 828 | \t\telse if (!strcmp(key, \"old-oid\") && val &&\n 829 | \t\t\t !parse_oid_hex(val, &old_oid, &val))\n 830 | \t\t\tstate->report->old_oid = oiddup(&old_oid);\n 831 | \t\telse if (!strcmp(key, \"new-oid\") && val &&\n 832 | \t\t\t !parse_oid_hex(val, &new_oid, &val))\n 833 | \t\t\tstate->report->new_oid = oiddup(&new_oid);\n 834 | \t\telse if (!strcmp(key, \"forced-update\"))\n 835 | \t\t\tstate->report->forced_update = 1;","lines":10},{"ref":"send-pack.c:220-229","code":" 220 | \t\t\tif (!strcmp(key, \"refname\"))\n 221 | \t\t\t\treport->ref_name = xstrdup_or_null(val);\n 222 | \t\t\telse if (!strcmp(key, \"old-oid\") && val &&\n 223 | \t\t\t\t !parse_oid_hex_algop(val, &old_oid, &val, r->hash_algo))\n 224 | \t\t\t\treport->old_oid = oiddup(&old_oid);\n 225 | \t\t\telse if (!strcmp(key, \"new-oid\") && val &&\n 226 | \t\t\t\t !parse_oid_hex_algop(val, &new_oid, &val, r->hash_algo))\n 227 | \t\t\t\treport->new_oid = oiddup(&new_oid);\n 228 | \t\t\telse if (!strcmp(key, \"forced-update\"))\n 229 | \t\t\t\treport->forced_update = 1;","lines":10}]},{"id":"OVF_1","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"high","easy":false,"description":"unpack-objects unpack_one() decodes the object size into a `size_t size` with a\ncorrect `bitsizeof(size_t)` shift guard, but then passes that value to functions\nwhose parameter is `unsigned long`: unpack_non_delta_entry(..., unsigned long\nsize, ...), unpack_delta_entry(..., unsigned long delta_size, ...), stream_blob(\nunsigned long size, ...), and get_data(unsigned long size). On LLP64 platforms\n(notably 64-bit Windows) `unsigned long` is 32-bit, so the just-validated 64-bit\nsize is silently truncated at these call sites. This is exactly the >4GB\ntruncation that commit f2063855fb set out to eliminate; the fix updated\nunpack_one's local but left the downstream signatures as `unsigned long`, so the\ntruncation simply moves one call frame later. A packfile carrying an object whose\nsize >= 4GB, when unpacked on 64-bit Windows (fetch into a repo using\nunpack-objects, i.e. no index-pack), will allocate/inflate against a truncated\nsize, corrupting the written object or causing an inflate length mismatch.","fix":"Change the size-carrying parameters and locals from `unsigned long` to `size_t`\nconsistently along the path: get_data(size_t), unpack_non_delta_entry(...,\nsize_t size, ...), unpack_delta_entry(..., size_t delta_size, ...),\nstream_blob(size_t size, ...), resolve_delta/write_object/added_object/\nadd_delta_to_list size params, and the delta_info.size / obj_buffer.size /\ndelta_size / base_size / result_size locals, matching the canonical decoder in\npackfile.c.","locations":[{"ref":"builtin/unpack-objects.c:117","code":" 117 | static void *get_data(unsigned long size)","lines":1},{"ref":"builtin/unpack-objects.c:352-359","code":" 352 | static void unpack_non_delta_entry(enum object_type type, unsigned long size,\n 353 | \t\t\t\t unsigned nr)\n 354 | {\n 355 | \tvoid *buf = get_data(size);\n 356 | \n 357 | \tif (buf)\n 358 | \t\twrite_object(nr, type, buf, size);\n 359 | }","lines":8},{"ref":"builtin/unpack-objects.c:388","code":" 388 | static void stream_blob(unsigned long size, unsigned nr)","lines":1},{"ref":"builtin/unpack-objects.c:434-438","code":" 434 | static void unpack_delta_entry(enum object_type type, unsigned long delta_size,\n 435 | \t\t\t unsigned nr)\n 436 | {\n 437 | \tvoid *delta_data, *base;\n 438 | \tunsigned long base_size;","lines":5},{"ref":"builtin/unpack-objects.c:557-568","code":" 557 | \t\t\tstream_blob(size, nr);\n 558 | \t\t\treturn;\n 559 | \t\t}\n 560 | \t\t/* fallthrough */\n 561 | \tcase OBJ_COMMIT:\n 562 | \tcase OBJ_TREE:\n 563 | \tcase OBJ_TAG:\n 564 | \t\tunpack_non_delta_entry(type, size, nr);\n 565 | \t\treturn;\n 566 | \tcase OBJ_REF_DELTA:\n 567 | \tcase OBJ_OFS_DELTA:\n 568 | \t\tunpack_delta_entry(type, size, nr);","lines":12}]},{"id":"OVF_2","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"high","easy":true,"description":"In coalesce_lines(), a parent bit is set into the unsigned-long parent_map field\nusing a signed-int shift: `baseend->parent_map |= 1<<parent;`. `parent` is the\nparent index. For a merge with 32 or more parents (octopus merges can have\narbitrarily many parents), `1<<parent` shifts a signed int by >= 31, which is\nundefined behavior, and for parent >= 32 cannot produce the intended bit at all,\nso the wrong (or no) parent bit gets recorded in parent_map. Every other parent_map\n/ flag bit operation in this file correctly uses an unsigned-long literal:\nappend_lost uses `1UL<<n`, combine_diff uses `1UL<<n` for nmask, dump_sline uses\n`1UL<<j`, reuse_combine_diff uses `1UL<<i`/`1UL<<j`. This one site inconsistently\ndrops the UL, corrupting coalesced lost-line parent attribution for high parent\nindices.","fix":"Change `baseend->parent_map |= 1<<parent;` to `baseend->parent_map |= 1UL<<parent;`.","locations":[{"ref":"combine-diff.c:249","code":" 249 | \t\t\tbaseend->parent_map |= 1<<parent;","lines":1}]},{"id":"OVF_3","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"high","easy":false,"description":"Stack buffer overflow via unbounded abbreviated-OID prefix in repo_for_each_abbrev().\n\nrepo_for_each_abbrev() takes a caller-supplied prefix string and does:\n\n .prefix_hex_len = strlen(prefix),\n ...\n parse_oid_prefix(prefix, opts.prefix_hex_len, algo, NULL, &prefix_oid)\n\nIt performs NO length validation, unlike the other entry point\ninit_object_disambiguation(), which rejects len > GIT_MAX_HEXSZ\n(object-name.c:273). parse_oid_prefix() then writes into the fixed-size\non-stack struct object_id:\n\n oid_out->hash[i >> 1] |= val; (object-name.c:256)\n\nfor i from 0 to len-1, with no bound against sizeof(hash) (GIT_MAX_RAWSZ).\nThe only early exit is a non-hex character (returns -1), so an attacker\nneed only supply > 2*GIT_MAX_RAWSZ (e.g. > 64 for SHA-256) all-hex\ncharacters to write past prefix_oid.hash and smash the stack.\n\nThis is directly reachable from user input: `git rev-parse --disambiguate=<arg>`\nforwards `arg` straight to repo_for_each_abbrev() with no validation\n(builtin/rev-parse.c:938-940). Example:\n\n git rev-parse --disambiguate=$(python3 -c 'print(\"a\"*200)')\n\nThe downstream loose-object path oidtree_each() only guards this with an\nassert() (oidtree.c:124), which is compiled out under NDEBUG, so it does\nnot save us either.","fix":"Validate the prefix length in repo_for_each_abbrev() before calling\nparse_oid_prefix(), mirroring init_object_disambiguation():\n\n size_t prefix_hex_len = strlen(prefix);\n if (prefix_hex_len > GIT_MAX_HEXSZ)\n return -1;\n\n(Optionally also reject < MINIMUM_ABBREV.) Independently, parse_oid_prefix()\nshould defensively cap writes to the hash[] array, and the oidtree_each()\nassert() should be a real runtime check.","locations":[{"ref":"object-name.c:548-569","code":" 548 | int repo_for_each_abbrev(struct repository *r, const char *prefix,\n 549 | \t\t\t const struct git_hash_algo *algo,\n 550 | \t\t\t each_abbrev_fn fn, void *cb_data)\n 551 | {\n 552 | \tstruct object_id prefix_oid = { 0 };\n 553 | \tstruct odb_for_each_object_options opts = {\n 554 | \t\t.prefix = &prefix_oid,\n 555 | \t\t.prefix_hex_len = strlen(prefix),\n 556 | \t};\n 557 | \tstruct oid_array collect = OID_ARRAY_INIT;\n 558 | \tint ret;\n 559 | \n 560 | \tif (parse_oid_prefix(prefix, opts.prefix_hex_len, algo, NULL, &prefix_oid) < 0)\n 561 | \t\treturn -1;\n 562 | \n 563 | \tif (odb_for_each_object_ext(r->objects, NULL, repo_collect_ambiguous, &collect, &opts) < 0)\n 564 | \t\treturn -1;\n 565 | \n 566 | \tret = oid_array_for_each_unique(&collect, fn, cb_data);\n 567 | \toid_array_clear(&collect);\n 568 | \treturn ret;\n 569 | }","lines":22},{"ref":"object-name.c:232-266","code":" 232 | static int parse_oid_prefix(const char *name, int len,\n 233 | \t\t\t const struct git_hash_algo *algo,\n 234 | \t\t\t char *hex_out,\n 235 | \t\t\t struct object_id *oid_out)\n 236 | {\n 237 | \tfor (int i = 0; i < len; i++) {\n 238 | \t\tunsigned char c = name[i];\n 239 | \t\tunsigned char val;\n 240 | \t\tif (c >= '0' && c <= '9') {\n 241 | \t\t\tval = c - '0';\n 242 | \t\t} else if (c >= 'a' && c <= 'f') {\n 243 | \t\t\tval = c - 'a' + 10;\n 244 | \t\t} else if (c >= 'A' && c <='F') {\n 245 | \t\t\tval = c - 'A' + 10;\n 246 | \t\t\tc -= 'A' - 'a';\n 247 | \t\t} else {\n 248 | \t\t\treturn -1;\n 249 | \t\t}\n 250 | \n 251 | \t\tif (hex_out)\n 252 | \t\t\thex_out[i] = c;\n 253 | \t\tif (oid_out) {\n 254 | \t\t\tif (!(i & 1))\n 255 | \t\t\t\tval <<= 4;\n 256 | \t\t\toid_out->hash[i >> 1] |= val;\n 257 | \t\t}\n 258 | \t}\n 259 | \n 260 | \tif (hex_out)\n 261 | \t\thex_out[len] = '\\0';\n 262 | \tif (oid_out)\n 263 | \t\toid_out->algo = algo ? hash_algo_by_ptr(algo) : GIT_HASH_UNKNOWN;\n 264 | \n 265 | \treturn 0;\n 266 | }","lines":35},{"ref":"builtin/rev-parse.c:938-940","code":" 938 | \t\t\tif (skip_prefix(arg, \"--disambiguate=\", &arg)) {\n 939 | \t\t\t\trepo_for_each_abbrev(the_repository, arg, the_hash_algo,\n 940 | \t\t\t\t\t\t show_abbrev, NULL);","lines":3},{"ref":"oidtree.c:123-128","code":" 123 | \tsize_t klen = prefix_hex_len / 2;\n 124 | \tassert(prefix_hex_len <= GIT_MAX_HEXSZ);\n 125 | \n 126 | \tif (prefix_hex_len & 1) {\n 127 | \t\tdata.last_byte = prefix->hash[klen];\n 128 | \t\tdata.last_nibble_at = &klen;","lines":6}]},{"id":"OVF_4","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"high","easy":false,"description":"While parsing the pseudo-merge extension in load_bitmap_header(), the\npseudo-merge `commits` table pointer and its element count are taken from the\nfile without bounds validation: `index->pseudo_merges.commits = ext +\nget_be64(index_end - 16)` and `index->pseudo_merges.commits_nr =\nget_be32(index_end - 20)`. The only validation performed checks the\npseudo_merges.v[] offset array (nr*8 + 24 <= table_size); it does NOT validate\nthe commits offset nor commits_nr. A crafted offset can place `commits` anywhere\n(before the map, past index_end), and commits_nr can be arbitrarily large. These\nare later consumed verbatim by find_pseudo_merge() in pseudo-merge.c via\n`bsearch(&pos, pm->commits, pm->commits_nr, PSEUDO_MERGE_COMMIT_RAWSZ=16, ...)`,\nreading up to commits_nr*16 bytes from an unvalidated pointer - an out-of-bounds\nread reachable from a malicious .bitmap during reachability queries.","fix":"After reading commits_offset and commits_nr, validate that the entire commits\nregion lies within the mapped extension: require commits_offset and\ncommits_offset + st_mult(commits_nr, PSEUDO_MERGE_COMMIT_RAWSZ) to fall within\n[ext - map, table_size], and error out otherwise before storing the pointer.","locations":[{"ref":"pack-bitmap.c:301-313","code":" 301 | \t\t\tif (git_env_bool(\"GIT_TEST_USE_PSEUDO_MERGES\", 1)) {\n 302 | \t\t\t\tconst unsigned char *ext = (index_end - table_size);\n 303 | \n 304 | \t\t\t\tindex->pseudo_merges.map = index->map;\n 305 | \t\t\t\tindex->pseudo_merges.map_size = index->map_size;\n 306 | \t\t\t\tindex->pseudo_merges.commits = ext + get_be64(index_end - 16);\n 307 | \t\t\t\tindex->pseudo_merges.commits_nr = get_be32(index_end - 20);\n 308 | \t\t\t\tindex->pseudo_merges.nr = get_be32(index_end - 24);\n 309 | \n 310 | \t\t\t\tif (st_add(st_mult(index->pseudo_merges.nr,\n 311 | \t\t\t\t\t\t sizeof(uint64_t)),\n 312 | \t\t\t\t\t 24) > table_size)\n 313 | \t\t\t\t\treturn error(_(\"corrupted bitmap index file, pseudo-merge table too short\"));","lines":13}]},{"id":"OVF_5","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"high","easy":false,"description":"In the commit-lookup-table code path (BITMAP_OPT_LOOKUP_TABLE, the default for\nfreshly written .bitmap files), an attacker-controlled 64-bit on-disk offset\n(triplet.offset, read via get_be64 from the lookup table) is assigned directly\ninto bitmap_git->map_pos (a size_t) and \"validated\" only with the unsigned\nsubtraction `map_size - map_pos < bitmap_header_size`. Because both operands are\nunsigned, if map_pos > map_size the subtraction wraps to a huge value that is not\n< 6, so the bounds check passes. Control then flows into read_bitmap_1() ->\nread_bitmap(), which computes `map + *map_pos` (a pointer far past the mmap) and\n`map_size - *map_pos` (a wrapped-around huge length) and hands both to\newah_read_mmap, producing a wild out-of-bounds read. Both the xor-chain offset\n(line 953) and the final commit offset (line 973) are affected. Reachable\nwhenever a maliciously crafted .bitmap is read during ordinary reachability\nqueries (including serving fetch/clone).","fix":"Before assigning an absolute on-disk offset to map_pos, reject any value that is\nout of range, e.g. `if (offset >= bitmap_git->map_size || bitmap_git->map_size -\noffset < bitmap_header_size) goto corrupt;` (and likewise for xor_item->offset).\nAdditionally harden read_bitmap() itself: at the top add\n`if (*map_pos > map_size) { error(...); ewah_pool_free(b); return NULL; }` so an\nout-of-range position can never reach the `map_size - *map_pos` subtraction.","locations":[{"ref":"pack-bitmap.c:951-978","code":" 951 | \twhile (xor_items_nr) {\n 952 | \t\txor_item = &xor_items[xor_items_nr - 1];\n 953 | \t\tbitmap_git->map_pos = xor_item->offset;\n 954 | \t\tif (bitmap_git->map_size - bitmap_git->map_pos < bitmap_header_size) {\n 955 | \t\t\terror(_(\"corrupt ewah bitmap: truncated header for bitmap of commit \\\"%s\\\"\"),\n 956 | \t\t\t\toid_to_hex(&xor_item->oid));\n 957 | \t\t\tgoto corrupt;\n 958 | \t\t}\n 959 | \n 960 | \t\tentry_map_pos = bitmap_git->map_pos;\n 961 | \t\tbitmap_git->map_pos += sizeof(uint32_t) + sizeof(uint8_t);\n 962 | \t\txor_flags = read_u8(bitmap_git->map, &bitmap_git->map_pos);\n 963 | \t\tbitmap = read_bitmap_1(bitmap_git);\n 964 | \n 965 | \t\tif (!bitmap)\n 966 | \t\t\tgoto corrupt;\n 967 | \n 968 | \t\txor_bitmap = store_bitmap(bitmap_git, bitmap, &xor_item->oid,\n 969 | \t\t\t\t\t xor_bitmap, xor_flags, entry_map_pos);\n 970 | \t\txor_items_nr--;\n 971 | \t}\n 972 | \n 973 | \tbitmap_git->map_pos = offset;\n 974 | \tif (bitmap_git->map_size - bitmap_git->map_pos < bitmap_header_size) {\n 975 | \t\terror(_(\"corrupt ewah bitmap: truncated header for bitmap of commit \\\"%s\\\"\"),\n 976 | \t\t\toid_to_hex(oid));\n 977 | \t\tgoto corrupt;\n 978 | \t}","lines":28},{"ref":"pack-bitmap.c:172-189","code":" 172 | struct ewah_bitmap *read_bitmap(const unsigned char *map,\n 173 | \t\t\t\tsize_t map_size, size_t *map_pos)\n 174 | {\n 175 | \tstruct ewah_bitmap *b = ewah_pool_new();\n 176 | \n 177 | \tssize_t bitmap_size = ewah_read_mmap(b, map + *map_pos,\n 178 | \t\t\t\t\t map_size - *map_pos);\n 179 | \n 180 | \tif (bitmap_size < 0) {\n 181 | \t\terror(_(\"failed to load bitmap index (corrupted?)\"));\n 182 | \t\tewah_pool_free(b);\n 183 | \t\treturn NULL;\n 184 | \t}\n 185 | \n 186 | \t*map_pos += bitmap_size;\n 187 | \n 188 | \treturn b;\n 189 | }","lines":18}]},{"id":"OVF_6","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"high","easy":false,"description":"create_pack_revindex()'s v2 (index_version > 1) branch walks the .idx 64-bit\nlarge-offset table via `off_64` and dereferences get_be64(off_64) for every\nentry whose 32-bit offset has the 0x80000000 bit set, advancing off_64 += 2 each\ntime, with NO bounds check against the mmap'd index size. The analogous\nnth_packed_object_offset() in packfile.c guards every 64-bit read with\ncheck_pack_index_ptr(). The .idx size validation in load_idx() only guarantees\nroom for at most nr-1 large-offset entries (max_size = min_size + (nr-1)*8). A\ncrafted .idx that sets the high bit on all nr 32-bit offsets causes the loop to\nread nr 8-byte entries from a table sized for at most nr-1, so the final\nget_be64 reads 8 bytes past the validated large-offset region. This is an\nout-of-bounds read of attacker-controlled on-disk data, reachable whenever the\nrevindex is built in memory (the common case when no .rev file exists), and via\ngit verify-pack / git fsck over a repo containing a crafted pack+idx.","fix":"Bound-check each large-offset read before dereferencing, mirroring\nnth_packed_object_offset(): before `get_be64(off_64)` verify\n`(const char *)off_64 + 8 <= (const char *)p->index_data + p->index_size`\n(e.g. via check_pack_index_ptr), and error instead of reading past the mapping.","locations":[{"ref":"pack-revindex.c:143-156","code":" 143 | \tif (p->index_version > 1) {\n 144 | \t\tconst uint32_t *off_32 =\n 145 | \t\t\t(uint32_t *)(index + 8 + (size_t)p->num_objects * (hashsz + 4));\n 146 | \t\tconst uint32_t *off_64 = off_32 + p->num_objects;\n 147 | \t\tfor (i = 0; i < num_ent; i++) {\n 148 | \t\t\tconst uint32_t off = ntohl(*off_32++);\n 149 | \t\t\tif (!(off & 0x80000000)) {\n 150 | \t\t\t\tp->revindex[i].offset = off;\n 151 | \t\t\t} else {\n 152 | \t\t\t\tp->revindex[i].offset = get_be64(off_64);\n 153 | \t\t\t\toff_64 += 2;\n 154 | \t\t\t}\n 155 | \t\t\tp->revindex[i].nr = i;\n 156 | \t\t}","lines":14}]},{"id":"OVF_7","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","easy":false,"description":"The bitmapped-packs (BTMP) chunk length m->chunk_bitmapped_packs_len is read but\nnever validated against the expected size num_packs *\nMIDX_CHUNK_BITMAPPED_PACKS_WIDTH (8 bytes/pack). Every other parsed chunk\nvalidates its length (OIDF == 4*256, OIDL == hash_len*num_objects, OOFF ==\nnum_objects*8, LOFF bounds-checked at read time), but BTMP does not - the chunk\nTOC check in chunk-format.c only confirms the chunk lies within the file, not\nthat it has the consumer-assumed size. nth_bitmapped_pack() then indexes the\nchunk via get_be32((char *)chunk_bitmapped_packs + 8 * local_pack_int_id ...)\nwith local_pack_int_id up to num_packs-1 and no per-access bound. A crafted MIDX\nwith num_packs large but a short BTMP chunk yields an out-of-bounds read of\nmmap'd memory past the chunk. Severity is medium because the BTMP path is only\nexercised by the MIDX-bitmap traversal code, not every MIDX open.","fix":"In load_multi_pack_index_one(), after pairing the BTMP chunk validate\n`if (m->chunk_bitmapped_packs && m->chunk_bitmapped_packs_len !=\nst_mult(m->num_packs, MIDX_CHUNK_BITMAPPED_PACKS_WIDTH)) die/error(...)`, or use\na read_chunk callback (like midx_read_oid_lookup) that enforces the size.\nOptionally also bounds-check local_pack_int_id inside nth_bitmapped_pack.","locations":[{"ref":"midx.c:189-192","code":" 189 | \tif (git_env_bool(\"GIT_TEST_MIDX_READ_BTMP\", 1))\n 190 | \t\tpair_chunk(cf, MIDX_CHUNKID_BITMAPPEDPACKS,\n 191 | \t\t\t (const unsigned char **)&m->chunk_bitmapped_packs,\n 192 | \t\t\t &m->chunk_bitmapped_packs_len);","lines":4},{"ref":"midx.c:498-519","code":" 498 | int nth_bitmapped_pack(struct multi_pack_index *m,\n 499 | \t\t struct bitmapped_pack *bp, uint32_t pack_int_id)\n 500 | {\n 501 | \tuint32_t local_pack_int_id = midx_for_pack(&m, pack_int_id);\n 502 | \n 503 | \tif (!m->chunk_bitmapped_packs)\n 504 | \t\treturn error(_(\"MIDX does not contain the BTMP chunk\"));\n 505 | \n 506 | \tif (prepare_midx_pack(m, pack_int_id))\n 507 | \t\treturn error(_(\"could not load bitmapped pack %\"PRIu32), pack_int_id);\n 508 | \n 509 | \tbp->p = m->packs[local_pack_int_id];\n 510 | \tbp->bitmap_pos = get_be32((char *)m->chunk_bitmapped_packs +\n 511 | \t\t\t\t MIDX_CHUNK_BITMAPPED_PACKS_WIDTH * local_pack_int_id);\n 512 | \tbp->bitmap_nr = get_be32((char *)m->chunk_bitmapped_packs +\n 513 | \t\t\t\t MIDX_CHUNK_BITMAPPED_PACKS_WIDTH * local_pack_int_id +\n 514 | \t\t\t\t sizeof(uint32_t));\n 515 | \tbp->pack_int_id = pack_int_id;\n 516 | \tbp->from_midx = m;\n 517 | \n 518 | \treturn 0;\n 519 | }","lines":22}]},{"id":"OVF_8","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","easy":false,"description":"load_index_extensions() (the extension parsing loop, run on the main thread when\nsingle-threaded) computes extsize = get_be32(mmap + src_offset + 4) and then does\nsrc_offset += 8; src_offset += extsize; with no check that src_offset + 8 +\nextsize stays within mmap_size or that it does not wrap. The loop condition\nsrc_offset <= mmap_size - rawsz - 8 is re-checked at the top, but a large extsize\ncan advance src_offset far past mmap_size in one step, and the extension payload\npointer (mmap + src_offset + 8, length extsize) is handed to read_index_extension\n-> cache_tree_read / resolve_undo_read / read_untracked_extension / etc. pointing\noutside the mapping. Notably read_eoie_extension() in this same file explicitly\nguards this exact case (\"verify the extension size isn't so large it will wrap\naround\", src_offset + 8 + extsize < src_offset), but the live parsing loop that\nactually dereferences the data has no such guard.","fix":"Before calling read_index_extension, validate that src_offset + 8 <= mmap_size,\nthat extsize <= mmap_size - src_offset - 8, and that the addition does not wrap\n(mirror the check already present in read_eoie_extension). die(\"index file\ncorrupt\") on violation.","locations":[{"ref":"read-cache.c:1999-2024","code":" 1999 | static void *load_index_extensions(void *_data)\n 2000 | {\n 2001 | \tstruct load_index_extensions *p = _data;\n 2002 | \tunsigned long src_offset = p->src_offset;\n 2003 | \n 2004 | \twhile (src_offset <= p->mmap_size - the_hash_algo->rawsz - 8) {\n 2005 | \t\t/* After an array of active_nr index entries,\n 2006 | \t\t * there can be arbitrary number of extended\n 2007 | \t\t * sections, each of which is prefixed with\n 2008 | \t\t * extension name (4-byte) and section length\n 2009 | \t\t * in 4-byte network byte order.\n 2010 | \t\t */\n 2011 | \t\tuint32_t extsize = get_be32(p->mmap + src_offset + 4);\n 2012 | \t\tif (read_index_extension(p->istate,\n 2013 | \t\t\t\t\t p->mmap + src_offset,\n 2014 | \t\t\t\t\t p->mmap + src_offset + 8,\n 2015 | \t\t\t\t\t extsize) < 0) {\n 2016 | \t\t\tmunmap((void *)p->mmap, p->mmap_size);\n 2017 | \t\t\tdie(_(\"index file corrupt\"));\n 2018 | \t\t}\n 2019 | \t\tsrc_offset += 8;\n 2020 | \t\tsrc_offset += extsize;\n 2021 | \t}\n 2022 | \n 2023 | \treturn NULL;\n 2024 | }","lines":26}]},{"id":"OVF_9","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"medium","easy":false,"description":"read_link_extension() stores the byte count returned by ewah_read_mmap() (which\nreturns ssize_t) into a plain `int ret`, then uses it for pointer arithmetic\n(data += ret; sz -= ret;) and compares it to the unsigned long sz (ret != sz).\nThe \"link\" extension is untrusted on-disk data. A bitmap whose consumed length\nexceeds INT_MAX truncates/sign-flips on the ssize_t->int conversion, which can\nbe misread as the error path or, worse, advance `data`/`sz` incorrectly so the\nsecond ewah_read_mmap parses from a wrong offset. The final ret != sz comparison\nalso mixes signed int with unsigned long. In practice ewah_read_mmap bounds its\nreads to the supplied sz, but the type is wrong relative to the API contract.","fix":"Declare ret as ssize_t, check ret < 0, advance with (size_t)ret, and compare\n(size_t)ret != sz, matching ewah_read_mmap's signature.","locations":[{"ref":"split-index.c:31-53","code":" 31 | \tint ret;\n 32 | \n 33 | \tif (sz < algo->rawsz)\n 34 | \t\treturn error(\"corrupt link extension (too short)\");\n 35 | \tsi = init_split_index(istate);\n 36 | \toidread(&si->base_oid, data, algo);\n 37 | \tdata += algo->rawsz;\n 38 | \tsz -= algo->rawsz;\n 39 | \tif (!sz)\n 40 | \t\treturn 0;\n 41 | \tsi->delete_bitmap = ewah_new();\n 42 | \tret = ewah_read_mmap(si->delete_bitmap, data, sz);\n 43 | \tif (ret < 0)\n 44 | \t\treturn error(\"corrupt delete bitmap in link extension\");\n 45 | \tdata += ret;\n 46 | \tsz -= ret;\n 47 | \tsi->replace_bitmap = ewah_new();\n 48 | \tret = ewah_read_mmap(si->replace_bitmap, data, sz);\n 49 | \tif (ret < 0)\n 50 | \t\treturn error(\"corrupt replace bitmap in link extension\");\n 51 | \tif (ret != sz)\n 52 | \t\treturn error(\"garbage at the end of link extension\");\n 53 | \treturn 0;","lines":23}]},{"id":"OVF_10","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"map_line_number() performs the line-number remapping\n((line_number - source_start) * 2 + 1) * destination_length / (source_length * 2)\n+ destination_start entirely in 'int'. All operands (line_number, source_start,\ndestination_length, source_length) are int and derived from diff chunk sizes of\nthe blamed file. For a very large file/chunk (hundreds of millions of lines)\nthe multiply (line_number - source_start) * 2 * destination_length overflows\n32-bit int, producing a garbage destination line that is then used to index\nfingerprint arrays in the line-matching path. Requires a pathologically large\nfile, hence low severity, but it is a genuine signed-overflow / out-of-range\nindex hazard.","fix":"Perform the arithmetic in a wider type (cast to long / int64_t before the\nmultiply) and assert the result fits in int before returning.","locations":[{"ref":"blame.c:552-559","code":" 552 | static int map_line_number(int line_number,\n 553 | \tconst struct line_number_mapping *mapping)\n 554 | {\n 555 | \treturn ((line_number - mapping->source_start) * 2 + 1) *\n 556 | \t mapping->destination_length /\n 557 | \t (mapping->source_length * 2) +\n 558 | \t mapping->destination_start;\n 559 | }","lines":8}]},{"id":"OVF_11","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"murmur3_seeded_v1/v2 take a size_t len but compute the body word count and loop\nindex as int: `int len4 = len / sizeof(uint32_t)` and `int i`. For len >\nINT_MAX*4 (~8 GiB) len4 overflows to negative and the body loop (and the 4*i\nindex expressions) misbehave. Inputs here are changed-path strings, far below\nthis bound in practice, so it is a latent robustness issue rather than presently\nreachable.","fix":"Use size_t for len4 and the loop index and index with size_t arithmetic.","locations":[{"ref":"bloom.c:118-122","code":" 118 | \tint i;\n 119 | \tuint32_t k1 = 0;\n 120 | \tconst char *tail;\n 121 | \n 122 | \tint len4 = len / sizeof(uint32_t);","lines":5},{"ref":"bloom.c:175-179","code":" 175 | \tint i;\n 176 | \tuint32_t k1 = 0;\n 177 | \tconst char *tail;\n 178 | \n 179 | \tint len4 = len / sizeof(uint32_t);","lines":5}]},{"id":"OVF_12","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"In unpack_all() the loop counter is `int i` iterated against `unsigned\nnr_objects` where nr_objects = get_be32(hdr) can be up to ~4.29 billion. Once i\npasses INT_MAX the i++ is signed-integer overflow (undefined behavior).\nwrite_rest() correctly uses `unsigned i`. In practice the preceding\nCALLOC_ARRAY(obj_list, nr_objects) (~170GB at the limit) would fail before the\nloop is reached, so the UB is not realistically reachable, but the type mismatch\nis a latent correctness issue. The analogous index-pack.c stores get_be32 into a\nsigned global `int nr_objects` (line 394), which can likewise go negative for a\nheader claiming > INT_MAX objects (largely self-protecting because st_add and the\nsigned loop bounds reject or skip).","fix":"Declare the counter as `unsigned i` (or uint32_t), matching nr_objects and write_rest(); consider making nr_objects unsigned in index-pack.c.","locations":[{"ref":"builtin/unpack-objects.c:581","code":" 581 | \tint i;","lines":1},{"ref":"builtin/unpack-objects.c:600-603","code":" 600 | \tfor (i = 0; i < nr_objects; i++) {\n 601 | \t\tunpack_one(i);\n 602 | \t\tdisplay_progress(progress, i + 1);\n 603 | \t}","lines":4},{"ref":"builtin/index-pack.c:394","code":" 394 | \tnr_objects = get_be32(hdr);","lines":1}]},{"id":"OVF_13","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"parse_int() in cache-tree.c reads untrusted digits into a signed int with no\noverflow guard (ret *= 10; ret += *s - '0') and applies a sign from a run of\nleading '-' characters. A long digit run is signed-integer overflow (undefined\nbehavior). The value flows into it->subtree_alloc = subtree_nr + 2 and\nCALLOC_ARRAY: a negative subtree_nr sign-extends to an enormous size_t allocation\nrequest (process abort), and a wrapped subtree_nr desynchronizes the subtree read\nloop from it->subtree_nr (caught only afterward by die()).","fix":"Use a checked parse: detect multiplication/addition overflow, reject negative\nsubtree_nr, and disallow multi-'-' sign runs.","locations":[{"ref":"cache-tree.c:552-581","code":" 552 | static int parse_int(const char **ptr, unsigned long *len_p, int *out)\n 553 | {\n 554 | \tconst char *s = *ptr;\n 555 | \tunsigned long len = *len_p;\n 556 | \tint ret = 0;\n 557 | \tint sign = 1;\n 558 | \n 559 | \twhile (len && *s == '-') {\n 560 | \t\tsign *= -1;\n 561 | \t\ts++;\n 562 | \t\tlen--;\n 563 | \t}\n 564 | \n 565 | \twhile (len) {\n 566 | \t\tif (!isdigit(*s))\n 567 | \t\t\tbreak;\n 568 | \t\tret *= 10;\n 569 | \t\tret += *s - '0';\n 570 | \t\ts++;\n 571 | \t\tlen--;\n 572 | \t}\n 573 | \n 574 | \tif (s == *ptr)\n 575 | \t\treturn -1;\n 576 | \n 577 | \t*ptr = s;\n 578 | \t*len_p = len;\n 579 | \t*out = sign * ret;\n 580 | \treturn 0;\n 581 | }","lines":30},{"ref":"cache-tree.c:636-637","code":" 636 | \tit->subtree_alloc = subtree_nr + 2;\n 637 | \tCALLOC_ARRAY(it->down, it->subtree_alloc);","lines":2}]},{"id":"OVF_14","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"A commit count exceeding INT_MAX is truncated when forwarded across the merge-base\ncall chain. paint_down_to_common and merge_bases_many declare the twos count as\n`int n`, but repo_get_merge_bases_many / get_merge_bases_many_0 carry it as\n`size_t n` and remove_redundant_no_gen passes a size_t (`filled`) into the int\nparameter. A size_t count > INT_MAX narrows to a negative/garbage int, corrupting\nloop bounds and risking OOB reads of twos[]. Requires > 2^31 commits, so it is\nlatent, but the size_t->int narrowing across these boundaries is a genuine\ntruncation.","fix":"Use size_t consistently for the count parameter through paint_down_to_common,\nmerge_bases_many and get_merge_bases_many_0, and change the internal `int i` loop\ncounters to size_t.","locations":[{"ref":"commit-reach.c:100-102","code":" 100 | static int paint_down_to_common(struct repository *r,\n 101 | \t\t\t\tstruct commit *one, int n,\n 102 | \t\t\t\tstruct commit **twos,","lines":3},{"ref":"commit-reach.c:193-197","code":" 193 | static int merge_bases_many(struct repository *r,\n 194 | \t\t\t struct commit *one, int n,\n 195 | \t\t\t struct commit **twos,\n 196 | \t\t\t enum merge_base_flags mb_flags,\n 197 | \t\t\t struct commit_list **result)","lines":5},{"ref":"commit-reach.c:306-308","code":" 306 | \t\tif (paint_down_to_common(r, array[i], filled,\n 307 | \t\t\t\t\t work, min_generation,\n 308 | \t\t\t\t\t MERGE_BASE_FIND_ALL, &common)) {","lines":3}]},{"id":"OVF_15","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"emit_rewrite_lines() declares its size parameter as `int size`, but its callers in\nemit_rewrite_diff() pass `size_t size_one`/`size_two` (the textconv'd content sizes\nfrom fill_textconv). For a rewrite/textconv'd file whose content exceeds INT_MAX\n(>2GB), `size` is truncated/sign-flipped on the call, and inside the function\n`int len = size;` then drives the line-emit loop, producing wrong or negative\nlengths and a corrupt/short rewrite diff. Reachability is limited (requires a\nsingle >2GB textconv'd or rewrite-mode file), so severity is low, but the int\nparameter is a genuine truncation of a size_t.","fix":"Change emit_rewrite_lines() to take `unsigned long size` (or size_t) and use a\nmatching wide type for the internal `len`/`plen`, consistent with the rest of the\nemit path which uses unsigned long line lengths.","locations":[{"ref":"diff.c:1834-1835","code":" 1834 | static void emit_rewrite_lines(struct emit_callback *ecbdata,\n 1835 | \t\t\t int prefix, const char *data, int size)","lines":2},{"ref":"diff.c:1846-1850","code":" 1846 | \t\t\tlen = endp - data + 1;\n 1847 | \t\t\tplen = len;\n 1848 | \t\t} else {\n 1849 | \t\t\tlen = size;\n 1850 | \t\t\tplen = len + 1;","lines":5},{"ref":"diff.c:1951-1953","code":" 1951 | \t\temit_rewrite_lines(&ecbdata, '-', data_one, size_one);\n 1952 | \tif (lc_b)\n 1953 | \t\temit_rewrite_lines(&ecbdata, '+', data_two, size_two);","lines":3}]},{"id":"OVF_16","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"deflate_it() stores the deflate bound in `int bound` even though\ngit_deflate_bound() returns a size_t-width value, then uses it as the xmalloc size\nand as stream.avail_out. For an input `size` near or above 2GB the bound exceeds\nINT_MAX and truncates (possibly to a negative/small value), yielding an undersized\nallocation while git_deflate writes up to the true bound -> heap buffer overflow.\nIn practice binary blobs this large are normally gated by big-file thresholds\nbefore reaching binary-diff emission, so severity is low, but the int truncation of\na size_t bound is a real latent overflow.","fix":"Declare `bound` (and the avail_out type as needed) as the same width returned by\ngit_deflate_bound (size_t / unsigned long) instead of int.","locations":[{"ref":"diff.c:3608-3624","code":" 3608 | static unsigned char *deflate_it(char *data,\n 3609 | \t\t\t\t unsigned long size,\n 3610 | \t\t\t\t unsigned long *result_size)\n 3611 | {\n 3612 | \tint bound;\n 3613 | \tunsigned char *deflated;\n 3614 | \tgit_zstream stream;\n 3615 | \tstruct repo_config_values *cfg = repo_config_values(the_repository);\n 3616 | \n 3617 | \tgit_deflate_init(&stream, cfg->zlib_compression_level);\n 3618 | \tbound = git_deflate_bound(&stream, size);\n 3619 | \tdeflated = xmalloc(bound);\n 3620 | \tstream.next_out = deflated;\n 3621 | \tstream.avail_out = bound;\n 3622 | \n 3623 | \tstream.next_in = (unsigned char *)data;\n 3624 | \tstream.avail_in = size;","lines":17}]},{"id":"OVF_17","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"decode_varint() consumes continuation bytes with no end-of-buffer guard, while\nread_one_dir() (and the ident_len / directory-count decodes in\nread_untracked_extension) only check `data > end` AFTER the call. For the\nuntrusted untracked-cache extension, a varint whose final continuation byte sits\nat end[-1] causes decode_varint() to read past `end` before the post-hoc bounds\ncheck can detect it. The overrun is small and the record is subsequently rejected,\nand the extension data is normally an interior slice of the mmap, so impact is\nlimited, but attacker-controlled-region bytes are read before any validation.","fix":"Use a length-aware varint decode that takes the remaining buffer length and\nrefuses to read past `end` (returning an error) instead of decoding first and\nbounds-checking after the fact.","locations":[{"ref":"dir.c:3785-3796","code":" 3785 | \tvalue = decode_varint(&data);\n 3786 | \tif (data > end)\n 3787 | \t\treturn -1;\n 3788 | \tud.recurse\t = 1;\n 3789 | \tud.untracked_alloc = value;\n 3790 | \tud.untracked_nr\t = value;\n 3791 | \tif (ud.untracked_nr)\n 3792 | \t\tALLOC_ARRAY(ud.untracked, ud.untracked_nr);\n 3793 | \n 3794 | \tud.dirs_alloc = ud.dirs_nr = decode_varint(&data);\n 3795 | \tif (data > end)\n 3796 | \t\treturn -1;","lines":12},{"ref":"dir.c:3884-3912","code":" 3884 | \tident_len = decode_varint(&next);\n 3885 | \tif (next + ident_len > end)\n 3886 | \t\treturn NULL;\n 3887 | \tident = (const char *)next;\n 3888 | \tnext += ident_len;\n 3889 | \n 3890 | \tif (next + exclude_per_dir_offset + 1 > end)\n 3891 | \t\treturn NULL;\n 3892 | \n 3893 | \tCALLOC_ARRAY(uc, 1);\n 3894 | \tstrbuf_init(&uc->ident, ident_len);\n 3895 | \tstrbuf_add(&uc->ident, ident, ident_len);\n 3896 | \tload_oid_stat(&uc->ss_info_exclude,\n 3897 | \t\t next + ouc_offset(info_exclude_stat),\n 3898 | \t\t next + offset);\n 3899 | \tload_oid_stat(&uc->ss_excludes_file,\n 3900 | \t\t next + ouc_offset(excludes_file_stat),\n 3901 | \t\t next + offset + hashsz);\n 3902 | \tuc->dir_flags = get_be32(next + ouc_offset(dir_flags));\n 3903 | \texclude_per_dir = (const char *)next + exclude_per_dir_offset;\n 3904 | \tuc->exclude_per_dir = uc->exclude_per_dir_to_free = xstrdup(exclude_per_dir);\n 3905 | \t/* NUL after exclude_per_dir is covered by sizeof(*ouc) */\n 3906 | \tnext += exclude_per_dir_offset + strlen(exclude_per_dir) + 1;\n 3907 | \tif (next >= end)\n 3908 | \t\tgoto done2;\n 3909 | \n 3910 | \tvarint_len = decode_varint(&next);\n 3911 | \tif (next > end || varint_len == 0)\n 3912 | \t\tgoto done2;","lines":29}]},{"id":"OVF_18","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"pcre2match() narrows PCRE2 match offsets (PCRE2_SIZE / size_t in ovector) into\nregmatch_t's int-width fields via unchecked '(int)' casts (rm_so/rm_eo). For a\nsubject line longer than INT_MAX bytes (no embedded newline) the offsets are\nsilently truncated, corrupting the match-offset arithmetic later used for\ncolorization (bol + rm_so, rm_eo - rm_so) and word-boundary checks. Only\nreachable with multi-gigabyte single lines, so it is a latent edge-case\noverflow rather than a practical exploit.","fix":"Keep offsets in a regoff_t-wide type and bail out or clamp when an offset\nexceeds what regmatch_t can hold (guard ovector[1] > INT_MAX before the cast).","locations":[{"ref":"grep.c:436-437","code":" 436 | \t\tmatch->rm_so = (int)ovector[0];\n 437 | \t\tmatch->rm_eo = (int)ovector[1];","lines":2}]},{"id":"OVF_19","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"kwsincr() encodes the per-keyword accepting index as 1 + 2 * kwset->words\n(kwset.c:316) into an 'unsigned int' field, decoded later as\naccept->accepting / 2 (kwset.c:774). kwset->words is a plain 'int' incremented\nper keyword. With a very large keyword set the multiplication 2 * kwset->words\noverflows, corrupting the reported match index (kwsmatch->index). Not\nmemory-unsafe (keyword count is bounded by allocatable trie nodes), but yields\na wrong index in the extreme case.","fix":"Use size_t for 'words' and the 'accepting' encoding (perform the multiply in\nsize_t and widen the fields), or enforce/document an upper bound on the number\nof keywords.","locations":[{"ref":"kwset.c:315-317","code":" 315 | if (!trie->accepting)\n 316 | trie->accepting = 1 + 2 * kwset->words;\n 317 | ++kwset->words;","lines":3},{"ref":"kwset.c:774","code":" 774 | kwsmatch->index = accept->accepting / 2;","lines":1}]},{"id":"OVF_20","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","easy":false,"description":"In detect_and_process_renames(), ALLOC_GROW is called with the sum of two\nsigned int pair counts computed in plain int arithmetic before the value\nreaches ALLOC_GROW's internal st_mult/st_add overflow guards:\n\n ALLOC_GROW(combined.queue,\n renames->pairs[1].nr + renames->pairs[2].nr,\n combined.alloc);\n\nIf the combined number of rename pairs ever exceeded INT_MAX, the signed\naddition itself overflows (undefined behavior / a negative argument) before\nany guard runs. This is not realistically reachable - pair counts are bounded\nby tree sizes - but it is inconsistent with the rest of the diff/strmap code,\nwhich deliberately wraps such additions in st_add() precisely to avoid this.","fix":"Use the checked addition helper:\n ALLOC_GROW(combined.queue,\n st_add(renames->pairs[1].nr, renames->pairs[2].nr),\n combined.alloc);","locations":[{"ref":"merge-ort.c:3599-3601","code":" 3599 | \tALLOC_GROW(combined.queue,\n 3600 | \t\t renames->pairs[1].nr + renames->pairs[2].nr,\n 3601 | \t\t combined.alloc);","lines":3}]},{"id":"OVF_21","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"negligible","easy":false,"description":"Potential signed/unsigned overflow in score arithmetic in should_break(). The\nproducts `src_removed * MAX_SCORE`, `delta_size * MAX_SCORE`, and\n`src->size * break_score` are computed in unsigned long before division. MAX_SCORE\nis 60000; for a file whose size approaches ULONG_MAX/60000 the products wrap,\nproducing a wrong break score. In practice files this large are excluded earlier\n(xdiff size caps, memory), so impact is negligible, but the arithmetic is\nunguarded.","fix":"If hardening is desired, compute scores with 64-bit-safe scaling (divide before\nmultiply, or a ratio helper) as other diffcore score sites do.","locations":[{"ref":"diffcore-break.c:109","code":" 109 | \t*merge_score_p = (int)(src_removed * MAX_SCORE / src->size);","lines":1},{"ref":"diffcore-break.c:117","code":" 117 | \tif (delta_size * MAX_SCORE / max_size < break_score)","lines":1},{"ref":"diffcore-break.c:123-125","code":" 123 | \tif ((src->size * break_score < src_removed * MAX_SCORE) &&\n 124 | \t (literal_added * 20 < src_removed) &&\n 125 | \t (literal_added * 20 < src_copied))","lines":3}]},{"id":"OVF_22","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"negligible","easy":false,"description":"find_separator() returns ssize_t but two callers store the result in `int\nseparator_pos`. For a single trailer line longer than INT_MAX bytes the offset would\ntruncate and could become negative, mis-classifying the line (a negative value other\nthan -1 would then be passed to parse_trailer as a bogus separator position). Needs\na ~2GB single line, so not realistically exploitable; flagged only for completeness.\nOther callers (e.g. lines 756, 795, 950) correctly use ssize_t.","fix":"Declare these as `ssize_t separator_pos;` to match find_separator()'s return type.","locations":[{"ref":"trailer.c:1079-1084","code":" 1079 | \t\tint separator_pos;\n 1080 | \t\tchar *trailer = trailer_block->trailers[i];\n 1081 | \t\tif (starts_with(trailer, comment_line_str))\n 1082 | \t\t\tcontinue;\n 1083 | \t\tseparator_pos = find_separator(trailer, separators);\n 1084 | \t\tif (separator_pos >= 1) {","lines":6},{"ref":"trailer.c:1233","code":" 1233 | \t\tint separator_pos = find_separator(line, separators);","lines":1}]},{"id":"LOGIC_1","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"bloom_keyvec_new counts path separators over the whole NUL-terminated string in\nits first loop (starting at index 0, so a *leading* '/' is counted), but its\nfill loop only inspects indices in (path, path+len-1] (condition `p > path`), so\nit never examines path[0] and never fills a key for a leading separator. For a\npath like \"/foo\" the count loop sets vec->count = 2 while the fill loop fills\nonly key[0], leaving nr == 1. This trips `assert(nr == vec->count)`. With NDEBUG\n(assert compiled out) vec->count stays 2 while key[1].hashes remains NULL (from\nxcalloc); a later bloom_filter_contains_vec -> bloom_filter_contains then\ndereferences key->hashes[i] on a NULL pointer. The two loops use inconsistent\niteration bounds / separator rules for path[0].","fix":"Make the counting loop and the filling loop use identical bounds and identical\nseparator handling. Simplest: count separators with the same `p > path`\ncondition used when filling so a leading separator is excluded from both count\nand fill; alternatively skip a leading '/' explicitly before counting.","locations":[{"ref":"bloom.c:289-317","code":" 289 | \tp = path;\n 290 | \twhile (*p) {\n 291 | \t\t/*\n 292 | \t\t * At this point, the path is normalized to use Unix-style\n 293 | \t\t * path separators. This is required due to how the\n 294 | \t\t * changed-path Bloom filters store the paths.\n 295 | \t\t */\n 296 | \t\tif (*p == '/')\n 297 | \t\t\tnr++;\n 298 | \t\tp++;\n 299 | \t}\n 300 | \n 301 | \tsz = sizeof(struct bloom_keyvec);\n 302 | \tsz += nr * sizeof(struct bloom_key);\n 303 | \tvec = (struct bloom_keyvec *)xcalloc(1, sz);\n 304 | \tif (!vec)\n 305 | \t\treturn NULL;\n 306 | \tvec->count = nr;\n 307 | \n 308 | \tbloom_key_fill(&vec->key[0], path, len, settings);\n 309 | \tnr = 1;\n 310 | \tp = path + len - 1;\n 311 | \twhile (p > path) {\n 312 | \t\tif (*p == '/') {\n 313 | \t\t\tbloom_key_fill(&vec->key[nr++], path, p - path, settings);\n 314 | \t\t}\n 315 | \t\tp--;\n 316 | \t}\n 317 | \tassert(nr == vec->count);","lines":29},{"ref":"bloom.c:584","code":" 584 | \t\tuint64_t hash_mod = key->hashes[i] % mod;","lines":1}]},{"id":"LOGIC_2","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"The --strategy incompatibility check in run_sequencer() reads the wrong variable. The\ncommand-line --strategy value is parsed into the local `strategy` pointer (declared at\nline 116, wired at line 133), and is only copied into opts->strategy later at lines\n260-263, which runs AFTER the verify_opt_compatible() check. The check at line 211 tests\n`opts->strategy ? 1 : 0`, i.e. only the config-derived value (normally NULL here), not the\nvalue just parsed from the command line. As a result `git revert --abort --strategy=foo`\n(and the --quit/--continue/--skip variants) silently fails to produce the intended\n\"--strategy cannot be used with --abort\" error. The sibling check on line 212\n(--strategy-option via opts->xopts.nr) works because -X is parsed directly into\nopts->xopts; this asymmetry confirms the --strategy case is an oversight.","fix":"Test the local command-line variable instead, e.g. \"--strategy\", strategy != &sentinel_value, or move the opts->strategy assignment (lines 260-263) before the incompatibility check.","locations":[{"ref":"builtin/revert.c:116","code":" 116 | \tconst char *strategy = &sentinel_value;","lines":1},{"ref":"builtin/revert.c:133","code":" 133 | \t\tOPT_STRING(0, \"strategy\", &strategy, N_(\"strategy\"), N_(\"merge strategy\")),","lines":1},{"ref":"builtin/revert.c:207-219","code":" 207 | \t\tverify_opt_compatible(me, this_operation,\n 208 | \t\t\t\t\"--no-commit\", opts->no_commit,\n 209 | \t\t\t\t\"--signoff\", opts->signoff,\n 210 | \t\t\t\t\"--mainline\", opts->mainline,\n 211 | \t\t\t\t\"--strategy\", opts->strategy ? 1 : 0,\n 212 | \t\t\t\t\"--strategy-option\", opts->xopts.nr ? 1 : 0,\n 213 | \t\t\t\t\"-x\", opts->record_origin,\n 214 | \t\t\t\t\"--ff\", opts->allow_ff,\n 215 | \t\t\t\t\"--rerere-autoupdate\", opts->allow_rerere_auto == RERERE_AUTOUPDATE,\n 216 | \t\t\t\t\"--no-rerere-autoupdate\", opts->allow_rerere_auto == RERERE_NOAUTOUPDATE,\n 217 | \t\t\t\t\"--keep-redundant-commits\", opts->keep_redundant_commits,\n 218 | \t\t\t\t\"--empty\", empty_opt != EMPTY_COMMIT_UNSPECIFIED,\n 219 | \t\t\t\tNULL);","lines":13},{"ref":"builtin/revert.c:255-263","code":" 255 | \t/* These option values will be free()d */\n 256 | \tif (gpg_sign != &sentinel_value) {\n 257 | \t\tfree(opts->gpg_sign);\n 258 | \t\topts->gpg_sign = xstrdup_or_null(gpg_sign);\n 259 | \t}\n 260 | \tif (strategy != &sentinel_value) {\n 261 | \t\tfree(opts->strategy);\n 262 | \t\topts->strategy = xstrdup_or_null(strategy);\n 263 | \t}","lines":9}]},{"id":"LOGIC_3","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"read_one() in cache-tree.c parses entry_count from the untrusted on-disk TREE\nextension and stores it with no upper-bound sanity check. verify_one() guards the\nvalue (entry_count + pos > cache_nr), but the sparse-index consumer does not:\nconvert_to_sparse_rec() takes span = ct->down[pos]->cache_tree->entry_count and\nuses it directly to bound a recursion range (i + span) and to advance the index\ncursor (i += span) with no check that i + span stays within the valid entry\nrange. A crafted sparse cache-tree with an inflated entry_count drives\nout-of-range istate->cache[] access during sparse-index conversion. read_one()\nis the natural choke point to reject implausible counts coming off disk.","fix":"Validate parsed values in read_one(): reject entry_count < -1, subtree_nr < 0,\nand entry_count larger than a sane bound for the index. At minimum make\nconvert_to_sparse_rec() clamp/verify span against the available [start,end)\nrange before using it as an array bound, as verify_one() already does.","locations":[{"ref":"cache-tree.c:602-651","code":" 602 | \tif (parse_int(&buf, &size, &it->entry_count) < 0)\n 603 | \t\tgoto free_return;\n 604 | \tif (!size || *buf != ' ')\n 605 | \t\tgoto free_return;\n 606 | \tbuf++; size--;\n 607 | \tif (parse_int(&buf, &size, &subtree_nr) < 0)\n 608 | \t\tgoto free_return;\n 609 | \tif (!size || *buf != '\\n')\n 610 | \t\tgoto free_return;\n 611 | \tbuf++; size--;\n 612 | \tif (0 <= it->entry_count) {\n 613 | \t\tif (size < rawsz)\n 614 | \t\t\tgoto free_return;\n 615 | \t\toidread(&it->oid, (const unsigned char *)buf,\n 616 | \t\t\tthe_repository->hash_algo);\n 617 | \t\tbuf += rawsz;\n 618 | \t\tsize -= rawsz;\n 619 | \t}\n 620 | \n 621 | #if DEBUG_CACHE_TREE\n 622 | \tif (0 <= it->entry_count)\n 623 | \t\tfprintf(stderr, \"cache-tree <%s> (%d ent, %d subtree) %s\\n\",\n 624 | \t\t\t*buffer, it->entry_count, subtree_nr,\n 625 | \t\t\toid_to_hex(&it->oid));\n 626 | \telse\n 627 | \t\tfprintf(stderr, \"cache-tree <%s> (%d subtrees) invalid\\n\",\n 628 | \t\t\t*buffer, subtree_nr);\n 629 | #endif\n 630 | \n 631 | \t/*\n 632 | \t * Just a heuristic -- we do not add directories that often but\n 633 | \t * we do not want to have to extend it immediately when we do,\n 634 | \t * hence +2.\n 635 | \t */\n 636 | \tit->subtree_alloc = subtree_nr + 2;\n 637 | \tCALLOC_ARRAY(it->down, it->subtree_alloc);\n 638 | \tfor (i = 0; i < subtree_nr; i++) {\n 639 | \t\t/* read each subtree */\n 640 | \t\tstruct cache_tree *sub;\n 641 | \t\tstruct cache_tree_sub *subtree;\n 642 | \t\tconst char *name = buf;\n 643 | \n 644 | \t\tsub = read_one(&buf, &size);\n 645 | \t\tif (!sub)\n 646 | \t\t\tgoto free_return;\n 647 | \t\tsubtree = cache_tree_sub(it, name);\n 648 | \t\tsubtree->cache_tree = sub;\n 649 | \t}\n 650 | \tif (subtree_nr != it->subtree_nr)\n 651 | \t\tdie(\"cache-tree: internal error\");","lines":50},{"ref":"cache-tree.c:968","code":" 968 | \tif (it->entry_count + pos > istate->cache_nr) {","lines":1}]},{"id":"LOGIC_4","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"parse_fsync_components() matches component names with\nstrncmp(n->name, string, len) using only the token length `len`, with no check\nthat `len == strlen(n->name)`. The name table contains both \"pack\" and\n\"pack-metadata\", where \"pack\" is a prefix of \"pack-metadata\". A token such as\n\"pack\" therefore matches BOTH entries, so core.fsync=pack unintentionally also\nenables FSYNC_COMPONENT_PACK_METADATA. Any token that is a proper prefix of\nanother component name will over-match.","fix":"Require an exact length match before comparing:\n\n for (size_t i = 0; i < ARRAY_SIZE(fsync_component_names); ++i) {\n const struct fsync_component_name *n = &fsync_component_names[i];\n if (strlen(n->name) != len || strncmp(n->name, string, len))\n continue;\n ...\n }","locations":[{"ref":"environment.c:267-278","code":" 267 | \t\tfor (size_t i = 0; i < ARRAY_SIZE(fsync_component_names); ++i) {\n 268 | \t\t\tconst struct fsync_component_name *n = &fsync_component_names[i];\n 269 | \n 270 | \t\t\tif (strncmp(n->name, string, len))\n 271 | \t\t\t\tcontinue;\n 272 | \n 273 | \t\t\tfound = 1;\n 274 | \t\t\tif (negated)\n 275 | \t\t\t\tnegative |= n->component_bits;\n 276 | \t\t\telse\n 277 | \t\t\t\tpositive |= n->component_bits;\n 278 | \t\t}","lines":12}]},{"id":"LOGIC_5","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"process_all_files() locates the affected range entry by re-searching the\nORIGINAL 'range' list, matching on pair->two->path, then assert(rg) (line 988).\nprocess_diff_filepair() is explicitly documented to \"destructively operate on\nrange\" and \"trample over data structures not owned here\" (NEEDSWORK comment at\nlines 982-983). Under rename/copy detection (-M/-C) two distinct filepairs in\nthe diff queue can share the same pair->two->path, so the re-search by path is\nnot guaranteed unique, and the assert(rg) can fire (or match the wrong entry)\non a legitimate-but-unusual history shape. This is a latent abort/correctness\nhazard tied to the acknowledged NEEDSWORK.","fix":"Carry an explicit back-reference from each pairdiff to its line_log_data entry\n(or look it up in *range_out, the structure actually being mutated) instead of\nre-searching the original 'range' list by two->path, which is not unique under\n-M/-C.","locations":[{"ref":"line-log.c:958-1004","code":" 958 | static int process_all_files(struct line_log_data **range_out,\n 959 | \t\t\t struct rev_info *rev,\n 960 | \t\t\t struct diff_queue_struct *queue,\n 961 | \t\t\t struct line_log_data *range)\n 962 | {\n 963 | \tint i, changed = 0;\n 964 | \n 965 | \t*range_out = line_log_data_copy(range);\n 966 | \n 967 | \tfor (i = 0; i < queue->nr; i++) {\n 968 | \t\tstruct diff_ranges *pairdiff = NULL;\n 969 | \t\tstruct diff_filepair *pair = queue->queue[i];\n 970 | \t\tif (process_diff_filepair(rev, pair, *range_out, &pairdiff)) {\n 971 | \t\t\t/*\n 972 | \t\t\t * Store away the diff for later output. We\n 973 | \t\t\t * tuck it in the ranges we got as _input_,\n 974 | \t\t\t * since that's the commit that caused the\n 975 | \t\t\t * diff.\n 976 | \t\t\t *\n 977 | \t\t\t * NEEDSWORK not enough when we get around to\n 978 | \t\t\t * doing something interesting with merges;\n 979 | \t\t\t * currently each invocation on a merge parent\n 980 | \t\t\t * trashes the previous one's diff.\n 981 | \t\t\t *\n 982 | \t\t\t * NEEDSWORK tramples over data structures not owned here\n 983 | \t\t\t */\n 984 | \t\t\tstruct line_log_data *rg = range;\n 985 | \t\t\tchanged++;\n 986 | \t\t\twhile (rg && strcmp(rg->path, pair->two->path))\n 987 | \t\t\t\trg = rg->next;\n 988 | \t\t\tassert(rg);\n 989 | \t\t\tif (rg->pair)\n 990 | \t\t\t\tdiff_free_filepair(rg->pair);\n 991 | \t\t\trg->pair = diff_filepair_dup(queue->queue[i]);\n 992 | \t\t\tdiff_ranges_release(&rg->diff);\n 993 | \t\t\tmemcpy(&rg->diff, pairdiff, sizeof(struct diff_ranges));\n 994 | \t\t\tFREE_AND_NULL(pairdiff);\n 995 | \t\t}\n 996 | \n 997 | \t\tif (pairdiff) {\n 998 | \t\t\tdiff_ranges_release(pairdiff);\n 999 | \t\t\tfree(pairdiff);\n 1000 | \t\t}\n 1001 | \t}\n 1002 | \n 1003 | \treturn changed;\n 1004 | }","lines":47}]},{"id":"LOGIC_6","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"The OID-ordering verification loop iterates the outer loop over the whole MIDX\nchain (for (curr = m; curr; curr = curr->base_midx)) but the loop body only ever\nreferences m (the top layer), never curr. Consequently base layers in an\nincremental chain are never checked for OID ordering (a verification gap), and\nthe identical top-layer check is redundantly repeated once per chain layer. curr\nis effectively unused.","fix":"Use curr inside the loop (nth_midxed_object_oid against curr and its\nnum_objects_in_base, bound i < curr->num_objects - 1), or, if only the top layer\nis intended to be verified, drop the outer loop entirely.","locations":[{"ref":"midx.c:975-988","code":" 975 | \tfor (curr = m; curr; curr = curr->base_midx) {\n 976 | \t\tfor (i = 0; i < m->num_objects - 1; i++) {\n 977 | \t\t\tstruct object_id oid1, oid2;\n 978 | \n 979 | \t\t\tnth_midxed_object_oid(&oid1, m, m->num_objects_in_base + i);\n 980 | \t\t\tnth_midxed_object_oid(&oid2, m, m->num_objects_in_base + i + 1);\n 981 | \n 982 | \t\t\tif (oidcmp(&oid1, &oid2) >= 0)\n 983 | \t\t\t\tmidx_report(_(\"oid lookup out of order: oid[%d] = %s >= %s = oid[%d]\"),\n 984 | \t\t\t\t\t i, oid_to_hex(&oid1), oid_to_hex(&oid2), i + 1);\n 985 | \n 986 | \t\t\tmidx_display_sparse_progress(progress, i + 1);\n 987 | \t\t}\n 988 | \t}","lines":14}]},{"id":"LOGIC_7","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"In verify_midx_file(), the QSORT call sorts only m->num_objects elements, but the\n`pairs` array was allocated and populated with m->num_objects +\nm->num_objects_in_base elements, and the subsequent verification loop iterates\nover all num_objects + num_objects_in_base of them. For an incremental MIDX chain\n(num_objects_in_base > 0) the tail of the array is left unsorted, so the\n\"group objects by packfile to keep only one pack open at a time\" invariant that\nthe loop relies on (it closes packs on pack_int_id transitions) is violated,\ncausing packs to be repeatedly closed/reopened and the verification to operate on\nmis-grouped data. The sort count is inconsistent with the allocation and\niteration counts.","fix":"QSORT(pairs, m->num_objects + m->num_objects_in_base, compare_pair_pos_vs_id);","locations":[{"ref":"midx.c:997-1001","code":" 997 | \tALLOC_ARRAY(pairs, m->num_objects + m->num_objects_in_base);\n 998 | \tfor (i = 0; i < m->num_objects + m->num_objects_in_base; i++) {\n 999 | \t\tpairs[i].pos = i;\n 1000 | \t\tpairs[i].pack_int_id = nth_midxed_pack_int_id(m, i);\n 1001 | \t}","lines":5},{"ref":"midx.c:1008","code":" 1008 | \tQSORT(pairs, m->num_objects, compare_pair_pos_vs_id);","lines":1},{"ref":"midx.c:1015","code":" 1015 | \tfor (i = 0; i < m->num_objects + m->num_objects_in_base; i++) {","lines":1}]},{"id":"LOGIC_8","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"filter_is_base() writes each is-base result into the wrong slot of a ref item's\nis_base[] array when more than one %(is-base:...) atom is used and at least one\nof them does not resolve to a base.\n\nThe writer uses a single counter `j` that is only incremented when\nget_branch_base_for_tip() returns a valid base (the base_index < 0 case does\n`continue` before j++). So `j` counts matched bases, not the ordinal position of\nthe is-base atom. The reader in populate_value() indexes\nref->is_base[is_base_atoms], where is_base_atoms is incremented for EVERY\nis-base atom in format order. Writer `j` and reader `is_base_atoms` therefore\ndisagree whenever an earlier is-base atom produced no base, shifting/swapping\nthe labels of different %(is-base:X) columns in the output.\n\nExample: format \"%(is-base:A)%(is-base:B)\". If A finds no base (j stays 0) and\nB's base is ref X, the code writes X->is_base[0] = \"B\". populate_value reads\nis_base[0] for atom A and prints \"(B)\" in the A column, and reads is_base[1]\n(NULL) for atom B and prints empty. The per-item array is sized is_base_nr, so\nthis is a correctness bug, not an overflow.","fix":"Index the destination by the is-base atom's ordinal position (matching the\nreader's is_base_atoms), not by a match counter:\n\n for (size_t i = 0, atom_idx = 0; i < used_atom_cnt; i++) {\n if (used_atom[i].atom_type != ATOM_ISBASE)\n continue;\n tip = used_atom[i].u.base.commit;\n base_index = get_branch_base_for_tip(r, tip, bases, bases_nr);\n if (base_index >= 0)\n back_index[base_index]->is_base[atom_idx] =\n xstrdup(used_atom[i].u.base.name);\n atom_idx++;\n }","locations":[{"ref":"ref-filter.c:3270-3284","code":" 3270 | \tfor (size_t i = 0, j = 0; i < used_atom_cnt; i++) {\n 3271 | \t\tstruct commit *tip;\n 3272 | \t\tint base_index;\n 3273 | \n 3274 | \t\tif (used_atom[i].atom_type != ATOM_ISBASE)\n 3275 | \t\t\tcontinue;\n 3276 | \n 3277 | \t\ttip = used_atom[i].u.base.commit;\n 3278 | \t\tbase_index = get_branch_base_for_tip(r, tip, bases, bases_nr);\n 3279 | \t\tif (base_index < 0)\n 3280 | \t\t\tcontinue;\n 3281 | \n 3282 | \t\t/* Store the string for use in output later. */\n 3283 | \t\tback_index[base_index]->is_base[j++] = xstrdup(used_atom[i].u.base.name);\n 3284 | \t}","lines":15},{"ref":"ref-filter.c:2586-2594","code":" 2586 | \t\t} else if (atom_type == ATOM_ISBASE) {\n 2587 | \t\t\tif (ref->is_base && ref->is_base[is_base_atoms]) {\n 2588 | \t\t\t\tv->s = xstrfmt(\"(%s)\", ref->is_base[is_base_atoms]);\n 2589 | \t\t\t\tfree(ref->is_base[is_base_atoms]);\n 2590 | \t\t\t} else {\n 2591 | \t\t\t\tv->s = xstrdup(\"\");\n 2592 | \t\t\t}\n 2593 | \t\t\tis_base_atoms++;\n 2594 | \t\t\tcontinue;","lines":9}]},{"id":"LOGIC_9","type":"logic_error","type_label":"Logic Error","severity":"medium","easy":false,"description":"midx_has_unknown_packs() compares pack name strings with mismatched suffixes. The entries of existing->midx_packs are produced by pack_basename(p), which always ends in '.pack' (a packed_git's pack_name is the .pack file, even when loaded via a MIDX). However the 'include' list it is checked against is built by midx_included_packs() using '.idx' suffixes, and the inner geometry loop also constructs '.idx' buffers (strbuf_strip_suffix '.pack' then add '.idx') before comparing them to pack_name. A '.pack' string can therefore never equal an '.idx' string, so both the string_list_has_string(include, pack_name) check and the per-split strcmp never match. The effect is that every pack referenced by an existing MIDX is treated as 'unknown', making the function effectively always return 1 and forcing cruft packs to be included in the new MIDX. This is the safe direction (over-inclusion), but it defeats the repack.midxMustContainCruft=0 optimization. Note: this predates the file split, but it lives entirely in the audited code.","fix":"Make the suffixes consistent: either store midx_packs entries with the '.idx' suffix used everywhere else (e.g. strip '.pack' and append '.idx' when collecting), or normalize pack_name to '.idx' at the top of the midx_has_unknown_packs loop before comparing against 'include' and the per-split buffers.","locations":[{"ref":"repack-midx.c:82-138","code":" 82 | \tfor_each_string_list_item(item, &existing->midx_packs) {\n 83 | \t\tconst char *pack_name = item->string;\n 84 | \n 85 | \t\t/*\n 86 | \t\t * Determine whether or not each MIDX'd pack from the existing\n 87 | \t\t * MIDX (if any) is represented in the new MIDX. For each pack\n 88 | \t\t * in the MIDX, it must either be:\n 89 | \t\t *\n 90 | \t\t * - In the \"include\" list of packs to be included in the new\n 91 | \t\t * MIDX. Note this function is called before the include\n 92 | \t\t * list is populated with any cruft pack(s).\n 93 | \t\t *\n 94 | \t\t * - Below the geometric split line (if using pack geometry),\n 95 | \t\t * indicating that the pack won't be included in the new\n 96 | \t\t * MIDX, but its contents were rolled up as part of the\n 97 | \t\t * geometric repack.\n 98 | \t\t *\n 99 | \t\t * - In the existing non-kept packs list (if not using pack\n 100 | \t\t * geometry), and marked as non-deleted.\n 101 | \t\t */\n 102 | \t\tif (string_list_has_string(include, pack_name)) {\n 103 | \t\t\tcontinue;\n 104 | \t\t} else if (geometry) {\n 105 | \t\t\tstruct strbuf buf = STRBUF_INIT;\n 106 | \t\t\tuint32_t j;\n 107 | \n 108 | \t\t\tfor (j = 0; j < geometry->split; j++) {\n 109 | \t\t\t\tstrbuf_reset(&buf);\n 110 | \t\t\t\tstrbuf_addstr(&buf, pack_basename(geometry->pack[j]));\n 111 | \t\t\t\tstrbuf_strip_suffix(&buf, \".pack\");\n 112 | \t\t\t\tstrbuf_addstr(&buf, \".idx\");\n 113 | \n 114 | \t\t\t\tif (!strcmp(pack_name, buf.buf)) {\n 115 | \t\t\t\t\tstrbuf_release(&buf);\n 116 | \t\t\t\t\tbreak;\n 117 | \t\t\t\t}\n 118 | \t\t\t}\n 119 | \n 120 | \t\t\tstrbuf_release(&buf);\n 121 | \n 122 | \t\t\tif (j < geometry->split)\n 123 | \t\t\t\tcontinue;\n 124 | \t\t} else {\n 125 | \t\t\tstruct string_list_item *item;\n 126 | \n 127 | \t\t\titem = string_list_lookup(&existing->non_kept_packs,\n 128 | \t\t\t\t\t\t pack_name);\n 129 | \t\t\tif (item && !existing_pack_is_marked_for_deletion(item))\n 130 | \t\t\t\tcontinue;\n 131 | \t\t}\n 132 | \n 133 | \t\t/*\n 134 | \t\t * If we got to this point, the MIDX includes some pack that we\n 135 | \t\t * don't know about.\n 136 | \t\t */\n 137 | \t\treturn 1;\n 138 | \t}","lines":57},{"ref":"repack-midx.c:102-118","code":" 102 | \t\tif (string_list_has_string(include, pack_name)) {\n 103 | \t\t\tcontinue;\n 104 | \t\t} else if (geometry) {\n 105 | \t\t\tstruct strbuf buf = STRBUF_INIT;\n 106 | \t\t\tuint32_t j;\n 107 | \n 108 | \t\t\tfor (j = 0; j < geometry->split; j++) {\n 109 | \t\t\t\tstrbuf_reset(&buf);\n 110 | \t\t\t\tstrbuf_addstr(&buf, pack_basename(geometry->pack[j]));\n 111 | \t\t\t\tstrbuf_strip_suffix(&buf, \".pack\");\n 112 | \t\t\t\tstrbuf_addstr(&buf, \".idx\");\n 113 | \n 114 | \t\t\t\tif (!strcmp(pack_name, buf.buf)) {\n 115 | \t\t\t\t\tstrbuf_release(&buf);\n 116 | \t\t\t\t\tbreak;\n 117 | \t\t\t\t}\n 118 | \t\t\t}","lines":17},{"ref":"repack.c:137-139","code":" 137 | \t\tif (p->multi_pack_index)\n 138 | \t\t\tstring_list_append(&existing->midx_packs,\n 139 | \t\t\t\t\t pack_basename(p));","lines":3}]},{"id":"LOGIC_10","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"strbuf_realpath_1's symlink-nesting guard uses a post-increment compared with\n'>', so it permits MAXSYMLINKS+1 (33) traversals before erroring rather than\nMAXSYMLINKS (32). `num_symlinks++ > MAXSYMLINKS` only becomes true once\nnum_symlinks has already reached 33, while the die() message claims \"More than\n32 nested symlinks\". The bound and the message disagree with the #define. Not\nexploitable (the loop still terminates) but the limit is off by one.","fix":"Use `>=` to enforce the documented limit exactly:\n if (num_symlinks++ >= MAXSYMLINKS) {","locations":[{"ref":"abspath.c:142-147","code":" 142 | \t\t\tif (num_symlinks++ > MAXSYMLINKS) {\n 143 | \t\t\t\terrno = ELOOP;\n 144 | \n 145 | \t\t\t\tif (flags & REALPATH_DIE_ON_ERROR)\n 146 | \t\t\t\t\tdie(\"More than %d nested symlinks \"\n 147 | \t\t\t\t\t \"on path '%s'\", MAXSYMLINKS, path);","lines":6}]},{"id":"LOGIC_11","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"In tips_reachable_from_bases the scan variable k is declared `unsigned int`\nwhile tips_nr and min_generation_index are size_t. The loop compares\n`k < tips_nr`; if tips_nr exceeds UINT_MAX, k wraps and the loop never\nterminates / computes a wrong min_generation_index. Theoretical (requires > 4\nbillion tips) but a real type mismatch against otherwise size_t-typed code.","fix":"Declare k as size_t to match tips_nr and min_generation_index.","locations":[{"ref":"commit-reach.c:1262-1273","code":" 1262 | \t\t\t\tif (commits[min_generation_index].commit->object.flags & mark) {\n 1263 | \t\t\t\t\tunsigned int k = min_generation_index + 1;\n 1264 | \t\t\t\t\twhile (k < tips_nr &&\n 1265 | \t\t\t\t\t (commits[k].commit->object.flags & mark))\n 1266 | \t\t\t\t\t\tk++;\n 1267 | \n 1268 | \t\t\t\t\t/* Terminate early if all found. */\n 1269 | \t\t\t\t\tif (k >= tips_nr)\n 1270 | \t\t\t\t\t\tgoto done;\n 1271 | \n 1272 | \t\t\t\t\tmin_generation_index = k;\n 1273 | \t\t\t\t\tmin_generation = commits[k].generation;","lines":12}]},{"id":"LOGIC_12","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"The \"none\" keyword in parse_fsync_components() is detected with\nstrcmp(string, \"none\"), which compares the entire remaining string rather than\nthe current comma-delimited token of length `len`. As a result \"none\" is only\nhonored when it is the last/only token; e.g. for \"none,loose-object\" the\nstrcmp() fails (string still contains \",loose-object\"), so the\ncurrent = FSYNC_COMPONENT_NONE reset never fires and \"none\" instead falls\nthrough to the unknown-component warning path. The check should be token-scoped.","fix":"if (len == strlen(\"none\") && !strncmp(string, \"none\", len)) {\n current = FSYNC_COMPONENT_NONE;\n goto next_name;\n }","locations":[{"ref":"environment.c:251-254","code":" 251 | \t\tif (!strcmp(string, \"none\")) {\n 252 | \t\t\tcurrent = FSYNC_COMPONENT_NONE;\n 253 | \t\t\tgoto next_name;\n 254 | \t\t}","lines":4}]},{"id":"LOGIC_13","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"parse_loc()'s relative-range branch computes '*ret = begin + num - 2' and the\nclamp 'begin + num > 0 ? begin + num : 1' on a 'num' parsed by strtol() with no\nmagnitude/ERANGE validation. A range like \"-L 1,+99999999999999999999\" yields a\nhuge or out-of-range num; begin + num can overflow signed 'long' (UB) before\nthe clamp runs. The absolute-line branch does validate (num <= 0 dies), so the\nrelative branch is inconsistently unchecked.","fix":"After strtol(), detect ERANGE and bound the magnitude of num before computing\nbegin + num; die(\"-L invalid line number\") on overflow, mirroring the\nabsolute branch.","locations":[{"ref":"line-range.c:35-52","code":" 35 | \tif (1 <= begin && (spec[0] == '+' || spec[0] == '-')) {\n 36 | \t\tnum = strtol(spec + 1, &term, 10);\n 37 | \t\tif (term != spec + 1) {\n 38 | \t\t\tif (!ret)\n 39 | \t\t\t\treturn term;\n 40 | \t\t\tif (num == 0)\n 41 | \t\t\t\tdie(\"-L invalid empty range\");\n 42 | \t\t\tif (spec[0] == '-')\n 43 | \t\t\t\tnum = 0 - num;\n 44 | \t\t\tif (0 < num)\n 45 | \t\t\t\t*ret = begin + num - 2;\n 46 | \t\t\telse if (!num)\n 47 | \t\t\t\t*ret = begin;\n 48 | \t\t\telse\n 49 | \t\t\t\t*ret = begin + num > 0 ? begin + num : 1;\n 50 | \t\t\treturn term;\n 51 | \t\t}\n 52 | \t\treturn spec;","lines":18}]},{"id":"LOGIC_14","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"compare_pair_pos_vs_id() returns `b->pack_int_id - a->pack_int_id`, subtracting\ntwo uint32_t values and converting the unsigned wrapping result to int. For\npack_int_id differences that do not fit in int this yields an incorrect sign and\nan inconsistent comparator (undefined ordering for qsort). pack_int_id is bounded\nby num_packs today so the difference stays small, but this is the classic\nsubtraction-comparator overflow defect and is fragile if pack counts grow.","fix":"Compare explicitly:\n`if (a->pack_int_id < b->pack_int_id) return 1; if (a->pack_int_id >\nb->pack_int_id) return -1; return 0;` (preserving the existing descending order).","locations":[{"ref":"midx.c:901-907","code":" 901 | static int compare_pair_pos_vs_id(const void *_a, const void *_b)\n 902 | {\n 903 | \tstruct pair_pos_vs_id *a = (struct pair_pos_vs_id *)_a;\n 904 | \tstruct pair_pos_vs_id *b = (struct pair_pos_vs_id *)_b;\n 905 | \n 906 | \treturn b->pack_int_id - a->pack_int_id;\n 907 | }","lines":7}]},{"id":"LOGIC_15","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"show_ambiguous_object() formats a commit using the global the_repository\ninstead of the disambiguation state's repository (ds->repo), even though the\ncommit object was looked up from ds->repo.\n\nThe commit is obtained with:\n\n struct commit *commit = lookup_commit(ds->repo, oid); // line 324\n\nbut then formatted with:\n\n repo_format_commit_message(the_repository, commit, \"%ad\", &date, &pp); // line 329\n repo_format_commit_message(the_repository, commit, \"%s\", &msg, &pp); // line 331\n\nEvery other access in this function correctly uses ds->repo (lines 305, 306,\n324, 347, 349). When ds->repo != the_repository (e.g. operating on a submodule\nor alternate repository object database), this passes a commit that belongs to\nds->repo's parsed-object pool to the_repository's formatting machinery, which\nreads from the wrong repository's caches/config. Display-path only (ambiguous\nobject-ID advice), hence low severity, but clearly inconsistent and a latent\ncorrectness bug as multi-repository support matures.","fix":"Use ds->repo consistently:\n\n repo_format_commit_message(ds->repo, commit, \"%ad\", &date, &pp);\n repo_format_commit_message(ds->repo, commit, \"%s\", &msg, &pp);","locations":[{"ref":"object-name.c:321-333","code":" 321 | \tif (type == OBJ_COMMIT) {\n 322 | \t\tstruct strbuf date = STRBUF_INIT;\n 323 | \t\tstruct strbuf msg = STRBUF_INIT;\n 324 | \t\tstruct commit *commit = lookup_commit(ds->repo, oid);\n 325 | \n 326 | \t\tif (commit) {\n 327 | \t\t\tstruct pretty_print_context pp = {0};\n 328 | \t\t\tpp.date_mode.type = DATE_SHORT;\n 329 | \t\t\trepo_format_commit_message(the_repository, commit,\n 330 | \t\t\t\t\t\t \"%ad\", &date, &pp);\n 331 | \t\t\trepo_format_commit_message(the_repository, commit,\n 332 | \t\t\t\t\t\t \"%s\", &msg, &pp);\n 333 | \t\t}","lines":13}]},{"id":"LOGIC_16","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"match_hash() uses `do { ... len -= 2; } while (len > 1);`. If invoked with\nlen == 0 the body executes once and `len -= 2` underflows the unsigned len to a\nhuge value, so the loop walks/compares the entire hash instead of matching a\nzero-length prefix (which should match everything). No OOB occurs because both a\nand b are always full object_id buffers (GIT_MAX_RAWSZ), so this is a correctness\nquirk, not a memory bug.","fix":"Convert to a head-controlled `while (len > 1)` loop (or early-return when len == 0) so a zero-length prefix short-circuits to match.","locations":[{"ref":"packfile.c:2398-2411","code":" 2398 | static int match_hash(unsigned len, const unsigned char *a, const unsigned char *b)\n 2399 | {\n 2400 | \tdo {\n 2401 | \t\tif (*a != *b)\n 2402 | \t\t\treturn 0;\n 2403 | \t\ta++;\n 2404 | \t\tb++;\n 2405 | \t\tlen -= 2;\n 2406 | \t} while (len > 1);\n 2407 | \tif (len)\n 2408 | \t\tif ((*a ^ *b) & 0xf0)\n 2409 | \t\t\treturn 0;\n 2410 | \treturn 1;\n 2411 | }","lines":14}]},{"id":"LOGIC_17","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"In walk_path, the blob_limit branch (the first branch) invokes info->path_fn for\nthe size-filtered blobs without checking ctx->info->blobs, whereas the parallel\nelse-if branch only invokes path_fn for blobs when ctx->info->blobs is set. A\ncaller that configures blob_limit but leaves blobs == 0 would still receive blob\ncallbacks via the first branch. Currently latent (prepare_filters wiring keeps\nthe two in sync), but the asymmetric condition is a real inconsistency.","fix":"Add the ctx->info->blobs (or direct-objects) guard to the condition of the\nblob_limit branch so it matches the else-if branch.","locations":[{"ref":"path-walk.c:365-391","code":" 365 | \tif (list->type == OBJ_BLOB &&\n 366 | \t ctx->info->blob_limit &&\n 367 | \t !path_is_for_direct_objects(path)) {\n 368 | \t\tstruct oid_array filtered = OID_ARRAY_INIT;\n 369 | \n 370 | \t\tfor (size_t i = 0; i < list->oids.nr; i++) {\n 371 | \t\t\tunsigned long size;\n 372 | \n 373 | \t\t\tif (odb_read_object_info(ctx->repo->objects,\n 374 | \t\t\t\t\t\t &list->oids.oid[i],\n 375 | \t\t\t\t\t\t &size) != OBJ_BLOB ||\n 376 | \t\t\t\tsize < ctx->info->blob_limit)\n 377 | \t\t\t\toid_array_append(&filtered,\n 378 | \t\t\t\t\t\t &list->oids.oid[i]);\n 379 | \t\t}\n 380 | \n 381 | \t\tif (filtered.nr)\n 382 | \t\t\tret = ctx->info->path_fn(path, &filtered, list->type,\n 383 | \t\t\t\t\t\t ctx->info->path_fn_data);\n 384 | \t\toid_array_clear(&filtered);\n 385 | \t} else if ((!ctx->info->strict_types && path_is_for_direct_objects(path)) ||\n 386 | \t\t (list->type == OBJ_TREE && ctx->info->trees) ||\n 387 | \t\t (list->type == OBJ_BLOB && ctx->info->blobs) ||\n 388 | \t\t (list->type == OBJ_TAG && ctx->info->tags)) {\n 389 | \t\tret = ctx->info->path_fn(path, &list->oids, list->type,\n 390 | \t\t\t\t\tctx->info->path_fn_data);\n 391 | \t}","lines":27}]},{"id":"LOGIC_18","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"preload_thread() uses a do/while loop that dereferences *cep++ before testing the\ncounter (do { ce = *cep++; ... } while (--nr > 0);). This is only safe because\npreload_index() partitions with work = DIV_ROUND_UP(cache_nr, threads), which\nguarantees every thread receives nr >= 1. The body running before the count check\nmeans any future change to the partitioning (different work formula, or a thread\nwith offset == cache_nr) becomes a one-element out-of-bounds read. The invariant\nis fragile and undocumented.","fix":"Convert to a while loop (or early-return when nr <= 0) so correctness does not\ndepend solely on the partitioning math:\n while (nr-- > 0) { struct cache_entry *ce = *cep++; ... }","locations":[{"ref":"preload-index.c:60-94","code":" 60 | \tdo {\n 61 | \t\tstruct cache_entry *ce = *cep++;\n 62 | \t\tstruct stat st;\n 63 | \n 64 | \t\tif (ce_stage(ce))\n 65 | \t\t\tcontinue;\n 66 | \t\tif (S_ISGITLINK(ce->ce_mode))\n 67 | \t\t\tcontinue;\n 68 | \t\tif (ce_uptodate(ce))\n 69 | \t\t\tcontinue;\n 70 | \t\tif (ce_skip_worktree(ce))\n 71 | \t\t\tcontinue;\n 72 | \t\tif (ce->ce_flags & CE_FSMONITOR_VALID)\n 73 | \t\t\tcontinue;\n 74 | \t\tif (p->progress && !(nr & 31)) {\n 75 | \t\t\tstruct progress_data *pd = p->progress;\n 76 | \n 77 | \t\t\tpthread_mutex_lock(&pd->mutex);\n 78 | \t\t\tpd->n += last_nr - nr;\n 79 | \t\t\tdisplay_progress(pd->progress, pd->n);\n 80 | \t\t\tpthread_mutex_unlock(&pd->mutex);\n 81 | \t\t\tlast_nr = nr;\n 82 | \t\t}\n 83 | \t\tif (!ce_path_match(index, ce, &p->pathspec, NULL))\n 84 | \t\t\tcontinue;\n 85 | \t\tif (threaded_has_symlink_leading_path(&cache, ce->name, ce_namelen(ce)))\n 86 | \t\t\tcontinue;\n 87 | \t\tp->t2_nr_lstat++;\n 88 | \t\tif (lstat(ce->name, &st))\n 89 | \t\t\tcontinue;\n 90 | \t\tif (ie_match_stat(index, ce, &st, CE_MATCH_RACY_IS_DIRTY|CE_MATCH_IGNORE_FSMONITOR))\n 91 | \t\t\tcontinue;\n 92 | \t\tce_mark_uptodate(ce);\n 93 | \t\tmark_fsmonitor_valid(index, ce);\n 94 | \t} while (--nr > 0);","lines":35},{"ref":"preload-index.c:121-156","code":" 121 | \tthreads = index->cache_nr / THREAD_COST;\n 122 | \tif ((index->cache_nr > 1) && (threads < 2) && git_env_bool(\"GIT_TEST_PRELOAD_INDEX\", 0))\n 123 | \t\tthreads = 2;\n 124 | \tif (threads < 2)\n 125 | \t\treturn;\n 126 | \n 127 | \ttrace2_region_enter(\"index\", \"preload\", NULL);\n 128 | \n 129 | \ttrace_performance_enter();\n 130 | \tif (threads > MAX_PARALLEL)\n 131 | \t\tthreads = MAX_PARALLEL;\n 132 | \toffset = 0;\n 133 | \twork = DIV_ROUND_UP(index->cache_nr, threads);\n 134 | \tmemset(&data, 0, sizeof(data));\n 135 | \n 136 | \tmemset(&pd, 0, sizeof(pd));\n 137 | \tif (refresh_flags & REFRESH_PROGRESS && isatty(2)) {\n 138 | \t\tpd.progress = start_delayed_progress(index->repo,\n 139 | \t\t\t\t\t\t _(\"Refreshing index\"),\n 140 | \t\t\t\t\t\t index->cache_nr);\n 141 | \t\tpthread_mutex_init(&pd.mutex, NULL);\n 142 | \t}\n 143 | \n 144 | \tfor (i = 0; i < threads; i++) {\n 145 | \t\tstruct thread_data *p = data+i;\n 146 | \t\tint err;\n 147 | \n 148 | \t\tp->index = index;\n 149 | \t\tif (pathspec)\n 150 | \t\t\tcopy_pathspec(&p->pathspec, pathspec);\n 151 | \t\tp->offset = offset;\n 152 | \t\tp->nr = work;\n 153 | \t\tif (pd.progress)\n 154 | \t\t\tp->progress = &pd;\n 155 | \t\toffset += work;\n 156 | \t\terr = pthread_create(&p->pthread, NULL, preload_thread, p);","lines":36}]},{"id":"LOGIC_19","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"In create_from_disk() for v4 (delta-compressed names), copy_len is derived from\nthe previous entry's name (previous_len - strip_len), and when len == CE_NAMEMASK\nthe full length is taken as strlen(name) + copy_len. The subsequent\nmemcpy(ce->name + copy_len, name, len + 1 - copy_len) trusts that the on-disk\nappended-name segment `name` is NUL-terminated within the mapping and that\nlen + 1 - copy_len does not underflow. strip_len is validated against\nprevious_len (die if previous_len < strip_len), which is good, but there is no\nvalidation that the appended name plus its NUL actually lies within mmap_size\n(see the high-severity OOB finding) nor that the non-CE_NAMEMASK `len` is\nconsistent with copy_len (a declared len < copy_len makes len + 1 - copy_len\nwrap to a huge size_t passed to memcpy). The strip_len/previous_len guard shows\nthe author intended such checks; the len-vs-copy_len relationship is left\nunchecked.","fix":"After determining len, assert copy_len <= len before computing\nlen + 1 - copy_len, and (combined with bounds-checking against mmap_size) ensure\nthe source name region and its terminating NUL are within the mapping. die() on\ninconsistency.","locations":[{"ref":"read-cache.c:1819-1879","code":" 1819 | \tif (expand_name_field) {\n 1820 | \t\tconst unsigned char *cp = (const unsigned char *)name;\n 1821 | \t\tuint64_t strip_len, previous_len;\n 1822 | \n 1823 | \t\t/* If we're at the beginning of a block, ignore the previous name */\n 1824 | \t\tstrip_len = decode_varint(&cp);\n 1825 | \t\tif (previous_ce) {\n 1826 | \t\t\tprevious_len = previous_ce->ce_namelen;\n 1827 | \t\t\tif (previous_len < strip_len)\n 1828 | \t\t\t\tdie(_(\"malformed name field in the index, near path '%s'\"),\n 1829 | \t\t\t\t\tprevious_ce->name);\n 1830 | \t\t\tcopy_len = previous_len - strip_len;\n 1831 | \t\t}\n 1832 | \t\tname = (const char *)cp;\n 1833 | \t}\n 1834 | \n 1835 | \tif (len == CE_NAMEMASK) {\n 1836 | \t\tlen = strlen(name);\n 1837 | \t\tif (expand_name_field)\n 1838 | \t\t\tlen += copy_len;\n 1839 | \t}\n 1840 | \n 1841 | \tce = mem_pool__ce_alloc(ce_mem_pool, len);\n 1842 | \n 1843 | \t/*\n 1844 | \t * NEEDSWORK: using 'offsetof()' is cumbersome and should be replaced\n 1845 | \t * with something more akin to 'load_bitmap_entries_v1()'s use of\n 1846 | \t * 'read_be16'/'read_be32'. For consistency with the corresponding\n 1847 | \t * ondisk entry write function ('copy_cache_entry_to_ondisk()'), this\n 1848 | \t * should be done at the same time as removing references to\n 1849 | \t * 'ondisk_cache_entry' there.\n 1850 | \t */\n 1851 | \tce->ce_stat_data.sd_ctime.sec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, ctime)\n 1852 | \t\t\t\t\t\t\t+ offsetof(struct cache_time, sec));\n 1853 | \tce->ce_stat_data.sd_mtime.sec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, mtime)\n 1854 | \t\t\t\t\t\t\t+ offsetof(struct cache_time, sec));\n 1855 | \tce->ce_stat_data.sd_ctime.nsec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, ctime)\n 1856 | \t\t\t\t\t\t\t + offsetof(struct cache_time, nsec));\n 1857 | \tce->ce_stat_data.sd_mtime.nsec = get_be32(ondisk + offsetof(struct ondisk_cache_entry, mtime)\n 1858 | \t\t\t\t\t\t\t + offsetof(struct cache_time, nsec));\n 1859 | \tce->ce_stat_data.sd_dev = get_be32(ondisk + offsetof(struct ondisk_cache_entry, dev));\n 1860 | \tce->ce_stat_data.sd_ino = get_be32(ondisk + offsetof(struct ondisk_cache_entry, ino));\n 1861 | \tce->ce_mode = get_be32(ondisk + offsetof(struct ondisk_cache_entry, mode));\n 1862 | \tce->ce_stat_data.sd_uid = get_be32(ondisk + offsetof(struct ondisk_cache_entry, uid));\n 1863 | \tce->ce_stat_data.sd_gid = get_be32(ondisk + offsetof(struct ondisk_cache_entry, gid));\n 1864 | \tce->ce_stat_data.sd_size = get_be32(ondisk + offsetof(struct ondisk_cache_entry, size));\n 1865 | \tce->ce_flags = flags & ~CE_NAMEMASK;\n 1866 | \tce->ce_namelen = len;\n 1867 | \tce->index = 0;\n 1868 | \toidread(&ce->oid, (const unsigned char *)ondisk + offsetof(struct ondisk_cache_entry, data),\n 1869 | \t\tthe_repository->hash_algo);\n 1870 | \n 1871 | \tif (expand_name_field) {\n 1872 | \t\tif (copy_len)\n 1873 | \t\t\tmemcpy(ce->name, previous_ce->name, copy_len);\n 1874 | \t\tmemcpy(ce->name + copy_len, name, len + 1 - copy_len);\n 1875 | \t\t*ent_size = (name - ((char *)ondisk)) + len + 1 - copy_len;\n 1876 | \t} else {\n 1877 | \t\tmemcpy(ce->name, name, len + 1);\n 1878 | \t\t*ent_size = ondisk_ce_size(ce);\n 1879 | \t}","lines":61}]},{"id":"LOGIC_20","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"debug_ref_iterator_advance() copies the underlying iterator's ref unconditionally\neven when advance() did not return ITER_OK. After\n`res = diter->iter->vtable->advance(diter->iter);`, when res is ITER_DONE or\nITER_ERROR the underlying iterator's `ref` field is not guaranteed valid, yet the\nwrapper still executes `diter->base.ref = diter->iter->ref;`. This copies a\nstale/undefined ref into the debug iterator's base. The generic iterator\ncontract is that ref is only valid after ITER_OK; well-behaved callers won't read\nit, so impact is limited, but the unconditional copy is incorrect and can surface\nunder tracing (GIT_TRACE_REFS).","fix":"Only propagate the ref on success:\n\n int res = diter->iter->vtable->advance(diter->iter);\n if (res == ITER_OK) {\n diter->base.ref = diter->iter->ref;\n trace_printf_key(&trace_refs, \"iterator_advance: %s (0)\\n\",\n diter->iter->ref.name);\n } else {\n trace_printf_key(&trace_refs, \"iterator_advance: (%d)\\n\", res);\n }\n return res;","locations":[{"ref":"refs/debug.c:173-186","code":" 173 | static int debug_ref_iterator_advance(struct ref_iterator *ref_iterator)\n 174 | {\n 175 | \tstruct debug_ref_iterator *diter =\n 176 | \t\t(struct debug_ref_iterator *)ref_iterator;\n 177 | \tint res = diter->iter->vtable->advance(diter->iter);\n 178 | \tif (res)\n 179 | \t\ttrace_printf_key(&trace_refs, \"iterator_advance: (%d)\\n\", res);\n 180 | \telse\n 181 | \t\ttrace_printf_key(&trace_refs, \"iterator_advance: %s (0)\\n\",\n 182 | \t\t\tditer->iter->ref.name);\n 183 | \n 184 | \tditer->base.ref = diter->iter->ref;\n 185 | \treturn res;\n 186 | }","lines":14}]},{"id":"LOGIC_21","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"In packed_fsck_ref_main_line(), when a refname both contains an embedded NUL and\nfails check_refname_format(), two separate fsck reports are produced for the\nsame line, but the shared packed_entry strbuf is not reset between them. The\nNUL-check branch does strbuf_addf(&packed_entry, \"packed-refs line %lu\",\nline_number) and sets report.path = packed_entry.buf, then falls through (no\ngoto cleanup); the refname-format branch then does another strbuf_addf onto the\nsame, non-reset strbuf, so report.path for the second report becomes a doubled,\ngarbled string like \"packed-refs line 5packed-refs line 5\".","fix":"Reset the strbuf before reusing it for the second report (strbuf_reset(&packed_entry)\nbefore the second strbuf_addf), or `goto cleanup` after the NUL report since a\nrefname containing embedded NULs cannot be meaningfully format-checked.","locations":[{"ref":"refs/packed-backend.c:1944-1961","code":" 1944 | \tif (refname_contains_nul(refname)) {\n 1945 | \t\tstrbuf_addf(&packed_entry, \"packed-refs line %lu\", line_number);\n 1946 | \t\treport.path = packed_entry.buf;\n 1947 | \n 1948 | \t\tret = fsck_report_ref(o, &report,\n 1949 | \t\t\t\t FSCK_MSG_BAD_PACKED_REF_ENTRY,\n 1950 | \t\t\t\t \"refname '%s' contains NULL binaries\",\n 1951 | \t\t\t\t refname->buf);\n 1952 | \t}\n 1953 | \n 1954 | \tif (check_refname_format(refname->buf, 0)) {\n 1955 | \t\tstrbuf_addf(&packed_entry, \"packed-refs line %lu\", line_number);\n 1956 | \t\treport.path = packed_entry.buf;\n 1957 | \n 1958 | \t\tret = fsck_report_ref(o, &report,\n 1959 | \t\t\t\t FSCK_MSG_BAD_REF_NAME,\n 1960 | \t\t\t\t \"has bad refname '%s'\", refname->buf);\n 1961 | \t}","lines":18}]},{"id":"LOGIC_22","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"midx_has_unknown_packs() selects its branch on 'if (geometry)' (the pointer), but geometry is always a non-NULL pointer: builtin/repack.c always sets opts.geometry = &geometry even in non-geometric mode (the struct is just zero-initialized). Consequently the 'else' branch that consults existing->non_kept_packs is dead code and is never reached. In non-geometric mode geometry->split is 0, so the inner loop runs zero iterations and every MIDX'd pack falls through to 'return 1'. The sibling function midx_included_packs() correctly distinguishes the modes via geometry->split_factor, so the two functions disagree about what 'using pack geometry' means.","fix":"Branch on geometry->split_factor (as midx_included_packs does) instead of on the always-non-NULL geometry pointer, so the non-geometric path actually consults existing->non_kept_packs.","locations":[{"ref":"repack-midx.c:104-131","code":" 104 | \t\t} else if (geometry) {\n 105 | \t\t\tstruct strbuf buf = STRBUF_INIT;\n 106 | \t\t\tuint32_t j;\n 107 | \n 108 | \t\t\tfor (j = 0; j < geometry->split; j++) {\n 109 | \t\t\t\tstrbuf_reset(&buf);\n 110 | \t\t\t\tstrbuf_addstr(&buf, pack_basename(geometry->pack[j]));\n 111 | \t\t\t\tstrbuf_strip_suffix(&buf, \".pack\");\n 112 | \t\t\t\tstrbuf_addstr(&buf, \".idx\");\n 113 | \n 114 | \t\t\t\tif (!strcmp(pack_name, buf.buf)) {\n 115 | \t\t\t\t\tstrbuf_release(&buf);\n 116 | \t\t\t\t\tbreak;\n 117 | \t\t\t\t}\n 118 | \t\t\t}\n 119 | \n 120 | \t\t\tstrbuf_release(&buf);\n 121 | \n 122 | \t\t\tif (j < geometry->split)\n 123 | \t\t\t\tcontinue;\n 124 | \t\t} else {\n 125 | \t\t\tstruct string_list_item *item;\n 126 | \n 127 | \t\t\titem = string_list_lookup(&existing->non_kept_packs,\n 128 | \t\t\t\t\t\t pack_name);\n 129 | \t\t\tif (item && !existing_pack_is_marked_for_deletion(item))\n 130 | \t\t\t\tcontinue;\n 131 | \t\t}","lines":28},{"ref":"builtin/repack.c:584","code":" 584 | \t\t\t.geometry = &geometry,","lines":1},{"ref":"repack-midx.c:164-188","code":" 164 | \tif (geometry->split_factor) {\n 165 | \t\tuint32_t i;\n 166 | \n 167 | \t\tfor (i = geometry->split; i < geometry->pack_nr; i++) {\n 168 | \t\t\tstruct packed_git *p = geometry->pack[i];\n 169 | \n 170 | \t\t\t/*\n 171 | \t\t\t * The multi-pack index never refers to packfiles part\n 172 | \t\t\t * of an alternate object database, so we skip these.\n 173 | \t\t\t * While git-multi-pack-index(1) would silently ignore\n 174 | \t\t\t * them anyway, this allows us to skip executing the\n 175 | \t\t\t * command completely when we have only non-local\n 176 | \t\t\t * packfiles.\n 177 | \t\t\t */\n 178 | \t\t\tif (!p->pack_local)\n 179 | \t\t\t\tcontinue;\n 180 | \n 181 | \t\t\tstrbuf_reset(&buf);\n 182 | \t\t\tstrbuf_addstr(&buf, pack_basename(p));\n 183 | \t\t\tstrbuf_strip_suffix(&buf, \".pack\");\n 184 | \t\t\tstrbuf_addstr(&buf, \".idx\");\n 185 | \n 186 | \t\t\tstring_list_insert(include, buf.buf);\n 187 | \t\t}\n 188 | \t} else {","lines":25}]},{"id":"LOGIC_23","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"`git_hash_init` (hash.c:261-264) immediately dereferences `algop` (`algop->init_fn(ctx)`) with no NULL check, and `hash_algo_ptr_by_number` (hash.c:244-249) returns NULL for any algo number >= GIT_HASH_NALGOS. The Rust `HashAlgorithm` enum is restricted to {1,2} so `hash_algo_ptr` cannot currently produce NULL, but the FFI binding `hash_algo_ptr_by_number(n: u32)` (src/hash.rs:348) is declared with a raw `u32` and the returned `*const c_void` is fed unchecked into `git_hash_init` (src/hash.rs:147) and `hashfd` (src/csum_file.rs:31). If any future caller obtains the pointer for algo 0 (GIT_HASH_UNKNOWN, a valid-but-unusable slot) or an out-of-range number, the NULL/garbage pointer flows into C and causes a NULL deref or use of an uninitialized algo. The Rust side never checks the pointer for NULL before passing it on.","fix":"Have `hash_algo_ptr()`/the binding treat a NULL return from `hash_algo_ptr_by_number` as an error (panic or Result) before passing it to `git_hash_init`/`hashfd`, and/or add a NULL check in the C `git_hash_init`. Keep the `HashAlgorithm` enum as the only source of algo numbers.","locations":[{"ref":"src/hash.rs:334-336","code":" 334 | pub fn hash_algo_ptr(self) -> *const c_void {\n 335 | unsafe { c::hash_algo_ptr_by_number(self as u32) }\n 336 | }","lines":3},{"ref":"src/hash.rs:145-149","code":" 145 | pub fn new(algo: HashAlgorithm) -> Self {\n 146 | let ctx = unsafe { c::git_hash_alloc() };\n 147 | unsafe { c::git_hash_init(ctx, algo.hash_algo_ptr()) };\n 148 | Self { algo, ctx }\n 149 | }","lines":5},{"ref":"hash.c:244-264","code":" 244 | const struct git_hash_algo *hash_algo_ptr_by_number(uint32_t algo)\n 245 | {\n 246 | \tif (algo >= GIT_HASH_NALGOS)\n 247 | \t\treturn NULL;\n 248 | \treturn &hash_algos[algo];\n 249 | }\n 250 | \n 251 | struct git_hash_ctx *git_hash_alloc(void)\n 252 | {\n 253 | \treturn xmalloc(sizeof(struct git_hash_ctx));\n 254 | }\n 255 | \n 256 | void git_hash_free(struct git_hash_ctx *ctx)\n 257 | {\n 258 | \tfree(ctx);\n 259 | }\n 260 | \n 261 | void git_hash_init(struct git_hash_ctx *ctx, const struct git_hash_algo *algop)\n 262 | {\n 263 | \talgop->init_fn(ctx);\n 264 | }","lines":21}]},{"id":"LOGIC_24","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"last_matching_offset iterates `for i in 0..=algop.raw_len()`, an inclusive range, and indexes `a.hash[i]` / `b.hash[i]`. `hash` is a fixed `[u8; GIT_MAX_RAWSZ]` (32 bytes) and `raw_len()` is 32 for SHA256, so when two object IDs match in all bytes the loop reaches `i == 32` and indexes `hash[32]`, which is out of bounds and panics. The intended behaviour (returning `raw_len()` for a full match) is already handled by the fallback `return` after the loop, so the range should be exclusive. In current usage the inputs are distinct BTreeMap keys that always differ within the meaningful bytes, so the OOB is not hit today, but it is a real off-by-one waiting on any future caller that can pass equal hashes.","fix":"Change the loop bound to the exclusive `for i in 0..algop.raw_len()`. The post-loop `algop.raw_len()` already covers the all-bytes-equal case without indexing `hash[raw_len()]`.","locations":[{"ref":"src/loose.rs:157-164","code":" 157 | fn last_matching_offset(a: &ObjectID, b: &ObjectID, algop: HashAlgorithm) -> usize {\n 158 | for i in 0..=algop.raw_len() {\n 159 | if a.hash[i] != b.hash[i] {\n 160 | return i;\n 161 | }\n 162 | }\n 163 | algop.raw_len()\n 164 | }","lines":8}]},{"id":"LOGIC_25","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"trailer_iterator_advance() violates parse_trailer()'s documented contract by\npassing separator_pos == 0. The contract at trailer.c:651-657 states \"separator_pos\nmust not be 0, since the token cannot be an empty string\", and every other caller\nenforces this: parse_trailers guards with `if (separator_pos >= 1)`,\nparse_trailers_from_command_line_args special-cases == 0, and validate_trailer_args\nrejects == 0. But the iterator calls find_separator() (line 1233) and feeds the\nresult straight into parse_trailer() (line 1238-1239) with no guard.\n\nfind_separator() returns 0 for any trailer-block line that begins with a separator\ncharacter (e.g. \":value\"). With separator_pos == 0, parse_trailer() builds an empty\ntoken (strbuf_add(tok, trailer, 0)), so tok_len becomes 0 and\ntoken_matches_item() does strncasecmp(tok, name, 0) which always returns 0 (match).\nThe empty-token line therefore spuriously \"matches\" the first configured trailer in\nconf_head and is emitted by the iterator under that configured key/conf. Not a\ncrash, but incorrect parsing that diverges from every other trailer code path.","fix":"Guard the iterator like the other callers, e.g. treat a 0 result as \"no separator\":\n ssize_t separator_pos = find_separator(line, separators);\n if (separator_pos < 1)\n separator_pos = -1;\nso parse_trailer() is never invoked with separator_pos == 0.","locations":[{"ref":"trailer.c:1229-1242","code":" 1229 | int trailer_iterator_advance(struct trailer_iterator *iter)\n 1230 | {\n 1231 | \tif (iter->internal.cur < iter->internal.trailer_block->trailer_nr) {\n 1232 | \t\tchar *line = iter->internal.trailer_block->trailers[iter->internal.cur++];\n 1233 | \t\tint separator_pos = find_separator(line, separators);\n 1234 | \n 1235 | \t\titer->raw = line;\n 1236 | \t\tstrbuf_reset(&iter->key);\n 1237 | \t\tstrbuf_reset(&iter->val);\n 1238 | \t\tparse_trailer(&iter->key, &iter->val, NULL,\n 1239 | \t\t\t line, separator_pos);\n 1240 | \t\t/* Always unfold values during iteration. */\n 1241 | \t\tunfold_value(&iter->val);\n 1242 | \t\treturn 1;","lines":14},{"ref":"trailer.c:615-619","code":" 615 | static int token_matches_item(const char *tok, struct arg_item *item, size_t tok_len)\n 616 | {\n 617 | \tif (!strncasecmp(tok, item->conf.name, tok_len))\n 618 | \t\treturn 1;\n 619 | \treturn item->conf.key ? !strncasecmp(tok, item->conf.key, tok_len) : 0;","lines":5},{"ref":"trailer.c:651-674","code":" 651 | /*\n 652 | * Obtain the token, value, and conf from the given trailer.\n 653 | *\n 654 | * separator_pos must not be 0, since the token cannot be an empty string.\n 655 | *\n 656 | * If separator_pos is -1, interpret the whole trailer as a token.\n 657 | */\n 658 | static void parse_trailer(struct strbuf *tok, struct strbuf *val,\n 659 | \t\t\t const struct conf_info **conf, const char *trailer,\n 660 | \t\t\t ssize_t separator_pos)\n 661 | {\n 662 | \tstruct arg_item *item;\n 663 | \tsize_t tok_len;\n 664 | \tstruct list_head *pos;\n 665 | \n 666 | \tif (separator_pos != -1) {\n 667 | \t\tstrbuf_add(tok, trailer, separator_pos);\n 668 | \t\tstrbuf_trim(tok);\n 669 | \t\tstrbuf_addstr(val, trailer + separator_pos + 1);\n 670 | \t\tstrbuf_trim(val);\n 671 | \t} else {\n 672 | \t\tstrbuf_addstr(tok, trailer);\n 673 | \t\tstrbuf_trim(tok);\n 674 | \t}","lines":24}]},{"id":"LOGIC_26","type":"logic_error","type_label":"Logic Error","severity":"low","easy":false,"description":"super_prefixed() caches both super_prefix_len and the per-buffer prefix text in\nfunction-static state on its first invocation and never recomputes them, even\nthough the super_prefix is passed in (from o->super_prefix) on every call. Only\nthe first call's value is honored. If the first call has super_prefix == NULL,\nthe length is pinned to 0 forever and a later unpack_trees run that does have a\nsuper_prefix emits error/warning paths with no prefix; if the first call has\n\"A/\", a later run with \"B/\" still prepends the stale \"A/\". Reachable when\nunpack_trees runs multiple times in one process with different super_prefix\nvalues (e.g. recursive submodule operations), producing incorrect path reporting\nin error/warning messages.","fix":"Do not cache state derived from the super_prefix argument across calls. Rebuild\nthe prefixed buffer from the passed super_prefix on every call (or assert the\nargument matches the cached value).","locations":[{"ref":"unpack-trees.c:85-117","code":" 85 | static const char *super_prefixed(const char *path, const char *super_prefix)\n 86 | {\n 87 | \t/*\n 88 | \t * It is necessary and sufficient to have two static buffers\n 89 | \t * here, as the return value of this function is fed to\n 90 | \t * error() using the unpack_*_errors[] templates we see above.\n 91 | \t */\n 92 | \tstatic struct strbuf buf[2] = {STRBUF_INIT, STRBUF_INIT};\n 93 | \tstatic int super_prefix_len = -1;\n 94 | \tstatic unsigned idx = ARRAY_SIZE(buf) - 1;\n 95 | \n 96 | \tif (super_prefix_len < 0) {\n 97 | \t\tif (!super_prefix) {\n 98 | \t\t\tsuper_prefix_len = 0;\n 99 | \t\t} else {\n 100 | \t\t\tint i;\n 101 | \t\t\tfor (i = 0; i < ARRAY_SIZE(buf); i++)\n 102 | \t\t\t\tstrbuf_addstr(&buf[i], super_prefix);\n 103 | \t\t\tsuper_prefix_len = buf[0].len;\n 104 | \t\t}\n 105 | \t}\n 106 | \n 107 | \tif (!super_prefix_len)\n 108 | \t\treturn path;\n 109 | \n 110 | \tif (++idx >= ARRAY_SIZE(buf))\n 111 | \t\tidx = 0;\n 112 | \n 113 | \tstrbuf_setlen(&buf[idx], super_prefix_len);\n 114 | \tstrbuf_addstr(&buf[idx], path);\n 115 | \n 116 | \treturn buf[idx].buf;\n 117 | }","lines":33}]},{"id":"LOGIC_27","type":"logic_error","type_label":"Logic Error","severity":"low","easy":true,"description":"In `process_deepen_since()` the validation guard contains a check on the\npointer `deepen_since` rather than the value it points to. The pointer is\nunconditionally dereferenced one line earlier (`*deepen_since = parse_timestamp(...)`),\nso by the time the guard runs `deepen_since` is guaranteed non-NULL and the\n`!deepen_since` sub-expression is always false - it is dead code. Both call\nsites (upload-pack.c:1104 and upload-pack.c:1639) pass the address of\n`data->deepen_since`, so it can never actually be NULL.\n\nThe intent was almost certainly `!*deepen_since`, i.e. reject a parsed\ntimestamp of 0, mirroring the adjacent `*deepen_since == -1` special-case\ncheck. As written, a client-supplied `deepen-since 0` line is accepted and\nforwarded to `git rev-list --max-age=0`. This is not memory-unsafe (the\npointer is never NULL and the dereference is valid), so it is purely a\ncorrectness / dead-code defect, not a remotely exploitable one.","fix":"Replace the pointer test with a value test:\n\n if (!end || *end || !*deepen_since ||\n /* revisions.c's max_age -1 is special */\n *deepen_since == -1)\n die(\"Invalid deepen-since: %s\", line);\n\n(If a zero timestamp is in fact intended to be valid, the `!deepen_since`\nclause should simply be removed, since it can never be true.)","locations":[{"ref":"upload-pack.c:994-999","code":" 994 | \t\tchar *end = NULL;\n 995 | \t\t*deepen_since = parse_timestamp(arg, &end, 0);\n 996 | \t\tif (!end || *end || !deepen_since ||\n 997 | \t\t /* revisions.c's max_age -1 is special */\n 998 | \t\t *deepen_since == -1)\n 999 | \t\t\tdie(\"Invalid deepen-since: %s\", line);","lines":6}]},{"id":"LOGIC_28","type":"logic_error","type_label":"Logic Error","severity":"negligible","easy":false,"description":"The PCRE2 < 10.36 workaround tests\n'PCRE2_MATCH_INVALID_UTF && options & (PCRE2_UTF | PCRE2_CASELESS)'.\nPCRE2_MATCH_INVALID_UTF is a fixed nonzero macro constant, so the left operand\nof '&&' is always true and the leading term gates nothing. The result happens\nto match the intent, so behavior is correct, but the expression is misleading\n- it reads as a feature guard that has no effect.","fix":"Drop the no-op 'PCRE2_MATCH_INVALID_UTF &&' term (the surrounding #ifndef\nalready provides the version guard).","locations":[{"ref":"grep.c:337-341","code":" 337 | #ifndef GIT_PCRE2_VERSION_10_36_OR_HIGHER\n 338 | \t/* Work around https://bugs.exim.org/show_bug.cgi?id=2642 fixed in 10.36 */\n 339 | \tif (PCRE2_MATCH_INVALID_UTF && options & (PCRE2_UTF | PCRE2_CASELESS))\n 340 | \t\toptions |= PCRE2_NO_START_OPTIMIZE;\n 341 | #endif","lines":5}]},{"id":"LOGIC_29","type":"logic_error","type_label":"Logic Error","severity":"negligible","easy":false,"description":"In `tloop_join()` (pthreads variant of the transport-helper bidirectional pump), the order of checks is inverted: the thread return value `tret` is inspected (`if (!tret)`) before the `err` returned by `pthread_join` is checked. If `pthread_join` fails, `tret` was never assigned by the join and holds an indeterminate value, so the wrong error may be reported (or none). In practice `pthread_join` rarely fails here so impact is negligible, but the check ordering is logically wrong.","fix":"Check `if (err)` (the pthread_join result) first, then evaluate `tret`.","locations":[{"ref":"transport-helper.c:1517-1531","code":" 1517 | static int tloop_join(pthread_t thread, const char *name)\n 1518 | {\n 1519 | \tint err;\n 1520 | \tvoid *tret;\n 1521 | \terr = pthread_join(thread, &tret);\n 1522 | \tif (!tret) {\n 1523 | \t\terror(_(\"%s thread failed\"), name);\n 1524 | \t\treturn 1;\n 1525 | \t}\n 1526 | \tif (err) {\n 1527 | \t\terror(_(\"%s thread failed to join: %s\"), name, strerror(err));\n 1528 | \t\treturn 1;\n 1529 | \t}\n 1530 | \treturn 0;\n 1531 | }","lines":15}]},{"id":"CPY_1","type":"copy_paste","type_label":"Copy Paste","severity":"negligible","easy":false,"description":"In parse_bundle_uri()'s validation, the error message for a malformed URI prints the\n`file` argument instead of the `uri` argument that actually failed the\nstrpbrk(uri, \" \\n\") check. The diagnostic shows the destination temp filename rather\nthan the offending server-supplied URI, which is misleading when diagnosing a\nmalicious/malformed bundle list. The filename-malformed message just below correctly\nreferences `file`.","fix":"Report the URI that failed:\n\treturn error(\"bundle-uri: URI is malformed: '%s'\", uri);","locations":[{"ref":"bundle-uri.c:334-335","code":" 334 | \tif (strpbrk(uri, \" \\n\"))\n 335 | \t\treturn error(\"bundle-uri: URI is malformed: '%s'\", file);","lines":2}]},{"id":"ERR_1","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":false,"description":"read_link_extension() feeds the untrusted delete/replace EWAH bitmaps straight\ninto ewah_read_mmap() and performs no validation of bit positions or bitmap size\nat read time. It relies entirely on merge_base_index() ->\nmark_entry_for_delete()/replace_entry() to die() when a set bit's position is\n>= istate->cache_nr. That covers bit positions, but ewah_read_mmap() itself\ncomputes self->rlw = self->buffer + get_be32(ptr) using an attacker-controlled\n32-bit offset with no check that the offset is within the bitmap buffer; the bad\nrlw pointer is later dereferenced while iterating bits during merge_base_index().\nsplit-index.c is the untrusted entry point that makes this reachable from a\nmalicious on-disk index.","fix":"Validate the EWAH bitmaps before merge_base_index() iterates them: bounds-check\nthe rlw offset against the buffer size in ewah_read_mmap (reject get_be32(rlw) >=\nbuffer_size), and/or verify bitmap bit_size does not exceed the base index size\nin read_link_extension.","locations":[{"ref":"split-index.c:25-54","code":" 25 | int read_link_extension(struct index_state *istate,\n 26 | \t\t\t const void *data_, unsigned long sz)\n 27 | {\n 28 | \tconst struct git_hash_algo *algo = istate->repo->hash_algo;\n 29 | \tconst unsigned char *data = data_;\n 30 | \tstruct split_index *si;\n 31 | \tint ret;\n 32 | \n 33 | \tif (sz < algo->rawsz)\n 34 | \t\treturn error(\"corrupt link extension (too short)\");\n 35 | \tsi = init_split_index(istate);\n 36 | \toidread(&si->base_oid, data, algo);\n 37 | \tdata += algo->rawsz;\n 38 | \tsz -= algo->rawsz;\n 39 | \tif (!sz)\n 40 | \t\treturn 0;\n 41 | \tsi->delete_bitmap = ewah_new();\n 42 | \tret = ewah_read_mmap(si->delete_bitmap, data, sz);\n 43 | \tif (ret < 0)\n 44 | \t\treturn error(\"corrupt delete bitmap in link extension\");\n 45 | \tdata += ret;\n 46 | \tsz -= ret;\n 47 | \tsi->replace_bitmap = ewah_new();\n 48 | \tret = ewah_read_mmap(si->replace_bitmap, data, sz);\n 49 | \tif (ret < 0)\n 50 | \t\treturn error(\"corrupt replace bitmap in link extension\");\n 51 | \tif (ret != sz)\n 52 | \t\treturn error(\"garbage at the end of link extension\");\n 53 | \treturn 0;\n 54 | }","lines":30},{"ref":"split-index.c:164-208","code":" 164 | void merge_base_index(struct index_state *istate)\n 165 | {\n 166 | \tstruct split_index *si = istate->split_index;\n 167 | \tunsigned int i;\n 168 | \n 169 | \tmark_base_index_entries(si->base);\n 170 | \n 171 | \tsi->saved_cache\t = istate->cache;\n 172 | \tsi->saved_cache_nr = istate->cache_nr;\n 173 | \tistate->cache_nr = si->base->cache_nr;\n 174 | \tistate->cache\t = NULL;\n 175 | \tistate->cache_alloc = 0;\n 176 | \tALLOC_GROW(istate->cache, istate->cache_nr, istate->cache_alloc);\n 177 | \tCOPY_ARRAY(istate->cache, si->base->cache, istate->cache_nr);\n 178 | \n 179 | \tsi->nr_deletions = 0;\n 180 | \tsi->nr_replacements = 0;\n 181 | \tewah_each_bit(si->replace_bitmap, replace_entry, istate);\n 182 | \tewah_each_bit(si->delete_bitmap, mark_entry_for_delete, istate);\n 183 | \tif (si->nr_deletions)\n 184 | \t\tremove_marked_cache_entries(istate, 0);\n 185 | \n 186 | \tfor (i = si->nr_replacements; i < si->saved_cache_nr; i++) {\n 187 | \t\tif (!ce_namelen(si->saved_cache[i]))\n 188 | \t\t\tdie(\"corrupt link extension, entry %d should \"\n 189 | \t\t\t \"have non-zero length name\", i);\n 190 | \t\tadd_index_entry(istate, si->saved_cache[i],\n 191 | \t\t\t\tADD_CACHE_OK_TO_ADD |\n 192 | \t\t\t\tADD_CACHE_KEEP_CACHE_TREE |\n 193 | \t\t\t\t/*\n 194 | \t\t\t\t * we may have to replay what\n 195 | \t\t\t\t * merge-recursive.c:update_stages()\n 196 | \t\t\t\t * does, which has this flag on\n 197 | \t\t\t\t */\n 198 | \t\t\t\tADD_CACHE_SKIP_DFCHECK);\n 199 | \t\tsi->saved_cache[i] = NULL;\n 200 | \t}\n 201 | \n 202 | \tewah_free(si->delete_bitmap);\n 203 | \tewah_free(si->replace_bitmap);\n 204 | \tFREE_AND_NULL(si->saved_cache);\n 205 | \tsi->delete_bitmap = NULL;\n 206 | \tsi->replace_bitmap = NULL;\n 207 | \tsi->saved_cache_nr = 0;\n 208 | }","lines":45}]},{"id":"ERR_2","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","easy":true,"description":"`finalize_hashfile` returns an `int` file descriptor (or 0 after a successful close) per its C contract (csum-file.c:65-101): when `CSUM_CLOSE` is NOT set in `flags`, the function does not close `f->fd` and instead returns it (csum-file.c:87-88, 101) so the caller can keep using or later close it. The Rust binding `finalize(self, component, flags)` discards this return value entirely (src/csum_file.rs:41). If a caller passes `flags` without `CSUM_CLOSE`, the fd that `finalize_hashfile` hands back is silently dropped on the floor -> fd leak. Rust callers have no way to learn the returned fd.","fix":"Return the `c_int` result from `finalize` (e.g. `-> i32` or `-> io::Result<i32>`) instead of discarding it, so callers can close/track the fd, or document that callers must always pass `CSUM_CLOSE` and assert it.","locations":[{"ref":"src/csum_file.rs:39-44","code":" 39 | pub fn finalize(self, component: u32, flags: u32) -> Vec<u8> {\n 40 | let mut result = vec![0u8; GIT_MAX_RAWSZ];\n 41 | unsafe { c::finalize_hashfile(self.ptr, result.as_mut_ptr(), component, flags) };\n 42 | result.truncate(self.algo.raw_len());\n 43 | result\n 44 | }","lines":6},{"ref":"csum-file.c:65-101","code":" 65 | int finalize_hashfile(struct hashfile *f, unsigned char *result,\n 66 | \t\t enum fsync_component component, unsigned int flags)\n 67 | {\n 68 | \tint fd;\n 69 | \n 70 | \thashflush(f);\n 71 | \n 72 | \tif (f->skip_hash)\n 73 | \t\thashclr(f->buffer, f->algop);\n 74 | \telse\n 75 | \t\tgit_hash_final(f->buffer, &f->ctx);\n 76 | \n 77 | \tif (result)\n 78 | \t\thashcpy(result, f->buffer, f->algop);\n 79 | \tif (flags & CSUM_HASH_IN_STREAM)\n 80 | \t\tflush(f, f->buffer, f->algop->rawsz);\n 81 | \tif (flags & CSUM_FSYNC)\n 82 | \t\tfsync_component_or_die(component, f->fd, f->name);\n 83 | \tif (flags & CSUM_CLOSE) {\n 84 | \t\tif (close(f->fd))\n 85 | \t\t\tdie_errno(\"%s: sha1 file error on close\", f->name);\n 86 | \t\tfd = 0;\n 87 | \t} else\n 88 | \t\tfd = f->fd;\n 89 | \tif (0 <= f->check_fd) {\n 90 | \t\tchar discard;\n 91 | \t\tint cnt = read_in_full(f->check_fd, &discard, 1);\n 92 | \t\tif (cnt < 0)\n 93 | \t\t\tdie_errno(\"%s: error when reading the tail of sha1 file\",\n 94 | \t\t\t\t f->name);\n 95 | \t\tif (cnt)\n 96 | \t\t\tdie(\"%s: sha1 file has trailing garbage\", f->name);\n 97 | \t\tif (close(f->check_fd))\n 98 | \t\t\tdie_errno(\"%s: sha1 file error on close\", f->name);\n 99 | \t}\n 100 | \tfree_hashfile(f);\n 101 | \treturn fd;","lines":37}]},{"id":"ERR_3","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"interpolate_path() is documented to \"return NULL on failure\", but its ~/home\nexpansion branch calls strbuf_add_real_path(), which forwards to\nstrbuf_realpath(..., die_on_error=1). There is no non-dying variant, so a\nmissing/inaccessible home or path makes the process die() inside that helper\nrather than returning NULL to the caller, violating the documented contract.\nCallers that expect a recoverable NULL (config include path interpolation,\netc.) instead get an abort.","fix":"Either add a non-dying strbuf_add_real_path variant (passing die_on_error=0 and\nhandling the NULL return in interpolate_path), or correct interpolate_path's\ndocumentation to state that the real_home/~ path can die() rather than return\nNULL.","locations":[{"ref":"abspath.c:318-327","code":" 318 | void strbuf_add_real_path(struct strbuf *sb, const char *path)\n 319 | {\n 320 | \tif (sb->len) {\n 321 | \t\tstruct strbuf resolved = STRBUF_INIT;\n 322 | \t\tstrbuf_realpath(&resolved, path, 1);\n 323 | \t\tstrbuf_addbuf(sb, &resolved);\n 324 | \t\tstrbuf_release(&resolved);\n 325 | \t} else\n 326 | \t\tstrbuf_realpath(sb, path, 1);\n 327 | }","lines":10},{"ref":"path.c:717-718","code":" 717 | \t\t\tif (real_home)\n 718 | \t\t\t\tstrbuf_add_real_path(&user_path, home);","lines":2}]},{"id":"ERR_4","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"In the rebase fast-forward path of cmd_rebase(), the return value of\nmove_to_original_branch() is ignored. That function calls reset_head() which can fail\n(e.g. could not write index / determine HEAD). On failure git has already printed\n\"Fast-forwarded ... to ...\", then unconditionally computes ret = finish_rebase() and\nreturns, reporting overall success (exit 0) even though the branch ref was not moved and\nHEAD may be left detached/inconsistent. Every other call site of\nmove_to_original_branch() (lines 640, 649, 731) propagates its return value; this is the\nlone exception.","fix":"Capture the result: `ret = move_to_original_branch(&options); if (!ret) ret = finish_rebase(&options); goto cleanup;`.","locations":[{"ref":"builtin/rebase.c:1892-1898","code":" 1892 | \tif (oideq(&branch_base, &options.orig_head->object.oid)) {\n 1893 | \t\tprintf(_(\"Fast-forwarded %s to %s.\\n\"),\n 1894 | \t\t\tbranch_name, options.onto_name);\n 1895 | \t\tmove_to_original_branch(&options);\n 1896 | \t\tret = finish_rebase(&options);\n 1897 | \t\tgoto cleanup;\n 1898 | \t}","lines":7},{"ref":"builtin/rebase.c:640","code":" 640 | \t\treturn move_to_original_branch(opts);","lines":1},{"ref":"builtin/rebase.c:649","code":" 649 | \t\treturn move_to_original_branch(opts);","lines":1},{"ref":"builtin/rebase.c:731","code":" 731 | \t\treturn move_to_original_branch(opts);","lines":1}]},{"id":"ERR_5","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"bundle_list_update() resolves a server-supplied \"bundle.<id>.uri\" relative value via\nrelative_url(list->baseURI, value, NULL). relative_url() dereferences its first\nargument unconditionally (strlen(remote_url); BUG() on empty) whenever `value` is a\nrelative URL. bundle_list_update() never verifies list->baseURI is non-NULL. Today every\nreachable caller sets baseURI first, so it is not currently remote-triggerable, but it is\nan undocumented cross-module invariant: any future caller that forgets to set baseURI\nlets a server send `bundle.x.uri=../evil` and crash the client (NULL deref / BUG()).","fix":"Guard the relative-URL resolution against a missing base:\n\n\tif (!strcmp(subkey, \"uri\")) {\n\t\tif (bundle->uri)\n\t\t\treturn -1;\n\t\tif (!list->baseURI)\n\t\t\treturn error(_(\"bundle list has no base URI to resolve '%s'\"), value);\n\t\tbundle->uri = relative_url(list->baseURI, value, NULL);\n\t\treturn 0;\n\t}","locations":[{"ref":"bundle-uri.c:212-217","code":" 212 | \tif (!strcmp(subkey, \"uri\")) {\n 213 | \t\tif (bundle->uri)\n 214 | \t\t\treturn -1;\n 215 | \t\tbundle->uri = relative_url(list->baseURI, value, NULL);\n 216 | \t\treturn 0;\n 217 | \t}","lines":6}]},{"id":"ERR_6","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"The core.checkstat handler in git_default_core_config() lacks a `return 0` on\nits success paths. After setting cfg->check_stat for \"default\"/\"minimal\" it\nfalls through and re-runs every subsequent strcmp() in the function (and\nultimately platform_core_config). It is currently benign because no later\nvariable name collides, but it is inconsistent with every other handler in the\nfunction, each of which returns immediately, and is fragile against future\nadditions. The core.fsyncmethod block has the same fall-through shape.","fix":"Add `return 0;` after the if/else block that sets cfg->check_stat (and similarly at the end of the core.fsyncmethod block).","locations":[{"ref":"environment.c:307-317","code":" 307 | \tif (!strcmp(var, \"core.checkstat\")) {\n 308 | \t\tif (!value)\n 309 | \t\t\treturn config_error_nonbool(var);\n 310 | \t\tif (!strcasecmp(value, \"default\"))\n 311 | \t\t\tcfg->check_stat = 1;\n 312 | \t\telse if (!strcasecmp(value, \"minimal\"))\n 313 | \t\t\tcfg->check_stat = 0;\n 314 | \t\telse\n 315 | \t\t\treturn error(_(\"invalid value for '%s': '%s'\"),\n 316 | \t\t\t\t var, value);\n 317 | \t}","lines":11}]},{"id":"ERR_7","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"In process_diff_filepair() the return value of diff_populate_filespec() is\nignored for both pair->two (around line 916) and pair->one (around line 921).\nIf population fails (corrupt/unreadable blob) the ->data pointers may be NULL,\nand file_target.ptr / file_parent.ptr are then handed to collect_diff() /\nxdi_diff as NULL inputs instead of producing a clean \"Cannot read blob\" error.\nElsewhere in the same file (fill_line_ends, fill_blob_sha1) the return value is\nchecked and die()d on, so this is an inconsistent and unsafe handling.","fix":"Check the return of diff_populate_filespec() for both filespecs and die()\n(matching fill_line_ends' \"Cannot read blob\") before dereferencing\n->data/->size.","locations":[{"ref":"line-log.c:915-927","code":" 915 | \tassert(pair->two->oid_valid);\n 916 | \tdiff_populate_filespec(rev->diffopt.repo, pair->two, NULL);\n 917 | \tfile_target.ptr = pair->two->data;\n 918 | \tfile_target.size = pair->two->size;\n 919 | \n 920 | \tif (pair->one->oid_valid) {\n 921 | \t\tdiff_populate_filespec(rev->diffopt.repo, pair->one, NULL);\n 922 | \t\tfile_parent.ptr = pair->one->data;\n 923 | \t\tfile_parent.size = pair->one->size;\n 924 | \t} else {\n 925 | \t\tfile_parent.ptr = parent_data_to_free = xstrdup(\"\");\n 926 | \t\tfile_parent.size = 0;\n 927 | \t}","lines":13}]},{"id":"ERR_8","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"reftable_be_reflog_expire() can call cleanup_fn() without a matching prior call\nto prepare_fn(), violating the documented prepare/cleanup pairing contract in\nrefs.h. `add` is set by reftable_stack_new_addition() at line 2554, but\nprepare_fn() is only invoked at line 2570; the three failure points in between\n(reftable_stack_init_log_iterator, reftable_iterator_seek_log,\nreftable_backend_read_ref at lines 2559-2569) `goto done`, where cleanup is\nguarded only by `if (add) cleanup_fn(policy_cb_data);`. So cleanup runs even\nthough prepare never ran.\n\nThe files backend (files-backend.c) always pairs the two calls. With the\nin-tree policy callback (reflog_expiry_cleanup) this is benign because the\nzero-initialized state leads to clear_commit_marks(NULL, ...) which tolerates\nNULL, but it breaks the documented invariant for any custom callback that\nassumes prepare always precedes cleanup.","fix":"Only call cleanup_fn if prepare_fn actually ran. Track a flag:\n\n int prepared = 0;\n ...\n prepare_fn(refname, &oid, policy_cb_data);\n prepared = 1;\n ...\ndone:\n if (prepared)\n cleanup_fn(policy_cb_data);","locations":[{"ref":"refs/reftable-backend.c:2554-2570","code":" 2554 | \tret = reftable_stack_new_addition(&add, be->stack,\n 2555 | \t\t\t\t\t REFTABLE_STACK_NEW_ADDITION_RELOAD);\n 2556 | \tif (ret < 0)\n 2557 | \t\tgoto done;\n 2558 | \n 2559 | \tret = reftable_stack_init_log_iterator(be->stack, &it);\n 2560 | \tif (ret < 0)\n 2561 | \t\tgoto done;\n 2562 | \n 2563 | \tret = reftable_iterator_seek_log(&it, refname);\n 2564 | \tif (ret < 0)\n 2565 | \t\tgoto done;\n 2566 | \n 2567 | \tret = reftable_backend_read_ref(be, refname, &oid, &referent, &type);\n 2568 | \tif (ret < 0)\n 2569 | \t\tgoto done;\n 2570 | \tprepare_fn(refname, &oid, policy_cb_data);","lines":17},{"ref":"refs/reftable-backend.c:2656-2658","code":" 2656 | done:\n 2657 | \tif (add)\n 2658 | \t\tcleanup_fn(policy_cb_data);","lines":3}]},{"id":"ERR_9","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"In reset_head() the `refs_only` branch returns the result of update_refs() directly at\nline 129 (`return update_refs(opts, oid, head);`) without going through the\nleave_reset_head cleanup label. This skips rollback_lock_file(&lock),\nclear_unpack_trees_porcelain(&unpack_tree_opts) and the desc.buffer free loop. In the\nrefs_only path the index lock was not taken and the tree descriptors/porcelain are not yet\npopulated, so today this leaks nothing in practice; however it is a latent footgun - any\nfuture code that acquires resources before this point would leak them, since this early\nreturn bypasses all cleanup.","fix":"Set `ret = update_refs(opts, oid, head); goto leave_reset_head;` so the cleanup path is always taken.","locations":[{"ref":"reset.c:128-129","code":" 128 | \tif (refs_only)\n 129 | \t\treturn update_refs(opts, oid, head);","lines":2},{"ref":"reset.c:182-188","code":" 182 | leave_reset_head:\n 183 | \trollback_lock_file(&lock);\n 184 | \tclear_unpack_trees_porcelain(&unpack_tree_opts);\n 185 | \twhile (nr)\n 186 | \t\tfree((void *)desc[--nr].buffer);\n 187 | \treturn ret;\n 188 | ","lines":7}]},{"id":"ERR_10","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"In do_reset(), the index lock is held from line 4019, but inside the `[new root]` branch\nthe two error returns at line 4028 (writing fake root commit) and line 4033 (writing\nsquash-onto) return directly rather than via `goto cleanup`, so rollback_lock_file(&lock)\nis never invoked for the held lock. Inconsistent with the rest of the function, which\nroutes all failures through the cleanup label. Severity is low because git rolls locks\nback at process exit, but it is still a defensible inconsistency on an error path.","fix":"Use `ret = error(...); goto cleanup;` for both early returns in the [new root] branch.","locations":[{"ref":"sequencer.c:4019-4020","code":" 4019 | \tif (repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0)\n 4020 | \t\treturn -1;","lines":2},{"ref":"sequencer.c:4022-4034","code":" 4022 | \tif (len == 10 && !strncmp(\"[new root]\", name, len)) {\n 4023 | \t\tif (!opts->have_squash_onto) {\n 4024 | \t\t\tconst char *hex;\n 4025 | \t\t\tif (commit_tree(\"\", 0, the_hash_algo->empty_tree,\n 4026 | \t\t\t\t\tNULL, &opts->squash_onto,\n 4027 | \t\t\t\t\tNULL, NULL))\n 4028 | \t\t\t\treturn error(_(\"writing fake root commit\"));\n 4029 | \t\t\topts->have_squash_onto = 1;\n 4030 | \t\t\thex = oid_to_hex(&opts->squash_onto);\n 4031 | \t\t\tif (write_message(hex, strlen(hex),\n 4032 | \t\t\t\t\t rebase_path_squash_onto(), 0))\n 4033 | \t\t\t\treturn error(_(\"writing squash-onto\"));\n 4034 | \t\t}","lines":13},{"ref":"sequencer.c:4093-4099","code":" 4093 | cleanup:\n 4094 | \tfree((void *)desc.buffer);\n 4095 | \tif (ret < 0)\n 4096 | \t\trollback_lock_file(&lock);\n 4097 | \tstrbuf_release(&ref_name);\n 4098 | \tclear_unpack_trees_porcelain(&unpack_tree_opts);\n 4099 | \treturn ret;","lines":7}]},{"id":"ERR_11","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","easy":false,"description":"In `get_refs_list_using_list()`, when a ref-list line from the helper does not begin with `@` or `?`, the code calls `get_oid_hex_algop(buf.buf, &(*tail)->old_oid, transport->hash_algo)` and ignores the return value. `get_oid_hex_algop` returns -1 on a malformed/short hex string and does NOT set the oid on failure. Because `alloc_ref` zero-initializes the ref, a malformed line is silently accepted as a ref with a null/zero oid and treated as valid downstream (fetched, written to FETCH_HEAD) rather than rejected. Every other parse failure on this code path calls `die()`; this one swallows the error. Not a memory-safety bug (the strbuf is NUL-terminated so the hex scan stops at the NUL), but a real error-handling inconsistency on untrusted helper output.","fix":"Check the return value and die on failure, e.g.:\n if (get_oid_hex_algop(buf.buf, &(*tail)->old_oid, transport->hash_algo))\n die(_(\"malformed response in ref list: %s\"), buf.buf);","locations":[{"ref":"transport-helper.c:1294-1297","code":" 1294 | \t\tif (buf.buf[0] == '@')\n 1295 | \t\t\t(*tail)->symref = xstrdup(buf.buf + 1);\n 1296 | \t\telse if (buf.buf[0] != '?')\n 1297 | \t\t\tget_oid_hex_algop(buf.buf, &(*tail)->old_oid, transport->hash_algo);","lines":4}]},{"id":"ERR_12","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"negligible","easy":false,"description":"read_object_list_from_stdin(): after parse_oid_hex(line, &oid, &p) sets p to\nline + hexsz, a stdin line consisting of exactly an OID with no trailing\nspace/newline (e.g. an unterminated final line at EOF) makes p point at the '\\0'\nand add_preferred_base_object(p + 1) reads starting one byte past the terminator.\nBecause `line` is sized GIT_MAX_HEXSZ + 1 + PATH_MAX + 2, p + 1 is still within\nthe stack buffer, so this is an over-read of trailing buffer bytes, not an\nout-of-bounds access, and the stdin format is normally produced by Git's own\nrev-list. Reported for completeness.","fix":"Only call add_preferred_base_object(p + 1) when *p is a separator (e.g. *p == ' '); otherwise treat the line as malformed.","locations":[{"ref":"builtin/pack-objects.c:4370-4374","code":" 4370 | \t\tif (parse_oid_hex(line, &oid, &p))\n 4371 | \t\t\tdie(_(\"expected object ID, got garbage:\\n %s\"), line);\n 4372 | \n 4373 | \t\tadd_preferred_base_object(p + 1);\n 4374 | \t\tadd_object_entry(&oid, OBJ_NONE, p + 1, 0);","lines":5}]},{"id":"ERR_13","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"negligible","easy":false,"description":"repo_is_descendant_of's non-generation branch can return -1 (propagated from\nrepo_in_merge_bases_many via `if (ret) return ret;`), while its generation-enabled\nbranch returns only 0/1 (can_all_from_reach). commit_contains returns the result\ndirectly to an int caller, so the two branches have different error-reporting\ncontracts. ref_newer does handle <0, so the primary caller is safe; this is an\ninconsistency rather than a crash.","fix":"Make both branches consistent: either propagate errors from both, or normalize\n-1 to a defined value before returning to non-error-aware callers like\ncommit_contains.","locations":[{"ref":"commit-reach.c:578-590","code":" 578 | \t} else {\n 579 | \t\twhile (with_commit) {\n 580 | \t\t\tstruct commit *other;\n 581 | \t\t\tint ret;\n 582 | \n 583 | \t\t\tother = with_commit->item;\n 584 | \t\t\twith_commit = with_commit->next;\n 585 | \t\t\tret = repo_in_merge_bases_many(r, other, 1, &commit, 0);\n 586 | \t\t\tif (ret)\n 587 | \t\t\t\treturn ret;\n 588 | \t\t}\n 589 | \t\treturn 0;\n 590 | \t}","lines":13},{"ref":"commit-reach.c:848-854","code":" 848 | int commit_contains(struct ref_filter *filter, struct commit *commit,\n 849 | \t\t struct commit_list *list, struct contains_cache *cache)\n 850 | {\n 851 | \tif (filter->with_commit_tag_algo)\n 852 | \t\treturn contains_tag_algo(commit, list, cache) == CONTAINS_YES;\n 853 | \treturn repo_is_descendant_of(the_repository, commit, list);\n 854 | }","lines":7}]},{"id":"ERR_14","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"negligible","easy":false,"description":"kwset.c checks obstack_alloc() / xmalloc-backed allocations for NULL and\nreturns \"memory exhausted\" on several paths, but the obstack chunk allocator is\nwired to xmalloc (obstack_chunk_alloc), which die()s on OOM rather than\nreturning NULL. Consequently the NULL-recovery branches are unreachable dead\ncode, misleading a reader into believing graceful OOM recovery exists when the\nprocess actually aborts inside xmalloc.","fix":"Drop the unreachable NULL-recovery branches for clarity, or add a comment\nnoting they are unreachable with the xmalloc-backed obstack.","locations":[{"ref":"kwset.c:44-49","code":" 44 | static void *obstack_chunk_alloc(long size)\n 45 | {\n 46 | \tif (size < 0)\n 47 | \t\tBUG(\"Cannot allocate a negative amount: %ld\", size);\n 48 | \treturn xmalloc(size);\n 49 | }","lines":6},{"ref":"kwset.c:141-145","code":" 141 | if (!kwset->trie)\n 142 | {\n 143 | kwsfree((kwset_t) kwset);\n 144 | return NULL;\n 145 | }","lines":5},{"ref":"kwset.c:216-226","code":" 216 | \t if (!link)\n 217 | \t return \"memory exhausted\";\n 218 | \t link->llink = NULL;\n 219 | \t link->rlink = NULL;\n 220 | \t link->trie = (struct trie *) obstack_alloc(&kwset->obstack,\n 221 | \t\t\t\t\t\t sizeof (struct trie));\n 222 | \t if (!link->trie)\n 223 | \t {\n 224 | \t obstack_free(&kwset->obstack, link);\n 225 | \t return \"memory exhausted\";\n 226 | \t }","lines":11},{"ref":"kwset.c:446-448","code":" 446 | kwset->target = obstack_alloc(&kwset->obstack, kwset->mind);\n 447 | if (!kwset->target)\n 448 | \treturn \"memory exhausted\";","lines":3}]},{"id":"API_1","type":"api_misuse","type_label":"Api Misuse","severity":"low","easy":false,"description":"`finalize(self, component: u32, flags: u32)` passes `component` and `flags` straight through to C with no validation (src/csum_file.rs:41). On the C side `flags` is interpreted as the bitfield CSUM_CLOSE=1 / CSUM_FSYNC=2 / CSUM_HASH_IN_STREAM=4 (csum-file.h:43-46) and `component` is an `enum fsync_component` (write-or-die.h:16-25). If `CSUM_FSYNC` is set, `component` is forwarded to `fsync_component_or_die()` (csum-file.c:81-82) which can `die()` (abort the whole process) on a bad/unsupported component or fsync failure. A Rust caller passing arbitrary integers (the API is untyped `u32`) can thus trigger process death or unexpected fsync behavior from \"safe\" Rust. The binding exposes no typed enum/bitflags to constrain these values.","fix":"Expose `component` and `flags` as Rust enums / bitflags mirroring `enum fsync_component` and the CSUM_* macros instead of raw `u32`, and document that `CSUM_FSYNC` paths may call `die()` and terminate the process.","locations":[{"ref":"src/csum_file.rs:39-44","code":" 39 | pub fn finalize(self, component: u32, flags: u32) -> Vec<u8> {\n 40 | let mut result = vec![0u8; GIT_MAX_RAWSZ];\n 41 | unsafe { c::finalize_hashfile(self.ptr, result.as_mut_ptr(), component, flags) };\n 42 | result.truncate(self.algo.raw_len());\n 43 | result\n 44 | }","lines":6},{"ref":"csum-file.c:79-88","code":" 79 | \tif (flags & CSUM_HASH_IN_STREAM)\n 80 | \t\tflush(f, f->buffer, f->algop->rawsz);\n 81 | \tif (flags & CSUM_FSYNC)\n 82 | \t\tfsync_component_or_die(component, f->fd, f->name);\n 83 | \tif (flags & CSUM_CLOSE) {\n 84 | \t\tif (close(f->fd))\n 85 | \t\t\tdie_errno(\"%s: sha1 file error on close\", f->name);\n 86 | \t\tfd = 0;\n 87 | \t} else\n 88 | \t\tfd = f->fd;","lines":10},{"ref":"write-or-die.h:16-25","code":" 16 | enum fsync_component {\n 17 | \tFSYNC_COMPONENT_NONE,\n 18 | \tFSYNC_COMPONENT_LOOSE_OBJECT\t\t= 1 << 0,\n 19 | \tFSYNC_COMPONENT_PACK\t\t\t= 1 << 1,\n 20 | \tFSYNC_COMPONENT_PACK_METADATA\t\t= 1 << 2,\n 21 | \tFSYNC_COMPONENT_COMMIT_GRAPH\t\t= 1 << 3,\n 22 | \tFSYNC_COMPONENT_INDEX\t\t\t= 1 << 4,\n 23 | \tFSYNC_COMPONENT_REFERENCE\t\t= 1 << 5,\n 24 | \tFSYNC_COMPONENT_OBJECT_MAP\t\t= 1 << 6,\n 25 | };","lines":10}]},{"id":"DEAD_1","type":"dead_code","type_label":"Dead Code","severity":"negligible","easy":true,"description":"builtin/repack.c defines DELETE_PACK and RETAIN_PACK, but these are no longer used in this file: the pack deletion/retention bit logic was moved into repack.c, which defines its own identical copies. The macros in builtin/repack.c are dead.","fix":"Remove the DELETE_PACK and RETAIN_PACK #defines from builtin/repack.c.","locations":[{"ref":"builtin/repack.c:23-24","code":" 23 | #define DELETE_PACK 1\n 24 | #define RETAIN_PACK 2","lines":2}]},{"id":"DEAD_2","type":"dead_code","type_label":"Dead Code","severity":"negligible","easy":false,"description":"In the cookie-redaction branch of redact_sensitive_header(), `equals` is assigned from\nstrchrnul(cookie, '='), which never returns NULL (it returns a pointer to the NUL\nterminator when '=' is absent). The subsequent `if (!equals)` branch is therefore dead.\nWorse, were that branch ever reachable, it does `strbuf_addstr(...); continue;` without\nadvancing `cookie`, which would loop forever. The code is only defensive today, but the\ndead branch is misleading and the latent infinite loop should be removed.","fix":"Drop the dead `if (!equals)` branch (strchrnul never returns NULL). If a guard for a\nmissing '=' is desired, base it on `*equals` and ensure `cookie` still advances so the\nloop terminates.","locations":[{"ref":"http.c:860-865","code":" 860 | \t\t\tequals = strchrnul(cookie, '=');\n 861 | \t\t\tif (!equals) {\n 862 | \t\t\t\t/* invalid cookie, just append and continue */\n 863 | \t\t\t\tstrbuf_addstr(&redacted_header, cookie);\n 864 | \t\t\t\tcontinue;\n 865 | \t\t\t}","lines":6}]},{"id":"DEAD_3","type":"dead_code","type_label":"Dead Code","severity":"negligible","easy":true,"description":"In write_with_updates(), the close/fsync-error path at lines 1551-1560 calls\nstrbuf_release(&sb), but `sb` was already released earlier (line 1389, and line\n1386 in the create-tempfile-failure path) and is never touched again. By the\ntime control reaches the close-error branch, `sb` is an empty STRBUF_INIT, so\nthis release is a harmless no-op but dead/misleading code suggesting `sb` is\nstill live here.","fix":"Remove the redundant strbuf_release(&sb) at line 1557; the meaningful cleanup on\nthis path is delete_tempfile(&refs->tempfile).","locations":[{"ref":"refs/packed-backend.c:1551-1560","code":" 1551 | \tif (fflush(out) ||\n 1552 | \t fsync_component(FSYNC_COMPONENT_REFERENCE, get_tempfile_fd(refs->tempfile)) ||\n 1553 | \t close_tempfile_gently(refs->tempfile)) {\n 1554 | \t\tstrbuf_addf(err, \"error closing file %s: %s\",\n 1555 | \t\t\t get_tempfile_path(refs->tempfile),\n 1556 | \t\t\t strerror(errno));\n 1557 | \t\tstrbuf_release(&sb);\n 1558 | \t\tdelete_tempfile(&refs->tempfile);\n 1559 | \t\treturn REF_TRANSACTION_ERROR_GENERIC;\n 1560 | \t}","lines":10}]},{"id":"CMT_1","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","easy":true,"description":"add_rename_dst() carries the doc comment \"Returns 0 on success, -1 if we found a\nduplicate\" but the function body unconditionally returns 0 and contains no\nduplicate detection. As a result the comment is misleading, and the caller's error\nbranch in diffcore_rename_extended() (\"else if (add_rename_dst(p) < 0) {\nwarning(...); goto cleanup; }\") is dead code: add_rename_dst() can never return < 0,\nso the warning and early cleanup goto are unreachable. A future maintainer could\nrely on the stated contract that no longer holds.","fix":"Either restore real duplicate detection inside add_rename_dst() so it returns -1 on\na duplicate p->two->path (matching the comment and making the caller branch live),\nor change add_rename_dst() to return void, drop the \"-1 on duplicate\" claim from\nthe comment, and simplify the call site to a plain add_rename_dst(p); removing the\ndead warning/goto.","locations":[{"ref":"diffcore-rename.c:39-50","code":" 39 | /*\n 40 | * Returns 0 on success, -1 if we found a duplicate.\n 41 | */\n 42 | static int add_rename_dst(struct diff_filepair *p)\n 43 | {\n 44 | \tALLOC_GROW(rename_dst, rename_dst_nr + 1, rename_dst_alloc);\n 45 | \trename_dst[rename_dst_nr].p = p;\n 46 | \trename_dst[rename_dst_nr].filespec_to_free = NULL;\n 47 | \trename_dst[rename_dst_nr].is_rename = 0;\n 48 | \trename_dst_nr++;\n 49 | \treturn 0;\n 50 | }","lines":12},{"ref":"diffcore-rename.c:1429-1434","code":" 1429 | \t\t\telse if (add_rename_dst(p) < 0) {\n 1430 | \t\t\t\twarning(\"skipping rename detection, detected\"\n 1431 | \t\t\t\t\t\" duplicate destination '%s'\",\n 1432 | \t\t\t\t\tp->two->path);\n 1433 | \t\t\t\tgoto cleanup;\n 1434 | \t\t\t}","lines":6}]},{"id":"ARCH_1","type":"architecture","type_label":"Architecture","severity":"high","easy":false,"description":"Unbounded recursion in dowild() enables stack exhaustion (DoS) from\nattacker-controlled glob patterns. dowild() recurses once per candidate text\nposition inside the '*' matching loop, and again for '**/'. There is no\nrecursion-depth cap. A pattern with many '*' segments matched against a long\nstring (e.g. a long path component) produces very deep, and for some inputs\nnear-exponential, recursion. Patterns originate from .gitattributes /\n.gitignore / pathspecs, which can be supplied by a remote repository or a\nmalicious clone, so a crafted attributes/ignore file can crash Git with a\nstack overflow. The classic problematic shape is \"*a*a*a*...*\" against\n\"aaaa...\". Recursion depth scales with text length and number of stars rather\nthan a fixed bound, so nothing prevents blowing the C stack.","fix":"Convert the '*' handling to the standard two-pointer backtracking technique so\nmatching runs iteratively in O(n*m) time with O(1) stack, or add a\nrecursion-depth limit that returns WM_ABORT_ALL once a sane bound is exceeded.\nThe two-pointer rewrite is preferable because it also eliminates the\nexponential-time behavior, not just the stack growth.","locations":[{"ref":"wildmatch.c:59-283","code":" 59 | static int dowild(const uchar *p, const uchar *text, unsigned int flags)\n 60 | {\n 61 | \tuchar p_ch;\n 62 | \tconst uchar *pattern = p;\n 63 | \n 64 | \tfor ( ; (p_ch = *p) != '\\0'; text++, p++) {\n 65 | \t\tint matched, match_slash, negated;\n 66 | \t\tuchar t_ch, prev_ch;\n 67 | \t\tif ((t_ch = *text) == '\\0' && p_ch != '*')\n 68 | \t\t\treturn WM_ABORT_ALL;\n 69 | \t\tif ((flags & WM_CASEFOLD) && ISUPPER(t_ch))\n 70 | \t\t\tt_ch = tolower(t_ch);\n 71 | \t\tif ((flags & WM_CASEFOLD) && ISUPPER(p_ch))\n 72 | \t\t\tp_ch = tolower(p_ch);\n 73 | \t\tswitch (p_ch) {\n 74 | \t\tcase '\\\\':\n 75 | \t\t\t/* Literal match with following character. Note that the test\n 76 | \t\t\t * in \"default\" handles the p[1] == '\\0' failure case. */\n 77 | \t\t\tp_ch = *++p;\n 78 | \t\t\t/* FALLTHROUGH */\n 79 | \t\tdefault:\n 80 | \t\t\tif (t_ch != p_ch)\n 81 | \t\t\t\treturn WM_NOMATCH;\n 82 | \t\t\tcontinue;\n 83 | \t\tcase '?':\n 84 | \t\t\t/* Match anything but '/'. */\n 85 | \t\t\tif ((flags & WM_PATHNAME) && t_ch == '/')\n 86 | \t\t\t\treturn WM_NOMATCH;\n 87 | \t\t\tcontinue;\n 88 | \t\tcase '*':\n 89 | \t\t\tif (*++p == '*') {\n 90 | \t\t\t\tconst uchar *prev_p = p;\n 91 | \t\t\t\twhile (*++p == '*') {}\n 92 | \t\t\t\tif (!(flags & WM_PATHNAME))\n 93 | \t\t\t\t\t/* without WM_PATHNAME, '*' == '**' */\n 94 | \t\t\t\t\tmatch_slash = 1;\n 95 | \t\t\t\telse if ((prev_p - pattern < 2 || *(prev_p - 2) == '/') &&\n 96 | \t\t\t\t (*p == '\\0' || *p == '/' ||\n 97 | \t\t\t\t (p[0] == '\\\\' && p[1] == '/'))) {\n 98 | \t\t\t\t\t/*\n 99 | \t\t\t\t\t * Assuming we already match 'foo/' and are at\n 100 | \t\t\t\t\t * <star star slash>, just assume it matches\n 101 | \t\t\t\t\t * nothing and go ahead match the rest of the\n 102 | \t\t\t\t\t * pattern with the remaining string. This\n 103 | \t\t\t\t\t * helps make foo/<*><*>/bar (<> because\n 104 | \t\t\t\t\t * otherwise it breaks C comment syntax) match\n 105 | \t\t\t\t\t * both foo/bar and foo/a/bar.\n 106 | \t\t\t\t\t */\n 107 | \t\t\t\t\tif (p[0] == '/' &&\n 108 | \t\t\t\t\t dowild(p + 1, text, flags) == WM_MATCH)\n 109 | \t\t\t\t\t\treturn WM_MATCH;\n 110 | \t\t\t\t\tmatch_slash = 1;\n 111 | \t\t\t\t} else /* WM_PATHNAME is set */\n 112 | \t\t\t\t\tmatch_slash = 0;\n 113 | \t\t\t} else\n 114 | \t\t\t\t/* without WM_PATHNAME, '*' == '**' */\n 115 | \t\t\t\tmatch_slash = flags & WM_PATHNAME ? 0 : 1;\n 116 | \t\t\tif (*p == '\\0') {\n 117 | \t\t\t\t/* Trailing \"**\" matches everything. Trailing \"*\" matches\n 118 | \t\t\t\t * only if there are no more slash characters. */\n 119 | \t\t\t\tif (!match_slash) {\n 120 | \t\t\t\t\tif (strchr((char *)text, '/'))\n 121 | \t\t\t\t\t\treturn WM_ABORT_TO_STARSTAR;\n 122 | \t\t\t\t}\n 123 | \t\t\t\treturn WM_MATCH;\n 124 | \t\t\t} else if (!match_slash && *p == '/') {\n 125 | \t\t\t\t/*\n 126 | \t\t\t\t * _one_ asterisk followed by a slash\n 127 | \t\t\t\t * with WM_PATHNAME matches the next\n 128 | \t\t\t\t * directory\n 129 | \t\t\t\t */\n 130 | \t\t\t\tconst char *slash = strchr((char*)text, '/');\n 131 | \t\t\t\tif (!slash)\n 132 | \t\t\t\t\treturn WM_ABORT_ALL;\n 133 | \t\t\t\ttext = (const uchar*)slash;\n 134 | \t\t\t\t/* the slash is consumed by the top-level for loop */\n 135 | \t\t\t\tbreak;\n 136 | \t\t\t}\n 137 | \t\t\twhile (1) {\n 138 | \t\t\t\tif (t_ch == '\\0')\n 139 | \t\t\t\t\tbreak;\n 140 | \t\t\t\t/*\n 141 | \t\t\t\t * Try to advance faster when an asterisk is\n 142 | \t\t\t\t * followed by a literal. We know in this case\n 143 | \t\t\t\t * that the string before the literal\n 144 | \t\t\t\t * must belong to \"*\".\n 145 | \t\t\t\t * If match_slash is false, do not look past\n 146 | \t\t\t\t * the first slash as it cannot belong to '*'.\n 147 | \t\t\t\t */\n 148 | \t\t\t\tif (!is_glob_special(*p)) {\n 149 | \t\t\t\t\tp_ch = *p;\n 150 | \t\t\t\t\tif ((flags & WM_CASEFOLD) && ISUPPER(p_ch))\n 151 | \t\t\t\t\t\tp_ch = tolower(p_ch);\n 152 | \t\t\t\t\twhile ((t_ch = *text) != '\\0' &&\n 153 | \t\t\t\t\t (match_slash || t_ch != '/')) {\n 154 | \t\t\t\t\t\tif ((flags & WM_CASEFOLD) && ISUPPER(t_ch))\n 155 | \t\t\t\t\t\t\tt_ch = tolower(t_ch);\n 156 | \t\t\t\t\t\tif (t_ch == p_ch)\n 157 | \t\t\t\t\t\t\tbreak;\n 158 | \t\t\t\t\t\ttext++;\n 159 | \t\t\t\t\t}\n 160 | \t\t\t\t\tif (t_ch != p_ch) {\n 161 | \t\t\t\t\t\tif (match_slash)\n 162 | \t\t\t\t\t\t\treturn WM_ABORT_ALL;\n 163 | \t\t\t\t\t\telse\n 164 | \t\t\t\t\t\t\treturn WM_ABORT_TO_STARSTAR;\n 165 | \t\t\t\t\t}\n 166 | \t\t\t\t}\n 167 | \t\t\t\tif ((matched = dowild(p, text, flags)) != WM_NOMATCH) {\n 168 | \t\t\t\t\tif (!match_slash || matched != WM_ABORT_TO_STARSTAR)\n 169 | \t\t\t\t\t\treturn matched;\n 170 | \t\t\t\t} else if (!match_slash && t_ch == '/')\n 171 | \t\t\t\t\treturn WM_ABORT_TO_STARSTAR;\n 172 | \t\t\t\tt_ch = *++text;\n 173 | \t\t\t}\n 174 | \t\t\treturn WM_ABORT_ALL;\n 175 | \t\tcase '[':\n 176 | \t\t\tp_ch = *++p;\n 177 | #ifdef NEGATE_CLASS2\n 178 | \t\t\tif (p_ch == NEGATE_CLASS2)\n 179 | \t\t\t\tp_ch = NEGATE_CLASS;\n 180 | #endif\n 181 | \t\t\t/* Assign literal 1/0 because of \"matched\" comparison. */\n 182 | \t\t\tnegated = p_ch == NEGATE_CLASS ? 1 : 0;\n 183 | \t\t\tif (negated) {\n 184 | \t\t\t\t/* Inverted character class. */\n 185 | \t\t\t\tp_ch = *++p;\n 186 | \t\t\t}\n 187 | \t\t\tprev_ch = 0;\n 188 | \t\t\tmatched = 0;\n 189 | \t\t\tdo {\n 190 | \t\t\t\tif (!p_ch)\n 191 | \t\t\t\t\treturn WM_ABORT_ALL;\n 192 | \t\t\t\tif (p_ch == '\\\\') {\n 193 | \t\t\t\t\tp_ch = *++p;\n 194 | \t\t\t\t\tif (!p_ch)\n 195 | \t\t\t\t\t\treturn WM_ABORT_ALL;\n 196 | \t\t\t\t\tif (t_ch == p_ch)\n 197 | \t\t\t\t\t\tmatched = 1;\n 198 | \t\t\t\t} else if (p_ch == '-' && prev_ch && p[1] && p[1] != ']') {\n 199 | \t\t\t\t\tp_ch = *++p;\n 200 | \t\t\t\t\tif (p_ch == '\\\\') {\n 201 | \t\t\t\t\t\tp_ch = *++p;\n 202 | \t\t\t\t\t\tif (!p_ch)\n 203 | \t\t\t\t\t\t\treturn WM_ABORT_ALL;\n 204 | \t\t\t\t\t}\n 205 | \t\t\t\t\tif (t_ch <= p_ch && t_ch >= prev_ch)\n 206 | \t\t\t\t\t\tmatched = 1;\n 207 | \t\t\t\t\telse if ((flags & WM_CASEFOLD) && ISLOWER(t_ch)) {\n 208 | \t\t\t\t\t\tuchar t_ch_upper = toupper(t_ch);\n 209 | \t\t\t\t\t\tif (t_ch_upper <= p_ch && t_ch_upper >= prev_ch)\n 210 | \t\t\t\t\t\t\tmatched = 1;\n 211 | \t\t\t\t\t}\n 212 | \t\t\t\t\tp_ch = 0; /* This makes \"prev_ch\" get set to 0. */\n 213 | \t\t\t\t} else if (p_ch == '[' && p[1] == ':') {\n 214 | \t\t\t\t\tconst uchar *s;\n 215 | \t\t\t\t\tint i;\n 216 | \t\t\t\t\tfor (s = p += 2; (p_ch = *p) && p_ch != ']'; p++) {} /*SHARED ITERATOR*/\n 217 | \t\t\t\t\tif (!p_ch)\n 218 | \t\t\t\t\t\treturn WM_ABORT_ALL;\n 219 | \t\t\t\t\ti = p - s - 1;\n 220 | \t\t\t\t\tif (i < 0 || p[-1] != ':') {\n 221 | \t\t\t\t\t\t/* Didn't find \":]\", so treat like a normal set. */\n 222 | \t\t\t\t\t\tp = s - 2;\n 223 | \t\t\t\t\t\tp_ch = '[';\n 224 | \t\t\t\t\t\tif (t_ch == p_ch)\n 225 | \t\t\t\t\t\t\tmatched = 1;\n 226 | \t\t\t\t\t\tgoto next;\n 227 | \t\t\t\t\t}\n 228 | \t\t\t\t\tif (CC_EQ(s,i, \"alnum\")) {\n 229 | \t\t\t\t\t\tif (ISALNUM(t_ch))\n 230 | \t\t\t\t\t\t\tmatched = 1;\n 231 | \t\t\t\t\t} else if (CC_EQ(s,i, \"alpha\")) {\n 232 | \t\t\t\t\t\tif (ISALPHA(t_ch))\n 233 | \t\t\t\t\t\t\tmatched = 1;\n 234 | \t\t\t\t\t} else if (CC_EQ(s,i, \"blank\")) {\n 235 | \t\t\t\t\t\tif (ISBLANK(t_ch))\n 236 | \t\t\t\t\t\t\tmatched = 1;\n 237 | \t\t\t\t\t} else if (CC_EQ(s,i, \"cntrl\")) {\n 238 | \t\t\t\t\t\tif (ISCNTRL(t_ch))\n 239 | \t\t\t\t\t\t\tmatched = 1;\n 240 | \t\t\t\t\t} else if (CC_EQ(s,i, \"digit\")) {\n 241 | \t\t\t\t\t\tif (ISDIGIT(t_ch))\n 242 | \t\t\t\t\t\t\tmatched = 1;\n 243 | \t\t\t\t\t} else if (CC_EQ(s,i, \"graph\")) {\n 244 | \t\t\t\t\t\tif (ISGRAPH(t_ch))\n 245 | \t\t\t\t\t\t\tmatched = 1;\n 246 | \t\t\t\t\t} else if (CC_EQ(s,i, \"lower\")) {\n 247 | \t\t\t\t\t\tif (ISLOWER(t_ch))\n 248 | \t\t\t\t\t\t\tmatched = 1;\n 249 | \t\t\t\t\t} else if (CC_EQ(s,i, \"print\")) {\n 250 | \t\t\t\t\t\tif (ISPRINT(t_ch))\n 251 | \t\t\t\t\t\t\tmatched = 1;\n 252 | \t\t\t\t\t} else if (CC_EQ(s,i, \"punct\")) {\n 253 | \t\t\t\t\t\tif (ISPUNCT(t_ch))\n 254 | \t\t\t\t\t\t\tmatched = 1;\n 255 | \t\t\t\t\t} else if (CC_EQ(s,i, \"space\")) {\n 256 | \t\t\t\t\t\tif (ISSPACE(t_ch))\n 257 | \t\t\t\t\t\t\tmatched = 1;\n 258 | \t\t\t\t\t} else if (CC_EQ(s,i, \"upper\")) {\n 259 | \t\t\t\t\t\tif (ISUPPER(t_ch))\n 260 | \t\t\t\t\t\t\tmatched = 1;\n 261 | \t\t\t\t\t\telse if ((flags & WM_CASEFOLD) && ISLOWER(t_ch))\n 262 | \t\t\t\t\t\t\tmatched = 1;\n 263 | \t\t\t\t\t} else if (CC_EQ(s,i, \"xdigit\")) {\n 264 | \t\t\t\t\t\tif (ISXDIGIT(t_ch))\n 265 | \t\t\t\t\t\t\tmatched = 1;\n 266 | \t\t\t\t\t} else /* malformed [:class:] string */\n 267 | \t\t\t\t\t\treturn WM_ABORT_ALL;\n 268 | \t\t\t\t\tp_ch = 0; /* This makes \"prev_ch\" get set to 0. */\n 269 | \t\t\t\t} else if (t_ch == p_ch)\n 270 | \t\t\t\t\tmatched = 1;\n 271 | next:\n 272 | \t\t\t\tprev_ch = p_ch;\n 273 | \t\t\t\tp_ch = *++p;\n 274 | \t\t\t} while (p_ch != ']');\n 275 | \t\t\tif (matched == negated ||\n 276 | \t\t\t ((flags & WM_PATHNAME) && t_ch == '/'))\n 277 | \t\t\t\treturn WM_NOMATCH;\n 278 | \t\t\tcontinue;\n 279 | \t\t}\n 280 | \t}\n 281 | \n 282 | \treturn *text ? WM_NOMATCH : WM_MATCH;\n 283 | }","lines":225},{"ref":"wildmatch.c:137-174","code":" 137 | \t\t\twhile (1) {\n 138 | \t\t\t\tif (t_ch == '\\0')\n 139 | \t\t\t\t\tbreak;\n 140 | \t\t\t\t/*\n 141 | \t\t\t\t * Try to advance faster when an asterisk is\n 142 | \t\t\t\t * followed by a literal. We know in this case\n 143 | \t\t\t\t * that the string before the literal\n 144 | \t\t\t\t * must belong to \"*\".\n 145 | \t\t\t\t * If match_slash is false, do not look past\n 146 | \t\t\t\t * the first slash as it cannot belong to '*'.\n 147 | \t\t\t\t */\n 148 | \t\t\t\tif (!is_glob_special(*p)) {\n 149 | \t\t\t\t\tp_ch = *p;\n 150 | \t\t\t\t\tif ((flags & WM_CASEFOLD) && ISUPPER(p_ch))\n 151 | \t\t\t\t\t\tp_ch = tolower(p_ch);\n 152 | \t\t\t\t\twhile ((t_ch = *text) != '\\0' &&\n 153 | \t\t\t\t\t (match_slash || t_ch != '/')) {\n 154 | \t\t\t\t\t\tif ((flags & WM_CASEFOLD) && ISUPPER(t_ch))\n 155 | \t\t\t\t\t\t\tt_ch = tolower(t_ch);\n 156 | \t\t\t\t\t\tif (t_ch == p_ch)\n 157 | \t\t\t\t\t\t\tbreak;\n 158 | \t\t\t\t\t\ttext++;\n 159 | \t\t\t\t\t}\n 160 | \t\t\t\t\tif (t_ch != p_ch) {\n 161 | \t\t\t\t\t\tif (match_slash)\n 162 | \t\t\t\t\t\t\treturn WM_ABORT_ALL;\n 163 | \t\t\t\t\t\telse\n 164 | \t\t\t\t\t\t\treturn WM_ABORT_TO_STARSTAR;\n 165 | \t\t\t\t\t}\n 166 | \t\t\t\t}\n 167 | \t\t\t\tif ((matched = dowild(p, text, flags)) != WM_NOMATCH) {\n 168 | \t\t\t\t\tif (!match_slash || matched != WM_ABORT_TO_STARSTAR)\n 169 | \t\t\t\t\t\treturn matched;\n 170 | \t\t\t\t} else if (!match_slash && t_ch == '/')\n 171 | \t\t\t\t\treturn WM_ABORT_TO_STARSTAR;\n 172 | \t\t\t\tt_ch = *++text;\n 173 | \t\t\t}\n 174 | \t\t\treturn WM_ABORT_ALL;","lines":38},{"ref":"wildmatch.c:107-109","code":" 107 | \t\t\t\t\tif (p[0] == '/' &&\n 108 | \t\t\t\t\t dowild(p + 1, text, flags) == WM_MATCH)\n 109 | \t\t\t\t\t\treturn WM_MATCH;","lines":3}]},{"id":"ARCH_2","type":"architecture","type_label":"Architecture","severity":"low","easy":false,"description":"state_dir_path() caches the directory prefix and its length in static variables on first\ninvocation, keyed off opts->state_dir at that moment, and never recomputes them. But\nopts->state_dir is assigned multiple times during cmd_rebase (provisionally during\nin-progress detection at lines 1273/1288, then definitively at lines 1617/1620) and the\ntwo possible values differ (\"rebase-apply\" vs \"rebase-merge\"). The function is only\ncorrect because, in the current control flow, the first call that matters\n(create_autostash at line 1780) happens after the final assignment. Any future call added\nbefore the final state_dir assignment, or with a different opts, would silently build a\nwrong path. The static cache also makes the helper needlessly non-reentrant.","fix":"Drop the static caching and rebuild the prefix from opts->state_dir on each call (cost is negligible), or assert that opts->state_dir matches the cached prefix.","locations":[{"ref":"builtin/rebase.c:423-436","code":" 423 | static const char *state_dir_path(const char *filename, struct rebase_options *opts)\n 424 | {\n 425 | \tstatic struct strbuf path = STRBUF_INIT;\n 426 | \tstatic size_t prefix_len;\n 427 | \n 428 | \tif (!prefix_len) {\n 429 | \t\tstrbuf_addf(&path, \"%s/\", opts->state_dir);\n 430 | \t\tprefix_len = path.len;\n 431 | \t}\n 432 | \n 433 | \tstrbuf_setlen(&path, prefix_len);\n 434 | \tstrbuf_addstr(&path, filename);\n 435 | \treturn path.buf;\n 436 | }","lines":14},{"ref":"builtin/rebase.c:1273","code":" 1273 | \t\toptions.state_dir = apply_dir();","lines":1},{"ref":"builtin/rebase.c:1288","code":" 1288 | \t\toptions.state_dir = merge_dir();","lines":1},{"ref":"builtin/rebase.c:1617-1620","code":" 1617 | \t\toptions.state_dir = merge_dir();\n 1618 | \t\tbreak;\n 1619 | \tcase REBASE_APPLY:\n 1620 | \t\toptions.state_dir = apply_dir();","lines":4}]},{"id":"OTHER_1","type":"other","type_label":"Other","severity":"high","easy":false,"description":"Out-of-bounds heap read followed by a write through an attacker-influenced\npointer while parsing the untracked-cache index extension\n(read_untracked_extension); reachable via a locally-crafted/corrupt index file\n(e.g. git status), hence high rather than critical. read_one_dir()\nallocates rd.ucd with exactly varint_len directory entries. Three EWAH bitmaps\n(valid, check_only, sha1_valid) are then read from disk; their set-bit positions\nare attacker-controlled. ewah_each_bit() invokes set_check_only(), read_stat()\nand read_oid() with arbitrary `pos`, and each does\n`ud = rd->ucd[pos]` with NO check that pos < rd->index. A bit set beyond the\ndirectory count reads a pointer out of bounds of the rd.ucd[] allocation and then\ndereferences and WRITES through it (ud->check_only = 1;\nstat_data_from_disk(&ud->stat_data,...); oidread(&ud->exclude_oid,...)). Reachable\nfrom any command that reads an index with an untracked-cache extension (e.g.\ngit status).","fix":"Bound pos before indexing rd->ucd in every EWAH callback (set_check_only,\nread_stat, read_oid). Store the final directory count (varint_len) separately\nfrom the rd->index cursor and, on pos >= count, mark the parse as failed\n(e.g. set rd->data = rd->end + 1) so the trailing `next != end` check discards\nthe cache.","locations":[{"ref":"dir.c:3826-3856","code":" 3826 | static void set_check_only(size_t pos, void *cb)\n 3827 | {\n 3828 | \tstruct read_data *rd = cb;\n 3829 | \tstruct untracked_cache_dir *ud = rd->ucd[pos];\n 3830 | \tud->check_only = 1;\n 3831 | }\n 3832 | \n 3833 | static void read_stat(size_t pos, void *cb)\n 3834 | {\n 3835 | \tstruct read_data *rd = cb;\n 3836 | \tstruct untracked_cache_dir *ud = rd->ucd[pos];\n 3837 | \tif (rd->data + sizeof(struct stat_data) > rd->end) {\n 3838 | \t\trd->data = rd->end + 1;\n 3839 | \t\treturn;\n 3840 | \t}\n 3841 | \tstat_data_from_disk(&ud->stat_data, rd->data);\n 3842 | \trd->data += sizeof(struct stat_data);\n 3843 | \tud->valid = 1;\n 3844 | }\n 3845 | \n 3846 | static void read_oid(size_t pos, void *cb)\n 3847 | {\n 3848 | \tstruct read_data *rd = cb;\n 3849 | \tstruct untracked_cache_dir *ud = rd->ucd[pos];\n 3850 | \tif (rd->data + the_hash_algo->rawsz > rd->end) {\n 3851 | \t\trd->data = rd->end + 1;\n 3852 | \t\treturn;\n 3853 | \t}\n 3854 | \toidread(&ud->exclude_oid, rd->data, the_repository->hash_algo);\n 3855 | \trd->data += the_hash_algo->rawsz;\n 3856 | }","lines":31},{"ref":"dir.c:3919-3922","code":" 3919 | \trd.index = 0;\n 3920 | \tALLOC_ARRAY(rd.ucd, varint_len);\n 3921 | \n 3922 | \tif (read_one_dir(&uc->root, &rd) || rd.index != varint_len)","lines":4},{"ref":"dir.c:3940-3943","code":" 3940 | \tewah_each_bit(rd.check_only, set_check_only, &rd);\n 3941 | \trd.data = next + len;\n 3942 | \tewah_each_bit(rd.valid, read_stat, &rd);\n 3943 | \tewah_each_bit(rd.sha1_valid, read_oid, &rd);","lines":4}]},{"id":"OTHER_2","type":"other","type_label":"Other","severity":"negligible","easy":true,"description":"In process_entries(), immediately before a BUG() that fires only on the\n\"dir_metadata accounting completely off\" should-never-happen invariant, two\nraw printf() calls plus fflush(stdout) dump diagnostic state to *stdout*\nrather than to stderr or trace2:\n\n printf(\"dir_metadata.offsets.nr = %\"PRIuMAX\" (should be 1)\\n\", ...);\n printf(\"dir_metadata.offsets.items[0].util = %u (should be 0)\\n\", ...);\n fflush(stdout);\n BUG(\"dir_metadata accounting completely off; shouldn't happen\");\n\nOn the (theoretically impossible) trigger path this writes to stdout, which can\ninterleave with porcelain output. It is intentional debug-on-BUG instrumentation,\nnot a real defect; flagged only because the channel is stdout.","fix":"If kept, route the diagnostic through trace2/stderr instead of stdout so it\ncannot corrupt machine-readable output.","locations":[{"ref":"merge-ort.c:4571-4578","code":" 4571 | \tif (dir_metadata.offsets.nr != 1 ||\n 4572 | \t (uintptr_t)dir_metadata.offsets.items[0].util != 0) {\n 4573 | \t\tprintf(\"dir_metadata.offsets.nr = %\"PRIuMAX\" (should be 1)\\n\",\n 4574 | \t\t (uintmax_t)dir_metadata.offsets.nr);\n 4575 | \t\tprintf(\"dir_metadata.offsets.items[0].util = %u (should be 0)\\n\",\n 4576 | \t\t (unsigned)(uintptr_t)dir_metadata.offsets.items[0].util);\n 4577 | \t\tfflush(stdout);\n 4578 | \t\tBUG(\"dir_metadata accounting completely off; shouldn't happen\");","lines":8}]},{"id":"OTHER_3","type":"other","type_label":"Other","severity":"negligible","easy":false,"description":"find_notes_merge_pair_pos() keeps a function-local `static int last_index`\nthat persists across all calls (and across independent notes_merge runs in a\nlong-lived process). It is used purely as a search-start hint, and the\nsurrounding loop self-corrects from any starting index, so this is not a\ncorrectness bug. However the static state makes the function non-reentrant and\nmeans behavior depends on prior unrelated invocations, which is a subtle\nfootgun for any future reuse of this helper (e.g. concurrent or library use).","fix":"Consider passing the hint in/out via a parameter (or the caller's loop index)\ninstead of file-scope static state, so the function is reentrant and free of\ncross-call coupling.","locations":[{"ref":"notes-merge.c:89-122","code":" 89 | \tstatic int last_index;\n 90 | \tint i = last_index < len ? last_index : len - 1;\n 91 | \tint prev_cmp = 0, cmp = -1;\n 92 | \twhile (i >= 0 && i < len) {\n 93 | \t\tcmp = oidcmp(obj, &list[i].obj);\n 94 | \t\tif (!cmp) /* obj belongs @ i */\n 95 | \t\t\tbreak;\n 96 | \t\telse if (cmp < 0 && prev_cmp <= 0) /* obj belongs < i */\n 97 | \t\t\ti--;\n 98 | \t\telse if (cmp < 0) /* obj belongs between i-1 and i */\n 99 | \t\t\tbreak;\n 100 | \t\telse if (cmp > 0 && prev_cmp >= 0) /* obj belongs > i */\n 101 | \t\t\ti++;\n 102 | \t\telse /* if (cmp > 0) */ { /* obj belongs between i and i+1 */\n 103 | \t\t\ti++;\n 104 | \t\t\tbreak;\n 105 | \t\t}\n 106 | \t\tprev_cmp = cmp;\n 107 | \t}\n 108 | \tif (i < 0)\n 109 | \t\ti = 0;\n 110 | \t/* obj belongs at, or immediately preceding, index i (0 <= i <= len) */\n 111 | \n 112 | \tif (!cmp)\n 113 | \t\t*occupied = 1;\n 114 | \telse {\n 115 | \t\t*occupied = 0;\n 116 | \t\tif (insert_new && i < len) {\n 117 | \t\t\tMOVE_ARRAY(list + i + 1, list + i, len - i);\n 118 | \t\t\tmemset(list + i, 0, sizeof(struct notes_merge_pair));\n 119 | \t\t}\n 120 | \t}\n 121 | \tlast_index = i;\n 122 | \treturn list + i;","lines":34}]}]</script>
<script type="application/json" id="PI">{"name":"git","description":"","git_url":"https://github.com/git/git.git","repo_url":"https://github.com/git/git","commit":"95e20213faefeb95df29277c58ac1980ab68f701","commit_short":"95e20213fa","commit_url":"https://github.com/git/git/commit/95e20213faefeb95df29277c58ac1980ab68f701","branch":"master","generated":"2026-06-19"}</script>
<div class="app">
<div class="top">
<div>
<h1>Code Audit Report — git</h1>
<div class="meta">Generated 2026-06-19 — <span id="stats"></span></div>
</div>
<div class="top-right">
<input type="search" id="search" placeholder="Search findings…" autocomplete="off">
<label class="group-label">Group by
<select id="groupby">
<option value="type">Type</option>
<option value="severity">Severity</option>
<option value="file">File</option>
<option value="none">None (flat)</option>
</select>
</label>
</div>
</div>
<div class="project-info"><div class="pi-name">git</div><div class="pi-row"><a class="pi-repo-btn" href="https://github.com/git/git" target="_blank" rel="noopener">🔗 https://github.com/git/git</a><span><span class="pi-k">branch</span> <code>master</code></span><span><span class="pi-k">commit</span> <a href="https://github.com/git/git/commit/95e20213faefeb95df29277c58ac1980ab68f701" target="_blank" rel="noopener"><code>95e20213fa</code></a></span></div></div>
<div class="filterbar" id="filterbar"></div>
<div id="progress-wrap">
<div id="progress-bar"><div id="progress-fill"></div></div>
<span id="progress-text"></span>
<button class="action-btn" data-action="collapse-all">⊟ Collapse all</button>
<button class="action-btn" data-action="expand-all">⊞ Expand all</button>
<button class="action-btn" data-action="download-md">⬇ MD</button>
<button class="action-btn" data-action="download-md-short">⬇ MD Short</button>
<button class="action-btn" data-action="download-toml">⬇ TOML</button>
</div>
<div id="hidden-bar"></div>
<div id="content"></div>
</div>
<script>
(function(){
'use strict';
// Data
const FINDINGS = JSON.parse(document.getElementById('D').textContent);
const PROJECT_INFO = JSON.parse(document.getElementById('PI').textContent);
const SEVS = ['critical','high','medium','low','negligible'];
const STATUSES = ['open','fixed','wont-fix','false-positive'];
const ALL_TYPES = [...new Set(FINDINGS.map(f => f.type))].sort();
// Build lookup map for O(1) access
const FINDING_BY_ID = new Map(FINDINGS.map(f => [f.id, f]));
// Only severities actually present in the report — the filter bar hides the rest.
const PRESENT_SEVS = SEVS.filter(s => FINDINGS.some(f => (f.severity || 'medium') === s));
// Whether any finding is flagged easy — the "Quick wins" filter row is hidden otherwise.
const HAS_EASY = FINDINGS.some(f => f.easy);
const STORE_KEY = 'audit-status:' + location.href;
const STATUS_CLS = {open:'st-open', fixed:'st-fixed', 'wont-fix':'st-wont', 'false-positive':'st-fp'};
const STATUS_LBL = {open:'● open', fixed:'✓ fixed', 'wont-fix':"— won't fix", 'false-positive':'✗ false pos.'};
const STATUS_NEXT = {open:'fixed', fixed:'wont-fix', 'wont-fix':'false-positive', 'false-positive':'open'};
// State
const S = {
search: '', groupBy: 'type',
hiddenSev: new Set(), hiddenType: new Set(),
hiddenStatus: new Set(), hiddenGroup: new Set(),
collapsed: new Set(), easyOnly: false,
};
const statuses = {};
// Persistence
function loadStatuses() {
try { Object.assign(statuses, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); } catch {}
}
function saveStatuses() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(statuses)); } catch {}
}
function statusOf(id) { return statuses[id] || 'open'; }
// URL hash — debounced so rapid filter changes don't thrash history
let _hashTimer = 0;
function syncHash() {
clearTimeout(_hashTimer);
_hashTimer = setTimeout(() => {
const p = new URLSearchParams();
if (S.groupBy !== 'type') p.set('g', S.groupBy);
if (S.hiddenSev.size) p.set('hs', [...S.hiddenSev].join(','));
if (S.hiddenType.size) p.set('ht', [...S.hiddenType].join(','));
if (S.hiddenStatus.size) p.set('hst', [...S.hiddenStatus].join(','));
if (S.easyOnly) p.set('e', '1');
if (S.search) p.set('q', S.search);
const str = p.toString();
history.replaceState(null, '', str ? '#' + str : location.pathname + location.search);
}, 300);
}
function loadHash() {
const p = new URLSearchParams(location.hash.slice(1));
if (p.has('g')) { S.groupBy = p.get('g'); document.getElementById('groupby').value = S.groupBy; }
if (p.has('q')) { S.search = p.get('q'); document.getElementById('search').value = S.search; }
if (p.has('hs')) p.get('hs') .split(',').filter(Boolean).forEach(v => S.hiddenSev.add(v));
if (p.has('ht')) p.get('ht') .split(',').filter(Boolean).forEach(v => S.hiddenType.add(v));
if (p.has('hst')) p.get('hst').split(',').filter(Boolean).forEach(v => S.hiddenStatus.add(v));
if (p.has('e')) S.easyOnly = p.get('e') === '1';
}
// Helpers
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function highlight(text, q) {
if (!q) return esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
return String(text).split(re).map((p, i) => i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p)).join('');
}
// Render text with ```fenced``` code blocks and `inline` backticks,
// also paragraph-splitting on blank lines. Output is HTML-safe.
function renderMd(text, q) {
const src = String(text == null ? '' : text);
const FENCE = /```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n([\s\S]*?)```/g;
const parts = [];
let last = 0, m;
while ((m = FENCE.exec(src)) !== null) {
parts.push({kind: 'text', value: src.slice(last, m.index)});
parts.push({kind: 'code', value: m[2].replace(/\r?\n$/, '')});
last = m.index + m[0].length;
}
parts.push({kind: 'text', value: src.slice(last)});
function renderInline(s) {
const re = /`([^`\n]+)`/g;
let out = '', i = 0, mm;
while ((mm = re.exec(s)) !== null) {
out += highlight(s.slice(i, mm.index), q);
out += '<code class="md-inline">' + highlight(mm[1], q) + '</code>';
i = mm.index + mm[0].length;
}
out += highlight(s.slice(i), q);
return out;
}
function renderTextChunk(s) {
const paragraphs = s.split(/\r?\n[ \t]*\r?\n/);
const out = [];
for (const p of paragraphs) {
const trimmed = p.replace(/^\s+|\s+$/g, '');
if (!trimmed) continue;
out.push('<p>' + renderInline(trimmed).replace(/\r?\n/g, '<br>') + '</p>');
}
return out.join('');
}
let html = '';
for (const p of parts) {
if (p.kind === 'text') html += renderTextChunk(p.value);
else html += '<pre class="md-code">' + highlight(p.value, q) + '</pre>';
}
return html;
}
function sevCls(s) { return SEVS.includes(s) ? 'sev-'+s : 'sev-negligible'; }
function sevLbl(s) { return s ? s[0].toUpperCase()+s.slice(1) : 'Medium'; }
function typeLabel(t) { return t.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()); }
// First sentence of text (up to and including the first period).
function firstSentence(text) {
const m = String(text).match(/\.(\s|$)/);
return m ? text.slice(0, text.indexOf(m[0]) + 1) : text;
}
// Build full MD report (mirrors to_markdown in Python).
function buildMdFull() {
const SORD = ['critical','high','medium','low','negligible'];
const today = new Date().toISOString().slice(0, 10);
let out = '# Code Audit Report\n\n_Generated ' + today + '_\n\n';
const typeMap = new Map();
for (const f of FINDINGS) {
if (!typeMap.has(f.type)) typeMap.set(f.type, []);
typeMap.get(f.type).push(f);
}
const sortedTypes = [...typeMap.keys()].sort();
out += '## Summary\n\n| Type | Severity | Count |\n|------|----------|-------|\n';
for (const t of sortedTypes) {
const items = typeMap.get(t);
const topSev = items.map(f => f.severity || 'medium').reduce((best, s) => {
const bi = SORD.indexOf(best), si = SORD.indexOf(s);
return (si >= 0 && (bi < 0 || si < bi)) ? s : best;
}, 'negligible');
out += '| ' + typeLabel(t) + ' | ' + topSev + ' | ' + items.length + ' |\n';
}
for (const t of sortedTypes) {
out += '\n## ' + typeLabel(t) + '\n\n';
for (const f of typeMap.get(t)) {
const sevStr = f.severity ? ' `' + f.severity.toUpperCase() + '`' : '';
const easyStr = f.easy ? ' ⚡ easy' : '';
out += '### ' + f.id + sevStr + easyStr + '\n\n';
out += '**Description:** ' + (f.description || '') + '\n\n';
if (f.locations.length) {
out += '**Locations:**\n\n';
for (const l of f.locations) {
if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
}
out += '**Fix:** ' + (f.fix || '') + '\n\n---\n\n';
}
}
return out;
}
// Build MD short checklist (mirrors to_md_short in Python).
function buildMdShort() {
const lines = [];
const ids = [];
const sorted = [...FINDINGS].sort((a, b) => a.type.localeCompare(b.type));
for (const f of sorted) {
ids.push(f.id);
const desc = firstSentence(f.description || '');
const locs = f.locations.map(l => l.ref);
const locStr = locs.length ? ' - [' + locs.join(', ') + ']' : '';
const sevStr = f.severity ? ' [' + f.severity.toUpperCase() + ']' : '';
const easyStr = f.easy ? ' ⚡' : '';
lines.push('- [ ] - ' + f.id + sevStr + easyStr + ' - ' + desc + locStr);
}
lines.push('', '## IDs only', '');
ids.forEach(id => lines.push('- [ ] - ' + id));
return lines.join('\n') + '\n';
}
function downloadText(text, filename, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'}));
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function tomlStr(s) {
return '"' + String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"';
}
// Build TOML matching the original findings.toml format (re-parseable by toml_to_report.py).
function buildFindingsToml() {
function pad(key) { return (key + ' ').slice(0, 12); }
const chunks = [];
for (const f of FINDINGS) {
const lns = ['[[finding]]'];
lns.push(pad('type') + '= ' + tomlStr(f.type));
lns.push(pad('severity') + '= ' + tomlStr(f.severity || 'medium'));
if (f.easy) lns.push(pad('easy') + '= true');
lns.push(pad('description') + '= ' + tomlStr(f.description || ''));
const refs = f.locations.map(l => tomlStr(l.ref));
lns.push(pad('locations') + '= [' + refs.join(', ') + ']');
lns.push(pad('fix') + '= ' + tomlStr(f.fix || ''));
chunks.push(lns.join('\n'));
}
return chunks.join('\n\n') + '\n';
}
// Build project-info.toml from embedded PROJECT_INFO (same format as gen_project_info.py).
function buildProjectInfoToml() {
const ORDER = ['name','description','git_url','repo_url','commit','commit_short','commit_url','branch','generated'];
const pi = Object.assign({}, PROJECT_INFO);
if (!('description' in pi)) pi.description = '';
const lines = [
'# Project metadata for the code audit report (all fields optional).',
'# Hand-edit freely (e.g. fill in description); re-run the audit without --force to keep edits.',
'', '[project]'
];
for (const k of ORDER) {
if (k in pi) lines.push(k + ' = ' + tomlStr(pi[k]));
}
return lines.join('\n') + '\n';
}
// Build the GitHub-ready Markdown for one finding (mirrors findings.md).
function findingToMd(f) {
let out = '### ' + f.id + (f.severity ? ' `' + f.severity.toUpperCase() + '`' : '') + (f.easy ? ' ⚡ easy' : '') + '\n\n';
out += '**Description:** ' + (f.description || '') + '\n\n';
if (f.locations.length) {
out += '**Locations:**\n\n';
for (const l of f.locations) {
if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
}
out += '**Fix:** ' + (f.fix || '') + '\n';
return out;
}
function flashBtn(btn, msg) {
btn.textContent = msg;
setTimeout(() => { btn.textContent = '⧉ Copy'; }, 1200);
}
function copyText(text, btn) {
const ok = () => flashBtn(btn, '✓ Copied');
const no = () => flashBtn(btn, '✗ Failed');
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px';
document.body.appendChild(ta); ta.select();
const r = document.execCommand('copy');
document.body.removeChild(ta);
r ? ok() : no();
} catch { no(); }
};
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(text).then(ok, fallback);
else fallback();
}
// Filter logic
function getKey(f) {
if (S.groupBy === 'severity') return f.severity || 'medium';
if (S.groupBy === 'file') return f.locations.length ? f.locations[0].ref.replace(/:.*/,'') : '(no location)';
if (S.groupBy === 'none') return '';
return f.type_label;
}
// Pre-compute search haystack per finding (cleared on full re-render)
const _hay = new Map();
function hayOf(f) {
if (!_hay.has(f.id)) {
_hay.set(f.id, [f.id, f.type_label, f.severity, f.description, f.fix,
f.easy ? 'easy quick win' : '',
...f.locations.map(l => l.ref)].join(' ').toLowerCase());
}
return _hay.get(f.id);
}
function matches(f) {
if (S.easyOnly && !f.easy) return false;
if (S.hiddenSev.has(f.severity || 'medium')) return false;
if (S.hiddenType.has(f.type)) return false;
const st = statusOf(f.id);
if (S.hiddenStatus.has(st)) return false;
if (S.search) {
const q = S.search.toLowerCase();
if (!hayOf(f).includes(q) && !st.includes(q)) return false;
}
return true;
}
function sortKeys(keys) {
if (S.groupBy === 'severity') {
return [...keys].sort((a, b) => {
const ai = SEVS.indexOf(a), bi = SEVS.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
}
return [...keys].sort();
}
// Build body HTML for an expanded card (called lazily on first expand).
function renderCardBody(f, q) {
let locs = '';
for (const l of f.locations) {
const lineInfo = l.lines != null
? ' <span class="loc-lines">(' + l.lines + ' line' + (l.lines === 1 ? '' : 's') + ')</span>'
: '';
locs += '<div class="loc-block"><div class="loc-ref">' + highlight(l.ref, q) + lineInfo + '</div>';
locs += l.code != null ? '<pre>' + esc(l.code) + '</pre>' : '<div class="not-found">(file not found)</div>';
locs += '</div>';
}
return (
'<div class="field-lbl">Description</div>' +
'<div class="desc">' + renderMd(f.description, q) + '</div>' +
(locs ? '<div class="field-lbl">Locations</div>' + locs : '') +
'<div class="field-lbl">Fix</div>' +
'<div class="fix-txt">' + renderMd(f.fix, q) + '</div>'
);
}
// Render card shell only — header + empty or populated body.
// When collapsed the body is empty (display:none) — avoids renderMd for 1800+ cards.
function renderCard(f, q) {
const sc = sevCls(f.severity || 'medium');
const sl = sevLbl(f.severity || 'medium');
const st = statusOf(f.id);
const sc2 = STATUS_CLS[st] || 'st-open';
const sl2 = STATUS_LBL[st] || '● open';
const isCollapsed = S.collapsed.has(f.id);
const isResolved = st !== 'open';
const locSummary = f.locations.length
? f.locations[0].ref + (f.locations.length > 1 ? ' \u2026' : '')
: '';
const bodyInner = isCollapsed ? '' : renderCardBody(f, q);
return (
'<div class="card' + (isResolved ? ' resolved' : '') + '" data-card-id="' + esc(f.id) + '">' +
'<div class="card-header" data-action="toggle-card" data-id="' + esc(f.id) + '">' +
'<span class="card-arrow">' + (isCollapsed ? '\u25b6' : '\u25bc') + '</span>' +
'<span class="card-id">' + esc(f.id) + '</span>' +
'<span class="badge ' + sc + '" style="cursor:default">' + esc(sl) + '</span>' +
'<span class="badge type-badge" style="cursor:default">' + esc(f.type_label) + '</span>' +
(f.easy ? '<span class="badge easy-badge" style="cursor:default" title="Easy to verify & fix - no deep knowledge of the code, SDK or technology required">\u26a1 Easy</span>' : '') +
(locSummary ? '<span class="card-loc" title="' + esc(locSummary) + '">' + esc(locSummary) + '</span>' : '') +
'<button class="status-btn ' + sc2 + '" data-action="cycle-status" data-id="' + esc(f.id) + '">' + sl2 + '</button>' +
'<button class="copy-btn" data-action="copy-md" data-id="' + esc(f.id) + '" title="Copy as Markdown for GitHub">\u29c9 Copy</button>' +
'</div>' +
'<div class="card-body" data-body-id="' + esc(f.id) + '"' + (isCollapsed ? ' style="display:none"' : '') + '>' +
bodyInner +
'</div></div>'
);
}
function initFilterBar() {
const fb = document.getElementById('filterbar');
let h = '<div class="filter-row"><span class="filter-lbl">Severity:</span>';
for (const s of PRESENT_SEVS)
h += '<button class="badge ' + sevCls(s) + '" data-sev="' + s + '">' + sevLbl(s) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="sev"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Type:</span>';
for (const t of ALL_TYPES)
h += '<button class="badge type-badge" data-type="' + esc(t) + '">' + esc(typeLabel(t)) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="type"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Status:</span>';
for (const st of STATUSES)
h += '<button class="badge ' + (STATUS_CLS[st]||'st-open') + '" data-status="' + st + '">' + (STATUS_LBL[st]||st) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="status"></button></div>';
if (HAS_EASY)
h += '<div class="filter-row"><span class="filter-lbl">Quick wins:</span>'
+ '<button class="badge easy-badge" data-easy="1">\u26a1 Easy only</button></div>';
fb.innerHTML = h;
}
function updateToggles() {
for (const b of document.querySelectorAll('[data-sev]')) b.classList.toggle('off', S.hiddenSev.has(b.dataset.sev));
for (const b of document.querySelectorAll('[data-type]')) b.classList.toggle('off', S.hiddenType.has(b.dataset.type));
for (const b of document.querySelectorAll('[data-status]')) b.classList.toggle('off', S.hiddenStatus.has(b.dataset.status));
for (const b of document.querySelectorAll('[data-easy]')) b.classList.toggle('off', !S.easyOnly);
for (const b of document.querySelectorAll('[data-toggle-all]')) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : S.hiddenStatus;
b.textContent = set.size === 0 ? 'Hide all' : 'Show all';
}
}
function updateProgress() {
const total = FINDINGS.length;
const done = FINDINGS.filter(f => statusOf(f.id) !== 'open').length;
const pct = total ? Math.round(done / total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-text').textContent = done + ' / ' + total + ' resolved (' + pct + '%)';
}
// Surgical toggle: flip one card without touching the rest of the DOM.
function toggleCard(id) {
const wasCollapsed = S.collapsed.has(id);
if (wasCollapsed) S.collapsed.delete(id); else S.collapsed.add(id);
const body = document.querySelector('[data-body-id="' + id + '"]');
const arrow = document.querySelector('[data-card-id="' + id + '"] .card-arrow');
if (!body) { render(); return; }
if (wasCollapsed) {
if (!body.firstChild) {
const f = FINDING_BY_ID.get(id);
if (f) body.innerHTML = renderCardBody(f, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
} else {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
}
}
// Surgical status update: update one card's button + resolved class.
function cycleStatus(id) {
statuses[id] = STATUS_NEXT[statusOf(id)] || 'open';
saveStatuses();
const st = statusOf(id);
const btn = document.querySelector('.status-btn[data-id="' + id + '"]');
if (!btn) { render(); return; }
btn.className = 'status-btn ' + (STATUS_CLS[st] || 'st-open');
btn.textContent = STATUS_LBL[st] || '\u25cf open';
const card = document.querySelector('[data-card-id="' + id + '"]');
if (card) card.classList.toggle('resolved', st !== 'open');
updateProgress();
}
// Surgical collapse/expand for a group: toggle all bodies in the group without full re-render.
function collapseGroup(key, collapse) {
FINDINGS.forEach(f => {
if (getKey(f) !== key) return;
if (collapse) S.collapsed.add(f.id); else S.collapsed.delete(f.id);
const body = document.querySelector('[data-body-id="' + f.id + '"]');
const arrow = document.querySelector('[data-card-id="' + f.id + '"] .card-arrow');
if (!body) return;
if (collapse) {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
} else {
if (!body.firstChild) {
const ff = FINDING_BY_ID.get(f.id);
if (ff) body.innerHTML = renderCardBody(ff, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
}
});
}
// Full re-render: used for filter/group-by/search changes.
// Batched via requestAnimationFrame.
let _rafPending = false;
function scheduleRender() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; render(); });
}
function render() {
_hay.clear();
updateToggles();
updateProgress();
const q = S.search;
const visible = FINDINGS.filter(matches);
document.getElementById('stats').textContent = visible.length + ' / ' + FINDINGS.length + ' findings';
// Build groups
const map = new Map();
for (const f of visible) {
const k = getKey(f);
if (!map.has(k)) map.set(k, []);
map.get(k).push(f);
}
// Hidden-groups bar
const hbar = document.getElementById('hidden-bar');
if (S.hiddenGroup.size > 0) {
let hh = '<div class="hidden-inner"><span class="hidden-lbl">Hidden:</span>';
for (const g of [...S.hiddenGroup].sort())
hh += '<button class="show-btn" data-action="show-group" data-key="' + esc(g) + '">' + esc(g || '(all findings)') + '</button>';
hh += '<button class="show-all-btn" data-action="show-all">Show all</button></div>';
hbar.innerHTML = hh;
} else {
hbar.innerHTML = '';
}
// Content
const content = document.getElementById('content');
if (!visible.length) {
content.innerHTML = '<div class="no-results">No findings match the current filters.</div>';
syncHash();
return;
}
let hh = '';
for (const k of sortKeys([...map.keys()])) {
if (S.hiddenGroup.has(k)) continue;
const items = map.get(k);
const title = k || 'All Findings';
const done = items.filter(f => statusOf(f.id) !== 'open').length;
const allDone = done === items.length;
hh += '<div class="group-section">' +
'<div class="group-header">' +
'<span class="group-title' + (allDone ? ' all-done' : '') + '">' + esc(title) + '</span>' +
'<span class="group-count">' + done + '/' + items.length + '</span>' +
'<button class="icon-btn" data-action="collapse-group" data-key="' + esc(k) + '" title="Collapse all">\u229f</button>' +
'<button class="icon-btn" data-action="expand-group" data-key="' + esc(k) + '" title="Expand all">\u229e</button>' +
'<button class="hide-btn" data-action="hide-group" data-key="' + esc(k) + '">Hide</button>' +
'</div>';
for (const f of items) hh += renderCard(f, q);
hh += '</div>';
}
content.innerHTML = hh;
syncHash();
}
// Event delegation
document.getElementById('filterbar').addEventListener('click', e => {
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-toggle-all],[data-easy]');
if (!b) return;
if (b.dataset.easy) {
S.easyOnly = !S.easyOnly;
}
else if (b.dataset.toggleAll) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : S.hiddenStatus;
const full = k === 'sev' ? PRESENT_SEVS : k === 'type' ? ALL_TYPES : STATUSES;
if (set.size === 0) full.forEach(v => set.add(v));
else set.clear();
}
else if (b.dataset.sev) { const s = S.hiddenSev; s.has(b.dataset.sev) ? s.delete(b.dataset.sev) : s.add(b.dataset.sev); }
else if (b.dataset.type) { const s = S.hiddenType; s.has(b.dataset.type) ? s.delete(b.dataset.type) : s.add(b.dataset.type); }
else if (b.dataset.status) { const s = S.hiddenStatus; s.has(b.dataset.status) ? s.delete(b.dataset.status) : s.add(b.dataset.status); }
scheduleRender();
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-action]');
if (!b) return;
const { action, key, id } = b.dataset;
if (action === 'hide-group') { S.hiddenGroup.add(key); scheduleRender(); }
else if (action === 'show-group') { S.hiddenGroup.delete(key); scheduleRender(); }
else if (action === 'show-all') { S.hiddenGroup.clear(); scheduleRender(); }
else if (action === 'collapse-all') { FINDINGS.forEach(f => S.collapsed.add(f.id)); scheduleRender(); }
else if (action === 'expand-all') { S.collapsed.clear(); scheduleRender(); }
else if (action === 'copy-md') {
e.stopPropagation();
const f = FINDING_BY_ID.get(id);
if (f) copyText(findingToMd(f), b);
}
else if (action === 'download-md') { downloadText(buildMdFull(), 'findings.md'); }
else if (action === 'download-md-short') { downloadText(buildMdShort(), 'findings-short.md'); }
else if (action === 'download-toml') {
downloadText(buildFindingsToml(), 'findings.toml', 'application/toml');
downloadText(buildProjectInfoToml(), 'project-info.toml', 'application/toml');
}
else if (action === 'cycle-status') {
e.stopPropagation();
cycleStatus(id);
}
else if (action === 'toggle-card') {
toggleCard(id);
}
else if (action === 'collapse-group') {
collapseGroup(key, true);
}
else if (action === 'expand-group') {
collapseGroup(key, false);
}
});
// Debounced search — 180 ms after last keystroke
let _searchTimer = 0;
document.getElementById('search').addEventListener('input', e => {
S.search = e.target.value;
clearTimeout(_searchTimer);
_searchTimer = setTimeout(scheduleRender, 180);
});
document.getElementById('groupby').addEventListener('change', e => {
S.groupBy = e.target.value;
S.hiddenGroup.clear();
scheduleRender();
});
// Init
loadStatuses();
initFilterBar();
loadHash();
FINDINGS.forEach(f => S.collapsed.add(f.id));
render();
})();
</script>
</body></html>