npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

trust-verify

v1.6.3

Published

TRUST — Trust Reporting & Unified Security Testing. Zero-dependency security verification for web, API, storage, AI-agent and mobile targets.

Downloads

943

Readme

TRUST

Trust Reporting & Unified Security Testing — continuous verification that the security controls you designed are actually wired up, across web, API, storage, AI-agent and mobile surfaces.

TRUST asks one question, repeatedly and in CI: do the controls hold? Not "what could an attacker find" — that is a scanner's question, and a scanner will always answer it better. TRUST answers the question a scanner cannot: cross-user isolation is supposed to hold on this endpoint — prove it did, today, with evidence a reviewer can check.

Each run produces a Trust Assessment: a control-by-control verdict with the evidence behind it, the remediation if it failed, and the exact command that retests it.

What this is, and what it is not

| | A scanner (ZAP, Burp, Nuclei) | TRUST | |---|---|---| | The question | "What can an attacker find?" | "Are the controls we designed wired up?" | | The unit | A vulnerability | A control | | The verdict | Probabilistic — needs triage | Deterministic — same input, same verdict, every time | | Who runs it | A specialist, periodically | The team that owns the service, every pipeline run | | The output | A finding list | An assurance artifact: evidence, remediation, retest |

Run this before the pentest, not instead of it. TRUST catches the controls that were never wired up, the ones that regressed last sprint, and the ones nobody tested because testing them by hand takes two identities and an afternoon. It does not fuzz, it does not mutate payloads, and it will not find the novel bug your pentester earns their fee on. Those are different jobs, and conflating them is how teams end up trusting a green result they should not.

Where it is genuinely ahead of the market is the AI-agent surface — hierarchy bypass, session and memory isolation, prompt injection with canary verification, guardrail enforcement per tier — because almost nothing else tests the infrastructure around a model rather than the model itself.

npx trust-verify init --target https://dev.example.com      # scaffold config + .env template
trust preflight --config config/dev.json                    # will this run work? (seconds, no probing)
trust run       --config config/dev.json --profile passive  # probe, write JSON + HTML
trust report    --dir reports                               # merge into one Trust Assessment

Install

npm i -D trust-verify        # in the repo whose target you are testing
npx trust-verify init --target https://dev.example.com

Nothing is compiled and there is no install script, so npm ci --ignore-scripts works.

Releases are published from CI via npm trusted publishing, so no long-lived token exists anywhere, and they carry a provenance attestation you can verify with npm audit signatures. Where a release was published by hand instead — because a trusted publisher cannot be attached to a package that does not yet exist, or was not yet configured — the changelog says so for that version, and npm audit signatures will report no attestation. Check it rather than taking this paragraph's word for it.

Airgapped partners can install the checksummed tarball attached to each release: npm i ./trust-verify-1.6.3.tgz.

Commands

| Command | Purpose | |---|---| | trust init --target <url> | Scaffold config/<env>.json, .env.example, an example org probe, .gitignore entries | | trust run --config <path> --profile <name> | Run a profile; exit 2 on a blocking failure | | trust report --dir reports | Merge the latest run per profile into one Trust Assessment | | trust preflight --config <path> | Check the run will work before it spends the budget: config, allowlist coverage, tokens, budget, reachability | | trust validate --config <path> | Config and allowlist checks only — no network, no tokens, safe against a production config | | trust tokens --config <path> | Acquire every declared auth strategy once and write the tokens to a 0600 file, so a CI job signs in once | | trust baseline --dir reports | Record today's findings as accepted, so the gate means "nothing got worse" | | trust catalog [--json] [--tag <tag>] | List every test with its category, trust domain and tags |

As a library

runProfile() never writes to the console and never calls process.exit, so it embeds cleanly in an existing harness:

import { loadConfig, runProfile, writeCombinedReport } from "trust-verify";

const config = await loadConfig("config/dev.json");
for (const profile of ["passive", "authenticated"]) {
  const { summary, findings } = await runProfile({
    config,
    profile,
    out: "reports",
    onEvent: (e) => e.type === "finding" && myLogger.info(e.finding),
  });
  await pushToDashboard(findings);       // report JSON is the integration surface
}
const { outPath } = await writeCombinedReport({ dir: "reports" });

Org-specific probes, without forking

Partners add their own tests by pointing config at a probe module — resolved relative to the config file:

{ "probes": ["./trust-probes/acme.mjs"] }
import { defineProbe, finding, skipped } from "trust-verify";

