Skip to content

Repository files navigation

servicenow-preflight

npm version npm downloads node license
CI last commit Known Vulnerabilities built with TypeScript

docs & live demo

Pre-deployment preflight checks for ServiceNow — validate a target instance and your changes before you ship them. Point it at an instance and it catches what quietly breaks a deploy: an update set that isn't really complete, failing ATF tests, a missing plugin dependency, untranslated strings, a wide-open ACL. It ships as both a CLI you drop into a CI gate and a small, dependency-free library you can embed.

Independent, community-built project. Not affiliated with, endorsed by, or sponsored by ServiceNow, Inc.

Docs & live demo: https://ivanbbaev.github.io/servicenow-preflight/

Contents

Quick start

Requires Node.js >= 20 (developed and tested on 22). Zero runtime dependencies — one package built on Node's global fetch.

# Provide credentials via the environment (or a .env file — see below).
export SNPF_INSTANCE=https://dev12345.service-now.com
export SNPF_USER=admin
export SNPF_PASS='***'

npx servicenow-preflight        # run the default suite (short alias: snpf)
✓ instance-url-configured: Instance URL looks good: https://dev12345.service-now.com
✓ connectivity-auth: Instance is reachable and the credentials authenticate.
! update-set-state: No update set configured (set updateSetId in the config); skipping.
! default-set-leakage: No target scope set (PreflightContext.scope); nothing to verify — skipping the Default-set leakage check.
✓ remote-set-preview: No pending retrieved update sets on the target instance — nothing awaiting preview or commit.
✓ atf-enablement: ATF test execution is enabled ("sn_atf.runner.enabled" is "true").
! atf-run: No ATF suite configured (set options.atfSuites); skipping.
! scoped-app-deps: No required apps declared (set options.requiredApps); skipping.
! i18n-completeness: No target scope set; skipping.
! acl-role-sanity: No scope set; skipping.
! client-callable-acl: No scope set — skipping the client-callable Script Include ACL gate (pass a scope to enable it).
! rest-endpoint-security: No scope set — skipping the scripted REST security gate (pass a scope to enable it).
! script-field-exposure: No scope set — skipping the script-field write ACL gate (pass a scope to enable it).
! scheduled-job-run-as: No scope set — skipping the Scheduled Job 'Run as' gate (pass a scope to enable it).
! mobile-menu-hygiene: No scope set — skipping the mobile menu/module gate (pass a scope to enable it).
! table-crud-acl: No scope set — skipping the table CRUD ACL gate (pass a scope to enable it).
! ui-page-acl: No scope set — skipping the UI Page read ACL gate (pass a scope to enable it).
! ui-action-gating: No scope set — skipping the UI Action gating check (pass a scope to enable it).

! table-namespace: No scope set — skipping the table namespace check (pass a scope to enable it).

! portal-roles: No scope set — skipping the Service Portal widget/page roles check (pass a scope to enable it).

! acl-out-of-scope: No scope set — skipping the out-of-scope ACL check (pass a scope to enable it).

! module-roles: No scope set — skipping the navigator module roles check (pass a scope to enable it).

! script-hygiene: No scope set — skipping the Fix Script / Script Include hygiene check (pass a scope to enable it).

4 passed, 19 warnings, 0 failed

Two identical binaries ship — servicenow-preflight and the alias snpf. Out of the box only the checks that need no target-specific input do real work; the rest turn on once you supply their inputs (a scope, an update set, ATF suite ids, required apps) via a config file. The CLI exits non-zero on any fail, so it drops straight into a pipeline before a promote/deploy step.

What it checks

runPreflight(ctx, checks?) runs each check against the target instance and aggregates a single PreflightReport (ok, results, summary). Twenty-three checks ship in the default suite; the CLI is a thin wrapper over that function.

