cli: carlos store scan
Six sub-verbs, and the honesty rules are the feature. They all live in printScanStatus so they are testable against a literal reply with no server at all: The coverage line names TWO numbers, always. "1000 scanned" alone is true and misleading when two hundred objects were too large to stream — nobody looked at those, and scanned and unscanned are not complements. A client-encrypted store never gets a coverage line, not even a zeroed one. The print layer refuses it even when the reply carries a rollup, because it must not depend on the server having got it right. A sweep in progress says so and says which numbers are stale. A first sweep that has not finished claims nothing: partial counts with no complete sweep behind them are not coverage. Signature age is called out — a clean verdict from three-month-old definitions is a weaker claim — and refuses to guess when the VERSION line is not the shape we know. Inventing an age would undermine the line it was meant to strengthen. The attestation flag is required with no default. Only the person who wrote the objects knows whether anyone else can read them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJkJkxZZLgW8X8A5tk69Sq
4 files changed,
+658
−3
apimode.go+124 −0scan.go+322 −0scan_test.go+198 −0store.go+14 −3
diff --git a/apimode.go b/apimode.go| index 0267d6b43..98e9e7f33 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1556,6 +1556,130 @@ type apiStoreReply struct { |
| MissingVars []string `json:"missing_vars"` |
| } |
| +// ---- store scanning (scan.go) ---------------------------------------- |
| + |
| +// The scan reply's blocks, mirrored here rather than imported from |
| +// internal/objstore, the way apiStoreProvisioned mirrors its own: this binary |
| +// decodes what the console sent, and a shared struct would make a server-side |
| +// field rename a client compile error instead of a wire question. |
| + |
| +type apiScanRequested struct { |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| + Encrypted bool `json:"encrypted,omitempty"` |
| +} |
| + |
| +type apiScanEnabled struct { |
| + Engine string `json:"engine"` |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| +} |
| + |
| +type apiScanEnforce struct { |
| + Key string `json:"key"` |
| + Value string `json:"value"` |
| +} |
| + |
| +type apiScanBlock struct { |
| + Requested *apiScanRequested `json:"requested,omitempty"` |
| + Enabled *apiScanEnabled `json:"enabled,omitempty"` |
| + Enforce *apiScanEnforce `json:"enforce,omitempty"` |
| +} |
| + |
| +// apiSweep is one reconciliation pass. Scanned and Unscanned are NOT |
| +// complements — skipped, denied and failed objects were never judged — which |
| +// is why the status line has to name two numbers. |
| +type apiSweep struct { |
| + StartedAt time.Time `json:"started_at"` |
| + At time.Time `json:"at"` |
| + Engine string `json:"engine"` |
| + Signature string `json:"signature,omitempty"` |
| + Objects int64 `json:"objects"` |
| + Scanned int64 `json:"scanned"` |
| + Infected int64 `json:"infected"` |
| + Skipped int64 `json:"skipped"` |
| + Denied int64 `json:"denied"` |
| + Failed int64 `json:"failed"` |
| + Unscanned int64 `json:"unscanned"` |
| + Bytes int64 `json:"bytes"` |
| + After string `json:"after,omitempty"` |
| +} |
| + |
| +type apiScanCoverage struct { |
| + NotApplicable bool `json:"not_applicable,omitempty"` |
| + Current *apiSweep `json:"current,omitempty"` |
| + LastComplete *apiSweep `json:"last_complete,omitempty"` |
| +} |
| + |
| +type apiScanReply struct { |
| + App string `json:"app"` |
| + Backend string `json:"backend"` |
| + Engine string `json:"engine"` |
| + EngineError string `json:"engine_error,omitempty"` |
| + Signature string `json:"signature,omitempty"` |
| + MaxSignatureAgeSeconds int64 `json:"max_signature_age_seconds"` |
| + Scanning *apiScanBlock `json:"scanning"` |
| + Coverage *apiScanCoverage `json:"coverage"` |
| +} |
| + |
| +// scanCall is every scan door's round trip: they all answer the same shape, so |
| +// there is one decoder and one place a reply could be misread. |
| +func (s *apiSession) scanCall(ctx context.Context, verb, path string, in any) (apiScanReply, error) { |
| + var status int |
| + var body []byte |
| + var err error |
| + if in == nil { |
| + status, body, err = s.getJSON(ctx, path) |
| + } else { |
| + status, body, err = s.postJSON(ctx, path, in) |
| + } |
| + if err != nil { |
| + return apiScanReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiScanReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiScanReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiScanReply{}, fmt.Errorf("%s: unreadable response: %w", verb, err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// scanStatus reads what is claimed about this store's objects. |
| +func (s *apiSession) scanStatus(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| + return s.scanCall(ctx, "store scan status", apiPath(a, app, "/store/scan"), nil) |
| +} |
| + |
| +// scanRequest records the member's request AND their attestation. encrypted is |
| +// sent explicitly, never omitted: the server refuses a body without it, |
| +// because there is no safe default for "can a scanner read these bytes". |
| +func (s *apiSession) scanRequest(ctx context.Context, a apiAcct, app string, encrypted bool) (apiScanReply, error) { |
| + in := struct { |
| + Encrypted bool `json:"encrypted"` |
| + }{encrypted} |
| + return s.scanCall(ctx, "store scan request", apiPath(a, app, "/store/scan"), in) |
| +} |
| + |
| +func (s *apiSession) scanGrant(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| + return s.scanCall(ctx, "store scan grant", apiPath(a, app, "/store/scan/grant"), struct{}{}) |
| +} |
| + |
| +func (s *apiSession) scanRevoke(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| + return s.scanCall(ctx, "store scan revoke", apiPath(a, app, "/store/scan/revoke"), struct{}{}) |
| +} |
| + |
| +func (s *apiSession) scanSweep(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| + return s.scanCall(ctx, "store scan sweep", apiPath(a, app, "/store/scan/sweep"), struct{}{}) |
| +} |
| + |
| +func (s *apiSession) scanEnforce(ctx context.Context, a apiAcct, app string, on bool) (apiScanReply, error) { |
| + in := struct { |
| + On bool `json:"on"` |
| + }{on} |
| + return s.scanCall(ctx, "store scan enforce", apiPath(a, app, "/store/scan/enforce"), in) |
| +} |
| + |
| // storeCreate asks the console to declare (or re-declare) a store for app, |
| // under envPrefix ("" takes the console's own default — objstore. |
| // DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a |
diff --git a/scan.go b/scan.go| new file mode 100644 |
| index 000000000..bb4e4004d |
| --- /dev/null |
| +++ b/scan.go |
| @@ -0,0 +1,322 @@ |
| +// 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 <request|status|grant|revoke|sweep|enforce> [flags] |
| + |
| + request --app <a> (--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 <a> 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 <a> operator-only: turn the engine on for this store. |
| + revoke --app <a> operator-only: turn it off, and clear enforcement. |
| + sweep --app <a> operator-only: run one budget of scanning now |
| + instead of waiting for the hourly pass. |
| + enforce --app <a> on|off |
| + operator-only: deny the app's own credentials read |
| + access to any object the engine tagged infected. |
| + |
| +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:]) |
| + 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 := fs.String("console", "", "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 <a> [--account <sqid-or-name>] [--console <origin>]" |
| + 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) |
| + } |
| + 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)) |
| + for _, a := range args { |
| + 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 <a> 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) |
| + |
| + if res.Scanning == nil || res.Scanning.Requested == nil { |
| + mutedf(w, "not requested — carlos store scan request --app %s --plaintext", res.App) |
| + return |
| + } |
| + req := res.Scanning.Requested |
| + 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.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 |
| + } |
| + fmt.Fprintf(w, " signatures: %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) |
| + } |
| + 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) |
| + } |
| +} |
diff --git a/scan_test.go b/scan_test.go| new file mode 100644 |
| index 000000000..33d8ef9e0 |
| --- /dev/null |
| +++ b/scan_test.go |
| @@ -0,0 +1,198 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func scanReplyFixture() apiScanReply { |
| + at := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) |
| + return apiScanReply{ |
| + App: "titogo", |
| + Backend: "aws", |
| + Engine: "clamav", |
| + Signature: "ClamAV 1.4.1/27500/" + time.Now().Format(time.ANSIC), |
| + MaxSignatureAgeSeconds: int64((168 * time.Hour) / time.Second), |
| + Scanning: &apiScanBlock{ |
| + Requested: &apiScanRequested{At: at, By: "m@example.com"}, |
| + Enabled: &apiScanEnabled{Engine: "clamav", At: at, By: "op@example.com"}, |
| + }, |
| + } |
| +} |
| + |
| +// The audit line names BOTH numbers. A bucket of over-size objects reporting |
| +// "1000 scanned" and nothing else would be true and misleading — nobody looked |
| +// at the other two hundred, and the sentence that hides that is the one an |
| +// auditor is entitled to catch. |
| +func TestPrintScanStatusNamesUnscannableCount(t *testing.T) { |
| + res := scanReplyFixture() |
| + res.Coverage = &apiScanCoverage{LastComplete: &apiSweep{ |
| + At: time.Now(), Objects: 1200, Scanned: 1000, Unscanned: 200, Skipped: 195, Failed: 5, |
| + }} |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if !strings.Contains(out, "1000 of 1200 objects scanned") { |
| + t.Fatalf("coverage line missing the two headline numbers:\n%s", out) |
| + } |
| + if !strings.Contains(out, "200 not scanned") { |
| + t.Fatalf("the unscanned count is not stated:\n%s", out) |
| + } |
| + if !strings.Contains(out, "195 too large or refused") { |
| + t.Fatalf("the breakdown of what was not scanned is missing:\n%s", out) |
| + } |
| +} |
| + |
| +// An encrypted store never gets the coverage sentence — not even a zeroed one. |
| +// A scanner cannot read ciphertext, so any number here would be a |
| +// true-looking claim about bytes nobody examined. |
| +func TestPrintScanStatusRefusesCoverageLineWhenEncrypted(t *testing.T) { |
| + res := scanReplyFixture() |
| + res.Scanning.Requested.Encrypted = true |
| + // Even with a rollup present — which should never happen, but the print |
| + // layer must not depend on the server having got it right. |
| + res.Coverage = &apiScanCoverage{LastComplete: &apiSweep{Objects: 10, Scanned: 10}} |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if strings.Contains(out, "objects scanned") { |
| + t.Fatalf("an encrypted store was given a coverage claim:\n%s", out) |
| + } |
| + if !strings.Contains(out, "client-encrypted") || !strings.Contains(out, "NO coverage") { |
| + t.Fatalf("the output does not say why there is no coverage:\n%s", out) |
| + } |
| +} |
| + |
| +// A sweep spanning ticks says so, and says which numbers are stale, rather |
| +// than implying the partial count is the answer. |
| +func TestPrintScanStatusShowsProgressMidSweep(t *testing.T) { |
| + res := scanReplyFixture() |
| + res.Coverage = &apiScanCoverage{ |
| + Current: &apiSweep{StartedAt: time.Now().Add(-2 * time.Hour), Objects: 300}, |
| + LastComplete: &apiSweep{At: time.Now().Add(-24 * time.Hour), Objects: 1200, Scanned: 1200}, |
| + } |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if !strings.Contains(out, "sweep in progress") { |
| + t.Fatalf("an in-flight sweep is not mentioned:\n%s", out) |
| + } |
| + if !strings.Contains(out, "last COMPLETE sweep") { |
| + t.Fatalf("the output does not say which numbers are stale:\n%s", out) |
| + } |
| + if !strings.Contains(out, "1200 of 1200 objects scanned") { |
| + t.Fatalf("the completed coverage is not shown:\n%s", out) |
| + } |
| +} |
| + |
| +// A first sweep that has not finished claims nothing at all. Partial counts |
| +// with no complete sweep behind them are not coverage. |
| +func TestPrintScanStatusClaimsNothingBeforeFirstCompleteSweep(t *testing.T) { |
| + res := scanReplyFixture() |
| + res.Coverage = &apiScanCoverage{Current: &apiSweep{StartedAt: time.Now(), Objects: 40}} |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if strings.Contains(out, "objects scanned") { |
| + t.Fatalf("a partial first sweep was printed as coverage:\n%s", out) |
| + } |
| + if !strings.Contains(out, "no coverage can be claimed") { |
| + t.Fatalf("the output does not refuse the claim outright:\n%s", out) |
| + } |
| +} |
| + |
| +// Old signatures are called out. A clean verdict from three-month-old |
| +// definitions is a weaker claim than a fresh one. |
| +func TestPrintScanStatusWarnsOnStaleSignatures(t *testing.T) { |
| + res := scanReplyFixture() |
| + old := time.Now().Add(-90 * 24 * time.Hour).Format(time.ANSIC) |
| + res.Signature = "ClamAV 1.4.1/27000/" + old |
| + res.Coverage = &apiScanCoverage{LastComplete: &apiSweep{At: time.Now(), Objects: 1, Scanned: 1}} |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if !strings.Contains(out, "days old") { |
| + t.Fatalf("stale signatures were not called out:\n%s", out) |
| + } |
| +} |
| + |
| +// A signature line we cannot parse produces NO age claim. Inventing one would |
| +// undermine the very line it was meant to strengthen. |
| +func TestSignatureAgeRefusesToGuess(t *testing.T) { |
| + for _, v := range []string{"ClamAV 1.4.1", "", "something/else", "ClamAV/27000/not a date"} { |
| + if _, ok := signatureAge(v, time.Now()); ok { |
| + t.Fatalf("signatureAge guessed an age from %q", v) |
| + } |
| + } |
| + good := "ClamAV 1.4.1/27500/" + time.Now().Add(-48*time.Hour).Format(time.ANSIC) |
| + age, ok := signatureAge(good, time.Now()) |
| + if !ok || age < 47*time.Hour || age > 49*time.Hour { |
| + t.Fatalf("signatureAge(%q) = %v, %v", good, age, ok) |
| + } |
| +} |
| + |
| +// An unreachable engine is reported as such, not silently omitted. "No new |
| +// verdicts are being reached" is the fact an operator needs. |
| +func TestPrintScanStatusReportsUnreachableEngine(t *testing.T) { |
| + res := scanReplyFixture() |
| + res.Signature = "" |
| + res.EngineError = "dial unix /run/clamav/clamd.ctl: connection refused" |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if !strings.Contains(out, "not answering") { |
| + t.Fatalf("an unreachable engine was not reported:\n%s", out) |
| + } |
| +} |
| + |
| +// A store nobody has requested scanning for says exactly that, and names the |
| +// verb that would start it. |
| +func TestPrintScanStatusUnrequested(t *testing.T) { |
| + res := apiScanReply{App: "titogo", Backend: "aws"} |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + out := b.String() |
| + |
| + if !strings.Contains(out, "not requested") { |
| + t.Fatalf("output = %q", out) |
| + } |
| + if !strings.Contains(out, "carlos store scan request --app titogo") { |
| + t.Fatalf("the output does not name the verb that starts it:\n%s", out) |
| + } |
| +} |
| + |
| +// Enabled but not enforcing is stated plainly. An operator reading "engine: |
| +// clamav" and assuming infected objects are blocked would be wrong, and the |
| +// difference is the whole of what enforcement buys. |
| +func TestPrintScanStatusDistinguishesEnabledFromEnforcing(t *testing.T) { |
| + res := scanReplyFixture() |
| + res.Coverage = &apiScanCoverage{LastComplete: &apiSweep{At: time.Now(), Objects: 2, Scanned: 2}} |
| + |
| + var b bytes.Buffer |
| + printScanStatus(&b, res) |
| + if !strings.Contains(b.String(), "not enforcing") { |
| + t.Fatalf("an enabled-but-not-enforcing store did not say so:\n%s", b.String()) |
| + } |
| + |
| + res.Scanning.Enforce = &apiScanEnforce{Key: "carlos-scan", Value: "infected"} |
| + var b2 bytes.Buffer |
| + printScanStatus(&b2, res) |
| + if !strings.Contains(b2.String(), "cannot read an object tagged carlos-scan=infected") { |
| + t.Fatalf("an enforcing store did not say what is blocked:\n%s", b2.String()) |
| + } |
| +} |
diff --git a/store.go b/store.go| index 8a7dfe5a0..2b94a67a2 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -6,7 +6,7 @@ |
| // there is nothing a headless box could do here that this CLI could |
| // meaningfully drive on its own. |
| // |
| -// Four sub-verbs, one flag family: |
| +// Five sub-verbs, one flag family: |
| // |
| // create declare that an app wants a store, under an env prefix. A |
| // REQUEST, not a provision — see printStoreCreate. |
| @@ -17,6 +17,9 @@ |
| // credentials. The one verb here that spends the deployment's |
| // own money. |
| // rotate mint a fresh credential; --finish retires the previous one. |
| +// scan the virus-scanning family (scan.go): request, status, grant, |
| +// revoke, sweep, enforce. Off by default and opt-in — a |
| +// deployment that sets nothing has no engine and no sweep. |
| // |
| // The output-honesty rule every line here follows is restart.go's: say what |
| // was REQUESTED or RECORDED, never what a box has since done with it. A grant |
| @@ -38,7 +41,7 @@ import ( |
| "time" |
| ) |
| -const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags] |
| +const storeUsage = `usage: carlos store <create|status|grant|rotate|scan> [flags] |
| create --app <a> [--env-prefix <PREFIX>] |
| declare that this app wants object storage, under |
| @@ -59,6 +62,12 @@ const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags] |
| mint a fresh credential, leaving the old one live |
| until --finish deletes it. A member verb: spends |
| nothing new, so there is no operator gate here. |
| + scan <sub-verb> --app <a> |
| + virus scanning over the store's objects — off by |
| + default. Run carlos store scan on its own to list |
| + the sub-verbs. A clean result on a client-encrypted |
| + store means nothing, and the CLI says so rather |
| + than claiming coverage it cannot have. |
| --account <sqid|name> account the app belongs to (default: your only |
| account, or the configured/chosen one) — |
| @@ -70,7 +79,7 @@ const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags] |
| Signed-in only (carlos auth login): the store record lives in the console's |
| own deployment bucket and its grant/rotate walks touch the deployment's own |
| -cloud account — there is no direct-bucket equivalent for any of these four |
| +cloud account — there is no direct-bucket equivalent for any of these |
| verbs. |
| ` |
| @@ -91,6 +100,8 @@ func runStore(args []string) { |
| runStoreGrant(args[1:]) |
| case "rotate": |
| runStoreRotate(args[1:]) |
| + case "scan": |
| + runStoreScan(args[1:]) |
| default: |
| fmt.Fprint(os.Stderr, storeUsage) |
| os.Exit(1) |