Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:
- name: Post PR comment
uses: actions/github-script@v8
with:
retries: 3
script: |
const fs = require('fs');
const summary = fs.readFileSync('coverage/coverage.txt', 'utf8');
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/distribution/reference v0.6.0
github.com/go-git/go-git/v5 v5.17.0
github.com/go-git/go-git/v5 v5.17.2
github.com/mattn/go-runewidth v0.0.21
github.com/muesli/termenv v0.16.0
github.com/schollz/progressbar/v3 v3.19.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand Down
21 changes: 20 additions & 1 deletion internal/scan/repo/ignore.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package repo

import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)

// maxIgnoreFileSize is the maximum allowed size for a .armisignore file (1 MB).
const maxIgnoreFileSize = 1 << 20

// IgnoreMatcher matches files against ignore patterns.
type IgnoreMatcher struct {
patterns []gitignore.Pattern
Expand All @@ -28,6 +33,9 @@ func LoadIgnorePatterns(repoRoot string) (*IgnoreMatcher, error) {
}

if !info.IsDir() && info.Name() == ".armisignore" {
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf(".armisignore is a symlink (rejected): %s", path)
}
Comment on lines 35 to +38
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly added behavior that rejects symlinked .armisignore files isn’t covered by a unit test. Since this is security-related behavior, add a test that creates a symlink .armisignore and asserts LoadIgnorePatterns returns an error (skip the test on platforms/environments where symlink creation isn’t permitted).

Copilot uses AI. Check for mistakes.
patterns, err := loadIgnoreFile(path, repoRoot)
Comment on lines 35 to 39
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The size guard is based on the os.FileInfo returned by filepath.Walk (which uses Lstat) and is checked before os.ReadFile. This can be bypassed by a symlink named .armisignore pointing to a large file (symlink size is small but ReadFile follows it), and it’s also a TOCTOU window if the file grows after the check. Consider enforcing the limit in loadIgnoreFile by opening the file and reading via a bounded reader (e.g., io.LimitReader) and/or rejecting symlinks explicitly, so the limit applies to the actual bytes read.

Copilot uses AI. Check for mistakes.
if err != nil {
return err
Expand All @@ -50,10 +58,21 @@ func LoadIgnorePatterns(repoRoot string) (*IgnoreMatcher, error) {
}

func loadIgnoreFile(ignoreFilePath, repoRoot string) ([]gitignore.Pattern, error) {
data, err := os.ReadFile(ignoreFilePath) // #nosec G304 - ignore file path is constructed internally
f, err := os.Open(ignoreFilePath) // #nosec G304 - ignore file path is constructed internally
if err != nil {
return nil, err
}
defer f.Close() //nolint:errcheck // read-only file

// Read up to maxIgnoreFileSize+1 to detect files exceeding the limit.
limited := io.LimitReader(f, maxIgnoreFileSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if len(data) > maxIgnoreFileSize {
return nil, fmt.Errorf(".armisignore file too large (max %d bytes): %s", maxIgnoreFileSize, ignoreFilePath)
}

ignoreDir := filepath.Dir(ignoreFilePath)
relDir, err := filepath.Rel(repoRoot, ignoreDir)
Expand Down
47 changes: 47 additions & 0 deletions internal/scan/repo/ignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package repo
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -97,3 +98,49 @@ func TestLoadIgnorePatternsNested(t *testing.T) {
t.Error("Expected nested pattern to match")
}
}

func TestLoadIgnorePatternsSymlinkRejected(t *testing.T) {
tmpDir := t.TempDir()

// Create a real file that the symlink will point to.
realFile := filepath.Join(tmpDir, "real-ignore")
if err := os.WriteFile(realFile, []byte("*.log\n"), 0600); err != nil {
t.Fatalf("Failed to create real ignore file: %v", err)
}

// Create a symlink named .armisignore pointing to the real file.
symlinkPath := filepath.Join(tmpDir, ".armisignore")
if err := os.Symlink(realFile, symlinkPath); err != nil {
t.Skipf("Symlink creation not supported: %v", err)
}

_, err := LoadIgnorePatterns(tmpDir)
if err == nil {
t.Fatal("expected error for symlinked .armisignore file")
}
if !strings.Contains(err.Error(), "symlink") {
t.Fatalf("expected 'symlink' error, got: %v", err)
}
}

func TestLoadIgnorePatternsOversizedFile(t *testing.T) {
tmpDir := t.TempDir()

oversized := make([]byte, maxIgnoreFileSize+1)
for i := range oversized {
oversized[i] = 'a'
}

ignoreFile := filepath.Join(tmpDir, ".armisignore")
if err := os.WriteFile(ignoreFile, oversized, 0600); err != nil {
t.Fatalf("Failed to create oversized ignore file: %v", err)
}

_, err := LoadIgnorePatterns(tmpDir)
if err == nil {
t.Fatal("expected error for oversized .armisignore file")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("expected 'too large' error, got: %v", err)
}
}
Loading