@apideck/quickbooks-review-check 0.0.0-stage → 0.1.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,111 @@
1
- # Temporary Holding Version
1
+ # 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
+ **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 the annual re-review doesn't catch you out.
4
+
5
+ It does two kinds of checks, both free:
6
+
7
+ | | What it checks | How |
8
+ |---|---|---|
9
+ | **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 | Probes your deployed app from outside |
10
+ | **Source** | Refresh token **and realmId** encrypted at rest (AES/3DES), AES key not inline, tokens or `authResponse` logged, tokens sent to the browser / `localStorage`, hardcoded `state: 'testState'` (CSRF in Intuit's sample code), missing state validation, CSRF middleware, TLS verification disabled, weak ciphers (ECB, `createCipher`), SQL/QBO-query string interpolation, XSS sinks, XXE, QuickBooks data flowing into LLM/analytics/APM SDKs | Static scan of your repo (JS/TS, Python, Ruby, PHP, Java, C#, Go). No code leaves your machine. |
11
+
12
+ Every result maps to one of Intuit's review categories. Each report ends with a **manual attestation checklist** for the items no scanner can prove: patching, access control, DAST, and the data-usage rules.
13
+
14
+ > **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.
15
+ >
16
+ > 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.
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ npx @apideck/quickbooks-review-check \
22
+ --url https://app.example.com \
23
+ --callback /oauth/quickbooks/callback \
24
+ --path /dashboard --path /settings/quickbooks \
25
+ --header "Cookie: session=…" \
26
+ --source .
27
+ ```
28
+
29
+ - `--callback` is the Redirect URI you registered with Intuit. The tool calls it with a fake `code`, `state` and `realmId`, and checks that it answers with a `302`, not HTML.
30
+ - `--path` names sensitive pages that must send `no-store`. Pass a session cookie with `--header` so they're checked while signed in, not as a redirect to the login page.
31
+ - `--markdown report.md` and `--json report.json` write reports. `--fail-on warn|fail|never` controls the exit code.
32
+
33
+ ## GitHub Action
34
+
35
+ ```yaml
36
+ name: QuickBooks review check
37
+ on:
38
+ deployment_status:
39
+ schedule: [{ cron: '0 6 * * 1' }] # weekly, catches drift before Intuit's annual re-review
40
+ pull_request:
41
+
42
+ jobs:
43
+ check:
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+ - uses: apideck-libraries/quickbooks-review-check@v0
48
+ with:
49
+ url: https://app.example.com
50
+ callback: /oauth/quickbooks/callback
51
+ paths: |
52
+ /dashboard
53
+ /settings/quickbooks
54
+ headers: |
55
+ Cookie: ${{ secrets.QBRC_SESSION_COOKIE }}
56
+ source: .
57
+ fail-on: fail
58
+ ```
59
+
60
+ The Markdown report is written to the job summary. To scan source only (e.g. on pull requests), omit `url`.
61
+
62
+ ## Encrypting the refresh token (Node)
63
+
64
+ Intuit requires the refresh token and realmId to be encrypted with AES (preferred) or 3DES, with the key kept out of your code:
65
+
66
+ ```js
67
+ import crypto from 'node:crypto';
68
+ const key = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'base64'); // 32 bytes: `openssl rand -base64 32`
69
+
70
+ export function encrypt(plain) {
71
+ const iv = crypto.randomBytes(12);
72
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
73
+ const data = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
74
+ return Buffer.concat([iv, c.getAuthTag(), data]).toString('base64');
75
+ }
76
+
77
+ export function decrypt(blob) {
78
+ const buf = Buffer.from(blob, 'base64');
79
+ const d = crypto.createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
80
+ d.setAuthTag(buf.subarray(12, 28));
81
+ return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
82
+ }
83
+ ```
84
+
85
+ ## Using Apideck?
86
+
87
+ If your app connects to QuickBooks through [Apideck Vault](https://www.apideck.com), Apideck stores and encrypts the Intuit OAuth tokens and realmId. Your app never holds them, and the scanner recognizes this setup. Ask Apideck for its security documentation if your reviewer wants details on token storage. You still own the runtime checks (HTTPS, cookies, cache headers) and the data-usage rules for your app, and this tool covers those.
88
+
89
+ ## Sample apps
90
+
91
+ `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:
92
+
93
+ - **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.**
94
+ - **secure-app** fixes every one of those.
95
+
96
+ ```bash
97
+ eval "$(node examples/certs.js)" && export TLS_KEY TLS_CERT NODE_EXTRA_CA_CERTS
98
+ node examples/insecure-app/server.js & # https://localhost:8443, http://localhost:8080
99
+ node bin/quickbooks-review-check.js --url https://localhost:8443 --http-port 8080 --callback /oauth/callback --path /dashboard --source examples/insecure-app
100
+ ```
101
+
102
+ `node examples/record.js <dir>` re-records both apps' scan results and source files for the website's "Try a sample" buttons.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ npm test # node:test, zero dependencies
108
+ node bin/quickbooks-review-check.js --source test/fixtures/insecure --fail-on never
109
+ ```
110
+
111
+ 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.1.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
+ }
package/src/http.js ADDED
@@ -0,0 +1,111 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import tls from 'node:tls';
4
+
5
+ const BODY_LIMIT = 64 * 1024;
6
+
7
+ // Low-level request that never follows redirects and supports any method (fetch rejects TRACE).
8
+ export function request(url, { method = 'GET', headers = {}, timeout = 10000, insecure = false, lookup } = {}) {
9
+ const u = new URL(url);
10
+ const mod = u.protocol === 'http:' ? http : https;
11
+ return new Promise((resolve, reject) => {
12
+ const req = mod.request(
13
+ u,
14
+ {
15
+ method,
16
+ headers: { 'user-agent': 'quickbooks-review-check/0.1 (+https://github.com/apideck-libraries/quickbooks-review-check)', ...headers },
17
+ timeout,
18
+ rejectUnauthorized: !insecure,
19
+ ...(lookup && { lookup }),
20
+ },
21
+ (res) => {
22
+ const chunks = [];
23
+ let size = 0;
24
+ res.on('data', (c) => {
25
+ if (size < BODY_LIMIT) chunks.push(c);
26
+ size += c.length;
27
+ });
28
+ res.on('end', () => {
29
+ const raw = res.headers['set-cookie'] || [];
30
+ resolve({
31
+ url,
32
+ status: res.statusCode,
33
+ headers: res.headers,
34
+ setCookies: Array.isArray(raw) ? raw : [raw],
35
+ body: Buffer.concat(chunks).toString('utf8').slice(0, BODY_LIMIT),
36
+ });
37
+ });
38
+ res.on('error', reject);
39
+ },
40
+ );
41
+ req.on('timeout', () => req.destroy(new Error(`timeout after ${timeout}ms`)));
42
+ req.on('error', reject);
43
+ req.end();
44
+ });
45
+ }
46
+
47
+ // Try a TLS handshake pinned to one protocol version. Resolves { ok, protocol, cipher } or { ok:false, error }.
48
+ // SECLEVEL=0 lets modern OpenSSL builds still *offer* TLS 1.0/1.1 so we can detect servers that accept them.
49
+ export function tlsHandshake(host, port, version, { timeout = 8000, lookup } = {}) {
50
+ return new Promise((resolve) => {
51
+ let settled = false;
52
+ const done = (v) => {
53
+ if (!settled) {
54
+ settled = true;
55
+ resolve(v);
56
+ }
57
+ };
58
+ let socket;
59
+ try {
60
+ socket = tls.connect({
61
+ host,
62
+ port,
63
+ servername: host,
64
+ minVersion: version,
65
+ maxVersion: version,
66
+ ciphers: version === 'TLSv1.3' ? undefined : 'DEFAULT:@SECLEVEL=0',
67
+ rejectUnauthorized: false,
68
+ timeout,
69
+ ...(lookup && { lookup }),
70
+ });
71
+ } catch (e) {
72
+ return done({ ok: false, error: e.message, clientUnsupported: true });
73
+ }
74
+ socket.once('secureConnect', () => {
75
+ const res = { ok: true, protocol: socket.getProtocol(), cipher: socket.getCipher(), authorized: socket.authorized, authError: socket.authorizationError };
76
+ socket.end();
77
+ done(res);
78
+ });
79
+ socket.once('timeout', () => {
80
+ socket.destroy();
81
+ done({ ok: false, error: 'timeout' });
82
+ });
83
+ socket.once('error', (e) => {
84
+ // A client-side "no protocols available" means this Node/OpenSSL build cannot test the version.
85
+ const clientUnsupported = /no protocols available|unsupported protocol/i.test(e.message) && !/alert/i.test(e.message);
86
+ done({ ok: false, error: e.message, clientUnsupported });
87
+ });
88
+ });
89
+ }
90
+
91
+ // Parse a Set-Cookie header into { name, attrs: Set<lowercase attr names>, sameSite }.
92
+ export function parseSetCookie(header) {
93
+ const parts = header.split(';').map((p) => p.trim());
94
+ const [nameValue, ...attrs] = parts;
95
+ const name = nameValue.split('=')[0].trim();
96
+ const attrNames = new Set();
97
+ let sameSite = null;
98
+ for (const a of attrs) {
99
+ const [k, v] = a.split('=');
100
+ const key = k.trim().toLowerCase();
101
+ attrNames.add(key);
102
+ if (key === 'samesite') sameSite = (v || '').trim().toLowerCase();
103
+ }
104
+ return { name, attrs: attrNames, sameSite, raw: header };
105
+ }
106
+
107
+ export function isHtml(res) {
108
+ const ct = String(res.headers['content-type'] || '').toLowerCase();
109
+ if (ct.includes('text/html') || ct.includes('application/xhtml')) return res.body.trim().length > 0;
110
+ return /^\s*(<!doctype html|<html)/i.test(res.body);
111
+ }
package/src/report.js ADDED
@@ -0,0 +1,78 @@
1
+ import { CATEGORIES, REQUIREMENTS, MANUAL_CHECKS } from './requirements.js';
2
+
3
+ const ICON = { pass: '✅', fail: '❌', warn: '⚠️', info: 'ℹ️', skip: '⏭️' };
4
+ const ORDER = { fail: 0, warn: 1, pass: 2, info: 3, skip: 4 };
5
+
6
+ export function summarize(results) {
7
+ const counts = { pass: 0, fail: 0, warn: 0, info: 0, skip: 0 };
8
+ for (const r of results) counts[r.status]++;
9
+ return counts;
10
+ }
11
+
12
+ function grouped(results) {
13
+ const byCat = new Map(Object.keys(CATEGORIES).map((c) => [c, []]));
14
+ for (const r of results) byCat.get(REQUIREMENTS[r.requirement]?.category || 'transport').push(r);
15
+ for (const list of byCat.values()) list.sort((a, b) => ORDER[a.status] - ORDER[b.status]);
16
+ return byCat;
17
+ }
18
+
19
+ export function toJson({ target, source, results }) {
20
+ return JSON.stringify({ tool: 'quickbooks-review-check', version: '0.1.0', generatedAt: new Date().toISOString(), target, source, summary: summarize(results), results, manualChecks: MANUAL_CHECKS.map((m) => ({ ...m, requirement: REQUIREMENTS[m.id].title })) }, null, 2);
21
+ }
22
+
23
+ export function toMarkdown({ target, source, results }) {
24
+ const c = summarize(results);
25
+ const verdict = c.fail ? `❌ **${c.fail} blocking issue${c.fail > 1 ? 's' : ''}** — Intuit would likely send this back.` : c.warn ? '⚠️ **No blocking issues**, but review the warnings before submitting.' : '✅ **All automated checks passed.**';
26
+ const lines = [
27
+ '# QuickBooks App Security Review — self-check',
28
+ '',
29
+ [target && `**App:** ${target}`, source && `**Source:** \`${source}\``].filter(Boolean).join(' · '),
30
+ '',
31
+ verdict,
32
+ '',
33
+ `| ✅ Pass | ❌ Fail | ⚠️ Warn | ⏭️ Skipped |`,
34
+ `|---|---|---|---|`,
35
+ `| ${c.pass} | ${c.fail} | ${c.warn} | ${c.skip} |`,
36
+ '',
37
+ ];
38
+ for (const [cat, list] of grouped(results)) {
39
+ if (!list.length) continue;
40
+ lines.push(`## ${CATEGORIES[cat]}`, '');
41
+ for (const r of list) {
42
+ lines.push(`- ${ICON[r.status]} **${r.title}** — ${md(r.detail)}`);
43
+ if (r.fix && (r.status === 'fail' || r.status === 'warn')) lines.push(` - _Fix:_ ${r.fix}`);
44
+ for (const l of r.locations || []) lines.push(` - \`${l.file}:${l.line}\` ${l.snippet ? `— \`${l.snippet.replace(/`/g, "'")}\`` : ''}`);
45
+ }
46
+ lines.push('');
47
+ }
48
+ lines.push('## Manual attestation (Intuit will ask)', '', 'These cannot be proven by a scanner. Tick them off before you submit:', '');
49
+ for (const m of MANUAL_CHECKS) lines.push(`- [ ] ${m.prompt}`);
50
+ lines.push('', '---', '_Generated by [quickbooks-review-check](https://github.com/apideck-libraries/quickbooks-review-check), a free tool from [Apideck](https://www.apideck.com). Self-assessment aid only, provided as is. Not affiliated with or endorsed by Intuit Inc.; results do not guarantee approval. QuickBooks is a trademark of Intuit Inc._');
51
+ return lines.join('\n');
52
+ }
53
+
54
+ const md = (s) => String(s || '').replace(/\|/g, '\\|');
55
+
56
+ const color = (code) => (s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
57
+ const C = { pass: color(32), fail: color(31), warn: color(33), info: color(36), skip: color(90), bold: color(1), dim: color(90) };
58
+ const TAG = { pass: 'PASS', fail: 'FAIL', warn: 'WARN', info: 'INFO', skip: 'SKIP' };
59
+
60
+ export function toConsole({ target, source, results }) {
61
+ const out = [C.bold('QuickBooks App Security Review — self-check'), C.dim([target, source].filter(Boolean).join(' · ')), ''];
62
+ for (const [cat, list] of grouped(results)) {
63
+ if (!list.length) continue;
64
+ out.push(C.bold(CATEGORIES[cat]));
65
+ for (const r of list) {
66
+ out.push(` ${C[r.status](TAG[r.status].padEnd(4))} ${r.title}`);
67
+ if (r.status !== 'pass') out.push(C.dim(` ${r.detail}`));
68
+ if (r.fix && (r.status === 'fail' || r.status === 'warn')) out.push(` → ${r.fix}`);
69
+ for (const l of (r.locations || []).slice(0, 5)) out.push(C.dim(` ${l.file}:${l.line} ${l.snippet || ''}`));
70
+ if ((r.locations || []).length > 5) out.push(C.dim(` … ${r.locations.length - 5} more`));
71
+ }
72
+ out.push('');
73
+ }
74
+ const c = summarize(results);
75
+ out.push(`${C.pass(`${c.pass} passed`)} ${C.fail(`${c.fail} failed`)} ${C.warn(`${c.warn} warnings`)} ${C.skip(`${c.skip} skipped`)}`);
76
+ out.push(C.dim(`Plus ${MANUAL_CHECKS.length} manual attestation items — run with --markdown to get the checklist.`));
77
+ return out.join('\n');
78
+ }
@@ -0,0 +1,111 @@
1
+ // Catalog of Intuit QuickBooks Online app security-review requirements.
2
+ // Each automated check references one of these ids so reports group by Intuit's categories.
3
+ // Source: Intuit Developer "Security requirements" for publishing an app (paraphrased).
4
+
5
+ export const CATEGORIES = {
6
+ transport: 'Server & transport',
7
+ attacks: 'Attack vulnerabilities',
8
+ cookies: 'Cookies & sessions',
9
+ oauth: 'OAuth token management',
10
+ redirect: 'Token-endpoint redirect rule',
11
+ data: 'QuickBooks data usage',
12
+ };
13
+
14
+ export const REQUIREMENTS = {
15
+ 'https-everywhere': {
16
+ category: 'transport',
17
+ title: 'HTTPS enforced on every page',
18
+ text: 'All pages of the app must be served over HTTPS.',
19
+ },
20
+ 'tls-version': {
21
+ category: 'transport',
22
+ title: 'TLS 1.1 minimum, TLS 1.2+ recommended',
23
+ text: 'SSL must support TLS 1.1 or higher; TLS 1.2 with AES-256 and SHA-256 is recommended.',
24
+ },
25
+ 'cache-control': {
26
+ category: 'transport',
27
+ title: 'Caching disabled on sensitive pages',
28
+ text: 'Use Cache-Control no-cache and no-store (not private) on SSL and sensitive pages.',
29
+ },
30
+ 'http-methods': {
31
+ category: 'transport',
32
+ title: 'TRACE and unused HTTP methods disabled',
33
+ text: 'The web server must disable TRACE and other HTTP methods that are not used.',
34
+ },
35
+ 'patching-logging': {
36
+ category: 'transport',
37
+ title: 'Patches current; no credential or QuickBooks data logging',
38
+ text: 'OS, web server and app server patches must be current. Never log user credentials or QuickBooks data.',
39
+ },
40
+ csrf: {
41
+ category: 'attacks',
42
+ title: 'Cross-Site Request Forgery protection',
43
+ text: 'State-changing requests (including the OAuth flow) must be protected against CSRF.',
44
+ },
45
+ xss: {
46
+ category: 'attacks',
47
+ title: 'Cross-Site Scripting (reflected & stored)',
48
+ text: 'Validate input and encode output to prevent reflected and stored XSS.',
49
+ },
50
+ injection: {
51
+ category: 'attacks',
52
+ title: 'SQL and XML injection',
53
+ text: 'Use parameterized queries and safe XML parsing.',
54
+ },
55
+ 'auth-session': {
56
+ category: 'attacks',
57
+ title: 'Authentication, session management, access control',
58
+ text: 'Authentication, session management and functional-level access control must be robust.',
59
+ },
60
+ 'open-redirect': {
61
+ category: 'attacks',
62
+ title: 'Validated forwards and redirects',
63
+ text: 'Redirect logic must be validated so it cannot be abused as an open redirect.',
64
+ },
65
+ 'cookie-flags': {
66
+ category: 'cookies',
67
+ title: 'Secure and HttpOnly on session cookies',
68
+ text: 'All session cookies must set the Secure and HttpOnly attributes.',
69
+ },
70
+ 'token-exposure': {
71
+ category: 'oauth',
72
+ title: 'Tokens never exposed or shared',
73
+ text: 'Intuit OAuth tokens and customer-identifying information must not be exposed in the app or shared with other parties.',
74
+ },
75
+ 'token-encryption': {
76
+ category: 'oauth',
77
+ title: 'Refresh token and realmId encrypted at rest',
78
+ text: 'Refresh token and realmId must be encrypted (AES preferred, or 3DES) and stored in persistent storage.',
79
+ },
80
+ 'key-storage': {
81
+ category: 'oauth',
82
+ title: 'Encryption key kept in separate configuration',
83
+ text: 'The AES key must be stored in a separate configuration file, not inline in code.',
84
+ },
85
+ 'token-endpoint-302': {
86
+ category: 'redirect',
87
+ title: 'Token-bearing endpoints respond with 302, not HTML',
88
+ text: 'Endpoints receiving tokens or sensitive data in URL parameters must not return HTML; they should 302 redirect to avoid Referer leaks.',
89
+ },
90
+ 'no-third-party-access': {
91
+ category: 'data',
92
+ title: 'No third-party access to QuickBooks data',
93
+ text: 'The app must not give third parties access to customer QuickBooks data, via APIs or any other means.',
94
+ },
95
+ 'data-minimization': {
96
+ category: 'data',
97
+ title: 'Store QuickBooks data only for functional use',
98
+ text: 'Do not export, save or store QuickBooks data for any purpose other than the functional use of the app.',
99
+ },
100
+ };
101
+
102
+ // Items no scanner can prove. These are printed as an attestation checklist in every report.
103
+ export const MANUAL_CHECKS = [
104
+ { id: 'patching-logging', prompt: 'OS, web server, runtime and dependencies are on current patch levels (e.g. `npm audit`, Dependabot, base image updates).' },
105
+ { id: 'auth-session', prompt: 'Every route that reads or writes QuickBooks data enforces authentication and checks the user owns that realmId (no IDOR).' },
106
+ { id: 'xss', prompt: 'A penetration test or DAST scan of signed-in pages (e.g. Aikido AI pentesting or an OWASP ZAP baseline) shows no XSS, CSRF, injection or access-control issues.' },
107
+ { id: 'injection', prompt: 'All database and XML access is parameterized; XML parsers have external entities disabled.' },
108
+ { id: 'no-third-party-access', prompt: 'No QuickBooks data is sent to analytics, LLM, logging or other third-party services outside the app\'s stated function.' },
109
+ { id: 'data-minimization', prompt: 'QuickBooks data is retained only as long as needed for the app\'s function, and deleted on disconnect.' },
110
+ { id: 'token-exposure', prompt: 'Disconnect/revoke flow calls Intuit\'s revoke endpoint and deletes stored tokens.' },
111
+ ];
package/src/runtime.js ADDED
@@ -0,0 +1,301 @@
1
+ import { request, tlsHandshake, parseSetCookie, isHtml } from './http.js';
2
+
3
+ const SESSION_COOKIE = /(sess|sid|auth|token|jwt|login|remember|connect\.sid|csrf|xsrf|identity|user)/i;
4
+ const EVIL = 'https://evil.example.com/qbrc';
5
+ const REDIRECT_PARAMS = ['redirect', 'redirect_uri', 'redirectUrl', 'next', 'returnTo', 'return_to', 'url', 'continue', 'dest'];
6
+
7
+ const r = (id, requirement, status, title, detail, extra = {}) => ({ id, requirement, status, title, detail, ...extra });
8
+
9
+ // ---------- pure analyzers (unit tested) ----------
10
+
11
+ export function analyzeHttpToHttps(outcome) {
12
+ const req = 'https-everywhere';
13
+ if (outcome.error) {
14
+ return r('http-redirect', req, 'pass', 'Plain HTTP not served', `Port 80 refused or unreachable (${outcome.error}); no plaintext content served.`);
15
+ }
16
+ const loc = String(outcome.headers.location || '');
17
+ if (outcome.status >= 300 && outcome.status < 400 && loc.startsWith('https://')) {
18
+ const permanent = [301, 308].includes(outcome.status);
19
+ return r('http-redirect', req, permanent ? 'pass' : 'warn', 'HTTP redirects to HTTPS',
20
+ permanent ? `HTTP ${outcome.status} → ${loc}` : `HTTP ${outcome.status} → ${loc}. Prefer a permanent 301/308 redirect.`);
21
+ }
22
+ return r('http-redirect', req, 'fail', 'HTTP does not redirect to HTTPS',
23
+ `GET ${outcome.url} returned ${outcome.status}${loc ? ` → ${loc}` : ''}. Every page must be served over HTTPS.`,
24
+ { fix: 'Redirect all port-80 traffic to HTTPS with a 301 and enable HSTS.' });
25
+ }
26
+
27
+ // Takes every HTTPS response from the host: an API host often answers `/` from its gateway (a bare
28
+ // 403 without app headers) while the app's own routes do send HSTS.
29
+ export function analyzeHsts(responses) {
30
+ const list = Array.isArray(responses) ? responses : [responses];
31
+ const withHsts = list.find((res) => res.headers['strict-transport-security']);
32
+ if (!withHsts) {
33
+ const urls = list.map((res) => new URL(res.url).pathname).join(', ');
34
+ return r('hsts', 'https-everywhere', 'warn', 'No Strict-Transport-Security header', `None of the probed responses (${urls}) set HSTS. Not an explicit Intuit item, but reviewers' scanners flag it.`, { fix: 'Add `Strict-Transport-Security: max-age=31536000; includeSubDomains`.' });
35
+ }
36
+ const h = withHsts.headers['strict-transport-security'];
37
+ const m = /max-age=(\d+)/i.exec(h);
38
+ if (!m || Number(m[1]) < 15552000) return r('hsts', 'https-everywhere', 'warn', 'HSTS max-age is short', `HSTS: ${h} (recommend ≥ 6 months).`);
39
+ return r('hsts', 'https-everywhere', 'pass', 'HSTS enabled', `${h} (on ${new URL(withHsts.url).pathname})`);
40
+ }
41
+
42
+ export function analyzeTls(results) {
43
+ const out = [];
44
+ const req = 'tls-version';
45
+ const { v10, v11, v12, v13 } = results;
46
+ if (v10?.ok) out.push(r('tls-1.0', req, 'fail', 'TLS 1.0 accepted', 'The server negotiates TLS 1.0, which is below Intuit\'s TLS 1.1 minimum.', { fix: 'Disable TLS 1.0 (and 1.1) on your load balancer / CDN.' }));
47
+ else if (v10?.clientUnsupported) out.push(r('tls-1.0', req, 'skip', 'TLS 1.0 not testable', 'This Node/OpenSSL build cannot offer TLS 1.0.'));
48
+ else out.push(r('tls-1.0', req, 'pass', 'TLS 1.0 rejected', v10?.error || 'Handshake refused.'));
49
+
50
+ if (v11?.ok) out.push(r('tls-1.1', req, 'warn', 'TLS 1.1 accepted', 'TLS 1.1 meets Intuit\'s minimum but is deprecated; Intuit recommends TLS 1.2+.', { fix: 'Set the minimum protocol to TLS 1.2.' }));
51
+ else if (v11?.clientUnsupported) out.push(r('tls-1.1', req, 'skip', 'TLS 1.1 not testable', 'This Node/OpenSSL build cannot offer TLS 1.1.'));
52
+ else out.push(r('tls-1.1', req, 'pass', 'TLS 1.1 rejected', v11?.error || 'Handshake refused.'));
53
+
54
+ if (!v12?.ok && !v13?.ok) {
55
+ out.push(r('tls-modern', req, 'fail', 'No TLS 1.2/1.3 support', `TLS 1.2: ${v12?.error}; TLS 1.3: ${v13?.error}`));
56
+ } else {
57
+ const best = v13?.ok ? v13 : v12;
58
+ const cipher = v12?.ok ? v12.cipher?.name : best.cipher?.name;
59
+ const strong = !v12?.ok || /AES_?256|AES256|CHACHA20/i.test(cipher || '');
60
+ out.push(r('tls-modern', req, strong ? 'pass' : 'warn', `${[v12?.ok && 'TLS 1.2', v13?.ok && 'TLS 1.3'].filter(Boolean).join(' + ')} supported`,
61
+ `Negotiated cipher (TLS 1.2): ${cipher || 'n/a'}${strong ? '' : ' — Intuit recommends AES-256 with SHA-256.'}`));
62
+ if (best.authorized === false) out.push(r('tls-cert', req, 'fail', 'Certificate not trusted', `Certificate validation failed: ${best.authError}`));
63
+ else out.push(r('tls-cert', req, 'pass', 'Certificate trusted', 'Certificate chain validates against the system trust store.'));
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export function analyzeCacheControl(res, { sensitive = true } = {}) {
69
+ const req = 'cache-control';
70
+ const id = `cache-control:${new URL(res.url).pathname}`;
71
+ const cc = String(res.headers['cache-control'] || '').toLowerCase();
72
+ const pragma = String(res.headers.pragma || '').toLowerCase();
73
+ const dirs = new Set(cc.split(',').map((d) => d.trim().split('=')[0]).filter(Boolean));
74
+ const path = new URL(res.url).pathname;
75
+ if (res.status >= 300 && res.status < 400 && !cc) {
76
+ return r(id, req, 'warn', `Cache-Control not checked on ${path}`, `${path} returned ${res.status} (redirect, probably to login). Pass --header "Cookie: …" to probe it authenticated.`);
77
+ }
78
+ if (dirs.has('no-store') && dirs.has('no-cache')) return r(id, req, 'pass', `Caching disabled on ${path}`, `Cache-Control: ${cc}`);
79
+ if (dirs.has('no-store')) return r(id, req, 'pass', `Caching disabled on ${path}`, `Cache-Control: ${cc} (Intuit asks for both no-store and no-cache; adding no-cache is harmless).`);
80
+ const status = sensitive ? 'fail' : 'warn';
81
+ const why = dirs.has('private') ? '`private` is not accepted by Intuit — it still allows browser caching.' : cc ? 'Missing no-store.' : 'No Cache-Control header.';
82
+ return r(id, req, status, `Sensitive page cacheable: ${path}`, `Cache-Control: ${cc || '(none)'}${pragma ? `, Pragma: ${pragma}` : ''}. ${why}`,
83
+ { fix: 'Send `Cache-Control: no-store, no-cache, must-revalidate` and `Pragma: no-cache` on every authenticated or token-handling response.' });
84
+ }
85
+
86
+ export function analyzeCookies(responses) {
87
+ const seen = new Map();
88
+ for (const res of responses) for (const sc of res.setCookies || []) {
89
+ const c = parseSetCookie(sc);
90
+ if (!seen.has(c.name)) seen.set(c.name, { ...c, url: res.url });
91
+ }
92
+ if (seen.size === 0) return [r('cookies', 'cookie-flags', 'skip', 'No cookies observed', 'No Set-Cookie headers on probed URLs. Probe your login/callback with --path to see session cookies.')];
93
+ const out = [];
94
+ for (const c of seen.values()) {
95
+ const missing = ['secure', 'httponly'].filter((a) => !c.attrs.has(a));
96
+ const session = SESSION_COOKIE.test(c.name);
97
+ // CSRF double-submit cookies must be JS-readable; only require Secure on them.
98
+ const csrfToken = /csrf|xsrf/i.test(c.name);
99
+ const required = csrfToken ? missing.filter((m) => m === 'secure') : missing;
100
+ if (required.length === 0) {
101
+ const ss = c.sameSite ? `SameSite=${c.sameSite}` : 'no SameSite (consider Lax)';
102
+ out.push(r(`cookie:${c.name}`, 'cookie-flags', 'pass', `Cookie ${c.name} is Secure${csrfToken ? '' : ' + HttpOnly'}`, ss));
103
+ } else {
104
+ out.push(r(`cookie:${c.name}`, 'cookie-flags', session ? 'fail' : 'warn', `Cookie ${c.name} missing ${required.join(' + ')}`,
105
+ `${session ? 'Looks like a session/auth cookie. ' : ''}Set-Cookie: ${c.raw.slice(0, 160)}`,
106
+ { fix: 'Set `Secure; HttpOnly; SameSite=Lax` on session cookies (e.g. express-session `cookie: { secure: true, httpOnly: true }`).' }));
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export function analyzeTrace(outcome, method = 'TRACE') {
113
+ const req = 'http-methods';
114
+ if (outcome.error) return r(`method:${method}`, req, 'pass', `${method} refused`, `Connection rejected: ${outcome.error}`);
115
+ const echoed = /^TRACE |x-qbrc-probe/i.test(outcome.body || '');
116
+ if (outcome.status >= 200 && outcome.status < 300) {
117
+ return r(`method:${method}`, req, 'fail', `${method} enabled`, `${method} ${outcome.url} → ${outcome.status}${echoed ? ' and echoed the request (Cross-Site Tracing)' : ''}.`,
118
+ { fix: `Disable ${method} at the web server / load balancer (nginx: \`if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; }\`).` });
119
+ }
120
+ return r(`method:${method}`, req, 'pass', `${method} disabled`, `${method} → ${outcome.status}`);
121
+ }
122
+
123
+ export function analyzeAllow(outcome) {
124
+ if (outcome.error) return null;
125
+ const allow = String(outcome.headers.allow || outcome.headers['access-control-allow-methods'] || '');
126
+ if (!allow) return null;
127
+ const bad = allow.split(',').map((m) => m.trim().toUpperCase()).filter((m) => ['TRACE', 'TRACK', 'CONNECT'].includes(m));
128
+ if (bad.length) return r('method:allow', 'http-methods', 'warn', `Server advertises ${bad.join(', ')}`, `OPTIONS Allow: ${allow}`);
129
+ return r('method:allow', 'http-methods', 'info', 'Advertised methods', `OPTIONS Allow: ${allow}. Confirm each is actually used.`);
130
+ }
131
+
132
+ export function analyzeTokenEndpoint(res) {
133
+ const req = 'token-endpoint-302';
134
+ const path = new URL(res.url).pathname;
135
+ const html = isHtml(res);
136
+ const refPolicy = String(res.headers['referrer-policy'] || '').toLowerCase();
137
+ const safeReferrer = /no-referrer|same-origin|strict-origin/.test(refPolicy);
138
+ if (res.status >= 300 && res.status < 400) {
139
+ const loc = String(res.headers.location || '');
140
+ if (/[?&](code|access_token|refresh_token|realmId|id_token)=/i.test(loc)) {
141
+ return r(`token-endpoint:${path}`, req, 'fail', `${path} forwards tokens in the redirect`, `Location: ${loc}`, { fix: 'Exchange the code server-side and redirect to a clean URL without token/realmId parameters.' });
142
+ }
143
+ return r(`token-endpoint:${path}`, req, 'pass', `${path} responds with ${res.status} redirect`, `Location: ${loc || '(none)'}`);
144
+ }
145
+ if (html) {
146
+ return r(`token-endpoint:${path}`, req, 'fail', `${path} returns HTML with tokens in the URL`,
147
+ `GET ${path}?code=…&realmId=… → ${res.status} text/html. Any third-party resource on that page receives the code and realmId via Referer.${safeReferrer ? ` (Referrer-Policy: ${refPolicy} mitigates, but Intuit still requires a redirect.)` : ''}`,
148
+ { fix: 'Handle the callback server-side and always answer with `302 Found` — on success and on error (redirect to an error page without the parameters).' });
149
+ }
150
+ // An error for a request it cannot place (unknown state, missing code) has nowhere safe to
151
+ // redirect to. What the rule guards against is HTML in the response, which a non-HTML error avoids.
152
+ if (res.status >= 400) {
153
+ return r(`token-endpoint:${path}`, req, 'pass', `${path} answers invalid requests without HTML`, `GET ${path} with a fake code and state → ${res.status} ${res.headers['content-type'] || ''}. No page is rendered, so nothing can leak through Referer. A real authorization should still end in a 302; test that with a real connect flow.`);
154
+ }
155
+ return r(`token-endpoint:${path}`, req, 'warn', `${path} returns ${res.status} without redirect`, `Body is not HTML (${res.headers['content-type'] || 'no content-type'}), so Referer leakage is unlikely, but Intuit expects a 302.`);
156
+ }
157
+
158
+ export function analyzeOpenRedirect(res, param) {
159
+ const loc = String(res.headers.location || '');
160
+ if (res.status >= 300 && res.status < 400 && loc.startsWith(EVIL)) {
161
+ // The same parameter can be open on several paths; keep one result per path and parameter.
162
+ const path = new URL(res.url).pathname;
163
+ return r(`open-redirect:${path}?${param}`, 'open-redirect', 'fail', `Open redirect via ${path}?${param}=`, `${res.url} → ${loc}`, { fix: 'Only redirect to relative paths or an allow-list of hosts.' });
164
+ }
165
+ return null;
166
+ }
167
+
168
+ export function analyzeDisclosure(res) {
169
+ const out = [];
170
+ const server = res.headers.server;
171
+ const powered = res.headers['x-powered-by'];
172
+ const versioned = [server, powered].filter((h) => h && /\d+\.\d+/.test(h));
173
+ if (versioned.length) out.push(r('disclosure', 'patching-logging', 'warn', 'Server version disclosed', `${versioned.join(' / ')} — reviewers use this to flag unpatched software.`, { fix: 'Hide version banners (nginx `server_tokens off;`, Express `app.disable("x-powered-by")`).' }));
174
+ else if (powered) out.push(r('disclosure', 'patching-logging', 'info', 'X-Powered-By header present', `X-Powered-By: ${powered}`));
175
+ return out;
176
+ }
177
+
178
+ export function analyzeSecurityHeaders(res) {
179
+ const missing = [];
180
+ const csp = String(res.headers['content-security-policy'] || '');
181
+ if (!/frame-ancestors/.test(csp) && !res.headers['x-frame-options']) missing.push('X-Frame-Options or CSP frame-ancestors (clickjacking)');
182
+ if (!res.headers['x-content-type-options']) missing.push('X-Content-Type-Options: nosniff');
183
+ if (!csp) missing.push('Content-Security-Policy (XSS defense in depth)');
184
+ if (!missing.length) return r('security-headers', 'xss', 'pass', 'Browser security headers present', 'CSP, framing and nosniff headers set.');
185
+ return r('security-headers', 'xss', 'warn', 'Missing browser security headers', missing.join('; '), { fix: 'Use helmet (Node), secure_headers (Rails) or equivalent.' });
186
+ }
187
+
188
+ // ---------- probes ----------
189
+
190
+ async function safe(fn) {
191
+ try {
192
+ return await fn();
193
+ } catch (e) {
194
+ return { error: e.code || e.message };
195
+ }
196
+ }
197
+
198
+ /**
199
+ * @param {{ url: string, paths?: string[], callback?: string, headers?: Record<string, string>, timeout?: number,
200
+ * log?: (m: string) => void, onResult?: (r: object) => void, lookup?: Function, httpPort?: number }} opts
201
+ */
202
+ // onResult streams each result as soon as it is known; lookup lets a hosted scanner pin DNS to vetted IPs.
203
+ export async function runRuntime({ url, paths = [], callback, headers = {}, timeout = 10000, log = () => {}, onResult, lookup, httpPort }) {
204
+ const base = new URL(url);
205
+ if (base.protocol !== 'https:') base.protocol = 'https:';
206
+ const results = [];
207
+ const push = (...items) => {
208
+ for (const item of items) {
209
+ results.push(item);
210
+ onResult?.(item);
211
+ }
212
+ };
213
+ const responses = [];
214
+ const at = (p) => new URL(p, base).toString();
215
+
216
+ log(`GET ${base.href}`);
217
+ const home = await safe(() => request(base.href, { headers, timeout, lookup }));
218
+ if (home.error) {
219
+ push(r('reachable', 'https-everywhere', 'fail', 'HTTPS endpoint unreachable', `${base.href}: ${home.error}`));
220
+ return results;
221
+ }
222
+ responses.push(home);
223
+
224
+ // HTTPS everywhere
225
+ const httpUrl = new URL(base.href);
226
+ httpUrl.protocol = 'http:';
227
+ // Plain HTTP on port 80 unless told otherwise (local test servers run on high ports).
228
+ httpUrl.port = httpPort ? String(httpPort) : '';
229
+ log(`GET ${httpUrl.href}`);
230
+ const plain = await safe(() => request(httpUrl.href, { timeout, lookup }));
231
+ push(analyzeHttpToHttps({ url: httpUrl.href, ...plain }));
232
+
233
+ // TLS
234
+ const port = Number(base.port || 443);
235
+ log(`TLS handshakes ${base.hostname}:${port}`);
236
+ const [v10, v11, v12, v13] = await Promise.all(['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'].map((v) => tlsHandshake(base.hostname, port, v, { lookup })));
237
+ push(...analyzeTls({ v10, v11, v12, v13 }));
238
+
239
+ // Cache-Control on sensitive pages (user-specified; else the callback and home page)
240
+ const sensitive = paths.length ? paths : [base.pathname];
241
+ for (const p of sensitive) {
242
+ const target = at(p);
243
+ log(`GET ${target}`);
244
+ const res = p === base.pathname ? home : await safe(() => request(target, { headers, timeout, lookup }));
245
+ if (res.error) {
246
+ push(r(`cache-control:${p}`, 'cache-control', 'warn', `Could not fetch ${p}`, res.error));
247
+ continue;
248
+ }
249
+ if (res !== home) responses.push(res);
250
+ push(analyzeCacheControl(res, { sensitive: paths.length > 0 }));
251
+ }
252
+
253
+ // HTTP methods
254
+ for (const m of ['TRACE', 'TRACK']) {
255
+ log(`${m} ${base.href}`);
256
+ const res = await safe(() => request(base.href, { method: m, headers: { 'x-qbrc-probe': '1' }, timeout, lookup }));
257
+ push(analyzeTrace(res.error ? { ...res, url: base.href } : res, m));
258
+ }
259
+ const opt = await safe(() => request(base.href, { method: 'OPTIONS', timeout, lookup }));
260
+ const allow = analyzeAllow(opt);
261
+ if (allow) push(allow);
262
+
263
+ // Token endpoint / OAuth callback
264
+ if (callback) {
265
+ const cb = new URL(at(callback));
266
+ cb.searchParams.set('code', 'QBRC_FAKE_AUTH_CODE');
267
+ cb.searchParams.set('state', 'qbrc-probe-state');
268
+ cb.searchParams.set('realmId', '9130000000000000');
269
+ log(`GET ${cb.href}`);
270
+ const res = await safe(() => request(cb.href, { headers: { ...headers, referer: 'https://appcenter.intuit.com/' }, timeout, lookup }));
271
+ if (res.error) push(r('token-endpoint', 'token-endpoint-302', 'warn', 'Callback unreachable', `${cb.href}: ${res.error}`));
272
+ else if (res.status === 404) push(r('token-endpoint', 'token-endpoint-302', 'warn', `Callback ${callback} returned 404`, 'Check the --callback path matches the Redirect URI registered in the Intuit developer portal.'));
273
+ else {
274
+ responses.push(res);
275
+ push(analyzeTokenEndpoint(res));
276
+ push(analyzeCacheControl(res, { sensitive: true }));
277
+ }
278
+ } else {
279
+ push(r('token-endpoint', 'token-endpoint-302', 'skip', 'OAuth callback not probed', 'Pass --callback /your/oauth/callback to verify the 302 rule.'));
280
+ }
281
+
282
+ // Open redirects on home and callback
283
+ const redirectTargets = [base.pathname, callback].filter(Boolean);
284
+ const probes = redirectTargets.flatMap((p) => REDIRECT_PARAMS.map((param) => ({ p, param })));
285
+ const found = await Promise.all(probes.map(async ({ p, param }) => {
286
+ const u = new URL(at(p));
287
+ u.searchParams.set(param, EVIL);
288
+ const res = await safe(() => request(u.href, { headers, timeout, lookup }));
289
+ return res.error ? null : analyzeOpenRedirect(res, param);
290
+ }));
291
+ const hits = found.filter(Boolean);
292
+ push(...hits);
293
+ const openRedirect = hits.length > 0;
294
+ if (!openRedirect) push(r('open-redirect', 'open-redirect', 'pass', 'No open redirect found', `Probed ${REDIRECT_PARAMS.length} common redirect parameters on ${redirectTargets.join(', ')}.`));
295
+
296
+ push(analyzeHsts(responses.filter((res) => res.url.startsWith('https:'))));
297
+ push(...analyzeCookies(responses));
298
+ push(...analyzeDisclosure(home));
299
+ push(analyzeSecurityHeaders(home));
300
+ return results;
301
+ }
@@ -0,0 +1,211 @@
1
+ export const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output', '.vercel', 'vendor', 'coverage', 'out', 'target', '__pycache__', '.venv', 'venv', 'bin', 'obj', '.turbo', '.cache']);
2
+ export const EXTS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts', '.vue', '.svelte', '.py', '.rb', '.php', '.java', '.kt', '.cs', '.go', '.ejs', '.erb', '.hbs', '.html']);
3
+ const TEST_FILE = /(^|[\\/])(test|tests|__tests__|__mocks__|spec|fixtures?|examples?)[\\/]|\.(test|spec)\.[a-z]+$/i;
4
+ export const MAX_BYTES = 1024 * 1024;
5
+
6
+ const TOKEN_WORDS = String.raw`(access_?token|refresh_?token|accessToken|refreshToken|realm_?id|realmId|client_?secret|clientSecret|authResponse|getToken\(\)|getJson\(\)|bearer)`;
7
+
8
+ // Line rules. `status` is the result status when the rule matches.
9
+ export const LINE_RULES = [
10
+ {
11
+ id: 'log-token', requirement: 'patching-logging', status: 'fail',
12
+ title: 'OAuth tokens or realmId written to logs',
13
+ re: new RegExp(String.raw`(console\.(log|info|debug|warn|error|trace)|\blogger\.\w+|\blog\.(info|debug|warn|error|trace)|\bprint(ln|f)?\s*\(|\bputs\b|\blogging\.\w+|Log\.(d|i|e|w)\(|\berror_log\(|fmt\.Print)[^\n]*\b${TOKEN_WORDS}`, 'i'),
14
+ fix: 'Never log tokens, realmId, or QuickBooks payloads. Log a token fingerprint (last 4 chars) or a request id instead.',
15
+ },
16
+ {
17
+ id: 'inline-key', requirement: 'key-storage', status: 'fail',
18
+ title: 'Encryption key hardcoded in source',
19
+ re: /\b(encrypt(ion)?_?key|aes_?key|cipher_?key|crypto_?key|token_?key|secret_?key)\b\s*[:=]\s*['"`][^'"`\s]{12,}['"`]|createCipheriv\(\s*['"`][^'"`]+['"`]\s*,\s*['"`]/i,
20
+ fix: 'Load the AES key from a separate config file or secret manager (env var, AWS KMS, Vault) — Intuit requires it not be inline.',
21
+ },
22
+ {
23
+ id: 'inline-client-secret', requirement: 'token-exposure', status: 'fail',
24
+ title: 'Intuit client secret hardcoded in source',
25
+ re: /\bclient_?secret\b['"]?\s*[:=]\s*['"`][A-Za-z0-9]{30,}['"`]/i,
26
+ fix: 'Move the client secret to environment/secret storage and rotate it in the Intuit developer portal.',
27
+ },
28
+ {
29
+ id: 'tls-verify-off', requirement: 'https-everywhere', status: 'fail',
30
+ title: 'TLS certificate verification disabled',
31
+ re: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|verify\s*=\s*False|InsecureSkipVerify\s*:\s*true|CURLOPT_SSL_VERIFYPEER\s*,\s*(false|0)|ServerCertificateValidationCallback\s*=.*=>\s*true/,
32
+ fix: 'Never disable certificate validation; tokens can be intercepted by a MITM.',
33
+ },
34
+ {
35
+ id: 'token-to-browser', requirement: 'token-exposure', status: 'fail',
36
+ title: 'OAuth tokens sent to or stored in the browser',
37
+ re: new RegExp(String.raw`(localStorage|sessionStorage)\.setItem\([^)]*(token|realm)|res\.(json|send|end|write)\([^)]*\b(refresh_?token|refreshToken|authResponse|getJson\(\)|oauthClient\.getToken\(\)|getToken\(\))|jsonify\([^)]*refresh_token|[?&](access_token|refresh_token)=\$\{|[?&](access_token|refresh_token)=['"]?\s*\+`, 'i'),
38
+ fix: 'Keep Intuit tokens server-side only. The browser should hold an opaque session cookie, never access/refresh tokens or realmId.',
39
+ },
40
+ {
41
+ id: 'weak-cipher', requirement: 'token-encryption', status: 'warn',
42
+ title: 'Weak or deprecated encryption mode',
43
+ re: /createCipher\(|createDecipher\(|aes-\d+-ecb|AES\.MODE_ECB|AES\/ECB|\brc4\b|des-cbc['"]|DES\/CBC|Cipher\.getInstance\(\s*"AES"\s*\)/i,
44
+ fix: 'Use AES-256-GCM (or AES-256-CBC + HMAC) with a random IV per record via createCipheriv / AESGCM.',
45
+ },
46
+ {
47
+ id: 'cookie-flag-off', requirement: 'cookie-flags', status: 'warn',
48
+ title: 'Cookie configured without Secure/HttpOnly',
49
+ re: /\b(httpOnly|http_only|httponly)\s*[:=]\s*(false|False)|\bsecure\s*:\s*false\b|SESSION_COOKIE_SECURE\s*=\s*False|SESSION_COOKIE_HTTPONLY\s*=\s*False/,
50
+ fix: 'Set secure: true and httpOnly: true for session cookies in every non-local environment.',
51
+ },
52
+ {
53
+ id: 'sql-concat', requirement: 'injection', status: 'warn',
54
+ title: 'SQL built with string interpolation',
55
+ re: /(\.query|\.execute|\.exec|\.raw|\$queryRawUnsafe|\$executeRawUnsafe|knex\.raw|sequelize\.query|db\.run|cursor\.execute)\s*\(\s*(`[^`]*\$\{|['"][^'"]*\b(SELECT|INSERT|UPDATE|DELETE)\b[^'"]*['"]\s*\+|f['"][^'"]*\b(SELECT|INSERT|UPDATE|DELETE)\b|['"][^'"]*%s[^'"]*['"]\s*%)/i,
56
+ fix: 'Use parameterized queries / prepared statements.',
57
+ },
58
+ {
59
+ id: 'qbo-query-concat', requirement: 'injection', status: 'warn',
60
+ title: 'QuickBooks query built with string interpolation',
61
+ re: /\bselect\s+\*?\s*[\w,\s]*\s+from\s+(Invoice|Customer|Vendor|Bill|Item|Account|Payment|Estimate)\b[^'"`]*(\$\{|['"`]\s*\+)/i,
62
+ fix: 'Escape single quotes in values interpolated into QuickBooks query language (e.g. DisplayName = \'O\\\'Brien\').',
63
+ },
64
+ {
65
+ id: 'xss-sink', requirement: 'xss', status: 'warn',
66
+ title: 'Unescaped HTML output sink',
67
+ re: /dangerouslySetInnerHTML|\.innerHTML\s*=(?!=)|\bv-html=|\{\{\{|<%-|\|\s*safe\b|\.html_safe\b|\bmark_safe\(|@Html\.Raw\(|\{@html\s/,
68
+ fix: 'Make sure QuickBooks data (customer names, memos) and user input are escaped or sanitized (DOMPurify) before rendering.',
69
+ },
70
+ {
71
+ id: 'xxe', requirement: 'injection', status: 'warn',
72
+ title: 'XML parser may resolve external entities',
73
+ re: /noent\s*:\s*true|resolve_entities\s*=\s*True|XmlUrlResolver\(|DtdProcessing\.Parse|LIBXML_NOENT|setExpandEntityReferences\(\s*true/,
74
+ fix: 'Disable DTDs/external entity resolution in XML parsers.',
75
+ },
76
+ ];
77
+
78
+ const INTUIT_REF = /intuit-oauth|node-quickbooks|quickbooks|oauth\.platform\.intuit\.com|appcenter\.intuit\.com|intuitlib|IntuitAuthorization|realmId|realm_id|com\.intuit\.oauth2/i;
79
+ const AUTHORIZE_REF = /authorizeUri|authorize_url|get_authorization_url|appcenter\.intuit\.com\/connect\/oauth2|OAuth2PlatformClient|getAuthorizationURL/i;
80
+ const STATIC_STATE = /\bstate(_token)?\s*[:=]\s*['"`]([A-Za-z0-9_-]{2,40})['"`]/;
81
+ const STATE_CHECK = /\bstate\b[^\n]{0,80}(!==|===|!=|==|\bnot in\b|\bequals\()|(req\.query|request\.args|params|searchParams|query)\W{0,3}\.?(get\(\s*['"])?state\b[^\n]{0,120}(session|cookie|stored|expected)|validateState|verifyState|check_state|state_matches|timingSafeEqual/i;
82
+ const CSRF_LIB = /\bcsurf\b|csrf-csrf|\blusca\b|@fastify\/csrf|@edge-csrf|CsrfViewMiddleware|protect_from_forgery|csrf_token|ValidateAntiForgeryToken|next-csrf|csrf\(\)|tiny-csrf|SameSite=Strict|sameSite\s*:\s*['"](strict|lax)/i;
83
+ const PERSIST_TOKEN = /\b(refresh_?token|refreshToken)\b[^\n]{0,160}\b(save|insert|update|upsert|create|set|put|write|store|persist|hset|setex|INSERT|UPDATE)\b|\b(save|insert|update|upsert|create|store|persist|hset|setex)\w*\s*\([^\n]{0,160}\b(refresh_?token|refreshToken)\b|\b(refresh_?token|refreshToken)\s*[:=]\s*(token|authResponse|response|tokens|json|data|oauth)/i;
84
+ const PERSIST_REALM = /\b(realm_?id|realmId)\b[^\n]{0,120}\b(save|insert|update|upsert|create|store|persist|INSERT|UPDATE)\b|\b(save|insert|update|upsert|create|store|persist)\w*\s*\([^\n]{0,160}\b(realm_?id|realmId)\b|\b(realm_?id|realmId)\s*:\s*(req|request|params|query|url|searchParams|token|authResponse)\b/i;
85
+ const ENCRYPT_EVIDENCE = /createCipheriv\(\s*['"`]?(aes|des-ede3)|createCipheriv\(|crypto\.subtle\.encrypt|['"]AES-GCM['"]|AES\.new\(|AESGCM\(|\bFernet\(|Cipher\.getInstance\(\s*"(AES|DESede)|openssl_encrypt\(|ActiveRecord::Encryption|\bencrypts\s+:|attr_encrypted|lockbox|TripleDES|des-ede3|@47ng\/cloak|prisma-field-encryption|mongoose-(field-)?encryption|crypto-js[^\n]*AES|CryptoJS\.AES|kms[\w.]*\.?encrypt|EncryptCommand|secretbox|AesGcm|Aes\.Create\(|\bencrypt(Token|RefreshToken|Secret|Field|Value)?\s*\(/i;
86
+ const THIRD_PARTY = [
87
+ { re: /['"](openai|@anthropic-ai\/sdk|@google\/generative-ai|@ai-sdk\/[\w-]+|ai|langchain|@langchain\/[\w-]+|cohere-ai)['"]|^\s*(import|from)\s+(openai|anthropic|langchain)\b/m, name: 'LLM provider' },
88
+ { re: /['"](@segment\/analytics-node|analytics-node|mixpanel|posthog-node|posthog-js|amplitude|@amplitude\/[\w-]+|@hubspot\/api-client|intercom-client|heap-api)['"]/, name: 'analytics / CRM SDK' },
89
+ { re: /['"](@sentry\/[\w-]+|@datadog\/[\w-]+|dd-trace|newrelic|@logtail\/[\w-]+|logrocket|@bugsnag\/[\w-]+|rollbar)['"]|^\s*import\s+sentry_sdk/m, name: 'error/APM SDK' },
90
+ ];
91
+ const APIDECK_REF = /@apideck\/[\w-]+|unify\.apideck\.com|vault\.apideck\.com|x-apideck-consumer-id|apideck-unify|from apideck_unify|apideck\.vault/i;
92
+ const QBO_DATA = /\b(Invoice|Customer|Vendor|Bill|JournalEntry|Payment|SalesReceipt|CompanyInfo|getInvoice|findCustomers|findInvoices|QueryResponse)\b/;
93
+
94
+ // Decide whether a file (by basename) is scanned as code, as a local env file, or skipped.
95
+ export function classifyFile(name) {
96
+ if (/^\.env(\.|$)/.test(name)) return /example|sample|template|dist/.test(name) ? null : 'env';
97
+ if (/\.min\.js$/.test(name)) return null;
98
+ const dot = name.lastIndexOf('.');
99
+ return dot > 0 && EXTS.has(name.slice(dot).toLowerCase()) ? 'code' : null;
100
+ }
101
+
102
+ export function isSkippedDir(name) {
103
+ return SKIP_DIRS.has(name) || name.startsWith('.');
104
+ }
105
+
106
+ const loc = (file, line, text) => ({ file, line, snippet: text.trim().slice(0, 160) });
107
+
108
+ // files: [{ rel, text, kind: 'code' | 'env' }] — pure, so it runs in Node and in the browser.
109
+ export function scanFiles(files, { includeTests = false } = {}) {
110
+ const hits = new Map(LINE_RULES.map((rule) => [rule.id, []]));
111
+ const evidence = { apideck: [], intuit: [], authorize: [], staticState: [], stateCheck: [], csrfLib: [], persistToken: [], persistRealm: [], encrypt: [], thirdParty: new Map(), envSecrets: [] };
112
+ let scanned = 0;
113
+
114
+ for (const f of files) {
115
+ if (f.text.length > MAX_BYTES) continue;
116
+ const { text } = f;
117
+ scanned++;
118
+
119
+ if (f.kind === 'env') {
120
+ text.split('\n').forEach((l, i) => {
121
+ if (/^\s*(\w*(CLIENT_SECRET|ENCRYPTION_KEY|AES_KEY|REFRESH_TOKEN)\w*)\s*=\s*\S{8,}/i.test(l)) evidence.envSecrets.push(loc(f.rel, i + 1, l.replace(/=.*/, '=***')));
122
+ });
123
+ continue;
124
+ }
125
+ const isTest = TEST_FILE.test(f.rel);
126
+ if (isTest && !includeTests) continue;
127
+
128
+ const lines = text.split('\n');
129
+ const fileIntuit = INTUIT_REF.test(text);
130
+ const fileAuthorize = AUTHORIZE_REF.test(text);
131
+ const fileQbo = fileIntuit && QBO_DATA.test(text);
132
+
133
+ lines.forEach((line, i) => {
134
+ if (line.length > 2000) return;
135
+ const n = i + 1;
136
+ for (const rule of LINE_RULES) if (rule.re.test(line)) hits.get(rule.id).push(loc(f.rel, n, line));
137
+ if (APIDECK_REF.test(line)) evidence.apideck.push(loc(f.rel, n, line));
138
+ if (fileIntuit && INTUIT_REF.test(line)) evidence.intuit.push(loc(f.rel, n, line));
139
+ if (AUTHORIZE_REF.test(line)) evidence.authorize.push(loc(f.rel, n, line));
140
+ if (fileAuthorize) {
141
+ const m = STATIC_STATE.exec(line);
142
+ if (m && !/^(state|query|params|body)$/i.test(m[2])) evidence.staticState.push(loc(f.rel, n, line));
143
+ }
144
+ if (STATE_CHECK.test(line)) evidence.stateCheck.push(loc(f.rel, n, line));
145
+ if (CSRF_LIB.test(line)) evidence.csrfLib.push(loc(f.rel, n, line));
146
+ if (PERSIST_TOKEN.test(line)) evidence.persistToken.push(loc(f.rel, n, line));
147
+ if (PERSIST_REALM.test(line)) evidence.persistRealm.push(loc(f.rel, n, line));
148
+ if (ENCRYPT_EVIDENCE.test(line)) evidence.encrypt.push(loc(f.rel, n, line));
149
+ });
150
+
151
+ if (fileQbo) for (const tp of THIRD_PARTY) if (tp.re.test(text)) {
152
+ if (!evidence.thirdParty.has(tp.name)) evidence.thirdParty.set(tp.name, []);
153
+ evidence.thirdParty.get(tp.name).push(loc(f.rel, 1, `imports ${tp.name}`));
154
+ }
155
+ }
156
+
157
+ return { scanned, results: buildResults(hits, evidence) };
158
+ }
159
+
160
+ const res = (id, requirement, status, title, detail, extra = {}) => ({ id, requirement, status, title, detail, ...extra });
161
+
162
+ function buildResults(hits, ev) {
163
+ const out = [];
164
+ if (!ev.intuit.length) {
165
+ out.push(res('src:intuit', 'token-encryption', 'warn', 'No QuickBooks integration code detected', 'Could not find intuit-oauth, node-quickbooks, realmId or Intuit OAuth URLs. OAuth-specific checks below are less reliable. (If you connect to QuickBooks through Apideck Vault, Intuit tokens are stored and encrypted by Apideck, not your app.)'));
166
+ }
167
+
168
+ for (const rule of LINE_RULES) {
169
+ const h = hits.get(rule.id);
170
+ if (h.length) out.push(res(`src:${rule.id}`, rule.requirement, rule.status, rule.title, `${h.length} occurrence${h.length > 1 ? 's' : ''}.`, { locations: h.slice(0, 15), fix: rule.fix }));
171
+ }
172
+
173
+ // OAuth CSRF (state parameter)
174
+ if (ev.staticState.length) {
175
+ out.push(res('src:static-state', 'csrf', 'fail', 'OAuth state parameter is a constant', 'The authorize URL uses a hardcoded `state` (Intuit\'s sample code ships `testState`). That disables CSRF protection on the connect flow.',
176
+ { locations: ev.staticState.slice(0, 10), fix: 'Generate a random state per request (crypto.randomBytes(32)), store it in the session, and compare it in the callback before exchanging the code.' }));
177
+ } else if (ev.authorize.length && !ev.stateCheck.length) {
178
+ out.push(res('src:state-check', 'csrf', 'warn', 'No OAuth state validation found', 'Found an Intuit authorize URL but no code comparing the returned `state` to a stored value.', { locations: ev.authorize.slice(0, 5), fix: 'Verify `state` in the callback with a constant-time compare before calling createToken.' }));
179
+ } else if (ev.authorize.length) {
180
+ out.push(res('src:state-check', 'csrf', 'pass', 'OAuth state appears to be validated', 'Found state comparison logic.', { locations: ev.stateCheck.slice(0, 3) }));
181
+ }
182
+ if (ev.csrfLib.length) out.push(res('src:csrf-lib', 'csrf', 'pass', 'CSRF protection in place', 'Found CSRF middleware or SameSite cookie configuration.', { locations: ev.csrfLib.slice(0, 3) }));
183
+ else out.push(res('src:csrf-lib', 'csrf', 'warn', 'No CSRF middleware detected', 'No CSRF library or SameSite cookie config found. Intuit\'s pen test checks state-changing forms and endpoints.', { fix: 'Add CSRF tokens (csrf-csrf, Django/Rails built-ins) or SameSite=Lax/Strict session cookies plus origin checks.' }));
184
+
185
+ // Token encryption at rest
186
+ const persisted = ev.persistToken.length > 0;
187
+ if (persisted && !ev.encrypt.length) {
188
+ out.push(res('src:token-encryption', 'token-encryption', 'fail', 'Refresh token stored without encryption', 'Found code persisting the refresh token but no AES/3DES encryption anywhere in the codebase.',
189
+ { locations: ev.persistToken.slice(0, 10), fix: 'Encrypt refresh token and realmId with AES-256-GCM before writing them (see README for a 20-line helper).' }));
190
+ } else if (persisted) {
191
+ out.push(res('src:token-encryption', 'token-encryption', 'pass', 'Encryption used near token storage', 'Found both token persistence and AES/3DES encryption. Confirm the refresh token *and* realmId pass through it before every write.',
192
+ { locations: [...ev.persistToken.slice(0, 3), ...ev.encrypt.slice(0, 3)] }));
193
+ } else if (ev.apideck.length && !ev.authorize.length) {
194
+ out.push(res('src:token-encryption', 'token-encryption', 'pass', 'Intuit tokens held by Apideck Vault', 'This app connects to QuickBooks through Apideck and runs no Intuit OAuth flow of its own, so refresh tokens and realmId are stored and encrypted by Apideck. Make sure your Apideck API key stays server-side.', { locations: ev.apideck.slice(0, 2) }));
195
+ } else if (ev.intuit.length) {
196
+ out.push(res('src:token-encryption', 'token-encryption', 'warn', 'Could not locate refresh-token storage', 'Intuit code found, but no obvious write of the refresh token. If you persist it (you must, to stay connected), make sure it is AES-encrypted.'));
197
+ }
198
+ if (ev.persistRealm.length && !ev.encrypt.length) {
199
+ out.push(res('src:realm-encryption', 'token-encryption', 'fail', 'realmId stored without encryption', 'Intuit requires realmId to be encrypted at rest along with the refresh token.', { locations: ev.persistRealm.slice(0, 5) }));
200
+ }
201
+
202
+ // Key storage
203
+ if (!hits.get('inline-key').length && ev.encrypt.length) out.push(res('src:key-storage', 'key-storage', 'pass', 'No inline encryption key found', 'Encryption keys appear to be loaded from configuration.'));
204
+ if (ev.envSecrets.length) out.push(res('src:env-secrets', 'key-storage', 'warn', 'Secrets present in local .env files', 'Make sure these files are git-ignored and never deployed to the client bundle.', { locations: ev.envSecrets.slice(0, 10) }));
205
+
206
+ // Third-party data sharing
207
+ for (const [name, locs] of ev.thirdParty) {
208
+ out.push(res(`src:third-party:${name}`, 'no-third-party-access', 'warn', `QuickBooks data code also imports ${name}`, `Files that handle QuickBooks entities import a ${name}. Intuit prohibits sending customer QuickBooks data to third parties outside your app's function — verify payloads${name.startsWith('error') ? ' are scrubbed (e.g. Sentry beforeSend)' : ''}.`, { locations: locs.slice(0, 10) }));
209
+ }
210
+ return out;
211
+ }
package/src/source.js ADDED
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { classifyFile, isSkippedDir, scanFiles, MAX_BYTES } from './scan-core.js';
4
+
5
+ export { LINE_RULES, scanFiles } from './scan-core.js';
6
+
7
+ function walk(dir, root, files) {
8
+ let entries;
9
+ try {
10
+ entries = fs.readdirSync(dir, { withFileTypes: true });
11
+ } catch {
12
+ return;
13
+ }
14
+ for (const e of entries) {
15
+ const full = path.join(dir, e.name);
16
+ if (e.isDirectory()) {
17
+ if (!isSkippedDir(e.name)) walk(full, root, files);
18
+ } else if (e.isFile()) {
19
+ const kind = classifyFile(e.name);
20
+ if (kind) files.push({ full, rel: path.relative(root, full), kind });
21
+ }
22
+ }
23
+ }
24
+
25
+ export function scanSource(root, opts = {}) {
26
+ const abs = path.resolve(root);
27
+ const found = [];
28
+ walk(abs, abs, found);
29
+ const files = [];
30
+ for (const f of found) {
31
+ try {
32
+ if (fs.statSync(f.full).size > MAX_BYTES) continue;
33
+ files.push({ rel: f.rel, kind: f.kind, text: fs.readFileSync(f.full, 'utf8') });
34
+ } catch {
35
+ // unreadable file; skip
36
+ }
37
+ }
38
+ return { root: abs, ...scanFiles(files, opts) };
39
+ }