Skip to main content

hackerone_api/
types.rs

1//! Wire types.
2//!
3//! The HackerOne API speaks a JSON:API-ish dialect: every payload is wrapped
4//! in `data` / `attributes` / `relationships`, with `links` and `meta` beside
5//! it. These types model that envelope generically ([`Resource`],
6//! [`SingleDoc`], [`CollectionDoc`], [`Page`]) and then the domain objects
7//! (reports, programs, scopes, weaknesses, …).
8//!
9//! Domain structs keep the fields the API documents and stash everything else
10//! in a flattened `extra` map, so a server-side field addition never breaks a
11//! decode.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17/// Deserialize a JSON:API `id` that may arrive as a string *or* a number.
18///
19/// HackerOne is inconsistent: hacker report ids are strings (`"1337"`), while
20/// hacktivity item ids are integers (`689314`). Both decode to `Option<String>`.
21fn de_id<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
22where
23    D: serde::Deserializer<'de>,
24{
25    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
26    Ok(value.and_then(|v| match v {
27        serde_json::Value::String(s) => Some(s),
28        serde_json::Value::Number(n) => Some(n.to_string()),
29        _ => None,
30    }))
31}
32
33/// JSON:API `links` object (pagination URLs).
34#[derive(Debug, Clone, Deserialize, Default)]
35pub struct Links {
36    /// Next page URL, when there is one.
37    #[serde(default)]
38    pub next: Option<String>,
39    /// Last page URL.
40    #[serde(default)]
41    pub last: Option<String>,
42    /// Self URL.
43    #[serde(default, rename = "self")]
44    pub this: Option<String>,
45}
46
47/// JSON:API `meta` object, kept loose.
48#[derive(Debug, Clone, Deserialize, Default)]
49pub struct Meta {
50    /// Any keys the API includes.
51    #[serde(flatten)]
52    pub extra: BTreeMap<String, serde_json::Value>,
53}
54
55/// A single JSON:API resource: id + type + attributes.
56#[derive(Debug, Clone, Default, Deserialize)]
57#[serde(bound(deserialize = "A: serde::Deserialize<'de> + Default"))]
58pub struct Resource<A> {
59    /// Resource id (string or number on the wire — see [`de_id`]).
60    #[serde(default, deserialize_with = "de_id")]
61    pub id: Option<String>,
62    /// Resource type (`"report"`, `"program"`, …).
63    #[serde(default, rename = "type")]
64    pub kind: Option<String>,
65    /// The resource's attributes.
66    #[serde(default)]
67    pub attributes: A,
68    /// Relationships, kept as raw JSON.
69    #[serde(default)]
70    pub relationships: serde_json::Value,
71}
72
73/// A bare `{ "data": … }` envelope whose `data` is not a JSON:API resource.
74///
75/// A few endpoints return a plain object under `data` instead of the usual
76/// `id`/`type`/`attributes` resource — notably
77/// `GET /v1/hackers/payments/balance` (`{"data":{"balance":105}}`).
78#[derive(Debug, Clone, Deserialize)]
79pub struct DataDoc<A> {
80    /// The unwrapped object.
81    pub data: A,
82}
83
84/// A single-object response (`GET /v1/me`, `GET /v1/reports/{id}`, …).
85#[derive(Debug, Clone, Deserialize)]
86#[serde(bound(deserialize = "A: serde::Deserialize<'de> + Default"))]
87pub struct SingleDoc<A> {
88    /// The resource.
89    pub data: Resource<A>,
90    /// Pagination/navigation links.
91    #[serde(default)]
92    pub links: Links,
93    /// Response metadata.
94    #[serde(default)]
95    pub meta: Meta,
96}
97
98/// A collection response (`GET /v1/reports`, `GET /v1/me/programs`, …).
99#[derive(Debug, Clone, Deserialize)]
100#[serde(bound(deserialize = "A: serde::Deserialize<'de> + Default"))]
101pub struct CollectionDoc<A> {
102    /// The resources.
103    #[serde(default)]
104    pub data: Vec<Resource<A>>,
105    /// Pagination links.
106    #[serde(default)]
107    pub links: Links,
108    /// Response metadata.
109    #[serde(default)]
110    pub meta: Meta,
111}
112
113/// A decoded page: the items plus the links needed to fetch more.
114#[derive(Debug, Clone)]
115pub struct Page<A> {
116    /// The page's resources.
117    pub resources: Vec<Resource<A>>,
118    /// URL of the next page, if any.
119    pub next: Option<String>,
120    /// URL of the last page, if any.
121    pub last: Option<String>,
122}
123
124impl<A> Page<A> {
125    /// Build a page from a decoded collection document.
126    pub fn from_doc(doc: CollectionDoc<A>) -> Self {
127        Self {
128            resources: doc.data,
129            next: doc.links.next,
130            last: doc.links.last,
131        }
132    }
133
134    /// Number of items on this page.
135    pub fn len(&self) -> usize {
136        self.resources.len()
137    }
138
139    /// Whether the page is empty.
140    pub fn is_empty(&self) -> bool {
141        self.resources.is_empty()
142    }
143
144    /// The item attributes, in order.
145    pub fn items(&self) -> impl Iterator<Item = &A> {
146        self.resources.iter().map(|r| &r.attributes)
147    }
148
149    /// The item ids, in order.
150    pub fn ids(&self) -> impl Iterator<Item = Option<&str>> {
151        self.resources.iter().map(|r| r.id.as_deref())
152    }
153
154    /// Consume the page into the bare item list.
155    pub fn into_items(self) -> Vec<A> {
156        self.resources.into_iter().map(|r| r.attributes).collect()
157    }
158}
159
160macro_rules! flexible {
161    ($name:ident { $( $field:ident : $ty:ty ),* $(,)? }) => {
162        #[derive(Debug, Clone, Deserialize, Default)]
163        #[doc = concat!("See the HackerOne API reference for `", stringify!($name), "`.")]
164        pub struct $name {
165            $(
166                #[serde(default)]
167                #[doc = concat!("`", stringify!($field), "`")]
168                pub $field: Option<$ty>,
169            )*
170            /// Fields the API returned that this version does not name.
171            #[serde(flatten)]
172            pub extra: BTreeMap<String, serde_json::Value>,
173        }
174    };
175}
176
177flexible!(User {
178    username: String,
179    name: String,
180    email: String,
181    created_at: String,
182    disabled: bool,
183    location: String,
184});
185
186flexible!(Program {
187    handle: String,
188    name: String,
189    state: String,
190    submission_state: String,
191    offers_bounties: bool,
192    policy: String,
193    started_accepting_at: String,
194});
195
196flexible!(StructuredScope {
197    asset_identifier: String,
198    asset_type: String,
199    eligible_for_bounty: bool,
200    eligible_for_submission: bool,
201    max_severity: String,
202    instruction: String,
203    created_at: String,
204});
205
206flexible!(Report {
207    title: String,
208    state: String,
209    created_at: String,
210    updated_at: String,
211    vulnerability_information: String,
212    disclosed_at: String,
213    bounty_awarded_at: String,
214    has_bounty: bool,
215});
216
217flexible!(Weakness {
218    name: String,
219    description: String,
220    external_id: String,
221    created_at: String,
222});
223
224flexible!(Severity {
225    rating: String,
226    score: f64,
227    cvss_vector: String,
228    author_type: String,
229    created_at: String,
230});
231
232flexible!(Hacktivity {
233    title: String,
234    substate: String,
235    url: String,
236    disclosed_at: String,
237    submitted_at: String,
238    disclosed: bool,
239    cve_ids: Vec<String>,
240    cwe: String,
241    severity_rating: String,
242    votes: i64,
243    total_awarded_amount: i64,
244    latest_disclosable_action: String,
245    latest_disclosable_activity_at: String,
246});
247
248flexible!(Earning {
249    amount: f64,
250    created_at: String,
251});
252
253/// The authenticated hacker's payment balance
254/// (`GET /v1/hackers/payments/balance`).
255///
256/// The endpoint returns a bare `{"data":{"balance":105}}` (no `attributes`
257/// wrapper), so this type accepts both that shape and a JSON:API
258/// `{"data":{"attributes":{"balance":…}}}` shape. Amounts are read from a
259/// JSON number *or* a numeric string.
260#[derive(Debug, Clone, Default)]
261pub struct Balance {
262    /// The balance amount, if the server reported one.
263    pub balance: Option<f64>,
264    /// The currency, if the server reported one.
265    pub currency: Option<String>,
266    /// Fields the API returned that this version does not name.
267    pub extra: BTreeMap<String, serde_json::Value>,
268}
269
270/// Parse a JSON number or numeric string into `f64`.
271fn parse_amount(value: &serde_json::Value) -> Option<f64> {
272    match value {
273        serde_json::Value::Number(n) => n.as_f64(),
274        serde_json::Value::String(s) => s.parse::<f64>().ok(),
275        _ => None,
276    }
277}
278
279impl<'de> Deserialize<'de> for Balance {
280    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
281    where
282        D: serde::Deserializer<'de>,
283    {
284        use serde::de::Error as _;
285        let value = serde_json::Value::deserialize(deserializer)?;
286        let object = match value {
287            serde_json::Value::Object(map) => map,
288            other => {
289                return Err(D::Error::custom(format!(
290                    "balance: expected an object, got {other}"
291                )))
292            }
293        };
294
295        // Accept `{balance:…}` or a resource `{attributes:{balance:…}}`.
296        let source = match object.get("attributes") {
297            Some(serde_json::Value::Object(attrs)) => attrs.clone(),
298            _ => object,
299        };
300
301        let balance = source.get("balance").and_then(parse_amount);
302        let currency = source
303            .get("currency")
304            .and_then(|v| v.as_str())
305            .map(str::to_string);
306
307        let extra = source
308            .into_iter()
309            .filter(|(k, _)| k != "balance" && k != "currency")
310            .collect();
311
312        Ok(Balance {
313            balance,
314            currency,
315            extra,
316        })
317    }
318}
319
320/// Severity rating supplied when creating a report.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
322#[serde(rename_all = "lowercase")]
323pub enum SeverityRating {
324    /// `none`
325    None,
326    /// `low`
327    Low,
328    /// `medium`
329    Medium,
330    /// `high`
331    High,
332    /// `critical`
333    Critical,
334}
335
336impl SeverityRating {
337    /// The wire value.
338    pub fn as_str(self) -> &'static str {
339        match self {
340            SeverityRating::None => "none",
341            SeverityRating::Low => "low",
342            SeverityRating::Medium => "medium",
343            SeverityRating::High => "high",
344            SeverityRating::Critical => "critical",
345        }
346    }
347}
348
349/// Report states accepted by a state change.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
351#[serde(rename_all = "snake_case")]
352pub enum ReportState {
353    /// `new`
354    New,
355    /// `triaged`
356    Triaged,
357    /// `needs_more_info`
358    NeedsMoreInfo,
359    /// `resolved`
360    Resolved,
361    /// `informative`
362    Informative,
363    /// `not_applicable`
364    NotApplicable,
365    /// `duplicate`
366    Duplicate,
367    /// `spam`
368    Spam,
369}
370
371impl ReportState {
372    /// The wire value.
373    pub fn as_str(self) -> &'static str {
374        match self {
375            ReportState::New => "new",
376            ReportState::Triaged => "triaged",
377            ReportState::NeedsMoreInfo => "needs_more_info",
378            ReportState::Resolved => "resolved",
379            ReportState::Informative => "informative",
380            ReportState::NotApplicable => "not_applicable",
381            ReportState::Duplicate => "duplicate",
382            ReportState::Spam => "spam",
383        }
384    }
385}
386
387/// A hacker report to create (`POST /v1/hackers/reports`).
388///
389/// Build it with the chainable methods, then submit it with
390/// [`Client::create_report`](crate::Client::create_report).
391///
392/// The JSON:API body this renders is exactly:
393///
394/// ```json
395/// {
396///   "data": {
397///     "type": "report",
398///     "attributes": {
399///       "team_handle": "chia_network",
400///       "title": "…",
401///       "vulnerability_information": "…",
402///       "impact": "…",
403///       "severity_rating": "high",
404///       "weakness_id": 1337,
405///       "structured_scope_id": 57
406///     }
407///   }
408/// }
409/// ```
410#[derive(Debug, Clone, Default, PartialEq)]
411pub struct CreateHackerReport {
412    /// Program handle the report is submitted to (required), e.g. `chia_network`.
413    pub team_handle: String,
414    /// Report title (required).
415    pub title: String,
416    /// Detailed write-up: steps to reproduce + supporting material (required).
417    pub vulnerability_information: String,
418    /// The security impact an attacker could achieve (required).
419    pub impact: String,
420    /// Qualitative severity, one of the five documented ratings.
421    pub severity_rating: Option<SeverityRating>,
422    /// Weakness (CWE) object id.
423    pub weakness_id: Option<u64>,
424    /// Structured scope object id this report targets.
425    pub structured_scope_id: Option<u64>,
426}
427
428impl CreateHackerReport {
429    /// A report for `team_handle` with `title`.
430    pub fn new(team_handle: impl Into<String>, title: impl Into<String>) -> Self {
431        Self {
432            team_handle: team_handle.into(),
433            title: title.into(),
434            ..Default::default()
435        }
436    }
437
438    /// Set the vulnerability write-up (required by the API).
439    pub fn vulnerability_information(mut self, text: impl Into<String>) -> Self {
440        self.vulnerability_information = text.into();
441        self
442    }
443
444    /// Set the impact statement (required by the API).
445    pub fn impact(mut self, text: impl Into<String>) -> Self {
446        self.impact = text.into();
447        self
448    }
449
450    /// Set the severity rating.
451    pub fn severity(mut self, rating: SeverityRating) -> Self {
452        self.severity_rating = Some(rating);
453        self
454    }
455
456    /// Attach a weakness (CWE) id.
457    pub fn weakness_id(mut self, id: u64) -> Self {
458        self.weakness_id = Some(id);
459        self
460    }
461
462    /// Pin the structured scope id.
463    pub fn structured_scope_id(mut self, id: u64) -> Self {
464        self.structured_scope_id = Some(id);
465        self
466    }
467
468    /// Render the JSON:API request body, validating the required fields.
469    pub fn to_json(&self) -> Result<serde_json::Value, crate::Error> {
470        for (field, value) in [
471            ("team_handle", &self.team_handle),
472            ("title", &self.title),
473            ("vulnerability_information", &self.vulnerability_information),
474            ("impact", &self.impact),
475        ] {
476            if value.trim().is_empty() {
477                return Err(crate::Error::Invalid(format!("report.{field} is required")));
478            }
479        }
480
481        let mut attributes = serde_json::Map::new();
482        attributes.insert("team_handle".into(), serde_json::json!(self.team_handle));
483        attributes.insert("title".into(), serde_json::json!(self.title));
484        attributes.insert(
485            "vulnerability_information".into(),
486            serde_json::json!(self.vulnerability_information),
487        );
488        attributes.insert("impact".into(), serde_json::json!(self.impact));
489        if let Some(rating) = self.severity_rating {
490            attributes.insert("severity_rating".into(), serde_json::json!(rating.as_str()));
491        }
492        if let Some(id) = self.weakness_id {
493            attributes.insert("weakness_id".into(), serde_json::json!(id));
494        }
495        if let Some(id) = self.structured_scope_id {
496            attributes.insert("structured_scope_id".into(), serde_json::json!(id));
497        }
498
499        Ok(serde_json::json!({
500            "data": {
501                "type": "report",
502                "attributes": serde_json::Value::Object(attributes),
503            }
504        }))
505    }
506}
507
508/// Pagination for the hacker list endpoints (`page[number]`, `page[size]`).
509#[derive(Debug, Clone, Default)]
510pub struct PageQuery {
511    /// 1-based page number.
512    pub page_number: Option<u32>,
513    /// Page size (1–100 per the API).
514    pub page_size: Option<u32>,
515    /// Any extra `key=value` pairs, passed through verbatim.
516    pub extra: Vec<(String, String)>,
517}
518
519impl PageQuery {
520    /// An empty query (server defaults: page 1, size 25).
521    pub fn new() -> Self {
522        Self::default()
523    }
524
525    /// Set the page.
526    pub fn page(mut self, number: u32, size: u32) -> Self {
527        self.page_number = Some(number);
528        self.page_size = Some(size);
529        self
530    }
531
532    /// Add a raw query pair.
533    pub fn filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
534        self.extra.push((key.into(), value.into()));
535        self
536    }
537
538    /// Render to query pairs, in the HackerOne `page[…]` shape.
539    pub fn to_pairs(&self) -> Vec<(String, String)> {
540        let mut pairs = Vec::new();
541        if let Some(n) = self.page_number {
542            pairs.push(("page[number]".to_string(), n.to_string()));
543        }
544        if let Some(s) = self.page_size {
545            pairs.push(("page[size]".to_string(), s.to_string()));
546        }
547        pairs.extend(self.extra.iter().cloned());
548        pairs
549    }
550}
551
552/// Query for [`Client::hacktivity`](crate::Client::hacktivity).
553///
554/// `query_string` uses HackerOne's Apache-Lucene filter syntax, e.g.
555/// `severity_rating:critical AND disclosed:true`.
556#[derive(Debug, Clone, Default)]
557pub struct HacktivityQuery {
558    /// Lucene query string (`queryString`).
559    pub query_string: Option<String>,
560    /// Sort attribute; prefix with `-` for descending.
561    pub sort: Option<String>,
562    /// 1-based page number.
563    pub page_number: Option<u32>,
564    /// Page size (1–100 per the API).
565    pub page_size: Option<u32>,
566    /// Any extra `key=value` pairs, passed through verbatim.
567    pub extra: Vec<(String, String)>,
568}
569
570impl HacktivityQuery {
571    /// An empty query (server returns all, newest activity first).
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Set the Lucene query string.
577    pub fn query(mut self, query: impl Into<String>) -> Self {
578        self.query_string = Some(query.into());
579        self
580    }
581
582    /// Set the sort attribute (prefix `-` for descending).
583    pub fn sort(mut self, sort: impl Into<String>) -> Self {
584        self.sort = Some(sort.into());
585        self
586    }
587
588    /// Set the page.
589    pub fn page(mut self, number: u32, size: u32) -> Self {
590        self.page_number = Some(number);
591        self.page_size = Some(size);
592        self
593    }
594
595    /// Add a raw query pair.
596    pub fn filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
597        self.extra.push((key.into(), value.into()));
598        self
599    }
600
601    /// Render to query pairs.
602    pub fn to_pairs(&self) -> Vec<(String, String)> {
603        let mut pairs = Vec::new();
604        if let Some(q) = &self.query_string {
605            pairs.push(("queryString".to_string(), q.clone()));
606        }
607        if let Some(sort) = &self.sort {
608            pairs.push(("sort".to_string(), sort.clone()));
609        }
610        if let Some(n) = self.page_number {
611            pairs.push(("page[number]".to_string(), n.to_string()));
612        }
613        if let Some(s) = self.page_size {
614            pairs.push(("page[size]".to_string(), s.to_string()));
615        }
616        pairs.extend(self.extra.iter().cloned());
617        pairs
618    }
619}
620
621/// Filters for [`Client::reports`](crate::Client::reports).
622#[derive(Debug, Clone, Default)]
623pub struct ReportQuery {
624    /// Restrict to these states, e.g. `["new", "triaged"]`.
625    pub states: Vec<String>,
626    /// Restrict to a program handle.
627    pub program: Option<String>,
628    /// Sort expression, e.g. `-created_at`.
629    pub sort: Option<String>,
630    /// 1-based page number.
631    pub page_number: Option<u32>,
632    /// Page size.
633    pub page_size: Option<u32>,
634    /// Any extra `key=value` pairs, passed through verbatim.
635    pub extra: Vec<(String, String)>,
636}
637
638impl ReportQuery {
639    /// An empty query.
640    pub fn new() -> Self {
641        Self::default()
642    }
643
644    /// Restrict to a state.
645    pub fn state(mut self, state: impl Into<String>) -> Self {
646        self.states.push(state.into());
647        self
648    }
649
650    /// Restrict to a program handle.
651    pub fn program(mut self, handle: impl Into<String>) -> Self {
652        self.program = Some(handle.into());
653        self
654    }
655
656    /// Sort expression (prefix `-` for descending).
657    pub fn sort(mut self, sort: impl Into<String>) -> Self {
658        self.sort = Some(sort.into());
659        self
660    }
661
662    /// Set the page.
663    pub fn page(mut self, number: u32, size: u32) -> Self {
664        self.page_number = Some(number);
665        self.page_size = Some(size);
666        self
667    }
668
669    /// Add a raw filter.
670    pub fn filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
671        self.extra.push((key.into(), value.into()));
672        self
673    }
674
675    /// Render to query pairs, in the HackerOne `filter[…]` / `page[…]` shape.
676    pub fn to_pairs(&self) -> Vec<(String, String)> {
677        let mut pairs = Vec::new();
678        for state in &self.states {
679            pairs.push(("filter[state][]".to_string(), state.clone()));
680        }
681        if let Some(program) = &self.program {
682            pairs.push(("filter[program][]".to_string(), program.clone()));
683        }
684        if let Some(sort) = &self.sort {
685            pairs.push(("sort".to_string(), sort.clone()));
686        }
687        if let Some(n) = self.page_number {
688            pairs.push(("page[number]".to_string(), n.to_string()));
689        }
690        if let Some(s) = self.page_size {
691            pairs.push(("page[size]".to_string(), s.to_string()));
692        }
693        pairs.extend(self.extra.iter().cloned());
694        pairs
695    }
696}