evershield 0.0.0-stage → 0.2.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 +215 -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 +213 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +352 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +8 -0
- package/dist/init.d.ts +21 -0
- package/dist/init.js +136 -0
- package/dist/report/index.d.ts +19 -0
- package/dist/report/index.js +82 -0
- package/dist/report/sarif.d.ts +2 -0
- package/dist/report/sarif.js +129 -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/dist/surface/collect.d.ts +13 -0
- package/dist/surface/collect.js +130 -0
- package/dist/surface/driver.d.ts +17 -0
- package/dist/surface/driver.js +114 -0
- package/dist/surface/rules.d.ts +32 -0
- package/dist/surface/rules.js +150 -0
- package/dist/surface/run.d.ts +23 -0
- package/dist/surface/run.js +80 -0
- package/dist/surface/types.d.ts +90 -0
- package/dist/surface/types.js +10 -0
- package/package.json +73 -4
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SARIF 2.1.0 output, the format GitHub code scanning and most security
|
|
3
|
+
* dashboards ingest. One run per scan; one rule entry per distinct ruleId;
|
|
4
|
+
* one result per finding with its first evidence as the primary location.
|
|
5
|
+
*
|
|
6
|
+
* File paths are relative to the scanned directory, not the repository
|
|
7
|
+
* root, because build output is usually not committed. Response evidence
|
|
8
|
+
* has no file, so it is reported as a logical location (endpoint, identity
|
|
9
|
+
* and JSON path); DOM evidence likewise (page, identity and selector).
|
|
10
|
+
*/
|
|
11
|
+
import { RESPONSE_RULES } from "../audit/rules.js";
|
|
12
|
+
import { VERSION } from "../index.js";
|
|
13
|
+
import { ARTIFACT_RULES } from "../rules/index.js";
|
|
14
|
+
import { SURFACE_RULES } from "../surface/rules.js";
|
|
15
|
+
import { helpUri } from "../rules/types.js";
|
|
16
|
+
import { isBlocking } from "./index.js";
|
|
17
|
+
function levelFor(severity) {
|
|
18
|
+
switch (severity) {
|
|
19
|
+
case "critical":
|
|
20
|
+
case "high":
|
|
21
|
+
return "error";
|
|
22
|
+
case "medium":
|
|
23
|
+
return "warning";
|
|
24
|
+
default:
|
|
25
|
+
return "note";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function locations(finding) {
|
|
29
|
+
const first = finding.evidence[0];
|
|
30
|
+
if (first === undefined)
|
|
31
|
+
return [];
|
|
32
|
+
if (first.kind === "file") {
|
|
33
|
+
const region = first.line === undefined ? {} : { region: { startLine: first.line, startColumn: first.column ?? 1 } };
|
|
34
|
+
return [{ physicalLocation: { artifactLocation: { uri: first.path }, ...region } }];
|
|
35
|
+
}
|
|
36
|
+
if (first.kind === "response") {
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
logicalLocations: [
|
|
40
|
+
{ name: first.jsonPath, fullyQualifiedName: `${first.endpoint} as ${first.identity} → ${first.jsonPath}`, kind: "member" },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
const as = first.identity === undefined ? "" : ` as ${first.identity}`;
|
|
46
|
+
return [{ logicalLocations: [{ name: first.selector, fullyQualifiedName: `${first.url}${as} → ${first.selector}`, kind: "element" }] }];
|
|
47
|
+
}
|
|
48
|
+
const KNOWN_RULES = new Map([
|
|
49
|
+
...ARTIFACT_RULES.map((rule) => [rule.id, rule]),
|
|
50
|
+
...RESPONSE_RULES.map((rule) => [rule.id, rule]),
|
|
51
|
+
...SURFACE_RULES.map((rule) => [rule.id, rule]),
|
|
52
|
+
]);
|
|
53
|
+
function invocationProperties(result, gate) {
|
|
54
|
+
if (result.layer === "artifact") {
|
|
55
|
+
return { layer: result.layer, adapter: result.adapter, filesScanned: result.filesScanned, ignored: result.ignored, gate };
|
|
56
|
+
}
|
|
57
|
+
if (result.layer === "surface") {
|
|
58
|
+
return {
|
|
59
|
+
layer: result.layer,
|
|
60
|
+
identities: result.identities,
|
|
61
|
+
pagesAudited: result.pagesAudited,
|
|
62
|
+
snapshotsEvaluated: result.snapshotsEvaluated,
|
|
63
|
+
warnings: result.warnings,
|
|
64
|
+
ignored: result.ignored,
|
|
65
|
+
gate,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
layer: result.layer,
|
|
70
|
+
identities: result.identities,
|
|
71
|
+
endpointsAudited: result.endpointsAudited,
|
|
72
|
+
responsesEvaluated: result.responsesEvaluated,
|
|
73
|
+
warnings: result.warnings,
|
|
74
|
+
ignored: result.ignored,
|
|
75
|
+
gate,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function renderSarif(result, gate) {
|
|
79
|
+
const ruleIds = [...new Set(result.findings.map((finding) => finding.ruleId))].sort();
|
|
80
|
+
const rules = ruleIds.map((id) => {
|
|
81
|
+
const meta = KNOWN_RULES.get(id);
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
name: id.split("/").pop() ?? id,
|
|
85
|
+
shortDescription: { text: id },
|
|
86
|
+
helpUri: helpUri(id),
|
|
87
|
+
defaultConfiguration: { level: meta === undefined ? "warning" : levelFor(meta.defaultSeverity) },
|
|
88
|
+
properties: meta === undefined ? {} : { defaultSeverity: meta.defaultSeverity, defaultConfidence: meta.defaultConfidence },
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const results = result.findings.map((finding) => ({
|
|
92
|
+
ruleId: finding.ruleId,
|
|
93
|
+
ruleIndex: ruleIds.indexOf(finding.ruleId),
|
|
94
|
+
level: levelFor(finding.severity),
|
|
95
|
+
message: { text: finding.remediation === undefined ? finding.message : `${finding.message}. ${finding.remediation}` },
|
|
96
|
+
locations: locations(finding),
|
|
97
|
+
properties: {
|
|
98
|
+
severity: finding.severity,
|
|
99
|
+
confidence: finding.confidence,
|
|
100
|
+
blocking: isBlocking(finding, gate),
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
const sarif = {
|
|
104
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
105
|
+
version: "2.1.0",
|
|
106
|
+
runs: [
|
|
107
|
+
{
|
|
108
|
+
tool: {
|
|
109
|
+
driver: {
|
|
110
|
+
name: "evershield",
|
|
111
|
+
version: VERSION,
|
|
112
|
+
informationUri: "https://github.com/inviy/evershield",
|
|
113
|
+
rules,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
invocations: [
|
|
117
|
+
{
|
|
118
|
+
executionSuccessful: true,
|
|
119
|
+
startTimeUtc: result.startedAt,
|
|
120
|
+
endTimeUtc: result.finishedAt,
|
|
121
|
+
properties: invocationProperties(result, gate),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
results,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
return `${JSON.stringify(sarif, null, 2)}\n`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { internalEndpoint } from "./internal-endpoint.js";
|
|
2
|
+
import { secretLikeString } from "./secret-like-string.js";
|
|
3
|
+
import { sensitiveEnvName } from "./sensitive-env-name.js";
|
|
4
|
+
import { sourceMapPublished } from "./source-map-published.js";
|
|
5
|
+
/** Layer 1 rules, in report order. */
|
|
6
|
+
export const ARTIFACT_RULES = [
|
|
7
|
+
secretLikeString,
|
|
8
|
+
sourceMapPublished,
|
|
9
|
+
sensitiveEnvName,
|
|
10
|
+
internalEndpoint,
|
|
11
|
+
];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { lineColumn } from "../scan/text.js";
|
|
2
|
+
import { helpUri, MAX_FINDINGS_PER_FILE } from "./types.js";
|
|
3
|
+
const PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
name: "Private IP address",
|
|
6
|
+
pattern: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/g,
|
|
7
|
+
severity: "medium",
|
|
8
|
+
confidence: "medium",
|
|
9
|
+
remediation: "Replace with a public hostname resolved server-side, or remove the reference.",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "Internal hostname",
|
|
13
|
+
pattern: /\bhttps?:\/\/[a-z0-9.-]+\.(?:internal|local|corp|lan|intranet)(?::\d+)?\b/gi,
|
|
14
|
+
severity: "medium",
|
|
15
|
+
confidence: "high",
|
|
16
|
+
remediation: "Internal names map your infrastructure for an attacker. Proxy the call through a public endpoint.",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "localhost URL",
|
|
20
|
+
pattern: /\bhttps?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\b/gi,
|
|
21
|
+
severity: "low",
|
|
22
|
+
confidence: "low",
|
|
23
|
+
remediation: "Usually a development fallback that leaked into production config. Confirm it is unreachable in production.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "Staging or admin hostname",
|
|
27
|
+
pattern: /\bhttps?:\/\/(?:[a-z0-9-]+\.)*(?:staging|stage|dev|admin|preprod|uat)\.[a-z0-9.-]+\b/gi,
|
|
28
|
+
severity: "low",
|
|
29
|
+
confidence: "low",
|
|
30
|
+
remediation: "Confirm the environment is meant to be reachable from the public bundle.",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
export const internalEndpoint = {
|
|
34
|
+
id: "artifact/internal-endpoint",
|
|
35
|
+
defaultSeverity: "medium",
|
|
36
|
+
defaultConfidence: "medium",
|
|
37
|
+
check(file) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const entry of PATTERNS) {
|
|
41
|
+
for (const match of file.text.matchAll(entry.pattern)) {
|
|
42
|
+
const value = match[0];
|
|
43
|
+
if (seen.has(value) || findings.length >= MAX_FINDINGS_PER_FILE)
|
|
44
|
+
continue;
|
|
45
|
+
seen.add(value);
|
|
46
|
+
const { line, column } = lineColumn(file.text, match.index);
|
|
47
|
+
findings.push({
|
|
48
|
+
ruleId: internalEndpoint.id,
|
|
49
|
+
layer: "artifact",
|
|
50
|
+
severity: entry.severity,
|
|
51
|
+
confidence: entry.confidence,
|
|
52
|
+
message: `${entry.name} in ${file.relativePath}: ${value}`,
|
|
53
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: value }],
|
|
54
|
+
remediation: entry.remediation,
|
|
55
|
+
helpUri: helpUri(internalEndpoint.id),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { decodeJwtPayload, lineColumn, looksLikePlaceholder, redact, shannonEntropy } from "../scan/text.js";
|
|
2
|
+
import { helpUri, MAX_FINDINGS_PER_FILE } from "./types.js";
|
|
3
|
+
const ROTATE = "Rotate the credential now; treat it as compromised. Move its use behind a server endpoint.";
|
|
4
|
+
const PROVIDERS = [
|
|
5
|
+
{ name: "AWS access key ID", pattern: /\bAKIA[0-9A-Z]{16}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
|
|
6
|
+
{ name: "GitHub token", pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
|
|
7
|
+
{ name: "Stripe secret key", pattern: /\b(?:sk|rk)_live_[0-9A-Za-z]{24,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
|
|
8
|
+
{ name: "Stripe test secret key", pattern: /\b(?:sk|rk)_test_[0-9A-Za-z]{24,}\b/g, severity: "medium", confidence: "high", remediation: "Test keys should not ship either; remove and keep them server-side." },
|
|
9
|
+
{ name: "Slack token", pattern: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
|
|
10
|
+
{ name: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
|
|
11
|
+
{ name: "OpenAI API key", pattern: /\bsk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}\b/g, severity: "critical", confidence: "high", remediation: ROTATE },
|
|
12
|
+
{ name: "Private key block", pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/g, severity: "critical", confidence: "high", remediation: "Remove the key from the build and rotate it." },
|
|
13
|
+
{
|
|
14
|
+
name: "Google API key",
|
|
15
|
+
pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
16
|
+
severity: "medium",
|
|
17
|
+
confidence: "medium",
|
|
18
|
+
remediation: "Browser keys are expected to ship; confirm this one is restricted to your origins and to the APIs it needs.",
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
22
|
+
const NAMED_VALUE = /\b((?:[A-Za-z_][A-Za-z0-9_]*?)?(?:secret|token|api[_-]?key|apikey|password|passwd|private[_-]?key|auth[_-]?key)[A-Za-z0-9_]*)["']?\s*[:=]\s*["']([A-Za-z0-9_\-./+=]{16,})["']/gi;
|
|
23
|
+
const MIN_NAMED_ENTROPY = 3.5;
|
|
24
|
+
function isPublicName(name, context) {
|
|
25
|
+
const upper = name.toUpperCase();
|
|
26
|
+
return context.adapter.publicEnvPrefixes.some((prefix) => upper.startsWith(prefix));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Entropy alone passes CSS custom properties (`--brand-color-hover`) and
|
|
30
|
+
* dotted paths (`spacing.large.default`): long, varied, and not secrets.
|
|
31
|
+
* Real credentials mix letters with digits or case and do not look like
|
|
32
|
+
* identifiers or paths.
|
|
33
|
+
*/
|
|
34
|
+
function looksLikeCredentialValue(value) {
|
|
35
|
+
if (value.startsWith("--") || value.startsWith("./") || value.startsWith("/"))
|
|
36
|
+
return false;
|
|
37
|
+
if (/^[a-z0-9]+(?:[.\-_/][a-z0-9]+)+$/i.test(value) && !/[0-9]/.test(value))
|
|
38
|
+
return false;
|
|
39
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) => re.test(value)).length;
|
|
40
|
+
return classes >= 2 && shannonEntropy(value) >= MIN_NAMED_ENTROPY;
|
|
41
|
+
}
|
|
42
|
+
export const secretLikeString = {
|
|
43
|
+
id: "artifact/secret-like-string",
|
|
44
|
+
defaultSeverity: "high",
|
|
45
|
+
defaultConfidence: "low",
|
|
46
|
+
check(file, context) {
|
|
47
|
+
const findings = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const push = (finding, key) => {
|
|
50
|
+
if (seen.has(key) || findings.length >= MAX_FINDINGS_PER_FILE)
|
|
51
|
+
return;
|
|
52
|
+
seen.add(key);
|
|
53
|
+
findings.push(finding);
|
|
54
|
+
};
|
|
55
|
+
for (const provider of PROVIDERS) {
|
|
56
|
+
for (const match of file.text.matchAll(provider.pattern)) {
|
|
57
|
+
const value = match[0];
|
|
58
|
+
const { line, column } = lineColumn(file.text, match.index);
|
|
59
|
+
push({
|
|
60
|
+
ruleId: secretLikeString.id,
|
|
61
|
+
layer: "artifact",
|
|
62
|
+
severity: provider.severity,
|
|
63
|
+
confidence: provider.confidence,
|
|
64
|
+
message: `${provider.name} shipped in ${file.relativePath}`,
|
|
65
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: redact(value) }],
|
|
66
|
+
remediation: provider.remediation,
|
|
67
|
+
helpUri: helpUri(secretLikeString.id),
|
|
68
|
+
}, `${provider.name}:${value}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const match of file.text.matchAll(JWT)) {
|
|
72
|
+
const token = match[0];
|
|
73
|
+
const payload = decodeJwtPayload(token);
|
|
74
|
+
if (payload === null)
|
|
75
|
+
continue;
|
|
76
|
+
const role = typeof payload.role === "string" ? payload.role : null;
|
|
77
|
+
const { line, column } = lineColumn(file.text, match.index);
|
|
78
|
+
if (role === "service_role") {
|
|
79
|
+
push({
|
|
80
|
+
ruleId: secretLikeString.id,
|
|
81
|
+
layer: "artifact",
|
|
82
|
+
severity: "critical",
|
|
83
|
+
confidence: "high",
|
|
84
|
+
message: `Supabase service_role JWT shipped in ${file.relativePath}; it bypasses row-level security`,
|
|
85
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: redact(token) }],
|
|
86
|
+
remediation: "Rotate the service role key in Supabase and use the anon key in the browser.",
|
|
87
|
+
helpUri: helpUri(secretLikeString.id),
|
|
88
|
+
}, `jwt:${token}`);
|
|
89
|
+
}
|
|
90
|
+
else if (role === null) {
|
|
91
|
+
// Not a Supabase anon/authenticated token; unknown JWTs in a bundle
|
|
92
|
+
// deserve a look but are often harmless fixtures.
|
|
93
|
+
push({
|
|
94
|
+
ruleId: secretLikeString.id,
|
|
95
|
+
layer: "artifact",
|
|
96
|
+
severity: "low",
|
|
97
|
+
confidence: "medium",
|
|
98
|
+
message: `JWT embedded in ${file.relativePath}`,
|
|
99
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: redact(token) }],
|
|
100
|
+
remediation: "Confirm the token is not a live credential. Tokens belong in responses, not bundles.",
|
|
101
|
+
helpUri: helpUri(secretLikeString.id),
|
|
102
|
+
}, `jwt:${token}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const match of file.text.matchAll(NAMED_VALUE)) {
|
|
106
|
+
const name = match[1];
|
|
107
|
+
const value = match[2];
|
|
108
|
+
if (name === undefined || value === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
if (isPublicName(name, context) || looksLikePlaceholder(value))
|
|
111
|
+
continue;
|
|
112
|
+
if (!looksLikeCredentialValue(value))
|
|
113
|
+
continue;
|
|
114
|
+
const { line, column } = lineColumn(file.text, match.index);
|
|
115
|
+
push({
|
|
116
|
+
ruleId: secretLikeString.id,
|
|
117
|
+
layer: "artifact",
|
|
118
|
+
severity: "high",
|
|
119
|
+
confidence: "low",
|
|
120
|
+
message: `"${name}" is assigned a high-entropy value in ${file.relativePath}`,
|
|
121
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: `${name} = ${redact(value)}` }],
|
|
122
|
+
remediation: "If this is a real credential, rotate it and move the call server-side. If it is public by design, rename it with your framework's public prefix.",
|
|
123
|
+
helpUri: helpUri(secretLikeString.id),
|
|
124
|
+
}, `named:${name}:${value}`);
|
|
125
|
+
}
|
|
126
|
+
return findings;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { lineColumn, redact } from "../scan/text.js";
|
|
2
|
+
import { helpUri, MAX_FINDINGS_PER_FILE } from "./types.js";
|
|
3
|
+
const KEYWORDS = [
|
|
4
|
+
"SERVICE_ROLE", "SECRET", "PRIVATE_KEY", "DATABASE_URL", "CONNECTION_STRING",
|
|
5
|
+
"SIGNING_KEY", "MASTER_KEY", "PASSWORD", "ACCESS_TOKEN", "REFRESH_TOKEN",
|
|
6
|
+
];
|
|
7
|
+
/** Keywords strong enough that even a bare mention deserves a look. */
|
|
8
|
+
const BARE_NAME_KEYWORDS = new Set([
|
|
9
|
+
"SERVICE_ROLE", "SECRET", "PRIVATE_KEY", "DATABASE_URL", "CONNECTION_STRING", "SIGNING_KEY", "MASTER_KEY",
|
|
10
|
+
]);
|
|
11
|
+
const SENSITIVE_NAME = new RegExp(`\\b((?:[A-Z][A-Z0-9]*_)*(${KEYWORDS.join("|")})(?:_[A-Z0-9]+)*)\\b(?:["']?\\s*[:=]\\s*["']([^"'\\s]{8,})["'])?`, "g");
|
|
12
|
+
function isPublicName(name, context) {
|
|
13
|
+
return context.adapter.publicEnvPrefixes.some((prefix) => name.startsWith(prefix));
|
|
14
|
+
}
|
|
15
|
+
/** Secrets are long and mix letters with digits or case; enum values and words are not. */
|
|
16
|
+
export function looksLikeSecretValue(value) {
|
|
17
|
+
if (value.length < 12)
|
|
18
|
+
return false;
|
|
19
|
+
if (/^[A-Z0-9_]+$/.test(value))
|
|
20
|
+
return false;
|
|
21
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) => re.test(value)).length;
|
|
22
|
+
return classes >= 2;
|
|
23
|
+
}
|
|
24
|
+
export const sensitiveEnvName = {
|
|
25
|
+
id: "artifact/sensitive-env-name",
|
|
26
|
+
defaultSeverity: "low",
|
|
27
|
+
defaultConfidence: "low",
|
|
28
|
+
check(file, context) {
|
|
29
|
+
const findings = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const match of file.text.matchAll(SENSITIVE_NAME)) {
|
|
32
|
+
const name = match[1];
|
|
33
|
+
const keyword = match[2];
|
|
34
|
+
const value = match[3];
|
|
35
|
+
if (name === undefined || keyword === undefined || isPublicName(name, context))
|
|
36
|
+
continue;
|
|
37
|
+
if (findings.length >= MAX_FINDINGS_PER_FILE)
|
|
38
|
+
break;
|
|
39
|
+
const { line, column } = lineColumn(file.text, match.index);
|
|
40
|
+
if (value !== undefined && looksLikeSecretValue(value)) {
|
|
41
|
+
const key = `${name}:${value}`;
|
|
42
|
+
if (seen.has(key))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(key);
|
|
45
|
+
findings.push({
|
|
46
|
+
ruleId: sensitiveEnvName.id,
|
|
47
|
+
layer: "artifact",
|
|
48
|
+
severity: "high",
|
|
49
|
+
confidence: "medium",
|
|
50
|
+
message: `${name} is assigned a literal value in ${file.relativePath}`,
|
|
51
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: `${name} = ${redact(value)}` }],
|
|
52
|
+
remediation: "Server-only configuration must not be bundled. Read it on the server and expose only what the client needs.",
|
|
53
|
+
helpUri: helpUri(sensitiveEnvName.id),
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!BARE_NAME_KEYWORDS.has(keyword))
|
|
58
|
+
continue;
|
|
59
|
+
const key = `${name}:`;
|
|
60
|
+
if (seen.has(key))
|
|
61
|
+
continue;
|
|
62
|
+
seen.add(key);
|
|
63
|
+
findings.push({
|
|
64
|
+
ruleId: sensitiveEnvName.id,
|
|
65
|
+
layer: "artifact",
|
|
66
|
+
severity: "low",
|
|
67
|
+
confidence: "low",
|
|
68
|
+
message: `Server-only variable name ${name} appears in ${file.relativePath}`,
|
|
69
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: name }],
|
|
70
|
+
remediation: "Check why client code references this name. If it is read at runtime, the value may be inlined elsewhere.",
|
|
71
|
+
helpUri: helpUri(sensitiveEnvName.id),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { lineColumn } from "../scan/text.js";
|
|
2
|
+
import { helpUri } from "./types.js";
|
|
3
|
+
const INLINE_MAP = /\/\/[#@]\s*sourceMappingURL=data:application\/json[^\n]*/g;
|
|
4
|
+
export const sourceMapPublished = {
|
|
5
|
+
id: "artifact/source-map-published",
|
|
6
|
+
defaultSeverity: "high",
|
|
7
|
+
defaultConfidence: "high",
|
|
8
|
+
check(file) {
|
|
9
|
+
const findings = [];
|
|
10
|
+
if (file.relativePath.endsWith(".map")) {
|
|
11
|
+
findings.push({
|
|
12
|
+
ruleId: sourceMapPublished.id,
|
|
13
|
+
layer: "artifact",
|
|
14
|
+
severity: "high",
|
|
15
|
+
confidence: "high",
|
|
16
|
+
message: `Source map shipped: ${file.relativePath}`,
|
|
17
|
+
evidence: [{ kind: "file", path: file.relativePath }],
|
|
18
|
+
remediation: "Disable production browser source maps, or upload them to your error tracker and delete them from the deploy.",
|
|
19
|
+
helpUri: helpUri(sourceMapPublished.id),
|
|
20
|
+
});
|
|
21
|
+
return findings;
|
|
22
|
+
}
|
|
23
|
+
for (const match of file.text.matchAll(INLINE_MAP)) {
|
|
24
|
+
const { line, column } = lineColumn(file.text, match.index);
|
|
25
|
+
findings.push({
|
|
26
|
+
ruleId: sourceMapPublished.id,
|
|
27
|
+
layer: "artifact",
|
|
28
|
+
severity: "high",
|
|
29
|
+
confidence: "high",
|
|
30
|
+
message: `Inline source map embedded in ${file.relativePath}`,
|
|
31
|
+
// Only the scheme prefix travels; the base64 payload is the whole source.
|
|
32
|
+
evidence: [{ kind: "file", path: file.relativePath, line, column, excerpt: `${match[0].slice(0, 44)}…` }],
|
|
33
|
+
remediation: "Build with source maps off, or emit them as separate files that are not deployed.",
|
|
34
|
+
helpUri: helpUri(sourceMapPublished.id),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return findings;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Confidence, Finding, Severity } from "../index.ts";
|
|
2
|
+
import type { Adapter } from "../scan/adapters.ts";
|
|
3
|
+
import type { ScannedFile } from "../scan/files.ts";
|
|
4
|
+
export interface RuleContext {
|
|
5
|
+
adapter: Adapter;
|
|
6
|
+
/** Every shipped file's relative path, for cross-file checks such as source map references. */
|
|
7
|
+
shippedPaths: ReadonlySet<string>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A rule inspects one file and returns findings with evidence. The doc
|
|
11
|
+
* comment on each rule module must state why its default severity and
|
|
12
|
+
* confidence are what they are (see CONTRIBUTING.md).
|
|
13
|
+
*/
|
|
14
|
+
export interface Rule {
|
|
15
|
+
id: string;
|
|
16
|
+
defaultSeverity: Severity;
|
|
17
|
+
defaultConfidence: Confidence;
|
|
18
|
+
check(file: ScannedFile, context: RuleContext): Finding[];
|
|
19
|
+
}
|
|
20
|
+
export declare const RULES_DOC = "https://github.com/inviy/evershield/blob/main/docs/rules.md";
|
|
21
|
+
/** Rules cap findings per file so one generated bundle cannot flood a report. */
|
|
22
|
+
export declare const MAX_FINDINGS_PER_FILE = 25;
|
|
23
|
+
export declare function helpUri(ruleId: string): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const RULES_DOC = "https://github.com/inviy/evershield/blob/main/docs/rules.md";
|
|
2
|
+
/** Rules cap findings per file so one generated bundle cannot flood a report. */
|
|
3
|
+
export const MAX_FINDINGS_PER_FILE = 25;
|
|
4
|
+
export function helpUri(ruleId) {
|
|
5
|
+
return `${RULES_DOC}#${ruleId.replace("/", "")}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An adapter knows which files in a build directory the browser actually
|
|
3
|
+
* receives, and which environment variable prefixes the framework treats as
|
|
4
|
+
* public by design. Every rule must still work with the plain adapter.
|
|
5
|
+
*/
|
|
6
|
+
export interface Adapter {
|
|
7
|
+
name: "nextjs" | "plain";
|
|
8
|
+
/** Whether a root-relative POSIX path is shipped to the browser. */
|
|
9
|
+
includes(relativePath: string): boolean;
|
|
10
|
+
/** Env var prefixes that are public by framework contract (never a finding by name). */
|
|
11
|
+
publicEnvPrefixes: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/** Any directory of built files: everything text-like is assumed shipped. */
|
|
14
|
+
export declare const plainAdapter: Adapter;
|
|
15
|
+
/**
|
|
16
|
+
* Next.js `.next/` output. Only `static/` reaches the browser as-is;
|
|
17
|
+
* `server/` holds server bundles that never leave the host, except the
|
|
18
|
+
* prerendered documents (`.html`, `.rsc`, `.body`) that are served verbatim.
|
|
19
|
+
* `cache/`, traces and manifests are build-time only.
|
|
20
|
+
*/
|
|
21
|
+
export declare const nextjsAdapter: Adapter;
|
|
22
|
+
/** Pick the adapter from the directory's shape. */
|
|
23
|
+
export declare function detectAdapter(root: string): Promise<Adapter>;
|
|
24
|
+
export declare const ADAPTERS: Record<Adapter["name"], Adapter>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const COMMON_PUBLIC_PREFIXES = [
|
|
4
|
+
"NEXT_PUBLIC_",
|
|
5
|
+
"VITE_",
|
|
6
|
+
"PUBLIC_",
|
|
7
|
+
"REACT_APP_",
|
|
8
|
+
"NUXT_PUBLIC_",
|
|
9
|
+
"EXPO_PUBLIC_",
|
|
10
|
+
"GATSBY_",
|
|
11
|
+
"VUE_APP_",
|
|
12
|
+
];
|
|
13
|
+
const TEXT_EXTENSIONS = new Set([
|
|
14
|
+
".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx",
|
|
15
|
+
".map", ".html", ".htm", ".css", ".json", ".txt", ".xml", ".svg",
|
|
16
|
+
".rsc", ".body", ".webmanifest",
|
|
17
|
+
]);
|
|
18
|
+
function hasTextExtension(relativePath) {
|
|
19
|
+
return TEXT_EXTENSIONS.has(path.posix.extname(relativePath).toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
/** Any directory of built files: everything text-like is assumed shipped. */
|
|
22
|
+
export const plainAdapter = {
|
|
23
|
+
name: "plain",
|
|
24
|
+
includes: hasTextExtension,
|
|
25
|
+
publicEnvPrefixes: COMMON_PUBLIC_PREFIXES,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Next.js `.next/` output. Only `static/` reaches the browser as-is;
|
|
29
|
+
* `server/` holds server bundles that never leave the host, except the
|
|
30
|
+
* prerendered documents (`.html`, `.rsc`, `.body`) that are served verbatim.
|
|
31
|
+
* `cache/`, traces and manifests are build-time only.
|
|
32
|
+
*/
|
|
33
|
+
export const nextjsAdapter = {
|
|
34
|
+
name: "nextjs",
|
|
35
|
+
includes(relativePath) {
|
|
36
|
+
if (!hasTextExtension(relativePath))
|
|
37
|
+
return false;
|
|
38
|
+
if (relativePath.startsWith("static/"))
|
|
39
|
+
return true;
|
|
40
|
+
if (relativePath.startsWith("server/")) {
|
|
41
|
+
const ext = path.posix.extname(relativePath).toLowerCase();
|
|
42
|
+
return ext === ".html" || ext === ".rsc" || ext === ".body";
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
},
|
|
46
|
+
publicEnvPrefixes: COMMON_PUBLIC_PREFIXES,
|
|
47
|
+
};
|
|
48
|
+
async function exists(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
await access(filePath);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Pick the adapter from the directory's shape. */
|
|
58
|
+
export async function detectAdapter(root) {
|
|
59
|
+
const isNext = (await exists(path.join(root, "BUILD_ID"))) && (await exists(path.join(root, "static")));
|
|
60
|
+
return isNext ? nextjsAdapter : plainAdapter;
|
|
61
|
+
}
|
|
62
|
+
export const ADAPTERS = {
|
|
63
|
+
nextjs: nextjsAdapter,
|
|
64
|
+
plain: plainAdapter,
|
|
65
|
+
};
|
|
@@ -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>;
|