From 78ddb8af400fb1869fd0855c0336d35f6865b0a5 Mon Sep 17 00:00:00 2001 From: egibs <20933572+egibs@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:56:00 -0600 Subject: [PATCH] chore: add more tests, fuzzing, and a separate fuzz Workflow Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> --- .github/workflows/fuzz.yaml | 137 +++++ .github/workflows/go-tests.yaml | 45 +- .github/workflows/release.yaml | 3 + .github/workflows/scorecard.yml | 3 + .github/workflows/third-party.yaml | 3 + .github/workflows/version.yaml | 3 + Makefile | 11 + pkg/action/archive_test.go | 3 + pkg/action/diff.go | 6 - pkg/action/diff_test.go | 250 ++++++++++ pkg/action/oci_test.go | 3 + pkg/action/path.go | 29 +- pkg/action/path_test.go | 373 ++++++++++++++ pkg/action/process.go | 3 + pkg/action/scan.go | 11 +- pkg/action/scan_error.go | 3 + pkg/action/scan_test.go | 127 ----- pkg/action/testdata/scan_archive | 2 +- pkg/action/testdata/scan_rpm | 4 +- pkg/action/testdata/scan_zlib | 6 +- pkg/action/testdata/scan_zstd | 4 +- pkg/archive/archive.go | 19 +- pkg/archive/bz2.go | 3 + pkg/archive/deb.go | 9 +- pkg/archive/fuzz_test.go | 350 +++++++++++++ pkg/archive/gzip.go | 3 + pkg/archive/oci.go | 3 + pkg/archive/rpm.go | 3 + pkg/archive/symlink_test.go | 3 + pkg/archive/tar.go | 13 +- pkg/archive/upx.go | 3 + pkg/archive/zip.go | 11 +- pkg/archive/zlib.go | 3 + pkg/archive/zstd.go | 3 + pkg/compile/compile_test.go | 2 +- pkg/compile/fuzz_test.go | 146 ++++++ pkg/file/file.go | 3 + pkg/file/file_test.go | 258 ++++++++++ pkg/pool/pool.go | 3 + pkg/pool/pool_test.go | 377 ++++++++++++++ pkg/profile/profile.go | 3 + pkg/profile/profile_test.go | 3 + pkg/programkind/fuzz_test.go | 7 +- pkg/refresh/action.go | 3 + pkg/refresh/diff.go | 3 + pkg/refresh/refresh.go | 3 + pkg/refresh/refresh_test.go | 403 +++++++++++++++ pkg/render/fuzz_test.go | 187 +++++++ pkg/render/json.go | 15 +- pkg/render/json_test.go | 292 +++++++++++ pkg/render/render.go | 15 + pkg/render/render_test.go | 156 ++++++ pkg/render/stats.go | 3 + pkg/render/tea.go | 3 + pkg/render/tea_style.go | 3 + .../FuzzRenderDifferential/426b03e48895f12a | 6 + .../FuzzRenderDifferential/5fd9627d4b62b585 | 6 + pkg/render/yaml.go | 14 +- pkg/render/yaml_test.go | 326 ++++++++++++ pkg/report/fuzz_test.go | 84 ++-- pkg/report/load.go | 7 +- pkg/report/load_test.go | 468 ++++++++++++++++++ pkg/report/report.go | 57 ++- pkg/report/report_test.go | 420 ++++++++++++++++ pkg/report/strings.go | 3 + pkg/report/strings_test.go | 3 + pkg/version/version.go | 3 + rules/rules.go | 3 + tests/linux/2021.XMR-Stak/1b1a56.elf.simple | 2 +- tests/linux/2022.bpfdoor/bpfdoor_1.simple | 4 +- tests/linux/2023.Kinsing/install.sh.simple | 2 +- tests/linux/2024.Gelsemium/dbus.simple | 2 +- tests/linux/2024.Gelsemium/kde.simple | 2 +- .../linux/2024.Gelsemium/libselinux.so.simple | 2 +- tests/linux/2024.Gelsemium/udevd.simple | 4 +- tests/linux/2024.Gelsemium/udevd_multi.simple | 4 +- .../2024.PAN-OS.Upstyle/update.py.simple | 2 +- .../update_base64_payload1.py.simple | 2 +- .../update_base64_payload2.py.simple | 4 +- .../uranus-ack-mike-cat.simple | 2 +- tests/linux/2024.chisel/crondx.simple | 2 +- ...0a888bb0c5a09192eae01d595f05bc5.elf.simple | 2 +- ...af5d0e2031551f9f1a70b6db475ba71b2.elf.json | 2 +- .../2024.xzutils/liblzma.so.5.6.1.simple | 5 +- .../liblzma_la-crc64-fast.o.simple | 5 +- .../linux/clean/http-fingerprints.lua.simple | 2 +- .../linux/mimipenguin/bash/mimipenguin.simple | 4 +- .../mimipenguin/python/mimipenguin.simple | 2 +- .../2023.3CX/libffmpeg.change_decrease.mdiff | 10 +- .../2023.3CX/libffmpeg.change_increase.mdiff | 10 +- .../2023.3CX/libffmpeg.dirty.dylib.simple | 10 +- tests/macOS/2023.3CX/libffmpeg.dirty.mdiff | 10 +- tests/macOS/2023.3CX/libffmpeg.increase.mdiff | 10 +- tests/php/2024.S3RV4N7-SHELL/crot.php.simple | 2 +- .../php/2024.alfa/alfa-obfuscated.php.simple | 2 +- tests/php/2024.malcure/simple.php.simple | 3 +- tests/php/2024.sagsooz/2024.php.simple | 2 +- tests/php/2024.sagsooz/bestmini.php.simple | 4 +- .../v8.3.46/__init__.py.simple | 2 +- .../2024.reverse_shells/oreilly2.rb.simple | 2 +- .../2024.GitHub.Clipper/main.exe.simple | 4 +- tests/windows/2024.Sharp/sharpil_RAT.exe.md | 2 +- .../2024.aspdasdksa2/callback.bat.json | 2 +- third_party/third_party.go | 3 + 104 files changed, 4531 insertions(+), 346 deletions(-) create mode 100644 .github/workflows/fuzz.yaml create mode 100644 pkg/action/diff_test.go create mode 100644 pkg/action/path_test.go delete mode 100644 pkg/action/scan_test.go create mode 100644 pkg/compile/fuzz_test.go create mode 100644 pkg/file/file_test.go create mode 100644 pkg/pool/pool_test.go create mode 100644 pkg/refresh/refresh_test.go create mode 100644 pkg/render/fuzz_test.go create mode 100644 pkg/render/json_test.go create mode 100644 pkg/render/render_test.go create mode 100644 pkg/render/testdata/fuzz/FuzzRenderDifferential/426b03e48895f12a create mode 100644 pkg/render/testdata/fuzz/FuzzRenderDifferential/5fd9627d4b62b585 create mode 100644 pkg/render/yaml_test.go create mode 100644 pkg/report/load_test.go diff --git a/.github/workflows/fuzz.yaml b/.github/workflows/fuzz.yaml new file mode 100644 index 000000000..c57f504bd --- /dev/null +++ b/.github/workflows/fuzz.yaml @@ -0,0 +1,137 @@ +# Copyright 2026 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 + +name: Fuzz Tests + +on: + push: + branches: + - "main" + schedule: + # Run weekly on Sunday at midnight UTC + - cron: "0 0 * * 0" + workflow_dispatch: + inputs: + fuzz_target: + description: "Specific fuzzer to run (leave empty for all)" + required: false + default: "" + type: string + fuzz_time: + description: "Fuzz duration per target (e.g., 30s, 1m, 5m)" + required: false + default: "30s" + type: choice + options: + - "10s" + - "30s" + - "1m" + - "5m" + - "10m" + - "30m" + - "60m" + - "180m" + +permissions: {} + +jobs: + discover: + if: ${{ github.repository == 'chainguard-dev/malcontent' }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + targets: ${{ steps.find.outputs.targets }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Discover fuzz targets + id: find + env: + TARGET_FILTER: ${{ inputs.fuzz_target }} + run: | + # Find all Fuzz* functions in test files and build JSON array + # Format: [{"test": "FuzzName", "package": "./pkg/path/"}] + all_targets=$(grep -r "^func Fuzz" --include="*_test.go" -l pkg/ | while read -r file; do + dir="./$(dirname "${file}")/" + grep -o "^func Fuzz[A-Za-z0-9_]*" "${file}" | sed 's/^func //' | while read -r func; do + echo "{\"test\":\"${func}\",\"package\":\"${dir}\"}" + done + done | jq -s -c '.') + + targets="${all_targets}" + + # If a specific target is requested, validate and filter + if [ -n "${TARGET_FILTER}" ]; then + # Validate format: must start with "Fuzz" and contain only alphanumeric/underscore + if ! echo "${TARGET_FILTER}" | grep -qE '^Fuzz[A-Za-z0-9_]+$'; then + echo "::error::Invalid fuzz target format: '${TARGET_FILTER}'. Must match pattern 'Fuzz[A-Za-z0-9_]+'" + exit 1 + fi + + # Filter to the requested target + targets=$(echo "${all_targets}" | jq -c --arg t "${TARGET_FILTER}" '[.[] | select(.test == $t)]') + + # Check if target exists + if [ "${targets}" = "[]" ]; then + echo "::error::Fuzz target '${TARGET_FILTER}' not found." + echo "Available targets:" + echo "${all_targets}" | jq -r '.[].test' | sort | sed 's/^/ - /' + exit 1 + fi + fi + + echo "targets=${targets}" >> "${GITHUB_OUTPUT}" + echo "Discovered targets: ${targets}" + + fuzz: + if: ${{ github.repository == 'chainguard-dev/malcontent' && needs.discover.outputs.targets != '[]' }} + needs: discover + runs-on: ubuntu-latest-16-core + permissions: + contents: read + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.discover.outputs.targets) }} + container: + image: cgr.dev/chainguard/wolfi-base:latest # zizmor: ignore[unpinned-images] + options: >- + --cap-add DAC_OVERRIDE + --cap-add SETGID + --cap-add SETUID + --cap-drop ALL + --cgroupns private + --cpu-shares=16384 + --memory-swappiness=0 + --security-opt no-new-privileges + --ulimit core=0 + --ulimit nofile=65535:65535 + --ulimit nproc=65535:65535 + steps: + - name: Install dependencies + run: | + apk update + apk add curl findutils git go nodejs upx xz yara-x~1.12.0 + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Trust repository + run: git config --global --add safe.directory "${GITHUB_WORKSPACE}" + + - name: Clone malcontent samples (required for compile fuzzers) + if: contains(matrix.target.package, 'compile') + run: | + make samples + + - name: Run fuzzer - ${{ matrix.target.test }} + env: + FUZZ_TIME: ${{ inputs.fuzz_time || '30s' }} + run: | + go test -timeout 0 -fuzz="${{ matrix.target.test }}" -fuzztime="${FUZZ_TIME}" "${{ matrix.target.package }}" diff --git a/.github/workflows/go-tests.yaml b/.github/workflows/go-tests.yaml index 599ad584a..8c471554e 100644 --- a/.github/workflows/go-tests.yaml +++ b/.github/workflows/go-tests.yaml @@ -20,7 +20,7 @@ jobs: permissions: contents: read container: - image: cgr.dev/chainguard/wolfi-base:latest + image: cgr.dev/chainguard/wolfi-base:latest # zizmor: ignore[unpinned-images] options: >- --cap-add DAC_OVERRIDE --cap-add SETGID @@ -57,7 +57,7 @@ jobs: permissions: contents: read container: - image: cgr.dev/chainguard/wolfi-base:latest + image: cgr.dev/chainguard/wolfi-base:latest # zizmor: ignore[unpinned-images] options: >- --cap-add DAC_OVERRIDE --cap-add SETGID @@ -87,44 +87,3 @@ jobs: - name: Integration tests run: | make integration - - fuzz: - if: ${{ github.repository == 'chainguard-dev/malcontent' }} - runs-on: ubuntu-latest-16-core - permissions: - contents: read - container: - image: cgr.dev/chainguard/wolfi-base:latest - options: >- - --cap-add DAC_OVERRIDE - --cap-add SETGID - --cap-add SETUID - --cap-drop ALL - --cgroupns private - --cpu-shares=16384 - --memory-swappiness=0 - --security-opt no-new-privileges - --ulimit core=0 - --ulimit nofile=65535:65535 - --ulimit nproc=65535:65535 - steps: - - name: Install dependencies - run: | - apk update - apk add curl findutils git go nodejs upx xz yara-x~1.12.0 - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Trust repository - run: git config --global --add safe.directory "${GITHUB_WORKSPACE}" - - - name: Clone malcontent samples required for Fuzz tests - run: | - make samples - - - name: Fuzz tests - run: | - make fuzz diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fd9d5c4bb..de5e14b81 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,3 +1,6 @@ +# Copyright 2024 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 + name: Cut Release on: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b7f68e818..b867b9201 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,3 +1,6 @@ +# Copyright 2024 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 + # This workflow uses actions that are not certified by GitHub. They are provided # by a third-party and are governed by separate terms of service, privacy # policy, and support documentation. diff --git a/.github/workflows/third-party.yaml b/.github/workflows/third-party.yaml index 91bb4ab3c..e02effd6a 100644 --- a/.github/workflows/third-party.yaml +++ b/.github/workflows/third-party.yaml @@ -1,3 +1,6 @@ +# Copyright 2024 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 + name: Update third-party rules on: diff --git a/.github/workflows/version.yaml b/.github/workflows/version.yaml index 58eebb796..b48a0f2f4 100644 --- a/.github/workflows/version.yaml +++ b/.github/workflows/version.yaml @@ -1,3 +1,6 @@ +# Copyright 2024 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 + name: Bump Version on: diff --git a/Makefile b/Makefile index f6707d94a..3a7ac18ed 100644 --- a/Makefile +++ b/Makefile @@ -142,14 +142,25 @@ test: fuzz: go test -timeout 0 -fuzz=FuzzContainsUnprintable -fuzztime=10s ./pkg/report/ go test -timeout 0 -fuzz=FuzzExtractArchive -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractBz2 -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractDeb -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractGzip -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractRPM -fuzztime=10s ./pkg/archive/ go test -timeout 0 -fuzz=FuzzExtractTar -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractUPX -fuzztime=10s ./pkg/archive/ go test -timeout 0 -fuzz=FuzzExtractZip -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractZlib -fuzztime=10s ./pkg/archive/ + go test -timeout 0 -fuzz=FuzzExtractZstd -fuzztime=10s ./pkg/archive/ go test -timeout 0 -fuzz=FuzzFile -fuzztime=30s ./pkg/programkind/ go test -timeout 0 -fuzz=FuzzGetExt -fuzztime=10s ./pkg/programkind/ go test -timeout 0 -fuzz=FuzzIsValidPath -fuzztime=10s ./pkg/archive/ go test -timeout 0 -fuzz=FuzzLongestUnique -fuzztime=10s ./pkg/report/ go test -timeout 0 -fuzz=FuzzMatchToString -fuzztime=10s ./pkg/report/ go test -timeout 0 -fuzz=FuzzPath -fuzztime=10s ./pkg/programkind/ + go test -timeout 0 -fuzz=FuzzRecursiveCompile -fuzztime=10s ./pkg/compile/ + go test -timeout 0 -fuzz=FuzzRemoveRules -fuzztime=10s ./pkg/compile/ + go test -timeout 0 -fuzz=FuzzRenderDifferential -fuzztime=10s ./pkg/render/ + go test -timeout 0 -fuzz=FuzzReportLoad -fuzztime=10s ./pkg/report/ go test -timeout 0 -fuzz=FuzzStringPoolAtomic -fuzztime=10s ./pkg/report/ go test -timeout 0 -fuzz=FuzzStringPoolConcurrent -fuzztime=10s ./pkg/report/ go test -timeout 0 -fuzz=FuzzStringPoolIntern -fuzztime=10s ./pkg/report/ diff --git a/pkg/action/archive_test.go b/pkg/action/archive_test.go index e94c96022..03dd7619e 100644 --- a/pkg/action/archive_test.go +++ b/pkg/action/archive_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package action import ( diff --git a/pkg/action/diff.go b/pkg/action/diff.go index 2219b569c..7f4763373 100644 --- a/pkg/action/diff.go +++ b/pkg/action/diff.go @@ -10,7 +10,6 @@ import ( "maps" "os" "path/filepath" - "runtime" "slices" "sort" "strings" @@ -19,7 +18,6 @@ import ( "github.com/chainguard-dev/malcontent/pkg/archive" "github.com/chainguard-dev/malcontent/pkg/file" "github.com/chainguard-dev/malcontent/pkg/malcontent" - "github.com/chainguard-dev/malcontent/pkg/pool" "github.com/chainguard-dev/malcontent/pkg/programkind" "github.com/chainguard-dev/malcontent/pkg/report" "github.com/egibs/reconcile/pkg/files" @@ -222,10 +220,6 @@ func Diff(ctx context.Context, c malcontent.Config, _ *clog.Logger) (*malcontent isReport bool ) - initReadPool.Do(func() { - readPool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) - }) - if c.OCI { srcPath, err = archive.OCI(ctx, srcPath, c.OCIAuth) if err != nil { diff --git a/pkg/action/diff_test.go b/pkg/action/diff_test.go new file mode 100644 index 000000000..44f8dd22c --- /dev/null +++ b/pkg/action/diff_test.go @@ -0,0 +1,250 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package action + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" +) + +// TestRelPath tests the relPath function which computes relative paths for diff operations. +func TestRelPath(t *testing.T) { + tmpDir := t.TempDir() + + // Create test file structure + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + // Create archive directory structure + archiveDir := filepath.Join(tmpDir, "archive") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatalf("failed to create archive dir: %v", err) + } + archiveFile := filepath.Join(archiveDir, "file") + if err := os.WriteFile(archiveFile, []byte("archive content"), 0o644); err != nil { + t.Fatalf("failed to create archive file: %v", err) + } + + tests := []struct { + name string + from string + fr *malcontent.FileReport + isArchive bool + isImage bool + wantErr bool + checkPath bool + }{ + { + name: "safe relative path", + from: tmpDir, + fr: &malcontent.FileReport{ + Path: filepath.Join(tmpDir, "safe.txt"), + FullPath: filepath.Join(tmpDir, "safe.txt"), + }, + isArchive: false, + isImage: false, + wantErr: false, + checkPath: true, + }, + { + name: "path with .. components", + from: tmpDir, + fr: &malcontent.FileReport{ + Path: filepath.Join(tmpDir, "..", "etc", "passwd"), + FullPath: filepath.Join(tmpDir, "..", "etc", "passwd"), + }, + isArchive: false, + isImage: false, + wantErr: false, // relPath computes paths, validation is done by archive.IsValidPath + checkPath: true, + }, + { + name: "absolute path escape", + from: tmpDir, + fr: &malcontent.FileReport{ + Path: "/etc/passwd", + FullPath: "/etc/passwd", + }, + isArchive: false, + isImage: false, + wantErr: false, + checkPath: true, + }, + { + name: "archive path", + from: "", + fr: &malcontent.FileReport{ + Path: "image:tag ∴ /safe/file", + FullPath: archiveFile, + ArchiveRoot: archiveDir, + }, + isArchive: true, + isImage: false, + wantErr: false, + checkPath: false, + }, + { + name: "image path with separator", + from: testFile, + fr: &malcontent.FileReport{ + Path: testFile + " ∴ /bin/app", + FullPath: testFile, + }, + isArchive: false, + isImage: true, + wantErr: false, + checkPath: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base, rel, err := relPath(tt.from, tt.fr, tt.isArchive, tt.isImage) + if (err != nil) != tt.wantErr { + t.Errorf("relPath() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err == nil && tt.checkPath { + t.Logf("base=%q rel=%q", base, rel) + } + }) + } +} + +func TestIsUPXBackup(t *testing.T) { + tests := []struct { + name string + path string + files map[string]*malcontent.FileReport + want bool + }{ + { + name: "upx backup with decompressed file", + path: "/path/to/file.~", + files: map[string]*malcontent.FileReport{"/path/to/file": {}}, + want: true, + }, + { + name: "upx backup without decompressed file", + path: "/path/to/file.~", + files: map[string]*malcontent.FileReport{}, + want: false, + }, + { + name: "normal file", + path: "/path/to/file", + files: map[string]*malcontent.FileReport{}, + want: false, + }, + { + name: "empty path", + path: "", + files: map[string]*malcontent.FileReport{}, + want: false, + }, + { + name: "tilde without dot is not upx backup", + path: "/path/to/file~", + files: map[string]*malcontent.FileReport{"/path/to/file": {}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isUPXBackup(tt.path, tt.files) + if got != tt.want { + t.Errorf("isUPXBackup(%q, ...) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestSelectPrimaryFile(t *testing.T) { + tests := []struct { + name string + files map[string]*malcontent.FileReport + want string // expected path of selected file + }{ + { + name: "empty map", + files: map[string]*malcontent.FileReport{}, + want: "", + }, + { + name: "single file", + files: map[string]*malcontent.FileReport{ + "/file": {Path: "/file"}, + }, + want: "/file", + }, + { + name: "backup and decompressed - prefer decompressed", + files: map[string]*malcontent.FileReport{ + "/file~": {Path: "/file~"}, + "/file": {Path: "/file"}, + }, + want: "/file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := selectPrimaryFile(tt.files) + if got == nil && tt.want == "" { + return // both nil, OK + } + if got == nil || got.Path != tt.want { + gotPath := "" + if got != nil { + gotPath = got.Path + } + t.Errorf("selectPrimaryFile() = %v, want path %v", gotPath, tt.want) + } + }) + } +} + +func TestFormatKey(t *testing.T) { + tests := []struct { + name string + res ScanResult + file string + want string + }{ + { + name: "simple path no context", + res: ScanResult{tmpRoot: "", imageURI: ""}, + file: "/bin/ls", + want: "/bin/ls", + }, + { + name: "with tmpRoot prepends tmpRoot", + res: ScanResult{tmpRoot: "/tmp/extract", imageURI: ""}, + file: "/tmp/extract/bin/ls", + want: "/tmp/extract ∴ /tmp/extract/bin/ls", + }, + { + name: "with image URI prepends imageURI", + res: ScanResult{tmpRoot: "/tmp/extract", imageURI: "registry.io/image:v1"}, + file: "/tmp/extract/app/main", + want: "registry.io/image:v1 ∴ /tmp/extract/app/main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatKey(tt.res, tt.file) + if got != tt.want { + t.Errorf("formatKey() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/action/oci_test.go b/pkg/action/oci_test.go index 114d36266..d85589b36 100644 --- a/pkg/action/oci_test.go +++ b/pkg/action/oci_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package action import ( diff --git a/pkg/action/path.go b/pkg/action/path.go index 585d8e71b..e17ebcff3 100644 --- a/pkg/action/path.go +++ b/pkg/action/path.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package action import ( @@ -59,8 +62,32 @@ func findFilesRecursively(ctx context.Context, rootPath string) ([]string, error } // CleanPath removes the temporary directory prefix from the path. +// It only removes the prefix if it's at a directory boundary to avoid +// partial matches (e.g., "/tmp/extract" should not match "/tmp/extract2/file"). func CleanPath(path string, prefix string) string { - return formatPath(strings.TrimPrefix(path, prefix)) + if prefix == "" { + return formatPath(path) + } + + // Check if path starts with prefix + if !strings.HasPrefix(path, prefix) { + return formatPath(path) + } + + // If path equals prefix exactly, return empty + if len(path) == len(prefix) { + return "" + } + + // Only strip if the next character is a path separator (directory boundary) + remainder := path[len(prefix):] + if remainder[0] == '/' || remainder[0] == '\\' { + return formatPath(remainder) + } + + // Partial match (e.g., prefix="/tmp/extract" but path="/tmp/extract2/file") + // Don't strip anything + return formatPath(path) } // formatPath formats the path for display. diff --git a/pkg/action/path_test.go b/pkg/action/path_test.go new file mode 100644 index 000000000..b019fd81e --- /dev/null +++ b/pkg/action/path_test.go @@ -0,0 +1,373 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package action + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFindFilesRecursively(t *testing.T) { + // Create temporary test directory structure + tmpDir := t.TempDir() + + // Create files and directories + files := []string{ + "file1.txt", + "file2.go", + "subdir/file3.txt", + "subdir/nested/file4.sh", + "another/file5.py", + } + + for _, f := range files { + fullPath := filepath.Join(tmpDir, f) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create directory %s: %v", dir, err) + } + if err := os.WriteFile(fullPath, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create file %s: %v", fullPath, err) + } + } + + // Create a .git directory that should be ignored + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + gitFile := filepath.Join(gitDir, "config") + if err := os.WriteFile(gitFile, []byte("git config"), 0o644); err != nil { + t.Fatalf("failed to create git file: %v", err) + } + + tests := []struct { + name string + rootPath string + wantCount int + wantErr bool + }{ + { + name: "scan all files", + rootPath: tmpDir, + wantCount: len(files), // Should find all files but not .git files + wantErr: false, + }, + { + name: "scan subdirectory", + rootPath: filepath.Join(tmpDir, "subdir"), + wantCount: 2, // file3.txt and nested/file4.sh + wantErr: false, + }, + { + name: "scan single file", + rootPath: filepath.Join(tmpDir, "file1.txt"), + wantCount: 1, + wantErr: false, + }, + { + name: "non-existent path", + rootPath: filepath.Join(tmpDir, "nonexistent"), + wantCount: 0, + wantErr: false, // Should return nil, nil for non-existent symlinks + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + got, err := findFilesRecursively(ctx, tt.rootPath) + + if (err != nil) != tt.wantErr { + t.Errorf("findFilesRecursively() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && len(got) != tt.wantCount { + t.Errorf("findFilesRecursively() found %d files, want %d", len(got), tt.wantCount) + t.Logf("Files found: %v", got) + } + + // Verify no .git files are included + for _, f := range got { + if strings.Contains(f, "/.git/") { + t.Errorf("findFilesRecursively() included .git file: %s", f) + } + } + }) + } +} + +func TestFindFilesRecursivelySymlinks(t *testing.T) { + tmpDir := t.TempDir() + + // Create a file + targetFile := filepath.Join(tmpDir, "target.txt") + if err := os.WriteFile(targetFile, []byte("content"), 0o644); err != nil { + t.Fatalf("failed to create target file: %v", err) + } + + // Create a symlink to the file + symlinkFile := filepath.Join(tmpDir, "link.txt") + if err := os.Symlink(targetFile, symlinkFile); err != nil { + t.Skipf("failed to create symlink (may not be supported): %v", err) + } + + ctx := context.Background() + files, err := findFilesRecursively(ctx, tmpDir) + if err != nil { + t.Fatalf("findFilesRecursively() error = %v", err) + } + + // Should find only the target file, not the symlink (L51-53 in path.go) + if len(files) != 1 { + t.Errorf("Expected 1 file, got %d: %v", len(files), files) + } +} + +func TestFindFilesRecursivelySymlinkRoot(t *testing.T) { + tmpDir := t.TempDir() + + // Create a directory with a file + targetDir := filepath.Join(tmpDir, "target") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("failed to create target directory: %v", err) + } + + targetFile := filepath.Join(targetDir, "file.txt") + if err := os.WriteFile(targetFile, []byte("content"), 0o644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + // Create a symlink to the directory + linkDir := filepath.Join(tmpDir, "link") + if err := os.Symlink(targetDir, linkDir); err != nil { + t.Skipf("failed to create symlink (may not be supported): %v", err) + } + + ctx := context.Background() + files, err := findFilesRecursively(ctx, linkDir) + if err != nil { + t.Fatalf("findFilesRecursively() error = %v", err) + } + + // Should follow the symlink at the root and find the file + if len(files) != 1 { + t.Errorf("Expected 1 file through symlinked root, got %d: %v", len(files), files) + } +} + +func TestFindFilesRecursivelyCanceledContext(t *testing.T) { + tmpDir := t.TempDir() + + // Create a file + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := findFilesRecursively(ctx, tmpDir) + if !errors.Is(err, context.Canceled) { + t.Errorf("findFilesRecursively() with canceled context error = %v, want %v", err, context.Canceled) + } +} + +func TestFindFilesRecursivelyPermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("Skipping permission test when running as root") + } + + tmpDir := t.TempDir() + + // Create a subdirectory + restrictedDir := filepath.Join(tmpDir, "restricted") + if err := os.MkdirAll(restrictedDir, 0o755); err != nil { + t.Fatalf("failed to create restricted directory: %v", err) + } + + // Create a file in the restricted directory + restrictedFile := filepath.Join(restrictedDir, "secret.txt") + if err := os.WriteFile(restrictedFile, []byte("secret"), 0o644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + // Create a normal file + normalFile := filepath.Join(tmpDir, "normal.txt") + if err := os.WriteFile(normalFile, []byte("normal"), 0o644); err != nil { + t.Fatalf("failed to create normal file: %v", err) + } + + // Remove read permissions from restricted directory + if err := os.Chmod(restrictedDir, 0o000); err != nil { + t.Fatalf("failed to chmod directory: %v", err) + } + defer os.Chmod(restrictedDir, 0o755) // Restore permissions for cleanup + + ctx := context.Background() + files, err := findFilesRecursively(ctx, tmpDir) + // Should not return error, just skip restricted directory + if err != nil { + t.Errorf("findFilesRecursively() error = %v, expected to skip permission denied", err) + } + + // Should find the normal file + if len(files) < 1 { + t.Error("findFilesRecursively() should find at least the normal file") + } + + // Should not find the restricted file + for _, f := range files { + if strings.Contains(f, "secret.txt") { + t.Error("findFilesRecursively() should not access permission-denied files") + } + } +} + +func TestFindFilesRecursivelyEmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + + ctx := context.Background() + files, err := findFilesRecursively(ctx, tmpDir) + if err != nil { + t.Fatalf("findFilesRecursively() error = %v", err) + } + + if len(files) != 0 { + t.Errorf("findFilesRecursively() on empty directory found %d files, want 0", len(files)) + } +} + +func TestFindFilesRecursivelyDeepNesting(t *testing.T) { + tmpDir := t.TempDir() + + // Create deeply nested structure + deepPath := tmpDir + for range 50 { + deepPath = filepath.Join(deepPath, "level") + } + + if err := os.MkdirAll(deepPath, 0o755); err != nil { + t.Fatalf("failed to create deep directory: %v", err) + } + + deepFile := filepath.Join(deepPath, "deep.txt") + if err := os.WriteFile(deepFile, []byte("deep"), 0o644); err != nil { + t.Fatalf("failed to create deep file: %v", err) + } + + ctx := context.Background() + files, err := findFilesRecursively(ctx, tmpDir) + if err != nil { + t.Fatalf("findFilesRecursively() error = %v", err) + } + + if len(files) != 1 { + t.Errorf("findFilesRecursively() found %d files in deep structure, want 1", len(files)) + } +} + +func TestCleanPath(t *testing.T) { + tests := []struct { + name string + path string + prefix string + want string + }{ + { + name: "remove prefix", + path: "/tmp/extract/bin/ls", + prefix: "/tmp/extract", + want: "/bin/ls", + }, + { + name: "no prefix match", + path: "/usr/bin/ls", + prefix: "/tmp/extract", + want: "/usr/bin/ls", + }, + { + name: "empty prefix", + path: "/usr/bin/ls", + prefix: "", + want: "/usr/bin/ls", + }, + { + name: "empty path", + path: "", + prefix: "/tmp", + want: "", + }, + { + name: "windows path", + path: "C:\\Users\\test\\file.txt", + prefix: "", + want: "C:/Users/test/file.txt", + }, + { + name: "partial prefix match - no strip", + path: "/tmp/extract2/bin/ls", + prefix: "/tmp/extract", + want: "/tmp/extract2/bin/ls", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CleanPath(tt.path, tt.prefix) + if got != tt.want { + t.Errorf("CleanPath(%q, %q) = %q, want %q", tt.path, tt.prefix, got, tt.want) + } + }) + } +} + +func TestFormatPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "unix path unchanged", + path: "/usr/bin/ls", + want: "/usr/bin/ls", + }, + { + name: "windows path converted", + path: "C:\\Users\\test\\file.txt", + want: "C:/Users/test/file.txt", + }, + { + name: "mixed separators", + path: "/tmp\\test/file\\name.txt", + want: "/tmp/test/file/name.txt", + }, + { + name: "empty path", + path: "", + want: "", + }, + { + name: "only backslashes", + path: "\\\\\\", + want: "///", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatPath(tt.path) + if got != tt.want { + t.Errorf("formatPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} diff --git a/pkg/action/process.go b/pkg/action/process.go index 9522fca64..37f3d1eed 100644 --- a/pkg/action/process.go +++ b/pkg/action/process.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package action import ( diff --git a/pkg/action/scan.go b/pkg/action/scan.go index caea9c937..c9621b8ab 100644 --- a/pkg/action/scan.go +++ b/pkg/action/scan.go @@ -40,12 +40,15 @@ var ( compiledRuleCache atomic.Pointer[yarax.Rules] // compiledRuleCache are a cache of previously compiled rules. compileOnce sync.Once // compileOnce ensures that we compile rules only once even across threads. ErrMatchedCondition = errors.New("matched exit criteria") - initReadPool sync.Once // initReadPool ensures that the bytes read pool is only initialized once. initScannerPool sync.Once // initScannerPool ensures that the scanner pool is only initialized once. readPool *pool.BufferPool scannerPool *pool.ScannerPool ) +func init() { + readPool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) +} + // scanSinglePath YARA scans a single path and converts it to a fileReport. // //nolint:cyclop // ignore complexity of 39 @@ -144,13 +147,9 @@ func scanSinglePath(ctx context.Context, c malcontent.Config, path string, ruleF return fr, nil } - initReadPool.Do(func() { - readPool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) - }) - // create a buffer sized to the minimum of the file's size or the default ReadBuffer // only do so if we actually need to retrieve the file's contents - buf := readPool.Get(min(size, file.ReadBuffer)) //nolint:nilaway // the buffer pool is created above + buf := readPool.Get(min(size, file.ReadBuffer)) //nolint:nilaway // the buffer pool is initialized in init() // Only retrieve the file's contents and calculate its checksum if we need to generate a report fc, err := file.GetContents(f, buf) diff --git a/pkg/action/scan_error.go b/pkg/action/scan_error.go index f2d023ed0..105c0ca44 100644 --- a/pkg/action/scan_error.go +++ b/pkg/action/scan_error.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package action import "fmt" diff --git a/pkg/action/scan_test.go b/pkg/action/scan_test.go deleted file mode 100644 index ed4efa4cc..000000000 --- a/pkg/action/scan_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package action - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCleanPath(t *testing.T) { - tests := []struct { - name string - path string - prefix string - want string - }{ - { - name: "expected behavior", - path: "nested/test.txt", - prefix: "nested", - want: "/test.txt", - }, - { - name: "symlink in path", - path: "symlink/test.txt", - prefix: "nested", - want: "/test.txt", - }, - { - name: "symlink in prefix", - path: "nested/test.txt", - prefix: "symlink", - want: "/test.txt", - }, - { - name: "non-existent path", - path: "does_not_exist/test.txt", - prefix: "temp", - }, - { - name: "path prefix mismatch", - path: "nested/test.txt", - prefix: "", - want: "nested/test.txt", - }, - { - name: "empty paths", - path: "", - prefix: "", - want: "", - }, - { - name: "identical path and prefix", - path: "nested", - prefix: "nested", - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - tempDir, err := os.MkdirTemp("", "TestCleanPath") - if err != nil { - t.Fatalf("failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - nestedDir := filepath.Join(tempDir, "nested") - if err := os.Mkdir(nestedDir, 0o700); err != nil { - t.Fatalf("failed to create nested directory: %v", err) - } - - symlinkPath := filepath.Join(tempDir, "symlink") - if err := os.Symlink(nestedDir, symlinkPath); err != nil { - t.Fatalf("failed to create symlink: %v", err) - } - - filePath := filepath.Join(nestedDir, "test.txt") - f, err := os.Create(filePath) - if err != nil { - t.Fatalf("failed to create file: %v", err) - } - defer f.Close() - - fullPath := filepath.Join(tempDir, tt.path) - fullPrefix := filepath.Join(tempDir, tt.prefix) - - got := CleanPath(fullPath, fullPrefix) - if !strings.HasSuffix(got, tt.want) { - t.Errorf("cleanPath() = %v, want suffix %v", got, tt.want) - } - }) - } -} - -func TestFormatPath(t *testing.T) { - tests := []struct { - name string - path string - want string - }{ - { - name: "single separator", - path: "/apko_0.13.2_linux_arm64/apko", - want: "/apko_0.13.2_linux_arm64/apko", - }, - { - name: "multiple separators", - path: "/usr/share/zoneinfo/zone1970", - want: "/usr/share/zoneinfo/zone1970", - }, - { - name: "multiple windows separators", - path: "\\usr\\share\\zoneinfo\\zone1970", - want: "/usr/share/zoneinfo/zone1970", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := formatPath(tt.path); got != tt.want { - t.Errorf("FormatPath() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/pkg/action/testdata/scan_archive b/pkg/action/testdata/scan_archive index 3aed2fcb4..b8895a604 100644 --- a/pkg/action/testdata/scan_archive +++ b/pkg/action/testdata/scan_archive @@ -2275,7 +2275,7 @@ "RuleName": "unsetenv" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], diff --git a/pkg/action/testdata/scan_rpm b/pkg/action/testdata/scan_rpm index 9b8f619a6..34f651b95 100644 --- a/pkg/action/testdata/scan_rpm +++ b/pkg/action/testdata/scan_rpm @@ -207,7 +207,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], @@ -502,7 +502,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], diff --git a/pkg/action/testdata/scan_zlib b/pkg/action/testdata/scan_zlib index 6dbeb316f..1aa61cd8d 100644 --- a/pkg/action/testdata/scan_zlib +++ b/pkg/action/testdata/scan_zlib @@ -174,7 +174,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], @@ -457,7 +457,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], @@ -642,7 +642,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], diff --git a/pkg/action/testdata/scan_zstd b/pkg/action/testdata/scan_zstd index d47789a6a..f8cc1c607 100644 --- a/pkg/action/testdata/scan_zstd +++ b/pkg/action/testdata/scan_zstd @@ -174,7 +174,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], @@ -457,7 +457,7 @@ "RuleName": "recvmsg" }, { - "Description": " close", + "Description": "close", "MatchStrings": [ "_close" ], diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go index c56a315f2..3996e58f2 100644 --- a/pkg/archive/archive.go +++ b/pkg/archive/archive.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( @@ -19,10 +22,14 @@ import ( "github.com/chainguard-dev/malcontent/pkg/programkind" ) -var ( - archivePool, tarPool, zipPool *pool.BufferPool - initializeOnce sync.Once -) +var archivePool, tarPool, zipPool *pool.BufferPool + +func init() { + // Initialize pools for direct use in one location + archivePool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) + tarPool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) + zipPool = pool.NewBufferPool(runtime.GOMAXPROCS(0) * 2) +} // isValidPath checks if the target file is within the given directory. func IsValidPath(target, dir string) bool { @@ -185,10 +192,6 @@ func ExtractArchiveToTempDir(ctx context.Context, c malcontent.Config, path stri return "", fmt.Errorf("failed to create temp dir: %w", err) } - initializeOnce.Do(func() { - archivePool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) - }) - var extract func(context.Context, string, string) error // Check for zlib-compressed files first and use the zlib-specific function ft, err := programkind.File(ctx, path) diff --git a/pkg/archive/bz2.go b/pkg/archive/bz2.go index e031aa6e4..375392f47 100644 --- a/pkg/archive/bz2.go +++ b/pkg/archive/bz2.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/deb.go b/pkg/archive/deb.go index 2c1b6da5c..3533074a8 100644 --- a/pkg/archive/deb.go +++ b/pkg/archive/deb.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( @@ -8,11 +11,9 @@ import ( "io" "os" "path/filepath" - "runtime" "strings" "github.com/chainguard-dev/clog" - "github.com/chainguard-dev/malcontent/pkg/pool" "github.com/egibs/go-debian/deb" ) @@ -25,10 +26,6 @@ func ExtractDeb(ctx context.Context, d, f string) error { logger := clog.FromContext(ctx).With("dir", d, "file", f) logger.Debug("extracting deb") - initTarPool.Do(func() { - tarPool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) - }) - fd, err := os.Open(f) if err != nil { return fmt.Errorf("failed to open file: %w", err) diff --git a/pkg/archive/fuzz_test.go b/pkg/archive/fuzz_test.go index 97df9d9ba..6cda60335 100644 --- a/pkg/archive/fuzz_test.go +++ b/pkg/archive/fuzz_test.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( @@ -257,3 +260,350 @@ func FuzzIsValidPath(f *testing.F) { } }) } + +// FuzzExtractGzip tests gzip extraction with random inputs. +func FuzzExtractGzip(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/apko.gz", + "../../pkg/action/testdata/joblib_0.9.4.dev0_compressed_cache_size_pickle_py35_np19.gz", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty + f.Add([]byte{0x1f, 0x8b}) // gzip magic only + f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}) // gzip header start + f.Add([]byte("not gzip")) // invalid + f.Add(make([]byte, 1024*1024)) // large zeros (compression bomb test) + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-gz-*.gz") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _ = ExtractGzip(ctx, tmpDir, tmpFile.Name()) + + // Verify no path traversal + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} + +// FuzzExtractBz2 tests bzip2 extraction with random inputs. +func FuzzExtractBz2(f *testing.F) { + f.Add([]byte{}) // empty + f.Add([]byte{0x42, 0x5a}) // bzip2 magic only + f.Add([]byte{0x42, 0x5a, 0x68}) // bzip2 header start + f.Add([]byte("not bzip2")) // invalid + f.Add(make([]byte, 1024*1024)) // large zeros + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-bz2-*.bz2") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _ = ExtractBz2(ctx, tmpDir, tmpFile.Name()) + + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} + +// FuzzExtractZstd tests zstd extraction with random inputs. +func FuzzExtractZstd(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/yara.tar.zst", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty + f.Add([]byte{0x28, 0xb5, 0x2f, 0xfd}) // zstd magic + f.Add([]byte("not zstd")) // invalid + f.Add(make([]byte, 1024*1024)) // large zeros + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-zst-*.zst") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _ = ExtractZstd(ctx, tmpDir, tmpFile.Name()) + + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} + +// FuzzExtractZlib tests zlib extraction with random inputs. +func FuzzExtractZlib(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/yara.tar.zlib", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty + f.Add([]byte{0x78, 0x9c}) // zlib default compression + f.Add([]byte{0x78, 0x01}) // zlib no compression + f.Add([]byte{0x78, 0xda}) // zlib best compression + f.Add([]byte("not zlib")) // invalid + f.Add(make([]byte, 1024*1024)) // large zeros + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-zlib-*.zlib") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _ = ExtractZlib(ctx, tmpDir, tmpFile.Name()) + + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} + +// FuzzExtractRPM tests RPM extraction with random inputs. +func FuzzExtractRPM(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/yara.rpm", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty + f.Add([]byte{0xed, 0xab, 0xee, 0xdb}) // rpm magic + f.Add([]byte("not rpm")) // invalid + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-rpm-*.rpm") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = ExtractRPM(ctx, tmpDir, tmpFile.Name()) + + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} + +// FuzzExtractDeb tests Debian package extraction with random inputs. +func FuzzExtractDeb(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/yara.deb", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty + f.Add([]byte("!\n")) // ar archive magic + f.Add([]byte("not deb")) // invalid + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-deb-*.deb") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = ExtractDeb(ctx, tmpDir, tmpFile.Name()) + + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} + +// FuzzExtractUPX tests UPX decompression with random inputs. +func FuzzExtractUPX(f *testing.F) { + f.Add([]byte{}) // empty + f.Add([]byte("UPX!")) // UPX signature + f.Add([]byte("not upx")) // invalid + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-upx-*") + if err != nil { + t.Skip() + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip() + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip() + } + defer os.RemoveAll(tmpDir) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = ExtractUPX(ctx, tmpDir, tmpFile.Name()) + + filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal: %s outside %s", path, tmpDir) + } + return nil + }) + }) +} diff --git a/pkg/archive/gzip.go b/pkg/archive/gzip.go index daeba09dd..2b46e0f47 100644 --- a/pkg/archive/gzip.go +++ b/pkg/archive/gzip.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/oci.go b/pkg/archive/oci.go index 9c368d266..c08ada7ee 100644 --- a/pkg/archive/oci.go +++ b/pkg/archive/oci.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/rpm.go b/pkg/archive/rpm.go index e18704c22..9e64897d5 100644 --- a/pkg/archive/rpm.go +++ b/pkg/archive/rpm.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/symlink_test.go b/pkg/archive/symlink_test.go index fb1231309..01e894c7f 100644 --- a/pkg/archive/symlink_test.go +++ b/pkg/archive/symlink_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/tar.go b/pkg/archive/tar.go index 9d4647add..ddd7b6267 100644 --- a/pkg/archive/tar.go +++ b/pkg/archive/tar.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( @@ -8,21 +11,16 @@ import ( "io" "os" "path/filepath" - "runtime" "strings" - "sync" "github.com/chainguard-dev/clog" "github.com/chainguard-dev/malcontent/pkg/file" - "github.com/chainguard-dev/malcontent/pkg/pool" "github.com/chainguard-dev/malcontent/pkg/programkind" bzip2 "github.com/cosnicolaou/pbzip2" gzip "github.com/klauspost/pgzip" "github.com/ulikunitz/xz" ) -var initTarPool sync.Once - // extractTar extracts .apk and .tar* archives. // //nolint:cyclop,gocognit // ignore complexity of 42, 99 respectively @@ -34,11 +32,6 @@ func ExtractTar(ctx context.Context, d string, f string) error { logger := clog.FromContext(ctx).With("dir", d, "file", f) logger.Debug("extracting tar") - // Initialize the tar sync pool here since OCI preparation bypasses the main extraction method - initTarPool.Do(func() { - tarPool = pool.NewBufferPool(runtime.GOMAXPROCS(0)) - }) - // Check if the file is valid fi, err := os.Stat(f) if err != nil { diff --git a/pkg/archive/upx.go b/pkg/archive/upx.go index 05987b15b..16ab1fb46 100644 --- a/pkg/archive/upx.go +++ b/pkg/archive/upx.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/zip.go b/pkg/archive/zip.go index 54acc257b..0f809a856 100644 --- a/pkg/archive/zip.go +++ b/pkg/archive/zip.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( @@ -9,19 +12,15 @@ import ( "path/filepath" "runtime" "strings" - "sync" "time" "github.com/chainguard-dev/clog" "github.com/chainguard-dev/malcontent/pkg/file" - "github.com/chainguard-dev/malcontent/pkg/pool" "github.com/chainguard-dev/malcontent/pkg/programkind" zip "github.com/klauspost/compress/zip" "golang.org/x/sync/errgroup" ) -var initZipPool sync.Once - var zipMIME = map[string]struct{}{ "application/jar": {}, "application/java-archive": {}, @@ -40,10 +39,6 @@ func ExtractZip(ctx context.Context, d string, f string) error { logger := clog.FromContext(ctx).With("dir", d, "file", f) logger.Debug("extracting zip") - initZipPool.Do(func() { - zipPool = pool.NewBufferPool(runtime.GOMAXPROCS(0) * 2) - }) - fi, err := os.Stat(f) if err != nil { return fmt.Errorf("failed to stat file %s: %w", f, err) diff --git a/pkg/archive/zlib.go b/pkg/archive/zlib.go index 243c76101..505fba357 100644 --- a/pkg/archive/zlib.go +++ b/pkg/archive/zlib.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/archive/zstd.go b/pkg/archive/zstd.go index 99e08ff8f..993d0d879 100644 --- a/pkg/archive/zstd.go +++ b/pkg/archive/zstd.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package archive import ( diff --git a/pkg/compile/compile_test.go b/pkg/compile/compile_test.go index 3a9e575c8..4d64e472f 100644 --- a/pkg/compile/compile_test.go +++ b/pkg/compile/compile_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Chainguard, Inc. +// Copyright 2025 Chainguard, Inc. // SPDX-License-Identifier: Apache-2.0 package compile diff --git a/pkg/compile/fuzz_test.go b/pkg/compile/fuzz_test.go new file mode 100644 index 000000000..bd8ce361a --- /dev/null +++ b/pkg/compile/fuzz_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package compile + +import ( + "context" + "io/fs" + "regexp" + "strings" + "testing" + "testing/fstest" + "time" +) + +// FuzzRemoveRules tests the removeRules function with random inputs. +func FuzzRemoveRules(f *testing.F) { + for _, root := range getAllRuleFS() { + err := fs.WalkDir(root, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + if !strings.HasSuffix(path, ".yara") && !strings.HasSuffix(path, ".yar") { + return nil + } + + data, err := fs.ReadFile(root, path) + if err != nil { + return err + } + + ruleNamePattern := `rule\s+(\w+)` + re := regexp.MustCompile(ruleNamePattern) + matches := re.FindAllStringSubmatch(string(data), -1) + + if len(matches) > 0 { + ruleName := matches[0][1] + f.Add(data, ruleName) + + if len(matches) > 1 { + var ruleNames []string + for _, m := range matches[:min(5, len(matches))] { + ruleNames = append(ruleNames, m[1]) + } + f.Add(data, strings.Join(ruleNames, ",")) + } + } + + return nil + }) + if err != nil { + f.Logf("failed to walk rules directory: %v", err) + } + } + + // Edge cases + f.Add([]byte(``), "") + f.Add([]byte(`rule empty {}`), "empty") + f.Add([]byte(`not a valid rule`), "anything") + + // Complex rule names with special characters + f.Add([]byte(`rule test_123 { condition: true }`), "test_123") + f.Add([]byte(`rule Test_Rule { condition: true }`), "Test_Rule") + + // Non-UTF8 rule name (should be skipped) + f.Add([]byte(`rule test { condition: true }`), "\xff\xfe") + + // Multiple rules to remove + f.Add([]byte(` +rule remove_me_1 { condition: true } +rule remove_me_2 { condition: false } +rule keep_me { condition: true } +`), "remove_me_1,remove_me_2") + + f.Fuzz(func(t *testing.T, data []byte, rulesToRemove string) { + var rules []string + if rulesToRemove != "" { + rules = strings.Split(rulesToRemove, ",") + } + + result := removeRules(data, rules) + + if len(result) > len(data) { + t.Fatalf("result length %d > input length %d", len(result), len(data)) + } + + if len(rules) == 0 || (len(rules) == 1 && rules[0] == "") { + if string(result) != string(data) { + t.Error("removeRules with empty rule list modified data") + } + } + }) +} + +// FuzzRecursiveCompile tests the Recursive compilation function with real YARA rules. +func FuzzRecursiveCompile(f *testing.F) { + for _, root := range getAllRuleFS() { + err := fs.WalkDir(root, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + if !strings.HasSuffix(path, ".yara") && !strings.HasSuffix(path, ".yar") { + return nil + } + + data, err := fs.ReadFile(root, path) + if err != nil { + return err + } + + f.Add(data) + + return nil + }) + if err != nil { + f.Logf("failed to walk rules directory: %v", err) + } + } + + // Edge cases + f.Add([]byte(``)) + f.Add([]byte(`rule empty {}`)) + f.Add([]byte(`not a valid rule`)) + + // Complex rule names with special characters + f.Add([]byte(`rule test_123 { condition: true }`)) + f.Add([]byte(`rule Test_Rule { condition: true }`)) + + // Non-UTF8 rule name (should be skipped) + f.Add([]byte(`rule \xff\xfe { condition: true }`)) + + f.Fuzz(func(_ *testing.T, data []byte) { + fsys := fstest.MapFS{ + "test.yara": { + Data: data, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, _ = Recursive(ctx, []fs.FS{fsys}) + }) +} diff --git a/pkg/file/file.go b/pkg/file/file.go index 21f6364b4..bc6760556 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package file import ( diff --git a/pkg/file/file_test.go b/pkg/file/file_test.go new file mode 100644 index 000000000..db530dafa --- /dev/null +++ b/pkg/file/file_test.go @@ -0,0 +1,258 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package file + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestGetContents(t *testing.T) { + tests := []struct { + name string + content []byte + bufSize int64 + wantErr bool + wantLen int + wantContent []byte + }{ + { + name: "empty file", + content: []byte{}, + bufSize: DefaultPoolBuffer, + wantErr: false, + wantLen: 0, + wantContent: []byte{}, + }, + { + name: "small file", + content: []byte("hello world"), + bufSize: DefaultPoolBuffer, + wantErr: false, + wantLen: 11, + wantContent: []byte("hello world"), + }, + { + name: "file with buffer size 1KB", + content: make([]byte, 1024), + bufSize: 1024, + wantErr: false, + wantLen: 1024, + wantContent: make([]byte, 1024), + }, + { + name: "file larger than buffer", + content: make([]byte, 8192), + bufSize: DefaultPoolBuffer, + wantErr: false, + wantLen: 8192, + wantContent: make([]byte, 8192), + }, + { + name: "file at ReadBuffer size", + content: make([]byte, ReadBuffer), + bufSize: ReadBuffer, + wantErr: false, + wantLen: int(ReadBuffer), + wantContent: make([]byte, ReadBuffer), + }, + { + name: "file with ExtractBuffer size", + content: make([]byte, ExtractBuffer), + bufSize: ExtractBuffer, + wantErr: false, + wantLen: int(ExtractBuffer), + wantContent: make([]byte, ExtractBuffer), + }, + { + name: "file with MaxPoolBuffer size", + content: make([]byte, MaxPoolBuffer), + bufSize: MaxPoolBuffer, + wantErr: false, + wantLen: int(MaxPoolBuffer), + wantContent: make([]byte, MaxPoolBuffer), + }, + { + name: "file with null bytes", + content: []byte{0, 1, 2, 0, 3, 4, 0}, + bufSize: DefaultPoolBuffer, + wantErr: false, + wantLen: 7, + wantContent: []byte{0, 1, 2, 0, 3, 4, 0}, + }, + { + name: "file with unicode content", + content: []byte("Hello 世界 🌍"), + bufSize: DefaultPoolBuffer, + wantErr: false, + wantLen: 17, + wantContent: []byte("Hello 世界 🌍"), + }, + { + name: "small buffer still works", + content: []byte("test content"), + bufSize: 4, + wantErr: false, + wantLen: 12, + wantContent: []byte("test content"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "testfile") + + if err := os.WriteFile(tmpFile, tt.content, 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + f, err := os.Open(tmpFile) + if err != nil { + t.Fatalf("failed to open test file: %v", err) + } + defer f.Close() + + buf := make([]byte, tt.bufSize) + + got, err := GetContents(f, buf) + if (err != nil) != tt.wantErr { + t.Errorf("GetContents() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(got) != tt.wantLen { + t.Errorf("GetContents() returned %d bytes, want %d", len(got), tt.wantLen) + } + + if !bytes.Equal(got, tt.wantContent) { + t.Errorf("GetContents() content mismatch") + } + }) + } +} + +func TestGetContentsClosedFile(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "testfile") + + if err := os.WriteFile(tmpFile, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + f, err := os.Open(tmpFile) + if err != nil { + t.Fatalf("failed to open test file: %v", err) + } + + f.Close() + + buf := make([]byte, DefaultPoolBuffer) + _, err = GetContents(f, buf) + if err == nil { + t.Error("GetContents() should error on closed file, got nil error") + } +} + +func TestGetContentsMaxBytesLimit(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "largefile") + + f, err := os.Create(tmpFile) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + testPattern := []byte("START") + if _, err := f.Write(testPattern); err != nil { + f.Close() + t.Fatalf("failed to write to test file: %v", err) + } + + chunkSize := 1024 * 1024 + chunk := make([]byte, chunkSize) + for i := range chunk { + chunk[i] = byte(i % 256) + } + + for range 10 { + if _, err := f.Write(chunk); err != nil { + f.Close() + t.Fatalf("failed to write chunk: %v", err) + } + } + + f.Close() + + f, err = os.Open(tmpFile) + if err != nil { + t.Fatalf("failed to open test file: %v", err) + } + defer f.Close() + + buf := make([]byte, ExtractBuffer) + got, err := GetContents(f, buf) + if err != nil { + t.Fatalf("GetContents() error = %v", err) + } + + expectedSize := 5 + (10 * chunkSize) + if len(got) != expectedSize { + t.Errorf("GetContents() read %d bytes, want %d", len(got), expectedSize) + } + + if string(got[:5]) != "START" { + t.Errorf("GetContents() start pattern = %q, want %q", got[:5], "START") + } +} + +func TestGetContentsNilBuffer(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "testfile") + + if err := os.WriteFile(tmpFile, []byte("test content"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + f, err := os.Open(tmpFile) + if err != nil { + t.Fatalf("failed to open test file: %v", err) + } + defer f.Close() + + got, err := GetContents(f, nil) + if err != nil { + t.Fatalf("GetContents() with nil buffer error = %v", err) + } + + want := []byte("test content") + if !bytes.Equal(got, want) { + t.Errorf("GetContents() = %q, want %q", got, want) + } +} + +func TestConstants(t *testing.T) { + tests := []struct { + name string + got int64 + want int64 + }{ + {"DefaultPoolBuffer", DefaultPoolBuffer, 4 * 1024}, + {"ExtractBuffer", ExtractBuffer, 64 * 1024}, + {"MaxPoolBuffer", MaxPoolBuffer, 128 * 1024}, + {"MaxBytes", MaxBytes, 1 << 32}, + {"ReadBuffer", ReadBuffer, 64 * 1024}, + {"ZipBuffer", ZipBuffer, 2 * 1024}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %d, want %d", tt.name, tt.got, tt.want) + } + }) + } +} diff --git a/pkg/pool/pool.go b/pkg/pool/pool.go index 3d704227d..5a2606357 100644 --- a/pkg/pool/pool.go +++ b/pkg/pool/pool.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package pool import ( diff --git a/pkg/pool/pool_test.go b/pkg/pool/pool_test.go new file mode 100644 index 000000000..f0a16c190 --- /dev/null +++ b/pkg/pool/pool_test.go @@ -0,0 +1,377 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pool + +import ( + "math" + "runtime" + "sync" + "testing" + + yarax "github.com/VirusTotal/yara-x/go" + "github.com/chainguard-dev/malcontent/pkg/file" +) + +func TestNewBufferPool(t *testing.T) { + tests := []struct { + name string + count int + }{ + {"zero count", 0}, + {"single buffer", 1}, + {"multiple buffers", 5}, + {"many buffers", 20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bp := NewBufferPool(tt.count) + if bp == nil { + t.Fatal("NewBufferPool returned nil") + } + + // Verify we can get buffers + buf := bp.Get(file.DefaultPoolBuffer) + if buf == nil { + t.Error("Get returned nil buffer") + } + if cap(buf) < int(file.DefaultPoolBuffer) { + t.Errorf("buffer capacity = %d, want >= %d", cap(buf), file.DefaultPoolBuffer) + } + }) + } +} + +func TestBufferPoolGet(t *testing.T) { + bp := NewBufferPool(2) + + tests := []struct { + name string + size int64 + wantSize int64 + }{ + {"negative size", -1, 1}, + {"zero size", 0, 1}, + {"small size", 100, 100}, + {"default size", file.DefaultPoolBuffer, file.DefaultPoolBuffer}, + {"large size", file.MaxPoolBuffer, file.MaxPoolBuffer}, + {"very large size", file.MaxPoolBuffer * 2, file.MaxPoolBuffer * 2}, + {"max int64", math.MaxInt64, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := bp.Get(tt.size) + if buf == nil { + t.Fatal("Get returned nil") + } + + if int64(len(buf)) != tt.wantSize { + t.Errorf("buffer length = %d, want %d", len(buf), tt.wantSize) + } + + if int64(cap(buf)) < tt.wantSize { + t.Errorf("buffer capacity = %d, want >= %d", cap(buf), tt.wantSize) + } + + // Return buffer to pool + bp.Put(buf) + }) + } +} + +func TestBufferPoolGetExceedsCapacity(t *testing.T) { + bp := NewBufferPool(1) + + // Get a small buffer + buf1 := bp.Get(1024) + if len(buf1) != 1024 { + t.Fatalf("first Get returned buffer of length %d, want 1024", len(buf1)) + } + + // Return it + bp.Put(buf1) + + // Request a larger buffer - should get new buffer since capacity is insufficient + buf2 := bp.Get(file.MaxPoolBuffer * 2) + if len(buf2) != int(file.MaxPoolBuffer*2) { + t.Errorf("second Get returned buffer of length %d, want %d", len(buf2), file.MaxPoolBuffer*2) + } +} + +func TestBufferPoolPut(t *testing.T) { + bp := NewBufferPool(2) + + t.Run("put nil buffer", func(_ *testing.T) { + // Should not panic + bp.Put(nil) + }) + + t.Run("put normal buffer", func(t *testing.T) { + buf := bp.Get(file.DefaultPoolBuffer) + // Modify buffer + for i := range buf { + buf[i] = byte(i % 256) + } + + bp.Put(buf) + + // Get buffer again and verify it was cleared + buf2 := bp.Get(file.DefaultPoolBuffer) + for i := range buf2 { + if buf2[i] != 0 { + t.Errorf("buffer not cleared at index %d: got %d, want 0", i, buf2[i]) + break + } + } + }) + + t.Run("put buffer exceeding max pool size", func(t *testing.T) { + // Create a very large buffer + largeBuf := make([]byte, file.MaxPoolBuffer*2) + bp.Put(largeBuf) + + // Get a normal buffer - should not get the large one back + buf := bp.Get(file.DefaultPoolBuffer) + if cap(buf) > int(file.MaxPoolBuffer*2) { + t.Error("got unexpectedly large buffer from pool") + } + }) +} + +func TestBufferPoolConcurrency(_ *testing.T) { + bp := NewBufferPool(5) + var wg sync.WaitGroup + iterations := 100 + + // Run multiple goroutines getting and putting buffers + for range 10 { + wg.Go(func() { + for range iterations { + buf := bp.Get(file.DefaultPoolBuffer) + // Simulate work + for k := range buf { + buf[k] = byte(k % 256) + } + bp.Put(buf) + } + }) + } + + wg.Wait() +} + +func TestNewScannerPool(t *testing.T) { + // Create a minimal YARA rule for testing + compiler, err := yarax.NewCompiler() + if err != nil { + t.Fatalf("failed to create compiler: %v", err) + } + + err = compiler.AddSource(` + rule test_rule { + strings: + $a = "test" + condition: + $a + } + `) + if err != nil { + t.Fatalf("failed to add rule: %v", err) + } + + rules := compiler.Build() + defer rules.Destroy() + + tests := []struct { + name string + count int + }{ + {"single scanner", 1}, + {"multiple scanners", 4}, + {"moderate number of scanners", 16}, + {"large number of scanners", 1024}, + {"unreasonable number of scanners", 65535}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sp := NewScannerPool(rules, tt.count) + if sp == nil { + t.Fatal("NewScannerPool returned nil") + } + defer sp.Close() + + scanner := sp.Get(rules) + if scanner == nil { + t.Error("Get returned nil scanner") + } + sp.Put(scanner) + }) + } +} + +func TestScannerPoolGet(t *testing.T) { + compiler, err := yarax.NewCompiler() + if err != nil { + t.Fatalf("failed to create compiler: %v", err) + } + + err = compiler.AddSource(` + rule test_rule { + strings: + $a = "test" + condition: + $a + } + `) + if err != nil { + t.Fatalf("failed to add rule: %v", err) + } + + rules := compiler.Build() + defer rules.Destroy() + + t.Run("get from pool", func(t *testing.T) { + sp := NewScannerPool(rules, 2) + defer sp.Close() + + scanner := sp.Get(rules) + if scanner == nil { + t.Fatal("Get returned nil") + } + + sp.Put(scanner) + }) + + t.Run("get from nil pool", func(t *testing.T) { + var sp *ScannerPool + scanner := sp.Get(rules) + if scanner == nil { + t.Error("Get on nil pool should return new scanner, got nil") + } else { + scanner.Destroy() + } + }) +} + +func TestScannerPoolPut(t *testing.T) { + compiler, err := yarax.NewCompiler() + if err != nil { + t.Fatalf("failed to create compiler: %v", err) + } + + err = compiler.AddSource(` + rule test_rule { + strings: + $a = "test" + condition: + $a + } + `) + if err != nil { + t.Fatalf("failed to add rule: %v", err) + } + + rules := compiler.Build() + defer rules.Destroy() + + sp := NewScannerPool(rules, 2) + defer sp.Close() + + t.Run("put nil scanner", func(_ *testing.T) { + sp.Put(nil) + }) + + t.Run("put valid scanner", func(t *testing.T) { + s1 := sp.Get(rules) + sp.Put(s1) + + s2 := sp.Get(rules) + if s2 == nil { + t.Error("failed to get scanner back from pool") + } + sp.Put(s2) + }) + + t.Run("fill pool", func(_ *testing.T) { + s1 := sp.Get(rules) + s2 := sp.Get(rules) + + sp.Put(s1) + sp.Put(s2) + + // add a third scanner to the already full pool (should be a NOP) + s3 := yarax.NewScanner(rules) + sp.Put(s3) + s3.Destroy() + }) +} + +func TestScannerPoolClose(t *testing.T) { + compiler, err := yarax.NewCompiler() + if err != nil { + t.Fatalf("failed to create compiler: %v", err) + } + + err = compiler.AddSource(` + rule test_rule { + strings: + $a = "test" + condition: + $a + } + `) + if err != nil { + t.Fatalf("failed to add rule: %v", err) + } + + rules := compiler.Build() + defer rules.Destroy() + + sp := NewScannerPool(rules, 2) + + sp.Close() + sp.Close() // Should not panic +} + +func TestScannerPoolConcurrency(t *testing.T) { + compiler, err := yarax.NewCompiler() + if err != nil { + t.Fatalf("failed to create compiler: %v", err) + } + + err = compiler.AddSource(` + rule test_rule { + strings: + $a = "test" + condition: + $a + } + `) + if err != nil { + t.Fatalf("failed to add rule: %v", err) + } + + rules := compiler.Build() + defer rules.Destroy() + + sp := NewScannerPool(rules, 3) + defer sp.Close() + + var wg sync.WaitGroup + iterations := 50 + + for range runtime.NumCPU() { + wg.Go(func() { + for range iterations { + scanner := sp.Get(rules) + _, _ = scanner.Scan([]byte("test data")) + sp.Put(scanner) + } + }) + } + + wg.Wait() +} diff --git a/pkg/profile/profile.go b/pkg/profile/profile.go index 43caa0210..f8bf0ba01 100644 --- a/pkg/profile/profile.go +++ b/pkg/profile/profile.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package profile import ( diff --git a/pkg/profile/profile_test.go b/pkg/profile/profile_test.go index 7213ddfa6..5492acd14 100644 --- a/pkg/profile/profile_test.go +++ b/pkg/profile/profile_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package profile import ( diff --git a/pkg/programkind/fuzz_test.go b/pkg/programkind/fuzz_test.go index cba8e812c..23e531b0d 100644 --- a/pkg/programkind/fuzz_test.go +++ b/pkg/programkind/fuzz_test.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package programkind import ( @@ -20,9 +23,7 @@ func FuzzFile(f *testing.F) { } if data, readErr := os.ReadFile(path); readErr == nil { - if len(data) <= 10*1024*1024 { // 10MB max - f.Add(data, filepath.Base(path)) - } + f.Add(data, filepath.Base(path)) } return nil }) diff --git a/pkg/refresh/action.go b/pkg/refresh/action.go index bd90b7eb1..8feec87a1 100644 --- a/pkg/refresh/action.go +++ b/pkg/refresh/action.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package refresh import ( diff --git a/pkg/refresh/diff.go b/pkg/refresh/diff.go index 8d4b9b5c2..ef58dfa99 100644 --- a/pkg/refresh/diff.go +++ b/pkg/refresh/diff.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package refresh import ( diff --git a/pkg/refresh/refresh.go b/pkg/refresh/refresh.go index 351845aae..b4f123604 100644 --- a/pkg/refresh/refresh.go +++ b/pkg/refresh/refresh.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package refresh import ( diff --git a/pkg/refresh/refresh_test.go b/pkg/refresh/refresh_test.go new file mode 100644 index 000000000..d16abb778 --- /dev/null +++ b/pkg/refresh/refresh_test.go @@ -0,0 +1,403 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package refresh + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chainguard-dev/clog" +) + +func TestDiscoverTestData(t *testing.T) { + // Create temporary directories + samplesDir := t.TempDir() + testDataDir := t.TempDir() + + // Create sample files + sampleFiles := []string{ + "sample1.txt", + "subdir/sample2.sh", + "sample3.py", + } + + for _, sf := range sampleFiles { + fullPath := filepath.Join(samplesDir, sf) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create sample directory: %v", err) + } + if err := os.WriteFile(fullPath, []byte("sample content"), 0o644); err != nil { + t.Fatalf("failed to create sample file: %v", err) + } + } + + // Create corresponding test data files + testDataFiles := []string{ + "sample1.txt.simple", + "subdir/sample2.sh.json", + "sample3.py.md", + "orphan.simple", // No corresponding sample + } + + for _, tdf := range testDataFiles { + fullPath := filepath.Join(testDataDir, tdf) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create test data directory: %v", err) + } + if err := os.WriteFile(fullPath, []byte("test data"), 0o644); err != nil { + t.Fatalf("failed to create test data file: %v", err) + } + } + + // Create a file that should be skipped (pkg/action/testdata) + skipDir := filepath.Join(testDataDir, "pkg/action/testdata") + if err := os.MkdirAll(skipDir, 0o755); err != nil { + t.Fatalf("failed to create skip directory: %v", err) + } + skipFile := filepath.Join(skipDir, "skip.simple") + if err := os.WriteFile(skipFile, []byte("skip"), 0o644); err != nil { + t.Fatalf("failed to create skip file: %v", err) + } + + rc := Config{ + SamplesPath: samplesDir, + TestDataPath: testDataDir, + } + + result, err := discoverTestData(rc) + if err != nil { + t.Fatalf("discoverTestData() error = %v", err) + } + + // Should find 3 test data files with corresponding samples (not the orphan) + expectedCount := 3 + if len(result) != expectedCount { + t.Errorf("discoverTestData() found %d files, want %d", len(result), expectedCount) + t.Logf("Found files: %v", result) + } + + // Verify each test data file maps to correct sample + for testData, sample := range result { + if !fileExists(sample) { + t.Errorf("Sample file %q referenced by %q does not exist", sample, testData) + } + } + + // Verify orphan file is not included + for testData := range result { + if filepath.Base(testData) == "orphan.simple" { + t.Error("discoverTestData() should not include orphan files without corresponding samples") + } + } + + // Verify pkg/action/testdata is skipped + for testData := range result { + if strings.Contains(testData, "pkg/action/testdata") { + t.Error("discoverTestData() should skip pkg/action/testdata directory") + } + } +} + +func TestDiscoverTestDataEmptyDirectory(t *testing.T) { + samplesDir := t.TempDir() + testDataDir := t.TempDir() + + rc := Config{ + SamplesPath: samplesDir, + TestDataPath: testDataDir, + } + + result, err := discoverTestData(rc) + if err != nil { + t.Fatalf("discoverTestData() error = %v", err) + } + + if len(result) != 0 { + t.Errorf("discoverTestData() on empty directory found %d files, want 0", len(result)) + } +} + +func TestDiscoverTestDataNonExistentPath(t *testing.T) { + samplesDir := t.TempDir() + nonExistentPath := filepath.Join(t.TempDir(), "nonexistent") + + rc := Config{ + SamplesPath: samplesDir, + TestDataPath: nonExistentPath, + } + + _, err := discoverTestData(rc) + if err == nil { + t.Error("discoverTestData() with non-existent path should return error") + } +} + +func TestNewConfig(t *testing.T) { + samplesDir := t.TempDir() + + rc := Config{ + SamplesPath: samplesDir, + TestDataPath: t.TempDir(), + Concurrency: 4, + } + + cfg := newConfig(rc) + + if cfg == nil { + t.Fatal("newConfig() returned nil") + } + + if cfg.MinFileRisk != 1 { + t.Errorf("newConfig() MinFileRisk = %d, want 1", cfg.MinFileRisk) + } + + if cfg.MinRisk != 1 { + t.Errorf("newConfig() MinRisk = %d, want 1", cfg.MinRisk) + } + + if !cfg.QuantityIncreasesRisk { + t.Error("newConfig() QuantityIncreasesRisk = false, want true") + } + + if len(cfg.RuleFS) < 1 { + t.Error("newConfig() should include at least one rule filesystem") + } + + if len(cfg.TrimPrefixes) != 1 || cfg.TrimPrefixes[0] != samplesDir { + t.Errorf("newConfig() TrimPrefixes = %v, want [%s]", cfg.TrimPrefixes, samplesDir) + } + + if len(cfg.IgnoreTags) == 0 { + t.Error("newConfig() should set IgnoreTags") + } +} + +func TestRefreshValidationErrors(t *testing.T) { + ctx := context.Background() + logger := clog.FromContext(ctx) + + tests := []struct { + name string + config Config + setup func() Config + }{ + { + name: "empty samples path", + setup: func() Config { + return Config{ + TestDataPath: t.TempDir(), + Concurrency: 1, + } + }, + }, + { + name: "empty test data path", + setup: func() Config { + return Config{ + SamplesPath: t.TempDir(), + Concurrency: 1, + } + }, + }, + { + name: "non-existent samples directory", + setup: func() Config { + return Config{ + SamplesPath: filepath.Join(t.TempDir(), "nonexistent"), + TestDataPath: t.TempDir(), + Concurrency: 1, + } + }, + }, + { + name: "samples path is a file not directory", + setup: func() Config { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + return Config{ + SamplesPath: filePath, + TestDataPath: t.TempDir(), + Concurrency: 1, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.setup() + err := Refresh(ctx, cfg, logger) + if err == nil { + t.Error("Refresh() should return error for invalid config") + } + }) + } +} + +func TestRefreshCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + samplesDir := t.TempDir() + testDataDir := t.TempDir() + logger := clog.FromContext(ctx) + + cfg := Config{ + SamplesPath: samplesDir, + TestDataPath: testDataDir, + Concurrency: 1, + } + + err := Refresh(ctx, cfg, logger) + if !errors.Is(err, context.Canceled) { + t.Errorf("Refresh() with canceled context error = %v, want %v", err, context.Canceled) + } +} + +func TestPrepareRefreshCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + samplesDir := t.TempDir() + testDataDir := t.TempDir() + + cfg := Config{ + SamplesPath: samplesDir, + TestDataPath: testDataDir, + Concurrency: 1, + } + + _, err := prepareRefresh(ctx, cfg) + if !errors.Is(err, context.Canceled) { + t.Errorf("prepareRefresh() with canceled context error = %v, want %v", err, context.Canceled) + } +} + +func TestExecuteRefreshCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + cfg := Config{ + SamplesPath: t.TempDir(), + TestDataPath: t.TempDir(), + Concurrency: 1, + } + + logger := clog.FromContext(ctx) + + err := executeRefresh(ctx, cfg, []TestData{}, logger) + if !errors.Is(err, context.Canceled) { + t.Errorf("executeRefresh() with canceled context error = %v, want %v", err, context.Canceled) + } +} + +func TestExecuteRefreshEmptyTestData(t *testing.T) { + ctx := context.Background() + + cfg := Config{ + SamplesPath: t.TempDir(), + TestDataPath: t.TempDir(), + Concurrency: 1, + } + + logger := clog.FromContext(ctx) + + err := executeRefresh(ctx, cfg, []TestData{}, logger) + if err != nil { + t.Errorf("executeRefresh() with empty test data error = %v", err) + } +} + +func TestConfigConcurrencyDefault(t *testing.T) { + ctx := context.Background() + samplesDir := t.TempDir() + testDataDir := t.TempDir() + + // Create minimal valid setup + if err := os.MkdirAll(samplesDir, 0o755); err != nil { + t.Fatalf("failed to create samples dir: %v", err) + } + + logger := clog.FromContext(ctx) + + cfg := Config{ + SamplesPath: samplesDir, + TestDataPath: testDataDir, + Concurrency: 0, // Should default to 1 + } + + // This will fail due to UPX requirement, but we can verify concurrency is set + err := Refresh(ctx, cfg, logger) + + // We expect an error (likely UPX not installed or no test data) + // but just verify the function handles concurrency=0 + if err == nil { + // Unexpected success, but that's ok for this test + t.Log("Refresh succeeded (unexpected but acceptable)") + } +} + +// Helper functions + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func TestDiscoverTestDataVariousExtensions(t *testing.T) { + samplesDir := t.TempDir() + testDataDir := t.TempDir() + + // Create sample + sample := filepath.Join(samplesDir, "test.bin") + if err := os.WriteFile(sample, []byte("sample"), 0o644); err != nil { + t.Fatalf("failed to create sample: %v", err) + } + + // Create test data files with different extensions + extensions := []string{".simple", ".md", ".json"} + for _, ext := range extensions { + testFile := filepath.Join(testDataDir, "test.bin"+ext) + if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + } + + // Create a file with unsupported extension (should be ignored) + unsupported := filepath.Join(testDataDir, "test.bin.txt") + if err := os.WriteFile(unsupported, []byte("unsupported"), 0o644); err != nil { + t.Fatalf("failed to create unsupported file: %v", err) + } + + rc := Config{ + SamplesPath: samplesDir, + TestDataPath: testDataDir, + } + + result, err := discoverTestData(rc) + if err != nil { + t.Fatalf("discoverTestData() error = %v", err) + } + + // Should find 3 files (.simple, .md, .json) but not .txt + if len(result) != 3 { + t.Errorf("discoverTestData() found %d files, want 3 (.simple, .md, .json)", len(result)) + } + + // Verify .txt file is not included + for testData := range result { + if filepath.Ext(testData) == ".txt" { + t.Error("discoverTestData() should not include .txt files") + } + } +} diff --git a/pkg/render/fuzz_test.go b/pkg/render/fuzz_test.go new file mode 100644 index 000000000..1de09ae23 --- /dev/null +++ b/pkg/render/fuzz_test.go @@ -0,0 +1,187 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package render + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" + orderedmap "github.com/wk8/go-ordered-map/v2" + "gopkg.in/yaml.v3" +) + +// FuzzRenderDifferential ensures JSON and YAML renderers produce semantically equivalent output. +func FuzzRenderDifferential(f *testing.F) { + f.Add(int8(0), "/bin/ls", "test_behavior", "description", false) + f.Add(int8(1), "/usr/bin/curl", "net/http", "HTTP client", false) + f.Add(int8(2), "/tmp/test", "file/write", "Writes files", false) + f.Add(int8(3), "/opt/app", "exec/shell", "Executes commands", false) + f.Add(int8(4), "/sbin/daemon", "proc/fork", "Forks processes", false) + f.Add(int8(2), "", "", "", false) // Empty strings + f.Add(int8(1), "/path/with spaces", "behavior", "desc", false) + f.Add(int8(3), "/path/with/unicode/世界", "test", "测试", false) + f.Add(int8(2), "/path/with\"quotes'", "behave", "desc", false) + f.Add(int8(1), "/path/with\nnewline", "test", "multiline\ndesc", false) + f.Add(int8(0), "/very/long/"+strings.Repeat("path/", 50), "behavior", "description", false) + f.Add(int8(4), "/bin/app", "critical", "Very dangerous", true) // With diff + + f.Fuzz(func(t *testing.T, riskLevel int8, filePath, behaviorName, behaviorDesc string, hasDiff bool) { + if filePath == "" { + return + } + + risk := max(int(riskLevel)%5, 0) + + report := &malcontent.Report{ + Files: sync.Map{}, + } + + fileReport := &malcontent.FileReport{ + Path: filePath, + RiskScore: risk, + RiskLevel: riskLevelString(risk), + } + + if behaviorName != "" { + fileReport.Behaviors = []*malcontent.Behavior{ + { + ID: behaviorName, + Description: behaviorDesc, + RiskScore: risk, + }, + } + } + + report.Files.Store(filePath, fileReport) + + if hasDiff { + report.Diff = &malcontent.DiffReport{ + Added: orderedmap.New[string, *malcontent.FileReport](), + Removed: orderedmap.New[string, *malcontent.FileReport](), + Modified: orderedmap.New[string, *malcontent.FileReport](), + } + } + + ctx := context.Background() + cfg := &malcontent.Config{Stats: !hasDiff} // Stats only when no diff + + var jsonBuf bytes.Buffer + jsonRenderer := NewJSON(&jsonBuf) + if err := jsonRenderer.Full(ctx, cfg, report); err != nil { + return + } + + var yamlBuf bytes.Buffer + yamlRenderer := NewYAML(&yamlBuf) + if err := yamlRenderer.Full(ctx, cfg, report); err != nil { + t.Fatalf("YAML rendering failed but JSON succeeded: %v", err) + } + + var fromJSON, fromYAML Report + + if err := json.Unmarshal(jsonBuf.Bytes(), &fromJSON); err != nil { + t.Fatalf("JSON unmarshal failed: %v\nJSON: %s", err, jsonBuf.String()) + } + + if err := yaml.Unmarshal(yamlBuf.Bytes(), &fromYAML); err != nil { + t.Fatalf("YAML unmarshal failed: %v\nYAML: %s", err, yamlBuf.String()) + } + + if len(fromJSON.Files) != len(fromYAML.Files) { + t.Errorf("File count mismatch: JSON=%d YAML=%d", len(fromJSON.Files), len(fromYAML.Files)) + } + + for key, jsonFR := range fromJSON.Files { + yamlFR, ok := fromYAML.Files[key] + if !ok { + t.Errorf("File %q present in JSON but missing in YAML", key) + continue + } + + if jsonFR.Path != yamlFR.Path { + t.Errorf("Path mismatch for %q: JSON=%q YAML=%q", key, jsonFR.Path, yamlFR.Path) + } + + if jsonFR.RiskScore != yamlFR.RiskScore { + t.Errorf("RiskScore mismatch for %q: JSON=%d YAML=%d", key, jsonFR.RiskScore, yamlFR.RiskScore) + } + + if jsonFR.RiskLevel != yamlFR.RiskLevel { + t.Errorf("RiskLevel mismatch for %q: JSON=%q YAML=%q", key, jsonFR.RiskLevel, yamlFR.RiskLevel) + } + + if len(jsonFR.Behaviors) != len(yamlFR.Behaviors) { + t.Errorf("Behavior count mismatch for %q: JSON=%d YAML=%d", + key, len(jsonFR.Behaviors), len(yamlFR.Behaviors)) + } + } + + compareDiffReports(t, fromJSON.Diff, fromYAML.Diff) + + if (fromJSON.Stats == nil) != (fromYAML.Stats == nil) { + t.Errorf("Stats presence mismatch: JSON nil=%v, YAML nil=%v", + fromJSON.Stats == nil, fromYAML.Stats == nil) + } + }) +} + +func riskLevelString(risk int) string { + switch risk { + case 0, 1: + return "low" + case 2: + return "medium" + case 3: + return "high" + case 4: + return "critical" + default: + return "unknown" + } +} + +// compareDiffReports compares two diff reports for equality. +func compareDiffReports(t *testing.T, jsonDiff, yamlDiff *malcontent.DiffReport) { + t.Helper() + + if jsonDiff == nil && yamlDiff == nil { + return + } + + if (jsonDiff == nil) != (yamlDiff == nil) { + t.Errorf("Diff presence mismatch: JSON nil=%v, YAML nil=%v", + jsonDiff == nil, yamlDiff == nil) + return + } + + jsonAddedLen := orderedMapLen(jsonDiff.Added) + yamlAddedLen := orderedMapLen(yamlDiff.Added) + jsonRemovedLen := orderedMapLen(jsonDiff.Removed) + yamlRemovedLen := orderedMapLen(yamlDiff.Removed) + jsonModifiedLen := orderedMapLen(jsonDiff.Modified) + yamlModifiedLen := orderedMapLen(yamlDiff.Modified) + + if jsonAddedLen != yamlAddedLen { + t.Errorf("Diff Added count mismatch: JSON=%d YAML=%d", jsonAddedLen, yamlAddedLen) + } + if jsonRemovedLen != yamlRemovedLen { + t.Errorf("Diff Removed count mismatch: JSON=%d YAML=%d", jsonRemovedLen, yamlRemovedLen) + } + if jsonModifiedLen != yamlModifiedLen { + t.Errorf("Diff Modified count mismatch: JSON=%d YAML=%d", jsonModifiedLen, yamlModifiedLen) + } +} + +// orderedMapLen returns the length of an ordered map, or 0 if nil. +func orderedMapLen[K comparable, V any](m *orderedmap.OrderedMap[K, V]) int { + if m == nil { + return 0 + } + return m.Len() +} diff --git a/pkg/render/json.go b/pkg/render/json.go index 307b6bc7d..f5f4bb114 100644 --- a/pkg/render/json.go +++ b/pkg/render/json.go @@ -55,10 +55,21 @@ func (r JSON) Full(ctx context.Context, c *malcontent.Config, rep *malcontent.Re if path, ok := key.(string); ok { if r, ok := value.(*malcontent.FileReport); ok { if r.Skipped == "" { - // Filter out diff-related fields r.ArchiveRoot = "" r.FullPath = "" - jr.Files[path] = r + + cleanPath := sanitizeUTF8(path) + + r.Path = sanitizeUTF8(r.Path) + + for _, b := range r.Behaviors { + if b != nil { + b.ID = sanitizeUTF8(b.ID) + b.Description = sanitizeUTF8(b.Description) + } + } + + jr.Files[cleanPath] = r } } } diff --git a/pkg/render/json_test.go b/pkg/render/json_test.go new file mode 100644 index 000000000..e5f57faf4 --- /dev/null +++ b/pkg/render/json_test.go @@ -0,0 +1,292 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package render + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" + orderedmap "github.com/wk8/go-ordered-map/v2" +) + +func TestJSONRendererEmpty(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + // Verify valid JSON was generated + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid JSON: %v", err) + } +} + +func TestJSONRendererWithFiles(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add a file report + report.Files.Store("/bin/ls", &malcontent.FileReport{ + Path: "/bin/ls", + RiskScore: 1, + RiskLevel: "low", + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + // Parse and verify JSON + var result Report + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid JSON: %v", err) + } + + if len(result.Files) != 1 { + t.Errorf("Expected 1 file, got %d", len(result.Files)) + } + + if fr, ok := result.Files["/bin/ls"]; ok { + if fr.Path != "/bin/ls" { + t.Errorf("File path = %q, want %q", fr.Path, "/bin/ls") + } + if fr.RiskScore != 1 { + t.Errorf("Risk score = %d, want 1", fr.RiskScore) + } + } else { + t.Error("File /bin/ls not found in JSON output") + } +} + +func TestJSONRendererWithSkippedFiles(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add a skipped file (should be filtered out) + report.Files.Store("/bin/skipped", &malcontent.FileReport{ + Path: "/bin/skipped", + Skipped: "reason", + }) + + // Add a normal file + report.Files.Store("/bin/normal", &malcontent.FileReport{ + Path: "/bin/normal", + RiskScore: 2, + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid JSON: %v", err) + } + + // Skipped files should be filtered out + if len(result.Files) != 1 { + t.Errorf("Expected 1 file (skipped should be filtered), got %d", len(result.Files)) + } + + if _, ok := result.Files["/bin/skipped"]; ok { + t.Error("Skipped file should not appear in JSON output") + } +} + +func TestJSONRendererNilReport(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + + err := renderer.Full(ctx, cfg, nil) + if err != nil { + t.Fatalf("Full() with nil report error = %v", err) + } + + // Buffer should be empty for nil report + if buf.Len() != 0 { + t.Errorf("Expected empty output for nil report, got %d bytes", buf.Len()) + } +} + +func TestJSONRendererCanceledContext(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + err := renderer.Full(ctx, cfg, report) + if !errors.Is(err, context.Canceled) { + t.Errorf("Full() with canceled context error = %v, want %v", err, context.Canceled) + } +} + +func TestJSONRendererWithStats(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{Stats: true} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add some files to generate stats + report.Files.Store("/bin/test1", &malcontent.FileReport{ + Path: "/bin/test1", + RiskScore: 2, + }) + + report.Files.Store("/bin/test2", &malcontent.FileReport{ + Path: "/bin/test2", + RiskScore: 3, + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid JSON: %v", err) + } + + // Stats should be present when enabled + if result.Stats == nil { + t.Error("Expected stats in output when Stats=true") + } +} + +func TestJSONRendererWithDiff(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{Stats: true} + diff := &malcontent.DiffReport{ + Added: orderedmap.New[string, *malcontent.FileReport](), + Removed: orderedmap.New[string, *malcontent.FileReport](), + Modified: orderedmap.New[string, *malcontent.FileReport](), + } + diff.Added.Set("/bin/added", &malcontent.FileReport{Path: "/bin/added", RiskScore: 2}) + diff.Removed.Set("/bin/removed", &malcontent.FileReport{Path: "/bin/removed", RiskScore: 1}) + diff.Modified.Set("/bin/modified", &malcontent.FileReport{Path: "/bin/modified", RiskScore: 3}) + report := &malcontent.Report{ + Files: sync.Map{}, + Diff: diff, + } + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid JSON: %v", err) + } + + // Diff should be present + if result.Diff == nil { + t.Error("Expected diff in output") + } + + // Stats should not be present for diff reports + if result.Stats != nil { + t.Error("Stats should not be present in diff reports") + } +} + +func TestJSONRendererScanningNoOp(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + // Scanning should be a no-op for JSON renderer + renderer.Scanning(context.Background(), "/some/path") + + if buf.Len() != 0 { + t.Error("Scanning() should not write anything for JSON renderer") + } +} + +func TestJSONRendererFileNoOp(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + fr := &malcontent.FileReport{Path: "/test"} + err := renderer.File(context.Background(), fr) + if err != nil { + t.Errorf("File() error = %v", err) + } + + if buf.Len() != 0 { + t.Error("File() should not write anything for JSON renderer") + } +} + +func TestJSONRendererSpecialCharacters(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add file with special characters + report.Files.Store("/bin/test\"quote'", &malcontent.FileReport{ + Path: "/bin/test\"quote'", + RiskScore: 1, + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + // Should produce valid JSON despite special characters + var result Report + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Failed to parse JSON with special characters: %v", err) + } +} diff --git a/pkg/render/render.go b/pkg/render/render.go index 933aea990..16ee549f7 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "sort" + "strings" + "unicode/utf8" "github.com/chainguard-dev/malcontent/pkg/malcontent" ) @@ -29,6 +31,19 @@ type Stats struct { TotalRisks int `json:",omitempty" yaml:",omitempty"` } +// sanitizeUTF8 replaces invalid UTF-8 sequences with the Unicode replacement character +// and replaces newlines/carriage returns with spaces to prevent YAML serialization issues. +// This ensures consistent handling across JSON and YAML serialization. +func sanitizeUTF8(s string) string { + if !utf8.ValidString(s) { + s = strings.ToValidUTF8(s, string(utf8.RuneError)) + } + // Replace newlines and carriage returns with spaces to avoid YAML complex key issues + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + return strings.TrimSpace(s) +} + // New returns a new Renderer. func New(kind string, w io.Writer) (malcontent.Renderer, error) { switch kind { diff --git a/pkg/render/render_test.go b/pkg/render/render_test.go new file mode 100644 index 000000000..6e9a5868c --- /dev/null +++ b/pkg/render/render_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package render + +import ( + "bytes" + "strings" + "testing" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + kind string + wantErr bool + wantNil bool + }{ + {"empty string defaults to terminal", "", false, false}, + {"auto defaults to terminal", "auto", false, false}, + {"terminal", "terminal", false, false}, + {"terminal_brief", "terminal_brief", false, false}, + {"markdown", "markdown", false, false}, + {"yaml", "yaml", false, false}, + {"json", "json", false, false}, + {"simple", "simple", false, false}, + {"strings", "strings", false, false}, + {"interactive", "interactive", false, false}, + {"unknown renderer", "unknown", true, true}, + {"invalid renderer", "invalid-type", true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + got, err := New(tt.kind, &buf) + + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantNil && got != nil { + t.Errorf("New() expected nil renderer for invalid type, got %T", got) + } + + if !tt.wantNil && got == nil { + t.Error("New() returned nil renderer for valid type") + } + + // Verify renderer name matches (except for invalid types) + if !tt.wantErr && got != nil { + name := got.Name() + if name == "" { + t.Error("renderer Name() returned empty string") + } + } + }) + } +} + +func TestRiskEmoji(t *testing.T) { + tests := []struct { + name string + score int + want string + }{ + {"score 0 - low", 0, "🔵"}, + {"score 1 - low", 1, "🔵"}, + {"score 2 - medium", 2, "🟡"}, + {"score 3 - high", 3, "🛑"}, + {"score 4 - critical", 4, "😈"}, + {"negative score", -1, "🔵"}, + {"very high score", 10, "🔵"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := riskEmoji(tt.score) + if got != tt.want { + t.Errorf("riskEmoji(%d) = %q, want %q", tt.score, got, tt.want) + } + }) + } +} + +func TestSerializedStatsNilReport(t *testing.T) { + stats := serializedStats(nil, nil) + if stats != nil { + t.Error("serializedStats with nil report should return nil") + } +} + +func TestNewJSON(t *testing.T) { + var buf bytes.Buffer + renderer := NewJSON(&buf) + + if renderer.Name() != "JSON" { + t.Errorf("NewJSON().Name() = %q, want %q", renderer.Name(), "JSON") + } +} + +func TestNewYAML(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + if renderer.Name() != "YAML" { + t.Errorf("NewYAML().Name() = %q, want %q", renderer.Name(), "YAML") + } +} + +func TestNewMarkdown(t *testing.T) { + var buf bytes.Buffer + renderer := NewMarkdown(&buf) + + if renderer.Name() != "Markdown" { + t.Errorf("NewMarkdown().Name() = %q, want %q", renderer.Name(), "Markdown") + } +} + +func TestNewTerminal(t *testing.T) { + var buf bytes.Buffer + renderer := NewTerminal(&buf) + + if renderer.Name() != "Terminal" { + t.Errorf("NewTerminal().Name() = %q, want %q", renderer.Name(), "Terminal") + } +} + +func TestNewTerminalBrief(t *testing.T) { + var buf bytes.Buffer + renderer := NewTerminalBrief(&buf) + + if renderer.Name() != "TerminalBrief" { + t.Errorf("NewTerminalBrief().Name() = %q, want %q", renderer.Name(), "TerminalBrief") + } +} + +func TestNewSimple(t *testing.T) { + var buf bytes.Buffer + renderer := NewSimple(&buf) + + if renderer.Name() != "Simple" { + t.Errorf("NewSimple().Name() = %q, want %q", renderer.Name(), "Simple") + } +} + +func TestNewStringMatches(t *testing.T) { + var buf bytes.Buffer + renderer := NewStringMatches(&buf) + + name := renderer.Name() + if !strings.Contains(name, "String") { + t.Errorf("NewStringMatches().Name() = %q, expected to contain 'String'", name) + } +} diff --git a/pkg/render/stats.go b/pkg/render/stats.go index dd79f49e8..670557041 100644 --- a/pkg/render/stats.go +++ b/pkg/render/stats.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package render import ( diff --git a/pkg/render/tea.go b/pkg/render/tea.go index 94622918a..07bd9be1a 100644 --- a/pkg/render/tea.go +++ b/pkg/render/tea.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package render import ( diff --git a/pkg/render/tea_style.go b/pkg/render/tea_style.go index ef2ad92ee..1814a1388 100644 --- a/pkg/render/tea_style.go +++ b/pkg/render/tea_style.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package render import ( diff --git a/pkg/render/testdata/fuzz/FuzzRenderDifferential/426b03e48895f12a b/pkg/render/testdata/fuzz/FuzzRenderDifferential/426b03e48895f12a new file mode 100644 index 000000000..68f70e1e8 --- /dev/null +++ b/pkg/render/testdata/fuzz/FuzzRenderDifferential/426b03e48895f12a @@ -0,0 +1,6 @@ +go test fuzz v1 +int8(1) +string("\xff") +string("0") +string("0") +bool(false) diff --git a/pkg/render/testdata/fuzz/FuzzRenderDifferential/5fd9627d4b62b585 b/pkg/render/testdata/fuzz/FuzzRenderDifferential/5fd9627d4b62b585 new file mode 100644 index 000000000..e6bba1bc0 --- /dev/null +++ b/pkg/render/testdata/fuzz/FuzzRenderDifferential/5fd9627d4b62b585 @@ -0,0 +1,6 @@ +go test fuzz v1 +int8(13) +string("0") +string("0") +string("\n0") +bool(false) diff --git a/pkg/render/yaml.go b/pkg/render/yaml.go index eb32b3113..2cbd5b8b2 100644 --- a/pkg/render/yaml.go +++ b/pkg/render/yaml.go @@ -57,7 +57,19 @@ func (r YAML) Full(ctx context.Context, c *malcontent.Config, rep *malcontent.Re if r.Skipped == "" { r.ArchiveRoot = "" r.FullPath = "" - yr.Files[path] = r + + cleanPath := sanitizeUTF8(path) + + r.Path = sanitizeUTF8(r.Path) + + for _, b := range r.Behaviors { + if b != nil { + b.ID = sanitizeUTF8(b.ID) + b.Description = sanitizeUTF8(b.Description) + } + } + + yr.Files[cleanPath] = r } } } diff --git a/pkg/render/yaml_test.go b/pkg/render/yaml_test.go new file mode 100644 index 000000000..a6cf3bcd7 --- /dev/null +++ b/pkg/render/yaml_test.go @@ -0,0 +1,326 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package render + +import ( + "bytes" + "context" + "errors" + "sync" + "testing" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" + orderedmap "github.com/wk8/go-ordered-map/v2" + "gopkg.in/yaml.v3" +) + +func TestYAMLRendererEmpty(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + // Verify valid YAML was generated + var result map[string]any + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid YAML: %v", err) + } +} + +func TestYAMLRendererWithFiles(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add a file report + report.Files.Store("/bin/ls", &malcontent.FileReport{ + Path: "/bin/ls", + RiskScore: 1, + RiskLevel: "low", + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + // Parse and verify YAML + var result Report + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid YAML: %v", err) + } + + if len(result.Files) != 1 { + t.Errorf("Expected 1 file, got %d", len(result.Files)) + } + + if fr, ok := result.Files["/bin/ls"]; ok { + if fr.Path != "/bin/ls" { + t.Errorf("File path = %q, want %q", fr.Path, "/bin/ls") + } + if fr.RiskScore != 1 { + t.Errorf("Risk score = %d, want 1", fr.RiskScore) + } + } else { + t.Error("File /bin/ls not found in YAML output") + } +} + +func TestYAMLRendererWithSkippedFiles(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add a skipped file (should be filtered out) + report.Files.Store("/bin/skipped", &malcontent.FileReport{ + Path: "/bin/skipped", + Skipped: "reason", + }) + + // Add a normal file + report.Files.Store("/bin/normal", &malcontent.FileReport{ + Path: "/bin/normal", + RiskScore: 2, + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid YAML: %v", err) + } + + // Skipped files should be filtered out + if len(result.Files) != 1 { + t.Errorf("Expected 1 file (skipped should be filtered), got %d", len(result.Files)) + } + + if _, ok := result.Files["/bin/skipped"]; ok { + t.Error("Skipped file should not appear in YAML output") + } +} + +func TestYAMLRendererNilReport(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + + err := renderer.Full(ctx, cfg, nil) + if err != nil { + t.Fatalf("Full() with nil report error = %v", err) + } + + // Buffer should be empty for nil report + if buf.Len() != 0 { + t.Errorf("Expected empty output for nil report, got %d bytes", buf.Len()) + } +} + +func TestYAMLRendererCanceledContext(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + err := renderer.Full(ctx, cfg, report) + if !errors.Is(err, context.Canceled) { + t.Errorf("Full() with canceled context error = %v, want %v", err, context.Canceled) + } +} + +func TestYAMLRendererWithStats(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{Stats: true} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add some files to generate stats + report.Files.Store("/bin/test1", &malcontent.FileReport{ + Path: "/bin/test1", + RiskScore: 2, + }) + + report.Files.Store("/bin/test2", &malcontent.FileReport{ + Path: "/bin/test2", + RiskScore: 3, + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid YAML: %v", err) + } + + // Stats should be present when enabled + if result.Stats == nil { + t.Error("Expected stats in output when Stats=true") + } +} + +func TestYAMLRendererWithDiff(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{Stats: true} + diff := &malcontent.DiffReport{ + Added: orderedmap.New[string, *malcontent.FileReport](), + Removed: orderedmap.New[string, *malcontent.FileReport](), + Modified: orderedmap.New[string, *malcontent.FileReport](), + } + diff.Added.Set("/bin/added", &malcontent.FileReport{Path: "/bin/added", RiskScore: 2}) + diff.Removed.Set("/bin/removed", &malcontent.FileReport{Path: "/bin/removed", RiskScore: 1}) + diff.Modified.Set("/bin/modified", &malcontent.FileReport{Path: "/bin/modified", RiskScore: 3}) + report := &malcontent.Report{ + Files: sync.Map{}, + Diff: diff, + } + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid YAML: %v", err) + } + + // Diff should be present + if result.Diff == nil { + t.Error("Expected diff in output") + } + + // Stats should not be present for diff reports + if result.Stats != nil { + t.Error("Stats should not be present in diff reports") + } +} + +func TestYAMLRendererScanningNoOp(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + // Scanning should be a no-op for YAML renderer + renderer.Scanning(context.Background(), "/some/path") + + if buf.Len() != 0 { + t.Error("Scanning() should not write anything for YAML renderer") + } +} + +func TestYAMLRendererFileNoOp(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + fr := &malcontent.FileReport{Path: "/test"} + err := renderer.File(context.Background(), fr) + if err != nil { + t.Errorf("File() error = %v", err) + } + + if buf.Len() != 0 { + t.Error("File() should not write anything for YAML renderer") + } +} + +func TestYAMLRendererSpecialCharacters(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add file with special characters + report.Files.Store("/bin/test:colon", &malcontent.FileReport{ + Path: "/bin/test:colon", + RiskScore: 1, + }) + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + // Should produce valid YAML despite special characters + var result Report + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Failed to parse YAML with special characters: %v", err) + } +} + +func TestYAMLRendererMultipleFiles(t *testing.T) { + var buf bytes.Buffer + renderer := NewYAML(&buf) + + ctx := context.Background() + cfg := &malcontent.Config{} + report := &malcontent.Report{ + Files: sync.Map{}, + } + + // Add multiple files + for i := 1; i <= 5; i++ { + path := "/bin/test" + string(rune('0'+i)) + report.Files.Store(path, &malcontent.FileReport{ + Path: path, + RiskScore: i % 5, + }) + } + + err := renderer.Full(ctx, cfg, report) + if err != nil { + t.Fatalf("Full() error = %v", err) + } + + var result Report + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Generated invalid YAML: %v", err) + } + + if len(result.Files) != 5 { + t.Errorf("Expected 5 files, got %d", len(result.Files)) + } +} diff --git a/pkg/report/fuzz_test.go b/pkg/report/fuzz_test.go index d56f32a50..8d84b3e48 100644 --- a/pkg/report/fuzz_test.go +++ b/pkg/report/fuzz_test.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package report import ( @@ -44,16 +47,6 @@ func FuzzLongestUnique(f *testing.F) { strs = strings.Split(input, ",") } - if len(strs) > 1000 { - strs = strs[:1000] - } - - for i, s := range strs { - if len(s) > 10000 { - strs[i] = s[:10000] - } - } - result := longestUnique(strs) for _, s := range result { @@ -125,10 +118,6 @@ func FuzzTrimPrefixes(f *testing.F) { prefixes = strings.Split(prefixesStr, ",") } - if len(prefixes) > 100 { - prefixes = prefixes[:100] - } - result := TrimPrefixes(path, prefixes) if len(result) > len(path) { @@ -146,13 +135,6 @@ func FuzzMatchToString(f *testing.F) { f.Add(strings.Repeat("a", 1000), strings.Repeat("b", 1000)) // long strings f.Fuzz(func(t *testing.T, ruleName, match string) { - if len(ruleName) > 10000 { - ruleName = ruleName[:10000] - } - if len(match) > 10000 { - match = match[:10000] - } - result := matchToString(ruleName, match) if len(result) > len(ruleName)+len(match)+100 { @@ -175,10 +157,6 @@ func FuzzStringPoolIntern(f *testing.F) { f.Add("\x00\x01\x02\x03") f.Fuzz(func(t *testing.T, input string) { - if len(input) > 10000 { - input = input[:10000] - } - pool := NewStringPool() s1 := pool.Intern(input) @@ -211,13 +189,10 @@ func FuzzStringPoolConcurrent(f *testing.F) { } parts := strings.Split(input, ",") - if len(parts) > 100 { - parts = parts[:100] - } var filtered []string for _, p := range parts { - if len(p) <= 1000 && p != "" { + if p != "" { filtered = append(filtered, p) } } @@ -279,10 +254,6 @@ func FuzzContainsUnprintable(f *testing.F) { f.Add([]byte("mixed\x00content")) f.Fuzz(func(t *testing.T, input []byte) { - if len(input) > 10000 { - input = input[:10000] - } - got := containsUnprintable(input) want := false @@ -354,3 +325,50 @@ func FuzzStringPoolAtomic(f *testing.F) { } }) } + +// FuzzReportLoad tests the Load function with random JSON inputs to find crashes, +// DoS via resource exhaustion, and unmarshaling bugs. +func FuzzReportLoad(f *testing.F) { + // Seed with valid JSON reports + f.Add([]byte(`{}`)) + f.Add([]byte(`{"Files":{}}`)) + f.Add([]byte(`{"Files":{"/bin/ls":{"Path":"/bin/ls","RiskScore":1,"RiskLevel":"low"}}}`)) + f.Add([]byte(`{"Files":{"/usr/bin/curl":{"Path":"/usr/bin/curl","RiskScore":2,"RiskLevel":"medium","Behaviors":{"net/http":{"Description":"HTTP"}}}}}`)) + + // Malformed JSON + f.Add([]byte(`{invalid json`)) + f.Add([]byte(`{"Files":`)) + f.Add([]byte(`null`)) + f.Add([]byte(``)) + + // Edge cases + f.Add([]byte(`{"Files":null}`)) + f.Add([]byte(`[]`)) // Wrong type + f.Add([]byte(`"string"`)) // Wrong type + + // Large JSON (potential DoS) + largeReport := []byte(`{"Files":{`) + for i := range 100 { + if i > 0 { + largeReport = append(largeReport, ',') + } + largeReport = append(largeReport, []byte(`"/file`+strings.Repeat("x", 100)+`":{"Path":"/file","RiskScore":1}`)...) + } + largeReport = append(largeReport, '}', '}') + f.Add(largeReport) + + // Deeply nested JSON + f.Add([]byte(`{"Files":{"/a":{"Behaviors":{"b1":{"Description":"d1"},"b2":{"Description":"d2"},"b3":{"Description":"d3"}}}}}`)) + + // Special characters + f.Add([]byte(`{"Files":{"/bin/\u0000":{"Path":"/bin/\u0000"}}}`)) // null byte + f.Add([]byte(`{"Files":{"/bin/\n\r\t":{"Path":"/bin/\n\r\t"}}}`)) // whitespace + f.Add([]byte(`{"Files":{"/bin/\"':{}\":{"Path":"/bin/\"':{}"}}}`)) // quote chars + + // Very long strings + f.Add([]byte(`{"Files":{"/bin/` + strings.Repeat("a", 10000) + `":{"Path":"x"}}}`)) + + f.Fuzz(func(_ *testing.T, data []byte) { + _, _ = Load(data) + }) +} diff --git a/pkg/report/load.go b/pkg/report/load.go index 68e2ffbb4..8fda3983c 100644 --- a/pkg/report/load.go +++ b/pkg/report/load.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package report import ( @@ -72,8 +75,8 @@ func CleanReportPath(path, tmpRoot, imageURI string) string { path = strings.TrimPrefix(path, matches[1]) } - // Ensure path starts with / - if !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, imageURI) { + // Ensure path starts with / (unless it has an imageURI prefix) + if !strings.HasPrefix(path, "/") && (imageURI == "" || !strings.HasPrefix(path, imageURI)) { path = "/" + path } diff --git a/pkg/report/load_test.go b/pkg/report/load_test.go new file mode 100644 index 000000000..2c1ead50f --- /dev/null +++ b/pkg/report/load_test.go @@ -0,0 +1,468 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package report + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + data []byte + wantErr bool + wantNonNilFiles bool // Only check FileReports != nil for these + }{ + { + name: "valid empty report", + data: []byte(`{"Files":{}}`), + wantErr: false, + wantNonNilFiles: true, + }, + { + name: "valid report with files", + data: []byte(`{ + "Files": { + "/bin/ls": { + "Path": "/bin/ls", + "RiskScore": 1, + "RiskLevel": "low" + } + } + }`), + wantErr: false, + wantNonNilFiles: true, + }, + { + name: "invalid json", + data: []byte(`{invalid json`), + wantErr: true, + }, + { + name: "empty data", + data: []byte(``), + wantErr: true, + }, + { + name: "null", + data: []byte(`null`), + wantErr: false, + wantNonNilFiles: false, + }, + { + name: "empty object", + data: []byte(`{}`), + wantErr: false, + wantNonNilFiles: false, + }, + { + name: "report with behaviors", + data: []byte(`{ + "Files": { + "/usr/bin/curl": { + "Path": "/usr/bin/curl", + "RiskScore": 2, + "RiskLevel": "medium", + "Behaviors": [ + { + "ID": "net/http", + "Description": "Makes HTTP requests" + } + ] + } + } + }`), + wantErr: false, + wantNonNilFiles: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Load(tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && tt.wantNonNilFiles && got.FileReports == nil { + t.Error("Load() returned report with nil FileReports map") + } + }) + } +} + +func TestExtractImageURI(t *testing.T) { + tests := []struct { + name string + files map[string]*malcontent.FileReport + want string + }{ + { + name: "empty files", + files: map[string]*malcontent.FileReport{}, + want: "", + }, + { + name: "no image URI", + files: map[string]*malcontent.FileReport{ + "/bin/ls": {Path: "/bin/ls"}, + }, + want: "", + }, + { + name: "with image URI", + files: map[string]*malcontent.FileReport{ + "key": {Path: "cgr.dev/chainguard/nginx:latest ∴ /usr/bin/nginx"}, + }, + want: "cgr.dev/chainguard/nginx:latest", + }, + { + name: "multiple files first with URI", + files: map[string]*malcontent.FileReport{ + "key1": {Path: "ghcr.io/org/image:v1 ∴ /app/main"}, + "key2": {Path: "/bin/sh"}, + }, + want: "ghcr.io/org/image:v1", + }, + { + name: "path with ∴ but starts with slash", + files: map[string]*malcontent.FileReport{ + "key": {Path: "/tmp/extract ∴ /file"}, + }, + want: "", + }, + { + name: "nil file report", + files: map[string]*malcontent.FileReport{ + "key": nil, + }, + want: "", + }, + { + name: "empty path", + files: map[string]*malcontent.FileReport{ + "key": {Path: ""}, + }, + want: "", + }, + { + name: "image URI with spaces", + files: map[string]*malcontent.FileReport{ + "key": {Path: " registry.io/image:tag ∴ /bin/file"}, + }, + want: "registry.io/image:tag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractImageURI(tt.files) + if got != tt.want { + t.Errorf("ExtractImageURI() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractTmpRoot(t *testing.T) { + tests := []struct { + name string + files map[string]*malcontent.FileReport + want string + }{ + { + name: "empty files", + files: map[string]*malcontent.FileReport{}, + want: "", + }, + { + name: "no temp paths", + files: map[string]*malcontent.FileReport{ + "/bin/ls": {Path: "/bin/ls"}, + }, + want: "", + }, + { + name: "with /tmp/ path", + files: map[string]*malcontent.FileReport{ + "key": {Path: "/tmp/abc123/xyz789/T/extract/file"}, + }, + want: "/tmp/abc123/xyz789/T/extract", + }, + { + name: "with /var/folders/ path (macOS)", + files: map[string]*malcontent.FileReport{ + "key": {Path: "/var/folders/ab/cd123456/T/extract123/file.txt"}, + }, + want: "/var/folders/ab/cd123456/T/extract123", + }, + { + name: "with /private/var/folders/ path", + files: map[string]*malcontent.FileReport{ + "key": {Path: "/private/var/folders/xy/z9876543/T/temp_dir/file"}, + }, + want: "/private/var/folders/xy/z9876543/T/temp_dir", + }, + { + name: "with /private/tmp/ path", + files: map[string]*malcontent.FileReport{ + "key": {Path: "/private/tmp/abc/def/T/ghi/file"}, + }, + want: "/private/tmp/abc/def/T/ghi", + }, + { + name: "nil file report", + files: map[string]*malcontent.FileReport{ + "key": nil, + }, + want: "", + }, + { + name: "empty path", + files: map[string]*malcontent.FileReport{ + "key": {Path: ""}, + }, + want: "", + }, + { + name: "multiple files returns one match", + files: map[string]*malcontent.FileReport{ + "key1": {Path: "/tmp/aaa/bbb/T/ccc/file1"}, + "key2": {Path: "/tmp/xxx/yyy/T/zzz/file2"}, + }, + want: "", // map iteration order is non-deterministic, will check separately + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractTmpRoot(tt.files) + + // edge case: multiple files test (map iteration is non-deterministic) + if tt.name == "multiple files returns one match" { + validResults := []string{"/tmp/aaa/bbb/T/ccc", "/tmp/xxx/yyy/T/zzz"} + isValid := slices.Contains(validResults, got) + if !isValid { + t.Errorf("ExtractTmpRoot() = %q, want one of %v", got, validResults) + } + return + } + + if got != tt.want { + t.Errorf("ExtractTmpRoot() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanReportPath(t *testing.T) { + tests := []struct { + name string + path string + tmpRoot string + imageURI string + want string + }{ + { + name: "empty path", + path: "", + tmpRoot: "", + imageURI: "", + want: "", + }, + { + name: "path already has image URI", + path: "cgr.dev/image:tag ∴ /bin/file", + tmpRoot: "/tmp/extract", + imageURI: "cgr.dev/image:tag", + want: "cgr.dev/image:tag ∴ /bin/file", + }, + { + name: "remove tmp root", + path: "/tmp/abc123/xyz/T/extract/bin/ls", + tmpRoot: "/tmp/abc123/xyz/T/extract", + imageURI: "", + want: "/bin/ls", + }, + { + name: "path without tmp root", + path: "/usr/bin/curl", + tmpRoot: "/tmp/extract", + imageURI: "", + want: "/usr/bin/curl", + }, + { + name: "relative path gets leading slash", + path: "bin/file", + tmpRoot: "", + imageURI: "", + want: "/bin/file", + }, + { + name: "path with temp pattern", + path: "/var/folders/ab/cd/T/extract/file", + tmpRoot: "", + imageURI: "", + want: "/file", + }, + { + name: "already clean absolute path", + path: "/bin/sh", + tmpRoot: "", + imageURI: "", + want: "/bin/sh", + }, + { + name: "image URI prefix preserved", + path: "registry.io/image ∴ /app/main", + tmpRoot: "", + imageURI: "registry.io/image", + want: "registry.io/image ∴ /app/main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CleanReportPath(tt.path, tt.tmpRoot, tt.imageURI) + if got != tt.want { + t.Errorf("CleanReportPath() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatReportKey(t *testing.T) { + tests := []struct { + name string + path string + tmpRoot string + imageURI string + want string + }{ + { + name: "empty path", + path: "", + tmpRoot: "", + imageURI: "", + want: "", + }, + { + name: "path with image URI prefix", + path: "cgr.dev/image:tag ∴ /bin/file", + tmpRoot: "", + imageURI: "cgr.dev/image:tag", + want: "cgr.dev/image:tag ∴ /bin/file", + }, + { + name: "format with image URI", + path: "/tmp/extract/bin/ls", + tmpRoot: "/tmp/extract", + imageURI: "registry.io/image:v1", + want: "registry.io/image:v1 ∴ /bin/ls", + }, + { + name: "format without image URI", + path: "/tmp/extract/usr/bin/curl", + tmpRoot: "/tmp/extract", + imageURI: "", + want: "/usr/bin/curl", + }, + { + name: "clean path without tmp root", + path: "/bin/sh", + tmpRoot: "", + imageURI: "", + want: "/bin/sh", + }, + { + name: "relative path gets leading slash", + path: "app/main", + tmpRoot: "", + imageURI: "", + want: "/app/main", + }, + { + name: "path with temp pattern", + path: "/var/folders/ab/cd/T/xyz/file.txt", + tmpRoot: "", + imageURI: "", + want: "/file.txt", + }, + { + name: "path with temp pattern and image URI", + path: "/private/tmp/a/b/T/c/bin/app", + tmpRoot: "", + imageURI: "ghcr.io/org/app:latest", + want: "ghcr.io/org/app:latest ∴ /bin/app", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatReportKey(tt.path, tt.tmpRoot, tt.imageURI) + if got != tt.want { + t.Errorf("FormatReportKey() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoadRoundTrip(t *testing.T) { + original := malcontent.ScanResult{ + FileReports: map[string]*malcontent.FileReport{ + "/bin/ls": { + Path: "/bin/ls", + RiskScore: 1, + RiskLevel: "low", + }, + "/usr/bin/curl": { + Path: "/usr/bin/curl", + RiskScore: 2, + RiskLevel: "medium", + }, + }, + } + + // marshal to JSON + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + // load the JSON data + loaded, err := Load(data) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // verify contents + if len(loaded.FileReports) != len(original.FileReports) { + t.Errorf("loaded %d files, want %d", len(loaded.FileReports), len(original.FileReports)) + } + + for key, origFR := range original.FileReports { + loadedFR, ok := loaded.FileReports[key] + if !ok { + t.Errorf("missing file %q in loaded report", key) + continue + } + + if loadedFR.Path != origFR.Path { + t.Errorf("file %q: path = %q, want %q", key, loadedFR.Path, origFR.Path) + } + + if loadedFR.RiskScore != origFR.RiskScore { + t.Errorf("file %q: risk score = %d, want %d", key, loadedFR.RiskScore, origFR.RiskScore) + } + + if loadedFR.RiskLevel != origFR.RiskLevel { + t.Errorf("file %q: risk level = %q, want %q", key, loadedFR.RiskLevel, origFR.RiskLevel) + } + } +} diff --git a/pkg/report/report.go b/pkg/report/report.go index fabe8fbf1..0f47a5487 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -73,6 +73,7 @@ var yaraForgeJunkWords = map[string]bool{ "malware": true, "offensive": true, "osx": true, + "sig": true, "small": true, "suspicious": true, "tool": true, @@ -110,10 +111,10 @@ func thirdPartyKey(path string, rule string) string { return "" } subDir := path[yaraIndex+5 : strings.IndexByte(path[yaraIndex+5:], '/')+yaraIndex+5] - words := []string{subDir} // ELASTIC_Linux_Trojan_Gafgyt_E4A1982B - words = append(words, strings.Split(strings.ToLower(rule), "_")...) + // Start with words from the rule name, not including subDir yet + words := strings.Split(strings.ToLower(rule), "_") var lastWord string // creating a slice with subDir initially should usually ensure this is at least one, @@ -127,31 +128,46 @@ func thirdPartyKey(path string, rule string) string { } keepWords := make([]string, 0, len(words)) + subDirLower := strings.ToLower(subDir) + for x, w := range words { // ends with a date or empty if (x == len(words)-1 && dateRe.MatchString(w)) || w == "" { continue } - if !yaraForgeJunkWords[w] { + // Filter out junk words and the subdirectory name + if !yaraForgeJunkWords[w] && w != subDirLower { keepWords = append(keepWords, w) } } - if len(keepWords) > 4 { - keepWords = keepWords[0:4] + + // Additionally filter "test" from the beginning if there are other words + if len(keepWords) > 1 && keepWords[0] == "test" { + keepWords = keepWords[1:] } - var src string - // the rule name is equivalent to the words we're keeping minus one to account for the source - ruleName := make([]string, 0, len(keepWords)-1) - if len(keepWords) > 0 { - // Fix name for https://github.com/Neo23x0/signature-base within YARAForge - src = strings.Replace(keepWords[0], "signature", "sig_base", 1) - if len(keepWords) > 1 { - ruleName = keepWords[1:] + // If we filtered everything, keep at least the first word that's not the subdir + if len(keepWords) == 0 { + for x := 0; x < len(words); x++ { + if words[x] != "" && !dateRe.MatchString(words[x]) && words[x] != subDirLower { + keepWords = append(keepWords, words[x]) + break + } } } + // Max 3 words in the rule name (source is separate) + if len(keepWords) > 3 { + keepWords = keepWords[0:3] + } + + // Fix name for https://github.com/Neo23x0/signature-base within YARAForge + src := strings.Replace(subDir, "signature", "sig_base", 1) + + // All keepWords are part of the rule name + ruleName := keepWords + return fmt.Sprintf("3P/%s/%s", src, strings.Join(ruleName, "_")) } @@ -370,11 +386,26 @@ func TrimPrefixes(path string, prefixes []string) string { case "/private": return strings.TrimPrefix(path, prefix) default: + // Strip ./ prefix prefix = strings.TrimPrefix(prefix, "./") + if prefix == "" { + continue + } + + // Try matching as-is first (handles both relative and absolute) if strings.HasPrefix(path, prefix) { trimmed := path[len(prefix):] return strings.TrimPrefix(trimmed, string(filepath.Separator)) } + + // If prefix is relative but path is absolute, try with leading / + if !strings.HasPrefix(prefix, "/") && strings.HasPrefix(path, "/") { + absPrefix := "/" + prefix + if strings.HasPrefix(path, absPrefix) { + trimmed := path[len(absPrefix):] + return strings.TrimPrefix(trimmed, string(filepath.Separator)) + } + } } } return path diff --git a/pkg/report/report_test.go b/pkg/report/report_test.go index 3f1b7c265..f7eb87c55 100644 --- a/pkg/report/report_test.go +++ b/pkg/report/report_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package report import ( @@ -390,3 +393,420 @@ func TestIsMalcontent(t *testing.T) { }) } } + +func TestThirdPartyKey(t *testing.T) { + tests := []struct { + name string + path string + rule string + want string + }{ + { + name: "ELASTIC Linux Trojan", + path: "yara/elastic/linux_trojan_gafgyt.yara", + rule: "ELASTIC_Linux_Trojan_Gafgyt_E4A1982B", + want: "3P/elastic/gafgyt", + }, + { + name: "no yara path", + path: "rules/malware/trojan.yara", + rule: "trojan_test", + want: "", + }, + { + name: "with hex suffix", + path: "yara/signature/test.yara", + rule: "SIG_Test_Rule_ABC123", + want: "3P/sig_base/rule", + }, + { + name: "with date suffix", + path: "yara/malware/test.yara", + rule: "Malware_Test_jan01", + want: "3P/malware/test", + }, + { + name: "many junk words", + path: "yara/forensic/test.yara", + rule: "forensic_generic_malware_trojan_suspicious_test_hunting", + want: "3P/forensic/test", + }, + { + name: "max 4 words", + path: "yara/source/test.yara", + rule: "Test_Word1_Word2_Word3_Word4_Word5_Word6", + want: "3P/source/word1_word2_word3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := thirdPartyKey(tt.path, tt.rule) + if got != tt.want { + t.Errorf("thirdPartyKey() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGenerateKey(t *testing.T) { + tests := []struct { + name string + src string + rule string + want string + }{ + { + name: "third party rule", + src: "yara/elastic/test.yara", + rule: "ELASTIC_Test_Rule", + want: "3P/elastic/rule", + }, + { + name: "simple rule", + src: "malware/trojan.yara", + rule: "trojan_test", + want: "malware/trojan", + }, + { + name: "with dashes", + src: "anti-static/analysis.yara", + rule: "static_analysis", + want: "anti-static/analysis", + }, + { + name: "remove .yara extension", + src: "exec/exec_dylib.yara", + rule: "dylib_test", + want: "exec/dylib", + }, + { + name: "reduce stutter", + src: "credential/credential_access.yara", + rule: "credential_dump", + want: "credential/access", + }, + { + name: "multiple levels", + src: "namespace/resource/technique.yara", + rule: "test", + want: "namespace/resource/technique", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateKey(tt.src, tt.rule) + if got != tt.want { + t.Errorf("generateKey() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGenerateRuleURL(t *testing.T) { + tests := []struct { + name string + src string + rule string + want string + }{ + { + name: "basic rule", + src: "malware/trojan.yara", + rule: "trojan_test", + want: "https://github.com/chainguard-dev/malcontent/blob/main/rules/malware/trojan.yara#trojan_test", + }, + { + name: "nested path", + src: "exec/dylib/loader.yara", + rule: "dylib_inject", + want: "https://github.com/chainguard-dev/malcontent/blob/main/rules/exec/dylib/loader.yara#dylib_inject", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateRuleURL(tt.src, tt.rule) + if got != tt.want { + t.Errorf("generateRuleURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIgnoreMatch(t *testing.T) { + tests := []struct { + name string + tags []string + ignoreTags map[string]bool + want bool + }{ + { + name: "no tags to ignore", + tags: []string{"malware", "trojan"}, + ignoreTags: map[string]bool{}, + want: false, + }, + { + name: "tag should be ignored", + tags: []string{"harmless", "common"}, + ignoreTags: map[string]bool{"harmless": true}, + want: true, + }, + { + name: "multiple tags one ignored", + tags: []string{"suspicious", "harmless"}, + ignoreTags: map[string]bool{"harmless": true, "benign": true}, + want: true, + }, + { + name: "no matching ignore tags", + tags: []string{"malware", "critical"}, + ignoreTags: map[string]bool{"harmless": true, "benign": true}, + want: false, + }, + { + name: "empty tags", + tags: []string{}, + ignoreTags: map[string]bool{"harmless": true}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ignoreMatch(tt.tags, tt.ignoreTags) + if got != tt.want { + t.Errorf("ignoreMatch() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBehaviorRisk(t *testing.T) { + tests := []struct { + name string + ns string + rule string + tags []string + want int + }{ + { + name: "third party default", + ns: "yara/somevendor/test.yara", + rule: "test_rule", + tags: []string{}, + want: HIGH, + }, + { + name: "low risk", + ns: "malware/test.yara", + rule: "test_rule", + tags: []string{"low"}, + want: LOW, + }, + { + name: "medium risk", + ns: "malware/test.yara", + rule: "test_rule", + tags: []string{"medium"}, + want: MEDIUM, + }, + { + name: "high risk", + ns: "malware/test.yara", + rule: "test_rule", + tags: []string{"high"}, + want: HIGH, + }, + { + name: "critical risk", + ns: "malware/test.yara", + rule: "test_rule", + tags: []string{"critical"}, + want: CRITICAL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := behaviorRisk(tt.ns, tt.rule, tt.tags) + if got != tt.want { + t.Errorf("behaviorRisk() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestFixURL(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "url with spaces", + url: "https://example.com/path with spaces", + want: "https://example.com/path%20with%20spaces", + }, + { + name: "url without spaces", + url: "https://example.com/path", + want: "https://example.com/path", + }, + { + name: "empty url", + url: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fixURL(tt.url) + if got != tt.want { + t.Errorf("fixURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMungeDescription(t *testing.T) { + tests := []struct { + name string + desc string + want string + }{ + { + name: "threat hunting keyword", + desc: "Detection patterns for the tool 'Nsight RMM' taken from the ThreatHunting-Keywords github project", + want: "references 'Nsight RMM' tool", + }, + { + name: "another threat hunting pattern", + desc: "Detection patterns for the tool 'AnyDesk' taken from the ThreatHunting-Keywords github project", + want: "references 'AnyDesk' tool", + }, + { + name: "normal description unchanged", + desc: "This is a normal malware description", + want: "This is a normal malware description", + }, + { + name: "empty description", + desc: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mungeDescription(tt.desc) + if got != tt.want { + t.Errorf("mungeDescription() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestThirdParty(t *testing.T) { + tests := []struct { + name string + src string + want bool + }{ + {"third party yara path", "yara/elastic/test.yara", true}, + {"local rule", "malware/trojan.yara", false}, + {"nested yara path", "rules/yara/test.yara", true}, + {"empty path", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := thirdParty(tt.src) + if got != tt.want { + t.Errorf("thirdParty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsValidURL(t *testing.T) { + tests := []struct { + name string + url string + want bool + }{ + {"valid http url", "http://example.com", true}, + {"valid https url", "https://example.com/path", true}, + {"valid relative url", "/path/to/resource", true}, + {"valid file url", "file:///path/to/file", true}, + {"empty string", "", true}, // url.Parse("") doesn't return error + {"invalid url with spaces", "http://example .com", false}, // url.Parse rejects spaces in hostname + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidURL(tt.url) + if got != tt.want { + t.Errorf("isValidURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTrimPrefixes(t *testing.T) { + tests := []struct { + name string + path string + prefixes []string + want string + }{ + { + name: "remove matching prefix", + path: "/tmp/extract/bin/ls", + prefixes: []string{"/tmp/extract"}, + want: "bin/ls", + }, + { + name: "no matching prefix", + path: "/usr/bin/ls", + prefixes: []string{"/tmp"}, + want: "/usr/bin/ls", + }, + { + name: "private prefix", + path: "/private/tmp/file", + prefixes: []string{"/private"}, + want: "/tmp/file", + }, + { + name: "relative prefix", + path: "/samples/test.bin", + prefixes: []string{"./samples"}, + want: "test.bin", + }, + { + name: "empty prefix", + path: "/tmp/file", + prefixes: []string{""}, + want: "/tmp/file", + }, + { + name: "multiple prefixes", + path: "/samples/malware/test.bin", + prefixes: []string{"/tmp", "/samples"}, + want: "malware/test.bin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TrimPrefixes(tt.path, tt.prefixes) + if got != tt.want { + t.Errorf("TrimPrefixes() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/report/strings.go b/pkg/report/strings.go index 7709bc382..0253e4c97 100644 --- a/pkg/report/strings.go +++ b/pkg/report/strings.go @@ -1,3 +1,6 @@ +// Copyright 2025 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package report import ( diff --git a/pkg/report/strings_test.go b/pkg/report/strings_test.go index fd4d55952..dfb69055f 100644 --- a/pkg/report/strings_test.go +++ b/pkg/report/strings_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package report import ( diff --git a/pkg/version/version.go b/pkg/version/version.go index 43cde9797..769e29a3f 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package version import ( diff --git a/rules/rules.go b/rules/rules.go index 64f336028..238280709 100644 --- a/rules/rules.go +++ b/rules/rules.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package rules import "embed" diff --git a/tests/linux/2021.XMR-Stak/1b1a56.elf.simple b/tests/linux/2021.XMR-Stak/1b1a56.elf.simple index d3b44485a..c0d8fc72f 100644 --- a/tests/linux/2021.XMR-Stak/1b1a56.elf.simple +++ b/tests/linux/2021.XMR-Stak/1b1a56.elf.simple @@ -1,7 +1,7 @@ # linux/2021.XMR-Stak/1b1a56.elf: critical 3P/TTC-CERT/kittipongk_cryptominer_xmr: high +3P/YARAForge/sekoia_miner_lin: critical 3P/elastic/cryptominer_stak: critical -3P/sekoia/miner_lin_xmrig: critical anti-behavior/random_behavior: low c2/addr/http_dynamic: medium c2/addr/ip: medium diff --git a/tests/linux/2022.bpfdoor/bpfdoor_1.simple b/tests/linux/2022.bpfdoor/bpfdoor_1.simple index db6f48346..36c5aba43 100644 --- a/tests/linux/2022.bpfdoor/bpfdoor_1.simple +++ b/tests/linux/2022.bpfdoor/bpfdoor_1.simple @@ -1,7 +1,7 @@ # linux/2022.bpfdoor/bpfdoor_1: critical +3P/YARAForge/sekoia_backdoor_lin: critical +3P/YARAForge/signature_redmenshen_bpfdoor: critical 3P/elastic/bpfdoor: critical -3P/sekoia/backdoor_lin_bpfdoor: critical -3P/sig_base/redmenshen_bpfdoor: critical data/random/insecure: low exec/program: medium exec/program/background: low diff --git a/tests/linux/2023.Kinsing/install.sh.simple b/tests/linux/2023.Kinsing/install.sh.simple index a323f2766..de2f9d023 100644 --- a/tests/linux/2023.Kinsing/install.sh.simple +++ b/tests/linux/2023.Kinsing/install.sh.simple @@ -1,6 +1,6 @@ # linux/2023.Kinsing/install.sh: critical +3P/YARAForge/signature_payload_f5: critical 3P/elastic/kinsing: critical -3P/sig_base/payload_f5_ip: critical anti-static/base64/exec: high c2/addr/ip: high c2/tool_transfer/arch: low diff --git a/tests/linux/2024.Gelsemium/dbus.simple b/tests/linux/2024.Gelsemium/dbus.simple index a0521c973..bcff700dd 100644 --- a/tests/linux/2024.Gelsemium/dbus.simple +++ b/tests/linux/2024.Gelsemium/dbus.simple @@ -1,5 +1,5 @@ # linux/2024.Gelsemium/dbus: critical -3P/sekoia/gelsemium_firewood_backdoor: critical +3P/YARAForge/sekoia_gelsemium_firewood: critical anti-behavior/random_behavior: low anti-static/elf/multiple: medium crypto/decrypt: low diff --git a/tests/linux/2024.Gelsemium/kde.simple b/tests/linux/2024.Gelsemium/kde.simple index c1529ab37..2960f6b95 100644 --- a/tests/linux/2024.Gelsemium/kde.simple +++ b/tests/linux/2024.Gelsemium/kde.simple @@ -1,5 +1,5 @@ # linux/2024.Gelsemium/kde: critical -3P/sekoia/gelsemium_wolfsbane_launcher: critical +3P/YARAForge/sekoia_gelsemium_wolfsbane: critical crypto/rc4: low discover/process/name: medium evasion/file/location/dev_shm: high diff --git a/tests/linux/2024.Gelsemium/libselinux.so.simple b/tests/linux/2024.Gelsemium/libselinux.so.simple index 685220a6d..49c072a82 100644 --- a/tests/linux/2024.Gelsemium/libselinux.so.simple +++ b/tests/linux/2024.Gelsemium/libselinux.so.simple @@ -1,5 +1,5 @@ # linux/2024.Gelsemium/libselinux.so: critical -3P/sekoia/gelsemium_wolfsbane_rootkit: critical +3P/YARAForge/sekoia_gelsemium_wolfsbane: critical anti-static/obfuscation/hidden_literals: medium anti-static/xor/commands: high anti-static/xor/paths: high diff --git a/tests/linux/2024.Gelsemium/udevd.simple b/tests/linux/2024.Gelsemium/udevd.simple index 67c745d8d..600f84457 100644 --- a/tests/linux/2024.Gelsemium/udevd.simple +++ b/tests/linux/2024.Gelsemium/udevd.simple @@ -1,6 +1,6 @@ # linux/2024.Gelsemium/udevd: critical -3P/reversinglabs/backdoor_wolfsbane: critical -3P/sekoia/gelsemium_wolfsbane_backdoor: critical +3P/YARAForge/reversinglabs_backdoor_wolfsbane: critical +3P/YARAForge/sekoia_gelsemium_wolfsbane: critical anti-behavior/random_behavior: low anti-static/elf/multiple: medium c2/addr/ip: medium diff --git a/tests/linux/2024.Gelsemium/udevd_multi.simple b/tests/linux/2024.Gelsemium/udevd_multi.simple index b9ce490e4..08dc55c06 100644 --- a/tests/linux/2024.Gelsemium/udevd_multi.simple +++ b/tests/linux/2024.Gelsemium/udevd_multi.simple @@ -1,6 +1,6 @@ # linux/2024.Gelsemium/udevd_multi: critical -3P/reversinglabs/backdoor_wolfsbane: critical -3P/sekoia/gelsemium_wolfsbane_backdoor: critical +3P/YARAForge/reversinglabs_backdoor_wolfsbane: critical +3P/YARAForge/sekoia_gelsemium_wolfsbane: critical anti-behavior/random_behavior: low anti-static/elf/multiple: medium c2/addr/ip: medium diff --git a/tests/linux/2024.PAN-OS.Upstyle/update.py.simple b/tests/linux/2024.PAN-OS.Upstyle/update.py.simple index 178e2ed1b..6e075fca2 100644 --- a/tests/linux/2024.PAN-OS.Upstyle/update.py.simple +++ b/tests/linux/2024.PAN-OS.Upstyle/update.py.simple @@ -1,5 +1,5 @@ # linux/2024.PAN-OS.Upstyle/update.py: critical -3P/volexity/py_upstyle: critical +3P/YARAForge/volexity_py_upstyle: critical anti-static/base64/eval: high anti-static/base64/function_names: critical data/base64/decode: medium diff --git a/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload1.py.simple b/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload1.py.simple index ba374f156..ffeb40fa7 100644 --- a/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload1.py.simple +++ b/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload1.py.simple @@ -1,5 +1,5 @@ # linux/2024.PAN-OS.Upstyle/update_base64_payload1.py: critical -3P/volexity/py_upstyle: critical +3P/YARAForge/volexity_py_upstyle: critical anti-static/base64/eval: high anti-static/base64/function_names: critical data/base64/decode: medium diff --git a/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload2.py.simple b/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload2.py.simple index 0354a64f2..e6c6ee1ba 100644 --- a/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload2.py.simple +++ b/tests/linux/2024.PAN-OS.Upstyle/update_base64_payload2.py.simple @@ -1,6 +1,6 @@ # linux/2024.PAN-OS.Upstyle/update_base64_payload2.py: critical -3P/sig_base/uta028_forensicartefacts_paloalto: critical -3P/volexity/py_upstyle: critical +3P/YARAForge/signature_uta028_forensicartefacts: critical +3P/YARAForge/volexity_py_upstyle: critical anti-static/obfuscation/python: medium data/base64/decode: medium data/encoding/base64: low diff --git a/tests/linux/2024.TellYouThePass/uranus-ack-mike-cat.simple b/tests/linux/2024.TellYouThePass/uranus-ack-mike-cat.simple index f3d540c1d..07db8e186 100644 --- a/tests/linux/2024.TellYouThePass/uranus-ack-mike-cat.simple +++ b/tests/linux/2024.TellYouThePass/uranus-ack-mike-cat.simple @@ -1,5 +1,5 @@ # linux/2024.TellYouThePass/uranus-ack-mike-cat: critical -3P/arkbird/solg_ran_elf: critical +3P/YARAForge/arkbird_solg_ran: critical anti-behavior/random_behavior: low c2/addr/ip: high collect/databases/mysql: medium diff --git a/tests/linux/2024.chisel/crondx.simple b/tests/linux/2024.chisel/crondx.simple index 12e4936b4..8793b2c80 100644 --- a/tests/linux/2024.chisel/crondx.simple +++ b/tests/linux/2024.chisel/crondx.simple @@ -1,5 +1,5 @@ # linux/2024.chisel/crondx: critical -3P/sekoia/chisel_strings: critical +3P/YARAForge/sekoia_chisel_strings: critical anti-behavior/random_behavior: low c2/addr/ip: high c2/addr/url: low diff --git a/tests/linux/2024.fog/5a99a15406c218fd6862f90ed3534fb8f0a888bb0c5a09192eae01d595f05bc5.elf.simple b/tests/linux/2024.fog/5a99a15406c218fd6862f90ed3534fb8f0a888bb0c5a09192eae01d595f05bc5.elf.simple index a62cb78dd..07d5ad03e 100644 --- a/tests/linux/2024.fog/5a99a15406c218fd6862f90ed3534fb8f0a888bb0c5a09192eae01d595f05bc5.elf.simple +++ b/tests/linux/2024.fog/5a99a15406c218fd6862f90ed3534fb8f0a888bb0c5a09192eae01d595f05bc5.elf.simple @@ -1,5 +1,5 @@ # linux/2024.fog/5a99a15406c218fd6862f90ed3534fb8f0a888bb0c5a09192eae01d595f05bc5.elf: critical -3P/sig_base/ransom_lockbit: critical +3P/YARAForge/signature_ransom_lockbit: critical c2/addr/tor_onion: high credential/password: low crypto/rc4: low diff --git a/tests/linux/2024.sliver/de33b8d9694b6b4c44e3459b2151571af5d0e2031551f9f1a70b6db475ba71b2.elf.json b/tests/linux/2024.sliver/de33b8d9694b6b4c44e3459b2151571af5d0e2031551f9f1a70b6db475ba71b2.elf.json index 86cdf9240..23cf1d746 100644 --- a/tests/linux/2024.sliver/de33b8d9694b6b4c44e3459b2151571af5d0e2031551f9f1a70b6db475ba71b2.elf.json +++ b/tests/linux/2024.sliver/de33b8d9694b6b4c44e3459b2151571af5d0e2031551f9f1a70b6db475ba71b2.elf.json @@ -30,7 +30,7 @@ "ReferenceURL": "https://github.com/unixpickle/gobfuscate", "RuleAuthor": "James Quinn, Paul Hager (merged with new similar pattern)", "RuleLicenseURL": "https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/LICENSE", - "ID": "3P/sig_base/susp_gobfuscate", + "ID": "3P/YARAForge/signature_susp_gobfuscate", "RuleName": "SIGNATURE_BASE_SUSP_Gobfuscate_May21" }, { diff --git a/tests/linux/2024.xzutils/liblzma.so.5.6.1.simple b/tests/linux/2024.xzutils/liblzma.so.5.6.1.simple index de2803f72..5c4940ec1 100644 --- a/tests/linux/2024.xzutils/liblzma.so.5.6.1.simple +++ b/tests/linux/2024.xzutils/liblzma.so.5.6.1.simple @@ -1,8 +1,7 @@ # linux/2024.xzutils/liblzma.so.5.6.1: critical -3P/craiu/unk_liblzma_backdoor: critical -3P/craiu/unk_liblzma_encstrings: critical +3P/YARAForge/craiu_unk_liblzma: critical +3P/YARAForge/signature_bkdr_xzutil: critical 3P/elastic/xzbackdoor: critical -3P/sig_base/bkdr_xzutil_binary: critical data/compression/lzma: low discover/process/runtime_deps: high process/multithreaded: low diff --git a/tests/linux/2024.xzutils/liblzma_la-crc64-fast.o.simple b/tests/linux/2024.xzutils/liblzma_la-crc64-fast.o.simple index 4a405fd01..6981aab0a 100644 --- a/tests/linux/2024.xzutils/liblzma_la-crc64-fast.o.simple +++ b/tests/linux/2024.xzutils/liblzma_la-crc64-fast.o.simple @@ -1,8 +1,7 @@ # linux/2024.xzutils/liblzma_la-crc64-fast.o: critical -3P/craiu/unk_liblzma_backdoor: critical -3P/craiu/unk_liblzma_encstrings: critical +3P/YARAForge/craiu_unk_liblzma: critical +3P/YARAForge/signature_bkdr_xzutil: critical 3P/elastic/xzbackdoor: critical -3P/sig_base/bkdr_xzutil_binary: critical anti-static/binary/opaque: medium data/compression/lzma: low discover/process/runtime_deps: medium diff --git a/tests/linux/clean/http-fingerprints.lua.simple b/tests/linux/clean/http-fingerprints.lua.simple index b9437a03b..f6afb0c26 100644 --- a/tests/linux/clean/http-fingerprints.lua.simple +++ b/tests/linux/clean/http-fingerprints.lua.simple @@ -1,5 +1,5 @@ # linux/clean/http-fingerprints.lua: medium -3P/sig_base/hacktool_strings_p0wnedshell: medium +3P/YARAForge/signature_hacktool_strings: medium anti-behavior/random_behavior: low c2/tool_transfer/grayware: medium c2/tool_transfer/os: medium diff --git a/tests/linux/mimipenguin/bash/mimipenguin.simple b/tests/linux/mimipenguin/bash/mimipenguin.simple index d5f19d7b0..2a9bb06e0 100644 --- a/tests/linux/mimipenguin/bash/mimipenguin.simple +++ b/tests/linux/mimipenguin/bash/mimipenguin.simple @@ -1,6 +1,6 @@ # linux/mimipenguin/bash/mimipenguin: critical -3P/sig_base/mimipenguin: critical -3P/sig_base/mimipenguin_sh: critical +3P/YARAForge/signature_mimipenguin: critical +3P/YARAForge/signature_mimipenguin_sh: critical credential/keychain/gnome_keyring_daemon: medium credential/os/shadow: medium credential/password: low diff --git a/tests/linux/mimipenguin/python/mimipenguin.simple b/tests/linux/mimipenguin/python/mimipenguin.simple index 3dba6765b..7ef1f7ac9 100644 --- a/tests/linux/mimipenguin/python/mimipenguin.simple +++ b/tests/linux/mimipenguin/python/mimipenguin.simple @@ -1,5 +1,5 @@ # linux/mimipenguin/python/mimipenguin: critical -3P/sig_base/mimipenguin: critical +3P/YARAForge/signature_mimipenguin: critical c2/tool_transfer/os: low credential/keychain/gnome_keyring_daemon: medium credential/os/shadow: medium diff --git a/tests/macOS/2023.3CX/libffmpeg.change_decrease.mdiff b/tests/macOS/2023.3CX/libffmpeg.change_decrease.mdiff index df1bcc58d..aeae5ec37 100644 --- a/tests/macOS/2023.3CX/libffmpeg.change_decrease.mdiff +++ b/tests/macOS/2023.3CX/libffmpeg.change_decrease.mdiff @@ -4,11 +4,11 @@ | RISK | KEY | DESCRIPTION | EVIDENCE | |:--|:--|:--|:--| -| -CRITICAL | [3P/sekoia/downloader_smooth_operator](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16) | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | -| -CRITICAL | [3P/sig_base/3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275) | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | -| -CRITICAL | [3P/sig_base/nk_3cx_dylib](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214) | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | -| -CRITICAL | [3P/sig_base/susp_xored_mozilla](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25) | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | -| -CRITICAL | [3P/volexity/iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50) | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | +| -CRITICAL | [3P/YARAForge/sekoia_downloader_smooth](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16) | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | +| -CRITICAL | [3P/YARAForge/signature_3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275) | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | +| -CRITICAL | [3P/YARAForge/signature_nk_3cx](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214) | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | +| -CRITICAL | [3P/YARAForge/signature_susp_xored](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25) | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | +| -CRITICAL | [3P/YARAForge/volexity_iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50) | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | | -CRITICAL | [anti-static/xor/user_agent](https://github.com/chainguard-dev/malcontent/blob/main/rules/anti-static/xor/xor-user_agent.yara#xor_mozilla) | XOR'ed user agent, often found in backdoors, by Florian Roth | [xor_mozilla::$Mozilla_5_0](https://github.com/search?q=xor_mozilla%3A%3A%24Mozilla_5_0&type=code) | | -CRITICAL | [impact/remote_access/net_exec](https://github.com/chainguard-dev/malcontent/blob/main/rules/impact/remote_access/net_exec.yara#lazarus_darwin_nsurl) | executes programs, sets permissions, sleeps, makes HTTP requests | [NSMutableURLRequest](https://github.com/search?q=NSMutableURLRequest&type=code)
[gethostname](https://github.com/search?q=gethostname&type=code)
[localtime](https://github.com/search?q=localtime&type=code)
[sprintf](https://github.com/search?q=sprintf&type=code)
[strncpy](https://github.com/search?q=strncpy&type=code)
[pclose](https://github.com/search?q=pclose&type=code)
[chmod](https://github.com/search?q=chmod&type=code)
[flock](https://github.com/search?q=flock&type=code)
[popen](https://github.com/search?q=popen&type=code)
[sleep](https://github.com/search?q=sleep&type=code)
[rand](https://github.com/search?q=rand&type=code) | | -HIGH | [exec/shell/arbitrary_command_dev_null](https://github.com/chainguard-dev/malcontent/blob/main/rules/exec/shell/arbitrary_command-dev_null.yara#cmd_dev_null_quoted) | runs quoted templated commands, discards output | ["%s" >/dev/null](https://github.com/search?q=%22%25s%22+%3E%2Fdev%2Fnull&type=code) | diff --git a/tests/macOS/2023.3CX/libffmpeg.change_increase.mdiff b/tests/macOS/2023.3CX/libffmpeg.change_increase.mdiff index f87ee1aea..8f0b2d770 100644 --- a/tests/macOS/2023.3CX/libffmpeg.change_increase.mdiff +++ b/tests/macOS/2023.3CX/libffmpeg.change_increase.mdiff @@ -4,11 +4,11 @@ | RISK | KEY | DESCRIPTION | EVIDENCE | |:--|:--|:--|:--| -| +CRITICAL | **[3P/sekoia/downloader_smooth_operator](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16)** | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | -| +CRITICAL | **[3P/sig_base/3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275)** | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | -| +CRITICAL | **[3P/sig_base/nk_3cx_dylib](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214)** | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | -| +CRITICAL | **[3P/sig_base/susp_xored_mozilla](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25)** | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | -| +CRITICAL | **[3P/volexity/iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50)** | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | +| +CRITICAL | **[3P/YARAForge/sekoia_downloader_smooth](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16)** | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | +| +CRITICAL | **[3P/YARAForge/signature_3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275)** | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | +| +CRITICAL | **[3P/YARAForge/signature_nk_3cx](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214)** | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | +| +CRITICAL | **[3P/YARAForge/signature_susp_xored](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25)** | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | +| +CRITICAL | **[3P/YARAForge/volexity_iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50)** | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | | +CRITICAL | **[anti-static/xor/user_agent](https://github.com/chainguard-dev/malcontent/blob/main/rules/anti-static/xor/xor-user_agent.yara#xor_mozilla)** | XOR'ed user agent, often found in backdoors, by Florian Roth | [xor_mozilla::$Mozilla_5_0](https://github.com/search?q=xor_mozilla%3A%3A%24Mozilla_5_0&type=code) | | +CRITICAL | **[impact/remote_access/net_exec](https://github.com/chainguard-dev/malcontent/blob/main/rules/impact/remote_access/net_exec.yara#lazarus_darwin_nsurl)** | executes programs, sets permissions, sleeps, makes HTTP requests | [NSMutableURLRequest](https://github.com/search?q=NSMutableURLRequest&type=code)
[gethostname](https://github.com/search?q=gethostname&type=code)
[localtime](https://github.com/search?q=localtime&type=code)
[sprintf](https://github.com/search?q=sprintf&type=code)
[strncpy](https://github.com/search?q=strncpy&type=code)
[pclose](https://github.com/search?q=pclose&type=code)
[chmod](https://github.com/search?q=chmod&type=code)
[flock](https://github.com/search?q=flock&type=code)
[popen](https://github.com/search?q=popen&type=code)
[sleep](https://github.com/search?q=sleep&type=code)
[rand](https://github.com/search?q=rand&type=code) | | +HIGH | **[exec/shell/arbitrary_command_dev_null](https://github.com/chainguard-dev/malcontent/blob/main/rules/exec/shell/arbitrary_command-dev_null.yara#cmd_dev_null_quoted)** | runs quoted templated commands, discards output | ["%s" >/dev/null](https://github.com/search?q=%22%25s%22+%3E%2Fdev%2Fnull&type=code) | diff --git a/tests/macOS/2023.3CX/libffmpeg.dirty.dylib.simple b/tests/macOS/2023.3CX/libffmpeg.dirty.dylib.simple index f396c7106..0564936e9 100644 --- a/tests/macOS/2023.3CX/libffmpeg.dirty.dylib.simple +++ b/tests/macOS/2023.3CX/libffmpeg.dirty.dylib.simple @@ -1,9 +1,9 @@ # macOS/2023.3CX/libffmpeg.dirty.dylib: critical -3P/sekoia/downloader_smooth_operator: critical -3P/sig_base/3cxdesktopapp_backdoor: critical -3P/sig_base/nk_3cx_dylib: critical -3P/sig_base/susp_xored_mozilla: critical -3P/volexity/iconic: critical +3P/YARAForge/sekoia_downloader_smooth: critical +3P/YARAForge/signature_3cxdesktopapp_backdoor: critical +3P/YARAForge/signature_nk_3cx: critical +3P/YARAForge/signature_susp_xored: critical +3P/YARAForge/volexity_iconic: critical anti-behavior/random_behavior: low anti-static/xor/user_agent: critical c2/addr/url: low diff --git a/tests/macOS/2023.3CX/libffmpeg.dirty.mdiff b/tests/macOS/2023.3CX/libffmpeg.dirty.mdiff index f87ee1aea..8f0b2d770 100644 --- a/tests/macOS/2023.3CX/libffmpeg.dirty.mdiff +++ b/tests/macOS/2023.3CX/libffmpeg.dirty.mdiff @@ -4,11 +4,11 @@ | RISK | KEY | DESCRIPTION | EVIDENCE | |:--|:--|:--|:--| -| +CRITICAL | **[3P/sekoia/downloader_smooth_operator](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16)** | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | -| +CRITICAL | **[3P/sig_base/3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275)** | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | -| +CRITICAL | **[3P/sig_base/nk_3cx_dylib](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214)** | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | -| +CRITICAL | **[3P/sig_base/susp_xored_mozilla](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25)** | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | -| +CRITICAL | **[3P/volexity/iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50)** | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | +| +CRITICAL | **[3P/YARAForge/sekoia_downloader_smooth](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16)** | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | +| +CRITICAL | **[3P/YARAForge/signature_3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275)** | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | +| +CRITICAL | **[3P/YARAForge/signature_nk_3cx](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214)** | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | +| +CRITICAL | **[3P/YARAForge/signature_susp_xored](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25)** | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | +| +CRITICAL | **[3P/YARAForge/volexity_iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50)** | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | | +CRITICAL | **[anti-static/xor/user_agent](https://github.com/chainguard-dev/malcontent/blob/main/rules/anti-static/xor/xor-user_agent.yara#xor_mozilla)** | XOR'ed user agent, often found in backdoors, by Florian Roth | [xor_mozilla::$Mozilla_5_0](https://github.com/search?q=xor_mozilla%3A%3A%24Mozilla_5_0&type=code) | | +CRITICAL | **[impact/remote_access/net_exec](https://github.com/chainguard-dev/malcontent/blob/main/rules/impact/remote_access/net_exec.yara#lazarus_darwin_nsurl)** | executes programs, sets permissions, sleeps, makes HTTP requests | [NSMutableURLRequest](https://github.com/search?q=NSMutableURLRequest&type=code)
[gethostname](https://github.com/search?q=gethostname&type=code)
[localtime](https://github.com/search?q=localtime&type=code)
[sprintf](https://github.com/search?q=sprintf&type=code)
[strncpy](https://github.com/search?q=strncpy&type=code)
[pclose](https://github.com/search?q=pclose&type=code)
[chmod](https://github.com/search?q=chmod&type=code)
[flock](https://github.com/search?q=flock&type=code)
[popen](https://github.com/search?q=popen&type=code)
[sleep](https://github.com/search?q=sleep&type=code)
[rand](https://github.com/search?q=rand&type=code) | | +HIGH | **[exec/shell/arbitrary_command_dev_null](https://github.com/chainguard-dev/malcontent/blob/main/rules/exec/shell/arbitrary_command-dev_null.yara#cmd_dev_null_quoted)** | runs quoted templated commands, discards output | ["%s" >/dev/null](https://github.com/search?q=%22%25s%22+%3E%2Fdev%2Fnull&type=code) | diff --git a/tests/macOS/2023.3CX/libffmpeg.increase.mdiff b/tests/macOS/2023.3CX/libffmpeg.increase.mdiff index f87ee1aea..8f0b2d770 100644 --- a/tests/macOS/2023.3CX/libffmpeg.increase.mdiff +++ b/tests/macOS/2023.3CX/libffmpeg.increase.mdiff @@ -4,11 +4,11 @@ | RISK | KEY | DESCRIPTION | EVIDENCE | |:--|:--|:--|:--| -| +CRITICAL | **[3P/sekoia/downloader_smooth_operator](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16)** | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | -| +CRITICAL | **[3P/sig_base/3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275)** | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | -| +CRITICAL | **[3P/sig_base/nk_3cx_dylib](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214)** | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | -| +CRITICAL | **[3P/sig_base/susp_xored_mozilla](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25)** | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | -| +CRITICAL | **[3P/volexity/iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50)** | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | +| +CRITICAL | **[3P/YARAForge/sekoia_downloader_smooth](https://github.com/SEKOIA-IO/Community/blob/80f51fd7496e3df4d2e166a34f8235e76f4aa1bf/yara_rules/downloader_mac_smooth_operator.yar#L1-L16)** | Detect the Smooth_Operator malware, by [Sekoia.io](https://github.com/SEKOIA-IO/Community) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code) | +| +CRITICAL | **[3P/YARAForge/signature_3cxdesktopapp_backdoor](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L251-L275)** | [Detects 3CXDesktopApp MacOS Backdoor component](https://www.volexity.com/blog/2023/03/30/3cx-supply-chain-compromise-leads-to-iconic-incident/), by X__Junior (Nextron Systems) | [%s/.main_storage](https://github.com/search?q=%25s%2F.main_storage&type=code)
[%s/UpdateAgent](https://github.com/search?q=%25s%2FUpdateAgent&type=code)
`$op1`
`$op2`
`$sa1`
`$sa2` | +| +CRITICAL | **[3P/YARAForge/signature_nk_3cx](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_mal_3cx_compromise_mar23.yar#L188-L214)** | [Detects malicious DYLIB files related to 3CX compromise](https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/), by Florian Roth (Nextron Systems) | `$xc1`
`$xc2`
`$xc3` | +| +CRITICAL | **[3P/YARAForge/signature_susp_xored](https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/yara/gen_xor_hunting.yar#L2-L25)** | [Detects suspicious single byte XORed keyword 'Mozilla/5.0' - it uses yara's XOR modifier and therefore cannot print the XOR key](https://gchq.github.io/CyberChef/#recipe=XOR_Brute_Force()), by Florian Roth | `$xo1` | +| +CRITICAL | **[3P/YARAForge/volexity_iconic](https://github.com/volexity/threat-intel/blob/92353b1ccc638f5ed0e7db43a26cb40fad7f03df/2023/2023-03-30%203CX/indicators/rules.yar#L32-L50)** | [Detects the MACOS version of the ICONIC loader.](https://www.reddit.com/r/crowdstrike/comments/125r3uu/20230329_situational_awareness_crowdstrike/), by threatintel@volexity.com | `$str1`
`$str2`
`$str3` | | +CRITICAL | **[anti-static/xor/user_agent](https://github.com/chainguard-dev/malcontent/blob/main/rules/anti-static/xor/xor-user_agent.yara#xor_mozilla)** | XOR'ed user agent, often found in backdoors, by Florian Roth | [xor_mozilla::$Mozilla_5_0](https://github.com/search?q=xor_mozilla%3A%3A%24Mozilla_5_0&type=code) | | +CRITICAL | **[impact/remote_access/net_exec](https://github.com/chainguard-dev/malcontent/blob/main/rules/impact/remote_access/net_exec.yara#lazarus_darwin_nsurl)** | executes programs, sets permissions, sleeps, makes HTTP requests | [NSMutableURLRequest](https://github.com/search?q=NSMutableURLRequest&type=code)
[gethostname](https://github.com/search?q=gethostname&type=code)
[localtime](https://github.com/search?q=localtime&type=code)
[sprintf](https://github.com/search?q=sprintf&type=code)
[strncpy](https://github.com/search?q=strncpy&type=code)
[pclose](https://github.com/search?q=pclose&type=code)
[chmod](https://github.com/search?q=chmod&type=code)
[flock](https://github.com/search?q=flock&type=code)
[popen](https://github.com/search?q=popen&type=code)
[sleep](https://github.com/search?q=sleep&type=code)
[rand](https://github.com/search?q=rand&type=code) | | +HIGH | **[exec/shell/arbitrary_command_dev_null](https://github.com/chainguard-dev/malcontent/blob/main/rules/exec/shell/arbitrary_command-dev_null.yara#cmd_dev_null_quoted)** | runs quoted templated commands, discards output | ["%s" >/dev/null](https://github.com/search?q=%22%25s%22+%3E%2Fdev%2Fnull&type=code) | diff --git a/tests/php/2024.S3RV4N7-SHELL/crot.php.simple b/tests/php/2024.S3RV4N7-SHELL/crot.php.simple index e9d520d94..7ae5dc377 100644 --- a/tests/php/2024.S3RV4N7-SHELL/crot.php.simple +++ b/tests/php/2024.S3RV4N7-SHELL/crot.php.simple @@ -1,5 +1,5 @@ # php/2024.S3RV4N7-SHELL/crot.php: critical -3P/sig_base/webshell_php: critical +3P/YARAForge/signature_webshell_php: critical anti-static/base64/function_names: medium anti-static/obfuscation/php: medium data/encoding/base64: low diff --git a/tests/php/2024.alfa/alfa-obfuscated.php.simple b/tests/php/2024.alfa/alfa-obfuscated.php.simple index 0fc4eed02..cac13ea51 100644 --- a/tests/php/2024.alfa/alfa-obfuscated.php.simple +++ b/tests/php/2024.alfa/alfa-obfuscated.php.simple @@ -1,5 +1,5 @@ # php/2024.alfa/alfa-obfuscated.php: critical -3P/sig_base/webshell_php: critical +3P/YARAForge/signature_webshell_php: critical anti-static/base64/obfuscated_caller: critical anti-static/obfuscation/bitwise: high anti-static/obfuscation/padding: critical diff --git a/tests/php/2024.malcure/simple.php.simple b/tests/php/2024.malcure/simple.php.simple index b41fe5d44..938314027 100644 --- a/tests/php/2024.malcure/simple.php.simple +++ b/tests/php/2024.malcure/simple.php.simple @@ -1,6 +1,5 @@ # php/2024.malcure/simple.php: critical -3P/sig_base/webshell_php: critical -3P/sig_base/webshell_php_obfusc: critical +3P/YARAForge/signature_webshell_php: critical data/encoding/base64: low exec/remote_commands/code_eval: high impact/remote_access/backdoor: medium diff --git a/tests/php/2024.sagsooz/2024.php.simple b/tests/php/2024.sagsooz/2024.php.simple index a88adeee7..0e13bd06f 100644 --- a/tests/php/2024.sagsooz/2024.php.simple +++ b/tests/php/2024.sagsooz/2024.php.simple @@ -1,5 +1,5 @@ # php/2024.sagsooz/2024.php: critical -3P/sig_base/webshell_php: critical +3P/YARAForge/signature_webshell_php: critical c2/addr/url: medium credential/password: low data/embedded/base64_url: medium diff --git a/tests/php/2024.sagsooz/bestmini.php.simple b/tests/php/2024.sagsooz/bestmini.php.simple index 93aa17327..0875d0e20 100644 --- a/tests/php/2024.sagsooz/bestmini.php.simple +++ b/tests/php/2024.sagsooz/bestmini.php.simple @@ -1,7 +1,5 @@ # php/2024.sagsooz/bestmini.php: critical -3P/sig_base/webshell_php: critical -3P/sig_base/webshell_php_by: critical -3P/sig_base/webshell_php_strings: critical +3P/YARAForge/signature_webshell_php: critical anti-static/obfuscation/php: medium impact/remote_access/reverse_shell: high net/url/embedded: medium diff --git a/tests/python/2024.ultralytics/v8.3.46/__init__.py.simple b/tests/python/2024.ultralytics/v8.3.46/__init__.py.simple index a2562dc70..0479734cf 100644 --- a/tests/python/2024.ultralytics/v8.3.46/__init__.py.simple +++ b/tests/python/2024.ultralytics/v8.3.46/__init__.py.simple @@ -1,5 +1,5 @@ # python/2024.ultralytics/v8.3.46/__init__.py: critical -3P/sig_base/pua_crypto_mining: critical +3P/YARAForge/signature_pua_crypto: critical c2/tool_transfer/os: low discover/system/platform: medium exec/imports/python: low diff --git a/tests/ruby/2024.reverse_shells/oreilly2.rb.simple b/tests/ruby/2024.reverse_shells/oreilly2.rb.simple index 995ca0b57..2b2957eb1 100644 --- a/tests/ruby/2024.reverse_shells/oreilly2.rb.simple +++ b/tests/ruby/2024.reverse_shells/oreilly2.rb.simple @@ -1,5 +1,5 @@ # ruby/2024.reverse_shells/oreilly2.rb: critical -3P/sig_base/hktl_shellpop_ruby: critical +3P/YARAForge/signature_hktl_shellpop: critical exec/cmd/pipe: medium impact/remote_access/reverse_shell: high net/tcp/connect: medium diff --git a/tests/windows/2024.GitHub.Clipper/main.exe.simple b/tests/windows/2024.GitHub.Clipper/main.exe.simple index 8b7e29042..7aad0b937 100644 --- a/tests/windows/2024.GitHub.Clipper/main.exe.simple +++ b/tests/windows/2024.GitHub.Clipper/main.exe.simple @@ -1,7 +1,7 @@ # windows/2024.GitHub.Clipper/main.exe: critical 3P/JPCERT/apt10_chches_lnk: critical -3P/ditekshen/discordurl: critical -3P/ditekshen/vm_evasion_macaddrcomb: critical +3P/YARAForge/ditekshen_discordurl: critical +3P/YARAForge/ditekshen_vm_evasion: critical 3P/elastic/infostealer_wallets: critical 3P/elastic/multi_threat: high anti-behavior/anti_debugger: medium diff --git a/tests/windows/2024.Sharp/sharpil_RAT.exe.md b/tests/windows/2024.Sharp/sharpil_RAT.exe.md index d091d77d9..2089863ac 100644 --- a/tests/windows/2024.Sharp/sharpil_RAT.exe.md +++ b/tests/windows/2024.Sharp/sharpil_RAT.exe.md @@ -2,7 +2,7 @@ | RISK | KEY | DESCRIPTION | EVIDENCE | |:--|:--|:--|:--| -| CRITICAL | [3P/ditekshen/telegramchatbot](https://github.com/ditekshen/detection/blob/e76c93dcdedff04076380ffc60ea54e45b313635/yara/indicator_suspicious.yar#L1293-L1308) | Detects executables using Telegram Chat Bot, by [ditekSHen](https://github.com/ditekshen/detection) | `$p1`
`$p2`
`$s1`
`$s2`
`$s4` | +| CRITICAL | [3P/YARAForge/ditekshen_telegramchatbot](https://github.com/ditekshen/detection/blob/e76c93dcdedff04076380ffc60ea54e45b313635/yara/indicator_suspicious.yar#L1293-L1308) | Detects executables using Telegram Chat Bot, by [ditekSHen](https://github.com/ditekshen/detection) | `$p1`
`$p2`
`$s1`
`$s2`
`$s4` | | HIGH | [net/email/send](https://github.com/chainguard-dev/malcontent/blob/main/rules/net/email/send.yara#SMTPClient_Send_creds) | sends e-mail with a hardcoded credentials | [NetworkCredential](https://github.com/search?q=NetworkCredential&type=code) | | MEDIUM | [c2/addr/discord](https://github.com/chainguard-dev/malcontent/blob/main/rules/c2/addr/discord.yara#discord) | may report back to 'Discord' | [Discord](https://github.com/search?q=Discord&type=code) | | MEDIUM | [c2/addr/telegram](https://github.com/chainguard-dev/malcontent/blob/main/rules/c2/addr/telegram.yara#telegram) | telegram | [Telegram](https://github.com/search?q=Telegram&type=code) | diff --git a/tests/windows/2024.aspdasdksa2/callback.bat.json b/tests/windows/2024.aspdasdksa2/callback.bat.json index 9eb4e626b..a8469ec8a 100644 --- a/tests/windows/2024.aspdasdksa2/callback.bat.json +++ b/tests/windows/2024.aspdasdksa2/callback.bat.json @@ -17,7 +17,7 @@ "RuleAuthor": "Florian Roth (Nextron Systems)", "RuleLicense": "Detection Rule License 1.1 https://github.com/Neo23x0/signature-base/blob/master/LICENSE", "RuleLicenseURL": "https://github.com/Neo23x0/signature-base/blob/6a18e50cdc09a14f850d17e6ae9c1f05f5ed9ba6/LICENSE", - "ID": "3P/sig_base/powershell_webdownload", + "ID": "3P/YARAForge/signature_powershell_webdownload", "RuleName": "SIGNATURE_BASE_Suspicious_Powershell_Webdownload_1" }, { diff --git a/third_party/third_party.go b/third_party/third_party.go index dbc7ffa42..f6ff96cb0 100644 --- a/third_party/third_party.go +++ b/third_party/third_party.go @@ -1,3 +1,6 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + package thirdparty import "embed"