@apideck/quickbooks-review-check 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Apideck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,326 @@
1
- # Temporary Holding Version
1
+ # Apideck QuickBooks Review Check
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@apideck/quickbooks-review-check)](https://www.npmjs.com/package/@apideck/quickbooks-review-check)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+
6
+ **Free, open-source self-check for the Intuit QuickBooks Online app security review.** Run it before you submit, and on every deploy after you're listed, so Intuit's yearly re-review doesn't catch you out.
7
+
8
+ It applies to apps for **QuickBooks Online and Intuit Enterprise Suite**: Intuit's IES developer docs say IES apps follow the identical publishing steps and three-part review.
9
+
10
+ Try it in the browser at **[apideck.com/tools/quickbooks-review](https://www.apideck.com/tools/quickbooks-review)**, run it locally with `npx`, or add it to CI as a GitHub Action.
11
+
12
+ > **Disclaimer.** This is a self-assessment aid, not a certification, penetration test, security audit or legal/compliance advice. Passing these checks does not mean an app meets Intuit's requirements or will be approved. Always confirm requirements against Intuit's official developer documentation. Only run runtime checks against apps you own or are authorized to test. Provided "as is" under the MIT License, without warranty.
13
+ >
14
+ > Not affiliated with, endorsed by or sponsored by Intuit Inc. Intuit and QuickBooks are trademarks of Intuit Inc., used only to describe what the tool checks against.
15
+
16
+ ## Contents
17
+
18
+ - [What it checks](#what-it-checks)
19
+ - [Quick start](#quick-start)
20
+ - [GitHub Action guide](#github-action-guide)
21
+ - [Reading the results](#reading-the-results)
22
+ - [CLI reference](#cli-reference)
23
+ - [Troubleshooting](#troubleshooting)
24
+ - [Fixing common findings](#fixing-common-findings)
25
+ - [Using Apideck?](#using-apideck)
26
+ - [Sample apps](#sample-apps)
27
+ - [Development](#development)
28
+
29
+ ## What it checks
30
+
31
+ | | What it checks | How |
32
+ |---|---|---|
33
+ | **Runtime** | HTTPS enforcement, TLS 1.0/1.1/1.2/1.3, certificate, HSTS, `Cache-Control: no-store` (not `private`) on sensitive pages, TRACE/TRACK disabled, `Secure` + `HttpOnly` session cookies, the **302 rule** on your OAuth callback, open redirects, version-banner disclosure, browser security headers | Ordinary HTTP requests and TLS handshakes against your deployed app |
34
+ | **Source** | Refresh token **and realmId** encrypted at rest (AES/3DES), encryption key not inline, tokens or `authResponse` logged, tokens sent to the browser or `localStorage`, a hardcoded `state: 'testState'` (CSRF, from Intuit's sample code), missing state validation, CSRF middleware, TLS verification disabled, weak ciphers (ECB, `createCipher`), SQL and QuickBooks-query string interpolation, XSS sinks, XXE, QuickBooks data near LLM/analytics/APM SDKs | Pattern-based scan of your repo (JS/TS, Python, Ruby, PHP, Java, C#, Go). Nothing is uploaded. |
35
+
36
+ Every result maps to one of Intuit's review areas. Each report ends with a **sign-off checklist** for what no scanner can prove: patching, access control, a penetration test or DAST scan, and the QuickBooks data-usage rules.
37
+
38
+ ## Quick start
39
+
40
+ **In the browser:** [apideck.com/tools/quickbooks-review](https://www.apideck.com/tools/quickbooks-review). Probe a deployed app, or pick a folder to scan in the browser, and download an auditor-style PDF report.
41
+
42
+ **From your terminal** (Node 20 or later, nothing to install):
43
+
44
+ ```bash
45
+ npx @apideck/quickbooks-review-check \
46
+ --url https://app.example.com \
47
+ --callback /oauth/quickbooks/callback \
48
+ --path /dashboard \
49
+ --header "Cookie: session=…" \
50
+ --source .
51
+ ```
52
+
53
+ **In CI:** add `.github/workflows/quickbooks-review.yml`:
54
+
55
+ ```yaml
56
+ name: QuickBooks review check
57
+ on:
58
+ pull_request:
59
+ schedule:
60
+ - cron: '0 6 * * 1' # every Monday
61
+
62
+ jobs:
63
+ check:
64
+ runs-on: ubuntu-latest
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ - uses: apideck-libraries/quickbooks-review-check@v0
68
+ with:
69
+ url: https://app.example.com
70
+ callback: /oauth/quickbooks/callback
71
+ ```
72
+
73
+ The report appears in the workflow run's job summary.
74
+
75
+ ## GitHub Action guide
76
+
77
+ ### Inputs
78
+
79
+ | Input | Default | Description |
80
+ |---|---|---|
81
+ | `url` | | Base URL of the deployed app. Runs the runtime checks. Leave empty to only scan source. |
82
+ | `callback` | | Your OAuth redirect URI path, as registered with Intuit (e.g. `/oauth/quickbooks/callback`). Checks the 302 rule, caching and cookies on it. |
83
+ | `paths` | | Sensitive pages that must not be cached, one per line (e.g. `/dashboard`). |
84
+ | `headers` | | Extra request headers, one per line. Use it to send a session cookie so signed-in pages are checked. Always pass values from secrets. |
85
+ | `source` | `.` | Directory to scan. Set to `''` to skip the source scan. |
86
+ | `fail-on` | `fail` | When the step fails: `fail` (any failed check), `warn` (also on warnings) or `never`. |
87
+
88
+ ### Outputs
89
+
90
+ | Output | Description |
91
+ |---|---|
92
+ | `report` | Path to the Markdown report |
93
+ | `report-json` | Path to the JSON report |
94
+ | `failed` | Number of failed checks |
95
+ | `warnings` | Number of warnings |
96
+ | `passed` | Number of passed checks |
97
+
98
+ The outputs are set even when the step fails, so later steps with `if: always()` can use them.
99
+
100
+ ### Recipe: check every deployment
101
+
102
+ Run after each successful deploy (Vercel, Netlify, Render and others create GitHub deployments) against the URL that was just deployed:
103
+
104
+ ```yaml
105
+ on: deployment_status
106
+
107
+ jobs:
108
+ check:
109
+ if: github.event.deployment_status.state == 'success'
110
+ runs-on: ubuntu-latest
111
+ steps:
112
+ - uses: actions/checkout@v4
113
+ with:
114
+ ref: ${{ github.event.deployment.sha }}
115
+ - uses: apideck-libraries/quickbooks-review-check@v0
116
+ with:
117
+ url: ${{ github.event.deployment_status.environment_url }}
118
+ callback: /oauth/quickbooks/callback
119
+ ```
120
+
121
+ If preview deployments sit behind a login (for example Vercel Deployment Protection), point `url` at production instead, or pass the provider's bypass header through `headers`.
122
+
123
+ ### Recipe: weekly drift check
124
+
125
+ Listed apps are reviewed again by Intuit. A weekly run catches a changed header or proxy setting long before that:
126
+
127
+ ```yaml
128
+ on:
129
+ schedule:
130
+ - cron: '0 6 * * 1'
131
+ workflow_dispatch:
132
+ ```
133
+
134
+ ### Recipe: source-only check on pull requests
135
+
136
+ No deployed URL needed. Catches a new `console.log(token)` or a hardcoded key before merge:
137
+
138
+ ```yaml
139
+ on: pull_request
140
+
141
+ jobs:
142
+ check:
143
+ runs-on: ubuntu-latest
144
+ steps:
145
+ - uses: actions/checkout@v4
146
+ - uses: apideck-libraries/quickbooks-review-check@v0
147
+ with:
148
+ source: .
149
+ ```
150
+
151
+ ### Recipe: check signed-in pages
152
+
153
+ Without a session, sensitive pages usually redirect to your login page, and the cache check can't see them. Create a dedicated test account, copy its session cookie into a repository secret (Settings → Secrets and variables → Actions → `QBRC_SESSION_COOKIE`), then:
154
+
155
+ ```yaml
156
+ - uses: apideck-libraries/quickbooks-review-check@v0
157
+ with:
158
+ url: https://app.example.com
159
+ callback: /oauth/quickbooks/callback
160
+ paths: |
161
+ /dashboard
162
+ /settings/integrations
163
+ headers: |
164
+ Cookie: ${{ secrets.QBRC_SESSION_COOKIE }}
165
+ ```
166
+
167
+ Use a test account with no real customer data, and rotate the cookie when it expires.
168
+
169
+ ### Recipe: keep the report as an artifact
170
+
171
+ ```yaml
172
+ - id: qbo
173
+ uses: apideck-libraries/quickbooks-review-check@v0
174
+ with:
175
+ url: https://app.example.com
176
+ - if: always()
177
+ uses: actions/upload-artifact@v4
178
+ with:
179
+ name: quickbooks-review
180
+ path: |
181
+ ${{ steps.qbo.outputs.report }}
182
+ ${{ steps.qbo.outputs.report-json }}
183
+ ```
184
+
185
+ ### Recipe: alert on failures without blocking
186
+
187
+ ```yaml
188
+ - id: qbo
189
+ uses: apideck-libraries/quickbooks-review-check@v0
190
+ with:
191
+ url: https://app.example.com
192
+ fail-on: never
193
+ - if: steps.qbo.outputs.failed != '0'
194
+ run: |
195
+ curl -X POST -H 'Content-type: application/json' \
196
+ --data "{\"text\":\"QuickBooks review check: ${{ steps.qbo.outputs.failed }} failed, ${{ steps.qbo.outputs.warnings }} warnings\"}" \
197
+ "${{ secrets.SLACK_WEBHOOK_URL }}"
198
+ ```
199
+
200
+ ## Reading the results
201
+
202
+ Each check has one of five statuses:
203
+
204
+ | Status | Meaning |
205
+ |---|---|
206
+ | **Fail** | The control is missing or wrong. Expect Intuit to flag it. |
207
+ | **Warn** | Partly in place, or worth a look (source patterns can be false positives). |
208
+ | **Pass** | The control is in place. |
209
+ | **Info** | Context, such as the HTTP methods your server advertises. |
210
+ | **Skip** | Not checked, for example no cookies were seen or no callback was given. |
211
+
212
+ Results are grouped by Intuit's review areas: server and transport, attack vulnerabilities, cookies and sessions, OAuth token management, the token-endpoint redirect rule, and QuickBooks data usage. Each finding includes the evidence (the response header or `file:line`) and a fix.
213
+
214
+ The website version adds a PDF report with severities (High, Medium, Low), evidence, remediation and a sign-off page, which is useful to keep with your submission. Intuit requires critical, high and medium issues found in its review to be fixed before an app is published.
215
+
216
+ **Exit codes:** `0` all good (for the chosen `--fail-on`), `1` failures found, `2` usage error.
217
+
218
+ ## CLI reference
219
+
220
+ ```text
221
+ npx @apideck/quickbooks-review-check [options]
222
+
223
+ Runtime checks (probe your deployed app):
224
+ --url <url> Base URL of your deployed app
225
+ --callback <path> OAuth redirect URI path (checks the 302 rule, cache headers, cookies)
226
+ --path <path> Sensitive page to check for no-store caching (repeatable)
227
+ --header "K: V" Extra request header, e.g. a session cookie (repeatable)
228
+ --timeout <ms> Per-request timeout (default 10000)
229
+ --http-port <port> Port for the plain-HTTP redirect check (default 80; for local testing)
230
+
231
+ Source checks (static scan of your code):
232
+ --source <dir> Scan a repository for token-handling issues
233
+ --include-tests Also scan test/example files
234
+
235
+ Output:
236
+ --markdown <file> Write a Markdown report ("-" for stdout)
237
+ --json <file> Write a JSON report ("-" for stdout)
238
+ --fail-on <level> Exit 1 on: fail (default) | warn | never
239
+ -q, --quiet Don't print the console report
240
+ ```
241
+
242
+ Examples:
243
+
244
+ ```bash
245
+ # Source only, Markdown report to a file
246
+ npx @apideck/quickbooks-review-check --source . --markdown quickbooks-review.md
247
+
248
+ # Runtime only, JSON to stdout for scripting
249
+ npx @apideck/quickbooks-review-check --url https://app.example.com --json - --quiet | jq '.summary'
250
+
251
+ # Fail the build on warnings too
252
+ npx @apideck/quickbooks-review-check --source . --fail-on warn
253
+ ```
254
+
255
+ ## Troubleshooting
256
+
257
+ **"Callback /oauth/callback returned 404".** The `callback` path must match the Redirect URI registered in the Intuit developer portal, exactly.
258
+
259
+ **"Cache-Control not checked on /dashboard".** The page redirected, usually to your login. Pass a session cookie with `headers` / `--header`.
260
+
261
+ **The callback answers 4xx with JSON.** That passes. It means your app rejected the fake test code without rendering a page. A real authorization should still end in a 302, which you can confirm with a real connect flow.
262
+
263
+ **"TLS 1.0 not testable".** Your runner's OpenSSL can't offer TLS 1.0. The other TLS results are still valid.
264
+
265
+ **TRACE shows as refused.** A firewall or CDN blocking TRACE counts as disabled, which is what Intuit wants.
266
+
267
+ **The source scan flags something that's fine.** It matches patterns, so for example an `innerHTML` that only renders static text shows as a warning. Treat warnings as prompts to look.
268
+
269
+ **Internal or private URLs.** The hosted scanner on apideck.com only checks public hosts on ports 80/443. Run the CLI or the Action inside your network for anything else.
270
+
271
+ ## Fixing common findings
272
+
273
+ The **[requirements guide](https://www.apideck.com/tools/quickbooks-review/checklist)** explains every check, with a command to verify it and fixes for Express, Next.js, nginx, Django and Rails. The fix Intuit reviewers ask about most is encrypting the refresh token and realmId with AES (preferred) or 3DES, with the key kept out of your code. In Node:
274
+
275
+ ```js
276
+ import crypto from 'node:crypto';
277
+ const key = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'base64'); // 32 bytes: `openssl rand -base64 32`
278
+
279
+ export function encrypt(plain) {
280
+ const iv = crypto.randomBytes(12);
281
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
282
+ const data = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
283
+ return Buffer.concat([iv, c.getAuthTag(), data]).toString('base64');
284
+ }
285
+
286
+ export function decrypt(blob) {
287
+ const buf = Buffer.from(blob, 'base64');
288
+ const d = crypto.createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
289
+ d.setAuthTag(buf.subarray(12, 28));
290
+ return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
291
+ }
292
+ ```
293
+
294
+ ## Using Apideck?
295
+
296
+ If your app connects to QuickBooks through [Apideck](https://www.apideck.com/accounting-api), you still register your own Intuit app and go through Intuit's review, but the OAuth redirect URI, token exchange, token storage and refresh run on Apideck Vault. Your app never holds the Intuit tokens, and the scanner recognizes this setup. You still own the runtime checks (HTTPS, cookies, cache headers) and the data-usage rules for your app, and this tool covers those.
297
+
298
+ ## Sample apps
299
+
300
+ `examples/insecure-app` and `examples/secure-app` are two small QuickBooks apps (Node built-ins only) that the end-to-end test runs over real TLS:
301
+
302
+ - **insecure-app** does what many apps start with: Intuit's sample OAuth code with `state: 'testState'`, tokens logged and stored in plain text, an HTML callback page, TRACE echoed, TLS 1.0 accepted, `Cache-Control: private`, cookies without flags, an open redirect and plain HTTP. **Never deploy it.**
303
+ - **secure-app** fixes every one of those.
304
+
305
+ ```bash
306
+ eval "$(node examples/certs.js)" && export TLS_KEY TLS_CERT NODE_EXTRA_CA_CERTS
307
+ node examples/insecure-app/server.js & # https://localhost:8443, http://localhost:8080
308
+ node bin/quickbooks-review-check.js --url https://localhost:8443 --http-port 8080 --callback /oauth/callback --path /dashboard --source examples/insecure-app
309
+ ```
310
+
311
+ `node examples/record.js <dir>` re-records both apps' scan results and source files for the website's "Try a sample" buttons.
312
+
313
+ ## Releasing
314
+
315
+ Bump `version` in `package.json`, merge, then push a matching tag (`git tag v0.2.0 && git push origin v0.2.0`). The Release workflow runs the tests, publishes to npm with provenance via trusted publishing, and moves the major tag (`v0`) the GitHub Action uses.
316
+
317
+ ## Development
318
+
319
+ ```bash
320
+ npm test # node:test, zero dependencies; includes the end-to-end test against both sample apps
321
+ node bin/quickbooks-review-check.js --source test/fixtures/insecure --fail-on never
322
+ ```
323
+
324
+ Issues and pull requests for new checks are welcome.
325
+
326
+ MIT © Apideck
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ // exitCode rather than process.exit(), so piped stdout (e.g. `--json - | jq`) is fully flushed.
5
+ main(process.argv.slice(2)).then(
6
+ (code) => {
7
+ process.exitCode = code;
8
+ },
9
+ (err) => {
10
+ console.error(err);
11
+ process.exitCode = 2;
12
+ },
13
+ );
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@apideck/quickbooks-review-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Free self-check for the Intuit QuickBooks Online app security review: runtime probes + source scan, as a CLI and GitHub Action.",
5
+ "type": "module",
6
+ "bin": {
7
+ "quickbooks-review-check": "bin/quickbooks-review-check.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test test/*.test.js"
20
+ },
21
+ "keywords": [
22
+ "quickbooks",
23
+ "intuit",
24
+ "security",
25
+ "app-review",
26
+ "oauth",
27
+ "github-action"
28
+ ],
29
+ "author": "Apideck",
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/apideck-libraries/quickbooks-review-check.git"
37
+ },
38
+ "homepage": "https://www.apideck.com/tools/quickbooks-review"
39
+ }
package/src/cli.js ADDED
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import { parseArgs } from 'node:util';
3
+ import { runRuntime } from './runtime.js';
4
+ import { scanSource } from './source.js';
5
+ import { toConsole, toMarkdown, toJson, summarize } from './report.js';
6
+
7
+ const HELP = `quickbooks-review-check — free self-check for the Intuit QuickBooks Online app security review
8
+
9
+ Usage:
10
+ quickbooks-review-check --url https://app.example.com --callback /oauth/callback [--source .]
11
+ quickbooks-review-check --source ./my-app
12
+
13
+ Runtime checks (probe your deployed app):
14
+ --url <url> Base URL of your deployed app
15
+ --callback <path> OAuth redirect URI path (checks the 302 rule, cache headers, cookies)
16
+ --path <path> Sensitive page to check for no-store caching (repeatable)
17
+ --header "K: V" Extra request header, e.g. an authenticated session cookie (repeatable)
18
+ --timeout <ms> Per-request timeout (default 10000)
19
+ --http-port <port> Port for the plain-HTTP redirect check (default 80; for local testing)
20
+
21
+ Source checks (static scan of your code):
22
+ --source <dir> Scan a repository for token-handling issues
23
+ --include-tests Also scan test/example files
24
+
25
+ Output:
26
+ --markdown <file> Write a Markdown report ("-" for stdout)
27
+ --json <file> Write a JSON report ("-" for stdout)
28
+ --fail-on <level> Exit 1 on: fail (default) | warn | never
29
+ -q, --quiet Don't print the console report
30
+ -h, --help
31
+ `;
32
+
33
+ export async function main(argv) {
34
+ let args;
35
+ try {
36
+ ({ values: args } = parseArgs({
37
+ args: argv,
38
+ options: {
39
+ url: { type: 'string' },
40
+ callback: { type: 'string' },
41
+ path: { type: 'string', multiple: true, default: [] },
42
+ header: { type: 'string', multiple: true, default: [] },
43
+ timeout: { type: 'string', default: '10000' },
44
+ 'http-port': { type: 'string' },
45
+ source: { type: 'string' },
46
+ 'include-tests': { type: 'boolean', default: false },
47
+ markdown: { type: 'string' },
48
+ json: { type: 'string' },
49
+ 'fail-on': { type: 'string', default: 'fail' },
50
+ quiet: { type: 'boolean', short: 'q', default: false },
51
+ verbose: { type: 'boolean', short: 'v', default: false },
52
+ help: { type: 'boolean', short: 'h', default: false },
53
+ },
54
+ }));
55
+ } catch (e) {
56
+ console.error(`${e.message}\n\n${HELP}`);
57
+ return 2;
58
+ }
59
+ if (args.help || (!args.url && !args.source)) {
60
+ console.log(HELP);
61
+ return args.help ? 0 : 2;
62
+ }
63
+
64
+ const headers = {};
65
+ for (const h of args.header) {
66
+ const i = h.indexOf(':');
67
+ if (i > 0) headers[h.slice(0, i).trim().toLowerCase()] = h.slice(i + 1).trim();
68
+ }
69
+ const log = args.verbose ? (m) => console.error(`· ${m}`) : () => {};
70
+
71
+ const results = [];
72
+ if (args.url) {
73
+ const url = /^https?:\/\//.test(args.url) ? args.url : `https://${args.url}`;
74
+ results.push(...(await runRuntime({ url, paths: args.path, callback: args.callback, headers, timeout: Number(args.timeout), log, httpPort: args['http-port'] ? Number(args['http-port']) : undefined })));
75
+ }
76
+ if (args.source) {
77
+ const { results: src, scanned } = scanSource(args.source, { includeTests: args['include-tests'] });
78
+ log(`scanned ${scanned} files`);
79
+ results.push(...src);
80
+ }
81
+
82
+ const report = { target: args.url, source: args.source, results };
83
+ if (!args.quiet) console.log(toConsole(report));
84
+ writeOut(args.markdown, () => toMarkdown(report));
85
+ writeOut(args.json, () => toJson(report));
86
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${toMarkdown(report)}\n`);
87
+
88
+ const c = summarize(results);
89
+ if (args['fail-on'] === 'never') return 0;
90
+ if (c.fail || (args['fail-on'] === 'warn' && c.warn)) return 1;
91
+ return 0;
92
+ }
93
+
94
+ function writeOut(dest, render) {
95
+ if (!dest) return;
96
+ if (dest === '-') process.stdout.write(`${render()}\n`);
97
+ else fs.writeFileSync(dest, render());
98
+ }
package/src/guard.js ADDED
@@ -0,0 +1,75 @@
1
+ import dns from 'node:dns';
2
+ import net from 'node:net';
3
+
4
+ // SSRF guard for running runtime checks as a hosted service: only public hosts on standard ports,
5
+ // and every connection resolves through a lookup that re-checks the IP (defeats DNS rebinding).
6
+
7
+ const V4_BLOCKED = [
8
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
9
+ ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
10
+ ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
11
+ ];
12
+
13
+ const v4ToInt = (ip) => ip.split('.').reduce((n, o) => (n << 8) + Number(o), 0) >>> 0;
14
+
15
+ export function isPublicIp(ip) {
16
+ if (net.isIPv4(ip)) {
17
+ const n = v4ToInt(ip);
18
+ return !V4_BLOCKED.some(([base, bits]) => (n >>> (32 - bits)) === (v4ToInt(base) >>> (32 - bits)));
19
+ }
20
+ if (net.isIPv6(ip)) {
21
+ const lower = ip.toLowerCase();
22
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
23
+ if (mapped) return isPublicIp(mapped[1]);
24
+ if (lower === '::' || lower === '::1') return false;
25
+ // fc00::/7 unique-local, fe80::/10 link-local, ff00::/8 multicast, 2001:db8::/32 docs, 64:ff9b::/96 NAT64
26
+ return !/^(f[cd]|fe[89ab]|ff|2001:db8|64:ff9b)/.test(lower);
27
+ }
28
+ return false;
29
+ }
30
+
31
+ export class TargetError extends Error {}
32
+
33
+ export function validateTarget(raw) {
34
+ let u;
35
+ try {
36
+ u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
37
+ } catch {
38
+ throw new TargetError('Enter a valid URL, like https://app.example.com');
39
+ }
40
+ if (!['http:', 'https:'].includes(u.protocol)) throw new TargetError('Only http and https URLs can be checked.');
41
+ if (u.username || u.password) throw new TargetError('Remove credentials from the URL.');
42
+ if (u.port && !['80', '443'].includes(u.port)) throw new TargetError('The hosted scanner only checks standard ports (80/443). Use the CLI for other ports.');
43
+ const host = u.hostname.replace(/^\[|\]$/g, '');
44
+ if (net.isIP(host) && !isPublicIp(host)) throw new TargetError('Private and reserved IP addresses cannot be scanned.');
45
+ if (/^(localhost|.*\.localhost|.*\.local|.*\.internal|.*\.lan|.*\.home\.arpa)$/i.test(host) || !host.includes('.')) {
46
+ throw new TargetError('Internal hostnames cannot be scanned. Use the CLI inside your network instead.');
47
+ }
48
+ u.hash = '';
49
+ return u;
50
+ }
51
+
52
+ // dns.lookup-compatible function that refuses non-public answers.
53
+ export function safeLookup(hostname, options, callback) {
54
+ if (typeof options === 'function') {
55
+ callback = options;
56
+ options = {};
57
+ }
58
+ dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
59
+ if (err) return callback(err);
60
+ const bad = addresses.find((a) => !isPublicIp(a.address));
61
+ if (bad || !addresses.length) {
62
+ const e = new Error(`${hostname} resolves to a non-public address`);
63
+ e.code = 'EBLOCKED';
64
+ return callback(e);
65
+ }
66
+ if (options.all) return callback(null, addresses);
67
+ callback(null, addresses[0].address, addresses[0].family);
68
+ });
69
+ }
70
+
71
+ export function assertPublicHost(hostname) {
72
+ return new Promise((resolve, reject) => {
73
+ safeLookup(hostname, { all: true }, (err, addrs) => (err ? reject(new TargetError(err.code === 'EBLOCKED' ? 'That host resolves to a private address and cannot be scanned.' : `Could not resolve ${hostname}.`)) : resolve(addrs)));
74
+ });
75
+ }