fini-proof 0.3.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 +5 -0
- package/README.md +220 -0
- package/action.yml +53 -0
- package/bin/fini-proof.mjs +6 -0
- package/ci/Jenkinsfile +13 -0
- package/ci/Jenkinsfile.daily +23 -0
- package/ci/bitbucket-pipelines-daily.yml +15 -0
- package/ci/bitbucket-pipelines.yml +12 -0
- package/ci/github-actions-daily.yml +31 -0
- package/ci/github-actions-example.yml +14 -0
- package/ci/gitlab-ci-daily.yml +17 -0
- package/ci/gitlab-ci.yml +13 -0
- package/package.json +31 -0
- package/src/cli.mjs +258 -0
- package/src/engines/fail-open.mjs +133 -0
- package/src/engines/gitleaks.mjs +143 -0
- package/src/engines/hollow-tests.mjs +121 -0
- package/src/engines/migrations.mjs +316 -0
- package/src/engines/secrets.mjs +88 -0
- package/src/engines/skip-ratchet.mjs +78 -0
- package/src/engines/tenant-filter.mjs +516 -0
- package/src/engines/vendor/entrypoint-guard-core.mjs +43 -0
- package/src/engines/vendor/migration-invariants-core.mjs +127 -0
- package/src/engines/vendor/secrets-core.mjs +123 -0
- package/src/engines/vendor/vacuous-spec-core.mjs +1066 -0
- package/src/evidence.mjs +130 -0
- package/src/files.mjs +89 -0
- package/src/hooks.mjs +118 -0
- package/src/licence.mjs +85 -0
- package/src/mcp.mjs +230 -0
- package/src/report.mjs +73 -0
- package/src/runner.mjs +306 -0
- package/src/state.mjs +189 -0
- package/src/upload.mjs +85 -0
- package/src/usage.mjs +64 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// fini-proof engine: skip-ratchet — focused (.only) tests and a ratchet on skipped tests.
|
|
2
|
+
// REUSED from Finipe: the skip call shapes (SKIP_RE, conditional-skip shapes) and the SKIP-LEDGER convention
|
|
3
|
+
// (a `SKIP-LEDGER: <ref>` comment on the skip's line or the 3 lines above exempts it) come from the ported F4
|
|
4
|
+
// detector (./vendor/vacuous-spec-core.mjs); the ratchet semantics (a count may only go down, a baseline is
|
|
5
|
+
// never raised silently) follow finipe scripts/ci/fini-rule-ratchet.mjs @ 4ac53b367. Python skip shapes and the
|
|
6
|
+
// .only rule are NEW.
|
|
7
|
+
import { maskStringsAndComments, skipLacksLedgerEntry, conditionalSkipOffsets } from './vendor/vacuous-spec-core.mjs'
|
|
8
|
+
|
|
9
|
+
const JS_TEST_RE = /(^|\/)(__tests__\/.*\.(js|jsx|ts|tsx|mjs|cjs)$|[^/]+\.(spec|test)\.(js|jsx|ts|tsx|mjs|cjs)$)/
|
|
10
|
+
const PY_TEST_RE = /(^|\/)(test_[^/]*\.py|[^/]*_test\.py|conftest\.py)$/
|
|
11
|
+
const ONLY_RE = /\b(?:it|test|describe|context|suite)\s*\.\s*only\s*\(|(?<![.\w$])(?:fit|fdescribe|ftest)\s*\(/g
|
|
12
|
+
const SKIP_RE = /\b(?:it|test|describe|context|suite)\s*\.\s*(?:skip|todo)\s*\(|(?<![.\w$])(?:xit|xtest|xdescribe)\s*\(/g
|
|
13
|
+
const PY_SKIP_RE = /@pytest\.mark\.(?:skip|skipif|xfail)\b|@unittest\.(?:skip|skipIf|skipUnless|expectedFailure)\b|\bpytest\.skip\s*\(|\bself\.skipTest\s*\(/g
|
|
14
|
+
|
|
15
|
+
function lineOf(src, idx) { return src.slice(0, idx).split('\n').length }
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: 'skip-ratchet',
|
|
19
|
+
version: '0.1.0',
|
|
20
|
+
title: 'Focused and skipped tests (ratchet)',
|
|
21
|
+
provenance: 'finipe check-vacuous-spec.mjs skip shapes + SKIP-LEDGER, fini-rule-ratchet.mjs semantics @ 4ac53b367',
|
|
22
|
+
appliesTo: (rel) => JS_TEST_RE.test(rel) || PY_TEST_RE.test(rel),
|
|
23
|
+
scanFile(rel, content) {
|
|
24
|
+
const out = []
|
|
25
|
+
const isPy = rel.endsWith('.py')
|
|
26
|
+
const masked = isPy ? content.replace(/#.*$/gm, (m) => ' '.repeat(m.length)) : maskStringsAndComments(content)
|
|
27
|
+
if (!isPy) {
|
|
28
|
+
let m
|
|
29
|
+
ONLY_RE.lastIndex = 0
|
|
30
|
+
while ((m = ONLY_RE.exec(masked)) !== null) out.push({ line: lineOf(content, m.index), rule: 'FOCUSED_TEST', severity: 'high', message: '`.only` / `fit` / `fdescribe` makes the runner skip every OTHER test in the file (or the run) — the suite goes green having run almost nothing. FIX: remove the focus before merging.' })
|
|
31
|
+
}
|
|
32
|
+
const skips = new Set()
|
|
33
|
+
const re = isPy ? PY_SKIP_RE : SKIP_RE
|
|
34
|
+
re.lastIndex = 0
|
|
35
|
+
let m
|
|
36
|
+
while ((m = re.exec(masked)) !== null) skips.add(lineOf(content, m.index))
|
|
37
|
+
if (!isPy) for (const at of conditionalSkipOffsets(masked)) skips.add(lineOf(content, at))
|
|
38
|
+
for (const line of [...skips].sort((a, b) => a - b)) {
|
|
39
|
+
if (!skipLacksLedgerEntry(content, line)) continue
|
|
40
|
+
out.push({ line, rule: 'SKIPPED_TEST', severity: 'info', message: 'skipped test with no `SKIP-LEDGER: <ref>` comment saying why and where that decision is recorded.' })
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
},
|
|
44
|
+
/** Ratchet: per file, unledgered skips above the committed baseline become SKIP_RATCHET (high). */
|
|
45
|
+
finalize(findings, ctx) {
|
|
46
|
+
const base = ctx.baseline?.['skip-ratchet'] || {}
|
|
47
|
+
const byFile = new Map()
|
|
48
|
+
for (const f of findings) if (f.rule === 'SKIPPED_TEST') byFile.set(f.file, (byFile.get(f.file) || 0) + 1)
|
|
49
|
+
const tolerated = {}
|
|
50
|
+
const out = []
|
|
51
|
+
for (const f of findings) {
|
|
52
|
+
if (f.rule !== 'SKIPPED_TEST') { out.push(f); continue }
|
|
53
|
+
const n = byFile.get(f.file), allowed = base[f.file] || 0
|
|
54
|
+
if (n > allowed) out.push({ ...f, rule: 'SKIP_RATCHET', severity: 'high', message: `${f.file} now has ${n} unledgered skipped test(s); the baseline allows ${allowed}. A skip hides a test without anyone deciding to. FIX: un-skip it, or add \`// SKIP-LEDGER: <ticket>\` naming why.` })
|
|
55
|
+
else tolerated[f.file] = n
|
|
56
|
+
}
|
|
57
|
+
return { findings: out, measurements: { skipsPerFile: Object.fromEntries(byFile), toleratedByBaseline: tolerated } }
|
|
58
|
+
},
|
|
59
|
+
rules: {
|
|
60
|
+
FOCUSED_TEST: { severity: 'high', summary: '`.only` / `fit` makes the runner skip every other test', fix: 'remove the focus' },
|
|
61
|
+
SKIPPED_TEST: { severity: 'info', summary: 'a skipped test with no `SKIP-LEDGER: <ref>` reason (tolerated up to the baseline)', fix: 'un-skip, or add a SKIP-LEDGER comment' },
|
|
62
|
+
SKIP_RATCHET: { severity: 'high', summary: 'more unledgered skipped tests than the committed baseline allows', fix: 'un-skip, or add `// SKIP-LEDGER: <ticket>`' },
|
|
63
|
+
},
|
|
64
|
+
negativeControls: [
|
|
65
|
+
{ id: 'skip-ratchet/NC-1', file: 'nc/a.test.js', expect: 'FOCUSED_TEST', content: "it.only('runs alone', () => {\n expect(f()).toBe(1)\n})\nit('never runs', () => { expect(g()).toBe(2) })\n" },
|
|
66
|
+
{ id: 'skip-ratchet/NC-2', file: 'nc/b.spec.ts', expect: 'SKIP_RATCHET', content: "it.skip('pays the vendor', () => {\n expect(pay()).toBe(true)\n})\n" },
|
|
67
|
+
{ id: 'skip-ratchet/NC-3', file: 'nc/test_c.py', expect: 'SKIP_RATCHET', content: '@pytest.mark.skip\ndef test_refund():\n assert refund() == 10\n' },
|
|
68
|
+
],
|
|
69
|
+
// Positive controls: `rule` names the rule each is the nearest clean look-alike for.
|
|
70
|
+
positiveControls: [
|
|
71
|
+
{ id: 'skip-ratchet/PC-1', rule: 'SKIP_RATCHET', file: 'pc/a.test.js', content: "// SKIP-LEDGER: JIRA-123 flaky upstream sandbox\nit.skip('sandbox', () => { expect(x()).toBe(1) })\nconst s = 'it.only(' \n" },
|
|
72
|
+
{ id: 'skip-ratchet/PC-2', rule: 'FOCUSED_TEST', file: 'pc/b.test.js', content: "// use it.only( locally, never commit it\nit('runs with the others', () => {\n expect(f()).toBe(1)\n})\nconst focus = 'fdescribe'\n" },
|
|
73
|
+
// SKIPPED_TEST has no negative control on purpose: finalize() never emits it as a finding (a skip within the
|
|
74
|
+
// baseline is recorded in measurements.toleratedByBaseline; above it, it becomes SKIP_RATCHET). It is info →
|
|
75
|
+
// ADVISORY in `state`, never PROVEN.
|
|
76
|
+
{ id: 'skip-ratchet/PC-3', rule: 'SKIP_RATCHET', file: 'pc/test_d.py', content: '# SKIP-LEDGER: JIRA-77 bank sandbox down until 2026-10-15\n@pytest.mark.skip\ndef test_payout():\n assert payout() == 10\n' },
|
|
77
|
+
],
|
|
78
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// fini-proof engine: tenant-filter — a query on a multi-tenant table that does not filter by the tenant column,
|
|
2
|
+
// so one customer's request can read or change another customer's rows (the #1 SaaS data-leak class).
|
|
3
|
+
//
|
|
4
|
+
// PROVENANCE. The detector shapes are ported from finipe .github/scripts/check-tenant-scoping.js
|
|
5
|
+
// (blob ca97b5c1, last changed 990d528dd, read at finipe commit 4ac53b367):
|
|
6
|
+
// 1. `.find({` / `.findOne({` whose options carry no tenantId / tenant_id (checkFindWithoutTenant)
|
|
7
|
+
// 2. `.createQueryBuilder(` whose chain never mentions the tenant (checkQueryBuilderWithoutTenant)
|
|
8
|
+
// 3. a raw `SELECT` string with no tenant_id (checkRawQueryWithoutTenant)
|
|
9
|
+
// ADAPTED for customer repos: Finipe's fixed 10/15/20-line look-ahead windows are replaced by the call's balanced
|
|
10
|
+
// argument list / the statement's own extent (a window lets the NEXT statement's tenantId hide a leak), Finipe's
|
|
11
|
+
// hard-coded backend/src/modules path and allowlist become config, and a finding is only raised when the queried
|
|
12
|
+
// table is KNOWN to be multi-tenant (detected from the repo's own schema, or configured) — Finipe flagged every
|
|
13
|
+
// find() and ran as a warning-only trend script for that reason.
|
|
14
|
+
// NEW: UPDATE/DELETE/JOIN raw SQL in any language, Prisma, Sequelize-style, Knex, Django ORM and SQLAlchemy shapes,
|
|
15
|
+
// schema detection (SQL, Prisma, TypeORM, Knex, Alembic, Django/SQLAlchemy models), and the exemption comment
|
|
16
|
+
// `fini-proof: tenant-exempt <reason>` (e.g. a platform-admin report that must read across tenants).
|
|
17
|
+
//
|
|
18
|
+
// Config (.fini-proof.json): { "tenant": { "column": "tenant_id", "tables": ["orders"], "exemptTables": [],
|
|
19
|
+
// "exemptPaths": ["src/admin/**"],
|
|
20
|
+
// "helpers": ["buildTeamWhereQuery"], "scopeColumns": ["project_id"] } }
|
|
21
|
+
// With no config the column is tenant_id (+ tenantId) and the tables are those whose schema declares that column.
|
|
22
|
+
// v0.3 (false-positive pass on 3 public SaaS repos, see FP_MEASUREMENT report):
|
|
23
|
+
// - `helpers`: a call to a shared where-builder counts as the tenant condition (the filter lives in the helper);
|
|
24
|
+
// - `scopeColumns`: extra columns the team declares as tenant-scoping (a parent key such as project_id whose row
|
|
25
|
+
// is itself tenant-scoped, or the authenticated user for per-user data) — recorded in the evidence measurements;
|
|
26
|
+
// - tables with Postgres ROW LEVEL SECURITY enabled in the schema are isolated by the database and are not
|
|
27
|
+
// checked at the call site (listed as rlsTables in the evidence);
|
|
28
|
+
// - the table set comes from the DECLARATIVE schema (Prisma, ORM models/entities) when the repo has one — old
|
|
29
|
+
// migrations describe history (documenso: tables whose tenant column was later dropped were flagged); Python
|
|
30
|
+
// model classes inherit the tenant column from an abstract base (Django ProjectBaseModel → Issue, Cycle, …);
|
|
31
|
+
// - HIGH is kept only where there is no sign of a tenant check around the call. It is lowered to UNPROVEN (low,
|
|
32
|
+
// non-blocking by default, reason in the message) when the row is re-keyed from an already-loaded object
|
|
33
|
+
// (`id: envelope.id`), when the same table was queried WITH the tenant condition earlier in the same function,
|
|
34
|
+
// or when the result's tenant column is checked right after the fetch (`envelope.teamId` → access check);
|
|
35
|
+
// - calls on external SDK clients (stripe.subscriptions.update, s3.…) are not database queries;
|
|
36
|
+
// - Array.prototype.find/count(callback), correlated sub-queries (Django OuterRef) and prose strings that merely
|
|
37
|
+
// start with "Update …"/"Select …" are no longer read as queries.
|
|
38
|
+
import { balanced } from './vendor/migration-invariants-core.mjs'
|
|
39
|
+
import { maskStringsAndComments } from './vendor/vacuous-spec-core.mjs'
|
|
40
|
+
import { globToRegExp } from '../files.mjs'
|
|
41
|
+
import { MIGRATION_PATH_RE } from './migrations.mjs'
|
|
42
|
+
|
|
43
|
+
const CODE_RE = /\.(js|jsx|ts|tsx|mjs|cjs|py|java|kt|go|rb|php|cs|sql)$/
|
|
44
|
+
const TEST_RE = /(^|\/)(__tests__|__mocks__|tests?|spec|e2e|fixtures?|seeds?|seeders)\/|\.(spec|test|e2e|stories)\.[a-z]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$|\.d\.ts$/
|
|
45
|
+
const SCHEMA_RE = /(^|\/)(migrations?|migrate|alembic\/versions)\/|\.sql$|(^|\/)schema\.prisma$|\.entity\.(ts|js)$|(^|\/)(entities|entity|models?)\/[^/]+\.(ts|js|py)$|(^|\/)models\.py$|\.model\.(ts|js)$/i
|
|
46
|
+
const JS_RE = /\.(js|jsx|ts|tsx|mjs|cjs)$/
|
|
47
|
+
const lineOf = (src, idx) => src.slice(0, idx).split('\n').length
|
|
48
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
49
|
+
|
|
50
|
+
/** orders / Orders / order_items / OrderItem / "public"."orders" → one comparable key. */
|
|
51
|
+
export function tableKey(name) {
|
|
52
|
+
let k = String(name).replace(/["'`[\]]/g, '').split('.').pop().toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
53
|
+
if (k.endsWith('ies')) k = k.slice(0, -3) + 'y'
|
|
54
|
+
else if (k.endsWith('sses')) k = k.slice(0, -2)
|
|
55
|
+
else if (k.endsWith('s') && !k.endsWith('ss')) k = k.slice(0, -1)
|
|
56
|
+
return k
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function columnVariants(column) {
|
|
60
|
+
const snake = column.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
61
|
+
const camel = snake.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase())
|
|
62
|
+
const base = snake.replace(/_id$/, '')
|
|
63
|
+
return { names: [...new Set([column, snake, camel])], base }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build the per-run context: which column means "tenant", which tables carry it. */
|
|
67
|
+
export function tenantContext({ column = 'tenant_id', columns, tables = [], exemptTables = [], exemptPaths = [], helpers = [], scopeColumns = [] } = {}) {
|
|
68
|
+
const cols = (columns && columns.length ? columns : [column]).map(columnVariants)
|
|
69
|
+
const names = [...new Set(cols.flatMap((c) => c.names))]
|
|
70
|
+
const bases = [...new Set(cols.map((c) => c.base).filter((b) => b && !names.includes(b)))]
|
|
71
|
+
const colRe = new RegExp(`\\b(?:${names.map(esc).join('|')})\\b${bases.length ? `|\\b(?:${bases.map(esc).join('|')})(?:__\\w+)?\\s*(?:=|:)(?!=)` : ''}`, 'i')
|
|
72
|
+
const exempt = new Set(exemptTables.map(tableKey))
|
|
73
|
+
// a scope column `user` also means user_id / userId; `project_id` also means project / projectId
|
|
74
|
+
const scopeNames = [...new Set(scopeColumns.map(columnVariants).flatMap((c) => [...c.names, c.base, `${c.base}_id`, `${c.base}Id`]).filter(Boolean))]
|
|
75
|
+
const extra = [
|
|
76
|
+
...(helpers.length ? [`\\b(?:${helpers.map(esc).join('|')})\\s*\\(`] : []),
|
|
77
|
+
// `{ token, }` shorthand counts; so does a Django relation path ending in it (project__projectmember__member=…)
|
|
78
|
+
...(scopeNames.length ? [`(?:\\b|__)(?:${scopeNames.map(esc).join('|')})(?:__\\w+)?\\b\\s*(?:=|:|\\bIN\\b|,|\\})(?!=)`] : []),
|
|
79
|
+
// the parent row looked up by its own scope id: Project.objects.get(pk=project_id) / id=self.kwargs.get("project_id")
|
|
80
|
+
...(scopeNames.length ? [`\\b(?:pk|id)\\s*[=:]\\s*(?:[\\w.]*\\(\\s*['"])?(?:${scopeNames.map(esc).join('|')})\\b`] : []),
|
|
81
|
+
]
|
|
82
|
+
const scopeRe = extra.length ? new RegExp(extra.join('|'), 'i') : null
|
|
83
|
+
return {
|
|
84
|
+
columns: names,
|
|
85
|
+
colRe,
|
|
86
|
+
/** true when the text carries the tenant condition (column, a declared scope column, or a declared helper). */
|
|
87
|
+
scoped: (text) => colRe.test(text) || Boolean(scopeRe && scopeRe.test(text)),
|
|
88
|
+
declRe: new RegExp(`(?:^|[\\s,({'"\`])["'\`]?(?:${names.map(esc).join('|')}${bases.length ? `|${bases.map(esc).join('|')}` : ''})["'\`]?\\b`, 'i'),
|
|
89
|
+
tables: new Set(tables.map(tableKey).filter((k) => !exempt.has(k))),
|
|
90
|
+
tableNames: tables.filter((t) => !exempt.has(tableKey(t))),
|
|
91
|
+
exempt,
|
|
92
|
+
exemptPathRes: exemptPaths.map(globToRegExp),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Tables the schema puts under Postgres row-level security (the database enforces isolation, not the call site). */
|
|
97
|
+
export function detectRlsTables(content) {
|
|
98
|
+
return [...content.matchAll(/ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?((?:["`\w]+\.)?["`]?\w+["`]?)\s+(?:FORCE|ENABLE)\s+ROW\s+LEVEL\s+SECURITY/gi)]
|
|
99
|
+
.map((m) => m[1].replace(/["`]/g, '').split('.').pop())
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Python model classes in this file: name, base class names, and the mapped table name. */
|
|
103
|
+
export function pyModelClasses(content) {
|
|
104
|
+
const out = []
|
|
105
|
+
for (const m of content.matchAll(/^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm)) {
|
|
106
|
+
const rest = content.slice(m.index + m[0].length)
|
|
107
|
+
const end = rest.search(/^\S/m)
|
|
108
|
+
const body = end < 0 ? rest : rest.slice(0, end)
|
|
109
|
+
const tn = /__tablename__\s*=\s*['"](\w+)/.exec(body) || /db_table\s*=\s*['"](\w+)/.exec(body)
|
|
110
|
+
out.push({ name: m[1], bases: m[2].split(',').map((b) => b.trim().split('.').pop()).filter(Boolean), table: tn ? tn[1] : null })
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const DECLARATIVE_RE = /(^|\/)schema\.prisma$|\.entity\.(ts|js)$|(^|\/)(entities|entity|models?)\/[^/]+\.(ts|js|py)$|(^|\/)models\.py$|\.model\.(ts|js)$/i
|
|
116
|
+
|
|
117
|
+
/** Tables whose schema (in this file) declares the tenant column. */
|
|
118
|
+
export function detectTenantTables(rel, content, t) {
|
|
119
|
+
const found = []
|
|
120
|
+
const has = (body) => t.declRe.test(body)
|
|
121
|
+
// SQL: CREATE TABLE x ( … tenant_id … ) and ALTER TABLE x ADD [COLUMN] tenant_id
|
|
122
|
+
for (const m of content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:["`\w]+\.)?["`]?\w+["`]?)\s*\(/gi)) {
|
|
123
|
+
const open = m.index + m[0].length - 1
|
|
124
|
+
const close = balanced(content, open, '(', ')')
|
|
125
|
+
if (has(content.slice(open, close < 0 ? content.length : close))) found.push(m[1])
|
|
126
|
+
}
|
|
127
|
+
for (const m of content.matchAll(/ALTER\s+TABLE\s+(?:ONLY\s+)?((?:["`\w]+\.)?["`]?\w+["`]?)\s+ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)/gi)) {
|
|
128
|
+
if (t.columns.some((c) => c.toLowerCase() === m[2].toLowerCase())) found.push(m[1])
|
|
129
|
+
}
|
|
130
|
+
// Prisma: model X { … tenantId … @@map("x") }
|
|
131
|
+
for (const m of content.matchAll(/^model\s+(\w+)\s*\{/gm)) {
|
|
132
|
+
const close = balanced(content, m.index + m[0].length - 1, '{', '}')
|
|
133
|
+
const body = content.slice(m.index, close < 0 ? content.length : close)
|
|
134
|
+
if (new RegExp(`^\\s*(?:${t.columns.map(esc).join('|')})\\s`, 'm').test(body)) {
|
|
135
|
+
found.push(m[1])
|
|
136
|
+
const map = /@@map\(\s*"(\w+)"/.exec(body)
|
|
137
|
+
if (map) found.push(map[1])
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// TypeORM: @Entity('x') class X { … tenantId … }
|
|
141
|
+
for (const m of content.matchAll(/@Entity\s*\(\s*(?:['"`](\w+)['"`]|\{[^}]*name\s*:\s*['"`](\w+)['"`][^}]*\})?[^)]*\)\s*(?:export\s+)?(?:default\s+)?class\s+(\w+)[^{]*\{/g)) {
|
|
142
|
+
const close = balanced(content, m.index + m[0].length - 1, '{', '}')
|
|
143
|
+
if (has(content.slice(m.index + m[0].length, close < 0 ? content.length : close))) found.push(...[m[1], m[2], m[3]].filter(Boolean))
|
|
144
|
+
}
|
|
145
|
+
// Knex createTable('x', t => { t.string('tenant_id') }) / Alembic op.create_table('x', sa.Column('tenant_id', …))
|
|
146
|
+
for (const m of content.matchAll(/(?:createTable|create_table)\s*\(\s*['"`](\w+)['"`]/g)) {
|
|
147
|
+
const open = content.indexOf('(', m.index)
|
|
148
|
+
const close = balanced(content, open, '(', ')')
|
|
149
|
+
if (has(content.slice(open, close < 0 ? content.length : close))) found.push(m[1])
|
|
150
|
+
}
|
|
151
|
+
// Django / SQLAlchemy model classes: class X(models.Model|Base): … tenant = … / tenant_id = Column(…)
|
|
152
|
+
if (rel.endsWith('.py')) {
|
|
153
|
+
for (const m of content.matchAll(/^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm)) {
|
|
154
|
+
const rest = content.slice(m.index + m[0].length)
|
|
155
|
+
const end = rest.search(/^\S/m)
|
|
156
|
+
const body = end < 0 ? rest : rest.slice(0, end)
|
|
157
|
+
if (new RegExp(`^\\s+(?:${[...t.columns, ...t.columns.map((c) => c.replace(/_id$/i, ''))].map(esc).join('|')})\\s*[:=]`, 'm').test(body)) {
|
|
158
|
+
found.push(m[1])
|
|
159
|
+
const tn = /__tablename__\s*=\s*['"](\w+)/.exec(body) || /db_table\s*=\s*['"](\w+)/.exec(body)
|
|
160
|
+
if (tn) found.push(tn[1])
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return found.map((n) => n.replace(/["`]/g, '').split('.').pop())
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── detectors ─────────────────────────────────────────────────────────────
|
|
168
|
+
const SQL_START_RE = /^\s*(SELECT|UPDATE|DELETE|WITH)\b/i
|
|
169
|
+
/** A real statement, not prose that starts with "Update …": SELECT…FROM, UPDATE x SET, DELETE FROM, WITH x AS (. */
|
|
170
|
+
const SQL_SHAPE_RE = /^\s*(?:SELECT\b[\s\S]*\bFROM\b|UPDATE\s+[\w."`]+\s+(?:AS\s+\w+\s+|\w+\s+)?SET\b|DELETE\s+FROM\b|WITH\s+(?:RECURSIVE\s+)?\w+(?:\s*\([^)]*\))?\s+AS\s*\()/i
|
|
171
|
+
|
|
172
|
+
/** String literals with their offsets; `'a' + 'b'` concatenations and Python implicit concatenation are joined. */
|
|
173
|
+
function stringLiterals(src, py) {
|
|
174
|
+
const out = []
|
|
175
|
+
let i = 0
|
|
176
|
+
const n = src.length
|
|
177
|
+
const readOne = (at) => {
|
|
178
|
+
const triple = py && (src.startsWith('"""', at) || src.startsWith("'''", at))
|
|
179
|
+
const q = triple ? src.slice(at, at + 3) : src[at]
|
|
180
|
+
let j = at + q.length
|
|
181
|
+
while (j < n) {
|
|
182
|
+
if (src[j] === '\\') { j += 2; continue }
|
|
183
|
+
if (src.startsWith(q, j)) return { text: src.slice(at + q.length, j), end: j + q.length }
|
|
184
|
+
if (!triple && q !== '`' && src[j] === '\n') return null
|
|
185
|
+
j++
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
while (i < n) {
|
|
190
|
+
const c = src[i]
|
|
191
|
+
if (!py && c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i); if (i < 0) break; continue }
|
|
192
|
+
if (!py && c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i); if (i < 0) break; i += 2; continue }
|
|
193
|
+
if (py && c === '#') { i = src.indexOf('\n', i); if (i < 0) break; continue }
|
|
194
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
195
|
+
const first = readOne(i)
|
|
196
|
+
if (!first) { i++; continue }
|
|
197
|
+
let text = first.text
|
|
198
|
+
let end = first.end
|
|
199
|
+
for (;;) {
|
|
200
|
+
const m = /^\s*\+?\s*(?=["'`])/.exec(src.slice(end))
|
|
201
|
+
if (!m || (!py && !/\+/.test(m[0]))) break
|
|
202
|
+
const nx = readOne(end + m[0].length)
|
|
203
|
+
if (!nx) break
|
|
204
|
+
text += nx.text; end = nx.end
|
|
205
|
+
}
|
|
206
|
+
out.push({ at: i, text, end })
|
|
207
|
+
i = end
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
i++
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sqlTables(sql) {
|
|
216
|
+
return [...sql.matchAll(/\b(?:FROM|JOIN|UPDATE|INTO)\s+((?:["`\w]+\.)?["`]?\w+["`]?)/gi)].map((m) => m[1])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function finding(src, idx, rule, what, table, t, why) {
|
|
220
|
+
const col = t.columns[0]
|
|
221
|
+
if (rule === 'TENANT_FILTER_UNPROVEN') {
|
|
222
|
+
return { line: lineOf(src, idx), rule, severity: 'low', message: `${what} on multi-tenant table ${table} ${why || 'builds its filter from a variable/spread'}, so the ${col} filter cannot be seen here. FIX: put ${col} in the literal where-clause, or mark it \`fini-proof: tenant-exempt <reason>\`.` }
|
|
223
|
+
}
|
|
224
|
+
return { line: lineOf(src, idx), rule, severity: 'high', message: `${what} on multi-tenant table ${table} has no ${t.columns.join(' / ')} filter — any tenant's request can reach every tenant's rows (cross-tenant data leak / IDOR). FIX: add the tenant condition from the authenticated request context (not from user input), or mark a deliberate cross-tenant path \`fini-proof: tenant-exempt <reason>\`.` }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function rawSql(src, t, py) {
|
|
228
|
+
const out = []
|
|
229
|
+
const scan = (text, at) => {
|
|
230
|
+
const kind = (SQL_START_RE.exec(text) || [])[1]
|
|
231
|
+
if (!kind || !SQL_SHAPE_RE.test(text)) return
|
|
232
|
+
const hit = sqlTables(text).find((x) => t.tables.has(tableKey(x)))
|
|
233
|
+
if (hit && !t.scoped(text)) out.push(finding(src, at, 'TENANT_FILTER_MISSING', `raw SQL ${kind.toUpperCase()}`, hit.replace(/["`]/g, ''), t))
|
|
234
|
+
}
|
|
235
|
+
for (const lit of stringLiterals(src, py)) scan(lit.text, lit.at)
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sqlFile(src, t) {
|
|
240
|
+
const out = []
|
|
241
|
+
const text = src.replace(/--[^\n]*/g, (m) => ' '.repeat(m.length))
|
|
242
|
+
let at = 0
|
|
243
|
+
for (const stmt of text.split(';')) {
|
|
244
|
+
const lead = stmt.length - stmt.trimStart().length
|
|
245
|
+
const kind = (SQL_START_RE.exec(stmt) || [])[1]
|
|
246
|
+
if (kind) {
|
|
247
|
+
const hit = sqlTables(stmt).find((x) => t.tables.has(tableKey(x)))
|
|
248
|
+
if (hit && !t.scoped(stmt)) out.push(finding(src, at + lead, 'TENANT_FILTER_MISSING', `SQL ${kind.toUpperCase()}`, hit.replace(/["`]/g, ''), t))
|
|
249
|
+
}
|
|
250
|
+
at += stmt.length + 1
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ORM_METHODS = 'find|findOne|findOneBy|findBy|findOneOrFail|findAndCount|findMany|findFirst|findFirstOrThrow|findUnique|findUniqueOrThrow|findAll|findByPk|count|countBy|update|updateMany|upsert|delete|deleteMany|destroy|softDelete|aggregate|groupBy|exist|exists'
|
|
256
|
+
const RECEIVER_SUFFIX_RE = /(Repository|Repo|Model|Collection|Table|Dao|DAO|Entity|Store)$/
|
|
257
|
+
const MANAGER_RE = /^(manager|entityManager|em|dataSource|getManager|queryRunner|trx|tx|prisma)$/
|
|
258
|
+
|
|
259
|
+
function receiverTable(rec, argsHead, t) {
|
|
260
|
+
let name = rec
|
|
261
|
+
if (MANAGER_RE.test(rec)) {
|
|
262
|
+
const first = /^\(\s*([A-Z]\w*)\s*,/.exec(argsHead)
|
|
263
|
+
if (!first) return null
|
|
264
|
+
name = first[1]
|
|
265
|
+
}
|
|
266
|
+
const k = tableKey(name.replace(RECEIVER_SUFFIX_RE, '') || name)
|
|
267
|
+
return t.tables.has(k) ? name.replace(RECEIVER_SUFFIX_RE, '') || name : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** End of a chained statement in masked JS: `;` or a blank line at paren/brace depth 0. */
|
|
271
|
+
function statementEnd(masked, from) {
|
|
272
|
+
let depth = 0
|
|
273
|
+
for (let i = from; i < masked.length; i++) {
|
|
274
|
+
const c = masked[i]
|
|
275
|
+
if (c === '(' || c === '{' || c === '[') depth++
|
|
276
|
+
else if (c === ')' || c === '}' || c === ']') { if (depth === 0) return i; depth-- }
|
|
277
|
+
else if (depth === 0 && (c === ';' || (c === '\n' && /^\s*\n/.test(masked.slice(i + 1))))) return i
|
|
278
|
+
else if (depth === 0 && c === '\n' && !/^\s*[.?)]/.test(masked.slice(i + 1)) && !/[.,(=+\-*&|?:]\s*$/.test(masked.slice(Math.max(0, i - 40), i))) return i
|
|
279
|
+
}
|
|
280
|
+
return masked.length
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const INPUT_OBJ_RE = /^(input|body|params|query|req|request|args|data|payload|dto|ctx|context|event|form|values|options|opts|props)$/
|
|
284
|
+
const SDK_OBJ_RE = /(stripe|s3|sqs|sns|ses|dynamo|octokit|github|slack|twilio|resend|sendgrid|axios|http|fetch|api|sdk|redis|cache|posthog|sentry)$/i
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Why a call with no visible tenant condition is still probably checked (→ UNPROVEN), or null (→ MISSING).
|
|
288
|
+
* src/masked are the file, open/close the call's argument list, table the table key, t the tenant context.
|
|
289
|
+
*/
|
|
290
|
+
function checkedNearby(src, masked, at, close, args, table, t, scopedAt) {
|
|
291
|
+
const re = /\b(?:id|pk|\w+Id|\w+_id)\s*[:=]\s*([A-Za-z_$][\w$]*)(?:\??\.[\w$]+)*\??\.(?:id|pk|\w*Id|\w*_id)\b/
|
|
292
|
+
const k = re.exec(args)
|
|
293
|
+
if (k && !INPUT_OBJ_RE.test(k[1])) return `is re-keyed from the already-loaded ${k[1]} (the tenant check is assumed to be in the lookup that produced it)`
|
|
294
|
+
const line = lineOf(src, at)
|
|
295
|
+
if ((scopedAt.get(table) || []).some((l) => l < line && line - l <= 150)) return 'is re-keyed after an earlier tenant-scoped query on the same table in this function'
|
|
296
|
+
const lhs = /(?:const|let|var)\s+(?:\{[^}]*\}|\[[^\]]*\]|([A-Za-z_$][\w$]*))\s*=\s*(?:await\s+)?[\w$.\s]*$/.exec(masked.slice(Math.max(0, at - 160), at))
|
|
297
|
+
if (lhs && lhs[1]) {
|
|
298
|
+
const after = src.slice(close, close + 2500)
|
|
299
|
+
const colAlt = [...t.columns, ...t.columns.map((c) => c.replace(/_?[iI]d$/, ''))].map(esc).join('|')
|
|
300
|
+
if (new RegExp(`\\b${esc(lhs[1])}\\??\\.(?:${colAlt})\\b`).test(after)) return `is loaded by id and its ${t.columns[0]} is checked right after the fetch (${lhs[1]}.${t.columns[0]})`
|
|
301
|
+
}
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function unproven(args) {
|
|
306
|
+
return /\.\.\./.test(args) || /\bwhere\s*:\s*[A-Za-z_$][\w$.]*\s*[,}(]/.test(args) || /[{,]\s*where\s*[,}]/.test(args) || /^\(\s*[A-Za-z_$][\w$.]*\s*\)$/.test(args.trim())
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function jsOrm(src, t) {
|
|
310
|
+
const out = []
|
|
311
|
+
const masked = maskStringsAndComments(src)
|
|
312
|
+
// 1+2. repository / Prisma / Sequelize / Mongoose-style calls (finipe check 1 & 2, balanced args instead of a window)
|
|
313
|
+
const re = new RegExp(`([A-Za-z_$][\\w$]*)\\s*\\)?\\s*\\.\\s*(${ORM_METHODS})\\s*\\(`, 'g')
|
|
314
|
+
const scopedAt = new Map()
|
|
315
|
+
let m
|
|
316
|
+
while ((m = re.exec(masked)) !== null) {
|
|
317
|
+
const open = m.index + m[0].length - 1
|
|
318
|
+
const close = balanced(masked, open, '(', ')')
|
|
319
|
+
const args = src.slice(open, close < 0 ? src.length : close + 1)
|
|
320
|
+
// Array.prototype.find / findIndex(callback) on an in-memory list is not a query
|
|
321
|
+
if (/^\(\s*(?:async\s+)?(?:function\b|\(?\s*[\w$]*\s*(?:,\s*[\w$]+\s*)*\)?\s*=>)/.test(args)) continue
|
|
322
|
+
// stripe.subscriptions.update(…) / s3.objects.delete(…): an external SDK, not the database
|
|
323
|
+
const owner = /([A-Za-z_$][\w$]*)\s*\.\s*$/.exec(masked.slice(Math.max(0, m.index - 60), m.index))
|
|
324
|
+
if (owner && SDK_OBJ_RE.test(owner[1])) continue
|
|
325
|
+
const table = receiverTable(m[1], args, t)
|
|
326
|
+
if (!table) continue
|
|
327
|
+
if (t.scoped(args)) { const k = tableKey(table); scopedAt.set(k, [...(scopedAt.get(k) || []), lineOf(src, m.index)]); continue }
|
|
328
|
+
const why = unproven(args) ? 'builds its filter from a variable/spread' : checkedNearby(src, masked, m.index, close, args, tableKey(table), t, scopedAt)
|
|
329
|
+
out.push(finding(src, m.index, why ? 'TENANT_FILTER_UNPROVEN' : 'TENANT_FILTER_MISSING', `${m[1]}.${m[2]}()`, table, t, why))
|
|
330
|
+
}
|
|
331
|
+
// 3. query builders (finipe check 3): the whole chain up to the end of the statement must mention the tenant
|
|
332
|
+
const qb = /([A-Za-z_$][\w$]*)\s*\)?\s*\.\s*createQueryBuilder\s*\(\s*(?:['"`](\w+)['"`])?/g
|
|
333
|
+
while ((m = qb.exec(masked)) !== null) {
|
|
334
|
+
const chain = src.slice(m.index, statementEnd(masked, m.index + m[0].indexOf('createQueryBuilder')))
|
|
335
|
+
const aliasFrom = /\.\s*from\s*\(\s*([A-Z]\w*|['"`]\w+['"`])/.exec(chain)
|
|
336
|
+
const table = receiverTable(m[1], '', t) || (aliasFrom && t.tables.has(tableKey(aliasFrom[1])) ? aliasFrom[1].replace(/['"`]/g, '') : null)
|
|
337
|
+
|| (/src|Repository|Repo/.test(m[1]) && m[2] && t.tables.has(tableKey(m[2])) ? m[2] : null)
|
|
338
|
+
// finipe: a builder with no terminator in view is continued elsewhere — reported as unproven, not missing
|
|
339
|
+
const ends = /\.\s*(getMany|getOne|getOneOrFail|getRawMany|getRawOne|getCount|getManyAndCount|getExists|execute|stream)\s*\(/.test(chain)
|
|
340
|
+
if (table && !t.scoped(chain)) out.push(finding(src, m.index, ends ? 'TENANT_FILTER_MISSING' : 'TENANT_FILTER_UNPROVEN', 'query builder', table, t))
|
|
341
|
+
}
|
|
342
|
+
// Knex: knex('orders') / db('orders') / trx('orders') … chain
|
|
343
|
+
const kx = /\b(knex|db|trx|tx|this\.knex|this\.db)\s*\(\s*(['"`])(\w+)\2\s*\)/g
|
|
344
|
+
for (const k of src.matchAll(kx)) {
|
|
345
|
+
if (!t.tables.has(tableKey(k[3]))) continue
|
|
346
|
+
if (/^\s*\.\s*insert\s*\(/.test(src.slice(k.index + k[0].length))) continue
|
|
347
|
+
const chain = src.slice(k.index, statementEnd(masked, k.index + k[0].length))
|
|
348
|
+
if (!t.scoped(chain)) out.push(finding(src, k.index, 'TENANT_FILTER_MISSING', `knex('${k[3]}')`, k[3], t))
|
|
349
|
+
}
|
|
350
|
+
return out
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function pyCheckedNearby(src, code, at, stmt, table, t, scopedAt) {
|
|
354
|
+
const k = /\b(?:pk|id|\w+_id)\s*=\s*([A-Za-z_]\w*)(?:\.\w+)*\.(?:id|pk|\w+_id)\b/.exec(stmt)
|
|
355
|
+
if (k && !/^(request|data|payload|params|kwargs|args|serializer|validated_data)$/.test(k[1])) return `is re-keyed from the already-loaded ${k[1]} (the tenant check is assumed to be in the lookup that produced it)`
|
|
356
|
+
// rows keyed by an already-loaded queryset: CycleIssue.objects.filter(issue__in=issues) / issue_id__in=qs.values_list(…)
|
|
357
|
+
const inQs = /\b\w+__in\s*=\s*([a-z]\w*)\b/.exec(stmt)
|
|
358
|
+
if (inQs && !/(^|_)(ids?|pks?)$|^(request|kwargs|data|payload|params|validated_data)$/.test(inQs[1])) return `is keyed by the already-loaded queryset ${inQs[1]}`
|
|
359
|
+
// DRF field queryset: ownership belongs in the serializer's validate(); fini-proof cannot see that link
|
|
360
|
+
if (/queryset\s*=\s*$/.test(code.slice(Math.max(0, at - 40), at)) && /^\s*def\s+validate\w*\s*\(/m.test(code)) return 'is a serializer field queryset (ownership is expected in validate(), which this serializer file defines)'
|
|
361
|
+
// child rows of an already-loaded parent object: IssueLabel.objects.filter(issue=instance)
|
|
362
|
+
const parent = /\(\s*(?:[^()]*,\s*)?([a-z]\w*)\s*=\s*(instance|obj|self\.instance|self\.object|[a-z]\w*)\s*[,)]/.exec(stmt)
|
|
363
|
+
if (parent && !/(^|_)(id|pk)$|^(request|kwargs|args|data|payload|params|validated_data)$/.test(parent[2]) && !/(_id|__\w+|_in)$/.test(parent[1]) && parent[1] !== 'pk')
|
|
364
|
+
return `selects the child rows of the already-loaded ${parent[2]} (${parent[1]}=${parent[2]})`
|
|
365
|
+
const line = lineOf(src, at)
|
|
366
|
+
if ((scopedAt.get(table) || []).some((l) => l < line && line - l <= 150)) return 'is re-keyed after an earlier tenant-scoped query on the same table in this function'
|
|
367
|
+
const lhs = /(?:^|\n)\s*([A-Za-z_]\w*)\s*=\s*$/.exec(code.slice(Math.max(0, at - 120), at))
|
|
368
|
+
if (lhs) {
|
|
369
|
+
const colAlt = [...t.columns, ...t.columns.map((c) => c.replace(/_?[iI]d$/, ''))].map(esc).join('|')
|
|
370
|
+
if (new RegExp(`\\b${esc(lhs[1])}\\.(?:${colAlt})\\b`).test(code.slice(at + stmt.length, at + stmt.length + 2500))) return `is loaded by id and its ${t.columns[0]} is checked right after the fetch`
|
|
371
|
+
}
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function pyOrm(src, t) {
|
|
376
|
+
const out = []
|
|
377
|
+
const pyScopedAt = new Map()
|
|
378
|
+
const code = src.replace(/#[^\n]*/g, (x) => ' '.repeat(x.length))
|
|
379
|
+
const logicalEnd = (from) => {
|
|
380
|
+
let depth = 0
|
|
381
|
+
for (let i = from; i < code.length; i++) {
|
|
382
|
+
const c = code[i]
|
|
383
|
+
if (c === '(' || c === '[' || c === '{') depth++
|
|
384
|
+
else if (c === ')' || c === ']' || c === '}') depth--
|
|
385
|
+
else if (c === '\n' && depth <= 0 && code[i - 1] !== '\\' && !/^\s*\./.test(code.slice(i + 1, i + 200))) return i
|
|
386
|
+
}
|
|
387
|
+
return code.length
|
|
388
|
+
}
|
|
389
|
+
// Django: Model.objects.<all|filter|get|exclude|update|delete|values…>(
|
|
390
|
+
for (const m of code.matchAll(/\b([A-Z]\w*)\.objects\.(all|filter|get|exclude|update|delete|values|values_list|first|last|count|iterator|select_related|prefetch_related|order_by|get_or_create|update_or_create)\s*\(/g)) {
|
|
391
|
+
if (!t.tables.has(tableKey(m[1]))) continue
|
|
392
|
+
const stmt = code.slice(m.index, logicalEnd(m.index))
|
|
393
|
+
if (/\bOuterRef\s*\(/.test(stmt)) continue // correlated sub-query: scoped by the outer query
|
|
394
|
+
if (/\bPrefetch\s*\([^()]*$/.test(code.slice(Math.max(0, m.index - 120), m.index))) continue // Prefetch(queryset=…): scoped by the outer queryset
|
|
395
|
+
if (t.scoped(stmt)) { const k = tableKey(m[1]); pyScopedAt.set(k, [...(pyScopedAt.get(k) || []), lineOf(src, m.index)]); continue }
|
|
396
|
+
const why = /\*\*\w/.test(stmt) ? 'builds its filter from a variable/spread' : pyCheckedNearby(src, code, m.index, stmt, tableKey(m[1]), t, pyScopedAt)
|
|
397
|
+
out.push(finding(src, m.index, why ? 'TENANT_FILTER_UNPROVEN' : 'TENANT_FILTER_MISSING', `${m[1]}.objects.${m[2]}()`, m[1], t, why))
|
|
398
|
+
}
|
|
399
|
+
// SQLAlchemy: session.query(Model)… / select(Model)… / update(Model) / delete(Model)
|
|
400
|
+
for (const m of code.matchAll(/\b(query|select|update|delete)\s*\(\s*([A-Z]\w*)\b/g)) {
|
|
401
|
+
if (!t.tables.has(tableKey(m[2]))) continue
|
|
402
|
+
const stmt = code.slice(m.index, logicalEnd(m.index))
|
|
403
|
+
if (!t.scoped(stmt)) out.push(finding(src, m.index, 'TENANT_FILTER_MISSING', `${m[1]}(${m[2]})`, m[2], t))
|
|
404
|
+
}
|
|
405
|
+
return out
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function exempted(src, line) {
|
|
409
|
+
const lines = src.split('\n')
|
|
410
|
+
return /fini-proof:\s*tenant-exempt\s+\S{3,}/.test(lines.slice(Math.max(0, line - 3), line).join('\n'))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const CTL = tenantContext({ column: 'tenant_id', tables: ['orders', 'invoices'] })
|
|
414
|
+
|
|
415
|
+
export default {
|
|
416
|
+
id: 'tenant-filter',
|
|
417
|
+
version: '0.3.0',
|
|
418
|
+
title: 'Tenant filter missing on multi-tenant queries',
|
|
419
|
+
provenance: 'finipe .github/scripts/check-tenant-scoping.js detector shapes @ 4ac53b367 (last changed 990d528dd); windows → balanced extents, schema-aware table set new',
|
|
420
|
+
optionalScope: true,
|
|
421
|
+
notApplicableReason: 'no source file in scope',
|
|
422
|
+
appliesTo: (rel) => CODE_RE.test(rel) && !TEST_RE.test(rel) && !MIGRATION_PATH_RE.test(rel),
|
|
423
|
+
/** Whole-repo pass (runs even in --base mode): which tables are multi-tenant. */
|
|
424
|
+
prepare({ config = {}, files = [], read }) {
|
|
425
|
+
const cfg = config.tenant || {}
|
|
426
|
+
const explicit = Boolean(config.tenant)
|
|
427
|
+
const base = tenantContext(cfg)
|
|
428
|
+
const detected = new Set()
|
|
429
|
+
const rls = new Set()
|
|
430
|
+
const unreadable = []
|
|
431
|
+
let tablesSource = 'schema'
|
|
432
|
+
if (!cfg.tables || cfg.detect !== false) {
|
|
433
|
+
const fromDecl = new Set()
|
|
434
|
+
const fromMig = new Set()
|
|
435
|
+
const pyClasses = []
|
|
436
|
+
for (const rel of files.filter((f) => SCHEMA_RE.test(f) && !TEST_RE.test(f)).sort()) {
|
|
437
|
+
const r = read(rel)
|
|
438
|
+
if (r.error) { unreadable.push(rel); continue }
|
|
439
|
+
const decl = DECLARATIVE_RE.test(rel) && !MIGRATION_PATH_RE.test(rel)
|
|
440
|
+
for (const n of detectTenantTables(rel, r.content, base)) (decl ? fromDecl : fromMig).add(n)
|
|
441
|
+
for (const n of detectRlsTables(r.content)) rls.add(n)
|
|
442
|
+
if (decl && rel.endsWith('.py')) pyClasses.push(...pyModelClasses(r.content))
|
|
443
|
+
}
|
|
444
|
+
// inheritance: a model whose base class carries the tenant column carries it too (transitively)
|
|
445
|
+
for (let grew = true; grew;) {
|
|
446
|
+
grew = false
|
|
447
|
+
for (const c of pyClasses) {
|
|
448
|
+
if (!fromDecl.has(c.name) && c.bases.some((b) => fromDecl.has(b))) {
|
|
449
|
+
fromDecl.add(c.name)
|
|
450
|
+
if (c.table) fromDecl.add(c.table)
|
|
451
|
+
grew = true
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// the declarative schema is the CURRENT truth; migrations are history (a column may have been dropped since)
|
|
456
|
+
const src = fromDecl.size ? fromDecl : fromMig
|
|
457
|
+
tablesSource = fromDecl.size ? 'schema:models' : 'schema:migrations'
|
|
458
|
+
for (const n of src) detected.add(n)
|
|
459
|
+
}
|
|
460
|
+
if (unreadable.length) return { unmeasured: `schema file(s) could not be read, so the multi-tenant table set is unknown: ${unreadable.slice(0, 5).join(', ')}` }
|
|
461
|
+
const rlsTables = [...rls].filter((n) => !(cfg.tables || []).some((x) => tableKey(x) === tableKey(n)))
|
|
462
|
+
const t = tenantContext({ ...cfg, tables: [...new Set([...(cfg.tables || []), ...detected])], exemptTables: [...(cfg.exemptTables || []), ...rlsTables] })
|
|
463
|
+
const measurements = {
|
|
464
|
+
tenantColumns: t.columns, tenantTables: [...new Set(t.tableNames)].sort(), tablesFrom: cfg.tables ? (detected.size ? `config+${tablesSource}` : 'config') : tablesSource,
|
|
465
|
+
...(rlsTables.length ? { rlsTables: rlsTables.sort() } : {}),
|
|
466
|
+
...(cfg.helpers?.length ? { scopeHelpers: cfg.helpers } : {}),
|
|
467
|
+
...(cfg.scopeColumns?.length ? { scopeColumns: cfg.scopeColumns } : {}),
|
|
468
|
+
}
|
|
469
|
+
if (!t.tables.size) {
|
|
470
|
+
const why = `no multi-tenant table found (no schema declares ${t.columns.join(' / ')}; set "tenant": { "column": …, "tables": [...] } in .fini-proof.json)`
|
|
471
|
+
return explicit ? { unmeasured: `tenant config is set but ${why}`, measurements } : { notApplicable: why, measurements }
|
|
472
|
+
}
|
|
473
|
+
return { ctx: { tenant: t }, measurements }
|
|
474
|
+
},
|
|
475
|
+
scanFile(rel, content, ctx = {}) {
|
|
476
|
+
const t = ctx.tenant
|
|
477
|
+
if (!t || t.exemptPathRes.some((re) => re.test(rel))) return []
|
|
478
|
+
let found
|
|
479
|
+
if (rel.endsWith('.sql')) found = sqlFile(content, t)
|
|
480
|
+
else if (rel.endsWith('.py')) found = [...rawSql(content, t, true), ...pyOrm(content, t)]
|
|
481
|
+
else if (JS_RE.test(rel)) found = [...rawSql(content, t, false), ...jsOrm(content, t)]
|
|
482
|
+
else found = rawSql(content, t, false)
|
|
483
|
+
const seen = new Set()
|
|
484
|
+
return found
|
|
485
|
+
.filter((f) => { const k = `${f.line}:${f.rule}`; if (seen.has(k)) return false; seen.add(k); return true })
|
|
486
|
+
.filter((f) => !exempted(content, f.line))
|
|
487
|
+
.sort((a, b) => a.line - b.line)
|
|
488
|
+
},
|
|
489
|
+
rules: {
|
|
490
|
+
TENANT_FILTER_MISSING: { severity: 'high', summary: 'a query on a multi-tenant table has no tenant condition — one tenant can read or change another tenant\'s rows', fix: 'add the tenant condition from the authenticated context, or mark a deliberate cross-tenant path `fini-proof: tenant-exempt <reason>`' },
|
|
491
|
+
TENANT_FILTER_UNPROVEN: { severity: 'low', summary: 'the query\'s filter comes from a variable or spread, so the tenant condition cannot be seen at the call site', fix: 'write the tenant condition into the literal where-clause' },
|
|
492
|
+
},
|
|
493
|
+
negativeControls: [
|
|
494
|
+
{ id: 'tenant-filter/NC-1', file: 'src/reports.js', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "export async function orderById(db, id) {\n return db.query('SELECT * FROM orders WHERE id = $1', [id])\n}\n" },
|
|
495
|
+
{ id: 'tenant-filter/NC-2', file: 'src/orders/order.service.ts', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "export class OrderService {\n get(id: string) {\n return this.orderRepository.findOne({ where: { id } })\n }\n}\n" },
|
|
496
|
+
{ id: 'tenant-filter/NC-3', file: 'src/invoices.ts', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "export const due = () => prisma.invoice.findMany({\n where: { status: 'DUE' },\n})\n" },
|
|
497
|
+
{ id: 'tenant-filter/NC-4', file: 'app/views.py', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "def open_orders(request):\n return Order.objects.filter(status='open')\n" },
|
|
498
|
+
{ id: 'tenant-filter/NC-6', file: 'src/billing.ts', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: tenantContext({ column: 'tenant_id', tables: ['invoices'], helpers: ['tenantWhere'] }) }, content: "// a declared helper counts only where it is called\nexport const due = () => prisma.invoice.findMany({ where: { status: 'DUE' } })\n" },
|
|
499
|
+
{ id: 'tenant-filter/NC-7', file: 'src/docs/send.ts', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "// an id taken from the request is NOT a re-key: still a leak\nexport const send = (input) => prisma.invoice.update({ where: { id: input.id }, data: { sent: true } })\n" },
|
|
500
|
+
{ id: 'tenant-filter/NC-8', file: 'src/docs/resend.ts', expect: 'TENANT_FILTER_UNPROVEN', ctx: { tenant: CTL }, content: "export async function resend(ctx, id) {\n const inv = await prisma.invoice.findFirst({ where: { id, tenantId: ctx.tenantId } })\n return prisma.invoice.update({ where: { id: inv.id }, data: { sent: true } })\n}\n" },
|
|
501
|
+
{ id: 'tenant-filter/NC-9', file: 'app/serializers.py', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "class InvoiceSerializer(serializers.Serializer):\n # no validate(): any tenant's invoice id is accepted\n invoice_id = serializers.PrimaryKeyRelatedField(queryset=Invoice.objects.all())\n" },
|
|
502
|
+
{ id: 'tenant-filter/NC-5', file: 'src/admin.js', expect: 'TENANT_FILTER_MISSING', ctx: { tenant: CTL }, content: "export const cancel = (id) =>\n knex('orders').where({ id }).update({ status: 'cancelled' })\n" },
|
|
503
|
+
],
|
|
504
|
+
positiveControls: [
|
|
505
|
+
{ id: 'tenant-filter/PC-1', rule: 'TENANT_FILTER_MISSING', file: 'src/reports.js', ctx: { tenant: CTL }, content: "export const orderById = (db, tenantId, id) =>\n db.query('SELECT * FROM orders WHERE tenant_id = $1 AND id = $2', [tenantId, id])\nexport const countries = (db) => db.query('SELECT * FROM countries')\nexport const one = (id, tenantId) => this.orderRepository.findOne({ where: { id, tenantId } })\n" },
|
|
506
|
+
{ id: 'tenant-filter/PC-2', file: 'app/views.py', ctx: { tenant: CTL }, content: "def open_orders(request):\n return Order.objects.filter(tenant=request.tenant, status='open')\n" },
|
|
507
|
+
{ id: 'tenant-filter/PC-4', file: 'src/ui/list.ts', ctx: { tenant: tenantContext({ column: 'tenant_id', tables: ['orders', 'invoices'], helpers: ['tenantWhere'], scopeColumns: ['project_id'] }) }, content: "const next = orders.find((o) => o.status !== 'DONE')\nexport const due = (ctx) => prisma.invoice.findMany({ where: tenantWhere(ctx, { status: 'DUE' }) })\nexport const byProject = (projectId) => prisma.order.findMany({ where: { project_id: projectId } })\nexport const doc = { summary: 'Update orders for a template', description: 'Select invoices from the list' }\n" },
|
|
508
|
+
{ id: 'tenant-filter/PC-7', file: 'src/invites.ts', ctx: { tenant: tenantContext({ column: 'tenant_id', tables: ['invites'], scopeColumns: ['token'] }) }, content: "export const byToken = (token) => prisma.invite.findFirst({ where: { token, }, })\n" },
|
|
509
|
+
{ id: 'tenant-filter/PC-8', file: 'app/views.py', ctx: { tenant: tenantContext({ column: 'workspace_id', tables: ['projects', 'modules', 'assets'], scopeColumns: ['project_id', 'user'] }) }, content: "def get(request, project_id, asset_id):\n project = Project.objects.get(pk=project_id)\n asset = Asset.objects.get(id=asset_id, user_id=request.user.id)\n return Module.objects.prefetch_related(Prefetch('links', queryset=Module.objects.all())).filter(project_id=project_id)\n" },
|
|
510
|
+
{ id: 'tenant-filter/PC-9', file: 'app/views/activity.py', ctx: { tenant: tenantContext({ column: 'workspace_id', tables: ['issue_activities', 'projects', 'issues'], scopeColumns: ['member', 'project_id'] }) }, content: "def get(self, request, issue_id):\n project = Project.objects.get(id=self.kwargs.get(\"project_id\"))\n return (\n IssueActivity.objects.filter(issue_id=issue_id)\n .filter(project__project_projectmember__member=self.request.user)\n )\n" },
|
|
511
|
+
{ id: 'tenant-filter/PC-6', file: 'src/billing/stripe.ts', ctx: { tenant: CTL }, content: "export const bump = (id, items) => stripe.invoices.update(id, { items })\n" },
|
|
512
|
+
{ id: 'tenant-filter/PC-5', file: 'app/views.py', ctx: { tenant: CTL }, content: "def annotated(qs):\n return qs.annotate(n=Subquery(Order.objects.filter(customer_id=OuterRef('id')).values('id')[:1]))\n" },
|
|
513
|
+
{ id: 'tenant-filter/PC-10', rule: 'TENANT_FILTER_UNPROVEN', file: 'src/docs/resend.ts', ctx: { tenant: CTL }, content: "export async function resend(ctx, id) {\n const inv = await prisma.invoice.findFirst({ where: { id, tenantId: ctx.tenantId } })\n return prisma.invoice.update({ where: { id: inv.id, tenantId: ctx.tenantId }, data: { sent: true } })\n}\n" },
|
|
514
|
+
{ id: 'tenant-filter/PC-3', file: 'src/platform/stats.ts', ctx: { tenant: CTL }, content: "// fini-proof: tenant-exempt platform-admin revenue report, reads all tenants by design (SEC-19)\nexport const total = () => prisma.invoice.findMany({ select: { amount: true } })\n" },
|
|
515
|
+
],
|
|
516
|
+
}
|