Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/topics/api/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ These are `v5` specific changes - `v4` changes apply also.
* 2026-02-19: added ``listingcontentreview`` endpoint for addons. https://github.com/mozilla/addons/issues/16050
* 2026-02-19: added the ability to patch a scanner result. https://github.com/mozilla/addons/issues/16004
* 2026-03-05: removed /scanner/results/ (internal API endpoint). https://github.com/mozilla/addons/issues/16088
* 2026-04-02: added /scanner/results/ endpoint to allow scanners to push results. https://github.com/mozilla/addons/issues/16115

.. _`#11380`: https://github.com/mozilla/addons-server/issues/11380/
.. _`#11379`: https://github.com/mozilla/addons-server/issues/11379/
Expand Down
22 changes: 22 additions & 0 deletions docs/topics/api/scanners.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ Scanners
These APIs are subject to change at any time and are for internal use only.


---------------------
Post - Push results
---------------------

.. _scanner-result-push:

This endpoint allows a scanner to push results for an existing version.

.. note::
Requires JWT authentication using the service account credentials
associated with the scanner webhook.

.. http:post:: /api/v5/scanner/results/

:<json integer version_id: The primary key of the add-on version.
:<json object results: The scanner results.
:statuscode 201: Result created successfully.
:statuscode 400: Invalid payload.
:statuscode 403: Authentication failed or the authenticated user is not
the service account of an active webhook with a push event.


----------------------
Patch - Update results
----------------------
Expand Down
50 changes: 45 additions & 5 deletions docs/topics/development/scanner_pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ determines how AMO handles the response:
Any other status code or a response body containing an `error` field is
unsupported and/or likely to be treated as a failure.

(synchronous-response)=
#### Synchronous response

Scanners can return a JSON response immediately that contains the following fields:
Expand Down Expand Up @@ -162,12 +163,15 @@ Add one or more [Scanner Webhook Events](#scanner-webhook-events).

```{note}
Upon creation, a _service account_ will be automatically generated for this
scanner webhook.
scanner webhook. The service account is automatically granted the
`Scanners:PatchResults` permission to [submit its results
asynchronously](#asynchronous-scanning).

A service account is needed to authenticate the scanner against the AMO API.
Make sure to add the relevant permissions to it, depending on what the scanner
needs to access.
```

### Creating a new scanner

We provide a library to quickly develop new scanners written with Node.js:
Expand Down Expand Up @@ -310,9 +314,7 @@ The payload sent looks like this:
},
"guid": "{887ea080-e5f1-4363-99d3-f90fb8594967}",
"type": "extension",
"categories": [
"photos-music-videos"
],
"categories": ["photos-music-videos"],
"is_featured": false,
"is_source_public": false,
"is_disabled": false,
Expand Down Expand Up @@ -413,13 +415,51 @@ The payload sent looks like this:
}
```

### `push`

In addition to responding to AMO-initiated webhook events, a scanner can
proactively **push** results for an existing version using the {ref}`push
endpoint <scanner-result-push>`. This is useful for scanners that operate on
their own schedule or that re-scan versions independently of AMO events.

#### Request

Scanners can push results by sending a POST request to `/api/v5/scanner/results/`
using their JWT service account credentials. The service account must have the
`Scanners:PushResults` permission. The request body must include the version to
attach results to and the scan results:

```json
{
"version_id": 123,
"results": {
"version": "1.0.0",
"matchedRules": ["RULE_1"],
"annotations": {}
}
}
```

| Field | Type | Description |
| ------------ | ------- | ----------------------------------------------------------------- |
| `version_id` | integer | The primary key of the add-on version to attach results to |
| `results` | object | Same structure as a [synchronous response](#synchronous-response) |

#### Responses

| Status code | Meaning |
| ----------------- | ---------------------------------------------------------------------------------------------- |
| `201 Created` | Result created successfully |
| `400 Bad Request` | Validation error (unknown version, missing fields, extra fields) |
| `403 Forbidden` | Not authenticated as a scanner service account, or no active webhook with a `push` event found |

### Adding a new event

1. Add a constant for the new event in `src/olympia/constants/scanners.py`. The
name must start with `WEBHOOK_`. Make sure the new constant is registered in
`WEBHOOK_EVENTS` (in the same file).
2. In a `tasks.py` file, create a Celery task that calls `call_webhooks(event_id,
payload, upload=none, version=None, activity_log=None)`. Make sure this task
payload, upload=none, version=None, activity_log=None)`. Make sure this task
is assigned to a queue in `src/olympia/lib/settings_base.py`.
3. Invoke this Celery task (with `.delay()`) where the event occurs in the code.
4. Update this documentation page.
Expand Down
4 changes: 4 additions & 0 deletions requirements/prod.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,10 @@ jsonschema==4.26.0 \
--hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
--hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
# Required by jsonschema
attrs==25.4.0 \
--hash=sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11 \
--hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
# Required by jsonschema
referencing==0.37.0 \
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
--hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
Expand Down
3 changes: 3 additions & 0 deletions src/olympia/constants/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
# Scanners can use a special endpoint to update their results.
SCANNERS_PATCH_RESULTS = AclPermission('Scanners', 'PatchResults')