export default defineProbe({
  name: "acme-sso",
  profiles: ["authenticated"],
  // Ships its own metadata, so these findings score, group and get a root cause
  // exactly like built-in ones.
  catalog: { "ACME-SSO-CLOCK-SKEW": { category: "Authentication", purpose: "Verify the assertion window rejects a skewed clock." } },
  async run(config, client) {
    if (!config.acme?.metadataUrl) return [skipped("ACME-SSO-CLOCK-SKEW", "Assertion window", "acme.metadataUrl not configured")];
    /* … */
  },
});

A probe that covers what a built-in already covers — written against your real schema rather than a generic pattern — can declare supersedes: "API-USERID-SPOOF" in its catalogue entry. The two then read as one control: yours keeps its identity, the worst outcome wins, and the built-in's result is kept in the evidence.

registerDomains(), registerRootCauses() and registerSummaryRules() are available for tests that describe a new architectural area. trust init writes a working example probe to start from.


Design guarantees

| Guarantee | How it is enforced | |---|---| | Zero dependencies | Node ≥ 22 standard library only — fetch, node:tls, node:crypto, node:test. This is supply-chain hygiene, not engineering purity: TRUST installs inside your pipeline, and a security tool that drags in hundreds of transitive packages is arguing against itself. npm ci --ignore-scripts works. Anything genuinely needing weight — a browser, a database — will ship as a separate optional package rather than be forced on every install. | | Deterministic verdicts | Every test returns PASS / FAIL / WARN / SKIP from a pattern match on status codes, headers or bodies. No model judges a result. Where the system under test is itself stochastic — an LLM agent — the decision rule is what stays deterministic: a fixed number of attempts, and a canary appearing in any of them is a failure. Same input, same verdict, so a finding can be argued with a vendor and gated on in CI. | | Evidence-backed findings | Every finding carries purpose, evidence and remediation. No finding without proof. | | Incapable of harm | All traffic passes through SafeHttpClient: HTTPS-only, host allowlist, hard request cap, delay floor, timeouts, manual redirects, production block, write guard, agent-invocation guard. Plain HTTP is permitted on loopback onlylocalhost, 127.0.0.1, ::1 — so a developer can test before deploying; never a hostname that merely resolves there, since DNS can answer differently between the check and the request. | | Redaction by default | JWTs, bearer tokens, cloud keys, connection strings, signed-URL signatures, PEM blocks and KEY=value secrets are stripped before evidence reaches disk. | | A skip is never a pass | Missing credentials and unmet preconditions produce SKIP, are excluded from scoring, and are listed under Retest Requirements. A sweep that could not finish reports how far it got: nothing performed is a skip, a partial sweep is a warning, and only a complete sweep can pass. | | Credentials are acquired, never printed | auth.strategies signs in through the same guarded client, so an IdP host must be allowlisted like any other. Config stores env var names; the console, the run JSON and the exports carry names, kinds and expiry — never a token. | | Nothing routes around a guard | A SigV4 signature is computed inside request(), after every check has passed. A mutation expected to be refused runs under its own switch (allowDenialTests) rather than by enabling writes. |


Layout

trust/
├── config/
│   ├── dev.json              worked example — every probe section documented
│   └── minimal.json          passive-only, no credentials required
├── src/
│   ├── index.mjs             public API — everything partners may import
│   ├── cli.mjs               init / run / report / preflight / tokens / baseline / catalog
│   ├── runner.mjs            runProfile(), defineProbe(), custom probe loading
│   ├── init.mjs              scaffolding written by `trust init`
│   ├── env.mjs               .env loading for the CLI (never for the library)
│   ├── safety.mjs            SafeHttpClient + config validation
│   ├── preflight.mjs         `trust preflight` / `trust validate` checks
│   ├── config.mjs            section aliases, `extends`, request budgets
│   ├── auth/                 declarative strategies: SRP, OAuth2 grants, SigV4 signing
│   ├── chain.mjs             dependsOn / condition — conditional execution
│   ├── baseline.mjs          accepted findings, and what changed against them
│   ├── export/               SARIF 2.1.0, JUnit XML, stable finding identity
│   ├── finding.mjs           finding() factory, redact(), canary(), headline()
│   ├── catalog.mjs           test metadata, domains, root causes, attack paths  ← source of truth
│   ├── report.mjs            per-run JSON + standalone HTML + surface derivation
│   ├── assessment/           the Trust Assessment: model, theme, client, sections/×8
│   └── probes/
│       ├── token.mjs         offline JWT claim hygiene — issues no requests
│       ├── web.mjs           headers, TLS, cookies, CORS, methods, exposure, SRI, caching
│       ├── injection.mjs     XSS, SQL error, SSTI, traversal, CRLF, host header, SSRF
│       ├── api.mjs           cross-user, scoping, RBAC, identity, inventory, query cost, session
│       ├── storage.mjs       object-store isolation, public access
│       ├── idp.mjs           OIDC discovery, PKCE, implicit flow, native password grant
│       ├── jwt.mjs           server-side token validation — alg:none, signature, kid, claims, audience
│       ├── isolation.mjs     declared authorisation boundaries — five types, config-driven
│       ├── agent.mjs         AI runtime: hierarchy, sessions, memory, injection, disclosure
│       └── mobile.mjs        deep links, app-site association, attestation
├── scripts/
│   ├── preflight.mjs         publish gate: no deps, no install scripts, no secrets shipped
│   └── combined-report.mjs   deprecated shim → `trust report`
├── test/                     239 tests over safety, auth, config, isolation, export, probes
└── reports/                  generated output (gitignored)

