initial commit
This commit is contained in:
commit
22a4e9fbf8
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
# STORM
|
||||
/dist
|
||||
|
29
Cargo.toml
Normal file
29
Cargo.toml
Normal 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
84
README.md
Normal 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
14
book.toml
Normal 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
6
book/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/SUMMARY.md
|
||||
/source/**/*.md
|
||||
/threat/**/*.md
|
||||
/observe/**/*.md
|
||||
/react/**/*.md
|
||||
/mitigate/**/*.md
|
49
book/macros.tera
Normal file
49
book/macros.tera
Normal 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 %}
|
22
book/observe/event.instance.tera
Normal file
22
book/observe/event.instance.tera
Normal 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
7
book/observe/event.tera
Normal 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) }}
|
17
book/react/action.instance.tera
Normal file
17
book/react/action.instance.tera
Normal 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
5
book/react/action.tera
Normal file
@ -0,0 +1,5 @@
|
||||
{% import "macros.tera" as macros %}
|
||||
|
||||
# Action
|
||||
|
||||
{{ macros::summary_list(instances=instances) }}
|
10
book/react/stage.instance.tera
Normal file
10
book/react/stage.instance.tera
Normal 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
7
book/react/stage.tera
Normal file
@ -0,0 +1,7 @@
|
||||
{% import "macros.tera" as macros %}
|
||||
|
||||
# Stage
|
||||
|
||||
Phase of a response to observed threat behaviour.
|
||||
|
||||
{{ macros::summary_list(instances=instances) }}
|
14
book/source/intelligence.instance.tera
Normal file
14
book/source/intelligence.instance.tera
Normal 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) }}
|
7
book/source/intelligence.tera
Normal file
7
book/source/intelligence.tera
Normal 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) }}
|
13
book/source/provider.instance.tera
Normal file
13
book/source/provider.instance.tera
Normal 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) }}
|
7
book/source/provider.tera
Normal file
7
book/source/provider.tera
Normal file
@ -0,0 +1,7 @@
|
||||
{% import "macros.tera" as macros %}
|
||||
|
||||
# Provider
|
||||
|
||||
An internal or external supplier of intelligence.
|
||||
|
||||
{{ macros::summary_list(instances=instances) }}
|
13
book/source/requirement.instance.tera
Normal file
13
book/source/requirement.instance.tera
Normal 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) }}
|
7
book/source/requirement.tera
Normal file
7
book/source/requirement.tera
Normal 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
16
book/summary.tera
Normal 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
13
build.rs
Normal 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
1
registry/config.toml
Normal file
@ -0,0 +1 @@
|
||||
edit-link = "https://localhost/registry/{{domain}}/{{model}}/{{model_id}}.md"
|
3
registry/mitigate/configuration/00000.md
Normal file
3
registry/mitigate/configuration/00000.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
|
||||
+++
|
3
registry/mitigate/platform/00000.md
Normal file
3
registry/mitigate/platform/00000.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
|
||||
+++
|
3
registry/mitigate/strategy/00000.md
Normal file
3
registry/mitigate/strategy/00000.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
|
||||
+++
|
3
registry/observe/configuration/00000.md
Normal file
3
registry/observe/configuration/00000.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
|
||||
+++
|
3
registry/observe/detection/00000.md
Normal file
3
registry/observe/detection/00000.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
|
||||
+++
|
43
registry/observe/event/00000.md
Normal file
43
registry/observe/event/00000.md
Normal 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
|
3
registry/observe/provider/00000.md
Normal file
3
registry/observe/provider/00000.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
|
||||
+++
|
6
registry/react/action/00001.md
Normal file
6
registry/react/action/00001.md
Normal 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'
|
||||
+++
|
||||
|
4
registry/react/stage/00001.md
Normal file
4
registry/react/stage/00001.md
Normal file
@ -0,0 +1,4 @@
|
||||
+++
|
||||
name = "Preparation"
|
||||
description = "Get prepared for a security incident"
|
||||
+++
|
4
registry/react/stage/00002.md
Normal file
4
registry/react/stage/00002.md
Normal file
@ -0,0 +1,4 @@
|
||||
+++
|
||||
name = "Identification"
|
||||
description = "Gather information about a threat that has triggered a security incident, its TTPs, and affected assets"
|
||||
+++
|
4
registry/react/stage/00003.md
Normal file
4
registry/react/stage/00003.md
Normal file
@ -0,0 +1,4 @@
|
||||
+++
|
||||
name = "Containment"
|
||||
description = "Prevent a threat from achieving its objectives and/or spreading around an environment"
|
||||
+++
|
4
registry/react/stage/00004.md
Normal file
4
registry/react/stage/00004.md
Normal file
@ -0,0 +1,4 @@
|
||||
+++
|
||||
name = "Eradication"
|
||||
description = "Remove a threat from an environment"
|
||||
+++
|
4
registry/react/stage/00005.md
Normal file
4
registry/react/stage/00005.md
Normal file
@ -0,0 +1,4 @@
|
||||
+++
|
||||
name = "Recovery"
|
||||
description = "Recover from the incident and return all the assets back to normal operation"
|
||||
+++
|
4
registry/react/stage/00006.md
Normal file
4
registry/react/stage/00006.md
Normal file
@ -0,0 +1,4 @@
|
||||
+++
|
||||
name = "Lessons Learned"
|
||||
description = "Discover how to improve the Incident Response process and implement the improvements"
|
||||
+++
|
7
registry/source/intelligence/00001.md
Normal file
7
registry/source/intelligence/00001.md
Normal file
@ -0,0 +1,7 @@
|
||||
+++
|
||||
name = "DomainTools Whois Lookup"
|
||||
provider = "SPR0001"
|
||||
references = [
|
||||
"https://whois.domaintools.com/"
|
||||
]
|
||||
+++
|
3
registry/source/provider/00001.md
Normal file
3
registry/source/provider/00001.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
name = "DomainTools"
|
||||
+++
|
3
registry/source/provider/00002.md
Normal file
3
registry/source/provider/00002.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
name = "VirusTotal"
|
||||
+++
|
6
registry/source/requirement/00001.md
Normal file
6
registry/source/requirement/00001.md
Normal file
@ -0,0 +1,6 @@
|
||||
+++
|
||||
name = "OSINT assessment of IPv4/IPv6 address"
|
||||
intelligence = [
|
||||
"SIE0001",
|
||||
]
|
||||
+++
|
6
registry/source/requirement/00002.md
Normal file
6
registry/source/requirement/00002.md
Normal file
@ -0,0 +1,6 @@
|
||||
+++
|
||||
name = "OSINT assessment of domain"
|
||||
intelligence = [
|
||||
"SIE0001",
|
||||
]
|
||||
+++
|
3
registry/source/requirement/00003.md
Normal file
3
registry/source/requirement/00003.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
name = "OSINT assessment of MD5 file hash"
|
||||
+++
|
3
registry/source/requirement/00004.md
Normal file
3
registry/source/requirement/00004.md
Normal file
@ -0,0 +1,3 @@
|
||||
+++
|
||||
name = "OSINT assessment of SHA256 file hash"
|
||||
+++
|
73
src/document.rs
Normal file
73
src/document.rs
Normal 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
236
src/domains/common.rs
Normal 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
48
src/domains/mitigate.rs
Normal 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
54
src/domains/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
75
src/domains/observe/mod.rs
Normal file
75
src/domains/observe/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
71
src/domains/observe/windows.rs
Normal file
71
src/domains/observe/windows.rs
Normal 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
71
src/domains/react.rs
Normal 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
67
src/domains/source.rs
Normal 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
63
src/domains/threat.rs
Normal 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
357
src/generator/mdbook.rs
Normal 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
101
src/generator/mod.rs
Normal 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
6
src/lib.rs
Normal 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
72
src/main.rs
Normal 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, ®istry).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
105
src/registry/directory.rs
Normal 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
178
src/registry/mod.rs
Normal 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
1
src/util/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod xml;
|
64
src/util/xml.rs
Normal file
64
src/util/xml.rs
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user