Check Needs Verifies
instance-url-configured — An instance URL is present and well-formed (prefers https).
connectivity-auth credentials The instance is reachable and the credentials authenticate.
update-set-state updateSetId The target update set, and any batched child sets, are complete and non-empty (not in-progress or ignored).
default-set-leakage scope No captured work is stranded in a "Default"-flagged update set for the scope — changes that would never ship.
remote-set-preview credentials Every pending retrieved update set on the target is previewed with all preview problems resolved (updateSetId, when set, focuses the gate on one set).
atf-enablement credentials ATF test execution is enabled instance-wide (optionally also an online scheduled client test runner, via options.atfEnablement.requireClientTestRunner).
atf-run options.atfSuites Configured ATF test suites run green (no failing or errored tests).
scoped-app-deps options.requiredApps Required scoped apps / plugins are installed, active, and meet any minVersion.
i18n-completeness scope, options.languages Every configured language has full translation coverage for the scope.
acl-role-sanity scope No wide-open mutating ACLs, and no ACLs referencing non-existent roles.
client-callable-acl scope Every active client-callable Script Include is gated by an active execute ACL (Store certification rule 1.2).
rest-endpoint-security scope Scripted REST resources require authentication and enforce ACL authorization backed by a REST_Endpoint ACL (rule 1.3).
script-field-exposure scope Every script-typed column ships with an active field write ACL — table.element or table.* (rule 1.4).
scheduled-job-run-as scope Scheduled Jobs leave "Run as" empty so they run as system (rule 6.2; advisory — never fails).
mobile-menu-hygiene scope No mobile Application Menus/Modules ship in a non-mobile app (rule 5.1; advisory — never fails).
table-crud-acl scope Every custom table has active table-level create/read/write/delete ACLs (rule 1.1; a gap on an extended table only warns).
ui-page-acl scope Every custom UI Page is protected by an active ui_page read ACL named for its endpoint (rule 2.2).
ui-action-gating scope Every active UI Action has a condition or a "Requires role" entry (sys_ui_action_role) — none is runnable by every user (rule 2.1).
table-namespace scope Every custom table is prefixed with the app namespace (<scope>_), taken from the resolved scope name (rule 6.1).
portal-roles scope Service Portal widgets and pages carry roles unless deliberately public; public ones are called out (rule 2.6; advisory — never fails).
acl-out-of-scope scope The app ships no ACLs onto tables outside its scope — table-level/wildcard ones fail; a field ACL for the app's own field only warns (rule 1.6).
module-roles scope Active navigator modules are gated by their own or their application menu's roles, honouring override_menu_roles (rule 5.5; advisory — never fails).
script-hygiene scope No Fix Scripts that are empty or comments only, and no Script Include names duplicated in the scope or shared with another scope (rule 4.10; advisory — never fails).

