@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate simplified API documentation from FastAPI OpenAPI spec.
|
||||
This script extracts the OpenAPI specification from the web-api service
|
||||
and generates clean markdown documentation in docs/api/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Add the web_api module to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "projects" / "web_api"))
|
||||
|
||||
|
||||
def extract_openapi_spec() -> dict[str, Any]:
|
||||
"""Extract OpenAPI specification from the FastAPI app."""
|
||||
try:
|
||||
# Set minimal environment variables to avoid database connection issues
|
||||
os.environ.setdefault("POSTGRES_CONNECTION_URI", "postgresql://test:test@localhost/test")
|
||||
os.environ.setdefault("MINIO_ROOT_USER", "test")
|
||||
os.environ.setdefault("MINIO_ROOT_PASSWORD", "test")
|
||||
os.environ.setdefault("MINIO_SERVER", "localhost:9000")
|
||||
os.environ.setdefault("BUCKET_NAME", "test")
|
||||
|
||||
# Mock Dapr dependencies to avoid connection issues
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock dapr modules before importing web_api.main
|
||||
mock_dapr_clients = MagicMock()
|
||||
mock_dapr_ext_fastapi = MagicMock()
|
||||
sys.modules["dapr.clients"] = mock_dapr_clients
|
||||
sys.modules["dapr.ext.fastapi"] = mock_dapr_ext_fastapi
|
||||
|
||||
# Mock the specific classes that are imported
|
||||
mock_dapr_clients.DaprClient = MagicMock()
|
||||
mock_dapr_ext_fastapi.DaprApp = MagicMock()
|
||||
|
||||
from web_api.main import app
|
||||
|
||||
return app.openapi()
|
||||
except ImportError as e:
|
||||
print(f"Error importing web_api.main: {e}")
|
||||
print("Make sure to run: cd projects/web_api && poetry install")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error generating OpenAPI spec: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_parameter(param: dict[str, Any]) -> str:
|
||||
"""Format a parameter for documentation."""
|
||||
param_type = param.get("schema", {}).get("type", "unknown")
|
||||
required = "**required**" if param.get("required", False) else "optional"
|
||||
description = param.get("description", "")
|
||||
|
||||
return f"- `{param['name']}` ({param_type}, {required}): {description}"
|
||||
|
||||
|
||||
def format_request_body(request_body: dict[str, Any]) -> str:
|
||||
"""Format request body information."""
|
||||
if not request_body:
|
||||
return ""
|
||||
|
||||
content = request_body.get("content", {})
|
||||
if "application/json" in content:
|
||||
schema = content["application/json"].get("schema", {})
|
||||
if "$ref" in schema:
|
||||
# Extract model name from reference
|
||||
model_name = schema["$ref"].split("/")[-1]
|
||||
return f"**Request Body:** `{model_name}` (JSON)"
|
||||
elif schema.get("type") == "object":
|
||||
return "**Request Body:** JSON object"
|
||||
|
||||
return "**Request Body:** See OpenAPI spec for details"
|
||||
|
||||
|
||||
def generate_endpoint_docs(paths: dict[str, Any]) -> str:
|
||||
"""Generate documentation for all endpoints."""
|
||||
docs = []
|
||||
|
||||
# Group endpoints by tags
|
||||
tagged_endpoints = {}
|
||||
untagged_endpoints = []
|
||||
|
||||
for path, methods in paths.items():
|
||||
for method, details in methods.items():
|
||||
if method.upper() not in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
|
||||
continue
|
||||
|
||||
tags = details.get("tags", [])
|
||||
endpoint_info = {"path": path, "method": method.upper(), "details": details}
|
||||
|
||||
if tags:
|
||||
tag = tags[0] # Use first tag
|
||||
if tag not in tagged_endpoints:
|
||||
tagged_endpoints[tag] = []
|
||||
tagged_endpoints[tag].append(endpoint_info)
|
||||
else:
|
||||
untagged_endpoints.append(endpoint_info)
|
||||
|
||||
# Generate docs for each tag group
|
||||
for tag, endpoints in sorted(tagged_endpoints.items()):
|
||||
docs.append(f"## {tag.title()}")
|
||||
docs.append("")
|
||||
|
||||
for endpoint in sorted(endpoints, key=lambda x: (x["path"], x["method"])):
|
||||
docs.extend(format_endpoint(endpoint))
|
||||
|
||||
docs.append("")
|
||||
|
||||
# Add untagged endpoints if any
|
||||
if untagged_endpoints:
|
||||
docs.append("## Other Endpoints")
|
||||
docs.append("")
|
||||
|
||||
for endpoint in sorted(untagged_endpoints, key=lambda x: (x["path"], x["method"])):
|
||||
docs.extend(format_endpoint(endpoint))
|
||||
|
||||
docs.append("")
|
||||
|
||||
return "\n".join(docs)
|
||||
|
||||
|
||||
def format_endpoint(endpoint: dict[str, Any]) -> list[str]:
|
||||
"""Format a single endpoint for documentation."""
|
||||
path = endpoint["path"]
|
||||
method = endpoint["method"]
|
||||
details = endpoint["details"]
|
||||
|
||||
docs = []
|
||||
|
||||
# Endpoint header
|
||||
summary = details.get("summary", "")
|
||||
docs.append(f"### `{method} {path}`")
|
||||
docs.append("")
|
||||
|
||||
if summary:
|
||||
docs.append(summary)
|
||||
docs.append("")
|
||||
|
||||
# Description
|
||||
description = details.get("description", "")
|
||||
if description and description != summary:
|
||||
docs.append(description)
|
||||
docs.append("")
|
||||
|
||||
# Parameters
|
||||
parameters = details.get("parameters", [])
|
||||
if parameters:
|
||||
docs.append("**Parameters:**")
|
||||
docs.append("")
|
||||
for param in parameters:
|
||||
docs.append(format_parameter(param))
|
||||
docs.append("")
|
||||
|
||||
# Request body
|
||||
request_body = details.get("requestBody", {})
|
||||
if request_body:
|
||||
body_docs = format_request_body(request_body)
|
||||
if body_docs:
|
||||
docs.append(body_docs)
|
||||
docs.append("")
|
||||
|
||||
# Response summary (simplified)
|
||||
responses = details.get("responses", {})
|
||||
if responses:
|
||||
success_responses = [code for code in responses.keys() if code.startswith("2")]
|
||||
if success_responses:
|
||||
docs.append(f"**Returns:** {', '.join(success_responses)} on success")
|
||||
docs.append("")
|
||||
|
||||
docs.append("---")
|
||||
docs.append("")
|
||||
|
||||
return docs
|
||||
|
||||
|
||||
def generate_markdown_docs(spec: dict[str, Any]) -> str:
|
||||
"""Generate complete markdown documentation."""
|
||||
info = spec.get("info", {})
|
||||
title = info.get("title", "API Documentation")
|
||||
version = info.get("version", "Unknown")
|
||||
description = info.get("description", "")
|
||||
|
||||
docs = [
|
||||
f"# {title}",
|
||||
"",
|
||||
f"**Version:** {version}",
|
||||
"",
|
||||
]
|
||||
|
||||
if description:
|
||||
docs.extend([description, ""])
|
||||
|
||||
docs.extend(["This documentation is automatically generated from the OpenAPI specification.", "", "---", ""])
|
||||
|
||||
# Add endpoints
|
||||
paths = spec.get("paths", {})
|
||||
if paths:
|
||||
docs.append(generate_endpoint_docs(paths))
|
||||
|
||||
return "\n".join(docs)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to generate API documentation."""
|
||||
print("Extracting OpenAPI specification...")
|
||||
spec = extract_openapi_spec()
|
||||
|
||||
print("Generating markdown documentation...")
|
||||
markdown_content = generate_markdown_docs(spec)
|
||||
|
||||
# Ensure docs directory exists (repo root)
|
||||
docs_dir = Path(__file__).parent.parent.parent / "docs"
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write main API documentation
|
||||
api_docs_file = docs_dir / "api.md"
|
||||
with open(api_docs_file, "w", encoding="utf-8") as f:
|
||||
f.write(markdown_content)
|
||||
|
||||
print(f"API documentation generated: {api_docs_file}")
|
||||
|
||||
# Also save the raw OpenAPI spec for reference
|
||||
openapi_file = docs_dir / "openapi.json"
|
||||
with open(openapi_file, "w", encoding="utf-8") as f:
|
||||
json.dump(spec, f, indent=2)
|
||||
|
||||
print(f"OpenAPI specification saved: {openapi_file}")
|
||||
print("Documentation generation complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'infra/docker/python_base/**'
|
||||
- 'projects/InspectAssembly/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -91,29 +90,6 @@ jobs:
|
||||
cache-from: type=gha,scope=python-base-prod-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=python-base-prod-${{ matrix.arch }}
|
||||
|
||||
# InspectAssembly Base Image
|
||||
- name: Extract metadata for InspectAssembly image
|
||||
id: meta-inspect-assembly
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/inspect-assembly
|
||||
tags: |
|
||||
type=sha,format=short,suffix=-${{ matrix.arch }}
|
||||
type=ref,event=branch,suffix=-${{ matrix.arch }}
|
||||
type=raw,value=latest-${{ matrix.arch }},enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push InspectAssembly image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./projects/InspectAssembly
|
||||
file: ./projects/InspectAssembly/Dockerfile
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ steps.meta-inspect-assembly.outputs.tags }}
|
||||
labels: ${{ steps.meta-inspect-assembly.outputs.labels }}
|
||||
cache-from: type=gha,scope=inspect-assembly-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=inspect-assembly-${{ matrix.arch }}
|
||||
|
||||
# Create multi-arch manifests
|
||||
create-manifests:
|
||||
needs: build-base-images
|
||||
@@ -175,26 +151,3 @@ jobs:
|
||||
"${tag}-arm64"
|
||||
fi
|
||||
done
|
||||
|
||||
# InspectAssembly Manifest
|
||||
- name: Extract metadata for InspectAssembly manifest
|
||||
id: meta-inspect-assembly
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/inspect-assembly
|
||||
tags: |
|
||||
type=sha,format=short
|
||||
type=ref,event=branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Create and push InspectAssembly multi-arch manifest
|
||||
run: |
|
||||
echo '${{ steps.meta-inspect-assembly.outputs.tags }}' | while IFS= read -r tag; do
|
||||
if [ -n "$tag" ]; then
|
||||
echo "Creating manifest for: $tag"
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
"${tag}-amd64" \
|
||||
"${tag}-arm64"
|
||||
fi
|
||||
done
|
||||
@@ -24,36 +24,36 @@ jobs:
|
||||
matrix:
|
||||
runner: [ubuntu-22.04, ubuntu-22.04-arm]
|
||||
service:
|
||||
- name: web-api
|
||||
- name: agents
|
||||
context: .
|
||||
dockerfile: ./projects/web_api/Dockerfile
|
||||
- name: dotnet-api
|
||||
dockerfile: ./projects/agents/Dockerfile
|
||||
- name: alerting
|
||||
context: .
|
||||
dockerfile: ./projects/dotnet_api/Dockerfile
|
||||
dockerfile: ./projects/alerting/Dockerfile
|
||||
- name: cli
|
||||
context: .
|
||||
dockerfile: ./projects/cli/Dockerfile
|
||||
- name: document-conversion
|
||||
context: .
|
||||
dockerfile: ./projects/document_conversion/Dockerfile
|
||||
- name: dotnet-service
|
||||
context: .
|
||||
dockerfile: ./projects/dotnet_service/Dockerfile
|
||||
- name: file-enrichment
|
||||
context: .
|
||||
dockerfile: ./projects/file_enrichment/Dockerfile
|
||||
- name: frontend
|
||||
context: ./projects/frontend
|
||||
dockerfile: ./projects/frontend/Dockerfile
|
||||
- name: jupyter
|
||||
context: ./projects/jupyter
|
||||
dockerfile: ./projects/jupyter/Dockerfile
|
||||
- name: alerting
|
||||
context: .
|
||||
dockerfile: ./projects/alerting/Dockerfile
|
||||
- name: triage
|
||||
context: .
|
||||
dockerfile: ./projects/triage/Dockerfile
|
||||
- name: cli
|
||||
context: .
|
||||
dockerfile: ./projects/cli/Dockerfile
|
||||
- name: housekeeping
|
||||
context: .
|
||||
dockerfile: ./projects/housekeeping/Dockerfile
|
||||
- name: document-conversion
|
||||
- name: jupyter
|
||||
context: ./projects/jupyter
|
||||
dockerfile: ./projects/jupyter/Dockerfile
|
||||
- name: web-api
|
||||
context: .
|
||||
dockerfile: ./projects/document_conversion/Dockerfile
|
||||
dockerfile: ./projects/web_api/Dockerfile
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Generate API Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'projects/web_api/**'
|
||||
- '.github/workflows/generate-api-docs.yml'
|
||||
- '.github/scripts/generate_api_docs.py'
|
||||
workflow_dispatch: # For manual triggering
|
||||
|
||||
jobs:
|
||||
generate-docs:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python virtualenv
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
python -m venv env
|
||||
source env/bin/activate
|
||||
cd projects/web_api
|
||||
pip install poetry
|
||||
poetry install --only main
|
||||
|
||||
- name: Generate API documentation
|
||||
run: |
|
||||
source env/bin/activate
|
||||
cd projects/web_api
|
||||
poetry run python ../../.github/scripts/generate_api_docs.py
|
||||
|
||||
- name: Commit documentation changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'docs: update API documentation [skip ci]'
|
||||
file_pattern: 'docs/api/*'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,4 +1,5 @@
|
||||
.DS_STORE
|
||||
version.json
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -175,3 +176,9 @@ cython_debug/
|
||||
old/
|
||||
|
||||
|
||||
projects/dotnet_service/obj/
|
||||
projects/dotnet_service/bin/
|
||||
|
||||
projects/jupyter/notebooks/*.csv
|
||||
|
||||
projects/frontend/public/env.js
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"path": "../infra",
|
||||
"name": "infra"
|
||||
},
|
||||
{
|
||||
"path": "../libs/chromium",
|
||||
"name": "libs → chromium"
|
||||
},
|
||||
{
|
||||
"path": "../libs/common",
|
||||
"name": "libs → common"
|
||||
@@ -20,6 +24,18 @@
|
||||
"path": "../libs/file_enrichment_modules",
|
||||
"name": "libs → file_enrichment_modules",
|
||||
},
|
||||
{
|
||||
"path": "../libs/file_linking",
|
||||
"name": "libs → file_linking"
|
||||
},
|
||||
{
|
||||
"path": "../libs/nemesis_dpapi",
|
||||
"name": "libs → nemesis_dpapi"
|
||||
},
|
||||
{
|
||||
"path": "../projects/agents",
|
||||
"name": "projects → agents"
|
||||
},
|
||||
{
|
||||
"path": "../projects/alerting",
|
||||
"name": "projects → alerting"
|
||||
@@ -56,10 +72,6 @@
|
||||
"path": "../projects/noseyparker_scanner",
|
||||
"name": "projects → noseyparker_scanner"
|
||||
},
|
||||
{
|
||||
"path": "../projects/triage",
|
||||
"name": "projects → triage"
|
||||
},
|
||||
{
|
||||
"path": "../projects/web_api",
|
||||
"name": "projects → web_api"
|
||||
@@ -72,6 +84,8 @@
|
||||
"settings": {
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/.benchmarks": true,
|
||||
"**/.ruff_cache": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/.git": true,
|
||||
"**/.hg": true,
|
||||
@@ -81,16 +95,9 @@
|
||||
"**/.venv": true,
|
||||
"**/CVS": true,
|
||||
"**/Thumbs.db": true,
|
||||
"projects/frontend/node_modules": true,
|
||||
"projects/frontend/dist": true
|
||||
},
|
||||
"[python]": {
|
||||
"diffEditor.ignoreTrimWhitespace": false,
|
||||
"editor.wordBasedSuggestions": "off",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"python.defaultInterpreterPath": "python",
|
||||
// "python.pixiToolPath": "",
|
||||
// "python.pipenvPath": "",
|
||||
},
|
||||
"extensions": {
|
||||
"recommendations": [
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
// "editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
// "source.fixAll": "explicit",
|
||||
// "source.organizeImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
// "editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"autoDocstring.docstringFormat": "google",
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
@@ -4,6 +4,207 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [2.1.4]
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-decryption of Chromium and DPAPI related data:
|
||||
- Cookie and Login Data(saved passwords) DPAPI value decryption
|
||||
- Added Chromium UI page to display Cookie/Login Data
|
||||
- CNG/Chromekey file enrichment module (parser + decryptor)
|
||||
- Chromium ABE v3 decryption (via decrypted CNG keys)
|
||||
- nemesis_dpapi support library:
|
||||
- Added Postgres support for DPAPI backend
|
||||
- Dapr pubsub integration for DPAPI-related event broadcasting
|
||||
- Added uniqueness and write constraints to prevent duplicate master/backup keys
|
||||
- Can now differentiate between user/system masterkeys
|
||||
- Added docs, examples, tests, and decryption benchmarks
|
||||
- Added support for v3 masterkeys decrypted with backup key
|
||||
- New file enrichment modules:
|
||||
- `dpapi_masterkey` - Extracts encrypted masterkeys from user/system DPAPI masterkey files and decrypts them, if possible.
|
||||
- `exif_metadata` file enrichment module for supported image files
|
||||
- Added `cng_file`
|
||||
- Added support for async code in Dapr activities used in enrichment modules
|
||||
- Findings Page: Modified the Severities filter button to use checkboxes
|
||||
- Multi-language Tika OCR support (`TIKA_OCR_LANGUAGES` ENV var, see `compose.yaml`)
|
||||
- Text translation agent
|
||||
- Retroactive DPAPI decryption:
|
||||
- Chromium Local State files when plaintext masterkeys are submitted/decrypted
|
||||
- Google Chromekeys (CNG file based), including decrypting applicable Local State files
|
||||
- Chromium Cookies/Login Data files
|
||||
- File linking:
|
||||
- Enchanced file linking with placeholders in the path that resolve once a matching file is collected.
|
||||
- File Viewer: Added ability to delete file linkings from the FileViewer
|
||||
- File Browser: Added the collection reason and "Linked to by" fields on the "Files that need collection" option
|
||||
- CLI: add a `--folder` option to the submit command that allows you to specify the path to the root folder of uploaded files.
|
||||
- Reporting functionality
|
||||
- SYSTEM wide and per SOURCE
|
||||
- API endpoints for statistic and PDF generation (via Gotenberg conversion)
|
||||
- Reporting summarization agent
|
||||
|
||||
### Changed
|
||||
|
||||
- Now use DPAPIck3 for blob decryption
|
||||
- Bumped Dapr version to 1.16.1
|
||||
- DPAPI_SYSTEM key pulled from registry parsing is now registered with the backend
|
||||
- Updated category filters for frontend
|
||||
- Collapsed inbound/outbound labels for linked files in dashboard
|
||||
- Update Prometheus endpoints
|
||||
- Chromium Local State, Cookies, and Login Data files now don't require hard paths
|
||||
- Reg hive file linkings now done programmatically instead of via rules
|
||||
- SYSTEM masterkey file linkings now done programmatically instead of via rules
|
||||
- Added more details to errors that cause a workflow to die
|
||||
- Optimized DPAPI masterkey decryption based on the type of masterkey
|
||||
- Converted several DB calls to async code
|
||||
- Optimized the housekeeping code to run in parallel and use transactions (where possible)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Properly use entropy for DPAPI blob decryption
|
||||
- Lots of async issues
|
||||
- Fixed tag search issue
|
||||
- Fix to keep strings.txt from sqlite dbs from processing
|
||||
- Path normalization: Fixed many bugs, removed lots of duplicate normalization, standardized normalization upon initial ingestion.
|
||||
- Countless linting and other fixes
|
||||
|
||||
|
||||
## [2.1.3]
|
||||
|
||||
### Added
|
||||
|
||||
- "Chromium" page in the web frontend to display history, downloads, cookies, logins, and state keys
|
||||
- Includes filtering + CSV downloads for displayed data
|
||||
- "File Browser" in the web frontend
|
||||
- "DPAPI" viewer/submission pages in the web frontend
|
||||
- Linked file tracking (via the ./libs/file_linking/ library)
|
||||
- Linked files exposed in the "File Browser" frontend and file viewer pages
|
||||
- Adaptation of @Dreadnode's .NET reversing agent to `agents`
|
||||
- "chromium" standard library in ./libs/ for parsing Chromium based files
|
||||
- "nemesis_dpapi" library in ./libs/ for handling DPAPI related data/decryption
|
||||
- Includes in-memory storage of keys as well as postgres
|
||||
- Allows subscriptions to react to DPAPI-related events (e.g. new backup key, new plaintext masterkey, etc.)
|
||||
- `registry_hive` parsing module that extracts bootkeys + local accounts + lsa secrets from linked hives
|
||||
- Refactored file enrichment web API code to be more modular
|
||||
- API route to submit DPAPI credential material
|
||||
- Auto-building API documents for ./docs/api.md from the FastAPI routes in `web_api` container
|
||||
- Documentation for "Containers" and LLM functionality
|
||||
- Changed default FileList view to All Files, added unviewed indicator dot, and change search default to always include wildcards.
|
||||
|
||||
### Changed
|
||||
|
||||
- Standardize logging + fix suppressed logs
|
||||
- DPAPI keys carved from LSASS dumps now saved via the nemesis_dpapi library
|
||||
|
||||
### Fixed
|
||||
|
||||
- Frontend live file reload
|
||||
- `certificate` and `keytab` file enrichment modules
|
||||
|
||||
|
||||
## [2.1.2] - 2025-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- Ability to drag/drop folders onto the file upload page
|
||||
- basic `triage` container greatly expanded to `agents`
|
||||
- JWT + finding validator agents implemented
|
||||
- Generalized/expandable agent infrastructure built
|
||||
- Confidence score, explanation, and risk detail returned by finding triage
|
||||
- "triage consensus" added for multiple triage values for the same file
|
||||
- Tracing for `agents` added with Arize Phoenix (/phoenix, if --monitoring is enabled)
|
||||
- Token costs pulled from LiteLLM instance and manually synced to Phoenix for cost tracking
|
||||
- Triage details added to finding table entries and findings modal
|
||||
- Settings frontend page now has commit/build date/etc. info and Slack alert channel info (if configured)
|
||||
- Repeat option added to submit script
|
||||
- Conditionally shown "Agents" page in the frontend that shows current agents and token spend stats
|
||||
- Also allows for editing Agent prompts in the UI
|
||||
|
||||
### Changed
|
||||
|
||||
- "explanation" field added to the findings_triage_history table in the schema
|
||||
- Help page in frontend only shows routes for services that are enabled
|
||||
- Logs suppressed during Dapr replay (only show on first run)
|
||||
- Removed loud FastAPI tracer
|
||||
- Enrichment modules rolled into one activity (no longer each their own) for optimization
|
||||
- Also means a reduction in file download actions for enrichment modules - modules modified to support this
|
||||
- LLM credential analysis and text summarization enrichment modules ported to `agents`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Markdown escape for displayed extracted hashes
|
||||
- Fixed PE parsing not throwing an exception
|
||||
- Fixed runtime deprecation warning
|
||||
- Maintain references to various asyncio tasks
|
||||
|
||||
|
||||
## [2.1.1] - 2025-08-01
|
||||
|
||||
### Added
|
||||
|
||||
- LiteLLM server (with "llm" profile in Docker) to serve future LLM integrations
|
||||
- Includes cost limits
|
||||
- Display/linking to originating container for files derived from containers
|
||||
- Support for include/exclude filters for the `/containers` API
|
||||
- Added filter support into `cli` container + submit.sh
|
||||
- Processing for a number of disk image formats
|
||||
- File monitoring for (large) containers copied to MOUNTED_CONTAINER_PATH
|
||||
- Containers have files extracted + processed
|
||||
- Used for workflows with very large containers/disk images
|
||||
- Velociraptor connector (server event .yaml option)
|
||||
- `noseyparker_scanner` now can scan zips and .git repos
|
||||
- Includes relevant match info in results (can be set by ENV vars)
|
||||
|
||||
### Changed
|
||||
|
||||
- Expired containers now cleaned up
|
||||
- Made "timestamp" and "expiration" submission fields optional (filled with defaults)
|
||||
- Bumped Dapr version to 1.15.8
|
||||
- Filtering by URL for containers
|
||||
- Bulk enrichment system now uses pub/sub
|
||||
- `triage` connecter now uses LiteLLM for models via Rigging
|
||||
- Pagination in FileList view for large number of files
|
||||
|
||||
### Fixed
|
||||
|
||||
- "source" field propagation for containers
|
||||
- Container filtering in dashboard
|
||||
- Arguments with value ordering error in submit.sh
|
||||
|
||||
|
||||
## [2.1.0] - 2025-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Retries for submit.py
|
||||
- `3_workflow_performance.ipynb` Jupyter notebook to assess pipeline performance
|
||||
- "source" field (to represent hostname, source site, etc.) integrated into schema + frontend
|
||||
- Start of bulk-enrichment re-rerunning, including re-running Yara rules from the dashboard
|
||||
- New system for large "container" triaging
|
||||
- New `/api/containers` route
|
||||
- Container process tracking system using pub/sub from `file_enrichment` -> `web_api`
|
||||
- Live updating container tracking in the dashboard (new "Containers" tab)
|
||||
- submit.sh/monitor.sh scripts now can submit "containers"
|
||||
- Internal queues now cleaned on up system delete/reset
|
||||
- PostgreSQL NOTIFY/LISTEN system for `file_enrichment` workers
|
||||
|
||||
### Changed
|
||||
|
||||
- Timeouts/improved submit logic for the web_api
|
||||
- Bumped Dapr version to 1.15.6
|
||||
- Combined `dotnet_api` and `InspectAssembly` into single, streamlined pure .NET `dotnet_service` container
|
||||
- Eliminated the internal file-enrichment queue
|
||||
- Now relies on the Dapr pub/sub queue (RabbitMQ) to provide backpressure
|
||||
- Stale workflows periodically cleaned up
|
||||
|
||||
### Fixed
|
||||
|
||||
- Limits/concurrency fixes for NoseyParker scanner to prevent OOM errors
|
||||
- Implemented queue/workflow persistence
|
||||
- RabbitMQ queues now restored properly even if containers are completely removed
|
||||
- In-flight workflows re-submitted for processing
|
||||
|
||||
|
||||
## [2.0.1]
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -13,13 +13,3 @@ services:
|
||||
build:
|
||||
context: ./infra/docker/python_base
|
||||
dockerfile: prod.Dockerfile
|
||||
|
||||
# Long term, this shouldn't be with the base image.
|
||||
# We should publish the build artifact and use that
|
||||
# in the final image (instead of copying from this one).
|
||||
# Until then. we'll keep it here so we don't have to
|
||||
# rebuild it each time we rebuild just the services.
|
||||
inspect-assembly:
|
||||
build:
|
||||
context: ./projects/InspectAssembly/
|
||||
dockerfile: Dockerfile
|
||||
@@ -14,6 +14,8 @@ services:
|
||||
- ./libs/:/src/libs/
|
||||
- ./projects/web_api/web_api:/src/projects/web_api/web_api
|
||||
- /src/projects/web_api/.venv
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
|
||||
|
||||
noseyparker-scanner:
|
||||
image: !reset null
|
||||
@@ -24,16 +26,14 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=debug
|
||||
|
||||
dotnet-api:
|
||||
dotnet-service:
|
||||
image: !reset null
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./projects/dotnet_api/Dockerfile
|
||||
dockerfile: ./projects/dotnet_service/Dockerfile
|
||||
target: dev
|
||||
volumes:
|
||||
- ./libs/:/src/libs/
|
||||
- ./projects/dotnet_api/dotnet_api:/src/projects/dotnet_api/dotnet_api
|
||||
- /src/projects/dotnet_api/.venv
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
|
||||
file-enrichment:
|
||||
image: !reset null
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
dockerfile: ./projects/file_enrichment/Dockerfile
|
||||
target: dev
|
||||
environment:
|
||||
- LOG_LEVEL=DEBUG
|
||||
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
|
||||
volumes:
|
||||
- ./libs/:/src/libs/
|
||||
- ./projects/file_enrichment/file_enrichment:/src/projects/file_enrichment/file_enrichment
|
||||
@@ -57,19 +57,19 @@ services:
|
||||
target: dev
|
||||
command: >
|
||||
sh -c "
|
||||
mkdir -p /app-runtime &&
|
||||
cp -r /app/* /app-runtime/ &&
|
||||
if [ -f /version.json ]; then cp /version.json /app/public/version.json; fi &&
|
||||
SECRET=\"$$HASURA_ADMIN_SECRET\" &&
|
||||
sed -i \"s/\\$$HASURA_ADMIN_SECRET/$$SECRET/g\" /app-runtime/index.html &&
|
||||
cd /app-runtime &&
|
||||
echo \"window.ENV = { HASURA_ADMIN_SECRET: '$$SECRET' };\" > /app/public/env.js &&
|
||||
cd /app &&
|
||||
npm run dev
|
||||
"
|
||||
volumes:
|
||||
- ./projects/frontend/index.html:/app/index.html:ro
|
||||
- ./projects/frontend/package.json:/app/package.json:ro
|
||||
- ./projects/frontend/public:/app/public:ro
|
||||
- ./projects/frontend/public:/app/public
|
||||
- ./projects/frontend/src:/app/src:ro
|
||||
- ./projects/frontend/vite.config.js:/app/vite.config.js:ro
|
||||
- ./version.json:/version.json:ro
|
||||
labels:
|
||||
- "traefik.http.services.frontend.loadbalancer.server.port=3000"
|
||||
|
||||
@@ -88,13 +88,21 @@ services:
|
||||
context: .
|
||||
dockerfile: ./projects/alerting/Dockerfile
|
||||
target: dev
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
|
||||
|
||||
triage:
|
||||
agents:
|
||||
image: !reset null
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./projects/triage/Dockerfile
|
||||
dockerfile: ./projects/agents/Dockerfile
|
||||
target: dev
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
|
||||
volumes:
|
||||
- ./libs/:/src/libs/
|
||||
- ./projects/agents/agents:/src/projects/agents/agents
|
||||
- /src/projects/agents/.venv
|
||||
|
||||
housekeeping:
|
||||
image: !reset null
|
||||
@@ -108,6 +116,7 @@ services:
|
||||
- /src/projects/housekeeping/.venv
|
||||
environment:
|
||||
- CLEANUP_SCHEDULE=*/3 * * * * # Test every 3 minutes
|
||||
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
|
||||
|
||||
document-conversion:
|
||||
image: !reset null
|
||||
@@ -115,7 +124,16 @@ services:
|
||||
context: .
|
||||
dockerfile: ./projects/document_conversion/Dockerfile
|
||||
target: dev
|
||||
args:
|
||||
- TIKA_OCR_LANGUAGES=${TIKA_OCR_LANGUAGES:-eng}
|
||||
volumes:
|
||||
- ./libs/:/src/libs/
|
||||
- ./projects/document_conversion/document_conversion:/src/projects/document_conversion/document_conversion
|
||||
- /src/projects/document_conversion/.venv
|
||||
- /src/projects/document_conversion/.venv
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-DEBUG}
|
||||
|
||||
postgres:
|
||||
# expose the port locally
|
||||
ports:
|
||||
- "5432:5432"
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
dockerfile: ./projects/noseyparker_scanner/Dockerfile
|
||||
target: prod
|
||||
|
||||
dotnet-api:
|
||||
dotnet-service:
|
||||
image: !reset null
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./projects/dotnet_api/Dockerfile
|
||||
dockerfile: ./projects/dotnet_service/Dockerfile
|
||||
target: prod
|
||||
|
||||
file-enrichment:
|
||||
@@ -59,11 +59,11 @@ services:
|
||||
dockerfile: ./projects/alerting/Dockerfile
|
||||
target: prod
|
||||
|
||||
triage:
|
||||
agents:
|
||||
image: !reset null
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./projects/triage/Dockerfile
|
||||
dockerfile: ./projects/agents/Dockerfile
|
||||
target: prod
|
||||
|
||||
housekeeping:
|
||||
|
||||
@@ -9,10 +9,12 @@ volumes:
|
||||
jaeger_data:
|
||||
loki_data:
|
||||
minio_data:
|
||||
phoenix_data:
|
||||
postgres_data:
|
||||
prometheus_data:
|
||||
rabbitmq_data:
|
||||
empty:
|
||||
empty-mounted-containers:
|
||||
|
||||
services:
|
||||
########################################
|
||||
@@ -30,6 +32,9 @@ services:
|
||||
- APP_ID=web-api
|
||||
- DAPR_GRPC_PORT=50001
|
||||
- DAPR_HTTP_PORT=3500
|
||||
- DEFAULT_EXPIRATION_DAYS=${DEFAULT_EXPIRATION_DAYS:-100}
|
||||
volumes:
|
||||
- ${MOUNTED_CONTAINER_PATH:-empty-mounted-containers}:/mounted-containers
|
||||
logging: &logging-config
|
||||
driver: "json-file"
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
@@ -45,14 +50,14 @@ services:
|
||||
- "traefik.http.routers.web-api.tls=true"
|
||||
- "traefik.http.routers.web-api.middlewares=auth"
|
||||
web-api-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--app-id",
|
||||
"web-api",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-port",
|
||||
"8000",
|
||||
"--dapr-http-port",
|
||||
@@ -67,6 +72,9 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/components/pubsub.yaml:/dapr/components/pubsub.yaml:ro
|
||||
@@ -76,6 +84,14 @@ services:
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:?}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:?}
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
- POSTGRES_USER=${POSTGRES_USER:?}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-enrichment}
|
||||
- POSTGRES_PARAMETERS=${POSTGRES_PARAMETERS:-sslmode=disable}
|
||||
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD:?}
|
||||
- RABBITMQ_USER=${RABBITMQ_USER:?}
|
||||
depends_on:
|
||||
web-api: { condition: service_started }
|
||||
placement: { condition: service_started }
|
||||
@@ -96,16 +112,20 @@ services:
|
||||
- MINIO_ENDPOINT=http://minio:9000
|
||||
- MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:?}
|
||||
- RUST_LOG=info
|
||||
- SNIPPET_LENGTH=512
|
||||
- SNIPPET_LENGTH=512 # context length around any Nosey Parker matches
|
||||
- MAX_CONCURRENT_FILES=2 # maximum number of concurrent files to scan
|
||||
- MAX_FILE_SIZE_MB=200 # maximum file size to scan
|
||||
- DECOMPRESS_ZIPS=true # whether to decompress+scan zips
|
||||
- MAX_EXTRACT_SIZE_MB=1000 # maximum number of bytes to extract if decompressing
|
||||
volumes:
|
||||
- ./projects/noseyparker_scanner/custom_rules/:/opt/noseyparker:ro
|
||||
noseyparker-scanner-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"noseyparker-scanner",
|
||||
"--app-port",
|
||||
@@ -124,6 +144,9 @@ services:
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--app-max-concurrency",
|
||||
"1",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
environment:
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
@@ -134,60 +157,64 @@ services:
|
||||
depends_on: [noseyparker-scanner, placement]
|
||||
network_mode: "service:noseyparker-scanner"
|
||||
|
||||
dotnet-api:
|
||||
image: ghcr.io/specterops/nemesis/dotnet-api:latest
|
||||
dotnet-service:
|
||||
image: ghcr.io/specterops/nemesis/dotnet-service:latest
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:1337/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
environment:
|
||||
- APP_ID=dotnet-api
|
||||
- DAPR_GRPC_PORT=50010
|
||||
- DAPR_HTTP_PORT=3507
|
||||
logging: *logging-config
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
postgres: { condition: service_started }
|
||||
placement: { condition: service_started }
|
||||
minio: { condition: service_healthy }
|
||||
rabbitmq: { condition: service_healthy }
|
||||
dotnet-api-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
environment:
|
||||
- DAPR_GRPC_PORT=50014
|
||||
- DAPR_PORT=3514
|
||||
- MINIO_ACCESS_KEY=${MINIO_ROOT_USER:?}
|
||||
- MINIO_BUCKET=files
|
||||
- MINIO_ENDPOINT=http://minio:9000
|
||||
- MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:?}
|
||||
- ASPNETCORE_URLS=http://0.0.0.0:5000
|
||||
expose:
|
||||
- "5000"
|
||||
dotnet-service-dapr:
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"dotnet-api",
|
||||
"dotnet-service",
|
||||
"--app-port",
|
||||
"1337",
|
||||
"5000",
|
||||
"--app-protocol",
|
||||
"http",
|
||||
"--dapr-http-port",
|
||||
"3507",
|
||||
"3514",
|
||||
"--dapr-grpc-port",
|
||||
"50010",
|
||||
"50014",
|
||||
"--placement-host-address",
|
||||
"placement:50006",
|
||||
"--scheduler-host-address",
|
||||
"scheduler:50007",
|
||||
"--resources-path",
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--app-max-concurrency",
|
||||
"1",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
environment:
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
volumes:
|
||||
- ./infra/dapr/components/pubsub.yaml:/dapr/components/pubsub.yaml:ro
|
||||
- ./infra/dapr/components/secretstore.yaml:/dapr/components/secretstore.yaml:ro
|
||||
- ./infra/dapr/configuration/config_monitoring_${NEMESIS_MONITORING:-disabled}.yaml:/dapr/configuration/config.yaml:ro
|
||||
environment:
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:?}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:?}
|
||||
- POSTGRES_CONNECTION_STRING=host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=enrichment port=5432 sslmode=disable
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
depends_on:
|
||||
dotnet-api: { condition: service_started }
|
||||
placement: { condition: service_started }
|
||||
scheduler: { condition: service_started }
|
||||
rabbitmq: { condition: service_healthy }
|
||||
network_mode: "service:dotnet-api"
|
||||
depends_on: [dotnet-service, placement]
|
||||
network_mode: "service:dotnet-service"
|
||||
|
||||
file-enrichment:
|
||||
image: ghcr.io/specterops/nemesis/file-enrichment:latest
|
||||
@@ -203,30 +230,31 @@ services:
|
||||
- APP_ID=file-enrichment
|
||||
- DAPR_GRPC_PORT=50003
|
||||
- DAPR_HTTP_PORT=3503
|
||||
- LOG_LEVEL=INFO
|
||||
- MAX_PARALLEL_ENRICHMENT_MODULES=${MAX_PARALLEL_ENRICHMENT_MODULES:-5}
|
||||
- MAX_PARALLEL_WORKFLOWS=${MAX_PARALLEL_WORKFLOWS:-5}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MAX_WORKFLOW_EXECUTION_TIME=${MAX_WORKFLOW_EXECUTION_TIME:-300}
|
||||
- NEMESIS_MONITORING=${NEMESIS_MONITORING:-disabled}
|
||||
- NEMESIS_URL=${NEMESIS_URL:?}
|
||||
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
|
||||
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_INSECURE=true
|
||||
- PYTHONASYNCIOTHREADPOOLSIZE=100 # Increase thread pool for asyncio.to_thread()
|
||||
- RIGGING_GENERATOR_CREDENTIALS=${RIGGING_GENERATOR_CREDENTIALS:-}
|
||||
- RIGGING_GENERATOR_SUMMARY=${RIGGING_GENERATOR_SUMMARY:-}
|
||||
- RIGGING_GENERATOR_TRIAGE=${RIGGING_GENERATOR_TRIAGE:-}
|
||||
- UVICORN_WORKERS=${UVICORN_WORKERS:-2}
|
||||
- UVICORN_WORKERS=1 # Reduce to 1 worker to avoid cross-process contention
|
||||
- WORKFLOW_RUNTIME_LOG_LEVEL=${WORKFLOW_RUNTIME_LOG_LEVEL:-WARNING}
|
||||
- WORKFLOW_CLIENT_LOG_LEVEL=${WORKFLOW_CLIENT_LOG_LEVEL:-WARNING}
|
||||
logging: *logging-config
|
||||
depends_on:
|
||||
postgres: { condition: service_started }
|
||||
placement: { condition: service_started }
|
||||
rabbitmq: { condition: service_healthy }
|
||||
file-enrichment-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"file-enrichment",
|
||||
"--app-port",
|
||||
@@ -243,6 +271,9 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/components/pubsub.yaml:/dapr/components/pubsub.yaml:ro
|
||||
@@ -250,7 +281,12 @@ services:
|
||||
- ./infra/dapr/components/workflowstate.yaml:/dapr/components/workflowstate.yaml:ro
|
||||
- ./infra/dapr/configuration/config_monitoring_${NEMESIS_MONITORING:-disabled}.yaml:/dapr/configuration/config.yaml:ro
|
||||
environment:
|
||||
- POSTGRES_CONNECTION_STRING=host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=enrichment port=5432 sslmode=disable
|
||||
- POSTGRES_USER=${POSTGRES_USER:?}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-enrichment}
|
||||
- POSTGRES_PARAMETERS=${POSTGRES_PARAMETERS:-sslmode=disable}
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:?}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:?}
|
||||
@@ -296,7 +332,7 @@ services:
|
||||
volumes:
|
||||
- "./projects/jupyter/notebooks:/home/jovyan/work"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/jupyter/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/jupyter/api"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -328,12 +364,12 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
alerting-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"alerting",
|
||||
"--app-port",
|
||||
@@ -350,6 +386,9 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/components/pubsub.yaml:/dapr/components/pubsub.yaml:ro
|
||||
@@ -365,13 +404,28 @@ services:
|
||||
rabbitmq: { condition: service_healthy }
|
||||
network_mode: "service:alerting"
|
||||
|
||||
triage:
|
||||
image: ghcr.io/specterops/nemesis/triage:latest
|
||||
agents:
|
||||
image: ghcr.io/specterops/nemesis/agents:latest
|
||||
environment:
|
||||
- DAPR_HTTP_PORT=3509
|
||||
- DAPR_GRPC_PORT=50009
|
||||
- NEMESIS_URL=${NEMESIS_URL:?}
|
||||
- RIGGING_GENERATOR_TRIAGE=${RIGGING_GENERATOR_TRIAGE:-}
|
||||
- WORKFLOW_RUNTIME_LOG_LEVEL=${WORKFLOW_RUNTIME_LOG_LEVEL:-WARNING}
|
||||
- WORKFLOW_CLIENT_LOG_LEVEL=${WORKFLOW_CLIENT_LOG_LEVEL:-WARNING}
|
||||
- LITELLM_ADMIN_KEY=sk-${LITELLM_ADMIN_PASSWORD:-admin123}
|
||||
- MAX_BUDGET=${LLM_MAX_BUDGET:-100}
|
||||
- BUDGET_DURATION=${LLM_BUDGET_DURATION:-30d}
|
||||
- DOTNET_ANALYSIS_RUN_REQUEST_LIMIT=${DOTNET_ANALYSIS_RUN_REQUEST_LIMIT:-25} # max LLM calls to use per .NET program analysis run
|
||||
- DOTNET_ANALYSIS_RUN_TOKENS_LIMIT=${DOTNET_ANALYSIS_RUN_TOKENS_LIMIT:-1000000} # max tokens to use per .NET program analysis run
|
||||
# if we hit this number of the same triage values for the same file, all future findings get that value
|
||||
- TRIAGE_CONSENSUS_THRESHOLD=${TRIAGE_CONSENSUS_THRESHOLD:-3}
|
||||
# Phoenix LLM tracing configuration
|
||||
- PHOENIX_ENABLED=${PHOENIX_ENABLED:-false}
|
||||
- PHOENIX_ENDPOINT=${PHOENIX_ENDPOINT:-http://phoenix:6006/v1/traces}
|
||||
- PHOENIX_SQL_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/phoenix
|
||||
- NEMESIS_MONITORING=${NEMESIS_MONITORING:-disabled}
|
||||
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
|
||||
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_INSECURE=true
|
||||
logging: *logging-config
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -379,15 +433,15 @@ services:
|
||||
rabbitmq: { condition: service_healthy }
|
||||
hasura: { condition: service_healthy }
|
||||
healthcheck: *healthcheck-python-svc
|
||||
triage-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
agents-dapr:
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"triage",
|
||||
"agents",
|
||||
"--app-port",
|
||||
"8000",
|
||||
"--dapr-http-port",
|
||||
@@ -402,19 +456,31 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/components/secretstore.yaml:/dapr/components/secretstore.yaml:ro
|
||||
- ./infra/dapr/components/workflowstate.yaml:/dapr/components/workflowstate.yaml:ro
|
||||
- ./infra/dapr/configuration/config_monitoring_${NEMESIS_MONITORING:-disabled}.yaml:/dapr/configuration/config.yaml:ro
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:?}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-enrichment}
|
||||
- POSTGRES_PARAMETERS=${POSTGRES_PARAMETERS:-sslmode=disable}
|
||||
- HASURA_ADMIN_SECRET=${HASURA_ADMIN_SECRET:-pass456}
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:?}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:?}
|
||||
depends_on:
|
||||
triage: { condition: service_started }
|
||||
agents: { condition: service_started }
|
||||
placement: { condition: service_started }
|
||||
scheduler: { condition: service_started }
|
||||
rabbitmq: { condition: service_healthy }
|
||||
network_mode: "service:triage"
|
||||
network_mode: "service:agents"
|
||||
|
||||
housekeeping:
|
||||
image: ghcr.io/specterops/nemesis/housekeeping:latest
|
||||
@@ -430,12 +496,12 @@ services:
|
||||
rabbitmq: { condition: service_healthy }
|
||||
healthcheck: *healthcheck-python-svc
|
||||
housekeeping-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"housekeeping",
|
||||
"--app-port",
|
||||
@@ -452,6 +518,9 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/components/secretstore.yaml:/dapr/components/secretstore.yaml:ro
|
||||
@@ -459,7 +528,12 @@ services:
|
||||
environment:
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:?}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:?}
|
||||
- POSTGRES_CONNECTION_STRING=host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=enrichment port=5432 sslmode=disable
|
||||
- POSTGRES_USER=${POSTGRES_USER:?}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-enrichment}
|
||||
- POSTGRES_PARAMETERS=${POSTGRES_PARAMETERS:-sslmode=disable}
|
||||
depends_on:
|
||||
housekeeping: { condition: service_started }
|
||||
placement: { condition: service_started }
|
||||
@@ -476,6 +550,14 @@ services:
|
||||
- DAPR_GRPC_PORT=50002
|
||||
- DAPR_HTTP_PORT=3501
|
||||
- TIKA_CONFIG=/tika-config.xml
|
||||
# If you want to have additional language packs supported (see https://github.com/tesseract-ocr/tessdata for a full list):
|
||||
# $ export TIKA_OCR_LANGUAGES="eng chi_sim chi_tra jpn rus deu spa"
|
||||
# To change the default:
|
||||
# - TIKA_OCR_LANGUAGES=${TIKA_OCR_LANGUAGES:-eng chi_sim chi_tra jpn rus deu spa}
|
||||
# Note: each package installed will increase the image size!
|
||||
- TIKA_OCR_LANGUAGES=${TIKA_OCR_LANGUAGES:-eng}
|
||||
- MAX_PARALLEL_WORKFLOWS=${MAX_PARALLEL_WORKFLOWS:-5}
|
||||
- MAX_WORKFLOW_EXECUTION_TIME=${MAX_WORKFLOW_EXECUTION_TIME:-300}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/healthz"]
|
||||
interval: 10s
|
||||
@@ -487,12 +569,12 @@ services:
|
||||
placement: { condition: service_started }
|
||||
rabbitmq: { condition: service_healthy }
|
||||
document-conversion-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"document-conversion",
|
||||
"--app-port",
|
||||
@@ -509,6 +591,9 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/components/pubsub.yaml:/dapr/components/pubsub.yaml:ro
|
||||
@@ -518,7 +603,12 @@ services:
|
||||
environment:
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:?}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:?}
|
||||
- POSTGRES_CONNECTION_STRING=host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=enrichment port=5432 sslmode=disable
|
||||
- POSTGRES_USER=${POSTGRES_USER:?}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-enrichment}
|
||||
- POSTGRES_PARAMETERS=${POSTGRES_PARAMETERS:-sslmode=disable}
|
||||
- RABBITMQ_CONNECTION_STRING=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
|
||||
depends_on:
|
||||
document-conversion: { condition: service_started }
|
||||
@@ -535,6 +625,7 @@ services:
|
||||
"--api-timeout=180s",
|
||||
"--libreoffice-restart-after=5",
|
||||
"--libreoffice-auto-start=true",
|
||||
"--prometheus-collect-interval=10s",
|
||||
]
|
||||
environment: { DISABLE_GOOGLE_CHROME: "1" }
|
||||
healthcheck:
|
||||
@@ -543,12 +634,12 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
gotenberg-dapr:
|
||||
image: "daprio/daprd:1.15.5"
|
||||
image: "daprio/daprd:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./daprd",
|
||||
"--max-body-size",
|
||||
"300Mi",
|
||||
"1Gi",
|
||||
"--app-id",
|
||||
"gotenberg",
|
||||
"--app-port",
|
||||
@@ -565,6 +656,9 @@ services:
|
||||
"/dapr/components",
|
||||
"--config",
|
||||
"/dapr/configuration/config.yaml",
|
||||
"--enable-metrics",
|
||||
"--dapr-graceful-shutdown-seconds",
|
||||
"5",
|
||||
]
|
||||
volumes:
|
||||
- ./infra/dapr/configuration/config_monitoring_${NEMESIS_MONITORING:-disabled}.yaml:/dapr/configuration/config.yaml:ro
|
||||
@@ -605,18 +699,21 @@ services:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"sleep 1; until mc alias set minio http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do echo 'Minio is not online. Waiting for it to start...'; sleep 2; done && mc mb minio/loki-data --ignore-existing",
|
||||
"sleep 1; until mc alias set minio http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do echo 'Minio is not online. Waiting for it to start...'; sleep 2; done && mc mb minio/loki-data --ignore-existing && mc mb minio/files --ignore-existing",
|
||||
]
|
||||
restart: "no"
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.1.1-management
|
||||
image: rabbitmq:4.1.2-management
|
||||
hostname: rabbitmq-node # have to do this for persistence reasons
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:?}
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:?}
|
||||
- RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=-rabbitmq_management path_prefix "/rabbitmq"
|
||||
- RABBITMQ_NODENAME=rabbit@rabbitmq-node # have to do this for persistence reasons
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
- ./infra/rabbitmq/enabled_plugins:/etc/rabbitmq/enabled_plugins:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
|
||||
interval: 10s
|
||||
@@ -630,7 +727,12 @@ services:
|
||||
- "traefik.http.routers.rabbitmq-ui.rule=PathPrefix(`/rabbitmq`)"
|
||||
|
||||
postgres:
|
||||
image: postgres:17.5-alpine
|
||||
image: postgres:17.6-alpine
|
||||
command: [
|
||||
"postgres",
|
||||
"-c", "max_connections=200",
|
||||
"-c", "shared_buffers=256MB"
|
||||
]
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-enrichment}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
@@ -644,20 +746,30 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres-exporter:
|
||||
profiles: ["monitoring"]
|
||||
image: prometheuscommunity/postgres-exporter:latest
|
||||
environment:
|
||||
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER:?}:${POSTGRES_PASSWORD:?}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-enrichment}?${POSTGRES_PARAMETERS:-sslmode=disable}"
|
||||
volumes:
|
||||
- ./infra/postgres-exporter/postgres_exporter.yml:/postgres_exporter.yml:ro
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
hasura:
|
||||
image: hasura/graphql-engine:v2.45.1.cli-migrations-v2
|
||||
image: hasura/graphql-engine:v2.48.6.cli-migrations-v2
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
environment:
|
||||
HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_ADMIN_SECRET:-pass456}"
|
||||
HASURA_GRAPHQL_BASE_PATH: "/hasura"
|
||||
HASURA_GRAPHQL_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/enrichment
|
||||
HASURA_GRAPHQL_DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-enrichment}
|
||||
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
|
||||
HASURA_GRAPHQL_ENABLE_METADATA_SYNC: "true"
|
||||
HASURA_GRAPHQL_ENABLE_TELEMETRY: "false"
|
||||
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
|
||||
HASURA_GRAPHQL_LOG_LEVEL: "warn"
|
||||
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/enrichment
|
||||
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-enrichment}
|
||||
HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata
|
||||
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous
|
||||
volumes:
|
||||
@@ -672,7 +784,7 @@ services:
|
||||
- "traefik.http.routers.hasura.tls=true"
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.3.2
|
||||
image: traefik:v3.4.4
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
# - "--log.level=DEBUG"
|
||||
@@ -700,18 +812,20 @@ services:
|
||||
- ./auth:/auth:ro
|
||||
- ./infra/traefik/certs:/certs:ro
|
||||
- ./infra/traefik/config:/config:ro
|
||||
|
||||
placement:
|
||||
image: "daprio/dapr:1.15.5"
|
||||
command: ["./placement", "-port", "50006"]
|
||||
image: "daprio/dapr:1.16.1"
|
||||
command: ["./placement", "-port", "50006", "--enable-metrics"]
|
||||
|
||||
scheduler:
|
||||
image: "daprio/dapr:1.15.5"
|
||||
image: "daprio/dapr:1.16.1"
|
||||
command:
|
||||
[
|
||||
"./scheduler",
|
||||
"--port",
|
||||
"50007",
|
||||
"--etcd-data-dir=/var/lock/dapr/scheduler",
|
||||
"--enable-metrics",
|
||||
]
|
||||
|
||||
############################
|
||||
@@ -750,6 +864,35 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
phoenix:
|
||||
profiles: ["monitoring"]
|
||||
image: arizephoenix/phoenix:11.24.1
|
||||
environment:
|
||||
- PHOENIX_WORKING_DIR=/data
|
||||
- PHOENIX_HOST_ROOT_PATH=/phoenix
|
||||
- PHOENIX_SQL_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/phoenix
|
||||
- PHOENIX_DEFAULT_RETENTION_POLICY_DAYS=100
|
||||
volumes:
|
||||
- phoenix_data:/data
|
||||
# ports:
|
||||
# - "6006:6006" # Phoenix UI port, for debugging
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.phoenix.loadbalancer.server.port=6006"
|
||||
- "traefik.http.routers.phoenix.rule=PathPrefix(`/phoenix`)"
|
||||
- "traefik.http.routers.phoenix.entrypoints=websecure"
|
||||
- "traefik.http.routers.phoenix.tls=true"
|
||||
- "traefik.http.routers.phoenix.middlewares=auth,phoenix-stripprefix"
|
||||
- "traefik.http.middlewares.phoenix-stripprefix.stripprefix.prefixes=/phoenix"
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:6006/healthz').read()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
loki:
|
||||
profiles: ["monitoring"]
|
||||
image: grafana/loki:3.3.2
|
||||
@@ -793,6 +936,7 @@ services:
|
||||
- GF_SERVER_DOMAIN=${EXTERNAL_HOST:-https://localhost}
|
||||
- GF_SERVER_ROOT_URL=${EXTERNAL_HOST:-https://localhost}/grafana
|
||||
- GF_SERVER_SERVE_FROM_SUB_PATH=true
|
||||
- POSTGRES_CONNECTION_STRING=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-enrichment}?${POSTGRES_PARAMETERS:-sslmode=disable}
|
||||
volumes:
|
||||
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
@@ -867,3 +1011,42 @@ services:
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /var/run:/var/run:ro
|
||||
devices: ["/dev/kmsg:/dev/kmsg"]
|
||||
|
||||
|
||||
############################
|
||||
# LLM Services (optional profile)
|
||||
############################
|
||||
|
||||
litellm:
|
||||
profiles: ["llm"]
|
||||
image: ghcr.io/berriai/litellm:v1.74.0-stable
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/litellm
|
||||
- STORE_MODEL_IN_DB=True
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||
- AWS_REGION_NAME=${AWS_REGION_NAME:-us-east-1}
|
||||
- LITELLM_MASTER_KEY=sk-${LITELLM_ADMIN_PASSWORD:-admin123}
|
||||
- LITELLM_SALT_KEY=sk-${LITELLM_ADMIN_PASSWORD:-admin123}
|
||||
- SERVER_ROOT_PATH=/llm
|
||||
- PROXY_BASE_URL=${NEMESIS_URL:?}
|
||||
- UI_BASE_PATH=/llm/ui
|
||||
volumes:
|
||||
- ./infra/litellm/config.yml:/app/config.yml:ro
|
||||
command: ["--config", "/app/config.yml"]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "-O", "/dev/null", "--header=Authorization: Bearer sk-${LITELLM_ADMIN_PASSWORD:-admin123}", "http://localhost:4000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
disable: true
|
||||
logging: *logging-config
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.litellm.rule=PathPrefix(`/llm`)"
|
||||
- "traefik.http.services.litellm.loadbalancer.server.port=4000"
|
||||
- "traefik.http.routers.litellm.entrypoints=websecure"
|
||||
- "traefik.http.routers.litellm.tls=true"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Nemesis Agents
|
||||
|
||||
Nemesis integrates a number of LLM agents as well as some rule-based agents for different actions. These agents are run from the `agents` container and utilize Dapr workflows as well as [Pydantic AI](https://ai.pydantic.dev/) for the core agent execution. LLM powered agents are not enabled by default (see Setup and Configuration below for details).
|
||||
|
||||
## Overview
|
||||
|
||||
Nemesis currently has the following agents:
|
||||
|
||||
| Agent Name | Type | Runs Automatically | Purpose |
|
||||
| --------------------- | ---------- | ------------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `validate` | LLM-based | true | Validates security findings by triaging them as true positives, false positives, or needing review |
|
||||
| `credential_analyzer` | LLM-based | false | Extracts credentials and passwords from text content using LLM analysis |
|
||||
| `dotnet_analyzer` | LLM-based | false | Adapted .NET vulnerability analyzer from @Dreadnode. |
|
||||
| `summarizer` | LLM-based | false | Creates concise summaries of text content using LLM analysis |
|
||||
| `jwt` | Rule-based | true | Rule-based JWT analysis that checks expiry status and identifies sample data |
|
||||
|
||||
## Setup
|
||||
|
||||
In order to enable any LLM-powered agents, you first need to configure one or more models in `./infra/litellm/config.yaml` . You can use any model provider that LiteLLM supports (more details [here](https://docs.litellm.ai/docs/proxy/configs)) - **ensure that at a minimum you have a model called "default"**. In the `agents` section of `./compose.yaml` you can adjust the MAX_BUDGET (default $100) and BUDGET_DURATION (default 30d) enrivonment variables as wanted. Then launch the "llm" profile for Nemesis by supplying `--llm` to the `./tools/nemesis-ctl.sh` script.
|
||||
|
||||
***Note:*** service keys/tokens/etc. should be configured in your .env file and can be used in `./infra/litellm/config.yaml` as demonstrated in the current config file.
|
||||
|
||||
## Agent Details
|
||||
|
||||
### Finding Validator
|
||||
|
||||
The `validate` agent uses the configured LLM to triage findings as true_positive, false_positive, or needs_review. This is based off of the finding details and the file path/name. It also will provide a 1-sentence reason for the decision along with a confidence score. Automated triage values are displayed in the main file-listing interface with a small robot icon for agent-powered triage. Mousing over the robot icon will give the explanation and confidence score:
|
||||
|
||||

|
||||
|
||||
If the finding is true_positive, a 1-sentence risk statement is also generated. Clicking the finding will give more details along with the risk statement if appliable
|
||||
|
||||

|
||||
|
||||
This agent will run/triage all findings that come in except for the following categories: "extracted_hash", "yara_match", "extracted_data".
|
||||
|
||||
### Credential Analyzer
|
||||
|
||||
The `credential_analyzer` agent is an LLM-powered agent that will examine a text file for any credentials that might be present. It does **not** run automatically, but is triggered manually from a file details interface:
|
||||
|
||||

|
||||
|
||||
Once processing is complete, a markdown file will appear with any results in the transforms tab:
|
||||
|
||||

|
||||
|
||||
### .NET Analyzer
|
||||
|
||||
The `dotnet_analyzer` agent is an LLM-powered agent directly adapted from [@Dreadnode](https://x.com/dreadnode)'s [example-agents](https://github.com/dreadnode/example-agents) repo. It will analyze a .NET binary using a number of callable tools, searching for security issues.
|
||||
|
||||
It does **not** run automatically, but is triggered manually from a file details interface. A confirmation dialog will confirm running the agent, as it may take a bit of time and tokens.
|
||||
|
||||

|
||||
|
||||
Once processing is complete, a markdown file will appear with any results in the transforms tab under ".NET Assembly Analysis":
|
||||
|
||||

|
||||
|
||||
### Text Summarizer
|
||||
|
||||
The text `summarizer` agent is an LLM-powered agent that will summarize. Like the credential analizer, it does **not** run automatically, but is triggered manually from a file details interface. After processing, it will display the markdown-formatted text summary:
|
||||
|
||||

|
||||
|
||||
### JWT Validator
|
||||
|
||||
The JWT validator is a rule-based agent that will extract JWT tokens from Nosey Parker findings and mark the finding as "false_positive" if the token has expired.
|
||||
|
||||
## The Nemesis Web Interface
|
||||
|
||||
### Agents Web Interface
|
||||
|
||||
If the llm profile is used, the Nemesis frontend will detect that LiteLLM was deployed and enable a new "Agents" tab on the left. Clicking on this tab will show you the currently enabled agents, as well as the current token and cost spend for the system:
|
||||
|
||||

|
||||
|
||||
For LLM-powered agents, you can modify the main system prompt used by clicking "Edit", making your changes to the prompt, and clicking "Save":
|
||||
|
||||

|
||||
|
||||
### Monitoring
|
||||
|
||||
If the `--monitoring` flag is also passed to the `./tools/nemesis-ctl.sh` script, the [Arize Phoenix](https://github.com/Arize-ai/phoenix) will be deployed to allow tracking of the inputs/outputs sent to the LLM (at the /phoenix route):
|
||||
|
||||

|
||||
|
||||
If the monitoring profile is used and Arize Phoenix is deployed, the Nemesis frontend will dynamically display links to the Phoenix interface in the Help menu as well.
|
||||
|
||||

|
||||
|
||||
### LiteLLM Interface
|
||||
|
||||
If the lm profile is used and LiteLLM is deployed, the Nemesis frontend will dynamically display links to the LiteLLM in the Help menu as well. Clicking on the link you can log in with `admin` and the value of the LITELLM_MASTER_KEY (sk-admin123 by default). This interface can give you additional breakdowns for tokens and costs:
|
||||
|
||||

|
||||
@@ -0,0 +1,329 @@
|
||||
# Enrichment API
|
||||
|
||||
**Version:** 0.1.0
|
||||
|
||||
API for file enrichment services
|
||||
|
||||
This documentation is automatically generated from the OpenAPI specification.
|
||||
|
||||
---
|
||||
|
||||
## Enrichments
|
||||
|
||||
### `GET /enrichments`
|
||||
|
||||
List enrichment modules
|
||||
|
||||
Get a list of all available enrichment modules
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /enrichments/{enrichment_name}`
|
||||
|
||||
Run enrichment module
|
||||
|
||||
Run a specific enrichment module on a file
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `enrichment_name` (string, **required**): Name of the enrichment module to run
|
||||
|
||||
**Request Body:** `EnrichmentRequest` (JSON)
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /enrichments/{enrichment_name}/bulk`
|
||||
|
||||
Start bulk enrichment
|
||||
|
||||
Start bulk enrichment for a specific module against all files in the system using distributed processing
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `enrichment_name` (string, **required**): Name of the enrichment module to run
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /enrichments/{enrichment_name}/bulk/status`
|
||||
|
||||
Get bulk enrichment status
|
||||
|
||||
Bulk enrichment status tracking has been simplified
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `enrichment_name` (string, **required**): Name of the enrichment module to check status for
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /enrichments/{enrichment_name}/bulk/stop`
|
||||
|
||||
Stop bulk enrichment
|
||||
|
||||
Bulk enrichment cannot be stopped once tasks are published
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `enrichment_name` (string, **required**): Name of the enrichment module to stop
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Files
|
||||
|
||||
### `POST /containers`
|
||||
|
||||
Submit large container file for processing with optional filtering
|
||||
|
||||
...
|
||||
|
||||
**Request Body:** See OpenAPI spec for details
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /containers/{container_id}/status`
|
||||
|
||||
Get container processing status
|
||||
|
||||
Get the current processing status and progress of a submitted container
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `container_id` (string, **required**): Unique identifier of the container
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /files`
|
||||
|
||||
Upload file with metadata
|
||||
|
||||
Upload a file using multipart/form-data with metadata.
|
||||
Returns an object_id for the uploaded file and submission_id for the metadata submission.
|
||||
|
||||
Example:
|
||||
```
|
||||
curl -k -u n:n -F "file=@example.txt" -F 'metadata={"agent_id":"agent123","project":"proj1","timestamp":"2024-01-29T12:00:00Z","expiration":"2024-02-29T12:00:00Z","path":"/tmp/example.txt"}' https://nemesis:7443/api/files
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
curl -k -u n:n -F "file=@example.txt" -F 'metadata={"agent_id":"agent123","project":"proj1","path":"/tmp/example.txt"}' https://nemesis:7443/api/files
|
||||
```
|
||||
|
||||
**Request Body:** See OpenAPI spec for details
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /files/{object_id}`
|
||||
|
||||
Download a file
|
||||
|
||||
Download a file by its object ID with optional raw format and custom filename
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `object_id` (string, **required**): Unique identifier of the file to download
|
||||
- `raw` (boolean, optional): Whether to return the file in raw format
|
||||
- `name` (string, optional): Custom filename for the downloaded file
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Queues
|
||||
|
||||
### `GET /queues`
|
||||
|
||||
Get queue statistics
|
||||
|
||||
Get comprehensive queue metrics for all workflow topics
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /queues/{queue_name}`
|
||||
|
||||
Get single queue statistics
|
||||
|
||||
Get metrics for a specific queue topic
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `queue_name` (string, **required**): Name of the queue to get metrics for
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
|
||||
## System
|
||||
|
||||
### `GET /agents`
|
||||
|
||||
Get available agents
|
||||
|
||||
Get a list of available AI agents with their metadata and capabilities
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /agents/dotnet_analysis`
|
||||
|
||||
Run .NET assembly analysis
|
||||
|
||||
Forward .NET assembly analysis request to agents service
|
||||
|
||||
**Request Body:** JSON object
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /agents/llm_credential_analysis`
|
||||
|
||||
Run credential analysis
|
||||
|
||||
Forward credential analysis request to agents service
|
||||
|
||||
**Request Body:** JSON object
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /agents/spend-data`
|
||||
|
||||
Get LLM spend and usage data
|
||||
|
||||
Get total spend and token usage statistics from LiteLLM logs
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /agents/text_summarizer`
|
||||
|
||||
Run text summarization
|
||||
|
||||
Forward text summarization request to agents service
|
||||
|
||||
**Request Body:** JSON object
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /system/apprise-info`
|
||||
|
||||
Get Apprise alert information
|
||||
|
||||
Get information about configured alert channels (currently Slack only)
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /system/available-services`
|
||||
|
||||
Get available services
|
||||
|
||||
Query Traefik to determine which optional services are currently available
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /system/cleanup`
|
||||
|
||||
Trigger database and datalake cleanup
|
||||
|
||||
Trigger the housekeeping service to clean up expired files and database entries, and reset the workflow manager state. Optionally specify an expiration date or 'all' to remove all files.
|
||||
|
||||
**Request Body:** `CleanupRequest` (JSON)
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /system/container-monitor/status`
|
||||
|
||||
Container monitor status
|
||||
|
||||
Get the status of the container file monitor
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /system/health`
|
||||
|
||||
Health check
|
||||
|
||||
Health check endpoint for Docker healthcheck
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /system/info`
|
||||
|
||||
API information
|
||||
|
||||
Root endpoint that shows API information
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `POST /system/yara/reload`
|
||||
|
||||
Reload Yara rules
|
||||
|
||||
Trigger a reload of all Yara rules in the backend across all workers/replicas
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Workflows
|
||||
|
||||
### `GET /workflows/failed`
|
||||
|
||||
Get failed workflows
|
||||
|
||||
Get the set of failed enrichment workflows
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
### `GET /workflows/status`
|
||||
|
||||
Get workflow enrichment workflow status
|
||||
|
||||
Get the current status of the enrichment workflow system
|
||||
|
||||
**Returns:** 200 on success
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Nemesis Chromium Analysis
|
||||
|
||||
Nemesis includes comprehensive support for analyzing Chromium-based browser data including Chrome, Edge, Brave, and other Chromium-based browsers. The system automatically detects and processes various Chromium database files to extract browsing history, saved credentials, cookies, downloads, and encryption state information.
|
||||
|
||||
## Overview
|
||||
|
||||
Nemesis currently analyzes the following Chromium data sources:
|
||||
|
||||
| Data Type | File Source | Purpose |
|
||||
| ---------- | ------------- | ------------------------------------------------------------------------------ |
|
||||
| History | `History` | Extracts browsing history including URLs, titles, visit counts, and timestamps |
|
||||
| Downloads | `History` | Extracts download history with file paths, URLs, and download metadata |
|
||||
| Logins | `Login Data` | Extracts saved login credentials including usernames and passwords |
|
||||
| Cookies | `Cookies` | Extracts browser cookies with domain, name, value, and expiration data |
|
||||
| State Keys | `Local State` | Extracts OS encryption keys used to decrypt passwords and sensitive data |
|
||||
|
||||
|
||||
## Chrome Web Tab
|
||||
|
||||
### Chromium Data Viewer
|
||||
|
||||
The Nemesis frontend provides a dedicated Chromium interface accessible through the main navigation. This interface organizes all extracted Chromium data into five main categories:
|
||||
|
||||
### History Tab
|
||||
|
||||
The History tab displays extracted browsing history with searchable and filterable tables. Each entry includes:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Source**: the "source" label for the originating file (host, url, etc.)
|
||||
- **Username**: Host-based username, extracted from file path context (i.e., `C:\Users\USER\*`)
|
||||
- **Browser**: Browser name, extracted from file path context
|
||||
- **Title**: Page title as recorded by the browser
|
||||
- **Visits**: Number of times the URL was visited
|
||||
- **Last Visit**: Timestamp of most recent visit
|
||||
- **URL**: The visited website URL
|
||||
|
||||

|
||||
|
||||
### Downloads Tab
|
||||
|
||||
The Downloads tab shows extracted download history with detailed information about each downloaded file:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Source**: the "source" label for the originating file (host, url, etc.)
|
||||
- **Username**: Host-based username, extracted from file path context (i.e., `C:\Users\USER\*`)
|
||||
- **Browser**: Browser name, extracted from file path context
|
||||
- **URL**: Original source URL of the downloaded file
|
||||
- **End Time**: Time the download ended
|
||||
- **Download Path**: Path the file was downloaded to on the host
|
||||
|
||||

|
||||
|
||||
### Logins Tab
|
||||
|
||||
The Logins tab displays extracted login credentials:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Decrypted**: Yes/No indication if the login entry has been decrypted (by a state key) or not
|
||||
- **Source**: the "source" label for the originating file (host, url, etc.)
|
||||
- **Username**: Host-based username, extracted from file path context (i.e., `C:\Users\USER\*`)
|
||||
- **Password**: Plaintext value of the password (if decrypted)
|
||||
- **Browser**: Browser name, extracted from file path context
|
||||
- **Login Name**: Extracted login name for the login data entry (NOT the host-based path username of the Login Data file)
|
||||
- **Times Used**: Number of times the login entry has been used
|
||||
- **Signon Realm**: Extracted signon realm for the login entry
|
||||
- **Origin URL**: Website where credentials were saved
|
||||
|
||||

|
||||
|
||||
### Cookies Tab
|
||||
|
||||
The Cookies tab provides access to extracted browser cookies:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Decrypted**: Yes/No indication if the coookie has been decrypted (by a state key) or not
|
||||
- **Source**: the "source" label for the originating file (host, url, etc.)
|
||||
- **Username**: Host-based username, extracted from file path context (i.e., `C:\Users\USER\*`)
|
||||
- **Password**: Plaintext value of the password (if decrypted)
|
||||
- **Browser**: Browser name, extracted from file path context
|
||||
- **Host Key**: Domain or host the cookie belongs to
|
||||
- **Expires UTC**: Time (in UTC) the cookie value expires
|
||||
- **Last Access UTC**: Time (in UTC) the cookie value was last uased
|
||||
- **Name**: Name identifier of the cookie
|
||||
|
||||
**Note**: If the cookie value has been decrypted, click "Download CSV" to download the currently filtered/viewable
|
||||
cookies on the page *including* decrypted values.
|
||||
|
||||

|
||||
|
||||
### State Keys Tab
|
||||
|
||||
The State Keys tab displays OS encryption keys used by Chromium to protect sensitive data:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Source**: the "source" label for the originating file (host, url, etc.)
|
||||
- **Username**: Host-based username, extracted from file path context (i.e., `C:\Users\USER\*`)
|
||||
- **Browser**: Browser name, extracted from file path context
|
||||
- **Key Decrypted**: If the pre-v127 Chromium local state encryption key has been decrypted
|
||||
- **App Bound Key Decrypted**: If the post-v127 Chromium App-bound encryption key has been decrypted
|
||||
|
||||

|
||||
|
||||
## Data Export and Analysis
|
||||
|
||||
### CSV Export Functionality
|
||||
|
||||
All Chromium data tables support CSV export for external analysis:
|
||||
|
||||
1. Use the table interface to filter and search desired records
|
||||
2. Click the "Download CSV" button in the top-right of each tab
|
||||
3. All currently visible/filtered records will be exported
|
||||
|
||||
### Copy Operations
|
||||
|
||||
Individual records or entire result sets can be copied to clipboard:
|
||||
|
||||
- **Single Row**: Double-click any table row to copy all fields
|
||||
- **Multiple Rows**: Select rows and use Ctrl+C (or Cmd+C on Mac)
|
||||
- **Filtered Results**: Copy button will copy all currently visible records
|
||||
@@ -72,6 +72,7 @@ The `./tools/submit.sh` script wraps the docker syntax automatically.
|
||||
--username your-username \
|
||||
--password your-password \
|
||||
--project my-project \
|
||||
--source host://HOST1 \
|
||||
--agent-id my-agent \
|
||||
--workers 5 \
|
||||
--recursive \
|
||||
@@ -103,6 +104,7 @@ Options:
|
||||
-u, --username TEXT Basic auth username [default: n]
|
||||
-p, --password TEXT Basic auth password [default: n]
|
||||
--project TEXT Project name for metadata [default: assess-test]
|
||||
--source TEXT Source name for metadata (e.g., 'host://HOST1')
|
||||
--agent-id TEXT Agent ID for metadata [default:
|
||||
submitunknown_user@docker-desktop]
|
||||
-f, --file FILE Path to single file to submit (alternative to PATHS
|
||||
@@ -110,16 +112,17 @@ Options:
|
||||
--help Show this message and exit.
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | --------------------- | ------------------------- |
|
||||
| `--host` | `0.0.0.0:7443` | Nemesis host and port |
|
||||
| `--recursive` | `false` | Process subdirectories |
|
||||
| `--workers` | `10` | Number of upload threads |
|
||||
| `--username` | `n` | Basic auth username |
|
||||
| `--password` | `n` | Basic auth password |
|
||||
| `--project` | `assess-test` | Project name for metadata |
|
||||
| `--agent-id` | `submit<user>@<host>` | Agent ID for metadata |
|
||||
| `--debug` | `false` | Enable debug logging |
|
||||
| Option | Default | Description |
|
||||
| ------------- | --------------------- | ----------------------------------------------------------------------- |
|
||||
| `--host` | `0.0.0.0:7443` | Nemesis host and port |
|
||||
| `--recursive` | `false` | Process subdirectories |
|
||||
| `--workers` | `10` | Number of upload threads |
|
||||
| `--username` | `n` | Basic auth username |
|
||||
| `--password` | `n` | Basic auth password |
|
||||
| `--project` | `assess-test` | Project name for metadata |
|
||||
| `--source` | | Source name (e.g., 'host://HOST1' or 'https://domain.com') for metadata |
|
||||
| `--agent-id` | `submit<user>@<host>` | Agent ID for metadata |
|
||||
| `--debug` | `false` | Enable debug logging |
|
||||
|
||||
## Folder Monitoring
|
||||
|
||||
@@ -173,6 +176,7 @@ docker run \
|
||||
--username your-username \
|
||||
--password your-password \
|
||||
--project my-project \
|
||||
--source host://HOST1 \
|
||||
--agent-id my-agent \
|
||||
--workers 5 \
|
||||
--debug
|
||||
@@ -188,16 +192,17 @@ poetry run python -m cli monitor /path/to/directory
|
||||
|
||||
### Options Reference
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------------- | ---------------------- | ----------------------------------------------- |
|
||||
| `--host` | `0.0.0.0:7443` | Nemesis host and port |
|
||||
| `--username` | `n` | Basic auth username |
|
||||
| `--password` | `n` | Basic auth password |
|
||||
| `--project` | `assess-test` | Project name for metadata |
|
||||
| `--agent-id` | `monitor<user>@<host>` | Agent ID for metadata |
|
||||
| `--workers` | `10` | Number of upload threads for initial submission |
|
||||
| `--only-monitor` | `false` | Skip existing files, only monitor for new ones |
|
||||
| `--debug` | `false` | Enable debug logging |
|
||||
| Option | Default | Description |
|
||||
| ---------------- | ---------------------- | ----------------------------------------------------------------------- |
|
||||
| `--host` | `0.0.0.0:7443` | Nemesis host and port |
|
||||
| `--username` | `n` | Basic auth username |
|
||||
| `--password` | `n` | Basic auth password |
|
||||
| `--project` | `assess-test` | Project name for metadata |
|
||||
| `--source` | | Source name (e.g., 'host://HOST1' or 'https://domain.com') for metadata |
|
||||
| `--agent-id` | `monitor<user>@<host>` | Agent ID for metadata |
|
||||
| `--workers` | `10` | Number of upload threads for initial submission |
|
||||
| `--only-monitor` | `false` | Skip existing files, only monitor for new ones |
|
||||
| `--debug` | `false` | Enable debug logging |
|
||||
|
||||
|
||||
## Mythic Connector
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Containers
|
||||
|
||||
Nemesis has the ability to process the files extracted/carved from various container formats _without_ saving the container itself to the internal datalake. This is useful the the following situations:
|
||||
|
||||
- When you want to process a large number of files without doubling the storage (storing .zip + extracted files)
|
||||
- When you want to process large "containers" like forensic disk images
|
||||
|
||||
## Submitting Containers
|
||||
|
||||
### "Regular" Containers
|
||||
|
||||
To process a regular container, you have two options. First, you can submit the .zip/etc. as normal, and then click "Extract/Process Container Contents" on the file viewer page:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can configure and drop containers into the mounted folder as described in the **"Large" Containers** section below.
|
||||
|
||||
You can also submit the container with the `nemesis-cli` (and `./tools/submit.sh` script) with something like (note the --container flag):
|
||||
|
||||
```bash
|
||||
% ./tools/submit.sh --project PROJECT-123 --source DEV --container zip_test.zip
|
||||
Uploading (✓:1 ✗:0 | 1.59 KB): 100%|██████████| 1/1 [00:00<00:00]
|
||||
INFO
|
||||
Upload Summary:
|
||||
INFO ────────────────────────────────────────
|
||||
INFO Total Files: 1
|
||||
INFO Successful: 1
|
||||
INFO Failed: 0
|
||||
INFO Success Rate: 100.0%
|
||||
INFO Total Uploaded: 1.59 KB
|
||||
```
|
||||
|
||||
### "Large" Containers
|
||||
|
||||
For large things like disk images, a straight REST API doesn't cut it - lots of things mess up. Getting a multi-gigabyte file into Nemesis can be a challenge, but the current process uses a mountained container and large container monitoring abilities in the `web-api` service.
|
||||
|
||||
In order to process really large containers, first create a folder on your host and set the MOUNTED_CONTAINER_PATH ENV variable to that path. This folder is mounted into the `web-api` and will process containers that appear there (after they're done copying in). Then just start Nemesis and copy containers into that folder, it's that easy!
|
||||
|
||||
Wait, but what about metadata?
|
||||
|
||||
#### Large Container Configs/Metadata
|
||||
|
||||
Since we're changing the normal way we submit files, we need a new way to pass metadata into Nemesis for a disk image/large container. In order to accomplish this, we support a YAML-based metadata file format that can be placed in the parent folder, or any sub folders. This file can be named config.[yaml|yml] or settings.[yaml|yml] and takes the form:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
project: project123
|
||||
agent: collector
|
||||
source: BLAH
|
||||
file_filters:
|
||||
pattern_type: regex
|
||||
include:
|
||||
- "^(?:[A-Za-z]://|/)?[Ww]indows/[Ss]ystem32/config/"
|
||||
exclude:
|
||||
- "^(?:[A-Za-z]://|/)?[Ww]indows/"
|
||||
```
|
||||
|
||||
The metadata will be passed through for every file carved from the container. The optional `file_filters` allow you specify which extracted/carved files to include, or not include, for processing.
|
||||
|
||||
The logic for inclusion varies based on which patterns are provided:
|
||||
|
||||
1. No filters: Include everything
|
||||
2. Only include patterns: Only include files matching include patterns (allowlist mode)
|
||||
3. Only exclude patterns: Include everything except files matching exclude patterns (blocklist mode)
|
||||
4. Both include and exclude: Include everything, apply excludes, then re-include matches from include patterns (exception mode)
|
||||
|
||||
This creates a natural hierarchy where include patterns act as exceptions to exclusions when both are present.
|
||||
|
||||
You can also create sub-directories in the submission folder, for example:
|
||||
|
||||
```
|
||||
MOUNTED_CONTAINER_PATH / settings.yaml
|
||||
/ disk1.dd
|
||||
/ windows /
|
||||
/ settings.yaml
|
||||
/ windows_disk.dd
|
||||
```
|
||||
|
||||
In this case, the `settings.yaml` closest to the hierarchy of the file takes precedence - the "windows_disk.dd" will use the settings.yaml in its current folder, but would use the MOUNTED_CONTAINER_PATH/settings.yaml file if one wasn't present lower down. This lets you create a nested structure with multiple config options depending on where you drop your disk image.
|
||||
|
||||
|
||||
## Tracking Containers
|
||||
|
||||
Whether a container is submitted via the cli or the mounted folder option, it will appear in the "Containers" page accessible from the left navigation page:
|
||||
|
||||

|
||||
|
||||
This page will show the status of the container file extraction, and lets you filter by various fields.
|
||||
@@ -20,13 +20,13 @@ The [document_conversion](https://github.com/SpecterOps/Nemesis/tree/main/projec
|
||||
|
||||
## Secrets
|
||||
|
||||
Nemesis uses the [Dapr Secrets management](https://docs.dapr.io/developing-applications/building-blocks/secrets/secrets-overview/) building block to protect secrets internally (like Postgres connection strings). Currently the [Local environment variables](https://docs.dapr.io/reference/components-reference/supported-secret-stores/envvar-secret-store/) component is used. These secrets are also refereced within some Dapr files such as [pubsub.yaml](https://github.com/SpecterOps/Nemesis/tree/main/infra/dapr/components/pubsub.yaml).
|
||||
Nemesis uses the [Dapr Secrets management](https://docs.dapr.io/developing-applications/building-blocks/secrets/secrets-overview/) building block to protect secrets internally (like PostgreSQL connection parameters). Currently the [Local environment variables](https://docs.dapr.io/reference/components-reference/supported-secret-stores/envvar-secret-store/) component is used. These secrets are also referenced within some Dapr files such as [pubsub.yaml](https://github.com/SpecterOps/Nemesis/tree/main/infra/dapr/components/pubsub.yaml).
|
||||
|
||||
This reason for using this abstraction is so alternative secret management systems like [Vault or Kubernetes secrets](https://docs.dapr.io/reference/components-reference/supported-secret-stores/) can be used in the future:
|
||||
|
||||

|
||||
|
||||
An example of retrieving a secret is at the top of the the [housekeeping code](https://github.com/SpecterOps/Nemesis/blob/main/projects/housekeeping/housekeeping/main.py) to retrieve the `POSTGRES_CONNECTION_STRING` string.
|
||||
An example of retrieving secrets is in [libs/common/common/db.py](https://github.com/SpecterOps/Nemesis/blob/main/libs/common/common/db.py) which retrieves individual PostgreSQL connection parameters (`POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_PARAMETERS`) and constructs the connection string.
|
||||
|
||||
## Service Invocation
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ Development images are not published and must be built locally. If you make any
|
||||
|
||||
The easiest method to build + run dev images is to just use the `dev` target instead of `prod` with `./tools/nemesis-ctl.sh` :
|
||||
```bash
|
||||
./tools/nemesis-ctl.sh start dev [--monitoring] [--jupyter]
|
||||
./tools/nemesis-ctl.sh start dev [--monitoring] [--jupyter] [--llm]
|
||||
```
|
||||
|
||||
### Step 1 - Configure environment variables
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Nemesis DPAPI Analysis
|
||||
|
||||
Nemesis includes comprehensive support for analyzing Microsoft's Data Protection API (DPAPI) artifacts, which are used to encrypt sensitive data on Windows systems.
|
||||
|
||||
The system provides functionality to view extracted DPAPI master keys, domain backup keys, and submit credential material to decrypt protected data.
|
||||
|
||||
Additionally, "retroactive" decryption will occur when plaintext DPAPI masterkeys are submitted (or scraped from LSASS dumps), or DPAPI masterkeys are successfully decrypted (via domain backup keys, NTLM hashes, passwords, etc.). Specifically, new plaintext masterkeys are used to decrypt Google Chromekey1's and Chromium Local State files.
|
||||
|
||||
## Overview
|
||||
|
||||
Nemesis currently analyzes and manages the following DPAPI data sources:
|
||||
|
||||
| Data Type | Purpose |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| Master Keys | User-specific DPAPI master keys used to encrypt/decrypt user data |
|
||||
| Domain Backup Keys | Domain controller backup keys for enterprise DPAPI key recovery |
|
||||
| Credential Material Submission | Interface to submit various credential types to decrypt existing DPAPI master keys |
|
||||
|
||||
## DPAPI Web Tab
|
||||
|
||||
### DPAPI Data Viewer
|
||||
|
||||
The Nemesis frontend provides a dedicated DPAPI interface accessible through the main navigation. This interface organizes all DPAPI-related functionality into three main categories:
|
||||
|
||||
### Master Keys Tab
|
||||
|
||||
The Master Keys tab displays extracted DPAPI master keys with searchable and filterable tables. Each entry includes:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Source**: The "source" label for the originating file (host, url, etc.)
|
||||
- **Username**: Host-based username, extracted from file path context (i.e., `C:\Users\USER\*`)
|
||||
- **Key GUID**: Unique identifier for the master key
|
||||
- **Key Status**: Decryption status of the master key
|
||||
- **Created Date**: When the master key was created
|
||||
- **Master Key Data**: The actual key material (if decrypted)
|
||||
|
||||

|
||||
|
||||
### Domain Backup Keys Tab
|
||||
|
||||
The Domain Backup Keys tab shows extracted domain backup keys used for enterprise DPAPI recovery:
|
||||
|
||||
- **Object ID**: The object_id from the originating file
|
||||
- **Source**: The "source" label for the originating file (host, url, etc.)
|
||||
- **Domain**: Domain name associated with the backup key
|
||||
- **Key GUID**: Unique identifier for the backup key
|
||||
- **Key Status**: Whether the backup key has been successfully extracted
|
||||
- **Domain Controller**: Source domain controller (if available)
|
||||
- **Backup Key Data**: The actual backup key material
|
||||
|
||||

|
||||
|
||||
### Submit Credential Material Tab
|
||||
|
||||
The Submit Credential Material tab provides an interface for submitting various types of credential material to decrypt existing DPAPI master keys stored in the system. This tab includes:
|
||||
|
||||
#### Credential Types Supported
|
||||
|
||||
The interface supports multiple credential types for DPAPI decryption:
|
||||
|
||||
- **Password**: Plain text passwords for user accounts
|
||||
- **NTLM Hash**: NT hash values (16 bytes) for password-equivalent authentication
|
||||
- **SHA1**: SHA1 credential keys (20 bytes) derived from NTLM hashes
|
||||
- **Secure Credential Key (PBKDF2)**: 16-byte keys derived using PBKDF2
|
||||
- **Domain Backup Key**: Domain controller DPAPI backup keys in base64 PVK format
|
||||
- **Master Keys {GUID}:SHA1 Pairs**: Plaintext DPAPI master keys as {GUID}:SHA1 pairs
|
||||
- **DPAPI_SYSTEM Secret**: System-wide DPAPI credentials for machine encryption
|
||||
|
||||

|
||||
|
||||
#### Form Fields
|
||||
|
||||
Based on the selected credential type, the form dynamically displays relevant fields:
|
||||
|
||||
- **User SID**: Required for user-specific credential types (password, NTLM hash, SHA1, PBKDF2)
|
||||
- **Backup Key GUID**: Required for domain backup keys
|
||||
- **Domain Controller**: Optional field for domain backup key submissions
|
||||
- **Credential Value**: Main input field for the credential material
|
||||
- **Master Key Data**: Structured input for {GUID}:SHA1 pairs (multi-line format)
|
||||
|
||||
Additionally, each credential type includes detailed descriptions explaining:
|
||||
|
||||
- What the credential type is used for
|
||||
- How to obtain or generate the credential material
|
||||
- Common sources where these credentials can be collected
|
||||
- Technical details about the key format and length requirements
|
||||
|
||||

|
||||
|
||||
## Data Export and Analysis
|
||||
|
||||
### CSV Export Functionality
|
||||
|
||||
DPAPI data tables support CSV export for external analysis:
|
||||
|
||||
1. Use the table interface to filter and search desired records
|
||||
2. Click the "Download CSV" button in the top-right of each tab
|
||||
3. All currently visible/filtered records will be exported
|
||||
|
||||
### Copy Operations
|
||||
|
||||
Individual records or entire result sets can be copied to clipboard:
|
||||
|
||||
- **Single Row**: Double-click any table row to copy all fields
|
||||
- **Multiple Rows**: Select rows and use Ctrl+C (or Cmd+C on Mac)
|
||||
- **Filtered Results**: Copy button will copy all currently visible records
|
||||
|
||||
## Integration with Other Nemesis Components
|
||||
|
||||
### Automatic Processing
|
||||
|
||||
DPAPI artifacts are automatically processed when:
|
||||
|
||||
- Windows registry hives are uploaded (SYSTEM, SECURITY, SAM)
|
||||
- LSASS dumps are analized
|
||||
- User/system profile directories containing DPAPI folders/masterkeys are processed
|
||||
@@ -33,8 +33,13 @@ The `should_process()` function determines if the module should run on a file. Y
|
||||
|
||||
```python
|
||||
...
|
||||
def should_process(self, state_key: str) -> bool:
|
||||
"""Determine if this module should run based on file type."""
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Determine if this module should run based on file type
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
file_enriched = get_file_enriched(state_key)
|
||||
# Check if file appears to be a VNC config file
|
||||
should_run = (
|
||||
@@ -65,18 +70,31 @@ rule has_dpapi_blob
|
||||
}
|
||||
""")
|
||||
|
||||
def should_process(self, object_id: str) -> bool:
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Check if this file should be processed by scanning for DPAPI blobs.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
logger.debug(f"File {object_id} should be processed by DPAPI blob analyzer")
|
||||
if file_enriched.size > self.size_limit:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
f"[dpapi_analyzer] file {file_enriched.path} ({file_enriched.object_id} / {file_enriched.size} bytes) exceeds the size limit of {self.size_limit} bytes, only analyzing the first {self.size_limit} bytes"
|
||||
)
|
||||
|
||||
num_bytes = file_enriched.size if file_enriched.size < self.size_limit else self.size_limit
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id, length=num_bytes)
|
||||
if file_path:
|
||||
# Use provided file path - read only the needed bytes
|
||||
with open(file_path, "rb") as f:
|
||||
num_bytes = min(file_enriched.size, self.size_limit)
|
||||
file_bytes = f.read(num_bytes)
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
num_bytes = file_enriched.size if file_enriched.size < self.size_limit else self.size_limit
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id, length=num_bytes)
|
||||
|
||||
should_run = len(self.yara_rule.scan(file_bytes).matching_rules) > 0
|
||||
logger.debug(f"[dpapi_analyzer] should_run: {should_run}")
|
||||
return should_run
|
||||
...
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 633 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 438 KiB |
@@ -28,23 +28,27 @@ Nemesis is an offensive file enrichment pipeline.
|
||||
|
||||
Nemesis 2.0 is built on [Docker](https://www.docker.com/) with heavy [Dapr integration](https://dapr.io/), our goal with Nemesis was to create a centralized file processing platform that functions as an "offensive VirusTotal".
|
||||
|
||||
## Additional Information
|
||||
Blog Posts:
|
||||
***Note that Nemesis v1.0 is incompatible with Nemesis 2.0!***
|
||||
|
||||
| Title | Date |
|
||||
|------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------|
|
||||
| [*Nemesis 1.0.0*](https://posts.specterops.io/nemesis-1-0-0-8c6b745dc7c5) | Apr 25, 2024 |
|
||||
| [*Summoning RAGnarok With Your Nemesis*](https://posts.specterops.io/summoning-ragnarok-with-your-nemesis-7c4f0577c93b) | Mar 13, 2024 |
|
||||
| [*Shadow Wizard Registry Gang: Structured Registry Querying*](https://posts.specterops.io/shadow-wizard-registry-gang-structured-registry-querying-9a2fab62a26f) | Sep 5, 2023 |
|
||||
| [*Hacking With Your Nemesis*](https://posts.specterops.io/hacking-with-your-nemesis-7861f75fcab4) | Aug 9, 2023 |
|
||||
| [*Challenges In Post-Exploitation Workflows*](https://posts.specterops.io/challenges-in-post-exploitation-workflows-2b3469810fe9) | Aug 2, 2023 |
|
||||
| [*On (Structured) Data*](https://posts.specterops.io/on-structured-data-707b7d9876c6) | Jul 26, 2023 |
|
||||
## Additional Information
|
||||
|
||||
Blog Posts
|
||||
|
||||
| Title | Nemesis Version | Date |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------------ |
|
||||
| [*Nemesis 2.0*](https://specterops.io/blog/2025/08/05/nemesis-2-0/) | v2.0 | Aug 5, 2025 |
|
||||
| [*Nemesis 1.0.0*](https://posts.specterops.io/nemesis-1-0-0-8c6b745dc7c5) | v1.0 | Apr 25, 2024 |
|
||||
| [*Summoning RAGnarok With Your Nemesis*](https://posts.specterops.io/summoning-ragnarok-with-your-nemesis-7c4f0577c93b) | v1.0 | Mar 13, 2024 |
|
||||
| [*Shadow Wizard Registry Gang: Structured Registry Querying*](https://posts.specterops.io/shadow-wizard-registry-gang-structured-registry-querying-9a2fab62a26f) | v1.0 | Sep 5, 2023 |
|
||||
| [*Hacking With Your Nemesis*](https://posts.specterops.io/hacking-with-your-nemesis-7861f75fcab4) | v1.0 | Aug 9, 2023 |
|
||||
| [*Challenges In Post-Exploitation Workflows*](https://posts.specterops.io/challenges-in-post-exploitation-workflows-2b3469810fe9) | v1.0 | Aug 2, 2023 |
|
||||
| [*On (Structured) Data*](https://posts.specterops.io/on-structured-data-707b7d9876c6) | v1.0 | Jul 26, 2023 |
|
||||
|
||||
|
||||
Presentations:
|
||||
|
||||
| Title | Date |
|
||||
|----------------------------------------------------------------------------|--------------|
|
||||
| -------------------------------------------------------------------------- | ------------ |
|
||||
| [*SAINTCON 2023*](https://www.youtube.com/watch?v=0q9u2hDcpIo) | Oct 24, 2023 |
|
||||
| [*BSidesAugusta 2023*](https://www.youtube.com/watch?v=Ug9r7lCF_FA) | Oct 7, 2023 |
|
||||
| [*44CON 2023*](https://www.youtube.com/watch?v=tjPTLBGI7K8) | Sep 15, 2023 |
|
||||
|
||||
@@ -22,6 +22,7 @@ Schema definition for the public `file` message POSTed to the API frontend.
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `path` | string | File system path to the relevant resource. Can use either forward (/) or backward (\\) slashes |
|
||||
| `source` | string | String representing a `host://IP`, `host://HOSTNAME`, or traditional URI (i.e., `https://domain.com) |
|
||||
| `originating_object_id` | string | UUID v4 format identifier referencing a parent or source object |
|
||||
| `nesting_level` | number | The level of nesting for the file within an originating container. Used to prevent indefinite container nesting. |
|
||||
| `creation_time` | datetime | ISO 8601 formatted timestamp for when the file was created |
|
||||
|
||||
@@ -10,11 +10,8 @@ If workflows begin to fail, or you are experiencing major performance issues (as
|
||||
For production (non-dev) deployments, multiple UVICORN_WORKERS are used for the `file-enrichment` service. The default value is 2 and is defined in the `file-enrichment` section in [compose.yaml](https://github.com/SpecterOps/Nemesis/blob/71406afc12f855140ea68aae337076f9b8dc292f/compose.yaml#L217). This value can be set to 1 for troubleshooting, or increased to 4+ for potential performance gains. You can modify this value by defining the `export UVICORN_WORKERS=4` environment variable before launching Nemesis.
|
||||
|
||||
|
||||
### MAX_PARALLEL_WORKFLOWS
|
||||
|
||||
The `file-enrichment` container runs a number of file-enrichment workflows in parallel, defaulting to 5. You can modify this value by defining the `export MAX_PARALLEL_WORKFLOWS=3` environment variable before launching Nemesis.
|
||||
|
||||
|
||||
### MAX_PARALLEL_ENRICHMENT_MODULES
|
||||
|
||||
For each file enrichment workflow, the `file-enrichment` container runs multiple file enrichment modules in parallel, defaulting to 5. You can modify this value by defining the `export MAX_PARALLEL_ENRICHMENT_MODULES=3` environment variable before launching Nemesis.
|
||||
# Useful Prometheus Metrics
|
||||
Minio
|
||||
```
|
||||
minio_cluster_usage_objects_count{}
|
||||
```
|
||||
@@ -17,6 +17,12 @@ Ensure your machine meets the following requirements:
|
||||
- Docker version 28.0.0 or higher is recommended. See [Docker's installation instructions](https://docs.docker.com/engine/install/) for instructions on installing Docker. Running the Docker Engine on Linux or on OS X via Docker Desktop is recommended. If using Docker Desktop, ensure that the VM is configured with sufficient RAM/Disk/swap.
|
||||
|
||||
|
||||
**NOTE:** for multi-language support for OCR/document processing, set the `TIKA_OCR_LANGUAGES` ENV var before launching with the [Tesseract language code](https://github.com/tesseract-ocr/tessdata):
|
||||
```bash
|
||||
$ export TIKA_OCR_LANGUAGES="eng chi_sim chi_tra jpn rus deu spa"
|
||||
```
|
||||
|
||||
|
||||
### Step 1: Clone the Nemesis Repository
|
||||
```bash
|
||||
git clone https://github.com/SpecterOps/Nemesis
|
||||
@@ -46,10 +52,10 @@ To start Nemesis's core services, run the `./tools/nemesis-ctl.sh` script:
|
||||
./tools/nemesis-ctl.sh start prod
|
||||
```
|
||||
|
||||
If you'd like to install the monitoring services and/or jupyter notebooks, use the associated optional command line arguments:
|
||||
If you'd like to install the monitoring services, jupyter notebooks, and/or LLM agents use the associated optional command line arguments:
|
||||
|
||||
```bash
|
||||
./tools/nemesis-ctl.sh start prod --monitoring --jupyter
|
||||
./tools/nemesis-ctl.sh start prod --monitoring --jupyter [--llm]
|
||||
```
|
||||
`nemesis-ctl.sh` effectively is a wrapper for `docker compose` commands and is in charge of pulling and starting the appropriate published Nemesis docker images. In general, we recommend people use `nemesis-ctl.sh` instead of manually invoking `docker compose`. For more complex deployment scenarios, see Nemesis's [Docker Compose documentation](docker_compose.md) to understand what `nemesis-ctl.sh` does underneath.
|
||||
|
||||
@@ -90,17 +96,19 @@ Click on the "Help" button on the bottom left to view the additionally exposed N
|
||||
|
||||
**NOTE:** The /jupyter/ route will only be available if you started with it enabled (`--jupyter`).
|
||||
|
||||
**NOTE:** The /jupyter/ route will only be available if you started with it enabled (`--jupyter`).
|
||||
|
||||

|
||||
|
||||
### Step 7: Shutting Nemesis Down
|
||||
|
||||
To shutdown Nemesis, use the `nemesis-ctl.sh` script's `stop` or `clean` commands ***with the same arguments you used to start it***. For example, if you started it with monitoring and jupyter enabled, then run the following:
|
||||
To shutdown Nemesis, use the `nemesis-ctl.sh` script's `stop` or `clean` commands ***with the same arguments you used to start it***. For example, if you started it with monitoring, jupyter, or LLM support enabled, then run the following:
|
||||
- To stop Nemesis containers:
|
||||
```bash
|
||||
./tools/nemesis-ctl.sh stop prod --monitoring --jupyter
|
||||
./tools/nemesis-ctl.sh stop prod --monitoring --jupyter --llm
|
||||
```
|
||||
|
||||
- To stop Nemesis containers and delete their associated volumes:
|
||||
```bash
|
||||
./tools/nemesis-ctl.sh clean prod --monitoring --jupyter
|
||||
./tools/nemesis-ctl.sh clean prod --monitoring --jupyter --llm
|
||||
```
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
MINIO_ROOT_PASSWORD=Qwerty12345
|
||||
MINIO_ROOT_USER=nemesis
|
||||
POSTGRES_PASSWORD=Qwerty12345
|
||||
POSTGRES_USER=nemesis
|
||||
RABBITMQ_PASSWORD=Qwerty12345
|
||||
RABBITMQ_USER=nemesis
|
||||
MINIO_ROOT_PASSWORD="Qwerty12345"
|
||||
MINIO_ROOT_USER="nemesis"
|
||||
|
||||
RABBITMQ_PASSWORD="Qwerty12345"
|
||||
RABBITMQ_USER="nemesis"
|
||||
|
||||
POSTGRES_PASSWORD="Qwerty12345"
|
||||
POSTGRES_USER="nemesis"
|
||||
POSTGRES_HOST="postgres"
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB="enrichment"
|
||||
POSTGRES_PARAMETERS="sslmode=disable"
|
||||
|
||||
|
||||
# You can change the password used by HASURA. If not defined a default password will be used.
|
||||
|
||||
# You can change the password used by HASURA. If not defined a default password will be used (pass456)
|
||||
# Example:
|
||||
# HASURA_ADMIN_SECRET=Qwerty12345
|
||||
# HASURA_ADMIN_SECRET="Qwerty12345"
|
||||
|
||||
|
||||
# NEMESIS_URL is used when building hyperlinks for findings and Apprise alerts.
|
||||
# If you change the port Nemesis listens on using NEMESIS_PORT (below), ensure this URL's port matches.
|
||||
# If using a host/domain name, a FQDN (with a top level domain) is recommended.
|
||||
NEMESIS_URL=https://localhost:7443/
|
||||
NEMESIS_URL="https://localhost:7443/"
|
||||
|
||||
|
||||
####################
|
||||
@@ -50,4 +57,11 @@ NEMESIS_URL=https://localhost:7443/
|
||||
# (Optional) Set Jupyter credentials using JUPYTER_PASSWORD.
|
||||
# If not defined, a random password will be generated and printed in the jupyter container's logs.
|
||||
# Example:
|
||||
# JUPYTER_PASSWORD=Qwerty12345
|
||||
# JUPYTER_PASSWORD="Qwerty12345"
|
||||
|
||||
|
||||
# (Optional) Enable Phoenix LLM tracing for Pydantic AI agents.
|
||||
# Requires starting Nemesis with monitoring profile: ./tools/nemesis-ctl.sh start dev --monitoring
|
||||
# Phoenix UI will be available at http://localhost:6006
|
||||
# Example:
|
||||
# PHOENIX_ENABLED=true
|
||||
|
||||
@@ -16,15 +16,54 @@ spec:
|
||||
value: "false"
|
||||
- name: autoAck
|
||||
value: "false"
|
||||
- name: requeueInFailure
|
||||
value: "true"
|
||||
- name: reconnectWait
|
||||
value: "5"
|
||||
value: "3"
|
||||
- name: concurrencyMode
|
||||
value: parallel
|
||||
# - name: backOffMaxRetries # doesn't appear to be a used value
|
||||
# value: "5"
|
||||
# - name: backOffMaxInterval # doesn't appear to be a used value
|
||||
# value: "5s"
|
||||
- name: prefetchCount
|
||||
value: "10" # Set to 1 for processing one message at a time
|
||||
- name: deliveryMode # persistence setting
|
||||
value: "2"
|
||||
- name: prefetchCount # should be equal or less than the max concurrent workflows
|
||||
value: "5"
|
||||
- name: maxPriority # set so we can prioritize some messages if wanted
|
||||
value: "3"
|
||||
auth:
|
||||
secretStore: nemesis-secret-store
|
||||
---
|
||||
apiVersion: dapr.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: broadcast
|
||||
spec:
|
||||
type: pubsub.rabbitmq
|
||||
version: v1
|
||||
metadata:
|
||||
- name: connectionString
|
||||
secretKeyRef:
|
||||
name: RABBITMQ_CONNECTION_STRING
|
||||
key: RABBITMQ_CONNECTION_STRING
|
||||
- name: consumerID
|
||||
value: mytest-{appID}-{uuid} # Unique consumerID causes the message to be sent to each subscriber. Each subscriber has a RabbitMQ queue named accoridingly.
|
||||
- name: durable
|
||||
value: true
|
||||
- name: deleteWhenUnused
|
||||
value: true
|
||||
- name: autoAck
|
||||
value: true
|
||||
- name: deliveryMode
|
||||
value: 2 # 1=transient messages(messages stay in-memory), 2=Persistent messages(use in conjunction with durable queues)
|
||||
- name: publisherConfirm
|
||||
value: true
|
||||
- name: requeueInFailure
|
||||
value: "true"
|
||||
- name: reconnectWait
|
||||
value: "3"
|
||||
- name: concurrencyMode
|
||||
value: parallel
|
||||
- name: prefetchCount # should be equal or less than the max concurrent workflows
|
||||
value: "5"
|
||||
- name: maxPriority # set so we can prioritize some messages if wanted
|
||||
value: "3"
|
||||
auth:
|
||||
secretStore: nemesis-secret-store
|
||||
@@ -7,9 +7,27 @@ spec:
|
||||
version: v1
|
||||
metadata:
|
||||
- name: connectionString
|
||||
value: "postgresql://username:password@hostname:5432/databasename?sslmode=disable"
|
||||
- name: host
|
||||
secretKeyRef:
|
||||
name: POSTGRES_CONNECTION_STRING
|
||||
key: POSTGRES_CONNECTION_STRING
|
||||
name: POSTGRES_HOST
|
||||
key: POSTGRES_HOST
|
||||
- name: port
|
||||
secretKeyRef:
|
||||
name: POSTGRES_PORT
|
||||
key: POSTGRES_PORT
|
||||
- name: database
|
||||
secretKeyRef:
|
||||
name: POSTGRES_DB
|
||||
key: POSTGRES_DB
|
||||
- name: user
|
||||
secretKeyRef:
|
||||
name: POSTGRES_USER
|
||||
key: POSTGRES_USER
|
||||
- name: password
|
||||
secretKeyRef:
|
||||
name: POSTGRES_PASSWORD
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: actorStateStore
|
||||
value: "true"
|
||||
- name: table
|
||||
|
||||
@@ -3,6 +3,9 @@ kind: Configuration
|
||||
metadata:
|
||||
name: schedulerconfig
|
||||
spec:
|
||||
# workflow:
|
||||
# maxConcurrentWorkflowInvocations: 1
|
||||
# maxConcurrentActivityInvocations: 1
|
||||
httpPipeline:
|
||||
handlers:
|
||||
- name: maximum-request-size
|
||||
|
||||
@@ -3,6 +3,9 @@ kind: Configuration
|
||||
metadata:
|
||||
name: schedulerconfig
|
||||
spec:
|
||||
# workflow:
|
||||
# maxConcurrentWorkflowInvocations: 1
|
||||
# maxConcurrentActivityInvocations: 1
|
||||
tracing:
|
||||
expandParams: true
|
||||
samplingRate: "1"
|
||||
|
||||
@@ -7,13 +7,8 @@ deleteDatasources:
|
||||
datasources:
|
||||
- name: Postgres
|
||||
type: postgres
|
||||
url: postgres:5432
|
||||
user: ${POSTGRES_USER}
|
||||
secureJsonData:
|
||||
password: ${POSTGRES_PASSWORD}
|
||||
url: ${POSTGRES_CONNECTION_STRING}
|
||||
jsonData:
|
||||
database: enrichment
|
||||
sslmode: disable
|
||||
maxOpenConns: 100
|
||||
maxIdleConns: 100
|
||||
maxIdleConnsAuto: true
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
- table:
|
||||
name: document_search_results
|
||||
schema: public
|
||||
- table:
|
||||
name: container_processing
|
||||
schema: public
|
||||
- table:
|
||||
name: workflows
|
||||
schema: public
|
||||
@@ -139,3 +142,59 @@
|
||||
- table:
|
||||
name: yara_rules
|
||||
schema: public
|
||||
- table:
|
||||
name: agent_prompts
|
||||
schema: public
|
||||
- table:
|
||||
name: file_linkings
|
||||
schema: public
|
||||
- table:
|
||||
name: file_listings
|
||||
schema: public
|
||||
|
||||
# `chromium` schema
|
||||
- table:
|
||||
name: history
|
||||
schema: chromium
|
||||
object_relationships:
|
||||
- name: files_enriched
|
||||
using:
|
||||
foreign_key_constraint_on: originating_object_id
|
||||
- table:
|
||||
name: downloads
|
||||
schema: chromium
|
||||
object_relationships:
|
||||
- name: files_enriched
|
||||
using:
|
||||
foreign_key_constraint_on: originating_object_id
|
||||
- table:
|
||||
name: logins
|
||||
schema: chromium
|
||||
object_relationships:
|
||||
- name: files_enriched
|
||||
using:
|
||||
foreign_key_constraint_on: originating_object_id
|
||||
- table:
|
||||
name: cookies
|
||||
schema: chromium
|
||||
object_relationships:
|
||||
- name: files_enriched
|
||||
using:
|
||||
foreign_key_constraint_on: originating_object_id
|
||||
- table:
|
||||
name: state_keys
|
||||
schema: chromium
|
||||
- table:
|
||||
name: chrome_keys
|
||||
schema: chromium
|
||||
|
||||
# `dpapi` schema
|
||||
- table:
|
||||
name: masterkeys
|
||||
schema: dpapi
|
||||
- table:
|
||||
name: system_credentials
|
||||
schema: dpapi
|
||||
- table:
|
||||
name: domain_backup_keys
|
||||
schema: dpapi
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
model_list:
|
||||
# used if other task models are not defined
|
||||
- model_name: default
|
||||
litellm_params:
|
||||
model: bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0
|
||||
aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID
|
||||
aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY
|
||||
aws_region_name: "us-east-1"
|
||||
# # used to triage findings and determine their validity
|
||||
# - model_name: triage
|
||||
# litellm_params:
|
||||
# model: bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0
|
||||
# aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID
|
||||
# aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY
|
||||
# aws_region_name: "us-east-1"
|
||||
|
||||
general_settings:
|
||||
database_connection_pool_limit: 30
|
||||
database_connection_timeout: 60
|
||||
@@ -0,0 +1,2 @@
|
||||
# Postgres Exporter Configuration
|
||||
auth_modules: {}
|
||||
@@ -6,11 +6,13 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
object_id UUID PRIMARY KEY,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
timestamp TIMESTAMP WITH TIME ZONE,
|
||||
expiration TIMESTAMP WITH TIME ZONE,
|
||||
path TEXT,
|
||||
originating_object_id UUID,
|
||||
originating_container_id UUID, -- for large container processing
|
||||
nesting_level INTEGER,
|
||||
file_creation_time TIMESTAMP WITH TIME ZONE,
|
||||
file_access_time TIMESTAMP WITH TIME ZONE,
|
||||
@@ -27,6 +29,7 @@ CREATE TABLE IF NOT EXISTS files (
|
||||
CREATE TABLE IF NOT EXISTS files_enriched (
|
||||
object_id UUID PRIMARY KEY,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
timestamp TIMESTAMP WITH TIME ZONE,
|
||||
expiration TIMESTAMP WITH TIME ZONE,
|
||||
@@ -39,6 +42,7 @@ CREATE TABLE IF NOT EXISTS files_enriched (
|
||||
is_plaintext BOOLEAN,
|
||||
is_container BOOLEAN,
|
||||
originating_object_id UUID,
|
||||
originating_container_id UUID, -- for large container processing
|
||||
nesting_level INTEGER,
|
||||
file_creation_time TIMESTAMP WITH TIME ZONE,
|
||||
file_access_time TIMESTAMP WITH TIME ZONE,
|
||||
@@ -105,6 +109,7 @@ CREATE TABLE IF NOT EXISTS files_feedback (
|
||||
CREATE TABLE IF NOT EXISTS files_enriched_dataset (
|
||||
object_id UUID PRIMARY KEY,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
timestamp TIMESTAMP WITH TIME ZONE,
|
||||
expiration TIMESTAMP WITH TIME ZONE,
|
||||
@@ -169,6 +174,9 @@ CREATE TABLE IF NOT EXISTS findings_triage_history (
|
||||
username VARCHAR(255) NOT NULL,
|
||||
automated BOOLEAN,
|
||||
value VARCHAR(255) NOT NULL,
|
||||
explanation VARCHAR(5000),
|
||||
confidence REAL,
|
||||
true_positive_context VARCHAR(5000),
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -256,6 +264,7 @@ SELECT
|
||||
ef.extension,
|
||||
ef.project,
|
||||
ef.agent_id,
|
||||
ef.source,
|
||||
ef."timestamp"::timestamp with time zone
|
||||
FROM plaintext_content pc
|
||||
JOIN files_enriched ef ON pc.object_id = ef.object_id;
|
||||
@@ -268,7 +277,8 @@ CREATE OR REPLACE FUNCTION public.search_documents(
|
||||
project_name text DEFAULT NULL,
|
||||
start_date timestamp with time zone DEFAULT NULL,
|
||||
end_date timestamp with time zone DEFAULT NULL,
|
||||
max_results integer DEFAULT 100
|
||||
max_results integer DEFAULT 100,
|
||||
source_pattern text DEFAULT NULL
|
||||
) RETURNS SETOF document_search_results
|
||||
STABLE
|
||||
LANGUAGE sql
|
||||
@@ -281,15 +291,13 @@ AS $$
|
||||
) as rn
|
||||
FROM document_search_results
|
||||
WHERE
|
||||
(
|
||||
content_vector @@ plainto_tsquery('simple', search_query)
|
||||
OR content ILIKE '%' || search_query || '%'
|
||||
OR file_name ILIKE '%' || search_query || '%'
|
||||
) AND (path_pattern IS NULL OR path LIKE path_pattern)
|
||||
content_vector @@ plainto_tsquery('simple', search_query)
|
||||
AND (path_pattern IS NULL OR path LIKE path_pattern)
|
||||
AND (agent_pattern IS NULL OR agent_id LIKE agent_pattern)
|
||||
AND (project_name IS NULL OR project = project_name)
|
||||
AND (start_date IS NULL OR "timestamp" >= start_date)
|
||||
AND (end_date IS NULL OR "timestamp" <= end_date)
|
||||
AND (source_pattern IS NULL OR source IS NULL OR source LIKE source_pattern)
|
||||
)
|
||||
SELECT
|
||||
object_id,
|
||||
@@ -301,6 +309,7 @@ AS $$
|
||||
extension,
|
||||
project,
|
||||
agent_id,
|
||||
source,
|
||||
"timestamp"
|
||||
FROM ranked_chunks
|
||||
WHERE rn = 1
|
||||
@@ -331,6 +340,160 @@ CREATE TABLE IF NOT EXISTS yara_rules (
|
||||
);
|
||||
|
||||
|
||||
-----------------------
|
||||
-- Agent Prompts
|
||||
-----------------------
|
||||
CREATE TABLE IF NOT EXISTS agent_prompts (
|
||||
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
description TEXT,
|
||||
prompt TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-----------------------
|
||||
-- FILE LINKINGS
|
||||
-----------------------
|
||||
CREATE TABLE IF NOT EXISTS file_linkings (
|
||||
linking_id BIGSERIAL PRIMARY KEY,
|
||||
source VARCHAR(1000) NOT NULL,
|
||||
file_path_1 TEXT NOT NULL,
|
||||
file_path_2 TEXT NOT NULL,
|
||||
link_type VARCHAR(255), -- Optional: to specify the type of relationship
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source, file_path_1, file_path_2)
|
||||
);
|
||||
|
||||
-- Create indexes for efficient lookups in both directions
|
||||
CREATE INDEX IF NOT EXISTS idx_file_linkings_file_1 ON file_linkings(file_path_1);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_linkings_file_2 ON file_linkings(file_path_2);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_linkings_source ON file_linkings(source);
|
||||
|
||||
|
||||
-----------------------
|
||||
-- FILE LISTINGS
|
||||
-----------------------
|
||||
CREATE TABLE IF NOT EXISTS file_listings (
|
||||
listing_id BIGSERIAL PRIMARY KEY,
|
||||
source VARCHAR(1000) NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
object_id UUID,
|
||||
status VARCHAR(50) NOT NULL CHECK (status IN ('needs_to_be_collected', 'not_exists', 'collected', 'not_wanted')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
path_lower TEXT GENERATED ALWAYS AS (LOWER(path)) STORED,
|
||||
UNIQUE(source, path_lower),
|
||||
FOREIGN KEY (object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_file_listings_source ON file_listings(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_listings_status ON file_listings(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_listings_object_id ON file_listings(object_id);
|
||||
-- Create composite index for efficient path prefix queries
|
||||
CREATE INDEX IF NOT EXISTS idx_file_listings_source_path ON file_listings(source, path);
|
||||
-- Create trigram index for path pattern matching
|
||||
CREATE INDEX IF NOT EXISTS idx_file_listings_path_trgm ON file_listings USING gist (path gist_trgm_ops);
|
||||
|
||||
-- Helper functions for file browser hierarchical navigation
|
||||
CREATE OR REPLACE FUNCTION get_path_depth(file_path text)
|
||||
RETURNS integer AS $$
|
||||
BEGIN
|
||||
-- Count forward slashes to determine depth
|
||||
-- Root files (no slash) are depth 0, /folder/file is depth 1, etc.
|
||||
IF file_path = '' OR file_path IS NULL THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
RETURN array_length(string_to_array(file_path, '/'), 1) - 1;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_path_parent(file_path text)
|
||||
RETURNS text AS $$
|
||||
BEGIN
|
||||
-- Return parent path, handling edge cases
|
||||
IF file_path IS NULL OR file_path = '' OR position('/' in file_path) = 0 THEN
|
||||
RETURN '';
|
||||
END IF;
|
||||
RETURN regexp_replace(file_path, '/[^/]*$', '');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_path_filename(file_path text)
|
||||
RETURNS text AS $$
|
||||
BEGIN
|
||||
-- Extract just the filename/folder name from full path
|
||||
IF file_path IS NULL OR file_path = '' THEN
|
||||
RETURN '';
|
||||
END IF;
|
||||
RETURN regexp_replace(file_path, '^.*/', '');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION is_file_path(file_path text)
|
||||
RETURNS boolean AS $$
|
||||
BEGIN
|
||||
-- Simple heuristic: if path has an extension, it's likely a file
|
||||
-- This isn't perfect but works for most cases
|
||||
IF file_path IS NULL OR file_path = '' THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
-- Check if the last part after the final slash contains a dot
|
||||
RETURN get_path_filename(file_path) LIKE '%.%';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- View for hierarchical file browser queries
|
||||
-- This creates virtual folder entries for efficient navigation
|
||||
CREATE OR REPLACE VIEW file_listings_hierarchy AS
|
||||
WITH RECURSIVE folder_paths AS (
|
||||
-- Get all unique folder paths from file paths
|
||||
SELECT DISTINCT
|
||||
source,
|
||||
get_path_parent(path) as folder_path,
|
||||
get_path_depth(get_path_parent(path)) as depth
|
||||
FROM file_listings
|
||||
WHERE get_path_parent(path) != ''
|
||||
|
||||
UNION
|
||||
|
||||
-- Add parent folders recursively
|
||||
SELECT
|
||||
source,
|
||||
get_path_parent(folder_path) as folder_path,
|
||||
get_path_depth(get_path_parent(folder_path)) as depth
|
||||
FROM folder_paths
|
||||
WHERE get_path_parent(folder_path) != '' AND get_path_parent(folder_path) != folder_path
|
||||
)
|
||||
SELECT
|
||||
source,
|
||||
folder_path as path,
|
||||
'folder' as item_type,
|
||||
null::uuid as object_id,
|
||||
'folder' as status,
|
||||
depth,
|
||||
get_path_parent(folder_path) as parent_path,
|
||||
get_path_filename(folder_path) as name
|
||||
FROM folder_paths
|
||||
WHERE folder_path != ''
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
source,
|
||||
path,
|
||||
'file' as item_type,
|
||||
object_id,
|
||||
status,
|
||||
get_path_depth(path) as depth,
|
||||
get_path_parent(path) as parent_path,
|
||||
get_path_filename(path) as name
|
||||
FROM file_listings;
|
||||
|
||||
|
||||
-----------------------
|
||||
-- CREATE UPDATE FIELD TRIGGER
|
||||
-----------------------
|
||||
@@ -362,6 +525,11 @@ CREATE OR REPLACE TRIGGER update_yara_rules_updated_at
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_agent_prompts_updated_at
|
||||
BEFORE UPDATE ON agent_prompts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_enrichments_updated_at
|
||||
BEFORE UPDATE ON enrichments
|
||||
FOR EACH ROW
|
||||
@@ -382,6 +550,15 @@ CREATE OR REPLACE TRIGGER update_files_enriched_dataset_updated_at
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_file_linkings_updated_at
|
||||
BEFORE UPDATE ON file_linkings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_file_listings_updated_at
|
||||
BEFORE UPDATE ON file_listings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
|
||||
-----------------------
|
||||
@@ -397,3 +574,244 @@ CREATE TABLE IF NOT EXISTS workflows (
|
||||
runtime_seconds REAL,
|
||||
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-----------------------
|
||||
-- Container Processing Tracking
|
||||
-----------------------
|
||||
CREATE TABLE IF NOT EXISTS container_processing (
|
||||
container_id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
container_type VARCHAR(50) NOT NULL,
|
||||
original_filename VARCHAR(255),
|
||||
original_size BIGINT,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'submitted',
|
||||
total_files_extracted INTEGER DEFAULT 0,
|
||||
total_bytes_extracted INTEGER DEFAULT 0,
|
||||
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expiration TIMESTAMP WITH TIME ZONE,
|
||||
processing_started_at TIMESTAMP WITH TIME ZONE,
|
||||
processing_completed_at TIMESTAMP WITH TIME ZONE,
|
||||
error_message TEXT,
|
||||
workflows_completed INTEGER DEFAULT 0,
|
||||
workflows_failed INTEGER DEFAULT 0,
|
||||
workflows_total INTEGER DEFAULT 0,
|
||||
total_bytes_processed INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
|
||||
-- Create phoenix database
|
||||
CREATE DATABASE phoenix;
|
||||
|
||||
|
||||
-----------------------
|
||||
-- Chromium schema/tables
|
||||
-----------------------
|
||||
|
||||
CREATE SCHEMA chromium;
|
||||
|
||||
-- "urls" table in "History" file
|
||||
CREATE TABLE IF NOT EXISTS chromium.history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
originating_object_id UUID,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
username TEXT, -- username extracted from user data directory, if applicable
|
||||
browser TEXT, -- browser name extracted from user data directory, if applicable
|
||||
|
||||
url TEXT, -- extracted from the Chromium DB
|
||||
title TEXT, -- extracted from the Chromium DB
|
||||
visit_count INTEGER, -- extracted from the Chromium DB
|
||||
last_visit_time TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
|
||||
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
|
||||
UNIQUE (source, username, browser, url, title, last_visit_time)
|
||||
);
|
||||
|
||||
-- "downloads" table in "History" file
|
||||
CREATE TABLE IF NOT EXISTS chromium.downloads (
|
||||
id SERIAL PRIMARY KEY,
|
||||
originating_object_id UUID,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
username TEXT, -- username extracted from user data directory, if applicable
|
||||
browser TEXT, -- browser name extracted from user data directory, if applicable
|
||||
|
||||
url TEXT, -- extracted from the Chromium DB
|
||||
download_path TEXT, -- extracted from the Chromium DB `target_path` field
|
||||
start_time TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
end_time TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
total_bytes INTEGER, -- extracted from the Chromium DB
|
||||
|
||||
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
|
||||
UNIQUE (source, username, browser, url, download_path, start_time)
|
||||
);
|
||||
|
||||
-- extracted from a Chromium "Local State" file
|
||||
CREATE TABLE chromium.state_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
originating_object_id UUID,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
username TEXT, -- username extracted from user data directory, if applicable
|
||||
browser TEXT, -- browser name extracted from user data directory, if applicable
|
||||
|
||||
key_masterkey_guid UUID, -- associated masterkey GUID for key_bytes_enc
|
||||
key_bytes_enc BYTEA, -- os_crypt.encrypted_key in Chromium `Local State` file (pre v127)
|
||||
key_bytes_dec BYTEA,
|
||||
key_is_decrypted BOOLEAN,
|
||||
|
||||
app_bound_key_enc BYTEA, -- os_crypt.app_bound_encrypted_key in Chromium `Local State` file (post v127)
|
||||
app_bound_key_system_masterkey_guid UUID, -- associated _system_ masterkey GUID for key_bytes_enc
|
||||
app_bound_key_system_dec BYTEA, -- intermediate dec value after the SYSTEM key has been used
|
||||
app_bound_key_user_masterkey_guid UUID, -- associated _user_ masterkey GUID for app_bound_key_system_dec
|
||||
app_bound_key_user_dec BYTEA, -- intermediate dec value after the USER key has been used (before chromekey for v3)
|
||||
app_bound_key_dec BYTEA, -- completely dec value
|
||||
app_bound_key_is_decrypted BOOLEAN,
|
||||
|
||||
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
|
||||
UNIQUE (source, username, browser)
|
||||
);
|
||||
|
||||
-- AES keys extracted from a CNG "Google Chromekey1" CNG file from C:\ProgramData\Microsoft\Crypto\SystemKeys\
|
||||
-- Used in v3 of the Chromium ABE decryption
|
||||
CREATE TABLE chromium.chrome_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
originating_object_id UUID,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000), -- should only be one key per host/source
|
||||
project VARCHAR(255),
|
||||
|
||||
key_masterkey_guid UUID, -- associated _system_ masterkey GUID for key_bytes_enc
|
||||
key_bytes_enc BYTEA, -- the raw DPAPI blob bytes from the CNG file
|
||||
key_bytes_dec BYTEA, -- completely dec AES key value
|
||||
key_is_decrypted BOOLEAN,
|
||||
|
||||
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
|
||||
UNIQUE (key_masterkey_guid)
|
||||
);
|
||||
|
||||
-- Create indexes for masterkey GUID lookups on state_keys
|
||||
CREATE INDEX IF NOT EXISTS idx_state_keys_key_masterkey_guid ON chromium.state_keys(key_masterkey_guid) WHERE key_is_decrypted = FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_state_keys_app_bound_system_mk_guid ON chromium.state_keys(app_bound_key_system_masterkey_guid) WHERE length(app_bound_key_system_dec) = 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_state_keys_app_bound_user_mk_guid ON chromium.state_keys(app_bound_key_user_masterkey_guid) WHERE length(app_bound_key_user_dec) = 0;
|
||||
|
||||
-- "logins" table in "Login Data" file
|
||||
CREATE TABLE IF NOT EXISTS chromium.logins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
originating_object_id UUID,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
username TEXT, -- username extracted from user data directory, if applicable
|
||||
browser TEXT, -- browser name extracted from user data directory, if applicable
|
||||
|
||||
origin_url TEXT, -- extracted from the Chromium DB
|
||||
username_value TEXT, -- extracted from the Chromium DB
|
||||
signon_realm TEXT, -- extracted from the Chromium DB
|
||||
date_created TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
date_last_used TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
date_password_modified TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
times_used INTEGER, -- extracted from the Chromium DB
|
||||
|
||||
encryption_type TEXT, -- carved from the `password_value_enc` bytes - dpapi, key, or abe (app-bound-encryption)
|
||||
masterkey_guid UUID, -- if encryption_type == dpapi, associated masterkey GUID
|
||||
state_key_id INTEGER, -- if encryption_type != dpapi, linked to "id" in `chromium.state_keys`
|
||||
is_decrypted BOOLEAN,
|
||||
password_value_enc BYTEA, -- extracted from the Chromium DB `password_value` field
|
||||
password_value_dec TEXT,
|
||||
|
||||
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (state_key_id) REFERENCES chromium.state_keys(id) ON DELETE SET NULL,
|
||||
UNIQUE (source, username, browser, origin_url, username_value)
|
||||
);
|
||||
|
||||
-- "cookies" table in "Cookies" file
|
||||
CREATE TABLE IF NOT EXISTS chromium.cookies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
originating_object_id UUID,
|
||||
agent_id VARCHAR(255),
|
||||
source VARCHAR(1000),
|
||||
project VARCHAR(255),
|
||||
username TEXT, -- username extracted from user data directory, if applicable
|
||||
browser TEXT, -- browser name extracted from user data directory, if applicable
|
||||
|
||||
host_key TEXT, -- extracted from the Chromium DB
|
||||
name TEXT, -- extracted from the Chromium DB
|
||||
path TEXT, -- extracted from the Chromium DB
|
||||
creation_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
expires_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
last_access_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
last_update_utc TIMESTAMP WITH TIME ZONE, -- extracted from the Chromium DB
|
||||
is_secure BOOLEAN, -- extracted from the Chromium DB
|
||||
is_httponly BOOLEAN, -- extracted from the Chromium DB
|
||||
is_persistent BOOLEAN, -- extracted from the Chromium DB
|
||||
samesite TEXT, -- extracted from the Chromium DB, translated from int
|
||||
source_port INTEGER, -- extracted from the Chromium DB
|
||||
|
||||
encryption_type TEXT, -- carved from the `encrypted_value` field - dpapi, key, or abe (app-bound-encryption)
|
||||
masterkey_guid UUID, -- if encryption_type == dpapi, associated masterkey GUID
|
||||
state_key_id INTEGER, -- if encryption_type != dpapi, linked to "id" in `chromium.state_keys`
|
||||
is_decrypted BOOLEAN,
|
||||
value_enc BYTEA, -- extracted from the Chromium DB `encrypted_value` field
|
||||
value_dec TEXT,
|
||||
|
||||
FOREIGN KEY (originating_object_id) REFERENCES files_enriched(object_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (state_key_id) REFERENCES chromium.state_keys(id) ON DELETE SET NULL,
|
||||
UNIQUE (source, username, browser, host_key, name, path)
|
||||
);
|
||||
|
||||
-- DPAPI tables
|
||||
CREATE SCHEMA dpapi;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dpapi.masterkeys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guid TEXT UNIQUE NOT NULL,
|
||||
encrypted_key_usercred BYTEA,
|
||||
encrypted_key_backup BYTEA,
|
||||
plaintext_key BYTEA,
|
||||
plaintext_key_sha1 BYTEA,
|
||||
backup_key_guid TEXT,
|
||||
masterkey_type TEXT DEFAULT 'unknown',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dpapi.domain_backup_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guid TEXT UNIQUE NOT NULL,
|
||||
key_data BYTEA NOT NULL,
|
||||
domain_controller TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dpapi.system_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_key BYTEA NOT NULL,
|
||||
machine_key BYTEA NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_key, machine_key)
|
||||
);
|
||||
|
||||
-- Create triggers for DPAPI tables
|
||||
CREATE OR REPLACE TRIGGER update_dpapi_masterkeys_updated_at
|
||||
BEFORE UPDATE ON dpapi.masterkeys
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_dpapi_domain_backup_keys_updated_at
|
||||
BEFORE UPDATE ON dpapi.domain_backup_keys
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_dpapi_system_credentials_updated_at
|
||||
BEFORE UPDATE ON dpapi.system_credentials
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -9,20 +9,128 @@ scrape_configs:
|
||||
- targets: ['traefik:8080']
|
||||
|
||||
- job_name: 'minio'
|
||||
metrics_path: /minio/v2/metrics/cluster
|
||||
metrics_path: /minio/metrics/v3/cluster/health
|
||||
scheme: http
|
||||
static_configs:
|
||||
- targets: ['minio:9000']
|
||||
|
||||
- job_name: 'minio-api'
|
||||
metrics_path: /minio/metrics/v3/api/requests
|
||||
scheme: http
|
||||
static_configs:
|
||||
- targets: ['minio:9000']
|
||||
|
||||
- job_name: 'minio-bucket-api'
|
||||
metrics_path: /minio/metrics/v3/bucket/api
|
||||
scheme: http
|
||||
static_configs:
|
||||
- targets: ['minio:9000']
|
||||
|
||||
- job_name: 'minio-cluster-usage-buckets'
|
||||
metrics_path: /minio/metrics/v3/cluster/usage/buckets
|
||||
scheme: http
|
||||
static_configs:
|
||||
- targets: ['minio:9000']
|
||||
|
||||
- job_name: 'minio-cluster-usage-objects'
|
||||
metrics_path: /minio/metrics/v3/cluster/usage/objects
|
||||
scheme: http
|
||||
static_configs:
|
||||
- targets: ['minio:9000']
|
||||
|
||||
- job_name: 'minio-drives'
|
||||
metrics_path: /minio/metrics/v3/system/drive
|
||||
scheme: http
|
||||
static_configs:
|
||||
- targets: ['minio:9000']
|
||||
|
||||
- job_name: 'loki'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['loki:3100']
|
||||
|
||||
- job_name: 'prometheus'
|
||||
metrics_path: '/prometheus/metrics'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
- job_name: 'node-exporter'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
|
||||
- job_name: 'cadvisor'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['cadvisor:8080']
|
||||
- targets: ['cadvisor:8080']
|
||||
|
||||
- job_name: 'postgres'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['postgres-exporter:9187']
|
||||
|
||||
- job_name: 'rabbitmq'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['rabbitmq:15692']
|
||||
|
||||
# Reserved for enterprise edition only?
|
||||
# - job_name: 'hasura'
|
||||
# metrics_path: '/v1/metrics'
|
||||
# static_configs:
|
||||
# - targets: ['hasura:8080']
|
||||
|
||||
- job_name: 'nemesis-web-api'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['web-api:9090']
|
||||
|
||||
- job_name: 'nemesis-noseyparker-scanner'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['noseyparker-scanner:9090']
|
||||
|
||||
- job_name: 'nemesis-dotnet-service'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['dotnet-service:9090']
|
||||
|
||||
- job_name: 'nemesis-file-enrichment'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['file-enrichment:9090']
|
||||
|
||||
- job_name: 'nemesis-alerting'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['alerting:9090']
|
||||
|
||||
- job_name: 'nemesis-agents'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['agents:9090']
|
||||
|
||||
- job_name: 'nemesis-housekeeping'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['housekeeping:9090']
|
||||
|
||||
- job_name: 'nemesis-document-conversion'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['document-conversion:9090']
|
||||
|
||||
- job_name: 'nemesis-gotenberg'
|
||||
metrics_path: '/prometheus/metrics'
|
||||
static_configs:
|
||||
- targets: ['gotenberg:3000']
|
||||
|
||||
- job_name: 'nemesis-placement'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['placement:9090']
|
||||
|
||||
- job_name: 'nemesis-scheduler'
|
||||
metrics_path: '/metrics'
|
||||
static_configs:
|
||||
- targets: ['scheduler:9090']
|
||||
@@ -0,0 +1 @@
|
||||
[rabbitmq_management,rabbitmq_prometheus].
|
||||
@@ -6,6 +6,64 @@
|
||||
<maxFiles>10</maxFiles>
|
||||
<numConsumers>5</numConsumers>
|
||||
<timeoutThresholdMillis>300000</timeoutThresholdMillis>
|
||||
<!-- Increase max file size for large documents -->
|
||||
<maxFileSize>1073741824</maxFileSize>
|
||||
</params>
|
||||
</server>
|
||||
|
||||
<parsers>
|
||||
<!-- Needed to ensure existing parsers fire -->
|
||||
<parser class="org.apache.tika.parser.DefaultParser"/>
|
||||
|
||||
<parser class="org.apache.tika.parser.ocr.TesseractOCRParser">
|
||||
<params>
|
||||
<!-- Multi-language support: add languages as needed (e.g., eng+spa+fra+deu) -->
|
||||
<param name="language" type="string">eng</param>
|
||||
|
||||
<!-- Timeout in seconds - adjust based on document complexity -->
|
||||
<param name="timeout" type="int">300</param>
|
||||
|
||||
<!-- Image preprocessing improves accuracy -->
|
||||
<param name="enableImageProcessing" type="bool">true</param>
|
||||
|
||||
<!-- Page segmentation mode: 3 is fully automatic (best for most documents) -->
|
||||
<param name="pageSegMode" type="string">3</param>
|
||||
|
||||
<!-- DPI for rendering PDFs to images - 300 is optimal for OCR -->
|
||||
<param name="density" type="int">300</param>
|
||||
|
||||
<!-- Image depth - 4 bits is good balance between speed and quality -->
|
||||
<param name="depth" type="int">4</param>
|
||||
|
||||
<!-- Color space - gray is faster, sufficient for most text -->
|
||||
<param name="colorspace" type="string">gray</param>
|
||||
|
||||
<!-- Filter for better preprocessing -->
|
||||
<param name="filter" type="string">triangle</param>
|
||||
|
||||
<!-- Resize images if too large (max dimension in pixels) -->
|
||||
<param name="resize" type="int">900</param>
|
||||
|
||||
<!-- Apply image processing enhancements -->
|
||||
<param name="applyRotation" type="bool">true</param>
|
||||
</params>
|
||||
</parser>
|
||||
|
||||
<!-- PDF Parser with OCR support -->
|
||||
<parser class="org.apache.tika.parser.pdf.PDFParser">
|
||||
<params>
|
||||
<!-- Auto: use OCR only when needed -->
|
||||
<param name="ocrStrategy" type="string">auto</param>
|
||||
<param name="extractInlineImages" type="bool">true</param>
|
||||
<param name="extractFontNames" type="bool">true</param>
|
||||
<!-- Prevent OOM on large PDFs -->
|
||||
<param name="maxMainMemoryBytes" type="long">536870912</param>
|
||||
</params>
|
||||
</parser>
|
||||
|
||||
</parsers>
|
||||
|
||||
<!-- Enable language detection -->
|
||||
<detector class="org.apache.tika.langdetect.optimaize.OptimaizeLangDetector"/>
|
||||
|
||||
</properties>
|
||||
@@ -6,15 +6,13 @@
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
"source.fixAll.ruff": "explicit",
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"autoDocstring.docstringFormat": "google",
|
||||
"editor.formatOnSave": true,
|
||||
"files.exclude": {
|
||||
"**/.DS_Store": true,
|
||||
"**/.git": true,
|
||||
@@ -39,27 +37,18 @@
|
||||
"**/*.egg-info/**": true,
|
||||
"**/build/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/node_modules/*/**": true,
|
||||
"**/node_modules/*/**": true
|
||||
},
|
||||
"python.analysis.diagnosticSeverityOverrides": {
|
||||
"reportMissingImports": "none",
|
||||
"reportMissingModuleSource": "none",
|
||||
"reportMissingModuleSource": "none"
|
||||
},
|
||||
"python.analysis.useLibraryCodeForTypes": true, // Pyright
|
||||
// "python.formatting.provider": "none",
|
||||
"python.languageServer": "Pylance",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.flake8Args": [
|
||||
"--max-line-length=240",
|
||||
"--ignore=E203,E722,W503",
|
||||
],
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.linting.lintOnSave": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Chromium
|
||||
A Chromium helper library.
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Chromium utility library for Chromium focused operations."""
|
||||
|
||||
from .chromekey import retry_decrypt_chrome_keys_for_masterkey
|
||||
from .cookies import process_chromium_cookies
|
||||
from .helpers import convert_chromium_timestamp
|
||||
from .history import process_chromium_history
|
||||
from .local_state import (
|
||||
process_chromium_local_state,
|
||||
retry_decrypt_state_keys_for_chromekey,
|
||||
retry_decrypt_state_keys_for_masterkey,
|
||||
)
|
||||
from .logins import process_chromium_logins
|
||||
from .retry import retry_decrypt_chromium_data
|
||||
|
||||
__all__ = [
|
||||
"convert_chromium_timestamp",
|
||||
"process_chromium_history",
|
||||
"process_chromium_cookies",
|
||||
"process_chromium_logins",
|
||||
"process_chromium_local_state",
|
||||
"retry_decrypt_chrome_keys_for_masterkey",
|
||||
"retry_decrypt_chromium_data",
|
||||
"retry_decrypt_state_keys_for_chromekey",
|
||||
"retry_decrypt_state_keys_for_masterkey",
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Chrome Key and database operations."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from common.logger import get_logger
|
||||
from file_enrichment_modules.cng_file.cng_parser import check_bcrypt_key_blob, extract_final_key_material
|
||||
from nemesis_dpapi import Blob, DpapiManager, MasterKeyNotDecryptedError, MasterKeyNotFoundError
|
||||
|
||||
from .helpers import get_postgres_connection_str
|
||||
from .local_state import retry_decrypt_state_keys_for_chromekey
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _retry_state_keys_for_chromekey_after_decrypt(source: str, chromekey: bytes):
|
||||
"""Helper to retry state key decryption after a chromekey is decrypted.
|
||||
|
||||
Import is done here to avoid circular dependency issues.
|
||||
"""
|
||||
return await retry_decrypt_state_keys_for_chromekey(source, chromekey)
|
||||
|
||||
|
||||
async def retry_decrypt_chrome_key(chrome_key_id: int, dpapi_manager: DpapiManager, pg_conn) -> dict:
|
||||
"""Attempt to decrypt a single chrome_key record using currently available masterkeys.
|
||||
|
||||
Args:
|
||||
chrome_key_id: The ID of the chrome_key record to decrypt
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
pg_conn: PostgreSQL connection
|
||||
|
||||
Returns:
|
||||
Dict with decryption results: {
|
||||
"decrypted": bool,
|
||||
"state_keys_result": dict (optional, if decryption succeeded)
|
||||
}
|
||||
"""
|
||||
result = {"decrypted": False}
|
||||
cng_key_blob_entropy = b'xT5rZW5qVVbrvpuA\x00'
|
||||
|
||||
# Fetch the chrome_key record
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source, key_masterkey_guid, key_bytes_enc,
|
||||
key_bytes_dec, key_is_decrypted
|
||||
FROM chromium.chrome_keys
|
||||
WHERE id = %s
|
||||
""",
|
||||
(chrome_key_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.warning("Chrome key not found", chrome_key_id=chrome_key_id)
|
||||
return result
|
||||
|
||||
(record_id, source, key_masterkey_guid, key_bytes_enc, key_bytes_dec, key_is_decrypted) = row
|
||||
|
||||
# Skip if already decrypted
|
||||
if key_is_decrypted:
|
||||
return result
|
||||
|
||||
# Try to decrypt the chrome key
|
||||
if key_bytes_enc and len(key_bytes_enc) > 0:
|
||||
try:
|
||||
dpapi_blob = Blob.from_bytes(key_bytes_enc)
|
||||
try:
|
||||
decrypted_blob = await dpapi_manager.decrypt_blob(dpapi_blob, entropy=cng_key_blob_entropy)
|
||||
if decrypted_blob:
|
||||
# Check for BCRYPT_KEY_DATA_BLOB and log details
|
||||
if check_bcrypt_key_blob(decrypted_blob):
|
||||
|
||||
# Extract the final 32-byte key material
|
||||
key_bytes_dec = extract_final_key_material(decrypted_blob)
|
||||
|
||||
if key_bytes_dec:
|
||||
key_is_decrypted = True
|
||||
result["decrypted"] = True
|
||||
logger.info(
|
||||
"Successfully decrypted and extracted chrome_key material",
|
||||
chrome_key_id=chrome_key_id,
|
||||
masterkey_guid=dpapi_blob.masterkey_guid,
|
||||
)
|
||||
|
||||
# Update database
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.chrome_keys
|
||||
SET key_bytes_dec = %s, key_is_decrypted = %s,
|
||||
key_masterkey_guid = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(key_bytes_dec, key_is_decrypted, dpapi_blob.masterkey_guid, chrome_key_id),
|
||||
)
|
||||
|
||||
# Commit the chrome_key update before trying state_keys
|
||||
pg_conn.commit()
|
||||
|
||||
# Now try to decrypt any state_keys waiting for this chromekey
|
||||
try:
|
||||
state_keys_result = await _retry_state_keys_for_chromekey_after_decrypt(source, key_bytes_dec)
|
||||
result["state_keys_result"] = state_keys_result
|
||||
logger.info(
|
||||
"Completed retroactive state_key decryption for newly decrypted chromekey",
|
||||
chrome_key_id=chrome_key_id,
|
||||
source=source,
|
||||
state_keys_result=state_keys_result,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error retrying state_keys after chromekey decryption",
|
||||
chrome_key_id=chrome_key_id,
|
||||
source=source,
|
||||
error=str(e),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to extract final key material from decrypted chrome_key",
|
||||
chrome_key_id=chrome_key_id,
|
||||
)
|
||||
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError):
|
||||
# Masterkey still not available, skip silently
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Error decrypting chrome_key", chrome_key_id=chrome_key_id, error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error processing chrome_key", chrome_key_id=chrome_key_id, error=str(e))
|
||||
else:
|
||||
# Commit even if no decryption happened (for consistency)
|
||||
pg_conn.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def retry_decrypt_chrome_keys_for_masterkey(
|
||||
masterkey_guid: UUID, dpapi_manager: DpapiManager, masterkey_type: str | None = None
|
||||
) -> dict:
|
||||
"""Find all chrome_keys waiting for this masterkey and try to decrypt them.
|
||||
|
||||
Args:
|
||||
masterkey_guid: The GUID of the newly available masterkey
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
masterkey_type: Optional masterkey type ('system', 'user', 'unknown') for optimization
|
||||
|
||||
Returns:
|
||||
Dict with statistics: {
|
||||
"chrome_keys_attempted": int,
|
||||
"chrome_keys_decrypted": int,
|
||||
"errors": list
|
||||
}
|
||||
"""
|
||||
result = {"chrome_keys_attempted": 0, "chrome_keys_decrypted": 0, "errors": []}
|
||||
|
||||
# Chrome keys only use SYSTEM masterkeys, so skip if this is a USER key
|
||||
if masterkey_type == "user":
|
||||
logger.debug(
|
||||
"Skipping chrome_key decryption for USER masterkey",
|
||||
masterkey_guid=masterkey_guid,
|
||||
masterkey_type=masterkey_type,
|
||||
)
|
||||
return result
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
|
||||
try:
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
# Find all chrome_keys that might need this masterkey
|
||||
with pg_conn.cursor() as cur:
|
||||
# SYSTEM keys are used for chrome_keys, but if type is unknown, still try
|
||||
query = """
|
||||
SELECT DISTINCT id FROM chromium.chrome_keys
|
||||
WHERE key_masterkey_guid = %s AND key_is_decrypted = FALSE
|
||||
"""
|
||||
cur.execute(query, (masterkey_guid,))
|
||||
|
||||
chrome_key_ids = [row[0] for row in cur.fetchall()]
|
||||
|
||||
logger.debug(
|
||||
"Found chrome_keys potentially waiting for masterkey",
|
||||
masterkey_guid=masterkey_guid,
|
||||
masterkey_type=masterkey_type,
|
||||
count=len(chrome_key_ids),
|
||||
)
|
||||
|
||||
# Try to decrypt each chrome_key
|
||||
for chrome_key_id in chrome_key_ids:
|
||||
result["chrome_keys_attempted"] += 1
|
||||
try:
|
||||
decrypt_result = await retry_decrypt_chrome_key(chrome_key_id, dpapi_manager, pg_conn)
|
||||
|
||||
# Check if decryption succeeded
|
||||
if decrypt_result["decrypted"]:
|
||||
result["chrome_keys_decrypted"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing chrome_key {chrome_key_id}: {str(e)}"
|
||||
logger.warning("Failed to retry decrypt chrome_key", chrome_key_id=chrome_key_id, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
logger.info(
|
||||
"Completed retroactive chrome_key decryption for masterkey",
|
||||
masterkey_guid=masterkey_guid,
|
||||
attempted=result["chrome_keys_attempted"],
|
||||
decrypted=result["chrome_keys_decrypted"],
|
||||
errors=len(result["errors"]),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Database error during retroactive chrome_key decryption: {str(e)}"
|
||||
logger.exception(
|
||||
"Error in retry_decrypt_chrome_keys_for_masterkey", masterkey_guid=masterkey_guid, error=str(e)
|
||||
)
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Chromium Cookies file parsing and database operations."""
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
import psycopg
|
||||
from common.logger import get_logger
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
from nemesis_dpapi import Blob, DpapiManager
|
||||
|
||||
from .helpers import (
|
||||
convert_chromium_timestamp,
|
||||
decrypt_chrome_string,
|
||||
detect_encryption_type,
|
||||
get_postgres_connection_str,
|
||||
get_state_key_bytes,
|
||||
get_state_key_id,
|
||||
is_sqlite3,
|
||||
parse_chromium_file_path,
|
||||
try_decrypt_with_all_keys,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def process_chromium_cookies(
|
||||
object_id: str, file_path: str | None = None, dpapi_manager: DpapiManager | None = None
|
||||
) -> None:
|
||||
"""Process Chromium Cookies file and insert cookies into database.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the Cookies file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
logger.info("Processing Chromium Cookies file", object_id=object_id)
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
# Extract username and browser from file path
|
||||
username, browser = parse_chromium_file_path(file_enriched.path or "")
|
||||
logger.debug("[process_chromium_cookies]", username=username, browser=browser)
|
||||
|
||||
# Get database file
|
||||
if file_path:
|
||||
db_path = file_path
|
||||
else:
|
||||
storage = StorageMinio()
|
||||
with storage.download(file_enriched.object_id) as temp_file:
|
||||
db_path = temp_file.name
|
||||
|
||||
if is_sqlite3(db_path) is False:
|
||||
logger.warning("File is not a valid SQLite3 database", object_id=object_id, file_path=file_enriched.path)
|
||||
return
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
_insert_cookies(file_enriched, username, browser, db_path, pg_conn, dpapi_manager)
|
||||
|
||||
logger.debug("Completed processing Chromium Cookies", object_id=object_id)
|
||||
|
||||
|
||||
def _translate_samesite(samesite_int: int) -> str:
|
||||
"""Translate samesite integer value to string.
|
||||
|
||||
Args:
|
||||
samesite_int: Integer value from database
|
||||
|
||||
Returns:
|
||||
String representation of samesite value
|
||||
"""
|
||||
samesite_map = {-1: "Unspecified", 0: "None", 1: "Lax", 2: "Strict"}
|
||||
return samesite_map.get(samesite_int, "Unknown")
|
||||
|
||||
|
||||
def _insert_cookies(
|
||||
file_enriched, username: str | None, browser: str, db_path: str, pg_conn, dpapi_manager: DpapiManager | None = None
|
||||
) -> None:
|
||||
"""Extract cookies from Cookies database and insert into chromium.cookies table."""
|
||||
try:
|
||||
# Read from SQLite
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.text_factory = bytes # Get raw bytes, we'll handle text decoding manually
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Query cookies table
|
||||
cursor.execute("""
|
||||
SELECT host_key, name, path, creation_utc, expires_utc, last_access_utc,
|
||||
last_update_utc, is_secure, is_httponly, is_persistent, samesite,
|
||||
source_port, encrypted_value
|
||||
FROM cookies
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# Prepare data for PostgreSQL
|
||||
cookies_data = []
|
||||
for row in rows:
|
||||
(
|
||||
host_key,
|
||||
name,
|
||||
path,
|
||||
creation_utc,
|
||||
expires_utc,
|
||||
last_access_utc,
|
||||
last_update_utc,
|
||||
is_secure,
|
||||
is_httponly,
|
||||
is_persistent,
|
||||
samesite,
|
||||
source_port,
|
||||
encrypted_value,
|
||||
) = row
|
||||
|
||||
# Decode text fields from bytes (since we set text_factory = bytes)
|
||||
host_key = host_key.decode("utf-8", errors="replace") if host_key else None
|
||||
name = name.decode("utf-8", errors="replace") if name else None
|
||||
path = path.decode("utf-8", errors="replace") if path else None
|
||||
|
||||
# encrypted_value is already binary (what we want)
|
||||
|
||||
if encrypted_value is None:
|
||||
encrypted_value = b""
|
||||
|
||||
# Detect encryption type and get masterkey GUID (if applicable)
|
||||
encryption_type, masterkey_guid = detect_encryption_type(encrypted_value)
|
||||
is_decrypted = False
|
||||
value_dec = None
|
||||
if masterkey_guid and dpapi_manager:
|
||||
try:
|
||||
value_dec_bytes = asyncio.run(dpapi_manager.decrypt_blob(Blob.from_bytes(encrypted_value)))
|
||||
if value_dec_bytes:
|
||||
value_dec = value_dec_bytes.decode("utf-8", errors="replace")
|
||||
is_decrypted = True
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get state key ID for key/abe encryption
|
||||
state_key_id = None
|
||||
if encryption_type in ["key", "abe"]:
|
||||
# Try primary approach: get state key based on username/browser
|
||||
if username: # Only try primary approach if username was extracted
|
||||
state_key_id = get_state_key_id(file_enriched.source, username, browser, pg_conn)
|
||||
if state_key_id:
|
||||
# Retrieve the pre-processed state key for decryption
|
||||
state_key_bytes = get_state_key_bytes(state_key_id, encryption_type, pg_conn)
|
||||
if state_key_bytes:
|
||||
try:
|
||||
value_dec_bytes = decrypt_chrome_string(
|
||||
encrypted_value, state_key_bytes, encryption_type
|
||||
)
|
||||
if value_dec_bytes:
|
||||
# For cookies, may need to strip offset bytes depending on version
|
||||
if encryption_type == "abe" and len(value_dec_bytes) > 32:
|
||||
# v20 cookies typically have 32-byte offset
|
||||
value_dec_bytes = value_dec_bytes[32:]
|
||||
elif encryption_type == "key" and len(value_dec_bytes) > 48:
|
||||
# v10/v11 cookies may have 32-byte prefix + 16-byte suffix
|
||||
value_dec_bytes = value_dec_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(value_dec_bytes) > 16:
|
||||
# Or just 16-byte suffix
|
||||
value_dec_bytes = value_dec_bytes[:-16]
|
||||
|
||||
value_dec = value_dec_bytes.decode("utf-8", errors="replace")
|
||||
is_decrypted = True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to decrypt cookie with state key",
|
||||
state_key_id=state_key_id,
|
||||
encryption_type=encryption_type,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Backup approach: try all state keys from the same source if primary failed
|
||||
if not is_decrypted:
|
||||
logger.debug(
|
||||
"Primary decryption failed, trying backup approach with all keys from source",
|
||||
source=file_enriched.source,
|
||||
encryption_type=encryption_type,
|
||||
)
|
||||
backup_decrypted_bytes, backup_state_key_id = try_decrypt_with_all_keys(
|
||||
encrypted_value, file_enriched.source, encryption_type, pg_conn
|
||||
)
|
||||
if backup_decrypted_bytes and backup_state_key_id:
|
||||
# Apply the same offset handling as above
|
||||
if encryption_type == "abe" and len(backup_decrypted_bytes) > 32:
|
||||
# v20 cookies typically have 32-byte offset
|
||||
backup_decrypted_bytes = backup_decrypted_bytes[32:]
|
||||
elif encryption_type == "key" and len(backup_decrypted_bytes) > 48:
|
||||
# v10/v11 cookies may have 32-byte prefix + 16-byte suffix
|
||||
backup_decrypted_bytes = backup_decrypted_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(backup_decrypted_bytes) > 16:
|
||||
# Or just 16-byte suffix
|
||||
backup_decrypted_bytes = backup_decrypted_bytes[:-16]
|
||||
|
||||
value_dec = backup_decrypted_bytes.decode("utf-8", errors="replace")
|
||||
is_decrypted = True
|
||||
state_key_id = backup_state_key_id
|
||||
logger.debug(
|
||||
"Successfully decrypted cookie using backup approach", state_key_id=backup_state_key_id
|
||||
)
|
||||
|
||||
cookie_data = {
|
||||
"originating_object_id": file_enriched.object_id,
|
||||
"agent_id": file_enriched.agent_id,
|
||||
"source": file_enriched.source,
|
||||
"project": file_enriched.project,
|
||||
"username": username,
|
||||
"browser": browser,
|
||||
"host_key": host_key,
|
||||
"name": name,
|
||||
"path": path,
|
||||
"creation_utc": convert_chromium_timestamp(creation_utc),
|
||||
"expires_utc": convert_chromium_timestamp(expires_utc),
|
||||
"last_access_utc": convert_chromium_timestamp(last_access_utc),
|
||||
"last_update_utc": convert_chromium_timestamp(last_update_utc),
|
||||
"is_secure": bool(is_secure),
|
||||
"is_httponly": bool(is_httponly),
|
||||
"is_persistent": bool(is_persistent),
|
||||
"samesite": _translate_samesite(samesite) if samesite is not None else "Unknown",
|
||||
"source_port": source_port,
|
||||
"encryption_type": encryption_type,
|
||||
"masterkey_guid": masterkey_guid,
|
||||
"state_key_id": state_key_id,
|
||||
"is_decrypted": is_decrypted,
|
||||
"value_enc": encrypted_value,
|
||||
"value_dec": value_dec,
|
||||
}
|
||||
cookies_data.append(cookie_data)
|
||||
|
||||
# Insert into PostgreSQL
|
||||
with pg_conn.cursor() as cur:
|
||||
insert_sql = """
|
||||
INSERT INTO chromium.cookies
|
||||
(originating_object_id, agent_id, source, project, username, browser,
|
||||
host_key, name, path, creation_utc, expires_utc, last_access_utc,
|
||||
last_update_utc, is_secure, is_httponly, is_persistent, samesite,
|
||||
source_port, encryption_type, masterkey_guid, state_key_id,
|
||||
is_decrypted, value_enc, value_dec)
|
||||
VALUES (%(originating_object_id)s, %(agent_id)s, %(source)s, %(project)s,
|
||||
%(username)s, %(browser)s, %(host_key)s, %(name)s, %(path)s,
|
||||
%(creation_utc)s, %(expires_utc)s, %(last_access_utc)s,
|
||||
%(last_update_utc)s, %(is_secure)s, %(is_httponly)s, %(is_persistent)s,
|
||||
%(samesite)s, %(source_port)s, %(encryption_type)s, %(masterkey_guid)s,
|
||||
%(state_key_id)s, %(is_decrypted)s, %(value_enc)s, %(value_dec)s)
|
||||
ON CONFLICT (source, username, browser, host_key, name, path)
|
||||
DO UPDATE SET
|
||||
host_key = EXCLUDED.host_key,
|
||||
name = EXCLUDED.name,
|
||||
path = EXCLUDED.path,
|
||||
creation_utc = EXCLUDED.creation_utc,
|
||||
expires_utc = EXCLUDED.expires_utc,
|
||||
last_access_utc = EXCLUDED.last_access_utc,
|
||||
last_update_utc = EXCLUDED.last_update_utc,
|
||||
is_secure = EXCLUDED.is_secure,
|
||||
is_httponly = EXCLUDED.is_httponly,
|
||||
is_persistent = EXCLUDED.is_persistent,
|
||||
samesite = EXCLUDED.samesite,
|
||||
source_port = EXCLUDED.source_port,
|
||||
encryption_type = EXCLUDED.encryption_type,
|
||||
masterkey_guid = EXCLUDED.masterkey_guid,
|
||||
state_key_id = EXCLUDED.state_key_id,
|
||||
is_decrypted = EXCLUDED.is_decrypted,
|
||||
value_enc = EXCLUDED.value_enc,
|
||||
value_dec = EXCLUDED.value_dec
|
||||
"""
|
||||
|
||||
cur.executemany(insert_sql, cookies_data)
|
||||
pg_conn.commit()
|
||||
|
||||
logger.info("Inserted cookies into database", count=len(cookies_data))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
e,
|
||||
"Error processing Cookies",
|
||||
object_id=file_enriched.object_id,
|
||||
file_path=file_enriched.path,
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Helper functions for Chromium data processing."""
|
||||
|
||||
import re
|
||||
import struct
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import psycopg
|
||||
from common.db import get_postgres_connection_str
|
||||
from common.logger import get_logger
|
||||
from Crypto.Cipher import AES, ChaCha20_Poly1305
|
||||
from nemesis_dpapi import Blob
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def is_sqlite3(filename):
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
header = f.read(16)
|
||||
return header.startswith(b"SQLite format 3\0")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def convert_chromium_timestamp(timestamp: int, str_format: bool = False) -> datetime | str | None:
|
||||
"""Convert Chromium timestamp to datetime.
|
||||
|
||||
Args:
|
||||
timestamp: Chromium timestamp (microseconds since 1601-01-01)
|
||||
str_format: Whether to return the timestamp in a string iso format (default is a datetime)
|
||||
|
||||
Returns:
|
||||
datetime object or None if invalid
|
||||
"""
|
||||
if not timestamp or timestamp == 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
epoch = datetime(1601, 1, 1, tzinfo=UTC)
|
||||
dt = epoch + timedelta(microseconds=timestamp)
|
||||
if str_format:
|
||||
return dt.isoformat()
|
||||
else:
|
||||
return dt
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_chromium_file_path(file_path: str) -> tuple[str | None, str]:
|
||||
"""Extract username and browser from Chromium file path.
|
||||
|
||||
Args:
|
||||
file_path: Path to the Chromium file
|
||||
|
||||
Returns:
|
||||
Tuple of (username, browser_name)
|
||||
"""
|
||||
|
||||
# Chrome/Edge/Brave pattern
|
||||
match = re.search(
|
||||
r".*/(?P<username>[^/]+)/AppData/Local/(?:Google|Microsoft|BraveSoftware)/(?P<browser>Chrome|Edge|Brave-Browser)/",
|
||||
file_path,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
if match:
|
||||
username = match.group("username").lower()
|
||||
browser_str = match.group("browser").lower()
|
||||
|
||||
if "chrome" in browser_str:
|
||||
return username, "chrome"
|
||||
elif "edge" in browser_str:
|
||||
return username, "edge"
|
||||
elif "brave" in browser_str:
|
||||
return username, "brave"
|
||||
|
||||
# Opera pattern
|
||||
match = re.search(r".*/(?P<username>[^/]+)/AppData/Roaming/Opera Software/Opera Stable/", file_path, re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
username = match.group("username").lower()
|
||||
return username, "opera"
|
||||
|
||||
return None, "unknown"
|
||||
|
||||
|
||||
def detect_encryption_type(encrypted_value: bytes) -> tuple[str, str | None]:
|
||||
"""Detect encryption type and extract masterkey GUID if applicable.
|
||||
|
||||
Args:
|
||||
encrypted_value: Raw encrypted value bytes
|
||||
|
||||
Returns:
|
||||
Tuple of (encryption_type, masterkey_guid)
|
||||
"""
|
||||
if not encrypted_value or len(encrypted_value) < 4:
|
||||
return "unknown", None
|
||||
|
||||
# Check for DPAPI (first 4 bytes are \x01\x00\x00\x00)
|
||||
if encrypted_value[:4] == b"\x01\x00\x00\x00":
|
||||
try:
|
||||
blob = Blob.from_bytes(encrypted_value)
|
||||
return "dpapi", str(blob.masterkey_guid)
|
||||
except Exception as e:
|
||||
raise Exception(f"Found DPAPI app bound key, but couldn't parse blob: {str(e)}") from e
|
||||
|
||||
# Check for key-based encryption (v10, v11)
|
||||
if len(encrypted_value) >= 3:
|
||||
prefix = encrypted_value[:3]
|
||||
try:
|
||||
prefix_str = prefix.decode("ascii")
|
||||
if prefix_str in ["v10", "v11"]:
|
||||
return "key", None
|
||||
elif prefix_str == "v20":
|
||||
return "abe", None
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def get_state_key_id(source: str, username: str | None, browser: str, pg_conn=None) -> int | None:
|
||||
"""Get state key ID for key/abe encryption types.
|
||||
|
||||
Args:
|
||||
source: Source value
|
||||
username: Username value
|
||||
browser: Browser value
|
||||
pg_conn: existing Postgres connection
|
||||
|
||||
Returns:
|
||||
State key ID if found, None otherwise
|
||||
"""
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM chromium.state_keys WHERE source = %s AND username = %s AND browser = %s",
|
||||
(source, username, browser),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup state key ID", error=str(e))
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM chromium.state_keys WHERE source = %s AND username = %s AND browser = %s",
|
||||
(source, username, browser),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup state key ID", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_state_key_bytes(state_key_id: int, encryption_type: str, pg_conn=None) -> bytes | None:
|
||||
"""Get decrypted state key bytes for key/abe encryption types.
|
||||
|
||||
Args:
|
||||
state_key_id: State key ID
|
||||
encryption_type: Either 'key' or 'abe'
|
||||
pg_conn: existing Postgres connection
|
||||
|
||||
Returns:
|
||||
Decrypted key bytes if found and decrypted, None otherwise
|
||||
"""
|
||||
if encryption_type not in ["key", "abe"]:
|
||||
return None
|
||||
|
||||
column_name = "key_bytes_dec" if encryption_type == "key" else "app_bound_key_dec"
|
||||
is_decrypted_col = "key_is_decrypted" if encryption_type == "key" else "app_bound_key_is_decrypted"
|
||||
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT {column_name}, {is_decrypted_col} FROM chromium.state_keys WHERE id = %s",
|
||||
(state_key_id,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if result and result[1]: # Check if is_decrypted is True
|
||||
return result[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup state key bytes", error=str(e))
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT {column_name}, {is_decrypted_col} FROM chromium.state_keys WHERE id = %s",
|
||||
(state_key_id,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if result and result[1]: # Check if is_decrypted is True
|
||||
return result[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup state key bytes", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_all_state_keys_from_source(source: str, pg_conn=None) -> list[dict]:
|
||||
"""Get all decrypted state keys from the same source.
|
||||
|
||||
Args:
|
||||
source: Source value
|
||||
pg_conn: existing Postgres connection
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing state key information
|
||||
"""
|
||||
state_keys = []
|
||||
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT id, username, browser, key_bytes_dec, key_is_decrypted,
|
||||
app_bound_key_dec, app_bound_key_is_decrypted
|
||||
FROM chromium.state_keys WHERE source = %s""",
|
||||
(source,),
|
||||
)
|
||||
results = cur.fetchall()
|
||||
for result in results:
|
||||
state_key_info = {
|
||||
"id": result[0],
|
||||
"username": result[1],
|
||||
"browser": result[2],
|
||||
"key_bytes_dec": result[3] if result[4] else None, # Only if decrypted
|
||||
"app_bound_key_dec": result[5] if result[6] else None, # Only if decrypted
|
||||
}
|
||||
state_keys.append(state_key_info)
|
||||
return state_keys
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup all state keys from source", error=str(e))
|
||||
return []
|
||||
else:
|
||||
try:
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT id, username, browser, key_bytes_dec, key_is_decrypted,
|
||||
app_bound_key_dec, app_bound_key_is_decrypted
|
||||
FROM chromium.state_keys WHERE source = %s""",
|
||||
(source,),
|
||||
)
|
||||
results = cur.fetchall()
|
||||
for result in results:
|
||||
state_key_info = {
|
||||
"id": result[0],
|
||||
"username": result[1],
|
||||
"browser": result[2],
|
||||
"key_bytes_dec": result[3] if result[4] else None, # Only if decrypted
|
||||
"app_bound_key_dec": result[5] if result[6] else None, # Only if decrypted
|
||||
}
|
||||
state_keys.append(state_key_info)
|
||||
return state_keys
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup all state keys from source", error=str(e))
|
||||
return []
|
||||
|
||||
|
||||
def is_valid_text(data: bytes) -> bool:
|
||||
"""Check if decrypted bytes represent valid ASCII or UTF-8 text.
|
||||
|
||||
Args:
|
||||
data: Bytes to validate
|
||||
|
||||
Returns:
|
||||
True if data is valid text, False otherwise
|
||||
"""
|
||||
if not data:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Try to decode as UTF-8
|
||||
text = data.decode("utf-8")
|
||||
# Check if it contains mostly printable characters
|
||||
printable_chars = sum(1 for c in text if c.isprintable() or c.isspace())
|
||||
return printable_chars / len(text) > 0.8 # At least 80% printable
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
# Try ASCII as fallback
|
||||
text = data.decode("ascii")
|
||||
printable_chars = sum(1 for c in text if c in "\x20-\x7e\t\n\r")
|
||||
return printable_chars / len(text) > 0.8
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
|
||||
|
||||
def try_decrypt_with_all_keys(
|
||||
encrypted_value: bytes, source: str, encryption_type: str, pg_conn=None
|
||||
) -> tuple[bytes | None, int | None]:
|
||||
"""Try to decrypt with all available state keys from the same source.
|
||||
|
||||
Args:
|
||||
encrypted_value: Raw encrypted value bytes
|
||||
source: Source value to look up keys from
|
||||
encryption_type: Either 'key' or 'abe'
|
||||
pg_conn: existing Postgres connection
|
||||
|
||||
Returns:
|
||||
Tuple of (decrypted_bytes, state_key_id) if successful, (None, None) otherwise
|
||||
"""
|
||||
if encryption_type not in ["key", "abe"]:
|
||||
return None, None
|
||||
|
||||
state_keys = get_all_state_keys_from_source(source, pg_conn)
|
||||
|
||||
for state_key in state_keys:
|
||||
key_bytes = state_key.get("key_bytes_dec") if encryption_type == "key" else state_key.get("app_bound_key_dec")
|
||||
|
||||
if not key_bytes:
|
||||
continue
|
||||
|
||||
try:
|
||||
decrypted_bytes = decrypt_chrome_string(encrypted_value, key_bytes, encryption_type)
|
||||
if decrypted_bytes:
|
||||
# Apply offset handling for cookies
|
||||
if encryption_type == "abe" and len(decrypted_bytes) > 32:
|
||||
# v20 cookies typically have 32-byte offset
|
||||
test_bytes = decrypted_bytes[32:]
|
||||
elif encryption_type == "key" and len(decrypted_bytes) > 48:
|
||||
# v10/v11 cookies may have 32-byte prefix + 16-byte suffix
|
||||
test_bytes = decrypted_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(decrypted_bytes) > 16:
|
||||
# Or just 16-byte suffix
|
||||
test_bytes = decrypted_bytes[:-16]
|
||||
else:
|
||||
test_bytes = decrypted_bytes
|
||||
|
||||
# Check if the result is valid text
|
||||
if is_valid_text(test_bytes):
|
||||
logger.debug(
|
||||
"Successfully decrypted with backup key",
|
||||
state_key_id=state_key["id"],
|
||||
username=state_key["username"],
|
||||
browser=state_key["browser"],
|
||||
)
|
||||
return decrypted_bytes, state_key["id"]
|
||||
except Exception:
|
||||
# Continue trying other keys
|
||||
continue
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def byte_xor(ba1, ba2):
|
||||
return bytes([_a ^ _b for _a, _b in zip(ba1, ba2)])
|
||||
|
||||
def parse_abe_blob(abe_data: bytes, chromekey: bytes | None = None) -> dict | None:
|
||||
"""Parse ABE (App-Bound Encryption) blob data.
|
||||
|
||||
Args:
|
||||
abe_data: Raw ABE blob bytes
|
||||
|
||||
Returns:
|
||||
Dictionary containing parsed ABE data, None if parsing fails
|
||||
"""
|
||||
try:
|
||||
abe_parsed = {}
|
||||
header_len = struct.unpack("<I", abe_data[:4])[0]
|
||||
abe_parsed["header"] = abe_data[4 : 4 + header_len].strip(b"\x02").decode(errors="ignore")
|
||||
content_len = struct.unpack("<I", abe_data[4 + header_len : 4 + header_len + 4])[0]
|
||||
content = abe_data[8 + header_len : 8 + header_len + content_len]
|
||||
|
||||
abe_parsed["version"] = int(content[0])
|
||||
content = content[1:]
|
||||
if abe_parsed["version"] <= 2: # Versions 1 and 2
|
||||
# Version|IV|ciphertext|tag, 1|12|32|16 bytes
|
||||
abe_parsed["iv"] = content[:12]
|
||||
abe_parsed["cipherdata"] = content[12 : 12 + 32]
|
||||
abe_parsed["tag"] = content[12 + 32 : 12 + 32 + 16]
|
||||
else: # Version 3
|
||||
# Version|encAES|IV|ciphertext|tag, 1|32|12|32|16 bytes
|
||||
# adapted from:
|
||||
# https://github.com/runassu/chrome_v20_decryption/blob/e8f244543e98266d50884aba2778e0ccedefa45d/decrypt_chrome_v20_cookie.py#L42-L69
|
||||
# https://github.com/tijldeneut/diana/blob/b9473b5004ecf1d7bdd5852232b5cd06a5378e5e/diana-browserdec.py
|
||||
abe_parsed["encrAES"] = content[:32]
|
||||
abe_parsed["iv"] = content[32 : 32 + 12]
|
||||
abe_parsed["cipherdata"] = content[32 + 12 : 32 + 12 + 32]
|
||||
abe_parsed["tag"] = content[32 + 12 + 32 : 32 + 12 + 32 + 16]
|
||||
|
||||
if chromekey:
|
||||
# gotta make sure to specify the \x00*16 IV here
|
||||
cipher = AES.new(chromekey, AES.MODE_CBC, b'\x00' * 16)
|
||||
result = cipher.decrypt(abe_parsed["encrAES"])
|
||||
xor_key = bytes.fromhex("CCF8A1CEC56605B8517552BA1A2D061C03A29E90274FB2FCF59BA4B75C392390")
|
||||
abe_parsed["xored_aes_key"] = byte_xor(result, xor_key)
|
||||
return abe_parsed
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse ABE blob", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
# Ref: https://github.com/runassu/chrome_v20_decryption/blob/e8f244543e98266d50884aba2778e0ccedefa45d/decrypt_chrome_v20_cookie.py#L121-L135
|
||||
def derive_abe_key(abe_data: dict) -> bytes | None:
|
||||
"""Derive ABE key from parsed ABE data.
|
||||
|
||||
Args:
|
||||
abe_data: Parsed ABE data dictionary
|
||||
|
||||
Returns:
|
||||
Derived ABE key bytes, None if derivation fails
|
||||
"""
|
||||
try:
|
||||
if abe_data["version"] == 1:
|
||||
cipher = AES.new(
|
||||
bytes.fromhex("B31C6E241AC846728DA9C1FAC4936651CFFB944D143AB816276BCC6DA0284787"),
|
||||
AES.MODE_GCM,
|
||||
nonce=abe_data["iv"],
|
||||
)
|
||||
elif abe_data["version"] == 2:
|
||||
cipher = ChaCha20_Poly1305.new(
|
||||
key=bytes.fromhex("E98F37D7F4E1FA433D19304DC2258042090E2D1D7EEA7670D41F738D08729660"),
|
||||
nonce=abe_data["iv"],
|
||||
)
|
||||
elif abe_data["version"] == 3:
|
||||
if abe_data["xored_aes_key"]:
|
||||
cipher = AES.new(abe_data["xored_aes_key"], AES.MODE_GCM, nonce=abe_data["iv"])
|
||||
else:
|
||||
# Version 3 requires CNG decryption of encrypted AES key
|
||||
logger.warning("xored_aes_key not present, ABE version 3 requires CNG decrypted 'Google Chromekey1'")
|
||||
return None
|
||||
else:
|
||||
logger.warning("Unknown ABE version", version=abe_data["version"])
|
||||
return None
|
||||
|
||||
return cipher.decrypt_and_verify(abe_data["cipherdata"], abe_data["tag"])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to derive ABE key", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def decrypt_chrome_string(encrypted_data: bytes, key_bytes: bytes, encryption_type: str) -> bytes | None:
|
||||
"""Decrypt Chrome encrypted string using key or ABE encryption.
|
||||
|
||||
Args:
|
||||
encrypted_data: Raw encrypted data bytes
|
||||
key_bytes: Decrypted key bytes (BME key for 'key', ABE key for 'abe')
|
||||
encryption_type: Either 'key' or 'abe'
|
||||
|
||||
Returns:
|
||||
Decrypted bytes, None if decryption fails
|
||||
"""
|
||||
if not encrypted_data or len(encrypted_data) < 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
if encryption_type == "key" and encrypted_data[:3] in [b"v10", b"v11"]:
|
||||
# Version|IV|ciphertext, 4|12|<var>
|
||||
iv = encrypted_data[3 : 3 + 12]
|
||||
ciphertext = encrypted_data[15:]
|
||||
cipher = AES.new(key_bytes, AES.MODE_GCM, iv)
|
||||
return cipher.decrypt(ciphertext)
|
||||
|
||||
elif encryption_type == "abe" and encrypted_data[:3] == b"v20":
|
||||
# Version|IV|ciphertext|tag, 3|12|<var>|16 bytes
|
||||
iv = encrypted_data[3 : 3 + 12]
|
||||
ciphertext = encrypted_data[15:-16]
|
||||
tag = encrypted_data[-16:]
|
||||
cipher = AES.new(key_bytes, AES.MODE_GCM, iv)
|
||||
decrypted = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
# v20 cookies have 32-byte offset, but this varies by data type
|
||||
return decrypted
|
||||
else:
|
||||
logger.warning("Unsupported encryption format", prefix=encrypted_data[:3], encryption_type=encryption_type)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to decrypt Chrome string", error=str(e))
|
||||
return None
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Chromium History file parsing and database operations."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import psycopg
|
||||
from common.logger import get_logger
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
|
||||
from .helpers import convert_chromium_timestamp, get_postgres_connection_str, parse_chromium_file_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def process_chromium_history(object_id: str, file_path: str | None = None) -> None:
|
||||
"""Process Chromium History file and insert URLs and downloads into database.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the History file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
logger.info("Processing Chromium History file", object_id=object_id)
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
# Extract username and browser from file path
|
||||
username, browser = parse_chromium_file_path(file_enriched.path or "")
|
||||
logger.debug("[process_chromium_history]", username=username, browser=browser)
|
||||
|
||||
# Get database file
|
||||
if file_path:
|
||||
db_path = file_path
|
||||
else:
|
||||
storage = StorageMinio()
|
||||
with storage.download(file_enriched.object_id) as temp_file:
|
||||
db_path = temp_file.name
|
||||
|
||||
# Process both tables
|
||||
_insert_history_urls(object_id, file_enriched, username, browser, db_path)
|
||||
_insert_history_downloads(object_id, file_enriched, username, browser, db_path)
|
||||
|
||||
logger.debug("Completed processing Chromium History", object_id=object_id)
|
||||
|
||||
|
||||
def _insert_history_urls(object_id: str, file_enriched, username: str | None, browser: str, db_path: str) -> None:
|
||||
"""Extract URLs from History and insert into chromium.history table."""
|
||||
try:
|
||||
# Read from SQLite
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.text_factory = lambda x: x.decode("utf-8", errors="replace")
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT url, title, visit_count, last_visit_time FROM urls")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# Prepare data for PostgreSQL
|
||||
urls_data = []
|
||||
for url, title, visit_count, last_visit_time in rows:
|
||||
urls_data.append(
|
||||
{
|
||||
"originating_object_id": file_enriched.object_id,
|
||||
"agent_id": file_enriched.agent_id,
|
||||
"source": file_enriched.source,
|
||||
"project": file_enriched.project,
|
||||
"username": username,
|
||||
"browser": browser,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"visit_count": visit_count,
|
||||
"last_visit_time": convert_chromium_timestamp(last_visit_time),
|
||||
}
|
||||
)
|
||||
|
||||
# Insert into PostgreSQL
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
with pg_conn.cursor() as cur:
|
||||
insert_sql = """
|
||||
INSERT INTO chromium.history
|
||||
(originating_object_id, agent_id, source, project, username, browser,
|
||||
url, title, visit_count, last_visit_time)
|
||||
VALUES (%(originating_object_id)s, %(agent_id)s, %(source)s, %(project)s,
|
||||
%(username)s, %(browser)s, %(url)s, %(title)s, %(visit_count)s, %(last_visit_time)s)
|
||||
ON CONFLICT (source, username, browser, url, title, last_visit_time)
|
||||
DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
title = EXCLUDED.title,
|
||||
visit_count = EXCLUDED.visit_count,
|
||||
last_visit_time = EXCLUDED.last_visit_time
|
||||
"""
|
||||
|
||||
cur.executemany(insert_sql, urls_data)
|
||||
pg_conn.commit()
|
||||
|
||||
logger.info("Inserted URLs into database", count=len(urls_data))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing History URLs", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
def _insert_history_downloads(object_id: str, file_enriched, username: str | None, browser: str, db_path: str) -> None:
|
||||
"""Extract downloads from History and insert into chromium_downloads table."""
|
||||
try:
|
||||
# Read from SQLite
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.text_factory = lambda x: x.decode("utf-8", errors="replace")
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT tab_url, target_path, start_time, end_time, total_bytes FROM downloads")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# Prepare data for PostgreSQL
|
||||
downloads_data = []
|
||||
for tab_url, target_path, start_time, end_time, total_bytes in rows:
|
||||
downloads_data.append(
|
||||
{
|
||||
"originating_object_id": file_enriched.object_id,
|
||||
"agent_id": file_enriched.agent_id,
|
||||
"source": file_enriched.source,
|
||||
"project": file_enriched.project,
|
||||
"username": username,
|
||||
"browser": browser,
|
||||
"url": tab_url,
|
||||
"download_path": target_path,
|
||||
"start_time": convert_chromium_timestamp(start_time),
|
||||
"end_time": convert_chromium_timestamp(end_time),
|
||||
"total_bytes": total_bytes,
|
||||
}
|
||||
)
|
||||
|
||||
# Insert into PostgreSQL
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
with pg_conn.cursor() as cur:
|
||||
insert_sql = """
|
||||
INSERT INTO chromium.downloads
|
||||
(originating_object_id, agent_id, source, project, username, browser,
|
||||
url, download_path, start_time, end_time, total_bytes)
|
||||
VALUES (%(originating_object_id)s, %(agent_id)s, %(source)s, %(project)s,
|
||||
%(username)s, %(browser)s, %(url)s, %(download_path)s,
|
||||
%(start_time)s, %(end_time)s, %(total_bytes)s)
|
||||
ON CONFLICT (source, username, browser, url, download_path, start_time)
|
||||
DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
download_path = EXCLUDED.download_path,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
total_bytes = EXCLUDED.total_bytes
|
||||
"""
|
||||
|
||||
cur.executemany(insert_sql, downloads_data)
|
||||
pg_conn.commit()
|
||||
|
||||
logger.info("Inserted downloads into database", count=len(downloads_data))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing History downloads", error=str(e))
|
||||
raise
|
||||
@@ -0,0 +1,968 @@
|
||||
"""Chromium Local State file parsing and database operations."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import posixpath
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from common.helpers import get_drive_from_path
|
||||
from common.logger import get_logger
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
from file_linking import add_file_linking
|
||||
from impacket.dpapi import DPAPI_BLOB
|
||||
from impacket.uuid import bin_to_string
|
||||
from nemesis_dpapi import Blob, DpapiManager, MasterKeyNotDecryptedError, MasterKeyNotFoundError
|
||||
|
||||
from .helpers import (
|
||||
derive_abe_key,
|
||||
detect_encryption_type,
|
||||
get_postgres_connection_str,
|
||||
parse_abe_blob,
|
||||
parse_chromium_file_path,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def process_chromium_local_state(
|
||||
dpapi_manager: DpapiManager,
|
||||
object_id: str,
|
||||
file_path: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Process Chromium Local State file and insert state keys into database.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the Local State file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
logger.info("Processing Chromium Local State file", object_id=object_id)
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
# Extract username and browser from file path
|
||||
username, browser = parse_chromium_file_path(file_enriched.path or "")
|
||||
logger.debug("[process_chromium_local_state()]", username=username, browser=browser)
|
||||
|
||||
# Get file content
|
||||
if file_path:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
else:
|
||||
storage = StorageMinio()
|
||||
with storage.download(file_enriched.object_id) as temp_file:
|
||||
with open(temp_file.name, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
state_key_data = await _insert_state_keys(file_enriched, username, browser, content, pg_conn, dpapi_manager)
|
||||
|
||||
logger.debug("Completed processing Chromium Local State", object_id=object_id)
|
||||
return state_key_data
|
||||
|
||||
|
||||
def _parse_app_bound_key(app_bound_key_b64: str) -> tuple[bytes, str | None]:
|
||||
"""Parse app-bound encrypted key and extract system masterkey GUID.
|
||||
|
||||
Args:
|
||||
app_bound_key_b64: Base64 encoded app-bound key
|
||||
|
||||
Returns:
|
||||
Tuple of (decoded_bytes, system_masterkey_guid)
|
||||
|
||||
Raises:
|
||||
ValueError: If the key doesn't have APPB header
|
||||
"""
|
||||
try:
|
||||
app_bound_key_bytes = base64.b64decode(app_bound_key_b64, validate=True)
|
||||
|
||||
# Check for APPB header (first 4 bytes)
|
||||
if len(app_bound_key_bytes) < 4 or app_bound_key_bytes[:4] != b"APPB":
|
||||
raise ValueError("App-bound key does not have APPB header")
|
||||
|
||||
# Remove APPB header and parse remaining as DPAPI blob
|
||||
dpapi_bytes = app_bound_key_bytes[4:]
|
||||
|
||||
blob = DPAPI_BLOB(dpapi_bytes)
|
||||
if blob.rawData is not None:
|
||||
blob.rawData = blob.rawData[: len(blob.getData())]
|
||||
system_masterkey_guid = bin_to_string(blob["GuidMasterKey"]).lower()
|
||||
return app_bound_key_bytes, system_masterkey_guid
|
||||
|
||||
return app_bound_key_bytes, None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse app-bound key", error=str(e))
|
||||
return base64.b64decode(app_bound_key_b64), None
|
||||
|
||||
|
||||
|
||||
async def _add_user_masterkey_link(file_enriched, username: str | None, masterkey_guid: UUID) -> None:
|
||||
"""Add file linking entry for user masterkey."""
|
||||
|
||||
# Skip trying to figure out the username/drive if we can
|
||||
if r"AppData/Local/Google/Chrome/User Data" in file_enriched.path:
|
||||
masterkey_path = posixpath.normpath(
|
||||
posixpath.join(
|
||||
file_enriched.path,
|
||||
"../../../../../Roaming/Microsoft/Protect/<WINDOWS_SECURITY_IDENTIFIER>",
|
||||
str(masterkey_guid),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Not an AppData path, so build path based on the drive letter and username (if available)
|
||||
drive = get_drive_from_path(file_enriched.path) or ""
|
||||
|
||||
if not username:
|
||||
username = "<WINDOWS_USERNAME>"
|
||||
|
||||
masterkey_path = (
|
||||
f"{drive}/Users/{username}/AppData/Roaming/Microsoft/Protect/<WINDOWS_SECURITY_IDENTIFIER>/{masterkey_guid}"
|
||||
)
|
||||
|
||||
await add_file_linking(file_enriched.source, file_enriched.path, masterkey_path, "windows:user_masterkey")
|
||||
|
||||
|
||||
def _get_chromekey_from_source(source: str, pg_conn) -> bytes | None:
|
||||
"""Get decrypted Chrome key from chrome_keys table by source.
|
||||
|
||||
Args:
|
||||
source: Source value to match
|
||||
pg_conn: PostgreSQL connection
|
||||
|
||||
Returns:
|
||||
Decrypted key bytes if found and decrypted, None otherwise
|
||||
"""
|
||||
try:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT key_bytes_dec FROM chromium.chrome_keys WHERE source = %s AND key_is_decrypted = TRUE",
|
||||
(source,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to lookup chrome key from source", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
async def _insert_state_keys(
|
||||
file_enriched,
|
||||
username: str | None,
|
||||
browser: str,
|
||||
content: str,
|
||||
pg_conn,
|
||||
dpapi_manager: DpapiManager,
|
||||
) -> dict | None:
|
||||
"""Parse Local State JSON and insert state keys into chromium.state_keys table."""
|
||||
try:
|
||||
# Parse JSON content
|
||||
data = json.loads(content)
|
||||
|
||||
# Extract os_crypt section
|
||||
os_crypt = data.get("os_crypt", {})
|
||||
|
||||
# Get Chrome key from chrome_keys table if available
|
||||
chromekey = _get_chromekey_from_source(file_enriched.source, pg_conn)
|
||||
|
||||
key_bytes_dec = b""
|
||||
key_is_decrypted = False
|
||||
app_bound_key_system_dec = b""
|
||||
app_bound_key_user_dec = b""
|
||||
app_bound_key_dec = b""
|
||||
app_bound_key_is_decrypted = False
|
||||
|
||||
# Get encrypted_key (pre v127)
|
||||
encrypted_key_b64 = os_crypt.get("encrypted_key")
|
||||
key_bytes_enc = b""
|
||||
key_masterkey_guid = None
|
||||
|
||||
if encrypted_key_b64:
|
||||
logger.debug("Found app bound key encrypted_key in Local State")
|
||||
key_bytes_enc = base64.b64decode(encrypted_key_b64)
|
||||
|
||||
if len(key_bytes_enc) < 5 or key_bytes_enc[:5] != b"DPAPI":
|
||||
raise ValueError("Encrypted key does not have DPAPI header")
|
||||
|
||||
# Remove DPAPI header and parse remaining as DPAPI blob
|
||||
dpapi_blob_bytes = key_bytes_enc[5:]
|
||||
|
||||
encryption_type, _ = detect_encryption_type(dpapi_blob_bytes)
|
||||
if encryption_type != "dpapi":
|
||||
raise Exception(f"Unsupported encryption type for v1 state key: {encryption_type}")
|
||||
|
||||
dpapi_blob = Blob.from_bytes(dpapi_blob_bytes)
|
||||
key_masterkey_guid = str(dpapi_blob.masterkey_guid)
|
||||
try:
|
||||
key_bytes_dec = await dpapi_manager.decrypt_blob(dpapi_blob)
|
||||
if key_bytes_dec:
|
||||
key_is_decrypted = True
|
||||
logger.debug(
|
||||
"Successfully decrypted encrypted_key state key",
|
||||
masterkey_guid=dpapi_blob.masterkey_guid,
|
||||
)
|
||||
else:
|
||||
logger.debug("Failed to decrypt encrypted_key state key with DPAPI")
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError) as e:
|
||||
logger.debug(
|
||||
"Masterkey not found or not decrypted for encrypted_key state key",
|
||||
masterkey_guid=dpapi_blob.masterkey_guid,
|
||||
reason=type(e).__name__,
|
||||
)
|
||||
|
||||
await _add_user_masterkey_link(file_enriched, username, dpapi_blob.masterkey_guid)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to decrypt state key DPAPI blob: {dpapi_blob.masterkey_guid}", error=str(e))
|
||||
|
||||
# Get app_bound_encrypted_key (post v127)
|
||||
app_bound_key_b64 = os_crypt.get("app_bound_encrypted_key")
|
||||
app_bound_key_enc = b""
|
||||
app_bound_key_system_masterkey_guid = None
|
||||
app_bound_key_user_masterkey_guid = None
|
||||
|
||||
if app_bound_key_b64:
|
||||
logger.debug("Found v2 app bound key encrypted_key in Local State")
|
||||
app_bound_key_enc, app_bound_key_system_masterkey_guid = _parse_app_bound_key(app_bound_key_b64)
|
||||
logger.debug(f"app_bound_key_system_masterkey_guid: {app_bound_key_system_masterkey_guid}")
|
||||
|
||||
drive = get_drive_from_path(file_enriched.path) or ""
|
||||
masterkey_path = (
|
||||
f"{drive}/Windows/System32/Microsoft/Protect/S-1-5-18/User/{app_bound_key_system_masterkey_guid}"
|
||||
)
|
||||
|
||||
# Filename format: <hash>_<machineGuid>
|
||||
# Hash comes from Chromium calling NCryptOpenKey with the key name of "Google Chromekey1"
|
||||
# Hashing algorithm is described here: https://gist.github.com/leechristensen/40acb67ff5b788d6b78d81443b66b444
|
||||
cng_system_private_key_path = (
|
||||
f"{drive}/ProgramData/Microsoft/Crypto/SystemKeys/7096db7aeb75c0d3497ecd56d355a695_<WINDOWS_MACHINE_GUID>"
|
||||
)
|
||||
|
||||
# add the masterkey file path (now that we know the key GUID) as a link/listing
|
||||
await add_file_linking(file_enriched.source, file_enriched.path, masterkey_path, "windows:system_masterkey")
|
||||
await add_file_linking(file_enriched.source, file_enriched.path, cng_system_private_key_path, "windows:cng_system_private_key - Contains Chrome key used to encrypt the Local State")
|
||||
|
||||
try:
|
||||
# Parse only the DPAPI portion (after APPB header)
|
||||
if len(app_bound_key_enc) >= 4 and app_bound_key_enc[:4] == b"APPB":
|
||||
dpapi_portion = app_bound_key_enc[4:]
|
||||
# Step 1 - decrypt with a SYSTEM masterkey
|
||||
app_bound_key_system_dec = await dpapi_manager.decrypt_blob(Blob.from_bytes(dpapi_portion))
|
||||
else:
|
||||
logger.warning("App-bound key missing APPB header, cannot decrypt")
|
||||
app_bound_key_system_dec = b""
|
||||
|
||||
if app_bound_key_system_dec:
|
||||
user_blob = Blob.from_bytes(app_bound_key_system_dec)
|
||||
app_bound_key_user_masterkey_guid = str(user_blob.masterkey_guid)
|
||||
|
||||
try:
|
||||
# Step 2 - decrypt with a _user_ masterkey
|
||||
abe_blob_bytes = await dpapi_manager.decrypt_blob(user_blob)
|
||||
if abe_blob_bytes:
|
||||
# Store the intermediate value after USER key decryption
|
||||
app_bound_key_user_dec = abe_blob_bytes
|
||||
|
||||
# Step 3 - parse and derive the final ABE key (using the Chromekey for v3)
|
||||
abe_parsed = parse_abe_blob(abe_blob_bytes, chromekey)
|
||||
|
||||
if abe_parsed:
|
||||
app_bound_key_dec = derive_abe_key(abe_parsed)
|
||||
|
||||
if app_bound_key_dec:
|
||||
app_bound_key_is_decrypted = True
|
||||
logger.debug(
|
||||
"Successfully derived ABE key",
|
||||
version=abe_parsed.get("version"),
|
||||
system_masterkey_guid=app_bound_key_system_masterkey_guid,
|
||||
)
|
||||
else:
|
||||
raise Exception("Failed to derive ABE key")
|
||||
else:
|
||||
raise Exception("Failed to parse ABE blob")
|
||||
else:
|
||||
raise Exception("Failed to decrypt ABE blob with user masterkey")
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError) as e:
|
||||
logger.debug(
|
||||
"ABE key not decrypted. Masterkey not found or not decrypted",
|
||||
masterkey_guid=user_blob.masterkey_guid,
|
||||
reason=type(e).__name__,
|
||||
)
|
||||
|
||||
await _add_user_masterkey_link(file_enriched, username, user_blob.masterkey_guid)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to decrypt/process final app bound key blob: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to decrypt intermediate/outer app bound key blob: {e}")
|
||||
|
||||
# Skip if no keys are present
|
||||
if not key_bytes_enc and not app_bound_key_enc:
|
||||
logger.warning("No encryption keys found in Local State file")
|
||||
return None
|
||||
|
||||
# Prepare data for PostgreSQL
|
||||
state_key_data = {
|
||||
"originating_object_id": file_enriched.object_id,
|
||||
"agent_id": file_enriched.agent_id,
|
||||
"source": file_enriched.source,
|
||||
"project": file_enriched.project,
|
||||
"username": username,
|
||||
"browser": browser,
|
||||
"key_masterkey_guid": key_masterkey_guid,
|
||||
"key_bytes_enc": key_bytes_enc,
|
||||
"key_bytes_dec": key_bytes_dec,
|
||||
"key_is_decrypted": key_is_decrypted,
|
||||
"app_bound_key_enc": app_bound_key_enc,
|
||||
"app_bound_key_system_masterkey_guid": app_bound_key_system_masterkey_guid,
|
||||
"app_bound_key_user_masterkey_guid": app_bound_key_user_masterkey_guid,
|
||||
"app_bound_key_system_dec": app_bound_key_system_dec,
|
||||
"app_bound_key_user_dec": app_bound_key_user_dec,
|
||||
"app_bound_key_dec": app_bound_key_dec,
|
||||
"app_bound_key_is_decrypted": app_bound_key_is_decrypted,
|
||||
}
|
||||
|
||||
# Create a serializable copy for return (hex encode binary data)
|
||||
serializable_data = state_key_data.copy()
|
||||
for key, value in serializable_data.items():
|
||||
if isinstance(value, bytes):
|
||||
serializable_data[key] = value.hex()
|
||||
|
||||
# Insert into PostgreSQL
|
||||
with pg_conn.cursor() as cur:
|
||||
insert_sql = """
|
||||
INSERT INTO chromium.state_keys
|
||||
(originating_object_id, agent_id, source, project, username, browser,
|
||||
key_masterkey_guid, key_bytes_enc, key_bytes_dec, key_is_decrypted,
|
||||
app_bound_key_enc, app_bound_key_system_masterkey_guid,
|
||||
app_bound_key_user_masterkey_guid, app_bound_key_system_dec, app_bound_key_user_dec,
|
||||
app_bound_key_dec, app_bound_key_is_decrypted)
|
||||
VALUES (%(originating_object_id)s, %(agent_id)s, %(source)s, %(project)s,
|
||||
%(username)s, %(browser)s, %(key_masterkey_guid)s, %(key_bytes_enc)s,
|
||||
%(key_bytes_dec)s, %(key_is_decrypted)s, %(app_bound_key_enc)s,
|
||||
%(app_bound_key_system_masterkey_guid)s, %(app_bound_key_user_masterkey_guid)s,
|
||||
%(app_bound_key_system_dec)s, %(app_bound_key_user_dec)s,
|
||||
%(app_bound_key_dec)s, %(app_bound_key_is_decrypted)s)
|
||||
ON CONFLICT (source, username, browser)
|
||||
DO UPDATE SET
|
||||
key_masterkey_guid = EXCLUDED.key_masterkey_guid,
|
||||
key_bytes_enc = EXCLUDED.key_bytes_enc,
|
||||
key_bytes_dec = EXCLUDED.key_bytes_dec,
|
||||
key_is_decrypted = EXCLUDED.key_is_decrypted,
|
||||
app_bound_key_enc = EXCLUDED.app_bound_key_enc,
|
||||
app_bound_key_system_masterkey_guid = EXCLUDED.app_bound_key_system_masterkey_guid,
|
||||
app_bound_key_user_masterkey_guid = EXCLUDED.app_bound_key_user_masterkey_guid,
|
||||
app_bound_key_system_dec = EXCLUDED.app_bound_key_system_dec,
|
||||
app_bound_key_user_dec = EXCLUDED.app_bound_key_user_dec,
|
||||
app_bound_key_dec = EXCLUDED.app_bound_key_dec,
|
||||
app_bound_key_is_decrypted = EXCLUDED.app_bound_key_is_decrypted
|
||||
"""
|
||||
|
||||
cur.execute(insert_sql, state_key_data)
|
||||
|
||||
# Get the inserted state key ID
|
||||
cur.execute(
|
||||
"SELECT id FROM chromium.state_keys WHERE source = %s AND username = %s AND browser = %s",
|
||||
(file_enriched.source, username, browser),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if result:
|
||||
state_key_id = result[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.logins
|
||||
SET state_key_id = %s
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND state_key_id IS NULL
|
||||
AND encryption_type IN ('key', 'abe')
|
||||
""",
|
||||
(state_key_id, file_enriched.source, username, browser),
|
||||
)
|
||||
logins_updated = cur.rowcount
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.cookies
|
||||
SET state_key_id = %s
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND state_key_id IS NULL
|
||||
AND encryption_type IN ('key', 'abe')
|
||||
""",
|
||||
(state_key_id, file_enriched.source, username, browser),
|
||||
)
|
||||
cookies_updated = cur.rowcount
|
||||
|
||||
logger.info(
|
||||
"Updated existing entries with state key ID",
|
||||
state_key_id=state_key_id,
|
||||
logins_updated=logins_updated,
|
||||
cookies_updated=cookies_updated,
|
||||
)
|
||||
|
||||
pg_conn.commit()
|
||||
|
||||
logger.info(
|
||||
"Inserted state keys into database",
|
||||
has_encrypted_key=bool(key_bytes_enc),
|
||||
has_app_bound_key=bool(app_bound_key_enc),
|
||||
)
|
||||
|
||||
return serializable_data
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.exception("Failed to parse Local State JSON", error=str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Error processing Local State", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
async def retry_decrypt_state_key(state_key_id: int, dpapi_manager: DpapiManager, pg_conn) -> dict:
|
||||
"""Attempt to decrypt a single state_key record using currently available masterkeys.
|
||||
|
||||
Args:
|
||||
state_key_id: The ID of the state_key record to decrypt
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
pg_conn: PostgreSQL connection
|
||||
|
||||
Returns:
|
||||
Dict with decryption results: {
|
||||
"decrypted_v1": bool,
|
||||
"decrypted_abe_stage1": bool,
|
||||
"decrypted_abe_stage2": bool
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"decrypted_v1": False,
|
||||
"decrypted_abe_stage1": False,
|
||||
"decrypted_abe_stage2": False,
|
||||
}
|
||||
|
||||
# Fetch the state_key record
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source, username, browser,
|
||||
key_masterkey_guid, key_bytes_enc, key_bytes_dec, key_is_decrypted,
|
||||
app_bound_key_enc, app_bound_key_system_masterkey_guid,
|
||||
app_bound_key_user_masterkey_guid, app_bound_key_system_dec,
|
||||
app_bound_key_user_dec, app_bound_key_dec, app_bound_key_is_decrypted
|
||||
FROM chromium.state_keys
|
||||
WHERE id = %s
|
||||
""",
|
||||
(state_key_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.warning("State key not found", state_key_id=state_key_id)
|
||||
return result
|
||||
|
||||
(
|
||||
record_id,
|
||||
source,
|
||||
username,
|
||||
browser,
|
||||
key_masterkey_guid,
|
||||
key_bytes_enc,
|
||||
key_bytes_dec,
|
||||
key_is_decrypted,
|
||||
app_bound_key_enc,
|
||||
app_bound_key_system_masterkey_guid,
|
||||
app_bound_key_user_masterkey_guid,
|
||||
app_bound_key_system_dec,
|
||||
app_bound_key_user_dec,
|
||||
app_bound_key_dec,
|
||||
app_bound_key_is_decrypted,
|
||||
) = row
|
||||
|
||||
# Try to decrypt pre-v127 encrypted_key
|
||||
if key_bytes_enc and len(key_bytes_enc) > 0 and not key_is_decrypted:
|
||||
try:
|
||||
# Remove DPAPI header (first 5 bytes)
|
||||
if len(key_bytes_enc) >= 5 and key_bytes_enc[:5] == b"DPAPI":
|
||||
dpapi_blob_bytes = key_bytes_enc[5:]
|
||||
|
||||
dpapi_blob = Blob.from_bytes(dpapi_blob_bytes)
|
||||
try:
|
||||
key_bytes_dec = await dpapi_manager.decrypt_blob(dpapi_blob)
|
||||
if key_bytes_dec:
|
||||
key_is_decrypted = True
|
||||
result["decrypted_v1"] = True
|
||||
logger.debug(
|
||||
"Successfully decrypted v1 state key",
|
||||
state_key_id=state_key_id,
|
||||
masterkey_guid=dpapi_blob.masterkey_guid,
|
||||
)
|
||||
|
||||
# Update database
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.state_keys
|
||||
SET key_bytes_dec = %s, key_is_decrypted = %s,
|
||||
key_masterkey_guid = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(key_bytes_dec, key_is_decrypted, str(dpapi_blob.masterkey_guid), state_key_id),
|
||||
)
|
||||
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError):
|
||||
# Masterkey still not available, skip silently
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Error decrypting v1 state key", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error processing v1 state key", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
# Try to decrypt post-v127 app_bound_encrypted_key
|
||||
if app_bound_key_enc and len(app_bound_key_enc) > 0:
|
||||
# Stage 1: Decrypt outer layer with SYSTEM masterkey
|
||||
if len(app_bound_key_system_dec) == 0:
|
||||
try:
|
||||
if len(app_bound_key_enc) >= 4 and app_bound_key_enc[:4] == b"APPB":
|
||||
dpapi_portion = app_bound_key_enc[4:]
|
||||
system_blob = Blob.from_bytes(dpapi_portion)
|
||||
try:
|
||||
app_bound_key_system_dec = await dpapi_manager.decrypt_blob(system_blob)
|
||||
if app_bound_key_system_dec:
|
||||
result["decrypted_abe_stage1"] = True
|
||||
logger.debug(
|
||||
"Successfully decrypted ABE stage 1 (SYSTEM key)",
|
||||
state_key_id=state_key_id,
|
||||
system_masterkey_guid=system_blob.masterkey_guid,
|
||||
)
|
||||
|
||||
# Parse the intermediate blob to get user masterkey GUID
|
||||
try:
|
||||
user_blob = Blob.from_bytes(app_bound_key_system_dec)
|
||||
app_bound_key_user_masterkey_guid = str(user_blob.masterkey_guid)
|
||||
except Exception:
|
||||
app_bound_key_user_masterkey_guid = None
|
||||
|
||||
# Update database with stage 1 results
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.state_keys
|
||||
SET app_bound_key_system_dec = %s,
|
||||
app_bound_key_system_masterkey_guid = %s,
|
||||
app_bound_key_user_masterkey_guid = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
app_bound_key_system_dec,
|
||||
str(system_blob.masterkey_guid),
|
||||
app_bound_key_user_masterkey_guid,
|
||||
state_key_id,
|
||||
),
|
||||
)
|
||||
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError):
|
||||
# SYSTEM masterkey still not available
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Error decrypting ABE stage 1", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error processing ABE stage 1", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
# Stage 2: Decrypt inner layer with USER masterkey and derive final key
|
||||
if len(app_bound_key_system_dec) > 0 and not app_bound_key_is_decrypted:
|
||||
try:
|
||||
user_blob = Blob.from_bytes(app_bound_key_system_dec)
|
||||
try:
|
||||
abe_blob_bytes = await dpapi_manager.decrypt_blob(user_blob)
|
||||
if abe_blob_bytes:
|
||||
# Store the intermediate value after USER key decryption
|
||||
app_bound_key_user_dec = abe_blob_bytes
|
||||
|
||||
# Get chrome_key from database
|
||||
chromekey = _get_chromekey_from_source(source, pg_conn)
|
||||
|
||||
# Always attempt to parse the ABE blob (works for v2 without chromekey)
|
||||
abe_parsed = parse_abe_blob(abe_blob_bytes, chromekey)
|
||||
|
||||
if abe_parsed:
|
||||
app_bound_key_dec = derive_abe_key(abe_parsed)
|
||||
if app_bound_key_dec:
|
||||
app_bound_key_is_decrypted = True
|
||||
result["decrypted_abe_stage2"] = True
|
||||
logger.debug(
|
||||
"Successfully decrypted ABE stage 2 (USER key + ABE derivation)",
|
||||
state_key_id=state_key_id,
|
||||
user_masterkey_guid=user_blob.masterkey_guid,
|
||||
abe_version=abe_parsed.get("version"),
|
||||
)
|
||||
|
||||
# Update database with final key and intermediate user_dec
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.state_keys
|
||||
SET app_bound_key_user_dec = %s,
|
||||
app_bound_key_dec = %s,
|
||||
app_bound_key_is_decrypted = %s,
|
||||
app_bound_key_user_masterkey_guid = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
app_bound_key_user_dec,
|
||||
app_bound_key_dec,
|
||||
app_bound_key_is_decrypted,
|
||||
str(user_blob.masterkey_guid),
|
||||
state_key_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning("Failed to derive ABE key", state_key_id=state_key_id)
|
||||
# Save the intermediate user_dec value for later retry
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.state_keys
|
||||
SET app_bound_key_user_dec = %s,
|
||||
app_bound_key_user_masterkey_guid = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
app_bound_key_user_dec,
|
||||
str(user_blob.masterkey_guid),
|
||||
state_key_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Parsing failed - likely v3 waiting for chromekey
|
||||
if chromekey is None:
|
||||
logger.warning(
|
||||
"ABE parsing failed, likely v3 waiting for Chrome key",
|
||||
state_key_id=state_key_id,
|
||||
source=source,
|
||||
)
|
||||
else:
|
||||
logger.warning("Failed to parse ABE blob", state_key_id=state_key_id)
|
||||
|
||||
# Save the intermediate user_dec value for later retry
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.state_keys
|
||||
SET app_bound_key_user_dec = %s,
|
||||
app_bound_key_user_masterkey_guid = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
app_bound_key_user_dec,
|
||||
str(user_blob.masterkey_guid),
|
||||
state_key_id,
|
||||
),
|
||||
)
|
||||
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError):
|
||||
# USER masterkey still not available
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Error decrypting ABE stage 2", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error processing ABE stage 2", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
# If we made any progress, update linked logins and cookies
|
||||
if result["decrypted_v1"] or result["decrypted_abe_stage2"]:
|
||||
try:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.logins
|
||||
SET state_key_id = %s
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND state_key_id IS NULL
|
||||
AND encryption_type IN ('key', 'abe')
|
||||
""",
|
||||
(state_key_id, source, username, browser),
|
||||
)
|
||||
logins_updated = cur.rowcount
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.cookies
|
||||
SET state_key_id = %s
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND state_key_id IS NULL
|
||||
AND encryption_type IN ('key', 'abe')
|
||||
""",
|
||||
(state_key_id, source, username, browser),
|
||||
)
|
||||
cookies_updated = cur.rowcount
|
||||
|
||||
if logins_updated > 0 or cookies_updated > 0:
|
||||
logger.info(
|
||||
"Linked state key to existing logins/cookies",
|
||||
state_key_id=state_key_id,
|
||||
logins_updated=logins_updated,
|
||||
cookies_updated=cookies_updated,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error linking logins/cookies to state key", state_key_id=state_key_id, error=str(e))
|
||||
|
||||
# Commit all changes
|
||||
pg_conn.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def retry_decrypt_state_keys_for_masterkey(
|
||||
masterkey_guid: UUID, dpapi_manager: DpapiManager, masterkey_type: str | None = None
|
||||
) -> dict:
|
||||
"""Find all state_keys waiting for this masterkey and try to decrypt them.
|
||||
|
||||
Args:
|
||||
masterkey_guid: The GUID of the newly available masterkey
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
masterkey_type: Optional masterkey type ('system', 'user', 'unknown') for optimization
|
||||
|
||||
Returns:
|
||||
Dict with statistics: {
|
||||
"state_keys_attempted": int,
|
||||
"state_keys_progressed": int,
|
||||
"errors": list
|
||||
}
|
||||
"""
|
||||
result = {"state_keys_attempted": 0, "state_keys_progressed": 0, "errors": []}
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
|
||||
try:
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
# Find all state_keys that might need this masterkey
|
||||
with pg_conn.cursor() as cur:
|
||||
# Build query based on masterkey type for optimization
|
||||
if masterkey_type == "system":
|
||||
# SYSTEM keys only used for v2 ABE stage 1
|
||||
query = """
|
||||
SELECT DISTINCT id FROM chromium.state_keys
|
||||
WHERE app_bound_key_system_masterkey_guid = %s
|
||||
AND length(app_bound_key_system_dec) = 0
|
||||
"""
|
||||
cur.execute(query, (str(masterkey_guid),))
|
||||
elif masterkey_type == "user":
|
||||
# USER keys used for both v1 and v2 ABE stage 2
|
||||
query = """
|
||||
SELECT DISTINCT id FROM chromium.state_keys
|
||||
WHERE (key_masterkey_guid = %s AND key_is_decrypted = FALSE)
|
||||
OR (app_bound_key_user_masterkey_guid = %s AND app_bound_key_is_decrypted = FALSE)
|
||||
"""
|
||||
cur.execute(query, (str(masterkey_guid), str(masterkey_guid)))
|
||||
else:
|
||||
# Unknown type - check all possible uses
|
||||
query = """
|
||||
SELECT DISTINCT id FROM chromium.state_keys
|
||||
WHERE (key_masterkey_guid = %s AND key_is_decrypted = FALSE)
|
||||
OR (app_bound_key_system_masterkey_guid = %s AND length(app_bound_key_system_dec) = 0)
|
||||
OR (app_bound_key_user_masterkey_guid = %s AND app_bound_key_is_decrypted = FALSE)
|
||||
"""
|
||||
cur.execute(query, (str(masterkey_guid), str(masterkey_guid), str(masterkey_guid)))
|
||||
|
||||
state_key_ids = [row[0] for row in cur.fetchall()]
|
||||
|
||||
logger.debug(
|
||||
"Found state keys potentially waiting for masterkey",
|
||||
masterkey_guid=masterkey_guid,
|
||||
masterkey_type=masterkey_type,
|
||||
count=len(state_key_ids),
|
||||
)
|
||||
|
||||
# Try to decrypt each state_key
|
||||
for state_key_id in state_key_ids:
|
||||
result["state_keys_attempted"] += 1
|
||||
try:
|
||||
decrypt_result = await retry_decrypt_state_key(state_key_id, dpapi_manager, pg_conn)
|
||||
|
||||
# Check if any progress was made
|
||||
if (
|
||||
decrypt_result["decrypted_v1"]
|
||||
or decrypt_result["decrypted_abe_stage1"]
|
||||
or decrypt_result["decrypted_abe_stage2"]
|
||||
):
|
||||
result["state_keys_progressed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing state_key {state_key_id}: {str(e)}"
|
||||
logger.warning("Failed to retry decrypt state key", state_key_id=state_key_id, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
logger.info(
|
||||
"Completed retroactive state_key decryption for masterkey",
|
||||
masterkey_guid=masterkey_guid,
|
||||
attempted=result["state_keys_attempted"],
|
||||
progressed=result["state_keys_progressed"],
|
||||
errors=len(result["errors"]),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Database error during retroactive decryption: {str(e)}"
|
||||
logger.exception("Error in retry_decrypt_state_keys_for_masterkey", masterkey_guid=masterkey_guid, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def retry_decrypt_state_keys_for_chromekey(source: str, chromekey: bytes) -> dict:
|
||||
"""Find all state_keys from a source waiting for chromekey and try to decrypt them.
|
||||
|
||||
This function handles v3 ABE decryption where the USER masterkey has already been
|
||||
applied (app_bound_key_user_dec is populated) but the chromekey is needed to
|
||||
complete the final derivation.
|
||||
|
||||
Args:
|
||||
source: The source identifier (hostname) for the chromekey
|
||||
chromekey: The decrypted Chrome key bytes
|
||||
|
||||
Returns:
|
||||
Dict with statistics: {
|
||||
"state_keys_attempted": int,
|
||||
"state_keys_decrypted": int,
|
||||
"errors": list
|
||||
}
|
||||
"""
|
||||
result = {"state_keys_attempted": 0, "state_keys_decrypted": 0, "errors": []}
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
|
||||
try:
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
# Find all state_keys from this source that have user_dec but aren't fully decrypted
|
||||
with pg_conn.cursor() as cur:
|
||||
query = """
|
||||
SELECT id, source, username, browser, app_bound_key_user_dec,
|
||||
app_bound_key_user_masterkey_guid
|
||||
FROM chromium.state_keys
|
||||
WHERE source = %s
|
||||
AND app_bound_key_is_decrypted = FALSE
|
||||
AND app_bound_key_user_dec IS NOT NULL
|
||||
AND length(app_bound_key_user_dec) > 0
|
||||
"""
|
||||
cur.execute(query, (source,))
|
||||
state_keys = cur.fetchall()
|
||||
|
||||
logger.debug(
|
||||
"Found state keys waiting for chromekey",
|
||||
source=source,
|
||||
count=len(state_keys),
|
||||
)
|
||||
|
||||
# Try to decrypt each state_key with the chromekey
|
||||
for row in state_keys:
|
||||
state_key_id, source, username, browser, app_bound_key_user_dec, user_masterkey_guid = row
|
||||
result["state_keys_attempted"] += 1
|
||||
|
||||
try:
|
||||
# Parse and derive the final ABE key using the chromekey
|
||||
abe_parsed = parse_abe_blob(app_bound_key_user_dec, chromekey)
|
||||
logger.debug(
|
||||
"[retry_decrypt_state_keys_for_chromekey] Parsed ABE blob",
|
||||
state_key_id=state_key_id,
|
||||
abe_parsed=abe_parsed,
|
||||
)
|
||||
|
||||
if abe_parsed:
|
||||
app_bound_key_dec = derive_abe_key(abe_parsed)
|
||||
logger.debug(
|
||||
"[retry_decrypt_state_keys_for_chromekey] Derived ABE key",
|
||||
state_key_id=state_key_id,
|
||||
success=bool(app_bound_key_dec),
|
||||
)
|
||||
|
||||
if app_bound_key_dec:
|
||||
# Update database with final decrypted key
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.state_keys
|
||||
SET app_bound_key_dec = %s,
|
||||
app_bound_key_is_decrypted = TRUE
|
||||
WHERE id = %s
|
||||
""",
|
||||
(app_bound_key_dec, state_key_id),
|
||||
)
|
||||
|
||||
# Link to existing logins/cookies
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.logins
|
||||
SET state_key_id = %s
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND state_key_id IS NULL
|
||||
AND encryption_type IN ('key', 'abe')
|
||||
""",
|
||||
(state_key_id, source, username, browser),
|
||||
)
|
||||
logins_updated = cur.rowcount
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.cookies
|
||||
SET state_key_id = %s
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND state_key_id IS NULL
|
||||
AND encryption_type IN ('key', 'abe')
|
||||
""",
|
||||
(state_key_id, source, username, browser),
|
||||
)
|
||||
cookies_updated = cur.rowcount
|
||||
|
||||
pg_conn.commit()
|
||||
result["state_keys_decrypted"] += 1
|
||||
|
||||
logger.debug(
|
||||
"Successfully decrypted ABE v3 with chromekey",
|
||||
state_key_id=state_key_id,
|
||||
source=source,
|
||||
username=username,
|
||||
browser=browser,
|
||||
abe_version=abe_parsed.get("version"),
|
||||
logins_updated=logins_updated,
|
||||
cookies_updated=cookies_updated,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to derive ABE key with chromekey",
|
||||
state_key_id=state_key_id,
|
||||
source=source,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to parse ABE blob with chromekey",
|
||||
state_key_id=state_key_id,
|
||||
source=source,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing state_key {state_key_id}: {str(e)}"
|
||||
logger.warning(
|
||||
"Failed to decrypt state key with chromekey",
|
||||
state_key_id=state_key_id,
|
||||
error=str(e),
|
||||
)
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
logger.info(
|
||||
"Completed retroactive state_key decryption for chromekey",
|
||||
source=source,
|
||||
attempted=result["state_keys_attempted"],
|
||||
decrypted=result["state_keys_decrypted"],
|
||||
errors=len(result["errors"]),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Database error during retroactive chromekey decryption: {str(e)}"
|
||||
logger.exception("Error in retry_decrypt_state_keys_for_chromekey", source=source, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Chromium Login Data file parsing and database operations."""
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
import psycopg
|
||||
from common.logger import get_logger
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
from nemesis_dpapi import Blob, DpapiManager
|
||||
|
||||
from .helpers import (
|
||||
convert_chromium_timestamp,
|
||||
decrypt_chrome_string,
|
||||
detect_encryption_type,
|
||||
get_postgres_connection_str,
|
||||
get_state_key_bytes,
|
||||
get_state_key_id,
|
||||
is_sqlite3,
|
||||
parse_chromium_file_path,
|
||||
try_decrypt_with_all_keys,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def process_chromium_logins(
|
||||
object_id: str, file_path: str | None = None, dpapi_manager: DpapiManager | None = None
|
||||
) -> None:
|
||||
"""Process Chromium Login Data file and insert logins into database.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the Login Data file
|
||||
file_path: Optional path to already downloaded file
|
||||
dpapi_manager: DPAPI manager for decryption
|
||||
"""
|
||||
logger.info("Processing Chromium Login Data file", object_id=object_id)
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
# Extract username and browser from file path
|
||||
username, browser = parse_chromium_file_path(file_enriched.path or "")
|
||||
logger.debug("[process_chromium_logins]", username=username, browser=browser)
|
||||
|
||||
# Get database file
|
||||
if file_path:
|
||||
db_path = file_path
|
||||
else:
|
||||
storage = StorageMinio()
|
||||
with storage.download(file_enriched.object_id) as temp_file:
|
||||
db_path = temp_file.name
|
||||
|
||||
if is_sqlite3(db_path) is False:
|
||||
logger.warning(
|
||||
"Login Data file is not a valid SQLite3 database", object_id=object_id, file_path=file_enriched.path
|
||||
)
|
||||
return
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
_insert_logins(file_enriched, username, browser, db_path, pg_conn, dpapi_manager)
|
||||
|
||||
logger.debug("Completed processing Chromium Login Data", object_id=object_id)
|
||||
|
||||
|
||||
def _insert_logins(
|
||||
file_enriched, username: str | None, browser: str, db_path: str, pg_conn, dpapi_manager: DpapiManager | None = None
|
||||
) -> None:
|
||||
"""Extract logins from Login Data database and insert into chromium.logins table."""
|
||||
try:
|
||||
# Read from SQLite
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.text_factory = bytes # Get raw bytes, we'll handle text decoding manually
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Query logins table
|
||||
cursor.execute("""
|
||||
SELECT origin_url, username_value, signon_realm, date_created,
|
||||
date_last_used, date_password_modified, times_used, password_value
|
||||
FROM logins
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# Prepare data for PostgreSQL
|
||||
logins_data = []
|
||||
for row in rows:
|
||||
(
|
||||
origin_url,
|
||||
username_value,
|
||||
signon_realm,
|
||||
date_created,
|
||||
date_last_used,
|
||||
date_password_modified,
|
||||
times_used,
|
||||
password_value,
|
||||
) = row
|
||||
|
||||
# Decode text fields from bytes (since we set text_factory = bytes)
|
||||
origin_url = origin_url.decode("utf-8", errors="replace") if origin_url else None
|
||||
username_value = username_value.decode("utf-8", errors="replace") if username_value else None
|
||||
signon_realm = signon_realm.decode("utf-8", errors="replace") if signon_realm else None
|
||||
|
||||
# password_value is already binary (what we want)
|
||||
if password_value is None:
|
||||
password_value = b""
|
||||
|
||||
# Detect encryption type and get masterkey GUID
|
||||
encryption_type, masterkey_guid = detect_encryption_type(password_value)
|
||||
is_decrypted = False
|
||||
password_value_dec = None
|
||||
|
||||
# Try DPAPI decryption first
|
||||
if masterkey_guid and dpapi_manager:
|
||||
try:
|
||||
password_dec_bytes = asyncio.run(dpapi_manager.decrypt_blob(Blob.from_bytes(password_value)))
|
||||
if password_dec_bytes:
|
||||
password_value_dec = password_dec_bytes.decode("utf-8", errors="replace")
|
||||
is_decrypted = True
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get state key ID for key/abe encryption
|
||||
state_key_id = None
|
||||
if encryption_type in ["key", "abe"]:
|
||||
# Try primary approach: get state key based on username/browser
|
||||
if username: # Only try primary approach if username was extracted
|
||||
state_key_id = get_state_key_id(file_enriched.source, username, browser, pg_conn)
|
||||
if state_key_id:
|
||||
# Retrieve the pre-processed state key for decryption
|
||||
state_key_bytes = get_state_key_bytes(state_key_id, encryption_type, pg_conn)
|
||||
if state_key_bytes:
|
||||
try:
|
||||
password_dec_bytes = decrypt_chrome_string(
|
||||
password_value, state_key_bytes, encryption_type
|
||||
)
|
||||
if password_dec_bytes:
|
||||
# For passwords, may need to strip offset bytes depending on version
|
||||
if encryption_type == "key" and len(password_dec_bytes) > 32:
|
||||
# v10/v11 passwords may have 32-byte prefix + 16-byte suffix
|
||||
password_dec_bytes = password_dec_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(password_dec_bytes) > 16:
|
||||
# Or just 16-byte suffix
|
||||
password_dec_bytes = password_dec_bytes[:-16]
|
||||
# v20 passwords typically don't have offset like cookies
|
||||
|
||||
password_value_dec = password_dec_bytes.decode("utf-8", errors="replace")
|
||||
is_decrypted = True
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to decrypt password with state key",
|
||||
state_key_id=state_key_id,
|
||||
encryption_type=encryption_type,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Backup approach: try all state keys from the same source if primary failed
|
||||
if not is_decrypted:
|
||||
logger.debug(
|
||||
"Primary decryption failed, trying backup approach with all keys from source",
|
||||
source=file_enriched.source,
|
||||
encryption_type=encryption_type,
|
||||
)
|
||||
backup_decrypted_bytes, backup_state_key_id = try_decrypt_with_all_keys(
|
||||
password_value, file_enriched.source, encryption_type, pg_conn
|
||||
)
|
||||
if backup_decrypted_bytes and backup_state_key_id:
|
||||
# Apply the same offset handling as above
|
||||
if encryption_type == "abe" and len(backup_decrypted_bytes) > 32:
|
||||
# v20 cookies typically have 32-byte offset
|
||||
backup_decrypted_bytes = backup_decrypted_bytes[32:]
|
||||
elif encryption_type == "key" and len(backup_decrypted_bytes) > 48:
|
||||
# v10/v11 cookies may have 32-byte prefix + 16-byte suffix
|
||||
backup_decrypted_bytes = backup_decrypted_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(backup_decrypted_bytes) > 16:
|
||||
# Or just 16-byte suffix
|
||||
backup_decrypted_bytes = backup_decrypted_bytes[:-16]
|
||||
|
||||
password_value_dec = backup_decrypted_bytes.decode("utf-8", errors="replace")
|
||||
is_decrypted = True
|
||||
state_key_id = backup_state_key_id
|
||||
logger.debug(
|
||||
"Successfully decrypted cookie using backup approach", state_key_id=backup_state_key_id
|
||||
)
|
||||
|
||||
login_data = {
|
||||
"originating_object_id": file_enriched.object_id,
|
||||
"agent_id": file_enriched.agent_id,
|
||||
"source": file_enriched.source,
|
||||
"project": file_enriched.project,
|
||||
"username": username,
|
||||
"browser": browser,
|
||||
"origin_url": origin_url,
|
||||
"username_value": username_value,
|
||||
"signon_realm": signon_realm,
|
||||
"date_created": convert_chromium_timestamp(date_created),
|
||||
"date_last_used": convert_chromium_timestamp(date_last_used),
|
||||
"date_password_modified": convert_chromium_timestamp(date_password_modified),
|
||||
"times_used": times_used,
|
||||
"encryption_type": encryption_type,
|
||||
"masterkey_guid": masterkey_guid,
|
||||
"state_key_id": state_key_id,
|
||||
"is_decrypted": is_decrypted,
|
||||
"password_value_enc": password_value,
|
||||
"password_value_dec": password_value_dec,
|
||||
}
|
||||
logins_data.append(login_data)
|
||||
|
||||
# Insert into PostgreSQL
|
||||
with pg_conn.cursor() as cur:
|
||||
insert_sql = """
|
||||
INSERT INTO chromium.logins
|
||||
(originating_object_id, agent_id, source, project, username, browser,
|
||||
origin_url, username_value, signon_realm, date_created, date_last_used,
|
||||
date_password_modified, times_used, encryption_type, masterkey_guid,
|
||||
state_key_id, is_decrypted, password_value_enc, password_value_dec)
|
||||
VALUES (%(originating_object_id)s, %(agent_id)s, %(source)s, %(project)s,
|
||||
%(username)s, %(browser)s, %(origin_url)s, %(username_value)s,
|
||||
%(signon_realm)s, %(date_created)s, %(date_last_used)s,
|
||||
%(date_password_modified)s, %(times_used)s, %(encryption_type)s,
|
||||
%(masterkey_guid)s, %(state_key_id)s, %(is_decrypted)s,
|
||||
%(password_value_enc)s, %(password_value_dec)s)
|
||||
ON CONFLICT (source, username, browser, origin_url, username_value)
|
||||
DO UPDATE SET
|
||||
origin_url = EXCLUDED.origin_url,
|
||||
username_value = EXCLUDED.username_value,
|
||||
signon_realm = EXCLUDED.signon_realm,
|
||||
date_created = EXCLUDED.date_created,
|
||||
date_last_used = EXCLUDED.date_last_used,
|
||||
date_password_modified = EXCLUDED.date_password_modified,
|
||||
times_used = EXCLUDED.times_used,
|
||||
encryption_type = EXCLUDED.encryption_type,
|
||||
masterkey_guid = EXCLUDED.masterkey_guid,
|
||||
state_key_id = EXCLUDED.state_key_id,
|
||||
is_decrypted = EXCLUDED.is_decrypted,
|
||||
password_value_enc = EXCLUDED.password_value_enc,
|
||||
password_value_dec = EXCLUDED.password_value_dec
|
||||
"""
|
||||
|
||||
cur.executemany(insert_sql, logins_data)
|
||||
pg_conn.commit()
|
||||
|
||||
logger.info("Inserted logins into database", count=len(logins_data))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error processing Login Data",
|
||||
error=str(e),
|
||||
object_id=file_enriched.object_id,
|
||||
file_path=file_enriched.path,
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Retry decryption logic for Chromium cookies and logins."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from common.logger import get_logger
|
||||
from nemesis_dpapi import Blob, DpapiManager, MasterKeyNotDecryptedError, MasterKeyNotFoundError
|
||||
|
||||
from .helpers import decrypt_chrome_string, get_postgres_connection_str, get_state_key_bytes
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def retry_decrypt_chromium_data(
|
||||
masterkey_guid: UUID, dpapi_manager: DpapiManager, masterkey_type: str | None = None
|
||||
) -> dict:
|
||||
"""Retry decrypting cookies and logins that failed to decrypt previously.
|
||||
|
||||
When a new masterkey becomes available, this function:
|
||||
1. Retries DPAPI-encrypted cookies/logins that use this masterkey
|
||||
2. Retries key/abe-encrypted cookies/logins (state keys may now be decrypted)
|
||||
|
||||
Args:
|
||||
masterkey_guid: The GUID of the newly available masterkey
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
masterkey_type: Optional masterkey type ('system', 'user', 'unknown')
|
||||
|
||||
Returns:
|
||||
Dict with statistics: {
|
||||
"cookies_attempted": int,
|
||||
"cookies_decrypted": int,
|
||||
"logins_attempted": int,
|
||||
"logins_decrypted": int,
|
||||
"errors": list
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"cookies_attempted": 0,
|
||||
"cookies_decrypted": 0,
|
||||
"logins_attempted": 0,
|
||||
"logins_decrypted": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
conn_str = get_postgres_connection_str()
|
||||
|
||||
try:
|
||||
with psycopg.connect(conn_str) as pg_conn:
|
||||
# Retry cookies
|
||||
cookies_result = await _retry_decrypt_cookies(masterkey_guid, dpapi_manager, pg_conn)
|
||||
result["cookies_attempted"] = cookies_result["attempted"]
|
||||
result["cookies_decrypted"] = cookies_result["decrypted"]
|
||||
result["errors"].extend(cookies_result["errors"])
|
||||
|
||||
# Retry logins
|
||||
logins_result = await _retry_decrypt_logins(masterkey_guid, dpapi_manager, pg_conn)
|
||||
result["logins_attempted"] = logins_result["attempted"]
|
||||
result["logins_decrypted"] = logins_result["decrypted"]
|
||||
result["errors"].extend(logins_result["errors"])
|
||||
|
||||
logger.warning(
|
||||
"Completed retroactive chromium data decryption for masterkey",
|
||||
masterkey_guid=masterkey_guid,
|
||||
cookies_attempted=result["cookies_attempted"],
|
||||
cookies_decrypted=result["cookies_decrypted"],
|
||||
logins_attempted=result["logins_attempted"],
|
||||
logins_decrypted=result["logins_decrypted"],
|
||||
errors=len(result["errors"]),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Database error during retroactive chromium data decryption: {str(e)}"
|
||||
logger.exception("Error in retry_decrypt_chromium_data", masterkey_guid=masterkey_guid, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _retry_decrypt_cookies(masterkey_guid: UUID, dpapi_manager: DpapiManager, pg_conn) -> dict:
|
||||
"""Retry decrypting cookies that previously failed.
|
||||
|
||||
Args:
|
||||
masterkey_guid: The GUID of the newly available masterkey
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
pg_conn: PostgreSQL connection
|
||||
|
||||
Returns:
|
||||
Dict with {"attempted": int, "decrypted": int, "errors": list}
|
||||
"""
|
||||
result = {"attempted": 0, "decrypted": 0, "errors": []}
|
||||
|
||||
# Find all undecrypted cookies
|
||||
# For DPAPI: only those matching this masterkey
|
||||
# For key/abe: all undecrypted (state keys may now be available)
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, encryption_type, masterkey_guid, state_key_id,
|
||||
value_enc, source, username, browser
|
||||
FROM chromium.cookies
|
||||
WHERE is_decrypted = FALSE
|
||||
AND (
|
||||
(encryption_type = 'dpapi' AND masterkey_guid = %s)
|
||||
OR encryption_type IN ('key', 'abe')
|
||||
)
|
||||
""",
|
||||
(masterkey_guid,),
|
||||
)
|
||||
cookies = cur.fetchall()
|
||||
|
||||
logger.debug(
|
||||
"Found undecrypted cookies to retry",
|
||||
masterkey_guid=masterkey_guid,
|
||||
count=len(cookies),
|
||||
)
|
||||
|
||||
for cookie in cookies:
|
||||
result["attempted"] += 1
|
||||
cookie_id, encryption_type, cookie_masterkey_guid, state_key_id, value_enc, source, username, browser = cookie
|
||||
|
||||
try:
|
||||
value_dec = None
|
||||
decrypted = False
|
||||
|
||||
# Try DPAPI decryption
|
||||
if encryption_type == "dpapi" and cookie_masterkey_guid == masterkey_guid:
|
||||
try:
|
||||
value_dec_bytes = await dpapi_manager.decrypt_blob(Blob.from_bytes(value_enc))
|
||||
if value_dec_bytes:
|
||||
value_dec = value_dec_bytes.decode("utf-8", errors="replace")
|
||||
decrypted = True
|
||||
logger.debug("Successfully decrypted cookie with DPAPI", cookie_id=cookie_id)
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError):
|
||||
# Still not available
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Error decrypting cookie with DPAPI", cookie_id=cookie_id, error=str(e))
|
||||
|
||||
# Try state key decryption (key/abe)
|
||||
elif encryption_type in ["key", "abe"]:
|
||||
# Try with existing state_key_id if available
|
||||
if state_key_id:
|
||||
state_key_bytes = get_state_key_bytes(state_key_id, encryption_type, pg_conn)
|
||||
if state_key_bytes:
|
||||
try:
|
||||
value_dec_bytes = decrypt_chrome_string(value_enc, state_key_bytes, encryption_type)
|
||||
if value_dec_bytes:
|
||||
# Apply offset handling for cookies
|
||||
if encryption_type == "abe" and len(value_dec_bytes) > 32:
|
||||
value_dec_bytes = value_dec_bytes[32:]
|
||||
elif encryption_type == "key" and len(value_dec_bytes) > 48:
|
||||
value_dec_bytes = value_dec_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(value_dec_bytes) > 16:
|
||||
value_dec_bytes = value_dec_bytes[:-16]
|
||||
|
||||
value_dec = value_dec_bytes.decode("utf-8", errors="replace")
|
||||
decrypted = True
|
||||
logger.debug(
|
||||
"Successfully decrypted cookie with state key",
|
||||
cookie_id=cookie_id,
|
||||
state_key_id=state_key_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to decrypt cookie with state key",
|
||||
cookie_id=cookie_id,
|
||||
state_key_id=state_key_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# If no state_key_id or decryption failed, try to find matching state key
|
||||
if not decrypted and username and browser:
|
||||
try:
|
||||
with pg_conn.cursor() as cur2:
|
||||
# Try to find a decrypted state key for this source/username/browser
|
||||
cur2.execute(
|
||||
"""
|
||||
SELECT id FROM chromium.state_keys
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND (
|
||||
(key_is_decrypted = TRUE AND %s = 'key')
|
||||
OR (app_bound_key_is_decrypted = TRUE AND %s = 'abe')
|
||||
)
|
||||
""",
|
||||
(source, username, browser, encryption_type, encryption_type),
|
||||
)
|
||||
state_key_row = cur2.fetchone()
|
||||
if state_key_row:
|
||||
new_state_key_id = state_key_row[0]
|
||||
state_key_bytes = get_state_key_bytes(new_state_key_id, encryption_type, pg_conn)
|
||||
if state_key_bytes:
|
||||
try:
|
||||
value_dec_bytes = decrypt_chrome_string(
|
||||
value_enc, state_key_bytes, encryption_type
|
||||
)
|
||||
if value_dec_bytes:
|
||||
# Apply offset handling
|
||||
if encryption_type == "abe" and len(value_dec_bytes) > 32:
|
||||
value_dec_bytes = value_dec_bytes[32:]
|
||||
elif encryption_type == "key" and len(value_dec_bytes) > 48:
|
||||
value_dec_bytes = value_dec_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(value_dec_bytes) > 16:
|
||||
value_dec_bytes = value_dec_bytes[:-16]
|
||||
|
||||
value_dec = value_dec_bytes.decode("utf-8", errors="replace")
|
||||
decrypted = True
|
||||
state_key_id = new_state_key_id
|
||||
logger.debug(
|
||||
"Successfully decrypted cookie with newly found state key",
|
||||
cookie_id=cookie_id,
|
||||
state_key_id=new_state_key_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to decrypt cookie with newly found state key",
|
||||
cookie_id=cookie_id,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error looking up state key for cookie", cookie_id=cookie_id, error=str(e))
|
||||
|
||||
# Update database if decrypted
|
||||
if decrypted and value_dec:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.cookies
|
||||
SET value_dec = %s, is_decrypted = TRUE, state_key_id = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(value_dec, state_key_id, cookie_id),
|
||||
)
|
||||
pg_conn.commit()
|
||||
result["decrypted"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing cookie {cookie_id}: {str(e)}"
|
||||
logger.warning("Failed to retry decrypt cookie", cookie_id=cookie_id, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _retry_decrypt_logins(masterkey_guid: UUID, dpapi_manager: DpapiManager, pg_conn) -> dict:
|
||||
"""Retry decrypting logins that previously failed.
|
||||
|
||||
Args:
|
||||
masterkey_guid: The GUID of the newly available masterkey
|
||||
dpapi_manager: DpapiManager instance for decryption
|
||||
pg_conn: PostgreSQL connection
|
||||
|
||||
Returns:
|
||||
Dict with {"attempted": int, "decrypted": int, "errors": list}
|
||||
"""
|
||||
result = {"attempted": 0, "decrypted": 0, "errors": []}
|
||||
|
||||
# Find all undecrypted logins
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, encryption_type, masterkey_guid, state_key_id,
|
||||
password_value_enc, source, username, browser
|
||||
FROM chromium.logins
|
||||
WHERE is_decrypted = FALSE
|
||||
AND (
|
||||
(encryption_type = 'dpapi' AND masterkey_guid = %s)
|
||||
OR encryption_type IN ('key', 'abe')
|
||||
)
|
||||
""",
|
||||
(masterkey_guid,),
|
||||
)
|
||||
logins = cur.fetchall()
|
||||
|
||||
logger.debug(
|
||||
"Found undecrypted logins to retry",
|
||||
masterkey_guid=masterkey_guid,
|
||||
count=len(logins),
|
||||
)
|
||||
|
||||
for login in logins:
|
||||
result["attempted"] += 1
|
||||
(
|
||||
login_id,
|
||||
encryption_type,
|
||||
login_masterkey_guid,
|
||||
state_key_id,
|
||||
password_value_enc,
|
||||
source,
|
||||
username,
|
||||
browser,
|
||||
) = login
|
||||
|
||||
try:
|
||||
password_dec = None
|
||||
decrypted = False
|
||||
|
||||
# Try DPAPI decryption
|
||||
if encryption_type == "dpapi" and login_masterkey_guid == masterkey_guid:
|
||||
try:
|
||||
password_dec_bytes = await dpapi_manager.decrypt_blob(Blob.from_bytes(password_value_enc))
|
||||
if password_dec_bytes:
|
||||
password_dec = password_dec_bytes.decode("utf-8", errors="replace")
|
||||
decrypted = True
|
||||
logger.debug("Successfully decrypted login with DPAPI", login_id=login_id)
|
||||
except (MasterKeyNotFoundError, MasterKeyNotDecryptedError):
|
||||
# Still not available
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Error decrypting login with DPAPI", login_id=login_id, error=str(e))
|
||||
|
||||
# Try state key decryption (key/abe)
|
||||
elif encryption_type in ["key", "abe"]:
|
||||
# Try with existing state_key_id if available
|
||||
if state_key_id:
|
||||
state_key_bytes = get_state_key_bytes(state_key_id, encryption_type, pg_conn)
|
||||
if state_key_bytes:
|
||||
try:
|
||||
password_dec_bytes = decrypt_chrome_string(
|
||||
password_value_enc, state_key_bytes, encryption_type
|
||||
)
|
||||
if password_dec_bytes:
|
||||
# Apply offset handling for passwords
|
||||
if encryption_type == "key" and len(password_dec_bytes) > 32:
|
||||
password_dec_bytes = password_dec_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(password_dec_bytes) > 16:
|
||||
password_dec_bytes = password_dec_bytes[:-16]
|
||||
# v20 passwords typically don't have offset like cookies
|
||||
|
||||
password_dec = password_dec_bytes.decode("utf-8", errors="replace")
|
||||
decrypted = True
|
||||
logger.debug(
|
||||
"Successfully decrypted login with state key",
|
||||
login_id=login_id,
|
||||
state_key_id=state_key_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to decrypt login with state key",
|
||||
login_id=login_id,
|
||||
state_key_id=state_key_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# If no state_key_id or decryption failed, try to find matching state key
|
||||
if not decrypted and source:
|
||||
try:
|
||||
with pg_conn.cursor() as cur2:
|
||||
if username and browser:
|
||||
# Try to find a decrypted state key for this source/username/browser
|
||||
cur2.execute(
|
||||
"""
|
||||
SELECT id FROM chromium.state_keys
|
||||
WHERE source = %s AND username = %s AND browser = %s
|
||||
AND (
|
||||
(key_is_decrypted = TRUE AND %s = 'key')
|
||||
OR (app_bound_key_is_decrypted = TRUE AND %s = 'abe')
|
||||
)
|
||||
""",
|
||||
(source, username, browser, encryption_type, encryption_type),
|
||||
)
|
||||
else:
|
||||
# if no username/password, just restrict to SOURCE
|
||||
cur2.execute(
|
||||
"""
|
||||
SELECT id FROM chromium.state_keys
|
||||
WHERE source = %s
|
||||
AND (
|
||||
(key_is_decrypted = TRUE AND %s = 'key')
|
||||
OR (app_bound_key_is_decrypted = TRUE AND %s = 'abe')
|
||||
)
|
||||
""",
|
||||
(source, encryption_type, encryption_type),
|
||||
)
|
||||
state_key_row = cur2.fetchone()
|
||||
if state_key_row:
|
||||
new_state_key_id = state_key_row[0]
|
||||
state_key_bytes = get_state_key_bytes(new_state_key_id, encryption_type, pg_conn)
|
||||
if state_key_bytes:
|
||||
try:
|
||||
password_dec_bytes = decrypt_chrome_string(
|
||||
password_value_enc, state_key_bytes, encryption_type
|
||||
)
|
||||
if password_dec_bytes:
|
||||
# Apply offset handling
|
||||
if encryption_type == "key" and len(password_dec_bytes) > 32:
|
||||
password_dec_bytes = password_dec_bytes[32:-16]
|
||||
elif encryption_type == "key" and len(password_dec_bytes) > 16:
|
||||
password_dec_bytes = password_dec_bytes[:-16]
|
||||
|
||||
password_dec = password_dec_bytes.decode("utf-8", errors="replace")
|
||||
decrypted = True
|
||||
state_key_id = new_state_key_id
|
||||
logger.debug(
|
||||
"Successfully decrypted login with newly found state key",
|
||||
login_id=login_id,
|
||||
state_key_id=new_state_key_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to decrypt login with newly found state key",
|
||||
login_id=login_id,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error looking up state key for login", login_id=login_id, error=str(e))
|
||||
|
||||
# Update database if decrypted
|
||||
if decrypted and password_dec:
|
||||
with pg_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE chromium.logins
|
||||
SET password_value_dec = %s, is_decrypted = TRUE, state_key_id = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(password_dec, state_key_id, login_id),
|
||||
)
|
||||
pg_conn.commit()
|
||||
result["decrypted"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing login {login_id}: {str(e)}"
|
||||
logger.warning("Failed to retry decrypt login", login_id=login_id, error=str(e))
|
||||
result["errors"].append(error_msg)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,25 @@
|
||||
[tool.poetry]
|
||||
name = "chromium"
|
||||
version = "0.1.0"
|
||||
description = "Modules Nemesis uses to handle Chromium files"
|
||||
authors = ["SpecterOps"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.12,<4.0"
|
||||
impacket = ">=0.12.0,<0.13.0"
|
||||
psycopg = {extras = ["binary"], version = ">=3.0.0,<4.0.0"}
|
||||
dapr = "1.16.0"
|
||||
structlog = ">=20.0.0,<30.0.0"
|
||||
nemesis_dpapi = { path = "../nemesis_dpapi", develop = true }
|
||||
file_linking = { path = "../file_linking", develop = true }
|
||||
common = { path = "../../libs/common", develop = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.9.2"
|
||||
pytest = "^8.4.2"
|
||||
pytest-asyncio = "^1.2.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -0,0 +1,3 @@
|
||||
def test_example():
|
||||
"""Simple example test to verify pytest is working."""
|
||||
assert True
|
||||
@@ -8,13 +8,12 @@
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
"source.fixAll.ruff": "explicit",
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"autoDocstring.docstringFormat": "google",
|
||||
"editor.formatOnSave": true,
|
||||
"files.exclude": {
|
||||
"**/.DS_Store": true,
|
||||
"**/.git": true,
|
||||
@@ -62,4 +61,5 @@
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
from functools import lru_cache
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from dapr.clients import DaprClient
|
||||
|
||||
_DAPR_SECRET_STORE_NAME = "nemesis-secret-store"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_postgres_connection_str(dapr_client: DaprClient | None = None) -> str:
|
||||
"""Get PostgreSQL connection string from Dapr secrets by building it from individual parameters."""
|
||||
|
||||
def fetch_secrets(client: DaprClient) -> dict:
|
||||
"""Fetch all required PostgreSQL secrets."""
|
||||
secrets = {}
|
||||
secret_keys = ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_HOST", "POSTGRES_PORT", "POSTGRES_DB", "POSTGRES_PARAMETERS"]
|
||||
|
||||
for key in secret_keys:
|
||||
try:
|
||||
secret = client.get_secret(store_name=_DAPR_SECRET_STORE_NAME, key=key)
|
||||
secrets[key] = secret.secret[key]
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to fetch {key} from Dapr secret store: {e}") from e
|
||||
|
||||
return secrets
|
||||
|
||||
if dapr_client:
|
||||
secrets = fetch_secrets(dapr_client)
|
||||
else:
|
||||
with DaprClient() as client:
|
||||
secrets = fetch_secrets(client)
|
||||
|
||||
# Build the connection string from individual parameters
|
||||
# URL-encode user and password to handle special characters like @, :, /, etc.
|
||||
user = quote_plus(secrets["POSTGRES_USER"])
|
||||
password = quote_plus(secrets["POSTGRES_PASSWORD"])
|
||||
host = secrets["POSTGRES_HOST"]
|
||||
port = secrets["POSTGRES_PORT"]
|
||||
db = secrets["POSTGRES_DB"]
|
||||
parameters = secrets["POSTGRES_PARAMETERS"]
|
||||
|
||||
output = f"postgresql://{user}:{password}@{host}:{port}/{db}?{parameters}"
|
||||
|
||||
if not output.startswith("postgresql://"):
|
||||
raise ValueError("Constructed POSTGRES connection string must start with 'postgresql://'")
|
||||
|
||||
return output
|
||||
@@ -3,7 +3,6 @@ import os
|
||||
import shutil
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.NullHandler())
|
||||
@@ -22,7 +21,7 @@ def find_missing_path_dependencies(
|
||||
commands: Sequence[str],
|
||||
*,
|
||||
raise_error: bool = True,
|
||||
search_paths: Optional[Sequence[Path]] = None,
|
||||
search_paths: Sequence[Path] | None = None,
|
||||
) -> list[str]:
|
||||
"""Checks if specified commands are available in system PATH.
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import hashlib
|
||||
import io
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(module=__name__)
|
||||
from typing import BinaryIO
|
||||
|
||||
|
||||
def calculate_file_hash(file_path: str, hash_type: str) -> str:
|
||||
@@ -250,6 +249,100 @@ def sanitize_file_path(file_path: str, num_chars=4):
|
||||
return f"{sanitized_base}.{extension[0]}" if extension else sanitized_base
|
||||
|
||||
|
||||
def get_file_extension(filepath):
|
||||
# Get just the final filename component of the path
|
||||
base_name = posixpath.basename(filepath)
|
||||
|
||||
# Split on the last dot, but only if the dot isn't the first character
|
||||
if base_name.startswith(".") or "." not in base_name:
|
||||
return ""
|
||||
|
||||
name_parts = base_name.split(".")
|
||||
if len(name_parts) > 1:
|
||||
return "." + name_parts[-1]
|
||||
return ""
|
||||
|
||||
def get_drive_from_path(path: str) -> str | None:
|
||||
"""
|
||||
Extract Windows drive letter from a file path.
|
||||
|
||||
Supports two path formats:
|
||||
1. POSIX-style with leading slash: "/C:/Users/..." or "/D:/Data/..."
|
||||
2. Windows-style without leading slash: "C:/Users/..." or "D:/Data/..."
|
||||
|
||||
Args:
|
||||
path: File path string to parse
|
||||
|
||||
Returns:
|
||||
str | None: Drive letter with colon, or None if no valid drive found
|
||||
- For POSIX-style paths: Returns with leading slash (e.g., "/C:", "/D:")
|
||||
- For Windows-style paths: Returns without leading slash (e.g., "C:", "D:")
|
||||
|
||||
Examples:
|
||||
>>> get_drive_from_path("/C:/Users/john/file.txt")
|
||||
'/C:'
|
||||
>>> get_drive_from_path("C:/Users/john/file.txt")
|
||||
'C:'
|
||||
>>> get_drive_from_path("/D:/Data/files")
|
||||
'/D:'
|
||||
>>> get_drive_from_path("invalid/path")
|
||||
None
|
||||
|
||||
Supported drive letters: A-Z (case-insensitive)
|
||||
"""
|
||||
parts = path.split("/")
|
||||
|
||||
# Handle paths without leading slash (e.g., "C:/Users/...")
|
||||
if len(parts) >= 1 and parts[0]:
|
||||
drive_part = parts[0]
|
||||
|
||||
# Validate drive part
|
||||
if not drive_part:
|
||||
return None
|
||||
|
||||
# Must be exactly 2 characters: letter + colon
|
||||
if len(drive_part) != 2:
|
||||
return None
|
||||
|
||||
# Second character must be ':'
|
||||
if drive_part[1] != ":":
|
||||
return None
|
||||
|
||||
# First character must be a letter (A-Z or a-z)
|
||||
if not drive_part[0].isalpha():
|
||||
return None
|
||||
|
||||
# Return drive without trailing slash for paths without leading slash
|
||||
return drive_part
|
||||
|
||||
# For paths like "/C:/Users/...", parts[0] is empty and parts[1] contains the drive
|
||||
if len(parts) >= 2:
|
||||
drive_part = parts[1]
|
||||
|
||||
# Validate drive part
|
||||
if not drive_part:
|
||||
return None
|
||||
|
||||
# Must be exactly 2 characters: letter + colon
|
||||
if len(drive_part) != 2:
|
||||
return None
|
||||
|
||||
# Second character must be ':'
|
||||
if drive_part[1] != ":":
|
||||
return None
|
||||
|
||||
# First character must be a letter (A-Z or a-z)
|
||||
if not drive_part[0].isalpha():
|
||||
return None
|
||||
|
||||
# Return "/" + drive letter with colon
|
||||
# e.g., "C:" -> "/C:"
|
||||
return f"/{drive_part}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def extract_all_strings(filename: str, min_len: int = 5):
|
||||
"""
|
||||
Returns a combined list of all single-byte ASCII strings
|
||||
@@ -274,6 +367,22 @@ def extract_all_strings(filename: str, min_len: int = 5):
|
||||
return all_strings
|
||||
|
||||
|
||||
def create_text_reader(binary_file: BinaryIO) -> io.TextIOWrapper:
|
||||
"""Creates a text reader that handles BOMs and mixed content"""
|
||||
|
||||
bom_check = binary_file.read(4)
|
||||
binary_file.seek(0) # Reset to start
|
||||
|
||||
if bom_check.startswith(b"\xff\xfe"):
|
||||
return io.TextIOWrapper(binary_file, encoding="utf-16le")
|
||||
elif bom_check.startswith(b"\xfe\xff"):
|
||||
return io.TextIOWrapper(binary_file, encoding="utf-16be")
|
||||
elif bom_check.startswith(b"\xef\xbb\xbf"):
|
||||
return io.TextIOWrapper(binary_file, encoding="utf-8-sig")
|
||||
else:
|
||||
return io.TextIOWrapper(binary_file, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def escape_markdown(text):
|
||||
"""
|
||||
Escapes markdown control characters in text by adding backslashes.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
from structlog.stdlib import ProcessorFormatter
|
||||
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
NUMERIC_LEVEL = getattr(logging, LOG_LEVEL, logging.INFO)
|
||||
|
||||
WORKFLOW_RUNTIME_LOG_LEVEL = os.getenv("WORKFLOW_RUNTIME_LOG_LEVEL", "WARNING")
|
||||
WORKFLOW_CLIENT_LOG_LEVEL = os.getenv("WORKFLOW_CLIENT_LOG_LEVEL", "WARNING")
|
||||
|
||||
|
||||
def add_callsite_from_record(_logger: logging.Logger, _method_name: str, event_dict: dict) -> dict:
|
||||
record = event_dict.get("_record")
|
||||
if record is not None:
|
||||
event_dict.setdefault("logger", record.name)
|
||||
event_dict.setdefault("module", record.module)
|
||||
event_dict.setdefault("func", record.funcName)
|
||||
event_dict.setdefault("line", record.lineno)
|
||||
return event_dict
|
||||
|
||||
|
||||
foreign_pre_chain = [
|
||||
add_callsite_from_record,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
formatter = ProcessorFormatter(
|
||||
processor=structlog.dev.ConsoleRenderer(colors=True),
|
||||
foreign_pre_chain=foreign_pre_chain,
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.handlers[:] = [handler]
|
||||
root.setLevel("WARNING")
|
||||
logging.captureWarnings(True)
|
||||
|
||||
# structlog -> hand off to ProcessorFormatter (no direct rendering here)
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name, # adds "logger" for your own logs
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
ProcessorFormatter.wrap_for_formatter, # defer final render
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
||||
logging.getLogger(name).setLevel(LOG_LEVEL)
|
||||
return structlog.get_logger(name)
|
||||
@@ -1,12 +1,16 @@
|
||||
# src/common/models.py
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, TypeVar
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
||||
|
||||
logger = structlog.get_logger(module=__name__)
|
||||
from .logger import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models2.api import FileMetadata
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
##########################################
|
||||
@@ -28,6 +32,7 @@ class FindingCategory(str, Enum):
|
||||
YARA_MATCH = "yara_match"
|
||||
PII = "pii"
|
||||
MISC = "misc"
|
||||
INFORMATIONAL = "informational"
|
||||
|
||||
|
||||
class FindingOrigin(str, Enum):
|
||||
@@ -59,6 +64,59 @@ class Alert(BaseModel):
|
||||
service: str | None = None # service that sent the message (optional)
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
# .NET
|
||||
#
|
||||
##########################################
|
||||
|
||||
|
||||
class DotNetInput(BaseModel):
|
||||
object_id: str
|
||||
|
||||
|
||||
class DotNetMethodInfo(BaseModel):
|
||||
MethodName: str
|
||||
FilterLevel: str | None = None
|
||||
|
||||
|
||||
class DotNetAssemblyAnalysis(BaseModel):
|
||||
AssemblyName: str
|
||||
Error: str | None = None
|
||||
RemotingChannels: list[str] = []
|
||||
IsWCFServer: bool = False
|
||||
IsWCFClient: bool = False
|
||||
SerializationGadgetCalls: dict[str, list[DotNetMethodInfo]] = {}
|
||||
WcfServerCalls: dict[str, list[DotNetMethodInfo]] = {}
|
||||
ClientCalls: dict[str, list[DotNetMethodInfo]] = {}
|
||||
RemotingCalls: dict[str, list[DotNetMethodInfo]] = {}
|
||||
ExecutionCalls: dict[str, list[DotNetMethodInfo]] = {}
|
||||
|
||||
|
||||
class DotNetOutput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
object_id: str = Field(alias="objectId")
|
||||
decompilation: str | None = None
|
||||
analysis: DotNetAssemblyAnalysis | None = None
|
||||
|
||||
@field_validator("analysis", mode="before")
|
||||
@classmethod
|
||||
def parse_analysis_json(cls, v):
|
||||
"""Parse analysis from JSON string if needed"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse DotNet analysis JSON: {e}")
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
# Special case for NoseyParker
|
||||
@@ -70,6 +128,14 @@ class NoseyParkerInput(BaseModel):
|
||||
object_id: str
|
||||
|
||||
|
||||
class GitCommitInfo(BaseModel):
|
||||
commit_id: str
|
||||
author: str
|
||||
author_email: str
|
||||
commit_date: str
|
||||
message: str
|
||||
|
||||
|
||||
class MatchLocation(BaseModel):
|
||||
line: int
|
||||
column: int
|
||||
@@ -81,6 +147,8 @@ class MatchInfo(BaseModel):
|
||||
matched_content: str
|
||||
location: MatchLocation
|
||||
snippet: str
|
||||
file_path: str | None = None
|
||||
git_commit: GitCommitInfo | None = None
|
||||
|
||||
|
||||
class ScanStats(BaseModel):
|
||||
@@ -90,31 +158,21 @@ class ScanStats(BaseModel):
|
||||
bytes_scanned: int
|
||||
matches_found: int
|
||||
|
||||
# Allow aliases for field names
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "ignore" # Ignore extra fields
|
||||
|
||||
|
||||
class ScanResults(BaseModel):
|
||||
scan_duration_ms: int
|
||||
bytes_scanned: int
|
||||
matches: list[MatchInfo] = [] # Default to empty list
|
||||
matches: list[MatchInfo]
|
||||
stats: ScanStats
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "ignore" # Ignore extra fields
|
||||
scan_type: str = "regular" # "regular", "zip", "git_repo"
|
||||
|
||||
|
||||
class NoseyParkerOutput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
object_id: str
|
||||
scan_result: ScanResults
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "ignore" # Ignore extra fields
|
||||
|
||||
# Add a factory method to handle flexible parsing
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict):
|
||||
@@ -137,32 +195,80 @@ class NoseyParkerOutput(BaseModel):
|
||||
bytes_scanned=data.get("scan_result", {}).get("stats", {}).get("bytes_scanned", 0),
|
||||
matches_found=data.get("scan_result", {}).get("stats", {}).get("matches_found", 0),
|
||||
),
|
||||
scan_type=data.get("scan_result", {}).get("scan_type", "regular"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
# Bulk Enrichment
|
||||
#
|
||||
##########################################
|
||||
|
||||
|
||||
class BulkEnrichmentEvent(BaseModel):
|
||||
enrichment_name: str
|
||||
object_id: str
|
||||
|
||||
|
||||
class SingleEnrichmentWorkflowInput(BaseModel):
|
||||
"""Input model for single enrichment workflows (bulk operations)."""
|
||||
|
||||
enrichment_name: str
|
||||
object_id: str
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
# Model for files submitted to the API
|
||||
#
|
||||
##########################################
|
||||
class File(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
exclude_none=True,
|
||||
exclude_unset=True,
|
||||
)
|
||||
|
||||
object_id: str
|
||||
agent_id: str
|
||||
source: str | None = None
|
||||
project: str
|
||||
timestamp: datetime
|
||||
expiration: datetime
|
||||
path: str | None = None
|
||||
path: str # | None = None
|
||||
originating_object_id: str | None = None
|
||||
originating_container_id: str | None = None
|
||||
nesting_level: int | None = None
|
||||
creation_time: str | None = None
|
||||
access_time: str | None = None
|
||||
modification_time: str | None = None
|
||||
|
||||
class Config:
|
||||
exclude_none = True
|
||||
exclude_unset = True
|
||||
json_encoders = {datetime: lambda dt: dt.isoformat()}
|
||||
@field_serializer("timestamp", "expiration")
|
||||
def serialize_datetime(self, dt: datetime, _info):
|
||||
return dt.isoformat()
|
||||
|
||||
@classmethod
|
||||
def from_file_metadata(cls, metadata: "FileMetadata", object_id: str) -> "File":
|
||||
"""
|
||||
Create a File instance from FileMetadata and object_id.
|
||||
|
||||
Args:
|
||||
metadata: FileMetadata object containing upload metadata
|
||||
object_id: The object ID of the uploaded file
|
||||
|
||||
Returns:
|
||||
File instance ready for submission
|
||||
"""
|
||||
return cls(
|
||||
object_id=object_id,
|
||||
agent_id=metadata.agent_id,
|
||||
source=metadata.source,
|
||||
project=metadata.project,
|
||||
timestamp=metadata.timestamp,
|
||||
expiration=metadata.expiration,
|
||||
path=metadata.path,
|
||||
)
|
||||
|
||||
|
||||
##########################################
|
||||
@@ -199,9 +305,6 @@ class FileEnriched(File):
|
||||
is_plaintext: bool
|
||||
is_container: bool
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda dt: dt.isoformat()}
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
@@ -218,10 +321,7 @@ class WorkflowStatus(BaseModel):
|
||||
result: dict | None = None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class CloudEvent(BaseModel, Generic[T]):
|
||||
class CloudEvent[T](BaseModel):
|
||||
"""Cloud event schema used in Dapr pub/sub"""
|
||||
|
||||
data: T
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Union
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
from pydantic import BaseModel, BeforeValidator, Field, field_serializer, field_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,14 +13,6 @@ class ErrorResponse(BaseModel):
|
||||
detail: str = Field(..., description="Error message details")
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
"""Model representing a validation error"""
|
||||
|
||||
loc: list[Union[str, int]]
|
||||
msg: str
|
||||
type: str
|
||||
|
||||
|
||||
class FileWithMetadataResponse(BaseModel):
|
||||
"""Response for combined file and metadata uploads"""
|
||||
|
||||
@@ -67,28 +60,107 @@ def ensure_utc_datetime(value) -> datetime:
|
||||
UTCDatetime = Annotated[datetime, BeforeValidator(ensure_utc_datetime)]
|
||||
|
||||
|
||||
class FileFilters(BaseModel):
|
||||
"""File filtering configuration for container extraction"""
|
||||
|
||||
include: list[str] | None = Field(
|
||||
default=None, description="Patterns for files to include. If empty/None, all files are included by default."
|
||||
)
|
||||
exclude: list[str] | None = Field(
|
||||
default=None, description="Patterns for files to exclude. Takes precedence over include patterns."
|
||||
)
|
||||
pattern_type: Literal["glob", "regex"] = Field(
|
||||
default="glob",
|
||||
description="Type of patterns to use: 'glob' for shell-style wildcards, 'regex' for regular expressions",
|
||||
)
|
||||
|
||||
@field_validator("include", "exclude")
|
||||
@classmethod
|
||||
def validate_patterns(cls, v, info):
|
||||
"""Validate that patterns can be compiled if regex type"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
# Access other field values through info.data
|
||||
pattern_type = info.data.get("pattern_type", "glob")
|
||||
|
||||
if pattern_type == "regex":
|
||||
# Validate that all regex patterns compile
|
||||
for pattern in v:
|
||||
try:
|
||||
re.compile(pattern, re.IGNORECASE)
|
||||
except re.error as e:
|
||||
raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from e
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class FileMetadata(BaseModel):
|
||||
"""Metadata model for file uploads"""
|
||||
|
||||
agent_id: str
|
||||
source: str | None = None
|
||||
project: str
|
||||
timestamp: datetime = Field(description="ISO 8601 formatted timestamp")
|
||||
expiration: datetime = Field(description="ISO 8601 formatted expiration date")
|
||||
timestamp: datetime | None = Field(
|
||||
default=None, description="ISO 8601 formatted timestamp of when the data was collected"
|
||||
)
|
||||
expiration: datetime | None = Field(
|
||||
default=None, description="ISO 8601 formatted expiration date (when the data should be deleted)"
|
||||
)
|
||||
path: str
|
||||
file_filters: FileFilters | None = Field(
|
||||
default=None, description="Optional file filtering configuration for container extraction"
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"agent_id": "beacon123",
|
||||
"source": "host://192.168.1.1",
|
||||
"project": "assess-test",
|
||||
"timestamp": "2025-01-06T23:48:46.925656Z",
|
||||
"expiration": "2026-01-06T23:48:46.925656Z",
|
||||
"path": "/path/to/file",
|
||||
"file_filters": {
|
||||
"include": ["*.exe", "*/Users/**/*"],
|
||||
"exclude": ["*/Windows/**/*", "*.tmp"],
|
||||
"pattern_type": "glob",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model_config = {"json_encoders": {datetime: lambda dt: dt.isoformat()}}
|
||||
@field_serializer("timestamp", "expiration", when_used="unless-none")
|
||||
def serialize_datetime(self, dt: datetime, _info):
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
class ContainerFromMountRequest(BaseModel):
|
||||
"""Request model for processing container files from mounted folder"""
|
||||
|
||||
filename: str = Field(description="Name of the container file in the mounted folder")
|
||||
metadata: FileMetadata = Field(description="File metadata for processing")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"filename": "large_archive.zip",
|
||||
"metadata": {
|
||||
"agent_id": "beacon123",
|
||||
"source": "host://192.168.1.1",
|
||||
"project": "assess-test",
|
||||
"timestamp": "2025-01-06T23:48:46.925656Z",
|
||||
"expiration": "2026-01-06T23:48:46.925656Z",
|
||||
"path": "/mounted/large_archive.zip",
|
||||
"file_filters": {
|
||||
"include": ["*.exe", "*/Users/**/*"],
|
||||
"exclude": ["*/Windows/**/*", "*.tmp"],
|
||||
"pattern_type": "glob",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
####################
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""DPAPI credential models for API requests."""
|
||||
|
||||
import re
|
||||
from typing import Annotated, Literal, Union
|
||||
from uuid import UUID
|
||||
|
||||
from common.logger import get_logger
|
||||
from nemesis_dpapi.types import Sid
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, field_serializer, field_validator
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PasswordCredentialKey(BaseModel):
|
||||
"""Use a plaintext password to derive a DPAPI credential key."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["password"]
|
||||
value: str # Plain text password
|
||||
user_sid: Sid # Required for to derive MK encryption keys
|
||||
|
||||
|
||||
class NtlmHashCredentialKey(BaseModel):
|
||||
"""Use an NTLM hash to derive a DPAPI credential key."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["cred_key_ntlm"]
|
||||
value: str # Hex string representation of NTLM hash (16 bytes)
|
||||
user_sid: Sid # Required for to derive MK encryption keys
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_ntlm_hash_length(cls, v):
|
||||
"""Validate that value is exactly 32 hex characters (16 bytes)."""
|
||||
if len(v) != 32:
|
||||
raise ValueError(f"NTLM hash value must be exactly 32 hex characters (16 bytes), got {len(v)} characters")
|
||||
if not re.match(r"^[0-9a-fA-F]+$", v):
|
||||
raise ValueError("NTLM hash value must contain only hex characters (0-9, a-f, A-F)")
|
||||
return v
|
||||
|
||||
|
||||
class Sha1CredentialKey(BaseModel):
|
||||
"""Use a SHA1 hash to derive a DPAPI credential key."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["cred_key_sha1"]
|
||||
value: str # Hex string representation of credential key (20 bytes)
|
||||
user_sid: Sid # Required for to derive MK encryption keys
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_cred_key_length(cls, v):
|
||||
"""Validate that value is either 40 hex characters (20 bytes)."""
|
||||
if len(v) != 40:
|
||||
raise ValueError(f"SHA1 credential key value must be 40 hex characters (20 bytes), got {len(v)} characters")
|
||||
if not re.match(r"^[0-9a-fA-F]+$", v):
|
||||
raise ValueError("SHA1 Credential key value must contain only hex characters (0-9, a-f, A-F)")
|
||||
return v
|
||||
|
||||
|
||||
class Pbkdf2StrongCredentialKey(BaseModel):
|
||||
"""Credential key object."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["cred_key_pbkdf2"]
|
||||
value: str # Hex string representation of credential key (16 bytes)
|
||||
user_sid: Sid # Required to derive MK encryption keys
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_cred_key_length(cls, v):
|
||||
"""Validate that value is either 32 hex characters (16 bytes)."""
|
||||
if len(v) != 32:
|
||||
raise ValueError(
|
||||
f"Secure credential key (PBKDF2) value must be exactly 32 hex characters (16 bytes), got {len(v)} characters"
|
||||
)
|
||||
if not re.match(r"^[0-9a-fA-F]+$", v):
|
||||
raise ValueError("Secure credential key (PBKDF2) value must contain only hex characters (0-9, a-f, A-F)")
|
||||
return v
|
||||
|
||||
|
||||
class DomainBackupKeyCredential(BaseModel):
|
||||
"""Domain backup key credential (PVK format)."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["domain_backup_key"]
|
||||
value: str # Base64 encoded PVK data
|
||||
guid: str # Domain backup key GUID (UUID format)
|
||||
domain_controller: str | None = None # Optional domain controller
|
||||
|
||||
@field_validator("guid")
|
||||
@classmethod
|
||||
def validate_guid_format(cls, v):
|
||||
"""Validate that guid is a valid UUID format."""
|
||||
try:
|
||||
UUID(v) # This will raise ValueError if not valid UUID format
|
||||
return v
|
||||
except ValueError as e:
|
||||
raise ValueError(f"guid must be a valid UUID format, got: {v}") from e
|
||||
|
||||
|
||||
class MasterKeyGuidPair(BaseModel):
|
||||
"""Strongly typed master key data."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
guid: UUID = Field(description="Master key GUID")
|
||||
key_hex: str = Field(description="Hex-encoded master key bytes", pattern=r"^[0-9a-fA-F]+$")
|
||||
|
||||
@field_serializer("guid")
|
||||
def serialize_guid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
class MasterKeyGuidPairList(BaseModel):
|
||||
"""Decrypted master key/GUID pairs."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["master_key_guid_pair"]
|
||||
value: list[MasterKeyGuidPair]
|
||||
|
||||
|
||||
class DpapiSystemCredentialRequest(BaseModel):
|
||||
"""DPAPI_SYSTEM LSA Secret credential sent in an API request."""
|
||||
|
||||
model_config = {"frozen": True, "extra": "forbid"}
|
||||
|
||||
type: Literal["dpapi_system"]
|
||||
value: str = Field(
|
||||
description="Hex-encoded DPAPI_SYSTEM LSA secret (40 bytes)",
|
||||
pattern=r"^[0-9a-fA-F]{80}$",
|
||||
)
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_hex_length(cls, v):
|
||||
"""Validate that value is exactly 80 hex characters (40 bytes)."""
|
||||
if len(v) != 80:
|
||||
raise ValueError(
|
||||
f"DPAPI_SYSTEM value must be exactly 80 hex characters (40 bytes), got {len(v)} characters"
|
||||
)
|
||||
if not re.match(r"^[0-9a-fA-F]+$", v):
|
||||
raise ValueError("DPAPI_SYSTEM value must contain only hex characters (0-9, a-f, A-F)")
|
||||
return v
|
||||
|
||||
|
||||
def get_credential_type(v):
|
||||
"""Discriminator function to determine credential type from 'type' field."""
|
||||
if isinstance(v, dict):
|
||||
return v.get("type")
|
||||
return getattr(v, "type", None)
|
||||
|
||||
|
||||
type DpapiCredentialRequest = Annotated[
|
||||
Union[
|
||||
Annotated[PasswordCredentialKey, Tag("password")],
|
||||
Annotated[NtlmHashCredentialKey, Tag("cred_key_ntlm")],
|
||||
Annotated[Sha1CredentialKey, Tag("cred_key_sha1")],
|
||||
Annotated[Pbkdf2StrongCredentialKey, Tag("cred_key_pbkdf2")],
|
||||
Annotated[DomainBackupKeyCredential, Tag("domain_backup_key")],
|
||||
Annotated[MasterKeyGuidPairList, Tag("master_key_guid_pair")],
|
||||
Annotated[DpapiSystemCredentialRequest, Tag("dpapi_system")],
|
||||
],
|
||||
Field(discriminator=Discriminator(get_credential_type)),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""API models for the /enrichments route."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EnrichmentRequest(BaseModel):
|
||||
object_id: str
|
||||
|
||||
|
||||
class EnrichmentResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
object_id: str
|
||||
instance_id: str
|
||||
|
||||
|
||||
class ModulesListResponse(BaseModel):
|
||||
modules: list[str]
|
||||
@@ -1,72 +1,113 @@
|
||||
import json
|
||||
|
||||
import asyncpg
|
||||
import psycopg
|
||||
import structlog
|
||||
from dapr.clients import DaprClient
|
||||
|
||||
from common.db import get_postgres_connection_str
|
||||
from common.models import FileEnriched
|
||||
|
||||
logger = structlog.get_logger(module=__name__)
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Single source of truth for file_enriched query (using psycopg style with %s placeholders)
|
||||
_FILE_ENRICHED_SELECT_QUERY = """
|
||||
SELECT
|
||||
object_id, agent_id, source, project, timestamp, expiration,
|
||||
path, file_name, extension, size, magic_type, mime_type,
|
||||
is_plaintext, is_container, originating_object_id,
|
||||
nesting_level, file_creation_time, file_access_time,
|
||||
file_modification_time, security_info, hashes
|
||||
FROM files_enriched
|
||||
"""
|
||||
|
||||
_FILE_ENRICHED_SELECT_QUERY_PSYCHOPG = f"""
|
||||
{_FILE_ENRICHED_SELECT_QUERY}
|
||||
WHERE object_id = %s
|
||||
"""
|
||||
|
||||
_FILE_ENRICHED_SELECT_QUERY_ASYNCPG = f"""
|
||||
{_FILE_ENRICHED_SELECT_QUERY}
|
||||
WHERE object_id = $1
|
||||
"""
|
||||
|
||||
|
||||
with DaprClient() as client:
|
||||
secret = client.get_secret(store_name="nemesis-secret-store", key="POSTGRES_CONNECTION_STRING")
|
||||
postgres_connection_string = secret.secret["POSTGRES_CONNECTION_STRING"]
|
||||
def _transform_file_enriched_data(file_data: dict) -> dict:
|
||||
"""
|
||||
Transform raw database data into format suitable for FileEnriched model.
|
||||
|
||||
Handles:
|
||||
- UUID to string conversion
|
||||
- Datetime to ISO format conversion
|
||||
- JSON field parsing
|
||||
- None value removal
|
||||
|
||||
Args:
|
||||
file_data: Dictionary of raw database values
|
||||
|
||||
Returns:
|
||||
Transformed dictionary ready for FileEnriched.model_validate()
|
||||
"""
|
||||
# Convert UUID to string
|
||||
if "object_id" in file_data and file_data["object_id"]:
|
||||
file_data["object_id"] = str(file_data["object_id"])
|
||||
if "originating_object_id" in file_data and file_data["originating_object_id"]:
|
||||
file_data["originating_object_id"] = str(file_data["originating_object_id"])
|
||||
|
||||
# Convert datetime objects to ISO format strings
|
||||
datetime_fields = [
|
||||
"timestamp",
|
||||
"expiration",
|
||||
"file_creation_time",
|
||||
"file_access_time",
|
||||
"file_modification_time",
|
||||
]
|
||||
for field in datetime_fields:
|
||||
if field in file_data and file_data[field]:
|
||||
file_data[field] = file_data[field].isoformat()
|
||||
|
||||
# Handle JSON fields (parse if string, keep as-is if dict)
|
||||
if "security_info" in file_data and isinstance(file_data["security_info"], str):
|
||||
file_data["security_info"] = json.loads(file_data["security_info"])
|
||||
if "hashes" in file_data and isinstance(file_data["hashes"], str):
|
||||
file_data["hashes"] = json.loads(file_data["hashes"])
|
||||
|
||||
# Remove None values
|
||||
file_data = {k: v for k, v in file_data.items() if v is not None}
|
||||
|
||||
return file_data
|
||||
|
||||
|
||||
def get_file_enriched(object_id: str) -> FileEnriched:
|
||||
"""Retrieve a file_enriched record from PostgreSQL and parse it into a FileEnriched object."""
|
||||
"""
|
||||
Retrieve a file_enriched record from PostgreSQL (synchronous).
|
||||
|
||||
Args:
|
||||
object_id: The object_id to query
|
||||
|
||||
Returns:
|
||||
FileEnriched model instance
|
||||
|
||||
Raises:
|
||||
ValueError: If no record found for object_id
|
||||
Exception: For database or parsing errors
|
||||
"""
|
||||
try:
|
||||
with psycopg.connect(postgres_connection_string) as conn:
|
||||
with psycopg.connect(get_postgres_connection_str()) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Query remains the same
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
object_id, agent_id, project, timestamp, expiration, path,
|
||||
file_name, extension, size, magic_type, mime_type,
|
||||
is_plaintext, is_container, originating_object_id,
|
||||
nesting_level, file_creation_time, file_access_time,
|
||||
file_modification_time, security_info, hashes
|
||||
FROM files_enriched
|
||||
WHERE object_id = %s
|
||||
""",
|
||||
(object_id,),
|
||||
)
|
||||
cur.execute(_FILE_ENRICHED_SELECT_QUERY_PSYCHOPG, (object_id,))
|
||||
|
||||
result = cur.fetchone()
|
||||
if not result:
|
||||
raise ValueError(f"No file_enriched record found for object_id {object_id}")
|
||||
|
||||
if not cur.description:
|
||||
raise RuntimeError("Query returned no column descriptions")
|
||||
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
file_data = dict(zip(columns, result))
|
||||
|
||||
# Convert UUID to string
|
||||
if "object_id" in file_data and file_data["object_id"]:
|
||||
file_data["object_id"] = str(file_data["object_id"])
|
||||
if "originating_object_id" in file_data and file_data["originating_object_id"]:
|
||||
file_data["originating_object_id"] = str(file_data["originating_object_id"])
|
||||
|
||||
# Convert datetime objects to ISO format strings
|
||||
datetime_fields = [
|
||||
"timestamp",
|
||||
"expiration",
|
||||
"file_creation_time",
|
||||
"file_access_time",
|
||||
"file_modification_time",
|
||||
]
|
||||
for field in datetime_fields:
|
||||
if field in file_data and file_data[field]:
|
||||
file_data[field] = file_data[field].isoformat()
|
||||
|
||||
# Handle JSON fields
|
||||
if "security_info" in file_data and isinstance(file_data["security_info"], str):
|
||||
file_data["security_info"] = json.loads(file_data["security_info"])
|
||||
if "hashes" in file_data and isinstance(file_data["hashes"], str):
|
||||
file_data["hashes"] = json.loads(file_data["hashes"])
|
||||
|
||||
# Remove None values
|
||||
file_data = {k: v for k, v in file_data.items() if v is not None}
|
||||
# Transform data using shared helper
|
||||
file_data = _transform_file_enriched_data(file_data)
|
||||
|
||||
return FileEnriched.model_validate(file_data)
|
||||
|
||||
@@ -76,3 +117,51 @@ def get_file_enriched(object_id: str) -> FileEnriched:
|
||||
except Exception as e:
|
||||
logger.exception(e, message="Error retrieving file_enriched from PostgreSQL")
|
||||
raise
|
||||
|
||||
|
||||
async def get_file_enriched_async(object_id: str, connection: str | asyncpg.Pool | None = None) -> FileEnriched:
|
||||
"""
|
||||
Retrieve a file_enriched record from PostgreSQL (asynchronous using asyncpg).
|
||||
|
||||
Args:
|
||||
object_id: The object_id to query
|
||||
connection: Optional connection string or asyncpg.Pool. If not provided, uses get_postgres_connection_str()
|
||||
|
||||
Returns:
|
||||
FileEnriched model instance
|
||||
|
||||
Raises:
|
||||
ValueError: If no record found for object_id
|
||||
Exception: For database or parsing errors
|
||||
"""
|
||||
try:
|
||||
# Determine if we're using a pool or creating a new connection
|
||||
if isinstance(connection, asyncpg.Pool):
|
||||
# Use the provided pool
|
||||
row = await connection.fetchrow(_FILE_ENRICHED_SELECT_QUERY_ASYNCPG, object_id)
|
||||
else:
|
||||
# Use connection string (provided or default)
|
||||
connection_string = connection if connection is not None else get_postgres_connection_str()
|
||||
conn = await asyncpg.connect(connection_string)
|
||||
try:
|
||||
row = await conn.fetchrow(_FILE_ENRICHED_SELECT_QUERY_ASYNCPG, object_id)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if not row:
|
||||
raise ValueError(f"No file_enriched record found for object_id {object_id}")
|
||||
|
||||
# Convert asyncpg.Record to dict
|
||||
file_data = dict(row)
|
||||
|
||||
# Transform data using shared helper
|
||||
file_data = _transform_file_enriched_data(file_data)
|
||||
|
||||
return FileEnriched.model_validate(file_data)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"File not found: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e, message="Error retrieving file_enriched from PostgreSQL (async)")
|
||||
raise
|
||||
|
||||
@@ -2,14 +2,15 @@ import tempfile
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import structlog
|
||||
from dapr.clients import DaprClient
|
||||
from fastapi import UploadFile
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from urllib3 import PoolManager, Retry
|
||||
|
||||
logger = structlog.get_logger(module=__name__)
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StorageMinio:
|
||||
@@ -58,11 +59,12 @@ class StorageMinio:
|
||||
logger.exception(e, message="Failed to download file")
|
||||
raise
|
||||
finally:
|
||||
logger.info("Downloaded file", file_uuid=file_uuid)
|
||||
logger.debug("Downloaded file", file_uuid=file_uuid)
|
||||
|
||||
return temp_file
|
||||
except Exception as e:
|
||||
logger.exception(e, file_uuid=file_uuid, bucket_name=self.bucket_name)
|
||||
raise
|
||||
|
||||
def download_bytes(self, file_uuid: str, offset: int = 0, length: int = 0) -> bytes:
|
||||
try:
|
||||
@@ -75,7 +77,7 @@ class StorageMinio:
|
||||
file_data = response.read()
|
||||
response.close()
|
||||
|
||||
logger.info("Successfully downloaded file", file_uuid=file_uuid)
|
||||
logger.debug("Successfully downloaded file", file_uuid=file_uuid)
|
||||
return file_data
|
||||
|
||||
except BaseException as e:
|
||||
@@ -115,7 +117,7 @@ class StorageMinio:
|
||||
yield chunk
|
||||
|
||||
response.close()
|
||||
logger.info("Successfully streamed file", file_uuid=file_uuid)
|
||||
logger.debug("Successfully streamed file", file_uuid=file_uuid)
|
||||
|
||||
except BaseException as e:
|
||||
logger.exception(e, message="Failed to stream file")
|
||||
@@ -131,10 +133,7 @@ class StorageMinio:
|
||||
return self.minio_client.stat_object(self.bucket_name, object_name)
|
||||
except Exception as e:
|
||||
logger.exception(e, "Error pulling object stats", object_name=object_name)
|
||||
|
||||
def check_bucket_exists(self):
|
||||
"""Returns True if the bucket exists, false if it doesn't."""
|
||||
return self.minio_client.bucket_exists(self.bucket_name)
|
||||
raise
|
||||
|
||||
def check_file_exists(self, object_name):
|
||||
"""Check if a file exists."""
|
||||
@@ -149,41 +148,10 @@ class StorageMinio:
|
||||
# For other errors, raise the exception
|
||||
raise
|
||||
|
||||
def ensure_bucket_exists(self):
|
||||
"""Checks if the bucket exists and creates it w/ the LifecycleConfig if not."""
|
||||
try:
|
||||
if not self.check_bucket_exists():
|
||||
logger.info("Creating Minio bucket", bucket=self.bucket_name)
|
||||
self.minio_client.make_bucket(f"{self.bucket_name}")
|
||||
|
||||
# # since this is the only place that creates the bucket, we can set
|
||||
# # the auto-expiration policy here
|
||||
# # # NOTE: this is now handled by the Housekeeping service
|
||||
|
||||
# config = LifecycleConfig(
|
||||
# [
|
||||
# Rule(
|
||||
# ENABLED,
|
||||
# rule_filter=Filter(prefix=""),
|
||||
# rule_id=f"expire-{self.storage_expiration_days}-days",
|
||||
# expiration=Expiration(days=self.storage_expiration_days),
|
||||
# ),
|
||||
# ],
|
||||
# )
|
||||
# logger.info(
|
||||
# f"Setting Minio bucket files to expire in {self.storage_expiration_days} days",
|
||||
# bucket=self.bucket_name,
|
||||
# )
|
||||
# self.minio_client.set_bucket_lifecycle(self.bucket_name, config)
|
||||
except Exception as e:
|
||||
logger.exception(e, bucket_name=self.bucket_name)
|
||||
|
||||
def upload_uploadfile(self, file: UploadFile) -> uuid.UUID:
|
||||
# uploads an UploadFile post directly from FastAPI
|
||||
try:
|
||||
self.ensure_bucket_exists()
|
||||
|
||||
logger.info(f"Uploading UploadFile {file.filename} to storage")
|
||||
logger.debug(f"Uploading UploadFile {file.filename} to storage")
|
||||
|
||||
# Get file size using SpooledTemporaryFile instead of async read
|
||||
file_size = 0
|
||||
@@ -204,16 +172,16 @@ class StorageMinio:
|
||||
file.file, # Use file.file directly
|
||||
length=file_size, # Provide the calculated size
|
||||
)
|
||||
logger.debug("Object upload compelted", file_name=file.filename)
|
||||
logger.debug("Object upload completed", file_name=file.filename)
|
||||
|
||||
return file_uuid
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e, bucket_name=self.bucket_name)
|
||||
raise
|
||||
|
||||
def upload_file(self, file_path: str) -> uuid.UUID:
|
||||
try:
|
||||
self.ensure_bucket_exists()
|
||||
logger.debug("Uploading file to storage", file_path=file_path)
|
||||
file_uuid = f"{uuid.uuid4()}"
|
||||
self.minio_client.fput_object(
|
||||
@@ -224,10 +192,10 @@ class StorageMinio:
|
||||
return file_uuid
|
||||
except Exception as e:
|
||||
logger.exception(e, file_path=file_path, bucket_name=self.bucket_name)
|
||||
raise
|
||||
|
||||
def upload(self, data: bytes) -> uuid.UUID:
|
||||
try:
|
||||
self.ensure_bucket_exists()
|
||||
logger.debug(f"Uploading {len(data)} bytes to storage")
|
||||
file_uuid = f"{uuid.uuid4()}"
|
||||
self.minio_client.put_object(
|
||||
@@ -239,6 +207,7 @@ class StorageMinio:
|
||||
return file_uuid
|
||||
except Exception as e:
|
||||
logger.exception(e, bucket_name=self.bucket_name)
|
||||
raise
|
||||
|
||||
def delete_object(self, object_id: str) -> bool:
|
||||
"""Delete a single object from Minio storage.
|
||||
@@ -281,7 +250,7 @@ class StorageMinio:
|
||||
upload creates everything correctly.
|
||||
"""
|
||||
|
||||
logger.info("Deleting all files from bucket", bucket_name=self.bucket_name)
|
||||
logger.debug("Deleting all files from bucket", bucket_name=self.bucket_name)
|
||||
|
||||
try:
|
||||
files = self.minio_client.list_objects(self.bucket_name, recursive=True)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
import dapr.ext.workflow as wf
|
||||
from common.logger import WORKFLOW_RUNTIME_LOG_LEVEL
|
||||
from dapr.ext.workflow.logger.options import LoggerOptions
|
||||
|
||||
wf_runtime: wf.WorkflowRuntime = wf.WorkflowRuntime(
|
||||
logger_options=LoggerOptions(
|
||||
log_level=WORKFLOW_RUNTIME_LOG_LEVEL,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def set_fastapi_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global fastapi_loop
|
||||
fastapi_loop = loop
|
||||
|
||||
|
||||
def workflow_activity(fn: Callable | None = None, *, name: str | None = None) -> Callable:
|
||||
"""
|
||||
Decorator to mark an async function as a workflow activity.
|
||||
The default @wf_runtime.activity decorator does not support async functions.
|
||||
|
||||
Can be used with or without parentheses:
|
||||
@workflow_activity
|
||||
async def my_activity(): ...
|
||||
|
||||
@workflow_activity()
|
||||
async def my_activity(): ...
|
||||
|
||||
@workflow_activity(name="custom_name")
|
||||
async def my_activity(): ...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
# To facilitate unit-testing, avoid using the @wf_runtime.activity decorator
|
||||
# and simply return the function as is.
|
||||
# if settings.pytest_running:
|
||||
# return func
|
||||
|
||||
@wf_runtime.activity(name=name)
|
||||
@wraps(func)
|
||||
def wrapped_fn(*args, **kwargs) -> Any: # type: ignore
|
||||
result = func(*args, **kwargs)
|
||||
if not asyncio.iscoroutine(result):
|
||||
# If the result is not a coroutine, just return it as is.
|
||||
return result
|
||||
|
||||
if fastapi_loop is None:
|
||||
raise RuntimeError("FastAPI event loop is not set.")
|
||||
return asyncio.run_coroutine_threadsafe(result, fastapi_loop).result()
|
||||
|
||||
return wrapped_fn
|
||||
|
||||
# If called without parentheses, fn will be the function
|
||||
if fn is not None:
|
||||
return decorator(fn)
|
||||
|
||||
# If called with parentheses (with or without arguments), return the decorator
|
||||
return decorator
|
||||
@@ -9,84 +9,19 @@ readme = "README.md"
|
||||
python = ">=3.12,<4.0"
|
||||
pydantic = "^2.10.5"
|
||||
structlog = "^25.1.0"
|
||||
dapr = "^1.14.0"
|
||||
dapr = "1.16.0"
|
||||
minio = "^7.2.14"
|
||||
fastapi = "^0.115.6"
|
||||
dapr-ext-workflow = "^1.16.0"
|
||||
psycopg = {extras = ["pool"], version = "^3.2.9"}
|
||||
asyncpg = "^0.30.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.9.2"
|
||||
pytest = "^8.4.1"
|
||||
pytest-asyncio = "^1.1.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"C", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
"UP", # pyupgrade
|
||||
"NPY", # numpydoc
|
||||
"A", # flake8-annotations
|
||||
"TCH001", # Move application-only imports into TYPE_CHECKING block
|
||||
"TCH002", # Move third-party imports into TYPE_CHECKING block
|
||||
"TCH003", # Move standard library imports into TYPE_CHECKING block
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long, handled by black
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"C901", # too complex
|
||||
"W191", # indentation contains tabs
|
||||
"F722", # syntax error in forward annotation
|
||||
"UP007", # X | Y syntax while we're still supporting 3.9
|
||||
"UP038", # isinstance() X | Y instance ^
|
||||
"B905", # zip() without strict (isn't supported in 3.9)
|
||||
]
|
||||
|
||||
exclude = [
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"venv",
|
||||
]
|
||||
|
||||
line-length = 120
|
||||
indent-width = 4
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
fixable = ["ALL"]
|
||||
unfixable = ["B"]
|
||||
|
||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
extend-select = [
|
||||
"I", # isort, added per instructions in the VS code extension (https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff)
|
||||
]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
def test_example():
|
||||
"""Simple example test to verify pytest is working."""
|
||||
assert True
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for chromium.local_state module."""
|
||||
|
||||
from common.helpers import get_drive_from_path
|
||||
|
||||
|
||||
class TestGetDriveFromPath:
|
||||
"""Test suite for get_drive_from_path function."""
|
||||
|
||||
def test_valid_path_with_colon(self):
|
||||
"""Test valid path with colon in drive letter."""
|
||||
assert get_drive_from_path("/C:/Users/test") == "/C:"
|
||||
assert get_drive_from_path("/D:/Program Files") == "/D:"
|
||||
assert get_drive_from_path("/E:/temp") == "/E:"
|
||||
|
||||
def test_invalid_path_without_colon(self):
|
||||
"""Test invalid path without colon in drive letter - should fail."""
|
||||
assert get_drive_from_path("/C/Users/test") is None
|
||||
assert get_drive_from_path("/D/Program Files") is None
|
||||
assert get_drive_from_path("/E/temp") is None
|
||||
|
||||
def test_lowercase_drive_letters(self):
|
||||
"""Test lowercase drive letters are preserved."""
|
||||
assert get_drive_from_path("/c:/Users/test") == "/c:"
|
||||
assert get_drive_from_path("/d:/temp") == "/d:"
|
||||
|
||||
def test_mixed_case_drive_letters(self):
|
||||
"""Test mixed case scenarios."""
|
||||
assert get_drive_from_path("/C:/Users/test") == "/C:"
|
||||
assert get_drive_from_path("/c:/Users/test") == "/c:"
|
||||
|
||||
def test_all_valid_drive_letters(self):
|
||||
"""Test all valid drive letters A-Z with colon."""
|
||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
|
||||
assert get_drive_from_path(f"/{letter}:/Users") == f"/{letter}:"
|
||||
# Without colon should fail
|
||||
assert get_drive_from_path(f"/{letter}/Users") is None
|
||||
|
||||
def test_invalid_numeric_drive(self):
|
||||
"""Test invalid numeric drive letters."""
|
||||
assert get_drive_from_path("/1:/Users/test") is None
|
||||
assert get_drive_from_path("/123/Users/test") is None
|
||||
assert get_drive_from_path("/0:/temp") is None
|
||||
|
||||
def test_invalid_special_characters(self):
|
||||
"""Test invalid special characters as drive."""
|
||||
assert get_drive_from_path("/$:/Users/test") is None
|
||||
assert get_drive_from_path("/@:/Users/test") is None
|
||||
assert get_drive_from_path("/#/Users/test") is None
|
||||
assert get_drive_from_path("/*/Users/test") is None
|
||||
|
||||
def test_invalid_multiple_letters(self):
|
||||
"""Test invalid multiple letters without colon."""
|
||||
assert get_drive_from_path("/AB/Users/test") is None
|
||||
assert get_drive_from_path("/CD:/Users/test") is None
|
||||
assert get_drive_from_path("/ABC/Users/test") is None
|
||||
|
||||
def test_invalid_colon_usage(self):
|
||||
"""Test invalid colon usage."""
|
||||
assert get_drive_from_path("/C;/Users/test") is None # semicolon instead
|
||||
assert get_drive_from_path("/C::/Users/test") is None # double colon
|
||||
assert get_drive_from_path("/:C/Users/test") is None # colon before letter
|
||||
|
||||
def test_path_without_leading_slash(self):
|
||||
"""Test paths without leading slash."""
|
||||
assert get_drive_from_path("C:/Users/test") == "C:"
|
||||
assert get_drive_from_path("C/Users/test") is None
|
||||
|
||||
def test_path_with_only_drive(self):
|
||||
"""Test paths with only drive letter."""
|
||||
assert get_drive_from_path("/C:") == "/C:"
|
||||
assert get_drive_from_path("/C") is None # Without colon should fail
|
||||
assert get_drive_from_path("/D:") == "/D:"
|
||||
|
||||
def test_path_with_trailing_elements(self):
|
||||
"""Test various path structures."""
|
||||
assert get_drive_from_path("/C:/Users/test/Documents/file.txt") == "/C:"
|
||||
assert get_drive_from_path("/C/Users/test/Documents/file.txt") is None # Without colon should fail
|
||||
assert get_drive_from_path("/D:/Program Files/App/config.ini") == "/D:"
|
||||
|
||||
def test_edge_case_single_slash(self):
|
||||
"""Test edge case of single slash."""
|
||||
assert get_drive_from_path("/") is None
|
||||
|
||||
def test_edge_case_double_slash(self):
|
||||
"""Test edge case of double slash."""
|
||||
assert get_drive_from_path("//") is None
|
||||
assert get_drive_from_path("//C/Users") is None
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""Test paths with spaces in directories."""
|
||||
assert get_drive_from_path("/C:/Program Files/Test") == "/C:"
|
||||
assert get_drive_from_path("/C/Program Files/Test") is None # Without colon should fail
|
||||
|
||||
def test_mixed_slashes_in_path(self):
|
||||
"""Test that function handles forward slashes correctly."""
|
||||
# The function expects POSIX-style paths with forward slashes
|
||||
assert get_drive_from_path("/C:/Users/test") == "/C:"
|
||||
assert get_drive_from_path("/C/Users/test") is None # Without colon should fail
|
||||
|
||||
def test_unicode_characters(self):
|
||||
"""Test paths with unicode characters in later parts."""
|
||||
assert get_drive_from_path("/C:/Users/тест") == "/C:"
|
||||
assert get_drive_from_path("/C/Users/测试") is None # Without colon should fail
|
||||
|
||||
def test_empty_drive_after_slash(self):
|
||||
"""Test path with empty drive section."""
|
||||
assert get_drive_from_path("//Users/test") is None
|
||||
|
||||
def test_real_world_chrome_paths(self):
|
||||
"""Test with real-world Chrome Local State paths."""
|
||||
assert get_drive_from_path("/C:/Users/itadmin/AppData/Local/Google/Chrome/User Data/Local State") == "/C:"
|
||||
assert get_drive_from_path("/C/Users/itadmin/AppData/Local/Google/Chrome/User Data/Local State") is None # Without colon should fail
|
||||
assert get_drive_from_path("/C/DPAPIUser/AppData/Local/Google/Chrome/User Data/Local State") is None # Without colon should fail
|
||||
|
||||
def test_alternate_browser_paths(self):
|
||||
"""Test with other Chromium-based browser paths."""
|
||||
assert get_drive_from_path("/D:/Users/test/AppData/Local/Microsoft/Edge/User Data/Local State") == "/D:"
|
||||
assert get_drive_from_path("/E/Users/test/AppData/Local/BraveSoftware/Brave-Browser/User Data/Local State") is None # Without colon should fail
|
||||
@@ -6,15 +6,13 @@
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.fixAll.ruff": "explicit",
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"autoDocstring.docstringFormat": "google",
|
||||
"editor.formatOnSave": true,
|
||||
"files.exclude": {
|
||||
"**/.DS_Store": true,
|
||||
"**/.git": true,
|
||||
@@ -46,16 +44,6 @@
|
||||
"reportMissingModuleSource": "none",
|
||||
},
|
||||
"python.analysis.useLibraryCodeForTypes": true, // Pyright
|
||||
// "python.formatting.provider": "none",
|
||||
"python.languageServer": "Pylance",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.flake8Args": [
|
||||
"--max-line-length=240",
|
||||
"--ignore=E203,E722,W503",
|
||||
],
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.linting.lintOnSave": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
|
||||
@@ -5,16 +5,15 @@ import re
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from common.helpers import is_plaintext
|
||||
from common.logger import get_logger
|
||||
from common.models import EnrichmentResult, File, Transform
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
from dapr.clients import DaprClient
|
||||
|
||||
from file_enrichment_modules.module_loader import EnrichmentModule
|
||||
|
||||
logger = structlog.get_logger(module=__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Base64DecoderAnalyzer(EnrichmentModule):
|
||||
@@ -35,8 +34,13 @@ class Base64DecoderAnalyzer(EnrichmentModule):
|
||||
# Allow whitespace/newlines within long sequences
|
||||
self.long_base64_pattern = re.compile(r"([A-Za-z0-9+/\s]{200,}={0,2})")
|
||||
|
||||
def should_process(self, object_id: str) -> bool:
|
||||
"""Determine if this module should run on plaintext files."""
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Determine if this module should run on plaintext files.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
|
||||
# there are some performance issues, so we're disabling this one for now
|
||||
return False
|
||||
@@ -56,7 +60,15 @@ class Base64DecoderAnalyzer(EnrichmentModule):
|
||||
|
||||
try:
|
||||
num_bytes = file_enriched.size if file_enriched.size < self.size_limit else self.size_limit
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id, length=num_bytes)
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read(num_bytes)
|
||||
else:
|
||||
# Fallback to original method
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id, length=num_bytes)
|
||||
|
||||
file_content = file_bytes.decode("utf-8", errors="ignore")
|
||||
|
||||
# Quick check using efficient patterns
|
||||
@@ -214,15 +226,28 @@ class Base64DecoderAnalyzer(EnrichmentModule):
|
||||
)
|
||||
return candidates
|
||||
|
||||
def process(self, object_id: str) -> EnrichmentResult | None:
|
||||
"""Process file to find and decode base64 content."""
|
||||
def process(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Process file to find and decode base64 content.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
try:
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
enrichment_result = EnrichmentResult(module_name=self.name)
|
||||
|
||||
# Download and read the file content (respect size limit)
|
||||
num_bytes = file_enriched.size if file_enriched.size < self.size_limit else self.size_limit
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id, length=num_bytes)
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read(num_bytes)
|
||||
else:
|
||||
# Fallback to original method
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id, length=num_bytes)
|
||||
|
||||
file_content = file_bytes.decode("utf-8", errors="ignore")
|
||||
|
||||
# Extract potential base64 candidates with efficient filtering
|
||||
@@ -296,6 +321,7 @@ class Base64DecoderAnalyzer(EnrichmentModule):
|
||||
file_message = File(
|
||||
object_id=decoded_object_id,
|
||||
agent_id=file_enriched.agent_id,
|
||||
source=file_enriched.source,
|
||||
project=file_enriched.project,
|
||||
timestamp=file_enriched.timestamp,
|
||||
expiration=file_enriched.expiration,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# enrichment_modules/chromium_cookies/analyzer.py
|
||||
import csv
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yara_x
|
||||
from chromium import convert_chromium_timestamp, process_chromium_cookies
|
||||
from common.logger import get_logger
|
||||
from common.models import EnrichmentResult, Transform
|
||||
from common.state_helpers import get_file_enriched, get_file_enriched_async
|
||||
from common.storage import StorageMinio
|
||||
from file_enrichment_modules.module_loader import EnrichmentModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
|
||||
from nemesis_dpapi import DpapiManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChromeCookiesParser(EnrichmentModule):
|
||||
def __init__(self):
|
||||
super().__init__("chrome_cookies_parser")
|
||||
self.storage = StorageMinio()
|
||||
|
||||
# the workflows this module should automatically run in
|
||||
self.workflows = ["default"]
|
||||
|
||||
self.dpapi_manager: DpapiManager = None # type: ignore
|
||||
self.loop: asyncio.AbstractEventLoop = None # type: ignore
|
||||
|
||||
# Yara rule to check for Chrome Cookies tables
|
||||
self.yara_rule = yara_x.compile("""
|
||||
rule Chrome_Cookies_Tables
|
||||
{
|
||||
meta:
|
||||
description = "Detects Chrome/Chromium cookies database tables"
|
||||
|
||||
strings:
|
||||
$cookies_table = "CREATE TABLE cookies"
|
||||
$cookies_index = "CREATE UNIQUE INDEX cookies_unique_index"
|
||||
|
||||
condition:
|
||||
all of them
|
||||
}
|
||||
""")
|
||||
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Determine if this module should run.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
if "sqlite 3.x database" not in file_enriched.magic_type.lower():
|
||||
return False
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id)
|
||||
|
||||
if file_enriched.is_plaintext:
|
||||
return False
|
||||
|
||||
# Verify Chrome cookies tables using Yara
|
||||
should_run = len(self.yara_rule.scan(file_bytes).matching_rules) > 0
|
||||
|
||||
return should_run
|
||||
|
||||
async def process(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Do the file enrichment.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
|
||||
Returns:
|
||||
EnrichmentResult or None if processing fails
|
||||
"""
|
||||
return await self._process_async(object_id, file_path)
|
||||
|
||||
async def _process_async(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Process Chrome Cookies database.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
try:
|
||||
file_enriched = await get_file_enriched_async(object_id)
|
||||
enrichment_result = EnrichmentResult(module_name=self.name, dependencies=self.dependencies)
|
||||
transforms = []
|
||||
|
||||
# Use the chromium library to process and insert into database
|
||||
process_chromium_cookies(object_id, file_path)
|
||||
|
||||
# Configure SQLite to handle non-UTF8 data for report generation
|
||||
def adapt_bytes(b):
|
||||
return b.hex() if b is not None else None
|
||||
|
||||
def convert_bytes(hex_str):
|
||||
return bytes.fromhex(hex_str) if hex_str is not None else None
|
||||
|
||||
sqlite3.register_adapter(bytes, adapt_bytes)
|
||||
sqlite3.register_converter("BLOB", convert_bytes)
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
conn = sqlite3.connect(file_path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
with self.storage.download(file_enriched.object_id) as temp_file:
|
||||
conn = sqlite3.connect(temp_file.name, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
|
||||
# Set text factory to handle non-UTF8 strings
|
||||
conn.text_factory = lambda x: x.decode("utf-8", errors="replace")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Generate summary report
|
||||
report_lines = []
|
||||
|
||||
# Cookies summary
|
||||
cursor.execute("SELECT COUNT(*) FROM cookies")
|
||||
cookie_count = cursor.fetchone()[0]
|
||||
report_lines.append("# Chrome Cookies Summary")
|
||||
report_lines.append(f"\nTotal cookies: {cookie_count}")
|
||||
|
||||
# Count of non-expired cookies at time of processing
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM cookies
|
||||
WHERE expires_utc IS NULL OR expires_utc > ?
|
||||
""",
|
||||
(int((datetime.now(UTC).timestamp() - 11644473600) * 1000000),),
|
||||
)
|
||||
non_expired_count = cursor.fetchone()[0]
|
||||
report_lines.append(f"Non-expired cookies (at time of processing): {non_expired_count}")
|
||||
|
||||
# Most recently accessed cookies
|
||||
cursor.execute("""
|
||||
SELECT host_key, name, last_access_utc
|
||||
FROM cookies
|
||||
ORDER BY last_access_utc DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
report_lines.append("\n## Most Recently Accessed Cookies")
|
||||
report_lines.append("\n| Last Access Time | Host | Cookie Name |")
|
||||
report_lines.append("| ---------------- | ---- | ----------- |")
|
||||
for host_key, name, last_access_utc in cursor.fetchall():
|
||||
last_access_iso = convert_chromium_timestamp(last_access_utc, True)
|
||||
# Escape pipe characters to prevent table formatting issues
|
||||
safe_host = host_key.replace("|", "\\|") if host_key else ""
|
||||
safe_name = name.replace("|", "\\|") if name else ""
|
||||
report_lines.append(f"| {last_access_iso} | {safe_host} | {safe_name} |")
|
||||
|
||||
# Create summary report transform
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as tmp_report:
|
||||
tmp_report.write("\n".join(report_lines))
|
||||
tmp_report.flush()
|
||||
report_object_id = self.storage.upload_file(tmp_report.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="finding_summary",
|
||||
object_id=f"{report_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}.md",
|
||||
"display_type_in_dashboard": "markdown",
|
||||
"default_display": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Export cookies table
|
||||
cursor.execute("""
|
||||
SELECT creation_utc, host_key, source_port, path, name, expires_utc,
|
||||
last_access_utc, last_update_utc, is_secure, is_httponly,
|
||||
is_persistent, samesite
|
||||
FROM cookies
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="") as tmp_cookies:
|
||||
writer = csv.writer(tmp_cookies)
|
||||
writer.writerow(
|
||||
[
|
||||
"creation_utc",
|
||||
"host_key",
|
||||
"source_port",
|
||||
"path",
|
||||
"name",
|
||||
"expires_utc",
|
||||
"last_access_utc",
|
||||
"last_update_utc",
|
||||
"is_secure",
|
||||
"is_httponly",
|
||||
"is_persistent",
|
||||
"samesite",
|
||||
]
|
||||
)
|
||||
for row in cursor:
|
||||
# Convert Chromium timestamps to ISO format
|
||||
creation_utc = convert_chromium_timestamp(row[0], True) if row[0] else None
|
||||
expires_utc = convert_chromium_timestamp(row[5], True) if row[5] else None
|
||||
last_access_utc = convert_chromium_timestamp(row[6], True) if row[6] else None
|
||||
last_update_utc = convert_chromium_timestamp(row[7], True) if row[7] else None
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
creation_utc,
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
expires_utc,
|
||||
last_access_utc,
|
||||
last_update_utc,
|
||||
row[8],
|
||||
row[9],
|
||||
row[10],
|
||||
row[11],
|
||||
]
|
||||
)
|
||||
tmp_cookies.flush()
|
||||
cookies_object_id = self.storage.upload_file(tmp_cookies.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="chromium_cookies",
|
||||
object_id=f"{cookies_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}_chromium_cookies.csv",
|
||||
"offer_as_download": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
conn.close()
|
||||
enrichment_result.transforms = transforms
|
||||
return enrichment_result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
e, message="Error processing Chrome Cookies database", object_id=object_id, file_path=file_enriched.path
|
||||
)
|
||||
|
||||
|
||||
def create_enrichment_module() -> EnrichmentModule:
|
||||
return ChromeCookiesParser()
|
||||
@@ -2,17 +2,16 @@
|
||||
import csv
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
import yara_x
|
||||
from chromium import convert_chromium_timestamp, process_chromium_history
|
||||
from common.logger import get_logger
|
||||
from common.models import EnrichmentResult, Transform
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
|
||||
from file_enrichment_modules.module_loader import EnrichmentModule
|
||||
|
||||
logger = structlog.get_logger(module=__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChromeHistoryParser(EnrichmentModule):
|
||||
@@ -39,166 +38,186 @@ rule Chrome_Downloads_Tables
|
||||
}
|
||||
""")
|
||||
|
||||
def should_process(self, object_id: str) -> bool:
|
||||
"""Determine if this module should run."""
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Determine if this module should run.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
# Check if filename is exactly "History" and SQLite magic type
|
||||
if not (file_enriched.file_name == "History" and "sqlite 3.x database" in file_enriched.magic_type.lower()):
|
||||
return False
|
||||
|
||||
if file_enriched.is_plaintext:
|
||||
return False
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id)
|
||||
|
||||
# Verify Chrome history tables using Yara
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id)
|
||||
should_run = len(self.yara_rule.scan(file_bytes).matching_rules) > 0
|
||||
|
||||
logger.debug(f"ChromeHistoryParser should_run: {should_run}")
|
||||
return should_run
|
||||
|
||||
def _chrome_time_to_iso(self, chrome_time: int) -> str:
|
||||
"""Convert Chrome timestamp to ISO 8601."""
|
||||
if not chrome_time:
|
||||
return ""
|
||||
# Chrome stores timestamps as microseconds since 1601-01-01 UTC
|
||||
epoch = datetime(1601, 1, 1, tzinfo=UTC)
|
||||
dt = epoch + timedelta(microseconds=chrome_time)
|
||||
return dt.isoformat()
|
||||
def process(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Process Chrome History database.
|
||||
|
||||
def process(self, object_id: str) -> EnrichmentResult | None:
|
||||
"""Process Chrome History database."""
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
try:
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
enrichment_result = EnrichmentResult(module_name=self.name, dependencies=self.dependencies)
|
||||
transforms = []
|
||||
|
||||
with self.storage.download(file_enriched.object_id) as temp_file:
|
||||
# Configure SQLite to handle non-UTF8 data
|
||||
def adapt_bytes(b):
|
||||
return b.hex() if b is not None else None
|
||||
# Use the chromium library to process and insert into the database
|
||||
process_chromium_history(object_id, file_path)
|
||||
|
||||
def convert_bytes(hex_str):
|
||||
return bytes.fromhex(hex_str) if hex_str is not None else None
|
||||
# Configure SQLite to handle non-UTF8 data for report generation
|
||||
def adapt_bytes(b):
|
||||
return b.hex() if b is not None else None
|
||||
|
||||
sqlite3.register_adapter(bytes, adapt_bytes)
|
||||
sqlite3.register_converter("BLOB", convert_bytes)
|
||||
def convert_bytes(hex_str):
|
||||
return bytes.fromhex(hex_str) if hex_str is not None else None
|
||||
|
||||
conn = sqlite3.connect(temp_file.name, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
# Set text factory to handle non-UTF8 strings
|
||||
conn.text_factory = lambda x: x.decode("utf-8", errors="replace")
|
||||
cursor = conn.cursor()
|
||||
sqlite3.register_adapter(bytes, adapt_bytes)
|
||||
sqlite3.register_converter("BLOB", convert_bytes)
|
||||
|
||||
# Generate summary report
|
||||
report_lines = []
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
conn = sqlite3.connect(file_path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
with self.storage.download(file_enriched.object_id) as temp_file:
|
||||
conn = sqlite3.connect(temp_file.name, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
|
||||
# URLs summary
|
||||
cursor.execute("SELECT COUNT(*) FROM urls")
|
||||
url_count = cursor.fetchone()[0]
|
||||
report_lines.append("# Chrome History Summary")
|
||||
report_lines.append(f"\nTotal URLs visited: {url_count}")
|
||||
# Set text factory to handle non-UTF8 strings
|
||||
conn.text_factory = lambda x: x.decode("utf-8", errors="replace")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Top 10 visited URLs
|
||||
cursor.execute("""
|
||||
SELECT url, visit_count
|
||||
FROM urls
|
||||
ORDER BY visit_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
report_lines.append("\n## Top 10 Most Visited URLs")
|
||||
report_lines.append("\n| URL | Visit Count |")
|
||||
report_lines.append("| --- | ----------- |")
|
||||
for url, count in cursor.fetchall():
|
||||
report_lines.append(f"| {url} | {count} |")
|
||||
# Generate summary report
|
||||
report_lines = []
|
||||
|
||||
# Downloads summary
|
||||
cursor.execute("SELECT COUNT(*) FROM downloads")
|
||||
download_count = cursor.fetchone()[0]
|
||||
report_lines.append(f"\nTotal Downloads: {download_count}")
|
||||
# URLs summary
|
||||
cursor.execute("SELECT COUNT(*) FROM urls")
|
||||
url_count = cursor.fetchone()[0]
|
||||
report_lines.append("# Chrome History Summary")
|
||||
report_lines.append(f"\nTotal URLs visited: {url_count}")
|
||||
|
||||
# Recent downloads
|
||||
cursor.execute("""
|
||||
SELECT target_path, tab_url, end_time
|
||||
FROM downloads
|
||||
ORDER BY end_time DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
report_lines.append("\n## Most Recent Downloads")
|
||||
report_lines.append("\n| Time | Path | Source URL |")
|
||||
report_lines.append("| ---- | ---- | ---------- |")
|
||||
for path, url, end_time in cursor.fetchall():
|
||||
end_time_iso = self._chrome_time_to_iso(end_time)
|
||||
# Escape pipe characters in paths and URLs to prevent table formatting issues
|
||||
safe_path = path.replace("|", "\\|") if path else ""
|
||||
safe_url = url.replace("|", "\\|") if url else ""
|
||||
report_lines.append(f"| {end_time_iso} | {safe_path} | {safe_url} |")
|
||||
# Top 10 visited URLs
|
||||
cursor.execute("""
|
||||
SELECT url, visit_count
|
||||
FROM urls
|
||||
ORDER BY visit_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
report_lines.append("\n## Top 10 Most Visited URLs")
|
||||
report_lines.append("\n| URL | Visit Count |")
|
||||
report_lines.append("| --- | ----------- |")
|
||||
for url, count in cursor.fetchall():
|
||||
report_lines.append(f"| {url} | {count} |")
|
||||
|
||||
# Create summary report transform
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as tmp_report:
|
||||
tmp_report.write("\n".join(report_lines))
|
||||
tmp_report.flush()
|
||||
object_id = self.storage.upload_file(tmp_report.name)
|
||||
# Downloads summary
|
||||
cursor.execute("SELECT COUNT(*) FROM downloads")
|
||||
download_count = cursor.fetchone()[0]
|
||||
report_lines.append(f"\nTotal Downloads: {download_count}")
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="finding_summary",
|
||||
object_id=f"{object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}.md",
|
||||
"display_type_in_dashboard": "markdown",
|
||||
"default_display": True,
|
||||
},
|
||||
)
|
||||
# Recent downloads
|
||||
cursor.execute("""
|
||||
SELECT target_path, tab_url, end_time
|
||||
FROM downloads
|
||||
ORDER BY end_time DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
report_lines.append("\n## Most Recent Downloads")
|
||||
report_lines.append("\n| Time | Path | Source URL |")
|
||||
report_lines.append("| ---- | ---- | ---------- |")
|
||||
for path, url, end_time in cursor.fetchall():
|
||||
end_time_iso = convert_chromium_timestamp(end_time, True)
|
||||
# Escape pipe characters in paths and URLs to prevent table formatting issues
|
||||
safe_path = path.replace("|", "\\|") if path else ""
|
||||
safe_url = url.replace("|", "\\|") if url else ""
|
||||
report_lines.append(f"| {end_time_iso} | {safe_path} | {safe_url} |")
|
||||
|
||||
# Create summary report transform
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as tmp_report:
|
||||
tmp_report.write("\n".join(report_lines))
|
||||
tmp_report.flush()
|
||||
report_object_id = self.storage.upload_file(tmp_report.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="finding_summary",
|
||||
object_id=f"{report_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}.md",
|
||||
"display_type_in_dashboard": "markdown",
|
||||
"default_display": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Export URLs table
|
||||
cursor.execute("""
|
||||
SELECT url, title, visit_count, last_visit_time
|
||||
FROM urls
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="") as tmp_urls:
|
||||
writer = csv.writer(tmp_urls)
|
||||
writer.writerow(["url", "title", "visit_count", "last_visit_time"])
|
||||
for row in cursor:
|
||||
writer.writerow([row[0], row[1], row[2], self._chrome_time_to_iso(row[3])])
|
||||
tmp_urls.flush()
|
||||
object_id = self.storage.upload_file(tmp_urls.name)
|
||||
# Export URLs table
|
||||
cursor.execute("""
|
||||
SELECT url, title, visit_count, last_visit_time
|
||||
FROM urls
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="") as tmp_urls:
|
||||
writer = csv.writer(tmp_urls)
|
||||
writer.writerow(["url", "title", "visit_count", "last_visit_time"])
|
||||
for row in cursor:
|
||||
writer.writerow([row[0], row[1], row[2], convert_chromium_timestamp(row[3], True)])
|
||||
tmp_urls.flush()
|
||||
urls_object_id = self.storage.upload_file(tmp_urls.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="chromium_urls",
|
||||
object_id=f"{object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}_chromium_urls.csv",
|
||||
"offer_as_download": True,
|
||||
},
|
||||
)
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="chromium_urls",
|
||||
object_id=f"{urls_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}_chromium_urls.csv",
|
||||
"offer_as_download": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Export downloads table
|
||||
cursor.execute("""
|
||||
SELECT target_path, total_bytes, end_time, tab_url, mime_type
|
||||
FROM downloads
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="") as tmp_downloads:
|
||||
writer = csv.writer(tmp_downloads)
|
||||
writer.writerow(["target_path", "total_bytes", "end_time", "tab_url", "mime_type"])
|
||||
for row in cursor:
|
||||
writer.writerow([row[0], row[1], self._chrome_time_to_iso(row[2]), row[3], row[4]])
|
||||
tmp_downloads.flush()
|
||||
object_id = self.storage.upload_file(tmp_downloads.name)
|
||||
# Export downloads table
|
||||
cursor.execute("""
|
||||
SELECT target_path, total_bytes, end_time, tab_url, mime_type
|
||||
FROM downloads
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="") as tmp_downloads:
|
||||
writer = csv.writer(tmp_downloads)
|
||||
writer.writerow(["target_path", "total_bytes", "end_time", "tab_url", "mime_type"])
|
||||
for row in cursor:
|
||||
writer.writerow([row[0], row[1], convert_chromium_timestamp(row[2], True), row[3], row[4]])
|
||||
tmp_downloads.flush()
|
||||
downloads_object_id = self.storage.upload_file(tmp_downloads.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="chromium_downloads",
|
||||
object_id=f"{object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}_chromium_downloads.csv",
|
||||
"offer_as_download": True,
|
||||
},
|
||||
)
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="chromium_downloads",
|
||||
object_id=f"{downloads_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}_chromium_downloads.csv",
|
||||
"offer_as_download": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
conn.close()
|
||||
enrichment_result.transforms = transforms
|
||||
return enrichment_result
|
||||
conn.close()
|
||||
enrichment_result.transforms = transforms
|
||||
return enrichment_result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e, message="Error processing Chrome History database")
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# enrichment_modules/chromium_logins/analyzer.py
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yara_x
|
||||
from chromium import process_chromium_local_state
|
||||
from common.logger import get_logger
|
||||
from common.models import EnrichmentResult
|
||||
from common.state_helpers import get_file_enriched
|
||||
from common.storage import StorageMinio
|
||||
from file_enrichment_modules.module_loader import EnrichmentModule
|
||||
from nemesis_dpapi import DpapiManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemesis_dpapi import DpapiManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChromeLocalStateParser(EnrichmentModule):
|
||||
def __init__(self):
|
||||
super().__init__("chrome_local_state_parser")
|
||||
self.storage = StorageMinio()
|
||||
|
||||
# the workflows this module should automatically run in
|
||||
self.workflows = ["default"]
|
||||
|
||||
self.dpapi_manager: DpapiManager = None # type: ignore
|
||||
self.loop: asyncio.AbstractEventLoop = None # type: ignore
|
||||
|
||||
# Yara rule to check for Chrome Login Data tables
|
||||
self.yara_rule = yara_x.compile("""
|
||||
rule Chrome_Local_State
|
||||
{
|
||||
meta:
|
||||
description = "Detects Chrome/Chromium Local State json"
|
||||
|
||||
strings:
|
||||
$local_state_1 = "\\"os_crypt\\""
|
||||
$local_state_2 = "\\"encrypted_key\\""
|
||||
$local_state_3 = "\\"user_experience_metrics\\""
|
||||
|
||||
condition:
|
||||
all of them
|
||||
}
|
||||
""")
|
||||
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Determine if this module should run.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
# Check if file is < 5 megs and JSON magic type
|
||||
if not ((file_enriched.size < 5000000) and ("json" in file_enriched.magic_type.lower())):
|
||||
return False
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id)
|
||||
|
||||
# Verify Chrome Local State using Yara
|
||||
should_run = len(self.yara_rule.scan(file_bytes).matching_rules) > 0
|
||||
|
||||
return should_run
|
||||
|
||||
async def process(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Process Chrome Local State files.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
return await self._process_async(object_id, file_path)
|
||||
|
||||
async def _process_async(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Async helper for process method."""
|
||||
|
||||
try:
|
||||
# Use the chromium library to process and insert into database
|
||||
state_key_data = await process_chromium_local_state(self.dpapi_manager, object_id, file_path)
|
||||
|
||||
if state_key_data:
|
||||
# Debug: Check for coroutines in state_key_data
|
||||
import inspect
|
||||
|
||||
for key, value in state_key_data.items():
|
||||
if inspect.iscoroutine(value):
|
||||
logger.error(f"FOUND COROUTINE IN state_key_data['{key}']!")
|
||||
|
||||
enrichment = EnrichmentResult(module_name=self.name)
|
||||
enrichment.results = {"parsed": state_key_data}
|
||||
|
||||
return enrichment
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e, message="Error processing Chrome Local State file")
|
||||
|
||||
|
||||
def create_enrichment_module() -> EnrichmentModule:
|
||||
return ChromeLocalStateParser()
|
||||
@@ -0,0 +1,232 @@
|
||||
# enrichment_modules/chromium_logins/analyzer.py
|
||||
import asyncio
|
||||
import csv
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yara_x
|
||||
from chromium import convert_chromium_timestamp, process_chromium_logins
|
||||
from common.logger import get_logger
|
||||
from common.models import EnrichmentResult, Transform
|
||||
from common.state_helpers import get_file_enriched, get_file_enriched_async
|
||||
from common.storage import StorageMinio
|
||||
from file_enrichment_modules.module_loader import EnrichmentModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemesis_dpapi import DpapiManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChromeLoginsParser(EnrichmentModule):
|
||||
def __init__(self):
|
||||
super().__init__("chrome_logins_parser")
|
||||
self.storage = StorageMinio()
|
||||
|
||||
# the workflows this module should automatically run in
|
||||
self.workflows = ["default"]
|
||||
|
||||
self.dpapi_manager: DpapiManager = None # type: ignore
|
||||
self.loop: asyncio.AbstractEventLoop = None # type: ignore
|
||||
|
||||
# Yara rule to check for Chrome Login Data tables
|
||||
self.yara_rule = yara_x.compile("""
|
||||
rule Chrome_Logins_Tables
|
||||
{
|
||||
meta:
|
||||
description = "Detects Chrome/Chromium logins database tables"
|
||||
|
||||
strings:
|
||||
$logins_table = "CREATE TABLE logins "
|
||||
$logins_table2 = "CREATE TABLE insecure_credentials"
|
||||
|
||||
condition:
|
||||
all of them
|
||||
}
|
||||
""")
|
||||
|
||||
def should_process(self, object_id: str, file_path: str | None = None) -> bool:
|
||||
"""Determine if this module should run.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
|
||||
file_enriched = get_file_enriched(object_id)
|
||||
|
||||
if "sqlite 3.x database" not in file_enriched.magic_type.lower():
|
||||
return False
|
||||
|
||||
if file_enriched.is_plaintext:
|
||||
return False
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
file_bytes = self.storage.download_bytes(file_enriched.object_id)
|
||||
|
||||
# Verify Chrome Login Data tables using Yara
|
||||
should_run = len(self.yara_rule.scan(file_bytes).matching_rules) > 0
|
||||
|
||||
return should_run
|
||||
|
||||
async def process(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Do the file enrichment.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
|
||||
Returns:
|
||||
EnrichmentResult or None if processing fails
|
||||
"""
|
||||
return await self._process_async(object_id, file_path)
|
||||
|
||||
async def _process_async(self, object_id: str, file_path: str | None = None) -> EnrichmentResult | None:
|
||||
"""Process Chrome Login Data database.
|
||||
|
||||
Args:
|
||||
object_id: The object ID of the file
|
||||
file_path: Optional path to already downloaded file
|
||||
"""
|
||||
try:
|
||||
file_enriched = await get_file_enriched_async(object_id)
|
||||
enrichment_result = EnrichmentResult(module_name=self.name, dependencies=self.dependencies)
|
||||
transforms = []
|
||||
|
||||
# Use the chromium library to process and insert into database
|
||||
process_chromium_logins(object_id, file_path)
|
||||
|
||||
# Configure SQLite to handle non-UTF8 data for report generation
|
||||
def adapt_bytes(b):
|
||||
return b.hex() if b is not None else None
|
||||
|
||||
def convert_bytes(hex_str):
|
||||
return bytes.fromhex(hex_str) if hex_str is not None else None
|
||||
|
||||
sqlite3.register_adapter(bytes, adapt_bytes)
|
||||
sqlite3.register_converter("BLOB", convert_bytes)
|
||||
|
||||
if file_path:
|
||||
# Use provided file path
|
||||
conn = sqlite3.connect(file_path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
else:
|
||||
# Fallback to downloading the file itself
|
||||
with self.storage.download(file_enriched.object_id) as temp_file:
|
||||
conn = sqlite3.connect(temp_file.name, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
|
||||
# Set text factory to handle non-UTF8 strings
|
||||
conn.text_factory = lambda x: x.decode("utf-8", errors="replace")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Generate summary report
|
||||
report_lines = []
|
||||
|
||||
# Logins summary
|
||||
cursor.execute("SELECT COUNT(*) FROM logins")
|
||||
login_count = cursor.fetchone()[0]
|
||||
report_lines.append("# Chrome Logins Summary")
|
||||
report_lines.append(f"\nTotal logins: {login_count}")
|
||||
|
||||
# Count of logins with non-empty password_value
|
||||
cursor.execute("SELECT COUNT(*) FROM logins WHERE password_value IS NOT NULL")
|
||||
password_count = cursor.fetchone()[0]
|
||||
report_lines.append(f"\nLogins with saved passwords: {password_count}")
|
||||
|
||||
# Most recently used logins
|
||||
cursor.execute("""
|
||||
SELECT origin_url, username_value, date_last_used
|
||||
FROM logins
|
||||
ORDER BY date_last_used DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
report_lines.append("\n## Most Recently Used Logins")
|
||||
report_lines.append("\n| Last Used Time | Origin URL | Username |")
|
||||
report_lines.append("| -------------- | ---------- | -------- |")
|
||||
for origin_url, username_value, date_last_used in cursor.fetchall():
|
||||
last_used_iso = convert_chromium_timestamp(date_last_used, True)
|
||||
# Escape pipe characters to prevent table formatting issues
|
||||
safe_origin = origin_url.replace("|", "\\|") if origin_url else ""
|
||||
safe_username = username_value.replace("|", "\\|") if username_value else ""
|
||||
report_lines.append(f"| {last_used_iso} | {safe_origin} | {safe_username} |")
|
||||
|
||||
# Create summary report transform
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as tmp_report:
|
||||
tmp_report.write("\n".join(report_lines))
|
||||
tmp_report.flush()
|
||||
report_object_id = self.storage.upload_file(tmp_report.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="finding_summary",
|
||||
object_id=f"{report_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}.md",
|
||||
"display_type_in_dashboard": "markdown",
|
||||
"default_display": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Export logins table
|
||||
cursor.execute("""
|
||||
SELECT origin_url, username_value, signon_realm, date_created,
|
||||
date_last_used, date_password_modified, times_used
|
||||
FROM logins
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="") as tmp_logins:
|
||||
writer = csv.writer(tmp_logins)
|
||||
writer.writerow(
|
||||
[
|
||||
"origin_url",
|
||||
"username_value",
|
||||
"signon_realm",
|
||||
"date_created",
|
||||
"date_last_used",
|
||||
"date_password_modified",
|
||||
"times_used",
|
||||
]
|
||||
)
|
||||
for row in cursor:
|
||||
# Convert Chromium timestamps to ISO format
|
||||
date_created = convert_chromium_timestamp(row[3], True) if row[3] else None
|
||||
date_last_used = convert_chromium_timestamp(row[4], True) if row[4] else None
|
||||
date_password_modified = convert_chromium_timestamp(row[5], True) if row[5] else None
|
||||
|
||||
writer.writerow(
|
||||
[row[0], row[1], row[2], date_created, date_last_used, date_password_modified, row[6]]
|
||||
)
|
||||
tmp_logins.flush()
|
||||
logins_object_id = self.storage.upload_file(tmp_logins.name)
|
||||
|
||||
transforms.append(
|
||||
Transform(
|
||||
type="chromium_logins",
|
||||
object_id=f"{logins_object_id}",
|
||||
metadata={
|
||||
"file_name": f"{file_enriched.file_name}_chromium_logins.csv",
|
||||
"offer_as_download": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
conn.close()
|
||||
enrichment_result.transforms = transforms
|
||||
return enrichment_result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
e,
|
||||
message="Error processing Chrome Login Data database",
|
||||
object_id=object_id,
|
||||
file_path=file_enriched.path,
|
||||
)
|
||||
|
||||
|
||||
def create_enrichment_module() -> EnrichmentModule:
|
||||
return ChromeLoginsParser()
|
||||