Secure SDLC: threat review at design, hatch grep in CI

A teal blueprint with one hatch circled in coral, house style.

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)
The note names the helper. The grep watches the string hatches. The sink stays bound.
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
PreparePO.1 requirements, PO.3 rolesthreat-review.yml, ownerId
ProtectPS.1 code accessruleset on main, no org-admin PAT
ProducePW.1 design, PW.7 review, PW.8 testthe note, hatch-grep, retestId
RespondRV.1 find, then fixticket 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.

  1. Commit threat-review.yml, hatches.json, hatch_grep.sh, and check_hatches.py.
  2. Open a PR that adds Sequelize.literal(req.query.sort) in exportInvoice and does not add an allow line. Expect exit 1.
  3. Remove the literal. Add exportInvoice without a hatches.json row. Expect check_hatches.py to fail if you also list the symbol from list_hatches.sh, or add the row and omit control. Expect the review-file step to fail.
  4. Restore the honest row. Run asBob against Alice’s export. Expect 404.
  5. Grep the tree for threat-review.yml and hatch-grep so 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.