Checks whose only need is credentials always run once credentials are present; the rest warn (and explain what's missing) until you supply their inputs — they never silently pass. Each check returns one status — pass (✓, holds), warn (!, advisory), or fail (✗, blocks). Only a fail fails the run; warnings never do. Checks never throw (transport/auth/API errors map to a result), and are read-mostly (the sole write is running the ATF suites you configure).

Exit codes: 0 when no check failed; 1 when a check failed, when a selection matched zero checks (nothing verified — treated as a failure, never a vacuous pass), or when the CLI hit an unexpected runtime error; 2 for a usage error — a bad invocation or config caught before any check runs (unknown option, invalid --format value, malformed --max-age, missing required argument).

CLI

snpf [run] [env] [options]     # run checks (default subcommand)
snpf sync <env> [options]      # pull ATF metadata → state manifest
snpf drift <src> <dst>         # compare two instances (promote gate)

Both --flag value and --flag=value forms are accepted.

Flag Description
-i, --instance <url> Target instance URL (single-instance, no registry).
-e, --env <name> Select a registry instance (same as positional).
--all run: sweep every instance in the registry.
--registry <path> Registry file (default .preflight/instances.json).
--config <path> Config file (default: auto-discovered).
--only / --skip <csv> Run only / skip these checks (comma-separated names).
--with-last-run sync: also pull each test's most recent result.
--allow-empty sync: commit an empty snapshot over a non-empty manifest.
--max-age <dur> drift: fail if a compared manifest is older than <dur> (e.g. 7d, 24h).
--allow-stage-skip drift: warn instead of fail when <dst> skips a declared pipeline stage.
--format <fmt> pretty (default), json, junit, sarif.
--json / -h Shorthand for --format json / show help.

--instance, --only and --skip override the matching config-file values. The scope and updateSetId a run targets come from the config file (or the programmatic context), not from CLI flags.

Credentials & auth

Credentials are read from the environment only — never from the config file, never logged, never placed into an error message. The tool covers the full range of ServiceNow inbound-auth methods, plus transport-level mutual TLS:

Method kind Environment inputs Applied as
Basic basic SNPF_USER + SNPF_PASS Authorization: Basic …
Static bearer oauth SNPF_TOKEN Authorization: Bearer …
API key apikey SNPF_API_KEY x-sn-apikey: … (Tokyo+)
OAuth — password grant oauth-password client id/secret + SNPF_USER / SNPF_PASS Bearer (acquired at run)
OAuth — client credentials oauth-client client id/secret Bearer (acquired at run)
OAuth — refresh token oauth-refresh client id/secret + SNPF_OAUTH_REFRESH_TOKEN Bearer (acquired at run)
OAuth — JWT bearer oauth-jwt client id + signing key / assertion Bearer (RS256, acquired)
Mutual TLS (separate) SNPF_MTLS_CERT + SNPF_MTLS_KEY (+ _CA, _PASSPHRASE) client cert on the TLS socket

For the four grant flows the token is minted at run time (POST to ${instance}/oauth_token.do, override with SNPF_OAUTH_TOKEN_URL), cached until just before expiry, and re-acquired once on a 401. JWT assertions are signed RS256 with node:crypto. Mutual TLS is a transport concern: a client cert composes with any header method above, or stands alone (cert-only).

A note on token lifetimes. A static SNPF_TOKEN bearer is convenient, but on a real instance it is usually an OAuth access token carrying the platform-default 1800-second (30-minute) TTL — so in a CI pipeline it is reliably expired by the time the job runs. For anything unattended, prefer a grant flow (which mints a fresh token each run) or an API key (SNPF_API_KEY), which is not on that 30-minute cadence. Note too that oauth-client (the client-credentials grant) is Vancouver+ only and requires the OAuth registry record (oauth_entity) to be bound to an integration user — without that binding the grant returns no token.

Beyond the matrix inputs: SNPF_INSTANCE sets the URL when --instance/config is unset; SNPF_UPDATE_SET likewise supplies updateSetId when the config file does not (the file wins when both are present); SNPF_AUTH forces a method; JWT claims come from SNPF_OAUTH_JWT_KID / _SUB / _AUD / _ISS (or supply a pre-signed _ASSERTION). Any PEM / key / assertion variable accepts an @path value (read from that file, e.g. SNPF_MTLS_KEY=@./certs/client.key); a missing @-file is a hard error reported with the path only, never contents.

HTTP(S) proxy

Outbound requests can be routed through a forward proxy via standard CONNECT tunneling — still zero runtime dependencies. Precedence, first match wins: the config file's proxy → SNPF_PROXY → HTTPS_PROXY → https_proxy. Only https: targets are ever proxied (every ServiceNow instance is https), so HTTP_PROXY is deliberately ignored. Both http:// and https:// proxy URLs work (the latter speaks TLS to the proxy itself, then TLS to the instance through it), proxy credentials go in the URL userinfo (http://user:pass@proxy:3128, always redacted from errors and logs), and mutual TLS composes through the tunnel. TLS verification of the instance is never weakened by proxying.

Bypass hosts with NO_PROXY-style lists — the union of the config file's noProxy, SNPF_NO_PROXY, and NO_PROXY/no_proxy applies: comma-separated entries, each a hostname, a domain suffix, a host:port, a bracketed IPv6 literal, or * (bypass everything).

Detection precedence (with SNPF_AUTH unset, first match wins): OAuth client id + secret (→ oauth-refresh if a refresh token is present, else oauth-jwt if a JWT key/assertion is, else oauth-password if user + pass are, else oauth-client) → SNPF_TOKEN → SNPF_API_KEY → SNPF_USER + SNPF_PASS → no header auth. Mutual TLS (SNPF_MTLS_*) resolves independently and attaches on top of whatever is selected. A .env file in the working directory is loaded automatically, but real environment variables always win over it. With no credentials at all, connectivity-auth reports warn (not fail) and network-dependent checks degrade to advisory warnings.

Configuration file

The CLI auto-discovers the first of preflight.config.json, .js, or .mjs in the working directory (or point at one with --config <path>); JS/MJS forms may export as default or a named config. The file declares the target, which checks to run, and per-check options — but never credentials.

{
  "instanceUrl": "https://dev12345.service-now.com",
  "scope": "x_acme_app",
  "updateSetId": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "select": { "skip": ["atf-run"] },
  "options": {
    "languages": ["de", "fr"],
    "baseLanguage": "en",
    "requiredApps": [{ "id": "x_acme_lib", "minVersion": "2.1.0" }],
    "atfSuites": ["<suite_sys_id>"]
  }
}
Field Type Used by
instanceUrl string Target instance (CLI --instance overrides).
scope string default-set-leakage, i18n-completeness, acl-role-sanity, and the thirteen certification checks (client-callable-acl, rest-endpoint-security, script-field-exposure, scheduled-job-run-as, mobile-menu-hygiene, table-crud-acl, ui-page-acl, ui-action-gating, table-namespace, portal-roles, acl-out-of-scope, module-roles, script-hygiene).
updateSetId string (sys_id) update-set-state; also focuses remote-set-preview on that set's retrieved copy.
select { only?: string[]; skip?: string[] } Check selection (CLI flags override).
options object Per-check options (atfSuites, requiredApps, languages, baseLanguage, …).

Multi-instance: registry, sync & drift

A single --instance URL is fine for a one-off check. Real deployments move a change through a pipeline — dev → staging → test → prod — and what you want to guarantee is that a promote never drops validated coverage. That is what the registry, sync and drift add on top of the single-instance run. It is opt-in: with no .preflight/instances.json, the tool behaves as before.

The registry (.preflight/instances.json) is a committed description of the instances a project targets and the order they promote in — it holds no credentials. Each instance needs a url; promotesTo chains the pipeline (or null for the terminal stage) and is enforced by drift; the optional scope, stage and envPrefix refine per-instance behaviour. A promotesTo that names an undeclared instance, the instance itself, or closes a cycle is rejected when the registry loads (exit 2).

{
  "version": 1,
  "scope": "x_acme_app",
  "instances": {
    "dev": {
      "url": "https://dev12345.service-now.com",
      "promotesTo": "staging"
    },
    "staging": {
      "url": "https://acmestaging.service-now.com",
      "promotesTo": "prod"
    },
    "prod": {
      "url": "https://acme.service-now.com",
      "promotesTo": null,
      "envPrefix": "PROD"
    }
  }
}

Per-instance credentials resolve from the environment exactly as above, except each instance looks up a namespaced variable first: an instance whose envPrefix is PROD reads SNPF_PROD_* before falling back to the unprefixed SNPF_*. envPrefix defaults to the instance name upper-cased (dev → SNPF_DEV_*). Every method and every SNPF_* variable works this way.

  • run <env> (or --all) runs the full suite against a named instance, resolving its url/scope/envPrefix from the registry and loading a committed manifest, if present. With --all, pretty tags each instance block (== staging ==) and the run exits 1 if any instance has a failing check.
  • sync <env> pulls the instance's ATF tests and suites — strictly read-only Table API reads — into a committed state manifest at .preflight/state/<env>.state.json. Each entry gets a logical id (scope/slug) stable across instances, so a re-sync yields a minimal, reviewable diff. --with-last-run also records each test's most recent result. The manifest also captures the instance's platform identity (glide.buildname / glide.war) and its installed apps and plugins with versions, feeding the parity gates below.
  • drift <src> <dst> compares two committed manifests offline by logical id. A test active upstream but missing downstream fails the gate (exit 1) — it would ship a promote without coverage upstream has validated. Extra tests downstream warn; a fully-covered target passes. Only active source tests can block. Two version-parity results ride along: instance-version-parity fails on a release-family mismatch (glide.buildname) and warns on patch-level skew (glide.war); app-version-parity fails when an app or plugin installed on the source is missing or older on the target. Manifests written by older versions of the tool (no identity/app data) degrade to an advisory warn, never a crash. A promotion-order result checks the pair against the registry's promotesTo chain: the declared next stage passes; a target further down that skips a stage fails (a warn under --allow-stage-skip, for a deliberate hotfix); a target upstream of the source or on no shared chain always fails. With no registry, an undeclared instance, or no promotesTo anywhere, it is an advisory warn.
snpf sync staging                 # commit the two manifests, then gate the promote
snpf sync prod
snpf drift staging prod           # exit 1 if staging has active tests prod lacks
snpf run prod                     # only if the gate passes

Report formats

Selected with --format (or --json):

  • pretty (default) — human-readable lines (✓ / ! / ✗) plus a summary.
  • json — the full PreflightReport (ok, results[], summary) as pretty-printed JSON.
  • junit — a JUnit XML document with one <testcase> per check: fail → <failure>, warn → a passing case with a <system-out> note, pass → an empty passing case. XML-1.0-illegal control characters are stripped and the five entities escaped, so arbitrary ATF output can't break the document.
  • sarif — a SARIF 2.1.0 log with one result per non-pass check (fail → error, warn → warning) for code-scanning dashboards.
snpf --format junit > preflight-junit.xml
snpf --format sarif > preflight.sarif
snpf --json | jq '.summary'

CI integration

servicenow-preflight exits non-zero on failure, so a single step gates a pipeline. This GitHub Actions job runs the checks and uploads the SARIF log to code scanning:

name: ServiceNow preflight
on: [workflow_dispatch, pull_request]

jobs:
  preflight:
    runs-on: ubuntu-latest
    permissions:
      security-events: write # to upload SARIF
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Run preflight
        env:
          SNPF_INSTANCE: ${{ secrets.SNPF_INSTANCE }}
          SNPF_USER: ${{ secrets.SNPF_USER }}
          SNPF_PASS: ${{ secrets.SNPF_PASS }}
        run: npx servicenow-preflight --format sarif > preflight.sarif
      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: preflight.sarif

Store credentials as CI secrets — the tool only ever reads them from the environment.

Library API

The public surface is src/index.ts; everything below is exported from the package root.

import { runPreflight, createSnClient } from "servicenow-preflight";

const http = createSnClient({
  instanceUrl: "https://dev12345.service-now.com",
  auth: {
    kind: "basic",
    user: process.env.SNPF_USER!,
    pass: process.env.SNPF_PASS!,
  },
});

const report = await runPreflight({
  instanceUrl: "https://dev12345.service-now.com",
  http,
  scope: "x_acme_app",
  updateSetId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  options: { languages: ["de", "fr"], baseLanguage: "en" },
});

console.log(report.ok, report.summary); // e.g. false { pass: 2, warn: 4, fail: 1 }
  • runPreflight(ctx, checks?) → Promise<PreflightReport>. Defaults to defaultChecks; ctx.select (only / skip by name) filters before they run.
  • selectChecks, defaultChecks, and each individual check (instanceUrlConfigured, connectivityAuth, …) are exported to compose your own list.
  • createSnClient(config) → SnClient ({ instanceUrl, auth?, tls?, timeoutMs?, cicdPollIntervalMs?, cicdMaxPolls?, maxRows?, proxy?, noProxy? }), backed by Node's global fetch (or node:https when tls is set). table(name).query() auto-paginates unless you pass a sysparm_limit, and throws SnTruncationError past maxRows (default 100000) rather than truncating. proxy outranks the HTTPS_PROXY / HTTP_PROXY environment variables; noProxy overrides NO_PROXY.
  • formatJUnit / formatSarif, and loadConfig / resolveAuthFromEnv / resolveTlsFromEnv (the CLI's own resolution).

Checks always call ctx.http, never fetch directly, so createFakeSnClient (an in-memory SnClient) unit-tests them with no network — seed table rows and CI/CD responses, or force error surfaces with the fail fixture. Client helpers throw a small typed hierarchy (all extend SnError) that checks map to results; secrets never appear in the messages:

Error Raised when
SnAuthError HTTP 401 / 403, or missing credentials (.status).
SnNetworkError DNS / connection failure / timeout — instance unreachable.
SnHttpError Any other non-2xx status (.status, .body).
SnResponseError A 2xx carrying a non-JSON body — a hibernating instance's wake-up page or an SSO/proxy interstitial.
SnTruncationError An auto-paginated query() hit maxRows (default 100000) — fails closed instead of silently truncating.

To add your own check, implement the Check interface (a run(ctx) returning a CheckResult, catching every ctx.http error) and pass a custom list to runPreflight, or register it in defaultChecks under src/checks/.

Development

npm install
npm run build        # tsc -> build/
npm test             # node --test (run AFTER build — tests import from build/)
npm run verify       # build + lint + format:check + test
npm run check        # verify + coverage (the full local gate)

ESM ("type": "module") with TypeScript Node16 resolution — relative imports carry the .js extension. Prettier: semi, double quotes, trailingComma: all. Tests (test/**/*.test.js) import compiled output from build/, so build before testing.

Security

  • Credentials are read from the environment only — never from the config file, never logged, never included in error messages.
  • Checks are read-mostly: the only write is running the ATF suites you explicitly configure via options.atfSuites.
  • Zero runtime dependencies — the entire supply chain is this package plus Node.

Support

Built and maintained in my own time. If it saves you or your team time, please consider supporting its continued development — sponsorship directly funds new features, fixes and maintenance.

  • GitHub Sponsors — one-off or recurring, with no platform fee (the preferred option).
  • Ko-fi — quick one-off support; also accepts PayPal, the fallback for anyone without a GitHub account.
  • Donate (Donatree) — a no-account donation page (card, PayPal and more) for a one-off tip.

Sponsor on GitHub Support on Ko-fi Donate via Donatree

License

MIT © Ivan Baev

About

Pre-deployment preflight checks for ServiceNow — validate a target instance and your changes before you ship. Ships as a CLI and a library.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages