shipwarden 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 +154 -2
- package/action.yml +67 -0
- package/package.json +44 -4
- package/src/action.js +94 -0
- package/src/ai-judge.js +138 -0
- package/src/checklist.js +255 -0
- package/src/cli.js +88 -0
- package/src/github.js +52 -0
- package/src/report.js +129 -0
- package/src/rules/firebase.js +116 -0
- package/src/rules/index.js +7 -0
- package/src/rules/secrets.js +121 -0
- package/src/rules/supabase.js +293 -0
- package/src/rules/web.js +193 -0
- package/src/scanner.js +177 -0
- package/src/util.js +118 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isClientCode, lineAt, looksLikePlaceholder, makeFinding } from '../util.js';
|
|
2
|
+
|
|
3
|
+
const ROTATE = 'Removing it from the code is not enough: it stays in git history and in any build you already deployed.';
|
|
4
|
+
|
|
5
|
+
const PATTERNS = [
|
|
6
|
+
{ name: 'Stripe live secret key', re: /\b[sr]k_live_[0-9a-zA-Z]{20,}\b/g, severity: 'critical', where: 'the Stripe dashboard (Developers → API keys)' },
|
|
7
|
+
{ name: 'OpenAI API key', re: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{30,}|\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/g, severity: 'critical', where: 'platform.openai.com → API keys' },
|
|
8
|
+
{ name: 'Anthropic API key', re: /\bsk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{40,}/g, severity: 'critical', where: 'the Claude Console → API keys' },
|
|
9
|
+
{ name: 'Supabase secret key', re: /\bsb_secret_[A-Za-z0-9_-]{20,}/g, severity: 'critical', where: 'Supabase → Project Settings → API Keys' },
|
|
10
|
+
{ name: 'AWS access key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, severity: 'high', where: 'the AWS IAM console' },
|
|
11
|
+
{ name: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/g, severity: 'high', where: 'GitHub → Settings → Developer settings' },
|
|
12
|
+
{ name: 'Slack token', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, severity: 'high', where: 'your Slack app settings' },
|
|
13
|
+
{ name: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, severity: 'high', where: 'SendGrid → Settings → API Keys' },
|
|
14
|
+
// Header plus an actual key body (also as an escaped "\\n" in JSON), not code that builds a PEM at runtime.
|
|
15
|
+
{ name: 'private key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----(?:\s|\\n)*[A-Za-z0-9+/=]{40,}/g, severity: 'critical', where: 'wherever it was issued' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
|
|
19
|
+
|
|
20
|
+
function jwtPayload(token) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const serviceRoleKey = {
|
|
29
|
+
id: 'supabase-service-role-key',
|
|
30
|
+
title: 'Supabase service_role key is in the code',
|
|
31
|
+
severity: 'critical',
|
|
32
|
+
check(file, ctx) {
|
|
33
|
+
const findings = [];
|
|
34
|
+
for (const m of file.content.matchAll(JWT)) {
|
|
35
|
+
if (jwtPayload(m[0])?.role !== 'service_role') continue;
|
|
36
|
+
const inBrowser = isClientCode(file, ctx);
|
|
37
|
+
findings.push(
|
|
38
|
+
makeFinding(this, file.path, lineAt(file.content, m.index), {
|
|
39
|
+
message:
|
|
40
|
+
'This is your Supabase `service_role` key. It skips every Row Level Security rule, so anyone who gets it can read, change or delete everything in your database' +
|
|
41
|
+
(inBrowser ? ', and this file is shipped to every visitor\'s browser.' : '.'),
|
|
42
|
+
fix: `Rotate the key now in Supabase (Project Settings → API Keys), delete it from the code, and only use it in server code through an environment variable with no NEXT_PUBLIC_/VITE_ prefix. ${ROTATE}`,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return findings;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const hardcodedSecret = {
|
|
51
|
+
id: 'hardcoded-secret',
|
|
52
|
+
title: 'Secret key committed to the repo',
|
|
53
|
+
severity: 'critical',
|
|
54
|
+
check(file) {
|
|
55
|
+
// Docs are full of illustrative keys; a real one there is rare enough to accept missing.
|
|
56
|
+
if (/\.md$|(^|\/)docs?\//i.test(file.path)) return [];
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const p of PATTERNS) {
|
|
59
|
+
for (const m of file.content.matchAll(p.re)) {
|
|
60
|
+
// Skip placeholders and regex literals such as a secret scanner's own patterns.
|
|
61
|
+
if (looksLikePlaceholder(m[0]) || file.content[m.index - 1] === '/') continue;
|
|
62
|
+
findings.push(
|
|
63
|
+
makeFinding(this, file.path, lineAt(file.content, m.index), {
|
|
64
|
+
severity: p.severity,
|
|
65
|
+
title: `${p.name[0].toUpperCase()}${p.name.slice(1)} committed to the repo`,
|
|
66
|
+
message: `A ${p.name} is written directly in this file. Anyone who can see the repo, or your shipped JavaScript if this runs in the browser, can use it on your account.`,
|
|
67
|
+
fix: `Revoke it in ${p.where}, then load the new one from a server-side environment variable. ${ROTATE}`,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const PUBLIC_VAR = /\b(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|REACT_APP_|NUXT_PUBLIC_|PUBLIC_)([A-Z0-9_]+)/g;
|
|
77
|
+
const CRITICAL_NAME = /SERVICE_ROLE|(^|_)SECRET(_|$)|PRIVATE_KEY|DATABASE_URL$|DB_URL$|POSTGRES_URL$|PASSWORD$/;
|
|
78
|
+
const AI_KEY_NAME = /(^|_)(OPENAI|ANTHROPIC|CLAUDE|GEMINI|GOOGLE_AI|GROQ|MISTRAL|REPLICATE|ELEVENLABS|RESEND|SENDGRID|TWILIO|DEEPSEEK|PERPLEXITY|OPENROUTER|XAI|TOGETHER|FAL|COHERE|HUGGINGFACE|HF)_(API_)?(KEY|TOKEN)$/;
|
|
79
|
+
|
|
80
|
+
export const publicEnvSecret = {
|
|
81
|
+
id: 'public-env-secret',
|
|
82
|
+
title: 'Secret exposed through a public env variable',
|
|
83
|
+
severity: 'critical',
|
|
84
|
+
check(file) {
|
|
85
|
+
const findings = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const m of file.content.matchAll(PUBLIC_VAR)) {
|
|
88
|
+
const [name, prefix, rest] = m;
|
|
89
|
+
if (seen.has(name) || /PUBLISHABLE|ANON/.test(rest)) continue;
|
|
90
|
+
const critical = CRITICAL_NAME.test(rest);
|
|
91
|
+
if (!critical && !AI_KEY_NAME.test(rest)) continue;
|
|
92
|
+
// The ai-key-in-browser finding already covers an AI key used from browser code.
|
|
93
|
+
if (!critical && /dangerouslyAllowBrowser\s*:\s*true/.test(file.content)) continue;
|
|
94
|
+
seen.add(name);
|
|
95
|
+
findings.push(
|
|
96
|
+
makeFinding(this, file.path, lineAt(file.content, m.index), {
|
|
97
|
+
severity: critical ? 'critical' : 'high',
|
|
98
|
+
title: `\`${name}\` is exposed to the browser`,
|
|
99
|
+
message: `Variables starting with \`${prefix}\` are copied into your frontend JavaScript at build time, so anyone can open dev tools and read \`${name}\`.`,
|
|
100
|
+
fix: `Rename it without the \`${prefix}\` prefix and only use it in server code (API routes, server actions, edge functions). Then rotate the value, because every deployed build already contains it.`,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const aiKeyInBrowser = {
|
|
109
|
+
id: 'ai-key-in-browser',
|
|
110
|
+
title: 'AI API key is sent to the browser',
|
|
111
|
+
severity: 'high',
|
|
112
|
+
message:
|
|
113
|
+
'The AI client is created with `dangerouslyAllowBrowser` switched on, so your API key ships to every visitor. Anyone can copy it and run up your bill.',
|
|
114
|
+
fix: 'Move the AI call into a server route, server action or edge function, and call that from the browser instead.',
|
|
115
|
+
check(file) {
|
|
116
|
+
const m = /dangerouslyAllowBrowser\s*:\s*true/.exec(file.content);
|
|
117
|
+
return m ? [makeFinding(this, file.path, lineAt(file.content, m.index))] : [];
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export default [serviceRoleKey, hardcodedSecret, publicEnvSecret, aiKeyInBrowser];
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { blank, lineAt, makeFinding } from '../util.js';
|
|
2
|
+
|
|
3
|
+
const IDENT = String.raw`(?:"[^"]+"|[A-Za-z_][\w$]*)`;
|
|
4
|
+
const QNAME = String.raw`(?<name>${IDENT}(?:\s*\.\s*${IDENT})?)`;
|
|
5
|
+
|
|
6
|
+
const STATEMENTS = {
|
|
7
|
+
createTable: new RegExp(String.raw`\bcreate\s+(?:(?:global|local)\s+)?(?<temp>temp(?:orary)?\s+)?(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?${QNAME}`, 'gi'),
|
|
8
|
+
dropTable: new RegExp(String.raw`\bdrop\s+table\s+(?:if\s+exists\s+)?${QNAME}`, 'gi'),
|
|
9
|
+
toggleRls: new RegExp(String.raw`\balter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${QNAME}\s+(?<mode>enable|disable)\s+row\s+level\s+security`, 'gi'),
|
|
10
|
+
createView: new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?<mat>materialized\s+)?view\s+(?:if\s+not\s+exists\s+)?${QNAME}(?<rest>[^;]*)`, 'gi'),
|
|
11
|
+
alterView: new RegExp(String.raw`\balter\s+view\s+(?:if\s+exists\s+)?${QNAME}\s+set\s*\((?<opts>[^)]*)\)`, 'gi'),
|
|
12
|
+
dropView: new RegExp(String.raw`\bdrop\s+(?:materialized\s+)?view\s+(?:if\s+exists\s+)?${QNAME}`, 'gi'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Matched with DO-block bodies kept: Lovable/AI migrations often wrap these in `DO $$ BEGIN ... END $$`.
|
|
16
|
+
const IN_BLOCKS = {
|
|
17
|
+
toggleRls: STATEMENTS.toggleRls,
|
|
18
|
+
createPolicy: new RegExp(String.raw`\bcreate\s+policy\s+(?<policy>"[^"]+"|[\w$]+)\s+on\s+${QNAME}(?<body>[^;]*)`, 'gi'),
|
|
19
|
+
dropPolicy: new RegExp(String.raw`\bdrop\s+policy\s+(?:if\s+exists\s+)?(?<policy>"[^"]+"|[\w$]+)\s+on\s+${QNAME}`, 'gi'),
|
|
20
|
+
};
|
|
21
|
+
delete STATEMENTS.toggleRls;
|
|
22
|
+
|
|
23
|
+
const DYNAMIC_RLS = /format\s*\(\s*'+[^']*?%I\s+enable\s+row\s+level\s+security/gi;
|
|
24
|
+
const DO_START = /\bdo\s*(\$\w*\$)/gi;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `execute format('alter table public.%I enable row level security', t)` inside a DO block.
|
|
28
|
+
* Returns the tables it covers: explicit names from the loop's array, or every table when it
|
|
29
|
+
* iterates over the catalog.
|
|
30
|
+
*/
|
|
31
|
+
function dynamicRls(text) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const m of text.matchAll(DYNAMIC_RLS)) {
|
|
34
|
+
let start = Math.max(0, m.index - 3000);
|
|
35
|
+
let end = m.index + 500;
|
|
36
|
+
for (const d of text.matchAll(DO_START)) {
|
|
37
|
+
if (d.index > m.index) break;
|
|
38
|
+
start = d.index;
|
|
39
|
+
const close = text.indexOf(d[1], d.index + d[0].length);
|
|
40
|
+
end = close === -1 ? text.length : close;
|
|
41
|
+
}
|
|
42
|
+
const block = text.slice(start, end);
|
|
43
|
+
const all = /pg_tables|information_schema\.tables|pg_class/i.test(block);
|
|
44
|
+
const names = [...block.matchAll(/'([A-Za-z_][\w$]*)'/g)].map((n) => n[1].toLowerCase()).filter((n) => n !== 'public');
|
|
45
|
+
out.push({ index: m.index, all, names });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Blank out comments and (unless keepBodies) dollar-quoted bodies, keeping offsets intact. */
|
|
51
|
+
export function cleanSql(sql, { keepBodies = false } = {}) {
|
|
52
|
+
let out = '';
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < sql.length) {
|
|
55
|
+
const c = sql[i];
|
|
56
|
+
const n = sql[i + 1];
|
|
57
|
+
let j;
|
|
58
|
+
if (c === '-' && n === '-') {
|
|
59
|
+
j = sql.indexOf('\n', i);
|
|
60
|
+
if (j === -1) j = sql.length;
|
|
61
|
+
out += blank(sql.slice(i, j));
|
|
62
|
+
} else if (c === '/' && n === '*') {
|
|
63
|
+
j = sql.indexOf('*/', i + 2);
|
|
64
|
+
j = j === -1 ? sql.length : j + 2;
|
|
65
|
+
out += blank(sql.slice(i, j));
|
|
66
|
+
} else if (c === "'") {
|
|
67
|
+
j = i + 1;
|
|
68
|
+
while (j < sql.length && !(sql[j] === "'" && sql[j + 1] !== "'")) j += sql[j] === "'" ? 2 : 1;
|
|
69
|
+
j = Math.min(j + 1, sql.length);
|
|
70
|
+
out += sql.slice(i, j);
|
|
71
|
+
} else if (!keepBodies && c === '$' && /^\$(?:[A-Za-z_]\w*)?\$/.test(sql.slice(i, i + 64))) {
|
|
72
|
+
const tag = /^\$(?:[A-Za-z_]\w*)?\$/.exec(sql.slice(i, i + 64))[0];
|
|
73
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
74
|
+
j = end === -1 ? sql.length : end + tag.length;
|
|
75
|
+
out += blank(sql.slice(i, j));
|
|
76
|
+
} else {
|
|
77
|
+
j = i + 1;
|
|
78
|
+
out += c;
|
|
79
|
+
}
|
|
80
|
+
i = j;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseName(raw) {
|
|
86
|
+
const parts = raw.match(/"[^"]+"|[A-Za-z_][\w$]*/g).map((p) => (p.startsWith('"') ? p.slice(1, -1) : p.toLowerCase()));
|
|
87
|
+
return parts.length > 1 ? { schema: parts[0], name: parts[1] } : { schema: 'public', name: parts[0] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function invokerOn(opts) {
|
|
91
|
+
const m = /security_invoker(?:\s*=\s*'?(\w+)'?)?/i.exec(opts ?? '');
|
|
92
|
+
return !!m && (!m[1] || /^(true|on|1|yes)$/i.test(m[1]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cache = new WeakMap();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replay the migrations in order to work out the final state of tables, views and policies.
|
|
99
|
+
* When the repo has supabase/migrations, only those count: other .sql files are usually
|
|
100
|
+
* backups, snapshots or docs. RLS enabled in *any* .sql file still clears a table, because a
|
|
101
|
+
* false "your data is public" alarm costs more trust than a missed one.
|
|
102
|
+
*/
|
|
103
|
+
function analyze(files) {
|
|
104
|
+
if (cache.has(files)) return cache.get(files);
|
|
105
|
+
const sql = files.filter((f) => f.path.endsWith('.sql'));
|
|
106
|
+
const migrations = sql.filter((f) => /(^|\/)supabase\/(migrations|schemas)\//.test(f.path));
|
|
107
|
+
const replayed = (migrations.length ? migrations : sql).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
108
|
+
|
|
109
|
+
const enabledAnywhere = new Set();
|
|
110
|
+
for (const file of sql) {
|
|
111
|
+
for (const m of cleanSql(file.content, { keepBodies: true }).matchAll(IN_BLOCKS.toggleRls)) {
|
|
112
|
+
const { schema, name } = parseName(m.groups.name);
|
|
113
|
+
if (m.groups.mode.toLowerCase() === 'enable') enabledAnywhere.add(`${schema}.${name}`);
|
|
114
|
+
}
|
|
115
|
+
for (const d of dynamicRls(cleanSql(file.content, { keepBodies: true }))) {
|
|
116
|
+
for (const n of d.names) enabledAnywhere.add(`public.${n}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const events = [];
|
|
121
|
+
replayed.forEach((file, fi) => {
|
|
122
|
+
const groups = [
|
|
123
|
+
[cleanSql(file.content), STATEMENTS],
|
|
124
|
+
[cleanSql(file.content, { keepBodies: true }), IN_BLOCKS],
|
|
125
|
+
];
|
|
126
|
+
for (const [text, statements] of groups) {
|
|
127
|
+
for (const [kind, re] of Object.entries(statements)) {
|
|
128
|
+
for (const m of text.matchAll(re)) events.push({ fi, kind, m, file });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const d of dynamicRls(groups[1][0])) events.push({ fi, kind: 'dynamicRls', m: { index: d.index }, d, file });
|
|
132
|
+
});
|
|
133
|
+
events.sort((a, b) => a.fi - b.fi || a.m.index - b.m.index);
|
|
134
|
+
|
|
135
|
+
const tables = new Map();
|
|
136
|
+
const views = new Map();
|
|
137
|
+
const policies = new Map();
|
|
138
|
+
for (const { kind, m, file, d } of events) {
|
|
139
|
+
if (kind === 'dynamicRls') {
|
|
140
|
+
for (const t of tables.values()) if (t.schema === 'public' && (d.all || d.names.includes(t.name.toLowerCase()))) t.rls = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const { schema, name } = parseName(m.groups.name);
|
|
144
|
+
const key = `${schema}.${name}`;
|
|
145
|
+
const loc = { file: file.path, line: lineAt(file.content, m.index) };
|
|
146
|
+
if (kind === 'createTable') {
|
|
147
|
+
const parent = new RegExp(String.raw`^\s*partition\s+of\s+${QNAME}`, 'i').exec(file.content.slice(m.index + m[0].length, m.index + m[0].length + 300));
|
|
148
|
+
const partitionOf = parent && parseName(parent.groups.name).name;
|
|
149
|
+
if (!m.groups.temp && !tables.has(key)) tables.set(key, { schema, name, rls: false, partitionOf, ...loc });
|
|
150
|
+
} else if (kind === 'dropTable') {
|
|
151
|
+
tables.delete(key);
|
|
152
|
+
} else if (kind === 'toggleRls') {
|
|
153
|
+
const enable = m.groups.mode.toLowerCase() === 'enable';
|
|
154
|
+
const table = tables.get(key) ?? { schema, name, ...loc };
|
|
155
|
+
table.rls = enable;
|
|
156
|
+
if (!enable) Object.assign(table, loc, { disabledExplicitly: true });
|
|
157
|
+
tables.set(key, table);
|
|
158
|
+
} else if (kind === 'createView') {
|
|
159
|
+
const withOpts = /^\s*with\s*\(([^)]*)\)/i.exec(m.groups.rest)?.[1];
|
|
160
|
+
views.set(key, { schema, name, materialized: !!m.groups.mat, invoker: invokerOn(withOpts), ...loc });
|
|
161
|
+
} else if (kind === 'alterView') {
|
|
162
|
+
const view = views.get(key);
|
|
163
|
+
if (view && invokerOn(m.groups.opts)) view.invoker = true;
|
|
164
|
+
} else if (kind === 'dropView') {
|
|
165
|
+
views.delete(key);
|
|
166
|
+
} else if (kind === 'createPolicy') {
|
|
167
|
+
policies.set(`${key}:${m.groups.policy}`, { schema, name, body: m.groups.body, ...loc });
|
|
168
|
+
} else if (kind === 'dropPolicy') {
|
|
169
|
+
policies.delete(`${key}:${m.groups.policy}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const [key, t] of tables) {
|
|
173
|
+
if (!t.rls && !t.disabledExplicitly && enabledAnywhere.has(key)) t.rls = true;
|
|
174
|
+
}
|
|
175
|
+
const result = { tables, views, policies: [...policies.values()] };
|
|
176
|
+
cache.set(files, result);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const rlsDisabled = {
|
|
181
|
+
id: 'supabase-rls-disabled',
|
|
182
|
+
title: 'Row Level Security is off',
|
|
183
|
+
severity: 'critical',
|
|
184
|
+
checkAll(files, ctx) {
|
|
185
|
+
if (!ctx.usesSupabase) return [];
|
|
186
|
+
const findings = [];
|
|
187
|
+
const partitions = new Map();
|
|
188
|
+
for (const t of analyze(files).tables.values()) {
|
|
189
|
+
if (t.rls || t.schema !== 'public') continue;
|
|
190
|
+
if (t.partitionOf) {
|
|
191
|
+
if (!partitions.has(t.partitionOf)) partitions.set(t.partitionOf, []);
|
|
192
|
+
partitions.get(t.partitionOf).push(t);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
findings.push(
|
|
196
|
+
makeFinding(this, t.file, t.line, {
|
|
197
|
+
title: `Row Level Security is off for table \`${t.name}\``,
|
|
198
|
+
message:
|
|
199
|
+
`${t.disabledExplicitly ? 'This migration turns Row Level Security off' : 'This table is created without Row Level Security'}. ` +
|
|
200
|
+
'Supabase serves every table in the `public` schema through its API, and your anon key is public (it is in your frontend), ' +
|
|
201
|
+
`so anyone can read, change or delete every row in \`${t.name}\`.`,
|
|
202
|
+
fix:
|
|
203
|
+
`Add a migration with \`alter table public.${t.name} enable row level security;\` and then policies that limit rows to their owner, ` +
|
|
204
|
+
`e.g. \`create policy "Owners can read" on public.${t.name} for select using (auth.uid() = user_id);\``,
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
for (const [parent, parts] of partitions) {
|
|
209
|
+
const [first] = parts;
|
|
210
|
+
findings.push(
|
|
211
|
+
makeFinding(this, first.file, first.line, {
|
|
212
|
+
title: `Row Level Security is off for ${parts.length} partition${parts.length === 1 ? '' : 's'} of \`${parent}\``,
|
|
213
|
+
message:
|
|
214
|
+
`${parts.map((t) => `\`${t.name}\``).slice(0, 4).join(', ')}${parts.length > 4 ? ` and ${parts.length - 4} more` : ''} have no Row Level Security. ` +
|
|
215
|
+
"Supabase's API serves each partition as its own table, and the parent table's policies don't apply when a partition is queried directly.",
|
|
216
|
+
fix: `Enable RLS on every partition (\`alter table public.<partition> enable row level security;\`), or revoke API access to them: \`revoke all on public.<partition> from anon, authenticated;\``,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const permissivePolicy = {
|
|
225
|
+
id: 'supabase-permissive-policy',
|
|
226
|
+
title: 'RLS policy allows everyone',
|
|
227
|
+
severity: 'high',
|
|
228
|
+
checkAll(files, ctx) {
|
|
229
|
+
if (!ctx.usesSupabase) return [];
|
|
230
|
+
const findings = [];
|
|
231
|
+
for (const p of analyze(files).policies) {
|
|
232
|
+
if (p.schema !== 'public' || /\bas\s+restrictive\b/i.test(p.body)) continue;
|
|
233
|
+
const cmd = (/\bfor\s+(all|select|insert|update|delete)\b/i.exec(p.body)?.[1] ?? 'all').toLowerCase();
|
|
234
|
+
const rolesRaw = /\bto\s+((?:"[^"]+"|[\w$]+)(?:\s*,\s*(?:"[^"]+"|[\w$]+))*)/i.exec(p.body)?.[1];
|
|
235
|
+
const roles = rolesRaw ? rolesRaw.split(',').map((r) => r.trim().replace(/"/g, '').toLowerCase()) : ['public'];
|
|
236
|
+
if (roles.every((r) => ['service_role', 'postgres', 'supabase_admin'].includes(r))) continue;
|
|
237
|
+
const usingTrue = /\busing\s*\(\s*true\s*\)/i.test(p.body);
|
|
238
|
+
const checkTrue = /\bwith\s+check\s*\(\s*true\s*\)/i.test(p.body);
|
|
239
|
+
const anyone = roles.some((r) => r === 'public' || r === 'anon');
|
|
240
|
+
const who = anyone ? 'anyone, even logged-out visitors,' : 'any logged-in user';
|
|
241
|
+
const whoShort = anyone ? 'anyone' : 'any logged-in user';
|
|
242
|
+
|
|
243
|
+
if (['all', 'update', 'delete'].includes(cmd) && usingTrue) {
|
|
244
|
+
const verb = { all: 'read, change and delete', update: 'change', delete: 'delete' }[cmd];
|
|
245
|
+
findings.push(
|
|
246
|
+
makeFinding(this, p.file, p.line, {
|
|
247
|
+
title: `RLS policy lets ${whoShort} ${verb} any row in \`${p.name}\``,
|
|
248
|
+
message: `\`using (true)\` never checks who owns the row, so this policy lets ${who} ${verb} every row in \`${p.name}\`.`,
|
|
249
|
+
fix: 'Replace `true` with an ownership check, e.g. `using (auth.uid() = user_id) with check (auth.uid() = user_id)`.',
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
} else if (cmd === 'insert' && checkTrue) {
|
|
253
|
+
findings.push(
|
|
254
|
+
makeFinding(this, p.file, p.line, {
|
|
255
|
+
severity: 'low',
|
|
256
|
+
confidence: 'medium',
|
|
257
|
+
title: `RLS policy lets ${whoShort} insert anything into \`${p.name}\``,
|
|
258
|
+
message: `\`with check (true)\` lets ${who} insert rows with any values, including someone else's \`user_id\`. Fine for a public form, risky for user-owned data.`,
|
|
259
|
+
fix: 'If rows belong to a user, use `with check (auth.uid() = user_id)`.',
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return findings;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export const unsafeView = {
|
|
269
|
+
id: 'supabase-view-bypasses-rls',
|
|
270
|
+
title: 'View bypasses Row Level Security',
|
|
271
|
+
severity: 'medium',
|
|
272
|
+
checkAll(files, ctx) {
|
|
273
|
+
if (!ctx.usesSupabase) return [];
|
|
274
|
+
const findings = [];
|
|
275
|
+
for (const v of analyze(files).views.values()) {
|
|
276
|
+
if (v.schema !== 'public' || v.invoker) continue;
|
|
277
|
+
findings.push(
|
|
278
|
+
makeFinding(this, v.file, v.line, {
|
|
279
|
+
title: `View \`${v.name}\` bypasses Row Level Security`,
|
|
280
|
+
message: v.materialized
|
|
281
|
+
? `Materialized views ignore RLS, and \`${v.name}\` is in the \`public\` schema, so anyone with your anon key can read all of it through the API.`
|
|
282
|
+
: `Views run with their creator's permissions by default, so \`${v.name}\` ignores the RLS policies on the tables it reads and anyone with your anon key can query it.`,
|
|
283
|
+
fix: v.materialized
|
|
284
|
+
? `Move it to a schema that isn't exposed (e.g. \`private\`), or revoke access: \`revoke select on public.${v.name} from anon, authenticated;\``
|
|
285
|
+
: `Recreate it with \`create view public.${v.name} with (security_invoker = true) as ...\` (Postgres 15+), or \`alter view public.${v.name} set (security_invoker = true);\``,
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return findings;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export default [rlsDisabled, permissivePolicy, unsafeView];
|
package/src/rules/web.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { EDGE_FUNCTION, ROUTE_HANDLER, hasDirective, isServerEndpoint, lineAt, makeFinding } from '../util.js';
|
|
2
|
+
|
|
3
|
+
const MUTATING_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b|export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=|\bas\s+(POST|PUT|PATCH|DELETE)\b/;
|
|
4
|
+
|
|
5
|
+
const AUTH_CHECK = new RegExp(
|
|
6
|
+
[
|
|
7
|
+
String.raw`\b(?:getUser|getClaims|getSession|getServerSession|getServerAuthSession|currentUser|getAuth|verifyIdToken|verifySessionCookie|validateRequest|getToken|getKindeServerSession|jwtVerify|constructEvent|constructEventAsync|verifyWebhook|timingSafeEqual)\s*\(`,
|
|
8
|
+
String.raw`\b(?:require|check|ensure|assert|verify|with|is)(?:Auth|Authenticated|User|Admin|Session|Signature)\w*\s*\(`,
|
|
9
|
+
String.raw`\bauth\s*\(\s*\)`,
|
|
10
|
+
String.raw`\.protect\s*\(`,
|
|
11
|
+
String.raw`\bjwt\.verify\s*\(`,
|
|
12
|
+
String.raw`(?:===|!==)\s*(?:process\.env|Deno\.env\.get)\b`,
|
|
13
|
+
String.raw`[!=]==\s*\x60Bearer\s`,
|
|
14
|
+
String.raw`headers\.get\(\s*['"]x-[\w-]*(?:secret|token|key|signature)['"]\s*\)`,
|
|
15
|
+
String.raw`\.rpc\(\s*['"](?:has_role|is_admin|has_permission|check_\w+)['"]`,
|
|
16
|
+
].join('|'),
|
|
17
|
+
'i',
|
|
18
|
+
);
|
|
19
|
+
const GUARD_NAME = /auth|guard|verif|require|protect|permission|assert|ensure|check/i;
|
|
20
|
+
const CLIENT_FACTORY = /client$|^(create|get|new|init|make)\w*(client|supabase|stripe|db)/i;
|
|
21
|
+
|
|
22
|
+
/** Calls an auth helper it imports or defines, e.g. `await authorize(req)` from ../_shared/authorize.ts. */
|
|
23
|
+
function callsGuardHelper(code) {
|
|
24
|
+
const names = new Set();
|
|
25
|
+
for (const m of code.matchAll(/import\s*\{([^}]*)\}\s*from/g)) {
|
|
26
|
+
for (const part of m[1].split(',')) names.add(part.trim().split(/\s+as\s+/).pop());
|
|
27
|
+
}
|
|
28
|
+
for (const m of code.matchAll(/import\s+(\w+)\s*(?:,|from)/g)) names.add(m[1]);
|
|
29
|
+
for (const m of code.matchAll(/(?:function\s+|const\s+)(\w+)\s*(?:=\s*(?:async\s*)?\(|\()/g)) names.add(m[1]);
|
|
30
|
+
return [...names].some((n) => n && GUARD_NAME.test(n) && !CLIENT_FACTORY.test(n) && new RegExp(String.raw`\b${n}\s*\(`).test(code.replace(/import[^;]*;/g, '')));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ADMIN_CLIENT = /service[_-]?role|supabaseAdmin|createAdminClient|createServiceClient|createServiceRoleClient|adminClient|SUPABASE_SECRET_KEY|\.auth\.admin\./i;
|
|
34
|
+
const SUPABASE_WRITE = /\.from\(\s*['"`][^'"`]+['"`]\s*\)\s*\.(insert|update|upsert|delete)\s*\(|\.auth\.admin\.(deleteUser|updateUserById|createUser|inviteUserByEmail)\s*\(/;
|
|
35
|
+
const ORM_WRITE = /\bprisma\.\w+\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(|\bdb\.(insert|update|delete)\s*\(|\.(insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|findOneAndUpdate|findOneAndDelete|findByIdAndUpdate|findByIdAndDelete)\s*\(|\bsql`\s*(insert|update|delete)\b/i;
|
|
36
|
+
|
|
37
|
+
function endpointLabel(file) {
|
|
38
|
+
if (EDGE_FUNCTION.test(file.path)) return 'Supabase Edge Function';
|
|
39
|
+
if (ROUTE_HANDLER.test(file.path) || /pages\/api\//.test(file.path)) return 'API route';
|
|
40
|
+
return "server action (every exported function in a 'use server' file is a public endpoint anyone can call)";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const unauthenticatedWrite = {
|
|
44
|
+
id: 'unauthenticated-write',
|
|
45
|
+
title: 'Endpoint changes data without checking who is calling',
|
|
46
|
+
severity: 'medium',
|
|
47
|
+
confidence: 'medium',
|
|
48
|
+
check(file) {
|
|
49
|
+
if (!isServerEndpoint(file)) return [];
|
|
50
|
+
const c = file.content;
|
|
51
|
+
if (ROUTE_HANDLER.test(file.path) && !MUTATING_EXPORT.test(c)) return [];
|
|
52
|
+
if (AUTH_CHECK.test(c) || callsGuardHelper(c)) return [];
|
|
53
|
+
|
|
54
|
+
// A plain Supabase client is still protected by RLS; the admin client is not.
|
|
55
|
+
const admin = ADMIN_CLIENT.test(c);
|
|
56
|
+
const m = (admin && SUPABASE_WRITE.exec(c)) || ORM_WRITE.exec(c);
|
|
57
|
+
if (!m) return [];
|
|
58
|
+
const viaAdmin = admin && SUPABASE_WRITE.test(m[0]);
|
|
59
|
+
const call = `${m[0].replace(/\s+/g, '').replace(/^\./, '')}${m[0].trim().endsWith('(') ? ')' : ''}`;
|
|
60
|
+
return [
|
|
61
|
+
makeFinding(this, file.path, lineAt(c, m.index), {
|
|
62
|
+
severity: viaAdmin ? 'high' : 'medium',
|
|
63
|
+
message:
|
|
64
|
+
`This ${endpointLabel(file)} writes to the database (\`${call}\`) but never checks who the caller is. ` +
|
|
65
|
+
(viaAdmin
|
|
66
|
+
? 'It uses the Supabase admin/service-role client, which skips RLS, so anyone who finds the URL can change or delete any data.'
|
|
67
|
+
: 'Direct database clients skip Supabase RLS, so anyone who finds the URL can make this change.'),
|
|
68
|
+
fix:
|
|
69
|
+
'Check the user at the top of the handler and confirm they own the data before writing. For example: ' +
|
|
70
|
+
"`const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 })` " +
|
|
71
|
+
'If auth is enforced elsewhere (middleware, a wrapper), add a `shipwarden-ignore` comment above this line.',
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const STRIPE_IMPORT = /import[^;]*['"][^'"]*\bstripe\b[^'"]*['"]|require\(\s*['"]stripe['"]\s*\)|new\s+Stripe\s*\(/;
|
|
78
|
+
|
|
79
|
+
export const stripeWebhook = {
|
|
80
|
+
id: 'stripe-webhook-unverified',
|
|
81
|
+
title: 'Stripe webhook does not verify the signature',
|
|
82
|
+
severity: 'high',
|
|
83
|
+
message:
|
|
84
|
+
"This Stripe webhook handler never checks the `Stripe-Signature` header, so anyone can send it a fake \"payment succeeded\" event and unlock paid features for free.",
|
|
85
|
+
fix: "Verify every event: `const event = stripe.webhooks.constructEvent(rawBody, req.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET)` (use `constructEventAsync` on Edge/Deno).",
|
|
86
|
+
check(file) {
|
|
87
|
+
if (!/webhook/i.test(file.path) || !/\.[cm]?[jt]sx?$/.test(file.path)) return [];
|
|
88
|
+
const c = file.content;
|
|
89
|
+
if (!STRIPE_IMPORT.test(c) || /constructEvent/.test(c)) return [];
|
|
90
|
+
const handler = /export\s+(?:async\s+)?function\s+POST\b|export\s+const\s+POST\b|export\s+default|\bserve\s*\(|Deno\.serve\s*\(/.exec(c);
|
|
91
|
+
return [makeFinding(this, file.path, handler ? lineAt(c, handler.index) : 1)];
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const getSessionOnServer = {
|
|
96
|
+
id: 'supabase-getsession-on-server',
|
|
97
|
+
title: 'Server trusts `getSession()` for auth',
|
|
98
|
+
severity: 'medium',
|
|
99
|
+
confidence: 'medium',
|
|
100
|
+
message:
|
|
101
|
+
"`supabase.auth.getSession()` reads the session from cookies without verifying it, so on the server a user can forge it. Supabase's docs say never to trust it in server code.",
|
|
102
|
+
fix: 'Use `supabase.auth.getUser()` (or `getClaims()`) for any auth decision on the server.',
|
|
103
|
+
check(file, ctx) {
|
|
104
|
+
if (!ctx.usesSupabase) return [];
|
|
105
|
+
const isServer =
|
|
106
|
+
isServerEndpoint(file) ||
|
|
107
|
+
/(^|\/)middleware\.[jt]s$/.test(file.path) ||
|
|
108
|
+
(/(^|\/)app\/(?:.*\/)?(page|layout)\.[jt]sx?$/.test(file.path) && !hasDirective(file.content, 'use client'));
|
|
109
|
+
if (!isServer || /\.auth\.(getUser|getClaims)\s*\(/.test(file.content)) return [];
|
|
110
|
+
const m = /\.auth\.getSession\s*\(/.exec(file.content);
|
|
111
|
+
return m ? [makeFinding(this, file.path, lineAt(file.content, m.index))] : [];
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const SANITIZER = /DOMPurify|dompurify|sanitize-html|sanitizeHtml|isomorphic-dompurify|rehype-sanitize|\bxss\(/;
|
|
116
|
+
|
|
117
|
+
export const unsafeHtml = {
|
|
118
|
+
id: 'xss-unsanitized-html',
|
|
119
|
+
title: 'Unsanitized HTML rendered with dangerouslySetInnerHTML',
|
|
120
|
+
severity: 'medium',
|
|
121
|
+
confidence: 'medium',
|
|
122
|
+
check(file) {
|
|
123
|
+
if (!/\.[jt]sx$/.test(file.path) || SANITIZER.test(file.content)) return [];
|
|
124
|
+
const findings = [];
|
|
125
|
+
for (const m of file.content.matchAll(/dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:\s*([^}]*?)\s*\}\}/g)) {
|
|
126
|
+
const expr = m[1];
|
|
127
|
+
if (/^(['"`])[^$]*\1$/.test(expr) || /JSON\.stringify|sanitiz|purify|escape/i.test(expr)) continue;
|
|
128
|
+
findings.push(
|
|
129
|
+
makeFinding(this, file.path, lineAt(file.content, m.index), {
|
|
130
|
+
message: `\`${expr}\` is inserted as raw HTML. If any part of it comes from users (or from an AI model), an attacker can run JavaScript in other users' browsers and steal their sessions.`,
|
|
131
|
+
fix: 'Render it as text, or sanitize first: `dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}`.',
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return findings;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const RAW_SQL = [
|
|
140
|
+
/\$(?:queryRawUnsafe|executeRawUnsafe)\s*\(\s*`[^`]*\$\{/g,
|
|
141
|
+
/\$(?:queryRawUnsafe|executeRawUnsafe)\s*\(\s*(['"])(?:(?!\1)[^\n])*\1\s*\+/g,
|
|
142
|
+
/\.(?:query|execute)\s*\(\s*`[^`]*\b(?:select|insert|update|delete)\b[^`]*\$\{/gi,
|
|
143
|
+
/\.(?:query|execute)\s*\(\s*(['"])(?:(?!\1)[^\n])*\b(?:select|insert|update|delete)\b(?:(?!\1)[^\n])*\1\s*\+/gi,
|
|
144
|
+
/\b(?:knex|db|sql)\.raw\s*\(\s*`[^`]*\$\{/g,
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
export const sqlInjection = {
|
|
148
|
+
id: 'sql-injection',
|
|
149
|
+
title: 'SQL query built from string interpolation',
|
|
150
|
+
severity: 'high',
|
|
151
|
+
confidence: 'medium',
|
|
152
|
+
message: 'This query is built by pasting values into the SQL text. If any value comes from the request, an attacker can rewrite the query and read or delete your data.',
|
|
153
|
+
fix: "Pass values as parameters instead: Prisma's `$queryRaw` tagged template, `db.query('select ... where id = $1', [id])`, or your ORM's query builder.",
|
|
154
|
+
check(file) {
|
|
155
|
+
if (!/\.[cm]?[jt]sx?$/.test(file.path)) return [];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
const findings = [];
|
|
158
|
+
for (const re of RAW_SQL) {
|
|
159
|
+
for (const m of file.content.matchAll(re)) {
|
|
160
|
+
const line = lineAt(file.content, m.index);
|
|
161
|
+
if (seen.has(line)) continue;
|
|
162
|
+
seen.add(line);
|
|
163
|
+
findings.push(makeFinding(this, file.path, line));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return findings;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const ENV_FILE = /(^|\/)\.env(\.[\w.-]+)?$/;
|
|
171
|
+
const ENV_TEMPLATE = /\.(example|sample|template|defaults|dist|schema)$/i;
|
|
172
|
+
const PUBLIC_PREFIX = /^(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|REACT_APP_|NUXT_PUBLIC_|PUBLIC_)/;
|
|
173
|
+
|
|
174
|
+
export const committedEnvFile = {
|
|
175
|
+
id: 'env-file-committed',
|
|
176
|
+
title: '.env file with secrets is committed',
|
|
177
|
+
severity: 'high',
|
|
178
|
+
check(file, ctx) {
|
|
179
|
+
if (!ctx.tracked || !ENV_FILE.test(file.path) || ENV_TEMPLATE.test(file.path)) return [];
|
|
180
|
+
const names = [...file.content.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]?[^\s'"#]/gm)].map((m) => m[1]);
|
|
181
|
+
const secrets = names.filter((n) => !PUBLIC_PREFIX.test(n));
|
|
182
|
+
if (!secrets.length) return [];
|
|
183
|
+
const shown = secrets.slice(0, 5).map((n) => `\`${n}\``).join(', ');
|
|
184
|
+
return [
|
|
185
|
+
makeFinding(this, file.path, 1, {
|
|
186
|
+
message: `This file is in git, so everyone with access to the repo can read ${shown}${secrets.length > 5 ? ` and ${secrets.length - 5} more` : ''}.`,
|
|
187
|
+
fix: `Add \`${file.path.split('/').pop()}\` to .gitignore, run \`git rm --cached ${file.path}\`, and rotate every value in it. They stay in git history even after you delete the file.`,
|
|
188
|
+
}),
|
|
189
|
+
];
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export default [unauthenticatedWrite, stripeWebhook, getSessionOnServer, unsafeHtml, sqlInjection, committedEnvFile];
|