diff --git a/explorer/src/components/account/SecurityCard.tsx b/explorer/src/components/account/SecurityCard.tsx new file mode 100644 index 0000000000..5a6119c982 --- /dev/null +++ b/explorer/src/components/account/SecurityCard.tsx @@ -0,0 +1,280 @@ +import { ErrorCard } from "components/common/ErrorCard"; +import { TableCardBody } from "components/common/TableCardBody"; +import { UpgradeableLoaderAccountData } from "providers/accounts"; +import { fromProgramData, SecurityTXT } from "utils/security-txt"; + +export function SecurityCard({ data }: { data: UpgradeableLoaderAccountData }) { + if (!data.programData) { + return ; + } + + const { securityTXT, error } = fromProgramData(data.programData); + if (!securityTXT) { + return ; + } + + return ( +
+
+

+ Security.txt +

+ + Note that this is self-reported by the author of the program and might + not be accurate. + +
+ + {ROWS.filter((x) => x.key in securityTXT).map((x, idx) => { + return ( + + {x.display} + + + ); + })} + +
+ ); +} + +enum DisplayType { + String, + URL, + Date, + Contacts, + PGP, + Auditors, +} +type TableRow = { + display: string; + key: keyof SecurityTXT; + type: DisplayType; +}; + +const ROWS: TableRow[] = [ + { + display: "Name", + key: "name", + type: DisplayType.String, + }, + { + display: "Project URL", + key: "project_url", + type: DisplayType.URL, + }, + { + display: "Contacts", + key: "contacts", + type: DisplayType.Contacts, + }, + { + display: "Policy", + key: "policy", + type: DisplayType.URL, + }, + { + display: "Preferred Languages", + key: "preferred_languages", + type: DisplayType.String, + }, + { + display: "Source Code URL", + key: "source_code", + type: DisplayType.URL, + }, + { + display: "Secure Contact Encryption", + key: "encryption", + type: DisplayType.PGP, + }, + { + display: "Auditors", + key: "auditors", + type: DisplayType.Auditors, + }, + { + display: "Acknowledgements", + key: "acknowledgements", + type: DisplayType.URL, + }, + { + display: "Expiry", + key: "expiry", + type: DisplayType.Date, + }, +]; + +function RenderEntry({ + value, + type, +}: { + value: SecurityTXT[keyof SecurityTXT]; + type: DisplayType; +}) { + if (!value) { + return <>; + } + switch (type) { + case DisplayType.String: + return {value}; + case DisplayType.Contacts: + return ( + + + + ); + case DisplayType.URL: + if (isValidLink(value)) { + return ( + + + + {value} + + + + + ); + } + return ( + +
{value.trim()}
+ + ); + case DisplayType.Date: + return {value}; + case DisplayType.PGP: + if (isValidLink(value)) { + return ( + + + + {value} + + + + + ); + } + return ( + + {value.trim()} + + ); + case DisplayType.Auditors: + if (isValidLink(value)) { + return ( + + + + {value} + + + + + ); + } + return ( + + + + ); + default: + break; + } + return <>; +} + +function isValidLink(value: string) { + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol); + } catch (err) { + return false; + } +} + +function Contact({ type, information }: { type: string; information: string }) { + switch (type) { + case "discord": + return ( + + Discord: {information} + + + ); + case "email": + return ( + + {information} + + + ); + case "telegram": + return ( + + Telegram: {information} + + + ); + case "twitter": + return ( + + Twitter {information} + + + ); + case "link": + if (isValidLink(information)) { + return ( + + {information} + + + ); + } + return <>{information}; + case "other": + default: + return ( + <> + {type}: {information} + + ); + } +} diff --git a/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx b/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx index 4b97a77b08..c23147cb46 100644 --- a/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx +++ b/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx @@ -18,6 +18,7 @@ import { Downloadable } from "components/common/Downloadable"; import { CheckingBadge, VerifiedBadge } from "components/common/VerifiedBadge"; import { InfoTooltip } from "components/common/InfoTooltip"; import { useVerifiableBuilds } from "utils/program-verification"; +import { SecurityTXTBadge } from "components/common/SecurityTXTBadge"; export function UpgradeableLoaderAccountSection({ account, @@ -146,6 +147,17 @@ export function UpgradeableProgramSection({ )} + + + + + + + + Last Deployed Slot @@ -165,6 +177,21 @@ export function UpgradeableProgramSection({ ); } +function SecurityLabel() { + return ( + + + Security.txt + + + + ); +} + function LastVerifiedBuildLabel() { return ( diff --git a/explorer/src/components/common/SecurityTXTBadge.tsx b/explorer/src/components/common/SecurityTXTBadge.tsx new file mode 100644 index 0000000000..e98c75c664 --- /dev/null +++ b/explorer/src/components/common/SecurityTXTBadge.tsx @@ -0,0 +1,33 @@ +import { PublicKey } from "@solana/web3.js"; +import { Link } from "react-router-dom"; +import { fromProgramData } from "utils/security-txt"; +import { clusterPath } from "utils/url"; +import { ProgramDataAccountInfo } from "validators/accounts/upgradeable-program"; + +export function SecurityTXTBadge({ + programData, + pubkey, +}: { + programData: ProgramDataAccountInfo; + pubkey: PublicKey; +}) { + const { securityTXT, error } = fromProgramData(programData); + if (securityTXT) { + return ( +

+ + Included + +

+ ); + } else { + return ( +

+ {error} +

+ ); + } +} diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx index c433f8ab5e..fb84739e3b 100644 --- a/explorer/src/pages/AccountDetailsPage.tsx +++ b/explorer/src/pages/AccountDetailsPage.tsx @@ -40,6 +40,7 @@ import { MetaplexMetadataCard } from "components/account/MetaplexMetadataCard"; import { NFTHeader } from "components/account/MetaplexNFTHeader"; import { DomainsCard } from "components/account/DomainsCard"; import isMetaplexNFT from "providers/accounts/utils/isMetaplexNFT"; +import { SecurityCard } from "components/account/SecurityCard"; const IDENTICON_WIDTH = 64; @@ -108,6 +109,13 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = { path: "/stake-history", }, ], + "bpf-upgradeable-loader": [ + { + slug: "security", + title: "Security", + path: "/security", + }, + ], }; const TOKEN_TABS_HIDDEN = [ @@ -319,7 +327,8 @@ export type MoreTabs = | "instructions" | "rewards" | "metadata" - | "domains"; + | "domains" + | "security"; function MoreSection({ account, @@ -389,6 +398,9 @@ function MoreSection({ /> )} {tab === "domains" && } + {tab === "security" && data?.program === "bpf-upgradeable-loader" && ( + + )} ); } diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss index a5029afd1b..75321c76e3 100644 --- a/explorer/src/scss/_solana.scss +++ b/explorer/src/scss/_solana.scss @@ -445,3 +445,33 @@ p.updated-time { text-overflow: ellipsis; white-space: nowrap; } + +// security-txt css hacks +.security-txt ul { + list-style: none; + text-align: right; + margin: 0; + padding-inline-start: 0; +} + +.security-txt p, pre { + text-align: left !important; + margin: 0; +} + +.security-txt a { + white-space: nowrap; +} + +.security-txt td { + white-space: unset; +} + +.security-txt code { + white-space: pre-wrap; + display: block; +} + +.security-txt-link-color-hack-reee { + color: white; +} \ No newline at end of file diff --git a/explorer/src/utils/security-txt.ts b/explorer/src/utils/security-txt.ts new file mode 100644 index 0000000000..099b909acc --- /dev/null +++ b/explorer/src/utils/security-txt.ts @@ -0,0 +1,100 @@ +import { ProgramDataAccountInfo } from "validators/accounts/upgradeable-program"; + +export type SecurityTXT = { + name: string; + project_url: string; + contacts: string; + policy: string; + preferred_languages?: string; + source_code?: string; + encryption?: string; + auditors?: string; + acknowledgements?: string; + expiry?: string; +}; +const REQUIRED_KEYS: (keyof SecurityTXT)[] = [ + "name", + "project_url", + "contacts", + "policy", +]; +const VALID_KEYS: (keyof SecurityTXT)[] = [ + "name", + "project_url", + "contacts", + "policy", + "preferred_languages", + "source_code", + "encryption", + "auditors", + "acknowledgements", + "expiry", +]; + +const HEADER = "=======BEGIN SECURITY.TXT V1=======\0"; +const FOOTER = "=======END SECURITY.TXT V1=======\0"; + +export const fromProgramData = ( + programData: ProgramDataAccountInfo +): { securityTXT?: SecurityTXT; error?: string } => { + const [data, encoding] = programData.data; + if (!(data && encoding === "base64")) + return { securityTXT: undefined, error: "Failed to decode program data" }; + + const decoded = Buffer.from(data, encoding); + + const headerIdx = decoded.indexOf(HEADER); + const footerIdx = decoded.indexOf(FOOTER); + + if (headerIdx < 0 || footerIdx < 0) { + return { securityTXT: undefined, error: "Program has no security.txt" }; + } + + /* + the expected structure of content should be a list + of ascii encoded key value pairs seperated by null characters. + e.g. key1\0value1\0key2\0value2\0 + */ + const content = decoded.subarray(headerIdx + HEADER.length, footerIdx); + + const map = content + .reduce( + (prev, current) => { + if (current === 0) { + prev.push([]); + } else { + prev[prev.length - 1].push(current); + } + return prev; + }, + [[]] + ) + .map((c) => String.fromCharCode(...c)) + .reduce<{ map: { [key: string]: string }; key: string | undefined }>( + (prev, current) => { + const key = prev.key; + if (!key) { + return { + map: prev.map, + key: current, + }; + } else { + return { + map: { + ...(VALID_KEYS.some((x) => x === key) ? { [key]: current } : {}), + ...prev.map, + }, + key: undefined, + }; + } + }, + { map: {}, key: undefined } + ).map; + if (!REQUIRED_KEYS.every((k) => k in map)) { + return { + securityTXT: undefined, + error: `some required fields (${REQUIRED_KEYS}) are missing`, + }; + } + return { securityTXT: map as SecurityTXT, error: undefined }; +};