Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
83 changes: 83 additions & 0 deletions .github/workflows/clusterfuzzlite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2026 PyThaiNLP Project
# SPDX-License-Identifier: Apache-2.0

name: ClusterFuzzLite

on:
push:
branches:
- dev
paths-ignore:
- '**.cff'
- '**.json'
- '**.md'
- '**.rst'
- '**.txt'
- '**.yml'
Comment thread
bact marked this conversation as resolved.
Outdated
- 'docs/**'
pull_request:
branches:
- dev
paths-ignore:
- '**.cff'
- '**.json'
- '**.md'
- '**.rst'
- '**.txt'
- '**.yml'
Comment thread
bact marked this conversation as resolved.
Outdated
- 'docs/**'
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC

# Avoid duplicate runs for the same source branch and repository.
# For pull_request events, uses the source repo name from
# github.event.pull_request.head.repo.full_name; otherwise uses github.repository.
# For push events, uses the branch name from github.ref_name.
# For pull_request events, uses the source branch name from github.head_ref.
# This ensures events for the same repo and branch share the same group,
# and avoids cross-fork collisions when branch names are reused.
concurrency:
group: >-
${{ github.workflow }}-${{
github.event.pull_request.head.repo.full_name || github.repository
}}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

permissions:
contents: read
Comment thread
bact marked this conversation as resolved.
Outdated
issues: write

jobs:
fuzzing:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sanitizer: [address]
steps:
- name: Build Fuzzers (${{ matrix.sanitizer }})
id: build
uses: google/clusterfuzzlite/actions/build_fuzzers@v1
with:
sanitizer: ${{ matrix.sanitizer }}
language: python
dockerfile-path: fuzz/Dockerfile

- name: Run Fuzzers (${{ matrix.sanitizer }})
id: run
uses: google/clusterfuzzlite/actions/run_fuzzers@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
fuzz-seconds: 300
mode: 'code-change'
Comment thread
bact marked this conversation as resolved.
Outdated
sanitizer: ${{ matrix.sanitizer }}
storage-repo: https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
storage-repo-branch: gh-pages
storage-repo-branch-coverage: gh-pages
Comment on lines +71 to +73

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow configuration specifies storage-repo-branch: gh-pages and storage-repo-branch-coverage: gh-pages for storing fuzzing corpus and coverage data. This assumes that a gh-pages branch exists in the repository. If this branch doesn't exist, the workflow may fail when trying to store corpus/coverage data. Consider either: 1) ensuring the gh-pages branch exists before this workflow runs, 2) documenting the need to create this branch in the PR description or setup documentation, or 3) using a different branch that already exists (like a dedicated fuzzing-data branch).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot from security point of view, storing fuzzing corpus in gh-pages (publicly available) is a concern?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing fuzzing corpus in gh-pages (public) is generally acceptable for open-source projects. The corpus contains test inputs that triggered interesting code paths, not secrets or sensitive data. However, if crash-triggering inputs are concerning:

Options:

  1. Keep gh-pages public - Standard practice for OSS fuzzing (e.g., OSS-Fuzz projects)
  2. Use a private branch - Change storage-repo-branch to a non-public branch if this repo has one
  3. Disable corpus storage - Remove the storage-repo* parameters (fuzzing will still work, just won't persist corpus between runs)

The current setup follows ClusterFuzzLite best practices for public repositories. Crash artifacts (line 76-83) are only uploaded as workflow artifacts (not gh-pages), which have limited retention and access.


- name: Upload crash artifacts
if: failure() && steps.run.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.sanitizer }}-artifacts
path: ./out/artifacts
29 changes: 29 additions & 0 deletions fuzz/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: 2026 PyThaiNLP Project
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
Comment thread
bact marked this conversation as resolved.

# Dockerfile for ClusterFuzzLite fuzzing
# This extends the OSS-Fuzz base builder image for Python projects

FROM gcr.io/oss-fuzz-base/base-builder-python

