diff --git a/.bandit b/.bandit
index 0b01d12b..35460139 100644
--- a/.bandit
+++ b/.bandit
@@ -1,3 +1,3 @@
# FILE: .bandit
[bandit]
-skips: ['B607', 'B605']
\ No newline at end of file
+skips: ['B607', 'B605']
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 05540e45..54ebb4e6 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,5 +1 @@
* @DefinetlyNotAI
-wifi_stealer.py @ski-sketch
-packet_sniffer.py @ski-sketch
-bluetooth_details.py @ski-sketch
-bluetooth_logger.py @ski-sketch
\ No newline at end of file
diff --git a/CREDITS.md b/.github/CREDITS.md
similarity index 97%
rename from CREDITS.md
rename to .github/CREDITS.md
index c66fcfec..3b25275d 100644
--- a/CREDITS.md
+++ b/.github/CREDITS.md
@@ -1,47 +1,47 @@
-# CREDITS
-
-This project is built on the shoulders of giants and inspired by the work of many talented individuals and
-organizations. We acknowledge their contributions and are grateful for the knowledge and tools they have shared.
-
-
-
-
-
-
-
-## 👨💻 Coders Credits 👨💻
-
-### Wifi-Stealer.py, bluetooth_details.py and bluetooth_logger.py by ski-sketch
-
-Created Wi-Fi Password Stealer using python
-The sole creator of the code of wifi-stealer
-Also created bluetooth_details.py and bluetooth_logger.py
-which are used to get the details of the bluetooth devices
-and log the details of the bluetooth devices respectively
-
-- [ski-sketch](https://github.com/ski-sketch)
-
-## 🛠️ Refactorers Credits 🛠️
-
-Until Now, no one. Become a contributor and help us spread the word.
-
-## 🔨 Enhancers Credits 🔨
-
-Until Now, no one. Become a contributor and help us spread the word.
-
-## 🐛 Bug bounty credits 🐛
-
-### Found development bug
-
-Found and attempted fix of 2 bugs: Zipping name error - `--dev` flag loop
-
-- [ski-sketch](https://github.com/ski-sketch)
-
-# Acknowledgments
-
-This project would not be possible without the contributions and inspirations from the above-mentioned individuals and
-organizations. We are deeply grateful for their work and the community that supports it.
+# CREDITS
+
+This project is built on the shoulders of giants and inspired by the work of many talented individuals and
+organizations. We acknowledge their contributions and are grateful for the knowledge and tools they have shared.
+
+
+
+
+
+
+
+## 👨💻 Coders Credits 👨💻
+
+### Wifi-Stealer.py, bluetooth_details.py and bluetooth_logger.py by ski-sketch
+
+Created Wi-Fi Password Stealer using python
+The sole creator of the code of wifi-stealer
+Also created bluetooth_details.py and bluetooth_logger.py
+which are used to get the details of the bluetooth devices
+and log the details of the bluetooth devices respectively
+
+- [ski-sketch](https://github.com/ski-sketch)
+
+## 🛠️ Refactorers Credits 🛠️
+
+Until Now, no one. Become a contributor and help us spread the word.
+
+## 🔨 Enhancers Credits 🔨
+
+Until Now, no one. Become a contributor and help us spread the word.
+
+## 🐛 Bug bounty credits 🐛
+
+### Found development bug
+
+Found and attempted fix of 2 bugs: Zipping name error - `--dev` flag loop
+
+- [ski-sketch](https://github.com/ski-sketch)
+
+# Acknowledgments
+
+This project would not be possible without the contributions and inspirations from the above-mentioned individuals and
+organizations. We are deeply grateful for their work and the community that supports it.
diff --git a/DCO.md b/.github/DCO.md
similarity index 95%
rename from DCO.md
rename to .github/DCO.md
index c3a29727..6a2e3c60 100644
--- a/DCO.md
+++ b/.github/DCO.md
@@ -32,5 +32,5 @@ By making a contribution to this project, I certify that:
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
- this project or the open source license(s) involved.
+ this project or the open source license(s) involved.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index f813bca4..530686d9 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,64 +1,78 @@
name: Report a bug
-description: Tell us about a bug or issue you may have identified in Logicytics.
-title: "Provide a general summary of the issue"
+description: Report reproducible Logicytics v4 behavior without sharing private evidence.
+title: "bug: "
labels: [ "progress/Unreviewed" ]
-assignees: "DefinetlyNotAI"
+assignees: [ "DefinetlyNotAI" ]
body:
- type: checkboxes
attributes:
label: Prerequisites
- description: Take a couple minutes to help our maintainers work faster.
options:
- - label: I have [searched](https://github.com/DefinetlyNotAI/Logicytics/issues?utf8=%E2%9C%93&q=is%3Aissue) for duplicate or closed issues.
+ - label: I searched existing issues and reproduced this on the latest supported v4 release.
required: true
- - label: I have read the [contributing guidelines](https://github.com/DefinetlyNotAI/Logicytics/blob/main/CONTRIBUTING.md).
+ - label: I read the contribution and security guidance and removed credentials, private evidence, and host identifiers.
required: true
- - label: I have checked that I am on the latest release, and have run the `--debug` flag and have made sure no external modification are responsible for this bug.
+ - label: I ran `python -m logicytics preflight` and the relevant command again with a sanitized configuration.
required: true
- type: textarea
- id: what-happened
+ id: description
attributes:
- label: Describe the issue
- description: Provide a summary of the issue and what you expected to happen, including specific steps to reproduce.
+ label: What happened?
+ description: Describe the actual result, expected result, and exact reproduction steps.
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Logicytics version and commit
+ placeholder: "4.0.0, commit abc1234"
+ validations:
+ required: true
+ - type: input
+ id: environment
+ attributes:
+ label: Windows and Python versions
+ placeholder: "Windows 11 24H2; Python 3.11.9"
+ validations:
+ required: true
+ - type: dropdown
+ id: action
+ attributes:
+ label: Affected action
+ options:
+ - preflight or plan
+ - standard or balanced collection
+ - quick or thorough collection
+ - offline collection
+ - extensions or non-Python MODs
+ - performance collection
+ - direct collector execution
+ - debug, update, or developer action
+ - usage or semantic matching
+ - public Python API
+ - package, hash, or artifact reading
+ - other
validations:
required: true
- type: textarea
- id: d_log
+ id: command
attributes:
- label: Debugger Log
- description: Include the log File generated by running `.\Logicytics --debug`, upload the File contents and paste it in.
+ label: Sanitized command and configuration
+ description: Include the command, profile/mode, approved capabilities, elevation state, and relevant non-secret settings.
+ render: powershell
validations:
required: true
- type: textarea
- id: b_log
+ id: diagnostics
attributes:
- label: Basic Log
- description: If possible, delete the default log, and attempt to run `.\Logicytics` again with the steps you have highlighted, upload the new log here.
+ label: Redacted diagnostics
+ description: Paste only relevant redacted preflight, manifest status, collector failure, or debug details. Never attach collected evidence or secrets.
validations:
required: false
- type: textarea
id: extra
attributes:
- label: Anything else?
- description: Include anything you deem important.
+ label: Additional context
+ description: Note whether optional WMIC, BitLocker, Sysinternals, PowerShell, or elevation capabilities were available.
validations:
required: false
- - type: dropdown
- id: flags_list
- attributes:
- label: What flags_list were you using to run Logicytics?
- multiple: false
- options:
- - Threading
- - Dev
- - Default
- - Modded
- - Other
- - N/A
- - type: input
- id: version
- attributes:
- label: What version of Logicytics are you using?
- placeholder: "e.g., v2.1.0 or v1.4.0"
- validations:
- required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index e62326c4..5368c4be 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,49 +1,31 @@
-## Pull Request Template
+## Summary
-### Prerequisites
+
-
-
+## Contract impact
-- [ ] I have [searched](https://github.com/DefinetlyNotAI/Logicytics/pulls) for duplicate or closed issues.
-- [ ] I have read the [contributing guidelines](https://github.com/DefinetlyNotAI/Logicytics/blob/main/CONTRIBUTING.md).
-- [ ] I have followed the instructions in the [wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) about
- contributions.
-- [ ] I have updated the documentation accordingly, if required.
-- [ ] I have tested my code with the `--dev` flag, if required.
+
-### PR Type
+## Verification
-
-
+
-- [ ] Bug fix
-- [ ] Deprecation Change
-- [ ] New feature
-- [ ] Refactoring
-- [ ] Documentation
- update
-- [ ] ⚠️ Breaking change ⚠️
+- [ ] Relevant focused tests pass.
+- [ ] `python -m unittest discover -v` passes.
+- [ ] `python -m compileall -q logicytics core tests` passes.
+- [ ] `python -m logicytics preflight` reports no invalid core collector.
+- [ ] `git diff --check` passes.
+- [ ] Live Windows integration tests pass when the change touches host behavior.
-### Description
+## Review checklist
-
+- [ ] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and searched for duplicate work.
+- [ ] This pull request contains one coherent theme and conventional commits.
+- [ ] Collector metadata, cancellation, isolation, and artifact registration remain valid.
+- [ ] Configuration, output, migration, release, and feature-status docs are updated when affected.
+- [ ] No generated evidence, credentials, private data, caches, or unrelated changes are included.
+- [ ] I agree to the [Developer Certificate of Origin](DCO.md) and repository license.
-### Motivation and Context
+## Related issues
-
-
-### Credit
-
-
-
-
-
-### Issues Fixed
-
-
+
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 554c5ed8..dd0fa13a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -18,4 +18,4 @@ updates:
interval: daily
labels:
- "type/Dependencies"
- - "type/Github Actions"
\ No newline at end of file
+ - "type/Github Actions"
diff --git a/.github/script/validate-commit-msg.sh b/.github/script/validate-commit-msg.sh
new file mode 100644
index 00000000..73aeada4
--- /dev/null
+++ b/.github/script/validate-commit-msg.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env sh
+
+set -eu
+
+commit_msg_file="$1"
+first_line="$(head -n 1 "$commit_msg_file")"
+
+pattern='^((fixup|squash|amend|reword)! )?(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([A-Za-z0-9._/-]+\))?!?: .+$'
+
+if printf '%s\n' "$first_line" | grep -Eq "$pattern"; then
+ exit 0
+fi
+
+cat >&2 <<'EOF'
+
+Invalid commit message.
+
+Expected Conventional Commits format:
+
+ :
+ ():
+ !:
+ ()!:
+
+Allowed types:
+
+ build
+ chore
+ ci
+ docs
+ feat
+ fix
+ perf
+ refactor
+ revert
+ style
+ test
+
+Expected Conventional Commits format:
+
+ :
+ ():
+ !:
+ ()!:
+
+Autosquash commits are also accepted:
+
+ fixup!
+ squash!
+ amend!
+ reword!
+
+EOF
+
+exit 1
diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml
index 6363edf0..b6d651f9 100644
--- a/.github/workflows/greetings.yml
+++ b/.github/workflows/greetings.yml
@@ -1,6 +1,6 @@
name: Greetings
-on: [pull_request_target, issues]
+on: [ pull_request_target, issues ]
permissions:
contents: read
diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml
new file mode 100644
index 00000000..a2468377
--- /dev/null
+++ b/.github/workflows/publish-wiki.yml
@@ -0,0 +1,75 @@
+name: Publish documentation wiki
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - "docs/**"
+ - ".github/workflows/publish-wiki.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: documentation-wiki
+ cancel-in-progress: true
+
+jobs:
+ publish:
+ name: Publish docs to the repository wiki
+ runs-on: ubuntu-latest
+ steps:
+ - name: Harden the runner
+ uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
+ with:
+ egress-policy: audit
+
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Checkout wiki repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ continue-on-error: true
+ with:
+ repository: ${{ github.repository }}.wiki
+ path: wiki
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Prepare wiki checkout
+ shell: bash
+ env:
+ REPOSITORY: ${{ github.repository }}
+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ if [ ! -d wiki/.git ]; then
+ rm -rf wiki
+ mkdir wiki
+ git -C wiki init -b master
+ git -C wiki remote add origin "https://x-access-token:${TOKEN}@github.com/${REPOSITORY}.wiki.git"
+ fi
+
+ - name: Replace wiki pages with docs
+ shell: bash
+ run: |
+ set -euo pipefail
+ find wiki -mindepth 1 ! -path 'wiki/.git' ! -path 'wiki/.git/*' -exec rm -rf {} +
+ cp -R docs/. wiki/
+
+ - name: Commit and push wiki pages
+ shell: bash
+ run: |
+ set -euo pipefail
+ git -C wiki config user.name "github-actions[bot]"
+ git -C wiki config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git -C wiki add --all
+ if git -C wiki diff --cached --quiet; then
+ echo "Wiki is already current."
+ exit 0
+ fi
+ git -C wiki commit -m "docs: publish repository documentation"
+ git -C wiki push origin HEAD:master
diff --git a/.gitignore b/.gitignore
index a25f2a40..ec85fc0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -319,6 +319,8 @@ $RECYCLE.BIN/
*.pyc
/CODE/SysInternal_Suite/.sys.ignore
/ACCESS/
+/output/
+/tools/
/CODE/vulnscan/tools/NN features/
/CODE/logicytics/User_History.json.gz
/CODE/logicytics/User_History.json
@@ -328,4 +330,6 @@ $RECYCLE.BIN/
/CODE/SysInternal_Suite/pslist.exe
/CODE/SysInternal_Suite/PsLoggedon.exe
/CODE/SysInternal_Suite/psloglist.exe
-.idea/
\ No newline at end of file
+.idea/
+.cache/
+.temp/
diff --git a/.mailmap b/.mailmap
index f5b83505..3e9b544f 100644
--- a/.mailmap
+++ b/.mailmap
@@ -1 +1 @@
-Shahm Najeeb
\ No newline at end of file
+Shahm Najeeb
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5c46e656..836af996 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,12 +3,20 @@ repos:
rev: v8.16.3
hooks:
- id: gitleaks
+
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- - repo: https://github.com/pylint-dev/pylint
- rev: v2.17.2
+
+ - # noinspection YAMLSchemaValidation
+ repo: local
hooks:
- - id: pylint
+ - # noinspection YAMLSchemaValidation
+ id: conventional-commit-message
+ name: Validate conventional commit message
+ entry: .github/script/validate-commit-msg.sh
+ language: script
+ stages:
+ - commit-msg
diff --git a/CODE/Logicytics.py b/CODE/Logicytics.py
deleted file mode 100644
index 3149d645..00000000
--- a/CODE/Logicytics.py
+++ /dev/null
@@ -1,522 +0,0 @@
-from __future__ import annotations
-
-import gc
-import os
-import subprocess
-import sys
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from datetime import datetime
-
-from prettytable import PrettyTable
-
-from logicytics import (
- Log,
- execute,
- check,
- get,
- file_management,
- flag,
- DEBUG,
- DELETE_LOGS,
- config,
-)
-
-# Initialization
-log = Log({"log_level": DEBUG, "delete_log": DELETE_LOGS})
-ACTION, SUB_ACTION = None, None
-MAX_WORKERS = config.getint(
- "Settings", "max_workers", fallback=min(32, (os.cpu_count() or 1) + 4)
-)
-log.debug(f"MAX_WORKERS: {MAX_WORKERS}")
-
-
-class ExecuteScript:
- def __init__(self):
- self.execution_list = self.__generate_execution_list()
-
- @staticmethod
- def __safe_remove(file_name: str, file_list: list[str] | set[str]) -> list[str]:
- file_set = set(file_list)
- if file_name in file_set:
- file_set.remove(file_name)
- else:
- log.critical(
- f"The file {file_name} should exist in this directory - But was not found!"
- )
- return list(file_set)
-
- @staticmethod
- def __safe_append(file_name: str, file_list: list[str] | set[str]) -> list[str]:
- file_set = set(file_list)
- if os.path.exists(file_name):
- file_set.add(file_name)
- else:
- log.critical(f"Missing required file: {file_name}")
- return list(file_set)
-
- def __generate_execution_list(self) -> list[str]:
- """
- Generate an execution list of scripts based on the specified action.
-
- This function dynamically creates a list of scripts to be executed by filtering and selecting
- scripts based on the global ACTION variable. It supports different execution modes:
- - 'minimal': A predefined set of lightweight scripts
- - 'nopy': PowerShell and script-based scripts without Python
- - 'modded': Includes scripts from the MODS directory
- - 'depth': Comprehensive script execution with data mining and logging scripts
- - 'vulnscan_ai': Vulnerability scanning script only
-
- Returns:
- list[str]: A list of script file paths to be executed, filtered and modified based on the current action.
-
- Raises:
- ValueError: Implicitly if a script file cannot be removed from the initial list.
-
- Notes:
- - Removes sensitive or unnecessary scripts from the initial file list
- - Logs the final execution list for debugging purposes
- - Warns users about potential long execution times for certain actions
- """
- execution_list = get.list_of_files(
- ".",
- only_extensions=(".py", ".exe", ".ps1", ".bat"),
- exclude_files=["Logicytics.py"],
- exclude_dirs=["logicytics", "SysInternal_Suite"],
- )
- files_to_remove = {
- "sensitive_data_miner.py",
- "dir_list.py",
- "tree.ps1",
- "vulnscan.py",
- "event_log.py",
- }
- execution_list = [
- file for file in execution_list if file not in files_to_remove
- ]
-
- if ACTION == "minimal":
- execution_list = [
- "cmd_commands.py",
- "registry.py",
- "tasklist.py",
- "wmic.py",
- "netadapter.ps1",
- "property_scraper.ps1",
- "window_feature_miner.ps1",
- "event_log.py",
- ]
-
- elif ACTION == "nopy":
- execution_list = [
- "browser_miner.ps1",
- "netadapter.ps1",
- "property_scraper.ps1",
- "window_feature_miner.ps1",
- "tree.ps1",
- ]
-
- elif ACTION == "modded":
- # Add all files in MODS to execution list
- execution_list = get.list_of_files(
- "../MODS",
- only_extensions=(".py", ".exe", ".ps1", ".bat"),
- append_file_list=execution_list,
- exclude_files=["Logicytics.py"],
- exclude_dirs=["logicytics", "SysInternal_Suite"],
- )
-
- elif ACTION == "depth":
- log.warning(
- "This flag will use clunky and huge scripts, and so may take a long time, but reap great rewards."
- )
- files_to_append = {
- "sensitive_data_miner.py",
- "dir_list.py",
- "tree.ps1",
- "event_log.py",
- }
- for file in files_to_append:
- execution_list = self.__safe_append(file, execution_list)
- log.warning("This flag will use threading!")
-
- elif ACTION == "vulnscan_ai":
- # Only vulnscan detector
- if os.path.exists("vulnscan.py"):
- execution_list = ["vulnscan.py"]
- else:
- log.critical("Vulnscan is missing...")
- exit(1)
-
- if len(execution_list) == 0:
- log.critical(
- "Nothing is in the execution list.. This is due to faulty code or corrupted Logicytics files!"
- )
- exit(1)
-
- log.debug(f"Execution list length: {len(execution_list)}")
- log.debug(f"The following will be executed: {execution_list}")
- return execution_list
-
- @staticmethod
- def __script_handler(script: str) -> tuple[str, Exception | None]:
- """
- Executes a single script and logs the result, capturing any exceptions that occur during execution.
-
- Parameters:
- script (str): The path to the script to be executed
- """
- log.debug(f"Executing {script}")
- try:
- log.execution(execute.script(script))
- log.info(f"{script} executed successfully")
- return script, None
- except Exception as err:
- log.error(f"Error executing {script}: {err}")
- return script, err
-
- def handler(self):
- """Executes the scripts in the execution list based on the action."""
- log.info("Starting Logicytics...")
-
- if ACTION == "threaded" or ACTION == "depth":
- self.__threaded()
- elif ACTION == "performance_check":
- self.__performance()
- else:
- self.__default()
-
- def __threaded(self):
- """Executes scripts in parallel using threading."""
- log.debug("Using threading")
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
- futures = {
- executor.submit(self.__script_handler, script): script
- for script in self.execution_list
- }
-
- for future in as_completed(futures):
- script = futures[future]
- try:
- result, error = future.result()
- if error:
- log.error(f"Failed to execute {script}: {error}")
- else:
- log.debug(f"Completed {script}")
- except Exception as e:
- log.error(f"Thread crashed while executing {script}: {e}")
-
- def __default(self):
- """Executes scripts sequentially."""
- try:
- for script in self.execution_list:
- result, error = self.__script_handler(script)
- if error:
- log.error(f"Failed to execute {script}")
- else:
- log.debug(f"Completed {script}")
- except UnicodeDecodeError as e:
- log.error(f"Error in script execution (Unicode): {e}")
- except Exception as e:
- log.error(f"Error in script execution: {e}")
-
- def __performance(self):
- """Checks performance of each script."""
- if DEBUG.lower() != "debug":
- log.warning("Advised to turn on DEBUG logging!!")
-
- execution_times = []
-
- for file in range(len(self.execution_list)):
- gc.collect()
- start_time = datetime.now()
- log.execution(execute.script(self.execution_list[file]))
- end_time = datetime.now()
- elapsed_time = end_time - start_time
- execution_times.append((self.execution_list[file], elapsed_time))
- log.info(f"{self.execution_list[file]} executed in {elapsed_time}")
-
- table = PrettyTable()
- table.field_names = ["Script", "Execution Time"]
- for script, elapsed_time in execution_times:
- table.add_row([script, elapsed_time])
-
- try:
- with open(
- f"../ACCESS/LOGS/PERFORMANCE/Performance_Summary_"
- f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.txt",
- "w",
- ) as f:
- f.write(table.get_string())
- f.write("\nNote: This test only measures execution time.\n")
- log.info(
- "Performance check complete! Performance log found in ACCESS/LOGS/PERFORMANCE"
- )
- except Exception as e:
- log.error(f"Error writing performance log: {e}")
-
-
-class SpecialAction:
- @staticmethod
- def update() -> tuple[str, str]:
- """
- Updates the repository by pulling the latest changes from the remote repository.
-
- This function navigates to the parent directory, pulls the latest changes using Git,
- and then returns to the current working directory.
-
- Returns:
- str: The output from the git pull command.
- """
- # Check if git command is available
- try:
- if (
- subprocess.run(["git", "--version"], capture_output=True).returncode
- != 0
- ):
- return "Git is not installed or not available in the PATH.", "error"
- except FileNotFoundError:
- return "Git is not installed or not available in the PATH.", "error"
-
- # Check if the project is a git repository
- try:
- if not os.path.exists(os.path.join(os.getcwd(), "../.git")):
- return (
- "This project is not a git repository. The update flag uses git.",
- "error",
- )
- except Exception as e:
- return f"Error checking for git repository: {e}", "error"
-
- current_dir = os.getcwd()
- parent_dir = os.path.dirname(current_dir)
- os.chdir(parent_dir)
- output = subprocess.run(["git", "pull"], capture_output=True).stdout.decode()
- os.chdir(current_dir)
- return output, "info"
-
- @staticmethod
- def execute_new_window(file_path: str):
- """
- Execute a Python script in a new command prompt window.
-
- This function launches the specified Python script in a separate command prompt window, waits for its completion, and then exits the current process.
-
- Parameters:
- file_path (str): The relative path to the Python script to be executed,
- which will be resolved relative to the current script's directory.
-
- Side Effects:
- - Opens a new command prompt window
- - Runs the specified Python script
- - Terminates the current process after script execution
-
- Raises:
- FileNotFoundError: If the specified script path does not exist
- subprocess.SubprocessError: If there are issues launching the subprocess
- """
- sr_current_dir = os.path.dirname(os.path.abspath(__file__))
- sr_script_path = os.path.join(sr_current_dir, file_path)
- sr_process = subprocess.Popen(
- ["cmd.exe", "/c", "start", sys.executable, sr_script_path]
- )
- sr_process.wait()
- exit(0)
-
-
-def get_flags():
- """
- Retrieves action and sub-action flags from the Flag module and sets global variables.
-
- This function extracts the current action and sub-action from the Flag module, setting global
- ACTION and SUB_ACTION variables. It logs the retrieved values for debugging and tracing purposes.
-
- No parameters.
-
- Side effects:
- - Sets global variables ACTION and SUB_ACTION
- - Logs debug information about current action and sub-action
- """
- global ACTION, SUB_ACTION
- # Get flags_list
- ACTION, SUB_ACTION = flag.data()
- log.debug(f"Action: {ACTION}")
- log.debug(f"Sub-Action: {SUB_ACTION}")
-
-
-def handle_special_actions():
- """
- Handles special actions based on the current action flag.
-
- This function performs specific actions depending on the global `ACTION` variable:
- - For "debug": Opens the debug menu by executing '_debug.py'
- - For "dev": Opens the developer menu by executing '_dev.py'
- - For "update": Updates the repository using Health.update() method
- - For "restore": Displays a warning and opens the backup location
- - For "backup": Creates backups of the CODE and MODS directories
-
- Side Effects:
- - Logs informational, debug, warning, or error messages
- - May execute external Python scripts
- - May open file locations
- - May terminate the program after completing special actions
-
- Raises:
- SystemExit: Exits the program after completing certain special actions
- """
- # Special actions -> Quit
- if ACTION == "debug":
- log.info("Opening debug menu...")
- SpecialAction.execute_new_window("_debug.py")
-
- messages = check.sys_internal_zip()
- if messages:
- # If there are messages, log them with debug
- log.debug(messages)
-
- if ACTION == "dev":
- log.info("Opening developer menu...")
- SpecialAction.execute_new_window("_dev.py")
-
- if ACTION == "update":
- log.info("Updating...")
- message, log_type = SpecialAction.update()
- log.string(message, log_type)
- if log_type == "info":
- log.info("Update complete!")
- else:
- log.error("Update failed!")
- input("Press Enter to exit...")
- exit(0)
-
- if ACTION == "usage":
- flag.Match.generate_summary_and_graph()
- input("Press Enter to exit...")
- exit(1)
-
-
-def check_privileges():
- """
- Checks if the script is running with administrative privileges and handles UAC (User Account Control) settings.
-
- This function verifies if the script has admin privileges. If not, it either logs a warning (in debug mode) or
- prompts the user to run the script with admin privileges and exits. It also checks if UAC is enabled and logs
- warnings accordingly.
-
- Raises:
- SystemExit: If the script is not running with admin privileges and not in debug mode.
-
- Notes:
- - Requires the `Check` module with `admin()` and `uac()` methods
- - Depends on global `DEBUG` configuration variable
- - Logs warnings or critical messages based on privilege and UAC status
- """
- if not check.admin():
- if DEBUG == "DEBUG":
- log.warning(
- "Running in debug mode, continuing without admin privileges - This may cause issues"
- )
- else:
- log.critical(
- "Please run this script with admin privileges - To ignore this message, run with DEBUG in config"
- )
- input("Press Enter to exit...")
- exit(1)
-
- if check.uac():
- log.warning(
- "UAC is enabled, this may cause issues - Please disable UAC if possible"
- )
-
-
-class ZIP:
- @classmethod
- def files(cls):
- """Zips generated files based on the action."""
- if ACTION == "modded":
- cls.__and_log("..\\MODS", "MODS")
- cls.__and_log(".", "CODE")
-
- @staticmethod
- def __and_log(directory: str, name: str):
- log.debug(
- f"Zipping directory '{directory}' with name '{name}' under action '{ACTION}'"
- )
- # noinspection PyUnreachableCode
- zip_values = file_management.Zip.and_hash(
- directory,
- name,
- ACTION
- if ACTION is not None
- else f"ERROR_NO_ACTION_SPECIFIED_{datetime.now().isoformat()}",
- )
- if isinstance(zip_values, str):
- log.error(zip_values)
- else:
- zip_loc, hash_loc = zip_values
- log.info(zip_loc)
- log.debug(hash_loc)
-
-
-def handle_sub_action():
- """
- Handles sub-actions based on the provided sub_action flag.
-
- This function checks the value of the `sub_action` variable and performs
- corresponding sub-actions such as shutting down or rebooting the system.
- """
- log.info("Completed successfully!")
- log.newline()
- # Handle sub actions for all actions except performance check
- if ACTION != "performance_check":
- if SUB_ACTION == "shutdown":
- subprocess.call("shutdown /s /t 3", shell=False)
- elif SUB_ACTION == "reboot":
- subprocess.call("shutdown /r /t 3", shell=False)
-
-
-@log.function
-def Logicytics():
- """
- Orchestrates the complete Logicytics workflow, managing script execution, system actions, and user interactions.
-
- This function serves as the primary entry point for the Logicytics utility, coordinating a series of system-level operations:
- - Retrieves command-line configuration flags
- - Processes special actions
- - Verifies system privileges
- - Executes targeted scripts
- - Compresses generated output files
- - Handles final system sub-actions
- - Provides a graceful exit mechanism
-
- Performs actions sequentially without returning a value, designed to be the main execution flow of the Logicytics utility.
- """
- # Get flags_list and configs
- get_flags()
- # Check for special actions
- handle_special_actions()
- # Check for privileges and errors
- check_privileges()
- # Execute scripts
- ExecuteScript().handler()
- # Zip generated files
- ZIP.files()
- # Finish with sub actions
- handle_sub_action()
- # Finish
- input("Press Enter to exit...")
-
-
-if __name__ == "__main__":
- try:
- Logicytics()
- except KeyboardInterrupt:
- log.warning(
- "Force shutdown detected! Some temporary files might be left behind."
- )
- log.warning("Next time, let the program finish naturally for complete cleanup.")
- # Emergency cleanup - zip generated files
- ZIP.files()
- exit(0)
-else:
- log.error("This script cannot be imported!")
- exit(1)
diff --git a/CODE/SysInternal_Suite/SysInternal_Suite.zip b/CODE/SysInternal_Suite/SysInternal_Suite.zip
deleted file mode 100644
index 4d310946..00000000
Binary files a/CODE/SysInternal_Suite/SysInternal_Suite.zip and /dev/null differ
diff --git a/CODE/_debug.py b/CODE/_debug.py
deleted file mode 100644
index 87cc8a14..00000000
--- a/CODE/_debug.py
+++ /dev/null
@@ -1,278 +0,0 @@
-from __future__ import annotations
-
-import configparser
-import os
-import platform
-import sys
-import time
-
-import psutil
-import requests
-
-from logicytics import Log, DEBUG, VERSION, check, config, get
-
-log_path = os.path.join(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "ACCESS\\LOGS\\DEBUG\\DEBUG.log",
-)
-log = Log(
- {
- "log_level": DEBUG,
- "filename": log_path,
- "truncate_message": False,
- "delete_log": True,
- }
-)
-url = config.get("System Settings", "config_url")
-
-
-class VersionManager:
- @staticmethod
- def parse_version(version: str) -> tuple[int, int, int | str, str]:
- """
- Parses a version string into a tuple (major, minor, patch, type).
- """
- try:
- if version.startswith("snapshot-"):
- parts = version.split("-")[1].split(".")
- major, minor = map(int, parts[:2])
- patch = parts[2] if len(parts) > 2 else "0"
- return major, minor, patch, "snapshot"
- else:
- return tuple(map(int, version.split("."))) + ("release",)
- except Exception as e:
- log.error(f"Failed to parse version: {e}")
- return 0, 0, 0, "error"
-
-
-class FileManager:
- @staticmethod
- def check_required_files(directory: str, required_files: list[str]):
- """
- Checks if all required files are present in the directory and its subdirectories.
- """
- try:
- log.debug(f"Checking directory: {directory}")
- if not os.path.exists(directory):
- log.error(f"Directory {directory} does not exist.")
- return
-
- # Use get.list_of_files to retrieve files, excluding specified files, dirs, and extensions
- actual_files = get.list_of_files(
- directory,
- exclude_files=[
- "logicytics/User_History.json.gz",
- "logicytics/User_History.json",
- ],
- exclude_dirs=["SysInternal_Suite"],
- exclude_extensions=[".pyc"],
- )
- actual_files = [
- f.replace("\\", "/").replace('"', "") for f in actual_files
- ] # Normalize paths
-
- log.debug(f"Actual files found: {actual_files}")
- # Strip quotes and normalize paths for comparison
- normalized_required_files = [
- required_file.strip()
- .replace("\\", "/")
- .replace('"', "") # Remove quotes and normalize paths
- for required_file in required_files
- ]
-
- # Compare files
- missing_files, extra_files = FileManager.compare_files(
- actual_files, normalized_required_files
- )
-
- if missing_files:
- log.error(f"Missing files: {', '.join(missing_files)}")
- if extra_files:
- log.warning(f"Extra files found: {', '.join(extra_files)}")
- if not missing_files and not extra_files:
- log.info("All required files are present.")
- except Exception as e:
- log.error(f"Unexpected error during file check: {e}")
-
- @staticmethod
- def compare_files(
- actual_files: list[str], required_files: list[str]
- ) -> tuple[list[str], list[str]]:
- """
- Compares actual and required files, returning missing and extra files.
- """
- missing_files = [file for file in required_files if file not in actual_files]
- extra_files = [file for file in actual_files if file not in required_files]
- return missing_files, extra_files
-
-
-class SysInternalManager:
- @staticmethod
- def check_binaries(path: str):
- """
- Checks the SysInternal Binaries in the given directory.
- """
- try:
- if not os.path.exists(path):
- raise FileNotFoundError("Directory does not exist")
-
- contents = os.listdir(path)
- log.debug(str(contents))
-
- has_zip = any(file.endswith(".zip") for file in contents)
- has_exe = any(file.endswith(".exe") for file in contents)
-
- if any(file.endswith(".ignore") for file in contents):
- log.warning("A `.sys.ignore` file was found - Ignoring")
- elif has_zip and not has_exe:
- log.error("Only zip files - Missing EXEs due to no `ignore` file")
- elif has_zip and has_exe:
- log.info("Both zip and exe files - All good")
- else:
- log.error(
- "SysInternal Binaries Not Found: Missing Files - Corruption detected"
- )
- except Exception as e:
- log.error(f"Unexpected error: {e}")
-
-
-class SystemInfoManager:
- @staticmethod
- def cpu_info() -> tuple[str, str, str]:
- """
- Retrieves CPU details.
- """
- return (
- f"CPU Architecture: {platform.machine()}",
- f"CPU Vendor ID: {platform.system()}",
- f"CPU Model: {platform.release()} {platform.version()}",
- )
-
- @staticmethod
- def python_version():
- """
- Checks the current Python version against recommended version ranges and logs the result.
- """
- version = sys.version.split()[0]
- MIN_VERSION = (3, 11)
- MAX_VERSION = (3, 14)
- try:
- major, minor = map(int, version.split(".")[:2])
- if MIN_VERSION <= (major, minor) < MAX_VERSION:
- if (major, minor) == MIN_VERSION:
- log.info(
- f"Python Version: {version} - Perfect (mainly tested on 3.11.x)"
- )
- else:
- log.info(f"Python Version: {version} - Supported")
- elif (major, minor) < MIN_VERSION:
- log.warning(f"Python Version: {version} - Recommended: 3.11.x")
- else:
- log.error(f"Python Version: {version} - Incompatible")
- except Exception as e:
- log.error(f"Failed to parse Python Version: {e}")
-
-
-class ConfigManager:
- @staticmethod
- def get_online_config() -> dict | None:
- """
- Retrieves configuration data from a remote repository.
- """
- try:
- _config = configparser.ConfigParser()
- _config.read_string(requests.get(url, timeout=15).text)
- return _config
- except requests.exceptions.RequestException as e:
- log.error(f"Connection error: {e}")
- return None
-
-
-class HealthCheck:
- @staticmethod
- def check_versions(local_version: str, remote_version: str):
- """
- Compares local and remote versions.
- """
- local_version_tuple = VersionManager.parse_version(local_version)
- remote_version_tuple = VersionManager.parse_version(remote_version)
-
- if "error" in local_version_tuple or "error" in remote_version_tuple:
- log.error("Version parsing error.")
- return
-
- try:
- if "snapshot" in local_version_tuple or "snapshot" in remote_version_tuple:
- log.warning("Snapshot versions are unstable.")
-
- if local_version_tuple == remote_version_tuple:
- log.info(f"Version is up to date. Your Version: {local_version}")
- elif local_version_tuple > remote_version_tuple:
- log.warning(
- "Version is ahead of the repository. "
- f"Your Version: {local_version}, "
- f"Repository Version: {remote_version}"
- )
- else:
- log.error(
- "Version is behind the repository. "
- f"Your Version: {local_version}, Repository Version: {remote_version}"
- )
- except Exception as e:
- log.error(f"Version comparison error: {e}")
-
-
-@log.function
-def debug():
- """
- Executes a comprehensive system debug routine, performing various checks and logging system information.
- """
- # Online Configuration Check
- _config = ConfigManager.get_online_config()
- if _config:
- HealthCheck.check_versions(VERSION, _config["System Settings"]["version"])
-
- # File Integrity Check
- required_files = _config["System Settings"].get("files", "").split(",")
- FileManager.check_required_files(".", required_files)
-
- # SysInternal Binaries Check
- SysInternalManager.check_binaries("SysInternal_Suite")
-
- # System Checks
- log.info("Admin privileges found") if check.admin() else log.warning(
- "Admin privileges not found"
- )
- log.info("UAC enabled") if check.uac() else log.warning("UAC disabled")
- log.info(f"Execution path: {psutil.__file__}")
- log.info(f"Global execution path: {sys.executable}")
- log.info(f"Local execution path: {sys.prefix}")
- log.info(
- "Running in a virtual environment"
- if sys.prefix != sys.base_prefix
- else "Not running in a virtual environment"
- )
- log.info(
- "Execution policy is unrestricted"
- if check.execution_policy()
- else "Execution policy is restricted"
- )
-
- # Python Version Check
- SystemInfoManager.python_version()
-
- # CPU Info
- for info in SystemInfoManager.cpu_info():
- log.info(info)
-
- # Final Debug Status
- log.info(f"Log Level: {DEBUG}")
-
-
-if __name__ == "__main__":
- try:
- debug()
- except Exception as err:
- log.error(f"Failed to execute debug routine: {err}")
- time.sleep(0.5)
- input("Press Enter to exit...")
diff --git a/CODE/_dev.py b/CODE/_dev.py
deleted file mode 100644
index b9863125..00000000
--- a/CODE/_dev.py
+++ /dev/null
@@ -1,223 +0,0 @@
-from __future__ import annotations
-
-import os
-import re
-import subprocess
-
-import configobj
-
-from logicytics import log, get, CURRENT_FILES, VERSION
-
-
-def color_print(text, color="reset", is_input=False) -> None | str:
- colors = {
- "reset": "\033[0m",
- "red": "\033[31m",
- "green": "\033[32m",
- "yellow": "\033[33m",
- "cyan": "\033[36m",
- }
-
- color_code = colors.get(color.lower(), colors["reset"])
- if is_input:
- return input(f"{color_code}{text}{colors['reset']}")
- print(f"{color_code}{text}{colors['reset']}")
- return None
-
-
-def _update_ini_file(filename: str, new_data: list | str, key: str) -> None:
- """
- Updates an INI file with a new array of current files or version.
- Args:
- filename (str): The path to the INI file to be updated.
- new_data (list | str): The list of current files or the new version to be written to the INI file.
- key (str): The key in the INI file to be updated.
- Returns:
- None
- """
- try:
- config = configobj.ConfigObj(
- filename, encoding="utf-8", write_empty_values=True
- )
-
- if key == "files":
- config["System Settings"][key] = ", ".join(new_data)
- elif key == "version":
- config["System Settings"][key] = new_data
- else:
- color_print(f"[!] Invalid key: {key}", "yellow")
- return
-
- config.write()
- except FileNotFoundError:
- color_print("[x] INI file not found", "red")
- except configobj.ConfigObjError as e:
- color_print(f"[x] Parsing INI file failed: {e}", "red")
- except Exception as e:
- color_print(f"[x] {e}", "red")
-
-
-def _prompt_user(
- question: str, file_to_open: str = None, special: bool = False
-) -> bool:
- """
- Prompts the user with a yes/no question and optionally opens a file.
-
- Parameters:
- question (str): The question to be presented to the user.
- file_to_open (str, optional): Path to a file that will be opened if the user does not respond affirmatively.
- special (bool, optional): Flag to suppress the default reminder message when the user responds negatively.
-
- Returns:
- bool: True if the user responds with 'yes' or 'Y', False otherwise.
-
- Raises:
- Exception: Logs any unexpected errors during user interaction.
-
- Notes:
- - Uses subprocess to open files on Windows systems
- - Case-insensitive input handling for 'yes' responses
- - Provides optional file opening and reminder messaging
- """
- try:
- answer = color_print(f"[?] {question} (y)es or (n)o:- ", "cyan", is_input=True)
- if not (answer.lower() == "yes" or answer.lower() == "y"):
- if file_to_open:
- subprocess.run(["start", file_to_open], shell=True)
- if not special:
- color_print(
- "[x] Please ensure you fix the issues/problem and try again with the checklist.",
- "red",
- )
- return False
- return True
- except Exception as e:
- color_print(f"[x] {e}", "red")
- return None
-
-
-def _perform_checks() -> bool:
- """
- Performs a series of user prompts for various checks.
-
- Returns:
- bool: True if all checks are confirmed by the user, False otherwise.
- """
- checks = [
- ("Have you read the required contributing guidelines?", "..\\CONTRIBUTING.md"),
- ("Have you made files you don't want to be run start with '_'?", "."),
- ("Have you added the file to CODE dir?", "."),
- ("Have you added docstrings and comments?", "..\\CONTRIBUTING.md"),
- ("Is each file containing around 1 main feature?", "..\\CONTRIBUTING.md"),
- ]
-
- for question, file_to_open in checks:
- if not _prompt_user(question, file_to_open):
- return False
- return True
-
-
-def _handle_file_operations() -> None:
- """
- Handles file operations and logging for added, removed, and normal files.
- """
- EXCLUDE_FILES = [
- "logicytics\\User_History.json.gz",
- "logicytics\\User_History.json",
- ]
- files = get.list_of_files(
- ".",
- exclude_files=EXCLUDE_FILES,
- exclude_dirs=["SysInternal_Suite"],
- exclude_extensions=[".pyc"],
- )
- added_files, removed_files, normal_files = [], [], []
- clean_files_list = [file.replace('"', "") for file in CURRENT_FILES]
-
- files_set = set(os.path.abspath(f) for f in files)
- clean_files_set = set(os.path.abspath(f) for f in clean_files_list)
-
- for file in files_set:
- if file in clean_files_set and file not in EXCLUDE_FILES:
- normal_files.append(file)
- elif file not in clean_files_set and file not in EXCLUDE_FILES:
- added_files.append(file)
-
- for file in clean_files_set:
- if file not in files_set and file not in EXCLUDE_FILES:
- removed_files.append(file)
-
- print("\n".join([f"\033[92m+ {file}\033[0m" for file in added_files])) # Green +
- print("\n".join([f"\033[91m- {file}\033[0m" for file in removed_files])) # Red -
- print("\n".join([f"* {file}" for file in normal_files]))
-
- if not _prompt_user("Does the list above include your added files?"):
- color_print("[x] Something went wrong! Please contact support.", "red")
- return
-
- max_attempts = 10
- attempts = 0
- _update_ini_file("config.ini", files, "files")
-
- while True:
- version = color_print(
- f"[?] Enter the new version of the project (Old version is {VERSION}): ",
- "cyan",
- is_input=True,
- )
-
- if re.match(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$", version):
- _update_ini_file("config.ini", version, "version")
- break
- attempts += 1
- if attempts >= max_attempts:
- color_print(
- "[x] Maximum attempts reached. Please run the script again.", "red"
- )
- exit()
- else:
- color_print(
- "[!] Please enter a valid version number (e.g., 1.2.3)", "yellow"
- )
- color_print(f"[!] {max_attempts - attempts} attempts remaining", "yellow")
-
- color_print(
- "\n[-] Great Job! Please tick the box in the GitHub PR request for completing steps in --dev",
- "green",
- )
-
-
-@log.function
-def dev_checks() -> None:
- """
- Performs comprehensive developer checks to ensure code quality and project guidelines compliance.
-
- This function guides developers through a series of predefined checks, validates file additions,
- and updates project configuration. It performs the following key steps:
- - Verify adherence to contributing guidelines
- - Check file naming conventions
- - Validate file placement
- - Confirm docstring and comment coverage
- - Assess feature modularity
- - Categorize and display file changes
- - Update project configuration file
-
- Raises:
- None: Returns None if any check fails or an error occurs during the process.
-
- Side Effects:
- - Creates necessary directories
- - Prompts user for multiple confirmations
- - Prints file change lists with color coding
- - Updates configuration file with current files and version
- - Logs warnings or errors during the process
- """
- if not _perform_checks():
- return
- _handle_file_operations()
-
-
-if __name__ == "__main__":
- dev_checks()
- # Wait for the user to press Enter to exit the program
- input("\n[*] Press Enter to exit the program... ")
diff --git a/CODE/bluetooth_details.py b/CODE/bluetooth_details.py
deleted file mode 100644
index fb28dcdb..00000000
--- a/CODE/bluetooth_details.py
+++ /dev/null
@@ -1,144 +0,0 @@
-from __future__ import annotations
-
-import json
-import subprocess
-from typing import TextIO
-
-from logicytics import log
-
-
-@log.function
-def get_bluetooth_device_details():
- """
- Retrieves and logs detailed information about Bluetooth devices on the system.
-
- Executes a PowerShell query to collect Bluetooth device details and writes the information to a text file.
- The function performs the following key actions:
- - Logs the start of the device information retrieval process
- - Queries Bluetooth devices using an internal helper function
- - Writes device details to 'Bluetooth Info.txt' if devices are found
-
- Returns:
- None: No return value; results are written to a file and logged
- """
- log.info("Fetching detailed info for Bluetooth devices...")
- devices = _query_bluetooth_devices()
- if devices:
- _write_device_info_to_file(devices, "Bluetooth Info.txt")
-
-
-def _query_bluetooth_devices() -> bool | list[dict[str, str]]:
- """
- Queries the system for Bluetooth devices using PowerShell commands.
-
- Executes a PowerShell command to retrieve detailed information about Bluetooth devices connected to the system.
- The function handles potential errors during command execution and JSON parsing, providing fallback values
- for device information.
-
- Returns:
- bool | list[dict[str, str]]: A list of device information dictionaries or False if an error occurs.
- Each dictionary contains details such as Name, Device ID, Description, Manufacturer, Status, and PNP Device ID.
-
- Raises:
- No direct exceptions are raised. Errors are logged and the function returns False.
-
- Example:
- devices = _query_bluetooth_devices()
- if devices:
- for device in devices:
- print(device['Name'])
- """
- try:
- # Run PowerShell command to get Bluetooth devices
- command = (
- "Get-PnpDevice | Where-Object { $_.FriendlyName -like '*Bluetooth*' } | "
- "Select-Object FriendlyName, DeviceID, Description, Manufacturer, Status, PnpDeviceID | "
- "ConvertTo-Json -Depth 3"
- )
- result = subprocess.run(["powershell", "-Command", command],
- capture_output=True, text=True, check=True)
- devices = json.loads(result.stdout)
- except subprocess.CalledProcessError as e:
- log.error(f"Failed to query Bluetooth devices with command '{command}': {e}")
- return False
- except json.JSONDecodeError as e:
- log.error(f"Failed to parse device information: {e}")
- return False
-
- if isinstance(devices, dict):
- devices = [devices] # Handle single result case
-
- device_info_list = []
- for device in devices:
- FALLBACK_MSG = 'Unknown (Fallback due to failed Get request)'
- device_info = {
- 'Name': device.get('FriendlyName', FALLBACK_MSG),
- 'Device ID': device.get('DeviceID', FALLBACK_MSG),
- 'Description': device.get('Description', FALLBACK_MSG),
- 'Manufacturer': device.get('Manufacturer', FALLBACK_MSG),
- 'Status': device.get('Status', FALLBACK_MSG),
- 'PNP Device ID': device.get('PnpDeviceID', FALLBACK_MSG)
- }
- log.debug(f"Retrieved device: {device_info['Name']}")
- device_info_list.append(device_info)
-
- return device_info_list
-
-
-def _write_device_info_to_file(devices: list[dict[str, str]], filename: str):
- """
- Writes the details of Bluetooth devices to a specified file.
-
- Args:
- devices (list): A list of dictionaries containing Bluetooth device information.
- filename (str): The path and name of the file where device details will be written.
-
- Raises:
- IOError: If there is an error opening or writing to the specified file.
- OSError: If there are file system related issues during file writing.
-
- Notes:
- - Uses UTF-8 encoding for file writing
- - Logs an error if file writing fails
- - Calls _write_single_device_info() for each device in the list
- """
- try:
- with open(filename, "w", encoding="UTF-8") as file:
- for device_info in devices:
- _write_single_device_info(file, device_info)
- log.info(f"Successfully wrote device details to '{filename}'")
- except Exception as e:
- log.error(f"Failed to write device information to file: {e}")
-
-
-def _write_single_device_info(file: TextIO, device_info: dict[str, str]):
- """
- Writes detailed information for a single Bluetooth device to the specified file.
-
- Parameters:
- file (TextIO): An open file object to which device information will be written.
- device_info (dict): A dictionary containing key-value pairs of Bluetooth device attributes.
-
- Writes the device name followed by all other device attributes, with each device's information separated by a blank line. Uses `.get()` method to provide a fallback 'Unknown' value if the device name is missing.
-
- Example:
- If device_info is {'Name': 'Wireless Headset', 'Address': '00:11:22:33:44:55', 'Connected': 'True'}
- The file will contain:
- Name: Wireless Headset
- Address: 00:11:22:33:44:55
- Connected: True
-
- If no name is provided:
- Name: Unknown
- Address: 00:11:22:33:44:55
- Connected: True
- """
- file.write(f"Name: {device_info.get('Name', 'Unknown')}\n")
- for key, value in device_info.items():
- if key != 'Name':
- file.write(f" {key}: {value}\n")
- file.write("\n") # Separate devices with a blank line
-
-
-if __name__ == "__main__":
- get_bluetooth_device_details()
diff --git a/CODE/bluetooth_logger.py b/CODE/bluetooth_logger.py
deleted file mode 100644
index c6d18819..00000000
--- a/CODE/bluetooth_logger.py
+++ /dev/null
@@ -1,179 +0,0 @@
-import datetime
-import re
-import subprocess
-from typing import LiteralString
-
-from logicytics import log
-
-
-# Utility function to log data to a file
-def save_to_file(filename: str, section_title: str, data: str):
- """
- Appends data to a file with a section title.
-
- Args:
- filename (str): Path to the file where data will be written. Must be a valid file path.
- section_title (str): Title describing the section being added to the file.
- data (str or list): Content to be written. Accepts either a single string or a list of strings.
-
- Raises:
- IOError: If the file cannot be opened or written to due to permission or path issues.
- Exception: For any unexpected errors during file writing.
-
- Notes:
- - Uses UTF-8 encoding for file writing
- - Adds decorative section separators around the content
- - Automatically handles single string or list of strings input
- - Logs any errors encountered during file writing
- """
- try:
- with open(filename, 'a', encoding='utf-8') as file:
- file.write(f"\n{'=' * 50}\n{section_title}\n{'=' * 50}\n")
- file.write(f"{data}\n" if isinstance(data, str) else "\n".join(data) + "\n")
- file.write(f"{'=' * 50}\n")
- except Exception as err:
- log.error(f"Error writing to file {filename}: {err}")
-
-
-# Utility function to run PowerShell commands
-def run_powershell_command(command: str) -> None | list[LiteralString]:
- """
- Runs a PowerShell command and returns the output as a list of lines.
-
- Args:
- command (str): The PowerShell command to execute.
-
- Returns:
- list: A list of strings representing each line of the command output.
- Returns an empty list if the command execution fails or an exception occurs.
-
- Raises:
- subprocess.CalledProcessError: If the PowerShell command returns a non-zero exit status.
- Exception: For any unexpected errors during command execution.
-
- Notes:
- - Uses subprocess.run() with capture_output=True to capture command output
- - Logs errors for failed commands or exceptions
- - Splits command output into lines for easier processing
- """
- try:
- result = subprocess.run(["powershell", "-Command", command], capture_output=True, text=True)
- if result.returncode != 0:
- log.error(f"PowerShell command failed with return code {result.returncode}")
- return []
- return result.stdout.splitlines()
- except Exception as err:
- log.error(f"Error running PowerShell command: {err}")
- return []
-
-
-# Unified parsing function for PowerShell output
-def parse_output(lines: list[LiteralString], regex: str, group_names: list[str]):
- """
- Parses the output lines using the provided regex and group names.
-
- Parameters:
- lines (list): A list of strings representing command output lines.
- regex (str): Regular expression pattern to match each line.
- group_names (list): List of group names to extract from matched regex.
-
- Returns:
- list: Dictionaries containing extracted group names and their values.
-
- Raises:
- Exception: If parsing the output encounters an unexpected error.
-
- Notes:
- - Skips lines that do not match the provided regex pattern
- - Logs debug messages for unrecognized lines
- - Logs error if parsing fails completely
- """
- results = []
- try:
- for line in lines:
- match = re.match(regex, line)
- if match:
- results.append({name: match.group(name) for name in group_names})
- else:
- log.debug(f"Skipping unrecognized line: {line}")
- return results
- except Exception as err:
- log.error(f"Parsing output failed: {err}")
-
-
-# Function to get paired Bluetooth devices
-def get_paired_bluetooth_devices() -> list[str]:
- """
- Retrieves a list of paired Bluetooth devices with their names and MAC addresses.
-
- This function executes a PowerShell command to fetch Bluetooth devices with an "OK" status,
- parses the output to extract device details, and attempts to retrieve MAC addresses from device IDs.
-
- Returns:
- list: A list of formatted strings containing device names and MAC addresses.
- Each string follows the format "Name: , MAC: ".
-
- Raises:
- Exception: If there are issues running the PowerShell command or parsing the output.
- """
- command = (
- 'Get-PnpDevice -Class Bluetooth | Where-Object { $_.Status -eq "OK" } | Select-Object Name, DeviceID'
- )
- output = run_powershell_command(command)
- log.debug(f"Raw PowerShell output for paired devices:\n{output}")
-
- devices = parse_output(
- output,
- regex=r"^(?P.+?)\s+(?P.+)$",
- group_names=["Name", "DeviceID"]
- )
-
- # Extract MAC addresses
- for device in devices:
- mac_match = re.search(r"BLUETOOTHDEVICE_(?P[A-F0-9]{12})", device["DeviceID"], re.IGNORECASE)
- device["MAC"] = mac_match.group("MAC") if mac_match else "Address Not Found"
-
- return [f"Name: {device['Name']}, MAC: {device['MAC']}" for device in devices]
-
-
-# Function to log all Bluetooth data
-@log.function
-def log_bluetooth():
- """
- Logs comprehensive Bluetooth data including paired devices and system event logs.
-
- This function performs the following actions:
- - Captures the current timestamp
- - Retrieves and logs paired Bluetooth devices
- - Collects Bluetooth connection/disconnection event logs
- - Captures Bluetooth file transfer logs
- - Saves all collected data to 'bluetooth_data.txt'
-
- The function uses internal utility functions to run PowerShell commands, parse outputs, and save results to a file. It provides a systematic approach to logging Bluetooth-related system information.
-
- Logs are saved with descriptive section titles, making the output easily readable and organized. If no data is found for a specific section, a default "No logs found" message is recorded.
-
- Note:
- - Requires administrative or sufficient system permissions to access Windows event logs
- - Logs are appended to the file, allowing historical tracking of Bluetooth events
- """
- log.info("Starting Bluetooth data logging...")
- filename = "bluetooth_data.txt"
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- save_to_file(filename, "Bluetooth Data Collection - Timestamp", timestamp)
-
- # Collect and log paired devices
- log.info(f"Collecting paired devices...")
- paired_devices = get_paired_bluetooth_devices()
- section_title = "Paired Bluetooth Devices"
- save_to_file(filename, section_title, paired_devices or ["No paired Bluetooth devices found."])
- log.debug(f"{section_title}: {paired_devices}")
-
- log.info("Finished Bluetooth data logging.")
-
-
-if __name__ == "__main__":
- try:
- log_bluetooth()
- except Exception as e:
- log.error(f"Failed to log Bluetooth data: {e}")
diff --git a/CODE/browser_miner.ps1 b/CODE/browser_miner.ps1
deleted file mode 100644
index f0be76b0..00000000
--- a/CODE/browser_miner.ps1
+++ /dev/null
@@ -1,89 +0,0 @@
-# Define the list of source paths with placeholders
-$sourcePaths = @(
- "C:\Users\{}\AppData\Local\Microsoft\Edge\User Data\Default\Network",
- "C:\Users\{}\AppData\Local\Google\Chrome\User Data\Default\Network",
- "C:\Users\{}\AppData\Roaming\Mozilla\Firefox\Profiles",
- "C:\Users\{}\AppData\Roaming\Opera Software\Opera Stable\Network",
- "C:\Users\{}\AppData\Roaming\Opera Software\Opera GX Stable\Network",
- 'C:\\Windows\\System32\\config',
- 'C:\\Windows\\System32\\GroupPolicy',
- 'C:\\Windows\\System32\\GroupPolicyUsers',
- 'C:\\Windows\\System32\\winevt\\Logs'
-)
-
-# Define the list of identifiers for renaming
-$identifiers = @(
- "Edge",
- "Chrome",
- "Firefox",
- "OperaStable",
- "OperaGXStable",
- "SAM",
- "SystemConfig",
- "GroupPolicy",
- "GroupPolicyUsers",
- "WindowsEventLogs"
-)
-
-# Get the current user's name
-$currentUser = $env:USERNAME
-
-# Define the base directory for the destination
-$baseDirectory = "Browser_Data"
-
-# Function to check if a path exists and is accessible
-function Test-PathAndAccess($path)
-{
- return Test-Path $path -PathType Container -ErrorAction SilentlyContinue
-}
-
-# Loop through each source path
-foreach ($sourcePath in $sourcePaths)
-{
- # Replace the placeholder with the current user's name
- $fullSourcePath = $sourcePath -replace '\{\}', $currentUser
-
- # Enhanced error checking for source path existence and accessibility
- if (-not (Test-PathAndAccess $fullSourcePath))
- {
- Write-Host "WARNING: Source path $fullSourcePath does not exist or cannot be accessed."
- continue
- }
-
-
- # Extract the identifier from the source path using the corresponding Index from the $identifiers array
- try
- {
- $index = [Array]::IndexOf($identifiers, $sourcePath.Split('\')[-1].Split('\\')[-1])
- $identifier = $identifiers[$index]
- }
- catch
- {
- Write-Host "ERROR: Failed to extract identifier from source path $fullSourcePath."
- continue
- }
-
-
- # Define the destination path
- $destinationPath = Join-Path -Path $baseDirectory -ChildPath "USER_$identifier"
-
- # Enhanced error checking for destination directory existence
- if (-not (Test-PathAndAccess $destinationPath))
- {
- New-Item -ItemType Directory -Path $destinationPath -Force | Out-Null
- }
-
- # Attempt to copy the folder to the DATA directory and rename it
- try
- {
- Copy-Item -Path $fullSourcePath -Destination $destinationPath -Recurse -Force -ErrorAction SilentlyContinue
- # Print the success message to the console
- Write-Host "INFO: Successfully copied $fullSourcePath to $destinationPath"
- }
- catch
- {
- # Detailed error handling
- Write-Host "ERROR: An error occurred while copying $fullSourcePath to $destinationPath : $_"
- exit
- }
-}
diff --git a/CODE/cmd_commands.py b/CODE/cmd_commands.py
deleted file mode 100644
index 3739dca7..00000000
--- a/CODE/cmd_commands.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from logicytics import log, execute
-
-
-@log.function
-def command(file: str, commands: str, message: str, encoding: str = "UTF-8") -> None:
- """
- Executes a command and writes the output to a file.
-
- Args:
- file (str): The name of the file to write the command output to.
- commands (str): The command to be executed.
- message (str): A message to be logged.
- encoding (str): The encoding to write the file in.
-
- Returns:
- None
- """
- log.info(f"Executing {message}")
- try:
- output = execute.command(commands)
- with open(file, "w", encoding=encoding) as f:
- f.write(output)
- log.info(f"{message} Successful - {file}")
- except Exception as e:
- log.error(f"Error while getting {message}: {e}")
-
-
-if __name__ == "__main__":
- command("Drivers.txt", "driverquery /v", "Driver Query")
- command("SysInfo.txt", "systeminfo", "System Info")
- command("GPResult.txt", "GPResult /r", "GPResult", "windows-1252")
diff --git a/CODE/config.ini b/CODE/config.ini
deleted file mode 100644
index 8395bd55..00000000
--- a/CODE/config.ini
+++ /dev/null
@@ -1,114 +0,0 @@
-########################################################
-# The following settings are for Logicytics as a whole #
-########################################################
-
-[Settings]
-# Would you like to enable debug mode?
-# This will print out more information to the console, with prefix DEBUG
-# This will not be logged however, and is useful for developers - This is different than the DEBUGGER itself
-log_using_debug = false
-
-# Would you like for new logs to be created every execution?
-# Or would you like to append to the same log file?
-delete_old_logs = false
-
-# When using threading mode, you have the option to decide how many threads to use (workers)
-# Uncomment and change the value to use a maximum amount of threads,
-# otherwise keep it commented if you don't need a maximum limit
-; max_workers = 10
-
-# Logicytics will save preferences and history in a file,
-# This is used by Flag.py, to suggest better flags
-# Would you like this to happen?
-# This is recommended, as it will improve the suggestions - Data will never be shared
-save_preferences = true
-
-[System Settings]
-# Do not play with these settings unless you know what you are doing
-# Dev Mode allows a safe way to modify these settings!!
-version = 3.6.0
-files = "bluetooth_details.py, bluetooth_logger.py, browser_miner.ps1, cmd_commands.py, config.ini, dir_list.py, dump_memory.py, encrypted_drive_audit.py, event_log.py, Logicytics.py, log_miner.py, media_backup.py, netadapter.ps1, network_psutil.py, packet_sniffer.py, property_scraper.ps1, registry.py, sensitive_data_miner.py, ssh_miner.py, sys_internal.py, tasklist.py, tree.ps1, usb_history.py, vulnscan.py, wifi_stealer.py, window_feature_miner.ps1, wmic.py, logicytics\Checks.py, logicytics\Config.py, logicytics\Execute.py, logicytics\FileManagement.py, logicytics\Flag.py, logicytics\Get.py, logicytics\Logger.py, logicytics\User_History.json.gz, vulnscan\Model_SenseMacro.4n1.pth"
-# If you forked the project, change the USERNAME to your own to use your own fork as update material,
-# I dont advise doing this however
-config_url = https://raw.githubusercontent.com/DefinetlyNotAI/Logicytics/main/CODE/config.ini
-
-########################################################
-# The following settings are for specific modules #
-########################################################
-
-[Flag Settings]
-# The minimum accuracy to suggest a flag,
-# This is a percentage, and must be a float
-# The default is 30.0, and is what we advise
-# If the accuracy is below this, the flag will move to the next suggestion process
-# The process is: difflib, then model, then history suggestions
-# Make sure to keep between 0.0 and 100.0
-accuracy_min = 30.0
-
-# This is the model to use to suggest flags,
-# I advise to keep it as all-MiniLM-L6-v2
-# This is the best model for this task, and is lightweight
-# The model MUST be a Sentence Transformer model
-model_to_use = all-MiniLM-L6-v2
-
-# Finally, should debug mode be enabled for the flag module?
-# This will print out more information to the console,
-# This is for the model itself, and is based on tqdm, it shows extra info on batches
-# As well as more information on behind the scenes
-model_debug = false
-
-###################################################
-
-[DumpMemory Settings]
-# If the file size generated exceeds this limit,
-# the file will be truncated with a message
-# Put 0 to disable the limit - Limit is in MiB - int
-file_size_limit = 0
-# Safety margin to check, it multiplies with the size limit
-# This makes sure that after the file is created, there is still
-# disk space left for other tasks,
-# Make sure its above 1 or else it will fail
-# Put 1 to disable the limit - Limit is in MiB - float
-file_size_safety = 1.5
-
-###################################################
-
-[NetWorkPsutil Settings]
-# Total time this will take will be `sample_count * interval`
-
-# Number of samples to take for feature `measure network bandwidth usage`
-# This is an integer, and should be 1 and above
-sample_count = 5
-# Time between samples in seconds for feature `measure network bandwidth usage`
-# This is a float, and should be above 0
-interval = 1.5
-
-###################################################
-
-[PacketSniffer Settings]
-# The interface to sniff packets on, keep it as WiFi for most cases
-# Autocorrects between WiFi and Wi-Fi
-interface = WiFi
-# The number of packets to sniff,
-# Must be greater than or equal to 1 - int
-packet_count = 5000
-# The time to timeout the sniffing process only,
-# Must be greater than or equal to 5 - int
-timeout = 10
-# The maximum retry time for the whole process,
-# Must be greater than or equal to 10 and timeout - int
-max_retry_time = 30
-
-###################################################
-
-[VulnScan Settings]
-# Max characters of text from each file to analyze. Set an integer or None to disable truncation.
-text_char_limit = None
-# Max workers to be used, either integer or use "auto" to make it decide the best value
-max_workers = auto
-# Sensitivity threshold (0.0–1.0) for the model to flag content as sensitive
-threshold = 0.6
-# Paths for required files
-model = vulnscan/Model_SenseMacro.4n1.pth
-
-##################################################
diff --git a/CODE/dir_list.py b/CODE/dir_list.py
deleted file mode 100644
index c4495b79..00000000
--- a/CODE/dir_list.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import os
-from concurrent.futures import ThreadPoolExecutor
-
-from logicytics import log, execute
-
-
-def run_command_threaded(directory: str, file: str, message: str, encoding: str = "UTF-8") -> None:
- """
- Executes a PowerShell command to recursively list directory contents and writes the output to a specified file.
-
- Args:
- directory (str): The target directory path to list contents from.
- file (str): The output file path where directory contents will be appended.
- message (str): A descriptive message for logging the operation.
- encoding (str, optional): File writing encoding. Defaults to "UTF-8".
-
- Raises:
- Exception: If command execution or file writing fails.
-
- Notes:
- - Uses PowerShell's Get-ChildItem with recursive flag
- - Appends output to the specified file
- - Logs operation start and result/error
- """
- log.info(f"Executing {message} for {directory}")
- try:
- safe_directory = directory.replace('"', '`"') # Escape quotes
- command = f'powershell -NoProfile -Command "Get-ChildItem \\""{safe_directory}\\"" -Recurse"'
- output = execute.command(command)
- open(file, "a", encoding=encoding).write(output)
- log.info(f"{message} Successful for {directory} - {file}")
- except Exception as e:
- log.error(f"Error while getting {message} for {directory}: {e}")
-
-
-@log.function
-def command_threaded(base_directory: str, file: str, message: str, encoding: str = "UTF-8") -> None:
- """
- Concurrently lists contents of subdirectories within a base directory using thread pooling.
-
- Args:
- base_directory (str): Root directory to explore and list subdirectories from.
- file (str): Output file path to write directory listing results.
- message (str): Descriptive logging message for the operation.
- encoding (str, optional): File writing character encoding. Defaults to "UTF-8".
-
- Raises:
- Exception: Logs and captures any errors during thread pool execution.
-
- Notes:
- - Uses ThreadPoolExecutor for parallel directory content listing
- - Processes each subdirectory concurrently
- - Writes results to the specified file
- - Handles potential errors during thread execution
- """
- try:
- with ThreadPoolExecutor(max_workers=min(32, os.cpu_count() * 4)) as executor:
- subdirectories = [os.path.join(base_directory, d) for d in os.listdir(base_directory) if
- os.path.isdir(os.path.join(base_directory, d))]
- futures = [executor.submit(run_command_threaded, subdir, file, message, encoding) for subdir in
- subdirectories]
- for future in futures:
- future.result()
- except Exception as e:
- log.error(f"Thread Pool Error: {e}")
-
-
-if __name__ == "__main__":
- log.warning("Running dir_list.py - This is very slow - We will use threading to speed it up")
- command_threaded("C:\\", "Dir_Root.txt", "Root Directory Listing")
diff --git a/CODE/dump_memory.py b/CODE/dump_memory.py
deleted file mode 100644
index 360a27d8..00000000
--- a/CODE/dump_memory.py
+++ /dev/null
@@ -1,173 +0,0 @@
-import os
-import platform
-import struct
-from datetime import datetime
-
-import psutil
-
-from logicytics import log, config
-
-# Constants from config with validation
-LIMIT_FILE_SIZE = config.getint("DumpMemory Settings", "file_size_limit") # MiB
-SAFETY_MARGIN = config.getfloat("DumpMemory Settings", "file_size_safety") # MiB
-DUMP_DIR = config.get("DumpMemory Settings", "dump_directory", fallback="memory_dumps")
-
-if SAFETY_MARGIN < 1:
- log.critical("Invalid Safety Margin Inputted - Cannot proceed with dump memory")
- exit(1)
-
-
-def capture_ram_snapshot():
- """
- Captures and logs the current system memory statistics to a file.
-
- Retrieves detailed information about RAM and swap memory usage using psutil.
- Writes memory statistics in gigabytes to 'Ram_Snapshot.txt', including:
- - Total RAM
- - Used RAM
- - Available RAM
- - Total Swap memory
- - Used Swap memory
- - Free Swap memory
- - Percentage of RAM used
-
- Logs the process and handles potential file writing errors.
-
- Raises:
- IOError: If unable to write to the output file
- Exception: For any unexpected errors during memory snapshot capture
- """
-
- def memory_helper(mem_var, flavor_text: str, use_free_rather_than_available: bool = False):
- file.write(f"Total {flavor_text}: {mem_var.total / (1024 ** 3):.2f} GB\n")
- file.write(f"Used {flavor_text}: {mem_var.used / (1024 ** 3):.2f} GB\n")
- if use_free_rather_than_available:
- file.write(f"Available {flavor_text}: {mem_var.free / (1024 ** 3):.2f} GB\n")
- else:
- file.write(f"Available {flavor_text}: {mem_var.available / (1024 ** 3):.2f} GB\n")
- file.write(f"{flavor_text} Percent Usage: {mem_var.percent:.2f}%\n")
-
- log.info("Capturing RAM Snapshot...")
- try:
- memory = psutil.virtual_memory()
- swap = psutil.swap_memory()
- with open(os.path.join(DUMP_DIR, "Ram_Snapshot.txt"), "w", encoding="utf-8") as file:
- memory_helper(memory, "RAM")
- memory_helper(swap, "Swap Memory", use_free_rather_than_available=True)
- except Exception as e:
- log.error(f"Failed to capture RAM snapshot: {e}")
- log.info("RAM Snapshot saved to Ram_Snapshot.txt")
-
-
-def gather_system_info():
- """
- Gathers detailed system information and saves it to a file.
- """
- log.info("Gathering system information...")
- try:
- sys_info = {
- 'Architecture': platform.architecture(),
- 'System': platform.system(),
- 'Machine': platform.machine(),
- 'Processor': platform.processor(),
- 'Page Size (bytes)': struct.calcsize("P"),
- 'CPU Count': psutil.cpu_count(),
- 'CPU Frequency': psutil.cpu_freq().current if psutil.cpu_freq() else 'Unavailable',
- 'Boot Time': datetime.fromtimestamp(psutil.boot_time()).strftime('%Y-%m-%d %H:%M:%S'),
- }
- except Exception as e:
- log.error(f"Error gathering system information: {e}")
- sys_info = {'Error': 'Failed to gather system information'}
- try:
- with open(os.path.join(DUMP_DIR, "SystemRam_Info.txt"), "w", encoding="utf-8") as file:
- for key, value in sys_info.items():
- file.write(f"{key}: {value}\n")
- except Exception as e:
- log.error(f"Error writing system info to file: {e}")
- log.info("System Information saved to SystemRam_Info.txt")
-
-
-# Memory Dump
-def memory_dump():
- """
- Performs a memory dump of the current process and saves it to a file.
- """
- log.info("Creating basic memory dump scan...")
- pid = os.getpid()
-
- try:
- process = psutil.Process(pid)
- dump_path = os.path.join(DUMP_DIR, "Ram_Dump.txt")
- with open(dump_path, "wb") as dump_file:
- total_size = 0
-
- # Disk space safety check
- required_space = LIMIT_FILE_SIZE * 1024 * 1024 * SAFETY_MARGIN
- free_space = psutil.disk_usage(DUMP_DIR).free
- if free_space < required_space:
- log.error(f"Not enough disk space. Need at least {required_space / (1024 ** 2):.2f} MiB")
- return
-
- for mem_region in process.memory_maps(grouped=False):
- if 'r' not in mem_region.perms:
- continue
-
- try:
- start, end = (int(addr, 16) for addr in mem_region.addr.split('-')) \
- if '-' in mem_region.addr else (int(mem_region.addr, 16),
- int(mem_region.addr, 16) + mem_region.rss)
- except Exception as e:
- log.warning(f"Invalid address format '{mem_region.addr}': {e}")
- continue
-
- region_metadata = {
- ' Start Address': hex(start),
- ' End Address': hex(end),
- ' Region Size (bytes)': end - start,
- ' RSS (bytes)': mem_region.rss,
- ' Permissions': mem_region.perms,
- ' Path': mem_region.path,
- ' Index': mem_region.index,
- }
-
- try:
- metadata_str = "Memory Region Metadata:\n" + "\n".join(
- f"{key}: {value}" for key, value in region_metadata.items()) + "\n\n"
- metadata_bytes = metadata_str.encode()
- if (total_size + len(metadata_bytes) > LIMIT_FILE_SIZE * 1024 * 1024) and (LIMIT_FILE_SIZE != 0):
- dump_file.write(f"Truncated: file exceeded {LIMIT_FILE_SIZE} MiB limit.\n".encode())
- break
- dump_file.write(metadata_bytes)
- total_size += len(metadata_bytes)
- except Exception as e:
- log.error(f"Error writing memory region metadata: {e}")
-
- except psutil.Error as e:
- log.error(f"Error accessing process memory: {e}")
- except Exception as e:
- log.error(f"General memory dump error: {e}")
-
- log.info("Memory scan saved to Ram_Dump.txt")
-
-
-# Main function to run all tasks
-@log.function
-def main():
- """
- Executes all memory diagnostics and collection routines.
- """
- try:
- os.makedirs(DUMP_DIR, exist_ok=True)
- except Exception as e:
- log.critical(f"Failed to create dump directory '{DUMP_DIR}': {e}")
- return
-
- log.info("Starting system memory collection tasks...")
- capture_ram_snapshot()
- gather_system_info()
- memory_dump()
- log.info("All tasks completed [dump_memory.py].")
-
-
-if __name__ == "__main__":
- main()
diff --git a/CODE/encrypted_drive_audit.py b/CODE/encrypted_drive_audit.py
deleted file mode 100644
index 290281bf..00000000
--- a/CODE/encrypted_drive_audit.py
+++ /dev/null
@@ -1,106 +0,0 @@
-import datetime
-import getpass
-import os
-import platform
-import shutil
-import subprocess
-from pathlib import Path
-
-from logicytics import check, log
-
-
-def now_iso():
- return datetime.datetime.now().astimezone().isoformat()
-
-
-def run_cmd(cmd):
- log.debug(f"Running command: {cmd}")
- try:
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
- if proc.returncode == 0:
- log.debug(f"Command succeeded: {cmd}")
- else:
- log.warning(f"Command returned {proc.returncode}: {cmd}")
- return proc.stdout.strip(), proc.stderr.strip(), proc.returncode
- except FileNotFoundError:
- log.error(f"Command not found: {cmd[0]}")
- return "", "not found", 127
- except subprocess.TimeoutExpired:
- log.error(f"Command timed out: {cmd}")
- return "", "timeout", 124
-
-
-def have(cmd_name):
- exists = shutil.which(cmd_name) is not None
- log.debug(f"Check if '{cmd_name}' exists: {exists}")
- return exists
-
-
-def get_mountvol_output():
- log.info("Gathering mounted volumes via mountvol")
- out, err, _ = run_cmd(["mountvol"])
- if not out:
- return err
- lines = out.splitlines()
- filtered = []
- keep = False
- for line in lines:
- if line.strip().startswith("\\\\?\\Volume"):
- keep = True
- if keep:
- filtered.append(line)
- return "\n".join(filtered)
-
-
-def main():
- script_dir = Path(__file__).resolve().parent
- report_path = script_dir / "win_encrypted_volume_report.txt"
- log.info(f"Starting encrypted volume analysis, report will be saved to {report_path}")
-
- with report_path.open("w", encoding="utf-8") as f:
- f.write("=" * 80 + "\n")
- f.write("Windows Encrypted Volume Report\n")
- f.write("=" * 80 + "\n")
- f.write(f"Generated at: {now_iso()}\n")
- f.write(f"User: {getpass.getuser()}\n")
- f.write(f"IsAdmin: {check.admin()}\n")
- f.write(f"Hostname: {platform.node()}\n")
- f.write(f"Version: {platform.platform()}\n\n")
-
- # Logical drives
- log.info("Gathering logical volumes via wmic")
- f.write("Logical Volumes (wmic):\n")
- out, err, _ = run_cmd(["wmic", "logicaldisk", "get",
- "DeviceID,DriveType,FileSystem,FreeSpace,Size,VolumeName"])
- f.write(out + "\n" + err + "\n\n")
-
- # Mounted volumes
- f.write("Mounted Volumes (mountvol):\n")
- f.write(get_mountvol_output() + "\n\n")
-
- # BitLocker status
- f.write("=" * 80 + "\nBitLocker Status\n" + "=" * 80 + "\n")
- if have("manage-bde"):
- log.info("Checking BitLocker status with manage-bde")
- for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
- path = f"{letter}:"
- if os.path.exists(f"{path}\\"):
- out, err, _ = run_cmd(["manage-bde", "-status", path])
- f.write(f"Drive {path}:\n{out}\n{err}\n\n")
- else:
- log.warning("manage-bde not found")
-
- if have("powershell"):
- log.info("Checking BitLocker status with PowerShell")
- f.write("PowerShell Get-BitLockerVolume:\n")
- ps_cmd = r"Get-BitLockerVolume | Format-List *"
- out, err, _ = run_cmd(["powershell", "-NoProfile", "-Command", ps_cmd])
- f.write(out + "\n" + err + "\n\n")
- else:
- log.warning("PowerShell not available")
-
- log.info(f"Report successfully saved to {report_path}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/CODE/event_log.py b/CODE/event_log.py
deleted file mode 100644
index a27d561c..00000000
--- a/CODE/event_log.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import os
-import shutil
-import threading
-
-import wmi # Import the wmi library
-
-from logicytics import log
-
-
-@log.function
-def parse_event_logs(log_type: str, output_file: str):
- """
- Parses Windows event logs of a specified type and writes them to an output file using WMI.
-
- Args:
- log_type (str): The type of event log to parse (e.g., 'Security', 'Application', 'System').
- output_file (str): The file path where the parsed event logs will be written.
-
- Raises:
- wmi.x_wmi: If there is a WMI-specific error during event log retrieval.
- Exception: If there is a general error during file operations or log parsing.
-
- Notes:
- - Requires administrative privileges to access Windows event logs.
- - Retrieves all events for the specified log type using a WMI query.
- - Writes event details including category, timestamp, source, event ID, type, and data.
- - Logs informational and debug messages during the parsing process.
- """
- log.info(f"Parsing {log_type} events (Windows Events) and writing to {output_file}, this may take a while...")
- try:
- # Initialize WMI connection
- c = wmi.WMI()
-
- # Query based on log_type ('Security', 'Application', or 'System')
- query = f"SELECT * FROM Win32_NTLogEvent WHERE Logfile = '{log_type}'"
- log.debug(f"Executing WMI query: {query}")
-
- # Open the output file for writing
- with open(output_file, 'w') as f:
- events = c.query(query)
- f.write(f"Total records: {len(events)}\n\n")
- log.debug(f"Number of events retrieved: {len(events)}")
- for event in events:
- event_data = {
- 'Event Category': event.Category,
- 'Time Generated': event.TimeGenerated,
- 'Source Name': event.SourceName,
- 'Event ID': event.EventCode,
- 'Event Type': event.Type,
- 'Event Data': event.InsertionStrings
- }
- f.write(str(event_data) + '\n\n')
-
- log.info(f"{log_type} events (Windows Events) have been written to {output_file}")
- except wmi.x_wmi as err:
- log.error(f"Error opening or reading the event log: {err}")
- except Exception as err:
- log.error(f"Fatal issue: {err}")
-
-
-if __name__ == "__main__":
- try:
- if os.path.exists('event_logs'):
- shutil.rmtree('event_logs')
- os.mkdir('event_logs')
- except Exception as e:
- log.error(f"Fatal issue: {e}")
- exit(1)
-
- threads = []
- threads_items = [('Security', 'event_logs/Security_events.txt'),
- ('Application', 'event_logs/App_events.txt'),
- ('System', 'event_logs/System_events.txt')]
-
- for log_type_main, output_file_main in threads_items:
- thread = threading.Thread(target=parse_event_logs, args=(log_type_main, output_file_main))
- thread.daemon = True # Don't hang if main thread exits
- threads.append(thread)
- thread.start()
- for thread in threads:
- thread.join(timeout=600) # Wait max 10 minutes per thread
- if thread.is_alive():
- log.error(f"Thread for {thread.name} timed out (10 minutes)!")
diff --git a/CODE/log_miner.py b/CODE/log_miner.py
deleted file mode 100644
index eff74f47..00000000
--- a/CODE/log_miner.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import subprocess
-
-from logicytics import log
-
-
-@log.function
-def backup_windows_logs():
- """
- Backs up Windows system logs to a CSV file using PowerShell.
-
- This function retrieves system logs and exports them to a CSV file named 'Logs_backup.csv'.
- It uses PowerShell's Get-EventLog cmdlet to collect system logs and Export-Csv to save them.
-
- The function handles potential errors during log backup and logs the operation's outcome.
- If the backup fails, an error message is logged without raising an exception.
-
- Returns:
- None
-
- Raises:
- No explicit exceptions are raised; errors are logged instead.
-
- Example:
- When called, this function will create a 'Logs_backup.csv' file
- containing all system event log entries.
- """
- try:
- log_type = "System"
- backup_file = "Logs_backup.csv"
- # Construct the PowerShell command as a single string
- cmd = f'Get-EventLog -LogName "{log_type}" | Export-Csv -Path "{backup_file}" -NoTypeInformation'
-
- # Use subprocess.Popen to Execute the PowerShell command
- process = subprocess.Popen(
- ["powershell.exe", "-Command", cmd],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- universal_newlines=True,
- )
- stdout, stderr = process.communicate(input=cmd)
-
- if process.returncode != 0:
- log.error(f"Failed to backup logs: {stderr.strip()}")
-
- log.info(f"Windows logs backed up to {backup_file}")
- except Exception as e:
- log.error(f"Failed to backup logs: {str(e)}")
-
- log.info("Log Miner completed.")
-
-
-if __name__ == "__main__":
- backup_windows_logs()
diff --git a/CODE/logicytics/Checks.py b/CODE/logicytics/Checks.py
deleted file mode 100644
index e3ad3c9d..00000000
--- a/CODE/logicytics/Checks.py
+++ /dev/null
@@ -1,92 +0,0 @@
-from __future__ import annotations
-
-import ctypes
-import os.path
-import subprocess
-import zipfile
-
-from logicytics.Execute import Execute
-
-
-class Check:
- @staticmethod
- def admin() -> bool:
- """
- Check if the current user has administrative privileges.
-
- Returns:
- bool: True if the user is an admin, False otherwise.
- """
- try:
- return ctypes.windll.shell32.IsUserAnAdmin()
- except AttributeError:
- return False
-
- @staticmethod
- def execution_policy() -> bool:
- """
- Check if PowerShell execution policy is set to unrestricted.
-
- Returns:
- bool: True if execution policy is unrestricted, False otherwise.
-
- Note:
- This method requires PowerShell to be available on the system.
- """
- try:
- result = subprocess.run(
- ["powershell", "-Command", "Get-ExecutionPolicy"],
- capture_output=True,
- text=True,
- timeout=5 # Don't hang forever
- )
- return result.returncode == 0 and result.stdout.strip().lower() == "unrestricted"
- except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
- exit(f"Failed to check execution policy: {e}")
-
- @staticmethod
- def uac() -> bool:
- """
- Check if User Account Control (UAC) is enabled on the system.
-
- This function runs a PowerShell command to retrieve the value of the EnableLUA registry key,
- which indicates whether UAC is enabled. It then returns True if UAC is enabled, False otherwise.
-
- Returns:
- bool: True if UAC is enabled, False otherwise.
- """
- value = Execute.command(
- r"powershell (Get-ItemProperty HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System).EnableLUA"
- )
- return int(value.strip("\n")) == 1
-
- @staticmethod
- def sys_internal_zip() -> str:
- """
- Extracts the SysInternal_Suite zip file if it exists and is not ignored.
-
- This function checks if the SysInternal_Suite zip file exists and if it is not ignored.
- If the zip file exists and is not ignored,
- it extracts its contents to the SysInternal_Suite directory.
- If the zip file is ignored, it prints a message indicating that it is skipping the extraction.
-
- Raises:
- Exception: If there is an error during the extraction process. The error message is printed to the console and the program exits.
- """
- try:
- ignore_file = os.path.exists("../SysInternal_Suite/.sys.ignore")
- zip_file = os.path.exists("../SysInternal_Suite/SysInternal_Suite.zip")
-
- if zip_file and not ignore_file:
- with zipfile.ZipFile(
- "../SysInternal_Suite/SysInternal_Suite.zip"
- ) as zip_ref:
- zip_ref.extractall("SysInternal_Suite")
- return "SysInternal_Suite zip extracted"
-
- elif ignore_file:
- return "Found .sys.ignore file, skipping SysInternal_Suite zip extraction"
-
- return None
- except Exception as err:
- exit(f"Failed to unzip SysInternal_Suite: {err}")
diff --git a/CODE/logicytics/Config.py b/CODE/logicytics/Config.py
deleted file mode 100644
index 6e0bc187..00000000
--- a/CODE/logicytics/Config.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import configparser
-import os
-
-
-def __config_data() -> tuple[str, str, list[str], bool, str]:
- """
- Retrieves configuration data from the 'config.ini' file.
-
- If the configuration file is not found in any of these locations,
- the program exits with an error message.
-
- Returns:
- tuple[str, str, list[str], bool]: A tuple containing:
- - Log level (str): Either "DEBUG" or "INFO"
- - Version (str): System version from configuration
- - Files (list[str]): List of files specified in configuration
- - Delete old logs (bool): Flag indicating whether to delete old log files
- - config itself
-
- Raises:
- SystemExit: If the 'config.ini' file cannot be found in any of the attempted locations
- """
-
- def _config_path() -> str:
- configs_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.ini")
-
- if os.path.exists(configs_path):
- return configs_path
- exit("The config.ini file is not found in the expected location.")
-
- config_local = configparser.ConfigParser()
- path = _config_path()
- config_local.read(path)
-
- log_using_debug = config_local.getboolean("Settings", "log_using_debug")
- delete_old_logs = config_local.getboolean("Settings", "delete_old_logs")
- version = config_local.get("System Settings", "version")
- files = config_local.get("System Settings", "files").split(", ")
-
- log_using_debug = "DEBUG" if log_using_debug else "INFO"
-
- return log_using_debug, version, files, delete_old_logs, config_local
-
-
-# Check if the script is being run directly, if not, set up the library
-if __name__ == '__main__':
- exit("This is a library, Please import rather than directly run.")
-DEBUG, VERSION, CURRENT_FILES, DELETE_LOGS, config = __config_data()
diff --git a/CODE/logicytics/Execute.py b/CODE/logicytics/Execute.py
deleted file mode 100644
index 85de3f8b..00000000
--- a/CODE/logicytics/Execute.py
+++ /dev/null
@@ -1,96 +0,0 @@
-from __future__ import annotations
-
-import subprocess
-from subprocess import CompletedProcess
-
-
-class Execute:
- @classmethod
- def script(cls, script_path: str) -> list[tuple[str, str]] | None:
- """
- Execute a script file based on its file extension.
-
- Executes Python and PowerShell scripts with different handling mechanisms.
- For Python scripts, runs the script and returns None.
- For PowerShell scripts, first unblocks the script and then executes it,
- returning a list of message-ID pairs.
-
- Parameters:
- script_path (str): Path to the script file to be executed.
-
- Returns:
- list[list[str]] | None: A list of message-ID pairs for PowerShell scripts,
- or None for Python scripts.
-
- Raises:
- Potential subprocess-related exceptions during script execution.
- """
- if script_path.endswith(".py"):
- cls.__run_python_script(script_path)
- return None
- else:
- if script_path.endswith(".ps1"):
- cls.__unblock_ps1_script(script_path)
- return cls.__run_other_script(script_path)
-
- @staticmethod
- def command(command: str) -> str:
- """
- Runs a command in a subprocess and returns the output as a string.
-
- Parameters:
- command (str): The command to be executed.
-
- Returns:
- CompletedProcess.stdout: The output of the command as a string.
- """
- process = subprocess.run(command, capture_output=True, text=True)
- return process.stdout
-
- @staticmethod
- def __unblock_ps1_script(script: str):
- """
- Unblocks and runs a PowerShell (.ps1) script.
- Parameters:
- script (str): The path of the PowerShell script.
- Returns:
- None
- """
- try:
- unblock_command = f'powershell.exe -Command "Unblock-File -Path {script}"'
- subprocess.run(unblock_command, shell=False, check=True)
- except Exception as err:
- exit(f"Failed to unblock script: {err}")
-
- @staticmethod
- def __run_python_script(script: str):
- """
- Runs a Python (.py) script.
- Parameters:
- script (str): The path of the Python script.
- Returns:
- None
- """
- result = subprocess.Popen(
- ["python", script], stdout=subprocess.PIPE
- ).communicate()[0]
- # LEAVE AS PRINT
- print(result.decode())
-
- @classmethod
- def __run_other_script(cls, script: str) -> list[list[str]]:
- """
- Runs a script with other extensions and logs output based on its content.
- Parameters:
- script (str): The path of the script.
- Returns:
- None
- """
- result = cls.command(f"powershell.exe -File {script}")
- lines = result.splitlines()
- messages = []
- for line in lines:
- if ":" in line:
- id_part, message_part = line.split(":", 1)
- messages.append([message_part.strip(), id_part.strip()])
- return messages
diff --git a/CODE/logicytics/FileManagement.py b/CODE/logicytics/FileManagement.py
deleted file mode 100644
index a2633feb..00000000
--- a/CODE/logicytics/FileManagement.py
+++ /dev/null
@@ -1,230 +0,0 @@
-from __future__ import annotations
-from __future__ import annotations
-
-import hashlib
-import os.path
-import shutil
-import subprocess
-import zipfile
-from datetime import datetime
-
-
-class FileManagement:
- @staticmethod
- def open_file(file: str, use_full_path: bool = False) -> str | None:
- """
- Opens a specified file using its default application in a cross-platform manner.
- Args:
- file (str): The path to the file to be opened.
- use_full_path (bool): Whether to use the full path of the file or not.
- Returns:
- None
- """
- if not file == "":
- if use_full_path:
- current_dir = os.path.dirname(os.path.abspath(__file__))
- file_path = os.path.join(current_dir, file)
- else:
- file_path = os.path.realpath(file)
- try:
- subprocess.run(["start", file_path], shell=False)
- except Exception as e:
- return f"Error opening file: {e}"
- return None
-
- @staticmethod
- def mkdir():
- """
- Creates the necessary directories for storing logs, and data.
-
- This method ensures the existence of specific directory structures used by the application, including:
- - Log directories for general, debug, and performance logs
- - Data directories for storing hashes and zip files
-
- The method uses `os.makedirs()` with `exist_ok=True` to create directories without raising an error if they already exist.
-
- Returns:
- None: No return value. Directories are created as a side effect.
- """
- os.makedirs("../ACCESS/LOGS/", exist_ok=True)
- os.makedirs("../ACCESS/LOGS/DEBUG", exist_ok=True)
- os.makedirs("../ACCESS/LOGS/PERFORMANCE", exist_ok=True)
- os.makedirs("../ACCESS/DATA/Hashes", exist_ok=True)
- os.makedirs("../ACCESS/DATA/Zip", exist_ok=True)
-
- class Zip:
- """
- A class to handle zipping files, generating SHA256 hashes, and moving files.
-
- Methods:
- __get_files_to_zip(path: str) -> list:
- Returns a list of files to be zipped, excluding certain file types and names.
-
- __create_zip_file(path: str, files: list, filename: str):
- Creates a zip file from the given list of files.
-
- __remove_files(path: str, files: list):
- Removes the specified files from the given path.
-
- __generate_sha256_hash(filename: str) -> str:
- Generates a SHA256 hash for the specified zip file.
-
- __write_hash_to_file(filename: str, sha256_hash: str):
- Writes the SHA256 hash to a file.
-
- __move_files(filename: str):
- Moves the zip file and its hash file to designated directories.
-
- and_hash(cls, path: str, name: str, flag: str) -> tuple | str:
- Zips files, generates a SHA256 hash, and moves the files.
- """
-
- @staticmethod
- def __get_files_to_zip(path: str) -> list:
- """
- Returns a list of files and directories to be zipped, excluding certain file types and names.
-
- Args:
- path (str): The directory path to search for files.
-
- Returns:
- list: A list of file and directory names to be zipped.
- """
- excluded_extensions = (".py", ".exe", ".bat", ".ps1", ".pkl", ".pth")
- excluded_prefixes = (
- "config.ini",
- "SysInternal_Suite",
- "__pycache__",
- "logicytics",
- "vulnscan",
- )
-
- return [
- f
- for f in os.listdir(path)
- if not f.endswith(excluded_extensions)
- and not f.startswith(excluded_prefixes)
- ]
-
- @staticmethod
- def __create_zip_file(path: str, files: list, filename: str):
- """
- Creates a zip file from the given list of files.
-
- Args:
- path (str): The directory path containing the files.
- files (list): A list of file names to be zipped.
- filename (str): The name of the output zip file.
-
- Returns:
- None
- """
-
- def ignore_files(files_func):
- for root, _, file_func in os.walk(os.path.join(path, files_func)):
- for f in file_func:
- zip_file.write(
- os.path.join(root, f),
- os.path.relpath(os.path.join(root, f), path),
- )
-
- with zipfile.ZipFile(f"{filename}.zip", "w") as zip_file:
- for file in files:
- if os.path.isdir(os.path.join(path, file)):
- ignore_files(file)
- else:
- zip_file.write(os.path.join(path, file))
-
- @staticmethod
- def __remove_files(path: str, files: list) -> str | None:
- """
- Removes the specified files from the given path.
-
- Args:
- path (str): The directory path containing the files.
- files (list): A list of file names to be removed.
-
- Returns:
- None or str: Returns an error message if an exception occurs.
- """
- for file in files:
- try:
- shutil.rmtree(os.path.join(path, file))
- except OSError:
- os.remove(os.path.join(path, file))
- except Exception as e:
- return f"Error: {e}"
- return None
-
- @staticmethod
- def __generate_sha256_hash(filename: str) -> str:
- """
- Generates a SHA256 hash for the specified zip file.
-
- Args:
- filename (str): The name of the zip file.
-
- Returns:
- str: The SHA256 hash of the zip file.
- """
- with open(f"{filename}.zip", "rb") as zip_file:
- zip_data = zip_file.read()
- return hashlib.sha256(zip_data).hexdigest()
-
- @staticmethod
- def __write_hash_to_file(filename: str, sha256_hash: str):
- """
- Writes the SHA256 hash to a file.
-
- Args:
- filename (str): The name of the hash file.
- sha256_hash (str): The SHA256 hash to be written.
-
- Returns:
- None
- """
- with open(f"{filename}.hash", "w") as hash_file:
- hash_file.write(sha256_hash)
-
- @staticmethod
- def __move_files(filename: str):
- """
- Moves the zip file and its hash file to designated directories.
-
- Args:
- filename (str): The name of the files to be moved.
-
- Returns:
- None
- """
- shutil.move(f"{filename}.zip", "../ACCESS/DATA/Zip")
- shutil.move(f"{filename}.hash", "../ACCESS/DATA/Hashes")
-
- @classmethod
- def and_hash(cls, path: str, name: str, flag: str) -> tuple | str:
- """
- Zips files, generates a SHA256 hash, and moves the files.
-
- Args:
- path (str): The directory path containing the files.
- name (str): The base name for the output files.
- flag (str): A flag to be included in the output file names.
-
- Returns:
- tuple or str: A tuple containing success messages or an error message.
- """
- time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- filename = f"Logicytics_{name}_{flag}_{time}"
- files_to_zip = cls.__get_files_to_zip(path)
- cls.__create_zip_file(path, files_to_zip, filename)
- check = cls.__remove_files(path, files_to_zip)
- if isinstance(check, str):
- return check
- else:
- sha256_hash = cls.__generate_sha256_hash(filename)
- cls.__write_hash_to_file(filename, sha256_hash)
- cls.__move_files(filename)
- return (
- f"Zip file moved to ../ACCESS/DATA/Zip/{filename}.zip",
- f"SHA256 Hash file moved to ../ACCESS/DATA/Hashes/{filename}.hash",
- )
diff --git a/CODE/logicytics/Flag.py b/CODE/logicytics/Flag.py
deleted file mode 100644
index 0a4367f0..00000000
--- a/CODE/logicytics/Flag.py
+++ /dev/null
@@ -1,820 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import difflib
-import gzip
-import json
-import os
-from collections import Counter
-from datetime import datetime
-
-from logicytics.Config import config
-from logicytics.Logger import log
-
-# Check if the script is being run directly, if not, set up the library
-if __name__ == "__main__":
- exit("This is a library, Please import rather than directly run.")
-else:
- # Save user preferences?
- SAVE_PREFERENCES = config.getboolean("Settings", "save_preferences")
- # Debug mode for Sentence Transformer
- DEBUG_MODE = config.getboolean(
- "Flag Settings", "model_debug"
- ) # Debug mode for Sentence Transformer
- # File for storing user history data
- HISTORY_FILE = os.path.join(
- os.path.dirname(os.path.abspath(__file__)), "User_History.json.gz"
- ) # User history file
- # Minimum accuracy threshold for flag suggestions
- MIN_ACCURACY_THRESHOLD = float(
- config.get("Flag Settings", "accuracy_min")
- ) # Minimum accuracy threshold for flag suggestions
- if not 1 <= MIN_ACCURACY_THRESHOLD <= 99:
- raise ValueError("accuracy_min must be between 1 and 99")
-
-
-class Flag:
- class Match:
- @staticmethod
- def __get_sim(user_input: str, all_descriptions: list[str]) -> list[float]:
- """
- Compute cosine similarity between user input and flag descriptions using a Sentence Transformer model.
-
- This method encodes the user input and historical flag descriptions into embeddings and calculates their cosine similarities. It handles model loading, logging configuration, and error handling for the embedding process.
-
- Parameters:
- user_input (str): The current user input to match against historical descriptions
- all_descriptions (list[str]): A list of historical flag descriptions to compare
-
- Returns:
- list[float]: A list of similarity scores between the user input and each historical description
-
- Raises:
- SystemExit: If there is an error loading the specified Sentence Transformer model
-
- Notes:
- - Uses the model specified in the configuration file
- - Configures logging based on the global DEBUG_MODE setting
- - Converts embeddings to tensors for efficient similarity computation
- """
- # Encode the current user input and historical inputs
- from sentence_transformers import SentenceTransformer, util
- import logging # Suppress logging messages from Sentence Transformer due to verbosity
-
- # Set the logging level based on the debug mode, either DEBUG or ERROR (aka only important messages)
- if DEBUG_MODE:
- logging.getLogger("sentence_transformers").setLevel(logging.DEBUG)
- else:
- logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
-
- try:
- MODEL = SentenceTransformer(config.get("Flag Settings", "model_to_use"))
- except Exception as e:
- log.critical(f"Error: {e}")
- log.error("Please check the model name in the config file.")
- log.error(
- f"Model name {config.get('Flag Settings', 'model_to_use')} may not be valid."
- )
- exit(1)
-
- user_embedding = MODEL.encode(
- user_input, convert_to_tensor=True, show_progress_bar=DEBUG_MODE
- )
- historical_embeddings = MODEL.encode(
- all_descriptions, convert_to_tensor=True, show_progress_bar=DEBUG_MODE
- )
-
- # Compute cosine similarities
- similarities = (
- util.pytorch_cos_sim(user_embedding, historical_embeddings)
- .squeeze(0)
- .tolist()
- )
- return similarities
-
- @classmethod
- def __suggest_flags_based_on_history(cls, user_input: str) -> list[str]:
- """
- Suggests flags based on historical data and similarity to the current input.
-
- This method analyzes historical user interactions to recommend relevant flags when preferences for saving history are enabled. It uses semantic similarity to find the most contextually related flags from past interactions.
-
- Parameters:
- user_input (str): The current input for which suggestions are needed.
-
- Returns:
- list[str]: A list of suggested flags derived from historical interactions, filtered by similarity threshold.
-
- Notes:
- - Returns an empty list if history saving is disabled or no interaction history exists
- - Uses cosine similarity with a minimum threshold of 0.3 to filter suggestions
- - Limits suggestions to top 3 most similar historical inputs
- - Removes duplicate flag suggestions
- """
- if not SAVE_PREFERENCES:
- return []
- history_data = cls.load_history()
- if not history_data or "interactions" not in history_data:
- return []
-
- interactions = history_data["interactions"]
- all_descriptions = []
- all_flags = []
-
- # Combine all flags and their respective user inputs
- for flag, details in interactions.items():
- all_flags.extend([flag] * len(details))
- all_descriptions.extend([detail["user_input"] for detail in details])
-
- # Encode the current user input and historical inputs
- # Compute cosine similarities
- similarities = cls.__get_sim(user_input, all_descriptions)
-
- # Find the top 3 most similar historical inputs
- top_indices = sorted(
- range(len(similarities)), key=lambda i: similarities[i], reverse=True
- )[:3]
- suggested_flags = [
- all_flags[i] for i in top_indices if similarities[i] > 0.3
- ]
-
- # Remove duplicates and return suggestions
- return list(dict.fromkeys(suggested_flags))
-
- @classmethod
- def generate_summary_and_graph(cls):
- """
- Generates a comprehensive summary and visualization of user interaction history with command-line flags.
-
- This method processes historical interaction data, computes statistical insights, and creates a bar graph representing flag usage frequency. It performs the following key tasks:
- - Loads historical interaction data from a compressed file
- - Calculates and prints detailed statistics for each flag
- - Generates a horizontal bar graph of flag usage counts
- - Saves the graph visualization to a PNG file
-
- Parameters:
- cls (_Match): The class instance containing historical data methods
-
- Raises:
- SystemExit: If no history data file is found
- FileNotFoundError: If unable to save the graph in default locations
-
- Side Effects:
- - Prints detailed interaction summary to console
- - Saves flag usage graph as a PNG image
- - Uses matplotlib to create visualization
-
- Notes:
- - Currently in beta stage of development
- - Requires matplotlib for graph generation
- - Attempts to save graph in multiple predefined directory paths
- """
- # Load the decompressed history data using the load_history function
- import matplotlib.pyplot as plt
-
- if not os.path.exists(HISTORY_FILE):
- exit("No history data found.")
-
- history_data = cls.load_history()
-
- # Extract interactions and flag usage count
- interactions = history_data["interactions"]
- flags_usage = history_data["flags_usage"]
-
- # Summary of flag usage
- total_interactions = sum(flags_usage.values())
-
- log.info(
- "--------------------------------------------------\n Flag guessing statistics:\n --------------------------------------------------"
- )
-
- for flag, details in interactions.items():
- accuracies = [detail["accuracy"] for detail in details]
- device_names = [detail["device_name"] for detail in details]
- user_inputs = [detail["user_input"] for detail in details]
-
- average_accuracy = sum(accuracies) / len(accuracies)
- most_common_device = Counter(device_names).most_common(1)[0][0]
- average_user_input = Counter(user_inputs).most_common(1)[0][0]
- log.info(f"""Flag: {flag}
- Average Accuracy: {average_accuracy:.2f}%
- Most Common Device Name: {most_common_device}
- Most Common User Input: {average_user_input}""")
-
- # Print the summary to the console
- log.info(
- "--------------------------------------------------\n User Interaction Summary:\n --------------------------------------------------"
- )
-
- log.info(
- f"Total Interactions with the match flag feature: {total_interactions}"
- )
- flag_usage_summary = "\n".join(
- [
- f" {flag}: {count} times"
- for flag, count in flags_usage.items()
- ]
- )
- log.info(f"Flag Usage Summary:\n{flag_usage_summary}")
-
- # Generate the graph for flag usage
- flags = list(flags_usage.keys())
- counts = list(flags_usage.values())
-
- plt.figure(figsize=(10, 6))
- plt.barh(flags, counts, color="skyblue")
- plt.xlabel("Usage Count")
- plt.title("Flag Usage Frequency")
- plt.gca().invert_yaxis() # Invert y-axis for better readability
- plt.subplots_adjust(
- left=0.2, right=0.8, top=0.9, bottom=0.1
- ) # Adjust layout
-
- # Save and display the graph
- try:
- plt.savefig("../ACCESS/DATA/Flag_usage_summary.png")
- log.info(
- "Flag Usage Summary Graph saved to 'ACCESS/DATA/Flag_usage_summary.png'"
- )
- except FileNotFoundError:
- try:
- plt.savefig("../../ACCESS/DATA/Flag_usage_summary.png")
- log.info(
- "Flag Usage Summary Graph saved to 'ACCESS/DATA/Flag_usage_summary.png'"
- )
- except FileNotFoundError:
- plt.savefig("Flag_usage_summary.png")
- log.info(
- "Flag Usage Summary Graph saved in current working directory as 'Flag_usage_summary.png'"
- )
-
- @staticmethod
- def load_history() -> dict:
- """
- Load user interaction history from a gzipped JSON file.
-
- This method attempts to read and parse historical interaction data from a compressed JSON file. If the file is not found, it returns an empty history structure with an empty interactions dictionary and a zero-initialized flags usage counter.
-
- Returns:
- dict: A dictionary containing:
- - 'interactions': A dictionary of past user interactions
- - 'flags_usage': A Counter object tracking flag usage frequencies
-
- Raises:
- json.JSONDecodeError: If the JSON file is malformed
- gzip.BadGzipFile: If the gzipped file is corrupted
- """
- try:
- with gzip.open(
- HISTORY_FILE, "rt", encoding="utf-8"
- ) as f: # Use 'rt' mode for text read
- return json.load(f)
- except FileNotFoundError:
- return {"interactions": {}, "flags_usage": Counter()}
-
- @staticmethod
- def save_history(history_data: dict):
- """
- Save user interaction history to a gzipped JSON file.
-
- This method writes the user history to a compressed JSON file only if saving preferences are enabled.
- The history is saved with an indentation of 4 spaces for readability.
-
- Parameters:
- history_data (dict[str, any]): A dictionary containing user interaction history data to be saved.
-
- Notes:
- - Saves only if SAVE_PREFERENCES is True
- - Uses gzip compression to reduce file size
- - Writes in UTF-8 encoding
- - Indents JSON for human-readable format
- """
- if SAVE_PREFERENCES:
- with gzip.open(
- HISTORY_FILE, "wt", encoding="utf-8"
- ) as f: # Use 'wt' mode for text write
- json.dump(history_data, f, indent=4)
-
- @classmethod
- def update_history(cls, user_input: str, matched_flag: str, accuracy: float):
- """
- Update the user interaction history with details of a matched flag.
-
- This method records user interactions with flags, including timestamp, input, match accuracy,
- and device information. It only updates history if save preferences are enabled.
-
- Parameters:
- user_input (str): The original input text provided by the user.
- matched_flag (str): The flag that was successfully matched to the user input.
- accuracy (float): The similarity/match accuracy score for the flag.
-
- Side Effects:
- - Modifies the history JSON file by adding a new interaction entry
- - Increments the usage count for the matched flag
- - Requires write access to the history file
-
- Notes:
- - Skips history update if SAVE_PREFERENCES is False
- - Creates new flag entries in history if they do not exist
- - Uses current timestamp and logged-in user's device name
- """
- if not SAVE_PREFERENCES:
- return
- history_data = cls.load_history()
- matched_flag = matched_flag.lstrip("-")
-
- # Ensure that interactions is a dictionary (not a list)
- if not isinstance(history_data["interactions"], dict):
- history_data["interactions"] = {}
-
- # Create a new interaction dictionary
- interaction = {
- "timestamp": datetime.now().strftime("%H:%M:%S - %d/%m/%Y"),
- "user_input": user_input,
- "accuracy": accuracy,
- "device_name": os.getlogin(),
- }
-
- # Ensure the flag exists in the interactions dictionary
- if matched_flag not in history_data["interactions"]:
- history_data["interactions"][matched_flag] = []
-
- # Append the new interaction to the flag's list of interactions
- history_data["interactions"][matched_flag].append(interaction)
-
- # Ensure the flag exists in the flags_usage counter and increment it
- if matched_flag not in history_data["flags_usage"]:
- history_data["flags_usage"][matched_flag] = 0
- history_data["flags_usage"][matched_flag] += 1
-
- cls.save_history(history_data)
-
- @classmethod
- def flag(
- cls, user_input: str, flags: list[str], flag_description: list[str]
- ) -> tuple[str, float]:
- """
- Matches user input to flag descriptions using advanced semantic similarity.
-
- Computes the best matching flag based on cosine similarity between the user input and flag descriptions.
- Handles matching with a minimum accuracy threshold and provides flag suggestions from historical data
- if no direct match is found.
-
- Parameters:
- user_input (str): The input string to match against available flags.
- flags (list[str]): List of available command flags.
- flag_description (list[str]): Corresponding descriptions for each flag.
-
- Returns:
- tuple[str, float]: A tuple containing:
- - The best matched flag (or 'Nothing matched')
- - Accuracy percentage of the match (0.0-100.0)
-
- Raises:
- ValueError: If the number of flags and descriptions do not match.
-
- Side Effects:
- - Updates user interaction history
- - Prints flag suggestions if no direct match is found
- - Requires a global MIN_ACCURACY_THRESHOLD to be defined
-
- Example:
- matched_flag, accuracy = Flag.flag("show help",
- ["-h", "--verbose"],
- ["Display help", "Enable verbose output"])
- """
- if len(flags) != len(flag_description):
- raise ValueError(
- "flags and flag_description lists must be of the same length"
- )
-
- # Combine flags and descriptions for better matching context
- combined_descriptions = [
- f"{flag} {desc}" for flag, desc in zip(flags, flag_description)
- ]
-
- # Encode user input and all descriptions
- # Compute cosine similarities
- similarities = cls.__get_sim(user_input, combined_descriptions)
-
- # Find the best match
- best_index = max(range(len(similarities)), key=lambda i: similarities[i])
- best_accuracy = similarities[best_index] * 100
- best_match = (
- flags[best_index]
- if best_accuracy > MIN_ACCURACY_THRESHOLD
- else "Nothing matched"
- )
-
- # Update history
- cls.update_history(user_input, best_match, best_accuracy)
-
- # Suggest flags if accuracy is low
- if best_accuracy < MIN_ACCURACY_THRESHOLD:
- suggested_flags = cls.__suggest_flags_based_on_history(user_input)
- if suggested_flags:
- log.warning(
- f"No Flags matched so suggestions based on historical data: "
- f"{', '.join(suggested_flags)}"
- )
-
- return best_match, best_accuracy
-
- @classmethod
- def __colorify(cls, text: str, color: str) -> str:
- """
- Colorize text with ANSI color codes.
-
- Args:
- text (str): The text to colorize
- color (str): The color code ('y' for yellow, 'r' for red, 'b' for blue)
-
- Returns:
- str: The colorized text with ANSI escape codes
- """
- colors = {"y": "\033[93m", "r": "\033[91m", "b": "\033[94m"}
- RESET = "\033[0m"
- return f"{colors.get(color, '')}{text}{RESET}" if color in colors else text
-
- @classmethod
- def __available_arguments(
- cls,
- ) -> tuple[argparse.Namespace, argparse.ArgumentParser]:
- """
- Defines and parses command-line arguments for the Logicytics application.
-
- This method creates an ArgumentParser with a comprehensive set of flags for customizing the application's behavior. It supports various execution modes, debugging options, system management flags, and post-execution actions.
-
- The method handles argument parsing, provides helpful descriptions for each flag, and includes color-coded hints for user guidance. It also supports suggesting valid flags if an unknown flag is provided.
-
- Returns:
- tuple[argparse.Namespace, argparse.ArgumentParser]: A tuple containing:
- - Parsed command-line arguments (Namespace)
- - The configured argument parser object
- """
- # Define the argument parser
- parser = argparse.ArgumentParser(
- description="Logicytics, The most powerful tool for system data analysis. "
- "This tool provides a comprehensive suite of features for analyzing system data, "
- "including various modes for different levels of detail and customization.",
- allow_abbrev=False,
- )
-
- # Define Actions Flags
- parser.add_argument(
- "--default",
- action="store_true",
- help="Runs Logicytics with its default settings and scripts. "
- f"{cls.__colorify('- Recommended for most users -', 'b')}",
- )
-
- parser.add_argument(
- "--threaded",
- action="store_true",
- help="Runs Logicytics using threads, where it runs in parallel, default settings though"
- f"{cls.__colorify('- Recommended for some users -', 'b')}",
- )
-
- parser.add_argument(
- "--modded",
- action="store_true",
- help="Runs the normal Logicytics, as well as any File in the MODS directory, "
- "Used for custom scripts as well as default ones.",
- )
-
- parser.add_argument(
- "--depth",
- action="store_true",
- help="This flag will run all default script's in threading mode, "
- "as well as any clunky and huge code, which produces a lot of data "
- f"{cls.__colorify('- Will take a long time -', 'y')}",
- )
-
- parser.add_argument(
- "--nopy",
- action="store_true",
- help="Run Logicytics using all non-python scripts, "
- f"These may be {cls.__colorify('outdated', 'y')} "
- "and not the best, use only if the device doesnt have python installed.",
- )
-
- # TODO v3.6.1 -> Out of beta
- parser.add_argument(
- "--vulnscan-ai",
- action="store_true",
- help="Run's Logicytics new Sensitive data Detection AI, its a new feature that will "
- "detect any files that are out of the ordinary, and logs their path. Runs threaded."
- f"{cls.__colorify('- Beta Mode -', 'y')} "
- f"{cls.__colorify('- Will take a long time -', 'y')}",
- )
-
- parser.add_argument(
- "--minimal",
- action="store_true",
- help="Run Logicytics in minimal mode. Just bare essential scraping using only quick scripts",
- )
-
- parser.add_argument(
- "--performance-check",
- action="store_true",
- help="Run's Logicytics default while testing its performance and time, "
- "this then shows a table with the file names and time to executed. ",
- )
-
- parser.add_argument(
- "--usage",
- action="store_true",
- help="Run's script that shows and gives your local statistics, on the flags used by you",
- )
-
- # Define Side Flags
- parser.add_argument(
- "--debug",
- action="store_true",
- help="Runs the Debugger, Will check for any issues, "
- "warning etc, useful for debugging and issue reporting "
- f"{cls.__colorify('- Use to get a special log file to report the bug -', 'b')}.",
- )
-
- parser.add_argument(
- "--update",
- action="store_true",
- help="Update Logicytics from GitHub, only if you have git properly installed "
- "and the project was downloaded via git "
- f"{cls.__colorify('- Use on your own device only -', 'y')}.",
- )
-
- parser.add_argument(
- "--dev",
- action="store_true",
- help="Run Logicytics developer mod, this is only for people who want to "
- "register their contributions properly. "
- f"{cls.__colorify('- Use on your own device only -', 'y')}.",
- )
-
- # Define After-Execution Flags
- parser.add_argument(
- "--reboot",
- action="store_true",
- help="Execute Flag that will reboot the device afterward",
- )
-
- parser.add_argument(
- "--shutdown",
- action="store_true",
- help="Execute Flag that will shutdown the device afterward",
- )
-
- # Parse the arguments
- args, unknown = parser.parse_known_args()
- valid_flags = [
- action.dest for action in parser._actions if action.dest != "help"
- ]
- if unknown:
- cls.__suggest_flag(unknown[0], valid_flags)
- exit(1)
- return args, parser
-
- @staticmethod
- def __exclusivity_logic(args: argparse.Namespace) -> bool:
- """
- Validates the mutual exclusivity of command-line flags to prevent invalid flag combinations.
-
- This method checks for conflicting or mutually exclusive flags across three flag categories:
- - Special flags (reboot, shutdown, webhook)
- - Action flags (default, threaded, modded, minimal, nopy, depth, performance_check)
- - Exclusive flags (vulnscan_ai)
-
- Parameters:
- args (argparse.Namespace): Parsed command-line arguments to validate.
-
- Returns:
- bool: True if any special flags are set, False otherwise.
-
- Raises:
- SystemExit: If incompatible flag combinations are detected, with an error message describing the conflict.
- """
- special_flags = {
- args.reboot,
- args.shutdown,
- }
- action_flags = {
- args.default,
- args.threaded,
- args.modded,
- args.minimal,
- args.nopy,
- args.depth,
- args.performance_check,
- args.usage,
- }
- exclusive_flags = {
- args.vulnscan_ai,
- }
-
- if any(special_flags) and not any(action_flags):
- log.error(
- "Invalid combination of flags_list: Special and Action flag exclusivity issue."
- )
- exit(1)
-
- if any(exclusive_flags) and any(action_flags):
- log.error(
- "Invalid combination of flags_list: Exclusive and Action flag exclusivity issue."
- )
- exit(1)
-
- if any(exclusive_flags) and any(special_flags):
- log.error(
- "Invalid combination of flags_list: Exclusive and Special flag exclusivity issue."
- )
- exit(1)
-
- return any(special_flags)
-
- @staticmethod
- def __used_flags_logic(args: argparse.Namespace) -> tuple[str, ...]:
- """
- Determines the flags that are set to True in the provided command-line arguments.
-
- This method examines the arguments namespace and returns a tuple of flag names
- that have been activated. It limits the returned flags to a maximum of two to
- prevent excessive flag usage.
-
- Parameters:
- args (argparse.Namespace): Parsed command-line arguments to be analyzed.
-
- Returns:
- tuple[str, ...]: A tuple containing the names of flags set to True,
- with a maximum of two flags.
-
- Notes:
- - If no flags are set, returns an empty tuple.
- - Stops collecting flags after finding two True flags to limit complexity.
- """
- flags = {key: getattr(args, key) for key in vars(args)}
- true_keys = []
- for key, value in flags.items():
- if value:
- true_keys.append(key)
- if len(true_keys) == 2:
- break
- return tuple(true_keys)
-
- @classmethod
- def __suggest_flag(cls, user_input: str, valid_flags: list[str]):
- """
- Suggests the closest valid flag based on the user's input and provides interactive flag matching.
-
- This method handles flag suggestion through two mechanisms:
- 1. Using difflib to find close flag matches
- 2. Prompting user for a description to find the most relevant flag
-
- Args:
- user_input (str): The flag input by the user.
- valid_flags (list[str]): The list of valid flags.
-
- Behavior:
- - If a close flag match exists, suggests the closest match
- - If no close match, prompts user for a description
- - Uses the _Match.flag method to find the most accurate flag based on description
- - Prints matching results, with optional detailed output in debug mode
-
- Side Effects:
- - Prints suggestions and matched flags to console
- - Prompts user for additional input if no direct match is found
- """
- # Get the closest valid flag match based on the user's input
- closest_matches = difflib.get_close_matches(
- user_input, valid_flags, n=1, cutoff=0.6
- )
- if closest_matches:
- log.warning(
- f"Invalid flag '{user_input}', Did you mean '--{closest_matches[0].replace('_', '-')}'?"
- )
- exit(1)
-
- # Prompt the user for a description if no close match is found
- user_input_desc = input(
- "We can't find a match, Please provide a description: "
- ).lower()
-
- # Map the user-provided description to the closest valid flag
- flags_list = [f"--{flag}" for flag in valid_flags]
- descriptions_list = [f"Run Logicytics with {flag}" for flag in valid_flags]
- flag_received, accuracy_received = cls.Match.flag(
- user_input_desc, flags_list, descriptions_list
- )
- if DEBUG_MODE:
- log.info(
- f"User input: {user_input_desc}\nMatched flag: {flag_received.replace('_', '-')}\nAccuracy: {accuracy_received:.2f}%\n"
- )
- else:
- log.info(
- f"Matched flag: {flag_received.replace('_', '-')} (Accuracy: {accuracy_received:.2f}%)\n"
- )
-
- @staticmethod
- def show_help_menu(return_output: bool = False) -> str | None:
- """
- Display the help menu for the Logicytics application.
-
- This method retrieves the argument parser from the Flag class and either prints or returns the help text based on the input parameter.
-
- Args:
- return_output (bool, optional): Controls the method's behavior.
- - If True, returns the formatted help text as a string.
- - If False (default), prints the help text directly to the console.
-
- Returns:
- str or None: Help text as a string if return_output is True, otherwise None.
-
- Example:
- # Print help menu to console
- Flag.show_help_menu()
-
- # Get help menu as a string
- help_text = Flag.show_help_menu(return_output=True)
- print(help_text)
- """
- parser = Flag.__available_arguments()[1]
- if return_output:
- return parser.format_help()
- parser.print_help()
- return None
-
- @classmethod
- def data(cls) -> tuple[str, str | None]:
- """
- Handles the parsing and validation of command-line flags.
-
- This method processes command-line arguments, validates their usage, and manages flag interactions. It ensures that:
- - Only one primary action flag is used at a time
- - Special flags are handled with specific logic
- - No invalid flag combinations are permitted
- - User history is optionally updated based on preferences
-
- Returns:
- tuple[str, str | None]: A tuple containing:
- - The primary matched flag
- - An optional secondary flag (None if not applicable)
- - Exits the program if no flags are used or invalid combinations are detected
-
- Raises:
- SystemExit: Terminates the program with an error message for:
- - Invalid flag combinations
- - No flags specified
- """
- args, parser = cls.__available_arguments()
- special_flag_used = cls.__exclusivity_logic(args)
-
- used_flags = [flag for flag in vars(args) if getattr(args, flag)]
-
- if not special_flag_used and len(used_flags) > 1:
- log.error("Invalid combination of flags: Maximum 1 action flag allowed.")
- exit(1)
-
- if special_flag_used:
- used_flags = cls.__used_flags_logic(args)
- if len(used_flags) > 2:
- log.error("Invalid combination of flags: Maximum 2 flag mixes allowed.")
- exit(1)
-
- if not used_flags:
- cls.show_help_menu()
- exit(0)
-
- # Update history with the matched flag(s)
- if not SAVE_PREFERENCES:
- return None
-
- def update_data_history(matched_flag: str):
- """
- Update the usage count for a specific flag in the user's interaction history.
-
- This method increments the usage count for a given flag in the historical data. If the flag
- does not exist in the history, it initializes its count to 0 before incrementing.
-
- Parameters:
- matched_flag (str): The flag whose usage count needs to be updated.
-
- Side Effects:
- - Modifies the 'flags_usage' dictionary in the user's history file
- - Saves the updated history data to a persistent storage
-
- Example:
- update_data_history('--verbose') # Increments usage count for '--verbose' flag
- """
- history_data = cls.Match.load_history()
- # Ensure the flag exists in the flags_usage counter and increment it
- if matched_flag.replace("--", "") not in history_data["flags_usage"]:
- history_data["flags_usage"][matched_flag.replace("--", "")] = 0
- history_data["flags_usage"][matched_flag.replace("--", "")] += 1
- cls.Match.save_history(history_data)
-
- if len(used_flags) == 2:
- for flag in used_flags:
- update_data_history(flag)
- return tuple(used_flags)
- update_data_history(used_flags[0])
- return used_flags[0], None
diff --git a/CODE/logicytics/Get.py b/CODE/logicytics/Get.py
deleted file mode 100644
index 8954000e..00000000
--- a/CODE/logicytics/Get.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from __future__ import annotations
-
-import os
-
-
-class Get:
- @staticmethod
- def list_of_files(
- directory: str,
- only_extensions: list[str] = None,
- append_file_list: list[str] = None,
- exclude_files: list[str] = None,
- exclude_extensions: list[str] = None,
- exclude_dirs: list[str] = None,
- ) -> list[str]:
- """
- Retrieves a list of files in the specified directory based on given extensions and exclusion criteria.
-
- Parameters:
- directory (str): Path of the directory to search for files.
- only_extensions (list[str], optional): List of file extensions to filter. If None, retrieves all files. Defaults to None.
- append_file_list (list[str], optional): Existing list to append found filenames to. Defaults to None.
- exclude_files (list[str], optional): List of filenames to exclude from results. Defaults to None.
- exclude_extensions (list[str], optional): List of extensions to exclude from results. Defaults to None.
- exclude_dirs (list[str], optional): List of directory names to ignore. Defaults to None.
-
- Returns:
- list[str]: A list of filenames matching the specified criteria.
-
- Exclusion rules:
- - Ignores files starting with an underscore (_)
- - Skips files specified in `exclude_files`
- - Skips directories specified in `ignore_dirs`
- """
- append_file_list = append_file_list or []
- exclude_files = set(exclude_files or [])
- exclude_extensions = set(exclude_extensions or [])
- exclude_dirs = set(exclude_dirs or []) # Set for faster lookup
-
- for root, dirs, filenames in os.walk(directory):
- # Remove ignored directories from the dirs list to prevent os.walk from entering them
- dirs[:] = [d for d in dirs if d not in exclude_dirs]
-
- for filename in filenames:
- if filename.startswith("_") or filename in exclude_files:
- continue # Skip excluded files
- if any(filename.endswith(ext) for ext in exclude_extensions):
- continue # Skip excluded files
-
- file_path = os.path.relpath(os.path.join(root, filename), directory)
-
- if only_extensions is None or any(filename.endswith(ext) for ext in only_extensions):
- append_file_list.append(file_path)
-
- return append_file_list
diff --git a/CODE/logicytics/Logger.py b/CODE/logicytics/Logger.py
deleted file mode 100644
index d4078f33..00000000
--- a/CODE/logicytics/Logger.py
+++ /dev/null
@@ -1,454 +0,0 @@
-from __future__ import annotations
-
-import inspect
-import logging
-import os
-import re
-import time
-from datetime import datetime
-from typing import Type
-
-import colorlog
-
-from logicytics.Config import DEBUG
-from logicytics.FileManagement import FileManagement
-
-
-class Log:
- """
- A logging class that supports colored output using the colorlog library.
- """
-
- _instance = None
-
- def __new__(cls, *args, **kwargs):
- """
- Ensures that only one instance of the Log class is created (Singleton pattern).
-
- :param cls: The class being instantiated.
- :param args: Positional arguments.
- :param kwargs: Keyword arguments.
- :return: The single instance of the Log class.
- """
- if cls._instance is None:
- cls._instance = super(Log, cls).__new__(cls)
- cls._instance._initialized = False
- return cls._instance
-
- def __init__(self, config: dict = None):
- """
- Initializes the Log class with the given configuration.
-
- :param config: A dictionary containing configuration options.
- """
- FileManagement.mkdir() # Ensure the necessary directories are created
- if self._initialized and config is None:
- return
- self._initialized = True
- if config:
- self.reset()
- # log_path_relative variable takes Logger.py full path,
- # goes up twice then joins with ACCESS\\LOGS\\Logicytics.log
- log_path_relative = os.path.join(
- os.path.dirname(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- ),
- "ACCESS\\LOGS\\Logicytics.log",
- )
- config = config or {
- "filename": log_path_relative,
- "use_colorlog": True,
- "log_level": "INFO",
- "debug_color": "cyan",
- "info_color": "green",
- "warning_color": "yellow",
- "error_color": "red",
- "critical_color": "bold_red",
- "exception_color": "red",
- "colorlog_fmt_parameters": "%(log_color)s%(levelname)-8s%(reset)s %(blue)s%(message)s",
- "truncate_message": True,
- "delete_log": False,
- }
- self.EXCEPTION_LOG_LEVEL = 45
- self.INTERNAL_LOG_LEVEL = 15
- logging.addLevelName(self.EXCEPTION_LOG_LEVEL, "EXCEPTION")
- logging.addLevelName(self.INTERNAL_LOG_LEVEL, "INTERNAL")
- self.color = config.get("use_colorlog", True)
- self.truncate = config.get("truncate_message", True)
-
- self.filename = config.get("filename", log_path_relative)
- if self.color:
- logger = colorlog.getLogger()
- logger.setLevel(getattr(logging, config["log_level"].upper(), logging.INFO))
- handler = colorlog.StreamHandler()
- log_colors = {
- "INTERNAL": "cyan",
- "DEBUG": config.get("debug_color", "cyan"),
- "INFO": config.get("info_color", "green"),
- "WARNING": config.get("warning_color", "yellow"),
- "ERROR": config.get("error_color", "red"),
- "CRITICAL": config.get("critical_color", "bold_red"),
- "EXCEPTION": config.get("exception_color", "red"),
- }
-
- formatter = colorlog.ColoredFormatter(
- config.get(
- "colorlog_fmt_parameters",
- "%(log_color)s%(levelname)-8s%(reset)s %(blue)s%(message)s",
- ),
- log_colors=log_colors,
- )
-
- handler.setFormatter(formatter)
- logger.addHandler(handler)
- try:
- getattr(logging, config["log_level"].upper())
- except AttributeError as AE:
- self.__internal(
- f"Log Level {config['log_level']} not found, setting default level to INFO -> {AE}"
- )
-
- if not os.path.exists(self.filename):
- self.newline()
- self._raw(
- "| Timestamp | LOG Level |"
- + " " * 71
- + "LOG Messages"
- + " " * 71
- + "|"
- )
- elif os.path.exists(self.filename) and config.get("delete_log", False):
- with open(self.filename, "w") as f:
- f.write(
- "| Timestamp | LOG Level |"
- + " " * 71
- + "LOG Messages"
- + " " * 71
- + "|"
- + "\n"
- )
- self.newline()
-
- @staticmethod
- def reset():
- """
- Resets the logger by removing all existing handlers.
- """
- logger = logging.getLogger()
- for handler in logger.handlers[:]:
- logger.removeHandler(handler)
-
- @staticmethod
- def __timestamp() -> str:
- """
- Returns the current timestamp as a string.
-
- :return: Current timestamp in 'YYYY-MM-DD HH:MM:SS' format.
- """
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
- def __trunc_message(self, message: str) -> str:
- """
- Pads or truncates the message to fit the log format.
-
- :param message: The log message to be padded or truncated.
- :return: The padded or truncated message.
- """
- if self.truncate is False:
- return message + " " * (153 - len(message)) + "|"
- return (
- message + " " * (153 - len(message))
- if len(message) < 153
- else message[:150] + "..."
- ) + "|"
-
- def __internal(self, message):
- """
- Log an internal message exclusively to the console.
-
- Internal messages are used for logging system states or debug information
- that should not be written to log files.
- These messages are only displayed in the console when color logging is enabled.
-
- Parameters:
- message (str): The internal message to be logged.
- If the message is "None" or None, no logging occurs.
-
- Notes:
- - Requires color logging to be enabled
- - Uses a custom internal log level
- - Converts the message to a string before logging
- """
- if self.color and message != "None" and message is not None:
- colorlog.log(self.INTERNAL_LOG_LEVEL, str(message))
-
- def debug(self, message):
- """
- Logs a debug message.
-
- :param message: The debug message to be logged.
- """
- if self.color and message != "None" and message is not None:
- colorlog.debug(str(message))
-
- def _raw(self, message):
- """
- Log a raw message directly to the log file.
-
- This method writes a message directly to the log file without any additional formatting
- or logging levels.
-
- WARNING: This method is for internal use only! Using it directly can mess up
- your log file format and make it hard to read. Use info(), debug(), or
- other public methods instead.
-
- Parameters:
- message (str): The raw message to be written to the log file.
-
- Notes:
- - Checks the calling context to warn about non-function calls
- - Handles potential Unicode encoding errors
- - Skips logging if message is None or "None"
- - Writes message with a newline character
- - Logs internal errors if file writing fails
-
- Raises:
- Logs internal errors for Unicode or file writing issues without stopping execution
- """
- frame = inspect.currentframe().f_back
- if frame and frame.f_code.co_name == "":
- self.__internal(
- f"Raw message called from a non-function - This is not recommended"
- )
- # Precompiled regex for ANSI escape codes
- # Remove all ANSI escape sequences in one pass
- message = re.compile(r"\033\[\d+(;\d+)*m").sub("", message)
-
- if message and message != "None":
- try:
- with open(self.filename, "a", encoding="utf-8") as f:
- f.write(f"{str(message)}\n")
- except (UnicodeDecodeError, UnicodeEncodeError) as UDE:
- self.__internal(f"UnicodeDecodeError: {UDE} - Message: {str(message)}")
- except Exception as E:
- self.__internal(f"Error: {E} - Message: {str(message)}")
-
- def newline(self):
- """
- Write a newline separator to the log file, creating a visual divider between log entries.
-
- This method writes a formatted horizontal line to the log file using ASCII characters,
- which helps visually separate different sections or log entries.
- The line consists of vertical bars and dashes creating a structured tabular-like separator.
-
- Side Effects:
- Appends a newline separator to the log file specified by `self.filename`.
- """
- with open(self.filename, "a") as f:
- f.write("|" + "-" * 19 + "|" + "-" * 13 + "|" + "-" * 154 + "|" + "\n")
-
- def info(self, message):
- """
- Logs an info message.
-
- :param message: The info message to be logged.
- """
- if self.color and message != "None" and message is not None:
- colorlog.info(str(message))
- self._raw(
- f"[{self.__timestamp()}] > INFO: | {self.__trunc_message(str(message))}"
- )
-
- def warning(self, message):
- """
- Logs a warning message.
-
- :param message: The warning message to be logged.
- """
- if self.color and message != "None" and message is not None:
- colorlog.warning(str(message))
- self._raw(
- f"[{self.__timestamp()}] > WARNING: | {self.__trunc_message(str(message))}"
- )
-
- def error(self, message):
- """
- Logs an error message.
-
- :param message: The error message to be logged.
- """
- if self.color and message != "None" and message is not None:
- colorlog.error(str(message))
- self._raw(
- f"[{self.__timestamp()}] > ERROR: | {self.__trunc_message(str(message))}"
- )
-
- def critical(self, message):
- """
- Logs a critical message.
-
- :param message: The critical message to be logged.
- """
- if self.color and message != "None" and message is not None:
- colorlog.critical(str(message))
- self._raw(
- f"[{self.__timestamp()}] > CRITICAL: | {self.__trunc_message(str(message))}"
- )
-
- def string(self, message, type: str):
- """
- Logs a message with a specified log type, supporting multiple type aliases.
-
- This method allows logging messages with flexible type specifications,
- mapping aliases to standard log types and handling potential errors in type selection.
- It supports logging with color if enabled.
-
- Parameters:
- message (str): The message to be logged. Skipped if "None" or None.
- type (str): The log type, which can be one of:
- - Standard types: 'debug', 'info', 'warning', 'error', 'critical'
- - Aliases: 'err' (error), 'warn' (warning), 'crit' (critical), 'except' (exception)
-
- Behavior:
- - Converts type to lowercase and maps aliases to standard log types
- - Logs message using the corresponding log method
- - Falls back to debug logging if an invalid type is provided
- - Only logs if color is enabled and message is not "None"
-
- Raises:
- AttributeError: If no matching log method is found (internally handled)
- """
- if self.color and message != "None" and message is not None:
- type_map = {
- "err": "error",
- "warn": "warning",
- "crit": "critical",
- "except": "exception",
- }
- type = type_map.get(type.lower(), type)
- try:
- getattr(self, type.lower())(str(message))
- except AttributeError as AE:
- self.__internal(
- f"A wrong Log Type was called: {type} not found. -> {AE}"
- )
- getattr(self, "Debug".lower())(str(message))
-
- def exception(self, message, exception_type: Type = Exception):
- """
- Log an exception message and raise the specified exception.
-
- Warning: Not recommended for production use. Prefer Log().error() for logging exceptions.
-
- Args:
- message (str): The exception message to be logged.
- exception_type (Type, optional): The type of exception to raise. Defaults to Exception.
-
- Raises:
- The specified exception type with the provided message.
-
- Note:
- - Only logs the exception if color logging is enabled and message is not None
- - Logs the exception with a timestamp and truncated message
- - Includes both the original message and the exception type in the log
- """
- if self.color and message != "None" and message is not None:
- self._raw(
- f"[{self.__timestamp()}] > EXCEPTION:| {self.__trunc_message(f'{message} -> Exception provoked: {str(exception_type)}')}"
- )
- raise exception_type(message)
-
- def execution(self, message_log: list[tuple[str, str]]):
- """
- Parse and log multiple messages with their corresponding log types.
-
- This method processes a list of messages, where each message is associated with a specific log type. It is designed for scenarios where multiple log entries need to be processed simultaneously, such as logging script execution results.
-
- Parameters:
- message_log (list[tuple[str, str]]): A list of message entries.
- Each entry is a list containing two elements:
- - First element: The log message (str)
- - Second element: The log type (str)
-
- Behavior:
- - Iterates through the provided message log
- - Logs each message using the specified log type via `self.string()`
- - Logs an internal warning if a message list does not contain exactly two elements
-
- Example:
- log = Log()
- log.parse_execution([
- ['Operation started', 'info'],
- ['Processing data', 'debug'],
- ['Completed successfully', 'info']
- ])
- """
- if message_log:
- for message_list in message_log:
- if len(message_list) == 2:
- self.string(message_list[0], message_list[1])
- else:
- self.__internal(
- f"Message List is not in the correct format: {message_list}"
- )
-
- def function(self, func: callable):
- """
- A decorator that logs the execution details of a function, tracking its performance and providing runtime insights.
-
- Parameters:
- func (callable): The function to be decorated and monitored.
-
- Returns:
- callable: A wrapper function that logs execution metrics.
-
- Raises:
- TypeError: If the provided function is not callable.
-
- Example:
- @log.function
- def example_function():
- # Function implementation
- pass
- """
- if not callable(func):
- self.exception(f"Function {func.__name__} is not callable.", TypeError)
-
- def wrapper(*args, **kwargs):
- """
- Wrapper function that logs the execution of the decorated function.
-
- Tracks and logs the start, execution, and completion of a function with performance timing.
-
- Parameters:
- *args (tuple): Positional arguments passed to the decorated function.
- **kwargs (dict): Keyword arguments passed to the decorated function.
-
- Returns:
- Any: The original result of the decorated function.
-
- Raises:
- TypeError: If the decorated function is not callable.
-
- Notes:
- - Logs debug messages before and after function execution
- - Measures and logs the total execution time with microsecond precision
- - Preserves the original function's return value
- """
- start_time = time.perf_counter()
- func_args = ", ".join(
- [str(arg) for arg in args] + [f"{k}={v}" for k, v in kwargs.items()]
- )
- self.debug(f"Running the function {func.__name__}({func_args}).")
- result = func(*args, **kwargs)
- end_time = time.perf_counter()
- elapsed_time = end_time - start_time
- self.debug(
- f"{func.__name__}({func_args}) executed in {elapsed_time} -> returned {type(result).__name__}"
- )
- return result
-
- return wrapper
-
-
-log = Log({"log_level": DEBUG})
diff --git a/CODE/logicytics/__init__.py b/CODE/logicytics/__init__.py
deleted file mode 100644
index 2e540e0c..00000000
--- a/CODE/logicytics/__init__.py
+++ /dev/null
@@ -1,126 +0,0 @@
-import functools
-import traceback
-
-from logicytics.Checks import Check
-from logicytics.Config import DEBUG, VERSION, CURRENT_FILES, DELETE_LOGS, config
-from logicytics.Execute import Execute
-from logicytics.FileManagement import FileManagement
-from logicytics.Flag import Flag
-from logicytics.Get import Get
-from logicytics.Logger import log, Log
-
-# Check if the script is being run directly, if not, set up the library
-if __name__ == "__main__":
- exit("This is a library, Please import rather than directly run.")
-
-execute = Execute() # Initialize the Execute class for executing commands
-get = Get() # Initialize the Get class for retrieving data
-check = Check() # Initialize the Check class for performing checks
-flag = Flag() # Initialize the Flag class for managing cli flags
-file_management = (
- FileManagement()
-) # Initialize the FileManagement class for file operations
-__show_trace = (
- DEBUG == "DEBUG"
-) # Determine if stack traces should be shown based on the debug level
-
-
-# Exception for handling object loading errors
-class ObjectLoadError(Exception):
- """Raised when an Object fails to load."""
-
- def __init__(self, message="Failed to load object", object_name=None):
- """
- Initialize the exception with a custom message and object details.
-
- Args:
- message (str): The error message
- object_name (str, optional): Name of the object that failed to load
- """
- self.object_name = object_name
- if object_name:
- message = f"{message} (Object: {object_name})"
- super().__init__(message)
-
-
-# Decorator for marking functions as deprecated [custom]
-def deprecated(
- removal_version: str, reason: str, show_trace: bool = __show_trace
-) -> callable:
- """
- Decorator function that marks a function as deprecated
- and provides a warning when the function is called.
-
- Args:
- removal_version (str): The version when the function will be removed.
- reason (str): The reason for deprecation.
- show_trace (bool): Whether to show the stack trace when the function is called. Default is based on DEBUG set by user.
-
- Returns:
- callable: A decorator that marks a function as deprecated.
-
- Notes:
- - Uses a nested decorator function to preserve the original function's metadata
- - Prints a colorized deprecation warning
- """
-
- def decorator(func: callable) -> callable:
- """
- Decorator function that marks a function as deprecated and provides a warning when the function is called.
-
- Args:
- func (callable): The function to be decorated with a deprecation warning.
-
- Returns:
- callable: A wrapper function that preserves the original function's metadata and prints a deprecation warning.
-
- Notes:
- - Uses functools.wraps to preserve the original function's metadata
- - Prints a colorized deprecation warning to stderr
- - Allows the original function to continue executing
- """
-
- @functools.wraps(func)
- def wrapper(*args, **kwargs) -> callable:
- """
- Wraps a deprecated function to print a warning message before execution.
-
- Args:
- *args: Positional arguments passed to the original function.
- **kwargs: Keyword arguments passed to the original function.
-
- Returns:
- The return value of the original function after printing a deprecation warning.
-
- Warns:
- Prints a colored deprecation warning to stderr with details about:
- - Function name being deprecated
- - Reason for deprecation
- - Version when the function will be removed
- """
- message = f"\033[91mDeprecationWarning: A call to the deprecated function {func.__name__}() has been called, {reason}. Function will be removed at version {removal_version}\n"
- if show_trace:
- stack = "".join(traceback.format_stack()[:-1])
- message += f"Called from:\n{stack}\033[0m"
- else:
- message += "\033[0m"
- print(message)
- return func(*args, **kwargs)
-
- return wrapper
-
- return decorator
-
-
-__all__ = [
- "execute",
- "get",
- "check",
- "flag",
- "file_management",
- "deprecated",
- "ObjectLoadError",
- "log",
- "Log",
- "config",
-]
diff --git a/CODE/media_backup.py b/CODE/media_backup.py
deleted file mode 100644
index e2480e0f..00000000
--- a/CODE/media_backup.py
+++ /dev/null
@@ -1,138 +0,0 @@
-import getpass
-import os
-import shutil
-from datetime import datetime
-
-from logicytics import log
-
-
-class Media:
- """
- A class to handle media backup operations.
- """
-
- @staticmethod
- def __get_default_paths() -> list:
- """
- Returns the default paths for photos and videos based on the Windows username.
-
- This method retrieves the current Windows user's default media directories for photos and videos
- by using the current username and standard Windows file system paths.
-
- Returns:
- list: A list containing two paths:
- - First element: Default photo directory path
- - Second element: Default video directory path
-
- Notes:
- - Uses `getpass.getuser()` to dynamically retrieve the current Windows username
- - Expands the user path using `os.path.expanduser()` to handle potential path variations
- - Assumes standard Windows user directory structure
- """
- username = getpass.getuser()
- default_photo_path = os.path.expanduser(f"C:\\Users\\{username}\\Pictures")
- default_video_path = os.path.expanduser(f"C:\\Users\\{username}\\Videos")
- return [default_photo_path, default_video_path]
-
- @staticmethod
- def __ensure_backup_directory_exists(backup_directory: str):
- """
- Ensures the backup directory exists, creating it if necessary.
-
- Args:
- backup_directory (str): The full path to the directory where media files will be backed up.
-
- Raises:
- OSError: If the directory cannot be created due to permission issues or other system constraints.
- """
- if not os.path.exists(backup_directory):
- os.makedirs(backup_directory)
-
- @staticmethod
- def __collect_media_files(source_dirs: list) -> list:
- """
- Collects media files from specified source directories.
-
- Recursively searches through the provided source directories to find image and video files with extensions .jpg, .jpeg, .png, and .mp4.
-
- Args:
- source_dirs (list): List of directory paths to search for media files.
-
- Returns:
- list: Absolute file paths of all discovered media files, including those in subdirectories.
-
- Raises:
- OSError: If any of the source directories are inaccessible or cannot be traversed.
- """
- media_files = []
- for source_dir in source_dirs:
- for root, _, files in os.walk(source_dir):
- for file in files:
- if file.endswith((".jpg", ".jpeg", ".png", ".mp4")):
- media_files.append(os.path.join(root, file))
- return media_files
-
- @staticmethod
- def __backup_files(media_files: list, backup_directory: str):
- """
- Copies media files to a backup directory with timestamped filenames.
-
- Parameters:
- media_files (list): A list of file paths for media files to be backed up.
- backup_directory (str): Destination directory path for storing backup files.
-
- Behavior:
- - Iterates through each media file in the input list
- - Generates a new filename with current timestamp
- - Attempts to copy each file to the backup directory
- - Logs successful copy operations
- - Logs any errors encountered during file copying
-
- Exceptions:
- Handles and logs any exceptions that occur during file copy process
- Does not interrupt the entire backup process if a single file copy fails
- """
- for src_file in media_files:
- dst_file = os.path.join(
- backup_directory,
- datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- + "_"
- + os.path.basename(src_file),
- )
- try:
- shutil.copy2(str(src_file), str(dst_file))
- log.info(f"Copied {os.path.basename(src_file)} to {dst_file}")
- except Exception as e:
- log.error(f"Failed to copy {src_file}: {str(e)}")
-
- @classmethod
- @log.function
- def backup(cls):
- """
- Orchestrates the complete media backup process by performing sequential backup operations.
-
- This class method coordinates the backup workflow:
- 1. Retrieves default media source directories
- 2. Sets a standard backup directory
- 3. Ensures the backup directory exists
- 4. Collects media files from source directories
- 5. Copies media files to the backup directory
- 6. Logs the completion of the backup process
-
- Returns:
- None: Performs backup operations without returning a value
-
- Raises:
- OSError: If directory creation or file operations fail
- PermissionError: If insufficient permissions for file/directory operations
- """
- source_dirs = cls.__get_default_paths()
- backup_directory = "MediaBackup"
- cls.__ensure_backup_directory_exists(backup_directory)
- media_files = cls.__collect_media_files(source_dirs)
- cls.__backup_files(media_files, backup_directory)
- log.info("Media backup script completed.")
-
-
-if __name__ == "__main__":
- Media.backup()
diff --git a/CODE/netadapter.ps1 b/CODE/netadapter.ps1
deleted file mode 100644
index 9453acea..00000000
--- a/CODE/netadapter.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-# Get all network details
-Write-Output "INFO: Getting NetAdapter Info"
-Get-NetAdapter | Select-Object Name, Status, MacAddress, ifIndex, InterfaceAlias, InterfaceDescription | Out-File -FilePath .\Network.txt
diff --git a/CODE/network_psutil.py b/CODE/network_psutil.py
deleted file mode 100644
index 2fee2afe..00000000
--- a/CODE/network_psutil.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import asyncio
-import os
-import socket
-
-import psutil
-
-from logicytics import log, execute, config
-
-
-class NetworkInfo:
- """
- A class to gather and save various network-related information.
- """
-
- def __init__(self):
- self.SAMPLE_COUNT = config.getint("NetWorkPsutil Settings", "sample_count")
- self.INTERVAL = config.getfloat("NetWorkPsutil Settings", "interval")
-
- @log.function
- async def get(self):
- """
- Gathers and saves various network-related information by calling multiple internal methods.
- """
- try:
- self.__fetch_network_io_stats()
- self.__fetch_network_connections()
- self.__fetch_network_interface_addresses()
- self.__fetch_network_interface_stats()
- self.__execute_external_network_command()
- self.__fetch_network_connections_with_process_info()
- await self.__measure_network_bandwidth_usage(sample_count=self.SAMPLE_COUNT, interval=self.INTERVAL)
- self.__fetch_hostname_and_ip()
- except Exception as e:
- log.error(f"Error getting network info: {e}, Type: {type(e).__name__}")
-
- @staticmethod
- def __save_data(filename: str, data: str, father_dir_name: str = "network_data"):
- """
- Saves the given data to a file.
-
- :param filename: The name of the file to save the data in.
- :param data: The data to be saved.
- :param father_dir_name: The directory to save the file in. Defaults to "network_data".
- """
- os.makedirs(father_dir_name, exist_ok=True)
- try:
- with open(os.path.join(father_dir_name, filename), "w") as f:
- f.write(data)
- except IOError as e:
- log.error(f"Failed to save {filename}: {e}")
-
- def __fetch_network_io_stats(self):
- """
- Fetches and saves network I/O statistics for each network interface.
- """
- log.debug("Fetching network interface stats...")
- net_io = psutil.net_io_counters(pernic=True)
- net_io_data = ""
- for iface, stats in net_io.items():
- net_io_data += f"Interface: {iface}\n"
- net_io_data += f"Bytes Sent: {stats.bytes_sent}, Bytes Received: {stats.bytes_recv}\n"
- net_io_data += f"Packets Sent: {stats.packets_sent}, Packets Received: {stats.packets_recv}\n"
- net_io_data += f"Errors In: {stats.errin}, Errors Out: {stats.errout}\n"
- net_io_data += f"Dropped In: {stats.dropin}, Dropped Out: {stats.dropout}\n\n"
- self.__save_data("network_io.txt", net_io_data)
- log.info("Network IO stats saved.")
-
- def __fetch_network_connections(self):
- """
- Fetches and saves information about network connections.
- """
- log.debug("Fetching network connections...")
- connections = psutil.net_connections(kind='all')
- connections_data = ""
- for conn in connections:
- connections_data += f"Type: {conn.type}, Local: {conn.laddr}, Remote: {conn.raddr}, Status: {conn.status}\n"
- self.__save_data("network_connections.txt", connections_data)
- log.info("Network connections saved.")
-
- def __fetch_network_interface_addresses(self):
- """
- Fetches and saves network interface addresses.
- """
- log.debug("Fetching network interface addresses...")
- interfaces = psutil.net_if_addrs()
- interfaces_data = ""
- for iface, addrs in interfaces.items():
- for addr in addrs:
- interfaces_data += f"Interface: {iface}, Address: {addr.address}, Netmask: {addr.netmask}, Broadcast: {addr.broadcast}\n"
- self.__save_data("network_interfaces.txt", interfaces_data)
- log.info("Network interface addresses saved.")
-
- def __fetch_network_interface_stats(self):
- """
- Fetches and saves network interface statistics.
- """
- log.debug("Fetching network interface stats...")
- stats = psutil.net_if_stats()
- stats_data = ""
- for iface, stat in stats.items():
- stats_data += f"Interface: {iface}, Speed: {stat.speed}Mbps, Duplex: {stat.duplex}, Up: {stat.isup}\n"
- self.__save_data("network_stats.txt", stats_data)
- log.info("Network interface stats saved.")
-
- def __execute_external_network_command(self):
- """
- Executes an external network command and saves the output.
- """
- log.debug("Executing external network command...")
- result = execute.command("ipconfig")
- self.__save_data("network_command_output.txt", result)
- log.info("Network command output saved.")
-
- def __fetch_network_connections_with_process_info(self):
- """
- Fetches and saves network connections along with associated process information.
- """
- log.debug("Fetching network connections with process info...")
- connections_data = ""
- for conn in psutil.net_connections(kind='all'):
- pid = conn.pid if conn.pid else "N/A"
- proc_name = "Unknown"
- if pid != "N/A":
- try:
- proc_name = psutil.Process(pid).name()
- except psutil.NoSuchProcess:
- proc_name = "Process Exited"
- connections_data += f"Type: {conn.type}, Local: {conn.laddr}, Remote: {conn.raddr}, Status: {conn.status}, Process: {proc_name} (PID: {pid})\n"
- self.__save_data("network_connections_with_processes.txt", connections_data)
- log.info("Network connections with process info saved.")
-
- async def __measure_network_bandwidth_usage(self, sample_count: int = 5, interval: float = 1.0):
- """
- Measures and saves the average network bandwidth usage.
-
- Args:
- sample_count: Number of samples to take (default: 5)
- interval: Time between samples in seconds (default: 1.0)
- """
- if sample_count < 1 or interval <= 0:
- log.critical(
- "Invalid values passed down from configuration for `NetworkInfo.__measure_network_bandwidth_usage()`")
- log.debug("Measuring network bandwidth usage...")
- samples = []
- for _ in range(sample_count):
- net1 = psutil.net_io_counters()
- await asyncio.sleep(interval)
- net2 = psutil.net_io_counters()
- samples.append({
- 'up': (net2.bytes_sent - net1.bytes_sent) / 1024,
- 'down': (net2.bytes_recv - net1.bytes_recv) / 1024
- })
- if samples:
- avg_up = sum(s['up'] for s in samples) / len(samples)
- avg_down = sum(s['down'] for s in samples) / len(samples)
- max_up = max(s['up'] for s in samples)
- max_down = max(s['down'] for s in samples)
- else:
- avg_up = avg_down = max_up = max_down = 0
- bandwidth_data = f"Average Upload Speed: {avg_up:.2f} KB/s\n"
- bandwidth_data += f"Average Download Speed: {avg_down:.2f} KB/s\n"
- bandwidth_data += f"Peak Upload Speed: {max_up:.2f} KB/s\n"
- bandwidth_data += f"Peak Download Speed: {max_down:.2f} KB/s\n"
- self.__save_data("network_bandwidth_usage.txt", bandwidth_data)
- log.info("Network bandwidth usage saved.")
-
- def __fetch_hostname_and_ip(self):
- """
- Fetches and saves the hostname and IP addresses of the machine.
- """
- try:
- hostname = socket.gethostname()
- ip_addresses = []
- for res in socket.getaddrinfo(hostname, None):
- ip = res[4][0]
- if ip not in ip_addresses:
- ip_addresses.append(ip)
- ip_config_data = f"Hostname: {hostname}\n"
- ip_config_data += "IP Addresses:\n"
- for ip in ip_addresses:
- ip_config_data += f" - {ip}\n"
- except socket.gaierror as e:
- log.error(f"Failed to resolve hostname: {e}")
- ip_config_data = f"Hostname: {hostname}\nFailed to resolve IP addresses\n"
- self.__save_data("hostname_ip.txt", ip_config_data)
- log.info("Hostname and IP address saved.")
-
-
-if __name__ == "__main__":
- try:
- asyncio.run(NetworkInfo().get()) # Use asyncio.run to run the async get method
- except asyncio.CancelledError:
- log.warning("Operation cancelled by user.")
- except Exception as err: # Catch all exceptions
- log.error(f"An error occurred: {err}")
diff --git a/CODE/packet_sniffer.py b/CODE/packet_sniffer.py
deleted file mode 100644
index 95d85627..00000000
--- a/CODE/packet_sniffer.py
+++ /dev/null
@@ -1,149 +0,0 @@
-from __future__ import annotations
-
-import warnings
-from time import time
-
-import matplotlib.pyplot as plt
-import networkx as nx
-import pandas as pd
-from cryptography.utils import CryptographyDeprecationWarning
-
-# TripleDES deprecation warning
-warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning)
-warnings.filterwarnings("ignore", category=DeprecationWarning)
-
-from scapy.all import sniff, conf
-from scapy.layers.inet import IP, TCP, UDP, ICMP
-
-from logicytics import log, config
-
-
-class PacketSniffer:
- def __init__(self):
- conf.verb = 0
- self.packet_data = []
- self.G = nx.Graph()
-
- @staticmethod
- def _get_protocol(packet: IP) -> str:
- if packet.haslayer(TCP):
- return "TCP"
- elif packet.haslayer(UDP):
- return "UDP"
- elif packet.haslayer(ICMP):
- return "ICMP"
- return "Other"
-
- @staticmethod
- def _get_port(packet: IP, port_type: str) -> int | None:
- if port_type == "sport":
- return getattr(packet[TCP], "sport", None) if packet.haslayer(TCP) else getattr(packet[UDP], "sport", None)
- elif port_type == "dport":
- return getattr(packet[TCP], "dport", None) if packet.haslayer(TCP) else getattr(packet[UDP], "dport", None)
- return None
-
- def _log_packet(self, packet: IP):
- if not packet.haslayer(IP):
- return
-
- try:
- protocol = self._get_protocol(packet)
- src_ip = packet[IP].src
- dst_ip = packet[IP].dst
-
- src_port = dst_port = None
- if protocol in ("TCP", "UDP"):
- src_port = self._get_port(packet, "sport")
- dst_port = self._get_port(packet, "dport")
-
- info = {
- "src_ip": src_ip,
- "dst_ip": dst_ip,
- "protocol": protocol,
- "src_port": src_port,
- "dst_port": dst_port
- }
-
- self.packet_data.append(info)
- self.G.add_edge(src_ip, dst_ip, protocol=protocol)
- log.debug(f"{protocol} {src_ip}:{src_port} -> {dst_ip}:{dst_port}")
- except Exception as err:
- log.error(f"Error logging packet: {err}")
-
- def _save_to_csv(self, path: str):
- if not self.packet_data:
- log.warning("No packets to save.")
- return
- pd.DataFrame(self.packet_data).to_csv(path, index=False)
- log.info(f"Saved packet data to {path}")
-
- def _visualize_graph(self, output: str = "graph.png"):
- if self.G.number_of_edges() == 0:
- log.warning("No edges to plot in graph.")
- return
-
- pos = nx.spring_layout(self.G)
- plt.figure(figsize=(12, 8))
- nx.draw(self.G, pos, with_labels=True, node_color="skyblue", node_size=3000, font_size=10, font_weight="bold")
- labels = nx.get_edge_attributes(self.G, 'protocol')
- nx.draw_networkx_edge_labels(self.G, pos, edge_labels=labels)
- plt.title("Network Graph")
- plt.savefig(output)
- plt.close()
- log.info(f"Graph saved to {output}")
-
- @staticmethod
- def _correct_interface(iface: str) -> str:
- corrections = {"WiFi": "Wi-Fi", "Wi-Fi": "WiFi"}
- return corrections.get(iface, iface)
-
- def sniff_packets(self, iface: str, count: int, timeout: int, retry_max: int):
- iface = self._correct_interface(iface)
- retry_start = time()
-
- while time() - retry_start < retry_max:
- try:
- log.info(f"Sniffing on {iface}... (count={count}, timeout={timeout})")
- sniff(
- iface=iface,
- prn=self._log_packet,
- count=count,
- timeout=timeout
- )
- log.info("Sniff complete.")
- break
- except Exception as err:
- log.warning(f"Sniff failed on {iface}: {err}")
- iface = self._correct_interface(iface)
- else:
- log.error("Max retry time exceeded.")
-
- self._save_to_csv("packets.csv")
- self._visualize_graph()
-
- def run(self):
- iface = config.get("PacketSniffer Settings", "interface", fallback="WiFi")
- count = config.getint("PacketSniffer Settings", "packet_count", fallback=5000)
- timeout = config.getint("PacketSniffer Settings", "timeout", fallback=10)
- retry_max = config.getint("PacketSniffer Settings", "max_retry_time", fallback=30)
-
- if count <= 0 or timeout < 5 or retry_max < timeout:
- log.critical("Invalid configuration values.")
- return
-
- self.sniff_packets(iface, count, timeout, retry_max)
-
- def cleanup(self):
- self.G.clear()
- plt.close("all")
- log.info("Cleanup complete.")
-
-
-if __name__ == "__main__":
- sniffer = PacketSniffer()
- try:
- sniffer.run()
- except Exception as e:
- log.error(f"Fatal error: {e}")
- finally:
- sniffer.cleanup()
diff --git a/CODE/property_scraper.ps1 b/CODE/property_scraper.ps1
deleted file mode 100644
index 93ef3524..00000000
--- a/CODE/property_scraper.ps1
+++ /dev/null
@@ -1,34 +0,0 @@
-# Collect system information
-$buildNumber = [System.Environment]::OSVersion.Version.Build
-$physicalMemory = [System.Diagnostics.Process]::PhysicalMemorySize64 / 1MB
-$virtualMemory = [System.Diagnostics.Process]::WorkingSet64 / 1MB
-$userName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
-$userSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent().UserValue)
-$userLanguageId = [System.Globalization.CultureInfo]::CurrentCulture.LCID
-$computerName = [System.Net.Dns]::GetHostName()
-$systemLanguageId = [System.Globalization.CultureInfo]::CurrentUICulture.LCID
-$time = Get-Date -Format HH:mm:ss
-$date = Get-Date -Format dd/MM/yyyy
-$rootDrive = $env:SystemDrive
-
-# Prepare the data to be written to the File
-$data = @"
-Property(C): Windows Build = $buildNumber
-Property(C): Physical Memory = $( $physicalMemory -as [int] )
-Property(C): Virtual Memory = $( $virtualMemory -as [int] )
-Property(C): Log on User = $userName
-Property(C): User SID = $userSid
-Property(C): User Language ID = $userLanguageId
-Property(C): Computer Name = $computerName
-Property(C): System Language ID = $systemLanguageId
-Property(C): Time = $time
-Property(C): Date = $date
-Property(C): Username = $userName
-Property(C): Root Drive = $rootDrive
-"@
-
-# Write the data to a text File
-$data | Out-File -FilePath ".\Extra_Data.txt"
-
-# Optionally, display a message indicating success
-Write-Host "INFO: Data successfully written to Extra_Data.txt"
diff --git a/CODE/registry.py b/CODE/registry.py
deleted file mode 100644
index e1fa3522..00000000
--- a/CODE/registry.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import os
-import subprocess
-
-from logicytics import log
-
-
-@log.function
-def backup_registry():
- """
- Backs up the Windows registry to a file named 'RegistryBackup.reg' in the current working directory.
-
- This function uses the reg export command to export the entire
- registry (HKEY_LOCAL_MACHINE) and logs the result.
- """
- export_path = os.path.join(os.getcwd(), "RegistryBackup.reg")
- reg_path = r"C:\Windows\System32\reg.exe"
- cmd = [reg_path, "export", "HKLM", export_path]
-
- try:
- result = subprocess.run(cmd, check=True, capture_output=True, text=True)
- log.info(f"Registry backed up successfully to {export_path}. Output: {result.stdout}")
- except subprocess.CalledProcessError as e:
- log.error(f"Failed to back up the registry: {e}.")
- except Exception as e:
- log.error(f"Failed to back up the registry: {e}")
-
-
-if __name__ == "__main__":
- backup_registry()
diff --git a/CODE/sensitive_data_miner.py b/CODE/sensitive_data_miner.py
deleted file mode 100644
index 89b92324..00000000
--- a/CODE/sensitive_data_miner.py
+++ /dev/null
@@ -1,179 +0,0 @@
-import os
-import shutil
-from concurrent.futures import ThreadPoolExecutor
-
-from pathlib import Path
-
-from logicytics import log
-
-# List of allowed extensions
-allowed_extensions = [
- ".png", ".txt", ".md", ".json", ".yaml", ".secret", ".jpg", ".jpeg",
- ".password", ".text", ".docx", ".doc", ".xls", ".xlsx", ".csv",
- ".xml", ".config", ".log", ".pdf", ".zip", ".rar", ".7z", ".tar",
- ".gz", ".tgz", ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst",
- ".sql", ".db", ".dbf", ".sqlite", ".sqlite3", ".bak", ".dbx",
- ".mdb", ".accdb", ".pst", ".ost", ".msg", ".eml", ".vsd",
- ".vsdx", ".vsdm", ".vss", ".vssx", ".vssm", ".vst", ".vstx",
- ".vstm", ".vdx", ".vsx", ".vtx", ".vdw", ".vsw", ".vst",
- ".mpp", ".mppx", ".mpt", ".mpd", ".mpx", ".mpd", ".mdf",
-]
-
-
-class Mine:
- @staticmethod
- def __search_files_by_keyword(root: Path, keyword: str) -> list:
- """
- Searches for files containing a specified keyword in their names within a given directory.
-
- Parameters:
- root (Path): The root directory to search in for files.
- keyword (str): The keyword to search for in file names (case-insensitive).
-
- Returns:
- list: A list of file paths matching the search criteria, which:
- - Contain the keyword in their filename (case-insensitive)
- - Are files (not directories)
- - Have file extensions in the allowed_extensions list
-
- Raises:
- WindowsError: If permission is denied when accessing the directory (logged as a warning in debug mode)
-
- Notes:
- - Skips files with unsupported extensions, logging debug information
- - Uses case-insensitive keyword matching
- """
- matching_files = []
- path_list = []
- try:
- path_list = os.listdir(root)
- except (WindowsError, PermissionError) as e:
- log.warning(f"Permission Denied: {e}")
- except Exception as e:
- log.error(f"Failed to access directory: {e}")
-
- for filename in path_list:
- file_path = root / filename
- if (
- keyword.lower() in filename.lower()
- and file_path.is_file()
- and file_path.suffix in allowed_extensions
- ):
- matching_files.append(file_path)
- else:
- log.debug(f"Skipped {file_path}, Unsupported due to {file_path.suffix} extension")
- return matching_files
-
- @staticmethod
- def __copy_file(src_file_path: Path, dst_file_path: Path):
- """
- Copy a file from the source path to the destination path.
-
- Parameters:
- src_file_path (Path): The full path of the source file to be copied.
- dst_file_path (Path): The full path where the file will be copied.
-
- Raises:
- FileExistsError: If a file already exists at the destination path.
- Exception: For any other unexpected errors during file copying.
-
- Notes:
- - Uses shutil.copy() for file copying
- - Logs debug message on successful copy
- - Logs warning if file already exists
- - Logs error for any other copying failures
- """
- try:
- # Check file size and permissions
- if src_file_path.stat().st_size > 10_000_000: # 10MB limit
- log.warning("File exceeds size limit")
- return
- shutil.copy(src_file_path, dst_file_path)
- log.debug(f"Copied {src_file_path} to {dst_file_path}")
- except FileExistsError as e:
- log.warning(f"File already exists in destination: {e}")
- except Exception as e:
- log.error(f"Failed to copy file: {e}")
-
- @classmethod
- def __search_and_copy_files(cls, keyword: str):
- """
- Searches for files containing the specified keyword in their names and copies them to a destination directory.
-
- This method performs a comprehensive file search across the C: drive, identifying files that match a given keyword and concurrently copying them to a dedicated "Password_Files" directory.
-
- Parameters:
- keyword (str): The keyword to search for in file names. Used to filter and identify potentially sensitive files.
-
- Side Effects:
- - Creates a "Password_Files" directory if it does not exist
- - Logs informational messages about the search and copy process
- - Utilizes multithreading to efficiently search and copy files
-
- Notes:
- - Searches recursively through all directories starting from C:\
- - Uses ThreadPoolExecutor for concurrent file searching and copying
- - Handles potential permission and file access errors during search
- """
- log.info(f"Searching/Copying file's with keyword: {keyword}")
- drives_root = Path("C:\\")
- destination = Path("Password_Files")
- if not destination.exists():
- destination.mkdir()
-
- with ThreadPoolExecutor() as executor:
- for root, dirs, _ in os.walk(drives_root):
- future_to_file = {
- executor.submit(cls.__search_files_by_keyword, Path(root) / sub_dir, keyword): sub_dir
- for sub_dir in dirs
- }
- for future in future_to_file:
- for file_path in future.result():
- dst_file_path = destination / file_path.name
- executor.submit(cls.__copy_file, file_path, dst_file_path)
-
- @classmethod
- @log.function
- def passwords(cls):
- """
- Searches for and copies files containing sensitive data keywords to a dedicated directory.
-
- This method performs a comprehensive search for files with predefined sensitive keywords in their names,
- copying matching files to a "Password_Files" directory. It handles directory cleanup and uses predefined
- keywords related to sensitive information.
-
- Side Effects:
- - Creates or recreates the "Password_Files" directory
- - Copies files matching sensitive keywords to the destination directory
- - Logs the completion of the sensitive data mining process
-
- Keywords Searched:
- - "password"
- - "secret"
- - "code"
- - "login"
- - "api"
- - "key"
-
- Logging:
- - Logs an informational message upon completion of the search and copy process
- """
- keywords = ["password", "secret", "code", "login", "api", "key",
- "token", "auth", "credentials", "private", "cert", "ssh", "pgp", "wallet"]
-
- # Ensure the destination directory is clean
- destination = Path("Password_Files")
- if destination.exists():
- shutil.rmtree(destination)
- destination.mkdir()
-
- for word in keywords:
- cls.__search_and_copy_files(word)
-
- log.info("Sensitive Data Miner Completed")
-
-
-if __name__ == "__main__":
- log.warning(
- "Sensitive Data Miner Initialized. Processing may take a while... (Consider a break: coffee or fresh air recommended!)")
- Mine.passwords()
diff --git a/CODE/ssh_miner.py b/CODE/ssh_miner.py
deleted file mode 100644
index 567cc539..00000000
--- a/CODE/ssh_miner.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import os
-import shutil
-
-from logicytics import log
-
-
-@log.function
-def ssh_miner():
- """
- This function backs up SSH keys and configuration
- by copying them from the default SSH directory to a subdirectory
- named 'ssh_backup' in the current working directory.
-
- Returns:
- None
- """
- # Get the current working directory
- current_dir = os.getcwd()
-
- # Define the path to the SSH directory
- ssh_folder = os.path.join(os.environ["USERPROFILE"], ".ssh")
-
- # Define the destination directory as the current working directory
- destination_dir = current_dir
-
- # Ensure the destination directory exists
- if not os.path.exists(destination_dir):
- os.makedirs(destination_dir)
-
- # Define source and destination directories
- source_dir = ssh_folder
- destination_dir = os.path.join(
- current_dir, "ssh_backup"
- ) # Use a subdirectory named 'ssh_backup' in the current directory
-
- # Copy SSH keys and config
- try:
- shutil.copytree(source_dir, destination_dir)
- log.info("SSH keys and configuration backed up successfully.")
- except Exception as e:
- log.error(f"Failed to back up SSH keys and configuration: {e}")
-
- log.info("SSH Miner completed.")
-
-
-if __name__ == "__main__":
- ssh_miner()
diff --git a/CODE/sys_internal.py b/CODE/sys_internal.py
deleted file mode 100644
index da71bea3..00000000
--- a/CODE/sys_internal.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import os
-import subprocess
-
-from logicytics import log
-
-sys_internal_executables = [
- "psfile.exe",
- "PsGetsid.exe",
- "PsInfo.exe",
- "pslist.exe",
- "PsLoggedon.exe",
- "psloglist.exe",
-]
-
-# Check if the executables exist
-sys_internal_executables = [
- exe for exe in sys_internal_executables
- if os.path.exists(os.path.join("SysInternal_Suite", exe))
-]
-
-
-@log.function
-def sys_internal():
- """
- This function runs a series of system internal sys_internal_executables and logs their output.
-
- It iterates over a list of executable names, constructs the command to run each one,
- captures the output, and writes it to a file named 'SysInternal.txt'.
-
- The function also logs information and warning messages for each executable,
- including any errors that occur during execution.
- """
- with open("SysInternal.txt", "a") as outfile:
- # Iterate over each executable
- for executable in sys_internal_executables:
- try:
- # Construct the command to run the executable
- command = f"{os.path.join('SysInternal_Suite', executable)}"
-
- # Execute the command and capture the output
- result = subprocess.run(
- command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
-
- # Write the output to the File
- outfile.write("-" * 190)
- outfile.write(f"{executable} Output:\n{result.stdout.decode()}")
- log.info(f"{executable}: Successfully executed")
-
- # Optionally, handle errors if any
- if (
- result.stderr.decode() != ""
- and result.returncode != 0
- and result.stderr.decode() is not None
- ):
- log.warning(f"{executable}: {result.stderr.decode()}")
- outfile.write(f"{executable}:\n{result.stderr.decode()}")
-
- except Exception as e:
- log.error(f"Error executing {executable}: {str(e)}")
- outfile.write(f"Error executing {executable}: {str(e)}\n")
- log.info("SysInternal Suite fully executed")
-
-
-def check_sys_internal_dir() -> tuple[bool, bool]:
- """
- Checks the existence of the 'SysInternal_Suite' directory and its contents.
-
- Returns:
- tuple[bool, bool]: A tuple where the first element is True if any of the
- sys_internal_executables exist in the 'SysInternal_Suite' directory, and
- the second element is True if 'SysInternal_Suite.zip' exists in the directory.
- """
- if os.path.exists("SysInternal_Suite"):
- return any(
- os.path.exists(f"SysInternal_Suite/{file}")
- for file in sys_internal_executables
- ), os.path.exists("SysInternal_Suite/SysInternal_Suite.zip")
- else:
- log.error(
- "SysInternal_Suite cannot be found as a directory, force closing the sys_internal.py program, continuing Logicytics"
- )
- return False, False
-
-
-if __name__ == "__main__":
- if check_sys_internal_dir()[0]:
- sys_internal()
- elif check_sys_internal_dir()[1]:
- log.warning(
- "Files are not found, They are still zipped, most likely due to a .ignore file being present, continuing Logicytics")
- else:
- log.error("Files are not found, The zip file is also missing!, continuing Logicytics")
diff --git a/CODE/tasklist.py b/CODE/tasklist.py
deleted file mode 100644
index 5f135453..00000000
--- a/CODE/tasklist.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import subprocess
-
-from logicytics import log
-
-
-@log.function
-def tasklist():
- """
- Retrieves a list of running tasks on the system and exports the result to a CSV file.
-
- Parameters:
- None
-
- Returns:
- None
- """
- try:
- result = subprocess.run(
- "tasklist /v /fo csv",
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- with open("tasks.csv", "wb") as file:
- file.write(result.stdout)
- log.info("Tasklist exported to tasks.csv")
- except subprocess.CalledProcessError as e:
- log.error(f"Subprocess Error: {e}")
- except Exception as e:
- log.error(f"Error: {e}")
-
-
-if __name__ == "__main__":
- tasklist()
diff --git a/CODE/tree.ps1 b/CODE/tree.ps1
deleted file mode 100644
index d19fde86..00000000
--- a/CODE/tree.ps1
+++ /dev/null
@@ -1,9 +0,0 @@
-Write-Host "INFO: Starting Tree Command"
-
-# Define the output file name as Tree.txt
-$outputFile = "Tree.txt"
-
-# Run the tree command and redirect the output to the file
-tree /f C:\ | Out-File -FilePath $outputFile -Force
-
-Write-Host "INFO: Saved $outputFile"
\ No newline at end of file
diff --git a/CODE/usb_history.py b/CODE/usb_history.py
deleted file mode 100644
index 912823ce..00000000
--- a/CODE/usb_history.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import ctypes
-import os
-import winreg
-from datetime import datetime, timedelta
-
-from logicytics import log
-
-
-class USBHistory:
- def __init__(self):
- self.history_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "usb_history.txt")
-
- def _save_history(self, message: str):
- """Append a timestamped message to the history file and log it."""
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- entry = f"{timestamp} - {message}\n"
- try:
- with open(self.history_path, "a", encoding="utf-8") as f:
- f.write(entry)
- log.debug(f"Saved entry: {message}")
- except Exception as e:
- log.error(f"Failed to write history: {e}")
-
- # noinspection PyUnresolvedReferences
- @staticmethod
- def _get_last_write_time(root_key, sub_key_path):
- """Return the precise last write time of a registry key, or None on failure."""
- handle = ctypes.wintypes.HANDLE()
- try:
- advapi32 = ctypes.windll.advapi32
- if advapi32.RegOpenKeyExW(root_key, sub_key_path, 0, winreg.KEY_READ, ctypes.byref(handle)) != 0:
- return None
- ft = ctypes.wintypes.FILETIME()
- if advapi32.RegQueryInfoKeyW(handle, None, None, None, None, None, None, None, None, None, None,
- ctypes.byref(ft)) != 0:
- return None
- t = ((ft.dwHighDateTime << 32) + ft.dwLowDateTime) // 10
- return datetime(1601, 1, 1) + timedelta(microseconds=t)
- finally:
- if handle:
- ctypes.windll.advapi32.RegCloseKey(handle)
-
- @staticmethod
- def _enum_subkeys(root, path, warn_func):
- """Yield all subkeys of a registry key, logging warnings on errors."""
- try:
- with winreg.OpenKey(root, path) as key:
- subkey_count, _, _ = winreg.QueryInfoKey(key)
- for i in range(subkey_count):
- try:
- yield winreg.EnumKey(key, i)
- except OSError as e:
- if getattr(e, "winerror", None) == 259: # ERROR_NO_MORE_ITEMS
- break
- warn_func(f"Error enumerating {path} index {i}: {e}")
- except OSError as e:
- warn_func(f"Failed to open registry key {path}: {e}")
-
- @staticmethod
- def _get_friendly_name(dev_info_path, device_id):
- """Return the friendly name of a device if available, else the device ID."""
- try:
- with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, dev_info_path) as dev_key:
- return winreg.QueryValueEx(dev_key, "FriendlyName")[0]
- except FileNotFoundError:
- return device_id
- except Exception as e:
- log.warning(f"Failed to read friendly name for {dev_info_path}: {e}")
- return device_id
-
- def read(self):
- """Read all USB devices from USBSTOR and log their info."""
- log.info("Starting USB history extraction...")
- reg_path = r"SYSTEM\CurrentControlSet\Enum\USBSTOR"
- try:
- for device_class in self._enum_subkeys(winreg.HKEY_LOCAL_MACHINE, reg_path, log.warning):
- dev_class_path = f"{reg_path}\\{device_class}"
- for device_id in self._enum_subkeys(winreg.HKEY_LOCAL_MACHINE, dev_class_path, log.warning):
- dev_info_path = f"{dev_class_path}\\{device_id}"
- friendly_name = self._get_friendly_name(dev_info_path, device_id)
- last_write = self._get_last_write_time(winreg.HKEY_LOCAL_MACHINE, dev_info_path) or "Unknown"
- self._save_history(f"USB Device Found: {friendly_name} | LastWriteTime: {last_write}")
- log.info(f"USB history extraction complete, saved to {self.history_path}")
- except Exception as e:
- log.error(f"Error during USB history extraction: {e}")
-
-
-if __name__ == "__main__":
- USBHistory().read()
diff --git a/CODE/vulnscan.py b/CODE/vulnscan.py
deleted file mode 100644
index b31a83ad..00000000
--- a/CODE/vulnscan.py
+++ /dev/null
@@ -1,196 +0,0 @@
-import csv
-import json
-import os
-import shutil
-from concurrent.futures import ThreadPoolExecutor, as_completed
-
-import torch
-from sentence_transformers import SentenceTransformer
-from torch import nn
-
-from logicytics import log, config
-
-# ================== GLOBAL SETTINGS ==================
-
-# File scan settings
-TEXT_EXTENSIONS = {
- ".txt", ".log", ".csv", ".json", ".xml", ".html", ".md", ".cfg", ".ini", ".yml", ".yaml",
- ".rtf", ".tex", ".rst", ".adoc", ".properties", ".conf", ".bat", ".ps1", ".sh", ".tsv",
- ".dat", ".env", ".toml", ".dockerfile", ".gitignore", ".gitattributes", ".npmrc", ".editorconfig"
-}
-MAX_TEXT_LENGTH = config.get("VulnScan Settings", "text_char_limit", fallback=None)
-MAX_TEXT_LENGTH = int(MAX_TEXT_LENGTH) if MAX_TEXT_LENGTH not in (None, "None", "") else None
-# Threading
-NUM_WORKERS = config.get("VulnScan Settings", "max_workers", fallback="auto")
-NUM_WORKERS = min(32, (os.cpu_count() or 1) * 2) if NUM_WORKERS == "auto" else int(NUM_WORKERS)
-# Classification threshold
-SENSITIVE_THRESHOLD = float(
- config.get("VulnScan Settings", "threshold", fallback=0.6)) # Probability cutoff to consider a file sensitive
-
-# Paths
-SENSITIVE_PATHS = [
- r"C:\Users\%USERNAME%\Documents",
- r"C:\Users\%USERNAME%\Desktop",
- r"C:\Users\%USERNAME%\Downloads",
- r"C:\Users\%USERNAME%\AppData\Roaming",
- r"C:\Users\%USERNAME%\AppData\Local",
- r"C:\Users\%USERNAME%\OneDrive",
- r"C:\Users\%USERNAME%\Dropbox",
- r"C:\Users\%USERNAME%\Google Drive",
-]
-SAVE_DIR = r"VulnScan_Files" # Backup folder
-MODEL_PATH = r"vulnscan/Model_SenseMacro.4n1.pth" # Your trained model checkpoint
-REPORT_JSON = "report.json"
-REPORT_CSV = "report.csv"
-
-# ================== DEVICE SETUP ==================
-DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
-log.debug(f"Using device: {DEVICE}")
-
-
-# ================== MODEL DEFINITION ==================
-class SimpleNN(nn.Module):
- def __init__(self, input_dim):
- super().__init__()
- self.fc = nn.Sequential(
- nn.Linear(in_features=input_dim, out_features=256),
- nn.ReLU(),
- nn.Linear(in_features=256, out_features=64),
- nn.ReLU(),
- nn.Linear(in_features=64, out_features=1),
- )
-
- def forward(self, x):
- return self.fc(x)
-
-
-# ================== LOAD MODELS ==================
-# Load classifier
-checkpoint = torch.load(MODEL_PATH, map_location=DEVICE)
-model = SimpleNN(input_dim=384)
-model.load_state_dict(checkpoint["model_state_dict"])
-model.to(DEVICE)
-model.eval()
-
-# Load embedding model
-embed_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device=DEVICE)
-
-# Make backup folder
-os.makedirs(SAVE_DIR, exist_ok=True)
-
-
-# ================== FILE PROCESSING ==================
-def process_file(filepath):
- try:
- _, ext = os.path.splitext(filepath)
- if ext.lower() not in TEXT_EXTENSIONS:
- return None
-
- with open(filepath, "r", encoding="utf-8", errors="ignore") as f_:
- content = f_.read()
- if not content.strip():
- return None
-
- # Limit file length
- if MAX_TEXT_LENGTH is not None:
- content = content[:MAX_TEXT_LENGTH]
-
- # Split content into lines
- lines = [line_ for line_ in content.splitlines() if line_.strip()]
- if not lines:
- return None
-
- # Embed all lines
- embeddings = embed_model.encode(lines, convert_to_tensor=True, device=DEVICE)
-
- # Predict per line
- probs = []
- for emb in embeddings:
- with torch.no_grad():
- output = model(emb.unsqueeze(0))
- probs.append(torch.sigmoid(output).item())
-
- max_prob = max(probs)
- if max_prob < SENSITIVE_THRESHOLD:
- return None
-
- # Get top 5 lines contributing most
- top_lines = [lines[i] for i, p in sorted(enumerate(probs), key=lambda x: x[1], reverse=True)[:5]]
-
- # Backup file
- rel_path = os.path.relpath(filepath, ROOT_DIR)
- backup_path = os.path.join(SAVE_DIR, rel_path)
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
- shutil.copy2(filepath, backup_path)
-
- return {
- "file": filepath,
- "probability": max_prob,
- "copied_to": backup_path,
- "reason": top_lines
- }
-
- except Exception as e:
- log.error(f"Could not process {filepath}: {e}")
- return None
-
-
-# ================== DIRECTORY SCAN ==================
-def scan_directory(root):
- sensitive_files = []
- with ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor:
- futures = []
- for dirpath, _, filenames in os.walk(root):
- for file in filenames:
- futures.append(executor.submit(process_file, os.path.join(dirpath, file)))
-
- for future in as_completed(futures):
- result = future.result()
- if result:
- sensitive_files.append(result)
-
- return sensitive_files
-
-
-# ================== MAIN ==================
-def main():
- log.info(f"Scanning directory: {ROOT_DIR} - This will take some time...")
- sensitive = scan_directory(ROOT_DIR)
-
- # Save JSON report
- with open(REPORT_JSON, "w", encoding="utf-8") as f:
- json.dump(sensitive, f, indent=2, ensure_ascii=False)
-
- # Save CSV report
- with open(REPORT_CSV, "w", newline="", encoding="utf-8") as f:
- writer = csv.DictWriter(f, fieldnames=["file", "probability", "copied_to", "reason"])
- writer.writeheader()
- for entry in sensitive:
- # Join top lines as single string for CSV
- entry_csv = entry.copy()
- entry_csv["reason"] = " | ".join(entry["reason"])
- writer.writerow(entry_csv)
-
- print()
- log.debug("Sensitive files detected and backed up:")
- for entry in sensitive:
- log.debug(f" - {entry['file']} (prob={entry['probability']:.4f})")
- for line in entry["reason"]:
- log.debug(f" -> {line}")
-
- print()
- log.info("Backup completed.\n")
- log.debug(f"Files copied into: {SAVE_DIR}")
- log.debug(f"JSON report saved as: {REPORT_JSON}")
- log.debug(f"CSV report saved as: {REPORT_CSV}")
-
-
-if __name__ == "__main__":
- log.info(f"Starting VulnScan with {NUM_WORKERS} thread workers and {len(SENSITIVE_PATHS)} paths...")
- for path in SENSITIVE_PATHS:
- expanded_path = os.path.expandvars(path)
- if os.path.exists(expanded_path):
- ROOT_DIR = expanded_path
- main()
- else:
- log.warning(f"Path does not exist and will be skipped: {expanded_path}")
diff --git a/CODE/vulnscan/Model_SenseMacro.4n1.pth b/CODE/vulnscan/Model_SenseMacro.4n1.pth
deleted file mode 100644
index 4f8182cc..00000000
Binary files a/CODE/vulnscan/Model_SenseMacro.4n1.pth and /dev/null differ
diff --git a/CODE/wifi_stealer.py b/CODE/wifi_stealer.py
deleted file mode 100644
index efb95567..00000000
--- a/CODE/wifi_stealer.py
+++ /dev/null
@@ -1,109 +0,0 @@
-from __future__ import annotations
-
-from logicytics import log, execute
-
-
-def get_password(ssid: str) -> str | None:
- """
- Retrieves the password for a specified Wi-Fi network.
-
- Args:
- ssid (str): The name (SSID) of the Wi-Fi network to retrieve the password for.
-
- Returns:
- str or None: The Wi-Fi network password if found, otherwise None.
-
- Raises:
- Exception: If an error occurs during command execution or password retrieval.
-
- Notes:
- - Uses the Windows `netsh` command to extract network profile details
- - Searches command output for "Key Content" to find the password
- - Logs any errors encountered during the process
- """
- try:
- command_output = execute.command(
- f'netsh wlan show profile name="{ssid}" key=clear'
- )
- if command_output:
- key_content = command_output.splitlines()
- for line in key_content:
- if "Key Content" in line:
- return line.split(":")[1].strip()
- return None
- except Exception as err:
- log.error(err)
- return None
-
-
-def parse_wifi_names(command_output: str) -> list:
- """
- Parses the output of the command to extract Wi-Fi profile names.
-
- Args:
- command_output (str): The output of the command "netsh wlan show profile" containing Wi-Fi profile information.
-
- Returns:
- list: A list of extracted Wi-Fi profile names, stripped of whitespace.
- """
- wifi_names = []
-
- for line in command_output.split("\n"):
- if "All User Profile" in line:
- start_index = line.find("All User Profile") + len("All User Profile")
- wifi_name = line[start_index:].strip()
- wifi_names.append(wifi_name)
-
- return wifi_names
-
-
-def get_wifi_names() -> list:
- """
- Retrieves the names of all Wi-Fi profiles on the system.
-
- Executes the "netsh wlan show profile" command to list available Wi-Fi network profiles.
- Parses the command output to extract individual profile names.
-
- Returns:
- list: A list of Wi-Fi network profile names discovered on the system.
-
- Raises:
- Exception: If an error occurs during the retrieval of Wi-Fi names.
-
- Example:
- wifi_profiles = get_wifi_names() # Returns ['HomeNetwork', 'CoffeeShop', ...]
- """
- try:
- log.info("Retrieving Wi-Fi names...")
- wifi_names = parse_wifi_names(execute.command("netsh wlan show profile"))
- log.info(f"Retrieved {len(wifi_names)} Wi-Fi names.")
- return wifi_names
- except Exception as err:
- log.error(err)
- return []
-
-
-@log.function
-def get_wifi_passwords():
- """
- Retrieves the passwords for all Wi-Fi profiles on the system.
-
- This function retrieves the names of all Wi-Fi profiles on the system using the get_wifi_names() function.
- It then iterates over each Wi-Fi profile name and retrieves the password associated with the profile using the get_password() function.
- The Wi-Fi profile names and passwords are stored in a dictionary where the key is the Wi-Fi profile name and the value is the password.
- """
- with open("WiFi.txt", "w") as file:
- for name in get_wifi_names():
- try:
- log.info(f"Retrieving password for {name.removeprefix(': ')}")
- file.write(
- f"Name: {name.removeprefix(': ')}, Password: {get_password(name.removeprefix(': '))}\n"
- )
- except UnicodeDecodeError as e:
- log.error(e)
- except Exception as e:
- log.error(e)
-
-
-if __name__ == "__main__":
- get_wifi_passwords()
diff --git a/CODE/window_feature_miner.ps1 b/CODE/window_feature_miner.ps1
deleted file mode 100644
index 83832b77..00000000
--- a/CODE/window_feature_miner.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-# List all optional features and save them to Features.txt
-Write-Output "INFO: Starting Feature mining, saving to Features.txt"
-Get-WindowsOptionalFeature -Online | Format-Table -Property FeatureName, State > Features.txt
diff --git a/CODE/wmic.py b/CODE/wmic.py
deleted file mode 100644
index 8978bf41..00000000
--- a/CODE/wmic.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from logicytics import log, execute
-
-
-@log.function
-def wmic():
- """
- Retrieves system information using WMIC commands.
-
- This function runs a series of WMIC commands to gather information about the system's BIOS,
- operating system, computer system, and disk drives.
- The output of each command is written to a file named "wmic_output.txt".
-
- Parameters:
- None
-
- Returns:
- None
- """
- data = execute.command("wmic BIOS get Manufacturer,Name,Version /format:htable")
- with open("WMIC.html", "w") as file:
- file.write(data)
- wmic_commands = [
- "wmic os get Caption,CSDVersion,ServicePackMajorVersion",
- "wmic computersystem get Model,Manufacturer,NumberOfProcessors",
- "wmic BIOS get Manufacturer,Name,Version",
- "wmic diskdrive get model,size",
- ]
- with open("wmic_output.txt", "w") as file:
- for index, command in enumerate(wmic_commands):
- log.info(f"Executing Command Number {index + 1}: {command}")
- output = execute.command(command)
- file.write("-" * 190)
- file.write(f"Command {index + 1}: {command}\n")
- file.write(output)
-
- file.write("-" * 190)
-
-
-if __name__ == "__main__":
- wmic()
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f9be04e7..5e05277e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,125 +1,173 @@
# Contributing to Logicytics
-Looking to contribute something to Logicytics? **Here's how you can help.**
-
-Please take a moment to review this document to make the contribution
-process easy and effective for everyone involved.
-
-Following these guidelines helps to communicate that you respect the time of
-the developers managing and developing this open source project. In return,
-they should reciprocate that respect in addressing your issue or assessing
-patches and features.
-
-## Using the issue tracker
-
-The [issue tracker](https://github.com/DefinetlyNotAI/Logicytics/issues) is
-the preferred channel for bug reports and features requests
-and submitting pull requests, but please respect the following
-restrictions:
-
-- Please **Do not** derail or troll issues. Keep the discussion on topic and
- respect the opinions of others.
-
-- Please **Do not** post comments consisting solely of "+1" or "👍 ".
- Use [GitHub's "reactions" feature](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments)
- instead. We reserve the right to delete comments which violate this rule.
-
-## Issues assignment
-
-I will be looking at the open issues, analyse them, and provide guidance on how to proceed.
-
-Issues can be assigned to anyone other than me and contributors are welcome
-to participate in the discussion and provide their input on how to best solve the issue,
-and even submit a PR if they want to.
-
-Please wait that the issue is ready to be worked on before submitting a PR.
-We don't want to waste your time.
-
-Please keep in mind that I am a human and have limited resources and am not always able to respond immediately.
-I will try to provide feedback as soon as possible, but please be patient.
-
-If you don't get a response immediately,
-it doesn't mean that we are ignoring you or that we don't care about your issue or PR.
-We will get back to you as soon as we can.
-
-If you decide to pull a PR or fork the project, keep in mind that you should only add/edit the scripts you need to,
-leave core files alone.
-
-## Guidelines for Modifications 📃
-
-When making modifications to the Logicytics project,
-please adhere to the following guidelines on the Wiki page.
-
-## Issues and labels 🛠️
-
-Our bug tracker utilizes several labels to help organize and identify issues.
-
-For a complete look at our labels, see the [project labels page](https://github.com/DefinetlyNotAI/Logicytics/labels).
-
-## Bug reports 🐛
-
-A bug is a _demonstrable problem_ that is caused by the code in the repository.
-Good bug reports are extremely helpful!
-
-Guidelines for bug reports:
-
-1. **Use the GitHub issue search** — check if the issue has already been
- reported.
-
-2. **Check if the issue has been fixed** — try to reproduce it using the
- latest `main` (or `version` branch if the issue is about a version) in the repository.
-
-A good bug report shouldn't leave others needing to chase you up for more
-information. Please try to be as detailed as possible in your report. What is
-your environment? What steps will reproduce the issue? What browser(s) and OS
-experience the problem? Do other browsers show the bug differently? What
-would you expect to be the outcome? All these details will help people to fix
-any potential bugs.
-
-## Feature requests 🚀
-
-Feature requests are welcome. But take a moment to find out whether your idea
-fits with the scope and aims of the project. It's up to _you_ to make a strong
-case to convince the project's developers of the merits of this feature. Please
-provide as much detail and context as possible.
-
-## Pull requests 📝
-
-Good pull requests—patches, improvements, new features—are a fantastic
-help. They should remain focused in scope and avoid containing unrelated
-commits.
-
-**Please ask first** before embarking on any **significant** pull request (e.g.
-implementing features, refactoring code, porting to a different language),
-otherwise you risk spending a lot of time working on something that the
-project's developers might not want to merge into the project. For trivial
-things, or things that don't require a lot of your time, you can go ahead and
-make a PR.
-
-Please adhere to the coding guidelines used throughout the
-project (indentation, accurate comments, etc.) and any other requirements
-(such as test coverage).
-
-View the Wiki for more information on how to write pull requests.
-
-**IMPORTANT**: By submitting a patch, you agree to allow the project owners to
-license your work under the terms of the [License](https://github.com/DefinetlyNotAI/Logicytics/blob/main/LICENSE)
-
-## License 📝
-
-By contributing your code, you agree to license your contribution under
-the [MIT License](https://github.com/DefinetlyNotAI/Logicytics/blob/main/LICENSE).
-By contributing to the documentation, you agree to license your contribution under
-the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/).
-
-You also agree to the [Developer Certificate of Origin](DCO.md).
-
-## Communication 🗣️
-
-- **Issues**: Use GitHub issues for bug reports and feature requests. Keep the discussion focused and relevant.
-- **Pull Requests**: Use pull requests to propose changes. Be prepared to discuss your changes and address any feedback.
-
-If you have any questions or need further clarification, please feel free to [contact](mailto:Nirt_12023@outlook.com)
-me.
-
-Thank you for your contributions!
+Logicytics v4 is a Windows-focused evidence collector with strict authorization,
+isolation, output, and compatibility contracts. Keep changes focused and preserve
+those contracts. Use the [issue tracker](https://github.com/DefinetlyNotAI/Logicytics/issues)
+for reproducible bugs and scoped feature proposals.
+
+## Development setup
+
+Requirements:
+
+- Windows for live collector and platform integration checks.
+- Python 3.11 or later. The v4 engine has no third-party runtime dependency.
+- Git for developer integrity and explicit update actions.
+
+From a fresh checkout:
+
+```powershell
+python -m logicytics preflight
+python -m unittest discover -v
+python -m compileall -q logicytics core tests
+```
+
+`preflight` must report no invalid core collector. Some live Windows features such
+as WMIC, BitLocker, or Sysinternals are optional; absence must produce an explicit
+skip or availability result rather than breaking unrelated collection.
+
+## Architecture boundaries
+
+The canonical pipeline is:
+
+`request -> validated plan -> isolated collectors -> registered artifacts -> manifest -> package`
+
+- `logicytics/cli/` parses and renders. It does not collect evidence.
+- `logicytics/module/planner.py` resolves one deterministic plan from immutable
+ `RunRequest` policy and validated metadata.
+- `logicytics/module/runtime.py` owns per-run and per-worker lifecycle, cancellation,
+ retries, timeouts, failure aggregation, and post-run actions.
+- `logicytics/module/platform_adapters.py` owns host command, process, registry,
+ filesystem, network, privilege, and Win32 access. Collectors must use these
+ injectable seams instead of importing host APIs directly.
+- `logicytics/module/artifacts.py` is the only publication path from collector
+ workspaces into the run artifact catalog.
+- `logicytics/module/packaging.py` consumes the finalized catalog; it never scans source
+ directories for arbitrary files.
+- Maintenance, debug, update, developer, and usage behavior stays separate from
+ normal collection.
+
+Importing `logicytics` must not start collection, load the supervisor, or create
+files. Do not add global mutable run state, implicit current-directory behavior,
+collector-to-collector calls, or a second execution/output path.
+
+## Core collector changes
+
+Each `core//.py` module owns exactly one public
+`Collector` and one primary job. Split a feature into a new ID when
+it needs a different capability, privilege level, network reach, sensitive-data
+category, timeout/cost policy, or output contract.
+
+A collector must:
+
+- inherit `CoreCollector` and provide fully typed, documented `metadata`,
+ `validate`, `prepare`, `collect`, `finalize`, and `cleanup` behavior;
+- use an ID, class name, file name, and `Specialty` that agree;
+- declare platform, capabilities, privilege, network access, sensitivity,
+ profiles, dependencies, scheduling policy, timeouts, retries, memory/output
+ limits, artifact count, and every MIME type it can publish;
+- check `context.is_cancelled` before work and during every long loop, query,
+ capture, copy, or traversal;
+- write only beneath `context.workspace`, report structured progress, register
+ every result through `context.artifacts`, and return a typed result;
+- turn expected absence or permission denial into an actionable `skipped` or
+ `partial` result without hiding an actual failure;
+- avoid `print`, `exit`, nested worker pools, mutable globals, repository writes,
+ package-wide cleanup, secrets in logs, and unbounded reads or subprocess output.
+
+Run the collector directly and through the orchestrator. Add mocked Windows
+responses, cancellation coverage, and output-contract evidence. If structured
+bytes change intentionally, update the relevant golden file in `tests/golden/`
+and explain why.
+
+## Plugins
+
+Plugins implement the typed `PluginCollector` contract and remain opt-in. They
+may not bypass preflight, planning, capability approval, isolated workspaces,
+artifact registration, or package filtering.
+
+Read [PLUGIN_AUTHORING.md](docs/PLUGIN_AUTHORING.md) before changing discovery or
+extension behavior. Read [MIGRATION.md](docs/MIGRATION.md) before changing a legacy
+flag, schema migration, or historical `CODE` evidence import. Compatibility code must remain
+a bounded translation into the canonical v4 model.
+
+## Configuration changes
+
+Configuration changes must preserve the strict schema in
+[CONFIGURATION.md](docs/CONFIGURATION.md). Update the field reference and its
+parser-backed tests in the same commit as any setting, default, bound, migration
+alias, or source-precedence change.
+
+Profiles, modes, include/exclude selections, plugin enablement, capability
+approval, authorization acknowledgement, scheduling overrides, reruns, package
+policy, and post-run power actions are invocation-only `RunRequest` behavior.
+They do not belong in persistent configuration.
+
+## Evidence, security, and compatibility
+
+- Update [OUTPUTS.md](docs/OUTPUTS.md) when a filename, MIME type, package path,
+ evidence kind, or retention rule changes.
+- Keep source, executables, models, configuration secrets, caches, and library
+ internals out of evidence packages.
+- Preserve reproducible package hashes and manifest schema validation.
+- Add explicit capabilities for sensitive or elevated access. Never weaken
+ authorization to make a test pass.
+- Follow [SECURITY.md](SECURITY.md) for vulnerability reports. Do not put secrets
+ or real private evidence in issues, fixtures, logs, or commits.
+
+## Testing expectations
+
+Use the narrowest relevant tests while iterating, then run the complete gates
+before opening a pull request:
+
+```powershell
+python -m unittest discover -v
+python -m compileall -q logicytics core tests
+python -m logicytics preflight
+git diff --check
+```
+
+On Windows, also run:
+
+```powershell
+python -m unittest tests.test_windows_integration -v
+```
+
+The suite includes static preflight, lifecycle/cancellation checks, mocked
+collector responses, golden output bytes, flow/mode matrices, package/hash
+reproduction, documentation contracts, and bounded live Windows probes. A build
+or compile check alone is not sufficient evidence.
+
+## Documentation changes
+
+Keep user and developer documentation synchronized with behavior:
+
+- [README.md](README.md): installation, quick start, CLI, permissions, and
+ troubleshooting.
+- [CONFIGURATION.md](docs/CONFIGURATION.md): every persistent setting and migration.
+- [OUTPUTS.md](docs/OUTPUTS.md): artifact and retention contracts.
+- [MIGRATION.md](docs/MIGRATION.md): supported compatibility boundary.
+- [FLOW_MATRIX.md](docs/FLOW_MATRIX.md): executable flow evidence.
+
+The repository wiki is complementary documentation, not a substitute for the
+versioned contract files required to review a change.
+
+## Issues and pull requests
+
+A useful bug report includes the Logicytics version, Windows edition/build,
+Python version, command and sanitized configuration, expected and actual result,
+exit code, relevant redacted logs, and exact reproduction steps. State whether
+the process was elevated and whether an optional Windows feature was installed.
+
+Pull requests should:
+
+- solve one coherent problem and avoid unrelated edits;
+- use conventional commit subjects such as `feat:`, `fix:`, `refactor:`,
+ `test:`, or `docs:` with a detailed body;
+- include tests and documentation proportional to the changed contract;
+- preserve unrelated work and never include generated evidence or secrets;
+- pass the complete verification gates above; and
+- comply with the [Developer Certificate of Origin](.github/DCO.md),
+ [Code of Conduct](CODE_OF_CONDUCT.md), and repository license.
+
+By contributing code, you agree to license it under the [MIT License](LICENSE).
+Documentation contributions use the repository's stated documentation license.
diff --git a/MODS/_MOD_SKELETON.py b/MODS/_MOD_SKELETON.py
deleted file mode 100644
index c77a6ecd..00000000
--- a/MODS/_MOD_SKELETON.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# If using the future annotations, it should be ontop of the file
-# from __future__ import annotations
-
-# Other Imports if needed or necessary go here
-
-# To know more check the WiKi
-from logicytics import log # And more if needed
-
-
-# Your actual code, must be able to run without any interference by outside actions
-# USE log.debug, log.info, log.error, log.warning and log.critical and log.string as well
-# You can choose to use any other of the code without issues
-# Example of said code:-
-
-
-# This log decorator logs the function name and the time it took to run,
-# It is recommended to use this,
-# as it only logs the function and the time it took to run
-# in debug mode thus helping when people enable debug mode
-# Do note however, if you are using multiple decorators, this should be the last one
-# check the WiKi for more information
-# Do not use this decorator if you are running a function that is part of another function
-@log.function
-def MOD_EXAMPLE() -> None:
- """
- This function MOD is used to log different types of messages.
-
- It logs an error message, a warning message, an info message, and a debug message.
-
- Parameters:
- None
-
- Returns:
- None
- """
- log.error("This is an error")
- log.warning("This is a warning")
- log.info("This is a info message")
- log.debug("This is a debug message")
- log.critical("This is a critical message")
- # This is special, allows you to use strings to specify the log level, it is not recommended to use these
- # Options are error, warning, info, debug, critical - It is case-insensitive and can be used with any of the log levels
- # Defaults with the log level of debug
- log.string("This is a random message", "ERROR")
- pass # Your code here with proper logging like the above log options
-
-
-# It is recommended to call your function at the end of the file using the following code
-# This is to ensure that the function is called only when directly executed and not when imported
-if __name__ == "__main__":
- MOD_EXAMPLE()
-
-# Always remember to call your function at the end of the file and then leave a new line
-# This is to ensure that the function is called and the file is not empty
diff --git a/PLANS.md b/PLANS.md
deleted file mode 100644
index 221aa875..00000000
--- a/PLANS.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# To-Do List
-
-> [!TIP]
-> Here is a key for the table above:
->
-> - ❌ ➡️ Might be done, Not sure yet
-> - ✅ ➡️ Will be done, 100% sure
-
-| Task | Version | Might or Will be done? |
-|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|------------------------|
-| Update to model 4n2 of vulnscan | v3.6.1 | ✅ |
-| Merge `sensitive data miner` with `vulnscan` to be 1 tool | v4.0.0 | ❌ |
-| Remake Logicytics End-Execution cycle, where files created must go in `temp/` directory, and zipper takes it from there only, simplifying any code logic with this as well | v4.0.0 | ✅ |
-| Replace Logger.py with Util that contains (tprint), also implement the ExceptionHandler and UpdateManager from Util | v4.0.0 | ✅ |
-| Make WIKI in the git repo, with a yaml file that updates it to the default github wiki | v4.0.0 | ✅ |
diff --git a/README.md b/README.md
index 19b25e9f..c2631523 100644
--- a/README.md
+++ b/README.md
@@ -1,252 +1,146 @@
-# Logicytics: System Data Harvester
-
-Logicytics is a cutting-edge tool designed to
-meticulously harvest and collect a vast array of Windows system data for forensic analysis.
-Crafted with Python, it's an actively developed project that is dedicated
-to gathering as much sensitive data as possible and packaging it neatly into a ZIP file.
-This comprehensive guide is here to equip you with everything you need to use Logicytics effectively.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-> [!CAUTION]
-> By using this software, you agree to the license, and agree that you hold responsibility of how you use and modify the
-> code.
-
-## Installation and Setup
-
-To install and setup Logicytics, follow these steps:
-
-1. **Install Python**: If you don't have Python installed, you can download it from
- the [official website](https://www.python.org/downloads/).
-
-2. **Install Dependencies**: Logicytics requires Python modules. You can install all the required modules by running the
- following command in your terminal: `pip install -r requirements.txt`
-
-3. **Run Logicytics**: To run Logicytics, simply run the following command in your terminal: `python Logicytics.py -h` -
- This opens a help menu.
-
-> [!IMPORTANT]
-> We recommend Python Version `3.11` or higher, as the project is developed and tested on this version.
->
-> To use vulnscan, you will need `torch` - Installation instructions can be
-> found [here](https://pytorch.org/#fws_68845ae25b0fb).
-> If you have a supported GPU, it is recommended to install the Nvidea GPU version of PyTorch for better performance.
->
-> Settings should be: `Stable -> Windows -> Pip -> Python` and if you have a supported CUDA version, select that too
-> else CPU.
-
-### Prerequisites
-
-- **Python**: The project requires Python 3.8 or higher. You can download Python from
- the [official website](https://www.python.org/downloads/).
-
-- **Dependencies**: The project requires certain Python modules to be installed. You can install all the required
- modules by running the following command in your terminal: `pip install -r requirements.txt`.
-
-- **Administrative Privileges**: To be able to run the program using certain features of the project, like registry
- modification, you must run the program with administrative privileges.
-
-- **System Requirements**: The project has been tested on Windows 10 and 11. It will not work on other operating
- systems.
-
-- **Knowledge of Command Line**: The project uses command line options for the user to interact with the program. It is
- recommended to have a basic understanding of command line options.
-
-> [!IMPORTANT]
-> You may create a `.sys.ignore` file in the `CODE/SysInternal_Suite` directory to not extract the exe binaries from the
-> ZIP file (This is done for the OpenSSF score and to discourage binaries being used without source code), if the
-`.sys.ignore` file is not found, it will auto extract the binaries and run them using `Logicytics`.
->
-> For more details on these binaries,
-> go [here](https://learn.microsoft.com/en-us/sysinternals/downloads/sysinternals-suite) - For you weary cautious
-> internet
-> crusaders, you can view the [source code here](https://github.com/MicrosoftDocs/sysinternals) and compare hashes and
-> perform your audits.
-
-## Step-by-Step Installation and Usage
-
-1) Install Python
- If you don't have Python installed, you can download it from the official
- website.
- Make sure to select the option to "Add Python to PATH" during installation.
-
-2) Install Dependencies
- Logicytics requires Python modules. You can install all the required modules by running the following command in your
- terminal:
- `pip install -r requirements.txt`
-
-3) Run Logicytics
- To run Logicytics, simply run the following command in your terminal:
- python Logicytics.py -h
- This opens a help menu.
-
-4) Run the Program
- Once you have run the program, you can run the program with the following command:
- `python Logicytics.py -h`
- Replace the flags with the ones you want to use.
- you must have admin privileges while running!
-
-> [!TIP]
-> Although it's really recommended to use admin, by setting debug in the config.json to true, you can bypass this
-> requirement
-
-5) Wait for magic to happen
- Logicytics will now run and gather data according to the flags you used.
-
-6) Enjoy the gathered data
- Once the program has finished running, you can find the gathered data in the "ACCESS/DATA" folder. Both Zip and Hash
- will be found there.
-
-> [!NOTE]
-> All Zips and Hashes follow a conventional naming mechanism that goes as follows
-> `Logicytics_{CODE-or-MODS}_{Flag-Used}_{Date-And-Time}.zip`
-
-7) Share the love
- If you like Logicytics, please consider sharing it with others or spreading the word about it.
-
-8) Contribute to the project
- If you have an idea or want to contribute to the project, you can submit an issue or PR on
- the GitHub repository.
-
-### Basic Usage
-
-After running and successfully collecting data, you may traverse the ACCESS directory as much as you like,
-Remove add and delete files, it's the safe directory where your backups, hashes, data zips and logs are found.
-
-> [!TIP]
-> Watch this [video](https://www.youtube.com/watch?v=XVTBmdTQqOs) to see a real life demo of Logicytics (Although the
-> tools and interface may be changed as it's an older version `2.1.1` - `2.3.3`)
-
-## Configuration
-
-Logicytics uses a config.ini file to store configurations. The config.ini is located in the CODE directory.
-
-The config.ini file is a INI file that contains important information, you can find it [here](CODE/config.ini)
-
-The config.ini file is used to store the DEBUG flag bool, the VERSION, and the CURRENT_FILES.
-It is also used to store and save settings for other programs.
-
-> [!TIP]
-> CURRENT_FILES is an array of strings that contains the names of the files you have,
-> this is used to later check for corruption or bugs.
-> VERSION is the version of the project, used to check and pull for updates.
-
-## Mods
-
-Mods are special files that are run with the `--modded` flag.
-These files are essentially scripts that are run after the main Logicytics.py script is run
-and the verified scripts are run.
-
-They are used to add extra functionality to the script.
-They are located in the `MODS` directory. In order to make a mod,
-you need to create a python file with the `.py` extension or any of the supported extensions `.exe .ps1 .bat`
-in the `MODS` directory.
-
-These file will be run after the main script is run.
-When making a mod, you should avoid acting based on other files directly,
-as this can cause conflicts with the data harvesting.
-Instead, you should use the `Logicytics.py` file and other scripts as a reference
-for how to add features to the script.
-
-The `--modded` flag is used to run all files in the `MODS` directory.
-This flag is not needed for other files in the `CODE` directory to run,
-but it is needed for mods to run.
-
-The `--modded` flag can also be used to run custom scripts.
-If you want to run a custom script with the `--modded` flag,
-you can add the script to the `MODS` directory, and it will be run with the `--modded` flag.
-
-To check all the mods and how to make your own, you can check the `Logicytics.py` file and the Wiki.
-Also refer to the contributing.md for more info
-
-## Troubleshooting
-
-If you are having issues, here are some troubleshooting tips:
-
-Some errors may not necessarily mean the script is at fault,
-but other OS related faults like files not existing,
-or files not being modified, or files not being created.
-
-Some tips are:
-
-- Check if the script is running as admin and not in a VM
-- Check if the script has the correct permissions and correct dependencies to run
-- Check if the script is not being blocked by a firewall or antivirus or by a VPN or proxy
-- Check if the script is not being blocked by any other software or service
-
-If those don't work attempt:
-
-- Try running the script with powershell instead of cmd, or vice versa
-- Try running the script in a different directory, computer or python version above 3.8
- - Note: The version used to develop, test and run the script is 3.11
-- Try running the `--debug` flag and check the logs
-
-### Support Resources
-
-Check out the [wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) for help.
-
-> [!TIP]
-> You can check out future plans [here](PLANS.md),
-> you can contribute these plans if you have no idea's on what to contribute!
-
-### Want to create your own mod?
-
-Check out the [contributing guidlines](CONTRIBUTING.md) file for more info
-
-### Want More?
-
-If there is a specific piece of data that you would like to see extracted by Logicytics,
-please let us know. We are constantly working to improve the project and adding new features.
-
-### Want to create your own mod?
-
-Check out the [contributing guidlines](CONTRIBUTING.md) file for more info,
-as well as the [wiki guidelines](https://github.com/DefinetlyNotAI/Logicytics/wiki/5-How-to-Contribute) for more info
-Tips and tricks of the given modules/APIs can be
-found [here](https://github.com/DefinetlyNotAI/Logicytics/wiki/6-Code-tips-and-tricks) too!
-
-> [!IMPORTANT]
-> Always adhere to the [coding standards](https://github.com/DefinetlyNotAI/Logicytics/wiki/7-Advanced-Coding-Standards)
-> of Logicytics!
-
-## Conclusion
-
-Logicytics is a powerful tool that can extract a wide variety of data from a Windows system.
-With its ability to extract data from various sources, Logicytics can be used for a variety of purposes,
-from forensics to system information gathering.
-Its ability to extract data from various sources makes it a valuable tool
-for any Windows system administrator or forensic investigator.
-
-> [!CAUTION]
-> Please remember that extracting data from a system without proper authorization is illegal and unethical.
-> Always obtain proper authorization before extracting any data from a system.
-
-## Support Me
-
-Please consider buying me a coffee or sponsoring me in GitHub sponsor,
-I am saving for my college funds, and I need your help!
-Supporters will be placed in the Credits ❤️
-
-### Links
-
-- [Project's Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki)
-- [Project's Future](PLANS.md)
-- [Project's License](LICENSE)
-
-### License
-
-- [Developer Certificate of Origin](DCO.md)
-- [Our License](LICENSE)
+# Logicytics
+
+
+ Reliable Windows evidence collection, organized around one verified run at a time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Logicytics is a Windows evidence collection framework. It validates every collector before use, runs each one in
+isolation, and keeps the result in a manifest-backed run folder. A collector can succeed, skip, or fail without
+obscuring the rest of the verified run.
+
+The complete user and developer manual is in [`docs/README.md`](docs/README.md) and is mirrored to
+the [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki).
+
+> Use Logicytics only on systems and data you are authorized to inspect.
+
+## Start here
+
+The installer is the only command intended to run outside the managed virtual environment. Run it once from the
+repository root:
+
+```powershell
+python -m logicytics.cli.installer
+```
+
+Then activate the environment and check the installation:
+
+```powershell
+.\.venv\Scripts\Activate.ps1
+python -m logicytics preflight
+```
+
+When preflight reports no invalid collectors, make a plan and run it:
+
+```powershell
+python -m logicytics plan --profile standard
+python -m logicytics run --profile standard --acknowledge-authorization
+```
+
+If a normal command says the environment is missing, run the installer. If it says the environment is not active, run
+`.\.venv\Scripts\Activate.ps1` first.
+
+## Choose a run
+
+Every run validates collectors, records a manifest, and packages the result unless `--no-package` is supplied.
+
+| Need | Command |
+|-------------------------------------|--------------------------------------------------------------------------------------------|
+| Fast local inventory | `python -m logicytics run --mode quick --acknowledge-authorization` |
+| Everyday collection | `python -m logicytics run --mode balanced --acknowledge-authorization` |
+| Deterministic sequential collection | `python -m logicytics run --mode standard --acknowledge-authorization` |
+| Local-only collection | `python -m logicytics run --mode offline --acknowledge-authorization` |
+| Extended collection | `python -m logicytics run --mode thorough --acknowledge-authorization` |
+| Thorough duration report | `python -m logicytics run --mode thorough --acknowledge-authorization --performance-check` |
+
+`thorough` can include administrator-only collectors. Start an elevated shell when the plan reports that requirement.
+See every available mode with `python -m logicytics --modes`.
+Add `--performance-check` to any `run --mode ...` command to time that mode's selected collectors serially.
+
+For offline collection from removable storage, add `--usb` to `preflight`,
+`plan`, `run`, or `collector`. It scans `A:` through `Z:` and uses the first
+drive containing `Windows`; use `--usb=E` to select a specific Windows drive.
+USB mode rejects output, cache, and temporary storage on that Windows disk.
+
+## Where results go
+
+Each run receives its own directory under `output/data/`:
+
+```text
+output/data/run//
+ manifest.json # status, collector results, and artifact catalog
+ artifacts/ # collected evidence
+ logs/ # run and collector JSONL events
+ reports/ # generated summaries
+
+output/data/zip/.zip
+output/data/hashes/.zip.sha256
+```
+
+The console is intentionally brief. Use `manifest.json` to inspect a run, the package hash to verify a package, and
+`output/logs/Logicytics.log` for the human-readable application log. Interaction history and its usage graph live in
+`.cache/`, which is created automatically. Worker scratch files default to project-local `.temp/`; set
+`runtime.temporary_directory: system` in `logicytics.yaml` to use `%TEMP%/logicytics/` instead. The fingerprint is a
+SHA-256 identity derived from the immutable run ID; output uses its shortest unique prefix, starting at eight characters
+and extending only on a collision. Set `logging.level: DEBUG` in `logicytics.yaml` when you need detailed worker
+lifecycle information and file call sites.
+
+## Useful commands
+
+```powershell
+# Revalidate every collector instead of reusing cached preflight probes
+python -m logicytics preflight --invalidate-cache
+
+# Inspect a plan without collecting evidence
+python -m logicytics plan --profile standard
+
+# Run one collector only
+python -m logicytics collector core.system.system_info --acknowledge-authorization
+
+# Run the complete test suite
+python -m logicytics.cli.tests
+
+# See diagnostics, configuration, and maintenance state
+python -m logicytics debug
+```
+
+Use `python -m logicytics --help` or append `--help` to any command for its full flags.
+
+## Configuration and extensions
+
+`logicytics.yaml` is the single user configuration file. It controls output locations, worker limits, logging, optional
+Sysinternals setup, and declared collector settings. Keep credentials and secrets out of it.
+
+Core collectors are shipped and validated as part of the application. Plugins are opt-in and must pass the same
+validation boundary before they can run.
+
+- [Configuration reference](docs/CONFIGURATION.md)
+- [Output contract](docs/OUTPUTS.md)
+- [Migration guide](docs/MIGRATION.md)
+- [Flow matrix](docs/FLOW_MATRIX.md)
+
+## Help and contributing
+
+The [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) covers setup, troubleshooting, collector
+development, architecture, and security in more depth.
+
+For changes to Logicytics, read [CONTRIBUTING.md](CONTRIBUTING.md). Please also review [SECURITY.md](SECURITY.md)
+and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
+
+## License
+
+Logicytics is released under the [project license](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
index 110e6ab6..39ab0d78 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,74 +1,62 @@
-# Security Policy
-
-## Supported Versions
-
-This section outlines the versions of our project that are currently supported with security updates.
-
-| Version | Supported | Major Release Date |
-|---------|-----------|--------------------|
-| 3.6.x | ✅ | July 26, 2025 |
-| 3.5.x | ✅ | July 26, 2025 |
-| 3.4.x | ✖️ | January 3, 2025 |
-| 3.3.x | ✖️ | January 3, 2025 |
-| 3.2.x | ✖️ | Dec 19, 2024 |
-| 3.1.x | ✖️ | Dec 11, 2024 |
-| 3.0.x | ❌ | Dec 6, 2024 |
-| 2.5.x | ❌ | Nov 25, 2024 |
-| 2.4.x | ❌ | Nov 12, 2024 |
-| 2.3.x | ❌ | Sep 21, 2024 |
-| 2.2.x | ❌ | Sep 9, 2024 |
-| 2.1.x | ❌ | Aug 29, 2024 |
-| 2.0.x | ❌ | Aug 25, 2024 |
-| 1.6.x | ❌ | Jun 18, 2024 |
-| 1.5.x | ❌ | Jun 10, 2024 |
-| 1.4.x | ❌ | May 30, 2024 |
-| 1.3.x | ❌ | May 21, 2024 |
-| 1.2.x | ❌ | May 16, 2024 |
-| 1.1.x | ❌ | May 10, 2024 |
-| 1.0.x | ❌ | May 4, 2024 |
-
-### Key:
-
-| Key | Desc |
-|-----|-----------------------------------------------------|
-| ✅ | Supported for all security updates |
-| ⚠️ | Supported, but will leave support next major update |
-| ✖️ | Only for major security issues (CVSS 8.0+) |
-| ❌ | No longer supported for any security updates |
-
-## Reporting a Vulnerability
-
-If you believe you have found a security vulnerability in our project, we encourage you to report it to us. Your report
-will help us improve the security of our project and ensure the trust of our users.
-
-### How to Report a Vulnerability
-
-1. **Identify the Vulnerability**: Clearly describe the vulnerability, including how it can be exploited and any
- potential impact.
-2. **Provide Detailed Information**: Include as much detail as possible, such as the version of the project affected,
- steps to reproduce the vulnerability, and any relevant code snippets or screenshots.
-3. **Contact Us**: Send your report to my [email](mailto:Nirt_12023@outlook.com). Please include "Security Vulnerability
- Report" in the subject line.
-
-### What to Expect
-
-- **Acknowledgment**: Upon receiving your report, we will acknowledge receipt within 2�5 business days.
-- **Investigation**: Our security team will investigate the vulnerability and determine its validity.
-- **Update**: If the vulnerability is accepted, we will work on a fix and provide an update on the timeline for a
- security update.
-- **Communication**: We will communicate with you regarding the status of the vulnerability and any necessary actions.
-
-### Vulnerability Acceptance Criteria
-
-- The vulnerability must be reproducible.
-- The vulnerability must be exploitable.
-- The vulnerability must not be a false positive.
-
-### Vulnerability Decline Criteria
-
-- The vulnerability is outside the scope of our project.
-
-Thank you for helping us maintain the security of our project. Your contribution is invaluable in keeping our users
-safe.
-
----
+# Security policy
+
+## Supported versions
+
+| Version | Security support | Release date |
+| ------- | ---------------- | ------------------ |
+| 4.0.x | Supported | September 25, 2026 |
+| 3.6.x | Partial Support | July 26, 2025 |
+| 3.5.x | Partial Support | July 26, 2025 |
+| 3.4.x | Partial Support | January 3, 2025 |
+| 3.3.x | Unsupported | January 3, 2025 |
+| 3.2.x | Unsupported | December 19, 2024 |
+| 3.1.x | Unsupported | December 11, 2024 |
+| 3.0.x | Unsupported | December 6, 2024 |
+| 2.5.x | Unsupported | November 25, 2024 |
+| 2.4.x | Unsupported | November 12, 2024 |
+| 2.3.x | Unsupported | September 21, 2024 |
+| 2.2.x | Unsupported | September 9, 2024 |
+| 2.1.x | Unsupported | August 29, 2024 |
+| 2.0.x | Unsupported | August 25, 2024 |
+| 1.6.x | Unsupported | June 18, 2024 |
+| 1.5.x | Unsupported | June 10, 2024 |
+| 1.4.x | Unsupported | May 30, 2024 |
+| 1.3.x | Unsupported | May 21, 2024 |
+| 1.2.x | Unsupported | May 16, 2024 |
+| 1.1.x | Unsupported | May 10, 2024 |
+| 1.0.x | Unsupported | May 4, 2024 |
+
+Only the current v4 release line receives security fixes. Upgrade through the
+documented [migration boundary](docs/MIGRATION.md); do not run an unsupported checkout
+against sensitive evidence.
+
+## Reporting a vulnerability
+
+Do not open a public issue containing an exploit, credential, private key,
+collected artifact, host identifier, or unredacted log. Email
+[Nirt_12023@outlook.com](mailto:Nirt_12023@outlook.com) with the subject
+`Logicytics security vulnerability`.
+
+Include:
+
+- the affected Logicytics version and commit;
+- Windows edition/build and Python version;
+- the affected collector, command, capability, or package contract;
+- reproducible steps and expected security boundary;
+- impact and required authorization/elevation state; and
+- a minimal redacted proof of concept.
+
+Reports should target behavior owned by this repository and be reproducible.
+Maintainers will acknowledge receipt, investigate the report, coordinate a fix
+and disclosure window when accepted, and credit the reporter if requested.
+
+## Security boundaries
+
+Logicytics requires authorization acknowledgement and explicit capability
+approval. A vulnerability includes bypassing those checks, escaping a collector
+workspace, publishing undeclared evidence, leaking secrets to logs/metadata,
+executing an undeclared process or network action, corrupting package/hash
+verification, or allowing one collector to affect unrelated runs.
+
+Do not weaken isolation, redaction, path validation, output limits, cancellation,
+or package verification while developing a fix. Use synthetic fixtures only.
diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py
new file mode 100644
index 00000000..9cf16dcb
--- /dev/null
+++ b/core/bluetooth/bluetooth_addresses.py
@@ -0,0 +1,123 @@
+"""Export paired Bluetooth names and address-like PnP identifiers as bounded JSON."""
+
+from __future__ import annotations
+
+import json
+import re
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common access-denied wording from PowerShell PnP queries."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+def _format_address(instance_id: str | None) -> str | None:
+ """Extract one conventional colon-separated address from a PnP instance identifier."""
+ if instance_id is None:
+ return None
+
+ match = re.search(
+ r"(? CollectorMetadata:
+ """Declare the subprocess-gated Bluetooth-address JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.bluetooth.bluetooth_addresses",
+ name="Bluetooth addresses",
+ version="4.0.0",
+ specialty=Specialty.BLUETOOTH,
+ output_media_types=("application/json",),
+ description="Exports paired Bluetooth friendly names and address-like identifiers from PnP data.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("wireless_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=256 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query PnP data and register Bluetooth names and extracted addresses as JSON."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-address collection")
+ context.report_progress("bluetooth_addresses_started")
+ command = "Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Present | ConvertTo-Json -Depth 2"
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=25,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Bluetooth PnP access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query failed", errors=(detail,))
+ try:
+ raw = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "Bluetooth PnP query returned invalid JSON",
+ errors=(str(error),),
+ )
+ devices = raw if isinstance(raw, list) else [raw]
+ if not all(isinstance(device, dict) for device in devices):
+ return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned an unexpected result")
+ report = [
+ {
+ "friendly_name": device.get("FriendlyName"),
+ "instance_id": device.get("InstanceId"),
+ "address": _format_address(device.get("InstanceId")),
+ "present": device.get("Present"),
+ }
+ for device in devices
+ ]
+ output = context.workspace / "bluetooth_addresses.json"
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "bluetooth_addresses_finished",
+ device_count=len(report),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("Bluetooth names and address identifiers collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py
new file mode 100644
index 00000000..a2599cda
--- /dev/null
+++ b/core/bluetooth/bluetooth_history.py
@@ -0,0 +1,107 @@
+"""Write a timestamped Bluetooth-device snapshot for retained run-history evidence."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common Windows permission-denied wording."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class BluetoothHistoryCollector(CoreCollector):
+ """Export a timestamped Bluetooth snapshot without mutating shared history state."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the bounded Bluetooth-history JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.bluetooth.bluetooth_history",
+ name="Bluetooth history snapshot",
+ version="4.0.0",
+ specialty=Specialty.BLUETOOTH,
+ output_media_types=("application/json",),
+ description="Exports a timestamped Bluetooth PnP snapshot; retained run packages provide historical evidence.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("device_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=45,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query Bluetooth PnP state and register a timestamped JSON snapshot."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-history collection")
+ context.report_progress("bluetooth_history_started")
+ command = (
+ "$ErrorActionPreference = 'Stop'; "
+ "@(Get-PnpDevice -Class Bluetooth | "
+ "Select-Object FriendlyName, InstanceId, Status, Problem, Present) | "
+ "ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Bluetooth-history access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query failed", errors=(detail,))
+ try:
+ devices = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "Bluetooth-history query returned invalid JSON",
+ errors=(str(error),),
+ )
+ snapshot = {
+ "collected_at": datetime.now(UTC).isoformat(),
+ "devices": devices if isinstance(devices, list) else [devices],
+ }
+ output = context.workspace / "bluetooth_history.json"
+ output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "bluetooth_history_finished",
+ device_count=len(snapshot["devices"]),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("Bluetooth history snapshot collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Leave temporary snapshot removal to the isolated workspace lifecycle."""
diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py
new file mode 100644
index 00000000..eb8ce941
--- /dev/null
+++ b/core/bluetooth/paired_devices.py
@@ -0,0 +1,101 @@
+"""Collect Windows Plug and Play metadata for Bluetooth devices."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize Windows and PowerShell access-denied messages."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class PairedDevicesCollector(CoreCollector):
+ """Export Bluetooth PnP device metadata without changing Bluetooth state."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the read-only PnP query, subprocess permission, and bounded output."""
+ return CollectorMetadata(
+ id="core.bluetooth.paired_devices",
+ name="Bluetooth devices",
+ version="4.0.0",
+ specialty=Specialty.BLUETOOTH,
+ output_media_types=("application/json",),
+ description="Captures available Bluetooth Plug and Play device metadata through PowerShell.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before issuing the PnP query."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run a read-only Bluetooth PnP query and register its normalized JSON output."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth collection")
+ context.report_progress("bluetooth_devices_started")
+ command = (
+ "Get-PnpDevice -Class Bluetooth | "
+ "Select-Object FriendlyName, InstanceId, Status, Class, Problem, Present | "
+ "ConvertTo-Json -Depth 2"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=20,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Bluetooth PnP access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query failed", errors=(detail,))
+ try:
+ payload = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "Bluetooth PnP query returned invalid JSON",
+ errors=(str(error),),
+ )
+ devices = payload if isinstance(payload, list) else [payload]
+ output = context.workspace / "bluetooth_devices.json"
+ output.write_text(json.dumps(devices, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "bluetooth_devices_finished",
+ device_count=len(devices),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("Bluetooth device metadata collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the PowerShell process has already exited."""
diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py
new file mode 100644
index 00000000..b2b7ae9b
--- /dev/null
+++ b/core/browser/browser_data_backup.py
@@ -0,0 +1,169 @@
+"""Copy bounded browser-profile evidence after explicit browser-data approval."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ EvidenceKind,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+CHROMIUM_FILES = (
+ "History",
+ "History-journal",
+ "Cookies",
+ "Cookies-journal",
+ "Login Data",
+ "Login Data-journal",
+ "Bookmarks",
+ "Preferences",
+)
+FIREFOX_FILES = ("places.sqlite", "cookies.sqlite", "logins.json", "key4.db", "prefs.js")
+MAX_FILE_BYTES = 50 * 1024 * 1024
+MAX_TOTAL_BYTES = 256 * 1024 * 1024
+
+
+def _copy_file(source: Path, destination: Path, copied_bytes: int) -> tuple[Path | None, int]:
+ """Copy one bounded regular file, preserving metadata, or return no artifact."""
+ try:
+ if not source.is_file() or source.is_symlink():
+ return None, copied_bytes
+ size = source.stat().st_size
+ except OSError:
+ return None, copied_bytes
+ if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES:
+ return None, copied_bytes
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ filesystem_adapter.copy_file(source, destination)
+ return destination, copied_bytes + size
+
+
+class BrowserDataBackupCollector(CoreCollector):
+ """Copy bounded data from supported local browser profiles into a private workspace."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the explicit-consent browser-data artifact contract."""
+ return CollectorMetadata(
+ id="core.browser.browser_data_backup",
+ name="Browser data backup",
+ version="4.0.0",
+ specialty=Specialty.BROWSER,
+ output_media_types=("application/octet-stream",),
+ description="Copies bounded local profile evidence from Edge, Chrome, Firefox, Opera, and Opera GX.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(
+ Capability.FILESYSTEM_READ,
+ Capability.BROWSER_DATA,
+ Capability.SENSITIVE_FILES,
+ ),
+ sensitive_data_categories=("browser_history", "cookies", "credentials"),
+ default_profiles=("deep",),
+ timeout_seconds=300,
+ maximum_output_bytes=256 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state before browser-profile copying starts."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Copy supported profile files from configured local browser locations."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before browser data backup")
+ home = filesystem_adapter.home()
+ local = filesystem_adapter.environment_path("LOCALAPPDATA", home / "AppData" / "Local")
+ roaming = filesystem_adapter.environment_path("APPDATA", home / "AppData" / "Roaming")
+ chromium_roots = (
+ ("chrome", local / "Google" / "Chrome" / "User Data"),
+ ("edge", local / "Microsoft" / "Edge" / "User Data"),
+ ("opera", roaming / "Opera Software" / "Opera Stable"),
+ ("opera_gx", roaming / "Opera Software" / "Opera GX Stable"),
+ )
+ copied: list[Path] = []
+ copied_bytes = 0
+ context.report_progress("browser_data_backup_started")
+ for browser, root in chromium_roots:
+ try:
+ if browser.startswith("opera"):
+ profile_roots = [root]
+ else:
+ profile_roots = [
+ path
+ for path in filesystem_adapter.children(root)
+ if path.is_dir() and (path.name == "Default" or path.name.startswith("Profile "))
+ ]
+ except OSError:
+ continue
+ for profile in profile_roots:
+ for filename in CHROMIUM_FILES:
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup")
+ destination, copied_bytes = _copy_file(
+ profile / filename,
+ context.workspace / "browser_data" / browser / profile.name / filename,
+ copied_bytes,
+ )
+ if context.is_cancelled:
+ if destination is not None:
+ destination.unlink(missing_ok=True)
+ for copied_path in copied:
+ copied_path.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup")
+ if destination is not None:
+ copied.append(destination)
+ firefox_profiles = roaming / "Mozilla" / "Firefox" / "Profiles"
+ try:
+ profiles = [path for path in filesystem_adapter.children(firefox_profiles) if path.is_dir()]
+ except OSError:
+ profiles = []
+ for profile in profiles:
+ for filename in FIREFOX_FILES:
+ if context.is_cancelled:
+ for copied_path in copied:
+ copied_path.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup")
+ destination, copied_bytes = _copy_file(
+ profile / filename,
+ context.workspace / "browser_data" / "firefox" / profile.name / filename,
+ copied_bytes,
+ )
+ if context.is_cancelled:
+ if destination is not None:
+ destination.unlink(missing_ok=True)
+ for copied_path in copied:
+ copied_path.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup")
+ if destination is not None:
+ copied.append(destination)
+ if not copied:
+ context.report_progress("browser_data_backup_finished", copied_files=0, bytes_written=0)
+ return CollectorResult.succeeded("no supported local browser profile data met the bounded backup policy")
+ artifacts = []
+ for path in copied:
+ if context.is_cancelled:
+ for unpublished in copied[len(artifacts):]:
+ unpublished.unlink(missing_ok=True)
+ return CollectorResult.cancelled("cancelled during browser-data registration", tuple(artifacts))
+ artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW))
+ artifact_tuple = tuple(artifacts)
+ context.report_progress(
+ "browser_data_backup_finished",
+ copied_files=len(artifact_tuple),
+ bytes_written=sum(item.size_bytes for item in artifact_tuple),
+ )
+ return CollectorResult.succeeded("browser data backup collected", artifact_tuple)
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Leave copied evidence removal to the isolated workspace lifecycle."""
diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py
new file mode 100644
index 00000000..8bb5bebd
--- /dev/null
+++ b/core/diagnostics/sysinternals_report.py
@@ -0,0 +1,105 @@
+"""Discover and report bounded output from supported local Sysinternals tools."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from subprocess import TimeoutExpired
+from time import monotonic
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+
+TOOLS = ("psfile", "psgetsid", "psinfo", "pslist", "psloggedon", "psloglist")
+MAX_OUTPUT_CHARS = 512_000
+MAX_COLLECTION_SECONDS = 45
+MAX_TOOL_SECONDS = 8
+
+
+class SysinternalsReportCollector(CoreCollector):
+ """Run available local Sysinternals tools and consolidate their diagnostic output."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated Sysinternals report artifact contract."""
+ return CollectorMetadata(
+ id="core.diagnostics.sysinternals_report",
+ name="Sysinternals report",
+ version="4.0.0",
+ specialty=Specialty.DIAGNOSTICS,
+ output_media_types=("text/plain",),
+ description="Reports supported Sysinternals binary/archive state and consolidates available tool output.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("system_diagnostics",),
+ default_profiles=("deep",),
+ timeout_seconds=60,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state before local tool discovery."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Discover local binaries, execute available tools, and write one text report."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Sysinternals collection")
+ project_root = Path(__file__).resolve().parents[2]
+ search_roots = (
+ project_root / "sysinternals",
+ project_root / "tools" / "sysinternals",
+ Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Sysinternals",
+ )
+ archive_paths = tuple(root.with_suffix(".zip") for root in search_roots)
+ sections: list[str] = ["Sysinternals report", ""]
+ context.report_progress("sysinternals_report_started")
+ deadline = monotonic() + MAX_COLLECTION_SECONDS
+ for tool in TOOLS:
+ binary = next(
+ (root / f"{tool}.exe" for root in search_roots if (root / f"{tool}.exe").is_file()),
+ None,
+ )
+ sections.append(f"## {tool}")
+ if binary is None:
+ archive_state = "archive available" if any(
+ path.is_file() for path in archive_paths) else "binary and archive missing"
+ sections.extend((f"status: {archive_state}", ""))
+ continue
+ remaining = deadline - monotonic()
+ if remaining <= 0:
+ sections.extend(("status: skipped because the collection time budget was exhausted", ""))
+ continue
+ timeout = min(MAX_TOOL_SECONDS, max(1, int(remaining)))
+ context.report_progress("sysinternals_tool_started", tool=tool, timeout_seconds=timeout)
+ try:
+ completed = subprocess.run([str(binary)], capture_output=True, check=False, text=True, timeout=timeout)
+ except OSError as error:
+ sections.extend((f"status: execution error: {error}", ""))
+ continue
+ except TimeoutExpired as error:
+ sections.extend((f"status: timed out after {error.timeout} seconds", ""))
+ continue
+ output = (completed.stdout + (
+ "\n" if completed.stdout and completed.stderr else "") + completed.stderr).strip()
+ sections.extend((f"status: executed (exit {completed.returncode})", output[:MAX_OUTPUT_CHARS], ""))
+ report = "\n".join(sections)
+ output_path = context.workspace / "sysinternals_report.txt"
+ output_path.write_text(report[:MAX_OUTPUT_CHARS], encoding="utf-8")
+ artifact = context.artifacts.register_file(output_path, media_type="text/plain")
+ context.report_progress("sysinternals_report_finished", bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("Sysinternals report collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because each tool exits before its output is returned."""
diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py
new file mode 100644
index 00000000..04eea1bb
--- /dev/null
+++ b/core/encryption/bitlocker_status.py
@@ -0,0 +1,77 @@
+"""Export local BitLocker status as bounded, read-only text evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str, return_code: int) -> bool:
+ """Recognize textual and HRESULT access-denied results from manage-bde."""
+ normalized = detail.casefold()
+ return return_code == 2147749891 or "permission denied" in normalized or (
+ "access" in normalized and "denied" in normalized)
+
+
+class BitlockerStatusCollector(CoreCollector):
+ """Capture local BitLocker status without changing protectors or encryption state."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated BitLocker-status artifact contract."""
+ return CollectorMetadata(
+ id="core.encryption.bitlocker_status",
+ name="BitLocker status",
+ version="4.0.0",
+ specialty=Specialty.ENCRYPTION,
+ output_media_types=("text/plain",),
+ description="Exports local drive BitLocker status through the read-only manage-bde command.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("encryption_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=45,
+ maximum_output_bytes=2 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and manage-bde availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("manage-bde") is None:
+ return ValidationResult(False, reasons=("manage-bde is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run read-only BitLocker status and register its text evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before BitLocker-status collection")
+ context.report_progress("bitlocker_status_started")
+ completed = subprocess.run(["manage-bde", "-status"], capture_output=True, check=False, text=True, timeout=40)
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"manage-bde exit code {completed.returncode}"
+ if _is_access_denied(detail, completed.returncode):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "BitLocker status access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "BitLocker status query failed", errors=(detail,))
+ output = context.workspace / "bitlocker_status.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ context.report_progress("bitlocker_status_finished", bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("BitLocker status collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because manage-bde exits before the result is returned."""
diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py
new file mode 100644
index 00000000..7b825d36
--- /dev/null
+++ b/core/encryption/bitlocker_volumes.py
@@ -0,0 +1,121 @@
+"""Export PowerShell BitLocker volume data as bounded, read-only JSON evidence."""
+
+from __future__ import annotations
+
+import getpass
+import json
+import platform
+from datetime import UTC, datetime
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import network_adapter as socket
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which, windows_api_adapter
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+def _is_administrator() -> bool | None:
+ """Return the local administrator token state when Windows can report it."""
+ try:
+ return windows_api_adapter.is_administrator()
+ except OSError:
+ return None
+
+
+class BitlockerVolumesCollector(CoreCollector):
+ """Capture local BitLocker-volume metadata without changing encryption configuration."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated BitLocker-volume JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.encryption.bitlocker_volumes",
+ name="BitLocker volumes",
+ version="4.0.0",
+ specialty=Specialty.ENCRYPTION,
+ output_media_types=("application/json",),
+ description="Exports local BitLocker volume metadata through read-only Get-BitLockerVolume.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("encryption_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=45,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query BitLocker volumes and register their JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before BitLocker-volume collection")
+ context.report_progress("bitlocker_volumes_started")
+ command = (
+ "$ErrorActionPreference = 'Stop'; "
+ "ConvertTo-Json -InputObject @(Get-BitLockerVolume | "
+ "Select-Object MountPoint, VolumeType, VolumeStatus, ProtectionStatus, EncryptionMethod, "
+ "EncryptionPercentage, LockStatus, AutoUnlockEnabled) -Depth 4"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "BitLocker volume access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query failed", errors=(detail,))
+ try:
+ volumes = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "BitLocker volume query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(volumes, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned an unexpected result")
+ report = {
+ "collected_at": datetime.now(UTC).isoformat(),
+ "user": getpass.getuser(),
+ "is_administrator": _is_administrator(),
+ "hostname": socket.gethostname(),
+ "platform": platform.platform(),
+ "volumes": volumes,
+ }
+ output = context.workspace / "bitlocker_volumes.json"
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(volumes) if isinstance(volumes, list) else 1
+ context.report_progress("bitlocker_volumes_finished", volume_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("BitLocker volumes collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py
new file mode 100644
index 00000000..994cdff2
--- /dev/null
+++ b/core/event_log/application_events.py
@@ -0,0 +1,102 @@
+"""Export a bounded Windows Application event-log sample as CSV evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ ResourceClass,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+_MAX_EVENTS = 1_000
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common access-denied wording emitted by PowerShell event queries."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class ApplicationEventsCollector(CoreCollector):
+ """Capture a bounded CSV sample of Application events without changing event logs."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated Application-event sample and output limit."""
+ return CollectorMetadata(
+ id="core.event_log.application_events",
+ name="Application event log",
+ version="4.0.0",
+ specialty=Specialty.EVENT_LOG,
+ output_media_types=("text/csv",),
+ description="Exports up to 1,000 local Windows Application events as CSV.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("event_logs",),
+ parallel_safe=True,
+ resource_class=ResourceClass.GENERAL,
+ default_profiles=("deep",),
+ timeout_seconds=90,
+ maximum_output_bytes=8 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before querying events."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query a bounded Application-event sample and register its CSV output."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Application event-log collection")
+ context.report_progress("application_events_started", maximum_events=_MAX_EVENTS)
+ command = (
+ f"Get-WinEvent -LogName Application -MaxEvents {_MAX_EVENTS} | "
+ "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | "
+ "ConvertTo-Csv -NoTypeInformation"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=75,
+ )
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Application event-log query")
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Application event-log access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "Application event-log query failed", errors=(detail,))
+ output = context.workspace / "application_events.csv"
+ output.write_text(completed.stdout, encoding="utf-8")
+ if context.is_cancelled:
+ output.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Application event-log export")
+ artifact = context.artifacts.register_file(output, media_type="text/csv")
+ event_count = max(0, len(completed.stdout.splitlines()) - 1)
+ context.report_progress(
+ "application_events_finished",
+ event_count=event_count,
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("Application event-log sample collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the event query process has already exited."""
diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py
new file mode 100644
index 00000000..8cd005c6
--- /dev/null
+++ b/core/event_log/security_events.py
@@ -0,0 +1,98 @@
+"""Export a bounded Windows Security event-log sample as CSV evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ ResourceClass,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+_MAX_EVENTS = 1_000
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common access-denied wording emitted by PowerShell event queries."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or "unauthorized" in normalized or (
+ "access" in normalized and "denied" in normalized)
+
+
+class SecurityEventsCollector(CoreCollector):
+ """Capture a bounded CSV sample of Security events without changing event logs."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated Security-event sample and output limit."""
+ return CollectorMetadata(
+ id="core.event_log.security_events",
+ name="Security event log",
+ version="4.0.0",
+ specialty=Specialty.EVENT_LOG,
+ output_media_types=("text/csv",),
+ description="Exports up to 1,000 local Windows Security events as CSV.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("event_logs",),
+ parallel_safe=True,
+ resource_class=ResourceClass.GENERAL,
+ default_profiles=("deep",),
+ timeout_seconds=90,
+ maximum_output_bytes=8 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before querying events."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query a bounded Security-event sample and register its CSV output."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Security event-log collection")
+ context.report_progress("security_events_started", maximum_events=_MAX_EVENTS)
+ command = (
+ f"Get-WinEvent -LogName Security -MaxEvents {_MAX_EVENTS} | "
+ "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | ConvertTo-Csv -NoTypeInformation"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=75,
+ )
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Security event-log query")
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Security event-log access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "Security event-log query failed", errors=(detail,))
+ output = context.workspace / "security_events.csv"
+ output.write_text(completed.stdout, encoding="utf-8")
+ if context.is_cancelled:
+ output.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Security event-log export")
+ artifact = context.artifacts.register_file(output, media_type="text/csv")
+ event_count = max(0, len(completed.stdout.splitlines()) - 1)
+ context.report_progress("security_events_finished", event_count=event_count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("Security event-log sample collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the event query process has already exited."""
diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py
new file mode 100644
index 00000000..ea3b24b2
--- /dev/null
+++ b/core/event_log/system_events.py
@@ -0,0 +1,98 @@
+"""Export a bounded Windows System event-log sample as CSV evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ ResourceClass,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+_MAX_EVENTS = 1_000
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common access-denied wording emitted by PowerShell event queries."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class SystemEventsCollector(CoreCollector):
+ """Capture a bounded CSV sample of Windows System events without changing event logs."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated event-log sample and its output limit."""
+ return CollectorMetadata(
+ id="core.event_log.system_events",
+ name="System event log",
+ version="4.0.0",
+ specialty=Specialty.EVENT_LOG,
+ output_media_types=("text/csv",),
+ description="Exports up to 1,000 local Windows System events as CSV.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("event_logs",),
+ parallel_safe=True,
+ resource_class=ResourceClass.GENERAL,
+ default_profiles=("deep",),
+ timeout_seconds=90,
+ maximum_output_bytes=8 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before querying events."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query a bounded System event sample and register its CSV output."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before event-log collection")
+ context.report_progress("system_events_started", maximum_events=_MAX_EVENTS)
+ command = (
+ f"Get-WinEvent -LogName System -MaxEvents {_MAX_EVENTS} | "
+ "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | "
+ "ConvertTo-Csv -NoTypeInformation"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=75,
+ )
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during System event-log query")
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "System event-log access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "System event-log query failed", errors=(detail,))
+ output = context.workspace / "system_events.csv"
+ output.write_text(completed.stdout, encoding="utf-8")
+ if context.is_cancelled:
+ output.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during System event-log export")
+ artifact = context.artifacts.register_file(output, media_type="text/csv")
+ event_count = max(0, len(completed.stdout.splitlines()) - 1)
+ context.report_progress("system_events_finished", event_count=event_count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("System event-log sample collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the event query process has already exited."""
diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py
new file mode 100644
index 00000000..317a89a9
--- /dev/null
+++ b/core/filesystem/sensitive_file_inventory.py
@@ -0,0 +1,183 @@
+"""Find and copy bounded sensitive-named files after explicit approval."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ EvidenceKind,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+KEYWORDS = (
+ "password",
+ "secret",
+ "code",
+ "login",
+ "api",
+ "key",
+ "token",
+ "auth",
+ "credential",
+ "private",
+ "certificate",
+ "ssh",
+ "pgp",
+ "wallet",
+)
+EXTENSIONS = {
+ ".txt",
+ ".csv",
+ ".json",
+ ".xml",
+ ".yml",
+ ".yaml",
+ ".ini",
+ ".cfg",
+ ".conf",
+ ".log",
+ ".pdf",
+ ".doc",
+ ".docx",
+ ".xls",
+ ".xlsx",
+ ".zip",
+ ".db",
+ ".sqlite",
+ ".pem",
+ ".key",
+ ".ppk",
+}
+MAX_FILE_BYTES = 10 * 1024 * 1024
+
+
+def _copy(source: Path, destination: Path) -> Path | None:
+ """Copy one regular source file into the private workspace, preserving metadata."""
+ try:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ filesystem_adapter.copy_file(source, destination)
+ except OSError:
+ return None
+ return destination
+
+
+class SensitiveFileInventoryCollector(CoreCollector):
+ """Search a bounded system-drive subset for sensitive-named supported files."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the explicit-consent sensitive-file inventory artifact contract."""
+ return CollectorMetadata(
+ id="core.filesystem.sensitive_file_inventory",
+ name="Sensitive file inventory",
+ version="4.0.0",
+ specialty=Specialty.FILESYSTEM,
+ output_media_types=("application/octet-stream",),
+ description="Finds and copies bounded supported files with sensitive-data keywords in their names.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES),
+ sensitive_data_categories=("credentials", "private_keys", "personal_documents"),
+ default_profiles=("deep",),
+ timeout_seconds=300,
+ maximum_output_bytes=256 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Validate bounded search settings before walking the requested root."""
+ if context.is_cancelled:
+ return ValidationResult(
+ False,
+ reasons=("run cancellation was requested",),
+ )
+
+ max_directories = context.setting_int("max_directories", 5_000)
+ max_matches = context.setting_int("max_matches", 500)
+
+ if max_directories < 1 or max_matches < 1:
+ return ValidationResult(
+ False,
+ reasons=("max_directories and max_matches must be positive",),
+ )
+
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Find matches and copy them through the scheduler-owned collector worker."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before sensitive-file inventory")
+ root = Path(
+ context.setting_str(
+ "root",
+ str(filesystem_adapter.system_drive_root()),
+ )
+ )
+ max_directories = context.setting_int("max_directories", 5_000)
+ max_matches = context.setting_int("max_matches", 500)
+ matches: list[Path] = []
+ scanned_directories = 0
+ context.report_progress("sensitive_file_inventory_started", root=str(root))
+ for directory, directories, filenames in filesystem_adapter.walk(root, onerror=lambda _: None):
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file inventory")
+ scanned_directories += 1
+ if scanned_directories > max_directories or len(matches) >= max_matches:
+ directories.clear()
+ break
+ for filename in filenames:
+ candidate = Path(directory) / filename
+ name = filename.casefold()
+ if candidate.suffix.casefold() not in EXTENSIONS or not any(keyword in name for keyword in KEYWORDS):
+ continue
+ try:
+ if candidate.is_symlink() or candidate.stat().st_size > MAX_FILE_BYTES:
+ continue
+ except OSError:
+ continue
+ matches.append(candidate)
+ if len(matches) >= max_matches:
+ break
+ destination_root = context.workspace / "sensitive_file_inventory"
+ copied: list[Path] = []
+ for source in matches:
+ if context.is_cancelled:
+ for copied_path in copied:
+ copied_path.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file copy")
+ destination = destination_root / source.drive.replace(":", "") / source.relative_to(root)
+ if copied_path := _copy(source, destination):
+ copied.append(copied_path)
+ if context.is_cancelled:
+ for copied_path in copied:
+ copied_path.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file copy")
+ if not copied:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "no sensitive-named supported files met the bounded inventory policy",
+ )
+ artifacts = []
+ for path in copied:
+ if context.is_cancelled:
+ for unpublished in copied[len(artifacts):]:
+ unpublished.unlink(missing_ok=True)
+ return CollectorResult.cancelled("cancelled during sensitive-file registration", tuple(artifacts))
+ artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW))
+ artifact_tuple = tuple(artifacts)
+ context.report_progress(
+ "sensitive_file_inventory_finished",
+ scanned_directories=scanned_directories,
+ copied_files=len(artifact_tuple),
+ bytes_written=sum(item.size_bytes for item in artifact_tuple),
+ )
+ return CollectorResult.succeeded("sensitive file inventory collected", artifact_tuple)
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Leave copied evidence removal to the isolated workspace lifecycle."""
diff --git a/core/filesystem/startup_folder_entries.py b/core/filesystem/startup_folder_entries.py
new file mode 100644
index 00000000..24c732d5
--- /dev/null
+++ b/core/filesystem/startup_folder_entries.py
@@ -0,0 +1,116 @@
+"""Export bounded metadata for files in the standard Windows Startup folders."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+_MAXIMUM_ENTRIES = 500
+
+
+def _startup_folders() -> tuple[Path, ...]:
+ """Return the standard per-user and all-users Startup folders without creating them."""
+ folders: list[Path] = []
+ appdata = os.environ.get("APPDATA")
+ programdata = os.environ.get("PROGRAMDATA")
+ if appdata:
+ folders.append(Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup")
+ if programdata:
+ folders.append(Path(programdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup")
+ return tuple(folders)
+
+
+class StartupFolderEntriesCollector(CoreCollector):
+ """Capture read-only metadata for entries configured to start through Startup folders."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the filesystem-read, bounded Startup-folder JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.filesystem.startup_folder_entries",
+ name="Startup folder entries",
+ version="4.0.0",
+ specialty=Specialty.FILESYSTEM,
+ output_media_types=("application/json",),
+ description="Exports names, locations, sizes, and timestamps for up to 500 Startup-folder entries.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.FILESYSTEM_READ,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=256 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and whether at least one standard folder is available."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if not _startup_folders():
+ return ValidationResult(False, reasons=("Windows Startup-folder environment variables are unavailable",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Record bounded metadata for direct files in existing Startup folders."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Startup-folder collection")
+ context.report_progress("startup_folder_entries_started")
+ entries: list[dict[str, int | str]] = []
+ missing_folders = 0
+ for folder in _startup_folders():
+ if not folder.is_dir():
+ missing_folders += 1
+ continue
+ try:
+ children = sorted(filesystem_adapter.children(folder), key=lambda path: path.name.casefold())
+ except OSError as error:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Startup-folder access was denied",
+ errors=(str(error),),
+ )
+ for child in children:
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Startup-folder collection")
+ if len(entries) >= _MAXIMUM_ENTRIES:
+ break
+ try:
+ stat = child.stat()
+ except OSError:
+ continue
+ entries.append(
+ {
+ "name": child.name,
+ "path": str(child),
+ "is_directory": str(child.is_dir()).lower(),
+ "size_bytes": stat.st_size,
+ "modified_epoch": int(stat.st_mtime),
+ }
+ )
+ output = context.workspace / "startup_folder_entries.json"
+ output.write_text(
+ json.dumps({"entries": entries, "missing_folders": missing_folders}, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "startup_folder_entries_finished",
+ entry_count=len(entries),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("Startup-folder entries collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because collection only reads directory metadata."""
diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py
new file mode 100644
index 00000000..ee63f09d
--- /dev/null
+++ b/core/filesystem/system_drive_listing.py
@@ -0,0 +1,135 @@
+"""Create a bounded recursive listing of the Windows system drive."""
+
+from __future__ import annotations
+
+from collections import deque
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+_DEFAULT_MAX_ENTRIES = 10_000
+_DEFAULT_MAX_DEPTH = 16
+_HARD_MAX_ENTRIES = 50_000
+_HARD_MAX_DEPTH = 32
+
+
+def _setting(settings: object, key: str, default: int, maximum: int) -> int:
+ """Return one positive integer setting constrained to its documented maximum."""
+ value = settings.get(key, default) if isinstance(settings, dict) else default
+ return value if isinstance(value, int) and 1 <= value <= maximum else default
+
+
+def _scan_directory(directory: Path) -> tuple[list[str], list[Path]]:
+ """Return metadata-only entries and non-link child directories for one path."""
+ entries: list[str] = []
+ children: list[Path] = []
+ try:
+ with filesystem_adapter.scan(directory) as scan:
+ for entry in scan:
+ try:
+ is_directory = entry.is_dir(follow_symlinks=False)
+ except OSError:
+ continue
+ entries.append(f"{entry.path}{'/' if is_directory else ''}")
+ if is_directory:
+ children.append(Path(entry.path))
+ except OSError:
+ return [], []
+ return sorted(entries), sorted(children)
+
+
+class SystemDriveListingCollector(CoreCollector):
+ """Capture a bounded recursive directory listing without reading file contents."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the filesystem-read system-drive listing contract."""
+ return CollectorMetadata(
+ id="core.filesystem.system_drive_listing",
+ name="System drive listing",
+ version="4.0.0",
+ specialty=Specialty.FILESYSTEM,
+ output_media_types=("text/plain",),
+ description="Exports a configurable, bounded recursive listing of the Windows system drive.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.FILESYSTEM_READ,),
+ sensitive_data_categories=("filesystem_metadata",),
+ default_profiles=("deep",),
+ timeout_seconds=180,
+ maximum_output_bytes=8 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and system-drive availability before traversal."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ root = filesystem_adapter.system_drive_root()
+ if not root.is_dir():
+ return ValidationResult(False, reasons=(f"system drive is unavailable: {root}",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Traverse deterministically inside the scheduler-owned worker and register text evidence."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive listing collection")
+ maximum_entries = _setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES)
+ maximum_depth = _setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH)
+ root = filesystem_adapter.system_drive_root()
+ lines = [
+ f"# system_drive={root}",
+ f"# max_entries={maximum_entries}",
+ f"# max_depth={maximum_depth}",
+ "# scheduling=run_supervisor",
+ ]
+ pending = deque([(root, 0)])
+ entries = 0
+ truncated = False
+ context.report_progress(
+ "system_drive_listing_started",
+ max_entries=maximum_entries,
+ max_depth=maximum_depth,
+ scheduling="run_supervisor",
+ )
+ while pending and entries < maximum_entries:
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive listing collection")
+ directory, depth = pending.popleft()
+ names, children = _scan_directory(directory)
+ for name in names:
+ if entries >= maximum_entries:
+ truncated = True
+ break
+ lines.append(str(Path(name).relative_to(root)))
+ entries += 1
+ if truncated:
+ break
+ if depth < maximum_depth:
+ pending.extend((child, depth + 1) for child in children)
+ if pending and entries >= maximum_entries:
+ truncated = True
+ if truncated:
+ lines.append("# truncated=true")
+ output = context.workspace / "system_drive_listing.txt"
+ output.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ context.report_progress(
+ "system_drive_listing_finished",
+ entry_count=entries,
+ truncated=str(truncated).lower(),
+ bytes_written=artifact.size_bytes,
+ )
+ summary = "system-drive listing collected" if not truncated else "system-drive listing collected with configured entry limit"
+ return CollectorResult.succeeded(summary, (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because traversal retains only collection-local state."""
diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py
new file mode 100644
index 00000000..2ce2fca7
--- /dev/null
+++ b/core/filesystem/system_drive_tree.py
@@ -0,0 +1,110 @@
+"""Create a bounded recursive tree of the Windows system drive without reading file contents."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+_DEFAULT_MAX_ENTRIES = 5_000
+_DEFAULT_MAX_DEPTH = 12
+_HARD_MAX_ENTRIES = 50_000
+_HARD_MAX_DEPTH = 32
+
+
+def _bounded_setting(settings: object, key: str, default: int, maximum: int) -> int:
+ """Return one integer traversal setting constrained to a safe positive range."""
+ value = settings.get(key, default) if isinstance(settings, dict) else default
+ return value if isinstance(value, int) and 1 <= value <= maximum else default
+
+
+class SystemDriveTreeCollector(CoreCollector):
+ """Capture a bounded recursive system-drive tree without reading file contents."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the filesystem-read, bounded system-drive tree artifact contract."""
+ return CollectorMetadata(
+ id="core.filesystem.system_drive_tree",
+ name="System drive tree",
+ version="4.0.0",
+ specialty=Specialty.FILESYSTEM,
+ output_media_types=("text/plain",),
+ description="Exports a configurable bounded recursive directory tree for the Windows system drive.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.FILESYSTEM_READ,),
+ sensitive_data_categories=("filesystem_metadata",),
+ default_profiles=("deep",),
+ timeout_seconds=120,
+ maximum_output_bytes=8 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and system-drive availability before traversal."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ system_drive = filesystem_adapter.system_drive_root()
+ if not system_drive.is_dir():
+ return ValidationResult(False, reasons=(f"system drive is unavailable: {system_drive}",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Walk a bounded tree and register the metadata-only text evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive tree collection")
+ maximum_entries = _bounded_setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES)
+ maximum_depth = _bounded_setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH)
+ root = filesystem_adapter.system_drive_root()
+ lines = [
+ f"# system_drive={root}",
+ f"# max_entries={maximum_entries}",
+ f"# max_depth={maximum_depth}",
+ ]
+ entries = 0
+ skipped = 0
+ truncated = False
+ context.report_progress("system_drive_tree_started", max_entries=maximum_entries, max_depth=maximum_depth)
+ for current, directories, filenames in filesystem_adapter.walk(root, topdown=True, followlinks=False,
+ onerror=lambda _error: None):
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive tree collection")
+ relative = Path(current).relative_to(root)
+ depth = len(relative.parts)
+ if depth >= maximum_depth:
+ skipped += len(directories)
+ directories[:] = []
+ for name, marker in [(name, "/") for name in directories] + [(name, "") for name in filenames]:
+ if entries >= maximum_entries:
+ truncated = True
+ break
+ lines.append(f"{relative / name}{marker}")
+ entries += 1
+ if truncated:
+ break
+ if truncated:
+ lines.append("# truncated=true")
+ output = context.workspace / "system_drive_tree.txt"
+ output.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ context.report_progress(
+ "system_drive_tree_finished",
+ entry_count=entries,
+ skipped_directories=skipped,
+ truncated=str(truncated).lower(),
+ bytes_written=artifact.size_bytes,
+ )
+ summary = "system-drive tree collected" if not truncated else "system-drive tree collected with configured entry limit"
+ return CollectorResult.succeeded(summary, (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because traversal holds no persistent resources."""
diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py
new file mode 100644
index 00000000..170d5f8f
--- /dev/null
+++ b/core/hardware/battery_status.py
@@ -0,0 +1,100 @@
+"""Export local Windows battery status as bounded read-only JSON evidence."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class BatteryStatusCollector(CoreCollector):
+ """Capture read-only battery capacity and charge state through Windows CIM."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated battery-status JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.hardware.battery_status",
+ name="Battery status",
+ version="4.0.0",
+ specialty=Specialty.HARDWARE,
+ output_media_types=("application/json",),
+ description="Exports local battery name, status, charge, capacity, and estimated runtime metadata.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=45,
+ maximum_output_bytes=128 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query battery CIM metadata and register a bounded JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before battery-status collection")
+ context.report_progress("battery_status_started")
+ command = (
+ "Get-CimInstance -ClassName Win32_Battery | "
+ "Select-Object Name, BatteryStatus, EstimatedChargeRemaining, EstimatedRunTime, DesignCapacity, FullChargeCapacity, Status | "
+ "ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "battery-status access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "battery-status query failed", errors=(detail,))
+ try:
+ batteries = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "battery-status query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(batteries, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "battery-status query returned an unexpected result")
+ output = context.workspace / "battery_status.json"
+ output.write_text(json.dumps(batteries, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(batteries) if isinstance(batteries, list) else 1
+ context.report_progress("battery_status_finished", battery_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("battery status collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py
new file mode 100644
index 00000000..e77df794
--- /dev/null
+++ b/core/hardware/display_adapters.py
@@ -0,0 +1,89 @@
+"""Export local display-adapter metadata as bounded, read-only JSON evidence."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+class DisplayAdaptersCollector(CoreCollector):
+ """Capture read-only local display-adapter identities, drivers, and memory through CIM."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated display-adapter JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.hardware.display_adapters",
+ name="Display adapters",
+ version="4.0.0",
+ specialty=Specialty.HARDWARE,
+ output_media_types=("application/json",),
+ description="Exports local display-adapter names, driver versions, resolution, and memory metadata.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("hardware_inventory",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=30,
+ maximum_output_bytes=256 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query display-adapter CIM metadata and register a bounded JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before display-adapter collection")
+ context.report_progress("display_adapters_started")
+ command = (
+ "Get-CimInstance Win32_VideoController | Select-Object Name, AdapterCompatibility, DriverVersion, "
+ "VideoProcessor, AdapterRAM, CurrentHorizontalResolution, CurrentVerticalResolution, CurrentRefreshRate "
+ "| ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=25,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ status = CollectorStatus.SKIPPED if "denied" in detail.casefold() else CollectorStatus.FAILED
+ return CollectorResult(status, "display-adapter query failed", errors=(detail,))
+ try:
+ adapters = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "display-adapter query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(adapters, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned an unexpected result")
+ output = context.workspace / "display_adapters.json"
+ output.write_text(json.dumps(adapters, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(adapters) if isinstance(adapters, list) else 1
+ context.report_progress("display_adapters_finished", adapter_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("display adapters collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py
new file mode 100644
index 00000000..f0c9ccb5
--- /dev/null
+++ b/core/hardware/windows_features.py
@@ -0,0 +1,106 @@
+"""Collect Windows optional-feature names and states through a bounded PowerShell query."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common Windows and PowerShell permission-denied error wording."""
+ normalized = detail.casefold()
+ return (
+ "permission denied" in normalized
+ or ("access" in normalized and "denied" in normalized)
+ or "requires elevation" in normalized
+ or "elevation is required" in normalized
+ )
+
+
+class WindowsFeaturesCollector(CoreCollector):
+ """Export Windows optional-feature state as a JSON artifact in the collector workspace."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated deep inventory and its bounded output limit."""
+ return CollectorMetadata(
+ id="core.hardware.windows_features",
+ name="Windows optional features",
+ version="4.0.0",
+ specialty=Specialty.HARDWARE,
+ output_media_types=("application/json",),
+ description="Exports Windows optional-feature names and enabled states through PowerShell.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=60,
+ maximum_output_bytes=5 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Verify cancellation state and the PowerShell executable before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run a read-only feature query and register its JSON output when available."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before optional-feature collection")
+ context.report_progress("windows_features_started")
+ command = "Get-WindowsOptionalFeature -Online | Select-Object FeatureName, State | ConvertTo-Json -Depth 2"
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=45,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "optional-feature access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "PowerShell optional-feature query failed",
+ errors=(detail,),
+ )
+ try:
+ payload = json.loads(completed.stdout)
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "PowerShell optional-feature query returned invalid JSON",
+ errors=(str(error),),
+ )
+ features = payload if isinstance(payload, list) else [payload]
+ output = context.workspace / "windows_features.json"
+ output.write_text(json.dumps(features, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "windows_features_finished",
+ feature_count=len(features),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("Windows optional features collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the PowerShell command is synchronous."""
diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py
new file mode 100644
index 00000000..34e1d591
--- /dev/null
+++ b/core/integration/legacy_code_outputs.py
@@ -0,0 +1,202 @@
+"""Import generated evidence from historical CODE checkouts into the v4 artifact catalog."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ EvidenceKind,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+_EVIDENCE_EXTENSIONS = {
+ ".csv",
+ ".dot",
+ ".evtx",
+ ".html",
+ ".htm",
+ ".json",
+ ".log",
+ ".reg",
+ ".svg",
+ ".txt",
+ ".xml",
+ ".zip",
+}
+_EXCLUDED_DIRECTORIES = {
+ ".git",
+ ".idea",
+ ".mypy_cache",
+ ".pytest_cache",
+ ".venv",
+ "__pycache__",
+ "lib",
+ "libs",
+ "site-packages",
+ "venv",
+}
+_MAXIMUM_FILES = 500
+_MAXIMUM_FILE_BYTES = 64 * 1024 * 1024
+
+
+class LegacyCodeOutputsCollector(CoreCollector):
+ """Import bounded generated files while excluding executable project material."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the compatibility evidence import and its filesystem-read boundary."""
+ return CollectorMetadata(
+ id="core.integration.legacy_code_outputs",
+ name="Legacy CODE generated outputs",
+ version="4.0.0",
+ specialty=Specialty.INTEGRATION,
+ output_media_types=("application/octet-stream",),
+ description="Imports bounded generated evidence from CODE without packaging source or configuration.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.FILESYSTEM_READ,),
+ sensitive_data_categories=("legacy_generated_evidence",),
+ default_profiles=("manual",),
+ timeout_seconds=120,
+ maximum_output_bytes=512 * 1024 * 1024,
+ maximum_artifact_bytes=_MAXIMUM_FILE_BYTES,
+ maximum_artifact_files=_MAXIMUM_FILES,
+ )
+
+ @staticmethod
+ def _code_root() -> Path:
+ """Resolve the historical directory relative to this checked-in collector source."""
+ return Path(__file__).resolve().parents[2] / "CODE"
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Skip cleanly when the historical CODE directory is not present."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ code_root = self._code_root()
+ if not code_root.is_dir() or code_root.is_symlink():
+ return ValidationResult(False, reasons=("historical CODE directory is unavailable",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Stream allowlisted generated files into the private workspace and artifact store."""
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled before legacy CODE import",
+ )
+
+ code_root = self._code_root().resolve()
+ candidates: list[Path] = []
+
+ for path in sorted(
+ filesystem_adapter.recursive(code_root),
+ key=lambda candidate: candidate.as_posix(),
+ ):
+ if context.is_cancelled:
+ return CollectorResult.cancelled("cancelled during legacy CODE discovery")
+
+ relative = path.relative_to(code_root)
+
+ if any(part.casefold() in _EXCLUDED_DIRECTORIES for part in relative.parts):
+ continue
+
+ try:
+ if (
+ not path.is_file()
+ or path.is_symlink()
+ or path.resolve().parent != (code_root / relative.parent).resolve()
+ or path.suffix.casefold() not in _EVIDENCE_EXTENSIONS
+ or path.stat().st_size > _MAXIMUM_FILE_BYTES
+ ):
+ continue
+ except OSError:
+ continue
+
+ candidates.append(path)
+
+ if len(candidates) >= _MAXIMUM_FILES:
+ break
+
+ artifacts = []
+ errors: list[str] = []
+
+ for source in candidates:
+ if context.is_cancelled:
+ return CollectorResult.cancelled(
+ "cancelled during legacy CODE import",
+ tuple(artifacts),
+ )
+
+ relative = source.relative_to(code_root)
+ destination = context.workspace / "legacy_code" / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+
+ temporary = destination.with_suffix(destination.suffix + ".tmp")
+
+ try:
+ with (
+ open(source, "rb") as source_stream,
+ open(temporary, "xb") as destination_stream,
+ ):
+ while block := source_stream.read(1024 * 1024):
+ if context.is_cancelled:
+ raise InterruptedError("run cancellation was requested")
+
+ destination_stream.write(block)
+
+ temporary.replace(destination)
+
+ artifacts.append(
+ context.artifacts.register_file(
+ destination,
+ media_type="application/octet-stream",
+ evidence_kind=EvidenceKind.RAW,
+ transformations=("imported from historical CODE output",),
+ )
+ )
+
+ except InterruptedError:
+ temporary.unlink(missing_ok=True)
+ destination.unlink(missing_ok=True)
+
+ return CollectorResult.cancelled(
+ "cancelled during legacy CODE import",
+ tuple(artifacts),
+ )
+
+ except OSError as error:
+ temporary.unlink(missing_ok=True)
+ errors.append(f"{relative.as_posix()}: {error}")
+
+ if not artifacts and not errors:
+ return CollectorResult.skipped("no generated legacy CODE evidence matched the import policy")
+
+ if errors:
+ return CollectorResult.partial(
+ "legacy CODE evidence imported with file-level failures",
+ tuple(artifacts),
+ errors=tuple(errors),
+ )
+
+ context.report_progress(
+ "legacy_code_outputs_finished",
+ imported_files=len(artifacts),
+ bytes_written=sum(artifact.size_bytes for artifact in artifacts),
+ )
+
+ return CollectorResult.succeeded(
+ "legacy CODE evidence imported",
+ tuple(artifacts),
+ )
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Remove only an unpublished temporary copy left by an interrupted import."""
+ for temporary in filesystem_adapter.recursive(context.workspace, "*.tmp"):
+ temporary.unlink(missing_ok=True)
diff --git a/core/media/media_backup.py b/core/media/media_backup.py
new file mode 100644
index 00000000..612ae692
--- /dev/null
+++ b/core/media/media_backup.py
@@ -0,0 +1,130 @@
+"""Back up bounded current-user Pictures and Videos evidence after explicit approval."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ EvidenceKind,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
+MAX_FILE_BYTES = 50 * 1024 * 1024
+MAX_TOTAL_BYTES = 512 * 1024 * 1024
+MAX_FILES = 1_000
+
+
+class MediaBackupCollector(CoreCollector):
+ """Copy supported current-user media into the collector's private workspace."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the explicit-consent media backup artifact contract."""
+ return CollectorMetadata(
+ id="core.media.media_backup",
+ name="Pictures and Videos backup",
+ version="4.0.0",
+ specialty=Specialty.MEDIA,
+ output_media_types=("application/octet-stream",),
+ description="Copies bounded JPG, JPEG, PNG, and MP4 files from current-user Pictures and Videos folders.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES),
+ sensitive_data_categories=("personal_media",),
+ default_profiles=("deep",),
+ timeout_seconds=300,
+ maximum_output_bytes=512 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state before the bounded media scan starts."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Copy supported regular media files while preserving source metadata."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before media backup")
+ home = filesystem_adapter.home()
+ source_roots = (("pictures", home / "Pictures"), ("videos", home / "Videos"))
+ destination_root = context.workspace / "media_backup"
+ copied: list[Path] = []
+ copied_bytes = 0
+ skipped_files = 0
+ inaccessible_roots = 0
+ context.report_progress("media_backup_started")
+ for category, source_root in source_roots:
+ try:
+ exists = source_root.is_dir()
+ except OSError:
+ inaccessible_roots += 1
+ continue
+ if not exists:
+ continue
+ try:
+ candidates = filesystem_adapter.recursive(source_root)
+ for candidate in candidates:
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during media backup")
+ if len(copied) >= MAX_FILES:
+ break
+ try:
+ if not candidate.is_file() or candidate.is_symlink() or candidate.suffix.casefold() not in SUPPORTED_EXTENSIONS:
+ continue
+ size = candidate.stat().st_size
+ except OSError:
+ skipped_files += 1
+ continue
+ if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES:
+ skipped_files += 1
+ continue
+ timestamp = datetime.fromtimestamp(candidate.stat().st_mtime, UTC).strftime("%Y%m%dT%H%M%SZ")
+ destination = destination_root / category / f"{candidate.stem}_{timestamp}{candidate.suffix.casefold()}"
+ suffix = 1
+ while destination.exists():
+ destination = destination_root / category / f"{candidate.stem}_{timestamp}_{suffix}{candidate.suffix.casefold()}"
+ suffix += 1
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ filesystem_adapter.copy_file(candidate, destination)
+ if context.is_cancelled:
+ destination.unlink(missing_ok=True)
+ for copied_path in copied:
+ copied_path.unlink(missing_ok=True)
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during media backup")
+ copied.append(destination)
+ copied_bytes += size
+ except OSError:
+ inaccessible_roots += 1
+ if not copied:
+ reason = "no supported media files met the bounded backup policy"
+ if inaccessible_roots == len(source_roots):
+ reason = "the current user's Pictures and Videos folders are inaccessible"
+ return CollectorResult(CollectorStatus.SKIPPED, reason)
+ artifacts = []
+ for path in copied:
+ if context.is_cancelled:
+ for unpublished in copied[len(artifacts):]:
+ unpublished.unlink(missing_ok=True)
+ return CollectorResult.cancelled("cancelled during media registration", tuple(artifacts))
+ artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW))
+ artifact_tuple = tuple(artifacts)
+ context.report_progress(
+ "media_backup_finished",
+ copied_files=len(artifacts),
+ skipped_files=skipped_files,
+ bytes_written=sum(item.size_bytes for item in artifact_tuple),
+ )
+ return CollectorResult.succeeded("current-user media backup collected", artifact_tuple)
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Leave copied evidence removal to the isolated workspace lifecycle."""
diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py
new file mode 100644
index 00000000..ff507126
--- /dev/null
+++ b/core/memory/memory_snapshot.py
@@ -0,0 +1,85 @@
+"""Collect bounded aggregate physical and virtual memory statistics from Windows."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+ global_memory_status,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+
+
+class MemorySnapshotCollector(CoreCollector):
+ """Write a JSON report containing aggregate memory and page-file availability."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare this bounded, capability-free Windows memory collector."""
+ return CollectorMetadata(
+ id="core.memory.memory_snapshot",
+ name="Memory snapshot",
+ version="4.0.0",
+ specialty=Specialty.MEMORY,
+ output_media_types=("application/json",),
+ description="Captures aggregate physical, virtual, and page-file memory statistics.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(),
+ default_profiles=("minimal", "standard", "deep", "offline"),
+ timeout_seconds=10,
+ maximum_output_bytes=64 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation and Windows memory API availability without writing artifacts."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ try:
+ global_memory_status()
+ except OSError as error:
+ return ValidationResult(False, reasons=(f"memory API is unavailable: {error}",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Capture aggregate counters and register the resulting JSON report."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory collection")
+ context.report_progress("memory_snapshot_started")
+ try:
+ status = global_memory_status()
+ except OSError as error:
+ return CollectorResult(CollectorStatus.FAILED, "could not read Windows memory status", errors=(str(error),))
+ report = {
+ "collected_at": datetime.now(UTC).isoformat(),
+ "memory_load_percent": status.dwMemoryLoad,
+ "physical_memory": {
+ "total_bytes": status.ullTotalPhys,
+ "available_bytes": status.ullAvailPhys,
+ "used_bytes": status.ullTotalPhys - status.ullAvailPhys,
+ },
+ "page_file": {
+ "total_bytes": status.ullTotalPageFile,
+ "available_bytes": status.ullAvailPageFile,
+ "used_bytes": status.ullTotalPageFile - status.ullAvailPageFile,
+ },
+ "virtual_memory": {
+ "total_bytes": status.ullTotalVirtual,
+ "available_bytes": status.ullAvailVirtual,
+ "used_bytes": status.ullTotalVirtual - status.ullAvailVirtual,
+ },
+ }
+ output = context.workspace / "memory_snapshot.json"
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress("memory_snapshot_finished", bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("memory snapshot collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the Windows memory API is synchronous."""
diff --git a/core/network/active_connections.py b/core/network/active_connections.py
new file mode 100644
index 00000000..c31d09cc
--- /dev/null
+++ b/core/network/active_connections.py
@@ -0,0 +1,87 @@
+"""Export local active Windows connections and owning PIDs as bounded text evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from netstat output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class ActiveConnectionsCollector(CoreCollector):
+ """Capture active TCP and UDP endpoint metadata without network probing or changes."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated active-connection artifact contract."""
+ return CollectorMetadata(
+ id="core.network.active_connections",
+ name="Active network connections",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("text/plain",),
+ description="Exports active TCP/UDP endpoints, states, and owning PIDs from netstat.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers", "process_metadata"),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=4 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and netstat availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("netstat") is None:
+ return ValidationResult(False, reasons=("netstat is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run the read-only connection query and register its text evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection collection")
+ context.report_progress("active_connections_started")
+ completed = subprocess.run(
+ ["netstat", "-ano"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=25,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "netstat access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "active-connection query failed", errors=(detail,))
+ output = context.workspace / "active_connections.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ connection_count = sum(1 for line in completed.stdout.splitlines() if line.lstrip().startswith(("TCP", "UDP")))
+ context.report_progress(
+ "active_connections_finished",
+ connection_count=connection_count,
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("active network connections collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because netstat exits before the result is returned."""
diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py
new file mode 100644
index 00000000..bda39a05
--- /dev/null
+++ b/core/network/adapter_statistics.py
@@ -0,0 +1,101 @@
+"""Export local Windows network adapter I/O counters as bounded JSON evidence."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class AdapterStatisticsCollector(CoreCollector):
+ """Capture read-only per-interface byte, packet, error, and discard counters."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated adapter-statistics artifact contract."""
+ return CollectorMetadata(
+ id="core.network.adapter_statistics",
+ name="Network adapter statistics",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("application/json",),
+ description="Exports per-interface network byte, packet, error, and discard counters.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=45,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query adapter counters and register their JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before adapter-statistics collection")
+ context.report_progress("adapter_statistics_started")
+ command = (
+ "Get-NetAdapterStatistics | "
+ "Select-Object Name, ReceivedBytes, SentBytes, ReceivedUnicastPackets, SentUnicastPackets, "
+ "ReceivedDiscardedPackets, OutboundDiscardedPackets, ReceivedPacketErrors, OutboundPacketErrors | "
+ "ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "adapter-statistics access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query failed", errors=(detail,))
+ try:
+ statistics = json.loads(completed.stdout)
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "adapter-statistics query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(statistics, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned an unexpected result")
+ output = context.workspace / "adapter_statistics.json"
+ output.write_text(json.dumps(statistics, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(statistics) if isinstance(statistics, list) else 1
+ context.report_progress("adapter_statistics_finished", interface_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("network adapter statistics collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/network/arp_cache.py b/core/network/arp_cache.py
new file mode 100644
index 00000000..d69d92d9
--- /dev/null
+++ b/core/network/arp_cache.py
@@ -0,0 +1,83 @@
+"""Export the local Windows ARP cache as bounded, read-only network evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from Windows command output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class ArpCacheCollector(CoreCollector):
+ """Capture current ARP mappings without probing or modifying the network."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated, bounded ARP-cache report."""
+ return CollectorMetadata(
+ id="core.network.arp_cache",
+ name="ARP cache",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("text/plain",),
+ description="Exports the local ARP cache without sending network traffic.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=1 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and arp availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("arp") is None:
+ return ValidationResult(False, reasons=("arp is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run the read-only ARP query and register its text artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before ARP-cache collection")
+ context.report_progress("arp_cache_started")
+ completed = subprocess.run(
+ ["arp", "-a"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=25,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"arp exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "ARP-cache access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "ARP-cache query failed", errors=(detail,))
+ output = context.workspace / "arp_cache.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ line_count = sum(1 for line in completed.stdout.splitlines() if line.strip())
+ context.report_progress("arp_cache_finished", line_count=line_count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("ARP-cache report collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because arp exits before the result is returned."""
diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py
new file mode 100644
index 00000000..5cc24d17
--- /dev/null
+++ b/core/network/bandwidth_sample.py
@@ -0,0 +1,169 @@
+"""Measure local adapter bandwidth from bounded read-only counter samples."""
+
+from __future__ import annotations
+
+import json
+import time
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+_DEFAULT_SAMPLES = 3
+_DEFAULT_INTERVAL_SECONDS = 1
+
+
+def _setting(settings: object, key: str, default: int, maximum: int) -> int:
+ """Return one bounded positive integer collector setting."""
+ value = settings.get(key, default) if isinstance(settings, dict) else default
+ return value if isinstance(value, int) and 1 <= value <= maximum else default
+
+
+def _interval_setting(settings: object) -> float:
+ """Return the configured finite positive sampling interval."""
+ value = settings.get("interval_seconds", _DEFAULT_INTERVAL_SECONDS) if isinstance(settings,
+ dict) else _DEFAULT_INTERVAL_SECONDS
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and 0.1 <= value <= 60:
+ return float(value)
+ return float(_DEFAULT_INTERVAL_SECONDS)
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class BandwidthSampleCollector(CoreCollector):
+ """Measure receive/send byte rates locally without generating network traffic."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated bounded bandwidth-sampling artifact contract."""
+ return CollectorMetadata(
+ id="core.network.bandwidth_sample",
+ name="Network bandwidth sample",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("application/json",),
+ description="Calculates local per-interface average and peak bandwidth from adapter counter samples.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=90,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before sampling."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Take bounded counter samples and register calculated rate evidence as JSON."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before bandwidth sampling")
+ samples = _setting(context.settings, "sample_count", _DEFAULT_SAMPLES, 10)
+ interval = _interval_setting(context.settings)
+ command = "Get-NetAdapterStatistics | Select-Object Name, ReceivedBytes, SentBytes | ConvertTo-Json -Depth 3"
+ observations: list[dict[str, dict[str, int]]] = []
+ context.report_progress("bandwidth_sample_started", sample_count=samples, interval_seconds=interval)
+ for index in range(samples):
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled during bandwidth sampling")
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=20,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "bandwidth-sample access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query failed", errors=(detail,))
+ try:
+ raw = json.loads(completed.stdout)
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "bandwidth-sample query returned invalid JSON",
+ errors=(str(error),),
+ )
+ records = raw if isinstance(raw, list) else [raw]
+ if not all(isinstance(record, dict) for record in records):
+ return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned an unexpected result")
+ observations.append(
+ {
+ str(record.get("Name", "unavailable")): {
+ "received": int(record.get("ReceivedBytes", 0)),
+ "sent": int(record.get("SentBytes", 0)),
+ }
+ for record in records
+ }
+ )
+ if index + 1 < samples:
+ time.sleep(interval)
+ rates: dict[str, dict[str, float]] = {}
+ for previous, current in zip(observations, observations[1:]):
+ for name, counters in current.items():
+ if name not in previous:
+ continue
+ receive = max(0, counters["received"] - previous[name]["received"]) / interval
+ sent = max(0, counters["sent"] - previous[name]["sent"]) / interval
+ values = rates.setdefault(
+ name,
+ {
+ "receive_average_bytes_per_second": 0.0,
+ "receive_peak_bytes_per_second": 0.0,
+ "send_average_bytes_per_second": 0.0,
+ "send_peak_bytes_per_second": 0.0,
+ "sample_intervals": 0.0,
+ },
+ )
+ values["sample_intervals"] += 1
+ values["receive_average_bytes_per_second"] += receive
+ values["send_average_bytes_per_second"] += sent
+ values["receive_peak_bytes_per_second"] = max(values["receive_peak_bytes_per_second"], receive)
+ values["send_peak_bytes_per_second"] = max(values["send_peak_bytes_per_second"], sent)
+ for values in rates.values():
+ values["receive_average_bytes_per_second"] /= values["sample_intervals"]
+ values["send_average_bytes_per_second"] /= values["sample_intervals"]
+ output = context.workspace / "bandwidth_sample.json"
+ output.write_text(
+ json.dumps(
+ {"sample_count": samples, "interval_seconds": interval, "interfaces": rates},
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "bandwidth_sample_finished",
+ interface_count=len(rates),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("network bandwidth sample collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because sampling subprocesses have already exited."""
diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py
new file mode 100644
index 00000000..d502f11b
--- /dev/null
+++ b/core/network/connection_processes.py
@@ -0,0 +1,143 @@
+"""Export local active connections correlated with process names as bounded CSV evidence."""
+
+from __future__ import annotations
+
+import csv
+import io
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from Windows command output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+def _parse_connections(netstat_output: str) -> list[dict[str, str]]:
+ """Parse the stable token layout of Windows netstat -ano connection rows."""
+ connections: list[dict[str, str]] = []
+ for line in netstat_output.splitlines():
+ fields = line.split()
+ if not fields or fields[0] not in {"TCP", "UDP"}:
+ continue
+ if fields[0] == "TCP" and len(fields) >= 5:
+ protocol, local, remote, state, pid = fields[:5]
+ elif fields[0] == "UDP" and len(fields) >= 4:
+ protocol, local, remote, pid = fields[:4]
+ state = ""
+ else:
+ continue
+ connections.append(
+ {
+ "protocol": protocol,
+ "local_endpoint": local,
+ "remote_endpoint": remote,
+ "state": state,
+ "pid": pid,
+ }
+ )
+ return connections
+
+
+def _parse_processes(tasklist_output: str) -> dict[str, str]:
+ """Map tasklist CSV process identifiers to their image names."""
+ processes: dict[str, str] = {}
+ for row in csv.reader(io.StringIO(tasklist_output)):
+ if len(row) >= 2 and row[1].isdigit():
+ processes[row[1]] = row[0]
+ return processes
+
+
+class ConnectionProcessesCollector(CoreCollector):
+ """Capture active connection endpoints with the local process name for each PID."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated connection-process CSV artifact contract."""
+ return CollectorMetadata(
+ id="core.network.connection_processes",
+ name="Connection process associations",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("text/csv",),
+ description="Correlates active Netstat TCP/UDP endpoints with local process names and PIDs.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers", "process_metadata"),
+ default_profiles=("deep",),
+ timeout_seconds=45,
+ maximum_output_bytes=4 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and required Windows command availability."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ missing = tuple(command for command in ("netstat", "tasklist") if which(command) is None)
+ if missing:
+ return ValidationResult(False, reasons=(f"required command is unavailable: {', '.join(missing)}",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Read Netstat and Tasklist data, then register their correlation as CSV."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection-process collection")
+ context.report_progress("connection_processes_started")
+ netstat = subprocess.run(["netstat", "-ano"], capture_output=True, check=False, text=True, timeout=25)
+ tasklist = subprocess.run(
+ ["tasklist", "/fo", "csv", "/nh"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=25,
+ )
+ failures = [result for result in (netstat, tasklist) if result.returncode != 0]
+ if failures:
+ detail = "\n".join(result.stderr.strip() or f"command exit code {result.returncode}" for result in failures)
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "connection-process access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "connection-process query failed", errors=(detail,))
+ processes = _parse_processes(tasklist.stdout)
+ rows = _parse_connections(netstat.stdout)
+ output = context.workspace / "connection_processes.csv"
+ with output.open("w", encoding="utf-8", newline="") as stream:
+ writer = csv.DictWriter(
+ stream,
+ fieldnames=(
+ "protocol",
+ "local_endpoint",
+ "remote_endpoint",
+ "state",
+ "pid",
+ "process_name",
+ ),
+ )
+ writer.writeheader()
+ for row in rows:
+ writer.writerow({**row, "process_name": processes.get(row["pid"], "unavailable")})
+ artifact = context.artifacts.register_file(output, media_type="text/csv")
+ context.report_progress(
+ "connection_processes_finished",
+ connection_count=len(rows),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("connection-process associations collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because both commands exit before the result is returned."""
diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py
new file mode 100644
index 00000000..8af06f18
--- /dev/null
+++ b/core/network/dns_cache.py
@@ -0,0 +1,76 @@
+"""Export the local Windows DNS resolver cache as bounded sensitive text evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from ipconfig output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class DnsCacheCollector(CoreCollector):
+ """Capture the local DNS resolver cache without modifying resolver state or sending traffic."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated sensitive DNS-cache artifact contract."""
+ return CollectorMetadata(
+ id="core.network.dns_cache",
+ name="DNS resolver cache",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("text/plain",),
+ description="Exports local DNS resolver cache records through read-only ipconfig output.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("dns_history",),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=4 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and ipconfig availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("ipconfig") is None:
+ return ValidationResult(False, reasons=("ipconfig is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run the read-only DNS-cache query and register its bounded text artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before DNS-cache collection")
+ context.report_progress("dns_cache_started")
+ completed = subprocess.run(["ipconfig", "/displaydns"], capture_output=True, check=False, text=True, timeout=25)
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "DNS-cache access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "DNS-cache query failed", errors=(detail,))
+ output = context.workspace / "dns_cache.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ context.report_progress("dns_cache_finished", bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("DNS resolver cache collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because ipconfig exits before the result is returned."""
diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py
new file mode 100644
index 00000000..62730961
--- /dev/null
+++ b/core/network/firewall_profiles.py
@@ -0,0 +1,102 @@
+"""Export local Windows firewall profile settings as bounded read-only JSON evidence."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class FirewallProfilesCollector(CoreCollector):
+ """Capture read-only local firewall profile configuration without changing firewall state."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated firewall-profile JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.network.firewall_profiles",
+ name="Firewall profiles",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("application/json",),
+ description="Exports local Domain, Private, and Public Windows firewall profile settings.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=45,
+ maximum_output_bytes=256 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query firewall profiles and register their bounded JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before firewall-profile collection")
+ context.report_progress("firewall_profiles_started")
+ command = (
+ "Get-NetFirewallProfile | "
+ "Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, "
+ "NotifyOnListen, AllowInboundRules, AllowLocalFirewallRules, "
+ "AllowLocalIPsecRules, LogFileName | "
+ "ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "firewall-profile access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "firewall-profile query failed", errors=(detail,))
+ try:
+ profiles = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "firewall-profile query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(profiles, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned an unexpected result")
+ output = context.workspace / "firewall_profiles.json"
+ output.write_text(json.dumps(profiles, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(profiles) if isinstance(profiles, list) else 1
+ context.report_progress("firewall_profiles_finished", profile_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("firewall profiles collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py
new file mode 100644
index 00000000..155b0904
--- /dev/null
+++ b/core/network/network_adapters.py
@@ -0,0 +1,81 @@
+"""Collect the local Windows IP configuration as a bounded text artifact."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+class NetworkAdaptersCollector(CoreCollector):
+ """Run the local IP configuration command and register its output as evidence."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated adapter inventory and its fixed output bound."""
+ return CollectorMetadata(
+ id="core.network.network_adapters",
+ name="Network adapters",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("text/plain",),
+ description="Captures local Windows IP configuration and adapter details through ipconfig.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=20,
+ maximum_output_bytes=2 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Confirm cancellation state and that ipconfig is available on the host."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("ipconfig") is None:
+ return ValidationResult(False, reasons=("ipconfig is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Capture local adapter configuration and register the bounded plain-text report."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before adapter collection")
+ context.report_progress("network_adapters_started")
+ completed = subprocess.run(
+ ["ipconfig", "/all"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=15,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}"
+ if "permission denied" in detail.casefold() or (
+ "access" in detail.casefold() and "denied" in detail.casefold()):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "ipconfig access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "ipconfig failed", errors=(detail,))
+ output = context.workspace / "network_adapters.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ adapter_sections = completed.stdout.casefold().count("adapter ")
+ context.report_progress(
+ "network_adapters_finished",
+ adapter_sections=adapter_sections,
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("network adapter configuration collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the ipconfig child process has completed."""
diff --git a/core/network/network_identity.py b/core/network/network_identity.py
new file mode 100644
index 00000000..4d67ba12
--- /dev/null
+++ b/core/network/network_identity.py
@@ -0,0 +1,89 @@
+"""Collect the hostname and resolver-provided addresses for the local host."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ NetworkAccess,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import network_adapter as socket
+
+
+class NetworkIdentityCollector(CoreCollector):
+ """Capture a bounded local network identity report without probing remote hosts."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare resolver access and the small JSON output produced by this collector."""
+ return CollectorMetadata(
+ id="core.network.network_identity",
+ name="Network identity",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("application/json",),
+ description="Records the local hostname and resolver-provided IP addresses without probing hosts.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.NETWORK,),
+ network_access=NetworkAccess.LOCAL,
+ default_profiles=("standard", "deep"),
+ timeout_seconds=10,
+ maximum_output_bytes=64 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Confirm that the run has not been cancelled before resolving local identity."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Resolve the local hostname and write a deterministic JSON report."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before network identity collection")
+ context.report_progress("network_identity_started")
+ hostname = socket.gethostname()
+ addresses: list[str] = []
+ resolver_error: str | None = None
+ try:
+ for result in socket.getaddrinfo(hostname, None):
+ address = result[4][0]
+ if address not in addresses:
+ addresses.append(address)
+ except socket.gaierror as error:
+ resolver_error = str(error)
+ report = {
+ "collected_at": datetime.now(UTC).isoformat(),
+ "hostname": hostname,
+ "addresses": sorted(addresses),
+ }
+ if resolver_error is not None:
+ report["resolver_error"] = resolver_error
+ output = context.workspace / "network_identity.json"
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "network_identity_finished",
+ address_count=len(addresses),
+ bytes_written=artifact.size_bytes,
+ )
+ if resolver_error is not None:
+ return CollectorResult(
+ CollectorStatus.PARTIAL,
+ "network identity collected without resolver addresses",
+ (artifact,),
+ errors=(resolver_error,),
+ )
+ return CollectorResult.succeeded("network identity collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because hostname resolution has already completed."""
diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py
new file mode 100644
index 00000000..916f1497
--- /dev/null
+++ b/core/network/network_interfaces.py
@@ -0,0 +1,143 @@
+"""Export local Windows interface addresses and link state as bounded JSON evidence."""
+
+from __future__ import annotations
+
+import ipaddress
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+def _enrich_ipv4_networks(records: list[dict[str, object]]) -> list[dict[str, object]]:
+ """Add deterministic IPv4 netmask and broadcast fields from each prefix length."""
+ enriched: list[dict[str, object]] = []
+
+ for record in records:
+ item = dict(record)
+ address = item.get("IPAddress")
+ prefix = item.get("PrefixLength")
+
+ if isinstance(address, str) and isinstance(prefix, int):
+ try:
+ network = ipaddress.IPv4Network(
+ f"{address}/{prefix}",
+ strict=False,
+ )
+ except ValueError:
+ pass
+ else:
+ item["Netmask"] = network.netmask.compressed
+ item["BroadcastAddress"] = network.broadcast_address.compressed
+
+ enriched.append(item)
+
+ return enriched
+
+
+class NetworkInterfacesCollector(CoreCollector):
+ """Capture read-only IPv4 addresses, masks, broadcasts, and adapter link metadata."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated network-interface JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.network.network_interfaces",
+ name="Network interfaces",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("application/json",),
+ description="Exports IPv4 addresses, masks, broadcasts, link states, speeds, and duplex data.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=45,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query interface address and link data, then register JSON evidence."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before network-interface collection")
+ context.report_progress("network_interfaces_started")
+ command = (
+ "$adapters = @(Get-NetAdapter); "
+ "Get-NetIPAddress -AddressFamily IPv4 | ForEach-Object { "
+ "$adapter = $adapters | Where-Object ifIndex -eq $_.InterfaceIndex | Select-Object -First 1; "
+ "$status = if ($null -eq $adapter) { 'Unknown' } else { $adapter.Status.ToString() }; "
+ "$linkSpeed = if ($null -eq $adapter) { $null } else { $adapter.LinkSpeed }; "
+ "$mediaConnectionState = if ($null -eq $adapter) { 'Unknown' } else { $adapter.MediaConnectionState.ToString() }; "
+ "$fullDuplex = if ($null -eq $adapter) { $null } else { $adapter.FullDuplex }; "
+ "[pscustomobject]@{ InterfaceAlias = $_.InterfaceAlias; IPAddress = $_.IPAddress; "
+ "PrefixLength = $_.PrefixLength; AddressState = $_.AddressState.ToString(); "
+ "Status = $status; LinkSpeed = $linkSpeed; "
+ "MediaConnectionState = $mediaConnectionState; FullDuplex = $fullDuplex } "
+ "} | ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "network-interface access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "network-interface query failed", errors=(detail,))
+ try:
+ interfaces = json.loads(completed.stdout)
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "network-interface query returned invalid JSON",
+ errors=(str(error),),
+ )
+ records = interfaces if isinstance(interfaces, list) else [interfaces]
+ if not all(isinstance(record, dict) for record in records):
+ return CollectorResult(CollectorStatus.FAILED, "network-interface query returned an unexpected result")
+ output = context.workspace / "network_interfaces.json"
+ output.write_text(
+ json.dumps(_enrich_ipv4_networks(records), indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "network_interfaces_finished",
+ interface_count=len(records),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("network-interface inventory collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/network/routing_table.py b/core/network/routing_table.py
new file mode 100644
index 00000000..df8faf67
--- /dev/null
+++ b/core/network/routing_table.py
@@ -0,0 +1,83 @@
+"""Export the local Windows routing table as bounded, read-only network evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from Windows command output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class RoutingTableCollector(CoreCollector):
+ """Capture the local routing table without changing routes or sending traffic."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated, bounded routing-table report."""
+ return CollectorMetadata(
+ id="core.network.routing_table",
+ name="Routing table",
+ version="4.0.0",
+ specialty=Specialty.NETWORK,
+ output_media_types=("text/plain",),
+ description="Exports the local IPv4 and IPv6 routing tables without changing routes.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_identifiers",),
+ default_profiles=("deep",),
+ timeout_seconds=30,
+ maximum_output_bytes=1 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and route availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("route") is None:
+ return ValidationResult(False, reasons=("route is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run the read-only route query and register its text artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before routing-table collection")
+ context.report_progress("routing_table_started")
+ completed = subprocess.run(
+ ["route", "print"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=25,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"route exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "routing-table access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "routing-table query failed", errors=(detail,))
+ output = context.workspace / "routing_table.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ line_count = sum(1 for line in completed.stdout.splitlines() if line.strip())
+ context.report_progress("routing_table_finished", line_count=line_count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("routing-table report collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because route exits before the result is returned."""
diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py
new file mode 100644
index 00000000..e8ef3975
--- /dev/null
+++ b/core/packet/connection_graph.py
@@ -0,0 +1,102 @@
+"""Export a labeled local connection graph in DOT format."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common Windows permission-denied wording."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+def render_connection_graph(command_output: str) -> str:
+ """Normalize netstat rows into deterministic, sorted Graphviz DOT source."""
+ edges: set[tuple[str, str, str]] = set()
+ for line in command_output.splitlines():
+ fields = line.split()
+ if len(fields) < 4 or fields[0].upper() not in {"TCP", "UDP"}:
+ continue
+ protocol, source, destination = fields[:3]
+ if destination in {"*:*", "*"}:
+ continue
+ edges.add((source, destination, protocol.upper()))
+
+ def quote(value: str) -> str:
+ """Escape one label for safe inclusion in a quoted DOT string."""
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+ lines = ["digraph connection_graph {", " rankdir=LR;"]
+ lines.extend(
+ f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" for source, destination, protocol in
+ sorted(edges))
+ lines.append("}")
+ return "\n".join(lines) + "\n"
+
+
+class ConnectionGraphCollector(CoreCollector):
+ """Build a DOT connection graph without retaining packet payloads."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated network graph artifact contract."""
+ return CollectorMetadata(
+ id="core.packet.connection_graph",
+ name="Connection graph",
+ version="4.0.0",
+ specialty=Specialty.PACKET,
+ output_media_types=("text/vnd.graphviz",),
+ description="Exports a DOT source/destination graph with TCP or UDP protocol edge labels.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("network_metadata",),
+ default_profiles=("deep",),
+ timeout_seconds=45,
+ maximum_output_bytes=2 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and netstat availability before graph collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("netstat") is None:
+ return ValidationResult(False, reasons=("netstat is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Read active connections and write a bounded DOT graph artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection graph collection")
+ context.report_progress("connection_graph_started")
+ completed = subprocess.run(["netstat", "-ano"], capture_output=True, check=False, text=True, timeout=40)
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "connection graph access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "connection graph query failed", errors=(detail,))
+ rendered = render_connection_graph(completed.stdout)
+ output = context.workspace / "connection_graph.dot"
+ output.write_text(rendered, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/vnd.graphviz")
+ edge_count = sum(1 for line in rendered.splitlines() if " -> " in line)
+ context.report_progress("connection_graph_finished", edge_count=edge_count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("connection graph collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Retain no graph or plot state; DOT generation uses only collection-local values."""
diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py
new file mode 100644
index 00000000..933958fe
--- /dev/null
+++ b/core/packet/packet_capture.py
@@ -0,0 +1,259 @@
+"""Capture bounded local IPv4 packet observations after explicit approval."""
+
+from __future__ import annotations
+
+import csv
+import struct
+import time
+
+import select
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ NetworkAccess,
+ PrivilegeLevel,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import network_adapter as socket
+
+
+def _packet_row(payload: bytes) -> dict[str, str] | None:
+ """Decode a minimal IPv4/TCP/UDP/ICMP observation without retaining payload data."""
+ if len(payload) < 20 or payload[0] >> 4 != 4:
+ return None
+ header_length = (payload[0] & 15) * 4
+ if header_length < 20 or len(payload) < header_length:
+ return None
+ protocol = payload[9]
+ names = {1: "ICMP", 6: "TCP", 17: "UDP"}
+ source = socket.inet_ntoa(payload[12:16])
+ destination = socket.inet_ntoa(payload[16:20])
+ source_port = destination_port = ""
+ if protocol in {6, 17} and len(payload) >= header_length + 4:
+ source_port, destination_port = (str(value) for value in
+ struct.unpack("!HH", payload[header_length: header_length + 4]))
+ return {
+ "source_ip": source,
+ "destination_ip": destination,
+ "protocol": names.get(protocol, str(protocol)),
+ "source_port": source_port,
+ "destination_port": destination_port,
+ "packet_bytes": str(len(payload)),
+ }
+
+
+class PacketCaptureCollector(CoreCollector):
+ """Capture metadata-only packet observations inside the isolated worker process."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the explicitly approved packet-capture CSV artifact contract."""
+ return CollectorMetadata(
+ id="core.packet.packet_capture",
+ name="IPv4 packet capture",
+ version="4.0.0",
+ specialty=Specialty.PACKET,
+ output_media_types=("text/csv",),
+ description="Captures bounded IPv4 packet metadata without saving packet payloads.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(
+ Capability.NETWORK,
+ Capability.PACKET_CAPTURE,
+ Capability.ELEVATED_PRIVILEGES,
+ ),
+ privilege_level=PrivilegeLevel.ELEVATED,
+ network_access=NetworkAccess.LOCAL,
+ sensitive_data_categories=("network_metadata",),
+ default_profiles=("deep",),
+ timeout_seconds=90,
+ maximum_output_bytes=2 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Validate bounded capture settings before raw-socket creation."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ try:
+ count = context.setting_int("packet_count", 100)
+ timeout = context.setting_float("timeout_seconds", 10.0)
+ retry_window = context.setting_float("retry_window_seconds", 0.0)
+ except (TypeError, ValueError):
+ return ValidationResult(
+ False,
+ reasons=("packet_count, timeout_seconds, and retry_window_seconds must be numeric",),
+ )
+ if not 1 <= count <= 10_000 or not 1 <= timeout <= 60 or not 0 <= retry_window <= 60:
+ return ValidationResult(
+ False,
+ reasons=("packet_count must be 1-10000, timeout_seconds 1-60, and retry_window_seconds 0-60",),
+ )
+ return ValidationResult(True)
+
+ @staticmethod
+ def _close_capture(capture_socket: socket.socket) -> None:
+ """Disable Windows promiscuous capture mode and close the socket."""
+ try:
+ capture_socket.ioctl(
+ socket.SIO_RCVALL,
+ socket.RCVALL_OFF,
+ )
+ except OSError:
+ pass
+ capture_socket.close()
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Capture bounded metadata and save it as CSV without retaining payload bytes."""
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled before packet capture",
+ )
+
+ count = context.setting_int("packet_count", 100)
+ timeout = context.setting_float("timeout_seconds", 10.0)
+ retry_window = context.setting_float("retry_window_seconds", 0.0)
+ interface = context.setting_str(
+ "interface",
+ socket.gethostbyname(socket.gethostname()),
+ )
+
+ context.report_progress(
+ "packet_capture_started",
+ interface=interface,
+ packet_count=count,
+ retry_window_seconds=retry_window,
+ )
+
+ output = context.workspace / "packet_capture.csv"
+ capture: socket.socket | None = None
+ observation_count = 0
+ early_result: CollectorResult | None = None
+
+ with output.open("w", newline="", encoding="utf-8") as stream:
+ writer = csv.DictWriter(
+ stream,
+ fieldnames=(
+ "source_ip",
+ "destination_ip",
+ "protocol",
+ "source_port",
+ "destination_port",
+ "packet_bytes",
+ ),
+ )
+ writer.writeheader()
+
+ try:
+ active_capture = socket.socket(
+ socket.AF_INET,
+ socket.SOCK_RAW,
+ socket.IPPROTO_IP,
+ )
+ capture = active_capture
+
+ active_capture.bind((interface, 0))
+ active_capture.setsockopt(
+ socket.IPPROTO_IP,
+ socket.IP_HDRINCL,
+ 1,
+ )
+ active_capture.ioctl(
+ socket.SIO_RCVALL,
+ socket.RCVALL_ON,
+ )
+
+ deadline = time.monotonic() + timeout
+ retry_deadline = time.monotonic() + retry_window
+
+ while observation_count < count and time.monotonic() < deadline:
+ if context.is_cancelled:
+ early_result = CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled during packet capture",
+ )
+ break
+
+ remaining = max(0.0, deadline - time.monotonic())
+ ready, _, _ = select.select(
+ [active_capture],
+ [],
+ [],
+ min(1.0, remaining),
+ )
+
+ if not ready:
+ continue
+
+ try:
+ payload = active_capture.recv(65_535)
+ except OSError:
+ if time.monotonic() < retry_deadline:
+ time.sleep(0.1)
+ continue
+ raise
+
+ row = _packet_row(payload)
+ if row is not None:
+ writer.writerow(row)
+ observation_count += 1
+
+ except PermissionError as error:
+ early_result = CollectorResult(
+ CollectorStatus.SKIPPED,
+ "raw packet capture requires an elevated account",
+ errors=(str(error),),
+ )
+
+ except OSError as error:
+ if error.winerror in {5, 10013}:
+ early_result = CollectorResult(
+ CollectorStatus.SKIPPED,
+ "raw packet capture was denied for the current account",
+ errors=(str(error),),
+ )
+ else:
+ early_result = CollectorResult(
+ CollectorStatus.FAILED,
+ "raw packet capture failed",
+ errors=(str(error),),
+ )
+
+ finally:
+ if capture is not None:
+ self._close_capture(capture)
+
+ if early_result is not None:
+ output.unlink(missing_ok=True)
+ return early_result
+
+ if context.is_cancelled:
+ output.unlink(missing_ok=True)
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled after packet capture",
+ )
+
+ artifact = context.artifacts.register_file(
+ output,
+ media_type="text/csv",
+ )
+
+ context.report_progress(
+ "packet_capture_finished",
+ observation_count=observation_count,
+ bytes_written=artifact.size_bytes,
+ )
+
+ return CollectorResult.succeeded(
+ "packet metadata captured",
+ (artifact,),
+ )
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Raw socket cleanup happens in collect's finally block."""
diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py
new file mode 100644
index 00000000..65b5e4f2
--- /dev/null
+++ b/core/process/detailed_processes.py
@@ -0,0 +1,96 @@
+"""Export the verbose Windows task list as bounded CSV process evidence."""
+
+from __future__ import annotations
+
+from subprocess import TimeoutExpired
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from tasklist output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class DetailedProcessesCollector(CoreCollector):
+ """Capture a read-only verbose tasklist CSV report without changing processes."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated detailed process-report contract."""
+ return CollectorMetadata(
+ id="core.process.detailed_processes",
+ name="Detailed running processes",
+ version="4.0.0",
+ specialty=Specialty.PROCESS,
+ output_media_types=("text/csv",),
+ description="Exports the verbose local Windows task list as CSV.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("process_metadata",),
+ default_profiles=("deep",),
+ timeout_seconds=45,
+ maximum_output_bytes=8 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and tasklist availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("tasklist") is None:
+ return ValidationResult(False, reasons=("tasklist is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run the verbose task list and register its bounded CSV evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before detailed-process collection")
+ context.report_progress("detailed_processes_started")
+ try:
+ completed = subprocess.run(
+ ["tasklist", "/v", "/fo", "csv", "/nh"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ except TimeoutExpired as error:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "detailed tasklist query timed out",
+ errors=(f"tasklist timed out after {error.timeout} seconds",),
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"tasklist exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "tasklist access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "detailed tasklist query failed", errors=(detail,))
+ output = context.workspace / "detailed_processes.csv"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/csv")
+ process_count = sum(1 for line in completed.stdout.splitlines() if line.strip())
+ context.report_progress(
+ "detailed_processes_finished",
+ process_count=process_count,
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("detailed process list collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because tasklist exits before the result is returned."""
diff --git a/core/process/memory_map.py b/core/process/memory_map.py
new file mode 100644
index 00000000..c9bf8c1d
--- /dev/null
+++ b/core/process/memory_map.py
@@ -0,0 +1,277 @@
+"""Export readable memory-region metadata for the isolated collector process."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from logicytics import (
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ MemoryBasicInformation,
+ ProcessMemoryCounters,
+ Specialty,
+ ValidationResult,
+ get_current_process,
+ get_mapped_file_name,
+ get_process_memory_info,
+ pointer_value,
+ virtual_query,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+
+def _permissions(protection: int) -> str:
+ """Return a readable Windows page-protection summary."""
+ values = {
+ 0x02: "read",
+ 0x04: "read_write",
+ 0x08: "write_copy",
+ 0x20: "execute_read",
+ 0x40: "execute_read_write",
+ 0x80: "execute_write_copy",
+ }
+ return values.get(protection & 0xFF, "unreadable")
+
+
+class MemoryMapCollector(CoreCollector):
+ """Capture bounded virtual-memory metadata from the isolated worker process."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the bounded memory-region JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.process.memory_map",
+ name="Process memory map",
+ version="4.0.0",
+ specialty=Specialty.PROCESS,
+ output_media_types=("application/json",),
+ description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ sensitive_data_categories=("process_metadata",),
+ default_profiles=("deep",),
+ capabilities=(),
+ timeout_seconds=90,
+ maximum_output_bytes=64 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Validate cancellation state and bounded region settings."""
+ if context.is_cancelled:
+ return ValidationResult(
+ False,
+ reasons=("run cancellation was requested",),
+ )
+
+ def setting_int(name: str, default: int) -> int:
+ """Read one integer setting while rejecting booleans and unsupported objects."""
+ value = context.settings.get(name, default)
+
+ if isinstance(value, bool):
+ raise ValueError(f"{name} must be an integer")
+
+ if isinstance(value, (int, str, bytes, bytearray)):
+ return int(value)
+
+ raise ValueError(f"{name} must be an integer")
+
+ try:
+ maximum = setting_int("max_regions", 5_000)
+ output_limit = setting_int(
+ "output_limit_bytes",
+ 64 * 1024 * 1024,
+ )
+ safety_margin = setting_int(
+ "disk_safety_margin_bytes",
+ 100 * 1024 * 1024,
+ )
+ except (TypeError, ValueError):
+ return ValidationResult(
+ False,
+ reasons=("memory-map limits must be integers",),
+ )
+
+ if not 1 <= maximum <= 100_000 or not 1_024 <= output_limit <= 64 * 1024 * 1024 or safety_margin < 0:
+ return ValidationResult(
+ False,
+ reasons=("invalid memory-map region, output, or safety limits",),
+ )
+
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query readable local memory regions and write metadata-only JSON evidence."""
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled before memory-map collection",
+ )
+
+ maximum = context.setting_int("max_regions", 5_000)
+ output_limit = context.setting_int(
+ "output_limit_bytes",
+ 64 * 1024 * 1024,
+ )
+ safety_margin = context.setting_int(
+ "disk_safety_margin_bytes",
+ 100 * 1024 * 1024,
+ )
+
+ configured_directory = Path(context.setting_str("dump_directory", "memory_maps"))
+ output_directory = (context.workspace / configured_directory).resolve()
+
+ try:
+ output_directory.relative_to(context.workspace.resolve())
+ except ValueError:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "memory-map dump_directory must stay inside the collector workspace",
+ )
+
+ process = get_current_process()
+
+ counters = ProcessMemoryCounters()
+ if not get_process_memory_info(process, counters):
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "could not query process memory counters",
+ )
+
+ memory = MemoryBasicInformation()
+ address = 0
+ regions: list[dict[str, object]] = []
+
+ context.report_progress(
+ "memory_map_started",
+ max_regions=maximum,
+ )
+
+ while len(regions) < maximum:
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled during memory-map collection",
+ )
+
+ queried = virtual_query(address, memory)
+ region_size = int(memory.RegionSize)
+
+ if not queried or region_size == 0:
+ break
+
+ base_address = pointer_value(memory.BaseAddress)
+ if base_address is None:
+ base_address = address
+
+ readable = memory.State == 0x1000 and not memory.Protect & 0x101
+
+ if readable:
+ mapped_path = get_mapped_file_name(
+ process,
+ base_address,
+ )
+
+ regions.append(
+ {
+ "index": len(regions),
+ "address": f"0x{base_address:016X}",
+ "size_bytes": region_size,
+ "rss_bytes": int(counters.WorkingSetSize),
+ "permissions": _permissions(int(memory.Protect)),
+ "mapped_path": mapped_path,
+ "state": int(memory.State),
+ "type": int(memory.Type),
+ }
+ )
+
+ next_address = base_address + region_size
+
+ if next_address <= address:
+ break
+
+ address = next_address
+
+ truncated = False
+
+ while True:
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled during memory-map serialization",
+ )
+
+ serialized = (
+ json.dumps(
+ {
+ "region_count": len(regions),
+ "truncated": truncated,
+ "regions": regions,
+ },
+ indent=2,
+ )
+ + "\n"
+ )
+
+ serialized_size = len(serialized.encode("utf-8"))
+
+ if serialized_size <= output_limit or not regions:
+ break
+
+ regions.pop()
+ truncated = True
+
+ if filesystem_adapter.disk_usage(context.workspace).free < serialized_size + safety_margin:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "insufficient free disk space after configured memory-map safety margin",
+ )
+
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled before memory-map publication",
+ )
+
+ output_directory.mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+
+ output = output_directory / "memory_map.json"
+ output.write_text(
+ serialized,
+ encoding="utf-8",
+ )
+
+ if context.is_cancelled:
+ output.unlink(missing_ok=True)
+
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled during memory-map publication",
+ )
+
+ artifact = context.artifacts.register_file(
+ output,
+ media_type="application/json",
+ )
+
+ context.report_progress(
+ "memory_map_finished",
+ region_count=len(regions),
+ truncated=str(truncated).lower(),
+ bytes_written=artifact.size_bytes,
+ )
+
+ summary = "process memory map collected" if not truncated else "process memory map collected with configured output truncation"
+
+ return CollectorResult.succeeded(
+ summary,
+ (artifact,),
+ )
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the collector only queries its own process."""
diff --git a/core/process/process_memory.py b/core/process/process_memory.py
new file mode 100644
index 00000000..51586c00
--- /dev/null
+++ b/core/process/process_memory.py
@@ -0,0 +1,100 @@
+"""Export bounded per-process memory counters as read-only JSON evidence."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class ProcessMemoryCollector(CoreCollector):
+ """Capture per-process aggregate memory counters without reading memory contents."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated process-memory JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.process.process_memory",
+ name="Process memory",
+ version="4.0.0",
+ specialty=Specialty.PROCESS,
+ output_media_types=("application/json",),
+ description="Exports aggregate working-set, private, and virtual memory counters for local processes.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("process_metadata",),
+ default_profiles=("deep",),
+ timeout_seconds=60,
+ maximum_output_bytes=4 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query aggregate process memory counters and register a JSON artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before process-memory collection")
+ context.report_progress("process_memory_started")
+ command = (
+ "$ErrorActionPreference = 'Continue'; Get-Process | "
+ "Select-Object Id, ProcessName, WorkingSet64, PrivateMemorySize64, VirtualMemorySize64, HandleCount, CPU, StartTime | "
+ "ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=55,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "process-memory access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "process-memory query failed", errors=(detail,))
+ try:
+ processes = json.loads(completed.stdout) if completed.stdout.strip() else []
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "process-memory query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(processes, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "process-memory query returned an unexpected result")
+ output = context.workspace / "process_memory.json"
+ output.write_text(json.dumps(processes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(processes) if isinstance(processes, list) else 1
+ context.report_progress("process_memory_finished", process_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("process memory collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/process/running_processes.py b/core/process/running_processes.py
new file mode 100644
index 00000000..5b62e08d
--- /dev/null
+++ b/core/process/running_processes.py
@@ -0,0 +1,83 @@
+"""Collect a bounded CSV inventory of running Windows processes."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+class RunningProcessesCollector(CoreCollector):
+ """Capture the non-verbose Windows Tasklist report through the artifact boundary."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the local subprocess capability and bounded CSV output contract."""
+ return CollectorMetadata(
+ id="core.process.running_processes",
+ name="Running processes",
+ version="4.0.0",
+ specialty=Specialty.PROCESS,
+ output_media_types=("text/csv",),
+ description="Exports the non-verbose Windows Tasklist process inventory as CSV.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=20,
+ maximum_output_bytes=2 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Verify that the Windows Tasklist command is available before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("tasklist") is None:
+ return ValidationResult(False, reasons=("tasklist is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run Tasklist, write its CSV output in the private workspace, and register it."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Tasklist execution")
+ context.report_progress("tasklist_started")
+ completed = subprocess.run(
+ ["tasklist", "/FO", "CSV", "/NH"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=15,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"tasklist exit code {completed.returncode}"
+ if "access denied" in detail.casefold() or "access is denied" in detail.casefold():
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "Tasklist access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "Tasklist did not complete successfully",
+ errors=(detail,),
+ )
+ output = context.workspace / "running_processes.csv"
+ output.write_text(completed.stdout, encoding="utf-8", newline="")
+ artifact = context.artifacts.register_file(output, media_type="text/csv")
+ process_count = sum(1 for line in completed.stdout.splitlines() if line.strip())
+ context.report_progress("tasklist_finished", processes=process_count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded(
+ "running-process inventory collected",
+ (artifact,),
+ )
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because Tasklist completes before the result is returned."""
diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py
new file mode 100644
index 00000000..be01eb11
--- /dev/null
+++ b/core/registry/hklm_backup.py
@@ -0,0 +1,95 @@
+"""Export the local HKLM registry hive to a sensitive, read-only .reg artifact."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ EvidenceKind,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize Windows permission-denied or elevation-required wording."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or (
+ "access" in normalized and "denied" in normalized) or "requires elevation" in normalized
+
+
+class HklmBackupCollector(CoreCollector):
+ """Create a sensitive registry export solely inside the isolated workspace."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the explicit-consent HKLM registry-export artifact contract."""
+ return CollectorMetadata(
+ id="core.registry.hklm_backup",
+ name="HKLM registry backup",
+ version="4.0.0",
+ specialty=Specialty.REGISTRY,
+ output_media_types=("text/plain",),
+ description="Exports the local HKLM hive as a .reg backup after explicit sensitive-data approval.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(
+ Capability.REGISTRY_READ,
+ Capability.SUBPROCESS,
+ Capability.SENSITIVE_FILES,
+ ),
+ sensitive_data_categories=("registry", "system_configuration", "credentials"),
+ default_profiles=("deep",),
+ timeout_seconds=300,
+ maximum_output_bytes=2 * 1024 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and the Windows registry tool before export."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("reg") is None:
+ return ValidationResult(False, reasons=("reg.exe is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Export HKLM to the private workspace and register the resulting artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before HKLM registry backup")
+ context.report_progress("hklm_backup_started")
+ output = context.workspace / "hklm_backup.reg"
+ completed = subprocess.run(
+ ["reg", "export", "HKLM", str(output), "/y"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=290,
+ )
+ detail = completed.stderr.strip() or completed.stdout.strip()
+ if completed.returncode != 0:
+ message = detail or f"reg.exe exit code {completed.returncode}"
+ if _is_access_denied(message):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "HKLM registry export was denied for the current account",
+ errors=(message,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "HKLM registry export failed", errors=(message,))
+ if not output.is_file() or output.stat().st_size == 0:
+ return CollectorResult(CollectorStatus.FAILED, "HKLM registry export produced no backup artifact")
+ artifact = context.artifacts.register_file(
+ output,
+ media_type="text/plain",
+ evidence_kind=EvidenceKind.RAW,
+ transformations=("exported from the HKLM registry hive",),
+ )
+ context.report_progress("hklm_backup_finished", bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("HKLM registry backup collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Leave removal to the isolated collector workspace lifecycle."""
diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py
new file mode 100644
index 00000000..a0fd392b
--- /dev/null
+++ b/core/registry/installed_applications.py
@@ -0,0 +1,136 @@
+"""Export installed Windows application metadata from read-only uninstall registry keys."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import registry_adapter as winreg
+
+_UNINSTALL_PATHS = (
+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
+ r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
+)
+_MAX_APPLICATIONS = 5_000
+
+
+def _registry_value(key: winreg.HKEYType, name: str) -> str | None:
+ """Return a string registry value when present without exposing registry errors."""
+ try:
+ value, _ = winreg.QueryValueEx(key, name)
+ except OSError:
+ return None
+ return str(value) if value is not None else None
+
+
+def _installed_applications() -> list[dict[str, str | None]]:
+ """Enumerate uninstall metadata from 64-bit and WOW6432Node registry views."""
+ applications: list[dict[str, str | None]] = []
+ seen: set[tuple[str, str]] = set()
+ for path in _UNINSTALL_PATHS:
+ try:
+ root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
+ except OSError:
+ continue
+ with root:
+ index = 0
+ while len(applications) < _MAX_APPLICATIONS:
+ try:
+ key_name = winreg.EnumKey(root, index)
+ except OSError:
+ break
+ index += 1
+ try:
+ with winreg.OpenKey(root, key_name) as key:
+ display_name = _registry_value(key, "DisplayName")
+ if not display_name:
+ continue
+ version = _registry_value(key, "DisplayVersion") or ""
+ identity = (display_name, version)
+ if identity in seen:
+ continue
+ seen.add(identity)
+ applications.append(
+ {
+ "display_name": display_name,
+ "display_version": version or None,
+ "publisher": _registry_value(key, "Publisher"),
+ "install_date": _registry_value(key, "InstallDate"),
+ "install_location": _registry_value(key, "InstallLocation"),
+ "uninstall_key": key_name,
+ }
+ )
+ except OSError:
+ continue
+ return sorted(applications, key=lambda item: (item["display_name"] or "").casefold())
+
+
+class InstalledApplicationsCollector(CoreCollector):
+ """Capture read-only installed-application metadata from Windows registry views."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the registry-read, bounded installed-application artifact contract."""
+ return CollectorMetadata(
+ id="core.registry.installed_applications",
+ name="Installed applications",
+ version="4.0.0",
+ specialty=Specialty.REGISTRY,
+ output_media_types=("application/json",),
+ description="Exports installed application names, versions, publishers, and install metadata from uninstall keys.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.REGISTRY_READ,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=60,
+ maximum_output_bytes=4 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation and whether at least one uninstall registry view is readable."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ for path in _UNINSTALL_PATHS:
+ try:
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path):
+ return ValidationResult(True)
+ except OSError:
+ continue
+ return ValidationResult(False, reasons=("Windows uninstall registry keys are unavailable",))
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Read installed application metadata and register the bounded JSON artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before installed-application collection")
+ context.report_progress("installed_applications_started")
+ applications = _installed_applications()
+ output = context.workspace / "installed_applications.json"
+ output.write_text(
+ json.dumps(
+ {"collected_at": datetime.now(UTC).isoformat(), "applications": applications},
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "installed_applications_finished",
+ application_count=len(applications),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("installed applications collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because all registry handles are scoped and closed."""
diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py
new file mode 100644
index 00000000..31d994c6
--- /dev/null
+++ b/core/registry/startup_applications.py
@@ -0,0 +1,116 @@
+"""Export standard Windows startup Run-key entries as bounded read-only JSON evidence."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import registry_adapter as winreg
+
+_RUN_PATHS = (
+ (
+ "HKEY_CURRENT_USER",
+ winreg.HKEY_CURRENT_USER,
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
+ ),
+ (
+ "HKEY_CURRENT_USER",
+ winreg.HKEY_CURRENT_USER,
+ r"Software\Microsoft\Windows\CurrentVersion\RunOnce",
+ ),
+ (
+ "HKEY_LOCAL_MACHINE",
+ winreg.HKEY_LOCAL_MACHINE,
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
+ ),
+ (
+ "HKEY_LOCAL_MACHINE",
+ winreg.HKEY_LOCAL_MACHINE,
+ r"Software\Microsoft\Windows\CurrentVersion\RunOnce",
+ ),
+)
+
+
+def _startup_entries() -> list[dict[str, str]]:
+ """Enumerate values from conventional per-user and machine startup Run keys."""
+ entries: list[dict[str, str]] = []
+ for hive_name, hive, path in _RUN_PATHS:
+ try:
+ key = winreg.OpenKey(hive, path)
+ except OSError:
+ continue
+ with key:
+ index = 0
+ while True:
+ try:
+ name, value, _ = winreg.EnumValue(key, index)
+ except OSError:
+ break
+ index += 1
+ entries.append({"hive": hive_name, "path": path, "name": name, "command": str(value)})
+ return sorted(entries, key=lambda item: (item["hive"], item["path"], item["name"].casefold()))
+
+
+class StartupApplicationsCollector(CoreCollector):
+ """Capture startup Run-key commands without launching or modifying any application."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the registry-read startup-application artifact contract."""
+ return CollectorMetadata(
+ id="core.registry.startup_applications",
+ name="Startup applications",
+ version="4.0.0",
+ specialty=Specialty.REGISTRY,
+ output_media_types=("application/json",),
+ description="Exports standard user and machine Run/RunOnce startup registry entries.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.REGISTRY_READ,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=30,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation before reading potentially available startup key paths."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Read startup metadata and register a bounded JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before startup-application collection")
+ context.report_progress("startup_applications_started")
+ entries = _startup_entries()
+ output = context.workspace / "startup_applications.json"
+ output.write_text(
+ json.dumps(
+ {"collected_at": datetime.now(UTC).isoformat(), "entries": entries},
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress(
+ "startup_applications_finished",
+ entry_count=len(entries),
+ bytes_written=artifact.size_bytes,
+ )
+ return CollectorResult.succeeded("startup applications collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because all registry handles are scoped and closed."""
diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py
new file mode 100644
index 00000000..67b75316
--- /dev/null
+++ b/core/ssh/ssh_backup.py
@@ -0,0 +1,179 @@
+"""Archive the current user's SSH directory after explicit sensitive approval."""
+
+from __future__ import annotations
+
+import zipfile
+from pathlib import Path
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ EvidenceKind,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import filesystem_adapter
+
+MAX_FILE_BYTES = 10 * 1024 * 1024
+MAX_ARCHIVE_SOURCE_BYTES = 128 * 1024 * 1024
+
+
+class SshBackupCollector(CoreCollector):
+ """Back up current-user SSH keys and configuration into the private workspace."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the explicit-consent SSH archive artifact contract."""
+ return CollectorMetadata(
+ id="core.ssh.ssh_backup",
+ name="SSH directory backup",
+ version="4.0.0",
+ specialty=Specialty.SSH,
+ output_media_types=("application/zip",),
+ description="Archives the current user's .ssh keys and configuration after explicit sensitive-data approval.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(
+ Capability.FILESYSTEM_READ,
+ Capability.SENSITIVE_FILES,
+ Capability.PRIVATE_KEYS,
+ ),
+ sensitive_data_categories=("private_keys", "ssh_configuration", "credentials"),
+ default_profiles=("deep",),
+ timeout_seconds=120,
+ maximum_output_bytes=128 * 1024 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state before scanning the current user's SSH directory."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Archive bounded regular files from .ssh while preserving relative paths."""
+ if context.is_cancelled:
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled before SSH backup",
+ )
+
+ ssh_directory = filesystem_adapter.home() / ".ssh"
+
+ try:
+ directory_exists = ssh_directory.is_dir()
+ except OSError as error:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "the current user's .ssh directory is inaccessible",
+ errors=(str(error),),
+ )
+
+ if not directory_exists:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "the current user has no .ssh directory",
+ )
+
+ context.report_progress("ssh_backup_started")
+
+ archive = context.workspace / "ssh_backup.zip"
+ source_bytes = 0
+ archived_files = 0
+ skipped_files = 0
+ cancelled = False
+
+ with zipfile.ZipFile(
+ archive,
+ "w",
+ compression=zipfile.ZIP_DEFLATED,
+ ) as output:
+ try:
+ candidates: list[Path] = sorted(
+ (Path(path) for path in filesystem_adapter.recursive(ssh_directory)),
+ key=lambda path: path.as_posix(),
+ )
+ except OSError as error:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "the current user's .ssh directory is inaccessible",
+ errors=(str(error),),
+ )
+
+ for candidate in candidates:
+ if context.is_cancelled:
+ cancelled = True
+ break
+
+ try:
+ is_file = candidate.is_file()
+ is_symlink = candidate.is_symlink()
+ size = candidate.stat().st_size if is_file else 0
+ except OSError:
+ skipped_files += 1
+ continue
+
+ if not is_file or is_symlink:
+ continue
+
+ if size > MAX_FILE_BYTES or source_bytes + size > MAX_ARCHIVE_SOURCE_BYTES:
+ skipped_files += 1
+ continue
+
+ output.write(
+ candidate,
+ arcname=candidate.relative_to(ssh_directory).as_posix(),
+ )
+
+ if context.is_cancelled:
+ cancelled = True
+ break
+
+ source_bytes += size
+ archived_files += 1
+
+ if cancelled:
+ archive.unlink(missing_ok=True)
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled during SSH backup",
+ )
+
+ if archived_files == 0:
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "no SSH files met the bounded backup policy",
+ )
+
+ if context.is_cancelled:
+ archive.unlink(missing_ok=True)
+ return CollectorResult(
+ CollectorStatus.CANCELLED,
+ "cancelled before SSH-backup registration",
+ )
+
+ artifact = context.artifacts.register_file(
+ archive,
+ media_type="application/zip",
+ evidence_kind=EvidenceKind.RAW,
+ transformations=("archived from the current user's SSH directory",),
+ )
+
+ context.report_progress(
+ "ssh_backup_finished",
+ archived_files=archived_files,
+ skipped_files=skipped_files,
+ source_bytes=source_bytes,
+ bytes_written=artifact.size_bytes,
+ )
+
+ return CollectorResult.succeeded(
+ "SSH directory backup collected",
+ (artifact,),
+ )
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Leave archive removal to the isolated collector workspace lifecycle."""
diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py
new file mode 100644
index 00000000..a0314121
--- /dev/null
+++ b/core/storage/logical_drives.py
@@ -0,0 +1,120 @@
+"""Collect bounded Windows logical-drive capacity and type metadata."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+ get_disk_free_space,
+ get_drive_type,
+ get_logical_drives,
+ ularge_integer,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+
+_DRIVE_TYPES = {
+ 0: "unknown",
+ 1: "no_root_directory",
+ 2: "removable",
+ 3: "fixed",
+ 4: "remote",
+ 5: "optical",
+ 6: "ram_disk",
+}
+
+
+def _logical_drives() -> list[dict[str, int | str]]:
+ """Return Windows logical drive metadata without walking any filesystem contents."""
+ mask = get_logical_drives()
+ drives: list[dict[str, int | str]] = []
+
+ for offset in range(26):
+ if not mask & (1 << offset):
+ continue
+
+ root = f"{chr(ord('A') + offset)}:\\"
+ available = ularge_integer()
+ total = ularge_integer()
+ free = ularge_integer()
+
+ if not get_disk_free_space(
+ root,
+ available,
+ total,
+ free,
+ ):
+ continue
+
+ drive_type_code = get_drive_type(root)
+
+ drives.append(
+ {
+ "root": root,
+ "type": _DRIVE_TYPES.get(drive_type_code, "unknown"),
+ "total_bytes": int(total.value),
+ "free_bytes": int(free.value),
+ "available_bytes": int(available.value),
+ }
+ )
+
+ return drives
+
+
+class LogicalDrivesCollector(CoreCollector):
+ """Write a JSON inventory of mounted logical-drive capacity and type metadata."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare a bounded, capability-free storage metadata collector."""
+ return CollectorMetadata(
+ id="core.storage.logical_drives",
+ name="Logical drives",
+ version="4.0.0",
+ specialty=Specialty.STORAGE,
+ output_media_types=("application/json",),
+ description="Records mounted logical-drive types and aggregate capacity metadata.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(),
+ default_profiles=("minimal", "standard", "deep", "offline"),
+ timeout_seconds=10,
+ maximum_output_bytes=64 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation and ensure the Windows logical-drive API is available."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ try:
+ _logical_drives()
+ except OSError as error:
+ return ValidationResult(False, reasons=(f"logical-drive API is unavailable: {error}",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Capture logical-drive metadata and register a bounded JSON report."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before storage collection")
+ context.report_progress("logical_drives_started")
+ try:
+ drives = _logical_drives()
+ except OSError as error:
+ return CollectorResult(CollectorStatus.FAILED, "could not read logical drives", errors=(str(error),))
+ report = {
+ "collected_at": datetime.now(UTC).isoformat(),
+ "drives": drives,
+ }
+ output = context.workspace / "logical_drives.json"
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress("logical_drives_finished", drive_count=len(drives), bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("logical-drive metadata collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because the Windows storage API is synchronous."""
diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py
new file mode 100644
index 00000000..e6f6dfc4
--- /dev/null
+++ b/core/storage/mounted_volumes.py
@@ -0,0 +1,78 @@
+"""Export mounted Windows volume GUID mappings as bounded, read-only text evidence."""
+
+from __future__ import annotations
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from mountvol output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class MountedVolumesCollector(CoreCollector):
+ """Capture mounted volume GUID mappings without changing any mount points."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated mounted-volume text artifact contract."""
+ return CollectorMetadata(
+ id="core.storage.mounted_volumes",
+ name="Mounted volumes",
+ version="4.0.0",
+ specialty=Specialty.STORAGE,
+ output_media_types=("text/plain",),
+ description="Exports Windows mounted volume GUID and mount-point mappings from mountvol.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=30,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and mountvol availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("mountvol") is None:
+ return ValidationResult(False, reasons=("mountvol is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Run read-only mountvol and register its bounded text evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before mounted-volume collection")
+ context.report_progress("mounted_volumes_started")
+ completed = subprocess.run(["mountvol"], capture_output=True, check=False, text=True, timeout=25)
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"mountvol exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "mounted-volume access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "mounted-volume query failed", errors=(detail,))
+ output = context.workspace / "mounted_volumes.txt"
+ output.write_text(completed.stdout, encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="text/plain")
+ volume_count = sum(1 for line in completed.stdout.splitlines() if line.strip().startswith("\\\\?\\Volume{"))
+ context.report_progress("mounted_volumes_finished", volume_count=volume_count,
+ bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("mounted-volume mappings collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because mountvol exits before the result is returned."""
diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py
new file mode 100644
index 00000000..30a8153d
--- /dev/null
+++ b/core/storage/physical_disks.py
@@ -0,0 +1,100 @@
+"""Export local Windows physical disk model and capacity CIM data as bounded JSON."""
+
+from __future__ import annotations
+
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+class PhysicalDisksCollector(CoreCollector):
+ """Capture a read-only inventory of locally attached physical disk hardware."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the subprocess-gated physical-disk JSON artifact contract."""
+ return CollectorMetadata(
+ id="core.storage.physical_disks",
+ name="Physical disks",
+ version="4.0.0",
+ specialty=Specialty.STORAGE,
+ output_media_types=("application/json",),
+ description="Exports local physical disk models, media types, interface types, and sizes.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ capabilities=(Capability.SUBPROCESS,),
+ sensitive_data_categories=("system_configuration",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=45,
+ maximum_output_bytes=512 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation state and PowerShell availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ if which("powershell") is None:
+ return ValidationResult(False, reasons=("PowerShell is unavailable on this system",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Query physical-disk CIM data and register a JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before physical-disk collection")
+ context.report_progress("physical_disks_started")
+ command = (
+ "Get-CimInstance -ClassName Win32_DiskDrive | "
+ "Select-Object Model, Size, MediaType, InterfaceType, DeviceID, Partitions | "
+ "ConvertTo-Json -Depth 3"
+ )
+ completed = subprocess.run(
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=40,
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}"
+ if _is_access_denied(detail):
+ return CollectorResult(
+ CollectorStatus.SKIPPED,
+ "physical-disk CIM access was denied for the current account",
+ errors=(detail,),
+ )
+ return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query failed", errors=(detail,))
+ try:
+ physical_disks = json.loads(completed.stdout)
+ except json.JSONDecodeError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "physical-disk CIM query returned invalid JSON",
+ errors=(str(error),),
+ )
+ if not isinstance(physical_disks, (dict, list)):
+ return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned an unexpected result")
+ output = context.workspace / "physical_disks.json"
+ output.write_text(json.dumps(physical_disks, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ count = len(physical_disks) if isinstance(physical_disks, list) else 1
+ context.report_progress("physical_disks_finished", disk_count=count, bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("physical-disk inventory collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because PowerShell exits before the result is returned."""
diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py
new file mode 100644
index 00000000..a9c96439
--- /dev/null
+++ b/core/storage/volume_details.py
@@ -0,0 +1,145 @@
+"""Export detailed mounted Windows volume metadata through bounded native API calls."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+
+from logicytics import (
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+ create_unicode_buffer,
+ dword,
+ get_disk_free_space,
+ get_volume_information,
+ ularge_integer,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import windows_api_adapter
+
+_DRIVE_TYPES = {
+ 0: "unknown",
+ 1: "no_root_directory",
+ 2: "removable",
+ 3: "fixed",
+ 4: "remote",
+ 5: "optical",
+ 6: "ram_disk",
+}
+
+
+def _volume_details() -> list[dict[str, int | str]]:
+ """Read mounted logical-volume capacity, label, and filesystem metadata only."""
+ kernel32 = windows_api_adapter.load_library("kernel32")
+ mask = kernel32.GetLogicalDrives()
+ if mask == 0:
+ raise OSError("GetLogicalDrives failed")
+ volumes: list[dict[str, int | str]] = []
+ for offset in range(26):
+ if not mask & (1 << offset):
+ continue
+ root = f"{chr(ord('A') + offset)}:\\"
+
+ available = ularge_integer()
+ total = ularge_integer()
+ free = ularge_integer()
+
+ if not get_disk_free_space(
+ root,
+ available,
+ total,
+ free,
+ ):
+ continue
+
+ label = create_unicode_buffer(261)
+ filesystem = create_unicode_buffer(261)
+ serial = dword()
+ maximum_component_length = dword()
+ flags = dword()
+
+ information_available = get_volume_information(
+ root,
+ label,
+ serial,
+ maximum_component_length,
+ flags,
+ filesystem,
+ )
+ volumes.append(
+ {
+ "root": root,
+ "drive_type": _DRIVE_TYPES.get(kernel32.GetDriveTypeW(root), "unknown"),
+ "filesystem": filesystem.value if information_available else "unavailable",
+ "volume_name": label.value if information_available else "unavailable",
+ "total_bytes": total.value,
+ "free_bytes": free.value,
+ "available_bytes": available.value,
+ }
+ )
+ return volumes
+
+
+class VolumeDetailsCollector(CoreCollector):
+ """Capture detailed mounted logical-volume metadata without walking file contents."""
+
+ @classmethod
+ def metadata(cls) -> CollectorMetadata:
+ """Declare the capability-free detailed volume artifact contract."""
+ return CollectorMetadata(
+ id="core.storage.volume_details",
+ name="Volume details",
+ version="4.0.0",
+ specialty=Specialty.STORAGE,
+ output_media_types=("application/json",),
+ description="Exports mounted drive type, filesystem, label, and capacity metadata.",
+ author="Logicytics",
+ supported_platforms=("win32",),
+ default_profiles=("standard", "deep"),
+ timeout_seconds=15,
+ capabilities=(),
+ maximum_output_bytes=128 * 1024,
+ )
+
+ def validate(self, context: CollectorContext) -> ValidationResult:
+ """Check cancellation and native volume API availability before collection."""
+ if context.is_cancelled:
+ return ValidationResult(False, reasons=("run cancellation was requested",))
+ try:
+ _volume_details()
+ except OSError as error:
+ return ValidationResult(False, reasons=(f"volume API is unavailable: {error}",))
+ return ValidationResult(True)
+
+ def collect(self, context: CollectorContext) -> CollectorResult:
+ """Read detailed volume metadata and register a JSON evidence artifact."""
+ if context.is_cancelled:
+ return CollectorResult(CollectorStatus.CANCELLED, "cancelled before volume-details collection")
+ context.report_progress("volume_details_started")
+ try:
+ volumes = _volume_details()
+ except OSError as error:
+ return CollectorResult(
+ CollectorStatus.FAILED,
+ "could not read detailed volume metadata",
+ errors=(str(error),),
+ )
+ output = context.workspace / "volume_details.json"
+ output.write_text(
+ json.dumps(
+ {"collected_at": datetime.now(UTC).isoformat(), "volumes": volumes},
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ artifact = context.artifacts.register_file(output, media_type="application/json")
+ context.report_progress("volume_details_finished", volume_count=len(volumes), bytes_written=artifact.size_bytes)
+ return CollectorResult.succeeded("detailed volume metadata collected", (artifact,))
+
+ def cleanup(self, context: CollectorContext) -> None:
+ """Release no resources because Windows volume API calls are synchronous."""
diff --git a/core/system/bios_info.py b/core/system/bios_info.py
new file mode 100644
index 00000000..85dc7fee
--- /dev/null
+++ b/core/system/bios_info.py
@@ -0,0 +1,126 @@
+"""Export Windows BIOS manufacturer, name, and version as a bounded HTML table."""
+
+from __future__ import annotations
+
+import html
+import json
+
+from logicytics import (
+ Capability,
+ CollectorMetadata,
+ CollectorResult,
+ CoreCollector,
+ Specialty,
+ ValidationResult,
+)
+from logicytics.contracts import CollectorContext, CollectorStatus
+from logicytics.platform_adapters import process_adapter as subprocess
+from logicytics.platform_adapters import which
+
+
+def _is_access_denied(detail: str) -> bool:
+ """Recognize common permission-denied wording from PowerShell output."""
+ normalized = detail.casefold()
+ return "permission denied" in normalized or ("access" in normalized and "denied" in normalized)
+
+
+def render_bios_table(bios: dict[str, str | None]) -> str:
+ """Render trusted structured BIOS fields into a portable evidence table."""
+ rows = "\n".join(
+ f"
{html.escape(label)}
{html.escape(str(bios.get(key) or 'unavailable'))}