initial commit

This commit is contained in:
avitex 2021-03-02 15:17:16 +11:00
commit 22a4e9fbf8
Signed by: avitex
GPG Key ID: 38C76CBF3749D62C
59 changed files with 2111 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/target
Cargo.lock
# STORM
/dist

29
Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "cyberstorm"
version = "0.1.0"
authors = ["avitex <avitex@wfxlabs.com>"]
edition = "2018"
publish = false
build = "build.rs"
[dependencies]
zc = "0.4"
serde = { version = "1.0", features = ["derive"] }
toml = "0.5"
async-trait = "0.1"
strum = { version = "0.20", features = ["derive"] }
roxmltree = "0.14"
tokio = { version = "1", features = ["rt-multi-thread", "fs", "macros"] }
validator = { version = "0.12", features = ["derive"] }
thiserror = "1.0"
tera = "1.6"
anyhow = "1.0"
regex = "1"
lazy_static = "1"
structopt = "0.3"
phf = "0.8.0"
includedir = "0.6"
[build-dependencies]
includedir_codegen = "0.6"
glob = "0.3"

84
README.md Normal file
View File

@ -0,0 +1,84 @@
# STORM
An angry cloud.
A registry of how to protect a system.
## Tags
Tags are limited to `a-z`, `0-9`, or `-` characters and can be hierarchically separated via `.`.
For example when referring to a MITRE ATT&CK technique we could use `attack.t0001`.
## Domains
| Domain | Model | Description |
| ---------- | ------------- | ----------------------------------------------------------------------------- |
| [Source] | | Sourcing of intelligence for improving observation, reaction and mitigation |
| | Intelligence | Feed or result of a query used to satisfy intelligence requirements |
| | Requirement | Collection of intelligence used by actions, detections and mitigations |
| | Provider | An internal or external supplier of intelligence |
| [Threat] | | Modelling and simulations of threat actor tactics, techniques and software |
| | Tactic | High-level categorization of threat actor behaviour |
| | Technique | Specific description of threat actor behaviour |
| | Software | Code/utilities/tools used in conducting threat actor behaviour |
| | Simulation | Generating threat behaviour for testing observation, reaction and mitigations |
| [Observe] | | Events, definitions and configuration for observing threat actor behaviour |
| | Event | Observable items use to detect and respond to threat actor behaviour |
| | Detection | Rule used to detect threat actor behaviour mapped to threat actor behaviour |
| | Provider | Software or systems that are capable of producing events |
| | Configuration | Configuration applied to a provider enabling it to produce events |
| [React] | | Structured responses to observed threat behaviour |
| | Stage | Phase of a response to observed threat behaviour |
| | Action | An atomic human action assigned to an response stage |
| | Playbook | Composition of response actions for a given context |
| [Mitigate] | | Proactive and reactive strategies to address address threats |
| | Strategy | Composition of platforms and configurations to address a threat |
| | Platform | Platform that is capable of mitigating threats with or without configuration |
| | Configuration | Configuration applied to a platform enabling it to mitigate threats |
### Source
How do you what is happening outside of your system or if something you saw
is elsewhere considered a threat?
A source is an entity that provides an external perspective and knowledge of
threats, techniques, tools, mitigations that can be pulled or queried.
#### Provider
#### Requirement
#### Intelligence
### Threat
### Observe
### React
### Mitigate
source/
provider `SPR#####`
requirement `SRT#####`
intelligence `SIE#####`
threat/
tactic `TTC#####`
software `TSE#####`
technique `TTE#####`
deficiency
simulation `TSN#####`
observe/
event `OET#####`
detection `ODN#####`
provider `OPR#####`
configuration `OCG#####`
react/
stage `RSE#####`
action `RAN#####`
playbook `RPK#####`
mitigate/
strategy `MSY#####`
platform `MPM#####`
configuration `MCG#####`

14
book.toml Normal file
View File

@ -0,0 +1,14 @@
[book]
authors = ["avitex"]
language = "en"
multilingual = false
src = "book"
title = "STORM"
[build]
create-missing = false
build-dir = "dist"
[output.html.fold]
enable = true
level = 0

6
book/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/SUMMARY.md
/source/**/*.md
/threat/**/*.md
/observe/**/*.md
/react/**/*.md
/mitigate/**/*.md

49
book/macros.tera Normal file
View File

@ -0,0 +1,49 @@
{% macro references(refs) %}
{%- if refs | length == 0 %}No references{% endif -%}
{%- for ref in refs -%}
- {{ ref | autolink }}
{% endfor -%}
{% endmacro references %}
{% macro title(title, id) -%}
{{ title }} <small>([edit]({{ id | domain_id_link(for="edit") }}))</small>
{%- endmacro details_next %}
{% macro details(id, name) -%}
| Title | {{ name }} |
|:---------------------------:|:------------------------|
{{ self::details_next(title="ID", value=id)}}
{%- endmacro details %}
{% macro details_next(title, value) -%}
| **{{ title }}** | {{ value }} |
{%- endmacro details_next %}
{% macro details_authors(authors) -%}
{{ self::details_next(title="Authors", value=authors | join )}}
{%- endmacro details_next %}
{% macro details_tags(tags) -%}
{% if tags | length == 0 -%}
{{ self::details_next(title="Tags", value="No tags") }}
{%- else -%}
{{ self::details_next(title="Tags", value=tags | join) }}
{%- endif %}
{%- endmacro details_next %}
{% macro content(content) %}
{%- set trimmed = content | trim -%}
{%- if trimmed | length == 0 %}No description{% else %}{{ trimmed }}{% endif -%}
{% endmacro details_next %}
{% macro name_and_id_link(value) -%}
{{ value["doc"]["name"] }} ([{{ value["id"] }}]({{ value["id"] | domain_id_link }}))
{%- endmacro name_and_id_link %}
{% macro summary_list(instances) -%}
| ID | Name |
|:---------------------------:|:------------------------|
{% for item in instances -%}
| [{{ item.id }}]({{ item.id | domain_id_link }}) | {{ item.name }} |
{% endfor %}
{%- endmacro summary_list %}

View File

@ -0,0 +1,22 @@
{% import "macros.tera" as macros %}
# {{ macros::title(title=doc.name, id=id) }}
{{ macros::details(id=id, name=doc.name) }}
{%- if doc.WindowsEvent %}
{{ macros::details_next(title="Type", value="Windows event") }}
{% endif -%}
{{ macros::details_next(title="Description", value=doc.description) }}
## Description
{{ macros::content(content=doc.content) }}
{% if doc.WindowsEvent %}
## Samples
{% for sample in doc.WindowsEvent.sample %}
```xml
{{ sample.xml }}
```
{% endfor %}
{% endif %}

7
book/observe/event.tera Normal file
View File

@ -0,0 +1,7 @@
{% import "macros.tera" as macros %}
# Event
Observable items use to detect and respond to threat actor behaviour.
{{ macros::summary_list(instances=instances) }}

