evershield 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +163 -2
- package/dist/audit/http.d.ts +25 -0
- package/dist/audit/http.js +98 -0
- package/dist/audit/json-paths.d.ts +20 -0
- package/dist/audit/json-paths.js +60 -0
- package/dist/audit/rules.d.ts +31 -0
- package/dist/audit/rules.js +158 -0
- package/dist/audit/run.d.ts +25 -0
- package/dist/audit/run.js +62 -0
- package/dist/audit/types.d.ts +57 -0
- package/dist/audit/types.js +10 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +161 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +249 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +8 -0
- package/dist/report/index.d.ts +18 -0
- package/dist/report/index.js +72 -0
- package/dist/report/sarif.d.ts +2 -0
- package/dist/report/sarif.js +115 -0
- package/dist/rules/index.d.ts +4 -0
- package/dist/rules/index.js +11 -0
- package/dist/rules/internal-endpoint.d.ts +2 -0
- package/dist/rules/internal-endpoint.js +61 -0
- package/dist/rules/secret-like-string.d.ts +2 -0
- package/dist/rules/secret-like-string.js +128 -0
- package/dist/rules/sensitive-env-name.d.ts +4 -0
- package/dist/rules/sensitive-env-name.js +76 -0
- package/dist/rules/source-map-published.d.ts +2 -0
- package/dist/rules/source-map-published.js +39 -0
- package/dist/rules/types.d.ts +23 -0
- package/dist/rules/types.js +6 -0
- package/dist/scan/adapters.d.ts +24 -0
- package/dist/scan/adapters.js +65 -0
- package/dist/scan/artifact.d.ts +18 -0
- package/dist/scan/artifact.js +68 -0
- package/dist/scan/files.d.ts +16 -0
- package/dist/scan/files.js +41 -0
- package/dist/scan/text.d.ts +18 -0
- package/dist/scan/text.js +58 -0
- package/package.json +57 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Finding, type ScanSummary } from "../index.ts";
|
|
2
|
+
import { type Adapter } from "./adapters.ts";
|
|
3
|
+
export interface ArtifactScanOptions {
|
|
4
|
+
/** Force an adapter instead of detecting one from the directory. */
|
|
5
|
+
adapter?: Adapter["name"];
|
|
6
|
+
}
|
|
7
|
+
export interface ArtifactScanResult extends ScanSummary {
|
|
8
|
+
layer: "artifact";
|
|
9
|
+
target: string;
|
|
10
|
+
adapter: Adapter["name"];
|
|
11
|
+
filesScanned: number;
|
|
12
|
+
/** Findings removed by config ignore rules; they are not in `findings`. */
|
|
13
|
+
ignored: number;
|
|
14
|
+
}
|
|
15
|
+
/** Worst first, then surest first, then by location so output is stable. */
|
|
16
|
+
export declare function sortFindings(findings: Finding[]): Finding[];
|
|
17
|
+
/** Layer 1: scan a directory of built files for what should not ship. */
|
|
18
|
+
export declare function scanArtifact(target: string, options?: ArtifactScanOptions): Promise<ArtifactScanResult>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { VERSION } from "../index.js";
|
|
4
|
+
import { ARTIFACT_RULES } from "../rules/index.js";
|
|
5
|
+
import { ADAPTERS, detectAdapter } from "./adapters.js";
|
|
6
|
+
import { collectFiles } from "./files.js";
|
|
7
|
+
const SEVERITY_RANK = {
|
|
8
|
+
critical: 0,
|
|
9
|
+
high: 1,
|
|
10
|
+
medium: 2,
|
|
11
|
+
low: 3,
|
|
12
|
+
info: 4,
|
|
13
|
+
};
|
|
14
|
+
const CONFIDENCE_RANK = {
|
|
15
|
+
high: 0,
|
|
16
|
+
medium: 1,
|
|
17
|
+
low: 2,
|
|
18
|
+
};
|
|
19
|
+
/** Worst first, then surest first, then by location so output is stable. */
|
|
20
|
+
export function sortFindings(findings) {
|
|
21
|
+
return [...findings].sort((a, b) => {
|
|
22
|
+
const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
23
|
+
if (bySeverity !== 0)
|
|
24
|
+
return bySeverity;
|
|
25
|
+
const byConfidence = CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence];
|
|
26
|
+
if (byConfidence !== 0)
|
|
27
|
+
return byConfidence;
|
|
28
|
+
const aPath = a.evidence[0]?.kind === "file" ? a.evidence[0].path : "";
|
|
29
|
+
const bPath = b.evidence[0]?.kind === "file" ? b.evidence[0].path : "";
|
|
30
|
+
if (aPath !== bPath)
|
|
31
|
+
return aPath < bPath ? -1 : 1;
|
|
32
|
+
const aLine = a.evidence[0]?.kind === "file" ? (a.evidence[0].line ?? 0) : 0;
|
|
33
|
+
const bLine = b.evidence[0]?.kind === "file" ? (b.evidence[0].line ?? 0) : 0;
|
|
34
|
+
return aLine - bLine;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** Layer 1: scan a directory of built files for what should not ship. */
|
|
38
|
+
export async function scanArtifact(target, options = {}) {
|
|
39
|
+
const startedAt = new Date().toISOString();
|
|
40
|
+
const root = path.resolve(target);
|
|
41
|
+
const info = await stat(root).catch(() => null);
|
|
42
|
+
if (info === null || !info.isDirectory()) {
|
|
43
|
+
throw new Error(`not a directory: ${target}`);
|
|
44
|
+
}
|
|
45
|
+
const adapter = options.adapter ? ADAPTERS[options.adapter] : await detectAdapter(root);
|
|
46
|
+
const files = await collectFiles(root, adapter);
|
|
47
|
+
const context = {
|
|
48
|
+
adapter,
|
|
49
|
+
shippedPaths: new Set(files.map((file) => file.relativePath)),
|
|
50
|
+
};
|
|
51
|
+
const findings = [];
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
for (const rule of ARTIFACT_RULES) {
|
|
54
|
+
findings.push(...rule.check(file, context));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
version: VERSION,
|
|
59
|
+
layer: "artifact",
|
|
60
|
+
target: root,
|
|
61
|
+
adapter: adapter.name,
|
|
62
|
+
filesScanned: files.length,
|
|
63
|
+
ignored: 0,
|
|
64
|
+
startedAt,
|
|
65
|
+
finishedAt: new Date().toISOString(),
|
|
66
|
+
findings: sortFindings(findings),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Adapter } from "./adapters.ts";
|
|
2
|
+
/** One text file inside the artifact, as rules see it. */
|
|
3
|
+
export interface ScannedFile {
|
|
4
|
+
/** Absolute path on disk. */
|
|
5
|
+
absolutePath: string;
|
|
6
|
+
/** Path relative to the scan root, with forward slashes. Used in reports. */
|
|
7
|
+
relativePath: string;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CollectOptions {
|
|
11
|
+
/** Files larger than this are skipped; bundles rarely exceed it and maps can be huge. */
|
|
12
|
+
maxBytes?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function toPosix(relative: string): string;
|
|
15
|
+
/** Walk the root, keep what the adapter says the browser receives, read as UTF-8. */
|
|
16
|
+
export declare function collectFiles(root: string, adapter: Adapter, options?: CollectOptions): Promise<ScannedFile[]>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
4
|
+
const ALWAYS_SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
5
|
+
/** Cheap binary check: a NUL byte in the first 8 KiB means not text. */
|
|
6
|
+
function isProbablyBinary(buffer) {
|
|
7
|
+
const end = Math.min(buffer.length, 8192);
|
|
8
|
+
for (let i = 0; i < end; i += 1) {
|
|
9
|
+
if (buffer[i] === 0)
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
export function toPosix(relative) {
|
|
15
|
+
return relative.split(path.sep).join("/");
|
|
16
|
+
}
|
|
17
|
+
/** Walk the root, keep what the adapter says the browser receives, read as UTF-8. */
|
|
18
|
+
export async function collectFiles(root, adapter, options = {}) {
|
|
19
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
20
|
+
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
21
|
+
const files = [];
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry.isFile())
|
|
24
|
+
continue;
|
|
25
|
+
const absolutePath = path.join(entry.parentPath, entry.name);
|
|
26
|
+
const relativePath = toPosix(path.relative(root, absolutePath));
|
|
27
|
+
if (relativePath.split("/").some((segment) => ALWAYS_SKIP_DIRS.has(segment)))
|
|
28
|
+
continue;
|
|
29
|
+
if (!adapter.includes(relativePath))
|
|
30
|
+
continue;
|
|
31
|
+
const info = await stat(absolutePath);
|
|
32
|
+
if (info.size === 0 || info.size > maxBytes)
|
|
33
|
+
continue;
|
|
34
|
+
const buffer = await readFile(absolutePath);
|
|
35
|
+
if (isProbablyBinary(buffer))
|
|
36
|
+
continue;
|
|
37
|
+
files.push({ absolutePath, relativePath, text: buffer.toString("utf8") });
|
|
38
|
+
}
|
|
39
|
+
files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Text helpers shared by rules: positions, redaction and entropy. */
|
|
2
|
+
export interface LineColumn {
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
}
|
|
6
|
+
/** 1-based line and column for a character offset. */
|
|
7
|
+
export declare function lineColumn(text: string, offset: number): LineColumn;
|
|
8
|
+
/**
|
|
9
|
+
* Redact a matched value so reports never reproduce the secret they found.
|
|
10
|
+
* Keeps enough of each end to recognise the value in the source.
|
|
11
|
+
*/
|
|
12
|
+
export declare function redact(value: string, keep?: number): string;
|
|
13
|
+
/** Shannon entropy in bits per character. ~4.5 is random base64, ~3 is prose. */
|
|
14
|
+
export declare function shannonEntropy(value: string): number;
|
|
15
|
+
/** Values humans type as stand-ins; never worth a finding on their own. */
|
|
16
|
+
export declare function looksLikePlaceholder(value: string): boolean;
|
|
17
|
+
/** Decode a JWT payload without verifying it. Returns null when it is not a JWT. */
|
|
18
|
+
export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Text helpers shared by rules: positions, redaction and entropy. */
|
|
2
|
+
/** 1-based line and column for a character offset. */
|
|
3
|
+
export function lineColumn(text, offset) {
|
|
4
|
+
let line = 1;
|
|
5
|
+
let lastBreak = -1;
|
|
6
|
+
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
7
|
+
if (text.charCodeAt(i) === 10) {
|
|
8
|
+
line += 1;
|
|
9
|
+
lastBreak = i;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return { line, column: offset - lastBreak };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Redact a matched value so reports never reproduce the secret they found.
|
|
16
|
+
* Keeps enough of each end to recognise the value in the source.
|
|
17
|
+
*/
|
|
18
|
+
export function redact(value, keep = 4) {
|
|
19
|
+
if (value.length <= keep * 2 + 1)
|
|
20
|
+
return "*".repeat(value.length);
|
|
21
|
+
return `${value.slice(0, keep)}…${value.slice(-keep)} (${value.length} chars)`;
|
|
22
|
+
}
|
|
23
|
+
/** Shannon entropy in bits per character. ~4.5 is random base64, ~3 is prose. */
|
|
24
|
+
export function shannonEntropy(value) {
|
|
25
|
+
if (value.length === 0)
|
|
26
|
+
return 0;
|
|
27
|
+
const counts = new Map();
|
|
28
|
+
for (const ch of value)
|
|
29
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
30
|
+
let entropy = 0;
|
|
31
|
+
for (const count of counts.values()) {
|
|
32
|
+
const p = count / value.length;
|
|
33
|
+
entropy -= p * Math.log2(p);
|
|
34
|
+
}
|
|
35
|
+
return entropy;
|
|
36
|
+
}
|
|
37
|
+
const PLACEHOLDER = /^(x+|0+|\*+|your[_-]|changeme|placeholder|example|dummy|test|sample|<)/i;
|
|
38
|
+
/** Values humans type as stand-ins; never worth a finding on their own. */
|
|
39
|
+
export function looksLikePlaceholder(value) {
|
|
40
|
+
return PLACEHOLDER.test(value) || /^(.)\1+$/.test(value);
|
|
41
|
+
}
|
|
42
|
+
/** Decode a JWT payload without verifying it. Returns null when it is not a JWT. */
|
|
43
|
+
export function decodeJwtPayload(token) {
|
|
44
|
+
const parts = token.split(".");
|
|
45
|
+
if (parts.length !== 3)
|
|
46
|
+
return null;
|
|
47
|
+
const payload = parts[1];
|
|
48
|
+
if (payload === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
const json = Buffer.from(payload, "base64url").toString("utf8");
|
|
52
|
+
const parsed = JSON.parse(json);
|
|
53
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evershield",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shield everything, everywhere: find what your web app exposes to the browser before attackers do",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Lee Dongwook (@inviy)",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/inviy/evershield.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/inviy/evershield#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/inviy/evershield/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"security",
|
|
17
|
+
"exposure",
|
|
18
|
+
"client-side",
|
|
19
|
+
"bundle",
|
|
20
|
+
"sourcemap",
|
|
21
|
+
"secrets",
|
|
22
|
+
"authorization",
|
|
23
|
+
"paywall",
|
|
24
|
+
"static-analysis",
|
|
25
|
+
"sarif",
|
|
26
|
+
"ci",
|
|
27
|
+
"devsecops"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bin": {
|
|
31
|
+
"evershield": "dist/bin.js"
|
|
32
|
+
},
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22.12"
|
|
46
|
+
},
|
|
47
|
+
"packageManager": "pnpm@10.28.0",
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
51
|
+
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
|
|
52
|
+
"check": "pnpm typecheck && pnpm test && pnpm build",
|
|
53
|
+
"prepublishOnly": "pnpm check"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.15.0",
|
|
57
|
+
"typescript": "^5.8.0"
|
|
58
|
+
}
|
|
59
|
+
}
|