Flatten the stolen data

This commit is contained in:
Kuba Orlik 2021-11-22 17:54:15 +01:00
parent c9f3876cf4
commit 68078546fa
12 changed files with 220 additions and 365 deletions

View File

@ -1,5 +1,10 @@
import { StolenDataEntry } from "./stolen-data-entry";
import { getshorthost, parseCookie, Request } from "./util";
import {
flattenObjectEntries,
getshorthost,
parseCookie,
Request,
} from "./util";
type NameValue = { name: string; value: string };
@ -151,9 +156,13 @@ export default class ExtendedRequest {
if (!this.hasCookie() || this.getCookie() === undefined) {
return [];
}
return Object.entries(parseCookie(this.getCookie()))
.map(([key, value]) => [key, value || ""])
.map(([key, value]) => new StolenDataEntry(this, "cookie", key, value));
return flattenObjectEntries(
Object.entries(parseCookie(this.getCookie()))
.map(([key, value]) => [key, value || ""])
.map(([key, value]) => {
return [key, StolenDataEntry.parseValue(value)];
})
).map(([key, value]) => new StolenDataEntry(this, "cookie", key, value));
}
hasReferer() {
@ -174,50 +183,56 @@ export default class ExtendedRequest {
if (!path.includes(";")) {
return [];
}
return path
.split(";")
.map((e) => e.split("="))
.map(([key, value]) => [key, value || ""])
.map(
([key, value]) =>
new StolenDataEntry(this, "pathname", key, decodeURIComponent(value))
);
return flattenObjectEntries(
path
.split(";")
.map((e) => e.split("="))
.map(([key, value]) => [key, value || ""])
.map(([key, value]) => {
return [key, StolenDataEntry.parseValue(decodeURIComponent(value))];
})
).map(([key, value]) => new StolenDataEntry(this, "pathname", key, value));
}
getQueryParams(): StolenDataEntry[] {
const url = new URL(this.data.url);
return Array.from((url.searchParams as any).entries())
.map(([key, value]) => [key, value || ""])
.map(([key, value]) => {
try {
value = decodeURIComponent(value);
} catch (e) {}
return new StolenDataEntry(this, "queryparams", key, value);
});
return flattenObjectEntries(
Array.from((url.searchParams as any).entries())
.map(([key, value]) => [key, value || ""])
.map(([key, value]) => {
return [key, StolenDataEntry.parseValue(decodeURIComponent(value))];
})
).map(([key, value]) => {
return new StolenDataEntry(this, "queryparams", key, value);
});
}
getHeadersData(): StolenDataEntry[] {
return this.data.requestHeaders
.filter((header) => {
for (const regex of whitelisted_cookies) {
if (regex.test(header.name)) {
return false;
return flattenObjectEntries(
this.data.requestHeaders
.filter((header) => {
for (const regex of whitelisted_cookies) {
if (regex.test(header.name)) {
return false;
}
}
}
return true;
})
.map(
(header) =>
new StolenDataEntry(this, "header", header.name, header.value)
);
return true;
})
.map((header) => {
return [
header.name,
StolenDataEntry.parseValue(decodeURIComponent(header.value)),
];
})
).map(([key, value]) => new StolenDataEntry(this, "header", key, value));
}
hasMark() {
return this.stolenData.some((data) => data.hasMark());
return this.stolenData.some((data) => data.isMarked);
}
getMarkedEntries() {
return this.stolenData.filter((data) => data.hasMark());
return this.stolenData.filter((data) => data.isMarked);
}
getHost() {

32
mark.ts
View File

@ -1,32 +0,0 @@
import { Classifications, StolenDataEntry } from "./stolen-data-entry";
export default class Mark {
classification: keyof typeof Classifications;
constructor(public entry: StolenDataEntry, public key: string) {
this.classification = entry.classification;
}
getParsedValue() {
return this.entry.getParsedValue(this.key);
}
get shorthost() {
return this.entry.request.shorthost;
}
get source() {
return this.entry.source;
}
get name() {
return this.entry.name;
}
get originalURL() {
return this.entry.request.originalURL;
}
get valuePreview(): string {
return this.entry.getValuePreview(this.key);
}
}

View File

@ -4,8 +4,9 @@
"description": "",
"main": "email-template-harsh.js",
"scripts": {
"build": "npx esbuild sidebar/sidebar.tsx report-window/report-window.tsx --bundle background.ts --bundle --outdir=./lib",
"watch": "npm run build -- --watch"
"build": "npx esbuild sidebar/sidebar.tsx test.ts report-window/report-window.tsx --bundle background.ts --bundle --outdir=./lib",
"watch": "npm run build -- --watch",
"typecheck": "tsc"
},
"repository": {
"type": "git",

View File

@ -25,18 +25,11 @@ export default function DomainSummary({
Właściciel domeny <strong>{cluster.id}</strong> otrzymał:{" "}
<ul>
<li>Mój adres IP</li>
{cluster.getRepresentativeMarks().map((mark) => (
{cluster.getRepresentativeStolenData().map((entry) => (
<li>
{emailClassifications[mark.classification]}{" "}
{emailSources[mark.source]} (nazwa: <code>{mark.name}</code>,{" "}
{mark.key ? (
<>
pozycja <code>{mark.key}</code>,
</>
) : (
""
)}
wartość: <code>{mark.valuePreview}</code>)
{emailClassifications[entry.classification]}{" "}
{emailSources[entry.source]} (nazwa: <code>{entry.name}</code>,{" "}
wartość: <code>{entry.getValuePreview()}</code>)
</li>
))}
</ul>

View File

@ -1,16 +1,16 @@
import React, { useState } from "react";
import Mark from "../mark";
import { RequestCluster } from "../request-cluster";
import { StolenDataEntry } from "../stolen-data-entry";
import { getDate, toBase64 } from "../util";
import DomainSummary from "./domain-summary";
type PopupState = "not_clicked" | "clicked_but_no_reject_all";
export default function EmailTemplate({
marks,
entries,
clusters,
}: {
marks: Mark[];
entries: StolenDataEntry[];
clusters: Record<string, RequestCluster>;
version: number;
}): JSX.Element {
@ -68,8 +68,8 @@ export default function EmailTemplate({
) : null}
<p>
Dzień dobry, w dniu {getDate()} odwiedziłem stronę{" "}
{marks[0].originalURL}. Strona ta wysłała moje dane osobowe do podmiotów
trzecich - bez mojej zgody.{" "}
{entries[0].request.originalURL}. Strona ta wysłała moje dane osobowe do
podmiotów trzecich - bez mojej zgody.{" "}
</p>
<ul>
{Object.values(clusters)

View File

@ -1,18 +1,18 @@
import React, { useEffect, useState } from "react";
import { HAREntry } from "../extended-request";
import Mark from "../mark";
import { StolenDataEntry } from "../stolen-data-entry";
import { getshorthost, unique } from "../util";
function handleNewFile(
element: HTMLInputElement,
marks: Mark[],
entries: StolenDataEntry[],
setFiltered: (Blob) => void
): void {
const reader = new FileReader();
reader.addEventListener("load", () => {
const content = JSON.parse(reader.result as string);
content.log.entries = content.log.entries.filter((har_entry: HAREntry) =>
marks.some((mark) => mark.entry.matchesHAREntry(har_entry))
entries.some((entry) => entry.matchesHAREntry(har_entry))
);
setFiltered(
new Blob([JSON.stringify(content)], { type: "application/json" })
@ -21,8 +21,8 @@ function handleNewFile(
reader.readAsText(element.files[0]);
}
function generateFakeHAR(marks: Mark[]) {
const requests = marks.map((mark) => mark.entry.request);
function generateFakeHAR(entries: StolenDataEntry[]) {
const requests = entries.map((entry) => entry.request);
return {
log: {
version: "1.2",
@ -50,12 +50,16 @@ function generateFakeHAR(marks: Mark[]) {
};
}
export default function HARConverter({ marks }: { marks: Mark[] }) {
export default function HARConverter({
entries,
}: {
entries: StolenDataEntry[];
}) {
const [filtered, setFiltered] = useState<Blob | null>(null);
const [filename, setFilename] = useState("");
const [fakeHAR, setFakeHAR] = useState<ReturnType<typeof generateFakeHAR>>();
useEffect(() => {
setFakeHAR(generateFakeHAR(marks));
setFakeHAR(generateFakeHAR(entries));
}, []);
return (
@ -65,7 +69,7 @@ export default function HARConverter({ marks }: { marks: Mark[] }) {
accept=".har"
onChange={(e) => {
setFilename(e.target.files[0].name);
handleNewFile(e.target, marks, setFiltered);
handleNewFile(e.target, entries, setFiltered);
}}
/>
{(filtered && (
@ -82,7 +86,7 @@ export default function HARConverter({ marks }: { marks: Mark[] }) {
new Blob([JSON.stringify(fakeHAR)], { type: "application/json" })
)}
download={`${getshorthost(
marks[0].originalURL
entries[0].request.originalURL
)}-${new Date().toJSON()}-trimmed.har`}
>
Pobierz "zfałszowany" HAR

View File

@ -15,9 +15,10 @@ function Report() {
setCounter((c) => c + 1);
}
const clusters = getMemory().getClustersForOrigin(origin);
const marks = Object.values(clusters)
.map((cluster) => cluster.getRepresentativeMarks())
.reduce(reduceConcat, []);
const entries = Object.values(clusters)
.map((cluster) => cluster.getRepresentativeStolenData())
.reduce(reduceConcat, [])
.filter((entry) => entry.isMarked);
return (
<div {...{ "data-version": counter }}>
<h1>Generuj treść maila dla {origin}</h1>
@ -31,37 +32,36 @@ function Report() {
</tr>
</thead>
<tbody>
{marks.map((mark) => (
{entries.map((entry) => (
<tr
key={mark.entry.request.originalURL + ";" + mark.key}
key={entry.id}
style={{
backgroundColor:
mark.classification == "id" ? "yellow" : "white",
entry.classification == "id" ? "yellow" : "white",
}}
>
<td>{mark.shorthost}</td>
<td>{entry.request.shorthost}</td>
<td style={{ overflowWrap: "anywhere" }}>
{mark.source}:{mark.name}
{mark.key}
{entry.source}:{entry.name}
</td>
<td
style={{
width: "400px",
overflowWrap: "anywhere",
backgroundColor: mark.entry.isRelatedToID()
backgroundColor: entry.isRelatedToID()
? "#ffff0054"
: "white",
}}
>
{mark.valuePreview}
{entry.getValuePreview()}
{/* always gonna have
one key, because unwrapEntry is called above */}
</td>
<td>
<select
value={mark.classification}
value={entry.classification}
onChange={(e) => {
mark.classification = e.target
entry.classification = e.target
.value as keyof typeof Classifications;
refresh();
}}
@ -81,8 +81,8 @@ function Report() {
))}
</tbody>
</table>
<EmailTemplate {...{ marks, clusters, version: counter }} />
<HARConverter {...{ marks }} />
<EmailTemplate {...{ entries, clusters, version: counter }} />
<HARConverter {...{ entries }} />
</div>
);
}

View File

@ -1,10 +1,6 @@
import { EventEmitter } from "events";
import ExtendedRequest from "./extended-request";
import {
MergedStolenDataEntry,
Sources,
StolenDataEntry,
} from "./stolen-data-entry";
import { Sources, StolenDataEntry } from "./stolen-data-entry";
import { allSubhosts, isSameURL, reduceConcat, unique } from "./util";
@ -34,13 +30,16 @@ export class RequestCluster extends EventEmitter {
return false;
}
getStolenData(filter: {
minValueLength: number;
cookiesOnly: boolean;
cookiesOrOriginOnly: boolean;
}): MergedStolenDataEntry[] {
getRepresentativeStolenData(
filter: {
minValueLength: number;
cookiesOnly: boolean;
cookiesOrOriginOnly: boolean;
} = { minValueLength: 0, cookiesOnly: false, cookiesOrOriginOnly: false }
): StolenDataEntry[] {
return this.requests
.map((request) => request.stolenData)
.reduce((a, b) => a.concat(b), [])
.filter((entry) => {
return entry.value.length >= filter.minValueLength;
@ -52,33 +51,64 @@ export class RequestCluster extends EventEmitter {
entry.source === "cookie" ||
entry.classification === "history"
)
.sort((entryA, entryB) => (entryA.name > entryB.name ? -1 : 1))
.filter((element, index, array) => {
// remove duplicates by name/value
.sort((entry1, entry2) => {
if (entry1.value > entry2.value) {
return -1;
} else if (entry1.value < entry2.value) {
return 1;
} else {
const indexA = source_priority.indexOf(entry1.source);
const indexB = source_priority.indexOf(entry2.source);
if (indexA < indexB) {
return -1;
} else if (indexA > indexB) {
return 1;
} else {
return entry1.value.length > entry2.value.length ? -1 : 1;
}
}
})
.filter((_, index, array) => {
// removing value duplicates
if (index == 0) {
return true;
}
if (
element.name != array[index - 1].name ||
element.value != array[index - 1].value
array[index].getValuePreview() ===
array[index - 1].getValuePreview() ||
(array[index].classification === "history" &&
array[index - 1].classification === "history") || // if they're both history, then the first one is the longest
isSameURL(array[index].value, array[index - 1].value)
) {
return false;
} else {
return true;
}
})
.sort((entryA, entryB) => (entryA.value > entryB.value ? -1 : 1))
.reduce(
(acc: MergedStolenDataEntry[], entry: StolenDataEntry) => {
// group by value
const last_entry = acc.slice(-1)[0];
if (last_entry.hasValue(entry.value)) {
last_entry.mergeWith(entry);
.sort((entry1, entry2) => {
if (entry1.name < entry2.name) {
return -1;
} else if (entry1.name > entry2.name) {
return 1;
} else {
if (entry1.value.length > entry2.value.length) {
return 1;
} else {
acc.push(new MergedStolenDataEntry([entry]));
return -1;
}
return acc;
},
[new MergedStolenDataEntry([])] as MergedStolenDataEntry[]
)
}
})
.filter((_, index, array) => {
// removing name duplicates, keeping only the first - which is the longest. Some data loss may occur.
if (index == 0) {
return true;
}
if (array[index].name === array[index - 1].name) {
return false;
} else {
return true;
}
})
.sort((entry1, entry2) =>
entry1.getPriority() > entry2.getPriority() ? -1 : 1
);
@ -125,49 +155,4 @@ export class RequestCluster extends EventEmitter {
exposesOrigin() {
return this.requests.some((request) => request.exposesOrigin());
}
getMarks() {
return this.requests
.map((request) => request.getMarkedEntries())
.reduce(reduceConcat, [])
.map((entry) => entry.marks)
.reduce(reduceConcat, []);
}
getRepresentativeMarks() {
// removes duplicates so the email/HAR file is shorter
return this.getMarks()
.sort((markA, markB) => {
if (markA.entry.value > markB.entry.value) {
return -1;
} else if (markA.entry.value < markB.entry.value) {
return 1;
} else {
const indexA = source_priority.indexOf(markA.source);
const indexB = source_priority.indexOf(markB.source);
if (indexA < indexB) {
return -1;
} else if (indexA > indexB) {
return 1;
} else {
return markA.entry.value.length > markB.entry.value.length ? -1 : 1;
}
}
})
.filter((_, index, array) => {
if (index == 0) {
return true;
}
if (
array[index].valuePreview === array[index - 1].valuePreview ||
(array[index].classification === "history" &&
array[index - 1].classification === "history") || // if they're both history, then the first one is the longest
isSameURL(array[index].entry.value, array[index - 1].entry.value)
) {
return false;
} else {
return true;
}
});
}
}

View File

@ -1,96 +1,36 @@
import React from "react";
import { getMemory } from "../memory";
import { RequestCluster } from "../request-cluster";
import { MergedStolenDataEntry, Sources } from "../stolen-data-entry";
import { Sources, StolenDataEntry } from "../stolen-data-entry";
import { hyphenate, useEmitter } from "../util";
import { useEmitter } from "../util";
const MAX_STRING_VALUE_LENGTH = 100;
function StolenDataValueTable({
entry,
prefixKey = "",
}: {
entry: MergedStolenDataEntry;
prefixKey: string;
}) {
return (
<div>
{entry.getDecodingsApplied().includes("base64") ? (
<span style={{ color: "white", backgroundColor: "green" }}>
"base64"
</span>
) : (
""
)}
<table>
<tbody>
{Object.keys(entry.getParsedValues(prefixKey)[0]).map((key) => {
const subkey = `${prefixKey}.${key}`;
return (
<tr key={`${prefixKey}.${key}`}>
<th
onClick={(e) => {
entry.toggleMark(subkey);
e.stopPropagation();
}}
style={{
border: entry.hasMark(subkey)
? "2px solid red"
: "2px solid transparent",
}}
>
{hyphenate(key)}
</th>
<td>
<StolenDataValue entry={entry} prefixKey={subkey} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function StolenDataValue({
entry,
prefixKey = "",
}: {
entry: MergedStolenDataEntry;
entry: StolenDataEntry;
prefixKey?: string;
}) {
const [version] = useEmitter(entry);
const value = entry.getParsedValues(prefixKey)[0];
let body = null;
if (!value) {
if (!entry.value) {
body = <></>;
} else if (typeof value === "string") {
const content = entry.getParsedValues(prefixKey)[0] as string;
} else {
body = (
<div
style={{
border: entry.hasMark(prefixKey)
? "2px solid red"
: "2px solid transparent",
}}
data-version={version}
>
{content.slice(0, MAX_STRING_VALUE_LENGTH)}{" "}
{content.length > MAX_STRING_VALUE_LENGTH ? "(...)" : ""}
<div data-version={version}>
{entry.value.slice(0, MAX_STRING_VALUE_LENGTH)}{" "}
{entry.value.length > MAX_STRING_VALUE_LENGTH ? "(...)" : ""}
</div>
);
} else {
body = <StolenDataValueTable entry={entry} prefixKey={prefixKey} />;
}
return (
<div
onClick={(e) => {
entry.toggleMark(prefixKey);
entry.toggleMark();
e.stopPropagation();
}}
data-marks={entry.getMarkedValues().join(", ")}
>
{body}
</div>
@ -108,27 +48,32 @@ function StolenDataRow({
entry,
cluster,
}: {
entry: MergedStolenDataEntry;
entry: StolenDataEntry;
cluster: RequestCluster;
}) {
const [version] = useEmitter(entry);
return (
<tr
key={origin + cluster.id + entry.getUniqueKey()}
data-key={origin + cluster.id + entry.getUniqueKey()}
data-version={version}
>
<td>
<input
type="checkbox"
checked={entry.isMarked}
onChange={() => entry.toggleMark()}
/>
</td>
<th
style={{
width: "100px",
overflowWrap: "anywhere",
border: entry.hasMark("") ? "2px solid red" : "2px solid transparent",
}}
onClick={() => entry.toggleMark("")}
onClick={() => entry.toggleMark()}
>
{entry.getNames().map(hyphenate).join(", ")}
{entry.name}
</th>
<td>{entry.getSources().map((source) => icons[source])}</td>
<td>{[entry.source].map((source) => icons[source])}</td>
<td style={{ wordWrap: "anywhere" as any }}>
<StolenDataValue entry={entry} />
</td>
@ -172,13 +117,17 @@ export default function StolenDataCluster({
<table>
<tbody>
{cluster
.getStolenData({ minValueLength, cookiesOnly, cookiesOrOriginOnly })
.getRepresentativeStolenData({
minValueLength,
cookiesOnly,
cookiesOrOriginOnly,
})
.map((entry) => (
<StolenDataRow
{...{
entry,
cluster,
key: origin + cluster.id + entry.getUniqueKey(),
key: entry.id,
}}
/>
))}

View File

@ -1,7 +1,6 @@
import { TCModel } from "@iabtcf/core";
// import { TCModel } from "@iabtcf/core";
import { EventEmitter } from "events";
import ExtendedRequest, { HAREntry } from "./extended-request";
import Mark from "./mark";
import {
getshorthost,
@ -9,8 +8,6 @@ import {
isJSONObject,
isURL,
parseToObject,
reduceConcat,
unique,
} from "./util";
export type Sources = "cookie" | "pathname" | "queryparams" | "header";
@ -35,9 +32,9 @@ export type DecodingSchema = "base64";
export class StolenDataEntry extends EventEmitter {
public isIAB = false;
public iab: TCModel | null = null;
// public iab: TCModel | null = null;
public id: number;
public marks: Mark[] = [];
private marked = false;
public classification: keyof typeof Classifications;
public decoding_applied: DecodingSchema = null;
@ -77,8 +74,8 @@ export class StolenDataEntry extends EventEmitter {
return priority;
}
mergeWith(entry: StolenDataEntry): MergedStolenDataEntry {
return new MergedStolenDataEntry([this, entry]);
get isMarked() {
return this.marked;
}
hasValue(value: string) {
@ -86,6 +83,9 @@ export class StolenDataEntry extends EventEmitter {
}
static parseValue(value: unknown): string | Record<string, unknown> {
if (isBase64JSON(value)) {
return StolenDataEntry.parseValue({ base64: JSON.parse(atob(value)) });
}
if (value === undefined) {
return "";
}
@ -144,29 +144,21 @@ export class StolenDataEntry extends EventEmitter {
return object;
}
addMark(key: string) {
this.marks.push(new Mark(this, key));
mark() {
this.marked = true;
this.emit("change");
}
hasMark(key?: string) {
if (key) {
return this.marks.some((k) => k.key == key);
} else {
return this.marks.length > 0;
}
}
removeMark(key: string) {
this.marks = this.marks.filter((mark) => mark.key != key);
unmark() {
this.marked = false;
this.emit("change");
}
toggleMark(key: string) {
if (this.hasMark(key)) {
this.removeMark(key);
toggleMark() {
if (this.marked) {
this.unmark();
} else {
this.addMark(key);
this.mark();
}
}
@ -199,7 +191,6 @@ export class StolenDataEntry extends EventEmitter {
}
getValuePreview(key = ""): string {
console.log("getValuePreview", key, this.getParsedValue(key));
const value = this.getParsedValue(key);
const str =
typeof value === "object" && value[Symbol.for("originalString")]
@ -218,90 +209,8 @@ export class StolenDataEntry extends EventEmitter {
return str;
}
}
}
export class MergedStolenDataEntry extends EventEmitter {
constructor(public entries: StolenDataEntry[]) {
super();
const all_marks = unique(
entries.map((entry) => entry.marks).reduce(reduceConcat, [])
);
for (const entry of entries) {
entry.marks = all_marks;
}
// getMemory().emit("change"); // to trigger render
}
on(event: string, listener: () => void) {
for (const entry of this.entries) {
entry.on(event, listener);
}
return this;
}
removeListener(event: string, listener: () => void) {
for (const entry of this.entries) {
entry.removeListener(event, listener);
}
return this;
}
hasValue(value: string) {
return this.entries.some((entry) => entry.value === value);
}
mergeWith(entry: StolenDataEntry) {
this.entries.push(entry);
return this;
}
getPriority() {
return Math.max(...this.entries.map((entry) => entry.getPriority()));
}
getUniqueKey() {
return `${this.getNames().join(":")};${this.entries
.map((e) => e.id)
.join(":")};`;
}
getNames(): string[] {
return unique(this.entries.map((e) => e.name));
}
getSources(): string[] {
return unique(this.entries.map((e) => e.source));
}
getValues() {
return unique(this.entries.map((e) => e.value));
}
getParsedValues(key_path: string) {
return Array.from(
new Set(this.entries.map((e) => e.getParsedValue(key_path)))
);
}
addMark(key: string) {
this.entries.forEach((entry) => entry.addMark(key));
}
getMarkedValues() {
return this.entries
.map((entry) => entry.marks)
.reduce((a, b) => a.concat(b), []);
}
hasMark(key: string): boolean {
return this.entries.some((entry) => entry.hasMark(key));
}
toggleMark(key: string): void {
this.entries.forEach((entry) => entry.toggleMark(key));
}
getDecodingsApplied(): DecodingSchema[] {
return unique(this.entries.map((entry) => entry.decoding_applied));
return this.request.shorthost + ";" + this.name + ";" + this.value;
}
}

3
test.ts Normal file
View File

@ -0,0 +1,3 @@
import { flattenObject } from "./util";
console.log(flattenObject({ a: { b: { c: [1, 2, 3] } } }));

28
util.ts
View File

@ -183,3 +183,31 @@ export function isBase64(s: string): boolean {
export function isBase64JSON(s: unknown): s is string {
return typeof s === "string" && isBase64(s) && isJSONObject(atob(s));
}
export function flattenObject(
obj: Record<string, unknown>
): [string, string][] {
const ret: [string, string][] = [];
for (const [key, value] of Object.entries(obj)) {
const value = obj[key];
if (value === null) {
ret.push([key, "null"]);
continue;
}
if (typeof value === "object") {
const flattened = flattenObject(value as Record<string, unknown>);
for (const [subkey, subvalue] of flattened) {
ret.push([`${key}.${subkey}`, subvalue]);
}
} else {
ret.push([key, value.toString()]);
}
}
return ret;
}
export function flattenObjectEntries(
entries: [string, unknown][]
): [string, string][] {
return flattenObject(Object.fromEntries(entries));
}