diff --git a/package.json b/package.json index 2b575dd..ac83dbd 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "snyk-api-and-web-record-sequence", - "version": "1.1.0", + "version": "1.2.0", "description": "Snyk API & Web Record login/sequence", "license": "MIT", "scripts": { diff --git a/src/manifest-firefox.json b/src/manifest-firefox.json index 4660825..080126f 100755 --- a/src/manifest-firefox.json +++ b/src/manifest-firefox.json @@ -1,6 +1,6 @@ { "name": "Snyk API & Web Sequence Recorder", - "version": "1.1.0", + "version": "1.2.0", "browser_specific_settings": { "gecko": { "id": "sequence-recorder@probely.com", diff --git a/src/pages/Content/modules/collectEvents.js b/src/pages/Content/modules/collectEvents.js index 75ae049..3438765 100644 --- a/src/pages/Content/modules/collectEvents.js +++ b/src/pages/Content/modules/collectEvents.js @@ -19,6 +19,59 @@ const lastNodes = { }; let stoMouseover = false; +let pendingInput = null; +let flushedInput = null; +const keystrokeBuffers = new WeakMap(); + +function getEffectiveValue(element) { + const domValue = element.value; + if (domValue && domValue.trim()) { + return domValue; + } + return keystrokeBuffers.get(element) || domValue; +} + +function isBufferedValue(element) { + const domValue = element.value; + return !(domValue && domValue.trim()) && keystrokeBuffers.has(element); +} + +function isCanvasSurface(element) { + if (element.nodeName.toLowerCase() === 'canvas') return true; + if (element.shadowRoot) { + try { + if (element.shadowRoot.querySelector('canvas')) return true; + } catch (ex) { /* ignore */ } + } + for (const child of element.children || []) { + if (child.shadowRoot) { + try { + if (child.shadowRoot.querySelector('canvas')) return true; + } catch (ex) { /* ignore */ } + } + } + return false; +} + +function getStableInputSelector(element, doc) { + const dynamicPatterns = ['focus', 'highlight', 'editable', 'caret']; + const classes = Array.from(element.classList || []).filter((cls) => { + const lower = cls.toLowerCase(); + return !dynamicPatterns.some((p) => lower.includes(p)) && !/^[0-9]/.test(cls); + }); + for (const cls of classes) { + const sel = '.' + cls; + try { + const matches = doc.querySelectorAll(sel); + if (matches.length === 1 && matches[0] === element) { + return sel; + } + } catch (ex) { + // ignore + } + } + return null; +} export function interceptEvents(event, doc, ifrSelector, callback) { let hasKeyReturn = false; @@ -110,6 +163,26 @@ export function interceptEvents(event, doc, ifrSelector, callback) { if (type === 'click') { lastNodes.click = tgt; + if (pendingInput && pendingInput.element !== tgt && callback) { + const useBuffer = isBufferedValue(pendingInput.element); + const fillEvent = { + ...pendingInput.oEventBase, + type: useBuffer ? 'bfill_value' : 'fill_value', + value: getEffectiveValue(pendingInput.element), + frame: pendingInput.frame, + }; + if (useBuffer) { + const stableSel = getStableInputSelector(pendingInput.element, doc); + if (stableSel) { + fillEvent.css = stableSel; + } + } + callback({ messageType: 'events', event: { ...fillEvent } }); + keystrokeBuffers.delete(pendingInput.element); + flushedInput = pendingInput.element; + pendingInput = null; + } + if ( lastNodes.return === lastNodes.change && tgt !== lastNodes.return && @@ -134,7 +207,7 @@ export function interceptEvents(event, doc, ifrSelector, callback) { } } let typeStr = 'click'; - if (nodeName === 'canvas') { + if (isCanvasSurface(tgt)) { const rect = tgt.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; @@ -261,6 +334,16 @@ export function interceptEvents(event, doc, ifrSelector, callback) { lastNodes.keydown = tgt; if (['input', 'textarea'].indexOf(nodeName) > -1) { oNodes[tgt] = tgt.value; + pendingInput = { element: tgt, oEventBase: { ...oEventBase }, frame: ifrSelector }; + if (event.key && event.key.length === 1) { + const buf = keystrokeBuffers.get(tgt) || ''; + keystrokeBuffers.set(tgt, buf + event.key); + } else if (event.key === 'Backspace') { + const buf = keystrokeBuffers.get(tgt) || ''; + if (buf.length > 0) { + keystrokeBuffers.set(tgt, buf.slice(0, -1)); + } + } } if ( nodeName === 'input' && @@ -271,15 +354,30 @@ export function interceptEvents(event, doc, ifrSelector, callback) { hasKeyReturn = true; // lastSelectorWithReturn = selector; lastNodes.return = tgt; + const useBuffer = isBufferedValue(tgt); oEventToSend = { ...oEventBase, - type: 'fill_value', - value: tgt.value, + type: useBuffer ? 'bfill_value' : 'fill_value', + value: getEffectiveValue(tgt), frame: ifrSelector, }; + if (useBuffer) { + const stableSel = getStableInputSelector(tgt, doc); + if (stableSel) { + oEventToSend.css = stableSel; + } + } + keystrokeBuffers.delete(tgt); } } else if (type === 'blur') { lastNodes.blur = tgt; + if (tgt === flushedInput) { + flushedInput = null; + return; + } + if (pendingInput && pendingInput.element === tgt) { + pendingInput = null; + } if (['input', 'textarea'].indexOf(nodeName) > -1) { oNodes[tgt] = tgt.value; if (tgt === lastNodes.return) { @@ -292,12 +390,20 @@ export function interceptEvents(event, doc, ifrSelector, callback) { ) { return; } + const useBuffer = isBufferedValue(tgt); oEventToSend = { ...oEventBase, - type: 'fill_value', - value: tgt.value, + type: useBuffer ? 'bfill_value' : 'fill_value', + value: getEffectiveValue(tgt), frame: ifrSelector, }; + if (useBuffer) { + const stableSel = getStableInputSelector(tgt, doc); + if (stableSel) { + oEventToSend.css = stableSel; + } + } + keystrokeBuffers.delete(tgt); } } else if (type === 'change') { lastNodes.change = tgt; diff --git a/src/pages/Popup/Popup.jsx b/src/pages/Popup/Popup.jsx index 159958e..84b4fb0 100644 --- a/src/pages/Popup/Popup.jsx +++ b/src/pages/Popup/Popup.jsx @@ -3,7 +3,7 @@ import logo from '../../assets/img/logo_probely.svg'; import help from '../../assets/img/help.svg'; import './Popup.css'; -const helpURL = 'https://help.probely.com/en/articles/5402869-how-to-record-a-sequence-with-probely-s-sequence-recorder-plugin'; +const helpURL = 'https://docs.snyk.io/scan-fix-and-prevent/scan-with-snyk/snyk-api-web/configure-targets/configure-web-targets/use-sequence-recorder'; const Popup = (props) => { // 🔴 @@ -11,22 +11,22 @@ const Popup = (props) => { const [isRecording, setIsRecording] = useState(false); const [startURL, setStartURL] = useState(''); const [recordingData, setRecordingData] = useState([]); - const [copyStatus, setCopyStatus] = useState({status: false, error: false, msg: 'Successfully copied to clipboard'}); + const [copyStatus, setCopyStatus] = useState({ status: false, error: false, msg: 'Successfully copied to clipboard' }); useEffect(() => { if (chrome && chrome.storage) { chrome.storage.sync.get(['isRecording'], (data) => { const recording = data.isRecording; - if(recording) { + if (recording) { setIsRecording(true); (chrome.action || chrome.browserAction).setBadgeText({ text: '🔴', - }, () => {}); + }, () => { }); } else { setIsRecording(false); (chrome.action || chrome.browserAction).setBadgeText({ text: '', - }, () => {}); + }, () => { }); } }); chrome.runtime.onMessage.addListener((data, sender, sendResponse) => { @@ -51,8 +51,8 @@ const Popup = (props) => { if (chrome) { (chrome.action || chrome.browserAction).setBadgeText({ text: '🔴', - }, () => {}); - chrome.storage.sync.set({isRecording: true}, () => { + }, () => { }); + chrome.storage.sync.set({ isRecording: true }, () => { chrome.runtime.sendMessage({ messageType: 'start', event: { @@ -63,7 +63,7 @@ const Popup = (props) => { url: startURL, }, }); - chrome.tabs.create({active: true, url: startURL}, (aa) => { + chrome.tabs.create({ active: true, url: startURL }, (aa) => { }); }); } @@ -74,15 +74,15 @@ const Popup = (props) => { if (chrome) { (chrome.action || chrome.browserAction).setBadgeText({ text: '', - }, () => {}); - chrome.storage.sync.set({isRecording: false}, () => { + }, () => { }); + chrome.storage.sync.set({ isRecording: false }, () => { askForRecordingData(); - chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (tabs && tabs.length) { const curTab = tabs[0]; chrome.tabs.remove(curTab.id); - chrome.tabs.create({active: true, url: './review.html'}, (aa) => { + chrome.tabs.create({ active: true, url: './review.html' }, (aa) => { }); } }); @@ -126,7 +126,7 @@ const Popup = (props) => { msg: 'Successfully copied to clipboard' }); setTimeout(() => { - setCopyStatus({status: false, error: false, msg: ''}); + setCopyStatus({ status: false, error: false, msg: '' }); }, 3000); } else { setCopyStatus({ @@ -135,7 +135,7 @@ const Popup = (props) => { msg: 'Error on copy to clipboard' }); setTimeout(() => { - setCopyStatus({status: false, error: false, msg: ''}); + setCopyStatus({ status: false, error: false, msg: '' }); }, 5000); } } @@ -144,7 +144,7 @@ const Popup = (props) => { function onClickDownload() { var blob = new Blob([JSON.stringify(recordingData, null, 2)], { type: "text/plain;charset=utf-8" - }); + }); var a = document.createElement('a'); a.download = 'snyk-api-and-web-recording.json'; a.rel = 'noopener'; @@ -169,7 +169,7 @@ const Popup = (props) => { function onClickHelpLink(ev) { ev.preventDefault(); - chrome.tabs.create({active: true, url: helpURL}, (aa) => { + chrome.tabs.create({ active: true, url: helpURL }, (aa) => { }); } @@ -181,8 +181,8 @@ const Popup = (props) => {
- Use this plugin to record a sequence of steps to be followed by Snyk API & Web during a scan.{' '} - When you finish recording, upload the script to your target settings. + Use this plugin to record a sequence of steps to be followed by Snyk API & Web during a scan.{' '} + When you finish recording, upload the script to your target settings.
You can copy or download the recorded information here or through the plugin window.
-After saving your sequence, make sure to import it to your target settings at Snyk API & Web, so it is followed during scans.
- > - :You can copy or download the recorded information here or through the plugin window.
+After saving your sequence, make sure to import it to your target settings at Snyk API & Web, so it is followed during scans.
+ > + :You can review the steps recorded below.{' '} - Note that changing the sequence in any way could prevent the crawler from successfully replaying it.
+ Note that changing the sequence in any way could prevent the crawler from successfully replaying it.Some tips:
#foo > .nav-item.item_42" where "42" is an ID,{' '}
- using ".item_42" is not recomended.
+ For instance for the selector "#foo > .nav-item.item_42" where "42" is an ID,{' '}
+ using ".item_42" is not recomended.
email+{RAND_STRING}@example.com.{RAND_STRING} - random string{RAND_STRING[5]} - random string with length X (e.g. 5){RAND_NUMBER} - random number{RAND_NUMBER[10-99]} - random number between X and Y (e.g. 10 and 99){RAND_STRING} - random string{RAND_STRING[5]} - random string with length X (e.g. 5){RAND_NUMBER} - random number{RAND_NUMBER[10-99]} - random number between X and Y (e.g. 10 and 99)