securl 1.4.1
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/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +427 -0
- package/RELEASING.md +37 -0
- package/SECURITY.md +27 -0
- package/dist/certificate.d.ts +5 -0
- package/dist/certificate.js +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +674 -0
- package/dist/compromiseSignals.d.ts +10 -0
- package/dist/compromiseSignals.js +183 -0
- package/dist/cookie-analysis.d.ts +2 -0
- package/dist/cookie-analysis.js +41 -0
- package/dist/cookieAnalysis.d.ts +2 -0
- package/dist/cookieAnalysis.js +82 -0
- package/dist/ctDiscovery.d.ts +19 -0
- package/dist/ctDiscovery.js +357 -0
- package/dist/domain-security.d.ts +10 -0
- package/dist/domain-security.js +416 -0
- package/dist/header-analysis.d.ts +14 -0
- package/dist/header-analysis.js +165 -0
- package/dist/historyDiff.d.ts +4 -0
- package/dist/historyDiff.js +117 -0
- package/dist/html-extraction.d.ts +12 -0
- package/dist/html-extraction.js +279 -0
- package/dist/html-page-analysis.d.ts +38 -0
- package/dist/html-page-analysis.js +459 -0
- package/dist/htmlInsights.d.ts +23 -0
- package/dist/htmlInsights.js +460 -0
- package/dist/identityProvider.d.ts +14 -0
- package/dist/identityProvider.js +259 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1008 -0
- package/dist/infrastructure.d.ts +9 -0
- package/dist/infrastructure.js +149 -0
- package/dist/libraryRisk.d.ts +3 -0
- package/dist/libraryRisk.js +164 -0
- package/dist/network-validation.d.ts +30 -0
- package/dist/network-validation.js +161 -0
- package/dist/network.d.ts +34 -0
- package/dist/network.js +139 -0
- package/dist/passive-intelligence.d.ts +21 -0
- package/dist/passive-intelligence.js +247 -0
- package/dist/path-discovery.d.ts +4 -0
- package/dist/path-discovery.js +50 -0
- package/dist/postureDigest.d.ts +142 -0
- package/dist/postureDigest.js +159 -0
- package/dist/postureDrift.d.ts +4 -0
- package/dist/postureDrift.js +118 -0
- package/dist/postureRemediation.d.ts +6 -0
- package/dist/postureRemediation.js +286 -0
- package/dist/redirectChain.d.ts +2 -0
- package/dist/redirectChain.js +39 -0
- package/dist/riskEvents.d.ts +3 -0
- package/dist/riskEvents.js +187 -0
- package/dist/scannerConfig.d.ts +49 -0
- package/dist/scannerConfig.js +79 -0
- package/dist/scoring.d.ts +32 -0
- package/dist/scoring.js +367 -0
- package/dist/security-txt.d.ts +4 -0
- package/dist/security-txt.js +123 -0
- package/dist/surfaceEnrichment.d.ts +44 -0
- package/dist/surfaceEnrichment.js +377 -0
- package/dist/technology-detection.d.ts +4 -0
- package/dist/technology-detection.js +93 -0
- package/dist/types.d.ts +730 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +66 -0
- package/dist/wafFingerprint.d.ts +5 -0
- package/dist/wafFingerprint.js +156 -0
- package/examples/risk-events.mjs +27 -0
- package/examples/scan-url.mjs +17 -0
- package/package.json +102 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import { DNS_LOOKUP_TIMEOUT_MS } from "./scannerConfig.js";
|
|
3
|
+
import { safeResolveWithTimeout } from "./utils.js";
|
|
4
|
+
const DKIM_COMMON_SELECTORS = [
|
|
5
|
+
"google",
|
|
6
|
+
"mail",
|
|
7
|
+
"dkim",
|
|
8
|
+
"selector1",
|
|
9
|
+
"selector2",
|
|
10
|
+
"k1",
|
|
11
|
+
"k2",
|
|
12
|
+
"default",
|
|
13
|
+
"smtp",
|
|
14
|
+
"mailgun",
|
|
15
|
+
"sendgrid",
|
|
16
|
+
"proofpoint",
|
|
17
|
+
"mimecast",
|
|
18
|
+
"amazonses",
|
|
19
|
+
"mandrill",
|
|
20
|
+
];
|
|
21
|
+
async function fetchMtaStsPolicy(host, requestText) {
|
|
22
|
+
const policyHost = `mta-sts.${host}`;
|
|
23
|
+
const policyUrl = new URL(`https://${policyHost}/.well-known/mta-sts.txt`);
|
|
24
|
+
try {
|
|
25
|
+
const response = await requestText(policyUrl);
|
|
26
|
+
if (response.statusCode >= 200 && response.statusCode < 300 && response.body.trim()) {
|
|
27
|
+
return { policyUrl: policyUrl.toString(), policy: response.body.trim() };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Ignore fetch failure and return a null policy below.
|
|
32
|
+
}
|
|
33
|
+
return { policyUrl: policyUrl.toString(), policy: null };
|
|
34
|
+
}
|
|
35
|
+
const parseDnsTags = (record) => {
|
|
36
|
+
const tags = new Map();
|
|
37
|
+
for (const part of record.split(";")) {
|
|
38
|
+
const [key, ...valueParts] = part.trim().split("=");
|
|
39
|
+
if (key && valueParts.length) {
|
|
40
|
+
tags.set(key.toLowerCase(), valueParts.join("=").trim());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return tags;
|
|
44
|
+
};
|
|
45
|
+
export const evaluateSpfPolicy = (spf) => {
|
|
46
|
+
if (!spf) {
|
|
47
|
+
return {
|
|
48
|
+
status: "missing",
|
|
49
|
+
allMechanism: null,
|
|
50
|
+
dnsLookupMechanisms: 0,
|
|
51
|
+
summary: "No SPF record was detected at the zone apex.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const mechanisms = spf.split(/\s+/).filter(Boolean);
|
|
55
|
+
const allMechanism = mechanisms.find((mechanism) => /^[~?+-]?all$/i.test(mechanism))?.toLowerCase() || null;
|
|
56
|
+
const normalizedAll = allMechanism === "all" ? "+all" : allMechanism;
|
|
57
|
+
const dnsLookupMechanisms = mechanisms.filter((mechanism) => /^(?:[~?+-])?(?:include|a|mx|ptr|exists)(?::|$)/i.test(mechanism)).length;
|
|
58
|
+
if (normalizedAll === "-all") {
|
|
59
|
+
return {
|
|
60
|
+
status: dnsLookupMechanisms > 10 ? "watch" : "strong",
|
|
61
|
+
allMechanism: "-all",
|
|
62
|
+
dnsLookupMechanisms,
|
|
63
|
+
summary: dnsLookupMechanisms > 10
|
|
64
|
+
? "SPF uses hardfail, but appears to exceed the 10 DNS-lookup guidance limit."
|
|
65
|
+
: "SPF uses hardfail, which is the strongest published all-mechanism.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (normalizedAll === "~all") {
|
|
69
|
+
return {
|
|
70
|
+
status: "watch",
|
|
71
|
+
allMechanism: "~all",
|
|
72
|
+
dnsLookupMechanisms,
|
|
73
|
+
summary: "SPF uses softfail; this is safer than no policy but weaker than hardfail.",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (normalizedAll === "?all" || normalizedAll === "+all") {
|
|
77
|
+
return {
|
|
78
|
+
status: "weak",
|
|
79
|
+
allMechanism: normalizedAll,
|
|
80
|
+
dnsLookupMechanisms,
|
|
81
|
+
summary: "SPF ends with a neutral or permissive all-mechanism, so spoofing resistance is weak.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
status: "watch",
|
|
86
|
+
allMechanism: null,
|
|
87
|
+
dnsLookupMechanisms,
|
|
88
|
+
summary: "SPF is present, but no explicit all-mechanism was found.",
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
export const evaluateSpfDetail = (spf) => {
|
|
92
|
+
const mechanisms = spf?.split(/\s+/).filter(Boolean) || [];
|
|
93
|
+
const normalizedMechanisms = mechanisms.map((mechanism) => mechanism.toLowerCase());
|
|
94
|
+
const hasPlusAll = normalizedMechanisms.some((mechanism) => mechanism === "+all" || mechanism === "all");
|
|
95
|
+
const hasTildeAll = normalizedMechanisms.includes("~all");
|
|
96
|
+
const hasMinusAll = normalizedMechanisms.includes("-all");
|
|
97
|
+
const hasQuestionAll = normalizedMechanisms.includes("?all");
|
|
98
|
+
const includeCount = normalizedMechanisms.filter((mechanism) => /^(?:[~?+-])?include:/i.test(mechanism)).length;
|
|
99
|
+
// Per RFC 7208 §4.6.4 the 10-lookup limit covers include, a, mx, ptr, and exists mechanisms.
|
|
100
|
+
// Counting only `include:` under-flags records with many a/mx/ptr/exists terms.
|
|
101
|
+
const dnsLookupCount = normalizedMechanisms.filter((m) => /^(?:[~?+-])?(?:include:|a(?:[:/\s]|$)|mx(?:[:/\s]|$)|ptr(?:[:/]|$)|exists:)/i.test(m)).length;
|
|
102
|
+
return {
|
|
103
|
+
hasPlusAll,
|
|
104
|
+
hasTildeAll,
|
|
105
|
+
hasMinusAll,
|
|
106
|
+
hasQuestionAll,
|
|
107
|
+
includeCount,
|
|
108
|
+
exceedsLookupLimit: dnsLookupCount > 10,
|
|
109
|
+
isOverlyPermissive: hasPlusAll || hasQuestionAll,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const normalizeDmarcPolicy = (value) => {
|
|
113
|
+
const normalized = value?.toLowerCase();
|
|
114
|
+
return normalized === "reject" || normalized === "quarantine" || normalized === "none" ? normalized : null;
|
|
115
|
+
};
|
|
116
|
+
export const evaluateDmarcPolicy = (dmarc) => {
|
|
117
|
+
if (!dmarc) {
|
|
118
|
+
return {
|
|
119
|
+
status: "missing",
|
|
120
|
+
policy: null,
|
|
121
|
+
subdomainPolicy: null,
|
|
122
|
+
pct: null,
|
|
123
|
+
reporting: false,
|
|
124
|
+
summary: "No DMARC record was detected.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const tags = parseDnsTags(dmarc);
|
|
128
|
+
const policy = normalizeDmarcPolicy(tags.get("p"));
|
|
129
|
+
const subdomainPolicy = normalizeDmarcPolicy(tags.get("sp"));
|
|
130
|
+
const pctRaw = tags.get("pct");
|
|
131
|
+
const pct = pctRaw && /^\d+$/.test(pctRaw) ? Math.max(0, Math.min(100, Number(pctRaw))) : null;
|
|
132
|
+
const reporting = Boolean(tags.get("rua") || tags.get("ruf"));
|
|
133
|
+
if (policy === "reject" || policy === "quarantine") {
|
|
134
|
+
const reducedRollout = pct !== null && pct < 100;
|
|
135
|
+
return {
|
|
136
|
+
status: reducedRollout ? "watch" : "strong",
|
|
137
|
+
policy,
|
|
138
|
+
subdomainPolicy,
|
|
139
|
+
pct,
|
|
140
|
+
reporting,
|
|
141
|
+
summary: reducedRollout
|
|
142
|
+
? `DMARC is enforcing ${policy}, but only for ${pct}% of mail.`
|
|
143
|
+
: `DMARC is enforcing ${policy}.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (policy === "none") {
|
|
147
|
+
return {
|
|
148
|
+
status: "weak",
|
|
149
|
+
policy,
|
|
150
|
+
subdomainPolicy,
|
|
151
|
+
pct,
|
|
152
|
+
reporting,
|
|
153
|
+
summary: "DMARC is present in monitoring mode only and does not enforce quarantine or reject.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
status: "watch",
|
|
158
|
+
policy,
|
|
159
|
+
subdomainPolicy,
|
|
160
|
+
pct,
|
|
161
|
+
reporting,
|
|
162
|
+
summary: "DMARC is present, but the policy tag could not be interpreted.",
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
const buildDkimInfo = (recordsBySelector) => {
|
|
166
|
+
const discovered = recordsBySelector
|
|
167
|
+
.flatMap(({ selector, records }) => records
|
|
168
|
+
.filter((record) => record.toLowerCase().startsWith("v=dkim1"))
|
|
169
|
+
.map((record) => ({ selector, record })));
|
|
170
|
+
const selectors = discovered.map((record) => record.selector);
|
|
171
|
+
return {
|
|
172
|
+
discovered,
|
|
173
|
+
selectors,
|
|
174
|
+
count: discovered.length,
|
|
175
|
+
summary: discovered.length
|
|
176
|
+
? `DKIM records were found at common selector${discovered.length === 1 ? "" : "s"}: ${selectors.join(", ")}.`
|
|
177
|
+
: "No DKIM records found at common selectors.",
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const gradeEmailScore = (score) => {
|
|
181
|
+
if (score >= 80)
|
|
182
|
+
return "A";
|
|
183
|
+
if (score >= 60)
|
|
184
|
+
return "B";
|
|
185
|
+
if (score >= 40)
|
|
186
|
+
return "C";
|
|
187
|
+
if (score >= 20)
|
|
188
|
+
return "D";
|
|
189
|
+
return "F";
|
|
190
|
+
};
|
|
191
|
+
const buildEmailDeliverabilityScore = ({ spf, spfDetail, dmarc, dmarcPolicy, dkim, mtaStsDns, tlsRptDns, bimiDns, dnssecSigned, }) => {
|
|
192
|
+
const breakdown = {};
|
|
193
|
+
if (spf)
|
|
194
|
+
breakdown["SPF present"] = 10;
|
|
195
|
+
if (spfDetail.hasMinusAll)
|
|
196
|
+
breakdown["SPF hardfail"] = 10;
|
|
197
|
+
else if (spfDetail.hasTildeAll)
|
|
198
|
+
breakdown["SPF softfail"] = 5;
|
|
199
|
+
if (dmarc)
|
|
200
|
+
breakdown["DMARC present"] = 15;
|
|
201
|
+
if (dmarcPolicy.policy === "reject")
|
|
202
|
+
breakdown["DMARC reject"] = 20;
|
|
203
|
+
else if (dmarcPolicy.policy === "quarantine")
|
|
204
|
+
breakdown["DMARC quarantine"] = 10;
|
|
205
|
+
if (dmarcPolicy.reporting)
|
|
206
|
+
breakdown["DMARC reporting"] = 5;
|
|
207
|
+
if (dkim.count > 0)
|
|
208
|
+
breakdown["DKIM discovered"] = 15;
|
|
209
|
+
if (mtaStsDns)
|
|
210
|
+
breakdown["MTA-STS present"] = 10;
|
|
211
|
+
if (tlsRptDns)
|
|
212
|
+
breakdown["TLS-RPT present"] = 5;
|
|
213
|
+
if (bimiDns)
|
|
214
|
+
breakdown["BIMI present"] = 5;
|
|
215
|
+
if (dnssecSigned)
|
|
216
|
+
breakdown["DNSSEC signed"] = 5;
|
|
217
|
+
const score = Math.min(100, Math.round(Object.values(breakdown).reduce((total, value) => total + value, 0)));
|
|
218
|
+
return {
|
|
219
|
+
score,
|
|
220
|
+
grade: gradeEmailScore(score),
|
|
221
|
+
breakdown,
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
export async function analyzeDomainSecurity(host, requestText) {
|
|
225
|
+
const apexHost = host.startsWith("www.") ? host.slice(4) : host;
|
|
226
|
+
const candidateHosts = [...new Set([host, apexHost])];
|
|
227
|
+
const resolveDns = (operation) => safeResolveWithTimeout(operation, DNS_LOOKUP_TIMEOUT_MS);
|
|
228
|
+
const [mxByHost, nsByHost, txtRootByHost, txtDmarcByHost, caaByHost, txtMtaStsByHost, txtTlsRptByHost, txtBimiByHost, txtDkimByHost, dsByHost,] = await Promise.all([
|
|
229
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveMx(candidate)))),
|
|
230
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveNs(candidate)))),
|
|
231
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveTxt(candidate)))),
|
|
232
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveTxt(`_dmarc.${candidate}`)))),
|
|
233
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveCaa(candidate)))),
|
|
234
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveTxt(`_mta-sts.${candidate}`)))),
|
|
235
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveTxt(`_smtp._tls.${candidate}`)))),
|
|
236
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolveTxt(`default._bimi.${candidate}`)))),
|
|
237
|
+
Promise.all(candidateHosts.map((candidate) => Promise.all(DKIM_COMMON_SELECTORS.map(async (selector) => ({
|
|
238
|
+
selector,
|
|
239
|
+
records: ((await resolveDns(() => dns.resolveTxt(`${selector}._domainkey.${candidate}`))) || [])
|
|
240
|
+
.map((entry) => entry.join("")),
|
|
241
|
+
}))))),
|
|
242
|
+
Promise.all(candidateHosts.map((candidate) => resolveDns(() => dns.resolve(candidate, "DS")))),
|
|
243
|
+
]);
|
|
244
|
+
const pickFirst = (values) => values.find((value) => value && value.length) || null;
|
|
245
|
+
const mxRecordsRaw = pickFirst(mxByHost) || [];
|
|
246
|
+
const nsRecordsRaw = pickFirst(nsByHost) || [];
|
|
247
|
+
const txtRoot = pickFirst(txtRootByHost) || [];
|
|
248
|
+
const txtDmarc = pickFirst(txtDmarcByHost) || [];
|
|
249
|
+
const caaRaw = pickFirst(caaByHost) || [];
|
|
250
|
+
const txtMtaSts = pickFirst(txtMtaStsByHost) || [];
|
|
251
|
+
const txtTlsRpt = pickFirst(txtTlsRptByHost) || [];
|
|
252
|
+
const txtBimi = pickFirst(txtBimiByHost) || [];
|
|
253
|
+
const dsRaw = pickFirst(dsByHost) || [];
|
|
254
|
+
const mxRecords = mxRecordsRaw
|
|
255
|
+
.sort((a, b) => a.priority - b.priority)
|
|
256
|
+
.map((record) => `${record.priority} ${record.exchange}`);
|
|
257
|
+
const nsRecords = nsRecordsRaw || [];
|
|
258
|
+
const txtValues = txtRoot.map((entry) => entry.join(""));
|
|
259
|
+
const dmarcValues = txtDmarc.map((entry) => entry.join(""));
|
|
260
|
+
const mtaStsValues = txtMtaSts.map((entry) => entry.join(""));
|
|
261
|
+
const tlsRptValues = txtTlsRpt.map((entry) => entry.join(""));
|
|
262
|
+
const bimiValues = txtBimi.map((entry) => entry.join(""));
|
|
263
|
+
const caaRecords = caaRaw.flatMap((record) => Object.entries(record)
|
|
264
|
+
.filter(([key]) => key !== "critical")
|
|
265
|
+
.map(([tag, value]) => `${tag} ${value}`));
|
|
266
|
+
const dsRecords = dsRaw.map((record) => `${record.keyTag} ${record.algorithm} ${record.digestType} ${record.digest}`);
|
|
267
|
+
const spf = txtValues.find((value) => value.toLowerCase().startsWith("v=spf1")) || null;
|
|
268
|
+
const dmarc = dmarcValues.find((value) => value.toLowerCase().startsWith("v=dmarc1")) || null;
|
|
269
|
+
const mtaStsDns = mtaStsValues.find((value) => value.toLowerCase().startsWith("v=stsv1")) || null;
|
|
270
|
+
const tlsRptDns = tlsRptValues.find((value) => value.toLowerCase().startsWith("v=tlsrptv1")) || null;
|
|
271
|
+
const bimiDns = bimiValues.find((value) => value.toLowerCase().startsWith("v=bimi1")) || null;
|
|
272
|
+
const txtDkim = txtDkimByHost.flat();
|
|
273
|
+
const dkim = buildDkimInfo(txtDkim);
|
|
274
|
+
const mtaStsTargetHost = txtMtaStsByHost[0]?.length ? candidateHosts[0] : candidateHosts[1] || candidateHosts[0];
|
|
275
|
+
const mtaStsPolicy = mtaStsDns ? await fetchMtaStsPolicy(mtaStsTargetHost, requestText) : { policyUrl: null, policy: null };
|
|
276
|
+
const spfDetail = evaluateSpfDetail(spf);
|
|
277
|
+
const emailPolicy = {
|
|
278
|
+
spf: evaluateSpfPolicy(spf),
|
|
279
|
+
dmarc: evaluateDmarcPolicy(dmarc),
|
|
280
|
+
};
|
|
281
|
+
const dnssecSigned = dsRecords.length > 0;
|
|
282
|
+
const emailDeliverabilityScore = buildEmailDeliverabilityScore({
|
|
283
|
+
spf,
|
|
284
|
+
spfDetail,
|
|
285
|
+
dmarc,
|
|
286
|
+
dmarcPolicy: emailPolicy.dmarc,
|
|
287
|
+
dkim,
|
|
288
|
+
mtaStsDns,
|
|
289
|
+
tlsRptDns,
|
|
290
|
+
bimiDns,
|
|
291
|
+
dnssecSigned,
|
|
292
|
+
});
|
|
293
|
+
const issues = [];
|
|
294
|
+
const strengths = [];
|
|
295
|
+
if (!mxRecords.length) {
|
|
296
|
+
issues.push("No MX records found.");
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
strengths.push("MX records are published.");
|
|
300
|
+
}
|
|
301
|
+
if (!spf) {
|
|
302
|
+
issues.push("No SPF record detected at the zone apex.");
|
|
303
|
+
}
|
|
304
|
+
else if (emailPolicy.spf.status === "weak") {
|
|
305
|
+
issues.push(emailPolicy.spf.summary);
|
|
306
|
+
}
|
|
307
|
+
else if (emailPolicy.spf.status === "watch") {
|
|
308
|
+
issues.push(emailPolicy.spf.summary);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
strengths.push(emailPolicy.spf.summary);
|
|
312
|
+
}
|
|
313
|
+
if (spfDetail.hasPlusAll) {
|
|
314
|
+
issues.push("Critical: SPF uses +all, which permits any sender to claim the domain.");
|
|
315
|
+
}
|
|
316
|
+
if (spfDetail.hasQuestionAll) {
|
|
317
|
+
issues.push("SPF uses ?all, leaving sender authorization neutral.");
|
|
318
|
+
}
|
|
319
|
+
if (spfDetail.exceedsLookupLimit) {
|
|
320
|
+
issues.push("SPF includes more than 10 include mechanisms and may hit the DNS lookup limit.");
|
|
321
|
+
}
|
|
322
|
+
if (spfDetail.hasMinusAll) {
|
|
323
|
+
strengths.push("SPF uses -all hardfail.");
|
|
324
|
+
}
|
|
325
|
+
if (!dmarc) {
|
|
326
|
+
issues.push("No DMARC record detected.");
|
|
327
|
+
}
|
|
328
|
+
else if (emailPolicy.dmarc.status === "weak") {
|
|
329
|
+
issues.push(emailPolicy.dmarc.summary);
|
|
330
|
+
}
|
|
331
|
+
else if (emailPolicy.dmarc.status === "watch") {
|
|
332
|
+
issues.push(emailPolicy.dmarc.summary);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
strengths.push(emailPolicy.dmarc.summary);
|
|
336
|
+
}
|
|
337
|
+
if (!caaRecords.length) {
|
|
338
|
+
issues.push("No CAA records found.");
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
strengths.push("CAA records restrict which certificate authorities may issue for the domain.");
|
|
342
|
+
}
|
|
343
|
+
if (!dsRecords.length) {
|
|
344
|
+
issues.push("No DNSSEC DS records detected at the domain apex.");
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
strengths.push("DNSSEC DS records are published.");
|
|
348
|
+
}
|
|
349
|
+
if (!mtaStsDns) {
|
|
350
|
+
issues.push("No MTA-STS DNS policy record detected.");
|
|
351
|
+
}
|
|
352
|
+
else if (!mtaStsPolicy.policy) {
|
|
353
|
+
issues.push("MTA-STS DNS record exists, but the HTTPS policy file could not be fetched.");
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
strengths.push("MTA-STS is published.");
|
|
357
|
+
}
|
|
358
|
+
if (!tlsRptDns) {
|
|
359
|
+
if (mtaStsDns) {
|
|
360
|
+
issues.push("MTA-STS is present, but no TLS-RPT reporting record was detected.");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
strengths.push("TLS-RPT reporting is published for SMTP transport issues.");
|
|
365
|
+
}
|
|
366
|
+
if (bimiDns) {
|
|
367
|
+
strengths.push("BIMI is published, which can support brand trust when paired with enforcing DMARC.");
|
|
368
|
+
}
|
|
369
|
+
if (dkim.count === 0) {
|
|
370
|
+
if (dmarc) {
|
|
371
|
+
issues.push("DMARC is published but no DKIM record was found at common selectors.");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
strengths.push(dkim.summary);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
host: apexHost,
|
|
379
|
+
mxRecords,
|
|
380
|
+
nsRecords,
|
|
381
|
+
caaRecords,
|
|
382
|
+
dnssec: {
|
|
383
|
+
enabled: dsRecords.length > 0,
|
|
384
|
+
dsRecords,
|
|
385
|
+
status: dsRecords.length > 0 ? "signed" : "not_signed",
|
|
386
|
+
},
|
|
387
|
+
spf,
|
|
388
|
+
dmarc,
|
|
389
|
+
emailPolicy,
|
|
390
|
+
mtaSts: {
|
|
391
|
+
dns: mtaStsDns,
|
|
392
|
+
policyUrl: mtaStsPolicy.policyUrl,
|
|
393
|
+
policy: mtaStsPolicy.policy,
|
|
394
|
+
},
|
|
395
|
+
spfDetail,
|
|
396
|
+
dkim,
|
|
397
|
+
tlsRpt: {
|
|
398
|
+
dns: tlsRptDns,
|
|
399
|
+
reporting: Boolean(tlsRptDns),
|
|
400
|
+
summary: tlsRptDns
|
|
401
|
+
? "TLS-RPT is published for SMTP transport reporting."
|
|
402
|
+
: "No TLS-RPT reporting record was detected.",
|
|
403
|
+
},
|
|
404
|
+
bimi: {
|
|
405
|
+
dns: bimiDns,
|
|
406
|
+
selector: "default",
|
|
407
|
+
status: bimiDns ? "present" : "missing",
|
|
408
|
+
summary: bimiDns
|
|
409
|
+
? "BIMI is published at the default selector."
|
|
410
|
+
: "No BIMI record was detected at the default selector.",
|
|
411
|
+
},
|
|
412
|
+
emailDeliverabilityScore,
|
|
413
|
+
issues,
|
|
414
|
+
strengths,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LibraryRiskSignal, RemediationSnippet, ScanIssue, SecurityHeaderResult } from "./types.js";
|
|
2
|
+
type ResponseHeaders = Record<string, string | string[] | undefined>;
|
|
3
|
+
export declare const SECURITY_HEADERS: Array<Pick<SecurityHeaderResult, "key" | "label" | "description" | "recommendation">>;
|
|
4
|
+
export declare const REMEDIATION_TARGETS: Record<string, string>;
|
|
5
|
+
export declare const analyzeHeaders: (headers: ResponseHeaders, isHttps: boolean) => {
|
|
6
|
+
headers: SecurityHeaderResult[];
|
|
7
|
+
issues: ScanIssue[];
|
|
8
|
+
strengths: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare const buildRawHeaders: (headers: ResponseHeaders) => Record<string, string>;
|
|
11
|
+
export declare const classifyIssueTaxonomy: (issue: ScanIssue) => ScanIssue;
|
|
12
|
+
export declare const buildLibraryRiskIssues: (libraryRiskSignals: LibraryRiskSignal[]) => ScanIssue[];
|
|
13
|
+
export declare const buildRemediation: (headerResults: SecurityHeaderResult[]) => RemediationSnippet[];
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { headerValue, unique } from "./utils.js";
|
|
2
|
+
export const SECURITY_HEADERS = [
|
|
3
|
+
{ key: "strict-transport-security", label: "Strict-Transport-Security", description: "Forces browsers to keep using HTTPS after the first secure visit.", recommendation: "Set HSTS with at least 6 months max-age and includeSubDomains." },
|
|
4
|
+
{ key: "content-security-policy", label: "Content-Security-Policy", description: "Reduces XSS and data injection risk by controlling allowed resource sources.", recommendation: "Add a CSP and avoid unsafe-inline / unsafe-eval where possible." },
|
|
5
|
+
{ key: "x-frame-options", label: "X-Frame-Options", description: "Helps prevent clickjacking in framed pages.", recommendation: "Use DENY or SAMEORIGIN unless framing is intentionally required." },
|
|
6
|
+
{ key: "x-content-type-options", label: "X-Content-Type-Options", description: "Stops MIME sniffing for mismatched content types.", recommendation: "Set X-Content-Type-Options to nosniff." },
|
|
7
|
+
{ key: "referrer-policy", label: "Referrer-Policy", description: "Limits how much referral data leaves the site.", recommendation: "Use strict-origin-when-cross-origin or stricter." },
|
|
8
|
+
{ key: "permissions-policy", label: "Permissions-Policy", description: "Restricts browser features such as camera and microphone access.", recommendation: "Disable unneeded browser capabilities with Permissions-Policy." },
|
|
9
|
+
{ key: "cross-origin-opener-policy", label: "Cross-Origin-Opener-Policy", description: "Improves browsing context isolation against cross-window attacks.", recommendation: "Set COOP to same-origin for stronger isolation where compatible." },
|
|
10
|
+
{ key: "cross-origin-resource-policy", label: "Cross-Origin-Resource-Policy", description: "Protects resources from being loaded by unintended origins.", recommendation: "Set CORP to same-origin or same-site when appropriate." },
|
|
11
|
+
];
|
|
12
|
+
export const REMEDIATION_TARGETS = {
|
|
13
|
+
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
|
14
|
+
"content-security-policy": "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; upgrade-insecure-requests",
|
|
15
|
+
"x-frame-options": "SAMEORIGIN",
|
|
16
|
+
"x-content-type-options": "nosniff",
|
|
17
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
18
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=(), browsing-topics=()",
|
|
19
|
+
"cross-origin-opener-policy": "same-origin",
|
|
20
|
+
"cross-origin-resource-policy": "same-origin",
|
|
21
|
+
};
|
|
22
|
+
export const analyzeHeaders = (headers, isHttps) => {
|
|
23
|
+
const results = [];
|
|
24
|
+
const issues = [];
|
|
25
|
+
const strengths = [];
|
|
26
|
+
const createIssue = (severity, area, title, detail, confidence = "high", source = "observed") => ({ severity, area, title, detail, confidence, source, owasp: [], mitre: [] });
|
|
27
|
+
for (const definition of SECURITY_HEADERS) {
|
|
28
|
+
const value = headerValue(headers, definition.key);
|
|
29
|
+
let status = value ? "present" : "missing";
|
|
30
|
+
let severity = value ? "good" : "warning";
|
|
31
|
+
let summary = value ? "Configured." : "Missing.";
|
|
32
|
+
if (definition.key === "strict-transport-security" && value) {
|
|
33
|
+
const lower = value.toLowerCase();
|
|
34
|
+
const maxAgeMatch = lower.match(/max-age=(\d+)/);
|
|
35
|
+
const maxAge = maxAgeMatch ? Number(maxAgeMatch[1]) : 0;
|
|
36
|
+
if (maxAge < 15552000 || !lower.includes("includesubdomains")) {
|
|
37
|
+
status = "warning";
|
|
38
|
+
severity = "warning";
|
|
39
|
+
summary = "Present, but the policy is weaker than recommended.";
|
|
40
|
+
issues.push(createIssue("warning", "transport", "HSTS could be stronger", "Increase max-age and include subdomains for better HTTPS protection.", "medium", "heuristic"));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
strengths.push("Strong HSTS policy detected.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (definition.key === "content-security-policy" && value) {
|
|
47
|
+
const directives = Object.fromEntries(value.split(";").map((directive) => directive.trim()).filter(Boolean).map((directive) => {
|
|
48
|
+
const [name, ...tokens] = directive.split(/\s+/);
|
|
49
|
+
return [name.toLowerCase(), tokens.map((token) => token.toLowerCase())];
|
|
50
|
+
}));
|
|
51
|
+
const scriptSources = directives["script-src"] || directives["default-src"] || [];
|
|
52
|
+
if (scriptSources.includes("'unsafe-inline'") || scriptSources.includes("'unsafe-eval'")) {
|
|
53
|
+
status = "warning";
|
|
54
|
+
severity = "warning";
|
|
55
|
+
summary = "Present, but allows unsafe script execution in script policies.";
|
|
56
|
+
issues.push(createIssue("warning", "headers", "CSP contains risky allowances", "unsafe-inline or unsafe-eval in script policies weakens CSP protections against XSS.", "high", "observed"));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
strengths.push("CSP is present without obvious unsafe script allowances.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (definition.key === "x-frame-options" && value) {
|
|
63
|
+
const lower = value.toLowerCase();
|
|
64
|
+
if (!["deny", "sameorigin"].includes(lower)) {
|
|
65
|
+
status = "warning";
|
|
66
|
+
severity = "warning";
|
|
67
|
+
summary = "Present, but uses a less reliable policy.";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (definition.key === "referrer-policy" && value) {
|
|
71
|
+
const lower = value.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean).at(-1) || "";
|
|
72
|
+
if (!["strict-origin", "strict-origin-when-cross-origin", "same-origin", "no-referrer"].includes(lower)) {
|
|
73
|
+
status = "warning";
|
|
74
|
+
severity = "warning";
|
|
75
|
+
summary = "Present, but a stricter referrer policy is recommended.";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!value) {
|
|
79
|
+
issues.push(createIssue(definition.key === "permissions-policy" ? "info" : "warning", "headers", `${definition.label} is missing`, definition.recommendation, "high", "observed"));
|
|
80
|
+
}
|
|
81
|
+
results.push({ ...definition, value, status, severity, summary });
|
|
82
|
+
}
|
|
83
|
+
if (!isHttps) {
|
|
84
|
+
issues.push(createIssue("critical", "transport", "Site is not using HTTPS", "Traffic can be intercepted or modified in transit over plain HTTP.", "high", "observed"));
|
|
85
|
+
}
|
|
86
|
+
return { headers: results, issues, strengths };
|
|
87
|
+
};
|
|
88
|
+
export const buildRawHeaders = (headers) => Object.fromEntries(Object.entries(headers)
|
|
89
|
+
.filter(([, value]) => value !== undefined)
|
|
90
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : String(value)]));
|
|
91
|
+
export const classifyIssueTaxonomy = (issue) => {
|
|
92
|
+
const text = `${issue.area} ${issue.title} ${issue.detail}`.toLowerCase();
|
|
93
|
+
const owasp = [];
|
|
94
|
+
const mitre = [];
|
|
95
|
+
const isCookieIssue = issue.area === "cookies" || text.includes("cookie");
|
|
96
|
+
const isHttpSurfaceDiscovery = text.includes("publicly reachable")
|
|
97
|
+
|| text.includes("exposed")
|
|
98
|
+
|| text.includes("fingerprint")
|
|
99
|
+
|| text.includes("banner")
|
|
100
|
+
|| text.includes("redirect chain")
|
|
101
|
+
|| text.includes("version")
|
|
102
|
+
|| text.includes("header");
|
|
103
|
+
if (text.includes("outdated component") || text.includes("known advis") || text.includes("osv") || text.includes("vulnerab") || text.includes("library "))
|
|
104
|
+
owasp.push("A06 Vulnerable and Outdated Components");
|
|
105
|
+
if (issue.area === "transport" || issue.area === "certificate" || text.includes("https") || text.includes("tls") || text.includes("certificate") || text.includes("hsts"))
|
|
106
|
+
owasp.push("A02 Cryptographic Failures");
|
|
107
|
+
if (issue.area === "headers" || text.includes("missing") || text.includes("csp") || text.includes("referrer-policy") || text.includes("permissions-policy") || text.includes("cors") || text.includes("samesite") || text.includes("httponly") || text.includes("secure flag"))
|
|
108
|
+
owasp.push("A05 Security Misconfiguration");
|
|
109
|
+
if (text.includes("unsafe-inline") || text.includes("unsafe-eval") || text.includes("xss") || text.includes("inline script"))
|
|
110
|
+
owasp.push("A03 Injection");
|
|
111
|
+
if (text.includes("publicly reachable") || text.includes("exposed") || text.includes("authorization") || text.includes("access-controlled"))
|
|
112
|
+
owasp.push("A01 Broken Access Control");
|
|
113
|
+
if (isCookieIssue)
|
|
114
|
+
owasp.push("A07 Identification and Authentication Failures");
|
|
115
|
+
if (text.includes("publicly reachable") || text.includes("exposed") || text.includes("site is not using https") || text.includes("redirect chain"))
|
|
116
|
+
mitre.push("Initial Access");
|
|
117
|
+
if (isHttpSurfaceDiscovery || text.includes("certificate") || text.includes("cors"))
|
|
118
|
+
mitre.push("Reconnaissance");
|
|
119
|
+
if (isCookieIssue || text.includes("password") || text.includes("token") || text.includes("session"))
|
|
120
|
+
mitre.push("Credential Access");
|
|
121
|
+
if (text.includes("referrer") || text.includes("inline script") || text.includes("sri"))
|
|
122
|
+
mitre.push("Collection");
|
|
123
|
+
if (text.includes("bypass") || text.includes("evasion") || text.includes("obfuscat"))
|
|
124
|
+
mitre.push("Defense Evasion");
|
|
125
|
+
return { ...issue, owasp: unique(owasp), mitre: unique(mitre) };
|
|
126
|
+
};
|
|
127
|
+
export const buildLibraryRiskIssues = (libraryRiskSignals) => libraryRiskSignals.map((signal) => {
|
|
128
|
+
const highestSeverity = signal.vulnerabilities.some((item) => item.severity === "critical" || item.severity === "high")
|
|
129
|
+
? "critical"
|
|
130
|
+
: signal.vulnerabilities.some((item) => item.severity === "moderate")
|
|
131
|
+
? "warning"
|
|
132
|
+
: "info";
|
|
133
|
+
const references = signal.vulnerabilities.flatMap((item) => item.aliases).filter(Boolean).slice(0, 3);
|
|
134
|
+
return {
|
|
135
|
+
severity: highestSeverity,
|
|
136
|
+
area: "headers",
|
|
137
|
+
title: `${signal.packageName} ${signal.version} has known advisories`,
|
|
138
|
+
detail: `OSV returned ${signal.vulnerabilities.length} advisory match${signal.vulnerabilities.length === 1 ? "" : "es"} for this publicly referenced library version.${references.length ? ` References: ${references.join(", ")}.` : ""}`,
|
|
139
|
+
confidence: signal.confidence,
|
|
140
|
+
source: "observed",
|
|
141
|
+
owasp: [],
|
|
142
|
+
mitre: [],
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
export const buildRemediation = (headerResults) => {
|
|
146
|
+
const requiredHeaders = headerResults
|
|
147
|
+
.filter((header) => header.status !== "present")
|
|
148
|
+
.map((header) => ({ key: header.key, label: header.label, value: REMEDIATION_TARGETS[header.key] }))
|
|
149
|
+
.filter((header) => header.value);
|
|
150
|
+
if (!requiredHeaders.length)
|
|
151
|
+
return [];
|
|
152
|
+
const nginxLines = requiredHeaders.map((header) => `add_header ${header.label} "${header.value}" always;`);
|
|
153
|
+
const apacheLines = requiredHeaders.map((header) => `Header always set ${header.label} "${header.value}"`);
|
|
154
|
+
const cloudflareLines = requiredHeaders.map((header) => `secured.headers.set("${header.label}", "${header.value.replaceAll('"', '\\"')}");`);
|
|
155
|
+
const vercelLines = requiredHeaders.map((header) => ` { key: "${header.label}", value: "${header.value}" },`);
|
|
156
|
+
const netlifyLines = requiredHeaders.map((header) => ` ${header.label}: ${header.value}`);
|
|
157
|
+
const names = requiredHeaders.map((header) => header.label).join(", ");
|
|
158
|
+
return [
|
|
159
|
+
{ platform: "nginx", title: "Nginx security headers", description: `Adds recommended headers for: ${names}.`, filename: "nginx.conf", snippet: ["server {", " # ...existing config", ...nginxLines.map((line) => ` ${line}`), "}"].join("\n") },
|
|
160
|
+
{ platform: "apache", title: "Apache mod_headers rules", description: "Use inside your vhost or .htaccess where mod_headers is enabled.", filename: ".htaccess", snippet: ["<IfModule mod_headers.c>", ...apacheLines.map((line) => ` ${line}`), "</IfModule>"].join("\n") },
|
|
161
|
+
{ platform: "cloudflare", title: "Cloudflare Worker response hardening", description: "Apply these headers in a Worker or edge response transform.", filename: "worker.js", snippet: ["export default {", " async fetch(request, env, ctx) {", " const response = await fetch(request);", " const secured = new Response(response.body, response);", ...cloudflareLines.map((line) => ` ${line}`), " return secured;", " },", "};"].join("\n") },
|
|
162
|
+
{ platform: "vercel", title: "Vercel headers() config", description: "Paste into next.config.js or next.config.mjs.", filename: "next.config.js", snippet: ["export default {", " async headers() {", " return [", " {", ' source: "/(.*)",', " headers: [", ...vercelLines, " ],", " },", " ];", " },", "};"].join("\n") },
|
|
163
|
+
{ platform: "netlify", title: "Netlify _headers file", description: "Add this block to your Netlify `_headers` file.", filename: "_headers", snippet: ["/*", ...netlifyLines, ""].join("\n") },
|
|
164
|
+
];
|
|
165
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AnalysisResult, HistoryDiff, HistorySnapshot } from "./types.js";
|
|
2
|
+
export declare const snapshotFromAnalysis: (analysis: AnalysisResult) => HistorySnapshot;
|
|
3
|
+
export declare const buildHistoryDiffFromSnapshots: (current: HistorySnapshot, previous: HistorySnapshot) => HistoryDiff;
|
|
4
|
+
export declare const buildHistoryDiff: (history: HistorySnapshot[]) => HistoryDiff | null;
|