ai-saas-guard 0.43.3 → 0.44.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 +7 -2
- package/dist/cli.js +12 -4
- package/dist/hosted/scan-worker/engine.d.ts +67 -0
- package/dist/hosted/scan-worker/engine.js +153 -0
- package/dist/hosted/scan-worker/job.d.ts +61 -0
- package/dist/hosted/scan-worker/job.js +113 -0
- package/dist/hosted/scan-worker/node-shims.d.ts +28 -0
- package/dist/hosted/scan-worker/node-shims.js +71 -0
- package/dist/hosted/scan-worker/worker.d.ts +31 -0
- package/dist/hosted/scan-worker/worker.js +112 -0
- package/dist/report/comment.d.ts +7 -0
- package/dist/report/comment.js +81 -0
- package/dist/rules/catalog.js +14 -0
- package/dist/scanners/actions.js +15 -3
- package/dist/scanners/apiRoutes.js +24 -1
- package/dist/scanners/secrets.js +8 -0
- package/dist/scanners/silentSuccess.js +1 -1
- package/dist/scanners/supabase.js +62 -10
- package/docs/CODEX_HANDOFF.md +9 -8
- package/docs/CODEX_RECENT_CHANGES.md +41 -1
- package/docs/CODEX_STATE.md +8 -8
- package/docs/README.zh-CN.md +7 -2
- package/docs/cross-project-traffic-evidence-2026-05-27.md +22 -17
- package/docs/demo-quickstart.md +1 -1
- package/docs/demo-terminal-output.txt +17 -11
- package/docs/demo-terminal-screenshot.svg +1 -1
- package/docs/design-partner-outreach-kit.md +114 -1
- package/docs/github-action.md +37 -0
- package/docs/hosted-operations-evidence.md +200 -0
- package/docs/hosted-pricing-draft.md +101 -0
- package/docs/hosted-uninstall-deletion-evidence-20261001.md +170 -0
- package/docs/launch-readiness-checklist.md +2 -2
- package/docs/project-handoff.md +7 -7
- package/docs/rules.md +24 -0
- package/examples/case-study-ai-saas/package.json +1 -1
- package/hosted/cloudflare-worker/README.md +16 -3
- package/hosted/cloudflare-worker/src/index.js +224 -15
- package/hosted/source-checkout-worker/README.md +83 -0
- package/hosted/source-checkout-worker/build-bundle.mjs +50 -0
- package/hosted/source-checkout-worker/deploy-staging.py +150 -0
- package/hosted/source-checkout-worker/wrangler.jsonc +1 -0
- package/package.json +2 -2
- package/scripts/cross-project-discovery-check.mjs +36 -30
- package/scripts/hosted-source-checkout-staging.mjs +620 -0
package/README.md
CHANGED
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
<a href="docs/release-quality-knowledge-base.md"><img alt="Release gate documented" src="https://img.shields.io/badge/release%20gate-documented-0f766e.svg"></a>
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
|
+
<p align="center">
|
|
29
|
+
<strong>Try the 30-second demo — no signup, no code upload, no LLM call:</strong><br>
|
|
30
|
+
<code>npx ai-saas-guard@latest demo --summary</code>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
28
33
|
---
|
|
29
34
|
|
|
30
35
|
## Before You Invite Users
|
|
@@ -112,7 +117,7 @@ Want to see the report before scanning your own repo?
|
|
|
112
117
|
npx ai-saas-guard@latest demo --summary
|
|
113
118
|
```
|
|
114
119
|
|
|
115
|
-
The demo command uses packaged public fixtures: `examples/demo-risky-saas` currently returns
|
|
120
|
+
The demo command uses packaged public fixtures: `examples/demo-risky-saas` currently returns 20 intentional findings across Stripe, Supabase, silent-success paths, Next/Vercel deploy hints, and GitHub Actions; `examples/demo-safe-saas` returns 0 findings for the same broad surfaces with safer static patterns. Rerun `demo` without `--summary` for the full human-readable report, or see [docs/demo-quickstart.md](docs/demo-quickstart.md) if you want to inspect the fixture files locally.
|
|
116
121
|
|
|
117
122
|
## See The Output
|
|
118
123
|
|
|
@@ -120,7 +125,7 @@ The report is designed to be read before launch or before merging an AI-heavy PR
|
|
|
120
125
|
|
|
121
126
|
```text
|
|
122
127
|
ai-saas-guard scan summary
|
|
123
|
-
Findings:
|
|
128
|
+
Findings: 20 findings: 2 critical, 6 high, 8 medium, 3 low, 1 info
|
|
124
129
|
Launch gate: blocked: critical launch-readiness findings need review before inviting users
|
|
125
130
|
|
|
126
131
|
Top risks:
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { applyGuardConfig, loadGuardConfig } from "./config.js";
|
|
4
4
|
import { checkActions, checkMcp, checkStripe, checkSupabase, classifyPrRisk, runShowcase, scanRepository } from "./index.js";
|
|
5
|
+
import { formatCommentReport } from "./report/comment.js";
|
|
5
6
|
import { formatJsonReport } from "./report/json.js";
|
|
6
7
|
import { formatMarkdownReport } from "./report/markdown.js";
|
|
7
8
|
import { formatSarifReport } from "./report/sarif.js";
|
|
@@ -25,6 +26,9 @@ async function main(argv) {
|
|
|
25
26
|
report = await scanRepository({ rootDir: args.rootDir });
|
|
26
27
|
break;
|
|
27
28
|
case "check-supabase":
|
|
29
|
+
if (args.doctor) {
|
|
30
|
+
process.stderr.write("Note: --doctor is deprecated; the Supabase RLS doctor section is always included.\n");
|
|
31
|
+
}
|
|
28
32
|
report = await checkSupabase({ rootDir: args.rootDir, doctor: args.doctor });
|
|
29
33
|
break;
|
|
30
34
|
case "check-stripe":
|
|
@@ -81,8 +85,8 @@ function parseArgs(argv) {
|
|
|
81
85
|
}
|
|
82
86
|
if (arg === "--format") {
|
|
83
87
|
const value = argv[index + 1];
|
|
84
|
-
if (value !== "terminal" && value !== "json" && value !== "sarif" && value !== "markdown" && value !== "summary") {
|
|
85
|
-
throw new Error("--format requires terminal, json, sarif, markdown, or
|
|
88
|
+
if (value !== "terminal" && value !== "json" && value !== "sarif" && value !== "markdown" && value !== "summary" && value !== "comment") {
|
|
89
|
+
throw new Error("--format requires terminal, json, sarif, markdown, summary, or comment");
|
|
86
90
|
}
|
|
87
91
|
result.format = value;
|
|
88
92
|
index += 1;
|
|
@@ -150,6 +154,8 @@ function formatReport(report, format) {
|
|
|
150
154
|
return formatMarkdownReport(report);
|
|
151
155
|
if (format === "summary")
|
|
152
156
|
return `${formatSummaryReport(report)}\n`;
|
|
157
|
+
if (format === "comment")
|
|
158
|
+
return `${formatCommentReport(report)}\n`;
|
|
153
159
|
return `${formatTerminalReport(report)}\n`;
|
|
154
160
|
}
|
|
155
161
|
function shouldFail(report, failOn) {
|
|
@@ -178,11 +184,11 @@ Repo-local launch-readiness scanner for AI-built SaaS apps.
|
|
|
178
184
|
Usage:
|
|
179
185
|
ai-saas-guard scan [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
|
|
180
186
|
ai-saas-guard demo [--json|--markdown|--summary]
|
|
181
|
-
ai-saas-guard check-supabase [--root <repo>] [--config <file>] [--
|
|
187
|
+
ai-saas-guard check-supabase [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
|
|
182
188
|
ai-saas-guard check-stripe [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
|
|
183
189
|
ai-saas-guard check-mcp [--root <repo>] [--config <file>] [--policy-template] [--json|--sarif|--summary] [--fail-on <severity>]
|
|
184
190
|
ai-saas-guard check-actions [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
|
|
185
|
-
ai-saas-guard pr-risk [--root <repo>] [--config <file>] [--base <branch>] [--json|--sarif|--markdown|--summary] [--fail-on <severity>]
|
|
191
|
+
ai-saas-guard pr-risk [--root <repo>] [--config <file>] [--base <branch>] [--json|--sarif|--markdown|--summary|--format comment] [--fail-on <severity>]
|
|
186
192
|
|
|
187
193
|
Defaults:
|
|
188
194
|
- read-only
|
|
@@ -192,8 +198,10 @@ Defaults:
|
|
|
192
198
|
- terminal output by default, JSON with --json
|
|
193
199
|
- SARIF output for GitHub code scanning with --sarif
|
|
194
200
|
- PR-focused markdown summary with --markdown
|
|
201
|
+
- PR-comment-ready markdown with pr-risk --format comment (paste into a review)
|
|
195
202
|
- first-run launch summary with --summary
|
|
196
203
|
- project config auto-loaded from .ai-saas-guard.json when present
|
|
204
|
+
- --doctor is deprecated: the Supabase RLS doctor section is always included in check-supabase output; the flag is still accepted but has no effect
|
|
197
205
|
`;
|
|
198
206
|
}
|
|
199
207
|
main(process.argv.slice(2)).then((code) => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real scanner engine, running inside the staging source-checkout worker.
|
|
3
|
+
*
|
|
4
|
+
* This is the same rule engine as the CLI: `classifyPrRisk` (the pr-risk
|
|
5
|
+
* review-queue engine) plus the full `scan` suite (secrets, Stripe, Supabase,
|
|
6
|
+
* MCP, API routes, deploy config, silent-success, Actions) executed over an
|
|
7
|
+
* in-memory ScanContext built from SHA-pinned file contents. No filesystem,
|
|
8
|
+
* no child processes, no network: pure deterministic rules over text.
|
|
9
|
+
*
|
|
10
|
+
* Outputs are compacted before leaving the worker: rule ID, severity, file,
|
|
11
|
+
* and line only. Raw source, raw diffs, PR text, matches, and snippets never
|
|
12
|
+
* leave the worker boundary.
|
|
13
|
+
*/
|
|
14
|
+
import type { Severity } from "../../types.js";
|
|
15
|
+
import { STAGING_PRIVACY_FLAGS, type ValidatedScanJob } from "./job.js";
|
|
16
|
+
export interface CompactFinding {
|
|
17
|
+
ruleId: string;
|
|
18
|
+
severity: Severity;
|
|
19
|
+
file: string;
|
|
20
|
+
line?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface PrRiskSummary {
|
|
23
|
+
categories: string[];
|
|
24
|
+
topRiskyFiles: Array<{
|
|
25
|
+
path: string;
|
|
26
|
+
score: number;
|
|
27
|
+
categories: string[];
|
|
28
|
+
}>;
|
|
29
|
+
reviewChecklist: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface SourceScanResult {
|
|
32
|
+
jobKey: string;
|
|
33
|
+
status: "completed";
|
|
34
|
+
scannerEngine: "cli-identical";
|
|
35
|
+
scannerVersion: string;
|
|
36
|
+
repositoryFullName: string;
|
|
37
|
+
pullRequestNumber: number;
|
|
38
|
+
baseSha: string;
|
|
39
|
+
headSha: string;
|
|
40
|
+
filesScanned: number;
|
|
41
|
+
startedAt: string;
|
|
42
|
+
completedAt: string;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
stages: Array<{
|
|
45
|
+
id: string;
|
|
46
|
+
ok: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
prRisk: PrRiskSummary;
|
|
49
|
+
summaryCounts: {
|
|
50
|
+
critical: number;
|
|
51
|
+
high: number;
|
|
52
|
+
medium: number;
|
|
53
|
+
low: number;
|
|
54
|
+
info: number;
|
|
55
|
+
total: number;
|
|
56
|
+
};
|
|
57
|
+
compactFindingCount: number;
|
|
58
|
+
compactFindings: CompactFinding[];
|
|
59
|
+
scannerErrors: string[];
|
|
60
|
+
privacy: typeof STAGING_PRIVACY_FLAGS;
|
|
61
|
+
}
|
|
62
|
+
export declare const MAX_COMPACT_FINDINGS = 200;
|
|
63
|
+
declare const SCAN_STAGES: readonly ["validate", "pr_risk", "full_scan", "compact"];
|
|
64
|
+
export declare function runSourceScan(job: ValidatedScanJob, options: {
|
|
65
|
+
scannerVersion: string;
|
|
66
|
+
}): Promise<SourceScanResult>;
|
|
67
|
+
export { SCAN_STAGES };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real scanner engine, running inside the staging source-checkout worker.
|
|
3
|
+
*
|
|
4
|
+
* This is the same rule engine as the CLI: `classifyPrRisk` (the pr-risk
|
|
5
|
+
* review-queue engine) plus the full `scan` suite (secrets, Stripe, Supabase,
|
|
6
|
+
* MCP, API routes, deploy config, silent-success, Actions) executed over an
|
|
7
|
+
* in-memory ScanContext built from SHA-pinned file contents. No filesystem,
|
|
8
|
+
* no child processes, no network: pure deterministic rules over text.
|
|
9
|
+
*
|
|
10
|
+
* Outputs are compacted before leaving the worker: rule ID, severity, file,
|
|
11
|
+
* and line only. Raw source, raw diffs, PR text, matches, and snippets never
|
|
12
|
+
* leave the worker boundary.
|
|
13
|
+
*/
|
|
14
|
+
import { checkActions } from "../../scanners/actions.js";
|
|
15
|
+
import { scanApiRoutes } from "../../scanners/apiRoutes.js";
|
|
16
|
+
import { scanDeployConfig } from "../../scanners/deploy.js";
|
|
17
|
+
import { classifyPrRisk } from "../../scanners/gitDiff.js";
|
|
18
|
+
import { checkMcp } from "../../scanners/mcp.js";
|
|
19
|
+
import { scanNextPublicEnv, scanSecrets } from "../../scanners/secrets.js";
|
|
20
|
+
import { scanSilentSuccess } from "../../scanners/silentSuccess.js";
|
|
21
|
+
import { checkStripe } from "../../scanners/stripe.js";
|
|
22
|
+
import { checkSupabase } from "../../scanners/supabase.js";
|
|
23
|
+
import { detectStackInventory } from "../../stackInventory.js";
|
|
24
|
+
import { summarizeFindings, uniqueFindings } from "../../report/findings.js";
|
|
25
|
+
import { STAGING_PRIVACY_FLAGS } from "./job.js";
|
|
26
|
+
export const MAX_COMPACT_FINDINGS = 200;
|
|
27
|
+
const SCAN_STAGES = ["validate", "pr_risk", "full_scan", "compact"];
|
|
28
|
+
function buildScanContext(files) {
|
|
29
|
+
const textFiles = files.map((file) => ({
|
|
30
|
+
path: file.path,
|
|
31
|
+
absolutePath: file.path,
|
|
32
|
+
content: file.content
|
|
33
|
+
}));
|
|
34
|
+
const diagnostics = {
|
|
35
|
+
filesScanned: textFiles.length,
|
|
36
|
+
bytesScanned: textFiles.reduce((total, file) => total + file.content.length, 0),
|
|
37
|
+
unreadableFiles: [],
|
|
38
|
+
unreadableDirectories: [],
|
|
39
|
+
skippedLargeFiles: [],
|
|
40
|
+
skippedBudgetFiles: [],
|
|
41
|
+
maxFilesReached: false,
|
|
42
|
+
maxTotalBytesReached: false
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
rootDir: ".",
|
|
46
|
+
files: textFiles,
|
|
47
|
+
filesByPath: new Map(textFiles.map((file) => [file.path, file])),
|
|
48
|
+
fileCollection: diagnostics,
|
|
49
|
+
getFiles: (predicate) => (predicate ? textFiles.filter(predicate) : [...textFiles])
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function compactFinding(finding) {
|
|
53
|
+
const evidence = finding.evidence?.[0];
|
|
54
|
+
const file = typeof evidence?.file === "string" && evidence.file.length > 0 ? evidence.file : undefined;
|
|
55
|
+
if (!file)
|
|
56
|
+
return undefined;
|
|
57
|
+
const compact = {
|
|
58
|
+
ruleId: finding.ruleId,
|
|
59
|
+
severity: finding.severity,
|
|
60
|
+
file
|
|
61
|
+
};
|
|
62
|
+
if (typeof evidence?.line === "number" && Number.isFinite(evidence.line)) {
|
|
63
|
+
compact.line = evidence.line;
|
|
64
|
+
}
|
|
65
|
+
return compact;
|
|
66
|
+
}
|
|
67
|
+
export async function runSourceScan(job, options) {
|
|
68
|
+
const startedAt = new Date().toISOString();
|
|
69
|
+
const startedMs = Date.now();
|
|
70
|
+
const stages = [{ id: "validate", ok: true }];
|
|
71
|
+
const scannerErrors = [];
|
|
72
|
+
const context = buildScanContext(job.files);
|
|
73
|
+
// Stage 1: the real pr-risk review-queue engine over the unified diff.
|
|
74
|
+
const prRiskReport = await classifyPrRisk({ rootDir: ".", diffText: job.diffText });
|
|
75
|
+
stages.push({ id: "pr_risk", ok: true });
|
|
76
|
+
// Stage 2: the full CLI `scan` suite over the in-memory file contents.
|
|
77
|
+
const scannerRuns = [
|
|
78
|
+
{ name: "scanSecrets", run: () => scanSecrets(context) },
|
|
79
|
+
{ name: "scanNextPublicEnv", run: () => scanNextPublicEnv(context) },
|
|
80
|
+
{ name: "checkStripe", run: async () => (await checkStripe(context)).findings },
|
|
81
|
+
{ name: "checkMcp", run: async () => (await checkMcp(context)).findings },
|
|
82
|
+
{ name: "scanApiRoutes", run: () => scanApiRoutes(context) },
|
|
83
|
+
{ name: "scanDeployConfig", run: () => scanDeployConfig(context) },
|
|
84
|
+
{ name: "scanSilentSuccess", run: () => scanSilentSuccess(context) },
|
|
85
|
+
{ name: "checkActions", run: async () => (await checkActions(context)).findings }
|
|
86
|
+
];
|
|
87
|
+
// checkSupabase mirrors the CLI: it only runs when the stack inventory
|
|
88
|
+
// detects Supabase usage in the scanned files.
|
|
89
|
+
const stackInventory = await detectStackInventory(context);
|
|
90
|
+
if (stackInventory.databases.includes("supabase")) {
|
|
91
|
+
scannerRuns.push({ name: "checkSupabase", run: async () => (await checkSupabase(context)).findings });
|
|
92
|
+
}
|
|
93
|
+
const collected = [...prRiskReport.findings];
|
|
94
|
+
for (const scanner of scannerRuns) {
|
|
95
|
+
try {
|
|
96
|
+
const findings = await scanner.run();
|
|
97
|
+
collected.push(...findings);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
scannerErrors.push(`${scanner.name}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
stages.push({ id: "full_scan", ok: true });
|
|
104
|
+
const merged = uniqueFindings(collected);
|
|
105
|
+
const summary = summarizeFindings(merged);
|
|
106
|
+
const compactFindings = [];
|
|
107
|
+
for (const finding of merged) {
|
|
108
|
+
const compact = compactFinding(finding);
|
|
109
|
+
if (compact)
|
|
110
|
+
compactFindings.push(compact);
|
|
111
|
+
if (compactFindings.length >= MAX_COMPACT_FINDINGS)
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
stages.push({ id: "compact", ok: true });
|
|
115
|
+
const completedAt = new Date().toISOString();
|
|
116
|
+
return {
|
|
117
|
+
jobKey: job.jobKey,
|
|
118
|
+
status: "completed",
|
|
119
|
+
scannerEngine: "cli-identical",
|
|
120
|
+
scannerVersion: options.scannerVersion,
|
|
121
|
+
repositoryFullName: job.repositoryFullName,
|
|
122
|
+
pullRequestNumber: job.pullRequestNumber,
|
|
123
|
+
baseSha: job.baseSha,
|
|
124
|
+
headSha: job.headSha,
|
|
125
|
+
filesScanned: job.files.length,
|
|
126
|
+
startedAt,
|
|
127
|
+
completedAt,
|
|
128
|
+
durationMs: Date.now() - startedMs,
|
|
129
|
+
stages,
|
|
130
|
+
prRisk: {
|
|
131
|
+
categories: [...prRiskReport.categories],
|
|
132
|
+
topRiskyFiles: prRiskReport.topRiskyFiles.map((file) => ({
|
|
133
|
+
path: file.path,
|
|
134
|
+
score: file.score,
|
|
135
|
+
categories: [...file.categories]
|
|
136
|
+
})),
|
|
137
|
+
reviewChecklist: [...prRiskReport.reviewChecklist]
|
|
138
|
+
},
|
|
139
|
+
summaryCounts: {
|
|
140
|
+
critical: summary.critical,
|
|
141
|
+
high: summary.high,
|
|
142
|
+
medium: summary.medium,
|
|
143
|
+
low: summary.low,
|
|
144
|
+
info: summary.info,
|
|
145
|
+
total: summary.total
|
|
146
|
+
},
|
|
147
|
+
compactFindingCount: compactFindings.length,
|
|
148
|
+
compactFindings,
|
|
149
|
+
scannerErrors,
|
|
150
|
+
privacy: STAGING_PRIVACY_FLAGS
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export { SCAN_STAGES };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scan-job validation for the staging source-checkout worker.
|
|
3
|
+
*
|
|
4
|
+
* Cloud-independent: no `cloudflare:workers` imports, safe under node:test.
|
|
5
|
+
* The worker receives PR source from the staging orchestrator (which fetches
|
|
6
|
+
* SHA-pinned file contents and the unified diff via the GitHub API). The
|
|
7
|
+
* worker never holds GitHub credentials and never touches the network for
|
|
8
|
+
* source: the trust boundary is the pinned head/base SHA pair plus the
|
|
9
|
+
* staging repository allowlist.
|
|
10
|
+
*/
|
|
11
|
+
export interface SourceFileInput {
|
|
12
|
+
path: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ScanJobRequestBody {
|
|
16
|
+
repositoryFullName?: unknown;
|
|
17
|
+
pullRequestNumber?: unknown;
|
|
18
|
+
baseSha?: unknown;
|
|
19
|
+
headSha?: unknown;
|
|
20
|
+
diffText?: unknown;
|
|
21
|
+
files?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface ValidatedScanJob {
|
|
24
|
+
jobKey: string;
|
|
25
|
+
repositoryFullName: string;
|
|
26
|
+
pullRequestNumber: number;
|
|
27
|
+
baseSha: string;
|
|
28
|
+
headSha: string;
|
|
29
|
+
diffText: string;
|
|
30
|
+
files: SourceFileInput[];
|
|
31
|
+
}
|
|
32
|
+
export declare const STAGING_ALLOWED_REPOSITORIES: ReadonlySet<string>;
|
|
33
|
+
export declare const SCAN_JOB_LIMITS: {
|
|
34
|
+
readonly maxFiles: 300;
|
|
35
|
+
readonly maxFileBytes: number;
|
|
36
|
+
readonly maxTotalBytes: number;
|
|
37
|
+
readonly maxDiffBytes: number;
|
|
38
|
+
readonly jobKeyMaxLength: 160;
|
|
39
|
+
};
|
|
40
|
+
export declare const STAGING_PRIVACY_FLAGS: {
|
|
41
|
+
readonly includesRawWebhookPayload: false;
|
|
42
|
+
readonly includesUntrustedPrText: false;
|
|
43
|
+
readonly includesRawSource: false;
|
|
44
|
+
readonly includesRawDiffs: false;
|
|
45
|
+
readonly includesSecrets: false;
|
|
46
|
+
readonly includesCustomerPayloads: false;
|
|
47
|
+
readonly includesPrivateCheckoutPath: false;
|
|
48
|
+
readonly includesInstallationToken: false;
|
|
49
|
+
readonly checkRunPublishedByWorker: false;
|
|
50
|
+
readonly persistsScanRecords: false;
|
|
51
|
+
};
|
|
52
|
+
export declare class ScanJobValidationError extends Error {
|
|
53
|
+
readonly code: string;
|
|
54
|
+
constructor(code: string, message: string);
|
|
55
|
+
}
|
|
56
|
+
export declare function buildScanJobKey(input: {
|
|
57
|
+
repositoryFullName: string;
|
|
58
|
+
pullRequestNumber: number;
|
|
59
|
+
headSha: string;
|
|
60
|
+
}): string;
|
|
61
|
+
export declare function validateScanJobRequest(body: ScanJobRequestBody): ValidatedScanJob;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scan-job validation for the staging source-checkout worker.
|
|
3
|
+
*
|
|
4
|
+
* Cloud-independent: no `cloudflare:workers` imports, safe under node:test.
|
|
5
|
+
* The worker receives PR source from the staging orchestrator (which fetches
|
|
6
|
+
* SHA-pinned file contents and the unified diff via the GitHub API). The
|
|
7
|
+
* worker never holds GitHub credentials and never touches the network for
|
|
8
|
+
* source: the trust boundary is the pinned head/base SHA pair plus the
|
|
9
|
+
* staging repository allowlist.
|
|
10
|
+
*/
|
|
11
|
+
export const STAGING_ALLOWED_REPOSITORIES = new Set(["zr9959/ai-saas-guard"]);
|
|
12
|
+
export const SCAN_JOB_LIMITS = {
|
|
13
|
+
maxFiles: 300,
|
|
14
|
+
maxFileBytes: 256 * 1024,
|
|
15
|
+
maxTotalBytes: 2 * 1024 * 1024,
|
|
16
|
+
maxDiffBytes: 2 * 1024 * 1024,
|
|
17
|
+
jobKeyMaxLength: 160
|
|
18
|
+
};
|
|
19
|
+
export const STAGING_PRIVACY_FLAGS = {
|
|
20
|
+
includesRawWebhookPayload: false,
|
|
21
|
+
includesUntrustedPrText: false,
|
|
22
|
+
includesRawSource: false,
|
|
23
|
+
includesRawDiffs: false,
|
|
24
|
+
includesSecrets: false,
|
|
25
|
+
includesCustomerPayloads: false,
|
|
26
|
+
includesPrivateCheckoutPath: false,
|
|
27
|
+
includesInstallationToken: false,
|
|
28
|
+
checkRunPublishedByWorker: false,
|
|
29
|
+
persistsScanRecords: false
|
|
30
|
+
};
|
|
31
|
+
const SHA_RE = /^[a-f0-9]{40}$/i;
|
|
32
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
33
|
+
const JOB_KEY_RE = /^[A-Za-z0-9:_\-.]{1,160}$/;
|
|
34
|
+
const textEncoder = new TextEncoder();
|
|
35
|
+
function utf8Bytes(value) {
|
|
36
|
+
return textEncoder.encode(value).length;
|
|
37
|
+
}
|
|
38
|
+
export class ScanJobValidationError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
constructor(code, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "ScanJobValidationError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function buildScanJobKey(input) {
|
|
47
|
+
const [owner, repo] = input.repositoryFullName.split("/");
|
|
48
|
+
return `scan:staging:${owner}:${repo}:${input.pullRequestNumber}:${input.headSha}`;
|
|
49
|
+
}
|
|
50
|
+
function asNonEmptyString(value) {
|
|
51
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
function validateSha(label, value) {
|
|
54
|
+
const text = asNonEmptyString(value);
|
|
55
|
+
if (!text || !SHA_RE.test(text)) {
|
|
56
|
+
throw new ScanJobValidationError("invalid_sha", `${label} must be a 40-character hex SHA`);
|
|
57
|
+
}
|
|
58
|
+
return text.toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
export function validateScanJobRequest(body) {
|
|
61
|
+
const repositoryFullName = asNonEmptyString(body?.repositoryFullName);
|
|
62
|
+
if (!repositoryFullName || !REPO_RE.test(repositoryFullName)) {
|
|
63
|
+
throw new ScanJobValidationError("invalid_repository", "repositoryFullName must look like owner/repo");
|
|
64
|
+
}
|
|
65
|
+
if (!STAGING_ALLOWED_REPOSITORIES.has(repositoryFullName)) {
|
|
66
|
+
throw new ScanJobValidationError("repository_not_allowed", "repository is not in the staging allowlist");
|
|
67
|
+
}
|
|
68
|
+
const pullRequestNumber = body?.pullRequestNumber;
|
|
69
|
+
if (typeof pullRequestNumber !== "number" || !Number.isInteger(pullRequestNumber) || pullRequestNumber <= 0) {
|
|
70
|
+
throw new ScanJobValidationError("invalid_pr_number", "pullRequestNumber must be a positive integer");
|
|
71
|
+
}
|
|
72
|
+
const baseSha = validateSha("baseSha", body?.baseSha);
|
|
73
|
+
const headSha = validateSha("headSha", body?.headSha);
|
|
74
|
+
const diffText = asNonEmptyString(body?.diffText);
|
|
75
|
+
if (!diffText) {
|
|
76
|
+
throw new ScanJobValidationError("diff_required", "diffText (unified diff) is required");
|
|
77
|
+
}
|
|
78
|
+
if (utf8Bytes(diffText) > SCAN_JOB_LIMITS.maxDiffBytes) {
|
|
79
|
+
throw new ScanJobValidationError("diff_too_large", "diffText exceeds the staging size cap");
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(body?.files) || body.files.length === 0) {
|
|
82
|
+
throw new ScanJobValidationError("files_required", "files (changed-file contents at head SHA) is required");
|
|
83
|
+
}
|
|
84
|
+
if (body.files.length > SCAN_JOB_LIMITS.maxFiles) {
|
|
85
|
+
throw new ScanJobValidationError("too_many_files", `files exceeds the staging cap of ${SCAN_JOB_LIMITS.maxFiles}`);
|
|
86
|
+
}
|
|
87
|
+
const files = [];
|
|
88
|
+
let totalBytes = 0;
|
|
89
|
+
for (const entry of body.files) {
|
|
90
|
+
const path = asNonEmptyString(entry?.path);
|
|
91
|
+
const content = typeof entry?.content === "string" ? entry.content : undefined;
|
|
92
|
+
if (!path || content === undefined) {
|
|
93
|
+
throw new ScanJobValidationError("invalid_file_entry", "every file entry needs { path, content }");
|
|
94
|
+
}
|
|
95
|
+
if (path.startsWith("/") || path.includes("..")) {
|
|
96
|
+
throw new ScanJobValidationError("invalid_file_path", "file paths must be repo-relative without traversal");
|
|
97
|
+
}
|
|
98
|
+
const bytes = utf8Bytes(content);
|
|
99
|
+
if (bytes > SCAN_JOB_LIMITS.maxFileBytes) {
|
|
100
|
+
throw new ScanJobValidationError("file_too_large", `file ${path} exceeds the per-file staging cap`);
|
|
101
|
+
}
|
|
102
|
+
totalBytes += bytes;
|
|
103
|
+
if (totalBytes > SCAN_JOB_LIMITS.maxTotalBytes) {
|
|
104
|
+
throw new ScanJobValidationError("total_too_large", "combined file contents exceed the staging cap");
|
|
105
|
+
}
|
|
106
|
+
files.push({ path, content });
|
|
107
|
+
}
|
|
108
|
+
const jobKey = buildScanJobKey({ repositoryFullName, pullRequestNumber, headSha });
|
|
109
|
+
if (!JOB_KEY_RE.test(jobKey) || jobKey.length > SCAN_JOB_LIMITS.jobKeyMaxLength) {
|
|
110
|
+
throw new ScanJobValidationError("invalid_job_key", "derived job key failed the charset check");
|
|
111
|
+
}
|
|
112
|
+
return { jobKey, repositoryFullName, pullRequestNumber, baseSha, headSha, diffText, files };
|
|
113
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* esbuild alias shims for node: builtins that do not exist in the Workers isolate.
|
|
3
|
+
*
|
|
4
|
+
* None of these code paths execute in the scan worker:
|
|
5
|
+
* - `node:child_process` execFile: only used by `readGitDiff` in
|
|
6
|
+
* `src/scanners/gitDiff.ts`, which is bypassed because the worker always
|
|
7
|
+
* passes explicit `diffText` to `classifyPrRisk`.
|
|
8
|
+
* - `node:fs/promises`: only used by file collectors (never called; the worker
|
|
9
|
+
* builds its ScanContext in memory) and by `fileMode` in `src/scanners/mcp.ts`,
|
|
10
|
+
* which catches the failure and treats the mode as unknown.
|
|
11
|
+
* - `node:util` promisify: only wraps execFile at module scope in gitDiff.ts.
|
|
12
|
+
* - `node:path` join/relative: only used by the file collectors.
|
|
13
|
+
*
|
|
14
|
+
* Aliasing keeps the bundle 100% platform-neutral so no nodejs_compat flag is
|
|
15
|
+
* required, and guarantees the real scanner engine (same code as the CLI) runs
|
|
16
|
+
* in the worker instead of a reimplementation.
|
|
17
|
+
*/
|
|
18
|
+
export declare function execFile(..._args: unknown[]): never;
|
|
19
|
+
export declare function promisify(fn: (...args: unknown[]) => void): (...args: unknown[]) => Promise<unknown>;
|
|
20
|
+
export declare function stat(..._args: unknown[]): Promise<never>;
|
|
21
|
+
export declare function readdir(..._args: unknown[]): Promise<never>;
|
|
22
|
+
export declare function readFile(..._args: unknown[]): Promise<never>;
|
|
23
|
+
export declare function join(...parts: string[]): string;
|
|
24
|
+
export declare function relative(_from: string, to: string): string;
|
|
25
|
+
export declare function resolve(...parts: string[]): string;
|
|
26
|
+
export declare function dirname(path: string): string;
|
|
27
|
+
export declare function basename(path: string): string;
|
|
28
|
+
export declare function extname(path: string): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* esbuild alias shims for node: builtins that do not exist in the Workers isolate.
|
|
3
|
+
*
|
|
4
|
+
* None of these code paths execute in the scan worker:
|
|
5
|
+
* - `node:child_process` execFile: only used by `readGitDiff` in
|
|
6
|
+
* `src/scanners/gitDiff.ts`, which is bypassed because the worker always
|
|
7
|
+
* passes explicit `diffText` to `classifyPrRisk`.
|
|
8
|
+
* - `node:fs/promises`: only used by file collectors (never called; the worker
|
|
9
|
+
* builds its ScanContext in memory) and by `fileMode` in `src/scanners/mcp.ts`,
|
|
10
|
+
* which catches the failure and treats the mode as unknown.
|
|
11
|
+
* - `node:util` promisify: only wraps execFile at module scope in gitDiff.ts.
|
|
12
|
+
* - `node:path` join/relative: only used by the file collectors.
|
|
13
|
+
*
|
|
14
|
+
* Aliasing keeps the bundle 100% platform-neutral so no nodejs_compat flag is
|
|
15
|
+
* required, and guarantees the real scanner engine (same code as the CLI) runs
|
|
16
|
+
* in the worker instead of a reimplementation.
|
|
17
|
+
*/
|
|
18
|
+
export function execFile(..._args) {
|
|
19
|
+
throw new Error("node:child_process is unavailable in the scan worker");
|
|
20
|
+
}
|
|
21
|
+
export function promisify(fn) {
|
|
22
|
+
return (...args) => new Promise((resolve, reject) => {
|
|
23
|
+
const callback = (err, ...results) => {
|
|
24
|
+
if (err) {
|
|
25
|
+
reject(err);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
reject(new Error("node:util.promisify stub is unavailable in the scan worker"));
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
fn(...args, callback);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
reject(error);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function stat(..._args) {
|
|
39
|
+
throw new Error("node:fs is unavailable in the scan worker");
|
|
40
|
+
}
|
|
41
|
+
export async function readdir(..._args) {
|
|
42
|
+
throw new Error("node:fs is unavailable in the scan worker");
|
|
43
|
+
}
|
|
44
|
+
export async function readFile(..._args) {
|
|
45
|
+
throw new Error("node:fs is unavailable in the scan worker");
|
|
46
|
+
}
|
|
47
|
+
export function join(...parts) {
|
|
48
|
+
return parts
|
|
49
|
+
.join("/")
|
|
50
|
+
.replace(/\/{2,}/g, "/")
|
|
51
|
+
.replace(/\/\.\//g, "/");
|
|
52
|
+
}
|
|
53
|
+
export function relative(_from, to) {
|
|
54
|
+
return to;
|
|
55
|
+
}
|
|
56
|
+
export function resolve(...parts) {
|
|
57
|
+
return join(...parts);
|
|
58
|
+
}
|
|
59
|
+
export function dirname(path) {
|
|
60
|
+
const index = path.lastIndexOf("/");
|
|
61
|
+
return index <= 0 ? "." : path.slice(0, index);
|
|
62
|
+
}
|
|
63
|
+
export function basename(path) {
|
|
64
|
+
const index = path.lastIndexOf("/");
|
|
65
|
+
return index === -1 ? path : path.slice(index + 1);
|
|
66
|
+
}
|
|
67
|
+
export function extname(path) {
|
|
68
|
+
const base = basename(path);
|
|
69
|
+
const index = base.lastIndexOf(".");
|
|
70
|
+
return index <= 0 ? "" : base.slice(index);
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-saas-guard staging source-checkout worker (plain Worker, no containers).
|
|
3
|
+
*
|
|
4
|
+
* Staging-only companion to the live webhook-ingress worker
|
|
5
|
+
* (`hosted/cloudflare-worker`, `ai-saas-guard-hosted`). It runs the REAL
|
|
6
|
+
* scanner engine — the identical rule code as the CLI (`classifyPrRisk` plus
|
|
7
|
+
* the full `scan` suite) — over SHA-pinned PR source supplied by the staging
|
|
8
|
+
* orchestrator. The live ingress worker only classifies PR file metadata;
|
|
9
|
+
* this worker executes all 66 deterministic rules over actual file contents.
|
|
10
|
+
*
|
|
11
|
+
* Trust and privacy design:
|
|
12
|
+
* - The worker holds NO GitHub credentials. The staging orchestrator fetches
|
|
13
|
+
* the unified diff and changed-file contents at the pinned head SHA via the
|
|
14
|
+
* GitHub API and POSTs them here. Nothing in the worker can mint tokens,
|
|
15
|
+
* clone repos, or publish Check Runs.
|
|
16
|
+
* - The staging repository allowlist plus full-SHA validation is the trust
|
|
17
|
+
* boundary: content is addressable by SHA, which is stronger than
|
|
18
|
+
* branch-name pinning.
|
|
19
|
+
* - Stateless: nothing is persisted. No KV, no Durable Objects, no logs of
|
|
20
|
+
* request bodies. Cleanup is trivially complete because nothing is stored.
|
|
21
|
+
* - Only compact findings (ruleId, severity, file, line) leave the worker.
|
|
22
|
+
* Raw source, raw diffs, PR text, matches, and snippets never do.
|
|
23
|
+
*/
|
|
24
|
+
interface WorkerEnv {
|
|
25
|
+
SCANNER_VERSION?: string;
|
|
26
|
+
SCAN_SECRET?: string;
|
|
27
|
+
}
|
|
28
|
+
declare const _default: {
|
|
29
|
+
fetch(request: Request, env: WorkerEnv): Promise<Response>;
|
|
30
|
+
};
|
|
31
|
+
export default _default;
|