@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/scan-core.js
ADDED
|
@@ -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
|
+
}
|