Skip to content

Commit ab2adb7

Browse files
deacon-mpdeacon-mp
andauthored
feat: add search filter to payload list API (#3288)
* feat: add name filter query parameter to payload list API (#3055) Add optional `?name=` query parameter to `GET /api/v2/payloads` that performs a case-insensitive substring match on payload filenames, enabling callers to search/filter the payload list without retrieving and filtering the full set client-side. * fix: address Copilot review feedback on payload name filter - Change name_filter annotation to Optional[str] - Filter only on PurePosixPath.name to avoid matching directory segments when add_path=True (e.g. 'plugins/stockpile/payloads/file.txt' should only match on 'file.txt') - Strengthen filter tests to assert non-matching payloads are excluded * Fix flake8 E127: correct continuation line indentation in test_payloads_api.py * Address Copilot review: use PurePath for cross-platform compatibility Replace PurePosixPath with PurePath in both the payload handler and tests so basename extraction works correctly on Windows where paths may use backslash separators. Move pathlib import to module scope. * Address Copilot review: schema fix, stronger tests, combination test - Add allow_none=True to PayloadQuerySchema name field - Parametrize filter tests (matches + case-insensitive) - Strengthen assertions to verify no false positives - Add combination test with sort=true and add_path=true * Fix flake8 E127: continuation line indentation --------- Co-authored-by: deacon-mp <mperry@mitre.org>
1 parent e568f4a commit ab2adb7

File tree

3 files changed

+40
-1
lines changed

3 files changed

+40
-1
lines changed

app/api/v2/handlers/payload_api.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pathlib
55
import re
66
from io import IOBase
7+
from typing import Optional
78

89
import aiohttp_apispec
910
from aiohttp import web
@@ -27,14 +28,16 @@ def add_routes(self, app: web.Application):
2728

2829
@aiohttp_apispec.docs(tags=['payloads'],
2930
summary='Retrieve payloads',
30-
description='Retrieves all stored payloads.')
31+
description='Retrieves all stored payloads. Supports optional filtering by name '
32+
'(case-insensitive substring match via the `name` query parameter).')
3133
@aiohttp_apispec.querystring_schema(PayloadQuerySchema)
3234
@aiohttp_apispec.response_schema(PayloadSchema(),
3335
description='Returns a list of all payloads in PayloadSchema format.')
3436
async def get_payloads(self, request: web.Request):
3537
sort: bool = request['querystring'].get('sort')
3638
exclude_plugins: bool = request['querystring'].get('exclude_plugins')
3739
add_path: bool = request['querystring'].get('add_path')
40+
name_filter: Optional[str] = request['querystring'].get('name')
3841

3942
cwd = pathlib.Path.cwd()
4043
payload_dirs = [cwd / 'data' / 'payloads']
@@ -52,6 +55,11 @@ async def get_payloads(self, request: web.Request):
5255
}
5356

5457
payloads = list(payloads)
58+
59+
if name_filter:
60+
name_filter_lower = name_filter.lower()
61+
payloads = [p for p in payloads if name_filter_lower in pathlib.PurePath(p).name.lower()]
62+
5563
if sort:
5664
payloads.sort()
5765

app/api/v2/schemas/payload_schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class PayloadQuerySchema(schema.Schema):
55
sort = fields.Boolean(required=False, load_default=False)
66
exclude_plugins = fields.Boolean(required=False, load_default=False)
77
add_path = fields.Boolean(required=False, load_default=False)
8+
name = fields.String(required=False, load_default=None, allow_none=True)
89

910

1011
class PayloadSchema(schema.Schema):

tests/api/v2/handlers/test_payloads_api.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import pathlib
23
import tempfile
34
from http import HTTPStatus
45

@@ -49,6 +50,35 @@ async def test_get_payloads(self, api_v2_client, api_cookies, expected_payload_f
4950

5051
assert filtered_payload_file_names == expected_payload_file_names
5152

53+
@pytest.mark.parametrize('query_name', ['payload_', 'PAYLOAD_'])
54+
async def test_get_payloads_name_filter(self, api_v2_client, api_cookies, expected_payload_file_names, query_name):
55+
resp = await api_v2_client.get(f'/api/v2/payloads?name={query_name}', cookies=api_cookies)
56+
assert resp.status == HTTPStatus.OK
57+
payload_file_names = await resp.json()
58+
59+
# All expected payloads should be present
60+
assert expected_payload_file_names <= set(payload_file_names)
61+
# Every returned payload must match the filter (no false positives)
62+
assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_file_names)
63+
64+
async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookies):
65+
resp = await api_v2_client.get('/api/v2/payloads?name=__no_match_xyzzy__', cookies=api_cookies)
66+
assert resp.status == HTTPStatus.OK
67+
assert await resp.json() == []
68+
69+
async def test_get_payloads_name_filter_with_sort_and_add_path(
70+
self, api_v2_client, api_cookies, expected_payload_file_names):
71+
resp = await api_v2_client.get('/api/v2/payloads?name=payload_&sort=true&add_path=true', cookies=api_cookies)
72+
assert resp.status == HTTPStatus.OK
73+
payload_paths = await resp.json()
74+
75+
# Results should be sorted
76+
assert payload_paths == sorted(payload_paths)
77+
# Every returned path's filename must match the filter
78+
assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_paths)
79+
# Results should contain paths (not bare filenames)
80+
assert all(os.sep in p or '/' in p for p in payload_paths)
81+
5282
async def test_unauthorized_get_payloads(self, api_v2_client):
5383
resp = await api_v2_client.get('/api/v2/payloads')
5484
assert resp.status == HTTPStatus.UNAUTHORIZED

0 commit comments

Comments
 (0)