prodlint 0.9.1 → 0.9.2
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 +1 -1
package/README.md
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
Production readiness for vibe-coded apps.
|
|
8
8
|
|
|
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
|
|
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.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx prodlint
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
prodlint v0.9.
|
|
17
|
-
Scanned 148 files ·
|
|
16
|
+
prodlint v0.9.2
|
|
17
|
+
Scanned 148 files · 2 critical · 5 warnings · 1 info
|
|
18
18
|
|
|
19
19
|
src/app/api/checkout/route.ts
|
|
20
|
-
12:1
|
|
20
|
+
12:1 INFO No rate limiting — anyone could spam this endpoint and run up your API costs rate-limiting
|
|
21
21
|
28:5 WARN Empty catch block silently swallows error shallow-catch
|
|
22
22
|
|
|
23
23
|
src/actions/submit.ts
|
|
@@ -35,7 +35,7 @@ npx prodlint
|
|
|
35
35
|
|
|
36
36
|
Overall: 82/100 (weighted)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
2 critical · 5 warnings · 4 info
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Why?
|
|
@@ -50,6 +50,11 @@ prodlint checks what TypeScript and ESLint don't: **whether your vibe-coded app
|
|
|
50
50
|
npx prodlint # Run directly (no install)
|
|
51
51
|
npx prodlint ./my-app # Scan specific path
|
|
52
52
|
npx prodlint --json # JSON output for CI
|
|
53
|
+
npx prodlint --sarif # SARIF 2.1.0 for GitHub Code Scanning
|
|
54
|
+
npx prodlint --summary # Quick pass/fail + top 3 blockers
|
|
55
|
+
npx prodlint --profile startup # Only critical findings
|
|
56
|
+
npx prodlint --profile strict # All findings including info
|
|
57
|
+
npx prodlint --baseline .prodlint-baseline.json # Only new findings
|
|
53
58
|
npx prodlint --ignore "*.test.ts" # Ignore patterns
|
|
54
59
|
npx prodlint --min-severity warning # Only warnings and criticals
|
|
55
60
|
npx prodlint --quiet # Suppress badge output
|
|
@@ -196,13 +201,40 @@ Posts a score breakdown as a PR comment and fails the build if below threshold.
|
|
|
196
201
|
| `score` | Overall score (0-100) |
|
|
197
202
|
| `critical` | Number of critical findings |
|
|
198
203
|
|
|
204
|
+
### SARIF + GitHub Code Scanning
|
|
205
|
+
|
|
206
|
+
Upload prodlint results to GitHub's Security tab:
|
|
207
|
+
|
|
208
|
+
```yaml
|
|
209
|
+
- name: Run prodlint
|
|
210
|
+
run: npx prodlint --sarif > prodlint.sarif
|
|
211
|
+
|
|
212
|
+
- name: Upload SARIF
|
|
213
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
214
|
+
with:
|
|
215
|
+
sarif_file: prodlint.sarif
|
|
216
|
+
category: prodlint
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Baseline for Existing Projects
|
|
220
|
+
|
|
221
|
+
Adopt prodlint gradually without drowning in pre-existing findings:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# Save current state as baseline
|
|
225
|
+
npx prodlint --baseline-save .prodlint-baseline.json
|
|
226
|
+
|
|
227
|
+
# CI only fails on NEW findings
|
|
228
|
+
npx prodlint --baseline .prodlint-baseline.json
|
|
229
|
+
```
|
|
230
|
+
|
|
199
231
|
## MCP Server
|
|
200
232
|
|
|
201
233
|
Use prodlint inside Cursor, Claude Code, or any MCP-compatible editor:
|
|
202
234
|
|
|
203
235
|
**Claude Code:**
|
|
204
236
|
```bash
|
|
205
|
-
claude mcp add prodlint npx prodlint-mcp
|
|
237
|
+
claude mcp add prodlint -- npx -y prodlint-mcp
|
|
206
238
|
```
|
|
207
239
|
|
|
208
240
|
**Cursor / Windsurf:**
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
+
import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
|
|
5
6
|
|
|
6
7
|
// src/utils/file-walker.ts
|
|
7
8
|
import fg from "fast-glob";
|
|
@@ -4592,6 +4593,99 @@ function reportPretty(result, opts = {}) {
|
|
|
4592
4593
|
function reportJson(result) {
|
|
4593
4594
|
return JSON.stringify(result, null, 2);
|
|
4594
4595
|
}
|
|
4596
|
+
function reportSummary(result) {
|
|
4597
|
+
const lines = [];
|
|
4598
|
+
const { critical, warning, info } = result.summary;
|
|
4599
|
+
const hasCritical = critical > 0;
|
|
4600
|
+
const verdict = hasCritical ? pc.red(pc.bold("FAIL")) : pc.green(pc.bold("PASS"));
|
|
4601
|
+
const scorePart = scoreColor(result.overallScore)(`${result.overallScore}/100`);
|
|
4602
|
+
const countParts = [];
|
|
4603
|
+
if (critical > 0) countParts.push(`${critical} critical`);
|
|
4604
|
+
if (warning > 0) countParts.push(`${warning} warning`);
|
|
4605
|
+
if (info > 0) countParts.push(`${info} info`);
|
|
4606
|
+
lines.push("");
|
|
4607
|
+
if (countParts.length > 0) {
|
|
4608
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} ${countParts.join(", ")} ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4609
|
+
} else {
|
|
4610
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} no issues ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4611
|
+
}
|
|
4612
|
+
const topFindings = result.findings.filter((f) => f.severity === "critical" || f.severity === "warning").slice(0, 3);
|
|
4613
|
+
if (topFindings.length > 0) {
|
|
4614
|
+
for (let i = 0; i < topFindings.length; i++) {
|
|
4615
|
+
const f = topFindings[i];
|
|
4616
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
4617
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
4618
|
+
lines.push(` ${pc.dim(`${i + 1}.`)} ${f.file}:${f.line} ${pc.dim("\u2014")} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`);
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
lines.push("");
|
|
4622
|
+
return lines.join("\n");
|
|
4623
|
+
}
|
|
4624
|
+
var SARIF_SEVERITY_MAP = {
|
|
4625
|
+
critical: "error",
|
|
4626
|
+
warning: "warning",
|
|
4627
|
+
info: "note"
|
|
4628
|
+
};
|
|
4629
|
+
function reportSarif(result) {
|
|
4630
|
+
const ruleMap = /* @__PURE__ */ new Map();
|
|
4631
|
+
for (const r of rules) {
|
|
4632
|
+
ruleMap.set(r.id, r);
|
|
4633
|
+
}
|
|
4634
|
+
const usedRuleIds = new Set(result.findings.map((f) => f.ruleId));
|
|
4635
|
+
const sarifRules = [...usedRuleIds].map((id) => {
|
|
4636
|
+
const rule = ruleMap.get(id);
|
|
4637
|
+
return {
|
|
4638
|
+
id,
|
|
4639
|
+
name: rule?.name ?? id,
|
|
4640
|
+
shortDescription: { text: rule?.description ?? id },
|
|
4641
|
+
defaultConfiguration: {
|
|
4642
|
+
level: SARIF_SEVERITY_MAP[rule?.severity ?? "info"] ?? "note"
|
|
4643
|
+
},
|
|
4644
|
+
helpUri: `https://prodlint.com/rules/${id}`
|
|
4645
|
+
};
|
|
4646
|
+
});
|
|
4647
|
+
const sarifResults = result.findings.map((f) => {
|
|
4648
|
+
const sarifResult = {
|
|
4649
|
+
ruleId: f.ruleId,
|
|
4650
|
+
level: SARIF_SEVERITY_MAP[f.severity] ?? "note",
|
|
4651
|
+
message: { text: f.message },
|
|
4652
|
+
locations: [
|
|
4653
|
+
{
|
|
4654
|
+
physicalLocation: {
|
|
4655
|
+
artifactLocation: { uri: f.file.replace(/\\/g, "/") },
|
|
4656
|
+
region: { startLine: f.line, startColumn: f.column }
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
]
|
|
4660
|
+
};
|
|
4661
|
+
if (f.fix) {
|
|
4662
|
+
sarifResult.fixes = [
|
|
4663
|
+
{
|
|
4664
|
+
description: { text: f.fix }
|
|
4665
|
+
}
|
|
4666
|
+
];
|
|
4667
|
+
}
|
|
4668
|
+
return sarifResult;
|
|
4669
|
+
});
|
|
4670
|
+
const sarif = {
|
|
4671
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
4672
|
+
version: "2.1.0",
|
|
4673
|
+
runs: [
|
|
4674
|
+
{
|
|
4675
|
+
tool: {
|
|
4676
|
+
driver: {
|
|
4677
|
+
name: "prodlint",
|
|
4678
|
+
version: getVersion(),
|
|
4679
|
+
informationUri: "https://prodlint.com",
|
|
4680
|
+
rules: sarifRules
|
|
4681
|
+
}
|
|
4682
|
+
},
|
|
4683
|
+
results: sarifResults
|
|
4684
|
+
}
|
|
4685
|
+
]
|
|
4686
|
+
};
|
|
4687
|
+
return JSON.stringify(sarif, null, 2);
|
|
4688
|
+
}
|
|
4595
4689
|
function renderBar(score) {
|
|
4596
4690
|
const width = 20;
|
|
4597
4691
|
const filled = Math.round(score / 100 * width);
|
|
@@ -4642,113 +4736,111 @@ function reportWebPretty(result) {
|
|
|
4642
4736
|
}
|
|
4643
4737
|
|
|
4644
4738
|
// src/web-scanner/checks.ts
|
|
4645
|
-
function make(id, name, description, maxPoints, severity,
|
|
4739
|
+
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4646
4740
|
return {
|
|
4647
4741
|
id,
|
|
4648
4742
|
name,
|
|
4649
4743
|
description,
|
|
4650
4744
|
status,
|
|
4651
4745
|
severity,
|
|
4652
|
-
maturity,
|
|
4653
4746
|
details,
|
|
4654
4747
|
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4655
4748
|
maxPoints
|
|
4656
4749
|
};
|
|
4657
4750
|
}
|
|
4658
4751
|
function checkRobotsTxt(ctx) {
|
|
4659
|
-
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4660
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4752
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
|
|
4753
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
|
|
4661
4754
|
}
|
|
4662
4755
|
function checkRobotsAiDirectives(ctx) {
|
|
4663
|
-
if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4664
|
-
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"
|
|
4756
|
+
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.");
|
|
4757
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4665
4758
|
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4666
|
-
if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4667
|
-
if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4668
|
-
return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "
|
|
4759
|
+
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.");
|
|
4760
|
+
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(", ")}.`);
|
|
4761
|
+
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(", ")}.`);
|
|
4669
4762
|
}
|
|
4670
4763
|
function checkContentUsage(ctx) {
|
|
4671
4764
|
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4672
4765
|
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4673
|
-
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4674
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4766
|
+
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
|
|
4767
|
+
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.");
|
|
4675
4768
|
}
|
|
4676
4769
|
function checkLlmsTxt(ctx) {
|
|
4677
|
-
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4678
|
-
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0
|
|
4679
|
-
if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4680
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4770
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
|
|
4771
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4772
|
+
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.");
|
|
4773
|
+
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
|
|
4681
4774
|
}
|
|
4682
4775
|
function checkTdmRep(ctx) {
|
|
4683
4776
|
const hasWK = ctx.tdmRep != null;
|
|
4684
4777
|
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4685
|
-
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4686
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4778
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
|
|
4779
|
+
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"]}`);
|
|
4687
4780
|
}
|
|
4688
4781
|
function checkAiDisclosure(ctx) {
|
|
4689
|
-
if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4690
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4782
|
+
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.");
|
|
4783
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4691
4784
|
}
|
|
4692
4785
|
function checkAgentCard(ctx) {
|
|
4693
|
-
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4786
|
+
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
|
|
4694
4787
|
try {
|
|
4695
4788
|
const card = JSON.parse(ctx.agentCard);
|
|
4696
|
-
if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4697
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4789
|
+
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.");
|
|
4790
|
+
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).`);
|
|
4698
4791
|
} catch {
|
|
4699
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4792
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
|
|
4700
4793
|
}
|
|
4701
4794
|
}
|
|
4702
4795
|
function checkAiTxt(ctx) {
|
|
4703
|
-
if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4796
|
+
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.");
|
|
4704
4797
|
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4705
|
-
if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4706
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4798
|
+
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.");
|
|
4799
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4707
4800
|
}
|
|
4708
4801
|
function checkWebMCP(ctx) {
|
|
4709
|
-
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4802
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
|
|
4710
4803
|
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4711
4804
|
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4712
|
-
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4805
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
|
|
4713
4806
|
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4714
|
-
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4807
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4715
4808
|
}
|
|
4716
4809
|
function checkStructuredData(ctx) {
|
|
4717
|
-
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4810
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
|
|
4718
4811
|
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4719
4812
|
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4720
|
-
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4721
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4813
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
|
|
4814
|
+
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).`);
|
|
4722
4815
|
}
|
|
4723
4816
|
function checkOpenGraph(ctx) {
|
|
4724
|
-
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4817
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
|
|
4725
4818
|
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)];
|
|
4726
4819
|
const passed = checks.filter(Boolean).length;
|
|
4727
|
-
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4728
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4729
|
-
|
|
4730
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
|
|
4820
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
|
|
4821
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
|
|
4822
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
|
|
4731
4823
|
}
|
|
4732
4824
|
function checkSitemap(ctx) {
|
|
4733
|
-
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4825
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
|
|
4734
4826
|
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4735
|
-
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4736
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4737
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4827
|
+
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
|
|
4828
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
|
|
4829
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4738
4830
|
}
|
|
4739
4831
|
function checkHttpSignatures(ctx) {
|
|
4740
4832
|
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4741
4833
|
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4742
4834
|
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4743
|
-
if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4744
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4745
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4835
|
+
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.");
|
|
4836
|
+
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.");
|
|
4837
|
+
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.");
|
|
4746
4838
|
}
|
|
4747
4839
|
function checkPageSpeed(ctx) {
|
|
4748
|
-
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4749
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4750
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4751
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4840
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
|
|
4841
|
+
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.`);
|
|
4842
|
+
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.`);
|
|
4843
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4752
4844
|
}
|
|
4753
4845
|
var allChecks = [
|
|
4754
4846
|
checkRobotsTxt,
|
|
@@ -4787,29 +4879,6 @@ function isPrivateHost(hostname) {
|
|
|
4787
4879
|
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4788
4880
|
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4789
4881
|
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4790
|
-
const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4791
|
-
if (v4mapped) {
|
|
4792
|
-
const [, a, b] = v4mapped.map(Number);
|
|
4793
|
-
if (a === 10 || a === 127 || a === 0) return true;
|
|
4794
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4795
|
-
if (a === 192 && b === 168) return true;
|
|
4796
|
-
if (a === 169 && b === 254) return true;
|
|
4797
|
-
return false;
|
|
4798
|
-
}
|
|
4799
|
-
if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
|
|
4800
|
-
if (/^\d+$/.test(hostname)) {
|
|
4801
|
-
const dec = parseInt(hostname, 10);
|
|
4802
|
-
if (dec >= 0 && dec <= 4294967295) {
|
|
4803
|
-
const a = dec >>> 24 & 255;
|
|
4804
|
-
const b = dec >>> 16 & 255;
|
|
4805
|
-
if (a === 10 || a === 127 || a === 0) return true;
|
|
4806
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4807
|
-
if (a === 192 && b === 168) return true;
|
|
4808
|
-
if (a === 169 && b === 254) return true;
|
|
4809
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4810
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4811
|
-
}
|
|
4812
|
-
}
|
|
4813
4882
|
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4814
4883
|
if (ipv4) {
|
|
4815
4884
|
const [, a, b] = ipv4.map(Number);
|
|
@@ -4822,49 +4891,22 @@ function isPrivateHost(hostname) {
|
|
|
4822
4891
|
}
|
|
4823
4892
|
return false;
|
|
4824
4893
|
}
|
|
4825
|
-
function isAllowedProtocol(url) {
|
|
4826
|
-
try {
|
|
4827
|
-
const p = new URL(url).protocol;
|
|
4828
|
-
return p === "https:" || p === "http:";
|
|
4829
|
-
} catch {
|
|
4830
|
-
return false;
|
|
4831
|
-
}
|
|
4832
|
-
}
|
|
4833
4894
|
function validateRedirectUrl(responseUrl) {
|
|
4834
4895
|
try {
|
|
4835
4896
|
const parsed = new URL(responseUrl);
|
|
4836
|
-
if (!isAllowedProtocol(responseUrl)) return false;
|
|
4837
4897
|
return !isPrivateHost(parsed.hostname);
|
|
4838
4898
|
} catch {
|
|
4839
4899
|
return false;
|
|
4840
4900
|
}
|
|
4841
4901
|
}
|
|
4842
|
-
var MAX_REDIRECTS = 5;
|
|
4843
|
-
async function safeFetch(url, timeout, signal) {
|
|
4844
|
-
let current = url;
|
|
4845
|
-
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
4846
|
-
const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
|
|
4847
|
-
const status = r.status;
|
|
4848
|
-
if (status >= 300 && status < 400) {
|
|
4849
|
-
const location = r.headers.get("location");
|
|
4850
|
-
if (!location) return null;
|
|
4851
|
-
const resolved = new URL(location, current).toString();
|
|
4852
|
-
if (!validateRedirectUrl(resolved)) return null;
|
|
4853
|
-
current = resolved;
|
|
4854
|
-
continue;
|
|
4855
|
-
}
|
|
4856
|
-
return r;
|
|
4857
|
-
}
|
|
4858
|
-
return null;
|
|
4859
|
-
}
|
|
4860
4902
|
async function fetchText(url, timeout = 8e3) {
|
|
4861
4903
|
try {
|
|
4862
4904
|
const c = new AbortController();
|
|
4863
4905
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4864
|
-
const r = await
|
|
4906
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4865
4907
|
clearTimeout(t);
|
|
4866
|
-
if (
|
|
4867
|
-
return await r.text();
|
|
4908
|
+
if (r.url && !validateRedirectUrl(r.url)) return null;
|
|
4909
|
+
return r.ok ? await r.text() : null;
|
|
4868
4910
|
} catch {
|
|
4869
4911
|
return null;
|
|
4870
4912
|
}
|
|
@@ -4873,9 +4915,9 @@ async function fetchHeaders(url, timeout = 8e3) {
|
|
|
4873
4915
|
try {
|
|
4874
4916
|
const c = new AbortController();
|
|
4875
4917
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4876
|
-
const r = await
|
|
4918
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4877
4919
|
clearTimeout(t);
|
|
4878
|
-
if (!r) return {};
|
|
4920
|
+
if (r.url && !validateRedirectUrl(r.url)) return {};
|
|
4879
4921
|
const h = {};
|
|
4880
4922
|
r.headers.forEach((v, k) => {
|
|
4881
4923
|
h[k.toLowerCase()] = v;
|
|
@@ -4890,18 +4932,20 @@ async function fetchWithTiming(url, timeout = 15e3) {
|
|
|
4890
4932
|
const c = new AbortController();
|
|
4891
4933
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4892
4934
|
const start = Date.now();
|
|
4893
|
-
const r = await
|
|
4894
|
-
clearTimeout(t);
|
|
4895
|
-
if (!r || !r.ok) return { html: null, loadTimeMs: null };
|
|
4935
|
+
const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
|
|
4896
4936
|
const html = await r.text();
|
|
4897
|
-
|
|
4937
|
+
clearTimeout(t);
|
|
4938
|
+
if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
|
|
4939
|
+
return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
|
|
4898
4940
|
} catch {
|
|
4899
4941
|
return { html: null, loadTimeMs: null };
|
|
4900
4942
|
}
|
|
4901
4943
|
}
|
|
4902
4944
|
function normalizeUrl(input) {
|
|
4903
4945
|
let url = input.trim();
|
|
4904
|
-
if (!/^https?:\/\//i.test(url))
|
|
4946
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
4947
|
+
url = `https://${url}`;
|
|
4948
|
+
}
|
|
4905
4949
|
const parsed = new URL(url);
|
|
4906
4950
|
return parsed.origin;
|
|
4907
4951
|
}
|
|
@@ -4941,13 +4985,26 @@ async function runWebScan(targetUrl) {
|
|
|
4941
4985
|
|
|
4942
4986
|
// src/cli.ts
|
|
4943
4987
|
var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
4988
|
+
var PROFILE_SEVERITY = {
|
|
4989
|
+
startup: "critical",
|
|
4990
|
+
balanced: "warning",
|
|
4991
|
+
strict: "info"
|
|
4992
|
+
};
|
|
4993
|
+
function fingerprintFinding(f) {
|
|
4994
|
+
return `${f.ruleId}::${f.file}::${f.line}`;
|
|
4995
|
+
}
|
|
4944
4996
|
async function main() {
|
|
4945
4997
|
const { values, positionals } = parseArgs({
|
|
4946
4998
|
allowPositionals: true,
|
|
4947
4999
|
options: {
|
|
4948
5000
|
json: { type: "boolean", default: false },
|
|
5001
|
+
sarif: { type: "boolean", default: false },
|
|
5002
|
+
summary: { type: "boolean", default: false },
|
|
4949
5003
|
ignore: { type: "string", multiple: true, default: [] },
|
|
4950
5004
|
"min-severity": { type: "string", default: "info" },
|
|
5005
|
+
profile: { type: "string" },
|
|
5006
|
+
baseline: { type: "string" },
|
|
5007
|
+
"baseline-save": { type: "string" },
|
|
4951
5008
|
quiet: { type: "boolean", default: false },
|
|
4952
5009
|
web: { type: "boolean", default: false },
|
|
4953
5010
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -4972,15 +5029,66 @@ async function main() {
|
|
|
4972
5029
|
return;
|
|
4973
5030
|
}
|
|
4974
5031
|
const targetPath = positionals[0] ?? ".";
|
|
4975
|
-
|
|
5032
|
+
let minSeverity;
|
|
5033
|
+
if (values.profile) {
|
|
5034
|
+
const profileName = values.profile;
|
|
5035
|
+
if (!(profileName in PROFILE_SEVERITY)) {
|
|
5036
|
+
console.error(`Unknown profile: ${profileName}. Available: startup, balanced, strict`);
|
|
5037
|
+
process.exit(2);
|
|
5038
|
+
}
|
|
5039
|
+
minSeverity = PROFILE_SEVERITY[profileName];
|
|
5040
|
+
} else {
|
|
5041
|
+
minSeverity = values["min-severity"] ?? "info";
|
|
5042
|
+
}
|
|
4976
5043
|
const result = await scan({
|
|
4977
5044
|
path: targetPath,
|
|
4978
5045
|
ignore: values.ignore
|
|
4979
5046
|
});
|
|
4980
5047
|
const minRank = SEVERITY_RANK[minSeverity] ?? 1;
|
|
4981
5048
|
result.findings = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank);
|
|
4982
|
-
|
|
5049
|
+
result.summary = {
|
|
5050
|
+
critical: result.findings.filter((f) => f.severity === "critical").length,
|
|
5051
|
+
warning: result.findings.filter((f) => f.severity === "warning").length,
|
|
5052
|
+
info: result.findings.filter((f) => f.severity === "info").length
|
|
5053
|
+
};
|
|
5054
|
+
if (values["baseline-save"]) {
|
|
5055
|
+
const baselinePath = values["baseline-save"];
|
|
5056
|
+
const entries = result.findings.map((f) => ({
|
|
5057
|
+
ruleId: f.ruleId,
|
|
5058
|
+
file: f.file,
|
|
5059
|
+
line: f.line,
|
|
5060
|
+
message: f.message
|
|
5061
|
+
}));
|
|
5062
|
+
writeFileSync(baselinePath, JSON.stringify(entries, null, 2));
|
|
5063
|
+
console.log(`Baseline saved: ${entries.length} findings written to ${baselinePath}`);
|
|
5064
|
+
return;
|
|
5065
|
+
}
|
|
5066
|
+
if (values.baseline) {
|
|
5067
|
+
const baselinePath = values.baseline;
|
|
5068
|
+
if (!existsSync(baselinePath)) {
|
|
5069
|
+
console.error(`Baseline file not found: ${baselinePath}`);
|
|
5070
|
+
console.error("Run with --baseline-save first to create a baseline.");
|
|
5071
|
+
process.exit(2);
|
|
5072
|
+
}
|
|
5073
|
+
const baselineData = JSON.parse(readFileSync2(baselinePath, "utf8"));
|
|
5074
|
+
const baselineFingerprints = new Set(
|
|
5075
|
+
baselineData.map((e) => `${e.ruleId}::${e.file}::${e.line}`)
|
|
5076
|
+
);
|
|
5077
|
+
result.findings = result.findings.filter(
|
|
5078
|
+
(f) => !baselineFingerprints.has(fingerprintFinding(f))
|
|
5079
|
+
);
|
|
5080
|
+
result.summary = {
|
|
5081
|
+
critical: result.findings.filter((f) => f.severity === "critical").length,
|
|
5082
|
+
warning: result.findings.filter((f) => f.severity === "warning").length,
|
|
5083
|
+
info: result.findings.filter((f) => f.severity === "info").length
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
if (values.sarif) {
|
|
5087
|
+
console.log(reportSarif(result));
|
|
5088
|
+
} else if (values.json) {
|
|
4983
5089
|
console.log(reportJson(result));
|
|
5090
|
+
} else if (values.summary) {
|
|
5091
|
+
console.log(reportSummary(result));
|
|
4984
5092
|
} else {
|
|
4985
5093
|
console.log(reportPretty(result, { quiet: values.quiet }));
|
|
4986
5094
|
}
|
|
@@ -5018,22 +5126,32 @@ function printHelp() {
|
|
|
5018
5126
|
|
|
5019
5127
|
Options:
|
|
5020
5128
|
--json Output results as JSON
|
|
5129
|
+
--sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
|
|
5130
|
+
--summary Quick pass/fail verdict with top 3 findings
|
|
5021
5131
|
--ignore <pattern> Glob patterns to ignore (can be repeated)
|
|
5022
|
-
--min-severity <level> Minimum severity
|
|
5132
|
+
--min-severity <level> Minimum severity: critical, warning, info (default: info)
|
|
5133
|
+
--profile <name> Preset: startup (criticals only), balanced (warnings+), strict (all)
|
|
5134
|
+
--baseline <file> Only show findings not in baseline file
|
|
5135
|
+
--baseline-save <file> Save current findings as baseline snapshot
|
|
5023
5136
|
--quiet Suppress badge and summary
|
|
5024
5137
|
--web Get your site's prodlint score (14 AI agent checks)
|
|
5025
5138
|
-h, --help Show this help message
|
|
5026
5139
|
-v, --version Show version
|
|
5027
5140
|
|
|
5028
5141
|
Examples:
|
|
5029
|
-
npx prodlint
|
|
5030
|
-
npx prodlint ./my-app
|
|
5031
|
-
npx prodlint --json
|
|
5032
|
-
npx prodlint --
|
|
5033
|
-
npx prodlint --
|
|
5034
|
-
npx prodlint --
|
|
5035
|
-
npx prodlint --
|
|
5036
|
-
npx prodlint --
|
|
5142
|
+
npx prodlint Scan current directory
|
|
5143
|
+
npx prodlint ./my-app Scan specific path
|
|
5144
|
+
npx prodlint --json JSON output
|
|
5145
|
+
npx prodlint --sarif SARIF output for CI integration
|
|
5146
|
+
npx prodlint --summary Quick pass/fail + top blockers
|
|
5147
|
+
npx prodlint --profile startup Only critical findings
|
|
5148
|
+
npx prodlint --baseline .prodlint-baseline.json Only new findings
|
|
5149
|
+
npx prodlint --baseline-save .prodlint-baseline.json Save baseline
|
|
5150
|
+
npx prodlint --ignore "*.test" Ignore test files
|
|
5151
|
+
npx prodlint --min-severity warning Only warnings and criticals
|
|
5152
|
+
npx prodlint --quiet No badge output
|
|
5153
|
+
npx prodlint --web example.com Site score
|
|
5154
|
+
npx prodlint --web example.com --json Site score with JSON output
|
|
5037
5155
|
`);
|
|
5038
5156
|
}
|
|
5039
5157
|
main().catch((err) => {
|