Repository: kpcyrd/sn0int (language: rust)

Issue:
Identities/Profiles
There should be a table to add online profiles.

From the top of my head, the following columns should exist:

- `service` - the name of the service. this should be somewhat unified to make sure modules are compatible with each other.
- `username` - the public username or login name
- `url` (optional) - if profiles have public urls, the url to the profile
- `email` (optional) - the profile email, if available
- `last_seen` (optional) - the datetime the profile was last online, possibly monitored using a cronjob
- `last_location` (optional) - the last location published by this profile
- `last_location_time` (optional) - the datetime this location was published

If there is no concept of usernames, this column should duplicate the email column. The combination of service+username should be unique.

We probably also need a Person table to link profiles to an individual. A profile could be used by multiple individuals, so this might have to be a many-to-many relationship. If no person is known/linked, this should be displayed as Anonymous.

This might be tricky since the location data should be available on the Person object by populating the `DetailedPerson` from its children, but this would update the location of every person linked to a shared profile.

We probably also want to link the email table to the Person table and extend the pgp script to extract more info from the rfc4880 uid.


Discussion / hints:
We might have to extend the `Source` for this one to accept a parameter. So for example you could have:
- `-- Source: account`
- `-- Source: account:github.com`
The first one selects all accounts, the second one would select all github accounts.
There is currently a text field for the email, we might want to replace this with a foreign key to the email table.

I'm not sure this would make anything more complicated because this means if we set accounts as a source we don't get the email address anymore.

Fix patch:
diff --git a/.travis.yml b/.travis.yml
index 7075d2eb..32ed11ee 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -17,10 +17,11 @@ matrix:
     rust: stable
     env:
     - BUILD_MODE=boxxy
-  - os: linux
-    rust: stable
-    env:
-    - BUILD_MODE=docker
+  # exceeds max travis build time
+  #- os: linux
+  #  rust: stable
+  #  env:
+  #  - BUILD_MODE=docker
   - os: linux
     rust: stable
     env:
diff --git a/Cargo.lock b/Cargo.lock
index 567c2cdd..bfd0b0a1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2448,6 +2448,7 @@ dependencies = [
  "rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)",
  "regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "rustyline 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "separator 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde_derive 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)",
diff --git a/Cargo.toml b/Cargo.toml
index 73ccf85b..690a810d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,6 +60,7 @@ nom = "4.1.1"
 atty = "0.2"
 bufstream = "0.1.4"
 tokio = "0.1.14"
+semver = "0.9"
 
 [target.'cfg(target_os="linux")'.dependencies]
 caps = "0.3"
diff --git a/migrations/2019-02-03-123424_accounts/down.sql b/migrations/2019-02-03-123424_accounts/down.sql
new file mode 100644
index 00000000..5eb8e054
--- /dev/null
+++ b/migrations/2019-02-03-123424_accounts/down.sql
@@ -0,0 +1,1 @@
+DROP TABLE accounts;
diff --git a/migrations/2019-02-03-123424_accounts/up.sql b/migrations/2019-02-03-123424_accounts/up.sql
new file mode 100644
index 00000000..04a0b4cb
--- /dev/null
+++ b/migrations/2019-02-03-123424_accounts/up.sql
@@ -0,0 +1,12 @@
+CREATE TABLE accounts (
+    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+    value VARCHAR NOT NULL,
+    service VARCHAR NOT NULL,
+    username VARCHAR NOT NULL,
+    displayname VARCHAR,
+    email VARCHAR,
+    url VARCHAR,
+    last_seen DATETIME,
+    unscoped BOOLEAN DEFAULT 0 NOT NULL,
+    CONSTRAINT account_unique UNIQUE (value)
+);
diff --git a/modules/dev/github.lua b/modules/dev/github.lua
new file mode 100644
index 00000000..69f6d402
--- /dev/null
+++ b/modules/dev/github.lua
@@ -0,0 +1,76 @@
+-- Description: Collect data from github profiles
+-- Version: 0.1.0
+-- Source: accounts:github.com
+-- License: GPL-3.0
+
+function api_get(url)
+    local req = http_request(session, 'GET', url, {})
+    local resp = http_send(req)
+    if last_err() then return end
+    if resp['status'] ~= 200 then return 'invalid status code' end
+
+    local data = json_decode(resp['text'])
+    if last_err() then return end
+    return data
+end
+
+function scan4email(username)
+    local url = 'https://api.github.com/users/' .. username .. '/repos'
+    local repos = api_get(url)
+    if last_err() then return end
+
+    i = 1
+    while i <= #repos do
+        local repo = repos[i]
+        debug(repo)
+        local commits = api_get(repo['url'] .. '/commits')
+        if last_err() then return end
+
+        j = 1
+        while j <= #commits do
+            local commit = commits[j]
+            debug(commit)
+
+            if commit['author']['login'] == username then
+                -- name = commit['commit']['author']['name']
+                local email = commit['commit']['author']['email']
+                return email
+            end
+
+            if commit['committer']['login'] == username then
+                -- name = commit['commit']['committer']['name']
+                local email = commit['commit']['committer']['email']
+                return email
+            end
+
+            j = j+1
+        end
+
+        i = i+1
+    end
+end
+
+function run(arg)
+    session = http_mksession()
+    local url = 'https://api.github.com/users/' .. arg['username']
+
+    local data = api_get(url)
+    if last_err() then return end
+    debug(data)
+
+    -- company = data['company']
+    -- location = data['location']
+    -- homepage = data['blog']
+
+    local email = data['email']
+    if not email and not arg['email'] then
+        email = scan4email(arg['username'])
+        if last_err() then return end
+    end
+
+    db_update('account', arg, {
+        url=data['html_url'],
+        displayname=data['name'],
+        email=email,
+    })
+end
diff --git a/modules/dev/instagram.lua b/modules/dev/instagram.lua
new file mode 100644
index 00000000..86fa000b
--- /dev/null
+++ b/modules/dev/instagram.lua
@@ -0,0 +1,36 @@
+-- Description: Collect data from instagram profiles
+-- Version: 0.1.0
+-- Source: accounts:instagram.com
+-- License: GPL-3.0
+
+function run(arg)
+    local session = http_mksession()
+    local url = 'https://www.instagram.com/' .. arg['username'] .. '/'
+    local req = http_request(session, 'GET', url, {})
+    local resp = http_send(req)
+    if last_err() then return end
+    if resp['status'] ~= 200 then return 'invalid status code' end
+    local html = resp['text']
+
+    local ld = html_select(html, 'script[type="application/ld+json"]')
+    if last_err() then return end
+    debug(ld)
+
+    local ld = json_decode(ld['text'])
+    if last_err() then return end
+    debug(ld)
+
+    if ld['email'] then
+        db_add('email', {
+            value=ld['email'],
+        })
+    end
+
+    -- homepage=ld['url']
+
+    db_update('account', arg, {
+        displayname=ld['name'],
+        email=ld['email'],
+        url=url,
+    })
+end
diff --git a/modules/dev/namechk.lua b/modules/dev/namechk.lua
new file mode 100644
index 00000000..6cfcdd96
--- /dev/null
+++ b/modules/dev/namechk.lua
@@ -0,0 +1,90 @@
+-- Description: Find accounts by username with namechk.com
+-- Version: 0.1.0
+-- Source: accounts
+-- License: GPL-3.0
+
+function get_services(html)
+    local divs = html_select_list(html, '.service')
+    if last_err() then return end
+
+    local services = {}
+
+    local i = 1
+    while i <= #divs do
+        services[i] = divs[i]['attrs']['data-name']
+        i = i+1
+    end
+
+    return services
+end
+
+function run(arg)
+    -- setup session
+    local session = http_mksession()
+    local req = http_request(session, 'GET', 'https://namechk.com/', {})
+    local resp = http_send(req)
+    if last_err() then return end
+    if resp['status'] ~= 200 then return 'http error: ' .. resp['status'] end
+
+    local token = html_select(resp['text'], 'input[name="authenticity_token"]')
+    local auth_token = token['attrs']['value']
+
+        local headers = {}
+        headers['X-CSRF-Token'] = authenticity_token
+
+    local services = get_services(resp['text'])
+    debug({
+        auth_token=auth_token,
+        services=services,
+    })
+
+    -- trigger the scan
+    local req = http_request(session, 'POST', 'https://namechk.com/', {
+            headers=headers,
+        form={
+            authenticity_token=auth_token,
+            q=arg['username'],
+        }
+    })
+    local resp = http_send(req)
+    if last_err() then return end
+    if resp['status'] ~= 200 then return 'http error: ' .. resp['status'] end
+    debug(resp)
+
+    local scan = json_decode(resp['text'])
+    if last_err() then return end
+    local scan_token = scan['valid']
+
+    -- get results
+    local i = 1
+    while i <= #services do
+        debug(services[i])
+
+        local req = http_request(session, 'POST', 'https://namechk.com/services/check', {
+            headers=headers,
+            form={
+                token=scan_token,
+                fat=auth_token,
+                service=services[i],
+            }
+        })
+        local resp = http_send(req)
+        if last_err() then return end
+
+        if resp['status'] == 200 then
+            local acc = json_decode(resp['text'])
+            if last_err() then return end
+            debug(acc)
+
+            if acc ~= nil and not acc['available'] and acc['status'] == 'unavailable' then
+                db_add('account', {
+                    service=services[i],
+                    username=arg['username'],
+                    url=acc['callback_url'],
+                })
+            end
+        end
+
+        i = i+1
+    end
+end
diff --git a/sn0int-registry/sn0int-common/src/metadata.rs b/sn0int-registry/sn0int-common/src/metadata.rs
index bf0be37c..be4fec36 100644
--- a/sn0int-registry/sn0int-common/src/metadata.rs
+++ b/sn0int-registry/sn0int-common/src/metadata.rs
@@ -35,6 +35,9 @@ pub enum Source {
     Urls,
     Emails,
     PhoneNumbers,
+    Networks,
+    Devices,
+    Accounts(Option<String>),
     KeyRing(String),
 }
 
@@ -56,6 +59,9 @@ impl FromStr for Source {
             ("urls", None) => Ok(Source::Urls),
             ("emails", None) => Ok(Source::Emails),
             ("phonenumbers", None) => Ok(Source::PhoneNumbers),
+            ("networks", None) => Ok(Source::Networks),
+            ("devices", None) => Ok(Source::Devices),
+            ("accounts", param) => Ok(Source::Accounts(param.map(String::from))),
             ("keyring", Some(param)) => Ok(Source::KeyRing(param.to_string())),
             (x, Some(param)) => bail!("Unknown Source: {:?} ({:?})", x, param),
             (x, None) => bail!("Unknown Source: {:?}", x),
@@ -244,4 +250,13 @@ mod tests {
         let x = Source::from_str("keyring");
         assert!(x.is_err());
     }
+
+    #[test]
+    fn verify_account_source() {
+        let x = Source::from_str("accounts").unwrap();
+        assert_eq!(x, Source::Accounts(None));
+
+        let x = Source::from_str("accounts:github.com").unwrap();
+        assert_eq!(x, Source::Accounts(Some("github.com".into())));
+    }
 }
diff --git a/src/api.rs b/src/api.rs
index d3c11da2..1f713f4c 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -46,6 +46,7 @@ impl Client {
             info!("Adding session token to request");
             request.header("Auth", session.as_str());
         }
+        request.header("User-Agent", web::default_user_agent());
 
         let request = request.body(body)?;
 
diff --git a/src/cmd/add_cmd.rs b/src/cmd/add_cmd.rs
index 8e1c21a6..314911b7 100644
--- a/src/cmd/add_cmd.rs
+++ b/src/cmd/add_cmd.rs
@@ -23,6 +23,8 @@ pub enum Args {
     Device(AddDevice),
     #[structopt(name="network")]
     Network(AddNetwork),
+    #[structopt(name="account")]
+    Account(AddAccount),
 }
 
 #[derive(Debug, StructOpt)]
@@ -59,6 +61,12 @@ pub struct AddNetwork {
     longitude: Option<f32>,
 }
 
+#[derive(Debug, StructOpt)]
+pub struct AddAccount {
+    service: Option<String>,
+    username: Option<String>,
+}
+
 pub fn run(rl: &mut Readline, args: &[String]) -> Result<()> {
     let args = Args::from_iter_safe(args)?;
     match args {
@@ -68,6 +76,7 @@ pub fn run(rl: &mut Readline, args: &[String]) -> Result<()> {
         Args::PhoneNumber(args) => add_phonenumber(rl, args),
         Args::Device(args) => add_device(rl, args),
         Args::Network(args) => add_network(rl, args),
+        Args::Account(args) => add_account(rl, args),
     }
 }
 
@@ -206,3 +215,33 @@ fn add_network(rl: &mut Readline, args: AddNetwork) -> Result<()> {
 
     Ok(())
 }
+
+fn add_account(rl: &mut Readline, args: AddAccount) -> Result<()> {
+    let (service, username) = match (args.service, args.username) {
+        (Some(service), Some(username)) => (service, username),
+        _ => {
+            let service = utils::question("Service")?;
+            let username = utils::question("Username")?;
+
+            (service, username)
+        },
+    };
+
+    if service.contains('/') {
+        // TODO: avoid duplication
+        bail!("Service field can't contain `/`");
+    }
+
+    let value = format!("{}/{}", service, username);
+    rl.db().insert_struct(NewAccount {
+        value: &value,
+        service: &service,
+        username: &username,
+        displayname: None,
+        email: None,
+        url: None,
+        last_seen: None,
+    })?;
+
+    Ok(())
+}
diff --git a/src/cmd/run_cmd.rs b/src/cmd/run_cmd.rs
index 6055e9c0..4633cfb7 100644
--- a/src/cmd/run_cmd.rs
+++ b/src/cmd/run_cmd.rs
@@ -73,8 +73,8 @@ fn prepare_arg<T: Serialize + Model>(x: T) -> Result<(serde_json::Value, Option<
     Ok((arg, Some(pretty)))
 }
 
-fn prepare_args<T: Scopable + Serialize + Model>(db: &Database, filter: &Filter) -> Result<Vec<(serde_json::Value, Option<String>)>> {
-    db.filter::<T>(filter)?
+fn prepare_args<T: Scopable + Serialize + Model>(db: &Database, filter: &Filter, param: Option<&String>) -> Result<Vec<(serde_json::Value, Option<String>)>> {
+    db.filter_with_param::<T>(filter, param)?
         .into_iter()
         .map(prepare_arg)
         .collect()
@@ -113,12 +113,15 @@ pub fn execute(rl: &mut Readline, params: Params, options: HashMap<String, Strin
     let filter = rl.scoped_targets();
 
     let args = match module.source() {
-        Some(Source::Domains) => prepare_args::<Domain>(rl.db(), &filter),
-        Some(Source::Subdomains) => prepare_args::<Subdomain>(rl.db(), &filter),
-        Some(Source::IpAddrs) => prepare_args::<IpAddr>(rl.db(), &filter),
-        Some(Source::Urls) => prepare_args::<Url>(rl.db(), &filter),
-        Some(Source::Emails) => prepare_args::<Email>(rl.db(), &filter),
-        Some(Source::PhoneNumbers) => prepare_args::<PhoneNumber>(rl.db(), &filter),
+        Some(Source::Domains) => prepare_args::<Domain>(rl.db(), &filter, None),
+        Some(Source::Subdomains) => prepare_args::<Subdomain>(rl.db(), &filter, None),
+        Some(Source::IpAddrs) => prepare_args::<IpAddr>(rl.db(), &filter, None),
+        Some(Source::Urls) => prepare_args::<Url>(rl.db(), &filter, None),
+        Some(Source::Emails) => prepare_args::<Email>(rl.db(), &filter, None),
+        Some(Source::PhoneNumbers) => prepare_args::<PhoneNumber>(rl.db(), &filter, None),
+        Some(Source::Networks) => prepare_args::<Network>(rl.db(), &filter, None),
+        Some(Source::Devices) => prepare_args::<Device>(rl.db(), &filter, None),
+        Some(Source::Accounts(service)) => prepare_args::<Account>(rl.db(), &filter, service.as_ref()),
         Some(Source::KeyRing(namespace)) => {
             let keyring = rl.keyring();
             if keyring.is_access_granted(&module, &namespace) {
diff --git a/src/cmd/select_cmd.rs b/src/cmd/select_cmd.rs
index 4eabd95a..5ea7f57d 100644
--- a/src/cmd/select_cmd.rs
+++ b/src/cmd/select_cmd.rs
@@ -48,6 +48,9 @@ pub enum Target {
     #[structopt(name="networks")]
     /// Select networks
     Networks(Filter),
+    #[structopt(name="accounts")]
+    /// Select accounts
+    Accounts(Filter),
 }
 
 #[derive(Debug, StructOpt)]
@@ -101,6 +104,7 @@ impl Cmd for Args {
             Target::PhoneNumbers(filter) => printer.select::<PhoneNumber>(&filter),
             Target::Devices(filter) => printer.select::<Device>(&filter),
             Target::Networks(filter) => printer.select::<Network>(&filter),
+            Target::Accounts(filter) => printer.select::<Account>(&filter),
         }
     }
 }
diff --git a/src/cmd/target_cmd.rs b/src/cmd/target_cmd.rs
index bc8d67cd..ed95c38e 100644
--- a/src/cmd/target_cmd.rs
+++ b/src/cmd/target_cmd.rs
@@ -31,12 +31,15 @@ pub fn run(rl: &mut Readline, args: &[String]) -> Result<()> {
 
     if args.filter.is_empty() {
         match source {
-            Source::Domains => select::<Domain>(rl)?,
-            Source::Subdomains => select::<Subdomain>(rl)?,
-            Source::IpAddrs => select::<IpAddr>(rl)?,
-            Source::Urls => select::<Url>(rl)?,
-            Source::Emails => select::<Email>(rl)?,
-            Source::PhoneNumbers => select::<PhoneNumber>(rl)?,
+            Source::Domains => select::<Domain>(rl, None)?,
+            Source::Subdomains => select::<Subdomain>(rl, None)?,
+            Source::IpAddrs => select::<IpAddr>(rl, None)?,
+            Source::Urls => select::<Url>(rl, None)?,
+            Source::Emails => select::<Email>(rl, None)?,
+            Source::PhoneNumbers => select::<PhoneNumber>(rl, None)?,
+            Source::Networks => select::<Network>(rl, None)?,
+            Source::Devices => select::<Device>(rl, None)?,
+            Source::Accounts(service) => select::<Account>(rl, service.as_ref())?,
             Source::KeyRing(namespace) => {
                 for key in rl.keyring().list_for(&namespace) {
                     println!("{}:{}", key.namespace, key.name);
@@ -64,15 +67,18 @@ fn count_selected(rl: &mut Readline, source: &Source) -> Result<usize> {
         Source::Urls => db.filter::<Url>(&filter)?.len(),
         Source::Emails => db.filter::<Email>(&filter)?.len(),
         Source::PhoneNumbers => db.filter::<PhoneNumber>(&filter)?.len(),
+        Source::Networks => db.filter::<Network>(&filter)?.len(),
+        Source::Devices => db.filter::<Device>(&filter)?.len(),
+        Source::Accounts(service) => db.filter_with_param::<Account>(&filter, service.as_ref())?.len(),
         Source::KeyRing(namespace) => rl.keyring().list_for(&namespace).len(),
     };
     Ok(num)
 }
 
-fn select<T: Model + Detailed>(rl: &mut Readline) -> Result<()> {
+fn select<T: Model + Detailed>(rl: &mut Readline, param: Option<&String>) -> Result<()> {
     let filter = rl.scoped_targets();
 
-    for obj in rl.db().filter::<T>(&filter)? {
+    for obj in rl.db().filter_with_param::<T>(&filter, param)? {
         println!("{}", obj.detailed(rl.db())?);
     }
 
diff --git a/src/complete.rs b/src/complete.rs
index 145336df..48ed70cd 100644
--- a/src/complete.rs
+++ b/src/complete.rs
@@ -33,7 +33,8 @@ impl CmdCompleter {
                             "emails",
                             "phonenumbers",
                             "devices",
-                            "networks"];
+                            "networks",
+                            "accounts"];
 
             let results: Vec<String> = options.iter()
                 .filter(|x| x.starts_with(arg))
@@ -93,7 +94,8 @@ impl Completer for CmdCompleter {
                                         "email",
                                         "phonenumber",
                                         "device",
-                                        "network"];
+                                        "network",
+                                        "account"];
 
                         let results: Vec<String> = options.iter()
                             .filter(|x| x.starts_with(arg))
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 80bbb19f..92e25eaf 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -44,6 +44,7 @@ pub enum Family {
     Device,
     Network,
     NetworkDevice,
+    Account,
 }
 
 impl FromStr for Family {
@@ -61,6 +62,7 @@ impl FromStr for Family {
             "device" => Family::Device,
             "network" => Family::Network,
             "network-device" => Family::NetworkDevice,
+            "account" => Family::Account,
             _ => bail!("Unknown object family"),
         })
     }
@@ -183,6 +185,15 @@ impl Database {
                 ipaddr: object.ipaddr.as_ref(),
                 last_seen: object.last_seen,
             }),
+            Insert::Account(object) => self.insert_struct(NewAccount {
+                value: &object.value,
+                service: &object.service,
+                username: &object.username,
+                displayname: object.displayname.as_ref(),
+                email: object.email.as_ref(),
+                url: object.url.as_ref(),
+                last_seen: object.last_seen,
+            }),
         }
     }
 
@@ -244,6 +255,7 @@ impl Database {
             Update::Device(object) => self.update_device(object),
             Update::Network(object) => self.update_network(object),
             Update::NetworkDevice(object) => self.update_network_device(object),
+            Update::Account(object) => self.update_account(object),
         }
     }
 
@@ -311,6 +323,14 @@ impl Database {
         Ok(network_device.id)
     }
 
+    pub fn update_account(&self, account: &AccountUpdate) -> Result<i32> {
+        use crate::schema::accounts::columns::*;
+        diesel::update(accounts::table.filter(id.eq(account.id)))
+            .set(account)
+            .execute(&self.db)?;
+        Ok(account.id)
+    }
+
     fn get_opt_typed<T: Model + Scopable>(&self, value: &T::ID) -> Result<Option<i32>> {
         match T::get_opt(self, &value)? {
             Some(ref obj) if obj.scoped() => Ok(Some(obj.id())),
@@ -330,6 +350,7 @@ impl Database {
             Family::Device => self.get_opt_typed::<Device>(&value),
             Family::Network => self.get_opt_typed::<Network>(&value),
             Family::NetworkDevice => bail!("Unsupported operation"),
+            Family::Account => self.get_opt_typed::<Account>(&value),
         }
     }
 
@@ -343,6 +364,13 @@ impl Database {
         T::filter(self, filter)
     }
 
+    pub fn filter_with_param<T: Model>(&self, filter: &Filter, param: Option<&String>) -> Result<Vec<T>> {
+        match param {
+            Some(param) => T::filter_with_param(self, filter, param),
+            _ => T::filter(self, filter),
+        }
+    }
+
     pub fn scope<T: Scopable>(&self, filter: &Filter) -> Result<usize> {
         T::scope(self, filter)
     }
diff --git a/src/engine/ctx.rs b/src/engine/ctx.rs
index d0086d96..dbef7075 100644
--- a/src/engine/ctx.rs
+++ b/src/engine/ctx.rs
@@ -312,7 +312,9 @@ fn ctx<'a>(env: Environment, logger: Arc<Mutex<Box<Reporter>>>) -> (hlua::Lua<'a
     runtime::psl_domain_from_dns_name(&mut lua, state.clone());
     runtime::regex_find(&mut lua, state.clone());
     runtime::regex_find_all(&mut lua, state.clone());
+    runtime::semver_match(&mut lua, state.clone());
     runtime::sleep(&mut lua, state.clone());
+    runtime::sn0int_version(&mut lua, state.clone());
     runtime::sock_connect(&mut lua, state.clone());
     runtime::sock_send(&mut lua, state.clone());
     runtime::sock_recv(&mut lua, state.clone());
diff --git a/src/models/account.rs b/src/models/account.rs
new file mode 100644
index 00000000..223c2c7e
--- /dev/null
+++ b/src/models/account.rs
@@ -0,0 +1,338 @@
+use crate::errors::*;
+use crate::fmt::colors::*;
+use diesel;
+use diesel::prelude::*;
+use crate::models::*;
+use chrono::NaiveDateTime;
+
+
+#[derive(Identifiable, Queryable, Serialize, Deserialize, PartialEq, Debug)]
+#[table_name="accounts"]
+pub struct Account {
+    pub id: i32,
+    pub value: String,
+    pub service: String,
+    pub username: String,
+    pub displayname: Option<String>,
+    pub email: Option<String>,
+    pub url: Option<String>,
+    pub last_seen: Option<NaiveDateTime>,
+    pub unscoped: bool,
+}
+
+impl Model for Account {
+    type ID = str;
+
+    fn to_string(&self) -> String {
+        self.value.to_owned()
+    }
+
+    fn list(db: &Database) -> Result<Vec<Self>> {
+        use crate::schema::accounts::dsl::*;
+
+        let results = accounts.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn filter(db: &Database, filter: &Filter) -> Result<Vec<Self>> {
+        use crate::schema::accounts::dsl::*;
+
+        let query = accounts.filter(filter.sql());
+        let results = query.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn filter_with_param(db: &Database, filter: &Filter, param: &str) -> Result<Vec<Self>> {
+        use crate::schema::accounts::dsl::*;
+
+        let query = accounts
+            .filter(service.eq(param))
+            .filter(filter.sql());
+        let results = query.load::<Self>(db.db())?;
+
+        Ok(results)
+    }
+
+    fn delete(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::accounts::dsl::*;
+
+        diesel::delete(accounts.filter(filter.sql()))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn delete_id(db: &Database, my_id: i32) -> Result<usize> {
+        use crate::schema::accounts::dsl::*;
+
+        diesel::delete(accounts.filter(id.eq(my_id)))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn id(&self) -> i32 {
+        self.id
+    }
+
+    fn value(&self) -> &Self::ID {
+        &self.value
+    }
+
+    fn by_id(db: &Database, my_id: i32) -> Result<Self> {
+        use crate::schema::accounts::dsl::*;
+
+        let domain = accounts.filter(id.eq(my_id))
+            .first::<Self>(db.db())?;
+
+        Ok(domain)
+    }
+
+    fn get(db: &Database, query: &Self::ID) -> Result<Self> {
+        use crate::schema::accounts::dsl::*;
+
+        let domain = accounts.filter(value.eq(query))
+            .first::<Self>(db.db())?;
+
+        Ok(domain)
+    }
+
+    fn get_opt(db: &Database, query: &Self::ID) -> Result<Option<Self>> {
+        use crate::schema::accounts::dsl::*;
+
+        let domain = accounts.filter(value.eq(query))
+            .first::<Self>(db.db())
+            .optional()?;
+
+        Ok(domain)
+    }
+}
+
+impl Scopable for Account {
+    fn scoped(&self) -> bool {
+        !self.unscoped
+    }
+
+    fn scope(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::accounts::dsl::*;
+
+        diesel::update(accounts.filter(filter.sql()))
+            .set(unscoped.eq(false))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+
+    fn noscope(db: &Database, filter: &Filter) -> Result<usize> {
+        use crate::schema::accounts::dsl::*;
+
+        diesel::update(accounts.filter(filter.sql()))
+            .set(unscoped.eq(true))
+            .execute(db.db())
+            .map_err(Error::from)
+    }
+}
+
+pub struct PrintableAccount {
+    value: String,
+}
+
+impl fmt::Display for PrintableAccount {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        write!(w, "{:?}", self.value)
+    }
+}
+
+impl Printable<PrintableAccount> for Account {
+    fn printable(&self, _db: &Database) -> Result<PrintableAccount> {
+        Ok(PrintableAccount {
+            value: self.value.to_string(),
+        })
+    }
+}
+
+pub struct DetailedAccount {
+    id: i32,
+    value: String,
+    displayname: Option<String>,
+    email: Option<String>,
+    url: Option<String>,
+    last_seen: Option<NaiveDateTime>,
+    unscoped: bool,
+}
+
+impl DisplayableDetailed for DetailedAccount {
+    #[inline]
+    fn scoped(&self) -> bool {
+        !self.unscoped
+    }
+
+    #[inline]
+    fn print(&self, w: &mut fmt::DetailFormatter) -> fmt::Result {
+        w.id(self.id)?;
+        w.debug::<Green, _>(&self.value)?;
+
+        w.start_group();
+        w.opt_debug::<Yellow, _>(&self.displayname)?;
+        w.opt_debug::<Yellow, _>(&self.email)?;
+        w.opt_debug::<Yellow, _>(&self.url)?;
+        w.opt_debug::<Yellow, _>(&self.last_seen)?;
+        w.end_group()?;
+
+        Ok(())
+    }
+
+    #[inline]
+    fn children(&self, _w: &mut fmt::DetailFormatter) -> fmt::Result {
+        Ok(())
+    }
+}
+
+display_detailed!(DetailedAccount);
+
+impl Detailed for Account {
+    type T = DetailedAccount;
+
+    fn detailed(&self, _db: &Database) -> Result<Self::T> {
+        Ok(DetailedAccount {
+            id: self.id,
+            value: self.value.to_string(),
+            displayname: self.displayname.clone(),
+            email: self.email.clone(),
+            url: self.url.clone(),
+            last_seen: self.last_seen.clone(),
+            unscoped: self.unscoped,
+        })
+    }
+}
+
+#[derive(Insertable)]
+#[table_name="accounts"]
+pub struct NewAccount<'a> {
+    pub value: &'a str,
+    pub service: &'a str,
+    pub username: &'a str,
+    pub displayname: Option<&'a String>,
+    pub email: Option<&'a String>,
+    pub url: Option<&'a String>,
+    pub last_seen: Option<NaiveDateTime>,
+}
+
+impl<'a> InsertableStruct<Account> for NewAccount<'a> {
+    fn value(&self) -> &str {
+        self.value
+    }
+
+    fn insert(&self, db: &Database) -> Result<()> {
+        diesel::insert_into(accounts::table)
+            .values(self)
+            .execute(db.db())?;
+        Ok(())
+    }
+}
+
+impl<'a> Upsertable<Account> for NewAccount<'a> {
+    type Update = AccountUpdate;
+
+    fn upsert(self, existing: &Account) -> Self::Update {
+        Self::Update {
+            id: existing.id,
+            displayname: Self::upsert_str(self.displayname, &existing.displayname),
+            email: Self::upsert_str(self.email, &existing.email),
+            url: Self::upsert_str(self.url, &existing.url),
+            last_seen: Self::upsert_opt(self.last_seen, &existing.last_seen),
+        }
+    }
+}
+
+#[derive(Debug, Insertable, Serialize, Deserialize)]
+#[table_name="accounts"]
+pub struct NewAccountOwned {
+    pub value: String,
+    pub service: String,
+    pub username: String,
+    pub displayname: Option<String>,
+    pub email: Option<String>,
+    pub url: Option<String>,
+    pub last_seen: Option<NaiveDateTime>,
+}
+
+impl Printable<PrintableAccount> for NewAccountOwned {
+    fn printable(&self, _db: &Database) -> Result<PrintableAccount> {
+        Ok(PrintableAccount {
+            value: self.value.to_string(),
+        })
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct InsertAccount {
+    pub service: String,
+    pub username: String,
+    pub displayname: Option<String>,
+    pub email: Option<String>,
+    pub url: Option<String>,
+    pub last_seen: Option<NaiveDateTime>,
+}
+
+impl LuaInsertToNewOwned for InsertAccount {
+    type Target = NewAccountOwned;
+
+    fn try_into_new(self) -> Result<NewAccountOwned> {
+        if self.service.contains('/') {
+            bail!("Service field can't contain `/`");
+        }
+        let value = format!("{}/{}", self.service, self.username);
+        Ok(NewAccountOwned {
+            value,
+            service: self.service,
+            username: self.username,
+            displayname: self.displayname,
+            email: self.email,
+            url: self.url,
+            last_seen: self.last_seen,
+        })
+    }
+}
+
+#[derive(Identifiable, AsChangeset, Serialize, Deserialize, Debug)]
+#[table_name="accounts"]
+pub struct AccountUpdate {
+    pub id: i32,
+    pub displayname: Option<String>,
+    pub email: Option<String>,
+    pub url: Option<String>,
+    pub last_seen: Option<NaiveDateTime>,
+}
+
+impl Upsert for AccountUpdate {
+    fn is_dirty(&self) -> bool {
+        self.displayname.is_some() ||
+        self.email.is_some() ||
+        self.url.is_some() ||
+        self.last_seen.is_some()
+    }
+
+    fn generic(self) -> Update {
+        Update::Account(self)
+    }
+
+    fn apply(&self, db: &Database) -> Result<i32> {
+        db.update_account(&self)
+    }
+}
+
+impl Updateable<Account> for AccountUpdate {
+    fn changeset(&mut self, existing: &Account) {
+        Self::clear_if_equal(&mut self.displayname, &existing.displayname);
+        Self::clear_if_equal(&mut self.email, &existing.email);
+        Self::clear_if_equal(&mut self.url, &existing.url);
+        Self::clear_if_equal(&mut self.last_seen, &existing.last_seen);
+    }
+
+    fn fmt(&self, updates: &mut Vec<String>) {
+        Self::push_value(updates, "displayname", &self.displayname);
+        Self::push_value(updates, "email", &self.email);
+        Self::push_value(updates, "url", &self.url);
+        Self::push_value(updates, "last_seen", &self.last_seen);
+    }
+}
diff --git a/src/models/mod.rs b/src/models/mod.rs
index ea326b9d..0921752e 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -16,6 +16,7 @@ pub enum Insert {
     Device(NewDeviceOwned),
     Network(NewNetworkOwned),
     NetworkDevice(NewNetworkDeviceOwned),
+    Account(NewAccountOwned),
 }
 
 impl Insert {
@@ -31,6 +32,7 @@ impl Insert {
             Insert::Device(x) => &x.value,
             Insert::Network(x) => &x.value,
             Insert::NetworkDevice(_x) => unimplemented!("NetworkDevice doesn't have value field"),
+            Insert::Account(x) => &x.value,
         }
     }
 
@@ -46,6 +48,7 @@ impl Insert {
             Insert::Device(_) => "devices",
             Insert::Network(_) => "networks",
             Insert::NetworkDevice(_) => "network_devices",
+            Insert::Account(_) => "accounts",
         }
     }
 
@@ -61,6 +64,7 @@ impl Insert {
             Insert::Device(x) => format!("Device: {}", x.printable(db)?),
             Insert::Network(x) => format!("Network: {}", x.printable(db)?),
             Insert::NetworkDevice(x) => x.printable(db)?.to_string(),
+            Insert::Account(x) => format!("Account: {}", x.printable(db)?),
         })
     }
 }
@@ -75,6 +79,7 @@ pub enum Update {
     Device(DeviceUpdate),
     Network(NetworkUpdate),
     NetworkDevice(NetworkDeviceUpdate),
+    Account(AccountUpdate),
 }
 
 impl Update {
@@ -88,6 +93,7 @@ impl Update {
             Update::Device(update)        => update.is_dirty(),
             Update::Network(update)       => update.is_dirty(),
             Update::NetworkDevice(update) => update.is_dirty(),
+            Update::Account(update)       => update.is_dirty(),
         }
     }
 }
@@ -103,6 +109,7 @@ impl fmt::Display for Update {
             Update::Device(update)        => write!(w, "{}", update.to_string()),
             Update::Network(update)       => write!(w, "{}", update.to_string()),
             Update::NetworkDevice(update) => write!(w, "{}", update.to_string()),
+            Update::Account(update)       => write!(w, "{}", update.to_string()),
         }
     }
 }
@@ -116,6 +123,10 @@ pub trait Model: Sized {
 
     fn filter(db: &Database, filter: &Filter) -> Result<Vec<Self>>;
 
+    fn filter_with_param(_db: &Database, _filter: &Filter, _param: &str) -> Result<Vec<Self>> {
+        unimplemented!("This model doesn't support filtering with an additional parameter")
+    }
+
     fn delete(db: &Database, filter: &Filter) -> Result<usize>;
 
     fn delete_id(db: &Database, my_id: i32) -> Result<usize>;
@@ -304,3 +315,6 @@ pub use self::device::*;
 
 mod network_device;
 pub use self::network_device::*;
+
+mod account;
+pub use self::account::*;
diff --git a/src/runtime/db.rs b/src/runtime/db.rs
index a4738fdb..5e39be56 100644
--- a/src/runtime/db.rs
+++ b/src/runtime/db.rs
@@ -50,6 +50,9 @@ fn into_insert(family: Family, object: LuaJsonValue) -> Result<Insert> {
         Family::NetworkDevice => {
             Insert::NetworkDevice(try_into_new::<InsertNetworkDevice>(object)?)
         },
+        Family::Account => {
+            Insert::Account(try_into_new::<InsertAccount>(object)?)
+        },
     };
     Ok(obj)
 }
@@ -138,6 +141,8 @@ pub fn db_update(lua: &mut hlua::Lua, state: Arc<State>) {
                 .map(|(id, v, u)| (id, v, Update::Network(u))),
             Family::NetworkDevice => gen_changeset::<NetworkDevice, NetworkDeviceUpdate>(object, update)
                 .map(|(id, v, u)| (id, v, Update::NetworkDevice(u))),
+            Family::Account => gen_changeset::<Account, AccountUpdate>(object, update)
+                .map(|(id, v, u)| (id, v, Update::Account(u))),
         };
 
         let (id, value, update) = update
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
index a1b9fede..2e6f4cfe 100644
--- a/src/runtime/mod.rs
+++ b/src/runtime/mod.rs
@@ -20,6 +20,7 @@ import_fns!(options);
 import_fns!(pgp);
 import_fns!(psl);
 import_fns!(regex);
+import_fns!(semver);
 import_fns!(sleep);
 import_fns!(sock);
 import_fns!(stdio);
diff --git a/src/runtime/semver.rs b/src/runtime/semver.rs
new file mode 100644
index 00000000..cb61a63b
--- /dev/null
+++ b/src/runtime/semver.rs
@@ -0,0 +1,79 @@
+use crate::engine::ctx::State;
+use crate::errors::*;
+use crate::hlua;
+use semver::{Version, VersionReq};
+use std::sync::Arc;
+
+
+pub fn sn0int_version(lua: &mut hlua::Lua, _: Arc<State>) {
+    lua.set("sn0int_version", hlua::function0(move || -> String {
+        env!("CARGO_PKG_VERSION").to_string()
+    }))
+}
+
+pub fn semver_match(lua: &mut hlua::Lua, state: Arc<State>) {
+    lua.set("semver_match", hlua::function2(move |range: String, version: String| -> Result<bool> {
+        let r = VersionReq::parse(&range)
+            .map_err(|err| state.set_error(Error::from(err)))?;
+        let v = Version::parse(&version)
+            .map_err(|err| state.set_error(Error::from(err)))?;
+        Ok(r.matches(&v))
+    }))
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::engine::ctx::Script;
+
+    #[test]
+    fn verify_semver_larger() {
+        let script = Script::load_unchecked(r#"
+        function run()
+            v = sn0int_version()
+            if not semver_match(">= 0.5.0", v) then
+                return 'fail'
+            end
+        end
+        "#).expect("Failed to load script");
+        script.test().expect("Script failed");
+    }
+
+    #[test]
+    fn verify_semver_smaller() {
+        let script = Script::load_unchecked(r#"
+        function run()
+            v = sn0int_version()
+            if not semver_match("< 999.1.2", v) then
+                return 'fail'
+            end
+        end
+        "#).expect("Failed to load script");
+        script.test().expect("Script failed");
+    }
+
+    #[test]
+    fn verify_semver_in_range() {
+        let script = Script::load_unchecked(r#"
+        function run()
+            v = sn0int_version()
+            if not semver_match(">= 0.7.2, <= 99.9.9", v) then
+                return 'fail'
+            end
+        end
+        "#).expect("Failed to load script");
+        script.test().expect("Script failed");
+    }
+
+    #[test]
+    fn verify_semver_outer_range() {
+        let script = Script::load_unchecked(r#"
+        function run()
+            v = sn0int_version()
+            if semver_match(">= 0.4.2, <= 0.8.9", v) then
+                return 'fail'
+            end
+        end
+        "#).expect("Failed to load script");
+        script.test().expect("Script failed");
+    }
+}
diff --git a/src/schema.rs b/src/schema.rs
index d1d94d6c..d51b0940 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -1,3 +1,17 @@
+table! {
+    accounts (id) {
+        id -> Integer,
+        value -> Text,
+        service -> Text,
+        username -> Text,
+        displayname -> Nullable<Text>,
+        email -> Nullable<Text>,
+        url -> Nullable<Text>,
+        last_seen -> Nullable<Timestamp>,
+        unscoped -> Bool,
+    }
+}
+
 table! {
     devices (id) {
         id -> Integer,
@@ -135,6 +149,7 @@ joinable!(subdomains -> domains (domain_id));
 joinable!(urls -> subdomains (subdomain_id));
 
 allow_tables_to_appear_in_same_query!(
+    accounts,
     devices,
     domains,
     emails,
diff --git a/src/web.rs b/src/web.rs
index dd7f7336..a2faa97f 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -71,6 +71,10 @@ impl RequestOptions {
     }
 }
 
+pub fn default_user_agent() -> String {
+    format!("sn0int/{}", env!("CARGO_PKG_VERSION"))
+}
+
 #[derive(Debug, Default, Serialize, Deserialize)]
 pub struct HttpRequest {
     // reference to the HttpSession
@@ -90,7 +94,7 @@ impl HttpRequest {
     pub fn new(session: &HttpSession, method: String, url: String, options: RequestOptions) -> HttpRequest {
         let cookies = session.cookies.clone();
 
-        let user_agent = options.user_agent.or_else(|| Some(format!("sn0int/{}", env!("CARGO_PKG_VERSION")))); // TODO
+        let user_agent = options.user_agent.or_else(|| Some(default_user_agent()));
         let timeout = options.timeout.map(Duration::from_millis);
 
         let mut request = HttpRequest {


Test patch: