diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 00000000..ef2f1e5b --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,26 @@ +on: + workflow_dispatch: {} + pull_request: {} + push: + branches: + - main + - master + paths: + - .github/workflows/semgrep.yml + schedule: + # random HH:MM to avoid a load spike on GitHub Actions at 00:00 + - cron: 6 9 * * * +name: Semgrep +jobs: + semgrep: + name: semgrep/ci + runs-on: ubuntu-latest + permissions: + contents: read + env: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + container: + image: semgrep/semgrep + steps: + - uses: actions/checkout@v6 + - run: semgrep ci diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 00000000..94656643 --- /dev/null +++ b/.semgrepignore @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/path-traversal-chained-test.py b/path-traversal-chained-test.py new file mode 100644 index 00000000..e465388a --- /dev/null +++ b/path-traversal-chained-test.py @@ -0,0 +1,34 @@ +import os + +UPLOAD_DIR = "/var/uploads" +SAFE_DIR = "/var/safe" + +# ruleid: path-traversal-open-os-path-join +# SHOULD MATCH — user_filename flows directly into os.path.join then open() +def bad_file_read(user_filename): + path = os.path.join(UPLOAD_DIR, user_filename) + with open(path, 'r') as f: + return f.read() + +# ruleid: path-traversal-open-os-path-join +# SHOULD MATCH — inline: os.path.join passed directly into open() in one expression +def bad_inline(user_filename): + with open(os.path.join(UPLOAD_DIR, user_filename), 'rb') as f: + return f.read() + +# ok: path-traversal-open-os-path-join +# SHOULD NOT MATCH — hardcoded filename, no user input +def safe_hardcoded(): + path = os.path.join(UPLOAD_DIR, "config.json") + with open(path, 'r') as f: + return f.read() + +# ok: path-traversal-open-os-path-join +# SHOULD NOT MATCH — path is validated before open() +def safe_validated(user_filename): + joined = os.path.join(UPLOAD_DIR, user_filename) + safe = os.path.abspath(joined) + if not safe.startswith(os.path.abspath(UPLOAD_DIR)): + raise ValueError("Path traversal detected") + with open(safe, 'r') as f: + return f.read() diff --git a/path-traversal-chained.yaml b/path-traversal-chained.yaml new file mode 100644 index 00000000..ffc54ce7 --- /dev/null +++ b/path-traversal-chained.yaml @@ -0,0 +1,38 @@ +rules: + - id: path-traversal-open-os-path-join-inline + pattern: open(os.path.join($BASE, $USER_INPUT), ...) + pattern-not: open(os.path.join("...", "..."), ...) + message: > + Path traversal: os.path.join() result passed directly to open() without + path validation. An attacker controlling the second argument can escape + the base directory using '../' sequences. Fix: resolve the path with + os.path.abspath() and assert it starts with os.path.abspath($BASE). + languages: [python] + severity: ERROR + metadata: + cwe: "CWE-22: Improper Limitation of a Pathname to a Restricted Directory" + owasp: "A01:2021 - Broken Access Control" + confidence: HIGH + category: security + + - id: path-traversal-open-os-path-join-two-step + patterns: + - pattern: | + $PATH = os.path.join($BASE, $USER_INPUT) + open($PATH, ...) + - pattern-not: | + $PATH = os.path.join("...", "...") + open($PATH, ...) + message: > + Path traversal: a path built with os.path.join() is passed to open() + without validation in between. An attacker controlling the second + argument of os.path.join() can escape the base directory. Fix: call + os.path.abspath() on $PATH and verify it starts with + os.path.abspath($BASE) before calling open(). + languages: [python] + severity: ERROR + metadata: + cwe: "CWE-22: Improper Limitation of a Pathname to a Restricted Directory" + owasp: "A01:2021 - Broken Access Control" + confidence: HIGH + category: security diff --git a/requirements.txt b/requirements.txt index 96714824..6818611a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ +ansible-core==2.16.0 certifi==2021.10.8 +chardet==3.0.4 charset-normalizer==2.0.12 click==8.0.1 Flask==2.0.1 diff --git a/test-vuln.py b/test-vuln.py new file mode 100644 index 00000000..753671cd --- /dev/null +++ b/test-vuln.py @@ -0,0 +1,22 @@ +import sh + +long = os.environ.get("LONG", "") +# ruleid: string-concat +sh.ls("-a" + long) + +# ok: string-concat +sh.ls("-al") + +# ok: string-concat +sh.semgrep("--config", "https://semgrep.dev/p/r2c-CI") + +confurl = os.environ.get("SEMGREP_CONFIG_URL", "") +# ruleid: string-concat +sh.semgrep("--config {}".format(confurl)) + +# ruleid: string-concat +sh.semgrep(f"--config {confurl}") + +# ok: string-concat +args = ["--config", confurl] +sh.semgrep(*args)