ai-saas-guard 0.43.0 → 0.43.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 (37) hide show
  1. package/README.md +5 -5
  2. package/action.yml +4 -2
  3. package/dist/commands/scan.js +16 -2
  4. package/dist/hosted/contracts.js +1 -1
  5. package/dist/hosted/production-adapters.js +61 -6
  6. package/dist/hosted/staging-harness.js +0 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +1 -0
  9. package/dist/rules/catalog.js +7 -0
  10. package/dist/scanners/apiRoutes.js +51 -2
  11. package/dist/scanners/mcp.js +1 -1
  12. package/dist/scanners/secrets.js +11 -0
  13. package/dist/scanners/supabase.js +19 -1
  14. package/dist/stackInventory.d.ts +22 -0
  15. package/dist/stackInventory.js +250 -0
  16. package/dist/types.d.ts +1 -0
  17. package/dist/utils/files.js +1 -1
  18. package/docs/CODEX_HANDOFF.md +218 -0
  19. package/docs/CODEX_RECENT_CHANGES.md +408 -0
  20. package/docs/CODEX_STATE.md +247 -0
  21. package/docs/CODEX_TODO.md +224 -0
  22. package/docs/README.zh-CN.md +5 -5
  23. package/docs/design-partner-outreach-kit.md +136 -0
  24. package/docs/hosted-next-proof-plan.md +141 -0
  25. package/docs/hosted-operational-release-gate.md +8 -2
  26. package/docs/hosted-operations-evidence.md +327 -0
  27. package/docs/hosted-operator-runbook.md +263 -0
  28. package/docs/hosted-preimplementation-contracts.md +1 -1
  29. package/docs/hosted-support-incident-ownership.md +88 -0
  30. package/docs/npm-publishing.md +3 -3
  31. package/docs/project-handoff.md +139 -4
  32. package/docs/public-beta-evidence-feedback.md +318 -0
  33. package/docs/rules.md +29 -0
  34. package/hosted/cloudflare-worker/README.md +6 -0
  35. package/hosted/cloudflare-worker/src/index.js +229 -4
  36. package/hosted/cloudflare-worker/wrangler.jsonc +4 -1
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -235,13 +235,13 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
235
235
  | Area | Status |
236
236
  | --- | --- |
237
237
  | Public GitHub repository | Available |
238
- | npm CLI | `ai-saas-guard@0.43.0` |
239
- | GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.43.0` |
238
+ | npm CLI | `ai-saas-guard@0.43.1` |
239
+ | GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.43.1` |
240
240
  | Outputs | Launch decision queue, short summary, terminal, JSON, SARIF, and PR-focused markdown |
241
241
  | Project config | `.ai-saas-guard.json` rule toggles, severity overrides, suppressions, and fail thresholds |
242
242
  | Privacy model | Local-first, read-only scan commands, no LLM calls, no code upload |
243
- | Versioned Action tags | `v0.43.0`, `v0` |
244
- | Current release | `0.43.0` adds pre-commercial hosted gates for Phase 4 beta readiness and Phase 5 team launch readiness while keeping billing disabled |
243
+ | Versioned Action tags | `v0.43.1`, `v0` |
244
+ | Current release | `0.43.1` hardens hosted rate-limit failure handling, GitHub API URL validation, Check Run reproduction commands, MCP policy templates, and Action summary output while keeping billing disabled |
245
245
  | npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
246
246
  | Repository trust hardening | Strict branch protection, Dependabot, CodeQL, fast-check fuzzing, signed release provenance assets, private vulnerability reporting, secret scanning, and push protection |
247
247
  | Cloudflare hosted ingress | Deployed at `https://ai-saas-guard-hosted.zr9959.workers.dev`; public install/privacy notes are in [docs/hosted-install-privacy.md](docs/hosted-install-privacy.md); signed GitHub App webhook delivery and compact Check Run smoke now pass in staging |
@@ -425,7 +425,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
425
425
 
426
426
  ## GitHub Action
427
427
 
428
- The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.43.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
428
+ The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.43.1` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
429
429
 
430
430
  ```yaml
431
431
  name: ai-saas-guard
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, or markdown. Use markdown for PR summaries and sarif for code scanning."
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."
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) ;;
70
+ terminal|json|sarif|markdown|summary) ;;
71
71
  *)
72
72
  echo "Invalid format input: ${INPUT_FORMAT}" >&2
73
73
  exit 2
@@ -100,6 +100,8 @@ runs:
100
100
  args+=("--sarif")
