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.
- package/README.md +31 -19
- package/action.yml +4 -2
- package/dist/cli.js +17 -4
- package/dist/commands/scan.js +6 -13
- package/dist/context.js +3 -0
- package/dist/hosted/contracts.js +67 -35
- package/dist/hosted/production-adapters.js +2 -0
- 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/hosted/worker.js +7 -4
- package/dist/report/comment.d.ts +7 -0
- package/dist/report/comment.js +93 -0
- package/dist/report/findings.d.ts +1 -1
- package/dist/report/findings.js +16 -1
- package/dist/report/launchGate.js +9 -4
- package/dist/report/markdown.js +131 -111
- package/dist/report/presentation.d.ts +8 -0
- package/dist/report/presentation.js +151 -0
- package/dist/report/sarif.js +4 -3
- package/dist/report/summary.js +52 -93
- package/dist/report/terminal.js +79 -71
- package/dist/rules/catalog.js +28 -0
- package/dist/scanners/actions.js +22 -5
- package/dist/scanners/apiRoutes.js +60 -2
- package/dist/scanners/gitDiff.d.ts +1 -0
- package/dist/scanners/gitDiff.js +40 -3
- package/dist/scanners/mcp.js +31 -1
- package/dist/scanners/secrets.js +12 -4
- package/dist/scanners/silentSuccess.js +1 -1
- package/dist/scanners/stripe.js +27 -5
- package/dist/scanners/supabase.js +154 -11
- package/dist/stackInventory.js +22 -11
- package/dist/utils/files.d.ts +1 -1
- package/dist/utils/files.js +44 -12
- package/docs/CODEX_HANDOFF.md +25 -10
- package/docs/CODEX_RECENT_CHANGES.md +80 -1
- package/docs/CODEX_STATE.md +42 -11
- package/docs/CODEX_TODO.md +34 -9
- package/docs/README.zh-CN.md +30 -18
- 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 +25 -17
- package/docs/demo-terminal-screenshot.svg +16 -13
- 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 +19 -11
- package/docs/release-quality-audit-2026-07-15.md +172 -0
- package/docs/rules.md +26 -0
- package/docs/sample-launch-report.md +80 -86
- package/examples/case-study-ai-saas/package.json +1 -1
- package/hosted/cloudflare-worker/README.md +22 -5
- package/hosted/cloudflare-worker/src/index.js +474 -37
- 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,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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
|
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>] [--
|
|
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) => {
|
package/dist/commands/scan.js
CHANGED
|
@@ -16,7 +16,12 @@ export async function scanRepository(options) {
|
|
|
16
16
|
scanSecrets(context),
|
|
17
17
|
scanNextPublicEnv(context),
|
|
18
18
|
checkStripe(context),
|
|
19
|
-
|
|
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 {
|
package/dist/hosted/contracts.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
"
|
|
1154
|
+
"## AI SaaS Guard",
|
|
1156
1155
|
"",
|
|
1157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1167
|
-
...(categories.length === 0 ? ["- None"] : categories.map((category) => `- ${category}`)),
|
|
1160
|
+
`**Review categories:** ${categories.length === 0 ? "None" : categories.map(hostedMarkdownCode).join(", ")}`,
|
|
1168
1161
|
"",
|
|
1169
|
-
"
|
|
1170
|
-
"
|
|
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
|
-
|
|
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
|
-
? ["
|
|
1177
|
-
: riskAreas.map((area) =>
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
1196
|
-
|
|
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
|
-
|
|
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
|
|
1316
|
-
return value
|
|
1326
|
+
function escapeHostedMarkdownInline(value) {
|
|
1327
|
+
return normalizeHostedMarkdownInline(value)
|
|
1328
|
+
.replaceAll("&", "&")
|
|
1329
|
+
.replaceAll("<", "<")
|
|
1330
|
+
.replaceAll(">", ">")
|
|
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 };
|