@mlawsonking/code-guard-mcp
Advanced tools
@@ -29,2 +29,78 @@ // GENERATED FILE - do not edit here. Your change will be overwritten. | ||
| // A body is not always the text a reader sees. Mail routinely arrives base64 or quoted-printable | ||
| // encoded, and multipart mail carries several parts with their own encodings. Scanning the raw block | ||
| // meant scanning the encoding, not the message: a base64 `text/plain` body carrying the same | ||
| // injection this engine catches in plain text scored zero, because the scanner was reading | ||
| // "SWdub3JlIGFsbCBwcmV2aW91cyBpbnN0cnVjdGlvbnM=" and no rule matches that. Decoding is not a nicety | ||
| // here; without it the engine does not do the job its name claims. | ||
| function decodeTransfer(text, encoding) { | ||
| const enc = String(encoding || '').trim().toLowerCase(); | ||
| const src = String(text || ''); | ||
| try { | ||
| if (enc === 'base64') { | ||
| const clean = src.replace(/[^A-Za-z0-9+/=]/g, ''); | ||
| if (!clean) return ''; | ||
| return Buffer.from(clean, 'base64').toString('utf8'); | ||
| } | ||
| if (enc === 'quoted-printable') { | ||
| return src | ||
| .replace(/=\r?\n/g, '') | ||
| .replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))); | ||
| } | ||
| } catch { return src; } | ||
| return src; | ||
| } | ||
| // RFC 2047: `Subject: =?utf-8?B?…?=` renders as ordinary text in every mail client, so a brand | ||
| // spoof or an instruction can hide in a header the same way it hides in a body. | ||
| function decodeEncodedWords(text) { | ||
| return String(text || '').replace( | ||
| /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, | ||
| (whole, charset, kind, data) => { | ||
| try { | ||
| if (/^b$/i.test(kind)) return Buffer.from(data, 'base64').toString('utf8'); | ||
| return data.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))); | ||
| } catch { return whole; } | ||
| }, | ||
| ); | ||
| } | ||
| function contentTypeOf(headers) { | ||
| return String(headers['content-type'] || ''); | ||
| } | ||
| function boundaryOf(headers) { | ||
| const m = contentTypeOf(headers).match(/boundary\s*=\s*"?([^";\r\n]+)"?/i); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| // Walk a multipart body into its decoded leaves. Depth-limited because a crafted message can nest | ||
| // parts forever, and this runs inside a hook. | ||
| function walkParts(body, headers, depth, out) { | ||
| if (depth > 6) return; | ||
| const boundary = boundaryOf(headers); | ||
| const ctype = contentTypeOf(headers).toLowerCase(); | ||
| if (!boundary || !/^multipart\//.test(ctype.trim())) { | ||
| const decoded = decodeTransfer(body, headers['content-transfer-encoding']); | ||
| if (!decoded) return; | ||
| if (/text\/html/i.test(ctype)) out.html.push(decoded); | ||
| else out.text.push(decoded); | ||
| return; | ||
| } | ||
| const marker = `--${boundary}`; | ||
| const segments = String(body).split(marker); | ||
| for (const segment of segments) { | ||
| const chunk = segment.replace(/^\r?\n/, ''); | ||
| if (!chunk.trim() || chunk.trim() === '--') continue; | ||
| const split = chunk.search(/\r?\n\r?\n/); | ||
| const partHeaders = parseHeaders(split >= 0 ? chunk.slice(0, split) : chunk); | ||
| const partBody = split >= 0 ? chunk.slice(split).replace(/^\r?\n\r?\n/, '') : ''; | ||
| // A part with no headers of its own is the preamble between boundaries, not a part. | ||
| if (split < 0 && !contentTypeOf(partHeaders)) continue; | ||
| walkParts(partBody, partHeaders, depth + 1, out); | ||
| } | ||
| } | ||
| function parseEmail(input) { | ||
@@ -36,3 +112,10 @@ let headers = {}, body = '', html = '', raw = ''; | ||
| headers = parseHeaders(idx >= 0 ? input.slice(0, idx) : input); | ||
| body = idx >= 0 ? input.slice(idx).trim() : ''; | ||
| const rawBody = idx >= 0 ? input.slice(idx).replace(/^\r?\n\r?\n/, '') : ''; | ||
| const parts = { text: [], html: [] }; | ||
| walkParts(rawBody, headers, 0, parts); | ||
| body = parts.text.join('\n').trim(); | ||
| html = parts.html.join('\n').trim(); | ||
| // Keep the undecoded block too when nothing decoded to anything, so a malformed message is still | ||
| // scanned rather than silently becoming empty. | ||
| if (!body && !html) body = rawBody.trim(); | ||
| } else if (input && typeof input === 'object') { | ||
@@ -53,3 +136,3 @@ const hsrc = input.headers || {}; | ||
| const to = parseAddress(headers.to); | ||
| const subject = headers.subject || ''; | ||
| const subject = decodeEncodedWords(headers.subject || ''); | ||
| const combined = [subject, body, html].filter(Boolean).join('\n'); | ||
@@ -162,2 +245,2 @@ return { headers, from, replyTo, returnPath, to, subject, body, html, combined, raw }; | ||
| module.exports = { parseEmail, parseAddress, parseAuthResults, checkDomainAuth, isDisposable, senderRisk, deliverabilityScan, extractLinks, AUTH_TRUST_NOTE, DKIM_NOT_CHECKED }; | ||
| module.exports = { parseEmail, decodeTransfer, decodeEncodedWords, parseAddress, parseAuthResults, checkDomainAuth, isDisposable, senderRisk, deliverabilityScan, extractLinks, AUTH_TRUST_NOTE, DKIM_NOT_CHECKED }; |
@@ -72,2 +72,10 @@ // GENERATED FILE - do not edit here. Your change will be overwritten. | ||
| verdict, | ||
| // `verdict: allow` says no rule in this set matched. It does not say the text is safe, and the | ||
| // difference is the whole product: a paraphrase nobody has written a rule for scores zero here. | ||
| // `matched` states the same fact without a word that reads like a safety judgement. It is added | ||
| // rather than replacing `verdict`, because callers already branch on that field. | ||
| matched: findings.length > 0, | ||
| means: findings.length | ||
| ? 'One or more known patterns matched. See findings for which rule and why.' | ||
| : 'No rule in this ruleset matched. That is not a finding of safety: novel or paraphrased wording is not covered by these rules.', | ||
| findings, | ||
@@ -74,0 +82,0 @@ categories: [...new Set(findings.map((f) => f.category))], |
@@ -320,7 +320,17 @@ // GENERATED FILE - do not edit here. Your change will be overwritten. | ||
| const worstSeverity = findings.reduce((a, f) => Math.max(a, f.severity === 'critical' ? 2 : 1), 0); | ||
| const verdict = worstSeverity === 2 ? 'danger' : worstSeverity === 1 ? 'caution' : 'safe'; | ||
| let verdict = worstSeverity === 2 ? 'danger' : worstSeverity === 1 ? 'caution' : 'safe'; | ||
| // `stamp` downgrades a verdict when a check was skipped, and here four are skipped by design, so | ||
| // pass the checks for reporting but keep the verdict this engine actually reached. The honesty is | ||
| // carried by `local_only` and by the skipped list itself, both of which the caller must show. | ||
| // The four network checks above are skipped by design in this engine, so their absence must not | ||
| // move the verdict: that is what `local_only` and the skipped list are for. The comparison list is | ||
| // different. It is the ONLY thing this engine checks a name against, it ships inside the package, | ||
| // and when it fails to load there is no name check at all. Reporting "safe" then is a check that | ||
| // did not run being read as a pass, which is the worst bug this codebase can ship. | ||
| // | ||
| // This is not hypothetical. Published 0.3.0 omitted `data/` from its npm `files` list, so every | ||
| // install answered `safe` for `crossenv`, a real npm typosquat attack. The `files` entry is fixed | ||
| // and a tarball test now guards it; this is the second lock, so that a list which cannot load can | ||
| // never again read as a clean name. | ||
| const listMissing = !list.available; | ||
| if (listMissing && verdict === 'safe') verdict = 'unknown'; | ||
| const out = stamp({ | ||
@@ -333,5 +343,7 @@ ok: true, | ||
| local_only: true, | ||
| summary: verdict === 'safe' | ||
| ? `Nothing in the name "${raw}" resembles a known popular package closely enough to flag. The registry, OSV and the package contents were not consulted, so this is not a statement that the package is safe.` | ||
| : findings.map((f) => f.message).join(' '), | ||
| summary: listMissing | ||
| ? `The bundled list of popular package names could not be loaded, so the name "${raw}" was not compared against anything. This is not a result: nothing was checked.` | ||
| : verdict === 'safe' | ||
| ? `Nothing in the name "${raw}" resembles a known popular package closely enough to flag. The registry, OSV and the package contents were not consulted, so this is not a statement that the package is safe.` | ||
| : findings.map((f) => f.message).join(' '), | ||
| list_generated: list.available ? list.generated : null, | ||
@@ -338,0 +350,0 @@ list_size: list.available ? list.size : 0, |
@@ -64,2 +64,5 @@ // GENERATED FILE - do not edit here. Your change will be overwritten. | ||
| // Addresses that are reserved by RFC and cannot resolve to a real mailbox. | ||
| const RESERVED_DOMAIN = /@(?:[A-Za-z0-9.-]*\.)?(?:test|example|invalid|localhost)$|@(?:[A-Za-z0-9.-]*\.)?example\.(?:com|net|org)$/i; | ||
| const PII_RULES = [ | ||
@@ -103,2 +106,7 @@ { id: 'email', type: 'Email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, severity: 'low', vg: 0 }, | ||
| if (r.luhn && !luhn(val)) { if (!r.re.global) break; continue; } | ||
| // RFC 2606 and 6761 set aside .test, .example, .invalid and .localhost (and example.com/net/org) | ||
| // precisely so documentation and tests have addresses that can never belong to anyone. Calling | ||
| // those personal data is noise, and noise is what gets a scanner switched off. This engine's | ||
| // own test fixtures tripped it. | ||
| if (r.id === 'email' && RESERVED_DOMAIN.test(val)) { if (!r.re.global) break; continue; } | ||
| const start = vg === 0 ? m.index : m.index + m[0].indexOf(val); | ||
@@ -105,0 +113,0 @@ spans.push({ start, end: start + val.length, type: r.type }); |
@@ -19,3 +19,5 @@ // GENERATED FILE - do not edit here. Your change will be overwritten. | ||
| const ORDER = { allow: 0, clear: 0, ok: 0, caution: 1, review: 1, warn: 1, block: 2, danger: 2 }; | ||
| // `safe` and `unknown` are listed explicitly rather than defaulted: `safe` is a pass, and `unknown` | ||
| // means a check could not run, which must sort with caution so it can never be mistaken for one. | ||
| const ORDER = { allow: 0, clear: 0, ok: 0, safe: 0, unknown: 1, caution: 1, review: 1, warn: 1, block: 2, danger: 2 }; | ||
@@ -22,0 +24,0 @@ // A verdict may only ever move toward caution, never away from it. |
+1
-1
| { | ||
| "name": "@mlawsonking/code-guard-mcp", | ||
| "version": "1.3.0", | ||
| "version": "1.4.0", | ||
| "mcpName": "io.github.mlawsonking/code-guard-mcp", | ||
@@ -5,0 +5,0 @@ "publishConfig": { |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
371177
1.81%6290
1.68%