laxy-verify 1.2.1 → 1.2.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 +193 -64
- package/dist/a11y-deep.d.ts +20 -0
- package/dist/a11y-deep.js +161 -0
- package/dist/ai-analysis.d.ts +28 -0
- package/dist/ai-analysis.js +32 -0
- package/dist/audit/broken-links.d.ts +5 -1
- package/dist/audit/broken-links.js +23 -12
- package/dist/bundle-size.d.ts +14 -0
- package/dist/bundle-size.js +209 -0
- package/dist/cli.js +432 -16
- package/dist/compare-env.d.ts +23 -0
- package/dist/compare-env.js +55 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +149 -4
- package/dist/entitlement.d.ts +2 -0
- package/dist/entitlement.js +5 -1
- package/dist/init-analysis.d.ts +6 -0
- package/dist/init-analysis.js +302 -0
- package/dist/init.js +66 -0
- package/dist/lighthouse.d.ts +31 -1
- package/dist/lighthouse.js +76 -3
- package/dist/outdated-check.d.ts +17 -0
- package/dist/outdated-check.js +123 -0
- package/dist/report-markdown.d.ts +14 -0
- package/dist/report-markdown.js +21 -0
- package/dist/route-discovery.d.ts +7 -0
- package/dist/route-discovery.js +108 -0
- package/dist/secret-scan.d.ts +15 -0
- package/dist/secret-scan.js +218 -0
- package/dist/security-audit.d.ts +9 -1
- package/dist/security-audit.js +87 -24
- package/dist/seo-deep.d.ts +24 -0
- package/dist/seo-deep.js +147 -0
- package/dist/typecheck.d.ts +8 -0
- package/dist/typecheck.js +99 -0
- package/dist/verification-core/report.js +117 -0
- package/dist/verification-core/types.d.ts +58 -2
- package/dist/visual-diff.d.ts +8 -1
- package/dist/visual-diff.js +53 -8
- package/dist/vitals-budget.d.ts +23 -0
- package/dist/vitals-budget.js +168 -0
- package/package.json +1 -1
|
@@ -2,6 +2,13 @@ import type { E2EScenarioResult } from "./e2e.js";
|
|
|
2
2
|
import type { LighthouseScores } from "./grade.js";
|
|
3
3
|
import type { TierVerificationView, VerificationReport } from "./verification-core/index.js";
|
|
4
4
|
import { type VisualDiffResult } from "./visual-diff.js";
|
|
5
|
+
import type { TypecheckResult } from "./typecheck.js";
|
|
6
|
+
import type { SecretScanResult } from "./secret-scan.js";
|
|
7
|
+
import type { BundleSizeResult } from "./bundle-size.js";
|
|
8
|
+
import type { OutdatedCheckResult } from "./outdated-check.js";
|
|
9
|
+
import type { A11yDeepResult } from "./a11y-deep.js";
|
|
10
|
+
import type { SeoDeepResult } from "./seo-deep.js";
|
|
11
|
+
import type { VitalsBudgetResult } from "./vitals-budget.js";
|
|
5
12
|
export interface MarkdownReportResult {
|
|
6
13
|
grade: string;
|
|
7
14
|
timestamp: string;
|
|
@@ -20,6 +27,13 @@ export interface MarkdownReportResult {
|
|
|
20
27
|
runs: number;
|
|
21
28
|
}) | null;
|
|
22
29
|
visualDiff?: VisualDiffResult | null;
|
|
30
|
+
typecheck?: TypecheckResult | null;
|
|
31
|
+
secretScan?: SecretScanResult | null;
|
|
32
|
+
bundleSize?: BundleSizeResult | null;
|
|
33
|
+
outdatedCheck?: OutdatedCheckResult | null;
|
|
34
|
+
a11yDeep?: A11yDeepResult | null;
|
|
35
|
+
seoDeep?: SeoDeepResult | null;
|
|
36
|
+
vitalsBudget?: VitalsBudgetResult | null;
|
|
23
37
|
thresholds: {
|
|
24
38
|
performance: number;
|
|
25
39
|
accessibility: number;
|
package/dist/report-markdown.js
CHANGED
|
@@ -162,6 +162,27 @@ function renderMetrics(result) {
|
|
|
162
162
|
if (result.visualDiff) {
|
|
163
163
|
lines.push(`| Visual diff | ${(0, visual_diff_js_1.formatVisualDiffSummary)(result.visualDiff)} |`);
|
|
164
164
|
}
|
|
165
|
+
if (result.typecheck && !result.typecheck.skipped) {
|
|
166
|
+
lines.push(`| TypeScript | ${result.typecheck.passed ? "OK" : `${result.typecheck.errorCount} error(s)`} |`);
|
|
167
|
+
}
|
|
168
|
+
if (result.secretScan && !result.secretScan.skipped) {
|
|
169
|
+
lines.push(`| Secret scan | ${result.secretScan.passed ? "OK" : `${result.secretScan.findings.length} finding(s)`} (${result.secretScan.filesScanned} files) |`);
|
|
170
|
+
}
|
|
171
|
+
if (result.bundleSize && !result.bundleSize.skipped) {
|
|
172
|
+
lines.push(`| Bundle size | ${result.bundleSize.advisory} |`);
|
|
173
|
+
}
|
|
174
|
+
if (result.outdatedCheck && !result.outdatedCheck.skipped) {
|
|
175
|
+
lines.push(`| Outdated deps | ${result.outdatedCheck.advisory} |`);
|
|
176
|
+
}
|
|
177
|
+
if (result.a11yDeep && !result.a11yDeep.skipped) {
|
|
178
|
+
lines.push(`| A11y deep | ${result.a11yDeep.summary} |`);
|
|
179
|
+
}
|
|
180
|
+
if (result.seoDeep && !result.seoDeep.skipped) {
|
|
181
|
+
lines.push(`| SEO deep | ${result.seoDeep.summary} |`);
|
|
182
|
+
}
|
|
183
|
+
if (result.vitalsBudget && !result.vitalsBudget.skipped) {
|
|
184
|
+
lines.push(`| Vitals budget | ${result.vitalsBudget.summary} |`);
|
|
185
|
+
}
|
|
165
186
|
lines.push("");
|
|
166
187
|
return `${lines.join("\n")}\n`;
|
|
167
188
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface RuntimeRouteDiscoveryResult {
|
|
2
|
+
routes: string[];
|
|
3
|
+
scriptUrls: string[];
|
|
4
|
+
}
|
|
5
|
+
export declare function extractScriptUrlsFromHtml(html: string, baseUrl: string): string[];
|
|
6
|
+
export declare function extractRoutesFromText(content: string): string[];
|
|
7
|
+
export declare function discoverRuntimeRoutes(baseUrl: string): Promise<RuntimeRouteDiscoveryResult>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractScriptUrlsFromHtml = extractScriptUrlsFromHtml;
|
|
4
|
+
exports.extractRoutesFromText = extractRoutesFromText;
|
|
5
|
+
exports.discoverRuntimeRoutes = discoverRuntimeRoutes;
|
|
6
|
+
const SCRIPT_SRC_REGEX = /<script[^>]+src=["']([^"'#?]+(?:\?[^"'#]*)?)["']/gi;
|
|
7
|
+
const HTML_ROUTE_REGEX = /(?:href|data-href)=["'](\/[^"'#? ]*)/gi;
|
|
8
|
+
const ROUTE_SNIPPET_REGEXES = [
|
|
9
|
+
/(?:path|pathname|href|to|route|router\.push|navigate)\s*[:=(]\s*["'`]((?:\/(?!\/)[^"'`?#]+))["'`]/g,
|
|
10
|
+
/["'`]((?:\/(?!\/)[a-z0-9][^"'`?#]*))["'`]/gi,
|
|
11
|
+
];
|
|
12
|
+
function normalizeRoute(candidate) {
|
|
13
|
+
const decoded = candidate
|
|
14
|
+
.replace(/\\u002F/gi, "/")
|
|
15
|
+
.replace(/\\\//g, "/")
|
|
16
|
+
.trim();
|
|
17
|
+
if (!decoded.startsWith("/"))
|
|
18
|
+
return null;
|
|
19
|
+
const normalized = decoded
|
|
20
|
+
.split("?")[0]
|
|
21
|
+
?.split("#")[0]
|
|
22
|
+
?.replace(/\/+/g, "/")
|
|
23
|
+
?.replace(/\/$/, "") || "/";
|
|
24
|
+
if (normalized === "/" || normalized.length < 2)
|
|
25
|
+
return null;
|
|
26
|
+
if (normalized.startsWith("/_next/") || normalized.startsWith("/api/"))
|
|
27
|
+
return null;
|
|
28
|
+
if (/[.*:[\]]/.test(normalized))
|
|
29
|
+
return null;
|
|
30
|
+
if (/\.[a-z0-9]{2,8}$/i.test(normalized))
|
|
31
|
+
return null;
|
|
32
|
+
if (/\s/.test(normalized))
|
|
33
|
+
return null;
|
|
34
|
+
if (!/^\/[a-z0-9/_-]+$/i.test(normalized))
|
|
35
|
+
return null;
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
function extractScriptUrlsFromHtml(html, baseUrl) {
|
|
39
|
+
const urls = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const match of html.matchAll(SCRIPT_SRC_REGEX)) {
|
|
42
|
+
const raw = match[1];
|
|
43
|
+
if (!raw)
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const scriptUrl = new URL(raw, baseUrl);
|
|
47
|
+
if (scriptUrl.origin !== new URL(baseUrl).origin)
|
|
48
|
+
continue;
|
|
49
|
+
const href = scriptUrl.href;
|
|
50
|
+
if (!seen.has(href)) {
|
|
51
|
+
seen.add(href);
|
|
52
|
+
urls.push(href);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Ignore malformed URLs.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return urls;
|
|
60
|
+
}
|
|
61
|
+
function extractRoutesFromText(content) {
|
|
62
|
+
const routes = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const regex of ROUTE_SNIPPET_REGEXES) {
|
|
65
|
+
for (const match of content.matchAll(regex)) {
|
|
66
|
+
const route = normalizeRoute(match[1] ?? "");
|
|
67
|
+
if (!route || seen.has(route))
|
|
68
|
+
continue;
|
|
69
|
+
seen.add(route);
|
|
70
|
+
routes.push(route);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return routes;
|
|
74
|
+
}
|
|
75
|
+
async function discoverRuntimeRoutes(baseUrl) {
|
|
76
|
+
const htmlRes = await fetch(baseUrl, {
|
|
77
|
+
signal: AbortSignal.timeout(8000),
|
|
78
|
+
headers: { accept: "text/html,application/xhtml+xml" },
|
|
79
|
+
});
|
|
80
|
+
const html = await htmlRes.text();
|
|
81
|
+
const routes = new Set(extractRoutesFromText(html));
|
|
82
|
+
for (const match of html.matchAll(HTML_ROUTE_REGEX)) {
|
|
83
|
+
const route = normalizeRoute(match[1] ?? "");
|
|
84
|
+
if (route)
|
|
85
|
+
routes.add(route);
|
|
86
|
+
}
|
|
87
|
+
const scriptUrls = extractScriptUrlsFromHtml(html, baseUrl)
|
|
88
|
+
.filter((url) => /\/_next\/static\/chunks\/|assets\/|static\/|build\//i.test(url))
|
|
89
|
+
.slice(0, 10);
|
|
90
|
+
await Promise.all(scriptUrls.map(async (scriptUrl) => {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(scriptUrl, { signal: AbortSignal.timeout(5000) });
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
return;
|
|
95
|
+
const content = await res.text();
|
|
96
|
+
for (const route of extractRoutesFromText(content)) {
|
|
97
|
+
routes.add(route);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Skip chunk fetch failures. This is best-effort coverage expansion.
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
return {
|
|
105
|
+
routes: Array.from(routes).sort((a, b) => a.localeCompare(b)),
|
|
106
|
+
scriptUrls,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SecretScanResult {
|
|
2
|
+
passed: boolean;
|
|
3
|
+
findings: SecretFinding[];
|
|
4
|
+
filesScanned: number;
|
|
5
|
+
skipped: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface SecretFinding {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
pattern: string;
|
|
11
|
+
preview: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function runSecretScan(projectDir: string, options?: {
|
|
14
|
+
ignorePaths?: string[];
|
|
15
|
+
}): Promise<SecretScanResult>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runSecretScan = runSecretScan;
|
|
37
|
+
/**
|
|
38
|
+
* Secret/credential leak scanner.
|
|
39
|
+
*
|
|
40
|
+
* Scans source files for hardcoded secrets using regex patterns.
|
|
41
|
+
* Supports ignore paths and custom ignore patterns from .laxy.yml.
|
|
42
|
+
*/
|
|
43
|
+
const fs = __importStar(require("node:fs"));
|
|
44
|
+
const path = __importStar(require("node:path"));
|
|
45
|
+
const DEFAULT_IGNORE_DIRS = new Set([
|
|
46
|
+
"node_modules",
|
|
47
|
+
".git",
|
|
48
|
+
".next",
|
|
49
|
+
"dist",
|
|
50
|
+
"build",
|
|
51
|
+
".laxy-tmp",
|
|
52
|
+
".laxy-baselines",
|
|
53
|
+
"coverage",
|
|
54
|
+
".cache",
|
|
55
|
+
"test",
|
|
56
|
+
"tests",
|
|
57
|
+
"__tests__",
|
|
58
|
+
"spec",
|
|
59
|
+
]);
|
|
60
|
+
const SECRET_PATTERNS = [
|
|
61
|
+
{ name: "AWS Access Key", regex: /AKIA[0-9A-Z]{16}/ },
|
|
62
|
+
{ name: "AWS Secret Key", regex: /(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*['"][A-Za-z0-9/+=]{40}['"]/ },
|
|
63
|
+
{ name: "GitHub Token", regex: /ghp_[A-Za-z0-9]{36}/ },
|
|
64
|
+
{ name: "GitHub OAuth", regex: /gho_[A-Za-z0-9]{36}/ },
|
|
65
|
+
{ name: "GitHub App Token", regex: /(?:ghs_|ghu_)[A-Za-z0-9]{36}/ },
|
|
66
|
+
{ name: "Slack Token", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
67
|
+
{ name: "Slack Webhook", regex: /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/ },
|
|
68
|
+
{ name: "Private Key Block", regex: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/ },
|
|
69
|
+
{ name: "Google API Key", regex: /AIza[0-9A-Za-z_-]{35}/ },
|
|
70
|
+
{ name: "Google OAuth Token", regex: /[0-9]+-[a-z0-9_]{32}\.apps\.googleusercontent\.com/ },
|
|
71
|
+
{ name: "Stripe Secret Key", regex: /sk_live_[0-9a-zA-Z]{24,}/ },
|
|
72
|
+
{ name: "Stripe Publishable Key", regex: /pk_live_[0-9a-zA-Z]{24,}/ },
|
|
73
|
+
{ name: "Twilio API Key", regex: /SK[0-9a-fA-F]{32}/ },
|
|
74
|
+
{ name: "SendGrid API Key", regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/ },
|
|
75
|
+
{ name: "Mailgun API Key", regex: /key-[0-9a-zA-Z]{32}/ },
|
|
76
|
+
{ name: "Hardcoded Bearer Token", regex: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/ },
|
|
77
|
+
{ name: "JWT-like Secret", regex: /['"]eyJ[A-Za-z0-9\-._~+/]+=*\.[A-Za-z0-9\-._~+/]+=*['"]/ },
|
|
78
|
+
{ name: "Generic Secret Assignment", regex: /(?:secret|password|passwd|token|api_key|apikey|access_key|private_key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
|
|
79
|
+
];
|
|
80
|
+
const ALLOWLIST_PATTERNS = [
|
|
81
|
+
// Common false positives
|
|
82
|
+
/process\.env\./,
|
|
83
|
+
/import\.meta\.env\./,
|
|
84
|
+
/NEXT_PUBLIC_/,
|
|
85
|
+
/VITE_/,
|
|
86
|
+
/REACT_APP_/,
|
|
87
|
+
/NUXT_ENV_/,
|
|
88
|
+
/placeholder/i,
|
|
89
|
+
/example/i,
|
|
90
|
+
/your[_-]?(?:api|secret|key|token)/i,
|
|
91
|
+
/xxx+/,
|
|
92
|
+
/<[^>]+>/,
|
|
93
|
+
/\$\{\{.*\}\}/, // GitHub Actions / CI template variables
|
|
94
|
+
/secrets\.[A-Z_]+/, // GitHub Actions secrets references
|
|
95
|
+
/env\.[A-Z_]+/, // CI env variable references
|
|
96
|
+
/regex:\s*\//, // Regex pattern definitions in source code
|
|
97
|
+
/^\s*\/\//, // Single-line comments (// ...)
|
|
98
|
+
/^\s*\*/, // Block comment lines (* ...)
|
|
99
|
+
/^\s*<!--/, // HTML comment lines
|
|
100
|
+
];
|
|
101
|
+
function shouldIgnoreLine(line) {
|
|
102
|
+
return ALLOWLIST_PATTERNS.some((p) => p.test(line));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Masks potential secret values in a line preview so that
|
|
106
|
+
* actual credentials are never exposed in JSON output or reports.
|
|
107
|
+
* Replaces quoted string contents after = or : with ***.
|
|
108
|
+
*/
|
|
109
|
+
function maskSecret(line) {
|
|
110
|
+
return line
|
|
111
|
+
// Mask single-quoted values: = '...' or : '...'
|
|
112
|
+
.replace(/([=:])\s*'([^']{4,})'/g, "$1 '***'")
|
|
113
|
+
// Mask double-quoted values: = "..." or : "..."
|
|
114
|
+
.replace(/([=:])\s*"([^"]{4,})"/g, '$1 "***"')
|
|
115
|
+
// Mask bare tokens after Bearer
|
|
116
|
+
.replace(/(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/g, "$1***")
|
|
117
|
+
// Mask AKIA keys
|
|
118
|
+
.replace(/(AKIA)[0-9A-Z]{16}/g, "$1****************")
|
|
119
|
+
// Mask ghp_/gho_/ghs_/ghu_ tokens
|
|
120
|
+
.replace(/(gh[pous]_)[A-Za-z0-9]{36}/g, "$1************************************")
|
|
121
|
+
// Mask xoxb-/xoxp- etc tokens
|
|
122
|
+
.replace(/(xox[baprs]-)[A-Za-z0-9-]{10,}/g, "$1**********")
|
|
123
|
+
// Mask AIza keys
|
|
124
|
+
.replace(/(AIza)[0-9A-Za-z_-]{35}/g, "$1***********************************")
|
|
125
|
+
// Mask sk_live_/pk_live_ keys
|
|
126
|
+
.replace(/(s?k_live_)[0-9a-zA-Z]{24,}/g, "$1************************")
|
|
127
|
+
// Mask SK hex keys (Twilio)
|
|
128
|
+
.replace(/(SK)[0-9a-fA-F]{32}/g, "$1********************************")
|
|
129
|
+
// Mask key- prefix (Mailgun)
|
|
130
|
+
.replace(/(key-)[0-9a-zA-Z]{32}/g, "$1********************************")
|
|
131
|
+
// Mask SG. tokens (SendGrid)
|
|
132
|
+
.replace(/(SG\.)[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, "$1**********************.*******************************************");
|
|
133
|
+
}
|
|
134
|
+
function scanFile(filePath, relativePath, ignorePatterns) {
|
|
135
|
+
if (ignorePatterns.some((p) => relativePath.includes(p)))
|
|
136
|
+
return [];
|
|
137
|
+
const findings = [];
|
|
138
|
+
let content;
|
|
139
|
+
try {
|
|
140
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const lines = content.split("\n");
|
|
146
|
+
for (let i = 0; i < lines.length; i++) {
|
|
147
|
+
const line = lines[i];
|
|
148
|
+
if (shouldIgnoreLine(line))
|
|
149
|
+
continue;
|
|
150
|
+
for (const { name, regex } of SECRET_PATTERNS) {
|
|
151
|
+
if (regex.test(line)) {
|
|
152
|
+
const preview = maskSecret(line.trim().slice(0, 120));
|
|
153
|
+
findings.push({
|
|
154
|
+
file: relativePath,
|
|
155
|
+
line: i + 1,
|
|
156
|
+
pattern: name,
|
|
157
|
+
preview,
|
|
158
|
+
});
|
|
159
|
+
break; // one finding per line max
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return findings;
|
|
164
|
+
}
|
|
165
|
+
function walkDir(dir, rootDir, ignoreDirs, ignorePatterns) {
|
|
166
|
+
const results = [];
|
|
167
|
+
let entries;
|
|
168
|
+
try {
|
|
169
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (ignoreDirs.has(entry.name))
|
|
176
|
+
continue;
|
|
177
|
+
if (entry.name.startsWith(".") && entry.name !== ".env")
|
|
178
|
+
continue;
|
|
179
|
+
const fullPath = path.join(dir, entry.name);
|
|
180
|
+
const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
181
|
+
if (ignorePatterns.some((p) => relativePath.includes(p)))
|
|
182
|
+
continue;
|
|
183
|
+
if (entry.isDirectory()) {
|
|
184
|
+
results.push(...walkDir(fullPath, rootDir, ignoreDirs, ignorePatterns));
|
|
185
|
+
}
|
|
186
|
+
else if (entry.isFile()) {
|
|
187
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
188
|
+
if ([".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".env", ".json", ".yml", ".yaml", ".toml", ".py", ".rb"].includes(ext) ||
|
|
189
|
+
entry.name === ".env" || entry.name.startsWith(".env.")) {
|
|
190
|
+
results.push(fullPath);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
async function runSecretScan(projectDir, options) {
|
|
197
|
+
const ignorePatterns = options?.ignorePaths ?? [];
|
|
198
|
+
const ignoreDirs = new Set(DEFAULT_IGNORE_DIRS);
|
|
199
|
+
console.log(" Running secret scan...");
|
|
200
|
+
const files = walkDir(projectDir, projectDir, ignoreDirs, ignorePatterns);
|
|
201
|
+
const allFindings = [];
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
const relativePath = path.relative(projectDir, file).replace(/\\/g, "/");
|
|
204
|
+
const findings = scanFile(file, relativePath, ignorePatterns);
|
|
205
|
+
allFindings.push(...findings);
|
|
206
|
+
}
|
|
207
|
+
const passed = allFindings.length === 0;
|
|
208
|
+
if (passed) {
|
|
209
|
+
console.log(` Secret scan: OK (${files.length} files scanned)`);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
console.log(` Secret scan: ${allFindings.length} finding(s) in ${files.length} files`);
|
|
213
|
+
for (const f of allFindings.slice(0, 5)) {
|
|
214
|
+
console.error(` ${f.file}:${f.line} [${f.pattern}]`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { passed, findings: allFindings, filesScanned: files.length, skipped: false };
|
|
218
|
+
}
|
package/dist/security-audit.d.ts
CHANGED
|
@@ -5,5 +5,13 @@ export interface SecurityAuditResult {
|
|
|
5
5
|
moderate: number;
|
|
6
6
|
low: number;
|
|
7
7
|
summary: string;
|
|
8
|
+
missingHeaders: string[];
|
|
9
|
+
headerCheckUrl?: string;
|
|
10
|
+
headerCheckError?: string;
|
|
8
11
|
}
|
|
9
|
-
export declare function
|
|
12
|
+
export declare function auditSecurityHeaders(url: string): Promise<{
|
|
13
|
+
missingHeaders: string[];
|
|
14
|
+
checkedUrl: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function runSecurityAudit(cwd: string, appUrl?: string, timeoutMs?: number): Promise<SecurityAuditResult>;
|
package/dist/security-audit.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.auditSecurityHeaders = auditSecurityHeaders;
|
|
3
4
|
exports.runSecurityAudit = runSecurityAudit;
|
|
4
5
|
/**
|
|
5
|
-
* npm audit wrapper
|
|
6
|
+
* npm audit wrapper plus runtime security-header checks.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* The package audit catches known dependency vulnerabilities.
|
|
9
|
+
* The header audit adds shallow runtime coverage for common missing
|
|
10
|
+
* browser-enforced protections on the running app.
|
|
9
11
|
*/
|
|
10
12
|
const node_child_process_1 = require("node:child_process");
|
|
11
|
-
async function
|
|
12
|
-
console.log(" Running security audit (npm audit)...");
|
|
13
|
+
async function runNpmAudit(cwd, timeoutMs) {
|
|
13
14
|
return new Promise((resolve) => {
|
|
14
15
|
const chunks = [];
|
|
15
16
|
const proc = process.platform === "win32"
|
|
@@ -27,38 +28,100 @@ async function runSecurityAudit(cwd, timeoutMs = 30000) {
|
|
|
27
28
|
proc.kill();
|
|
28
29
|
}
|
|
29
30
|
catch { }
|
|
30
|
-
resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0
|
|
31
|
+
resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0 });
|
|
31
32
|
}, timeoutMs);
|
|
32
33
|
proc.stdout?.on("data", (chunk) => chunks.push(chunk.toString()));
|
|
33
|
-
proc.stderr?.on("data", () => { });
|
|
34
|
+
proc.stderr?.on("data", () => { });
|
|
34
35
|
proc.on("exit", () => {
|
|
35
36
|
clearTimeout(timer);
|
|
36
|
-
const raw = chunks.join("");
|
|
37
37
|
try {
|
|
38
|
-
const json = JSON.parse(
|
|
39
|
-
// npm audit v2 format
|
|
38
|
+
const json = JSON.parse(chunks.join(""));
|
|
40
39
|
const meta = json.metadata?.vulnerabilities ?? json.vulnerabilities ?? {};
|
|
41
40
|
const critical = meta.critical ?? 0;
|
|
42
41
|
const high = meta.high ?? 0;
|
|
43
42
|
const moderate = meta.moderate ?? 0;
|
|
44
43
|
const low = meta.low ?? 0;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
parts.push(`${moderate} moderate`);
|
|
53
|
-
if (low > 0)
|
|
54
|
-
parts.push(`${low} low`);
|
|
55
|
-
const summary = total === 0 ? "No known vulnerabilities" : parts.join(", ");
|
|
56
|
-
console.log(` Security: ${summary}`);
|
|
57
|
-
resolve({ totalVulnerabilities: total, critical, high, moderate, low, summary });
|
|
44
|
+
resolve({
|
|
45
|
+
totalVulnerabilities: critical + high + moderate + low,
|
|
46
|
+
critical,
|
|
47
|
+
high,
|
|
48
|
+
moderate,
|
|
49
|
+
low,
|
|
50
|
+
});
|
|
58
51
|
}
|
|
59
52
|
catch {
|
|
60
|
-
resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0
|
|
53
|
+
resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0 });
|
|
61
54
|
}
|
|
62
55
|
});
|
|
63
56
|
});
|
|
64
57
|
}
|
|
58
|
+
async function auditSecurityHeaders(url) {
|
|
59
|
+
const requiredHeaders = ["X-Frame-Options", "Content-Security-Policy"];
|
|
60
|
+
const urlObj = new URL(url);
|
|
61
|
+
if (urlObj.protocol === "https:") {
|
|
62
|
+
requiredHeaders.push("Strict-Transport-Security");
|
|
63
|
+
}
|
|
64
|
+
let response;
|
|
65
|
+
try {
|
|
66
|
+
response = await fetch(url, {
|
|
67
|
+
method: "HEAD",
|
|
68
|
+
redirect: "follow",
|
|
69
|
+
signal: AbortSignal.timeout(5000),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
try {
|
|
74
|
+
response = await fetch(url, {
|
|
75
|
+
method: "GET",
|
|
76
|
+
redirect: "follow",
|
|
77
|
+
signal: AbortSignal.timeout(5000),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return {
|
|
82
|
+
missingHeaders: [],
|
|
83
|
+
checkedUrl: url,
|
|
84
|
+
error: error instanceof Error ? error.message : String(error),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const missingHeaders = requiredHeaders.filter((headerName) => !response.headers.get(headerName));
|
|
89
|
+
return {
|
|
90
|
+
missingHeaders,
|
|
91
|
+
checkedUrl: response.url || url,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function runSecurityAudit(cwd, appUrl, timeoutMs = 30000) {
|
|
95
|
+
console.log(" Running security audit (npm audit + runtime headers)...");
|
|
96
|
+
const [npmAudit, headerAudit] = await Promise.all([
|
|
97
|
+
runNpmAudit(cwd, timeoutMs),
|
|
98
|
+
appUrl ? auditSecurityHeaders(appUrl) : Promise.resolve(null),
|
|
99
|
+
]);
|
|
100
|
+
const parts = [];
|
|
101
|
+
if (npmAudit.critical > 0)
|
|
102
|
+
parts.push(`${npmAudit.critical} critical`);
|
|
103
|
+
if (npmAudit.high > 0)
|
|
104
|
+
parts.push(`${npmAudit.high} high`);
|
|
105
|
+
if (npmAudit.moderate > 0)
|
|
106
|
+
parts.push(`${npmAudit.moderate} moderate`);
|
|
107
|
+
if (npmAudit.low > 0)
|
|
108
|
+
parts.push(`${npmAudit.low} low`);
|
|
109
|
+
const missingHeaders = headerAudit?.missingHeaders ?? [];
|
|
110
|
+
if (missingHeaders.length > 0) {
|
|
111
|
+
parts.push(`missing headers: ${missingHeaders.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
if (headerAudit?.error) {
|
|
114
|
+
parts.push(`header check skipped: ${headerAudit.error}`);
|
|
115
|
+
}
|
|
116
|
+
const summary = parts.length > 0
|
|
117
|
+
? parts.join(" | ")
|
|
118
|
+
: "No known vulnerabilities or missing security headers";
|
|
119
|
+
console.log(` Security: ${summary}`);
|
|
120
|
+
return {
|
|
121
|
+
...npmAudit,
|
|
122
|
+
summary,
|
|
123
|
+
missingHeaders,
|
|
124
|
+
headerCheckUrl: headerAudit?.checkedUrl,
|
|
125
|
+
headerCheckError: headerAudit?.error,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep SEO audit.
|
|
3
|
+
*
|
|
4
|
+
* Checks page-level SEO signals beyond Lighthouse's basic SEO category:
|
|
5
|
+
* title, meta description, canonical, robots, Open Graph, Twitter Card,
|
|
6
|
+
* JSON-LD structured data. Runs against the running dev server via HTTP.
|
|
7
|
+
*/
|
|
8
|
+
export interface SeoDeepResult {
|
|
9
|
+
passed: boolean;
|
|
10
|
+
checks: SeoCheck[];
|
|
11
|
+
warningCount: number;
|
|
12
|
+
errorCount: number;
|
|
13
|
+
url: string;
|
|
14
|
+
skipped: boolean;
|
|
15
|
+
summary: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SeoCheck {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
passed: boolean;
|
|
21
|
+
value: string | null;
|
|
22
|
+
severity: "error" | "warning";
|
|
23
|
+
}
|
|
24
|
+
export declare function runSeoDeep(url: string): Promise<SeoDeepResult>;
|