Skip to main content

tulip_solana_security_txt/
lib.rs

1//! # security.txt
2//!
3//! This library defines a macro, whose aim it is to provide easy-to-parse information to security researchers that wish to contact the authors of a Solana smart contract.
4//! It is inspired by https://securitytxt.org/.
5//!
6//! ## Example
7//! ```rust
8//! security_txt! {
9//!     name: "Example",
10//!     project_url: "http://example.com",
11//!     source_code: "https://github.com/example/example",
12//!     expiry: "2042-01-01",
13//!     preferred_languages: "en,de",
14//!     contacts: "email:example@example.com,discord:example#1234",
15//!     encryption: "
16//! -----BEGIN PGP PUBLIC KEY BLOCK-----
17//! Comment: Alice's OpenPGP certificate
18//! Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html
19//!
20//! mDMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U
21//! b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE
22//! ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy
23//! MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO
24//! dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4
25//! OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s
26//! E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb
27//! DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn
28//! 0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE=
29//! =iIGO
30//! -----END PGP PUBLIC KEY BLOCK-----
31//! ",
32//!     acknowledgements: "
33//! The following hackers could've stolen all our money but didn't:
34//! - Neodyme
35//! ",
36//!     policy: "https://github.com/solana-labs/solana/blob/master/SECURITY.md"
37//! }
38//! ```
39//!
40//! ## Format
41//! All values need to be string literals that may not contain nullbytes.
42//! Naive parsers may fail if the binary contains one of the security.txt delimiters anywhere else
43//! (`=======BEGIN SECURITY.TXT V1=======\0` and `=======END SECURITY.TXT V1=======\0`).
44//!
45//! The following fields are supported, some of which are required for this to be considered a valid security.txt:
46//! - `name` (required): The name of the project.
47//! -  `project_url` (required): A URL to the project's homepage/dapp.
48//! - `source_code` (optional): A URL to the project's source code.
49//! - `expiry` (optional): The date the security.txt will expire. The format is YYYY-MM-DD.
50//! - `preferred_languages` (required): A comma-separated list of preferred languages.
51//! - `contacts` (required): A comma-separated list of contact information in the format `<contact type>:<contact information>`. Possible contact types are `email`, `discord`, `telegram`, `twitter`, `link` and `other`.
52//! - `encryption` (optional): A PGP public key block (or similar) or a link to one
53//! - `acknowledgements` (optional): Either a link or a Markdown document containing acknowledgements to security researchers that have found vulnerabilities in the project in the past.
54//! - `policy` (required): Either a link or a Markdown document describing the project's security policy. This should describe what kind of bounties your project offers and the terms under which you offer them.
55//!
56//! ## How it works
57//! The macro inserts a `&str` into the `.security.txt` section of the resulting ELF. Because of how Rust strings work, this is a tuple of a pointer to the actual string and the length.
58//!
59//! The string the macro builds begins with the start marker `=======BEGIN SECURITY.TXT V1=======\0`, and ends with the end marker `=======END SECURITY.TXT V1=======\0`. In between is a list of an even amount of strings, delimited by nullbytes. Every two strings form a key-value-pair.
60
61use core::fmt;
62use std::{collections::HashMap, fmt::Display};
63
64use thiserror::Error;
65use twoway::find_bytes;
66
67pub const SECURITY_TXT_BEGIN: &str = "=======BEGIN SECURITY.TXT V1=======\0";
68pub const SECURITY_TXT_END: &str = "=======END SECURITY.TXT V1=======\0";
69
70#[macro_export]
71macro_rules! security_txt {
72    ($($name:ident: $value:expr),*) => {
73        #[allow(dead_code)]
74        #[no_mangle]
75        #[link_section = ".security.txt"]
76        pub static security_txt: &str = concat! {
77            "=======BEGIN SECURITY.TXT V1=======\0",
78            $(stringify!($name), "\0", $value, "\0",)*
79            "=======END SECURITY.TXT V1=======\0"
80        };
81    };
82}
83
84#[derive(Error, Debug)]
85pub enum SecurityTxtError {
86    #[error("security.txt doesn't start with the right string")]
87    InvalidSecurityTxtBegin,
88    #[error("Couldn't find end string")]
89    EndNotFound,
90    #[error("Couldn't find start string")]
91    StartNotFound,
92    #[error("Invalid field: `{0:?}`")]
93    InvalidField(Vec<u8>),
94    #[error("Unknown field: `{0}`")]
95    UnknownField(String),
96    #[error("Invalid value `{0:?}` for field `{1}`")]
97    InvalidValue(Vec<u8>, String),
98    #[error("Invalid contact `{0}`")]
99    InvalidContact(String),
100    #[error("Missing field: `{0}`")]
101    MissingField(String),
102    #[error("Duplicate field: `{0}`")]
103    DuplicateField(String),
104    #[error("Uneven amount of parts")]
105    Uneven,
106}
107
108pub enum Contact {
109    Email(String),
110    Discord(String),
111    Telegram(String),
112    Twitter(String),
113    Link(String),
114    Other(String),
115}
116
117impl Display for Contact {
118    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
119        match self {
120            Contact::Discord(s) => write!(f, "Discord: {}", s),
121            Contact::Email(s) => write!(f, "Email: {}", s),
122            Contact::Telegram(s) => write!(f, "Telegram: {}", s),
123            Contact::Twitter(s) => write!(f, "Twitter: {}", s),
124            Contact::Link(s) => write!(f, "Link: {}", s),
125            Contact::Other(s) => write!(f, "Other: {}", s),
126        }
127    }
128}
129
130impl Contact {
131    pub fn from_str(s: &str) -> Result<Self, SecurityTxtError> {
132        let parts: Vec<_> = s.split(":").collect();
133        if parts.len() != 2 {
134            return Err(SecurityTxtError::InvalidContact(s.to_string()));
135        }
136        let (contact_type, contact_info) = (parts[0].trim(), parts[1].trim());
137        match contact_type.to_ascii_lowercase().as_str() {
138            "email" => Ok(Contact::Email(contact_info.to_string())),
139            "discord" => Ok(Contact::Discord(contact_info.to_string())),
140            "telegram" => Ok(Contact::Telegram(contact_info.to_string())),
141            "twitter" => Ok(Contact::Twitter(contact_info.to_string())),
142            "link" => Ok(Contact::Link(contact_info.to_string())),
143            "other" => Ok(Contact::Other(contact_info.to_string())),
144            _ => Err(SecurityTxtError::InvalidContact(s.to_string())),
145        }
146    }
147}
148
149pub struct SecurityTxt {
150    pub name: String,
151    pub project_url: String,
152    pub source_code: Option<String>,
153    pub expiry: Option<String>,
154    pub preferred_languages: Vec<String>,
155    pub contacts: Vec<Contact>,
156    pub encryption: Option<String>,
157    pub acknowledgements: Option<String>,
158    pub policy: String,
159}
160
161impl Display for SecurityTxt {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        writeln!(f, "Name: {}", self.name)?;
164        writeln!(f, "Project URL: {}", self.project_url)?;
165
166        if let Some(expiry) = &self.expiry {
167            writeln!(f, "Expires at: {}", expiry)?;
168        }
169
170        if let Some(source_code) = &self.source_code {
171            writeln!(f, "Source code: {}", source_code)?;
172        }
173
174        if !self.contacts.is_empty() {
175            writeln!(f, "\nContacts:")?;
176            for contact in &self.contacts {
177                writeln!(f, "  {}", contact)?;
178            }
179        }
180
181        if !self.preferred_languages.is_empty() {
182            writeln!(f, "\nPreferred Languages:")?;
183            for languages in &self.preferred_languages {
184                writeln!(f, "  {}", languages)?;
185            }
186        }
187
188        if let Some(encryption) = &self.encryption {
189            writeln!(f, "\nEncryption:")?;
190            writeln!(f, "{}", encryption)?;
191        }
192
193        if let Some(acknowledegments) = &self.acknowledgements {
194            writeln!(f, "\nAcknowledgements:")?;
195            writeln!(f, "{}", acknowledegments)?;
196        }
197
198        writeln!(f, "\nPolicy:")?;
199        writeln!(f, "{}", self.policy)?;
200
201        Ok(())
202    }
203}
204
205/// Parses a security.txt. Might not consume all of `data`.
206pub fn parse(mut data: &[u8]) -> Result<SecurityTxt, SecurityTxtError> {
207    if !data.starts_with(SECURITY_TXT_BEGIN.as_bytes()) {
208        return Err(SecurityTxtError::InvalidSecurityTxtBegin);
209    }
210
211    let end = match find_bytes(data, SECURITY_TXT_END.as_bytes()) {
212        Some(i) => i,
213        None => return Err(SecurityTxtError::EndNotFound),
214    };
215
216    data = &data[SECURITY_TXT_BEGIN.len()..end];
217
218    let mut attributes = HashMap::<String, String>::default();
219    let mut field: Option<String> = None;
220    for part in data.split(|&b| b == 0) {
221        if let Some(ref f) = field {
222            let value = std::str::from_utf8(part)
223                .map_err(|_| SecurityTxtError::InvalidValue(part.to_vec(), f.clone()))?;
224            attributes.insert(f.clone(), value.to_string());
225            field = None;
226        } else {
227            field = Some({
228                let field = std::str::from_utf8(part)
229                    .map_err(|_| SecurityTxtError::InvalidField(part.to_vec()))?
230                    .to_string();
231                if attributes.contains_key(&field) {
232                    return Err(SecurityTxtError::DuplicateField(field));
233                }
234                field
235            });
236        }
237    }
238
239    let name = attributes
240        .remove("name")
241        .ok_or_else(|| SecurityTxtError::MissingField("name".to_string()))?;
242    let project_url = attributes
243        .remove("project_url")
244        .ok_or_else(|| SecurityTxtError::MissingField("project_url".to_string()))?;
245    let source_code = attributes.remove("source_code");
246    let expiry = attributes.remove("expiry");
247    let preferred_languages: Vec<_> = attributes
248        .remove("preferred_languages")
249        .ok_or_else(|| SecurityTxtError::MissingField("preferred_languages".to_string()))?
250        .split(',')
251        .map(|s| s.trim().to_string())
252        .collect();
253    let contacts: Result<Vec<_>, SecurityTxtError> = attributes
254        .remove("contacts")
255        .ok_or_else(|| SecurityTxtError::MissingField("contacts".to_string()))?
256        .split(",")
257        .map(|s| Contact::from_str(s.trim()))
258        .collect();
259    let contacts = contacts?;
260    let encryption = attributes.remove("encryption");
261    let acknowledgements = attributes.remove("acknowledgements");
262    let policy = attributes
263        .remove("policy")
264        .ok_or_else(|| SecurityTxtError::MissingField("policy".to_string()))?;
265
266    if !attributes.is_empty() {
267        return Err(SecurityTxtError::UnknownField(
268            attributes.keys().next().unwrap().clone(),
269        ));
270    }
271
272    Ok(SecurityTxt {
273        name,
274        project_url,
275        source_code,
276        expiry,
277        preferred_languages,
278        contacts,
279        encryption,
280        acknowledgements,
281        policy,
282    })
283}
284
285/// Finds and parses the security.txt in the haystack
286pub fn find_and_parse(data: &[u8]) -> Result<SecurityTxt, SecurityTxtError> {
287    let start = match find_bytes(data, SECURITY_TXT_BEGIN.as_bytes()) {
288        Some(i) => i,
289        None => return Err(SecurityTxtError::StartNotFound),
290    };
291    parse(&data[start..])
292}