prodlint 0.7.1 → 0.8.0
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/LICENSE +21 -21
- package/README.md +297 -253
- package/action.yml +152 -152
- package/dist/cli.js +325 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.js +243 -1
- package/dist/mcp.js +300 -3
- package/package.json +90 -83
package/dist/cli.js
CHANGED
|
@@ -720,7 +720,8 @@ var SECRET_PATTERNS = [
|
|
|
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,}/ },
|
|
723
|
-
{ name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
723
|
+
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
724
|
+
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
724
725
|
{ name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
|
|
725
726
|
{ name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
|
|
726
727
|
{ name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
|
|
@@ -4484,6 +4485,295 @@ function renderBar(score) {
|
|
|
4484
4485
|
const color = scoreColor(score);
|
|
4485
4486
|
return color("\u2588".repeat(filled)) + pc.dim("\u2591".repeat(empty));
|
|
4486
4487
|
}
|
|
4488
|
+
var STATUS_ICONS = {
|
|
4489
|
+
pass: pc.green,
|
|
4490
|
+
fail: pc.red,
|
|
4491
|
+
warn: pc.yellow,
|
|
4492
|
+
info: pc.blue
|
|
4493
|
+
};
|
|
4494
|
+
var STATUS_SYMBOLS = {
|
|
4495
|
+
pass: "\u2713",
|
|
4496
|
+
fail: "\u2717",
|
|
4497
|
+
warn: "!",
|
|
4498
|
+
info: "i"
|
|
4499
|
+
};
|
|
4500
|
+
function reportWebPretty(result) {
|
|
4501
|
+
const lines = [];
|
|
4502
|
+
lines.push("");
|
|
4503
|
+
lines.push(pc.bold(" prodlint site score"));
|
|
4504
|
+
lines.push(pc.dim(` ${result.domain} \xB7 ${result.summary.totalChecks} checks`));
|
|
4505
|
+
lines.push("");
|
|
4506
|
+
const overallColor = scoreColor(result.overallScore);
|
|
4507
|
+
const bar = renderBar(result.overallScore);
|
|
4508
|
+
lines.push(` ${pc.bold("Score:")} ${overallColor(pc.bold(`${result.overallScore}`))} ${overallColor(result.grade)} ${bar}`);
|
|
4509
|
+
lines.push("");
|
|
4510
|
+
const order = { fail: 0, warn: 1, info: 2, pass: 3 };
|
|
4511
|
+
const sorted = [...result.checks].sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9));
|
|
4512
|
+
for (const check of sorted) {
|
|
4513
|
+
const color = STATUS_ICONS[check.status] ?? pc.dim;
|
|
4514
|
+
const symbol = STATUS_SYMBOLS[check.status] ?? "?";
|
|
4515
|
+
const pts = `${check.points}/${check.maxPoints}`;
|
|
4516
|
+
lines.push(` ${color(symbol)} ${check.name.padEnd(28)} ${pc.dim(pts.padStart(6))} ${pc.dim(check.details || "")}`);
|
|
4517
|
+
}
|
|
4518
|
+
lines.push("");
|
|
4519
|
+
const parts = [];
|
|
4520
|
+
if (result.summary.passed > 0) parts.push(pc.green(`${result.summary.passed} passed`));
|
|
4521
|
+
if (result.summary.failed > 0) parts.push(pc.red(`${result.summary.failed} failed`));
|
|
4522
|
+
if (result.summary.warnings > 0) parts.push(pc.yellow(`${result.summary.warnings} warnings`));
|
|
4523
|
+
lines.push(` ${parts.join(pc.dim(" \xB7 "))}`);
|
|
4524
|
+
lines.push("");
|
|
4525
|
+
lines.push(pc.dim(` Full results: https://prodlint.com/score?url=${encodeURIComponent(result.domain)}`));
|
|
4526
|
+
lines.push("");
|
|
4527
|
+
return lines.join("\n");
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
// src/web-scanner/checks.ts
|
|
4531
|
+
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4532
|
+
return {
|
|
4533
|
+
id,
|
|
4534
|
+
name,
|
|
4535
|
+
description,
|
|
4536
|
+
status,
|
|
4537
|
+
severity,
|
|
4538
|
+
details,
|
|
4539
|
+
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4540
|
+
maxPoints
|
|
4541
|
+
};
|
|
4542
|
+
}
|
|
4543
|
+
function checkRobotsTxt(ctx) {
|
|
4544
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
|
|
4545
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
|
|
4546
|
+
}
|
|
4547
|
+
function checkRobotsAiDirectives(ctx) {
|
|
4548
|
+
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.");
|
|
4549
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4550
|
+
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4551
|
+
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.");
|
|
4552
|
+
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(", ")}.`);
|
|
4553
|
+
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(", ")}.`);
|
|
4554
|
+
}
|
|
4555
|
+
function checkContentUsage(ctx) {
|
|
4556
|
+
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4557
|
+
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4558
|
+
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
|
|
4559
|
+
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.");
|
|
4560
|
+
}
|
|
4561
|
+
function checkLlmsTxt(ctx) {
|
|
4562
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
|
|
4563
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4564
|
+
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.");
|
|
4565
|
+
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
|
|
4566
|
+
}
|
|
4567
|
+
function checkTdmRep(ctx) {
|
|
4568
|
+
const hasWK = ctx.tdmRep != null;
|
|
4569
|
+
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4570
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
|
|
4571
|
+
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"]}`);
|
|
4572
|
+
}
|
|
4573
|
+
function checkAiDisclosure(ctx) {
|
|
4574
|
+
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.");
|
|
4575
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4576
|
+
}
|
|
4577
|
+
function checkAgentCard(ctx) {
|
|
4578
|
+
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
|
|
4579
|
+
try {
|
|
4580
|
+
const card = JSON.parse(ctx.agentCard);
|
|
4581
|
+
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.");
|
|
4582
|
+
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).`);
|
|
4583
|
+
} catch {
|
|
4584
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
function checkAiTxt(ctx) {
|
|
4588
|
+
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.");
|
|
4589
|
+
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4590
|
+
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.");
|
|
4591
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4592
|
+
}
|
|
4593
|
+
function checkWebMCP(ctx) {
|
|
4594
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
|
|
4595
|
+
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4596
|
+
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4597
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
|
|
4598
|
+
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4599
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4600
|
+
}
|
|
4601
|
+
function checkStructuredData(ctx) {
|
|
4602
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
|
|
4603
|
+
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4604
|
+
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4605
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
|
|
4606
|
+
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).`);
|
|
4607
|
+
}
|
|
4608
|
+
function checkOpenGraph(ctx) {
|
|
4609
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
|
|
4610
|
+
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)];
|
|
4611
|
+
const passed = checks.filter(Boolean).length;
|
|
4612
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
|
|
4613
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
|
|
4614
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
|
|
4615
|
+
}
|
|
4616
|
+
function checkSitemap(ctx) {
|
|
4617
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
|
|
4618
|
+
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4619
|
+
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
|
|
4620
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
|
|
4621
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4622
|
+
}
|
|
4623
|
+
function checkHttpSignatures(ctx) {
|
|
4624
|
+
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4625
|
+
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4626
|
+
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4627
|
+
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.");
|
|
4628
|
+
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.");
|
|
4629
|
+
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.");
|
|
4630
|
+
}
|
|
4631
|
+
function checkPageSpeed(ctx) {
|
|
4632
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
|
|
4633
|
+
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.`);
|
|
4634
|
+
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.`);
|
|
4635
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4636
|
+
}
|
|
4637
|
+
var allChecks = [
|
|
4638
|
+
checkRobotsTxt,
|
|
4639
|
+
checkRobotsAiDirectives,
|
|
4640
|
+
checkContentUsage,
|
|
4641
|
+
checkLlmsTxt,
|
|
4642
|
+
checkAiTxt,
|
|
4643
|
+
checkTdmRep,
|
|
4644
|
+
checkAiDisclosure,
|
|
4645
|
+
checkAgentCard,
|
|
4646
|
+
checkWebMCP,
|
|
4647
|
+
checkHttpSignatures,
|
|
4648
|
+
checkStructuredData,
|
|
4649
|
+
checkOpenGraph,
|
|
4650
|
+
checkSitemap,
|
|
4651
|
+
checkPageSpeed
|
|
4652
|
+
];
|
|
4653
|
+
|
|
4654
|
+
// src/web-scanner/index.ts
|
|
4655
|
+
function getGrade(score) {
|
|
4656
|
+
if (score >= 80) return "A";
|
|
4657
|
+
if (score >= 60) return "B";
|
|
4658
|
+
if (score >= 40) return "C";
|
|
4659
|
+
if (score >= 20) return "D";
|
|
4660
|
+
return "F";
|
|
4661
|
+
}
|
|
4662
|
+
function getDomain(url) {
|
|
4663
|
+
try {
|
|
4664
|
+
return new URL(url).hostname;
|
|
4665
|
+
} catch {
|
|
4666
|
+
return url;
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
|
|
4670
|
+
function isPrivateHost(hostname) {
|
|
4671
|
+
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4672
|
+
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4673
|
+
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4674
|
+
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4675
|
+
if (ipv4) {
|
|
4676
|
+
const [, a, b] = ipv4.map(Number);
|
|
4677
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4678
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4679
|
+
if (a === 192 && b === 168) return true;
|
|
4680
|
+
if (a === 169 && b === 254) return true;
|
|
4681
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4682
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4683
|
+
}
|
|
4684
|
+
return false;
|
|
4685
|
+
}
|
|
4686
|
+
function validateRedirectUrl(responseUrl) {
|
|
4687
|
+
try {
|
|
4688
|
+
const parsed = new URL(responseUrl);
|
|
4689
|
+
return !isPrivateHost(parsed.hostname);
|
|
4690
|
+
} catch {
|
|
4691
|
+
return false;
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
async function fetchText(url, timeout = 8e3) {
|
|
4695
|
+
try {
|
|
4696
|
+
const c = new AbortController();
|
|
4697
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4698
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4699
|
+
clearTimeout(t);
|
|
4700
|
+
if (r.url && !validateRedirectUrl(r.url)) return null;
|
|
4701
|
+
return r.ok ? await r.text() : null;
|
|
4702
|
+
} catch {
|
|
4703
|
+
return null;
|
|
4704
|
+
}
|
|
4705
|
+
}
|
|
4706
|
+
async function fetchHeaders(url, timeout = 8e3) {
|
|
4707
|
+
try {
|
|
4708
|
+
const c = new AbortController();
|
|
4709
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4710
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4711
|
+
clearTimeout(t);
|
|
4712
|
+
if (r.url && !validateRedirectUrl(r.url)) return {};
|
|
4713
|
+
const h = {};
|
|
4714
|
+
r.headers.forEach((v, k) => {
|
|
4715
|
+
h[k.toLowerCase()] = v;
|
|
4716
|
+
});
|
|
4717
|
+
return h;
|
|
4718
|
+
} catch {
|
|
4719
|
+
return {};
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
async function fetchWithTiming(url, timeout = 15e3) {
|
|
4723
|
+
try {
|
|
4724
|
+
const c = new AbortController();
|
|
4725
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4726
|
+
const start = Date.now();
|
|
4727
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4728
|
+
const html = await r.text();
|
|
4729
|
+
clearTimeout(t);
|
|
4730
|
+
if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
|
|
4731
|
+
return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
|
|
4732
|
+
} catch {
|
|
4733
|
+
return { html: null, loadTimeMs: null };
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
function normalizeUrl(input) {
|
|
4737
|
+
let url = input.trim();
|
|
4738
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
4739
|
+
url = `https://${url}`;
|
|
4740
|
+
}
|
|
4741
|
+
const parsed = new URL(url);
|
|
4742
|
+
return parsed.origin;
|
|
4743
|
+
}
|
|
4744
|
+
async function runWebScan(targetUrl) {
|
|
4745
|
+
const domain = getDomain(targetUrl);
|
|
4746
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
|
|
4747
|
+
fetchText(`${targetUrl}/robots.txt`),
|
|
4748
|
+
fetchText(`${targetUrl}/llms.txt`),
|
|
4749
|
+
fetchText(`${targetUrl}/ai.txt`),
|
|
4750
|
+
fetchText(`${targetUrl}/.well-known/tdmrep.json`),
|
|
4751
|
+
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
4752
|
+
fetchText(`${targetUrl}/sitemap.xml`),
|
|
4753
|
+
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
4754
|
+
fetchHeaders(targetUrl),
|
|
4755
|
+
fetchWithTiming(targetUrl)
|
|
4756
|
+
]);
|
|
4757
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
|
|
4758
|
+
const checks = allChecks.map((fn) => fn(ctx));
|
|
4759
|
+
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
4760
|
+
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
4761
|
+
const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
|
|
4762
|
+
return {
|
|
4763
|
+
url: targetUrl,
|
|
4764
|
+
domain,
|
|
4765
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4766
|
+
overallScore,
|
|
4767
|
+
grade: getGrade(overallScore),
|
|
4768
|
+
checks,
|
|
4769
|
+
summary: {
|
|
4770
|
+
passed: checks.filter((c) => c.status === "pass").length,
|
|
4771
|
+
failed: checks.filter((c) => c.status === "fail").length,
|
|
4772
|
+
warnings: checks.filter((c) => c.status === "warn").length,
|
|
4773
|
+
totalChecks: checks.length
|
|
4774
|
+
}
|
|
4775
|
+
};
|
|
4776
|
+
}
|
|
4487
4777
|
|
|
4488
4778
|
// src/cli.ts
|
|
4489
4779
|
var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
@@ -4495,6 +4785,7 @@ async function main() {
|
|
|
4495
4785
|
ignore: { type: "string", multiple: true, default: [] },
|
|
4496
4786
|
"min-severity": { type: "string", default: "info" },
|
|
4497
4787
|
quiet: { type: "boolean", default: false },
|
|
4788
|
+
web: { type: "boolean", default: false },
|
|
4498
4789
|
help: { type: "boolean", short: "h", default: false },
|
|
4499
4790
|
version: { type: "boolean", short: "v", default: false }
|
|
4500
4791
|
}
|
|
@@ -4507,6 +4798,15 @@ async function main() {
|
|
|
4507
4798
|
console.log(getVersion());
|
|
4508
4799
|
process.exit(0);
|
|
4509
4800
|
}
|
|
4801
|
+
if (values.web) {
|
|
4802
|
+
const url = positionals[0];
|
|
4803
|
+
if (!url) {
|
|
4804
|
+
console.error("Usage: npx prodlint --web <url>");
|
|
4805
|
+
process.exit(2);
|
|
4806
|
+
}
|
|
4807
|
+
await runWebScan2(url, { json: values.json });
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4510
4810
|
const targetPath = positionals[0] ?? ".";
|
|
4511
4811
|
const minSeverity = values["min-severity"] ?? "info";
|
|
4512
4812
|
const result = await scan({
|
|
@@ -4524,18 +4824,40 @@ async function main() {
|
|
|
4524
4824
|
process.exit(1);
|
|
4525
4825
|
}
|
|
4526
4826
|
}
|
|
4827
|
+
async function runWebScan2(url, opts) {
|
|
4828
|
+
let normalizedUrl;
|
|
4829
|
+
try {
|
|
4830
|
+
normalizedUrl = normalizeUrl(url);
|
|
4831
|
+
} catch {
|
|
4832
|
+
console.error("Invalid URL:", url);
|
|
4833
|
+
process.exit(2);
|
|
4834
|
+
}
|
|
4835
|
+
const hostname = new URL(normalizedUrl).hostname;
|
|
4836
|
+
if (isPrivateHost(hostname)) {
|
|
4837
|
+
console.error("Cannot scan private/internal hosts.");
|
|
4838
|
+
process.exit(2);
|
|
4839
|
+
}
|
|
4840
|
+
const result = await runWebScan(normalizedUrl);
|
|
4841
|
+
if (opts.json) {
|
|
4842
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4843
|
+
} else {
|
|
4844
|
+
console.log(reportWebPretty(result));
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4527
4847
|
function printHelp() {
|
|
4528
4848
|
console.log(`
|
|
4529
4849
|
prodlint - The linter for vibe-coded apps
|
|
4530
4850
|
|
|
4531
4851
|
Usage:
|
|
4532
4852
|
npx prodlint [path] [options]
|
|
4853
|
+
npx prodlint --web <url>
|
|
4533
4854
|
|
|
4534
4855
|
Options:
|
|
4535
4856
|
--json Output results as JSON
|
|
4536
4857
|
--ignore <pattern> Glob patterns to ignore (can be repeated)
|
|
4537
4858
|
--min-severity <level> Minimum severity to show: critical, warning, info (default: info)
|
|
4538
4859
|
--quiet Suppress badge and summary
|
|
4860
|
+
--web Get your site's prodlint score (14 AI agent checks)
|
|
4539
4861
|
-h, --help Show this help message
|
|
4540
4862
|
-v, --version Show version
|
|
4541
4863
|
|
|
@@ -4546,6 +4868,8 @@ function printHelp() {
|
|
|
4546
4868
|
npx prodlint --ignore "*.test" Ignore test files
|
|
4547
4869
|
npx prodlint --min-severity warning Only warnings and criticals
|
|
4548
4870
|
npx prodlint --quiet No badge output
|
|
4871
|
+
npx prodlint --web example.com Site score
|
|
4872
|
+
npx prodlint --web example.com --json Site score with JSON output
|
|
4549
4873
|
`);
|
|
4550
4874
|
}
|
|
4551
4875
|
main().catch((err) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -73,4 +73,47 @@ declare function scan(options: ScanOptions): Promise<ScanResult>;
|
|
|
73
73
|
|
|
74
74
|
declare const rules: Rule[];
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
type CheckStatus = "pass" | "fail" | "warn" | "info";
|
|
77
|
+
type CheckSeverity = "critical" | "high" | "medium" | "low";
|
|
78
|
+
interface ScanCheck {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
description: string;
|
|
82
|
+
status: CheckStatus;
|
|
83
|
+
severity: CheckSeverity;
|
|
84
|
+
points: number;
|
|
85
|
+
maxPoints: number;
|
|
86
|
+
details?: string;
|
|
87
|
+
}
|
|
88
|
+
interface CheckContext {
|
|
89
|
+
url: string;
|
|
90
|
+
domain: string;
|
|
91
|
+
robotsTxt: string | null;
|
|
92
|
+
llmsTxt: string | null;
|
|
93
|
+
aiTxt: string | null;
|
|
94
|
+
tdmRep: string | null;
|
|
95
|
+
agentCard: string | null;
|
|
96
|
+
sitemapXml: string | null;
|
|
97
|
+
httpSigDirectory: string | null;
|
|
98
|
+
headers: Record<string, string>;
|
|
99
|
+
html: string | null;
|
|
100
|
+
loadTimeMs: number | null;
|
|
101
|
+
}
|
|
102
|
+
interface WebScanResult {
|
|
103
|
+
url: string;
|
|
104
|
+
domain: string;
|
|
105
|
+
scannedAt: string;
|
|
106
|
+
overallScore: number;
|
|
107
|
+
grade: string;
|
|
108
|
+
checks: ScanCheck[];
|
|
109
|
+
summary: {
|
|
110
|
+
passed: number;
|
|
111
|
+
failed: number;
|
|
112
|
+
warnings: number;
|
|
113
|
+
totalChecks: number;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
declare function runWebScan(targetUrl: string): Promise<WebScanResult>;
|
|
118
|
+
|
|
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 };
|