evershield 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * Headless browser driver. Playwright is an optional peer dependency: the
11
+ * layer 1 and 2 commands never load it, and `evershield surface` explains
12
+ * how to install it when it is missing.
13
+ *
14
+ * Only the small slice of Playwright's API evershield uses is typed here,
15
+ * so the published types do not depend on Playwright being installed.
16
+ */
17
+ import { readFile } from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { interpolateEnv } from "../audit/http.js";
20
+ import { ConfigError } from "../config.js";
21
+ import { collectSurface } from "./collect.js";
22
+ export const PLAYWRIGHT_MISSING = "evershield surface drives a headless browser and needs Playwright: npm install --save-dev playwright && npx playwright install chromium";
23
+ async function loadPlaywright() {
24
+ for (const name of ["playwright", "playwright-core"]) {
25
+ try {
26
+ return (await import(__rewriteRelativeImportExtension(name)));
27
+ }
28
+ catch {
29
+ // Try the next package name.
30
+ }
31
+ }
32
+ throw new Error(PLAYWRIGHT_MISSING);
33
+ }
34
+ const COLLECT_LIMITS = { maxBlocks: 5000, maxText: 500 };
35
+ function emptySnapshot(request, error) {
36
+ return {
37
+ identity: request.identityName,
38
+ url: request.url,
39
+ finalUrl: request.url,
40
+ status: 0,
41
+ blocks: [],
42
+ attributes: [],
43
+ scripts: [],
44
+ storage: [],
45
+ selectorMatches: [],
46
+ error,
47
+ };
48
+ }
49
+ async function readStorageState(file, env) {
50
+ const resolved = path.resolve(interpolateEnv(file, env));
51
+ try {
52
+ return JSON.parse(await readFile(resolved, "utf8"));
53
+ }
54
+ catch (error) {
55
+ throw new ConfigError(`cannot read storageState ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ export async function launchPlaywrightDriver(env = process.env) {
59
+ const playwright = await loadPlaywright();
60
+ const executablePath = env.EVERSHIELD_CHROMIUM_PATH;
61
+ const browser = await playwright.chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) });
62
+ return {
63
+ async capture(request) {
64
+ const env = request.env ?? process.env;
65
+ const origin = new URL(request.baseUrl).origin;
66
+ const headers = Object.fromEntries(Object.entries(request.identity.headers ?? {}).map(([name, value]) => [name.toLowerCase(), interpolateEnv(value, env)]));
67
+ const cookies = Object.entries(request.identity.cookies ?? {}).map(([name, value]) => ({ name, value: interpolateEnv(value, env), url: origin }));
68
+ const storageState = request.identity.storageState === undefined ? undefined : await readStorageState(request.identity.storageState, env);
69
+ // Service workers are blocked so every request goes through the route
70
+ // below, which adds credentials only for the audited origin.
71
+ const context = await browser.newContext({ serviceWorkers: "block", ...(storageState === undefined ? {} : { storageState }) });
72
+ try {
73
+ if (Object.keys(headers).length > 0) {
74
+ await context.route("**/*", async (route) => {
75
+ const target = route.request();
76
+ if (new URL(target.url()).origin === origin)
77
+ await route.continue({ headers: { ...target.headers(), ...headers } });
78
+ else
79
+ await route.continue();
80
+ });
81
+ }
82
+ if (cookies.length > 0)
83
+ await context.addCookies(cookies);
84
+ const page = await context.newPage();
85
+ let response;
86
+ try {
87
+ response = await page.goto(request.url, { waitUntil: "networkidle", timeout: request.timeoutMs });
88
+ }
89
+ catch (error) {
90
+ return emptySnapshot(request, error instanceof Error ? error.message.split("\n")[0] : String(error));
91
+ }
92
+ if (request.waitFor !== undefined) {
93
+ try {
94
+ await page.waitForSelector(request.waitFor, { timeout: request.timeoutMs, state: "attached" });
95
+ }
96
+ catch {
97
+ return { ...emptySnapshot(request, `waitFor selector "${request.waitFor}" never appeared`), finalUrl: page.url(), status: response?.status() ?? 0 };
98
+ }
99
+ }
100
+ // Let late effects (entitlement checks, transitions) settle.
101
+ await page.waitForTimeout(300);
102
+ const args = { absentSelectors: request.absentSelectors, ...COLLECT_LIMITS };
103
+ const collected = await page.evaluate(collectSurface, args);
104
+ return { identity: request.identityName, url: request.url, finalUrl: page.url(), status: response?.status() ?? 0, ...collected };
105
+ }
106
+ finally {
107
+ await context.close();
108
+ }
109
+ },
110
+ async close() {
111
+ await browser.close();
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Surface rules. Each inspects one page observed as every identity.
3
+ *
4
+ * - surface/expected-absent-present: the operator said identity X must not
5
+ * receive text T (or selector S) on this page, and it is somewhere in X's
6
+ * DOM, attributes, inline scripts or storage. High/high: the operator's
7
+ * own contract.
8
+ * - surface/hidden-higher-tier-content: text the top tier can see is present
9
+ * for a lower tier but hidden from it (display:none, blur, clipped, in a
10
+ * data attribute, inline script or storage). The paywall is cosmetic.
11
+ * High/high: the same text, visible to one tier and hidden in another's
12
+ * page, is not a coincidence.
13
+ * - surface/obscured-content: a lower tier's page blurs or makes transparent
14
+ * real-looking text. Blur is how UIs fake a paywall; legitimate hidden UI
15
+ * (menus, tabs) uses display or visibility instead. High/medium: the text
16
+ * may be a decoy, which the rule cannot always tell.
17
+ *
18
+ * Tabs, menus and dialogs hidden for every tier never fire the differential
19
+ * rule, because it requires the top tier to see the text.
20
+ */
21
+ import type { Confidence, Finding, Severity } from "../index.ts";
22
+ import type { PageObservation } from "./types.ts";
23
+ export interface SurfaceRule {
24
+ id: string;
25
+ defaultSeverity: Severity;
26
+ defaultConfidence: Confidence;
27
+ check(observation: PageObservation): Finding[];
28
+ }
29
+ export declare const expectedAbsentPresent: SurfaceRule;
30
+ export declare const hiddenHigherTierContent: SurfaceRule;
31
+ export declare const obscuredContent: SurfaceRule;
32
+ export declare const SURFACE_RULES: readonly SurfaceRule[];
@@ -0,0 +1,154 @@
1
+ const RULES_DOC = "https://github.com/inviy/evershield/blob/main/docs/rules.md";
2
+ const help = (id) => `${RULES_DOC}#${id.replace("/", "")}`;
3
+ function excerpt(text, max = 80) {
4
+ return text.length > max ? `${text.slice(0, max)}…` : text;
5
+ }
6
+ function usable(snapshot) {
7
+ return snapshot !== undefined && snapshot.error === undefined && snapshot.status < 400;
8
+ }
9
+ /** Find `needle` in everything the identity received, hidden or not. */
10
+ function locate(snapshot, needle, options) {
11
+ for (const block of snapshot.blocks) {
12
+ if (options.hiddenOnly && block.hiddenBy === null)
13
+ continue;
14
+ if (block.text.includes(needle)) {
15
+ return { selector: block.selector, where: block.hiddenBy === null ? "visible text" : `text hidden by ${block.hiddenBy}`, text: block.text };
16
+ }
17
+ }
18
+ for (const attribute of snapshot.attributes) {
19
+ if (attribute.value.includes(needle))
20
+ return { selector: `${attribute.selector}[${attribute.name}]`, where: `attribute ${attribute.name}`, text: attribute.value };
21
+ }
22
+ for (const script of snapshot.scripts) {
23
+ if (script.text.includes(needle))
24
+ return { selector: script.selector, where: "inline script", text: needle };
25
+ }
26
+ for (const entry of snapshot.storage) {
27
+ if (entry.value.includes(needle) || entry.key.includes(needle)) {
28
+ return { selector: `${entry.area}[${JSON.stringify(entry.key)}]`, where: entry.area, text: needle };
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ export const expectedAbsentPresent = {
34
+ id: "surface/expected-absent-present",
35
+ defaultSeverity: "high",
36
+ defaultConfidence: "high",
37
+ check(observation) {
38
+ const findings = [];
39
+ for (const [identity, expectation] of Object.entries(observation.page.expect ?? {})) {
40
+ const snapshot = observation.snapshots.get(identity);
41
+ if (!usable(snapshot))
42
+ continue;
43
+ for (const text of expectation.absentText ?? []) {
44
+ const hit = locate(snapshot, text, { hiddenOnly: false });
45
+ if (hit === undefined)
46
+ continue;
47
+ findings.push({
48
+ ruleId: expectedAbsentPresent.id,
49
+ layer: "surface",
50
+ severity: "high",
51
+ confidence: "high",
52
+ message: `${observation.page.path} gives ${identity} "${excerpt(text, 40)}" (${hit.where}), which the config says it must not receive`,
53
+ evidence: [{ kind: "dom", url: observation.url, selector: hit.selector, identity, excerpt: excerpt(hit.text) }],
54
+ remediation: "Do not send this content to this tier at all: render it on the server only for entitled users. Hiding it in the page is not a boundary.",
55
+ helpUri: help(expectedAbsentPresent.id),
56
+ });
57
+ }
58
+ for (const match of snapshot.selectorMatches) {
59
+ if (match.text === "(invalid selector)")
60
+ continue;
61
+ findings.push({
62
+ ruleId: expectedAbsentPresent.id,
63
+ layer: "surface",
64
+ severity: "high",
65
+ confidence: "high",
66
+ message: `${observation.page.path} renders "${match.selector}" for ${identity}, which the config says must match nothing`,
67
+ evidence: [{ kind: "dom", url: observation.url, selector: match.selector, identity, excerpt: excerpt(match.text) }],
68
+ remediation: "Do not render this element for this tier; if it needs a placeholder, render placeholder content, not the real value.",
69
+ helpUri: help(expectedAbsentPresent.id),
70
+ });
71
+ }
72
+ }
73
+ return findings;
74
+ },
75
+ };
76
+ export const hiddenHigherTierContent = {
77
+ id: "surface/hidden-higher-tier-content",
78
+ defaultSeverity: "high",
79
+ defaultConfidence: "high",
80
+ check(observation) {
81
+ const findings = [];
82
+ const topName = observation.tiers[observation.tiers.length - 1];
83
+ const top = topName === undefined ? undefined : observation.snapshots.get(topName);
84
+ if (!usable(top) || observation.tiers.length < 2)
85
+ return findings;
86
+ const topVisible = [...new Set(top.blocks.filter((block) => block.hiddenBy === null && block.text.length >= observation.minTextLength).map((block) => block.text))];
87
+ for (const identity of observation.tiers.slice(0, -1)) {
88
+ const snapshot = observation.snapshots.get(identity);
89
+ if (!usable(snapshot))
90
+ continue;
91
+ const visibleHere = snapshot.blocks.filter((block) => block.hiddenBy === null).map((block) => block.text);
92
+ let reported = 0;
93
+ for (const text of topVisible) {
94
+ // Visible to this tier too: shared content, not a leak.
95
+ if (visibleHere.some((visible) => visible.includes(text)))
96
+ continue;
97
+ const hit = locate(snapshot, text, { hiddenOnly: true });
98
+ if (hit === undefined)
99
+ continue;
100
+ reported += 1;
101
+ if (reported > 20)
102
+ break;
103
+ findings.push({
104
+ ruleId: hiddenHigherTierContent.id,
105
+ layer: "surface",
106
+ severity: "high",
107
+ confidence: "high",
108
+ message: `${observation.page.path}: "${excerpt(text, 40)}" is visible to ${topName} and present but hidden for ${identity} (${hit.where})`,
109
+ evidence: [{ kind: "dom", url: observation.url, selector: hit.selector, identity, excerpt: excerpt(text) }],
110
+ remediation: `Anyone on the ${identity} tier can read this in DevTools. Leave it out of the ${identity} page (server-render a placeholder) instead of hiding it.`,
111
+ helpUri: help(hiddenHigherTierContent.id),
112
+ });
113
+ }
114
+ }
115
+ return findings;
116
+ },
117
+ };
118
+ /**
119
+ * Text that is obviously a decoy: masks, lorem ipsum, a single repeated
120
+ * character, or a label next to a mask ("Burstiness score ●●●●"), which is
121
+ * the fix this rule recommends.
122
+ */
123
+ const PLACEHOLDER = /^(?:[\sxX*•●▒█#?·._,\-–—0]+|lorem ipsum.*|(.)\1+)$|[•●▒█*#]{3,}|\b[xX]{4,}\b/i;
124
+ export const obscuredContent = {
125
+ id: "surface/obscured-content",
126
+ defaultSeverity: "high",
127
+ defaultConfidence: "medium",
128
+ check(observation) {
129
+ const findings = [];
130
+ // With one identity there is nothing more privileged to protect; still
131
+ // check it, since blur on any audited page is the classic fake paywall.
132
+ const lower = observation.tiers.length > 1 ? observation.tiers.slice(0, -1) : observation.tiers;
133
+ for (const identity of lower) {
134
+ const snapshot = observation.snapshots.get(identity);
135
+ if (!usable(snapshot))
136
+ continue;
137
+ const obscured = snapshot.blocks.filter((block) => (block.hiddenBy === "blur" || block.hiddenBy === "transparent-text") && block.text.length >= 3 && !PLACEHOLDER.test(block.text));
138
+ if (obscured.length === 0)
139
+ continue;
140
+ findings.push({
141
+ ruleId: obscuredContent.id,
142
+ layer: "surface",
143
+ severity: "high",
144
+ confidence: "medium",
145
+ message: `${observation.page.path} shows ${identity} ${obscured.length} blurred or transparent text element(s); the text is readable in the DOM`,
146
+ evidence: obscured.slice(0, 10).map((block) => ({ kind: "dom", url: observation.url, selector: block.selector, identity, excerpt: excerpt(block.text) })),
147
+ remediation: `Render placeholder text (e.g. "●●●") behind the blur for ${identity}, or nothing; send the real values only to tiers that may see them.`,
148
+ helpUri: help(obscuredContent.id),
149
+ });
150
+ }
151
+ return findings;
152
+ },
153
+ };
154
+ export const SURFACE_RULES = [expectedAbsentPresent, hiddenHigherTierContent, obscuredContent];
@@ -0,0 +1,23 @@
1
+ import { type ScanSummary } from "../index.ts";
2
+ import { type SurfaceDriver } from "./driver.ts";
3
+ import type { SurfaceConfig } from "./types.ts";
4
+ export interface SurfaceResult extends ScanSummary {
5
+ layer: "surface";
6
+ target: string;
7
+ identities: string[];
8
+ pagesAudited: number;
9
+ /** Page loads that rendered and were evaluated by the rules. */
10
+ snapshotsEvaluated: number;
11
+ ignored: number;
12
+ warnings: string[];
13
+ }
14
+ export interface SurfaceOptions {
15
+ env?: NodeJS.ProcessEnv;
16
+ /** Injected in tests; defaults to a Playwright Chromium. */
17
+ driver?: SurfaceDriver;
18
+ }
19
+ /**
20
+ * Layer 3: open each page as each identity, least privileged first, one at a
21
+ * time, and run the surface rules over what each identity's browser held.
22
+ */
23
+ export declare function runSurface(config: SurfaceConfig, options?: SurfaceOptions): Promise<SurfaceResult>;
@@ -0,0 +1,80 @@
1
+ import { VERSION } from "../index.js";
2
+ import { sortFindings } from "../scan/artifact.js";
3
+ import { launchPlaywrightDriver } from "./driver.js";
4
+ import { SURFACE_RULES } from "./rules.js";
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ const DEFAULT_MIN_TEXT = 12;
7
+ /**
8
+ * Layer 3: open each page as each identity, least privileged first, one at a
9
+ * time, and run the surface rules over what each identity's browser held.
10
+ */
11
+ export async function runSurface(config, options = {}) {
12
+ const startedAt = new Date().toISOString();
13
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
14
+ const minTextLength = config.minTextLength ?? DEFAULT_MIN_TEXT;
15
+ const findings = [];
16
+ const warnings = [];
17
+ let snapshotsEvaluated = 0;
18
+ const driver = options.driver ?? (await launchPlaywrightDriver(options.env));
19
+ try {
20
+ for (const page of config.pages) {
21
+ const url = new URL(page.path, config.baseUrl).toString();
22
+ const observation = { page, url, tiers: config.tiers, snapshots: new Map(), minTextLength };
23
+ for (const identityName of config.tiers) {
24
+ const identity = config.identities[identityName];
25
+ if (identity === undefined) {
26
+ warnings.push(`${page.path}: identity "${identityName}" is not defined`);
27
+ continue;
28
+ }
29
+ const snapshot = await driver.capture({
30
+ baseUrl: config.baseUrl,
31
+ url,
32
+ identityName,
33
+ identity,
34
+ absentSelectors: page.expect?.[identityName]?.absentSelectors ?? [],
35
+ timeoutMs,
36
+ ...(page.waitFor === undefined ? {} : { waitFor: page.waitFor }),
37
+ ...(options.env === undefined ? {} : { env: options.env }),
38
+ });
39
+ if (snapshot.error === undefined && new URL(snapshot.finalUrl).origin !== new URL(config.baseUrl).origin) {
40
+ snapshot.error = `redirected off the audited origin to ${new URL(snapshot.finalUrl).origin}`;
41
+ }
42
+ observation.snapshots.set(identityName, snapshot);
43
+ for (const match of snapshot.selectorMatches) {
44
+ if (match.text === "(invalid selector)")
45
+ warnings.push(`${page.path}: absentSelectors entry "${match.selector}" is not a valid CSS selector`);
46
+ }
47
+ if (snapshot.error !== undefined) {
48
+ warnings.push(`${page.path} as ${identityName}: ${snapshot.error}, not evaluated`);
49
+ }
50
+ else if (snapshot.status >= 400) {
51
+ warnings.push(`${page.path} as ${identityName}: HTTP ${snapshot.status}, not evaluated`);
52
+ }
53
+ else {
54
+ snapshotsEvaluated += 1;
55
+ if (snapshot.finalUrl !== url)
56
+ warnings.push(`${page.path} as ${identityName}: landed on ${snapshot.finalUrl}`);
57
+ }
58
+ }
59
+ for (const rule of SURFACE_RULES)
60
+ findings.push(...rule.check(observation));
61
+ }
62
+ }
63
+ finally {
64
+ if (options.driver === undefined)
65
+ await driver.close();
66
+ }
67
+ return {
68
+ version: VERSION,
69
+ layer: "surface",
70
+ target: config.baseUrl,
71
+ identities: [...config.tiers],
72
+ pagesAudited: config.pages.length,
73
+ snapshotsEvaluated,
74
+ ignored: 0,
75
+ warnings,
76
+ startedAt,
77
+ finishedAt: new Date().toISOString(),
78
+ findings: sortFindings(findings),
79
+ };
80
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Layer 3: rendered-surface audit.
3
+ *
4
+ * The operator lists pages and identities. evershield opens each page in a
5
+ * headless browser as each identity, waits for it to settle, and records
6
+ * every text-bearing element with whether a person can actually see it,
7
+ * plus `data-*` attributes, inline scripts and web storage. The rules then
8
+ * look for data that is present but only visually hidden.
9
+ */
10
+ export interface SurfaceIdentityConfig {
11
+ /** Sent only to requests for `baseUrl`'s origin. Credential headers must reference env vars. */
12
+ headers?: Record<string, string>;
13
+ /** Cookie name → value, set for `baseUrl`. Values must reference env vars. */
14
+ cookies?: Record<string, string>;
15
+ /** Path to a Playwright storage-state file (cookies + localStorage), e.g. from `playwright codegen --save-storage`. May reference env vars. */
16
+ storageState?: string;
17
+ }
18
+ export interface PageExpectation {
19
+ /** Strings that must not appear anywhere for this identity: DOM text (hidden or not), attributes, inline scripts, storage. */
20
+ absentText?: string[];
21
+ /** CSS selectors that must match nothing for this identity. */
22
+ absentSelectors?: string[];
23
+ }
24
+ export interface PageConfig {
25
+ /** Path relative to `baseUrl`, starting with `/`. */
26
+ path: string;
27
+ /** CSS selector to wait for before capturing, for pages that render after data loads. */
28
+ waitFor?: string;
29
+ expect?: Record<string, PageExpectation>;
30
+ }
31
+ export interface SurfaceConfig {
32
+ /** Origin the browser is allowed to be credentialed for. Pages are relative to it. */
33
+ baseUrl: string;
34
+ /** Identity names ordered from least to most privileged. */
35
+ tiers: string[];
36
+ identities: Record<string, SurfaceIdentityConfig>;
37
+ pages: PageConfig[];
38
+ timeoutMs?: number;
39
+ /** Shortest text, in characters, the differential rule compares. Default 12. */
40
+ minTextLength?: number;
41
+ }
42
+ /** Why an element cannot be seen. `null` means visible. */
43
+ export type HiddenBy = "display" | "visibility" | "opacity" | "blur" | "transparent-text" | "clipped" | "zero-size";
44
+ export interface TextBlock {
45
+ selector: string;
46
+ /** Element's own text (direct text nodes), whitespace collapsed. */
47
+ text: string;
48
+ hiddenBy: HiddenBy | null;
49
+ }
50
+ export interface AttributeValue {
51
+ selector: string;
52
+ name: string;
53
+ value: string;
54
+ }
55
+ export interface ScriptText {
56
+ selector: string;
57
+ text: string;
58
+ }
59
+ export interface StorageEntry {
60
+ area: "localStorage" | "sessionStorage";
61
+ key: string;
62
+ value: string;
63
+ }
64
+ export interface SurfaceSnapshot {
65
+ identity: string;
66
+ /** URL requested. */
67
+ url: string;
68
+ /** URL after redirects. */
69
+ finalUrl: string;
70
+ status: number;
71
+ blocks: TextBlock[];
72
+ attributes: AttributeValue[];
73
+ scripts: ScriptText[];
74
+ storage: StorageEntry[];
75
+ /** Selectors from the page's `absentSelectors` expectations that matched, with a text sample. */
76
+ selectorMatches: Array<{
77
+ selector: string;
78
+ text: string;
79
+ }>;
80
+ /** Navigation or capture failure. */
81
+ error?: string;
82
+ }
83
+ export interface PageObservation {
84
+ page: PageConfig;
85
+ /** Absolute URL of the page. */
86
+ url: string;
87
+ tiers: string[];
88
+ snapshots: Map<string, SurfaceSnapshot>;
89
+ minTextLength: number;
90
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Layer 3: rendered-surface audit.
3
+ *
4
+ * The operator lists pages and identities. evershield opens each page in a
5
+ * headless browser as each identity, waits for it to settle, and records
6
+ * every text-bearing element with whether a person can actually see it,
7
+ * plus `data-*` attributes, inline scripts and web storage. The rules then
8
+ * look for data that is present but only visually hidden.
9
+ */
10
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "evershield",
3
- "version": "0.1.0",
4
- "description": "Shield everything, everywhere: find what your web app exposes to the browser before attackers do",
3
+ "version": "0.3.0",
4
+ "description": "Shield everything, everywhere: find what your web app exposes to the browser, in bundles, API responses and the rendered page",
5
5
  "license": "MIT",
6
6
  "author": "Lee Dongwook (@inviy)",
7
7
  "repository": {
@@ -24,7 +24,9 @@
24
24
  "static-analysis",
25
25
  "sarif",
26
26
  "ci",
27
- "devsecops"
27
+ "devsecops",
28
+ "playwright",
29
+ "dom"
28
30
  ],
29
31
  "type": "module",
30
32
  "bin": {
@@ -47,13 +49,28 @@
47
49
  "packageManager": "pnpm@10.28.0",
48
50
  "scripts": {
49
51
  "build": "tsc -p tsconfig.build.json",
50
- "typecheck": "tsc -p tsconfig.json --noEmit",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.scripts.json",
51
53
  "test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
52
54
  "check": "pnpm typecheck && pnpm test && pnpm build",
53
- "prepublishOnly": "pnpm check"
55
+ "prepublishOnly": "pnpm check",
56
+ "corpus": "node --experimental-strip-types scripts/corpus.ts"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/node": "^22.15.0",
60
+ "playwright": "1.56.1",
61
+ "playwright-core": "1.56.1",
57
62
  "typescript": "^5.8.0"
63
+ },
64
+ "peerDependencies": {
65
+ "playwright": ">=1.40.0",
66
+ "playwright-core": ">=1.40.0"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "playwright": {
70
+ "optional": true
71
+ },
72
+ "playwright-core": {
73
+ "optional": true
74
+ }
58
75
  }
59
76
  }