scout-sf
v1.0.0
Published
TypeScript security framework for Node/Express APIs: input sanitization & validation, JWT auth, multi-tenant isolation, rate limiting, and Guardian threat detection. Alpha — feedback welcome.
Readme
scout-sf
Built in. Not bolted on.
scout-sf is an application-layer security framework for Node / TypeScript APIs. It wraps your functions with input sanitization, runtime validation, a call-integrity check, and an automatic audit trail (the DataLog) — and ships Express middleware for identity context, multi-tenant isolation, role-based access control, rate limiting, and Guardian behavioral monitoring.
Security posture (pre-release ·
v0.2.x). Read this before trusting the green numbers. Scout is defense-in-depth, not a replacement for TLS, parameterized queries / an ORM, JWT signature verification, Postgres RLS, or a WAF. The function-level pipeline (sanitize → validate → integrity → encode → DataLog) is strong and heavily tested. The risk is concentrated in the layer tested least — HTTP / auth / multi-tenant / deploy. All assessment to date is author self-test, not an independent third-party review. Test counts describe the strong layer; they are not a "0 critical" claim. Seepentest/for the honest status.
0.2.0 — cleaned up. This release removed accreted and domain-specific code to get back to a focused core: the compliance-regime modules, the NEC calculation engine, the message-bus specializations (SecureBus / DatabaseBus / BusFactory), and the runtime error-code registry were all removed. What remains is the pipeline plus the capability modules below.
Install
npm install scout-sfRequirements: Node.js 18+, TypeScript 5.0+.
Quick start
import S from 'scout-sf'
// S.S — the audited path: name-first, rules required, full DataLog.
const createUser = S.S('createUser',
function createUser(P: { name: string; age: number }): string {
return `${P.name} (${P.age})`
},
{
name: { type: 'string', minLength: 1, maxLength: 100 },
age: { type: 'number', min: 0, max: 150 },
}
)
// Synchronous by default — no await needed.
createUser({ name: 'Ada', age: 36 }) // → "Ada (36)"
createUser({ name: 'Ada', age: 999 }) // → throws: value 999 exceeds max 150Scout is synchronous by default and needs no setup to start wrapping functions.
The only feature that needs an adapter is rate limiting (see below), and in
development (NODE_ENV !== 'production') Scout auto-installs an in-memory one.
What Scout does on every call
0. Guardian check — behavioral anomaly score checked; blocked users rejected (opt-in)
1. Category check — business params cannot contain identity or security fields
2. Sanitize — HTML encoding, SQL-injection rejection, unicode normalization
3. Validate — runtime type, range, enum, and pattern checking
4. Rate limit — per-user or per-function (opt-in)
5. Execute — your function runs with sanitized, validated params
6. Integrity check — SHA-256 entry/exit call key (input-mutation guard; see note)
7. Output validate — return type verified when declared
8. Encode — result encoded before returning
9. DataLog — every step recorded with WHO called WHAT and WHENAbout step 6. Scout hashes the sanitized params on entry (from a deep clone) and again on exit. A mismatch means the wrapped function mutated its own input — a real correctness bug caught at runtime. It is an input-mutation guard, not tamper detection: any in-process code can recompute a matching key, so it does not defend against an in-process attacker. Treat it as defense-in-depth on your own code.
S.S and S.L — capability model
Scout's modes are named by capability, not by compliance regime.
S.Sis the audited path. Use it for any operation that needs an audit trail and full attribution. Rules are required, the function must be named (first argument), and every call is fully logged. This produces the evidence trail a SOC 2 assessment draws from when the DataLog is persisted — it does not, by itself, make you compliant.S.Lis the light path. Internal tooling, analytics, non-sensitive logic. Rules optional, types-only DataLog.
const a = S.S('myFn', (P: { x: number }): number => P.x, { x: { type: 'number' } }) // name-first
const b = S.L('myFn', (P: { x: number }): number => P.x) // name-first
const c = S.L(function myFn(P: { x: number }): number { return P.x }) // function-first| Feature | S.S (audited) | S.L (light) | |-------------------|--------------------|------------------------| | Rules | Required | Optional | | Name | Required (1st arg) | Optional | | DataLog detail | Full values | Types only | | Rate limiting | Opt-in | Opt-in | | Output validation | ✓ | ✓ | | Call integrity | ✓ | ✓ |
Both are synchronous by default; both return Promise<R> (and must be awaited)
only when you pass { rateLimit } or { guardian: true }.
Three param categories
Developers only ever write business params. Identity and security are assembled automatically and are structurally separate — they cannot be spoofed or forgotten.
// BUSINESS — you write these
// IDENTITY — from the auth session: userId, companyId, deviceId (never written by you)
// SECURITY — assembled by Scout: jwtHash, timestamp, callKey (never written by you)Passing an identity field (userId, companyId) as a business param throws
immediately — no silent identity override is possible.
Identity — WHO context
Scout reads identity from the session automatically; you never pass userId/companyId.
import S, { setSession, clearSession } from 'scout-sf'
// Backend: set in middleware after JWT verification. Pass `next` so identity is bound
// to THIS request via AsyncLocalStorage (never a shared global).
setSession({ identity: { userId: 'u_123', companyId: 'c_456' }, jwt: token }, next)
// Frontend (single session per process): set after login, clear on logout.
setSession({ identity: { userId: 'u_123', companyId: 'c_456' }, jwt: token })
clearSession()Rules
S.S('fn', fn, {
name: { type: 'string', minLength: 1, maxLength: 100 },
age: { type: 'number', min: 0, max: 150 },
status: { type: 'string', values: ['active', 'inactive'] }, // enum
email: { type: 'string', pattern: /^[^@]+@[^@]+$/, patternError: 'invalid email' },
})Shorthand strings cover the common cases: 'string', 'string:1-50', 'number',
'number:0-1000', 'boolean', 'enum:a,b,c'. Built-in PATTERNS (lettersOnly,
slug, phone, zipCode, …) are available for pattern.
Less verbose: array & composite shorthand
When every param maps to a built-in rule by the same name, pass an array — Scout expands it and
infers P from the rule names, so the (P: {...}) annotation is no longer needed:
const VOLTDROP = S.S('voltdrop',
(P) => (P.phase === 3 ? 1.732 : 2) * P.length * P.amps / (P.awg || 1),
['amps', 'awg', 'length', 'phase'], // P inferred: { amps, awg, length, phase: number }
{ output: 'number', rateLimit: '1000/min' }
)Combine a reusable rule set with per-function extras using the composite array-of-arrays form (duplicate fields throw at definition time):
const ELECTRICAL = ['amps', 'awg', 'length'] as const
S.S('createEstimate', fn, [ELECTRICAL, { jobName: 'string:1-200' }], { output: 'number' })Full reference: Shorthand API.
Custom rules
import { defineRules, addRules } from 'scout-sf'
const myRules = defineRules({
jobName: { type: 'string', minLength: 1, maxLength: 200 },
tradeType: { type: 'string', values: ['electrical', 'plumbing', 'hvac'] },
})
addRules(myRules) // register once at startup
S.S('fn', fn, { jobName: 'jobName' }) // then reference by nametableToRule(record) turns a lookup table's keys into an enum rule.
ScoutMiddleware — Express on-ramp
import express from 'express'
import { scoutStack, scoutErrorHandler } from 'scout-sf'
const app = express()
app.use(express.json())
app.use(scoutStack({ jwtSecret: process.env.SCOUT_JWT_SECRET, sessionRequired: true }))
// ... your routes — req.body is sanitized, session is set, res.json is encoded ...
app.use(scoutErrorHandler()) // mount lastIndividual middleware (scoutLogger, scoutBodySanitize, scoutSession,
scoutResponseEncode, scoutErrorHandler) can be mounted separately.
Application-layer access control
These are the app-layer complement to network/DB controls — defense-in-depth, not a replacement for Postgres RLS.
import { requireTenant, assertTenantOwnership } from 'scout-sf' // multi-tenant isolation
import { requireRole, requirePermission } from 'scout-sf' // RBAC: owner > admin > member > viewerScoutGuardian — behavioral monitoring (opt-in)
Guardian accumulates a per-user anomaly score from repeated validation failures, injection attempts, and integrity violations; past a threshold the user is warned or blocked. It is opt-in (a no-op until an adapter is set) and fail-open (adapter errors never block a call).
import { setGuardianAdapter, InMemoryGuardianAdapter, RedisGuardianAdapter } from 'scout-sf'
setGuardianAdapter(new InMemoryGuardianAdapter()) // dev / tests
setGuardianAdapter(new RedisGuardianAdapter(redis), { blockThreshold: 200 }) // prodDataLog & persistence
import { getLogs, clearLogs } from 'scout-sf'
getLogs() // DataLog[] — every wrapped call this session (WHO/WHAT/WHEN + every step)For a durable audit trail, flush the DataLog to Postgres with DrizzleDataLogStore +
startAuditFlusher (in-process logs are not an audit log).
Rate limiting
import { setRateLimitAdapter, RedisRateLimitAdapter } from 'scout-sf'
setRateLimitAdapter(new RedisRateLimitAdapter(redis)) // prod, set once at startup
const fn = S.S('fn', impl, rules, { rateLimit: '100/min' }) // opt-in; fn becomes asyncError diagnostics
import { ScoutError, SCOUT_ERROR_CODES } from 'scout-sf'
try { fn(params) } catch (err) {
if (ScoutError.isScoutError(err)) {
err.code // 'SCOUT_SQL_INJECTION'
err.title; err.reason; err.fix; err.docsLink
}
}SQL sanitization is a blocklist, not a defense
Scout rejects common SQL-injection patterns at input, but this is defense-in-depth, not a substitute for parameterized queries or an ORM. Always use prepared statements.
What Scout is not
Scout supplements, it does not replace:
- TLS / HTTPS — encrypt data in transit at the network layer
- Parameterized queries / ORM — the primary SQL-injection defense
- JWT signature verification — Scout reads session format, not crypto signatures
- Row-level security — use Postgres RLS for multi-tenant data isolation
- Network firewalls / WAFs — Scout operates at the function-call level
Package surface
scout-sf ships a single entry point — everything is a named import from 'scout-sf'.
| Symbol | Contents |
|--------|----------|
| S (default), S.S / S.L | Core pipeline — sanitize, validate, integrity, encode, DataLog |
| setSession, rules, defineRules, ScoutError | Session/identity, rule helpers, error diagnostics |
| scoutStack, scoutErrorHandler, … | Express middleware on-ramp |
| requireTenant, assertTenantOwnership | Multi-tenant isolation |
| requireRole, requirePermission | Role-based access control |
| ScoutGuardian exports | Behavioral monitoring, anomaly scoring |
| DrizzleDataLogStore, startAuditFlusher | Drizzle-backed audit-log persistence |
| scoutBus, connectScoutToBus | In-process module event bus |
Not exported (roadmap):
ScoutAuth(email + OAuth login) exists in the repo but is not part of the published barrel; feed your own verified identity tosetSession().
Shorthand API · Custom Rules Guide · Error Reference · Integration