View File

@ -0,0 +1,17 @@
{% import "macros.tera" as macros %}
{% set stage = doc.stage | get_doc %}
# {{ macros::title(title=doc.name, id=id) }}
{{ macros::details(id=id, name=doc.name) }}
{{ macros::details_next(title="Stage", value=macros::name_and_id_link(value=stage)) }}
{{ macros::details_next(title="Description", value=doc.description) }}
{{ macros::details_tags(tags=doc.tags) }}
## Description
{{ macros::content(content=doc.content) }}
## References
{{ macros::references(refs=doc.references) }}

5
book/react/action.tera Normal file
View File

@ -0,0 +1,5 @@
{% import "macros.tera" as macros %}
# Action
{{ macros::summary_list(instances=instances) }}

View File

@ -0,0 +1,10 @@
{% import "macros.tera" as macros %}
# {{ macros::title(title=doc.name, id=id) }}
{{ macros::details(id=id, name=doc.name) }}
{{ macros::details_next(title="Description", value=doc.description) }}
## Description
{{ macros::content(content=doc.content) }}

7
book/react/stage.tera Normal file
View File

@ -0,0 +1,7 @@
{% import "macros.tera" as macros %}
# Stage
Phase of a response to observed threat behaviour.
{{ macros::summary_list(instances=instances) }}

View File

@ -0,0 +1,14 @@
{% import "macros.tera" as macros %}
# {{ macros::title(title=doc.name, id=id) }}
{{ macros::details(id=id, name=doc.name) }}
{{ macros::details_next(title="Provider", value=doc.provider) }}
## Description
{{ macros::content(content=doc.content) }}
## References
{{ macros::references(refs=doc.references) }}

View File

@ -0,0 +1,7 @@
{% import "macros.tera" as macros %}
# Intelligence
Feed or result of a query used to satisfy intelligence requirements.
{{ macros::summary_list(instances=instances) }}

View File

@ -0,0 +1,13 @@
{% import "macros.tera" as macros %}
# {{ macros::title(title=doc.name, id=id) }}
{{ macros::details(id=id, name=doc.name) }}
## Description
{{ macros::content(content=doc.content) }}
## References
{{ macros::references(refs=doc.references) }}

View File

@ -0,0 +1,7 @@
{% import "macros.tera" as macros %}
# Provider
An internal or external supplier of intelligence.
{{ macros::summary_list(instances=instances) }}

View File

@ -0,0 +1,13 @@
{% import "macros.tera" as macros %}
# {{ macros::title(title=doc.name, id=id) }}
{{ macros::details(id=id, name=doc.name) }}
## Description
{{ macros::content(content=doc.content) }}
## References
{{ macros::references(refs=doc.references) }}

View File

@ -0,0 +1,7 @@
{% import "macros.tera" as macros %}
# Requirement
Collection of intelligence used by actions, detections and mitigations.
{{ macros::summary_list(instances=instances) }}

16
book/summary.tera Normal file
View File

@ -0,0 +1,16 @@
# Summary
{%- set last_domain = "" -%}
{%- set last_model = "" -%}
{%- for item in summary -%}
{%- set id_parts = item.id | domain_id -%}
{%- if last_domain != id_parts.domain -%}
{%- set_global last_domain = id_parts.domain %}
# {{ id_parts.domain | capitalize }}
{%- endif -%}
{%- if last_model != id_parts.model -%}
{%- set_global last_model = id_parts.model %}
- [{{ id_parts.model | capitalize }}](.{{ item.id | domain_id_link(for="model") }})
{%- endif %}
- [{{ item.name }}](.{{ item.id | domain_id_link }})
{%- endfor -%}

13
build.rs Normal file
View File

@ -0,0 +1,13 @@
use glob::glob;
use includedir_codegen::Compression;
fn main() {
let mut templates = includedir_codegen::start("BASE_TEMPLATES");
for path_result in glob("book/**/*.tera").unwrap() {
let path = path_result.unwrap();
templates.add_file(path, Compression::Gzip).unwrap();
}
templates.build("mdbook-templates.rs").unwrap();
}

1
registry/config.toml Normal file
View File

@ -0,0 +1 @@
edit-link = "https://localhost/registry/{{domain}}/{{model}}/{{model_id}}.md"

View File

@ -0,0 +1,3 @@
+++
+++

View File

@ -0,0 +1,3 @@
+++
+++

View File

@ -0,0 +1,3 @@
+++
+++

View File

@ -0,0 +1,3 @@
+++
+++

View File

@ -0,0 +1,3 @@
+++
+++

View File

@ -0,0 +1,43 @@
+++
name = "Some event"
description = "Some description"
[[WindowsEvent.sample]]
xml = """
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-A5BA-3E3B0328C30D}" />
<EventID>4688</EventID>
<Version>2</Version>
<Level>0</Level>
<Task>13312</Task>
<Opcode>0</Opcode>
<Keywords>0x8020000000000000</Keywords>
<TimeCreated SystemTime="2015-11-12T02:24:52.377352500Z" />
<EventRecordID>2814</EventRecordID>
<Correlation />
<Execution ProcessID="4" ThreadID="400" />
<Channel>Security</Channel>
<Computer>WIN-GG82ULGC9GO.contoso.local</Computer>
<Security />
</System>
<EventData>
<Data Name="SubjectUserSid">S-1-5-18</Data>
<Data Name="SubjectUserName">WIN-GG82ULGC9GO$</Data>
<Data Name="SubjectDomainName">CONTOSO</Data>
<Data Name="SubjectLogonId">0x3e7</Data>
<Data Name="NewProcessId">0x2bc</Data>
<Data Name="NewProcessName">C:\\Windows\\System32\\rundll32.exe</Data>
<Data Name="TokenElevationType">%%1938</Data>
<Data Name="ProcessId">0xe74</Data>
<Data Name="TargetUserSid">S-1-5-21-1377283216-344919071-3415362939-1104</Data>
<Data Name="TargetUserName">dadmin</Data>
<Data Name="TargetDomainName">CONTOSO</Data>
<Data Name="TargetLogonId">0x4a5af0</Data>
<Data Name="ParentProcessName">C:\\Windows\\explorer.exe</Data>
<Data Name="MandatoryLabel">S-1-16-8192</Data>
</EventData>
</Event>
"""
+++
Something about the event

View File

@ -0,0 +1,3 @@
+++
+++

View File

@ -0,0 +1,6 @@
+++
name = 'Set up a centralized long-term log storage'
stage = 'RSE00001'
description = 'Set up a centralized long-term log storage. This is one of the most critical problems companies have nowadays. Even if there is such a system, in most of the cases it stores irrelevant data or has too small retention period'
+++

View File

@ -0,0 +1,4 @@
+++
name = "Preparation"
description = "Get prepared for a security incident"
+++

View File

@ -0,0 +1,4 @@
+++
name = "Identification"
description = "Gather information about a threat that has triggered a security incident, its TTPs, and affected assets"
+++

