ai-saas-guard 0.20.0 → 0.22.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 +11 -5
- package/README.zh-CN.md +10 -4
- package/dist/hosted/app.d.ts +144 -0
- package/dist/hosted/app.js +311 -0
- package/dist/hosted/staging.d.ts +146 -0
- package/dist/hosted/staging.js +263 -0
- package/docs/github-action.md +1 -1
- package/docs/github-app-deployment.md +3 -1
- package/docs/hosted-node-container-app.md +126 -0
- package/docs/hosted-preimplementation-contracts.md +46 -1
- package/docs/hosted-production-adapters.md +5 -0
- package/docs/hosted-service-runtime.md +7 -1
- package/docs/hosted-staging-deployment.md +93 -0
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +4 -2
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -73,11 +73,13 @@ 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.22.0`, `v0` |
|
|
77
|
+
| npm package | `ai-saas-guard@0.22.0` |
|
|
78
78
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
79
79
|
| Runtime hardening | Per-file and total text scan caps, escaped markdown evidence, stricter hosted deployment blockers |
|
|
80
80
|
| Hosted production adapters | GitHub App JWT signing, installation-token request planning, bounded worker execution, and terminal-state cleanup planning |
|
|
81
|
+
| Hosted app skeleton | Node/container HTTP ingress, health route, worker tick, in-memory provider adapters, and deployment plan validation |
|
|
82
|
+
| Hosted staging deployment planner | Provider binding, staging release-gate evidence, Node/container deployment composition, and GitHub App promotion gating |
|
|
81
83
|
|
|
82
84
|
## Quick Start
|
|
83
85
|
|
|
@@ -216,13 +218,17 @@ The hosted GitHub App deployment planner is documented in [docs/github-app-deplo
|
|
|
216
218
|
|
|
217
219
|
The hosted production adapter layer is documented in [docs/hosted-production-adapters.md](docs/hosted-production-adapters.md). It exports `createHostedGitHubAppJwt`, `planHostedGitHubInstallationTokenRequest`, and `planHostedProductionWorkerExecution` from `ai-saas-guard/hosted/production-adapters`. It adds RS256 GitHub App JWT generation, selected-repository installation-token request plans, separate worker and Check Run token scopes, a fixed read-only worker command, bounded timeout and output budgets, compact JSON-only output, and cleanup plans for success, failure, timeout, and cancellation. It still does not expose a public hosted service by itself.
|
|
218
220
|
|
|
221
|
+
The hosted Node/container app skeleton is documented in [docs/hosted-node-container-app.md](docs/hosted-node-container-app.md). It exports `createHostedHttpApp`, `createInMemoryHostedAppPlatform`, and `planHostedNodeContainerDeployment` from `ai-saas-guard/hosted/app`. It adds a safe `/healthz` route, signed `/github/webhook` ingress, one-job worker tick, in-memory provider adapters for tests, and deployment-plan validation for secret manager, queue, compact report store, worker sandbox, and GitHub Checks publisher references. It still does not deploy or expose a public hosted service by itself.
|
|
222
|
+
|
|
223
|
+
The hosted staging deployment planner is documented in [docs/hosted-staging-deployment.md](docs/hosted-staging-deployment.md). It exports `planHostedProviderBinding`, `planHostedStagingDeployment`, and `planHostedGitHubAppPromotion` from `ai-saas-guard/hosted/staging`. It composes real provider references, the Node/container deployment plan, hosted operational release-gate evidence, and GitHub App deployment planning so staging and production promotion stay blocked until the required queue, store, worker sandbox, Check Run publisher, logs, metrics, rollback, and incident-response references are present. It still does not call a cloud provider, create a GitHub App, or expose a public hosted service by itself.
|
|
224
|
+
|
|
219
225
|
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.
|
|
220
226
|
|
|
221
227
|
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
228
|
|
|
223
229
|
Hosted pricing and packaging boundaries are documented in [docs/hosted-pricing-packaging.md](docs/hosted-pricing-packaging.md). Core local scanning stays useful without an account; hosted plans may add workflow convenience, saved reports, team policy, and optional human review, but they do not gate local CLI scanning.
|
|
224
230
|
|
|
225
|
-
Hosted pre-implementation pure contracts are documented in [docs/hosted-preimplementation-contracts.md](docs/hosted-preimplementation-contracts.md). They now include a pull request webhook intake planner that verifies signatures before parsing or queueing, a durable scan queue planner that reuses queued, running, and completed jobs for the same trusted scan key, a worker read-only scan planner that fixes the CLI command and requires repository `contents: read`, and a Check Run publication planner that requires repository `checks: write` and builds bounded check-only payloads from compact reports. They also cover queue-safe webhook event parsing, bounded check-run summary rendering, idempotent queue cleanup planning, worker checkout cleanup planning, a retention/deletion cleanup planner, an operational release gate evaluator,
|
|
231
|
+
Hosted pre-implementation pure contracts are documented in [docs/hosted-preimplementation-contracts.md](docs/hosted-preimplementation-contracts.md). They now include a pull request webhook intake planner that verifies signatures before parsing or queueing, a durable scan queue planner that reuses queued, running, and completed jobs for the same trusted scan key, a worker read-only scan planner that fixes the CLI command and requires repository `contents: read`, and a Check Run publication planner that requires repository `checks: write` and builds bounded check-only payloads from compact reports. They also cover queue-safe webhook event parsing, bounded check-run summary rendering, idempotent queue cleanup planning, worker checkout cleanup planning, a retention/deletion cleanup planner, an operational release gate evaluator, the production adapter plans needed for GitHub App auth and bounded worker execution, the Node/container app skeleton needed for real provider wiring, and the staging deployment planner needed before production GitHub App promotion. The service runtime composes these contracts behind replaceable adapters. PR comments remain a later workflow or paid hosted feature, not part of the hosted MVP contract.
|
|
226
232
|
|
|
227
233
|
A public hosted compact report schema fixture is available at [examples/hosted-compact-report.json](examples/hosted-compact-report.json). It is synthetic and public-safe: compact evidence only, no raw source, raw diffs, secrets, webhook payload bodies, customer payloads, private URLs, or worker checkout paths.
|
|
228
234
|
|
|
@@ -262,7 +268,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
262
268
|
|
|
263
269
|
## GitHub Action
|
|
264
270
|
|
|
265
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
271
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.22.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
266
272
|
|
|
267
273
|
```yaml
|
|
268
274
|
name: ai-saas-guard
|
|
@@ -392,7 +398,7 @@ Open-source core:
|
|
|
392
398
|
|
|
393
399
|
Near-term priorities:
|
|
394
400
|
|
|
395
|
-
-
|
|
401
|
+
- Use the hosted staging deployment planner to bind real provider references, deploy a staging artifact, and collect webhook replay, Check Run, cleanup, monitoring, rollback, and incident-response evidence from that artifact.
|
|
396
402
|
- Keep hosted exposure blocked until the operational release gate has fresh evidence from a deployed artifact.
|
|
397
403
|
|
|
398
404
|
Potential paid layer later:
|
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.22.0`。GitHub Action 支持 `v0` 浮动标签,也支持固定版本标签,例如 `v0.22.0`。
|
|
59
59
|
|
|
60
60
|
| 模块 | 状态 |
|
|
61
61
|
| --- | --- |
|
|
@@ -66,11 +66,13 @@ CLI 已发布到 npm:`ai-saas-guard@0.20.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.22.0` |
|
|
70
|
+
| Action 标签 | `v0.22.0`、`v0` |
|
|
71
71
|
| npm 发布 | GitHub Actions Trusted Publisher/OIDC,无需长期 npm token |
|
|
72
72
|
| 运行时加固 | 单文件和总扫描文本预算、markdown evidence 转义、更严格的 hosted deployment 阻断 |
|
|
73
73
|
| Hosted production adapters | GitHub App JWT 签名、installation-token 请求规划、有边界的 worker 执行和终态 cleanup 规划 |
|
|
74
|
+
| Hosted app skeleton | Node/container HTTP ingress、health route、worker tick、in-memory provider adapters 和 deployment plan 校验 |
|
|
75
|
+
| Hosted staging deployment planner | provider binding、staging release-gate evidence、Node/container deployment 组合和 GitHub App promotion gating |
|
|
74
76
|
|
|
75
77
|
## 快速开始
|
|
76
78
|
|
|
@@ -251,6 +253,8 @@ jobs:
|
|
|
251
253
|
- [docs/hosted-deployment-model.md](docs/hosted-deployment-model.md)
|
|
252
254
|
- [docs/hosted-service-runtime.md](docs/hosted-service-runtime.md)
|
|
253
255
|
- [docs/hosted-production-adapters.md](docs/hosted-production-adapters.md)
|
|
256
|
+
- [docs/hosted-node-container-app.md](docs/hosted-node-container-app.md)
|
|
257
|
+
- [docs/hosted-staging-deployment.md](docs/hosted-staging-deployment.md)
|
|
254
258
|
- [docs/hosted-operational-release-gate.md](docs/hosted-operational-release-gate.md)
|
|
255
259
|
- [docs/hosted-uninstall-data-deletion.md](docs/hosted-uninstall-data-deletion.md)
|
|
256
260
|
- [docs/hosted-pricing-packaging.md](docs/hosted-pricing-packaging.md)
|
|
@@ -264,6 +268,8 @@ jobs:
|
|
|
264
268
|
- hosted service runtime:`ai-saas-guard/hosted/service` 导出 `createHostedServiceRuntime`,把签名 webhook intake、幂等 queue upsert、read-only worker 编排、compact report 存储、Check Run 发布 adapter 和 worker cleanup 串成可测试的服务核心;它本身不部署公开 hosted 环境
|
|
265
269
|
- GitHub App deployment planner:`ai-saas-guard/hosted/github-app` 导出 `planHostedGitHubAppDeployment`,生成 first slice 最小权限 manifest,并在 release gate、公开 HTTPS URL、container digest、secret 引用、原始 secret 输入、permission 或 event 不安全时阻止创建
|
|
266
270
|
- Hosted production adapter layer:`ai-saas-guard/hosted/production-adapters` 导出 `createHostedGitHubAppJwt`、`planHostedGitHubInstallationTokenRequest` 和 `planHostedProductionWorkerExecution`,用于 GitHub App RS256 JWT、selected-repository installation token 请求规划、worker/check-run 分离 token scope、固定只读 worker 命令、timeout/output 预算、compact JSON-only 输出,以及 success/failure/timeout/cancellation 的 cleanup 规划;它本身仍然不部署公开 hosted 服务
|
|
271
|
+
- Hosted Node/container app skeleton:`ai-saas-guard/hosted/app` 导出 `createHostedHttpApp`、`createInMemoryHostedAppPlatform` 和 `planHostedNodeContainerDeployment`,提供安全 `/healthz`、签名 `/github/webhook` ingress、单 job worker tick、测试用 in-memory provider adapters,以及 secret manager、queue、compact report store、worker sandbox、GitHub Checks publisher 的部署引用校验;它本身仍然不部署或暴露公开 hosted 服务
|
|
272
|
+
- Hosted staging deployment planner:`ai-saas-guard/hosted/staging` 导出 `planHostedProviderBinding`、`planHostedStagingDeployment` 和 `planHostedGitHubAppPromotion`,把真实 provider 引用、Node/container deployment plan、hosted operational release-gate evidence 和 GitHub App deployment planning 组合起来;缺少 queue、store、worker sandbox、Check Run publisher、logs、metrics、rollback 或 incident-response 引用时,会阻止 staging exposure 和 production promotion;它本身仍然不会调用云平台、创建 GitHub App 或暴露公开 hosted 服务
|
|
267
273
|
- webhook event parser
|
|
268
274
|
- check-run summary renderer
|
|
269
275
|
- Check Run publication planner:要求 repository `checks: write`,只从 compact report 生成有长度上限的 Check Run payload,包含 review categories、优先 review 文件、verification steps 和本地 CLI 复现命令;MVP 不发 PR comment
|
|
@@ -273,7 +279,7 @@ jobs:
|
|
|
273
279
|
- operational release gate evaluator:检查 hosted 暴露前是否具备 fresh CI、webhook replay、workflow static check、dependency/container scan、cleanup、privacy、monitoring、rollback、incident response 和 release cleanup 证据;缺任何 P0 证据都会阻止 hosted exposure
|
|
274
280
|
- hosted compact report fixture:[examples/hosted-compact-report.json](examples/hosted-compact-report.json)
|
|
275
281
|
|
|
276
|
-
这些 helper
|
|
282
|
+
这些 helper 不会暴露公开服务、不会直接调用 GitHub API、不会持久化 installation token、不会真实写 check run、不会发 PR comment,也不会上传源码。
|
|
277
283
|
|
|
278
284
|
## 它不是什么
|
|
279
285
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type HostedCheckRunPublisher, type HostedCheckRunRequest, type HostedCompactReportStore, type HostedCompactReportStoreRecord, type HostedServiceQueueAdapter, type HostedServiceRuntime, type HostedServiceRuntimeOptions, type HostedServiceScanRunner } from "./service.js";
|
|
2
|
+
export declare const HOSTED_NODE_CONTAINER_PLATFORM = "node_container";
|
|
3
|
+
export declare const HOSTED_NODE_CONTAINER_ROLES: readonly ["webhook-ingress", "scan-worker"];
|
|
4
|
+
type RepositoryIdSource = HostedServiceRuntimeOptions["selectedRepositoryIdsByInstallation"];
|
|
5
|
+
export interface HostedAppHttpRequest {
|
|
6
|
+
method: string;
|
|
7
|
+
path: string;
|
|
8
|
+
headers: Record<string, string | string[] | undefined>;
|
|
9
|
+
body: string | Buffer;
|
|
10
|
+
}
|
|
11
|
+
export interface HostedAppHttpResponse {
|
|
12
|
+
status: number;
|
|
13
|
+
headers: {
|
|
14
|
+
"content-type": "application/json; charset=utf-8";
|
|
15
|
+
};
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
export interface HostedHttpAppOptions {
|
|
19
|
+
runtime: HostedServiceRuntime;
|
|
20
|
+
webhookPath?: string;
|
|
21
|
+
healthPath?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface HostedHttpApp {
|
|
24
|
+
handleHttpRequest(request: HostedAppHttpRequest): HostedAppHttpResponse;
|
|
25
|
+
runWorkerTick(): Promise<HostedAppWorkerTickResult>;
|
|
26
|
+
}
|
|
27
|
+
export type HostedAppWorkerTickResult = {
|
|
28
|
+
processed: false;
|
|
29
|
+
reason: "empty_queue";
|
|
30
|
+
platform: typeof HOSTED_NODE_CONTAINER_PLATFORM;
|
|
31
|
+
privacy: HostedAppResponsePrivacy;
|
|
32
|
+
} | {
|
|
33
|
+
processed: true;
|
|
34
|
+
status: "completed";
|
|
35
|
+
checkRunPublished: boolean;
|
|
36
|
+
compactReportStored: boolean;
|
|
37
|
+
cleanupPlanned: boolean;
|
|
38
|
+
platform: typeof HOSTED_NODE_CONTAINER_PLATFORM;
|
|
39
|
+
privacy: HostedAppResponsePrivacy;
|
|
40
|
+
} | {
|
|
41
|
+
processed: true;
|
|
42
|
+
status: "failed";
|
|
43
|
+
errorClass: "worker_plan_rejected" | "check_run_publication_rejected" | "scan_runner_failed";
|
|
44
|
+
reason?: string;
|
|
45
|
+
cleanupPlanned: boolean;
|
|
46
|
+
platform: typeof HOSTED_NODE_CONTAINER_PLATFORM;
|
|
47
|
+
privacy: HostedAppResponsePrivacy;
|
|
48
|
+
};
|
|
49
|
+
export interface HostedAppResponsePrivacy {
|
|
50
|
+
includesRawWebhookPayload: false;
|
|
51
|
+
includesUntrustedPrText: false;
|
|
52
|
+
includesRawSource: false;
|
|
53
|
+
includesRawDiffs: false;
|
|
54
|
+
includesSecrets: false;
|
|
55
|
+
includesCustomerPayloads: false;
|
|
56
|
+
includesPrivateCheckoutPath: false;
|
|
57
|
+
includesInstallationToken: false;
|
|
58
|
+
}
|
|
59
|
+
export interface InMemoryHostedAppPlatformOptions {
|
|
60
|
+
signingKey: string | Buffer;
|
|
61
|
+
scannerVersion: string;
|
|
62
|
+
selectedRepositoryIdsByInstallation: RepositoryIdSource;
|
|
63
|
+
removedRepositoryIdsByInstallation?: RepositoryIdSource;
|
|
64
|
+
scanRunner: HostedServiceScanRunner;
|
|
65
|
+
now?: () => string;
|
|
66
|
+
}
|
|
67
|
+
export interface InMemoryHostedAppPlatform {
|
|
68
|
+
app: HostedHttpApp;
|
|
69
|
+
adapters: {
|
|
70
|
+
queue: HostedServiceQueueAdapter;
|
|
71
|
+
compactReportStore: HostedCompactReportStore & {
|
|
72
|
+
records: HostedCompactReportStoreRecord[];
|
|
73
|
+
};
|
|
74
|
+
checkRunPublisher: HostedCheckRunPublisher & {
|
|
75
|
+
requests: HostedCheckRunRequest[];
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
platform: typeof HOSTED_NODE_CONTAINER_PLATFORM;
|
|
79
|
+
roles: typeof HOSTED_NODE_CONTAINER_ROLES;
|
|
80
|
+
}
|
|
81
|
+
export interface HostedNodeContainerDeploymentSecretRefs {
|
|
82
|
+
githubAppId: string;
|
|
83
|
+
githubAppPrivateKey: string;
|
|
84
|
+
githubWebhookSecret: string;
|
|
85
|
+
}
|
|
86
|
+
export interface HostedNodeContainerDeploymentInput {
|
|
87
|
+
environment: string;
|
|
88
|
+
publicBaseUrl: string;
|
|
89
|
+
containerImageDigest: string;
|
|
90
|
+
secretRefs: HostedNodeContainerDeploymentSecretRefs;
|
|
91
|
+
queueRef: string;
|
|
92
|
+
compactReportStoreRef: string;
|
|
93
|
+
workerSandboxRef: string;
|
|
94
|
+
checkRunPublisherRef: string;
|
|
95
|
+
rawPrivateKey?: string;
|
|
96
|
+
rawWebhookSecret?: string;
|
|
97
|
+
rawInstallationToken?: string;
|
|
98
|
+
rawSource?: string;
|
|
99
|
+
rawDiff?: string;
|
|
100
|
+
secretValues?: string[];
|
|
101
|
+
customerPayload?: unknown;
|
|
102
|
+
}
|
|
103
|
+
export interface HostedNodeContainerDeploymentPlan {
|
|
104
|
+
readyToDeploy: boolean;
|
|
105
|
+
blockedReasons: string[];
|
|
106
|
+
platform: typeof HOSTED_NODE_CONTAINER_PLATFORM;
|
|
107
|
+
roles: typeof HOSTED_NODE_CONTAINER_ROLES;
|
|
108
|
+
environment: string;
|
|
109
|
+
containerImageDigest: string;
|
|
110
|
+
endpoints: {
|
|
111
|
+
webhookUrl: string;
|
|
112
|
+
healthUrl: string;
|
|
113
|
+
};
|
|
114
|
+
adapters: {
|
|
115
|
+
secretManager: "platform_secret_manager";
|
|
116
|
+
queue: string;
|
|
117
|
+
compactReportStore: string;
|
|
118
|
+
workerSandbox: string;
|
|
119
|
+
checkRunPublisher: string;
|
|
120
|
+
};
|
|
121
|
+
runtime: {
|
|
122
|
+
httpIngress: "node_http";
|
|
123
|
+
worker: "node_worker";
|
|
124
|
+
localCliNoNetwork: true;
|
|
125
|
+
rawSourcePersistence: false;
|
|
126
|
+
rawDiffPersistence: false;
|
|
127
|
+
secretPersistence: false;
|
|
128
|
+
customerPayloadPersistence: false;
|
|
129
|
+
};
|
|
130
|
+
privacy: {
|
|
131
|
+
includesPrivateKey: false;
|
|
132
|
+
includesWebhookSecret: false;
|
|
133
|
+
includesInstallationToken: false;
|
|
134
|
+
includesRawSource: false;
|
|
135
|
+
includesRawDiffs: false;
|
|
136
|
+
includesSecrets: false;
|
|
137
|
+
includesCustomerPayloads: false;
|
|
138
|
+
includesPrivateUrls: false;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export declare function createHostedHttpApp(options: HostedHttpAppOptions): HostedHttpApp;
|
|
142
|
+
export declare function createInMemoryHostedAppPlatform(options: InMemoryHostedAppPlatformOptions): InMemoryHostedAppPlatform;
|
|
143
|
+
export declare function planHostedNodeContainerDeployment(input: HostedNodeContainerDeploymentInput): HostedNodeContainerDeploymentPlan;
|
|
144
|
+
export {};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createHostedServiceRuntime, createInMemoryHostedServiceAdapters } from "./service.js";
|
|
2
|
+
export const HOSTED_NODE_CONTAINER_PLATFORM = "node_container";
|
|
3
|
+
export const HOSTED_NODE_CONTAINER_ROLES = ["webhook-ingress", "scan-worker"];
|
|
4
|
+
export function createHostedHttpApp(options) {
|
|
5
|
+
const webhookPath = options.webhookPath ?? "/github/webhook";
|
|
6
|
+
const healthPath = options.healthPath ?? "/healthz";
|
|
7
|
+
return {
|
|
8
|
+
handleHttpRequest(request) {
|
|
9
|
+
if (request.path === healthPath) {
|
|
10
|
+
return request.method.toUpperCase() === "GET"
|
|
11
|
+
? jsonResponse(200, {
|
|
12
|
+
ok: true,
|
|
13
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
14
|
+
roles: [...HOSTED_NODE_CONTAINER_ROLES],
|
|
15
|
+
privacy: appResponsePrivacy()
|
|
16
|
+
})
|
|
17
|
+
: jsonResponse(405, methodNotAllowedBody(["GET"]));
|
|
18
|
+
}
|
|
19
|
+
if (request.path !== webhookPath) {
|
|
20
|
+
return jsonResponse(404, {
|
|
21
|
+
accepted: false,
|
|
22
|
+
reason: "not_found",
|
|
23
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
24
|
+
privacy: appResponsePrivacy()
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (request.method.toUpperCase() !== "POST") {
|
|
28
|
+
return jsonResponse(405, methodNotAllowedBody(["POST"]));
|
|
29
|
+
}
|
|
30
|
+
const result = options.runtime.handlePullRequestWebhook({
|
|
31
|
+
payload: request.body,
|
|
32
|
+
signatureHeader: headerValue(request.headers, "x-hub-signature-256"),
|
|
33
|
+
deliveryId: headerValue(request.headers, "x-github-delivery"),
|
|
34
|
+
manualRerun: headerValue(request.headers, "x-ai-saas-guard-manual-rerun") === "true"
|
|
35
|
+
});
|
|
36
|
+
return jsonResponse(result.accepted ? 202 : 400, safeWebhookResponse(result));
|
|
37
|
+
},
|
|
38
|
+
async runWorkerTick() {
|
|
39
|
+
return safeWorkerTickResult(await options.runtime.runNextQueuedScan());
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createInMemoryHostedAppPlatform(options) {
|
|
44
|
+
const adapters = createInMemoryHostedServiceAdapters();
|
|
45
|
+
const runtime = createHostedServiceRuntime({
|
|
46
|
+
signingKey: options.signingKey,
|
|
47
|
+
scannerVersion: options.scannerVersion,
|
|
48
|
+
selectedRepositoryIdsByInstallation: options.selectedRepositoryIdsByInstallation,
|
|
49
|
+
removedRepositoryIdsByInstallation: options.removedRepositoryIdsByInstallation,
|
|
50
|
+
queue: adapters.queue,
|
|
51
|
+
compactReportStore: adapters.compactReportStore,
|
|
52
|
+
checkRunPublisher: adapters.checkRunPublisher,
|
|
53
|
+
scanRunner: options.scanRunner,
|
|
54
|
+
now: options.now
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
app: createHostedHttpApp({ runtime }),
|
|
58
|
+
adapters,
|
|
59
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
60
|
+
roles: HOSTED_NODE_CONTAINER_ROLES
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function planHostedNodeContainerDeployment(input) {
|
|
64
|
+
const blockedReasons = [
|
|
65
|
+
...publicBaseUrlBlockedReasons(input.publicBaseUrl),
|
|
66
|
+
...containerDigestBlockedReasons(input.containerImageDigest),
|
|
67
|
+
...secretRefBlockedReasons(input.secretRefs),
|
|
68
|
+
...adapterRefBlockedReasons(input),
|
|
69
|
+
...rawInputBlockedReasons(input)
|
|
70
|
+
];
|
|
71
|
+
const publicBaseUrl = isSafePublicHttpsUrl(input.publicBaseUrl)
|
|
72
|
+
? normalizePublicBaseUrl(input.publicBaseUrl)
|
|
73
|
+
: "";
|
|
74
|
+
return {
|
|
75
|
+
readyToDeploy: blockedReasons.length === 0,
|
|
76
|
+
blockedReasons,
|
|
77
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
78
|
+
roles: HOSTED_NODE_CONTAINER_ROLES,
|
|
79
|
+
environment: input.environment,
|
|
80
|
+
containerImageDigest: input.containerImageDigest,
|
|
81
|
+
endpoints: {
|
|
82
|
+
webhookUrl: publicBaseUrl ? `${publicBaseUrl}/github/webhook` : "",
|
|
83
|
+
healthUrl: publicBaseUrl ? `${publicBaseUrl}/healthz` : ""
|
|
84
|
+
},
|
|
85
|
+
adapters: {
|
|
86
|
+
secretManager: "platform_secret_manager",
|
|
87
|
+
queue: safeAdapterRef(input.queueRef, "queue:"),
|
|
88
|
+
compactReportStore: safeAdapterRef(input.compactReportStoreRef, "store:"),
|
|
89
|
+
workerSandbox: safeAdapterRef(input.workerSandboxRef, "sandbox:"),
|
|
90
|
+
checkRunPublisher: safeAdapterRef(input.checkRunPublisherRef, "github-checks:")
|
|
91
|
+
},
|
|
92
|
+
runtime: {
|
|
93
|
+
httpIngress: "node_http",
|
|
94
|
+
worker: "node_worker",
|
|
95
|
+
localCliNoNetwork: true,
|
|
96
|
+
rawSourcePersistence: false,
|
|
97
|
+
rawDiffPersistence: false,
|
|
98
|
+
secretPersistence: false,
|
|
99
|
+
customerPayloadPersistence: false
|
|
100
|
+
},
|
|
101
|
+
privacy: {
|
|
102
|
+
includesPrivateKey: false,
|
|
103
|
+
includesWebhookSecret: false,
|
|
104
|
+
includesInstallationToken: false,
|
|
105
|
+
includesRawSource: false,
|
|
106
|
+
includesRawDiffs: false,
|
|
107
|
+
includesSecrets: false,
|
|
108
|
+
includesCustomerPayloads: false,
|
|
109
|
+
includesPrivateUrls: false
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function safeWebhookResponse(result) {
|
|
114
|
+
return {
|
|
115
|
+
accepted: result.accepted,
|
|
116
|
+
stage: result.stage,
|
|
117
|
+
...(result.reason === undefined ? {} : { reason: result.reason }),
|
|
118
|
+
...(result.deliveryId === undefined ? {} : { deliveryId: result.deliveryId }),
|
|
119
|
+
queuedWorker: result.queueDecision?.shouldEnqueueWorker ?? false,
|
|
120
|
+
shouldCreateCheckRun: result.shouldCreateCheckRun,
|
|
121
|
+
shouldCreatePrComment: false,
|
|
122
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
123
|
+
privacy: appResponsePrivacy()
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function safeWorkerTickResult(result) {
|
|
127
|
+
if (!result.processed) {
|
|
128
|
+
return {
|
|
129
|
+
processed: false,
|
|
130
|
+
reason: "empty_queue",
|
|
131
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
132
|
+
privacy: appResponsePrivacy()
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (result.status === "completed") {
|
|
136
|
+
return {
|
|
137
|
+
processed: true,
|
|
138
|
+
status: "completed",
|
|
139
|
+
checkRunPublished: result.checkRunPublication.shouldWriteCheckRun,
|
|
140
|
+
compactReportStored: true,
|
|
141
|
+
cleanupPlanned: result.cleanup.shouldDeleteWorkerCheckout,
|
|
142
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
143
|
+
privacy: appResponsePrivacy()
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
processed: true,
|
|
148
|
+
status: "failed",
|
|
149
|
+
errorClass: result.errorClass,
|
|
150
|
+
...(result.reason === undefined ? {} : { reason: result.reason }),
|
|
151
|
+
cleanupPlanned: result.cleanup?.shouldDeleteWorkerCheckout ?? false,
|
|
152
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
153
|
+
privacy: appResponsePrivacy()
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function jsonResponse(status, body) {
|
|
157
|
+
return {
|
|
158
|
+
status,
|
|
159
|
+
headers: {
|
|
160
|
+
"content-type": "application/json; charset=utf-8"
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify(body)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function methodNotAllowedBody(allowed) {
|
|
166
|
+
return {
|
|
167
|
+
accepted: false,
|
|
168
|
+
reason: "method_not_allowed",
|
|
169
|
+
allowed,
|
|
170
|
+
platform: HOSTED_NODE_CONTAINER_PLATFORM,
|
|
171
|
+
privacy: appResponsePrivacy()
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function appResponsePrivacy() {
|
|
175
|
+
return {
|
|
176
|
+
includesRawWebhookPayload: false,
|
|
177
|
+
includesUntrustedPrText: false,
|
|
178
|
+
includesRawSource: false,
|
|
179
|
+
includesRawDiffs: false,
|
|
180
|
+
includesSecrets: false,
|
|
181
|
+
includesCustomerPayloads: false,
|
|
182
|
+
includesPrivateCheckoutPath: false,
|
|
183
|
+
includesInstallationToken: false
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function headerValue(headers, name) {
|
|
187
|
+
const lowerName = name.toLowerCase();
|
|
188
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
189
|
+
if (key.toLowerCase() !== lowerName) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
return Array.isArray(value) ? value[0] : value;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
function publicBaseUrlBlockedReasons(publicBaseUrl) {
|
|
197
|
+
return isSafePublicHttpsUrl(publicBaseUrl) ? [] : ["invalid_public_base_url"];
|
|
198
|
+
}
|
|
199
|
+
function containerDigestBlockedReasons(containerImageDigest) {
|
|
200
|
+
return /^sha256:[a-f0-9]{64}$/i.test(containerImageDigest)
|
|
201
|
+
? []
|
|
202
|
+
: ["invalid_container_image_digest"];
|
|
203
|
+
}
|
|
204
|
+
function secretRefBlockedReasons(secretRefs) {
|
|
205
|
+
const reasons = [];
|
|
206
|
+
for (const key of ["githubAppId", "githubAppPrivateKey", "githubWebhookSecret"]) {
|
|
207
|
+
const ref = secretRefs[key].trim();
|
|
208
|
+
if (!ref) {
|
|
209
|
+
reasons.push(`missing_secret_ref:${key}`);
|
|
210
|
+
}
|
|
211
|
+
else if (!isValidSecretRef(ref)) {
|
|
212
|
+
reasons.push(`invalid_secret_ref:${key}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return reasons;
|
|
216
|
+
}
|
|
217
|
+
function adapterRefBlockedReasons(input) {
|
|
218
|
+
const adapterRefs = {
|
|
219
|
+
queue: { value: input.queueRef, prefix: "queue:" },
|
|
220
|
+
compactReportStore: { value: input.compactReportStoreRef, prefix: "store:" },
|
|
221
|
+
workerSandbox: { value: input.workerSandboxRef, prefix: "sandbox:" },
|
|
222
|
+
checkRunPublisher: { value: input.checkRunPublisherRef, prefix: "github-checks:" }
|
|
223
|
+
};
|
|
224
|
+
const reasons = [];
|
|
225
|
+
for (const [key, ref] of Object.entries(adapterRefs)) {
|
|
226
|
+
const value = ref.value.trim();
|
|
227
|
+
if (!value) {
|
|
228
|
+
reasons.push(`missing_adapter_ref:${key}`);
|
|
229
|
+
}
|
|
230
|
+
else if (!value.startsWith(ref.prefix)) {
|
|
231
|
+
reasons.push(`invalid_adapter_ref:${key}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return reasons;
|
|
235
|
+
}
|
|
236
|
+
function rawInputBlockedReasons(input) {
|
|
237
|
+
const reasons = [];
|
|
238
|
+
for (const key of ["rawPrivateKey", "rawWebhookSecret", "rawInstallationToken"]) {
|
|
239
|
+
if (typeof input[key] === "string" && input[key].trim()) {
|
|
240
|
+
reasons.push(`raw_secret_material:${key}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const key of ["rawSource", "rawDiff"]) {
|
|
244
|
+
if (typeof input[key] === "string" && input[key].trim()) {
|
|
245
|
+
reasons.push(`raw_source_material:${key}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (Array.isArray(input.secretValues) && input.secretValues.some((value) => value.trim())) {
|
|
249
|
+
reasons.push("raw_secret_material:secretValues");
|
|
250
|
+
}
|
|
251
|
+
if (input.customerPayload !== undefined && input.customerPayload !== null) {
|
|
252
|
+
reasons.push("raw_customer_payload:customerPayload");
|
|
253
|
+
}
|
|
254
|
+
return reasons;
|
|
255
|
+
}
|
|
256
|
+
function safeAdapterRef(value, prefix) {
|
|
257
|
+
const ref = value.trim();
|
|
258
|
+
return ref.startsWith(prefix) ? ref : "";
|
|
259
|
+
}
|
|
260
|
+
function isValidSecretRef(value) {
|
|
261
|
+
return /^secret:[A-Za-z0-9._:/@-]+$/.test(value);
|
|
262
|
+
}
|
|
263
|
+
function normalizePublicBaseUrl(publicBaseUrl) {
|
|
264
|
+
return publicBaseUrl.trim().replace(/\/+$/, "");
|
|
265
|
+
}
|
|
266
|
+
function isSafePublicHttpsUrl(value) {
|
|
267
|
+
try {
|
|
268
|
+
const url = new URL(value);
|
|
269
|
+
return url.protocol === "https:" && !isUnsafeHostedHostname(url.hostname);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function isUnsafeHostedHostname(hostname) {
|
|
276
|
+
const normalized = normalizeHostname(hostname);
|
|
277
|
+
return (normalized === "localhost" ||
|
|
278
|
+
normalized.endsWith(".localhost") ||
|
|
279
|
+
isUnsafeIpv4Hostname(normalized) ||
|
|
280
|
+
isUnsafeIpv6Hostname(normalized));
|
|
281
|
+
}
|
|
282
|
+
function normalizeHostname(hostname) {
|
|
283
|
+
const lower = hostname.toLowerCase().replace(/\.$/, "");
|
|
284
|
+
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
285
|
+
}
|
|
286
|
+
function isUnsafeIpv4Hostname(hostname) {
|
|
287
|
+
const parts = hostname.split(".");
|
|
288
|
+
if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
const octets = parts.map((part) => Number(part));
|
|
292
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const [first, second] = octets;
|
|
296
|
+
return (first === 0 ||
|
|
297
|
+
first === 10 ||
|
|
298
|
+
first === 127 ||
|
|
299
|
+
(first === 169 && second === 254) ||
|
|
300
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
301
|
+
(first === 192 && second === 168) ||
|
|
302
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
303
|
+
first >= 224);
|
|
304
|
+
}
|
|
305
|
+
function isUnsafeIpv6Hostname(hostname) {
|
|
306
|
+
return (hostname === "::" ||
|
|
307
|
+
hostname === "::1" ||
|
|
308
|
+
hostname.startsWith("fc") ||
|
|
309
|
+
hostname.startsWith("fd") ||
|
|
310
|
+
hostname.startsWith("fe80:"));
|
|
311
|
+
}
|