Skip to content

Commit 0efd60a

Browse files
authored
Improve Python bindings code quality (Codacy/Bandit cleanup) (#4084)
1 parent c407438 commit 0efd60a

34 files changed

Lines changed: 1810 additions & 121 deletions

.github/workflows/test-python-bindings.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@ permissions:
3333
contents: read
3434

3535
jobs:
36+
bandit:
37+
name: Bandit security scan (bindings/python)
38+
runs-on: ubuntu-latest
39+
steps:
40+
- name: Checkout code
41+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
42+
43+
- name: Set up Python
44+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
45+
with:
46+
python-version: "3.12"
47+
48+
- name: Install Bandit
49+
run: python -m pip install "bandit==1.9.4"
50+
51+
- name: Run Bandit on src and tests (must be clean)
52+
working-directory: bindings/python
53+
run: python -m bandit -c pyproject.toml -r src tests --severity-level low --confidence-level low
54+
55+
- name: Run Bandit on examples (must be clean at medium+/high-confidence)
56+
working-directory: bindings/python
57+
run: python -m bandit -c pyproject.toml -r examples --severity-level medium --confidence-level high
58+
3659
# First job: Download ArcadeDB JARs (platform-agnostic)
3760
download-jars:
3861
name: Download ArcadeDB JARs

bindings/python/examples/11_vector_index_build.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,10 @@ def get_docker_version() -> str | None:
9595

9696

9797
def fetch_json(url: str) -> dict:
98+
if not url.startswith("https://"):
99+
raise ValueError(f"Refusing to open non-HTTPS URL: {url!r}")
98100
req = Request(url, headers={"User-Agent": "arcadedb-bench"})
99-
with urlopen(req, timeout=30) as response:
101+
with urlopen(req, timeout=30) as response: # nosec B310 - https-only
100102
payload = json.load(response)
101103
if not isinstance(payload, dict):
102104
raise RuntimeError(f"Expected JSON object from {url}")
@@ -870,7 +872,9 @@ def get_qdrant_version(client) -> str | None:
870872

871873

872874
def qdrant_project_name(db_path: Path) -> str:
873-
digest = hashlib.sha1(str(db_path).encode("utf-8")).hexdigest()[:10]
875+
digest = hashlib.sha1(
876+
str(db_path).encode("utf-8"), usedforsecurity=False
877+
).hexdigest()[:10]
874878
return f"arcadb-qdrant-{digest}"
875879

876880

@@ -960,7 +964,9 @@ def wait_for_qdrant_ready(host: str, port: int, timeout_sec: int = 120) -> None:
960964
while True:
961965
for url in urls:
962966
try:
963-
with urlopen(url, timeout=3) as response:
967+
with urlopen(
968+
url, timeout=3
969+
) as response: # nosec B310 - localhost health-check URL
964970
if 200 <= int(response.status) < 500:
965971
return
966972
except Exception:
@@ -1010,7 +1016,9 @@ def ensure_milvus_compose_file(compose_file: Path, release_tag: str) -> None:
10101016
"https://github.com/milvus-io/milvus/releases/download/"
10111017
f"{release_tag}/milvus-standalone-docker-compose.yml"
10121018
)
1013-
urlretrieve(url, str(compose_file))
1019+
urlretrieve(
1020+
url, str(compose_file)
1021+
) # nosec B310 - url is a hardcoded https://github.com URL
10141022
raw = compose_file.read_text(encoding="utf-8")
10151023

10161024
sanitized = re.sub(r"(?m)^\s*container_name:\s*.*\n", "", raw)
@@ -1024,7 +1032,9 @@ def ensure_milvus_compose_file(compose_file: Path, release_tag: str) -> None:
10241032

10251033

10261034
def milvus_project_name(db_path: Path) -> str:
1027-
digest = hashlib.sha1(str(db_path).encode("utf-8")).hexdigest()[:10]
1035+
digest = hashlib.sha1(
1036+
str(db_path).encode("utf-8"), usedforsecurity=False
1037+
).hexdigest()[:10]
10281038
return f"arcadb-milvus-{digest}"
10291039

10301040

bindings/python/examples/12_vector_search.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,10 @@ def get_docker_version() -> str | None:
100100

101101

102102
def fetch_json(url: str) -> dict:
103+
if not url.startswith("https://"):
104+
raise ValueError(f"Refusing to open non-HTTPS URL: {url!r}")
103105
req = Request(url, headers={"User-Agent": "arcadedb-bench"})
104-
with urlopen(req, timeout=30) as response:
106+
with urlopen(req, timeout=30) as response: # nosec B310 - https-only
105107
payload = json.load(response)
106108
if not isinstance(payload, dict):
107109
raise RuntimeError(f"Expected JSON object from {url}")
@@ -914,7 +916,9 @@ def get_qdrant_version(client) -> str | None:
914916

