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
30 changes: 24 additions & 6 deletions cmd/brief/enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,26 @@ func detectPublishedPURLs(root string) []string {
return purls
}

// isSymlink returns true if path is a symbolic link.
func isSymlink(path string) bool {
info, err := os.Lstat(path)
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}

// safeReadManifest reads a manifest file, rejecting symlinks to prevent
// file disclosure when extracted content is sent to external services.
func safeReadManifest(path string) ([]byte, error) {
if isSymlink(path) {
return nil, fmt.Errorf("refusing to read symlink: %s", path)
}
return os.ReadFile(path)
}

func goModulePURL(root string) string {
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
data, err := safeReadManifest(filepath.Join(root, "go.mod"))
if err != nil {
return ""
}
Expand All @@ -181,7 +199,7 @@ func goModulePURL(root string) string {
}

func npmPackagePURL(root string) string {
data, err := os.ReadFile(filepath.Join(root, "package.json"))
data, err := safeReadManifest(filepath.Join(root, "package.json"))
if err != nil {
return ""
}
Expand All @@ -198,7 +216,7 @@ func npmPackagePURL(root string) string {

func pythonPackagePURL(root string) string {
// Try pyproject.toml [project] name
data, err := os.ReadFile(filepath.Join(root, "pyproject.toml"))
data, err := safeReadManifest(filepath.Join(root, "pyproject.toml"))
if err == nil {
var pyproject struct {
Project struct {
Expand All @@ -211,7 +229,7 @@ func pythonPackagePURL(root string) string {
}

// Try setup.cfg [metadata] name
data, err = os.ReadFile(filepath.Join(root, "setup.cfg"))
data, err = safeReadManifest(filepath.Join(root, "setup.cfg"))
if err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
Expand All @@ -231,7 +249,7 @@ func gemPURL(root string) string {
// Look for *.gemspec
matches, _ := filepath.Glob(filepath.Join(root, "*.gemspec"))
if len(matches) > 0 {
data, err := os.ReadFile(matches[0])
data, err := safeReadManifest(matches[0])
if err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
Expand All @@ -253,7 +271,7 @@ func gemPURL(root string) string {
}

func cratePURL(root string) string {
data, err := os.ReadFile(filepath.Join(root, "Cargo.toml"))
data, err := safeReadManifest(filepath.Join(root, "Cargo.toml"))
if err != nil {
return ""
}
Expand Down
111 changes: 111 additions & 0 deletions cmd/brief/enrich_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,117 @@ func TestProductFromFile(t *testing.T) {
}
}

func TestSafeReadManifest_RejectsSymlink(t *testing.T) {
dir := t.TempDir()
real := filepath.Join(dir, "real.txt")
if err := os.WriteFile(real, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link.txt")
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}

_, err := safeReadManifest(link)
if err == nil {
t.Fatal("expected error reading symlink, got nil")
}
}

func TestSafeReadManifest_AllowsRegularFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "regular.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}

data, err := safeReadManifest(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "hello" {
t.Errorf("got %q, want %q", string(data), "hello")
}
}

func TestGoModulePURL_SymlinkRejected(t *testing.T) {
dir := t.TempDir()
real := t.TempDir()
writeFile(t, real, "go.mod", "module github.com/evil/pkg\n\ngo 1.22.0\n")
if err := os.Symlink(filepath.Join(real, "go.mod"), filepath.Join(dir, "go.mod")); err != nil {
t.Fatal(err)
}

if got := goModulePURL(dir); got != "" {
t.Errorf("expected empty string for symlinked go.mod, got %q", got)
}
}

func TestNpmPackagePURL_SymlinkRejected(t *testing.T) {
dir := t.TempDir()
real := t.TempDir()
writeFile(t, real, "package.json", `{"name": "evil-pkg"}`)
if err := os.Symlink(filepath.Join(real, "package.json"), filepath.Join(dir, "package.json")); err != nil {
t.Fatal(err)
}

if got := npmPackagePURL(dir); got != "" {
t.Errorf("expected empty string for symlinked package.json, got %q", got)
}
}

func TestPythonPackagePURL_SymlinkRejected(t *testing.T) {
dir := t.TempDir()
real := t.TempDir()
writeFile(t, real, "pyproject.toml", "[project]\nname = \"evil\"\n")
if err := os.Symlink(filepath.Join(real, "pyproject.toml"), filepath.Join(dir, "pyproject.toml")); err != nil {
t.Fatal(err)
}

if got := pythonPackagePURL(dir); got != "" {
t.Errorf("expected empty string for symlinked pyproject.toml, got %q", got)
}
}

func TestPythonPackagePURL_SetupCfg_SymlinkRejected(t *testing.T) {
dir := t.TempDir()
real := t.TempDir()
writeFile(t, real, "setup.cfg", "[metadata]\nname = evil\n")
if err := os.Symlink(filepath.Join(real, "setup.cfg"), filepath.Join(dir, "setup.cfg")); err != nil {
t.Fatal(err)
}

if got := pythonPackagePURL(dir); got != "" {
t.Errorf("expected empty string for symlinked setup.cfg, got %q", got)
}
}

func TestGemPURL_SymlinkRejected(t *testing.T) {
dir := t.TempDir()
real := t.TempDir()
writeFile(t, real, "evil.gemspec", "Gem::Specification.new do |s|\n s.name = \"evil\"\nend\n")
if err := os.Symlink(filepath.Join(real, "evil.gemspec"), filepath.Join(dir, "evil.gemspec")); err != nil {
t.Fatal(err)
}

if got := gemPURL(dir); got != "" {
t.Errorf("expected empty string for symlinked gemspec, got %q", got)
}
}

func TestCratePURL_SymlinkRejected(t *testing.T) {
dir := t.TempDir()
real := t.TempDir()
writeFile(t, real, "Cargo.toml", "[package]\nname = \"evil\"\nversion = \"0.1.0\"\n")
if err := os.Symlink(filepath.Join(real, "Cargo.toml"), filepath.Join(dir, "Cargo.toml")); err != nil {
t.Fatal(err)
}

if got := cratePURL(dir); got != "" {
t.Errorf("expected empty string for symlinked Cargo.toml, got %q", got)
}
}

func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
Expand Down
8 changes: 4 additions & 4 deletions report/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,13 @@ func mdPlatforms(w io.Writer, platforms *brief.PlatformInfo) {
}
_, _ = fmt.Fprintln(w)
for _, name := range sortedKeys(platforms.CIMatrixVersions) {
_, _ = fmt.Fprintf(w, "**Platforms:** %s %s (CI matrix)\n", name, strings.Join(platforms.CIMatrixVersions[name], ", "))
_, _ = fmt.Fprintf(w, "**Platforms:** %s %s (CI matrix)\n", name, sanitize(strings.Join(platforms.CIMatrixVersions[name], ", ")))
}
for _, file := range sortedKeys(platforms.RuntimeVersionFiles) {
_, _ = fmt.Fprintf(w, "- %s: %s\n", sanitize(file), sanitize(platforms.RuntimeVersionFiles[file]))
}
if len(platforms.CIMatrixOS) > 0 {
_, _ = fmt.Fprintf(w, "- OS: %s (CI matrix)\n", strings.Join(platforms.CIMatrixOS, ", "))
_, _ = fmt.Fprintf(w, "- OS: %s (CI matrix)\n", sanitize(strings.Join(platforms.CIMatrixOS, ", ")))
}
}

Expand Down Expand Up @@ -268,7 +268,7 @@ func mdResources(w io.Writer, res *brief.ResourceInfo) {

func mdResource(w io.Writer, path string) {
if path != "" {
_, _ = fmt.Fprintf(w, "- %s\n", path)
_, _ = fmt.Fprintf(w, "- %s\n", sanitize(path))
}
}

Expand All @@ -278,7 +278,7 @@ func mdResourceGroup(w io.Writer, label string, group map[string]string) {
}
_, _ = fmt.Fprintf(w, "- %s:\n", label)
for _, k := range sortedKeys(group) {
_, _ = fmt.Fprintf(w, " - %s\n", group[k])
_, _ = fmt.Fprintf(w, " - %s\n", sanitize(group[k]))
}
}

Expand Down
46 changes: 46 additions & 0 deletions report/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,52 @@ func TestMarkdownResources(t *testing.T) {
}
}

func TestMarkdownSanitizesCIMatrix(t *testing.T) {
r := &brief.Report{
Version: "dev",
Path: "/tmp/test",
Platforms: &brief.PlatformInfo{
CIMatrixVersions: map[string][]string{
"node": {"\x1b[31mRED\x1b[0m"},
},
CIMatrixOS: []string{"\x1b]0;PWNED\x07-ubuntu"},
RuntimeVersionFiles: map[string]string{},
},
}

var buf bytes.Buffer
Markdown(&buf, r, false)
out := buf.String()

if strings.Contains(out, "\x1b") {
t.Errorf("output contains ESC byte\ngot:\n%q", out)
}
if strings.Contains(out, "\x07") {
t.Errorf("output contains BEL byte\ngot:\n%q", out)
}
}

func TestMarkdownSanitizesResources(t *testing.T) {
r := &brief.Report{
Version: "dev",
Path: "/tmp/test",
Resources: &brief.ResourceInfo{
Readme: "README\x1b[31mRED\x1b[0m.md",
Community: map[string]string{
"contributing": "CONTRIBUTING\x1b[31m.md",
},
},
}

var buf bytes.Buffer
Markdown(&buf, r, false)
out := buf.String()

if strings.Contains(out, "\x1b") {
t.Errorf("output contains ESC byte\ngot:\n%q", out)
}
}

func TestMarkdownGit(t *testing.T) {
r := &brief.Report{
Version: "dev",
Expand Down
8 changes: 4 additions & 4 deletions report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,13 @@ func printPlatforms(w io.Writer, platforms *brief.PlatformInfo) {
}
_, _ = fmt.Fprintln(w)
for _, name := range sortedKeys(platforms.CIMatrixVersions) {
_, _ = fmt.Fprintf(w, "Platforms: %s %s (CI matrix)\n", name, strings.Join(platforms.CIMatrixVersions[name], ", "))
_, _ = fmt.Fprintf(w, "Platforms: %s %s (CI matrix)\n", name, sanitize(strings.Join(platforms.CIMatrixVersions[name], ", ")))
}
for _, file := range sortedKeys(platforms.RuntimeVersionFiles) {
_, _ = fmt.Fprintf(w, " %s: %s\n", sanitize(file), sanitize(platforms.RuntimeVersionFiles[file]))
}
if len(platforms.CIMatrixOS) > 0 {
_, _ = fmt.Fprintf(w, " OS: %s (CI matrix)\n", strings.Join(platforms.CIMatrixOS, ", "))
_, _ = fmt.Fprintf(w, " OS: %s (CI matrix)\n", sanitize(strings.Join(platforms.CIMatrixOS, ", ")))
}
}

Expand Down Expand Up @@ -302,7 +302,7 @@ func printResources(w io.Writer, res *brief.ResourceInfo) {

func printResourceGroup(w io.Writer, label string, group map[string]string) {
for _, k := range sortedKeys(group) {
_, _ = fmt.Fprintf(w, "%-12s %s\n", label+":", group[k])
_, _ = fmt.Fprintf(w, "%-12s %s\n", label+":", sanitize(group[k]))
}
}

Expand Down Expand Up @@ -467,7 +467,7 @@ func MissingHuman(w io.Writer, r *brief.MissingReport) {

func printResource(w io.Writer, value string) {
if value != "" {
_, _ = fmt.Fprintf(w, "Resources: %s\n", value)
_, _ = fmt.Fprintf(w, "Resources: %s\n", sanitize(value))
}
}

Expand Down
52 changes: 52 additions & 0 deletions report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,58 @@ func TestJoinDirs(t *testing.T) {
}
}

func TestHumanSanitizesCIMatrix(t *testing.T) {
r := &brief.Report{
Version: "dev",
Path: "/tmp/test",
Platforms: &brief.PlatformInfo{
CIMatrixVersions: map[string][]string{
"node": {"\x1b[31mRED\x1b[0m"},
},
CIMatrixOS: []string{"\x1b]0;PWNED\x07-ubuntu"},
RuntimeVersionFiles: map[string]string{},
},
}

var buf bytes.Buffer
Human(&buf, r, false)
out := buf.String()

if strings.Contains(out, "\x1b") {
t.Errorf("output contains ESC byte\ngot:\n%q", out)
}
if strings.Contains(out, "\x07") {
t.Errorf("output contains BEL byte\ngot:\n%q", out)
}
if !strings.Contains(out, "[31mRED[0m") {
t.Errorf("expected sanitized version string\ngot:\n%s", out)
}
}

func TestHumanSanitizesResources(t *testing.T) {
r := &brief.Report{
Version: "dev",
Path: "/tmp/test",
Resources: &brief.ResourceInfo{
Readme: "README\x1b[31mRED\x1b[0m.md",
Community: map[string]string{
"contributing": "CONTRIBUTING\x1b[31m.md",
},
},
}

var buf bytes.Buffer
Human(&buf, r, false)
out := buf.String()

if strings.Contains(out, "\x1b") {
t.Errorf("output contains ESC byte\ngot:\n%q", out)
}
if !strings.Contains(out, "README[31mRED[0m.md") {
t.Errorf("expected sanitized resource path\ngot:\n%s", out)
}
}

func sampleThreatReport() *brief.ThreatReport {
return &brief.ThreatReport{
Ecosystems: []string{"ruby"},
Expand Down