View File

@ -0,0 +1,4 @@
+++
name = "Containment"
description = "Prevent a threat from achieving its objectives and/or spreading around an environment"
+++

View File

@ -0,0 +1,4 @@
+++
name = "Eradication"
description = "Remove a threat from an environment"
+++

View File

@ -0,0 +1,4 @@
+++
name = "Recovery"
description = "Recover from the incident and return all the assets back to normal operation"
+++

View File

@ -0,0 +1,4 @@
+++
name = "Lessons Learned"
description = "Discover how to improve the Incident Response process and implement the improvements"
+++

View File

@ -0,0 +1,7 @@
+++
name = "DomainTools Whois Lookup"
provider = "SPR0001"
references = [
"https://whois.domaintools.com/"
]
+++

View File

@ -0,0 +1,3 @@
+++
name = "DomainTools"
+++

View File

@ -0,0 +1,3 @@
+++
name = "VirusTotal"
+++

View File

@ -0,0 +1,6 @@
+++
name = "OSINT assessment of IPv4/IPv6 address"
intelligence = [
"SIE0001",
]
+++

View File

@ -0,0 +1,6 @@
+++
name = "OSINT assessment of domain"
intelligence = [
"SIE0001",
]
+++

View File

@ -0,0 +1,3 @@
+++
name = "OSINT assessment of MD5 file hash"
+++

View File

@ -0,0 +1,3 @@
+++
name = "OSINT assessment of SHA256 file hash"
+++

73
src/document.rs Normal file
View File

@ -0,0 +1,73 @@
use std::fmt;
use std::str::FromStr;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MetaError {
#[error("{0}")]
Parse(toml::de::Error),
#[error("meta data not present")]
NotPresent,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Document<M> {
#[serde(flatten)]
pub meta: M,
pub content: String,
}
impl<M> FromStr for Document<M>
where
M: DeserializeOwned,
{
type Err = MetaError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_str_parts(s).map(|(meta, content)| Self {
meta,
content: content.to_owned(),
})
}
}
impl<M> fmt::Display for Document<M>
where
M: Serialize,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "+++")?;
writeln!(f, "{}", toml::to_string(&self.meta).unwrap())?;
writeln!(f, "\n+++")?;
f.write_str(self.content.as_str())
}
}
impl<M> Document<M>
where
M: DeserializeOwned,
{
pub fn parse_str_meta(s: &str) -> Result<M, MetaError> {
parse_str_parts(s).map(|(meta, _)| meta)
}
}
fn parse_str_parts<M>(s: &str) -> Result<(M, &str), MetaError>
where
M: DeserializeOwned,
{
if let Some(s) = s.strip_prefix("+++") {
let mut parts = s.splitn(2, "\n+++");
match (parts.next(), parts.next()) {
(Some(meta), Some(content)) => toml::from_str(meta)
.map(|meta| (meta, content))
.map_err(MetaError::Parse),
_ => Err(MetaError::NotPresent),
}
} else {
Err(MetaError::NotPresent)
}
}

236
src/domains/common.rs Normal file
View File

@ -0,0 +1,236 @@
use std::fmt;
use std::num::{NonZeroU16, ParseIntError};
use std::str::FromStr;
use serde::de::{DeserializeOwned, Deserializer, Error};
use serde::ser::Serializer;
use serde::{Deserialize, Serialize};
use strum::{EnumString, IntoStaticStr};
pub use toml::Value;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct References(Vec<String>);
pub trait DomainModel: Send + Serialize + DeserializeOwned {
fn kind() -> DomainModelKind;
fn name(&self) -> &str;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
pub enum DomainKind {
Source,
Threat,
Observe,
React,
Mitigate,
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)]
pub enum DomainModelKind {
#[strum(serialize = "SIE")]
SourceIntelligence,
#[strum(serialize = "SPR")]
SourceProvider,
#[strum(serialize = "SRT")]
SourceRequirement,
#[strum(serialize = "TTC")]
ThreatTactic,
#[strum(serialize = "TTE")]
ThreatTechnique,
#[strum(serialize = "TSE")]
ThreatSoftware,
#[strum(serialize = "TSN")]
ThreatSimulation,
#[strum(serialize = "OET")]
ObserveEvent,
#[strum(serialize = "ODN")]
ObserveDetection,
#[strum(serialize = "OPR")]
ObserveProvider,
#[strum(serialize = "OCN")]
ObserveConfiguration,
#[strum(serialize = "RSE")]
ReactStage,
#[strum(serialize = "RAN")]
ReactAction,
#[strum(serialize = "RPK")]
ReactPlaybook,
#[strum(serialize = "MSY")]
MitigateStrategy,
#[strum(serialize = "MPM")]
MitigatePlatform,
#[strum(serialize = "MCN")]
MitigateConfiguration,
}
impl DomainModelKind {
pub fn domain(&self) -> DomainKind {
match self {
DomainModelKind::SourceIntelligence => DomainKind::Source,
DomainModelKind::SourceProvider => DomainKind::Source,
DomainModelKind::SourceRequirement => DomainKind::Source,
DomainModelKind::ThreatTactic => DomainKind::Threat,
DomainModelKind::ThreatTechnique => DomainKind::Threat,
DomainModelKind::ThreatSoftware => DomainKind::Threat,
DomainModelKind::ThreatSimulation => DomainKind::Threat,
DomainModelKind::ObserveEvent => DomainKind::Observe,
DomainModelKind::ObserveDetection => DomainKind::Observe,
DomainModelKind::ObserveProvider => DomainKind::Observe,
DomainModelKind::ObserveConfiguration => DomainKind::Observe,
DomainModelKind::ReactStage => DomainKind::React,
DomainModelKind::ReactAction => DomainKind::React,
DomainModelKind::ReactPlaybook => DomainKind::React,
DomainModelKind::MitigateStrategy => DomainKind::Mitigate,
DomainModelKind::MitigatePlatform => DomainKind::Mitigate,
DomainModelKind::MitigateConfiguration => DomainKind::Mitigate,
}
}
pub fn parts(self) -> (&'static str, &'static str) {
match self {
DomainModelKind::SourceIntelligence => ("source", "intelligence"),
DomainModelKind::SourceProvider => ("source", "provider"),
DomainModelKind::SourceRequirement => ("source", "requirement"),
DomainModelKind::ThreatTactic => ("threat", "tactic"),
DomainModelKind::ThreatTechnique => ("threat", "technique"),
DomainModelKind::ThreatSoftware => ("threat", "software"),
DomainModelKind::ThreatSimulation => ("threat", "simulation"),
DomainModelKind::ObserveEvent => ("observe", "event"),
DomainModelKind::ObserveDetection => ("observe", "detection"),
DomainModelKind::ObserveProvider => ("observe", "provider"),
DomainModelKind::ObserveConfiguration => ("observe", "configuration"),
DomainModelKind::ReactStage => ("react", "stage"),
DomainModelKind::ReactAction => ("react", "action"),
DomainModelKind::ReactPlaybook => ("react", "playbook"),
DomainModelKind::MitigateStrategy => ("mitigate", "strategy"),
DomainModelKind::MitigatePlatform => ("mitigate", "platform"),
DomainModelKind::MitigateConfiguration => ("mitigate", "configuration"),
}
}
}
///////////////////////////////////////////////////////////////////////////////
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Copy)]
pub struct ModelId {
pub major: u16,
pub minor: Option<NonZeroU16>,
}
impl ModelId {
pub fn new(major: u16) -> Self {
Self { major, minor: None }
}
pub fn is_example(self) -> bool {
self.major == 0
}
}
impl fmt::Display for ModelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:0>5}", self.major)?;
if let Some(minor) = self.minor {
write!(f, ".{:0>3}", minor)?;
}
Ok(())
}
}
impl FromStr for ModelId {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.splitn(2, '.');
let major = parts.next().unwrap().parse()?;
let minor = parts.next().map(FromStr::from_str).transpose()?;
Ok(Self { major, minor })
}
}
impl Serialize for ModelId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for ModelId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = <&str>::deserialize(deserializer)?;
s.parse().map_err(D::Error::custom)
}
}
///////////////////////////////////////////////////////////////////////////////
#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Copy)]
pub struct DomainId {
pub kind: DomainModelKind,
pub model_id: ModelId,
}
impl DomainId {
pub fn new(kind: DomainModelKind, model_id: ModelId) -> Self {
Self { kind, model_id }
}
}
impl fmt::Debug for DomainId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Id")
.field(&format_args!("{}", self))
.finish()
}
}
impl fmt::Display for DomainId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", <&'static str>::from(&self.kind), self.model_id)
}
}
impl FromStr for DomainId {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > 3 && s.is_char_boundary(3) {
let (kind, model_id) = s.split_at(3);
if let (Ok(kind), Ok(model_id)) =
(DomainModelKind::from_str(kind), ModelId::from_str(model_id))
{
return Ok(Self { kind, model_id });
}
}
Err("invalid storm id")
}
}
impl Serialize for DomainId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for DomainId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = <&str>::deserialize(deserializer)?;
s.parse().map_err(D::Error::custom)
}
}

