Repository: sopel-irc/sopel

Issue:
Safety throws error with positives
<!-- Before reporting a bug, please search both open *and closed* issues to
see if it has already been reported. If you can, try to reproduce the problem
on an unmodified copy of the `master` branch first, as sometimes bugs are found
and fixed without a report. If the problem is unreported and persists in
`master`, please help us fix it quickly by filling out as much of this
information as you can. Thanks! -->

### Description
When Safety is enabled with a valid personal free API key whenever a new URL is seen by the bot in a safety enabled channel, it throws an error.

### Reproduction steps
1. Set `safety` to `on` or `strict`
2. Send new message containing a URL

### Expected behavior
What did you expect to happen instead?

### Logs
From the bot
```
<Ad> https://www.cnn.com/2020/04/21/uk/uk-death-toll-coronavirus-statistics-gbr-intl/index.html
<^quark^> [ UK coronavirus: Death toll in England and Wales 41% higher than government figures - CNN ] - www.cnn.com
<^quark^> Unexpected error ('positives') from Ad at 2020-04-21 21:47:29.752477. Message was: https://www.cnn.com/2020/04/21/uk/uk-death-toll-coronavirus-statistics-gbr-intl/index.html
```

From logs
```
[2020-04-21 21:47:29,752] sopel.bot            ERROR    - Unexpected error ('positives') from Ad at 2020-04-21 21:47:29.752477. Message was: https://www.cnn.com/2020/04/21/uk/uk-death-toll-coronavirus-statistics-gbr-intl/index.html
Traceback (most recent call last):
  File "/home/sopel/.local/lib/python3.8/site-packages/sopel-7.0.0-py3.8.egg/sopel/bot.py", line 590, in call
    exit_code = func(sopel, trigger)
  File "/home/sopel/.local/lib/python3.8/site-packages/sopel-7.0.0-py3.8.egg/sopel/modules/safety.py", line 166, in url_handler
    data = {'positives': result['positives'],
KeyError: 'positives'

```

### Environment
- Sopel `.version`: 7.0.1
- Sopel installed via: git 
- Python version: 3.8
- Operating system: alpine
- IRCd `/version`: UnrealIRCd-5.0.3.1
- Relevant plugins: safety

### Notes




Discussion:
OK, this is a pretty basic one now that I've looked at the API documentation and examined some sample responses in Postman. VirusTotal only sends `"positives"` in the JSON response if it's already scanned the link, so that key (obviously) isn't always present.

We tell VT `scan=1`, so it will go off and scan the link we give it if there's no existing scan report. The `safety` plugin must check back later to get the results; there's no mechanism to be proactively notified when the scan we initiated completes.

Of note: A free/public API key may only send 4 queries per minute. An empty response with HTTP status code 204 is returned if any quota (minute, day, or month) has been exceeded for the provided key, but there's no information about when the over-limit condition will abate (Twitter sends this info, and it's really helpful).

VirusTotal's API v3.0 is in beta (we use v2.0). It provides a semantically correct 429 error when the quota has been exceeded, according to the documentation, but I wasn't able to trigger an over-limit condition. Maybe the quota restrictions are relaxed during the beta period; it didn't seem important enough to investigate further just yet.

----

**Anyway:** I think for 7.0.2, the solution is switching to `.get('key', <default>)` instead of subscripting the dictionary, so we gracefully handle missing keys. It's Good Enough™ to assume that we'll (almost) never be the first to submit a malicious URL.

For 7.1, we can get fancier. I think we _could_ do with a revamp of this plugin to submit URLs and then check for results in ~30 sec if it gets a "scan queued" response instead of an immediate result. Doing such a rewrite for 7.0.2 is too much, though; it's just a maintenance release.

Resolving patch:
diff --git a/sopel/modules/safety.py b/sopel/modules/safety.py
--- a/sopel/modules/safety.py
+++ b/sopel/modules/safety.py
@@ -163,17 +163,20 @@ def url_handler(bot, trigger):
                 r.raise_for_status()
                 result = r.json()
                 fetched = time.time()
-                data = {'positives': result['positives'],
-                        'total': result['total'],
-                        'fetched': fetched}
-                bot.memory['safety_cache'][trigger] = data
-                if len(bot.memory['safety_cache']) >= (2 * cache_limit):
-                    _clean_cache(bot)
+                if all(k in result for k in ['positives', 'total']):
+                    # cache result only if it contains a scan report
+                    # TODO: handle checking back for results from queued scans
+                    data = {'positives': result['positives'],
+                            'total': result['total'],
+                            'fetched': fetched}
+                    bot.memory['safety_cache'][trigger] = data
+                    if len(bot.memory['safety_cache']) >= (2 * cache_limit):
+                        _clean_cache(bot)
             else:
                 print('using cache')
                 result = bot.memory['safety_cache'][trigger]
-            positives = result['positives']
-            total = result['total']
+            positives = result.get('positives', 0)
+            total = result.get('total', 0)
     except requests.exceptions.RequestException:
         # Ignoring exceptions with VT so MalwareDomains will always work
         LOGGER.debug('[VirusTotal] Error obtaining response.', exc_info=True)


Test patch: