@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 +21 -0
- package/README.md +325 -2
- package/bin/quickbooks-review-check.js +13 -0
- package/package.json +37 -4
- package/src/cli.js +98 -0
- package/src/guard.js +75 -0
- package/src/http.js +111 -0
- package/src/report.js +78 -0
- package/src/requirements.js +111 -0
- package/src/runtime.js +301 -0
- package/src/scan-core.js +211 -0
- package/src/source.js +39 -0
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
|
+
}
|