Profiles

| Profile | Auth needed | Modules | |---|---|---| | passive | none | web, idp, injection | | authenticated | identity tokens (two users) | token, api, storage, isolation | | agent | bearer tokens + allowAgentInvocations | token, agent | | mobile | optional | mobile | | all | all tokens | every module |

token probes issue no HTTP requests — they inspect the claims of the tokens you supply, so they cost nothing and cannot touch the target.


Configuration

Config drives everything. It stores env var names, never secret values — secrets live in .env (gitignored; see .env.example).

trust run and trust report load .env from the working directory automatically. Real environment variables always win, so CI secrets are never shadowed by a stale local file. Override the path with --env-file <path>, or disable with --no-env.

{
  "name": "example-app-dev",
  "environment": "dev",                  // prod|production|live is refused
  "targets": {
    "web": "https://dev.example.com",
    "allowedHosts": ["dev.example.com", "api.dev.example.com"]
  },
  "safety": {
    "maxRequests": 120,                  // hard cap; blocked requests do not consume it
    "minimumDelayMs": 150,               // floor between requests (min 50)
    "requestTimeoutMs": 30000,
    "allowWrites": false,                // PUT/PATCH/DELETE and write-marked POSTs
    "allowAgentInvocations": false,      // live LLM calls
    "productionOverride": false          // requires written authorisation
  },
  "api": { "endpoint": "…", "tokenAEnv": "AUTH_TOKEN_A", "tokenBEnv": "AUTH_TOKEN_B", /* probe specs */ },
  "storage": { "baseUrl": "…", "targets": [ /* who accesses whose prefix */ ] },
  "agent": { "runtimeEndpoint": "…", "allowedAgentId": "…", "subAgents": [ … ] }
}

Probe bodies (GraphQL queries, REST paths, spoofed identifiers, storage keys) are declared in config, so pointing TRUST at a new target is configuration, not code. See config/dev.json for a fully annotated example. Comments are permitted in config files.

Validate before you run — --dry-run checks the config and prints the plan without issuing a request:

node src/cli.mjs --config config/dev.json --profile all --dry-run

Before the run: trust preflight

Seconds, no probing, and it answers three different questions:

trust preflight --config config/dev.json --profile all

Will this run work? Config validity, every configured endpoint inside targets.allowedHosts, token presence and expiry, two identities that are genuinely different principals, a request budget that covers the profile, TLS reachability. The expired-token fixture is judged by the opposite rule — expired is correct there, still-valid is the problem.

What will it not reach? Every control that applies to a configured surface but is one setting short is named, with the key:

! API-CSRF — add api.csrf.endpoint (a state-changing endpoint a browser session can reach)
! ISO-RECORD-OWNERSHIP: add queryA — a pinned record ID goes stale, and a placeholder skips

That list is the difference between agreeing coverage in advance and discovering it as a wall of skips afterwards.

Will the requests it sends make sense? A spec written in the wrong shape — a GraphQL check declared as a REST call — sends a document the server rejects on shape, and a probe that reads "not a rejection" as a verdict then reports a finding about the request rather than the target. That is checkable before the run, so it is checked here.

trust validate runs the same checks with no network at all, which makes it safe against a production config.

Config resolution

Sections resolve through conventional spellings, so an application that already calls its API section graphql or appSync, or its agent section agentCore or bedrock, does not have to duplicate the same endpoint under a second key:

| Canonical | Also accepted as | |---|---| | api | graphql, appSync, rest, backend | | agent | agentCore, bedrock, llm, aiAgent | | storage | s3, objectStore, blob, bucket | | mobile | app, device |

An explicit canonical key always wins, and the run records which key each section resolved from, so resolution is visible rather than guessed.

Environments inherit. A child overrides only what differs, and arrays replace rather than merge — so a child can narrow an allowlist, never silently widen it:

{ "extends": "./base.json", "environment": "uat", "safety": { "maxRequests": 250 } }

Request budgets

safety.maxRequests takes a number, or a map keyed by profile. safety.budgets optionally caps individual suites, which stops a long web sweep exhausting the run before the storage and agent probes execute:

"safety": {
  "maxRequests": { "passive": 100, "authenticated": 150, "agent": 200, "default": 120 },
  "budgets": { "web": 50, "injection": 25, "api": 20 }
}

Spend is recorded per suite in the run JSON, and an exhausted budget names the suite that consumed it. A sweep that could not complete reports what it managed — no checks performed is a skip, a partial sweep is a warning that says how far it got, and only a complete sweep can pass.

Cross-service token reuse

A token is minted for something. A service that verifies only the signature accepts every token its issuer ever produced — including the one belonging to a neighbour with different privileges, which is how an over-shared machine credential becomes access nobody granted.

"api": {
  "crossService": [
    { "name": "agent-token-at-api", "token": "agentUser",
      "endpoint": "https://api.dev.example.com/graphql",
      "query": "query { __typename }", "expectedAudience": "bt-ask-api" },
    { "name": "api-token-at-agent", "token": "apiUser",
      "endpoint": "https://agents.dev.example.com/invoke", "agentInvocation": true }
  ]
}

Each spec names a token and an endpoint that should not accept it. Declared rather than inferred, because only you know which credential belongs to which surface — crossing them on a guess would produce findings about a boundary nobody drew. Crossing a token with the audience it already names is refused as a test: it would pass correctly and prove nothing.

Session checks against a GraphQL API

api.session re-checks a token after logout and with a deliberately expired one. Both send a request to verifyEndpoint, and for a GraphQL API that request needs a document:

"api": {
  "kind": "graphql",
  "session": {
    "verifyEndpoint": "/graphql",
    "verifyQuery": "query { me { id } }",   // defaults to `query { __typename }`
    "logoutEndpoint": "/logout",
    "expiredTokenEnv": "EXPIRED_TOKEN"
  }
}

Without one the check sends an empty document, the server rejects the request shape, and the probe never reaches the auth layer it is asking about. Both controls require positive evidence before they will claim anything: a response that answers normally is a failure, a refusal is a pass, and a 400, a 5xx or an unrelated error is reported as inconclusive with the reason. A critical finding never rests on the absence of a rejection.

Testing that a mutation is refused

safety.allowDenialTests permits a mutation that is expected to be denied, without enabling writes generally. Proving a permission mutation is rejected previously required turning on every destructive path in the harness. If the target accepts the request, that acceptance is the finding.

Authentication

Real deployments do not keep a bearer token in .env; they sign in against an IdP, exchange the result for scoped credentials, and sign the request. TRUST declares that in config and resolves it once, before any probe runs:

"auth": {
  "strategies": {
    "userA": { "type": "cognito-srp", "region": "us-east-1", "userPoolId": "us-east-1_AbC123",
               "clientId": "…", "username": "[email protected]", "passwordEnv": "USER_A_PASSWORD" },
    "userB": { "type": "cognito-srp", "…": "…", "username": "[email protected]", "passwordEnv": "USER_B_PASSWORD" },
    "signed": { "type": "cognito-identity-pool", "region": "us-east-1", "identityPoolId": "…",
                "providerName": "cognito-idp.us-east-1.amazonaws.com/us-east-1_AbC123",
                "idTokenFrom": "userA", "service": "execute-api" }
  }
},
"api": { "endpoint": "https://api.dev.example.com/graphql", "tokenA": "userA", "tokenB": "userB" }

| Strategy | What it does | |---|---| | static | Today's behaviour, named — a bearer token from an env var | | cognito-srp | Cognito USER_SRP_AUTH. SRP, so no plaintext-password grant has to be enabled on the pool to be assessed | | cognito-identity-pool | Exchanges an ID token for temporary AWS credentials, then signs | | okta-ropc | Okta resource-owner password grant | | client-credentials | OAuth2 machine-to-machine — the grant CI can always use | | sigv4 | AWS SigV4 from keys in the environment |

Three properties hold for every strategy, and they are the reason this is worth having in the tool rather than in a shell script around it:

  • Nothing weakens SafeHttpClient. Acquisition goes through the guarded client, so an IdP host must appear in targets.allowedHosts like any other host, and a SigV4 signature is computed inside request() on a URL the guards have already approved.
  • Config still stores names, never secrets. passwordEnv, clientSecretEnv and accessKeyIdEnv name environment variables. Nothing prints a token: the run report and the console carry strategy names, kinds and expiry.
  • A missing input is a precise skip. "USER_A_PASSWORD is not set in the environment" rather than a failed login that reads like a finding about the target. trust preflight reports the same thing without signing in at all — a check that costs a login is a check teams stop running.

