ai-saas-guard 0.43.3 → 0.45.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 (69) hide show
  1. package/README.md +31 -19
  2. package/action.yml +4 -2
  3. package/dist/cli.js +17 -4
  4. package/dist/commands/scan.js +6 -13
  5. package/dist/context.js +3 -0
  6. package/dist/hosted/contracts.js +67 -35
  7. package/dist/hosted/production-adapters.js +2 -0
  8. package/dist/hosted/scan-worker/engine.d.ts +67 -0
  9. package/dist/hosted/scan-worker/engine.js +153 -0
  10. package/dist/hosted/scan-worker/job.d.ts +61 -0
  11. package/dist/hosted/scan-worker/job.js +113 -0
  12. package/dist/hosted/scan-worker/node-shims.d.ts +28 -0
  13. package/dist/hosted/scan-worker/node-shims.js +71 -0
  14. package/dist/hosted/scan-worker/worker.d.ts +31 -0
  15. package/dist/hosted/scan-worker/worker.js +112 -0
  16. package/dist/hosted/worker.js +7 -4
  17. package/dist/report/comment.d.ts +7 -0
  18. package/dist/report/comment.js +93 -0
  19. package/dist/report/findings.d.ts +1 -1
  20. package/dist/report/findings.js +16 -1
  21. package/dist/report/launchGate.js +9 -4
  22. package/dist/report/markdown.js +131 -111
  23. package/dist/report/presentation.d.ts +8 -0
  24. package/dist/report/presentation.js +151 -0
  25. package/dist/report/sarif.js +4 -3
  26. package/dist/report/summary.js +52 -93
  27. package/dist/report/terminal.js +79 -71
  28. package/dist/rules/catalog.js +28 -0
  29. package/dist/scanners/actions.js +22 -5
  30. package/dist/scanners/apiRoutes.js +60 -2
  31. package/dist/scanners/gitDiff.d.ts +1 -0
  32. package/dist/scanners/gitDiff.js +40 -3
  33. package/dist/scanners/mcp.js +31 -1
  34. package/dist/scanners/secrets.js +12 -4
  35. package/dist/scanners/silentSuccess.js +1 -1
  36. package/dist/scanners/stripe.js +27 -5
  37. package/dist/scanners/supabase.js +154 -11
  38. package/dist/stackInventory.js +22 -11
  39. package/dist/utils/files.d.ts +1 -1
  40. package/dist/utils/files.js +44 -12
  41. package/docs/CODEX_HANDOFF.md +25 -10
  42. package/docs/CODEX_RECENT_CHANGES.md +80 -1
  43. package/docs/CODEX_STATE.md +42 -11
  44. package/docs/CODEX_TODO.md +34 -9
  45. package/docs/README.zh-CN.md +30 -18
  46. package/docs/cross-project-traffic-evidence-2026-05-27.md +22 -17
  47. package/docs/demo-quickstart.md +1 -1
  48. package/docs/demo-terminal-output.txt +25 -17
  49. package/docs/demo-terminal-screenshot.svg +16 -13
  50. package/docs/design-partner-outreach-kit.md +114 -1
  51. package/docs/github-action.md +37 -0
  52. package/docs/hosted-operations-evidence.md +200 -0
  53. package/docs/hosted-pricing-draft.md +101 -0
  54. package/docs/hosted-uninstall-deletion-evidence-20261001.md +170 -0
  55. package/docs/launch-readiness-checklist.md +2 -2
  56. package/docs/project-handoff.md +19 -11
  57. package/docs/release-quality-audit-2026-07-15.md +172 -0
  58. package/docs/rules.md +26 -0
  59. package/docs/sample-launch-report.md +80 -86
  60. package/examples/case-study-ai-saas/package.json +1 -1
  61. package/hosted/cloudflare-worker/README.md +22 -5
  62. package/hosted/cloudflare-worker/src/index.js +474 -37
  63. package/hosted/source-checkout-worker/README.md +83 -0
  64. package/hosted/source-checkout-worker/build-bundle.mjs +50 -0
  65. package/hosted/source-checkout-worker/deploy-staging.py +150 -0
  66. package/hosted/source-checkout-worker/wrangler.jsonc +1 -0
  67. package/package.json +2 -2
  68. package/scripts/cross-project-discovery-check.mjs +36 -30
  69. 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,30 +117,37 @@ 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
 
