| 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. |
| 24 | package main |
| 25 | |
| 26 | import ( |
| 27 | "context" |
| 28 | "flag" |
| 29 | "fmt" |
| 30 | "io" |
| 31 | "log" |
| 32 | "os" |
| 33 | "strings" |
| 34 | "time" |
| 35 | ) |
| 36 | |
| 37 | const 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 | |
| 58 | Flags: --account/--account-id to disambiguate, --console to pick a console. |
| 59 | ` |
| 60 | |
| 61 | func 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. |
| 88 | func 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. |
| 111 | func 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 | |
| 134 | func 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. |
| 146 | func 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 | |
| 169 | func 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 | |
| 210 | func 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. |
| 226 | func 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. |
| 282 | func 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. |
| 309 | func 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. |
| 322 | func 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 | |