Before you ship a vibe-coded Python app: keep secrets out of code, never build SQL or shell commands from user input, never use user input as a template or a URL, keep TLS on and debug off, use secrets for tokens, install only real packages, and check auth on every route. A scanner can check most of that for you; the rest needs a test.
Most vibe coding checklists, such as Replit's, cover hosting, HTTPS and platform settings. This one covers the Python code the assistant writes. To keep it honest, we wrote a small Flask app with the patterns assistants commonly produce and scanned it.
The vibe-coded app
This is app/main.py, the only module in the test app:
import os
import random
import sqlite3
import string
import requests
from flask import Flask, render_template_string, request
app = Flask(__name__)
app.config["SECRET_KEY"] = "dev"
STRIPE_KEY = "sk_live_..." # a realistic-looking test key in the real file
@app.route("/user")
def get_user():
user_id = request.args.get("id")
db = sqlite3.connect("app.db")
return str(db.execute(f"SELECT * FROM users WHERE id = '{user_id}'").fetchall())
@app.route("/convert")
def convert():
name = request.args.get("file")
os.system(f"convert {name} out.pdf")
return "ok"
@app.route("/hi")
def hi():
template = request.args.get("t", "Hi {{ name }}")
return render_template_string(template, name="User")
@app.route("/fetch")
def fetch():
url = request.args.get("url")
return requests.get(url, verify=False).text
def reset_token():
return "".join(random.choices(string.ascii_letters, k=32))
if __name__ == "__main__":
app.run(debug=True)
skylos . --danger --secrets --format concise printed:
app/main.py:40 SKY-U001 unused function: reset_token
app/main.py:11 SKY-U003 unused variable: STRIPE_KEY
app/main.py:24 SKY-D203 Use of os.system()
app/main.py:37 SKY-D210 requests call with verify=False
app/main.py:41 SKY-D250 Weak random source; use secrets or os.urandom for security-sensitive values
app/main.py:45 SKY-D346 Flask debug mode enabled
app/main.py:18 SKY-D211 Possible SQL injection: tainted or string-built query.
app/main.py:24 SKY-D212 Possible command injection (os.system): tainted input.
app/main.py:37 SKY-D216 Possible SSRF: tainted URL passed to HTTP client.
app/main.py:31 SKY-D349 Server-side template injection: untrusted input (request data `request.args.get`) is compiled as template source by render_template_string(). Pass it as a context variable instead.
app/main.py:11 SKY-S101 Potential stripe secret detected
Adding --quality also reported the requests.get() call without a timeout (SKY-L031). With --gate, the same scan exited 1.
Note what's not there: line 10, app.config["SECRET_KEY"] = "dev", and the fact that none of the routes checks who is calling. Those are the manual items below.
The checklist
| # | Check | How to check it | Our scan |
|---|---|---|---|
| 1 | No API keys, tokens or passwords in code | Secret scanner; load from environment or a secret manager | Flagged (SKY-S101) |
| 2 | No SQL built from strings | Data-flow scan; use parameters (db.execute(q, (user_id,))) | Flagged (SKY-D211) |
| 3 | No shell commands with user input | Data-flow scan; use subprocess.run([...]) without shell=True | Flagged (SKY-D212, D203) |
| 4 | User input is never a template | Data-flow scan; pass input as a variable to a fixed template | Flagged (SKY-D349) |
| 5 | User-supplied URLs aren't fetched blindly | Data-flow scan; allowlist hosts | Flagged (SKY-D216) |
| 6 | TLS verification on, timeouts set | Scan for verify=False; set timeout= on every request | Flagged (SKY-D210, L031) |
| 7 | Debug mode off in production | Scan for debug=True; read it from the environment | Flagged (SKY-D346) |
| 8 | Tokens from secrets, not random | Scan; use secrets.token_urlsafe(32) | Flagged (SKY-D250) |
| 9 | Every package exists and is declared | skylos . --select SKY-D222,SKY-D223 --strict; details | Not in this file; tested separately |
| 10 | Framework secret keys come from config | Manual: SECRET_KEY must not be a literal like "dev" | Not flagged |
| 11 | Every route checks who is asking | Manual: tests for unauthenticated, unauthorized and cross-user requests | Not flagged (can't be inferred) |
| 12 | No auth check removed in a change | skylos . --diff-base origin/main --danger --quality (SKY-L021); details | Not applicable to a new file |
The fixes, in code
import os
import secrets
import sqlite3
import subprocess
from urllib.parse import urlparse
import requests
from flask import Flask, abort, render_template, request
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"] # fails fast if missing
STRIPE_KEY = os.environ.get("STRIPE_KEY")
ALLOWED_HOSTS = {"api.example.com"}
@app.route("/user")
def get_user():
user_id = request.args.get("id")
with sqlite3.connect("app.db") as db:
return str(db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchall())
@app.route("/convert")
def convert():
name = request.args.get("file", "")
subprocess.run(["convert", name, "out.pdf"], check=True)
return "ok"
@app.route("/hi")
def hi():
return render_template("hi.html", name=request.args.get("name", "User"))
@app.route("/fetch")
def fetch():
url = request.args.get("url", "")
if urlparse(url).hostname not in ALLOWED_HOSTS:
abort(400)
return requests.get(url, timeout=10).text
def reset_token():
return secrets.token_urlsafe(32)
if __name__ == "__main__":
app.run(debug=os.environ.get("FLASK_DEBUG") == "1")
Scanning the fixed file left one finding, and the gate still exited 1:
app/main.py:41 SKY-D216 Possible SSRF: tainted URL passed to HTTP client.
Skylos doesn't recognize a hand-written host allowlist as a guard. Once you've reviewed it, record the decision on the line, and the gate passed:
return requests.get(url, timeout=10).text # skylos: ignore[SKY-D216] host checked against ALLOWED_HOSTS
Two things the fixes don't do. The convert route still passes user input to a program as an argument, which the scan didn't flag; validate the file name against what your app expects. And none of this adds authentication; that is item 11.
Item 11: test access control, because no scanner can
A scanner doesn't know who should see invoice 42. Tests do. For every route that reads or changes user data, write the three tests an assistant usually skips:
def test_owner_can_read_invoice(client, user, user_invoice):
r = client.get(f"/invoice/{user_invoice.id}", headers=user.auth)
assert r.status_code == 200
def test_other_user_cannot_read_invoice(client, other_user, user_invoice):
r = client.get(f"/invoice/{user_invoice.id}", headers=other_user.auth)
assert r.status_code in (403, 404)
def test_anonymous_cannot_read_invoice(client, user_invoice):
r = client.get(f"/invoice/{user_invoice.id}")
assert r.status_code == 401
OWASP's Top 10 lists broken access control as A01:2025.
Two failure modes that are new with AI
Made-up packages. Assistants import and install packages that don't exist; attackers register the popular ones (slopsquatting). Check imports with skylos . --select SKY-D222,SKY-D223 --strict, and if an agent runs installs for you, skylos agent install-hooks denies installs of names that don't exist before they run.
Tests deleted to get a green run. An agent told to make the tests pass can delete or skip the failing one. skylos done --base main re-runs the tests and fails if tests were deleted, skipped or emptied of assertions (how).
Gate it
Run the scan on every pull request so the next AI change faces the same checks:
name: Security Gate
on: [pull_request]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- run: python -I -m pip install "skylos==5.1.1"
- name: Security gate
run: skylos . --danger --secrets --gate
--gate uses the default policy: among other limits, it fails on any critical security finding, any secret, or more than five high-severity security findings, and the limits are configurable under [tool.skylos.gate]. It does not fail on AI-defect findings unless you set max_ai_defects = 0, so add the package check from item 9 as its own step if you want that to block. For a fuller workflow with annotations and review comments, run skylos cicd init --no-upload.
Suppressions sit in the diff next to the code, so a reviewer sees each one. skylos done also reports suppressions added in a change (SKY-A118, advice by default).
What this checklist doesn't cover
- Hosting and platform settings: HTTPS, database row-level security, environment separation, backups. Use your platform's checklist.
- Business logic: who may refund, cancel or export what.
- Dependencies you already trust that later ship a vulnerability; run
skylos . -a(includes dependency checks) or your SCA tool on a schedule.