objstore: a verdict is a signature, not a tag
Discussion #19, and the end of a run of four defects in one property.
The design said the app cannot write the carlos-scan tag, enforced by
keeping s3:PutObjectTagging out of its IAM policy. Four times now that
has turned out not to be the property: x-amz-tagging on PutObject,
eight junk tags crowding ours out, multipart, copy-with-REPLACE — and
finally a browser-direct POST-policy form upload, which MinIO permits
with every Deny in place because no IAM condition key describes a form
upload. There is nothing left to tighten. The tool does not reach.
SO STOP TRYING TO MAKE THE TAG UNWRITABLE AND MAKE IT UNFORGEABLE. Every
verdict now carries an HMAC over the object, its version, the verdict
and the moment it was judged, keyed by a secret only the console holds.
readSignedTag is the single trust boundary — Verdict and Status both go
through it — and a tag that does not verify comes back as NO VERDICT AT
ALL. Not a suspicious one to be handled carefully somewhere else: the
same answer as a bare object, producing the same behaviour, which is
scan it. An app can still write the bytes. It cannot make them mean
anything.
That closes the four routes found, the ones not found, and the ones a
future S3 implementation adds. The IAM Denys stay, demoted to defence in
depth, which is the job they were always suited to.
A SEPARATE ROOT FROM THE CALLBACK KEY, on the review's argument rather
than my first instinct, which was one master with purpose-separated
derivation. The lifetimes differ and that decides it: callback keys
authenticate deliveries that are over in seconds and can rotate the
moment every app is resealed; tag keys authenticate durable records that
may sit on objects for years. One root means a rotation for callback
reasons either invalidates every stored verdict or forces a tag
migration. Sharing would have been defensible and needlessly coupled.
Details that are load-bearing rather than fussy:
THE ENCODING IS LENGTH-PREFIXED, NOT DELIMITED. An S3 object key may
contain any byte a delimiter could be, so with a delimiter an app that
controls its own keys can make two different (bucket, key, version,
verdict, at) tuples serialise identically — and then a signature issued
for one object is valid for another.
Rotation accepts the previous key, and the sweep re-signs a still-valid
verdict under the current one WITHOUT rescanning and WITHOUT moving
carlos-scan-at. That timestamp is when the bytes were judged; a re-sign
judges nothing, and moving it would make every verdict look freshly
scanned and destroy the field an auditor reads to ask how old the answer
is. A re-sign that fails logs and moves on rather than abandoning the
page.
NewTagSigner refuses an empty current key, and refuses a previous key
equal to the current one — a rotation that rotated to itself, which
would leave an operator believing a key had been retired. The console is
fatal at boot when clamd is wired and the key is not: a scanner that
cannot sign would read every forgery as genuine and report it as clean,
which is strictly worse than no scanner, because no scanner reports
nothing and this reports the attacker's answer.
isScanTag owns the signature, so a re-scan replaces it rather than
preserving a stale one beside a fresh verdict. The infected-eviction
budget from 63e80cc is now derived from the tag set rather than the
literal 3, which the fourth tag had silently made wrong in the direction
of writing an eleven-tag set S3 would reject.
Seven mutations, all caught. Two of them are worth recording because
they were wrong about my own work first:
- "length prefixes become a constant" — a fixed four-byte delimiter,
exactly the bug the encoding exists to prevent — SURVIVED. The test
only tried single-character separators. A test against "a delimiter"
has to mean any delimiter; one written against the separator the
author imagined proves nothing about the others. It now tries
multi-byte separators including the zeroed prefix, plus a general
boundary-shift property across every adjacent field pair.
- three mutations first reported BUILD FAILED, including the one that
matters most: verification running and its answer being ignored. A
build failure is not a caught mutation. Redone as compiling
mutations, both forgery tests fail on it.
Thirty existing scanner constructions gained a signer, and every test
that asserts on how a verdict READS now builds a signed tag set through
signedTagPairs. That is not ceremony: an unsigned set is not a weaker
verdict, it is not a verdict, and a test hand-writing one was asserting
against a shape the code no longer accepts.
Locally failing, all `listen unix ...: socket: operation not permitted`
in the sandbox: internal/activator, internal/edge, internal/host. Named
rather than counted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEkJh1ESqiA84quAHJV4K6
5 files changed,
+888
−54
cmd/console/main.go+25 −1internal/objstore/clamav.go+96 −12internal/objstore/clamav_test.go+342 −41internal/objstore/scantag.go+163 −0internal/objstore/scantag_test.go+262 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index dce737fd5..b8642dc85 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -778,7 +778,31 @@ func storeScannerFor(d objstore.Deployment) (objstore.Scanner, bool) { |
| log.Fatal("console: a clamd address is configured but CARLOS_STORE_BACKEND is not — there are no object stores on this deployment to scan") |
| } |
| maxBytes := envBytes("CARLOS_CLAMD_MAX_BYTES", 25<<20) |
| - sc, err := objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, addr, maxBytes) |
| + |
| + // THE TAG KEY IS NOT OPTIONAL, and a missing one is fatal here rather than |
| + // a scanner that runs without it. |
| + // |
| + // A verdict is only worth anything because it carries a signature this |
| + // deployment issued: on several S3 implementations an app can write the |
| + // carlos-scan tag on its own object whatever the IAM policy says (a |
| + // browser-direct POST-policy upload does it on MinIO), so authenticity is |
| + // the property and not write-permission. A scanner with no key would write |
| + // verdicts nothing can verify and read every forgery as genuine — strictly |
| + // worse than no scanner at all, because it would report them as clean. |
| + // |
| + // Fatal, and at boot: an operator who has wired clamd and not the key gets |
| + // one line naming the variable, before any tenant byte is read. |
| + signer, err := objstore.NewTagSigner( |
| + os.Getenv("CARLOS_SCAN_TAG_KEY"), |
| + os.Getenv("CARLOS_SCAN_TAG_KEY_PREVIOUS"), |
| + ) |
| + if err != nil { |
| + log.Fatalf("console: a clamd address is configured but the scan tag key is not usable: %v — "+ |
| + "generate one (32 random bytes is plenty), put it in the deployment's secret store as "+ |
| + "CARLOS_SCAN_TAG_KEY, and keep it: every stored verdict is signed with it", err) |
| + } |
| + |
| + sc, err := objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, addr, maxBytes, signer) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/internal/objstore/clamav.go b/internal/objstore/clamav.go| index 0d1b30628..a90aa1337 100644 |
| --- a/internal/objstore/clamav.go |
| +++ b/internal/objstore/clamav.go |
| @@ -49,6 +49,11 @@ type clamavScanner struct { |
| s3 scanS3API |
| engine streamScanner |
| maxBytes int64 |
| + // signer is what makes a tag a verdict. See scantag.go: the app can write |
| + // these tags on some gateways whatever the IAM policy says, so authenticity |
| + // and not write-permission is the property. Never nil in a running |
| + // deployment — the console refuses to construct a scanner without one. |
| + signer *TagSigner |
| } |
| var _ Scanner = (*clamavScanner)(nil) |
| @@ -63,7 +68,13 @@ var _ Scanner = (*clamavScanner)(nil) |
| // because a scanner is restarting would be a far worse outage than the one it |
| // prevents. An unreachable engine surfaces through Provenance, which is what |
| // `carlos store scan status` reports, and fails the pass loudly per tick. |
| -func NewClamAVScanner(ctx context.Context, region, endpoint, socket string, maxBytes int64) (Scanner, error) { |
| +func NewClamAVScanner(ctx context.Context, region, endpoint, socket string, maxBytes int64, signer *TagSigner) (Scanner, error) { |
| + if signer == nil { |
| + // A scanner with no signer would write verdicts nothing can verify and |
| + // read every forgery as genuine. Refuse at construction, where an |
| + // operator sees it, rather than at the first tag. |
| + return nil, ErrNoTagKey |
| + } |
| s3c, err := S3ClientFor(ctx, region, endpoint) |
| if err != nil { |
| return nil, fmt.Errorf("objstore: clamav scanner: %w", err) |
| @@ -72,6 +83,7 @@ func NewClamAVScanner(ctx context.Context, region, endpoint, socket string, maxB |
| s3: s3c, |
| engine: clamd.New(socket, clamd.Timeouts{}), |
| maxBytes: maxBytes, |
| + signer: signer, |
| }, nil |
| } |
| @@ -121,7 +133,7 @@ func (c *clamavScanner) Enforcement() DenyTag { |
| // choose. A tag whose recorded version does not match reads as unknown, and |
| // the sweep scans it again. |
| func (c *clamavScanner) Verdict(ctx context.Context, bucket, key, version string) (Verdict, error) { |
| - verdict, taggedVersion, err := c.readTag(ctx, bucket, key) |
| + verdict, taggedVersion, at, stale, err := c.readSignedTag(ctx, bucket, key) |
| if err != nil { |
| return VerdictUnknown, err |
| } |
| @@ -130,7 +142,27 @@ func (c *clamavScanner) Verdict(ctx context.Context, bucket, key, version string |
| // transport failure, and it must not fail the pass. |
| return VerdictDenied, nil |
| } |
| - return c.interpretForSweep(verdict, taggedVersion, version), nil |
| + out := c.interpretForSweep(verdict, taggedVersion, version) |
| + if stale && out == verdict && verdict != "" { |
| + // A verdict issued under the previous key, still valid, still about |
| + // these bytes. Re-sign it so the old key can eventually be dropped — |
| + // WITHOUT rescanning, and WITHOUT moving carlos-scan-at, because a |
| + // re-sign judges nothing and must not claim a fresh scan. |
| + c.resign(ctx, bucket, key, taggedVersion, verdict, at) |
| + } |
| + return out, nil |
| +} |
| + |
| +// resign rewrites the signature of an existing verdict under the current key. |
| +// |
| +// Best effort, deliberately. The verdict is already valid under the previous |
| +// key, so the cost of failing is that it is re-signed on some later tick; |
| +// returning an error here would abandon the whole page over a housekeeping |
| +// write and stall every object behind this one. |
| +func (c *clamavScanner) resign(ctx context.Context, bucket, key, version string, v Verdict, at string) { |
| + if _, err := c.tagWith(ctx, bucket, key, version, v, at); err != nil { |
| + log.Printf("objstore: could not re-sign %s/%s under the current tag key (it stays valid under the previous one): %v", bucket, key, err) |
| + } |
| } |
| // readTag is the one place tags are read, shared by Verdict and Status so the |
| @@ -140,30 +172,62 @@ func (c *clamavScanner) Verdict(ctx context.Context, bucket, key, version string |
| // A denied read comes back as (VerdictDenied, "", nil): unreadable tags are a |
| // fact about the object. |
| func (c *clamavScanner) readTag(ctx context.Context, bucket, key string) (Verdict, string, error) { |
| + v, ver, _, _, err := c.readSignedTag(ctx, bucket, key) |
| + return v, ver, err |
| +} |
| + |
| +// readSignedTag is readTag with the two extra facts the re-signing path needs: |
| +// the recorded timestamp, and whether the signature that vouched for all of it |
| +// was issued under the PREVIOUS key. |
| +// |
| +// THIS IS THE TRUST BOUNDARY. Everything above it — Verdict, Status, the |
| +// sweep, the drain, the status door — is reading a claim that has already been |
| +// checked against the deployment's key. An unsigned or badly signed tag does |
| +// not come back as a suspicious verdict to be handled carefully somewhere |
| +// else; it comes back as no verdict at all, which is the same answer as a bare |
| +// object and produces the same behaviour: scan it. |
| +func (c *clamavScanner) readSignedTag(ctx context.Context, bucket, key string) (verdict Verdict, version, at string, stale bool, err error) { |
| out, err := c.s3.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{ |
| Bucket: &bucket, |
| Key: &key, |
| }) |
| if err != nil { |
| if isGone(err) { |
| - return VerdictUnknown, "", ErrObjectGone |
| + return VerdictUnknown, "", "", false, ErrObjectGone |
| } |
| if isDenied(err) { |
| - return VerdictDenied, "", nil |
| + return VerdictDenied, "", "", false, nil |
| } |
| - return VerdictUnknown, "", fmt.Errorf("objstore: read tags %s/%s: %w", bucket, key, err) |
| + return VerdictUnknown, "", "", false, fmt.Errorf("objstore: read tags %s/%s: %w", bucket, key, err) |
| } |
| - var verdict, taggedVersion string |
| + var raw, sig string |
| for _, t := range out.TagSet { |
| switch aws2s(t.Key) { |
| case TagVerdict: |
| - verdict = aws2s(t.Value) |
| + raw = aws2s(t.Value) |
| case TagVersion: |
| - taggedVersion = aws2s(t.Value) |
| + version = aws2s(t.Value) |
| + case TagAt: |
| + at = aws2s(t.Value) |
| + case TagSignature: |
| + sig = aws2s(t.Value) |
| } |
| } |
| - return Verdict(verdict), taggedVersion, nil |
| + if raw == "" { |
| + return "", "", "", false, nil |
| + } |
| + ok, stale := c.signer.Verify(scanFacts{ |
| + bucket: bucket, key: key, version: version, verdict: raw, at: at, |
| + }, sig) |
| + if !ok { |
| + // Somebody else wrote this. On several gateways an app CAN, whatever |
| + // the IAM policy says, so this is the expected shape of an attack and |
| + // not an anomaly to shout about. Report exactly what a bare object |
| + // reports. |
| + return "", "", "", false, nil |
| + } |
| + return Verdict(raw), version, at, stale, nil |
| } |
| // interpretForSweep applies the SWEEP's reading of a tag: unknown when it does |
| @@ -337,10 +401,25 @@ func verdictFor(s clamd.Status) Verdict { |
| // that is still using them for lifecycle rules or cost allocation. Replacing |
| // the set blindly would delete them, silently, on first scan. |
| func (c *clamavScanner) tag(ctx context.Context, bucket, key, version string, v Verdict) (recorded bool, err error) { |
| + // The timestamp is minted HERE, once, and threaded through — it goes in a |
| + // tag and into the signature over it, and two calls to time.Now() would |
| + // produce a signature over a moment the tag does not record. |
| + return c.tagWith(ctx, bucket, key, version, v, time.Now().UTC().Format(time.RFC3339)) |
| +} |
| + |
| +// tagWith writes a verdict stamped with a GIVEN time, so that re-signing an |
| +// existing verdict after a key rotation can keep the moment the bytes were |
| +// actually judged. tag() supplies now; resign() supplies what was already |
| +// there. |
| +func (c *clamavScanner) tagWith(ctx context.Context, bucket, key, version string, v Verdict, at string) (recorded bool, err error) { |
| scanTagSet := []s3types.Tag{ |
| {Key: s2aws(TagVerdict), Value: s2aws(string(v))}, |
| {Key: s2aws(TagVersion), Value: s2aws(version)}, |
| - {Key: s2aws(TagAt), Value: s2aws(time.Now().UTC().Format(time.RFC3339))}, |
| + {Key: s2aws(TagAt), Value: s2aws(at)}, |
| + // The one that makes the other three mean something. |
| + {Key: s2aws(TagSignature), Value: s2aws(c.signer.Sign(scanFacts{ |
| + bucket: bucket, key: key, version: version, verdict: string(v), at: at, |
| + }))}, |
| } |
| // OURS FIRST, so that truncating to the limit below drops foreign tags |
| // and never one of the three this whole mechanism exists to write. |
| @@ -405,6 +484,11 @@ func (c *clamavScanner) tag(ctx context.Context, bucket, key, version string, v |
| } |
| log.Printf("objstore: %s/%s is infected and carries %d foreign tags — evicting %d of them so the verdict can be recorded and enforcement can see it", |
| bucket, key, len(set)-len(scanTagSet), len(set)-maxObjectTags) |
| + // set[:maxObjectTags] keeps OUR tags, which are first, and as many |
| + // foreign ones as still fit. The room left for a tenant is |
| + // maxObjectTags-len(scanTagSet) — derived, because it changed from |
| + // three to four the moment the signature tag landed and a hardcoded |
| + // number would have quietly started writing an eleven-tag set. |
| set = set[:maxObjectTags] |
| } |
| @@ -424,7 +508,7 @@ const maxObjectTags = 10 |
| // isScanTag is the set this scanner owns and may overwrite. Everything else on |
| // the object belongs to whoever put it there. |
| func isScanTag(key string) bool { |
| - return key == TagVerdict || key == TagVersion || key == TagAt |
| + return key == TagVerdict || key == TagVersion || key == TagAt || key == TagSignature |
| } |
| // normalizeETag strips the quotes S3 wraps an ETag in. The listing and the tag |
diff --git a/internal/objstore/clamav_test.go b/internal/objstore/clamav_test.go| index 3c4207fa2..44f886d15 100644 |
| --- a/internal/objstore/clamav_test.go |
| +++ b/internal/objstore/clamav_test.go |
| @@ -129,7 +129,7 @@ func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) { |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| eng := &fakeEngine{} |
| - c := &clamavScanner{s3: s3, engine: eng, maxBytes: 10} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 10} |
| v, _, err := c.Scan(context.Background(), "b", "big.iso", "abc", 11) |
| if err != nil { |
| @@ -160,7 +160,7 @@ func TestClamAVTagsVerdictWithETag(t *testing.T) { |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}} |
| - c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| v, _, err := c.Scan(context.Background(), "b", "k", "d41d8cd9@2026-09-01T10:00:00Z", 5) |
| if err != nil { |
| @@ -189,7 +189,7 @@ func TestClamAVDialFailureWritesNoVerdict(t *testing.T) { |
| 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} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| if err == nil { |
| @@ -213,7 +213,7 @@ func TestClamAVSizeExceededMapsToSkipped(t *testing.T) { |
| 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 |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), 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 { |
| @@ -241,7 +241,7 @@ func TestClamAVEngineErrorIsRecordedAsFailed(t *testing.T) { |
| 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} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| if err != nil { |
| @@ -266,7 +266,7 @@ func TestClamAVInfectedVerdictMatchesEnforcementTag(t *testing.T) { |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| eng := &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Signature"}} |
| - c := &clamavScanner{s3: s3, engine: eng, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: eng, maxBytes: 1 << 20} |
| if _, _, err := c.Scan(context.Background(), "b", "k", "e", 6); err != nil { |
| t.Fatal(err) |
| @@ -294,7 +294,7 @@ func TestClamAVVerdictIgnoresStaleETag(t *testing.T) { |
| s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| return taggingOutput(TagVerdict, "clean", TagVersion, "old-etag"), nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| v, err := c.Verdict(context.Background(), "b", "k", "new-etag") |
| if err != nil { |
| @@ -341,7 +341,7 @@ func TestClamAVUntaggedObjectIsUnknown(t *testing.T) { |
| s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| return taggingOutput(), nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| v, err := c.Verdict(context.Background(), "b", "k", "e") |
| if err != nil { |
| @@ -357,7 +357,7 @@ func TestClamAVUntaggedObjectIsUnknown(t *testing.T) { |
| // 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{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: &stubScanS3{t: t}, engine: &fakeEngine{}} |
| if err := c.EnsureEnabled(ctx, "b"); err != nil { |
| t.Fatal(err) |
| @@ -378,7 +378,7 @@ func TestClamAVAttachesNothingToTheBucket(t *testing.T) { |
| // 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} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: &stubScanS3{t: t}, engine: eng} |
| engine, version, err := c.Provenance(context.Background()) |
| if err != nil { |
| @@ -418,7 +418,7 @@ func TestClamAVUnreadableObjectIsDeniedNotAPassFailure(t *testing.T) { |
| wrote = tagsOf(in) |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| if err != nil { |
| @@ -445,7 +445,7 @@ func TestClamAVDeletedObjectIsGone(t *testing.T) { |
| s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| return nil, apiErr("NoSuchKey") |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| if _, _, err := c.Scan(context.Background(), "b", "k", "e", 5); !errors.Is(err, ErrObjectGone) { |
| t.Fatalf("err = %v, want ErrObjectGone", err) |
| @@ -459,7 +459,7 @@ func TestClamAVUnreadableTagsAreDenied(t *testing.T) { |
| s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| return nil, apiErr("AccessDenied") |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| v, err := c.Verdict(context.Background(), "b", "k", "e") |
| if err != nil { |
| @@ -477,7 +477,7 @@ func TestClamAVTransportErrorStillFailsThePass(t *testing.T) { |
| s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| return nil, apiErr("InternalError") |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| v, _, err := c.Scan(context.Background(), "b", "k", "e", 5) |
| if err == nil { |
| @@ -504,7 +504,7 @@ func TestClamAVPreservesForeignTags(t *testing.T) { |
| wrote = tagsOf(in) |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| if _, _, err := c.Scan(context.Background(), "b", "k", "fresh", 5); err != nil { |
| t.Fatal(err) |
| @@ -528,11 +528,10 @@ func TestClamAVPreservesForeignTags(t *testing.T) { |
| func TestClamAVDeniedAndFailedAreReIssuedNotInherited(t *testing.T) { |
| for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} { |
| t.Run(string(stuck), func(t *testing.T) { |
| + sg := testSigner(t, "test-key", "") |
| s3 := &stubScanS3{t: t} |
| - s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| - return taggingOutput(TagVerdict, string(stuck), TagVersion, "v1"), nil |
| - } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + s3.allowTagRead(signedTagPairs(t, sg, "b", "k", "v1", stuck)...) |
| + c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}} |
| v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| if err != nil { |
| @@ -548,11 +547,10 @@ func TestClamAVDeniedAndFailedAreReIssuedNotInherited(t *testing.T) { |
| // that have not changed. |
| for _, keep := range []Verdict{VerdictClean, VerdictInfected, VerdictSkipped} { |
| t.Run(string(keep), func(t *testing.T) { |
| + sg := testSigner(t, "test-key", "") |
| s3 := &stubScanS3{t: t} |
| - s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| - return taggingOutput(TagVerdict, string(keep), TagVersion, "v1"), nil |
| - } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + s3.allowTagRead(signedTagPairs(t, sg, "b", "k", "v1", keep)...) |
| + c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}} |
| v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| if err != nil { |
| t.Fatal(err) |
| @@ -576,7 +574,7 @@ func TestClamAVDoesNotBlindWriteWhenTagReadFails(t *testing.T) { |
| s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| return nil, apiErr("SlowDown") |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err == nil { |
| t.Fatal("an unreadable tag set was written over anyway") |
| @@ -597,7 +595,7 @@ func TestClamAVSkipsTagWriteRatherThanStallAtTheTagLimit(t *testing.T) { |
| return objectBody("hello"), nil |
| } |
| s3.allowTagRead(foreign...) |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| v, _, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| if err != nil { |
| @@ -622,7 +620,7 @@ func TestStatusReturnsObjectSoCallerCanScan(t *testing.T) { |
| s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| return taggingOutput(), nil // no tag yet |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| v, obj, err := c.Status(context.Background(), "b", "k") |
| if err != nil { |
| @@ -648,12 +646,11 @@ func TestStatusDoesNotApplyTheReIssueRule(t *testing.T) { |
| for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} { |
| t.Run(string(stuck), func(t *testing.T) { |
| + sg := testSigner(t, "test-key", "") |
| s3 := &stubScanS3{t: t} |
| s3.headOK("abc", mod, 10) |
| - s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| - return taggingOutput(TagVerdict, string(stuck), TagVersion, version), nil |
| - } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + s3.allowTagRead(signedTagPairs(t, sg, "b", "k", version, stuck)...) |
| + c := &clamavScanner{signer: sg, s3: s3, engine: &fakeEngine{}} |
| // Status: as written. |
| got, _, err := c.Status(context.Background(), "b", "k") |
| @@ -686,7 +683,7 @@ func TestStatusIgnoresStaleTag(t *testing.T) { |
| return taggingOutput(TagVerdict, "clean", |
| TagVersion, ScanObject{ETag: "old-etag", Modified: mod}.Version()), nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| v, _, err := c.Status(context.Background(), "b", "k") |
| if err != nil { |
| @@ -723,7 +720,7 @@ func TestStatusOnDeniedHead(t *testing.T) { |
| s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| return nil, apiErr("AccessDenied") |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| v, obj, err := c.Status(context.Background(), "b", "k") |
| if err != nil { |
| @@ -742,7 +739,7 @@ func TestStatusOnMissingObject(t *testing.T) { |
| s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| return nil, apiErr("NotFound") |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}} |
| if _, _, err := c.Status(context.Background(), "b", "k"); !errors.Is(err, ErrObjectGone) { |
| t.Fatalf("err = %v, want ErrObjectGone", err) |
| @@ -763,7 +760,7 @@ func TestScanReportsUnrecordedAtTheTagLimit(t *testing.T) { |
| return objectBody("hello"), nil |
| } |
| s3.allowTagRead(foreign...) |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| if err != nil { |
| @@ -787,7 +784,7 @@ func TestScanReportsRecordedOnTheOrdinaryPath(t *testing.T) { |
| s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| if err != nil || v != VerdictClean || !recorded { |
| @@ -806,7 +803,7 @@ func TestScanDeniedIsNeverRecorded(t *testing.T) { |
| s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{}, maxBytes: 1 << 20} |
| v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| if err != nil || v != VerdictDenied { |
| @@ -852,7 +849,7 @@ func TestInfectedIsRecordedEvenWhenForeignTagsCrowdItOut(t *testing.T) { |
| written = in.Tagging.TagSet |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| - c := &clamavScanner{ |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), |
| s3: s3, |
| engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}}, |
| maxBytes: 1 << 20, |
| @@ -904,7 +901,7 @@ func TestInfectedEvictsOnlyAsManyForeignTagsAsItMustNot(t *testing.T) { |
| written = in.Tagging.TagSet |
| return &awss3.PutObjectTaggingOutput{}, nil |
| } |
| - c := &clamavScanner{ |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), |
| s3: s3, |
| engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}}, |
| maxBytes: 1 << 20, |
| @@ -919,8 +916,16 @@ func TestInfectedEvictsOnlyAsManyForeignTagsAsItMustNot(t *testing.T) { |
| kept++ |
| } |
| } |
| - // Ten minus our three. |
| - if want := maxObjectTags - 3; kept != want { |
| + // Ten minus however many WE own — derived, because that number went from |
| + // three to four when the signature tag landed and a literal would have |
| + // silently started asserting an eleven-tag write. |
| + var owned int |
| + for _, tg := range written { |
| + if isScanTag(aws2s(tg.Key)) { |
| + owned++ |
| + } |
| + } |
| + if want := maxObjectTags - owned; kept != want { |
| t.Fatalf("kept %d foreign tags, want %d — evict the minimum the limit forces, not all of them:\n%v", kept, want, written) |
| } |
| } |
| @@ -938,7 +943,7 @@ func TestCleanStillYieldsToForeignTagsAtTheLimit(t *testing.T) { |
| return objectBody("hello"), nil |
| } |
| s3.allowTagRead(foreign...) |
| - c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| + c := &clamavScanner{signer: testSigner(t, "test-key", ""), s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20} |
| v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| if err != nil { |
| @@ -948,3 +953,299 @@ func TestCleanStillYieldsToForeignTagsAtTheLimit(t *testing.T) { |
| t.Fatalf("clean = %q recorded=%v, want clean and unrecorded — a tenant's tags outrank one avoided re-scan", v, recorded) |
| } |
| } |
| + |
| +// THE FORGERY THE IAM DENYS CANNOT REACH, refused at the only place that |
| +// matters: the read. |
| +// |
| +// A POST-policy form upload sets carlos-scan freely on MinIO with every Deny |
| +// in place, because no IAM condition key describes a form upload. So the app |
| +// CAN write these three tags. What it cannot do is make them mean anything: |
| +// without a signature this deployment issued, readTag reports no verdict and |
| +// the object is scanned like any other. |
| +// |
| +// This is the test that would have caught all four of the tagging defects at |
| +// once, and it does not care which route the tag arrived by. |
| +func TestForgedTagWithoutASignatureIsNotAVerdict(t *testing.T) { |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.headObject = func(*awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) { |
| + return &awss3.HeadObjectOutput{ETag: s2aws(`"etag1"`)}, nil |
| + } |
| + // Exactly what an app can write for itself, and nothing else. |
| + s3.allowTagRead(TagVerdict, string(VerdictClean), TagVersion, "v1", TagAt, "2026-09-05T00:00:00Z") |
| + c := &clamavScanner{ |
| + s3: s3, |
| + engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, |
| + maxBytes: 1 << 20, |
| + signer: testSigner(t, "deployment-key", ""), |
| + } |
| + |
| + v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictUnknown { |
| + t.Fatalf("verdict = %q for an unsigned tag, want unknown — an app just marked its own object clean", v) |
| + } |
| + |
| + // And Status, the door's read, must agree. Two readers of one tag that |
| + // disagree about whether it is a verdict is the drift readTag exists to |
| + // prevent. |
| + st, _, err := c.Status(context.Background(), "b", "k") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st != VerdictUnknown { |
| + t.Fatalf("Status = %q for an unsigned tag, want unknown", st) |
| + } |
| +} |
| + |
| +// A signature that is present but not ours — a random value, or one lifted |
| +// from a different object — is the same answer. |
| +func TestForgedSignatureIsNotAVerdict(t *testing.T) { |
| + signer := testSigner(t, "deployment-key", "") |
| + // A signature legitimately issued for a DIFFERENT object, copied across. |
| + lifted := signer.Sign(scanFacts{ |
| + bucket: "b", key: "other", version: "v1", |
| + verdict: string(VerdictClean), at: "2026-09-05T00:00:00Z", |
| + }) |
| + |
| + for _, tc := range []struct{ name, sig string }{ |
| + {"rubbish", "not-a-signature"}, |
| + {"another object's", lifted}, |
| + {"an empty value", ""}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.allowTagRead( |
| + TagVerdict, string(VerdictClean), |
| + TagVersion, "v1", |
| + TagAt, "2026-09-05T00:00:00Z", |
| + TagSignature, tc.sig, |
| + ) |
| + c := &clamavScanner{ |
| + s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, |
| + maxBytes: 1 << 20, signer: signer, |
| + } |
| + v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictUnknown { |
| + t.Fatalf("verdict = %q, want unknown", v) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The positive half: a verdict this scanner wrote is read back and trusted, so |
| +// the whole mechanism does not simply refuse everything. Without this the two |
| +// tests above are satisfied by a readTag that always returns unknown. |
| +func TestAVerdictThisScannerWroteIsTrusted(t *testing.T) { |
| + signer := testSigner(t, "deployment-key", "") |
| + var written []s3types.Tag |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.allowTagRead() |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + written = in.Tagging.TagSet |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + c := &clamavScanner{ |
| + s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, |
| + maxBytes: 1 << 20, signer: signer, |
| + } |
| + |
| + v, recorded, err := c.Scan(context.Background(), "b", "k", "v1", 5) |
| + if err != nil || v != VerdictClean || !recorded { |
| + t.Fatalf("Scan = %q recorded=%v err=%v", v, recorded, err) |
| + } |
| + if len(written) != 4 { |
| + t.Fatalf("wrote %d tags, want four — the verdict, the version, the time and the signature over them:\n%v", len(written), written) |
| + } |
| + |
| + // Read it back through the real path: what was written IS what the next |
| + // read sees, which is the round trip the whole mechanism depends on. |
| + s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) { |
| + return &awss3.GetObjectTaggingOutput{TagSet: written}, nil |
| + } |
| + got, err := c.Verdict(context.Background(), "b", "k", "v1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != VerdictClean { |
| + t.Fatalf("reading back a verdict this scanner just wrote = %q, want clean", got) |
| + } |
| +} |
| + |
| +// ROTATION. A verdict signed under the previous key is still a verdict — the |
| +// alternative is that rotating the key rescans every object in every store at |
| +// once — and the sweep re-signs it under the current key so the old one can |
| +// eventually be dropped. |
| +// |
| +// The re-sign must NOT move carlos-scan-at. That timestamp is when the bytes |
| +// were judged, and a rotation judges nothing; moving it would silently make |
| +// every verdict look freshly scanned and destroy the one field an auditor |
| +// reads to ask "how old is this answer". |
| +func TestRotationResignsWithoutRescanningOrMovingTheTimestamp(t *testing.T) { |
| + old := testSigner(t, "old-key", "") |
| + rotated := testSigner(t, "new-key", "old-key") |
| + |
| + const at = "2026-09-05T00:00:00Z" |
| + stale := old.Sign(scanFacts{ |
| + bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at, |
| + }) |
| + |
| + var written []s3types.Tag |
| + s3 := &stubScanS3{t: t} |
| + s3.allowTagRead( |
| + TagVerdict, string(VerdictClean), |
| + TagVersion, "v1", |
| + TagAt, at, |
| + TagSignature, stale, |
| + ) |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + written = in.Tagging.TagSet |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + // getObject is deliberately unset: reaching the engine at all fails the |
| + // test, which is how "without rescanning" is asserted rather than assumed. |
| + c := &clamavScanner{s3: s3, engine: nil, maxBytes: 1 << 20, signer: rotated} |
| + |
| + v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if v != VerdictClean { |
| + t.Fatalf("verdict = %q under the previous key, want clean — a rotation must not invalidate stored verdicts", v) |
| + } |
| + if written == nil { |
| + t.Fatal("nothing was re-signed, so the previous key could never be dropped") |
| + } |
| + |
| + var gotAt, gotSig string |
| + for _, tg := range written { |
| + switch aws2s(tg.Key) { |
| + case TagAt: |
| + gotAt = aws2s(tg.Value) |
| + case TagSignature: |
| + gotSig = aws2s(tg.Value) |
| + } |
| + } |
| + if gotAt != at { |
| + t.Fatalf("carlos-scan-at moved to %q — a re-sign judges nothing and must not claim a fresh scan", gotAt) |
| + } |
| + if gotSig == stale { |
| + t.Fatal("the signature was rewritten unchanged, still under the old key") |
| + } |
| + if ok, wasStale := rotated.Verify(scanFacts{ |
| + bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at, |
| + }, gotSig); !ok || wasStale { |
| + t.Fatalf("the re-signed value does not verify under the current key (ok=%v stale=%v)", ok, wasStale) |
| + } |
| +} |
| + |
| +// A re-sign that cannot be written must not fail the sweep. The verdict is |
| +// still valid under the previous key; the worst case is that it is re-signed |
| +// on a later tick, and failing the page over it would stall every object |
| +// behind this one. |
| +func TestAFailedResignDoesNotFailTheSweep(t *testing.T) { |
| + old := testSigner(t, "old-key", "") |
| + rotated := testSigner(t, "new-key", "old-key") |
| + const at = "2026-09-05T00:00:00Z" |
| + |
| + s3 := &stubScanS3{t: t} |
| + s3.allowTagRead( |
| + TagVerdict, string(VerdictClean), TagVersion, "v1", TagAt, at, |
| + TagSignature, old.Sign(scanFacts{bucket: "b", key: "k", version: "v1", verdict: string(VerdictClean), at: at}), |
| + ) |
| + s3.putTagging = func(*awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + return nil, errors.New("the bucket had a bad moment") |
| + } |
| + c := &clamavScanner{s3: s3, engine: nil, maxBytes: 1 << 20, signer: rotated} |
| + |
| + v, err := c.Verdict(context.Background(), "b", "k", "v1") |
| + if err != nil { |
| + t.Fatalf("a failed re-sign failed the sweep: %v", err) |
| + } |
| + if v != VerdictClean { |
| + t.Fatalf("verdict = %q, want clean", v) |
| + } |
| +} |
| + |
| +// Four owned tags, not three, so the room left for a tenant's own is six. |
| +// 63e80cc computed this from a hardcoded 3; a fourth tag silently made that |
| +// wrong in the direction of writing an eleven-tag set S3 would reject. |
| +func TestInfectedEvictionAccountsForTheSignatureTag(t *testing.T) { |
| + var foreign []string |
| + for i := 0; i < 8; i++ { |
| + foreign = append(foreign, "own-"+string(rune('a'+i)), "v") |
| + } |
| + var written []s3types.Tag |
| + s3 := &stubScanS3{t: t} |
| + s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) { |
| + return objectBody("hello"), nil |
| + } |
| + s3.allowTagRead(foreign...) |
| + s3.putTagging = func(in *awss3.PutObjectTaggingInput) (*awss3.PutObjectTaggingOutput, error) { |
| + written = in.Tagging.TagSet |
| + return &awss3.PutObjectTaggingOutput{}, nil |
| + } |
| + c := &clamavScanner{ |
| + s3: s3, |
| + engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusInfected, Signature: "Eicar-Test"}}, |
| + maxBytes: 1 << 20, |
| + signer: testSigner(t, "deployment-key", ""), |
| + } |
| + if _, _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(written) > maxObjectTags { |
| + t.Fatalf("wrote %d tags, over S3's limit of %d", len(written), maxObjectTags) |
| + } |
| + var kept int |
| + for _, tg := range written { |
| + if !isScanTag(aws2s(tg.Key)) { |
| + kept++ |
| + } |
| + } |
| + if want := maxObjectTags - 4; kept != want { |
| + t.Fatalf("kept %d foreign tags, want %d — four owned tags now, not three:\n%v", kept, want, written) |
| + } |
| +} |
| + |
| +// The signature tag is the scanner's own, so a re-scan replaces it rather than |
| +// treating it as a tenant's and preserving it. A stale signature left beside a |
| +// fresh verdict is a verdict that will not verify. |
| +func TestSignatureTagIsOwnedByTheScanner(t *testing.T) { |
| + if !isScanTag(TagSignature) { |
| + t.Fatal("the signature tag is not in isScanTag — a re-scan would preserve the old one beside a new verdict") |
| + } |
| +} |
| + |
| +// signedTagPairs is a tag set as the scanner itself would have written it: |
| +// verdict, version, time, and a signature over all of it. |
| +// |
| +// Every test that asserts on how a verdict READS has to build one of these |
| +// now. An unsigned set is not a weaker verdict, it is not a verdict — which is |
| +// the entire point of the mechanism, and is why hand-written tag sets stopped |
| +// working the moment signing landed. |
| +func signedTagPairs(t *testing.T, sg *TagSigner, bucket, key, version string, v Verdict) []string { |
| + t.Helper() |
| + const at = "2026-09-05T00:00:00Z" |
| + return []string{ |
| + TagVerdict, string(v), |
| + TagVersion, version, |
| + TagAt, at, |
| + TagSignature, sg.Sign(scanFacts{ |
| + bucket: bucket, key: key, version: version, verdict: string(v), at: at, |
| + }), |
| + } |
| +} |
diff --git a/internal/objstore/scantag.go b/internal/objstore/scantag.go| new file mode 100644 |
| index 000000000..e0cb98fa9 |
| --- /dev/null |
| +++ b/internal/objstore/scantag.go |
| @@ -0,0 +1,163 @@ |
| +package objstore |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/binary" |
| + "encoding/hex" |
| + "errors" |
| + "strings" |
| +) |
| + |
| +// Signed verdict tags: why the security property is authenticity and not |
| +// write-permission. |
| +// |
| +// The design began with "the app cannot write the carlos-scan tag", enforced |
| +// by keeping s3:PutObjectTagging out of the app's IAM policy. That rule was |
| +// necessary, correctly enforced, and — three times over — not the property. |
| +// There are several ways to attach a tag to an object, and the IAM condition |
| +// key that covers them (s3:RequestObjectTagKeys) is not populated by every |
| +// gateway for every route. A browser-direct POST-policy upload against MinIO |
| +// sets carlos-scan freely with the Deny in place; no condition key describes |
| +// "this request is a form upload", so IAM cannot be made to reach it. |
| +// |
| +// SO WE STOP TRYING TO MAKE THE TAG UNWRITABLE AND MAKE IT UNFORGEABLE. Every |
| +// verdict carries an HMAC over the facts it asserts, keyed by a secret only |
| +// the console holds. A tag that does not verify is not a verdict — it reads as |
| +// unknown, and the object is scanned. An app can still write the bytes; it |
| +// cannot make them mean anything. |
| +// |
| +// This is gateway-independent, which is the point: it closes the routes we |
| +// found, the ones we did not, and the ones a future S3 implementation adds. |
| +// The IAM Denys stay, demoted to defence in depth, which is the job they were |
| +// always suited to. |
| +// |
| +// WHAT IT DOES NOT DO. An app can still forge `infected` on its own object and |
| +// have `scan enforce on` deny reads of it. That is a tenant harming only |
| +// itself with a credential it already holds, and it is not worth a second |
| +// mechanism. |
| + |
| +// TagSignature is the fourth tag: the one that makes the other three mean |
| +// something. It is in isScanTag, so the scanner owns and replaces it. |
| +const TagSignature = "carlos-scan-sig" |
| + |
| +// sigFormat is the version prefix on the tag value. A format change gets a new |
| +// prefix rather than a new parser guess: `v2:...` read by this build is an |
| +// unknown signature, which reads as no verdict, which rescans. Safe direction. |
| +const sigFormat = "v1" |
| + |
| +// scanFacts is exactly what a verdict asserts, and therefore exactly what the |
| +// signature covers. Anything a reader will later trust has to be in here — a |
| +// field left out is a field an attacker may vary freely while keeping a |
| +// legitimately issued signature. |
| +type scanFacts struct { |
| + bucket string |
| + key string |
| + version string |
| + verdict string |
| + at string |
| +} |
| + |
| +// TagSigner signs and verifies verdict tags. It holds the deployment's tag |
| +// key, and optionally the previous one so a rotation does not invalidate every |
| +// stored verdict at once. |
| +type TagSigner struct { |
| + current tagKey |
| + previous *tagKey |
| +} |
| + |
| +type tagKey struct { |
| + secret []byte |
| + id string |
| +} |
| + |
| +// ErrNoTagKey is what a deployment gets for asking to scan without a key to |
| +// sign verdicts with. It is a refusal and not a fallback: signing with nothing |
| +// would verify every forgery ever written. |
| +var ErrNoTagKey = errors.New("objstore: no scan tag key (CARLOS_SCAN_TAG_KEY)") |
| + |
| +// NewTagSigner builds the signer from the deployment's current key and, |
| +// optionally, the previous one. Both come from the environment and neither |
| +// ever leaves the console — unlike the callback key, which is derived per app |
| +// and delivered, this one is never given to anybody. |
| +func NewTagSigner(current, previous string) (*TagSigner, error) { |
| + if strings.TrimSpace(current) == "" { |
| + return nil, ErrNoTagKey |
| + } |
| + if previous != "" && previous == current { |
| + // A rotation that rotated to itself. Accepting it would leave an |
| + // operator believing a key had been retired while every signature |
| + // still verifies under it. |
| + return nil, errors.New("objstore: the previous scan tag key is identical to the current one — that is not a rotation") |
| + } |
| + s := &TagSigner{current: newTagKey(current)} |
| + if previous != "" { |
| + p := newTagKey(previous) |
| + s.previous = &p |
| + } |
| + return s, nil |
| +} |
| + |
| +// newTagKey derives the public identifier for a key. It is a domain-separated |
| +// hash rather than any part of the key itself, so a tag can name which key |
| +// signed it without the tag becoming a place the key leaks. |
| +func newTagKey(secret string) tagKey { |
| + h := hmac.New(sha256.New, []byte("carlos-scan-tag-key-id")) |
| + h.Write([]byte(secret)) |
| + return tagKey{secret: []byte(secret), id: hex.EncodeToString(h.Sum(nil))[:16]} |
| +} |
| + |
| +// Sign returns the value for the TagSignature tag: v1:<key-id>:<mac>. |
| +func (s *TagSigner) Sign(f scanFacts) string { |
| + return sigFormat + ":" + s.current.id + ":" + hex.EncodeToString(mac(s.current.secret, f)) |
| +} |
| + |
| +// Verify reports whether sig is a signature this deployment issued over f, and |
| +// whether it was issued under the PREVIOUS key — which is valid, and wants |
| +// re-signing so the old key can eventually be dropped. |
| +// |
| +// Every failure mode is the same answer: false. A malformed value, an unknown |
| +// key id, a truncated MAC and a deliberate forgery are indistinguishable to a |
| +// caller and should be, because the response to all of them is identical — |
| +// treat it as no verdict and scan the object. |
| +func (s *TagSigner) Verify(f scanFacts, sig string) (ok bool, stale bool) { |
| + parts := strings.Split(sig, ":") |
| + if len(parts) != 3 || parts[0] != sigFormat { |
| + return false, false |
| + } |
| + got, err := hex.DecodeString(parts[2]) |
| + if err != nil || len(got) != sha256.Size { |
| + return false, false |
| + } |
| + switch parts[1] { |
| + case s.current.id: |
| + return hmac.Equal(got, mac(s.current.secret, f)), false |
| + } |
| + if s.previous != nil && parts[1] == s.previous.id { |
| + return hmac.Equal(got, mac(s.previous.secret, f)), true |
| + } |
| + return false, false |
| +} |
| + |
| +// mac is the signature over the facts, under one key. |
| +// |
| +// THE ENCODING IS LENGTH-PREFIXED, NOT DELIMITED, and that is not fastidiousness. |
| +// An S3 object key may contain any byte a delimiter could be — `|`, `:`, a |
| +// newline, a NUL. With a delimiter, an app that controls its own object keys |
| +// can arrange for two different (bucket, key, version, verdict, at) tuples to |
| +// serialise identically, and then a signature legitimately issued for one |
| +// object is a valid signature for another. Length prefixes make that |
| +// impossible rather than unlikely. |
| +// |
| +// The format label goes in first, so a future encoding cannot collide with |
| +// this one either. |
| +func mac(secret []byte, f scanFacts) []byte { |
| + h := hmac.New(sha256.New, secret) |
| + for _, s := range []string{sigFormat, f.bucket, f.key, f.version, f.verdict, f.at} { |
| + var n [4]byte |
| + binary.BigEndian.PutUint32(n[:], uint32(len(s))) |
| + h.Write(n[:]) |
| + h.Write([]byte(s)) |
| + } |
| + return h.Sum(nil) |
| +} |
diff --git a/internal/objstore/scantag_test.go b/internal/objstore/scantag_test.go| new file mode 100644 |
| index 000000000..2718cd967 |
| --- /dev/null |
| +++ b/internal/objstore/scantag_test.go |
| @@ -0,0 +1,262 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func testSigner(t *testing.T, current, previous string) *TagSigner { |
| + t.Helper() |
| + s, err := NewTagSigner(current, previous) |
| + if err != nil { |
| + t.Fatalf("NewTagSigner: %v", err) |
| + } |
| + return s |
| +} |
| + |
| +// The signature is over ALL of it, and changing any single field must break |
| +// it. This is the whole trust boundary: a verdict that verifies is one the |
| +// console wrote, about these bytes, at that moment. |
| +// |
| +// The table is per-field on purpose. A signature over (verdict) alone would |
| +// let an attacker move a clean signature from one object to another; over |
| +// (key, verdict) it would survive an overwrite; without `at` two verdicts |
| +// written a year apart are interchangeable. |
| +func TestTagSignatureCoversEveryField(t *testing.T) { |
| + s := testSigner(t, "master-key-one", "") |
| + |
| + base := scanFacts{ |
| + bucket: "carlos-store-tito-bab-titogo", |
| + key: "uploads/invoice.pdf", |
| + version: "d41d8cd98f00b204e9800998ecf8427e@2026-09-05T01:02:03Z", |
| + verdict: string(VerdictClean), |
| + at: "2026-09-05T01:02:04Z", |
| + } |
| + sig := s.Sign(base) |
| + if ok, _ := s.Verify(base, sig); !ok { |
| + t.Fatal("a signature this signer just produced does not verify") |
| + } |
| + |
| + for _, tc := range []struct { |
| + name string |
| + edit func(*scanFacts) |
| + }{ |
| + {"bucket", func(f *scanFacts) { f.bucket = "carlos-store-tito-bab-other" }}, |
| + {"key", func(f *scanFacts) { f.key = "uploads/other.pdf" }}, |
| + {"version", func(f *scanFacts) { f.version = "0000000000000000000000000000@2026-09-05T01:02:03Z" }}, |
| + {"verdict", func(f *scanFacts) { f.verdict = string(VerdictInfected) }}, |
| + {"at", func(f *scanFacts) { f.at = "2026-09-05T01:02:05Z" }}, |
| + } { |
| + t.Run("changing the "+tc.name+" breaks it", func(t *testing.T) { |
| + f := base |
| + tc.edit(&f) |
| + if ok, _ := s.Verify(f, sig); ok { |
| + t.Fatalf("the signature still verified after changing the %s", tc.name) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// THE ENCODING IS INJECTIVE, and this is the test that says why it cannot be |
| +// string concatenation with a delimiter. |
| +// |
| +// An S3 object key may contain any UTF-8, including whatever separator looks |
| +// safe — `|`, `\n`, `\x00`. With a delimiter, an attacker who controls a key |
| +// can arrange for two different (bucket, key, version, verdict) tuples to |
| +// serialise to the same bytes, and then a signature legitimately issued for |
| +// one is valid for the other. Length-prefixing removes the possibility rather |
| +// than hiding it behind a character nobody expects to appear. |
| +func TestTagSignatureEncodingCannotBeConfused(t *testing.T) { |
| + s := testSigner(t, "master-key-one", "") |
| + |
| + // Two tuples that a naive `bucket|key|version|verdict|at` join renders |
| + // identically: the boundary between key and version is moved into the key. |
| + a := scanFacts{bucket: "b", key: "x", version: "y@z", verdict: "clean", at: "t"} |
| + b := scanFacts{bucket: "b", key: "x|y@z", version: "", verdict: "clean", at: "t"} |
| + |
| + if s.Sign(a) == s.Sign(b) { |
| + t.Fatal("two different tuples produced the same signature — the encoding is not injective, and a signature issued for one object is valid for another") |
| + } |
| + |
| + // The same confusion attempted with every separator a hand-rolled encoder |
| + // might have chosen, INCLUDING multi-byte ones. |
| + // |
| + // The first version of this test tried only single characters, and a |
| + // mutation replacing the length prefix with a constant four zero bytes — |
| + // a fixed four-byte delimiter, which is exactly the bug this test exists |
| + // to catch — SURVIVED it. The lesson is that a test against "a delimiter" |
| + // has to mean any delimiter, and a test written against the one the author |
| + // happened to imagine proves nothing about the others. |
| + for _, sep := range []string{ |
| + "\n", "\x00", "|", ":", "\x1f", |
| + "\x00\x00\x00\x00", // four zero bytes: a zeroed length prefix |
| + "\x00\x00\x00\x01", |
| + "\xff\xff\xff\xff", |
| + } { |
| + // Move the boundary between two fields into the data of the first. |
| + c := scanFacts{bucket: "b", key: "x" + sep + "y", version: "v", verdict: "clean", at: "t"} |
| + d := scanFacts{bucket: "b", key: "x", version: "y" + sep + "v", verdict: "clean", at: "t"} |
| + if s.Sign(c) == s.Sign(d) { |
| + t.Fatalf("confusable across %q — the encoding is delimited, not length-prefixed", sep) |
| + } |
| + } |
| + |
| + // And the general property the length prefix actually buys, stated without |
| + // guessing at a separator: shifting one character across every adjacent |
| + // field boundary must change the signature. A delimiter scheme survives |
| + // this only while the delimiter cannot appear in the data, which for an S3 |
| + // object key is never true. |
| + for _, pair := range []struct { |
| + name string |
| + a, b scanFacts |
| + }{ |
| + {"bucket/key", scanFacts{bucket: "ab", key: "c", version: "v", verdict: "clean", at: "t"}, |
| + scanFacts{bucket: "a", key: "bc", version: "v", verdict: "clean", at: "t"}}, |
| + {"key/version", scanFacts{bucket: "b", key: "ab", version: "c", verdict: "clean", at: "t"}, |
| + scanFacts{bucket: "b", key: "a", version: "bc", verdict: "clean", at: "t"}}, |
| + {"version/verdict", scanFacts{bucket: "b", key: "k", version: "ab", verdict: "c", at: "t"}, |
| + scanFacts{bucket: "b", key: "k", version: "a", verdict: "bc", at: "t"}}, |
| + {"verdict/at", scanFacts{bucket: "b", key: "k", version: "v", verdict: "ab", at: "c"}, |
| + scanFacts{bucket: "b", key: "k", version: "v", verdict: "a", at: "bc"}}, |
| + } { |
| + if s.Sign(pair.a) == s.Sign(pair.b) { |
| + t.Fatalf("a character moved across the %s boundary did not change the signature", pair.name) |
| + } |
| + } |
| +} |
| + |
| +// A different deployment's key must not verify another's. Without this a |
| +// single leaked or shared secret makes every deployment's verdicts |
| +// interchangeable. |
| +func TestTagSignatureIsPerKey(t *testing.T) { |
| + one := testSigner(t, "master-key-one", "") |
| + two := testSigner(t, "master-key-two", "") |
| + |
| + f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| + if ok, _ := two.Verify(f, one.Sign(f)); ok { |
| + t.Fatal("one deployment's signature verified under another's key") |
| + } |
| +} |
| + |
| +// Rotation: the previous key still verifies, and says so, so the sweep can |
| +// re-sign a still-valid verdict instead of re-scanning bytes it has already |
| +// judged. `stale` is the whole reason Verify returns two values. |
| +func TestTagSignatureAcceptsThePreviousKeyAndSaysSo(t *testing.T) { |
| + old := testSigner(t, "master-key-old", "") |
| + rotated := testSigner(t, "master-key-new", "master-key-old") |
| + |
| + f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| + |
| + ok, stale := rotated.Verify(f, old.Sign(f)) |
| + if !ok { |
| + t.Fatal("a verdict signed under the previous key stopped verifying after rotation — every stored verdict would be rescanned at once") |
| + } |
| + if !stale { |
| + t.Fatal("Verify did not report the previous key, so nothing would ever re-sign and the old key could never be dropped") |
| + } |
| + |
| + ok, stale = rotated.Verify(f, rotated.Sign(f)) |
| + if !ok || stale { |
| + t.Fatalf("current key: ok=%v stale=%v, want true/false", ok, stale) |
| + } |
| +} |
| + |
| +// Dropping the previous key is what finishes a rotation, and it must actually |
| +// stop accepting it — otherwise "rotated" means nothing after a compromise. |
| +func TestTagSignatureRefusesADroppedKey(t *testing.T) { |
| + old := testSigner(t, "master-key-old", "") |
| + current := testSigner(t, "master-key-new", "") |
| + |
| + f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| + if ok, _ := current.Verify(f, old.Sign(f)); ok { |
| + t.Fatal("a signature under a dropped key still verified") |
| + } |
| +} |
| + |
| +// The value carries a format version and a key id, so a deployment holding two |
| +// keys knows which one to try first and a future format can be told apart from |
| +// a corrupt one. |
| +func TestTagSignatureValueShape(t *testing.T) { |
| + s := testSigner(t, "master-key-one", "") |
| + sig := s.Sign(scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"}) |
| + |
| + parts := strings.Split(sig, ":") |
| + if len(parts) != 3 { |
| + t.Fatalf("signature %q is not v1:<key-id>:<mac>", sig) |
| + } |
| + if parts[0] != "v1" { |
| + t.Fatalf("format = %q, want v1", parts[0]) |
| + } |
| + if parts[1] == "" || parts[1] == "master-key-one" { |
| + t.Fatalf("key id = %q — it must identify the key without being the key", parts[1]) |
| + } |
| + if len(parts[2]) != 64 { |
| + t.Fatalf("mac is %d hex chars, want 64 (HMAC-SHA256)", len(parts[2])) |
| + } |
| +} |
| + |
| +// Everything malformed reads as "no valid signature", never as an error and |
| +// never as a pass. A verdict tag with a rubbish signature is exactly what a |
| +// forgery looks like, and the caller's job is to treat it as unknown and scan. |
| +func TestTagSignatureRefusesMalformedValues(t *testing.T) { |
| + s := testSigner(t, "master-key-one", "") |
| + f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| + good := s.Sign(f) |
| + |
| + for _, tc := range []struct{ name, sig string }{ |
| + {"empty", ""}, |
| + {"no structure", "deadbeef"}, |
| + {"two fields", "v1:abcd1234"}, |
| + {"four fields", good + ":extra"}, |
| + {"unknown format", "v2" + strings.TrimPrefix(good, "v1")}, |
| + {"unknown key id", "v1:0000000000000000:" + strings.Split(good, ":")[2]}, |
| + {"non-hex mac", "v1:" + strings.Split(good, ":")[1] + ":" + strings.Repeat("z", 64)}, |
| + {"truncated mac", good[:len(good)-2]}, |
| + {"empty mac", "v1:" + strings.Split(good, ":")[1] + ":"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if ok, _ := s.Verify(f, tc.sig); ok { |
| + t.Fatalf("Verify accepted %q", tc.sig) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A signer needs a key. Constructing one without a current key must fail |
| +// loudly at boot rather than producing something that signs with nothing — |
| +// which would verify every forgery in the world. |
| +func TestTagSignerRefusesAnEmptyKey(t *testing.T) { |
| + if _, err := NewTagSigner("", ""); err == nil { |
| + t.Fatal("NewTagSigner accepted an empty current key") |
| + } |
| + if _, err := NewTagSigner("", "previous-only"); err == nil { |
| + t.Fatal("NewTagSigner accepted a previous key with no current key") |
| + } |
| +} |
| + |
| +// Two keys that happen to be equal are a configuration mistake — a rotation |
| +// that rotated to itself — and silently accepting it would make the operator |
| +// believe a rotation had happened. |
| +func TestTagSignerRefusesIdenticalKeys(t *testing.T) { |
| + if _, err := NewTagSigner("same", "same"); err == nil { |
| + t.Fatal("NewTagSigner accepted identical current and previous keys — a rotation that rotated to itself") |
| + } |
| +} |
| + |
| +// The constructor is where a missing key has to be caught, because it is the |
| +// last moment at which nobody has been told anything. |
| +// |
| +// A scanner with no signer would write verdicts nothing can verify and, worse, |
| +// read every forgery as genuine — so it would report an app's own |
| +// `carlos-scan=clean` as a real clean verdict. That is strictly worse than |
| +// having no scanner: no scanner reports nothing, this reports the attacker's |
| +// answer. The console turns this refusal into a fatal at boot, naming the |
| +// variable, before a single tenant byte is read. |
| +func TestNewClamAVScannerRefusesWithoutASigner(t *testing.T) { |
| + _, err := NewClamAVScanner(context.Background(), "eu-west-1", "", "/run/clamav/clamd.ctl", 1<<20, nil) |
| + if !errors.Is(err, ErrNoTagKey) { |
| + t.Fatalf("NewClamAVScanner with no signer = %v, want ErrNoTagKey — a scanner that cannot sign reads every forgery as genuine", err) |
| + } |
| +} |