119
124
  The report is designed to be read before launch or before merging an AI-heavy PR. A longer copy-paste example is in [docs/sample-launch-report.md](docs/sample-launch-report.md).
120
125
 
121
126
  ```text
122
- ai-saas-guard scan summary
123
- Findings: 19 findings: 2 critical, 6 high, 7 medium, 3 low, 1 info
124
- Launch gate: blocked: critical launch-readiness findings need review before inviting users
125
-
126
- Top risks:
127
- - CRITICAL stripe.webhook.missing-signature at app/api/stripe/webhook/route.ts:1 - Stripe webhook does not verify the Stripe signature
128
- - CRITICAL supabase.rls.broad-policy at supabase/migrations/001_accounts.sql:10 - Broad Supabase RLS policy on public.accounts
129
- - HIGH silent-success.swallowed-error at app/api/billing/checkout/route.ts:4 - Catch block may turn upstream failure into success
130
-
131
- Manual proof to run next:
132
- - Send a request without a valid Stripe signature and confirm the handler rejects it before changing entitlement state.
133
- - Run the generated two-account IDOR test and confirm User B cannot read, update, or delete User A resources.
134
- - Force the upstream billing call to fail and confirm the route returns an error, not fake success.
135
-
136
- Next steps
137
- - Fix critical and high trust-boundary findings first.
138
- - Run the manual proof steps in staging and confirm each risky path fails closed.
127
+ ai-saas-guard | SCAN SUMMARY
128
+ ----------------------------------------
129
+ Target /path/to/your-saas
130
+ Launch gate blocked: critical launch-readiness findings need review before inviting users
131
+ Findings 20 findings | 2 critical | 6 high | 8 medium | 3 low | 1 info
132
+ Decision Can a real user get access they should not have? Review auth, tenant ownership, Supabase RLS, webhook entitlement, and data mutation findings first.
133
+
134
+ TOP RISKS
135
+ 1. CRITICAL stripe.webhook.missing-signature at app/api/stripe/webhook/route.ts:1 - Stripe webhook does not verify the Stripe signature
136
+ 2. CRITICAL supabase.rls.broad-policy at supabase/migrations/001_accounts.sql:10 - Broad Supabase RLS policy on public.accounts
137
+ 3. HIGH silent-success.swallowed-error at app/api/billing/checkout/route.ts:4 - Catch block may turn upstream failure into success: app/api/billing/checkout/route.ts
138
+
139
+ MANUAL PROOF
140
+ 1. Send a request without a valid Stripe signature and confirm the handler rejects it before changing entitlement state.
141
+ 2. Run the generated two-account IDOR test and confirm User B cannot read, update, or delete User A resources.
142
+ 3. Force the upstream API, auth provider, billing provider, or database call to fail and confirm the route returns an error or disclosed degraded mode, not a fake success.
143
+
144
+ NEXT STEPS
145
+ 1. Fix critical and high trust-boundary findings first: auth/session, billing/webhook, tenant data, and silent-success paths.
146
+ 2. Run the manual proof steps above in staging and confirm each risky path fails closed.
147
+ 3. Treat low and info deploy/CI hygiene hints as cleanup after critical, high, and medium launch paths are understood.
148
+
149
+ FULL REPORT
150
+ Rerun without --summary, or use --json, --sarif, or --markdown where supported.
139
151
  ```
140
152
 
141
153
  ## What You Get
@@ -333,7 +345,7 @@ node dist/cli.js pr-risk --root /path/to/your-saas --base origin/main --json
333
345
  node dist/cli.js pr-risk --root /path/to/your-saas --base origin/main --markdown
