ai-saas-guard 0.42.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 (40) hide show
  1. package/README.md +7 -5
  2. package/action.yml +4 -2
  3. package/dist/commands/scan.js +16 -2
  4. package/dist/hosted/beta.d.ts +97 -0
  5. package/dist/hosted/beta.js +132 -0
  6. package/dist/hosted/contracts.js +1 -1
  7. package/dist/hosted/production-adapters.js +61 -6
  8. package/dist/hosted/staging-harness.js +0 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +1 -0
  11. package/dist/rules/catalog.js +7 -0
  12. package/dist/scanners/apiRoutes.js +51 -2
  13. package/dist/scanners/mcp.js +1 -1
  14. package/dist/scanners/secrets.js +11 -0
  15. package/dist/scanners/supabase.js +19 -1
  16. package/dist/stackInventory.d.ts +22 -0
  17. package/dist/stackInventory.js +250 -0
  18. package/dist/types.d.ts +1 -0
  19. package/dist/utils/files.js +1 -1
  20. package/docs/CODEX_HANDOFF.md +218 -0
  21. package/docs/CODEX_RECENT_CHANGES.md +408 -0
  22. package/docs/CODEX_STATE.md +247 -0
  23. package/docs/CODEX_TODO.md +224 -0
  24. package/docs/README.zh-CN.md +7 -5
  25. package/docs/design-partner-outreach-kit.md +136 -0
  26. package/docs/hosted-next-proof-plan.md +141 -0
  27. package/docs/hosted-node-container-app.md +7 -2
  28. package/docs/hosted-operational-release-gate.md +30 -2
  29. package/docs/hosted-operations-evidence.md +331 -0
  30. package/docs/hosted-operator-runbook.md +263 -0
  31. package/docs/hosted-preimplementation-contracts.md +1 -1
  32. package/docs/hosted-support-incident-ownership.md +88 -0
  33. package/docs/npm-publishing.md +3 -3
  34. package/docs/project-handoff.md +139 -4
  35. package/docs/public-beta-evidence-feedback.md +318 -0
  36. package/docs/rules.md +29 -0
  37. package/hosted/cloudflare-worker/README.md +7 -1
  38. package/hosted/cloudflare-worker/src/index.js +229 -4
  39. package/hosted/cloudflare-worker/wrangler.jsonc +5 -2
  40. package/package.json +5 -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.42.0` |
239
- | GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.42.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.42.0`, `v0` |
244
- | Current release | `0.42.0` adds a single Phase 3 source-checkout trial gate that combines plan, stage evidence, scan proof, live smoke, rollback, monitoring, and incident-owner checks before hosted beta |
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 |
@@ -369,6 +369,8 @@ The first live hosted ingress is deployed on Cloudflare Workers at `https://ai-s
369
369
 
370
370
  The next hosted source-checkout step is intentionally narrow: deploy the existing read-only checkout worker behind the same selected-repository identity, keep the fixed `pr-risk --json` command, write only compact findings to the Check Run, and require deployed cleanup/log-boundary/rollback evidence before broader trial use. The hosted worker export includes `createHostedSourceCheckoutTrialPlan`, `createHostedSourceCheckoutEvidence`, and `evaluateHostedSourceCheckoutTrialGate` so Phase 3 has one machine-checkable gate for checkout start/end, token removal, CLI start/end, compact report write, Check Run write, cleanup status, live smoke, rollback, monitoring, and incident-owner proof before Phase 4 beta.
371
371
 
372
+ The `ai-saas-guard/hosted/beta` export adds `evaluateHostedBetaReadinessGate` and `evaluateTeamLaunchGateReadiness`. These pre-commercial gates block hosted beta unless selected-repository install limits, abuse controls, safe telemetry, uninstall deletion proof, rollback, support ownership, beta smoke, and no-audit-claim wording are ready; they also block team use unless org policy config, required status-check docs, suppression audit, reviewer checklist, release evidence export, retention docs, and billing-disabled proof are in place.
373
+
372
374
  Hosted install and privacy details are summarized in [docs/hosted-install-privacy.md](docs/hosted-install-privacy.md): selected-repository permissions, supported events, Check Run data boundaries, uninstall cleanup, and why the local CLI remains the private/offline path.
373
375
 
374
376
  The hosted operational release gate is documented in [docs/hosted-operational-release-gate.md](docs/hosted-operational-release-gate.md). It defines the hosted-specific CI, replay, queue, worker cleanup, privacy, monitoring, rollback, and incident-response evidence required before any hosted environment is exposed to users. The pure gate evaluator exported from `ai-saas-guard/hosted/contracts` blocks hosted exposure unless every P0 evidence item is fresh, a container digest is recorded, and release notes avoid pentest, certification, and full-audit claims.
@@ -423,7 +425,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
423
425
 
424
426
  ## GitHub Action
425
427
 
426
- The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.42.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:
427
429
 
428
430
  ```yaml
