ai-saas-guard 0.17.0 → 0.19.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 +8 -4
- package/README.zh-CN.md +7 -3
- package/dist/hosted/github-app.d.ts +69 -0
- package/dist/hosted/github-app.js +186 -0
- package/dist/report/markdown.js +11 -5
- package/dist/utils/files.d.ts +9 -1
- package/dist/utils/files.js +28 -5
- package/docs/github-action.md +1 -1
- package/docs/github-app-deployment.md +79 -0
- package/docs/github-app-design.md +2 -0
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +5 -3
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -73,9 +73,10 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
|
|
|
73
73
|
| JSON and SARIF output | Available |
|
|
74
74
|
| Composite GitHub Action | Available |
|
|
75
75
|
| Project config | `.ai-saas-guard.json` rule toggles, severity overrides, and fail thresholds |
|
|
76
|
-
| Versioned Action tags | `v0.
|
|
77
|
-
| npm package | `ai-saas-guard@0.
|
|
76
|
+
| Versioned Action tags | `v0.19.0`, `v0` |
|
|
77
|
+
| npm package | `ai-saas-guard@0.19.0` |
|
|
78
78
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
79
|
+
| Runtime hardening | Per-file and total text scan caps, escaped markdown evidence, stricter hosted deployment blockers |
|
|
79
80
|
|
|
80
81
|
## Quick Start
|
|
81
82
|
|
|
@@ -210,6 +211,8 @@ The hosted deployment model is documented in [docs/hosted-deployment-model.md](d
|
|
|
210
211
|
|
|
211
212
|
The hosted service runtime is documented in [docs/hosted-service-runtime.md](docs/hosted-service-runtime.md). It exports `createHostedServiceRuntime` from `ai-saas-guard/hosted/service` and implements the provider-independent service core for signed webhook intake, idempotent queue upsert, read-only worker orchestration, compact report storage, Check Run publication adapters, and worker cleanup planning. It does not deploy a public hosted environment by itself.
|
|
212
213
|
|
|
214
|
+
The hosted GitHub App deployment planner is documented in [docs/github-app-deployment.md](docs/github-app-deployment.md). It exports `planHostedGitHubAppDeployment` from `ai-saas-guard/hosted/github-app`, generates the least-privilege manifest for the first hosted slice, and blocks creation when the release gate, public HTTPS URLs, container digest, secret references, raw secret inputs, permissions, or events are incomplete or unsafe.
|
|
215
|
+
|
|
213
216
|
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.
|
|
214
217
|
|
|
215
218
|
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.
|
|
@@ -222,7 +225,7 @@ A public hosted compact report schema fixture is available at [examples/hosted-c
|
|
|
222
225
|
|
|
223
226
|
The proposed hosted app permission boundary is intentionally narrow: repository contents read, pull requests read, checks write, and metadata read for the first version. Optional PR comments require repository policy opt-in, and broad permissions such as administration, deployments, Actions write, and repository secrets are out of scope.
|
|
224
227
|
|
|
225
|
-
The repository also includes hosted contract helpers and runtime tests for webhook intake order, webhook verification, installation token scoping, durable queue idempotency, compact reports, retention limits, uninstall cleanup, repeated cleanup idempotency, scoped deletion planning, operational release gate blocking,
|
|
228
|
+
The repository also includes hosted contract helpers and runtime tests for webhook intake order, webhook verification, installation token scoping, durable queue idempotency, compact reports, retention limits, uninstall cleanup, repeated cleanup idempotency, scoped deletion planning, operational release gate blocking, provider-independent hosted service orchestration, and GitHub App deployment planning. These helpers do not deploy a public hosted service.
|
|
226
229
|
|
|
227
230
|
Users should prefer the local CLI for private repositories, offline review, or no-account workflows where hosted code processing is not acceptable.
|
|
228
231
|
|
|
@@ -256,7 +259,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
256
259
|
|
|
257
260
|
## GitHub Action
|
|
258
261
|
|
|
259
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
262
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.19.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
260
263
|
|
|
261
264
|
```yaml
|
|
262
265
|
name: ai-saas-guard
|
|
@@ -335,6 +338,7 @@ Use this sparingly. The goal is not to hide launch blockers; it is to keep repor
|
|
|
335
338
|
- Does not upload code.
|
|
336
339
|
- Requires no account or login.
|
|
337
340
|
- Does not modify scanned repositories.
|
|
341
|
+
- Limits scanned text by per-file and total scan budgets to reduce worst-case memory use.
|
|
338
342
|
- Redacts matched secret-like evidence.
|
|
339
343
|
|
|
340
344
|
## What This Is Not
|
package/README.zh-CN.md
CHANGED
|
@@ -55,7 +55,7 @@ AI 能很快把一个 SaaS 从想法做成可运行的产品。真正难的是
|
|
|
55
55
|
|
|
56
56
|
这个仓库是公开 GitHub 仓库。
|
|
57
57
|
|
|
58
|
-
CLI 已发布到 npm:`ai-saas-guard@0.
|
|
58
|
+
CLI 已发布到 npm:`ai-saas-guard@0.19.0`。GitHub Action 支持 `v0` 浮动标签,也支持固定版本标签,例如 `v0.19.0`。
|
|
59
59
|
|
|
60
60
|
| 模块 | 状态 |
|
|
61
61
|
| --- | --- |
|
|
@@ -66,9 +66,10 @@ CLI 已发布到 npm:`ai-saas-guard@0.17.0`。GitHub Action 支持 `v0` 浮动
|
|
|
66
66
|
| Markdown PR summary | 已可用 |
|
|
67
67
|
| GitHub Action | 已可用 |
|
|
68
68
|
| 项目配置 | `.ai-saas-guard.json` 支持规则开关、severity 覆盖和 fail threshold |
|
|
69
|
-
| 当前版本 | `0.
|
|
70
|
-
| Action 标签 | `v0.
|
|
69
|
+
| 当前版本 | `0.19.0` |
|
|
70
|
+
| Action 标签 | `v0.19.0`、`v0` |
|
|
71
71
|
| npm 发布 | GitHub Actions Trusted Publisher/OIDC,无需长期 npm token |
|
|
72
|
+
| 运行时加固 | 单文件和总扫描文本预算、markdown evidence 转义、更严格的 hosted deployment 阻断 |
|
|
72
73
|
|
|
73
74
|
## 快速开始
|
|
74
75
|
|
|
@@ -234,6 +235,7 @@ jobs:
|
|
|
234
235
|
- 不上传代码
|
|
235
236
|
- 不需要账号或登录
|
|
236
237
|
- 不修改被扫描仓库
|
|
238
|
+
- 对单文件和总扫描文本设置预算,降低极端仓库带来的内存占用风险
|
|
237
239
|
- 对类似 secret 的 evidence 做 redaction
|
|
238
240
|
|
|
239
241
|
## Hosted GitHub App 设计
|
|
@@ -243,6 +245,7 @@ jobs:
|
|
|
243
245
|
相关文档:
|
|
244
246
|
|
|
245
247
|
- [docs/github-app-design.md](docs/github-app-design.md)
|
|
248
|
+
- [docs/github-app-deployment.md](docs/github-app-deployment.md)
|
|
246
249
|
- [docs/hosted-first-service-slice.md](docs/hosted-first-service-slice.md)
|
|
247
250
|
- [docs/hosted-deployment-model.md](docs/hosted-deployment-model.md)
|
|
248
251
|
- [docs/hosted-service-runtime.md](docs/hosted-service-runtime.md)
|
|
@@ -257,6 +260,7 @@ jobs:
|
|
|
257
260
|
- durable scan queue planner:同一个 trusted scan key 的 queued/running/completed job 会复用,不重复排 worker,也不会把源码、diff、secret 或 PR 正文放进队列 payload
|
|
258
261
|
- worker read-only scan planner:只用 trusted identity 规划临时 worker checkout,要求 repository `contents: read`,固定运行 `ai-saas-guard pr-risk --json`,并忽略 PR 正文里的 repo 名、token scope 或命令
|
|
259
262
|
- hosted service runtime:`ai-saas-guard/hosted/service` 导出 `createHostedServiceRuntime`,把签名 webhook intake、幂等 queue upsert、read-only worker 编排、compact report 存储、Check Run 发布 adapter 和 worker cleanup 串成可测试的服务核心;它本身不部署公开 hosted 环境
|
|
263
|
+
- GitHub App deployment planner:`ai-saas-guard/hosted/github-app` 导出 `planHostedGitHubAppDeployment`,生成 first slice 最小权限 manifest,并在 release gate、公开 HTTPS URL、container digest、secret 引用、原始 secret 输入、permission 或 event 不安全时阻止创建
|
|
260
264
|
- webhook event parser
|
|
261
265
|
- check-run summary renderer
|
|
262
266
|
- Check Run publication planner:要求 repository `checks: write`,只从 compact report 生成有长度上限的 Check Run payload,包含 review categories、优先 review 文件、verification steps 和本地 CLI 复现命令;MVP 不发 PR comment
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export declare const HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS: {
|
|
2
|
+
readonly contents: "read";
|
|
3
|
+
readonly pull_requests: "read";
|
|
4
|
+
readonly checks: "write";
|
|
5
|
+
readonly metadata: "read";
|
|
6
|
+
};
|
|
7
|
+
export declare const HOSTED_GITHUB_APP_EVENTS: readonly ["pull_request", "installation", "installation_repositories"];
|
|
8
|
+
export type HostedGitHubAppPermissionName = keyof typeof HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS;
|
|
9
|
+
export type HostedGitHubAppPermissionValue = "read" | "write" | "none";
|
|
10
|
+
export type HostedGitHubAppEvent = (typeof HOSTED_GITHUB_APP_EVENTS)[number];
|
|
11
|
+
export interface HostedGitHubAppReleaseGateSummary {
|
|
12
|
+
shouldExposeHostedEnvironment: boolean;
|
|
13
|
+
blocked: boolean;
|
|
14
|
+
containerImageDigestRecorded: boolean;
|
|
15
|
+
missingEvidenceIds?: string[];
|
|
16
|
+
failedEvidenceIds?: string[];
|
|
17
|
+
staleEvidenceIds?: string[];
|
|
18
|
+
exceptionEvidenceIds?: string[];
|
|
19
|
+
releaseNotesCompliant?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface HostedGitHubAppSecretRefs {
|
|
22
|
+
appId: string;
|
|
23
|
+
privateKey: string;
|
|
24
|
+
webhookSecret: string;
|
|
25
|
+
}
|
|
26
|
+
export interface HostedGitHubAppDeploymentInput {
|
|
27
|
+
appName: string;
|
|
28
|
+
homepageUrl: string;
|
|
29
|
+
webhookUrl: string;
|
|
30
|
+
environment: string;
|
|
31
|
+
containerImageDigest: string;
|
|
32
|
+
secretRefs: HostedGitHubAppSecretRefs;
|
|
33
|
+
releaseGate: HostedGitHubAppReleaseGateSummary;
|
|
34
|
+
setupUrl?: string;
|
|
35
|
+
callbackUrl?: string;
|
|
36
|
+
requestedPermissions?: Record<string, HostedGitHubAppPermissionValue>;
|
|
37
|
+
requestedEvents?: string[];
|
|
38
|
+
rawPrivateKey?: string;
|
|
39
|
+
rawWebhookSecret?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface HostedGitHubAppManifest {
|
|
42
|
+
name: string;
|
|
43
|
+
url: string;
|
|
44
|
+
hook_attributes: {
|
|
45
|
+
url: string;
|
|
46
|
+
active: true;
|
|
47
|
+
};
|
|
48
|
+
redirect_url?: string;
|
|
49
|
+
setup_url?: string;
|
|
50
|
+
public: false;
|
|
51
|
+
default_permissions: typeof HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS;
|
|
52
|
+
default_events: HostedGitHubAppEvent[];
|
|
53
|
+
}
|
|
54
|
+
export interface HostedGitHubAppDeploymentPlan {
|
|
55
|
+
readyToCreateGitHubApp: boolean;
|
|
56
|
+
blockedReasons: string[];
|
|
57
|
+
environment: string;
|
|
58
|
+
containerImageDigest: string;
|
|
59
|
+
manifest: HostedGitHubAppManifest;
|
|
60
|
+
requiredSecretRefs: string[];
|
|
61
|
+
deploymentSteps: string[];
|
|
62
|
+
privacy: {
|
|
63
|
+
includesPrivateKey: false;
|
|
64
|
+
includesWebhookSecret: false;
|
|
65
|
+
includesClientSecret: false;
|
|
66
|
+
includesCustomerPayloads: false;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export declare function planHostedGitHubAppDeployment(input: HostedGitHubAppDeploymentInput): HostedGitHubAppDeploymentPlan;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
export const HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS = {
|
|
2
|
+
contents: "read",
|
|
3
|
+
pull_requests: "read",
|
|
4
|
+
checks: "write",
|
|
5
|
+
metadata: "read"
|
|
6
|
+
};
|
|
7
|
+
export const HOSTED_GITHUB_APP_EVENTS = [
|
|
8
|
+
"pull_request",
|
|
9
|
+
"installation",
|
|
10
|
+
"installation_repositories"
|
|
11
|
+
];
|
|
12
|
+
export function planHostedGitHubAppDeployment(input) {
|
|
13
|
+
const blockedReasons = [
|
|
14
|
+
...releaseGateBlockedReasons(input.releaseGate),
|
|
15
|
+
...containerDigestBlockedReasons(input.containerImageDigest),
|
|
16
|
+
...urlBlockedReasons("homepage_url", input.homepageUrl),
|
|
17
|
+
...urlBlockedReasons("webhook_url", input.webhookUrl),
|
|
18
|
+
...optionalUrlBlockedReasons("setup_url", input.setupUrl),
|
|
19
|
+
...optionalUrlBlockedReasons("callback_url", input.callbackUrl),
|
|
20
|
+
...secretRefBlockedReasons(input.secretRefs),
|
|
21
|
+
...rawSecretInputBlockedReasons(input),
|
|
22
|
+
...permissionBlockedReasons(input.requestedPermissions),
|
|
23
|
+
...eventBlockedReasons(input.requestedEvents)
|
|
24
|
+
];
|
|
25
|
+
const manifest = {
|
|
26
|
+
name: input.appName.trim() || "AI SaaS Guard Hosted",
|
|
27
|
+
url: input.homepageUrl,
|
|
28
|
+
hook_attributes: {
|
|
29
|
+
url: input.webhookUrl,
|
|
30
|
+
active: true
|
|
31
|
+
},
|
|
32
|
+
...(input.callbackUrl ? { redirect_url: input.callbackUrl } : {}),
|
|
33
|
+
...(input.setupUrl ? { setup_url: input.setupUrl } : {}),
|
|
34
|
+
public: false,
|
|
35
|
+
default_permissions: HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS,
|
|
36
|
+
default_events: [...HOSTED_GITHUB_APP_EVENTS]
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
readyToCreateGitHubApp: blockedReasons.length === 0,
|
|
40
|
+
blockedReasons,
|
|
41
|
+
environment: input.environment,
|
|
42
|
+
containerImageDigest: input.containerImageDigest,
|
|
43
|
+
manifest,
|
|
44
|
+
requiredSecretRefs: safeSecretRefs(input.secretRefs),
|
|
45
|
+
deploymentSteps: [
|
|
46
|
+
"Create the GitHub App from the generated least-privilege manifest.",
|
|
47
|
+
"Store the App ID, private key, and webhook secret in the platform secret manager.",
|
|
48
|
+
"Deploy webhook ingress and scan worker containers with the recorded image digest.",
|
|
49
|
+
"Run the hosted operational release gate against the deployed artifact before exposure."
|
|
50
|
+
],
|
|
51
|
+
privacy: {
|
|
52
|
+
includesPrivateKey: false,
|
|
53
|
+
includesWebhookSecret: false,
|
|
54
|
+
includesClientSecret: false,
|
|
55
|
+
includesCustomerPayloads: false
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function releaseGateBlockedReasons(gate) {
|
|
60
|
+
return gate.shouldExposeHostedEnvironment &&
|
|
61
|
+
!gate.blocked &&
|
|
62
|
+
gate.containerImageDigestRecorded &&
|
|
63
|
+
gate.releaseNotesCompliant !== false
|
|
64
|
+
? []
|
|
65
|
+
: ["release_gate_blocked"];
|
|
66
|
+
}
|
|
67
|
+
function containerDigestBlockedReasons(containerImageDigest) {
|
|
68
|
+
return /^sha256:[a-f0-9]{64}$/i.test(containerImageDigest)
|
|
69
|
+
? []
|
|
70
|
+
: ["invalid_container_image_digest"];
|
|
71
|
+
}
|
|
72
|
+
function urlBlockedReasons(name, value) {
|
|
73
|
+
return isSafeHttpsUrl(value) ? [] : [`invalid_${name}`];
|
|
74
|
+
}
|
|
75
|
+
function optionalUrlBlockedReasons(name, value) {
|
|
76
|
+
if (!value) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
return urlBlockedReasons(name, value);
|
|
80
|
+
}
|
|
81
|
+
function isSafeHttpsUrl(value) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(value);
|
|
84
|
+
return url.protocol === "https:" && !isUnsafeHostedHostname(url.hostname);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function isUnsafeHostedHostname(hostname) {
|
|
91
|
+
const normalized = normalizeHostname(hostname);
|
|
92
|
+
return (normalized === "localhost" ||
|
|
93
|
+
normalized.endsWith(".localhost") ||
|
|
94
|
+
isUnsafeIpv4Hostname(normalized) ||
|
|
95
|
+
isUnsafeIpv6Hostname(normalized));
|
|
96
|
+
}
|
|
97
|
+
function normalizeHostname(hostname) {
|
|
98
|
+
const lower = hostname.toLowerCase().replace(/\.$/, "");
|
|
99
|
+
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
100
|
+
}
|
|
101
|
+
function isUnsafeIpv4Hostname(hostname) {
|
|
102
|
+
const parts = hostname.split(".");
|
|
103
|
+
if (parts.length !== 4) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (!parts.every((part) => /^\d+$/.test(part))) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const octets = parts.map((part) => Number(part));
|
|
110
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const [first, second] = octets;
|
|
114
|
+
return (first === 0 ||
|
|
115
|
+
first === 10 ||
|
|
116
|
+
first === 127 ||
|
|
117
|
+
(first === 169 && second === 254) ||
|
|
118
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
119
|
+
(first === 192 && second === 168) ||
|
|
120
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
121
|
+
first >= 224);
|
|
122
|
+
}
|
|
123
|
+
function isUnsafeIpv6Hostname(hostname) {
|
|
124
|
+
return (hostname === "::" ||
|
|
125
|
+
hostname === "::1" ||
|
|
126
|
+
hostname.startsWith("fc") ||
|
|
127
|
+
hostname.startsWith("fd") ||
|
|
128
|
+
hostname.startsWith("fe80:"));
|
|
129
|
+
}
|
|
130
|
+
function secretRefBlockedReasons(secretRefs) {
|
|
131
|
+
const reasons = [];
|
|
132
|
+
for (const key of ["appId", "privateKey", "webhookSecret"]) {
|
|
133
|
+
const value = secretRefs[key];
|
|
134
|
+
if (!value.trim()) {
|
|
135
|
+
reasons.push(`missing_secret_ref:${key}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (looksLikeRawSecretMaterial(value)) {
|
|
139
|
+
reasons.push(`raw_secret_material:${key}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return reasons;
|
|
143
|
+
}
|
|
144
|
+
function rawSecretInputBlockedReasons(input) {
|
|
145
|
+
const reasons = [];
|
|
146
|
+
for (const key of ["rawPrivateKey", "rawWebhookSecret"]) {
|
|
147
|
+
const value = input[key];
|
|
148
|
+
if (typeof value === "string" && value.trim()) {
|
|
149
|
+
reasons.push(`raw_secret_material:${key}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return reasons;
|
|
153
|
+
}
|
|
154
|
+
function looksLikeRawSecretMaterial(value) {
|
|
155
|
+
return /-----BEGIN [A-Z ]*PRIVATE KEY-----|whsec_|gh[opurs]_|github_pat_/i.test(value);
|
|
156
|
+
}
|
|
157
|
+
function permissionBlockedReasons(requestedPermissions) {
|
|
158
|
+
if (!requestedPermissions) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
const reasons = [];
|
|
162
|
+
for (const permission of Object.keys(requestedPermissions).sort()) {
|
|
163
|
+
if (!(permission in HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS)) {
|
|
164
|
+
reasons.push(`permission_not_allowed:${permission}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const requiredValue = HOSTED_GITHUB_APP_REQUIRED_PERMISSIONS[permission];
|
|
168
|
+
if (requestedPermissions[permission] !== requiredValue) {
|
|
169
|
+
reasons.push(`permission_not_allowed:${permission}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return reasons;
|
|
173
|
+
}
|
|
174
|
+
function eventBlockedReasons(requestedEvents) {
|
|
175
|
+
if (!requestedEvents) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
const allowedEvents = new Set(HOSTED_GITHUB_APP_EVENTS);
|
|
179
|
+
return [...new Set(requestedEvents)]
|
|
180
|
+
.filter((event) => !allowedEvents.has(event))
|
|
181
|
+
.sort()
|
|
182
|
+
.map((event) => `event_not_allowed:${event}`);
|
|
183
|
+
}
|
|
184
|
+
function safeSecretRefs(secretRefs) {
|
|
185
|
+
return [secretRefs.appId, secretRefs.privateKey, secretRefs.webhookSecret].filter((value) => value.trim() && !looksLikeRawSecretMaterial(value));
|
|
186
|
+
}
|
package/dist/report/markdown.js
CHANGED
|
@@ -63,12 +63,12 @@ function appendFindings(lines, findings) {
|
|
|
63
63
|
}
|
|
64
64
|
for (const [index, finding] of findings.entries()) {
|
|
65
65
|
lines.push("");
|
|
66
|
-
lines.push(`${index + 1}. **[${finding.severity.toUpperCase()}] ${finding.title}**`);
|
|
66
|
+
lines.push(`${index + 1}. **[${finding.severity.toUpperCase()}] ${escapeMarkdownInline(finding.title)}**`);
|
|
67
67
|
lines.push(` - Rule: \`${finding.ruleId}\``);
|
|
68
68
|
lines.push(` - Evidence: ${formatEvidence(finding.evidence[0])}`);
|
|
69
|
-
lines.push(` - Why: ${finding.why}`);
|
|
70
|
-
lines.push(` - Verify: ${finding.suggestedVerification}`);
|
|
71
|
-
lines.push(` - Fix direction: ${finding.suggestedFix}`);
|
|
69
|
+
lines.push(` - Why: ${escapeMarkdownInline(finding.why)}`);
|
|
70
|
+
lines.push(` - Verify: ${escapeMarkdownInline(finding.suggestedVerification)}`);
|
|
71
|
+
lines.push(` - Fix direction: ${escapeMarkdownInline(finding.suggestedFix)}`);
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
function formatEvidence(evidence) {
|
|
@@ -76,8 +76,14 @@ function formatEvidence(evidence) {
|
|
|
76
76
|
return "`none`";
|
|
77
77
|
const location = evidence.line ? `${evidence.file}:${evidence.line}` : evidence.file;
|
|
78
78
|
const detail = evidence.snippet ?? evidence.match;
|
|
79
|
-
|
|
79
|
+
const safeLocation = escapeMarkdownInline(location).replaceAll("`", "'");
|
|
80
|
+
return detail
|
|
81
|
+
? `\`${safeLocation}\` - ${escapeMarkdownInline(detail)}`
|
|
82
|
+
: `\`${safeLocation}\``;
|
|
80
83
|
}
|
|
81
84
|
function escapeMarkdownTableCell(value) {
|
|
82
85
|
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
83
86
|
}
|
|
87
|
+
function escapeMarkdownInline(value) {
|
|
88
|
+
return value.replace(/\r?\n/g, " ").replaceAll("|", "\\|").trim();
|
|
89
|
+
}
|
package/dist/utils/files.d.ts
CHANGED
|
@@ -3,7 +3,15 @@ export interface TextFile {
|
|
|
3
3
|
absolutePath: string;
|
|
4
4
|
content: string;
|
|
5
5
|
}
|
|
6
|
-
export
|
|
6
|
+
export interface CollectTextFilesOptions {
|
|
7
|
+
maxFileBytes?: number;
|
|
8
|
+
maxFiles?: number;
|
|
9
|
+
maxTotalBytes?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare const DEFAULT_MAX_TEXT_FILE_BYTES: number;
|
|
12
|
+
export declare const DEFAULT_MAX_TEXT_FILES = 10000;
|
|
13
|
+
export declare const DEFAULT_MAX_TOTAL_TEXT_BYTES: number;
|
|
14
|
+
export declare function collectTextFiles(rootDir: string, options?: CollectTextFilesOptions): Promise<TextFile[]>;
|
|
7
15
|
export declare function lineNumberForIndex(content: string, index: number): number;
|
|
8
16
|
export declare function lineAt(content: string, lineNumber: number): string;
|
|
9
17
|
export declare function toPosix(path: string): string;
|
package/dist/utils/files.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
|
+
export const DEFAULT_MAX_TEXT_FILE_BYTES = 1024 * 1024;
|
|
4
|
+
export const DEFAULT_MAX_TEXT_FILES = 10_000;
|
|
5
|
+
export const DEFAULT_MAX_TOTAL_TEXT_BYTES = 50 * 1024 * 1024;
|
|
3
6
|
const ignoredDirectories = new Set([
|
|
4
7
|
".git",
|
|
5
8
|
".next",
|
|
@@ -11,13 +14,21 @@ const ignoredDirectories = new Set([
|
|
|
11
14
|
"out"
|
|
12
15
|
]);
|
|
13
16
|
const textFilePattern = /(^|\/)(\.env[^/]*|\.mcp\.json|mcp\.json|claude_desktop_config\.json)$|\.(cjs|cts|js|jsx|json|mjs|mts|prisma|sql|toml|ts|tsx|yaml|yml|env|md|txt)$/i;
|
|
14
|
-
export async function collectTextFiles(rootDir) {
|
|
17
|
+
export async function collectTextFiles(rootDir, options = {}) {
|
|
15
18
|
const files = [];
|
|
16
19
|
const ignores = await loadIgnoreRules(rootDir);
|
|
17
|
-
|
|
20
|
+
const budget = {
|
|
21
|
+
maxFileBytes: positiveIntegerOrDefault(options.maxFileBytes, DEFAULT_MAX_TEXT_FILE_BYTES),
|
|
22
|
+
maxFiles: positiveIntegerOrDefault(options.maxFiles, DEFAULT_MAX_TEXT_FILES),
|
|
23
|
+
maxTotalBytes: positiveIntegerOrDefault(options.maxTotalBytes, DEFAULT_MAX_TOTAL_TEXT_BYTES),
|
|
24
|
+
collectedBytes: 0
|
|
25
|
+
};
|
|
26
|
+
await walk(rootDir, rootDir, files, ignores, budget);
|
|
18
27
|
return files;
|
|
19
28
|
}
|
|
20
|
-
async function walk(rootDir, currentDir, files, ignores) {
|
|
29
|
+
async function walk(rootDir, currentDir, files, ignores, budget) {
|
|
30
|
+
if (files.length >= budget.maxFiles || budget.collectedBytes >= budget.maxTotalBytes)
|
|
31
|
+
return;
|
|
21
32
|
let entries;
|
|
22
33
|
try {
|
|
23
34
|
entries = await readdir(currentDir, { withFileTypes: true });
|
|
@@ -26,6 +37,8 @@ async function walk(rootDir, currentDir, files, ignores) {
|
|
|
26
37
|
return;
|
|
27
38
|
}
|
|
28
39
|
for (const entry of entries) {
|
|
40
|
+
if (files.length >= budget.maxFiles || budget.collectedBytes >= budget.maxTotalBytes)
|
|
41
|
+
break;
|
|
29
42
|
if (entry.name === ".DS_Store" || entry.name.startsWith("._"))
|
|
30
43
|
continue;
|
|
31
44
|
const absolutePath = join(currentDir, entry.name);
|
|
@@ -35,13 +48,15 @@ async function walk(rootDir, currentDir, files, ignores) {
|
|
|
35
48
|
if (entry.isDirectory()) {
|
|
36
49
|
if (ignoredDirectories.has(entry.name))
|
|
37
50
|
continue;
|
|
38
|
-
await walk(rootDir, absolutePath, files, ignores);
|
|
51
|
+
await walk(rootDir, absolutePath, files, ignores, budget);
|
|
39
52
|
continue;
|
|
40
53
|
}
|
|
41
54
|
if (!entry.isFile() || !textFilePattern.test(relativePath))
|
|
42
55
|
continue;
|
|
43
56
|
const fileStat = await stat(absolutePath);
|
|
44
|
-
if (fileStat.size >
|
|
57
|
+
if (fileStat.size > budget.maxFileBytes)
|
|
58
|
+
continue;
|
|
59
|
+
if (budget.collectedBytes + fileStat.size > budget.maxTotalBytes)
|
|
45
60
|
continue;
|
|
46
61
|
try {
|
|
47
62
|
files.push({
|
|
@@ -49,6 +64,7 @@ async function walk(rootDir, currentDir, files, ignores) {
|
|
|
49
64
|
absolutePath,
|
|
50
65
|
content: await readFile(absolutePath, "utf8")
|
|
51
66
|
});
|
|
67
|
+
budget.collectedBytes += fileStat.size;
|
|
52
68
|
}
|
|
53
69
|
catch {
|
|
54
70
|
continue;
|
|
@@ -114,3 +130,10 @@ function ignorePatternToRegex(pattern) {
|
|
|
114
130
|
function escapeRegex(value) {
|
|
115
131
|
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
116
132
|
}
|
|
133
|
+
function positiveIntegerOrDefault(value, fallback) {
|
|
134
|
+
if (value === undefined) {
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
const normalized = Math.floor(value);
|
|
138
|
+
return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback;
|
|
139
|
+
}
|
package/docs/github-action.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`ai-saas-guard` ships as a composite GitHub Action for pull request and code scanning workflows.
|
|
4
4
|
|
|
5
|
-
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.
|
|
5
|
+
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.19.0` 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,79 @@
|
|
|
1
|
+
# Hosted GitHub App Deployment
|
|
2
|
+
|
|
3
|
+
This document defines the GitHub App deployment planner now implemented in `src/hosted/github-app.ts`.
|
|
4
|
+
|
|
5
|
+
It does not create a GitHub App by itself. GitHub App creation still requires a real HTTPS webhook URL, a deployed hosted service artifact, platform secret storage, and a human owner or automation account with permission to create the app in GitHub. The planner makes those requirements explicit and blocks unsafe or incomplete deployment input.
|
|
6
|
+
|
|
7
|
+
## What Exists
|
|
8
|
+
|
|
9
|
+
The deployment planner exports `planHostedGitHubAppDeployment` from `ai-saas-guard/hosted/github-app`.
|
|
10
|
+
|
|
11
|
+
It creates a least-privilege GitHub App manifest for the first hosted slice:
|
|
12
|
+
|
|
13
|
+
- `contents: read`
|
|
14
|
+
- `pull_requests: read`
|
|
15
|
+
- `checks: write`
|
|
16
|
+
- `metadata: read`
|
|
17
|
+
|
|
18
|
+
Allowed events:
|
|
19
|
+
|
|
20
|
+
- `pull_request`
|
|
21
|
+
- `installation`
|
|
22
|
+
- `installation_repositories`
|
|
23
|
+
|
|
24
|
+
The manifest stays private by default and uses one active webhook URL.
|
|
25
|
+
|
|
26
|
+
## Required Inputs
|
|
27
|
+
|
|
28
|
+
A ready deployment plan requires:
|
|
29
|
+
|
|
30
|
+
- app name
|
|
31
|
+
- HTTPS homepage URL
|
|
32
|
+
- HTTPS webhook URL
|
|
33
|
+
- hosted environment name
|
|
34
|
+
- `sha256:<digest>` container image digest
|
|
35
|
+
- secret reference for GitHub App ID
|
|
36
|
+
- secret reference for GitHub App private key
|
|
37
|
+
- secret reference for webhook secret
|
|
38
|
+
- hosted operational release gate decision that allows hosted exposure
|
|
39
|
+
|
|
40
|
+
Secret references must be platform lookup names such as `platform-ref:github-app-key`, not raw secret values.
|
|
41
|
+
|
|
42
|
+
## Blockers
|
|
43
|
+
|
|
44
|
+
The planner blocks GitHub App creation when:
|
|
45
|
+
|
|
46
|
+
- the hosted release gate is blocked
|
|
47
|
+
- the container image digest is missing or malformed
|
|
48
|
+
- URLs are not HTTPS
|
|
49
|
+
- localhost, loopback, private, link-local, or multicast URLs are used
|
|
50
|
+
- required secret references are missing
|
|
51
|
+
- raw private keys or webhook secrets are passed instead of secret references, including explicit raw secret input fields
|
|
52
|
+
- requested permissions exceed the first-slice permission contract
|
|
53
|
+
- requested events exceed the first-slice event contract
|
|
54
|
+
|
|
55
|
+
## Privacy
|
|
56
|
+
|
|
57
|
+
The deployment plan never returns:
|
|
58
|
+
|
|
59
|
+
- private key material
|
|
60
|
+
- webhook secret values
|
|
61
|
+
- client secret values
|
|
62
|
+
- customer payloads
|
|
63
|
+
|
|
64
|
+
It returns only safe manifest fields, blocker IDs, environment metadata, container digest, secret reference names, and deployment steps.
|
|
65
|
+
|
|
66
|
+
## Deployment Steps
|
|
67
|
+
|
|
68
|
+
When `readyToCreateGitHubApp` is true:
|
|
69
|
+
|
|
70
|
+
1. Create the GitHub App from the generated least-privilege manifest.
|
|
71
|
+
2. Store the App ID, private key, and webhook secret in the platform secret manager.
|
|
72
|
+
3. Deploy webhook ingress and scan worker containers with the recorded image digest.
|
|
73
|
+
4. Run the hosted operational release gate against the deployed artifact before exposure.
|
|
74
|
+
|
|
75
|
+
## Current Status
|
|
76
|
+
|
|
77
|
+
The repository can now produce and validate the deployment plan, but it cannot honestly create a live GitHub App until a public hosted webhook URL, container image digest, and secret manager references exist.
|
|
78
|
+
|
|
79
|
+
The next deployment stage should wire the hosted service runtime to a real platform queue, compact report store, GitHub installation authentication, and Checks API publisher.
|
|
@@ -52,6 +52,8 @@ The hosted deployment model is scoped in [docs/hosted-deployment-model.md](hoste
|
|
|
52
52
|
|
|
53
53
|
The hosted service runtime is scoped in [docs/hosted-service-runtime.md](hosted-service-runtime.md). It implements the provider-independent core for signed webhook intake, idempotent queue upsert, read-only worker orchestration, compact report storage, Check Run publication adapters, and worker cleanup planning. It does not deploy a public hosted environment by itself.
|
|
54
54
|
|
|
55
|
+
The hosted GitHub App deployment planner is scoped in [docs/github-app-deployment.md](github-app-deployment.md). It generates the least-privilege first-slice manifest and blocks creation when release-gate evidence, HTTPS URLs, container digest, secret references, permissions, or events are incomplete or unsafe.
|
|
56
|
+
|
|
55
57
|
The hosted operational release gate is scoped in [docs/hosted-operational-release-gate.md](hosted-operational-release-gate.md). It blocks hosted exposure unless CI, webhook replay, signature verification, token scoping, idempotency, privacy and retention, worker cleanup, monitoring, alerting, rollback, and incident response evidence are fresh for the release candidate.
|
|
56
58
|
|
|
57
59
|
Hosted uninstall and data deletion behavior is scoped in [docs/hosted-uninstall-data-deletion.md](hosted-uninstall-data-deletion.md). It defines repository removal, full app uninstall, compact report deletion, queue cancellation, limited audit record retention, repeated cleanup idempotency, and user-facing deletion wording.
|
package/docs/npm-publishing.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
## Current State
|
|
6
6
|
|
|
7
7
|
- Package name: `ai-saas-guard`
|
|
8
|
-
- Current version: `0.
|
|
8
|
+
- Current version: `0.19.0`
|
|
9
9
|
- npm registry state: published at <https://www.npmjs.com/package/ai-saas-guard>
|
|
10
10
|
- First npm-published version: `0.1.1`
|
|
11
|
-
- GitHub Release: `v0.
|
|
11
|
+
- GitHub Release: `v0.19.0`
|
|
12
12
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
13
13
|
- Trusted Publisher: GitHub Actions, `zr9959/ai-saas-guard`, workflow `npm-publish.yml`, allowed action `npm publish`
|
|
14
14
|
- Long-lived npm publish token: not required
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
Use GitHub Actions with npm Trusted Publisher/OIDC:
|
|
19
19
|
|
|
20
|
-
1. Create and review a release tag such as `v0.
|
|
20
|
+
1. Create and review a release tag such as `v0.19.0`.
|
|
21
21
|
2. Publish from the GitHub Release or run the `Publish npm` workflow manually with `ref` set to that tag.
|
|
22
22
|
3. Keep `permissions.id-token: write` in the workflow so npm can exchange the GitHub Actions OIDC identity for a short-lived publish credential.
|
|
23
23
|
4. Run `npm publish --access public` from the workflow. Trusted publishing automatically generates provenance for this public package from this public repository.
|
package/docs/project-handoff.md
CHANGED
|
@@ -48,7 +48,7 @@ Implemented surfaces:
|
|
|
48
48
|
- Next/Vercel deploy and runtime footguns
|
|
49
49
|
- PR diff risk triage for auth, billing, RLS, env, tests removed, and large mixed diffs
|
|
50
50
|
- PR diff diagnostics when a base ref or shallow checkout prevents comparison
|
|
51
|
-
- PR-focused markdown summary output for GitHub step summaries or PR comments
|
|
51
|
+
- PR-focused markdown summary output for GitHub step summaries or PR comments, with escaped single-line evidence in generic markdown reports
|
|
52
52
|
- project-local `.ai-saas-guard.json` config for rule toggles, severity overrides, path-specific suppressions, and default fail thresholds
|
|
53
53
|
- rule stability labels in catalog metadata, public rule docs, and SARIF rule properties
|
|
54
54
|
- hosted GitHub App design note covering least-privilege permissions, webhook verification, privacy, data retention, prompt-injection handling, and implementation gates
|
|
@@ -58,9 +58,11 @@ Implemented surfaces:
|
|
|
58
58
|
- hosted uninstall and data deletion document defining repository removal, full app uninstall, compact report deletion, queue cancellation, audit record retention, repeated cleanup idempotency, and user-facing deletion wording
|
|
59
59
|
- hosted pricing and packaging document defining open-source CLI boundaries, free/public repo hosted behavior, private repo hosted behavior, PR comments, saved reports, team policy, optional Launch Review, and no pentest/certification/full-audit claims
|
|
60
60
|
- hosted service runtime document and provider-independent runtime core for signed webhook intake, idempotent queue upsert, read-only worker orchestration, compact report storage, Check Run publication adapters, and worker cleanup planning
|
|
61
|
+
- hosted GitHub App deployment planner document and least-privilege manifest planner for required permissions, events, public HTTPS URLs, container digest, secret references, raw secret input blockers, and release-gate checks
|
|
62
|
+
- resource caps for repository text collection, including per-file, total-file, and total-byte scan budgets to reduce worst-case memory use
|
|
61
63
|
- hosted pre-implementation contracts document, hosted compact report fixture, and pure helpers for pull request webhook intake planning, durable scan queue upsert planning, worker read-only scan planning, Check Run publication planning, queue-safe pull request event parsing from trusted GitHub event fields, bounded check-run summary rendering, idempotent queue cleanup planning, worker checkout cleanup planning, retention/deletion cleanup planning, and operational release gate evaluation
|
|
62
64
|
- implementation-ready hosted GitHub App permission contract for required permissions, optional PR comment permissions, selected repository installation, and out-of-scope broad permissions
|
|
63
|
-
- hosted GitHub App contract helpers and tests for webhook intake order, webhook verification, installation token scoping, durable scan queue idempotency, compact reports, retention limits, uninstall cleanup, repeated cleanup idempotency, scoped deletion planning, operational release gate blocking,
|
|
65
|
+
- hosted GitHub App contract helpers and tests for webhook intake order, webhook verification, installation token scoping, durable scan queue idempotency, compact reports, retention limits, uninstall cleanup, repeated cleanup idempotency, scoped deletion planning, operational release gate blocking, provider-independent service runtime orchestration, and GitHub App deployment planning
|
|
64
66
|
- GitHub issue templates for bug reports, false positives, false negatives, rule requests, and public-safe security reports
|
|
65
67
|
- CODEOWNERS for source, tests, docs, workflows, Action, and package metadata
|
|
66
68
|
- JSON output
|
|
@@ -131,7 +133,7 @@ CI:
|
|
|
131
133
|
Publishing:
|
|
132
134
|
|
|
133
135
|
- npm package: `ai-saas-guard`
|
|
134
|
-
- Current release line: `v0.
|
|
136
|
+
- Current release line: `v0.19.0`
|
|
135
137
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
136
138
|
- Trusted Publisher: GitHub Actions for `zr9959/ai-saas-guard`, workflow `npm-publish.yml`
|
|
137
139
|
- Long-lived npm publish tokens should not be required.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-saas-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Repo-local launch-readiness scanner for AI-built SaaS apps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/zr9959/ai-saas-guard#readme",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"./hosted/service": {
|
|
38
38
|
"types": "./dist/hosted/service.d.ts",
|
|
39
39
|
"default": "./dist/hosted/service.js"
|
|
40
|
+
},
|
|
41
|
+
"./hosted/github-app": {
|
|
42
|
+
"types": "./dist/hosted/github-app.d.ts",
|
|
43
|
+
"default": "./dist/hosted/github-app.js"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"bin": {
|