101
101
  elif [ "${INPUT_FORMAT}" = "markdown" ]; then
102
102
  args+=("--markdown")
103
+ elif [ "${INPUT_FORMAT}" = "summary" ]; then
104
+ args+=("--summary")
103
105
  fi
104
106
 
105
107
  if [ "${INPUT_FAIL_ON}" != "none" ]; then
@@ -8,13 +8,15 @@ import { scanNextPublicEnv, scanSecrets } from "../scanners/secrets.js";
8
8
  import { scanSilentSuccess } from "../scanners/silentSuccess.js";
9
9
  import { checkStripe } from "../scanners/stripe.js";
10
10
  import { checkSupabase } from "../scanners/supabase.js";
11
+ import { detectStackInventory } from "../stackInventory.js";
11
12
  export async function scanRepository(options) {
12
13
  const context = await createScanContext(options.rootDir);
14
+ const stackInventory = await detectStackInventory(context);
13
15
  const [secretFindings, nextPublicFindings, stripeReport, supabaseReport, mcpReport, apiFindings, deployFindings, silentSuccessFindings, actionsReport] = await Promise.all([
14
16
  scanSecrets(context),
15
17
  scanNextPublicEnv(context),
16
18
  checkStripe(context),
17
- checkSupabase(context),
19
+ stackInventory.databases.includes("supabase") ? checkSupabase(context) : createSkippedSupabaseReport(context.rootDir),
18
20
  checkMcp(context),
19
21
  scanApiRoutes(context),
20
22
  scanDeployConfig(context),
@@ -31,5 +33,17 @@ export async function scanRepository(options) {
31
33
  ...deployFindings,
32
34
  ...silentSuccessFindings,
33
35
  ...actionsReport.findings
34
- ]), {});
36
+ ]), { stackInventory });
37
+ }
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
+ });
35
49
  }
