prodlint 0.9.1 → 0.9.3
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 +38 -6
- package/dist/cli.js +237 -119
- package/dist/index.d.ts +13 -7
- package/dist/index.js +252 -107
- package/dist/mcp.js +61 -111
- package/package.json +13 -9
package/dist/index.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ interface CategoryScore {
|
|
|
50
50
|
score: number;
|
|
51
51
|
findingCount: number;
|
|
52
52
|
}
|
|
53
|
-
interface ScanResult
|
|
53
|
+
interface ScanResult {
|
|
54
54
|
version: string;
|
|
55
55
|
scannedPath: string;
|
|
56
56
|
filesScanned: number;
|
|
@@ -69,20 +69,26 @@ interface ScanOptions {
|
|
|
69
69
|
ignore?: string[];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
declare function scan(options: ScanOptions): Promise<ScanResult
|
|
72
|
+
declare function scan(options: ScanOptions): Promise<ScanResult>;
|
|
73
73
|
|
|
74
74
|
declare const rules: Rule[];
|
|
75
75
|
|
|
76
|
+
interface ReportOptions {
|
|
77
|
+
quiet?: boolean;
|
|
78
|
+
}
|
|
79
|
+
declare function reportPretty(result: ScanResult, opts?: ReportOptions): string;
|
|
80
|
+
declare function reportJson(result: ScanResult): string;
|
|
81
|
+
declare function reportSummary(result: ScanResult): string;
|
|
82
|
+
declare function reportSarif(result: ScanResult): string;
|
|
83
|
+
|
|
76
84
|
type CheckStatus = "pass" | "fail" | "warn" | "info";
|
|
77
85
|
type CheckSeverity = "critical" | "high" | "medium" | "low";
|
|
78
|
-
type CheckMaturity = "established" | "emerging";
|
|
79
86
|
interface ScanCheck {
|
|
80
87
|
id: string;
|
|
81
88
|
name: string;
|
|
82
89
|
description: string;
|
|
83
90
|
status: CheckStatus;
|
|
84
91
|
severity: CheckSeverity;
|
|
85
|
-
maturity: CheckMaturity;
|
|
86
92
|
points: number;
|
|
87
93
|
maxPoints: number;
|
|
88
94
|
details?: string;
|
|
@@ -101,7 +107,7 @@ interface CheckContext {
|
|
|
101
107
|
html: string | null;
|
|
102
108
|
loadTimeMs: number | null;
|
|
103
109
|
}
|
|
104
|
-
interface
|
|
110
|
+
interface WebScanResult {
|
|
105
111
|
url: string;
|
|
106
112
|
domain: string;
|
|
107
113
|
scannedAt: string;
|
|
@@ -116,6 +122,6 @@ interface ScanResult {
|
|
|
116
122
|
};
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
declare function runWebScan(targetUrl: string): Promise<
|
|
125
|
+
declare function runWebScan(targetUrl: string): Promise<WebScanResult>;
|
|
120
126
|
|
|
121
|
-
export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult
|
|
127
|
+
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, reportJson, reportPretty, reportSarif, reportSummary, rules, runWebScan, scan };
|
package/dist/index.js
CHANGED
|
@@ -4501,114 +4501,305 @@ async function scan(options) {
|
|
|
4501
4501
|
};
|
|
4502
4502
|
}
|
|
4503
4503
|
|
|
4504
|
+
// src/reporter.ts
|
|
4505
|
+
import pc from "picocolors";
|
|
4506
|
+
var SEVERITY_COLORS = {
|
|
4507
|
+
critical: pc.red,
|
|
4508
|
+
warning: pc.yellow,
|
|
4509
|
+
info: pc.blue
|
|
4510
|
+
};
|
|
4511
|
+
var SEVERITY_LABELS = {
|
|
4512
|
+
critical: "CRIT",
|
|
4513
|
+
warning: "WARN",
|
|
4514
|
+
info: "INFO"
|
|
4515
|
+
};
|
|
4516
|
+
function scoreColor(score) {
|
|
4517
|
+
if (score >= 80) return pc.green;
|
|
4518
|
+
if (score >= 50) return pc.yellow;
|
|
4519
|
+
return pc.red;
|
|
4520
|
+
}
|
|
4521
|
+
function groupByFile(findings) {
|
|
4522
|
+
const map = /* @__PURE__ */ new Map();
|
|
4523
|
+
for (const f of findings) {
|
|
4524
|
+
const group = map.get(f.file) ?? [];
|
|
4525
|
+
group.push(f);
|
|
4526
|
+
map.set(f.file, group);
|
|
4527
|
+
}
|
|
4528
|
+
return map;
|
|
4529
|
+
}
|
|
4530
|
+
function reportPretty(result, opts = {}) {
|
|
4531
|
+
const lines = [];
|
|
4532
|
+
const { critical, warning, info } = result.summary;
|
|
4533
|
+
lines.push("");
|
|
4534
|
+
lines.push(pc.bold(" prodlint") + pc.dim(` v${result.version}`));
|
|
4535
|
+
const headerParts = [`Scanned ${result.filesScanned} files`];
|
|
4536
|
+
if (critical > 0) headerParts.push(`${critical} critical`);
|
|
4537
|
+
if (warning > 0) headerParts.push(`${warning} warnings`);
|
|
4538
|
+
if (info > 0) headerParts.push(`${info} info`);
|
|
4539
|
+
lines.push(pc.dim(` ${headerParts.join(" \xB7 ")}`));
|
|
4540
|
+
lines.push("");
|
|
4541
|
+
if (result.findings.length > 0) {
|
|
4542
|
+
const grouped = groupByFile(result.findings);
|
|
4543
|
+
for (const [file, findings] of grouped) {
|
|
4544
|
+
lines.push(pc.underline(file));
|
|
4545
|
+
for (const f of findings) {
|
|
4546
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
4547
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
4548
|
+
lines.push(
|
|
4549
|
+
` ${pc.dim(`${f.line}:${f.column}`)} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`
|
|
4550
|
+
);
|
|
4551
|
+
if (f.fix) {
|
|
4552
|
+
lines.push(` ${pc.dim(` \u21B3 ${f.fix}`)}`);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
lines.push("");
|
|
4556
|
+
}
|
|
4557
|
+
}
|
|
4558
|
+
lines.push(pc.bold(" Scores"));
|
|
4559
|
+
for (const cs of result.categoryScores) {
|
|
4560
|
+
const color = scoreColor(cs.score);
|
|
4561
|
+
const bar = renderBar(cs.score);
|
|
4562
|
+
lines.push(` ${cs.category.padEnd(14)} ${color(String(cs.score).padStart(3))} ${bar} ${pc.dim(`(${cs.findingCount} issues)`)}`);
|
|
4563
|
+
}
|
|
4564
|
+
lines.push("");
|
|
4565
|
+
const overallColor = scoreColor(result.overallScore);
|
|
4566
|
+
lines.push(pc.bold(` Overall: ${overallColor(String(result.overallScore))}/100`));
|
|
4567
|
+
lines.push("");
|
|
4568
|
+
const summaryParts = [];
|
|
4569
|
+
if (critical > 0) summaryParts.push(pc.red(`${critical} critical`));
|
|
4570
|
+
if (warning > 0) summaryParts.push(pc.yellow(`${warning} warnings`));
|
|
4571
|
+
if (info > 0) summaryParts.push(pc.blue(`${info} info`));
|
|
4572
|
+
if (summaryParts.length === 0) {
|
|
4573
|
+
lines.push(pc.green(" No issues found!"));
|
|
4574
|
+
} else {
|
|
4575
|
+
lines.push(` ${summaryParts.join(pc.dim(" \xB7 "))}`);
|
|
4576
|
+
}
|
|
4577
|
+
lines.push("");
|
|
4578
|
+
if (!opts.quiet) {
|
|
4579
|
+
const badgeColor = result.overallScore >= 80 ? "brightgreen" : result.overallScore >= 60 ? "yellow" : "red";
|
|
4580
|
+
const badgeUrl = `https://img.shields.io/badge/prodlint-${result.overallScore}%2F100-${badgeColor}`;
|
|
4581
|
+
lines.push(pc.dim(" Add to your README:"));
|
|
4582
|
+
lines.push(pc.dim(` [](https://prodlint.com)`));
|
|
4583
|
+
lines.push("");
|
|
4584
|
+
}
|
|
4585
|
+
return lines.join("\n");
|
|
4586
|
+
}
|
|
4587
|
+
function reportJson(result) {
|
|
4588
|
+
return JSON.stringify(result, null, 2);
|
|
4589
|
+
}
|
|
4590
|
+
function reportSummary(result) {
|
|
4591
|
+
const lines = [];
|
|
4592
|
+
const { critical, warning, info } = result.summary;
|
|
4593
|
+
const hasCritical = critical > 0;
|
|
4594
|
+
const verdict = hasCritical ? pc.red(pc.bold("FAIL")) : pc.green(pc.bold("PASS"));
|
|
4595
|
+
const scorePart = scoreColor(result.overallScore)(`${result.overallScore}/100`);
|
|
4596
|
+
const countParts = [];
|
|
4597
|
+
if (critical > 0) countParts.push(`${critical} critical`);
|
|
4598
|
+
if (warning > 0) countParts.push(`${warning} warning`);
|
|
4599
|
+
if (info > 0) countParts.push(`${info} info`);
|
|
4600
|
+
lines.push("");
|
|
4601
|
+
if (countParts.length > 0) {
|
|
4602
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} ${countParts.join(", ")} ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4603
|
+
} else {
|
|
4604
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} no issues ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4605
|
+
}
|
|
4606
|
+
const topFindings = result.findings.filter((f) => f.severity === "critical" || f.severity === "warning").slice(0, 3);
|
|
4607
|
+
if (topFindings.length > 0) {
|
|
4608
|
+
for (let i = 0; i < topFindings.length; i++) {
|
|
4609
|
+
const f = topFindings[i];
|
|
4610
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
4611
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
4612
|
+
lines.push(` ${pc.dim(`${i + 1}.`)} ${f.file}:${f.line} ${pc.dim("\u2014")} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
lines.push("");
|
|
4616
|
+
return lines.join("\n");
|
|
4617
|
+
}
|
|
4618
|
+
var SARIF_SEVERITY_MAP = {
|
|
4619
|
+
critical: "error",
|
|
4620
|
+
warning: "warning",
|
|
4621
|
+
info: "note"
|
|
4622
|
+
};
|
|
4623
|
+
function reportSarif(result) {
|
|
4624
|
+
const ruleMap = /* @__PURE__ */ new Map();
|
|
4625
|
+
for (const r of rules) {
|
|
4626
|
+
ruleMap.set(r.id, r);
|
|
4627
|
+
}
|
|
4628
|
+
const usedRuleIds = new Set(result.findings.map((f) => f.ruleId));
|
|
4629
|
+
const sarifRules = [...usedRuleIds].map((id) => {
|
|
4630
|
+
const rule = ruleMap.get(id);
|
|
4631
|
+
return {
|
|
4632
|
+
id,
|
|
4633
|
+
name: rule?.name ?? id,
|
|
4634
|
+
shortDescription: { text: rule?.description ?? id },
|
|
4635
|
+
defaultConfiguration: {
|
|
4636
|
+
level: SARIF_SEVERITY_MAP[rule?.severity ?? "info"] ?? "note"
|
|
4637
|
+
},
|
|
4638
|
+
helpUri: `https://prodlint.com/rules/${id}`
|
|
4639
|
+
};
|
|
4640
|
+
});
|
|
4641
|
+
const sarifResults = result.findings.map((f) => {
|
|
4642
|
+
const sarifResult = {
|
|
4643
|
+
ruleId: f.ruleId,
|
|
4644
|
+
level: SARIF_SEVERITY_MAP[f.severity] ?? "note",
|
|
4645
|
+
message: { text: f.message },
|
|
4646
|
+
locations: [
|
|
4647
|
+
{
|
|
4648
|
+
physicalLocation: {
|
|
4649
|
+
artifactLocation: { uri: f.file.replace(/\\/g, "/") },
|
|
4650
|
+
region: { startLine: f.line, startColumn: f.column }
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
]
|
|
4654
|
+
};
|
|
4655
|
+
if (f.fix) {
|
|
4656
|
+
sarifResult.fixes = [
|
|
4657
|
+
{
|
|
4658
|
+
description: { text: f.fix }
|
|
4659
|
+
}
|
|
4660
|
+
];
|
|
4661
|
+
}
|
|
4662
|
+
return sarifResult;
|
|
4663
|
+
});
|
|
4664
|
+
const sarif = {
|
|
4665
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
4666
|
+
version: "2.1.0",
|
|
4667
|
+
runs: [
|
|
4668
|
+
{
|
|
4669
|
+
tool: {
|
|
4670
|
+
driver: {
|
|
4671
|
+
name: "prodlint",
|
|
4672
|
+
version: getVersion(),
|
|
4673
|
+
informationUri: "https://prodlint.com",
|
|
4674
|
+
rules: sarifRules
|
|
4675
|
+
}
|
|
4676
|
+
},
|
|
4677
|
+
results: sarifResults
|
|
4678
|
+
}
|
|
4679
|
+
]
|
|
4680
|
+
};
|
|
4681
|
+
return JSON.stringify(sarif, null, 2);
|
|
4682
|
+
}
|
|
4683
|
+
function renderBar(score) {
|
|
4684
|
+
const width = 20;
|
|
4685
|
+
const filled = Math.round(score / 100 * width);
|
|
4686
|
+
const empty = width - filled;
|
|
4687
|
+
const color = scoreColor(score);
|
|
4688
|
+
return color("\u2588".repeat(filled)) + pc.dim("\u2591".repeat(empty));
|
|
4689
|
+
}
|
|
4690
|
+
var STATUS_ICONS = {
|
|
4691
|
+
pass: pc.green,
|
|
4692
|
+
fail: pc.red,
|
|
4693
|
+
warn: pc.yellow,
|
|
4694
|
+
info: pc.blue
|
|
4695
|
+
};
|
|
4696
|
+
|
|
4504
4697
|
// src/web-scanner/checks.ts
|
|
4505
|
-
function make(id, name, description, maxPoints, severity,
|
|
4698
|
+
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4506
4699
|
return {
|
|
4507
4700
|
id,
|
|
4508
4701
|
name,
|
|
4509
4702
|
description,
|
|
4510
4703
|
status,
|
|
4511
4704
|
severity,
|
|
4512
|
-
maturity,
|
|
4513
4705
|
details,
|
|
4514
4706
|
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4515
4707
|
maxPoints
|
|
4516
4708
|
};
|
|
4517
4709
|
}
|
|
4518
4710
|
function checkRobotsTxt(ctx) {
|
|
4519
|
-
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4520
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4711
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
|
|
4712
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
|
|
4521
4713
|
}
|
|
4522
4714
|
function checkRobotsAiDirectives(ctx) {
|
|
4523
|
-
if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4524
|
-
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"
|
|
4715
|
+
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.");
|
|
4716
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4525
4717
|
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4526
|
-
if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4527
|
-
if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4528
|
-
return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4718
|
+
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.");
|
|
4719
|
+
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(", ")}.`);
|
|
4720
|
+
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(", ")}.`);
|
|
4529
4721
|
}
|
|
4530
4722
|
function checkContentUsage(ctx) {
|
|
4531
4723
|
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4532
4724
|
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4533
|
-
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4534
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4725
|
+
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
|
|
4726
|
+
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.");
|
|
4535
4727
|
}
|
|
4536
4728
|
function checkLlmsTxt(ctx) {
|
|
4537
|
-
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4538
|
-
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0
|
|
4539
|
-
if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4540
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4729
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
|
|
4730
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4731
|
+
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.");
|
|
4732
|
+
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
|
|
4541
4733
|
}
|
|
4542
4734
|
function checkTdmRep(ctx) {
|
|
4543
4735
|
const hasWK = ctx.tdmRep != null;
|
|
4544
4736
|
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4545
|
-
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4546
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4737
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
|
|
4738
|
+
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"]}`);
|
|
4547
4739
|
}
|
|
4548
4740
|
function checkAiDisclosure(ctx) {
|
|
4549
|
-
if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4550
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4741
|
+
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.");
|
|
4742
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4551
4743
|
}
|
|
4552
4744
|
function checkAgentCard(ctx) {
|
|
4553
|
-
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4745
|
+
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
|
|
4554
4746
|
try {
|
|
4555
4747
|
const card = JSON.parse(ctx.agentCard);
|
|
4556
|
-
if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4557
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4748
|
+
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.");
|
|
4749
|
+
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).`);
|
|
4558
4750
|
} catch {
|
|
4559
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4751
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
|
|
4560
4752
|
}
|
|
4561
4753
|
}
|
|
4562
4754
|
function checkAiTxt(ctx) {
|
|
4563
|
-
if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4755
|
+
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.");
|
|
4564
4756
|
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4565
|
-
if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4566
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4757
|
+
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.");
|
|
4758
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4567
4759
|
}
|
|
4568
4760
|
function checkWebMCP(ctx) {
|
|
4569
|
-
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4761
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
|
|
4570
4762
|
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4571
4763
|
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4572
|
-
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4764
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
|
|
4573
4765
|
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4574
|
-
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4766
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4575
4767
|
}
|
|
4576
4768
|
function checkStructuredData(ctx) {
|
|
4577
|
-
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4769
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
|
|
4578
4770
|
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4579
4771
|
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4580
|
-
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4581
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4772
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
|
|
4773
|
+
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).`);
|
|
4582
4774
|
}
|
|
4583
4775
|
function checkOpenGraph(ctx) {
|
|
4584
|
-
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4776
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
|
|
4585
4777
|
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)];
|
|
4586
4778
|
const passed = checks.filter(Boolean).length;
|
|
4587
|
-
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4588
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4589
|
-
|
|
4590
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
|
|
4779
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
|
|
4780
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
|
|
4781
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
|
|
4591
4782
|
}
|
|
4592
4783
|
function checkSitemap(ctx) {
|
|
4593
|
-
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4784
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
|
|
4594
4785
|
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4595
|
-
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4596
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4597
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4786
|
+
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
|
|
4787
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
|
|
4788
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4598
4789
|
}
|
|
4599
4790
|
function checkHttpSignatures(ctx) {
|
|
4600
4791
|
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4601
4792
|
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4602
4793
|
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4603
|
-
if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4604
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4605
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4794
|
+
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.");
|
|
4795
|
+
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.");
|
|
4796
|
+
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.");
|
|
4606
4797
|
}
|
|
4607
4798
|
function checkPageSpeed(ctx) {
|
|
4608
|
-
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4609
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4610
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4611
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4799
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
|
|
4800
|
+
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.`);
|
|
4801
|
+
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.`);
|
|
4802
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4612
4803
|
}
|
|
4613
4804
|
var allChecks = [
|
|
4614
4805
|
checkRobotsTxt,
|
|
@@ -4647,29 +4838,6 @@ function isPrivateHost(hostname) {
|
|
|
4647
4838
|
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4648
4839
|
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4649
4840
|
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4650
|
-
const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4651
|
-
if (v4mapped) {
|
|
4652
|
-
const [, a, b] = v4mapped.map(Number);
|
|
4653
|
-
if (a === 10 || a === 127 || a === 0) return true;
|
|
4654
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4655
|
-
if (a === 192 && b === 168) return true;
|
|
4656
|
-
if (a === 169 && b === 254) return true;
|
|
4657
|
-
return false;
|
|
4658
|
-
}
|
|
4659
|
-
if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
|
|
4660
|
-
if (/^\d+$/.test(hostname)) {
|
|
4661
|
-
const dec = parseInt(hostname, 10);
|
|
4662
|
-
if (dec >= 0 && dec <= 4294967295) {
|
|
4663
|
-
const a = dec >>> 24 & 255;
|
|
4664
|
-
const b = dec >>> 16 & 255;
|
|
4665
|
-
if (a === 10 || a === 127 || a === 0) return true;
|
|
4666
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4667
|
-
if (a === 192 && b === 168) return true;
|
|
4668
|
-
if (a === 169 && b === 254) return true;
|
|
4669
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4670
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4671
|
-
}
|
|
4672
|
-
}
|
|
4673
4841
|
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4674
4842
|
if (ipv4) {
|
|
4675
4843
|
const [, a, b] = ipv4.map(Number);
|
|
@@ -4682,49 +4850,22 @@ function isPrivateHost(hostname) {
|
|
|
4682
4850
|
}
|
|
4683
4851
|
return false;
|
|
4684
4852
|
}
|
|
4685
|
-
function isAllowedProtocol(url) {
|
|
4686
|
-
try {
|
|
4687
|
-
const p = new URL(url).protocol;
|
|
4688
|
-
return p === "https:" || p === "http:";
|
|
4689
|
-
} catch {
|
|
4690
|
-
return false;
|
|
4691
|
-
}
|
|
4692
|
-
}
|
|
4693
4853
|
function validateRedirectUrl(responseUrl) {
|
|
4694
4854
|
try {
|
|
4695
4855
|
const parsed = new URL(responseUrl);
|
|
4696
|
-
if (!isAllowedProtocol(responseUrl)) return false;
|
|
4697
4856
|
return !isPrivateHost(parsed.hostname);
|
|
4698
4857
|
} catch {
|
|
4699
4858
|
return false;
|
|
4700
4859
|
}
|
|
4701
4860
|
}
|
|
4702
|
-
var MAX_REDIRECTS = 5;
|
|
4703
|
-
async function safeFetch(url, timeout, signal) {
|
|
4704
|
-
let current = url;
|
|
4705
|
-
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
4706
|
-
const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
|
|
4707
|
-
const status = r.status;
|
|
4708
|
-
if (status >= 300 && status < 400) {
|
|
4709
|
-
const location = r.headers.get("location");
|
|
4710
|
-
if (!location) return null;
|
|
4711
|
-
const resolved = new URL(location, current).toString();
|
|
4712
|
-
if (!validateRedirectUrl(resolved)) return null;
|
|
4713
|
-
current = resolved;
|
|
4714
|
-
continue;
|
|
4715
|
-
}
|
|
4716
|
-
return r;
|
|
4717
|
-
}
|
|
4718
|
-
return null;
|
|
4719
|
-
}
|
|
4720
4861
|
async function fetchText(url, timeout = 8e3) {
|
|
4721
4862
|
try {
|
|
4722
4863
|
const c = new AbortController();
|
|
4723
4864
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4724
|
-
const r = await
|
|
4865
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4725
4866
|
clearTimeout(t);
|
|
4726
|
-
if (
|
|
4727
|
-
return await r.text();
|
|
4867
|
+
if (r.url && !validateRedirectUrl(r.url)) return null;
|
|
4868
|
+
return r.ok ? await r.text() : null;
|
|
4728
4869
|
} catch {
|
|
4729
4870
|
return null;
|
|
4730
4871
|
}
|
|
@@ -4733,9 +4874,9 @@ async function fetchHeaders(url, timeout = 8e3) {
|
|
|
4733
4874
|
try {
|
|
4734
4875
|
const c = new AbortController();
|
|
4735
4876
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4736
|
-
const r = await
|
|
4877
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4737
4878
|
clearTimeout(t);
|
|
4738
|
-
if (!r) return {};
|
|
4879
|
+
if (r.url && !validateRedirectUrl(r.url)) return {};
|
|
4739
4880
|
const h = {};
|
|
4740
4881
|
r.headers.forEach((v, k) => {
|
|
4741
4882
|
h[k.toLowerCase()] = v;
|
|
@@ -4750,11 +4891,11 @@ async function fetchWithTiming(url, timeout = 15e3) {
|
|
|
4750
4891
|
const c = new AbortController();
|
|
4751
4892
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4752
4893
|
const start = Date.now();
|
|
4753
|
-
const r = await
|
|
4754
|
-
clearTimeout(t);
|
|
4755
|
-
if (!r || !r.ok) return { html: null, loadTimeMs: null };
|
|
4894
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4756
4895
|
const html = await r.text();
|
|
4757
|
-
|
|
4896
|
+
clearTimeout(t);
|
|
4897
|
+
if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
|
|
4898
|
+
return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
|
|
4758
4899
|
} catch {
|
|
4759
4900
|
return { html: null, loadTimeMs: null };
|
|
4760
4901
|
}
|
|
@@ -4793,6 +4934,10 @@ async function runWebScan(targetUrl) {
|
|
|
4793
4934
|
};
|
|
4794
4935
|
}
|
|
4795
4936
|
export {
|
|
4937
|
+
reportJson,
|
|
4938
|
+
reportPretty,
|
|
4939
|
+
reportSarif,
|
|
4940
|
+
reportSummary,
|
|
4796
4941
|
rules,
|
|
4797
4942
|
runWebScan,
|
|
4798
4943
|
scan
|