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
64 changes: 55 additions & 9 deletions .github/scripts/pr_security_scan.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import re, sys, os, json
import re, sys, os

changed_files_raw = os.environ.get("CHANGED_FILES", "")
mcf_files = [
f.strip() for f in changed_files_raw.splitlines()
if f.strip().endswith(".mcfunction") and os.path.isfile(f.strip())
]

# --- Bypass: repo admin PR açtıysa fail etme, sadece warn ---
PR_AUTHOR_IS_ADMIN = os.environ.get("PR_AUTHOR_IS_ADMIN", "false").lower() == "true"

# --- Path whitelist ---
# Bu path'lerde eşleşen kurallar WARN'a düşer, fail etmez.
PATH_RULE_WHITELIST = [
# datalib internal CB sistemi — koordinat macro'su ve storage temizliği normaldir
("data/datalib/function/api/cb/", "MACRO_CHAIN"),
("data/datalib/function/api/cb/", "DATA_REMOVE_ENGINE"),
("data/datalib/function/systems/cb/", "MACRO_CHAIN"),
("data/datalib/function/systems/cb/", "DATA_REMOVE_ENGINE"),
# load sırasında storage sıfırlama normaldir
("data/dl_load/function/load/storages", "DATA_REMOVE_ENGINE"),
]

def is_whitelisted(fpath: str, label: str) -> bool:
for path_prefix, rule_label in PATH_RULE_WHITELIST:
if path_prefix in fpath and rule_label == label:
return True
return False

PATTERNS = [
# Privilege escalation
("OP_GRANT", r'(?<!#)^\s*op\s+(@[ase]|@p|\$|\S+)', "CRITICAL", "Grants operator to players"),
Expand Down Expand Up @@ -43,6 +64,7 @@

results = []
total_critical = total_high = total_medium = 0
total_whitelisted = 0

for fpath in mcf_files:
try:
Expand All @@ -59,14 +81,18 @@
continue
for label, pattern, severity, desc in PATTERNS:
if re.search(pattern, stripped, re.IGNORECASE):
whitelisted = is_whitelisted(fpath, label)
file_hits.append({
"line": lineno, "label": label,
"severity": severity, "desc": desc,
"content": stripped[:120]
"content": stripped[:120],
"whitelisted": whitelisted,
})
if severity == "CRITICAL": total_critical += 1
elif severity == "HIGH": total_high += 1
elif severity == "MEDIUM": total_medium += 1
if whitelisted:
total_whitelisted += 1
elif severity == "CRITICAL": total_critical += 1
elif severity == "HIGH": total_high += 1
elif severity == "MEDIUM": total_medium += 1

if file_hits:
results.append({"file": fpath, "hits": file_hits})
Expand All @@ -79,6 +105,7 @@
f.write("critical=0\nhigh=0\nmedium=0\n")
sys.exit(0)

# --- Report ---
report_lines = [
"## ⚠️ PR Security Scan — Issues Found",
"",
Expand All @@ -87,17 +114,30 @@
f"| 🔴 CRITICAL | {total_critical} |",
f"| 🟠 HIGH | {total_high} |",
f"| 🟡 MEDIUM | {total_medium} |",
"",
]

if total_whitelisted > 0:
report_lines.append(f"| ⚪ WHITELISTED (info) | {total_whitelisted} |")

if PR_AUTHOR_IS_ADMIN:
report_lines += [
"",
"> ℹ️ **Admin bypass active** — PR author is a repository admin. Scan findings are informational only; merge is not blocked.",
]

report_lines.append("")

for entry in results:
if "error" in entry:
report_lines.append(f"### ❌ `{entry['file']}` — read error: {entry['error']}")
continue
report_lines.append(f"### `{entry['file']}`")
for hit in entry["hits"]:
icon = {"CRITICAL": "🔴", "HIGH": "🟠", "MEDIUM": "🟡"}.get(hit["severity"], "⚪")
report_lines.append(f"- {icon} **{hit['severity']}** `{hit['label']}` (line {hit['line']}): {hit['desc']}")
if hit["whitelisted"]:
report_lines.append(f"- ⚪ **WHITELISTED** `{hit['label']}` (line {hit['line']}): {hit['desc']} *(internal path — expected)*")
else:
icon = {"CRITICAL": "🔴", "HIGH": "🟠", "MEDIUM": "🟡"}.get(hit["severity"], "⚪")
report_lines.append(f"- {icon} **{hit['severity']}** `{hit['label']}` (line {hit['line']}): {hit['desc']}")
report_lines.append(f" ```")
report_lines.append(f" {hit['content']}")
report_lines.append(f" ```")
Expand All @@ -106,6 +146,7 @@
report_lines += [
"> **This scan is automated.** MEDIUM findings may be false positives.",
"> CRITICAL and HIGH findings must be reviewed before merge.",
"> Whitelisted findings are expected patterns in internal engine paths.",
]

report = "\n".join(report_lines)
Expand All @@ -123,9 +164,14 @@
with open(github_output, "a") as f:
f.write(f"critical={total_critical}\nhigh={total_high}\nmedium={total_medium}\n")

# Admin bypass: hiçbir zaman fail etme
if PR_AUTHOR_IS_ADMIN:
print("SCAN_WARNED (admin bypass)")
sys.exit(0)

if total_critical > 0 or total_high > 0:
print("SCAN_FAILED")
sys.exit(1)

print("SCAN_WARNED")
sys.exit(0)
sys.exit(0)
90 changes: 12 additions & 78 deletions .github/workflows/pr-security-scan.yml
Original file line number Diff line number Diff line change
@@ -1,84 +1,18 @@
name: PR Security Scan

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

permissions:
contents: read
pull-requests: write

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
scan:
name: Scan PR for malicious patterns
runs-on: ubuntu-latest

steps:
- name: Checkout PR branch
uses: actions/checkout@v6
- name: Check if PR author is org admin
id: admin_check
uses: actions/github-script@v8
with:
fetch-depth: 0

- name: Get changed files
id: changed
run: |
git fetch origin ${{ github.base_ref }} --depth=1
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
echo "Changed files:"
echo "$CHANGED"
echo "files<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGED" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
script: |
const { data: membership } = await github.rest.orgs.getMembershipForUser({
org: context.repo.owner,
username: context.payload.pull_request.user.login,
}).catch(() => ({ data: { role: 'member' } }));
const isAdmin = membership.role === 'admin';
core.setOutput('is_admin', isAdmin ? 'true' : 'false');

- name: Security scan — mcfunction
id: mcf_scan
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
run: python3 .github/scripts/pr_security_scan.py

- name: Post scan results as PR comment
if: always() && steps.mcf_scan.outcome != 'skipped'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const reportPath = '/tmp/scan_report.md';
if (!fs.existsSync(reportPath)) {
console.log('No issues found — skipping comment.');
return;
}
const body = fs.readFileSync(reportPath, 'utf8');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('PR Security Scan')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}

- name: Fail on CRITICAL or HIGH findings
if: steps.mcf_scan.outcome == 'failure'
run: |
echo "PR blocked: CRITICAL or HIGH severity findings detected."
echo "Review the scan report above before merging."
exit 1
PR_AUTHOR_IS_ADMIN: ${{ steps.admin_check.outputs.is_admin }}
run: python3 .github/scripts/pr_security_scan.py