ai-saas-guard 0.43.2 → 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.
Files changed (45) hide show
  1. package/README.md +12 -7
  2. package/dist/cli.js +12 -4
  3. package/dist/hosted/scan-worker/engine.d.ts +67 -0
  4. package/dist/hosted/scan-worker/engine.js +153 -0
  5. package/dist/hosted/scan-worker/job.d.ts +61 -0
  6. package/dist/hosted/scan-worker/job.js +113 -0
  7. package/dist/hosted/scan-worker/node-shims.d.ts +28 -0
  8. package/dist/hosted/scan-worker/node-shims.js +71 -0
  9. package/dist/hosted/scan-worker/worker.d.ts +31 -0
  10. package/dist/hosted/scan-worker/worker.js +112 -0
  11. package/dist/report/comment.d.ts +7 -0
  12. package/dist/report/comment.js +81 -0
  13. package/dist/rules/catalog.js +14 -0
  14. package/dist/scanners/actions.js +15 -3
  15. package/dist/scanners/apiRoutes.js +24 -1
  16. package/dist/scanners/secrets.js +8 -0
  17. package/dist/scanners/silentSuccess.js +50 -4
  18. package/dist/scanners/supabase.js +62 -10
  19. package/docs/CODEX_HANDOFF.md +10 -8
  20. package/docs/CODEX_RECENT_CHANGES.md +41 -1
  21. package/docs/CODEX_STATE.md +8 -8
  22. package/docs/README.zh-CN.md +12 -7
  23. package/docs/cross-project-traffic-evidence-2026-05-27.md +22 -17
  24. package/docs/demo-quickstart.md +1 -1
  25. package/docs/demo-terminal-output.txt +17 -11
  26. package/docs/demo-terminal-screenshot.svg +1 -1
  27. package/docs/design-partner-outreach-kit.md +114 -1
  28. package/docs/github-action.md +37 -0
  29. package/docs/hosted-operations-evidence.md +200 -0
  30. package/docs/hosted-pricing-draft.md +101 -0
  31. package/docs/hosted-uninstall-deletion-evidence-20261001.md +170 -0
  32. package/docs/launch-readiness-checklist.md +2 -2
  33. package/docs/npm-publishing.md +3 -3
  34. package/docs/project-handoff.md +7 -7
  35. package/docs/rules.md +24 -0
  36. package/examples/case-study-ai-saas/package.json +1 -1
  37. package/hosted/cloudflare-worker/README.md +16 -3
  38. package/hosted/cloudflare-worker/src/index.js +224 -15
  39. package/hosted/source-checkout-worker/README.md +83 -0
  40. package/hosted/source-checkout-worker/build-bundle.mjs +50 -0
  41. package/hosted/source-checkout-worker/deploy-staging.py +150 -0
  42. package/hosted/source-checkout-worker/wrangler.jsonc +1 -0
  43. package/package.json +2 -2
  44. package/scripts/cross-project-discovery-check.mjs +36 -30
  45. 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 19 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.
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: 19 findings: 2 critical, 6 high, 7 medium, 3 low, 1 info
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:
@@ -248,13 +253,13 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
248
253
  | Area | Status |
249
254
  | --- | --- |
250
255
  | Public GitHub repository | Available |
251
- | npm CLI | `ai-saas-guard@0.43.2` |
252
- | GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.43.2` |
256
+ | npm CLI | `ai-saas-guard@0.43.3` |
257
+ | GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.43.3` |
253
258
  | Outputs | Launch decision queue, short summary, terminal, JSON, SARIF, and PR-focused markdown |
254
259
  | Project config | `.ai-saas-guard.json` rule toggles, severity overrides, suppressions, and fail thresholds |
255
260
  | Privacy model | Local-first, read-only scan commands, no LLM calls, no code upload |
256
- | Versioned Action tags | `v0.43.2`, `v0` |
257
- | Current release | `0.43.2` fixes hosted cleanup observability, scoped smoke KV cleanup, shallow-history PR diff fallback, Check Run Markdown escaping, and local scan coverage diagnostics while keeping billing disabled |
261
+ | Versioned Action tags | `v0.43.3`, `v0` |
262
+ | Current release | `0.43.3` reduces silent-success false positives for Cloudflare Durable Object stubs, benign null-return parsing/cache reads, configuration fallback parameters, and assertion-rich tests while keeping billing disabled |
258
263
  | npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
259
264
  | Repository trust hardening | Strict branch protection, Dependabot, CodeQL, fast-check fuzzing, signed release provenance assets, private vulnerability reporting, secret scanning, and push protection |
260
265
  | Cloudflare hosted ingress | Deployed at `https://ai-saas-guard-hosted.zr9959.workers.dev`; public install/privacy notes are in [docs/hosted-install-privacy.md](docs/hosted-install-privacy.md); signed GitHub App webhook delivery and compact Check Run smoke now pass in staging |
@@ -450,7 +455,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
450
455
 
451
456
  ## GitHub Action
452
457
 
453
- The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.43.2` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
458
+ The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.43.3` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
454
459
 
455
460
  ```yaml
456
461
  name: ai-saas-guard
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 summary");
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>] [--doctor] [--json|--sarif|--summary] [--fail-on <severity>]
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;