
NIST SP 800-218, SSDF Version 1.1, published in February 2022. I opened the CSRC final page. The 17 December 2025 news note is the initial public draft of 1.2. Comments closed 30 January 2026. I did not find a final 1.2 on that CSRC page on 22 August 2026. A printed practice id is not a review. A new export that skipped canInvoice is.
That is a map. This page is the two files invoice-app actually commits: threat-review.yml and the grep job. Pair it with the injection guide for every interpreter the grep hits, the IDOR guide for the object check the grep will miss, and the secure coding checklist for the control name you write on the note.
SSDF 1.1 is a map, not the review
SSDF groups practices into Prepare, Protect, Produce, and Respond. PW.1 is design software to meet security requirements and mitigate risks. The 1.1 text names threat modeling and attack-surface work as the example. That is the right moment. It is not a 40-page model you run once at kickoff and file next to the architecture slide.
jameshart’s next sentences on that thread are the reason this page exists: an iterative cycle is overlapping design and build. The iteration that broke last quarter’s assumption does not announce itself. A living note on the hatch you are about to add, plus a job that still greps the dangerous methods, is the verify step the literature often skips.
I am citing 1.1 as the published final. The 1.2 draft adds PO.6, a continuous improvement plan, and PS.4, robust updates. I opened NIST’s mid-December draft note for those two names. Until CSRC marks 1.2 final, do not invent a compliance badge that requires them. Do the 1.1 work: write the requirement, protect the tree, produce the hatch with a review, respond with a ticket that names a retest.
Write the design note before the hatch ships
A hatch is an entry. HTTP is one. A webhook is one. A queue consumer is one. A cron that reads a bucket is one. Before the PR that adds the entry, write threat-review.yml for that id. Four fields. If a field is empty, the hatch does not ship.
# threat-review.yml one document, many rows
app: invoice-app
asvs: v5.0.0
ssdf: "SP 800-218 1.1"
reviews:
- hatchId: http-invoice-export
change: "GET /invoices/:id/export.csv"
file: src/routes/invoice.js
symbol: exportInvoice
trusts:
- session cookie
- invoice.ownerId
breaks_if:
- export omits canInvoice
- filename comes from the client
control: canInvoice
ci_grep:
- Sequelize.literal
- whereRaw
- $queryRawUnsafe
ownerId: invoice-oncall
retestId: test/export-invoice-idor.test.js
That row is PW.1 and PW.2 for this shop. You listed what you trust, what breaks the trust, the helper that must exist, and the test that will prove it. You did not draw every STRIDE box. You did not schedule a workshop. If the change is a new interpreter, add the hatch name to the grep list in the same commit.
OWASP ASVS 5.0.0 shipped on 30 May 2025. I opened the GitHub release. You do not walk 17 chapters on a CSV export. You walk V8 on the object and V1 if the export builds SQL. Write the requirement id you actually checked, in the v5.0.0-8.2.2 form the standard itself recommends, on the ticket that closes the note.
Fill the export row in twenty minutes, not a workshop. Read the handler sketch. Ask who can call it. Ask which column the CSV may include. Ask whether the filename is minted. Write those answers into trusts and breaks_if. If you cannot name the helper, you do not have a control yet. Stop the PR. Do not ship a route whose only lock is "must be logged in." A session is authentication. canInvoice is the object rule.
When the review finds a miss, open one ticket that can close. Copy the hatchId, the file, the missing helper, and the test path. That is RV.1 for this shop. A slide titled "improve the life cycle" will not close. The same retestId that was empty on the note becomes the CI case after the patch.
{
"ticketId": "VM-2026-0318",
"hatchId": "http-invoice-export",
"file": "src/routes/invoice.js",
"asvs": "v5.0.0-8.2.2",
"ssdf": "PW.1",
"ownerId": "invoice-oncall",
"evidence": "exportInvoice loaded by invoiceId with no canInvoice",
"retestId": "test/export-invoice-idor.test.js",
"retestResult": "fail"
}
Keep every entry in hatches.json
The design note names one change. The inventory names every entry the app already has. If a worker is not in the file, nobody will write a note when it grows a new field. Build the list from grep, not from memory.
{
"app": "invoice-app",
"asvs": "v5.0.0",
"hatches": [
{
"id": "http-invoice-get",
"file": "src/routes/invoice.js",
"symbol": "getInvoice",
"control": "canInvoice",
"ownerId": "invoice-oncall"
},
{
"id": "http-invoice-export",
"file": "src/routes/invoice.js",
"symbol": "exportInvoice",
"control": "canInvoice",
"ownerId": "invoice-oncall"
},
{
"id": "webhook-billing",
"file": "src/webhooks/billing.js",
"symbol": "billingHook",
"control": "verifyBillingHmac",
"ownerId": "billing-oncall"
},
{
"id": "sqs-render",
"file": "src/workers/render.js",
"symbol": "renderWorker",
"control": "canInvoice",
"ownerId": "invoice-oncall"
}
]
}
# tools/list_hatches.sh
rg -n "app\\.(get|post|put|patch|delete)\\(|router\\.(get|post|put|patch|delete)\\(" src
rg -n "createEventSourceMapping|node-cron|schedule\\(|webhooks/" src infra
rg -n "multer|upload\\.|createWriteStream|/admin" src
check_hatches.py exits 1 if a row’s symbol is missing from file, or when ownerId is empty. That script is the named fallback. A red job, not a skip. The export row above must exist before exportInvoice merges.
# tools/check_hatches.py
import json, pathlib, sys
hatches = json.loads(pathlib.Path("hatches.json").read_text())["hatches"]
def has_symbol(hatch):
text = pathlib.Path(hatch["file"]).read_text()
return hatch["symbol"] in text
missing = [h["id"] for h in hatches if not has_symbol(h)]
unowned = [h["id"] for h in hatches if not h.get("ownerId")]
if missing or unowned:
print("missing", missing, "unowned", unowned)
sys.exit(1)
NOTE threat-review.yml
hatchId http-invoice-export
control canInvoice
|
v
HATCH exportInvoice in invoice.js
row added to hatches.json
|
v
GREP Sequelize.literal whereRaw
$queryRawUnsafe .query(
hit -> PR red
|
v
SINK SELECT with $1
canInvoice(actor, row)
miss -> 404
Grep the ORM hatches on every pull request
Prisma Client, Sequelize, Knex, and TypeORM parameterize the builder path. The failure is the method you reach for when the builder cannot express the query. Those methods have names. They grep. The injection page is the longer form. This page only blocks merge if a new hatch string appears without an allowline.
# tools/hatch_grep.sh
# fail if a hatch method is added outside allow-hatches.txt
set -euo pipefail
PAT='Sequelize\.literal|whereRaw|\$queryRawUnsafe|\.query\(|knex\.raw\('
rg -n -e "$PAT" --glob '!node_modules' --glob '!allow-hatches.txt' src \
| sort > /tmp/hatch-hits.txt
touch allow-hatches.txt
sort -u allow-hatches.txt > /tmp/hatch-allow.txt
if ! comm -13 /tmp/hatch-allow.txt /tmp/hatch-hits.txt | grep -q .; then
exit 0
fi
echo "new hatch string. add a threat-review row or remove the call"
comm -13 /tmp/hatch-allow.txt /tmp/hatch-hits.txt
exit 1
# allow-hatches.txt one line per already-reviewed call
src/jobs/migrate.js:14:knex.raw('SELECT 1')
# .github/workflows/hatch-grep.yml
name: hatch-grep
on:
pull_request:
branches: [main]
jobs:
hatch-grep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: inventory
run: python3 tools/check_hatches.py
- name: hatch-grep
run: bash tools/hatch_grep.sh
- name: review-file
run: |
python3 - <<'PY'
import pathlib, sys, yaml
p = pathlib.Path("threat-review.yml")
if not p.exists():
sys.exit("missing threat-review.yml")
data = yaml.safe_load(p.read_text())
for row in data["reviews"]:
if not row.get("control") or not row.get("retestId"):
sys.exit(f"incomplete {row.get('hatchId')}")
PY
Identifiers stay hatch_grep.sh, allow-hatches.txt, threat-review.yml, and canInvoice. A hit that is already on the allow list is a reviewed exception, not a silent skip. Adding a line to the allow list without a new row in the design file is the cheat. The review-file step is how that cheat stays red. If your runner lacks PyYAML, parse with a ten-line checker that only looks for control: and retestId: under each hatchId.
OpenEMR’s CVE-2026-24908, published in the AISLE set on 28 April 2026, was a _sort query glued into ORDER BY. That is an identifier, not a bind. The grep will not see a concatenated column name unless you also search for ORDER BY next to a request field. Add that pattern when your app exposes sort tokens. Map the token to a column you wrote. Never paste the token into SQL.
Map four SSDF groups to those files
An auditor who wants SSDF wants evidence of the practice, not a list of other frameworks. This table is the evidence map for invoice-app. I am using the 1.1 group names from the CSRC page I opened.
| 1.1 group | Practice you keep | File on invoice-app |
|---|---|---|
| Prepare | PO.1 requirements, PO.3 roles | threat-review.yml, ownerId |
| Protect | PS.1 code access | ruleset on main, no org-admin PAT |
| Produce | PW.1 design, PW.7 review, PW.8 test | the note, hatch-grep, retestId |
| Respond | RV.1 find, then fix | ticket with the same hatchId |
Protect the tree the way the sibling merge-gate page does: a pull request, a code-owner approve, a named job. This page does not repeat that UI. It only insists the named job includes hatch-grep. Produce means the note and the grep landed before the handler. Respond means a miss becomes a ticket that points at retestId, not a slide that says "improve SDLC."
What the grep will never see
Object authorization is a helper, not a method name. getInvoice can load by id and return 200 for Bob. No Sequelize.literal appears. The design note’s control: canInvoice is the prompt for the human. The test file named in retestId is the machine half of that prompt.
// src/routes/invoice.js
async function exportInvoice(req, res) {
const actor = requireUser(req);
if (!actor) return res.status(401).end();
const invoice = await findInvoice(req.params.invoiceId);
if (!invoice || !canInvoice(actor, invoice)) {
return res.status(404).end();
}
const body = await renderCsv(invoice.id);
res.set("Content-Type", "text/csv");
return res.send(body);
}
function canInvoice(actor, invoice) {
if (actor.role === "admin") return true;
return invoice.ownerId === actor.userId;
}
// test/export-invoice-idor.test.js
test("bob cannot export alice invoice", async () => {
const res = await asBob.get("/invoices/" + aliceInvoiceId + "/export.csv");
expect(res.status).toBe(404);
});
If exportInvoice exists and that test is missing, the design file lied. Fail the job. A scanner that never swapped aliceSid for bobSid will score the route green. That gap is why the note lists breaks_if in plain language, not as a CWE number.
Mass assignment is a PATCH that accepts ownerId. Command injection is exec on an upload name. Those have greppable names too: exec(, innerHTML, dangerouslySetInnerHTML. Add them to hatch_grep.sh when that interpreter is in the tree. Do not grow the pattern list until the first four ORM hatches are actually failing the build.
Prove invoice-app yourself
You are proving the job went red when a hatch string appeared, and that a new route without a note cannot merge. Use a repo you own.
- Commit
threat-review.yml,hatches.json,hatch_grep.sh, andcheck_hatches.py. - Open a PR that adds
Sequelize.literal(req.query.sort)inexportInvoiceand does not add an allow line. Expect exit 1. - Remove the literal. Add
exportInvoicewithout ahatches.jsonrow. Expectcheck_hatches.pyto fail if you also list the symbol fromlist_hatches.sh, or add the row and omitcontrol. Expect the review-file step to fail. - Restore the honest row. Run
asBobagainst Alice’s export. Expect 404. - Grep the tree for
threat-review.ymlandhatch-grepso the next hire can find the loop.
rg -n "hatch_grep|threat-review.yml|Sequelize\\.literal|canInvoice" \
--glob '!node_modules'
If allow-hatches.txt is a junk drawer, the grep is theater. If the design file lists a retestId that does not exist, the note is a poster. Identifiers stay http-invoice-export, exportInvoice, canInvoice, and hatch_grep.sh.
Questions we keep getting
Do we need a full STRIDE workshop for every feature?
No. You need the four fields on the hatch you are adding. A workshop is for a new tenant model or a new money flow. Daily work is the note plus the grep.
Is SSDF 1.2 required in 2026?
I opened the CSRC final page on 22 August 2026 and still saw 1.1, dated February 2022. The 1.2 text I can cite is the December draft already named in the opener. Map your evidence to 1.1 ids until the final lands.
Can Semgrep replace hatch_grep.sh?
Use both. Semgrep 1.174.0, published 20 August 2026, is the floor for the Top 10 pack. The tiny allow-list script is how a one-off literal still has a human name next to it. A pack will not read your threat-review.yml.



