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
Maintainers
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 AssessmentInstall
npm i -D trust-verify # in the repo whose target you are testing
npx trust-verify init --target https://dev.example.comNothing 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 only — localhost, 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-runBefore the run: trust preflight
Seconds, no probing, and it answers three different questions:
trust preflight --config config/dev.json --profile allWill 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 skipsThat 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 intargets.allowedHostslike any other host, and a SigV4 signature is computed insiderequest()on a URL the guards have already approved. - Config still stores names, never secrets.
passwordEnv,clientSecretEnvandaccessKeyIdEnvname 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_PASSWORDis not set in the environment" rather than a failed login that reads like a finding about the target.trust preflightreports 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 authenticatedProbe 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 + methodologyAdding 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 authenticatedbaseline: 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 recordA 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-1…owasp-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-2Tags 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 controlA 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 # afterTRUST 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.
