ai-saas-guard 0.28.0 → 0.29.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 +33 -10
- package/dist/hosted/app.d.ts +37 -0
- package/dist/hosted/app.js +58 -0
- package/dist/rules/catalog.js +21 -0
- package/dist/scanners/apiRoutes.js +86 -0
- package/dist/scanners/deploy.js +55 -0
- package/docs/README.zh-CN.md +33 -10
- package/docs/github-action.md +1 -1
- package/docs/github-marketplace-wrapper-decision.md +42 -0
- package/docs/hosted-node-container-app.md +14 -1
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +1 -1
- package/docs/rules.md +3 -0
- package/docs/sample-launch-report.md +60 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
ai-saas-guard is a local-first launch gate for AI-built SaaS apps. It focuses on auth, billing, data access, secrets, MCP, and deploy decisions, plus CI and fake-success paths, so you know what to review before launch or merge. It runs locally, reads your repo only, and does not upload code.
|
|
8
|
+
ai-saas-guard is a local-first launch gate for AI-built Next.js, Supabase, Stripe, Vercel, and MCP SaaS apps. It focuses on auth, billing, data access, secrets, MCP, and deploy decisions, plus CI and fake-success paths, so you know what to review before launch or merge. It runs locally, reads your repo only, and does not upload code.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -33,6 +33,7 @@ AI can make a SaaS look finished while the real launch blockers sit in trust-bou
|
|
|
33
33
|
|
|
34
34
|
- one customer can see or change another customer's data
|
|
35
35
|
- Stripe grants access from an unsigned, duplicated, missing, or failed webhook path
|
|
36
|
+
- Clerk or Prisma code trusts user-writable metadata or an unscoped tenant query
|
|
36
37
|
- provider errors get swallowed and the app returns fake success or demo data
|
|
37
38
|
- a secret leaks through env config or `NEXT_PUBLIC_*`
|
|
38
39
|
- an MCP tool, GitHub workflow, or deploy job has more power than the launch needs
|
|
@@ -41,6 +42,25 @@ AI can make a SaaS look finished while the real launch blockers sit in trust-bou
|
|
|
41
42
|
|
|
42
43
|
`ai-saas-guard` gives you a short local review queue for those risks. It does not prove the app is secure, certify a release, or replace human review. It tells founders, solo builders, small teams, and reviewers what deserves attention first.
|
|
43
44
|
|
|
45
|
+
## See The Output
|
|
46
|
+
|
|
47
|
+
The report is designed to be read before launch or before merging an AI-heavy PR. A longer copy-paste example is in [docs/sample-launch-report.md](docs/sample-launch-report.md).
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
Launch Gate: review before launch
|
|
51
|
+
4 findings: 1 high, 3 medium
|
|
52
|
+
|
|
53
|
+
HIGH stripe.webhook.missing-signature
|
|
54
|
+
File: app/api/stripe/webhook/route.ts
|
|
55
|
+
Why: billing access can be granted from a webhook path that does not verify Stripe signatures.
|
|
56
|
+
Verify: replay a webhook with an invalid signature and confirm the route rejects it.
|
|
57
|
+
Fix: read the raw body, call stripe.webhooks.constructEvent, and make event handling idempotent.
|
|
58
|
+
|
|
59
|
+
MEDIUM supabase.rls.tenant-predicate-missing
|
|
60
|
+
File: supabase/migrations/20260524_accounts.sql
|
|
61
|
+
Verify: sign in as user A and user B; confirm neither can SELECT or UPDATE the other's rows.
|
|
62
|
+
```
|
|
63
|
+
|
|
44
64
|
## What You Get
|
|
45
65
|
|
|
46
66
|
One command returns a launch-readiness report with:
|
|
@@ -58,9 +78,10 @@ One command returns a launch-readiness report with:
|
|
|
58
78
|
| Launch question | What ai-saas-guard checks |
|
|
59
79
|
| --- | --- |
|
|
60
80
|
| Can users only access their own data? | Supabase RLS, tenant/owner predicates, storage policies, API ownership hints, two-account verification guidance |
|
|
81
|
+
| Can auth metadata be trusted? | Clerk unsafe metadata used for roles, plans, tenant membership, or entitlements |
|
|
61
82
|
| Will billing change access correctly? | Stripe webhook signature, raw body, idempotency, entitlement paths, failure/cancel/update/refund coverage |
|
|
62
83
|
| Will broken integrations fail visibly? | Silent-success fallbacks, swallowed errors, hardcoded success responses, production mock/demo data, skipped or placeholder tests |
|
|
63
|
-
| Will production behave like local? | Next/Vercel headers, env docs, public env inventory, image/request amplification hints, request ID logging |
|
|
84
|
+
| Will production behave like local? | Next/Vercel headers, env docs, public env inventory, image/request amplification hints, request ID logging, Vercel cron guard hints |
|
|
64
85
|
| Are tools and CI overpowered? | MCP side-effect classes, local policy/receipt templates, GitHub Actions permissions, concurrency, checkout depth, action pinning |
|
|
65
86
|
| Can reviewers trust the PR? | `pr-risk` ranking for auth, billing, RLS, deploy, API, storage, tests, silent-success paths, missing spec context, and large AI diffs |
|
|
66
87
|
|
|
@@ -73,13 +94,13 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
|
|
|
73
94
|
| Area | Status |
|
|
74
95
|
| --- | --- |
|
|
75
96
|
| Public GitHub repository | Available |
|
|
76
|
-
| npm CLI | `ai-saas-guard@0.
|
|
77
|
-
| GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.
|
|
97
|
+
| npm CLI | `ai-saas-guard@0.29.0` |
|
|
98
|
+
| GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.29.0` |
|
|
78
99
|
| Outputs | Terminal, JSON, SARIF, and PR-focused markdown |
|
|
79
100
|
| Project config | `.ai-saas-guard.json` rule toggles, severity overrides, suppressions, and fail thresholds |
|
|
80
101
|
| Privacy model | Local-first, read-only scan commands, no LLM calls, no code upload |
|
|
81
|
-
| Versioned Action tags | `v0.
|
|
82
|
-
| Current release | `0.
|
|
102
|
+
| Versioned Action tags | `v0.29.0`, `v0` |
|
|
103
|
+
| Current release | `0.29.0` hosted Node checkout platform composition, Clerk unsafe metadata rule, Prisma tenant-scope rule, Vercel cron guard rule, sample launch report, and Marketplace wrapper decision |
|
|
83
104
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
84
105
|
| Repository trust hardening | Strict branch protection, Dependabot, CodeQL, fast-check fuzzing, signed release provenance assets, private vulnerability reporting, secret scanning, and push protection |
|
|
85
106
|
| Cloudflare hosted ingress | Deployed at `https://ai-saas-guard-hosted.zr9959.workers.dev`; signed GitHub App webhook delivery and compact Check Run smoke now pass in staging |
|
|
@@ -158,9 +179,9 @@ Evidence:
|
|
|
158
179
|
| Stripe | Missing webhook route, unsigned webhook handling, parsed-body signature risk, missing idempotency, missing failure/cancel/update/refund paths |
|
|
159
180
|
| Supabase | RLS disabled on sensitive tables, broad `USING`/`WITH CHECK`, tenant membership patterns, weak write checks, storage object policy scope |
|
|
160
181
|
| Silent success | Swallowed provider errors, hardcoded fallback success, production mock/demo data in sensitive paths, temporary trust-boundary bypasses, skipped or placeholder tests |
|
|
161
|
-
| API routes | Auth checks without obvious ownership guards, missing rate-limit hints on sensitive mutation routes |
|
|
182
|
+
| API routes | Auth checks without obvious ownership guards, Clerk unsafe metadata, Prisma tenant-scope gaps, missing rate-limit hints on sensitive mutation routes |
|
|
162
183
|
| MCP | Plaintext secrets, non-localhost binds, broad filesystem/write access, shell tools, raw SQL tools, side-effect classification, local policy and receipt template |
|
|
163
|
-
| Next/Vercel deploy | Static export/runtime mismatches, Edge runtime with Node-only APIs, missing security headers, undocumented server env, public env inventory, image/request amplification hints, missing request ID logging |
|
|
184
|
+
| Next/Vercel deploy | Static export/runtime mismatches, Edge runtime with Node-only APIs, missing security headers, undocumented server env, public env inventory, image/request amplification hints, missing request ID logging, unguarded Vercel cron routes |
|
|
164
185
|
| GitHub Actions | Broad workflow permissions, stale PR runs, docs-only full CI, missing fail-fast secret checks, shallow `pr-risk` checkout, unpinned Action refs |
|
|
165
186
|
| PR risk | Auth, billing, RLS, env, deploy, API, storage, silent-success, test-removal, missing spec context, and large mixed-diff classification |
|
|
166
187
|
|
|
@@ -242,7 +263,7 @@ The hosted production adapter layer is documented in [docs/hosted-production-ada
|
|
|
242
263
|
|
|
243
264
|
The hosted read-only checkout worker is exported from `ai-saas-guard/hosted/worker`. It creates a temporary checkout from trusted GitHub App identity, uses a runtime installation token only through git askpass, runs the fixed `ai-saas-guard pr-risk --json` command with bounded timeout/output, converts CLI JSON into compact findings, and deletes the checkout after success or failure. It does not return source, diffs, secrets, checkout paths, PR-authored commands, or installation tokens.
|
|
244
265
|
|
|
245
|
-
The hosted Node/container app skeleton is documented in [docs/hosted-node-container-app.md](docs/hosted-node-container-app.md). It exports `createHostedHttpApp`, `createInMemoryHostedAppPlatform`, and `planHostedNodeContainerDeployment` from `ai-saas-guard/hosted/app`. It adds a safe `/healthz` route, signed `/github/webhook` ingress, one-job worker tick, in-memory provider adapters for tests, and deployment-plan validation for secret manager, queue, compact report store, worker sandbox, and GitHub Checks publisher references. It still does not deploy or expose a public hosted service by itself.
|
|
266
|
+
The hosted Node/container app skeleton is documented in [docs/hosted-node-container-app.md](docs/hosted-node-container-app.md). It exports `createHostedHttpApp`, `createInMemoryHostedAppPlatform`, `createHostedNodeCheckoutAppPlatform`, and `planHostedNodeContainerDeployment` from `ai-saas-guard/hosted/app`. It adds a safe `/healthz` route, signed `/github/webhook` ingress, one-job worker tick, in-memory provider adapters for tests, a concrete read-only checkout worker composition with visible timeout/output safety budgets, and deployment-plan validation for secret manager, queue, compact report store, worker sandbox, and GitHub Checks publisher references. It still does not deploy or expose a public hosted service by itself.
|
|
246
267
|
|
|
247
268
|
The hosted staging deployment planner is documented in [docs/hosted-staging-deployment.md](docs/hosted-staging-deployment.md). It exports `planHostedProviderBinding`, `planHostedStagingDeployment`, and `planHostedGitHubAppPromotion` from `ai-saas-guard/hosted/staging`. It composes real provider references, the Node/container deployment plan, hosted operational release-gate evidence, and GitHub App deployment planning so staging and production promotion stay blocked until the required queue, store, worker sandbox, Check Run publisher, logs, metrics, rollback, and incident-response references are present. It still does not call a cloud provider, create a GitHub App, or expose a public hosted service by itself.
|
|
248
269
|
|
|
@@ -296,7 +317,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
296
317
|
|
|
297
318
|
## GitHub Action
|
|
298
319
|
|
|
299
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
320
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.29.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
300
321
|
|
|
301
322
|
```yaml
|
|
302
323
|
name: ai-saas-guard
|
|
@@ -353,6 +374,8 @@ Use markdown for reviewer-facing PR triage and SARIF for GitHub code scanning al
|
|
|
353
374
|
|
|
354
375
|
For maximum reproducibility, replace `v0` with the full commit SHA from the release notes.
|
|
355
376
|
|
|
377
|
+
The GitHub Marketplace wrapper decision is documented in [docs/github-marketplace-wrapper-decision.md](docs/github-marketplace-wrapper-decision.md). The current decision is to keep this as one product repo and not create a separate listing wrapper yet.
|
|
378
|
+
|
|
356
379
|
## Ignore File
|
|
357
380
|
|
|
358
381
|
Add `.ai-saas-guardignore` at the repository root to suppress generated fixtures, snapshots, vendored output, or known noisy paths:
|
package/dist/hosted/app.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type HostedCheckRunPublisher, type HostedCheckRunRequest, type HostedCompactReportStore, type HostedCompactReportStoreRecord, type HostedServiceQueueAdapter, type HostedServiceRuntime, type HostedServiceRuntimeOptions, type HostedServiceScanRunner } from "./service.js";
|
|
2
|
+
import { type HostedInstallationTokenProvider, type HostedReadOnlyCheckoutCommandRunner } from "./worker.js";
|
|
2
3
|
export declare const HOSTED_NODE_CONTAINER_PLATFORM = "node_container";
|
|
3
4
|
export declare const HOSTED_NODE_CONTAINER_ROLES: readonly ["webhook-ingress", "scan-worker"];
|
|
4
5
|
type RepositoryIdSource = HostedServiceRuntimeOptions["selectedRepositoryIdsByInstallation"];
|
|
@@ -64,6 +65,22 @@ export interface InMemoryHostedAppPlatformOptions {
|
|
|
64
65
|
scanRunner: HostedServiceScanRunner;
|
|
65
66
|
now?: () => string;
|
|
66
67
|
}
|
|
68
|
+
export interface HostedNodeCheckoutAppPlatformOptions {
|
|
69
|
+
signingKey: string | Buffer;
|
|
70
|
+
scannerVersion: string;
|
|
71
|
+
selectedRepositoryIdsByInstallation: RepositoryIdSource;
|
|
72
|
+
removedRepositoryIdsByInstallation?: RepositoryIdSource;
|
|
73
|
+
checkoutRoot?: string;
|
|
74
|
+
githubCloneBaseUrl?: string;
|
|
75
|
+
gitCommand?: string;
|
|
76
|
+
cliCommand?: string;
|
|
77
|
+
fetchDepth?: number;
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
maxOutputBytes?: number;
|
|
80
|
+
installationTokenProvider: HostedInstallationTokenProvider;
|
|
81
|
+
commandRunner?: HostedReadOnlyCheckoutCommandRunner;
|
|
82
|
+
now?: () => string;
|
|
83
|
+
}
|
|
67
84
|
export interface InMemoryHostedAppPlatform {
|
|
68
85
|
app: HostedHttpApp;
|
|
69
86
|
adapters: {
|
|
@@ -78,6 +95,25 @@ export interface InMemoryHostedAppPlatform {
|
|
|
78
95
|
platform: typeof HOSTED_NODE_CONTAINER_PLATFORM;
|
|
79
96
|
roles: typeof HOSTED_NODE_CONTAINER_ROLES;
|
|
80
97
|
}
|
|
98
|
+
export interface HostedNodeCheckoutAppPlatform extends InMemoryHostedAppPlatform {
|
|
99
|
+
checkoutWorker: "read_only_checkout";
|
|
100
|
+
workerSafety: HostedNodeCheckoutWorkerSafety;
|
|
101
|
+
}
|
|
102
|
+
export interface HostedNodeCheckoutWorkerSafety {
|
|
103
|
+
commandSource: "trusted_runtime_plan";
|
|
104
|
+
timeoutMs: number;
|
|
105
|
+
maxOutputBytes: number;
|
|
106
|
+
shell: "disabled";
|
|
107
|
+
cliNetworkAccess: "disabled";
|
|
108
|
+
writeMode: "read_only";
|
|
109
|
+
compactJsonOnly: true;
|
|
110
|
+
cleanupRequired: true;
|
|
111
|
+
returnsCheckoutPath: false;
|
|
112
|
+
persistsRawSource: false;
|
|
113
|
+
persistsRawDiffs: false;
|
|
114
|
+
persistsSecrets: false;
|
|
115
|
+
persistsCustomerPayloads: false;
|
|
116
|
+
}
|
|
81
117
|
export interface HostedNodeContainerDeploymentSecretRefs {
|
|
82
118
|
githubAppId: string;
|
|
83
119
|
githubAppPrivateKey: string;
|
|
@@ -140,5 +176,6 @@ export interface HostedNodeContainerDeploymentPlan {
|
|
|
140
176
|
}
|
|
141
177
|
export declare function createHostedHttpApp(options: HostedHttpAppOptions): HostedHttpApp;
|
|
142
178
|
export declare function createInMemoryHostedAppPlatform(options: InMemoryHostedAppPlatformOptions): InMemoryHostedAppPlatform;
|
|
179
|
+
export declare function createHostedNodeCheckoutAppPlatform(options: HostedNodeCheckoutAppPlatformOptions): HostedNodeCheckoutAppPlatform;
|
|
143
180
|
export declare function planHostedNodeContainerDeployment(input: HostedNodeContainerDeploymentInput): HostedNodeContainerDeploymentPlan;
|
|
144
181
|
export {};
|
package/dist/hosted/app.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHostedServiceRuntime, createInMemoryHostedServiceAdapters } from "./service.js";
|
|
2
|
+
import { createHostedReadOnlyCheckoutScanRunner } from "./worker.js";
|
|
3
|
+
import { HOSTED_WORKER_DEFAULT_TIMEOUT_MS, HOSTED_WORKER_MAX_OUTPUT_BYTES, HOSTED_WORKER_MAX_TIMEOUT_MS } from "./production-adapters.js";
|
|
2
4
|
export const HOSTED_NODE_CONTAINER_PLATFORM = "node_container";
|
|
3
5
|
export const HOSTED_NODE_CONTAINER_ROLES = ["webhook-ingress", "scan-worker"];
|
|
4
6
|
export function createHostedHttpApp(options) {
|
|
@@ -60,6 +62,39 @@ export function createInMemoryHostedAppPlatform(options) {
|
|
|
60
62
|
roles: HOSTED_NODE_CONTAINER_ROLES
|
|
61
63
|
};
|
|
62
64
|
}
|
|
65
|
+
export function createHostedNodeCheckoutAppPlatform(options) {
|
|
66
|
+
const adapters = createInMemoryHostedServiceAdapters();
|
|
67
|
+
const scanRunner = createHostedReadOnlyCheckoutScanRunner({
|
|
68
|
+
checkoutRoot: options.checkoutRoot,
|
|
69
|
+
githubCloneBaseUrl: options.githubCloneBaseUrl,
|
|
70
|
+
gitCommand: options.gitCommand,
|
|
71
|
+
cliCommand: options.cliCommand,
|
|
72
|
+
fetchDepth: options.fetchDepth,
|
|
73
|
+
timeoutMs: options.timeoutMs,
|
|
74
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
75
|
+
installationTokenProvider: options.installationTokenProvider,
|
|
76
|
+
commandRunner: options.commandRunner
|
|
77
|
+
});
|
|
78
|
+
const runtime = createHostedServiceRuntime({
|
|
79
|
+
signingKey: options.signingKey,
|
|
80
|
+
scannerVersion: options.scannerVersion,
|
|
81
|
+
selectedRepositoryIdsByInstallation: options.selectedRepositoryIdsByInstallation,
|
|
82
|
+
removedRepositoryIdsByInstallation: options.removedRepositoryIdsByInstallation,
|
|
83
|
+
queue: adapters.queue,
|
|
84
|
+
compactReportStore: adapters.compactReportStore,
|
|
85
|
+
checkRunPublisher: adapters.checkRunPublisher,
|
|
86
|
+
scanRunner,
|
|
87
|
+
now: options.now
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
app: createHostedHttpApp({ runtime }),
|
|
91
|
+
adapters,
|
|
92
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
93
|
+
roles: HOSTED_NODE_CONTAINER_ROLES,
|
|
94
|
+
checkoutWorker: "read_only_checkout",
|
|
95
|
+
workerSafety: hostedNodeCheckoutWorkerSafety(options)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
63
98
|
export function planHostedNodeContainerDeployment(input) {
|
|
64
99
|
const blockedReasons = [
|
|
65
100
|
...publicBaseUrlBlockedReasons(input.publicBaseUrl),
|
|
@@ -183,6 +218,29 @@ function appResponsePrivacy() {
|
|
|
183
218
|
includesInstallationToken: false
|
|
184
219
|
};
|
|
185
220
|
}
|
|
221
|
+
function hostedNodeCheckoutWorkerSafety(options) {
|
|
222
|
+
return {
|
|
223
|
+
commandSource: "trusted_runtime_plan",
|
|
224
|
+
timeoutMs: clampPositiveInteger(options.timeoutMs, HOSTED_WORKER_DEFAULT_TIMEOUT_MS, HOSTED_WORKER_MAX_TIMEOUT_MS),
|
|
225
|
+
maxOutputBytes: clampPositiveInteger(options.maxOutputBytes, HOSTED_WORKER_MAX_OUTPUT_BYTES, HOSTED_WORKER_MAX_OUTPUT_BYTES),
|
|
226
|
+
shell: "disabled",
|
|
227
|
+
cliNetworkAccess: "disabled",
|
|
228
|
+
writeMode: "read_only",
|
|
229
|
+
compactJsonOnly: true,
|
|
230
|
+
cleanupRequired: true,
|
|
231
|
+
returnsCheckoutPath: false,
|
|
232
|
+
persistsRawSource: false,
|
|
233
|
+
persistsRawDiffs: false,
|
|
234
|
+
persistsSecrets: false,
|
|
235
|
+
persistsCustomerPayloads: false
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function clampPositiveInteger(value, fallback, maximum) {
|
|
239
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
240
|
+
return fallback;
|
|
241
|
+
}
|
|
242
|
+
return Math.min(maximum, Math.max(1, Math.floor(value)));
|
|
243
|
+
}
|
|
186
244
|
function headerValue(headers, name) {
|
|
187
245
|
const lowerName = name.toLowerCase();
|
|
188
246
|
for (const [key, value] of Object.entries(headers)) {
|
package/dist/rules/catalog.js
CHANGED
|
@@ -195,6 +195,20 @@ export const RULE_CATALOG = {
|
|
|
195
195
|
why: "Login checks do not prove resource ownership checks.",
|
|
196
196
|
stability: "experimental"
|
|
197
197
|
},
|
|
198
|
+
"auth.clerk.unsafe-metadata": {
|
|
199
|
+
ruleId: "auth.clerk.unsafe-metadata",
|
|
200
|
+
severity: "high",
|
|
201
|
+
title: "Clerk unsafe metadata is used for privileged state",
|
|
202
|
+
why: "Clerk unsafe metadata is user-writable and should not drive roles, plans, tenant membership, or entitlements.",
|
|
203
|
+
stability: "default"
|
|
204
|
+
},
|
|
205
|
+
"data.prisma.tenant-scope-missing": {
|
|
206
|
+
ruleId: "data.prisma.tenant-scope-missing",
|
|
207
|
+
severity: "high",
|
|
208
|
+
title: "Prisma resource access lacks tenant or owner scope",
|
|
209
|
+
why: "Authenticated Prisma reads or mutations on tenant-like resources need tenant, owner, organization, or workspace predicates.",
|
|
210
|
+
stability: "experimental"
|
|
211
|
+
},
|
|
198
212
|
"silent-success.swallowed-error": {
|
|
199
213
|
ruleId: "silent-success.swallowed-error",
|
|
200
214
|
severity: "high",
|
|
@@ -279,6 +293,13 @@ export const RULE_CATALOG = {
|
|
|
279
293
|
why: "Billing, webhook, and tenant incidents are hard to debug without traceable request IDs.",
|
|
280
294
|
stability: "experimental"
|
|
281
295
|
},
|
|
296
|
+
"deploy.vercel.cron-missing-guard": {
|
|
297
|
+
ruleId: "deploy.vercel.cron-missing-guard",
|
|
298
|
+
severity: "medium",
|
|
299
|
+
title: "Vercel cron route lacks launch guards",
|
|
300
|
+
why: "Scheduled billing, tenant, or cleanup jobs need a secret guard, idempotency, and request tracing before launch.",
|
|
301
|
+
stability: "experimental"
|
|
302
|
+
},
|
|
282
303
|
"deploy.edge-runtime-node-api": {
|
|
283
304
|
ruleId: "deploy.edge-runtime-node-api",
|
|
284
305
|
severity: "medium",
|
|
@@ -11,6 +11,8 @@ export async function scanApiRoutes(input) {
|
|
|
11
11
|
for (const file of files) {
|
|
12
12
|
const hasPostOrMutation = /\b(POST|PUT|PATCH|DELETE)\b|export\s+async\s+function\s+(POST|PUT|PATCH|DELETE)/.test(file.content);
|
|
13
13
|
const isSensitive = sensitiveRoutePattern.test(file.path) || sensitiveRoutePattern.test(file.content);
|
|
14
|
+
findings.push(...scanClerkUnsafeMetadata(file.path, file.content));
|
|
15
|
+
findings.push(...scanPrismaTenantScope(file.path, file.content));
|
|
14
16
|
if (isSensitive && hasPostOrMutation && !rateLimitPattern.test(file.content)) {
|
|
15
17
|
findings.push(finding({
|
|
16
18
|
ruleId: "api.route.missing-rate-limit",
|
|
@@ -36,11 +38,95 @@ export async function scanApiRoutes(input) {
|
|
|
36
38
|
}
|
|
37
39
|
return uniqueFindings(findings);
|
|
38
40
|
}
|
|
41
|
+
function scanClerkUnsafeMetadata(filePath, content) {
|
|
42
|
+
if (!/\b(@clerk\/|clerkClient|currentUser|auth\s*\()/i.test(content))
|
|
43
|
+
return [];
|
|
44
|
+
const findings = [];
|
|
45
|
+
const privilegedUnsafeMetadataPattern = /unsafeMetadata\s*:\s*\{[\s\S]{0,700}\b(role|roles|admin|isAdmin|plan|tier|subscription|entitlement|credits|tenant|tenantId|organization|organizationId|orgId|workspace|permissions?)\b/gi;
|
|
46
|
+
for (const match of content.matchAll(privilegedUnsafeMetadataPattern)) {
|
|
47
|
+
const line = lineNumberForIndex(content, match.index ?? 0);
|
|
48
|
+
findings.push(finding({
|
|
49
|
+
ruleId: "auth.clerk.unsafe-metadata",
|
|
50
|
+
title: `Clerk unsafe metadata appears to store privileged launch state: ${filePath}`,
|
|
51
|
+
severity: "high",
|
|
52
|
+
evidence: [{ file: filePath, line, snippet: lineAt(content, line) }],
|
|
53
|
+
why: "Clerk unsafe metadata can be modified from the client side, so roles, paid plans, tenant membership, or entitlement state stored there can become authorization input by accident.",
|
|
54
|
+
suggestedVerification: "Try changing the same metadata as a normal signed-in user and confirm it cannot grant admin, paid plan, tenant, workspace, or entitlement access.",
|
|
55
|
+
suggestedFix: "Store authorization and billing state in server-controlled Clerk private/public metadata or your database, and use unsafe metadata only for non-privileged user preferences."
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
60
|
+
function scanPrismaTenantScope(filePath, content) {
|
|
61
|
+
if (!/\bprisma\.[A-Za-z0-9_]+\./.test(content) || !authPattern.test(content))
|
|
62
|
+
return [];
|
|
63
|
+
const findings = [];
|
|
64
|
+
const operationPattern = /\bprisma\.([A-Za-z0-9_]+)\.(findUnique|findFirst|update|delete|upsert|updateMany|deleteMany)\s*\(/gi;
|
|
65
|
+
for (const match of content.matchAll(operationPattern)) {
|
|
66
|
+
const model = match[1];
|
|
67
|
+
if (!isTenantLikePrismaSurface(filePath, content, model))
|
|
68
|
+
continue;
|
|
69
|
+
const start = content.indexOf("(", match.index ?? 0);
|
|
70
|
+
const end = findMatchingParen(content, start);
|
|
71
|
+
const operationText = content.slice(match.index ?? 0, end > start ? end + 1 : (match.index ?? 0) + 900);
|
|
72
|
+
if (hasTenantOrOwnerScope(operationText))
|
|
73
|
+
continue;
|
|
74
|
+
const line = lineNumberForIndex(content, match.index ?? 0);
|
|
75
|
+
findings.push(finding({
|
|
76
|
+
ruleId: "data.prisma.tenant-scope-missing",
|
|
77
|
+
title: `Prisma ${model} access lacks an obvious tenant or owner predicate: ${filePath}`,
|
|
78
|
+
severity: "high",
|
|
79
|
+
evidence: [{ file: filePath, line, snippet: lineAt(content, line) }],
|
|
80
|
+
why: "A route can authenticate the caller but still read or mutate another tenant's resource when Prisma queries only scope by a guessed resource ID.",
|
|
81
|
+
suggestedVerification: "Run a two-account or cross-tenant test: create this resource as Tenant/User A, then attempt the same read/update/delete with Tenant/User B.",
|
|
82
|
+
suggestedFix: "Add tenant, organization, workspace, owner, user, or membership predicates to the Prisma `where` clause and cover the cross-tenant denial in tests."
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
}
|
|
39
87
|
function isApiRoute(path) {
|
|
40
88
|
return (/(^|\/)app\/api\/.+\/route\.(ts|js|tsx|jsx)$/i.test(path) ||
|
|
41
89
|
/(^|\/)pages\/api\/.+\.(ts|js)$/i.test(path) ||
|
|
42
90
|
/(^|\/)(routes|controllers)\/.+\.(ts|js)$/i.test(path));
|
|
43
91
|
}
|
|
92
|
+
function isTenantLikePrismaSurface(filePath, content, model) {
|
|
93
|
+
return /\[(?:tenant|org|organization|workspace|project|client|customer|team|account|invoice|subscription|id)[^\]]*\]/i.test(filePath) ||
|
|
94
|
+
/\b(tenant|organization|orgId|workspace|project|client|customer|team|account|invoice|subscription|membership)\b/i.test(`${model}\n${filePath}\n${content}`);
|
|
95
|
+
}
|
|
96
|
+
function hasTenantOrOwnerScope(operationText) {
|
|
97
|
+
return /\b(tenantId|tenant_id|orgId|organizationId|organization_id|workspaceId|workspace_id|ownerId|owner_id|userId|user_id|memberId|member_id|membershipId|membership_id)\s*:/i.test(operationText) ||
|
|
98
|
+
/\b(memberships?|organization|workspace|tenant)\s*:\s*\{/i.test(operationText);
|
|
99
|
+
}
|
|
100
|
+
function findMatchingParen(content, openParen) {
|
|
101
|
+
if (openParen < 0)
|
|
102
|
+
return -1;
|
|
103
|
+
let depth = 0;
|
|
104
|
+
let inSingle = false;
|
|
105
|
+
let inDouble = false;
|
|
106
|
+
let inTemplate = false;
|
|
107
|
+
for (let index = openParen; index < content.length; index += 1) {
|
|
108
|
+
const char = content[index];
|
|
109
|
+
const prev = content[index - 1];
|
|
110
|
+
if (prev === "\\")
|
|
111
|
+
continue;
|
|
112
|
+
if (char === "'" && !inDouble && !inTemplate)
|
|
113
|
+
inSingle = !inSingle;
|
|
114
|
+
if (char === "\"" && !inSingle && !inTemplate)
|
|
115
|
+
inDouble = !inDouble;
|
|
116
|
+
if (char === "`" && !inSingle && !inDouble)
|
|
117
|
+
inTemplate = !inTemplate;
|
|
118
|
+
if (inSingle || inDouble || inTemplate)
|
|
119
|
+
continue;
|
|
120
|
+
if (char === "(")
|
|
121
|
+
depth += 1;
|
|
122
|
+
if (char === ")") {
|
|
123
|
+
depth -= 1;
|
|
124
|
+
if (depth === 0)
|
|
125
|
+
return index;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
44
130
|
function firstLine(content, pattern) {
|
|
45
131
|
const match = pattern.exec(content);
|
|
46
132
|
pattern.lastIndex = 0;
|
package/dist/scanners/deploy.js
CHANGED
|
@@ -11,6 +11,7 @@ export async function scanDeployConfig(input) {
|
|
|
11
11
|
const envReferences = [];
|
|
12
12
|
const sensitiveRoutes = files.filter((file) => isSensitiveServerRoute(file.path, file.content));
|
|
13
13
|
const nextConfig = files.find((file) => /(^|\/)next\.config\.(ts|js|mjs|cjs)$/.test(file.path));
|
|
14
|
+
const vercelCronPaths = collectVercelCronPaths(files);
|
|
14
15
|
for (const file of files) {
|
|
15
16
|
for (const match of file.content.matchAll(/process\.env\.([A-Z0-9_]+)/g)) {
|
|
16
17
|
referencedEnv.add(match[1]);
|
|
@@ -96,6 +97,18 @@ export async function scanDeployConfig(input) {
|
|
|
96
97
|
suggestedFix: "Add structured logging with request ID or trace ID near the route entry point and around billing/tenant state changes."
|
|
97
98
|
}));
|
|
98
99
|
}
|
|
100
|
+
if (isVercelCronRoute(file.path, vercelCronPaths) && !hasVercelCronLaunchGuard(file.content)) {
|
|
101
|
+
const line = firstLine(file.content, /export\s+async\s+function\s+(GET|POST)|\b(GET|POST)\b/i) ?? 1;
|
|
102
|
+
findings.push(finding({
|
|
103
|
+
ruleId: "deploy.vercel.cron-missing-guard",
|
|
104
|
+
title: `Vercel cron route lacks secret, idempotency, or tracing guard: ${file.path}`,
|
|
105
|
+
severity: "medium",
|
|
106
|
+
evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
|
|
107
|
+
why: "Scheduled launch jobs often mutate billing, tenant, or cleanup state; without a secret guard, idempotency key, and request trace, retries or accidental calls can create hidden production drift.",
|
|
108
|
+
suggestedVerification: "Call the cron route without the expected cron secret and with a repeated request ID; confirm unauthorized calls fail and repeated runs do not duplicate state changes.",
|
|
109
|
+
suggestedFix: "Check a server-only cron secret, record an idempotency key or run lock, and log a request/trace ID before stateful cron work starts."
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
99
112
|
}
|
|
100
113
|
if (sensitiveRoutes.length > 0 && !hasSecurityHeaders(files)) {
|
|
101
114
|
const evidenceFile = nextConfig ?? sensitiveRoutes[0];
|
|
@@ -194,6 +207,48 @@ function isObservabilitySensitiveRoute(path, content) {
|
|
|
194
207
|
function hasRequestLogging(content) {
|
|
195
208
|
return /\b(requestId|request_id|traceId|trace_id|x-request-id|console\.(info|log|warn|error)|logger\.(info|warn|error))\b/i.test(content);
|
|
196
209
|
}
|
|
210
|
+
function collectVercelCronPaths(files) {
|
|
211
|
+
const paths = new Set();
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
if (!/(^|\/)vercel\.json$/i.test(file.path))
|
|
214
|
+
continue;
|
|
215
|
+
for (const match of file.content.matchAll(/"path"\s*:\s*"([^"]+)"/g)) {
|
|
216
|
+
if (/\/api\/.+/i.test(match[1])) {
|
|
217
|
+
paths.add(normalizeRoutePath(match[1]));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return paths;
|
|
222
|
+
}
|
|
223
|
+
function isVercelCronRoute(path, cronPaths) {
|
|
224
|
+
const routePath = routePathForServerRoute(path);
|
|
225
|
+
return (Boolean(routePath && cronPaths.has(routePath)) ||
|
|
226
|
+
/(^|\/)(app\/api|pages\/api|api)\/cron(\/|\.|-)/i.test(path));
|
|
227
|
+
}
|
|
228
|
+
function routePathForServerRoute(path) {
|
|
229
|
+
const appRoute = path.match(/(?:^|\/)app\/api\/(.+)\/route\.[cm]?[jt]sx?$/i);
|
|
230
|
+
if (appRoute)
|
|
231
|
+
return normalizeRoutePath(`/api/${appRoute[1]}`);
|
|
232
|
+
const pagesRoute = path.match(/(?:^|\/)pages\/api\/(.+)\.[cm]?[jt]sx?$/i);
|
|
233
|
+
if (pagesRoute)
|
|
234
|
+
return normalizeRoutePath(`/api/${pagesRoute[1]}`);
|
|
235
|
+
const plainRoute = path.match(/(?:^|\/)api\/(.+)\.[cm]?[jt]sx?$/i);
|
|
236
|
+
if (plainRoute)
|
|
237
|
+
return normalizeRoutePath(`/api/${plainRoute[1]}`);
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
function normalizeRoutePath(path) {
|
|
241
|
+
return path
|
|
242
|
+
.replace(/\\/g, "/")
|
|
243
|
+
.replace(/\/+/g, "/")
|
|
244
|
+
.replace(/\/$/, "");
|
|
245
|
+
}
|
|
246
|
+
function hasVercelCronLaunchGuard(content) {
|
|
247
|
+
const hasSecretGuard = /\b(CRON_SECRET|AUTHORIZATION|authorization|Bearer|x-vercel-cron|x-vercel-signature)\b/i.test(content);
|
|
248
|
+
const hasIdempotency = /\b(idempotency|idempotent|cronRun|jobRun|runId|x-vercel-id|upsert|dedupe|lock)\b/i.test(content);
|
|
249
|
+
const hasRequestTrace = /\b(requestId|request_id|traceId|trace_id|x-vercel-id|logger\.(info|warn|error)|console\.(info|warn|error))\b/i.test(content);
|
|
250
|
+
return hasSecretGuard && hasIdempotency && hasRequestTrace;
|
|
251
|
+
}
|
|
197
252
|
function firstLine(content, pattern) {
|
|
198
253
|
const match = pattern.exec(content);
|
|
199
254
|
pattern.lastIndex = 0;
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
ai-saas-guard 是面向 AI 构建的 SaaS 的本地优先上线 gate。它会优先指出 auth、billing、data access、secrets、MCP、deploy、CI 和“假成功”路径里最值得人工 review 的改动,让你在上线前知道该先看哪里。它本地运行、只读仓库、不上传代码。
|
|
8
|
+
ai-saas-guard 是面向 AI 构建的 Next.js、Supabase、Stripe、Vercel 和 MCP SaaS 的本地优先上线 gate。它会优先指出 auth、billing、data access、secrets、MCP、deploy、CI 和“假成功”路径里最值得人工 review 的改动,让你在上线前知道该先看哪里。它本地运行、只读仓库、不上传代码。
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -32,6 +32,7 @@ AI 能很快把一个 SaaS 做到“看起来能用”。真正危险的是上
|
|
|
32
32
|
|
|
33
33
|
- 一个用户能看到或修改另一个客户的数据
|
|
34
34
|
- Stripe webhook 因为未签名、重复、漏处理失败事件而错误开通权限
|
|
35
|
+
- Clerk 或 Prisma 代码把用户可改 metadata、未按租户约束的查询当成可信权限依据
|
|
35
36
|
- 真实服务失败后,AI 生成的代码仍然返回“成功”或 demo 数据
|
|
36
37
|
- secret 被 env 配置或 `NEXT_PUBLIC_*` 暴露出去
|
|
37
38
|
- MCP 工具、GitHub workflow 或 deploy job 拿到了过大的权限
|
|
@@ -40,6 +41,25 @@ AI 能很快把一个 SaaS 做到“看起来能用”。真正危险的是上
|
|
|
40
41
|
|
|
41
42
|
`ai-saas-guard` 是面向这个时刻的本地优先、review-first 上线预检工具。它不会证明你的应用绝对安全,也不是渗透测试、认证或完整安全审计。它的目标是给 founder、独立开发者、小团队和 reviewer 一份短而有证据的清单,告诉你上线或合并 PR 前最该先看哪里。
|
|
42
43
|
|
|
44
|
+
## 输出长什么样
|
|
45
|
+
|
|
46
|
+
报告是给上线前或合并 AI 大 PR 前快速阅读的。更完整的可复制样例见 [docs/sample-launch-report.md](sample-launch-report.md)。
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
Launch Gate: review before launch
|
|
50
|
+
4 findings: 1 high, 3 medium
|
|
51
|
+
|
|
52
|
+
HIGH stripe.webhook.missing-signature
|
|
53
|
+
File: app/api/stripe/webhook/route.ts
|
|
54
|
+
Why: billing access can be granted from a webhook path that does not verify Stripe signatures.
|
|
55
|
+
Verify: replay a webhook with an invalid signature and confirm the route rejects it.
|
|
56
|
+
Fix: read the raw body, call stripe.webhooks.constructEvent, and make event handling idempotent.
|
|
57
|
+
|
|
58
|
+
MEDIUM supabase.rls.tenant-predicate-missing
|
|
59
|
+
File: supabase/migrations/20260524_accounts.sql
|
|
60
|
+
Verify: sign in as user A and user B; confirm neither can SELECT or UPDATE the other's rows.
|
|
61
|
+
```
|
|
62
|
+
|
|
43
63
|
## 你会得到什么
|
|
44
64
|
|
|
45
65
|
一个命令会返回一份上线前 review 队列:
|
|
@@ -57,9 +77,10 @@ AI 能很快把一个 SaaS 做到“看起来能用”。真正危险的是上
|
|
|
57
77
|
| 上线问题 | ai-saas-guard 会检查什么 |
|
|
58
78
|
| --- | --- |
|
|
59
79
|
| 用户是否只能访问自己的数据? | Supabase RLS、tenant/owner predicate、storage policy、API ownership 提示、双账号验证建议 |
|
|
80
|
+
| auth metadata 是否可信? | Clerk unsafe metadata 是否被用于 role、plan、tenant membership 或 entitlement |
|
|
60
81
|
| 付费权限是否会正确开通和撤销? | Stripe webhook 签名、raw body、幂等、entitlement 路径、失败/取消/更新/退款覆盖 |
|
|
61
82
|
| 集成失败时会不会明显失败? | silent-success fallback、吞错、hardcoded success、production mock/demo data、跳过或占位测试 |
|
|
62
|
-
| 生产环境是否真的等于本地成功? | Next/Vercel headers、env 文档、public env 盘点、image/request 放大风险、request ID logging |
|
|
83
|
+
| 生产环境是否真的等于本地成功? | Next/Vercel headers、env 文档、public env 盘点、image/request 放大风险、request ID logging、Vercel cron guard 提示 |
|
|
63
84
|
| 工具和 CI 权限是不是过大? | MCP side-effect 分类、本地 policy/receipt 模板、GitHub Actions 权限、concurrency、checkout depth、Action pinning |
|
|
64
85
|
| reviewer 能不能看懂 AI PR? | `pr-risk` 对 auth、billing、RLS、deploy、API、storage、测试、silent-success、缺 spec context 和大型 diff 排序 |
|
|
65
86
|
|
|
@@ -67,18 +88,18 @@ AI 能很快把一个 SaaS 做到“看起来能用”。真正危险的是上
|
|
|
67
88
|
|
|
68
89
|
这个仓库是公开 GitHub 仓库。
|
|
69
90
|
|
|
70
|
-
CLI 已发布到 npm:`ai-saas-guard@0.
|
|
91
|
+
CLI 已发布到 npm:`ai-saas-guard@0.29.0`。GitHub Action 支持 `v0` 浮动标签,也支持固定版本标签,例如 `v0.29.0`。
|
|
71
92
|
|
|
72
93
|
| 模块 | 状态 |
|
|
73
94
|
| --- | --- |
|
|
74
95
|
| 公开 GitHub 仓库 | 已可用 |
|
|
75
|
-
| npm CLI | `ai-saas-guard@0.
|
|
76
|
-
| GitHub Action | `zr9959/ai-saas-guard@v0` 或固定标签 `v0.
|
|
96
|
+
| npm CLI | `ai-saas-guard@0.29.0` |
|
|
97
|
+
| GitHub Action | `zr9959/ai-saas-guard@v0` 或固定标签 `v0.29.0` |
|
|
77
98
|
| 输出格式 | Terminal、JSON、SARIF 和 PR markdown |
|
|
78
99
|
| 项目配置 | `.ai-saas-guard.json` 支持规则开关、severity 覆盖、suppressions 和 fail threshold |
|
|
79
100
|
| 隐私模型 | 本地优先、只读扫描、不调用 LLM、不上传代码 |
|
|
80
|
-
| 当前版本 | `0.
|
|
81
|
-
| Action 标签 | `v0.
|
|
101
|
+
| 当前版本 | `0.29.0` hosted Node checkout platform 组合入口、Clerk unsafe metadata 规则、Prisma tenant-scope 规则、Vercel cron guard 规则、sample launch report 和 Marketplace wrapper 决策 |
|
|
102
|
+
| Action 标签 | `v0.29.0`、`v0` |
|
|
82
103
|
| npm 发布 | GitHub Actions Trusted Publisher/OIDC,无需长期 npm token |
|
|
83
104
|
| 仓库可信度加固 | 严格 branch protection、Dependabot、CodeQL、fast-check fuzzing、signed release provenance assets、private vulnerability reporting、secret scanning 和 push protection |
|
|
84
105
|
| Cloudflare hosted ingress | 已部署到 `https://ai-saas-guard-hosted.zr9959.workers.dev`;签名 GitHub App webhook delivery 和 compact Check Run staging smoke 已通过 |
|
|
@@ -142,9 +163,9 @@ node dist/cli.js scan --root /path/to/your-saas
|
|
|
142
163
|
| Stripe | webhook 缺失、未验证签名、raw body 签名风险、缺幂等、缺失败/取消/退款/更新处理 |
|
|
143
164
|
| Supabase | 敏感表没启用 RLS、policy 过宽、缺少 ownership filter、`WITH CHECK` 过弱、storage object policy 过宽 |
|
|
144
165
|
| Silent success | 捕获错误后返回假成功、敏感路径里的 hardcoded fallback、production 路径引入 mock/demo data、临时绕过 auth/webhook/ownership、跳过或占位测试 |
|
|
145
|
-
| API routes | 有 auth 但缺少明显 ownership guard,敏感 mutation route 缺少 rate-limit 提示 |
|
|
166
|
+
| API routes | 有 auth 但缺少明显 ownership guard、Clerk unsafe metadata、Prisma tenant-scope gap,敏感 mutation route 缺少 rate-limit 提示 |
|
|
146
167
|
| MCP | 明文 secret、非 localhost 绑定、过宽文件系统权限、shell 工具、raw SQL 工具、side-effect 分类、本地 policy/receipt 模板 |
|
|
147
|
-
| Next/Vercel deploy | Next static export 和 API route 冲突、Edge runtime 使用 Node-only API、security headers 缺失、server env 文档缺失、public env 盘点、image/request 放大风险、request ID logging 缺失 |
|
|
168
|
+
| Next/Vercel deploy | Next static export 和 API route 冲突、Edge runtime 使用 Node-only API、security headers 缺失、server env 文档缺失、public env 盘点、image/request 放大风险、request ID logging 缺失、Vercel cron route guard 缺失 |
|
|
148
169
|
| GitHub Actions | workflow 权限过宽、PR workflow 缺 concurrency cancel、docs-only 改动跑全量 CI、secret/tool version 缺 fail-fast、`pr-risk` checkout 太浅、Action 未 pin SHA |
|
|
149
170
|
| PR risk | auth、billing、RLS、env、deploy、API、storage、silent-success、测试删除、缺 spec/context、大型混合 diff |
|
|
150
171
|
|
|
@@ -231,6 +252,8 @@ jobs:
|
|
|
231
252
|
|
|
232
253
|
更多 GitHub Action 示例请看 [docs/github-action.md](github-action.md)。
|
|
233
254
|
|
|
255
|
+
GitHub Marketplace wrapper 决策见 [docs/github-marketplace-wrapper-decision.md](github-marketplace-wrapper-decision.md)。当前决策是继续保持单一产品仓库,暂不创建单独 listing wrapper。
|
|
256
|
+
|
|
234
257
|
## 项目配置
|
|
235
258
|
|
|
236
259
|
在仓库根目录添加 `.ai-saas-guard.json` 可以调整规则:
|
|
@@ -297,7 +320,7 @@ jobs:
|
|
|
297
320
|
- hosted service runtime:`ai-saas-guard/hosted/service` 导出 `createHostedServiceRuntime`,把签名 webhook intake、幂等 queue upsert、read-only worker 编排、compact report 存储、Check Run 发布 adapter 和 worker cleanup 串成可测试的服务核心;它本身不部署公开 hosted 环境
|
|
298
321
|
- GitHub App deployment planner:`ai-saas-guard/hosted/github-app` 导出 `planHostedGitHubAppDeployment`,生成 first slice 最小权限 manifest,并在 release gate、公开 HTTPS URL、container digest、secret 引用、原始 secret 输入、permission 或 event 不安全时阻止创建
|
|
299
322
|
- Hosted production adapter layer:`ai-saas-guard/hosted/production-adapters` 导出 `createHostedGitHubAppJwt`、`planHostedGitHubInstallationTokenRequest` 和 `planHostedProductionWorkerExecution`,用于 GitHub App RS256 JWT、selected-repository installation token 请求规划、worker/check-run 分离 token scope、固定只读 worker 命令、timeout/output 预算、compact JSON-only 输出,以及 success/failure/timeout/cancellation 的 cleanup 规划;它本身仍然不部署公开 hosted 服务
|
|
300
|
-
- Hosted Node/container app skeleton:`ai-saas-guard/hosted/app` 导出 `createHostedHttpApp`、`createInMemoryHostedAppPlatform` 和 `planHostedNodeContainerDeployment`,提供安全 `/healthz`、签名 `/github/webhook` ingress、单 job worker tick、测试用 in-memory provider adapters
|
|
323
|
+
- Hosted Node/container app skeleton:`ai-saas-guard/hosted/app` 导出 `createHostedHttpApp`、`createInMemoryHostedAppPlatform`、`createHostedNodeCheckoutAppPlatform` 和 `planHostedNodeContainerDeployment`,提供安全 `/healthz`、签名 `/github/webhook` ingress、单 job worker tick、测试用 in-memory provider adapters、真实 read-only checkout worker 组合入口、可见 timeout/output 安全预算,以及 secret manager、queue、compact report store、worker sandbox、GitHub Checks publisher 的部署引用校验;它本身仍然不部署或暴露公开 hosted 服务
|
|
301
324
|
- Hosted staging deployment planner:`ai-saas-guard/hosted/staging` 导出 `planHostedProviderBinding`、`planHostedStagingDeployment` 和 `planHostedGitHubAppPromotion`,把真实 provider 引用、Node/container deployment plan、hosted operational release-gate evidence 和 GitHub App deployment planning 组合起来;缺少 queue、store、worker sandbox、Check Run publisher、logs、metrics、rollback 或 incident-response 引用时,会阻止 staging exposure 和 production promotion;它本身仍然不会调用云平台、创建 GitHub App 或暴露公开 hosted 服务
|
|
302
325
|
- Hosted staging harness:`ai-saas-guard/hosted/staging-harness` 导出 `createFileBackedHostedStagingHarness` 和 `createHostedStagingHarnessEvidence`,可以在本地用 file-backed queue、compact report、Check Run request 和 worker sandbox 跑通签名 webhook replay、worker tick 和 cleanup 校验;它只是 staging 演练工具,不会调用云平台、创建 GitHub App、写真实 Check Run 或暴露公开 hosted 服务
|
|
303
326
|
- Cloudflare hosted ingress:`hosted/cloudflare-worker` 已部署到 `https://ai-saas-guard-hosted.zr9959.workers.dev`,提供 `/healthz`、`/github/app/manifest-callback` 和签名 `/github/webhook` intake;Worker 已具备 compact pull request identity、file/category risk signal 和 Check Run metadata 路径;staging GitHub App ID 为 `3834787`,installation ID 为 `135085075`;真实 GitHub App webhook delivery 和 Check Run smoke 已通过;完整 source checkout worker deployment、monitoring、rollback 和 incident-response evidence 仍需要通过 hosted operational release gate
|
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.28.
|
|
5
|
+
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.28.1` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
|
|
6
6
|
|
|
7
7
|
## PR Summary
|
|
8
8
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# GitHub Marketplace Wrapper Decision
|
|
2
|
+
|
|
3
|
+
Last checked: 2026-05-24
|
|
4
|
+
|
|
5
|
+
Decision: do not create a separate Marketplace wrapper repository now.
|
|
6
|
+
|
|
7
|
+
`ai-saas-guard` stays a single product: a local-first CLI, GitHub Action wrapper, docs, examples, and hosted-design contracts in this repository. The Action remains usable through `zr9959/ai-saas-guard@v0` and fixed release tags.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
GitHub's current Action Marketplace docs say Marketplace actions are published from a public repository that has a single action metadata file at the root and must not contain workflow files. This repository intentionally contains the CLI source, tests, docs, release workflows, hosted contracts, and project governance files. Reshaping it only to satisfy Marketplace listing constraints would weaken the repository boundary and make normal release quality checks harder to keep visible.
|
|
12
|
+
|
|
13
|
+
The current `action.yml` remains useful without a Marketplace listing:
|
|
14
|
+
|
|
15
|
+
- users can copy the README workflow and use `zr9959/ai-saas-guard@v0`
|
|
16
|
+
- npm remains the main install path for local use
|
|
17
|
+
- release tags and signed provenance assets already support controlled upgrades
|
|
18
|
+
- all rules, docs, and tests stay in the same product repo
|
|
19
|
+
|
|
20
|
+
## Wrapper Repo Option
|
|
21
|
+
|
|
22
|
+
A thin Marketplace wrapper can be revisited later if Marketplace search becomes a clear distribution channel. If that happens, the wrapper should stay minimal:
|
|
23
|
+
|
|
24
|
+
- only the action metadata, wrapper code, and Marketplace README needed for the listing
|
|
25
|
+
- no scanner logic fork
|
|
26
|
+
- no separate product positioning
|
|
27
|
+
- every release points back to this repository and the npm package
|
|
28
|
+
- no hidden workflow that changes the local-first privacy promise
|
|
29
|
+
|
|
30
|
+
## Revisit Criteria
|
|
31
|
+
|
|
32
|
+
Revisit only after at least one of these is true:
|
|
33
|
+
|
|
34
|
+
- multiple external users ask specifically for a GitHub Marketplace listing
|
|
35
|
+
- the Action API has stayed stable across several releases
|
|
36
|
+
- README, npm, and GitHub discovery are no longer enough for the intended launch-readiness audience
|
|
37
|
+
- the wrapper can be maintained without duplicating scanner rules, docs, release gates, or security boundaries
|
|
38
|
+
|
|
39
|
+
## References
|
|
40
|
+
|
|
41
|
+
- GitHub Docs: https://docs.github.com/en/actions/how-tos/create-and-publish-actions/publish-in-github-marketplace
|
|
42
|
+
- GitHub Action metadata syntax: https://docs.github.com/en/enterprise-cloud@latest/actions/reference/workflows-and-actions/metadata-syntax
|
|
@@ -21,6 +21,7 @@ The package exports `ai-saas-guard/hosted/app` with:
|
|
|
21
21
|
|
|
22
22
|
- `createHostedHttpApp`
|
|
23
23
|
- `createInMemoryHostedAppPlatform`
|
|
24
|
+
- `createHostedNodeCheckoutAppPlatform`
|
|
24
25
|
- `planHostedNodeContainerDeployment`
|
|
25
26
|
|
|
26
27
|
The staging deployment planner in [hosted-staging-deployment.md](hosted-staging-deployment.md) composes this Node/container deployment plan with real provider references, hosted operational release-gate evidence, and GitHub App promotion gates.
|
|
@@ -78,6 +79,18 @@ These adapters are not production storage. Real providers must wire the same bou
|
|
|
78
79
|
- read-only worker sandbox
|
|
79
80
|
- GitHub Checks API publisher
|
|
80
81
|
|
|
82
|
+
`createHostedNodeCheckoutAppPlatform` composes the same HTTP app and service runtime with the concrete read-only checkout worker from `ai-saas-guard/hosted/worker`. It is still adapter-driven: deployments must provide a runtime installation-token provider, durable queue/store, worker sandbox, and Check Run publisher. The helper exposes a safe `workerSafety` summary so deployers can verify the runtime boundary without logging private paths or tokens:
|
|
83
|
+
|
|
84
|
+
- command source: trusted runtime plan
|
|
85
|
+
- timeout capped at 600 seconds
|
|
86
|
+
- output capped at 1 MiB
|
|
87
|
+
- shell disabled
|
|
88
|
+
- CLI network access disabled
|
|
89
|
+
- read-only write mode
|
|
90
|
+
- compact JSON-only output
|
|
91
|
+
- checkout cleanup required
|
|
92
|
+
- no checkout path, source, diff, secret, or customer payload persistence
|
|
93
|
+
|
|
81
94
|
## Deployment Plan
|
|
82
95
|
|
|
83
96
|
`planHostedNodeContainerDeployment` validates the provider-facing deployment shape.
|
|
@@ -121,6 +134,6 @@ It does not return:
|
|
|
121
134
|
|
|
122
135
|
## Current Status
|
|
123
136
|
|
|
124
|
-
The repository can now instantiate a Node/container hosted app skeleton, route signed webhooks into the hosted service runtime, process one worker tick through adapters, and validate provider adapter references before deployment.
|
|
137
|
+
The repository can now instantiate a Node/container hosted app skeleton, route signed webhooks into the hosted service runtime, process one worker tick through adapters, compose the real read-only checkout scan runner behind a token-provider boundary, expose clamped worker safety budgets, and validate provider adapter references before deployment.
|
|
125
138
|
|
|
126
139
|
A public hosted environment still requires actual platform infrastructure, a public HTTPS webhook URL, platform secrets, durable queue/storage, worker sandboxing, GitHub Checks API credentials at runtime, monitoring, rollback, incident-response evidence, and the hosted operational release gate. Use [hosted-staging-deployment.md](hosted-staging-deployment.md) to plan and block staging exposure until those provider references and evidence exist.
|
package/docs/npm-publishing.md
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
## Current State
|
|
6
6
|
|
|
7
7
|
- Package name: `ai-saas-guard`
|
|
8
|
-
- Current published version: `0.
|
|
8
|
+
- Current published version: `0.29.0`
|
|
9
9
|
- Next source candidate: none
|
|
10
10
|
- npm registry state: published at <https://www.npmjs.com/package/ai-saas-guard>
|
|
11
11
|
- First npm-published version: `0.1.1`
|
|
12
|
-
- GitHub Release: `v0.
|
|
12
|
+
- GitHub Release: `v0.29.0`
|
|
13
13
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
14
14
|
- Trusted Publisher: GitHub Actions, `zr9959/ai-saas-guard`, workflow `npm-publish.yml`, allowed action `npm publish`
|
|
15
15
|
- Long-lived npm publish token: not required
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
Use GitHub Actions with npm Trusted Publisher/OIDC:
|
|
20
20
|
|
|
21
|
-
1. Create and review a release tag such as `v0.
|
|
21
|
+
1. Create and review a release tag such as `v0.29.0`.
|
|
22
22
|
2. Publish from the GitHub Release or run the `Publish npm` workflow manually with `ref` set to that tag.
|
|
23
23
|
3. Keep `permissions.id-token: write` in the workflow so npm can exchange the GitHub Actions OIDC identity for a short-lived publish credential.
|
|
24
24
|
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
|
@@ -161,7 +161,7 @@ OpenSSF Best Practices:
|
|
|
161
161
|
Publishing:
|
|
162
162
|
|
|
163
163
|
- npm package: `ai-saas-guard`
|
|
164
|
-
- Current published release line: `v0.28.
|
|
164
|
+
- Current published release line: `v0.28.1` pending this branch release
|
|
165
165
|
- Next source candidate: none
|
|
166
166
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
167
167
|
- Trusted Publisher: GitHub Actions for `zr9959/ai-saas-guard`, workflow `npm-publish.yml`
|
package/docs/rules.md
CHANGED
|
@@ -87,6 +87,8 @@ Prefer fixing risky code over suppressing findings. When a finding is a reviewed
|
|
|
87
87
|
| --- | --- | --- |
|
|
88
88
|
| `api.route.missing-rate-limit` | medium | Login, checkout, upload, AI, and webhook routes are common abuse targets. |
|
|
89
89
|
| `api.route.auth-without-ownership` | high | Login checks do not prove resource ownership checks. |
|
|
90
|
+
| `auth.clerk.unsafe-metadata` | high | Clerk unsafe metadata is user-writable and should not hold roles, plans, tenant membership, or entitlements. |
|
|
91
|
+
| `data.prisma.tenant-scope-missing` | high | Authenticated Prisma reads or mutations on tenant-like resources need tenant, owner, organization, or workspace predicates. |
|
|
90
92
|
| `deploy.next.static-export-api-risk` | medium | Static export can conflict with runtime API assumptions. |
|
|
91
93
|
| `deploy.edge-runtime-node-api` | medium | Edge runtime can break Node-only dependencies. |
|
|
92
94
|
| `deploy.env.example-missing` | low | Missing env docs cause local-success, production-failure deploys. |
|
|
@@ -96,6 +98,7 @@ Prefer fixing risky code over suppressing findings. When a finding is a reviewed
|
|
|
96
98
|
| `deploy.next.image-cost-risk` | medium | Broad remote image patterns or user-controlled image sources can amplify deploy cost and trust risk. |
|
|
97
99
|
| `deploy.next.request-amplification` | low | High-cardinality dynamic route prefetching can create unexpected production request volume. |
|
|
98
100
|
| `deploy.observability.missing-request-id` | low | Billing, webhook, and tenant incidents are hard to debug without traceable request IDs. |
|
|
101
|
+
| `deploy.vercel.cron-missing-guard` | medium | Scheduled billing, tenant, or cleanup jobs need a secret guard, idempotency, and request tracing before launch. |
|
|
99
102
|
|
|
100
103
|
## MCP
|
|
101
104
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Sample Launch Report
|
|
2
|
+
|
|
3
|
+
This is a synthetic, public-safe example of the kind of review queue `ai-saas-guard` produces. Paths, snippets, and checks are intentionally small so a founder or reviewer can understand the output before running the tool.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
Launch Gate: review before launch
|
|
7
|
+
6 findings: 3 high, 3 medium
|
|
8
|
+
|
|
9
|
+
HIGH stripe.webhook.missing-signature
|
|
10
|
+
Rule: stripe.webhook.missing-signature
|
|
11
|
+
File: app/api/stripe/webhook/route.ts:12
|
|
12
|
+
Why: Billing access can be granted from a webhook path that does not verify Stripe signatures.
|
|
13
|
+
Verify: Replay a webhook with an invalid signature and confirm the route rejects it.
|
|
14
|
+
Fix direction: Read the raw body, call stripe.webhooks.constructEvent, and keep entitlement changes idempotent.
|
|
15
|
+
|
|
16
|
+
HIGH auth.clerk.unsafe-metadata
|
|
17
|
+
Rule: auth.clerk.unsafe-metadata
|
|
18
|
+
File: app/api/auth/profile/route.ts:8
|
|
19
|
+
Why: Clerk unsafe metadata can be changed from the client side, so roles, paid plans, tenant membership, or entitlements stored there can become authorization input by accident.
|
|
20
|
+
Verify: Try changing the same metadata as a normal signed-in user and confirm it cannot grant admin, paid plan, tenant, workspace, or entitlement access.
|
|
21
|
+
Fix direction: Store authorization and billing state in server-controlled Clerk private/public metadata or your database.
|
|
22
|
+
|
|
23
|
+
HIGH data.prisma.tenant-scope-missing
|
|
24
|
+
Rule: data.prisma.tenant-scope-missing
|
|
25
|
+
File: app/api/projects/[projectId]/route.ts:9
|
|
26
|
+
Why: A route can authenticate the caller but still read or mutate another tenant's resource when Prisma queries only scope by a guessed resource ID.
|
|
27
|
+
Verify: Create this resource as Tenant/User A, then attempt the same update with Tenant/User B.
|
|
28
|
+
Fix direction: Add tenant, organization, workspace, owner, user, or membership predicates to the Prisma where clause.
|
|
29
|
+
|
|
30
|
+
MEDIUM supabase.rls.tenant-predicate-missing
|
|
31
|
+
Rule: supabase.rls.tenant-predicate-missing
|
|
32
|
+
File: supabase/migrations/20260524_projects.sql:22
|
|
33
|
+
Why: Multi-tenant tables need tenant, workspace, organization, owner, or membership predicates.
|
|
34
|
+
Verify: Sign in as user A and user B; confirm neither can SELECT, INSERT, UPDATE, or DELETE the other's rows.
|
|
35
|
+
Fix direction: Add tenant or membership predicates and rerun a two-account staging check.
|
|
36
|
+
|
|
37
|
+
MEDIUM deploy.vercel.cron-missing-guard
|
|
38
|
+
Rule: deploy.vercel.cron-missing-guard
|
|
39
|
+
File: app/api/cron/reconcile-billing/route.ts:3
|
|
40
|
+
Why: Scheduled billing, tenant, or cleanup jobs need a secret guard, idempotency, and request tracing before launch.
|
|
41
|
+
Verify: Call the cron route without the expected cron secret and with a repeated request ID; confirm unauthorized calls fail and repeated runs do not duplicate state changes.
|
|
42
|
+
Fix direction: Check a server-only cron secret, record an idempotency key or run lock, and log a request/trace ID before stateful cron work starts.
|
|
43
|
+
|
|
44
|
+
MEDIUM silent-success.swallowed-error
|
|
45
|
+
Rule: silent-success.swallowed-error
|
|
46
|
+
File: app/api/billing/checkout/route.ts:31
|
|
47
|
+
Why: Swallowed provider, auth, billing, or data errors can make a launch path look successful when it failed.
|
|
48
|
+
Verify: Force the upstream provider call to fail and confirm the route returns an error or disclosed degraded mode.
|
|
49
|
+
Fix direction: Log the failure, return an explicit error status, and avoid granting access after the failed dependency.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How To Read It
|
|
53
|
+
|
|
54
|
+
Start with the highest severity findings that touch trust-boundary code: auth, billing, tenant data, webhooks, and scheduled jobs. Each finding should give you enough to answer three launch questions:
|
|
55
|
+
|
|
56
|
+
- What file should I inspect first?
|
|
57
|
+
- Why could this fail for real users?
|
|
58
|
+
- What manual proof shows the path fails closed?
|
|
59
|
+
|
|
60
|
+
The report is a focused review queue. It does not replace your two-account authorization tests, Stripe webhook replay, deploy-preview checks, or human review.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-saas-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.29.0",
|
|
4
|
+
"description": "Local-first CLI that catches launch blockers in AI-built Next.js/Supabase/Stripe SaaS apps.",
|
|
5
5
|
"readmeFilename": "README.md",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"homepage": "https://github.com/zr9959/ai-saas-guard#readme",
|
|
@@ -14,14 +14,25 @@
|
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"ai",
|
|
17
|
+
"ai-code-review",
|
|
17
18
|
"saas",
|
|
18
19
|
"security",
|
|
19
20
|
"launch",
|
|
20
21
|
"preflight",
|
|
22
|
+
"launch-readiness",
|
|
23
|
+
"nextjs",
|
|
24
|
+
"vercel",
|
|
21
25
|
"supabase",
|
|
26
|
+
"supabase-rls",
|
|
22
27
|
"stripe",
|
|
28
|
+
"stripe-webhooks",
|
|
23
29
|
"mcp",
|
|
30
|
+
"mcp-security",
|
|
24
31
|
"github-action",
|
|
32
|
+
"github-actions",
|
|
33
|
+
"static-analysis",
|
|
34
|
+
"devsecops",
|
|
35
|
+
"local-first",
|
|
25
36
|
"cli"
|
|
26
37
|
],
|
|
27
38
|
"main": "./dist/index.js",
|