safegres 1.8.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 +57 -0
- 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/cli/audit.js +77 -1
- package/cli.js +4 -2
- package/commands/audit.d.ts +6 -0
- package/commands/audit.js +18 -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/cli/audit.js +44 -1
- package/esm/cli.js +4 -2
- package/esm/commands/audit.d.ts +6 -0
- package/esm/commands/audit.js +18 -1
- package/esm/index.d.ts +7 -0
- package/esm/index.js +4 -0
- package/esm/pg/functions.d.ts +45 -0
- package/esm/pg/functions.js +95 -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 +29 -4
- package/esm/types.d.ts +11 -0
- package/index.d.ts +7 -0
- package/index.js +14 -1
- package/package.json +6 -5
- package/pg/functions.d.ts +45 -0
- package/pg/functions.js +98 -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 +29 -4
- package/types.d.ts +11 -0
package/README.md
CHANGED
|
@@ -26,6 +26,15 @@ 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
40
|
| Code | Severity | Direction | Category | Check |
|
|
@@ -91,6 +100,54 @@ Some open reads are deliberate — pricing tables, reference data, a public user
|
|
|
91
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.
|
|
92
101
|
- `safegres doctor` warns about stale `public.read` patterns that no longer match any table.
|
|
93
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
|
+
|
|
94
151
|
## Configuration
|
|
95
152
|
|
|
96
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)).
|
|
@@ -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>;
|