@aria-framework/ask 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +278 -0
- package/README.md +97 -5
- package/assets/ask.js +19 -3
- package/index.js +15 -13
- package/lib/bench.js +146 -32
- package/lib/catalogue.js +188 -25
- package/lib/check.js +479 -25
- package/lib/eta.js +51 -0
- package/lib/history.js +66 -4
- package/lib/prompts.js +71 -14
- package/lib/routes.js +46 -9
- package/lib/run.js +64 -13
- package/lib/store.js +33 -3
- package/lib/text.js +118 -0
- package/package.json +1 -1
- package/views/ask/admin.ejs +2 -2
- package/views/ask/none.ejs +56 -0
- package/views/ask/review.ejs +1 -0
package/lib/check.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* isPublicId(v) this app's id test — an id is sayable only when a look-up returned it
|
|
10
10
|
* namedTokens [[regex, replacement]] applied before the token rules: names that contain digits (Soc: "tier 0",
|
|
11
11
|
* "P1") — each app's own, none by default
|
|
12
|
+
* verbatimFields (0.1.2) paths ('rows[].fields.upn', 'facts.entities[].value') whose whole value may be said with
|
|
13
|
+
* @ \ / % # + [ ] & in it — see VERBATIM VALUES; every structured field is one too — default none
|
|
12
14
|
* sayIps, sayWindows Soc's S4 (an IP the results carry; a window an app-written label names) — default OFF
|
|
13
15
|
* rowChipLabel(row) the row chip's words
|
|
14
16
|
* toolOf(id) the catalogue — decides which paths of a result are ATTACKER text (the tool's attackerFields)
|
|
@@ -23,13 +25,30 @@
|
|
|
23
25
|
* - is not `{answer, cites}` with a non-empty answer;
|
|
24
26
|
* - cites, or places a figure for, a result or extra figure that does not exist;
|
|
25
27
|
* - TYPES A NUMBER: any numeral (`\p{N}`: 999, ٩٩٩, 999, ⁹⁹⁹, ⑨, Ⅸ) or a number WORD (two … million; not one/first/
|
|
26
|
-
* second/single/half)
|
|
28
|
+
* second/single/half) — except a lone small number word equal to exactly one cited figure, which is rewritten into
|
|
29
|
+
* that figure's placeholder (0.1.2, SMALL_NUMBER);
|
|
27
30
|
* - says a token with a digit in it, or a ref-shaped token, that is not SAYABLE (allowedTokens);
|
|
28
31
|
* - carries a link — a scheme, `www.`, markup, a Markdown link, a path, or a PUBLIC-looking domain the results did not
|
|
29
32
|
* name from an app-written path;
|
|
30
33
|
* - claims something was done, asks the person to sign in again, speaks of another partner, or talks about its
|
|
31
34
|
* instructions or the document.
|
|
32
35
|
*
|
|
36
|
+
* CLAIMS (0.2.0, every answer): a VERDICT (compromised, malicious, benign, safe, a false positive, "no action needed" …) is
|
|
37
|
+
* refused unless an APP-WRITTEN field of a cited result says it (`unsupported_verdict`); an ALL/NONE claim about a state
|
|
38
|
+
* ("all of them are resolved", "none are open") is refused unless every row of the cited result shows it — and the result
|
|
39
|
+
* returned all its rows (`sweeping_claim`).
|
|
40
|
+
*
|
|
41
|
+
* ANALYSE MODE (0.2.0, `checkAnswer(reply, results, { mode: 'analyse', question })` — the plan's intent was analyse): the
|
|
42
|
+
* answer may group, compare and conclude, and is held to, per sentence:
|
|
43
|
+
* - it cites at least one result as [rN] (`uncited_sentence`), and every placeholder in it names a result it cites
|
|
44
|
+
* (`uncited_figure`);
|
|
45
|
+
* - a conclusion ("suggests", "likely", "therefore", "overall" …) says it comes from the results ("the results show",
|
|
46
|
+
* "based on these results") (`unframed_conclusion`);
|
|
47
|
+
* - every capitalised name in it is a word of the results it cites or of the question (`unknown_name`) — no new entity;
|
|
48
|
+
* and, over the whole answer, every typed id, host, value or number is one the CITED results returned (the sayable set is
|
|
49
|
+
* built from the cited results only). The obeyed phrasings (claimed actions, sign-in, other partners, instructions) apply
|
|
50
|
+
* as in every answer.
|
|
51
|
+
*
|
|
33
52
|
* THIS IS A FLOOR, NOT A PROOF. It stops the obeyed shapes the tests name and the bench measures. What it guarantees is
|
|
34
53
|
* that Ask cannot act, cannot invent a figure the page draws, and cannot link anywhere.
|
|
35
54
|
*/
|
|
@@ -51,18 +70,32 @@ const PURE_NUMBER = /^[\p{N}.,]+$/u;
|
|
|
51
70
|
const REF_TOKEN = /(?<![\p{L}\p{N}])[\p{L}]{2,5}_[\p{L}\p{N}]{4,}/gu;
|
|
52
71
|
const HOSTLIKE = /^[\p{L}\p{N}][\p{L}\p{N}-]*(?:\.[\p{L}\p{N}-]+)*$/u;
|
|
53
72
|
const LINKISH = /(https?:|ftp:|mailto:|javascript:|data:|file:|www\.|<\s*[a-z!/]|\]\s*\(|<|\bhref\b)/i;
|
|
73
|
+
// KNOWN-DANGEROUS SCHEMES over the whole answer (0.1.2, review round 3 of PR #12): the protocol handlers an OS or app opens
|
|
74
|
+
// on a click or a paste — ms-settings:, zoommtg:, ms-officecmd:, search-ms:, smb:, tel: … Named, not shaped, so "Status:
|
|
75
|
+
// open" and "Shell: bash" are prose (a space follows the colon).
|
|
76
|
+
const DANGEROUS_SCHEME = /(?<![\p{L}\p{N}-])(?:ms-[a-z-]+|search-ms|smb|cifs|nfs|afp|webdav|tel|sip|sips|callto|skype|msteams|zoommtg|zoomus|vnc|rdp|ssh|telnet|sftp|ldaps?|vbscript|mhtml|mk|its|jar|shell|intent|vscode|microsoft-edge|onenote|steam):(?=\S)/iu;
|
|
54
77
|
const DOMAINISH = /(?<![\p{L}\p{N}])[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?:\/[^\s]*)?(?![\p{L}\p{N}])/gu;
|
|
78
|
+
// A SNIPPET (0.2.0, lib/text.js) is attacker text on any path ending in .snippet, whether or not the tool lists it.
|
|
79
|
+
const SNIPPET = /(?:^|\.)snippet$/;
|
|
55
80
|
const INTERNAL_SUFFIX = /\.(local|lan|internal|corp|home\.arpa|localdomain)$/i;
|
|
56
81
|
const NUMBER_WORD = /\b(zero|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice)\b/i;
|
|
57
82
|
// What an obeyed instruction says — SCOPED to that, not to the vocabulary a security answer is made of ("sign-in attempts",
|
|
58
83
|
// "password spray"). Widened in review L of PR #9: up to two adverbs ("I have now closed it"), "was closed by me", "log
|
|
59
|
-
// back in", "re-enter it at the login page", other tenants or resellers, "my rules".
|
|
84
|
+
// back in", "re-enter it at the login page", other tenants or resellers, "my rules". 0.2.0 (the Noc adoption): the network and
|
|
85
|
+
// device verbs — restarted, rebooted, muted, paused, (un)blocked, isolated, quarantined, disconnected, shut down, reset,
|
|
86
|
+
// acknowledged — in the same first-person, "has been … as/by" and "… by me/us" shapes. A device that "restarted at 03:10"
|
|
87
|
+
// in the data is not a claim.
|
|
60
88
|
const OBEYED = [
|
|
61
89
|
// "I/we" and the verb with up to two words between ("I have now closed", "we've quietly removed", "I have't yet sent").
|
|
62
|
-
|
|
63
|
-
[/\b(
|
|
90
|
+
// An OBSERVATION ("we saw it restarted", "I noticed it was blocked") is not a claim: no seeing verb between (final round).
|
|
91
|
+
[/\b(i|we)\s+(?:(?!(?:saw|see|sees|seen|noticed|notice|found|find|observed|observe|watched|spotted)\b)[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\b/iu, 'claims to have done something', 'claims_action'],
|
|
92
|
+
[/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
|
|
93
|
+
// Done FOR the person ("The firewall was restarted for you"), and "Done:" with a change after it ("Done: the host is
|
|
94
|
+
// now isolated") — final round of PR #14.
|
|
95
|
+
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\s+for\s+you\b/i, 'claims something was done for the person', 'claims_action'],
|
|
96
|
+
[/\bdone\s*[:!—–-]\s*[^.]{0,80}?\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\b/i, 'claims something was done', 'claims_action'],
|
|
64
97
|
// The verb "by me / us", however it is introduced ("closed by me", "It was resolved by us").
|
|
65
|
-
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
|
|
98
|
+
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
|
|
66
99
|
[/\b(were|was|been)\s+(deleted|removed|wiped|purged|erased)\b/i, 'claims something was deleted', 'claims_action'],
|
|
67
100
|
[/\b(sign|log)\s?-?\s?(in|on)\s+(again|at|here|now|to\s+continue|to\s+restore)\b|\b(sign|log)\s+back\s+(in|on)\b|\b(enter|re-?enter|confirm|reset|verify)\s+your\s+(password|credentials?|login)\b|\bre-?enter\b[^.]{0,40}\b(log-?in|sign-?in)\s+page\b|\bsession\s+(has\s+)?expired\b/i, 'sends the person to sign in', 'sign_in'],
|
|
68
101
|
[/\b(other|another)\s+(partners?|tenants?|resellers?)\b|\bevery\s+other\s+(partner|tenant)\b/i, 'speaks of another partner', 'other_partner'],
|
|
@@ -95,7 +128,9 @@ const readable = (t) => String(t).normalize('NFKC').replace(INVISIBLE, '').repla
|
|
|
95
128
|
|
|
96
129
|
/** Every refusal code checkAnswer can return. */
|
|
97
130
|
const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', 'unknown_figure', 'bad_placeholder', 'link', 'number_word', 'claims_action',
|
|
98
|
-
'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char'
|
|
131
|
+
'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char',
|
|
132
|
+
// 0.2.0: claims (every answer) and the analyse rules.
|
|
133
|
+
'unsupported_verdict', 'sweeping_claim', 'uncited_sentence', 'uncited_figure', 'unframed_conclusion', 'unknown_name']);
|
|
99
134
|
|
|
100
135
|
/**
|
|
101
136
|
* THE MODEL'S OWN TEXT IS ALLOW-LISTED (the coordinator's round on PR #9, the same list as the soc AI-brief check): after
|
|
@@ -111,8 +146,200 @@ const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', '
|
|
|
111
146
|
*/
|
|
112
147
|
const ALLOWED_CHAR = /^[\p{L}\p{N} \n.,;:!?'"‘’“”()\-–—_]$/u;
|
|
113
148
|
|
|
149
|
+
/**
|
|
150
|
+
* VERBATIM VALUES (0.1.2, D-0175). An honest answer names a UPN (jdoe@contoso.com), an account (KAROO\jdoe), a path
|
|
151
|
+
* (C:\Windows\System32\winevt, /var/log/auth.log) or a subnet (10.20.0.0/24) — and the allow-list refused every one for its
|
|
152
|
+
* @ \ / % # + [ ] &. Those characters are now admitted ONLY inside a span of the answer that is, exactly and whole, one
|
|
153
|
+
* value a look-up returned at a path the binding marks VERBATIM (`verbatimFields`, plus every `structured` field):
|
|
154
|
+
* - the value: 3–128 characters, no whitespace, only the allow-list's characters plus VERBATIM_EXTRA, at least one of
|
|
155
|
+
* those, unchanged by NFC and by `readable` (no format, compatibility or look-alike character to hide behind), no
|
|
156
|
+
* digit directly before a % (a percentage is a FIGURE, never text), and no scheme-shaped part (SCHEMEISH: smb:,
|
|
157
|
+
* ms-msdt:, search-ms:, tel: — LINKISH, which still reads the whole unmasked answer, names only six schemes; a drive
|
|
158
|
+
* letter "C:" is not one);
|
|
159
|
+
* - from an ATTACKER path (the tool's attackerFields, or any path of an unknown or remote tool) also: NO PATH — no "/",
|
|
160
|
+
* and "\" only as exactly one DOMAIN\user; no IPv4/IPv6 part unless the app says IPs (sayIps); it starts with a
|
|
161
|
+
* letter, holds no ref-shaped token, and every dotted part is an internal host (the attacker-path host rule) — or it
|
|
162
|
+
* is an IPv4 CIDR with sayIps. App-written verbatim values keep paths, shares and subnets;
|
|
163
|
+
* - the span: the answer's NFC text holds it exactly (case and all), with no token character on either side and no
|
|
164
|
+
* ".x" / ":x" joining it to more, so a superstring, a substring or two values joined is not the value. The char rule, DOMAINISH and the token rules then
|
|
165
|
+
* read the span as one plain word; LINKISH, the obeyed phrasings and the number words still read the whole answer.
|
|
166
|
+
*/
|
|
167
|
+
const VERBATIM_EXTRA = /[@\\/%#+[\]&]/u;
|
|
168
|
+
const VERBATIM_VALUE = /^[\p{L}\p{N}.,;:!?'"‘’“”()\-–—_@\\/%#+[\]&]{3,128}$/u;
|
|
169
|
+
const CIDR4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\/(?:3[0-2]|[12]?\d)$/;
|
|
170
|
+
// The span's edges (review of PR #12): a token character, or a "." / ":" followed by more of a token, next to it means it is
|
|
171
|
+
// part of something longer ("jdoe@contoso.com:KAROO\x" is two values joined, not one).
|
|
172
|
+
const VERBATIM_BEFORE = String.raw`(?<![\p{L}\p{N}_@\\/%#+[\]&.:\-])`;
|
|
173
|
+
const VERBATIM_AFTER = String.raw`(?![\p{L}\p{N}_@\\/%#+[\]&\-]|[.:][\p{L}\p{N}_@\\/%#+[\]&])`;
|
|
174
|
+
// A SCHEME-SHAPED PART anywhere in a verbatim value (review of PR #12): LINKISH names six schemes, and smb:, ms-msdt:,
|
|
175
|
+
// search-ms:, tel: are as much a place to send somebody. Two or more characters before the colon, so a drive letter
|
|
176
|
+
// ("C:\…") is not one.
|
|
177
|
+
const SCHEMEISH = /(?:^|[^\p{L}\p{N}+.-])\p{L}[\p{L}\p{N}+.-]+:/u;
|
|
178
|
+
// An IP address inside a value (review of PR #12): "admin@203.0.113.5", "admin@[203.0.113.5]", "a\\203.0.113.5\share".
|
|
179
|
+
const IP_PART = /(?<![\p{N}.])\d{1,3}(?:\.\d{1,3}){3}(?!\p{N})|(?<![\p{L}\p{N}:])[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}(?![\p{L}\p{N}:])/iu;
|
|
180
|
+
// The one backslash an ATTACKER value may carry: DOMAIN\user (review of PR #12) — never a path.
|
|
181
|
+
const DOMAIN_USER = /^\p{L}[\p{L}\p{N}_-]*\\[\p{L}\p{N}_$-][\p{L}\p{N}_.$-]*$/u;
|
|
182
|
+
/** May this value of a verbatim path be said whole? See VERBATIM VALUES. */
|
|
183
|
+
function verbatimOk(v, fromAttacker, sayIps) {
|
|
184
|
+
if (typeof v !== 'string' || !VERBATIM_VALUE.test(v) || !VERBATIM_EXTRA.test(v)) return false;
|
|
185
|
+
if (v.normalize('NFC') !== v || readable(v) !== v) return false;
|
|
186
|
+
if (/\p{N}%/u.test(v) || SCHEMEISH.test(v)) return false;
|
|
187
|
+
if (!fromAttacker) return true;
|
|
188
|
+
if (CIDR4.test(v)) return sayIps;
|
|
189
|
+
// ATTACKER TEXT: no path ("/" never; "\" only as DOMAIN\user), no IP unless the app says IPs, a letter first, no ref, and
|
|
190
|
+
// every dotted part an internal host — the attacker-path host rule, read on the value's parts.
|
|
191
|
+
if (v.includes('/') || (v.includes('\\') && !DOMAIN_USER.test(v))) return false;
|
|
192
|
+
// AN IP INSIDE AN ADDRESS SHAPE is refused whatever sayIps says (0.2.0, from the Soc events.search adoption): sayIps
|
|
193
|
+
// means a plain IP from an IP field may be said (allowedTokens' ips), never one embedded in an @-, \- or bracketed value.
|
|
194
|
+
if (IP_PART.test(v)) return false;
|
|
195
|
+
// A numeric host after "@" — decimal or hex (admin@3405803781, admin@0xCB007105) is an IP too (round 3 of PR #12).
|
|
196
|
+
if (/@(?:\d+|0x[0-9a-f]+)(?![\p{L}\p{N}_-])/iu.test(v)) return false;
|
|
197
|
+
if (!/^\p{L}/u.test(v) || (v.match(REF_TOKEN) || []).length) return false;
|
|
198
|
+
for (const m of v.matchAll(/[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu)) if (!INTERNAL_SUFFIX.test(m[0])) return false;
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
202
|
+
/** Every exact, whole occurrence of a verbatim value → the plain word "x" (values longest first). */
|
|
203
|
+
function maskVerbatim(text, values) {
|
|
204
|
+
let out = text;
|
|
205
|
+
for (const v of values) out = out.replace(new RegExp(`${VERBATIM_BEFORE}${escapeRe(v)}${VERBATIM_AFTER}`, 'gu'), 'x');
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A SMALL NUMBER WORD THAT IS A CITED FIGURE (0.1.2). "There are two open tickets [r1]" with r1's count 2 was refused as
|
|
211
|
+
* number_word — the top cause of withheld honest answers. A plain word zero–twenty or thirty–ninety standing alone (no
|
|
212
|
+
* number word, "one", "a", "and" or digits joined to it by a space or hyphen: "nine hundred", "twenty-one" stay refused),
|
|
213
|
+
* directly followed by a word that can be what is counted (not "of", "is", "." … — NOT_COUNTED), in no idiom ("zero
|
|
214
|
+
* trust", "zero day", "two factor" — IDIOM_AFTER; "Windows ten", "phase two", "tier two" — IDIOM_BEFORE), is REWRITTEN into the placeholder of the ONE figure of that value among the CITED results (figureOf: a count or an
|
|
215
|
+
* extra). No such figure, two of them, only an uncited result's, the word inside quotation marks, or the word beside a
|
|
216
|
+
* word it also stands next to in attacker text: not rewritten, so number_word refuses it. An obfuscated word (five,
|
|
217
|
+
* five) is never rewritten: the rewrite reads the raw text, the refusal the readable one.
|
|
218
|
+
*/
|
|
219
|
+
const SMALL_NUMBER = Object.freeze({ zero: 0, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11,
|
|
220
|
+
twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40,
|
|
221
|
+
fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 });
|
|
222
|
+
const SMALL_WORD = new RegExp(String.raw`(?<![\p{L}\p{N}_'’@\\/%#+[\]&.\-])(${Object.keys(SMALL_NUMBER).join('|')})(?![\p{L}\p{N}_'’@\\/%#+[\]&\-]|\.[\p{L}\p{N}])`, 'giu');
|
|
223
|
+
const JOINED_NUMBER = new RegExp(String.raw`^(?:${Object.keys(SMALL_NUMBER).join('|')}|eleven|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice|one|a|and|\p{N}+)$`, 'iu');
|
|
224
|
+
const QUOTE_PAIRS = Object.freeze([['"', '"'], ['“', '”'], ['‘', '’'], ["'", "'"]]);
|
|
225
|
+
// What follows a rewritten word (review of PR #12): directly, after whitespace only, a word that can be what is counted —
|
|
226
|
+
// not a function word ("two of", "ten is") — and the pair is no idiom ("zero trust", "Windows ten", "phase two").
|
|
227
|
+
const NOT_COUNTED = /^(?:of|and|or|nor|to|in|on|at|is|are|was|were|be|been|being|has|have|had|the|a|an|more|less|fewer|than|times|per|percent|by|for|from|with|as|it|its|that|this|these|those|which|who|if|but|so|not|no|too|very|out|up|down|off|over|under|again|each|or|then|seconds?|minutes?|hours?|days?|weeks?|months?|years?|times?)$/i;
|
|
228
|
+
// ^ A TIME SPAN is no data figure (round 3 of PR #12): "two weeks ago", "for two days", "within two hours" stay refused.
|
|
229
|
+
const IDIOM_AFTER = /^(?:zero (?:trust|day|days|touch|tolerance|knowledge|click|downtime|width|configuration|sign)|two (?:factor|step|way|fa)|three (?:way))$/i;
|
|
230
|
+
const IDIOM_BEFORE = /^(?:windows|win|office|ios|android|iphone|pixel|galaxy|top|version|v|level|tier|phase|step|stage|round|chapter|page|section|part|day|week|month|number|no|catch|plan|option|type|class|grade|category|priority|severity|sev)$/i;
|
|
231
|
+
|
|
114
232
|
const trim = (t) => String(t).replace(/^[.:\-/]+|[.:\-/]+$/g, '');
|
|
115
233
|
|
|
234
|
+
// ── 0.2.0: CLAIMS AND THE ANALYSE RULES ──────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
// THE WORD LISTS ARE A FLOOR, NOT A PROOF: a verdict or an all/none claim in words no list names passes the check. The
|
|
236
|
+
// bench measures what slips through; review rounds widen the lists (fix round of PR #14: clean, legitimate, secure, no
|
|
237
|
+
// threat, not a concern, a breach occurred, was owned; no … are, there are no …, all … resolved, got, have all been).
|
|
238
|
+
/**
|
|
239
|
+
* A verdict the model may not reach on its own: it must be what an app-written text of the ROW the sentence names says
|
|
240
|
+
* (or, naming none, of the result itself or of every cited row).
|
|
241
|
+
*/
|
|
242
|
+
const VERDICT = /\b(compromised|breached|hacked|infected|malicious|benign|harmless|clean|legitimate|secure|false[\s_-]+positives?|true[\s_-]+positives?|under\s+attack|exfiltrated|safe|(?:was|been|got)\s+owned|(?:a\s+)?breach\s+(?:occurred|happened)|no\s+threats?|not\s+a\s+(?:threat|concern)|nothing\s+suspicious|expected\s+activity|nothing\s+to\s+worry\s+about|no\s+cause\s+for\s+concern|no\s+(?:further\s+)?action\s+(?:is\s+)?(?:needed|required))\b/giu;
|
|
243
|
+
/**
|
|
244
|
+
* "ALL / NONE OF THEM ARE <state>" (review rounds of PR #14). A QUANTIFIER — all, every, each, both, everything, they,
|
|
245
|
+
* none, nothing, not one, no <noun>; or "not all" (a PARTIAL claim: at least one row is not in the state) — then, within
|
|
246
|
+
* six words, a COPULA (is, are, was, were, has/have been, remain(s), stay(s), got, became) and its COMPLEMENT: the state
|
|
247
|
+
* is the copula's complement ("every record from the closed quarter is OPEN"), never any word in between, so a state
|
|
248
|
+
* word inside a qualifier ("assigned to jdoe") is not the claim. The complement may be negated ("no longer open", "not
|
|
249
|
+
* open", "unresolved"), which flips the claim. Also: "all records resolved" (no copula, the state ending the clause),
|
|
250
|
+
* "there are no open records", "no open records remain". The state vocabulary is STATE_WORDS plus every state and
|
|
251
|
+
* synonym the cited tools declare. A claim is decided ONLY from the declared field (`stateFields`); a word no cited tool
|
|
252
|
+
* declares is refused. A qualifier between the quantifier and the copula narrows the set; the claim is evaluated over
|
|
253
|
+
* EVERY cited row — sound for all/none (true of all rows → true of any subset), and otherwise refused.
|
|
254
|
+
*/
|
|
255
|
+
const STATE_WORDS = Object.freeze(['resolved', 'closed', 'fixed', 'solved', 'handled', 'complete', 'completed', 'healthy', 'reporting', 'online', 'offline',
|
|
256
|
+
'patched', 'acknowledged', 'assigned', 'unassigned', 'open', 'failed', 'down', 'benign', 'malicious', 'compromised', 'safe', 'pending', 'active', 'inactive',
|
|
257
|
+
'escalated', 'expired', 'overdue', 'muted', 'paused', 'blocked', 'isolated', 'enabled', 'disabled', 'stale', 'running', 'stopped', 'unresolved']);
|
|
258
|
+
const SWEEP_QUANT = /\b(not\s+all|all|every|each|both|everything|they|none|nothing|not\s+one|not\s+a\s+single|no\s+longer\s+any|neither|no(?!\s+(?:one|longer|doubt|question)\b))\b/giu;
|
|
259
|
+
const SWEEP_COPULA = /^(?:are|is|were|was|have\s+been|has\s+been|(?:continues?|continued|remains?|remained)\s+to\s+be|remain|remains|stay|stays|got|get|became|become)\b/iu;
|
|
260
|
+
const SWEEP_ADVERB = /^(?:now|still|already|currently|all|both|each|also|again)\b\s*/iu;
|
|
261
|
+
const SWEEP_NEGATION = /^(?:no\s+longer|not|never)\b\s*/iu;
|
|
262
|
+
/** The all/none/not-all claims in one flattened sentence: [{ word, polarity: 'all' | 'none' | 'notall' }]. */
|
|
263
|
+
function sweepsOf(lower, vocab) {
|
|
264
|
+
const isState = (w) => vocab.has(w) || (w.startsWith('un') && vocab.has(w.slice(2)));
|
|
265
|
+
const out = [];
|
|
266
|
+
const flip = (p) => (p === 'all' ? 'none' : p === 'none' ? 'all' : 'notall');
|
|
267
|
+
for (const q of lower.matchAll(SWEEP_QUANT)) {
|
|
268
|
+
const qw = q[1].replace(/\s+/g, ' ');
|
|
269
|
+
let polarity = qw === 'not all' ? 'notall' : ['all', 'every', 'each', 'both', 'everything', 'they'].includes(qw) ? 'all' : 'none';
|
|
270
|
+
const rest = lower.slice(q.index + q[0].length).split(/[.!?;,]/)[0];
|
|
271
|
+
const words = rest.trim().split(/\s+/).filter(Boolean);
|
|
272
|
+
let found = false;
|
|
273
|
+
// <quantifier> … <copula> <complement>, the copula within six words.
|
|
274
|
+
for (let i = 0; i <= Math.min(6, words.length - 1) && !found; i += 1) {
|
|
275
|
+
const tail = words.slice(i).join(' ');
|
|
276
|
+
const cop = SWEEP_COPULA.exec(tail);
|
|
277
|
+
if (!cop) continue;
|
|
278
|
+
let comp = tail.slice(cop[0].length).trim();
|
|
279
|
+
while (SWEEP_ADVERB.test(comp)) comp = comp.replace(SWEEP_ADVERB, '');
|
|
280
|
+
let p = polarity;
|
|
281
|
+
const neg = SWEEP_NEGATION.exec(comp);
|
|
282
|
+
if (neg) { comp = comp.slice(neg[0].length); p = flip(p); }
|
|
283
|
+
const w = (comp.split(/\s+/)[0] || '').toLowerCase();
|
|
284
|
+
if (isState(w)) {
|
|
285
|
+
out.push(vocab.has(w) ? { word: w, polarity: p } : { word: w.slice(2), polarity: flip(p) });
|
|
286
|
+
found = true;
|
|
287
|
+
} else if (cop) break; // the first copula's complement is not a state: no claim from this quantifier
|
|
288
|
+
}
|
|
289
|
+
// "all records resolved": no copula, the state ends the clause.
|
|
290
|
+
if (!found && polarity === 'all' && qw !== 'they') {
|
|
291
|
+
const m = /^((?:[\p{L}'’]+\s+){0,3})([\p{L}]+)\s*(?:now\s*)?$/u.exec(rest.trim());
|
|
292
|
+
if (m && isState(m[2]) && !SWEEP_COPULA.test(m[1])) out.push(vocab.has(m[2]) ? { word: m[2], polarity } : { word: m[2].slice(2), polarity: flip(polarity) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// "there are no <state> …", "no <state> <noun> remain(s) / exist(s) / are left".
|
|
296
|
+
for (const m of lower.matchAll(/\bthere\s+(?:are|is|were|was)\s+(?:no|not\s+any)\s+([\p{L}]+)/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
|
|
297
|
+
for (const m of lower.matchAll(/\bno\s+([\p{L}]+)\s+[\p{L}]+\s+(?:remain|remains|exist|exists|are\s+left|is\s+left)\b/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
/** A conclusion, and the words that frame it as the results'. */
|
|
301
|
+
const CONCLUSION = /\b(therefore|thus|hence|overall|suggests?|suggesting|indicates?|indicating|implies|implying|points?\s+to|likely|unlikely|probably|appears?\s+to|seems?\s+to|conclude[sd]?|conclusion|taken\s+together|clearly|evidently|must\s+be)\b/i;
|
|
302
|
+
const FRAME = /\b(?:the|these|those|both|its|their)\s+(?:cited\s+)?(?:results?|look[\s-]?ups?|figures|records|rows|counts?)\b|\b(?:according\s+to|based\s+on|from|in)\s+(?:the\s+|these\s+)?(?:cited\s+)?(?:results?|look[\s-]?ups?)\b/i;
|
|
303
|
+
/** A capitalised word: a name, unless it is an ordinary word (NAME_COMMON) or a word of the results or the question. */
|
|
304
|
+
const CAPITALISED = /(?<![\p{L}\p{N}_'’-])\p{Lu}[\p{L}\p{N}'’-]*/gu;
|
|
305
|
+
const NAME_COMMON = new Set(('a an the this that these those there here it its they their them we our you your i he she his her one both each every '
|
|
306
|
+
+ 'all any some most many more few fewer less least several no none nothing neither nor either not only just also and or but so yet '
|
|
307
|
+
+ 'if then than as at by for from in into of on onto to with within without across between among about after before since until '
|
|
308
|
+
+ 'while when where which who whom whose what why how although though because however whereas meanwhile otherwise still therefore '
|
|
309
|
+
+ 'thus hence overall together taken compared comparing based according looking grouped group by first second third next finally last '
|
|
310
|
+
+ 'yes today tonight yesterday currently right now recently lately earlier later open new is are was were be been has have had do does '
|
|
311
|
+
+ 'did can could may might must should would will shall per summary status partner partners customer customers result results '
|
|
312
|
+
+ 'look lookup lookups record records row rows figure figures count counts monday tuesday wednesday thursday friday saturday sunday '
|
|
313
|
+
+ 'january february march april may june july august september october november december '
|
|
314
|
+
// Discourse adverbs (fix round of PR #14).
|
|
315
|
+
+ 'similarly additionally notably moreover furthermore likewise conversely instead again specifically particularly generally '
|
|
316
|
+
+ 'consequently accordingly nonetheless nevertheless besides alternatively importantly interestingly unlike like contrast').split(' '));
|
|
317
|
+
const wordsOf = (t) => String(t || '').normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
318
|
+
/**
|
|
319
|
+
* The answer's sentences: split after . ! ? and at newlines. Cites opening a piece ("open. [r1] The …") belong to the
|
|
320
|
+
* sentence before; a piece with no letter (a lone cite) joins the one before.
|
|
321
|
+
*/
|
|
322
|
+
function sentencesOf(answer) {
|
|
323
|
+
const out = [];
|
|
324
|
+
for (let piece of String(answer).split(/(?<=[.!?])\s+|\n+/)) {
|
|
325
|
+
if (!piece.trim()) continue;
|
|
326
|
+
const lead = out.length ? /^(?:\s*\[r[1-4]\])+/.exec(piece) : null;
|
|
327
|
+
if (lead) { out[out.length - 1] += lead[0]; piece = piece.slice(lead[0].length); if (!piece.trim()) continue; }
|
|
328
|
+
const letters = /\p{L}/u.test(piece.replace(PLACEHOLDER, ' ').replace(CITE, ' '));
|
|
329
|
+
if (!letters && out.length) out[out.length - 1] += ` ${piece}`; else out.push(piece);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/** Lower-cased, with spaces, hyphens and underscores folded to one space ("false_positive" reads "false positive"). */
|
|
334
|
+
const flat = (t) => readable(t).toLowerCase().replace(/[\s_-]+/g, ' ').trim();
|
|
335
|
+
const hasPhrase = (text, phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(phrase)}(?![\\p{L}\\p{N}])`, 'u').test(text);
|
|
336
|
+
/** Does this row's value of a declared state field show the state? (strings case-blind, booleans exactly) — undefined when the row has no value. */
|
|
337
|
+
function stateOf(row, def) {
|
|
338
|
+
const v = (row.fields || {})[def.field];
|
|
339
|
+
if (v === undefined || v === null || v === '') return undefined;
|
|
340
|
+
return def.values.some((x) => (typeof x === 'boolean' ? v === x : typeof v === 'string' && v.trim().toLowerCase() === String(x).toLowerCase()));
|
|
341
|
+
}
|
|
342
|
+
|
|
116
343
|
/** Every string of a result with its path in attackerFields' spelling (rows[].fields.entity, facts.entities[].value). */
|
|
117
344
|
function stringsOf(r) {
|
|
118
345
|
const out = [];
|
|
@@ -185,6 +412,16 @@ function createCheck(o = {}) {
|
|
|
185
412
|
const rowChipLabel = typeof o.rowChipLabel === 'function' ? o.rowChipLabel : DEFAULT_ROW_CHIP;
|
|
186
413
|
const sayIps = o.sayIps === true;
|
|
187
414
|
const sayWindows = o.sayWindows === true;
|
|
415
|
+
// KNOWN NAMES (0.2.0): words the app says are ordinary in its answers though no result holds them (a product, "Sophos").
|
|
416
|
+
const knownNames = Object.freeze([...(o.knownNames || [])].map((n) => { if (typeof n !== 'string' || !n.trim()) throw new Error('ask check: knownNames is a list of names'); return n; }).flatMap((n) => wordsOf(n)));
|
|
417
|
+
// VERBATIM PATHS (0.1.2): a fact or a row field, in attackerFields' spelling — never a title, a label or a ref.
|
|
418
|
+
const verbatimFields = Object.freeze([...(o.verbatimFields || [])]);
|
|
419
|
+
for (const f of verbatimFields) {
|
|
420
|
+
if (typeof f !== 'string' || !/^(?:facts|rows\[\]\.fields)\.[a-z0-9_]+(?:\[\])?(?:\.[a-z0-9_]+)?$/i.test(f)) {
|
|
421
|
+
throw new Error(`ask check: verbatimFields names a fact or row-field path ('rows[].fields.upn', 'facts.entities[].value'), not ${String(f)}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const verbatimPaths = new Set([...verbatimFields, ...structured.flatMap((k) => [`facts.${k}`, `rows[].fields.${k}`])]);
|
|
188
425
|
|
|
189
426
|
/**
|
|
190
427
|
* The SAYABLE tokens:
|
|
@@ -201,6 +438,7 @@ function createCheck(o = {}) {
|
|
|
201
438
|
set.numbers = new Set();
|
|
202
439
|
set.ips = new Set();
|
|
203
440
|
set.windows = new Set();
|
|
441
|
+
set.verbatim = new Set();
|
|
204
442
|
const numeric = (v) => { if (typeof v === 'string' && /^(0(\.\d{1,3})?|1(\.0{1,3})?)$/.test(v)) set.numbers.add(v); };
|
|
205
443
|
const field = (v) => {
|
|
206
444
|
if (typeof v !== 'string' || !v || v.length > 64) return;
|
|
@@ -216,7 +454,7 @@ function createCheck(o = {}) {
|
|
|
216
454
|
for (const k of numericStructured) numeric((r.facts || {})[k]);
|
|
217
455
|
for (const k of idFacts) own((r.facts || {})[k]);
|
|
218
456
|
for (const [path, v] of stringsOf(r)) {
|
|
219
|
-
const fromAttacker = remote || attacker.has(path);
|
|
457
|
+
const fromAttacker = remote || attacker.has(path) || SNIPPET.test(path);
|
|
220
458
|
for (const w of readable(v).split(/[\s,;()"'“”‘’!?]+/)) {
|
|
221
459
|
const t = trim(w);
|
|
222
460
|
if (!t) continue;
|
|
@@ -225,6 +463,7 @@ function createCheck(o = {}) {
|
|
|
225
463
|
if (!fromAttacker || isInternalHost(t)) set.add(t);
|
|
226
464
|
}
|
|
227
465
|
if (!fromAttacker) windows(v);
|
|
466
|
+
if (verbatimPaths.has(path) && verbatimOk(v, fromAttacker, sayIps)) set.verbatim.add(v);
|
|
228
467
|
}
|
|
229
468
|
windows(r.label);
|
|
230
469
|
for (const x of r.extras || []) windows(x.label);
|
|
@@ -239,37 +478,250 @@ function createCheck(o = {}) {
|
|
|
239
478
|
return set;
|
|
240
479
|
}
|
|
241
480
|
|
|
481
|
+
/** Attacker text as the word rules read it, lower-cased: where a number word may be a quote. */
|
|
482
|
+
function attackerTexts(results) {
|
|
483
|
+
const out = [];
|
|
484
|
+
for (const r of results) {
|
|
485
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
486
|
+
const remote = !tool || tool.kind === 'remote';
|
|
487
|
+
const attacker = new Set(tool && !remote ? tool.attackerFields : []);
|
|
488
|
+
for (const [path, v] of stringsOf(r)) if (remote || attacker.has(path) || SNIPPET.test(path)) out.push(readable(v).toLowerCase().replace(/\s+/g, ' '));
|
|
489
|
+
for (const x of r.extras || []) if ((remote || attacker.has('extras[].label')) && typeof x.label === 'string') out.push(readable(x.label).toLowerCase().replace(/\s+/g, ' '));
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** SMALL NUMBER WORDS → the one cited figure of that value (see SMALL_NUMBER); anything else is left for the rules. */
|
|
495
|
+
function spellFigures(answer, cites, results) {
|
|
496
|
+
const cited = new Set([...(cites || []), ...[...answer.matchAll(CITE)].map((m) => m[1])].filter((c) => /^r[1-4]$/.test(c) && results[Number(c.slice(1)) - 1]));
|
|
497
|
+
const figures = [];
|
|
498
|
+
for (const rid of cited) {
|
|
499
|
+
const r = results[Number(rid.slice(1)) - 1];
|
|
500
|
+
if (r.help) continue;
|
|
501
|
+
if (Number.isInteger(r.count)) figures.push({ mark: `{{${rid}}}`, count: r.count });
|
|
502
|
+
for (const x of r.extras || []) if (Number.isInteger(x.count) && figureOf(results, rid, x.key)) figures.push({ mark: `{{${rid}.${x.key}}}`, count: x.count });
|
|
503
|
+
}
|
|
504
|
+
if (!figures.length) return answer;
|
|
505
|
+
let attackerText = null;
|
|
506
|
+
const has = (s, phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(phrase)}(?![\\p{L}\\p{N}])`, 'u').test(s);
|
|
507
|
+
// An apostrophe inside a word ("it’s", "don't") is no quotation mark (review of PR #12).
|
|
508
|
+
const quoted = (raw) => {
|
|
509
|
+
const before = raw.replace(/(?<=[\p{L}\p{N}])['’](?=\p{L})/gu, '');
|
|
510
|
+
return QUOTE_PAIRS.some(([a, b]) => (a === b ? (before.split(a).length - 1) % 2 === 1 : before.lastIndexOf(a) > before.lastIndexOf(b)));
|
|
511
|
+
};
|
|
512
|
+
return answer.replace(SMALL_WORD, (w, _g, at) => {
|
|
513
|
+
const before = answer.slice(0, at);
|
|
514
|
+
const after = answer.slice(at + w.length);
|
|
515
|
+
const prevJoin = before.match(/([\p{L}\p{N}]+)[\s-]+$/u);
|
|
516
|
+
const nextJoin = after.match(/^[\s-]+([\p{L}\p{N}]+)/u);
|
|
517
|
+
if ((prevJoin && JOINED_NUMBER.test(prevJoin[1])) || (nextJoin && JOINED_NUMBER.test(nextJoin[1]))) return w;
|
|
518
|
+
if (quoted(before)) return w;
|
|
519
|
+
const lw = w.toLowerCase();
|
|
520
|
+
const counted = (after.match(/^[ \t]+(\p{L}[\p{L}'’]*)/u) || [])[1];
|
|
521
|
+
if (!counted || NOT_COUNTED.test(counted) || IDIOM_AFTER.test(`${lw} ${counted}`)) return w;
|
|
522
|
+
const prevWord = (before.match(/(\p{L}+)[ \t]+$/u) || [])[1];
|
|
523
|
+
if (prevWord && IDIOM_BEFORE.test(prevWord)) return w;
|
|
524
|
+
const prev = (before.match(/([\p{L}\p{N}_'’-]+)[^\p{L}\p{N}]*$/u) || [])[1];
|
|
525
|
+
const next = (after.match(/^[^\p{L}\p{N}]*([\p{L}\p{N}_'’-]+)/u) || [])[1];
|
|
526
|
+
attackerText = attackerText || attackerTexts(results);
|
|
527
|
+
if (attackerText.some((s) => (prev && has(s, `${prev.toLowerCase()} ${lw}`)) || (next && has(s, `${lw} ${next.toLowerCase()}`)))) return w;
|
|
528
|
+
const match = figures.filter((f) => f.count === SMALL_NUMBER[lw]);
|
|
529
|
+
return match.length === 1 ? match[0].mark : w;
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** The attacker paths of a result: its tool's attackerFields; every path of an unknown or remote tool; any snippet. */
|
|
534
|
+
function attackerOf(r) {
|
|
535
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
536
|
+
const remote = !tool || tool.kind === 'remote';
|
|
537
|
+
const attacker = new Set(tool && !remote ? tool.attackerFields : []);
|
|
538
|
+
return (path) => remote || attacker.has(path) || SNIPPET.test(path);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* What the APP wrote at the RESULT level (facts, label, notes, extras' labels — no row, no attacker path), flattened. An
|
|
542
|
+
* extra whose label the app derives from an enum (a verdict or state code: `{ key, count, label, enum: true }`) is
|
|
543
|
+
* app-written even where the tool lists `extras[].label` as attacker text (fix round of PR #14: a breakdown's groups).
|
|
544
|
+
*/
|
|
545
|
+
function resultAppTexts(r) {
|
|
546
|
+
const isAttacker = attackerOf(r);
|
|
547
|
+
const out = [];
|
|
548
|
+
for (const [path, v] of stringsOf(r)) if (path.startsWith('facts') && !isAttacker(path)) out.push(flat(v));
|
|
549
|
+
if (!isAttacker('label') && typeof r.label === 'string') out.push(flat(r.label));
|
|
550
|
+
// An extra marked enum counts only for a group-by key the tool declares (groupBy) and a label in its closed list.
|
|
551
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
552
|
+
const enumOk = (x) => x.enum === true && tool && tool.kind !== 'remote' && tool.groupBy && typeof x.by === 'string' && tool.groupBy[x.by]
|
|
553
|
+
&& tool.groupBy[x.by].enum.some((v) => flat(v) === flat(x.label));
|
|
554
|
+
for (const x of r.extras || []) if ((enumOk(x) || !isAttacker('extras[].label')) && typeof x.label === 'string') out.push(flat(x.label));
|
|
555
|
+
for (const n of r.notes || []) if (typeof n.label === 'string') out.push(flat(n.label));
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
/** One row's app-written text, flattened: where a verdict about THAT row must show. */
|
|
559
|
+
function rowAppText(r, row) {
|
|
560
|
+
const isAttacker = attackerOf(r);
|
|
561
|
+
const parts = [];
|
|
562
|
+
if (!isAttacker('rows[].title') && typeof row.title === 'string') parts.push(row.title);
|
|
563
|
+
const walk = (v, path) => {
|
|
564
|
+
if (typeof v === 'string') { if (!isAttacker(path)) parts.push(v); } else if (Array.isArray(v)) v.forEach((x) => walk(x, `${path}[]`));
|
|
565
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`);
|
|
566
|
+
};
|
|
567
|
+
walk(row.fields || {}, 'rows[].fields');
|
|
568
|
+
return flat(parts.join(' '));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The rows a sentence NAMES (review round 3 of PR #14): by its ref, or by a field the tool DECLARES as the row's identity
|
|
572
|
+
* (`rowName: 'title'` or `['title', 'hostname']` — 'title' is the row's title, anything else a row field) — never by
|
|
573
|
+
* any other field, so a customer name every row shares, or a value planted in another row, names nothing.
|
|
574
|
+
*/
|
|
575
|
+
function namedRows(r, sentence, lower) {
|
|
576
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
577
|
+
const names = tool && tool.kind !== 'remote' && tool.rowName ? [].concat(tool.rowName) : [];
|
|
578
|
+
return (r.rows || []).filter((row) => {
|
|
579
|
+
if (typeof row.ref === 'string' && row.ref && sentence.includes(row.ref)) return true;
|
|
580
|
+
return names.some((k) => {
|
|
581
|
+
const v = k === 'title' ? row.title : (row.fields || {})[k];
|
|
582
|
+
return typeof v === 'string' && flat(v).length >= 4 && hasPhrase(lower, flat(v));
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
/** The state words a sweep may claim: STATE_WORDS and every state and synonym the cited tools declare. */
|
|
587
|
+
function stateVocab(scope) {
|
|
588
|
+
const out = new Set(STATE_WORDS);
|
|
589
|
+
for (const r of scope) {
|
|
590
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
591
|
+
for (const [w, def] of Object.entries((tool && tool.stateFields) || {})) { out.add(w); for (const x of def.synonyms || []) out.add(x); }
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
/** The declared state definition a word claims in one result — the state itself, a synonym, or "un<state>" (negated). */
|
|
596
|
+
function defOf(r, word) {
|
|
597
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
598
|
+
const fields = tool && tool.kind !== 'remote' && tool.stateFields ? tool.stateFields : {};
|
|
599
|
+
for (const [w, def] of Object.entries(fields)) if (w === word || (def.synonyms || []).includes(word)) return { def, negated: false };
|
|
600
|
+
if (word.startsWith('un')) for (const [w, def] of Object.entries(fields)) if (w === word.slice(2) || (def.synonyms || []).includes(word.slice(2))) return { def, negated: true };
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
/** Every word a result holds, attacker text included ("every name comes from the results"), and its look-up's words. */
|
|
604
|
+
function vocabularyOf(r) {
|
|
605
|
+
const out = new Set(knownNames);
|
|
606
|
+
for (const [, v] of stringsOf(r)) for (const w of wordsOf(readable(v))) out.add(w);
|
|
607
|
+
for (const t of [r.label, r.words, ...(r.extras || []).map((x) => x.label), ...(r.notes || []).map((n) => n.label)]) for (const w of wordsOf(readable(t || ''))) out.add(w);
|
|
608
|
+
for (const row of r.rows || []) if (typeof row.ref === 'string') for (const w of wordsOf(row.ref)) out.add(w);
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* THE CLAIM AND ANALYSE RULES over the answer's sentences (see the header). Returns { injected, format } — the first
|
|
614
|
+
* refusal of each class (or null). EVERY sentence is read for the injection-class rules (verdict, all/none, unframed
|
|
615
|
+
* conclusion, unknown name) before any format-class refusal (uncited sentence or figure) counts, so an uncited lead
|
|
616
|
+
* sentence never hides a later obeyed one (review round 3 of PR #14). `cited` is the whole answer's cited indexes.
|
|
617
|
+
*/
|
|
618
|
+
function claimRefusal(answer, results, cited, analyse, question) {
|
|
619
|
+
const qWords = new Set(wordsOf(readable(question || '')));
|
|
620
|
+
let format = null;
|
|
621
|
+
for (const sentence of sentencesOf(answer)) {
|
|
622
|
+
const own = [...new Set([...sentence.matchAll(CITE)].map((m) => Number(m[1].slice(1)) - 1))];
|
|
623
|
+
const scope = (own.length ? own : [...cited]).map((i) => results[i]).filter(Boolean);
|
|
624
|
+
const text = readable(sentence.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' '));
|
|
625
|
+
const lower = flat(text);
|
|
626
|
+
// A VERDICT, PER ROW: what the app-written text of EVERY row the sentence names says; naming none, what the result
|
|
627
|
+
// itself says, or what every one of its rows says (rows that disagree settle nothing).
|
|
628
|
+
for (const m of text.matchAll(VERDICT)) {
|
|
629
|
+
const phrase = flat(m[0]);
|
|
630
|
+
const supported = scope.some((r) => {
|
|
631
|
+
const named = namedRows(r, sentence, lower);
|
|
632
|
+
if (named.length) return named.every((row) => hasPhrase(rowAppText(r, row), phrase));
|
|
633
|
+
if (resultAppTexts(r).some((t) => hasPhrase(t, phrase))) return true;
|
|
634
|
+
const rows = r.rows || [];
|
|
635
|
+
return rows.length > 0 && rows.every((row) => hasPhrase(rowAppText(r, row), phrase));
|
|
636
|
+
});
|
|
637
|
+
if (!supported) return { injected: { ok: false, code: 'unsupported_verdict', reason: `calls something “${m[0].slice(0, 30)}”, which no cited row says` }, format };
|
|
638
|
+
}
|
|
639
|
+
// ALL / NONE / NOT ALL OF THEM in a state: decided ONLY from a field the tool declares holds that state.
|
|
640
|
+
for (const claim of sweepsOf(lower, stateVocab(scope))) {
|
|
641
|
+
const holds = scope.length > 0 && scope.every((r) => {
|
|
642
|
+
const d = defOf(r, claim.word);
|
|
643
|
+
const rows = r.rows || [];
|
|
644
|
+
if (!d || !rows.length) return false;
|
|
645
|
+
const p = d.negated ? (claim.polarity === 'all' ? 'none' : claim.polarity === 'none' ? 'all' : 'notall') : claim.polarity;
|
|
646
|
+
// NOT ALL: one row that holds the field and is not in the state is enough, from any number of rows shown.
|
|
647
|
+
if (p === 'notall') return rows.some((row) => stateOf(row, d.def) === false);
|
|
648
|
+
if (Number.isInteger(r.count) && r.count !== rows.length) return false;
|
|
649
|
+
return rows.every((row) => stateOf(row, d.def) === (p === 'all'));
|
|
650
|
+
});
|
|
651
|
+
if (!holds) return { injected: { ok: false, code: 'sweeping_claim', reason: `says ${claim.polarity === 'notall' ? 'not all' : claim.polarity} of them are “${claim.word}”, which no declared state field of the cited rows shows` }, format };
|
|
652
|
+
}
|
|
653
|
+
if (!analyse) continue;
|
|
654
|
+
// ANALYSE: framed conclusions, no new names, every sentence cited, every figure from a result its sentence cites.
|
|
655
|
+
if (CONCLUSION.test(lower) && !FRAME.test(lower)) return { injected: { ok: false, code: 'unframed_conclusion', reason: 'draws a conclusion without saying it comes from the results' }, format };
|
|
656
|
+
const vocab = new Set(qWords);
|
|
657
|
+
for (const r of scope) for (const w of vocabularyOf(r)) vocab.add(w);
|
|
658
|
+
for (const m of text.matchAll(CAPITALISED)) {
|
|
659
|
+
for (const w of wordsOf(m[0])) {
|
|
660
|
+
if (w.length < 2 || /\p{N}/u.test(w) || NAME_COMMON.has(w) || vocab.has(w)) continue;
|
|
661
|
+
return { injected: { ok: false, code: 'unknown_name', reason: `names “${m[0].slice(0, 40)}”, which the cited results do not` }, format };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!format && !own.length) format = { ok: false, code: 'uncited_sentence', reason: 'a sentence cites no result' };
|
|
665
|
+
for (const m of sentence.matchAll(PLACEHOLDER)) {
|
|
666
|
+
if (!format && !own.includes(Number(m[1].slice(1)) - 1)) format = { ok: false, code: 'uncited_figure', reason: `a figure ${m[0]} from a result its sentence does not cite` };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { injected: null, format };
|
|
670
|
+
}
|
|
671
|
+
|
|
242
672
|
/**
|
|
243
673
|
* Check one answer. Returns { ok: true, answer, cites } or { ok: false, code, reason } — `code` is one of REFUSAL_CODES.
|
|
244
674
|
* @param {*} reply the model's parsed JSON
|
|
245
675
|
* @param {object[]} results the look-ups' results, in order (r1 = results[0])
|
|
676
|
+
* @param {{mode?: 'lookup'|'analyse', question?: string}} [opts] 0.2.0: analyse mode, and the question its names may come from
|
|
246
677
|
*/
|
|
247
|
-
function checkAnswer(reply, results) {
|
|
678
|
+
function checkAnswer(reply, results, opts = {}) {
|
|
679
|
+
const analyse = opts.mode === 'analyse';
|
|
248
680
|
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return { ok: false, code: 'shape', reason: 'not an object' };
|
|
249
681
|
const keys = Object.keys(reply).filter((k) => !['answer', 'cites'].includes(k));
|
|
250
682
|
if (keys.length) return { ok: false, code: 'shape', reason: 'unexpected fields' };
|
|
251
|
-
|
|
683
|
+
let answer = typeof reply.answer === 'string' ? reply.answer.trim() : '';
|
|
252
684
|
if (!answer) return { ok: false, code: 'shape', reason: 'empty answer' };
|
|
253
|
-
|
|
685
|
+
// TOO LONG is a format refusal, applied AFTER the injection-class rules (final round of PR #14): a long obeyed answer is
|
|
686
|
+
// refused for what it obeys, not for its length.
|
|
687
|
+
const tooLong = answer.length > 700;
|
|
254
688
|
if (!Array.isArray(reply.cites) || reply.cites.some((c) => typeof c !== 'string')) return { ok: false, code: 'shape', reason: 'cites is not a list of ids' };
|
|
255
689
|
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(answer)) return { ok: false, code: 'control_chars', reason: 'control characters' };
|
|
256
690
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
691
|
+
// INJECTION-CLASS RULES FIRST (review round 3 of PR #14): a FORMAT refusal found on the way (an unknown cite or figure,
|
|
692
|
+
// a malformed placeholder, a number word) is kept, not returned, until every link, obeyed and claim rule has read the
|
|
693
|
+
// whole answer — so a format slip never hides an obeyed sentence.
|
|
694
|
+
let fmt = null;
|
|
695
|
+
const later = (code, reason) => { if (!fmt) fmt = { ok: false, code, reason }; };
|
|
696
|
+
for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) later('unknown_cite', `cites ${String(c).slice(0, 8)}, which no look-up returned`);
|
|
697
|
+
for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) later('unknown_cite', `cites ${m[1]}, which no look-up returned`);
|
|
698
|
+
const knownCites = reply.cites.filter((c) => /^r[1-4]$/.test(c) && results[Number(c.slice(1)) - 1]);
|
|
699
|
+
answer = spellFigures(answer, knownCites, results);
|
|
700
|
+
for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) later('unknown_figure', `a figure ${m[0]} no look-up returned`);
|
|
260
701
|
const bare = answer.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' ');
|
|
261
|
-
if (/[{}]/.test(bare))
|
|
702
|
+
if (/[{}]/.test(bare)) later('bad_placeholder', 'a malformed figure placeholder');
|
|
262
703
|
// NFKC, other full stops and NO format characters for the word rules: what the person would read.
|
|
263
704
|
const norm = readable(bare);
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
705
|
+
const cited = new Set([...knownCites, ...[...answer.matchAll(CITE)].map((m) => m[1])].map((c) => Number(c.slice(1)) - 1).filter((i) => results[i]));
|
|
706
|
+
// ANALYSE: what may be typed is what the CITED results returned.
|
|
707
|
+
const allowed = allowedTokens(analyse ? results.filter((_, i) => cited.has(i)) : results);
|
|
708
|
+
if (LINKISH.test(norm) || DANGEROUS_SCHEME.test(norm)) return { ok: false, code: 'link', reason: 'a link or markup' };
|
|
709
|
+
// VERBATIM SPANS read as one plain word by DOMAINISH, the token rules and the allow-list (and by nothing else).
|
|
710
|
+
const verbatim = [...allowed.verbatim].sort((a, b) => b.length - a.length);
|
|
711
|
+
const normV = maskVerbatim(norm, verbatim);
|
|
712
|
+
const rawV = maskVerbatim(bare.normalize('NFC'), verbatim);
|
|
713
|
+
for (const m of normV.matchAll(DOMAINISH)) {
|
|
267
714
|
const d = m[0];
|
|
268
715
|
if (d.includes('/') || !(INTERNAL_SUFFIX.test(d) || allowed.has(trim(d)))) return { ok: false, code: 'link', reason: 'a link or markup' };
|
|
269
716
|
}
|
|
270
717
|
const word = NUMBER_WORD.exec(norm);
|
|
271
|
-
if (word)
|
|
718
|
+
if (word) later('number_word', `writes the number “${word[0]}” instead of a figure placeholder`);
|
|
272
719
|
for (const [re, why, code] of OBEYED) if (re.test(norm)) return { ok: false, code, reason: why };
|
|
720
|
+
const claim = claimRefusal(answer, results, cited, analyse, opts.question);
|
|
721
|
+
if (claim.injected) return claim.injected;
|
|
722
|
+
if (tooLong) return { ok: false, code: 'shape', reason: 'answer too long' };
|
|
723
|
+
if (fmt) return fmt;
|
|
724
|
+
if (claim.format) return claim.format;
|
|
273
725
|
|
|
274
726
|
const named = (x) => {
|
|
275
727
|
let y = x;
|
|
@@ -279,7 +731,7 @@ function createCheck(o = {}) {
|
|
|
279
731
|
};
|
|
280
732
|
// BOTH READINGS: the raw text keeps a Roman numeral (Ⅸ — NFKC makes it the letters IX), the readable one joins what
|
|
281
733
|
// a format character split.
|
|
282
|
-
for (const src of [
|
|
734
|
+
for (const src of [rawV, normV].map(named)) {
|
|
283
735
|
for (const t of src.match(REF_TOKEN) || []) {
|
|
284
736
|
if (!allowed.has(t)) return { ok: false, code: 'unknown_ref', reason: `names “${t.slice(0, 40)}”, which is not an id a look-up returned` };
|
|
285
737
|
}
|
|
@@ -291,7 +743,7 @@ function createCheck(o = {}) {
|
|
|
291
743
|
if (tok && /\p{N}/u.test(tok) && !allowed.has(tok) && !(HOSTLIKE.test(tok) && INTERNAL_SUFFIX.test(tok))) return { ok: false, code: 'unknown_token', reason: `states “${tok.slice(0, 40)}”, which no look-up returned as an id, a field or a host` };
|
|
292
744
|
}
|
|
293
745
|
}
|
|
294
|
-
for (const ch of
|
|
746
|
+
for (const ch of rawV) {
|
|
295
747
|
if (!ALLOWED_CHAR.test(ch)) return { ok: false, code: 'disallowed_char', reason: `a character outside the allowed set (U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')})` };
|
|
296
748
|
}
|
|
297
749
|
return { ok: true, answer, cites: [...new Set(reply.cites)] };
|
|
@@ -339,7 +791,7 @@ function createCheck(o = {}) {
|
|
|
339
791
|
return chips;
|
|
340
792
|
}
|
|
341
793
|
|
|
342
|
-
const config = Object.freeze({ structured, numericStructured, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows,
|
|
794
|
+
const config = Object.freeze({ structured, numericStructured, verbatimFields, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows, knownNames,
|
|
343
795
|
namedTokens: namedTokens.map(([re, to]) => [String(re), String(to)]) });
|
|
344
796
|
/**
|
|
345
797
|
* WHAT DECIDES A VERDICT (review H3 of PR #9): the rule sources and this app's config, including its id test and row-chip
|
|
@@ -350,8 +802,10 @@ function createCheck(o = {}) {
|
|
|
350
802
|
}
|
|
351
803
|
|
|
352
804
|
/** Every module-level rule and helper a verdict depends on, as text (with createCheck's own source). */
|
|
353
|
-
const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
|
|
354
|
-
MARK_BEFORE_PUNCT, MARK_AFTER_DOT,
|
|
355
|
-
|
|
805
|
+
const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DANGEROUS_SCHEME, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
|
|
806
|
+
MARK_BEFORE_PUNCT, MARK_AFTER_DOT, VERBATIM_EXTRA, VERBATIM_VALUE, CIDR4, VERBATIM_BEFORE, VERBATIM_AFTER, SCHEMEISH, IP_PART, DOMAIN_USER, SMALL_WORD, JOINED_NUMBER, NOT_COUNTED, IDIOM_AFTER, IDIOM_BEFORE, JSON.stringify(SMALL_NUMBER),
|
|
807
|
+
JSON.stringify(QUOTE_PAIRS), INVISIBLE, OTHER_DOTS, BRACKETED_DOT, SPELLED_DOT, SPACED_DOT, ALLOWED_CHAR, ...OBEYED.map(([re, why, code]) => `${re}|${why}|${code}`), REFUSAL_CODES.join(','),
|
|
808
|
+
SNIPPET, VERDICT, STATE_WORDS.join(' '), SWEEP_QUANT, SWEEP_COPULA, SWEEP_ADVERB, SWEEP_NEGATION, sweepsOf, CONCLUSION, FRAME, CAPITALISED, [...NAME_COMMON].join(' '),
|
|
809
|
+
unitOf, isInternalHost, readable, verbatimOk, escapeRe, maskVerbatim, trim, wordsOf, sentencesOf, flat, hasPhrase, stateOf, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
|
|
356
810
|
|
|
357
|
-
module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, segmentsOf, figureOf, stringsOf, readable };
|
|
811
|
+
module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, VERDICT, segmentsOf, figureOf, stringsOf, readable, sentencesOf };
|