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:
Felipe Custodio
2022-03-31 11:23:32 +02:00
committed by GitHub
parent cb5e67d327
commit 9abebc2d64
6 changed files with 483 additions and 1 deletions

View 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}
</>
);
}
}

View File

@ -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.">

View 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>
);
}
}

View File

@ -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} />
)}
</>
);
}

View File

@ -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;
}

View 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 };
};