A long run outlives a short-lived token, so a 401 triggers one refresh and a retry. A second 401 is believed and reported: after that it is a statement about the target, not the harness.

For CI, acquire once and share:

trust tokens --config config/dev.json --out .trust-credentials.env   # 0600, tokens never printed
trust run --dotenv .trust-credentials.env --config config/dev.json --profile authenticated

Probe catalog

Web / infrastructure (passive) — HSTS, CSP (with weak-directive detection), X-Content-Type-Options, Referrer-Policy, Permissions-Policy, clickjacking, frame-ancestors allowlist, cookie flags, token-in-web-storage, source maps, sensitive-file exposure (with SPA-fallback discrimination), CORS reflection, open redirect, rate limiting, TLS version, certificate validity.

API / authorisation (authenticated) — cross-user record read, owner-scoped lists, permission-mutation RBAC, client-supplied identity, cross-origin state change (CSRF), mass assignment of privileged fields, GraphQL introspection, error disclosure, native password-grant availability, plus arbitrary extraChecks per endpoint.

Server-side token validation (authenticated) — the live half of token hygiene. The token probes read claims offline; these take the real token, alter exactly one property of it — alg:none, a broken signature, elevated claims the signature does not cover, an unknown kid — and check the API refuses each one. An API that accepts any of them has no authentication, and every authorisation result in the run is then describing what happens to a caller the server believes rather than to an attacker.

Storage — anonymous listing/read, cross-tenant prefix access, cross-user object access from both directions, path traversal out of the caller's prefix in four encodings, and signed-URL integrity (altering the signature or extending the expiry must invalidate it).

AI agent — unauthorised agent target, identity spoofing, direct and indirect prompt injection, multi-turn injection (planted in one turn, claimed in a later one, after the guardrail that read the first has stopped paying attention), tool-use abuse, dangerous URI output, cross-session inheritance, memory isolation, sub-agent hierarchy bypass, and — conditionally, only when the hierarchy is breached — sub-agent ACL and guardrail bypass. Then system-prompt, credential and tool-schema disclosure.

Mobile — deep-link destination validation, app-site association files, device-attestation enforcement. Certificate pinning and sandbox storage SKIP with the exact manual procedure, because a network harness cannot verify them.

Identity provider (passive) — discovery document, PKCE with S256, implicit flow still advertised, an unauthenticated token endpoint uncompensated by PKCE, what the application's own authorisation request asks for, and a Cognito user pool still accepting USER_PASSWORD_AUTH. Session fixation and the post-callback verifier cookie SKIP with the manual procedure, for the same reason.

Declared boundaries — whatever config.isolation states: record ownership, storage prefixes, enumeration, privileged mutations and client-supplied identity. These are the tests a team writes about its own data model, without writing code.

Input handling covers query parameters throughout, and JSON request bodies where injection.body declares one — a POST-first API is otherwise untested by a suite that only writes to query strings.

Injection and leak tests use the canary technique: plant a unique UUID, assert on its absence. No interpretation, no false confidence.


Reports

Each run writes reports/<name>-<profile>-<timestamp>.json (machine-readable, the artefact CI keeps) and a matching standalone HTML view.

trust report merges the latest run per profile into one Trust Assessment:

1. Posture score (0–100)      severity-weighted: critical 10, high 5, medium 3, low 1, info 0.5
2. Deployment readiness       Ready / Caution / Not Ready
3. Domain cards               worst-first, so a strong composite cannot hide a weak domain
4. Impact summary             blockers / high priority / config improvements / controls validated
5. Executive interpretation   what failed, in prose, grouped by category
6. Root causes                architectural observations, not a fix list
7. Verified trust controls    grouped and summarised — families collapse into one statement
8. Detailed findings          purpose, evidence, remediation; failures expanded by default
9. Remediation plan + retest + searchable inventory + methodology

Adding a test means adding one entry to src/catalog.mjs — category, domain, root cause, scoring and every report section follow automatically.

The same JSON is the integration surface: --sarif and --junit translate it for a security dashboard and a CI test view, and --baseline compares it with what a team has already accepted. See CI/CD.

Declaring what to test

Four things are stated in config rather than written as code. Each is a config section, each degrades to a precise skip when its inputs are missing, and each is covered in full by trust.config.schema.json — which your editor will read.

Declared isolation boundaries

Most real security bugs are authorisation failures, and the test never changes shape: act as A, act as B against A's resource, ask whether it was refused. Declaring the boundary is enough — TRUST supplies the test, the verdict and the report entry:

