release: WRNexusJS 0.4.0
This commit is contained in:
+101
-40
@@ -1,4 +1,3 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
@@ -22,17 +21,6 @@ export interface CaptchaAuditIssue {
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
|
||||
|
||||
async function copyCaptchaClientRuntime(destinationDir: string): Promise<void> {
|
||||
await mkdir(destinationDir, { recursive: true });
|
||||
await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
|
||||
}
|
||||
|
||||
async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise<void> {
|
||||
await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
|
||||
if (includeBuildOutput) {
|
||||
await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
|
||||
}
|
||||
}
|
||||
export function captchaComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
@@ -40,24 +28,54 @@ export function captchaComponentsDir(): string {
|
||||
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
|
||||
if (!code.includes("<Captcha") && !code.includes('data-component="Captcha"')) return [];
|
||||
const issues: CaptchaAuditIssue[] = [];
|
||||
const push = (id: string, severity: CaptchaAuditIssue["severity"], title: string, message: string) =>
|
||||
issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
const push = (
|
||||
id: string,
|
||||
severity: CaptchaAuditIssue["severity"],
|
||||
title: string,
|
||||
message: string,
|
||||
) => issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
|
||||
if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) {
|
||||
push("client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.");
|
||||
push(
|
||||
"client-secret",
|
||||
"error",
|
||||
"CAPTCHA secret exposed",
|
||||
"Never pass a provider secret or verification secret to a .wrn component.",
|
||||
);
|
||||
}
|
||||
if (!/action\s*=/.test(code)) {
|
||||
push("missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.");
|
||||
push(
|
||||
"missing-action",
|
||||
"warning",
|
||||
"CAPTCHA action is missing",
|
||||
"Bind each challenge to a stable action such as signup, login, or contact-submit.",
|
||||
);
|
||||
}
|
||||
if (/required\s*=\s*["']?false/i.test(code)) {
|
||||
push("optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.");
|
||||
push(
|
||||
"optional-captcha",
|
||||
"warning",
|
||||
"CAPTCHA is optional",
|
||||
"Protected forms should require a CAPTCHA response and verify it on the server.",
|
||||
);
|
||||
}
|
||||
if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) {
|
||||
push("audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.");
|
||||
push(
|
||||
"audio-alternative",
|
||||
"suggestion",
|
||||
"Confirm an accessible alternative",
|
||||
"Visual challenges should offer an audio or non-visual alternative.",
|
||||
);
|
||||
}
|
||||
|
||||
const imageStyle = code.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase();
|
||||
if (imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])) {
|
||||
const imageStyle = code
|
||||
.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
imageStyle &&
|
||||
!CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])
|
||||
) {
|
||||
push(
|
||||
"unknown-image-style",
|
||||
"error",
|
||||
@@ -67,7 +85,11 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
|
||||
}
|
||||
|
||||
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
|
||||
if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
|
||||
if (
|
||||
Number.isFinite(disturbance) &&
|
||||
disturbance >= 65 &&
|
||||
/showAudio\s*=\s*["']?false/i.test(code)
|
||||
) {
|
||||
push(
|
||||
"hard-without-audio",
|
||||
"warning",
|
||||
@@ -75,10 +97,24 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
|
||||
"High disturbance should include audio or another non-visual challenge path.",
|
||||
);
|
||||
}
|
||||
if (external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code)) {
|
||||
push("missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.");
|
||||
if (
|
||||
external &&
|
||||
/provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) &&
|
||||
!/siteKey\s*=/.test(code)
|
||||
) {
|
||||
push(
|
||||
"missing-site-key",
|
||||
"error",
|
||||
"Provider site key is missing",
|
||||
"External CAPTCHA providers require a public site key in the browser.",
|
||||
);
|
||||
}
|
||||
push("server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().");
|
||||
push(
|
||||
"server-verification",
|
||||
"suggestion",
|
||||
"Server verification required",
|
||||
"Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().",
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -86,37 +122,62 @@ export function captchaPlugin(options: CaptchaPluginOptions = {}) {
|
||||
const metadataKey = "@wrnexus/captcha:audit";
|
||||
return definePlugin({
|
||||
name: "@wrnexus/captcha",
|
||||
version: "0.3.6",
|
||||
version: "0.4.0",
|
||||
enforce: "post",
|
||||
async configure(config, context) {
|
||||
componentDirs:
|
||||
options.exposeComponentDirectory === false
|
||||
? []
|
||||
: [options.componentDir ?? captchaComponentsDir()],
|
||||
clientRuntimes: [
|
||||
{
|
||||
id: "captcha",
|
||||
entry: captchaClientRuntime,
|
||||
type: "script",
|
||||
load: "defer",
|
||||
singleton: true,
|
||||
bundle: false,
|
||||
},
|
||||
],
|
||||
styleSources: [
|
||||
{
|
||||
id: "captcha-components",
|
||||
source: options.componentDir ?? captchaComponentsDir(),
|
||||
order: "normal",
|
||||
},
|
||||
],
|
||||
configure(config, context) {
|
||||
const current = (config.captcha ?? {}) as Record<string, unknown>;
|
||||
config.captcha = {
|
||||
componentDir: options.componentDir ?? captchaComponentsDir(),
|
||||
...current,
|
||||
};
|
||||
context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
|
||||
await installCaptchaClientRuntime(context.root, context.command === "build");
|
||||
context.metadata.set(
|
||||
"@wrnexus/captcha:component-dir",
|
||||
options.componentDir ?? captchaComponentsDir(),
|
||||
);
|
||||
},
|
||||
transformCode(code, context) {
|
||||
if (context.mode !== "development") return;
|
||||
const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
const withoutFile = previous.filter((issue) => issue.file !== context.file);
|
||||
context.metadata.set(
|
||||
metadataKey,
|
||||
[...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true)],
|
||||
);
|
||||
context.metadata.set(metadataKey, [
|
||||
...withoutFile,
|
||||
...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true),
|
||||
]);
|
||||
},
|
||||
devToolbarPanels(context) {
|
||||
if (options.enableDevToolbar === false) return [];
|
||||
const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
return [{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user