safegres 1.7.0 → 1.11.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/README.md +158 -17
- package/callgraph/baseline.d.ts +28 -0
- package/callgraph/baseline.js +55 -0
- package/callgraph/extract.d.ts +27 -0
- package/callgraph/extract.js +170 -0
- package/callgraph/graph.d.ts +82 -0
- package/callgraph/graph.js +276 -0
- package/checks/anti-patterns.d.ts +14 -8
- package/checks/anti-patterns.js +38 -19
- package/cli/audit.js +165 -47
- package/cli.js +4 -2
- package/commands/audit.d.ts +12 -1
- package/commands/audit.js +68 -2
- package/commands/doctor.js +48 -0
- package/config/presets.d.ts +13 -4
- package/config/presets.js +21 -8
- package/config/resolve.js +6 -0
- package/config/types.d.ts +60 -1
- package/esm/callgraph/baseline.d.ts +28 -0
- package/esm/callgraph/baseline.js +48 -0
- package/esm/callgraph/extract.d.ts +27 -0
- package/esm/callgraph/extract.js +167 -0
- package/esm/callgraph/graph.d.ts +82 -0
- package/esm/callgraph/graph.js +273 -0
- package/esm/checks/anti-patterns.d.ts +14 -8
- package/esm/checks/anti-patterns.js +38 -19
- package/esm/cli/audit.js +133 -48
- package/esm/cli.js +4 -2
- package/esm/commands/audit.d.ts +12 -1
- package/esm/commands/audit.js +69 -3
- package/esm/commands/doctor.js +49 -1
- package/esm/config/presets.d.ts +13 -4
- package/esm/config/presets.js +21 -8
- package/esm/config/resolve.js +6 -0
- package/esm/config/types.d.ts +60 -1
- package/esm/index.d.ts +11 -2
- package/esm/index.js +5 -0
- package/esm/pg/exposure.d.ts +34 -0
- package/esm/pg/exposure.js +92 -0
- package/esm/pg/functions.d.ts +45 -0
- package/esm/pg/functions.js +95 -0
- package/esm/pgpm-test.d.ts +22 -0
- package/esm/pgpm-test.js +39 -0
- package/esm/report/callgraph.d.ts +9 -0
- package/esm/report/callgraph.js +74 -0
- package/esm/report/pretty.d.ts +7 -2
- package/esm/report/pretty.js +51 -4
- package/esm/rules/registry.d.ts +7 -1
- package/esm/rules/registry.js +38 -9
- package/esm/score/score.d.ts +19 -9
- package/esm/score/score.js +93 -10
- package/esm/types.d.ts +50 -0
- package/index.d.ts +11 -2
- package/index.js +18 -1
- package/package.json +14 -5
- package/pg/exposure.d.ts +34 -0
- package/pg/exposure.js +97 -0
- package/pg/functions.d.ts +45 -0
- package/pg/functions.js +98 -0
- package/pgpm-test.d.ts +22 -0
- package/pgpm-test.js +42 -0
- package/report/callgraph.d.ts +9 -0
- package/report/callgraph.js +81 -0
- package/report/pretty.d.ts +7 -2
- package/report/pretty.js +51 -4
- package/rules/registry.d.ts +7 -1
- package/rules/registry.js +38 -9
- package/score/score.d.ts +19 -9
- package/score/score.js +93 -10
- package/types.d.ts +50 -0
package/README.md
CHANGED
|
@@ -26,27 +26,128 @@ safegres audit
|
|
|
26
26
|
|
|
27
27
|
Per-field overrides (`--host`, `--port`, `--user`, `--password`, `--database`) and a full `--connection <url>` flag are also supported. See `safegres audit --help`.
|
|
28
28
|
|
|
29
|
+
### Output & verbosity
|
|
30
|
+
|
|
31
|
+
Pretty output prints the exposure line, score, and the exposed findings. Internal (non-exposed) advisories are collapsed to a one-line count by default so a large database's report stays readable.
|
|
32
|
+
|
|
33
|
+
- `--summary`, `-q` — print only the exposure line, score/grade, and severity counts (no per-finding lines). Ideal for CI job summaries.
|
|
34
|
+
- `--verbose` — expand the internal advisories instead of collapsing them to a count.
|
|
35
|
+
- `--exposed-only` — drop internal findings entirely.
|
|
36
|
+
- `--format json` / `--format json-pretty` — machine-readable output (always carries every finding).
|
|
37
|
+
|
|
29
38
|
## What it checks
|
|
30
39
|
|
|
31
|
-
| Code | Severity | Category | Check |
|
|
32
|
-
| --- | --- | --- | --- |
|
|
33
|
-
| A1 |
|
|
34
|
-
| A2 | high | flags | Grants exist on a table with **RLS disabled** |
|
|
35
|
-
| A3 |
|
|
36
|
-
| A4 |
|
|
37
|
-
| A5 |
|
|
38
|
-
| A6 | info | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
|
|
39
|
-
| A7 |
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
40
|
+
| Code | Severity | Direction | Category | Check |
|
|
41
|
+
| --- | --- | --- | --- | --- |
|
|
42
|
+
| A1 | low | fail-closed | flags | RLS enabled but **0 policies** (deny-all — confirm the lock is intended) |
|
|
43
|
+
| A2 | high | fail-open | flags | Grants exist on a table with **RLS disabled** |
|
|
44
|
+
| A3 | low | fail-open | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
|
|
45
|
+
| A4 | low | fail-closed | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** — writes are denied at runtime |
|
|
46
|
+
| A5 | low | fail-closed | coverage | SELECT grant with **no policy** — queries silently return 0 rows |
|
|
47
|
+
| A6 | info | fail-closed | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
|
|
48
|
+
| A7 | critical | fail-open | anti-pattern | Trivially-permissive **WRITE** policy (INSERT/UPDATE/DELETE/ALL with literal `true`) |
|
|
49
|
+
| A8 | low | fail-open | anti-pattern | Trivially-permissive **SELECT** policy (`USING (true)` — confirm public-read is intended) |
|
|
50
|
+
| P1 | high | neutral | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
|
|
51
|
+
| P5 | high | fail-open | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
|
|
52
|
+
| R1 | critical | fail-open | anti-pattern | An **untrusted role** (options: `{ roles: [...] }`) holds a write privilege |
|
|
53
|
+
| R2 | high | fail-open | anti-pattern | A permissive write policy applies to an untrusted role or PUBLIC |
|
|
54
|
+
| R3 | medium | fail-open | anti-pattern | An RLS table has grants **TO PUBLIC** (includes all current/future roles) |
|
|
55
|
+
| W1 | medium | — | meta | No exposure surface configured — whole database assumed reachable, score capped |
|
|
56
|
+
|
|
57
|
+
**Direction matters**: `fail-open` findings are actual exposure (the untrusted side can reach more than intended). `fail-closed` findings are denied at runtime — an availability/hygiene concern, not a leak — and contribute **nothing to the score** by default (tune with `scoring.failClosedWeight`).
|
|
45
58
|
|
|
46
59
|
Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
|
|
47
60
|
|
|
48
61
|
R1/R2 are no-ops until a role list is configured — e.g. `"R1": ["critical", { "roles": ["anonymous"] }]` — so they cost nothing on databases without an untrusted-role model. The `safegres:constructive` preset configures them for `anonymous`.
|
|
49
62
|
|
|
63
|
+
## Exposure surface
|
|
64
|
+
|
|
65
|
+
A database-wide score is meaningless if most of the database isn't reachable through the app's APIs. Declare (or auto-resolve) the **exposure surface** and safegres partitions findings:
|
|
66
|
+
|
|
67
|
+
- **Exposed** findings (on API-reachable schemas) drive the score.
|
|
68
|
+
- **Internal** findings are reported as unscored *internal advisories* (hide entirely with `--exposed-only`).
|
|
69
|
+
- **No exposure configured** → a `W1` warning is emitted and the score is capped at 80/B (`scoring.unknownExposureCap`).
|
|
70
|
+
|
|
71
|
+
```jsonc
|
|
72
|
+
{
|
|
73
|
+
"exposure": {
|
|
74
|
+
"schemas": ["app_public", "app_hidden"] // static surface
|
|
75
|
+
// or, on a Constructive database:
|
|
76
|
+
// "resolver": "constructive" // introspects routing_public.apis → api_schemas
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
CLI: `--exposure-schemas <csv>`, `--exposed-only`. The `safegres:constructive` preset sets `exposure.resolver: "constructive"` so the surface is discovered automatically from the routing plane (including API roles from `role_name`/`anon_role`).
|
|
82
|
+
|
|
83
|
+
## Declared public surface
|
|
84
|
+
|
|
85
|
+
Some open reads are deliberate — pricing tables, reference data, a public user directory. Declare them and safegres treats them as intent instead of findings:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
{
|
|
89
|
+
"public": {
|
|
90
|
+
"read": [
|
|
91
|
+
"app_public.plans*", // schema.table globs
|
|
92
|
+
"app_public.event_types",
|
|
93
|
+
"app_public.users" // deliberate public directory
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- An open SELECT policy (`USING (true)` — rule A8) on a declared table is **acknowledged**: reported as info, excluded from the score.
|
|
100
|
+
- An open read on any *undeclared* table stays a scored finding — even in a `*_public`-named schema. Naming is never treated as intent; the config declaration is.
|
|
101
|
+
- `safegres doctor` warns about stale `public.read` patterns that no longer match any table.
|
|
102
|
+
|
|
103
|
+
## Call graph (`--call-graph`)
|
|
104
|
+
|
|
105
|
+
RLS findings tell you what the *tables* allow. The call graph tells you what the *functions* reach: starting from the exposed entry points (functions the API roles can `EXECUTE`), safegres statically walks each body and lists every **trust boundary** on the way — unscored, because a public `SECURITY DEFINER` calling private functions is the intended pattern (that's how `sign_in` works). The output is a deterministic checklist for human review:
|
|
106
|
+
|
|
107
|
+
| Code | Boundary |
|
|
108
|
+
|------|----------|
|
|
109
|
+
| CF1 | `SECURITY DEFINER` without a pinned `search_path` (CWE-426) — provable misconfiguration, fix these |
|
|
110
|
+
| CF2 | `SECURITY DEFINER` executable by `anonymous`/PUBLIC — widest blast radius, confirm intent |
|
|
111
|
+
| CG2 | RLS-bypass path — a DEFINER's owner owns (or bypasses RLS on) a table it touches, so RLS does not protect that table on this path |
|
|
112
|
+
| CG3 | Auth-context mutation — a reachable function writes `jwt.claims.*` / `role` |
|
|
113
|
+
| CG1 | Trust hop — execution crosses into a `SECURITY DEFINER` (you are trusting its author's authorization logic) |
|
|
114
|
+
| CG4 | Internal reach — a non-exposed table is reached from a public entry via a DEFINER path |
|
|
115
|
+
| CG5 | Opaque node — dynamic SQL (`EXECUTE`) or an unparseable body; static analysis ends here, audit manually |
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
safegres audit --database mydb --call-graph
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
call graph — trust boundaries reachable from the exposed surface (unscored; human review)
|
|
123
|
+
2 entry point(s) → 4 reachable function(s) | 3 trust hop(s) 1 RLS-bypass 1 auth-context 1 internal-reach 1 opaque
|
|
124
|
+
|
|
125
|
+
CG2 — RLS-bypass paths (RLS does not protect the table on this path) (1)
|
|
126
|
+
• fx_cg_private.verify_password → fx_cg_private.users
|
|
127
|
+
RLS on fx_cg_private.users does not apply on this path — fx_cg_private.verify_password is SECURITY DEFINER running as postgres (BYPASSRLS/superuser)
|
|
128
|
+
via: fx_cg_public.sign_in → fx_cg_private.verify_password
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Bodies are analyzed for `sql` and `plpgsql` functions (via the PL/pgSQL parser); overloads collapse into one node per `schema.name`; unqualified calls resolve to every user function with that name (a conservative over-approximation). JSON output (`--format json`) carries the full graph — nodes, edges, and checklist — sorted stably so it can be snapshotted and diffed in CI.
|
|
132
|
+
|
|
133
|
+
### Baseline diffing (CI gate for new trust boundaries)
|
|
134
|
+
|
|
135
|
+
Snapshot the checklist once, commit it, and let CI report anything **new**:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
safegres audit --write-baseline .safegres-callgraph.json # snapshot (implies --call-graph)
|
|
139
|
+
safegres audit --baseline .safegres-callgraph.json # diff: report new/resolved boundaries
|
|
140
|
+
safegres audit --baseline .safegres-callgraph.json --fail-on-new-boundaries # gate: exit 1 on new
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
baseline: 1 NEW trust boundary — review and re-baseline to accept:
|
|
145
|
+
+ [CF2] app_public.new_fn
|
|
146
|
+
SECURITY DEFINER executable by PUBLIC, anonymous — widest blast radius; confirm this is intended
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The baseline stores only boundary *identity* (`code` + entry + function + table), so message rewording and path changes between safegres versions never invalidate it. A boundary that disappears is reported as resolved; re-run `--write-baseline` to accept either direction. The diff is also carried in JSON output (`callGraphDiff`).
|
|
150
|
+
|
|
50
151
|
## Configuration
|
|
51
152
|
|
|
52
153
|
safegres is configurable like a linter. Config is discovered by walking up from the current directory: `safegres.config.{ts,js,mjs,cjs}`, `.safegresrc{,.json,.yaml,.yml,.js}`, `safegres.json`, or a `"safegres"` key in package.json (via [confstash](https://github.com/constructive-io/dev-utils/tree/main/packages/confstash)).
|
|
@@ -86,15 +187,21 @@ export default defineConfig({
|
|
|
86
187
|
| Preset | Behavior |
|
|
87
188
|
| --- | --- |
|
|
88
189
|
| `safegres:recommended` | Every rule at its default severity (the no-config behavior) |
|
|
89
|
-
| `safegres:strict` |
|
|
90
|
-
| `safegres:constructive` |
|
|
190
|
+
| `safegres:strict` | Everything escalated; fail-closed findings count 25% toward the score, `failOn: high` |
|
|
191
|
+
| `safegres:constructive` | Auto-resolves exposure from the routing plane; R1/R2 watch `anonymous`; leak surfaces (A2, P5) critical; A3 off (API roles never own tables) |
|
|
91
192
|
| `safegres:minimal` | Structural flags only (A1–A3) — fast CI smoke check |
|
|
92
193
|
|
|
93
194
|
CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
|
|
94
195
|
|
|
95
196
|
### Scoring
|
|
96
197
|
|
|
97
|
-
Every report includes a config-driven score (0–100 + grade)
|
|
198
|
+
Every report includes a config-driven score (0–100 + grade). The default **density** model normalizes by the exposed surface so large schemas don't saturate to 0/F:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
score = 100 · exp(−k · riskPoints / exposedTables)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
where `riskPoints` is the severity-weighted sum (critical 25, high 10, medium 4, low 1, info 0) of *exposed, fail-open* findings, and `k` defaults to 0.17 (≈ one critical per 10 exposed tables lands at a C). Non-exposed findings score 0; fail-closed findings score 0 unless `scoring.failClosedWeight` is raised; unknown exposure caps the score (`scoring.unknownExposureCap`, default 80). Any exposed critical floors the grade at C (`scoring.floorOnCritical`). The legacy flat-deduction model is available via `scoring.model: "weighted"`. Tune via `scoring.weights`, `scoring.perRuleWeights`, `scoring.densityK`, `scoring.gradeBands`. Gate CI with `--fail-on-score <n>` / `--fail-on-grade <g>` or `failOn` in config.
|
|
98
205
|
|
|
99
206
|
### Other commands
|
|
100
207
|
|
|
@@ -121,6 +228,40 @@ console.log(renderPretty(report));
|
|
|
121
228
|
console.log(`${report.findings.length} findings`);
|
|
122
229
|
```
|
|
123
230
|
|
|
231
|
+
## pgpm projects
|
|
232
|
+
|
|
233
|
+
For pgpm workspaces, safegres can deploy the workspace into an ephemeral test
|
|
234
|
+
database and audit it — no running database or connection flags required
|
|
235
|
+
(needs the optional peer dependency `pgsql-test`):
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
safegres audit --pgpm # nearest pgpm module/workspace from cwd
|
|
239
|
+
safegres audit --pgpm ./packages/my-db
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Or as a jest test via the `safegres/pgpm-test` entrypoint:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import { auditPgpmWorkspace } from 'safegres/pgpm-test';
|
|
246
|
+
|
|
247
|
+
it('passes the security audit', async () => {
|
|
248
|
+
const report = await auditPgpmWorkspace();
|
|
249
|
+
expect(report.score.grade).toBe('A+');
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Both discover the project's safegres config (`safegres.config.js`,
|
|
254
|
+
`.safegresrc*`, …) by walking up from the workspace directory. pgpm projects
|
|
255
|
+
usually don't have Constructive routing metadata, so declare the exposed
|
|
256
|
+
surface statically:
|
|
257
|
+
|
|
258
|
+
```json
|
|
259
|
+
{
|
|
260
|
+
"extends": "safegres:recommended",
|
|
261
|
+
"exposure": { "schemas": ["app_public"] }
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
124
265
|
---
|
|
125
266
|
|
|
126
267
|
## Education and Tutorials
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CallGraphReport, ChecklistCode, ChecklistItem } from './graph';
|
|
2
|
+
/**
|
|
3
|
+
* A committed snapshot of the call-graph checklist, used by CI to detect
|
|
4
|
+
* *new* trust boundaries introduced by a change. Only the identity of each
|
|
5
|
+
* boundary is stored (code + entry + fn + table) — messages and paths may
|
|
6
|
+
* be reworded between versions without invalidating the baseline.
|
|
7
|
+
*/
|
|
8
|
+
export interface CallGraphBaseline {
|
|
9
|
+
version: 1;
|
|
10
|
+
boundaries: BaselineBoundary[];
|
|
11
|
+
}
|
|
12
|
+
export interface BaselineBoundary {
|
|
13
|
+
code: ChecklistCode;
|
|
14
|
+
entry: string;
|
|
15
|
+
fn: string;
|
|
16
|
+
table?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CallGraphDiff {
|
|
19
|
+
/** Checklist items present now but not in the baseline — require sign-off. */
|
|
20
|
+
added: ChecklistItem[];
|
|
21
|
+
/** Baseline boundaries no longer present — resolved or removed. */
|
|
22
|
+
removed: BaselineBoundary[];
|
|
23
|
+
}
|
|
24
|
+
export declare function boundaryKey(b: BaselineBoundary): string;
|
|
25
|
+
export declare function toBaseline(report: CallGraphReport): CallGraphBaseline;
|
|
26
|
+
export declare function diffCallGraph(report: CallGraphReport, baseline: CallGraphBaseline): CallGraphDiff;
|
|
27
|
+
export declare function parseBaseline(raw: string): CallGraphBaseline;
|
|
28
|
+
export declare function serializeBaseline(baseline: CallGraphBaseline): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.boundaryKey = boundaryKey;
|
|
4
|
+
exports.toBaseline = toBaseline;
|
|
5
|
+
exports.diffCallGraph = diffCallGraph;
|
|
6
|
+
exports.parseBaseline = parseBaseline;
|
|
7
|
+
exports.serializeBaseline = serializeBaseline;
|
|
8
|
+
function boundaryKey(b) {
|
|
9
|
+
return [b.code, b.entry, b.fn, b.table ?? ''].join('|');
|
|
10
|
+
}
|
|
11
|
+
function toBaseline(report) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const boundaries = [];
|
|
14
|
+
for (const item of report.checklist) {
|
|
15
|
+
const b = {
|
|
16
|
+
code: item.code,
|
|
17
|
+
entry: item.entry,
|
|
18
|
+
fn: item.fn,
|
|
19
|
+
...(item.table ? { table: item.table } : {})
|
|
20
|
+
};
|
|
21
|
+
const key = boundaryKey(b);
|
|
22
|
+
if (seen.has(key))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(key);
|
|
25
|
+
boundaries.push(b);
|
|
26
|
+
}
|
|
27
|
+
boundaries.sort((a, b) => boundaryKey(a).localeCompare(boundaryKey(b)));
|
|
28
|
+
return { version: 1, boundaries };
|
|
29
|
+
}
|
|
30
|
+
function diffCallGraph(report, baseline) {
|
|
31
|
+
const baseKeys = new Set(baseline.boundaries.map(boundaryKey));
|
|
32
|
+
const current = toBaseline(report);
|
|
33
|
+
const currentKeys = new Set(current.boundaries.map(boundaryKey));
|
|
34
|
+
const addedKeys = new Set();
|
|
35
|
+
const added = [];
|
|
36
|
+
for (const item of report.checklist) {
|
|
37
|
+
const key = boundaryKey({ code: item.code, entry: item.entry, fn: item.fn, table: item.table });
|
|
38
|
+
if (baseKeys.has(key) || addedKeys.has(key))
|
|
39
|
+
continue;
|
|
40
|
+
addedKeys.add(key);
|
|
41
|
+
added.push(item);
|
|
42
|
+
}
|
|
43
|
+
const removed = baseline.boundaries.filter((b) => !currentKeys.has(boundaryKey(b)));
|
|
44
|
+
return { added, removed };
|
|
45
|
+
}
|
|
46
|
+
function parseBaseline(raw) {
|
|
47
|
+
const data = JSON.parse(raw);
|
|
48
|
+
if (data.version !== 1 || !Array.isArray(data.boundaries)) {
|
|
49
|
+
throw new Error('invalid call-graph baseline: expected { version: 1, boundaries: [...] }');
|
|
50
|
+
}
|
|
51
|
+
return { version: 1, boundaries: data.boundaries };
|
|
52
|
+
}
|
|
53
|
+
function serializeBaseline(baseline) {
|
|
54
|
+
return JSON.stringify(baseline, null, 2) + '\n';
|
|
55
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static body extraction for the call graph.
|
|
3
|
+
*
|
|
4
|
+
* Given a function's source, extract the function calls, table references
|
|
5
|
+
* (read vs write), and auth-context mutations it contains. Dynamic SQL
|
|
6
|
+
* (`EXECUTE format(...)`) and unparseable bodies are surfaced as `opaque`
|
|
7
|
+
* rather than silently dropped — static analysis ends there.
|
|
8
|
+
*/
|
|
9
|
+
import type { FunctionSnapshot } from '../pg/functions';
|
|
10
|
+
export interface NameRef {
|
|
11
|
+
schema?: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TableRef extends NameRef {
|
|
15
|
+
write: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ExtractedBody {
|
|
18
|
+
calls: NameRef[];
|
|
19
|
+
tables: TableRef[];
|
|
20
|
+
/** GUC names written via `set_config(...)` / `SET`, plus `role` for SET ROLE. */
|
|
21
|
+
settings: string[];
|
|
22
|
+
/** True when the body contains dynamic SQL we cannot follow statically. */
|
|
23
|
+
opaque: boolean;
|
|
24
|
+
/** Why the body is (partially) opaque, when it is. */
|
|
25
|
+
opaqueReason?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function extractBody(fn: FunctionSnapshot): Promise<ExtractedBody>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Static body extraction for the call graph.
|
|
4
|
+
*
|
|
5
|
+
* Given a function's source, extract the function calls, table references
|
|
6
|
+
* (read vs write), and auth-context mutations it contains. Dynamic SQL
|
|
7
|
+
* (`EXECUTE format(...)`) and unparseable bodies are surfaced as `opaque`
|
|
8
|
+
* rather than silently dropped — static analysis ends there.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.extractBody = extractBody;
|
|
12
|
+
const libpg_query_1 = require("libpg-query");
|
|
13
|
+
const pgsql_parser_1 = require("pgsql-parser");
|
|
14
|
+
const walk_1 = require("../ast/walk");
|
|
15
|
+
const EMPTY = { calls: [], tables: [], settings: [], opaque: false };
|
|
16
|
+
/** Languages whose bodies we can statically analyze. */
|
|
17
|
+
const ANALYZABLE = new Set(['sql', 'plpgsql']);
|
|
18
|
+
async function extractBody(fn) {
|
|
19
|
+
if (!ANALYZABLE.has(fn.language)) {
|
|
20
|
+
if (fn.language === 'internal' || fn.language === 'c')
|
|
21
|
+
return EMPTY;
|
|
22
|
+
return { ...EMPTY, opaque: true, opaqueReason: `language "${fn.language}" is not statically analyzable` };
|
|
23
|
+
}
|
|
24
|
+
if (fn.language === 'sql') {
|
|
25
|
+
if (!fn.source || fn.source.trim() === '')
|
|
26
|
+
return EMPTY;
|
|
27
|
+
return extractFromSql(fn.source, 'statement');
|
|
28
|
+
}
|
|
29
|
+
// plpgsql: parse the full CREATE FUNCTION, then analyze every embedded
|
|
30
|
+
// SQL expression/statement the PL/pgSQL parser hands back.
|
|
31
|
+
if (!fn.definition)
|
|
32
|
+
return { ...EMPTY, opaque: true, opaqueReason: 'no function definition available' };
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = await (0, libpg_query_1.parsePlPgSQL)(fn.definition);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { ...EMPTY, opaque: true, opaqueReason: 'PL/pgSQL body failed to parse' };
|
|
39
|
+
}
|
|
40
|
+
const out = { calls: [], tables: [], settings: [], opaque: false };
|
|
41
|
+
const exprs = [];
|
|
42
|
+
collectPlpgsql(parsed, exprs, out);
|
|
43
|
+
for (const e of exprs) {
|
|
44
|
+
// parseMode 0 = full statement; 3 = assignment (`target := expr` or
|
|
45
|
+
// `target = expr`) — strip the anchored target so the RHS parses. The
|
|
46
|
+
// RHS may itself contain `:=` (named arguments), so only the leading
|
|
47
|
+
// target is removed. Anything else is a bare expression.
|
|
48
|
+
let q = e.query;
|
|
49
|
+
if (e.parseMode === 3) {
|
|
50
|
+
q = q.replace(/^\s*[a-zA-Z_"][\w$".]*(\[[^\]]*\])*\s*:?=\s*/, '');
|
|
51
|
+
}
|
|
52
|
+
const sql = e.parseMode === 0 ? q : `SELECT ${q}`;
|
|
53
|
+
const part = await extractFromSql(sql, 'statement');
|
|
54
|
+
mergeBody(out, part);
|
|
55
|
+
}
|
|
56
|
+
return finalize(out);
|
|
57
|
+
}
|
|
58
|
+
/** Walk the PL/pgSQL JSON tree: collect embedded SQL, flag dynamic EXECUTE. */
|
|
59
|
+
function collectPlpgsql(node, exprs, out) {
|
|
60
|
+
if (Array.isArray(node)) {
|
|
61
|
+
for (const item of node)
|
|
62
|
+
collectPlpgsql(item, exprs, out);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!node || typeof node !== 'object')
|
|
66
|
+
return;
|
|
67
|
+
const rec = node;
|
|
68
|
+
if (rec.PLpgSQL_stmt_dynexecute) {
|
|
69
|
+
out.opaque = true;
|
|
70
|
+
out.opaqueReason = 'dynamic SQL (EXECUTE) — cannot follow statically';
|
|
71
|
+
// Still walk it: the format() expression itself may call functions.
|
|
72
|
+
}
|
|
73
|
+
const expr = rec.PLpgSQL_expr;
|
|
74
|
+
if (expr && typeof expr.query === 'string') {
|
|
75
|
+
exprs.push({ query: expr.query, parseMode: typeof expr.parseMode === 'number' ? expr.parseMode : 2 });
|
|
76
|
+
}
|
|
77
|
+
for (const value of Object.values(rec))
|
|
78
|
+
collectPlpgsql(value, exprs, out);
|
|
79
|
+
}
|
|
80
|
+
async function extractFromSql(sql, _mode) {
|
|
81
|
+
let ast;
|
|
82
|
+
try {
|
|
83
|
+
ast = await (0, pgsql_parser_1.parse)(sql);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Individual fragments can legitimately fail (PL/pgSQL variables in
|
|
87
|
+
// type positions, etc.) — treat as opaque rather than erroring out.
|
|
88
|
+
return { ...EMPTY, opaque: true, opaqueReason: 'SQL fragment failed to parse' };
|
|
89
|
+
}
|
|
90
|
+
const out = { calls: [], tables: [], settings: [], opaque: false };
|
|
91
|
+
for (const call of (0, walk_1.findAll)(ast, 'FuncCall')) {
|
|
92
|
+
const ref = (0, walk_1.funcNameParts)(call);
|
|
93
|
+
if (ref.name === '<unknown>')
|
|
94
|
+
continue;
|
|
95
|
+
out.calls.push(ref);
|
|
96
|
+
if (ref.name === 'set_config' && (!ref.schema || ref.schema === 'pg_catalog')) {
|
|
97
|
+
const setting = firstStringArg(call);
|
|
98
|
+
if (setting)
|
|
99
|
+
out.settings.push(setting);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Write targets: the relation of INSERT/UPDATE/DELETE statements.
|
|
103
|
+
const writeOids = new Set();
|
|
104
|
+
for (const tag of ['InsertStmt', 'UpdateStmt', 'DeleteStmt']) {
|
|
105
|
+
for (const stmt of (0, walk_1.findAll)(ast, tag)) {
|
|
106
|
+
const rel = stmt.relation;
|
|
107
|
+
if (rel)
|
|
108
|
+
writeOids.add(rel);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const rv of (0, walk_1.findAll)(ast, 'RangeVar')) {
|
|
112
|
+
const name = typeof rv.relname === 'string' ? rv.relname : undefined;
|
|
113
|
+
if (!name)
|
|
114
|
+
continue;
|
|
115
|
+
const schema = typeof rv.schemaname === 'string' ? rv.schemaname : undefined;
|
|
116
|
+
out.tables.push({ schema, name, write: writeOids.has(rv) });
|
|
117
|
+
}
|
|
118
|
+
// SET role / SET session_authorization / SET request.jwt… inside SQL bodies.
|
|
119
|
+
for (const set of (0, walk_1.findAll)(ast, 'VariableSetStmt')) {
|
|
120
|
+
const name = typeof set.name === 'string' ? set.name : undefined;
|
|
121
|
+
if (name)
|
|
122
|
+
out.settings.push(name.toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
return finalize(out);
|
|
125
|
+
}
|
|
126
|
+
function firstStringArg(call) {
|
|
127
|
+
const args = call.args;
|
|
128
|
+
if (!Array.isArray(args) || args.length === 0)
|
|
129
|
+
return null;
|
|
130
|
+
const first = args[0];
|
|
131
|
+
const aconst = first.A_Const;
|
|
132
|
+
const sval = aconst?.sval;
|
|
133
|
+
const v = sval?.sval;
|
|
134
|
+
return typeof v === 'string' ? v : null;
|
|
135
|
+
}
|
|
136
|
+
function mergeBody(into, from) {
|
|
137
|
+
into.calls.push(...from.calls);
|
|
138
|
+
into.tables.push(...from.tables);
|
|
139
|
+
into.settings.push(...from.settings);
|
|
140
|
+
if (from.opaque && !into.opaque) {
|
|
141
|
+
into.opaque = true;
|
|
142
|
+
into.opaqueReason = from.opaqueReason;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function finalize(body) {
|
|
146
|
+
const callKeys = new Set();
|
|
147
|
+
const calls = body.calls.filter((c) => {
|
|
148
|
+
const k = `${c.schema ?? ''}.${c.name}`;
|
|
149
|
+
if (callKeys.has(k))
|
|
150
|
+
return false;
|
|
151
|
+
callKeys.add(k);
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
const tableKeys = new Map();
|
|
155
|
+
for (const t of body.tables) {
|
|
156
|
+
const k = `${t.schema ?? ''}.${t.name}`;
|
|
157
|
+
const existing = tableKeys.get(k);
|
|
158
|
+
if (existing)
|
|
159
|
+
existing.write = existing.write || t.write;
|
|
160
|
+
else
|
|
161
|
+
tableKeys.set(k, { ...t });
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
calls,
|
|
165
|
+
tables: [...tableKeys.values()],
|
|
166
|
+
settings: [...new Set(body.settings)],
|
|
167
|
+
opaque: body.opaque,
|
|
168
|
+
...(body.opaqueReason ? { opaqueReason: body.opaqueReason } : {})
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call-graph construction and trust-boundary classification.
|
|
3
|
+
*
|
|
4
|
+
* Starting from the exposed entry points (functions the API roles can
|
|
5
|
+
* EXECUTE), walk what those functions transitively call and touch, and emit
|
|
6
|
+
* an unscored audit checklist of trust boundaries for human review:
|
|
7
|
+
*
|
|
8
|
+
* CG1 trust hop — execution crosses into a SECURITY DEFINER
|
|
9
|
+
* CG2 RLS-bypass path — a DEFINER touches a table whose RLS its owner bypasses
|
|
10
|
+
* CG3 auth-context change — a reachable function mutates jwt claims / role
|
|
11
|
+
* CG4 internal reach — a non-exposed table is reached via a DEFINER hop
|
|
12
|
+
* CG5 opaque node — dynamic SQL / unparseable body; audit manually
|
|
13
|
+
*
|
|
14
|
+
* Plus two provable misconfigurations:
|
|
15
|
+
*
|
|
16
|
+
* CF1 SECURITY DEFINER without a pinned search_path (CWE-426)
|
|
17
|
+
* CF2 SECURITY DEFINER executable by anonymous / PUBLIC
|
|
18
|
+
*/
|
|
19
|
+
import type { FunctionSnapshot } from '../pg/functions';
|
|
20
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
21
|
+
export interface CallGraphOptions {
|
|
22
|
+
functions: FunctionSnapshot[];
|
|
23
|
+
tables: TableSnapshot[];
|
|
24
|
+
/** Exposed schemas; when undefined the exposure surface is unknown. */
|
|
25
|
+
exposedSchemas?: string[];
|
|
26
|
+
/** Roles the API connects as. EXECUTE for any of these (or PUBLIC) marks an entry point. */
|
|
27
|
+
apiRoles: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface CallGraphNode {
|
|
30
|
+
id: string;
|
|
31
|
+
schema: string;
|
|
32
|
+
name: string;
|
|
33
|
+
securityDefiner: boolean;
|
|
34
|
+
owner: string;
|
|
35
|
+
ownerBypassesRls: boolean;
|
|
36
|
+
searchPathPinned: boolean;
|
|
37
|
+
language: string;
|
|
38
|
+
opaque: boolean;
|
|
39
|
+
opaqueReason?: string;
|
|
40
|
+
/** Auth-context settings this function writes (jwt claims, role, …). */
|
|
41
|
+
authSettings: string[];
|
|
42
|
+
/** Entry-point roles when this node is an entry (EXECUTE-granted API roles). */
|
|
43
|
+
entryRoles?: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface CallGraphEdge {
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
kind: 'call' | 'read' | 'write';
|
|
49
|
+
}
|
|
50
|
+
export type ChecklistCode = 'CG1' | 'CG2' | 'CG3' | 'CG4' | 'CG5' | 'CF1' | 'CF2';
|
|
51
|
+
export interface ChecklistItem {
|
|
52
|
+
code: ChecklistCode;
|
|
53
|
+
/** Entry point this boundary is reachable from. */
|
|
54
|
+
entry: string;
|
|
55
|
+
/** Call path from the entry to the flagged node, entry first. */
|
|
56
|
+
path: string[];
|
|
57
|
+
/** The flagged function. */
|
|
58
|
+
fn: string;
|
|
59
|
+
/** The touched table, for CG2/CG4. */
|
|
60
|
+
table?: string;
|
|
61
|
+
message: string;
|
|
62
|
+
}
|
|
63
|
+
export interface CallGraphReport {
|
|
64
|
+
entries: Array<{
|
|
65
|
+
fn: string;
|
|
66
|
+
roles: string[];
|
|
67
|
+
securityDefiner: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
nodes: CallGraphNode[];
|
|
70
|
+
edges: CallGraphEdge[];
|
|
71
|
+
checklist: ChecklistItem[];
|
|
72
|
+
stats: {
|
|
73
|
+
entryPoints: number;
|
|
74
|
+
reachableFunctions: number;
|
|
75
|
+
trustHops: number;
|
|
76
|
+
rlsBypassPaths: number;
|
|
77
|
+
authContextMutations: number;
|
|
78
|
+
internalReach: number;
|
|
79
|
+
opaqueNodes: number;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export declare function buildCallGraph(options: CallGraphOptions): Promise<CallGraphReport>;
|