escany
v0.1.0
Published
Authorization visibility engine for JS/TS backends — builds an evidence-backed route x actor matrix from source, with honest uncertainty
Downloads
175
Maintainers
Readme
escany
Authorization visibility for JS/TS backends. It reads your source and answers who can call what — and says so plainly where the code makes no statement at all.
escany matrix gives you one row per endpoint. This is the real, unedited output of escany matrix -p tests/fixtures/nest-school, one of the fixtures shipped in this repo:
Legend ⇢ bypass applies here
World assumption: closed (/path/to/nest-school)
Resolution: 7/8 routes full, 0 partial, 1 none
┌────────┬────────────────────────┬─────────┬────────────────────────────────────┬────────────────────────────────┬───────────────────────┬──────┐
│ Method │ Endpoint │ Entity │ Actors / Roles │ Guards │ Access │ Conf │
├────────┼────────────────────────┼─────────┼────────────────────────────────────┼────────────────────────────────┼───────────────────────┼──────┤
│ GET │ /health │ - │ everyone incl. anon │ ⇢isPublic │ PUBLIC_DECLARED │ MED │
│ ALL │ /* │ - │ any authenticated │ JwtAuthGuard │ AUTHENTICATED_ANY │ HIGH │
│ GET │ /students │ Student │ needs student:read │ JwtAuthGuard, PermissionsGuard │ PERMISSION_RESTRICTED │ MED │
│ GET │ /students/:id │ Student │ needs student:read │ JwtAuthGuard, PermissionsGuard │ PERMISSION_RESTRICTED │ MED │
│ POST │ /students │ Student │ needs student:write │ JwtAuthGuard, PermissionsGuard │ PERMISSION_RESTRICTED │ MED │
│ DELETE │ /students/:id │ Student │ needs student:write+student:delete │ JwtAuthGuard, PermissionsGuard │ PERMISSION_RESTRICTED │ MED │
│ GET │ /teachers │ Teacher │ Admin, Teacher │ JwtAuthGuard, RolesGuard │ ROLE_RESTRICTED │ MED │
│ GET │ /teachers/:id/students │ Student │ Teacher │ JwtAuthGuard, RolesGuard │ ROLE_RESTRICTED │ MED │
└────────┴────────────────────────┴─────────┴────────────────────────────────────┴────────────────────────────────┴───────────────────────┴──────┘
needs … = required grant atoms that match no actor escany discovered
Conf = statusConfidence (how completely the chain resolved; capped at MED when the chain is empty). The gate reads a FINDING's confidence, listed per rule above.--view actors gives you the per-actor decision grid instead. This is real output too, captured by running escany against lujakob/nestjs-realworld-example-app — one of the reference apps in docs/dogfood:
Legend ✓ allow ⊘ conditional ✗ not granted ⛔ explicit deny ? undetermined ● ungoverned
World assumption: closed (/path/to/nestjs-realworld-example-app)
Resolution: 12/21 routes full, 0 partial, 9 none
┌────────┬──────────────────────────────────┬───────────────┬──────┬───────────────────┬──────┐
│ Method │ Endpoint │ authenticated │ anon │ Status │ Conf │
├────────┼──────────────────────────────────┼───────────────┼──────┼───────────────────┼──────┤
│ GET │ /api │ ● │ ● │ UNPROTECTED │ MED │
│ GET │ /api/articles │ ● │ ● │ UNPROTECTED │ MED │
│ GET │ /api/articles/feed │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ GET │ /api/articles/:slug │ ● │ ● │ UNPROTECTED │ MED │
│ GET │ /api/articles/:slug/comments │ ● │ ● │ UNPROTECTED │ MED │
│ POST │ /api/articles │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ PUT │ /api/articles/:slug │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ DELETE │ /api/articles/:slug │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ POST │ /api/articles/:slug/comments │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ DELETE │ /api/articles/:slug/comments/:id │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ POST │ /api/articles/:slug/favorite │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ DELETE │ /api/articles/:slug/favorite │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ GET │ /api/profiles/:username │ ● │ ● │ UNPROTECTED │ MED │
│ POST │ /api/profiles/:username/follow │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ DELETE │ /api/profiles/:username/follow │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ GET │ /api/tags │ ● │ ● │ UNPROTECTED │ MED │
│ GET │ /api/user │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ PUT │ /api/user │ ✓ │ ✗ │ AUTHENTICATED_ANY │ MED │
│ POST │ /api/users │ ● │ ● │ UNPROTECTED │ MED │
│ DELETE │ /api/users/:slug │ ● │ ● │ UNPROTECTED │ MED │
│ POST │ /api/users/login │ ● │ ● │ UNPROTECTED │ MED │
└────────┴──────────────────────────────────┴───────────────┴──────┴───────────────────┴──────┘
Findings on the rows above (rule [finding confidence])
⚠ GET /api AZ001 [HIGH]
⚠ GET /api/articles AZ001 [HIGH], AZ017 [HIGH]
⚠ GET /api/articles/:slug AZ001 [HIGH], AZ017 [HIGH]
⚠ GET /api/articles/:slug/comments AZ001 [HIGH], AZ017 [HIGH]
⚠ GET /api/profiles/:username AZ001 [HIGH], AZ017 [HIGH]
⚠ GET /api/tags AZ001 [HIGH], AZ017 [HIGH]
⚠ POST /api/users AZ001 [HIGH], AZ017 [HIGH]
⚠ DELETE /api/users/:slug AZ001 [HIGH], AZ017 [HIGH]
⚠ POST /api/users/login AZ001 [HIGH], AZ017 [HIGH]The DELETE /api/users/:slug row is a real finding: the controller carries a class-level @ApiBearerAuth() (Swagger documentation, not enforcement), and the module's AuthMiddleware is wired to user GET/PUT only — so this destructive route has no enforcement at all. escany fires both AZ001 (no enforcement) and AZ017 (documented as secured but not enforced) on it. Full writeup: docs/dogfood/01-nestjs-realworld.md.
Three things in those tables are deliberate rather than cosmetic:
●is not✓. "Open to everyone because nothing governs it" and "granted to every role" are different facts, and only the second is a decision somebody made.needs student:write+student:deleteis not an actor list. The+says both atoms are required; a comma would read as either.?is never✗. "escany could not resolve this" and "no grant covers this actor" are different answers, and the tool will not print the second when it means the first.
Table of contents
- Overview
- What it is not
- Install
- Quick start
- Commands
- Supported frameworks
- The two design decisions that matter
- Rules
- Configuration
- CI
- Entities
- What it cannot tell you
- Architecture
- Project structure
- Development
- Testing
- Roadmap
- Contributing
- Security
- License
- Author
Overview
The problem. On any backend past a certain size, nobody can answer "who can call DELETE /users/:id?" without reading code. Authorization is scattered across global guards, controller decorators, handler decorators, middleware wired in a module, and opt-out decorators that quietly undo all of it. The answer exists, but it is spread over five files and three layers of composition.
The usual responses each fail in their own way. A hand-maintained permissions spreadsheet drifts from the code within a sprint. A generic SAST tool reports a wall of maybes. And a script that greps for @Roles will confidently tell you admin is allowed on a route whose guard never reads that decorator's key.
What escany does. It reconstructs the enforcement chain per route from the source, resolves it per actor where the code is literal enough to permit that, and marks the rest as unresolved with a file:line and a reason. The output is a route × actor matrix you can read, diff, and gate a build on.
The part that makes it usable. A cell is not a boolean. Every answer carries a confidence, and the tool distinguishes "no grant covers this actor" (✗) from "a decision node exists here that I could not evaluate" (?). Every report prints its own completeness before it prints any conclusion. A tool that hides its blind spots is worse than no tool, because a clean report becomes evidence of safety.
Who it is for
- Backend and platform teams who need an accurate, current answer to "who can call what" for a NestJS, Express, or Fastify service.
- Reviewers and auditors who want route-level evidence with source references rather than a claim.
- CI owners who want a gate that fires on a new authorization gap without having to fix every legacy one first (see
--baseline).
What it is not
It does not decide whether an authorization rule is correct — that is a business question. It reconstructs the enforcement your code declares, resolves it per actor where the code is literal enough to permit that, and refuses to guess where it is not.
The word "vulnerability" appears nowhere in its output. Findings are findings.
Install
Requires Node.js >= 20.
Run it without installing:
npx escany scanOr add it to a project:
npm install --save-dev escanyNo configuration required. Configuration exists only to correct the engine or declare intent, never to make it work.
From source
git clone https://github.com/Rocks-D-xebec-0/Escany.git
cd Escany
npm install
npm run build
node dist/cli/index.js matrix -p /path/to/your/serviceQuick start
# The endpoint table for the current directory
npx escany matrix
# The per-actor decision grid
npx escany matrix --view actors
# Why does this one route resolve the way it does?
npx escany explain DELETE /students/:id
# Everything escany found, with remediation
npx escany findings
# Gate a build: exit 1 on breach, 2 on tool error
npx escany checkEvery command accepts -p, --project <path> to point at a repository other than the working directory.
Commands
| Command | Purpose |
|---|---|
| escany scan | analyse and write escany.json |
| escany matrix | the endpoint table: method, path, entity, actors, guards, access |
| escany matrix --view actors | the route × actor decision grid (--collapse for access-pattern classes) |
| escany matrix --json | a stable JSON projection of either view, for CI, an editor or MCP |
| escany explain <METHOD> <path> | the full decision chain for one endpoint, with file:line evidence |
| escany routes | route inventory (--unprotected to filter) |
| escany actors | the discovered actor universe |
| escany findings | findings with remediation |
| escany check | CI gate — exit 1 on breach, 2 on tool error |
| escany report -f markdown\|sarif\|json | reports, to stdout or to a file with -o |
| escany init | write a starter escany.config.json |
Shared options: -p, --project <path>, -c, --config <path>, and -i, --input <path> to read an existing artifact instead of re-scanning. Run escany <command> --help for the full list.
Writing a report to a file
report prints to stdout by default. -o, --out <path> writes it to a file instead, creating parent directories as needed:
npx escany report -f markdown -o docs/authorization.md
npx escany report -f endpoints -o docs/endpoints.txt
npx escany report -f sarif -o escany.sarifColour is stripped on the file path, because a file has no terminal to interpret an escape sequence: a [32m in a committed .md is literal garbage in every reader that opens it, and it breaks grep and diff in CI, which is usually the reason to write the file at all. markdown, json and sarif emit no colour anyway, so nothing about those documents changes. The terminal path is untouched.
Supported frameworks
| Framework | Enforcement understood |
|---|---|
| NestJS 11–12 | @UseGuards at handler/controller/global, APP_GUARD, useGlobalGuards, configure(consumer) middleware, composite decorators via applyDecorators, Reflector.createDecorator, @Public-style opt-outs, passport AuthGuard |
| Express 4–5 | app.use / router.use mount trees, per-route middleware arrays, path-prefix middleware, multi-mount routers |
| Fastify | onRequest/preHandler hooks, route-options hooks, plugin encapsulation, and the fastify-plugin encapsulation break |
The two design decisions that matter
1. A decision is an interval, not a boolean
@Roles('admin') present does not mean admin is allowed. The guard might not read that key. The key might be unresolvable. Two grants might conflict. So every cell carries a bound over three values — allowed, denied, unknown — plus its basis and its evidence:
✓ allow ⊘ conditional ✗ not granted
⛔ explicit deny ? undetermined ● ungoverned✗ (no grant covers this actor) and ? (a decision node exists but cannot be resolved) are different facts, and rendering the second as the first is the failure this tool exists to avoid. ● means the route is open to anyone, including unauthenticated callers — not the same as "granted to every role".
Composition is published and property-tested: allOf is a meet, anyOf is a join. So anyOf(allow, unresolved) is allow, and an unresolved node may only ever widen an interval, never narrow it.
2. Confidence caps severity
Nothing fails your build unless it is both high-severity and high-confidence. Guard classified only by its name? LOW confidence, never gates. Absence-based claims never reach HIGH by vacuity — an empty guard chain trivially satisfies "everything resolved".
Every report prints its own completeness before any conclusion:
world closed · completeness: globalGuardsResolved ✓ allModulesParsed ✓
unresolvedDecorators 0 uncataloguedGuardBodies 0
Routes 21 · Resolution: full 12 · partial 0 · none 9A tool that hides its blind spots is worse than no tool, because a clean report becomes evidence of safety.
Rules
AZ018 inert grant is the headline: a @Roles(...) no guard ever reads. It needs no world assumption, no actor universe and no hierarchy — only two metadata keys that fail to match — which makes it the most provable finding in the catalogue.
Also: no enforcement point (AZ001), authenticated-but-not-authorized (AZ002, aggregated per controller), guard with no grant (AZ003), unclassifiable point (AZ004), sibling inconsistency (AZ006), delegated downstream (AZ008), public opt-out on a destructive route (AZ009), grant naming an unknown actor (AZ011), unused actor (AZ012), authz-before-authn ordering (AZ014), route shadowing (AZ015), documented-but-unenforced (AZ017).
AZ011 compares against resolved runtime values, so @Roles('admin') with enum Role { Admin = 'admin' } does not fire. That distinction is why the rule is usable.
Configuration
escany runs with no configuration. A config file exists only to correct the engine or declare intent.
escany init writes a starter escany.config.json. These names are picked up automatically from the project root: escany.config.json, escany.config.js, escany.config.mjs, escany.config.ts, .escanyrc, .escanyrc.json. A .ts config needs a TypeScript loader in the host process; prefer escany.config.json unless you are running escany through tsx.
{
// One or more project roots. A monorepo can mix world assumptions per project.
"projects": ["."],
// "closed": the actor universe discovered in source is complete.
// "open": other actors may exist, so absence of a grant is not a denial.
"worldAssumption": "closed",
// Correct a guard escany could not classify from its body.
"guards": {
"TenantGuard": {
"mechanism": "TENANT_SCOPE",
"metadataKey": "tenant",
"quantifier": "all",
"grantCombining": "last-applicable",
"conditional": true
}
},
// Declare the actor model where the code does not state it.
"actors": {
"absorbsGrantsOf": { "Admin": ["Teacher"] }, // allow grants only
"impliesMembershipOf": { "Admin": ["Staff"] }, // allow AND deny
"values": { "Admin": ["admin", "ADMIN"] }
},
// "off" | "warn" | "error", keyed by rule id.
"rules": { "AZ012": "off" },
"ignore": ["**/legacy/**"],
"include": []
}The two hierarchy relations are deliberately separate because deny inverts under inheritance: absorbsGrantsOf expands allow grants only, while impliesMembershipOf expands allow and deny.
Environment variables
None. escany reads no environment variables and needs no .env file. Everything is a CLI flag or a config file key. There is no .env.example in this repository because there is nothing to put in one.
CI
npx escany check --baseline .escany/baseline.json
npx escany report -f markdown >> $GITHUB_STEP_SUMMARYExit codes follow ESLint: 0 pass, 1 gate failed, 2 tool error. Not "1 = warnings", because CI cannot then distinguish a policy breach from a crash, and a team that sees both as a red build learns to ignore both.
The default gate is severity >= high AND confidence == HIGH. --fail-on and --min-confidence move the bar; --max-warnings <n> adds a count ceiling.
--baseline is what makes adoption possible on an existing codebase: current findings are recorded, the build stays green, and the gate fires on the next one. Write the baseline with escany check --update-baseline and commit it.
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Authorization gate
run: npx escany check --baseline .escany/baseline.json
- name: Authorization matrix
if: always()
run: npx escany report -f markdown >> "$GITHUB_STEP_SUMMARY"SARIF caveat: inline PR annotations are free on public repos, require GitHub Code Security (paid per committer) on private ones, and Bitbucket Cloud has no SARIF ingestion at all. The markdown step summary is the channel that always works.
Entities
Where the code says so, each route carries the resource it acts on. /students is a Student; /teachers/:id/students is a Student whose parent is Teacher.
The signals are the path, the controller class name, the declared return type, a @Body() parameter type and @ApiTags — ranked in that order, with a resource named only when one of them survives. Type signals are read syntactically, never through the type checker, so the answer does not change with project size or with whether node_modules is installed.
An entity never rises above MEDIUM confidence, which puts it structurally out of reach of the gate: escany check fires at HIGH only, so a naming convention cannot fail your build. Where no signal survives, the cell is -, and that means "escany did not determine a resource" — never "this route has no resource".
What it cannot tell you
escany models the declared enforcement surface of a repository. It does not observe, prove, or guarantee the effective access control of a running system.
Out of reach: authorization inside services, roles loaded from a database, resource ownership and relationship checks, ABAC and request-time policy engines, external identity providers and policy decision points, gateways and service meshes, ORM query scoping that filters by absence of rows, and whether ADMIN in your code is the ADMIN your IdP issues.
Where a decision depends on any of those, the cell is ? with a reason and a file:line, and explain tells you exactly which line it could not evaluate.
Architecture
One pipeline, one direction. Each stage may only produce its own kind of statement, and the kinds do not mix.
target repository (JS/TS sources)
│
▼
┌──────────────────────┐
│ Workspace │ collectFiles, MAX_FILES ceiling
│ + adapter.detect() │ cheap bounded text scan, never a compiler program
└──────────┬───────────┘
▼
┌──────────────────────┐
│ adapter.analyse() │ src/adapters/{nest,express,fastify}
│ FACTS │ "these points apply in this order,
└──────────┬───────────┘ with these grant literals"
▼
┌──────────────────────┐
│ normalise │ src/core/normalise.ts
└──────────┬───────────┘
▼
┌──────────────────────┐
│ entity + resolver │ src/core/{entity,resolver,hierarchy,protection}.ts
│ DECISIONS │ the interval algebra, per (route, actor)
└──────────┬───────────┘
▼
┌──────────────────────┐
│ rules │ src/rules/* → AZ001 … AZ018
│ FINDINGS │ no severities invented downstream, no exit codes
└──────────┬───────────┘
▼
escany.json (the public interface, SCHEMA_VERSION 1)
│
┌──────┴───────┐
▼ ▼
reporters gate src/reporters/* src/cli/gate.ts
table, markdown, sarif, exit 0 / 1 / 2
json, explain, summaryLayering invariants, enforced by dependency direction and pinned by tests:
- adapters emit facts, never verdicts
- the resolver emits decisions, never severities
- rules emit findings, never exit codes
- the CLI owns exit-code policy
- nothing downstream of
escany.jsonreads a source file; reporters are pure and synchronous
All framework knowledge lives in src/adapters/*. Adding a framework means adding an adapter, never touching the resolver.
Design rationale, including the adversarial review that reshaped it: docs/specs/2026-09-07-escany-design.md.
Project structure
src/
├── adapters/ framework knowledge lives here and nowhere else
│ ├── detect.ts cheap bounded framework detection
│ ├── types.ts the fact contract every adapter emits
│ ├── nest/ NestJS: guards, decorators, middleware, actors
│ ├── express/ Express mount trees and middleware chains
│ └── fastify/ Fastify hooks and plugin encapsulation
├── core/ framework-agnostic engine
│ ├── analyse.ts orchestrates the pipeline
│ ├── normalise.ts facts → intermediate representation
│ ├── resolver.ts the per-actor decision resolution
│ ├── hierarchy.ts actor absorption and membership relations
│ ├── protection.ts the per-route protection axis and status labels
│ ├── entity.ts resource naming from ranked signals
│ └── config.ts config discovery, schema and defaults
├── rules/ findings, one module per family
│ ├── no-enforcement.ts grants.ts classification.ts
│ ├── consistency.ts ordering.ts
│ └── types.ts index.ts
├── reporters/ pure, synchronous, artifact-in / text-out
│ ├── table.ts endpoints.ts markdown.ts sarif.ts
│ ├── explain.ts summary.ts matrix-json.ts stable-json.ts
├── schema/ the public data model
│ ├── decision.ts the three-value interval algebra
│ └── model.ts Route, Actor, Finding, Artifact
├── cli/
│ ├── index.ts command definitions (commander)
│ └── gate.ts exit-code policy, baselines
└── index.ts library entry point
tests/
├── algebra.test.ts property tests on the decision algebra (fast-check)
├── resolver.test.ts resolver units on hand-built IR, no filesystem
├── entity.test.ts entity resolution, mostly refusals
├── reporters.test.ts reporter output over a hand-built artifact
├── adapters.test.ts adapters over the fixture corpus
└── fixtures/ 34 minimal apps, one pattern or pathology each
docs/specs/ design rationale and the adversarial reviewThe layer boundaries in that tree are the same ones listed under Architecture. tests/fixtures/* are analysis input: they are never executed and never typechecked.
Development
npm install
npm test # 398 tests
npm run typecheck # tsc --noEmit, strict + noUncheckedIndexedAccess
npm run build # tsc -p tsconfig.build.json -> dist/| Script | What it runs |
|---|---|
| npm test | vitest run — the full suite |
| npm run test:watch | vitest in watch mode |
| npm run typecheck | tsc --noEmit |
| npm run build | tsc -p tsconfig.build.json, output to dist/ |
| npm run dev | tsx src/cli/index.ts — the CLI from source |
| npm run scan | tsx src/cli/index.ts scan |
Run the tool against a fixture to see behaviour end to end:
npx tsx src/cli/index.ts matrix -p tests/fixtures/nest-school
npx tsx src/cli/index.ts matrix -p tests/fixtures/nest-school --view actors
npx tsx src/cli/index.ts explain GET /cats/secret -p tests/fixtures/nest-basic
npx tsx src/cli/index.ts findings -p tests/fixtures/nest-inert-grantexplain is the best debugging tool in the repo: it prints the whole chain, each point's metadata key, grant lookup and classification, every grant, and the per-actor bound with its basis and reason.
Windows note: Git Bash rewrites a leading-slash argument like
/cats/secretinto a Windows path. Use PowerShell forexplain, or pass the path through a variable.
Conventions worth knowing before the first patch: the package is ESM, so every relative import ends in .js even though sources are .ts; noUncheckedIndexedAccess is on, so arr[0] is T | undefined; and the dependency list is deliberately short. See CONTRIBUTING.md.
Testing
Vitest plus fast-check for the property tests. 398 tests across six files and five layers, each catching a different class of failure:
| File | Layer | Catches |
|---|---|---|
| tests/algebra.test.ts | pure | closure, commutativity, associativity, idempotence, monotonicity and absorption of the decision lattice |
| tests/glob.test.ts | pure | the ignore-list matcher, as a pattern x path truth table — a defect here silently deletes routes from analysis before any other layer runs |
| tests/resolver.test.ts | hand-built IR | lattice, hierarchy, quantifier and closed-world behaviour, with no filesystem or adapter involved |
| tests/entity.test.ts | mostly hand-built | entity resolution — most assertions are refusals, because a heuristic that names something on every route is indistinguishable from one that invents |
| tests/reporters.test.ts | hand-built artifact | the endpoint view, the JSON projection, and the renderMatrix default that two callers depend on implicitly |
| tests/adapters.test.ts | fixture corpus | adapters over 34 fixture apps; a silently dropped route is the failure these exist to catch |
npm test # all of it
npx vitest run tests/algebra.test.ts # one file
npx vitest run -t "absorption" # one test by name substring
npx vitest # watch modeThere is no coverage script configured; coverage is not currently measured or claimed.
Roadmap
Implemented today: everything under Commands, Supported frameworks and Rules. Nothing below is available yet.
- [ ] Freeze the artifact schema —
SCHEMA_VERSIONis1butSCHEMA_FROZENisfalse, and the breaking changes listed in §2.10 of the design doc have to land first - [ ] Coverage reporting in CI
- [ ] Additional adapters (Koa, Hapi, tRPC) behind the same fact contract
- [ ] Widen the guard-body catalogue past its current eight shapes, one reviewed shape at a time
- [ ] An editor surface over
matrix --json
The stopping point is a design position, not a backlog item: escany is not becoming a general static analyser, and open-ended inference in place of the guard-body catalogue is out of scope.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md first — in particular the layering invariants and the fixture requirement, which are the two things most likely to send a pull request back.
The short version: one concern per PR, npm run typecheck && npm test && npm run build green, and a fixture for anything that changes analysis behaviour.
A false permissive result — escany printing ✓ where the code does not actually grant access — is the highest-priority class of bug in this project. Please say so in the title if you find one.
Security
escany is a read-only static analyser: it does not execute the code it analyses, opens no network connections, reads no environment variables, and sends no telemetry.
Note that escany.json and the rendered reports contain file paths, route paths, guard names, role literals and line numbers from the analysed repository. On a private codebase, treat the artifact with the same care as the source it describes.
To report a security problem in escany itself, see SECURITY.md. Please do not open a public issue for one.
Status
v0.1.0. The artifact schema is versioned but not frozen — see §2.10 of the design doc.
License
Apache-2.0. Copyright 2026 Mosbah Houcem Eddine.
Author
Mosbah Houcem Eddine
- GitHub: @Rocks-D-xebec-0
- Repository: Rocks-D-xebec-0/Escany
