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 +1 -1
- package/dist/cli.js +126 -61
- package/dist/index.d.ts +7 -5
- package/dist/index.js +125 -58
- package/dist/mcp.js +126 -61
- package/package.json +2 -1
package/README.md
CHANGED
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]{
|
|
719
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
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 =
|
|
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",
|
|
4644
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
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",
|
|
4658
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
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",
|
|
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",
|
|
4664
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
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",
|
|
4670
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
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",
|
|
4674
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
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",
|
|
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",
|
|
4681
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
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",
|
|
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",
|
|
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",
|
|
4690
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
4705
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
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",
|
|
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",
|
|
4712
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4713
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
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",
|
|
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",
|
|
4719
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4720
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
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",
|
|
4727
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4728
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
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",
|
|
4732
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4733
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4734
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
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
|
|
4864
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4798
4865
|
clearTimeout(t);
|
|
4799
|
-
if (r
|
|
4800
|
-
return
|
|
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
|
|
4876
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4810
4877
|
clearTimeout(t);
|
|
4811
|
-
if (
|
|
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
|
|
4827
|
-
const html = await r.text();
|
|
4893
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4828
4894
|
clearTimeout(t);
|
|
4829
|
-
if (r
|
|
4830
|
-
|
|
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
|
|
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<
|
|
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]{
|
|
714
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
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 =
|
|
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",
|
|
4504
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
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",
|
|
4518
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
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",
|
|
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",
|
|
4524
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
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",
|
|
4530
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
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",
|
|
4534
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
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",
|
|
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",
|
|
4541
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
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",
|
|
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",
|
|
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",
|
|
4550
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
4565
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
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",
|
|
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",
|
|
4572
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4573
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
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",
|
|
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",
|
|
4579
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4580
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
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",
|
|
4587
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4588
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
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",
|
|
4592
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4593
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4594
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
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
|
|
4724
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4658
4725
|
clearTimeout(t);
|
|
4659
|
-
if (r
|
|
4660
|
-
return
|
|
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
|
|
4736
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4670
4737
|
clearTimeout(t);
|
|
4671
|
-
if (
|
|
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
|
|
4687
|
-
const html = await r.text();
|
|
4753
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4688
4754
|
clearTimeout(t);
|
|
4689
|
-
if (r
|
|
4690
|
-
|
|
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]{
|
|
723
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
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 =
|
|
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",
|
|
4513
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
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",
|
|
4527
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
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",
|
|
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",
|
|
4533
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
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",
|
|
4539
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
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",
|
|
4543
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
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",
|
|
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",
|
|
4550
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
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",
|
|
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",
|
|
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",
|
|
4559
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
4574
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
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",
|
|
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",
|
|
4581
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4582
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
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",
|
|
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",
|
|
4588
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4589
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
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",
|
|
4596
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4597
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
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",
|
|
4601
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4602
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4603
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
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
|
|
4733
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4667
4734
|
clearTimeout(t);
|
|
4668
|
-
if (r
|
|
4669
|
-
return
|
|
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
|
|
4745
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4679
4746
|
clearTimeout(t);
|
|
4680
|
-
if (
|
|
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
|
|
4696
|
-
const html = await r.text();
|
|
4762
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4697
4763
|
clearTimeout(t);
|
|
4698
|
-
if (r
|
|
4699
|
-
|
|
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.
|
|
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"
|