"isolation": [
  { "id": "API-CROSS-USER-RECORD", "type": "record-ownership",
    "description": "User B cannot read User A's record",
    "endpoint": "https://api.dev.example.com/graphql",
    "queryA": "query { listMyRecords(limit: 1) { items { id } } }",
    "queryB": "query($id: ID!) { getRecord(id: $id) { id owner } }",
    "tokenA": "userA", "tokenB": "userB", "severity": "high" }
]

| Type | What it does | Identities | |---|---|---| | record-ownership | Discovers a record as A, requests it as B | two | | prefix-scoped-storage | Lists and reads another tenant's object prefix | two | | enumeration | Checks a list endpoint returns only the caller's own records | one | | mutation-guard | Attempts a privileged mutation, expecting refusal | one | | identity-injection | Sends a client-supplied identity field, expecting it to be ignored | one |

The record ID is discovered rather than pinned in config, so a declared boundary survives a data reseed. A boundary needing two identities skips when only one is available rather than reporting a pass it did not earn, and an ambiguous response is a warning that says what was ambiguous — add denialPatterns for how your API phrases a refusal and the verdict sharpens. mutation-guard runs under safety.allowDenialTests, because a control that holds writes nothing.

Conditional execution

A downstream test often only means something if an upstream boundary broke. Declare that, and the chain does two useful things — it saves the request, and it turns a skip into a statement about the system:

{ "id": "ACL-BYPASS", "dependsOn": "AGENT-ENDPOINT-COORDINATOR", "condition": "failed" }
AGENT-ENDPOINT-COORDINATOR   FAIL  An external token reaches coordinator directly
ACL-BYPASS                   FAIL  Reachable because AGENT-ENDPOINT-COORDINATOR failed.

AGENT-ENDPOINT-EXECUTOR      PASS  An external token cannot reach executor
ACL-BYPASS-EXECUTOR          SKIP  Not reachable — upstream control held (…-EXECUTOR passed)

condition is failed (the default), passed or any. Dependencies may point at any finding in the run, including one from a different probe module, and a dependsOn naming a test that never ran is reported rather than silently satisfying the gate. trust preflight catches that before the run.

Agent tiers

An agent hierarchy is a list of endpoints with expectations, not a topology language:

"agent": {
  "runtimeEndpoint": "https://agents.dev.example.com/invoke",
  "accessTokenA": "userA",
  "endpoints": [
    { "name": "coordinator", "agentId": "coord-a", "expectDenied": true },
    { "name": "coordinator-acl", "agentId": "coord-a", "expectDenied": false,
      "expectPatterns": ["ACCESS-DENIED"], "dependsOn": "AGENT-ENDPOINT-COORDINATOR" }
  ]
}

An internal tier that accepts an end-user token has no boundary of its own — whatever the orchestrator enforces can be walked around by calling it directly, which is why expectDenied defaults to true and its failure is critical.

Identity provider posture

The idp section checks the provider itself, unauthenticated: the discovery document, PKCE with S256, whether the implicit flow is still advertised, whether an unauthenticated token endpoint is compensated by PKCE, what the application's own authorisation request asks for, and whether a Cognito user pool still accepts USER_PASSWORD_AUTH — which bypasses federated sign-in and everything attached to it, including MFA. Checks that genuinely need a browser (session fixation across a real login, the code-verifier cookie after callback) are reported as skips carrying the manual procedure rather than guessed at.

CI/CD

Exit codes: 0 clear (or low/info only) · 1 config or safety error, nothing tested · 2 critical/high/medium failure — fail the pipeline.

A pipeline, end to end

# Cheap checks first: preflight fails in seconds on an expired token, an unlisted host or a
# budget too small for the profile — rather than after half an hour of probing that produces
# a wall of skips reading like findings about the target.
- run: npx trust preflight --config config/$ENV.json --profile all

# Acquire once, so the pipeline does not sign in per step and trip the IdP's rate limit.
- run: npx trust tokens --config config/$ENV.json --out .trust-credentials.env

- run: npx trust run --dotenv .trust-credentials.env --config config/$ENV.json --profile passive
- run: npx trust run --dotenv .trust-credentials.env --config config/$ENV.json --profile authenticated

# One assessment across every profile, plus the formats the rest of the pipeline reads.
- run: npx trust report --dir reports --sarif trust.sarif --junit trust-junit.xml
         --baseline .trust-baseline.json

- uses: github/codeql-action/upload-sarif@v3      # needs security-events: write
  with: { sarif_file: trust.sarif }
