// scan.go is `carlos store scan`: request, inspect and drive virus scanning // over an app's object store. API mode only, like the rest of `store`. // // THE HONESTY RULES ARE THE FEATURE, and they all live in printScanStatus so // they are testable against a literal reply with no server: // // 1. The coverage line names TWO numbers, always. "N scanned" alone is true // and misleading — an object too large to stream was never judged, and a // bucket of them could report zero infections while nobody looked at // anything. Scanned and unscanned are not complements and are printed as // the separate facts they are. // // 2. A client-encrypted store NEVER gets a coverage line. A scanner cannot // read ciphertext, so "all clean" there would be a true-looking sentence // about bytes nobody examined — worse than saying nothing. // // 3. A sweep in progress says so, and says which numbers are stale. Printing // a part-finished count as though it were the answer is how a compliance // artifact quietly becomes wrong. // // 4. Old signatures are called out. A clean verdict from three-month-old // definitions is a weaker claim than a fresh one, and an audit answer that // cannot tell them apart is worth less than it looks. package main import ( "context" "flag" "fmt" "io" "log" "os" "strings" "time" ) const storeScanUsage = `usage: carlos store scan [flags] request --app (--plaintext | --client-encrypted) ask for this store's objects to be scanned. One of the two attestations is REQUIRED: a scanner cannot read ciphertext, so a client-encrypted store can never be given a coverage claim. status --app what is claimed about this store's objects: the engine, its signature database, and how much of the bucket has actually been looked at. grant --app operator-only: turn the engine on for this store. revoke --app operator-only: turn it off, and clear enforcement. sweep --app operator-only: run one budget of scanning now instead of waiting for the hourly pass. enforce --app on|off operator-only: deny the app's own credentials read access to any object the engine tagged infected. rotate-webhook-key --app operator-only: replace this app's callback secret and restart it, without touching any other app. Flags: --account/--account-id to disambiguate, --console to pick a console. ` func runStoreScan(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, storeScanUsage) os.Exit(1) } switch args[0] { case "request": runStoreScanRequest(args[1:]) case "status": runStoreScanStatus(args[1:]) case "grant": runStoreScanSimple(args[1:], "grant") case "revoke": runStoreScanSimple(args[1:], "revoke") case "sweep": runStoreScanSimple(args[1:], "sweep") case "enforce": runStoreScanEnforce(args[1:]) case "rotate-webhook-key": runStoreScanSimple(args[1:], "rotate-webhook-key") default: fmt.Fprint(os.Stderr, storeScanUsage) os.Exit(1) } } // scanTarget is the flag boilerplate every scan sub-verb shares. func scanTarget(fs *flag.FlagSet, args []string, verb, what string) (*apiSession, apiAcct, string) { app := appFlag(fs, what) acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to act through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos store scan " + verb + " --app [--account ] [--console ]" if name != "" { if err := validAppName(name); err != nil { log.Fatalf("store scan %s: %v", verb, err) } } sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) return sess, acct, resolvedApp(sess, acct, name, usage) } // runStoreScanRequest is the member's verb, and the attestation is the whole // point of it. There is no default and no inferred answer: only the person who // wrote the objects knows whether anything but they can read them. func runStoreScanRequest(args []string) { fs := flag.NewFlagSet("store scan request", flag.ExitOnError) plaintext := fs.Bool("plaintext", false, "these objects are readable by the deployment — scanning them is meaningful") encrypted := fs.Bool("client-encrypted", false, "these objects are encrypted before upload — no coverage will be claimed") sess, acct, name := scanTarget(fs, args, "request", "app to request scanning for") switch { case *plaintext && *encrypted: log.Fatal("store scan request: pass one of --plaintext or --client-encrypted, not both") case !*plaintext && !*encrypted: log.Fatal("store scan request: say which this store holds — --plaintext or --client-encrypted.\n" + "A scanner cannot read ciphertext, so this is not a detail the console can guess: a wrong\n" + "answer either hands an unreadable store a coverage claim that means nothing, or silently\n" + "turns off a control you asked for.") } res, err := sess.scanRequest(context.Background(), acct, name, *encrypted) if err != nil { log.Fatalf("store scan request: %v", err) } printScanRequest(stdout, res, *encrypted) } func runStoreScanStatus(args []string) { fs := flag.NewFlagSet("store scan status", flag.ExitOnError) sess, acct, name := scanTarget(fs, args, "status", "app whose scan state to show") res, err := sess.scanStatus(context.Background(), acct, name) if err != nil { log.Fatalf("store scan status: %v", err) } printScanStatus(stdout, res) } // runStoreScanSimple covers grant, revoke and sweep: same flags, same reply, // different door. func runStoreScanSimple(args []string, verb string) { fs := flag.NewFlagSet("store scan "+verb, flag.ExitOnError) sess, acct, name := scanTarget(fs, args, verb, "app to "+verb+" scanning on") ctx := context.Background() var res apiScanReply var err error switch verb { case "grant": res, err = sess.scanGrant(ctx, acct, name) case "revoke": res, err = sess.scanRevoke(ctx, acct, name) case "sweep": res, err = sess.scanSweep(ctx, acct, name) case "rotate-webhook-key": res, err = sess.scanRotateCallbackKey(ctx, acct, name) } if err != nil { log.Fatalf("store scan %s: %v", verb, err) } printScanStatus(stdout, res) } func runStoreScanEnforce(args []string) { // on|off is positional and must be read before the flag set, so // `enforce on --app x` and `enforce --app x on` both work the way a person // would expect them to. var on bool var seen bool rest := make([]string, 0, len(args)) // Only a BARE on/off is the switch. `--app on` names an app called "on", // and eating it here would send the enforcement change to whatever app the // session happened to default to — a wrong-target mutation that looks like // it worked. Anything sitting in a flag's value position is left alone. takesValue := map[string]bool{"--app": true, "-app": true, "--account": true, "-account": true, "--account-id": true, "-account-id": true, "--console": true, "-console": true} for i, a := range args { if i > 0 && takesValue[args[i-1]] { rest = append(rest, a) continue } switch a { case "on": on, seen = true, true case "off": on, seen = false, true default: rest = append(rest, a) } } if !seen { log.Fatal("store scan enforce: say which — `carlos store scan enforce --app on` or `... off`") } fs := flag.NewFlagSet("store scan enforce", flag.ExitOnError) sess, acct, name := scanTarget(fs, rest, "enforce", "app whose store to enforce on") res, err := sess.scanEnforce(context.Background(), acct, name, on) if err != nil { log.Fatalf("store scan enforce: %v", err) } printScanStatus(stdout, res) } func printScanRequest(w io.Writer, res apiScanReply, encrypted bool) { c := colorFor(w) fmt.Fprintf(w, "%s %s\n", c.head("scanning requested for"), res.App) if encrypted { mutedf(w, " attested client-encrypted — no coverage will be claimed for this store,") mutedf(w, " because a scanner cannot read ciphertext.") return } if res.Scanning == nil || res.Scanning.Enabled == nil { mutedf(w, " nothing is scanning yet — this is a request a deployment operator fulfils") mutedf(w, " (carlos store scan grant --app %s).", res.App) } } // printScanStatus is where every honesty rule in this file's header actually // lives. func printScanStatus(w io.Writer, res apiScanReply) { c := colorFor(w) fmt.Fprintf(w, "%s %s\n", c.head("object scanning for"), res.App) fmt.Fprintf(w, "Queued objects: %d\n", res.QueueDepth) if res.Scanning == nil || (res.Scanning.Requested == nil && res.Scanning.Enabled == nil) { mutedf(w, "not requested — carlos store scan request --app %s --plaintext", res.App) return } // An engine can be enabled with no request on record — it should not be, // and the console now refuses it, but a record written before that refusal // existed can still look like this. Returning early on a nil Requested // would print "not requested" while an engine was actively reading these // objects, which is the worst sentence this command could produce. req := res.Scanning.Requested switch { case req == nil: warnf(w, "no attestation is on record for this store, but an engine is enabled — "+ "run carlos store scan request --app %s to say whether these objects are readable", res.App) default: fmt.Fprintf(w, " requested: by %s at %s\n", req.By, req.At.Format(time.RFC3339)) } // Rule 2, and it returns: an encrypted store gets no coverage line at all, // not a zeroed one. if req != nil && req.Encrypted { mutedf(w, " attested client-encrypted — a scanner cannot read these objects,") mutedf(w, " so NO coverage is claimed for this store and none can be.") return } if res.Scanning.Enabled == nil { mutedf(w, " no engine is enabled yet — waiting on a deployment operator") mutedf(w, " (carlos store scan grant --app %s)", res.App) return } en := res.Scanning.Enabled fmt.Fprintf(w, " engine: %s, enabled by %s at %s\n", en.Engine, en.By, en.At.Format(time.RFC3339)) printScanSignature(w, res) if res.Scanning.Enforce != nil { fmt.Fprintf(w, " enforcing: the app's own credentials cannot read an object tagged %s=%s\n", res.Scanning.Enforce.Key, res.Scanning.Enforce.Value) } else { mutedf(w, " not enforcing — infected objects are recorded, not blocked") mutedf(w, " (carlos store scan enforce --app %s on)", res.App) } printScanCoverage(w, res) } // printScanSignature carries rule 4. ClamAV answers // "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026" — engine, database version, // database date — and the date is the half that decides what a clean verdict // is worth. func printScanSignature(w io.Writer, res apiScanReply) { if res.EngineError != "" { warnf(w, "the engine is not answering: %s — no new verdicts are being reached", res.EngineError) return } if res.Signature == "" { return } // "now", not bare "signatures". A clean tag is reused for as long as the // object's ETag matches, so most of the rollup below may have been judged // against an older database than this one. printScanCoverage says how much. fmt.Fprintf(w, " engine database now: %s\n", res.Signature) age, ok := signatureAge(res.Signature, time.Now()) if !ok { return } max := time.Duration(res.MaxSignatureAgeSeconds) * time.Second if max > 0 && age > max { warnf(w, "those signatures are %d days old — a clean verdict from them is a weaker claim "+ "than a fresh one; update the engine's database", int(age.Hours()/24)) } } // signatureAge parses the date out of a clamd VERSION line. It returns ok // false rather than guessing when the shape is not the one we know: printing // nothing is honest, and inventing an age would undermine the very line it // was meant to strengthen. func signatureAge(version string, now time.Time) (time.Duration, bool) { parts := strings.Split(version, "/") if len(parts) < 3 { return 0, false } t, err := time.Parse(time.ANSIC, strings.TrimSpace(parts[2])) if err != nil { return 0, false } return now.Sub(t), true } // printScanCoverage carries rules 1 and 3. func printScanCoverage(w io.Writer, res apiScanReply) { cov := res.Coverage if cov == nil || (cov.Current == nil && cov.LastComplete == nil) { mutedf(w, " no sweep has finished yet — no coverage can be claimed for this store") return } // Rule 3: an in-flight sweep says so, and says which numbers are stale. if cov.Current != nil { fmt.Fprintf(w, " sweep in progress since %s: %d objects seen so far\n", cov.Current.StartedAt.Format(time.RFC3339), cov.Current.Objects) if cov.LastComplete == nil { mutedf(w, " no complete sweep yet — the numbers above are partial and cover only") mutedf(w, " part of the bucket, so no coverage can be claimed") return } mutedf(w, " the coverage below is from the last COMPLETE sweep, not from the one running now") } last := cov.LastComplete if last == nil { return } // Rule 1: two numbers, always. fmt.Fprintf(w, " as of %s: %d of %d objects scanned, %d not scanned\n", last.At.Format(time.RFC3339), last.Scanned, last.Objects, last.Unscanned) if last.Unscanned > 0 { fmt.Fprintf(w, " not scanned: %d too large or refused, %d denied, %d failed\n", last.Skipped, last.Denied, last.Failed) } // How much of that coverage was actually reached by THIS sweep. A verdict // is kept for as long as the object's bytes do not change, so a store that // has been quiet for months can report full coverage made up entirely of // old judgements — against signatures that are not the ones named above. // Printing the split is the difference between an honest number and a // number that merely looks current. if last.Reused > 0 { fmt.Fprintf(w, " of those, %d were judged during this sweep and %d carried forward\n", last.Fresh, last.Reused) mutedf(w, " a carried-forward verdict was reached against whatever database was") mutedf(w, " current then, not the one above; carlos store scan sweep re-runs nothing") mutedf(w, " whose bytes have not changed") } if last.Infected > 0 { warnf(w, "%d infected object(s) found — carlos store scan enforce --app %s on blocks reads of them", last.Infected, res.App) } }