From 88809430ffbe49ad93a44712aad476c3c36e3200 Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:57:35 +1000 Subject: [PATCH 01/11] Add tests folder to tests branch only --- .github/workflows/owasp.yml | 127 ---------------------- .gitignore | 7 -- README.md | 104 ------------------ requirements.txt | 3 - scanner/core.py | 133 ----------------------- scanner/main.py | 15 --- scanner/rules/_template.py | 11 -- scanner/rules/auth_failures.py | 41 ------- scanner/rules/broken_access_control.py | 87 --------------- scanner/rules/insecure_design.py | 24 ---- scanner/rules/integrity_failures.py | 48 -------- scanner/rules/logging_failures.py | 44 -------- scanner/rules/security_misconfig.py | 74 ------------- scanner/rules/sensitive_data_exposure.py | 35 ------ scanner/rules/sql_injection.py | 31 ------ scanner/rules/ssrf.py | 35 ------ scanner/rules/vulnerable_components.py | 31 ------ 17 files changed, 850 deletions(-) delete mode 100644 .github/workflows/owasp.yml delete mode 100644 .gitignore delete mode 100644 README.md delete mode 100644 requirements.txt delete mode 100644 scanner/core.py delete mode 100644 scanner/main.py delete mode 100644 scanner/rules/_template.py delete mode 100644 scanner/rules/auth_failures.py delete mode 100644 scanner/rules/broken_access_control.py delete mode 100644 scanner/rules/insecure_design.py delete mode 100644 scanner/rules/integrity_failures.py delete mode 100644 scanner/rules/logging_failures.py delete mode 100644 scanner/rules/security_misconfig.py delete mode 100644 scanner/rules/sensitive_data_exposure.py delete mode 100644 scanner/rules/sql_injection.py delete mode 100644 scanner/rules/ssrf.py delete mode 100644 scanner/rules/vulnerable_components.py diff --git a/.github/workflows/owasp.yml b/.github/workflows/owasp.yml deleted file mode 100644 index 8493fdd..0000000 --- a/.github/workflows/owasp.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: OWASP PR Scanner - -on: - pull_request_target: - types: [opened, synchronize, reopened] -permissions: - contents: read - pull-requests: write - -jobs: - scan: - runs-on: ubuntu-latest - - steps: - - name: Checkout PR HEAD - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install deps - run: | - python -m pip install -U pip - if [ -f scanner/requirements.txt ]; then - pip install -r scanner/requirements.txt - elif [ -f requirements.txt ]; then - pip install -r requirements.txt - fi - - - name: Determine changed files for this PR - id: diff - run: | - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - RAW="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" || true)" - APP_CHANGED="$(echo "$RAW" \ - | grep -E '\.(js|jsx|ts|tsx|py|java|go|rb|php|html|css|md|conf|yml|yaml|json)$' \ - || true)" - if [ -z "$APP_CHANGED" ]; then - APP_CHANGED="$(git ls-files)" - fi - echo "changed_files<> $GITHUB_OUTPUT - echo "$APP_CHANGED" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Run OWASP scanner - id: owasp - run: | - CHANGED_FILES="${{ steps.diff.outputs.changed_files }}" - if [ -z "$CHANGED_FILES" ]; then - echo "Nothing to scan." | tee owasp-results.txt - echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT - exit 0 - fi - - if [ ! -d "scanner" ]; then - echo "::error::Scanner module not found (scanner/)." - exit 1 - fi - - : > owasp-results.txt - EXIT=0 - while IFS= read -r file; do - [ -z "$file" ] && continue - echo "### File: $file" >> owasp-results.txt - echo '```' >> owasp-results.txt - python -m scanner.main "$file" >> owasp-results.txt 2>&1 || EXIT=1 - echo '```' >> owasp-results.txt - echo "" >> owasp-results.txt - done <<< "$CHANGED_FILES" - - if [ $EXIT -ne 0 ]; then - echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT - else - echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT - fi - exit 0 - - - name: Create PR comment body - if: always() - run: | - RESULTS=$(cat owasp-results.txt || echo "No results.") - if [ "${{ steps.owasp.outputs.vulnerabilities_found }}" == "true" ]; then - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ OWASP Scanner Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'Vulnerabilities were detected:' >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo "$RESULTS" >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo 'β›” Please address these before merging.' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - else - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ OWASP Scanner Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'No vulnerabilities detected.' >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo "$RESULTS" >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo 'βœ… Good to go.' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - fi - - - name: Comment PR - uses: peter-evans/create-or-update-comment@v4 - with: - issue-number: ${{ github.event.pull_request.number }} - body: ${{ env.comment_body }} - - - name: Upload scan artifact - uses: actions/upload-artifact@v4 - with: - name: owasp-scan-results - path: owasp-results.txt - retention-days: 5 - - - name: Fail if vulnerabilities found - if: steps.owasp.outputs.vulnerabilities_found == 'true' - run: | - echo "::error::OWASP scanner reported vulnerabilities." - exit 1 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 966675c..0000000 --- a/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -__pycache__/ -*.py[cod] -*.pyo -*.pyd -.venv/ -venv/ -__init__.py diff --git a/README.md b/README.md deleted file mode 100644 index b42465f..0000000 --- a/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# OWASP PR Scanner - -This tool scans Python files for security vulnerabilities based on the **OWASP Top 10**. -It is designed for lightweight static analysis of pull requests, helping developers catch common issues early and enforce secure coding practices. - ---- - -## βœ… Current Functionality - -The scanner detects vulnerabilities using static analysis (regex + simple heuristics). -It groups results by OWASP Top 10 category and highlights severity with colour-coded output. - -Implemented rules: - -- **A01:2021 – Broken Access Control** - - Detects Flask routes without authentication decorators - -- **A02:2021 – Cryptographic Failures** - - Detects weak hashing algorithms (MD5, SHA1) - - Flags hardcoded secrets, API keys, and default passwords - - Warns about unsafe fallback values - -- **A03:2021 – Injection** - - Detects unparameterized SQL queries - - Flags SQL built with string concatenation or f-strings - -- **A04:2021 – Insecure Design** - - Flags insecure β€œTODO” markers, temporary overrides, or auth bypass notes - -- **A05:2021 – Security Misconfiguration** - - Detects `debug=True` in Flask apps - - Flags permissive host settings (`ALLOWED_HOSTS = ['*']`) - - Insecure cookie/CSRF flags - - Hardcoded Flask secrets - -- **A06:2021 – Vulnerable and Outdated Components** - - Detects dependency pins like `flask==0.12` or `django==1.11` - - Helps identify outdated or risky components - -- **A07:2021 – Identification and Authentication Failures** - - Detects default credentials (`admin`, `password`) - - Flags login routes without auth checks - - Warns about disabled TLS verification (`verify=False`) - -- **A08:2021 – Software and Data Integrity Failures** - - Detects dangerous use of `eval()` - - Warns about unsafe deserialization (`pickle.load`) - - Flags subprocess calls with `shell=True` - -- **A09:2021 – Security Logging and Monitoring Failures** - - Detects print statements in auth flows - - Flags bare `except:` blocks with no logging - - Warns when secrets are printed to stdout - -- **A10:2021 – Server-Side Request Forgery (SSRF)** - - Detects unvalidated user input passed into `requests.get/post` - ---- - - -## πŸ“‚ Test Cases - -- **`test_positive.py`** - A deliberately vulnerable file that triggers all implemented OWASP rules (A01–A10). - -- **`test_negative.py`** - A safe baseline file with secure practices β€” should pass with **no findings**. - Used for regression testing and validation. - ---- - -## 🎨 Output Example - -- Findings are grouped by OWASP category (A01–A10) -- Severity levels are **colour-coded**: - - πŸ”΄ High - - 🟠 Medium - - 🟒 Low - -Example: -=== A01: Injection (2 findings) === -Summary: High: 2 - -β€’ Line 60 | Severity HIGH | Confidence MEDIUM -β†’ SQL query created via string concatenation: ... - ---- - -## Running the Script -### 1. Navigate to your project root -cd path/to/owasp-scanner - -### 2. Set PYTHONPATH so Python recognizes `scanner/` as a package -set PYTHONPATH=. - -### 3. Run the script with the file to scan as an argument -python scanner/main.py tests/test_positive.py - -## πŸ‘€ Author -Developed by Liana Perry (2025) -Cybersecurity SecDevOps Sub-team | Redback Operations - -## πŸ™Œ Acknowledgements -This project is inspired by the original vulnerability scanning logic created by Amir Zandieh, and extends it into a modular and OWASP-aligned security scanning tool for pull requests. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6c924a7..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# This file lists all dependencies needed to run the scanner. -# To install the requirements: -# pip install -r requirements.txt \ No newline at end of file diff --git a/scanner/core.py b/scanner/core.py deleted file mode 100644 index b92d068..0000000 --- a/scanner/core.py +++ /dev/null @@ -1,133 +0,0 @@ -import os -import importlib -import pkgutil -import scanner.rules as rules_pkg - - -# -------- Rule auto-discovery -------- -def _load_rule_modules(): - modules = [] - for _, modname, _ in pkgutil.iter_modules(rules_pkg.__path__): - if modname.startswith("_"): - continue # skip __init__, _template, etc. - mod = importlib.import_module(f"{rules_pkg.__name__}.{modname}") - if hasattr(mod, "check"): - modules.append(mod) - - def key(m): - cat = getattr(m, "CATEGORY", "") - head = cat.split(":", 1)[0].strip() if cat else "" - return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, m.__name__) - - return sorted(modules, key=key) - - -RULE_MODULES = _load_rule_modules() - - -# -------- Scanner -------- -class VulnerabilityScanner: - def __init__(self, file_path): - self.file_path = file_path - self.code_lines = [] - self.vulnerabilities = [] - - def add_vulnerability(self, category, description, line, severity, confidence): - self.vulnerabilities.append( - { - "category": category, - "description": description, - "line": line, - "severity": severity, - "confidence": confidence, - } - ) - - def parse_file(self): - if not os.path.exists(self.file_path): - print(f"File {self.file_path} does not exist.") - return False - with open(self.file_path, "r", encoding="utf-8") as f: - self.code_lines = f.readlines() - return True - - def run_checks(self): - for rule in RULE_MODULES: - rule.check(self.code_lines, self.add_vulnerability) - - def run(self): - if not self.parse_file(): - return - self.run_checks() - - def report(self): - def supports_truecolor() -> bool: - return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") - - def rgb(r, g, b) -> str: - return f"\033[38;2;{r};{g};{b}m" - - ANSI = { - "reset": "\033[0m", - "bold": "\033[1m", - "cyan": "\033[96m", - "magenta": "\033[95m", - "yellow": "\033[93m", - "red": "\033[91m", - "green": "\033[92m", - "blue": "\033[94m", - } - - TRUECOLOR = supports_truecolor() - - CRIT = (rgb(220, 20, 60) if TRUECOLOR else ANSI["red"] + ANSI["bold"]) - HIGH = (rgb(255, 0, 0) if TRUECOLOR else ANSI["red"]) - MED = (rgb(255, 165, 0) if TRUECOLOR else ANSI["yellow"]) - LOW = (rgb(0, 200, 0) if TRUECOLOR else ANSI["green"]) - - RESET = ANSI["reset"] - BOLD = ANSI["bold"] - HDR = (rgb(180, 130, 255) if TRUECOLOR else ANSI["magenta"]) - TITLE = (rgb(120, 220, 200) if TRUECOLOR else ANSI["cyan"]) - SUM = (rgb(255, 215, 0) if TRUECOLOR else ANSI["yellow"]) - - sev_color = {"CRITICAL": CRIT, "HIGH": HIGH, "MEDIUM": MED, "LOW": LOW} - - print(f"\n{BOLD}{TITLE}Scan Results for {self.file_path}:{RESET}") - - if not self.vulnerabilities: - ok = rgb(0, 200, 0) if TRUECOLOR else ANSI["green"] - print(f"{ok}βœ… No vulnerabilities found.{RESET}") - return - - groups = {} - for v in self.vulnerabilities: - groups.setdefault(v["category"], []).append(v) - - def cat_key(cat: str): - head = cat.split(":", 1)[0].strip() - return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, cat.lower()) - - for cat in sorted(groups.keys(), key=cat_key): - items = sorted(groups[cat], key=lambda x: x["line"]) - sev_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} - for v in items: - sev_counts[v["severity"]] = sev_counts.get(v["severity"], 0) + 1 - - total = len(items) - print(f"\n{BOLD}{HDR}=== {cat} ({total} finding{'s' if total != 1 else ''}) ==={RESET}") - - chips = [] - for k in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]: - n = sev_counts.get(k, 0) - if n: - chips.append(f"{sev_color[k]}{k.title()}{RESET}: {n}") - if chips: - print(f"{SUM}Summary:{RESET} " + ", ".join(chips)) - - for v in items: - sc = sev_color.get(v["severity"], ANSI["blue"]) - print(f"\n {BOLD}β€’ Line {v['line']} |{RESET} " - f"Severity {sc}{v['severity']}{RESET} | " - f"Confidence {v['confidence']}") - print(f" β†’ {v['description']}") diff --git a/scanner/main.py b/scanner/main.py deleted file mode 100644 index 270e43c..0000000 --- a/scanner/main.py +++ /dev/null @@ -1,15 +0,0 @@ -import argparse -from scanner.core import VulnerabilityScanner - - -def main(): - parser = argparse.ArgumentParser(description="OWASP PR Vulnerability Scanner") - parser.add_argument("path", help="Path to Python file to scan") - args = parser.parse_args() - - scanner = VulnerabilityScanner(args.path) - scanner.run() - scanner.report() - -if __name__ == "__main__": - main() diff --git a/scanner/rules/_template.py b/scanner/rules/_template.py deleted file mode 100644 index d890ea3..0000000 --- a/scanner/rules/_template.py +++ /dev/null @@ -1,11 +0,0 @@ -# Template for adding new OWASP rule modules -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - if "pattern" in line: - add_vulnerability( - "Axx: Rule Name", - f"Description: {line.strip()}", - i + 1, - "HIGH", - "MEDIUM" - ) diff --git a/scanner/rules/auth_failures.py b/scanner/rules/auth_failures.py deleted file mode 100644 index 89077d3..0000000 --- a/scanner/rules/auth_failures.py +++ /dev/null @@ -1,41 +0,0 @@ -# A07:2021 – Identification and Authentication Failures -# Detects default credentials (`admin`, `password`) - -import re - -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - if re.search(r"@app\.route\([\"'](/login|/auth|/signin)[\"']", line): - add_vulnerability( - "A07: Identification and Authentication Failures", - f"Authentication-related route without explicit auth checks: {line.strip()}", - i + 1, - "HIGH", - "MEDIUM" - ) - - if "requests." in line and "verify=False" in line: - add_vulnerability( - "A07: Identification and Authentication Failures", - f"Insecure TLS verification disabled: {line.strip()}", - i + 1, - "HIGH", - "HIGH" - ) - - if re.search(r"(user(name)?\s*=\s*['\"](admin|root)['\"])", line, re.IGNORECASE): - add_vulnerability( - "A07: Identification and Authentication Failures", - f"Hardcoded default username detected: {line.strip()}", - i + 1, - "HIGH", - "HIGH" - ) - if re.search(r"(password\s*=\s*['\"](admin|1234|password)['\"])", line, re.IGNORECASE): - add_vulnerability( - "A07: Identification and Authentication Failures", - f"Hardcoded default password detected: {line.strip()}", - i + 1, - "HIGH", - "HIGH" - ) diff --git a/scanner/rules/broken_access_control.py b/scanner/rules/broken_access_control.py deleted file mode 100644 index 86e8f99..0000000 --- a/scanner/rules/broken_access_control.py +++ /dev/null @@ -1,87 +0,0 @@ -# A01:2021 – Broken Access Control -# -# It looks for common patterns that suggest missing or weak authorization checks: -# 1) Flask routes without an auth/role decorator (e.g., @login_required, @jwt_required). -# 2) Django REST Framework endpoints that explicitly allow unauthenticated access -# (e.g., permission_classes = [AllowAny]). -# 3) Express.js routes that attach a handler directly with no middleware -# (e.g., app.get('/admin', (req, res) => ...)) which often implies no auth check. -# -# Function: -# - `check(code_lines, add_vulnerability)`: Scans lines and reports findings with context. - -import re - -AUTH_DECORATOR_RE = re.compile( - r'@(login_required|jwt_required|roles_required|requires_auth|auth_required|permission_required)', - re.IGNORECASE, -) -FLASK_ROUTE_RE = re.compile(r'@(?:\w+\.)?route\s*\(', re.IGNORECASE) -DEF_RE = re.compile(r'^\s*def\s+\w+\s*\(', re.IGNORECASE) - -DRF_ALLOWANY_RE = re.compile(r'permission_classes\s*=\s*\[\s*AllowAny\s*\]') -DRF_IMPORT_ALLOWANY_RE = re.compile(r'from\s+rest_framework\.permissions\s+import\s+.*AllowAny', re.IGNORECASE) - -EXPRESS_ROUTE_RE = re.compile( - r'\b(?:app|router)\.(get|post|put|patch|delete|options|head)\s*\(\s*[\'"][^\'"]+[\'"]\s*,\s*(?:function|\()', - re.IGNORECASE, -) - -def check(code_lines, add_vulnerability): - drf_allowany_seen = any(DRF_IMPORT_ALLOWANY_RE.search(line) for line in code_lines) - - i = 0 - while i < len(code_lines): - line = code_lines[i] - if FLASK_ROUTE_RE.search(line): - decorators = [] - j = i - while j + 1 < len(code_lines) and not DEF_RE.search(code_lines[j + 1]): - j += 1 - if code_lines[j].lstrip().startswith('@'): - decorators.append(code_lines[j].strip()) - - if j + 1 < len(code_lines) and DEF_RE.search(code_lines[j + 1]): - has_auth = any(AUTH_DECORATOR_RE.search(d) for d in decorators) - path_hint = "" - m = re.search(r'route\s*\(\s*[\'"]([^\'"]+)', line, re.IGNORECASE) - if m: - path_hint = m.group(1) - - if not has_auth: - sev_like = "HIGH" if re.search(r'/?(admin|settings|manage|delete|update|user|account)', path_hint, re.IGNORECASE) else "MEDIUM" - add_vulnerability( - "A02: Broken Access Control", - f"Flask route appears without an auth decorator: {line.strip()}", - i + 1, - sev_like, - "HIGH", - ) - i = j + 1 - else: - i += 1 - else: - i += 1 - - # -------- DRF AllowAny on views / viewsets ---------- - for idx, line in enumerate(code_lines): - if DRF_ALLOWANY_RE.search(line): - like = "HIGH" if drf_allowany_seen else "MEDIUM" - add_vulnerability( - "A02: Broken Access Control", - f"DRF endpoint allows unauthenticated access with AllowAny: {line.strip()}", - idx + 1, - like, - "HIGH", - ) - - # -------- Express routes without middleware ---------- - for idx, line in enumerate(code_lines): - if EXPRESS_ROUTE_RE.search(line): - add_vulnerability( - "A02: Broken Access Control", - f"Express route handler attached without visible auth middleware: {line.strip()}", - idx + 1, - "MEDIUM", - "HIGH", - ) diff --git a/scanner/rules/insecure_design.py b/scanner/rules/insecure_design.py deleted file mode 100644 index c03c199..0000000 --- a/scanner/rules/insecure_design.py +++ /dev/null @@ -1,24 +0,0 @@ -# A04:2021 – Insecure Design -# Flags insecure β€œTODO” markers, temporary overrides, or auth bypass notes - - -import re - -PATTERNS = [ - re.compile(r'\btodo\b.*\b(insecure|security|auth|bypass)\b', re.IGNORECASE), - re.compile(r'\btemporary\b.*\boverride\b', re.IGNORECASE), - re.compile(r'\bdisable(d)?\s+(auth(entication)?|authori[sz]ation)\b', re.IGNORECASE), - re.compile(r'\bbypass(ing)?\s+(auth|security)\b', re.IGNORECASE), -] - -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - stripped = line.strip() - if any(p.search(stripped) for p in PATTERNS): - add_vulnerability( - "A04: Insecure Design", - f"Potential insecure design marker: {stripped}", - i + 1, - "MEDIUM", - "LOW", - ) \ No newline at end of file diff --git a/scanner/rules/integrity_failures.py b/scanner/rules/integrity_failures.py deleted file mode 100644 index a5ea3dd..0000000 --- a/scanner/rules/integrity_failures.py +++ /dev/null @@ -1,48 +0,0 @@ -# A08:2021 – Software and Data Integrity Failure -# Flags: eval/exec, unsafe deserialization (pickle), unsafe YAML load, and shell=True - -import re - -UNSAFE_YAML_RE = re.compile(r'\byaml\.load\s*\(') - -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - stripped = line.strip() - if stripped.startswith("#"): - continue - - if "eval(" in stripped or "exec(" in stripped: - add_vulnerability( - "A08: Software and Data Integrity Failures", - f"Use of dangerous dynamic evaluation: {stripped}", - i + 1, - "HIGH", - "HIGH", - ) - - if "pickle.load(" in stripped or "pickle.loads(" in stripped: - add_vulnerability( - "A08: Software and Data Integrity Failures", - f"Potential unsafe deserialization via pickle: {stripped}", - i + 1, - "HIGH", - "HIGH", - ) - - if UNSAFE_YAML_RE.search(stripped) and "safe_load" not in stripped: - add_vulnerability( - "A08: Software and Data Integrity Failures", - f"Unsafe YAML load detected; use yaml.safe_load(): {stripped}", - i + 1, - "HIGH", - "MEDIUM", - ) - - if "subprocess." in stripped and "shell=True" in stripped: - add_vulnerability( - "A08: Software and Data Integrity Failures", - f"subprocess call with shell=True detected: {stripped}", - i + 1, - "HIGH", - "MEDIUM", - ) diff --git a/scanner/rules/logging_failures.py b/scanner/rules/logging_failures.py deleted file mode 100644 index ce6b7dd..0000000 --- a/scanner/rules/logging_failures.py +++ /dev/null @@ -1,44 +0,0 @@ -# A09:2021 – Security Logging and Monitoring Failures -# Flags: printing secrets, bare except with print, and print in login/auth paths - -import re - -SECRET_WORDS = ("password", "passwd", "secret", "api_key", "apikey", "token") - -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - stripped = line.strip() - low = stripped.lower() - - if stripped.startswith("#"): - continue - - if "print(" in low and any(w in low for w in SECRET_WORDS): - add_vulnerability( - "A09: Security Logging and Monitoring Failures", - f"Possible secret printed to stdout: {stripped}", - i + 1, - "MEDIUM", - "MEDIUM", - ) - if low.startswith("except:") or re.match(r"^except\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s+\w+\s*:\s*$", low): - nxt = code_lines[i + 1].strip().lower() if i + 1 < len(code_lines) else "" - if "print(" in nxt: - add_vulnerability( - "A09: Security Logging and Monitoring Failures", - f"Exception handled with print() instead of proper logging/alerting near: {stripped}", - i + 1, - "MEDIUM", - "LOW", - ) - - if ("@app.route('/login'" in low or "@app.route(\"/login\"" in low) and i + 3 < len(code_lines): - window = " ".join(code_lines[i : i + 5]).lower() - if "print(" in window: - add_vulnerability( - "A09: Security Logging and Monitoring Failures", - "Print used in authentication flow; prefer structured, secure logging.", - i + 1, - "MEDIUM", - "LOW", - ) diff --git a/scanner/rules/security_misconfig.py b/scanner/rules/security_misconfig.py deleted file mode 100644 index 271aabf..0000000 --- a/scanner/rules/security_misconfig.py +++ /dev/null @@ -1,74 +0,0 @@ -# A05:2021 – Security Misconfiguration - - -import re - -DJANGO_DEBUG_RE = re.compile(r'\bDEBUG\s*=\s*True\b') -FLASK_DEBUG_RE = re.compile(r'\bapp\.run\s*\(\s*.*\bdebug\s*=\s*True\b', re.IGNORECASE) -DJANGO_ALLOWED_HOSTS_ANY_RE = re.compile(r'\bALLOWED_HOSTS\s*=\s*\[\s*[\'"]\*\s*[\'"]\s*\]', re.IGNORECASE) - -CORS_WILDCARD_RE = re.compile(r'(Access-Control-Allow-Origin\s*[:=]\s*[\'"]\*\s*[\'"])|("allowAllOrigins"\s*:\s*true)', re.IGNORECASE) -SECURE_FLAG_FALSE_RE = re.compile(r'\b(SECURE_[A-Z_]+|SESSION_COOKIE_SECURE|CSRF_COOKIE_SECURE)\s*=\s*False\b') -INSECURE_COOKIE_RE = re.compile(r'cookie\s*(secure|httpOnly)\s*[:=]\s*false', re.IGNORECASE) - -DEFAULTY_SECRET_RE = re.compile( - r'\b(SECRET_KEY|APP_SECRET|JWT_SECRET|API_KEY|TOKEN|PASSWORD)\s*[:=]\s*[\'"]([^\'"]+)[\'"]', re.IGNORECASE -) -OBVIOUS_DEFAULTS = {'admin', 'password', 'changeme', 'change_me', 'default', 'test', 'secret'} - -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - if DJANGO_DEBUG_RE.search(line): - add_vulnerability( - "A05: Security Misconfiguration", - f"Django DEBUG is enabled: {line.strip()}", - i + 1, - "HIGH", - "MEDIUM", - ) - if FLASK_DEBUG_RE.search(line): - add_vulnerability( - "A05: Security Misconfiguration", - f"Flask debug mode is enabled: {line.strip()}", - i + 1, - "HIGH", - "MEDIUM", - ) - - if DJANGO_ALLOWED_HOSTS_ANY_RE.search(line): - add_vulnerability( - "A05: Security Misconfiguration", - f"ALLOWED_HOSTS permits all hosts: {line.strip()}", - i + 1, - "MEDIUM", - "MEDIUM", - ) - if CORS_WILDCARD_RE.search(line): - add_vulnerability( - "A05: Security Misconfiguration", - f"Wildcard CORS detected: {line.strip()}", - i + 1, - "MEDIUM", - "MEDIUM", - ) - - if SECURE_FLAG_FALSE_RE.search(line) or INSECURE_COOKIE_RE.search(line): - add_vulnerability( - "A05: Security Misconfiguration", - f"Insecure cookie or transport flag: {line.strip()}", - i + 1, - "MEDIUM", - "MEDIUM", - ) - - m = DEFAULTY_SECRET_RE.search(line) - if m: - key, value = m.group(1), m.group(2) - like = "HIGH" if value.strip().lower() in OBVIOUS_DEFAULTS else "MEDIUM" - add_vulnerability( - "A05: Security Misconfiguration", - f"Hardcoded secret or credential in config context: {key} = '***'", - i + 1, - like, - "HIGH", - ) diff --git a/scanner/rules/sensitive_data_exposure.py b/scanner/rules/sensitive_data_exposure.py deleted file mode 100644 index eef860a..0000000 --- a/scanner/rules/sensitive_data_exposure.py +++ /dev/null @@ -1,35 +0,0 @@ -# A02:2021 – Cryptographic Failures -# Detects weak hashing algorithms (MD5, SHA1) -# Flags hardcoded secrets, API keys, and default passwords -# Warns about unsafe fallback values -import re - -def check(code_lines, add_vulnerability): - weak_hashes = ["md5", "sha1"] - sensitive_keywords = ["password", "passwd", "secret", "apikey", "api_key", "token"] - - for i, line in enumerate(code_lines): - stripped = line.strip() - - if stripped.startswith("#"): - continue - - if any(h in stripped.lower() for h in weak_hashes): - add_vulnerability( - "A03: Sensitive Data Exposure", - f"Weak hashing algorithm detected: {stripped}", - i + 1, - "HIGH", - "HIGH" - ) - - if any(kw in stripped.lower() for kw in sensitive_keywords) and "=" in stripped: - if "os.environ" in stripped or "hashlib.sha256" in stripped: - continue - add_vulnerability( - "A03: Sensitive Data Exposure", - f"Potential hardcoded sensitive data: {stripped}", - i + 1, - "HIGH", - "MEDIUM" - ) diff --git a/scanner/rules/sql_injection.py b/scanner/rules/sql_injection.py deleted file mode 100644 index e993868..0000000 --- a/scanner/rules/sql_injection.py +++ /dev/null @@ -1,31 +0,0 @@ -# A03:2021 – Injection* - -import re - -def check(code_lines, add_vulnerability): - assigned_queries = {} - - for i, line in enumerate(code_lines): - if re.search(r"=\s*['\"]\s*(SELECT|INSERT|UPDATE|DELETE)", line, re.IGNORECASE) and '+' in line: - var_match = re.match(r"\s*(\w+)\s*=", line) - if var_match: - var_name = var_match.group(1) - assigned_queries[var_name] = i + 1 - - add_vulnerability( - "A01: Injection", - f"SQL query created via string concatenation: {line.strip()}", - i + 1, - "HIGH", - "MEDIUM" - ) - - for var_name in assigned_queries: - if f"execute({var_name})" in line: - add_vulnerability( - "A01: Injection", - f"Suspicious query passed to execute(): {line.strip()}", - i + 1, - "HIGH", - "HIGH" - ) \ No newline at end of file diff --git a/scanner/rules/ssrf.py b/scanner/rules/ssrf.py deleted file mode 100644 index 0ec3c93..0000000 --- a/scanner/rules/ssrf.py +++ /dev/null @@ -1,35 +0,0 @@ -# A10:2021 – Server-Side Request Forgery (SSRF) -# Heuristic data-flow: user input -> variable -> requests.*(var) - -import re - -REQUEST_CALL_RE = re.compile(r'\brequests\.(get|post|put|patch|delete|head)\s*\(') - -def check(code_lines, add_vulnerability): - input_vars = set() - - for i, line in enumerate(code_lines): - stripped = line.strip() - if stripped.startswith("#"): - continue - - m = re.match(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*input\s*\(', stripped) - if m: - input_vars.add(m.group(1)) - - for i, line in enumerate(code_lines): - stripped = line.strip() - if stripped.startswith("#"): - continue - - if REQUEST_CALL_RE.search(stripped): - for var in input_vars: - if re.search(rf'\b{var}\b', stripped): - add_vulnerability( - "A10: Server-Side Request Forgery", - f"Potential SSRF: unvalidated user-controlled URL passed to requests.*(): {stripped}", - i + 1, - "HIGH", - "HIGH", - ) - break diff --git a/scanner/rules/vulnerable_components.py b/scanner/rules/vulnerable_components.py deleted file mode 100644 index 14aaba1..0000000 --- a/scanner/rules/vulnerable_components.py +++ /dev/null @@ -1,31 +0,0 @@ -# A06:2021 – Vulnerable and Outdated Components -# Placeholder rule: looks for requirements with outdated versions. - -import re - -PIN_RE = re.compile(r'^\s*([A-Za-z0-9][A-Za-z0-9_\-]*)\s*==\s*([A-Za-z0-9\.\-\+]+)\s*$') - -SUSPECT_PACKAGES = {"flask", "django"} - -def check(code_lines, add_vulnerability): - for i, line in enumerate(code_lines): - stripped = line.strip() - - if stripped.startswith("#"): - continue - - m = PIN_RE.match(stripped) - if not m: - continue - - pkg = m.group(1).lower() - ver = m.group(2) - - if pkg in SUSPECT_PACKAGES: - add_vulnerability( - "A06: Vulnerable and Outdated Components", - f"Dependency pin detected (manual review required): {pkg}=={ver}", - i + 1, - "MEDIUM", - "LOW", - ) \ No newline at end of file From 16cf54717c2314e483ab4702cd219c785669ff6b Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:01:26 +1000 Subject: [PATCH 02/11] Add scanner and test_negative.py --- scanner/core.py | 133 +++++++++++++++++++++++ scanner/main.py | 15 +++ scanner/rules/_template.py | 11 ++ scanner/rules/auth_failures.py | 41 +++++++ scanner/rules/broken_access_control.py | 87 +++++++++++++++ scanner/rules/insecure_design.py | 24 ++++ scanner/rules/integrity_failures.py | 48 ++++++++ scanner/rules/logging_failures.py | 44 ++++++++ scanner/rules/security_misconfig.py | 74 +++++++++++++ scanner/rules/sensitive_data_exposure.py | 35 ++++++ scanner/rules/sql_injection.py | 31 ++++++ scanner/rules/ssrf.py | 35 ++++++ scanner/rules/vulnerable_components.py | 31 ++++++ 13 files changed, 609 insertions(+) create mode 100644 scanner/core.py create mode 100644 scanner/main.py create mode 100644 scanner/rules/_template.py create mode 100644 scanner/rules/auth_failures.py create mode 100644 scanner/rules/broken_access_control.py create mode 100644 scanner/rules/insecure_design.py create mode 100644 scanner/rules/integrity_failures.py create mode 100644 scanner/rules/logging_failures.py create mode 100644 scanner/rules/security_misconfig.py create mode 100644 scanner/rules/sensitive_data_exposure.py create mode 100644 scanner/rules/sql_injection.py create mode 100644 scanner/rules/ssrf.py create mode 100644 scanner/rules/vulnerable_components.py diff --git a/scanner/core.py b/scanner/core.py new file mode 100644 index 0000000..b92d068 --- /dev/null +++ b/scanner/core.py @@ -0,0 +1,133 @@ +import os +import importlib +import pkgutil +import scanner.rules as rules_pkg + + +# -------- Rule auto-discovery -------- +def _load_rule_modules(): + modules = [] + for _, modname, _ in pkgutil.iter_modules(rules_pkg.__path__): + if modname.startswith("_"): + continue # skip __init__, _template, etc. + mod = importlib.import_module(f"{rules_pkg.__name__}.{modname}") + if hasattr(mod, "check"): + modules.append(mod) + + def key(m): + cat = getattr(m, "CATEGORY", "") + head = cat.split(":", 1)[0].strip() if cat else "" + return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, m.__name__) + + return sorted(modules, key=key) + + +RULE_MODULES = _load_rule_modules() + + +# -------- Scanner -------- +class VulnerabilityScanner: + def __init__(self, file_path): + self.file_path = file_path + self.code_lines = [] + self.vulnerabilities = [] + + def add_vulnerability(self, category, description, line, severity, confidence): + self.vulnerabilities.append( + { + "category": category, + "description": description, + "line": line, + "severity": severity, + "confidence": confidence, + } + ) + + def parse_file(self): + if not os.path.exists(self.file_path): + print(f"File {self.file_path} does not exist.") + return False + with open(self.file_path, "r", encoding="utf-8") as f: + self.code_lines = f.readlines() + return True + + def run_checks(self): + for rule in RULE_MODULES: + rule.check(self.code_lines, self.add_vulnerability) + + def run(self): + if not self.parse_file(): + return + self.run_checks() + + def report(self): + def supports_truecolor() -> bool: + return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") + + def rgb(r, g, b) -> str: + return f"\033[38;2;{r};{g};{b}m" + + ANSI = { + "reset": "\033[0m", + "bold": "\033[1m", + "cyan": "\033[96m", + "magenta": "\033[95m", + "yellow": "\033[93m", + "red": "\033[91m", + "green": "\033[92m", + "blue": "\033[94m", + } + + TRUECOLOR = supports_truecolor() + + CRIT = (rgb(220, 20, 60) if TRUECOLOR else ANSI["red"] + ANSI["bold"]) + HIGH = (rgb(255, 0, 0) if TRUECOLOR else ANSI["red"]) + MED = (rgb(255, 165, 0) if TRUECOLOR else ANSI["yellow"]) + LOW = (rgb(0, 200, 0) if TRUECOLOR else ANSI["green"]) + + RESET = ANSI["reset"] + BOLD = ANSI["bold"] + HDR = (rgb(180, 130, 255) if TRUECOLOR else ANSI["magenta"]) + TITLE = (rgb(120, 220, 200) if TRUECOLOR else ANSI["cyan"]) + SUM = (rgb(255, 215, 0) if TRUECOLOR else ANSI["yellow"]) + + sev_color = {"CRITICAL": CRIT, "HIGH": HIGH, "MEDIUM": MED, "LOW": LOW} + + print(f"\n{BOLD}{TITLE}Scan Results for {self.file_path}:{RESET}") + + if not self.vulnerabilities: + ok = rgb(0, 200, 0) if TRUECOLOR else ANSI["green"] + print(f"{ok}βœ… No vulnerabilities found.{RESET}") + return + + groups = {} + for v in self.vulnerabilities: + groups.setdefault(v["category"], []).append(v) + + def cat_key(cat: str): + head = cat.split(":", 1)[0].strip() + return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, cat.lower()) + + for cat in sorted(groups.keys(), key=cat_key): + items = sorted(groups[cat], key=lambda x: x["line"]) + sev_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for v in items: + sev_counts[v["severity"]] = sev_counts.get(v["severity"], 0) + 1 + + total = len(items) + print(f"\n{BOLD}{HDR}=== {cat} ({total} finding{'s' if total != 1 else ''}) ==={RESET}") + + chips = [] + for k in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]: + n = sev_counts.get(k, 0) + if n: + chips.append(f"{sev_color[k]}{k.title()}{RESET}: {n}") + if chips: + print(f"{SUM}Summary:{RESET} " + ", ".join(chips)) + + for v in items: + sc = sev_color.get(v["severity"], ANSI["blue"]) + print(f"\n {BOLD}β€’ Line {v['line']} |{RESET} " + f"Severity {sc}{v['severity']}{RESET} | " + f"Confidence {v['confidence']}") + print(f" β†’ {v['description']}") diff --git a/scanner/main.py b/scanner/main.py new file mode 100644 index 0000000..270e43c --- /dev/null +++ b/scanner/main.py @@ -0,0 +1,15 @@ +import argparse +from scanner.core import VulnerabilityScanner + + +def main(): + parser = argparse.ArgumentParser(description="OWASP PR Vulnerability Scanner") + parser.add_argument("path", help="Path to Python file to scan") + args = parser.parse_args() + + scanner = VulnerabilityScanner(args.path) + scanner.run() + scanner.report() + +if __name__ == "__main__": + main() diff --git a/scanner/rules/_template.py b/scanner/rules/_template.py new file mode 100644 index 0000000..d890ea3 --- /dev/null +++ b/scanner/rules/_template.py @@ -0,0 +1,11 @@ +# Template for adding new OWASP rule modules +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + if "pattern" in line: + add_vulnerability( + "Axx: Rule Name", + f"Description: {line.strip()}", + i + 1, + "HIGH", + "MEDIUM" + ) diff --git a/scanner/rules/auth_failures.py b/scanner/rules/auth_failures.py new file mode 100644 index 0000000..89077d3 --- /dev/null +++ b/scanner/rules/auth_failures.py @@ -0,0 +1,41 @@ +# A07:2021 – Identification and Authentication Failures +# Detects default credentials (`admin`, `password`) + +import re + +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + if re.search(r"@app\.route\([\"'](/login|/auth|/signin)[\"']", line): + add_vulnerability( + "A07: Identification and Authentication Failures", + f"Authentication-related route without explicit auth checks: {line.strip()}", + i + 1, + "HIGH", + "MEDIUM" + ) + + if "requests." in line and "verify=False" in line: + add_vulnerability( + "A07: Identification and Authentication Failures", + f"Insecure TLS verification disabled: {line.strip()}", + i + 1, + "HIGH", + "HIGH" + ) + + if re.search(r"(user(name)?\s*=\s*['\"](admin|root)['\"])", line, re.IGNORECASE): + add_vulnerability( + "A07: Identification and Authentication Failures", + f"Hardcoded default username detected: {line.strip()}", + i + 1, + "HIGH", + "HIGH" + ) + if re.search(r"(password\s*=\s*['\"](admin|1234|password)['\"])", line, re.IGNORECASE): + add_vulnerability( + "A07: Identification and Authentication Failures", + f"Hardcoded default password detected: {line.strip()}", + i + 1, + "HIGH", + "HIGH" + ) diff --git a/scanner/rules/broken_access_control.py b/scanner/rules/broken_access_control.py new file mode 100644 index 0000000..86e8f99 --- /dev/null +++ b/scanner/rules/broken_access_control.py @@ -0,0 +1,87 @@ +# A01:2021 – Broken Access Control +# +# It looks for common patterns that suggest missing or weak authorization checks: +# 1) Flask routes without an auth/role decorator (e.g., @login_required, @jwt_required). +# 2) Django REST Framework endpoints that explicitly allow unauthenticated access +# (e.g., permission_classes = [AllowAny]). +# 3) Express.js routes that attach a handler directly with no middleware +# (e.g., app.get('/admin', (req, res) => ...)) which often implies no auth check. +# +# Function: +# - `check(code_lines, add_vulnerability)`: Scans lines and reports findings with context. + +import re + +AUTH_DECORATOR_RE = re.compile( + r'@(login_required|jwt_required|roles_required|requires_auth|auth_required|permission_required)', + re.IGNORECASE, +) +FLASK_ROUTE_RE = re.compile(r'@(?:\w+\.)?route\s*\(', re.IGNORECASE) +DEF_RE = re.compile(r'^\s*def\s+\w+\s*\(', re.IGNORECASE) + +DRF_ALLOWANY_RE = re.compile(r'permission_classes\s*=\s*\[\s*AllowAny\s*\]') +DRF_IMPORT_ALLOWANY_RE = re.compile(r'from\s+rest_framework\.permissions\s+import\s+.*AllowAny', re.IGNORECASE) + +EXPRESS_ROUTE_RE = re.compile( + r'\b(?:app|router)\.(get|post|put|patch|delete|options|head)\s*\(\s*[\'"][^\'"]+[\'"]\s*,\s*(?:function|\()', + re.IGNORECASE, +) + +def check(code_lines, add_vulnerability): + drf_allowany_seen = any(DRF_IMPORT_ALLOWANY_RE.search(line) for line in code_lines) + + i = 0 + while i < len(code_lines): + line = code_lines[i] + if FLASK_ROUTE_RE.search(line): + decorators = [] + j = i + while j + 1 < len(code_lines) and not DEF_RE.search(code_lines[j + 1]): + j += 1 + if code_lines[j].lstrip().startswith('@'): + decorators.append(code_lines[j].strip()) + + if j + 1 < len(code_lines) and DEF_RE.search(code_lines[j + 1]): + has_auth = any(AUTH_DECORATOR_RE.search(d) for d in decorators) + path_hint = "" + m = re.search(r'route\s*\(\s*[\'"]([^\'"]+)', line, re.IGNORECASE) + if m: + path_hint = m.group(1) + + if not has_auth: + sev_like = "HIGH" if re.search(r'/?(admin|settings|manage|delete|update|user|account)', path_hint, re.IGNORECASE) else "MEDIUM" + add_vulnerability( + "A02: Broken Access Control", + f"Flask route appears without an auth decorator: {line.strip()}", + i + 1, + sev_like, + "HIGH", + ) + i = j + 1 + else: + i += 1 + else: + i += 1 + + # -------- DRF AllowAny on views / viewsets ---------- + for idx, line in enumerate(code_lines): + if DRF_ALLOWANY_RE.search(line): + like = "HIGH" if drf_allowany_seen else "MEDIUM" + add_vulnerability( + "A02: Broken Access Control", + f"DRF endpoint allows unauthenticated access with AllowAny: {line.strip()}", + idx + 1, + like, + "HIGH", + ) + + # -------- Express routes without middleware ---------- + for idx, line in enumerate(code_lines): + if EXPRESS_ROUTE_RE.search(line): + add_vulnerability( + "A02: Broken Access Control", + f"Express route handler attached without visible auth middleware: {line.strip()}", + idx + 1, + "MEDIUM", + "HIGH", + ) diff --git a/scanner/rules/insecure_design.py b/scanner/rules/insecure_design.py new file mode 100644 index 0000000..c03c199 --- /dev/null +++ b/scanner/rules/insecure_design.py @@ -0,0 +1,24 @@ +# A04:2021 – Insecure Design +# Flags insecure β€œTODO” markers, temporary overrides, or auth bypass notes + + +import re + +PATTERNS = [ + re.compile(r'\btodo\b.*\b(insecure|security|auth|bypass)\b', re.IGNORECASE), + re.compile(r'\btemporary\b.*\boverride\b', re.IGNORECASE), + re.compile(r'\bdisable(d)?\s+(auth(entication)?|authori[sz]ation)\b', re.IGNORECASE), + re.compile(r'\bbypass(ing)?\s+(auth|security)\b', re.IGNORECASE), +] + +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + stripped = line.strip() + if any(p.search(stripped) for p in PATTERNS): + add_vulnerability( + "A04: Insecure Design", + f"Potential insecure design marker: {stripped}", + i + 1, + "MEDIUM", + "LOW", + ) \ No newline at end of file diff --git a/scanner/rules/integrity_failures.py b/scanner/rules/integrity_failures.py new file mode 100644 index 0000000..a5ea3dd --- /dev/null +++ b/scanner/rules/integrity_failures.py @@ -0,0 +1,48 @@ +# A08:2021 – Software and Data Integrity Failure +# Flags: eval/exec, unsafe deserialization (pickle), unsafe YAML load, and shell=True + +import re + +UNSAFE_YAML_RE = re.compile(r'\byaml\.load\s*\(') + +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + stripped = line.strip() + if stripped.startswith("#"): + continue + + if "eval(" in stripped or "exec(" in stripped: + add_vulnerability( + "A08: Software and Data Integrity Failures", + f"Use of dangerous dynamic evaluation: {stripped}", + i + 1, + "HIGH", + "HIGH", + ) + + if "pickle.load(" in stripped or "pickle.loads(" in stripped: + add_vulnerability( + "A08: Software and Data Integrity Failures", + f"Potential unsafe deserialization via pickle: {stripped}", + i + 1, + "HIGH", + "HIGH", + ) + + if UNSAFE_YAML_RE.search(stripped) and "safe_load" not in stripped: + add_vulnerability( + "A08: Software and Data Integrity Failures", + f"Unsafe YAML load detected; use yaml.safe_load(): {stripped}", + i + 1, + "HIGH", + "MEDIUM", + ) + + if "subprocess." in stripped and "shell=True" in stripped: + add_vulnerability( + "A08: Software and Data Integrity Failures", + f"subprocess call with shell=True detected: {stripped}", + i + 1, + "HIGH", + "MEDIUM", + ) diff --git a/scanner/rules/logging_failures.py b/scanner/rules/logging_failures.py new file mode 100644 index 0000000..ce6b7dd --- /dev/null +++ b/scanner/rules/logging_failures.py @@ -0,0 +1,44 @@ +# A09:2021 – Security Logging and Monitoring Failures +# Flags: printing secrets, bare except with print, and print in login/auth paths + +import re + +SECRET_WORDS = ("password", "passwd", "secret", "api_key", "apikey", "token") + +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + stripped = line.strip() + low = stripped.lower() + + if stripped.startswith("#"): + continue + + if "print(" in low and any(w in low for w in SECRET_WORDS): + add_vulnerability( + "A09: Security Logging and Monitoring Failures", + f"Possible secret printed to stdout: {stripped}", + i + 1, + "MEDIUM", + "MEDIUM", + ) + if low.startswith("except:") or re.match(r"^except\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s+\w+\s*:\s*$", low): + nxt = code_lines[i + 1].strip().lower() if i + 1 < len(code_lines) else "" + if "print(" in nxt: + add_vulnerability( + "A09: Security Logging and Monitoring Failures", + f"Exception handled with print() instead of proper logging/alerting near: {stripped}", + i + 1, + "MEDIUM", + "LOW", + ) + + if ("@app.route('/login'" in low or "@app.route(\"/login\"" in low) and i + 3 < len(code_lines): + window = " ".join(code_lines[i : i + 5]).lower() + if "print(" in window: + add_vulnerability( + "A09: Security Logging and Monitoring Failures", + "Print used in authentication flow; prefer structured, secure logging.", + i + 1, + "MEDIUM", + "LOW", + ) diff --git a/scanner/rules/security_misconfig.py b/scanner/rules/security_misconfig.py new file mode 100644 index 0000000..271aabf --- /dev/null +++ b/scanner/rules/security_misconfig.py @@ -0,0 +1,74 @@ +# A05:2021 – Security Misconfiguration + + +import re + +DJANGO_DEBUG_RE = re.compile(r'\bDEBUG\s*=\s*True\b') +FLASK_DEBUG_RE = re.compile(r'\bapp\.run\s*\(\s*.*\bdebug\s*=\s*True\b', re.IGNORECASE) +DJANGO_ALLOWED_HOSTS_ANY_RE = re.compile(r'\bALLOWED_HOSTS\s*=\s*\[\s*[\'"]\*\s*[\'"]\s*\]', re.IGNORECASE) + +CORS_WILDCARD_RE = re.compile(r'(Access-Control-Allow-Origin\s*[:=]\s*[\'"]\*\s*[\'"])|("allowAllOrigins"\s*:\s*true)', re.IGNORECASE) +SECURE_FLAG_FALSE_RE = re.compile(r'\b(SECURE_[A-Z_]+|SESSION_COOKIE_SECURE|CSRF_COOKIE_SECURE)\s*=\s*False\b') +INSECURE_COOKIE_RE = re.compile(r'cookie\s*(secure|httpOnly)\s*[:=]\s*false', re.IGNORECASE) + +DEFAULTY_SECRET_RE = re.compile( + r'\b(SECRET_KEY|APP_SECRET|JWT_SECRET|API_KEY|TOKEN|PASSWORD)\s*[:=]\s*[\'"]([^\'"]+)[\'"]', re.IGNORECASE +) +OBVIOUS_DEFAULTS = {'admin', 'password', 'changeme', 'change_me', 'default', 'test', 'secret'} + +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + if DJANGO_DEBUG_RE.search(line): + add_vulnerability( + "A05: Security Misconfiguration", + f"Django DEBUG is enabled: {line.strip()}", + i + 1, + "HIGH", + "MEDIUM", + ) + if FLASK_DEBUG_RE.search(line): + add_vulnerability( + "A05: Security Misconfiguration", + f"Flask debug mode is enabled: {line.strip()}", + i + 1, + "HIGH", + "MEDIUM", + ) + + if DJANGO_ALLOWED_HOSTS_ANY_RE.search(line): + add_vulnerability( + "A05: Security Misconfiguration", + f"ALLOWED_HOSTS permits all hosts: {line.strip()}", + i + 1, + "MEDIUM", + "MEDIUM", + ) + if CORS_WILDCARD_RE.search(line): + add_vulnerability( + "A05: Security Misconfiguration", + f"Wildcard CORS detected: {line.strip()}", + i + 1, + "MEDIUM", + "MEDIUM", + ) + + if SECURE_FLAG_FALSE_RE.search(line) or INSECURE_COOKIE_RE.search(line): + add_vulnerability( + "A05: Security Misconfiguration", + f"Insecure cookie or transport flag: {line.strip()}", + i + 1, + "MEDIUM", + "MEDIUM", + ) + + m = DEFAULTY_SECRET_RE.search(line) + if m: + key, value = m.group(1), m.group(2) + like = "HIGH" if value.strip().lower() in OBVIOUS_DEFAULTS else "MEDIUM" + add_vulnerability( + "A05: Security Misconfiguration", + f"Hardcoded secret or credential in config context: {key} = '***'", + i + 1, + like, + "HIGH", + ) diff --git a/scanner/rules/sensitive_data_exposure.py b/scanner/rules/sensitive_data_exposure.py new file mode 100644 index 0000000..eef860a --- /dev/null +++ b/scanner/rules/sensitive_data_exposure.py @@ -0,0 +1,35 @@ +# A02:2021 – Cryptographic Failures +# Detects weak hashing algorithms (MD5, SHA1) +# Flags hardcoded secrets, API keys, and default passwords +# Warns about unsafe fallback values +import re + +def check(code_lines, add_vulnerability): + weak_hashes = ["md5", "sha1"] + sensitive_keywords = ["password", "passwd", "secret", "apikey", "api_key", "token"] + + for i, line in enumerate(code_lines): + stripped = line.strip() + + if stripped.startswith("#"): + continue + + if any(h in stripped.lower() for h in weak_hashes): + add_vulnerability( + "A03: Sensitive Data Exposure", + f"Weak hashing algorithm detected: {stripped}", + i + 1, + "HIGH", + "HIGH" + ) + + if any(kw in stripped.lower() for kw in sensitive_keywords) and "=" in stripped: + if "os.environ" in stripped or "hashlib.sha256" in stripped: + continue + add_vulnerability( + "A03: Sensitive Data Exposure", + f"Potential hardcoded sensitive data: {stripped}", + i + 1, + "HIGH", + "MEDIUM" + ) diff --git a/scanner/rules/sql_injection.py b/scanner/rules/sql_injection.py new file mode 100644 index 0000000..e993868 --- /dev/null +++ b/scanner/rules/sql_injection.py @@ -0,0 +1,31 @@ +# A03:2021 – Injection* + +import re + +def check(code_lines, add_vulnerability): + assigned_queries = {} + + for i, line in enumerate(code_lines): + if re.search(r"=\s*['\"]\s*(SELECT|INSERT|UPDATE|DELETE)", line, re.IGNORECASE) and '+' in line: + var_match = re.match(r"\s*(\w+)\s*=", line) + if var_match: + var_name = var_match.group(1) + assigned_queries[var_name] = i + 1 + + add_vulnerability( + "A01: Injection", + f"SQL query created via string concatenation: {line.strip()}", + i + 1, + "HIGH", + "MEDIUM" + ) + + for var_name in assigned_queries: + if f"execute({var_name})" in line: + add_vulnerability( + "A01: Injection", + f"Suspicious query passed to execute(): {line.strip()}", + i + 1, + "HIGH", + "HIGH" + ) \ No newline at end of file diff --git a/scanner/rules/ssrf.py b/scanner/rules/ssrf.py new file mode 100644 index 0000000..0ec3c93 --- /dev/null +++ b/scanner/rules/ssrf.py @@ -0,0 +1,35 @@ +# A10:2021 – Server-Side Request Forgery (SSRF) +# Heuristic data-flow: user input -> variable -> requests.*(var) + +import re + +REQUEST_CALL_RE = re.compile(r'\brequests\.(get|post|put|patch|delete|head)\s*\(') + +def check(code_lines, add_vulnerability): + input_vars = set() + + for i, line in enumerate(code_lines): + stripped = line.strip() + if stripped.startswith("#"): + continue + + m = re.match(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*input\s*\(', stripped) + if m: + input_vars.add(m.group(1)) + + for i, line in enumerate(code_lines): + stripped = line.strip() + if stripped.startswith("#"): + continue + + if REQUEST_CALL_RE.search(stripped): + for var in input_vars: + if re.search(rf'\b{var}\b', stripped): + add_vulnerability( + "A10: Server-Side Request Forgery", + f"Potential SSRF: unvalidated user-controlled URL passed to requests.*(): {stripped}", + i + 1, + "HIGH", + "HIGH", + ) + break diff --git a/scanner/rules/vulnerable_components.py b/scanner/rules/vulnerable_components.py new file mode 100644 index 0000000..14aaba1 --- /dev/null +++ b/scanner/rules/vulnerable_components.py @@ -0,0 +1,31 @@ +# A06:2021 – Vulnerable and Outdated Components +# Placeholder rule: looks for requirements with outdated versions. + +import re + +PIN_RE = re.compile(r'^\s*([A-Za-z0-9][A-Za-z0-9_\-]*)\s*==\s*([A-Za-z0-9\.\-\+]+)\s*$') + +SUSPECT_PACKAGES = {"flask", "django"} + +def check(code_lines, add_vulnerability): + for i, line in enumerate(code_lines): + stripped = line.strip() + + if stripped.startswith("#"): + continue + + m = PIN_RE.match(stripped) + if not m: + continue + + pkg = m.group(1).lower() + ver = m.group(2) + + if pkg in SUSPECT_PACKAGES: + add_vulnerability( + "A06: Vulnerable and Outdated Components", + f"Dependency pin detected (manual review required): {pkg}=={ver}", + i + 1, + "MEDIUM", + "LOW", + ) \ No newline at end of file From 7894c77aebe14199b49a12935646ac00c5cca9ad Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:09:54 +1000 Subject: [PATCH 03/11] Add test_negative.py --- tests/test_negative.py | 43 +----------------------------------------- 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/tests/test_negative.py b/tests/test_negative.py index 276620b..bc68891 100644 --- a/tests/test_negative.py +++ b/tests/test_negative.py @@ -1,42 +1 @@ -# This file should produce clean results - -import os -import sqlite3 -import hashlib -import requests -from flask import Flask - -def login_required(fn): - return fn - -# --- Secure Flask setup --- -app = Flask(__name__) -app.config["DEBUG"] = False -app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "fallback_only_for_dev_builds") - -@app.route("/dashboard") -@login_required -def dashboard(): - return "secure dashboard" - -# --- Parameterised SQL query (safe) --- -username = input("Enter your username: ") -query = "SELECT * FROM users WHERE username = ?" - -conn = sqlite3.connect("example.db") -cursor = conn.cursor() -cursor.execute(query, (username,)) - -# --- Secure cryptography usage --- -hashed_password = hashlib.sha256(username.encode()).hexdigest() - -# --- Secure HTTP request (TLS verification enabled) --- -resp = requests.get("https://example.com", verify=True) -print(resp.status_code) - - -# --- Safe YAML load --- -data = yaml.safe_load("key: value") - -# --- Safe subprocess usage (no shell=True) --- -subprocess.run(["echo", "hello"], check=True) \ No newline at end of file +"# Negative test file" From 59be61727d10e3af6ade70a5e4ec76d883456b86 Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:11:28 +1000 Subject: [PATCH 04/11] Remove tests folder for positive-results branch --- tests/test_negative.py | 1 - tests/test_positive.py | 77 ------------------------------------------ 2 files changed, 78 deletions(-) delete mode 100644 tests/test_negative.py delete mode 100644 tests/test_positive.py diff --git a/tests/test_negative.py b/tests/test_negative.py deleted file mode 100644 index bc68891..0000000 --- a/tests/test_negative.py +++ /dev/null @@ -1 +0,0 @@ -"# Negative test file" diff --git a/tests/test_positive.py b/tests/test_positive.py deleted file mode 100644 index a2066a1..0000000 --- a/tests/test_positive.py +++ /dev/null @@ -1,77 +0,0 @@ -import sqlite3 -import hashlib -import requests -import yaml -import pickle -import subprocess -from flask import Flask, Response - -# ---------- A05: Security Misconfiguration ---------- -SECRET_KEY = "changeme" -ALLOWED_HOSTS = ['*'] -SESSION_COOKIE_SECURE = False -CSRF_COOKIE_SECURE = False - -# ---------- A03: Sensitive Data Exposure ---------- -password = "SuperSecret123" -api_key = "sk_test_123456" -hashlib.md5(b"weak") - -# ---------- A07: Identification and Authentication Failures ---------- -username = "admin" -default_password = "password" -requests.get("https://example.com", verify=False) - -app = Flask(__name__) - -# ---------- A02: Broken Access Control ---------- -@app.route("/admin") -def admin_panel(): - resp = Response("admin panel") - resp.headers["Access-Control-Allow-Origin"] = "*" - return resp - -@app.route("/login") -def login_page(): - print("login attempt for user") - return "login page" - -# ---------- A04: Insecure Design ---------- - - -# ---------- A06: Vulnerable and Outdated Components ---------- -requirements_block = """ -flask==0.12 -django==1.11 -""" - -# ---------- A08: Software and Data Integrity Failures ---------- -user_code = "1 + 2" -result = eval(user_code) -data = yaml.load("key: value") -with open("tmp.bin", "wb") as fh: - pickle.dump({"x": 1}, fh) -with open("tmp.bin", "rb") as fh: - obj = pickle.load(fh) -subprocess.run("echo hi", shell=True) - -# ---------- A09: Security Logging and Monitoring Failures ---------- -try: - raise ValueError("x") -except: - print("error:", default_password) - -# ---------- A10: SSRF ---------- -url = input("Enter URL: ") -requests.get(url) - -# ---------- A01: Injection ---------- -user_input = input("Enter your username: ") -query = "SELECT * FROM users WHERE username = '" + user_input + "'" - -conn = sqlite3.connect("example.db") -cursor = conn.cursor() -cursor.execute(query) - -if __name__ == "__main__": - app.run(debug=True) From 45c0d83a2c92376bfa86908d5f93acc7a5a0c82e Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:13:01 +1000 Subject: [PATCH 05/11] Add test_positive.py in positive-results branch --- tests/test_positive.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_positive.py diff --git a/tests/test_positive.py b/tests/test_positive.py new file mode 100644 index 0000000..a2066a1 --- /dev/null +++ b/tests/test_positive.py @@ -0,0 +1,77 @@ +import sqlite3 +import hashlib +import requests +import yaml +import pickle +import subprocess +from flask import Flask, Response + +# ---------- A05: Security Misconfiguration ---------- +SECRET_KEY = "changeme" +ALLOWED_HOSTS = ['*'] +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + +# ---------- A03: Sensitive Data Exposure ---------- +password = "SuperSecret123" +api_key = "sk_test_123456" +hashlib.md5(b"weak") + +# ---------- A07: Identification and Authentication Failures ---------- +username = "admin" +default_password = "password" +requests.get("https://example.com", verify=False) + +app = Flask(__name__) + +# ---------- A02: Broken Access Control ---------- +@app.route("/admin") +def admin_panel(): + resp = Response("admin panel") + resp.headers["Access-Control-Allow-Origin"] = "*" + return resp + +@app.route("/login") +def login_page(): + print("login attempt for user") + return "login page" + +# ---------- A04: Insecure Design ---------- + + +# ---------- A06: Vulnerable and Outdated Components ---------- +requirements_block = """ +flask==0.12 +django==1.11 +""" + +# ---------- A08: Software and Data Integrity Failures ---------- +user_code = "1 + 2" +result = eval(user_code) +data = yaml.load("key: value") +with open("tmp.bin", "wb") as fh: + pickle.dump({"x": 1}, fh) +with open("tmp.bin", "rb") as fh: + obj = pickle.load(fh) +subprocess.run("echo hi", shell=True) + +# ---------- A09: Security Logging and Monitoring Failures ---------- +try: + raise ValueError("x") +except: + print("error:", default_password) + +# ---------- A10: SSRF ---------- +url = input("Enter URL: ") +requests.get(url) + +# ---------- A01: Injection ---------- +user_input = input("Enter your username: ") +query = "SELECT * FROM users WHERE username = '" + user_input + "'" + +conn = sqlite3.connect("example.db") +cursor = conn.cursor() +cursor.execute(query) + +if __name__ == "__main__": + app.run(debug=True) From 1f88360b542a7f3db7c2ebf0556b0804d5cb4b4d Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:14:35 +1000 Subject: [PATCH 06/11] Fix positive-results branch: include scanner + workflow + test_positive.py --- .github/workflows/owasp.yml | 127 ++++++++++++++++++++++++++++++++++++ README.md | 104 +++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 .github/workflows/owasp.yml create mode 100644 README.md diff --git a/.github/workflows/owasp.yml b/.github/workflows/owasp.yml new file mode 100644 index 0000000..8493fdd --- /dev/null +++ b/.github/workflows/owasp.yml @@ -0,0 +1,127 @@ +name: OWASP PR Scanner + +on: + pull_request_target: + types: [opened, synchronize, reopened] +permissions: + contents: read + pull-requests: write + +jobs: + scan: + runs-on: ubuntu-latest + + steps: + - name: Checkout PR HEAD + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install deps + run: | + python -m pip install -U pip + if [ -f scanner/requirements.txt ]; then + pip install -r scanner/requirements.txt + elif [ -f requirements.txt ]; then + pip install -r requirements.txt + fi + + - name: Determine changed files for this PR + id: diff + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + RAW="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" || true)" + APP_CHANGED="$(echo "$RAW" \ + | grep -E '\.(js|jsx|ts|tsx|py|java|go|rb|php|html|css|md|conf|yml|yaml|json)$' \ + || true)" + if [ -z "$APP_CHANGED" ]; then + APP_CHANGED="$(git ls-files)" + fi + echo "changed_files<> $GITHUB_OUTPUT + echo "$APP_CHANGED" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Run OWASP scanner + id: owasp + run: | + CHANGED_FILES="${{ steps.diff.outputs.changed_files }}" + if [ -z "$CHANGED_FILES" ]; then + echo "Nothing to scan." | tee owasp-results.txt + echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT + exit 0 + fi + + if [ ! -d "scanner" ]; then + echo "::error::Scanner module not found (scanner/)." + exit 1 + fi + + : > owasp-results.txt + EXIT=0 + while IFS= read -r file; do + [ -z "$file" ] && continue + echo "### File: $file" >> owasp-results.txt + echo '```' >> owasp-results.txt + python -m scanner.main "$file" >> owasp-results.txt 2>&1 || EXIT=1 + echo '```' >> owasp-results.txt + echo "" >> owasp-results.txt + done <<< "$CHANGED_FILES" + + if [ $EXIT -ne 0 ]; then + echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT + else + echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT + fi + exit 0 + + - name: Create PR comment body + if: always() + run: | + RESULTS=$(cat owasp-results.txt || echo "No results.") + if [ "${{ steps.owasp.outputs.vulnerabilities_found }}" == "true" ]; then + echo 'comment_body<> $GITHUB_ENV + echo '## πŸ”’ OWASP Scanner Results' >> $GITHUB_ENV + echo '' >> $GITHUB_ENV + echo 'Vulnerabilities were detected:' >> $GITHUB_ENV + echo '```' >> $GITHUB_ENV + echo "$RESULTS" >> $GITHUB_ENV + echo '```' >> $GITHUB_ENV + echo 'β›” Please address these before merging.' >> $GITHUB_ENV + echo 'EOF' >> $GITHUB_ENV + else + echo 'comment_body<> $GITHUB_ENV + echo '## πŸ”’ OWASP Scanner Results' >> $GITHUB_ENV + echo '' >> $GITHUB_ENV + echo 'No vulnerabilities detected.' >> $GITHUB_ENV + echo '```' >> $GITHUB_ENV + echo "$RESULTS" >> $GITHUB_ENV + echo '```' >> $GITHUB_ENV + echo 'βœ… Good to go.' >> $GITHUB_ENV + echo 'EOF' >> $GITHUB_ENV + fi + + - name: Comment PR + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: ${{ env.comment_body }} + + - name: Upload scan artifact + uses: actions/upload-artifact@v4 + with: + name: owasp-scan-results + path: owasp-results.txt + retention-days: 5 + + - name: Fail if vulnerabilities found + if: steps.owasp.outputs.vulnerabilities_found == 'true' + run: | + echo "::error::OWASP scanner reported vulnerabilities." + exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b42465f --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# OWASP PR Scanner + +This tool scans Python files for security vulnerabilities based on the **OWASP Top 10**. +It is designed for lightweight static analysis of pull requests, helping developers catch common issues early and enforce secure coding practices. + +--- + +## βœ… Current Functionality + +The scanner detects vulnerabilities using static analysis (regex + simple heuristics). +It groups results by OWASP Top 10 category and highlights severity with colour-coded output. + +Implemented rules: + +- **A01:2021 – Broken Access Control** + - Detects Flask routes without authentication decorators + +- **A02:2021 – Cryptographic Failures** + - Detects weak hashing algorithms (MD5, SHA1) + - Flags hardcoded secrets, API keys, and default passwords + - Warns about unsafe fallback values + +- **A03:2021 – Injection** + - Detects unparameterized SQL queries + - Flags SQL built with string concatenation or f-strings + +- **A04:2021 – Insecure Design** + - Flags insecure β€œTODO” markers, temporary overrides, or auth bypass notes + +- **A05:2021 – Security Misconfiguration** + - Detects `debug=True` in Flask apps + - Flags permissive host settings (`ALLOWED_HOSTS = ['*']`) + - Insecure cookie/CSRF flags + - Hardcoded Flask secrets + +- **A06:2021 – Vulnerable and Outdated Components** + - Detects dependency pins like `flask==0.12` or `django==1.11` + - Helps identify outdated or risky components + +- **A07:2021 – Identification and Authentication Failures** + - Detects default credentials (`admin`, `password`) + - Flags login routes without auth checks + - Warns about disabled TLS verification (`verify=False`) + +- **A08:2021 – Software and Data Integrity Failures** + - Detects dangerous use of `eval()` + - Warns about unsafe deserialization (`pickle.load`) + - Flags subprocess calls with `shell=True` + +- **A09:2021 – Security Logging and Monitoring Failures** + - Detects print statements in auth flows + - Flags bare `except:` blocks with no logging + - Warns when secrets are printed to stdout + +- **A10:2021 – Server-Side Request Forgery (SSRF)** + - Detects unvalidated user input passed into `requests.get/post` + +--- + + +## πŸ“‚ Test Cases + +- **`test_positive.py`** + A deliberately vulnerable file that triggers all implemented OWASP rules (A01–A10). + +- **`test_negative.py`** + A safe baseline file with secure practices β€” should pass with **no findings**. + Used for regression testing and validation. + +--- + +## 🎨 Output Example + +- Findings are grouped by OWASP category (A01–A10) +- Severity levels are **colour-coded**: + - πŸ”΄ High + - 🟠 Medium + - 🟒 Low + +Example: +=== A01: Injection (2 findings) === +Summary: High: 2 + +β€’ Line 60 | Severity HIGH | Confidence MEDIUM +β†’ SQL query created via string concatenation: ... + +--- + +## Running the Script +### 1. Navigate to your project root +cd path/to/owasp-scanner + +### 2. Set PYTHONPATH so Python recognizes `scanner/` as a package +set PYTHONPATH=. + +### 3. Run the script with the file to scan as an argument +python scanner/main.py tests/test_positive.py + +## πŸ‘€ Author +Developed by Liana Perry (2025) +Cybersecurity SecDevOps Sub-team | Redback Operations + +## πŸ™Œ Acknowledgements +This project is inspired by the original vulnerability scanning logic created by Amir Zandieh, and extends it into a modular and OWASP-aligned security scanning tool for pull requests. \ No newline at end of file From 5894fd22f7b4b90e970561efd26ce8b590bdecd7 Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:28:51 +1000 Subject: [PATCH 07/11] Scanner outputs GitHub-friendly Markdown; remove pycache/init files --- .github/workflows/owasp.yml | 88 ++++------------------- .gitignore | 7 ++ requirements.txt | 3 + scanner/core.py | 138 +++++++++++++++++++----------------- 4 files changed, 96 insertions(+), 140 deletions(-) create mode 100644 .gitignore create mode 100644 requirements.txt diff --git a/.github/workflows/owasp.yml b/.github/workflows/owasp.yml index 8493fdd..745e6fe 100644 --- a/.github/workflows/owasp.yml +++ b/.github/workflows/owasp.yml @@ -3,9 +3,11 @@ name: OWASP PR Scanner on: pull_request_target: types: [opened, synchronize, reopened] + permissions: contents: read pull-requests: write + issues: write jobs: scan: @@ -28,100 +30,38 @@ jobs: python -m pip install -U pip if [ -f scanner/requirements.txt ]; then pip install -r scanner/requirements.txt - elif [ -f requirements.txt ]; then - pip install -r requirements.txt - fi - - - name: Determine changed files for this PR - id: diff - run: | - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - RAW="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" || true)" - APP_CHANGED="$(echo "$RAW" \ - | grep -E '\.(js|jsx|ts|tsx|py|java|go|rb|php|html|css|md|conf|yml|yaml|json)$' \ - || true)" - if [ -z "$APP_CHANGED" ]; then - APP_CHANGED="$(git ls-files)" fi - echo "changed_files<> $GITHUB_OUTPUT - echo "$APP_CHANGED" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - name: Run OWASP scanner + - name: Run OWASP Scanner id: owasp run: | - CHANGED_FILES="${{ steps.diff.outputs.changed_files }}" - if [ -z "$CHANGED_FILES" ]; then - echo "Nothing to scan." | tee owasp-results.txt - echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT - exit 0 - fi - - if [ ! -d "scanner" ]; then - echo "::error::Scanner module not found (scanner/)." - exit 1 - fi - - : > owasp-results.txt - EXIT=0 - while IFS= read -r file; do - [ -z "$file" ] && continue - echo "### File: $file" >> owasp-results.txt - echo '```' >> owasp-results.txt - python -m scanner.main "$file" >> owasp-results.txt 2>&1 || EXIT=1 - echo '```' >> owasp-results.txt - echo "" >> owasp-results.txt - done <<< "$CHANGED_FILES" - - if [ $EXIT -ne 0 ]; then + python scanner/main.py > scan_output.txt + if grep -q "Severity" scan_output.txt; then echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT else echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT fi - exit 0 - - - name: Create PR comment body - if: always() - run: | - RESULTS=$(cat owasp-results.txt || echo "No results.") - if [ "${{ steps.owasp.outputs.vulnerabilities_found }}" == "true" ]; then - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ OWASP Scanner Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'Vulnerabilities were detected:' >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo "$RESULTS" >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo 'β›” Please address these before merging.' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - else - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ OWASP Scanner Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'No vulnerabilities detected.' >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo "$RESULTS" >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo 'βœ… Good to go.' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - fi - - name: Comment PR + - name: Post PR Comment uses: peter-evans/create-or-update-comment@v4 with: issue-number: ${{ github.event.pull_request.number }} - body: ${{ env.comment_body }} + body-path: scan_output.txt - name: Upload scan artifact uses: actions/upload-artifact@v4 with: name: owasp-scan-results - path: owasp-results.txt + path: scan_output.txt retention-days: 5 - name: Fail if vulnerabilities found if: steps.owasp.outputs.vulnerabilities_found == 'true' run: | - echo "::error::OWASP scanner reported vulnerabilities." + echo "::error::❌ Vulnerabilities detected! Merge blocked." exit 1 + + - name: Safe to merge + if: steps.owasp.outputs.vulnerabilities_found == 'false' + run: | + echo "βœ… No vulnerabilities found. Safe to merge." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..966675c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.venv/ +venv/ +__init__.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c924a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# This file lists all dependencies needed to run the scanner. +# To install the requirements: +# pip install -r requirements.txt \ No newline at end of file diff --git a/scanner/core.py b/scanner/core.py index b92d068..d67deea 100644 --- a/scanner/core.py +++ b/scanner/core.py @@ -60,74 +60,80 @@ def run(self): return self.run_checks() - def report(self): - def supports_truecolor() -> bool: - return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") - - def rgb(r, g, b) -> str: - return f"\033[38;2;{r};{g};{b}m" - - ANSI = { - "reset": "\033[0m", - "bold": "\033[1m", - "cyan": "\033[96m", - "magenta": "\033[95m", - "yellow": "\033[93m", - "red": "\033[91m", - "green": "\033[92m", - "blue": "\033[94m", - } - - TRUECOLOR = supports_truecolor() - - CRIT = (rgb(220, 20, 60) if TRUECOLOR else ANSI["red"] + ANSI["bold"]) - HIGH = (rgb(255, 0, 0) if TRUECOLOR else ANSI["red"]) - MED = (rgb(255, 165, 0) if TRUECOLOR else ANSI["yellow"]) - LOW = (rgb(0, 200, 0) if TRUECOLOR else ANSI["green"]) - - RESET = ANSI["reset"] - BOLD = ANSI["bold"] - HDR = (rgb(180, 130, 255) if TRUECOLOR else ANSI["magenta"]) - TITLE = (rgb(120, 220, 200) if TRUECOLOR else ANSI["cyan"]) - SUM = (rgb(255, 215, 0) if TRUECOLOR else ANSI["yellow"]) - - sev_color = {"CRITICAL": CRIT, "HIGH": HIGH, "MEDIUM": MED, "LOW": LOW} - - print(f"\n{BOLD}{TITLE}Scan Results for {self.file_path}:{RESET}") - - if not self.vulnerabilities: - ok = rgb(0, 200, 0) if TRUECOLOR else ANSI["green"] - print(f"{ok}βœ… No vulnerabilities found.{RESET}") - return - - groups = {} - for v in self.vulnerabilities: - groups.setdefault(v["category"], []).append(v) - - def cat_key(cat: str): - head = cat.split(":", 1)[0].strip() - return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, cat.lower()) - - for cat in sorted(groups.keys(), key=cat_key): - items = sorted(groups[cat], key=lambda x: x["line"]) - sev_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} - for v in items: - sev_counts[v["severity"]] = sev_counts.get(v["severity"], 0) + 1 - - total = len(items) - print(f"\n{BOLD}{HDR}=== {cat} ({total} finding{'s' if total != 1 else ''}) ==={RESET}") - +def report(self): + def supports_truecolor() -> bool: + return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") + + disable_color = os.environ.get("GITHUB_ACTIONS") == "true" + + def rgb(r, g, b) -> str: + return f"\033[38;2;{r};{g};{b}m" + + ANSI = { + "reset": "" if disable_color else "\033[0m", + "bold": "" if disable_color else "\033[1m", + "cyan": "" if disable_color else "\033[96m", + "magenta": "" if disable_color else "\033[95m", + "yellow": "" if disable_color else "\033[93m", + "red": "" if disable_color else "\033[91m", + "green": "" if disable_color else "\033[92m", + "blue": "" if disable_color else "\033[94m", + } + + TRUECOLOR = supports_truecolor() and not disable_color + + sev_color = { + "CRITICAL": "**CRITICAL**" if disable_color else (rgb(220,20,60) if TRUECOLOR else ANSI["red"] + ANSI["bold"]), + "HIGH": "**HIGH**" if disable_color else (rgb(255,0,0) if TRUECOLOR else ANSI["red"]), + "MEDIUM": "**MEDIUM**" if disable_color else (rgb(255,165,0) if TRUECOLOR else ANSI["yellow"]), + "LOW": "**LOW**" if disable_color else (rgb(0,200,0) if TRUECOLOR else ANSI["green"]), + } + + # ---- Print header ---- + if disable_color: + print(f"\n### πŸ”’ OWASP Scanner Results for `{self.file_path}`") + else: + print(f"\n{ANSI['bold']}{ANSI['cyan']}Scan Results for {self.file_path}:{ANSI['reset']}") + + if not self.vulnerabilities: + msg = "βœ… No vulnerabilities found." + print(f"{msg}") + return + + # ---- Group by category ---- + groups = {} + for v in self.vulnerabilities: + groups.setdefault(v["category"], []).append(v) + + def cat_key(cat: str): + head = cat.split(":", 1)[0].strip() + return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, cat.lower()) + + for cat in sorted(groups.keys(), key=cat_key): + items = sorted(groups[cat], key=lambda x: x["line"]) + sev_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for v in items: + sev_counts[v["severity"]] += 1 + + if disable_color: + print(f"\n#### {cat} ({len(items)} findings)") chips = [] for k in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]: - n = sev_counts.get(k, 0) - if n: - chips.append(f"{sev_color[k]}{k.title()}{RESET}: {n}") + if sev_counts[k]: + chips.append(f"{k}: {sev_counts[k]}") if chips: - print(f"{SUM}Summary:{RESET} " + ", ".join(chips)) - - for v in items: - sc = sev_color.get(v["severity"], ANSI["blue"]) - print(f"\n {BOLD}β€’ Line {v['line']} |{RESET} " - f"Severity {sc}{v['severity']}{RESET} | " + print(f"**Summary:** " + ", ".join(chips)) + else: + print(f"\n{ANSI['bold']}{ANSI['magenta']}=== {cat} ({len(items)} findings) ==={ANSI['reset']}") + + # ---- List individual vulnerabilities ---- + for v in items: + sev = sev_color.get(v["severity"], v["severity"]) + if disable_color: + print(f"- Line {v['line']} | Severity {sev} | Confidence {v['confidence']}") + print(f" β†’ {v['description']}") + else: + print(f" {ANSI['bold']}β€’ Line {v['line']} |{ANSI['reset']} " + f"Severity {sev}{ANSI['reset']} | " f"Confidence {v['confidence']}") print(f" β†’ {v['description']}") From 8f3262acca737da39441155be09ee55d60bc6e0b Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:33:38 +1000 Subject: [PATCH 08/11] Fix scanner output for GitHub Markdown and improve main.py handling --- scanner/core.py | 145 ++++++++++++++++++++++++------------------------ scanner/main.py | 36 +++++++++--- 2 files changed, 100 insertions(+), 81 deletions(-) diff --git a/scanner/core.py b/scanner/core.py index d67deea..6da4ecd 100644 --- a/scanner/core.py +++ b/scanner/core.py @@ -60,80 +60,81 @@ def run(self): return self.run_checks() -def report(self): - def supports_truecolor() -> bool: - return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") - - disable_color = os.environ.get("GITHUB_ACTIONS") == "true" - - def rgb(r, g, b) -> str: - return f"\033[38;2;{r};{g};{b}m" - - ANSI = { - "reset": "" if disable_color else "\033[0m", - "bold": "" if disable_color else "\033[1m", - "cyan": "" if disable_color else "\033[96m", - "magenta": "" if disable_color else "\033[95m", - "yellow": "" if disable_color else "\033[93m", - "red": "" if disable_color else "\033[91m", - "green": "" if disable_color else "\033[92m", - "blue": "" if disable_color else "\033[94m", - } - - TRUECOLOR = supports_truecolor() and not disable_color - - sev_color = { - "CRITICAL": "**CRITICAL**" if disable_color else (rgb(220,20,60) if TRUECOLOR else ANSI["red"] + ANSI["bold"]), - "HIGH": "**HIGH**" if disable_color else (rgb(255,0,0) if TRUECOLOR else ANSI["red"]), - "MEDIUM": "**MEDIUM**" if disable_color else (rgb(255,165,0) if TRUECOLOR else ANSI["yellow"]), - "LOW": "**LOW**" if disable_color else (rgb(0,200,0) if TRUECOLOR else ANSI["green"]), - } - - # ---- Print header ---- - if disable_color: - print(f"\n### πŸ”’ OWASP Scanner Results for `{self.file_path}`") - else: - print(f"\n{ANSI['bold']}{ANSI['cyan']}Scan Results for {self.file_path}:{ANSI['reset']}") - - if not self.vulnerabilities: - msg = "βœ… No vulnerabilities found." - print(f"{msg}") - return - - # ---- Group by category ---- - groups = {} - for v in self.vulnerabilities: - groups.setdefault(v["category"], []).append(v) - - def cat_key(cat: str): - head = cat.split(":", 1)[0].strip() - return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, cat.lower()) - - for cat in sorted(groups.keys(), key=cat_key): - items = sorted(groups[cat], key=lambda x: x["line"]) - sev_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} - for v in items: - sev_counts[v["severity"]] += 1 - + def report(self): + """Outputs results with colors locally, or clean Markdown when in GitHub Actions.""" + def supports_truecolor() -> bool: + return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") + + disable_color = os.environ.get("GITHUB_ACTIONS") == "true" + + def rgb(r, g, b) -> str: + return f"\033[38;2;{r};{g};{b}m" + + ANSI = { + "reset": "" if disable_color else "\033[0m", + "bold": "" if disable_color else "\033[1m", + "cyan": "" if disable_color else "\033[96m", + "magenta": "" if disable_color else "\033[95m", + "yellow": "" if disable_color else "\033[93m", + "red": "" if disable_color else "\033[91m", + "green": "" if disable_color else "\033[92m", + "blue": "" if disable_color else "\033[94m", + } + + TRUECOLOR = supports_truecolor() and not disable_color + + sev_color = { + "CRITICAL": "**CRITICAL**" if disable_color else (rgb(220, 20, 60) if TRUECOLOR else ANSI["red"] + ANSI["bold"]), + "HIGH": "**HIGH**" if disable_color else (rgb(255, 0, 0) if TRUECOLOR else ANSI["red"]), + "MEDIUM": "**MEDIUM**" if disable_color else (rgb(255, 165, 0) if TRUECOLOR else ANSI["yellow"]), + "LOW": "**LOW**" if disable_color else (rgb(0, 200, 0) if TRUECOLOR else ANSI["green"]), + } + + # ---- Print header ---- if disable_color: - print(f"\n#### {cat} ({len(items)} findings)") - chips = [] - for k in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]: - if sev_counts[k]: - chips.append(f"{k}: {sev_counts[k]}") - if chips: - print(f"**Summary:** " + ", ".join(chips)) + print(f"\n### πŸ”’ OWASP Scanner Results for `{self.file_path}`") else: - print(f"\n{ANSI['bold']}{ANSI['magenta']}=== {cat} ({len(items)} findings) ==={ANSI['reset']}") + print(f"\n{ANSI['bold']}{ANSI['cyan']}Scan Results for {self.file_path}:{ANSI['reset']}") + + if not self.vulnerabilities: + msg = "βœ… No vulnerabilities found." + print(msg) + return + + # ---- Group by category ---- + groups = {} + for v in self.vulnerabilities: + groups.setdefault(v["category"], []).append(v) + + def cat_key(cat: str): + head = cat.split(":", 1)[0].strip() + return (0, int(head[1:])) if head.startswith("A") and head[1:].isdigit() else (1, cat.lower()) + + for cat in sorted(groups.keys(), key=cat_key): + items = sorted(groups[cat], key=lambda x: x["line"]) + sev_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for v in items: + sev_counts[v["severity"]] += 1 - # ---- List individual vulnerabilities ---- - for v in items: - sev = sev_color.get(v["severity"], v["severity"]) if disable_color: - print(f"- Line {v['line']} | Severity {sev} | Confidence {v['confidence']}") - print(f" β†’ {v['description']}") + print(f"\n#### {cat} ({len(items)} findings)") + chips = [] + for k in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]: + if sev_counts[k]: + chips.append(f"{k}: {sev_counts[k]}") + if chips: + print(f"**Summary:** " + ", ".join(chips)) else: - print(f" {ANSI['bold']}β€’ Line {v['line']} |{ANSI['reset']} " - f"Severity {sev}{ANSI['reset']} | " - f"Confidence {v['confidence']}") - print(f" β†’ {v['description']}") + print(f"\n{ANSI['bold']}{ANSI['magenta']}=== {cat} ({len(items)} findings) ==={ANSI['reset']}") + + # ---- List individual vulnerabilities ---- + for v in items: + sev = sev_color.get(v["severity"], v["severity"]) + if disable_color: + print(f"- Line {v['line']} | Severity {sev} | Confidence {v['confidence']}") + print(f" β†’ {v['description']}") + else: + print(f" {ANSI['bold']}β€’ Line {v['line']} |{ANSI['reset']} " + f"Severity {sev}{ANSI['reset']} | " + f"Confidence {v['confidence']}") + print(f" β†’ {v['description']}") diff --git a/scanner/main.py b/scanner/main.py index 270e43c..714ea75 100644 --- a/scanner/main.py +++ b/scanner/main.py @@ -1,15 +1,33 @@ -import argparse +import sys +import os from scanner.core import VulnerabilityScanner -def main(): - parser = argparse.ArgumentParser(description="OWASP PR Vulnerability Scanner") - parser.add_argument("path", help="Path to Python file to scan") - args = parser.parse_args() +def main(file_paths): + any_vulns = False + + for file_path in file_paths: + scanner = VulnerabilityScanner(file_path) + if not scanner.parse_file(): + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"\n### ⚠️ File `{file_path}` not found") + else: + print(f"\n[!] File {file_path} does not exist.") + continue + + scanner.run_checks() + scanner.report() + + if scanner.vulnerabilities: + any_vulns = True + + if any_vulns: + sys.exit(1) - scanner = VulnerabilityScanner(args.path) - scanner.run() - scanner.report() if __name__ == "__main__": - main() + if len(sys.argv) < 2: + print("Usage: python scanner/main.py ...") + sys.exit(1) + + main(sys.argv[1:]) From 1496dcfe03f69caeed399686194a12207796dec6 Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:36:37 +1000 Subject: [PATCH 09/11] Add test_positive.py to trigger scanner in CI --- tests/test_positive.py | 78 +----------------------------------------- 1 file changed, 1 insertion(+), 77 deletions(-) diff --git a/tests/test_positive.py b/tests/test_positive.py index a2066a1..298b9f6 100644 --- a/tests/test_positive.py +++ b/tests/test_positive.py @@ -1,77 +1 @@ -import sqlite3 -import hashlib -import requests -import yaml -import pickle -import subprocess -from flask import Flask, Response - -# ---------- A05: Security Misconfiguration ---------- -SECRET_KEY = "changeme" -ALLOWED_HOSTS = ['*'] -SESSION_COOKIE_SECURE = False -CSRF_COOKIE_SECURE = False - -# ---------- A03: Sensitive Data Exposure ---------- -password = "SuperSecret123" -api_key = "sk_test_123456" -hashlib.md5(b"weak") - -# ---------- A07: Identification and Authentication Failures ---------- -username = "admin" -default_password = "password" -requests.get("https://example.com", verify=False) - -app = Flask(__name__) - -# ---------- A02: Broken Access Control ---------- -@app.route("/admin") -def admin_panel(): - resp = Response("admin panel") - resp.headers["Access-Control-Allow-Origin"] = "*" - return resp - -@app.route("/login") -def login_page(): - print("login attempt for user") - return "login page" - -# ---------- A04: Insecure Design ---------- - - -# ---------- A06: Vulnerable and Outdated Components ---------- -requirements_block = """ -flask==0.12 -django==1.11 -""" - -# ---------- A08: Software and Data Integrity Failures ---------- -user_code = "1 + 2" -result = eval(user_code) -data = yaml.load("key: value") -with open("tmp.bin", "wb") as fh: - pickle.dump({"x": 1}, fh) -with open("tmp.bin", "rb") as fh: - obj = pickle.load(fh) -subprocess.run("echo hi", shell=True) - -# ---------- A09: Security Logging and Monitoring Failures ---------- -try: - raise ValueError("x") -except: - print("error:", default_password) - -# ---------- A10: SSRF ---------- -url = input("Enter URL: ") -requests.get(url) - -# ---------- A01: Injection ---------- -user_input = input("Enter your username: ") -query = "SELECT * FROM users WHERE username = '" + user_input + "'" - -conn = sqlite3.connect("example.db") -cursor = conn.cursor() -cursor.execute(query) - -if __name__ == "__main__": - app.run(debug=True) +"# Dummy positive test file to trigger scanner" From 998cf02316de5ccd6a58c9f11f9d9d96f15bde77 Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:38:53 +1000 Subject: [PATCH 10/11] Replace dummy test_positive.py with vulnerable version --- tests/test_positive.py | 78 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/test_positive.py b/tests/test_positive.py index 298b9f6..a2066a1 100644 --- a/tests/test_positive.py +++ b/tests/test_positive.py @@ -1 +1,77 @@ -"# Dummy positive test file to trigger scanner" +import sqlite3 +import hashlib +import requests +import yaml +import pickle +import subprocess +from flask import Flask, Response + +# ---------- A05: Security Misconfiguration ---------- +SECRET_KEY = "changeme" +ALLOWED_HOSTS = ['*'] +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + +# ---------- A03: Sensitive Data Exposure ---------- +password = "SuperSecret123" +api_key = "sk_test_123456" +hashlib.md5(b"weak") + +# ---------- A07: Identification and Authentication Failures ---------- +username = "admin" +default_password = "password" +requests.get("https://example.com", verify=False) + +app = Flask(__name__) + +# ---------- A02: Broken Access Control ---------- +@app.route("/admin") +def admin_panel(): + resp = Response("admin panel") + resp.headers["Access-Control-Allow-Origin"] = "*" + return resp + +@app.route("/login") +def login_page(): + print("login attempt for user") + return "login page" + +# ---------- A04: Insecure Design ---------- + + +# ---------- A06: Vulnerable and Outdated Components ---------- +requirements_block = """ +flask==0.12 +django==1.11 +""" + +# ---------- A08: Software and Data Integrity Failures ---------- +user_code = "1 + 2" +result = eval(user_code) +data = yaml.load("key: value") +with open("tmp.bin", "wb") as fh: + pickle.dump({"x": 1}, fh) +with open("tmp.bin", "rb") as fh: + obj = pickle.load(fh) +subprocess.run("echo hi", shell=True) + +# ---------- A09: Security Logging and Monitoring Failures ---------- +try: + raise ValueError("x") +except: + print("error:", default_password) + +# ---------- A10: SSRF ---------- +url = input("Enter URL: ") +requests.get(url) + +# ---------- A01: Injection ---------- +user_input = input("Enter your username: ") +query = "SELECT * FROM users WHERE username = '" + user_input + "'" + +conn = sqlite3.connect("example.db") +cursor = conn.cursor() +cursor.execute(query) + +if __name__ == "__main__": + app.run(debug=True) From c4890976e196a78ce9dee7c21b7bcab566245d47 Mon Sep 17 00:00:00 2001 From: Liana Perry <62174756+lperry022@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:42:10 +1000 Subject: [PATCH 11/11] Replace test_positive.py with vulnerable examples to trigger scanner --- tests/test_positive.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_positive.py b/tests/test_positive.py index a2066a1..a65221d 100644 --- a/tests/test_positive.py +++ b/tests/test_positive.py @@ -6,6 +6,8 @@ import subprocess from flask import Flask, Response + + # ---------- A05: Security Misconfiguration ---------- SECRET_KEY = "changeme" ALLOWED_HOSTS = ['*']