ai-saas-guard 0.1.1

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +285 -0
  3. package/action.yml +100 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +156 -0
  6. package/dist/commands/checkMcp.d.ts +2 -0
  7. package/dist/commands/checkMcp.js +4 -0
  8. package/dist/commands/checkStripe.d.ts +2 -0
  9. package/dist/commands/checkStripe.js +4 -0
  10. package/dist/commands/checkSupabase.d.ts +2 -0
  11. package/dist/commands/checkSupabase.js +4 -0
  12. package/dist/commands/prRisk.d.ts +2 -0
  13. package/dist/commands/prRisk.js +4 -0
  14. package/dist/commands/scan.d.ts +2 -0
  15. package/dist/commands/scan.js +29 -0
  16. package/dist/context.d.ts +10 -0
  17. package/dist/context.js +16 -0
  18. package/dist/index.d.ts +10 -0
  19. package/dist/index.js +7 -0
  20. package/dist/report/findings.d.ts +6 -0
  21. package/dist/report/findings.js +49 -0
  22. package/dist/report/json.d.ts +2 -0
  23. package/dist/report/json.js +3 -0
  24. package/dist/report/sarif.d.ts +2 -0
  25. package/dist/report/sarif.js +62 -0
  26. package/dist/report/terminal.d.ts +3 -0
  27. package/dist/report/terminal.js +31 -0
  28. package/dist/rules/catalog.d.ts +11 -0
  29. package/dist/rules/catalog.js +208 -0
  30. package/dist/scanners/apiRoutes.d.ts +3 -0
  31. package/dist/scanners/apiRoutes.js +52 -0
  32. package/dist/scanners/deploy.d.ts +3 -0
  33. package/dist/scanners/deploy.js +56 -0
  34. package/dist/scanners/gitDiff.d.ts +2 -0
  35. package/dist/scanners/gitDiff.js +246 -0
  36. package/dist/scanners/mcp.d.ts +3 -0
  37. package/dist/scanners/mcp.js +180 -0
  38. package/dist/scanners/secrets.d.ts +5 -0
  39. package/dist/scanners/secrets.js +122 -0
  40. package/dist/scanners/stripe.d.ts +3 -0
  41. package/dist/scanners/stripe.js +166 -0
  42. package/dist/scanners/supabase.d.ts +3 -0
  43. package/dist/scanners/supabase.js +126 -0
  44. package/dist/types.d.ts +92 -0
  45. package/dist/types.js +1 -0
  46. package/dist/utils/files.d.ts +11 -0
  47. package/dist/utils/files.js +116 -0
  48. package/docs/npm-publishing.md +59 -0
  49. package/docs/positioning.md +66 -0
  50. package/docs/project-handoff.md +208 -0
  51. package/docs/release-quality-knowledge-base.md +482 -0
  52. package/docs/rules.md +75 -0
  53. package/examples/sample-report.md +41 -0
  54. package/package.json +58 -0
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { scanRepository } from "./commands/scan.js";
2
+ export { checkStripe } from "./commands/checkStripe.js";
3
+ export { checkSupabase } from "./commands/checkSupabase.js";
4
+ export { checkMcp } from "./commands/checkMcp.js";
5
+ export { classifyPrRisk } from "./commands/prRisk.js";
6
+ export { createScanContext } from "./context.js";
7
+ export { getRuleMetadata, RULE_CATALOG } from "./rules/catalog.js";
@@ -0,0 +1,6 @@
1
+ import type { BaseReport, CommandName, Finding, Summary } from "../types.js";
2
+ export declare function summarizeFindings(findings: Finding[]): Summary;
3
+ export declare function createReport<T extends BaseReport>(command: CommandName, rootDir: string, findings: Finding[], extra: Omit<T, keyof BaseReport | "command">): T;
4
+ export declare function sortFindings(findings: Finding[]): Finding[];
5
+ export declare function finding(input: Finding): Finding;
6
+ export declare function uniqueFindings(findings: Finding[]): Finding[];
@@ -0,0 +1,49 @@
1
+ const severities = ["critical", "high", "medium", "low", "info"];
2
+ export function summarizeFindings(findings) {
3
+ const summary = {
4
+ critical: 0,
5
+ high: 0,
6
+ medium: 0,
7
+ low: 0,
8
+ info: 0,
9
+ total: findings.length
10
+ };
11
+ for (const finding of findings) {
12
+ summary[finding.severity] += 1;
13
+ }
14
+ return summary;
15
+ }
16
+ export function createReport(command, rootDir, findings, extra) {
17
+ return {
18
+ command,
19
+ rootDir,
20
+ generatedAt: new Date().toISOString(),
21
+ findings: sortFindings(findings),
22
+ summary: summarizeFindings(findings),
23
+ ...extra
24
+ };
25
+ }
26
+ export function sortFindings(findings) {
27
+ return [...findings].sort((a, b) => {
28
+ const severityDelta = severities.indexOf(a.severity) - severities.indexOf(b.severity);
29
+ if (severityDelta !== 0)
30
+ return severityDelta;
31
+ return a.ruleId.localeCompare(b.ruleId);
32
+ });
33
+ }
34
+ export function finding(input) {
35
+ return input;
36
+ }
37
+ export function uniqueFindings(findings) {
38
+ const seen = new Set();
39
+ const result = [];
40
+ for (const item of findings) {
41
+ const firstEvidence = item.evidence[0];
42
+ const key = `${item.ruleId}:${firstEvidence?.file ?? ""}:${firstEvidence?.line ?? ""}:${item.title}`;
43
+ if (seen.has(key))
44
+ continue;
45
+ seen.add(key);
46
+ result.push(item);
47
+ }
48
+ return result;
49
+ }
@@ -0,0 +1,2 @@
1
+ import type { BaseReport } from "../types.js";
2
+ export declare function formatJsonReport(report: BaseReport): string;
@@ -0,0 +1,3 @@
1
+ export function formatJsonReport(report) {
2
+ return `${JSON.stringify(report, null, 2)}\n`;
3
+ }
@@ -0,0 +1,2 @@
1
+ import type { BaseReport } from "../types.js";
2
+ export declare function formatSarifReport(report: BaseReport): string;
@@ -0,0 +1,62 @@
1
+ import { getRuleMetadata } from "../rules/catalog.js";
2
+ export function formatSarifReport(report) {
3
+ const rules = new Map();
4
+ for (const finding of report.findings) {
5
+ if (rules.has(finding.ruleId))
6
+ continue;
7
+ const metadata = getRuleMetadata(finding.ruleId);
8
+ rules.set(finding.ruleId, {
9
+ id: finding.ruleId,
10
+ name: finding.ruleId,
11
+ shortDescription: { text: metadata?.title ?? finding.title },
12
+ fullDescription: { text: metadata?.why ?? finding.why },
13
+ help: { text: `${finding.suggestedVerification}\n\nFix direction: ${finding.suggestedFix}` },
14
+ defaultConfiguration: { level: sarifLevel(finding) }
15
+ });
16
+ }
17
+ return `${JSON.stringify({
18
+ version: "2.1.0",
19
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
20
+ runs: [
21
+ {
22
+ tool: {
23
+ driver: {
24
+ name: "ai-saas-guard",
25
+ informationUri: "https://github.com/zr9959/ai-saas-guard",
26
+ rules: [...rules.values()]
27
+ }
28
+ },
29
+ results: report.findings.map((finding) => sarifResult(finding))
30
+ }
31
+ ]
32
+ }, null, 2)}\n`;
33
+ }
34
+ function sarifResult(finding) {
35
+ const evidence = finding.evidence[0] ?? { file: "." };
36
+ return {
37
+ ruleId: finding.ruleId,
38
+ level: sarifLevel(finding),
39
+ message: {
40
+ text: `${finding.title}\n\nWhy: ${finding.why}\n\nVerify: ${finding.suggestedVerification}\n\nFix direction: ${finding.suggestedFix}`
41
+ },
42
+ locations: [
43
+ {
44
+ physicalLocation: {
45
+ artifactLocation: {
46
+ uri: evidence.file
47
+ },
48
+ region: {
49
+ startLine: evidence.line ?? 1
50
+ }
51
+ }
52
+ }
53
+ ]
54
+ };
55
+ }
56
+ function sarifLevel(finding) {
57
+ if (finding.severity === "critical" || finding.severity === "high")
58
+ return "error";
59
+ if (finding.severity === "medium" || finding.severity === "low")
60
+ return "warning";
61
+ return "note";
62
+ }
@@ -0,0 +1,3 @@
1
+ import type { BaseReport, Finding } from "../types.js";
2
+ export declare function formatTerminalReport(report: BaseReport): string;
3
+ export declare function formatFindingSummary(finding: Finding): string;
@@ -0,0 +1,31 @@
1
+ export function formatTerminalReport(report) {
2
+ const lines = [];
3
+ lines.push(`ai-saas-guard ${report.command}`);
4
+ lines.push(`Root: ${report.rootDir}`);
5
+ lines.push(`Findings: ${report.summary.total} total | critical ${report.summary.critical} | high ${report.summary.high} | medium ${report.summary.medium} | low ${report.summary.low} | info ${report.summary.info}`);
6
+ if (report.findings.length === 0) {
7
+ lines.push("");
8
+ lines.push("No heuristic launch-readiness risks found by this command.");
9
+ return lines.join("\n");
10
+ }
11
+ for (const [index, item] of report.findings.entries()) {
12
+ lines.push("");
13
+ lines.push(`${index + 1}. [${item.severity.toUpperCase()}] ${item.title}`);
14
+ lines.push(` Rule: ${item.ruleId}`);
15
+ lines.push(` Why: ${item.why}`);
16
+ lines.push(` Verify: ${item.suggestedVerification}`);
17
+ lines.push(` Fix direction: ${item.suggestedFix}`);
18
+ lines.push(" Evidence:");
19
+ for (const evidence of item.evidence.slice(0, 5)) {
20
+ const location = evidence.line ? `${evidence.file}:${evidence.line}` : evidence.file;
21
+ const detail = evidence.snippet ?? evidence.match ?? "";
22
+ lines.push(` - ${location}${detail ? ` -> ${detail}` : ""}`);
23
+ }
24
+ }
25
+ return lines.join("\n");
26
+ }
27
+ export function formatFindingSummary(finding) {
28
+ const firstEvidence = finding.evidence[0];
29
+ const location = firstEvidence?.line ? `${firstEvidence.file}:${firstEvidence.line}` : firstEvidence?.file;
30
+ return `${finding.severity.toUpperCase()} ${finding.ruleId}${location ? ` ${location}` : ""}`;
31
+ }
@@ -0,0 +1,11 @@
1
+ import type { Severity } from "../types.js";
2
+ export type RuleStability = "default" | "experimental" | "strict";
3
+ export interface RuleMetadata {
4
+ ruleId: string;
5
+ severity: Severity;
6
+ title: string;
7
+ why: string;
8
+ stability: RuleStability;
9
+ }
10
+ export declare const RULE_CATALOG: Record<string, RuleMetadata>;
11
+ export declare function getRuleMetadata(ruleId: string): RuleMetadata | undefined;
@@ -0,0 +1,208 @@
1
+ export const RULE_CATALOG = {
2
+ "secrets.detected": {
3
+ ruleId: "secrets.detected",
4
+ severity: "high",
5
+ title: "Secret-like value detected",
6
+ why: "Credentials committed to source, config, or examples can be exposed before launch.",
7
+ stability: "default"
8
+ },
9
+ "next.env.public-secret": {
10
+ ruleId: "next.env.public-secret",
11
+ severity: "high",
12
+ title: "Risky NEXT_PUBLIC environment variable",
13
+ why: "Next.js exposes NEXT_PUBLIC variables to browser code, so secret-like values can leak to users.",
14
+ stability: "default"
15
+ },
16
+ "stripe.webhook.missing-route": {
17
+ ruleId: "stripe.webhook.missing-route",
18
+ severity: "medium",
19
+ title: "No Stripe webhook handler found",
20
+ why: "Stripe checkout redirects are not a reliable source of billing truth.",
21
+ stability: "default"
22
+ },
23
+ "stripe.webhook.missing-signature": {
24
+ ruleId: "stripe.webhook.missing-signature",
25
+ severity: "critical",
26
+ title: "Stripe webhook does not verify the Stripe signature",
27
+ why: "Unsigned webhook handlers can accept forged billing events.",
28
+ stability: "default"
29
+ },
30
+ "stripe.webhook.raw-body-risk": {
31
+ ruleId: "stripe.webhook.raw-body-risk",
32
+ severity: "high",
33
+ title: "Stripe signature verification may use a parsed JSON body",
34
+ why: "Stripe signature checks require the exact raw request body.",
35
+ stability: "default"
36
+ },
37
+ "stripe.webhook.public-secret": {
38
+ ruleId: "stripe.webhook.public-secret",
39
+ severity: "critical",
40
+ title: "Stripe signing secret appears public",
41
+ why: "Public Stripe secrets can be bundled into client code and abused.",
42
+ stability: "default"
43
+ },
44
+ "stripe.webhook.missing-idempotency": {
45
+ ruleId: "stripe.webhook.missing-idempotency",
46
+ severity: "high",
47
+ title: "Stripe webhook lacks duplicate event idempotency",
48
+ why: "Stripe retries and duplicate events can drift billing state.",
49
+ stability: "default"
50
+ },
51
+ "stripe.webhook.no-entitlement-path": {
52
+ ruleId: "stripe.webhook.no-entitlement-path",
53
+ severity: "medium",
54
+ title: "Stripe webhook does not show an entitlement update path",
55
+ why: "Returning HTTP 200 is not the same as changing application access state.",
56
+ stability: "default"
57
+ },
58
+ "stripe.webhook.missing-critical-event": {
59
+ ruleId: "stripe.webhook.missing-critical-event",
60
+ severity: "high",
61
+ title: "Stripe webhook does not handle a critical lifecycle event",
62
+ why: "Failure, cancellation, update, and refund paths need explicit handling.",
63
+ stability: "default"
64
+ },
65
+ "supabase.rls.broad-policy": {
66
+ ruleId: "supabase.rls.broad-policy",
67
+ severity: "critical",
68
+ title: "Broad Supabase RLS policy",
69
+ why: "`USING (true)` often turns login into public data access.",
70
+ stability: "default"
71
+ },
72
+ "supabase.rls.missing-ownership-filter": {
73
+ ruleId: "supabase.rls.missing-ownership-filter",
74
+ severity: "high",
75
+ title: "Supabase policy lacks an ownership filter",
76
+ why: "Policies need resource ownership or tenant membership checks.",
77
+ stability: "default"
78
+ },
79
+ "supabase.table.missing-owner-column": {
80
+ ruleId: "supabase.table.missing-owner-column",
81
+ severity: "medium",
82
+ title: "Sensitive table has no owner or tenant column",
83
+ why: "Sensitive tables are hard to protect without owner or tenant keys.",
84
+ stability: "default"
85
+ },
86
+ "supabase.rls.not-enabled": {
87
+ ruleId: "supabase.rls.not-enabled",
88
+ severity: "critical",
89
+ title: "Sensitive table does not enable row level security",
90
+ why: "User-data tables should enable row level security.",
91
+ stability: "default"
92
+ },
93
+ "supabase.storage.public-bucket": {
94
+ ruleId: "supabase.storage.public-bucket",
95
+ severity: "high",
96
+ title: "Supabase storage policy or bucket appears public",
97
+ why: "Storage buckets can leak files even when database rows are protected.",
98
+ stability: "default"
99
+ },
100
+ "api.route.missing-rate-limit": {
101
+ ruleId: "api.route.missing-rate-limit",
102
+ severity: "medium",
103
+ title: "Sensitive API route lacks obvious rate limiting",
104
+ why: "Login, checkout, upload, AI, and webhook routes are common abuse targets.",
105
+ stability: "default"
106
+ },
107
+ "api.route.auth-without-ownership": {
108
+ ruleId: "api.route.auth-without-ownership",
109
+ severity: "high",
110
+ title: "API route checks auth but lacks an ownership guard",
111
+ why: "Login checks do not prove resource ownership checks.",
112
+ stability: "default"
113
+ },
114
+ "deploy.next.static-export-api-risk": {
115
+ ruleId: "deploy.next.static-export-api-risk",
116
+ severity: "medium",
117
+ title: "Next static export may conflict with server routes",
118
+ why: "Static export can conflict with runtime API assumptions.",
119
+ stability: "default"
120
+ },
121
+ "deploy.edge-runtime-node-api": {
122
+ ruleId: "deploy.edge-runtime-node-api",
123
+ severity: "medium",
124
+ title: "Route may use Node-only APIs in Edge runtime",
125
+ why: "Edge runtime can break Node-only dependencies.",
126
+ stability: "default"
127
+ },
128
+ "deploy.env.example-missing": {
129
+ ruleId: "deploy.env.example-missing",
130
+ severity: "low",
131
+ title: "Important runtime env var is not documented",
132
+ why: "Missing env docs cause local-success, production-failure deploys.",
133
+ stability: "default"
134
+ },
135
+ "mcp.config.invalid-json": {
136
+ ruleId: "mcp.config.invalid-json",
137
+ severity: "medium",
138
+ title: "MCP config is not valid JSON",
139
+ why: "Broken MCP configs hide the actual tool inventory.",
140
+ stability: "default"
141
+ },
142
+ "mcp.config.plaintext-secret": {
143
+ ruleId: "mcp.config.plaintext-secret",
144
+ severity: "high",
145
+ title: "MCP server contains plaintext secret-like config",
146
+ why: "Prompt and tool logs can expose plaintext credentials.",
147
+ stability: "default"
148
+ },
149
+ "mcp.config.non-local-bind": {
150
+ ruleId: "mcp.config.non-local-bind",
151
+ severity: "high",
152
+ title: "MCP server may bind outside localhost",
153
+ why: "Broad bind addresses can expose local tools to the network.",
154
+ stability: "default"
155
+ },
156
+ "mcp.config.insecure-http": {
157
+ ruleId: "mcp.config.insecure-http",
158
+ severity: "medium",
159
+ title: "MCP server uses insecure HTTP for a non-local endpoint",
160
+ why: "Plain HTTP can expose tool calls and credentials outside localhost.",
161
+ stability: "default"
162
+ },
163
+ "mcp.config.broad-filesystem": {
164
+ ruleId: "mcp.config.broad-filesystem",
165
+ severity: "high",
166
+ title: "MCP server has broad filesystem or write access",
167
+ why: "Write access over broad paths increases prompt-injection blast radius.",
168
+ stability: "default"
169
+ },
170
+ "mcp.tool.shell": {
171
+ ruleId: "mcp.tool.shell",
172
+ severity: "high",
173
+ title: "MCP server exposes shell-like tools",
174
+ why: "Generic shell tools can turn prompt injection into command execution.",
175
+ stability: "default"
176
+ },
177
+ "mcp.tool.raw-sql": {
178
+ ruleId: "mcp.tool.raw-sql",
179
+ severity: "high",
180
+ title: "MCP server exposes database or raw SQL capability",
181
+ why: "Raw SQL tools can read or mutate production data if over-scoped.",
182
+ stability: "default"
183
+ },
184
+ "mcp.config.loose-permissions": {
185
+ ruleId: "mcp.config.loose-permissions",
186
+ severity: "low",
187
+ title: "MCP config file is readable by group or other users",
188
+ why: "Secret-bearing configs should not be group/world-readable.",
189
+ stability: "default"
190
+ },
191
+ "pr-risk.sensitive-surface": {
192
+ ruleId: "pr-risk.sensitive-surface",
193
+ severity: "medium",
194
+ title: "Review first sensitive PR surface",
195
+ why: "AI-generated PRs often bury trust-boundary changes inside larger diffs.",
196
+ stability: "default"
197
+ },
198
+ "pr-risk.no-diff": {
199
+ ruleId: "pr-risk.no-diff",
200
+ severity: "info",
201
+ title: "No git diff found",
202
+ why: "PR classification needs a diff to identify changed trust boundaries.",
203
+ stability: "default"
204
+ }
205
+ };
206
+ export function getRuleMetadata(ruleId) {
207
+ return RULE_CATALOG[ruleId];
208
+ }
@@ -0,0 +1,3 @@
1
+ import type { Finding } from "../types.js";
2
+ import type { ScanInput } from "../context.js";
3
+ export declare function scanApiRoutes(input: ScanInput): Promise<Finding[]>;
@@ -0,0 +1,52 @@
1
+ import { resolveScanContext } from "../context.js";
2
+ import { finding, uniqueFindings } from "../report/findings.js";
3
+ import { lineAt, lineNumberForIndex } from "../utils/files.js";
4
+ const sensitiveRoutePattern = /(login|register|auth|checkout|stripe|webhook|upload|ai|generate|admin|password|reset|token)/i;
5
+ const rateLimitPattern = /(rateLimit|ratelimit|rate-limit|throttle|limiter|upstash|slowDown)/i;
6
+ const authPattern = /(auth|session|currentUser|getUser|jwt|cookies|authorization)/i;
7
+ const ownershipPattern = /(user_id|owner_id|tenant_id|organization_id|workspace_id|resource\.user|resource\.owner|where\s*:\s*{[\s\S]{0,100}(user|owner|tenant))/i;
8
+ export async function scanApiRoutes(input) {
9
+ const files = (await resolveScanContext(input)).getFiles((file) => isApiRoute(file.path));
10
+ const findings = [];
11
+ for (const file of files) {
12
+ const hasPostOrMutation = /\b(POST|PUT|PATCH|DELETE)\b|export\s+async\s+function\s+(POST|PUT|PATCH|DELETE)/.test(file.content);
13
+ const isSensitive = sensitiveRoutePattern.test(file.path) || sensitiveRoutePattern.test(file.content);
14
+ if (isSensitive && hasPostOrMutation && !rateLimitPattern.test(file.content)) {
15
+ findings.push(finding({
16
+ ruleId: "api.route.missing-rate-limit",
17
+ title: `Sensitive API route lacks obvious rate limiting: ${file.path}`,
18
+ severity: "medium",
19
+ evidence: [{ file: file.path, line: firstLine(file.content, /\b(POST|PUT|PATCH|DELETE)\b/), snippet: firstSnippet(file.content, /\b(POST|PUT|PATCH|DELETE)\b/) }],
20
+ why: "Login, checkout, upload, AI, and webhook endpoints are common abuse targets in AI-built SaaS apps.",
21
+ suggestedVerification: "Run repeated requests against this route in a staging environment and confirm abuse is throttled before expensive or stateful work runs.",
22
+ suggestedFix: "Add IP/user keyed rate limiting close to the route entry point, with stricter limits for auth, checkout, upload, AI, and webhook paths."
23
+ }));
24
+ }
25
+ if (authPattern.test(file.content) && /params\.|searchParams|get\(|findUnique|findFirst|update|delete/i.test(file.content) && !ownershipPattern.test(file.content)) {
26
+ findings.push(finding({
27
+ ruleId: "api.route.auth-without-ownership",
28
+ title: `API route checks auth but lacks an obvious ownership guard: ${file.path}`,
29
+ severity: "high",
30
+ evidence: [{ file: file.path, line: firstLine(file.content, authPattern), snippet: firstSnippet(file.content, authPattern) }],
31
+ why: "A route can require login but still allow User B to access User A's resource by guessing an ID.",
32
+ suggestedVerification: "Run a two-account IDOR test: create a resource as User A, then read, update, and delete it using User B's session.",
33
+ suggestedFix: "Scope every resource query or mutation by the current user's owner, tenant, or membership relationship."
34
+ }));
35
+ }
36
+ }
37
+ return uniqueFindings(findings);
38
+ }
39
+ function isApiRoute(path) {
40
+ return (/(^|\/)app\/api\/.+\/route\.(ts|js|tsx|jsx)$/i.test(path) ||
41
+ /(^|\/)pages\/api\/.+\.(ts|js)$/i.test(path) ||
42
+ /(^|\/)(routes|controllers)\/.+\.(ts|js)$/i.test(path));
43
+ }
44
+ function firstLine(content, pattern) {
45
+ const match = pattern.exec(content);
46
+ pattern.lastIndex = 0;
47
+ return match ? lineNumberForIndex(content, match.index) : undefined;
48
+ }
49
+ function firstSnippet(content, pattern) {
50
+ const line = firstLine(content, pattern);
51
+ return line ? lineAt(content, line) : undefined;
52
+ }
@@ -0,0 +1,3 @@
1
+ import type { Finding } from "../types.js";
2
+ import type { ScanInput } from "../context.js";
3
+ export declare function scanDeployConfig(input: ScanInput): Promise<Finding[]>;
@@ -0,0 +1,56 @@
1
+ import { resolveScanContext } from "../context.js";
2
+ import { finding, uniqueFindings } from "../report/findings.js";
3
+ import { lineAt, lineNumberForIndex } from "../utils/files.js";
4
+ export async function scanDeployConfig(input) {
5
+ const files = (await resolveScanContext(input)).files;
6
+ const findings = [];
7
+ const envExample = files.find((file) => /(^|\/)\.env\.example$/.test(file.path));
8
+ const referencedEnv = new Set();
9
+ for (const file of files) {
10
+ for (const match of file.content.matchAll(/process\.env\.([A-Z0-9_]+)/g)) {
11
+ referencedEnv.add(match[1]);
12
+ }
13
+ if (/next\.config\.(ts|js|mjs)$/.test(file.path) && /output\s*:\s*["']export["']/.test(file.content) && /app\/api|route\.ts|serverActions/i.test(file.content)) {
14
+ const line = lineNumberForIndex(file.content, file.content.search(/output\s*:/));
15
+ findings.push(finding({
16
+ ruleId: "deploy.next.static-export-api-risk",
17
+ title: "Next static export may conflict with server routes",
18
+ severity: "medium",
19
+ evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
20
+ why: "Next/Vercel deploys often break when local server behavior depends on runtime routes that static export cannot serve.",
21
+ suggestedVerification: "Run the same production build command used by CI and request each API route locally from the built output.",
22
+ suggestedFix: "Use a server-capable Next deployment target for API routes, or split static pages from server endpoints."
23
+ }));
24
+ }
25
+ if (/runtime\s*=\s*["']edge["']/.test(file.content) && /(PrismaClient|fs\.|node:fs|stripe\.webhooks\.constructEvent)/.test(file.content)) {
26
+ const line = lineNumberForIndex(file.content, file.content.search(/runtime\s*=/));
27
+ findings.push(finding({
28
+ ruleId: "deploy.edge-runtime-node-api",
29
+ title: `Route may use Node-only APIs while configured for Edge runtime: ${file.path}`,
30
+ severity: "medium",
31
+ evidence: [{ file: file.path, line, snippet: lineAt(file.content, line) }],
32
+ why: "Next.js routes that work locally can fail on Vercel when Edge runtime code uses Node-only libraries or raw body assumptions.",
33
+ suggestedVerification: "Run `next build` and deploy-preview logs for this route with production runtime settings.",
34
+ suggestedFix: "Move Node-only code to the Node.js runtime or replace incompatible dependencies."
35
+ }));
36
+ }
37
+ }
38
+ if (envExample) {
39
+ const documentedEnv = new Set([...envExample.content.matchAll(/^([A-Z0-9_]+)=/gm)].map((match) => match[1]));
40
+ const missingImportantEnv = [...referencedEnv].filter((name) => {
41
+ return /(STRIPE|SUPABASE|DATABASE|NEXTAUTH|AUTH|WEBHOOK|SECRET|TOKEN)/.test(name) && !documentedEnv.has(name);
42
+ });
43
+ for (const name of missingImportantEnv.slice(0, 10)) {
44
+ findings.push(finding({
45
+ ruleId: "deploy.env.example-missing",
46
+ title: `Important runtime env var is not documented in .env.example: ${name}`,
47
+ severity: "low",
48
+ evidence: [{ file: envExample.path, match: name }],
49
+ why: "Missing production env variables are a common reason Next/Vercel apps work locally and fail after deploy.",
50
+ suggestedVerification: "Compare required variables against Vercel or CI environment settings before launch.",
51
+ suggestedFix: "Add a placeholder and short purpose comment for this variable in .env.example."
52
+ }));
53
+ }
54
+ }
55
+ return uniqueFindings(findings);
56
+ }
@@ -0,0 +1,2 @@
1
+ import type { PrRiskOptions, PrRiskReport } from "../types.js";
2
+ export declare function classifyPrRisk(options: PrRiskOptions): Promise<PrRiskReport>;