Skip to content
Open
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
4 changes: 4 additions & 0 deletions .releaseguard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ scanning:
enabled: true
require:
- LICENSE
file_size:
enabled: false # set to true to enforce artifact size limits
max_file_bytes: 10485760 # 10 MiB per file
max_total_bytes: 104857600 # 100 MiB total bundle

transforms:
remove_source_maps: true
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ releaseguard harden ./dist
- Metadata leaks (source maps, debug symbols, build paths, internal URLs)
- Unexpected content (test files, `.git` remnants, CI configs)
- License and notice presence checker
- **File size limits** — per-file and total bundle thresholds to catch accidental large binaries or bloated bundles

### SBOM Generation
- **All major ecosystems**: Node.js, Python, Go, Rust, Java, .NET, Ruby, PHP, Container, System packages
Expand Down Expand Up @@ -214,6 +215,12 @@ scanning:
metadata:
enabled: true
fail_on_source_maps: true
file_size:
enabled: true
max_file_bytes: 10485760 # 10 MiB per file
max_total_bytes: 104857600 # 100 MiB total bundle
per_extension:
".wasm": 52428800 # 50 MiB override for WebAssembly
transforms:
remove_source_maps: true
add_checksums: true
Expand Down
5 changes: 5 additions & 0 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func DefaultConfig() *Config {
Enabled: true,
Require: []string{"LICENSE"},
},
FileSize: FileSizeConfig{
Enabled: false,
MaxFileBytes: 10 * 1024 * 1024, // 10 MiB
MaxTotalBytes: 100 * 1024 * 1024, // 100 MiB
},
},
Transforms: TransformConfig{
RemoveSourceMaps: true,
Expand Down
16 changes: 16 additions & 0 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ type ScanConfig struct {
Metadata MetadataConfig `mapstructure:"metadata" yaml:"metadata"`
UnexpectedFiles UnexpectedConfig `mapstructure:"unexpected_files" yaml:"unexpected_files"`
Licenses LicenseConfig `mapstructure:"licenses" yaml:"licenses"`
FileSize FileSizeConfig `mapstructure:"file_size" yaml:"file_size"`
}

// FileSizeConfig controls detection of oversized files in release artifacts.
type FileSizeConfig struct {
// Enabled controls whether the file size scanner runs.
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
// MaxFileBytes is the maximum allowed size for any single file (in bytes).
// Default: 10 MiB (10 * 1024 * 1024).
MaxFileBytes int64 `mapstructure:"max_file_bytes" yaml:"max_file_bytes"`
// MaxTotalBytes is the maximum allowed total size of all artifacts combined (in bytes).
// Default: 100 MiB (100 * 1024 * 1024).
MaxTotalBytes int64 `mapstructure:"max_total_bytes" yaml:"max_total_bytes"`
// PerExtension overrides MaxFileBytes for specific file extensions (e.g. ".wasm": 52428800).
// Keys must include the leading dot. Values are in bytes.
PerExtension map[string]int64 `mapstructure:"per_extension" yaml:"per_extension,omitempty"`
}

type SecretsConfig struct {
Expand Down
1 change: 1 addition & 0 deletions internal/model/finding.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
CategoryUnexpected = "unexpected"
CategoryPolicy = "policy"
CategoryLicense = "license"
CategoryFileSize = "filesize"
)

// Finding represents a single scanner result.
Expand Down
76 changes: 76 additions & 0 deletions internal/scan/filesize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package scan

import (
"fmt"
"path/filepath"

"github.com/Helixar-AI/ReleaseGuard/internal/config"
"github.com/Helixar-AI/ReleaseGuard/internal/model"
)

// FileSizeScanner detects individual files and total artifact bundles that exceed
// configurable size thresholds.
type FileSizeScanner struct{}

func (s *FileSizeScanner) Name() string { return "filesize" }

func (s *FileSizeScanner) Scan(root string, artifacts []model.Artifact, cfg *config.Config) ([]model.Finding, error) {
fsCfg := cfg.Scanning.FileSize
var findings []model.Finding
var totalBytes int64

for _, a := range artifacts {
if a.Kind != "file" {
continue
}

totalBytes += a.Size

limit := fsCfg.MaxFileBytes
if override, ok := fsCfg.PerExtension[filepath.Ext(a.Path)]; ok {
limit = override
}

if a.Size > limit {
findings = append(findings, model.Finding{
ID: "RG-SIZE-001",
Category: model.CategoryFileSize,
Severity: model.SeverityMedium,
Path: a.Path,
Message: fmt.Sprintf("File exceeds size limit: %s > %s", humanBytes(a.Size), humanBytes(limit)),
Evidence: fmt.Sprintf("size=%d bytes, limit=%d bytes", a.Size, limit),
Autofixable: false,
RecommendedFix: "Exclude this file from the release artifact or reduce its size.",
})
}
}

if totalBytes > fsCfg.MaxTotalBytes {
findings = append(findings, model.Finding{
ID: "RG-SIZE-002",
Category: model.CategoryFileSize,
Severity: model.SeverityHigh,
Path: ".",
Message: fmt.Sprintf("Total artifact bundle exceeds size limit: %s > %s", humanBytes(totalBytes), humanBytes(fsCfg.MaxTotalBytes)),
Evidence: fmt.Sprintf("total=%d bytes, limit=%d bytes", totalBytes, fsCfg.MaxTotalBytes),
Autofixable: false,
RecommendedFix: "Review and remove large or unnecessary files from the release bundle.",
})
}

return findings, nil
}

// humanBytes returns a human-readable representation of a byte count.
func humanBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
198 changes: 198 additions & 0 deletions internal/scan/filesize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package scan

import (
"testing"

"github.com/Helixar-AI/ReleaseGuard/internal/config"
"github.com/Helixar-AI/ReleaseGuard/internal/model"
)

func TestFileSizeScanner_Name(t *testing.T) {
s := &FileSizeScanner{}
if s.Name() != "filesize" {
t.Errorf("Name() = %q, want %q", s.Name(), "filesize")
}
}

func TestFileSizeScanner_Scan(t *testing.T) {
const (
kb = int64(1024)
mb = 1024 * kb
mib = mb
)

makeCfg := func(maxFile, maxTotal int64, perExt map[string]int64) *config.Config {
cfg := config.DefaultConfig()
cfg.Scanning.FileSize = config.FileSizeConfig{
Enabled: true,
MaxFileBytes: maxFile,
MaxTotalBytes: maxTotal,
PerExtension: perExt,
}
return cfg
}

makeArtifacts := func(sizes ...int64) []model.Artifact {
arts := make([]model.Artifact, 0, len(sizes))
for i, sz := range sizes {
arts = append(arts, model.Artifact{
Path: "file.bin",
Size: sz,
Kind: "file",
})
_ = i
}
return arts
}

t.Run("file under threshold produces no finding", func(t *testing.T) {
cfg := makeCfg(10*mib, 100*mib, nil)
arts := makeArtifacts(5 * mib)
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected 0 findings, got %d: %+v", len(findings), findings)
}
})

t.Run("file at exact threshold produces no finding", func(t *testing.T) {
cfg := makeCfg(10*mib, 100*mib, nil)
arts := makeArtifacts(10 * mib)
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected 0 findings at threshold, got %d", len(findings))
}
})

