Four years into a career focused on APIs and developer tooling, I kept running into the same gap: security scanning options were either too heavy (stand up a platform, configure a workspace, wait for results) or too risky to run in CI without careful guardrails. I wanted a lightweight, CLI-first tool that gave a clear first pass without the danger of destructive side effects.
That's Sentinel: a spec-aware API security scanner built for developers and CI pipelines. Out of the box it sends only GETs, keeps burst probes conservative, and requires an explicit opt-in, plus an OpenAPI spec, before it touches anything injection-related. Safety isn't a feature, it's the default.
Here's how that show's up in the implementation:
Architecture
Spec first endpoint selection
When an OpenAPI spec is provided, Sentinel resolves the full set of endpoints to test once, before any suite runs, and passes them down via ctx.selectedEndpoints. Suites call resolveEndpoints(ctx.selectedEndpoints) to get that list, it's never re-computed. The spec's servers[0].url base path is extracted and normalized into every endpoint path at selection time, so suites never have to think about it.
The fallback is deliberate too: no spec, no scope config, or filters that exclude everything all collapse to GET /. Suites will always get a valid list.
Finding metadata as a manifest
In the headers suite, finding definitions live in a static REQUIRED_HEADERS array: each entry carries the finding ID, title, severity, description, remediation, and OWASP reference. The detection logic is a separate loop that iterates that manifest and checks for each header. Adding a new required header check means adding one entry to the array; there's no detection logic to write.
This pattern keeps two concerns from drifting apart over time: what a finding is versus what triggers it.
Errors aren't findings
RunResult has three distinct fields: findings, suiteErrors, and reporterErrors. That separation is enforced at the type level: suiteErrors and reporterErrors are required fields, not optional. TypeScript won't let you construct a RunResult without accounting for both. The intent is that if a suite throws unexpectedly mid-scan, that failure surfaces in its own field with its own exit code (1) rather than being folded into security findings or silently dropped.
A finding has severity, remediation, affected endpoints, evidence. An operational error has none of that. Conflating them would make the output untrustworthy in exactly the situation where you need to trust it most: when something goes wrong.
Targeted Testing
During the process of building Sentinel I discovered I needed a way to guarantee findings. I built Anemone: a small, configurable, vulnerable API that became a permanent part of Sentinel's testing story. It hosts a series of endpoints designed to trigger at least one finding from every suite:
-
Headers: security headers absent by default; triggers
missing_hsts,missing_xcto,missing_referrer_policy - CORS: reflects arbitrary origins with credentials by default; a second mode tests wildcard + credentials
-
Auth: /api/v2/auth returns a JWT with
alg:none, a ~27h TTL, and no enforcement on protected routes -
Inventory:
/debug,/swagger,/openapi.json, and/graphqlall exposed;/api/v1/still responds alongside the declared v2 spec -
Injection:
/api/v2/searchreflects SQL errors on quote characters;/api/v2/greetevaluates{{expr}}in query params
Each misconfiguration is individually toggleable via env var, so you can fix one issue, re-run Sentinel, and verify that specific finding disappears. It's a useful sanity check when writing new suites.
Moving Anemone from a testing fixture to a publicly hosted API required some thought, especially around the injection suite. The {{7*7}} → 49 behavior that triggers Sentinel's template injection probe had to work without enabling real RCE. safeEval() handles this: a hand-rolled arithmetic evaluator that resolves simple expressions and returns a fake TemplateError for anything else. No eval(), no execution.
Running a demo
You can see Sentinel in action in under a minute from your command line:
npm install -g @uncommon-carp/sentinel
Then point it at Anemone:
sentinel scan -u https://target.barbel.dev
Sentinel outputs both a human readable Markdown doc and machine readable JSON. Here's a sample from hitting Anemone:
# Sentinel Report
- **Target:** `https://target.barbel.dev`
- **Scanned:** 2026-06-22T08:48:39.931Z
- **Finished:** 2026-06-22T08:48:44.344Z
- **Duration:** 4413ms
- **Version:** 0.3.1
## Summary
| Severity | Count |
| --- | --- |
| Critical | 2 |
| Medium | 10 |
| Low | 6 |
| **Total** | **18** |
## OWASP Coverage
| Category | Findings |
| --- | --- |
| API2: Broken Authentication | 5 |
| API4: Unrestricted Resource Consumption | 2 |
| API8: Security Misconfiguration | 8 |
| API9: Improper Inventory Management | 3 |
## Findings
---
### [Critical] JWT with alg:none detected in response
`auth.jwt_alg_none` | Suite: auth | API2: Broken Authentication
A JWT using the "none" algorithm was found in a response. Tokens with alg:none carry no cryptographic signature; servers that accept them can be trivially bypassed.
> **Why it matters:** An attacker can forge arbitrary JWT claims — including elevated roles — and gain unauthorized access to any endpoint that trusts the token, with no cryptographic barrier.
**Remediation:** Reject JWTs with alg:none server-side and enforce an explicit algorithm allowlist.
---
### [Medium] Missing Strict-Transport-Security (HSTS)
`headers.missing_hsts` | Suite: headers | API8: Security Misconfiguration
HSTS helps enforce HTTPS by telling browsers to only connect over TLS for a period of time.
> **Why it matters:** Without HSTS, browsers may connect over plain HTTP on subsequent visits, enabling downgrade attacks that allow credential and session token interception on the local network.
**Remediation:** Add a Strict-Transport-Security header on HTTPS responses (e.g. max-age=31536000; includeSubDomains).
{
"meta": {
"startedAt": "2026-06-22T08:45:30.542Z",
"targetBaseUrl": "https://target.barbel.dev",
"version": "0.3.1",
"finishedAt": "2026-06-22T08:45:35.978Z",
"durationMs": 5433
},
"config": {
"target": {
"baseUrl": "https://target.barbel.dev",
"openapi": "http://target.barbel.dev/openapi.json"
},
},
"findings": [
{
"id": "headers.missing_hsts",
"title": "Missing Strict-Transport-Security (HSTS)",
"severity": "medium",
"description": "HSTS helps enforce HTTPS by telling browsers to only connect over TLS for a period of time.",
"whyItMatters": "Without HSTS, browsers may connect over plain HTTP on subsequent visits, enabling downgrade attacks that allow credential and session token interception on the local network.",
"remediation": "Add a Strict-Transport-Security header on HTTPS responses (e.g. max-age=31536000; includeSubDomains).",
"owasp": "API8: Security Misconfiguration",
"suite": "headers",
"tags": [
"headers",
"http"
],
"evidence": {
"header": "strict-transport-security",
"count": 5,
"probed": 5,
"affected": [
{
"method": "get",
"path": "/api/v2/auth",
"url": "https://target.barbel.dev/api/v2/auth",
"status": 200
},
{
"method": "get",
"path": "/api/v2/greet",
"url": "https://target.barbel.dev/api/v2/greet",
"status": 200
},
{
"method": "get",
"path": "/api/v2/users",
"url": "https://target.barbel.dev/api/v2/users",
"status": 200
},
{
"method": "get",
"path": "/api/v2/health",
"url": "https://target.barbel.dev/api/v2/health",
"status": 200
},
{
"method": "get",
"path": "/api/v2/search",
"url": "https://target.barbel.dev/api/v2/search",
"status": 200
}
]
}
},
]
}
What's Next
Next up: Anemone will be expanded with richer auth and rate limiting scenarios, there's deduplication work to be done for findings, and the injection suite is ready for deeper SSRF/Interactsh exploration.
All it takes to get started is npx @uncommon-carp/sentinel scan --url <your-api>. Stars and issues are welcome on GitHub: bug reports, false positives, or suite ideas especially.
Top comments (0)