- uses: actions/upload-artifact@v4
  with: { name: trust-assessment, path: reports/ }

| Output | Where it lands | |---|---| | --sarif <file> | A security dashboard (GitHub's Security tab, and anything else that reads SARIF 2.1.0) | | --junit <file> | The test-results view of any CI | | --baseline <file> | Nothing new — it changes which findings may block the build |

SARIF carries the severity as security-severity and as impactIfFailed, and only failures get a level: a passing CRITICAL control is normal in TRUST, and must not paint a dashboard red. Results are anchored to the config file rather than to a fabricated source line, because an invented file and line is a lie a reviewer would act on. Each result carries a stable fingerprint — test plus target, never evidence — so a dashboard de-duplicates across runs instead of re-raising everything each night.

JUnit renders a warning as a failure. JUnit has no third state, and the honest options are red or invisible; invisible is worse for a control that could not be confirmed. The build gate is the exit code, which distinguishes them properly.

Baselines

A team adopting TRUST mid-life inherits findings it did not cause and cannot fix this week. Without a baseline the choice is to fail every build until the backlog clears — which nobody does — or to stop gating on the tool, which is the same as removing it.

trust baseline --dir reports --note "adopted at rollout"   # writes .trust-baseline.json
trust run --baseline .trust-baseline.json --config config/dev.json --profile authenticated
baseline: 1 new · 0 worsened · 12 known · 2 fixed
    new      critical API-USERID-SPOOF — Server derives identity from the token
    fixed    high     API-CROSS-USER   — User B cannot read User A's record

A baseline hides nothing: the report still shows everything, and only the exit code changes. Fixed findings are reported as loudly as new ones, a warning that becomes a failure is worsened rather than accepted, and a baselined finding whose profile did not run this time is reported as absent rather than fixed — a gate that congratulates a team for skipping a profile is worse than no gate. Commit the baseline file; it is a policy record, not a cache.

A ready-made GitHub Actions workflow is in .github/workflows/trust.yml.

A skip is never a pass — and now it says why

Three different facts wore one badge. They call for different actions, so they are now distinguished on the finding, counted separately in coverage, and stated in the callout:

| Kind | Meaning | |---|---| | unconfigured | Nobody looked — the config does not say where. The target may well have this problem. | | not-applicable | Cannot apply here, or cannot be verified over HTTP at all (certificate pinning needs a device) | | precondition | The harness looked and could not proceed: an upstream control held, an identity was missing, a guard refused |

Warnings split the same way — inconclusive (could not tell), partial (the check did not finish, so absence proves nothing) and advisory (present but weaker than it should be).

This is the answer to the failure mode that would discredit the tool fastest: a team configures half of it, sees green, and believes it tested something.

Filtering by tag

Every control carries tags derived from its category — owasp-api-1owasp-api-10, authn, authz, ai, injection, hardening, plus its trust domain:

trust catalog --tag owasp-api-2                 # which controls cover broken authentication
trust run --config config/dev.json --profile all --tag owasp-api-2

Tags are derived from the category rather than stored per control, so one mapping stays in step instead of ninety-seven drifting apart. registerTags() extends it for a partner category.

Debugging one control

trust run --config config/dev.json --profile authenticated --only API-CROSS-USER --verbose

--only narrows to the probe module that can produce that ID and reports only it — a probe suite is the smallest executable unit, so this cannot run half a probe, and it says so rather than implying otherwise. --verbose traces every guarded request: method, status, duration and header names. Never header values; a trace is the last place a token should surface.

The report reads by control

One control tested in three profiles used to produce three finding cards, three remediation rows and three retest rows. A 58-control run rendered 161 cards. The narrative sections now group by control — cards, remediation and retest — with the profiles that executed each control shown on its card, and any disagreement between them kept in the evidence. On a representative run that halves the report without removing a single fact from it.

Remediation groups by the fix, not by the finding: seven header controls that all close with one change to the CDN configuration are one row that says "closes 7 controls", because they are one ticket. The inventory deliberately stays per-execution — it is the searchable ledger, and its profile column is the point of it.

None of that changes a verdict, so it needs no flag. Scoring is the part that does:

Scoring: controls, not executions

A control that runs in several profiles — token hygiene runs in three — used to be counted once per execution, and both the posture score and coverage were weighted that way. That let a run improve by adding a profile:

| | Score | Coverage | |---|---|---| | Scored by execution (1.x default) | 79 | 22% | | Scored by control, same runs | 61 | 13% |

Nothing about the target differs between those two rows. The second is the honest one, and it is what --score-by control reports:

trust report --dir reports --score-by control

A control is counted once, at its worst outcome across profiles — a boundary that failed in one profile and passed in another has not held — and the profiles that executed it are listed on the finding, so deduplication does not cost the attribution it replaces. Where outcomes differed, the evidence says so.

This is not the default in 1.x because changing it changes every published score, and finding verdicts are a public API. It becomes the default in 2.0. Switching units is recorded in the trend history, so a run scored one way and compared against a run scored the other is flagged as not comparable rather than reported as an improvement.

Trends and history

trust report records each run in .trends/trends.json and renders a Trends section once there is more than one run: posture, coverage and blockers over time, per-domain movement, and which controls were newly introduced, fixed or are still failing.

History is state, not output. It lives in .trends/ rather than reports/ because reports are regenerated, published and wiped between runs — deleting reports/ must not destroy the series. Both directories are gitignored.

In CI the directory has to be restored before the run and persisted after it, or every run looks like the first:

- uses: actions/cache@v4        # simplest option
  with:
    path: .trends
    key: trust-trends-${{ github.ref_name }}-${{ github.run_id }}
    restore-keys: trust-trends-${{ github.ref_name }}-

For a shared store, sync it instead — the shape is the same:

aws s3 sync s3://bucket/trust-trends .trends   # before
trust run --config config/dev.json --profile all && trust report --dir reports
aws s3 sync .trends s3://bucket/trust-trends   # after

TRUST does not talk to object storage itself: that would mean shipping a cloud SDK and holding bucket write credentials inside a zero-dependency security harness. Your pipeline already has both. Use --trends-dir <path> or TRUST_TRENDS_DIR to point it elsewhere, and --no-trends for a one-off report that must not touch history.


Versioning

TRUST is consumed by other organisations' pipelines, and two things make its compatibility surface wider than an ordinary library. Finding IDs and severities are an API: partners gate CI on them (exit 2), chart them, and file tickets against them, so renaming an ID or promoting a finding from medium to high silently changes someone's build outcome. The run JSON is an API: dashboards parse findings[].status, .severity, .domain and the summary block. The semver contract therefore covers the catalogue as well as the code.

| | What it covers | |---|---| | Major | Removing or renaming a finding ID (the old ID must also be added to DEPRECATED_IDS); raising a severity; changing verdict logic so a previously passing control now fails; removing or renaming a run-JSON field or a profile; removing a package export; raising the minimum Node version; changing scoring weights or readiness thresholds | | Minor | New probes, catalogue entries, profiles, config keys with safe defaults, exports, report sections and extension points. A new test may of course fail — that is the point — but no existing verdict changes. Lowering a severity, or a verdict becoming less strict | | Patch | Fixing a probe that produced a wrong verdict, documented in the changelog with the before and after; redaction improvements; evidence wording, report layout and styling; performance, error messages, docs |

An ID is never edited in place. It is aliased in DEPRECATED_IDS, both IDs resolve to the same metadata, and the rename ships in a major — so a partner's dashboard keeps working across the upgrade rather than silently losing a series.

Extending

A new built-in probe module — write src/probes/<surface>.mjs exporting async run…(config, client) that returns findings, add it to BUILTIN_PROBES and a profile in src/runner.mjs, then add catalog entries. To extend TRUST from outside the package, use defineProbe and config.probes as shown above — no fork required.

Rules for probes: check prerequisites and SKIP (never crash) on missing config or tokens; use two identities for any isolation claim; use a canary for any leak claim; gate conditional probes on the prerequisite actually failing; never mutate data unless allowWrites is set, and clean up if you do.

Tests

npm test        # node --test "test/*.test.mjs"

239 tests cover the safety guards (HTTPS-only, allowlist, per-run and per-suite caps, throttle, write/agent/denial guards, production block), the auth strategies (SigV4 against AWS's published test vector, the SRP group by its own defining property), config resolution and inheritance, declared isolation boundaries and conditional execution, the finding factory and every redaction rule, SARIF and JUnit output, baseline diffing, report construction, HTML escaping, scoring, domain ordering and catalogue integrity.

They are not shipped in the package — a partner installing TRUST should not pay for the test suite — so run them from a clone.


Licence and authorisation

Licensed under Apache-2.0 — see NOTICE for the attribution that must travel with redistributions. Before your first publish, set the copyright holder in NOTICE.

The licence governs copying and modification. It grants no authorisation to test any particular system — that is a separate question, covered by ACCEPTABLE_USE.md. In short: run TRUST only against systems you have written permission to test, keep targets.allowedHosts identical to your agreed scope, and treat safety.productionOverride as requiring named authorisation rather than convenience. Production targets are refused without it, reports are written 0600, and they are classified Internal — Security Sensitive.