@@ -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 .`;
480
+ const localCliCommand = `npx ai-saas-guard@${report.scannerVersion} pr-risk --root . --base ${report.baseSha} --json`;
481
481
  const conclusion = resolveCheckRunConclusion(report, input.failOnSeverity);
482
482
  const launchGate = hostedLaunchGateVerdict(report);
483
483
  return {
@@ -6,6 +6,7 @@ export const HOSTED_GITHUB_APP_JWT_CLOCK_SKEW_SECONDS = 60;
6
6
  export const HOSTED_WORKER_MAX_TIMEOUT_MS = 600_000;
7
7
  export const HOSTED_WORKER_DEFAULT_TIMEOUT_MS = 300_000;
8
8
  export const HOSTED_WORKER_MAX_OUTPUT_BYTES = 1_048_576;
9
+ const HOSTED_GITHUB_API_BASE_URL = "https://api.github.com";
9
10
  export function createHostedGitHubAppJwt(input) {
10
11
  const nowSeconds = normalizeUnixSeconds(input.nowSeconds, Math.floor(Date.now() / 1000));
11
12
  const ttlSeconds = clampPositiveInteger(input.ttlSeconds, HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS, HOSTED_GITHUB_APP_JWT_MAX_TTL_SECONDS);
@@ -180,17 +181,71 @@ function safeApiUrlBlockedReasons(apiBaseUrl) {
180
181
  if (!apiBaseUrl) {
181
182
  return [];
182
183
  }
184
+ return safeGitHubApiBaseUrl(apiBaseUrl) ? [] : ["invalid_github_api_url"];
185
+ }
186
+ function normalizeApiBaseUrl(apiBaseUrl) {
187
+ return safeGitHubApiBaseUrl(apiBaseUrl) ?? HOSTED_GITHUB_API_BASE_URL;
188
+ }
189
+ function safeGitHubApiBaseUrl(apiBaseUrl) {
190
+ if (!apiBaseUrl) {
191
+ return undefined;
192
+ }
193
+ const trimmed = trimTrailingSlashes(apiBaseUrl.trim());
183
194
  try {
184
- const url = new URL(apiBaseUrl);
185
- return url.protocol === "https:" ? [] : ["invalid_github_api_url"];
195
+ const url = new URL(trimmed);
196
+ if (url.protocol !== "https:" ||
197
+ url.username ||
198
+ url.password ||
199
+ url.search ||
200
+ url.hash ||
201
+ !isRootPath(url.pathname) ||
202
+ isUnsafeHostedHostname(url.hostname)) {
203
+ return undefined;
204
+ }
205
+ return trimmed;
186
206
  }
187
207
  catch {
188
- return ["invalid_github_api_url"];
208
+ return undefined;
189
209
  }
190
210
  }
191
- function normalizeApiBaseUrl(apiBaseUrl) {
192
- const value = apiBaseUrl?.trim() || "https://api.github.com";
193
- return trimTrailingSlashes(value);
211
+ function isRootPath(pathname) {
212
+ return pathname === "" || pathname === "/";
213
+ }
214
+ function isUnsafeHostedHostname(hostname) {
215
+ const normalized = normalizeHostname(hostname);
216
+ return (normalized === "localhost" ||
217
+ normalized.endsWith(".localhost") ||
218
+ isUnsafeIpv4Hostname(normalized) ||
219
+ isUnsafeIpv6Hostname(normalized));
220
+ }
221
+ function normalizeHostname(hostname) {
222
+ const lower = hostname.toLowerCase().replace(/\.$/, "");
223
+ return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
224
+ }
225
+ function isUnsafeIpv4Hostname(hostname) {
226
+ const parts = hostname.split(".");
227
+ if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part)))
228
+ return false;
229
+ const octets = parts.map((part) => Number(part));
230
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
231
+ return false;
232
+ }
233
+ const [first, second] = octets;
234
+ return (first === 0 ||
235
+ first === 10 ||
236
+ first === 127 ||
237
+ (first === 169 && second === 254) ||
238
+ (first === 172 && second >= 16 && second <= 31) ||
239
+ (first === 192 && second === 168) ||
240
+ (first === 100 && second >= 64 && second <= 127) ||
241
+ first >= 224);
242
+ }
243
+ function isUnsafeIpv6Hostname(hostname) {
244
+ return (hostname === "::" ||
245
+ hostname === "::1" ||
246
+ hostname.startsWith("fc") ||
247
+ hostname.startsWith("fd") ||
248
+ hostname.startsWith("fe80:"));
194
249
  }
195
250
  function trimTrailingSlashes(value) {
196
251
  let end = value.length;
@@ -24,7 +24,6 @@ export function createFileBackedHostedStagingHarness(options) {
24
24
  const scanResult = typeof options.scanResult === "function"
25
25
  ? await options.scanResult(input)
26
26
  : options.scanResult;
27
- await writeFile(join(sandboxPath, "source.ts"), scanResult.rawSource ?? "", "utf8");
28
27
  return scanResult;
29
28
  },
30
29
  now: options.now
package/dist/index.d.ts CHANGED
@@ -7,11 +7,13 @@ export { checkActions } from "./commands/checkActions.js";
7
7
  export { classifyPrRisk } from "./commands/prRisk.js";
8
8
  export { applyGuardConfig, defaultConfigFileName, loadGuardConfig } from "./config.js";
9
9
  export { createScanContext } from "./context.js";
10
+ export { detectStackInventory } from "./stackInventory.js";
10
11
  export { getRuleMetadata, RULE_CATALOG } from "./rules/catalog.js";
11
12
  export { formatSummaryReport } from "./report/summary.js";
12
13
  export { createLocalScanResourceBudget } from "./performance.js";
13
14
  export type { BaseReport, CommandName, Evidence, Finding, ActionsReport, McpOptions, McpPolicyTemplate, McpReport, McpServerInventory, McpSideEffect, PrRiskFile, PrRiskReport, ScanOptions, ShowcaseReport, StripeReport, SupabaseOptions, SupabaseDoctorReport, SupabaseReport } from "./types.js";
14
15
  export type { ScanContext, ScanInput } from "./context.js";
16
+ export type { StackCategory, StackEvidence, StackInventory, StackInventoryInput } from "./stackInventory.js";
15
17
  export type { FindingSuppression, GuardConfig, RuleConfigValue } from "./config.js";
16
18
  export type { RuleMetadata, RuleStability } from "./rules/catalog.js";
17
19
  export type { LocalScanResourceBudget, LocalScanResourceBudgetInput } from "./performance.js";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export { checkActions } from "./commands/checkActions.js";
7
7
  export { classifyPrRisk } from "./commands/prRisk.js";
8
8
  export { applyGuardConfig, defaultConfigFileName, loadGuardConfig } from "./config.js";
9
9
  export { createScanContext } from "./context.js";
10
+ export { detectStackInventory } from "./stackInventory.js";
10
11
  export { getRuleMetadata, RULE_CATALOG } from "./rules/catalog.js";
11
12
  export { formatSummaryReport } from "./report/summary.js";
12
13
  export { createLocalScanResourceBudget } from "./performance.js";
@@ -188,6 +188,13 @@ export const RULE_CATALOG = {
188
188
  why: "Login, checkout, upload, AI, and webhook routes are common abuse targets.",
189
189
  stability: "experimental"
190
190
  },
191
+ "api.route.provider-debug-exposed": {
192
+ ruleId: "api.route.provider-debug-exposed",
193
+ severity: "high",
194
+ title: "Provider debug endpoint exposes server-side credential probe",
195
+ why: "Public provider probe endpoints can spend quota, reveal integration state, or exercise server credentials without returning the token.",
196
+ stability: "default"
197
+ },
191
198
  "api.route.auth-without-ownership": {
192
199
  ruleId: "api.route.auth-without-ownership",
193
200
  severity: "high",
@@ -4,7 +4,9 @@ import { lineAt, lineNumberForIndex } from "../utils/files.js";
4
4
  const sensitiveRoutePattern = /(login|register|auth|checkout|stripe|webhook|upload|ai|generate|admin|password|reset|token)/i;
5
5
  const rateLimitPattern = /(rateLimit|ratelimit|rate-limit|throttle|limiter|upstash|slowDown)/i;
6
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;
7
+ const ownershipPattern = /(user_id|userId|owner_id|ownerId|tenant_id|tenantId|organization_id|organizationId|workspace_id|workspaceId|resource\.user|resource\.owner|req\.user(?:Id|\.id)|where\s*:\s*{[\s\S]{0,140}(user|owner|tenant|organization|workspace))/i;
8
+ const providerDebugPathPattern = /(paypal|stripe|github|oauth|openai|anthropic|resend|sendgrid).*(token|config|status|test|probe|debug)|(token|config|status|test|probe|debug).*(paypal|stripe|github|oauth|openai|anthropic|resend|sendgrid)/i;
9
+ const providerCredentialProbePattern = /(client_credentials|oauth2\/token|access_token|PAYPAL_SECRET|PAYPAL_CLIENT_SECRET|STRIPE_SECRET|GITHUB_APP_PRIVATE_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|SENDGRID_API_KEY|RESEND_API_KEY)/i;
8
10
  export async function scanApiRoutes(input) {
9
11
  const files = (await resolveScanContext(input)).getFiles((file) => isApiRoute(file.path));
10
12
  const findings = [];
@@ -13,6 +15,7 @@ export async function scanApiRoutes(input) {
13
15
  const isSensitive = sensitiveRoutePattern.test(file.path) || sensitiveRoutePattern.test(file.content);
14
16
  findings.push(...scanClerkUnsafeMetadata(file.path, file.content));
15
17
  findings.push(...scanPrismaTenantScope(file.path, file.content));
18
+ findings.push(...scanProviderDebugEndpoint(file.path, file.content));
16
19
  if (isSensitive && hasPostOrMutation && !rateLimitPattern.test(file.content)) {
17
20
  findings.push(finding({
18
21
  ruleId: "api.route.missing-rate-limit",
@@ -24,7 +27,11 @@ export async function scanApiRoutes(input) {
24
27
  suggestedFix: "Add IP/user keyed rate limiting close to the route entry point, with stricter limits for auth, checkout, upload, AI, and webhook paths."
25
28
  }));
26
29
  }
27
- if (authPattern.test(file.content) && /params\.|searchParams|get\(|findUnique|findFirst|update|delete/i.test(file.content) && !ownershipPattern.test(file.content)) {
30
+ if (authPattern.test(file.content) &&
31
+ /params\.|searchParams|get\(|findUnique|findFirst|update|delete/i.test(file.content) &&
32
+ !ownershipPattern.test(file.content) &&
33
+ !hasExplicitAdminGuard(file.content) &&
34
+ !hasBenignRouteClassification(file.path, file.content)) {
28
35
  findings.push(finding({
29
36
  ruleId: "api.route.auth-without-ownership",
30
37
  title: `API route checks auth but lacks an obvious ownership guard: ${file.path}`,
@@ -38,6 +45,48 @@ export async function scanApiRoutes(input) {
38
45
  }
39
46
  return uniqueFindings(findings);
40
47
  }
48
+ function scanProviderDebugEndpoint(filePath, content) {
49
+ if (!/\bGET\b|export\s+async\s+function\s+GET/i.test(content))
50
+ return [];
51
+ if (!providerDebugPathPattern.test(filePath) && !providerDebugPathPattern.test(content))
52
+ return [];
53
+ if (!providerCredentialProbePattern.test(content))
54
+ return [];
55
+ if (hasExplicitAdminGuard(content))
56
+ return [];
57
+ return [
58
+ finding({
59
+ ruleId: "api.route.provider-debug-exposed",
60
+ title: `Public provider token or configuration probe endpoint: ${filePath}`,
61
+ severity: "high",
62
+ evidence: [{ file: filePath, line: firstLine(content, providerCredentialProbePattern), snippet: firstSnippet(content, providerCredentialProbePattern) }],
63
+ why: "A public debug endpoint can spend provider quota, reveal integration mode or configuration state, and exercise server-side credentials even when it does not return the token.",
64
+ suggestedVerification: "Call the route without a session in staging and confirm it cannot trigger provider authentication or reveal provider configuration state.",
65
+ suggestedFix: "Remove the endpoint before launch, or require admin authentication, add a dedicated rate limit, emit an audit log, and disable it in production."
66
+ })
67
+ ];
68
+ }
69
+ function hasExplicitAdminGuard(content) {
70
+ return /\b(requireAdmin|adminMiddleware|isAdmin|requireRole\s*\(\s*["']admin|role\s*[:=]\s*["']admin|router\.use\s*\([\s\S]{0,160}requireAdmin)/i.test(content);
71
+ }
72
+ function hasBenignRouteClassification(filePath, content) {
73
+ return isPublicReadOnlyContentRoute(filePath, content) || isInternalProxyRoute(filePath, content) || isScopedTokenRoute(content);
74
+ }
75
+ function isPublicReadOnlyContentRoute(filePath, content) {
76
+ if (/\b(POST|PUT|PATCH|DELETE)\b|export\s+async\s+function\s+(POST|PUT|PATCH|DELETE)/.test(content))
77
+ return false;
78
+ const publicContentSurface = /(\/|^)(content|seo|blog|article|articles|sitemap|robots|public)(\/|\.|-|$)/i.test(filePath);
79
+ const publicPredicate = /\b(published|public|isPublished|visibility)\s*:\s*(true|["']public["'])/i.test(content);
80
+ return publicContentSurface && publicPredicate;
81
+ }
82
+ function isInternalProxyRoute(filePath, content) {
83
+ const internalPath = /(\/|^)(internal|proxy|bff)(\/|\.|-|$)/i.test(filePath);
84
+ const hasServiceTokenGuard = /\b(INTERNAL_[A-Z0-9_]*TOKEN|SERVICE_[A-Z0-9_]*TOKEN|PROXY_[A-Z0-9_]*TOKEN|authorization\s*!==\s*`?Bearer)/i.test(content);
85
+ return internalPath && hasServiceTokenGuard;
86
+ }
87
+ function isScopedTokenRoute(content) {
88
+ return /\b(scopeToken|scopedToken|verifyAgentScopeToken|agentScope|scopes\s*:\s*\[|content-agent:[a-z-]+)/i.test(content);
89
+ }
41
90
  function scanClerkUnsafeMetadata(filePath, content) {
42
91
  if (!/\b(@clerk\/|clerkClient|currentUser|auth\s*\()/i.test(content))
43
92
  return [];
@@ -248,7 +248,7 @@ function buildPolicyTemplate(servers) {
248
248
  " decision: allow",
249
249
  " reason: repo-local read scope",
250
250
  " - match: { sideEffectClass: shell }",
251
- "decision: deny",
251
+ " decision: deny",
252
252
  " reason: shell tools require explicit human approval before launch work"
253
253
  ],
254
254
  receiptFormat: [
@@ -65,6 +65,8 @@ export async function scanSecrets(input) {
65
65
  secretPattern.pattern.lastIndex = 0;
66
66
  for (const match of file.content.matchAll(secretPattern.pattern)) {
67
67
  const matchedText = match[0] ?? "";
68
+ if (isObviousPlaceholderSecret(file.path, matchedText))
69
+ continue;
68
70
  const line = lineNumberForIndex(file.content, match.index ?? 0);
69
71
  findings.push(finding({
70
72
  ruleId: "secrets.detected",
@@ -87,6 +89,15 @@ export async function scanSecrets(input) {
87
89
  }
88
90
  return findings;
89
91
  }
92
+ function isObviousPlaceholderSecret(filePath, matchedText) {
93
+ const lowerPath = filePath.toLowerCase();
94
+ const lower = matchedText.toLowerCase();
95
+ const isExampleFile = /(^|\/)(\.env\.example|\.env\.sample|example\.env|sample\.env)$/.test(lowerPath) || lowerPath.includes("/examples/");
96
+ const hasPlaceholderValue = /\b(your|replace[-_]?me|placeholder|example|sample|dummy|fake|test[-_]?token|do[-_]?not[-_]?use|changeme)\b/i.test(lower) ||
97
+ /<[^>\n]*(key|secret|token|password|client)[^>\n]*>/i.test(matchedText) ||
98
+ /\b(?:1x|2x)0{10,}[A-Za-z0-9_-]*\b/.test(matchedText);
99
+ return hasPlaceholderValue && (isExampleFile || !/sk_(?:live|test)_|gh[pousr]_|-----BEGIN|SUPABASE_SERVICE_ROLE_KEY\s*=\s*eyJ/i.test(matchedText));
100
+ }
90
101
  export async function scanNextPublicEnv(input) {
91
102
  const files = (await resolveScanContext(input)).files;
92
103
  const findings = [];
@@ -5,6 +5,15 @@ const sensitiveTablePattern = /\b(user|account|profile|team|tenant|project|order
5
5
  const ownershipColumnPattern = /\b(user_id|owner_id|tenant_id|account_id|organization_id|workspace_id|created_by)\b/i;
6
6
  export async function checkSupabase(input, options = {}) {
7
7
  const context = await resolveScanContext(input);
8
+ const doctor = buildDoctorReport(options.doctor ?? true);
9
+ if (!hasSupabaseContext(context.files)) {
10
+ return createReport("check-supabase", context.rootDir, [], {
11
+ riskyTables: [],
12
+ riskyPolicies: [],
13
+ manualAuthorizationTest: [],
14
+ doctor
15
+ });
16
+ }
8
17
  const files = context.getFiles((file) => {
9
18
  const path = file.path.toLowerCase();
10
19
  return path.includes("supabase") || path.includes("migration") || path.endsWith(".sql") || path.endsWith(".prisma");
@@ -129,7 +138,6 @@ export async function checkSupabase(input, options = {}) {
129
138
  }
130
139
  }
131
140
  findings.push(...buildDoctorFindings(files, tables, rlsEnabledTables, policies));
132
- const doctor = buildDoctorReport(options.doctor ?? true);
133
141
  return createReport("check-supabase", context.rootDir, uniqueFindings(findings), {
134
142
  riskyTables: [...new Set(tables.filter((table) => table.sensitive && !rlsEnabledTables.has(table.name)).map((table) => table.name))],
135
143
  riskyPolicies,
@@ -143,6 +151,16 @@ export async function checkSupabase(input, options = {}) {
143
151
  doctor
144
152
  });
145
153
  }
154
+ function hasSupabaseContext(files) {
155
+ return files.some((file) => {
156
+ const path = file.path.toLowerCase();
157
+ if (path.includes("supabase"))
158
+ return true;
159
+ if (path === "package.json" && /@supabase\/supabase-js|supabase/i.test(file.content))
160
+ return true;
161
+ return /\bfrom\s+["']@supabase\/|create\s+policy\b|enable\s+row\s+level\s+security|auth\.uid\s*\(|storage\.objects/i.test(file.content);
162
+ });
163
+ }
146
164
  function buildDoctorFindings(files, tables, rlsEnabledTables, policies) {
147
165
  const findings = [];
148
166
  const policiesByTable = new Map();
@@ -0,0 +1,22 @@
1
+ import type { ScanInput } from "./context.js";
2
+ export type StackCategory = "frameworks" | "databases" | "orms" | "auth" | "payments" | "storage" | "deploy";
3
+ export interface StackEvidence {
4
+ category: StackCategory;
5
+ tool: string;
6
+ file: string;
7
+ reason: string;
8
+ }
9
+ export interface StackInventory {
10
+ frameworks: string[];
11
+ databases: string[];
12
+ orms: string[];
13
+ auth: string[];
14
+ payments: string[];
15
+ storage: string[];
16
+ deploy: string[];
17
+ evidence: StackEvidence[];
18
+ }
19
+ export type StackInventoryInput = ScanInput | {
20
+ rootDir: string;
21
+ };
22
+ export declare function detectStackInventory(input: StackInventoryInput): Promise<StackInventory>;