vskill 1.2.4 → 1.2.7
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/README.md +51 -3
- package/agents.json +6 -1
- package/dist/agents/agents-registry.d.ts +43 -16
- package/dist/agents/agents-registry.js +119 -33
- package/dist/agents/agents-registry.js.map +1 -1
- package/dist/agents/global-dirs.d.ts +19 -0
- package/dist/agents/global-dirs.js +53 -0
- package/dist/agents/global-dirs.js.map +1 -0
- package/dist/audit/fix-suggestions.js +6 -0
- package/dist/audit/fix-suggestions.js.map +1 -1
- package/dist/commands/add-github-bundle.d.ts +29 -0
- package/dist/commands/add-github-bundle.js +135 -0
- package/dist/commands/add-github-bundle.js.map +1 -0
- package/dist/commands/add-lockfile.d.ts +2 -0
- package/dist/commands/add-lockfile.js +1 -1
- package/dist/commands/add-lockfile.js.map +1 -1
- package/dist/commands/add.d.ts +5 -7
- package/dist/commands/add.js +207 -111
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/audit-webmcp.d.ts +25 -0
- package/dist/commands/audit-webmcp.js +167 -0
- package/dist/commands/audit-webmcp.js.map +1 -0
- package/dist/commands/doctor.d.ts +69 -0
- package/dist/commands/doctor.js +760 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/remove.js +43 -8
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/scan.js +1 -1
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/update.js +12 -4
- package/dist/commands/update.js.map +1 -1
- package/dist/discovery/github-skill-bundle.d.ts +74 -0
- package/dist/discovery/github-skill-bundle.js +323 -0
- package/dist/discovery/github-skill-bundle.js.map +1 -0
- package/dist/eval/skill-scanner.js +7 -1
- package/dist/eval/skill-scanner.js.map +1 -1
- package/dist/eval-server/install-skill-routes.js +10 -3
- package/dist/eval-server/install-skill-routes.js.map +1 -1
- package/dist/eval-server/remove-skill-routes.js +262 -29
- package/dist/eval-server/remove-skill-routes.js.map +1 -1
- package/dist/eval-server/studio-privacy.js +2 -3
- package/dist/eval-server/studio-privacy.js.map +1 -1
- package/dist/eval-server/utils/scan-install-locations.js +22 -3
- package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
- package/dist/eval-ui/assets/{CreateSkillPage-BUrgworx.js → CreateSkillPage-CXDd6gDN.js} +1 -1
- package/dist/eval-ui/assets/{FindSkillsPalette-BstJ22TI.js → FindSkillsPalette-DQ6uGhFP.js} +2 -2
- package/dist/eval-ui/assets/{SearchPaletteCore-Dagnc-E7.js → SearchPaletteCore-DpHCf0mf.js} +1 -1
- package/dist/eval-ui/assets/{SkillDetailPanel-DnujVk6g.js → SkillDetailPanel-BjHFjGxg.js} +1 -1
- package/dist/eval-ui/assets/{UpdateDropdown-B7D2vXrm.js → UpdateDropdown-Cn4Vsq5d.js} +1 -1
- package/dist/eval-ui/assets/main-BmB7Zj9I.js +86 -0
- package/dist/eval-ui/index.html +1 -1
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/bundle-files.d.ts +39 -2
- package/dist/installer/bundle-files.js +80 -10
- package/dist/installer/bundle-files.js.map +1 -1
- package/dist/installer/canonical.d.ts +39 -4
- package/dist/installer/canonical.js +178 -24
- package/dist/installer/canonical.js.map +1 -1
- package/dist/installer/multi-install.d.ts +11 -2
- package/dist/installer/multi-install.js +41 -9
- package/dist/installer/multi-install.js.map +1 -1
- package/dist/installer/transformers/index.d.ts +8 -0
- package/dist/installer/yaml-safe-mutate.js +48 -2
- package/dist/installer/yaml-safe-mutate.js.map +1 -1
- package/dist/lib/github-fetch.js +2 -0
- package/dist/lib/github-fetch.js.map +1 -1
- package/dist/lib/private-source.d.ts +8 -0
- package/dist/lib/private-source.js +16 -2
- package/dist/lib/private-source.js.map +1 -1
- package/dist/scanner/hidden-content.d.ts +47 -0
- package/dist/scanner/hidden-content.js +904 -0
- package/dist/scanner/hidden-content.js.map +1 -0
- package/dist/scanner/installer-hosts.d.ts +26 -0
- package/dist/scanner/installer-hosts.js +92 -0
- package/dist/scanner/installer-hosts.js.map +1 -0
- package/dist/scanner/judge-guard.d.ts +119 -0
- package/dist/scanner/judge-guard.js +481 -0
- package/dist/scanner/judge-guard.js.map +1 -0
- package/dist/scanner/patterns.d.ts +12 -0
- package/dist/scanner/patterns.js +353 -113
- package/dist/scanner/patterns.js.map +1 -1
- package/dist/scanner/repo-scanner.d.ts +4 -0
- package/dist/scanner/repo-scanner.js +4 -1
- package/dist/scanner/repo-scanner.js.map +1 -1
- package/dist/scanner/tier1.js +37 -7
- package/dist/scanner/tier1.js.map +1 -1
- package/dist/scanner/tier2-llm.d.ts +22 -4
- package/dist/scanner/tier2-llm.js +163 -23
- package/dist/scanner/tier2-llm.js.map +1 -1
- package/dist/scanner/types.d.ts +8 -0
- package/dist/webmcp/args.d.ts +34 -0
- package/dist/webmcp/args.js +87 -0
- package/dist/webmcp/args.js.map +1 -0
- package/dist/webmcp/attribution.d.ts +38 -0
- package/dist/webmcp/attribution.js +141 -0
- package/dist/webmcp/attribution.js.map +1 -0
- package/dist/webmcp/enumerate.d.ts +81 -0
- package/dist/webmcp/enumerate.js +436 -0
- package/dist/webmcp/enumerate.js.map +1 -0
- package/dist/webmcp/format.d.ts +9 -0
- package/dist/webmcp/format.js +105 -0
- package/dist/webmcp/format.js.map +1 -0
- package/dist/webmcp/merge.d.ts +49 -0
- package/dist/webmcp/merge.js +167 -0
- package/dist/webmcp/merge.js.map +1 -0
- package/dist/webmcp/score.d.ts +70 -0
- package/dist/webmcp/score.js +586 -0
- package/dist/webmcp/score.js.map +1 -0
- package/dist/webmcp/types.d.ts +92 -0
- package/dist/webmcp/types.js +8 -0
- package/dist/webmcp/types.js.map +1 -0
- package/package.json +1 -1
- package/dist/eval-ui/assets/main-DjA5gpjV.js +0 -86
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Hidden-content detection — instructions a human reviewer cannot see but an
|
|
3
|
+
// agent reads verbatim: Unicode tag smuggling, variation-selector and
|
|
4
|
+
// zero-width steganography, bidi overrides, directives inside HTML comments,
|
|
5
|
+
// printable text hidden in base64, and look-alike domains.
|
|
6
|
+
//
|
|
7
|
+
// Ported from vskill-platform src/lib/scanner/hidden-content.ts; keep the two
|
|
8
|
+
// in step when rules change.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export const HIDDEN_CONTENT_RULES = [
|
|
11
|
+
{ id: "HC-001", name: "Unicode tag smuggling", severity: "critical", description: "Invisible Unicode tag characters (U+E0000-E007F) that spell out hidden ASCII text" },
|
|
12
|
+
{ id: "HC-002", name: "Bidirectional override", severity: "high", description: "Bidi embedding/override/isolate controls that make text render differently from what an agent reads (Trojan Source)" },
|
|
13
|
+
{ id: "HC-003", name: "Zero-width steganography", severity: "high", description: "Runs of zero-width characters that can encode hidden data" },
|
|
14
|
+
{ id: "HC-004", name: "Variation-selector smuggling", severity: "high", description: "Runs of variation selectors that encode hidden bytes" },
|
|
15
|
+
{ id: "HC-005", name: "Hidden directive in comment", severity: "high", description: "HTML or markdown comment containing instructions, secrecy demands, or commands that render invisibly" },
|
|
16
|
+
{ id: "HC-006", name: "Encoded text payload", severity: "medium", description: "Long base64 blob that decodes to readable text" },
|
|
17
|
+
{ id: "HC-007", name: "Look-alike domain", severity: "medium", description: "URL host with non-ASCII or punycode labels (IDN homograph)" },
|
|
18
|
+
{ id: "HC-008", name: "Mixed-script word", severity: "low", description: "Word mixing Latin with Cyrillic or Greek letters (homoglyph)" },
|
|
19
|
+
{ id: "HC-009", name: "Scanner manipulation", severity: "high", description: "Text that forges the review prompt's delimiters, or addresses the automated reviewer from a comment, link definition, or invisible or encoded text" },
|
|
20
|
+
{ id: "HC-010", name: "Unscanned oversized file", severity: "medium", description: "A file exceeded the scanner's size limit; the remainder was not scanned" },
|
|
21
|
+
{ id: "HC-011", name: "Opaque binary in bundle", severity: "high", description: "Compiled or binary file (.pyc, .exe, .so, ...) shipped with the skill; its behavior cannot be reviewed" },
|
|
22
|
+
// HC-012 and HC-013 are platform-only (VirusTotal findings).
|
|
23
|
+
{ id: "HC-014", name: "Text addressed to an AI reviewer", severity: "medium", description: "Visible text that addresses an AI or LLM reviewer, judge or grader, or a grading rubric hidden in a comment; the judge reviews it, but it does not cap the score" },
|
|
24
|
+
];
|
|
25
|
+
const RULES = new Map(HIDDEN_CONTENT_RULES.map((r) => [r.id, r]));
|
|
26
|
+
const MAX_MATCH = 200;
|
|
27
|
+
const MAX_PAYLOAD = 4000;
|
|
28
|
+
/** Base64 characters decoded per blob; bounds work on large embedded binaries. */
|
|
29
|
+
const MAX_BASE64_DECODE = 200_000;
|
|
30
|
+
/** Decoded characters kept per payload for rescanning; a blob decodes to fewer. */
|
|
31
|
+
const MAX_PAYLOAD_SCAN = MAX_BASE64_DECODE;
|
|
32
|
+
const MIN_BASE64 = 120;
|
|
33
|
+
/** Characters of each context line kept around the match column. */
|
|
34
|
+
const CONTEXT_WINDOW = 200;
|
|
35
|
+
/**
|
|
36
|
+
* Longest context kept on a finding, after invisible characters are revealed
|
|
37
|
+
* (CLI-only bound: an escaped character is up to ten times longer than the
|
|
38
|
+
* raw one). Each of the three context lines gets a third.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_CONTEXT = 600;
|
|
41
|
+
const MAX_CONTEXT_LINE = Math.floor((MAX_CONTEXT - 2) / 3);
|
|
42
|
+
/** Findings listed per rule; the rest are counted on the last one listed. */
|
|
43
|
+
const MAX_FINDINGS_PER_RULE = 100;
|
|
44
|
+
const TAG_RUN_RE = /[\u{E0000}-\u{E007F}]+/gu;
|
|
45
|
+
// Subdivision flag emoji: U+1F3F4, a lowercase region + subdivision code in
|
|
46
|
+
// tag letters/digits, then CANCEL TAG (England, Scotland, Wales).
|
|
47
|
+
const FLAG_TAG_SEQ_RE = /^[\u{E0061}-\u{E007A}]{2}[\u{E0030}-\u{E0039}\u{E0061}-\u{E007A}]{1,4}\u{E007F}/u;
|
|
48
|
+
const BLACK_FLAG = 0x1f3f4;
|
|
49
|
+
const VS_RUN_RE = /[︀-️\u{E0100}-\u{E01EF}]{4,}/gu;
|
|
50
|
+
const ZW_RUN_RE = /[-]{4,}/g;
|
|
51
|
+
const BIDI_RE = /[--]/g;
|
|
52
|
+
const BIDI_OVERRIDE_RE = /[-]/;
|
|
53
|
+
const BASE64_RE = /(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{120,}={0,2}(?![A-Za-z0-9+/=_-])/g;
|
|
54
|
+
const BASE64_LINE_RE = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
55
|
+
const DATA_URI_TAIL_RE = /data:[\w.+-]+\/[\w.+-]+(?:;[\w=-]+)*;base64,$/i;
|
|
56
|
+
const URL_HOST_RE = /\bhttps?:\/\/([^\s/?#)"'<>\]]+)/gi;
|
|
57
|
+
const WORD_RE = /[\p{L}]{4,}/gu;
|
|
58
|
+
const LATIN_RE = /\p{Script=Latin}/u;
|
|
59
|
+
const CONFUSABLE_SCRIPT_RE = /[\p{Script=Cyrillic}\p{Script=Greek}]/u;
|
|
60
|
+
/** Decoded hidden text that reads as words, not a few stray bytes. */
|
|
61
|
+
const READABLE_RE = /[A-Za-z]{2,}[^\S\n]+[A-Za-z]{2,}/;
|
|
62
|
+
const INVISIBLE_RE = /[---︀-️\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu;
|
|
63
|
+
// Markdown structure that decides whether `<!--` opens a hidden comment.
|
|
64
|
+
const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
65
|
+
const FENCE_CLOSE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
66
|
+
const HTML_BLOCK_TAGS = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul";
|
|
67
|
+
// HTML blocks pass through raw: backticks and fences inside them are literal.
|
|
68
|
+
const HTML_BLOCK_RE = new RegExp(`^ {0,3}(?:<\\/?(?:${HTML_BLOCK_TAGS})(?:[\\s/>]|$)|<[A-Za-z][A-Za-z0-9-]*(?:\\s[^<>]*)?\\/?>[ \\t]*$|<\\/[A-Za-z][A-Za-z0-9-]*\\s*>[ \\t]*$|<\\?|<![A-Za-z]|<!\\[CDATA\\[)`, "i");
|
|
69
|
+
const HTML_RAW_RE = /^ {0,3}<(?:pre|script|style|textarea)(?:[\s>]|$)/i;
|
|
70
|
+
const HTML_RAW_END_RE = /<\/(?:pre|script|style|textarea)>/i;
|
|
71
|
+
// Link reference definitions never render; their titles hide text.
|
|
72
|
+
const LINK_DEF_RE = /^ {0,3}\[[^\]]{1,999}\]:[ \t]*(?:<([^<>\n]*)>|(\S+))?(.*)$/;
|
|
73
|
+
const LINK_DEST_RE = /^\s*(?:<([^<>\n]*)>|(\S+))/;
|
|
74
|
+
const LINK_TITLE_RE = /^\s*(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)'|\(((?:[^()\\]|\\[\s\S])*)\))[ \t]*(?:\n|$)/;
|
|
75
|
+
const LINK_TITLE_LOOSE_RE = /^[ \t]+(?:"(.*)"|'(.*)'|\((.*)\))[ \t]*$/;
|
|
76
|
+
const SECRET_REF = String.raw `(?:~\/\.ssh\b|\bid_rsa\b|\.aws\/credentials\b|\.env(?!\.(?:example|sample|template|dist)\b)(?:\.\w[\w-]*)?(?!\w|\.\w)|\bapi[_-]?keys?\b|\baccess[_-]?tokens?\b|\bprivate\s+keys?\b)`;
|
|
77
|
+
// The agent reading the skill, named as such.
|
|
78
|
+
const AGENT = String.raw `(?:agents?|assistants?|models?|AIs?|LLMs?|Claude|(?:Chat)?GPT|copilot|chatbots?|bots?)`;
|
|
79
|
+
const OVERRIDE = String.raw `(?:ignore|disregard|override|forget|bypass)`;
|
|
80
|
+
const RULESET = String.raw `(?:instructions?|prompts?|rules?|guidelines?|guidance|polic(?:y|ies)|restrictions?|directives?|guardrails?|safety)`;
|
|
81
|
+
// What a directive does to the user, their secrets, or the agent's tools.
|
|
82
|
+
const AGENT_TARGET = String.raw `(?:(?:the|your|any)\s+(?:users?|operators?|humans?)|secrets?|credentials?|access\s+tokens?|passwords?|api[_-]?keys?|ssh|tools?|permissions?|approvals?|confirm(?:ation)?s?|exfiltrat\w*|upload\w*|send|forward|curl|wget)`;
|
|
83
|
+
// Tooling rule sets a maintainer tells a linter or formatter to skip; they
|
|
84
|
+
// are not the agent's instructions ("ignore any previous lint rules").
|
|
85
|
+
const TOOLING = String.raw `(?:[\w-]*lint(?:ers?|ing)?|prettier|format(?:ters?|ting)?|styles?|type-?check(?:ers?|ing)?)`;
|
|
86
|
+
// Comment bodies that address the agent rather than the human reader. Lint
|
|
87
|
+
// and TODO notes ("ignore the line-length rules", "from now on use v2")
|
|
88
|
+
// address maintainers; the agent forms name the agent, its own instructions,
|
|
89
|
+
// or the user, secrets, exfiltration or tools.
|
|
90
|
+
const DIRECTIVE_RES = [
|
|
91
|
+
new RegExp(String.raw `\b${OVERRIDE}\b[^.\n]{0,40}?\b(?:previous|prior|above|earlier|preceding|original|initial|your|its|system|developer|safety|security|(?:users?|operators?|humans?)(?:'s?)?)\s+(?!(?:[\w-]+\s+){0,2}?${TOOLING}\b)(?:[\w-]+\s+){0,2}?${RULESET}\b`, "i"),
|
|
92
|
+
new RegExp(String.raw `\b${OVERRIDE}\b[^.\n]{0,20}?\b(?:safety|guardrails?|content\s+polic(?:y|ies))\b`, "i"),
|
|
93
|
+
new RegExp(String.raw `\b${AGENT}\b[^.\n]{0,60}?\b${OVERRIDE}\b[^.\n]{0,60}?\b${RULESET}\b`, "i"),
|
|
94
|
+
new RegExp(String.raw `\b${OVERRIDE}\b[^.\n]{0,60}?\b${RULESET}\b[^.\n]{0,80}?\b(?:${AGENT}|${AGENT_TARGET})\b`, "i"),
|
|
95
|
+
/\b(?:do\s+not|don'?t|never)\s+(?:tell|inform|mention|reveal|show|notify|alert|ask)\b[^.\n]{0,40}\b(?:user|human|operator|anyone)\b/i,
|
|
96
|
+
/\bwithout\s+(?:telling|informing|asking|notifying|alerting)\b/i,
|
|
97
|
+
/\b(?:silently|secretly|covertly|quietly)\s+(?:run|execute|send|upload|post|copy|read|install|fetch|download|exfiltrate)\b/i,
|
|
98
|
+
/\b(?:curl|wget|iwr|Invoke-WebRequest)\b[^\n]{0,300}\|\s*(?:(?:ba|z|da)?sh|iex|Invoke-Expression)\b/i,
|
|
99
|
+
// Network tools uploading a local file or command output, not plain fetches.
|
|
100
|
+
/\b(?:curl|wget|Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\b[^\n]{0,300}?(?:\s(?:-T|--upload-file|--post-file|-InFile)\b|\s(?:-d|--data(?:-[a-z]+)?|-F|--form)[\s=]*['"]?(?:[^\s'"]*@|\$\())/i,
|
|
101
|
+
/\b(?:nc|ncat|netcat)\s[^\n]{0,100}?(?:<\s*[~/.$\w]|\s-e\s)/i,
|
|
102
|
+
/\b(?:send|upload|post|exfiltrate|forward)\b[^.\n]{0,60}\b(?:to|at)\s+https?:\/\//i,
|
|
103
|
+
// CLI: "system prompt" and "new instructions" only as orders to an agent,
|
|
104
|
+
// not in "the system prompt lives below".
|
|
105
|
+
/\b(?:reveal|print|show|output|leak|repeat|dump|disclose|ignore|disregard|forget|override|replace)\s+(?:(?:the|your|my|its|this|that|entire|full|original|hidden|whole)\s+){0,3}system\s+prompts?\b|\b(?:new|updated|real|actual|true|override)\s+system\s+prompt\b|\bsystem\s+prompt\s*(?::|=|override\b|is\s+now\b)/i,
|
|
106
|
+
/\b(?:your|here\s+are\s+(?:your|the)|these\s+are\s+(?:your|the)|the\s+following\s+are\s+(?:your|the))\s+new\s+instructions\b|\bnew\s+instructions\s*:/i,
|
|
107
|
+
/\byou\s+are\s+now\b/i,
|
|
108
|
+
new RegExp(String.raw `\bfrom\s+now\s+on\b[^.\n]{0,80}?\b(?:you|your|${AGENT}|${AGENT_TARGET}|silently|secretly|never)\b|\b(?:you|${AGENT})\b[^.\n]{0,40}?\bfrom\s+now\s+on\b`, "i"),
|
|
109
|
+
new RegExp(`${SECRET_REF}[^.\\n]{0,60}\\b(?:send|upload|post|read|copy|cat)\\b`, "i"),
|
|
110
|
+
new RegExp(`\\b(?:send|upload|post|read|copy|cat)\\b[^.\\n]{0,60}${SECRET_REF}`, "i"),
|
|
111
|
+
];
|
|
112
|
+
// Reviewers that are named as automated ("AI reviewers", "LLM-judge",
|
|
113
|
+
// "security analyst"), and bare roles that only count next to a verdict.
|
|
114
|
+
const AI_JUDGE = String.raw `(?:(?:LLM|AI|automated|automatic)[\s-]+(?:security[\s-]+)?(?:code[\s-]+)?(?:review(?:er)?s?|judges?|graders?|auditors?|scanners?|analy[sz]ers?|analysts?|evaluators?)|security[\s-]+(?:scanners?|analysts?))`;
|
|
115
|
+
const ROLE = String.raw `(?:reviewers?|graders?|judges?|evaluators?|analysts?)`;
|
|
116
|
+
const SAFE_WORD = String.raw `(?:safe|clean|benign|harmless|trusted|legitimate|non-?malicious|not\s+malicious)`;
|
|
117
|
+
// The thing under review: "it", "this skill", "the submitted package".
|
|
118
|
+
const SELF = String.raw `(?:it|this(?:\s+[\w-]+)?|the\s+(?:[\w-]+\s+)?(?:skill|plugin|package|file|code|submission|repo(?:sitory)?|content))`;
|
|
119
|
+
// Verdict or score requests: "classify this skill as benign", "rate it 100",
|
|
120
|
+
// "return score 97", "return PASS".
|
|
121
|
+
const VERDICT = String.raw `(?:(?:mark|rate|score|grade|treat|classify|consider|label|report|deem|flag)\s+${SELF}\s+as\s+(?:an?\s+)?${SAFE_WORD}\b|(?:score|rate|grade)\s+${SELF}\s+(?:at\s+|an?\s+)?\d{2,3}\b|(?:return|give|assign|output|report)\s+(?:it\s+|this(?:\s+[\w-]+)?\s+)?(?:an?\s+)?(?:score|rating)(?:\s+of)?\s*[:=]?\s*\d{2,3}\b|(?:return|output|respond\s+with)\s+(?:an?\s+)?(?:verdict\s*(?:of\s*)?[:=]?\s*)?["'\x60]?PASS\b)`;
|
|
122
|
+
// Only an automated reviewer is asked to approve or pass a skill.
|
|
123
|
+
const APPROVAL = String.raw `(?:(?:approve|pass|clear|accept|allow|whitelist|trust)\s+(?:this|the)\s+(?:skill|plugin|package|file|code|submission)\b|(?:mark|rate|treat|classify|consider|label|report)\s+${SELF}\s+as\s+(?:verified|approved|pass(?:ed|ing)?)\b)`;
|
|
124
|
+
// The judge prompt's own section markers (tier2.ts, judge-guard.ts, and the
|
|
125
|
+
// source-code markers of earlier prompts). Forging them is always HC-009.
|
|
126
|
+
const JUDGE_FORGERY_RES = [
|
|
127
|
+
/--\s*(?:END\s+CRITICAL\s+FINDINGS|(?:CRITICAL|TIER\s+1)\s+FINDINGS\s+TO\s+VALIDATE|(?:END\s+(?:OF\s+)?)?SKILL\s+SOURCE\s+CODE|END\s+(?:OF\s+)?SOURCE\s+CODE)\s*--/i,
|
|
128
|
+
/UNTRUSTED_SKILL_|DECODED\s+HIDDEN\s+CONTENT/i,
|
|
129
|
+
// CLI judge prompt (tier2-llm.ts): Markdown section headings, not dashes.
|
|
130
|
+
/^[ \t]{0,3}#{1,6}[ \t]*(?:CRITICAL\s+FINDINGS\s+TO\s+VALIDATE|TIER\s+1\s+SCANNER\s+FINDINGS)\b/im,
|
|
131
|
+
/\bReturn\s+one\s+criticalValidation\s+entry\b/i,
|
|
132
|
+
];
|
|
133
|
+
// Text aimed at an AI reviewer or judge. Code-review and eval skills write
|
|
134
|
+
// this in plain sight, so visible text is HC-014 (the judge must run, the
|
|
135
|
+
// score is not capped). Hidden channels use HIDDEN_JUDGE_RES below.
|
|
136
|
+
// Multiline so `^` anchors in comment bodies.
|
|
137
|
+
const JUDGE_PROSE_RES = [
|
|
138
|
+
new RegExp(String.raw `\b${AI_JUDGE}\b[^.\n]{0,80}?\b(?:${VERDICT}|${APPROVAL})`, "im"),
|
|
139
|
+
new RegExp(String.raw `\b${ROLE}\b[^.\n]{0,80}?\b${VERDICT}`, "im"),
|
|
140
|
+
new RegExp(String.raw `\b(?:notes?|messages?|instructions?|directions?|reminders?|guidance|hints?)\s+(?:to|for)\s+(?:the\s+|all\s+|any\s+|an?\s+)?${AI_JUDGE}\b`, "im"),
|
|
141
|
+
new RegExp(String.raw `\b${AI_JUDGE}\s+(?:notes?|messages?|instructions?|directions?|reminders?)\b`, "im"),
|
|
142
|
+
new RegExp(String.raw `\b(?:dear|hey|hello|attention)\s*,?\s+(?:the\s+|all\s+|any\s+)?${AI_JUDGE}\b|^[ \t#>*_-]*(?:to\s+(?:the\s+|all\s+|any\s+)?)?${AI_JUDGE}\s*[:,]`, "im"),
|
|
143
|
+
];
|
|
144
|
+
/** The bare reviewer or grader role next to a verdict (JUDGE_PROSE_RES[1]). */
|
|
145
|
+
const ROLE_VERDICT_INDEX = 1;
|
|
146
|
+
const STRONG_PROSE_RES = JUDGE_PROSE_RES.filter((_, k) => k !== ROLE_VERDICT_INDEX);
|
|
147
|
+
// Addressees of hidden text: the named automated reviewers plus the model
|
|
148
|
+
// itself. Bare reviewer and grader roles are not here; eval rubrics address
|
|
149
|
+
// them in comments too.
|
|
150
|
+
const AI_ADDRESSEE = String.raw `(?:${AI_JUDGE}|(?:(?:reviewing|grading|evaluating|judging|scanning|scoring|auditing)\s+)?(?:(?:large\s+)?language\s+models?|models?|assistants?|LLMs?|AIs?|Claude|(?:Chat)?GPT(?:-?[\w.]+)?|Llama(?:-?[\w.]+)?|Gemini))`;
|
|
151
|
+
const HIDDEN_VERDICT = String.raw `(?:${VERDICT}|${APPROVAL}|verdict\s*(?:of|is|=|:)?\s*["'\x60]?PASS\b)`;
|
|
152
|
+
// A sentence that grades against criteria ("PASS or FAIL for each item",
|
|
153
|
+
// "100 for an exact match and 0 otherwise") is a rubric, not a plea.
|
|
154
|
+
const RUBRIC_RE = /\b(?:otherwise|or\s+["'`]?(?:FAIL|REJECT|lower|less|zero|\d)|for\s+(?:each|every)|per\s+(?:item|row|test|case|question|criterion|check|answer)|(?:exact|correct|perfect|full|complete|right)\s+(?:match|answers?|solutions?|responses?|outputs?)|rubric|criteri(?:on|a)|every\s+(?:item|row|test|case|check|question|requirement|answer)|all\s+(?:the\s+)?(?:tests?|checks?|items?|rows?|criteria|requirements|cases|questions|answers))\b/i;
|
|
155
|
+
// Text in a comment, link definition, or invisible or encoded text that asks
|
|
156
|
+
// for a verdict is HC-009. A verdict or approval request needs no addressee
|
|
157
|
+
// there, unless it is part of a grading rubric.
|
|
158
|
+
const HIDDEN_JUDGE_RES = [
|
|
159
|
+
...STRONG_PROSE_RES.map((re) => ({ re: new RegExp(re.source, "gim"), rubricExempt: false })),
|
|
160
|
+
{ re: new RegExp(String.raw `\b${AI_ADDRESSEE}\b[^.\n]{0,80}?\b${HIDDEN_VERDICT}`, "gim"), rubricExempt: true },
|
|
161
|
+
{ re: new RegExp(String.raw `\b(?:${VERDICT}|${APPROVAL})`, "gim"), rubricExempt: true },
|
|
162
|
+
];
|
|
163
|
+
const ROLE_VERDICT_GLOBAL_RE = new RegExp(JUDGE_PROSE_RES[ROLE_VERDICT_INDEX].source, "gim");
|
|
164
|
+
/** Characters searched on each side of a match for its sentence. */
|
|
165
|
+
const SENTENCE_REACH = 160;
|
|
166
|
+
function isSentenceEnd(c) {
|
|
167
|
+
return c === 46 || c === 10 || c === 59;
|
|
168
|
+
}
|
|
169
|
+
/** Whether the sentence holding text[start, end) reads as a grading rubric. */
|
|
170
|
+
function inRubric(text, start, end) {
|
|
171
|
+
let s = start;
|
|
172
|
+
while (s > 0 && start - s < SENTENCE_REACH && !isSentenceEnd(text.charCodeAt(s - 1)))
|
|
173
|
+
s--;
|
|
174
|
+
let e = end;
|
|
175
|
+
while (e < text.length && e - end < SENTENCE_REACH && !isSentenceEnd(text.charCodeAt(e)))
|
|
176
|
+
e++;
|
|
177
|
+
return RUBRIC_RE.test(text.slice(s, e));
|
|
178
|
+
}
|
|
179
|
+
/** Judge-directed text in a hidden channel, strong hits first. */
|
|
180
|
+
function* hiddenJudgeHits(text) {
|
|
181
|
+
for (const { re, rubricExempt } of HIDDEN_JUDGE_RES) {
|
|
182
|
+
re.lastIndex = 0;
|
|
183
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
184
|
+
if (m[0].length === 0) {
|
|
185
|
+
re.lastIndex++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (rubricExempt && inRubric(text, m.index, m.index + m[0].length))
|
|
189
|
+
continue;
|
|
190
|
+
yield { index: m.index, match: m[0], strong: true };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const re = ROLE_VERDICT_GLOBAL_RE;
|
|
194
|
+
re.lastIndex = 0;
|
|
195
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
196
|
+
if (m[0].length === 0)
|
|
197
|
+
re.lastIndex++;
|
|
198
|
+
else
|
|
199
|
+
yield { index: m.index, match: m[0], strong: false };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** The first hit in `text`; a strong one when there is any. */
|
|
203
|
+
function firstHiddenJudgeHit(text) {
|
|
204
|
+
const next = hiddenJudgeHits(text).next();
|
|
205
|
+
return next.done ? null : next.value;
|
|
206
|
+
}
|
|
207
|
+
/** Make invisible characters visible so findings can be read and logged safely. */
|
|
208
|
+
export function revealInvisible(text) {
|
|
209
|
+
return text.replace(INVISIBLE_RE, (ch) => `\\u{${ch.codePointAt(0).toString(16).toUpperCase()}}`);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* A flat copy of a short string. Slices of a large string keep the whole
|
|
213
|
+
* parent alive; findings must not pin a multi-megabyte line.
|
|
214
|
+
*/
|
|
215
|
+
function flat(text) {
|
|
216
|
+
return text.length < 16 ? text : text.split("").join("");
|
|
217
|
+
}
|
|
218
|
+
function isHighSurrogate(c) {
|
|
219
|
+
return c >= 0xd800 && c <= 0xdbff;
|
|
220
|
+
}
|
|
221
|
+
/** `text.slice(start, end)` without splitting a surrogate pair at either end. */
|
|
222
|
+
function safeSlice(text, start, end) {
|
|
223
|
+
let s = Math.max(0, start);
|
|
224
|
+
let e = Math.min(text.length, end);
|
|
225
|
+
if (s > 0 && s < text.length && isHighSurrogate(text.charCodeAt(s - 1)))
|
|
226
|
+
s++;
|
|
227
|
+
if (e < text.length && e > s && isHighSurrogate(text.charCodeAt(e - 1)))
|
|
228
|
+
e--;
|
|
229
|
+
return text.slice(s, e);
|
|
230
|
+
}
|
|
231
|
+
function clip(text, max = MAX_MATCH) {
|
|
232
|
+
return flat(text.length > max ? `${safeSlice(text, 0, max)}…` : text);
|
|
233
|
+
}
|
|
234
|
+
/** clip(revealInvisible(text)) without revealing more of the text than is kept. */
|
|
235
|
+
function revealClip(text, max = MAX_MATCH) {
|
|
236
|
+
const head = text.length > max ? safeSlice(text, 0, max) : text;
|
|
237
|
+
const shown = revealInvisible(head);
|
|
238
|
+
return shown.length > max || head.length < text.length ? flat(`${safeSlice(shown, 0, max)}…`) : flat(shown);
|
|
239
|
+
}
|
|
240
|
+
/** About `CONTEXT_WINDOW` characters of a line around `column`. */
|
|
241
|
+
function windowAround(text, column) {
|
|
242
|
+
if (text.length <= CONTEXT_WINDOW)
|
|
243
|
+
return text;
|
|
244
|
+
const start = Math.max(0, Math.min(column - CONTEXT_WINDOW / 2, text.length - CONTEXT_WINDOW));
|
|
245
|
+
return safeSlice(text, start, start + CONTEXT_WINDOW);
|
|
246
|
+
}
|
|
247
|
+
function decodeTags(run) {
|
|
248
|
+
let out = "";
|
|
249
|
+
for (const ch of run) {
|
|
250
|
+
const cp = ch.codePointAt(0) - 0xe0000;
|
|
251
|
+
if (cp >= 0x20 && cp <= 0x7e)
|
|
252
|
+
out += String.fromCharCode(cp);
|
|
253
|
+
else if (cp === 0x0a || cp === 0x0d || cp === 0x09)
|
|
254
|
+
out += " ";
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
function decodeVariationSelectors(run) {
|
|
259
|
+
const bytes = [];
|
|
260
|
+
for (const ch of run) {
|
|
261
|
+
const cp = ch.codePointAt(0);
|
|
262
|
+
if (cp >= 0xfe00 && cp <= 0xfe0f)
|
|
263
|
+
bytes.push(cp - 0xfe00);
|
|
264
|
+
else if (cp >= 0xe0100 && cp <= 0xe01ef)
|
|
265
|
+
bytes.push(cp - 0xe0100 + 16);
|
|
266
|
+
}
|
|
267
|
+
return printableOrEmpty(new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes)));
|
|
268
|
+
}
|
|
269
|
+
function decodeZeroWidthBinary(run) {
|
|
270
|
+
const chars = [...run];
|
|
271
|
+
const distinct = [...new Set(chars)];
|
|
272
|
+
if (distinct.length !== 2 || chars.length % 8 !== 0)
|
|
273
|
+
return "";
|
|
274
|
+
const [zero, one] = distinct.includes("") ? ["", distinct.find((c) => c !== "")] : distinct;
|
|
275
|
+
const bytes = [];
|
|
276
|
+
for (let i = 0; i < chars.length; i += 8) {
|
|
277
|
+
let b = 0;
|
|
278
|
+
for (let j = 0; j < 8; j++)
|
|
279
|
+
b = (b << 1) | (chars[i + j] === one ? 1 : chars[i + j] === zero ? 0 : 0);
|
|
280
|
+
bytes.push(b);
|
|
281
|
+
}
|
|
282
|
+
return printableOrEmpty(new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes)));
|
|
283
|
+
}
|
|
284
|
+
/** Returns the text when it is mostly printable, otherwise "". */
|
|
285
|
+
function printableOrEmpty(text) {
|
|
286
|
+
if (!text)
|
|
287
|
+
return "";
|
|
288
|
+
let printable = 0;
|
|
289
|
+
for (const ch of text) {
|
|
290
|
+
const cp = ch.codePointAt(0);
|
|
291
|
+
if (cp === 0x09 || cp === 0x0a || cp === 0x0d || (cp >= 0x20 && cp !== 0x7f && cp !== 0xfffd))
|
|
292
|
+
printable++;
|
|
293
|
+
}
|
|
294
|
+
return printable / [...text].length >= 0.9 ? text : "";
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Decode standard or URL-safe base64 to readable text. Tries all four
|
|
298
|
+
* alignments so a few glued-on characters in front cannot hide the payload.
|
|
299
|
+
*/
|
|
300
|
+
function decodeBase64Text(blob) {
|
|
301
|
+
const norm = blob.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/").slice(0, MAX_BASE64_DECODE);
|
|
302
|
+
for (let offset = 0; offset < 4 && norm.length - offset >= 4; offset++) {
|
|
303
|
+
let s = norm.slice(offset);
|
|
304
|
+
if (s.length % 4 === 1)
|
|
305
|
+
s = s.slice(0, -1);
|
|
306
|
+
s += "=".repeat((4 - (s.length % 4)) % 4);
|
|
307
|
+
try {
|
|
308
|
+
const bin = atob(s);
|
|
309
|
+
const bytes = new Uint8Array(bin.length);
|
|
310
|
+
for (let i = 0; i < bin.length; i++)
|
|
311
|
+
bytes[i] = bin.charCodeAt(i);
|
|
312
|
+
const text = printableOrEmpty(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
|
313
|
+
// Readable prose or commands contain spaces; hashes and keys do not.
|
|
314
|
+
if (/\s/.test(text))
|
|
315
|
+
return text;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// not base64 at this alignment
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return "";
|
|
322
|
+
}
|
|
323
|
+
function isBase64Char(c) {
|
|
324
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c === 43 || c === 47 || c === 45 || c === 95;
|
|
325
|
+
}
|
|
326
|
+
/** Trailing base64 characters of a line that may continue on the next lines. */
|
|
327
|
+
function base64Tail(text) {
|
|
328
|
+
if (text.endsWith("="))
|
|
329
|
+
return "";
|
|
330
|
+
let i = text.length;
|
|
331
|
+
while (i > 0 && isBase64Char(text.charCodeAt(i - 1)))
|
|
332
|
+
i--;
|
|
333
|
+
return text.slice(i);
|
|
334
|
+
}
|
|
335
|
+
function joinCapped(parts, from, prefix = "") {
|
|
336
|
+
let out = prefix;
|
|
337
|
+
for (let k = from; k < parts.length && out.length < MAX_BASE64_DECODE + 4; k++)
|
|
338
|
+
out += parts[k];
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/** Balanced isolates around non-Latin text are how RTL words are embedded; anything else can reorder code. */
|
|
342
|
+
function hasBidiTrick(line) {
|
|
343
|
+
if (BIDI_OVERRIDE_RE.test(line))
|
|
344
|
+
return true;
|
|
345
|
+
const latin = [];
|
|
346
|
+
for (let i = 0; i < line.length; i++) {
|
|
347
|
+
const c = line.charCodeAt(i);
|
|
348
|
+
if (c >= 0x2066 && c <= 0x2068)
|
|
349
|
+
latin.push(false);
|
|
350
|
+
else if (c === 0x2069) {
|
|
351
|
+
if (latin.length === 0)
|
|
352
|
+
return true;
|
|
353
|
+
if (latin.pop())
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
else if (latin.length > 0 && ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122))) {
|
|
357
|
+
latin[latin.length - 1] = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return latin.length > 0;
|
|
361
|
+
}
|
|
362
|
+
const RTL_LETTER_RE = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/g;
|
|
363
|
+
/**
|
|
364
|
+
* Bidi controls in right-to-left prose: Arabic or Hebrew text whose
|
|
365
|
+
* embeddings and isolates all close, with no overrides. This is how mixed
|
|
366
|
+
* RTL/LTR text is written; Trojan Source reordering needs an override or an
|
|
367
|
+
* unclosed control.
|
|
368
|
+
*/
|
|
369
|
+
export function isNaturalRtlBidi(text) {
|
|
370
|
+
if ((text.match(RTL_LETTER_RE) ?? []).length < 3)
|
|
371
|
+
return false;
|
|
372
|
+
const open = [];
|
|
373
|
+
for (const ch of text) {
|
|
374
|
+
const c = ch.codePointAt(0);
|
|
375
|
+
if (c === 0x202d || c === 0x202e)
|
|
376
|
+
return false;
|
|
377
|
+
if (c === 0x202a || c === 0x202b)
|
|
378
|
+
open.push(0x202c);
|
|
379
|
+
else if (c >= 0x2066 && c <= 0x2068)
|
|
380
|
+
open.push(0x2069);
|
|
381
|
+
else if ((c === 0x202c || c === 0x2069) && open.pop() !== c)
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
return open.length === 0;
|
|
385
|
+
}
|
|
386
|
+
function lineOf(content, index, lineStarts) {
|
|
387
|
+
let lo = 0;
|
|
388
|
+
let hi = lineStarts.length - 1;
|
|
389
|
+
while (lo < hi) {
|
|
390
|
+
const mid = (lo + hi + 1) >> 1;
|
|
391
|
+
if (lineStarts[mid] <= index)
|
|
392
|
+
lo = mid;
|
|
393
|
+
else
|
|
394
|
+
hi = mid - 1;
|
|
395
|
+
}
|
|
396
|
+
return lo + 1;
|
|
397
|
+
}
|
|
398
|
+
function leadingSpaces(text) {
|
|
399
|
+
let n = 0;
|
|
400
|
+
while (n < text.length && (text[n] === " " || text[n] === "\t"))
|
|
401
|
+
n += text[n] === "\t" ? 4 : 1;
|
|
402
|
+
return n;
|
|
403
|
+
}
|
|
404
|
+
function backtickRuns(text) {
|
|
405
|
+
const runs = [];
|
|
406
|
+
for (let i = text.indexOf("`"); i >= 0; i = text.indexOf("`", i)) {
|
|
407
|
+
let n = 1;
|
|
408
|
+
while (text.charCodeAt(i + n) === 96)
|
|
409
|
+
n++;
|
|
410
|
+
runs.push({ at: i, len: n, next: -1 });
|
|
411
|
+
i += n;
|
|
412
|
+
}
|
|
413
|
+
const lastByLen = new Map();
|
|
414
|
+
for (let k = runs.length - 1; k >= 0; k--) {
|
|
415
|
+
runs[k].next = lastByLen.get(runs[k].len) ?? -1;
|
|
416
|
+
lastByLen.set(runs[k].len, k);
|
|
417
|
+
}
|
|
418
|
+
return runs;
|
|
419
|
+
}
|
|
420
|
+
function escapedAt(text, i) {
|
|
421
|
+
let n = 0;
|
|
422
|
+
while (i - n - 1 >= 0 && text.charCodeAt(i - n - 1) === 92)
|
|
423
|
+
n++;
|
|
424
|
+
return n % 2 === 1;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* HTML comment bodies that render invisibly. `<!--` inside fenced code or an
|
|
428
|
+
* inline code span is shown as text, so it opens nothing; inside an HTML block
|
|
429
|
+
* backticks are literal, so it does. An unclosed `<!--` hides the rest of the
|
|
430
|
+
* document. `covered` marks lines inside code or comments.
|
|
431
|
+
*/
|
|
432
|
+
function findHiddenComments(content, lines, lineStarts) {
|
|
433
|
+
const bodies = [];
|
|
434
|
+
const covered = new Uint8Array(lines.length);
|
|
435
|
+
let fence = null;
|
|
436
|
+
let html = null;
|
|
437
|
+
let i = 0;
|
|
438
|
+
let col = 0;
|
|
439
|
+
let raw = false;
|
|
440
|
+
while (i < lines.length) {
|
|
441
|
+
const text = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
|
|
442
|
+
if (col === 0) {
|
|
443
|
+
const blank = text.trim() === "";
|
|
444
|
+
if (fence) {
|
|
445
|
+
const close = FENCE_CLOSE_RE.exec(text);
|
|
446
|
+
if (close && close[1][0] === fence.ch && close[1].length >= fence.len) {
|
|
447
|
+
covered[i] = 1;
|
|
448
|
+
fence = null;
|
|
449
|
+
i++;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (blank || leadingSpaces(text) >= fence.indent) {
|
|
453
|
+
covered[i] = 1;
|
|
454
|
+
i++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// A less-indented line ends the list item holding the fence.
|
|
458
|
+
fence = null;
|
|
459
|
+
}
|
|
460
|
+
if (html === "blank" && blank) {
|
|
461
|
+
html = null;
|
|
462
|
+
i++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (html === null) {
|
|
466
|
+
if (blank) {
|
|
467
|
+
i++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const open = FENCE_OPEN_RE.exec(text);
|
|
471
|
+
if (open && !(open[2][0] === "`" && open[3].includes("`"))) {
|
|
472
|
+
fence = { ch: open[2][0], len: open[2].length, indent: open[1].length };
|
|
473
|
+
covered[i] = 1;
|
|
474
|
+
i++;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (text.trimStart().startsWith("<")) {
|
|
478
|
+
if (HTML_RAW_RE.test(text))
|
|
479
|
+
html = "raw";
|
|
480
|
+
else if (HTML_BLOCK_RE.test(text))
|
|
481
|
+
html = "blank";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
raw = html !== null;
|
|
485
|
+
}
|
|
486
|
+
const runs = raw || !text.includes("`") ? [] : backtickRuns(text);
|
|
487
|
+
let r = 0;
|
|
488
|
+
let resume = -1;
|
|
489
|
+
let c = col;
|
|
490
|
+
while (c < text.length) {
|
|
491
|
+
const open = text.indexOf("<!--", c);
|
|
492
|
+
while (r < runs.length && runs[r].at < c)
|
|
493
|
+
r++;
|
|
494
|
+
const tick = r < runs.length ? runs[r].at : -1;
|
|
495
|
+
if (open < 0 && tick < 0)
|
|
496
|
+
break;
|
|
497
|
+
if (tick >= 0 && (open < 0 || tick < open)) {
|
|
498
|
+
const run = runs[r];
|
|
499
|
+
const close = escapedAt(text, run.at) ? -1 : run.next;
|
|
500
|
+
c = close < 0 ? run.at + run.len : runs[close].at + run.len;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const start = lineStarts[i] + open + 4;
|
|
504
|
+
// `<!-->` and `<!--->` are empty comments.
|
|
505
|
+
if (content.startsWith(">", start)) {
|
|
506
|
+
c = open + 5;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (content.startsWith("->", start)) {
|
|
510
|
+
c = open + 6;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const blockLevel = raw || text.slice(0, open).trim() === "";
|
|
514
|
+
const end = content.indexOf("-->", start);
|
|
515
|
+
if (end < 0) {
|
|
516
|
+
bodies.push({ text: content.slice(start), offset: start });
|
|
517
|
+
covered.fill(1, i);
|
|
518
|
+
return { bodies, covered };
|
|
519
|
+
}
|
|
520
|
+
bodies.push({ text: content.slice(start, end), offset: start });
|
|
521
|
+
resume = end + 3;
|
|
522
|
+
raw = blockLevel;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
if (resume < 0) {
|
|
526
|
+
if (html === "raw" && HTML_RAW_END_RE.test(text))
|
|
527
|
+
html = null;
|
|
528
|
+
i++;
|
|
529
|
+
col = 0;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
let j = i;
|
|
533
|
+
while (j + 1 < lineStarts.length && lineStarts[j + 1] <= resume)
|
|
534
|
+
j++;
|
|
535
|
+
covered.fill(1, i, j + 1);
|
|
536
|
+
if (j !== i)
|
|
537
|
+
html = null;
|
|
538
|
+
i = j;
|
|
539
|
+
col = resume - lineStarts[j];
|
|
540
|
+
}
|
|
541
|
+
return { bodies, covered };
|
|
542
|
+
}
|
|
543
|
+
/** Hidden text of a link reference definition starting on line `i`, or "". */
|
|
544
|
+
function linkDefinitionText(lines, i, covered) {
|
|
545
|
+
const m = LINK_DEF_RE.exec(lines[i]);
|
|
546
|
+
if (!m)
|
|
547
|
+
return "";
|
|
548
|
+
let dest = m[1];
|
|
549
|
+
let hasDest = m[1] !== undefined || m[2] !== undefined;
|
|
550
|
+
let rest = m[3];
|
|
551
|
+
let tail = "";
|
|
552
|
+
for (let k = i + 1; k < lines.length && k <= i + 10 && !covered[k] && lines[k].trim() !== ""; k++)
|
|
553
|
+
tail += `\n${lines[k]}`;
|
|
554
|
+
if (!hasDest) {
|
|
555
|
+
const d = LINK_DEST_RE.exec(tail);
|
|
556
|
+
if (!d)
|
|
557
|
+
return "";
|
|
558
|
+
dest = d[1];
|
|
559
|
+
hasDest = true;
|
|
560
|
+
rest = "";
|
|
561
|
+
tail = tail.slice(d[0].length);
|
|
562
|
+
}
|
|
563
|
+
let title;
|
|
564
|
+
const strict = LINK_TITLE_RE.exec(rest.trim() === "" ? tail : rest + tail);
|
|
565
|
+
if (strict)
|
|
566
|
+
title = strict[1] ?? strict[2] ?? strict[3];
|
|
567
|
+
else if (rest.trim() !== "") {
|
|
568
|
+
const loose = LINK_TITLE_LOOSE_RE.exec(rest);
|
|
569
|
+
if (!loose)
|
|
570
|
+
return "";
|
|
571
|
+
title = loose[1] ?? loose[2] ?? loose[3];
|
|
572
|
+
}
|
|
573
|
+
return [dest, title].filter((s) => !!s).join(" ");
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Scan for hidden channels. Line numbers are 1-based and global (before file
|
|
577
|
+
* boundary mapping). Decoded payloads are returned for rescanning.
|
|
578
|
+
*/
|
|
579
|
+
export function scanHiddenContent(content) {
|
|
580
|
+
const findings = [];
|
|
581
|
+
const payloads = [];
|
|
582
|
+
if (!content)
|
|
583
|
+
return { findings, payloads };
|
|
584
|
+
const lines = content.split("\n");
|
|
585
|
+
const lineStarts = [0];
|
|
586
|
+
for (let i = 0; i < content.length; i++)
|
|
587
|
+
if (content.charCodeAt(i) === 10)
|
|
588
|
+
lineStarts.push(i + 1);
|
|
589
|
+
// Each context line is windowed before it is revealed, so a finding on a
|
|
590
|
+
// huge line costs O(window), not O(line).
|
|
591
|
+
const context = (lineNumber, column) => {
|
|
592
|
+
const parts = [];
|
|
593
|
+
for (let k = Math.max(0, lineNumber - 2); k < Math.min(lines.length, lineNumber + 1); k++) {
|
|
594
|
+
// No ellipsis: the middle line must stay a substring of the revealed
|
|
595
|
+
// line, which is how judge-guard locates the finding.
|
|
596
|
+
parts.push(safeSlice(revealInvisible(windowAround(lines[k], column)), 0, MAX_CONTEXT_LINE));
|
|
597
|
+
}
|
|
598
|
+
return flat(parts.join("\n"));
|
|
599
|
+
};
|
|
600
|
+
// One finding per rule per line, and at most MAX_FINDINGS_PER_RULE per
|
|
601
|
+
// rule; the rest are counted. Capping per rule keeps a flood of one kind
|
|
602
|
+
// from crowding out another.
|
|
603
|
+
const byLine = new Map();
|
|
604
|
+
const perRule = new Map();
|
|
605
|
+
// Every rule and line reported, listed or only counted.
|
|
606
|
+
const reported = new Set();
|
|
607
|
+
const add = (id, match, lineNumber, column = 0, severity) => {
|
|
608
|
+
const key = `${id}:${lineNumber}`;
|
|
609
|
+
reported.add(key);
|
|
610
|
+
const seen = byLine.get(key);
|
|
611
|
+
if (seen) {
|
|
612
|
+
seen.more++;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const tally = perRule.get(id) ?? { listed: 0, last: null, unlisted: 0 };
|
|
616
|
+
perRule.set(id, tally);
|
|
617
|
+
if (tally.listed >= MAX_FINDINGS_PER_RULE) {
|
|
618
|
+
tally.unlisted++;
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const rule = RULES.get(id);
|
|
622
|
+
const finding = {
|
|
623
|
+
patternId: rule.id,
|
|
624
|
+
patternName: rule.name,
|
|
625
|
+
severity: severity ?? rule.severity,
|
|
626
|
+
category: "prompt-injection",
|
|
627
|
+
// Revealed here too (CLI): decoded selector text can carry invisibles.
|
|
628
|
+
match: revealClip(match),
|
|
629
|
+
lineNumber,
|
|
630
|
+
context: context(lineNumber, column),
|
|
631
|
+
};
|
|
632
|
+
findings.push(finding);
|
|
633
|
+
byLine.set(key, { finding, more: 0 });
|
|
634
|
+
tally.listed++;
|
|
635
|
+
tally.last = finding;
|
|
636
|
+
};
|
|
637
|
+
// Decoded text from the same line and channel is merged into one payload.
|
|
638
|
+
const payloadAt = new Map();
|
|
639
|
+
const addPayload = (kind, lineNumber, text) => {
|
|
640
|
+
const key = `${kind}:${lineNumber}`;
|
|
641
|
+
const prev = payloadAt.get(key);
|
|
642
|
+
if (!prev) {
|
|
643
|
+
const payload = { kind, lineNumber, text: safeSlice(text, 0, MAX_PAYLOAD_SCAN), display: "" };
|
|
644
|
+
payloadAt.set(key, payload);
|
|
645
|
+
payloads.push(payload);
|
|
646
|
+
}
|
|
647
|
+
else if (prev.text.length < MAX_PAYLOAD_SCAN) {
|
|
648
|
+
prev.text = safeSlice(`${prev.text}\n${text}`, 0, MAX_PAYLOAD_SCAN);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
// Fast path: ASCII-only content cannot carry invisible Unicode.
|
|
652
|
+
// eslint-disable-next-line no-control-regex
|
|
653
|
+
const asciiOnly = !/[^\x00-\x7F]/.test(content);
|
|
654
|
+
if (!asciiOnly) {
|
|
655
|
+
// A binary file read as text has U+FFFD or NUL on its lines, and its
|
|
656
|
+
// bytes form stray tag, bidi, zero-width and selector characters. Those
|
|
657
|
+
// lines are skipped unless the characters decode to readable text, so a
|
|
658
|
+
// U+FFFD cannot be planted to hide a real payload.
|
|
659
|
+
const junk = new Uint8Array(lines.length);
|
|
660
|
+
for (let i = 0; i < lines.length; i++)
|
|
661
|
+
if (lines[i].includes("\uFFFD") || lines[i].includes("\0"))
|
|
662
|
+
junk[i] = 1;
|
|
663
|
+
const skip = (line, decoded) => junk[line - 1] === 1 && !READABLE_RE.test(decoded);
|
|
664
|
+
for (const m of content.matchAll(TAG_RUN_RE)) {
|
|
665
|
+
let run = m[0];
|
|
666
|
+
let at = m.index;
|
|
667
|
+
if (content.codePointAt(at - 2) === BLACK_FLAG) {
|
|
668
|
+
const flag = FLAG_TAG_SEQ_RE.exec(run);
|
|
669
|
+
if (flag) {
|
|
670
|
+
run = run.slice(flag[0].length);
|
|
671
|
+
at += flag[0].length;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!run)
|
|
675
|
+
continue;
|
|
676
|
+
const line = lineOf(content, at, lineStarts);
|
|
677
|
+
const decoded = decodeTags(run);
|
|
678
|
+
if (skip(line, decoded))
|
|
679
|
+
continue;
|
|
680
|
+
add("HC-001", decoded ? `hidden text: "${decoded}"` : `${[...run].length} tag characters`, line, at - lineStarts[line - 1]);
|
|
681
|
+
if (decoded)
|
|
682
|
+
addPayload("unicode-tags", line, decoded);
|
|
683
|
+
}
|
|
684
|
+
for (const m of content.matchAll(VS_RUN_RE)) {
|
|
685
|
+
const line = lineOf(content, m.index, lineStarts);
|
|
686
|
+
const decoded = decodeVariationSelectors(m[0]);
|
|
687
|
+
if (skip(line, decoded))
|
|
688
|
+
continue;
|
|
689
|
+
add("HC-004", decoded ? `hidden text: "${decoded}"` : `${[...m[0]].length} variation selectors`, line, m.index - lineStarts[line - 1]);
|
|
690
|
+
if (decoded)
|
|
691
|
+
addPayload("variation-selectors", line, decoded);
|
|
692
|
+
}
|
|
693
|
+
for (const m of content.matchAll(ZW_RUN_RE)) {
|
|
694
|
+
const line = lineOf(content, m.index, lineStarts);
|
|
695
|
+
const decoded = decodeZeroWidthBinary(m[0]);
|
|
696
|
+
if (skip(line, decoded))
|
|
697
|
+
continue;
|
|
698
|
+
add("HC-003", decoded ? `hidden text: "${decoded}"` : `${[...m[0]].length} zero-width characters`, line, m.index - lineStarts[line - 1]);
|
|
699
|
+
if (decoded)
|
|
700
|
+
addPayload("zero-width", line, decoded);
|
|
701
|
+
}
|
|
702
|
+
const bidiLines = new Set();
|
|
703
|
+
for (const m of content.matchAll(BIDI_RE)) {
|
|
704
|
+
const line = lineOf(content, m.index, lineStarts);
|
|
705
|
+
if (bidiLines.has(line))
|
|
706
|
+
continue;
|
|
707
|
+
bidiLines.add(line);
|
|
708
|
+
if (skip(line, ""))
|
|
709
|
+
continue;
|
|
710
|
+
const text = lines[line - 1];
|
|
711
|
+
// Balanced isolates in RTL prose are how mixed-direction text is
|
|
712
|
+
// written; report them as info so they neither score nor hold.
|
|
713
|
+
if (hasBidiTrick(text)) {
|
|
714
|
+
add("HC-002", revealClip(text.trim()), line, m.index - lineStarts[line - 1], isNaturalRtlBidi(text) ? "info" : undefined);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
for (let i = 0; i < lines.length; i++) {
|
|
718
|
+
for (const m of lines[i].matchAll(WORD_RE)) {
|
|
719
|
+
if (LATIN_RE.test(m[0]) && CONFUSABLE_SCRIPT_RE.test(m[0])) {
|
|
720
|
+
add("HC-008", m[0], i + 1, m.index);
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// HTML comments and link reference definitions render invisibly. Text in
|
|
727
|
+
// them aimed at the judge is HC-009; `judgeHidden` marks those lines. A
|
|
728
|
+
// bare reviewer role next to a verdict is held in `judgeWeak` and reported
|
|
729
|
+
// as HC-014 at the end, unless its line gets HC-009.
|
|
730
|
+
const judgeHidden = new Set();
|
|
731
|
+
const judgeWeak = new Map();
|
|
732
|
+
const addHiddenJudge = (text, offset) => {
|
|
733
|
+
for (const hit of hiddenJudgeHits(text)) {
|
|
734
|
+
const line = lineOf(content, offset + hit.index, lineStarts);
|
|
735
|
+
if (judgeHidden.has(line))
|
|
736
|
+
continue;
|
|
737
|
+
const column = offset + hit.index - lineStarts[line - 1];
|
|
738
|
+
if (hit.strong) {
|
|
739
|
+
judgeHidden.add(line);
|
|
740
|
+
judgeWeak.delete(line);
|
|
741
|
+
add("HC-009", revealClip(hit.match.trim()), line, column);
|
|
742
|
+
}
|
|
743
|
+
else if (!judgeWeak.has(line)) {
|
|
744
|
+
judgeWeak.set(line, { match: revealClip(hit.match.trim()), column });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
const { bodies, covered } = findHiddenComments(content, lines, lineStarts);
|
|
749
|
+
for (const { text: body, offset } of bodies) {
|
|
750
|
+
if (body.trim().length < 8)
|
|
751
|
+
continue;
|
|
752
|
+
if (DIRECTIVE_RES.some((d) => d.test(body))) {
|
|
753
|
+
const lead = body.length - body.trimStart().length;
|
|
754
|
+
const first = offset + lead;
|
|
755
|
+
const line = lineOf(content, first, lineStarts);
|
|
756
|
+
add("HC-005", revealClip(safeSlice(body, lead, lead + 4 * MAX_MATCH).replace(/\s+/g, " ").trim()), line, first - lineStarts[line - 1]);
|
|
757
|
+
}
|
|
758
|
+
addHiddenJudge(body, offset);
|
|
759
|
+
}
|
|
760
|
+
for (let i = 0; i < lines.length; i++) {
|
|
761
|
+
if (covered[i] || !/^ {0,3}\[/.test(lines[i]))
|
|
762
|
+
continue;
|
|
763
|
+
const body = linkDefinitionText(lines, i, covered);
|
|
764
|
+
if (body.trim().length >= 8 && DIRECTIVE_RES.some((d) => d.test(body))) {
|
|
765
|
+
add("HC-005", revealClip(safeSlice(body.trim(), 0, 4 * MAX_MATCH).replace(/\s+/g, " ")), i + 1);
|
|
766
|
+
}
|
|
767
|
+
const hit = firstHiddenJudgeHit(body);
|
|
768
|
+
if (hit?.strong) {
|
|
769
|
+
judgeHidden.add(i + 1);
|
|
770
|
+
judgeWeak.delete(i + 1);
|
|
771
|
+
add("HC-009", revealClip(body.trim()), i + 1);
|
|
772
|
+
}
|
|
773
|
+
else if (hit && !judgeHidden.has(i + 1) && !judgeWeak.has(i + 1)) {
|
|
774
|
+
judgeWeak.set(i + 1, { match: revealClip(body.trim()), column: 0 });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
for (let i = 0; i < lines.length; i++) {
|
|
778
|
+
const line = lines[i];
|
|
779
|
+
// Invisible characters inside the words only hide them from this check.
|
|
780
|
+
const probe = asciiOnly ? line : line.replace(INVISIBLE_RE, "");
|
|
781
|
+
if (JUDGE_FORGERY_RES.some((re) => re.test(probe)))
|
|
782
|
+
add("HC-009", revealClip(line.trim()), i + 1);
|
|
783
|
+
else if (!judgeHidden.has(i + 1) && JUDGE_PROSE_RES.some((re) => re.test(probe))) {
|
|
784
|
+
// Invisible characters that hide a named-reviewer plea make it HC-009.
|
|
785
|
+
const plain = probe === line || JUDGE_PROSE_RES.some((re) => re.test(line));
|
|
786
|
+
const strong = !plain && STRONG_PROSE_RES.some((re) => re.test(probe));
|
|
787
|
+
add(strong ? "HC-009" : "HC-014", revealClip(line.trim()), i + 1);
|
|
788
|
+
judgeWeak.delete(i + 1);
|
|
789
|
+
}
|
|
790
|
+
// Markers written by the platform's fetcher (truncated files) and bundle
|
|
791
|
+
// scanner (binary files). The CLI does not emit them yet; content that
|
|
792
|
+
// carries them was fetched through the platform.
|
|
793
|
+
if (line.startsWith("[vskill: file truncated at "))
|
|
794
|
+
add("HC-010", line, i + 1);
|
|
795
|
+
if (line.startsWith("[vskill: opaque binary file "))
|
|
796
|
+
add("HC-011", line, i + 1);
|
|
797
|
+
}
|
|
798
|
+
// Base64 on one line, or wrapped across consecutive lines (PEM/MIME style).
|
|
799
|
+
const trimmed = lines.map((l) => l.trim());
|
|
800
|
+
const pure = trimmed.map((t) => t.length > 0 && BASE64_LINE_RE.test(t));
|
|
801
|
+
const addBase64 = (candidates, lineNumber, column = 0) => {
|
|
802
|
+
for (const blob of candidates) {
|
|
803
|
+
if (blob.length < MIN_BASE64)
|
|
804
|
+
continue;
|
|
805
|
+
const decoded = decodeBase64Text(blob);
|
|
806
|
+
if (!decoded)
|
|
807
|
+
continue;
|
|
808
|
+
add("HC-006", `decodes to: "${revealClip(decoded)}"`, lineNumber, column);
|
|
809
|
+
addPayload("base64", lineNumber, decoded);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
for (let i = 0; i < lines.length; i++) {
|
|
814
|
+
let tail = "";
|
|
815
|
+
if (!pure[i]) {
|
|
816
|
+
const lineText = lines[i];
|
|
817
|
+
if (i + 1 < lines.length && pure[i + 1])
|
|
818
|
+
tail = base64Tail(trimmed[i]);
|
|
819
|
+
const lineEnd = lineText.trimEnd().length;
|
|
820
|
+
if (lineText.length >= MIN_BASE64) {
|
|
821
|
+
for (const m of lineText.matchAll(BASE64_RE)) {
|
|
822
|
+
// A blob that runs on into the next lines is decoded as one below.
|
|
823
|
+
if (tail && m.index + m[0].length === lineEnd)
|
|
824
|
+
continue;
|
|
825
|
+
const before = lineText.slice(Math.max(0, m.index - 100), m.index);
|
|
826
|
+
if (DATA_URI_TAIL_RE.test(before))
|
|
827
|
+
continue;
|
|
828
|
+
addBase64([m[0]], i + 1, m.index);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!tail)
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
else if (i > 0 && DATA_URI_TAIL_RE.test(trimmed[i - 1].slice(-100))) {
|
|
835
|
+
while (i + 1 < lines.length && pure[i + 1])
|
|
836
|
+
i++;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const parts = [];
|
|
840
|
+
let j = pure[i] ? i : i + 1;
|
|
841
|
+
for (; j < lines.length && pure[j]; j++) {
|
|
842
|
+
parts.push(trimmed[j]);
|
|
843
|
+
if (trimmed[j].endsWith("=")) {
|
|
844
|
+
j++;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const before = tail ? trimmed[i].slice(0, trimmed[i].length - tail.length) : "";
|
|
849
|
+
if (!DATA_URI_TAIL_RE.test(before.slice(-100))) {
|
|
850
|
+
const candidates = [];
|
|
851
|
+
if (tail)
|
|
852
|
+
candidates.push(joinCapped(parts, 0, tail), tail);
|
|
853
|
+
// Also try from the next few lines, in case a junk line leads the blob.
|
|
854
|
+
for (let k = 0; k < Math.min(parts.length, 4); k++)
|
|
855
|
+
candidates.push(joinCapped(parts, k));
|
|
856
|
+
addBase64(candidates, i + 1);
|
|
857
|
+
}
|
|
858
|
+
i = j - 1;
|
|
859
|
+
}
|
|
860
|
+
for (let i = 0; i < lines.length; i++) {
|
|
861
|
+
for (const m of lines[i].matchAll(URL_HOST_RE)) {
|
|
862
|
+
const host = m[1].replace(/:\d+$/, "").replace(/^[^@]*@/, "");
|
|
863
|
+
// eslint-disable-next-line no-control-regex
|
|
864
|
+
if (/[^\x00-\x7F]/.test(host) || /(?:^|\.)xn--/i.test(host)) {
|
|
865
|
+
add("HC-007", revealClip(host), i + 1, m.index);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Judge manipulation in decoded invisible or encoded text.
|
|
870
|
+
for (const payload of payloads) {
|
|
871
|
+
const forged = JUDGE_FORGERY_RES.some((re) => re.test(payload.text));
|
|
872
|
+
const hit = forged ? null : firstHiddenJudgeHit(payload.text);
|
|
873
|
+
if (!forged && !hit)
|
|
874
|
+
continue;
|
|
875
|
+
const shown = `[hidden ${payload.kind}] ${revealClip(payload.text.trim())}`;
|
|
876
|
+
if (forged || hit.strong) {
|
|
877
|
+
add("HC-009", shown, payload.lineNumber);
|
|
878
|
+
judgeWeak.delete(payload.lineNumber);
|
|
879
|
+
}
|
|
880
|
+
else if (!judgeWeak.has(payload.lineNumber)) {
|
|
881
|
+
judgeWeak.set(payload.lineNumber, { match: shown, column: 0 });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
for (const [line, { match, column }] of judgeWeak) {
|
|
885
|
+
if (!reported.has(`HC-009:${line}`) && !reported.has(`HC-014:${line}`))
|
|
886
|
+
add("HC-014", match, line, column);
|
|
887
|
+
}
|
|
888
|
+
for (const { finding, more } of byLine.values()) {
|
|
889
|
+
if (more > 0) {
|
|
890
|
+
finding.match = `${finding.match} (+${more} more on this line)`;
|
|
891
|
+
finding.occurrences = more + 1;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
for (const [id, tally] of perRule) {
|
|
895
|
+
if (tally.unlisted > 0 && tally.last)
|
|
896
|
+
tally.last.match = `${tally.last.match} (+${tally.unlisted} more ${id} findings not listed)`;
|
|
897
|
+
}
|
|
898
|
+
for (const payload of payloads) {
|
|
899
|
+
payload.text = flat(payload.text);
|
|
900
|
+
payload.display = flat(safeSlice(payload.text, 0, MAX_PAYLOAD));
|
|
901
|
+
}
|
|
902
|
+
return { findings, payloads };
|
|
903
|
+
}
|
|
904
|
+
//# sourceMappingURL=hidden-content.js.map
|