48
src/domains/mitigate.rs Normal file
View File

@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use crate::domains::common::*;
#[derive(Debug, Serialize, Deserialize)]
pub struct Strategy {
pub name: String,
}
impl DomainModel for Strategy {
fn kind() -> DomainModelKind {
DomainModelKind::MitigateStrategy
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Platform {
pub name: String,
}
impl DomainModel for Platform {
fn kind() -> DomainModelKind {
DomainModelKind::MitigatePlatform
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Configuration {
pub name: String,
}
impl DomainModel for Configuration {
fn kind() -> DomainModelKind {
DomainModelKind::MitigateConfiguration
}
fn name(&self) -> &str {
self.name.as_str()
}
}

54
src/domains/mod.rs Normal file
View File

@ -0,0 +1,54 @@
use common::DomainModelKind;
use crate::document::Document;
pub mod common;
pub mod mitigate;
pub mod observe;
pub mod react;
pub mod source;
pub mod threat;
pub enum GenericDocument {
SourceIntelligence(Document<source::Intelligence>),
SourceProvider(Document<source::Provider>),
SourceRequirement(Document<source::Requirement>),
ThreatTactic(Document<threat::Tactic>),
ThreatTechnique(Document<threat::Technique>),
ThreatSoftware(Document<threat::Software>),
ThreatSimulation(Document<threat::Simulation>),
ObserveEvent(Document<observe::Event>),
ObserveDetection(Document<observe::Detection>),
ObserveProvider(Document<observe::Provider>),
ObserveConfiguration(Document<observe::Configuration>),
ReactStage(Document<react::Stage>),
ReactAction(Document<react::Action>),
ReactPlaybook(Document<react::Playbook>),
MitigateStrategy(Document<mitigate::Strategy>),
MitigatePlatform(Document<mitigate::Platform>),
MitigateConfiguration(Document<mitigate::Configuration>),
}
impl GenericDocument {
pub fn kind(&self) -> DomainModelKind {
match self {
Self::SourceIntelligence(_) => DomainModelKind::SourceIntelligence,
Self::SourceProvider(_) => DomainModelKind::SourceProvider,
Self::SourceRequirement(_) => DomainModelKind::SourceRequirement,
Self::ThreatTactic(_) => DomainModelKind::ThreatTactic,
Self::ThreatTechnique(_) => DomainModelKind::ThreatTechnique,
Self::ThreatSoftware(_) => DomainModelKind::ThreatSoftware,
Self::ThreatSimulation(_) => DomainModelKind::ThreatSimulation,
Self::ObserveEvent(_) => DomainModelKind::ObserveEvent,
Self::ObserveDetection(_) => DomainModelKind::ObserveDetection,
Self::ObserveProvider(_) => DomainModelKind::ObserveProvider,
Self::ObserveConfiguration(_) => DomainModelKind::ObserveConfiguration,
Self::ReactStage(_) => DomainModelKind::ReactStage,
Self::ReactAction(_) => DomainModelKind::ReactAction,
Self::ReactPlaybook(_) => DomainModelKind::ReactPlaybook,
Self::MitigateStrategy(_) => DomainModelKind::MitigateStrategy,
Self::MitigatePlatform(_) => DomainModelKind::MitigatePlatform,
Self::MitigateConfiguration(_) => DomainModelKind::MitigateConfiguration,
}
}
}

View File

@ -0,0 +1,75 @@
mod windows;
use serde::{Deserialize, Serialize};
use crate::domains::common::*;
pub use self::windows::WindowsEvent;
#[derive(Debug, Serialize, Deserialize)]
pub struct Event {
pub name: String,
#[serde(flatten)]
pub body: EventBody,
pub description: String,
}
impl DomainModel for Event {
fn kind() -> DomainModelKind {
DomainModelKind::ObserveEvent
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum EventBody {
WindowsEvent(WindowsEvent),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Detection {
pub name: String,
}
impl DomainModel for Detection {
fn kind() -> DomainModelKind {
DomainModelKind::ObserveDetection
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Provider {
pub name: String,
}
impl DomainModel for Provider {
fn kind() -> DomainModelKind {
DomainModelKind::ObserveProvider
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Configuration {
pub name: String,
}
impl DomainModel for Configuration {
fn kind() -> DomainModelKind {
DomainModelKind::ObserveConfiguration
}
fn name(&self) -> &str {
self.name.as_str()
}
}

View File

@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use crate::util::xml;
#[derive(Debug, Serialize, Deserialize)]
pub struct WindowsEvent {
pub sample: Vec<Sample>,
}
#[derive(Debug)]
pub struct WindowsEventSummary<'a> {
pub event_id: u32,
pub channel: &'a str,
pub provider_name: &'a str,
pub provider_guid: Option<&'a str>,
}
impl WindowsEvent {
pub fn summary(&self) -> Result<WindowsEventSummary<'_>, &'static str> {
let sample = self.sample.get(0).ok_or("no sample")?;
let event_id: u32 = sample
.event_id()
.ok_or("no event id")?
.parse()
.map_err(|_| "event id not u32")?;
let (provider_name, provider_guid) = sample.provider();
Ok(WindowsEventSummary {
event_id,
channel: sample.channel().ok_or("no channel")?,
provider_name: provider_name.ok_or("no provider name")?,
provider_guid,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Sample {
pub xml: xml::DocumentBuf,
pub description: Option<String>,
}
impl Sample {
pub fn provider_name(&self) -> Option<&str> {
self.provider().0
}
pub fn provider(&self) -> (Option<&str>, Option<&str>) {
self.get_event_element("System", "Provider")
.map(|p| (p.attribute("Name"), p.attribute("Guid")))
.unwrap_or_else(|| (None, None))
}
pub fn channel(&self) -> Option<&str> {
self.get_event_element("System", "Channel")
.and_then(|n| n.text())
}
pub fn event_id(&self) -> Option<&str> {
self.get_event_element("System", "EventID")
.and_then(|n| n.text())
}
fn get_event_element(&self, parent: &str, element: &str) -> Option<xml::Node> {
self.xml
.document()
.root_element()
.children()
.find(|c| c.tag_name().name() == parent)
.and_then(|c| c.children().find(|c| c.tag_name().name() == element))
}
}

71
src/domains/react.rs Normal file
View File

@ -0,0 +1,71 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::domains::common::*;
#[derive(Debug, Serialize, Deserialize)]
pub struct Stage {
pub name: String,
pub description: String,
#[serde(default)]
pub additional: Vec<(String, Value)>,
}
impl DomainModel for Stage {
fn kind() -> DomainModelKind {
DomainModelKind::ReactStage
}
fn name(&self) -> &str {
self.name.as_str()
}
}
///////////////////////////////////////////////////////////////////////////////
#[derive(Debug, Serialize, Deserialize)]
pub struct Action {
pub name: String,
pub description: String,
pub stage: DomainId,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub references: References,
#[serde(default)]
pub additional: Vec<(String, Value)>,
}
impl DomainModel for Action {
fn kind() -> DomainModelKind {
DomainModelKind::ReactAction
}
fn name(&self) -> &str {
self.name.as_str()
}
}
///////////////////////////////////////////////////////////////////////////////
#[derive(Debug, Serialize, Deserialize)]
pub struct Playbook {
pub name: String,
pub tags: Vec<String>,
pub description: String,
pub references: References,
pub stages: HashMap<DomainId, Vec<DomainId>>,
#[serde(default)]
pub additional: Vec<(String, Value)>,
}
impl DomainModel for Playbook {
fn kind() -> DomainModelKind {
DomainModelKind::ReactPlaybook
}
fn name(&self) -> &str {
self.name.as_str()
}
}

67
src/domains/source.rs Normal file
View File

@ -0,0 +1,67 @@
use serde::{Deserialize, Serialize};
use crate::domains::common::*;
#[derive(Debug, Serialize, Deserialize)]
pub struct Requirement {
pub name: String,
#[serde(default)]
pub intelligence: Vec<DomainId>,
#[serde(default)]
pub references: References,
#[serde(default)]
pub additional: Vec<(String, Value)>,
}
impl DomainModel for Requirement {
fn kind() -> DomainModelKind {
DomainModelKind::SourceRequirement
}
fn name(&self) -> &str {
self.name.as_str()
}
}
///////////////////////////////////////////////////////////////////////////////
#[derive(Debug, Serialize, Deserialize)]
pub struct Intelligence {
pub name: String,
pub provider: DomainId,
#[serde(default)]
pub references: References,
#[serde(default)]
pub additional: Vec<(String, Value)>,
}
impl DomainModel for Intelligence {
fn kind() -> DomainModelKind {
DomainModelKind::SourceIntelligence
}
fn name(&self) -> &str {
self.name.as_str()
}
}
///////////////////////////////////////////////////////////////////////////////
#[derive(Debug, Serialize, Deserialize)]
pub struct Provider {
pub name: String,
#[serde(default)]
pub references: References,
#[serde(default)]
pub additional: Vec<(String, Value)>,
}
impl DomainModel for Provider {
fn kind() -> DomainModelKind {
DomainModelKind::SourceProvider
}
fn name(&self) -> &str {
self.name.as_str()
}
}

63
src/domains/threat.rs Normal file
View File

@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
use crate::domains::common::*;
#[derive(Debug, Serialize, Deserialize)]
pub struct Tactic {
pub name: String,
}
impl DomainModel for Tactic {
fn kind() -> DomainModelKind {
DomainModelKind::ThreatTactic
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Technique {
pub name: String,
}
impl DomainModel for Technique {
fn kind() -> DomainModelKind {
DomainModelKind::ThreatTechnique
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Software {
pub name: String,
}
impl DomainModel for Software {
fn kind() -> DomainModelKind {
DomainModelKind::ThreatSoftware
}
fn name(&self) -> &str {
self.name.as_str()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Simulation {
pub name: String,
}
impl DomainModel for Simulation {
fn kind() -> DomainModelKind {
DomainModelKind::ThreatSimulation
}
fn name(&self) -> &str {
self.name.as_str()
}
}

357
src/generator/mdbook.rs Normal file
View File

@ -0,0 +1,357 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{io, mem};
use async_trait::async_trait;
use lazy_static::lazy_static;
use regex::Regex;
use serde::Serialize;
use tera::{Context, Map, Tera, Value};
use thiserror::Error;
use tokio::fs;
include!(concat!(env!("OUT_DIR"), "/mdbook-templates.rs"));
use crate::document::Document;
use crate::domains::common::{DomainId, DomainModel, ModelId};
use crate::domains::GenericDocument;
use crate::registry::{Registry, RegistryConfig};
use crate::generator::{Engine, GeneratorError};
#[derive(Debug, Error)]
pub enum MDBookEngineError {
#[error("{0}")]
Io(#[from] io::Error),
#[error("{0}")]
Tera(#[from] tera::Error),
}
impl From<MDBookEngineError> for GeneratorError {
fn from(err: MDBookEngineError) -> Self {
GeneratorError::Engine(err.into())
}
}
#[derive(Debug, Serialize)]
struct SummaryItem {
id: DomainId,
name: String,
}
pub struct MDBookEngine {
inner: Inner,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
enum Inner {
Start {
src: PathBuf,
},
LoadAndRender {
src: PathBuf,
templates: Tera,
summary: Vec<SummaryItem>,
documents: HashMap<DomainId, Value>,
},
Finish,
}
impl MDBookEngine {
pub fn new(src: &Path) -> Result<Self, MDBookEngineError> {
Ok(Self {
inner: Inner::Start {
src: src.canonicalize()?,
},
})
}
}
// (?Send)
#[async_trait]
impl Engine for MDBookEngine {
async fn start<R>(&mut self, _registry: &R) -> Result<(), GeneratorError>
where
R: Registry,
{
if let Inner::Start { src } = mem::replace(&mut self.inner, Inner::Finish) {
let templates = load_templates(&src).await?;
let documents = HashMap::new();
let summary = Vec::new();
self.inner = Inner::LoadAndRender {
src,
templates,
documents,
summary,
};
}
Ok(())
}
async fn push_document<R>(
&mut self,
model_id: ModelId,
doc: GenericDocument,
_registry: &R,
) -> Result<(), GeneratorError>
where
R: Registry,
{
if let Inner::LoadAndRender {
ref mut summary,
ref mut documents,
..
} = self.inner
{
macro_rules! push_documents {
($($kind:ident),+) => {
match doc {
$(
GenericDocument::$kind(doc) => {
save_domain_model(summary, documents, model_id, &doc);
}
)+
}
};
}
push_documents!(
SourceIntelligence,
SourceRequirement,
SourceProvider,
ThreatTactic,
ThreatTechnique,
ThreatSoftware,
ThreatSimulation,
ObserveEvent,
ObserveDetection,
ObserveProvider,
ObserveConfiguration,
ReactStage,
ReactAction,
ReactPlaybook,
MitigateStrategy,
MitigatePlatform,
MitigateConfiguration
);
}
Ok(())
}
async fn finish<R>(&mut self, registry: &R) -> Result<(), GeneratorError>
where
R: Registry,
{
if let Inner::LoadAndRender {
src,
mut templates,
documents,
summary,
} = mem::replace(&mut self.inner, Inner::Finish)
{
let summary = Arc::new(summary);
let documents = Arc::new(documents);
let registry_config = registry.get_config().await?;
register_filters(&mut templates, documents.clone(), registry_config);
render_indexes(&templates, &src, &summary).await?;
render_summary(&templates, &src, &summary).await?;
render_documents(&templates, &src, documents).await?;
}
Ok(())
}
}
async fn load_templates(src: &Path) -> Result<Tera, MDBookEngineError> {
let mut files = Vec::new();
for filename in BASE_TEMPLATES.file_names() {
let name = filename.strip_prefix("book/").unwrap();
let external = src.join(name);
let content = if external.is_file() {
fs::read_to_string(external).await?
} else {
let mut content = String::new();
BASE_TEMPLATES
.read(filename)?
.read_to_string(&mut content)?;
content
};
files.push((name, content));
}
let mut templates = Tera::default();
templates.add_raw_templates(files)?;
Ok(templates)
}
fn save_domain_model<M>(
summary: &mut Vec<SummaryItem>,
documents: &mut HashMap<DomainId, Value>,
model_id: ModelId,
doc: &Document<M>,
) where
M: DomainModel,
{
let id = DomainId::new(M::kind(), model_id);
let mut value = Map::new();
value.insert("id".to_owned(), tera::to_value(&id).unwrap());
value.insert("doc".to_owned(), tera::to_value(&doc).unwrap());
documents.insert(id, value.into());
summary.push(SummaryItem {
id,
name: doc.meta.name().to_owned(),
});
}
async fn render_documents(
templates: &Tera,
src: &Path,
documents: Arc<HashMap<DomainId, Value>>,
) -> Result<(), MDBookEngineError> {
for (id, value) in documents.iter() {
let (domain, model) = id.kind.parts();
let model_path = format!("{}/{}", domain, model);
let template_path = format!("{}.instance.tera", model_path);
let output_path = src
.join(model_path)
.join(id.model_id.to_string())
.with_extension("md");
let context = Context::from_value(value.clone())?;
let contents = templates.render(&template_path, &context)?;
fs::write(output_path, contents).await?;
}
Ok(())
}
async fn render_summary(
templates: &Tera,
src: &Path,
summary: &[SummaryItem],
) -> Result<(), MDBookEngineError> {
let mut context = Context::new();
context.insert("summary", summary);
let contents = templates.render("summary.tera", &context)?;
let summary_path = src.join("SUMMARY.md");
fs::write(summary_path, contents).await?;
Ok(())
}
async fn render_indexes(
templates: &Tera,
src: &Path,
summary: &[SummaryItem],
) -> Result<(), MDBookEngineError> {
let mut last_i = 0;
let mut last_kind = None;
for (i, item) in summary.iter().enumerate() {
if Some(item.id.kind) != last_kind {
let (domain, model) = item.id.kind.parts();
let domain_dir = src.join(domain);
let model_dir = domain_dir.join(model);
let mut context = Context::new();
context.insert("instances", &summary[last_i..i]);
if !domain_dir.is_dir() {
fs::create_dir(&domain_dir).await?;
}
if !model_dir.is_dir() {
fs::create_dir(&model_dir).await?;
}
let model_index = model_dir.with_extension("md");
let template_path = format!("{}/{}.tera", domain, model);
let contents = templates.render(&template_path, &context)?;
fs::write(model_index, contents).await?;
last_i = i;
last_kind = Some(item.id.kind);
}
}
Ok(())
}
fn register_filters(
tera: &mut Tera,
registry: Arc<HashMap<DomainId, Value>>,
registry_config: Arc<RegistryConfig>,
) {
use tera::{try_get_value, Error};
fn parse_domain_id(value: &Value) -> Result<DomainId, Error> {
let id = try_get_value!("link", "value", String, value);
id.parse()
.map_err(|_| Error::msg(format_args!("failed to parse domain id `{}`", id)))
}
tera.register_filter(
"domain_id_link",
move |value: &Value, args: &HashMap<String, Value>| {
let id = parse_domain_id(value)?;
let (domain, model) = id.kind.parts();
let link = match args.get("for").and_then(|v| v.as_str()) {
None => format!("/{}/{}/{}.md", domain, model, id.model_id),
Some("model") => format!("/{}/{}.md", domain, model),
Some("edit") => registry_config.edit_link(id).map_err(Error::msg)?,
Some(_) => {
return Err(Error::msg(
"domain_id_link `for` arg can only be `model|edit`",
))
}
};
Ok(Value::String(link))
},
);
tera.register_filter(
"domain_id",
move |value: &Value, _: &HashMap<String, Value>| {
let id = parse_domain_id(value)?;
let (domain, model) = id.kind.parts();
let mut parts = Map::with_capacity(4);
parts.insert("domain".to_owned(), domain.into());
parts.insert("model".to_owned(), model.into());
parts.insert("model_id".to_owned(), id.model_id.to_string().into());
Ok(Value::Object(parts))
},
);
tera.register_filter(
"get_doc",
move |value: &Value, _: &HashMap<String, Value>| {
let id = parse_domain_id(value)?;
registry
.get(&id)
.cloned()
.ok_or_else(|| Error::msg(format_args!("unknown document id {}", id)))
},
);
tera.register_filter("autolink", |value: &Value, _: &HashMap<String, Value>| {
let text = try_get_value!("autolink", "value", String, value);
if text.is_empty() {
return Ok(Value::String(String::new()));
}
lazy_static! {
static ref URL: Regex = Regex::new(
r"(?ix)
\b(([\w-]+://?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))
",
)
.unwrap();
}
let replaced = URL.replace_all(text.as_str(), "[$0]($0)").into_owned();
Ok(Value::String(replaced))
});
}

101
src/generator/mod.rs Normal file
View File

@ -0,0 +1,101 @@
pub mod mdbook;
use async_trait::async_trait;
use thiserror::Error;
use crate::document::Document;
use crate::domains::common::{DomainId, DomainModel, ModelId};
use crate::domains::GenericDocument;
use crate::registry::{Registry, RegistryError};
#[derive(Debug, Error)]
pub enum GeneratorError {
#[error("{0}")]
Engine(#[from] anyhow::Error),
#[error("{0}")]
Registry(#[from] RegistryError),
}
pub struct Generator<E, R> {
engine: E,
registry: R,
}
impl<E, R> Generator<E, R>
where
E: Engine,
R: Registry,
{
pub fn new(engine: E, registry: R) -> Self {
Self { engine, registry }
}
pub async fn generate(mut self) -> Result<(), GeneratorError> {
self.engine.start(&self.registry).await?;
macro_rules! push_documents {
($($kind:ident),+) => {
$(
for (id, doc) in self.get_documents().await? {
self.engine
.push_document(id.model_id, GenericDocument::$kind(doc), &self.registry)
.await?;
}
)*
};
}
push_documents!(
SourceIntelligence,
SourceRequirement,
SourceProvider,
ThreatTactic,
ThreatTechnique,
ThreatSoftware,
ThreatSimulation,
ObserveEvent,
ObserveDetection,
ObserveProvider,
ObserveConfiguration,
ReactStage,
ReactAction,
ReactPlaybook,
MitigateStrategy,
MitigatePlatform,
MitigateConfiguration
);
self.engine.finish(&self.registry).await
}
async fn get_documents<M>(&self) -> Result<Vec<(DomainId, Document<M>)>, GeneratorError>
where
M: DomainModel,
{
self.registry
.get_documents()
.await
.map(|mut docs| {
docs.sort_by_key(|(id, _)| *id);
docs
})
.map_err(GeneratorError::Registry)
}
}
#[async_trait]
pub trait Engine {
async fn start<R>(&mut self, registry: &R) -> Result<(), GeneratorError>
where
R: Registry;
async fn push_document<R>(
&mut self,
id: ModelId,
doc: GenericDocument,
registry: &R,
) -> Result<(), GeneratorError>
where
R: Registry;
async fn finish<R>(&mut self, registry: &R) -> Result<(), GeneratorError>
where
R: Registry;
}

6
src/lib.rs Normal file
View File

@ -0,0 +1,6 @@
mod util;
pub mod document;
pub mod domains;
pub mod generator;
pub mod registry;

72
src/main.rs Normal file
View File

@ -0,0 +1,72 @@
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{Context, Error};
use structopt::StructOpt;
use cyberstorm::generator::mdbook::MDBookEngine;
use cyberstorm::generator::Generator;
use cyberstorm::registry::{DirectoryRegistry, SupportedRegistry};
#[derive(Debug, StructOpt)]
#[structopt(name = "cyberstorm")]
struct Opt {
/// Registry to use.
registry: RegistryOpt,
#[structopt(subcommand)]
cmd: Cmd,
}
#[derive(Debug, StructOpt)]
enum Cmd {
BuildMdbook(BuildMdbookOpt),
}
#[derive(Debug, StructOpt)]
struct BuildMdbookOpt {
/// Path to the output MDBook directory.
#[structopt(parse(from_os_str))]
book: PathBuf,
}
#[derive(Debug)]
enum RegistryOpt {
Directory(PathBuf),
}
impl FromStr for RegistryOpt {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::Directory(PathBuf::from(s)))
}
}
async fn run(opt: Opt) -> Result<(), Error> {
let registry = match &opt.registry {
RegistryOpt::Directory(path) => SupportedRegistry::Directory(DirectoryRegistry::new(path)),
};
match &opt.cmd {
Cmd::BuildMdbook(build_opt) => build_mdbook(&opt, build_opt, &registry).await,
}
}
async fn build_mdbook(
_opt: &Opt,
build_opt: &BuildMdbookOpt,
registry: &SupportedRegistry,
) -> Result<(), Error> {
let engine = MDBookEngine::new(&build_opt.book).context("failed to load mdbook engine")?;
Generator::new(engine, registry)
.generate()
.await
.context("failed to generate mdbook content")
}
#[tokio::main]
async fn main() {
if let Err(err) = run(Opt::from_args()).await {
eprintln!("{:#}", err);
}
}

105
src/registry/directory.rs Normal file
View File

@ -0,0 +1,105 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use async_trait::async_trait;
use tokio::fs;
use crate::document::Document;
use crate::domains::common::*;
use crate::registry::{Registry, RegistryConfig, RegistryError};
#[derive(Debug)]
pub struct DirectoryRegistry {
path: PathBuf,
}
impl DirectoryRegistry {
pub fn new<P: Into<PathBuf>>(path: P) -> Self {
Self { path: path.into() }
}
fn domain_model_kind_path(&self, kind: DomainModelKind) -> PathBuf {
let (domain, model) = kind.parts();
self.path.join(format!("{}/{}", domain, model))
}
fn domain_id_path(&self, id: DomainId) -> PathBuf {
let (domain, model) = id.kind.parts();
self.path
.join(format!("{}/{}/{}.md", domain, model, id.model_id))
}
}
#[async_trait]
impl Registry for DirectoryRegistry {
async fn get_config(&self) -> Result<Arc<RegistryConfig>, RegistryError> {
let config_str = fs::read_to_string(self.path.join("config.toml")).await?;
toml::from_str(&config_str)
.context("failed to read registry `config.toml`")
.map(Arc::new)
.map_err(Into::into)
}
async fn get_document<M>(&self, model_id: ModelId) -> Result<Document<M>, RegistryError>
where
M: DomainModel,
{
let id = DomainId::new(M::kind(), model_id);
let path = self.domain_id_path(id);
if !path.is_file() {
return Err(RegistryError::NotFound(id));
}
let s = fs::read_to_string(path).await?;
s.parse().map_err(|err| RegistryError::Document(id, err))
}
async fn get_documents<M>(&self) -> Result<Vec<(DomainId, Document<M>)>, RegistryError>
where
M: DomainModel,
{
let ids = self.get_document_ids(M::kind()).await?;
let mut docs = Vec::with_capacity(ids.len());
for id in ids {
docs.push((id, self.get_document(id.model_id).await?));
}
Ok(docs)
}
async fn get_document_ids(
&self,
kind: DomainModelKind,
) -> Result<Vec<DomainId>, RegistryError> {
let mut ids = Vec::new();
let path = self.domain_model_kind_path(kind);
if !path.is_dir() {
return Ok(vec![]);
}
let mut dirs = fs::read_dir(path).await?;
while let Some(entry) = dirs.next_entry().await? {
let path = entry.path();
if path.is_file() {
let filestem = path.file_stem().unwrap().to_string_lossy();
let model_id: ModelId = filestem.as_ref().parse().unwrap();
if !model_id.is_example() {
ids.push(DomainId::new(kind, model_id))
}
}
}
Ok(ids)
}
async fn put_document<M>(
&self,
model_id: ModelId,
document: Document<M>,
) -> Result<(), RegistryError>
where
M: DomainModel,
{
let path = self.domain_id_path(DomainId::new(M::kind(), model_id));
let document = document.to_string();
fs::write(path, document.as_str()).await?;
Ok(())
}
}

178
src/registry/mod.rs Normal file
View File

@ -0,0 +1,178 @@
mod directory;
use std::fmt::Debug;
use std::sync::Arc;
use anyhow::{anyhow, Context as _};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tera::{Context, Tera};
use thiserror::Error;
use crate::document::{Document, MetaError};
use crate::domains::common::*;
pub use self::directory::DirectoryRegistry;
#[derive(Debug, Error)]
pub enum RegistryError {
#[error("config error: {0}")]
Config(anyhow::Error),
#[error("failed to parse document {0}: {1}")]
Document(DomainId, MetaError),
#[error("domain id {0} not found")]
NotFound(DomainId),
#[error("{0}")]
Other(#[from] anyhow::Error),
}
impl From<std::io::Error> for RegistryError {
fn from(err: std::io::Error) -> Self {
Self::Other(err.into())
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct RegistryConfig {
edit_link: Option<String>,
}
impl RegistryConfig {
pub fn edit_link(&self, domain_id: DomainId) -> Result<String, RegistryError> {
let edit_link = self
.edit_link
.as_deref()
.ok_or_else(|| RegistryError::Config(anyhow!("edit-link option not set")))?;
let (domain, model) = domain_id.kind.parts();
let mut context = Context::new();
context.insert("id", &domain_id);
context.insert("domain", domain);
context.insert("model", model);
context.insert("model_id", &domain_id.model_id);
Tera::one_off(edit_link, &context, false)
.context("error rendering edit link")
.map_err(RegistryError::Config)
}
}
#[async_trait]
pub trait Registry: Send + Sync {
async fn get_config(&self) -> Result<Arc<RegistryConfig>, RegistryError>;
async fn get_document<M>(&self, model_id: ModelId) -> Result<Document<M>, RegistryError>
where
M: DomainModel;
async fn get_documents<M>(&self) -> Result<Vec<(DomainId, Document<M>)>, RegistryError>
where
M: DomainModel;
async fn get_document_ids(&self, kind: DomainModelKind)
-> Result<Vec<DomainId>, RegistryError>;
async fn put_document<M>(
&self,
model_id: ModelId,
document: Document<M>,
) -> Result<(), RegistryError>
where
M: DomainModel;
}
#[async_trait]
impl<T> Registry for &T
where
T: Registry,
{
async fn get_config(&self) -> Result<Arc<RegistryConfig>, RegistryError> {
(**self).get_config().await
}
async fn get_document<M>(&self, model_id: ModelId) -> Result<Document<M>, RegistryError>
where
M: DomainModel,
{
(**self).get_document(model_id).await
}
async fn get_documents<M>(&self) -> Result<Vec<(DomainId, Document<M>)>, RegistryError>
where
M: DomainModel,
{
(**self).get_documents().await
}
async fn get_document_ids(
&self,
kind: DomainModelKind,
) -> Result<Vec<DomainId>, RegistryError> {
(**self).get_document_ids(kind).await
}
async fn put_document<M>(
&self,
model_id: ModelId,
document: Document<M>,
) -> Result<(), RegistryError>
where
M: DomainModel,
{
(**self).put_document(model_id, document).await
}
}
#[derive(Debug)]
pub enum SupportedRegistry {
Directory(DirectoryRegistry),
}
#[async_trait]
impl Registry for SupportedRegistry {
async fn get_config(&self) -> Result<Arc<RegistryConfig>, RegistryError> {
match self {
Self::Directory(r) => r.get_config().await,
}
}
async fn get_document<M>(&self, model_id: ModelId) -> Result<Document<M>, RegistryError>
where
M: DomainModel,
{
match self {
Self::Directory(r) => r.get_document(model_id).await,
}
}
async fn get_documents<M>(&self) -> Result<Vec<(DomainId, Document<M>)>, RegistryError>
where
M: DomainModel,
{
match self {
Self::Directory(r) => r.get_documents().await,
}
}
async fn get_document_ids(
&self,
kind: DomainModelKind,
) -> Result<Vec<DomainId>, RegistryError> {
match self {
Self::Directory(r) => r.get_document_ids(kind).await,
}
}
async fn put_document<M>(
&self,
model_id: ModelId,
document: Document<M>,
) -> Result<(), RegistryError>
where
M: DomainModel,
{
match self {
Self::Directory(r) => r.put_document(model_id, document).await,
}
}
}

1
src/util/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod xml;

64
src/util/xml.rs Normal file
View File

@ -0,0 +1,64 @@
use core::convert::TryFrom;
use core::fmt;
use roxmltree::Document;
use zc::{Dependant, Zc};
pub use roxmltree::{self, *};
pub struct DocumentBuf(Zc<String, DependantDocument<'static>>);
impl DocumentBuf {
pub fn document<'input>(&'input self) -> &Document<'input> {
&self.0.get::<DependantDocument<'input>>().0
}
}
impl fmt::Debug for DocumentBuf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self.document(), f)
}
}
impl TryFrom<String> for DocumentBuf {
type Error = roxmltree::Error;
fn try_from(s: String) -> Result<Self, Self::Error> {
match zc::try_from!(s, DependantDocument, str) {
Ok(doc) => Ok(Self(doc)),
Err((err, _)) => Err(err),
}
}
}
impl serde::ser::Serialize for DocumentBuf {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.0.as_owned())
}
}
impl<'de> serde::de::Deserialize<'de> for DocumentBuf {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
use serde::de::Error;
Self::try_from(String::deserialize(deserializer)?).map_err(D::Error::custom)
}
}
struct DependantDocument<'input>(Document<'input>);
unsafe impl<'o> Dependant<'o> for DependantDocument<'o> {
type Static = DependantDocument<'static>;
}
impl<'input> TryFrom<&'input str> for DependantDocument<'input> {
type Error = roxmltree::Error;
fn try_from(s: &'input str) -> Result<Self, Self::Error> {
Document::parse(s).map(Self)
}
}