| 1 | package objstore |
| 2 | |
| 3 | import "context" |
| 4 | |
| 5 | // Scanner is the scanning-engine seam (spec §3): everything the sweep needs an |
| 6 | // engine to do, and nothing an app ever calls. Like Provisioner, every method |
| 7 | // is idempotent — "already enabled" and "already gone" are successes — because |
| 8 | // the sweep that drives one must be re-runnable after a half-failure. |
| 9 | // |
| 10 | // v1 ships the ClamAV implementation and this package's in-memory fake. A |
| 11 | // GuardDuty scanner arrives here later without touching the sweep, once the |
| 12 | // question in spec §4.2 has been measured: AWS tags only objects uploaded |
| 13 | // after tagging was enabled, and every object in an existing store predates |
| 14 | // its plan, so if an on-demand scan does not tag them then that engine needs a |
| 15 | // different verdict source and not just a different implementation of this. |
| 16 | type Scanner interface { |
| 17 | // EnsureEnabled turns scanning on for a bucket where the backend scans |
| 18 | // natively. Engines that scan by fetching have nothing to configure. |
| 19 | EnsureEnabled(ctx context.Context, bucket string) error |
| 20 | // EnsureDisabled releases whatever EnsureEnabled took. Teardown calls it |
| 21 | // BEFORE deleting the bucket (spec §7.3): the record is the only thing |
| 22 | // naming the backend estate, so releasing it after the record is gone |
| 23 | // leaks an estate nobody can find again. |
| 24 | EnsureDisabled(ctx context.Context, bucket string) error |
| 25 | // Verdict answers the recorded verdict for one object AT etag. A verdict |
| 26 | // recorded against a DIFFERENT etag is (VerdictUnknown, nil): the object |
| 27 | // has been rewritten since it was judged, and bytes nobody looked at have |
| 28 | // no verdict. Absent is (VerdictUnknown, nil) too — the same idiom Get |
| 29 | // uses for an absent record, because "not scanned" is a state. |
| 30 | Verdict(ctx context.Context, bucket, key, version string) (Verdict, error) |
| 31 | // Status answers what is known about the CURRENT version of one object, |
| 32 | // and returns the object as a listing page would have described it so the |
| 33 | // caller can go on to Scan it without a HeadObject of its own. |
| 34 | // |
| 35 | // TWO DIFFERENCES FROM Verdict, and both matter. |
| 36 | // |
| 37 | // It finds the current version itself, because a caller holding only a key |
| 38 | // — the on-demand drain, the status door — has no listing page to take one |
| 39 | // from. Handing them the token to reconstruct would leak an internal |
| 40 | // format and hand them the laundering bug as a correctness bug. |
| 41 | // |
| 42 | // And it does NOT apply Verdict's re-issue rule. A stored `denied` or |
| 43 | // `failed` comes back as itself here. That rule is SWEEP RETRY POLICY — |
| 44 | // re-scan the transient ones — and it is the wrong answer to "what |
| 45 | // happened to this object": left in place, a failed scan would read as |
| 46 | // pending forever, because the drain deletes the item as scanned and |
| 47 | // nothing would ever change the answer. |
| 48 | // |
| 49 | // ErrObjectGone when the object is not there. A denied HeadObject is |
| 50 | // (VerdictDenied, ScanObject{}, nil) — there is no version or size to |
| 51 | // scan with, so a caller must not go on to Scan a zero ScanObject. |
| 52 | Status(ctx context.Context, bucket, key string) (Verdict, ScanObject, error) |
| 53 | // Scan scans one object and records its verdict against etag. It returns |
| 54 | // the verdict when the engine is synchronous (ClamAV) and VerdictUnknown |
| 55 | // when the engine scans asynchronously (GuardDuty). The sweep counts what |
| 56 | // it gets back, so a synchronous engine's FIRST pass is a pass full of |
| 57 | // real verdicts rather than one full of "unscanned". |
| 58 | // |
| 59 | // size comes from the listing page the sweep is already holding, so an |
| 60 | // engine can refuse an over-ceiling object without fetching it. Paying |
| 61 | // egress for bytes the scanner will reject is pure waste, and the |
| 62 | // alternative — a HeadObject per object per sweep — is a round trip on |
| 63 | // every object forever. |
| 64 | // recorded reports whether the verdict was actually PERSISTED to the |
| 65 | // object. It is not always: tag() declines to write when the object |
| 66 | // already carries eight foreign tags (S3 caps an object at ten) or when |
| 67 | // the pre-write tag read is denied. Both are deliberate — they stop one |
| 68 | // object stalling a whole sweep — and the sweep tolerates them because it |
| 69 | // re-lists every 24h. |
| 70 | // |
| 71 | // A queue item does not. Without this flag the on-demand drain would |
| 72 | // report clean, delete the item, and the status door would answer pending |
| 73 | // forever, because the tag is not there — the app's declared source of |
| 74 | // truth never arriving while a callback said otherwise. |
| 75 | Scan(ctx context.Context, bucket, key, version string, size int64) (verdict Verdict, recorded bool, err error) |
| 76 | // Enforcement names the object tag this engine writes for an infected |
| 77 | // object, so the IAM policy writer can deny reads on it (spec §8). |
| 78 | // Engine-specific by necessity: a Deny statement must name a literal key |
| 79 | // and value, which is the one place the sweep's engine-blindness cannot |
| 80 | // hold. |
| 81 | Enforcement() DenyTag |
| 82 | // Capacity reports how many buckets this engine can still protect and the |
| 83 | // cap; (-1, -1) is uncapped. ClamAV is uncapped. GuardDuty caps at 25 per |
| 84 | // account per Region, not adjustable, against a platform that mints one |
| 85 | // bucket per app — which is why it is not the default engine. |
| 86 | Capacity(ctx context.Context) (remaining, cap int, err error) |
| 87 | // Provenance names what did the scanning — the engine and its signature |
| 88 | // database version — for the evidence record. "Scanned with what?" is an |
| 89 | // audit question, and a clean verdict from three-month-old signatures |
| 90 | // should not read like a fresh one. |
| 91 | Provenance(ctx context.Context) (engine, version string, err error) |
| 92 | } |
| 93 | |
| 94 | // Verdict is one object's scan result. The zero value is "no recorded |
| 95 | // verdict", which is a state and not an error. |
| 96 | type Verdict string |
| 97 | |
| 98 | const ( |
| 99 | VerdictUnknown Verdict = "" |
| 100 | VerdictClean Verdict = "clean" |
| 101 | // VerdictInfected is the only verdict the Deny statement acts on. |
| 102 | VerdictInfected Verdict = "infected" |
| 103 | // VerdictSkipped is too large, unsupported, or unextractable. It is NOT |
| 104 | // clean, and the evidence must never blur the two: a store that is mostly |
| 105 | // skips has not been meaningfully scanned. |
| 106 | VerdictSkipped Verdict = "skipped" |
| 107 | // VerdictDenied is the engine being unable to read the object. Distinct |
| 108 | // from Failed because the repair is a permissions fix, not a retry. |
| 109 | VerdictDenied Verdict = "denied" |
| 110 | // VerdictFailed is the engine reading the object and failing on it. It is |
| 111 | // NOT "the engine was unreachable" — an outage must never stamp thousands |
| 112 | // of objects with a verdict about their contents. |
| 113 | VerdictFailed Verdict = "failed" |
| 114 | ) |
| 115 | |
| 116 | // DenyTag is the object tag an engine writes for an infected object, and the |
| 117 | // literal a Deny statement matches on (spec §8). |
| 118 | // |
| 119 | // It lives in the RECORD rather than being asked of a Scanner at policy-write |
| 120 | // time, because Grant takes a Provisioner and has no Scanner to ask. That is |
| 121 | // also what lets `scan enforce off` clear a deny on a deployment with no |
| 122 | // engine built — without it, unsetting CARLOS_STORE_SCANNER on a deployment |
| 123 | // holding any enforcing record would leave that store unrepairable. |
| 124 | type DenyTag struct { |
| 125 | Key string `json:"key"` |
| 126 | Value string `json:"value"` |
| 127 | } |
| 128 | |
| 129 | // The tags the ClamAV engine writes. TagVersion is what binds a verdict to the |
| 130 | // bytes it judged: without it a tag says only "some bytes under this key were |
| 131 | // clean once", and a re-uploaded object would inherit its predecessor's |
| 132 | // verdict. |
| 133 | // |
| 134 | // The version is the ETag AND the last-modified time (ScanObject.Version), not |
| 135 | // the ETag alone. A single-part upload's ETag is the content MD5, and |
| 136 | // chosen-prefix MD5 collisions are practical on ordinary hardware — an |
| 137 | // attacker controlling both uploads could otherwise give a payload the ETag of |
| 138 | // the clean object it replaces. Last-modified moves on every overwrite and is |
| 139 | // not theirs to choose. |
| 140 | const ( |
| 141 | TagVerdict = "carlos-scan" |
| 142 | TagVersion = "carlos-scan-version" |
| 143 | TagAt = "carlos-scan-at" |
| 144 | ) |
| 145 | |