Skip to content

Commit 38a2b09

Browse files
authored
Merge pull request #1431 from PyThaiNLP/copilot/issue-1430-add-bandit-checks
Add bandit security checks and fix B615 HuggingFace unsafe download warnings
2 parents f324027 + 055ca78 commit 38a2b09

24 files changed

Lines changed: 225 additions & 79 deletions

File tree

.github/workflows/bandit.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-FileCopyrightText: 2026-present PyThaiNLP Project
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Bandit is a tool designed to find common security issues in Python code.
5+
# https://github.com/pycqa/bandit
6+
7+
name: Bandit
8+
9+
on:
10+
push:
11+
branches:
12+
- dev
13+
- main
14+
paths-ignore:
15+
- '**.cff'
16+
- '**.json'
17+
- '**.md'
18+
- '**.rst'
19+
- '**.txt'
20+
- 'docs/**'
21+
pull_request:
22+
branches:
23+
- dev
24+
- main
25+
paths-ignore:
26+
- '**.cff'
27+
- '**.json'
28+
- '**.md'
29+
- '**.rst'
30+
- '**.txt'
31+
- 'docs/**'
32+
33+
# Avoid duplicate runs for the same source branch and repository.
34+
# For pull_request events, uses the source repo name from
35+
# github.event.pull_request.head.repo.full_name; otherwise uses github.repository.
36+
# For push events, uses the branch name from github.ref_name.
37+
# For pull_request events, uses the source branch name from github.head_ref.
38+
# This ensures events for the same repo and branch share the same group,
39+
# and avoids cross-fork collisions when branch names are reused.
40+
concurrency:
41+
group: >-
42+
${{ github.workflow }}-${{
43+
github.event.pull_request.head.repo.full_name || github.repository
44+
}}-${{ github.head_ref || github.ref_name }}
45+
cancel-in-progress: true
46+
47+
jobs:
48+
bandit:
49+
runs-on: ubuntu-latest
50+
permissions:
51+
contents: read
52+
steps:
53+
- name: Checkout
54+
uses: actions/checkout@v6.0.2
55+
56+
- name: Set up Python
57+
uses: actions/setup-python@v6
58+
with:
59+
python-version: "3.x"
60+
cache: "pip"
61+
62+
- name: Install bandit
63+
run: pip install "bandit>=1.9.4"
64+
65+
- name: Run bandit
66+
run: bandit -r pythainlp -c pyproject.toml

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
1+
# SPDX-FileCopyrightText: 2016-present PyThaiNLP Project
22
# SPDX-FileType: SOURCE
33
# SPDX-License-Identifier: Apache-2.0
44

