prodlint 0.7.2 → 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/README.md +46 -10
- package/action.yml +2 -2
- 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 +2 -2
package/README.md
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/prodlint)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Production readiness for vibe-coded apps.
|
|
8
8
|
|
|
9
|
-
Static analysis for vibe-coded apps.
|
|
9
|
+
Static analysis for vibe-coded apps. Flags the security, reliability, performance, and AI quality issues that Cursor, v0, Bolt, and Copilot create — hallucinated imports, missing auth, hardcoded secrets, unvalidated server actions, and more. Zero config, no LLM, 52 rules, under 100ms.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx prodlint
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
prodlint v0.
|
|
16
|
+
prodlint v0.8.0
|
|
17
17
|
Scanned 148 files · 3 critical · 5 warnings
|
|
18
18
|
|
|
19
19
|
src/app/api/checkout/route.ts
|
|
@@ -40,9 +40,9 @@ npx prodlint
|
|
|
40
40
|
|
|
41
41
|
## Why?
|
|
42
42
|
|
|
43
|
-
Vibe coding is the fastest way to build.
|
|
43
|
+
Vibe coding is the fastest way to build. Shipping fast means knowing your code is production-ready — not just that it compiles. Hardcoded secrets, hallucinated packages, missing auth, and XSS vectors pass type-checks and look correct — but they aren't ready for production.
|
|
44
44
|
|
|
45
|
-
prodlint
|
|
45
|
+
prodlint checks what TypeScript and ESLint don't: **whether your vibe-coded app is ready for production**.
|
|
46
46
|
|
|
47
47
|
## Install
|
|
48
48
|
|
|
@@ -66,7 +66,7 @@ npm i -g prodlint # Global install
|
|
|
66
66
|
|
|
67
67
|
### Security (27 rules)
|
|
68
68
|
|
|
69
|
-
| Rule | What it
|
|
69
|
+
| Rule | What it checks |
|
|
70
70
|
|------|----------------|
|
|
71
71
|
| `secrets` | API keys, tokens, passwords hardcoded in source |
|
|
72
72
|
| `auth-checks` | API routes with no authentication |
|
|
@@ -98,7 +98,7 @@ npm i -g prodlint # Global install
|
|
|
98
98
|
|
|
99
99
|
### Reliability (11 rules)
|
|
100
100
|
|
|
101
|
-
| Rule | What it
|
|
101
|
+
| Rule | What it checks |
|
|
102
102
|
|------|----------------|
|
|
103
103
|
| `hallucinated-imports` | Imports of packages not in package.json |
|
|
104
104
|
| `error-handling` | Async operations without try/catch |
|
|
@@ -114,7 +114,7 @@ npm i -g prodlint # Global install
|
|
|
114
114
|
|
|
115
115
|
### Performance (6 rules)
|
|
116
116
|
|
|
117
|
-
| Rule | What it
|
|
117
|
+
| Rule | What it checks |
|
|
118
118
|
|------|----------------|
|
|
119
119
|
| `no-sync-fs` | `readFileSync` in API routes |
|
|
120
120
|
| `no-n-plus-one` | Database calls inside loops |
|
|
@@ -125,7 +125,7 @@ npm i -g prodlint # Global install
|
|
|
125
125
|
|
|
126
126
|
### AI Quality (8 rules)
|
|
127
127
|
|
|
128
|
-
| Rule | What it
|
|
128
|
+
| Rule | What it checks |
|
|
129
129
|
|------|----------------|
|
|
130
130
|
| `ai-smells` | `any` types, `console.log`, TODO comments piling up |
|
|
131
131
|
| `placeholder-content` | Lorem ipsum, example emails, "your-api-key-here" left in production code |
|
|
@@ -219,13 +219,49 @@ claude mcp add prodlint npx prodlint-mcp
|
|
|
219
219
|
|
|
220
220
|
Ask your AI: *"Run prodlint on this project"* and it calls the `scan` tool directly.
|
|
221
221
|
|
|
222
|
+
## Site Score
|
|
223
|
+
|
|
224
|
+
Check any deployed website for AI agent-readiness — 14 checks covering emerging standards like llms.txt, TDMRep, AgentCard, AI-Disclosure, HTTP Signatures (RFC 9421), and more.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npx prodlint --web example.com
|
|
228
|
+
npx prodlint --web example.com --json # JSON output
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
prodlint site score
|
|
233
|
+
example.com · 14 checks
|
|
234
|
+
|
|
235
|
+
Score: 42 C ████████░░░░░░░░░░░░
|
|
236
|
+
|
|
237
|
+
✗ AI-Disclosure Header 0/10 No AI-Disclosure header found.
|
|
238
|
+
✗ Content-Usage Directives 0/10 No Content-Usage directives found.
|
|
239
|
+
✗ TDMRep 0/10 No TDMRep found.
|
|
240
|
+
✗ A2A AgentCard 0/5 No agent-card.json found.
|
|
241
|
+
✗ ai.txt 0/5 No ai.txt found at site root.
|
|
242
|
+
! llms.txt 2/5 llms.txt found but missing key sections.
|
|
243
|
+
✓ robots.txt 10/10 robots.txt found with 15 rules.
|
|
244
|
+
✓ Sitemap 10/10 Valid sitemap with 42 URLs.
|
|
245
|
+
✓ Structured Data 10/10 Found JSON-LD structured data.
|
|
246
|
+
✓ OpenGraph 10/10 Complete OpenGraph tags found.
|
|
247
|
+
✓ Page Speed 5/5 Loaded in 0.8s.
|
|
248
|
+
✓ AI Bot Directives 5/5 AI-specific bot rules found.
|
|
249
|
+
✓ WebMCP Tools 0/5 No WebMCP tools detected.
|
|
250
|
+
|
|
251
|
+
7 passed · 5 failed · 1 warnings
|
|
252
|
+
|
|
253
|
+
Full results: https://prodlint.com/score?url=example.com
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Or check your score interactively at [prodlint.com/score](https://prodlint.com/score).
|
|
257
|
+
|
|
222
258
|
## For AI Tools
|
|
223
259
|
|
|
224
260
|
- **LLM-friendly docs**: [prodlint.com/llms.txt](https://prodlint.com/llms.txt) — concise project summary for LLMs
|
|
225
261
|
- **Full reference**: [prodlint.com/llms-full.txt](https://prodlint.com/llms-full.txt) — all 52 rules with details
|
|
226
262
|
- **MCP setup guide**: [prodlint.com/mcp](https://prodlint.com/mcp) — detailed editor setup for Claude Code, Cursor, Windsurf
|
|
227
263
|
|
|
228
|
-
prodlint is designed specifically for AI-generated code patterns. Every rule
|
|
264
|
+
prodlint is designed specifically for AI-generated code patterns. Every rule checks for production issues that AI coding tools consistently create — not style nits.
|
|
229
265
|
|
|
230
266
|
## Suppression
|
|
231
267
|
|
package/action.yml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: 'Prodlint'
|
|
2
|
-
description: '
|
|
2
|
+
description: 'Production readiness for vibe-coded apps — check your AI code before you ship'
|
|
3
3
|
branding:
|
|
4
4
|
icon: 'shield'
|
|
5
5
|
color: 'green'
|
|
@@ -101,7 +101,7 @@ runs:
|
|
|
101
101
|
const d = JSON.parse(fs.readFileSync('/tmp/prodlint-result.json', 'utf8'));
|
|
102
102
|
const esc = s => String(s).replace(/[[\]()\\*_\`<>]/g, c => '\\\\' + c);
|
|
103
103
|
const lines = [];
|
|
104
|
-
lines.push('## ' + '$EMOJI' + '
|
|
104
|
+
lines.push('## ' + '$EMOJI' + ' Production Readiness: **' + d.overallScore + '/100**');
|
|
105
105
|
lines.push('');
|
|
106
106
|
lines.push('| Category | Score | Issues |');
|
|
107
107
|
lines.push('|----------|-------|--------|');
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -715,7 +715,8 @@ var SECRET_PATTERNS = [
|
|
|
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,}/ },
|
|
718
|
-
{ name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
718
|
+
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
719
|
+
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
719
720
|
{ name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
|
|
720
721
|
{ name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
|
|
721
722
|
{ name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
|
|
@@ -4385,8 +4386,249 @@ async function scan(options) {
|
|
|
4385
4386
|
summary
|
|
4386
4387
|
};
|
|
4387
4388
|
}
|
|
4389
|
+
|
|
4390
|
+
// src/web-scanner/checks.ts
|
|
4391
|
+
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4392
|
+
return {
|
|
4393
|
+
id,
|
|
4394
|
+
name,
|
|
4395
|
+
description,
|
|
4396
|
+
status,
|
|
4397
|
+
severity,
|
|
4398
|
+
details,
|
|
4399
|
+
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4400
|
+
maxPoints
|
|
4401
|
+
};
|
|
4402
|
+
}
|
|
4403
|
+
function checkRobotsTxt(ctx) {
|
|
4404
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
|
|
4405
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
|
|
4406
|
+
}
|
|
4407
|
+
function checkRobotsAiDirectives(ctx) {
|
|
4408
|
+
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.");
|
|
4409
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4410
|
+
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4411
|
+
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.");
|
|
4412
|
+
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(", ")}.`);
|
|
4413
|
+
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(", ")}.`);
|
|
4414
|
+
}
|
|
4415
|
+
function checkContentUsage(ctx) {
|
|
4416
|
+
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4417
|
+
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4418
|
+
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
|
|
4419
|
+
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.");
|
|
4420
|
+
}
|
|
4421
|
+
function checkLlmsTxt(ctx) {
|
|
4422
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
|
|
4423
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4424
|
+
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.");
|
|
4425
|
+
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
|
|
4426
|
+
}
|
|
4427
|
+
function checkTdmRep(ctx) {
|
|
4428
|
+
const hasWK = ctx.tdmRep != null;
|
|
4429
|
+
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4430
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
|
|
4431
|
+
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"]}`);
|
|
4432
|
+
}
|
|
4433
|
+
function checkAiDisclosure(ctx) {
|
|
4434
|
+
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.");
|
|
4435
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4436
|
+
}
|
|
4437
|
+
function checkAgentCard(ctx) {
|
|
4438
|
+
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
|
|
4439
|
+
try {
|
|
4440
|
+
const card = JSON.parse(ctx.agentCard);
|
|
4441
|
+
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.");
|
|
4442
|
+
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).`);
|
|
4443
|
+
} catch {
|
|
4444
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
function checkAiTxt(ctx) {
|
|
4448
|
+
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.");
|
|
4449
|
+
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4450
|
+
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.");
|
|
4451
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4452
|
+
}
|
|
4453
|
+
function checkWebMCP(ctx) {
|
|
4454
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
|
|
4455
|
+
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4456
|
+
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4457
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
|
|
4458
|
+
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4459
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4460
|
+
}
|
|
4461
|
+
function checkStructuredData(ctx) {
|
|
4462
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
|
|
4463
|
+
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4464
|
+
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4465
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
|
|
4466
|
+
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).`);
|
|
4467
|
+
}
|
|
4468
|
+
function checkOpenGraph(ctx) {
|
|
4469
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
|
|
4470
|
+
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)];
|
|
4471
|
+
const passed = checks.filter(Boolean).length;
|
|
4472
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
|
|
4473
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
|
|
4474
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
|
|
4475
|
+
}
|
|
4476
|
+
function checkSitemap(ctx) {
|
|
4477
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
|
|
4478
|
+
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4479
|
+
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
|
|
4480
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
|
|
4481
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4482
|
+
}
|
|
4483
|
+
function checkHttpSignatures(ctx) {
|
|
4484
|
+
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4485
|
+
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4486
|
+
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4487
|
+
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.");
|
|
4488
|
+
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.");
|
|
4489
|
+
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.");
|
|
4490
|
+
}
|
|
4491
|
+
function checkPageSpeed(ctx) {
|
|
4492
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
|
|
4493
|
+
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.`);
|
|
4494
|
+
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.`);
|
|
4495
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4496
|
+
}
|
|
4497
|
+
var allChecks = [
|
|
4498
|
+
checkRobotsTxt,
|
|
4499
|
+
checkRobotsAiDirectives,
|
|
4500
|
+
checkContentUsage,
|
|
4501
|
+
checkLlmsTxt,
|
|
4502
|
+
checkAiTxt,
|
|
4503
|
+
checkTdmRep,
|
|
4504
|
+
checkAiDisclosure,
|
|
4505
|
+
checkAgentCard,
|
|
4506
|
+
checkWebMCP,
|
|
4507
|
+
checkHttpSignatures,
|
|
4508
|
+
checkStructuredData,
|
|
4509
|
+
checkOpenGraph,
|
|
4510
|
+
checkSitemap,
|
|
4511
|
+
checkPageSpeed
|
|
4512
|
+
];
|
|
4513
|
+
|
|
4514
|
+
// src/web-scanner/index.ts
|
|
4515
|
+
function getGrade(score) {
|
|
4516
|
+
if (score >= 80) return "A";
|
|
4517
|
+
if (score >= 60) return "B";
|
|
4518
|
+
if (score >= 40) return "C";
|
|
4519
|
+
if (score >= 20) return "D";
|
|
4520
|
+
return "F";
|
|
4521
|
+
}
|
|
4522
|
+
function getDomain(url) {
|
|
4523
|
+
try {
|
|
4524
|
+
return new URL(url).hostname;
|
|
4525
|
+
} catch {
|
|
4526
|
+
return url;
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
4529
|
+
var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
|
|
4530
|
+
function isPrivateHost(hostname) {
|
|
4531
|
+
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4532
|
+
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4533
|
+
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4534
|
+
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4535
|
+
if (ipv4) {
|
|
4536
|
+
const [, a, b] = ipv4.map(Number);
|
|
4537
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4538
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4539
|
+
if (a === 192 && b === 168) return true;
|
|
4540
|
+
if (a === 169 && b === 254) return true;
|
|
4541
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4542
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4543
|
+
}
|
|
4544
|
+
return false;
|
|
4545
|
+
}
|
|
4546
|
+
function validateRedirectUrl(responseUrl) {
|
|
4547
|
+
try {
|
|
4548
|
+
const parsed = new URL(responseUrl);
|
|
4549
|
+
return !isPrivateHost(parsed.hostname);
|
|
4550
|
+
} catch {
|
|
4551
|
+
return false;
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
async function fetchText(url, timeout = 8e3) {
|
|
4555
|
+
try {
|
|
4556
|
+
const c = new AbortController();
|
|
4557
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4558
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4559
|
+
clearTimeout(t);
|
|
4560
|
+
if (r.url && !validateRedirectUrl(r.url)) return null;
|
|
4561
|
+
return r.ok ? await r.text() : null;
|
|
4562
|
+
} catch {
|
|
4563
|
+
return null;
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
async function fetchHeaders(url, timeout = 8e3) {
|
|
4567
|
+
try {
|
|
4568
|
+
const c = new AbortController();
|
|
4569
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4570
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4571
|
+
clearTimeout(t);
|
|
4572
|
+
if (r.url && !validateRedirectUrl(r.url)) return {};
|
|
4573
|
+
const h = {};
|
|
4574
|
+
r.headers.forEach((v, k) => {
|
|
4575
|
+
h[k.toLowerCase()] = v;
|
|
4576
|
+
});
|
|
4577
|
+
return h;
|
|
4578
|
+
} catch {
|
|
4579
|
+
return {};
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
async function fetchWithTiming(url, timeout = 15e3) {
|
|
4583
|
+
try {
|
|
4584
|
+
const c = new AbortController();
|
|
4585
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4586
|
+
const start = Date.now();
|
|
4587
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4588
|
+
const html = await r.text();
|
|
4589
|
+
clearTimeout(t);
|
|
4590
|
+
if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
|
|
4591
|
+
return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
|
|
4592
|
+
} catch {
|
|
4593
|
+
return { html: null, loadTimeMs: null };
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
async function runWebScan(targetUrl) {
|
|
4597
|
+
const domain = getDomain(targetUrl);
|
|
4598
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
|
|
4599
|
+
fetchText(`${targetUrl}/robots.txt`),
|
|
4600
|
+
fetchText(`${targetUrl}/llms.txt`),
|
|
4601
|
+
fetchText(`${targetUrl}/ai.txt`),
|
|
4602
|
+
fetchText(`${targetUrl}/.well-known/tdmrep.json`),
|
|
4603
|
+
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
4604
|
+
fetchText(`${targetUrl}/sitemap.xml`),
|
|
4605
|
+
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
4606
|
+
fetchHeaders(targetUrl),
|
|
4607
|
+
fetchWithTiming(targetUrl)
|
|
4608
|
+
]);
|
|
4609
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
|
|
4610
|
+
const checks = allChecks.map((fn) => fn(ctx));
|
|
4611
|
+
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
4612
|
+
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
4613
|
+
const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
|
|
4614
|
+
return {
|
|
4615
|
+
url: targetUrl,
|
|
4616
|
+
domain,
|
|
4617
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4618
|
+
overallScore,
|
|
4619
|
+
grade: getGrade(overallScore),
|
|
4620
|
+
checks,
|
|
4621
|
+
summary: {
|
|
4622
|
+
passed: checks.filter((c) => c.status === "pass").length,
|
|
4623
|
+
failed: checks.filter((c) => c.status === "fail").length,
|
|
4624
|
+
warnings: checks.filter((c) => c.status === "warn").length,
|
|
4625
|
+
totalChecks: checks.length
|
|
4626
|
+
}
|
|
4627
|
+
};
|
|
4628
|
+
}
|
|
4388
4629
|
export {
|
|
4389
4630
|
rules,
|
|
4631
|
+
runWebScan,
|
|
4390
4632
|
scan
|
|
4391
4633
|
};
|
|
4392
4634
|
//# sourceMappingURL=index.js.map
|
package/dist/mcp.js
CHANGED
|
@@ -724,7 +724,8 @@ var SECRET_PATTERNS = [
|
|
|
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,}/ },
|
|
727
|
-
{ name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
727
|
+
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
728
|
+
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
728
729
|
{ name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
|
|
729
730
|
{ name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
|
|
730
731
|
{ name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
|
|
@@ -4395,6 +4396,254 @@ async function scan(options) {
|
|
|
4395
4396
|
};
|
|
4396
4397
|
}
|
|
4397
4398
|
|
|
4399
|
+
// src/web-scanner/checks.ts
|
|
4400
|
+
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4401
|
+
return {
|
|
4402
|
+
id,
|
|
4403
|
+
name,
|
|
4404
|
+
description,
|
|
4405
|
+
status,
|
|
4406
|
+
severity,
|
|
4407
|
+
details,
|
|
4408
|
+
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4409
|
+
maxPoints
|
|
4410
|
+
};
|
|
4411
|
+
}
|
|
4412
|
+
function checkRobotsTxt(ctx) {
|
|
4413
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
|
|
4414
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
|
|
4415
|
+
}
|
|
4416
|
+
function checkRobotsAiDirectives(ctx) {
|
|
4417
|
+
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.");
|
|
4418
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4419
|
+
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4420
|
+
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.");
|
|
4421
|
+
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(", ")}.`);
|
|
4422
|
+
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(", ")}.`);
|
|
4423
|
+
}
|
|
4424
|
+
function checkContentUsage(ctx) {
|
|
4425
|
+
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4426
|
+
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4427
|
+
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
|
|
4428
|
+
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.");
|
|
4429
|
+
}
|
|
4430
|
+
function checkLlmsTxt(ctx) {
|
|
4431
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
|
|
4432
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4433
|
+
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.");
|
|
4434
|
+
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
|
|
4435
|
+
}
|
|
4436
|
+
function checkTdmRep(ctx) {
|
|
4437
|
+
const hasWK = ctx.tdmRep != null;
|
|
4438
|
+
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4439
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
|
|
4440
|
+
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"]}`);
|
|
4441
|
+
}
|
|
4442
|
+
function checkAiDisclosure(ctx) {
|
|
4443
|
+
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.");
|
|
4444
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4445
|
+
}
|
|
4446
|
+
function checkAgentCard(ctx) {
|
|
4447
|
+
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
|
|
4448
|
+
try {
|
|
4449
|
+
const card = JSON.parse(ctx.agentCard);
|
|
4450
|
+
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.");
|
|
4451
|
+
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).`);
|
|
4452
|
+
} catch {
|
|
4453
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
function checkAiTxt(ctx) {
|
|
4457
|
+
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.");
|
|
4458
|
+
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4459
|
+
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.");
|
|
4460
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4461
|
+
}
|
|
4462
|
+
function checkWebMCP(ctx) {
|
|
4463
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
|
|
4464
|
+
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4465
|
+
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4466
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
|
|
4467
|
+
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4468
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4469
|
+
}
|
|
4470
|
+
function checkStructuredData(ctx) {
|
|
4471
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
|
|
4472
|
+
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4473
|
+
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4474
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
|
|
4475
|
+
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).`);
|
|
4476
|
+
}
|
|
4477
|
+
function checkOpenGraph(ctx) {
|
|
4478
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
|
|
4479
|
+
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)];
|
|
4480
|
+
const passed = checks.filter(Boolean).length;
|
|
4481
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
|
|
4482
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
|
|
4483
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
|
|
4484
|
+
}
|
|
4485
|
+
function checkSitemap(ctx) {
|
|
4486
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
|
|
4487
|
+
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4488
|
+
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
|
|
4489
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
|
|
4490
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4491
|
+
}
|
|
4492
|
+
function checkHttpSignatures(ctx) {
|
|
4493
|
+
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4494
|
+
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4495
|
+
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4496
|
+
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.");
|
|
4497
|
+
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.");
|
|
4498
|
+
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.");
|
|
4499
|
+
}
|
|
4500
|
+
function checkPageSpeed(ctx) {
|
|
4501
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
|
|
4502
|
+
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.`);
|
|
4503
|
+
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.`);
|
|
4504
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4505
|
+
}
|
|
4506
|
+
var allChecks = [
|
|
4507
|
+
checkRobotsTxt,
|
|
4508
|
+
checkRobotsAiDirectives,
|
|
4509
|
+
checkContentUsage,
|
|
4510
|
+
checkLlmsTxt,
|
|
4511
|
+
checkAiTxt,
|
|
4512
|
+
checkTdmRep,
|
|
4513
|
+
checkAiDisclosure,
|
|
4514
|
+
checkAgentCard,
|
|
4515
|
+
checkWebMCP,
|
|
4516
|
+
checkHttpSignatures,
|
|
4517
|
+
checkStructuredData,
|
|
4518
|
+
checkOpenGraph,
|
|
4519
|
+
checkSitemap,
|
|
4520
|
+
checkPageSpeed
|
|
4521
|
+
];
|
|
4522
|
+
|
|
4523
|
+
// src/web-scanner/index.ts
|
|
4524
|
+
function getGrade(score) {
|
|
4525
|
+
if (score >= 80) return "A";
|
|
4526
|
+
if (score >= 60) return "B";
|
|
4527
|
+
if (score >= 40) return "C";
|
|
4528
|
+
if (score >= 20) return "D";
|
|
4529
|
+
return "F";
|
|
4530
|
+
}
|
|
4531
|
+
function getDomain(url) {
|
|
4532
|
+
try {
|
|
4533
|
+
return new URL(url).hostname;
|
|
4534
|
+
} catch {
|
|
4535
|
+
return url;
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
|
|
4539
|
+
function isPrivateHost(hostname) {
|
|
4540
|
+
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4541
|
+
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4542
|
+
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4543
|
+
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4544
|
+
if (ipv4) {
|
|
4545
|
+
const [, a, b] = ipv4.map(Number);
|
|
4546
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4547
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4548
|
+
if (a === 192 && b === 168) return true;
|
|
4549
|
+
if (a === 169 && b === 254) return true;
|
|
4550
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4551
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4552
|
+
}
|
|
4553
|
+
return false;
|
|
4554
|
+
}
|
|
4555
|
+
function validateRedirectUrl(responseUrl) {
|
|
4556
|
+
try {
|
|
4557
|
+
const parsed = new URL(responseUrl);
|
|
4558
|
+
return !isPrivateHost(parsed.hostname);
|
|
4559
|
+
} catch {
|
|
4560
|
+
return false;
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
async function fetchText(url, timeout = 8e3) {
|
|
4564
|
+
try {
|
|
4565
|
+
const c = new AbortController();
|
|
4566
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4567
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4568
|
+
clearTimeout(t);
|
|
4569
|
+
if (r.url && !validateRedirectUrl(r.url)) return null;
|
|
4570
|
+
return r.ok ? await r.text() : null;
|
|
4571
|
+
} catch {
|
|
4572
|
+
return null;
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
4575
|
+
async function fetchHeaders(url, timeout = 8e3) {
|
|
4576
|
+
try {
|
|
4577
|
+
const c = new AbortController();
|
|
4578
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4579
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4580
|
+
clearTimeout(t);
|
|
4581
|
+
if (r.url && !validateRedirectUrl(r.url)) return {};
|
|
4582
|
+
const h = {};
|
|
4583
|
+
r.headers.forEach((v, k) => {
|
|
4584
|
+
h[k.toLowerCase()] = v;
|
|
4585
|
+
});
|
|
4586
|
+
return h;
|
|
4587
|
+
} catch {
|
|
4588
|
+
return {};
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
async function fetchWithTiming(url, timeout = 15e3) {
|
|
4592
|
+
try {
|
|
4593
|
+
const c = new AbortController();
|
|
4594
|
+
const t = setTimeout(() => c.abort(), timeout);
|
|
4595
|
+
const start = Date.now();
|
|
4596
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4597
|
+
const html = await r.text();
|
|
4598
|
+
clearTimeout(t);
|
|
4599
|
+
if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
|
|
4600
|
+
return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
|
|
4601
|
+
} catch {
|
|
4602
|
+
return { html: null, loadTimeMs: null };
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
function normalizeUrl(input) {
|
|
4606
|
+
let url = input.trim();
|
|
4607
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
4608
|
+
url = `https://${url}`;
|
|
4609
|
+
}
|
|
4610
|
+
const parsed = new URL(url);
|
|
4611
|
+
return parsed.origin;
|
|
4612
|
+
}
|
|
4613
|
+
async function runWebScan(targetUrl) {
|
|
4614
|
+
const domain = getDomain(targetUrl);
|
|
4615
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
|
|
4616
|
+
fetchText(`${targetUrl}/robots.txt`),
|
|
4617
|
+
fetchText(`${targetUrl}/llms.txt`),
|
|
4618
|
+
fetchText(`${targetUrl}/ai.txt`),
|
|
4619
|
+
fetchText(`${targetUrl}/.well-known/tdmrep.json`),
|
|
4620
|
+
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
4621
|
+
fetchText(`${targetUrl}/sitemap.xml`),
|
|
4622
|
+
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
4623
|
+
fetchHeaders(targetUrl),
|
|
4624
|
+
fetchWithTiming(targetUrl)
|
|
4625
|
+
]);
|
|
4626
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
|
|
4627
|
+
const checks = allChecks.map((fn) => fn(ctx));
|
|
4628
|
+
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
4629
|
+
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
4630
|
+
const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
|
|
4631
|
+
return {
|
|
4632
|
+
url: targetUrl,
|
|
4633
|
+
domain,
|
|
4634
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4635
|
+
overallScore,
|
|
4636
|
+
grade: getGrade(overallScore),
|
|
4637
|
+
checks,
|
|
4638
|
+
summary: {
|
|
4639
|
+
passed: checks.filter((c) => c.status === "pass").length,
|
|
4640
|
+
failed: checks.filter((c) => c.status === "fail").length,
|
|
4641
|
+
warnings: checks.filter((c) => c.status === "warn").length,
|
|
4642
|
+
totalChecks: checks.length
|
|
4643
|
+
}
|
|
4644
|
+
};
|
|
4645
|
+
}
|
|
4646
|
+
|
|
4398
4647
|
// src/mcp.ts
|
|
4399
4648
|
var server = new McpServer({
|
|
4400
4649
|
name: "prodlint",
|
|
@@ -4402,13 +4651,20 @@ var server = new McpServer({
|
|
|
4402
4651
|
});
|
|
4403
4652
|
server.tool(
|
|
4404
4653
|
"scan",
|
|
4405
|
-
"
|
|
4654
|
+
"Check a vibe-coded project's production readiness. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
|
|
4406
4655
|
{
|
|
4407
4656
|
path: z.string().describe("Absolute path to the project directory to scan"),
|
|
4408
4657
|
ignore: z.array(z.string()).optional().describe("Glob patterns to ignore")
|
|
4409
4658
|
},
|
|
4410
4659
|
async ({ path, ignore }) => {
|
|
4411
4660
|
const resolved = resolve4(path);
|
|
4661
|
+
const cwd = process.cwd();
|
|
4662
|
+
if (!resolved.startsWith(cwd)) {
|
|
4663
|
+
return {
|
|
4664
|
+
content: [{ type: "text", text: `Error: Path must be within the current working directory (${cwd})` }],
|
|
4665
|
+
isError: true
|
|
4666
|
+
};
|
|
4667
|
+
}
|
|
4412
4668
|
try {
|
|
4413
4669
|
const stats = await stat2(resolved);
|
|
4414
4670
|
if (!stats.isDirectory()) {
|
|
@@ -4425,7 +4681,7 @@ server.tool(
|
|
|
4425
4681
|
}
|
|
4426
4682
|
const result = await scan({ path: resolved, ignore });
|
|
4427
4683
|
const summary = [
|
|
4428
|
-
`##
|
|
4684
|
+
`## Production Readiness: ${result.overallScore}/100`,
|
|
4429
4685
|
"",
|
|
4430
4686
|
`Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`,
|
|
4431
4687
|
"",
|
|
@@ -4452,6 +4708,47 @@ server.tool(
|
|
|
4452
4708
|
};
|
|
4453
4709
|
}
|
|
4454
4710
|
);
|
|
4711
|
+
server.tool(
|
|
4712
|
+
"scan-web",
|
|
4713
|
+
"Check a deployed website's AI agent-readiness. Returns a 0-100 score across 14 checks including robots.txt AI directives, llms.txt, AgentCard, WebMCP, and more.",
|
|
4714
|
+
{ url: z.string().describe("URL of the website to scan (e.g. https://example.com)") },
|
|
4715
|
+
async ({ url }) => {
|
|
4716
|
+
let normalizedUrl;
|
|
4717
|
+
try {
|
|
4718
|
+
normalizedUrl = normalizeUrl(url);
|
|
4719
|
+
} catch {
|
|
4720
|
+
return {
|
|
4721
|
+
content: [{ type: "text", text: `Error: Invalid URL "${url}"` }],
|
|
4722
|
+
isError: true
|
|
4723
|
+
};
|
|
4724
|
+
}
|
|
4725
|
+
const hostname = new URL(normalizedUrl).hostname;
|
|
4726
|
+
if (isPrivateHost(hostname)) {
|
|
4727
|
+
return {
|
|
4728
|
+
content: [{ type: "text", text: "Error: Cannot scan private or internal hosts." }],
|
|
4729
|
+
isError: true
|
|
4730
|
+
};
|
|
4731
|
+
}
|
|
4732
|
+
const result = await runWebScan(normalizedUrl);
|
|
4733
|
+
const STATUS_SYMBOLS = { pass: "\u2713", fail: "\u2717", warn: "!", info: "i" };
|
|
4734
|
+
const summary = [
|
|
4735
|
+
`## Site Score: ${result.overallScore}/100 (${result.grade})`,
|
|
4736
|
+
"",
|
|
4737
|
+
`Scanned ${result.domain} \xB7 ${result.summary.totalChecks} checks`,
|
|
4738
|
+
"",
|
|
4739
|
+
"### Checks",
|
|
4740
|
+
...result.checks.map((c) => {
|
|
4741
|
+
const sym = STATUS_SYMBOLS[c.status] ?? "?";
|
|
4742
|
+
return `- ${sym} **${c.name}** \u2014 ${c.details || "No details"} (${c.points}/${c.maxPoints})`;
|
|
4743
|
+
}),
|
|
4744
|
+
"",
|
|
4745
|
+
`### Summary: ${result.summary.passed} passed \xB7 ${result.summary.failed} failed \xB7 ${result.summary.warnings} warnings`
|
|
4746
|
+
];
|
|
4747
|
+
return {
|
|
4748
|
+
content: [{ type: "text", text: summary.join("\n") }]
|
|
4749
|
+
};
|
|
4750
|
+
}
|
|
4751
|
+
);
|
|
4455
4752
|
async function main() {
|
|
4456
4753
|
const transport = new StdioServerTransport();
|
|
4457
4754
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prodlint",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
7
7
|
"type": "module",
|