feat: parse and display Security.txt in explorer (#23995)
* feat: parse and display Security.txt * implement review suggestions * rename Encryption to Secure Contact Encryption * Update explorer/src/components/account/UpgradeableLoaderAccountSection.tsx Co-authored-by: Justin Starry <justin.m.starry@gmail.com> * address re-review Co-authored-by: Justin Starry <justin.m.starry@gmail.com>
This commit is contained in:
280
explorer/src/components/account/SecurityCard.tsx
Normal file
280
explorer/src/components/account/SecurityCard.tsx
Normal file
@ -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 <ErrorCard text="Account has no data" />;
|
||||
}
|
||||
|
||||
const { securityTXT, error } = fromProgramData(data.programData);
|
||||
if (!securityTXT) {
|
||||
return <ErrorCard text={error!} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card security-txt">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Security.txt
|
||||
</h3>
|
||||
<small>
|
||||
Note that this is self-reported by the author of the program and might
|
||||
not be accurate.
|
||||
</small>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
{ROWS.filter((x) => x.key in securityTXT).map((x, idx) => {
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="w-100">{x.display}</td>
|
||||
<RenderEntry value={securityTXT[x.key]} type={x.type} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <td className="text-lg-end font-monospace">{value}</td>;
|
||||
case DisplayType.Contacts:
|
||||
return (
|
||||
<td className="text-lg-end font-monospace">
|
||||
<ul>
|
||||
{value?.split(",").map((c, i) => {
|
||||
const idx = c.indexOf(":");
|
||||
if (idx < 0) {
|
||||
//invalid contact
|
||||
return <li key={i}>{c}</li>;
|
||||
}
|
||||
const [type, information] = [c.slice(0, idx), c.slice(idx + 1)];
|
||||
return (
|
||||
<li key={i}>
|
||||
<Contact type={type} information={information} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
case DisplayType.URL:
|
||||
if (isValidLink(value)) {
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
<a rel="noopener noreferrer" target="_blank" href={value}>
|
||||
{value}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<pre>{value.trim()}</pre>
|
||||
</td>
|
||||
);
|
||||
case DisplayType.Date:
|
||||
return <td className="text-lg-end font-monospace">{value}</td>;
|
||||
case DisplayType.PGP:
|
||||
if (isValidLink(value)) {
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
<a rel="noopener noreferrer" target="_blank" href={value}>
|
||||
{value}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td>
|
||||
<code>{value.trim()}</code>
|
||||
</td>
|
||||
);
|
||||
case DisplayType.Auditors:
|
||||
if (isValidLink(value)) {
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
<a rel="noopener noreferrer" target="_blank" href={value}>
|
||||
{value}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td>
|
||||
<ul>
|
||||
{value?.split(",").map((c, idx) => {
|
||||
return <li key={idx}>{c}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
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 (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`https://discordapp.com/users/${information}`}
|
||||
>
|
||||
Discord: {information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "email":
|
||||
return (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`mailto:${information}`}
|
||||
>
|
||||
{information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "telegram":
|
||||
return (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`https://t.me/${information}`}
|
||||
>
|
||||
Telegram: {information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "twitter":
|
||||
return (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`https://twitter.com/${information}`}
|
||||
>
|
||||
Twitter {information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "link":
|
||||
if (isValidLink(information)) {
|
||||
return (
|
||||
<a rel="noopener noreferrer" target="_blank" href={`${information}`}>
|
||||
{information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <>{information}</>;
|
||||
case "other":
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
{type}: {information}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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({
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<SecurityLabel />
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
<SecurityTXTBadge
|
||||
programData={programData}
|
||||
pubkey={account.pubkey}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Deployed Slot</td>
|
||||
<td className="text-lg-end">
|
||||
@ -165,6 +177,21 @@ export function UpgradeableProgramSection({
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityLabel() {
|
||||
return (
|
||||
<InfoTooltip text="Security.txt helps security researchers to contact developers if they find security bugs.">
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/neodyme-labs/solana-security-txt"
|
||||
>
|
||||
<span className="security-txt-link-color-hack-reee">Security.txt</span>
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</InfoTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function LastVerifiedBuildLabel() {
|
||||
return (
|
||||
<InfoTooltip text="Indicates whether the program currently deployed on-chain is verified to match the associated published source code, when it is available.">
|
||||
|
33
explorer/src/components/common/SecurityTXTBadge.tsx
Normal file
33
explorer/src/components/common/SecurityTXTBadge.tsx
Normal file
@ -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 (
|
||||
<h3 className="mb-0">
|
||||
<Link
|
||||
className="c-pointer badge bg-success-soft rank"
|
||||
to={clusterPath(`/address/${pubkey.toBase58()}/security`)}
|
||||
>
|
||||
Included
|
||||
</Link>
|
||||
</h3>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<h3 className="mb-0">
|
||||
<span className="badge bg-warning-soft rank">{error}</span>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
}
|
@ -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" && <DomainsCard pubkey={pubkey} />}
|
||||
{tab === "security" && data?.program === "bpf-upgradeable-loader" && (
|
||||
<SecurityCard data={data} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
100
explorer/src/utils/security-txt.ts
Normal file
100
explorer/src/utils/security-txt.ts
Normal file
@ -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<number[][]>(
|
||||
(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 };
|
||||
};
|
Reference in New Issue
Block a user