ai-saas-guard 0.43.0 → 0.43.2

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 (52) hide show
  1. package/README.md +30 -5
  2. package/action.yml +4 -2
  3. package/dist/commands/scan.js +16 -2
  4. package/dist/context.d.ts +2 -1
  5. package/dist/context.js +4 -2
  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/hosted/worker.d.ts +2 -0
  10. package/dist/hosted/worker.js +9 -9
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +1 -0
  13. package/dist/report/summary.js +43 -0
  14. package/dist/rules/catalog.js +7 -0
  15. package/dist/scanners/apiRoutes.js +51 -2
  16. package/dist/scanners/gitDiff.js +20 -2
  17. package/dist/scanners/mcp.js +1 -1
  18. package/dist/scanners/secrets.js +11 -0
  19. package/dist/scanners/supabase.js +19 -1
  20. package/dist/stackInventory.d.ts +27 -0
  21. package/dist/stackInventory.js +256 -0
  22. package/dist/types.d.ts +2 -0
  23. package/dist/utils/files.d.ts +15 -0
  24. package/dist/utils/files.js +61 -10
  25. package/docs/CODEX_AGENT_WORKING_RULES.md +58 -0
  26. package/docs/CODEX_HANDOFF.md +223 -0
  27. package/docs/CODEX_RECENT_CHANGES.md +546 -0
  28. package/docs/CODEX_STATE.md +257 -0
  29. package/docs/CODEX_TODO.md +227 -0
  30. package/docs/README.zh-CN.md +29 -6
  31. package/docs/beta-evidence-execution-2026-05-27.md +203 -0
  32. package/docs/beta-readiness-review-2026-05-27.md +153 -0
  33. package/docs/cross-project-traffic-evidence-2026-05-27.md +115 -0
  34. package/docs/design-partner-outreach-kit.md +158 -0
  35. package/docs/hosted-next-proof-plan.md +141 -0
  36. package/docs/hosted-operational-release-gate.md +10 -4
  37. package/docs/hosted-operations-evidence.md +362 -1
  38. package/docs/hosted-operator-runbook.md +263 -0
  39. package/docs/hosted-preimplementation-contracts.md +1 -1
  40. package/docs/hosted-support-incident-ownership.md +88 -0
  41. package/docs/metrics-snapshot.md +55 -0
  42. package/docs/npm-publishing.md +3 -3
  43. package/docs/project-handoff.md +142 -4
  44. package/docs/public-beta-evidence-feedback.md +335 -0
  45. package/docs/rules.md +29 -0
  46. package/hosted/cloudflare-worker/README.md +7 -1
  47. package/hosted/cloudflare-worker/src/index.js +251 -8
  48. package/hosted/cloudflare-worker/wrangler.jsonc +4 -1
  49. package/package.json +6 -2
  50. package/scripts/cross-project-discovery-check.mjs +366 -0
  51. package/scripts/hosted-pr-smoke.mjs +62 -11
  52. package/scripts/metrics-snapshot.mjs +467 -0
package/README.md CHANGED
@@ -174,6 +174,19 @@ For a concise comparison with Semgrep, zizmor, OpenSSF Scorecard, Snyk, and GitH
174
174
 
175
175
  Choose the path by trust boundary: use the **Local CLI** when code must stay on your machine, the **GitHub Action** when you want repeatable CI evidence, and the **Hosted GitHub App** when reviewers need an automatic Check Run that groups auth, billing, tenant-data, deploy, and test-risk areas before merge.
176
176
 