915917

916918
def qdrant_project_name(db_path: Path) -> str:
917-
digest = hashlib.sha1(str(db_path).encode("utf-8")).hexdigest()[:10]
919+
digest = hashlib.sha1(
920+
str(db_path).encode("utf-8"), usedforsecurity=False
921+
).hexdigest()[:10]
918922
return f"arcadb-qdrant-{digest}"
919923

920924

@@ -1004,7 +1008,9 @@ def wait_for_qdrant_ready(host: str, port: int, timeout_sec: int = 120) -> None:
10041008
while True:
10051009
for url in urls:
10061010
try:
1007-
with urlopen(url, timeout=3) as response:
1011+
with urlopen(
1012+
url, timeout=3
1013+
) as response: # nosec B310 - localhost health-check URL
10081014
if 200 <= int(response.status) < 500:
10091015
return
10101016
except Exception:
@@ -1120,7 +1126,8 @@ def run_repeated_search(
11201126
**run_stats,
11211127
"run": run_idx + 1,
11221128
"query_order_hash": hashlib.sha1(
1123-
",".join(str(v) for v in run_qids).encode("utf-8")
1129+
",".join(str(v) for v in run_qids).encode("utf-8"),
1130+
usedforsecurity=False,
11241131
).hexdigest(),
11251132
}
11261133
per_run_stats.append(run_stats)
@@ -1240,7 +1247,9 @@ def ensure_milvus_compose_file(compose_file: Path, release_tag: str) -> None:
12401247
"https://github.com/milvus-io/milvus/releases/download/"
12411248
f"{release_tag}/milvus-standalone-docker-compose.yml"
12421249
)
1243-
urlretrieve(url, str(compose_file))
1250+
urlretrieve(
1251+
url, str(compose_file)
1252+
) # nosec B310 - url is a hardcoded https://github.com URL
12441253
raw = compose_file.read_text(encoding="utf-8")
12451254

12461255
sanitized = re.sub(r"(?m)^\s*version\s*:\s*.*\n", "", raw)
@@ -1255,7 +1264,9 @@ def ensure_milvus_compose_file(compose_file: Path, release_tag: str) -> None:
12551264

12561265

12571266
def milvus_project_name(db_path: Path) -> str:
1258-
digest = hashlib.sha1(str(db_path).encode("utf-8")).hexdigest()[:10]
1267+
digest = hashlib.sha1(
1268+
str(db_path).encode("utf-8"), usedforsecurity=False
1269+
).hexdigest()[:10]
12591270
return f"arcadb-milvus-{digest}"
12601271

12611272

bindings/python/examples/16_import_database_vs_transactional_graph_ingest.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ def edge_endpoints(edge_id: int, vertex_count: int) -> Tuple[int, int]:
131131

132132