t.Run("file over threshold produces RG-SIZE-001", func(t *testing.T) {
cfg := makeCfg(10*mib, 100*mib, nil)
arts := makeArtifacts(11 * mib)
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 1 {
t.Fatalf("expected 1 finding, got %d", len(findings))
}
if findings[0].ID != "RG-SIZE-001" {
t.Errorf("expected ID RG-SIZE-001, got %q", findings[0].ID)
}
if findings[0].Category != model.CategoryFileSize {
t.Errorf("expected category %q, got %q", model.CategoryFileSize, findings[0].Category)
}
if findings[0].Severity != model.SeverityMedium {
t.Errorf("expected severity %q, got %q", model.SeverityMedium, findings[0].Severity)
}
})

t.Run("per-extension override allows larger file", func(t *testing.T) {
cfg := makeCfg(1*mib, 100*mib, map[string]int64{".wasm": 50 * mib})
arts := []model.Artifact{
{Path: "app.wasm", Size: 20 * mib, Kind: "file"},
}
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected 0 findings for .wasm within override limit, got %d", len(findings))
}
})

t.Run("per-extension override triggers when exceeded", func(t *testing.T) {
cfg := makeCfg(100*mib, 1000*mib, map[string]int64{".wasm": 5 * mib})
arts := []model.Artifact{
{Path: "app.wasm", Size: 10 * mib, Kind: "file"},
}
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 1 || findings[0].ID != "RG-SIZE-001" {
t.Errorf("expected RG-SIZE-001 for oversized .wasm, got %+v", findings)
}
})

t.Run("total bundle over threshold produces RG-SIZE-002", func(t *testing.T) {
cfg := makeCfg(100*mib, 10*mib, nil)
arts := makeArtifacts(6*mib, 6*mib) // 12 MiB total > 10 MiB limit
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var found bool
for _, f := range findings {
if f.ID == "RG-SIZE-002" {
found = true
if f.Severity != model.SeverityHigh {
t.Errorf("RG-SIZE-002 expected severity %q, got %q", model.SeverityHigh, f.Severity)
}
break
}
}
if !found {
t.Errorf("expected RG-SIZE-002 finding, got: %+v", findings)
}
})

t.Run("directories are skipped in size accounting", func(t *testing.T) {
cfg := makeCfg(1*kb, 1*kb, nil)
arts := []model.Artifact{
{Path: "subdir", Size: 999999, Kind: "dir"},
}
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected directories to be skipped, got %d findings", len(findings))
}
})

t.Run("zero-byte file produces no finding", func(t *testing.T) {
cfg := makeCfg(10*mib, 100*mib, nil)
arts := makeArtifacts(0)
s := &FileSizeScanner{}
findings, err := s.Scan(".", arts, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected 0 findings for zero-byte file, got %d", len(findings))
}
})
}

func TestHumanBytes(t *testing.T) {
tests := []struct {
input int64
want string
}{
{0, "0 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1024 * 1024, "1.0 MiB"},
{10 * 1024 * 1024, "10.0 MiB"},
}

for _, tc := range tests {
got := humanBytes(tc.input)
if got != tc.want {
t.Errorf("humanBytes(%d) = %q, want %q", tc.input, got, tc.want)
}
}
}
3 changes: 3 additions & 0 deletions internal/scan/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ func NewPipeline(cfg *config.Config) *Pipeline {
if cfg.Scanning.Licenses.Enabled {
p.scanners = append(p.scanners, &LicenseScanner{})
}
if cfg.Scanning.FileSize.Enabled {
p.scanners = append(p.scanners, &FileSizeScanner{})
}

return p
}
Expand Down
Loading