# Scanners can use a special endpoint to push their results.
SCANNERS_PUSH_RESULTS = AclPermission('Scanners', 'PushResults')

# Can submit add-ons signed with Mozilla internal certificate, or add-ons with
# a guid ending with reserved suffixes like @mozilla.com
SYSTEM_ADDON_SUBMIT = AclPermission('SystemAddon', 'Submit')
Expand Down
2 changes: 2 additions & 0 deletions src/olympia/constants/scanners.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@
WEBHOOK_DURING_VALIDATION = 1
WEBHOOK_ON_SOURCE_CODE_UPLOADED = 2
WEBHOOK_ON_VERSION_CREATED = 3
WEBHOOK_PUSH = 4

WEBHOOK_EVENTS = {
WEBHOOK_DURING_VALIDATION: 'during_validation',
WEBHOOK_ON_SOURCE_CODE_UPLOADED: 'on_source_code_uploaded',
WEBHOOK_ON_VERSION_CREATED: 'on_version_created',
WEBHOOK_PUSH: 'push',
}

# Special rule name used as fallback when a scanner has no better rule to
Expand Down
3 changes: 2 additions & 1 deletion src/olympia/scanners/api_urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django.urls import re_path

from .views import patch_scanner_result
from .views import patch_scanner_result, push_scanner_result


