Skip to content

Commit c0340f2

Browse files
authored
Add attachment integrity check (#1418)
* Add attachment integrity check * Update baselines
1 parent 7660485 commit c0340f2

File tree

4 files changed

+213
-41
lines changed

4 files changed

+213
-41
lines changed

.bandit.baseline

+13-36
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"errors": [],
3-
"generated_at": "2023-05-25T13:28:03Z",
3+
"generated_at": "2024-04-18T11:21:08Z",
44
"metrics": {
55
"_totals": {
6-
"CONFIDENCE.HIGH": 3,
6+
"CONFIDENCE.HIGH": 2,
77
"CONFIDENCE.LOW": 0,
88
"CONFIDENCE.MEDIUM": 0,
99
"CONFIDENCE.UNDEFINED": 0,
1010
"SEVERITY.HIGH": 0,
11-
"SEVERITY.LOW": 3,
11+
"SEVERITY.LOW": 2,
1212
"SEVERITY.MEDIUM": 0,
1313
"SEVERITY.UNDEFINED": 0,
1414
"loc": 883,
@@ -42,15 +42,15 @@
4242
"skipped_tests": 0
4343
},
4444
"telescope/app.py": {
45-
"CONFIDENCE.HIGH": 3,
45+
"CONFIDENCE.HIGH": 2,
4646
"CONFIDENCE.LOW": 0,
4747
"CONFIDENCE.MEDIUM": 0,
4848
"CONFIDENCE.UNDEFINED": 0,
4949
"SEVERITY.HIGH": 0,
50-
"SEVERITY.LOW": 3,
50+
"SEVERITY.LOW": 2,
5151
"SEVERITY.MEDIUM": 0,
5252
"SEVERITY.UNDEFINED": 0,
53-
"loc": 380,
53+
"loc": 382,
5454
"nosec": 0,
5555
"skipped_tests": 0
5656
},
@@ -63,7 +63,7 @@
6363
"SEVERITY.LOW": 0,
6464
"SEVERITY.MEDIUM": 0,
6565
"SEVERITY.UNDEFINED": 0,
66-
"loc": 89,
66+
"loc": 87,
6767
"nosec": 1,
6868
"skipped_tests": 0
6969
},
@@ -124,35 +124,12 @@
124124
"line_range": [
125125
6
126126
],
127-
"more_info": "https://bandit.readthedocs.io/en/1.7.5/blacklists/blacklist_imports.html#b404-import-subprocess",
127+
"more_info": "https://bandit.readthedocs.io/en/1.7.8/blacklists/blacklist_imports.html#b404-import-subprocess",
128128
"test_id": "B404",
129129
"test_name": "blacklist"
130130
},
131131
{
132-
"code": "218 # Check that `curl` has HTTP2 and HTTP3 for `checks.core.http_versions`\n219 curl_cmd = subprocess.run(\n220 [\"curl\", \"--version\"],\n221 capture_output=True,\n222 )\n223 output = curl_cmd.stdout.strip().decode()\n",
133-
"col_offset": 15,
134-
"end_col_offset": 5,
135-
"filename": "telescope/app.py",
136-
"issue_confidence": "HIGH",
137-
"issue_cwe": {
138-
"id": 78,
139-
"link": "https://cwe.mitre.org/data/definitions/78.html"
140-
},
141-
"issue_severity": "LOW",
142-
"issue_text": "Starting a process with a partial executable path",
143-
"line_number": 219,
144-
"line_range": [
145-
219,
146-
220,
147-
221,
148-
222
149-
],
150-
"more_info": "https://bandit.readthedocs.io/en/1.7.5/plugins/b607_start_process_with_partial_path.html",
151-
"test_id": "B607",
152-
"test_name": "start_process_with_partial_path"
153-
},
154-
{
155-
"code": "218 # Check that `curl` has HTTP2 and HTTP3 for `checks.core.http_versions`\n219 curl_cmd = subprocess.run(\n220 [\"curl\", \"--version\"],\n221 capture_output=True,\n222 )\n223 output = curl_cmd.stdout.strip().decode()\n",
132+
"code": "219 # Check that `curl` has HTTP2 and HTTP3 for `checks.core.http_versions`\n220 curl_cmd = subprocess.run(\n221 [config.CURL_BINARY_PATH, \"--version\"],\n222 capture_output=True,\n223 )\n224 output = curl_cmd.stdout.strip().decode()\n",
156133
"col_offset": 15,
157134
"end_col_offset": 5,
158135
"filename": "telescope/app.py",
@@ -163,14 +140,14 @@
163140
},
164141
"issue_severity": "LOW",
165142
"issue_text": "subprocess call - check for execution of untrusted input.",
166-
"line_number": 219,
143+
"line_number": 220,
167144
"line_range": [
168-
219,
169145
220,
170146
221,
171-
222
147+
222,
148+
223
172149
],
173-
"more_info": "https://bandit.readthedocs.io/en/1.7.5/plugins/b603_subprocess_without_shell_equals_true.html",
150+
"more_info": "https://bandit.readthedocs.io/en/1.7.8/plugins/b603_subprocess_without_shell_equals_true.html",
174151
"test_id": "B603",
175152
"test_name": "subprocess_without_shell_equals_true"
176153
}