177
+ ## Pre-Commercial Feedback
178
+
179
+ `ai-saas-guard` is looking for privacy-safe design-partner feedback before any public hosted beta decision. The safest path is local:
180
+
181
+ ```bash
182
+ npx --yes ai-saas-guard@latest demo --summary
183
+ npx --yes ai-saas-guard@latest scan --root /path/to/your-low-risk-demo-repo --summary
184
+ ```
185
+
186
+ Public-safe feedback belongs in [issue #93](https://github.com/zr9959/ai-saas-guard/issues/93): package version, path used, stack category, severity counts, rule IDs, what felt confusing/noisy/missing, and whether the report would change a launch or merge decision. Do not share source, raw diffs, PR text, logs, secrets, customer data, private URLs, checkout paths, or credentials.
187
+
188
+ Downloads, stars, page views, anonymous comments, simulated scans, and internal assumptions do not count as design-partner evidence.
189
+
177
190
  ## Quick Start
178
191
 
179
192
  Run the published CLI without installing it globally:
@@ -235,20 +248,32 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
235
248
  | Area | Status |
236
249
  | --- | --- |
237
250
  | 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` |
251
+ | npm CLI | `ai-saas-guard@0.43.2` |
252
+ | GitHub Action | `zr9959/ai-saas-guard@v0` or fixed tag `v0.43.2` |
240
253
  | Outputs | Launch decision queue, short summary, terminal, JSON, SARIF, and PR-focused markdown |
241
254
  | Project config | `.ai-saas-guard.json` rule toggles, severity overrides, suppressions, and fail thresholds |
242
255
  | 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 |
256
+ | Versioned Action tags | `v0.43.2`, `v0` |
257
+ | Current release | `0.43.2` fixes hosted cleanup observability, scoped smoke KV cleanup, shallow-history PR diff fallback, Check Run Markdown escaping, and local scan coverage diagnostics while keeping billing disabled |
245
258
  | npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
246
259
  | Repository trust hardening | Strict branch protection, Dependabot, CodeQL, fast-check fuzzing, signed release provenance assets, private vulnerability reporting, secret scanning, and push protection |
247
260
  | 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 |
248
261
  | Hosted GitHub App staging | Private App `ai-saas-guard-hosted` (`3834787`) installed on `zr9959/ai-saas-guard`; hosted operations evidence is in [docs/hosted-operations-evidence.md](docs/hosted-operations-evidence.md) |
262
+ | Public beta readiness | Blocked on real design-partner feedback, deployed source-checkout proof, full GitHub App deletion proof, and provider monitoring evidence for the source-checkout path |
249
263
  | OpenSSF Best Practices | Passing badge, project `12955`; `.bestpractices.json` remains the conservative evidence record |
250
264
  | Previous roadmap | v0.36.0 plan is tracked in [docs/v0.36-roadmap.md](docs/v0.36-roadmap.md) |
251
265
 
266
+ ## Related TIYBAI Tools
267
+
268
+ Built by [TIYBAI](https://www.tiybai.com/), `ai-saas-guard` stays focused on launch-risk review for AI-built SaaS apps. These adjacent TIYBAI pages are useful for developer workflows without turning this project into a broad cross-promotion surface:
269
+
270
+ - [TIYBAI Toolbox](https://www.tiybai.com/en/tools) for browser-based utilities that do not require installing a separate desktop app.
271
+ - [JSON Formatter](https://www.tiybai.com/en/tools/developer/json-formatter) for checking structured API responses and config snippets.
272
+ - [JWT Decoder](https://www.tiybai.com/en/tools/developer/jwt-decoder) for inspecting token claims during auth review.
273
+ - [URL Encoder / Decoder](https://www.tiybai.com/en/tools/developer/url-encoder) for checking callback URLs, webhook URLs, and encoded query strings.
274
+ - [AI Metadata Generator](https://www.tiybai.com/en/tools/ai/metadata-generator) for preparing public page metadata before launch.
275
+ - [PageStow](https://plugin.tiybai.com/) for saving browser context, sessions, notes, and tasks locally in Chrome.
276
+
252
277
  ## Example Finding
253
278
 
254
279
  Terminal output is designed to be useful to a reviewer, not just a scanner dashboard.
@@ -425,7 +450,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
425
450
 
426
451
  ## GitHub Action
427
452
 
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:
453
+ The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.43.2` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
429
454
 
430
455
  ```yaml
431
456
  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, fileCollection: context.fileCollection });
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
  }
package/dist/context.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { type TextFile } from "./utils/files.js";
1
+ import { type FileCollectionDiagnostics, type TextFile } from "./utils/files.js";
2
2
  export interface ScanContext {
3
3
  rootDir: string;
4
4
  files: readonly TextFile[];
5
5
  filesByPath: ReadonlyMap<string, TextFile>;
6
+ fileCollection: FileCollectionDiagnostics;
6
7
  getFiles: (predicate?: (file: TextFile) => boolean) => TextFile[];
7
8
  }
8
9
  export type ScanInput = string | ScanContext;
package/dist/context.js CHANGED
@@ -1,11 +1,13 @@
1
- import { collectTextFiles } from "./utils/files.js";
1
+ import { collectTextFilesWithDiagnostics } from "./utils/files.js";
2
2
  export async function createScanContext(rootDir) {
3
- const files = await collectTextFiles(rootDir);
3
+ const collection = await collectTextFilesWithDiagnostics(rootDir);
4
+ const { files } = collection;
4
5
  const filesByPath = new Map(files.map((file) => [file.path, file]));
5
6
  return {
6
7
  rootDir,
7
8
  files,
8
9
  filesByPath,
10
+ fileCollection: collection.diagnostics,
9
11
  getFiles(predicate) {
10
12
  return predicate ? files.filter(predicate) : [...files];
11
13
  }
@@ -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
@@ -15,6 +15,7 @@ export interface HostedReadOnlyCheckoutCommandResult {
15
15
  stdout: string;
16
16
  }
17
17
  export type HostedReadOnlyCheckoutCommandRunner = (command: HostedReadOnlyCheckoutCommand) => Promise<HostedReadOnlyCheckoutCommandResult> | HostedReadOnlyCheckoutCommandResult;
18
+ export type HostedCheckoutCleanup = (checkoutDir: string) => Promise<void> | void;
18
19
  export type HostedInstallationTokenProvider = (input: HostedServiceScanRunnerInput) => Promise<string> | string;
19
20
  export interface HostedReadOnlyCheckoutScanRunnerOptions {
20
21
  checkoutRoot?: string;
@@ -26,6 +27,7 @@ export interface HostedReadOnlyCheckoutScanRunnerOptions {
26
27
  maxOutputBytes?: number;
27
28
  installationTokenProvider: HostedInstallationTokenProvider;
28
29
  commandRunner?: HostedReadOnlyCheckoutCommandRunner;
30
+ cleanupCheckout?: HostedCheckoutCleanup;
29
31
  }
30
32
  export interface HostedSourceCheckoutTrialPlanInput {
31
33
  requestedAt: string;
@@ -258,13 +258,13 @@ export async function runHostedReadOnlyCheckoutScan(input, options) {
258
258
  const maxOutputBytes = clampPositiveInteger(options.maxOutputBytes, HOSTED_WORKER_MAX_OUTPUT_BYTES, HOSTED_WORKER_MAX_OUTPUT_BYTES);
259
259
  const fetchDepth = clampPositiveInteger(options.fetchDepth, DEFAULT_FETCH_DEPTH, MAX_FETCH_DEPTH);
260
260
  const checkoutRoot = options.checkoutRoot ?? join(tmpdir(), "ai-saas-guard-hosted-checkouts");
261
+ const cleanupCheckout = options.cleanupCheckout ?? defaultCleanupCheckout;
261
262
  const token = await options.installationTokenProvider(input);
262
263
  if (typeof token !== "string" || token.trim().length === 0) {
263
264
  throw new HostedReadOnlyCheckoutScanError("missing_installation_token");
264
265
  }
265
266
  await mkdir(checkoutRoot, { recursive: true, mode: 0o700 });
266
267
  const checkoutDir = await mkdtemp(join(checkoutRoot, "job-"));
267
- let terminalError;
268
268
  try {
269
269
  await chmod(checkoutDir, 0o700);
270
270
  const askpassPath = join(checkoutDir, ".git-askpass.sh");
@@ -297,23 +297,23 @@ export async function runHostedReadOnlyCheckoutScan(input, options) {
297
297
  return compactScanRunnerResult(cliResult.stdout);
298
298
  }
299
299
  catch (error) {
300
- terminalError =
301
- error instanceof HostedReadOnlyCheckoutScanError
302
- ? error
303
- : new HostedReadOnlyCheckoutScanError("cli_scan_failed");
300
+ const terminalError = error instanceof HostedReadOnlyCheckoutScanError
301
+ ? error
302
+ : new HostedReadOnlyCheckoutScanError("cli_scan_failed");
304
303
  throw terminalError;
305
304
  }
306
305
  finally {
307
306
  try {
308
- await rm(checkoutDir, { recursive: true, force: true });
307
+ await cleanupCheckout(checkoutDir);
309
308
  }
310
309
  catch {
311
- if (!terminalError) {
312
- throw new HostedReadOnlyCheckoutScanError("cleanup_failed");
313
- }
310
+ throw new HostedReadOnlyCheckoutScanError("cleanup_failed");
314
311
  }
315
312
  }
316
313
  }
314
+ async function defaultCleanupCheckout(checkoutDir) {
315
+ await rm(checkoutDir, { recursive: true, force: true });
316
+ }
317
317
  function commandSpec(stage, command, args, cwd, env, timeoutMs, maxOutputBytes) {
318
318
  return {
319
319
  stage,
package/dist/index.d.ts CHANGED
@@ -7,11 +7,14 @@ 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, StackInventoryWarning } from "./stackInventory.js";
17
+ export type { FileCollectionDiagnostics, TextFile, TextFileCollection } from "./utils/files.js";
15
18
  export type { FindingSuppression, GuardConfig, RuleConfigValue } from "./config.js";
16
19
  export type { RuleMetadata, RuleStability } from "./rules/catalog.js";
17
20
  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";
@@ -8,6 +8,9 @@ export function formatSummaryReport(report) {
8
8
  lines.push(`Findings: ${summaryText(report)}`);
9
9
  lines.push(`Launch gate: ${launchGateVerdict(report)}`);
10
10
  lines.push(`Decision queue: ${launchDecisionQuestions(report.findings)[0]}`);
11
+ const scanCoverage = scanCoverageText(report);
12
+ if (scanCoverage)
13
+ lines.push(`Scan coverage: ${scanCoverage}`);
11
14
  if (report.findings.length > 0) {
12
15
  lines.push("Review trust-boundary findings before deploy/cost hygiene.");
13
16
  }
@@ -73,3 +76,43 @@ function summaryText(report) {
73
76
  return "0 findings";
74
77
  return `${report.summary.total} findings: ${report.summary.critical} critical, ${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low, ${report.summary.info} info`;
75
78
  }
79
+ function scanCoverageText(report) {
80
+ const parts = [];
81
+ const collection = report.fileCollection;
82
+ if (collection) {
83
+ const unreadableFileCount = collection.unreadableFiles.length;
84
+ const unreadableDirectoryCount = collection.unreadableDirectories.length;
85
+ const skippedLargeCount = collection.skippedLargeFiles.length;
86
+ const skippedBudgetCount = collection.skippedBudgetFiles.length;
87
+ const hasCollectionWarning = unreadableFileCount > 0 ||
88
+ unreadableDirectoryCount > 0 ||
89
+ skippedLargeCount > 0 ||
90
+ skippedBudgetCount > 0 ||
91
+ collection.maxFilesReached ||
92
+ collection.maxTotalBytesReached;
93
+ if (hasCollectionWarning) {
94
+ parts.push(`${collection.filesScanned} ${plural(collection.filesScanned, "file")} scanned`);
95
+ if (unreadableFileCount > 0)
96
+ parts.push(`${unreadableFileCount} unreadable ${plural(unreadableFileCount, "file")}`);
97
+ if (unreadableDirectoryCount > 0) {
98
+ parts.push(`${unreadableDirectoryCount} unreadable ${plural(unreadableDirectoryCount, "directory", "directories")}`);
99
+ }
100
+ if (skippedLargeCount > 0)
101
+ parts.push(`${skippedLargeCount} large ${plural(skippedLargeCount, "file")} skipped`);
102
+ if (skippedBudgetCount > 0)
103
+ parts.push(`${skippedBudgetCount} budget-skipped ${plural(skippedBudgetCount, "file")}`);
104
+ if (collection.maxFilesReached)
105
+ parts.push("file count budget reached");
106
+ if (collection.maxTotalBytesReached)
107
+ parts.push("total byte budget reached");
108
+ }
109
+ }
110
+ const malformedPackageCount = report.stackInventory?.warnings.filter((warning) => warning.reason === "invalid_package_json").length ?? 0;
111
+ if (malformedPackageCount > 0) {
112
+ parts.push(`${malformedPackageCount} malformed package ${plural(malformedPackageCount, "manifest")}`);
113
+ }
114
+ return parts.length > 0 ? parts.join("; ") : undefined;
115
+ }
116
+ function plural(count, singular, pluralValue = `${singular}s`) {
117
+ return count === 1 ? singular : pluralValue;
118
+ }
@@ -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 [];
@@ -84,14 +84,28 @@ export async function classifyPrRisk(options) {
84
84
  }
85
85
  async function readGitDiff(rootDir, base) {
86
86
  if (base) {
87
+ const tripleDotArgs = ["diff", `${base}...HEAD`];
87
88
  try {
88
- const { stdout } = await execFileAsync("git", ["diff", `${base}...HEAD`], { cwd: rootDir, maxBuffer: 20 * 1024 * 1024 });
89
+ const { stdout } = await execFileAsync("git", tripleDotArgs, { cwd: rootDir, maxBuffer: 20 * 1024 * 1024 });
89
90
  return { diffText: stdout, diagnostics: [] };
90
91
  }
91
92
  catch (error) {
93
+ if (isMergeBaseUnavailableError(error)) {
94
+ const directDiffArgs = ["diff", `${base}..HEAD`];
95
+ try {
96
+ const { stdout } = await execFileAsync("git", directDiffArgs, { cwd: rootDir, maxBuffer: 20 * 1024 * 1024 });
97
+ return { diffText: stdout, diagnostics: [] };
98
+ }
99
+ catch (fallbackError) {
100
+ return {
101
+ diffText: "",
102
+ diagnostics: [buildGitDiffFailureFinding(rootDir, ["git", ...directDiffArgs], fallbackError, base)]
103
+ };
104
+ }
105
+ }
92
106
  return {
93
107
  diffText: "",
94
- diagnostics: [buildGitDiffFailureFinding(rootDir, ["git", "diff", `${base}...HEAD`], error, base)]
108
+ diagnostics: [buildGitDiffFailureFinding(rootDir, ["git", ...tripleDotArgs], error, base)]
95
109
  };
96
110
  }
97
111
  }
@@ -146,6 +160,10 @@ function buildGitDiffFailureFinding(rootDir, command, error, base) {
146
160
  suggestedFix
147
161
  });
148
162
  }
163
+ function isMergeBaseUnavailableError(error) {
164
+ const lowerError = getGitErrorText(error).toLowerCase();
165
+ return lowerError.includes("no merge base") || lowerError.includes("shallow");
166
+ }
149
167
  function buildFetchCommand(base) {
150
168
  const remoteRef = /^([^/\s]+)\/(.+)$/.exec(base);
151
169
  if (remoteRef)
@@ -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();