Secure Coding Patterns
Most injection-class vulnerabilities aren't exotic. They're the same shape, over and over: untrusted data gets concatenated into a string that's handed to an interpreter — a SQL engine, a shell, a template renderer, an object deserializer — which can no longer tell where the "instructions" end and the "data" begins. This page is not a list of things to remember to check for. It's a set of patterns — parameterized queries, output encoding chosen by where the data lands rather than where it came from, and deserialization that never lets a byte stream decide which code runs — that make an entire vulnerability class structurally absent from a call site, regardless of whether a scanner ever looks at it. SAST, DAST & SCA covers the detection layer; this page covers the layer underneath it, the one that determines how much the detection layer actually has to catch.
Imagine a form letter with a blank for a name: "Dear ____, you have won a prize." If you let anyone write anything in the blank, someone eventually writes "Dear Nobody, you have won a prize. P.S. also please erase every other letter in the building" — and if a robot reads the whole thing as one set of instructions, it might actually go do the erasing. A safer form has a blank that can only ever be read as a name — no matter what someone writes in it, the sentence around it stays exactly what it always was. Parameterized queries, careful escaping, and safe deserialization are all the same trick: build the blank so it can never turn into a new instruction, instead of reading the whole letter and hoping you notice the dangerous part in time.
Why "the scanner will catch it" is the wrong mental model
☺ Like you're 10: A metal detector only beeps at metal it was built to recognize. It's not lying to you when it stays silent for something it was never taught to notice — but silence isn't the same thing as safe.
Static analysis is a pattern-matcher with a finite ruleset, run against code whose shape it has to infer. It works by tracing taint — marking data that originated somewhere untrusted (a request parameter, a form field, a header) and following it through assignments, function calls, and returns until it either gets sanitized or reaches a sink the tool recognizes as dangerous (a query execution call, a shell invocation, an HTML write). Every one of those steps is a place the trace can quietly break: taint tracking loses precision across reflection, dynamic dispatch, and deeply interprocedural flows; a sink hidden behind your team's own thin wrapper around the database driver may not be modeled as a sink at all until someone teaches the tool that it is; a query built in one function and executed three calls away, in a different file, is exactly the kind of long-range flow that taxes a tool's configured analysis depth; and second-order injection — data written safely once, then read back later and concatenated unsafely somewhere else entirely — crosses a boundary most tools don't even attempt to trace, because the "input" at the vulnerable sink is your own database, not the original request.
None of that makes SAST worthless — it catches a large share of the obvious cases, cheaply, on every commit, which is exactly why SAST, DAST & SCA and static analysis & secrets detection are load-bearing parts of this course. The point is narrower and more important than "scanners are bad": a scanner finding an instance of a vulnerability class is a probabilistic event that has to keep succeeding, correctly, on every future line anyone on the team ever writes, forever, against a ruleset that's always catching up to a moving target. A pattern that makes the vulnerability class structurally impossible at the API level — a parameterized query, an argument-vector process call, an auto-escaping template — only has to be adopted once. After that, there's nothing left for the scanner to need to catch, because there's no longer a code shape that could be wrong.
"Zero SAST findings" means zero findings the ruleset was built to look for — it is not a claim that zero vulnerabilities exist. A scanner cannot flag a sink it doesn't model, a flow it can't trace across the boundary you introduced, or a vulnerability class its authors hadn't seen yet when the ruleset shipped. Treat a clean scan as "nothing we already know to look for," never as "safe."
The shape every injection vulnerability shares
☺ Like you're 10: SQL injection, command injection, and a dozen other named bugs aren't really different problems wearing different names — they're the exact same mistake, made against a different translator.
Once you see the pattern once, you see it everywhere: an interpreter — something that parses a string and turns part of it into executable instructions — receives a string built by concatenating trusted structure (a query template, a shell command, a template body) with untrusted data (something a user typed, a filename, a header value), and the interpreter has no way to tell which parts were meant as instructions and which were meant as inert data, because by the time it sees the string, that distinction has already been erased. The fix is never "add a filter that catches the dangerous characters after the fact" — that's a blocklist chasing an open-ended list of ways to encode the same metacharacter differently. The fix is a boundary the interpreter itself enforces, so the data can't be reinterpreted as structure no matter what it contains.
| Injection type | The interpreter | Untrusted data ends up as | Real-world example |
|---|---|---|---|
| SQL injection | The database engine's SQL parser | Query syntax instead of a bound value | TalkTalk's 2015 breach, later attributed by the UK ICO to SQL injection in a legacy web page |
| OS command injection | The shell (/bin/sh) | A second command, chained with ;, |, or ` ` | Countless "image converter" and "ping this host" utilities that shell out with concatenated input |
| Path traversal | The filesystem path resolver | ../ segments that walk outside an intended root directory | CVE-2021-41773 — Apache HTTP Server 2.4.49, chained into RCE where CGI was enabled |
| Server-side template injection (SSTI) | The template engine (Jinja2, FreeMarker, Velocity) | Template syntax instead of a value to interpolate | User-controlled strings passed straight into render_template_string() |
| NoSQL injection | The document store's query operators | Operators like MongoDB's $where/$ne instead of a literal | JSON bodies parsed straight into a query filter object with no schema check |
| LDAP / XPath injection | The LDAP or XPath query parser | Filter syntax instead of a literal to match | Login forms that build an LDAP filter string by hand |
| Log injection | Whatever parses the log stream later (a SIEM query, a log viewer) | Forged log lines, or control characters that fake a new entry | Unescaped newlines in user input written straight into a log line |
Every row in that table gets the same fix, restated for a different interpreter: keep the untrusted data in a channel the interpreter is structurally required to treat as data — a bind parameter, an argument vector, an auto-escaping placeholder — instead of splicing it into the string the interpreter parses as instructions. Learn the pattern once and every named vulnerability class above stops looking like a separate thing to memorize.
Parameterized queries over string concatenation
☺ Like you're 10: Instead of writing your question and the answer on the same piece of paper and hoping the reader can tell them apart, you hand over a form with blanks, and separately, in a different envelope, hand over exactly what goes in each blank.
A prepared statement doesn't just "escape" the input more carefully than you would by hand — it changes the protocol. The query template (with placeholders) is sent to the database and compiled into an execution plan first, before any bind values are sent at all. The values are then transmitted separately, over a channel the database already knows is data, not SQL text, so there's no parsing step during which a bind value could be reinterpreted as query syntax — an apostrophe inside a bound string is just a character in a string, never a syntax boundary. This is why parameterization closes the entire vulnerability class rather than reducing its probability: the interpreter has structurally already finished parsing "the query" before it ever looks at "the values."
-- Never do this: the value becomes part of the SQL grammar, not just its content. -- A username of ' OR '1'='1 turns the whole WHERE clause into something else entirely. SELECT * FROM users WHERE username = '" + username + "'
// Java — JDBC PreparedStatement
String sql = "SELECT * FROM users WHERE username = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username);
ResultSet rs = stmt.executeQuery();
}# Python — psycopg2 (Postgres). The driver binds the tuple; never do this with % or f-strings.
cur.execute("SELECT * FROM users WHERE username = %s", (username,))// Node.js — node-postgres (pg). $1 is a bind placeholder, not a template slot.
await pool.query('SELECT * FROM users WHERE username = $1', [username]);// Go — database/sql. Placeholder syntax is driver-specific: ? for MySQL, $1 for pgx/lib/pq.
row := db.QueryRow("SELECT * FROM users WHERE username = ?", username)// PHP — PDO with named parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute(['username' => $username]);// Rust — sqlx goes one step further than parameterization: query! checks the query's
// column names and types against your actual database schema AT COMPILE TIME, so a typo'd
// column or a type mismatch is a build failure, not a runtime surprise in production.
let row = sqlx::query!("SELECT * FROM users WHERE username = $1", username)
.fetch_one(&pool)
.await?;An ORM's query builder is safe by default — its raw-query escape hatch is not
Most ORMs parameterize automatically when you use their query-builder API, which is a real safety net. But nearly every ORM also ships an explicit "just run this SQL" escape hatch for the cases the builder can't express, and that escape hatch reintroduces exactly the flaw parameterization was supposed to eliminate the moment someone builds its argument with string interpolation instead of the tool's own bind-parameter support.
# Django — .raw() takes a query string, and it will parameterize IF you use its
# placeholder syntax. An f-string defeats the entire point of calling .raw() at all.
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'") # UNSAFE — reintroduces concatenation
User.objects.raw("SELECT * FROM users WHERE username = %s", [username]) # SAFE — still parameterized// Sequelize — .query() accepts raw SQL either way; only "replacements" is bound safely.
await sequelize.query(`SELECT * FROM users WHERE username = '${username}'`); // UNSAFE
await sequelize.query('SELECT * FROM users WHERE username = :username', {
replacements: { username }, type: QueryTypes.SELECT
}); // SAFEThe same trap hides one layer deeper inside stored procedures: a procedure that receives a bound, safely-passed parameter and then builds a second, dynamic SQL string inside itself with EXEC(@sql) or sp_executesql concatenation has simply moved the vulnerable concatenation from your application code into the database — the parameter was safe getting into the procedure, and unsafe again the moment the procedure built new SQL text out of it. And watch for second-order injection: a value stored safely via a parameterized INSERT is completely inert sitting in the table, but if some other code path later reads that same value back and concatenates it into a different query, the vulnerability exists at the second site, not the first — the original insert being "safe" tells you nothing about every downstream read.
Command execution and path handling without a shell in the middle
☺ Like you're 10: Handing someone a single sentence to shout at a very literal robot butler is risky — handing the butler a labeled list of exactly which words are the command and which are the ingredients isn't.
OS command injection is the same shape as SQL injection with one interpreter swapped for another: /bin/sh. Any API that hands a single string to a shell for interpretation treats every shell metacharacter in that string — ;, |, &&, backticks, $() — as live syntax, because that's the shell's whole job. The fix is the same fix, restated: use the API that takes an argument vector directly and never invokes a shell at all, so those characters arrive as inert bytes in one argument, with nowhere to be reinterpreted.
# UNSAFE — shell=True hands the whole string to /bin/sh; metacharacters and all
subprocess.run(f"convert {filename} out.png", shell=True)
# SAFE — an argument list with the default shell=False never spawns a shell to parse it
subprocess.run(["convert", filename, "out.png"], shell=False)// UNSAFE — exec() runs the whole string through a shell
exec(`convert ${filename} out.png`);
// SAFE — execFile() (or spawn() with an argument array) never spawns a shell
execFile("convert", [filename, "out.png"]);// UNSAFE — Runtime.exec(String) does its own naive whitespace tokenizing and still
// hands metacharacters through untouched once the string was built by concatenation
Runtime.getRuntime().exec("convert " + filename + " out.png");
// SAFE — ProcessBuilder takes the argument vector directly, no shell, no re-tokenizing
new ProcessBuilder("convert", filename, "out.png").start();// SAFE by default — exec.Command never invokes a shell unless you explicitly ask for one
cmd := exec.Command("convert", filename, "out.png")Path traversal is command injection's quieter cousin: the "interpreter" is the filesystem's path resolver, and a blocklist that strips literal ../ sequences is exactly the wrong shape of defense, because it can be bypassed with encoding, double-encoding, absolute paths, or a symlink the check never walked through. CVE-2021-41773 — a path traversal in Apache HTTP Server 2.4.49 that chained into remote code execution wherever CGI scripts were enabled — was mass-exploited within days of disclosure precisely because normalization had a gap a blocklist-style check missed. The durable pattern is canonicalize, then verify containment: resolve the path to its real, absolute form and confirm it still sits under the one directory you intended to allow, rather than trying to enumerate every string that could walk out of it.
import os
base = os.path.realpath("/var/app/uploads")
target = os.path.realpath(os.path.join(base, user_supplied_path))
if os.path.commonpath([base, target]) != base:
raise ValueError("path escapes the allowed root")Output encoding by context, not by habit
☺ Like you're 10: The same word needs a different kind of quotation mark depending on whether you're writing it in a story, texting it, or putting it on a street sign — one universal "make it safe" button can't know which one you're doing.
The single most common misconception about cross-site scripting defense is that "sanitizing the input" is one function you call once. It isn't, because the correct transformation depends entirely on the sink — where the value is about to land — not on where it came from. The same untrusted string needs a completely different encoding depending on whether it's about to sit in an HTML text node, inside an HTML attribute, inside a <script> block as a JavaScript string literal, inside a URL, or inside a CSS value — and applying the wrong one doesn't just fail to help, it can look like it worked while leaving the injection wide open.
| Sink context | Required transform | Example call |
|---|---|---|
| HTML body text | HTML entity encoding | Java: Encode.forHtml(name) · Python: markupsafe.escape(name) |
| HTML attribute value | Entity encoding, and the attribute must stay quoted | Java: Encode.forHtmlAttribute(name) |
JS string literal (inside <script>) | JavaScript string escaping — not HTML encoding | Java: Encode.forJavaScript(name) |
| URL parameter value | Percent-encoding | Java: Encode.forUriComponent(q) · most languages: a URL-encode stdlib call |
| CSS value | CSS escaping | Java: Encode.forCssString(v) |
This is exactly why auto-escaping template engines matter as much as parameterized queries do — they're the same pattern applied to output: the engine tracks where in the document a placeholder sits and escapes accordingly, instead of leaving it to the developer to remember which of five functions applies at each spot. Go's html/template package parses the template well enough to know a given {{.Username}} sits inside a <script> string literal versus an HTML attribute versus plain body text, and applies the matching escaping automatically — its sibling package, text/template, does none of this and must never be used to render into HTML. Flask's Jinja2 defaults to autoescape=True. React escapes JSX text content by default; the one bypass, dangerouslySetInnerHTML, is named exactly that on purpose, so it greps easily and a reviewer knows to ask why it's there. Vue's v-html and Angular's bypassSecurityTrust* family are the same kind of deliberately loud escape hatch.
// html/template tracks parse context and escapes for it automatically
tmpl := template.Must(template.New("x").Parse(`<script>var u = "{{.Username}}";</script>`))
// A Username of "; alert(1); // is emitted JS-escaped here — as a JSON string is,
// or double-encoded on top of your own JS-escaping if you tried to "sanitize" it yourself.
// text/template would emit the raw value here with zero escaping — never use it for HTML output.Keep two more distinctions straight, because conflating them is common. Encoding is reversible and preserves meaning — it makes a value safe to place in a given context without changing what it represents. Sanitization is destructive by design — it rewrites or strips content, which is what you need when the input is genuinely supposed to contain markup (a user-authored "rich text" comment), using a library built for exactly that, like DOMPurify, not a hand-rolled regex. And some sinks aren't fixable by encoding at all: a URL whose scheme is attacker-controlled (javascript:alert(1) passed into an href) executes regardless of how carefully you encode the rest of the string, because the danger is which scheme it is, not which characters it contains — that needs an allowlist of permitted schemes, not an encoder. A Content-Security-Policy with nonces or hashes is worth layering on top of all of this as a backstop, the same way this whole page argues for layering encoding correctly rather than relying on any single control — but CSP is the last line, not a substitute for getting the encoding right at the point of output.
Safe deserialization: don't let the wire format double as executable code
☺ Like you're 10: A gift-wrapped box that also comes with a remote control for your house is a strange gift to accept from a stranger — but that's what some "just unwrap this data" formats secretly hand you.
Native object-graph deserialization — Java's ObjectInputStream, Python's pickle, PHP's unserialize(), .NET's BinaryFormatter, Ruby's Marshal and unsafe YAML loading — is a different flavor of the same shape one more time, and it's worth naming explicitly because it doesn't look like the others at first glance. There's no string concatenation and no obvious "query" being built. Instead, the byte stream itself gets to choose which classes get instantiated and which of their methods run during reconstruction — constructors, readObject, PHP's __wakeup, Python's __reduce__, finalizers. An attacker doesn't need to inject new code at all; they only need classes that are already present on the classpath, chained together into a sequence where reconstructing one triggers behavior that feeds into the next — a gadget chain — ending in arbitrary code execution, built entirely out of code you already trusted enough to ship.
This is not a theoretical risk. ysoserial, released by Chris Frohoff and Gabriel Lawrence at AppSecCali 2015, automated gadget-chain generation for common Java libraries (Apache Commons Collections among them) and fueled a wave of real deserialization RCEs across WebLogic, WebSphere, and JBoss deployments in the years after. Apache Struts2's REST plugin shipped an XStream deserialization flaw of the same shape (CVE-2017-9805). And CVE-2013-0156 — Ruby on Rails' YAML/XML parameter-parsing vulnerability, patched in an emergency release in January 2013 — is the canonical example of a framework's own convenience feature (auto-parsing request parameters as YAML) turning into unauthenticated remote code execution for essentially every Rails app that hadn't patched. PHP has its own version of ysoserial in PHPGGC, targeting unserialize() gadget chains in popular frameworks. And Microsoft's own .NET documentation now states plainly that BinaryFormatter is insecure and cannot be made secure — check the docs for your target framework version, since recent releases increasingly disable it by default outside a narrow, explicit opt-in.
Deserialization vulnerabilities are injection's cousin, not a separate category: the interpreter here is the language runtime's own object-reconstruction machinery, and the "instructions" hidden in the data are which class to build and which method to call while building it. Once you see it that way, the fix is the familiar one: keep untrusted bytes on the "data" side of a boundary the deserializer can't cross into "code."
The reliable fix is to stop using formats with code-execution hooks for anything crossing a trust boundary at all — prefer data-only formats like JSON or Protocol Buffers, validated against an explicit schema, which have no constructor-invocation or method-dispatch step built into parsing a plain object or map. Where a native format is unavoidable, use its safe subset and an explicit allowlist of exactly which classes may be reconstructed.
# UNSAFE — the default Loader can instantiate arbitrary Python objects from the stream config = yaml.load(untrusted_bytes, Loader=yaml.Loader) # SAFE — SafeLoader only ever constructs plain dict/list/str/int/float/bool/None config = yaml.safe_load(untrusted_bytes)
// Java (JEP 290, JDK 9+, backported to 8u121) — an allowlist filter on the input stream.
// Reject everything not explicitly named, instead of trying to blocklist known-bad gadgets.
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.acme.dto.*;java.base/*;!*");
ois.setObjectInputFilter(filter);// UNSAFE — unserialize() on attacker-controlled bytes can trigger __wakeup/__destruct gadgets $obj = unserialize($_COOKIE['session_data']); // SAFE — JSON has no constructor-invocation or magic-method hooks to abuse $obj = json_decode($_COOKIE['session_data'], true);
Making the safe pattern the only pattern the codebase can express
☺ Like you're 10: The best safety rule isn't the one everyone remembers to follow — it's the one where the unsafe version simply doesn't fit through the door in the first place.
Every pattern on this page sits at the same point in a defense-in-depth stack, and it's worth being explicit about the ordering, because each layer is only as necessary as the layer above it is incomplete. Type-system-level prevention is the strongest and cheapest layer once it's in place — Google's application-security team has written about wrapping raw strings in distinct types like SafeHtml, SafeUrl, and SafeSql (see Christoph Kern's writing on this, published as an ACM Queue piece — worth reading in full, and worth confirming the exact venue before you cite it, since it's been re-published in a few places) so that the compiler rejects an unsafe construction outright, with no runtime check and no scanner required at all. Beneath that sits API design — the theme of this entire page — parameterized query builders, argument-vector process calls, and auto-escaping templates that make the safe path the only path that's convenient to write. Beneath that, lint rules and custom static-analysis checks can block the deliberate escape hatches at review time — a rule that fails a build the moment someone writes cursor.execute(f"..."), or child_process.exec(, or dangerouslySetInnerHTML without a sanitizer call wrapped around it. And only at the bottom, as the backstop for whatever slipped through the first three layers, does general-purpose SAST earn its place — not as the primary control this page argued against treating it as, but as exactly what a backstop should be: probabilistic, broad, and there to catch what the API design and the lint rule didn't.
# A Semgrep rule enforcing this page's first pattern at review time — failing the
# build on sight of concatenation into a query call, rather than hoping someone
# remembers the rule on their own.
rules:
- id: no-string-concat-sql
languages: [python]
severity: ERROR
message: >
Build queries with parameter placeholders, not string concatenation
or f-strings — see Secure Coding Patterns.
patterns:
- pattern-either:
- pattern: $CURSOR.execute($A + $B, ...)
- pattern: $CURSOR.execute(f"...{$X}...", ...)This is also where the earlier point about scanner blind spots comes back around usefully rather than as a complaint: a custom rule like the one above, written for your own codebase's own wrapper functions, closes exactly the gap a generic off-the-shelf ruleset can't — because you're the one who knows which function in your code is the real sink. See static analysis & secrets detection for how Timmy actually wires a rule like this into a merge-blocking gate, secure SDLC gates & the DevSecOps maturity model for where in the pipeline that gate belongs, and API security in depth for the authorization-shaped bugs that sit at the opposite end of the spectrum from everything on this page — syntactically perfect requests that a scanner built for injection can't recognize as wrong at all.
Benny the Beaver: Search endpoint's done. I built the query with a template string, it's fine, Timmy's scan runs on every PR anyway.
Timmy the Turtle: My scan runs, sure. But "runs" and "definitely catches this exact shape" aren't the same promise. What does the query actually look like?
Benny the Beaver: f"SELECT * FROM listings WHERE city = '{city}'" — through our own db.run() helper, not the raw driver.
Timmy the Turtle: There it is. My ruleset knows the driver's own execute call as a sink. It's never been taught that db.run() is the same thing three layers down. That's not a bug in my scan — it's the exact shape of thing a scan can miss.
Rocky the Raccoon: Which is why I don't bother trying to sneak past the scanner at all. I go looking for exactly the wrapper it wasn't taught about. Found it in about four minutes.
Benny the Beaver: ...Fair. Rewriting it as a bind parameter now. That closes it whether or not anyone ever taught Timmy about db.run().
Professor Owl: That's the whole lesson in one exchange. Timmy's gate is real and it's staying. But the fix that actually holds is the one that makes the question "did the scanner catch it" stop mattering in the first place.
1. Give two concrete reasons a SAST tool can miss an injection flaw that's genuinely present in the code, and explain why "zero findings" doesn't mean "zero vulnerabilities." 2. What does a prepared statement actually change at the protocol level that makes it more than "careful escaping"? 3. Why is subprocess.run(cmd, shell=True) with a concatenated string dangerous in a way that subprocess.run([cmd, arg1, arg2]) structurally isn't? 4. A developer HTML-encodes a value before writing it into a <script> block. Does that stop XSS at that sink? Why or why not? 5. Explain, in the deserialization sense, what a "gadget chain" is — and why the attacker doesn't need to inject any new code to build one.
Check your answers
- Any two of: taint tracking losing precision across reflection or dynamic dispatch; a sink hidden behind a custom wrapper function the tool was never taught to recognize; a flow spanning too many function calls or files for the tool's configured analysis depth; second-order injection, where the "input" at the vulnerable sink is data read back from your own database rather than the original untrusted request. "Zero findings" only means the ruleset found nothing it was built to look for — it says nothing about flaws outside that ruleset's coverage.
- It sends the query template to the database and has it compiled into an execution plan before any bind values are transmitted, over a channel the database already treats as data rather than SQL text — so there's no parsing step during which a bind value's contents could be reinterpreted as query syntax. That's a structural guarantee, not a "we tried hard to escape correctly" guarantee.
shell=Truehands the entire string to/bin/shfor interpretation, so shell metacharacters (;,|, backticks,$()) inside a concatenated argument are live syntax the shell will act on. An argument-vector call never invokes a shell at all — each list element arrives as one inert argument, with no interpreter positioned to reinterpret its contents as a second command.- No. A browser's JavaScript parser never decodes HTML entities before executing a
<script>block's contents — so an HTML-encoded quote stays as the literal characters'instead of closing the string, and depending on the payload the injection can still fire. That sink needs JavaScript string escaping, not HTML entity encoding — the encoding has to match the sink, not the source. - A gadget chain is a sequence of classes already present on the classpath (or already loaded in the runtime) whose ordinary methods — constructors,
readObject,__wakeup, finalizers — happen to feed into one another when reconstructed in a particular order, ending in arbitrary code execution. The attacker supplies only the byte stream describing which objects to build and in what shape; every piece of actual code that runs was already trusted and shipped as part of the application or its dependencies.
You now have the pattern that underlies every injection-class vulnerability, and the API-level fix for each of its major shapes: bind parameters for structured query languages, argument vectors for shell execution, contextual auto-escaping for output, and schema-validated data formats in place of code-carrying deserialization. From here, software composition analysis in depth covers the dependency side of the same trust boundary — the code you didn't write yourself — and offensive security for DevSecOps is Rocky's full playbook for finding exactly the gaps a ruleset was never taught about.