Skip to content

Commit a5e8484

Browse files
committed
[extension] Fix highligthing
1 parent c425c7e commit a5e8484

5 files changed

Lines changed: 79 additions & 21 deletions

File tree

.stores/cover-440x280.png

62 KB
Loading

.stores/logo-300x-300.png

24.5 KB
Loading

.stores/screenshot1-1280x-800.png

383 KB
Loading

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- **New TLD Support**: Added `.me` and `.company` TLDs for domain detection, enabling detection of domains like `example[.]me` and `example[.]company` commonly seen in threat intel reports
1212

1313
### Fixed
14-
- **IOC List Highlighting**: Fixed partial highlighting of defanged observables in IOC lists (e.g., on threat intel blog posts). When multiple observables exist in the same text node, all occurrences are now properly highlighted instead of only the first one. Root cause: matches across different observables were applied separately, causing DOM node invalidation for subsequent highlights
14+
- **IOC List Highlighting**: Fixed partial highlighting of defanged observables in IOC lists (e.g., on threat intel blog posts). When multiple observables exist in the same text node, all occurrences are now properly highlighted. The fix deduplicates matches by position, sorts for proper DOM traversal order, and applies highlights from end to start to prevent node reference invalidation
15+
- **First IOC Not Highlighted**: Fixed first domain in IOC lists not being highlighted when preceded by a sentence ending with a period (e.g., "Sinkholed by MSTIC." followed by "sopatrasoftware[.]net"). The boundary checker now correctly recognizes DOM text node boundaries, so punctuation from a previous paragraph doesn't incorrectly reject the first item as being "inside an identifier"
1516
- **PDF IOC Highlighting**: Applied same highlighting fix to PDF scanner for consistent behavior when multiple entities appear in the same text region
17+
- **URL Path File Detection**: Fixed incorrect detection of filenames at the end of URLs (e.g., `public-index.7162a3fd.js` in `hxxps://account.proton.me/assets/static/public-index.7162a3fd.js`). Files following a `/` in a URL path are no longer detected as separate entities
1618

1719
## [0.0.18] - 2026-01-07
1820

