Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/owasp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: OWASP PR Scanner

on:
pull_request_target:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: write
issues: 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<<EOF" >> $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

- 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<<EOF' >> $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<<EOF' >> $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::❌ 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."
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ package-lock.json
package.json
yarn.lock
package-lock.json

__pycache__/
*.pyc
*.pyo

140 changes: 140 additions & 0 deletions scanner/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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):
"""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### 🔒 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(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"]:
if sev_counts[k]:
chips.append(f"{k}: {sev_counts[k]}")
if chips:
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']}")
33 changes: 33 additions & 0 deletions scanner/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import sys
import os
from scanner.core import VulnerabilityScanner


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)


if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python scanner/main.py <file1> <file2> ...")
sys.exit(1)

main(sys.argv[1:])
11 changes: 11 additions & 0 deletions scanner/rules/_template.py
Original file line number Diff line number Diff line change
@@ -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: # replace with real logic
add_vulnerability(
"Axx: Rule Name",
f"Description: {line.strip()}",
i + 1,
"HIGH",
"MEDIUM"
)
45 changes: 45 additions & 0 deletions scanner/rules/auth_failures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# A07:2021 – Identification and Authentication Failures
# Detects default credentials (`admin`, `password`)
# Flags login routes without auth checks
# Warns about disabled TLS verification (`verify=False`)
import re

def check(code_lines, add_vulnerability):
for i, line in enumerate(code_lines):
# Flask/Django style routes that should require auth
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"
)

# Python requests with TLS verify disabled
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"
)

# Hardcoded default creds
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"
)
Loading