package objstore import "context" // Scanner is the scanning-engine seam (spec §3): everything the sweep needs an // engine to do, and nothing an app ever calls. Like Provisioner, every method // is idempotent — "already enabled" and "already gone" are successes — because // the sweep that drives one must be re-runnable after a half-failure. // // v1 ships the ClamAV implementation and this package's in-memory fake. A // GuardDuty scanner arrives here later without touching the sweep, once the // question in spec §4.2 has been measured: AWS tags only objects uploaded // after tagging was enabled, and every object in an existing store predates // its plan, so if an on-demand scan does not tag them then that engine needs a // different verdict source and not just a different implementation of this. type Scanner interface { // EnsureEnabled turns scanning on for a bucket where the backend scans // natively. Engines that scan by fetching have nothing to configure. EnsureEnabled(ctx context.Context, bucket string) error // EnsureDisabled releases whatever EnsureEnabled took. Teardown calls it // BEFORE deleting the bucket (spec §7.3): the record is the only thing // naming the backend estate, so releasing it after the record is gone // leaks an estate nobody can find again. EnsureDisabled(ctx context.Context, bucket string) error // Verdict answers the recorded verdict for one object AT etag. A verdict // recorded against a DIFFERENT etag is (VerdictUnknown, nil): the object // has been rewritten since it was judged, and bytes nobody looked at have // no verdict. Absent is (VerdictUnknown, nil) too — the same idiom Get // uses for an absent record, because "not scanned" is a state. Verdict(ctx context.Context, bucket, key, version string) (Verdict, error) // Status answers what is known about the CURRENT version of one object, // and returns the object as a listing page would have described it so the // caller can go on to Scan it without a HeadObject of its own. // // TWO DIFFERENCES FROM Verdict, and both matter. // // It finds the current version itself, because a caller holding only a key // — the on-demand drain, the status door — has no listing page to take one // from. Handing them the token to reconstruct would leak an internal // format and hand them the laundering bug as a correctness bug. // // And it does NOT apply Verdict's re-issue rule. A stored `denied` or // `failed` comes back as itself here. That rule is SWEEP RETRY POLICY — // re-scan the transient ones — and it is the wrong answer to "what // happened to this object": left in place, a failed scan would read as // pending forever, because the drain deletes the item as scanned and // nothing would ever change the answer. // // ErrObjectGone when the object is not there. A denied HeadObject is // (VerdictDenied, ScanObject{}, nil) — there is no version or size to // scan with, so a caller must not go on to Scan a zero ScanObject. Status(ctx context.Context, bucket, key string) (Verdict, ScanObject, error) // Scan scans one object and records its verdict against etag. It returns // the verdict when the engine is synchronous (ClamAV) and VerdictUnknown // when the engine scans asynchronously (GuardDuty). The sweep counts what // it gets back, so a synchronous engine's FIRST pass is a pass full of // real verdicts rather than one full of "unscanned". // // size comes from the listing page the sweep is already holding, so an // engine can refuse an over-ceiling object without fetching it. Paying // egress for bytes the scanner will reject is pure waste, and the // alternative — a HeadObject per object per sweep — is a round trip on // every object forever. // recorded reports whether the verdict was actually PERSISTED to the // object. It is not always: tag() declines to write when the object // already carries eight foreign tags (S3 caps an object at ten) or when // the pre-write tag read is denied. Both are deliberate — they stop one // object stalling a whole sweep — and the sweep tolerates them because it // re-lists every 24h. // // A queue item does not. Without this flag the on-demand drain would // report clean, delete the item, and the status door would answer pending // forever, because the tag is not there — the app's declared source of // truth never arriving while a callback said otherwise. Scan(ctx context.Context, bucket, key, version string, size int64) (verdict Verdict, recorded bool, err error) // Enforcement names the object tag this engine writes for an infected // object, so the IAM policy writer can deny reads on it (spec §8). // Engine-specific by necessity: a Deny statement must name a literal key // and value, which is the one place the sweep's engine-blindness cannot // hold. Enforcement() DenyTag // Capacity reports how many buckets this engine can still protect and the // cap; (-1, -1) is uncapped. ClamAV is uncapped. GuardDuty caps at 25 per // account per Region, not adjustable, against a platform that mints one // bucket per app — which is why it is not the default engine. Capacity(ctx context.Context) (remaining, cap int, err error) // Provenance names what did the scanning — the engine and its signature // database version — for the evidence record. "Scanned with what?" is an // audit question, and a clean verdict from three-month-old signatures // should not read like a fresh one. Provenance(ctx context.Context) (engine, version string, err error) } // Verdict is one object's scan result. The zero value is "no recorded // verdict", which is a state and not an error. type Verdict string const ( VerdictUnknown Verdict = "" VerdictClean Verdict = "clean" // VerdictInfected is the only verdict the Deny statement acts on. VerdictInfected Verdict = "infected" // VerdictSkipped is too large, unsupported, or unextractable. It is NOT // clean, and the evidence must never blur the two: a store that is mostly // skips has not been meaningfully scanned. VerdictSkipped Verdict = "skipped" // VerdictDenied is the engine being unable to read the object. Distinct // from Failed because the repair is a permissions fix, not a retry. VerdictDenied Verdict = "denied" // VerdictFailed is the engine reading the object and failing on it. It is // NOT "the engine was unreachable" — an outage must never stamp thousands // of objects with a verdict about their contents. VerdictFailed Verdict = "failed" ) // DenyTag is the object tag an engine writes for an infected object, and the // literal a Deny statement matches on (spec §8). // // It lives in the RECORD rather than being asked of a Scanner at policy-write // time, because Grant takes a Provisioner and has no Scanner to ask. That is // also what lets `scan enforce off` clear a deny on a deployment with no // engine built — without it, unsetting CARLOS_STORE_SCANNER on a deployment // holding any enforcing record would leave that store unrepairable. type DenyTag struct { Key string `json:"key"` Value string `json:"value"` } // The tags the ClamAV engine writes. TagVersion is what binds a verdict to the // bytes it judged: without it a tag says only "some bytes under this key were // clean once", and a re-uploaded object would inherit its predecessor's // verdict. // // The version is the ETag AND the last-modified time (ScanObject.Version), not // the ETag alone. A single-part upload's ETag is the content MD5, and // chosen-prefix MD5 collisions are practical on ordinary hardware — an // attacker controlling both uploads could otherwise give a payload the ETag of // the clean object it replaces. Last-modified moves on every overwrite and is // not theirs to choose. const ( TagVerdict = "carlos-scan" TagVersion = "carlos-scan-version" TagAt = "carlos-scan-at" )