334
346
  ```
335
347
 
336
- If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` instead of silently reporting a clean or empty diff. In GitHub Actions, use `actions/checkout` with `fetch-depth: 0` when you need merge-base comparison against `origin/main`.
348
+ If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` instead of silently reporting a clean or empty diff. `--base` accepts branch, tag, full ref, or commit SHA names only (letters, numbers, dots, underscores, slashes, hyphens); revision expressions such as `HEAD~3` are rejected so option-like or malformed values never reach git argument parsing. In GitHub Actions, use `actions/checkout` with `fetch-depth: 0` when you need merge-base comparison against `origin/main`.
337
349
 
338
350
  ## Commands
339
351
 
package/action.yml CHANGED
@@ -12,7 +12,7 @@ inputs:
12
12
  required: false
13
13
  default: ${{ github.workspace }}
14
14
  format:
15
- description: "Output format: terminal, json, sarif, markdown, or summary. Use summary for first-run CLI output, markdown for PR summaries, and sarif for code scanning."
15
+ description: "Output format: terminal, json, sarif, markdown, summary, or comment. Use summary for first-run CLI output, markdown for PR summaries, sarif for code scanning, and comment for PR-comment-ready markdown (pr-risk)."
16
16
  required: false
17
17
  default: terminal
18
18
  fail-on:
@@ -67,7 +67,7 @@ runs:
67
67
  esac
68
68
 
69
69
  case "${INPUT_FORMAT}" in
70
- terminal|json|sarif|markdown|summary) ;;
70
+ terminal|json|sarif|markdown|summary|comment) ;;
71
71
  *)
72
72
  echo "Invalid format input: ${INPUT_FORMAT}" >&2
73
73
  exit 2
@@ -102,6 +102,8 @@ runs:
102
102
  args+=("--markdown")
103
103
  elif [ "${INPUT_FORMAT}" = "summary" ]; then
104
104
  args+=("--summary")
105
+ elif [ "${INPUT_FORMAT}" = "comment" ]; then
106
+ args+=("--format" "comment")
105
107
  fi
106
108
 
107
109
  if [ "${INPUT_FAIL_ON}" != "none" ]; then
package/dist/cli.js CHANGED
@@ -2,11 +2,13 @@
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";
8
9
  import { formatSummaryReport } from "./report/summary.js";
9
10
  import { formatTerminalReport } from "./report/terminal.js";
11
+ import { isSafeGitBaseRef } from "./scanners/gitDiff.js";
10
12
  async function main(argv) {
11
13
  const args = parseArgs(argv);
12
14
  if (!args.command || args.command === "help") {
@@ -25,6 +27,9 @@ async function main(argv) {
25
27
  report = await scanRepository({ rootDir: args.rootDir });
26
28
  break;
27
29
  case "check-supabase":
30
+ if (args.doctor) {
31
+ process.stderr.write("Note: --doctor is deprecated; the Supabase RLS doctor section is always included.\n");
32
+ }
28
33
  report = await checkSupabase({ rootDir: args.rootDir, doctor: args.doctor });
29
34
  break;
30
35
  case "check-stripe":
@@ -81,8 +86,8 @@ function parseArgs(argv) {
81
86
  }
82
87
  if (arg === "--format") {
83
88
  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");
89
+ if (value !== "terminal" && value !== "json" && value !== "sarif" && value !== "markdown" && value !== "summary" && value !== "comment") {
90
+ throw new Error("--format requires terminal, json, sarif, markdown, summary, or comment");
86
91
  }
87
92
  result.format = value;
88
93
  index += 1;
@@ -116,6 +121,9 @@ function parseArgs(argv) {
116
121
  const value = argv[index + 1];
117
122
  if (!value)
118
123
  throw new Error("--base requires a branch or ref");
124
+ if (!isSafeGitBaseRef(value)) {
125
+ throw new Error("--base must be a safe branch or ref using letters, numbers, dots, underscores, slashes, or hyphens");
126
+ }
119
127
  result.base = value;
120
128
  index += 1;
121
129
  continue;
@@ -150,6 +158,8 @@ function formatReport(report, format) {
150
158
  return formatMarkdownReport(report);
151
159
  if (format === "summary")
152
160
  return `${formatSummaryReport(report)}\n`;
161
+ if (format === "comment")
162
+ return `${formatCommentReport(report)}\n`;
153
163
  return `${formatTerminalReport(report)}\n`;
154
164
  }
155
165
  function shouldFail(report, failOn) {
@@ -178,11 +188,11 @@ Repo-local launch-readiness scanner for AI-built SaaS apps.
178
188
  Usage:
179
189
  ai-saas-guard scan [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
180
190
  ai-saas-guard demo [--json|--markdown|--summary]
181
- ai-saas-guard check-supabase [--root <repo>] [--config <file>] [--doctor] [--json|--sarif|--summary] [--fail-on <severity>]
191
+ ai-saas-guard check-supabase [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
182
192
  ai-saas-guard check-stripe [--root <repo>] [--config <file>] [--json|--sarif|--summary] [--fail-on <severity>]
183
193
  ai-saas-guard check-mcp [--root <repo>] [--config <file>] [--policy-template] [--json|--sarif|--summary] [--fail-on <severity>]
184
194
  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>]
195
+ ai-saas-guard pr-risk [--root <repo>] [--config <file>] [--base <branch>] [--json|--sarif|--markdown|--summary|--format comment] [--fail-on <severity>]
186
196
 
187
197
  Defaults:
188
198
  - read-only
@@ -192,8 +202,11 @@ Defaults:
192
202
  - terminal output by default, JSON with --json
193
203
  - SARIF output for GitHub code scanning with --sarif
194
204
  - PR-focused markdown summary with --markdown
205
+ - PR-comment-ready markdown with pr-risk --format comment (paste into a review)
195
206
  - first-run launch summary with --summary
196
207
  - project config auto-loaded from .ai-saas-guard.json when present
208
+ - --doctor is deprecated: the Supabase RLS doctor section is always included in check-supabase output; the flag is still accepted but has no effect
209
+ - --base accepts branch, tag, full ref, or commit SHA names only (letters, numbers, dots, underscores, slashes, hyphens); revisions like HEAD~3 are rejected
197
210
  `;