@@ -71,6 +71,7 @@ dependencies = [
7171
## 1) Development ########################################
7272

7373
dev = [
74+
"bandit>=1.9.4",
7475
"black>=25.11.0",
7576
"build>=1.0.0",
7677
"bump-my-version>=1.2.6",
@@ -311,6 +312,11 @@ include = [
311312
"README.md",
312313
]
313314

315+
[tool.bandit]
316+
# Skip tests that produce known false positives or are accepted risks.
317+
# B110: Try/Except/Pass. Accepted pattern in optional-dependency loading.
318+
skips = ["B110"]
319+
314320
[tool.bumpversion]
315321
current_version = "5.3.4"
316322
commit = true

pythainlp/augment/lm/phayathaibert.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ def __init__(self) -> None:
2929
)
3030

3131
self.tokenizer: AutoTokenizer = AutoTokenizer.from_pretrained(
32-
_MODEL_NAME
32+
_MODEL_NAME # nosec B615
3333
)
3434
self.model_for_masked_lm: AutoModelForMaskedLM = (
35-
AutoModelForMaskedLM.from_pretrained(_MODEL_NAME)
35+
AutoModelForMaskedLM.from_pretrained(_MODEL_NAME) # nosec B615
3636
)
3737
self.model: Pipeline = pipeline(
3838
"fill-mask",
@@ -55,7 +55,7 @@ def generate(
5555
input_text = self.processor.preprocess(sample_txt)
5656
if sample:
5757
# Non-cryptographic use, pseudo-random generator is acceptable here
58-
random_word_idx = random.randint(0, 4) # noqa: S311
58+
random_word_idx = random.randint(0, 4) # noqa: S311 # nosec B311 # NOSONAR
5959
output = self.model(input_text)[random_word_idx]["sequence"]
6060
else:
6161
output = self.model(input_text)[word_rank]["sequence"]

pythainlp/augment/lm/wangchanberta.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def __init__(self) -> None:
2929
self.target_tokenizer: type[CamembertTokenizer] = CamembertTokenizer
3030
self.tokenizer: CamembertTokenizer = (
3131
CamembertTokenizer.from_pretrained(
32-
self.model_name, revision="main"
32+
self.model_name, revision="main" # nosec B615
3333
)
3434
)
3535
self.tokenizer.additional_special_tokens = [

pythainlp/corpus/core.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def get_corpus_db(url: str) -> Optional[_ResponseWrapper]:
7171
try:
7272
req = Request(url, headers={"User-Agent": _USER_AGENT})
7373
# SSL certificate verification is enabled by default
74-
with urlopen(req, timeout=10) as response:
74+
with urlopen(req, timeout=10) as response: # nosec B310
7575
corpus_db = _ResponseWrapper(response)
7676
except HTTPError as http_err:
7777
print(f"HTTP error occurred: {http_err}")
@@ -349,7 +349,7 @@ def _download(url: str, dst: str) -> int:
349349

350350
req = Request(url, headers={"User-Agent": _USER_AGENT})
351351
# SSL certificate verification is enabled by default
352-
with urlopen(req, timeout=10) as response:
352+
with urlopen(req, timeout=10) as response: # nosec B310
353353
file_size = int(response.info().get("Content-Length", -1))
354354
with open(get_full_data_path(dst), "wb") as f:
355355
pbar = None
@@ -383,7 +383,7 @@ def _check_hash(dst: str, md5: str) -> None:
383383
with open(get_full_data_path(dst), "rb") as f:
384384
content = f.read()
385385
# MD5 is insecure but sufficient here
386-
file_md5 = hashlib.md5(content).hexdigest() # noqa: S324
386+
file_md5 = hashlib.md5(content).hexdigest() # noqa: S324 # nosec B324
387387

388388
if md5 != file_md5:
389389
raise ValueError("Hash does not match expected.")
@@ -484,7 +484,7 @@ def _safe_extract_tar(tar: tarfile.TarFile, path: str) -> None:
484484
f"Symlink {member.name} points outside extraction directory: {member.linkname}"
485485
)
486486

487-
tar.extractall(path=path)
487+
tar.extractall(path=path) # nosec B202
488488

489489

490490
def _safe_extract_zip(zip_file: zipfile.ZipFile, path: str) -> None:
@@ -539,7 +539,7 @@ def _safe_extract_zip(zip_file: zipfile.ZipFile, path: str) -> None:
539539
f"Symlink {member} points outside extraction directory: {link_target}"
540540
)
541541

542-
zip_file.extractall(path=path)
542+
zip_file.extractall(path=path) # nosec B202
543543

544544

545545
def _version2int(v: str) -> int:
@@ -853,12 +853,17 @@ def make_safe_directory_name(name: str) -> str:
853853
return safe_name
854854

855855

856-
def get_hf_hub(repo_id: str, filename: str = "") -> str:
856+
def get_hf_hub(
857+
repo_id: str, filename: str = "", revision: Optional[str] = None
858+
) -> str:
857859
"""HuggingFace Hub in :mod:`pythainlp` data directory.
858860
859861
:param str repo_id: repo_id
860862
:param str filename: filename (optional, default is empty string).
861863
If empty, downloads entire snapshot.
864+
:param Optional[str] revision: a git revision id, which can be a branch
865+
name, a tag, or a commit hash (optional, default is ``None``).
866+
Pin to a full commit hash for reproducible and secure downloads.
862867
:return: path
863868
:rtype: str
864869
"""
@@ -876,10 +881,15 @@ def get_hf_hub(repo_id: str, filename: str = "") -> str:
876881
root_project = safe_path_join(hf_root, name_dir)
877882
if filename:
878883
output_path = hf_hub_download(
879-
repo_id=repo_id, filename=filename, local_dir=root_project
884+
repo_id=repo_id,
885+
filename=filename,
886+
local_dir=root_project,
887+
revision=revision,
880888
)
881889
else:
882890
output_path = snapshot_download(
883-
repo_id=repo_id, local_dir=root_project
891+
repo_id=repo_id,
892+
local_dir=root_project,
893+
revision=revision,
884894
)
885895
return str(output_path)

pythainlp/generate/core.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def gen_sentence(
8282
"""
8383
if not start_seq:
8484
# Non-cryptographic use, pseudo-random generator is acceptable here
85-
start_seq = random.choice(self.word) # noqa: S311
85+
start_seq = random.choice(self.word) # noqa: S311 # nosec B311 # NOSONAR
8686
rand_text = start_seq.lower()
8787
self._word_prob = {
8888
i: self.counts[i] / self.n
@@ -108,10 +108,10 @@ def _next_word(
108108
N = len(word_list)
109109
for _ in range(N):
110110
# Non-cryptographic use, pseudo-random generator is acceptable here
111-
w = random.choice(word_list) # noqa: S311
111+
w = random.choice(word_list) # noqa: S311 # nosec B311 # NOSONAR
112112
if duplicate is False:
113113
while w in words:
114-
w = random.choice(word_list) # noqa: S311
114+
w = random.choice(word_list) # noqa: S311 # nosec B311 # NOSONAR
115115
words.append(w)
116116

117117
if output_str:
@@ -185,7 +185,7 @@ def gen_sentence(
185185
"""
186186
if not start_seq:
187187
# Non-cryptographic use, pseudo-random generator is acceptable here
188-
start_seq = random.choice(self.words) # noqa: S311
188+
start_seq = random.choice(self.words) # noqa: S311 # nosec B311 # NOSONAR
189189
late_word = start_seq
190190
list_word = []
191191
list_word.append(start_seq)
@@ -204,7 +204,7 @@ def gen_sentence(
204204
if len(p2) == 0:
205205
break
206206
# Non-cryptographic use, pseudo-random generator is acceptable here
207-
items = temp[probs.index(random.choice(p2))] # noqa: S311
207+
items = temp[probs.index(random.choice(p2))] # noqa: S311 # nosec B311 # NOSONAR
208208
late_word = items[-1]
209209
list_word.append(late_word)
210210

@@ -288,7 +288,7 @@ def gen_sentence(
288288
late_word: Union[str, tuple[str, str]]
289289
if not start_seq:
290290
# Non-cryptographic use, pseudo-random generator is acceptable here
291-
start_seq = random.choice(self.bi_keys) # noqa: S311
291+
start_seq = random.choice(self.bi_keys) # noqa: S311 # nosec B311 # NOSONAR
292292
late_word = start_seq
293293
list_word: list[Union[str, tuple[str, str]]] = []
294294
list_word.append(start_seq)
@@ -307,7 +307,7 @@ def gen_sentence(
307307
if len(p2) == 0:
308308
break
309309
# Non-cryptographic use, pseudo-random generator is acceptable here
310-
items = temp[probs.index(random.choice(p2))] # noqa: S311
310+
items = temp[probs.index(random.choice(p2))] # noqa: S311 # nosec B311 # NOSONAR
311311
late_word = items[1:]
312312
list_word.append(late_word)
313313

pythainlp/generate/thai2fit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
"emb_sz": 400,
9898
"n_hid": 1550,
9999
"n_layers": 4,
100-
"pad_token": 1,
100+
"pad_token": 1, # nosec B105
101101
"qrnn": False,
102102
"tie_weights": True,
103103
"out_bias": True,
@@ -150,7 +150,7 @@ def gen_sentence(
150150
"""
151151
if not start_seq:
152152
# Non-cryptographic use, pseudo-random generator is acceptable here
153-
start_seq = random.choice(list(thwiki_itos)) # noqa: S311
153+
start_seq = random.choice(list(thwiki_itos)) # noqa: S311 # nosec B311 # NOSONAR
154154
predicted_text: str = learn.predict(
155155
start_seq, N, temperature=0.8, min_p=prob, sep="-*-"
156156
)

pythainlp/generate/wangchanglm.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def load_model(
4747
torch_dtype: Optional["torch.dtype"] = None,
4848
offload_folder: str = "./",
4949
low_cpu_mem_usage: bool = True,
50+
revision: Optional[str] = None,
5051
) -> None:
5152
"""Load model
5253
@@ -57,6 +58,8 @@ def load_model(
5758
:param Optional[torch.dtype] torch_dtype: torch_dtype
5859
:param str offload_folder: offload folder
5960
:param bool low_cpu_mem_usage: low cpu mem usage
61+
:param Optional[str] revision: a git revision id (branch, tag, or
62+
commit hash). Pin to a full commit hash for secure downloads.
6063
"""
6164
import pandas as pd
6265
from transformers import AutoModelForCausalLM, AutoTokenizer
@@ -72,9 +75,10 @@ def load_model(
7275
torch_dtype=torch_dtype,
7376
offload_folder=offload_folder,
7477
low_cpu_mem_usage=low_cpu_mem_usage,
78+
revision=revision,
7579
)
7680
self.tokenizer: "PreTrainedTokenizerBase" = (
77-
AutoTokenizer.from_pretrained(self.model_path)
81+
AutoTokenizer.from_pretrained(self.model_path, revision=revision)
7882
)
7983
self.df: "pd.DataFrame" = pd.DataFrame(
8084
self.tokenizer.vocab.items(), columns=["text", "idx"]

pythainlp/lm/qwen3.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@ def load_model(
3131
device: str = "cuda",
3232
torch_dtype: Optional["torch.dtype"] = None,
3333
low_cpu_mem_usage: bool = True,
34+
revision: Optional[str] = None,
3435
) -> None:
3536
"""Load Qwen3 model.
3637
3738
:param str model_path: model path or HuggingFace model ID
3839
:param str device: device (cpu, cuda or other)
3940
:param Optional[torch.dtype] torch_dtype: torch data type (e.g., torch.float16, torch.bfloat16)
4041
:param bool low_cpu_mem_usage: low cpu mem usage
42+
:param Optional[str] revision: a git revision id (branch, tag, or
43+
commit hash). Pin to a full commit hash for secure downloads.
4144
4245
:Example:
4346
@@ -74,7 +77,9 @@ def load_model(
7477
self.model_path = model_path
7578

7679
try:
77-
self.tokenizer = AutoTokenizer.from_pretrained(self.model_path)
80+
self.tokenizer = AutoTokenizer.from_pretrained(
81+
self.model_path, revision=revision
82+
)
7883
except OSError as exc:
7984
raise RuntimeError(
8085
f"Failed to load tokenizer from '{self.model_path}'. "
@@ -87,6 +92,7 @@ def load_model(
8792
device_map=device,
8893
torch_dtype=torch_dtype,
8994
low_cpu_mem_usage=low_cpu_mem_usage,
95+
revision=revision,
9096
)
9197
except OSError as exc:
9298
# Clean up tokenizer on failure

pythainlp/parse/transformers_ud.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626

2727
class Parse:
2828
def __init__(
29-
self, model: Optional[str] = "KoichiYasuoka/deberta-base-thai-ud-head"
29+
self,
30+
model: Optional[str] = "KoichiYasuoka/deberta-base-thai-ud-head",
31+
revision: Optional[str] = None,
3032
) -> None:
3133
from transformers import (
3234
AutoConfig,
@@ -39,9 +41,13 @@ def __init__(
3941

4042
if model is None:
4143
model = "KoichiYasuoka/deberta-base-thai-ud-head"
42-
self.tokenizer: AutoTokenizer = AutoTokenizer.from_pretrained(model)
44+
self.tokenizer: AutoTokenizer = AutoTokenizer.from_pretrained(
45+
model, revision=revision
46+
)
4347
self.model: AutoModelForQuestionAnswering = (
44-
AutoModelForQuestionAnswering.from_pretrained(model)
48+
AutoModelForQuestionAnswering.from_pretrained(
49+
model, revision=revision
50+
)
4551
)
4652
x = AutoModelForTokenClassification.from_pretrained
4753
if os.path.isdir(model):
@@ -50,14 +56,14 @@ def __init__(
5056
x(safe_path_join(model, "tagger")),
5157
)
5258
else:
53-
c = AutoConfig.from_pretrained(
54-
cached_file(model, "deprel/config.json")
59+
c = AutoConfig.from_pretrained( # nosec B615
60+
cached_file(model, "deprel/config.json", revision=revision),
5561
)
56-
d = x(cached_file(model, "deprel/pytorch_model.bin"), config=c)
57-
s = AutoConfig.from_pretrained(
58-
cached_file(model, "tagger/config.json")
62+
d = x(cached_file(model, "deprel/pytorch_model.bin", revision=revision), config=c)
63+
s = AutoConfig.from_pretrained( # nosec B615
64+
cached_file(model, "tagger/config.json", revision=revision),
5965
)
60-
t = x(cached_file(model, "tagger/pytorch_model.bin"), config=s)
66+
t = x(cached_file(model, "tagger/pytorch_model.bin", revision=revision), config=s)
6167
self.deprel: TokenClassificationPipeline = TokenClassificationPipeline(
6268
model=d, tokenizer=self.tokenizer, aggregation_strategy="simple"
6369
)

0 commit comments

Comments
 (0)