mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2025-12-06 17:22:49 +01:00
31
.github/workflows/pythontest.yml
vendored
Normal file
31
.github/workflows/pythontest.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: python-test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-code-path: ["salt/sensoroni/files/analyzers"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install flake8 pytest pytest-cov
|
||||
find . -name requirements.txt -exec pip install -r {} \;
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 ${{ matrix.python-code-path }} --show-source --max-complexity=12 --doctests --max-line-length=200 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest ${{ matrix.python-code-path }} --cov=${{ matrix.python-code-path }} --doctest-modules --cov-report=term --cov-fail-under=100 --cov-config=${{ matrix.python-code-path }}/pytest.ini
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -57,3 +57,14 @@ $RECYCLE.BIN/
|
||||
*.lnk
|
||||
|
||||
# End of https://www.gitignore.io/api/macos,windows
|
||||
|
||||
# Pytest output
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.coverage
|
||||
*.pyc
|
||||
.venv
|
||||
|
||||
# Analyzer dev/test config files
|
||||
*_dev.yaml
|
||||
site-packages
|
||||
14
README.md
14
README.md
@@ -1,14 +1,20 @@
|
||||
## Security Onion 2.3.120
|
||||
## Security Onion 2.3.130
|
||||
|
||||
Security Onion 2.3.120 is here!
|
||||
Security Onion 2.3.130 is here!
|
||||
|
||||
## Screenshots
|
||||
|
||||
Alerts
|
||||

|
||||

|
||||
|
||||
Dashboards
|
||||

|
||||
|
||||
Hunt
|
||||

|
||||

|
||||
|
||||
Cases
|
||||

|
||||
|
||||
### Release Notes
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
### 2.3.120-20220425 ISO image built on 2022/04/25
|
||||
### 2.3.130-20220607 ISO image built on 2022/06/07
|
||||
|
||||
|
||||
|
||||
### Download and Verify
|
||||
|
||||
2.3.120-20220425 ISO image:
|
||||
https://download.securityonion.net/file/securityonion/securityonion-2.3.120-20220425.iso
|
||||
2.3.130-20220607 ISO image:
|
||||
https://download.securityonion.net/file/securityonion/securityonion-2.3.130-20220607.iso
|
||||
|
||||
MD5: C99729E452B064C471BEF04532F28556
|
||||
SHA1: 60BF07D5347C24568C7B793BFA9792E98479CFBF
|
||||
SHA256: CD17D0D7CABE21D45FA45E1CF91C5F24EB9608C79FF88480134E5592AFDD696E
|
||||
MD5: 0034D6A9461C04357AFF512875408A4C
|
||||
SHA1: BF80EEB101C583153CAD8E185A7DB3173FD5FFE8
|
||||
SHA256: 15943623B96D8BB4A204A78668447F36B54A63ABA5F8467FBDF0B25C5E4E6078
|
||||
|
||||
Signature for ISO image:
|
||||
https://github.com/Security-Onion-Solutions/securityonion/raw/master/sigs/securityonion-2.3.120-20220425.iso.sig
|
||||
https://github.com/Security-Onion-Solutions/securityonion/raw/master/sigs/securityonion-2.3.130-20220607.iso.sig
|
||||
|
||||
Signing key:
|
||||
https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/master/KEYS
|
||||
@@ -26,22 +26,22 @@ wget https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/ma
|
||||
|
||||
Download the signature file for the ISO:
|
||||
```
|
||||
wget https://github.com/Security-Onion-Solutions/securityonion/raw/master/sigs/securityonion-2.3.120-20220425.iso.sig
|
||||
wget https://github.com/Security-Onion-Solutions/securityonion/raw/master/sigs/securityonion-2.3.130-20220607.iso.sig
|
||||
```
|
||||
|
||||
Download the ISO image:
|
||||
```
|
||||
wget https://download.securityonion.net/file/securityonion/securityonion-2.3.120-20220425.iso
|
||||
wget https://download.securityonion.net/file/securityonion/securityonion-2.3.130-20220607.iso
|
||||
```
|
||||
|
||||
Verify the downloaded ISO image using the signature file:
|
||||
```
|
||||
gpg --verify securityonion-2.3.120-20220425.iso.sig securityonion-2.3.120-20220425.iso
|
||||
gpg --verify securityonion-2.3.130-20220607.iso.sig securityonion-2.3.130-20220607.iso
|
||||
```
|
||||
|
||||
The output should show "Good signature" and the Primary key fingerprint should match what's shown below:
|
||||
```
|
||||
gpg: Signature made Mon 25 Apr 2022 08:20:40 AM EDT using RSA key ID FE507013
|
||||
gpg: Signature made Tue 07 Jun 2022 01:27:20 PM EDT using RSA key ID FE507013
|
||||
gpg: Good signature from "Security Onion Solutions, LLC <info@securityonionsolutions.com>"
|
||||
gpg: WARNING: This key is not certified with a trusted signature!
|
||||
gpg: There is no indication that the signature belongs to the owner.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 245 KiB |
BIN
assets/images/screenshots/alerts.png
Normal file
BIN
assets/images/screenshots/alerts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
assets/images/screenshots/cases-comments.png
Normal file
BIN
assets/images/screenshots/cases-comments.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
BIN
assets/images/screenshots/dashboards.png
Normal file
BIN
assets/images/screenshots/dashboards.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
BIN
assets/images/screenshots/hunt.png
Normal file
BIN
assets/images/screenshots/hunt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -2,7 +2,7 @@
|
||||
{% set cached_grains = salt.saltutil.runner('cache.grains', tgt='*') %}
|
||||
{% for minionid, ip in salt.saltutil.runner(
|
||||
'mine.get',
|
||||
tgt='G@role:so-manager or G@role:so-managersearch or G@role:so-standalone or G@role:so-node or G@role:so-heavynode or G@role:so-receiver or G@role:so-helix ',
|
||||
tgt='G@role:so-manager or G@role:so-managersearch or G@role:so-standalone or G@role:so-node or G@role:so-heavynode or G@role:so-receiver or G@role:so-helix',
|
||||
fun='network.ip_addrs',
|
||||
tgt_type='compound') | dictsort()
|
||||
%}
|
||||
|
||||
@@ -44,7 +44,7 @@ operation=$1
|
||||
email=$2
|
||||
role=$3
|
||||
|
||||
kratosUrl=${KRATOS_URL:-http://127.0.0.1:4434}
|
||||
kratosUrl=${KRATOS_URL:-http://127.0.0.1:4434/admin}
|
||||
databasePath=${KRATOS_DB_PATH:-/opt/so/conf/kratos/db/db.sqlite}
|
||||
databaseTimeout=${KRATOS_DB_TIMEOUT:-5000}
|
||||
bcryptRounds=${BCRYPT_ROUNDS:-12}
|
||||
@@ -408,7 +408,7 @@ function migrateLockedUsers() {
|
||||
# This is a migration function to convert locked users from prior to 2.3.90
|
||||
# to inactive users using the newer Kratos functionality. This should only
|
||||
# find locked users once.
|
||||
lockedEmails=$(curl -s http://localhost:4434/identities | jq -r '.[] | select(.traits.status == "locked") | .traits.email')
|
||||
lockedEmails=$(curl -s ${kratosUrl}/identities | jq -r '.[] | select(.traits.status == "locked") | .traits.email')
|
||||
if [[ -n "$lockedEmails" ]]; then
|
||||
echo "Disabling locked users..."
|
||||
for email in $lockedEmails; do
|
||||
|
||||
@@ -423,6 +423,7 @@ preupgrade_changes() {
|
||||
[[ "$INSTALLEDVERSION" == 2.3.90 || "$INSTALLEDVERSION" == 2.3.91 ]] && up_to_2.3.100
|
||||
[[ "$INSTALLEDVERSION" == 2.3.100 ]] && up_to_2.3.110
|
||||
[[ "$INSTALLEDVERISON" == 2.3.110 ]] && up_to_2.3.120
|
||||
[[ "$INSTALLEDVERISON" == 2.3.120 ]] && up_to_2.3.130
|
||||
true
|
||||
}
|
||||
|
||||
@@ -437,6 +438,8 @@ postupgrade_changes() {
|
||||
[[ "$POSTVERSION" == 2.3.90 || "$POSTVERSION" == 2.3.91 ]] && post_to_2.3.100
|
||||
[[ "$POSTVERSION" == 2.3.100 ]] && post_to_2.3.110
|
||||
[[ "$POSTVERSION" == 2.3.110 ]] && post_to_2.3.120
|
||||
[[ "$POSTVERSION" == 2.3.120 ]] && post_to_2.3.130
|
||||
|
||||
|
||||
true
|
||||
}
|
||||
@@ -507,6 +510,11 @@ post_to_2.3.120() {
|
||||
sed -i '/so-thehive-es/d;/so-thehive/d;/so-cortex/d' /opt/so/conf/so-status/so-status.conf
|
||||
}
|
||||
|
||||
post_to_2.3.130() {
|
||||
echo "Post Processing for 2.3.130"
|
||||
POSTVERSION=2.3.130
|
||||
}
|
||||
|
||||
|
||||
|
||||
stop_salt_master() {
|
||||
@@ -765,7 +773,12 @@ up_to_2.3.120() {
|
||||
so-thehive-stop
|
||||
so-thehive-es-stop
|
||||
so-cortex-stop
|
||||
}
|
||||
}
|
||||
|
||||
up_to_2.3.130() {
|
||||
# Remove file for nav update
|
||||
rm -f /opt/so/conf/navigator/layers/nav_layer_playbook.json
|
||||
}
|
||||
|
||||
verify_upgradespace() {
|
||||
CURRENTSPACE=$(df -BG / | grep -v Avail | awk '{print $4}' | sed 's/.$//')
|
||||
|
||||
@@ -267,6 +267,7 @@ filebeat.inputs:
|
||||
|
||||
{%- if RITAENABLED %}
|
||||
- type: filestream
|
||||
id: rita-beacon
|
||||
paths:
|
||||
- /nsm/rita/beacons.csv
|
||||
exclude_lines: ['^Score', '^Source', '^Domain', '^No results']
|
||||
@@ -282,6 +283,7 @@ filebeat.inputs:
|
||||
index: "so-rita"
|
||||
|
||||
- type: filestream
|
||||
id: rita-connection
|
||||
paths:
|
||||
- /nsm/rita/long-connections.csv
|
||||
- /nsm/rita/open-connections.csv
|
||||
@@ -298,6 +300,7 @@ filebeat.inputs:
|
||||
index: "so-rita"
|
||||
|
||||
- type: filestream
|
||||
id: rita-dns
|
||||
paths:
|
||||
- /nsm/rita/exploded-dns.csv
|
||||
exclude_lines: ['^Domain', '^No results']
|
||||
@@ -443,6 +446,13 @@ output.logstash:
|
||||
|
||||
# The Logstash hosts
|
||||
hosts:
|
||||
{# dont let filebeat send to a node designated as dmz #}
|
||||
{% import_yaml 'logstash/dmz_nodes.yaml' as dmz_nodes -%}
|
||||
{% if dmz_nodes.logstash.dmz_nodes -%}
|
||||
{% set dmz_nodes = dmz_nodes.logstash.dmz_nodes -%}
|
||||
{% else -%}
|
||||
{% set dmz_nodes = [] -%}
|
||||
{% endif -%}
|
||||
{%- if grains.role in ['so-sensor', 'so-fleet', 'so-node', 'so-idh'] %}
|
||||
{%- set LOGSTASH = namespace() %}
|
||||
{%- set LOGSTASH.count = 0 %}
|
||||
@@ -451,8 +461,10 @@ output.logstash:
|
||||
{%- for node_type, node_details in node_data.items() | sort -%}
|
||||
{%- if node_type in ['manager', 'managersearch', 'standalone', 'receiver' ] %}
|
||||
{%- for hostname in node_data[node_type].keys() %}
|
||||
{%- if hostname not in dmz_nodes %}
|
||||
{%- set LOGSTASH.count = LOGSTASH.count + 1 %}
|
||||
- "{{ hostname }}:5644" #{{ node_details[hostname].ip }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if LOGSTASH.count > 1 %}
|
||||
|
||||
@@ -59,7 +59,7 @@ update() {
|
||||
|
||||
IFS=$'\r\n' GLOBIGNORE='*' command eval 'LINES=($(cat $1))'
|
||||
for i in "${LINES[@]}"; do
|
||||
RESPONSE=$({{ ELASTICCURL }} -X PUT "localhost:5601/api/saved_objects/config/7.17.3" -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d " $i ")
|
||||
RESPONSE=$({{ ELASTICCURL }} -X PUT "localhost:5601/api/saved_objects/config/7.17.4" -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d " $i ")
|
||||
echo $RESPONSE; if [[ "$RESPONSE" != *"\"success\":true"* ]] && [[ "$RESPONSE" != *"updated_at"* ]] ; then RETURN_CODE=1;fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"attributes": {"buildNum": 39457,"defaultIndex": "2289a0c0-6970-11ea-a0cd-ffa0f6a1bc29","defaultRoute": "/app/dashboards#/view/a8411b30-6d03-11ea-b301-3d6c35840645","discover:sampleSize": 100,"theme:darkMode": true,"timepicker:timeDefaults": "{\n \"from\": \"now-24h\",\n \"to\": \"now\"\n}"},"coreMigrationVersion": "7.17.3","id": "7.17.3","migrationVersion": {"config": "7.13.0"},"references": [],"type": "config","updated_at": "2021-10-10T10:10:10.105Z","version": "WzI5NzUsMl0="}
|
||||
{"attributes": {"buildNum": 39457,"defaultIndex": "2289a0c0-6970-11ea-a0cd-ffa0f6a1bc29","defaultRoute": "/app/dashboards#/view/a8411b30-6d03-11ea-b301-3d6c35840645","discover:sampleSize": 100,"theme:darkMode": true,"timepicker:timeDefaults": "{\n \"from\": \"now-24h\",\n \"to\": \"now\"\n}"},"coreMigrationVersion": "7.17.4","id": "7.17.4","migrationVersion": {"config": "7.13.0"},"references": [],"type": "config","updated_at": "2021-10-10T10:10:10.105Z","version": "WzI5NzUsMl0="}
|
||||
|
||||
@@ -37,7 +37,7 @@ selfservice:
|
||||
ui_url: https://{{ WEBACCESS }}/login/
|
||||
|
||||
default_browser_return_url: https://{{ WEBACCESS }}/
|
||||
whitelisted_return_urls:
|
||||
allowed_return_urls:
|
||||
- http://127.0.0.1
|
||||
|
||||
log:
|
||||
@@ -59,7 +59,10 @@ hashers:
|
||||
cost: 12
|
||||
|
||||
identity:
|
||||
default_schema_url: file:///kratos-conf/schema.json
|
||||
default_schema_id: default
|
||||
schemas:
|
||||
- id: default
|
||||
url: file:///kratos-conf/schema.json
|
||||
|
||||
courier:
|
||||
smtp:
|
||||
|
||||
9
salt/logstash/dmz_nodes.yaml
Normal file
9
salt/logstash/dmz_nodes.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
# Do not edit this file. Copy it to /opt/so/saltstack/local/salt/logstash/ and make changes there. It should be formatted as a list.
|
||||
# logstash:
|
||||
# dmz_nodes:
|
||||
# - mydmznodehostname1
|
||||
# - mydmznodehostname2
|
||||
# - mydmznodehostname3
|
||||
|
||||
logstash:
|
||||
dmz_nodes:
|
||||
@@ -130,6 +130,8 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Proxy "";
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /usr/share/nginx/html/50x.html {
|
||||
@@ -331,6 +333,8 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Proxy "";
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
|
||||
{%- endif %}
|
||||
|
||||
@@ -1,27 +1,52 @@
|
||||
{
|
||||
"name": "Playbook",
|
||||
"version": "3.0",
|
||||
"domain": "mitre-enterprise",
|
||||
"description": "Current Coverage of Playbook",
|
||||
"name": "Playbook Coverage",
|
||||
"versions": {
|
||||
"attack": "11",
|
||||
"navigator": "4.6.4",
|
||||
"layer": "4.3"
|
||||
},
|
||||
"domain": "enterprise-attack",
|
||||
"description": "",
|
||||
"filters": {
|
||||
"stages": ["act"],
|
||||
"platforms": [
|
||||
"windows",
|
||||
"linux",
|
||||
"mac"
|
||||
"Linux",
|
||||
"macOS",
|
||||
"Windows",
|
||||
"Azure AD",
|
||||
"Office 365",
|
||||
"SaaS",
|
||||
"IaaS",
|
||||
"Google Workspace",
|
||||
"PRE",
|
||||
"Network",
|
||||
"Containers"
|
||||
]
|
||||
},
|
||||
"sorting": 0,
|
||||
"viewMode": 0,
|
||||
"layout": {
|
||||
"layout": "side",
|
||||
"aggregateFunction": "average",
|
||||
"showID": false,
|
||||
"showName": true,
|
||||
"showAggregateScores": false,
|
||||
"countUnscored": false
|
||||
},
|
||||
"hideDisabled": false,
|
||||
"techniques": [],
|
||||
"gradient": {
|
||||
"colors": ["#ff6666", "#ffe766", "#8ec843"],
|
||||
"colors": [
|
||||
"#ff6666ff",
|
||||
"#ffe766ff",
|
||||
"#8ec843ff"
|
||||
],
|
||||
"minValue": 0,
|
||||
"maxValue": 100
|
||||
},
|
||||
"legendItems": [],
|
||||
"metadata": [],
|
||||
"links": [],
|
||||
"showTacticRowBackground": false,
|
||||
"tacticRowBackground": "#dddddd",
|
||||
"selectTechniquesAcrossTactics": true
|
||||
"selectTechniquesAcrossTactics": true,
|
||||
"selectSubtechniquesWithParent": false
|
||||
}
|
||||
@@ -1,58 +1,62 @@
|
||||
{%- set URL_BASE = salt['pillar.get']('global:url_base', '') %}
|
||||
|
||||
{
|
||||
"enterprise_attack_url": "assets/enterprise-attack.json",
|
||||
"pre_attack_url": "assets/pre-attack.json",
|
||||
"mobile_data_url": "assets/mobile-attack.json",
|
||||
"taxii_server": {
|
||||
"enabled": false,
|
||||
"url": "https://cti-taxii.mitre.org/",
|
||||
"collections": {
|
||||
"enterprise_attack": "95ecc380-afe9-11e4-9b6c-751b66dd541e",
|
||||
"pre_attack": "062767bd-02d2-4b72-84ba-56caef0f8658",
|
||||
"mobile_attack": "2f669986-b40b-4423-b720-4396ca6a462b"
|
||||
"versions": [
|
||||
{
|
||||
"name": "ATT&CK v11",
|
||||
"version": "11",
|
||||
"domains": [
|
||||
{
|
||||
"name": "Enterprise",
|
||||
"identifier": "enterprise-attack",
|
||||
"data": ["assets/so/enterprise-attack.json"]
|
||||
}
|
||||
},
|
||||
|
||||
"domain": "mitre-enterprise",
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"custom_context_menu_items": [ {"label": "view related plays","url": " https://{{URL_BASE}}/playbook/projects/detection-playbooks/issues?utf8=%E2%9C%93&set_filter=1&sort=id%3Adesc&f%5B%5D=cf_15&op%5Bcf_15%5D=%3D&f%5B%5D=&c%5B%5D=status&c%5B%5D=cf_10&c%5B%5D=cf_13&c%5B%5D=cf_18&c%5B%5D=cf_19&c%5B%5D=cf_1&c%5B%5D=updated_on&v%5Bcf_15%5D%5B%5D=~Technique_ID~"}],
|
||||
|
||||
"default_layers": {
|
||||
"default_layers": {
|
||||
"enabled": true,
|
||||
"urls": [
|
||||
"assets/playbook.json"
|
||||
]
|
||||
"urls": ["assets/so/nav_layer_playbook.json"]
|
||||
},
|
||||
|
||||
"comment_color": "yellow",
|
||||
|
||||
"link_color": "blue",
|
||||
"banner": "",
|
||||
"features": [
|
||||
{"name": "leave_site_dialog", "enabled": true, "description": "Disable to remove the dialog prompt when leaving site."},
|
||||
{"name": "tabs", "enabled": true, "description": "Disable to remove the ability to open new tabs."},
|
||||
{"name": "selecting_techniques", "enabled": true, "description": "Disable to remove the ability to select techniques."},
|
||||
{"name": "header", "enabled": true, "description": "Disable to remove the header containing 'MITRE ATT&CK Navigator' and the link to the help page. The help page can still be accessed from the new tab menu."},
|
||||
{"name": "subtechniques", "enabled": true, "description": "Disable to remove all sub-technique features from the interface."},
|
||||
{"name": "selection_controls", "enabled": true, "description": "Disable to to disable all subfeatures", "subfeatures": [
|
||||
{"name": "search", "enabled": true, "description": "Disable to remove the technique search panel from the interface."},
|
||||
{"name": "multiselect", "enabled": true, "description": "Disable to remove the multiselect panel from interface."},
|
||||
{"name": "deselect_all", "enabled": true, "description": "Disable to remove the deselect all button from the interface."}
|
||||
]},
|
||||
{"name": "layer_controls", "enabled": true, "description": "Disable to to disable all subfeatures", "subfeatures": [
|
||||
{"name": "layer_info", "enabled": true, "description": "Disable to remove the layer info (name, description and metadata) panel from the interface. Note that the layer can still be renamed in the tab."},
|
||||
{"name": "layer_controls", "enabled": true, "description": "Disable to disable all subfeatures", "subfeatures": [
|
||||
{"name": "layer_info", "enabled": true, "description": "Disable to remove the layer info (name, description and layer metadata) panel from the interface. Note that the layer can still be renamed in the tab."},
|
||||
{"name": "download_layer", "enabled": true, "description": "Disable to remove the button to download the layer."},
|
||||
{"name": "export_render", "enabled": true, "description": "Disable to the remove the button to render the current layer."},
|
||||
{"name": "export_excel", "enabled": true, "description": "Disable to the remove the button to export the current layer to MS Excel (.xlsx) format."},
|
||||
{"name": "filters", "enabled": true, "description": "Disable to the remove the filters panel from interface."},
|
||||
{"name": "sorting", "enabled": true, "description": "Disable to the remove the sorting button from the interface."},
|
||||
{"name": "color_setup", "enabled": true, "description": "Disable to the remove the color setup panel from interface, containing customization controls for scoring gradient and tactic row color."},
|
||||
{"name": "toggle_hide_disabled", "enabled": true, "description": "Disable to the remove the hide disabled techniques button from the interface."},
|
||||
{"name": "toggle_view_mode", "enabled": true, "description": "Disable to the remove the toggle view mode button from interface."},
|
||||
{"name": "legend", "enabled": true, "description": "Disable to the remove the legend panel from the interface."}
|
||||
{"name": "export_render", "enabled": true, "description": "Disable to remove the button to render the current layer."},
|
||||
{"name": "export_excel", "enabled": true, "description": "Disable to remove the button to export the current layer to MS Excel (.xlsx) format."},
|
||||
{"name": "filters", "enabled": true, "description": "Disable to remove the filters panel from interface."},
|
||||
{"name": "sorting", "enabled": true, "description": "Disable to remove the sorting button from the interface."},
|
||||
{"name": "color_setup", "enabled": true, "description": "Disable to remove the color setup panel from interface, containing customization controls for scoring gradient and tactic row color."},
|
||||
{"name": "toggle_hide_disabled", "enabled": true, "description": "Disable to remove the hide disabled techniques button from the interface."},
|
||||
{"name": "layout_controls", "enabled": true, "description": "Disable to remove the ability to change the current matrix layout."},
|
||||
{"name": "legend", "enabled": true, "description": "Disable to remove the legend panel from the interface."}
|
||||
]},
|
||||
{"name": "technique_controls", "enabled": true, "description": "Disable to to disable all subfeatures", "subfeatures": [
|
||||
{"name": "disable_techniques", "enabled": true, "description": "Disable to the remove the ability to disable techniques."},
|
||||
{"name": "manual_color", "enabled": true, "description": "Disable to the remove the ability to assign manual colors to techniques."},
|
||||
{"name": "scoring", "enabled": true, "description": "Disable to the remove the ability to score techniques."},
|
||||
{"name": "comments", "enabled": true, "description": "Disable to the remove the ability to add comments to techniques."},
|
||||
{"name": "technique_controls", "enabled": true, "description": "Disable to disable all subfeatures", "subfeatures": [
|
||||
{"name": "disable_techniques", "enabled": true, "description": "Disable to remove the ability to disable techniques."},
|
||||
{"name": "manual_color", "enabled": true, "description": "Disable to remove the ability to assign manual colors to techniques."},
|
||||
{"name": "scoring", "enabled": true, "description": "Disable to remove the ability to score techniques."},
|
||||
{"name": "comments", "enabled": true, "description": "Disable to remove the ability to add comments to techniques."},
|
||||
{"name": "comment_underline", "enabled": true, "description": "Disable to remove the comment underline effect on techniques."},
|
||||
{"name": "links", "enabled": true, "description": "Disable to remove the ability to assign hyperlinks to techniques."},
|
||||
{"name": "link_underline", "enabled": true, "description": "Disable to remove the hyperlink underline effect on techniques."},
|
||||
{"name": "metadata", "enabled": true, "description": "Disable to remove the ability to add metadata to techniques."},
|
||||
{"name": "clear_annotations", "enabled": true, "description": "Disable to remove the button to clear all annotations on the selected techniques."}
|
||||
]}
|
||||
]
|
||||
|
||||
@@ -50,7 +50,7 @@ nginxtmp:
|
||||
|
||||
navigatorconfig:
|
||||
file.managed:
|
||||
- name: /opt/so/conf/navigator/navigator_config.json
|
||||
- name: /opt/so/conf/navigator/config.json
|
||||
- source: salt://nginx/files/navigator_config.json
|
||||
- user: 939
|
||||
- group: 939
|
||||
@@ -59,7 +59,7 @@ navigatorconfig:
|
||||
|
||||
navigatordefaultlayer:
|
||||
file.managed:
|
||||
- name: /opt/so/conf/navigator/nav_layer_playbook.json
|
||||
- name: /opt/so/conf/navigator/layers/nav_layer_playbook.json
|
||||
- source: salt://nginx/files/nav_layer_playbook.json
|
||||
- user: 939
|
||||
- group: 939
|
||||
@@ -69,7 +69,7 @@ navigatordefaultlayer:
|
||||
|
||||
navigatorpreattack:
|
||||
file.managed:
|
||||
- name: /opt/so/conf/navigator/pre-attack.json
|
||||
- name: /opt/so/conf/navigator/layers/pre-attack.json
|
||||
- source: salt://nginx/files/pre-attack.json
|
||||
- user: 939
|
||||
- group: 939
|
||||
@@ -78,7 +78,7 @@ navigatorpreattack:
|
||||
|
||||
navigatorenterpriseattack:
|
||||
file.managed:
|
||||
- name: /opt/so/conf/navigator/enterprise-attack.json
|
||||
- name: /opt/so/conf/navigator/layers/enterprise-attack.json
|
||||
- source: salt://nginx/files/enterprise-attack.json
|
||||
- user: 939
|
||||
- group: 939
|
||||
@@ -99,10 +99,8 @@ so-nginx:
|
||||
- /etc/pki/managerssl.crt:/etc/pki/nginx/server.crt:ro
|
||||
- /etc/pki/managerssl.key:/etc/pki/nginx/server.key:ro
|
||||
# ATT&CK Navigator binds
|
||||
- /opt/so/conf/navigator/navigator_config.json:/opt/socore/html/navigator/assets/config.json:ro
|
||||
- /opt/so/conf/navigator/nav_layer_playbook.json:/opt/socore/html/navigator/assets/playbook.json:ro
|
||||
- /opt/so/conf/navigator/enterprise-attack.json:/opt/socore/html/navigator/assets/enterprise-attack.json:ro
|
||||
- /opt/so/conf/navigator/pre-attack.json:/opt/socore/html/navigator/assets/pre-attack.json:ro
|
||||
- /opt/so/conf/navigator/layers/:/opt/socore/html/navigator/assets/so:ro
|
||||
- /opt/so/conf/navigator/config.json:/opt/socore/html/navigator/assets/config.json:ro
|
||||
{% endif %}
|
||||
{% if ISAIRGAP is sameas true %}
|
||||
- /nsm/repo:/opt/socore/html/repo:ro
|
||||
|
||||
@@ -42,6 +42,15 @@ query_updatwebhooks:
|
||||
- connection_user: root
|
||||
- connection_pass: {{ MYSQLPASS }}
|
||||
|
||||
query_updatename:
|
||||
mysql_query.run:
|
||||
- database: playbook
|
||||
- query: "update custom_fields set name = 'Custom Filter' where id = 21;"
|
||||
- connection_host: {{ MAINIP }}
|
||||
- connection_port: 3306
|
||||
- connection_user: root
|
||||
- connection_pass: {{ MYSQLPASS }}
|
||||
|
||||
query_updatepluginurls:
|
||||
mysql_query.run:
|
||||
- database: playbook
|
||||
|
||||
269
salt/sensoroni/files/analyzers/README.md
Normal file
269
salt/sensoroni/files/analyzers/README.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Security Onion Analyzers
|
||||
|
||||
Security Onion provides a means for performing data analysis on varying inputs. This data can be any data of interest sourced from event logs. Examples include hostnames, IP addresses, file hashes, URLs, etc. The analysis is conducted by one or more analyzers that understand that type of input. Analyzers come with the default installation of Security Onion. However, it is also possible to add additional analyzers to extend the analysis across additional areas or data types.
|
||||
|
||||
## Supported Observable Types
|
||||
The built-in analyzers support the following observable types:
|
||||
|
||||
| Name | Domain | Hash | IP | JA3 | Mail | Other | URI | URL | User Agent |
|
||||
| ------------------------|--------|-------|-------|-------|-------|-------|-------|-------|------------
|
||||
| Alienvault OTX |✓ |✓|✓|✗|✗|✗|✗|✓|✗|
|
||||
| EmailRep |✗ |✗|✗|✗|✓|✗|✗|✗|✗|
|
||||
| Greynoise |✗ |✗|✓|✗|✗|✗|✗|✗|✗|
|
||||
| JA3er |✗ |✗|✗|✓|✗|✗|✗|✗|✗|
|
||||
| LocalFile |✓ |✓|✓|✓|✗|✓|✗|✓|✗|
|
||||
| Malware Hash Registry |✗ |✓|✗|✗|✗|✗|✗|✓|✗|
|
||||
| Pulsedive |✓ |✓|✓|✗|✗|✗|✓|✓|✓|
|
||||
| Spamhaus |✗ |✗|✓|✗|✗|✗|✗|✗|✗|
|
||||
| Urlhaus |✗ |✗|✗|✗|✗|✗|✗|✓|✗|
|
||||
| Urlscan |✗ |✗|✗|✗|✗|✗|✗|✓|✗|
|
||||
| Virustotal |✓ |✓|✓|✗|✗|✗|✗|✓|✗|
|
||||
| WhoisLookup |✓ |✗|✗|✗|✗|✗|✓|✗|✗|
|
||||
|
||||
## Authentication
|
||||
Many analyzers require authentication, via an API key or similar. The table below illustrates which analyzers require authentication.
|
||||
|
||||
| Name | Authn Req'd|
|
||||
--------------------------|------------|
|
||||
[AlienVault OTX](https://otx.alienvault.com/api) |✓|
|
||||
[EmailRep](https://emailrep.io/key) |✓|
|
||||
[GreyNoise](https://www.greynoise.io/plans/community) |✓|
|
||||
[JA3er](https://ja3er.com/) |✗|
|
||||
LocalFile |✗|
|
||||
[Malware Hash Registry](https://hash.cymru.com/docs_whois) |✗|
|
||||
[Pulsedive](https://pulsedive.com/api/) |✓|
|
||||
[Spamhaus](https://www.spamhaus.org/dbl/) |✗|
|
||||
[Urlhaus](https://urlhaus.abuse.ch/) |✗|
|
||||
[Urlscan](https://urlscan.io/docs/api/) |✓|
|
||||
[VirusTotal](https://developers.virustotal.com/reference/overview) |✓|
|
||||
[WhoisLookup](https://github.com/meeb/whoisit) |✗|
|
||||
|
||||
|
||||
## Developer Guide
|
||||
|
||||
### Python
|
||||
|
||||
Analyzers are Python modules, and can be made up of a single .py script, for simpler analyzers, or a complex set of scripts organized within nested directories.
|
||||
|
||||
The Python language was chosen because of it's wide adoption in the security industry, ease of development and testing, and the abundance of developers with Python skills.
|
||||
|
||||
Specifically, analyzers must be compatible with Python 3.10.
|
||||
|
||||
For more information about Python, see the [Python Documentation](https://docs.python.org).
|
||||
|
||||
### Development
|
||||
|
||||
Custom analyzers should be developed outside of the Security Onion cluster, in a proper software development environment, with version control or other backup mechanisms in place. The analyzer can be developed, unit tested, and integration tested without the need for a Security Onion installation. Once satisifed with the analyzer functionality the analyzer directory should be copied to the Security Onion manager node.
|
||||
|
||||
Developing an analyzer directly on a Security Onion manager node is strongly discouraged, as loss of source code (and time and effort) can occur, should the management node suffer a catastrophic failure with disk storage loss.
|
||||
|
||||
For best results, avoid long, complicated functions in favor of short, discrete functions. This has several benefits:
|
||||
|
||||
- Easier to troubleshoot
|
||||
- Easier to maintain
|
||||
- Easier to unit test
|
||||
- Easier for other developers to review
|
||||
|
||||
### Linting
|
||||
|
||||
Source code should adhere to the [PEP 8 - Style Guide for Python Code](https://peps.python.org/pep-0008/). Developers can use the default configuration of `flake8` to validate conformance, or run the included `build.sh` inside the analyzers directory. Note that linting conformance is mandatory for analyzers that are contributed back to the Security Onion project.
|
||||
|
||||
### Testing
|
||||
|
||||
Python's [unitest](https://docs.python.org/3/library/unittest.html) library can be used for covering analyzer code with unit tests. Unit tests are encouraged for custom analyzers, and mandatory for public analyzers submitted back to the Security Onion project.
|
||||
|
||||
If you are new to unit testing, please see the included `urlhaus_test.py` as an example.
|
||||
|
||||
Unit tests should be named following the pattern `<scriptname>_test.py`.
|
||||
|
||||
|
||||
### Analyzer Package Structure
|
||||
|
||||
Delpoyment of a custom analyzer entails copying the analyzer source directory and depenency wheel archives to the Security Onion manager node. The destination locations can be found inside the `securityonion` salt source directory tree. Using the [Saltstack](https://github.com/saltstack/salt) directory pattern allows Security Onion developers to add their own analyzers with minimal additional effort needed to upgrade to newer versions of Security Onion. When the _sensoroni_ salt state executes it will merge the default analyzers with any local analyzers, and copy the merged analyzers into the `/opt/so/conf/sensoroni` directory.
|
||||
|
||||
Do not modify files in the `/opt/so/conf/sensoroni` directory! This is a generated directory and changes made inside will be automatically erased on a frequent interval.
|
||||
|
||||
On a Security Onion manager, custom analyzers should be placed inside the `/opt/so/saltstack/local/salt/sensoroni` directory, as described in the next section.
|
||||
|
||||
#### Directory Tree
|
||||
|
||||
From within the default saltstack directory, the following files and directories exist:
|
||||
|
||||
```
|
||||
salt
|
||||
|- sensoroni
|
||||
|- files
|
||||
|- analyzers
|
||||
|- urlhaus <- Example of an existing analyzer
|
||||
| |- source-packages <- Contains wheel package bundles for this analyzer's dependencies
|
||||
| |- site-packages <- Auto-generated site-packages directory (or used for custom dependencies)
|
||||
| |- requirements.txt <- List of all dependencies needed for this analyzer
|
||||
| |- urlhaus.py <- Source code for the analyzer
|
||||
| |- urlhaus_test.py <- Unit tests for the analyzer source code
|
||||
| |- urlhaus.json <- Metadata for the analyzer
|
||||
| |- __init__.py <- Package initialization file, often empty
|
||||
|
|
||||
|- build.sh <- Simple CI tool for validating linting and unit tests
|
||||
|- helpers.py <- Common functions shared by many analyzers
|
||||
|- helpers_test.py <- Unit tests for the shared source code
|
||||
|- pytest.ini <- Configuration options for the flake8 and pytest
|
||||
|- README.md <- The file you are currently reading
|
||||
```
|
||||
|
||||
Custom analyzers should conform to this same structure, but instead of being placed in the `/opt/so/saltstack/default` directory tree, they should be placed in the `/opt/so/saltstack/local` directory tree. This ensures future Security Onion upgrades will not overwrite customizations. Shared files like `build.sh` and `helpers.py` do not need to be duplicated. They can remain in the _default_ directory tree. Only new or modified files should exist in the _local_ directory tree.
|
||||
|
||||
#### Metadata
|
||||
|
||||
Each analyzer has certain metadata that helps describe the function of the analyzer, required inputs, artifact compatibility, optional configuration options, analyzer version, and other important details of the analyzer. This file is a static file and is not intended to be used for dynamic or custom configuration options. It should only be modified by the author of the analyzer.
|
||||
|
||||
The following example describes the urlhaus metadata content:
|
||||
|
||||
```
|
||||
{
|
||||
"name": "Urlhaus", <- Unique human-friendly name of this analyzer
|
||||
"version": "0.1", <- The version of the analyzer
|
||||
"author": "Security Onion Solutions", <- Author's name, and/or email or other contact information
|
||||
"description": "This analyzer queries URLHaus...", <- A brief, concise description of the analyzer
|
||||
"supportedTypes" : ["url"], <- List of types that must match the SOC observable types
|
||||
"baseUrl": "https://urlhaus-api.abuse.ch/v1/url/" <- Optional hardcoded data used by the analyzer
|
||||
}
|
||||
```
|
||||
|
||||
The `supportedTypes` values should only contain the types that this analyzer can work with. In the case of the URLHaus analyzer, we know that it works with URLs. So adding "hash" to this list wouldn't make sense, since URLHaus doesn't provide information about file hashes. If an analyzer does not support a particular type then it will not show up in the analyzer results in SOC for that observable being analyzed. This is intentional, to eliminate unnecessary screen clutter in SOC. To find a list of available values for the `supportedTypes` field, login to SOC and inside of a Case, click the + button on the Observables tab. You will see a list of types and each of those can be used in this metadata field, when applicable to the analyzer.
|
||||
|
||||
#### Dependencies
|
||||
|
||||
Analyzers will often require the use of third-party packages. For example, if an analyzer needs to make a request to a remote server via HTTPS, then the `requests` package will likely be used. Each analyzer will container a `requirements.txt` file, in which all third-party dependencies can be specified, following the python [Requirements File Specification](https://pip.pypa.io/en/stable/reference/requirements-file-format/).
|
||||
|
||||
Additionally, to support airgapped users, the dependency packages themselves, and any transitive dependencies, should be placed inside the `source-packages` directory. To obtain the full hierarchy of dependencies, execute the following commands:
|
||||
|
||||
```bash
|
||||
pip download -r <my-analyzer-path>/requirements.txt -d <my-analyzer-path>/source-packages
|
||||
```
|
||||
|
||||
|
||||
### Analyzer Architecture
|
||||
|
||||
The Sensoroni Docker container is responsible for executing analyzers. Only the manager's Sensoroni container will process analyzer jobs. Other nodes in the grid, such as sensors and search nodes, will not be assigned analyzer jobs.
|
||||
|
||||
When the Sensoroni Docker container starts, the `/opt/so/conf/sensoroni/analyzer` directory is mapped into the container. The initialization of the Sensoroni Analyze module will scan that directory for any subdirectories. Valid subdirectories will be added as an available analyzer.
|
||||
|
||||
The analyzer itself will only run when a user in SOC enqueues an analyzer job, such as via the Cases -> Observables tab. When the Sensoroni node is ready to run the job it will execute the python command interpretor separately for each loaded analyzer. The command line resembles the following:
|
||||
|
||||
```bash
|
||||
python -m urlhaus '{"artifactType":"url","value":"https://bigbadbotnet.invalid",...}'
|
||||
```
|
||||
|
||||
It is up to each analyzer to determine whether the provided input is compatible with that analyzer. This is assisted by the analyzer metadata, as described earlier in this document, with the use of the `supportedTypes` list.
|
||||
|
||||
Once the analyzer completes its functionality, it must terminate promptly. See the following sections for more details on expected internal behavior of the analyzer.
|
||||
|
||||
#### Configuration
|
||||
|
||||
Analyzers may need dynamic configuration data, such as credentials or other secrets, in order to complete their function. Optional configuration files can provide this information, and are expected to reside in the analyzer's directory. Configuration files are typically written in YAML syntax for ease of modification.
|
||||
|
||||
Configuration files for analyzers included with Security Onion will be pillarized, meaning they derive their custom values from the Saltstack pillar data. For example, an analyzer that requires a user supplied credential might contain a config file resembling the following, where Jinja templating syntax is used to extra Salt pillar data:
|
||||
|
||||
```yaml
|
||||
username: {{ salt['pillar.get']('sensoroni:analyzers:myanalyzer:username', '') }}
|
||||
password: {{ salt['pillar.get']('sensoroni:analyzers:myanalyzer:password', '') }}
|
||||
```
|
||||
|
||||
Sensoroni will not provide any inputs to the analyzer during execution, other than the artifact input in JSON format. However, developers will likely need to test the analyzer outside of Sensoroni and without Jinja templating, therefore an alternate config file should normally be supplied as the configuration argument during testing. Analyzers should allow for this additional command line argument, but by default should automatically read a configuration file stored in the analyzer's directory.
|
||||
|
||||
#### Exit Code
|
||||
|
||||
If an analyzer determines it cannot or should not operate on the input then the analyzer should return an exit code of `126`.
|
||||
|
||||
If an analyzer does attempt to operate against the input then the exit code should be 0, regardless of the outcome. The outcome, be it an error, a confirmed threat detection, or perhaps an unknown outcome, should be noted in the output of the analyzer.
|
||||
|
||||
#### Output
|
||||
|
||||
The outcome of the analyzer is reflected in the analyzer's output to `stdout`. The output must be JSON formatted, and should contain the following fields.
|
||||
|
||||
`summary`: A very short summarization of the outcome. This should be under 50 characters, otherwise it will be truncated when displayed on the Analyzer job list.
|
||||
|
||||
`status`: Can be one of the following status values, which most appropriately reflects the outcome:
|
||||
- `ok`: The analyzer has concluded that the provided input is not a known threat.
|
||||
- `info`: This analyzer provides informative data, but does not attempt to conclude the input is a threat.
|
||||
- `caution`: The data provided is inconclusive. Analysts should review this information further. This can be used in error scenarios, such as if the analyzer fails to complete, perhaps due to a remote service being offline.
|
||||
- `threat`: The analyzer has detected that the input is likely related to a threat.
|
||||
|
||||
`error`: [Optional] If the analyzer encounters an unrecoverable error, those details, useful for administrators to troubleshoot the problem, should be placed in this field.
|
||||
|
||||
Additional fields are allowed, and should contain data that is specific to the analyzer.
|
||||
|
||||
Below is an example of a _urlhaus_ analyzer output. Note that the urlhaus raw JSON is added to a custom field called "response".
|
||||
|
||||
```json
|
||||
{
|
||||
"response": {
|
||||
"blacklists": {
|
||||
"spamhaus_dbl": "not listed",
|
||||
"surbl": "not listed"
|
||||
},
|
||||
"date_added": "2022-04-07 12:39:14 UTC",
|
||||
"host": "abeibaba.com",
|
||||
"id": "2135795",
|
||||
"larted": "false",
|
||||
"last_online": null,
|
||||
"payloads": null,
|
||||
"query_status": "ok",
|
||||
"reporter": "switchcert",
|
||||
"tags": [
|
||||
"Flubot"
|
||||
],
|
||||
"takedown_time_seconds": null,
|
||||
"threat": "malware_download",
|
||||
"url": "https://abeibaba.com/ian/?redacted",
|
||||
"url_status": "offline",
|
||||
"urlhaus_reference": "https://urlhaus.abuse.ch/url/2135795/"
|
||||
},
|
||||
"status": "threat",
|
||||
"summary": "malware_download"
|
||||
}
|
||||
```
|
||||
|
||||
Users in SOC will be able to view the entire JSON output, therefore it is important that sensitive information, such as credentials or other secrets, is excluded from the output.
|
||||
|
||||
#### Internationalization
|
||||
|
||||
Some of the built-in analyzers use snake_case summary values, instead of human friendly words or phrases. These are identifiers that the SOC UI will use to lookup a localized translation for the user. The use of these identifiers is not required for custom analyzers. In fact, in order for an identifier to be properly localized the translations must exist in the SOC product, which is out of scope of this development guide. That said, the following generic translations might be useful for custom analyzers:
|
||||
|
||||
| Identifier | English |
|
||||
| ------------------ | -------------------------- |
|
||||
| `malicious` | Malicious |
|
||||
| `suspicious` | Suspicious |
|
||||
| `harmless` | Harmless |
|
||||
| `internal_failure` | Analyzer Internal Failure |
|
||||
| `timeout` | Remote Host Timed Out |
|
||||
|
||||
#### Timeout
|
||||
|
||||
It is expected that analyzers will finish quickly, but there is a default timeout in place that will abort the analyzer if the timeout is exceeded. By default that timeout is 15 minutes (900000 milliseconds), but can be customized via the `sensoroni:analyze_timeout_ms` salt pillar.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Review the Security Onion project [contribution guidelines](https://github.com/Security-Onion-Solutions/securityonion/blob/master/CONTRIBUTING.md) if you are considering contributing an analyzer to the Security Onion project.
|
||||
|
||||
#### Procedure
|
||||
|
||||
In order to make a custom analyzer into a permanent Security Onion analyzer, the following steps need to be taken:
|
||||
|
||||
1. Fork the [securityonion GitHub repository](https://github.com/Security-Onion-Solutions/securityonion)
|
||||
2. Copy your custom analyzer directory to the forked project, under the `securityonion/salt/sensoroni/files/analyzers` directory.
|
||||
3. Ensure the contribution requirements in the following section are met.
|
||||
4. Submit a [pull request](https://github.com/Security-Onion-Solutions/securityonion/pulls) to merge your GitHub fork back into the `securityonion` _dev_ branch.
|
||||
|
||||
#### Requirements
|
||||
|
||||
The following requirements must be satisfied in order for analyzer pull requests to be accepted into the Security Onion GitHub project:
|
||||
|
||||
- Analyzer contributions must not contain licensed dependencies or source code that is incompatible with the [GPLv2 licensing](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html).
|
||||
- All source code must pass the `flake8` lint check. This ensures source code conforms to the same style guides as the other analyzers. The Security Onion project will automatically run the linter after each push to a `securityonion` repository fork, and again when submitting a pull request. Failed lint checks will result in the submitter being sent an automated email message.
|
||||
- All source code must include accompanying unit test coverage. The Security Onion project will automatically run the unit tests after each push to a `securityonion` repository fork, and again when submitting a pull request. Failed unit tests, or insufficient unit test coverage, will result in the submitter being sent an automated email message.
|
||||
- Documentation of the analyzer, its input requirements, conditions for operation, and other relevant information must be clearly written in an accompanying analyzer metadata file. This file is described in more detail earlier in this document.
|
||||
- Source code must be well-written and be free of security defects that can put users or their data at unnecessary risk.
|
||||
|
||||
|
||||
39
salt/sensoroni/files/analyzers/build.sh
Executable file
39
salt/sensoroni/files/analyzers/build.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
COMMAND=$1
|
||||
SENSORONI_CONTAINER=${SENSORONI_CONTAINER:-so-sensoroni}
|
||||
|
||||
function ci() {
|
||||
HOME_DIR=$(dirname "$0")
|
||||
TARGET_DIR=${1:-.}
|
||||
|
||||
PATH=$PATH:/usr/local/bin
|
||||
|
||||
if ! which pytest &> /dev/null || ! which flake8 &> /dev/null ; then
|
||||
echo "Missing dependencies. Consider running the following command:"
|
||||
echo " python -m pip install flake8 pytest pytest-cov"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
flake8 "$TARGET_DIR" "--config=${HOME_DIR}/pytest.ini"
|
||||
pytest "$TARGET_DIR" "--cov-config=${HOME_DIR}/pytest.ini" "--cov=$TARGET_DIR" --doctest-modules --cov-report=term --cov-fail-under=100
|
||||
}
|
||||
|
||||
function download() {
|
||||
ANALYZERS=$1
|
||||
if [[ $ANALYZERS = "all" ]]; then
|
||||
ANALYZERS="*/"
|
||||
fi
|
||||
for ANALYZER in $ANALYZERS; do
|
||||
rm -fr $ANALYZER/site-packages
|
||||
mkdir -p $ANALYZER/source-packages
|
||||
rm -fr $ANALYZER/source-packages/*
|
||||
docker exec -it $SENSORONI_CONTAINER pip download -r /opt/sensoroni/analyzers/$ANALYZER/requirements.txt -d /opt/sensoroni/analyzers/$ANALYZER/source-packages
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "$COMMAND" == "download" ]]; then
|
||||
download "$2"
|
||||
else
|
||||
ci
|
||||
fi
|
||||
17
salt/sensoroni/files/analyzers/emailrep/README.md
Normal file
17
salt/sensoroni/files/analyzers/emailrep/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# EmailRep
|
||||
|
||||
## Description
|
||||
Submit an email address to EmailRepIO for analysis.
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
``api_key`` - API key used for communication with the EmailRepIO API
|
||||
|
||||
This value should be set in the ``sensoroni`` pillar, like so:
|
||||
|
||||
```
|
||||
sensoroni:
|
||||
analyzers:
|
||||
emailrep:
|
||||
api_key: $yourapikey
|
||||
```
|
||||
0
salt/sensoroni/files/analyzers/emailrep/__init__.py
Normal file
0
salt/sensoroni/files/analyzers/emailrep/__init__.py
Normal file
7
salt/sensoroni/files/analyzers/emailrep/emailrep.json
Normal file
7
salt/sensoroni/files/analyzers/emailrep/emailrep.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "EmailRep",
|
||||
"version": "0.1",
|
||||
"author": "Security Onion Solutions",
|
||||
"description": "This analyzer queries the EmailRep API for email address reputation information",
|
||||
"supportedTypes" : ["email", "mail"]
|
||||
}
|
||||
67
salt/sensoroni/files/analyzers/emailrep/emailrep.py
Executable file
67
salt/sensoroni/files/analyzers/emailrep/emailrep.py
Executable file
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import helpers
|
||||
import argparse
|
||||
|
||||
|
||||
def checkConfigRequirements(conf):
|
||||
if "api_key" not in conf:
|
||||
sys.exit(126)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def sendReq(conf, meta, email):
|
||||
url = conf['base_url'] + email
|
||||
headers = {"Key": conf['api_key']}
|
||||
response = requests.request('GET', url=url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
|
||||
def prepareResults(raw):
|
||||
if "suspicious" in raw:
|
||||
if raw['suspicious'] is True:
|
||||
status = "caution"
|
||||
summary = "suspicious"
|
||||
elif raw['suspicious'] is False:
|
||||
status = "ok"
|
||||
summary = "harmless"
|
||||
elif "status" in raw:
|
||||
if raw["reason"] == "invalid email":
|
||||
status = "caution"
|
||||
summary = "invalid_input"
|
||||
if "exceeded daily limit" in raw["reason"]:
|
||||
status = "caution"
|
||||
summary = "excessive_usage"
|
||||
else:
|
||||
status = "caution"
|
||||
summary = "internal_failure"
|
||||
results = {'response': raw, 'summary': summary, 'status': status}
|
||||
return results
|
||||
|
||||
|
||||
def analyze(conf, input):
|
||||
checkConfigRequirements(conf)
|
||||
meta = helpers.loadMetadata(__file__)
|
||||
data = helpers.parseArtifact(input)
|
||||
helpers.checkSupportedType(meta, data["artifactType"])
|
||||
response = sendReq(conf, meta, data["value"])
|
||||
return prepareResults(response)
|
||||
|
||||
|
||||
def main():
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
parser = argparse.ArgumentParser(description='Search Greynoise for a given artifact')
|
||||
parser.add_argument('artifact', help='the artifact represented in JSON format')
|
||||
parser.add_argument('-c', '--config', metavar="CONFIG_FILE", default=dir + "/emailrep.yaml", help='optional config file to use instead of the default config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.artifact:
|
||||
results = analyze(helpers.loadConfig(args.config), args.artifact)
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
salt/sensoroni/files/analyzers/emailrep/emailrep.yaml
Normal file
2
salt/sensoroni/files/analyzers/emailrep/emailrep.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
base_url: https://emailrep.io/
|
||||
api_key: "{{ salt['pillar.get']('sensoroni:analyzers:emailrep:api_key', '') }}"
|
||||
85
salt/sensoroni/files/analyzers/emailrep/emailrep_test.py
Normal file
85
salt/sensoroni/files/analyzers/emailrep/emailrep_test.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from io import StringIO
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from emailrep import emailrep
|
||||
import unittest
|
||||
|
||||
|
||||
class TestEmailRepMethods(unittest.TestCase):
|
||||
|
||||
def test_main_missing_input(self):
|
||||
with patch('sys.exit', new=MagicMock()) as sysmock:
|
||||
with patch('sys.stderr', new=StringIO()) as mock_stderr:
|
||||
sys.argv = ["cmd"]
|
||||
emailrep.main()
|
||||
self.assertEqual(mock_stderr.getvalue(), "usage: cmd [-h] [-c CONFIG_FILE] artifact\ncmd: error: the following arguments are required: artifact\n")
|
||||
sysmock.assert_called_once_with(2)
|
||||
|
||||
def test_main_success(self):
|
||||
output = {"foo": "bar"}
|
||||
with patch('sys.stdout', new=StringIO()) as mock_stdout:
|
||||
with patch('emailrep.emailrep.analyze', new=MagicMock(return_value=output)) as mock:
|
||||
sys.argv = ["cmd", "input"]
|
||||
emailrep.main()
|
||||
expected = '{"foo": "bar"}\n'
|
||||
self.assertEqual(mock_stdout.getvalue(), expected)
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_checkConfigRequirements_not_present(self):
|
||||
conf = {"not_a_file_path": "blahblah"}
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
emailrep.checkConfigRequirements(conf)
|
||||
self.assertEqual(cm.exception.code, 126)
|
||||
|
||||
def test_sendReq(self):
|
||||
with patch('requests.request', new=MagicMock(return_value=MagicMock())) as mock:
|
||||
meta = {}
|
||||
conf = {"base_url": "https://myurl/", "api_key": "abcd1234"}
|
||||
email = "test@abc.com"
|
||||
response = emailrep.sendReq(conf=conf, meta=meta, email=email)
|
||||
mock.assert_called_once_with("GET", headers={"Key": "abcd1234"}, url="https://myurl/test@abc.com")
|
||||
self.assertIsNotNone(response)
|
||||
|
||||
def test_prepareResults_invalidEmail(self):
|
||||
raw = {"status": "fail", "reason": "invalid email"}
|
||||
results = emailrep.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "invalid_input")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_not_suspicious(self):
|
||||
raw = {"email": "notsus@domain.com", "reputation": "high", "suspicious": False, "references": 21, "details": {"blacklisted": False, "malicious_activity": False, "profiles": ["twitter"]}}
|
||||
results = emailrep.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "harmless")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_suspicious(self):
|
||||
raw = {"email": "sus@domain.com", "reputation": "none", "suspicious": True, "references": 0, "details": {"blacklisted": False, "malicious_activity": False, "profiles": []}}
|
||||
results = emailrep.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_exceeded_limit(self):
|
||||
raw = {"status": "fail", "reason": "exceeded daily limit. please wait 24 hrs or visit emailrep.io/key for an api key."}
|
||||
results = emailrep.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "excessive_usage")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_error(self):
|
||||
raw = {}
|
||||
results = emailrep.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "internal_failure")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_analyze(self):
|
||||
output = {"email": "sus@domain.com", "reputation": "none", "suspicious": True, "references": 0, "details": {"blacklisted": False, "malicious_activity": False, "profiles": []}}
|
||||
artifactInput = '{"value":"sus@domain.com","artifactType":"email"}'
|
||||
conf = {"base_url": "myurl/", "api_key": "abcd1234"}
|
||||
with patch('emailrep.emailrep.sendReq', new=MagicMock(return_value=output)) as mock:
|
||||
results = emailrep.analyze(conf, artifactInput)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
mock.assert_called_once()
|
||||
2
salt/sensoroni/files/analyzers/emailrep/requirements.txt
Normal file
2
salt/sensoroni/files/analyzers/emailrep/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests>=2.27.1
|
||||
pyyaml>=6.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
19
salt/sensoroni/files/analyzers/greynoise/README.md
Normal file
19
salt/sensoroni/files/analyzers/greynoise/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Greynoise
|
||||
|
||||
## Description
|
||||
Submit an IP address to Greynoise for analysis.
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
``api_key`` - API key used for communication with the Greynoise API
|
||||
``api_version`` - Version of Greynoise API. Default is ``community``
|
||||
|
||||
|
||||
This value should be set in the ``sensoroni`` pillar, like so:
|
||||
|
||||
```
|
||||
sensoroni:
|
||||
analyzers:
|
||||
greynoise:
|
||||
api_key: $yourapikey
|
||||
```
|
||||
7
salt/sensoroni/files/analyzers/greynoise/greynoise.json
Normal file
7
salt/sensoroni/files/analyzers/greynoise/greynoise.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Greynoise IP Analyzer",
|
||||
"version": "0.1",
|
||||
"author": "Security Onion Solutions",
|
||||
"description": "This analyzer queries Greynoise for context around an IP address",
|
||||
"supportedTypes" : ["ip"]
|
||||
}
|
||||
78
salt/sensoroni/files/analyzers/greynoise/greynoise.py
Executable file
78
salt/sensoroni/files/analyzers/greynoise/greynoise.py
Executable file
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import helpers
|
||||
import argparse
|
||||
|
||||
|
||||
def checkConfigRequirements(conf):
|
||||
if "api_key" not in conf or len(conf['api_key']) == 0:
|
||||
sys.exit(126)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def sendReq(conf, meta, ip):
|
||||
url = conf['base_url']
|
||||
if conf['api_version'] == 'community':
|
||||
url = url + 'v3/community/' + ip
|
||||
elif conf['api_version'] == 'investigate' or 'automate':
|
||||
url = url + 'v2/noise/context/' + ip
|
||||
headers = {"key": conf['api_key']}
|
||||
response = requests.request('GET', url=url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
|
||||
def prepareResults(raw):
|
||||
if "message" in raw:
|
||||
if "Success" in raw["message"]:
|
||||
if "classification" in raw:
|
||||
if "benign" in raw['classification']:
|
||||
status = "ok"
|
||||
summary = "harmless"
|
||||
elif "malicious" in raw['classification']:
|
||||
status = "threat"
|
||||
summary = "malicious"
|
||||
elif "unknown" in raw['classification']:
|
||||
status = "caution"
|
||||
summary = "suspicious"
|
||||
elif "IP not observed scanning the internet or contained in RIOT data set." in raw["message"]:
|
||||
status = "ok"
|
||||
summary = "no_results"
|
||||
elif "Request is not a valid routable IPv4 address" in raw["message"]:
|
||||
status = "caution"
|
||||
summary = "invalid_input"
|
||||
else:
|
||||
status = "info"
|
||||
summary = raw["message"]
|
||||
else:
|
||||
status = "caution"
|
||||
summary = "internal_failure"
|
||||
results = {'response': raw, 'summary': summary, 'status': status}
|
||||
return results
|
||||
|
||||
|
||||
def analyze(conf, input):
|
||||
checkConfigRequirements(conf)
|
||||
meta = helpers.loadMetadata(__file__)
|
||||
data = helpers.parseArtifact(input)
|
||||
helpers.checkSupportedType(meta, data["artifactType"])
|
||||
response = sendReq(conf, meta, data["value"])
|
||||
return prepareResults(response)
|
||||
|
||||
|
||||
def main():
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
parser = argparse.ArgumentParser(description='Search Greynoise for a given artifact')
|
||||
parser.add_argument('artifact', help='the artifact represented in JSON format')
|
||||
parser.add_argument('-c', '--config', metavar="CONFIG_FILE", default=dir + "/greynoise.yaml", help='optional config file to use instead of the default config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.artifact:
|
||||
results = analyze(helpers.loadConfig(args.config), args.artifact)
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
salt/sensoroni/files/analyzers/greynoise/greynoise.yaml
Normal file
3
salt/sensoroni/files/analyzers/greynoise/greynoise.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
base_url: https://api.greynoise.io/
|
||||
api_key: "{{ salt['pillar.get']('sensoroni:analyzers:greynoise:api_key', '') }}"
|
||||
api_version: "{{ salt['pillar.get']('sensoroni:analyzers:greynoise:api_version', 'community') }}"
|
||||
117
salt/sensoroni/files/analyzers/greynoise/greynoise_test.py
Normal file
117
salt/sensoroni/files/analyzers/greynoise/greynoise_test.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from io import StringIO
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from greynoise import greynoise
|
||||
import unittest
|
||||
|
||||
|
||||
class TestGreynoiseMethods(unittest.TestCase):
|
||||
|
||||
def test_main_missing_input(self):
|
||||
with patch('sys.exit', new=MagicMock()) as sysmock:
|
||||
with patch('sys.stderr', new=StringIO()) as mock_stderr:
|
||||
sys.argv = ["cmd"]
|
||||
greynoise.main()
|
||||
self.assertEqual(mock_stderr.getvalue(), "usage: cmd [-h] [-c CONFIG_FILE] artifact\ncmd: error: the following arguments are required: artifact\n")
|
||||
sysmock.assert_called_once_with(2)
|
||||
|
||||
def test_main_success(self):
|
||||
output = {"foo": "bar"}
|
||||
with patch('sys.stdout', new=StringIO()) as mock_stdout:
|
||||
with patch('greynoise.greynoise.analyze', new=MagicMock(return_value=output)) as mock:
|
||||
sys.argv = ["cmd", "input"]
|
||||
greynoise.main()
|
||||
expected = '{"foo": "bar"}\n'
|
||||
self.assertEqual(mock_stdout.getvalue(), expected)
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_checkConfigRequirements_not_present(self):
|
||||
conf = {"not_a_file_path": "blahblah"}
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
greynoise.checkConfigRequirements(conf)
|
||||
self.assertEqual(cm.exception.code, 126)
|
||||
|
||||
def test_sendReq_community(self):
|
||||
with patch('requests.request', new=MagicMock(return_value=MagicMock())) as mock:
|
||||
meta = {}
|
||||
conf = {"base_url": "https://myurl/", "api_key": "abcd1234", "api_version": "community"}
|
||||
ip = "192.168.1.1"
|
||||
response = greynoise.sendReq(conf=conf, meta=meta, ip=ip)
|
||||
mock.assert_called_once_with("GET", headers={'key': 'abcd1234'}, url="https://myurl/v3/community/192.168.1.1")
|
||||
self.assertIsNotNone(response)
|
||||
|
||||
def test_sendReq_investigate(self):
|
||||
with patch('requests.request', new=MagicMock(return_value=MagicMock())) as mock:
|
||||
meta = {}
|
||||
conf = {"base_url": "https://myurl/", "api_key": "abcd1234", "api_version": "investigate"}
|
||||
ip = "192.168.1.1"
|
||||
response = greynoise.sendReq(conf=conf, meta=meta, ip=ip)
|
||||
mock.assert_called_once_with("GET", headers={'key': 'abcd1234'}, url="https://myurl/v2/noise/context/192.168.1.1")
|
||||
self.assertIsNotNone(response)
|
||||
|
||||
def test_sendReq_automate(self):
|
||||
with patch('requests.request', new=MagicMock(return_value=MagicMock())) as mock:
|
||||
meta = {}
|
||||
conf = {"base_url": "https://myurl/", "api_key": "abcd1234", "api_version": "automate"}
|
||||
ip = "192.168.1.1"
|
||||
response = greynoise.sendReq(conf=conf, meta=meta, ip=ip)
|
||||
mock.assert_called_once_with("GET", headers={'key': 'abcd1234'}, url="https://myurl/v2/noise/context/192.168.1.1")
|
||||
self.assertIsNotNone(response)
|
||||
|
||||
def test_prepareResults_invalidIP(self):
|
||||
raw = {"message": "Request is not a valid routable IPv4 address"}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "invalid_input")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_not_found(self):
|
||||
raw = {"ip": "192.190.1.1", "noise": "false", "riot": "false", "message": "IP not observed scanning the internet or contained in RIOT data set."}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "no_results")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_benign(self):
|
||||
raw = {"ip": "8.8.8.8", "noise": "false", "riot": "true", "classification": "benign", "name": "Google Public DNS", "link": "https://viz.gn.io", "last_seen": "2022-04-26", "message": "Success"}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "harmless")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_malicious(self):
|
||||
raw = {"ip": "121.142.87.218", "noise": "true", "riot": "false", "classification": "malicious", "name": "unknown", "link": "https://viz.gn.io", "last_seen": "2022-04-26", "message": "Success"}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "malicious")
|
||||
self.assertEqual(results["status"], "threat")
|
||||
|
||||
def test_prepareResults_unknown(self):
|
||||
raw = {"ip": "221.4.62.149", "noise": "true", "riot": "false", "classification": "unknown", "name": "unknown", "link": "https://viz.gn.io", "last_seen": "2022-04-26", "message": "Success"}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_unknown_message(self):
|
||||
raw = {"message": "unknown"}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "unknown")
|
||||
self.assertEqual(results["status"], "info")
|
||||
|
||||
def test_prepareResults_error(self):
|
||||
raw = {}
|
||||
results = greynoise.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "internal_failure")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_analyze(self):
|
||||
output = {"ip": "221.4.62.149", "noise": "true", "riot": "false", "classification": "unknown", "name": "unknown", "link": "https://viz.gn.io", "last_seen": "2022-04-26", "message": "Success"}
|
||||
artifactInput = '{"value":"221.4.62.149","artifactType":"ip"}'
|
||||
conf = {"base_url": "myurl/", "api_key": "abcd1234", "api_version": "community"}
|
||||
with patch('greynoise.greynoise.sendReq', new=MagicMock(return_value=output)) as mock:
|
||||
results = greynoise.analyze(conf, artifactInput)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
mock.assert_called_once()
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.27.1
|
||||
pyyaml>=6.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
28
salt/sensoroni/files/analyzers/helpers.py
Normal file
28
salt/sensoroni/files/analyzers/helpers.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def checkSupportedType(meta, artifact_type):
|
||||
if artifact_type not in meta['supportedTypes']:
|
||||
sys.exit(126)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def parseArtifact(artifact):
|
||||
data = json.loads(artifact)
|
||||
return data
|
||||
|
||||
|
||||
def loadMetadata(file):
|
||||
dir = os.path.dirname(os.path.realpath(file))
|
||||
filename = os.path.realpath(file).rsplit('/', 1)[1].split('.')[0]
|
||||
with open(str(dir + "/" + filename + ".json"), "r") as metafile:
|
||||
return json.load(metafile)
|
||||
|
||||
|
||||
def loadConfig(path):
|
||||
import yaml
|
||||
with open(str(path), "r") as conffile:
|
||||
return yaml.safe_load(conffile)
|
||||
35
salt/sensoroni/files/analyzers/helpers_test.py
Normal file
35
salt/sensoroni/files/analyzers/helpers_test.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
import helpers
|
||||
import os
|
||||
import unittest
|
||||
|
||||
|
||||
class TestHelpersMethods(unittest.TestCase):
|
||||
|
||||
def test_checkSupportedType(self):
|
||||
with patch('sys.exit', new=MagicMock()) as mock:
|
||||
meta = {"supportedTypes": ["ip", "foo"]}
|
||||
result = helpers.checkSupportedType(meta, "ip")
|
||||
self.assertTrue(result)
|
||||
mock.assert_not_called()
|
||||
|
||||
result = helpers.checkSupportedType(meta, "bar")
|
||||
self.assertFalse(result)
|
||||
mock.assert_called_once_with(126)
|
||||
|
||||
def test_loadMetadata(self):
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
input = dir + '/urlhaus/urlhaus.py'
|
||||
data = helpers.loadMetadata(input)
|
||||
self.assertEqual(data["name"], "Urlhaus")
|
||||
|
||||
def test_loadConfig(self):
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
data = helpers.loadConfig(dir + "/virustotal/virustotal.yaml")
|
||||
self.assertEqual(data["base_url"], "https://www.virustotal.com/api/v3/search?query=")
|
||||
|
||||
def test_parseArtifact(self):
|
||||
input = '{"value":"foo","artifactType":"bar"}'
|
||||
data = helpers.parseArtifact(input)
|
||||
self.assertEqual(data["artifactType"], "bar")
|
||||
self.assertEqual(data["value"], "foo")
|
||||
0
salt/sensoroni/files/analyzers/ja3er/__init__.py
Normal file
0
salt/sensoroni/files/analyzers/ja3er/__init__.py
Normal file
7
salt/sensoroni/files/analyzers/ja3er/ja3er.json
Normal file
7
salt/sensoroni/files/analyzers/ja3er/ja3er.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "JA3er Hash Search",
|
||||
"version": "0.1",
|
||||
"author": "Security Onion Solutions",
|
||||
"description": "This analyzer queries JA3er user agents and sightings",
|
||||
"supportedTypes" : ["ja3"]
|
||||
}
|
||||
53
salt/sensoroni/files/analyzers/ja3er/ja3er.py
Executable file
53
salt/sensoroni/files/analyzers/ja3er/ja3er.py
Executable file
@@ -0,0 +1,53 @@
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
import helpers
|
||||
import argparse
|
||||
|
||||
|
||||
def sendReq(conf, meta, hash):
|
||||
url = conf['base_url'] + hash
|
||||
response = requests.request('GET', url)
|
||||
return response.json()
|
||||
|
||||
|
||||
def prepareResults(raw):
|
||||
if "error" in raw:
|
||||
if "Sorry" in raw["error"]:
|
||||
status = "ok"
|
||||
summary = "no_results"
|
||||
elif "Invalid hash" in raw["error"]:
|
||||
status = "caution"
|
||||
summary = "invalid_input"
|
||||
else:
|
||||
status = "caution"
|
||||
summary = "internal_failure"
|
||||
else:
|
||||
status = "info"
|
||||
summary = "suspicious"
|
||||
results = {'response': raw, 'summary': summary, 'status': status}
|
||||
return results
|
||||
|
||||
|
||||
def analyze(conf, input):
|
||||
meta = helpers.loadMetadata(__file__)
|
||||
data = helpers.parseArtifact(input)
|
||||
helpers.checkSupportedType(meta, data["artifactType"])
|
||||
response = sendReq(conf, meta, data["value"])
|
||||
return prepareResults(response)
|
||||
|
||||
|
||||
def main():
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
parser = argparse.ArgumentParser(description='Search JA3er for a given artifact')
|
||||
parser.add_argument('artifact', help='the artifact represented in JSON format')
|
||||
parser.add_argument('-c', '--config', metavar="CONFIG_FILE", default=dir + "/ja3er.yaml", help='optional config file to use instead of the default config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.artifact:
|
||||
results = analyze(helpers.loadConfig(args.config), args.artifact)
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
salt/sensoroni/files/analyzers/ja3er/ja3er.yaml
Normal file
1
salt/sensoroni/files/analyzers/ja3er/ja3er.yaml
Normal file
@@ -0,0 +1 @@
|
||||
base_url: https://ja3er.com/search/
|
||||
72
salt/sensoroni/files/analyzers/ja3er/ja3er_test.py
Normal file
72
salt/sensoroni/files/analyzers/ja3er/ja3er_test.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from io import StringIO
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from ja3er import ja3er
|
||||
import unittest
|
||||
|
||||
|
||||
class TestJa3erMethods(unittest.TestCase):
|
||||
|
||||
def test_main_missing_input(self):
|
||||
with patch('sys.exit', new=MagicMock()) as sysmock:
|
||||
with patch('sys.stderr', new=StringIO()) as mock_stderr:
|
||||
sys.argv = ["cmd"]
|
||||
ja3er.main()
|
||||
self.assertEqual(mock_stderr.getvalue(), "usage: cmd [-h] [-c CONFIG_FILE] artifact\ncmd: error: the following arguments are required: artifact\n")
|
||||
sysmock.assert_called_once_with(2)
|
||||
|
||||
def test_main_success(self):
|
||||
output = {"foo": "bar"}
|
||||
with patch('sys.stdout', new=StringIO()) as mock_stdout:
|
||||
with patch('ja3er.ja3er.analyze', new=MagicMock(return_value=output)) as mock:
|
||||
sys.argv = ["cmd", "input"]
|
||||
ja3er.main()
|
||||
expected = '{"foo": "bar"}\n'
|
||||
self.assertEqual(mock_stdout.getvalue(), expected)
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_sendReq(self):
|
||||
with patch('requests.request', new=MagicMock(return_value=MagicMock())) as mock:
|
||||
meta = {}
|
||||
conf = {"base_url": "myurl/"}
|
||||
hash = "abcd1234"
|
||||
response = ja3er.sendReq(conf=conf, meta=meta, hash=hash)
|
||||
mock.assert_called_once_with("GET", "myurl/abcd1234")
|
||||
self.assertIsNotNone(response)
|
||||
|
||||
def test_prepareResults_none(self):
|
||||
raw = {"error": "Sorry no values found"}
|
||||
results = ja3er.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "no_results")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_invalidHash(self):
|
||||
raw = {"error": "Invalid hash"}
|
||||
results = ja3er.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "invalid_input")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_internal_failure(self):
|
||||
raw = {"error": "unknown"}
|
||||
results = ja3er.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "internal_failure")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_info(self):
|
||||
raw = [{"User-Agent": "Blah/5.0", "Count": 24874, "Last_seen": "2022-04-08 16:18:38"}, {"Comment": "Brave browser v1.36.122\n\n", "Reported": "2022-03-28 20:26:42"}]
|
||||
results = ja3er.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
self.assertEqual(results["status"], "info")
|
||||
|
||||
def test_analyze(self):
|
||||
output = {"info": "Results found."}
|
||||
artifactInput = '{"value":"abcd1234","artifactType":"ja3"}'
|
||||
conf = {"base_url": "myurl/"}
|
||||
with patch('ja3er.ja3er.sendReq', new=MagicMock(return_value=output)) as mock:
|
||||
results = ja3er.analyze(conf, artifactInput)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
mock.assert_called_once()
|
||||
2
salt/sensoroni/files/analyzers/ja3er/requirements.txt
Normal file
2
salt/sensoroni/files/analyzers/ja3er/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests>=2.27.1
|
||||
pyyaml>=6.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
32
salt/sensoroni/files/analyzers/localfile/README.md
Normal file
32
salt/sensoroni/files/analyzers/localfile/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Localfile
|
||||
|
||||
## Description
|
||||
Utilize a local CSV file (or multiple) for associating a value to contextual data.
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
``file_path`` - Path(s) used for CSV files containing associative data. CSV files can be dropped in the analyzer directory, with ``file_path`` specified like ``mycsv.csv``.
|
||||
|
||||
- The value in the first column is used for matching
|
||||
- Header information should be supplied, as it is used for dynamically creating result sets
|
||||
- Matches will be aggregated from the provided CSV files
|
||||
|
||||
The content of the CSV file(s) should be similar to the following:
|
||||
|
||||
Ex.
|
||||
|
||||
```
|
||||
MatchValue,MatchDescription,MatchReference
|
||||
abcd1234,ThisIsADescription,https://siteabouthings.abc
|
||||
```
|
||||
|
||||
The ``file_path`` value(s) should be set in the ``sensoroni`` pillar, like so:
|
||||
|
||||
```
|
||||
sensoroni:
|
||||
analyzers:
|
||||
localfile:
|
||||
file_path:
|
||||
- $file_path1
|
||||
- $file_path2
|
||||
```
|
||||
7
salt/sensoroni/files/analyzers/localfile/localfile.json
Normal file
7
salt/sensoroni/files/analyzers/localfile/localfile.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Local File Analyzer",
|
||||
"version": "0.1",
|
||||
"author": "Security Onion Solutions",
|
||||
"description": "This analyzer queries one or more local CSV files for a value, then returns all columns within matching rows.",
|
||||
"supportedTypes" : ["domain", "hash", "ip", "other", "url"]
|
||||
}
|
||||
79
salt/sensoroni/files/analyzers/localfile/localfile.py
Executable file
79
salt/sensoroni/files/analyzers/localfile/localfile.py
Executable file
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import helpers
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import csv
|
||||
|
||||
|
||||
def checkConfigRequirements(conf):
|
||||
if "file_path" not in conf or len(conf['file_path']) == 0:
|
||||
sys.exit(126)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def searchFile(artifact, csvfiles):
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
found = []
|
||||
for f in csvfiles:
|
||||
filename = dir + "/" + f
|
||||
with open(filename, "r") as csvfile:
|
||||
csvdata = csv.DictReader(csvfile)
|
||||
for row in csvdata:
|
||||
first_key = list(row.keys())[0]
|
||||
if artifact in row[first_key]:
|
||||
row.update({"filename": filename})
|
||||
found.append(row)
|
||||
if len(found) != 0:
|
||||
if len(found) == 1:
|
||||
results = found[0]
|
||||
else:
|
||||
results = found
|
||||
else:
|
||||
results = "No results"
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def prepareResults(raw):
|
||||
if len(raw) > 0:
|
||||
if "No results" in raw:
|
||||
status = "ok"
|
||||
summary = "no_results"
|
||||
else:
|
||||
status = "info"
|
||||
summary = "suspicious"
|
||||
else:
|
||||
raw = {}
|
||||
status = "caution"
|
||||
summary = "internal_failure"
|
||||
response = raw
|
||||
results = {'response': response, 'status': status, 'summary': summary}
|
||||
return results
|
||||
|
||||
|
||||
def analyze(conf, input):
|
||||
checkConfigRequirements(conf)
|
||||
meta = helpers.loadMetadata(__file__)
|
||||
data = helpers.parseArtifact(input)
|
||||
helpers.checkSupportedType(meta, data["artifactType"])
|
||||
search = searchFile(data["value"], conf['file_path'])
|
||||
results = prepareResults(search)
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
parser = argparse.ArgumentParser(description='Search CSV file for a given artifact')
|
||||
parser.add_argument('artifact', help='the artifact represented in JSON format')
|
||||
parser.add_argument('-c', '--config', metavar="CONFIG_FILE", default=dir + "/localfile.yaml", help='optional config file to use instead of the default config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.artifact:
|
||||
results = analyze(helpers.loadConfig(args.config), args.artifact)
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
salt/sensoroni/files/analyzers/localfile/localfile.yaml
Normal file
1
salt/sensoroni/files/analyzers/localfile/localfile.yaml
Normal file
@@ -0,0 +1 @@
|
||||
file_path: []
|
||||
@@ -0,0 +1,4 @@
|
||||
indicator,description,reference
|
||||
abcd1234,This is a test!,Testing
|
||||
abcd1234,This is another test!,Testing
|
||||
192.168.1.1,Yet another test!,Testing
|
||||
|
119
salt/sensoroni/files/analyzers/localfile/localfile_test.py
Normal file
119
salt/sensoroni/files/analyzers/localfile/localfile_test.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from io import StringIO
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from localfile import localfile
|
||||
import unittest
|
||||
|
||||
|
||||
class TestLocalfileMethods(unittest.TestCase):
|
||||
|
||||
def test_main_missing_input(self):
|
||||
with patch('sys.exit', new=MagicMock()) as sysmock:
|
||||
with patch('sys.stderr', new=StringIO()) as mock_stderr:
|
||||
sys.argv = ["cmd"]
|
||||
localfile.main()
|
||||
self.assertEqual(mock_stderr.getvalue(), "usage: cmd [-h] [-c CONFIG_FILE] artifact\ncmd: error: the following arguments are required: artifact\n")
|
||||
sysmock.assert_called_once_with(2)
|
||||
|
||||
def test_main_success(self):
|
||||
output = {"foo": "bar"}
|
||||
with patch('sys.stdout', new=StringIO()) as mock_stdout:
|
||||
with patch('localfile.localfile.analyze', new=MagicMock(return_value=output)) as mock:
|
||||
sys.argv = ["cmd", "input"]
|
||||
localfile.main()
|
||||
expected = '{"foo": "bar"}\n'
|
||||
self.assertEqual(mock_stdout.getvalue(), expected)
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_checkConfigRequirements_present(self):
|
||||
conf = {"file_path": "['intel.csv']"}
|
||||
self.assertTrue(localfile.checkConfigRequirements(conf))
|
||||
|
||||
def test_checkConfigRequirements_not_present(self):
|
||||
conf = {"not_a_file_path": "blahblah"}
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
localfile.checkConfigRequirements(conf)
|
||||
self.assertEqual(cm.exception.code, 126)
|
||||
|
||||
def test_checkConfigRequirements_empty(self):
|
||||
conf = {"file_path": ""}
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
localfile.checkConfigRequirements(conf)
|
||||
self.assertEqual(cm.exception.code, 126)
|
||||
|
||||
def test_searchFile_multiple_found(self):
|
||||
artifact = "abcd1234"
|
||||
results = localfile.searchFile(artifact, ["localfile_test.csv"])
|
||||
self.assertEqual(results[0]["indicator"], "abcd1234")
|
||||
self.assertEqual(results[0]["description"], "This is a test!")
|
||||
self.assertEqual(results[0]["reference"], "Testing")
|
||||
self.assertEqual(results[1]["indicator"], "abcd1234")
|
||||
self.assertEqual(results[1]["description"], "This is another test!")
|
||||
|
||||
def test_searchFile_single_found(self):
|
||||
artifact = "192.168.1.1"
|
||||
results = localfile.searchFile(artifact, ["localfile_test.csv"])
|
||||
self.assertEqual(results["indicator"], "192.168.1.1")
|
||||
self.assertEqual(results["description"], "Yet another test!")
|
||||
self.assertEqual(results["reference"], "Testing")
|
||||
|
||||
def test_searchFile_not_found(self):
|
||||
artifact = "youcan'tfindme"
|
||||
results = localfile.searchFile(artifact, ["localfile_test.csv"])
|
||||
self.assertEqual(results, "No results")
|
||||
|
||||
def test_prepareResults_none(self):
|
||||
raw = "No results"
|
||||
results = localfile.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "no_results")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_ok(self):
|
||||
raw = [
|
||||
{
|
||||
"description": "This is one BAD piece of malware!",
|
||||
"filename": "/opt/sensoroni/analyzers/localfile/intel.csv",
|
||||
"indicator": "abc1234",
|
||||
"reference": "https://myintelservice"
|
||||
},
|
||||
{
|
||||
"filename": "/opt/sensoroni/analyzers/localfile/random.csv",
|
||||
"randomcol1": "myothervalue",
|
||||
"randomcol2": "myotherothervalue",
|
||||
"value": "abc1234"
|
||||
}
|
||||
]
|
||||
results = localfile.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
self.assertEqual(results["status"], "info")
|
||||
|
||||
def test_prepareResults_error(self):
|
||||
raw = {}
|
||||
results = localfile.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "internal_failure")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_analyze(self):
|
||||
output = [
|
||||
{
|
||||
"description": "This is one BAD piece of malware!",
|
||||
"filename": "/opt/sensoroni/analyzers/localfile/intel.csv",
|
||||
"indicator": "abc1234",
|
||||
"reference": "https://myintelservice"
|
||||
},
|
||||
{
|
||||
"filename": "/opt/sensoroni/analyzers/localfile/random.csv",
|
||||
"randomcol1": "myothervalue",
|
||||
"randomcol2": "myotherothervalue",
|
||||
"value": "abc1234"
|
||||
}
|
||||
]
|
||||
artifactInput = '{"value":"foo","artifactType":"url"}'
|
||||
conf = {"file_path": "/home/intel.csv"}
|
||||
with patch('localfile.localfile.searchFile', new=MagicMock(return_value=output)) as mock:
|
||||
results = localfile.analyze(conf, artifactInput)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
mock.assert_called_once()
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.27.1
|
||||
pyyaml>=6.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Team Cymru Malware Hash Registry",
|
||||
"version": "0.1",
|
||||
"author": "Security Onion Solutions",
|
||||
"description": "This analyzer queries Team Cymru's Malware Hash registry for hashes to determine if the associated files are considered malicious.",
|
||||
"supportedTypes" : ["hash"]
|
||||
}
|
||||
65
salt/sensoroni/files/analyzers/malwarehashregistry/malwarehashregistry.py
Executable file
65
salt/sensoroni/files/analyzers/malwarehashregistry/malwarehashregistry.py
Executable file
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
import helpers
|
||||
import argparse
|
||||
import datetime
|
||||
from whois import NICClient
|
||||
|
||||
|
||||
def sendReq(hash):
|
||||
server = "hash.cymru.com"
|
||||
flags = 0
|
||||
options = {"whoishost": server}
|
||||
nic_client = NICClient()
|
||||
response = nic_client.whois_lookup(options, hash, flags).rstrip()
|
||||
hash = response.split(' ')[0]
|
||||
lastSeen = response.split(' ')[1]
|
||||
if lastSeen == "NO_DATA":
|
||||
avPct = 0
|
||||
else:
|
||||
avPct = response.split(' ')[2]
|
||||
lastSeen = datetime.datetime.fromtimestamp(int(lastSeen)).strftime("%Y-%d-%m %H:%M:%S")
|
||||
raw = {"hash": hash, "last_seen": lastSeen, "av_detection_percentage": int(avPct)}
|
||||
return raw
|
||||
|
||||
|
||||
def prepareResults(raw):
|
||||
if raw and "last_seen" in raw:
|
||||
if raw["last_seen"] == "NO_DATA":
|
||||
status = "ok"
|
||||
summary = "no_results"
|
||||
elif raw["av_detection_percentage"] < 1:
|
||||
status = "ok"
|
||||
summary = "harmless"
|
||||
elif raw["av_detection_percentage"] in range(1, 50):
|
||||
status = "caution"
|
||||
summary = "suspicious"
|
||||
elif raw["av_detection_percentage"] in range(51, 100):
|
||||
status = "threat"
|
||||
summary = "malicious"
|
||||
else:
|
||||
status = "caution"
|
||||
summary = "internal_failure"
|
||||
results = {'response': raw, 'summary': summary, 'status': status}
|
||||
return results
|
||||
|
||||
|
||||
def analyze(input):
|
||||
meta = helpers.loadMetadata(__file__)
|
||||
data = helpers.parseArtifact(input)
|
||||
helpers.checkSupportedType(meta, data["artifactType"])
|
||||
response = sendReq(data["value"])
|
||||
return prepareResults(response)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Search Team Cymru Malware Hash Registry for a given artifact')
|
||||
parser.add_argument('artifact', help='the artifact represented in JSON format')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.artifact:
|
||||
results = analyze(args.artifact)
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
from io import StringIO
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from malwarehashregistry import malwarehashregistry
|
||||
import unittest
|
||||
|
||||
|
||||
class TestMalwareHashRegistryMethods(unittest.TestCase):
|
||||
|
||||
def test_main_missing_input(self):
|
||||
with patch('sys.exit', new=MagicMock()) as sysmock:
|
||||
with patch('sys.stderr', new=StringIO()) as mock_stderr:
|
||||
sys.argv = ["cmd"]
|
||||
malwarehashregistry.main()
|
||||
self.assertEqual(mock_stderr.getvalue(), "usage: cmd [-h] artifact\ncmd: error: the following arguments are required: artifact\n")
|
||||
sysmock.assert_called_once_with(2)
|
||||
|
||||
def test_main_success(self):
|
||||
output = {"foo": "bar"}
|
||||
with patch('sys.stdout', new=StringIO()) as mock_stdout:
|
||||
with patch('malwarehashregistry.malwarehashregistry.analyze', new=MagicMock(return_value=output)) as mock:
|
||||
sys.argv = ["cmd", "input"]
|
||||
malwarehashregistry.main()
|
||||
expected = '{"foo": "bar"}\n'
|
||||
self.assertEqual(mock_stdout.getvalue(), expected)
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_sendReq(self):
|
||||
output = "84af04b8e69682782607a0c5796ca56999eda6b3 1563161433 35"
|
||||
hash = "abcd1234"
|
||||
server = "hash.cymru.com"
|
||||
flags = 0
|
||||
options = {"whoishost": server}
|
||||
with patch('whois.NICClient.whois_lookup', new=MagicMock(return_value=output)) as mock:
|
||||
response = malwarehashregistry.sendReq(hash)
|
||||
mock.assert_called_once_with(options, hash, flags)
|
||||
self.assertIsNotNone(response)
|
||||
self.assertEqual(response, {"hash": "84af04b8e69682782607a0c5796ca56999eda6b3", "last_seen": "2019-15-07 03:30:33", "av_detection_percentage": 35})
|
||||
|
||||
def test_sendReqNoData(self):
|
||||
output = "84af04b8e69682782607a0c5796ca5696b3 NO_DATA"
|
||||
hash = "abcd1234"
|
||||
server = "hash.cymru.com"
|
||||
flags = 0
|
||||
options = {"whoishost": server}
|
||||
with patch('whois.NICClient.whois_lookup', new=MagicMock(return_value=output)) as mock:
|
||||
response = malwarehashregistry.sendReq(hash)
|
||||
mock.assert_called_once_with(options, hash, flags)
|
||||
self.assertIsNotNone(response)
|
||||
self.assertEqual(response, {"hash": "84af04b8e69682782607a0c5796ca5696b3", "last_seen": "NO_DATA", "av_detection_percentage": 0})
|
||||
|
||||
def test_prepareResults_none(self):
|
||||
raw = {"hash": "14af04b8e69682782607a0c5796ca56999eda6b3", "last_seen": "NO_DATA", "av_detection_percentage": 0}
|
||||
results = malwarehashregistry.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "no_results")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_harmless(self):
|
||||
raw = {"hash": "14af04b8e69682782607a0c5796ca56999eda6b3", "last_seen": "123456", "av_detection_percentage": 0}
|
||||
results = malwarehashregistry.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "harmless")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_sus(self):
|
||||
raw = {"hash": "14af04b8e69682782607a0c5796ca56999eda6b3", "last_seen": "123456", "av_detection_percentage": 1}
|
||||
results = malwarehashregistry.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_mal(self):
|
||||
raw = {"hash": "14af04b8e69682782607a0c5796ca56999eda6b3", "last_seen": "123456", "av_detection_percentage": 51}
|
||||
results = malwarehashregistry.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "malicious")
|
||||
self.assertEqual(results["status"], "threat")
|
||||
|
||||
def test_prepareResults_error(self):
|
||||
raw = {}
|
||||
results = malwarehashregistry.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "internal_failure")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_analyze(self):
|
||||
output = {"hash": "14af04b8e69682782607a0c5796ca56999eda6b3", "last_seen": "NO_DATA", "av_detection_percentage": 0}
|
||||
artifactInput = '{"value": "14af04b8e69682782607a0c5796ca56999eda6b3", "artifactType": "hash"}'
|
||||
with patch('malwarehashregistry.malwarehashregistry.sendReq', new=MagicMock(return_value=output)) as mock:
|
||||
results = malwarehashregistry.analyze(artifactInput)
|
||||
self.assertEqual(results["summary"], "no_results")
|
||||
mock.assert_called_once()
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.27.1
|
||||
python-whois>=0.7.3
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
salt/sensoroni/files/analyzers/otx/README.md
Normal file
17
salt/sensoroni/files/analyzers/otx/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Alienvault OTX
|
||||
|
||||
## Description
|
||||
Submit a domain, hash, IP, or URL to Alienvault OTX for analysis.
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
``api_key`` - API key used for communication with the Alienvault API
|
||||
|
||||
This value should be set in the ``sensoroni`` pillar, like so:
|
||||
|
||||
```
|
||||
sensoroni:
|
||||
analyzers:
|
||||
otx:
|
||||
api_key: $yourapikey
|
||||
```
|
||||
0
salt/sensoroni/files/analyzers/otx/__init__.py
Normal file
0
salt/sensoroni/files/analyzers/otx/__init__.py
Normal file
7
salt/sensoroni/files/analyzers/otx/otx.json
Normal file
7
salt/sensoroni/files/analyzers/otx/otx.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Alienvault OTX",
|
||||
"version": "0.1",
|
||||
"author": "Security Onion Solutions",
|
||||
"description": "This analyzer queries Alienvault OTX for a domain, hash, IP, or URL, then returns a report for it.",
|
||||
"supportedTypes" : ["domain", "hash", "ip", "url"]
|
||||
}
|
||||
88
salt/sensoroni/files/analyzers/otx/otx.py
Executable file
88
salt/sensoroni/files/analyzers/otx/otx.py
Executable file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import requests
|
||||
import helpers
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
|
||||
def buildReq(conf, artifact_type, artifact_value):
|
||||
headers = {"X-OTX-API-KEY": conf["api_key"]}
|
||||
base_url = conf['base_url']
|
||||
if artifact_type == "ip":
|
||||
uri = "indicators/IPv4/"
|
||||
elif artifact_type == "url":
|
||||
uri = "indicators/url/"
|
||||
elif artifact_type == "domain":
|
||||
uri = "indicators/domain/"
|
||||
elif artifact_type == "hash":
|
||||
uri = "indicators/file/"
|
||||
section = "/general"
|
||||
url = base_url + uri + artifact_value + section
|
||||
return url, headers
|
||||
|
||||
|
||||
def checkConfigRequirements(conf):
|
||||
if "api_key" not in conf or len(conf['api_key']) == 0:
|
||||
sys.exit(126)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def sendReq(url, headers):
|
||||
response = requests.request('GET', url, headers=headers)
|
||||
return response.json()
|
||||
|
||||
|
||||
def prepareResults(response):
|
||||
if len(response) != 0:
|
||||
raw = response
|
||||
if 'reputation' in raw:
|
||||
reputation = raw["reputation"]
|
||||
if reputation == 0:
|
||||
status = "ok"
|
||||
summaryinfo = "harmless"
|
||||
elif reputation > 0 and reputation < 50:
|
||||
status = "ok"
|
||||
summaryinfo = "likely_harmless"
|
||||
elif reputation >= 50 and reputation < 75:
|
||||
status = "caution"
|
||||
summaryinfo = "suspicious"
|
||||
elif reputation >= 75 and reputation <= 100:
|
||||
status = "threat"
|
||||
summaryinfo = "malicious"
|
||||
else:
|
||||
status = "info"
|
||||
summaryinfo = "analyzer_analysis_complete"
|
||||
else:
|
||||
raw = {}
|
||||
status = "caution"
|
||||
summaryinfo = "internal_failure"
|
||||
results = {'response': raw, 'status': status, 'summary': summaryinfo}
|
||||
return results
|
||||
|
||||
|
||||
def analyze(conf, input):
|
||||
checkConfigRequirements(conf)
|
||||
meta = helpers.loadMetadata(__file__)
|
||||
data = helpers.parseArtifact(input)
|
||||
helpers.checkSupportedType(meta, data["artifactType"])
|
||||
request = buildReq(conf, data["artifactType"], data["value"])
|
||||
response = sendReq(request[0], request[1])
|
||||
return prepareResults(response)
|
||||
|
||||
|
||||
def main():
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
parser = argparse.ArgumentParser(description='Search Alienvault OTX for a given artifact')
|
||||
parser.add_argument('artifact', help='the artifact represented in JSON format')
|
||||
parser.add_argument('-c', '--config', metavar="CONFIG_FILE", default=dir + "/otx.yaml", help='optional config file to use instead of the default config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.artifact:
|
||||
results = analyze(helpers.loadConfig(args.config), args.artifact)
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
salt/sensoroni/files/analyzers/otx/otx.yaml
Normal file
2
salt/sensoroni/files/analyzers/otx/otx.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
base_url: https://otx.alienvault.com/api/v1/
|
||||
api_key: "{{ salt['pillar.get']('sensoroni:analyzers:otx:api_key', '') }}"
|
||||
250
salt/sensoroni/files/analyzers/otx/otx_test.py
Normal file
250
salt/sensoroni/files/analyzers/otx/otx_test.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from io import StringIO
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from otx import otx
|
||||
import unittest
|
||||
|
||||
|
||||
class TestOtxMethods(unittest.TestCase):
|
||||
|
||||
def test_main_missing_input(self):
|
||||
with patch('sys.exit', new=MagicMock()) as sysmock:
|
||||
with patch('sys.stderr', new=StringIO()) as mock_stderr:
|
||||
sys.argv = ["cmd"]
|
||||
otx.main()
|
||||
self.assertEqual(mock_stderr.getvalue(), "usage: cmd [-h] [-c CONFIG_FILE] artifact\ncmd: error: the following arguments are required: artifact\n")
|
||||
sysmock.assert_called_once_with(2)
|
||||
|
||||
def test_main_success(self):
|
||||
output = {"foo": "bar"}
|
||||
with patch('sys.stdout', new=StringIO()) as mock_stdout:
|
||||
with patch('otx.otx.analyze', new=MagicMock(return_value=output)) as mock:
|
||||
sys.argv = ["cmd", "input"]
|
||||
otx.main()
|
||||
expected = '{"foo": "bar"}\n'
|
||||
self.assertEqual(mock_stdout.getvalue(), expected)
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_checkConfigRequirements(self):
|
||||
conf = {"not_a_key": "abcd12345"}
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
otx.checkConfigRequirements(conf)
|
||||
self.assertEqual(cm.exception.code, 126)
|
||||
|
||||
def test_buildReq_domain(self):
|
||||
conf = {'base_url': 'https://myurl/', 'api_key': 'abcd12345'}
|
||||
artifact_type = "domain"
|
||||
artifact_value = "abc.com"
|
||||
result = otx.buildReq(conf, artifact_type, artifact_value)
|
||||
self.assertEqual("https://myurl/indicators/domain/abc.com/general", result[0])
|
||||
self.assertEqual({'X-OTX-API-KEY': 'abcd12345'}, result[1])
|
||||
|
||||
def test_buildReq_hash(self):
|
||||
conf = {'base_url': 'https://myurl/', 'api_key': 'abcd12345'}
|
||||
artifact_type = "hash"
|
||||
artifact_value = "abcd1234"
|
||||
result = otx.buildReq(conf, artifact_type, artifact_value)
|
||||
self.assertEqual("https://myurl/indicators/file/abcd1234/general", result[0])
|
||||
self.assertEqual({'X-OTX-API-KEY': 'abcd12345'}, result[1])
|
||||
|
||||
def test_buildReq_ip(self):
|
||||
conf = {'base_url': 'https://myurl/', 'api_key': 'abcd12345'}
|
||||
artifact_type = "ip"
|
||||
artifact_value = "192.168.1.1"
|
||||
result = otx.buildReq(conf, artifact_type, artifact_value)
|
||||
self.assertEqual("https://myurl/indicators/IPv4/192.168.1.1/general", result[0])
|
||||
self.assertEqual({'X-OTX-API-KEY': 'abcd12345'}, result[1])
|
||||
|
||||
def test_buildReq_url(self):
|
||||
conf = {'base_url': 'https://myurl/', 'api_key': 'abcd12345'}
|
||||
artifact_type = "url"
|
||||
artifact_value = "https://abc.com"
|
||||
result = otx.buildReq(conf, artifact_type, artifact_value)
|
||||
self.assertEqual("https://myurl/indicators/url/https://abc.com/general", result[0])
|
||||
self.assertEqual({'X-OTX-API-KEY': 'abcd12345'}, result[1])
|
||||
|
||||
def test_sendReq(self):
|
||||
with patch('requests.request', new=MagicMock(return_value=MagicMock())) as mock:
|
||||
url = "https://myurl="
|
||||
response = otx.sendReq(url, headers={"x-apikey": "xyz"})
|
||||
mock.assert_called_once_with("GET", "https://myurl=", headers={"x-apikey": "xyz"})
|
||||
self.assertIsNotNone(response)
|
||||
|
||||
def test_prepareResults_harmless(self):
|
||||
raw = {
|
||||
"whois": "http://whois.domaintools.com/192.168.1.1",
|
||||
"reputation": 0,
|
||||
"indicator": "192.168.1.1",
|
||||
"type": "IPv4",
|
||||
"pulse_info": {
|
||||
"count": 0,
|
||||
"pulses": [],
|
||||
"related": {
|
||||
"alienvault": {
|
||||
"adversary": [],
|
||||
"malware_families": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"false_positive": [],
|
||||
"sections": [
|
||||
"general"
|
||||
]
|
||||
}
|
||||
results = otx.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "harmless")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_likely_harmless(self):
|
||||
raw = {
|
||||
"whois": "http://whois.domaintools.com/192.168.1.1",
|
||||
"reputation": 49,
|
||||
"indicator": "192.168.1.1",
|
||||
"type": "IPv4",
|
||||
"pulse_info": {
|
||||
"count": 0,
|
||||
"pulses": [],
|
||||
"related": {
|
||||
"alienvault": {
|
||||
"adversary": [],
|
||||
"malware_families": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"false_positive": [],
|
||||
"sections": [
|
||||
"general"
|
||||
]
|
||||
}
|
||||
results = otx.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "likely_harmless")
|
||||
self.assertEqual(results["status"], "ok")
|
||||
|
||||
def test_prepareResults_suspicious(self):
|
||||
raw = {
|
||||
"whois": "http://whois.domaintools.com/192.168.1.1",
|
||||
"reputation": 50,
|
||||
"indicator": "192.168.1.1",
|
||||
"type": "IPv4",
|
||||
"pulse_info": {
|
||||
"count": 0,
|
||||
"pulses": [],
|
||||
"related": {
|
||||
"alienvault": {
|
||||
"adversary": [],
|
||||
"malware_families": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"false_positive": [],
|
||||
"sections": [
|
||||
"general"
|
||||
]
|
||||
}
|
||||
results = otx.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "suspicious")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_prepareResults_threat(self):
|
||||
raw = {
|
||||
"whois": "http://whois.domaintools.com/192.168.1.1",
|
||||
"reputation": 75,
|
||||
"indicator": "192.168.1.1",
|
||||
"type": "IPv4",
|
||||
"pulse_info": {
|
||||
"count": 0,
|
||||
"pulses": [],
|
||||
"related": {
|
||||
"alienvault": {
|
||||
"adversary": [],
|
||||
"malware_families": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"false_positive": [],
|
||||
"sections": [
|
||||
"general"
|
||||
]
|
||||
}
|
||||
results = otx.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "malicious")
|
||||
self.assertEqual(results["status"], "threat")
|
||||
|
||||
def test_prepareResults_undetermined(self):
|
||||
raw = {
|
||||
"alexa": "",
|
||||
"base_indicator": {},
|
||||
"domain": "Unavailable",
|
||||
"false_positive": [],
|
||||
"hostname": "Unavailable",
|
||||
"indicator": "http://192.168.1.1",
|
||||
"pulse_info": {
|
||||
"count": 0,
|
||||
"pulses": [],
|
||||
"references": [],
|
||||
"related": {
|
||||
"alienvault": {
|
||||
"adversary": [],
|
||||
"industries": [],
|
||||
"malware_families": [],
|
||||
"unique_indicators": 0
|
||||
},
|
||||
"other": {
|
||||
"adversary": [],
|
||||
"industries": [],
|
||||
"malware_families": [],
|
||||
"unique_indicators": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"sections": [
|
||||
"general"
|
||||
],
|
||||
"type": "url",
|
||||
"type_title": "URL",
|
||||
"validation": []
|
||||
}
|
||||
results = otx.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "analyzer_analysis_complete")
|
||||
self.assertEqual(results["status"], "info")
|
||||
|
||||
def test_prepareResults_error(self):
|
||||
raw = {}
|
||||
results = otx.prepareResults(raw)
|
||||
self.assertEqual(results["response"], raw)
|
||||
self.assertEqual(results["summary"], "internal_failure")
|
||||
self.assertEqual(results["status"], "caution")
|
||||
|
||||
def test_analyze(self):
|
||||
output = {
|
||||
"whois": "http://whois.domaintools.com/192.168.1.1",
|
||||
"reputation": 0,
|
||||
"indicator": "192.168.1.1",
|
||||
"type": "IPv4",
|
||||
"pulse_info": {
|
||||
"count": 0,
|
||||
"pulses": [],
|
||||
"related": {
|
||||
"alienvault": {
|
||||
"adversary": [],
|
||||
"malware_families": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"false_positive": [],
|
||||
"sections": [
|
||||
"general"
|
||||
]
|
||||
}
|
||||
|
||||
artifactInput = '{"value":"192.168.1.1","artifactType":"ip"}'
|
||||
conf = {"base_url": "https://myurl/", "api_key": "xyz"}
|
||||
with patch('otx.otx.sendReq', new=MagicMock(return_value=output)) as mock:
|
||||
results = otx.analyze(conf, artifactInput)
|
||||
self.assertEqual(results["summary"], "harmless")
|
||||
mock.assert_called_once()
|
||||
2
salt/sensoroni/files/analyzers/otx/requirements.txt
Normal file
2
salt/sensoroni/files/analyzers/otx/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests>=2.27.1
|
||||
pyyaml>=6.0
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user