urlpatterns = [
re_path(r'^results/$', push_scanner_result, name='scanner-result-push'),
re_path(
r'^results/(?P<pk>\d+)/$',
patch_scanner_result,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 4.2 on 2026-03-11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('scanners', '0079_scannerwebhook_service_account'),
]

operations = [
migrations.AlterField(
model_name='scannerwebhookevent',
name='event',
field=models.PositiveSmallIntegerField(
choices=[
(1, 'during_validation'),
(2, 'on_source_code_uploaded'),
(3, 'on_version_created'),
(4, 'push'),
]
),
),
]
59 changes: 59 additions & 0 deletions src/olympia/scanners/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.urls import reverse

import jsonschema
from rest_framework import serializers

from olympia.addons.models import Addon
Expand Down Expand Up @@ -65,6 +66,64 @@ def get_download_source_url(self, obj):
return absolutify(reverse('downloads.source', kwargs={'version_id': obj.id}))


class PushScannerResultSerializer(serializers.Serializer):
RESULTS_SCHEMA = {
'type': 'object',
'required': ['version', 'matchedRules'],
'properties': {
'version': {'type': 'string'},
'matchedRules': {
'type': 'array',
'items': {'type': 'string'},
},
'annotations': {
'type': 'object',
'additionalProperties': {
'type': 'array',
'items': {'type': 'object'},
},
},
},
}

version_id = serializers.IntegerField()
results = serializers.JSONField()

def validate_version_id(self, value):
try:
Version.unfiltered.get(pk=value)
except Version.DoesNotExist as err:
raise serializers.ValidationError('Version not found.') from err

return value

def validate_results(self, value):
try:
jsonschema.validate(value, self.RESULTS_SCHEMA)
except jsonschema.ValidationError as e:
raise serializers.ValidationError(e.message) from e

if 'annotations' in value:
matched_rules = set(value['matchedRules'])
unknown_keys = set(value['annotations'].keys()) - matched_rules
if unknown_keys:
raise serializers.ValidationError(
f'Annotation keys not in matchedRules: {sorted(unknown_keys)}'
)

return value

def validate(self, data):
if hasattr(self, 'initial_data'):
extra_fields = set(self.initial_data.keys()) - set(self.fields.keys())
if extra_fields:
raise serializers.ValidationError(
{field: 'Unexpected field.' for field in extra_fields}
)

return data


class PatchScannerResultSerializer(serializers.Serializer):
"""Serializer for updating scanner result data via PATCH endpoint."""

Expand Down
81 changes: 81 additions & 0 deletions src/olympia/scanners/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from olympia.amo.templatetags.jinja_helpers import absolutify
from olympia.amo.tests import TestCase, addon_factory, reverse_ns, version_factory
from olympia.scanners.serializers import (
PushScannerResultSerializer,
WebhookAddonSerializer,
WebhookVersionSerializer,
)
Expand Down Expand Up @@ -106,3 +107,83 @@ def test_download_source_url_with_source(self):
assert data['download_source_url'] == absolutify(
reverse('downloads.source', kwargs={'version_id': self.version.id})
)


class TestPushScannerResultSerializer(TestCase):
def setUp(self):
super().setUp()
self.version = version_factory(addon=addon_factory())
self.valid_results = {'version': '1.0.0', 'matchedRules': []}

def serialize(self, data):
serializer = PushScannerResultSerializer(data=data)
serializer.is_valid()
return serializer

def test_valid(self):
serializer = self.serialize(
{'version_id': self.version.pk, 'results': self.valid_results}
)
assert not serializer.errors
assert serializer.validated_data['version_id'] == self.version.pk
assert serializer.validated_data['results'] == self.valid_results

def test_version_id_not_found(self):
serializer = self.serialize(
{'version_id': 999999, 'results': self.valid_results}
)
assert serializer.errors == {'version_id': ['Version not found.']}

def test_missing_version_id(self):
serializer = self.serialize({'results': self.valid_results})
assert 'version_id' in serializer.errors

def test_missing_results(self):
serializer = self.serialize({'version_id': self.version.pk})
assert 'results' in serializer.errors

def test_results_missing_scanner_version(self):
serializer = self.serialize(
{'version_id': self.version.pk, 'results': {'matchedRules': []}}
)
assert 'results' in serializer.errors

def test_results_missing_matched_rules(self):
serializer = self.serialize(
{'version_id': self.version.pk, 'results': {'version': '1.0.0'}}
)
assert 'results' in serializer.errors

def test_results_extra_property_allowed(self):
results = {'version': '1.0.0', 'matchedRules': [], 'unexpected': 'field'}
serializer = self.serialize({'version_id': self.version.pk, 'results': results})
assert not serializer.errors

def test_results_with_valid_annotations(self):
results = {
'version': '1.0.0',
'matchedRules': ['RULE_1'],
'annotations': {'RULE_1': [{'message': 'found something'}]},
}
serializer = self.serialize({'version_id': self.version.pk, 'results': results})
assert not serializer.errors

def test_results_annotation_key_not_in_matched_rules(self):
results = {
'version': '1.0.0',
'matchedRules': [],
'annotations': {'UNKNOWN_RULE': [{'message': 'oops'}]},
}
serializer = self.serialize({'version_id': self.version.pk, 'results': results})
assert 'results' in serializer.errors
assert 'UNKNOWN_RULE' in serializer.errors['results'][0]

def test_extra_top_level_field_not_allowed(self):
serializer = self.serialize(
{
'version_id': self.version.pk,
'results': self.valid_results,
'unexpected': 'value',
}
)
assert 'unexpected' in serializer.errors
Loading
Loading