src/content/highlighting.ts

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -287,12 +287,14 @@ function isValidCharBoundary(char: string | undefined): boolean {
287287
* Only checks character BEFORE because:
288288
* - "dl.software" → `.` before "software" = inside domain = reject
289289
* - "Ransomware.Live." → `.` after is just punctuation = allow
290+
* - "/public-index.js" → `/` before filename = inside URL path = reject
290291
*/
291292
function isInsideIdentifier(charBefore: string | undefined): boolean {
292293
if (!charBefore) return false;
293-
// Character BEFORE the match indicates we're inside an identifier
294+
// Character BEFORE the match indicates we're inside an identifier or URL path
294295
// e.g., "dl.software" - the dot before "software" means it's part of a domain
295-
return /[.\-_[\]]/.test(charBefore);
296+
// e.g., "/public-index.js" - the slash before means it's part of a URL path
297+
return /[.\-_[\]/]/.test(charBefore);
296298
}
297299

298300
/**
@@ -306,7 +308,8 @@ function isInsideIdentifier(charBefore: string | undefined): boolean {
306308
* (allows multi-node matches like "DEGermany" but NOT "dl.software")
307309
*
308310
* Special handling:
309-
* - Character BEFORE: '.', '-', '_', '[', ']' = inside identifier = reject
311+
* - Character BEFORE: '.', '-', '_', '[', ']', '/' = inside identifier = reject
312+
* UNLESS we're at a node boundary (the character is from a different text node)
310313
* - Character AFTER: these are allowed (could be punctuation like "Ransomware.Live.")
311314
*/
312315
function isWordBoundaryWithNodeAwareness(
@@ -317,16 +320,22 @@ function isWordBoundaryWithNodeAwareness(
317320
matchEnd: number,
318321
nodeMap: NodeMapEntry[]
319322
): boolean {
323+
// Check if we're at a node boundary FIRST
324+
// If so, the character before is from a different text node and shouldn't count
325+
// as being "inside an identifier". E.g., "MSTIC." in one node and "sopatrasoftware[.]net"
326+
// in the next node - the dot is from a different context, not part of an identifier.
327+
const atNodeBoundaryBefore = isAtNodeBoundary(matchStart, nodeMap);
328+
320329
// Check if we're inside an identifier (character BEFORE indicates this)
321330
// e.g., "dl.software" - the dot before "software" means reject
322-
// But "Ransomware.Live." - the dot after is just punctuation, allow it
323-
if (isInsideIdentifier(charBefore)) {
331+
// But skip this check if we're at a node boundary - the dot is from a different node
332+
if (!atNodeBoundaryBefore && isInsideIdentifier(charBefore)) {
324333
return false;
325334
}
326335

327-
// THEN: Check if the character is a valid punctuation/whitespace boundary
336+
// Check if the character is a valid punctuation/whitespace boundary
328337
// OR if it's at a DOM node boundary (for multi-node text like "DE" + "Germany")
329-
const isValidBefore = isValidCharBoundary(charBefore) || isAtNodeBoundary(matchStart, nodeMap);
338+
const isValidBefore = isValidCharBoundary(charBefore) || atNodeBoundaryBefore;
330339
const isValidAfter = isValidCharBoundary(charAfter) || isAtNodeBoundary(matchEnd, nodeMap);
331340

332341
return isValidBefore && isValidAfter;
@@ -558,12 +567,23 @@ export function highlightResults(
558567
}
559568
}
560569

561-
// Sort all matches by position descending to apply from end to start
570+
// Deduplicate matches by position - keep only one match per position
571+
// This handles cases where both clean and defanged variants find the same text
572+
const seenPositions = new Set<number>();
573+
const deduplicatedMatches = allObservableMatches.filter(({ match }) => {
574+
if (seenPositions.has(match.pos)) {
575+
return false;
576+
}
577+
seenPositions.add(match.pos);
578+
return true;
579+
});
580+
581+
// Sort matches by position DESCENDING to process from end to start
562582
// This prevents earlier highlights from invalidating later node references
563-
allObservableMatches.sort((a, b) => b.match.pos - a.match.pos);
583+
deduplicatedMatches.sort((a, b) => b.match.pos - a.match.pos);
564584

565-
// Apply all highlights in a single reverse pass
566-
for (const { match, meta, searchValue } of allObservableMatches) {
585+
// Apply highlights one at a time in sorted order (highest position first)
586+
for (const { match, meta, searchValue } of deduplicatedMatches) {
567587
applyHighlightsInReverse([match], () => createScanResultHighlightConfig(meta, searchValue, handlers));
568588
}
569589

@@ -602,9 +622,21 @@ export function highlightResults(
602622
}
603623
}
604624

605-
// Sort by position descending and apply in a single reverse pass
606-
allOctiMatches.sort((a, b) => b.match.pos - a.match.pos);
607-
for (const { match, meta, entityName } of allOctiMatches) {
625+
// Deduplicate matches by position
626+
const seenOctiPositions = new Set<number>();
627+
const deduplicatedOctiMatches = allOctiMatches.filter(({ match }) => {
628+
if (seenOctiPositions.has(match.pos)) {
629+
return false;
630+
}
631+
seenOctiPositions.add(match.pos);
632+
return true;
633+
});
634+
635+
// Sort by position DESCENDING to process from end to start
636+
deduplicatedOctiMatches.sort((a, b) => b.match.pos - a.match.pos);
637+
638+
// Apply highlights one at a time in sorted order
639+
for (const { match, meta, entityName } of deduplicatedOctiMatches) {
608640
applyHighlightsInReverse([match], () => createScanResultHighlightConfig(meta, entityName, handlers));
609641
}
610642
}
@@ -674,9 +706,21 @@ export function highlightResults(
674706
}
675707
}
676708

677-
// Sort by position descending and apply in a single reverse pass
678-
allOaevMatches.sort((a, b) => b.match.pos - a.match.pos);
679-
for (const { match, meta, entityName } of allOaevMatches) {
709+
// Deduplicate matches by position
710+
const seenOaevPositions = new Set<number>();
711+
const deduplicatedOaevMatches = allOaevMatches.filter(({ match }) => {
712+
if (seenOaevPositions.has(match.pos)) {
713+
return false;
714+
}
715+
seenOaevPositions.add(match.pos);
716+
return true;
717+
});
718+
719+
// Sort by position DESCENDING to process from end to start
720+
deduplicatedOaevMatches.sort((a, b) => b.match.pos - a.match.pos);
721+
722+
// Apply highlights one at a time in sorted order
723+
for (const { match, meta, entityName } of deduplicatedOaevMatches) {
680724
applyHighlightsInReverse([match], () => createScanResultHighlightConfig(meta, entityName, handlers));
681725
}
682726
}
@@ -828,9 +872,21 @@ export function highlightResultsForInvestigation(
828872
}
829873
}
830874

831-
// Sort by position descending and apply in a single reverse pass
832-
allMatches.sort((a, b) => b.match.pos - a.match.pos);
833-
for (const { match, type, value, entityId, platformId } of allMatches) {
875+
// Deduplicate matches by position
876+
const seenInvestPositions = new Set<number>();
877+
const deduplicatedInvestMatches = allMatches.filter(({ match }) => {
878+
if (seenInvestPositions.has(match.pos)) {
879+
return false;
880+
}
881+
seenInvestPositions.add(match.pos);
882+
return true;
883+
});
884+
885+
// Sort by position DESCENDING to process from end to start
886+
deduplicatedInvestMatches.sort((a, b) => b.match.pos - a.match.pos);
887+
888+
// Apply highlights one at a time in sorted order
889+
for (const { match, type, value, entityId, platformId } of deduplicatedInvestMatches) {
834890
applyHighlightsInReverse([match], () =>
835891
createInvestigationHighlightConfig(type, value, entityId, platformId, onHighlightClick)
836892
);

0 commit comments

Comments
 (0)