# Install system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libicu-dev \
pkg-config && \
rm -rf /var/lib/apt/lists/*

# Copy repository to $SRC/pythainlp
COPY . $SRC/pythainlp

# Set working directory
WORKDIR $SRC/pythainlp

# Install pythainlp in development mode with minimal dependencies
# This installs the package without heavy ML dependencies to speed up builds
RUN pip install --no-cache-dir -e .

# Copy build script
Comment thread
bact marked this conversation as resolved.
Outdated
COPY fuzz/build.sh $SRC/
156 changes: 156 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# PyThaiNLP Fuzz Testing

This directory contains fuzz testing infrastructure using
[ClusterFuzzLite](https://google.github.io/clusterfuzzlite/) and [Atheris](https://github.com/google/atheris).

## Overview

Fuzz testing helps discover edge cases, crashes, and potential security vulnerabilities by feeding random inputs to functions. This setup uses:

- **ClusterFuzzLite**: Google's continuous fuzzing solution for GitHub projects
- **Atheris**: Coverage-guided Python fuzzing engine
- **AddressSanitizer**: Memory safety checks

## Directory Structure

```
fuzz/
├── Dockerfile # Docker image for ClusterFuzzLite fuzzing
├── build.sh # Build script for compiling fuzzers
├── fuzz_tokenize.py # Fuzzer for word_tokenize()
├── fuzz_util_normalize.py # Fuzzer for normalize()
└── README.md # This file
```

## Current Fuzzing Targets

### 1. `fuzz_tokenize.py`
Tests `pythainlp.tokenize.word_tokenize()` with random Unicode input to ensure:
- No crashes on malformed input
- Proper handling of edge cases
- Memory safety

### 2. `fuzz_util_normalize.py`
Tests `pythainlp.util.normalize()` with random Unicode input to ensure:
- No crashes on malformed input
- Proper string normalization
- Type safety

## Local Testing

To test fuzzers locally:

```bash
# Install atheris
pip install atheris

# Run a specific fuzzer for 60 seconds
python fuzz/fuzz_tokenize.py -max_total_time=60

# Run with specific corpus directory
python fuzz/fuzz_tokenize.py corpus_dir/ -max_total_time=60
```

## CI/CD Integration

Fuzzing runs automatically via GitHub Actions:
- On pull requests to `dev` branch (focuses on code changes)
- On push to `dev` branch
- Daily at 06:00 UTC (full fuzzing run)

Configuration: `.github/workflows/clusterfuzzlite.yml`

## Adding New Fuzzers

To add a new fuzzing target:

1. Create a new file `fuzz/fuzz_<module_name>.py`:

```python
# SPDX-FileCopyrightText: 2026 PyThaiNLP Project
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileType: SOURCE
"""Fuzzing harness for pythainlp.<module>.<function>()"""

import sys
import atheris
import pythainlp.<module>


def TestOneInput(data: bytes) -> None:
"""Fuzz target for <function>."""
fdp = atheris.FuzzedDataProvider(data)

try:
# Generate test input
text = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())

# Call target function
result = pythainlp.<module>.<function>(text)

# Validate output
assert isinstance(result, <expected_type>)
Comment thread
bact marked this conversation as resolved.
Outdated

except (ValueError, TypeError, UnicodeDecodeError):
# Expected exceptions
pass


def main() -> None:
"""Entry point for the fuzzer."""
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()


if __name__ == "__main__":
main()
```

2. The fuzzer will be automatically discovered and built by `build.sh`
Comment thread
bact marked this conversation as resolved.
Outdated

3. No changes needed to GitHub Actions workflow

## Expansion Plan

Future fuzzing targets to consider:

### High Priority
- **spell/** - Spelling correction functions
- **soundex/** - Phonetic encoding functions
- **transliterate/** - Romanization functions

### Medium Priority
- **corpus/** - Data loading and corpus functions
- **tag/** - Part-of-speech tagging
- **parse/** - Parsing functions

### Low Priority
- **classify/** - Classification functions
- **generate/** - Text generation functions
- **summarize/** - Summarization functions

## Troubleshooting

### Fuzzer Crashes
If a fuzzer finds a crash:
1. Check the GitHub Actions artifacts for crash reports
2. Reproduce locally: `python fuzz/fuzz_<name>.py <crash_file>`
3. Fix the underlying issue in the target function
4. Re-run fuzzer to verify fix

### Performance Issues
- Adjust fuzzing time in `.github/workflows/clusterfuzzlite.yml`
- Default is 300 seconds (5 minutes) per fuzzer
- For longer sessions, increase the value

### False Positives
- Update the exception handling in the fuzzer
- Add expected exceptions to the `except` block
- Document the reasoning in comments

## Resources

- [ClusterFuzzLite Documentation](https://google.github.io/clusterfuzzlite/)
- [Atheris Documentation](https://github.com/google/atheris)
- [OSS-Fuzz](https://github.com/google/oss-fuzz)
- [libFuzzer Tutorial](https://github.com/google/fuzzing/blob/master/tutorial/libFuzzerTutorial.md)
28 changes: 28 additions & 0 deletions fuzz/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash -eu
# SPDX-FileCopyrightText: 2026 PyThaiNLP Project
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileType: SOURCE

# Build script for ClusterFuzzLite fuzzing harnesses
# This script installs atheris and compiles all fuzzing harnesses

echo "Building PyThaiNLP fuzz targets..."

# Install atheris for Python fuzzing
pip install atheris
Comment thread
bact marked this conversation as resolved.
Outdated

# Find all fuzz_*.py files in the fuzz directory
for fuzzer in "${SRC}/pythainlp/fuzz"/fuzz_*.py; do
fuzzer_basename=$(basename -s .py "$fuzzer")
fuzzer_package="fuzz.${fuzzer_basename}"
Comment thread
bact marked this conversation as resolved.
Outdated

echo "Compiling ${fuzzer_basename}..."

# Compile fuzzer with atheris
python -m atheris.instrument_libfuzzer "${fuzzer}" "${OUT}/${fuzzer_basename}"
Comment thread
bact marked this conversation as resolved.
Outdated

# Make fuzzer executable
chmod +x "${OUT}/${fuzzer_basename}"
done

echo "Build completed successfully!"
51 changes: 51 additions & 0 deletions fuzz/fuzz_tokenize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: 2026 PyThaiNLP Project
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileType: SOURCE
Comment thread
bact marked this conversation as resolved.
"""Fuzzing harness for pythainlp.tokenize.word_tokenize()

This fuzzer tests the word_tokenize function with random Unicode input
to discover edge cases, crashes, and potential security issues.
"""

import sys

import atheris

import pythainlp.tokenize


def TestOneInput(data: bytes) -> None:
Comment thread
bact marked this conversation as resolved.
Outdated
"""Fuzz target for word_tokenize.

:param bytes data: Random input bytes from the fuzzer
"""
Comment thread
bact marked this conversation as resolved.
fdp = atheris.FuzzedDataProvider(data)

try:
# Generate random Unicode string
text = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())

# Test word_tokenize with default engine
result = pythainlp.tokenize.word_tokenize(text)

# Validate output type
assert isinstance(result, list), f"Expected list, got {type(result)}"
assert all(isinstance(token, str) for token in result), \
"All tokens should be strings"
Comment thread
bact marked this conversation as resolved.
Outdated

except (ValueError, TypeError, UnicodeDecodeError):
# Expected exceptions - these are acceptable
pass
except Exception:
# Unexpected exceptions - re-raise for investigation
raise
Comment thread
bact marked this conversation as resolved.
Outdated


def main() -> None:
"""Entry point for the fuzzer."""
Comment thread
bact marked this conversation as resolved.
Outdated
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()


if __name__ == "__main__":
main()
Loading
Loading