429
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
  }
@@ -0,0 +1,97 @@
1
+ export interface HostedBetaReadinessGateInput {
2
+ requestedAt: string;
3
+ phase3GatePassed: boolean;
4
+ selectedRepositoryInstallOnly: boolean;
5
+ publicInstallDocsReady: boolean;
6
+ rateLimitEnabled: boolean;
7
+ abuseKillSwitchReady: boolean;
8
+ telemetrySafe: boolean;
9
+ uninstallDeletionTested: boolean;
10
+ rollbackTested: boolean;
11
+ incidentOwnerRecorded: boolean;
12
+ supportPathReady: boolean;
13
+ betaSmokePassed: boolean;
14
+ avoidsAuditClaims: boolean;
15
+ noRawSourceStorage: boolean;
16
+ noRawDiffStorage: boolean;
17
+ noPrTextStorage: boolean;
18
+ maxReposPerInstallation: number;
19
+ maxConcurrentScans: number;
20
+ rawSource?: string;
21
+ rawDiff?: string;
22
+ prText?: string;
23
+ installationToken?: string;
24
+ }
25
+ export interface HostedBetaReadinessGate {
26
+ phase: "phase_4_hosted_beta_readiness";
27
+ readyForPublicBeta: boolean;
28
+ blockedReasons: string[];
29
+ requestedAt: string;
30
+ installBoundary: {
31
+ selectedRepositoryOnly: true;
32
+ maxReposPerInstallation: number;
33
+ maxConcurrentScans: number;
34
+ };
35
+ operations: {
36
+ rateLimitEnabled: boolean;
37
+ abuseKillSwitchReady: boolean;
38
+ telemetrySafe: boolean;
39
+ uninstallDeletionTested: boolean;
40
+ rollbackTested: boolean;
41
+ incidentOwnerRecorded: boolean;
42
+ supportPathReady: boolean;
43
+ betaSmokePassed: boolean;
44
+ };
45
+ nextAction: string;
46
+ privacy: {
47
+ includesRawSource: false;
48
+ includesRawDiffs: false;
49
+ includesUntrustedPrText: false;
50
+ includesInstallationToken: false;
51
+ claimsPentest: false;
52
+ claimsFullAudit: false;
53
+ claimsCertification: false;
54
+ };
55
+ }
56
+ export interface TeamLaunchGateReadinessInput {
57
+ requestedAt: string;
58
+ hostedBetaGatePassed: boolean;
59
+ orgPolicyConfigReady: boolean;
60
+ requiredStatusCheckDocumented: boolean;
61
+ suppressionAuditReady: boolean;
62
+ reviewerChecklistReady: boolean;
63
+ releaseEvidenceExportReady: boolean;
64
+ teamDocsReady: boolean;
65
+ adminBypassDocumented: boolean;
66
+ retentionPolicyDocumented: boolean;
67
+ noCommercialBillingEnabled: boolean;
68
+ rawSource?: string;
69
+ customerPayload?: unknown;
70
+ }
71
+ export interface TeamLaunchGateReadiness {
72
+ phase: "phase_5_team_launch_gate";
73
+ readyForTeamUse: boolean;
74
+ blockedReasons: string[];
75
+ requestedAt: string;
76
+ teamControls: {
77
+ orgPolicyConfigReady: boolean;
78
+ requiredStatusCheckDocumented: boolean;
79
+ suppressionAuditReady: boolean;
80
+ reviewerChecklistReady: boolean;
81
+ releaseEvidenceExportReady: boolean;
82
+ teamDocsReady: boolean;
83
+ adminBypassDocumented: boolean;
84
+ retentionPolicyDocumented: boolean;
85
+ };
86
+ commercialization: {
87
+ enabled: false;
88
+ reason: "pre_commercial_feedback_stage";
89
+ };
90
+ nextAction: string;
91
+ privacy: {
92
+ includesRawSource: false;
93
+ includesCustomerPayloads: false;
94
+ };
95
+ }
96
+ export declare function evaluateHostedBetaReadinessGate(input: HostedBetaReadinessGateInput): HostedBetaReadinessGate;
97
+ export declare function evaluateTeamLaunchGateReadiness(input: TeamLaunchGateReadinessInput): TeamLaunchGateReadiness;
@@ -0,0 +1,132 @@
1
+ const MAX_BETA_REPOS_PER_INSTALLATION = 10;
2
+ const MAX_BETA_CONCURRENT_SCANS = 5;
3
+ export function evaluateHostedBetaReadinessGate(input) {
4
+ const blockedReasons = hostedBetaBlockedReasons(input);
5
+ return {
6
+ phase: "phase_4_hosted_beta_readiness",
7
+ readyForPublicBeta: blockedReasons.length === 0,
8
+ blockedReasons,
9
+ requestedAt: input.requestedAt,
10
+ installBoundary: {
11
+ selectedRepositoryOnly: true,
12
+ maxReposPerInstallation: Math.max(0, Math.floor(input.maxReposPerInstallation)),
13
+ maxConcurrentScans: Math.max(0, Math.floor(input.maxConcurrentScans))
14
+ },
15
+ operations: {
16
+ rateLimitEnabled: input.rateLimitEnabled,
17
+ abuseKillSwitchReady: input.abuseKillSwitchReady,
18
+ telemetrySafe: input.telemetrySafe,
19
+ uninstallDeletionTested: input.uninstallDeletionTested,
20
+ rollbackTested: input.rollbackTested,
21
+ incidentOwnerRecorded: input.incidentOwnerRecorded,
22
+ supportPathReady: input.supportPathReady,
23
+ betaSmokePassed: input.betaSmokePassed
24
+ },
25
+ nextAction: blockedReasons.length === 0
26
+ ? "Open a limited public beta only for selected repositories and keep collecting operational evidence before commercialization."
27
+ : "Do not open hosted beta. Resolve the blocked reasons and rerun the beta readiness gate.",
28
+ privacy: {
29
+ includesRawSource: false,
30
+ includesRawDiffs: false,
31
+ includesUntrustedPrText: false,
32
+ includesInstallationToken: false,
33
+ claimsPentest: false,
34
+ claimsFullAudit: false,
35
+ claimsCertification: false
36
+ }
37
+ };
38
+ }
39
+ export function evaluateTeamLaunchGateReadiness(input) {
40
+ const blockedReasons = teamLaunchBlockedReasons(input);
41
+ return {
42
+ phase: "phase_5_team_launch_gate",
43
+ readyForTeamUse: blockedReasons.length === 0,
44
+ blockedReasons,
45
+ requestedAt: input.requestedAt,
46
+ teamControls: {
47
+ orgPolicyConfigReady: input.orgPolicyConfigReady,
48
+ requiredStatusCheckDocumented: input.requiredStatusCheckDocumented,
49
+ suppressionAuditReady: input.suppressionAuditReady,
50
+ reviewerChecklistReady: input.reviewerChecklistReady,
51
+ releaseEvidenceExportReady: input.releaseEvidenceExportReady,
52
+ teamDocsReady: input.teamDocsReady,
53
+ adminBypassDocumented: input.adminBypassDocumented,
54
+ retentionPolicyDocumented: input.retentionPolicyDocumented
55
+ },
56
+ commercialization: {
57
+ enabled: false,
58
+ reason: "pre_commercial_feedback_stage"
59
+ },
60
+ nextAction: blockedReasons.length === 0
61
+ ? "Use the team launch gate with design partners, collect user feedback, and delay commercialization until usage evidence exists."
62
+ : "Do not sell or commercialize. Resolve the team launch gate blockers before inviting teams beyond beta.",
63
+ privacy: {
64
+ includesRawSource: false,
65
+ includesCustomerPayloads: false
66
+ }
67
+ };
68
+ }
69
+ function hostedBetaBlockedReasons(input) {
70
+ const reasons = [];
71
+ if (!input.phase3GatePassed)
72
+ reasons.push("phase3_gate_missing");
73
+ if (!input.selectedRepositoryInstallOnly)
74
+ reasons.push("selected_repository_install_required");
75
+ if (!input.publicInstallDocsReady)
76
+ reasons.push("public_install_docs_missing");
77
+ if (!input.rateLimitEnabled)
78
+ reasons.push("rate_limit_missing");
79
+ if (!input.abuseKillSwitchReady)
80
+ reasons.push("abuse_kill_switch_missing");
81
+ if (!input.telemetrySafe)
82
+ reasons.push("safe_telemetry_missing");
83
+ if (!input.uninstallDeletionTested)
84
+ reasons.push("uninstall_deletion_proof_missing");
85
+ if (!input.rollbackTested)
86
+ reasons.push("rollback_test_missing");
87
+ if (!input.incidentOwnerRecorded)
88
+ reasons.push("incident_owner_missing");
89
+ if (!input.supportPathReady)
90
+ reasons.push("support_path_missing");
91
+ if (!input.betaSmokePassed)
92
+ reasons.push("beta_smoke_missing");
93
+ if (!input.avoidsAuditClaims)
94
+ reasons.push("audit_claims_not_blocked");
95
+ if (!input.noRawSourceStorage)
96
+ reasons.push("raw_source_storage_blocked");
97
+ if (!input.noRawDiffStorage)
98
+ reasons.push("raw_diff_storage_blocked");
99
+ if (!input.noPrTextStorage)
100
+ reasons.push("pr_text_storage_blocked");
101
+ if (!Number.isFinite(input.maxReposPerInstallation) || input.maxReposPerInstallation > MAX_BETA_REPOS_PER_INSTALLATION) {
102
+ reasons.push("repo_limit_too_high");
103
+ }
104
+ if (!Number.isFinite(input.maxConcurrentScans) || input.maxConcurrentScans > MAX_BETA_CONCURRENT_SCANS) {
105
+ reasons.push("concurrency_limit_too_high");
106
+ }
107
+ return reasons;
108
+ }
109
+ function teamLaunchBlockedReasons(input) {
110
+ const reasons = [];
111
+ if (!input.hostedBetaGatePassed)
112
+ reasons.push("hosted_beta_gate_missing");
113
+ if (!input.orgPolicyConfigReady)
114
+ reasons.push("org_policy_config_missing");
115
+ if (!input.requiredStatusCheckDocumented)
116
+ reasons.push("required_status_check_docs_missing");
117
+ if (!input.suppressionAuditReady)
118
+ reasons.push("suppression_audit_missing");
119
+ if (!input.reviewerChecklistReady)
120
+ reasons.push("reviewer_checklist_missing");
121
+ if (!input.releaseEvidenceExportReady)
122
+ reasons.push("release_evidence_export_missing");
123
+ if (!input.teamDocsReady)
124
+ reasons.push("team_docs_missing");
125
+ if (!input.adminBypassDocumented)
126
+ reasons.push("admin_bypass_docs_missing");
127
+ if (!input.retentionPolicyDocumented)
128
+ reasons.push("retention_policy_docs_missing");
129
+ if (!input.noCommercialBillingEnabled)
130
+ reasons.push("commercial_billing_enabled_too_early");
131
+ return reasons;
132
+ }
@@ -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>;