| 1 | package objstore |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/aws/smithy-go" |
| 13 | |
| 14 | awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| 15 | s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/clamd" |
| 18 | ) |
| 19 | |
| 20 | // stubScanS3 implements the narrow scanS3API seam with per-method hooks, in |
| 21 | // the same style as stubS3. An un-set hook fails the test loudly rather than |
| 22 | // panicking on a nil func — several tests here exist precisely to assert that |
| 23 | // a call did NOT happen, and that only works if an unexpected call says so. |
| 24 | type stubScanS3 struct { |
| 25 | t *testing.T |
| 26 | |
| 27 | getObject func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) |
| 28 | headObject func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) |
| 29 | getTagging func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) |
| 30 | putTagging func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) |
| 31 | } |
| 32 | |
| 33 | func (s *stubScanS3) GetObject(_ context.Context, in *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) { |
| 34 | if s.getObject == nil { |
| 35 | s.t.Fatal("unexpected GetObject call — bytes were fetched that should not have been") |
| 36 | } |
| 37 | return s.getObject(in) |
| 38 | } |
| 39 | |
| 40 | func (s *stubScanS3) HeadObject(_ context.Context, in *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) { |
| 41 | if s.headObject == nil { |
| 42 | s.t.Fatal("unexpected HeadObject call") |
| 43 | } |
| 44 | return s.headObject(in) |
| 45 | } |
| 46 | |
| 47 | // headOK makes HeadObject describe an object of the given shape, which is what |
| 48 | // Status needs in order to know the CURRENT version. |
| 49 | func (s *stubScanS3) headOK(etag string, modified time.Time, size int64) { |
| 50 | s.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| 51 | return &awss3.HeadObjectOutput{ETag: &etag, LastModified: &modified, ContentLength: &size}, nil |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func (s *stubScanS3) GetObjectTagging(_ context.Context, in *awss3.GetObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.GetObjectTaggingOutput, error) { |
| 56 | if s.getTagging == nil { |
| 57 | s.t.Fatal("unexpected GetObjectTagging call") |
| 58 | } |
| 59 | return s.getTagging(in) |
| 60 | } |
| 61 | |
| 62 | func (s *stubScanS3) PutObjectTagging(_ context.Context, in *awss3.PutObjectTaggingInput, _ ...func(*awss3.Options)) (*awss3.PutObjectTaggingOutput, error) { |
| 63 | if s.putTagging == nil { |
| 64 | s.t.Fatal("unexpected PutObjectTagging call — a verdict was written that should not have been") |
| 65 | } |
| 66 | return s.putTagging(in) |
| 67 | } |
| 68 | |
| 69 | // fakeEngine stands in for clamd. The wire protocol has its own tests in |
| 70 | // internal/clamd; what matters here is how the scanner reacts to each answer. |
| 71 | type fakeEngine struct { |
| 72 | res clamd.Result |
| 73 | err error |
| 74 | version string |
| 75 | |
| 76 | calls int |
| 77 | read []byte |
| 78 | } |
| 79 | |
| 80 | func (f *fakeEngine) ScanStream(_ context.Context, r io.Reader) (clamd.Result, error) { |
| 81 | f.calls++ |
| 82 | f.read, _ = io.ReadAll(r) |
| 83 | if f.err != nil { |
| 84 | return clamd.Result{}, f.err |
| 85 | } |
| 86 | return f.res, nil |
| 87 | } |
| 88 | |
| 89 | func (f *fakeEngine) Version(_ context.Context) (string, error) { |
| 90 | if f.err != nil { |
| 91 | return "", f.err |
| 92 | } |
| 93 | return f.version, nil |
| 94 | } |
| 95 | |
| 96 | // allowTagRead lets tag()'s read-merge-write see an empty tag set. Scan now |
| 97 | // reads the existing tags before replacing them, so that a bucket CARLOS |
| 98 | // adopted does not lose tags someone else's tooling put there. |
| 99 | func (s *stubScanS3) allowTagRead(tags ...string) { |
| 100 | s.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 101 | return taggingOutput(tags...), nil |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func objectBody(s string) *awss3.GetObjectOutput { |
| 106 | return &awss3.GetObjectOutput{Body: io.NopCloser(strings.NewReader(s))} |
| 107 | } |
| 108 | |
| 109 | func tagsOf(in *awss3.PutObjectTaggingInput) map[string]string { |
| 110 | m := map[string]string{} |
| 111 | if in.Tagging == nil { |
| 112 | return m |
| 113 | } |
| 114 | for _, t := range in.Tagging.TagSet { |
| 115 | m[aws2s(t.Key)] = aws2s(t.Value) |
| 116 | } |
| 117 | return m |
| 118 | } |
| 119 | |
| 120 | // An object above the ceiling is tagged skipped WITHOUT a GetObject: paying |
| 121 | // egress to fetch bytes clamd will refuse is pure waste, and the size is |
| 122 | // already on the listing page. |
| 123 | func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) { |
| 124 | var wrote map[string]string |
| 125 | s3 := &stubScanS3{t: t} // getObject deliberately unset: calling it fails the test |
| 126 | s3.allowTagRead() |
| 127 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 128 | wrote = tagsOf(in) |
| 129 | return &awss3.PutObjectTaggingOutput{}, nil |
| 130 | } |
| 131 | eng := &fakeEngine{} |
| 132 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 10} |
| 133 | |
| 134 | v, _, err := c.Scan(context.Background(), "b", "big.iso", "abc", 11) |
| 135 | if err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | if v != VerdictSkipped { |
| 139 | t.Fatalf("verdict = %q, want skipped", v) |
| 140 | } |
| 141 | if eng.calls != 0 { |
| 142 | t.Fatal("an oversize object was streamed to the engine anyway") |
| 143 | } |
| 144 | if wrote[TagVerdict] != string(VerdictSkipped) { |
| 145 | t.Fatalf("tags = %v", wrote) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // A verdict is written with the etag it judged, so a later rewrite reads as |
| 150 | // unknown rather than inheriting the old verdict. |
| 151 | func TestClamAVTagsVerdictWithETag(t *testing.T) { |
| 152 | var wrote map[string]string |
| 153 | s3 := &stubScanS3{t: t} |
| 154 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 155 | return objectBody("hello"), nil |
| 156 | } |
| 157 | s3.allowTagRead() |
| 158 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 159 | wrote = tagsOf(in) |
| 160 | return &awss3.PutObjectTaggingOutput{}, nil |
| 161 | } |
| 162 | eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}} |
| 163 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| 164 | |
| 165 | v, _, err := c.Scan(context.Background(), "b", "k", "d41d8cd9@2026-09-01T10:00:00Z", 5) |
| 166 | if err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | if v != VerdictClean { |
| 170 | t.Fatalf("verdict = %q", v) |
| 171 | } |
| 172 | if wrote[TagVersion] != "d41d8cd9@2026-09-01T10:00:00Z" { |
| 173 | t.Fatalf("verdict is not bound to the object version it judged: %v", wrote) |
| 174 | } |
| 175 | if wrote[TagAt] == "" { |
| 176 | t.Fatalf("no scan time recorded: %v", wrote) |
| 177 | } |
| 178 | if !bytes.Equal(eng.read, []byte("hello")) { |
| 179 | t.Fatalf("engine saw %q", eng.read) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // clamd being unreachable is a PASS error, not thousands of failed tags. |
| 184 | // "The engine is unreachable" and "the engine read this object and failed on |
| 185 | // it" are different facts and only the second belongs in a tag. |
| 186 | func TestClamAVDialFailureWritesNoVerdict(t *testing.T) { |
| 187 | s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test |
| 188 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 189 | return objectBody("hello"), nil |
| 190 | } |
| 191 | eng := &fakeEngine{err: errors.New("dial unix /run/clamav/clamd.ctl: connection refused")} |
| 192 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| 193 | |
| 194 | v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| 195 | if err == nil { |
| 196 | t.Fatal("an unreachable engine reported success") |
| 197 | } |
| 198 | if v != VerdictUnknown { |
| 199 | t.Fatalf("verdict = %q, want unknown", v) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // A mid-stream size refusal maps to skipped, not failed. |
| 204 | func TestClamAVSizeExceededMapsToSkipped(t *testing.T) { |
| 205 | var wrote map[string]string |
| 206 | s3 := &stubScanS3{t: t} |
| 207 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 208 | return objectBody("hello"), nil |
| 209 | } |
| 210 | s3.allowTagRead() |
| 211 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 212 | wrote = tagsOf(in) |
| 213 | return &awss3.PutObjectTaggingOutput{}, nil |
| 214 | } |
| 215 | eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusSizeExceeded}} |
| 216 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 0} // no local ceiling: the engine's own |
| 217 | |
| 218 | v, _, err := c.Scan(context.Background(), "b", "k", "e", 999) |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | if v != VerdictSkipped { |
| 223 | t.Fatalf("verdict = %q, want skipped", v) |
| 224 | } |
| 225 | if wrote[TagVerdict] != string(VerdictSkipped) { |
| 226 | t.Fatalf("tags = %v", wrote) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // An engine ERROR on an object it did read IS a per-object fact, and does get |
| 231 | // recorded — the mirror of the unreachable case above. |
| 232 | func TestClamAVEngineErrorIsRecordedAsFailed(t *testing.T) { |
| 233 | var wrote map[string]string |
| 234 | s3 := &stubScanS3{t: t} |
| 235 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 236 | return objectBody("hello"), nil |
| 237 | } |
| 238 | s3.allowTagRead() |
| 239 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 240 | wrote = tagsOf(in) |
| 241 | return &awss3.PutObjectTaggingOutput{}, nil |
| 242 | } |
| 243 | eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusError, Raw: "stream: broken ERROR"}} |
| 244 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| 245 | |
| 246 | v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| 247 | if err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if v != VerdictFailed || wrote[TagVerdict] != string(VerdictFailed) { |
| 251 | t.Fatalf("verdict = %q, tags = %v", v, wrote) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // An infected object carries the verdict the store policy denies on. If these |
| 256 | // two strings ever drift apart, enforcement silently stops matching anything. |
| 257 | func TestClamAVInfectedVerdictMatchesEnforcementTag(t *testing.T) { |
| 258 | s3 := &stubScanS3{t: t} |
| 259 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 260 | return objectBody("x5O!P%"), nil |
| 261 | } |
| 262 | s3.allowTagRead() |
| 263 | var wrote map[string]string |
| 264 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 265 | wrote = tagsOf(in) |
| 266 | return &awss3.PutObjectTaggingOutput{}, nil |
| 267 | } |
| 268 | eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Signature"}} |
| 269 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| 270 | |
| 271 | if _, _, err := c.Scan(context.Background(), "b", "k", "e", 6); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | deny := c.Enforcement() |
| 275 | if wrote[deny.Key] != deny.Value { |
| 276 | t.Fatalf("the tag written (%v) does not match the tag denied on (%+v)", wrote, deny) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | func taggingOutput(pairs ...string) *awss3.GetObjectTaggingOutput { |
| 281 | var set []s3types.Tag |
| 282 | for i := 0; i+1 < len(pairs); i += 2 { |
| 283 | k, v := pairs[i], pairs[i+1] |
| 284 | set = append(set, s3types.Tag{Key: &k, Value: &v}) |
| 285 | } |
| 286 | return &awss3.GetObjectTaggingOutput{TagSet: set} |
| 287 | } |
| 288 | |
| 289 | // A verdict is bound to the bytes it judged. An object rewritten under the |
| 290 | // same key reads as unknown, so the sweep scans it again — otherwise anyone |
| 291 | // could launder a payload through a key that once held something clean. |
| 292 | func TestClamAVVerdictIgnoresStaleETag(t *testing.T) { |
| 293 | s3 := &stubScanS3{t: t} |
| 294 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 295 | return taggingOutput(TagVerdict, "clean", TagVersion, "old-etag"), nil |
| 296 | } |
| 297 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 298 | |
| 299 | v, err := c.Verdict(context.Background(), "b", "k", "new-etag") |
| 300 | if err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | if v != VerdictUnknown { |
| 304 | t.Fatalf("a rewritten object inherited its old verdict: %q", v) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // S3 hands back a QUOTED ETag. Normalisation happens once, where the version |
| 309 | // token is built, so a stored tag and a fresh listing compare equal. If they |
| 310 | // never did, every object would re-scan on every sweep forever — an invisible |
| 311 | // bug that costs money rather than correctness. |
| 312 | func TestScanObjectVersionNormalisesQuoting(t *testing.T) { |
| 313 | at := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) |
| 314 | quoted := ScanObject{Key: "k", ETag: `"d41d8cd9"`, Modified: at} |
| 315 | bare := ScanObject{Key: "k", ETag: "d41d8cd9", Modified: at} |
| 316 | if quoted.Version() != bare.Version() { |
| 317 | t.Fatalf("quoting changed the version token: %q vs %q", quoted.Version(), bare.Version()) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | // The version moves when the object is overwritten, even if the bytes hash to |
| 322 | // the same ETag. That is the whole point of folding last-modified in: a |
| 323 | // single-part upload's ETag is the content MD5, and chosen-prefix MD5 |
| 324 | // collisions are practical — an attacker controlling both uploads could give a |
| 325 | // payload the ETag of the clean object it replaces and inherit its verdict. |
| 326 | // Last-modified is not theirs to choose. |
| 327 | func TestScanObjectVersionChangesOnOverwriteWithCollidingETag(t *testing.T) { |
| 328 | etag := "d41d8cd9" // same hash, as a collision would give |
| 329 | before := ScanObject{Key: "k", ETag: etag, Modified: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)} |
| 330 | after := ScanObject{Key: "k", ETag: etag, Modified: time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC)} |
| 331 | if before.Version() == after.Version() { |
| 332 | t.Fatalf("an overwrite kept the same version token (%q) — the old verdict would be inherited", |
| 333 | before.Version()) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | // An untagged object is unknown, not clean. Defaulting the other way would |
| 338 | // report a bucket nobody has ever scanned as fully clean. |
| 339 | func TestClamAVUntaggedObjectIsUnknown(t *testing.T) { |
| 340 | s3 := &stubScanS3{t: t} |
| 341 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 342 | return taggingOutput(), nil |
| 343 | } |
| 344 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 345 | |
| 346 | v, err := c.Verdict(context.Background(), "b", "k", "e") |
| 347 | if err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | if v != VerdictUnknown { |
| 351 | t.Fatalf("an object nobody scanned reported %q", v) |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | // Nothing is attached to the bucket, so nothing is consumed and nothing leaks. |
| 356 | // This is the whole reason ClamAV is the primary engine rather than the bought |
| 357 | // alternative, whose per-bucket plans cap at 25 per account per Region. |
| 358 | func TestClamAVAttachesNothingToTheBucket(t *testing.T) { |
| 359 | ctx := context.Background() |
| 360 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: &stubScanS3{t: t}, engine: &fakeEngine{}} |
| 361 | |
| 362 | if err := c.EnsureEnabled(ctx, "b"); err != nil { |
| 363 | t.Fatal(err) |
| 364 | } |
| 365 | if err := c.EnsureDisabled(ctx, "b"); err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | remaining, capacity, err := c.Capacity(ctx) |
| 369 | if err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | if remaining != -1 || capacity != -1 { |
| 373 | t.Fatalf("capacity = %d/%d, want -1/-1 (unbounded)", remaining, capacity) |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | // Provenance carries the signature database, not just the engine name. "Clean" |
| 378 | // without a database version is not evidence of anything. |
| 379 | func TestClamAVProvenanceCarriesSignatureVersion(t *testing.T) { |
| 380 | eng := &fakeEngine{version: "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026"} |
| 381 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: &stubScanS3{t: t}, engine: eng} |
| 382 | |
| 383 | engine, version, err := c.Provenance(context.Background()) |
| 384 | if err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | if engine != "clamav" { |
| 388 | t.Fatalf("engine = %q", engine) |
| 389 | } |
| 390 | if !strings.Contains(version, "27500") { |
| 391 | t.Fatalf("version = %q, want the signature database in it", version) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // apiErr is an AWS API error with a given code, which is what isDenied and |
| 396 | // isGone classify on. |
| 397 | func apiErr(code string) error { |
| 398 | return &smithy.GenericAPIError{Code: code, Message: code} |
| 399 | } |
| 400 | |
| 401 | // An object we are not allowed to read is DENIED, and the sweep goes on. |
| 402 | // |
| 403 | // This is not tidiness. A plain error here fails the whole page unwritten; the |
| 404 | // next tick re-lists the same page, meets the same object and fails |
| 405 | // identically, and the bucket's sweep never advances past it. One SSE-KMS |
| 406 | // object outside the console's key policy would stall a store's coverage |
| 407 | // forever while every surface said "in progress". |
| 408 | func TestClamAVUnreadableObjectIsDeniedNotAPassFailure(t *testing.T) { |
| 409 | for _, code := range []string{"AccessDenied", "AccessDeniedException", "Forbidden", "InvalidObjectState"} { |
| 410 | t.Run(code, func(t *testing.T) { |
| 411 | var wrote map[string]string |
| 412 | s3 := &stubScanS3{t: t} |
| 413 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 414 | return nil, apiErr(code) |
| 415 | } |
| 416 | s3.allowTagRead() |
| 417 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 418 | wrote = tagsOf(in) |
| 419 | return &awss3.PutObjectTaggingOutput{}, nil |
| 420 | } |
| 421 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| 422 | |
| 423 | v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| 424 | if err != nil { |
| 425 | t.Fatalf("an unreadable object failed the pass: %v", err) |
| 426 | } |
| 427 | if v != VerdictDenied { |
| 428 | t.Fatalf("verdict = %q, want denied", v) |
| 429 | } |
| 430 | // The tag is an audit trace, not an answer: the next sweep |
| 431 | // re-issues denied rather than reading it back. See |
| 432 | // TestClamAVDeniedAndFailedAreReIssuedNotInherited. |
| 433 | if wrote[TagVerdict] != string(VerdictDenied) { |
| 434 | t.Fatalf("tags = %v", wrote) |
| 435 | } |
| 436 | }) |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | // An object deleted between the listing and the fetch is GONE — neither |
| 441 | // covered nor a gap. Routine on any bucket in use, and it must not stall the |
| 442 | // page either. |
| 443 | func TestClamAVDeletedObjectIsGone(t *testing.T) { |
| 444 | s3 := &stubScanS3{t: t} // putTagging unset: nothing may be written |
| 445 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 446 | return nil, apiErr("NoSuchKey") |
| 447 | } |
| 448 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| 449 | |
| 450 | if _, _, err := c.Scan(context.Background(), "b", "k", "e", 5); !errors.Is(err, ErrObjectGone) { |
| 451 | t.Fatalf("err = %v, want ErrObjectGone", err) |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | // Unreadable TAGS are denied too, by the same argument: Verdict is called for |
| 456 | // every object on every page, so an error there stalls just as hard. |
| 457 | func TestClamAVUnreadableTagsAreDenied(t *testing.T) { |
| 458 | s3 := &stubScanS3{t: t} |
| 459 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 460 | return nil, apiErr("AccessDenied") |
| 461 | } |
| 462 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 463 | |
| 464 | v, err := c.Verdict(context.Background(), "b", "k", "e") |
| 465 | if err != nil { |
| 466 | t.Fatalf("an unreadable tag set failed the pass: %v", err) |
| 467 | } |
| 468 | if v != VerdictDenied { |
| 469 | t.Fatalf("verdict = %q, want denied", v) |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | // A genuine transport failure is still a pass failure — the classification |
| 474 | // must not swallow everything. |
| 475 | func TestClamAVTransportErrorStillFailsThePass(t *testing.T) { |
| 476 | s3 := &stubScanS3{t: t} |
| 477 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 478 | return nil, apiErr("InternalError") |
| 479 | } |
| 480 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| 481 | |
| 482 | v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| 483 | if err == nil { |
| 484 | t.Fatal("a transport failure was recorded as a verdict") |
| 485 | } |
| 486 | if v != VerdictUnknown { |
| 487 | t.Fatalf("verdict = %q, want unknown", v) |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | // An adopted bucket's objects can carry tags written long before CARLOS ever |
| 492 | // scoped credentials to it — lifecycle rules, cost allocation. PutObjectTagging |
| 493 | // replaces the whole set, so those have to be carried across explicitly. |
| 494 | // Deleting a tenant's tags silently on first scan would be a real loss with no |
| 495 | // trace of what went. |
| 496 | func TestClamAVPreservesForeignTags(t *testing.T) { |
| 497 | var wrote map[string]string |
| 498 | s3 := &stubScanS3{t: t} |
| 499 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 500 | return objectBody("hello"), nil |
| 501 | } |
| 502 | s3.allowTagRead("cost-centre", "research", TagVerdict, "clean", TagVersion, "stale") |
| 503 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 504 | wrote = tagsOf(in) |
| 505 | return &awss3.PutObjectTaggingOutput{}, nil |
| 506 | } |
| 507 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| 508 | |
| 509 | if _, _, err := c.Scan(context.Background(), "b", "k", "fresh", 5); err != nil { |
| 510 | t.Fatal(err) |
| 511 | } |
| 512 | if wrote["cost-centre"] != "research" { |
| 513 | t.Fatalf("a tag this scanner does not own was destroyed: %v", wrote) |
| 514 | } |
| 515 | // Its own tags ARE replaced, not duplicated. |
| 516 | if wrote[TagVersion] != "fresh" { |
| 517 | t.Fatalf("the scanner did not overwrite its own stale etag tag: %v", wrote) |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | // denied and failed are RE-ISSUED every sweep, never carried forward. |
| 522 | // |
| 523 | // Both are transient — a permissions fix, a Glacier restore, an engine having |
| 524 | // a bad day. Reading them back as answers would make them permanent: the |
| 525 | // object is not rewritten, so its version never changes, so it would never be |
| 526 | // retried and the repair would never take. The SSE-KMS case is the one that |
| 527 | // bites, because tagging needs no KMS access and so the tag does land. |
| 528 | func TestClamAVDeniedAndFailedAreReIssuedNotInherited(t *testing.T) { |
| 529 | for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} { |
| 530 | t.Run(string(stuck), func(t *testing.T) { |
| 531 | sg := testSigner(t, "test-key", "") |
| 532 | s3 := &stubScanS3{t: t} |
| 533 | s3.allowTagRead(signedTagPairs(t, sg, "b", "k", "v1", stuck)...) |
| 534 | c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}} |
| 535 | |
| 536 | v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 537 | if err != nil { |
| 538 | t.Fatal(err) |
| 539 | } |
| 540 | if v != VerdictUnknown { |
| 541 | t.Fatalf("verdict = %q, want unknown — %q must be retried, not inherited", v, stuck) |
| 542 | } |
| 543 | }) |
| 544 | } |
| 545 | |
| 546 | // clean and infected ARE carried forward: they are answers about bytes |
| 547 | // that have not changed. |
| 548 | for _, keep := range []Verdict{VerdictClean, VerdictInfected, VerdictSkipped} { |
| 549 | t.Run(string(keep), func(t *testing.T) { |
| 550 | sg := testSigner(t, "test-key", "") |
| 551 | s3 := &stubScanS3{t: t} |
| 552 | s3.allowTagRead(signedTagPairs(t, sg, "b", "k", "v1", keep)...) |
| 553 | c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}} |
| 554 | v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 555 | if err != nil { |
| 556 | t.Fatal(err) |
| 557 | } |
| 558 | if v != keep { |
| 559 | t.Fatalf("verdict = %q, want %q carried forward", v, keep) |
| 560 | } |
| 561 | }) |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | // A tag set we cannot READ must never be written over. PutObjectTagging |
| 566 | // replaces the whole set, so writing blind here destroys exactly the foreign |
| 567 | // tags the merge exists to protect — on the one path where we know least about |
| 568 | // what is there. |
| 569 | func TestClamAVDoesNotBlindWriteWhenTagReadFails(t *testing.T) { |
| 570 | s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test |
| 571 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 572 | return objectBody("hello"), nil |
| 573 | } |
| 574 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 575 | return nil, apiErr("SlowDown") |
| 576 | } |
| 577 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| 578 | |
| 579 | if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err == nil { |
| 580 | t.Fatal("an unreadable tag set was written over anyway") |
| 581 | } |
| 582 | } |
| 583 | |
| 584 | // An object already carrying eight foreign tags cannot also hold our three — |
| 585 | // S3 caps an object at ten. Erroring there would abandon the page unwritten |
| 586 | // every tick forever, which is the exact permanent stall the denied |
| 587 | // classification was added to remove. Record the verdict, skip the write. |
| 588 | func TestClamAVSkipsTagWriteRatherThanStallAtTheTagLimit(t *testing.T) { |
| 589 | var foreign []string |
| 590 | for i := 0; i < 8; i++ { |
| 591 | foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| 592 | } |
| 593 | s3 := &stubScanS3{t: t} // putTagging unset: an 11-tag write fails the test |
| 594 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 595 | return objectBody("hello"), nil |
| 596 | } |
| 597 | s3.allowTagRead(foreign...) |
| 598 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| 599 | |
| 600 | v, _, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 601 | if err != nil { |
| 602 | t.Fatalf("a heavily tagged object stalled the sweep: %v", err) |
| 603 | } |
| 604 | if v != VerdictClean { |
| 605 | t.Fatalf("verdict = %q, want clean — it was scanned, just not recorded", v) |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | // Status finds the CURRENT version itself and hands back the object, so a |
| 610 | // caller holding only a key can go on to Scan without a HeadObject of its own. |
| 611 | // |
| 612 | // That second return is the whole point: Scan needs a version and a size, the |
| 613 | // on-demand drain has no listing page, and asking it to reconstruct the token |
| 614 | // would leak an internal format and hand it the laundering bug as a |
| 615 | // correctness bug. |
| 616 | func TestStatusReturnsObjectSoCallerCanScan(t *testing.T) { |
| 617 | mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) |
| 618 | s3 := &stubScanS3{t: t} |
| 619 | s3.headOK(`"d41d8cd9"`, mod, 4096) |
| 620 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 621 | return taggingOutput(), nil // no tag yet |
| 622 | } |
| 623 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 624 | |
| 625 | v, obj, err := c.Status(context.Background(), "b", "k") |
| 626 | if err != nil { |
| 627 | t.Fatal(err) |
| 628 | } |
| 629 | if v != VerdictUnknown { |
| 630 | t.Fatalf("verdict = %q, want unknown for an untagged object", v) |
| 631 | } |
| 632 | if obj.Size != 4096 || obj.ETag != "d41d8cd9" || !obj.Modified.Equal(mod) { |
| 633 | t.Fatalf("object = %+v — a caller cannot Scan without these", obj) |
| 634 | } |
| 635 | if obj.Version() == "" { |
| 636 | t.Fatal("no version token to scan against") |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | // Status reports what the tag SAYS. Verdict decides what the SWEEP does about |
| 641 | // it. Conflating them makes a failed scan read as pending forever: the drain |
| 642 | // deletes the item as scanned, and nothing would ever change the answer. |
| 643 | func TestStatusDoesNotApplyTheReIssueRule(t *testing.T) { |
| 644 | mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) |
| 645 | version := ScanObject{ETag: "abc", Modified: mod}.Version() |
| 646 | |
| 647 | for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} { |
| 648 | t.Run(string(stuck), func(t *testing.T) { |
| 649 | sg := testSigner(t, "test-key", "") |
| 650 | s3 := &stubScanS3{t: t} |
| 651 | s3.headOK("abc", mod, 10) |
| 652 | s3.allowTagRead(signedTagPairs(t, sg, "b", "k", version, stuck)...) |
| 653 | c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}} |
| 654 | |
| 655 | // Status: as written. |
| 656 | got, _, err := c.Status(context.Background(), "b", "k") |
| 657 | if err != nil { |
| 658 | t.Fatal(err) |
| 659 | } |
| 660 | if got != stuck { |
| 661 | t.Fatalf("Status = %q, want %q reported as itself", got, stuck) |
| 662 | } |
| 663 | |
| 664 | // Verdict: re-issued, so the sweep retries it. |
| 665 | sweepView, err := c.Verdict(context.Background(), "b", "k", version) |
| 666 | if err != nil { |
| 667 | t.Fatal(err) |
| 668 | } |
| 669 | if sweepView != VerdictUnknown { |
| 670 | t.Fatalf("Verdict = %q, want unknown so the sweep re-scans", sweepView) |
| 671 | } |
| 672 | }) |
| 673 | } |
| 674 | } |
| 675 | |
| 676 | // A tag about bytes that have since changed is not an answer about the bytes |
| 677 | // that are there now. |
| 678 | func TestStatusIgnoresStaleTag(t *testing.T) { |
| 679 | mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) |
| 680 | s3 := &stubScanS3{t: t} |
| 681 | s3.headOK("new-etag", mod, 10) |
| 682 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 683 | return taggingOutput(TagVerdict, "clean", |
| 684 | TagVersion, ScanObject{ETag: "old-etag", Modified: mod}.Version()), nil |
| 685 | } |
| 686 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 687 | |
| 688 | v, _, err := c.Status(context.Background(), "b", "k") |
| 689 | if err != nil { |
| 690 | t.Fatal(err) |
| 691 | } |
| 692 | if v != VerdictUnknown { |
| 693 | t.Fatalf("a rewritten object reported %q from its predecessor's tag", v) |
| 694 | } |
| 695 | |
| 696 | // THE case the version binding exists for, and the one the check above |
| 697 | // does not make: the ETag is IDENTICAL and only the timestamp moved. |
| 698 | // Single-part ETag is the content MD5, and chosen-prefix MD5 collisions |
| 699 | // are practical — so an attacker who can get one file scanned can upload a |
| 700 | // second with the same ETag and inherit its clean verdict. Comparing only |
| 701 | // the half before the "@" passes the first assertion and fails this one. |
| 702 | s3.headOK("same-etag", mod.Add(time.Hour), 10) |
| 703 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 704 | return taggingOutput(TagVerdict, "clean", |
| 705 | TagVersion, ScanObject{ETag: "same-etag", Modified: mod}.Version()), nil |
| 706 | } |
| 707 | v, _, err = c.Status(context.Background(), "b", "k") |
| 708 | if err != nil { |
| 709 | t.Fatal(err) |
| 710 | } |
| 711 | if v != VerdictUnknown { |
| 712 | t.Fatalf("an object rewritten with a colliding ETag reported %q — the timestamp half of the version is not being compared", v) |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | // A denied HeadObject leaves no version and no size, so the caller must not go |
| 717 | // on to Scan — the zero ScanObject is how it knows. |
| 718 | func TestStatusOnDeniedHead(t *testing.T) { |
| 719 | s3 := &stubScanS3{t: t} |
| 720 | s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| 721 | return nil, apiErr("AccessDenied") |
| 722 | } |
| 723 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 724 | |
| 725 | v, obj, err := c.Status(context.Background(), "b", "k") |
| 726 | if err != nil { |
| 727 | t.Fatal(err) |
| 728 | } |
| 729 | if v != VerdictDenied { |
| 730 | t.Fatalf("verdict = %q, want denied", v) |
| 731 | } |
| 732 | if obj != (ScanObject{}) { |
| 733 | t.Fatalf("object = %+v, want the zero value so nobody tries to scan it", obj) |
| 734 | } |
| 735 | } |
| 736 | |
| 737 | func TestStatusOnMissingObject(t *testing.T) { |
| 738 | s3 := &stubScanS3{t: t} |
| 739 | s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| 740 | return nil, apiErr("NotFound") |
| 741 | } |
| 742 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| 743 | |
| 744 | if _, _, err := c.Status(context.Background(), "b", "k"); !errors.Is(err, ErrObjectGone) { |
| 745 | t.Fatalf("err = %v, want ErrObjectGone", err) |
| 746 | } |
| 747 | } |
| 748 | |
| 749 | // A verdict reached but not persisted says so. Without the flag the on-demand |
| 750 | // drain would report clean, delete the item, and the status door would answer |
| 751 | // pending forever — the app's source of truth never arriving while a callback |
| 752 | // said otherwise. |
| 753 | func TestScanReportsUnrecordedAtTheTagLimit(t *testing.T) { |
| 754 | var foreign []string |
| 755 | for i := 0; i < 8; i++ { |
| 756 | foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| 757 | } |
| 758 | s3 := &stubScanS3{t: t} // putTagging unset: an 11-tag write fails the test |
| 759 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 760 | return objectBody("hello"), nil |
| 761 | } |
| 762 | s3.allowTagRead(foreign...) |
| 763 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| 764 | |
| 765 | v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 766 | if err != nil { |
| 767 | t.Fatal(err) |
| 768 | } |
| 769 | if v != VerdictClean { |
| 770 | t.Fatalf("verdict = %q, want clean — it WAS scanned", v) |
| 771 | } |
| 772 | if recorded { |
| 773 | t.Fatal("recorded = true, but nothing was written to the object") |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | // The ordinary path records, so `recorded` is not simply always false. |
| 778 | func TestScanReportsRecordedOnTheOrdinaryPath(t *testing.T) { |
| 779 | s3 := &stubScanS3{t: t} |
| 780 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 781 | return objectBody("hello"), nil |
| 782 | } |
| 783 | s3.allowTagRead() |
| 784 | s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 785 | return &awss3.PutObjectTaggingOutput{}, nil |
| 786 | } |
| 787 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| 788 | |
| 789 | v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 790 | if err != nil || v != VerdictClean || !recorded { |
| 791 | t.Fatalf("= %q, %v, %v", v, recorded, err) |
| 792 | } |
| 793 | } |
| 794 | |
| 795 | // A denied fetch is never "recorded", whether or not the best-effort tag |
| 796 | // landed: nothing may depend on a trace we could not verify. |
| 797 | func TestScanDeniedIsNeverRecorded(t *testing.T) { |
| 798 | s3 := &stubScanS3{t: t} |
| 799 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 800 | return nil, apiErr("AccessDenied") |
| 801 | } |
| 802 | s3.allowTagRead() |
| 803 | s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 804 | return &awss3.PutObjectTaggingOutput{}, nil |
| 805 | } |
| 806 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| 807 | |
| 808 | v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 809 | if err != nil || v != VerdictDenied { |
| 810 | t.Fatalf("= %q, %v", v, err) |
| 811 | } |
| 812 | if recorded { |
| 813 | t.Fatal("a denied object reported its verdict as recorded") |
| 814 | } |
| 815 | } |
| 816 | |
| 817 | // AN INFECTED VERDICT MUST ALWAYS BE RECORDABLE, AND FOREIGN TAGS DO NOT |
| 818 | // OUTRANK IT. |
| 819 | // |
| 820 | // The tag limit tolerance above is right for a clean verdict: the object is |
| 821 | // simply re-scanned next tick, and an app's own tags — which on an ADOPTED |
| 822 | // bucket may predate CARLOS entirely — are worth more than one avoided |
| 823 | // re-scan. For infected it is a bypass of the whole enforcement mechanism. |
| 824 | // |
| 825 | // `scan enforce on` writes a Deny keyed on `s3:ExistingObjectTag/carlos-scan = |
| 826 | // infected` (aws.go). That Deny only ever engages if the tag gets WRITTEN. An |
| 827 | // app that attaches eight tags of its own to an upload makes its object |
| 828 | // permanently unmarkable: the verdict is reached, the write is skipped, the |
| 829 | // Deny never matches, and the malware stays readable through the app's own |
| 830 | // credential. Nothing alarms — the sweep counts the object as reached, the |
| 831 | // result object says recorded:false, and the status door reads no tag and |
| 832 | // answers unknown. |
| 833 | // |
| 834 | // So: for infected, foreign tags are evicted to make room. That is a real |
| 835 | // cost to a tenant's metadata and it is the right trade, because the |
| 836 | // alternative is a known-infected object that enforcement cannot touch. |
| 837 | func TestInfectedIsRecordedEvenWhenForeignTagsCrowdItOut(t *testing.T) { |
| 838 | var foreign []string |
| 839 | for i := 0; i < 8; i++ { |
| 840 | foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| 841 | } |
| 842 | var written []s3types.Tag |
| 843 | s3 := &stubScanS3{t: t} |
| 844 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 845 | return objectBody("hello"), nil |
| 846 | } |
| 847 | s3.allowTagRead(foreign...) |
| 848 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 849 | written = in.Tagging.TagSet |
| 850 | return &awss3.PutObjectTaggingOutput{}, nil |
| 851 | } |
| 852 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), |
| 853 | s3: s3, |
| 854 | engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}}, |
| 855 | maxBytes: 1 << 20, |
| 856 | } |
| 857 | |
| 858 | v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 859 | if err != nil { |
| 860 | t.Fatal(err) |
| 861 | } |
| 862 | if v != VerdictInfected { |
| 863 | t.Fatalf("verdict = %q, want infected", v) |
| 864 | } |
| 865 | if !recorded { |
| 866 | t.Fatal("an infected verdict went unrecorded — `scan enforce on` denies on the TAG, so an object that cannot be tagged cannot be quarantined") |
| 867 | } |
| 868 | if len(written) == 0 { |
| 869 | t.Fatal("nothing was written to the object at all") |
| 870 | } |
| 871 | if len(written) > maxObjectTags { |
| 872 | t.Fatalf("wrote %d tags, over S3's limit of %d — the write would be rejected", len(written), maxObjectTags) |
| 873 | } |
| 874 | var gotVerdict string |
| 875 | for _, tg := range written { |
| 876 | if aws2s(tg.Key) == TagVerdict { |
| 877 | gotVerdict = aws2s(tg.Value) |
| 878 | } |
| 879 | } |
| 880 | if gotVerdict != string(VerdictInfected) { |
| 881 | t.Fatalf("the written set carries carlos-scan=%q, want infected:\n%v", gotVerdict, written) |
| 882 | } |
| 883 | } |
| 884 | |
| 885 | // The eviction is only as large as it needs to be: our three go in, and as |
| 886 | // many foreign tags as still fit are kept. Dropping all of them would be a |
| 887 | // simpler implementation and a gratuitous one — the object is a tenant's, and |
| 888 | // this takes the minimum the limit forces. |
| 889 | func TestInfectedEvictsOnlyAsManyForeignTagsAsItMustNot(t *testing.T) { |
| 890 | var foreign []string |
| 891 | for i := 0; i < 8; i++ { |
| 892 | foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| 893 | } |
| 894 | var written []s3types.Tag |
| 895 | s3 := &stubScanS3{t: t} |
| 896 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 897 | return objectBody("hello"), nil |
| 898 | } |
| 899 | s3.allowTagRead(foreign...) |
| 900 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 901 | written = in.Tagging.TagSet |
| 902 | return &awss3.PutObjectTaggingOutput{}, nil |
| 903 | } |
| 904 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), |
| 905 | s3: s3, |
| 906 | engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}}, |
| 907 | maxBytes: 1 << 20, |
| 908 | } |
| 909 | if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err != nil { |
| 910 | t.Fatal(err) |
| 911 | } |
| 912 | |
| 913 | var kept int |
| 914 | for _, tg := range written { |
| 915 | if !isScanTag(aws2s(tg.Key)) { |
| 916 | kept++ |
| 917 | } |
| 918 | } |
| 919 | // Ten minus however many WE own — derived, because that number went from |
| 920 | // three to four when the signature tag landed and a literal would have |
| 921 | // silently started asserting an eleven-tag write. |
| 922 | var owned int |
| 923 | for _, tg := range written { |
| 924 | if isScanTag(aws2s(tg.Key)) { |
| 925 | owned++ |
| 926 | } |
| 927 | } |
| 928 | if want := maxObjectTags - owned; kept != want { |
| 929 | t.Fatalf("kept %d foreign tags, want %d — evict the minimum the limit forces, not all of them:\n%v", kept, want, written) |
| 930 | } |
| 931 | } |
| 932 | |
| 933 | // And a CLEAN verdict still yields, which is the behaviour the two tests above |
| 934 | // this one pin. The split is deliberate: clean costs one re-scan, infected |
| 935 | // costs the enforcement Deny never engaging. |
| 936 | func TestCleanStillYieldsToForeignTagsAtTheLimit(t *testing.T) { |
| 937 | var foreign []string |
| 938 | for i := 0; i < 8; i++ { |
| 939 | foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| 940 | } |
| 941 | s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test |
| 942 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 943 | return objectBody("hello"), nil |
| 944 | } |
| 945 | s3.allowTagRead(foreign...) |
| 946 | c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| 947 | |
| 948 | v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 949 | if err != nil { |
| 950 | t.Fatal(err) |
| 951 | } |
| 952 | if v != VerdictClean || recorded { |
| 953 | t.Fatalf("clean = %q recorded=%v, want clean and unrecorded — a tenant's tags outrank one avoided re-scan", v, recorded) |
| 954 | } |
| 955 | } |
| 956 | |
| 957 | // THE FORGERY THE IAM DENYS CANNOT REACH, refused at the only place that |
| 958 | // matters: the read. |
| 959 | // |
| 960 | // A POST-policy form upload sets carlos-scan freely on MinIO with every Deny |
| 961 | // in place, because no IAM condition key describes a form upload. So the app |
| 962 | // CAN write these three tags. What it cannot do is make them mean anything: |
| 963 | // without a signature this deployment issued, readTag reports no verdict and |
| 964 | // the object is scanned like any other. |
| 965 | // |
| 966 | // This is the test that would have caught all four of the tagging defects at |
| 967 | // once, and it does not care which route the tag arrived by. |
| 968 | func TestForgedTagWithoutASignatureIsNotAVerdict(t *testing.T) { |
| 969 | s3 := &stubScanS3{t: t} |
| 970 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 971 | return objectBody("hello"), nil |
| 972 | } |
| 973 | s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| 974 | return &awss3.HeadObjectOutput{ETag: s2aws(`"etag1"`)}, nil |
| 975 | } |
| 976 | // Exactly what an app can write for itself, and nothing else. |
| 977 | s3.allowTagRead(TagVerdict, string(VerdictClean), TagVersion, "v1", TagAt, "2026-09-05T00:00:00Z") |
| 978 | c := &clamavScanner{ |
| 979 | s3: s3, |
| 980 | engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, |
| 981 | maxBytes: 1 << 20, |
| 982 | signer: testSigner(t, "deployment-key", ""), |
| 983 | } |
| 984 | |
| 985 | v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 986 | if err != nil { |
| 987 | t.Fatal(err) |
| 988 | } |
| 989 | if v != VerdictUnknown { |
| 990 | t.Fatalf("verdict = %q for an unsigned tag, want unknown — an app just marked its own object clean", v) |
| 991 | } |
| 992 | |
| 993 | // And Status, the door's read, must agree. Two readers of one tag that |
| 994 | // disagree about whether it is a verdict is the drift readTag exists to |
| 995 | // prevent. |
| 996 | st, _, err := c.Status(context.Background(), "b", "k") |
| 997 | if err != nil { |
| 998 | t.Fatal(err) |
| 999 | } |
| 1000 | if st != VerdictUnknown { |
| 1001 | t.Fatalf("Status = %q for an unsigned tag, want unknown", st) |
| 1002 | } |
| 1003 | } |
| 1004 | |
| 1005 | // A signature that is present but not ours — a random value, or one lifted |
| 1006 | // from a different object — is the same answer. |
| 1007 | func TestForgedSignatureIsNotAVerdict(t *testing.T) { |
| 1008 | signer := testSigner(t, "deployment-key", "") |
| 1009 | // A signature legitimately issued for a DIFFERENT object, copied across. |
| 1010 | lifted := signer.Sign(scanFacts{ |
| 1011 | bucket: "b", key: "other", version: "v1", |
| 1012 | verdict: string(VerdictClean), at: "2026-09-05T00:00:00Z", |
| 1013 | }) |
| 1014 | |
| 1015 | for _, tc := range []struct{ name, sig string }{ |
| 1016 | {"rubbish", "not-a-signature"}, |
| 1017 | {"another object's", lifted}, |
| 1018 | {"an empty value", ""}, |
| 1019 | } { |
| 1020 | t.Run(tc.name, func(t *testing.T) { |
| 1021 | s3 := &stubScanS3{t: t} |
| 1022 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 1023 | return objectBody("hello"), nil |
| 1024 | } |
| 1025 | s3.allowTagRead( |
| 1026 | TagVerdict, string(VerdictClean), |
| 1027 | TagVersion, "v1", |
| 1028 | TagAt, "2026-09-05T00:00:00Z", |
| 1029 | TagSignature, tc.sig, |
| 1030 | ) |
| 1031 | c := &clamavScanner{ |
| 1032 | s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, |
| 1033 | maxBytes: 1 << 20, signer: signer, |
| 1034 | } |
| 1035 | v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 1036 | if err != nil { |
| 1037 | t.Fatal(err) |
| 1038 | } |
| 1039 | if v != VerdictUnknown { |
| 1040 | t.Fatalf("verdict = %q, want unknown", v) |
| 1041 | } |
| 1042 | }) |
| 1043 | } |
| 1044 | } |
| 1045 | |
| 1046 | // The positive half: a verdict this scanner wrote is read back and trusted, so |
| 1047 | // the whole mechanism does not simply refuse everything. Without this the two |
| 1048 | // tests above are satisfied by a readTag that always returns unknown. |
| 1049 | func TestAVerdictThisScannerWroteIsTrusted(t *testing.T) { |
| 1050 | signer := testSigner(t, "deployment-key", "") |
| 1051 | var written []s3types.Tag |
| 1052 | s3 := &stubScanS3{t: t} |
| 1053 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 1054 | return objectBody("hello"), nil |
| 1055 | } |
| 1056 | s3.allowTagRead() |
| 1057 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 1058 | written = in.Tagging.TagSet |
| 1059 | return &awss3.PutObjectTaggingOutput{}, nil |
| 1060 | } |
| 1061 | c := &clamavScanner{ |
| 1062 | s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, |
| 1063 | maxBytes: 1 << 20, signer: signer, |
| 1064 | } |
| 1065 | |
| 1066 | v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| 1067 | if err != nil || v != VerdictClean || !recorded { |
| 1068 | t.Fatalf("Scan = %q recorded=%v err=%v", v, recorded, err) |
| 1069 | } |
| 1070 | if len(written) != 4 { |
| 1071 | t.Fatalf("wrote %d tags, want four — the verdict, the version, the time and the signature over them:\n%v", len(written), written) |
| 1072 | } |
| 1073 | |
| 1074 | // Read it back through the real path: what was written IS what the next |
| 1075 | // read sees, which is the round trip the whole mechanism depends on. |
| 1076 | s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| 1077 | return &awss3.GetObjectTaggingOutput{TagSet: written}, nil |
| 1078 | } |
| 1079 | got, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 1080 | if err != nil { |
| 1081 | t.Fatal(err) |
| 1082 | } |
| 1083 | if got != VerdictClean { |
| 1084 | t.Fatalf("reading back a verdict this scanner just wrote = %q, want clean", got) |
| 1085 | } |
| 1086 | } |
| 1087 | |
| 1088 | // ROTATION. A verdict signed under the previous key is still a verdict — the |
| 1089 | // alternative is that rotating the key rescans every object in every store at |
| 1090 | // once — and the sweep re-signs it under the current key so the old one can |
| 1091 | // eventually be dropped. |
| 1092 | // |
| 1093 | // The re-sign must NOT move carlos-scan-at. That timestamp is when the bytes |
| 1094 | // were judged, and a rotation judges nothing; moving it would silently make |
| 1095 | // every verdict look freshly scanned and destroy the one field an auditor |
| 1096 | // reads to ask "how old is this answer". |
| 1097 | func TestRotationResignsWithoutRescanningOrMovingTheTimestamp(t *testing.T) { |
| 1098 | old := testSigner(t, "old-key", "") |
| 1099 | rotated := testSigner(t, "new-key", "old-key") |
| 1100 | |
| 1101 | const at = "2026-09-05T00:00:00Z" |
| 1102 | stale := old.Sign(scanFacts{ |
| 1103 | bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at, |
| 1104 | }) |
| 1105 | |
| 1106 | var written []s3types.Tag |
| 1107 | s3 := &stubScanS3{t: t} |
| 1108 | s3.allowTagRead( |
| 1109 | TagVerdict, string(VerdictClean), |
| 1110 | TagVersion, "v1", |
| 1111 | TagAt, at, |
| 1112 | TagSignature, stale, |
| 1113 | ) |
| 1114 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 1115 | written = in.Tagging.TagSet |
| 1116 | return &awss3.PutObjectTaggingOutput{}, nil |
| 1117 | } |
| 1118 | // getObject is deliberately unset: reaching the engine at all fails the |
| 1119 | // test, which is how "without rescanning" is asserted rather than assumed. |
| 1120 | c := &clamavScanner{s3: s3, engine: nil, maxBytes: 1 << 20, signer: rotated} |
| 1121 | |
| 1122 | v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 1123 | if err != nil { |
| 1124 | t.Fatal(err) |
| 1125 | } |
| 1126 | if v != VerdictClean { |
| 1127 | t.Fatalf("verdict = %q under the previous key, want clean — a rotation must not invalidate stored verdicts", v) |
| 1128 | } |
| 1129 | if written == nil { |
| 1130 | t.Fatal("nothing was re-signed, so the previous key could never be dropped") |
| 1131 | } |
| 1132 | |
| 1133 | var gotAt, gotSig string |
| 1134 | for _, tg := range written { |
| 1135 | switch aws2s(tg.Key) { |
| 1136 | case TagAt: |
| 1137 | gotAt = aws2s(tg.Value) |
| 1138 | case TagSignature: |
| 1139 | gotSig = aws2s(tg.Value) |
| 1140 | } |
| 1141 | } |
| 1142 | if gotAt != at { |
| 1143 | t.Fatalf("carlos-scan-at moved to %q — a re-sign judges nothing and must not claim a fresh scan", gotAt) |
| 1144 | } |
| 1145 | if gotSig == stale { |
| 1146 | t.Fatal("the signature was rewritten unchanged, still under the old key") |
| 1147 | } |
| 1148 | if ok, wasStale := rotated.Verify(scanFacts{ |
| 1149 | bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at, |
| 1150 | }, gotSig); !ok || wasStale { |
| 1151 | t.Fatalf("the re-signed value does not verify under the current key (ok=%v stale=%v)", ok, wasStale) |
| 1152 | } |
| 1153 | } |
| 1154 | |
| 1155 | // A re-sign that cannot be written must not fail the sweep. The verdict is |
| 1156 | // still valid under the previous key; the worst case is that it is re-signed |
| 1157 | // on a later tick, and failing the page over it would stall every object |
| 1158 | // behind this one. |
| 1159 | func TestAFailedResignDoesNotFailTheSweep(t *testing.T) { |
| 1160 | old := testSigner(t, "old-key", "") |
| 1161 | rotated := testSigner(t, "new-key", "old-key") |
| 1162 | const at = "2026-09-05T00:00:00Z" |
| 1163 | |
| 1164 | s3 := &stubScanS3{t: t} |
| 1165 | s3.allowTagRead( |
| 1166 | TagVerdict, string(VerdictClean), TagVersion, "v1", TagAt, at, |
| 1167 | TagSignature, old.Sign(scanFacts{bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at}), |
| 1168 | ) |
| 1169 | s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 1170 | return nil, errors.New("the bucket had a bad moment") |
| 1171 | } |
| 1172 | c := &clamavScanner{s3: s3, engine: nil, maxBytes: 1 << 20, signer: rotated} |
| 1173 | |
| 1174 | v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| 1175 | if err != nil { |
| 1176 | t.Fatalf("a failed re-sign failed the sweep: %v", err) |
| 1177 | } |
| 1178 | if v != VerdictClean { |
| 1179 | t.Fatalf("verdict = %q, want clean", v) |
| 1180 | } |
| 1181 | } |
| 1182 | |
| 1183 | // Four owned tags, not three, so the room left for a tenant's own is six. |
| 1184 | // 63e80cc computed this from a hardcoded 3; a fourth tag silently made that |
| 1185 | // wrong in the direction of writing an eleven-tag set S3 would reject. |
| 1186 | func TestInfectedEvictionAccountsForTheSignatureTag(t *testing.T) { |
| 1187 | var foreign []string |
| 1188 | for i := 0; i < 8; i++ { |
| 1189 | foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| 1190 | } |
| 1191 | var written []s3types.Tag |
| 1192 | s3 := &stubScanS3{t: t} |
| 1193 | s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| 1194 | return objectBody("hello"), nil |
| 1195 | } |
| 1196 | s3.allowTagRead(foreign...) |
| 1197 | s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| 1198 | written = in.Tagging.TagSet |
| 1199 | return &awss3.PutObjectTaggingOutput{}, nil |
| 1200 | } |
| 1201 | c := &clamavScanner{ |
| 1202 | s3: s3, |
| 1203 | engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}}, |
| 1204 | maxBytes: 1 << 20, |
| 1205 | signer: testSigner(t, "deployment-key", ""), |
| 1206 | } |
| 1207 | if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err != nil { |
| 1208 | t.Fatal(err) |
| 1209 | } |
| 1210 | if len(written) > maxObjectTags { |
| 1211 | t.Fatalf("wrote %d tags, over S3's limit of %d", len(written), maxObjectTags) |
| 1212 | } |
| 1213 | var kept int |
| 1214 | for _, tg := range written { |
| 1215 | if !isScanTag(aws2s(tg.Key)) { |
| 1216 | kept++ |
| 1217 | } |
| 1218 | } |
| 1219 | if want := maxObjectTags - 4; kept != want { |
| 1220 | t.Fatalf("kept %d foreign tags, want %d — four owned tags now, not three:\n%v", kept, want, written) |
| 1221 | } |
| 1222 | } |
| 1223 | |
| 1224 | // The signature tag is the scanner's own, so a re-scan replaces it rather than |
| 1225 | // treating it as a tenant's and preserving it. A stale signature left beside a |
| 1226 | // fresh verdict is a verdict that will not verify. |
| 1227 | func TestSignatureTagIsOwnedByTheScanner(t *testing.T) { |
| 1228 | if !isScanTag(TagSignature) { |
| 1229 | t.Fatal("the signature tag is not in isScanTag — a re-scan would preserve the old one beside a new verdict") |
| 1230 | } |
| 1231 | } |
| 1232 | |
| 1233 | // signedTagPairs is a tag set as the scanner itself would have written it: |
| 1234 | // verdict, version, time, and a signature over all of it. |
| 1235 | // |
| 1236 | // Every test that asserts on how a verdict READS has to build one of these |
| 1237 | // now. An unsigned set is not a weaker verdict, it is not a verdict — which is |
| 1238 | // the entire point of the mechanism, and is why hand-written tag sets stopped |
| 1239 | // working the moment signing landed. |
| 1240 | func signedTagPairs(t *testing.T, sg *TagSigner, bucket, key, version string, v Verdict) []string { |
| 1241 | t.Helper() |
| 1242 | const at = "2026-09-05T00:00:00Z" |
| 1243 | return []string{ |
| 1244 | TagVerdict, string(v), |
| 1245 | TagVersion, version, |
| 1246 | TagAt, at, |
| 1247 | TagSignature, sg.Sign(scanFacts{ |
| 1248 | bucket: bucket, key: key, version: version, verdict: string(v), at: at, |
| 1249 | }), |
| 1250 | } |
| 1251 | } |
| 1252 | |