feature:security
Table of contents
- Introduction
- Scanning every feature
- Fixing SQL
- Fixing HTML built in Python
- Fixing a template
- Marking a reviewed exception
- Reference
- See also
Introduction
feature:security lists the lines of a feature that build SQL or HTML from a string, and the template code the Content-Security-Policy blocks. The framework escapes what a template prints and binds what the ORM sends, so these are the only ways left to an SQL injection or an XSS, and no running app can catch them. Run it while you work on a feature. product:validate runs the same check on every feature of a product, and feature:release refuses a feature that fails it.
splent feature:security notes
Security scan (SQL and XSS sinks)
splent_feature_notes
routes.py:42 [sql-from-string] text() takes SQL built with an f-string
Bind the values, text("... WHERE id = :id").bindparams(id=value), or use the ORM.
templates/notes/index.html:18 [inline-handler] <button onclick=> runs inline code, which the Content-Security-Policy blocks
Bind the handler in the feature's JavaScript with addEventListener, and find the element by a data- attribute.
❌ 2 finding(s) in 1 of 1 feature.
It exits 0 when there is nothing to fix and 1 when there is.
Scanning every feature
splent feature:security
Without a name it scans every editable feature at the workspace root. Name several to scan just those.
splent feature:security notes profile
Fixing SQL
Send values as bound parameters, or use the ORM.
# Refused
db.session.execute(text(f"SELECT id FROM notes WHERE title = '{title}'"))
# Fine
db.session.execute(
text("SELECT id FROM notes WHERE title = :title"), {"title": title}
)
Note.query.filter(Note.title == title)
An order the request names goes through a mapping of the columns it may pick. contains searches inside a column with the request’s % and _ taken as written.
from splent_framework.security import contains, sort_by
notes = sort_by(
Note.query,
request.args.get("sort"),
{"title": Note.title, "created": Note.created_at},
)
notes = notes.filter(contains(Note.title, request.args.get("q", "")))
A table or column name may come from a constant, a model’s __tablename__ or a mapping your code fixes.
TABLE = "notes"
db.session.execute(text(f"SELECT COUNT(*) FROM {TABLE}"))
Fixing HTML built in Python
Build markup with Markup(...).format(), which escapes each value, or wrap each value in escape().
# Refused
return Markup(f'<a href="{url}">{name}</a>')
# Fine
return Markup('<a href="{}">{}</a>').format(url, name)
return Markup(f'<a href="{escape(url)}">{escape(name)}</a>')
A template you render from a string goes in the feature’s templates/ and through render_template().
Fixing a template
Move the code to the feature’s JavaScript, and find the element by a data- attribute.
<!-- Refused -->
<button onclick="archive({{ note.id }})">Archive</button>
<a href="javascript:void(0)" class="more">More</a>
<!-- Fine -->
<button type="button" data-archive="{{ note.id }}">Archive</button>
<button type="button" class="more">More</button>
A <script> that must stay inline takes the request’s nonce, and data for the page goes in a JSON block.
<script nonce="{{ csp_nonce() }}">document.documentElement.dataset.theme = "dark";</script>
<script type="application/json" id="notes-config">{{ config | tojson }}</script>
Never turn escaping off with autoescape false. HTML your code builds is Markup in Python, and | safe cleans any other string as rich text.
Marking a reviewed exception
When you have reviewed a line and it is safe, such as a table name your code quotes, say so on that line with the rule’s name.
conn.execute(text(f"DROP TABLE {quote(name)}")) # splent: allow sql-from-string
<a href="javascript:history.back()">Back</a>{# splent: allow javascript-url #}
In Python the comment holds on any line of the statement, so it stays valid wherever ruff format moves it. Of an if, with or def only the first line counts. In a template it holds on its own line, or on the line the tag starts. Several rules go comma separated.
Reference
| Argument | Description |
|---|---|
FEATURE_IDENTIFIERS |
Short names (notes) or package names (splent_feature_notes), each optionally under a namespace (splent-io/notes) or with @<version>. None scans every editable feature at the workspace root |
Rules
| Rule | Finding |
|---|---|
sql-from-string |
The SQL of text(), execute(), exec_driver_sql() or literal_column(), or a string given to filter(), where(), order_by() or having(), built with an f-string, %, + or format() from a value that is not a literal |
markup-from-string |
Markup() of a string built that way from a value not wrapped in escape() |
template-from-string |
render_template_string() of a template that is not a literal |
autoescape-off |
{% autoescape false %} |
inline-handler |
An event handler attribute, such as onclick= or onsubmit= |
inline-script |
A <script> with code and no src or nonce. A data block, such as type="application/json" or type="application/ld+json", is fine |
javascript-url |
An href, src, action or formaction that starts with javascript: |
A literal, a module-level constant, a pick from a constant mapping and a local name only ever bound to one of those are not variable. A plain parameter passed through is not reported, only a string built in the feature.
What it reads
It reads the copy a name resolves to, the working copy at the workspace root first, then a snapshot in .splent_cache. Inside the package it reads every .py file and the .html, .j2, .xml and .svg files under templates/. Tests are left out, and a file that does not parse is left to the linter.
| Message | Meaning |
|---|---|
Feature '<id>' not found at the workspace root or in .splent_cache. |
No copy to read. Exits 1 |
See also
product:validateruns this check on every feature of a product.feature:releaserefuses a feature with findings.feature:testruns the feature’s tests.