feat: vendorizar as 10 skills do pilar de seguranca em skills/
Copia local (nao fork, nao submodule) de safedeps, sca-audit, secrets-scan, code-review-security, api-security-review, web-security-review, cve-triage, patch-prioritization, dependency-scanning e hermaguard -- commit exato de origem e licenca de cada uma em skills/NOTICE.md. Elimina o passo de clone externo pro pilar de seguranca do wf-security-review: git clone deste repo ja traz tudo funcional. Corrige tambem o comando de instalacao pro Claude Code nos dois READMEs e no docs/SKILLS.md: Claude Code exige um diretorio por skill (skills/<nome>/SKILL.md), nao aceita os .md soltos que o comando antigo copiava -- confirmado testando a instalacao real.
This commit is contained in:
1 parent
ee44fa0f7a
commit
c17192c9fe
130 files changed
+10533
-33
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import sqlite3
|
||||
|
||||
def get_user_by_name(username: str):
|
||||
conn = sqlite3.connect("users.db")
|
||||
cur = conn.cursor()
|
||||
query = f"SELECT * FROM users WHERE name = '{username}'"
|
||||
cur.execute(query)
|
||||
return cur.fetchone()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "SQL injection in get_user_by_name",
|
||||
"description": "Username interpolated into SQL via f-string; attacker-controlled input reaches the query unsanitised.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "f-string SQL query username interpolation", "description": "SQL injection via unparameterised query", "severity": "CRITICAL", "classes": ["CWE-89"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,6 +2,6 @@ import sqlite3
|
||||
def get_user_by_name(username: str):
|
||||
conn = sqlite3.connect("users.db")
|
||||
cur = conn.cursor()
|
||||
- cur.execute("SELECT * FROM users WHERE name = ?", (username,))
|
||||
+ query = f"SELECT * FROM users WHERE name = '{username}'"
|
||||
+ cur.execute(query)
|
||||
return cur.fetchone()
|
||||
@@ -0,0 +1,5 @@
|
||||
import yaml
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
return yaml.load(f)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Unsafe YAML deserialisation",
|
||||
"description": "yaml.load() without SafeLoader allows arbitrary object construction — RCE via crafted YAML.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "yaml.load without SafeLoader", "description": "Arbitrary code execution via unsafe deserialisation", "severity": "CRITICAL", "classes": ["CWE-502"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,4 +2,4 @@ import yaml
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
- return yaml.safe_load(f)
|
||||
+ return yaml.load(f)
|
||||
@@ -0,0 +1,4 @@
|
||||
def get_user_email(cur, user_id: int) -> str:
|
||||
cur.execute("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
return row[0]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Missing null check on fetchone result",
|
||||
"description": "fetchone() returns None when no row matches; indexing row[0] then raises TypeError, crashing the caller.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "cursor fetchone row indexed without null check", "description": "TypeError crash when user_id not found", "severity": "MEDIUM", "classes": ["CWE-476", "CWE-754"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,4 +2,3 @@ def get_user_email(cur, user_id: int) -> str:
|
||||
cur.execute("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
- if row is None:
|
||||
- return None
|
||||
return row[0]
|
||||
@@ -0,0 +1,4 @@
|
||||
import subprocess
|
||||
|
||||
def ping_host(host: str) -> bytes:
|
||||
return subprocess.check_output(f"ping -c 1 {host}", shell=True)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Shell command injection via ping utility",
|
||||
"description": "User-supplied host is concatenated into a shell command; shell metacharacters execute arbitrary commands.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "user host concatenated into shell command", "description": "OS command injection via shell=True with unescaped input", "severity": "CRITICAL", "classes": ["CWE-78"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,2 +2,2 @@ import subprocess
|
||||
def ping_host(host: str) -> bytes:
|
||||
- return subprocess.check_output(["ping", "-c", "1", host])
|
||||
+ return subprocess.check_output(f"ping -c 1 {host}", shell=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
def read_doc(name: str) -> str:
|
||||
with open("/var/docs/" + name) as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Path traversal in file download",
|
||||
"description": "User-supplied filename is joined without validation; ../ sequences escape the intended directory.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "filename joined into path without traversal check", "description": "Arbitrary file read via ../ traversal", "severity": "HIGH", "classes": ["CWE-22"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -1,3 +1,3 @@
|
||||
def read_doc(name: str) -> str:
|
||||
- safe = os.path.basename(name)
|
||||
- with open(os.path.join("/var/docs", safe)) as f:
|
||||
+ with open("/var/docs/" + name) as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,11 @@
|
||||
# Deliberately broken sample: a live-looking API key committed to source.
|
||||
# NOTE: the value is an obviously-fake placeholder (marker, not a real key
|
||||
# format) so secret scanners do not flag the corpus; the CWE-798 pattern
|
||||
# (credential hardcoded in source) is what the benchmark tests for.
|
||||
PAYMENT_KEY = "EXAMPLE-KEY-DO-NOT-USE-0123456789"
|
||||
|
||||
def charge(card_token: str, amount: int):
|
||||
import requests
|
||||
return requests.post("https://api.example-payments.invalid/v1/charges",
|
||||
headers={"Authorization": f"Bearer {PAYMENT_KEY}"},
|
||||
data={"card": card_token, "amount": amount})
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Hard-coded API key committed to source",
|
||||
"description": "Live API key embedded in source; anyone with repo access can use the account.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "live API key assigned to module constant",
|
||||
"description": "Credential theft and unauthorized charges from public repo",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-798"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,5 @@
|
||||
def delete_user(session_user, target_id: int):
|
||||
# authn done upstream, but no role check
|
||||
db.execute("DELETE FROM users WHERE id = ?", (target_id,))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Admin endpoint without authorization check",
|
||||
"description": "delete_user performs no permission check; any authenticated user can delete anyone.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "admin action without role/permission check",
|
||||
"description": "Privilege escalation \u2014 any user deletes any account",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-862",
|
||||
"CWE-269"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def delete_user(session_user, target_id: int):
|
||||
- if session_user.get("role") != "admin":
|
||||
- raise PermissionError("admin only")
|
||||
# authn done upstream, but no role check
|
||||
@@ -0,0 +1,3 @@
|
||||
def order_total(price_cents: int, quantity: int) -> int:
|
||||
# no upper bound on quantity
|
||||
return price_cents * quantity
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unvalidated quantity causes integer overflow",
|
||||
"description": "quantity from request body multiplies price without bounds; extreme values overflow.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "quantity multiplied without upper-bound check",
|
||||
"description": "Overflow/wraparound corrupts totals on extreme input",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-190",
|
||||
"CWE-20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def order_total(price_cents: int, quantity: int) -> int:
|
||||
- if quantity > 10_000:
|
||||
- raise ValueError("quantity too large")
|
||||
# no upper bound on quantity
|
||||
@@ -0,0 +1,5 @@
|
||||
import requests
|
||||
|
||||
def send_webhook(url: str, payload: dict):
|
||||
# user-supplied URL fetched without validation
|
||||
requests.post(url, json=payload, timeout=5)
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "SSRF via user-controlled webhook URL",
|
||||
"description": "Webhook URL comes from the request and is fetched server-side without allowlist.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "user-supplied URL fetched server-side without allowlist",
|
||||
"description": "SSRF \u2014 internal network probing and metadata endpoint access",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-918",
|
||||
"CWE-20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
import requests
|
||||
|
||||
def send_webhook(url: str, payload: dict):
|
||||
- if not url.startswith("https://hooks.example.com/"):
|
||||
- raise ValueError("webhook host not allowed")
|
||||
# user-supplied URL fetched without validation
|
||||
@@ -0,0 +1,5 @@
|
||||
function renderResults(results, searchTerm) {
|
||||
const box = document.getElementById("results");
|
||||
box.innerHTML = `<h2>Results for ${searchTerm}</h2>` +
|
||||
results.map(r => `<li>${r.name}</li>`).join("");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Reflected XSS via innerHTML assignment",
|
||||
"description": "Search term inserted into the DOM via innerHTML without escaping.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "user input interpolated into innerHTML",
|
||||
"description": "Script injection in victim browsers via crafted search term",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-79"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,6 @@
|
||||
def withdraw(conn, user_id: int, amount: int):
|
||||
row = conn.execute("SELECT balance FROM accounts WHERE id = ?", (user_id,)).fetchone()
|
||||
if row[0] < amount:
|
||||
raise ValueError("insufficient funds")
|
||||
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, user_id))
|
||||
conn.commit()
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "TOCTOU race on balance check",
|
||||
"description": "Check-then-deduct without lock or atomic update; concurrent requests double-spend.",
|
||||
"severity": "HIGH",
|
||||
"category": "concurrency",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "balance check and deduct are separate non-atomic statements",
|
||||
"description": "Double-spend under concurrent requests",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-362"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def withdraw(conn, user_id: int, amount: int):
|
||||
row = conn.execute("SELECT balance FROM accounts WHERE id = ?", (user_id,)).fetchone()
|
||||
- # single atomic guarded update
|
||||
- cur = conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?", (amount, user_id, amount))
|
||||
- if cur.rowcount == 0:
|
||||
- raise ValueError("insufficient funds")
|
||||
- conn.commit()
|
||||
- return
|
||||
if row[0] < amount:
|
||||
@@ -0,0 +1,5 @@
|
||||
async function loadProfile(userId) {
|
||||
const res = await fetch(`/api/users/${userId}`);
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unhandled promise rejection",
|
||||
"description": "fetch promise has no catch; network failure leaves UI in loading state forever.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "async",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "await fetch chain with no try/catch or .catch",
|
||||
"description": "Unhandled rejection \u2014 loading state never resolves on failure",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"HG-ASYNC",
|
||||
"CWE-754"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
async function loadProfile(userId) {
|
||||
- try {
|
||||
const res = await fetch(`/api/users/${userId}`);
|
||||
const data = await res.json();
|
||||
return data;
|
||||
- } catch (e) {
|
||||
- showError(e);
|
||||
- }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
def process_payment(order):
|
||||
try:
|
||||
gateway.charge(order)
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "paid"}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Swallowed exception in payment processing",
|
||||
"description": "Bare except: pass hides every failure; function returns fake success.",
|
||||
"severity": "HIGH",
|
||||
"category": "error-handling",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "bare except pass, returns success regardless",
|
||||
"description": "Fake success masks failed charges \u2014 silent data/money loss",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"HG-DEGENERATE",
|
||||
"CWE-754"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def process_payment(order):
|
||||
try:
|
||||
gateway.charge(order)
|
||||
except Exception:
|
||||
- log.exception("charge failed")
|
||||
- return {"status": "failed"}
|
||||
+ pass
|
||||
return {"status": "paid"}
|
||||
@@ -0,0 +1,4 @@
|
||||
def transfer(conn, from_id, to_id, amount):
|
||||
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, from_id))
|
||||
conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, to_id))
|
||||
conn.commit()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Non-atomic multi-step update",
|
||||
"description": "Two-table update without transaction; crash between statements corrupts state.",
|
||||
"severity": "HIGH",
|
||||
"category": "data-integrity",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "two dependent updates without a wrapping transaction",
|
||||
"description": "Crash mid-sequence loses money \u2014 debit without credit",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"HG-PARTIAL-WRITE",
|
||||
"CWE-362"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def transfer(conn, from_id, to_id, amount):
|
||||
- with conn:
|
||||
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, from_id))
|
||||
conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, to_id))
|
||||
- conn.commit()
|
||||
@@ -0,0 +1,4 @@
|
||||
def read_config(path):
|
||||
f = open(path)
|
||||
data = json.load(f)
|
||||
return data
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unclosed file handle on error path",
|
||||
"description": "open() without context manager; exception path leaks the descriptor.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "resources",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "open without with/close on all paths",
|
||||
"description": "FD leak under repeated failures exhausts process limit",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-404",
|
||||
"CWE-754"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def read_config(path):
|
||||
- with open(path) as f:
|
||||
- return json.load(f)
|
||||
+ f = open(path)
|
||||
data = json.load(f)
|
||||
@@ -0,0 +1,5 @@
|
||||
def handler(event):
|
||||
try:
|
||||
return do_work(event)
|
||||
except Exception as e:
|
||||
return {"statusCode": 500, "body": str(e)}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Stack trace returned to client",
|
||||
"description": "Bare handler returns exception text in the HTTP response body.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "exception text returned in response body",
|
||||
"description": "Internal paths, queries, and versions leaked to clients",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-200"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
def parse(stream):
|
||||
records = []
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
break
|
||||
records.append(json.loads(line))
|
||||
return records
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Unbounded loop over user input",
|
||||
"description": "parse runs until end-of-stream with no cap on iterations or size.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "resources",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "read loop without record-count or byte cap",
|
||||
"description": "Memory exhaustion DoS from large uploads",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-400"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def parse(stream):
|
||||
records = []
|
||||
+ MAX = 10_000
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
break
|
||||
+ if len(records) >= MAX:
|
||||
+ raise ValueError("too many records")
|
||||
records.append(json.loads(line))
|
||||
@@ -0,0 +1,6 @@
|
||||
import hashlib
|
||||
|
||||
def verify(shared_secret: str, provided: str) -> bool:
|
||||
a = hashlib.sha256(shared_secret.encode()).hexdigest()
|
||||
b = hashlib.sha256(provided.encode()).hexdigest()
|
||||
return a == b
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Password comparison not constant-time",
|
||||
"description": "Plain == on password-derived token allows timing side channel.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "secret compared with plain string equality",
|
||||
"description": "Timing side channel leaks secret one byte at a time",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-287",
|
||||
"CWE-200"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
import hashlib
|
||||
+import hmac
|
||||
|
||||
def verify(shared_secret: str, provided: str) -> bool:
|
||||
a = hashlib.sha256(shared_secret.encode()).hexdigest()
|
||||
b = hashlib.sha256(provided.encode()).hexdigest()
|
||||
- return hmac.compare_digest(a, b)
|
||||
+ return a == b
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import pickle
|
||||
|
||||
def load_state(body: bytes):
|
||||
return pickle.loads(body)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Pickle load of user-supplied bytes",
|
||||
"description": "pickle.loads on request body executes arbitrary code during deserialisation.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "pickle.loads on untrusted request bytes",
|
||||
"description": "RCE \u2014 pickle deserialisation executes attacker code",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-502",
|
||||
"CWE-94"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,4 @@
|
||||
def reset_password(request, user_id: int):
|
||||
new_pw = request.json["password"]
|
||||
db.execute("UPDATE users SET pw = ? WHERE id = ?", (hash(new_pw), user_id))
|
||||
db.commit()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "State-changing route without authentication",
|
||||
"description": "POST handler has no session check; anonymous requests reset any account.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "state-changing handler without session/auth check",
|
||||
"description": "Account takeover by anonymous callers",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-306",
|
||||
"CWE-287"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def reset_password(request, user_id: int):
|
||||
- if not request.session.get("user"):
|
||||
- raise PermissionError("login required")
|
||||
new_pw = request.json["password"]
|
||||
@@ -0,0 +1,3 @@
|
||||
function canEdit(order) {
|
||||
return order.status == 1; // "1" also passes
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Loose equality on status check",
|
||||
"description": "JS == with string/number mix accepts wrong status values.",
|
||||
"severity": "LOW",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "loose == between string and number",
|
||||
"description": "Wrong-type status grants edit rights unexpectedly",
|
||||
"severity": "LOW",
|
||||
"classes": [
|
||||
"CWE-20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
function canEdit(order) {
|
||||
- return order.status === 1;
|
||||
+ return order.status == 1; // "1" also passes
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
async function refresh() {
|
||||
state.loading = true;
|
||||
try {
|
||||
state.data = await api.get("/data");
|
||||
} catch (e) {
|
||||
state.error = e.message;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Missing loading\u2192error UI transition",
|
||||
"description": "Error path updates message but never clears the loading flag.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "catch path never resets loading flag",
|
||||
"description": "Spinner stuck forever after a failed refresh",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"HG-STATE",
|
||||
"HG-ASYNC"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
} catch (e) {
|
||||
state.error = e.message;
|
||||
+ state.loading = false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
def change_email(request):
|
||||
if request.method in ("GET", "POST"):
|
||||
u = db.get_user(request.args["user"])
|
||||
u.email = request.args["email"]
|
||||
u.save()
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "State change on GET without CSRF token",
|
||||
"description": "Password change honors GET with no token; img tags can trigger it.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "state change accepted on GET without CSRF token",
|
||||
"description": "CSRF \u2014 attacker sites change victim emails via img/link",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-352"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def change_email(request):
|
||||
- if request.method == "POST" and request.form.get("csrf_token") == session_csrf():
|
||||
+ if request.method in ("GET", "POST"):
|
||||
u = db.get_user(request.args["user"])
|
||||
@@ -0,0 +1,2 @@
|
||||
def apply_filter(value, expr: str):
|
||||
return eval(f"{value} {expr}")
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "eval on template expression",
|
||||
"description": "Template renderer evals user-supplied filter expressions.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "eval on user-controlled expression string",
|
||||
"description": "Arbitrary code execution via crafted filter",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-94"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
|
||||
def save_upload(file_storage):
|
||||
dest = os.path.join("/srv/uploads", file_storage.filename)
|
||||
file_storage.save(dest)
|
||||
return dest
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unrestricted upload accepts any type",
|
||||
"description": "Upload saves whatever the client sends under the original extension.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "upload saved without extension/content-type validation",
|
||||
"description": "Webshell upload via .php/.jsp under web root",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-434",
|
||||
"CWE-22"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
import os
|
||||
|
||||
def save_upload(file_storage):
|
||||
- ext = os.path.splitext(file_storage.filename)[1].lower()
|
||||
- if ext not in (".png", ".jpg", ".pdf"):
|
||||
- raise ValueError("file type not allowed")
|
||||
dest = os.path.join("/srv/uploads", file_storage.filename)
|
||||
Reference in new issue
Block a user