ai-saas-guard 0.38.0 → 0.40.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 +13 -7
- package/dist/hosted/contracts.js +80 -4
- package/docs/README.zh-CN.md +14 -7
- package/docs/hosted-install-privacy.md +68 -0
- package/docs/hosted-operational-release-gate.md +15 -0
- package/docs/hosted-operations-evidence.md +19 -0
- package/docs/npm-publishing.md +3 -3
- package/hosted/cloudflare-worker/README.md +22 -1
- package/hosted/cloudflare-worker/src/index.js +92 -4
- package/hosted/cloudflare-worker/wrangler.jsonc +1 -1
- package/package.json +2 -1
- package/scripts/hosted-pr-smoke.mjs +314 -0
package/README.md
CHANGED
|
@@ -170,7 +170,9 @@ For a concise comparison with Semgrep, zizmor, OpenSSF Scorecard, Snyk, and GitH
|
|
|
170
170
|
| --- | --- | --- |
|
|
171
171
|
| Local CLI | Private code, first local launch review, founder or reviewer workflow | Published on npm; local-first, read-only, no code upload, no LLM calls |
|
|
172
172
|
| GitHub Action | CI review queue, SARIF upload, PR summary artifacts, controlled fail thresholds | Available through `zr9959/ai-saas-guard@v0` and fixed version tags |
|
|
173
|
-
| Hosted GitHub App |
|
|
173
|
+
| Hosted GitHub App | Selected-repository Check Run for AI-heavy SaaS PRs | Limited trial gate with [install/privacy notes](docs/hosted-install-privacy.md), public install-info, compact Check Runs, and cleanup handling; not the complete hosted SaaS, not a public hosted scanner |
|
|
174
|
+
|
|
175
|
+
Choose the path by trust boundary: use the **Local CLI** when code must stay on your machine, the **GitHub Action** when you want repeatable CI evidence, and the **Hosted GitHub App** when reviewers need an automatic Check Run that groups auth, billing, tenant-data, deploy, and test-risk areas before merge.
|
|
174
176
|
|
|
175
177
|
## Quick Start
|
|
176
178
|
|
|
@@ -233,16 +235,16 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
|
|
|
233
235
|
| Area | Status |
|
|
234
236
|
| --- | --- |
|
|
235
237
|
| Public GitHub repository | Available |
|
|
236
|
-
| npm CLI | `ai-saas-guard@0.
|
|
237
|
-
| GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.
|
|
238
|
+
| npm CLI | `ai-saas-guard@0.40.0` |
|
|
239
|
+
| GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.40.0` |
|
|
238
240
|
| Outputs | Launch decision queue, short summary, terminal, JSON, SARIF, and PR-focused markdown |
|
|
239
241
|
| Project config | `.ai-saas-guard.json` rule toggles, severity overrides, suppressions, and fail thresholds |
|
|
240
242
|
| Privacy model | Local-first, read-only scan commands, no LLM calls, no code upload |
|
|
241
|
-
| Versioned Action tags | `v0.
|
|
242
|
-
| Current release | `0.
|
|
243
|
+
| Versioned Action tags | `v0.40.0`, `v0` |
|
|
244
|
+
| Current release | `0.40.0` groups hosted Check Run output by launch-risk area, adds machine-readable hosted smoke evidence, clarifies CLI/Action/Hosted path selection, and documents the next source-checkout boundary |
|
|
243
245
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
244
246
|
| Repository trust hardening | Strict branch protection, Dependabot, CodeQL, fast-check fuzzing, signed release provenance assets, private vulnerability reporting, secret scanning, and push protection |
|
|
245
|
-
| 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 |
|
|
247
|
+
| Cloudflare hosted ingress | Deployed at `https://ai-saas-guard-hosted.zr9959.workers.dev`; public install/privacy notes are in [docs/hosted-install-privacy.md](docs/hosted-install-privacy.md); signed GitHub App webhook delivery and compact Check Run smoke now pass in staging |
|
|
246
248
|
| Hosted GitHub App staging | Private App `ai-saas-guard-hosted` (`3834787`) installed on `zr9959/ai-saas-guard`; hosted operations evidence is in [docs/hosted-operations-evidence.md](docs/hosted-operations-evidence.md) |
|
|
247
249
|
| OpenSSF Best Practices | Passing badge, project `12955`; `.bestpractices.json` remains the conservative evidence record |
|
|
248
250
|
| Previous roadmap | v0.36.0 plan is tracked in [docs/v0.36-roadmap.md](docs/v0.36-roadmap.md) |
|
|
@@ -365,6 +367,10 @@ Deployed worker staging evidence is documented in [docs/hosted-deployed-worker-s
|
|
|
365
367
|
|
|
366
368
|
The first live hosted ingress is deployed on Cloudflare Workers at `https://ai-saas-guard-hosted.zr9959.workers.dev` and documented in [hosted/cloudflare-worker/README.md](hosted/cloudflare-worker/README.md). It exposes `/healthz`, `/github/app/install-info`, `/github/app/manifest-callback`, and signed `/github/webhook` intake backed by Cloudflare KV. A private staging GitHub App, `ai-saas-guard-hosted`, is installed on `zr9959/ai-saas-guard` with selected-repository access and the first-slice permission contract. The Worker verifies signatures, stores compact pull request identity records, exchanges a scoped installation token, fetches PR file metadata from GitHub, classifies PR-risk hotspots, and publishes a bounded selected-repository hosted check with a review queue and manual proof prompt. Signed installation deletion and repository removal events delete matching compact records. Current deployed evidence is tracked in [docs/hosted-operations-evidence.md](docs/hosted-operations-evidence.md): health, signed webhook delivery, compact KV records, cleanup, and Check Run publication pass in staging. The Cloudflare Worker still does not run a full source checkout scan worker or store raw webhook payloads, PR title/body text, raw diffs, source, secrets, checkout paths, or installation tokens.
|
|
367
369
|
|
|
370
|
+
The next hosted source-checkout step is intentionally narrow: deploy the existing read-only checkout worker behind the same selected-repository identity, keep the fixed `pr-risk --json` command, write only compact findings to the Check Run, and require deployed cleanup/log-boundary/rollback evidence before broader trial use.
|
|
371
|
+
|
|
372
|
+
Hosted install and privacy details are summarized in [docs/hosted-install-privacy.md](docs/hosted-install-privacy.md): selected-repository permissions, supported events, Check Run data boundaries, uninstall cleanup, and why the local CLI remains the private/offline path.
|
|
373
|
+
|
|
368
374
|
The hosted operational release gate is documented in [docs/hosted-operational-release-gate.md](docs/hosted-operational-release-gate.md). It defines the hosted-specific CI, replay, queue, worker cleanup, privacy, monitoring, rollback, and incident-response evidence required before any hosted environment is exposed to users. The pure gate evaluator exported from `ai-saas-guard/hosted/contracts` blocks hosted exposure unless every P0 evidence item is fresh, a container digest is recorded, and release notes avoid pentest, certification, and full-audit claims.
|
|
369
375
|
|
|
370
376
|
Hosted uninstall and data deletion behavior is documented in [docs/hosted-uninstall-data-deletion.md](docs/hosted-uninstall-data-deletion.md). It defines repository removal, full app uninstall, compact report deletion, queue cancellation, audit record retention, repeated cleanup, and user-facing deletion wording.
|
|
@@ -417,7 +423,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
417
423
|
|
|
418
424
|
## GitHub Action
|
|
419
425
|
|
|
420
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
426
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.40.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
421
427
|
|
|
422
428
|
```yaml
|
|
423
429
|
name: ai-saas-guard
|
package/dist/hosted/contracts.js
CHANGED
|
@@ -485,7 +485,12 @@ export function createHostedCheckRunSummary(input) {
|
|
|
485
485
|
conclusion,
|
|
486
486
|
output: {
|
|
487
487
|
title: formatCheckRunTitle(totalFindings, conclusion, input.failOnSeverity),
|
|
488
|
-
summary:
|
|
488
|
+
summary: [
|
|
489
|
+
`Launch-risk gate: ${launchGate}. Launch gate: ${launchGate}.`,
|
|
490
|
+
"Review task: inspect the files below before merge.",
|
|
491
|
+
"Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
492
|
+
"Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification."
|
|
493
|
+
].join(" "),
|
|
489
494
|
text: truncateMarkdown(formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate), input.maxMarkdownChars)
|
|
490
495
|
},
|
|
491
496
|
annotations: report.evidence.slice(0, MAX_CHECK_RUN_ANNOTATIONS).map((finding) => {
|
|
@@ -1137,6 +1142,7 @@ function formatCheckRunTitle(totalFindings, conclusion, failOnSeverity) {
|
|
|
1137
1142
|
}
|
|
1138
1143
|
function formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate) {
|
|
1139
1144
|
const categories = getHostedCheckRunCategories(report);
|
|
1145
|
+
const riskAreas = getHostedCheckRunRiskAreas(report);
|
|
1140
1146
|
const filesToReview = getHostedCheckRunFiles(report);
|
|
1141
1147
|
const findingLines = report.evidence.length === 0
|
|
1142
1148
|
? ["No findings in the compact hosted report."]
|
|
@@ -1148,7 +1154,9 @@ function formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate)
|
|
|
1148
1154
|
return [
|
|
1149
1155
|
"### AI SaaS Guard Launch-risk gate",
|
|
1150
1156
|
"",
|
|
1151
|
-
"Review
|
|
1157
|
+
"Review task: inspect the files below before merge.",
|
|
1158
|
+
"Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
1159
|
+
"Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
|
|
1152
1160
|
"",
|
|
1153
1161
|
`Launch gate: ${launchGate}`,
|
|
1154
1162
|
`Conclusion: ${conclusion}`,
|
|
@@ -1159,9 +1167,14 @@ function formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate)
|
|
|
1159
1167
|
...(categories.length === 0 ? ["- None"] : categories.map((category) => `- ${category}`)),
|
|
1160
1168
|
"",
|
|
1161
1169
|
"Verification steps:",
|
|
1162
|
-
"-
|
|
1170
|
+
"- Inspect the listed files locally before release or merge.",
|
|
1163
1171
|
"- Reproduce locally with the CLI command above.",
|
|
1164
|
-
"-
|
|
1172
|
+
"- Prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
1173
|
+
"",
|
|
1174
|
+
"Risk areas:",
|
|
1175
|
+
...(riskAreas.length === 0
|
|
1176
|
+
? ["- None"]
|
|
1177
|
+
: riskAreas.map((area) => `- ${area.name}: ${area.count} finding(s). Proof: ${area.proof}`)),
|
|
1165
1178
|
"",
|
|
1166
1179
|
"Launch decision queue:",
|
|
1167
1180
|
"- Can a real user get access they should not have?",
|
|
@@ -1219,6 +1232,69 @@ function getHostedCheckRunCategories(report) {
|
|
|
1219
1232
|
const categories = report.evidence.map((finding) => categoryForRuleId(finding.ruleId));
|
|
1220
1233
|
return [...new Set(categories)];
|
|
1221
1234
|
}
|
|
1235
|
+
function getHostedCheckRunRiskAreas(report) {
|
|
1236
|
+
const counts = new Map();
|
|
1237
|
+
for (const finding of report.evidence) {
|
|
1238
|
+
const area = riskAreaForRuleId(finding.ruleId);
|
|
1239
|
+
counts.set(area.key, {
|
|
1240
|
+
...area,
|
|
1241
|
+
count: (counts.get(area.key)?.count ?? 0) + 1
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
return [...counts.values()].sort((a, b) => {
|
|
1245
|
+
if (b.weight !== a.weight)
|
|
1246
|
+
return b.weight - a.weight;
|
|
1247
|
+
return b.count - a.count;
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
function riskAreaForRuleId(ruleId) {
|
|
1251
|
+
if (/^(auth|api)\./.test(ruleId)) {
|
|
1252
|
+
return {
|
|
1253
|
+
key: "auth",
|
|
1254
|
+
name: "Auth and session",
|
|
1255
|
+
proof: "use two accounts and confirm access, session, and ownership checks fail closed",
|
|
1256
|
+
weight: 50
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
if (/^stripe\./.test(ruleId)) {
|
|
1260
|
+
return {
|
|
1261
|
+
key: "billing",
|
|
1262
|
+
name: "Billing and entitlement",
|
|
1263
|
+
proof: "force unsigned, duplicate, failed, and canceled billing events before granting access",
|
|
1264
|
+
weight: 50
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
if (/^(supabase|data)\./.test(ruleId)) {
|
|
1268
|
+
return {
|
|
1269
|
+
key: "data",
|
|
1270
|
+
name: "Tenant data access",
|
|
1271
|
+
proof: "run cross-tenant SELECT, INSERT, UPDATE, and DELETE checks with user A and user B",
|
|
1272
|
+
weight: 45
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (/^(deploy|secrets|mcp|actions)\./.test(ruleId)) {
|
|
1276
|
+
return {
|
|
1277
|
+
key: "deploy",
|
|
1278
|
+
name: "Deploy, secrets, tools, and permissions",
|
|
1279
|
+
proof: "confirm production env, workflow permissions, and tool scopes are least privilege",
|
|
1280
|
+
weight: 35
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
if (/^silent-success\.|weakened-test|test/i.test(ruleId)) {
|
|
1284
|
+
return {
|
|
1285
|
+
key: "tests",
|
|
1286
|
+
name: "Tests and silent success",
|
|
1287
|
+
proof: "make the upstream path fail and confirm tests catch an error instead of fake success",
|
|
1288
|
+
weight: 40
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
key: "pr",
|
|
1293
|
+
name: "PR trust boundary",
|
|
1294
|
+
proof: "explain the trust-boundary decision and prove the changed path fails closed",
|
|
1295
|
+
weight: 30
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1222
1298
|
function categoryForRuleId(ruleId) {
|
|
1223
1299
|
const prefix = ruleId.split(".")[0] ?? "review";
|
|
1224
1300
|
const categoryNames = {
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -165,7 +165,9 @@ Next steps
|
|
|
165
165
|
| --- | --- | --- |
|
|
166
166
|
| 本地 CLI | 私有代码、本机首次上线 review、founder 或 reviewer 自查 | 已发布到 npm;本地优先、只读、不上传代码、不调用 LLM |
|
|
167
167
|
| GitHub Action | CI 里的 review queue、SARIF、PR summary artifact、可控 fail threshold | 可通过 `zr9959/ai-saas-guard@v0` 或固定版本标签使用 |
|
|
168
|
-
| Hosted GitHub App |
|
|
168
|
+
| Hosted GitHub App | 面向 AI 大 PR 的 selected-repository Check Run | 当前是 staging ingress,已有[安装和隐私说明](hosted-install-privacy.md)、公开 install-info、compact Check Run 和 cleanup handling;不是完整 hosted SaaS,也不是公开 hosted scanner |
|
|
169
|
+
|
|
170
|
+
按信任边界选择:代码必须留在本机时用 **Local CLI**;需要 CI 里的可重复证据时用 **GitHub Action**;reviewer 需要自动 Check Run 时用 **Hosted GitHub App**,它会把 auth、billing、tenant data、deploy 和 test-risk areas 分组后放到 PR 里。
|
|
169
171
|
|
|
170
172
|
## 快速开始
|
|
171
173
|
|
|
@@ -211,21 +213,21 @@ node dist/cli.js scan --root /path/to/your-saas
|
|
|
211
213
|
|
|
212
214
|
这个仓库是公开 GitHub 仓库。
|
|
213
215
|
|
|
214
|
-
CLI 已发布到 npm:`ai-saas-guard@0.
|
|
216
|
+
CLI 已发布到 npm:`ai-saas-guard@0.40.0`。GitHub Action 支持 `v0` 浮动标签,也支持固定版本标签,例如 `v0.40.0`。
|
|
215
217
|
|
|
216
218
|
| 模块 | 状态 |
|
|
217
219
|
| --- | --- |
|
|
218
220
|
| 公开 GitHub 仓库 | 已可用 |
|
|
219
|
-
| npm CLI | `ai-saas-guard@0.
|
|
220
|
-
| GitHub Action | `zr9959/ai-saas-guard@v0` 或固定标签 `v0.
|
|
221
|
+
| npm CLI | `ai-saas-guard@0.40.0` |
|
|
222
|
+
| GitHub Action | `zr9959/ai-saas-guard@v0` 或固定标签 `v0.40.0` |
|
|
221
223
|
| 输出格式 | 上线决策队列、短 summary、Terminal、JSON、SARIF 和 PR markdown |
|
|
222
224
|
| 项目配置 | `.ai-saas-guard.json` 支持规则开关、severity 覆盖、suppressions 和 fail threshold |
|
|
223
225
|
| 隐私模型 | 本地优先、只读扫描、不调用 LLM、不上传代码 |
|
|
224
|
-
| 当前版本 | `0.
|
|
225
|
-
| Action 标签 | `v0.
|
|
226
|
+
| 当前版本 | `0.40.0` 按上线风险区域分组 hosted Check Run 输出,增加机器可读 hosted smoke evidence,讲清 CLI/Action/Hosted 三条路径选择,并记录下一步 source-checkout 边界 |
|
|
227
|
+
| Action 标签 | `v0.40.0`、`v0` |
|
|
226
228
|
| npm 发布 | GitHub Actions Trusted Publisher/OIDC,无需长期 npm token |
|
|
227
229
|
| 仓库可信度加固 | 严格 branch protection、Dependabot、CodeQL、fast-check fuzzing、signed release provenance assets、private vulnerability reporting、secret scanning 和 push protection |
|
|
228
|
-
| Cloudflare hosted ingress | 已部署到 `https://ai-saas-guard-hosted.zr9959.workers.dev
|
|
230
|
+
| Cloudflare hosted ingress | 已部署到 `https://ai-saas-guard-hosted.zr9959.workers.dev`;安装和隐私说明见 [hosted-install-privacy.md](hosted-install-privacy.md);提供 `/github/app/install-info`,签名 GitHub App webhook delivery、compact Check Run 和 installation cleanup staging smoke 已通过 |
|
|
229
231
|
| Hosted GitHub App staging | 私有 App `ai-saas-guard-hosted`(`3834787`)已安装到 `zr9959/ai-saas-guard`;hosted operations evidence 见 [docs/hosted-operations-evidence.md](hosted-operations-evidence.md) |
|
|
230
232
|
| OpenSSF Best Practices | 已获得 passing badge,项目 `12955`;`.bestpractices.json` 继续作为保守证据记录 |
|
|
231
233
|
| 上一版路线 | v0.36.0 计划见 [v0.36-roadmap.md](v0.36-roadmap.md) |
|
|
@@ -381,6 +383,10 @@ GitHub Marketplace wrapper 决策见 [docs/github-marketplace-wrapper-decision.m
|
|
|
381
383
|
|
|
382
384
|
当前仓库已经包含未来 Hosted GitHub App 的设计文档、纯契约测试、第一个真实 Cloudflare hosted ingress,以及 Node/container read-only checkout scan runner。私有 staging GitHub App `ai-saas-guard-hosted` 已安装到 `zr9959/ai-saas-guard`,Cloudflare 已配置所需的云端凭据绑定。Worker 代码已经能接收签名 webhook、写入 KV 队列、换取 scoped installation token、读取 GitHub PR file metadata、做 compact PR-risk classification,并发布有长度上限的 selected-repository Check Run summary;`/github/app/install-info` 会返回公开安全的安装说明、权限、事件、隐私边界和卸载说明。签名 installation deletion 和 repository removal 事件会删除匹配的 compact records。当前端到端 GitHub App webhook delivery smoke 已通过,证据记录在 [docs/hosted-operations-evidence.md](hosted-operations-evidence.md)。Cloudflare ingress 本身仍不是完整 source checkout scan worker。
|
|
383
385
|
|
|
386
|
+
下一步 hosted source checkout 仍然要保持窄边界:把现有 read-only checkout worker 放到同一个 selected-repository identity 后面,继续固定 `pr-risk --json` 命令,只把 compact findings 写入 Check Run,并在扩大 trial 前要求 deployed cleanup、log-boundary 和 rollback evidence。
|
|
387
|
+
|
|
388
|
+
Hosted 安装、权限和隐私边界见 [hosted-install-privacy.md](hosted-install-privacy.md):selected-repository 权限、支持的 GitHub 事件、Check Run 数据边界、卸载清理,以及为什么本地 CLI 仍然是私有/离线路径。
|
|
389
|
+
|
|
384
390
|
相关文档:
|
|
385
391
|
|
|
386
392
|
- [docs/github-app-design.md](github-app-design.md)
|
|
@@ -394,6 +400,7 @@ GitHub Marketplace wrapper 决策见 [docs/github-marketplace-wrapper-decision.m
|
|
|
394
400
|
- [docs/hosted-staging-harness.md](hosted-staging-harness.md)
|
|
395
401
|
- [docs/hosted-deployed-worker-staging.md](hosted-deployed-worker-staging.md)
|
|
396
402
|
- [docs/hosted-operational-release-gate.md](hosted-operational-release-gate.md)
|
|
403
|
+
- [docs/hosted-install-privacy.md](hosted-install-privacy.md)
|
|
397
404
|
- [docs/hosted-uninstall-data-deletion.md](hosted-uninstall-data-deletion.md)
|
|
398
405
|
- [docs/hosted-pricing-packaging.md](hosted-pricing-packaging.md)
|
|
399
406
|
- [docs/hosted-preimplementation-contracts.md](hosted-preimplementation-contracts.md)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Hosted Install And Privacy
|
|
2
|
+
|
|
3
|
+
`ai-saas-guard-hosted` is the limited hosted GitHub App path for teams that want a selected-repository Check Run instead of running only the local CLI.
|
|
4
|
+
|
|
5
|
+
Use the local CLI first when you need private, offline, no-account review:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx ai-saas-guard@latest pr-risk --root /path/to/your-saas --base origin/main
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Install information for the hosted App is available from the deployed Worker:
|
|
12
|
+
|
|
13
|
+
- public install info: `https://ai-saas-guard-hosted.zr9959.workers.dev/github/app/install-info`
|
|
14
|
+
- GitHub App install URL: `https://github.com/apps/ai-saas-guard-hosted/installations/new`
|
|
15
|
+
- staging App ID: `3834787`
|
|
16
|
+
|
|
17
|
+
## What It Solves
|
|
18
|
+
|
|
19
|
+
AI-built SaaS pull requests often change auth, billing, tenant data, deploy settings, or tests inside a large diff. The hosted App turns those trust-boundary changes into a compact GitHub Check Run so reviewers know what files to inspect before merge.
|
|
20
|
+
|
|
21
|
+
It is not an AI reviewer, pentest, full audit, or certification. It is a launch-risk review queue.
|
|
22
|
+
|
|
23
|
+
## Permissions
|
|
24
|
+
|
|
25
|
+
The first hosted slice uses selected-repository access only:
|
|
26
|
+
|
|
27
|
+
| Permission | Access | Why |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `checks` | write | Publish the bounded launch-risk Check Run |
|
|
30
|
+
| `contents` | read | Read PR file metadata for the selected repository |
|
|
31
|
+
| `pull_requests` | read | Identify the PR, base SHA, and head SHA |
|
|
32
|
+
| `metadata` | read | Required by GitHub Apps |
|
|
33
|
+
|
|
34
|
+
It does not request administration, Actions write, repository secrets, deployments, issues, or organization-wide access.
|
|
35
|
+
|
|
36
|
+
## Events
|
|
37
|
+
|
|
38
|
+
The hosted App listens for:
|
|
39
|
+
|
|
40
|
+
- `pull_request` for opened, reopened, synchronize, and ready-for-review events
|
|
41
|
+
- `installation` for full uninstall cleanup
|
|
42
|
+
- `installation_repositories` for selected repository removal cleanup
|
|
43
|
+
|
|
44
|
+
Unsupported, draft, unsigned, oversized, or malformed events are ignored or rejected before scan side effects.
|
|
45
|
+
|
|
46
|
+
## Privacy Boundary
|
|
47
|
+
|
|
48
|
+
The hosted Check Run and compact records are designed to avoid sensitive payloads:
|
|
49
|
+
|
|
50
|
+
- no raw source files
|
|
51
|
+
- no raw diffs
|
|
52
|
+
- no webhook payload bodies
|
|
53
|
+
- no PR title or body text
|
|
54
|
+
- no secrets
|
|
55
|
+
- no customer payloads
|
|
56
|
+
- no private checkout paths
|
|
57
|
+
- no installation tokens
|
|
58
|
+
- no model training and no LLM calls
|
|
59
|
+
|
|
60
|
+
The hosted Worker stores compact identity, file path, category, severity, and rule signals needed to publish a review queue. Local CLI use remains available without any hosted processing.
|
|
61
|
+
|
|
62
|
+
## Uninstall And Deletion
|
|
63
|
+
|
|
64
|
+
Repository removal and full App uninstall trigger cleanup for matching compact records and queued work. GitHub-owned Check Runs may remain in GitHub history, but hosted compact records are deleted according to the cleanup path described in [hosted-uninstall-data-deletion.md](hosted-uninstall-data-deletion.md).
|
|
65
|
+
|
|
66
|
+
## Current Trial Boundary
|
|
67
|
+
|
|
68
|
+
The deployed Cloudflare Worker currently handles signed webhook intake, scoped installation token exchange, PR file metadata classification, compact Check Run publication, and installation cleanup for staging. It is not yet the complete source checkout scan worker or public hosted SaaS.
|
|
@@ -21,6 +21,8 @@ Every hosted release must record:
|
|
|
21
21
|
- privacy and retention evidence
|
|
22
22
|
- monitoring and alerting checks
|
|
23
23
|
- manual rollback result
|
|
24
|
+
- real hosted PR smoke result, when a live GitHub App ingress is deployed
|
|
25
|
+
- staging KV cleanup result for `delivery:` and `scan:` smoke records
|
|
24
26
|
- incident response owner and escalation path
|
|
25
27
|
|
|
26
28
|
## P0 Gate Summary
|
|
@@ -35,6 +37,7 @@ Every hosted release must record:
|
|
|
35
37
|
8. Retention checks prove compact reports expire according to policy.
|
|
36
38
|
9. Monitoring and alerting checks cover ingress, queue depth, worker failures, check run failures, and cleanup failures.
|
|
37
39
|
10. Manual rollback is tested against the release candidate.
|
|
40
|
+
11. Any deployed GitHub App ingress passes a real temporary PR smoke with Check Run evidence and post-smoke branch, PR, and KV cleanup.
|
|
38
41
|
|
|
39
42
|
## Current Source Candidate Evidence Notes
|
|
40
43
|
|
|
@@ -58,6 +61,7 @@ Source-level evidence notes for this release candidate:
|
|
|
58
61
|
| `container_scan` | Container image scan has no unresolved high or critical runtime-layer findings | No hosted container image exists in the public package release | Not applicable to current non-hosted release; required before hosted exposure |
|
|
59
62
|
| `queue_worker_cleanup` | Queue dedupe, running cancellation, terminal cleanup, worker checkout deletion, and no long-running processes | Pure queue, worker, checkout, retention cleanup planner tests, staging harness success/failure cleanup probes, and deployed worker staging cleanup evidence helper | Passed for source candidate; deployed helper can record success/failure cleanup before exposure |
|
|
60
63
|
| `privacy_retention` | No raw source, raw diffs, secrets, customer payloads, private URLs, or full file contents; retention and uninstall cleanup are proven | Compact report, Check Run publication, retention/deletion cleanup, docs tests, `validateHostedLogBoundary` source-candidate log checks, and deployed log-boundary staging evidence | Passed for source candidate; deployed log sampling still required before exposure |
|
|
64
|
+
| `hosted_pr_smoke` | Deployed GitHub App ingress creates a temporary PR, publishes `ai-saas-guard PR risk`, closes the PR, deletes the branch, and clears staging `delivery:` / `scan:` KV records | `node scripts/hosted-pr-smoke.mjs --plan` plus `node scripts/hosted-pr-smoke.mjs` after deployment | Required for any release that changes the live hosted Worker or GitHub App wiring |
|
|
61
65
|
| `monitoring_alerting` | Ingress, queue depth, worker failures, Check Run failures, cleanup failures, retention failures, and credential rotation alerts | Required alert list remains in this document | Documented; must attach provider evidence before exposure |
|
|
62
66
|
| `manual_rollback` | Worker pause, previous artifact redeploy, queue resume, controlled ingress failure, and affected Check Run identification | Manual rollback procedure remains in this document | Documented; must execute against deployed artifact before exposure |
|
|
63
67
|
| `incident_response` | Owner, backup, credential rotation, queue pause, customer communication, status path, and privacy-safe evidence collection | Incident response checklist remains in this document | Documented; must name live owners before exposure |
|
|
@@ -79,6 +83,7 @@ node dist/cli.js scan --root . --sarif
|
|
|
79
83
|
node dist/cli.js pr-risk --root . --json
|
|
80
84
|
npm audit --audit-level=high --registry=https://registry.npmjs.org
|
|
81
85
|
npm pack --dry-run --json
|
|
86
|
+
node scripts/hosted-pr-smoke.mjs --plan
|
|
82
87
|
```
|
|
83
88
|
|
|
84
89
|
CI must also run GitHub Actions static checks:
|
|
@@ -90,6 +95,14 @@ uvx zizmor --offline .github/workflows
|
|
|
90
95
|
|
|
91
96
|
For a hosted release candidate, CI must additionally verify the built container image or deployment artifact rather than only source files.
|
|
92
97
|
|
|
98
|
+
For any release that changes the deployed Cloudflare GitHub App ingress, run the real smoke after deployment:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
node scripts/hosted-pr-smoke.mjs --evidence-file /tmp/ai-saas-guard-hosted-smoke.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The script must refuse a dirty working tree, target only `zr9959/ai-saas-guard`, create a `codex/hosted-smoke-*` branch, query Check Runs with a GET request on the trusted head SHA, write a public-safe machine-readable evidence record, close the temporary PR, delete the remote and local branch, and bulk-delete staging KV `delivery:` / `scan:` records.
|
|
105
|
+
|
|
93
106
|
## Hosted Security Tests
|
|
94
107
|
|
|
95
108
|
The release blocks when any of these tests fail:
|
|
@@ -241,9 +254,11 @@ Each hosted release run must clean:
|
|
|
241
254
|
|
|
242
255
|
- temporary files
|
|
243
256
|
- package tarballs that are not release assets
|
|
257
|
+
- temporary smoke PRs and `codex/hosted-smoke-*` branches
|
|
244
258
|
- worker checkouts
|
|
245
259
|
- generated SARIF and JSON scratch files
|
|
246
260
|
- dead test queues or local stores
|
|
261
|
+
- staging KV `delivery:` and `scan:` smoke records
|
|
247
262
|
- long-running processes started during verification
|
|
248
263
|
|
|
249
264
|
After cleanup, `git status --short --branch` should be clean, and process checks should show no test, build, watch, queue, worker, or dev-server processes left behind.
|
|
@@ -10,6 +10,14 @@ Recorded on 2026-05-25 from the deployed Cloudflare Worker plus the earlier temp
|
|
|
10
10
|
|
|
11
11
|
| Check | Evidence | Result |
|
|
12
12
|
| --- | --- | --- |
|
|
13
|
+
| Cloudflare Worker health, v0.40.0 | `GET https://ai-saas-guard-hosted.zr9959.workers.dev/healthz` returned `ok: true`, routes including `/github/app/install-info`, `checkRunPublisher: "configured"`, `scannerVersion: "0.40.0"`, and all privacy flags set to false for raw payloads, PR text, source, diffs, secrets, customer payloads, checkout paths, and installation tokens | Passed |
|
|
14
|
+
| Public install guidance, v0.40.0 | `GET https://ai-saas-guard-hosted.zr9959.workers.dev/github/app/install-info` returned the `ai-saas-guard-hosted` install URL, selected-repository boundary wording, first-slice permissions `checks: write`, `contents: read`, `metadata: read`, `pull_requests: read`, subscribed events `pull_request`, `installation`, and `installation_repositories`, uninstall cleanup wording, `scannerVersion: "0.40.0"`, and no private keys, webhook secrets, installation tokens, source, diffs, or customer payloads | Passed |
|
|
15
|
+
| Deployed Worker version, v0.40.0 | `wrangler deploy` uploaded 38.99 KiB / gzip 10.01 KiB and deployed version `47e90d1c-0d7b-455f-b1a4-1ec7ee10d58b` at `2026-05-25T12:47:28Z` verification time | Passed |
|
|
16
|
+
| Real hosted PR smoke, v0.40.0 | `node scripts/hosted-pr-smoke.mjs --evidence-file /tmp/ai-saas-guard-hosted-smoke-v0.40.json` opened temporary PR `#85`, waited for Check Run `77714061842` on head SHA `e312073d12dffdca3358edfce17869adac48d7f4`, received conclusion `success`, closed the PR, restored the original branch, deleted the local branch, and deleted 4 staging KV records with `remainingSmokeKeys: 0`; `gh pr view 85` returned `state: "CLOSED"`, `git ls-remote --heads origin codex/hosted-smoke-20260525124817` returned no remote branch, and `wrangler kv key list --namespace-id fa5344fbd7944de6a776bf8731d58460 --remote` returned `[]` after cleanup | Passed |
|
|
17
|
+
| Cloudflare Worker health, v0.39.0 | `GET https://ai-saas-guard-hosted.zr9959.workers.dev/healthz` returned `ok: true`, routes including `/github/app/install-info`, `checkRunPublisher: "configured"`, `scannerVersion: "0.39.0"`, and all privacy flags set to false for raw payloads, PR text, source, diffs, secrets, customer payloads, checkout paths, and installation tokens | Passed |
|
|
18
|
+
| Public install guidance, v0.39.0 | `GET https://ai-saas-guard-hosted.zr9959.workers.dev/github/app/install-info` returned the `ai-saas-guard-hosted` install URL, selected-repository boundary wording, first-slice permissions `checks: write`, `contents: read`, `metadata: read`, `pull_requests: read`, subscribed events `pull_request`, `installation`, and `installation_repositories`, uninstall cleanup wording, `scannerVersion: "0.39.0"`, and no private keys, webhook secrets, installation tokens, source, diffs, or customer payloads | Passed |
|
|
19
|
+
| Deployed Worker version, v0.39.0 | `wrangler deploy` uploaded 36.25 KiB / gzip 9.26 KiB and deployed version `91aebf30-4c25-4639-bf5c-6f8be4e85690` at `2026-05-25T12:26:24Z` verification time | Passed |
|
|
20
|
+
| Real hosted PR smoke, v0.39.0 | `node scripts/hosted-pr-smoke.mjs` opened temporary PR `#82`, waited for Check Run `77711358510` on head SHA `64fa25f631a78131b19ee33094c9469736f151dc`, received conclusion `success`, closed the PR, deleted branch `codex/hosted-smoke-20260525122732`, and `wrangler kv key list --namespace-id fa5344fbd7944de6a776bf8731d58460 --remote` returned `[]` after cleanup | Passed |
|
|
13
21
|
| Cloudflare Worker health, v0.38.0 | `GET https://ai-saas-guard-hosted.zr9959.workers.dev/healthz` returned `ok: true`, routes including `/github/app/install-info`, `checkRunPublisher: "configured"`, `scannerVersion: "0.38.0"`, and all privacy flags set to false for raw payloads, PR text, source, diffs, secrets, customer payloads, checkout paths, and installation tokens | Passed |
|
|
14
22
|
| Public install guidance, v0.38.0 | `GET https://ai-saas-guard-hosted.zr9959.workers.dev/github/app/install-info` returned the `ai-saas-guard-hosted` install URL, selected-repository boundary wording, first-slice permissions `checks: write`, `contents: read`, `metadata: read`, `pull_requests: read`, subscribed events `pull_request`, `installation`, and `installation_repositories`, uninstall cleanup wording, `scannerVersion: "0.38.0"`, and no private keys, webhook secrets, installation tokens, source, diffs, or customer payloads | Passed |
|
|
15
23
|
| Deployed Worker version, v0.38.0 | `wrangler deploy` uploaded 36.35 KiB / gzip 9.30 KiB and deployed version `5999ccce-c64d-4f3f-96c9-b46cff5a2aed` at `2026-05-25T10:51:30Z` verification time | Passed |
|
|
@@ -66,3 +74,14 @@ npx wrangler kv key list --namespace-id fa5344fbd7944de6a776bf8731d58460 --remot
|
|
|
66
74
|
Then open a temporary no-file-change PR, wait for an `ai-saas-guard PR risk` Check Run on the smoke commit, and close the PR plus delete the branch. After the smoke run, verify no temporary KV records remain unless a retained compact report is intentionally part of that test.
|
|
67
75
|
|
|
68
76
|
Do not leave smoke PRs, scratch branches, package tarballs, SARIF files, or test KV records behind.
|
|
77
|
+
|
|
78
|
+
The executable path for this procedure is:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
node scripts/hosted-pr-smoke.mjs --plan
|
|
82
|
+
node scripts/hosted-pr-smoke.mjs --evidence-file /tmp/ai-saas-guard-hosted-smoke.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The script is the preferred release-gate evidence path for the current Cloudflare hosted ingress. It creates a temporary `codex/hosted-smoke-*` branch and PR, waits for the hosted `ai-saas-guard PR risk` Check Run, records only public-safe Check Run metadata plus cleanup status, closes the PR, deletes the branch, restores the local branch, and bulk-deletes staging KV `delivery:` and `scan:` records. It refuses to target repositories outside `zr9959/ai-saas-guard` and does not print source, diffs, secrets, installation tokens, customer payloads, or checkout paths.
|
|
86
|
+
|
|
87
|
+
The script also refuses to run against a dirty working tree, queries the trusted head SHA Check Run through `gh api --method GET`, writes an optional `--evidence-file` JSON record with mode `0600`, and attempts remote branch deletion even if PR creation or Check Run polling fails. That makes it suitable for release evidence because failure paths still exercise cleanup instead of leaving smoke resources behind.
|
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.40.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.40.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.40.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.
|
|
@@ -23,7 +23,7 @@ This Worker is a real hosted ingress with first-slice Check Run publishing code,
|
|
|
23
23
|
- `HOSTED_EVENTS`: Cloudflare KV namespace for compact delivery and queued scan records.
|
|
24
24
|
- `WEBHOOK_SECRET`: Worker secret matching the GitHub App webhook secret.
|
|
25
25
|
- `GITHUB_APP_PRIVATE_KEY`: Worker secret for the staging GitHub App private key, used only in memory to sign short-lived GitHub App JWTs.
|
|
26
|
-
- `SCANNER_VERSION`: public version string, currently `0.
|
|
26
|
+
- `SCANNER_VERSION`: public version string, currently `0.40.0`.
|
|
27
27
|
- `GITHUB_APP_ID`, `GITHUB_APP_SLUG`, `GITHUB_APP_INSTALLATION_ID`: public staging identifiers for the private GitHub App installation.
|
|
28
28
|
|
|
29
29
|
## Deployment
|
|
@@ -68,6 +68,7 @@ Do not add raw app private keys, webhook secrets, installation tokens, source, d
|
|
|
68
68
|
The Check Run is intentionally compact. It should answer:
|
|
69
69
|
|
|
70
70
|
- What changed at a launch-risk boundary?
|
|
71
|
+
- Which risk areas are involved: auth/session, billing/entitlement, tenant data, deploy/permissions, API contract, or tests/silent success?
|
|
71
72
|
- Which files are in the Review queue?
|
|
72
73
|
- What Manual proof should block merge until it passes?
|
|
73
74
|
- What selected-repository permissions did the hosted check use?
|
|
@@ -84,6 +85,26 @@ The Worker handles signed GitHub cleanup events, including installation deletion
|
|
|
84
85
|
|
|
85
86
|
Delivery audit records may remain for the normal KV TTL. They must not contain source, diffs, secrets, customer payloads, PR-authored text, checkout paths, or installation tokens.
|
|
86
87
|
|
|
88
|
+
## Real PR Smoke Automation
|
|
89
|
+
|
|
90
|
+
Use the repository smoke runner before each hosted release:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
node scripts/hosted-pr-smoke.mjs --plan
|
|
94
|
+
node scripts/hosted-pr-smoke.mjs --evidence-file /tmp/ai-saas-guard-hosted-smoke.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The real run is intentionally narrow:
|
|
98
|
+
|
|
99
|
+
- creates a temporary branch named `codex/hosted-smoke-<timestamp>`
|
|
100
|
+
- commits one public smoke marker file with no secrets or source excerpts
|
|
101
|
+
- opens a temporary PR against `zr9959/ai-saas-guard`
|
|
102
|
+
- waits for the `ai-saas-guard PR risk` Check Run on the trusted head SHA
|
|
103
|
+
- records only the Check Run conclusion, URL, safe output title, and cleanup status
|
|
104
|
+
- closes the PR, deletes the branch, restores the local branch, and clears staging KV `delivery:` and `scan:` records with `wrangler kv bulk delete`
|
|
105
|
+
|
|
106
|
+
The script refuses to run against another repository, requires HTTPS Worker URLs, and keeps the local CLI path available even if hosted smoke fails.
|
|
107
|
+
|
|
87
108
|
## Release Boundary
|
|
88
109
|
|
|
89
110
|
Do not expose this as the full product. The hosted operational release gate still requires deployed evidence for Check Run publication, worker cleanup, monitoring, rollback, incident response, dependency and deployment artifact scanning, and GitHub App installation behavior.
|
|
@@ -820,13 +820,16 @@ function summarizeFindings(findings) {
|
|
|
820
820
|
}
|
|
821
821
|
|
|
822
822
|
function renderCheckRunSummary({ identity, report, scannerVersion }) {
|
|
823
|
+
const riskAreas = summarizeHostedRiskAreas(report.topRiskyFiles);
|
|
823
824
|
const lines = [
|
|
824
|
-
`Launch-risk gate: ai-saas-guard found ${report.summary.total} PR risk signal(s) for ${identity.repositoryFullName}#${identity.pullRequestNumber}
|
|
825
|
+
`Launch-risk gate: ai-saas-guard found ${report.summary.total} PR risk signal(s) for ${identity.repositoryFullName}#${identity.pullRequestNumber}.`,
|
|
825
826
|
`Scanner version: ${scannerVersion}.`,
|
|
826
827
|
"",
|
|
827
|
-
"
|
|
828
|
+
"Review task: inspect the files below before merge.",
|
|
829
|
+
"Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
830
|
+
"Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
|
|
828
831
|
"",
|
|
829
|
-
"
|
|
832
|
+
"Selected-repository hosted check: this App uses checks:write, contents:read, pull_requests:read, and metadata:read for the installed repository only.",
|
|
830
833
|
"",
|
|
831
834
|
"Launch decision queue:",
|
|
832
835
|
"- Can a real user get access they should not have?",
|
|
@@ -837,11 +840,21 @@ function renderCheckRunSummary({ identity, report, scannerVersion }) {
|
|
|
837
840
|
];
|
|
838
841
|
|
|
839
842
|
if (report.topRiskyFiles.length > 0) {
|
|
843
|
+
lines.push("", "Risk areas:");
|
|
844
|
+
for (const area of riskAreas.slice(0, 5)) {
|
|
845
|
+
lines.push(`- ${area.name}: ${area.count} file(s). Proof: ${area.proof}`);
|
|
846
|
+
}
|
|
840
847
|
lines.push("", "Review queue:");
|
|
841
848
|
for (const file of report.topRiskyFiles.slice(0, 5)) {
|
|
842
849
|
lines.push(`- ${file.path}: ${file.categories.join(", ")} (${file.added}+/${file.removed}-)`);
|
|
843
850
|
}
|
|
844
|
-
lines.push(
|
|
851
|
+
lines.push(
|
|
852
|
+
"",
|
|
853
|
+
"Reviewer checklist:",
|
|
854
|
+
"- What trust boundary changed?",
|
|
855
|
+
"- Why is this auth, billing, data, deploy, or test decision safe?",
|
|
856
|
+
"- What manual proof shows it fails closed?"
|
|
857
|
+
);
|
|
845
858
|
}
|
|
846
859
|
|
|
847
860
|
if (report.truncated) {
|
|
@@ -851,6 +864,81 @@ function renderCheckRunSummary({ identity, report, scannerVersion }) {
|
|
|
851
864
|
return lines.join("\n").slice(0, 6_000);
|
|
852
865
|
}
|
|
853
866
|
|
|
867
|
+
function summarizeHostedRiskAreas(files) {
|
|
868
|
+
const counts = new Map();
|
|
869
|
+
for (const file of files) {
|
|
870
|
+
for (const category of file.categories) {
|
|
871
|
+
const area = hostedRiskAreaForCategory(category);
|
|
872
|
+
counts.set(area.key, {
|
|
873
|
+
...area,
|
|
874
|
+
count: (counts.get(area.key)?.count ?? 0) + 1
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return [...counts.values()].sort((a, b) => {
|
|
880
|
+
if (b.weight !== a.weight) return b.weight - a.weight;
|
|
881
|
+
return b.count - a.count;
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function hostedRiskAreaForCategory(category) {
|
|
886
|
+
if (category === "auth/session") {
|
|
887
|
+
return {
|
|
888
|
+
key: "auth",
|
|
889
|
+
name: "Auth and session",
|
|
890
|
+
weight: 50,
|
|
891
|
+
proof: "use two accounts and confirm access, session, and ownership checks fail closed"
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
if (category === "billing/subscription") {
|
|
895
|
+
return {
|
|
896
|
+
key: "billing",
|
|
897
|
+
name: "Billing and entitlement",
|
|
898
|
+
weight: 50,
|
|
899
|
+
proof: "force unsigned, duplicate, failed, and canceled billing events before granting access"
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
if (category === "database schema/migration" || category === "RLS/policy") {
|
|
903
|
+
return {
|
|
904
|
+
key: "data",
|
|
905
|
+
name: "Tenant data access",
|
|
906
|
+
weight: 45,
|
|
907
|
+
proof: "run cross-tenant SELECT, INSERT, UPDATE, and DELETE checks with user A and user B"
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
if (category === "env/secrets/deploy" || category === "permissions/storage") {
|
|
911
|
+
return {
|
|
912
|
+
key: "deploy",
|
|
913
|
+
name: "Deploy, secrets, and permissions",
|
|
914
|
+
weight: 35,
|
|
915
|
+
proof: "confirm production env, workflow permissions, and storage scopes are least privilege"
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
if (category === "tests removed or weakened") {
|
|
919
|
+
return {
|
|
920
|
+
key: "tests",
|
|
921
|
+
name: "Tests and silent success",
|
|
922
|
+
weight: 40,
|
|
923
|
+
proof: "make the upstream path fail and confirm tests catch an error instead of fake success"
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (category === "API contract") {
|
|
927
|
+
return {
|
|
928
|
+
key: "api",
|
|
929
|
+
name: "API contract",
|
|
930
|
+
weight: 30,
|
|
931
|
+
proof: "exercise invalid, unauthorized, and failed upstream requests against changed routes"
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
key: "large-diff",
|
|
936
|
+
name: "Large AI diff",
|
|
937
|
+
weight: 20,
|
|
938
|
+
proof: "split unrelated UI, refactor, and trust-boundary changes before review"
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
854
942
|
async function createGitHubAppJwt({ appId, privateKeyPem }) {
|
|
855
943
|
if (!appId || !privateKeyPem) throw createGitHubApiError("jwt", "missing_config");
|
|
856
944
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-saas-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
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",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"dist",
|
|
101
101
|
"examples",
|
|
102
102
|
"hosted",
|
|
103
|
+
"scripts",
|
|
103
104
|
"README.md"
|
|
104
105
|
],
|
|
105
106
|
"license": "MIT",
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
repo: "zr9959/ai-saas-guard",
|
|
12
|
+
base: "main",
|
|
13
|
+
branchPrefix: "codex/hosted-smoke",
|
|
14
|
+
checkName: "ai-saas-guard PR risk",
|
|
15
|
+
workerUrl: "https://ai-saas-guard-hosted.zr9959.workers.dev",
|
|
16
|
+
kvNamespaceId: "fa5344fbd7944de6a776bf8731d58460",
|
|
17
|
+
waitSeconds: 180
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const args = parseArgs(process.argv.slice(2));
|
|
21
|
+
const options = {
|
|
22
|
+
...DEFAULTS,
|
|
23
|
+
...args
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (options.help) {
|
|
27
|
+
printHelp();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const plan = createPlan(options);
|
|
32
|
+
|
|
33
|
+
if (options.plan) {
|
|
34
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await runSmoke(plan);
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const parsed = {};
|
|
42
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
43
|
+
const arg = argv[index];
|
|
44
|
+
if (arg === "--plan") parsed.plan = true;
|
|
45
|
+
else if (arg === "--help" || arg === "-h") parsed.help = true;
|
|
46
|
+
else if (arg.startsWith("--repo=")) parsed.repo = arg.slice("--repo=".length);
|
|
47
|
+
else if (arg === "--repo") parsed.repo = argv[++index];
|
|
48
|
+
else if (arg.startsWith("--base=")) parsed.base = arg.slice("--base=".length);
|
|
49
|
+
else if (arg === "--base") parsed.base = argv[++index];
|
|
50
|
+
else if (arg.startsWith("--worker-url=")) parsed.workerUrl = arg.slice("--worker-url=".length);
|
|
51
|
+
else if (arg === "--worker-url") parsed.workerUrl = argv[++index];
|
|
52
|
+
else if (arg.startsWith("--kv-namespace-id=")) parsed.kvNamespaceId = arg.slice("--kv-namespace-id=".length);
|
|
53
|
+
else if (arg === "--kv-namespace-id") parsed.kvNamespaceId = argv[++index];
|
|
54
|
+
else if (arg.startsWith("--wait-seconds=")) parsed.waitSeconds = Number(arg.slice("--wait-seconds=".length));
|
|
55
|
+
else if (arg === "--wait-seconds") parsed.waitSeconds = Number(argv[++index]);
|
|
56
|
+
else if (arg.startsWith("--evidence-file=")) parsed.evidenceFile = arg.slice("--evidence-file=".length);
|
|
57
|
+
else if (arg === "--evidence-file") parsed.evidenceFile = argv[++index];
|
|
58
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createPlan(input) {
|
|
64
|
+
validateSafeInput(input);
|
|
65
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
66
|
+
const branch = `${input.branchPrefix}-${stamp}`;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
repo: input.repo,
|
|
70
|
+
base: input.base,
|
|
71
|
+
branch,
|
|
72
|
+
checkName: input.checkName,
|
|
73
|
+
workerUrl: input.workerUrl,
|
|
74
|
+
installInfoUrl: `${input.workerUrl.replace(/\/+$/g, "")}/github/app/install-info`,
|
|
75
|
+
healthUrl: `${input.workerUrl.replace(/\/+$/g, "")}/healthz`,
|
|
76
|
+
kvNamespaceId: input.kvNamespaceId,
|
|
77
|
+
evidenceFile: input.evidenceFile,
|
|
78
|
+
waitSeconds: input.waitSeconds,
|
|
79
|
+
privacy: {
|
|
80
|
+
writesSourceToLogs: false,
|
|
81
|
+
writesTokensToLogs: false,
|
|
82
|
+
uploadsLocalSource: false,
|
|
83
|
+
deletesTemporaryBranch: true,
|
|
84
|
+
closesTemporaryPullRequest: true,
|
|
85
|
+
clearsHostedKvSmokeRecords: true
|
|
86
|
+
},
|
|
87
|
+
steps: [
|
|
88
|
+
"Verify hosted /healthz and /github/app/install-info.",
|
|
89
|
+
"Create a temporary branch from the base branch.",
|
|
90
|
+
"Commit one public smoke marker file with no secrets or source excerpts.",
|
|
91
|
+
"Push the temporary branch and open a draft-free pull request.",
|
|
92
|
+
"Wait for the hosted GitHub App Check Run on the trusted head SHA.",
|
|
93
|
+
"Record only Check Run conclusion, URL, and safe summary fields.",
|
|
94
|
+
"Close the temporary pull request, delete the remote branch, restore the local branch.",
|
|
95
|
+
"Bulk-delete staging KV delivery and scan records."
|
|
96
|
+
]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateSafeInput(input) {
|
|
101
|
+
if (input.repo !== DEFAULTS.repo) {
|
|
102
|
+
throw new Error(`Refusing hosted smoke outside ${DEFAULTS.repo}`);
|
|
103
|
+
}
|
|
104
|
+
if (!/^[a-z0-9._-]+\/[a-z0-9._-]+$/i.test(input.repo)) {
|
|
105
|
+
throw new Error("Invalid GitHub repository name");
|
|
106
|
+
}
|
|
107
|
+
if (!/^[a-z0-9._/-]+$/i.test(input.base) || input.base.startsWith("-")) {
|
|
108
|
+
throw new Error("Invalid base branch");
|
|
109
|
+
}
|
|
110
|
+
if (!String(input.workerUrl).startsWith("https://")) {
|
|
111
|
+
throw new Error("Worker URL must be HTTPS");
|
|
112
|
+
}
|
|
113
|
+
if (!/^[a-f0-9]{32}$/i.test(input.kvNamespaceId)) {
|
|
114
|
+
throw new Error("KV namespace id must be a 32-character hex id");
|
|
115
|
+
}
|
|
116
|
+
if (!Number.isSafeInteger(input.waitSeconds) || input.waitSeconds < 30 || input.waitSeconds > 600) {
|
|
117
|
+
throw new Error("wait-seconds must be between 30 and 600");
|
|
118
|
+
}
|
|
119
|
+
if (input.evidenceFile !== undefined && (!input.evidenceFile || input.evidenceFile.startsWith("-") || input.evidenceFile.includes("\0"))) {
|
|
120
|
+
throw new Error("Invalid evidence file path");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runSmoke(plan) {
|
|
125
|
+
let originalBranch;
|
|
126
|
+
let prNumber;
|
|
127
|
+
let result;
|
|
128
|
+
let cleanup;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await verifyHostedEndpoints(plan);
|
|
132
|
+
const status = (await git(["status", "--porcelain"])).trim();
|
|
133
|
+
if (status) {
|
|
134
|
+
throw new Error("Refusing hosted smoke with dirty working tree");
|
|
135
|
+
}
|
|
136
|
+
originalBranch = (await git(["branch", "--show-current"])).trim();
|
|
137
|
+
await git(["fetch", "origin", plan.base]);
|
|
138
|
+
await git(["switch", "-c", plan.branch, `origin/${plan.base}`]);
|
|
139
|
+
await writeFile(
|
|
140
|
+
".github/ai-saas-guard-hosted-smoke.md",
|
|
141
|
+
`# ai-saas-guard hosted smoke\n\nTemporary hosted GitHub App smoke for ${new Date().toISOString()}.\n`
|
|
142
|
+
);
|
|
143
|
+
await git(["add", ".github/ai-saas-guard-hosted-smoke.md"]);
|
|
144
|
+
await git(["commit", "-m", "Run hosted GitHub App smoke"]);
|
|
145
|
+
await git(["push", "-u", "origin", plan.branch]);
|
|
146
|
+
|
|
147
|
+
const prUrl = (
|
|
148
|
+
await gh([
|
|
149
|
+
"pr",
|
|
150
|
+
"create",
|
|
151
|
+
"--repo",
|
|
152
|
+
plan.repo,
|
|
153
|
+
"--base",
|
|
154
|
+
plan.base,
|
|
155
|
+
"--head",
|
|
156
|
+
plan.branch,
|
|
157
|
+
"--title",
|
|
158
|
+
"Hosted GitHub App smoke",
|
|
159
|
+
"--body",
|
|
160
|
+
"Temporary hosted smoke PR. It should be closed and deleted by scripts/hosted-pr-smoke.mjs."
|
|
161
|
+
])
|
|
162
|
+
).trim();
|
|
163
|
+
prNumber = Number(prUrl.split("/").pop());
|
|
164
|
+
if (!Number.isSafeInteger(prNumber)) throw new Error(`Could not parse PR number from ${prUrl}`);
|
|
165
|
+
|
|
166
|
+
const headSha = (await git(["rev-parse", "HEAD"])).trim();
|
|
167
|
+
const checkRun = await waitForCheckRun({ ...plan, headSha });
|
|
168
|
+
|
|
169
|
+
result = {
|
|
170
|
+
ok: true,
|
|
171
|
+
generatedAt: new Date().toISOString(),
|
|
172
|
+
repo: plan.repo,
|
|
173
|
+
base: plan.base,
|
|
174
|
+
branch: plan.branch,
|
|
175
|
+
pullRequest: prNumber,
|
|
176
|
+
headSha,
|
|
177
|
+
checkRun,
|
|
178
|
+
privacy: plan.privacy
|
|
179
|
+
};
|
|
180
|
+
} finally {
|
|
181
|
+
cleanup = await cleanupSmoke({ plan, prNumber, originalBranch });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (result) {
|
|
185
|
+
result.cleanup = cleanup;
|
|
186
|
+
await writeEvidence(plan, result);
|
|
187
|
+
console.log(JSON.stringify(result, null, 2));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function verifyHostedEndpoints(plan) {
|
|
192
|
+
const health = JSON.parse(await curlJson(plan.healthUrl));
|
|
193
|
+
if (health.ok !== true || health.checkRunPublisher !== "configured") {
|
|
194
|
+
throw new Error("Hosted health is not ready for smoke");
|
|
195
|
+
}
|
|
196
|
+
const installInfo = JSON.parse(await curlJson(plan.installInfoUrl));
|
|
197
|
+
if (installInfo.ok !== true || installInfo.installUrl !== "https://github.com/apps/ai-saas-guard-hosted/installations/new") {
|
|
198
|
+
throw new Error("Hosted install-info is not ready for smoke");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function waitForCheckRun(plan) {
|
|
203
|
+
const deadline = Date.now() + plan.waitSeconds * 1000;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
const response = JSON.parse(
|
|
206
|
+
await gh([
|
|
207
|
+
"api",
|
|
208
|
+
"--method",
|
|
209
|
+
"GET",
|
|
210
|
+
`repos/${plan.repo}/commits/${plan.headSha}/check-runs`,
|
|
211
|
+
"-f",
|
|
212
|
+
`check_name=${plan.checkName}`
|
|
213
|
+
])
|
|
214
|
+
);
|
|
215
|
+
const run = response.check_runs?.find((candidate) => candidate.name === plan.checkName);
|
|
216
|
+
if (run?.status === "completed") {
|
|
217
|
+
return {
|
|
218
|
+
id: run.id,
|
|
219
|
+
conclusion: run.conclusion,
|
|
220
|
+
htmlUrl: run.html_url,
|
|
221
|
+
title: run.output?.title
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
await sleep(5000);
|
|
225
|
+
}
|
|
226
|
+
throw new Error(`Timed out waiting for Check Run: ${plan.checkName}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function cleanupSmoke({ plan, prNumber, originalBranch }) {
|
|
230
|
+
const cleanup = {
|
|
231
|
+
closedPullRequest: false,
|
|
232
|
+
deletedRemoteBranch: false,
|
|
233
|
+
restoredBranch: false,
|
|
234
|
+
deletedLocalBranch: false,
|
|
235
|
+
kv: { deletedKeys: 0, remainingSmokeKeys: 0 }
|
|
236
|
+
};
|
|
237
|
+
if (prNumber) {
|
|
238
|
+
cleanup.closedPullRequest = await ignoreFailure(gh(["pr", "close", String(prNumber), "--repo", plan.repo, "--delete-branch"]));
|
|
239
|
+
}
|
|
240
|
+
cleanup.deletedRemoteBranch = await ignoreFailure(git(["push", "origin", "--delete", plan.branch]));
|
|
241
|
+
if (originalBranch) {
|
|
242
|
+
cleanup.restoredBranch = await ignoreFailure(git(["switch", originalBranch]));
|
|
243
|
+
}
|
|
244
|
+
cleanup.deletedLocalBranch = await ignoreFailure(git(["branch", "-D", plan.branch]));
|
|
245
|
+
cleanup.kv = await clearHostedKv(plan);
|
|
246
|
+
return cleanup;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function clearHostedKv(plan) {
|
|
250
|
+
const listJson = await wrangler(["kv", "key", "list", "--namespace-id", plan.kvNamespaceId, "--remote"]);
|
|
251
|
+
const keys = JSON.parse(listJson)
|
|
252
|
+
.map((item) => item.name)
|
|
253
|
+
.filter((name) => /^(delivery|scan):/.test(name));
|
|
254
|
+
if (keys.length === 0) return { deletedKeys: 0, remainingSmokeKeys: 0 };
|
|
255
|
+
|
|
256
|
+
const file = join(tmpdir(), `ai-saas-guard-hosted-kv-delete-${Date.now()}.json`);
|
|
257
|
+
await writeFile(file, JSON.stringify(keys, null, 2));
|
|
258
|
+
try {
|
|
259
|
+
await wrangler(["kv", "bulk", "delete", file, "--namespace-id", plan.kvNamespaceId, "--remote", "--force"]);
|
|
260
|
+
} finally {
|
|
261
|
+
await rm(file, { force: true });
|
|
262
|
+
}
|
|
263
|
+
const remainingJson = await wrangler(["kv", "key", "list", "--namespace-id", plan.kvNamespaceId, "--remote"]);
|
|
264
|
+
const remainingSmokeKeys = JSON.parse(remainingJson).filter((item) => /^(delivery|scan):/.test(item.name)).length;
|
|
265
|
+
return { deletedKeys: keys.length, remainingSmokeKeys };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function writeEvidence(plan, result) {
|
|
269
|
+
if (!plan.evidenceFile) return;
|
|
270
|
+
await mkdir(dirname(plan.evidenceFile), { recursive: true });
|
|
271
|
+
await writeFile(plan.evidenceFile, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function git(args) {
|
|
275
|
+
return run("git", args);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function gh(args) {
|
|
279
|
+
return run("gh", args);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function wrangler(args) {
|
|
283
|
+
return run("npx", ["wrangler", ...args], { cwd: "hosted/cloudflare-worker" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function curlJson(url) {
|
|
287
|
+
return run("curl", ["-fsSL", url]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function run(command, args, options = {}) {
|
|
291
|
+
const { stdout } = await execFileAsync(command, args, {
|
|
292
|
+
cwd: options.cwd ?? process.cwd(),
|
|
293
|
+
maxBuffer: 1024 * 1024
|
|
294
|
+
});
|
|
295
|
+
return stdout;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function ignoreFailure(promise) {
|
|
299
|
+
try {
|
|
300
|
+
await promise;
|
|
301
|
+
return true;
|
|
302
|
+
} catch {
|
|
303
|
+
// Cleanup should continue through already-removed branches, PRs, or KV records.
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function sleep(ms) {
|
|
309
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function printHelp() {
|
|
313
|
+
console.log(`Usage: node scripts/hosted-pr-smoke.mjs [--plan] [--evidence-file <path>]\n\nRuns a real hosted GitHub App staging smoke against ${DEFAULTS.repo}.\nThe real run creates a temporary PR, waits for the hosted Check Run, closes the PR, deletes the branch, and clears staging KV smoke records.`);
|
|
314
|
+
}
|