| new file mode 100644 |
| index 000000000..2a1cd44e6 |
| --- /dev/null |
| +++ b/internal/objstore/clamav_test.go |
| @@ -0,0 +1,350 @@ |
| +package objstore |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "io" |
| + "strings" |
| + "testing" |
| + |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| + |
| + "github.com/carlosframework/platform/internal/clamd" |
| +) |
| + |
| +// stubScanS3 implements the narrow scanS3API seam with per-method hooks, in |
| +// the same style as stubS3. An un-set hook fails the test loudly rather than |
| +// panicking on a nil func — several tests here exist precisely to assert that |
| +// a call did NOT happen, and that only works if an unexpected call says so. |
| +type stubScanS3 struct { |
| + t *testing.T |
| + |
| + getObject func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) |
| + getTagging func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) |
| + putTagging func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) |
| +} |
| + |
| +func (s *stubScanS3) GetObject(_ context.Context, in *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) { |
| + if s.getObject == nil { |
| + s.t.Fatal("unexpected GetObject call — bytes were fetched that should not have been") |
| + } |
| + return s.getObject(in) |
| +} |
| + |
| +func (s *stubScanS3) GetObjectTagging(_ context.Context, in *awss3.GetObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.GetObjectTaggingOutput, error) { |
| + if s.getTagging == nil { |
| + s.t.Fatal("unexpected GetObjectTagging call") |
| + } |
| + return s.getTagging(in) |
| +} |
| + |
| +func (s *stubScanS3) PutObjectTagging(_ context.Context, in *awss3.PutObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.PutObjectTaggingOutput, error) { |
| + if s.putTagging == nil { |
| + s.t.Fatal("unexpected PutObjectTagging call — a verdict was written that should not have been") |
| + } |
| + return s.putTagging(in) |
| +} |
| + |
| +// fakeEngine stands in for clamd. The wire protocol has its own tests in |
| +// internal/clamd; what matters here is how the scanner reacts to each answer. |
| +type fakeEngine struct { |
| + res clamd.Result |
| + err error |
| + version string |
| + |
| + calls int |
| + read []byte |
| +} |
| + |
| +func (f *fakeEngine) ScanStream(_ context.Context, r io.Reader) (clamd.Result, error) { |
| + f.calls++ |
| + f.read, _ = io.ReadAll(r) |
| + if f.err != nil { |
| + return clamd.Result{}, f.err |
| + } |
| + return f.res, nil |
| +} |
| + |
| +func (f *fakeEngine) Version(_ context.Context) (string, error) { |
| + if f.err != nil { |
| + return "", f.err |
| + } |
| + return f.version, nil |
| +} |
| + |
| +func objectBody(s string) *awss3.GetObjectOutput { |
| + return &awss3.GetObjectOutput{Body: io.NopCloser(strings.NewReader(s))} |
| +} |
| + |
| +func tagsOf(in *awss3.PutObjectTaggingInput) map[string]string { |
| + m := map[string]string{} |
| + if in.Tagging == nil { |
| + return m |
| + } |
| + for _, t := range in.Tagging.TagSet { |
| + m[aws2s(t.Key)] = aws2s(t.Value) |
| + } |
| + return m |
| +} |
| + |
| +// An object above the ceiling is tagged skipped WITHOUT a GetObject: paying |
| +// egress to fetch bytes clamd will refuse is pure waste, and the size is |
| +// already on the listing page. |
| +func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) { |
| + var wrote map[string]string |
| + s3 := &stubScanS3{t: t} // getObject deliberately unset: calling it fails the test |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + wrote = tagsOf(in) |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + eng := &fakeEngine{} |
| + c := &clamavScanner{s3: s3, engine: eng, maxBytes: 10} |
| + |
| + v, err := c.Scan(context.Background(), "b", "big.iso", "abc", 11) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictSkipped { |
| + t.Fatalf("verdict = %q, want skipped", v) |
| + } |
| + if eng.calls != 0 { |
| + t.Fatal("an oversize object was streamed to the engine anyway") |
| + } |
| + if wrote[TagVerdict] != string(VerdictSkipped) { |
| + t.Fatalf("tags = %v", wrote) |
| + } |
| +} |
| + |
| +// A verdict is written with the etag it judged, so a later rewrite reads as |
| +// unknown rather than inheriting the old verdict. |
| +func TestClamAVTagsVerdictWithETag(t *testing.T) { |
| + var wrote map[string]string |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + wrote = tagsOf(in) |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}} |
| + c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20} |
| + |
| + v, err := c.Scan(context.Background(), "b", "k", `"d41d8cd9"`, 5) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictClean { |
| + t.Fatalf("verdict = %q", v) |
| + } |
| + if wrote[TagETag] != "d41d8cd9" { |
| + t.Fatalf("verdict is not bound to the etag it judged: %v", wrote) |
| + } |
| + if wrote[TagAt] == "" { |
| + t.Fatalf("no scan time recorded: %v", wrote) |
| + } |
| + if !bytes.Equal(eng.read, []byte("hello")) { |
| + t.Fatalf("engine saw %q", eng.read) |
| + } |
| +} |
| + |
| +// clamd being unreachable is a PASS error, not thousands of failed tags. |
| +// "The engine is unreachable" and "the engine read this object and failed on |
| +// it" are different facts and only the second belongs in a tag. |
| +func TestClamAVDialFailureWritesNoVerdict(t *testing.T) { |
| + s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + eng := &fakeEngine{err: errors.New("dial unix /run/clamav/clamd.ctl: connection refused")} |
| + c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20} |
| + |
| + v, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| + if err == nil { |
| + t.Fatal("an unreachable engine reported success") |
| + } |
| + if v != VerdictUnknown { |
| + t.Fatalf("verdict = %q, want unknown", v) |
| + } |
| +} |
| + |
| +// A mid-stream size refusal maps to skipped, not failed. |
| +func TestClamAVSizeExceededMapsToSkipped(t *testing.T) { |
| + var wrote map[string]string |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + wrote = tagsOf(in) |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusSizeExceeded}} |
| + c := &clamavScanner{s3: s3, engine: eng, maxBytes: 0} // no local ceiling: the engine's own |
| + |
| + v, err := c.Scan(context.Background(), "b", "k", "e", 999) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictSkipped { |
| + t.Fatalf("verdict = %q, want skipped", v) |
| + } |
| + if wrote[TagVerdict] != string(VerdictSkipped) { |
| + t.Fatalf("tags = %v", wrote) |
| + } |
| +} |
| + |
| +// An engine ERROR on an object it did read IS a per-object fact, and does get |
| +// recorded — the mirror of the unreachable case above. |
| +func TestClamAVEngineErrorIsRecordedAsFailed(t *testing.T) { |
| + var wrote map[string]string |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + wrote = tagsOf(in) |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusError, Raw: "stream: broken ERROR"}} |
| + c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20} |
| + |
| + v, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictFailed || wrote[TagVerdict] != string(VerdictFailed) { |
| + t.Fatalf("verdict = %q, tags = %v", v, wrote) |
| + } |
| +} |
| + |
| +// An infected object carries the verdict the store policy denies on. If these |
| +// two strings ever drift apart, enforcement silently stops matching anything. |
| +func TestClamAVInfectedVerdictMatchesEnforcementTag(t *testing.T) { |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("x5O!P%"), nil |
| + } |
| + var wrote map[string]string |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + wrote = tagsOf(in) |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Signature"}} |
| + c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20} |
| + |
| + if _, err := c.Scan(context.Background(), "b", "k", "e", 6); err != nil { |
| + t.Fatal(err) |
| + } |
| + deny := c.Enforcement() |
| + if wrote[deny.Key] != deny.Value { |
| + t.Fatalf("the tag written (%v) does not match the tag denied on (%+v)", wrote, deny) |
| + } |
| +} |
| + |
| +func taggingOutput(pairs ...string) *awss3.GetObjectTaggingOutput { |
| + var set []s3types.Tag |
| + for i := 0; i+1 < len(pairs); i += 2 { |
| + k, v := pairs[i], pairs[i+1] |
| + set = append(set, s3types.Tag{Key: &k, Value: &v}) |
| + } |
| + return &awss3.GetObjectTaggingOutput{TagSet: set} |
| +} |
| + |
| +// A verdict is bound to the bytes it judged. An object rewritten under the |
| +// same key reads as unknown, so the sweep scans it again — otherwise anyone |
| +// could launder a payload through a key that once held something clean. |
| +func TestClamAVVerdictIgnoresStaleETag(t *testing.T) { |
| + s3 := &stubScanS3{t: t} |
| + s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| + return taggingOutput(TagVerdict, "clean", TagETag, "old-etag"), nil |
| + } |
| + c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + |
| + v, err := c.Verdict(context.Background(), "b", "k", "new-etag") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictUnknown { |
| + t.Fatalf("a rewritten object inherited its old verdict: %q", v) |
| + } |
| +} |
| + |
| +// The listing gives a quoted ETag and the tag holds an unquoted one. If those |
| +// never compare equal, every object re-scans on every sweep forever — an |
| +// invisible bug that costs money rather than correctness. |
| +func TestClamAVVerdictMatchesAcrossETagQuoting(t *testing.T) { |
| + s3 := &stubScanS3{t: t} |
| + s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| + return taggingOutput(TagVerdict, "clean", TagETag, "d41d8cd9"), nil |
| + } |
| + c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + |
| + v, err := c.Verdict(context.Background(), "b", "k", `"d41d8cd9"`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictClean { |
| + t.Fatalf("verdict = %q, want clean — quoting broke the comparison", v) |
| + } |
| +} |
| + |
| +// An untagged object is unknown, not clean. Defaulting the other way would |
| +// report a bucket nobody has ever scanned as fully clean. |
| +func TestClamAVUntaggedObjectIsUnknown(t *testing.T) { |
| + s3 := &stubScanS3{t: t} |
| + s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| + return taggingOutput(), nil |
| + } |
| + c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + |
| + v, err := c.Verdict(context.Background(), "b", "k", "e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictUnknown { |
| + t.Fatalf("an object nobody scanned reported %q", v) |
| + } |
| +} |
| + |
| +// Nothing is attached to the bucket, so nothing is consumed and nothing leaks. |
| +// This is the whole reason ClamAV is the primary engine rather than the bought |
| +// alternative, whose per-bucket plans cap at 25 per account per Region. |
| +func TestClamAVAttachesNothingToTheBucket(t *testing.T) { |
| + ctx := context.Background() |
| + c := &clamavScanner{s3: &stubScanS3{t: t}, engine: &fakeEngine{}} |
| + |
| + if err := c.EnsureEnabled(ctx, "b"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.EnsureDisabled(ctx, "b"); err != nil { |
| + t.Fatal(err) |
| + } |
| + remaining, capacity, err := c.Capacity(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if remaining != -1 || capacity != -1 { |
| + t.Fatalf("capacity = %d/%d, want -1/-1 (unbounded)", remaining, capacity) |
| + } |
| +} |
| + |
| +// Provenance carries the signature database, not just the engine name. "Clean" |
| +// without a database version is not evidence of anything. |
| +func TestClamAVProvenanceCarriesSignatureVersion(t *testing.T) { |
| + eng := &fakeEngine{version: "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026"} |
| + c := &clamavScanner{s3: &stubScanS3{t: t}, engine: eng} |
| + |
| + engine, version, err := c.Provenance(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if engine != "clamav" { |
| + t.Fatalf("engine = %q", engine) |
| + } |
| + if !strings.Contains(version, "27500") { |
| + t.Fatalf("version = %q, want the signature database in it", version) |
| + } |
| +} |