133133
def build_rid_lookup_for_vertex_type(db, vertex_type: str) -> Dict[int, str]:
134-
rows = db.query("sql", f"SELECT Id, @rid as rid FROM {vertex_type}").to_list()
134+
rows = db.query(
135+
"sql",
136+
f"SELECT Id, @rid as rid FROM {vertex_type}", # nosec B608 - vertex_type is a script constant
137+
).to_list()
135138
rid_lookup: Dict[int, str] = {}
136139
for row in rows:
137140
row_id = row.get("Id")
@@ -165,7 +168,12 @@ def collect_vertex_sample(
165168
db, vertex_type: str, vertex_id: int, props: List[ColumnDef]
166169
) -> dict:
167170
row = query_one_or_none(
168-
db.query("sql", f"SELECT FROM {vertex_type} WHERE Id = {vertex_id}")
171+
db.query(
172+
"sql",
173+
# vertex_type is a constant from this script; vertex_id is bound as parameter.
174+
f"SELECT FROM {vertex_type} WHERE Id = ?", # nosec B608
175+
vertex_id,
176+
)
169177
)
170178
if row is None:
171179
return {"Id": vertex_id, "missing": True}
@@ -246,7 +254,8 @@ def collect_graph_signature(
246254
vertex_aggregate = query_one_or_none(
247255
db.query(
248256
"sql",
249-
f"SELECT {', '.join(vertex_aggregate_fields)} FROM {vertex_type}",
257+
# vertex_aggregate_fields and vertex_type are script-local constants.
258+
f"SELECT {', '.join(vertex_aggregate_fields)} FROM {vertex_type}", # nosec B608
250259
)
251260
)
252261
edge_aggregate = query_one_or_none(

bindings/python/examples/17_timeseries_end_to_end.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,10 +338,13 @@ def main() -> int:
338338
for row in db.query(
339339
"sql",
340340
"SELECT FROM SensorReading "
341-
f"WHERE ts BETWEEN {raw_window_start} AND {raw_window_end} "
342-
f"AND sensor_id = '{focus_sensor.sensor_id}' "
343-
f"AND building = '{focus_sensor.building}' "
341+
"WHERE ts BETWEEN ? AND ? "
342+
"AND sensor_id = ? AND building = ? "
344343
"ORDER BY ts",
344+
raw_window_start,
345+
raw_window_end,
346+
focus_sensor.sensor_id,
347+
focus_sensor.building,
345348
)
346349
]
347350
print_rows(

bindings/python/examples/20_graph_algorithms_route_planning.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,8 @@ def insert_seed_data(db) -> None:
284284
for route in ROUTES:
285285
db.command(
286286
"sql",
287-
f"CREATE EDGE {route['edge_type']} "
287+
# route['edge_type'] is a constant from the demo schema.
288+
f"CREATE EDGE {route['edge_type']} " # nosec B608
288289
"FROM (SELECT FROM City WHERE code = ? LIMIT 1) "
289290
"TO (SELECT FROM City WHERE code = ? LIMIT 1) "
290291
"SET distance = ?, duration = ?, risk = ?, lane = ?",
@@ -755,7 +756,10 @@ def run_reopen_phase(db_path: Path) -> None:
755756
.get("count")
756757
)
757758
route_count = sum(
758-
reopened_db.query("sql", f"SELECT count(*) AS count FROM {edge_type}")
759+
reopened_db.query(
760+
"sql",
761+
f"SELECT count(*) AS count FROM {edge_type}", # nosec B608 - edge_type is a script constant
762+
)
759763
.first()
760764
.get("count")
761765
for edge_type in ("Road", "Rail", "Ferry")

bindings/python/examples/21_server_mode_http_access.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,14 @@ def http_json_request(
164164
request_headers["Content-Type"] = "application/json"
165165

166166
request = Request(url, data=data, headers=request_headers, method=method)
167+
if not request.full_url.startswith(
168+
("http://localhost", "http://127.0.0.1", "https://")
169+
):
170+
raise ValueError(f"Refusing to call unexpected URL: {request.full_url!r}")
167171
try:
168-
with urlopen(request, timeout=timeout) as response:
172+
with urlopen(
173+
request, timeout=timeout
174+
) as response: # nosec B310 - localhost or https
169175
body = response.read().decode("utf-8")
170176
except HTTPError as exc:
171177
detail = exc.read().decode("utf-8", errors="replace")

bindings/python/examples/22_graph_analytical_view_sql.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@ def query_direct_neighbor_sample(
539539

540540

541541
def query_two_hop_summary(db, origin_code: str) -> dict:
542+
# origin_code is a script-local constant from the demo dataset.
542543
result = db.query(
543544
"sql",
544545
f"""
@@ -550,7 +551,7 @@ def query_two_hop_summary(db, origin_code: str) -> dict:
550551
{{type: City, as: dst}}
551552
RETURN DISTINCT dst.code AS code
552553
)
553-
""",
554+
""", # nosec B608 - demo-data constants only
554555
)
555556
row = result.first()
556557
require(row is not None, "Expected a two-hop summary row")
@@ -567,7 +568,7 @@ def query_hub_inbound_count(db, hub_code: str) -> int:
567568
{{type: City, as: hub, where: (code = '{hub_code}')}}
568569
RETURN src.code AS code
569570
)
570-
""",
571+
""", # nosec B608 - demo-data constants only
571572
)
572573
row = result.first()
573574
require(row is not None, "Expected an inbound count row")
@@ -583,7 +584,7 @@ def query_region_sample(db, sample_limit: int) -> list[dict]:
583584
GROUP BY region
584585
ORDER BY region
585586
LIMIT {sample_limit}
586-
""",
587+
""", # nosec B608 - sample_limit is a script integer constant
587588
)
588589
return rows_to_dicts(result, ["region", "city_count", "avg_demand"])
589590

bindings/python/examples/download_data.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@
113113
tqdm = None
114114

115115

116+
def _require_https(url: str) -> str:
117+
"""Reject non-HTTPS URLs before opening them.
118+
119+
Bandit B310 flags urlopen() because it permits file:// and custom schemes.
120+
Examples download from a fixed list of HTTPS dataset URLs, so we enforce
121+
that contract explicitly here.
122+
"""
123+
if not url.startswith("https://"):
124+
raise ValueError(f"Refusing to open non-HTTPS URL: {url!r}")
125+
return url
126+
127+
116128
def ensure_clean_dir(path: Path, label: str) -> None:
117129
if path.exists():
118130
print(f"[CLEAN] Removing existing {label} directory: {path}")
@@ -188,8 +200,10 @@ def _download_with_python(
188200
f"[DOWNLOAD] Resuming {destination.name} from {_format_bytes(resume_from)}"
189201
)
190202

191-
request = urllib.request.Request(url, headers=headers)
192-
with urllib.request.urlopen(request, timeout=60) as response:
203+
request = urllib.request.Request(_require_https(url), headers=headers)
204+
with urllib.request.urlopen(
205+
request, timeout=60
206+
) as response: # nosec B310 - https-only
193207
status = getattr(response, "status", response.getcode())
194208

195209
if resume_from > 0 and status != 206:
@@ -649,7 +663,9 @@ def report_progress(block_num, block_size, total_size):
649663
end="",
650664
)
651665

652-
urllib.request.urlretrieve(url, zip_path, reporthook=report_progress)
666+
urllib.request.urlretrieve(
667+
_require_https(url), zip_path, reporthook=report_progress
668+
) # nosec B310 - https-only
653669
print() # New line after progress
654670
download_elapsed = time.time() - download_start
655671
print(f"[OK] Downloaded to: {zip_path} " f"({download_elapsed:.2f}s)")
@@ -1085,9 +1101,11 @@ def create_stackoverflow_large(
10851101

10861102

10871103
def _iter_stackoverflow_rows(xml_path: Path, fields: list[str]):
1088-
import xml.etree.ElementTree as ET
1104+
import xml.etree.ElementTree as ET # nosec B405 - parsing files we just downloaded over HTTPS and verified
10891105

1090-
context = ET.iterparse(xml_path, events=("start", "end"))
1106+
context = ET.iterparse(
1107+
xml_path, events=("start", "end")
1108+
) # nosec B314 - input is a downloaded, checksum-verified file
10911109
_, root = next(context)
10921110
for event, elem in context:
10931111
if event == "end" and elem.tag == "row":
@@ -1618,7 +1636,9 @@ def report_progress(block_num, block_size, total_size):
16181636
end="",
16191637
)
16201638

1621-
urllib.request.urlretrieve(url, dbgen_zip, reporthook=report_progress)
1639+
urllib.request.urlretrieve(
1640+
_require_https(url), dbgen_zip, reporthook=report_progress
1641+
) # nosec B310 - https-only
16221642
print()
16231643

16241644
extract_dir = data_dir / "tpch-dbgen-extract"
@@ -1701,7 +1721,13 @@ def download_ldbc_snb(scale_factor: int = 1) -> Path:
17011721
"main/params-csv-merge-foreign.ini"
17021722
)
17031723
print("[DOWNLOAD] LDBC SNB params template")
1704-
template = urllib.request.urlopen(template_url).read().decode("utf-8")
1724+
template = (
1725+
urllib.request.urlopen( # nosec B310 - https-only
1726+
_require_https(template_url)
1727+
)
1728+
.read()
1729+
.decode("utf-8")
1730+
)
17051731
lines = []
17061732
inserted = False
17071733
for line in template.splitlines():
@@ -2139,7 +2165,7 @@ def verify_xml_nulls(extract_dir, sample_size=None):
21392165
Returns:
21402166
dict: Verification results
21412167
"""
2142-
import xml.etree.ElementTree as ET
2168+
import xml.etree.ElementTree as ET # nosec B405 - parsing files we just downloaded over HTTPS and verified
21432169

21442170
verification_start = time.time()
21452171
results = {}
@@ -2163,7 +2189,9 @@ def verify_xml_nulls(extract_dir, sample_size=None):
21632189
file_start = time.time()
21642190

21652191
# Parse XML iteratively for large files
2166-
context = ET.iterparse(xml_path, events=("start", "end"))
2192+
context = ET.iterparse(
2193+
xml_path, events=("start", "end")
2194+
) # nosec B314 - input is a downloaded, checksum-verified file
21672195
_, root = next(context) # Get root element
21682196

21692197
all_attrs = set()

0 commit comments

Comments
 (0)