198
211
  }
199
212
  main(process.argv.slice(2)).then((code) => {
@@ -16,7 +16,12 @@ export async function scanRepository(options) {
16
16
  scanSecrets(context),
17
17
  scanNextPublicEnv(context),
18
18
  checkStripe(context),
19
- stackInventory.databases.includes("supabase") ? checkSupabase(context) : createSkippedSupabaseReport(context.rootDir),
19
+ // Never gate on the stack inventory: it only recognizes standard
20
+ // layouts (supabase/ paths, package deps, policy syntax), so SQL
21
+ // migrations in non-standard paths would silently skip every Supabase
22
+ // rule while the scan still reported clear. checkSupabase already
23
+ // returns an empty report when it finds no Supabase context.
24
+ checkSupabase(context),
20
25
  checkMcp(context),
21
26
  scanApiRoutes(context),
22
27
  scanDeployConfig(context),
@@ -35,15 +40,3 @@ export async function scanRepository(options) {
35
40
  ...actionsReport.findings
36
41
  ]), { stackInventory, fileCollection: context.fileCollection });
37
42
  }
38
- function createSkippedSupabaseReport(rootDir) {
39
- return createReport("check-supabase", rootDir, [], {
40
- riskyTables: [],
41
- riskyPolicies: [],
42
- manualAuthorizationTest: [],
43
- doctor: {
44
- staticChecks: [],
45
- twoAccountVerificationSteps: [],
46
- sqlCookbook: []
47
- }
48
- });
49
- }
package/dist/context.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { collectTextFilesWithDiagnostics } from "./utils/files.js";
2
2
  export async function createScanContext(rootDir) {
3
3
  const collection = await collectTextFilesWithDiagnostics(rootDir);
4
+ if (collection.diagnostics.unreadableDirectories.includes(".")) {
5
+ throw new Error(`Could not read scan root: ${rootDir}`);
6
+ }
4
7
  const { files } = collection;
5
8
  const filesByPath = new Map(files.map((file) => [file.path, file]));
6
9
  return {
@@ -477,7 +477,7 @@ export function createCompactHostedReport(input) {
477
477
  export function createHostedCheckRunSummary(input) {
478
478
  const { report } = input;
479
479
  const totalFindings = getHostedReportFindingTotal(report);
480
- const localCliCommand = `npx ai-saas-guard@${report.scannerVersion} pr-risk --root . --base ${report.baseSha} --json`;
480
+ const localCliCommand = `npx ai-saas-guard@${safeHostedCliVersion(report.scannerVersion)} pr-risk --root . --base ${safeHostedBaseSha(report.baseSha)} --json`;
481
481
  const conclusion = resolveCheckRunConclusion(report, input.failOnSeverity);
482
482
  const launchGate = hostedLaunchGateVerdict(report);
483
483
  return {
@@ -486,7 +486,7 @@ export function createHostedCheckRunSummary(input) {
486
486
  output: {
487
487
  title: formatCheckRunTitle(totalFindings, conclusion, input.failOnSeverity),
488
488
  summary: [
489
- `Launch-risk gate: ${launchGate}. Launch gate: ${launchGate}.`,
489
+ `Launch gate: ${launchGate}. ${totalFindings} finding${totalFindings === 1 ? "" : "s"}.`,
490
490
  "Review task: inspect risk areas and files before merge.",
491
491
  "Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
492
492
  "Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification."
@@ -1146,54 +1146,61 @@ function formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate)
1146
1146
  const filesToReview = getHostedCheckRunFiles(report);
1147
1147
  const findingLines = report.evidence.length === 0
1148
1148
  ? ["No findings in the compact hosted report."]
1149
- : [
1150
- "| Severity | Rule | Evidence |",
1151
- "| --- | --- | --- |",
1152
- ...report.evidence.map((finding) => `| ${escapeMarkdownTableCell(finding.severity)} | ${escapeMarkdownTableCell(finding.ruleId)} | ${escapeMarkdownTableCell(formatFindingLocation(finding))} |`)
1153
- ];
1149
+ : report.evidence.flatMap((finding, index) => [
1150
+ `${index + 1}. **${escapeHostedMarkdownInline(finding.severity.toUpperCase())}** ${hostedMarkdownCode(finding.ruleId)}`,
1151
+ ` - Location: ${hostedMarkdownCode(formatFindingLocation(finding))}`
1152
+ ]);
1154
1153
  return [
1155
- "### AI SaaS Guard Launch-risk gate",
1154
+ "## AI SaaS Guard",
1156
1155
  "",
1157
- "Review task: inspect risk areas and files before merge.",
1158
- "Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
1159
- "Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
1156
+ `**Launch gate:** ${escapeHostedMarkdownInline(launchGate)}`,
1160
1157
  "",
1161
- `Launch gate: ${launchGate}`,
1162
- `Conclusion: ${conclusion}`,
1163
- `Local CLI: \`${localCliCommand}\``,
1164
- `Retention: compact report ${report.retentionDays} days; no raw source, diffs, secrets, or customer payloads.`,
1158
+ `**Conclusion:** ${escapeHostedMarkdownInline(conclusion)}`,
1165
1159
  "",
1166
- "Review categories:",
1167
- ...(categories.length === 0 ? ["- None"] : categories.map((category) => `- ${category}`)),
1160
+ `**Review categories:** ${categories.length === 0 ? "None" : categories.map(hostedMarkdownCode).join(", ")}`,
1168
1161
  "",
1169
- "Verification steps:",
1170
- "- Inspect the listed files locally before release or merge.",
1171
- "- Reproduce locally with the CLI command above.",
1172
- "- Prove changed auth, billing, data, deploy, or tests fail closed.",
1162
+ "> Review task: inspect the risk areas and files below before merge.",
1163
+ "> Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
1173
1164
  "",
1174
- "Risk areas:",
1165
+ `**Reproduce locally:** ${hostedMarkdownCode(localCliCommand)}`,
1166
+ "",
1167
+ "**Scope:** Selected repository only. No raw source, diffs, secrets, installation tokens, or customer payloads are included.",
1168
+ "",
1169
+ "### Review First",
1175
1170
  ...(riskAreas.length === 0
1176
- ? ["- None"]
1177
- : riskAreas.map((area) => `- ${area.name}: ${area.count} finding(s). Proof: ${area.proof}`)),
1171
+ ? ["No risk areas in the compact hosted report."]
1172
+ : riskAreas.map((area, index) => `${index + 1}. **${escapeHostedMarkdownInline(area.name)}** - ${area.count} finding(s). ${escapeHostedMarkdownInline(area.proof)}`)),
1173
+ "",
1174
+ "### Files",
1175
+ ...(filesToReview.length === 0
1176
+ ? ["No files in the review queue."]
1177
+ : filesToReview.map((file, index) => `${index + 1}. ${hostedMarkdownCode(file)}`)),
1178
1178
  "",
1179
- "Launch decision queue:",
1179
+ "### Findings",
1180
+ ...findingLines,
1181
+ "",
1182
+ "### Manual Proof",
1183
+ "1. Inspect each listed file locally before release or merge.",
1184
+ "2. Reproduce the result with the local CLI command below.",
1185
+ "3. Prove changed auth, billing, data, deploy, or tests fail closed.",
1186
+ "",
1187
+ "### Launch Decision Queue",
1180
1188
  "- Can a real user get access they should not have?",
1181
1189
  "- Can the app claim success when something failed?",
1182
1190
  "- Can launch infrastructure do too much damage?",
1183
1191
  "",
1184
- "Summary:",
1192
+ "### Scan Summary",
1185
1193
  ...severityOrder.map((severity) => `- ${capitalize(severity)}: ${report.summaryCounts[severity] ?? 0}`),
1186
1194
  "",
1187
- "Files to review first:",
1188
- ...(filesToReview.length === 0 ? ["- None"] : filesToReview.map((file) => `- ${file}`)),
1189
- "",
1190
- "Launch-boundary reviewer checklist:",
1195
+ "### Reviewer Checklist",
1191
1196
  "- What changed at the launch boundary?",
1192
1197
  "- Why this auth billing data or deploy decision is safe?",
1193
1198
  "- What manual test proves it fails closed?",
1194
1199
  "",
1195
- "Findings:",
1196
- ...findingLines
1200
+ "### Scope And Privacy",
1201
+ "- Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
1202
+ `- Retention: compact report ${report.retentionDays} days.`,
1203
+ "- No raw source, diffs, secrets, installation tokens, or customer payloads are included."
1197
1204
  ].join("\n");
1198
1205
  }
1199
1206
  function truncateMarkdown(markdown, maxMarkdownChars) {
@@ -1207,7 +1214,11 @@ function truncateMarkdown(markdown, maxMarkdownChars) {
1207
1214
  if (maxChars <= suffix.length) {
1208
1215
  return suffix.slice(0, maxChars);
1209
1216
  }
1210
- return `${markdown.slice(0, maxChars - suffix.length).trimEnd()}${suffix}`;
1217
+ const availableChars = maxChars - suffix.length;
1218
+ const candidate = markdown.slice(0, availableChars);
1219
+ const lastLineBreak = candidate.lastIndexOf("\n");
1220
+ const safePrefix = lastLineBreak > 0 ? candidate.slice(0, lastLineBreak) : candidate;
1221
+ return `${safePrefix.trimEnd()}${suffix}`;
1211
1222
  }
1212
1223
  function severityRank(severity) {
1213
1224
  const normalized = severity.toLowerCase();
@@ -1312,8 +1323,29 @@ function categoryForRuleId(ruleId) {
1312
1323
  function getHostedCheckRunFiles(report) {
1313
1324
  return [...new Set(report.evidence.map((finding) => finding.file))].slice(0, 10);
1314
1325
  }
1315
- function escapeMarkdownTableCell(value) {
1316
- return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replaceAll("\r", " ").replaceAll("\n", " ");
1326
+ function escapeHostedMarkdownInline(value) {
1327
+ return normalizeHostedMarkdownInline(value)
1328
+ .replaceAll("&", "&amp;")
1329
+ .replaceAll("<", "&lt;")
1330
+ .replaceAll(">", "&gt;")
1331
+ .replace(/([\\`*_[\]])/g, "\\$1")
1332
+ .replaceAll("|", "\\|");
1333
+ }
1334
+ function hostedMarkdownCode(value) {
1335
+ return `\`${normalizeHostedMarkdownInline(value).replaceAll("`", "'")}\``;
1336
+ }
1337
+ function normalizeHostedMarkdownInline(value) {
1338
+ return value
1339
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
1340
+ .replace(/[\r\n]+/g, " ")
1341
+ .replace(/\s+/g, " ")
1342
+ .trim();
1343
+ }
1344
+ function safeHostedCliVersion(value) {
1345
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) ? value : "latest";
1346
+ }
1347
+ function safeHostedBaseSha(value) {
1348
+ return /^[a-f0-9]{40}$/i.test(value) ? value : "<base-sha>";
1317
1349
  }
1318
1350
  function capitalize(value) {
1319
1351
  return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
@@ -198,6 +198,8 @@ function safeGitHubApiBaseUrl(apiBaseUrl) {
198
198
  url.password ||
199
199
  url.search ||
200
200
  url.hash ||
201
+ url.port ||
202
+ url.hostname.toLowerCase() !== "api.github.com" ||
201
203
  !isRootPath(url.pathname) ||
202
204
  isUnsafeHostedHostname(url.hostname)) {
203
205
  return undefined;
@@ -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 };