carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// scan.go is `carlos store scan`: request, inspect and drive virus scanning
2// over an app's object store. API mode only, like the rest of `store`.
3//
4// THE HONESTY RULES ARE THE FEATURE, and they all live in printScanStatus so
5// they are testable against a literal reply with no server:
6//
7// 1. The coverage line names TWO numbers, always. "N scanned" alone is true
8// and misleading — an object too large to stream was never judged, and a
9// bucket of them could report zero infections while nobody looked at
10// anything. Scanned and unscanned are not complements and are printed as
11// the separate facts they are.
12//
13// 2. A client-encrypted store NEVER gets a coverage line. A scanner cannot
14// read ciphertext, so "all clean" there would be a true-looking sentence
15// about bytes nobody examined — worse than saying nothing.
16//
17// 3. A sweep in progress says so, and says which numbers are stale. Printing
18// a part-finished count as though it were the answer is how a compliance
19// artifact quietly becomes wrong.
20//
21// 4. Old signatures are called out. A clean verdict from three-month-old
22// definitions is a weaker claim than a fresh one, and an audit answer that
23// cannot tell them apart is worth less than it looks.
24package main
25
26import (
27 "context"
28 "flag"
29 "fmt"
30 "io"
31 "log"
32 "os"
33 "strings"
34 "time"
35)
36
37const storeScanUsage = `usage: carlos store scan <request|status|grant|revoke|sweep|enforce|rotate-webhook-key> [flags]
38
39 request --app <a> (--plaintext | --client-encrypted)
40 ask for this store's objects to be scanned. One of
41 the two attestations is REQUIRED: a scanner cannot
42 read ciphertext, so a client-encrypted store can
43 never be given a coverage claim.
44 status --app <a> what is claimed about this store's objects: the
45 engine, its signature database, and how much of the
46 bucket has actually been looked at.
47 grant --app <a> operator-only: turn the engine on for this store.
48 revoke --app <a> operator-only: turn it off, and clear enforcement.
49 sweep --app <a> operator-only: run one budget of scanning now
50 instead of waiting for the hourly pass.
51 enforce --app <a> on|off
52 operator-only: deny the app's own credentials read
53 access to any object the engine tagged infected.
54 rotate-webhook-key --app <a>
55 operator-only: replace this app's callback secret
56 and restart it, without touching any other app.
57
58Flags: --account/--account-id to disambiguate, --console to pick a console.
59`
60
61func runStoreScan(args []string) {
62 if len(args) == 0 {
63 fmt.Fprint(os.Stderr, storeScanUsage)
64 os.Exit(1)
65 }
66 switch args[0] {
67 case "request":
68 runStoreScanRequest(args[1:])
69 case "status":
70 runStoreScanStatus(args[1:])
71 case "grant":
72 runStoreScanSimple(args[1:], "grant")
73 case "revoke":
74 runStoreScanSimple(args[1:], "revoke")
75 case "sweep":
76 runStoreScanSimple(args[1:], "sweep")
77 case "enforce":
78 runStoreScanEnforce(args[1:])
79 case "rotate-webhook-key":
80 runStoreScanSimple(args[1:], "rotate-webhook-key")
81 default:
82 fmt.Fprint(os.Stderr, storeScanUsage)
83 os.Exit(1)
84 }
85}
86
87// scanTarget is the flag boilerplate every scan sub-verb shares.
88func scanTarget(fs *flag.FlagSet, args []string, verb, what string) (*apiSession, apiAcct, string) {
89 app := appFlag(fs, what)
90 acctRaw, acctIDRaw, _ := accountFlags(fs)
91 consoleFlag := consoleOption(fs, "console origin to act through (API mode only; default: the only logged-in one)")
92 fs.Usage = flagUsage(fs, "")
93 fs.Parse(args)
94
95 name := strings.TrimSpace(*app)
96 usage := "usage: carlos store scan " + verb + " --app <a> [--account <sqid-or-name>] [--console <origin>]"
97 if name != "" {
98 if err := validAppName(name); err != nil {
99 log.Fatalf("store scan %s: %v", verb, err)
100 }
101 }
102 sess := requireStoreAPISession(*consoleFlag)
103 printUsing(stdout, sess)
104 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
105 return sess, acct, resolvedApp(sess, acct, name, usage)
106}
107
108// runStoreScanRequest is the member's verb, and the attestation is the whole
109// point of it. There is no default and no inferred answer: only the person who
110// wrote the objects knows whether anything but they can read them.
111func runStoreScanRequest(args []string) {
112 fs := flag.NewFlagSet("store scan request", flag.ExitOnError)
113 plaintext := fs.Bool("plaintext", false, "these objects are readable by the deployment — scanning them is meaningful")
114 encrypted := fs.Bool("client-encrypted", false, "these objects are encrypted before upload — no coverage will be claimed")
115 sess, acct, name := scanTarget(fs, args, "request", "app to request scanning for")
116
117 switch {
118 case *plaintext && *encrypted:
119 log.Fatal("store scan request: pass one of --plaintext or --client-encrypted, not both")
120 case !*plaintext && !*encrypted:
121 log.Fatal("store scan request: say which this store holds — --plaintext or --client-encrypted.\n" +
122 "A scanner cannot read ciphertext, so this is not a detail the console can guess: a wrong\n" +
123 "answer either hands an unreadable store a coverage claim that means nothing, or silently\n" +
124 "turns off a control you asked for.")
125 }
126
127 res, err := sess.scanRequest(context.Background(), acct, name, *encrypted)
128 if err != nil {
129 log.Fatalf("store scan request: %v", err)
130 }
131 printScanRequest(stdout, res, *encrypted)
132}
133
134func runStoreScanStatus(args []string) {
135 fs := flag.NewFlagSet("store scan status", flag.ExitOnError)
136 sess, acct, name := scanTarget(fs, args, "status", "app whose scan state to show")
137 res, err := sess.scanStatus(context.Background(), acct, name)
138 if err != nil {
139 log.Fatalf("store scan status: %v", err)
140 }
141 printScanStatus(stdout, res)
142}
143
144// runStoreScanSimple covers grant, revoke and sweep: same flags, same reply,
145// different door.
146func runStoreScanSimple(args []string, verb string) {
147 fs := flag.NewFlagSet("store scan "+verb, flag.ExitOnError)
148 sess, acct, name := scanTarget(fs, args, verb, "app to "+verb+" scanning on")
149
150 ctx := context.Background()
151 var res apiScanReply
152 var err error
153 switch verb {
154 case "grant":
155 res, err = sess.scanGrant(ctx, acct, name)
156 case "revoke":
157 res, err = sess.scanRevoke(ctx, acct, name)
158 case "sweep":
159 res, err = sess.scanSweep(ctx, acct, name)
160 case "rotate-webhook-key":
161 res, err = sess.scanRotateCallbackKey(ctx, acct, name)
162 }
163 if err != nil {
164 log.Fatalf("store scan %s: %v", verb, err)
165 }
166 printScanStatus(stdout, res)
167}
168
169func runStoreScanEnforce(args []string) {
170 // on|off is positional and must be read before the flag set, so
171 // `enforce on --app x` and `enforce --app x on` both work the way a person
172 // would expect them to.
173 var on bool
174 var seen bool
175 rest := make([]string, 0, len(args))
176 // Only a BARE on/off is the switch. `--app on` names an app called "on",
177 // and eating it here would send the enforcement change to whatever app the
178 // session happened to default to — a wrong-target mutation that looks like
179 // it worked. Anything sitting in a flag's value position is left alone.
180 takesValue := map[string]bool{"--app": true, "-app": true,
181 "--account": true, "-account": true, "--account-id": true, "-account-id": true,
182 "--console": true, "-console": true}
183 for i, a := range args {
184 if i > 0 && takesValue[args[i-1]] {
185 rest = append(rest, a)
186 continue
187 }
188 switch a {
189 case "on":
190 on, seen = true, true
191 case "off":
192 on, seen = false, true
193 default:
194 rest = append(rest, a)
195 }
196 }
197 if !seen {
198 log.Fatal("store scan enforce: say which — `carlos store scan enforce --app <a> on` or `... off`")
199 }
200 fs := flag.NewFlagSet("store scan enforce", flag.ExitOnError)
201 sess, acct, name := scanTarget(fs, rest, "enforce", "app whose store to enforce on")
202
203 res, err := sess.scanEnforce(context.Background(), acct, name, on)
204 if err != nil {
205 log.Fatalf("store scan enforce: %v", err)
206 }
207 printScanStatus(stdout, res)
208}
209
210func printScanRequest(w io.Writer, res apiScanReply, encrypted bool) {
211 c := colorFor(w)
212 fmt.Fprintf(w, "%s %s\n", c.head("scanning requested for"), res.App)
213 if encrypted {
214 mutedf(w, " attested client-encrypted — no coverage will be claimed for this store,")
215 mutedf(w, " because a scanner cannot read ciphertext.")
216 return
217 }
218 if res.Scanning == nil || res.Scanning.Enabled == nil {
219 mutedf(w, " nothing is scanning yet — this is a request a deployment operator fulfils")
220 mutedf(w, " (carlos store scan grant --app %s).", res.App)
221 }
222}
223
224// printScanStatus is where every honesty rule in this file's header actually
225// lives.
226func printScanStatus(w io.Writer, res apiScanReply) {
227 c := colorFor(w)
228 fmt.Fprintf(w, "%s %s\n", c.head("object scanning for"), res.App)
229 fmt.Fprintf(w, "Queued objects: %d\n", res.QueueDepth)
230
231 if res.Scanning == nil || (res.Scanning.Requested == nil && res.Scanning.Enabled == nil) {
232 mutedf(w, "not requested — carlos store scan request --app %s --plaintext", res.App)
233 return
234 }
235
236 // An engine can be enabled with no request on record — it should not be,
237 // and the console now refuses it, but a record written before that refusal
238 // existed can still look like this. Returning early on a nil Requested
239 // would print "not requested" while an engine was actively reading these
240 // objects, which is the worst sentence this command could produce.
241 req := res.Scanning.Requested
242 switch {
243 case req == nil:
244 warnf(w, "no attestation is on record for this store, but an engine is enabled — "+
245 "run carlos store scan request --app %s to say whether these objects are readable", res.App)
246 default:
247 fmt.Fprintf(w, " requested: by %s at %s\n", req.By, req.At.Format(time.RFC3339))
248 }
249
250 // Rule 2, and it returns: an encrypted store gets no coverage line at all,
251 // not a zeroed one.
252 if req != nil && req.Encrypted {
253 mutedf(w, " attested client-encrypted — a scanner cannot read these objects,")
254 mutedf(w, " so NO coverage is claimed for this store and none can be.")
255 return
256 }
257
258 if res.Scanning.Enabled == nil {
259 mutedf(w, " no engine is enabled yet — waiting on a deployment operator")
260 mutedf(w, " (carlos store scan grant --app %s)", res.App)
261 return
262 }
263 en := res.Scanning.Enabled
264 fmt.Fprintf(w, " engine: %s, enabled by %s at %s\n", en.Engine, en.By, en.At.Format(time.RFC3339))
265 printScanSignature(w, res)
266
267 if res.Scanning.Enforce != nil {
268 fmt.Fprintf(w, " enforcing: the app's own credentials cannot read an object tagged %s=%s\n",
269 res.Scanning.Enforce.Key, res.Scanning.Enforce.Value)
270 } else {
271 mutedf(w, " not enforcing — infected objects are recorded, not blocked")
272 mutedf(w, " (carlos store scan enforce --app %s on)", res.App)
273 }
274
275 printScanCoverage(w, res)
276}
277
278// printScanSignature carries rule 4. ClamAV answers
279// "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026" — engine, database version,
280// database date — and the date is the half that decides what a clean verdict
281// is worth.
282func printScanSignature(w io.Writer, res apiScanReply) {
283 if res.EngineError != "" {
284 warnf(w, "the engine is not answering: %s — no new verdicts are being reached", res.EngineError)
285 return
286 }
287 if res.Signature == "" {
288 return
289 }
290 // "now", not bare "signatures". A clean tag is reused for as long as the
291 // object's ETag matches, so most of the rollup below may have been judged
292 // against an older database than this one. printScanCoverage says how much.
293 fmt.Fprintf(w, " engine database now: %s\n", res.Signature)
294 age, ok := signatureAge(res.Signature, time.Now())
295 if !ok {
296 return
297 }
298 max := time.Duration(res.MaxSignatureAgeSeconds) * time.Second
299 if max > 0 && age > max {
300 warnf(w, "those signatures are %d days old — a clean verdict from them is a weaker claim "+
301 "than a fresh one; update the engine's database", int(age.Hours()/24))
302 }
303}
304
305// signatureAge parses the date out of a clamd VERSION line. It returns ok
306// false rather than guessing when the shape is not the one we know: printing
307// nothing is honest, and inventing an age would undermine the very line it
308// was meant to strengthen.
309func signatureAge(version string, now time.Time) (time.Duration, bool) {
310 parts := strings.Split(version, "/")
311 if len(parts) < 3 {
312 return 0, false
313 }
314 t, err := time.Parse(time.ANSIC, strings.TrimSpace(parts[2]))
315 if err != nil {
316 return 0, false
317 }
318 return now.Sub(t), true
319}
320
321// printScanCoverage carries rules 1 and 3.
322func printScanCoverage(w io.Writer, res apiScanReply) {
323 cov := res.Coverage
324 if cov == nil || (cov.Current == nil && cov.LastComplete == nil) {
325 mutedf(w, " no sweep has finished yet — no coverage can be claimed for this store")
326 return
327 }
328
329 // Rule 3: an in-flight sweep says so, and says which numbers are stale.
330 if cov.Current != nil {
331 fmt.Fprintf(w, " sweep in progress since %s: %d objects seen so far\n",
332 cov.Current.StartedAt.Format(time.RFC3339), cov.Current.Objects)
333 if cov.LastComplete == nil {
334 mutedf(w, " no complete sweep yet — the numbers above are partial and cover only")
335 mutedf(w, " part of the bucket, so no coverage can be claimed")
336 return
337 }
338 mutedf(w, " the coverage below is from the last COMPLETE sweep, not from the one running now")
339 }
340
341 last := cov.LastComplete
342 if last == nil {
343 return
344 }
345 // Rule 1: two numbers, always.
346 fmt.Fprintf(w, " as of %s: %d of %d objects scanned, %d not scanned\n",
347 last.At.Format(time.RFC3339), last.Scanned, last.Objects, last.Unscanned)
348 if last.Unscanned > 0 {
349 fmt.Fprintf(w, " not scanned: %d too large or refused, %d denied, %d failed\n",
350 last.Skipped, last.Denied, last.Failed)
351 }
352 // How much of that coverage was actually reached by THIS sweep. A verdict
353 // is kept for as long as the object's bytes do not change, so a store that
354 // has been quiet for months can report full coverage made up entirely of
355 // old judgements — against signatures that are not the ones named above.
356 // Printing the split is the difference between an honest number and a
357 // number that merely looks current.
358 if last.Reused > 0 {
359 fmt.Fprintf(w, " of those, %d were judged during this sweep and %d carried forward\n",
360 last.Fresh, last.Reused)
361 mutedf(w, " a carried-forward verdict was reached against whatever database was")
362 mutedf(w, " current then, not the one above; carlos store scan sweep re-runs nothing")
363 mutedf(w, " whose bytes have not changed")
364 }
365 if last.Infected > 0 {
366 warnf(w, "%d infected object(s) found — carlos store scan enforce --app %s on blocks reads of them",
367 last.Infected, res.App)
368 }
369}
370