Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/releases/2026-11-02h.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Release 2026-11-02h
Build: f5a83ece4231 Worker: f85b54d3 (staging ee028cff; rollback eae17c13) Tag: v2026.11.02h

## What changed (user-visible)
- Fetch proxy: Cloudflare stamps `server: cloudflare`, `cf-ray` and `cf-cache-status` on every Worker subrequest. Every site fingerprinted as Cloudflare, and Website Headers flagged a Server disclosure everywhere. The proxy now drops those headers for hosts outside Cloudflare's published ranges and returns `masked: ["server"]`; Tech Fingerprint and Website Headers say the Server value is not readable through the proxy.
- Email Security: SPF DNS lookups counted through every include and redirect.
- Website Headers: legacy/optional headers (X-XSS-Protection, CORP, COEP, X-DNS-Prefetch-Control, X-Permitted-Cross-Domain-Policies) shown but not graded.
- Certificate Search: crt.sh through the proxy (was blocked by CORS), Cert Spotter fallback.
- DNS Lookup: MX/SRV sorted by priority.
- CVE Search: keyword matches newest first (was the oldest 20).
- Email Header Analyzer: SPF softfail flagged; DKIM pass not aligned with From flagged; internal hops labeled.
- security.txt: Contact/Policy point to security@darknode.ai and /contact (were /education); Expires under a year.

## Security / rules / data changes
- No rules changes. Worker /api/fetch adds one DoH lookup (cached 10 min per isolate) for responses carrying `server: cloudflare`.

## Verified (how)
- npm test 4995/0; release:check READY.
- Staging and prod proxy: google.com/github.com/darknode.ai masked, cloudflare.com/discord.com keep Cloudflare headers.
- Local Deck: each Quick Check run on real domains; live build.json, CSP, security.txt and cve-search.js checked.

## Skipped or known issues
- Owner: confirm the security@darknode.ai forward exists at Porkbun (contact@ is the one used elsewhere).

## Owner decisions still open
- Server plan gating for monitors/sync/keys; ban list readable by signed-in users.
7 changes: 4 additions & 3 deletions public/.well-known/security.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Contact: https://darknode.ai/education
Expires: 2027-12-31T23:59:59.000Z
Contact: mailto:security@darknode.ai
Contact: https://darknode.ai/contact
Expires: 2027-10-31T23:59:59.000Z
Preferred-Languages: en
Canonical: https://darknode.ai/.well-known/security.txt
Policy: https://darknode.ai/education
Policy: https://darknode.ai/contact
16 changes: 12 additions & 4 deletions public/js/cve-search.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,17 +212,25 @@ window._cveDoSearch = function() {
if (/^CVE-\d{4}-\d{4,}$/i.test(query)) {
url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=' + encodeURIComponent(query.toUpperCase());
} else {
url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=' + encodeURIComponent(query) + '&resultsPerPage=20';
url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=' + encodeURIComponent(query) + '&resultsPerPage=' + _CVE_LATEST_N;
}

// NVD lists keyword matches oldest first: when there are more than one page, read the
// last page instead so the newest matches are the ones shown.
_cveFetch(url)
.then(function(data) {
var total = data.totalResults || 0, got = (data.vulnerabilities || []).length;
if (total <= got || !/keywordSearch=/.test(url)) return data;
return _cveFetch(url + '&startIndex=' + Math.max(0, total - _CVE_LATEST_N)).then(function(last) { last.totalResults = total; return last; });
})
.then(function(data) {
if (tok !== _cveTok) return;
var vulns = data.vulnerabilities || [];
var vulns = (data.vulnerabilities || []).filter(function(v) { return v && v.cve && v.cve.id; });
vulns.sort(function(a, b) { return String(b.cve.published || '').localeCompare(String(a.cve.published || '')) || String(b.cve.id).localeCompare(String(a.cve.id)); });
var n = data.totalResults || vulns.length;
_cveLastResults = vulns;
_cveLimit = _CVE_PAGE;
_cveSetSrc(n + ' NVD match' + (n === 1 ? '' : 'es') + ' for "' + query + '"' + (n > vulns.length ? ' (showing the first ' + vulns.length + ')' : '') + '.');
_cveSetSrc(n + ' NVD match' + (n === 1 ? '' : 'es') + ' for "' + query + '"' + (n > vulns.length ? ' (showing the newest ' + vulns.length + ')' : ', newest first') + '.');
_cveRenderList(results, vulns);
})
.catch(function(err) { if (tok === _cveTok) _cveErr(results, err); });
Expand Down Expand Up @@ -261,7 +269,7 @@ function _cveRenderList(el, vulns) {
}

var shown = Math.min(filtered.length, _cveLimit);
var h = '<div class="cve-count">' + (shown < filtered.length ? 'Showing ' + shown + ' of ' : '') + filtered.length + ' of ' + vulns.length + ' results</div>';
var h = '<div class="cve-count">' + (shown < filtered.length ? 'Showing ' + shown + ' of ' : '') + filtered.length + (filtered.length < vulns.length ? ' of ' + vulns.length : '') + ' results' + (filtered.length < vulns.length ? ' (severity filter)' : '') + '</div>';

for (var i = 0; i < shown; i++) {
var cve = filtered[i].cve;
Expand Down
2 changes: 2 additions & 0 deletions public/js/dns-enum.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ async function _deDohRecords(domain) {
const rev = A[0].value.split('.').reverse().join('.') + '.in-addr.arpa';
PTR = await q(rev, 'PTR');
}
const byPri = (a, b) => (a.priority - b.priority) || a.value.localeCompare(b.value);
MX.sort(byPri); srv.sort((a, b) => a.name.localeCompare(b.name) || byPri(a, b));
Object.assign(records, { A, AAAA, MX, NS, TXT: TXT.concat(dmarc), CNAME: cnApex.concat(cnWww), SOA, SRV: srv, PTR, CAA });
// Record types whose lookup failed (vs. genuinely empty), for the Records view to flag.
Object.defineProperty(records, 'failed', { value: [...failed], enumerable: false });
Expand Down
50 changes: 50 additions & 0 deletions public/js/email-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,58 @@ function analyzeDKIM(results) {

// ── LOOKUP JOBS — glue transport to parsers; resolve { net, result } ──
// net=false means the lookup failed at the transport level (not cached, so a retry can succeed).
// The RFC 7208 limit of 10 DNS lookups counts the lookups inside every include: and
// redirect= target too, so the top-level count from analyzeSPF is only a lower bound.
// Fetch the include chain and replace that estimate with the real total.
var SPF_LIMIT_RE = /^(include: count = |SPF allows a maximum of 10|Close to the SPF 10)/;
function spfTargets(txts) {
var out = [];
(txts || []).forEach(function (t) {
if (!/^v=spf1(\s|$)/i.test(String(t).trim())) return;
String(t).split(/\s+/).forEach(function (tok) {
var m = tok.replace(/^[-~?+]/, "").match(/^(?:include:|redirect=)(.+)$/i);
if (m && !/%\{/.test(m[1])) out.push(m[1].toLowerCase().replace(/\.$/, ""));
});
});
return out;
}
function spfDeepCount(domain, txts, result, signal) {
var records = {}, queue = spfTargets(txts), seen = new Set([domain]), q = 0;
records[domain] = txts;
function step() {
var batch = [];
while (queue.length && batch.length < 8 && q < 40) { var n = queue.shift(); if (!seen.has(n)) { seen.add(n); batch.push(n); q++; } }
if (!batch.length) return Promise.resolve();
return Promise.all(batch.map(function (n) {
return dohTXT(n, signal).then(function (r) { if (r.ok) { records[n] = r.txt; queue.push.apply(queue, spfTargets(r.txt)); } });
})).then(step);
}
return step().then(function () {
return import("/js/exposure/dmarcreports-engine.js").then(function (m) {
var t = m.spfTotalLookups(domain, records);
var f = result.findings.filter(function (x) { return !SPF_LIMIT_RE.test(x.text); });
var unresolved = t.unresolved.length ? " " + t.unresolved.length + " include(s) could not be resolved, so the real number may be higher." : "";
f.push({ kind: "info", text: "DNS lookups counted through every include and redirect: " + t.total + " of the 10 allowed." + unresolved });
var status = result.status;
if (t.total > 10) {
f.push({ kind: "bad", text: "This SPF record needs " + t.total + " DNS lookups including nested includes; receivers stop at 10 and return permerror, so SPF fails for all mail. Flatten or remove includes." });
status = "FAIL";
} else if (t.total >= 8) {
f.push({ kind: "warn", text: "Close to the SPF limit: " + t.total + " of 10 DNS lookups including nested includes." });
if (status === "PASS") status = "WARN";
}
return { status: status, record: result.record, findings: f };
});
}).catch(function () { return result; }); // keep the top-level estimate if the walk fails
}

function spfJob(domain, signal) {
return dohTXT(domain, signal).then(function (r) {
if (r.ok) {
var base = analyzeSPF(r.txt);
if (!base.record) return { net: true, result: base };
return spfDeepCount(domain, r.txt, base, signal).then(function (res) { return { net: true, result: res }; });
}
return r.ok
? { net: true, result: analyzeSPF(r.txt) }
: { net: false, result: { status: "FAIL", record: null, findings: [{ kind: "bad", text: "SPF lookup failed: " + r.error }] } };
Expand Down
26 changes: 23 additions & 3 deletions public/js/email-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,20 @@ function parseAuth(val) {
var results = { spf: 'none', dkim: 'none', dmarc: 'none', spfDetail: '', dkimDetail: '', dmarcDetail: '' };
var spf = val.match(/spf=(\w+)(?:\s*\(([^)]*)\))?/i);
if (spf) { results.spf = spf[1].toLowerCase(); results.spfDetail = spf[2] || ''; }
var dkim = val.match(/dkim=(\w+)/i);
if (dkim) { results.dkim = dkim[1].toLowerCase(); }
// Every DKIM result with its signing domain (header.d=, or the domain of header.i=);
// one passing signature is enough for DKIM to count as pass.
results.dkimSigs = [];
var dre = /dkim=(\w+)([^;]*)/gi, dm;
while ((dm = dre.exec(val))) {
var dd = (/header\.d=([\w.-]+)/i.exec(dm[2]) || /header\.i=[^@\s;]*@([\w.-]+)/i.exec(dm[2]) || [])[1] || '';
results.dkimSigs.push({ result: dm[1].toLowerCase(), domain: dd.toLowerCase() });
}
if (results.dkimSigs.length) {
var good = results.dkimSigs.filter(function(x) { return x.result === 'pass'; });
results.dkim = good.length ? 'pass' : results.dkimSigs[0].result;
results.dkimDetail = results.dkimSigs.filter(function(x) { return x.domain; }).map(function(x) { return x.domain + ' (' + x.result + ')'; }).join(', ');
if (results.dkimDetail) results.dkimDetail = 'Signing domain: ' + results.dkimDetail;
}
var dmarc = val.match(/dmarc=(\w+)(?:\s*\(([^)]*)\))?/i);
if (dmarc) { results.dmarc = dmarc[1].toLowerCase(); results.dmarcDetail = dmarc[2] || ''; }
return results;
Expand Down Expand Up @@ -159,7 +171,15 @@ function detectSuspicious(headers, hops, auth) {
var pol = ((auth.dmarcDetail.match(/\bp=(\w+)/i) || [])[1] || '').toLowerCase();
flags.push({ severity: 'critical', text: 'DMARC check failed. ' + (pol === 'reject' ? 'The domain asks receivers to reject this email.' : pol === 'quarantine' ? 'The domain asks receivers to send this email to spam.' : pol === 'none' ? 'The domain only monitors (p=none), so it was not blocked.' : 'The From domain could not be verified.') });
}
if (auth.spf === 'softfail') flags.push({ severity: 'medium', text: 'SPF softfail. The sending server is not listed as authorized for the envelope domain, and the domain only asks for this to be marked, not rejected.' });
if (auth.dkim === 'fail') flags.push({ severity: 'high', text: 'DKIM signature failed. Email may have been altered in transit.' });
if (auth.dkim === 'pass' && auth.dmarc !== 'pass' && from) {
var fD = addrDomain(from);
var signers = (auth.dkimSigs || []).filter(function(x) { return x.result === 'pass' && x.domain; }).map(function(x) { return x.domain; });
if (fD && signers.length && !signers.some(function(d) { return sameOrg(d, fD); })) {
flags.push({ severity: 'high', text: 'DKIM passed, but the signature is from ' + esc(signers.join(', ')) + ', not the From domain (' + esc(fD) + '). It proves nothing about the visible sender.' });
}
}
if (/urgent|immediate|wire transfer|confidential|act now|verify your account|suspended/i.test(subject)) {
flags.push({ severity: 'medium', text: 'Subject line contains urgency/social engineering language: "' + esc(subject.substring(0, 60)) + '"' });
}
Expand Down Expand Up @@ -282,7 +302,7 @@ export function renderEmailHeader(container) {
contentHtml += '<div class="eh-hop">' +
'<div class="eh-hop-num">' + (i + 1) + '</div>' +
'<div class="eh-hop-info">' +
'<div class="eh-hop-server">' + esc(h.from || 'Origin') + ' → ' + esc(h.by || '?') + '</div>' +
'<div class="eh-hop-server">' + esc(h.from || (i === 0 ? 'Origin' : 'Internal handoff')) + ' → ' + esc(h.by || '?') + '</div>' +
'<div class="eh-hop-detail">' +
(h.ip ? 'IP: <strong>' + esc(h.ip) + '</strong> · ' : '') +
(h.proto ? 'Protocol: ' + esc(h.proto) + ' · ' : '') +
Expand Down
Loading
Loading