.secrets.baseline

+17-5
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,6 @@
7575
{
7676
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
7777
},
78-
{
79-
"path": "detect_secrets.filters.common.is_baseline_file",
80-
"filename": ".secrets.baseline"
81-
},
8278
{
8379
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
8480
"min_level": 2
@@ -137,6 +133,22 @@
137133
"line_number": 23
138134
}
139135
],
136+
"tests/checks/remotesettings/test_attachments_integrity.py": [
137+
{
138+
"type": "Hex High Entropy String",
139+
"filename": "tests/checks/remotesettings/test_attachments_integrity.py",
140+
"hashed_secret": "263e1869fb57deeb75844cf85d55f0d03a6019fc",
141+
"is_verified": false,
142+
"line_number": 31
143+
},
144+
{
145+
"type": "Hex High Entropy String",
146+
"filename": "tests/checks/remotesettings/test_attachments_integrity.py",
147+
"hashed_secret": "fdf34abe05071170ce2ec082a18aa762ec454ae3",
148+
"is_verified": false,
149+
"line_number": 39
150+
}
151+
],
140152
"tests/checks/remotesettings/test_public_suffix_list.py": [
141153
{
142154
"type": "Hex High Entropy String",
@@ -147,5 +159,5 @@
147159
}
148160
]
149161
},
150-
"generated_at": "2023-09-19T13:14:09Z"
162+
"generated_at": "2024-04-18T11:22:16Z"
151163
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Every attachment in every collection has the right size and hash.
3+
4+
The URLs of invalid attachments is returned along with the number of checked records.
5+
"""
6+
7+
import hashlib
8+
9+
import aiohttp
10+
11+
from telescope.typings import CheckResult
12+
from telescope.utils import ClientSession, run_parallel
13+
14+
from .utils import KintoClient
15+
16+
17+
async def test_attachment(session, attachment):
18+
url = attachment["location"]
19+
try:
20+
async with session.get(url) as response:
21+
binary = await response.read()
22+
except aiohttp.client_exceptions.ClientError as exc:
23+
return {"url": url, "error": str(exc)}, False
24+
25+
if (bz := len(binary)) != (az := attachment["size"]):
26+
return {"url": url, "error": f"size differ ({bz}!={az})"}, False
27+
28+
h = hashlib.sha256(binary)
29+
if (bh := h.hexdigest()) != (ah := attachment["hash"]):
30+
return {"url": url, "error": f"hash differ ({bh}!={ah})"}, False
31+
32+
return {}, True
33+
34+
35+
async def run(server: str) -> CheckResult:
36+
client = KintoClient(server_url=server)
37+
38+
info = await client.server_info()
39+
base_url = info["capabilities"]["attachments"]["base_url"]
40+
41+
# Fetch collections records in parallel.
42+
entries = await client.get_monitor_changes()
43+
futures = [
44+
client.get_records(
45+
bucket=entry["bucket"],
46+
collection=entry["collection"],
47+
_expected=entry["last_modified"],
48+
)
49+
for entry in entries
50+
if "preview" not in entry["bucket"]
51+
]
52+
results = await run_parallel(*futures)
53+
54+
# For each record that has an attachment, check the attachment content.
55+
attachments = []
56+
for entry, records in zip(entries, results):
57+
for record in records:
58+
if "attachment" not in record:
59+
continue
60+
attachment = record["attachment"]
61+
attachment["location"] = base_url + attachment["location"]
62+
attachments.append(attachment)
63+
64+
async with ClientSession() as session:
65+
futures = [test_attachment(session, attachment) for attachment in attachments]
66+
results = await run_parallel(*futures)
67+
bad = [result for result, success in results if not success]
68+
return len(bad) == 0, {"bad": bad, "checked": len(attachments)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from checks.remotesettings.attachments_integrity import run
2+
3+
4+
RECORDS_URL = "/buckets/{}/collections/{}/records"
5+
6+
7+
async def test_positive(mock_responses, mock_aioresponses):
8+
server_url = "http://fake.local/v1"
9+
mock_responses.get(
10+
server_url + "/",
11+
payload={"capabilities": {"attachments": {"base_url": "http://cdn/"}}},
12+
)
13+
changes_url = server_url + RECORDS_URL.format("monitor", "changes")
14+
mock_responses.get(
15+
changes_url,
16+
payload={
17+
"data": [
18+
{"id": "abc", "bucket": "bid", "collection": "cid", "last_modified": 42}
19+
]
20+
},
21+
)
22+
records_url = server_url + RECORDS_URL.format("bid", "cid") + "?_expected=42"
23+
mock_responses.get(
24+
records_url,
25+
payload={
26+
"data": [
27+
{
28+
"id": "abc",
29+
"attachment": {
30+
"size": 5,
31+
"hash": "ed968e840d10d2d313a870bc131a4e2c311d7ad09bdf32b3418147221f51a6e2",
32+
"location": "file1.jpg",
33+
},
34+
},
35+
{
36+
"id": "efg",
37+
"attachment": {
38+
"size": 10,
39+
"hash": "bf2cb58a68f684d95a3b78ef8f661c9a4e5b09e82cc8f9cc88cce90528caeb27",
40+
"location": "file2.jpg",
41+
},
42+
},
43+
{"id": "ijk"},
44+
]
45+
},
46+
)
47+
mock_aioresponses.get("http://cdn/file1.jpg", body=b"a" * 5)
48+
mock_aioresponses.get("http://cdn/file2.jpg", body=b"a" * 10)
49+
50+
status, data = await run(server_url)
51+
52+
# assert status is True
53+
assert data == {"bad": [], "checked": 2}
54+
55+
56+
async def test_negative(mock_responses, mock_aioresponses):
57+
server_url = "http://fake.local/v1"
58+
mock_responses.get(
59+
server_url + "/",
60+
payload={"capabilities": {"attachments": {"base_url": "http://cdn/"}}},
61+
)
62+
changes_url = server_url + RECORDS_URL.format("monitor", "changes")
63+
mock_responses.get(
64+
changes_url,
65+
payload={
66+
"data": [
67+
{"id": "abc", "bucket": "bid", "collection": "cid", "last_modified": 42}
68+
]
69+
},
70+
)
71+
records_url = server_url + RECORDS_URL.format("bid", "cid") + "?_expected=42"
72+
mock_responses.get(
73+
records_url,
74+
payload={
75+
"data": [
76+
{
77+
"id": "abc",
78+
"attachment": {
79+
"size": 7,
80+
"hash": "ed968e840d10d2d313a870bc131a4e2c311d7ad09bdf32b3418147221f51a6e2",
81+
"location": "file1.jpg",
82+
},
83+
},
84+
{"id": "efg", "attachment": {"location": "missing.jpg"}},
85+
{
86+
"id": "ijk",
87+
"attachment": {"size": 10, "hash": "foo", "location": "file2.jpg"},
88+
},
89+
{"id": "lmn"},
90+
]
91+
},
92+
)
93+
mock_aioresponses.get("http://cdn/file1.jpg", body=b"a" * 5)
94+
mock_aioresponses.get("http://cdn/file2.jpg", body=b"a" * 10)
95+
96+
status, data = await run(server_url)
97+
98+
assert status is False
99+
assert data == {
100+
"bad": [
101+
{
102+
"error": "size differ (5!=7)",
103+
"url": "http://cdn/file1.jpg",
104+
},
105+
{
106+
"error": "Connection refused: GET http://cdn/missing.jpg",
107+
"url": "http://cdn/missing.jpg",
108+
},
109+
{
110+
"error": "hash differ (bf2cb58a68f684d95a3b78ef8f661c9a4e5b09e82cc8f9cc88cce90528caeb27!=foo)",
111+
"url": "http://cdn/file2.jpg",
112+
},
113+
],
114+
"checked": 3,
115+
}

0 commit comments

Comments
 (0)