ai-saas-guard 0.2.0 → 0.4.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 +28 -6
- package/action.yml +9 -0
- package/dist/cli.js +20 -7
- package/dist/config.d.ts +10 -0
- package/dist/config.js +89 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/rules/catalog.js +8 -1
- package/dist/scanners/supabase.js +132 -18
- package/docs/github-action.md +17 -1
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +7 -9
- package/docs/rules.md +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,8 +50,9 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
|
|
|
50
50
|
| Local CLI from source | Available for development |
|
|
51
51
|
| JSON and SARIF output | Available |
|
|
52
52
|
| Composite GitHub Action | Available |
|
|
53
|
-
|
|
|
54
|
-
|
|
|
53
|
+
| Project config | `.ai-saas-guard.json` rule toggles, severity overrides, and fail thresholds |
|
|
54
|
+
| Versioned Action tags | `v0.4.0`, `v0` |
|
|
55
|
+
| npm package | `ai-saas-guard@0.4.0` |
|
|
55
56
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
56
57
|
|
|
57
58
|
## Quick Start
|
|
@@ -77,6 +78,7 @@ Machine-readable output:
|
|
|
77
78
|
npx ai-saas-guard@latest scan --root /path/to/your-saas --json
|
|
78
79
|
npx ai-saas-guard@latest scan --root /path/to/your-saas --sarif > ai-saas-guard.sarif
|
|
79
80
|
npx ai-saas-guard@latest pr-risk --root /path/to/your-saas --base origin/main --markdown > ai-saas-guard-pr.md
|
|
81
|
+
npx ai-saas-guard@latest scan --root /path/to/your-saas --config <file> --json
|
|
80
82
|
npx ai-saas-guard@latest scan --root /path/to/your-saas --fail-on high
|
|
81
83
|
```
|
|
82
84
|
|
|
@@ -119,7 +121,7 @@ Evidence:
|
|
|
119
121
|
| --- | --- |
|
|
120
122
|
| Secrets and env | Secret-like values, risky `NEXT_PUBLIC_*` exposure |
|
|
121
123
|
| Stripe | Missing webhook route, unsigned webhook handling, parsed-body signature risk, missing idempotency, missing failure/cancel/update/refund paths |
|
|
122
|
-
| Supabase | RLS disabled on sensitive tables, `USING
|
|
124
|
+
| Supabase | RLS disabled on sensitive tables, broad `USING`/`WITH CHECK`, tenant membership patterns, weak write checks, storage object policy scope |
|
|
123
125
|
| API routes | Auth checks without obvious ownership guards, missing rate-limit hints on sensitive mutation routes |
|
|
124
126
|
| MCP | Plaintext secrets, non-localhost binds, broad filesystem/write access, shell tools, raw SQL tools |
|
|
125
127
|
| Deploy config | Next static export/runtime mismatches, Edge runtime with Node-only APIs, missing important env documentation |
|
|
@@ -168,9 +170,28 @@ If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` inste
|
|
|
168
170
|
| `check-stripe` | Inspect webhook handlers and billing lifecycle coverage |
|
|
169
171
|
| `check-mcp` | Inventory MCP configs and classify side effects |
|
|
170
172
|
|
|
173
|
+
## Project Configuration
|
|
174
|
+
|
|
175
|
+
Add `.ai-saas-guard.json` at the repository root to tune findings without changing scanner code. The CLI auto-loads this file from `--root` when it exists. Use `--config <file>` to point to a different JSON file.
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"failOn": "high",
|
|
180
|
+
"rules": {
|
|
181
|
+
"stripe.webhook.missing-signature": "off",
|
|
182
|
+
"stripe.webhook.missing-idempotency": "critical",
|
|
183
|
+
"deploy.env.example-missing": "info"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`rules` is keyed by published rule ID from [docs/rules.md](docs/rules.md). Set a rule to `off` to remove matching findings from terminal, JSON, SARIF, and markdown output. Set a rule to `critical`, `high`, `medium`, `low`, or `info` to override severity before summaries and `--fail-on` are evaluated.
|
|
189
|
+
|
|
190
|
+
`failOn` sets the default CI failure threshold for the project. A CLI `--fail-on` value takes precedence, so local runs can still use `--fail-on none` or a stricter threshold.
|
|
191
|
+
|
|
171
192
|
## GitHub Action
|
|
172
193
|
|
|
173
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
194
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.4.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
174
195
|
|
|
175
196
|
```yaml
|
|
176
197
|
name: ai-saas-guard
|
|
@@ -194,6 +215,7 @@ jobs:
|
|
|
194
215
|
root: ${{ github.workspace }}
|
|
195
216
|
base: origin/main
|
|
196
217
|
fail-on: high
|
|
218
|
+
config: .ai-saas-guard.json
|
|
197
219
|
```
|
|
198
220
|
|
|
199
221
|
For SARIF upload:
|
|
@@ -298,10 +320,10 @@ Open-source core:
|
|
|
298
320
|
|
|
299
321
|
Near-term priorities:
|
|
300
322
|
|
|
301
|
-
- configurable severity and rule toggles
|
|
302
|
-
- expanded Supabase RLS fixtures
|
|
303
323
|
- Stripe webhook replay cookbook
|
|
304
324
|
- launch-readiness checklist content
|
|
325
|
+
- false-positive suppression and rule stability labels
|
|
326
|
+
- GitHub App design note for the potential hosted layer
|
|
305
327
|
|
|
306
328
|
Potential paid layer later:
|
|
307
329
|
|
package/action.yml
CHANGED
|
@@ -23,6 +23,10 @@ inputs:
|
|
|
23
23
|
description: Base ref for pr-risk.
|
|
24
24
|
required: false
|
|
25
25
|
default: ""
|
|
26
|
+
config:
|
|
27
|
+
description: Optional ai-saas-guard JSON config path.
|
|
28
|
+
required: false
|
|
29
|
+
default: ""
|
|
26
30
|
output:
|
|
27
31
|
description: Optional path to write output while also keeping the command exit code.
|
|
28
32
|
required: false
|
|
@@ -49,6 +53,7 @@ runs:
|
|
|
49
53
|
INPUT_FORMAT: ${{ inputs.format }}
|
|
50
54
|
INPUT_FAIL_ON: ${{ inputs.fail-on }}
|
|
51
55
|
INPUT_BASE: ${{ inputs.base }}
|
|
56
|
+
INPUT_CONFIG: ${{ inputs.config }}
|
|
52
57
|
INPUT_OUTPUT: ${{ inputs.output }}
|
|
53
58
|
run: |
|
|
54
59
|
set -o pipefail
|
|
@@ -95,6 +100,10 @@ runs:
|
|
|
95
100
|
args+=("--base" "${INPUT_BASE}")
|
|
96
101
|
fi
|
|
97
102
|
|
|
103
|
+
if [ -n "${INPUT_CONFIG}" ]; then
|
|
104
|
+
args+=("--config" "${INPUT_CONFIG}")
|
|
105
|
+
fi
|
|
106
|
+
|
|
98
107
|
if [ -n "${INPUT_OUTPUT}" ]; then
|
|
99
108
|
node "${GITHUB_ACTION_PATH}/dist/cli.js" "${args[@]}" | tee -- "${INPUT_OUTPUT}"
|
|
100
109
|
else
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { applyGuardConfig, loadGuardConfig } from "./config.js";
|
|
3
4
|
import { checkMcp, checkStripe, checkSupabase, classifyPrRisk, scanRepository } from "./index.js";
|
|
4
5
|
import { formatJsonReport } from "./report/json.js";
|
|
5
6
|
import { formatMarkdownReport } from "./report/markdown.js";
|
|
@@ -11,6 +12,7 @@ async function main(argv) {
|
|
|
11
12
|
process.stdout.write(helpText());
|
|
12
13
|
return 0;
|
|
13
14
|
}
|
|
15
|
+
const config = await loadGuardConfig(args.rootDir, args.configPath);
|
|
14
16
|
let report;
|
|
15
17
|
switch (args.command) {
|
|
16
18
|
case "scan":
|
|
@@ -32,9 +34,11 @@ async function main(argv) {
|
|
|
32
34
|
process.stderr.write(`Unknown command: ${String(args.command)}\n\n${helpText()}`);
|
|
33
35
|
return 2;
|
|
34
36
|
}
|
|
37
|
+
report = applyGuardConfig(report, config);
|
|
35
38
|
process.stdout.write(formatReport(report, args.format));
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const failOn = args.failOn ?? config.failOn;
|
|
40
|
+
if (shouldFail(report, failOn)) {
|
|
41
|
+
process.stderr.write(`Failing because findings met --fail-on ${failOn}\n`);
|
|
38
42
|
return 1;
|
|
39
43
|
}
|
|
40
44
|
return 0;
|
|
@@ -87,6 +91,14 @@ function parseArgs(argv) {
|
|
|
87
91
|
index += 1;
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
94
|
+
if (arg === "--config") {
|
|
95
|
+
const value = argv[index + 1];
|
|
96
|
+
if (!value)
|
|
97
|
+
throw new Error("--config requires a path");
|
|
98
|
+
result.configPath = resolve(value);
|
|
99
|
+
index += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
90
102
|
if (arg === "--base") {
|
|
91
103
|
const value = argv[index + 1];
|
|
92
104
|
if (!value)
|
|
@@ -141,11 +153,11 @@ function helpText() {
|
|
|
141
153
|
Repo-local launch-readiness scanner for AI-built SaaS apps.
|
|
142
154
|
|
|
143
155
|
Usage:
|
|
144
|
-
ai-saas-guard scan [--root <repo>] [--json|--sarif] [--fail-on <severity>]
|
|
145
|
-
ai-saas-guard check-supabase [--root <repo>] [--json|--sarif] [--fail-on <severity>]
|
|
146
|
-
ai-saas-guard check-stripe [--root <repo>] [--json|--sarif] [--fail-on <severity>]
|
|
147
|
-
ai-saas-guard check-mcp [--root <repo>] [--json|--sarif] [--fail-on <severity>]
|
|
148
|
-
ai-saas-guard pr-risk [--root <repo>] [--base <branch>] [--json|--sarif|--markdown] [--fail-on <severity>]
|
|
156
|
+
ai-saas-guard scan [--root <repo>] [--config <file>] [--json|--sarif] [--fail-on <severity>]
|
|
157
|
+
ai-saas-guard check-supabase [--root <repo>] [--config <file>] [--json|--sarif] [--fail-on <severity>]
|
|
158
|
+
ai-saas-guard check-stripe [--root <repo>] [--config <file>] [--json|--sarif] [--fail-on <severity>]
|
|
159
|
+
ai-saas-guard check-mcp [--root <repo>] [--config <file>] [--json|--sarif] [--fail-on <severity>]
|
|
160
|
+
ai-saas-guard pr-risk [--root <repo>] [--config <file>] [--base <branch>] [--json|--sarif|--markdown] [--fail-on <severity>]
|
|
149
161
|
|
|
150
162
|
Defaults:
|
|
151
163
|
- read-only
|
|
@@ -154,6 +166,7 @@ Defaults:
|
|
|
154
166
|
- terminal output by default, JSON with --json
|
|
155
167
|
- SARIF output for GitHub code scanning with --sarif
|
|
156
168
|
- PR-focused markdown summary with --markdown
|
|
169
|
+
- project config auto-loaded from .ai-saas-guard.json when present
|
|
157
170
|
`;
|
|
158
171
|
}
|
|
159
172
|
main(process.argv.slice(2)).then((code) => {
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BaseReport, Severity } from "./types.js";
|
|
2
|
+
export declare const defaultConfigFileName = ".ai-saas-guard.json";
|
|
3
|
+
export type RuleConfigValue = "off" | Severity;
|
|
4
|
+
export interface GuardConfig {
|
|
5
|
+
sourcePath?: string;
|
|
6
|
+
failOn?: Severity | "none";
|
|
7
|
+
rules: Record<string, RuleConfigValue>;
|
|
8
|
+
}
|
|
9
|
+
export declare function loadGuardConfig(rootDir: string, explicitPath?: string): Promise<GuardConfig>;
|
|
10
|
+
export declare function applyGuardConfig<T extends BaseReport>(report: T, config: GuardConfig): T;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { summarizeFindings, sortFindings } from "./report/findings.js";
|
|
4
|
+
import { getRuleMetadata } from "./rules/catalog.js";
|
|
5
|
+
export const defaultConfigFileName = ".ai-saas-guard.json";
|
|
6
|
+
export async function loadGuardConfig(rootDir, explicitPath) {
|
|
7
|
+
const sourcePath = explicitPath ? resolve(explicitPath) : resolve(rootDir, defaultConfigFileName);
|
|
8
|
+
let content;
|
|
9
|
+
try {
|
|
10
|
+
content = await readFile(sourcePath, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (!explicitPath && isNotFoundError(error))
|
|
14
|
+
return { rules: {} };
|
|
15
|
+
throw new Error(`Could not read config file ${sourcePath}: ${errorMessage(error)}`);
|
|
16
|
+
}
|
|
17
|
+
return parseGuardConfig(content, sourcePath);
|
|
18
|
+
}
|
|
19
|
+
export function applyGuardConfig(report, config) {
|
|
20
|
+
const configuredRuleIds = Object.keys(config.rules);
|
|
21
|
+
if (configuredRuleIds.length === 0)
|
|
22
|
+
return report;
|
|
23
|
+
const findings = sortFindings(report.findings.flatMap((finding) => {
|
|
24
|
+
const ruleConfig = config.rules[finding.ruleId];
|
|
25
|
+
if (!ruleConfig)
|
|
26
|
+
return [finding];
|
|
27
|
+
if (ruleConfig === "off")
|
|
28
|
+
return [];
|
|
29
|
+
return [{ ...finding, severity: ruleConfig }];
|
|
30
|
+
}));
|
|
31
|
+
return {
|
|
32
|
+
...report,
|
|
33
|
+
findings,
|
|
34
|
+
summary: summarizeFindings(findings)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function parseGuardConfig(content, sourcePath) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(content);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new Error(`Invalid JSON in config file ${sourcePath}: ${errorMessage(error)}`);
|
|
44
|
+
}
|
|
45
|
+
if (!isPlainObject(parsed)) {
|
|
46
|
+
throw new Error(`Invalid config file ${sourcePath}: expected a JSON object`);
|
|
47
|
+
}
|
|
48
|
+
const failOn = parsed.failOn;
|
|
49
|
+
if (failOn !== undefined && !isFailOnValue(failOn)) {
|
|
50
|
+
throw new Error("Invalid config failOn: expected critical, high, medium, low, info, or none");
|
|
51
|
+
}
|
|
52
|
+
const rawRules = parsed.rules ?? {};
|
|
53
|
+
if (!isPlainObject(rawRules)) {
|
|
54
|
+
throw new Error("Invalid config rules: expected an object keyed by rule ID");
|
|
55
|
+
}
|
|
56
|
+
const rules = {};
|
|
57
|
+
for (const [ruleId, value] of Object.entries(rawRules)) {
|
|
58
|
+
if (!getRuleMetadata(ruleId)) {
|
|
59
|
+
throw new Error(`Unknown rule ID in config: ${ruleId}`);
|
|
60
|
+
}
|
|
61
|
+
if (!isRuleConfigValue(value)) {
|
|
62
|
+
throw new Error(`Invalid config for rule ${ruleId}: expected off, critical, high, medium, low, or info`);
|
|
63
|
+
}
|
|
64
|
+
rules[ruleId] = value;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
sourcePath,
|
|
68
|
+
failOn,
|
|
69
|
+
rules
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function isRuleConfigValue(value) {
|
|
73
|
+
return value === "off" || isSeverity(value);
|
|
74
|
+
}
|
|
75
|
+
function isFailOnValue(value) {
|
|
76
|
+
return value === "none" || isSeverity(value);
|
|
77
|
+
}
|
|
78
|
+
function isSeverity(value) {
|
|
79
|
+
return value === "critical" || value === "high" || value === "medium" || value === "low" || value === "info";
|
|
80
|
+
}
|
|
81
|
+
function isPlainObject(value) {
|
|
82
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
function isNotFoundError(error) {
|
|
85
|
+
return isPlainObject(error) && error.code === "ENOENT";
|
|
86
|
+
}
|
|
87
|
+
function errorMessage(error) {
|
|
88
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ export { checkStripe } from "./commands/checkStripe.js";
|
|
|
3
3
|
export { checkSupabase } from "./commands/checkSupabase.js";
|
|
4
4
|
export { checkMcp } from "./commands/checkMcp.js";
|
|
5
5
|
export { classifyPrRisk } from "./commands/prRisk.js";
|
|
6
|
+
export { applyGuardConfig, defaultConfigFileName, loadGuardConfig } from "./config.js";
|
|
6
7
|
export { createScanContext } from "./context.js";
|
|
7
8
|
export { getRuleMetadata, RULE_CATALOG } from "./rules/catalog.js";
|
|
8
9
|
export type { BaseReport, CommandName, Evidence, Finding, McpReport, McpServerInventory, PrRiskFile, PrRiskReport, ScanOptions, StripeReport, SupabaseReport } from "./types.js";
|
|
9
10
|
export type { ScanContext, ScanInput } from "./context.js";
|
|
11
|
+
export type { GuardConfig, RuleConfigValue } from "./config.js";
|
|
10
12
|
export type { RuleMetadata, RuleStability } from "./rules/catalog.js";
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@ export { checkStripe } from "./commands/checkStripe.js";
|
|
|
3
3
|
export { checkSupabase } from "./commands/checkSupabase.js";
|
|
4
4
|
export { checkMcp } from "./commands/checkMcp.js";
|
|
5
5
|
export { classifyPrRisk } from "./commands/prRisk.js";
|
|
6
|
+
export { applyGuardConfig, defaultConfigFileName, loadGuardConfig } from "./config.js";
|
|
6
7
|
export { createScanContext } from "./context.js";
|
|
7
8
|
export { getRuleMetadata, RULE_CATALOG } from "./rules/catalog.js";
|
package/dist/rules/catalog.js
CHANGED
|
@@ -66,7 +66,7 @@ export const RULE_CATALOG = {
|
|
|
66
66
|
ruleId: "supabase.rls.broad-policy",
|
|
67
67
|
severity: "critical",
|
|
68
68
|
title: "Broad Supabase RLS policy",
|
|
69
|
-
why: "`USING (true)`
|
|
69
|
+
why: "`USING (true)` or `WITH CHECK (true)` can turn login into broad data access or writes.",
|
|
70
70
|
stability: "default"
|
|
71
71
|
},
|
|
72
72
|
"supabase.rls.missing-ownership-filter": {
|
|
@@ -76,6 +76,13 @@ export const RULE_CATALOG = {
|
|
|
76
76
|
why: "Policies need resource ownership or tenant membership checks.",
|
|
77
77
|
stability: "default"
|
|
78
78
|
},
|
|
79
|
+
"supabase.rls.weak-with-check": {
|
|
80
|
+
ruleId: "supabase.rls.weak-with-check",
|
|
81
|
+
severity: "high",
|
|
82
|
+
title: "Supabase write policy has a weak WITH CHECK predicate",
|
|
83
|
+
why: "Insert and update policies need WITH CHECK predicates tied to the current user or tenant membership.",
|
|
84
|
+
stability: "default"
|
|
85
|
+
},
|
|
79
86
|
"supabase.table.missing-owner-column": {
|
|
80
87
|
ruleId: "supabase.table.missing-owner-column",
|
|
81
88
|
severity: "medium",
|
|
@@ -28,18 +28,24 @@ export async function checkSupabase(input) {
|
|
|
28
28
|
for (const match of file.content.matchAll(/alter\s+table\s+([a-zA-Z0-9_."]+)\s+enable\s+row\s+level\s+security/gi)) {
|
|
29
29
|
rlsEnabledTables.add(normalizeSqlIdentifier(match[1]));
|
|
30
30
|
}
|
|
31
|
-
for (const
|
|
32
|
-
const policyName =
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
for (const policy of parsePolicies(file.content)) {
|
|
32
|
+
const { name: policyName, tableName, line } = policy;
|
|
33
|
+
const predicates = [policy.usingPredicate, policy.withCheckPredicate].filter((value) => Boolean(value));
|
|
34
|
+
if (isStorageObjectsTable(tableName)) {
|
|
35
|
+
const riskyStoragePredicate = predicates.find((predicate) => isUnscopedStoragePredicate(predicate));
|
|
36
|
+
if (riskyStoragePredicate) {
|
|
37
|
+
findings.push(storageFinding(file.path, file.content, line, "Supabase storage.objects policy lacks owner or tenant scope"));
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const broadPredicate = predicates.find((predicate) => isBroadPredicate(predicate));
|
|
42
|
+
if (broadPredicate || /\bto\s+(public|anon|authenticated)\b[\s\S]*\busing\s*\(\s*true\s*\)/i.test(policy.statement)) {
|
|
37
43
|
riskyPolicies.push({
|
|
38
44
|
file: file.path,
|
|
39
45
|
line,
|
|
40
46
|
policyName,
|
|
41
47
|
tableName,
|
|
42
|
-
reason: "Policy predicate is broad (`USING (true)` or equivalent)."
|
|
48
|
+
reason: "Policy predicate is broad (`USING (true)`, `WITH CHECK (true)`, or equivalent)."
|
|
43
49
|
});
|
|
44
50
|
findings.push(finding({
|
|
45
51
|
ruleId: "supabase.rls.broad-policy",
|
|
@@ -51,7 +57,8 @@ export async function checkSupabase(input) {
|
|
|
51
57
|
suggestedFix: "Replace broad predicates with ownership checks such as `auth.uid() = user_id` or a tenant membership join."
|
|
52
58
|
}));
|
|
53
59
|
}
|
|
54
|
-
|
|
60
|
+
const combinedPredicate = predicates.join(" ");
|
|
61
|
+
if (!/\bauth\.uid\s*\(/i.test(combinedPredicate) && !ownershipColumnPattern.test(combinedPredicate) && sensitiveTablePattern.test(tableName)) {
|
|
55
62
|
riskyPolicies.push({
|
|
56
63
|
file: file.path,
|
|
57
64
|
line,
|
|
@@ -69,18 +76,28 @@ export async function checkSupabase(input) {
|
|
|
69
76
|
suggestedFix: "Reference `auth.uid()` and a stable ownership or membership column in every sensitive table policy."
|
|
70
77
|
}));
|
|
71
78
|
}
|
|
79
|
+
if (sensitiveTablePattern.test(tableName) && hasWeakWithCheck(policy)) {
|
|
80
|
+
riskyPolicies.push({
|
|
81
|
+
file: file.path,
|
|
82
|
+
line,
|
|
83
|
+
policyName,
|
|
84
|
+
tableName,
|
|
85
|
+
reason: "Write policy has a weak or missing `WITH CHECK` predicate."
|
|
86
|
+
});
|
|
87
|
+
findings.push(finding({
|
|
88
|
+
ruleId: "supabase.rls.weak-with-check",
|
|
89
|
+
title: `Supabase write policy "${policyName}" has a weak WITH CHECK predicate`,
|
|
90
|
+
severity: "high",
|
|
91
|
+
evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
|
|
92
|
+
why: "A write policy can read the right tenant rows but still allow inserted or updated rows to move into another owner or tenant unless `WITH CHECK` is scoped.",
|
|
93
|
+
suggestedVerification: "As User A, try inserting or updating a row with User B's owner, organization, workspace, or tenant ID and confirm the database rejects it.",
|
|
94
|
+
suggestedFix: "Add a `WITH CHECK` predicate tied to `auth.uid()` and the same owner, tenant, or membership relationship used by the read policy."
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
72
97
|
}
|
|
73
|
-
for (const match of file.content.matchAll(/storage\.buckets[\s\S]{0,200}\bpublic\b\s*[,=]\s*true
|
|
98
|
+
for (const match of file.content.matchAll(/storage\.buckets[\s\S]{0,200}\bpublic\b\s*[,=]\s*true/gi)) {
|
|
74
99
|
const line = lineNumberForIndex(file.content, match.index ?? 0);
|
|
75
|
-
findings.push(
|
|
76
|
-
ruleId: "supabase.storage.public-bucket",
|
|
77
|
-
title: "Supabase storage policy or bucket appears public",
|
|
78
|
-
severity: "high",
|
|
79
|
-
evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
|
|
80
|
-
why: "Public buckets can expose uploads, invoices, profile documents, or tenant files even when database rows are protected.",
|
|
81
|
-
suggestedVerification: "Upload a private file as User A and confirm unauthenticated users and User B cannot fetch it by URL.",
|
|
82
|
-
suggestedFix: "Make buckets private by default and add storage object policies scoped by owner or tenant."
|
|
83
|
-
}));
|
|
100
|
+
findings.push(storageFinding(file.path, file.content, line, "Supabase storage bucket appears public"));
|
|
84
101
|
}
|
|
85
102
|
}
|
|
86
103
|
for (const table of tables) {
|
|
@@ -124,3 +141,100 @@ export async function checkSupabase(input) {
|
|
|
124
141
|
function normalizeSqlIdentifier(value) {
|
|
125
142
|
return value.replace(/"/g, "").trim().toLowerCase();
|
|
126
143
|
}
|
|
144
|
+
function parsePolicies(content) {
|
|
145
|
+
const policies = [];
|
|
146
|
+
const policyPattern = /create\s+policy\s+(?:"([^"]+)"|([^\s\n]+))\s+on\s+([a-zA-Z0-9_."]+)[\s\S]*?;/gi;
|
|
147
|
+
for (const match of content.matchAll(policyPattern)) {
|
|
148
|
+
const statement = match[0];
|
|
149
|
+
policies.push({
|
|
150
|
+
name: (match[1] ?? match[2] ?? "").trim(),
|
|
151
|
+
tableName: normalizeSqlIdentifier(match[3]),
|
|
152
|
+
operation: parsePolicyOperation(statement),
|
|
153
|
+
usingPredicate: extractPredicate(statement, "using"),
|
|
154
|
+
withCheckPredicate: extractPredicate(statement, "with check"),
|
|
155
|
+
statement,
|
|
156
|
+
line: lineNumberForIndex(content, match.index ?? 0)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return policies;
|
|
160
|
+
}
|
|
161
|
+
function parsePolicyOperation(statement) {
|
|
162
|
+
const match = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(statement);
|
|
163
|
+
const operation = match?.[1]?.toLowerCase();
|
|
164
|
+
if (operation === "select" ||
|
|
165
|
+
operation === "insert" ||
|
|
166
|
+
operation === "update" ||
|
|
167
|
+
operation === "delete" ||
|
|
168
|
+
operation === "all") {
|
|
169
|
+
return operation;
|
|
170
|
+
}
|
|
171
|
+
return "unknown";
|
|
172
|
+
}
|
|
173
|
+
function extractPredicate(statement, clause) {
|
|
174
|
+
const clausePattern = clause === "using" ? /\busing\s*\(/i : /\bwith\s+check\s*\(/i;
|
|
175
|
+
const match = clausePattern.exec(statement);
|
|
176
|
+
if (!match)
|
|
177
|
+
return undefined;
|
|
178
|
+
const openParen = match.index + match[0].lastIndexOf("(");
|
|
179
|
+
return extractBalancedParentheses(statement, openParen);
|
|
180
|
+
}
|
|
181
|
+
function extractBalancedParentheses(value, openParen) {
|
|
182
|
+
let depth = 0;
|
|
183
|
+
let inSingleQuote = false;
|
|
184
|
+
for (let index = openParen; index < value.length; index += 1) {
|
|
185
|
+
const char = value[index];
|
|
186
|
+
const next = value[index + 1];
|
|
187
|
+
if (char === "'" && next === "'") {
|
|
188
|
+
index += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (char === "'") {
|
|
192
|
+
inSingleQuote = !inSingleQuote;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (inSingleQuote)
|
|
196
|
+
continue;
|
|
197
|
+
if (char === "(")
|
|
198
|
+
depth += 1;
|
|
199
|
+
if (char === ")") {
|
|
200
|
+
depth -= 1;
|
|
201
|
+
if (depth === 0)
|
|
202
|
+
return value.slice(openParen + 1, index).trim();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
function isBroadPredicate(predicate) {
|
|
208
|
+
return predicate.trim().toLowerCase() === "true";
|
|
209
|
+
}
|
|
210
|
+
function hasWeakWithCheck(policy) {
|
|
211
|
+
if (policy.operation !== "insert" && policy.operation !== "update" && policy.operation !== "all")
|
|
212
|
+
return false;
|
|
213
|
+
if (!policy.withCheckPredicate)
|
|
214
|
+
return policy.operation === "insert";
|
|
215
|
+
if (isBroadPredicate(policy.withCheckPredicate))
|
|
216
|
+
return true;
|
|
217
|
+
return !isScopedOwnershipPredicate(policy.withCheckPredicate);
|
|
218
|
+
}
|
|
219
|
+
function isScopedOwnershipPredicate(predicate) {
|
|
220
|
+
return /\bauth\.uid\s*\(/i.test(predicate) && ownershipColumnPattern.test(predicate);
|
|
221
|
+
}
|
|
222
|
+
function isStorageObjectsTable(tableName) {
|
|
223
|
+
return tableName === "storage.objects";
|
|
224
|
+
}
|
|
225
|
+
function isUnscopedStoragePredicate(predicate) {
|
|
226
|
+
if (isBroadPredicate(predicate))
|
|
227
|
+
return true;
|
|
228
|
+
return !/\bauth\.uid\s*\(|\bowner\b|\bowner_id\b|\buser_id\b|\btenant_id\b|\borganization_id\b|\bworkspace_id\b/i.test(predicate);
|
|
229
|
+
}
|
|
230
|
+
function storageFinding(filePath, content, line, title) {
|
|
231
|
+
return finding({
|
|
232
|
+
ruleId: "supabase.storage.public-bucket",
|
|
233
|
+
title,
|
|
234
|
+
severity: "high",
|
|
235
|
+
evidence: [{ file: filePath, line, snippet: lineAt(content, line) }],
|
|
236
|
+
why: "Storage object policies or public buckets can expose tenant files even when database rows are protected.",
|
|
237
|
+
suggestedVerification: "Upload a private file as User A and confirm unauthenticated users and User B cannot fetch it by URL or object path.",
|
|
238
|
+
suggestedFix: "Keep buckets private and scope storage object policies by `owner`, `auth.uid()`, tenant, workspace, or a membership relationship."
|
|
239
|
+
});
|
|
240
|
+
}
|
package/docs/github-action.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`ai-saas-guard` ships as a composite GitHub Action for pull request and code scanning workflows.
|
|
4
4
|
|
|
5
|
-
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.
|
|
5
|
+
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.4.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
|
|
6
6
|
|
|
7
7
|
## PR Summary
|
|
8
8
|
|
|
@@ -29,6 +29,7 @@ jobs:
|
|
|
29
29
|
command: pr-risk
|
|
30
30
|
root: ${{ github.workspace }}
|
|
31
31
|
base: origin/main
|
|
32
|
+
config: .ai-saas-guard.json
|
|
32
33
|
format: markdown
|
|
33
34
|
output: ai-saas-guard-pr.md
|
|
34
35
|
- run: cat ai-saas-guard-pr.md >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -36,6 +37,21 @@ jobs:
|
|
|
36
37
|
|
|
37
38
|
Use markdown for PR review triage. It is intentionally short enough for a GitHub step summary or a PR comment created by your own workflow. It does not require a hosted service.
|
|
38
39
|
|
|
40
|
+
## Project Config
|
|
41
|
+
|
|
42
|
+
The Action auto-loads `.ai-saas-guard.json` from `root` when the file exists. Use the `config` input when the policy file lives somewhere else or when you want the workflow to be explicit:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
- uses: zr9959/ai-saas-guard@v0
|
|
46
|
+
with:
|
|
47
|
+
command: scan
|
|
48
|
+
root: ${{ github.workspace }}
|
|
49
|
+
config: .ai-saas-guard.json
|
|
50
|
+
fail-on: none
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Project config can disable noisy rules, override severity by rule ID, and set a default `failOn` threshold. A workflow `fail-on` input overrides the config threshold for that run.
|
|
54
|
+
|
|
39
55
|
## SARIF Upload
|
|
40
56
|
|
|
41
57
|
Use SARIF when you want findings to appear in GitHub code scanning alerts.
|
package/docs/npm-publishing.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
## Current State
|
|
6
6
|
|
|
7
7
|
- Package name: `ai-saas-guard`
|
|
8
|
-
- Current version: `0.
|
|
8
|
+
- Current version: `0.4.0`
|
|
9
9
|
- npm registry state: published at <https://www.npmjs.com/package/ai-saas-guard>
|
|
10
10
|
- First npm-published version: `0.1.1`
|
|
11
|
-
- GitHub Release: `v0.
|
|
11
|
+
- GitHub Release: `v0.4.0`
|
|
12
12
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
13
13
|
- Trusted Publisher: GitHub Actions, `zr9959/ai-saas-guard`, workflow `npm-publish.yml`, allowed action `npm publish`
|
|
14
14
|
- Long-lived npm publish token: not required
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
Use GitHub Actions with npm Trusted Publisher/OIDC:
|
|
19
19
|
|
|
20
|
-
1. Create and review a release tag such as `v0.
|
|
20
|
+
1. Create and review a release tag such as `v0.4.0`.
|
|
21
21
|
2. Publish from the GitHub Release or run the `Publish npm` workflow manually with `ref` set to that tag.
|
|
22
22
|
3. Keep `permissions.id-token: write` in the workflow so npm can exchange the GitHub Actions OIDC identity for a short-lived publish credential.
|
|
23
23
|
4. Run `npm publish --access public` from the workflow. Trusted publishing automatically generates provenance for this public package from this public repository.
|
package/docs/project-handoff.md
CHANGED
|
@@ -40,13 +40,14 @@ Implemented surfaces:
|
|
|
40
40
|
|
|
41
41
|
- secret-like values and risky public env exposure
|
|
42
42
|
- Stripe webhook signature, raw body, idempotency, and lifecycle handler heuristics
|
|
43
|
-
- Supabase RLS,
|
|
43
|
+
- Supabase RLS, tenant membership, ownership filter, weak `WITH CHECK`, and storage object policy heuristics
|
|
44
44
|
- sensitive API route heuristics
|
|
45
45
|
- MCP config side-effect and secret-bearing risk inventory
|
|
46
46
|
- Next/Vercel deploy and runtime footguns
|
|
47
47
|
- PR diff risk triage for auth, billing, RLS, env, tests removed, and large mixed diffs
|
|
48
48
|
- PR diff diagnostics when a base ref or shallow checkout prevents comparison
|
|
49
49
|
- PR-focused markdown summary output for GitHub step summaries or PR comments
|
|
50
|
+
- project-local `.ai-saas-guard.json` config for rule toggles, severity overrides, and default fail thresholds
|
|
50
51
|
- JSON output
|
|
51
52
|
- SARIF output
|
|
52
53
|
- composite GitHub Action wrapper
|
|
@@ -99,9 +100,7 @@ GitHub Project:
|
|
|
99
100
|
Current issue set:
|
|
100
101
|
|
|
101
102
|
- #1 Add launch-readiness checklist content
|
|
102
|
-
- #3 Add configurable rule severity and rule toggles
|
|
103
103
|
- #5 Write Stripe webhook replay cookbook
|
|
104
|
-
- #7 Expand Supabase RLS fixtures and ownership patterns
|
|
105
104
|
|
|
106
105
|
CI:
|
|
107
106
|
|
|
@@ -113,7 +112,7 @@ CI:
|
|
|
113
112
|
Publishing:
|
|
114
113
|
|
|
115
114
|
- npm package: `ai-saas-guard`
|
|
116
|
-
- Current release line: `v0.
|
|
115
|
+
- Current release line: `v0.4.0`
|
|
117
116
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
118
117
|
- Trusted Publisher: GitHub Actions for `zr9959/ai-saas-guard`, workflow `npm-publish.yml`
|
|
119
118
|
- Long-lived npm publish tokens should not be required.
|
|
@@ -140,11 +139,10 @@ Not allowed:
|
|
|
140
139
|
|
|
141
140
|
Recommended order:
|
|
142
141
|
|
|
143
|
-
1.
|
|
144
|
-
2.
|
|
145
|
-
3.
|
|
146
|
-
4.
|
|
147
|
-
5. Add a GitHub App design note for the potential hosted layer.
|
|
142
|
+
1. Write Stripe webhook replay cookbook.
|
|
143
|
+
2. Add launch-readiness checklist content.
|
|
144
|
+
3. Improve false-positive suppression and rule stability labels.
|
|
145
|
+
4. Add a GitHub App design note for the potential hosted layer.
|
|
148
146
|
|
|
149
147
|
For every feature, keep the scanner evidence-first:
|
|
150
148
|
|
package/docs/rules.md
CHANGED
|
@@ -27,11 +27,12 @@ Rule metadata is centralized in `src/rules/catalog.ts` and covered by tests so S
|
|
|
27
27
|
|
|
28
28
|
| Rule ID | Severity | Why it exists |
|
|
29
29
|
| --- | --- | --- |
|
|
30
|
-
| `supabase.rls.broad-policy` | critical | `USING (true)`
|
|
30
|
+
| `supabase.rls.broad-policy` | critical | `USING (true)` or `WITH CHECK (true)` can turn login into broad data access or writes. |
|
|
31
31
|
| `supabase.rls.missing-ownership-filter` | high | Policies need resource ownership or tenant membership checks. |
|
|
32
|
+
| `supabase.rls.weak-with-check` | high | Write policies need `WITH CHECK` predicates tied to the current user or tenant membership. |
|
|
32
33
|
| `supabase.table.missing-owner-column` | medium | Sensitive tables are hard to protect without owner/tenant keys. |
|
|
33
34
|
| `supabase.rls.not-enabled` | critical | User-data tables should enable row level security. |
|
|
34
|
-
| `supabase.storage.public-bucket` | high | Storage buckets can leak files even when database rows are protected. |
|
|
35
|
+
| `supabase.storage.public-bucket` | high | Storage buckets or unscoped storage object policies can leak files even when database rows are protected. |
|
|
35
36
|
|
|
36
37
|
## API Routes And Deploy
|
|
37
38
|
|