ai-saas-guard 0.18.0 → 0.20.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 +12 -6
- package/README.zh-CN.md +10 -5
- package/dist/hosted/github-app.js +52 -1
- package/dist/hosted/production-adapters.d.ts +143 -0
- package/dist/hosted/production-adapters.js +226 -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 +5 -3
- package/docs/hosted-preimplementation-contracts.md +24 -1
- package/docs/hosted-production-adapters.md +114 -0
- package/docs/hosted-service-runtime.md +3 -0
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +6 -4
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -73,9 +73,11 @@ 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.20.0`, `v0` |
|
|
77
|
+
| npm package | `ai-saas-guard@0.20.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 |
|
|
80
|
+
| Hosted production adapters | GitHub App JWT signing, installation-token request planning, bounded worker execution, and terminal-state cleanup planning |
|
|
79
81
|
|
|
80
82
|
## Quick Start
|
|
81
83
|
|
|
@@ -210,7 +212,9 @@ The hosted deployment model is documented in [docs/hosted-deployment-model.md](d
|
|
|
210
212
|
|
|
211
213
|
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
214
|
|
|
213
|
-
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, HTTPS URLs, container digest, secret references, permissions, or events are incomplete or unsafe.
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
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.
|
|
214
218
|
|
|
215
219
|
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.
|
|
216
220
|
|
|
@@ -218,7 +222,7 @@ Hosted uninstall and data deletion behavior is documented in [docs/hosted-uninst
|
|
|
218
222
|
|
|
219
223
|
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.
|
|
220
224
|
|
|
221
|
-
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,
|
|
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, and the production adapter plans needed for GitHub App auth and bounded worker execution. 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.
|
|
222
226
|
|
|
223
227
|
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.
|
|
224
228
|
|
|
@@ -258,7 +262,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
258
262
|
|
|
259
263
|
## GitHub Action
|
|
260
264
|
|
|
261
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
265
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.20.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
262
266
|
|
|
263
267
|
```yaml
|
|
264
268
|
name: ai-saas-guard
|
|
@@ -337,6 +341,7 @@ Use this sparingly. The goal is not to hide launch blockers; it is to keep repor
|
|
|
337
341
|
- Does not upload code.
|
|
338
342
|
- Requires no account or login.
|
|
339
343
|
- Does not modify scanned repositories.
|
|
344
|
+
- Limits scanned text by per-file and total scan budgets to reduce worst-case memory use.
|
|
340
345
|
- Redacts matched secret-like evidence.
|
|
341
346
|
|
|
342
347
|
## What This Is Not
|
|
@@ -387,7 +392,8 @@ Open-source core:
|
|
|
387
392
|
|
|
388
393
|
Near-term priorities:
|
|
389
394
|
|
|
390
|
-
-
|
|
395
|
+
- Wire the hosted production adapters to a real provider queue, secret manager, compact report store, and GitHub Checks API client.
|
|
396
|
+
- Keep hosted exposure blocked until the operational release gate has fresh evidence from a deployed artifact.
|
|
391
397
|
|
|
392
398
|
Potential paid layer later:
|
|
393
399
|
|
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.20.0`。GitHub Action 支持 `v0` 浮动标签,也支持固定版本标签,例如 `v0.20.0`。
|
|
59
59
|
|
|
60
60
|
| 模块 | 状态 |
|
|
61
61
|
| --- | --- |
|
|
@@ -66,9 +66,11 @@ CLI 已发布到 npm:`ai-saas-guard@0.18.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.20.0` |
|
|
70
|
+
| Action 标签 | `v0.20.0`、`v0` |
|
|
71
71
|
| npm 发布 | GitHub Actions Trusted Publisher/OIDC,无需长期 npm token |
|
|
72
|
+
| 运行时加固 | 单文件和总扫描文本预算、markdown evidence 转义、更严格的 hosted deployment 阻断 |
|
|
73
|
+
| Hosted production adapters | GitHub App JWT 签名、installation-token 请求规划、有边界的 worker 执行和终态 cleanup 规划 |
|
|
72
74
|
|
|
73
75
|
## 快速开始
|
|
74
76
|
|
|
@@ -234,6 +236,7 @@ jobs:
|
|
|
234
236
|
- 不上传代码
|
|
235
237
|
- 不需要账号或登录
|
|
236
238
|
- 不修改被扫描仓库
|
|
239
|
+
- 对单文件和总扫描文本设置预算,降低极端仓库带来的内存占用风险
|
|
237
240
|
- 对类似 secret 的 evidence 做 redaction
|
|
238
241
|
|
|
239
242
|
## Hosted GitHub App 设计
|
|
@@ -247,6 +250,7 @@ jobs:
|
|
|
247
250
|
- [docs/hosted-first-service-slice.md](docs/hosted-first-service-slice.md)
|
|
248
251
|
- [docs/hosted-deployment-model.md](docs/hosted-deployment-model.md)
|
|
249
252
|
- [docs/hosted-service-runtime.md](docs/hosted-service-runtime.md)
|
|
253
|
+
- [docs/hosted-production-adapters.md](docs/hosted-production-adapters.md)
|
|
250
254
|
- [docs/hosted-operational-release-gate.md](docs/hosted-operational-release-gate.md)
|
|
251
255
|
- [docs/hosted-uninstall-data-deletion.md](docs/hosted-uninstall-data-deletion.md)
|
|
252
256
|
- [docs/hosted-pricing-packaging.md](docs/hosted-pricing-packaging.md)
|
|
@@ -258,7 +262,8 @@ jobs:
|
|
|
258
262
|
- durable scan queue planner:同一个 trusted scan key 的 queued/running/completed job 会复用,不重复排 worker,也不会把源码、diff、secret 或 PR 正文放进队列 payload
|
|
259
263
|
- worker read-only scan planner:只用 trusted identity 规划临时 worker checkout,要求 repository `contents: read`,固定运行 `ai-saas-guard pr-risk --json`,并忽略 PR 正文里的 repo 名、token scope 或命令
|
|
260
264
|
- hosted service runtime:`ai-saas-guard/hosted/service` 导出 `createHostedServiceRuntime`,把签名 webhook intake、幂等 queue upsert、read-only worker 编排、compact report 存储、Check Run 发布 adapter 和 worker cleanup 串成可测试的服务核心;它本身不部署公开 hosted 环境
|
|
261
|
-
- GitHub App deployment planner:`ai-saas-guard/hosted/github-app` 导出 `planHostedGitHubAppDeployment`,生成 first slice 最小权限 manifest,并在 release gate
|
|
265
|
+
- 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
|
+
- 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 服务
|
|
262
267
|
- webhook event parser
|
|
263
268
|
- check-run summary renderer
|
|
264
269
|
- Check Run publication planner:要求 repository `checks: write`,只从 compact report 生成有长度上限的 Check Run payload,包含 review categories、优先 review 文件、verification steps 和本地 CLI 复现命令;MVP 不发 PR comment
|
|
@@ -268,7 +273,7 @@ jobs:
|
|
|
268
273
|
- 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
|
|
269
274
|
- hosted compact report fixture:[examples/hosted-compact-report.json](examples/hosted-compact-report.json)
|
|
270
275
|
|
|
271
|
-
这些 helper
|
|
276
|
+
这些 helper 不会启动服务、不会直接调用 GitHub API、不会持久化 installation token、不会真实写 check run、不会发 PR comment,也不会上传源码。
|
|
272
277
|
|
|
273
278
|
## 它不是什么
|
|
274
279
|
|
|
@@ -18,6 +18,7 @@ export function planHostedGitHubAppDeployment(input) {
|
|
|
18
18
|
...optionalUrlBlockedReasons("setup_url", input.setupUrl),
|
|
19
19
|
...optionalUrlBlockedReasons("callback_url", input.callbackUrl),
|
|
20
20
|
...secretRefBlockedReasons(input.secretRefs),
|
|
21
|
+
...rawSecretInputBlockedReasons(input),
|
|
21
22
|
...permissionBlockedReasons(input.requestedPermissions),
|
|
22
23
|
...eventBlockedReasons(input.requestedEvents)
|
|
23
24
|
];
|
|
@@ -80,12 +81,52 @@ function optionalUrlBlockedReasons(name, value) {
|
|
|
80
81
|
function isSafeHttpsUrl(value) {
|
|
81
82
|
try {
|
|
82
83
|
const url = new URL(value);
|
|
83
|
-
return url.protocol === "https:" && url.hostname
|
|
84
|
+
return url.protocol === "https:" && !isUnsafeHostedHostname(url.hostname);
|
|
84
85
|
}
|
|
85
86
|
catch {
|
|
86
87
|
return false;
|
|
87
88
|
}
|
|
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
|
+
}
|
|
89
130
|
function secretRefBlockedReasons(secretRefs) {
|
|
90
131
|
const reasons = [];
|
|
91
132
|
for (const key of ["appId", "privateKey", "webhookSecret"]) {
|
|
@@ -100,6 +141,16 @@ function secretRefBlockedReasons(secretRefs) {
|
|
|
100
141
|
}
|
|
101
142
|
return reasons;
|
|
102
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
|
+
}
|
|
103
154
|
function looksLikeRawSecretMaterial(value) {
|
|
104
155
|
return /-----BEGIN [A-Z ]*PRIVATE KEY-----|whsec_|gh[opurs]_|github_pat_/i.test(value);
|
|
105
156
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
import { type HostedScanIdentity, type HostedWorkerCheckoutCleanupPlan, type HostedWorkerReadOnlyScanPlan } from "./contracts.js";
|
|
3
|
+
export declare const HOSTED_GITHUB_API_VERSION = "2026-03-10";
|
|
4
|
+
export declare const HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS = 600;
|
|
5
|
+
export declare const HOSTED_GITHUB_APP_JWT_CLOCK_SKEW_SECONDS = 60;
|
|
6
|
+
export declare const HOSTED_WORKER_MAX_TIMEOUT_MS = 600000;
|
|
7
|
+
export declare const HOSTED_WORKER_DEFAULT_TIMEOUT_MS = 300000;
|
|
8
|
+
export declare const HOSTED_WORKER_MAX_OUTPUT_BYTES = 1048576;
|
|
9
|
+
export type HostedGitHubInstallationTokenPurpose = "worker_checkout" | "check_run_publication" | "first_slice";
|
|
10
|
+
export interface HostedGitHubAppJwtInput {
|
|
11
|
+
appId: string | number;
|
|
12
|
+
privateKey: string | Buffer | KeyObject;
|
|
13
|
+
nowSeconds?: number;
|
|
14
|
+
ttlSeconds?: number;
|
|
15
|
+
clockSkewSeconds?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface HostedGitHubAppJwt {
|
|
18
|
+
token: string;
|
|
19
|
+
algorithm: "RS256";
|
|
20
|
+
issuer: string;
|
|
21
|
+
issuedAt: number;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
maxTtlSeconds: typeof HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS;
|
|
24
|
+
privacy: {
|
|
25
|
+
includesPrivateKey: false;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface HostedGitHubInstallationTokenRequestInput {
|
|
29
|
+
installationId: number;
|
|
30
|
+
repositoryId: number;
|
|
31
|
+
purpose: HostedGitHubInstallationTokenPurpose;
|
|
32
|
+
requestedAt: string;
|
|
33
|
+
apiBaseUrl?: string;
|
|
34
|
+
apiVersion?: string;
|
|
35
|
+
appJwt?: string;
|
|
36
|
+
rawPrivateKey?: string;
|
|
37
|
+
rawInstallationToken?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface HostedGitHubInstallationTokenRequestPlan {
|
|
40
|
+
readyToRequestToken: boolean;
|
|
41
|
+
blockedReasons: string[];
|
|
42
|
+
purpose: HostedGitHubInstallationTokenPurpose;
|
|
43
|
+
requestedAt: string;
|
|
44
|
+
request: {
|
|
45
|
+
method: "POST";
|
|
46
|
+
url: string;
|
|
47
|
+
endpoint: string;
|
|
48
|
+
headers: {
|
|
49
|
+
accept: "application/vnd.github+json";
|
|
50
|
+
"x-github-api-version": string;
|
|
51
|
+
};
|
|
52
|
+
authorization: "runtime_bearer_app_jwt";
|
|
53
|
+
body: {
|
|
54
|
+
repository_ids: number[];
|
|
55
|
+
permissions: Record<string, "read" | "write">;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
responseHandling: {
|
|
59
|
+
tokenType: "installation_access_token";
|
|
60
|
+
persistToken: false;
|
|
61
|
+
cacheUntilExpiresAt: true;
|
|
62
|
+
redactTokenInLogs: true;
|
|
63
|
+
};
|
|
64
|
+
privacy: {
|
|
65
|
+
includesAppJwt: false;
|
|
66
|
+
includesInstallationToken: false;
|
|
67
|
+
includesPrivateKey: false;
|
|
68
|
+
includesCustomerPayloads: false;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface HostedProductionWorkerExecutionInput {
|
|
72
|
+
identity: HostedScanIdentity;
|
|
73
|
+
jobKey: string;
|
|
74
|
+
requestedAt: string;
|
|
75
|
+
selectedRepositoryIds: number[];
|
|
76
|
+
removedRepositoryIds?: number[];
|
|
77
|
+
temporaryCheckoutRoot?: string;
|
|
78
|
+
workerTimeoutMs?: number;
|
|
79
|
+
maxOutputBytes?: number;
|
|
80
|
+
rawSource?: string;
|
|
81
|
+
rawDiff?: string;
|
|
82
|
+
secretValues?: string[];
|
|
83
|
+
customerPayload?: unknown;
|
|
84
|
+
}
|
|
85
|
+
export interface HostedProductionWorkerExecutionPlan {
|
|
86
|
+
readyToRunWorker: boolean;
|
|
87
|
+
blockedReasons: string[];
|
|
88
|
+
jobKey: string;
|
|
89
|
+
requestedAt: string;
|
|
90
|
+
workerPlan: HostedWorkerReadOnlyScanPlan;
|
|
91
|
+
checkoutTokenRequest: HostedGitHubInstallationTokenRequestPlan;
|
|
92
|
+
checkRunTokenRequest: HostedGitHubInstallationTokenRequestPlan;
|
|
93
|
+
execution: {
|
|
94
|
+
commandSource: "trusted_runtime_plan";
|
|
95
|
+
timeoutMs: number;
|
|
96
|
+
maxOutputBytes: number;
|
|
97
|
+
cancellation: "supported";
|
|
98
|
+
networkAccess: "disabled";
|
|
99
|
+
writeMode: "read_only";
|
|
100
|
+
shell: "disabled";
|
|
101
|
+
};
|
|
102
|
+
output: {
|
|
103
|
+
compactJsonOnly: true;
|
|
104
|
+
persistRawSource: false;
|
|
105
|
+
persistRawDiffs: false;
|
|
106
|
+
persistSecrets: false;
|
|
107
|
+
persistCustomerPayloads: false;
|
|
108
|
+
};
|
|
109
|
+
cleanup: {
|
|
110
|
+
success: HostedWorkerCheckoutCleanupPlan;
|
|
111
|
+
failure: HostedWorkerCheckoutCleanupPlan;
|
|
112
|
+
timeout: HostedWorkerCheckoutCleanupPlan;
|
|
113
|
+
cancellation: HostedWorkerCheckoutCleanupPlan;
|
|
114
|
+
};
|
|
115
|
+
privacy: {
|
|
116
|
+
includesTemporaryCheckoutRoot: false;
|
|
117
|
+
includesRawSource: false;
|
|
118
|
+
includesRawDiffs: false;
|
|
119
|
+
includesSecrets: false;
|
|
120
|
+
includesCustomerPayloads: false;
|
|
121
|
+
includesAppJwt: false;
|
|
122
|
+
includesInstallationToken: false;
|
|
123
|
+
acceptsCommandFromPrText: false;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export interface HostedProductionSecretProvider {
|
|
127
|
+
getSecret(ref: string): Promise<string>;
|
|
128
|
+
}
|
|
129
|
+
export interface HostedProductionInstallationTokenRequester {
|
|
130
|
+
requestInstallationToken(plan: HostedGitHubInstallationTokenRequestPlan, appJwt: HostedGitHubAppJwt): Promise<{
|
|
131
|
+
token: string;
|
|
132
|
+
expiresAt: string;
|
|
133
|
+
}>;
|
|
134
|
+
}
|
|
135
|
+
export interface HostedProductionWorkerAdapter {
|
|
136
|
+
runReadOnlyCli(plan: HostedProductionWorkerExecutionPlan): Promise<{
|
|
137
|
+
stdout: string;
|
|
138
|
+
exitCode: number;
|
|
139
|
+
}>;
|
|
140
|
+
}
|
|
141
|
+
export declare function createHostedGitHubAppJwt(input: HostedGitHubAppJwtInput): HostedGitHubAppJwt;
|
|
142
|
+
export declare function planHostedGitHubInstallationTokenRequest(input: HostedGitHubInstallationTokenRequestInput): HostedGitHubInstallationTokenRequestPlan;
|
|
143
|
+
export declare function planHostedProductionWorkerExecution(input: HostedProductionWorkerExecutionInput): HostedProductionWorkerExecutionPlan;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createSign } from "node:crypto";
|
|
2
|
+
import { createHostedWorkerCheckoutCleanupPlan, planHostedWorkerReadOnlyScan } from "./contracts.js";
|
|
3
|
+
export const HOSTED_GITHUB_API_VERSION = "2026-03-10";
|
|
4
|
+
export const HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS = 600;
|
|
5
|
+
export const HOSTED_GITHUB_APP_JWT_CLOCK_SKEW_SECONDS = 60;
|
|
6
|
+
export const HOSTED_WORKER_MAX_TIMEOUT_MS = 600_000;
|
|
7
|
+
export const HOSTED_WORKER_DEFAULT_TIMEOUT_MS = 300_000;
|
|
8
|
+
export const HOSTED_WORKER_MAX_OUTPUT_BYTES = 1_048_576;
|
|
9
|
+
export function createHostedGitHubAppJwt(input) {
|
|
10
|
+
const nowSeconds = normalizeUnixSeconds(input.nowSeconds, Math.floor(Date.now() / 1000));
|
|
11
|
+
const ttlSeconds = clampPositiveInteger(input.ttlSeconds, HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS, HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS);
|
|
12
|
+
const clockSkewSeconds = clampPositiveInteger(input.clockSkewSeconds, HOSTED_GITHUB_APP_JWT_CLOCK_SKEW_SECONDS, HOSTED_GITHUB_APP_JWT_CLOCK_SKEW_SECONDS);
|
|
13
|
+
const issuedAt = nowSeconds - clockSkewSeconds;
|
|
14
|
+
const expiresAt = nowSeconds + ttlSeconds;
|
|
15
|
+
const issuer = String(input.appId);
|
|
16
|
+
const header = { typ: "JWT", alg: "RS256" };
|
|
17
|
+
const payload = { iat: issuedAt, exp: expiresAt, iss: issuer };
|
|
18
|
+
const signingInput = `${base64UrlJson(header)}.${base64UrlJson(payload)}`;
|
|
19
|
+
const signature = createSign("RSA-SHA256")
|
|
20
|
+
.update(signingInput)
|
|
21
|
+
.end()
|
|
22
|
+
.sign(input.privateKey)
|
|
23
|
+
.toString("base64url");
|
|
24
|
+
return {
|
|
25
|
+
token: `${signingInput}.${signature}`,
|
|
26
|
+
algorithm: "RS256",
|
|
27
|
+
issuer,
|
|
28
|
+
issuedAt,
|
|
29
|
+
expiresAt,
|
|
30
|
+
maxTtlSeconds: HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS,
|
|
31
|
+
privacy: {
|
|
32
|
+
includesPrivateKey: false
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function planHostedGitHubInstallationTokenRequest(input) {
|
|
37
|
+
const blockedReasons = [
|
|
38
|
+
...installationTokenInputBlockedReasons(input),
|
|
39
|
+
...safeApiUrlBlockedReasons(input.apiBaseUrl)
|
|
40
|
+
];
|
|
41
|
+
const apiBaseUrl = normalizeApiBaseUrl(input.apiBaseUrl);
|
|
42
|
+
const endpoint = `/app/installations/${input.installationId}/access_tokens`;
|
|
43
|
+
return {
|
|
44
|
+
readyToRequestToken: blockedReasons.length === 0,
|
|
45
|
+
blockedReasons,
|
|
46
|
+
purpose: input.purpose,
|
|
47
|
+
requestedAt: input.requestedAt,
|
|
48
|
+
request: {
|
|
49
|
+
method: "POST",
|
|
50
|
+
url: `${apiBaseUrl}${endpoint}`,
|
|
51
|
+
endpoint,
|
|
52
|
+
headers: {
|
|
53
|
+
accept: "application/vnd.github+json",
|
|
54
|
+
"x-github-api-version": input.apiVersion?.trim() || HOSTED_GITHUB_API_VERSION
|
|
55
|
+
},
|
|
56
|
+
authorization: "runtime_bearer_app_jwt",
|
|
57
|
+
body: {
|
|
58
|
+
repository_ids: [input.repositoryId],
|
|
59
|
+
permissions: permissionsForPurpose(input.purpose)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
responseHandling: {
|
|
63
|
+
tokenType: "installation_access_token",
|
|
64
|
+
persistToken: false,
|
|
65
|
+
cacheUntilExpiresAt: true,
|
|
66
|
+
redactTokenInLogs: true
|
|
67
|
+
},
|
|
68
|
+
privacy: {
|
|
69
|
+
includesAppJwt: false,
|
|
70
|
+
includesInstallationToken: false,
|
|
71
|
+
includesPrivateKey: false,
|
|
72
|
+
includesCustomerPayloads: false
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function planHostedProductionWorkerExecution(input) {
|
|
77
|
+
const workerPlan = planHostedWorkerReadOnlyScan({
|
|
78
|
+
identity: input.identity,
|
|
79
|
+
jobKey: input.jobKey,
|
|
80
|
+
requestedAt: input.requestedAt,
|
|
81
|
+
installationId: input.identity.installationId,
|
|
82
|
+
selectedRepositoryIds: input.selectedRepositoryIds,
|
|
83
|
+
removedRepositoryIds: input.removedRepositoryIds,
|
|
84
|
+
installationTokenPermissions: { contents: "read" }
|
|
85
|
+
});
|
|
86
|
+
const checkoutTokenRequest = planHostedGitHubInstallationTokenRequest({
|
|
87
|
+
installationId: input.identity.installationId,
|
|
88
|
+
repositoryId: input.identity.repositoryId,
|
|
89
|
+
purpose: "worker_checkout",
|
|
90
|
+
requestedAt: input.requestedAt
|
|
91
|
+
});
|
|
92
|
+
const checkRunTokenRequest = planHostedGitHubInstallationTokenRequest({
|
|
93
|
+
installationId: input.identity.installationId,
|
|
94
|
+
repositoryId: input.identity.repositoryId,
|
|
95
|
+
purpose: "check_run_publication",
|
|
96
|
+
requestedAt: input.requestedAt
|
|
97
|
+
});
|
|
98
|
+
const blockedReasons = [
|
|
99
|
+
...workerPlanBlockedReasons(workerPlan),
|
|
100
|
+
...prefixBlockedReasons("checkout_token", checkoutTokenRequest.blockedReasons),
|
|
101
|
+
...prefixBlockedReasons("check_run_token", checkRunTokenRequest.blockedReasons)
|
|
102
|
+
];
|
|
103
|
+
return {
|
|
104
|
+
readyToRunWorker: blockedReasons.length === 0,
|
|
105
|
+
blockedReasons,
|
|
106
|
+
jobKey: input.jobKey,
|
|
107
|
+
requestedAt: input.requestedAt,
|
|
108
|
+
workerPlan,
|
|
109
|
+
checkoutTokenRequest,
|
|
110
|
+
checkRunTokenRequest,
|
|
111
|
+
execution: {
|
|
112
|
+
commandSource: "trusted_runtime_plan",
|
|
113
|
+
timeoutMs: clampPositiveInteger(input.workerTimeoutMs, HOSTED_WORKER_DEFAULT_TIMEOUT_MS, HOSTED_WORKER_MAX_TIMEOUT_MS),
|
|
114
|
+
maxOutputBytes: clampPositiveInteger(input.maxOutputBytes, HOSTED_WORKER_MAX_OUTPUT_BYTES, HOSTED_WORKER_MAX_OUTPUT_BYTES),
|
|
115
|
+
cancellation: "supported",
|
|
116
|
+
networkAccess: "disabled",
|
|
117
|
+
writeMode: "read_only",
|
|
118
|
+
shell: "disabled"
|
|
119
|
+
},
|
|
120
|
+
output: {
|
|
121
|
+
compactJsonOnly: true,
|
|
122
|
+
persistRawSource: false,
|
|
123
|
+
persistRawDiffs: false,
|
|
124
|
+
persistSecrets: false,
|
|
125
|
+
persistCustomerPayloads: false
|
|
126
|
+
},
|
|
127
|
+
cleanup: {
|
|
128
|
+
success: cleanupPlan(input, "success"),
|
|
129
|
+
failure: cleanupPlan(input, "failure"),
|
|
130
|
+
timeout: cleanupPlan(input, "timeout"),
|
|
131
|
+
cancellation: cleanupPlan(input, "cancellation")
|
|
132
|
+
},
|
|
133
|
+
privacy: {
|
|
134
|
+
includesTemporaryCheckoutRoot: false,
|
|
135
|
+
includesRawSource: false,
|
|
136
|
+
includesRawDiffs: false,
|
|
137
|
+
includesSecrets: false,
|
|
138
|
+
includesCustomerPayloads: false,
|
|
139
|
+
includesAppJwt: false,
|
|
140
|
+
includesInstallationToken: false,
|
|
141
|
+
acceptsCommandFromPrText: false
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function base64UrlJson(value) {
|
|
146
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
147
|
+
}
|
|
148
|
+
function normalizeUnixSeconds(value, fallback) {
|
|
149
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
return Math.max(0, Math.floor(value));
|
|
153
|
+
}
|
|
154
|
+
function clampPositiveInteger(value, fallback, maximum) {
|
|
155
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
156
|
+
return fallback;
|
|
157
|
+
}
|
|
158
|
+
return Math.min(maximum, Math.max(1, Math.floor(value)));
|
|
159
|
+
}
|
|
160
|
+
function installationTokenInputBlockedReasons(input) {
|
|
161
|
+
const reasons = [];
|
|
162
|
+
if (typeof input.appJwt === "string" && input.appJwt.trim()) {
|
|
163
|
+
reasons.push("raw_secret_material:appJwt");
|
|
164
|
+
}
|
|
165
|
+
if (typeof input.rawPrivateKey === "string" && input.rawPrivateKey.trim()) {
|
|
166
|
+
reasons.push("raw_secret_material:rawPrivateKey");
|
|
167
|
+
}
|
|
168
|
+
if (typeof input.rawInstallationToken === "string" && input.rawInstallationToken.trim()) {
|
|
169
|
+
reasons.push("raw_secret_material:rawInstallationToken");
|
|
170
|
+
}
|
|
171
|
+
if (!Number.isSafeInteger(input.installationId) || input.installationId <= 0) {
|
|
172
|
+
reasons.push("invalid_installation_id");
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isSafeInteger(input.repositoryId) || input.repositoryId <= 0) {
|
|
175
|
+
reasons.push("invalid_repository_id");
|
|
176
|
+
}
|
|
177
|
+
return reasons;
|
|
178
|
+
}
|
|
179
|
+
function safeApiUrlBlockedReasons(apiBaseUrl) {
|
|
180
|
+
if (!apiBaseUrl) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const url = new URL(apiBaseUrl);
|
|
185
|
+
return url.protocol === "https:" ? [] : ["invalid_github_api_url"];
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return ["invalid_github_api_url"];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function normalizeApiBaseUrl(apiBaseUrl) {
|
|
192
|
+
const value = apiBaseUrl?.trim() || "https://api.github.com";
|
|
193
|
+
return value.replace(/\/+$/, "");
|
|
194
|
+
}
|
|
195
|
+
function permissionsForPurpose(purpose) {
|
|
196
|
+
if (purpose === "worker_checkout") {
|
|
197
|
+
return {
|
|
198
|
+
contents: "read",
|
|
199
|
+
pull_requests: "read"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (purpose === "check_run_publication") {
|
|
203
|
+
return {
|
|
204
|
+
checks: "write"
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
contents: "read",
|
|
209
|
+
pull_requests: "read",
|
|
210
|
+
checks: "write"
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function workerPlanBlockedReasons(plan) {
|
|
214
|
+
return plan.accepted ? [] : [`worker_plan_rejected:${plan.reason ?? "unknown"}`];
|
|
215
|
+
}
|
|
216
|
+
function prefixBlockedReasons(prefix, reasons) {
|
|
217
|
+
return reasons.map((reason) => `${prefix}:${reason}`);
|
|
218
|
+
}
|
|
219
|
+
function cleanupPlan(input, terminalState) {
|
|
220
|
+
return createHostedWorkerCheckoutCleanupPlan({
|
|
221
|
+
identity: input.identity,
|
|
222
|
+
jobKey: input.jobKey,
|
|
223
|
+
terminalState,
|
|
224
|
+
finishedAt: input.requestedAt
|
|
225
|
+
});
|
|
226
|
+
}
|
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.20.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
|
|
6
6
|
|
|
7
7
|
## PR Summary
|
|
8
8
|
|
|
@@ -46,9 +46,9 @@ The planner blocks GitHub App creation when:
|
|
|
46
46
|
- the hosted release gate is blocked
|
|
47
47
|
- the container image digest is missing or malformed
|
|
48
48
|
- URLs are not HTTPS
|
|
49
|
-
- localhost URLs are used
|
|
49
|
+
- localhost, loopback, private, link-local, or multicast URLs are used
|
|
50
50
|
- required secret references are missing
|
|
51
|
-
- raw private keys or webhook secrets are passed instead of secret references
|
|
51
|
+
- raw private keys or webhook secrets are passed instead of secret references, including explicit raw secret input fields
|
|
52
52
|
- requested permissions exceed the first-slice permission contract
|
|
53
53
|
- requested events exceed the first-slice event contract
|
|
54
54
|
|
|
@@ -61,6 +61,8 @@ The deployment plan never returns:
|
|
|
61
61
|
- client secret values
|
|
62
62
|
- customer payloads
|
|
63
63
|
|
|
64
|
+
The production adapter layer in [hosted-production-adapters.md](hosted-production-adapters.md) extends this boundary after App creation: it generates short-lived RS256 GitHub App JWTs, plans selected-repository installation-token requests, separates worker checkout and Check Run token scopes, and keeps bearer credentials out of serializable request plans.
|
|
65
|
+
|
|
64
66
|
It returns only safe manifest fields, blocker IDs, environment metadata, container digest, secret reference names, and deployment steps.
|
|
65
67
|
|
|
66
68
|
## Deployment Steps
|
|
@@ -76,4 +78,4 @@ When `readyToCreateGitHubApp` is true:
|
|
|
76
78
|
|
|
77
79
|
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
80
|
|
|
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.
|
|
81
|
+
The next deployment stage should wire the hosted service runtime and production adapters to a real platform queue, compact report store, GitHub installation authentication, worker isolation layer, and Checks API publisher.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This document collects pure hosted contracts that can be tested before any hosted GitHub App service is deployed. These contracts keep the hosted design inspectable, local-first, and implementation-ready without adding network calls, credentials, queues, workers, or GitHub API writes. They are no network calls contracts by design.
|
|
4
4
|
|
|
5
|
-
The helpers live in `src/hosted/contracts.ts` and are exported from `ai-saas-guard/hosted/contracts`.
|
|
5
|
+
The helpers live in `src/hosted/contracts.ts` and are exported from `ai-saas-guard/hosted/contracts`. The production adapter plans live in `src/hosted/production-adapters.ts` and are exported from `ai-saas-guard/hosted/production-adapters`.
|
|
6
6
|
|
|
7
7
|
## Pull Request Webhook Intake Planner
|
|
8
8
|
|
|
@@ -70,6 +70,29 @@ Trust boundaries:
|
|
|
70
70
|
|
|
71
71
|
The exported helper is `planHostedWorkerReadOnlyScan`. It is intended to be the worker-provider-independent contract for the first real hosted worker implementation.
|
|
72
72
|
|
|
73
|
+
## Production Adapter Plans
|
|
74
|
+
|
|
75
|
+
The production adapter layer turns the pure hosted contracts into a safer shape for real platform wiring. It is still provider-independent: it does not start a worker, call GitHub, request live installation tokens, write Check Runs, or upload source code.
|
|
76
|
+
|
|
77
|
+
Default behavior:
|
|
78
|
+
|
|
79
|
+
- create short-lived GitHub App JWTs with RS256 signing, 60-second issued-at clock skew, and a 10-minute maximum expiration
|
|
80
|
+
- plan installation-token requests for a selected repository ID only
|
|
81
|
+
- use separate token scopes for worker checkout and Check Run publication
|
|
82
|
+
- keep bearer credentials outside serializable request plans by using `runtime_bearer_app_jwt`
|
|
83
|
+
- fix the worker command to `ai-saas-guard pr-risk --root <worker-checkout> --base <trusted-base-sha> --json`
|
|
84
|
+
- cap worker timeout and output budgets
|
|
85
|
+
- require compact JSON-only worker output
|
|
86
|
+
- precompute cleanup plans for success, failure, timeout, and cancellation
|
|
87
|
+
|
|
88
|
+
Privacy boundaries:
|
|
89
|
+
|
|
90
|
+
- do not persist signing-key material, App JWTs, installation tokens, temporary checkout roots, checkout paths, raw source, raw diffs, secrets, customer payloads, or low-level worker errors
|
|
91
|
+
- do not accept repository identity, token scope, or worker command from PR-authored text
|
|
92
|
+
- keep local CLI usage independent from the hosted service
|
|
93
|
+
|
|
94
|
+
The exported helpers are `createHostedGitHubAppJwt`, `planHostedGitHubInstallationTokenRequest`, and `planHostedProductionWorkerExecution`.
|
|
95
|
+
|
|
73
96
|
## Webhook Event Parser
|
|
74
97
|
|
|
75
98
|
The webhook event parser runs after webhook signature verification. It converts a reduced GitHub `pull_request` webhook payload into a queue-safe scan request identity.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Hosted Production Adapters
|
|
2
|
+
|
|
3
|
+
This document describes the hosted production adapter layer implemented in `src/hosted/production-adapters.ts`.
|
|
4
|
+
|
|
5
|
+
It does not announce a public hosted service. The module provides provider-independent auth, token-request, worker-execution, timeout, output, and cleanup plans that can be wired to a real hosted platform after the operational release gate has evidence.
|
|
6
|
+
|
|
7
|
+
## What Exists
|
|
8
|
+
|
|
9
|
+
The package exports `ai-saas-guard/hosted/production-adapters` with:
|
|
10
|
+
|
|
11
|
+
- `createHostedGitHubAppJwt`
|
|
12
|
+
- `planHostedGitHubInstallationTokenRequest`
|
|
13
|
+
- `planHostedProductionWorkerExecution`
|
|
14
|
+
|
|
15
|
+
The layer covers the next hosted build step:
|
|
16
|
+
|
|
17
|
+
- GitHub App JWT generation with RS256 signing
|
|
18
|
+
- 60-second issued-at clock skew
|
|
19
|
+
- 10-minute maximum JWT expiration
|
|
20
|
+
- installation-token request planning for selected repositories only
|
|
21
|
+
- separate token scopes for worker checkout and Check Run publication
|
|
22
|
+
- no raw private key, app JWT, or installation token persistence in request plans
|
|
23
|
+
- fixed worker command from trusted runtime state, not pull request text
|
|
24
|
+
- bounded worker timeout and output budgets
|
|
25
|
+
- compact JSON-only worker output
|
|
26
|
+
- cleanup plans for success, failure, timeout, and cancellation
|
|
27
|
+
|
|
28
|
+
## GitHub App Auth Chain
|
|
29
|
+
|
|
30
|
+
The runtime path is intentionally split:
|
|
31
|
+
|
|
32
|
+
1. A secret provider reads the GitHub App private key at runtime.
|
|
33
|
+
2. `createHostedGitHubAppJwt` signs a short-lived App JWT.
|
|
34
|
+
3. `planHostedGitHubInstallationTokenRequest` creates a safe installation-token request plan.
|
|
35
|
+
4. A platform adapter injects the runtime App JWT into the HTTP request and receives the installation token.
|
|
36
|
+
5. The token is cached only until its GitHub `expires_at`, redacted from logs, and not persisted as report or queue state.
|
|
37
|
+
|
|
38
|
+
The safe request plan uses `authorization: "runtime_bearer_app_jwt"` instead of returning a bearer token. This prevents accidental serialization of live credentials in logs, job records, release notes, or tests.
|
|
39
|
+
|
|
40
|
+
## Token Scopes
|
|
41
|
+
|
|
42
|
+
| Purpose | Repository scope | Permissions |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `worker_checkout` | selected repository ID only | `contents: read`, `pull_requests: read` |
|
|
45
|
+
| `check_run_publication` | selected repository ID only | `checks: write` |
|
|
46
|
+
| `first_slice` | selected repository ID only | `contents: read`, `pull_requests: read`, `checks: write` |
|
|
47
|
+
|
|
48
|
+
`metadata: read` remains part of the GitHub App manifest permission contract, but GitHub installation token request bodies only include permissions that need explicit narrowing for the current operation.
|
|
49
|
+
|
|
50
|
+
## Worker Execution Boundary
|
|
51
|
+
|
|
52
|
+
`planHostedProductionWorkerExecution` composes the existing read-only worker contract with production execution limits:
|
|
53
|
+
|
|
54
|
+
- command: `ai-saas-guard`
|
|
55
|
+
- args: `pr-risk --root <worker-checkout> --base <trusted-base-sha> --json`
|
|
56
|
+
- shell: disabled
|
|
57
|
+
- network access: disabled for the CLI process
|
|
58
|
+
- write mode: read-only
|
|
59
|
+
- timeout: maximum 600 seconds
|
|
60
|
+
- output budget: maximum 1 MiB
|
|
61
|
+
- output retention: compact JSON only
|
|
62
|
+
|
|
63
|
+
The plan never returns:
|
|
64
|
+
|
|
65
|
+
- temporary checkout roots
|
|
66
|
+
- private checkout paths
|
|
67
|
+
- raw source
|
|
68
|
+
- raw diffs
|
|
69
|
+
- secrets
|
|
70
|
+
- customer payloads
|
|
71
|
+
- app JWTs
|
|
72
|
+
- installation tokens
|
|
73
|
+
|
|
74
|
+
## Cleanup Behavior
|
|
75
|
+
|
|
76
|
+
The production plan precomputes cleanup obligations for every terminal worker state:
|
|
77
|
+
|
|
78
|
+
- success
|
|
79
|
+
- failure
|
|
80
|
+
- timeout
|
|
81
|
+
- cancellation
|
|
82
|
+
|
|
83
|
+
Every terminal state schedules worker checkout deletion, credential removal, raw-source removal, raw-diff removal, generated-artifact removal, and compact audit metadata preservation. Cleanup failure remains a separate operator-review path in the lower-level hosted contracts.
|
|
84
|
+
|
|
85
|
+
## Adapter Interfaces
|
|
86
|
+
|
|
87
|
+
The module also defines minimal interfaces for real platform wiring:
|
|
88
|
+
|
|
89
|
+
- `HostedProductionSecretProvider`
|
|
90
|
+
- `HostedProductionInstallationTokenRequester`
|
|
91
|
+
- `HostedProductionWorkerAdapter`
|
|
92
|
+
|
|
93
|
+
These are intentionally small. A deployment can back them with Cloudflare, Fly.io, Render, AWS, GCP, Azure, or another platform without changing the scanner core.
|
|
94
|
+
|
|
95
|
+
## Current Status
|
|
96
|
+
|
|
97
|
+
The repository can now plan the production auth and worker boundary for the hosted GitHub App path. A public hosted environment still requires:
|
|
98
|
+
|
|
99
|
+
- public HTTPS webhook URL
|
|
100
|
+
- platform secret manager
|
|
101
|
+
- deployed ingress and worker containers
|
|
102
|
+
- managed durable queue
|
|
103
|
+
- compact report storage
|
|
104
|
+
- real GitHub Checks API publisher
|
|
105
|
+
- monitoring and alerting
|
|
106
|
+
- rollback and incident-response evidence
|
|
107
|
+
- hosted operational release gate evidence from the deployed artifact
|
|
108
|
+
|
|
109
|
+
Do not describe this module as a live hosted service. It is the production adapter layer needed before a live hosted service can be exposed.
|
|
110
|
+
|
|
111
|
+
## References
|
|
112
|
+
|
|
113
|
+
- GitHub Docs: [Generating a JSON Web Token for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app)
|
|
114
|
+
- GitHub Docs: [Generating an installation access token for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app)
|
|
@@ -41,6 +41,8 @@ Production deployments must provide durable adapters:
|
|
|
41
41
|
|
|
42
42
|
The exported `createInMemoryHostedServiceAdapters` is only for tests, local smoke runs, and examples. It is not a production queue or production data store.
|
|
43
43
|
|
|
44
|
+
The production adapter layer in [hosted-production-adapters.md](hosted-production-adapters.md) now defines the next boundary around this runtime: GitHub App JWT creation, selected-repository installation-token request planning, separate worker and Check Run token scopes, fixed read-only worker execution, bounded timeout/output settings, compact JSON-only output, and cleanup planning for success, failure, timeout, and cancellation.
|
|
45
|
+
|
|
44
46
|
## Privacy
|
|
45
47
|
|
|
46
48
|
The runtime intentionally returns safe planning and status objects only.
|
|
@@ -81,6 +83,7 @@ This runtime makes the hosted service implementation-ready inside the repository
|
|
|
81
83
|
- platform secret manager
|
|
82
84
|
- managed queue
|
|
83
85
|
- compact report storage
|
|
86
|
+
- production adapters wired to the platform secret manager and GitHub Checks API
|
|
84
87
|
- container image and digest
|
|
85
88
|
- live monitoring and rollback evidence
|
|
86
89
|
- hosted operational release gate evidence from the deployed artifact
|
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.20.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.20.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.20.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,10 +58,12 @@ 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, HTTPS URLs, container digest, secret references, and release-gate checks
|
|
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
|
+
- hosted production adapter layer document and helpers for RS256 GitHub App JWT creation, selected-repository installation-token request planning, separate worker/check-run token scopes, fixed read-only worker execution, timeout/output budgets, and cleanup planning for success, failure, timeout, and cancellation
|
|
63
|
+
- resource caps for repository text collection, including per-file, total-file, and total-byte scan budgets to reduce worst-case memory use
|
|
62
64
|
- 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
|
|
63
65
|
- implementation-ready hosted GitHub App permission contract for required permissions, optional PR comment permissions, selected repository installation, and out-of-scope broad permissions
|
|
64
|
-
- 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,
|
|
66
|
+
- 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, GitHub App deployment planning, and hosted production adapter planning
|
|
65
67
|
- GitHub issue templates for bug reports, false positives, false negatives, rule requests, and public-safe security reports
|
|
66
68
|
- CODEOWNERS for source, tests, docs, workflows, Action, and package metadata
|
|
67
69
|
- JSON output
|
|
@@ -132,7 +134,7 @@ CI:
|
|
|
132
134
|
Publishing:
|
|
133
135
|
|
|
134
136
|
- npm package: `ai-saas-guard`
|
|
135
|
-
- Current release line: `v0.
|
|
137
|
+
- Current release line: `v0.20.0`
|
|
136
138
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
137
139
|
- Trusted Publisher: GitHub Actions for `zr9959/ai-saas-guard`, workflow `npm-publish.yml`
|
|
138
140
|
- 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.20.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",
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
"./hosted/github-app": {
|
|
42
42
|
"types": "./dist/hosted/github-app.d.ts",
|
|
43
43
|
"default": "./dist/hosted/github-app.js"
|
|
44
|
+
},
|
|
45
|
+
"./hosted/production-adapters": {
|
|
46
|
+
"types": "./dist/hosted/production-adapters.d.ts",
|
|
47
|
+
"default": "./dist/hosted/production-adapters.js"
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"bin": {
|