prodlint 0.9.0 → 0.9.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ npx prodlint
13
13
  ```
14
14
 
15
15
  ```
16
- prodlint v0.9.0
16
+ prodlint v0.9.1
17
17
  Scanned 148 files · 3 critical · 5 warnings
18
18
 
19
19
  src/app/api/checkout/route.ts
package/dist/cli.js CHANGED
@@ -715,8 +715,8 @@ function summarizeFindings(findings) {
715
715
 
716
716
  // src/rules/secrets.ts
717
717
  var SECRET_PATTERNS = [
718
- { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
719
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
718
+ { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
719
+ { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
720
720
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
721
721
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
722
722
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
@@ -1250,7 +1250,7 @@ var corsConfigRule = {
1250
1250
  };
1251
1251
 
1252
1252
  // src/rules/ai-smells.ts
1253
- var CONSOLE_LOG_THRESHOLD = 5;
1253
+ var CONSOLE_LOG_THRESHOLD = 3;
1254
1254
  var ANY_TYPE_THRESHOLD = 5;
1255
1255
  var COMMENTED_CODE_THRESHOLD = 3;
1256
1256
  var aiSmellsRule = {
@@ -3290,6 +3290,7 @@ var useClientOveruseRule = {
3290
3290
  // src/rules/env-fallback-secret.ts
3291
3291
  var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
3292
3292
  var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
3293
+ var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
3293
3294
  var envFallbackSecretRule = {
3294
3295
  id: "env-fallback-secret",
3295
3296
  name: "Secret with Fallback Value",
@@ -3317,6 +3318,20 @@ var envFallbackSecretRule = {
3317
3318
  });
3318
3319
  continue;
3319
3320
  }
3321
+ const connMatch = CONN_STRING_FALLBACK.exec(line);
3322
+ if (connMatch) {
3323
+ findings.push({
3324
+ ruleId: "env-fallback-secret",
3325
+ file: file.relativePath,
3326
+ line: i + 1,
3327
+ column: connMatch.index + 1,
3328
+ message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
3329
+ severity: "warning",
3330
+ category: "security",
3331
+ fix: "Fail fast when required env vars are missing instead of falling back to a default value"
3332
+ });
3333
+ continue;
3334
+ }
3320
3335
  const genericMatch = ENV_FALLBACK.exec(line);
3321
3336
  if (genericMatch && !isConfigFile(file.relativePath)) {
3322
3337
  findings.push({
@@ -4627,111 +4642,113 @@ function reportWebPretty(result) {
4627
4642
  }
4628
4643
 
4629
4644
  // src/web-scanner/checks.ts
4630
- function make(id, name, description, maxPoints, severity, status, details) {
4645
+ function make(id, name, description, maxPoints, severity, maturity, status, details) {
4631
4646
  return {
4632
4647
  id,
4633
4648
  name,
4634
4649
  description,
4635
4650
  status,
4636
4651
  severity,
4652
+ maturity,
4637
4653
  details,
4638
4654
  points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4639
4655
  maxPoints
4640
4656
  };
4641
4657
  }
4642
4658
  function checkRobotsTxt(ctx) {
4643
- if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4644
- return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4659
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
4660
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
4645
4661
  }
4646
4662
  function checkRobotsAiDirectives(ctx) {
4647
- if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4648
- const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4663
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No robots.txt found. AI bots have no guidance.");
4664
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
4649
4665
  const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4650
- if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4651
- if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4652
- return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4666
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No AI-specific user-agent directives found.");
4667
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4668
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4653
4669
  }
4654
4670
  function checkContentUsage(ctx) {
4655
4671
  const hasHeader = ctx.headers["content-usage"] != null;
4656
4672
  const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4657
- if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4658
- return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4673
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "fail", "No Content-Usage directives found.");
4674
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4659
4675
  }
4660
4676
  function checkLlmsTxt(ctx) {
4661
- if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4662
- const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4663
- if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4664
- return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4677
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
4678
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4679
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "warn", "llms.txt found but appears minimal.");
4680
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "pass", `llms.txt found with ${lines.length} content lines.`);
4665
4681
  }
4666
4682
  function checkTdmRep(ctx) {
4667
4683
  const hasWK = ctx.tdmRep != null;
4668
4684
  const hasHeader = ctx.headers["tdm-reservation"] != null;
4669
- if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4670
- return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4685
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
4686
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4671
4687
  }
4672
4688
  function checkAiDisclosure(ctx) {
4673
- if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4674
- return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4689
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "fail", "No AI-Disclosure header found.");
4690
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4675
4691
  }
4676
4692
  function checkAgentCard(ctx) {
4677
- if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4693
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "fail", "No A2A AgentCard found.");
4678
4694
  try {
4679
4695
  const card = JSON.parse(ctx.agentCard);
4680
- if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4681
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4696
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but missing name or skills.");
4697
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4682
4698
  } catch {
4683
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4699
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
4684
4700
  }
4685
4701
  }
4686
4702
  function checkAiTxt(ctx) {
4687
- if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4703
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "fail", "No ai.txt found at site root.");
4688
4704
  const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4689
- if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4690
- return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4705
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "warn", "ai.txt found but appears minimal.");
4706
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
4691
4707
  }
4692
4708
  function checkWebMCP(ctx) {
4693
- if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4709
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
4694
4710
  const hasToolname = /toolname=/i.test(ctx.html);
4695
4711
  const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4696
- if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4712
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
4697
4713
  const count = (ctx.html.match(/toolname=/gi) || []).length;
4698
- return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4714
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4699
4715
  }
4700
4716
  function checkStructuredData(ctx) {
4701
- if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4717
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
4702
4718
  const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4703
4719
  const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4704
- if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4705
- return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4720
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
4721
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4706
4722
  }
4707
4723
  function checkOpenGraph(ctx) {
4708
- if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4724
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
4709
4725
  const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4710
4726
  const passed = checks.filter(Boolean).length;
4711
- if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4712
- if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4713
- return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4727
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
4728
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
4729
+ if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
4730
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
4714
4731
  }
4715
4732
  function checkSitemap(ctx) {
4716
- if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4733
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
4717
4734
  const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4718
- if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4719
- if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4720
- return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4735
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", "Sitemap index found.");
4736
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
4737
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
4721
4738
  }
4722
4739
  function checkHttpSignatures(ctx) {
4723
4740
  const hasDirectory = ctx.httpSigDirectory != null;
4724
4741
  const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4725
4742
  const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4726
- if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4727
- if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4728
- return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4743
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "fail", "No HTTP signature support detected.");
4744
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", "/.well-known/http-message-signatures-directory found.");
4745
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4729
4746
  }
4730
4747
  function checkPageSpeed(ctx) {
4731
- if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4732
- if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4733
- if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4734
- return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4748
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
4749
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4750
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4751
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4735
4752
  }
4736
4753
  var allChecks = [
4737
4754
  checkRobotsTxt,
@@ -4770,6 +4787,29 @@ function isPrivateHost(hostname) {
4770
4787
  if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4771
4788
  const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4772
4789
  if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4790
+ const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4791
+ if (v4mapped) {
4792
+ const [, a, b] = v4mapped.map(Number);
4793
+ if (a === 10 || a === 127 || a === 0) return true;
4794
+ if (a === 172 && b >= 16 && b <= 31) return true;
4795
+ if (a === 192 && b === 168) return true;
4796
+ if (a === 169 && b === 254) return true;
4797
+ return false;
4798
+ }
4799
+ if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
4800
+ if (/^\d+$/.test(hostname)) {
4801
+ const dec = parseInt(hostname, 10);
4802
+ if (dec >= 0 && dec <= 4294967295) {
4803
+ const a = dec >>> 24 & 255;
4804
+ const b = dec >>> 16 & 255;
4805
+ if (a === 10 || a === 127 || a === 0) return true;
4806
+ if (a === 172 && b >= 16 && b <= 31) return true;
4807
+ if (a === 192 && b === 168) return true;
4808
+ if (a === 169 && b === 254) return true;
4809
+ if (a === 100 && b >= 64 && b <= 127) return true;
4810
+ if (a === 198 && (b === 18 || b === 19)) return true;
4811
+ }
4812
+ }
4773
4813
  const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4774
4814
  if (ipv4) {
4775
4815
  const [, a, b] = ipv4.map(Number);
@@ -4782,22 +4822,49 @@ function isPrivateHost(hostname) {
4782
4822
  }
4783
4823
  return false;
4784
4824
  }
4825
+ function isAllowedProtocol(url) {
4826
+ try {
4827
+ const p = new URL(url).protocol;
4828
+ return p === "https:" || p === "http:";
4829
+ } catch {
4830
+ return false;
4831
+ }
4832
+ }
4785
4833
  function validateRedirectUrl(responseUrl) {
4786
4834
  try {
4787
4835
  const parsed = new URL(responseUrl);
4836
+ if (!isAllowedProtocol(responseUrl)) return false;
4788
4837
  return !isPrivateHost(parsed.hostname);
4789
4838
  } catch {
4790
4839
  return false;
4791
4840
  }
4792
4841
  }
4842
+ var MAX_REDIRECTS = 5;
4843
+ async function safeFetch(url, timeout, signal) {
4844
+ let current = url;
4845
+ for (let i = 0; i < MAX_REDIRECTS; i++) {
4846
+ const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
4847
+ const status = r.status;
4848
+ if (status >= 300 && status < 400) {
4849
+ const location = r.headers.get("location");
4850
+ if (!location) return null;
4851
+ const resolved = new URL(location, current).toString();
4852
+ if (!validateRedirectUrl(resolved)) return null;
4853
+ current = resolved;
4854
+ continue;
4855
+ }
4856
+ return r;
4857
+ }
4858
+ return null;
4859
+ }
4793
4860
  async function fetchText(url, timeout = 8e3) {
4794
4861
  try {
4795
4862
  const c = new AbortController();
4796
4863
  const t = setTimeout(() => c.abort(), timeout);
4797
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4864
+ const r = await safeFetch(url, timeout, c.signal);
4798
4865
  clearTimeout(t);
4799
- if (r.url && !validateRedirectUrl(r.url)) return null;
4800
- return r.ok ? await r.text() : null;
4866
+ if (!r || !r.ok) return null;
4867
+ return await r.text();
4801
4868
  } catch {
4802
4869
  return null;
4803
4870
  }
@@ -4806,9 +4873,9 @@ async function fetchHeaders(url, timeout = 8e3) {
4806
4873
  try {
4807
4874
  const c = new AbortController();
4808
4875
  const t = setTimeout(() => c.abort(), timeout);
4809
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4876
+ const r = await safeFetch(url, timeout, c.signal);
4810
4877
  clearTimeout(t);
4811
- if (r.url && !validateRedirectUrl(r.url)) return {};
4878
+ if (!r) return {};
4812
4879
  const h = {};
4813
4880
  r.headers.forEach((v, k) => {
4814
4881
  h[k.toLowerCase()] = v;
@@ -4823,20 +4890,18 @@ async function fetchWithTiming(url, timeout = 15e3) {
4823
4890
  const c = new AbortController();
4824
4891
  const t = setTimeout(() => c.abort(), timeout);
4825
4892
  const start = Date.now();
4826
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4827
- const html = await r.text();
4893
+ const r = await safeFetch(url, timeout, c.signal);
4828
4894
  clearTimeout(t);
4829
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4830
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4895
+ if (!r || !r.ok) return { html: null, loadTimeMs: null };
4896
+ const html = await r.text();
4897
+ return { html, loadTimeMs: Date.now() - start };
4831
4898
  } catch {
4832
4899
  return { html: null, loadTimeMs: null };
4833
4900
  }
4834
4901
  }
4835
4902
  function normalizeUrl(input) {
4836
4903
  let url = input.trim();
4837
- if (!/^https?:\/\//i.test(url)) {
4838
- url = `https://${url}`;
4839
- }
4904
+ if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
4840
4905
  const parsed = new URL(url);
4841
4906
  return parsed.origin;
4842
4907
  }
package/dist/index.d.ts CHANGED
@@ -50,7 +50,7 @@ interface CategoryScore {
50
50
  score: number;
51
51
  findingCount: number;
52
52
  }
53
- interface ScanResult {
53
+ interface ScanResult$1 {
54
54
  version: string;
55
55
  scannedPath: string;
56
56
  filesScanned: number;
@@ -69,18 +69,20 @@ interface ScanOptions {
69
69
  ignore?: string[];
70
70
  }
71
71
 
72
- declare function scan(options: ScanOptions): Promise<ScanResult>;
72
+ declare function scan(options: ScanOptions): Promise<ScanResult$1>;
73
73
 
74
74
  declare const rules: Rule[];
75
75
 
76
76
  type CheckStatus = "pass" | "fail" | "warn" | "info";
77
77
  type CheckSeverity = "critical" | "high" | "medium" | "low";
78
+ type CheckMaturity = "established" | "emerging";
78
79
  interface ScanCheck {
79
80
  id: string;
80
81
  name: string;
81
82
  description: string;
82
83
  status: CheckStatus;
83
84
  severity: CheckSeverity;
85
+ maturity: CheckMaturity;
84
86
  points: number;
85
87
  maxPoints: number;
86
88
  details?: string;
@@ -99,7 +101,7 @@ interface CheckContext {
99
101
  html: string | null;
100
102
  loadTimeMs: number | null;
101
103
  }
102
- interface WebScanResult {
104
+ interface ScanResult {
103
105
  url: string;
104
106
  domain: string;
105
107
  scannedAt: string;
@@ -114,6 +116,6 @@ interface WebScanResult {
114
116
  };
115
117
  }
116
118
 
117
- declare function runWebScan(targetUrl: string): Promise<WebScanResult>;
119
+ declare function runWebScan(targetUrl: string): Promise<ScanResult>;
118
120
 
119
- export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult, type Severity, type CheckContext as WebCheckContext, type ScanCheck as WebScanCheck, type WebScanResult, rules, runWebScan, scan };
121
+ export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult$1 as ScanResult, type Severity, type CheckContext as WebCheckContext, type ScanCheck as WebScanCheck, type ScanResult as WebScanResult, rules, runWebScan, scan };
package/dist/index.js CHANGED
@@ -710,8 +710,8 @@ function summarizeFindings(findings) {
710
710
 
711
711
  // src/rules/secrets.ts
712
712
  var SECRET_PATTERNS = [
713
- { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
714
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
713
+ { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
714
+ { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
715
715
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
716
716
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
717
717
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
@@ -1245,7 +1245,7 @@ var corsConfigRule = {
1245
1245
  };
1246
1246
 
1247
1247
  // src/rules/ai-smells.ts
1248
- var CONSOLE_LOG_THRESHOLD = 5;
1248
+ var CONSOLE_LOG_THRESHOLD = 3;
1249
1249
  var ANY_TYPE_THRESHOLD = 5;
1250
1250
  var COMMENTED_CODE_THRESHOLD = 3;
1251
1251
  var aiSmellsRule = {
@@ -3285,6 +3285,7 @@ var useClientOveruseRule = {
3285
3285
  // src/rules/env-fallback-secret.ts
3286
3286
  var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
3287
3287
  var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
3288
+ var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
3288
3289
  var envFallbackSecretRule = {
3289
3290
  id: "env-fallback-secret",
3290
3291
  name: "Secret with Fallback Value",
@@ -3312,6 +3313,20 @@ var envFallbackSecretRule = {
3312
3313
  });
3313
3314
  continue;
3314
3315
  }
3316
+ const connMatch = CONN_STRING_FALLBACK.exec(line);
3317
+ if (connMatch) {
3318
+ findings.push({
3319
+ ruleId: "env-fallback-secret",
3320
+ file: file.relativePath,
3321
+ line: i + 1,
3322
+ column: connMatch.index + 1,
3323
+ message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
3324
+ severity: "warning",
3325
+ category: "security",
3326
+ fix: "Fail fast when required env vars are missing instead of falling back to a default value"
3327
+ });
3328
+ continue;
3329
+ }
3315
3330
  const genericMatch = ENV_FALLBACK.exec(line);
3316
3331
  if (genericMatch && !isConfigFile(file.relativePath)) {
3317
3332
  findings.push({
@@ -4487,111 +4502,113 @@ async function scan(options) {
4487
4502
  }
4488
4503
 
4489
4504
  // src/web-scanner/checks.ts
4490
- function make(id, name, description, maxPoints, severity, status, details) {
4505
+ function make(id, name, description, maxPoints, severity, maturity, status, details) {
4491
4506
  return {
4492
4507
  id,
4493
4508
  name,
4494
4509
  description,
4495
4510
  status,
4496
4511
  severity,
4512
+ maturity,
4497
4513
  details,
4498
4514
  points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4499
4515
  maxPoints
4500
4516
  };
4501
4517
  }
4502
4518
  function checkRobotsTxt(ctx) {
4503
- if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4504
- return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4519
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
4520
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
4505
4521
  }
4506
4522
  function checkRobotsAiDirectives(ctx) {
4507
- if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4508
- const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4523
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No robots.txt found. AI bots have no guidance.");
4524
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
4509
4525
  const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4510
- if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4511
- if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4512
- return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4526
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No AI-specific user-agent directives found.");
4527
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4528
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4513
4529
  }
4514
4530
  function checkContentUsage(ctx) {
4515
4531
  const hasHeader = ctx.headers["content-usage"] != null;
4516
4532
  const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4517
- if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4518
- return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4533
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "fail", "No Content-Usage directives found.");
4534
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4519
4535
  }
4520
4536
  function checkLlmsTxt(ctx) {
4521
- if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4522
- const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4523
- if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4524
- return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4537
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
4538
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4539
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "warn", "llms.txt found but appears minimal.");
4540
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "pass", `llms.txt found with ${lines.length} content lines.`);
4525
4541
  }
4526
4542
  function checkTdmRep(ctx) {
4527
4543
  const hasWK = ctx.tdmRep != null;
4528
4544
  const hasHeader = ctx.headers["tdm-reservation"] != null;
4529
- if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4530
- return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4545
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
4546
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4531
4547
  }
4532
4548
  function checkAiDisclosure(ctx) {
4533
- if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4534
- return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4549
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "fail", "No AI-Disclosure header found.");
4550
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4535
4551
  }
4536
4552
  function checkAgentCard(ctx) {
4537
- if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4553
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "fail", "No A2A AgentCard found.");
4538
4554
  try {
4539
4555
  const card = JSON.parse(ctx.agentCard);
4540
- if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4541
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4556
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but missing name or skills.");
4557
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4542
4558
  } catch {
4543
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4559
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
4544
4560
  }
4545
4561
  }
4546
4562
  function checkAiTxt(ctx) {
4547
- if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4563
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "fail", "No ai.txt found at site root.");
4548
4564
  const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4549
- if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4550
- return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4565
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "warn", "ai.txt found but appears minimal.");
4566
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
4551
4567
  }
4552
4568
  function checkWebMCP(ctx) {
4553
- if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4569
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
4554
4570
  const hasToolname = /toolname=/i.test(ctx.html);
4555
4571
  const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4556
- if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4572
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
4557
4573
  const count = (ctx.html.match(/toolname=/gi) || []).length;
4558
- return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4574
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4559
4575
  }
4560
4576
  function checkStructuredData(ctx) {
4561
- if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4577
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
4562
4578
  const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4563
4579
  const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4564
- if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4565
- return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4580
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
4581
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4566
4582
  }
4567
4583
  function checkOpenGraph(ctx) {
4568
- if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4584
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
4569
4585
  const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4570
4586
  const passed = checks.filter(Boolean).length;
4571
- if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4572
- if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4573
- return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4587
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
4588
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
4589
+ if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
4590
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
4574
4591
  }
4575
4592
  function checkSitemap(ctx) {
4576
- if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4593
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
4577
4594
  const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4578
- if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4579
- if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4580
- return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4595
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", "Sitemap index found.");
4596
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
4597
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
4581
4598
  }
4582
4599
  function checkHttpSignatures(ctx) {
4583
4600
  const hasDirectory = ctx.httpSigDirectory != null;
4584
4601
  const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4585
4602
  const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4586
- if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4587
- if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4588
- return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4603
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "fail", "No HTTP signature support detected.");
4604
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", "/.well-known/http-message-signatures-directory found.");
4605
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4589
4606
  }
4590
4607
  function checkPageSpeed(ctx) {
4591
- if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4592
- if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4593
- if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4594
- return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4608
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
4609
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4610
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4611
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4595
4612
  }
4596
4613
  var allChecks = [
4597
4614
  checkRobotsTxt,
@@ -4630,6 +4647,29 @@ function isPrivateHost(hostname) {
4630
4647
  if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4631
4648
  const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4632
4649
  if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4650
+ const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4651
+ if (v4mapped) {
4652
+ const [, a, b] = v4mapped.map(Number);
4653
+ if (a === 10 || a === 127 || a === 0) return true;
4654
+ if (a === 172 && b >= 16 && b <= 31) return true;
4655
+ if (a === 192 && b === 168) return true;
4656
+ if (a === 169 && b === 254) return true;
4657
+ return false;
4658
+ }
4659
+ if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
4660
+ if (/^\d+$/.test(hostname)) {
4661
+ const dec = parseInt(hostname, 10);
4662
+ if (dec >= 0 && dec <= 4294967295) {
4663
+ const a = dec >>> 24 & 255;
4664
+ const b = dec >>> 16 & 255;
4665
+ if (a === 10 || a === 127 || a === 0) return true;
4666
+ if (a === 172 && b >= 16 && b <= 31) return true;
4667
+ if (a === 192 && b === 168) return true;
4668
+ if (a === 169 && b === 254) return true;
4669
+ if (a === 100 && b >= 64 && b <= 127) return true;
4670
+ if (a === 198 && (b === 18 || b === 19)) return true;
4671
+ }
4672
+ }
4633
4673
  const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4634
4674
  if (ipv4) {
4635
4675
  const [, a, b] = ipv4.map(Number);
@@ -4642,22 +4682,49 @@ function isPrivateHost(hostname) {
4642
4682
  }
4643
4683
  return false;
4644
4684
  }
4685
+ function isAllowedProtocol(url) {
4686
+ try {
4687
+ const p = new URL(url).protocol;
4688
+ return p === "https:" || p === "http:";
4689
+ } catch {
4690
+ return false;
4691
+ }
4692
+ }
4645
4693
  function validateRedirectUrl(responseUrl) {
4646
4694
  try {
4647
4695
  const parsed = new URL(responseUrl);
4696
+ if (!isAllowedProtocol(responseUrl)) return false;
4648
4697
  return !isPrivateHost(parsed.hostname);
4649
4698
  } catch {
4650
4699
  return false;
4651
4700
  }
4652
4701
  }
4702
+ var MAX_REDIRECTS = 5;
4703
+ async function safeFetch(url, timeout, signal) {
4704
+ let current = url;
4705
+ for (let i = 0; i < MAX_REDIRECTS; i++) {
4706
+ const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
4707
+ const status = r.status;
4708
+ if (status >= 300 && status < 400) {
4709
+ const location = r.headers.get("location");
4710
+ if (!location) return null;
4711
+ const resolved = new URL(location, current).toString();
4712
+ if (!validateRedirectUrl(resolved)) return null;
4713
+ current = resolved;
4714
+ continue;
4715
+ }
4716
+ return r;
4717
+ }
4718
+ return null;
4719
+ }
4653
4720
  async function fetchText(url, timeout = 8e3) {
4654
4721
  try {
4655
4722
  const c = new AbortController();
4656
4723
  const t = setTimeout(() => c.abort(), timeout);
4657
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4724
+ const r = await safeFetch(url, timeout, c.signal);
4658
4725
  clearTimeout(t);
4659
- if (r.url && !validateRedirectUrl(r.url)) return null;
4660
- return r.ok ? await r.text() : null;
4726
+ if (!r || !r.ok) return null;
4727
+ return await r.text();
4661
4728
  } catch {
4662
4729
  return null;
4663
4730
  }
@@ -4666,9 +4733,9 @@ async function fetchHeaders(url, timeout = 8e3) {
4666
4733
  try {
4667
4734
  const c = new AbortController();
4668
4735
  const t = setTimeout(() => c.abort(), timeout);
4669
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4736
+ const r = await safeFetch(url, timeout, c.signal);
4670
4737
  clearTimeout(t);
4671
- if (r.url && !validateRedirectUrl(r.url)) return {};
4738
+ if (!r) return {};
4672
4739
  const h = {};
4673
4740
  r.headers.forEach((v, k) => {
4674
4741
  h[k.toLowerCase()] = v;
@@ -4683,11 +4750,11 @@ async function fetchWithTiming(url, timeout = 15e3) {
4683
4750
  const c = new AbortController();
4684
4751
  const t = setTimeout(() => c.abort(), timeout);
4685
4752
  const start = Date.now();
4686
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4687
- const html = await r.text();
4753
+ const r = await safeFetch(url, timeout, c.signal);
4688
4754
  clearTimeout(t);
4689
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4690
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4755
+ if (!r || !r.ok) return { html: null, loadTimeMs: null };
4756
+ const html = await r.text();
4757
+ return { html, loadTimeMs: Date.now() - start };
4691
4758
  } catch {
4692
4759
  return { html: null, loadTimeMs: null };
4693
4760
  }
package/dist/mcp.js CHANGED
@@ -719,8 +719,8 @@ function summarizeFindings(findings) {
719
719
 
720
720
  // src/rules/secrets.ts
721
721
  var SECRET_PATTERNS = [
722
- { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
723
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
722
+ { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
723
+ { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
724
724
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
725
725
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
726
726
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
@@ -1254,7 +1254,7 @@ var corsConfigRule = {
1254
1254
  };
1255
1255
 
1256
1256
  // src/rules/ai-smells.ts
1257
- var CONSOLE_LOG_THRESHOLD = 5;
1257
+ var CONSOLE_LOG_THRESHOLD = 3;
1258
1258
  var ANY_TYPE_THRESHOLD = 5;
1259
1259
  var COMMENTED_CODE_THRESHOLD = 3;
1260
1260
  var aiSmellsRule = {
@@ -3294,6 +3294,7 @@ var useClientOveruseRule = {
3294
3294
  // src/rules/env-fallback-secret.ts
3295
3295
  var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
3296
3296
  var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
3297
+ var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
3297
3298
  var envFallbackSecretRule = {
3298
3299
  id: "env-fallback-secret",
3299
3300
  name: "Secret with Fallback Value",
@@ -3321,6 +3322,20 @@ var envFallbackSecretRule = {
3321
3322
  });
3322
3323
  continue;
3323
3324
  }
3325
+ const connMatch = CONN_STRING_FALLBACK.exec(line);
3326
+ if (connMatch) {
3327
+ findings.push({
3328
+ ruleId: "env-fallback-secret",
3329
+ file: file.relativePath,
3330
+ line: i + 1,
3331
+ column: connMatch.index + 1,
3332
+ message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
3333
+ severity: "warning",
3334
+ category: "security",
3335
+ fix: "Fail fast when required env vars are missing instead of falling back to a default value"
3336
+ });
3337
+ continue;
3338
+ }
3324
3339
  const genericMatch = ENV_FALLBACK.exec(line);
3325
3340
  if (genericMatch && !isConfigFile(file.relativePath)) {
3326
3341
  findings.push({
@@ -4496,111 +4511,113 @@ async function scan(options) {
4496
4511
  }
4497
4512
 
4498
4513
  // src/web-scanner/checks.ts
4499
- function make(id, name, description, maxPoints, severity, status, details) {
4514
+ function make(id, name, description, maxPoints, severity, maturity, status, details) {
4500
4515
  return {
4501
4516
  id,
4502
4517
  name,
4503
4518
  description,
4504
4519
  status,
4505
4520
  severity,
4521
+ maturity,
4506
4522
  details,
4507
4523
  points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4508
4524
  maxPoints
4509
4525
  };
4510
4526
  }
4511
4527
  function checkRobotsTxt(ctx) {
4512
- if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4513
- return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4528
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
4529
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
4514
4530
  }
4515
4531
  function checkRobotsAiDirectives(ctx) {
4516
- if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4517
- const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4532
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No robots.txt found. AI bots have no guidance.");
4533
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
4518
4534
  const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4519
- if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4520
- if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4521
- return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4535
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No AI-specific user-agent directives found.");
4536
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4537
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4522
4538
  }
4523
4539
  function checkContentUsage(ctx) {
4524
4540
  const hasHeader = ctx.headers["content-usage"] != null;
4525
4541
  const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4526
- if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4527
- return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4542
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "fail", "No Content-Usage directives found.");
4543
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4528
4544
  }
4529
4545
  function checkLlmsTxt(ctx) {
4530
- if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4531
- const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4532
- if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4533
- return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4546
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
4547
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4548
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "warn", "llms.txt found but appears minimal.");
4549
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "pass", `llms.txt found with ${lines.length} content lines.`);
4534
4550
  }
4535
4551
  function checkTdmRep(ctx) {
4536
4552
  const hasWK = ctx.tdmRep != null;
4537
4553
  const hasHeader = ctx.headers["tdm-reservation"] != null;
4538
- if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4539
- return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4554
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
4555
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4540
4556
  }
4541
4557
  function checkAiDisclosure(ctx) {
4542
- if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4543
- return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4558
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "fail", "No AI-Disclosure header found.");
4559
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4544
4560
  }
4545
4561
  function checkAgentCard(ctx) {
4546
- if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4562
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "fail", "No A2A AgentCard found.");
4547
4563
  try {
4548
4564
  const card = JSON.parse(ctx.agentCard);
4549
- if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4550
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4565
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but missing name or skills.");
4566
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4551
4567
  } catch {
4552
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4568
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
4553
4569
  }
4554
4570
  }
4555
4571
  function checkAiTxt(ctx) {
4556
- if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4572
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "fail", "No ai.txt found at site root.");
4557
4573
  const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4558
- if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4559
- return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4574
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "warn", "ai.txt found but appears minimal.");
4575
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
4560
4576
  }
4561
4577
  function checkWebMCP(ctx) {
4562
- if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4578
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
4563
4579
  const hasToolname = /toolname=/i.test(ctx.html);
4564
4580
  const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4565
- if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4581
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
4566
4582
  const count = (ctx.html.match(/toolname=/gi) || []).length;
4567
- return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4583
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4568
4584
  }
4569
4585
  function checkStructuredData(ctx) {
4570
- if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4586
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
4571
4587
  const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4572
4588
  const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4573
- if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4574
- return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4589
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
4590
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4575
4591
  }
4576
4592
  function checkOpenGraph(ctx) {
4577
- if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4593
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
4578
4594
  const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4579
4595
  const passed = checks.filter(Boolean).length;
4580
- if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4581
- if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4582
- return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4596
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
4597
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
4598
+ if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
4599
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
4583
4600
  }
4584
4601
  function checkSitemap(ctx) {
4585
- if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4602
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
4586
4603
  const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4587
- if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4588
- if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4589
- return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4604
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", "Sitemap index found.");
4605
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
4606
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
4590
4607
  }
4591
4608
  function checkHttpSignatures(ctx) {
4592
4609
  const hasDirectory = ctx.httpSigDirectory != null;
4593
4610
  const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4594
4611
  const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4595
- if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4596
- if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4597
- return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4612
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "fail", "No HTTP signature support detected.");
4613
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", "/.well-known/http-message-signatures-directory found.");
4614
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4598
4615
  }
4599
4616
  function checkPageSpeed(ctx) {
4600
- if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4601
- if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4602
- if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4603
- return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4617
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
4618
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4619
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4620
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4604
4621
  }
4605
4622
  var allChecks = [
4606
4623
  checkRobotsTxt,
@@ -4639,6 +4656,29 @@ function isPrivateHost(hostname) {
4639
4656
  if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4640
4657
  const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4641
4658
  if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4659
+ const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4660
+ if (v4mapped) {
4661
+ const [, a, b] = v4mapped.map(Number);
4662
+ if (a === 10 || a === 127 || a === 0) return true;
4663
+ if (a === 172 && b >= 16 && b <= 31) return true;
4664
+ if (a === 192 && b === 168) return true;
4665
+ if (a === 169 && b === 254) return true;
4666
+ return false;
4667
+ }
4668
+ if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
4669
+ if (/^\d+$/.test(hostname)) {
4670
+ const dec = parseInt(hostname, 10);
4671
+ if (dec >= 0 && dec <= 4294967295) {
4672
+ const a = dec >>> 24 & 255;
4673
+ const b = dec >>> 16 & 255;
4674
+ if (a === 10 || a === 127 || a === 0) return true;
4675
+ if (a === 172 && b >= 16 && b <= 31) return true;
4676
+ if (a === 192 && b === 168) return true;
4677
+ if (a === 169 && b === 254) return true;
4678
+ if (a === 100 && b >= 64 && b <= 127) return true;
4679
+ if (a === 198 && (b === 18 || b === 19)) return true;
4680
+ }
4681
+ }
4642
4682
  const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4643
4683
  if (ipv4) {
4644
4684
  const [, a, b] = ipv4.map(Number);
@@ -4651,22 +4691,49 @@ function isPrivateHost(hostname) {
4651
4691
  }
4652
4692
  return false;
4653
4693
  }
4694
+ function isAllowedProtocol(url) {
4695
+ try {
4696
+ const p = new URL(url).protocol;
4697
+ return p === "https:" || p === "http:";
4698
+ } catch {
4699
+ return false;
4700
+ }
4701
+ }
4654
4702
  function validateRedirectUrl(responseUrl) {
4655
4703
  try {
4656
4704
  const parsed = new URL(responseUrl);
4705
+ if (!isAllowedProtocol(responseUrl)) return false;
4657
4706
  return !isPrivateHost(parsed.hostname);
4658
4707
  } catch {
4659
4708
  return false;
4660
4709
  }
4661
4710
  }
4711
+ var MAX_REDIRECTS = 5;
4712
+ async function safeFetch(url, timeout, signal) {
4713
+ let current = url;
4714
+ for (let i = 0; i < MAX_REDIRECTS; i++) {
4715
+ const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
4716
+ const status = r.status;
4717
+ if (status >= 300 && status < 400) {
4718
+ const location = r.headers.get("location");
4719
+ if (!location) return null;
4720
+ const resolved = new URL(location, current).toString();
4721
+ if (!validateRedirectUrl(resolved)) return null;
4722
+ current = resolved;
4723
+ continue;
4724
+ }
4725
+ return r;
4726
+ }
4727
+ return null;
4728
+ }
4662
4729
  async function fetchText(url, timeout = 8e3) {
4663
4730
  try {
4664
4731
  const c = new AbortController();
4665
4732
  const t = setTimeout(() => c.abort(), timeout);
4666
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4733
+ const r = await safeFetch(url, timeout, c.signal);
4667
4734
  clearTimeout(t);
4668
- if (r.url && !validateRedirectUrl(r.url)) return null;
4669
- return r.ok ? await r.text() : null;
4735
+ if (!r || !r.ok) return null;
4736
+ return await r.text();
4670
4737
  } catch {
4671
4738
  return null;
4672
4739
  }
@@ -4675,9 +4742,9 @@ async function fetchHeaders(url, timeout = 8e3) {
4675
4742
  try {
4676
4743
  const c = new AbortController();
4677
4744
  const t = setTimeout(() => c.abort(), timeout);
4678
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4745
+ const r = await safeFetch(url, timeout, c.signal);
4679
4746
  clearTimeout(t);
4680
- if (r.url && !validateRedirectUrl(r.url)) return {};
4747
+ if (!r) return {};
4681
4748
  const h = {};
4682
4749
  r.headers.forEach((v, k) => {
4683
4750
  h[k.toLowerCase()] = v;
@@ -4692,20 +4759,18 @@ async function fetchWithTiming(url, timeout = 15e3) {
4692
4759
  const c = new AbortController();
4693
4760
  const t = setTimeout(() => c.abort(), timeout);
4694
4761
  const start = Date.now();
4695
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4696
- const html = await r.text();
4762
+ const r = await safeFetch(url, timeout, c.signal);
4697
4763
  clearTimeout(t);
4698
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4699
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4764
+ if (!r || !r.ok) return { html: null, loadTimeMs: null };
4765
+ const html = await r.text();
4766
+ return { html, loadTimeMs: Date.now() - start };
4700
4767
  } catch {
4701
4768
  return { html: null, loadTimeMs: null };
4702
4769
  }
4703
4770
  }
4704
4771
  function normalizeUrl(input) {
4705
4772
  let url = input.trim();
4706
- if (!/^https?:\/\//i.test(url)) {
4707
- url = `https://${url}`;
4708
- }
4773
+ if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
4709
4774
  const parsed = new URL(url);
4710
4775
  return parsed.origin;
4711
4776
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodlint",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Production readiness for vibe-coded apps — know your AI code is ready to ship",
5
5
  "license": "MIT",
6
6
  "author": "prodlint contributors",
@@ -46,6 +46,7 @@
46
46
  "@babel/parser": "^7.29.0",
47
47
  "@babel/types": "^7.29.0",
48
48
  "@modelcontextprotocol/sdk": "^1.26.0",
49
+ "@rollup/rollup-win32-x64-msvc": "^4.59.0",
49
50
  "fast-glob": "^3.3.3",
50
51
  "picocolors": "^1.1.1",
51
52
  "zod": "^4.3.6"