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
2 changes: 2 additions & 0 deletions .releaseguard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ scanning:
enabled: true
require:
- LICENSE
symlinks:
enabled: false # set to true to flag symlinks in release artifacts

transforms:
remove_source_maps: true
Expand Down
5 changes: 5 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
- **Symlink detection** — flags symbolic links in release artifacts, which can cause path traversal vulnerabilities when archives are unpacked

### SBOM Generation
- **All major ecosystems**: Node.js, Python, Go, Rust, Java, .NET, Ruby, PHP, Container, System packages
Expand Down Expand Up @@ -214,6 +215,10 @@ scanning:
metadata:
enabled: true
fail_on_source_maps: true
symlinks:
enabled: true
allow:
- "latest" # intentional symlink: latest -> v1.2.3
transforms:
remove_source_maps: true
add_checksums: true
Expand Down
3 changes: 3 additions & 0 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ func DefaultConfig() *Config {
Enabled: true,
Require: []string{"LICENSE"},
},
Symlinks: SymlinksConfig{
Enabled: false,
},
},
Transforms: TransformConfig{
RemoveSourceMaps: true,
Expand Down
10 changes: 10 additions & 0 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type ScanConfig struct {
Metadata MetadataConfig `mapstructure:"metadata" yaml:"metadata"`
UnexpectedFiles UnexpectedConfig `mapstructure:"unexpected_files" yaml:"unexpected_files"`
Licenses LicenseConfig `mapstructure:"licenses" yaml:"licenses"`
Symlinks SymlinksConfig `mapstructure:"symlinks" yaml:"symlinks"`
}

type SecretsConfig struct {
Expand All @@ -67,6 +68,15 @@ type LicenseConfig struct {
Require []string `mapstructure:"require" yaml:"require"`
}

// SymlinksConfig controls detection of symbolic links within the artifact tree.
type SymlinksConfig struct {
// Enabled controls whether the symlink scanner runs.
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
// Allow lists specific symlink paths (relative to scan root) that are permitted.
// Useful for intentional symlinks like `latest -> v1.2.3`.
Allow []string `mapstructure:"allow" yaml:"allow,omitempty"`
}

type TransformConfig struct {
RemoveSourceMaps bool `mapstructure:"remove_source_maps" yaml:"remove_source_maps"`
DeleteForbiddenFiles bool `mapstructure:"delete_forbidden_files" yaml:"delete_forbidden_files"`
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"
CategorySymlink = "symlink"
)

// Finding represents a single scanner result.
Expand Down
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.Symlinks.Enabled {
p.scanners = append(p.scanners, &SymlinksScanner{})
}

return p
}
Expand Down
50 changes: 50 additions & 0 deletions internal/scan/symlinks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package scan

import (
"fmt"

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

// SymlinksScanner detects symbolic links present in release artifacts.
// Symlinks in a distribution bundle are almost always unintentional and carry
// real risk: they may point outside the artifact root (enabling path traversal
// when the archive is unpacked) and are not portable across all filesystems.
type SymlinksScanner struct{}

func (s *SymlinksScanner) Name() string { return "symlinks" }

func (s *SymlinksScanner) Scan(root string, artifacts []model.Artifact, cfg *config.Config) ([]model.Finding, error) {
symCfg := cfg.Scanning.Symlinks

// Build a set of explicitly allowed symlink paths for O(1) lookup.
allowed := make(map[string]struct{}, len(symCfg.Allow))
for _, p := range symCfg.Allow {
allowed[p] = struct{}{}
}

var findings []model.Finding

for _, a := range artifacts {
if a.Kind != "symlink" {
continue
}
if _, ok := allowed[a.Path]; ok {
continue
}

findings = append(findings, model.Finding{
ID: "RG-SYM-001",
Category: model.CategorySymlink,
Severity: model.SeverityMedium,
Path: a.Path,
Message: fmt.Sprintf("Symbolic link found in release artifact: %s", a.Path),
Evidence: "Symlinks can point outside the artifact root and cause path traversal issues when unpacked.",
Autofixable: false,
RecommendedFix: "Replace the symlink with a regular file copy, or add the path to the symlinks.allow list if intentional.",
})
}

return findings, nil
}
140 changes: 140 additions & 0 deletions internal/scan/symlinks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package scan

import (
"testing"

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

func TestSymlinksScanner_Name(t *testing.T) {
s := &SymlinksScanner{}
if s.Name() != "symlinks" {
t.Errorf("Name() = %q, want %q", s.Name(), "symlinks")
}
}

func TestSymlinksScanner_Scan(t *testing.T) {
makeCfg := func(allow ...string) *config.Config {
cfg := config.DefaultConfig()
cfg.Scanning.Symlinks = config.SymlinksConfig{
Enabled: true,
Allow: allow,
}
return cfg
}

t.Run("no symlinks produces no findings", func(t *testing.T) {
arts := []model.Artifact{
{Path: "index.js", Kind: "file"},
{Path: "style.css", Kind: "file"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected 0 findings, got %d", len(findings))
}
})

t.Run("single symlink produces RG-SYM-001", func(t *testing.T) {
arts := []model.Artifact{
{Path: "latest", Kind: "symlink"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 1 {
t.Fatalf("expected 1 finding, got %d", len(findings))
}
f := findings[0]
if f.ID != "RG-SYM-001" {
t.Errorf("expected ID RG-SYM-001, got %q", f.ID)
}
if f.Category != model.CategorySymlink {
t.Errorf("expected category %q, got %q", model.CategorySymlink, f.Category)
}
if f.Severity != model.SeverityMedium {
t.Errorf("expected severity %q, got %q", model.SeverityMedium, f.Severity)
}
})

t.Run("multiple symlinks produce multiple findings", func(t *testing.T) {
arts := []model.Artifact{
{Path: "latest", Kind: "symlink"},
{Path: "current", Kind: "symlink"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 2 {
t.Errorf("expected 2 findings, got %d", len(findings))
}
})

t.Run("allowed symlink path is skipped", func(t *testing.T) {
arts := []model.Artifact{
{Path: "latest", Kind: "symlink"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg("latest"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected allowed symlink to be skipped, got %d findings", len(findings))
}
})

t.Run("allowed symlink skipped, others still flagged", func(t *testing.T) {
arts := []model.Artifact{
{Path: "latest", Kind: "symlink"},
{Path: "unexpected-link", Kind: "symlink"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg("latest"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 1 {
t.Fatalf("expected 1 finding for non-allowed symlink, got %d", len(findings))
}
if findings[0].Path != "unexpected-link" {
t.Errorf("expected finding for unexpected-link, got %q", findings[0].Path)
}
})

t.Run("regular files are not flagged", func(t *testing.T) {
arts := []model.Artifact{
{Path: "main.js", Kind: "file"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected regular files to be ignored, got %d findings", len(findings))
}
})

t.Run("directories are not flagged", func(t *testing.T) {
arts := []model.Artifact{
{Path: "static", Kind: "dir"},
}
s := &SymlinksScanner{}
findings, err := s.Scan(".", arts, makeCfg())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("expected directories to be ignored, got %d findings", len(findings))
}
})
}
28 changes: 28 additions & 0 deletions test/integration/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,34 @@ func TestCheck_CleanDist_NoSecretFindings(t *testing.T) {
}
}

func TestCheck_ReactDist_NoSymlinkFindings(t *testing.T) {
root := filepath.Join(fixturesDir(t), "react-dist")
cfg := config.DefaultConfig()
cfg.Scanning.Secrets.Enabled = false
cfg.Scanning.Metadata.Enabled = false
cfg.Scanning.UnexpectedFiles.Enabled = false
cfg.Scanning.Licenses.Enabled = false
cfg.Scanning.Symlinks = config.SymlinksConfig{Enabled: true}

walker := collect.NewWalker()
artifacts, err := walker.Walk(root)
if err != nil {
t.Fatalf("walk: %v", err)
}

pipeline := scan.NewPipeline(cfg)
findings, err := pipeline.Run(root, artifacts, cfg)
if err != nil {
t.Fatalf("scan: %v", err)
}

for _, f := range findings {
if f.Category == model.CategorySymlink {
t.Errorf("unexpected symlink finding in react-dist: %+v", f)
}
}
}

func TestCheck_EntropyDetection(t *testing.T) {
root := filepath.Join(fixturesDir(t), "react-dist")
cfg := config.DefaultConfig()
Expand Down