From 5595c7e462aba94346439a771c34969c5f2abd6d Mon Sep 17 00:00:00 2001 From: Umu Date: Sun, 8 Nov 2026 03:13:02 -0800 Subject: [PATCH] Import upstream Linux CIS, DLP and DNS audit tools with pinned provenance --- .gitattributes | 2 + README.md | 3 + THIRD_PARTY.md | 7 +- scripts/check_community_imports.py | 4 +- third_party/README.md | 2 +- third_party/community/README.md | 28 +- .../community/dlp-scanner/.dlp-scanner.yml | 86 ++ third_party/community/dlp-scanner/LICENSE | 661 +++++++++++++ third_party/community/dlp-scanner/README.md | 119 +++ .../community/dlp-scanner/pyproject.toml | 181 ++++ .../dlp-scanner/src/dlp_scanner/__init__.py | 6 + .../dlp-scanner/src/dlp_scanner/cli.py | 73 ++ .../src/dlp_scanner/commands/__init__.py | 4 + .../src/dlp_scanner/commands/report.py | 187 ++++ .../src/dlp_scanner/commands/scan.py | 195 ++++ .../dlp-scanner/src/dlp_scanner/compliance.py | 245 +++++ .../dlp-scanner/src/dlp_scanner/config.py | 173 ++++ .../dlp-scanner/src/dlp_scanner/constants.py | 171 ++++ .../src/dlp_scanner/detectors/__init__.py | 4 + .../src/dlp_scanner/detectors/base.py | 51 + .../src/dlp_scanner/detectors/context.py | 142 +++ .../src/dlp_scanner/detectors/entropy.py | 126 +++ .../src/dlp_scanner/detectors/pattern.py | 68 ++ .../src/dlp_scanner/detectors/registry.py | 113 +++ .../dlp_scanner/detectors/rules/__init__.py | 4 + .../detectors/rules/credentials.py | 157 +++ .../dlp_scanner/detectors/rules/financial.py | 184 ++++ .../src/dlp_scanner/detectors/rules/health.py | 140 +++ .../src/dlp_scanner/detectors/rules/pii.py | 209 ++++ .../dlp-scanner/src/dlp_scanner/engine.py | 146 +++ .../src/dlp_scanner/extractors/__init__.py | 4 + .../src/dlp_scanner/extractors/archive.py | 191 ++++ .../src/dlp_scanner/extractors/base.py | 27 + .../src/dlp_scanner/extractors/email.py | 123 +++ .../src/dlp_scanner/extractors/office.py | 178 ++++ .../src/dlp_scanner/extractors/pdf.py | 56 ++ .../src/dlp_scanner/extractors/plaintext.py | 112 +++ .../src/dlp_scanner/extractors/structured.py | 327 +++++++ .../dlp-scanner/src/dlp_scanner/log.py | 80 ++ .../dlp-scanner/src/dlp_scanner/models.py | 112 +++ .../src/dlp_scanner/network/__init__.py | 4 + .../src/dlp_scanner/network/exfiltration.py | 279 ++++++ .../src/dlp_scanner/network/flow_tracker.py | 126 +++ .../src/dlp_scanner/network/pcap.py | 115 +++ .../src/dlp_scanner/network/protocols.py | 250 +++++ .../dlp-scanner/src/dlp_scanner/redaction.py | 84 ++ .../src/dlp_scanner/reporters/__init__.py | 4 + .../src/dlp_scanner/reporters/base.py | 20 + .../src/dlp_scanner/reporters/console.py | 162 ++++ .../src/dlp_scanner/reporters/csv_report.py | 64 ++ .../src/dlp_scanner/reporters/json_report.py | 110 +++ .../src/dlp_scanner/reporters/sarif.py | 171 ++++ .../src/dlp_scanner/scanners/__init__.py | 4 + .../src/dlp_scanner/scanners/base.py | 20 + .../src/dlp_scanner/scanners/db_scanner.py | 531 +++++++++++ .../src/dlp_scanner/scanners/file_scanner.py | 221 +++++ .../dlp_scanner/scanners/network_scanner.py | 337 +++++++ .../dlp-scanner/src/dlp_scanner/scoring.py | 52 + third_party/community/dns-lookup/LICENSE | 661 +++++++++++++ third_party/community/dns-lookup/README.md | 67 ++ .../dns-lookup/dnslookup/__init__.py | 37 + .../dns-lookup/dnslookup/__main__.py | 11 + .../community/dns-lookup/dnslookup/cli.py | 441 +++++++++ .../community/dns-lookup/dnslookup/output.py | 406 ++++++++ .../dns-lookup/dnslookup/resolver.py | 424 +++++++++ .../dns-lookup/dnslookup/whois_lookup.py | 241 +++++ .../community/dns-lookup/pyproject.toml | 93 ++ third_party/community/imports.json | 360 +++++++ .../linux-cis-hardening-auditor/DEMO.md | 49 + .../linux-cis-hardening-auditor/LICENSE | 661 +++++++++++++ .../linux-cis-hardening-auditor/README.md | 105 ++ .../src/checks/01_initial_setup.sh | 487 ++++++++++ .../src/checks/02_services.sh | 446 +++++++++ .../src/checks/03_network.sh | 466 +++++++++ .../src/checks/04_logging.sh | 385 ++++++++ .../src/checks/05_access.sh | 449 +++++++++ .../src/checks/05_access_password.sh | 123 +++ .../src/checks/06_maintenance.sh | 231 +++++ .../src/cisaudit.sh | 299 ++++++ .../src/controls/registry_data.sh | 894 ++++++++++++++++++ .../src/lib/baseline.sh | 108 +++ .../src/lib/constants.sh | 57 ++ .../src/lib/engine.sh | 103 ++ .../src/lib/registry.sh | 110 +++ .../src/lib/report_html.sh | 452 +++++++++ .../src/lib/report_json.sh | 138 +++ .../src/lib/report_terminal.sh | 279 ++++++ .../src/lib/utils.sh | 128 +++ 88 files changed, 15956 insertions(+), 10 deletions(-) create mode 100644 .gitattributes create mode 100644 third_party/community/dlp-scanner/.dlp-scanner.yml create mode 100644 third_party/community/dlp-scanner/LICENSE create mode 100644 third_party/community/dlp-scanner/README.md create mode 100644 third_party/community/dlp-scanner/pyproject.toml create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/cli.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/commands/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/commands/report.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/commands/scan.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/compliance.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/config.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/constants.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/base.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/context.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/entropy.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/pattern.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/registry.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/health.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/engine.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/archive.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/base.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/email.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/office.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/pdf.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/plaintext.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/extractors/structured.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/log.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/models.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/network/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/network/exfiltration.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/network/flow_tracker.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/network/pcap.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/network/protocols.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/redaction.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/reporters/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/reporters/base.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/reporters/console.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/reporters/csv_report.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/reporters/json_report.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/reporters/sarif.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/scanners/__init__.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/scanners/base.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py create mode 100644 third_party/community/dlp-scanner/src/dlp_scanner/scoring.py create mode 100644 third_party/community/dns-lookup/LICENSE create mode 100644 third_party/community/dns-lookup/README.md create mode 100644 third_party/community/dns-lookup/dnslookup/__init__.py create mode 100644 third_party/community/dns-lookup/dnslookup/__main__.py create mode 100644 third_party/community/dns-lookup/dnslookup/cli.py create mode 100644 third_party/community/dns-lookup/dnslookup/output.py create mode 100644 third_party/community/dns-lookup/dnslookup/resolver.py create mode 100644 third_party/community/dns-lookup/dnslookup/whois_lookup.py create mode 100644 third_party/community/dns-lookup/pyproject.toml create mode 100644 third_party/community/linux-cis-hardening-auditor/DEMO.md create mode 100644 third_party/community/linux-cis-hardening-auditor/LICENSE create mode 100644 third_party/community/linux-cis-hardening-auditor/README.md create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/02_services.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/03_network.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/04_logging.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/05_access.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/05_access_password.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/checks/06_maintenance.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/cisaudit.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/controls/registry_data.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/baseline.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/constants.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/engine.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/registry.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/report_html.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/report_json.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/report_terminal.sh create mode 100644 third_party/community/linux-cis-hardening-auditor/src/lib/utils.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3815488 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep pinned upstream community source bytes identical on all platforms. +third_party/community/** -text diff --git a/README.md b/README.md index 2061db0..b5a7548 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ These are runnable third-party CLI imports, **not projects written for this port | --- | --- | --- | | [HTTP security-header scanner](third_party/community/http-headers-scanner/) | ©AngelaMos / CarterPerez-dev; evaluates HTTP response security headers | [Code, usage and limitations](third_party/community/README.md) | | [Hash identifier](third_party/community/hash-identifier/) | ©AngelaMos / CarterPerez-dev; suggests likely hash formats | [Code, usage and limitations](third_party/community/README.md) | +| [Linux CIS hardening auditor](third_party/community/linux-cis-hardening-auditor/) | ©AngelaMos / CarterPerez-dev; Linux hardening checks and compliance scores | [Source and run limits](third_party/community/README.md) | +| [DLP scanner](third_party/community/dlp-scanner/) | ©AngelaMos / CarterPerez-dev; checks for sensitive data in files, databases and PCAPs | [Source and run limits](third_party/community/README.md) | +| [DNS lookup CLI](third_party/community/dns-lookup/) | ©AngelaMos / CarterPerez-dev; DNS, reverse lookup and WHOIS investigation | [Source and run limits](third_party/community/README.md) | The pinned files have a [separate import manifest](third_party/community/imports.json), preserving the earlier recorded integrations unchanged. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 5190e25..36e2bc8 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -2,9 +2,9 @@ ![Open-source integration](assets/badges/open-source-integration.svg) -**Work type:** upstream-authored tool source, paired with collection-developed integration examples. [Portfolio projects](README.md#portfolio-projects) have a separate index. +**Work type:** upstream-authored tool source. The first two integrations have collection-developed examples; community imports are source-only. [Portfolio projects](README.md#portfolio-projects) have a separate index. -These tools are copied release snapshots credited to their original authors. Their source is unmodified; each retains its own license. The collection's contribution is the pinned import, local examples, recorded outputs and integration checks. +These tools are copied release snapshots credited to their original authors. Their source is unmodified; each retains its own license. The collection's contribution varies: the earlier ssh-audit/detect-secrets snapshots have retained examples and results; community copies have pinned provenance and source-integrity checks, but no collection execution claims. | Tool | Original project and authors | Imported release | License | Collection example | | --- | --- | --- | --- | --- | @@ -12,6 +12,9 @@ These tools are copied release snapshots credited to their original authors. The | detect-secrets | [Yelp and contributors](https://github.com/Yelp/detect-secrets) | [v1.5.0 / 01886c8](https://github.com/Yelp/detect-secrets/tree/01886c8a910c64595c47f186ca1ffc0b77fa5458) | [Apache-2.0](third_party/detect-secrets/LICENSE), with embedded notices preserved | [Secret-detection controls](projects/12-secret-detection/) | | HTTP security-header scanner | [AngelaMos / CarterPerez-dev](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/foundations/http-headers-scanner) | [Pinned source / 6c8ac85](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865) | [AGPL-3.0-or-later](third_party/community/http-headers-scanner/LICENSE) | [Copied runnable source only](third_party/community/http-headers-scanner/); no collection execution claimed | | Hash identifier | [AngelaMos / CarterPerez-dev](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/foundations/hash-identifier) | [Pinned source / 6c8ac85](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865) | [AGPL-3.0-or-later](third_party/community/hash-identifier/LICENSE) | [Copied runnable source only](third_party/community/hash-identifier/); no collection execution claimed | +| Linux CIS hardening auditor | [AngelaMos / CarterPerez-dev](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/beginner/linux-cis-hardening-auditor) | [Pinned source / 6c8ac85](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865) | [AGPL-3.0-or-later](third_party/community/linux-cis-hardening-auditor/LICENSE) | [Copied runnable source only](third_party/community/linux-cis-hardening-auditor/); no collection execution claimed | +| DLP scanner | [AngelaMos / CarterPerez-dev](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/intermediate/dlp-scanner) | [Pinned source / 6c8ac85](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865) | [AGPL-3.0-or-later](third_party/community/dlp-scanner/LICENSE) | [Copied runnable source only](third_party/community/dlp-scanner/); no collection execution claimed | +| DNS lookup CLI | [AngelaMos / CarterPerez-dev](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/beginner/dns-lookup) | [Pinned source / 6c8ac85](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865) | [AGPL-3.0-or-later](third_party/community/dns-lookup/LICENSE) | [Copied runnable source only](third_party/community/dns-lookup/); no collection execution claimed | [imports.json](third_party/imports.json) records the earlier two evidence-bound integration snapshots by path and SHA-256; [community/imports.json](third_party/community/imports.json) separately records the new source-only imports by Git blob hash. Runtime source, package metadata and notices are included; upstream test suites, build automation and test keys are excluded. Upstream READMEs are retained unchanged as source documents; use the collection examples above for commands that match this layout. diff --git a/scripts/check_community_imports.py b/scripts/check_community_imports.py index 4ec92f2..f44b0db 100644 --- a/scripts/check_community_imports.py +++ b/scripts/check_community_imports.py @@ -1,4 +1,4 @@ -"""Verify pinned, untouched community CLI copies; never execute upstream tools.""" +"""Verify pinned, untouched community tool copies; never execute upstream scanners.""" import ast import hashlib import json @@ -14,7 +14,7 @@ def git_blob_sha1(data: bytes) -> str: def main() -> None: records = json.loads(MANIFEST.read_text(encoding="utf-8"))["imports"] - expected_names = {"http-headers-scanner", "hash-identifier"} + expected_names = {"http-headers-scanner", "hash-identifier", "linux-cis-hardening-auditor", "dlp-scanner", "dns-lookup"} if {item["name"] for item in records} != expected_names or len(records) != len(expected_names): raise ValueError("Unexpected community import set") total = 0 diff --git a/third_party/README.md b/third_party/README.md index 00884a5..d1124ac 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -7,6 +7,6 @@ This directory contains unmodified release snapshots of software written by upst The collection-developed fixtures, runners and recorded results are described under [Open-source tool integrations](../README.md#open-source-tool-integrations). Portfolio investigations and software are listed separately under [Portfolio projects](../README.md#portfolio-projects). -Additional [community CLI source copies](community/) include the upstream HTTP security-header scanner and hash identifier from CarterPerez-dev/AngelaMos (AGPL-3.0-or-later). Their [separate pinned manifest](community/imports.json) leaves the previously recorded import evidence untouched. They are runnable source only; no collection-authored security findings or results are claimed. +Additional [community CLI source copies](community/) include HTTP security-header scanning, hash identification, Linux CIS hardening audits, DLP scanning and DNS lookup from CarterPerez-dev/AngelaMos (AGPL-3.0-or-later). Their [separate pinned manifest](community/imports.json) leaves the previously recorded import evidence untouched. They are runnable source only; no collection-authored security findings or results are claimed. [Original authors and licenses](../THIRD_PARTY.md) · [Import manifest](imports.json) diff --git a/third_party/community/README.md b/third_party/community/README.md index 606b995..3389cb1 100644 --- a/third_party/community/README.md +++ b/third_party/community/README.md @@ -1,26 +1,44 @@ # Community tools — original third-party code -These are **verbatim copies of upstream-authored CLI tools**, not software written by Don Lopez. The imports are pinned to [CarterPerez-dev/Cybersecurity-Projects at `6c8ac8547cf52edaa06935e637a1fe11ac45b865`](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865). The Python source attributes ©AngelaMos (2026). Each tool remains under its original **AGPL-3.0-or-later** license. See [the import manifest](imports.json) and [original author credits](../../THIRD_PARTY.md). +These are **verbatim copies of upstream-authored CLI tools**, not software written by Don Lopez. The imports are pinned to [CarterPerez-dev/Cybersecurity-Projects at `6c8ac8547cf52edaa06935e637a1fe11ac45b865`](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865). The upstream source attributes ©AngelaMos (2026). Each tool remains under its original **AGPL-3.0-or-later** license. See [the import manifest](imports.json) and [original author credits](../../THIRD_PARTY.md). | Copied tool | Upstream source | What it does | Status | | --- | --- | --- | --- | | [HTTP security-header scanner](http-headers-scanner/) | [Foundations project](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/foundations/http-headers-scanner) | Fetches one URL and grades six response security headers. | Verbatim source; no collection scan or recorded result | | [Hash identifier](hash-identifier/) | [Foundations project](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/foundations/hash-identifier) | Suggests hash algorithm formats by prefix, length and character set. | Verbatim source; no accuracy study or recorded result | +| [Linux CIS hardening auditor](linux-cis-hardening-auditor/) | [Beginner project](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/beginner/linux-cis-hardening-auditor) | Evaluates Linux hardening controls and formats compliance reports. | Verbatim source; no host audit or recorded score | +| [DLP scanner](dlp-scanner/) | [Intermediate project](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/intermediate/dlp-scanner) | Checks files, database samples and packet captures for sensitive-data patterns. | Verbatim source; no real-data scan or results | +| [DNS lookup CLI](dns-lookup/) | [Beginner project](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/6c8ac8547cf52edaa06935e637a1fe11ac45b865/PROJECTS/beginner/dns-lookup) | Queries DNS record types, reverse lookup and WHOIS. | Verbatim source; no network queries or results | ## Use locally -These copies include the upstream scripts, readmes, dependency metadata and licenses. They omit the large upstream learning modules, tests and lockfiles. The original READMEs may link to omitted companion material; use the pinned upstream links above for that material. +All copies retain the upstream code, README and license, plus relevant package metadata or config. They omit upstream tests, large learning modules and lockfiles; the original READMEs may link to those omitted companion assets. For details, follow the pinned upstream source links above. -For Python 3.13+ in a dedicated environment, install the upstream runtime dependencies `rich>=15` (both tools) and `httpx>=0.28.1` (header scanner), then invoke: +For the original two Python CLI tools, use Python 3.13+ in a dedicated environment with `rich>=15` and (for headers) `httpx>=0.28.1`: ```bash python third_party/community/hash-identifier/hash_identifier.py --help python third_party/community/http-headers-scanner/http_headers_scanner.py --help ``` -Use the header scanner **only with websites you own or are authorized to assess**. Hash guesses are heuristic, not proof of a generating algorithm. Neither copy is represented as a vulnerability confirmation or original portfolio project. +For a read-only Linux CIS control listing (Bash on Debian/Ubuntu): -Run the bounded source-integrity check, which **does not execute either tool**: +```bash +bash third_party/community/linux-cis-hardening-auditor/src/cisaudit.sh --list-controls +``` + +For the DLP and DNS CLIs, use isolated virtual environments with the Python versions required by their upstream `pyproject.toml` (DLP ≥3.12; DNS ≥3.13). Installation can download large dependencies; review each upstream `pyproject.toml` and install only if you need to run that tool. From the repository root: + +```bash +python -m pip install -e third_party/community/dlp-scanner +python -m pip install -e third_party/community/dns-lookup +dlp-scan --help +dnslookup --help +``` + +Only scan systems, hosts and data you own or are authorized to examine. DLP output can include sensitive matches: do not commit real logs, personal data, credentials, or scan reports to this public repo. The CIS auditor describes a benchmark subset, not a certification of compliance. DNS/WHOIS may make external queries. Hash identification is heuristic; HTTP-header grades are not vulnerability confirmation. **No execution outcome for any of these five imports is claimed.** + +Run the bounded source-integrity check, which **does not execute the imported scanners**: ```bash python scripts/check_community_imports.py diff --git a/third_party/community/dlp-scanner/.dlp-scanner.yml b/third_party/community/dlp-scanner/.dlp-scanner.yml new file mode 100644 index 0000000..422cd5d --- /dev/null +++ b/third_party/community/dlp-scanner/.dlp-scanner.yml @@ -0,0 +1,86 @@ +# ©AngelaMos | 2026 +# .dlp-scanner.yml + +scan: + file: + max_file_size_mb: 100 + recursive: true + exclude_patterns: + - "*.pyc" + - "__pycache__" + - ".git" + - "node_modules" + - ".venv" + include_extensions: + - ".pdf" + - ".docx" + - ".xlsx" + - ".xls" + - ".csv" + - ".json" + - ".xml" + - ".yaml" + - ".yml" + - ".txt" + - ".log" + - ".eml" + - ".msg" + - ".parquet" + - ".avro" + - ".tar.gz" + - ".tar.bz2" + - ".zip" + + database: + sample_percentage: 5 + max_rows_per_table: 10000 + timeout_seconds: 30 + exclude_tables: [] + include_tables: [] + + network: + bpf_filter: "" + entropy_threshold: 7.2 + dns_label_entropy_threshold: 4.0 + max_packets: 0 + +detection: + min_confidence: 0.20 + severity_threshold: "low" + context_window_tokens: 10 + enable_rules: + - "*" + disable_rules: [] + allowlists: + values: + - "123-45-6789" + - "000-00-0000" + - "4111111111111111" + domains: + - "example.com" + - "test.com" + file_patterns: + - "test_*" + - "*_fixture*" + - "mock_*" + +compliance: + frameworks: + - "HIPAA" + - "PCI_DSS" + - "GDPR" + - "CCPA" + - "SOX" + - "GLBA" + +output: + format: "console" + output_file: "" + redaction_style: "partial" + verbose: false + color: true + +logging: + level: "INFO" + json_output: false + log_file: "" diff --git a/third_party/community/dlp-scanner/LICENSE b/third_party/community/dlp-scanner/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/third_party/community/dlp-scanner/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/third_party/community/dlp-scanner/README.md b/third_party/community/dlp-scanner/README.md new file mode 100644 index 0000000..b2271ea --- /dev/null +++ b/third_party/community/dlp-scanner/README.md @@ -0,0 +1,119 @@ +```ruby +$$$$$$$\ $$\ $$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$\ $$\ $$\ $$\ $$$$$$$$\ $$$$$$$\ +$$ __$$\ $$ | $$ __$$\ $$ __$$\ $$ __$$\ $$ __$$\ $$$\ $$ |$$$\ $$ |$$ _____|$$ __$$\ +$$ | $$ |$$ | $$ | $$ | $$ / \__|$$ / \__|$$ / $$ |$$$$\ $$ |$$$$\ $$ |$$ | $$ | $$ | +$$ | $$ |$$ | $$$$$$$ | \$$$$$$\ $$ | $$$$$$$$ |$$ $$\$$ |$$ $$\$$ |$$$$$\ $$$$$$$ | +$$ | $$ |$$ | $$ ____/ \____$$\ $$ | $$ __$$ |$$ \$$$$ |$$ \$$$$ |$$ __| $$ __$$< +$$ | $$ |$$ | $$ | $$\ $$ |$$ | $$\ $$ | $$ |$$ |\$$$ |$$ |\$$$ |$$ | $$ | $$ | +$$$$$$$ |$$$$$$$$\ $$ | \$$$$$$ |\$$$$$$ |$$ | $$ |$$ | \$$ |$$ | \$$ |$$$$$$$$\ $$ | $$ | +\_______/ \________|\__| \______/ \______/ \__| \__|\__| \__|\__| \__|\________|\__| \__| +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2323-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/dlp-scanner) +[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) + +> Data Loss Prevention scanner for files, databases, and network traffic. + +*This is a quick overview. Security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Scans files (PDF, DOCX, XLSX, CSV, JSON, XML, YAML, Parquet, Avro, archives, emails) for PII, credentials, financial data, and PHI +- Scans databases (PostgreSQL, MySQL, MongoDB, SQLite) with schema introspection and sampling +- Scans network captures (PCAP/PCAPNG) with protocol parsing, TCP reassembly, and DNS exfiltration detection +- Confidence scoring pipeline: regex match, checksum validation (Luhn, Mod-97, Mod-11), context keyword proximity, entity co-occurrence +- Maps findings to compliance frameworks (HIPAA, PCI-DSS, GDPR, CCPA, SOX, GLBA, FERPA) +- Reports in console (Rich tables), JSON, SARIF 2.1.0, or CSV + +## Quick Start + +```bash +bash install.sh +dlp-scan file ./data +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Usage + +```bash +dlp-scan file ./data/employees/ # scan a directory +dlp-scan file ./report.pdf -f json # scan a file, JSON output +dlp-scan db postgres://user:pass@host/db # scan PostgreSQL +dlp-scan db sqlite:///path/to/local.db # scan SQLite +dlp-scan network capture.pcap # scan network traffic +dlp-scan file ./data -f sarif -o results.sarif # SARIF for CI/CD +dlp-scan report convert results.json -f csv # convert report format +dlp-scan report summary results.json # print summary stats +``` + +### Global Options + +``` +--config, -c Path to YAML config file +--verbose, -v Enable debug logging +--version Show version +``` + +### Output Formats + +| Format | Flag | Use Case | +|--------|------|----------| +| Console | `-f console` | Interactive review with Rich tables | +| JSON | `-f json` | Structured analysis and archival | +| SARIF | `-f sarif` | GitHub code scanning, CI/CD integration | +| CSV | `-f csv` | Compliance team export, spreadsheet import | + +## Stack + +**Language:** Python 3.12+ + +**CLI:** Typer 0.15+ with Rich integration + +**Detection:** Regex + checksum validators + Shannon entropy + context keyword scoring + +**File Formats:** PyMuPDF, python-docx, openpyxl, xlrd, defusedxml, lxml, pyarrow, fastavro, extract-msg + +**Databases:** asyncpg (PostgreSQL), aiomysql (MySQL), pymongo async (MongoDB), aiosqlite (SQLite) + +**Network:** dpkt (PCAP parsing), TCP reassembly, DPI protocol identification, DNS exfiltration heuristics + +**Config:** Pydantic 2.10+ models with YAML config loading (ruamel.yaml) + +**Quality:** ruff, mypy (strict), yapf, pytest + hypothesis, structlog + +## Configuration + +Copy `.dlp-scanner.yml` to your project root and customize. Key settings: + +```yaml +detection: + min_confidence: 0.20 # minimum score to report + enable_rules: ["*"] # glob patterns for rule IDs + allowlists: + values: ["123-45-6789"] # suppress known test values + +output: + format: "console" # console, json, sarif, csv + redaction_style: "partial" # partial, full, none +``` + +## Learn + +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | DLP theory and real-world breaches | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | + +## License + +[AGPLv3](https://www.gnu.org/licenses/agpl-3.0) diff --git a/third_party/community/dlp-scanner/pyproject.toml b/third_party/community/dlp-scanner/pyproject.toml new file mode 100644 index 0000000..175d799 --- /dev/null +++ b/third_party/community/dlp-scanner/pyproject.toml @@ -0,0 +1,181 @@ +# ©AngelaMos | 2026 +# pyproject.toml + +[project] +name = "dlp-scanner" +version = "0.1.0" +description = "Data Loss Prevention scanner for files, databases, and network traffic" +requires-python = ">=3.12" +dependencies = [ + "typer>=0.15.0", + "rich>=14.0.0", + "structlog>=25.0.0", + "pydantic>=2.10.0", + "orjson>=3.10.0", + "ruamel.yaml>=0.18.0", + "pymupdf>=1.25.0", + "python-docx>=1.1.0", + "openpyxl>=3.1.0", + "xlrd>=2.0.0", + "defusedxml>=0.7.0", + "lxml>=6.1.0", + "pyarrow>=16.0.0", + "fastavro>=1.9.0", + "extract-msg>=0.50.0", + "asyncpg>=0.30.0", + "aiomysql>=0.2.0", + "pymongo>=4.10.0", + "aiosqlite>=0.21.0", + "dpkt>=1.9.0", +] + +[project.scripts] +dlp-scan = "dlp_scanner.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/dlp_scanner"] + +[dependency-groups] +dev = [ + "ruff>=0.11.0", + "mypy>=1.15.0", + "yapf>=0.43.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=6.0.0", + "hypothesis>=6.130.0", +] + +[tool.ruff] +line-length = 75 +indent-width = 4 +target-version = "py312" +src = ["src"] + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "B", + "S", + "C90", + "N", + "UP", + "SIM", + "PTH", + "PERF", + "RUF", + "PL", + "TRY", + "LOG", +] +ignore = [ + "S101", + "S112", + "TRY003", + "PLR2004", + "PLR0913", + "PLR0911", + "PLC0415", + "PTH123", + "PERF401", + "E501", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "PLR2004", "S104", "S105", "S106"] +"src/dlp_scanner/detectors/rules/**/*.py" = ["E501"] +"src/dlp_scanner/scanners/db_scanner.py" = ["S608"] +"src/dlp_scanner/network/protocols.py" = ["S110"] +"src/dlp_scanner/extractors/structured.py" = ["N817"] + +[tool.ruff.lint.mccabe] +max-complexity = 12 + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_configs = true +show_error_codes = true +show_column_numbers = true +pretty = true +mypy_path = "src" + +[[tool.mypy.overrides]] +module = [ + "dpkt.*", + "extract_msg.*", + "fastavro.*", + "xlrd.*", + "docx.*", + "openpyxl.*", + "fitz.*", + "defusedxml.*", + "lxml.*", + "aiomysql.*", + "pymongo.*", + "asyncpg.*", + "aiosqlite.*", + "pyarrow.*", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "dlp_scanner.extractors.email", + "dlp_scanner.extractors.office", + "dlp_scanner.extractors.structured", + "dlp_scanner.scanners.db_scanner", + "dlp_scanner.scanners.network_scanner", + "dlp_scanner.network.pcap", +] +disallow_any_expr = false +warn_return_any = false +disable_error_code = ["attr-defined", "unused-coroutine", "no-untyped-call", "import-untyped"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = [ + "--strict-markers", + "--tb=short", +] +markers = [ + "unit: fast unit tests with no I/O", + "integration: tests requiring real file system or DB", + "slow: long-running tests", +] + +[tool.coverage.run] +source = ["src"] +branch = true +omit = [ + "*/tests/*", + "src/dlp_scanner/extractors/pdf.py", + "src/dlp_scanner/extractors/office.py", + "src/dlp_scanner/extractors/archive.py", + "src/dlp_scanner/extractors/email.py", + "src/dlp_scanner/network/pcap.py", + "src/dlp_scanner/scanners/network_scanner.py", + "src/dlp_scanner/scanners/db_scanner.py", + "src/dlp_scanner/reporters/base.py", + "src/dlp_scanner/scanners/base.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "\\.\\.\\.", +] diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/__init__.py new file mode 100644 index 0000000..4e0a5ce --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/__init__.py @@ -0,0 +1,6 @@ +""" +©AngelaMos | 2026 +__init__.py +""" + +__version__ = "0.1.0" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/cli.py b/third_party/community/dlp-scanner/src/dlp_scanner/cli.py new file mode 100644 index 0000000..1cff363 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/cli.py @@ -0,0 +1,73 @@ +""" +©AngelaMos | 2026 +cli.py +""" + + +from typing import Annotated + +import typer + +from dlp_scanner import __version__ +from dlp_scanner.commands.report import report_app +from dlp_scanner.commands.scan import register + + +app = typer.Typer( + name = "dlp-scan", + help = ( + "Data Loss Prevention scanner for files, " + "databases, and network traffic" + ), + no_args_is_help = True, +) + + +def _version_callback(value: bool) -> None: + """ + Print version and exit + """ + if value: + typer.echo(f"dlp-scanner {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + ctx: typer.Context, + config: Annotated[ + str, + typer.Option( + "--config", + "-c", + help = "Path to config YAML file", + ), + ] = "", + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help = "Enable verbose output", + ), + ] = False, + version: Annotated[ + bool, + typer.Option( + "--version", + callback = _version_callback, + is_eager = True, + help = "Show version and exit", + ), + ] = False, +) -> None: + """ + DLP Scanner - detect sensitive data across files, databases, and network captures + """ + ctx.ensure_object(dict) + ctx.obj["config_path"] = config + ctx.obj["verbose"] = verbose + + +register(app) +app.add_typer(report_app, name = "report") diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/commands/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/commands/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/commands/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/commands/report.py b/third_party/community/dlp-scanner/src/dlp_scanner/commands/report.py new file mode 100644 index 0000000..bae4109 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/commands/report.py @@ -0,0 +1,187 @@ +""" +©AngelaMos | 2026 +report.py +""" + + +from datetime import datetime +from pathlib import Path +from typing import Annotated, Any + +import orjson +import typer + +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) + + +report_app = typer.Typer(help = "Report conversion and summary") + +VALID_FORMATS: frozenset[str] = frozenset( + { + "console", + "json", + "sarif", + "csv", + } +) + + +@report_app.command("convert") +def convert( + input_file: Annotated[ + str, + typer.Argument(help = "JSON scan results file"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = "Target format (json, sarif, csv)", + ), + ] = "sarif", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = "Write converted report to file", + ), + ] = "", +) -> None: + """ + Convert a JSON scan result to another format + """ + from dlp_scanner.config import ScanConfig + from dlp_scanner.engine import ScanEngine + + if output_format not in VALID_FORMATS: + typer.echo( + f"Invalid format: {output_format}", + err = True, + ) + raise typer.Exit(code = 1) + + path = Path(input_file) + if not path.exists(): + typer.echo( + f"File not found: {input_file}", + err = True, + ) + raise typer.Exit(code = 1) + + raw = path.read_bytes() + data = orjson.loads(raw) + result = _rebuild_result(data) + + config = ScanConfig() + engine = ScanEngine(config) + + output = engine.generate_report(result, output_format) + + if output_file: + Path(output_file).write_text(output) + typer.echo(f"Converted report written to " + f"{output_file}") + else: + typer.echo(output) + + +@report_app.command("summary") +def summary( + input_file: Annotated[ + str, + typer.Argument(help = "JSON scan results file"), + ], +) -> None: + """ + Print summary statistics from a scan result file + """ + path = Path(input_file) + if not path.exists(): + typer.echo( + f"File not found: {input_file}", + err = True, + ) + raise typer.Exit(code = 1) + + raw = path.read_bytes() + data = orjson.loads(raw) + result = _rebuild_result(data) + + from dlp_scanner.reporters.console import ( + ConsoleReporter, + ) + + reporter = ConsoleReporter() + reporter.display(result) + + +def _rebuild_result( + data: dict[str, + Any], +) -> ScanResult: + """ + Rebuild a ScanResult from deserialized JSON report + """ + meta = data.get("scan_metadata", {}) + result = ScanResult( + targets_scanned = meta.get("targets_scanned", + 0), + ) + result.scan_id = meta.get("scan_id", result.scan_id) + + if meta.get("scan_completed_at"): + result.scan_completed_at = ( + datetime.fromisoformat(meta["scan_completed_at"]) + ) + + result.errors = meta.get("errors", []) + + for f_data in data.get("findings", []): + loc_data = f_data.get("location", {}) + location = Location( + source_type = loc_data.get("source_type", + "file"), + uri = loc_data.get("uri", + ""), + line = loc_data.get("line"), + column = loc_data.get("column"), + table_name = loc_data.get("table_name"), + column_name = loc_data.get("column_name"), + ) + + finding = Finding( + rule_id = f_data.get("rule_id", + ""), + rule_name = f_data.get("rule_name", + ""), + severity = f_data.get("severity", + "low"), + confidence = f_data.get("confidence", + 0.0), + location = location, + redacted_snippet = f_data.get("redacted_snippet", + ""), + compliance_frameworks = f_data.get( + "compliance_frameworks", + [] + ), + remediation = f_data.get("remediation", + ""), + ) + + if f_data.get("finding_id"): + finding.finding_id = f_data["finding_id"] + if f_data.get("detected_at"): + finding.detected_at = ( + datetime.fromisoformat(f_data["detected_at"]) + ) + + result.findings.append(finding) + + return result diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/commands/scan.py b/third_party/community/dlp-scanner/src/dlp_scanner/commands/scan.py new file mode 100644 index 0000000..cd3fd36 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/commands/scan.py @@ -0,0 +1,195 @@ +""" +©AngelaMos | 2026 +scan.py +""" + + +from pathlib import Path +from typing import Annotated, Any + +import typer + + +FORMAT_HELP: str = ("Output format (console, json, sarif, csv)") +OUTPUT_HELP: str = "Write report to file" +VALID_FORMATS: frozenset[str] = frozenset( + { + "console", + "json", + "sarif", + "csv", + } +) + + +def scan_file( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help = "File or directory path"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = FORMAT_HELP, + ), + ] = "console", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = OUTPUT_HELP, + ), + ] = "", +) -> None: + """ + Scan files and directories for sensitive data + """ + _run_scan(ctx, "file", target, output_format, output_file) + + +def scan_db( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help = "Database connection URI"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = FORMAT_HELP, + ), + ] = "console", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = OUTPUT_HELP, + ), + ] = "", +) -> None: + """ + Scan database tables for sensitive data + """ + _run_scan(ctx, "db", target, output_format, output_file) + + +def scan_network( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help = "PCAP file path"), + ], + output_format: Annotated[ + str, + typer.Option( + "--format", + "-f", + help = FORMAT_HELP, + ), + ] = "console", + output_file: Annotated[ + str, + typer.Option( + "--output", + "-o", + help = OUTPUT_HELP, + ), + ] = "", +) -> None: + """ + Scan network capture files for sensitive data in transit + """ + _run_scan( + ctx, + "network", + target, + output_format, + output_file, + ) + + +def register(app: typer.Typer) -> None: + """ + Register scan commands on the root app + """ + app.command("file")(scan_file) + app.command("db")(scan_db) + app.command("network")(scan_network) + + +def _run_scan( + ctx: typer.Context, + scan_type: str, + target: str, + output_format: str, + output_file: str, +) -> None: + """ + Shared scan execution logic + """ + from dlp_scanner.config import ( + ScanConfig, + load_config, + ) + from dlp_scanner.engine import ScanEngine + from dlp_scanner.log import configure_logging + + if output_format not in VALID_FORMATS: + typer.echo( + f"Invalid format: {output_format}. " + f"Choose from: " + f"{', '.join(sorted(VALID_FORMATS))}", + err = True, + ) + raise typer.Exit(code = 1) + + obj: dict[str, Any] = ctx.ensure_object(dict) + config_path: str = obj.get("config_path", "") + verbose: bool = obj.get("verbose", False) + + if verbose: + configure_logging(level = "DEBUG") + elif output_format == "console": + configure_logging(level = "INFO") + else: + configure_logging(level = "WARNING") + + config: ScanConfig + cfg_path = Path(config_path) if config_path else None + if cfg_path and cfg_path.exists(): + config = load_config(cfg_path) + else: + config = ScanConfig() + + config.output.format = output_format + if output_file: + config.output.output_file = output_file + + engine = ScanEngine(config) + + scan_methods = { + "file": engine.scan_files, + "db": engine.scan_database, + "network": engine.scan_network, + } + + result = scan_methods[scan_type](target) + + if output_file: + engine.write_report(result, output_file) + typer.echo(f"Report written to {output_file}") + elif output_format == "console": + engine.display_console(result) + else: + output = engine.generate_report(result) + typer.echo(output) + + if result.errors: + raise typer.Exit(code = 1) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/compliance.py b/third_party/community/dlp-scanner/src/dlp_scanner/compliance.py new file mode 100644 index 0000000..8761f09 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/compliance.py @@ -0,0 +1,245 @@ +""" +©AngelaMos | 2026 +compliance.py +""" + + +from dlp_scanner.constants import ( + SEVERITY_SCORE_THRESHOLDS, + Severity, +) + + +RULE_FRAMEWORK_MAP: dict[str, + list[str]] = { + "PII_SSN": ["HIPAA", + "CCPA", + "GLBA", + "GDPR"], + "PII_EMAIL": ["GDPR", + "CCPA"], + "PII_PHONE": ["GDPR", + "CCPA", + "HIPAA"], + "PII_PHONE_INTL": ["GDPR", + "CCPA"], + "PII_PASSPORT_US": ["GDPR", + "CCPA"], + "PII_PASSPORT_UK": ["GDPR"], + "PII_DRIVERS_LICENSE": ["CCPA", + "HIPAA"], + "PII_DRIVERS_LICENSE_FL": ["CCPA", + "HIPAA"], + "PII_DRIVERS_LICENSE_IL": ["CCPA", + "HIPAA"], + "PII_IPV4": ["GDPR"], + "FIN_CREDIT_CARD_VISA": ["PCI_DSS", + "GLBA"], + "FIN_CREDIT_CARD_MC": ["PCI_DSS", + "GLBA"], + "FIN_CREDIT_CARD_AMEX": ["PCI_DSS", + "GLBA"], + "FIN_CREDIT_CARD_DISC": ["PCI_DSS", + "GLBA"], + "FIN_IBAN": ["GDPR", + "GLBA"], + "FIN_NHS_NUMBER": ["GDPR"], + "CRED_AWS_ACCESS_KEY": [], + "CRED_GITHUB_TOKEN": [], + "CRED_GITHUB_FINE_GRAINED": [], + "CRED_GITHUB_OAUTH": [], + "CRED_GITHUB_APP": [], + "CRED_JWT": [], + "CRED_STRIPE_KEY": [], + "CRED_SLACK_TOKEN": [], + "CRED_GENERIC_API_KEY": [], + "CRED_PRIVATE_KEY": [], + "HEALTH_MEDICAL_RECORD": ["HIPAA"], + "HEALTH_DEA_NUMBER": ["HIPAA"], + "HEALTH_NPI": ["HIPAA"], + "NET_HIGH_ENTROPY": [], + "NET_DNS_EXFIL_LONG_LABEL": [], + "NET_DNS_EXFIL_HIGH_ENTROPY": [], + "NET_DNS_EXFIL_LONG_QNAME": [], + "NET_DNS_EXFIL_TXT_VOLUME": [], + "NET_ENCODED_BASE64": [], + "NET_ENCODED_HEX": [], + } + +RULE_REMEDIATION_MAP: dict[ + str, + str] = { + "PII_SSN": ( + "Remove or encrypt SSNs. Use tokenization " + "for storage. Never store in plaintext." + ), + "PII_EMAIL": ( + "Evaluate if email storage is necessary. " + "Hash or pseudonymize where possible." + ), + "PII_PHONE": ( + "Restrict access to phone number fields. " + "Consider masking in non-production environments." + ), + "PII_PHONE_INTL": ( + "Restrict access to phone number fields. " + "Consider masking in non-production environments." + ), + "PII_PASSPORT_US": ( + "Passport numbers must be encrypted at rest. " + "Limit access to identity verification systems." + ), + "PII_PASSPORT_UK": ( + "Passport numbers must be encrypted at rest. " + "Limit access to identity verification systems." + ), + "PII_IPV4": ( + "Evaluate whether IP address storage is necessary. " + "Anonymize or pseudonymize where possible." + ), + "PII_DRIVERS_LICENSE": ( + "Encrypt driver's license numbers at rest. " + "Restrict access per CCPA/HIPAA requirements." + ), + "PII_DRIVERS_LICENSE_FL": ( + "Encrypt driver's license numbers at rest. " + "Restrict access per CCPA/HIPAA requirements." + ), + "PII_DRIVERS_LICENSE_IL": ( + "Encrypt driver's license numbers at rest. " + "Restrict access per CCPA/HIPAA requirements." + ), + "FIN_CREDIT_CARD_VISA": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_CREDIT_CARD_MC": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_CREDIT_CARD_AMEX": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_CREDIT_CARD_DISC": ( + "PCI-DSS requires PANs to be encrypted, hashed, " + "or truncated. Never store in plaintext." + ), + "FIN_IBAN": ( + "Encrypt IBAN numbers at rest. " + "Restrict access to financial systems." + ), + "FIN_NHS_NUMBER": ( + "NHS numbers are personal data under UK GDPR. " + "Encrypt at rest and restrict access." + ), + "CRED_AWS_ACCESS_KEY": ( + "Rotate exposed AWS credentials immediately. " + "Use IAM roles or Vault dynamic secrets." + ), + "CRED_GITHUB_TOKEN": ( + "Revoke the token at github.com/settings/tokens. " + "Use environment variables, not hardcoded values." + ), + "CRED_GITHUB_FINE_GRAINED": ( + "Revoke the token at github.com/settings/tokens. " + "Use environment variables, not hardcoded values." + ), + "CRED_GITHUB_OAUTH": ( + "Revoke the OAuth token in GitHub settings. " + "Store tokens in a secrets manager." + ), + "CRED_GITHUB_APP": ( + "Revoke the app installation token. " + "Rotate app private keys if compromised." + ), + "CRED_JWT": ( + "Rotate the signing key if the JWT secret is " + "exposed. Never hardcode tokens in source." + ), + "CRED_STRIPE_KEY": ( + "Rotate the Stripe key at dashboard.stripe.com. " + "Use restricted keys with minimal permissions." + ), + "CRED_SLACK_TOKEN": ( + "Revoke the Slack token in workspace settings. " + "Use environment variables for bot tokens." + ), + "CRED_GENERIC_API_KEY": ( + "Rotate the exposed API key immediately. " + "Store secrets in a vault, not in source code." + ), + "CRED_PRIVATE_KEY": ( + "Rotate the compromised key pair. Store private " + "keys in a secrets manager, never in source code." + ), + "HEALTH_MEDICAL_RECORD": ( + "MRNs are PHI under HIPAA. Encrypt at rest and " + "apply minimum necessary access controls." + ), + "HEALTH_DEA_NUMBER": ( + "DEA numbers identify prescribers of controlled " + "substances. Encrypt and restrict access per HIPAA." + ), + "HEALTH_NPI": ( + "NPIs are provider identifiers under HIPAA. " + "Restrict access to authorized systems only." + ), + "NET_HIGH_ENTROPY": ( + "High entropy data may indicate encrypted or " + "compressed secrets in transit. Investigate the flow." + ), + "NET_DNS_EXFIL_LONG_LABEL": ( + "Unusually long DNS labels may indicate DNS " + "tunneling. Investigate the queried domain." + ), + "NET_DNS_EXFIL_HIGH_ENTROPY": ( + "High-entropy DNS subdomains suggest data " + "exfiltration via DNS tunneling. Block the domain." + ), + "NET_DNS_EXFIL_LONG_QNAME": ( + "Excessively long DNS QNAMEs may carry encoded " + "data. Investigate and block suspicious domains." + ), + "NET_DNS_EXFIL_TXT_VOLUME": ( + "High ratio of TXT queries to a domain suggests " + "DNS-based command and control. Investigate traffic." + ), + "NET_ENCODED_BASE64": ( + "Base64-encoded payloads in network traffic may " + "carry exfiltrated data. Inspect the content." + ), + "NET_ENCODED_HEX": ( + "Hex-encoded payloads in network traffic may " + "indicate data exfiltration. Inspect the content." + ), + } + +DEFAULT_REMEDIATION: str = ( + "Review and restrict access to this data. " + "Apply encryption at rest if required by policy." +) + + +def get_frameworks_for_rule(rule_id: str) -> list[str]: + """ + Return applicable compliance frameworks for a rule + """ + return RULE_FRAMEWORK_MAP.get(rule_id, []) + + +def get_remediation_for_rule(rule_id: str) -> str: + """ + Return remediation guidance for a rule + """ + return RULE_REMEDIATION_MAP.get(rule_id, DEFAULT_REMEDIATION) + + +def score_to_severity(score: float) -> Severity: + """ + Convert a confidence score to a severity level + """ + for threshold, severity in SEVERITY_SCORE_THRESHOLDS: + if score >= threshold: + return severity + return "low" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/config.py b/third_party/community/dlp-scanner/src/dlp_scanner/config.py new file mode 100644 index 0000000..7381399 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/config.py @@ -0,0 +1,173 @@ +""" +©AngelaMos | 2026 +config.py +""" + + +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field +from ruamel.yaml import YAML + +from dlp_scanner.constants import ( + DEFAULT_CONTEXT_WINDOW_TOKENS, + DEFAULT_DB_MAX_ROWS, + DEFAULT_DB_SAMPLE_PERCENTAGE, + DEFAULT_DB_TIMEOUT_SECONDS, + DEFAULT_DNS_ENTROPY_THRESHOLD, + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_EXCLUDE_PATTERNS, + DEFAULT_MAX_FILE_SIZE_MB, + DEFAULT_MIN_CONFIDENCE, + SCANNABLE_EXTENSIONS, + OutputFormat, + RedactionStyle, + Severity, +) + + +class FileScanConfig(BaseModel): + """ + Configuration for file scanning + """ + max_file_size_mb: int = DEFAULT_MAX_FILE_SIZE_MB + recursive: bool = True + exclude_patterns: list[str] = Field( + default_factory = lambda: list(DEFAULT_EXCLUDE_PATTERNS) + ) + include_extensions: list[str] = Field( + default_factory = lambda: sorted(SCANNABLE_EXTENSIONS) + ) + + +class DatabaseScanConfig(BaseModel): + """ + Configuration for database scanning + """ + sample_percentage: int = DEFAULT_DB_SAMPLE_PERCENTAGE + max_rows_per_table: int = DEFAULT_DB_MAX_ROWS + timeout_seconds: int = DEFAULT_DB_TIMEOUT_SECONDS + exclude_tables: list[str] = Field(default_factory = list) + include_tables: list[str] = Field(default_factory = list) + + +class NetworkScanConfig(BaseModel): + """ + Configuration for network traffic scanning + """ + bpf_filter: str = "" + entropy_threshold: float = DEFAULT_ENTROPY_THRESHOLD + dns_label_entropy_threshold: float = (DEFAULT_DNS_ENTROPY_THRESHOLD) + max_packets: int = 0 + + +class AllowlistConfig(BaseModel): + """ + Allowlists for suppressing known false positives + """ + values: list[str] = Field(default_factory = list) + domains: list[str] = Field(default_factory = list) + file_patterns: list[str] = Field(default_factory = list) + + +class DetectionConfig(BaseModel): + """ + Configuration for detection behavior + """ + min_confidence: float = DEFAULT_MIN_CONFIDENCE + severity_threshold: Severity = "low" + context_window_tokens: int = (DEFAULT_CONTEXT_WINDOW_TOKENS) + enable_rules: list[str] = Field(default_factory = lambda: ["*"]) + disable_rules: list[str] = Field(default_factory = list) + allowlists: AllowlistConfig = Field(default_factory = AllowlistConfig) + + +class ComplianceConfig(BaseModel): + """ + Configuration for compliance framework mapping + """ + frameworks: list[str] = Field( + default_factory = lambda: [ + "HIPAA", + "PCI_DSS", + "GDPR", + "CCPA",] + ) + + +class OutputConfig(BaseModel): + """ + Configuration for output and reporting + """ + format: OutputFormat = "console" + output_file: str = "" + redaction_style: RedactionStyle = "partial" + verbose: bool = False + color: bool = True + + +class LoggingConfig(BaseModel): + """ + Configuration for logging behavior + """ + level: str = "INFO" + json_output: bool = False + log_file: str = "" + + +class ScanConfig(BaseModel): + """ + Root configuration model for the DLP scanner + """ + file: FileScanConfig = Field(default_factory = FileScanConfig) + database: DatabaseScanConfig = Field( + default_factory = DatabaseScanConfig + ) + network: NetworkScanConfig = Field(default_factory = NetworkScanConfig) + detection: DetectionConfig = Field(default_factory = DetectionConfig) + compliance: ComplianceConfig = Field( + default_factory = ComplianceConfig + ) + output: OutputConfig = Field(default_factory = OutputConfig) + logging: LoggingConfig = Field(default_factory = LoggingConfig) + + +def load_config(path: Path | None = None) -> ScanConfig: + """ + Load configuration from a YAML file or return defaults + """ + if path is None: + candidates = [ + Path(".dlp-scanner.yml"), + Path(".dlp-scanner.yaml"), + Path.home() / ".dlp-scanner.yml", + ] + for candidate in candidates: + if candidate.exists(): + path = candidate + break + + if path is None or not path.exists(): + return ScanConfig() + + yaml = YAML(typ = "safe") + raw: dict[str, Any] = yaml.load(path) or {} + + scan_section = raw.get("scan", {}) + return ScanConfig( + file = FileScanConfig(**scan_section.get("file", + {})), + database = DatabaseScanConfig(**scan_section.get("database", + {})), + network = NetworkScanConfig(**scan_section.get("network", + {})), + detection = DetectionConfig(**raw.get("detection", + {})), + compliance = ComplianceConfig(**raw.get("compliance", + {})), + output = OutputConfig(**raw.get("output", + {})), + logging = LoggingConfig(**raw.get("logging", + {})), + ) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/constants.py b/third_party/community/dlp-scanner/src/dlp_scanner/constants.py new file mode 100644 index 0000000..41bf385 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/constants.py @@ -0,0 +1,171 @@ +""" +©AngelaMos | 2026 +constants.py +""" + + +from typing import Literal + + +Severity = Literal["critical", "high", "medium", "low"] +OutputFormat = Literal["console", "json", "sarif", "csv"] +RedactionStyle = Literal["partial", "full", "none"] +ScanTargetType = Literal["file", "database", "network"] + +SEVERITY_ORDER: dict[Severity, + int] = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + } + +SEVERITY_SCORE_THRESHOLDS: list[tuple[float, + Severity]] = [ + (0.85, + "critical"), + (0.65, + "high"), + (0.40, + "medium"), + (0.20, + "low"), + ] + +COMPLIANCE_FRAMEWORKS: list[str] = [ + "HIPAA", + "PCI_DSS", + "GDPR", + "CCPA", + "SOX", + "GLBA", + "FERPA", +] + +DEFAULT_CONTEXT_WINDOW_TOKENS: int = 10 +DEFAULT_MIN_CONFIDENCE: float = 0.20 +DEFAULT_ENTROPY_THRESHOLD: float = 7.2 +DEFAULT_DNS_ENTROPY_THRESHOLD: float = 4.0 +DEFAULT_MAX_FILE_SIZE_MB: int = 100 +DEFAULT_DB_SAMPLE_PERCENTAGE: int = 5 +DEFAULT_DB_MAX_ROWS: int = 10000 +DEFAULT_DB_TIMEOUT_SECONDS: int = 30 + +CHECKSUM_BOOST: float = 0.30 +CONTEXT_BOOST_MAX: float = 0.35 +CONTEXT_BOOST_MIN_FLOOR: float = 0.40 +COOCCURRENCE_BOOST: float = 0.15 + +KNOWN_TEST_VALUES: frozenset[str] = frozenset( + { + "123-45-6789", + "000-00-0000", + "078-05-1120", + "219-09-9999", + "4111111111111111", + "5500000000000004", + "340000000000009", + "6011000000000004", + "test@example.com", + "user@test.com", + } +) + +DEFAULT_EXCLUDE_PATTERNS: list[str] = [ + "*.pyc", + "__pycache__", + ".git", + "node_modules", + ".venv", + ".env", + "*.egg-info", +] + +SCANNABLE_EXTENSIONS: frozenset[str] = frozenset( + { + ".pdf", + ".docx", + ".xlsx", + ".xls", + ".csv", + ".json", + ".xml", + ".yaml", + ".yml", + ".txt", + ".log", + ".cfg", + ".ini", + ".toml", + ".conf", + ".eml", + ".msg", + ".parquet", + ".avro", + ".md", + ".rst", + ".html", + ".htm", + ".tsv", + ".py", + ".js", + ".ts", + ".go", + ".rb", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".rs", + ".env", + ".sh", + ".bat", + ".ps1", + ".tf", + ".hcl", + } +) + +TEXT_DB_COLUMN_TYPES_PG: frozenset[str] = frozenset( + { + "text", + "character varying", + "character", + "json", + "jsonb", + "varchar", + } +) + +TEXT_DB_COLUMN_TYPES_MYSQL: frozenset[str] = frozenset( + { + "varchar", + "text", + "mediumtext", + "longtext", + "json", + "char", + "tinytext", + } +) + +SEVERITY_COLORS: dict[Severity, + str] = { + "critical": "bold red", + "high": "red", + "medium": "yellow", + "low": "green", + } + +SARIF_SEVERITY_MAP: dict[Severity, + str] = { + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + } + +MAX_ARCHIVE_DEPTH: int = 3 +MAX_ARCHIVE_MEMBER_SIZE_MB: int = 50 +ZIP_BOMB_RATIO_THRESHOLD: int = 100 diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/base.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/base.py new file mode 100644 index 0000000..d843843 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/base.py @@ -0,0 +1,51 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +import re +from dataclasses import dataclass, field +from typing import Protocol +from collections.abc import Callable + + +@dataclass(frozen = True, slots = True) +class DetectionRule: + """ + A single detection rule combining regex, validation, and context + """ + rule_id: str + rule_name: str + pattern: re.Pattern[str] + base_score: float + context_keywords: list[str] = field(default_factory = list) + validator: Callable[[str], bool] | None = None + compliance_frameworks: list[str] = field(default_factory = list) + severity_override: str | None = None + + +@dataclass(frozen = True, slots = True) +class DetectorMatch: + """ + A raw match from a detector before scoring + """ + rule_id: str + rule_name: str + start: int + end: int + matched_text: str + score: float + context_keywords: list[str] = field(default_factory = list) + compliance_frameworks: list[str] = field(default_factory = list) + + +class Detector(Protocol): + """ + Protocol for all detection strategies + """ + def detect(self, text: str) -> list[DetectorMatch]: + """ + Scan text and return all matches + """ + ... diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/context.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/context.py new file mode 100644 index 0000000..7330e21 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/context.py @@ -0,0 +1,142 @@ +""" +©AngelaMos | 2026 +context.py +""" + + +from dlp_scanner.constants import ( + CONTEXT_BOOST_MAX, + CONTEXT_BOOST_MIN_FLOOR, + COOCCURRENCE_BOOST, + DEFAULT_CONTEXT_WINDOW_TOKENS, +) +from dlp_scanner.detectors.base import DetectorMatch + + +def apply_context_boost( + text: str, + matches: list[DetectorMatch], + window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS, +) -> list[DetectorMatch]: + """ + Boost match scores based on nearby context keywords + """ + if not matches: + return matches + + tokens = text.lower().split() + boosted: list[DetectorMatch] = [] + + for match in matches: + if not match.context_keywords: + boosted.append(match) + continue + + char_to_token = _char_offset_to_token_index(text, match.start) + window_start = max(0, char_to_token - window_tokens) + window_end = min(len(tokens), char_to_token + window_tokens) + window_text = " ".join(tokens[window_start : window_end]) + + boost = _compute_keyword_boost( + window_text, + match.context_keywords, + window_tokens, + ) + + new_score = min(1.0, match.score + boost) + if boost > 0 and new_score < CONTEXT_BOOST_MIN_FLOOR: + new_score = CONTEXT_BOOST_MIN_FLOOR + + boosted.append( + DetectorMatch( + rule_id = match.rule_id, + rule_name = match.rule_name, + start = match.start, + end = match.end, + matched_text = match.matched_text, + score = new_score, + context_keywords = match.context_keywords, + compliance_frameworks = match.compliance_frameworks, + ) + ) + + return _apply_cooccurrence_boost(boosted) + + +def _compute_keyword_boost( + window_text: str, + keywords: list[str], + window_tokens: int, +) -> float: + """ + Compute score boost based on keyword proximity + """ + best_boost = 0.0 + + for keyword in keywords: + kw_lower = keyword.lower() + pos = window_text.find(kw_lower) + if pos < 0: + continue + + center = len(window_text) // 2 + distance = abs(pos - center) + max_distance = window_tokens * 5 + + proximity_factor = 1.0 - min(1.0, distance / max(1, max_distance)) + boost = CONTEXT_BOOST_MAX * proximity_factor + best_boost = max(best_boost, boost) + + return best_boost + + +def _apply_cooccurrence_boost( + matches: list[DetectorMatch], +) -> list[DetectorMatch]: + """ + Boost scores when multiple PII types appear near each other + """ + if len(matches) < 2: + return matches + + proximity_threshold = 500 + boosted: list[DetectorMatch] = [] + + for i, match in enumerate(matches): + has_neighbor = False + for j, other in enumerate(matches): + if i == j: + continue + if other.rule_id == match.rule_id: + continue + distance = abs(match.start - other.start) + if distance < proximity_threshold: + has_neighbor = True + break + + if has_neighbor: + new_score = min(1.0, match.score + COOCCURRENCE_BOOST) + boosted.append( + DetectorMatch( + rule_id = match.rule_id, + rule_name = match.rule_name, + start = match.start, + end = match.end, + matched_text = match.matched_text, + score = new_score, + context_keywords = match.context_keywords, + compliance_frameworks = match.compliance_frameworks, + ) + ) + else: + boosted.append(match) + + return boosted + + +def _char_offset_to_token_index(text: str, char_offset: int) -> int: + """ + Convert a character offset to an approximate token index + """ + prefix = text[: char_offset] + return len(prefix.split()) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/entropy.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/entropy.py new file mode 100644 index 0000000..5ed0385 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/entropy.py @@ -0,0 +1,126 @@ +""" +©AngelaMos | 2026 +entropy.py +""" + + +import math +from collections import Counter + +from dlp_scanner.constants import DEFAULT_ENTROPY_THRESHOLD +from dlp_scanner.detectors.base import DetectorMatch + + +WINDOW_SIZE: int = 256 +WINDOW_STEP: int = 128 + + +def shannon_entropy(data: bytes) -> float: + """ + Calculate Shannon entropy in bits per byte + """ + if not data: + return 0.0 + + counts = Counter(data) + total = len(data) + return -sum( + (c / total) * math.log2(c / total) for c in counts.values() + ) + + +def shannon_entropy_str(text: str) -> float: + """ + Calculate Shannon entropy for a string + """ + return shannon_entropy(text.encode("utf-8")) + + +def detect_high_entropy_regions( + data: bytes, + threshold: float = DEFAULT_ENTROPY_THRESHOLD, + window_size: int = WINDOW_SIZE, + step: int = WINDOW_STEP, +) -> list[tuple[int, + int, + float]]: + """ + Find regions of high entropy using a sliding window + + Returns list of (start_offset, end_offset, entropy_value) + """ + if len(data) < window_size: + h = shannon_entropy(data) + if h >= threshold: + return [(0, len(data), h)] + return [] + + regions: list[tuple[int, int, float]] = [] + i = 0 + + while i + window_size <= len(data): + window = data[i : i + window_size] + h = shannon_entropy(window) + + if h >= threshold: + end = i + window_size + while end + step <= len(data): + next_window = data[end - window_size + step : end + step] + next_h = shannon_entropy(next_window) + if next_h < threshold: + break + h = max(h, next_h) + end += step + + regions.append((i, end, h)) + i = end + else: + i += step + + return regions + + +class EntropyDetector: + """ + Detects high-entropy data that may indicate encrypted + or compressed content + """ + def __init__( + self, + threshold: float = DEFAULT_ENTROPY_THRESHOLD, + ) -> None: + self._threshold = threshold + + def detect(self, text: str) -> list[DetectorMatch]: + """ + Scan text for high-entropy regions + """ + data = text.encode("utf-8") + regions = detect_high_entropy_regions( + data, + threshold = self._threshold, + ) + + matches: list[DetectorMatch] = [] + for start, end, entropy_val in regions: + score = min( + 1.0, + (entropy_val - self._threshold) / + (8.0 - self._threshold) * 0.5 + 0.5, + ) + + matches.append( + DetectorMatch( + rule_id = "NET_HIGH_ENTROPY", + rule_name = "High Entropy Data", + start = start, + end = end, + matched_text = + f"[{end - start} bytes, H={entropy_val:.2f}]", + score = score, + context_keywords = [], + compliance_frameworks = [], + ) + ) + + return matches diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/pattern.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/pattern.py new file mode 100644 index 0000000..060c771 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/pattern.py @@ -0,0 +1,68 @@ +""" +©AngelaMos | 2026 +pattern.py +""" + + +from dlp_scanner.constants import CHECKSUM_BOOST, KNOWN_TEST_VALUES +from dlp_scanner.detectors.base import ( + DetectionRule, + DetectorMatch, +) + + +class PatternDetector: + """ + Detects sensitive data using regex patterns with optional + checksum validation + """ + def __init__( + self, + rules: list[DetectionRule], + allowlist_values: frozenset[str] | None = None, + ) -> None: + self._rules = rules + self._allowlist = allowlist_values or KNOWN_TEST_VALUES + + def detect(self, text: str) -> list[DetectorMatch]: + """ + Scan text against all registered patterns + """ + matches: list[DetectorMatch] = [] + + for rule in self._rules: + for m in rule.pattern.finditer(text): + matched_text = m.group() + + if self._is_allowlisted(matched_text): + continue + + score = rule.base_score + + if rule.validator is not None: + if rule.validator(matched_text): + score = min(1.0, score + CHECKSUM_BOOST) + else: + continue + + matches.append( + DetectorMatch( + rule_id = rule.rule_id, + rule_name = rule.rule_name, + start = m.start(), + end = m.end(), + matched_text = matched_text, + score = score, + context_keywords = rule.context_keywords, + compliance_frameworks = rule.compliance_frameworks, + ) + ) + + return matches + + def _is_allowlisted(self, value: str) -> bool: + """ + Check if a matched value is in the allowlist + """ + normalized = value.strip() + return normalized in self._allowlist diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/registry.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/registry.py new file mode 100644 index 0000000..635e8a1 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/registry.py @@ -0,0 +1,113 @@ +""" +©AngelaMos | 2026 +registry.py +""" + + +import fnmatch + +from dlp_scanner.detectors.base import ( + DetectionRule, + DetectorMatch, +) +from dlp_scanner.detectors.context import ( + apply_context_boost, +) +from dlp_scanner.detectors.entropy import EntropyDetector +from dlp_scanner.detectors.pattern import PatternDetector +from dlp_scanner.detectors.rules.credentials import ( + CREDENTIAL_RULES, +) +from dlp_scanner.detectors.rules.financial import ( + FINANCIAL_RULES, +) +from dlp_scanner.detectors.rules.health import HEALTH_RULES +from dlp_scanner.detectors.rules.pii import PII_RULES + + +ALL_RULES: list[DetectionRule] = [ + *PII_RULES, + *FINANCIAL_RULES, + *CREDENTIAL_RULES, + *HEALTH_RULES, +] + + +class DetectorRegistry: + """ + Central registry that loads, filters, and runs all detectors + """ + def __init__( + self, + *, + enable_patterns: list[str] | None = None, + disable_patterns: list[str] | None = None, + allowlist_values: frozenset[str] | None = None, + context_window_tokens: int = 10, + entropy_threshold: float = 7.2, + enable_entropy: bool = True, + ) -> None: + active_rules = _filter_rules( + ALL_RULES, + enable_patterns or ["*"], + disable_patterns or [], + ) + + self._pattern_detector = PatternDetector( + rules = active_rules, + allowlist_values = allowlist_values, + ) + self._entropy_detector = ( + EntropyDetector(threshold = entropy_threshold) + if enable_entropy else None + ) + self._context_window = context_window_tokens + + def detect(self, text: str) -> list[DetectorMatch]: + """ + Run all detectors against text and return scored matches + """ + matches = self._pattern_detector.detect(text) + matches = apply_context_boost( + text, + matches, + window_tokens = self._context_window, + ) + + if self._entropy_detector is not None: + entropy_matches = (self._entropy_detector.detect(text)) + matches.extend(entropy_matches) + + return matches + + @property + def rule_count(self) -> int: + """ + Return the number of active pattern rules + """ + return len(self._pattern_detector._rules) + + +def _filter_rules( + rules: list[DetectionRule], + enable_patterns: list[str], + disable_patterns: list[str], +) -> list[DetectionRule]: + """ + Filter rules by enable/disable glob patterns + """ + filtered: list[DetectionRule] = [] + + for rule in rules: + enabled = any( + fnmatch.fnmatch(rule.rule_id, + pat) for pat in enable_patterns + ) + disabled = any( + fnmatch.fnmatch(rule.rule_id, + pat) for pat in disable_patterns + ) + if enabled and not disabled: + filtered.append(rule) + + return filtered diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py new file mode 100644 index 0000000..afffee1 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/credentials.py @@ -0,0 +1,157 @@ +""" +©AngelaMos | 2026 +credentials.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +AWS_ACCESS_KEY_PATTERN = re.compile(r"\b((?:AKIA|ASIA)[0-9A-Z]{16})\b") + +GITHUB_CLASSIC_PAT_PATTERN = re.compile(r"\bghp_[a-zA-Z0-9]{36}\b") + +GITHUB_FINE_GRAINED_PATTERN = re.compile( + r"\bgithub_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}\b" +) + +GITHUB_OAUTH_PATTERN = re.compile(r"\bgho_[a-zA-Z0-9]{36}\b") + +GITHUB_APP_PATTERN = re.compile(r"\bghs_[a-zA-Z0-9]{36}\b") + +JWT_PATTERN = re.compile( + r"\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b" +) + +STRIPE_KEY_PATTERN = re.compile( + r"\b(?:sk|pk)_(?:test|live)_[a-zA-Z0-9]{24,}\b" +) + +SLACK_TOKEN_PATTERN = re.compile(r"\bxox[baprs]-[a-zA-Z0-9\-]{10,48}\b") + +GENERIC_API_KEY_PATTERN = re.compile( + r"(?i)(?:api[_\-]?key|apikey|api[_\-]?token|access[_\-]?key|secret[_\-]?key)" + r"\s*[:=]\s*['\"]?" + r"([a-zA-Z0-9\-_.]{20,64})" + r"['\"]?" +) + +PRIVATE_KEY_PATTERN = re.compile( + r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----" +) + +API_KEY_CONTEXT = [ + "api_key", + "apikey", + "api key", + "secret", + "token", + "authorization", + "bearer", + "credential", + "password", + "access_key", +] + +CREDENTIAL_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "CRED_AWS_ACCESS_KEY", + rule_name = "AWS Access Key ID", + pattern = AWS_ACCESS_KEY_PATTERN, + base_score = 0.85, + context_keywords = [ + "aws", + "amazon", + "access_key", + "aws_access_key_id", + ], + ), + DetectionRule( + rule_id = "CRED_GITHUB_TOKEN", + rule_name = "GitHub Personal Access Token", + pattern = GITHUB_CLASSIC_PAT_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "token", + "pat"], + ), + DetectionRule( + rule_id = "CRED_GITHUB_FINE_GRAINED", + rule_name = "GitHub Fine-Grained PAT", + pattern = GITHUB_FINE_GRAINED_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "token"], + ), + DetectionRule( + rule_id = "CRED_GITHUB_OAUTH", + rule_name = "GitHub OAuth Token", + pattern = GITHUB_OAUTH_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "oauth"], + ), + DetectionRule( + rule_id = "CRED_GITHUB_APP", + rule_name = "GitHub App Token", + pattern = GITHUB_APP_PATTERN, + base_score = 0.90, + context_keywords = ["github", + "app"], + ), + DetectionRule( + rule_id = "CRED_JWT", + rule_name = "JSON Web Token", + pattern = JWT_PATTERN, + base_score = 0.70, + context_keywords = [ + "jwt", + "token", + "bearer", + "authorization", + ], + ), + DetectionRule( + rule_id = "CRED_STRIPE_KEY", + rule_name = "Stripe API Key", + pattern = STRIPE_KEY_PATTERN, + base_score = 0.90, + context_keywords = [ + "stripe", + "payment", + "api_key", + ], + ), + DetectionRule( + rule_id = "CRED_SLACK_TOKEN", + rule_name = "Slack Token", + pattern = SLACK_TOKEN_PATTERN, + base_score = 0.85, + context_keywords = [ + "slack", + "token", + "webhook", + ], + ), + DetectionRule( + rule_id = "CRED_GENERIC_API_KEY", + rule_name = "Generic API Key", + pattern = GENERIC_API_KEY_PATTERN, + base_score = 0.50, + context_keywords = API_KEY_CONTEXT, + ), + DetectionRule( + rule_id = "CRED_PRIVATE_KEY", + rule_name = "Private Key", + pattern = PRIVATE_KEY_PATTERN, + base_score = 0.95, + context_keywords = [ + "private key", + "rsa", + "ssh", + "certificate", + ], + ), +] diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py new file mode 100644 index 0000000..8355801 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/financial.py @@ -0,0 +1,184 @@ +""" +©AngelaMos | 2026 +financial.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +VISA_PATTERN = re.compile( + r"\b4[0-9]{3}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b" +) + +MASTERCARD_PATTERN = re.compile( + r"\b(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)" + r"[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b" +) + +AMEX_PATTERN = re.compile(r"\b3[47][0-9]{2}[-\s]?[0-9]{6}[-\s]?[0-9]{5}\b") + +DISCOVER_PATTERN = re.compile( + r"\b6(?:011|5[0-9]{2})[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b" +) + +IBAN_PATTERN = re.compile( + r"\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}[A-Z0-9]{0,16}\b" +) + +NHS_PATTERN = re.compile(r"\b\d{3}[-\s]?\d{3}[-\s]?\d{4}\b") + + +def luhn_check(number: str) -> bool: + """ + Validate a number using the Luhn algorithm + """ + digits = [int(d) for d in number if d.isdigit()] + if len(digits) < 13: + return False + + odd_digits = digits[-1 ::-2] + even_digits = digits[-2 ::-2] + total = sum(odd_digits) + for d in even_digits: + total += sum(divmod(d * 2, 10)) + return total % 10 == 0 + + +def iban_check(value: str) -> bool: + """ + Validate an IBAN using the mod-97 algorithm + """ + cleaned = value.replace(" ", "").upper() + if len(cleaned) < 15 or len(cleaned) > 34: + return False + + rearranged = cleaned[4 :] + cleaned[: 4] + numeric = "" + for char in rearranged: + if char.isalpha(): + numeric += str(ord(char) - ord("A") + 10) + else: + numeric += char + + return int(numeric) % 97 == 1 + + +def nhs_check(value: str) -> bool: + """ + Validate a UK NHS number using mod-11 + """ + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 10 or not digits.isdigit(): + return False + + weights = range(10, 1, -1) + total = sum( + int(d) * w for d, w in zip(digits[: 9], weights, strict = False) + ) + remainder = 11 - (total % 11) + if remainder == 11: + remainder = 0 + if remainder == 10: + return False + return remainder == int(digits[9]) + + +CREDIT_CARD_CONTEXT = [ + "credit card", + "card number", + "cc", + "cvv", + "cvc", + "expiry", + "expiration", + "visa", + "mastercard", + "amex", + "card no", + "payment card", + "pan", +] + +IBAN_CONTEXT = [ + "iban", + "bank account", + "account number", + "swift", + "bic", + "wire transfer", + "bank transfer", +] + +NHS_CONTEXT = [ + "nhs", + "nhs number", + "national health", + "health service", + "patient id", + "patient number", +] + +FINANCIAL_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "FIN_CREDIT_CARD_VISA", + rule_name = "Visa Credit Card Number", + pattern = VISA_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_CREDIT_CARD_MC", + rule_name = "Mastercard Credit Card Number", + pattern = MASTERCARD_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_CREDIT_CARD_AMEX", + rule_name = "American Express Card Number", + pattern = AMEX_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_CREDIT_CARD_DISC", + rule_name = "Discover Card Number", + pattern = DISCOVER_PATTERN, + base_score = 0.50, + context_keywords = CREDIT_CARD_CONTEXT, + validator = luhn_check, + compliance_frameworks = ["PCI_DSS", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_IBAN", + rule_name = "IBAN Number", + pattern = IBAN_PATTERN, + base_score = 0.40, + context_keywords = IBAN_CONTEXT, + validator = iban_check, + compliance_frameworks = ["GDPR", + "GLBA"], + ), + DetectionRule( + rule_id = "FIN_NHS_NUMBER", + rule_name = "UK NHS Number", + pattern = NHS_PATTERN, + base_score = 0.15, + context_keywords = NHS_CONTEXT, + validator = nhs_check, + compliance_frameworks = ["GDPR"], + ), +] diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/health.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/health.py new file mode 100644 index 0000000..ead20a9 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/health.py @@ -0,0 +1,140 @@ +""" +©AngelaMos | 2026 +health.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +MEDICAL_RECORD_PATTERN = re.compile( + r"\b(?:MRN|MR#|MED)\s*[-:#]?\s*\d{6,10}\b", + re.IGNORECASE, +) + +DEA_NUMBER_PATTERN = re.compile(r"\b[A-Z][A-Z9]\d{7}\b") + +NPI_PATTERN = re.compile(r"\b\d{10}\b") + +PHI_CONTEXT_KEYWORDS = [ + "patient", + "diagnosis", + "treatment", + "medical", + "health", + "hospital", + "clinical", + "physician", + "prescription", + "medication", + "lab result", + "blood type", + "allergies", + "insurance", + "claim", + "icd", + "cpt", + "hcpcs", + "hipaa", + "phi", + "protected health", + "discharge", + "admission", + "prognosis", +] + +MEDICAL_RECORD_CONTEXT = [ + "medical record", + "mrn", + "patient id", + "chart number", + "record number", + "health record", + "ehr", + "emr", +] + +DEA_CONTEXT = [ + "dea", + "dea number", + "drug enforcement", + "prescriber", + "controlled substance", +] + +NPI_CONTEXT = [ + "npi", + "national provider", + "provider id", + "provider number", + "provider identifier", + "cms", +] + + +def _validate_npi(value: str) -> bool: + """ + Validate an NPI using Luhn with the 80840 prefix + """ + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 10 or not digits.isdigit(): + return False + + prefixed = "80840" + digits + total = 0 + for i, d in enumerate(reversed(prefixed)): + n = int(d) + if i % 2 == 1: + n *= 2 + if n > 9: + n -= 9 + total += n + return total % 10 == 0 + + +def _validate_dea_number(value: str) -> bool: + """ + Validate a DEA number using its check digit algorithm + """ + if len(value) != 9: + return False + digits = value[2 :] + if not digits.isdigit(): + return False + + odd_sum = (int(digits[0]) + int(digits[2]) + int(digits[4])) + even_sum = (int(digits[1]) + int(digits[3]) + int(digits[5])) + check = (odd_sum + even_sum * 2) % 10 + return check == int(digits[6]) + + +HEALTH_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "HEALTH_MEDICAL_RECORD", + rule_name = "Medical Record Number", + pattern = MEDICAL_RECORD_PATTERN, + base_score = 0.55, + context_keywords = MEDICAL_RECORD_CONTEXT, + compliance_frameworks = ["HIPAA"], + ), + DetectionRule( + rule_id = "HEALTH_DEA_NUMBER", + rule_name = "DEA Registration Number", + pattern = DEA_NUMBER_PATTERN, + base_score = 0.35, + context_keywords = DEA_CONTEXT, + validator = _validate_dea_number, + compliance_frameworks = ["HIPAA"], + ), + DetectionRule( + rule_id = "HEALTH_NPI", + rule_name = "National Provider Identifier", + pattern = NPI_PATTERN, + base_score = 0.10, + context_keywords = NPI_CONTEXT, + validator = _validate_npi, + compliance_frameworks = ["HIPAA"], + ), +] diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py new file mode 100644 index 0000000..c543c78 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/detectors/rules/pii.py @@ -0,0 +1,209 @@ +""" +©AngelaMos | 2026 +pii.py +""" + + +import re + +from dlp_scanner.detectors.base import DetectionRule + + +SSN_PATTERN = re.compile( + r"\b(?!000|666|9\d{2})\d{3}" + r"[-\s]?" + r"(?!00)\d{2}" + r"[-\s]?" + r"(?!0000)\d{4}\b" +) + +EMAIL_PATTERN = re.compile( + r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b" +) + +PHONE_US_PATTERN = re.compile( + r"\b(?:\+?1[-.\s]?)?" + r"(?:\(?[2-9]\d{2}\)?[-.\s]?)" + r"[2-9]\d{2}[-.\s]?\d{4}\b" +) + +PHONE_E164_PATTERN = re.compile(r"\+[1-9]\d{6,14}\b") + +PASSPORT_US_PATTERN = re.compile(r"\b[A-Z]{1,2}\d{6,7}\b") + +PASSPORT_UK_PATTERN = re.compile(r"\b\d{9}\b") + +IPV4_PATTERN = re.compile( + r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}" + r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b" +) + +DRIVERS_LICENSE_CA_PATTERN = re.compile(r"\b[A-Z]\d{7}\b") +DRIVERS_LICENSE_FL_PATTERN = re.compile(r"\b[A-Z]\d{12}\b") +DRIVERS_LICENSE_IL_PATTERN = re.compile(r"\b[A-Z]\d{11}\b") + + +def _validate_ssn(value: str) -> bool: + """ + Validate SSN area, group, and serial numbers + """ + digits = value.replace("-", "").replace(" ", "") + if len(digits) != 9 or not digits.isdigit(): + return False + + area = int(digits[0 : 3]) + group = int(digits[3 : 5]) + serial = int(digits[5 : 9]) + + if area in {0, 666} or area >= 900: + return False + if group == 0: + return False + return serial != 0 + + +SSN_CONTEXT = [ + "ssn", + "social security", + "social security number", + "ss#", + "taxpayer id", + "sin", + "tax id", +] + +EMAIL_CONTEXT = [ + "email", + "e-mail", + "mail", + "contact", + "reach at", +] + +PHONE_CONTEXT = [ + "phone", + "mobile", + "cell", + "tel", + "telephone", + "fax", + "contact number", + "call", +] + +PASSPORT_CONTEXT = [ + "passport", + "pass no", + "travel document", + "passport number", + "document number", +] + +DRIVERS_LICENSE_CONTEXT = [ + "driver's license", + "drivers license", + "driver license", + "dl#", + "dl number", + "license number", + "licence number", +] + +PII_RULES: list[DetectionRule] = [ + DetectionRule( + rule_id = "PII_SSN", + rule_name = "US Social Security Number", + pattern = SSN_PATTERN, + base_score = 0.45, + context_keywords = SSN_CONTEXT, + validator = _validate_ssn, + compliance_frameworks = [ + "HIPAA", + "CCPA", + "GLBA", + "GDPR", + ], + ), + DetectionRule( + rule_id = "PII_EMAIL", + rule_name = "Email Address", + pattern = EMAIL_PATTERN, + base_score = 0.30, + context_keywords = EMAIL_CONTEXT, + compliance_frameworks = ["GDPR", + "CCPA"], + ), + DetectionRule( + rule_id = "PII_PHONE", + rule_name = "US Phone Number", + pattern = PHONE_US_PATTERN, + base_score = 0.25, + context_keywords = PHONE_CONTEXT, + compliance_frameworks = [ + "GDPR", + "CCPA", + "HIPAA", + ], + ), + DetectionRule( + rule_id = "PII_PHONE_INTL", + rule_name = "International Phone Number", + pattern = PHONE_E164_PATTERN, + base_score = 0.30, + context_keywords = PHONE_CONTEXT, + compliance_frameworks = ["GDPR", + "CCPA"], + ), + DetectionRule( + rule_id = "PII_PASSPORT_US", + rule_name = "US Passport Number", + pattern = PASSPORT_US_PATTERN, + base_score = 0.15, + context_keywords = PASSPORT_CONTEXT, + compliance_frameworks = ["GDPR", + "CCPA"], + ), + DetectionRule( + rule_id = "PII_PASSPORT_UK", + rule_name = "UK Passport Number", + pattern = PASSPORT_UK_PATTERN, + base_score = 0.10, + context_keywords = PASSPORT_CONTEXT, + compliance_frameworks = ["GDPR"], + ), + DetectionRule( + rule_id = "PII_IPV4", + rule_name = "IPv4 Address", + pattern = IPV4_PATTERN, + base_score = 0.15, + context_keywords = [], + compliance_frameworks = ["GDPR"], + ), + DetectionRule( + rule_id = "PII_DRIVERS_LICENSE", + rule_name = "US Driver's License (CA)", + pattern = DRIVERS_LICENSE_CA_PATTERN, + base_score = 0.10, + context_keywords = DRIVERS_LICENSE_CONTEXT, + compliance_frameworks = ["CCPA", + "HIPAA"], + ), + DetectionRule( + rule_id = "PII_DRIVERS_LICENSE_FL", + rule_name = "US Driver's License (FL)", + pattern = DRIVERS_LICENSE_FL_PATTERN, + base_score = 0.10, + context_keywords = DRIVERS_LICENSE_CONTEXT, + compliance_frameworks = ["CCPA", + "HIPAA"], + ), + DetectionRule( + rule_id = "PII_DRIVERS_LICENSE_IL", + rule_name = "US Driver's License (IL)", + pattern = DRIVERS_LICENSE_IL_PATTERN, + base_score = 0.10, + context_keywords = DRIVERS_LICENSE_CONTEXT, + compliance_frameworks = ["CCPA", + "HIPAA"], + ), +] diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/engine.py b/third_party/community/dlp-scanner/src/dlp_scanner/engine.py new file mode 100644 index 0000000..b45f5b1 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/engine.py @@ -0,0 +1,146 @@ +""" +©AngelaMos | 2026 +engine.py +""" + + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.constants import OutputFormat +from dlp_scanner.detectors.registry import ( + DetectorRegistry, +) +from dlp_scanner.models import ScanResult +from dlp_scanner.reporters.console import ( + ConsoleReporter, +) +from dlp_scanner.reporters.csv_report import ( + CsvReporter, +) +from dlp_scanner.reporters.json_report import ( + JsonReporter, +) +from dlp_scanner.reporters.sarif import SarifReporter +from dlp_scanner.scanners.db_scanner import ( + DatabaseScanner, +) +from dlp_scanner.scanners.file_scanner import ( + FileScanner, +) +from dlp_scanner.scanners.network_scanner import ( + NetworkScanner, +) + + +log = structlog.get_logger() + +REPORTER_MAP: dict[str, + type] = { + "console": ConsoleReporter, + "json": JsonReporter, + "sarif": SarifReporter, + "csv": CsvReporter, + } + + +class ScanEngine: + """ + Orchestrates the full scan pipeline + """ + def __init__(self, config: ScanConfig) -> None: + self._config = config + detection = config.detection + allowlist_vals = detection.allowlists.values + self._registry = DetectorRegistry( + enable_patterns = detection.enable_rules, + disable_patterns = detection.disable_rules, + allowlist_values = ( + frozenset(allowlist_vals) if allowlist_vals else None + ), + context_window_tokens = (detection.context_window_tokens), + ) + + def scan_files(self, target: str) -> ScanResult: + """ + Scan filesystem target for sensitive data + """ + scanner = FileScanner(self._config, self._registry) + result = scanner.scan(target) + log.info( + "file_scan_complete", + target = target, + findings = len(result.findings), + targets = result.targets_scanned, + ) + return result + + def scan_database(self, target: str) -> ScanResult: + """ + Scan database target for sensitive data + """ + scanner = DatabaseScanner(self._config, self._registry) + result = scanner.scan(target) + log.info( + "database_scan_complete", + target = target, + findings = len(result.findings), + targets = result.targets_scanned, + ) + return result + + def scan_network(self, target: str) -> ScanResult: + """ + Scan network capture file for sensitive data + """ + scanner = NetworkScanner(self._config, self._registry) + result = scanner.scan(target) + log.info( + "network_scan_complete", + target = target, + findings = len(result.findings), + targets = result.targets_scanned, + ) + return result + + def generate_report( + self, + result: ScanResult, + output_format: OutputFormat | None = None, + ) -> str: + """ + Generate report string in the requested format + """ + fmt = output_format or self._config.output.format + reporter_cls = REPORTER_MAP[fmt] + reporter = reporter_cls() + output: str = reporter.generate(result) + return output + + def display_console( + self, + result: ScanResult, + ) -> None: + """ + Display Rich-formatted results to console + """ + reporter = ConsoleReporter() + reporter.display(result) + + def write_report( + self, + result: ScanResult, + output_path: str, + output_format: OutputFormat | None = None, + ) -> None: + """ + Generate report and write to file + """ + content = self.generate_report(result, output_format) + with open(output_path, "w") as f: + f.write(content) + log.info( + "report_written", + path = output_path, + format = output_format or self._config.output.format, + ) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/archive.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/archive.py new file mode 100644 index 0000000..18fe4a3 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/archive.py @@ -0,0 +1,191 @@ +""" +©AngelaMos | 2026 +archive.py +""" + + +import tarfile +import zipfile + +import structlog + +from dlp_scanner.constants import ( + MAX_ARCHIVE_DEPTH, + MAX_ARCHIVE_MEMBER_SIZE_MB, + ZIP_BOMB_RATIO_THRESHOLD, +) +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +ARCHIVE_EXTENSIONS: frozenset[str] = frozenset( + { + ".zip", + ".tar", + ".tar.gz", + ".tgz", + ".tar.bz2", + } +) + +MAX_MEMBER_BYTES: int = MAX_ARCHIVE_MEMBER_SIZE_MB * 1024 * 1024 + + +class ArchiveExtractor: + """ + Extracts text content from archive files with security guards + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return ARCHIVE_EXTENSIONS + + def extract( + self, + path: str, + depth: int = 0, + ) -> list[TextChunk]: + """ + Extract text from archive members + """ + if depth >= MAX_ARCHIVE_DEPTH: + log.warning( + "archive_depth_exceeded", + path = path, + depth = depth, + ) + return [] + + if path.endswith(".zip"): + return self._extract_zip(path, depth) + + if any(path.endswith(ext) + for ext in (".tar", ".tar.gz", ".tgz", ".tar.bz2")): + return self._extract_tar(path, depth) + + return [] + + def _extract_zip(self, path: str, depth: int) -> list[TextChunk]: + """ + Extract from ZIP with bomb and traversal protection + """ + chunks: list[TextChunk] = [] + + try: + with zipfile.ZipFile(path, "r") as zf: + for info in zf.infolist(): + if not self._is_safe_zip_member(info): + continue + + data = zf.read(info.filename) + if not data: + continue + + try: + text = data.decode("utf-8", errors = "replace") + except Exception: + continue + + if text.strip(): + chunks.append( + TextChunk( + text = text, + location = Location( + source_type = "archive", + uri = f"{path}!{info.filename}", + ), + ) + ) + + except Exception: + log.warning("zip_extract_failed", path = path) + + return chunks + + def _extract_tar(self, path: str, depth: int) -> list[TextChunk]: + """ + Extract from TAR with traversal protection + """ + chunks: list[TextChunk] = [] + + try: + with tarfile.open(path) as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + + if not self._is_safe_tar_member(member): + continue + + if member.size > MAX_MEMBER_BYTES: + continue + + extracted = tf.extractfile(member) + if extracted is None: + continue + + data = extracted.read() + try: + text = data.decode("utf-8", errors = "replace") + except Exception: + continue + + if text.strip(): + chunks.append( + TextChunk( + text = text, + location = Location( + source_type = "archive", + uri = f"{path}!{member.name}", + ), + ) + ) + + except Exception: + log.warning("tar_extract_failed", path = path) + + return chunks + + def _is_safe_zip_member(self, info: zipfile.ZipInfo) -> bool: + """ + Check a ZIP member for path traversal and bomb indicators + """ + if ".." in info.filename or info.filename.startswith("/"): + log.warning( + "zip_path_traversal_blocked", + filename = info.filename, + ) + return False + + if "\x00" in info.filename: + return False + + if info.file_size > MAX_MEMBER_BYTES: + return False + + if (info.compress_size > 0 and info.file_size / info.compress_size + > ZIP_BOMB_RATIO_THRESHOLD): + log.warning( + "zip_bomb_detected", + filename = info.filename, + ratio = info.file_size / info.compress_size, + ) + return False + + return True + + def _is_safe_tar_member(self, member: tarfile.TarInfo) -> bool: + """ + Check a TAR member for path traversal + """ + if ".." in member.name or member.name.startswith("/"): + log.warning( + "tar_path_traversal_blocked", + filename = member.name, + ) + return False + + return not (member.issym() or member.islnk()) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/base.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/base.py new file mode 100644 index 0000000..2e008b1 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/base.py @@ -0,0 +1,27 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +from typing import Protocol + +from dlp_scanner.models import TextChunk + + +class Extractor(Protocol): + """ + Protocol for text extraction from different file formats + """ + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text chunks from a file at the given path + """ + ... + + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + ... diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/email.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/email.py new file mode 100644 index 0000000..7dba9cf --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/email.py @@ -0,0 +1,123 @@ +""" +©AngelaMos | 2026 +email.py +""" + + +import email as email_lib +from email import policy + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +EMAIL_EXTENSIONS: frozenset[str] = frozenset({ + ".eml", + ".msg", +}) + + +class EmlExtractor: + """ + Extracts text from RFC 2822 EML files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".eml"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse EML and extract headers and body text + """ + chunks: list[TextChunk] = [] + + try: + with open(path, "rb") as f: + msg = email_lib.message_from_binary_file( + f, + policy = policy.default + ) + + parts: list[str] = [] + + for header in ("From", "To", "Cc", "Subject"): + value = msg.get(header) + if value: + parts.append(f"{header}: {value}") + + body = msg.get_body(preferencelist = ("plain", "html")) + if body is not None: + content = body.get_content() + if content: + parts.append(content) + + if parts: + chunks.append( + TextChunk( + text = "\n".join(parts), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("eml_extract_failed", path = path) + + return chunks + + +class MsgExtractor: + """ + Extracts text from Outlook MSG files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".msg"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse MSG and extract headers and body text + """ + import extract_msg + + chunks: list[TextChunk] = [] + + try: + with extract_msg.Message(path) as msg: + parts: list[str] = [] + + if msg.sender: + parts.append(f"From: {msg.sender}") + if msg.to: + parts.append(f"To: {msg.to}") + if msg.subject: + parts.append(f"Subject: {msg.subject}") + if msg.body: + parts.append(msg.body) + + if parts: + chunks.append( + TextChunk( + text = "\n".join(parts), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("msg_extract_failed", path = path) + + return chunks diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/office.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/office.py new file mode 100644 index 0000000..25f6497 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/office.py @@ -0,0 +1,178 @@ +""" +©AngelaMos | 2026 +office.py +""" + + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +DOCX_EXTENSIONS: frozenset[str] = frozenset({".docx"}) +XLSX_EXTENSIONS: frozenset[str] = frozenset({".xlsx"}) +XLS_EXTENSIONS: frozenset[str] = frozenset({".xls"}) +OFFICE_EXTENSIONS: frozenset[str] = ( + DOCX_EXTENSIONS | XLSX_EXTENSIONS | XLS_EXTENSIONS +) + + +class DocxExtractor: + """ + Extracts text from DOCX files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return DOCX_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from paragraphs, tables, and metadata + """ + from docx import Document + + chunks: list[TextChunk] = [] + + try: + doc = Document(path) + paragraphs: list[str] = [] + + for para in doc.paragraphs: + if para.text.strip(): + paragraphs.append(para.text) + + for table in doc.tables: + for row in table.rows: + cells = [ + cell.text + for cell in row.cells + if cell.text.strip() + ] + if cells: + paragraphs.append(" | ".join(cells)) + + if doc.core_properties.author: + paragraphs.append(f"Author: {doc.core_properties.author}") + if doc.core_properties.title: + paragraphs.append(f"Title: {doc.core_properties.title}") + + if paragraphs: + chunks.append( + TextChunk( + text = "\n".join(paragraphs), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("docx_extract_failed", path = path) + + return chunks + + +class XlsxExtractor: + """ + Extracts text from XLSX files using openpyxl + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return XLSX_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from all sheets and cells + """ + from openpyxl import load_workbook + + chunks: list[TextChunk] = [] + + try: + wb = load_workbook( + path, + read_only = True, + data_only = True, + ) + for sheet in wb.worksheets: + rows: list[str] = [] + for row in sheet.iter_rows(values_only = True): + cell_values = [str(c) for c in row if c is not None] + if cell_values: + rows.append(" | ".join(cell_values)) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + sheet_name = sheet.title, + ), + ) + ) + wb.close() + except Exception: + log.warning("xlsx_extract_failed", path = path) + + return chunks + + +class XlsExtractor: + """ + Extracts text from legacy XLS files using xlrd + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return XLS_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from legacy Excel workbooks + """ + import xlrd + + chunks: list[TextChunk] = [] + + try: + wb = xlrd.open_workbook(path) + for sheet in wb.sheets(): + rows: list[str] = [] + for row_idx in range(sheet.nrows): + cell_values = [ + str(sheet.cell_value(row_idx, + col)) + for col in range(sheet.ncols) + if sheet.cell_value(row_idx, col) + ] + if cell_values: + rows.append(" | ".join(cell_values)) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + sheet_name = sheet.name, + ), + ) + ) + except Exception: + log.warning("xls_extract_failed", path = path) + + return chunks diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/pdf.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/pdf.py new file mode 100644 index 0000000..07d938a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/pdf.py @@ -0,0 +1,56 @@ +""" +©AngelaMos | 2026 +pdf.py +""" + + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +PDF_EXTENSIONS: frozenset[str] = frozenset({".pdf"}) + + +class PDFExtractor: + """ + Extracts text from PDF files using PyMuPDF + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return PDF_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Extract text from each page of a PDF + """ + import fitz + + chunks: list[TextChunk] = [] + + try: + doc = fitz.open(path) + for page_num in range(len(doc)): + page = doc[page_num] + text = page.get_text("text") + if text.strip(): + chunks.append( + TextChunk( + text = text, + location = Location( + source_type = "file", + uri = path, + line = page_num + 1, + ), + ) + ) + doc.close() + except Exception: + log.warning("pdf_extract_failed", path = path) + + return chunks diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/plaintext.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/plaintext.py new file mode 100644 index 0000000..5771447 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/plaintext.py @@ -0,0 +1,112 @@ +""" +©AngelaMos | 2026 +plaintext.py +""" + + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + +PLAINTEXT_EXTENSIONS: frozenset[str] = frozenset( + { + ".txt", + ".log", + ".cfg", + ".ini", + ".conf", + ".toml", + ".md", + ".rst", + ".html", + ".htm", + ".tsv", + ".env", + ".sh", + ".bat", + ".ps1", + ".py", + ".js", + ".ts", + ".go", + ".rb", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".rs", + ".tf", + ".hcl", + } +) + +CHUNK_MAX_LINES: int = 500 + + +class PlaintextExtractor: + """ + Extracts text from plaintext and source code files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return PLAINTEXT_EXTENSIONS + + def extract(self, path: str) -> list[TextChunk]: + """ + Read a text file and return chunks + """ + chunks: list[TextChunk] = [] + + try: + with open( + path, + encoding = "utf-8", + errors = "replace", + ) as f: + lines: list[str] = [] + line_number = 1 + chunk_start = 1 + + for line in f: + lines.append(line) + if len(lines) >= CHUNK_MAX_LINES: + chunks.append( + TextChunk( + text = "".join(lines), + location = Location( + source_type = "file", + uri = path, + line = chunk_start, + ), + ) + ) + chunk_start = line_number + 1 + lines = [] + line_number += 1 + + if lines: + chunks.append( + TextChunk( + text = "".join(lines), + location = Location( + source_type = "file", + uri = path, + line = chunk_start, + ), + ) + ) + + except OSError: + log.warning( + "file_read_failed", + path = path, + ) + + return chunks diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/extractors/structured.py b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/structured.py new file mode 100644 index 0000000..52eec32 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/extractors/structured.py @@ -0,0 +1,327 @@ +""" +©AngelaMos | 2026 +structured.py +""" + + +import csv +import json +from typing import Any + +import structlog + +from dlp_scanner.models import Location, TextChunk + + +log = structlog.get_logger() + + +class CsvExtractor: + """ + Extracts text from CSV and TSV files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".csv", ".tsv"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Read CSV row by row and concatenate cell values + """ + chunks: list[TextChunk] = [] + + try: + with open( + path, + newline = "", + encoding = "utf-8-sig", + ) as f: + dialect = csv.Sniffer().sniff(f.read(4096)) + f.seek(0) + reader = csv.reader(f, dialect) + rows: list[str] = [] + + for _row_num, row in enumerate(reader, 1): + cells = [c for c in row if c.strip()] + if cells: + rows.append(" | ".join(cells)) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + line = 1, + ), + ) + ) + + except Exception: + log.warning("csv_extract_failed", path = path) + + return chunks + + +class JsonExtractor: + """ + Extracts text values from JSON files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".json"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse JSON and extract all string values recursively + """ + chunks: list[TextChunk] = [] + + try: + with open(path, encoding = "utf-8") as f: + data = json.load(f) + + strings = _extract_json_strings(data) + if strings: + chunks.append( + TextChunk( + text = "\n".join(strings), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("json_extract_failed", path = path) + + return chunks + + +class XmlExtractor: + """ + Extracts text from XML files using defusedxml + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".xml"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse XML safely and extract all text content + """ + import defusedxml.ElementTree as ET + + chunks: list[TextChunk] = [] + + try: + tree = ET.parse(path) + root = tree.getroot() + texts: list[str] = [] + + for elem in root.iter(): + if elem.text and elem.text.strip(): + texts.append(elem.text.strip()) + if elem.tail and elem.tail.strip(): + texts.append(elem.tail.strip()) + for attr_val in elem.attrib.values(): + if attr_val.strip(): + texts.append(attr_val.strip()) + + if texts: + chunks.append( + TextChunk( + text = "\n".join(texts), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("xml_extract_failed", path = path) + + return chunks + + +class YamlExtractor: + """ + Extracts text from YAML files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".yaml", ".yml"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Parse YAML safely and extract string values + """ + from ruamel.yaml import YAML + + chunks: list[TextChunk] = [] + + try: + yaml = YAML(typ = "safe") + with open(path) as f: + data = yaml.load(f) + + if data: + strings = _extract_json_strings(data) + if strings: + chunks.append( + TextChunk( + text = "\n".join(strings), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("yaml_extract_failed", path = path) + + return chunks + + +class ParquetExtractor: + """ + Extracts text from Parquet files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".parquet"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Read Parquet file and extract string columns + """ + import pyarrow.parquet as pq + + chunks: list[TextChunk] = [] + + try: + pf = pq.ParquetFile(path) + schema = pf.schema_arrow + + string_cols = [ + field.name for field in schema if str(field.type) in ( + "string", + "large_string", + "utf8", + "large_utf8",) + ] + + if not string_cols: + return chunks + + for batch in pf.iter_batches( + batch_size = 5000, + columns = string_cols, + ): + rows: list[str] = [] + table_dict = batch.to_pydict() + for col_name, values in table_dict.items(): + for val in values: + if val is not None and str(val).strip(): + rows.append(f"{col_name}: {val}") + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("parquet_extract_failed", path = path) + + return chunks + + +class AvroExtractor: + """ + Extracts text from Avro files + """ + @property + def supported_extensions(self) -> frozenset[str]: + """ + File extensions this extractor handles + """ + return frozenset({".avro"}) + + def extract(self, path: str) -> list[TextChunk]: + """ + Read Avro file and extract string fields + """ + from fastavro import reader + + chunks: list[TextChunk] = [] + + try: + with open(path, "rb") as f: + rows: list[str] = [] + for record in reader(f): + strings = _extract_json_strings(record) + rows.extend(strings) + + if rows: + chunks.append( + TextChunk( + text = "\n".join(rows), + location = Location( + source_type = "file", + uri = path, + ), + ) + ) + + except Exception: + log.warning("avro_extract_failed", path = path) + + return chunks + + +def _extract_json_strings( + data: Any, + prefix: str = "", +) -> list[str]: + """ + Recursively extract all string values from a JSON-like structure + """ + strings: list[str] = [] + + if isinstance(data, str): + if data.strip(): + label = f"{prefix}: {data}" if prefix else data + strings.append(label) + elif isinstance(data, dict): + for key, val in data.items(): + key_path = (f"{prefix}.{key}" if prefix else str(key)) + strings.extend(_extract_json_strings(val, key_path)) + elif isinstance(data, list): + for item in data: + strings.extend(_extract_json_strings(item, prefix)) + + return strings diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/log.py b/third_party/community/dlp-scanner/src/dlp_scanner/log.py new file mode 100644 index 0000000..e9ee554 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/log.py @@ -0,0 +1,80 @@ +""" +©AngelaMos | 2026 +log.py +""" + + +import logging +import sys +from typing import Any + +import orjson +import structlog + + +def _orjson_serializer( + data: Any, + **_kwargs: Any, +) -> str: + """ + Serialize log data using orjson for performance + """ + return orjson.dumps(data).decode("utf-8") + + +def configure_logging( + level: str = "INFO", + json_output: bool = False, + log_file: str = "", +) -> None: + """ + Set up structlog with stdlib integration + """ + shared_processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt = "iso"), + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.StackInfoRenderer(), + ] + + if json_output: + renderer: structlog.types.Processor = ( + structlog.processors.JSONRenderer( + serializer = _orjson_serializer + ) + ) + else: + renderer = structlog.dev.ConsoleRenderer(colors = True) + + structlog.configure( + processors = [ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory = structlog.stdlib.LoggerFactory(), + wrapper_class = structlog.stdlib.BoundLogger, + cache_logger_on_first_use = True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain = shared_processors, + processors = [ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + handler: logging.Handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(getattr(logging, level.upper())) + + if log_file: + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/models.py b/third_party/community/dlp-scanner/src/dlp_scanner/models.py new file mode 100644 index 0000000..2c5edab --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/models.py @@ -0,0 +1,112 @@ +""" +©AngelaMos | 2026 +models.py +""" + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, UTC + +from dlp_scanner.constants import Severity + + +@dataclass(frozen = True, slots = True) +class Location: + """ + Where a finding was detected + """ + source_type: str + uri: str + line: int | None = None + column: int | None = None + byte_offset: int | None = None + table_name: str | None = None + column_name: str | None = None + sheet_name: str | None = None + + +@dataclass(slots = True) +class Finding: + """ + A fully scored and classified detection result + """ + finding_id: str = field( + default_factory = lambda: uuid.uuid4().hex[: 12] + ) + rule_id: str = "" + rule_name: str = "" + severity: Severity = "low" + confidence: float = 0.0 + location: Location = field( + default_factory = lambda: Location( + source_type = "unknown", + uri = "",) + ) + redacted_snippet: str = "" + compliance_frameworks: list[str] = field(default_factory = list) + remediation: str = "" + detected_at: datetime = field( + default_factory = lambda: datetime.now(UTC) + ) + + +@dataclass(slots = True) +class ScanResult: + """ + Aggregated results from a complete scan run + """ + scan_id: str = field(default_factory = lambda: uuid.uuid4().hex[: 16]) + tool_version: str = "0.1.0" + scan_started_at: datetime = field( + default_factory = lambda: datetime.now(UTC) + ) + scan_completed_at: datetime | None = None + targets_scanned: int = 0 + findings: list[Finding] = field(default_factory = list) + errors: list[str] = field(default_factory = list) + + @property + def findings_by_severity(self) -> dict[str, int]: + """ + Count findings grouped by severity level + """ + counts: dict[str, + int] = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + } + for f in self.findings: + counts[f.severity] = counts.get(f.severity, 0) + 1 + return counts + + @property + def findings_by_rule(self) -> dict[str, int]: + """ + Count findings grouped by rule ID + """ + counts: dict[str, int] = {} + for f in self.findings: + counts[f.rule_id] = counts.get(f.rule_id, 0) + 1 + return counts + + @property + def findings_by_framework(self) -> dict[str, int]: + """ + Count findings grouped by compliance framework + """ + counts: dict[str, int] = {} + for f in self.findings: + for fw in f.compliance_frameworks: + counts[fw] = counts.get(fw, 0) + 1 + return counts + + +@dataclass(frozen = True, slots = True) +class TextChunk: + """ + A piece of extracted text with its source location + """ + text: str + location: Location diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/network/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/network/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/network/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/network/exfiltration.py b/third_party/community/dlp-scanner/src/dlp_scanner/network/exfiltration.py new file mode 100644 index 0000000..b10f484 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/network/exfiltration.py @@ -0,0 +1,279 @@ +""" +©AngelaMos | 2026 +exfiltration.py +""" + + +import re +from collections import defaultdict +from dataclasses import dataclass + +import structlog + +from dlp_scanner.constants import ( + DEFAULT_DNS_ENTROPY_THRESHOLD, +) +from dlp_scanner.detectors.entropy import ( + shannon_entropy_str, +) +from dlp_scanner.network.protocols import DnsQuery + + +log = structlog.get_logger() + +DNS_LABEL_MAX_NORMAL: int = 50 +DNS_QNAME_MAX_NORMAL: int = 100 +TXT_VOLUME_THRESHOLD: float = 0.05 + +BASE64_PATTERN = re.compile(rb"[A-Za-z0-9+/]{40,}={0,2}") +HEX_PATTERN = re.compile(rb"[0-9A-Fa-f]{64,}") + + +@dataclass(frozen = True, slots = True) +class ExfilIndicator: + """ + An indicator of potential data exfiltration + """ + + indicator_type: str + description: str + confidence: float + source_ip: str + dest_ip: str + evidence: str + + +class DnsExfilDetector: + """ + Detects DNS-based data exfiltration patterns + """ + def __init__( + self, + entropy_threshold: float = (DEFAULT_DNS_ENTROPY_THRESHOLD), + ) -> None: + self._entropy_threshold = entropy_threshold + self._indicators: list[ExfilIndicator] = [] + self._domain_txt_counts: dict[str, int] = defaultdict(int) + self._domain_total_counts: dict[str, int] = defaultdict(int) + + def analyze_query( + self, + query: DnsQuery, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Analyze a single DNS query for exfiltration + """ + name = query.name + domain = _extract_base_domain(name) + + self._domain_total_counts[domain] += 1 + if query.query_type == "TXT": + self._domain_txt_counts[domain] += 1 + + indicator = self._check_label_length(name, src_ip, dst_ip) + if indicator is not None: + self._indicators.append(indicator) + return indicator + + indicator = self._check_subdomain_entropy(name, src_ip, dst_ip) + if indicator is not None: + self._indicators.append(indicator) + return indicator + + indicator = self._check_qname_length(name, src_ip, dst_ip) + if indicator is not None: + self._indicators.append(indicator) + return indicator + + return None + + def check_txt_volume( + self, + ) -> list[ExfilIndicator]: + """ + Check for suspicious TXT query volume ratios + """ + indicators: list[ExfilIndicator] = [] + + for domain, txt_count in (self._domain_txt_counts.items()): + total = self._domain_total_counts.get(domain, 0) + if total == 0: + continue + + ratio = txt_count / total + if ratio > TXT_VOLUME_THRESHOLD: + indicator = ExfilIndicator( + indicator_type = "dns_txt_volume", + description = ( + f"High TXT query ratio " + f"({ratio:.1%}) for " + f"{domain}" + ), + confidence = min(0.90, + 0.50 + ratio), + source_ip = "", + dest_ip = "", + evidence = (f"{txt_count} TXT / " + f"{total} total"), + ) + indicators.append(indicator) + + self._indicators.extend(indicators) + return indicators + + def get_indicators( + self, + ) -> list[ExfilIndicator]: + """ + Return all collected exfiltration indicators + """ + return list(self._indicators) + + def _check_label_length( + self, + name: str, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Flag suspiciously long DNS labels + """ + for label in name.split("."): + if len(label) > DNS_LABEL_MAX_NORMAL: + return ExfilIndicator( + indicator_type = ("dns_long_label"), + description = ( + f"DNS label length " + f"{len(label)} exceeds " + f"normal threshold" + ), + confidence = 0.75, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = name, + ) + return None + + def _check_subdomain_entropy( + self, + name: str, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Flag high-entropy subdomains suggesting tunneling + """ + parts = name.split(".") + if len(parts) < 3: + return None + + subdomain = ".".join(parts[:-2]) + if not subdomain: + return None + + entropy = shannon_entropy_str(subdomain) + if entropy > self._entropy_threshold: + return ExfilIndicator( + indicator_type = ("dns_high_entropy"), + description = ( + f"High subdomain entropy " + f"({entropy:.2f}) suggesting " + f"DNS tunneling" + ), + confidence = min( + 0.95, + 0.50 + (entropy - 3.0) * 0.15, + ), + source_ip = src_ip, + dest_ip = dst_ip, + evidence = name, + ) + return None + + def _check_qname_length( + self, + name: str, + src_ip: str, + dst_ip: str, + ) -> ExfilIndicator | None: + """ + Flag excessively long QNAMEs + """ + if len(name) > DNS_QNAME_MAX_NORMAL: + return ExfilIndicator( + indicator_type = "dns_long_qname", + description = ( + f"QNAME length {len(name)} " + f"exceeds normal threshold" + ), + confidence = 0.65, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = name, + ) + return None + + +def detect_base64_payload( + data: bytes, + src_ip: str = "", + dst_ip: str = "", +) -> list[ExfilIndicator]: + """ + Detect base64 or hex-encoded data in payloads + """ + indicators: list[ExfilIndicator] = [] + + for m in BASE64_PATTERN.finditer(data): + matched = m.group() + indicators.append( + ExfilIndicator( + indicator_type = "base64_payload", + description = ( + f"Base64-encoded data " + f"({len(matched)} bytes) " + f"in network payload" + ), + confidence = 0.55, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = matched[: 80].decode( + "ascii", + errors = "replace" + ), + ) + ) + + for m in HEX_PATTERN.finditer(data): + matched = m.group() + indicators.append( + ExfilIndicator( + indicator_type = "hex_payload", + description = ( + f"Hex-encoded data " + f"({len(matched)} bytes) " + f"in network payload" + ), + confidence = 0.45, + source_ip = src_ip, + dest_ip = dst_ip, + evidence = matched[: 80].decode( + "ascii", + errors = "replace" + ), + ) + ) + + return indicators + + +def _extract_base_domain(name: str) -> str: + """ + Extract the registerable domain from a QNAME + """ + parts = name.rstrip(".").split(".") + if len(parts) >= 2: + return ".".join(parts[-2 :]) + return name diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/network/flow_tracker.py b/third_party/community/dlp-scanner/src/dlp_scanner/network/flow_tracker.py new file mode 100644 index 0000000..b5fb651 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/network/flow_tracker.py @@ -0,0 +1,126 @@ +""" +©AngelaMos | 2026 +flow_tracker.py +""" + + +from dataclasses import dataclass, field + +import structlog + +from dlp_scanner.network.pcap import PacketInfo + + +log = structlog.get_logger() + +FlowKey = tuple[str, str, int, int] + + +@dataclass(slots = True) +class FlowStats: + """ + Aggregated statistics for a network flow + """ + + src_ip: str = "" + dst_ip: str = "" + src_port: int = 0 + dst_port: int = 0 + protocol: str = "" + packet_count: int = 0 + total_bytes: int = 0 + start_time: float = 0.0 + end_time: float = 0.0 + segments: list[tuple[int, bytes]] = field(default_factory = list) + + +class FlowTracker: + """ + Tracks and reassembles network flows from packets + """ + def __init__(self) -> None: + self._flows: dict[FlowKey, FlowStats] = {} + + def add_packet(self, packet: PacketInfo) -> None: + """ + Add a packet to its corresponding flow + """ + key = make_flow_key(packet) + flow = self._flows.get(key) + + if flow is None: + flow = FlowStats( + src_ip = packet.src_ip, + dst_ip = packet.dst_ip, + src_port = packet.src_port, + dst_port = packet.dst_port, + protocol = packet.protocol, + start_time = packet.timestamp, + ) + self._flows[key] = flow + + flow.packet_count += 1 + flow.total_bytes += len(packet.payload) + flow.end_time = packet.timestamp + + if packet.payload: + flow.segments.append((packet.tcp_seq, packet.payload)) + + def get_flows(self) -> list[FlowStats]: + """ + Return all tracked flows + """ + return list(self._flows.values()) + + def get_flow(self, key: FlowKey) -> FlowStats | None: + """ + Get a specific flow by key + """ + return self._flows.get(key) + + def reassemble_stream(self, key: FlowKey) -> bytes: + """ + Reassemble TCP payload ordered by sequence number + """ + flow = self._flows.get(key) + if flow is None: + return b"" + + sorted_segments = sorted(flow.segments, key = lambda s: s[0]) + + seen_offsets: set[int] = set() + parts: list[bytes] = [] + for seq, data in sorted_segments: + if seq not in seen_offsets: + seen_offsets.add(seq) + parts.append(data) + + return b"".join(parts) + + @property + def flow_count(self) -> int: + """ + Return the number of tracked flows + """ + return len(self._flows) + + +def make_flow_key( + packet: PacketInfo, +) -> FlowKey: + """ + Create a bidirectional flow key from a packet + """ + forward = ( + packet.src_ip, + packet.dst_ip, + packet.src_port, + packet.dst_port, + ) + reverse = ( + packet.dst_ip, + packet.src_ip, + packet.dst_port, + packet.src_port, + ) + return min(forward, reverse) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/network/pcap.py b/third_party/community/dlp-scanner/src/dlp_scanner/network/pcap.py new file mode 100644 index 0000000..6bd1f4b --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/network/pcap.py @@ -0,0 +1,115 @@ +""" +©AngelaMos | 2026 +pcap.py +""" + + +import socket +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import structlog + + +log = structlog.get_logger() + +TCP_PROTO: int = 6 +UDP_PROTO: int = 17 + + +@dataclass(frozen = True, slots = True) +class PacketInfo: + """ + Parsed network packet with extracted metadata + """ + + timestamp: float + src_ip: str + dst_ip: str + src_port: int + dst_port: int + protocol: str + payload: bytes + raw_length: int + tcp_flags: int = 0 + tcp_seq: int = 0 + + +def read_pcap( + path: Path, + max_packets: int = 0, +) -> Iterator[PacketInfo]: + """ + Read packets from a PCAP or PCAPNG file + """ + import dpkt + + with open(path, "rb") as f: + try: + pcap = dpkt.pcap.Reader(f) + except ValueError: + f.seek(0) + pcap = dpkt.pcapng.Reader(f) + + count = 0 + for timestamp, buf in pcap: + if max_packets > 0 and count >= max_packets: + break + + packet = _parse_ethernet(timestamp, buf) + if packet is not None: + yield packet + count += 1 + + +def _parse_ethernet( + timestamp: float, + buf: bytes, +) -> PacketInfo | None: + """ + Parse an Ethernet frame into a PacketInfo + """ + import dpkt + + try: + eth = dpkt.ethernet.Ethernet(buf) + except (dpkt.NeedData, dpkt.UnpackError): + return None + + if not isinstance(eth.data, dpkt.ip.IP): + return None + + ip_pkt = eth.data + src_ip = socket.inet_ntoa(ip_pkt.src) + dst_ip = socket.inet_ntoa(ip_pkt.dst) + + if isinstance(ip_pkt.data, dpkt.tcp.TCP): + tcp = ip_pkt.data + return PacketInfo( + timestamp = timestamp, + src_ip = src_ip, + dst_ip = dst_ip, + src_port = tcp.sport, + dst_port = tcp.dport, + protocol = "tcp", + payload = bytes(tcp.data), + raw_length = len(buf), + tcp_flags = tcp.flags, + tcp_seq = tcp.seq, + ) + + if isinstance(ip_pkt.data, dpkt.udp.UDP): + udp = ip_pkt.data + return PacketInfo( + timestamp = timestamp, + src_ip = src_ip, + dst_ip = dst_ip, + src_port = udp.sport, + dst_port = udp.dport, + protocol = "udp", + payload = bytes(udp.data), + raw_length = len(buf), + ) + + return None diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/network/protocols.py b/third_party/community/dlp-scanner/src/dlp_scanner/network/protocols.py new file mode 100644 index 0000000..edf2abd --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/network/protocols.py @@ -0,0 +1,250 @@ +""" +©AngelaMos | 2026 +protocols.py +""" + + +import socket +from dataclasses import dataclass, field + +import structlog + + +log = structlog.get_logger() + +HTTP_METHODS: frozenset[bytes] = frozenset( + { + b"GET", + b"POST", + b"PUT", + b"DELETE", + b"HEAD", + b"OPTIONS", + b"PATCH", + } +) + +HTTP_RESPONSE_PREFIX: bytes = b"HTTP/" +TLS_RECORD_PREFIX: bytes = b"\x16\x03" +SSH_PREFIX: bytes = b"SSH-" +SMTP_BANNER_PREFIX: bytes = b"220 " +DNS_PORT: int = 53 + +DNS_QTYPES: dict[int, + str] = { + 1: "A", + 2: "NS", + 5: "CNAME", + 6: "SOA", + 12: "PTR", + 15: "MX", + 16: "TXT", + 28: "AAAA", + 33: "SRV", + 255: "ANY", + } + + +@dataclass(frozen = True, slots = True) +class HttpMessage: + """ + Parsed HTTP request or response + """ + + method: str + uri: str + version: str + headers: dict[str, str] + body: str + is_request: bool + + +@dataclass(frozen = True, slots = True) +class DnsQuery: + """ + A single DNS query entry + """ + + name: str + query_type: str + query_class: str + + +@dataclass(frozen = True, slots = True) +class DnsRecord: + """ + Parsed DNS message with queries and answers + """ + + queries: list[DnsQuery] = field(default_factory = list) + answers: list[str] = field(default_factory = list) + is_response: bool = False + transaction_id: int = 0 + + +def parse_http( + payload: bytes, +) -> HttpMessage | None: + """ + Parse HTTP request or response from raw payload + """ + import dpkt + + try: + if _is_http_request(payload): + req = dpkt.http.Request(payload) + headers = dict(req.headers) + body = _decode_body(req.body) + return HttpMessage( + method = req.method, + uri = req.uri, + version = req.version, + headers = headers, + body = body, + is_request = True, + ) + + if payload.startswith(HTTP_RESPONSE_PREFIX): + resp = dpkt.http.Response(payload) + headers = dict(resp.headers) + body = _decode_body(resp.body) + return HttpMessage( + method = "", + uri = "", + version = resp.version, + headers = headers, + body = body, + is_request = False, + ) + except (dpkt.NeedData, dpkt.UnpackError): + return None + + return None + + +def parse_dns( + payload: bytes, +) -> DnsRecord | None: + """ + Parse DNS message from raw UDP payload + """ + import dpkt + + try: + dns = dpkt.dns.DNS(payload) + except (dpkt.NeedData, dpkt.UnpackError): + return None + + queries: list[DnsQuery] = [] + for qd in dns.qd: + qtype = DNS_QTYPES.get(qd.type, str(qd.type)) + queries.append( + DnsQuery( + name = qd.name, + query_type = qtype, + query_class = str(qd.cls), + ) + ) + + answers: list[str] = [] + for an in dns.an: + _parse_answer(an, answers) + + return DnsRecord( + queries = queries, + answers = answers, + is_response = bool(dns.qr), + transaction_id = dns.id, + ) + + +def identify_protocol( + payload: bytes, +) -> str: + """ + Identify application-layer protocol via DPI + """ + if not payload: + return "unknown" + + if _is_http_request(payload): + return "http" + + if payload.startswith(HTTP_RESPONSE_PREFIX): + return "http" + + if (len(payload) > 2 and payload[: 2] == TLS_RECORD_PREFIX): + return "tls" + + if payload.startswith(SSH_PREFIX): + return "ssh" + + if payload.startswith(SMTP_BANNER_PREFIX): + return "smtp" + + return "unknown" + + +def _is_http_request(payload: bytes) -> bool: + """ + Check if payload starts with an HTTP method + """ + first_space = payload.find(b" ") + if first_space < 3 or first_space > 7: + return False + return payload[: first_space] in HTTP_METHODS + + +def _decode_body(body: bytes | str) -> str: + """ + Decode HTTP body bytes to string + """ + if isinstance(body, str): + return body + if not body: + return "" + try: + return body.decode("utf-8", errors = "replace") + except Exception: + return "" + + +def _parse_answer( + an: object, + answers: list[str], +) -> None: + """ + Parse a single DNS answer record + """ + try: + an_type = getattr(an, "type", 0) + rdata = getattr(an, "rdata", b"") + + if an_type == 1 and len(rdata) == 4: + answers.append(socket.inet_ntoa(rdata)) + elif an_type == 16 and rdata: + answers.append(_parse_txt_rdata(rdata)) + elif hasattr(an, "cname") and an.cname: + answers.append(an.cname) + elif hasattr(an, "name") and an.name: + answers.append(an.name) + except Exception: + pass + + +def _parse_txt_rdata(rdata: bytes) -> str: + """ + Parse TXT record rdata (length-prefixed strings) + """ + parts: list[str] = [] + i = 0 + while i < len(rdata): + length = rdata[i] + i += 1 + if i + length <= len(rdata): + chunk = rdata[i : i + length] + parts.append(chunk.decode("utf-8", errors = "replace")) + i += length + else: + break + return " ".join(parts) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/redaction.py b/third_party/community/dlp-scanner/src/dlp_scanner/redaction.py new file mode 100644 index 0000000..2982866 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/redaction.py @@ -0,0 +1,84 @@ +""" +©AngelaMos | 2026 +redaction.py +""" + + +from dlp_scanner.constants import RedactionStyle + + +REDACTED_LABEL: str = "[REDACTED]" +MASK_CHAR: str = "*" +SNIPPET_CONTEXT_CHARS: int = 20 + + +def redact( + text: str, + start: int, + end: int, + style: RedactionStyle = "partial", +) -> str: + """ + Redact matched text according to the chosen strategy + """ + matched = text[start : end] + + if style == "none": + return _build_snippet(text, start, end, matched) + + if style == "full": + return _build_snippet(text, start, end, REDACTED_LABEL) + + redacted = _partial_redact(matched) + return _build_snippet(text, start, end, redacted) + + +def _partial_redact(value: str) -> str: + """ + Partially mask a value, keeping the last few chars visible + """ + stripped = value.replace("-", "").replace(" ", "") + + if len(stripped) >= 9 and stripped.isdigit(): + return MASK_CHAR * (len(value) - 4) + value[-4 :] + + if "@" in value: + local, domain = value.rsplit("@", maxsplit = 1) + masked_local = local[0] + MASK_CHAR * (len(local) - 1) + return f"{masked_local}@{domain}" + + if len(value) > 8: + visible = max(4, len(value) // 4) + return (MASK_CHAR * (len(value) - visible) + value[-visible :]) + + return MASK_CHAR * len(value) + + +def _build_snippet( + text: str, + start: int, + end: int, + replacement: str, +) -> str: + """ + Build a snippet with context around the redacted match + """ + context_start = max(0, start - SNIPPET_CONTEXT_CHARS) + context_end = min(len(text), end + SNIPPET_CONTEXT_CHARS) + + prefix = text[context_start : start] + suffix = text[end : context_end] + + prefix = prefix.replace("\n", " ").strip() + suffix = suffix.replace("\n", " ").strip() + + parts: list[str] = [] + if context_start > 0: + parts.append("...") + parts.append(prefix) + parts.append(replacement) + parts.append(suffix) + if context_end < len(text): + parts.append("...") + + return "".join(parts) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/reporters/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/reporters/base.py b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/base.py new file mode 100644 index 0000000..05940a2 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/base.py @@ -0,0 +1,20 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +from typing import Protocol + +from dlp_scanner.models import ScanResult + + +class Reporter(Protocol): + """ + Protocol for all report output formats + """ + def generate(self, result: ScanResult) -> str: + """ + Generate report content as a string + """ + ... diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/reporters/console.py b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/console.py new file mode 100644 index 0000000..7c3f631 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/console.py @@ -0,0 +1,162 @@ +""" +©AngelaMos | 2026 +console.py +""" + + +from rich.console import Console +from rich.table import Table + +from dlp_scanner.constants import SEVERITY_COLORS +from dlp_scanner.models import ScanResult + + +TRUNCATE_SNIPPET: int = 60 + + +class ConsoleReporter: + """ + Rich console output with severity-colored tables + """ + def __init__( + self, + console: Console | None = None, + ) -> None: + self._console = console or Console() + + def generate(self, result: ScanResult) -> str: + """ + Generate plain-text table for piping + """ + lines: list[str] = [] + lines.append( + f"Scan {result.scan_id} | " + f"{len(result.findings)} findings | " + f"{result.targets_scanned} targets" + ) + lines.append("") + + for finding in result.findings: + loc = finding.location.uri + if finding.location.line is not None: + loc += f":{finding.location.line}" + if finding.location.table_name: + loc += (f" [{finding.location.table_name}]") + + snippet = finding.redacted_snippet + if len(snippet) > TRUNCATE_SNIPPET: + snippet = (snippet[: TRUNCATE_SNIPPET] + "...") + + frameworks = ", ".join(finding.compliance_frameworks) + + lines.append( + f"[{finding.severity.upper()}] " + f"{finding.rule_name} | " + f"{loc} | " + f"{finding.confidence:.0%} | " + f"{snippet} | " + f"{frameworks}" + ) + + lines.append("") + lines.append(_format_summary(result)) + return "\n".join(lines) + + def display(self, result: ScanResult) -> None: + """ + Print Rich-formatted table to console + """ + self._console.print() + + if not result.findings: + self._console.print("[green]No findings detected.[/green]") + _print_summary(self._console, result) + return + + table = Table( + title = ( + f"DLP Scan Results " + f"({len(result.findings)} findings)" + ), + show_lines = True, + ) + + table.add_column("Severity", width = 10, justify = "center") + table.add_column("Rule", width = 25) + table.add_column("Location", width = 30) + table.add_column("Confidence", width = 10) + table.add_column("Snippet", width = 40) + table.add_column("Compliance", width = 20) + + for finding in result.findings: + color = SEVERITY_COLORS.get(finding.severity, "white") + + loc = finding.location.uri + if finding.location.line is not None: + loc += f":{finding.location.line}" + if finding.location.table_name: + loc += (f"\n[{finding.location.table_name}]") + + snippet = finding.redacted_snippet + if len(snippet) > TRUNCATE_SNIPPET: + snippet = (snippet[: TRUNCATE_SNIPPET] + "...") + + frameworks = "\n".join(finding.compliance_frameworks) + + table.add_row( + f"[{color}]{finding.severity.upper()}" + f"[/{color}]", + finding.rule_name, + loc, + f"{finding.confidence:.0%}", + snippet, + frameworks, + ) + + self._console.print(table) + _print_summary(self._console, result) + + if result.errors: + self._console.print() + self._console.print( + f"[yellow]{len(result.errors)} " + f"error(s) during scan[/yellow]" + ) + + +def _format_summary(result: ScanResult) -> str: + """ + Format summary statistics as plain text + """ + by_sev = result.findings_by_severity + parts: list[str] = [] + for sev in ("critical", "high", "medium", "low"): + count = by_sev.get(sev, 0) + if count > 0: + parts.append(f"{sev}: {count}") + + summary = " | ".join(parts) if parts else "clean" + return ( + f"Summary: {summary} " + f"({result.targets_scanned} targets scanned)" + ) + + +def _print_summary(console: Console, result: ScanResult) -> None: + """ + Print formatted summary using Rich + """ + console.print() + by_sev = result.findings_by_severity + parts: list[str] = [] + for sev in ("critical", "high", "medium", "low"): + count = by_sev.get(sev, 0) + if count > 0: + color = SEVERITY_COLORS.get(sev, "white") + parts.append(f"[{color}]{sev}: {count}[/{color}]") + + summary = (" | ".join(parts) if parts else "[green]clean") + console.print( + f"Summary: {summary} " + f"({result.targets_scanned} targets)" + ) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/reporters/csv_report.py b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/csv_report.py new file mode 100644 index 0000000..6675a9a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/csv_report.py @@ -0,0 +1,64 @@ +""" +©AngelaMos | 2026 +csv_report.py +""" + + +import csv +import io + +from dlp_scanner.models import ScanResult + + +CSV_COLUMNS: list[str] = [ + "finding_id", + "scan_date", + "severity", + "confidence", + "rule_id", + "rule_name", + "source_type", + "uri", + "line", + "column", + "table_name", + "redacted_snippet", + "compliance_frameworks", + "remediation", +] + + +class CsvReporter: + """ + CSV export for compliance team consumption + """ + def generate(self, result: ScanResult) -> str: + """ + Generate CSV report as a string + """ + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(CSV_COLUMNS) + + for finding in result.findings: + frameworks = ";".join(finding.compliance_frameworks) + writer.writerow( + [ + finding.finding_id, + finding.detected_at.isoformat(), + finding.severity, + f"{finding.confidence:.4f}", + finding.rule_id, + finding.rule_name, + finding.location.source_type, + finding.location.uri, + finding.location.line or "", + finding.location.column or "", + finding.location.table_name or "", + finding.redacted_snippet, + frameworks, + finding.remediation, + ] + ) + + return output.getvalue() diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/reporters/json_report.py b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/json_report.py new file mode 100644 index 0000000..bb91c52 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/json_report.py @@ -0,0 +1,110 @@ +""" +©AngelaMos | 2026 +json_report.py +""" + + +from typing import Any + +import orjson + +from dlp_scanner.models import ScanResult + + +class JsonReporter: + """ + Structured JSON report with metadata and summary + """ + def generate(self, result: ScanResult) -> str: + """ + Generate JSON report as a formatted string + """ + report = _build_report(result) + return orjson.dumps( + report, + option = (orjson.OPT_INDENT_2 + | orjson.OPT_NON_STR_KEYS), + ).decode("utf-8") + + +def _build_report( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build the complete report structure + """ + return { + "scan_metadata": _build_metadata(result), + "findings": [_serialize_finding(f) for f in result.findings], + "summary": _build_summary(result), + } + + +def _build_metadata( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build scan metadata section + """ + return { + "scan_id": + result.scan_id, + "tool_version": + result.tool_version, + "scan_started_at": (result.scan_started_at.isoformat()), + "scan_completed_at": ( + result.scan_completed_at.isoformat() + if result.scan_completed_at else None + ), + "targets_scanned": + result.targets_scanned, + "total_findings": + len(result.findings), + "errors": + result.errors, + } + + +def _serialize_finding( + finding: Any, +) -> dict[str, + Any]: + """ + Serialize a single finding to dict + """ + return { + "finding_id": finding.finding_id, + "rule_id": finding.rule_id, + "rule_name": finding.rule_name, + "severity": finding.severity, + "confidence": round(finding.confidence, + 4), + "location": { + "source_type": (finding.location.source_type), + "uri": finding.location.uri, + "line": finding.location.line, + "column": finding.location.column, + "table_name": (finding.location.table_name), + "column_name": (finding.location.column_name), + }, + "redacted_snippet": (finding.redacted_snippet), + "compliance_frameworks": (finding.compliance_frameworks), + "remediation": finding.remediation, + "detected_at": (finding.detected_at.isoformat()), + } + + +def _build_summary( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build summary statistics section + """ + return { + "by_severity": result.findings_by_severity, + "by_rule": result.findings_by_rule, + "by_framework": result.findings_by_framework, + } diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/reporters/sarif.py b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/sarif.py new file mode 100644 index 0000000..3d309eb --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/reporters/sarif.py @@ -0,0 +1,171 @@ +""" +©AngelaMos | 2026 +sarif.py +""" + + +from typing import Any + +import orjson + +from dlp_scanner.constants import SARIF_SEVERITY_MAP +from dlp_scanner.models import Finding, ScanResult + + +SARIF_SCHEMA: str = ( + "https://raw.githubusercontent.com/" + "oasis-tcs/sarif-spec/main/sarif-2.1/" + "schema/sarif-schema-2.1.0.json" +) +SARIF_VERSION: str = "2.1.0" +TOOL_NAME: str = "dlp-scanner" + + +class SarifReporter: + """ + SARIF 2.1.0 output for CI/CD integration + """ + def generate(self, result: ScanResult) -> str: + """ + Generate SARIF 2.1.0 report as JSON string + """ + sarif = _build_sarif(result) + return orjson.dumps( + sarif, + option = (orjson.OPT_INDENT_2 + | orjson.OPT_NON_STR_KEYS), + ).decode("utf-8") + + +def _build_sarif( + result: ScanResult, +) -> dict[str, + Any]: + """ + Build complete SARIF document + """ + rules = _collect_rules(result.findings) + results = [_build_result(f, rules) for f in result.findings] + + return { + "$schema": + SARIF_SCHEMA, + "version": + SARIF_VERSION, + "runs": [ + { + "tool": { + "driver": { + "name": TOOL_NAME, + "version": (result.tool_version), + "rules": list(rules.values()), + } + }, + "results": results, + } + ], + } + + +def _collect_rules( + findings: list[Finding], +) -> dict[str, + dict[str, + Any]]: + """ + Collect unique rules from findings + """ + rules: dict[str, dict[str, Any]] = {} + + for finding in findings: + if finding.rule_id in rules: + continue + + rules[finding.rule_id] = { + "id": finding.rule_id, + "name": finding.rule_name, + "shortDescription": { + "text": finding.rule_name, + }, + "properties": { + "compliance_frameworks": (finding.compliance_frameworks), + }, + } + + return rules + + +def _build_result( + finding: Finding, + rules: dict[str, + dict[str, + Any]], +) -> dict[str, + Any]: + """ + Build a single SARIF result entry + """ + level = SARIF_SEVERITY_MAP.get(finding.severity, "note") + + location = _build_location(finding) + + return { + "ruleId": finding.rule_id, + "ruleIndex": list(rules.keys()).index(finding.rule_id), + "level": level, + "message": { + "text": ( + f"{finding.rule_name} detected " + f"with {finding.confidence:.0%} " + f"confidence" + ), + }, + "locations": [location], + "properties": { + "confidence": round(finding.confidence, + 4), + "redactedSnippet": (finding.redacted_snippet), + "complianceFrameworks": (finding.compliance_frameworks), + "remediation": finding.remediation, + }, + } + + +def _build_location( + finding: Finding, +) -> dict[str, + Any]: + """ + Build SARIF location from finding + """ + loc = finding.location + + physical: dict[str, + Any] = { + "artifactLocation": { + "uri": loc.uri + }, + } + + region: dict[str, Any] = {} + if loc.line is not None: + region["startLine"] = loc.line + if loc.column is not None: + region["startColumn"] = loc.column + if region: + physical["region"] = region + + result: dict[str, + Any] = { + "physicalLocation": physical, + } + + if loc.table_name: + result["logicalLocations"] = [ + { + "name": loc.table_name, + "kind": "table", + } + ] + + return result diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/scanners/__init__.py b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/__init__.py new file mode 100644 index 0000000..e1add2a --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/__init__.py @@ -0,0 +1,4 @@ +""" +©AngelaMos | 2026 +__init__.py +""" diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/scanners/base.py b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/base.py new file mode 100644 index 0000000..74f56f8 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/base.py @@ -0,0 +1,20 @@ +""" +©AngelaMos | 2026 +base.py +""" + + +from typing import Protocol + +from dlp_scanner.models import ScanResult + + +class Scanner(Protocol): + """ + Protocol for all scan strategies + """ + def scan(self, target: str) -> ScanResult: + """ + Scan the target and return aggregated results + """ + ... diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py new file mode 100644 index 0000000..a3a9553 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/db_scanner.py @@ -0,0 +1,531 @@ +""" +©AngelaMos | 2026 +db_scanner.py +""" + + +import asyncio +from datetime import datetime, UTC +from typing import Any +from urllib.parse import urlparse + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.constants import ( + TEXT_DB_COLUMN_TYPES_MYSQL, + TEXT_DB_COLUMN_TYPES_PG, +) +from dlp_scanner.detectors.base import DetectorMatch +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.models import ( + Location, + ScanResult, +) +from dlp_scanner.scoring import match_to_finding + + +log = structlog.get_logger() + +POSTGRES_SCHEMES: frozenset[str] = frozenset({ + "postgresql", + "postgres", +}) +MYSQL_SCHEMES: frozenset[str] = frozenset({ + "mysql", + "mysql+aiomysql", +}) +MONGODB_SCHEMES: frozenset[str] = frozenset({ + "mongodb", + "mongodb+srv", +}) +SQLITE_SCHEMES: frozenset[str] = frozenset({ + "sqlite", +}) + + +class DatabaseScanner: + """ + Scans database tables for sensitive data in text columns + """ + def __init__( + self, + config: ScanConfig, + registry: DetectorRegistry, + ) -> None: + self._db_config = config.database + self._detection_config = config.detection + self._redaction_style = config.output.redaction_style + self._registry = registry + + def scan(self, target: str) -> ScanResult: + """ + Scan a database identified by connection URI + """ + return asyncio.run(self._scan_async(target)) + + async def _scan_async( + self, + connection_uri: str, + ) -> ScanResult: + """ + Dispatch to the appropriate database scanner + """ + result = ScanResult() + parsed = urlparse(connection_uri) + scheme = parsed.scheme.lower() + + try: + if scheme in POSTGRES_SCHEMES: + await self._scan_postgres(connection_uri, result) + elif scheme in MYSQL_SCHEMES: + await self._scan_mysql(connection_uri, result) + elif scheme in MONGODB_SCHEMES: + await self._scan_mongodb(connection_uri, result) + elif scheme in SQLITE_SCHEMES: + await self._scan_sqlite(connection_uri, result) + else: + result.errors.append( + f"Unsupported database scheme: " + f"{scheme}" + ) + except Exception as exc: + log.warning( + "database_scan_failed", + scheme = scheme, + error = str(exc), + ) + result.errors.append(f"Database scan failed: {exc}") + + result.scan_completed_at = datetime.now(UTC) + return result + + async def _scan_postgres( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan PostgreSQL using asyncpg with TABLESAMPLE + """ + import asyncpg + + conn = await asyncpg.connect( + uri, + timeout = self._db_config.timeout_seconds, + ) + + try: + tables = await self._get_pg_tables(conn) + tables = self._filter_tables(tables) + + for table_name in tables: + text_cols = ( + await self._get_pg_text_columns(conn, + table_name) + ) + if not text_cols: + continue + + col_list = ", ".join(f'"{c}"' for c in text_cols) + query = ( + f"SELECT {col_list} " + f'FROM "{table_name}" ' + f"TABLESAMPLE BERNOULLI(" + f"{self._db_config.sample_percentage}" + f") LIMIT " + f"{self._db_config.max_rows_per_table}" + ) + + rows = await conn.fetch(query) + self._process_record_rows( + rows, + text_cols, + table_name, + uri, + result, + ) + result.targets_scanned += 1 + finally: + await conn.close() + + async def _get_pg_tables( + self, + conn: Any, + ) -> list[str]: + """ + List user tables in PostgreSQL + """ + rows = await conn.fetch( + "SELECT table_name " + "FROM information_schema.tables " + "WHERE table_schema = 'public' " + "AND table_type = 'BASE TABLE'" + ) + return [r["table_name"] for r in rows] + + async def _get_pg_text_columns( + self, + conn: Any, + table_name: str, + ) -> list[str]: + """ + Find text-type columns in a PostgreSQL table + """ + rows = await conn.fetch( + "SELECT column_name " + "FROM information_schema.columns " + "WHERE table_name = $1 " + "AND data_type = ANY($2::text[])", + table_name, + list(TEXT_DB_COLUMN_TYPES_PG), + ) + return [r["column_name"] for r in rows] + + async def _scan_mysql( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan MySQL using aiomysql with random sampling + """ + import aiomysql + + parsed = urlparse(uri) + conn = await aiomysql.connect( + host = parsed.hostname or "localhost", + port = parsed.port or 3306, + user = parsed.username or "root", + password = parsed.password or "", + db = parsed.path.lstrip("/"), + connect_timeout = (self._db_config.timeout_seconds), + ) + + try: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT table_name " + "FROM information_schema.tables " + "WHERE table_schema = DATABASE() " + "AND table_type = 'BASE TABLE'" + ) + raw_tables = await cur.fetchall() + tables = [r["TABLE_NAME"] for r in raw_tables] + tables = self._filter_tables(tables) + + for table_name in tables: + text_cols = ( + await self._get_mysql_text_cols(cur, + table_name) + ) + if not text_cols: + continue + + col_list = ", ".join(f"`{c}`" for c in text_cols) + limit = (self._db_config.max_rows_per_table) + await cur.execute( + f"SELECT {col_list} " + f"FROM `{table_name}` " + f"ORDER BY RAND() " + f"LIMIT {limit}" + ) + rows = await cur.fetchall() + self._process_dict_rows( + rows, + text_cols, + table_name, + uri, + result, + ) + result.targets_scanned += 1 + finally: + conn.close() + + async def _get_mysql_text_cols( + self, + cursor: Any, + table_name: str, + ) -> list[str]: + """ + Find text-type columns in a MySQL table + """ + placeholders = ",".join(["%s"] * len(TEXT_DB_COLUMN_TYPES_MYSQL)) + await cursor.execute( + "SELECT column_name " + "FROM information_schema.columns " + "WHERE table_name = %s " + "AND table_schema = DATABASE() " + f"AND data_type IN ({placeholders})", + (table_name, + *TEXT_DB_COLUMN_TYPES_MYSQL), + ) + rows = await cursor.fetchall() + return [r["COLUMN_NAME"] for r in rows] + + async def _scan_mongodb( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan MongoDB collections using pymongo async + """ + from pymongo import AsyncMongoClient + + parsed = urlparse(uri) + db_name = parsed.path.lstrip("/").split("?")[0] + + if not db_name: + result.errors.append("MongoDB URI must include database name") + return + + client: AsyncMongoClient[dict[str, Any]] = (AsyncMongoClient(uri)) + + try: + db = client[db_name] + collections = (await db.list_collection_names()) + collections = self._filter_tables(collections) + + for coll_name in collections: + coll = db[coll_name] + sample_size = (self._db_config.max_rows_per_table) + cursor = coll.aggregate( + [{ + "$sample": { + "size": sample_size + } + }] + ) + + async for doc in cursor: + text_parts: list[str] = [] + _extract_mongo_strings(doc, text_parts) + if not text_parts: + continue + + combined = "\n".join(text_parts) + matches = self._registry.detect(combined) + self._append_findings( + matches, + combined, + table_name = coll_name, + uri = uri, + result = result, + ) + + result.targets_scanned += 1 + finally: + client.close() + + async def _scan_sqlite( + self, + uri: str, + result: ScanResult, + ) -> None: + """ + Scan SQLite database using aiosqlite + """ + import aiosqlite + + parsed = urlparse(uri) + db_path = parsed.path + while db_path.startswith("//"): + db_path = db_path[1 :] + + async with aiosqlite.connect(db_path) as db: + cursor = await db.execute( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%'" + ) + rows = await cursor.fetchall() + tables = [r[0] for r in rows] + tables = self._filter_tables(tables) + + for table_name in tables: + text_cols = ( + await self._get_sqlite_text_cols(db, + table_name) + ) + if not text_cols: + continue + + col_list = ", ".join(f'"{c}"' for c in text_cols) + limit = (self._db_config.max_rows_per_table) + cursor = await db.execute( + f"SELECT {col_list} " + f'FROM "{table_name}" ' + f"ORDER BY RANDOM() " + f"LIMIT {limit}" + ) + fetched = await cursor.fetchall() + for row in fetched: + for idx, col_name in enumerate(text_cols): + val = row[idx] + if val is None: + continue + text = str(val) + if not text.strip(): + continue + matches = self._registry.detect(text) + self._append_findings( + matches, + text, + table_name = table_name, + column_name = col_name, + uri = uri, + result = result, + ) + result.targets_scanned += 1 + + async def _get_sqlite_text_cols( + self, + db: Any, + table_name: str, + ) -> list[str]: + """ + Find text-type columns in a SQLite table + """ + cursor = await db.execute(f'PRAGMA table_info("{table_name}")') + rows = await cursor.fetchall() + text_types = frozenset({"text", "varchar", "char", "clob"}) + return [ + r[1] + for r in rows + if r[2].lower() in text_types or "text" in r[2].lower() + ] + + def _filter_tables( + self, + tables: list[str], + ) -> list[str]: + """ + Apply include/exclude table filters + """ + include = self._db_config.include_tables + exclude = frozenset(self._db_config.exclude_tables) + + if include: + include_set = frozenset(include) + tables = [t for t in tables if t in include_set] + + return [t for t in tables if t not in exclude] + + def _process_record_rows( + self, + rows: list[Any], + columns: list[str], + table_name: str, + uri: str, + result: ScanResult, + ) -> None: + """ + Process asyncpg Record rows through detection + """ + for row in rows: + for col_name in columns: + val = row[col_name] + if val is None: + continue + text = str(val) + if not text.strip(): + continue + matches = self._registry.detect(text) + self._append_findings( + matches, + text, + table_name = table_name, + column_name = col_name, + uri = uri, + result = result, + ) + + def _process_dict_rows( + self, + rows: list[dict[str, + Any]], + columns: list[str], + table_name: str, + uri: str, + result: ScanResult, + ) -> None: + """ + Process dictionary rows through detection + """ + for row in rows: + for col_name in columns: + val = row.get(col_name) + if val is None: + continue + text = str(val) + if not text.strip(): + continue + matches = self._registry.detect(text) + self._append_findings( + matches, + text, + table_name = table_name, + column_name = col_name, + uri = uri, + result = result, + ) + + def _append_findings( + self, + matches: list[DetectorMatch], + text: str, + *, + table_name: str, + uri: str, + result: ScanResult, + column_name: str = "", + ) -> None: + """ + Convert detector matches to findings and append + """ + min_confidence = (self._detection_config.min_confidence) + + location = Location( + source_type = "database", + uri = uri, + table_name = table_name, + column_name = column_name or None, + ) + + for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, + text, + location, + self._redaction_style, + ) + result.findings.append(finding) + + +def _extract_mongo_strings( + doc: dict[str, + Any], + parts: list[str], + prefix: str = "", +) -> None: + """ + Recursively extract string values from a MongoDB document + """ + for key, val in doc.items(): + if key == "_id": + continue + key_path = (f"{prefix}.{key}" if prefix else key) + if isinstance(val, str) and val.strip(): + parts.append(f"{key_path}: {val}") + elif isinstance(val, dict): + _extract_mongo_strings(val, parts, key_path) + elif isinstance(val, list): + for item in val: + if (isinstance(item, str) and item.strip()): + parts.append(f"{key_path}: {item}") + elif isinstance(item, dict): + _extract_mongo_strings(item, parts, key_path) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py new file mode 100644 index 0000000..2046cbc --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/file_scanner.py @@ -0,0 +1,221 @@ +""" +©AngelaMos | 2026 +file_scanner.py +""" + + +import fnmatch +from datetime import datetime, UTC +from pathlib import Path + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.extractors.archive import ArchiveExtractor +from dlp_scanner.extractors.base import Extractor +from dlp_scanner.extractors.email import ( + EmlExtractor, + MsgExtractor, +) +from dlp_scanner.extractors.office import ( + DocxExtractor, + XlsExtractor, + XlsxExtractor, +) +from dlp_scanner.extractors.pdf import PDFExtractor +from dlp_scanner.extractors.plaintext import ( + PlaintextExtractor, +) +from dlp_scanner.extractors.structured import ( + AvroExtractor, + CsvExtractor, + JsonExtractor, + ParquetExtractor, + XmlExtractor, + YamlExtractor, +) +from dlp_scanner.models import ScanResult +from dlp_scanner.scoring import match_to_finding + + +log = structlog.get_logger() + +MB_BYTES: int = 1024 * 1024 + + +class FileScanner: + """ + Scans files in a directory tree for sensitive data + """ + def __init__( + self, + config: ScanConfig, + registry: DetectorRegistry, + ) -> None: + self._file_config = config.file + self._detection_config = config.detection + self._redaction_style = config.output.redaction_style + self._registry = registry + self._extension_map = _build_extension_map() + self._allowed_extensions = frozenset( + self._file_config.include_extensions + ) + + def scan(self, target: str) -> ScanResult: + """ + Walk a directory and scan all matching files + """ + result = ScanResult() + target_path = Path(target) + + if target_path.is_file(): + self._scan_file(target_path, result) + result.targets_scanned = 1 + elif target_path.is_dir(): + self._scan_directory(target_path, result) + else: + result.errors.append(f"Target not found: {target}") + + result.scan_completed_at = datetime.now(UTC) + return result + + def _scan_directory( + self, + directory: Path, + result: ScanResult, + ) -> None: + """ + Recursively walk a directory and scan matching files + """ + max_bytes = (self._file_config.max_file_size_mb * MB_BYTES) + iterator = ( + directory.rglob("*") + if self._file_config.recursive else directory.glob("*") + ) + + for path in iterator: + if not path.is_file(): + continue + + if self._is_excluded(path, directory): + continue + + suffix = _get_full_suffix(path) + if suffix not in self._allowed_extensions: + continue + + try: + file_size = path.stat().st_size + except OSError: + continue + + if file_size > max_bytes: + log.debug( + "file_skipped_too_large", + path = str(path), + size = file_size, + ) + continue + + if file_size == 0: + continue + + self._scan_file(path, result) + result.targets_scanned += 1 + + def _scan_file( + self, + path: Path, + result: ScanResult, + ) -> None: + """ + Extract text from a single file and run detection + """ + suffix = _get_full_suffix(path) + extractor = self._extension_map.get(suffix) + + if extractor is None: + return + + try: + chunks = extractor.extract(str(path)) + except Exception: + log.warning("extraction_failed", path = str(path)) + result.errors.append(f"Extraction failed: {path}") + return + + min_confidence = (self._detection_config.min_confidence) + + for chunk in chunks: + matches = self._registry.detect(chunk.text) + for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, + chunk.text, + chunk.location, + self._redaction_style, + ) + result.findings.append(finding) + + def _is_excluded( + self, + path: Path, + base: Path, + ) -> bool: + """ + Check if a path matches any exclude pattern + """ + relative = str(path.relative_to(base)) + for pattern in self._file_config.exclude_patterns: + if fnmatch.fnmatch(relative, pattern): + return True + if fnmatch.fnmatch(path.name, pattern): + return True + if any(fnmatch.fnmatch(part, pattern) for part in path.parts): + return True + return False + + +def _build_extension_map() -> dict[str, Extractor]: + """ + Build a mapping from file extension to extractor instance + """ + extractors: list[Extractor] = [ + PlaintextExtractor(), + PDFExtractor(), + DocxExtractor(), + XlsxExtractor(), + XlsExtractor(), + CsvExtractor(), + JsonExtractor(), + XmlExtractor(), + YamlExtractor(), + ParquetExtractor(), + AvroExtractor(), + ArchiveExtractor(), + EmlExtractor(), + MsgExtractor(), + ] + + ext_map: dict[str, Extractor] = {} + for extractor in extractors: + for ext in extractor.supported_extensions: + ext_map[ext] = extractor + + return ext_map + + +def _get_full_suffix(path: Path) -> str: + """ + Get full suffix including compound extensions + """ + name = path.name + if name.endswith(".tar.gz"): + return ".tar.gz" + if name.endswith(".tar.bz2"): + return ".tar.bz2" + return path.suffix.lower() diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py new file mode 100644 index 0000000..e7aa9f8 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/scanners/network_scanner.py @@ -0,0 +1,337 @@ +""" +©AngelaMos | 2026 +network_scanner.py +""" + + +from datetime import datetime, UTC +from pathlib import Path + +import structlog + +from dlp_scanner.config import ScanConfig +from dlp_scanner.detectors.registry import DetectorRegistry +from dlp_scanner.models import ( + Finding, + Location, + ScanResult, +) +from dlp_scanner.network.exfiltration import ( + DnsExfilDetector, + ExfilIndicator, + detect_base64_payload, +) +from dlp_scanner.network.flow_tracker import FlowTracker +from dlp_scanner.network.pcap import read_pcap +from dlp_scanner.network.protocols import ( + DNS_PORT, + identify_protocol, + parse_dns, + parse_http, +) +from dlp_scanner.scoring import match_to_finding + + +log = structlog.get_logger() + +EXFIL_RULE_MAP: dict[str, tuple[str, str]] = { + "dns_long_label": ( + "NET_DNS_EXFIL_LONG_LABEL", + "DNS Exfiltration: Long Label", + ), + "dns_high_entropy": ( + "NET_DNS_EXFIL_HIGH_ENTROPY", + "DNS Exfiltration: High Entropy Subdomain", + ), + "dns_long_qname": ( + "NET_DNS_EXFIL_LONG_QNAME", + "DNS Exfiltration: Long QNAME", + ), + "dns_txt_volume": ( + "NET_DNS_EXFIL_TXT_VOLUME", + "DNS Exfiltration: High TXT Volume", + ), + "base64_payload": ( + "NET_ENCODED_BASE64", + "Encoded Payload: Base64", + ), + "hex_payload": ( + "NET_ENCODED_HEX", + "Encoded Payload: Hex", + ), +} + + +class NetworkScanner: + """ + Scans network capture files for sensitive data in transit + """ + def __init__( + self, + config: ScanConfig, + registry: DetectorRegistry, + ) -> None: + self._net_config = config.network + self._detection_config = config.detection + self._redaction_style = config.output.redaction_style + self._registry = registry + + def scan(self, target: str) -> ScanResult: + """ + Scan a PCAP file for sensitive data in payloads + """ + result = ScanResult() + target_path = Path(target) + + if not target_path.exists(): + result.errors.append(f"PCAP file not found: {target}") + result.scan_completed_at = datetime.now(UTC) + return result + + try: + self._scan_pcap(target_path, result) + except Exception as exc: + log.warning( + "pcap_scan_failed", + path = str(target_path), + error = str(exc), + ) + result.errors.append(f"PCAP scan failed: {exc}") + + result.scan_completed_at = datetime.now(UTC) + return result + + def _scan_pcap( + self, + path: Path, + result: ScanResult, + ) -> None: + """ + Read packets, reassemble flows, and run detection + """ + tracker = FlowTracker() + dns_detector = DnsExfilDetector( + entropy_threshold = ( + self._net_config.dns_label_entropy_threshold + ), + ) + packet_count = 0 + + for packet in read_pcap( + path, + max_packets = self._net_config.max_packets, + ): + packet_count += 1 + tracker.add_packet(packet) + + if ( + packet.protocol == "udp" + and ( + DNS_PORT in ( + packet.src_port, + packet.dst_port, + ) + ) + ): + self._process_dns_packet( + packet.payload, + packet.src_ip, + packet.dst_ip, + path = path, + packet_num = packet_count, + dns_detector = dns_detector, + result = result, + ) + + if packet.payload: + exfil_indicators = detect_base64_payload( + packet.payload, + src_ip = packet.src_ip, + dst_ip = packet.dst_ip, + ) + for indicator in exfil_indicators: + finding = _indicator_to_finding( + indicator, + str(path), + packet_count, + ) + result.findings.append(finding) + + txt_indicators = dns_detector.check_txt_volume() + for indicator in txt_indicators: + finding = _indicator_to_finding( + indicator, + str(path), + packet_count, + ) + result.findings.append(finding) + + self._scan_reassembled_flows(tracker, path, result) + + result.targets_scanned = packet_count + + def _process_dns_packet( + self, + payload: bytes, + src_ip: str, + dst_ip: str, + *, + path: Path, + packet_num: int, + dns_detector: DnsExfilDetector, + result: ScanResult, + ) -> None: + """ + Parse DNS and check for exfiltration patterns + """ + dns_record = parse_dns(payload) + if dns_record is None: + return + + for query in dns_record.queries: + indicator = dns_detector.analyze_query( + query, + src_ip, + dst_ip, + ) + if indicator is not None: + finding = _indicator_to_finding( + indicator, + str(path), + packet_num, + ) + result.findings.append(finding) + + def _scan_reassembled_flows( + self, + tracker: FlowTracker, + path: Path, + result: ScanResult, + ) -> None: + """ + Reassemble TCP streams and scan for sensitive data + """ + min_confidence = self._detection_config.min_confidence + + for flow in tracker.get_flows(): + key = ( + flow.src_ip, + flow.dst_ip, + flow.src_port, + flow.dst_port, + ) + stream = tracker.reassemble_stream(key) + if not stream: + continue + + protocol = identify_protocol(stream) + text = self._extract_scannable_text( + stream, + protocol, + ) + + if not text or not text.strip(): + continue + + matches = self._registry.detect(text) + + location = Location( + source_type = "network", + uri = str(path), + ) + + for match in matches: + if match.score < min_confidence: + continue + + finding = match_to_finding( + match, + text, + location, + self._redaction_style, + ) + result.findings.append(finding) + + def _extract_scannable_text( + self, + stream: bytes, + protocol: str, + ) -> str: + """ + Extract text content from a reassembled stream + """ + if protocol == "http": + return self._extract_http_text(stream) + + if protocol in ("tls", "ssh"): + return "" + + try: + return stream.decode("utf-8", errors = "replace") + except Exception: + return "" + + def _extract_http_text( + self, + stream: bytes, + ) -> str: + """ + Extract scannable text from HTTP messages + """ + http_msg = parse_http(stream) + if http_msg is None: + try: + return stream.decode( + "utf-8", + errors = "replace", + ) + except Exception: + return "" + + parts: list[str] = [] + + if http_msg.is_request and http_msg.uri: + parts.append(http_msg.uri) + + for header_name in ("cookie", "authorization", "set-cookie"): + val = http_msg.headers.get(header_name, "") + if val: + parts.append(val) + + if http_msg.body: + parts.append(http_msg.body) + + return "\n".join(parts) + + +def _indicator_to_finding( + indicator: ExfilIndicator, + uri: str, + packet_num: int, +) -> Finding: + """ + Convert an exfiltration indicator to a Finding + """ + rule_id, rule_name = EXFIL_RULE_MAP.get( + indicator.indicator_type, + ("NET_EXFIL_UNKNOWN", "Network Exfiltration Indicator"), + ) + + severity = "high" if indicator.confidence >= 0.70 else "medium" + + location = Location( + source_type = "network", + uri = uri, + byte_offset = packet_num, + ) + + return Finding( + rule_id = rule_id, + rule_name = rule_name, + severity = severity, + confidence = indicator.confidence, + location = location, + redacted_snippet = indicator.evidence[:120], + compliance_frameworks = [], + remediation = indicator.description, + ) diff --git a/third_party/community/dlp-scanner/src/dlp_scanner/scoring.py b/third_party/community/dlp-scanner/src/dlp_scanner/scoring.py new file mode 100644 index 0000000..48b5682 --- /dev/null +++ b/third_party/community/dlp-scanner/src/dlp_scanner/scoring.py @@ -0,0 +1,52 @@ +""" +©AngelaMos | 2026 +scoring.py +""" + + +from dlp_scanner.compliance import ( + get_frameworks_for_rule, + get_remediation_for_rule, + score_to_severity, +) +from dlp_scanner.constants import RedactionStyle +from dlp_scanner.detectors.base import DetectorMatch +from dlp_scanner.models import Finding, Location +from dlp_scanner.redaction import redact + + +def match_to_finding( + match: DetectorMatch, + text: str, + location: Location, + redaction_style: RedactionStyle, +) -> Finding: + """ + Convert a detector match into a fully classified finding + """ + severity = score_to_severity(match.score) + frameworks = get_frameworks_for_rule(match.rule_id) + if match.compliance_frameworks: + combined = ( + set(frameworks) | set(match.compliance_frameworks) + ) + frameworks = sorted(combined) + remediation = get_remediation_for_rule(match.rule_id) + + snippet = redact( + text, + match.start, + match.end, + style = redaction_style, + ) + + return Finding( + rule_id = match.rule_id, + rule_name = match.rule_name, + severity = severity, + confidence = match.score, + location = location, + redacted_snippet = snippet, + compliance_frameworks = frameworks, + remediation = remediation, + ) diff --git a/third_party/community/dns-lookup/LICENSE b/third_party/community/dns-lookup/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/third_party/community/dns-lookup/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/third_party/community/dns-lookup/README.md b/third_party/community/dns-lookup/README.md new file mode 100644 index 0000000..fda2674 --- /dev/null +++ b/third_party/community/dns-lookup/README.md @@ -0,0 +1,67 @@ +```ruby +██████╗ ███╗ ██╗███████╗██╗ ██████╗ ██████╗ ██╗ ██╗██╗ ██╗██████╗ +██╔══██╗████╗ ██║██╔════╝██║ ██╔═══██╗██╔═══██╗██║ ██╔╝██║ ██║██╔══██╗ +██║ ██║██╔██╗ ██║███████╗██║ ██║ ██║██║ ██║█████╔╝ ██║ ██║██████╔╝ +██║ ██║██║╚██╗██║╚════██║██║ ██║ ██║██║ ██║██╔═██╗ ██║ ██║██╔═══╝ +██████╔╝██║ ╚████║███████║███████╗╚██████╔╝╚██████╔╝██║ ██╗╚██████╔╝██║ +╚═════╝ ╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%234-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/dns-lookup) +[![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![PyPI](https://img.shields.io/pypi/v/dnslookup-cli?color=3775A9&logo=pypi&logoColor=white)](https://pypi.org/project/dnslookup-cli/) + +> Professional DNS query CLI with Rich terminal output, reverse lookups, and WHOIS integration. + +*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +**[Screenshots & demo →](DEMO.md)** + +## What It Does + +- Query A, AAAA, MX, NS, TXT, CNAME, and SOA records with colored table output +- Reverse DNS lookup to resolve IP addresses back to hostnames +- Trace DNS resolution path from root servers to authoritative nameservers +- Batch lookups with concurrent queries for processing domain lists +- WHOIS integration for domain registration information +- JSON export for scripting and pipeline integration + +## Quick Start + +```bash +uv tool install dnslookup-cli +dnslookup query example.com +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Commands + +| Command | Description | +|---------|-------------| +| `dnslookup query` | Query DNS records for a domain with colored table output | +| `dnslookup reverse` | Resolve an IP address back to its hostname | +| `dnslookup trace` | Trace the DNS resolution path from root to authoritative servers | +| `dnslookup batch` | Query multiple domains concurrently from a file | +| `dnslookup whois` | Retrieve WHOIS registration information for a domain | + +## Learn + +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | Security theory and real-world breaches | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | + + +## License + +AGPL 3.0 diff --git a/third_party/community/dns-lookup/dnslookup/__init__.py b/third_party/community/dns-lookup/dnslookup/__init__.py new file mode 100644 index 0000000..2bdfd02 --- /dev/null +++ b/third_party/community/dns-lookup/dnslookup/__init__.py @@ -0,0 +1,37 @@ +""" +CarterPerez-dev | 2026 +__init__.py + +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣎⣉⡙⠒⠴⠏⠉⣙⣉⡒⠢⣀⣀⡀⠀⠀⠀⢀⡠⠤⠶⠲⠖⢆⠀⠀⠀⠀⠀⠀⠀ +⠀⢠⡤⠖⠒⠒⠶⠦⠤⠤⣄⣀⣠⠒⠉⢱⠓⣤⠧⠜⠊⠉⠒⢢⡱⣀⠙⣇⣭⠔⠛⠒⠛⠉⠁⠀⢀⠄⠀⠀⢺⠂⠀⠀⠀⠀⠀⠀ +⠀⢨⡇⠀⠀⣀⣀⠀⠀⠀⠀⠀⡨⠟⠋⣁⣝⡉⠆⡶⠭⢭⠒⠬⠶⢧⡀⢈⡓⡶⡗⠢⢄⣠⡾⠗⠉⠀⠀⠀⢸⡆⠀⠀⠀⠀⠀⠀ +⠀⠀⡇⠀⠀⠀⠉⠛⠖⠢⣤⠞⢱⡴⣩⢭⣀⣀⡼⠉⠉⠓⠒⠢⠴⠤⡌⠁⠈⠉⢳⠀⡀⠙⢢⡀⠀⠀⠀⠀⣺⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⢻⡀⠀⠀⠀⠀⢠⠎⠀⠀⠈⢠⠟⠀⠀⠀⠀⠀⠀⠀⠀⢠⠀⠈⠳⠤⠴⠺⣵⡁⢹⡭⠗⢅⡀⡀⠀⢀⡞⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠈⣯⠀⠀⣄⣀⡇⣔⣿⠔⡞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣇⣀⣀⠀⢀⠀⠀⠙⢄⡎⠱⡢⣨⠆⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠸⢦⠸⣅⠀⠀⠀⠈⣾⠃⠀⠀⢠⣀⠤⠀⠀⠀⠀⠒⠉⣉⢧⣤⣳⡎⢇⠀⢰⡊⠀⠀⠻⡈⠢⣤⣋⡀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⣨⠷⣩⠤⢤⡔⢊⠼⠦⢖⢳⣞⣡⠄⠀⡖⠂⠀⠀⠀⢸⠀⠉⠻⣿⡘⡀⠀⢗⠴⣔⡄⣵⡠⣋⡀⠉⠓⠦⣄⠀⠀⠀⠀ +⠀⠀⢀⣠⢧⣄⠉⡻⡝⡖⣏⣀⠐⢼⠛⢳⡇⠀⠀⡇⠀⠀⠀⠀⢸⡆⠀⠀⠈⢧⢣⠀⢸⠘⢏⠀⢿⢱⡈⣹⢳⠀⠀⡰⡃⠀⠀⠀ +⢠⡖⠁⠀⣜⠋⠈⠘⡳⢙⡟⢒⣶⠉⢱⢻⡇⠀⢀⠇⠀⢦⠀⠀⢸⡇⠀⠀⠀⠘⡜⡄⢸⣇⠈⢒⡞⢄⡇⢙⠏⠀⡰⢁⡜⠀⠀⠀ +⠀⠻⡄⠈⢞⠦⣤⡖⡇⠀⠈⡟⠿⠀⡘⣼⡇⠀⣼⣀⢰⢾⡀⠀⣾⡗⠚⠉⣁⣀⣣⣇⢸⣿⡄⠈⢆⣀⡴⠃⢀⡞⣡⠊⠀⠀⠀⠀ +⠀⢀⡽⡄⠀⠉⣟⣀⡇⠀⡀⡇⡇⣠⣳⣿⠁⢠⣿⡇⣸⣾⡇⢰⣧⣡⣾⣿⣿⣿⣿⢿⢿⣿⣷⡀⢸⠀⠙⢶⠋⠘⣅⠀⠀⠀⠀⠀ +⡴⠋⠀⠘⢤⡀⣔⠉⡇⢀⣥⣿⣿⠿⢿⣿⣿⣿⣻⣳⣿⣿⣵⡿⠻⠟⢩⣉⠛⢉⢷⣼⣾⣿⣿⣷⢾⠣⣀⠜⠀⢀⡽⠆⠀⠀⠀⠀ +⠙⢦⠀⠀⡜⠉⢈⢀⣴⠀⠋⢻⣧⡀⠹⡍⠛⣩⠋⠉⠉⠀⠀⠀⠀⠀⠒⠻⠾⠷⠻⢸⢻⢳⣹⣿⣸⠀⠀⡠⠞⠉⠀⠀⠀⠀⠀⠀ +⠀⠀⠙⢢⠇⠀⠈⠉⢸⠀⢸⢸⢻⡛⠲⠞⠒⠉⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⢸⣻⣾⡟⠁⡟⠖⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠘⠂⠤⢄⡀⢸⠀⢸⠈⣸⣿⣄⠀⠀⠀⠀⠀⠀⠨⣀⠇⠀⠀⠀⠀⠀⠀⢀⣹⡿⣿⠀⠄⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⢹⠀⢰⡆⡏⣿⣎⢧⠀⠀⠀⠀⠀⠀⣀⣀⣀⠀⠀⠀⠀⢀⣼⣿⣧⡏⢸⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⢸⠆⠘⡇⢻⡏⢻⣆⠳⣄⠀⠀⠀⠉⠁⠉⠉⠁⠀⢀⣴⡟⡏⣿⣿⠇⠈⠐⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠷⢸⢰⢸⡏⢣⡈⠓⣤⣀⡀⢀⠤⣄⣀⠔⠋⢸⡇⡇⡏⡺⠀⠁⠸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⢸⡀⠀⠀⠀⣼⢸⡇⢸⢘⣆⣈⠦⠛⠁⢀⡨⢿⣄⡴⠛⣧⡇⡇⠇⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣼⡇⠀⢀⠀⢇⣾⣇⢸⢸⡏⠤⠄⠒⠊⡁⣀⣈⡻⡀⣠⠟⣧⣿⠀⠀⠀⢠⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⢀⣏⣿⢀⠘⡆⠸⣫⠉⢻⢮⣱⠀⠈⠉⠉⢿⡆⣀⡘⢿⣀⠾⠣⡾⡄⠀⠀⢸⢣⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⢀⡔⠊⠉⠀⢹⢸⡄⢱⡀⢻⡖⠈⠉⠉⡆⠀⠀⠀⣾⠋⣹⡷⣠⠃⠀⢀⡣⠆⣀⡀⢸⣜⣆⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⣰⠏⠀⠀⠀⠀⠈⡞⣧⠀⢣⢮⢻⣲⠤⣀⣡⠀⠀⠀⠈⡆⣇⣾⠃⠀⠀⢸⡇⢸⣿⡇⢸⠀⠀⠀⠀⠀⠈⠉⠳⢦⣀⠀⠀ +⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⣧⢻⡀⠠⢿⡳⡳⡱⣄⢹⡀⠀⠀⠀⢱⠟⢸⠶⠖⠚⢻⡇⠸⣡⡇⠘⡆⠀⠀⠀⠀⠀⠀⠀⠀⠙⣆⠀ +⠀⠀⢠⠇⠀⠀⠀⠀⠀⠀⠘⡌⣇⠀⠸⠹⡻⣿⣢⣄⡇⠀⠀⠀⠀⠇⢈⠇⠀⠀⠀⡿⡀⢫⣧⠀⢇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⡆ +⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠰⣳⢺⣼⠀⡇⢣⠀⠉⠁⢹⠀⠀⠀⠀⠆⡜⠀⠀⠀⠀⠹⣧⠈⢿⡄⢸⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇ +⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⢏⣟⣧⡆⢰⣘⢤⠊⠉⠙⠦⣠⡖⠲⢼⣄⠀⠀⠀⠀⠀⠱⣣⠈⢻⣄⢳⠀⠀⣸⡆⠀⠀⠀⠀⠀⡇ + +""" + +__version__ = "0.1.1" +__author__ = "CarterPerez-dev" diff --git a/third_party/community/dns-lookup/dnslookup/__main__.py b/third_party/community/dns-lookup/dnslookup/__main__.py new file mode 100644 index 0000000..0b487f6 --- /dev/null +++ b/third_party/community/dns-lookup/dnslookup/__main__.py @@ -0,0 +1,11 @@ +""" +ⒸAngelaMos | 2026 +__main__.py + +Entry point for python -m dnslookup +""" + +from dnslookup.cli import app + +if __name__ == "__main__": + app() diff --git a/third_party/community/dns-lookup/dnslookup/cli.py b/third_party/community/dns-lookup/dnslookup/cli.py new file mode 100644 index 0000000..479e8b0 --- /dev/null +++ b/third_party/community/dns-lookup/dnslookup/cli.py @@ -0,0 +1,441 @@ +""" +ⒸAngelaMos | 2026 +cli.py + +Typer CLI application for DNS lookups + +Defines the dnslookup CLI using Typer with five commands: query, reverse, +trace, batch, and whois. Each command shows a Rich spinner during the +operation, then either renders formatted terminal output or JSON depending +on the --json flag. The batch command also supports writing JSON to a file +with --output. + +Key exports: + app - The Typer application instance, used as the entry point in __main__.py + +Connects to: + resolver.py - calls lookup(), reverse_lookup(), trace_dns(), batch_lookup() + output.py - calls all print_* functions and results_to_json(), trace_to_json() + whois_lookup.py - calls lookup_whois(), print_whois_result(), whois_to_json() + __init__.py - imports __version__ for the --version flag +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Annotated + +import typer +from rich.progress import ( + Progress, + SpinnerColumn, + TextColumn, +) + +from dnslookup import __version__ +from dnslookup.output import ( + console, + print_batch_progress_header, + print_batch_results, + print_errors, + print_header, + print_results_table, + print_reverse_result, + print_summary, + print_trace_result, + results_to_json, + trace_to_json, +) +from dnslookup.resolver import ( + ALL_RECORD_TYPES, + RecordType, + batch_lookup, + lookup, + reverse_lookup, + trace_dns, +) +from dnslookup.whois_lookup import ( + lookup_whois, + print_whois_result, + whois_to_json, +) + +app = typer.Typer( + name = "dnslookup", + help = + "[bold green]DNS Lookup Tool[/bold green] - Professional DNS query CLI with clean output", + rich_markup_mode = "rich", + no_args_is_help = True, +) + + +def version_callback(value: bool) -> None: + """ + Display version and exit + """ + if value: + console.print( + f"[bold cyan]dnslookup[/bold cyan] version [green]{__version__}[/green]" + ) + raise typer.Exit() + + +def parse_record_types(types_str: str) -> list[RecordType]: + """ + Parse comma separated record types string + """ + if types_str.upper() == "ALL": + return list(ALL_RECORD_TYPES) + + types = [] + for t in types_str.upper().split(","): + t = t.strip() + try: + types.append(RecordType(t)) + except ValueError: + console.print( + f"[yellow]Warning:[/yellow] Unknown record type '{t}', skipping" + ) + + return types if types else list(ALL_RECORD_TYPES) + + +@app.callback() +def main( + version: Annotated[ + bool | None, + typer.Option( + "--version", + "-v", + help = "Show version and exit", + callback = version_callback, + is_eager = True, + ), + ] = None, +) -> None: + """ + [bold green]DNS Lookup Tool[/bold green] + + Query DNS records, perform reverse lookups, trace resolution paths, + and retrieve WHOIS information with nice terminal output. + """ + pass + + +@app.command() +def query( + domain: Annotated[str, + typer.Argument(help = "Domain name to query")], + record_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help = + "Record types to query (A,AAAA,MX,NS,TXT,CNAME,SOA or ALL)", + ), + ] = "ALL", + server: Annotated[ + str | None, + typer.Option( + "--server", + "-s", + help = "DNS server to use (e.g., 8.8.8.8)", + ), + ] = None, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help = "Query timeout in seconds", + ), + ] = 5.0, + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]Query DNS records[/bold cyan] for a domain. + + Examples: + dnslookup query example.com + dnslookup query example.com --type A,MX + dnslookup query example.com --server 8.8.8.8 --json + """ + record_types = parse_record_types(record_type) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task(f"Querying {domain}...", total = None) + result = asyncio.run( + lookup(domain, + record_types, + server, + timeout) + ) + + if json_output: + console.print(results_to_json(result)) + else: + print_header(domain) + print_results_table(result) + print_errors(result) + print_summary(result) + + +@app.command() +def reverse( + ip: Annotated[ + str, + typer.Argument(help = "IP address for reverse lookup")], + server: Annotated[ + str | None, + typer.Option( + "--server", + "-s", + help = "DNS server to use", + ), + ] = None, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help = "Query timeout in seconds", + ), + ] = 5.0, + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]Reverse DNS lookup[/bold cyan] for an IP address. + + Examples: + dnslookup reverse 8.8.8.8 + dnslookup reverse 2606:4700:4700::1111 + """ + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task(f"Resolving {ip}...", total = None) + result = asyncio.run(reverse_lookup(ip, server, timeout)) + + if json_output: + console.print(results_to_json(result)) + else: + print_reverse_result(result) + + +@app.command() +def trace( + domain: Annotated[str, + typer.Argument(help = "Domain to trace")], + record_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help = "Record type to trace (default: A)", + ), + ] = "A", + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]Trace DNS resolution path[/bold cyan] from root to authoritative servers. + + Shows the complete resolution chain including root servers, TLD servers, + and authoritative nameservers. + + Examples: + dnslookup trace example.com + dnslookup trace example.com --type MX + """ + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task(f"Tracing {domain}...", total = None) + result = trace_dns(domain, record_type.upper()) + + if json_output: + console.print(trace_to_json(result)) + else: + print_trace_result(result) + + +@app.command() +def batch( + file: Annotated[ + Path, + typer. + Argument(help = "File containing domains (one per line)"), + ], + record_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help = "Record types to query", + ), + ] = "A,MX,NS", + server: Annotated[ + str | None, + typer.Option( + "--server", + "-s", + help = "DNS server to use", + ), + ] = None, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help = "Query timeout in seconds", + ), + ] = 5.0, + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help = "Output file for JSON results", + ), + ] = None, + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON to stdout", + ), + ] = False, +) -> None: + """ + [bold cyan]Batch DNS lookup[/bold cyan] for multiple domains from a file. + + The file should contain one domain per line. Empty lines and lines + starting with # are ignored. + + Examples: + dnslookup batch domains.txt + dnslookup batch domains.txt --type A,MX --output results.json + """ + if not file.exists(): + console.print(f"[red]Error:[/red] File not found: {file}") + raise typer.Exit(1) + + domains = [] + with open(file) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + domains.append(line) + + if not domains: + console.print( + "[yellow]Warning:[/yellow] No domains found in file" + ) + raise typer.Exit(0) + + record_types = parse_record_types(record_type) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task( + f"Querying {len(domains)} domains...", + total = None + ) + results = asyncio.run( + batch_lookup(domains, + record_types, + server, + timeout) + ) + + if json_output: + console.print(results_to_json(results)) + elif output: + with open(output, "w") as f: + f.write(results_to_json(results)) + console.print( + f"[green]:heavy_check_mark:[/green] Results saved to {output}" + ) + else: + print_batch_progress_header(len(domains)) + print_batch_results(results) + + +@app.command() +def whois( + domain: Annotated[str, + typer.Argument(help = "Domain to lookup")], + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]WHOIS lookup[/bold cyan] for domain registration information. + + Shows registrar, creation date, expiration date, name servers, + and other registration details. + + Examples: + dnslookup whois example.com + dnslookup whois google.com --json + """ + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task( + f"Looking up WHOIS for {domain}...", + total = None + ) + result = lookup_whois(domain) + + if json_output: + console.print(whois_to_json(result)) + else: + print_whois_result(result) + + +if __name__ == "__main__": + app() diff --git a/third_party/community/dns-lookup/dnslookup/output.py b/third_party/community/dns-lookup/dnslookup/output.py new file mode 100644 index 0000000..2a73496 --- /dev/null +++ b/third_party/community/dns-lookup/dnslookup/output.py @@ -0,0 +1,406 @@ +""" +ⒸAngelaMos | 2026 +output.py + +Rich terminal output formatting for DNS results + +Handles all visual presentation for query, reverse, trace, batch, and +WHOIS results. Uses Rich tables, panels, and tree structures with color +coding per record type. Also provides JSON serialization used when the +--json flag is passed from the CLI. + +Key exports: + console - Shared Rich Console instance imported by cli.py + print_results_table - Renders DNS records as a color-coded rounded table + print_reverse_result - Renders PTR lookup output with hostname table + print_trace_result - Renders the DNS resolution path as a Rich tree + print_batch_results - Renders a summary table for multiple domain results + results_to_json - Serializes one or more DNSResult objects to a JSON string + trace_to_json - Serializes a TraceResult to a JSON string + +Connects to: + resolver.py - imports DNSResult, TraceResult, RecordType for type annotations + cli.py - all print_* functions and JSON serializers called in command handlers +""" + +from __future__ import annotations + +import json +from typing import Any + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.tree import Tree + +from dnslookup.resolver import ( + DNSResult, + RecordType, + TraceResult, +) + +console = Console() + +RECORD_COLORS: dict[RecordType, + str] = { + RecordType.A: "green", + RecordType.AAAA: "blue", + RecordType.MX: "magenta", + RecordType.NS: "cyan", + RecordType.TXT: "yellow", + RecordType.CNAME: "red", + RecordType.SOA: "white", + RecordType.PTR: "bright_cyan", + } + + +def get_record_color(record_type: RecordType) -> str: + """ + Get the color for a record type + """ + return RECORD_COLORS.get(record_type, "white") + + +def format_ttl(ttl: int) -> str: + """ + Format TTL in human-readable format + """ + if ttl >= 86400: + days = ttl // 86400 + return f"{days}d" + elif ttl >= 3600: + hours = ttl // 3600 + return f"{hours}h" + elif ttl >= 60: + minutes = ttl // 60 + return f"{minutes}m" + return f"{ttl}s" + + +def print_header( + domain: str, + icon: str = ":globe_showing_americas:" +) -> None: + """ + Print a styled header for DNS lookup + """ + console.print() + console.print( + f"{icon} [bold cyan]DNS Lookup:[/bold cyan] [bold white]{domain}[/bold white]" + ) + console.rule(style = "blue") + + +def print_results_table(result: DNSResult) -> None: + """ + Display DNS results in a nice table + """ + if not result.records: + console.print( + Panel( + f"[yellow]No records found for {result.domain}[/yellow]", + title = "[yellow]Warning[/yellow]", + border_style = "yellow", + expand = False, + ) + ) + return + + table = Table( + title = "[bold]DNS Records[/bold]", + box = box.ROUNDED, + border_style = "blue", + row_styles = ["", + "dim"], + show_header = True, + header_style = "bold cyan", + ) + + table.add_column("Type", width = 8, no_wrap = True) + table.add_column("Value", style = "green", min_width = 30) + table.add_column( + "TTL", + justify = "right", + style = "dim", + width = 8 + ) + + for record in result.records: + color = get_record_color(record.record_type) + value = record.value + + if record.priority is not None: + value = f"{value} [dim](priority: {record.priority})[/dim]" + + table.add_row( + f"[{color}]{record.record_type}[/{color}]", + value, + format_ttl(record.ttl), + ) + + console.print(table) + + +def print_summary(result: DNSResult) -> None: + """ + Print summary line after results + """ + record_count = len(result.records) + time_str = f"{result.query_time_ms:.0f}ms" + + if record_count > 0: + console.print( + f"\n[green]:heavy_check_mark:[/green] Found [bold]{record_count}[/bold] record(s) in [cyan]{time_str}[/cyan]" + ) + else: + console.print( + f"\n[yellow]:warning:[/yellow] No records found ({time_str})" + ) + + if result.nameserver: + console.print(f"[dim]Nameserver: {result.nameserver}[/dim]") + + console.print() + + +def print_errors(result: DNSResult) -> None: + """ + Print any errors that occurred + """ + if result.errors: + for error in result.errors: + console.print(f"[red]:x:[/red] {error}") + + +def print_reverse_result(result: DNSResult) -> None: + """ + Display reverse DNS lookup results + """ + console.print() + console.print( + f":mag: [bold cyan]Reverse Lookup:[/bold cyan] [bold white]{result.domain}[/bold white]" + ) + console.rule(style = "blue") + + if result.records: + table = Table( + title = "[bold]PTR Records[/bold]", + box = box.ROUNDED, + border_style = "blue", + ) + table.add_column("IP Address", style = "cyan") + table.add_column("Hostname", style = "green") + table.add_column( + "TTL", + justify = "right", + style = "dim", + width = 8 + ) + + for record in result.records: + table.add_row( + result.domain, + record.value, + format_ttl(record.ttl), + ) + + console.print(table) + print_summary(result) + else: + print_errors(result) + console.print( + Panel( + f"[yellow]No PTR record found for {result.domain}[/yellow]", + border_style = "yellow", + expand = False, + ) + ) + console.print() + + +def print_trace_result(result: TraceResult) -> None: + """ + Display DNS trace as a tree visualization + """ + console.print() + console.print( + f":mag_right: [bold cyan]DNS Trace:[/bold cyan] [bold white]{result.domain}[/bold white]" + ) + console.rule(style = "blue") + + if result.error: + console.print(f"[red]:x:[/red] {result.error}") + console.print() + return + + tree = Tree( + "[bold blue]:globe_showing_americas: DNS Resolution Path[/bold blue]", + guide_style = "blue", + ) + + zone_nodes: dict[str, Any] = {} + + for hop in result.hops: + if hop.zone not in zone_nodes: + if hop.zone == ".": + zone_display = "[bold yellow][.] Root[/bold yellow]" + elif hop.zone.endswith("."): + zone_display = f"[bold yellow][{hop.zone}] TLD[/bold yellow]" + else: + zone_display = f"[bold yellow][{hop.zone}.] Authoritative[/bold yellow]" + + zone_node = tree.add(zone_display) + zone_nodes[hop.zone] = zone_node + else: + zone_node = zone_nodes[hop.zone] + + server_style = "green" if hop.is_authoritative else "cyan" + server_branch = zone_node.add( + f"[{server_style}]:arrow_right: {hop.server}[/{server_style}] [dim]({hop.server_ip})[/dim]" + ) + server_branch.add(f"[dim]{hop.response}[/dim]") + + console.print(tree) + + if result.final_answer: + console.print( + f"\n[green]:heavy_check_mark:[/green] Resolution complete: [bold green]{result.final_answer}[/bold green]" + ) + + hop_count = len(result.hops) + console.print(f"[dim]Total hops: {hop_count}[/dim]") + console.print() + + +def print_batch_progress_header(total: int) -> None: + """ + Print header for batch operations + """ + console.print() + console.print( + f":package: [bold cyan]Batch Lookup:[/bold cyan] [bold white]{total} domains[/bold white]" + ) + console.rule(style = "blue") + + +def print_batch_results(results: list[DNSResult]) -> None: + """ + Display batch lookup results in a summary table + """ + table = Table( + title = "[bold]Batch Results[/bold]", + box = box.ROUNDED, + border_style = "blue", + row_styles = ["", + "dim"], + ) + + table.add_column("Domain", style = "cyan", min_width = 25) + table.add_column("A", justify = "center", width = 15) + table.add_column("MX", justify = "center", width = 5) + table.add_column("NS", justify = "center", width = 5) + table.add_column( + "Time", + justify = "right", + style = "dim", + width = 8 + ) + + for result in results: + a_records = [ + r for r in result.records if r.record_type == RecordType.A + ] + mx_count = len( + [ + r for r in result.records + if r.record_type == RecordType.MX + ] + ) + ns_count = len( + [ + r for r in result.records + if r.record_type == RecordType.NS + ] + ) + + a_value = a_records[0].value if a_records else "[dim]-[/dim]" + mx_value = str(mx_count) if mx_count else "[dim]-[/dim]" + ns_value = str(ns_count) if ns_count else "[dim]-[/dim]" + + table.add_row( + result.domain, + a_value, + mx_value, + ns_value, + f"{result.query_time_ms:.0f}ms", + ) + + console.print(table) + + total_records = sum(len(r.records) for r in results) + total_time = sum(r.query_time_ms for r in results) + console.print( + f"\n[green]:heavy_check_mark:[/green] {len(results)} domains, {total_records} total records in {total_time:.0f}ms" + ) + console.print() + + +def results_to_json(results: list[DNSResult] | DNSResult) -> str: + """ + Convert results to JSON string + """ + if isinstance(results, DNSResult): + results = [results] + + data = [] + for result in results: + record_data = [ + { + "type": r.record_type.value, + "value": r.value, + "ttl": r.ttl, + "priority": r.priority, + } for r in result.records + ] + + data.append( + { + "domain": result.domain, + "records": record_data, + "errors": result.errors, + "query_time_ms": round(result.query_time_ms, + 2), + "nameserver": result.nameserver, + } + ) + + if len(data) == 1: + return json.dumps(data[0], indent = 2) + + return json.dumps(data, indent = 2) + + +def trace_to_json(result: TraceResult) -> str: + """ + Convert trace result to JSON string + """ + data = { + "domain": + result.domain, + "hops": [ + { + "zone": hop.zone, + "server": hop.server, + "server_ip": hop.server_ip, + "response": hop.response, + "is_authoritative": hop.is_authoritative, + } for hop in result.hops + ], + "final_answer": + result.final_answer, + "error": + result.error, + } + + return json.dumps(data, indent = 2) diff --git a/third_party/community/dns-lookup/dnslookup/resolver.py b/third_party/community/dns-lookup/dnslookup/resolver.py new file mode 100644 index 0000000..95d861e --- /dev/null +++ b/third_party/community/dns-lookup/dnslookup/resolver.py @@ -0,0 +1,424 @@ +""" +ⒸAngelaMos | 2026 +resolver.py + +Async DNS resolution with record type support + +Core DNS engine for the tool. Provides async functions for forward lookup, +reverse lookup, and trace using dnspython. Forward lookups fire all +record type queries concurrently via asyncio.gather. The trace function +walks the DNS hierarchy starting from root servers, following NS referrals +until it reaches an authoritative server with the final answer. + +Key exports: + RecordType - StrEnum of supported record types (A, AAAA, MX, NS, TXT, CNAME, SOA, PTR) + ALL_RECORD_TYPES - Default list used by forward lookups (excludes PTR) + DNSRecord - Single record with type, value, TTL, and optional priority + DNSResult - Full lookup result with records, errors, timing, and nameserver + TraceHop - One server queried during a trace with zone, IP, and response summary + TraceResult - Full trace path including all hops and the final resolved answer + lookup() - Async forward lookup for one domain across multiple record types + reverse_lookup() - Async PTR record lookup for an IP address + trace_dns() - Synchronous DNS trace from root servers to authoritative servers + batch_lookup() - Async concurrent lookup for a list of domains + +Connects to: + cli.py - all public functions and constants imported here + output.py - DNSResult, TraceResult, RecordType imported for display formatting +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +import dns.asyncresolver +import dns.exception +import dns.message +import dns.name +import dns.query +import dns.rcode +import dns.rdatatype +import dns.resolver +import dns.reversename + + +class RecordType(StrEnum): + """ + Supported DNS record types + """ + A = "A" + AAAA = "AAAA" + MX = "MX" + NS = "NS" + TXT = "TXT" + CNAME = "CNAME" + SOA = "SOA" + PTR = "PTR" + + +ALL_RECORD_TYPES = [ + RecordType.A, + RecordType.AAAA, + RecordType.MX, + RecordType.NS, + RecordType.TXT, + RecordType.CNAME, + RecordType.SOA, +] + + +@dataclass +class DNSRecord: + """ + Represents a single DNS record + """ + record_type: RecordType + value: str + ttl: int + priority: int | None = None + + +@dataclass +class DNSResult: + """ + Result of a DNS lookup + """ + domain: str + records: list[DNSRecord] = field(default_factory = list) + errors: list[str] = field(default_factory = list) + query_time_ms: float = 0.0 + nameserver: str | None = None + + +@dataclass +class TraceHop: + """ + Represents a single hop in DNS resolution trace + """ + zone: str + server: str + server_ip: str + response: str + is_authoritative: bool = False + + +@dataclass +class TraceResult: + """ + Result of a DNS trace + """ + domain: str + hops: list[TraceHop] = field(default_factory = list) + final_answer: str | None = None + error: str | None = None + + +def create_resolver( + nameserver: str | None = None, + timeout: float = 5.0, +) -> dns.asyncresolver.Resolver: + """ + Create a configured async DNS resolver + """ + resolver = dns.asyncresolver.Resolver() + resolver.timeout = timeout + resolver.lifetime = timeout * 2 + + if nameserver: + resolver.nameservers = [nameserver] + + return resolver + + +def extract_record_value(rdata: Any, + record_type: RecordType + ) -> tuple[str, + int | None]: + """ + Extract value and priority from rdata based on record type + """ + priority = None + + if record_type == RecordType.A or record_type == RecordType.AAAA: + value = rdata.address + elif record_type == RecordType.MX: + value = str(rdata.exchange).rstrip(".") + priority = rdata.preference + elif record_type in (RecordType.NS, + RecordType.CNAME, + RecordType.PTR): + value = str(rdata.target).rstrip(".") + elif record_type == RecordType.TXT: + value = rdata.to_text() + elif record_type == RecordType.SOA: + value = f"NS: {str(rdata.mname).rstrip('.')}, Serial: {rdata.serial}" + else: + value = rdata.to_text() + + return value, priority + + +async def query_record_type( + domain: str, + record_type: RecordType, + resolver: dns.asyncresolver.Resolver, +) -> list[DNSRecord]: + """ + Query a single record type for a domain + """ + records = [] + + try: + answers = await resolver.resolve(domain, record_type.value) + + for rdata in answers: + value, priority = extract_record_value(rdata, record_type) + records.append( + DNSRecord( + record_type = record_type, + value = value, + ttl = answers.rrset.ttl, + priority = priority, + ) + ) + except (dns.resolver.NXDOMAIN, + dns.resolver.NoAnswer, + dns.resolver.NoNameservers): + pass + except dns.exception.Timeout: + pass + + return records + + +async def lookup( + domain: str, + record_types: list[RecordType] | None = None, + nameserver: str | None = None, + timeout: float = 5.0, +) -> DNSResult: + """ + Perform DNS lookup for specified record types + """ + if record_types is None: + record_types = ALL_RECORD_TYPES + + resolver = create_resolver(nameserver, timeout) + result = DNSResult(domain = domain, nameserver = nameserver) + + start_time = time.perf_counter() + + tasks = [ + query_record_type(domain, + rt, + resolver) for rt in record_types + ] + query_results = await asyncio.gather( + *tasks, + return_exceptions = True + ) + + for i, query_result in enumerate(query_results): + if isinstance(query_result, Exception): + result.errors.append(f"{record_types[i]}: {query_result}") + else: + result.records.extend(query_result) + + result.query_time_ms = (time.perf_counter() - start_time) * 1000 + + return result + + +async def reverse_lookup( + ip_address: str, + nameserver: str | None = None, + timeout: float = 5.0, +) -> DNSResult: + """ + Perform reverse DNS lookup for an IP address + """ + resolver = create_resolver(nameserver, timeout) + result = DNSResult(domain = ip_address, nameserver = nameserver) + + start_time = time.perf_counter() + + try: + answers = await resolver.resolve_address(ip_address) + for rdata in answers: + result.records.append( + DNSRecord( + record_type = RecordType.PTR, + value = str(rdata.target).rstrip("."), + ttl = answers.rrset.ttl, + ) + ) + except dns.resolver.NXDOMAIN: + result.errors.append("No PTR record found") + except dns.resolver.NoAnswer: + result.errors.append("No answer from nameserver") + except dns.resolver.NoNameservers: + result.errors.append("No nameservers available") + except dns.exception.Timeout: + result.errors.append("Query timed out") + except dns.exception.DNSException as e: + result.errors.append(str(e)) + + result.query_time_ms = (time.perf_counter() - start_time) * 1000 + + return result + + +def trace_dns(domain: str, record_type: str = "A") -> TraceResult: + """ + Trace DNS resolution path from root to authoritative servers + """ + result = TraceResult(domain = domain) + + try: + name = dns.name.from_text(domain) + rdtype = dns.rdatatype.from_text(record_type) + + root_servers = [ + ("a.root-servers.net", + "198.41.0.4"), + ("b.root-servers.net", + "170.247.170.2"), + ("c.root-servers.net", + "192.33.4.12"), + ] + + current_servers = root_servers + current_zone = "." + + while True: + server_name, server_ip = current_servers[0] + + try: + query = dns.message.make_query(name, rdtype) + response = dns.query.udp( + query, + server_ip, + timeout = 3.0 + ) + + rcode = response.rcode() + + if rcode != dns.rcode.NOERROR: + result.error = f"DNS error: {dns.rcode.to_text(rcode)}" + break + + if response.answer: + for rrset in response.answer: + for rdata in rrset: + result.final_answer = str(rdata) + break + + result.hops.append( + TraceHop( + zone = current_zone, + server = server_name, + server_ip = server_ip, + response = + f"{record_type}: {result.final_answer}", + is_authoritative = True, + ) + ) + break + + if response.authority: + ns_records = [] + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.NS: + for rdata in rrset: + ns_name = str(rdata.target + ).rstrip(".") + ns_records.append(ns_name) + new_zone = str(rrset.name).rstrip(".") + if not new_zone: + new_zone = "." + + if ns_records: + referral_msg = f"Referred to {new_zone or 'next'} servers" + result.hops.append( + TraceHop( + zone = current_zone, + server = server_name, + server_ip = server_ip, + response = referral_msg, + ) + ) + + glue_ips = {} + if response.additional: + for rrset in response.additional: + if rrset.rdtype == dns.rdatatype.A: + for rdata in rrset: + glue_ips[str( + rrset.name + ).rstrip(".")] = rdata.address + + new_servers = [] + for ns in ns_records: + if ns in glue_ips: + new_servers.append((ns, glue_ips[ns])) + else: + try: + answers = dns.resolver.resolve( + ns, + "A" + ) + for rdata in answers: + new_servers.append( + (ns, + rdata.address) + ) + break + except dns.exception.DNSException: + continue + + if new_servers: + current_servers = new_servers + current_zone = new_zone + else: + result.error = "Could not resolve nameserver IPs" + break + else: + result.error = "No NS records in authority section" + break + else: + result.error = "No answer or authority in response" + break + + except dns.exception.Timeout: + result.error = f"Timeout querying {server_name}" + break + except dns.exception.DNSException as e: + result.error = str(e) + break + + except Exception as e: + result.error = str(e) + + return result + + +async def batch_lookup( + domains: list[str], + record_types: list[RecordType] | None = None, + nameserver: str | None = None, + timeout: float = 5.0, +) -> list[DNSResult]: + """ + Perform DNS lookups for multiple domains concurrently + """ + tasks = [ + lookup(domain, + record_types, + nameserver, + timeout) for domain in domains + ] + return await asyncio.gather(*tasks) diff --git a/third_party/community/dns-lookup/dnslookup/whois_lookup.py b/third_party/community/dns-lookup/dnslookup/whois_lookup.py new file mode 100644 index 0000000..edb5082 --- /dev/null +++ b/third_party/community/dns-lookup/dnslookup/whois_lookup.py @@ -0,0 +1,241 @@ +""" +ⒸAngelaMos | 2026 +whois_lookup.py + +WHOIS domain information lookup and display + +Wraps the python-whois library to query domain registration data and +normalize results into the WhoisResult dataclass. Handles the inconsistent +responses that whois returns across registrars (single values vs lists, +missing fields). Also provides Rich table rendering and JSON serialization +for the whois CLI command. + +Key exports: + WhoisResult - Dataclass with registrar, dates, name servers, DNSSEC, and error fields + lookup_whois() - Queries WHOIS and returns a normalized WhoisResult + print_whois_result() - Renders WhoisResult as a two-column Rich table + whois_to_json() - Serializes WhoisResult to a JSON string with ISO date formatting + +Connects to: + cli.py - lookup_whois(), print_whois_result(), whois_to_json() called in the whois command +""" + +from __future__ import annotations + +import json +from dataclasses import ( + dataclass, + field, +) +from datetime import datetime +from typing import Any + +import whois +from rich import box +from rich.console import Console +from rich.table import Table + +console = Console() + + +@dataclass +class WhoisResult: + """ + WHOIS lookup result + """ + domain: str + registrar: str | None = None + creation_date: datetime | None = None + expiration_date: datetime | None = None + updated_date: datetime | None = None + status: list[str] = field(default_factory = list) + name_servers: list[str] = field(default_factory = list) + registrant: str | None = None + registrant_country: str | None = None + dnssec: str | None = None + error: str | None = None + + +def format_date(dt: datetime | list | None) -> str: + """ + Format datetime for display + """ + if dt is None: + return "[dim]-[/dim]" + + if isinstance(dt, list): + dt = dt[0] if dt else None + + if dt is None: + return "[dim]-[/dim]" + + if isinstance(dt, datetime): + return dt.strftime("%Y-%m-%d") + + return str(dt) + + +def lookup_whois(domain: str) -> WhoisResult: + """ + Perform WHOIS lookup for a domain + """ + result = WhoisResult(domain = domain) + + try: + w = whois.whois(domain) + + if w is None or (hasattr(w, + "domain_name") + and w.domain_name is None): + result.error = "Domain not found or WHOIS data unavailable" + return result + + result.registrar = w.registrar if hasattr( + w, + "registrar" + ) else None + result.creation_date = w.creation_date if hasattr( + w, + "creation_date" + ) else None + result.expiration_date = w.expiration_date if hasattr( + w, + "expiration_date" + ) else None + result.updated_date = w.updated_date if hasattr( + w, + "updated_date" + ) else None + + if hasattr(w, "status"): + status = w.status + if isinstance(status, str): + result.status = [status] + elif isinstance(status, list): + result.status = status + else: + result.status = [] + + if hasattr(w, "name_servers") and w.name_servers: + ns = w.name_servers + if isinstance(ns, str): + result.name_servers = [ns.lower()] + elif isinstance(ns, list): + result.name_servers = [n.lower() for n in ns if n] + else: + result.name_servers = [] + + if hasattr(w, "org"): + result.registrant = w.org + elif hasattr(w, "name"): + result.registrant = w.name + + if hasattr(w, "country"): + result.registrant_country = w.country + + if hasattr(w, "dnssec"): + result.dnssec = str(w.dnssec) if w.dnssec else None + + except Exception as e: + result.error = str(e) + + return result + + +def print_whois_result(result: WhoisResult) -> None: + """ + Display WHOIS result in a nice table + """ + console.print() + console.print( + f":clipboard: [bold cyan]WHOIS:[/bold cyan] [bold white]{result.domain}[/bold white]" + ) + console.rule(style = "blue") + + if result.error: + console.print(f"[red]:x:[/red] {result.error}") + console.print() + return + + table = Table( + box = box.ROUNDED, + border_style = "blue", + show_header = False, + padding = (0, + 1), + ) + + table.add_column("Field", style = "cyan", width = 15) + table.add_column("Value", style = "green") + + if result.registrar: + table.add_row("Registrar", result.registrar) + + if result.registrant: + table.add_row("Registrant", result.registrant) + + if result.registrant_country: + table.add_row("Country", result.registrant_country) + + table.add_row("Created", format_date(result.creation_date)) + table.add_row("Expires", format_date(result.expiration_date)) + table.add_row("Updated", format_date(result.updated_date)) + + if result.status: + status_display = result.status[0] if len( + result.status + ) == 1 else f"{len(result.status)} statuses" + table.add_row("Status", status_display) + + if result.name_servers: + for i, ns in enumerate(result.name_servers[: 4]): + label = "Name Servers" if i == 0 else "" + table.add_row(label, ns) + + if len(result.name_servers) > 4: + table.add_row( + "", + f"[dim]... and {len(result.name_servers) - 4} more[/dim]" + ) + + if result.dnssec: + table.add_row("DNSSEC", result.dnssec) + + console.print(table) + console.print() + + +def whois_to_json(result: WhoisResult) -> str: + """ + Convert WHOIS result to JSON string + """ + def serialize_date(dt: datetime | list | None) -> str | None: + if dt is None: + return None + if isinstance(dt, list): + dt = dt[0] if dt else None + if isinstance(dt, datetime): + return dt.isoformat() + return str(dt) if dt else None + + data: dict[str, + Any] = { + "domain": result.domain, + "registrar": result.registrar, + "creation_date": + serialize_date(result.creation_date), + "expiration_date": serialize_date( + result.expiration_date + ), + "updated_date": serialize_date( + result.updated_date + ), + "status": result.status, + "name_servers": result.name_servers, + "registrant": result.registrant, + "registrant_country": result.registrant_country, + "dnssec": result.dnssec, + "error": result.error, + } + + return json.dumps(data, indent = 2) diff --git a/third_party/community/dns-lookup/pyproject.toml b/third_party/community/dns-lookup/pyproject.toml new file mode 100644 index 0000000..9b18dd9 --- /dev/null +++ b/third_party/community/dns-lookup/pyproject.toml @@ -0,0 +1,93 @@ +[project] +name = "dnslookup-cli" +version = "0.1.1" +description = "Professional DNS lookup CLI with beautiful Rich output" +readme = "README.md" +requires-python = ">=3.13" +license = { text = "MIT" } +authors = [{ name = "CarterPerez-dev", email = "support@certgames.com" }] +keywords = ["dns", "lookup", "cli", "networking", "security"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: Name Service (DNS)", + "Topic :: System :: Networking", + "Topic :: Utilities", +] +dependencies = [ + "dnspython>=2.8.0", + "rich>=14.2.0", + "typer>=0.20.0", + "python-whois>=0.9.6", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=6.0.0", + "ruff>=0.9.0", + "mypy>=1.15.0", +] + +[project.urls] +Homepage = "https://pypi.org/project/dnslookup-cli/" +Documentation = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/dns-lookup/README.md" +Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects" +Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues" + + +[project.scripts] +dnslookup = "dnslookup.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["dnslookup"] + +[tool.ruff] +target-version = "py313" +line-length = 95 + +[tool.ruff.lint] +select = ["E", "F", "W", "B", "C4", "UP", "SIM", "I"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +strict_optional = true +disallow_untyped_defs = false +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = ["dns.*", "whois.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["dnslookup.resolver"] +disable_error_code = ["union-attr", "attr-defined", "arg-type"] + +[tool.pylint] +ignore-paths = ["dnslookup", "tests"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = "-v --tb=short" diff --git a/third_party/community/imports.json b/third_party/community/imports.json index 11b95c1..da34718 100644 --- a/third_party/community/imports.json +++ b/third_party/community/imports.json @@ -56,6 +56,366 @@ "git_blob_sha1": "6185c772eee2eab06bada5c03a541f14659b9d0b" } ] + }, + { + "name": "linux-cis-hardening-auditor", + "repository": "CarterPerez-dev/Cybersecurity-Projects", + "upstream_path": "PROJECTS/beginner/linux-cis-hardening-auditor", + "commit": "6c8ac8547cf52edaa06935e637a1fe11ac45b865", + "license": "AGPL-3.0-or-later", + "credit": "AngelaMos (2026); distributed in CarterPerez-dev/Cybersecurity-Projects", + "root": "third_party/community/linux-cis-hardening-auditor", + "scope": "Verbatim runtime source and original license/docs; upstream tests, learning modules and generated artifacts omitted.", + "files": [ + { + "path": "src/cisaudit.sh", + "git_blob_sha1": "e6796c3a48946bde33469761979618c5de46b637" + }, + { + "path": "src/checks/01_initial_setup.sh", + "git_blob_sha1": "2bab95142b82c2d328f1efec5b7e64930aa9c7ac" + }, + { + "path": "src/checks/02_services.sh", + "git_blob_sha1": "9c0fb4c0984a64ec3eb734d41639234d72f050e2" + }, + { + "path": "src/checks/03_network.sh", + "git_blob_sha1": "89743b16531c9808d7301a2eb1af70dc86f88b52" + }, + { + "path": "src/checks/04_logging.sh", + "git_blob_sha1": "11dea8e5fe56892043c07f85effa180a8bf355f5" + }, + { + "path": "src/checks/05_access.sh", + "git_blob_sha1": "a9828d0e0763e2560dc9a41da44b0559305e4c9b" + }, + { + "path": "src/checks/05_access_password.sh", + "git_blob_sha1": "d6ed503a7f931732517a054bd0598fee6144eb58" + }, + { + "path": "src/checks/06_maintenance.sh", + "git_blob_sha1": "c3acc0f6224bf0bfbc06b11ed04a208c76b65526" + }, + { + "path": "src/controls/registry_data.sh", + "git_blob_sha1": "269c8dbef7d2cbff63da91ce6882a0b2a91d90b5" + }, + { + "path": "src/lib/baseline.sh", + "git_blob_sha1": "7799c0e76908c5f4d273604a15bb54aab829ee01" + }, + { + "path": "src/lib/constants.sh", + "git_blob_sha1": "7231f1a279ef0a5fa8101f8ac88e4b0f9b8b086b" + }, + { + "path": "src/lib/engine.sh", + "git_blob_sha1": "48c4faa737cb7602cb24aa925e383659ad552395" + }, + { + "path": "src/lib/registry.sh", + "git_blob_sha1": "d24794d4e7dc08cc07d5c65cd0478a9ac157901f" + }, + { + "path": "src/lib/report_html.sh", + "git_blob_sha1": "8660441108795ffa4810a2ef5ec778eb68607783" + }, + { + "path": "src/lib/report_json.sh", + "git_blob_sha1": "144eaa0040b0c70f0ba360ac0936081cfe65003e" + }, + { + "path": "src/lib/report_terminal.sh", + "git_blob_sha1": "d0652c3a100d734d5944779d8aad69daf92f9210" + }, + { + "path": "src/lib/utils.sh", + "git_blob_sha1": "82d7aa9b6382d9e0361654eaf74672ff732a11e4" + }, + { + "path": "README.md", + "git_blob_sha1": "0f4c2c2e04303b4fdefbaa3dd1a76c7808c713e9" + }, + { + "path": "LICENSE", + "git_blob_sha1": "0ad25db4bd1d86c452db3f9602ccdbe172438f52" + }, + { + "path": "DEMO.md", + "git_blob_sha1": "063aeec79915c63ebb5c22fdec43c10ad53f0f88" + } + ] + }, + { + "name": "dlp-scanner", + "repository": "CarterPerez-dev/Cybersecurity-Projects", + "upstream_path": "PROJECTS/intermediate/dlp-scanner", + "commit": "6c8ac8547cf52edaa06935e637a1fe11ac45b865", + "license": "AGPL-3.0-or-later", + "credit": "AngelaMos (2026); distributed in CarterPerez-dev/Cybersecurity-Projects", + "root": "third_party/community/dlp-scanner", + "scope": "Verbatim runtime source and original license/docs; upstream tests, learning modules and generated artifacts omitted.", + "files": [ + { + "path": "src/dlp_scanner/__init__.py", + "git_blob_sha1": "4e0a5ce65f4354d5bb3182f7f46a24e4e4aadf38" + }, + { + "path": "src/dlp_scanner/cli.py", + "git_blob_sha1": "1cff3630df429ced3bd4c724daf347fe35ee53b1" + }, + { + "path": "src/dlp_scanner/compliance.py", + "git_blob_sha1": "8761f09593c4ada3a36143d6f74da1b57e226806" + }, + { + "path": "src/dlp_scanner/config.py", + "git_blob_sha1": "73813991521a39e467bb0751f4384638acd3947f" + }, + { + "path": "src/dlp_scanner/constants.py", + "git_blob_sha1": "41bf3850c32450ef6234cc8ad6ba2712407a6466" + }, + { + "path": "src/dlp_scanner/engine.py", + "git_blob_sha1": "b45f5b185c05d9e9bba74961e581deff401f6375" + }, + { + "path": "src/dlp_scanner/log.py", + "git_blob_sha1": "e9ee554678c6dfb960c64c00572a3f13623d4c78" + }, + { + "path": "src/dlp_scanner/models.py", + "git_blob_sha1": "2c5edab35760c78dc251747f7c124df95163d476" + }, + { + "path": "src/dlp_scanner/redaction.py", + "git_blob_sha1": "2982866b9ae7046c51bf1638627f82592e150b53" + }, + { + "path": "src/dlp_scanner/scoring.py", + "git_blob_sha1": "48b5682782deb9fa208d0d76bb360fe08eae0d40" + }, + { + "path": "src/dlp_scanner/commands/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/commands/report.py", + "git_blob_sha1": "bae4109f5fd9b70d5dde1cab84e400e7ec9d9ed3" + }, + { + "path": "src/dlp_scanner/commands/scan.py", + "git_blob_sha1": "cd3fd366fea540a7fdedc77caca3274766a27425" + }, + { + "path": "src/dlp_scanner/detectors/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/detectors/base.py", + "git_blob_sha1": "d8438432041377b9b1218f7796bff78f2184ff00" + }, + { + "path": "src/dlp_scanner/detectors/context.py", + "git_blob_sha1": "7330e21c6bb28d18a495274229423a73e9904124" + }, + { + "path": "src/dlp_scanner/detectors/entropy.py", + "git_blob_sha1": "5ed0385107ed9bac475aad3827dc78565e1dff3e" + }, + { + "path": "src/dlp_scanner/detectors/pattern.py", + "git_blob_sha1": "060c771402d22a4580824b8c3cdf065c03745a09" + }, + { + "path": "src/dlp_scanner/detectors/registry.py", + "git_blob_sha1": "635e8a1cdd20b92afcdb1351ec1467c2eb7b3408" + }, + { + "path": "src/dlp_scanner/detectors/rules/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/detectors/rules/credentials.py", + "git_blob_sha1": "afffee1b3f3191fc8413cbe978140aa6397385bc" + }, + { + "path": "src/dlp_scanner/detectors/rules/financial.py", + "git_blob_sha1": "835580187dc0d4ca8da1c3b0410ab4e6fc9decce" + }, + { + "path": "src/dlp_scanner/detectors/rules/health.py", + "git_blob_sha1": "ead20a9c4e02ae04619eae2b8df75d81d7833e5d" + }, + { + "path": "src/dlp_scanner/detectors/rules/pii.py", + "git_blob_sha1": "c543c7853e715850806b046bf15ec00e90497a09" + }, + { + "path": "src/dlp_scanner/extractors/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/extractors/archive.py", + "git_blob_sha1": "18fe4a39a2c5f7c01311f462ef0e78f1c5878b9b" + }, + { + "path": "src/dlp_scanner/extractors/base.py", + "git_blob_sha1": "2e008b1e43b430d09d50c2844f55b845600ed4ec" + }, + { + "path": "src/dlp_scanner/extractors/email.py", + "git_blob_sha1": "7dba9cf5d20595fd51f58a96027bdd6acd5bd869" + }, + { + "path": "src/dlp_scanner/extractors/office.py", + "git_blob_sha1": "25f6497f1ab2dc722d61cf8c90a4f94e25435235" + }, + { + "path": "src/dlp_scanner/extractors/pdf.py", + "git_blob_sha1": "07d938a50b1c1646cdad15419ba6b0aa9d5babdc" + }, + { + "path": "src/dlp_scanner/extractors/plaintext.py", + "git_blob_sha1": "5771447f8740c2954d57cf5ed25928f4bcb9a615" + }, + { + "path": "src/dlp_scanner/extractors/structured.py", + "git_blob_sha1": "52eec32d865561e09e7e0c12753a70906921da82" + }, + { + "path": "src/dlp_scanner/network/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/network/exfiltration.py", + "git_blob_sha1": "b10f48459176468e4a82b5fb8b8f44333945aa0e" + }, + { + "path": "src/dlp_scanner/network/flow_tracker.py", + "git_blob_sha1": "b5fb651920ee751fdef43638f895a3c29dec2936" + }, + { + "path": "src/dlp_scanner/network/pcap.py", + "git_blob_sha1": "6bd1f4b290006f27a67996e52933addb42f6f59b" + }, + { + "path": "src/dlp_scanner/network/protocols.py", + "git_blob_sha1": "edf2abda1c08b14216fbf1361dfc6ffbb304c8af" + }, + { + "path": "src/dlp_scanner/reporters/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/reporters/base.py", + "git_blob_sha1": "05940a2023b89b25b29ddfa0139b120d0b9baf38" + }, + { + "path": "src/dlp_scanner/reporters/console.py", + "git_blob_sha1": "7c3f6315bfdfade0a095f27a8cbf6d80e3c459c7" + }, + { + "path": "src/dlp_scanner/reporters/csv_report.py", + "git_blob_sha1": "6675a9abcd7e139b90da1704de66302ab5f1c670" + }, + { + "path": "src/dlp_scanner/reporters/json_report.py", + "git_blob_sha1": "bb91c5297acba91ce431048290f33e9411d2bc90" + }, + { + "path": "src/dlp_scanner/reporters/sarif.py", + "git_blob_sha1": "3d309ebd668845a180f7c21be4669044bf9ea154" + }, + { + "path": "src/dlp_scanner/scanners/__init__.py", + "git_blob_sha1": "e1add2a99827a0287bdd84beb838f5f37159a48c" + }, + { + "path": "src/dlp_scanner/scanners/base.py", + "git_blob_sha1": "74f56f8d5883e3f42d2d8c8d1fdf306a32a7b6f9" + }, + { + "path": "src/dlp_scanner/scanners/db_scanner.py", + "git_blob_sha1": "a3a955321554798101fe96523526c499767e0231" + }, + { + "path": "src/dlp_scanner/scanners/file_scanner.py", + "git_blob_sha1": "2046cbc60a3e2fad4472e6033a264ad0d30dcc73" + }, + { + "path": "src/dlp_scanner/scanners/network_scanner.py", + "git_blob_sha1": "e7aa9f88d42ae11d80b3aecaa195ad9fd8daca4b" + }, + { + "path": "README.md", + "git_blob_sha1": "b2271ea3cb2660388fc69f9f75e0d86cdadf0d94" + }, + { + "path": "LICENSE", + "git_blob_sha1": "0ad25db4bd1d86c452db3f9602ccdbe172438f52" + }, + { + "path": "pyproject.toml", + "git_blob_sha1": "175d799682f0ae47d5cd2f6db21c141d00feaee7" + }, + { + "path": ".dlp-scanner.yml", + "git_blob_sha1": "422cd5dd5ea6c9f4e4563bb010a5437861db78f9" + } + ] + }, + { + "name": "dns-lookup", + "repository": "CarterPerez-dev/Cybersecurity-Projects", + "upstream_path": "PROJECTS/beginner/dns-lookup", + "commit": "6c8ac8547cf52edaa06935e637a1fe11ac45b865", + "license": "AGPL-3.0-or-later", + "credit": "AngelaMos (2026); distributed in CarterPerez-dev/Cybersecurity-Projects", + "root": "third_party/community/dns-lookup", + "scope": "Verbatim runtime source and original license/docs; upstream tests, learning modules and generated artifacts omitted.", + "files": [ + { + "path": "dnslookup/__init__.py", + "git_blob_sha1": "2bdfd02e3056875128eeda6b5845319c5e6c95d1" + }, + { + "path": "dnslookup/__main__.py", + "git_blob_sha1": "0b487f6e89564124aa4a040dd4cf50160f261533" + }, + { + "path": "dnslookup/cli.py", + "git_blob_sha1": "479e8b0322112276e6952daa309ffd794d7a0a45" + }, + { + "path": "dnslookup/output.py", + "git_blob_sha1": "2a734961047f868d4c9578619e1f330f8883ce91" + }, + { + "path": "dnslookup/resolver.py", + "git_blob_sha1": "95d861ef162e9114330895ece2dea5302c3ea32a" + }, + { + "path": "dnslookup/whois_lookup.py", + "git_blob_sha1": "edb50822b593ca51584d0804c95c09cab970f9f2" + }, + { + "path": "README.md", + "git_blob_sha1": "fda2674f583dc81917594833cef6252e3f0cb406" + }, + { + "path": "LICENSE", + "git_blob_sha1": "0ad25db4bd1d86c452db3f9602ccdbe172438f52" + }, + { + "path": "pyproject.toml", + "git_blob_sha1": "9b18dd93c04dd4a43c0b9aa52250e292bd5e2cf7" + } + ] } ] } diff --git a/third_party/community/linux-cis-hardening-auditor/DEMO.md b/third_party/community/linux-cis-hardening-auditor/DEMO.md new file mode 100644 index 0000000..063aeec --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/DEMO.md @@ -0,0 +1,49 @@ + + + +
+ +```ruby + ██████╗██╗███████╗ █████╗ ██╗ ██╗██████╗ ██╗████████╗ + ██╔════╝██║██╔════╝██╔══██╗██║ ██║██╔══██╗██║╚══██╔══╝ + ██║ ██║███████╗███████║██║ ██║██║ ██║██║ ██║ + ██║ ██║╚════██║██╔══██║██║ ██║██║ ██║██║ ██║ + ╚██████╗██║███████║██║ ██║╚██████╔╝██████╔╝██║ ██║ + ╚═════╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ +``` + +**Demo & Preview** + +
+ + + CIS Benchmark + + +
+ +```ruby +./install.sh → sudo cisaudit +``` + +
+ +[Audit Report](#audit-report) · [Control Catalog](#control-catalog) + +
+ +--- + +### Audit Report + +Section-scored compliance report with per-control evidence, severity, and remediation commands rendered against a fixture root requiring no privileges + +![Audit Report](assets/audit.png) + +--- + +### Control Catalog + +109 registered controls across six CIS categories with section, level, and scored/non-scored classification + +![Control Catalog](assets/controls.png) diff --git a/third_party/community/linux-cis-hardening-auditor/LICENSE b/third_party/community/linux-cis-hardening-auditor/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/third_party/community/linux-cis-hardening-auditor/README.md b/third_party/community/linux-cis-hardening-auditor/README.md new file mode 100644 index 0000000..0f4c2c2 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/README.md @@ -0,0 +1,105 @@ +```regex + ██████╗██╗███████╗ █████╗ ██╗ ██╗██████╗ ██╗████████╗ +██╔════╝██║██╔════╝██╔══██╗██║ ██║██╔══██╗██║╚══██╔══╝ +██║ ██║███████╗███████║██║ ██║██║ ██║██║ ██║ +██║ ██║╚════██║██╔══██║██║ ██║██║ ██║██║ ██║ +╚██████╗██║███████║██║ ██║╚██████╔╝██████╔╝██║ ██║ + ╚═════╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2321-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/linux-cis-hardening-auditor) +[![Bash](https://img.shields.io/badge/Bash-4%2B-4EAA25?style=flat&logo=gnubash&logoColor=white)](https://www.gnu.org/software/bash/) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) + +> CIS Benchmark compliance auditor for Linux systems with scored reporting, baseline comparison, and remediation guidance. + +*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Audit Linux systems against 104 CIS Benchmark controls (Debian/Ubuntu) +- Check filesystem hardening, services, network parameters, logging, SSH, and user accounts +- Generate scored compliance reports in terminal, JSON, or HTML format +- Compare results against a saved baseline to detect regressions and improvements +- Provide specific remediation commands for every failed control +- Support Level 1 and Level 2 benchmark profiles +- Run in test mode against mock fixtures without root access + +## Quick Start + +```bash +./install.sh +sudo cisaudit +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Commands + +| Command | Description | +|---------|-------------| +| `sudo cisaudit` | Run full audit with terminal output | +| `sudo cisaudit -l 1` | Audit Level 1 controls only | +| `sudo cisaudit -f json -o report.json` | Generate JSON report | +| `sudo cisaudit -f html -o report.html` | Generate HTML report | +| `sudo cisaudit -c 5` | Audit only Section 5 (Access/Auth) | +| `cisaudit --list-controls` | List all 104 registered controls | +| `sudo cisaudit -s baseline.json` | Save current results as baseline | +| `sudo cisaudit -b baseline.json` | Compare against a previous baseline | +| `cisaudit -t testdata/fixtures` | Run against test fixtures (no root needed) | + +### Options + +| Flag | Default | Description | +|------|---------|-------------| +| `-l, --level` | `all` | Benchmark level: `1`, `2`, or `all` | +| `-f, --format` | `terminal` | Output format: `terminal`, `json`, `html` | +| `-o, --output` | stdout | Write report to file | +| `-c, --categories` | `all` | Categories to audit: `1,2,3,4,5,6` | +| `-t, --test-root` | `/` | System root for testing | +| `--threshold` | `0` | Minimum pass % to exit 0 | +| `-q, --quiet` | off | Suppress progress output | + +## CIS Benchmark Sections + +| # | Section | Controls | +|---|---------|----------| +| 1 | Initial Setup | 20 | +| 2 | Services | 18 | +| 3 | Network Configuration | 20 | +| 4 | Logging and Auditing | 18 | +| 5 | Access, Authentication and Authorization | 18 | +| 6 | System Maintenance | 10 | +| | **Total** | **104** | + +## Examples + +```bash +sudo cisaudit -l 1 -f json -o report.json + +sudo cisaudit -c 3,5 -f terminal + +sudo cisaudit -s baselines/march.json +sudo cisaudit -b baselines/march.json + +cisaudit -t testdata/fixtures -f json | python3 -m json.tool +``` + +## Learn + +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | CIS benchmarks, real breaches, and compliance frameworks | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design, module layout, and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough with file references | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | + +## License + +AGPL 3.0 diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh new file mode 100644 index 0000000..2bab951 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/01_initial_setup.sh @@ -0,0 +1,487 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 01_initial_setup.sh +# +# CIS Section 1 checks: Initial Setup +# +# Implements controls 1.1.1-1.5.3 covering filesystem hardening and +# kernel protections. Checks kernel module disabling for eight legacy +# filesystems (cramfs, freevxfs, jffs2, hfs, hfsplus, squashfs, udf, +# vfat) via modprobe.d configs and lsmod. Verifies /tmp as a separate +# partition with noexec, nosuid, and nodev mount options through fstab +# and findmnt. Validates package repository configuration, GPG key +# presence, GRUB bootloader password, grub.cfg permissions and +# ownership, single-user mode authentication (sulogin), ASLR +# (kernel.randomize_va_space=2), core dump restrictions (limits.conf +# + fs.suid_dumpable), and prelink removal. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - get_sysctl, read_file, run_cmd, file_exists +# controls/registry_data.sh - defines CIS IDs 1.x.x + +_check_module_disabled() { + local id="$1" + local module="$2" + + local status="$STATUS_PASS" + local evidence="" + local modprobe_conf="${SYSROOT}/etc/modprobe.d/${module}.conf" + + if run_cmd lsmod | grep -q "^${module} "; then + status="$STATUS_FAIL" + evidence="${module} module is currently loaded" + elif [[ -f "$modprobe_conf" ]] && grep -q "install ${module} /bin/true\|install ${module} /bin/false" "$modprobe_conf"; then + evidence="${module} disabled via ${modprobe_conf}" + elif run_cmd modprobe -n -v "$module" 2>/dev/null | grep -q "install /bin/true\|install /bin/false"; then + evidence="${module} disabled via modprobe config" + else + local found_disabled="false" + for conf in "${SYSROOT}"/etc/modprobe.d/*.conf; do + [[ -f "$conf" ]] || continue + if grep -q "install ${module} /bin/true\|install ${module} /bin/false\|blacklist ${module}" "$conf"; then + found_disabled="true" + evidence="${module} disabled via ${conf}" + break + fi + done + if [[ "$found_disabled" == "false" ]]; then + if [[ "$SYSROOT" != "/" ]]; then + status="$STATUS_FAIL" + evidence="No modprobe config found disabling ${module}" + else + status="$STATUS_FAIL" + evidence="${module} is not disabled" + fi + fi + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_1_1() { _check_module_disabled "1.1.1" "cramfs"; } +check_1_1_2() { _check_module_disabled "1.1.2" "freevxfs"; } +check_1_1_3() { _check_module_disabled "1.1.3" "jffs2"; } +check_1_1_4() { _check_module_disabled "1.1.4" "hfs"; } +check_1_1_5() { _check_module_disabled "1.1.5" "hfsplus"; } +check_1_1_6() { _check_module_disabled "1.1.6" "squashfs"; } +check_1_1_7() { _check_module_disabled "1.1.7" "udf"; } +check_1_1_8() { _check_module_disabled "1.1.8" "vfat"; } + +check_1_2_1() { + local id="1.2.1" + local status="$STATUS_PASS" + local evidence="" + + local fstab_content + fstab_content=$(read_file "/etc/fstab" 2>/dev/null) || true + + if [[ -n "$fstab_content" ]]; then + local tmp_line + tmp_line=$(echo "$fstab_content" | grep -E '\s/tmp\s' || true) + if [[ -n "$tmp_line" ]]; then + evidence="/tmp is a separate partition in fstab" + else + status="$STATUS_FAIL" + evidence="/tmp is not configured as a separate partition in fstab" + fi + elif run_cmd findmnt -n /tmp > /dev/null 2>&1; then + evidence="/tmp is a separate partition (findmnt)" + else + status="$STATUS_FAIL" + evidence="/tmp is not configured as a separate partition" + fi + + record_result "$id" "$status" "$evidence" +} + +_check_tmp_mount_option() { + local id="$1" + local option="$2" + + local status="$STATUS_PASS" + local evidence="" + local fstab_content + fstab_content=$(read_file "/etc/fstab" 2>/dev/null) || true + local options="" + + if [[ -n "$fstab_content" ]]; then + local tmp_line + tmp_line=$(echo "$fstab_content" | grep -E '\s/tmp\s' || true) + if [[ -n "$tmp_line" ]]; then + options=$(echo "$tmp_line" | awk '{print $4}') + fi + fi + + if [[ -z "$options" ]]; then + options=$(run_cmd findmnt -n -o OPTIONS /tmp 2>/dev/null) || true + fi + + if [[ -z "$options" ]]; then + status="$STATUS_SKIP" + evidence="/tmp partition not found or not accessible" + elif echo "$options" | grep -q "$option"; then + evidence="${option} is set on /tmp (${options})" + else + status="$STATUS_FAIL" + evidence="${option} is not set on /tmp (${options})" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_2_2() { _check_tmp_mount_option "1.2.2" "noexec"; } +check_1_2_3() { _check_tmp_mount_option "1.2.3" "nosuid"; } +check_1_2_4() { _check_tmp_mount_option "1.2.4" "nodev"; } + +check_1_3_1() { + local id="1.3.1" + local status="$STATUS_PASS" + local evidence="" + + local sources_list="${SYSROOT}/etc/apt/sources.list" + local sources_dir="${SYSROOT}/etc/apt/sources.list.d" + local found_repos="false" + + if [[ -f "$sources_list" ]]; then + local active_lines + active_lines=$(grep -cE '^\s*deb\s' "$sources_list" 2>/dev/null) || true + if [[ "$active_lines" -gt 0 ]]; then + found_repos="true" + evidence="Found ${active_lines} repo(s) in sources.list" + fi + fi + + if [[ -d "$sources_dir" ]]; then + local source_files + source_files=$(find "$sources_dir" -maxdepth 1 -name "*.list" -o -name "*.sources" 2>/dev/null | head -20) || true + if [[ -n "$source_files" ]]; then + local file_count + file_count=$(echo "$source_files" | wc -l) + found_repos="true" + if [[ -n "$evidence" ]]; then + evidence="${evidence}; ${file_count} source file(s) in sources.list.d" + else + evidence="${file_count} source file(s) in sources.list.d" + fi + fi + fi + + if [[ "$found_repos" == "false" ]]; then + status="$STATUS_FAIL" + evidence="No package repositories configured" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_3_2() { + local id="1.3.2" + local status="$STATUS_PASS" + local evidence="" + + local trusted_dir="${SYSROOT}/etc/apt/trusted.gpg.d" + local keyrings_dir="${SYSROOT}/etc/apt/keyrings" + local found_keys="false" + + if [[ -d "$trusted_dir" ]]; then + local key_files + key_files=$(find "$trusted_dir" -maxdepth 1 -name "*.gpg" -o -name "*.asc" 2>/dev/null | head -20) || true + if [[ -n "$key_files" ]]; then + local key_count + key_count=$(echo "$key_files" | wc -l) + found_keys="true" + evidence="${key_count} GPG key(s) in trusted.gpg.d" + fi + fi + + if [[ -d "$keyrings_dir" ]]; then + local keyring_files + keyring_files=$(find "$keyrings_dir" -maxdepth 1 -name "*.gpg" -o -name "*.asc" 2>/dev/null | head -20) || true + if [[ -n "$keyring_files" ]]; then + local ring_count + ring_count=$(echo "$keyring_files" | wc -l) + found_keys="true" + if [[ -n "$evidence" ]]; then + evidence="${evidence}; ${ring_count} keyring(s) in apt/keyrings" + else + evidence="${ring_count} keyring(s) in apt/keyrings" + fi + fi + fi + + if [[ "$found_keys" == "false" ]]; then + local apt_key_output + if apt_key_output=$(run_cmd apt-key list 2>/dev/null) && [[ -n "$apt_key_output" ]]; then + found_keys="true" + evidence="GPG keys found via apt-key" + fi + fi + + if [[ "$found_keys" == "false" ]]; then + status="$STATUS_FAIL" + evidence="No GPG keys configured for package verification" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_4_1() { + local id="1.4.1" + local status="$STATUS_PASS" + local evidence="" + + local grub_dir="${SYSROOT}/etc/grub.d" + local grub_default="${SYSROOT}/etc/default/grub" + local found_password="false" + + if [[ -d "$grub_dir" ]]; then + for grub_file in "${grub_dir}"/*; do + [[ -f "$grub_file" ]] || continue + if grep -q "password" "$grub_file" 2>/dev/null; then + found_password="true" + evidence="Bootloader password set in ${grub_file}" + break + fi + done + fi + + if [[ "$found_password" == "false" && -f "$grub_default" ]]; then + if grep -q "GRUB_PASSWORD\|GRUB_USERS" "$grub_default" 2>/dev/null; then + found_password="true" + evidence="Bootloader password configured in /etc/default/grub" + fi + fi + + if [[ "$found_password" == "false" ]]; then + local grub_cfg="${SYSROOT}/boot/grub/grub.cfg" + if [[ -f "$grub_cfg" ]]; then + if grep -q "password_pbkdf2\|password " "$grub_cfg" 2>/dev/null; then + found_password="true" + evidence="Bootloader password found in grub.cfg" + fi + fi + fi + + if [[ "$found_password" == "false" ]]; then + status="$STATUS_FAIL" + evidence="Bootloader password is not set" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_4_2() { + local id="1.4.2" + local status="$STATUS_PASS" + local evidence="" + + local grub_cfg="${SYSROOT}/boot/grub/grub.cfg" + + if [[ ! -f "$grub_cfg" ]]; then + status="$STATUS_SKIP" + evidence="/boot/grub/grub.cfg not found" + record_result "$id" "$status" "$evidence" + return + fi + + local file_stat + if file_stat=$(run_cmd stat -c '%a %U %G' "$grub_cfg" 2>/dev/null); then + local perms owner group + perms=$(echo "$file_stat" | awk '{print $1}') + owner=$(echo "$file_stat" | awk '{print $2}') + group=$(echo "$file_stat" | awk '{print $3}') + + if [[ "$owner" != "root" || "$group" != "root" ]]; then + status="$STATUS_FAIL" + evidence="grub.cfg owned by ${owner}:${group} (expected root:root)" + elif (( 8#$perms > 8#0600 )); then + status="$STATUS_FAIL" + evidence="grub.cfg permissions ${perms} (expected 600 or more restrictive)" + else + evidence="grub.cfg permissions ${perms}, owned by ${owner}:${group}" + fi + else + local ls_output + ls_output=$(ls -l "$grub_cfg" 2>/dev/null) || true + if [[ -n "$ls_output" ]]; then + local perm_str + perm_str=$(echo "$ls_output" | awk '{print $1}') + local file_owner + file_owner=$(echo "$ls_output" | awk '{print $3}') + local file_group + file_group=$(echo "$ls_output" | awk '{print $4}') + + if [[ "$file_owner" != "root" || "$file_group" != "root" ]]; then + status="$STATUS_FAIL" + evidence="grub.cfg owned by ${file_owner}:${file_group} (expected root:root)" + elif echo "$perm_str" | grep -q "......r\|.......w\|........x"; then + status="$STATUS_FAIL" + evidence="grub.cfg has excessive permissions (${perm_str})" + else + evidence="grub.cfg permissions ${perm_str}, owned by ${file_owner}:${file_group}" + fi + else + status="$STATUS_SKIP" + evidence="Unable to determine grub.cfg permissions" + fi + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_4_3() { + local id="1.4.3" + local status="$STATUS_PASS" + local evidence="" + + local rescue_service="${SYSROOT}/usr/lib/systemd/system/rescue.service" + local emergency_service="${SYSROOT}/usr/lib/systemd/system/emergency.service" + local found_sulogin="false" + + if [[ -f "$rescue_service" ]]; then + if grep -q "sulogin" "$rescue_service" 2>/dev/null; then + found_sulogin="true" + evidence="sulogin required for rescue mode" + fi + fi + + if [[ -f "$emergency_service" ]]; then + if grep -q "sulogin" "$emergency_service" 2>/dev/null; then + if [[ "$found_sulogin" == "true" ]]; then + evidence="sulogin required for rescue and emergency modes" + else + found_sulogin="true" + evidence="sulogin required for emergency mode" + fi + fi + fi + + if [[ "$found_sulogin" == "false" ]]; then + local inittab="${SYSROOT}/etc/inittab" + if [[ -f "$inittab" ]]; then + if grep -q "sulogin" "$inittab" 2>/dev/null; then + found_sulogin="true" + evidence="sulogin required via /etc/inittab" + fi + fi + fi + + if [[ "$found_sulogin" == "false" ]]; then + local override_rescue="${SYSROOT}/etc/systemd/system/rescue.service.d" + local override_emergency="${SYSROOT}/etc/systemd/system/emergency.service.d" + + for override_dir in "$override_rescue" "$override_emergency"; do + [[ -d "$override_dir" ]] || continue + for override_file in "${override_dir}"/*.conf; do + [[ -f "$override_file" ]] || continue + if grep -q "sulogin" "$override_file" 2>/dev/null; then + found_sulogin="true" + evidence="sulogin required via systemd override" + break 2 + fi + done + done + fi + + if [[ "$found_sulogin" == "false" ]]; then + status="$STATUS_FAIL" + evidence="Authentication not required for single-user mode" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_5_1() { + local id="1.5.1" + local status="$STATUS_PASS" + local evidence="" + + local aslr_value + aslr_value=$(get_sysctl "kernel.randomize_va_space") || true + + if [[ -z "$aslr_value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to determine ASLR status" + elif [[ "$aslr_value" == "2" ]]; then + evidence="ASLR is fully enabled (kernel.randomize_va_space = 2)" + elif [[ "$aslr_value" == "1" ]]; then + status="$STATUS_WARN" + evidence="ASLR is partially enabled (kernel.randomize_va_space = 1, expected 2)" + else + status="$STATUS_FAIL" + evidence="ASLR is disabled (kernel.randomize_va_space = ${aslr_value}, expected 2)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_5_2() { + local id="1.5.2" + local status="$STATUS_PASS" + local evidence="" + + local limits_ok="false" + local sysctl_ok="false" + local limits_file="${SYSROOT}/etc/security/limits.conf" + local limits_dir="${SYSROOT}/etc/security/limits.d" + + if [[ -f "$limits_file" ]]; then + if grep -qE '^\s*\*\s+hard\s+core\s+0' "$limits_file" 2>/dev/null; then + limits_ok="true" + fi + fi + + if [[ "$limits_ok" == "false" && -d "$limits_dir" ]]; then + for lconf in "${limits_dir}"/*.conf; do + [[ -f "$lconf" ]] || continue + if grep -qE '^\s*\*\s+hard\s+core\s+0' "$lconf" 2>/dev/null; then + limits_ok="true" + break + fi + done + fi + + local dumpable + dumpable=$(get_sysctl "fs.suid_dumpable") || true + + if [[ "$dumpable" == "0" ]]; then + sysctl_ok="true" + fi + + if [[ "$limits_ok" == "true" && "$sysctl_ok" == "true" ]]; then + evidence="Core dumps restricted (limits.conf + fs.suid_dumpable = 0)" + elif [[ "$limits_ok" == "true" && "$sysctl_ok" == "false" ]]; then + status="$STATUS_FAIL" + evidence="Core limit set but fs.suid_dumpable = ${dumpable:-unknown} (expected 0)" + elif [[ "$limits_ok" == "false" && "$sysctl_ok" == "true" ]]; then + status="$STATUS_FAIL" + evidence="fs.suid_dumpable = 0 but hard core 0 not set in limits.conf" + else + status="$STATUS_FAIL" + evidence="Core dumps not restricted (missing limits.conf and sysctl settings)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_1_5_3() { + local id="1.5.3" + local status="$STATUS_PASS" + local evidence="" + + if run_cmd dpkg-query -W prelink > /dev/null 2>&1; then + status="$STATUS_FAIL" + evidence="prelink is installed" + else + local prelink_bin="${SYSROOT}/usr/sbin/prelink" + if [[ -f "$prelink_bin" ]]; then + status="$STATUS_FAIL" + evidence="prelink binary found at /usr/sbin/prelink" + else + evidence="prelink is not installed" + fi + fi + + record_result "$id" "$status" "$evidence" +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/02_services.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/02_services.sh new file mode 100644 index 0000000..9c0fb4c --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/02_services.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 02_services.sh +# +# CIS Section 2 checks: Services +# +# Implements controls 2.1.1-2.2.16 verifying that unnecessary network +# services are not installed on the system. Checks legacy super-servers +# (xinetd, openbsd-inetd), graphical environment (X Window System), +# and fourteen network daemons: Avahi, CUPS, ISC DHCP, OpenLDAP, NFS, +# BIND DNS, vsftpd, Apache/nginx HTTP, Dovecot IMAP/POP3, Samba, +# Squid proxy, SNMP, and NIS. Each check uses package_is_installed +# with file_exists fallback for binary/config detection. The MTA +# check (2.2.15) inspects Postfix inet_interfaces, Exim +# dc_local_interfaces, and ss port 25 listeners to verify local-only +# mail delivery. Also checks rsync daemon installation. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - package_is_installed, file_exists, read_file, +# service_is_enabled, run_cmd + +check_2_1_1() { + local id="2.1.1" + local status="$STATUS_PASS" + local evidence="" + local pkg="xinetd" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/etc/xinetd.conf" || file_exists "/etc/xinetd.d"; then + status="$STATUS_FAIL" + evidence="${pkg} configuration found at /etc/xinetd.conf" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_1_2() { + local id="2.1.2" + local status="$STATUS_PASS" + local evidence="" + local pkg="openbsd-inetd" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/etc/inetd.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} configuration found at /etc/inetd.conf" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_1() { + local id="2.2.1" + local status="$STATUS_PASS" + local evidence="" + local pkg="xserver-xorg" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/bin/X" || file_exists "/usr/bin/Xorg"; then + status="$STATUS_FAIL" + evidence="X Window System binary found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_2() { + local id="2.2.2" + local status="$STATUS_PASS" + local evidence="" + local pkg="avahi-daemon" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/avahi-daemon" || file_exists "/etc/avahi/avahi-daemon.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_3() { + local id="2.2.3" + local status="$STATUS_PASS" + local evidence="" + local pkg="cups" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/cupsd" || file_exists "/etc/cups/cupsd.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_4() { + local id="2.2.4" + local status="$STATUS_PASS" + local evidence="" + local pkg="isc-dhcp-server" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/dhcpd" || file_exists "/etc/dhcp/dhcpd.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_5() { + local id="2.2.5" + local status="$STATUS_PASS" + local evidence="" + local pkg="slapd" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/slapd" || file_exists "/etc/ldap/slapd.d"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_6() { + local id="2.2.6" + local status="$STATUS_PASS" + local evidence="" + local pkg="nfs-kernel-server" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/rpc.nfsd" || file_exists "/etc/exports"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_7() { + local id="2.2.7" + local status="$STATUS_PASS" + local evidence="" + local pkg="bind9" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/named" || file_exists "/etc/bind/named.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_8() { + local id="2.2.8" + local status="$STATUS_PASS" + local evidence="" + local pkg="vsftpd" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/vsftpd" || file_exists "/etc/vsftpd.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_9() { + local id="2.2.9" + local status="$STATUS_PASS" + local evidence="" + local installed="" + + if package_is_installed "apache2"; then + installed="apache2" + elif package_is_installed "nginx"; then + installed="nginx" + elif file_exists "/usr/sbin/apache2" || file_exists "/etc/apache2/apache2.conf"; then + installed="apache2" + elif file_exists "/usr/sbin/nginx" || file_exists "/etc/nginx/nginx.conf"; then + installed="nginx" + fi + + if [[ -n "$installed" ]]; then + status="$STATUS_FAIL" + evidence="${installed} is installed" + else + evidence="No HTTP server is installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_10() { + local id="2.2.10" + local status="$STATUS_PASS" + local evidence="" + local installed="" + + if package_is_installed "dovecot-imapd"; then + installed="dovecot-imapd" + fi + + if package_is_installed "dovecot-pop3d"; then + if [[ -n "$installed" ]]; then + installed="${installed} and dovecot-pop3d" + else + installed="dovecot-pop3d" + fi + fi + + if [[ -z "$installed" ]]; then + if file_exists "/usr/sbin/dovecot" || file_exists "/etc/dovecot/dovecot.conf"; then + installed="dovecot" + fi + fi + + if [[ -n "$installed" ]]; then + status="$STATUS_FAIL" + evidence="${installed} is installed" + else + evidence="No IMAP or POP3 server is installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_11() { + local id="2.2.11" + local status="$STATUS_PASS" + local evidence="" + local pkg="samba" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/smbd" || file_exists "/etc/samba/smb.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_12() { + local id="2.2.12" + local status="$STATUS_PASS" + local evidence="" + local pkg="squid" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/squid" || file_exists "/etc/squid/squid.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_13() { + local id="2.2.13" + local status="$STATUS_PASS" + local evidence="" + local pkg="snmpd" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/snmpd" || file_exists "/etc/snmp/snmpd.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_14() { + local id="2.2.14" + local status="$STATUS_PASS" + local evidence="" + local pkg="nis" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif file_exists "/usr/sbin/ypserv" || file_exists "/etc/yp.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary or configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_2_2_15() { + local id="2.2.15" + local status="$STATUS_PASS" + local evidence="" + + local listening_external="" + + if run_cmd ss -lntp | grep -qE ':25\s' 2>/dev/null; then + local listeners + listeners=$(run_cmd ss -lntp | grep -E ':25\s' 2>/dev/null) || true + + if echo "$listeners" | grep -qvE '127\.0\.0\.1:25|::1:25|\[::1\]:25|\*:25'; then + local bound_addrs + bound_addrs=$(echo "$listeners" | awk '{print $4}') + if echo "$bound_addrs" | grep -qvE '^127\.0\.0\.1:|^\[::1\]:|^::1:'; then + listening_external="true" + fi + fi + fi + + if [[ "$listening_external" == "true" ]]; then + status="$STATUS_FAIL" + evidence="MTA is listening on non-loopback interface on port 25" + record_result "$id" "$status" "$evidence" + return + fi + + local main_cf + main_cf=$(read_file "/etc/postfix/main.cf" 2>/dev/null) || true + + if [[ -n "$main_cf" ]]; then + local inet_interfaces + inet_interfaces=$(echo "$main_cf" | grep -Ei '^\s*inet_interfaces\s*=' | tail -1 | awk -F= '{print $2}' | tr -d ' ') || true + + if [[ -z "$inet_interfaces" ]]; then + status="$STATUS_FAIL" + evidence="Postfix inet_interfaces not configured (defaults to all)" + elif [[ "$inet_interfaces" == "loopback-only" || "$inet_interfaces" == "localhost" || "$inet_interfaces" == "127.0.0.1" ]]; then + evidence="Postfix configured for local-only (inet_interfaces = ${inet_interfaces})" + else + status="$STATUS_FAIL" + evidence="Postfix inet_interfaces = ${inet_interfaces} (expected loopback-only or localhost)" + fi + + record_result "$id" "$status" "$evidence" + return + fi + + local exim_conf + exim_conf=$(read_file "/etc/exim4/update-exim4.conf.conf" 2>/dev/null) || true + + if [[ -n "$exim_conf" ]]; then + local listen_addrs + listen_addrs=$(echo "$exim_conf" | grep -Ei '^\s*dc_local_interfaces' | tail -1 | awk -F= "'{print \$2}'" | tr -d "' ") || true + + if [[ -z "$listen_addrs" || "$listen_addrs" == "127.0.0.1;::1" || "$listen_addrs" == "127.0.0.1" ]]; then + evidence="Exim configured for local-only (dc_local_interfaces = ${listen_addrs:-127.0.0.1;::1})" + else + status="$STATUS_FAIL" + evidence="Exim dc_local_interfaces = ${listen_addrs} (expected 127.0.0.1;::1)" + fi + + record_result "$id" "$status" "$evidence" + return + fi + + if file_exists "/usr/sbin/sendmail" || file_exists "/usr/lib/sendmail"; then + status="$STATUS_WARN" + evidence="Sendmail detected but configuration not checked in test mode" + record_result "$id" "$status" "$evidence" + return + fi + + evidence="No MTA detected or MTA not listening on port 25" + record_result "$id" "$status" "$evidence" +} + +check_2_2_16() { + local id="2.2.16" + local status="$STATUS_PASS" + local evidence="" + local pkg="rsync" + + if package_is_installed "$pkg"; then + status="$STATUS_FAIL" + evidence="${pkg} is installed" + elif service_is_enabled "rsync"; then + status="$STATUS_FAIL" + evidence="rsync service is enabled" + elif file_exists "/usr/bin/rsync" && file_exists "/etc/rsyncd.conf"; then + status="$STATUS_FAIL" + evidence="${pkg} binary and daemon configuration found" + else + evidence="${pkg} is not installed" + fi + + record_result "$id" "$status" "$evidence" +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/03_network.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/03_network.sh new file mode 100644 index 0000000..89743b1 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/03_network.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 03_network.sh +# +# CIS Section 3 checks: Network Configuration +# +# Implements controls 3.1.1-3.4.5 auditing kernel network parameters, +# host firewall policy, and uncommon protocol modules. Sysctl checks +# cover IP forwarding, ICMP send_redirects, source routing acceptance, +# ICMP redirect acceptance, martian packet logging, broadcast ICMP +# ignore, bogus ICMP response ignore, reverse path filtering, TCP +# SYN cookies, and IPv6 router advertisement acceptance. Firewall +# checks verify iptables installation, default deny policies on +# INPUT/FORWARD/OUTPUT chains (from live iptables or rules.v4 file), +# and open-port-to-rule coverage via ss. Validates wireless interface +# disabling through rfkill and checks modprobe.d for four uncommon +# protocol modules: DCCP, SCTP, RDS, and TIPC. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - get_sysctl, package_is_installed, file_exists, run_cmd + +check_3_1_1() { + local id="3.1.1" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv4.ip_forward") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv4.ip_forward" + elif [[ "$value" != "0" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv4.ip_forward = ${value} (expected 0)" + else + evidence="IP forwarding is disabled (net.ipv4.ip_forward = 0)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_1_2() { + local id="3.1.2" + local status="$STATUS_PASS" + local evidence="" + + local val_all val_default + val_all=$(get_sysctl "net.ipv4.conf.all.send_redirects") || true + val_default=$(get_sysctl "net.ipv4.conf.default.send_redirects") || true + + if [[ -z "$val_all" && -z "$val_default" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read send_redirects sysctl values" + elif [[ "$val_all" != "0" || "$val_default" != "0" ]]; then + status="$STATUS_FAIL" + evidence="send_redirects: all=${val_all:-unset} default=${val_default:-unset} (expected 0)" + else + evidence="Packet redirect sending disabled (all=0, default=0)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_1_3() { + local id="3.1.3" + local status="$STATUS_PASS" + local evidence="" + + local val_all val_default + val_all=$(get_sysctl "net.ipv4.conf.all.accept_source_route") || true + val_default=$(get_sysctl "net.ipv4.conf.default.accept_source_route") || true + + if [[ -z "$val_all" && -z "$val_default" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read accept_source_route sysctl values" + elif [[ "$val_all" != "0" || "$val_default" != "0" ]]; then + status="$STATUS_FAIL" + evidence="accept_source_route: all=${val_all:-unset} default=${val_default:-unset} (expected 0)" + else + evidence="Source routed packets not accepted (all=0, default=0)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_1_4() { + local id="3.1.4" + local status="$STATUS_PASS" + local evidence="" + + local val_all val_default + val_all=$(get_sysctl "net.ipv4.conf.all.accept_redirects") || true + val_default=$(get_sysctl "net.ipv4.conf.default.accept_redirects") || true + + if [[ -z "$val_all" && -z "$val_default" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read accept_redirects sysctl values" + elif [[ "$val_all" != "0" || "$val_default" != "0" ]]; then + status="$STATUS_FAIL" + evidence="accept_redirects: all=${val_all:-unset} default=${val_default:-unset} (expected 0)" + else + evidence="ICMP redirects not accepted (all=0, default=0)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_2_1() { + local id="3.2.1" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv4.conf.all.log_martians") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv4.conf.all.log_martians" + elif [[ "$value" != "1" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv4.conf.all.log_martians = ${value} (expected 1)" + else + evidence="Suspicious packets are logged (net.ipv4.conf.all.log_martians = 1)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_2_2() { + local id="3.2.2" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv4.icmp_echo_ignore_broadcasts") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv4.icmp_echo_ignore_broadcasts" + elif [[ "$value" != "1" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv4.icmp_echo_ignore_broadcasts = ${value} (expected 1)" + else + evidence="Broadcast ICMP requests ignored (net.ipv4.icmp_echo_ignore_broadcasts = 1)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_2_3() { + local id="3.2.3" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv4.icmp_ignore_bogus_error_responses") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv4.icmp_ignore_bogus_error_responses" + elif [[ "$value" != "1" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv4.icmp_ignore_bogus_error_responses = ${value} (expected 1)" + else + evidence="Bogus ICMP responses ignored (net.ipv4.icmp_ignore_bogus_error_responses = 1)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_2_4() { + local id="3.2.4" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv4.conf.all.rp_filter") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv4.conf.all.rp_filter" + elif [[ "$value" != "1" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv4.conf.all.rp_filter = ${value} (expected 1)" + else + evidence="Reverse Path Filtering enabled (net.ipv4.conf.all.rp_filter = 1)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_2_5() { + local id="3.2.5" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv4.tcp_syncookies") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv4.tcp_syncookies" + elif [[ "$value" != "1" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv4.tcp_syncookies = ${value} (expected 1)" + else + evidence="TCP SYN Cookies enabled (net.ipv4.tcp_syncookies = 1)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_2_6() { + local id="3.2.6" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_sysctl "net.ipv6.conf.all.accept_ra") || true + + if [[ -z "$value" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read net.ipv6.conf.all.accept_ra" + elif [[ "$value" != "0" ]]; then + status="$STATUS_FAIL" + evidence="net.ipv6.conf.all.accept_ra = ${value} (expected 0)" + else + evidence="IPv6 router advertisements not accepted (net.ipv6.conf.all.accept_ra = 0)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_3_1() { + local id="3.3.1" + local status="$STATUS_PASS" + local evidence="" + + if package_is_installed "iptables"; then + evidence="iptables package is installed" + elif file_exists "/usr/sbin/iptables"; then + evidence="iptables binary found at /usr/sbin/iptables" + else + status="$STATUS_FAIL" + evidence="iptables is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_3_2() { + local id="3.3.2" + local status="$STATUS_PASS" + local evidence="" + + local policy="" + local iptables_output + if iptables_output=$(run_cmd iptables -L INPUT -n 2>/dev/null); then + policy=$(echo "$iptables_output" | head -1 | grep -oP 'policy \K\w+') || true + fi + + if [[ -z "$policy" ]]; then + local rules_file="${SYSROOT}/etc/iptables/rules.v4" + if [[ -f "$rules_file" ]]; then + policy=$(grep -E '^\s*:INPUT\s' "$rules_file" | awk '{print $2}') || true + fi + fi + + if [[ -z "$policy" ]]; then + status="$STATUS_SKIP" + evidence="Unable to determine INPUT chain policy" + elif [[ "$policy" == "DROP" || "$policy" == "REJECT" ]]; then + evidence="INPUT chain default policy is ${policy}" + else + status="$STATUS_FAIL" + evidence="INPUT chain default policy is ${policy} (expected DROP or REJECT)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_3_3() { + local id="3.3.3" + local status="$STATUS_PASS" + local evidence="" + + local policy="" + local iptables_output + if iptables_output=$(run_cmd iptables -L FORWARD -n 2>/dev/null); then + policy=$(echo "$iptables_output" | head -1 | grep -oP 'policy \K\w+') || true + fi + + if [[ -z "$policy" ]]; then + local rules_file="${SYSROOT}/etc/iptables/rules.v4" + if [[ -f "$rules_file" ]]; then + policy=$(grep -E '^\s*:FORWARD\s' "$rules_file" | awk '{print $2}') || true + fi + fi + + if [[ -z "$policy" ]]; then + status="$STATUS_SKIP" + evidence="Unable to determine FORWARD chain policy" + elif [[ "$policy" == "DROP" || "$policy" == "REJECT" ]]; then + evidence="FORWARD chain default policy is ${policy}" + else + status="$STATUS_FAIL" + evidence="FORWARD chain default policy is ${policy} (expected DROP or REJECT)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_3_4() { + local id="3.3.4" + local status="$STATUS_PASS" + local evidence="" + + local policy="" + local iptables_output + if iptables_output=$(run_cmd iptables -L OUTPUT -n 2>/dev/null); then + policy=$(echo "$iptables_output" | head -1 | grep -oP 'policy \K\w+') || true + fi + + if [[ -z "$policy" ]]; then + local rules_file="${SYSROOT}/etc/iptables/rules.v4" + if [[ -f "$rules_file" ]]; then + policy=$(grep -E '^\s*:OUTPUT\s' "$rules_file" | awk '{print $2}') || true + fi + fi + + if [[ -z "$policy" ]]; then + status="$STATUS_SKIP" + evidence="Unable to determine OUTPUT chain policy" + elif [[ "$policy" == "DROP" || "$policy" == "REJECT" ]]; then + evidence="OUTPUT chain default policy is ${policy}" + else + status="$STATUS_FAIL" + evidence="OUTPUT chain default policy is ${policy} (expected DROP or REJECT)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_3_5() { + local id="3.3.5" + local status="$STATUS_PASS" + local evidence="" + + if [[ "$SYSROOT" != "/" ]]; then + status="$STATUS_SKIP" + evidence="Firewall rule coverage requires live system (test mode)" + record_result "$id" "$status" "$evidence" + return + fi + + local open_ports + open_ports=$(run_cmd ss -tlnp 2>/dev/null | awk 'NR>1 {print $4}' | grep -oP '\d+$' | sort -un) || true + + if [[ -z "$open_ports" ]]; then + evidence="No open TCP ports detected" + record_result "$id" "$status" "$evidence" + return + fi + + local iptables_rules + iptables_rules=$(run_cmd iptables -L INPUT -n 2>/dev/null) || true + + if [[ -z "$iptables_rules" ]]; then + status="$STATUS_SKIP" + evidence="Unable to read iptables INPUT rules" + record_result "$id" "$status" "$evidence" + return + fi + + local uncovered="" + local port + for port in $open_ports; do + if ! echo "$iptables_rules" | grep -q "dpt:${port}\b"; then + if [[ -n "$uncovered" ]]; then + uncovered="${uncovered}, ${port}" + else + uncovered="${port}" + fi + fi + done + + if [[ -n "$uncovered" ]]; then + status="$STATUS_FAIL" + evidence="Open ports without firewall rules: ${uncovered}" + else + evidence="All open ports have matching firewall rules" + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_4_1() { + local id="3.4.1" + local status="$STATUS_PASS" + local evidence="" + + if [[ "$SYSROOT" != "/" ]]; then + status="$STATUS_SKIP" + evidence="Wireless interface check requires live system (test mode)" + record_result "$id" "$status" "$evidence" + return + fi + + local wireless_ifaces + wireless_ifaces=$(run_cmd ip link show 2>/dev/null | grep -oP '^\d+:\s+\Kwlan\S+') || true + + if [[ -n "$wireless_ifaces" ]]; then + local all_blocked="true" + if run_cmd rfkill list wifi 2>/dev/null | grep -q "Soft blocked: yes"; then + evidence="Wireless interfaces found but blocked via rfkill" + else + all_blocked="false" + fi + + if [[ "$all_blocked" == "false" ]]; then + status="$STATUS_FAIL" + evidence="Active wireless interfaces found: ${wireless_ifaces}" + fi + else + evidence="No wireless interfaces detected" + fi + + record_result "$id" "$status" "$evidence" +} + +_check_protocol_module_disabled() { + local id="$1" + local module="$2" + + local status="$STATUS_PASS" + local evidence="" + local found_disabled="false" + + for conf in "${SYSROOT}"/etc/modprobe.d/*.conf; do + [[ -f "$conf" ]] || continue + if grep -q "install ${module} /bin/true\|install ${module} /bin/false\|blacklist ${module}" "$conf"; then + found_disabled="true" + evidence="${module} disabled via ${conf}" + break + fi + done + + if [[ "$found_disabled" == "false" ]]; then + if run_cmd lsmod | grep -q "^${module} "; then + status="$STATUS_FAIL" + evidence="${module} module is loaded" + else + status="$STATUS_FAIL" + evidence="No modprobe config found disabling ${module}" + fi + fi + + record_result "$id" "$status" "$evidence" +} + +check_3_4_2() { _check_protocol_module_disabled "3.4.2" "dccp"; } +check_3_4_3() { _check_protocol_module_disabled "3.4.3" "sctp"; } +check_3_4_4() { _check_protocol_module_disabled "3.4.4" "rds"; } +check_3_4_5() { _check_protocol_module_disabled "3.4.5" "tipc"; } diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/04_logging.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/04_logging.sh new file mode 100644 index 0000000..11dea8e --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/04_logging.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 04_logging.sh +# +# CIS Section 4 checks: Logging and Auditing +# +# Implements controls 4.1.1-4.2.4 for the audit subsystem and syslog +# configuration. Verifies auditd is installed, enabled, and configured +# for boot-time auditing (audit=1 in GRUB_CMDLINE_LINUX) with a +# sufficient backlog limit (>=8192). Validates ten categories of audit +# rules by searching /etc/audit/rules.d/ and audit.rules: time changes +# (adjtimex, settimeofday, clock_settime), user/group modifications, +# network environment changes (sethostname, /etc/issue, /etc/hosts), +# MAC policy changes (SELinux/AppArmor paths), login/logout events, +# session initiation (utmp/wtmp/btmp), DAC permission changes (chmod, +# chown family), unauthorized access attempts (EACCES/EPERM), file +# system mounts, and file deletions (unlink/rename). Checks rsyslog +# installation, service status, FileCreateMode, and logging rules. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - package_is_installed, file_exists, service_is_enabled + +check_4_1_1() { + local id="4.1.1" + local status="$STATUS_PASS" + local evidence="" + + if package_is_installed "auditd"; then + evidence="auditd package is installed" + elif file_exists "/usr/sbin/auditd"; then + evidence="auditd binary found at /usr/sbin/auditd" + else + status="$STATUS_FAIL" + evidence="auditd is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_1_2() { + local id="4.1.2" + local status="$STATUS_PASS" + local evidence="" + + if service_is_enabled "auditd"; then + evidence="auditd service is enabled" + else + local symlink="${SYSROOT}/etc/systemd/system/multi-user.target.wants/auditd.service" + if [[ -L "$symlink" || -f "$symlink" ]]; then + evidence="auditd enabled via systemd symlink" + else + status="$STATUS_FAIL" + evidence="auditd service is not enabled" + fi + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_1_3() { + local id="4.1.3" + local status="$STATUS_PASS" + local evidence="" + + local grub_default="${SYSROOT}/etc/default/grub" + + if [[ ! -f "$grub_default" ]]; then + status="$STATUS_SKIP" + evidence="/etc/default/grub not found" + record_result "$id" "$status" "$evidence" + return + fi + + local cmdline + cmdline=$(grep -E '^\s*GRUB_CMDLINE_LINUX=' "$grub_default" | tail -1) || true + + if [[ -z "$cmdline" ]]; then + status="$STATUS_FAIL" + evidence="GRUB_CMDLINE_LINUX not configured" + elif echo "$cmdline" | grep -q 'audit=1'; then + evidence="Pre-auditd auditing enabled (audit=1 in GRUB_CMDLINE_LINUX)" + else + status="$STATUS_FAIL" + evidence="audit=1 not found in GRUB_CMDLINE_LINUX" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_1_4() { + local id="4.1.4" + local status="$STATUS_PASS" + local evidence="" + + local grub_default="${SYSROOT}/etc/default/grub" + + if [[ ! -f "$grub_default" ]]; then + status="$STATUS_SKIP" + evidence="/etc/default/grub not found" + record_result "$id" "$status" "$evidence" + return + fi + + local cmdline + cmdline=$(grep -E '^\s*GRUB_CMDLINE_LINUX=' "$grub_default" | tail -1) || true + + if [[ -z "$cmdline" ]]; then + status="$STATUS_FAIL" + evidence="GRUB_CMDLINE_LINUX not configured" + elif echo "$cmdline" | grep -qE 'audit_backlog_limit=[0-9]+'; then + local limit + limit=$(echo "$cmdline" | grep -oE 'audit_backlog_limit=[0-9]+' | cut -d= -f2) + if (( limit >= 8192 )); then + evidence="Audit backlog limit is sufficient (${limit})" + else + status="$STATUS_FAIL" + evidence="Audit backlog limit is ${limit} (expected >= 8192)" + fi + else + status="$STATUS_FAIL" + evidence="audit_backlog_limit not found in GRUB_CMDLINE_LINUX" + fi + + record_result "$id" "$status" "$evidence" +} + +_check_audit_rules() { + local id="$1" + shift + local description="$1" + shift + local search_patterns=("$@") + + local status="$STATUS_PASS" + local evidence="" + local rules_dir="${SYSROOT}/etc/audit/rules.d" + local audit_rules="${SYSROOT}/etc/audit/audit.rules" + local missing=() + + for pattern in "${search_patterns[@]}"; do + local found="false" + if [[ -d "$rules_dir" ]]; then + for rule_file in "$rules_dir"/*.rules; do + [[ -f "$rule_file" ]] || continue + if grep -q "$pattern" "$rule_file"; then + found="true" + break + fi + done + fi + if [[ "$found" == "false" && -f "$audit_rules" ]]; then + if grep -q "$pattern" "$audit_rules"; then + found="true" + fi + fi + if [[ "$found" == "false" ]]; then + missing+=("$pattern") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + status="$STATUS_FAIL" + evidence="Missing audit rules for: ${missing[*]}" + else + evidence="${description} audit rules are configured" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_1_5() { + _check_audit_rules "4.1.5" "Time change" \ + "adjtimex" "settimeofday" "clock_settime" "/etc/localtime" +} + +check_4_1_6() { + _check_audit_rules "4.1.6" "User/group change" \ + "/etc/group" "/etc/passwd" "/etc/gshadow" "/etc/shadow" "/etc/security/opasswd" +} + +check_4_1_7() { + _check_audit_rules "4.1.7" "Network environment" \ + "sethostname" "setdomainname" "/etc/issue" "/etc/hosts" "/etc/networks" +} + +check_4_1_8() { + local id="4.1.8" + local status="$STATUS_PASS" + local evidence="" + local rules_dir="${SYSROOT}/etc/audit/rules.d" + local audit_rules="${SYSROOT}/etc/audit/audit.rules" + local found="false" + + local mac_patterns=("/etc/selinux/" "/etc/apparmor/") + + for pattern in "${mac_patterns[@]}"; do + if [[ -d "$rules_dir" ]]; then + for rule_file in "$rules_dir"/*.rules; do + [[ -f "$rule_file" ]] || continue + if grep -q "$pattern" "$rule_file"; then + found="true" + break 2 + fi + done + fi + if [[ "$found" == "false" && -f "$audit_rules" ]]; then + if grep -q "$pattern" "$audit_rules"; then + found="true" + break + fi + fi + done + + if [[ "$found" == "false" ]]; then + status="$STATUS_FAIL" + evidence="Missing audit rules for MAC policy changes" + else + evidence="MAC policy change audit rules are configured" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_1_9() { + _check_audit_rules "4.1.9" "Login/logout event" \ + "/var/log/lastlog" "/var/run/faillock/" +} + +check_4_1_10() { + _check_audit_rules "4.1.10" "Session initiation" \ + "/var/run/utmp" "/var/log/wtmp" "/var/log/btmp" +} + +check_4_1_11() { + _check_audit_rules "4.1.11" "DAC permission change" \ + "chmod" "chown" "fchmod" "fchown" "lchown" "setxattr" +} + +check_4_1_12() { + local id="4.1.12" + local status="$STATUS_PASS" + local evidence="" + local rules_dir="${SYSROOT}/etc/audit/rules.d" + local audit_rules="${SYSROOT}/etc/audit/audit.rules" + local found_eacces="false" + local found_eperm="false" + + if [[ -d "$rules_dir" ]]; then + for rule_file in "$rules_dir"/*.rules; do + [[ -f "$rule_file" ]] || continue + grep -q "EACCES" "$rule_file" && found_eacces="true" + grep -q "EPERM" "$rule_file" && found_eperm="true" + done + fi + if [[ -f "$audit_rules" ]]; then + grep -q "EACCES" "$audit_rules" && found_eacces="true" + grep -q "EPERM" "$audit_rules" && found_eperm="true" + fi + + if [[ "$found_eacces" == "false" || "$found_eperm" == "false" ]]; then + local missing_items="" + [[ "$found_eacces" == "false" ]] && missing_items="EACCES" + [[ "$found_eperm" == "false" ]] && missing_items="${missing_items:+${missing_items} }EPERM" + status="$STATUS_FAIL" + evidence="Missing audit rules for unauthorized access: ${missing_items}" + else + evidence="Unauthorized file access audit rules are configured" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_1_13() { + _check_audit_rules "4.1.13" "File system mount" \ + "mount" +} + +check_4_1_14() { + _check_audit_rules "4.1.14" "File deletion" \ + "unlink" "rename" +} + +check_4_2_1() { + local id="4.2.1" + local status="$STATUS_PASS" + local evidence="" + + if package_is_installed "rsyslog"; then + evidence="rsyslog package is installed" + elif file_exists "/usr/sbin/rsyslogd"; then + evidence="rsyslogd binary found at /usr/sbin/rsyslogd" + else + status="$STATUS_FAIL" + evidence="rsyslog is not installed" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_2_2() { + local id="4.2.2" + local status="$STATUS_PASS" + local evidence="" + + if service_is_enabled "rsyslog"; then + evidence="rsyslog service is enabled" + else + local symlink="${SYSROOT}/etc/systemd/system/multi-user.target.wants/rsyslog.service" + if [[ -L "$symlink" || -f "$symlink" ]]; then + evidence="rsyslog enabled via systemd symlink" + else + status="$STATUS_FAIL" + evidence="rsyslog service is not enabled" + fi + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_2_3() { + local id="4.2.3" + local status="$STATUS_PASS" + local evidence="" + + local rsyslog_conf="${SYSROOT}/etc/rsyslog.conf" + + if [[ ! -f "$rsyslog_conf" ]]; then + status="$STATUS_SKIP" + evidence="/etc/rsyslog.conf not found" + record_result "$id" "$status" "$evidence" + return + fi + + local file_mode + # shellcheck disable=SC2016 # literal $FileCreateMode and $2 are regex/awk syntax + file_mode=$(grep -E '^\s*\$FileCreateMode' "$rsyslog_conf" | tail -1 | awk '{print $2}') || true + + if [[ -z "$file_mode" ]]; then + status="$STATUS_FAIL" + evidence="\$FileCreateMode not set in rsyslog.conf" + elif (( 8#$file_mode <= 8#0640 )); then + evidence="rsyslog FileCreateMode is ${file_mode}" + else + status="$STATUS_FAIL" + evidence="rsyslog FileCreateMode is ${file_mode} (expected 0640 or stricter)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_4_2_4() { + local id="4.2.4" + local status="$STATUS_PASS" + local evidence="" + + local rsyslog_conf="${SYSROOT}/etc/rsyslog.conf" + + if [[ ! -f "$rsyslog_conf" ]]; then + status="$STATUS_SKIP" + evidence="/etc/rsyslog.conf not found" + record_result "$id" "$status" "$evidence" + return + fi + + local rule_count + rule_count=$(grep -cE '^\s*[a-z]+\.\*\s+/|^\s*\*\.\*\s+/' "$rsyslog_conf") || true + + if [[ "$rule_count" -gt 0 ]]; then + evidence="Found ${rule_count} logging rule(s) in rsyslog.conf" + else + local include_count + # shellcheck disable=SC2016 # literal $IncludeConfig is regex syntax, not shell + include_count=$(grep -cE '^\s*\$IncludeConfig|^\s*include\(' "$rsyslog_conf") || true + + if [[ "$include_count" -gt 0 ]]; then + evidence="Logging configured via included config files (${include_count} include directive(s))" + else + status="$STATUS_FAIL" + evidence="No logging rules found in rsyslog.conf" + fi + fi + + record_result "$id" "$status" "$evidence" +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/05_access.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/05_access.sh new file mode 100644 index 0000000..a9828d0 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/05_access.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 05_access.sh +# +# CIS Section 5 checks: Cron and SSH hardening +# +# Implements controls 5.1.1-5.2.14 covering cron daemon and SSH +# server configuration. Verifies cron is enabled and checks +# permissions/ownership on /etc/crontab, cron.hourly, and cron.daily +# (root:root, 0600/0700). SSH checks parse sshd_config for: file +# permissions (0600 root-owned), access restrictions (AllowUsers/ +# AllowGroups/DenyUsers/DenyGroups), host private key permissions, +# LogLevel (INFO or VERBOSE), X11Forwarding (no), MaxAuthTries (<=4), +# IgnoreRhosts (yes), PermitRootLogin (no), PermitEmptyPasswords (no), +# PermitUserEnvironment (no), weak cipher/MAC/KexAlgorithm rejection +# (CBC ciphers, MD5/SHA1-96 MACs, DH group1/14-sha1 KEX), and +# LoginGraceTime (<=60s). Uses _check_ssh_value and _check_ssh_max_int +# helpers for consistent sshd_config parsing. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - service_is_enabled, file_exists, run_cmd, +# get_config_value + +check_5_1_1() { + local id="5.1.1" + local status="$STATUS_PASS" + local evidence="" + + if service_is_enabled "cron"; then + evidence="cron daemon is enabled" + elif file_exists "/etc/crontab"; then + evidence="cron configuration exists (/etc/crontab found)" + else + status="$STATUS_FAIL" + evidence="cron daemon is not enabled" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_1_2() { + local id="5.1.2" + local status="$STATUS_PASS" + local evidence="" + + local crontab="${SYSROOT}/etc/crontab" + + if [[ ! -f "$crontab" ]]; then + status="$STATUS_SKIP" + evidence="/etc/crontab not found" + record_result "$id" "$status" "$evidence" + return + fi + + local file_stat + if file_stat=$(run_cmd stat -c '%a %U %G' "$crontab"); then + local perms owner group + perms=$(echo "$file_stat" | awk '{print $1}') + owner=$(echo "$file_stat" | awk '{print $2}') + group=$(echo "$file_stat" | awk '{print $3}') + + if [[ "$owner" != "root" || "$group" != "root" ]]; then + status="$STATUS_FAIL" + evidence="/etc/crontab owned by ${owner}:${group} (expected root:root)" + elif (( 8#$perms > 8#0600 )); then + status="$STATUS_FAIL" + evidence="/etc/crontab permissions ${perms} (expected 600 or stricter)" + else + evidence="/etc/crontab permissions ${perms}, owned by ${owner}:${group}" + fi + else + evidence="/etc/crontab exists (permissions not verifiable in test mode)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_1_3() { + local id="5.1.3" + local status="$STATUS_PASS" + local evidence="" + + local cron_hourly="${SYSROOT}/etc/cron.hourly" + + if [[ ! -d "$cron_hourly" ]]; then + status="$STATUS_SKIP" + evidence="/etc/cron.hourly not found" + record_result "$id" "$status" "$evidence" + return + fi + + local dir_stat + if dir_stat=$(run_cmd stat -c '%a %U %G' "$cron_hourly"); then + local perms owner group + perms=$(echo "$dir_stat" | awk '{print $1}') + owner=$(echo "$dir_stat" | awk '{print $2}') + group=$(echo "$dir_stat" | awk '{print $3}') + + if [[ "$owner" != "root" || "$group" != "root" ]]; then + status="$STATUS_FAIL" + evidence="/etc/cron.hourly owned by ${owner}:${group} (expected root:root)" + elif (( 8#$perms > 8#0700 )); then + status="$STATUS_FAIL" + evidence="/etc/cron.hourly permissions ${perms} (expected 700 or stricter)" + else + evidence="/etc/cron.hourly permissions ${perms}, owned by ${owner}:${group}" + fi + else + evidence="/etc/cron.hourly exists (permissions not verifiable in test mode)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_1_4() { + local id="5.1.4" + local status="$STATUS_PASS" + local evidence="" + + local cron_daily="${SYSROOT}/etc/cron.daily" + + if [[ ! -d "$cron_daily" ]]; then + status="$STATUS_SKIP" + evidence="/etc/cron.daily not found" + record_result "$id" "$status" "$evidence" + return + fi + + local dir_stat + if dir_stat=$(run_cmd stat -c '%a %U %G' "$cron_daily"); then + local perms owner group + perms=$(echo "$dir_stat" | awk '{print $1}') + owner=$(echo "$dir_stat" | awk '{print $2}') + group=$(echo "$dir_stat" | awk '{print $3}') + + if [[ "$owner" != "root" || "$group" != "root" ]]; then + status="$STATUS_FAIL" + evidence="/etc/cron.daily owned by ${owner}:${group} (expected root:root)" + elif (( 8#$perms > 8#0700 )); then + status="$STATUS_FAIL" + evidence="/etc/cron.daily permissions ${perms} (expected 700 or stricter)" + else + evidence="/etc/cron.daily permissions ${perms}, owned by ${owner}:${group}" + fi + else + evidence="/etc/cron.daily exists (permissions not verifiable in test mode)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_1() { + local id="5.2.1" + local status="$STATUS_PASS" + local evidence="" + + local sshd_config="${SYSROOT}/etc/ssh/sshd_config" + + if [[ ! -f "$sshd_config" ]]; then + status="$STATUS_SKIP" + evidence="sshd_config not found" + record_result "$id" "$status" "$evidence" + return + fi + + local file_stat + if file_stat=$(run_cmd stat -c '%a %U %G' "$sshd_config"); then + local perms owner group + perms=$(echo "$file_stat" | awk '{print $1}') + owner=$(echo "$file_stat" | awk '{print $2}') + group=$(echo "$file_stat" | awk '{print $3}') + + if [[ "$owner" != "root" ]]; then + status="$STATUS_FAIL" + evidence="sshd_config owned by ${owner} (expected root)" + elif (( 8#$perms > 8#0600 )); then + status="$STATUS_FAIL" + evidence="sshd_config permissions ${perms} (expected 600 or stricter)" + else + evidence="sshd_config permissions ${perms}, owned by ${owner}:${group}" + fi + else + evidence="sshd_config exists (permissions not verifiable in test mode)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_2() { + local id="5.2.2" + local status="$STATUS_PASS" + local evidence="" + + if ! file_exists "/etc/ssh/sshd_config"; then + status="$STATUS_SKIP" + evidence="sshd_config not found" + record_result "$id" "$status" "$evidence" + return + fi + + local sshd_config="${SYSROOT}/etc/ssh/sshd_config" + local found_directive="false" + local directives=("AllowUsers" "AllowGroups" "DenyUsers" "DenyGroups") + local found_list="" + + for directive in "${directives[@]}"; do + if grep -Eiq "^\s*${directive}\s" "$sshd_config"; then + found_directive="true" + found_list="${found_list:+${found_list}, }${directive}" + fi + done + + if [[ "$found_directive" == "true" ]]; then + evidence="SSH access limited via: ${found_list}" + else + status="$STATUS_FAIL" + evidence="No SSH access restrictions configured (no AllowUsers/AllowGroups/DenyUsers/DenyGroups)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_3() { + local id="5.2.3" + local status="$STATUS_PASS" + local evidence="" + + local ssh_dir="${SYSROOT}/etc/ssh" + local bad_keys="" + local key_count=0 + + if [[ ! -d "$ssh_dir" ]]; then + status="$STATUS_SKIP" + evidence="/etc/ssh directory not found" + record_result "$id" "$status" "$evidence" + return + fi + + for key_file in "${ssh_dir}"/ssh_host_*_key; do + [[ -f "$key_file" ]] || continue + ((key_count++)) || true + + local file_stat + if file_stat=$(run_cmd stat -c '%a %U' "$key_file"); then + local perms owner + perms=$(echo "$file_stat" | awk '{print $1}') + owner=$(echo "$file_stat" | awk '{print $2}') + + if [[ "$owner" != "root" ]] || (( 8#$perms > 8#0600 )); then + bad_keys="${bad_keys:+${bad_keys}, }${key_file##*/}(${perms}/${owner})" + fi + fi + done + + if [[ "$key_count" -eq 0 ]]; then + if [[ "$SYSROOT" != "/" ]]; then + evidence="No SSH host private keys found (test mode)" + else + status="$STATUS_WARN" + evidence="No SSH host private keys found" + fi + elif [[ -n "$bad_keys" ]]; then + status="$STATUS_FAIL" + evidence="Insecure SSH private key files: ${bad_keys}" + else + evidence="All ${key_count} SSH private key file(s) have correct permissions" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_4() { + local id="5.2.4" + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_config_value "/etc/ssh/sshd_config" "LogLevel") || true + + if [[ -z "$value" ]]; then + if ! file_exists "/etc/ssh/sshd_config"; then + status="$STATUS_SKIP" + evidence="sshd_config not found" + else + evidence="LogLevel not set (default is INFO)" + fi + elif [[ "${value^^}" == "INFO" || "${value^^}" == "VERBOSE" ]]; then + evidence="SSH LogLevel = ${value}" + else + status="$STATUS_FAIL" + evidence="SSH LogLevel = ${value} (expected INFO or VERBOSE)" + fi + + record_result "$id" "$status" "$evidence" +} + +_check_ssh_value() { + local id="$1" + local directive="$2" + local expected="$3" + local default_status="$4" + local default_msg="$5" + local pass_msg="$6" + + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_config_value "/etc/ssh/sshd_config" "$directive") || true + + if [[ -z "$value" ]]; then + if ! file_exists "/etc/ssh/sshd_config"; then + status="$STATUS_SKIP" + evidence="sshd_config not found" + else + status="$default_status" + evidence="${directive} not set (${default_msg})" + fi + elif [[ "${value,,}" == "${expected,,}" ]]; then + evidence="$pass_msg" + else + status="$STATUS_FAIL" + evidence="${directive} = ${value} (expected ${expected})" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_5() { + _check_ssh_value "5.2.5" "X11Forwarding" "no" "$STATUS_FAIL" \ + "default may allow forwarding" "SSH X11Forwarding is disabled" +} + +_check_ssh_max_int() { + local id="$1" + local directive="$2" + local threshold="$3" + local default_msg="$4" + + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_config_value "/etc/ssh/sshd_config" "$directive") || true + + if [[ -z "$value" ]]; then + if ! file_exists "/etc/ssh/sshd_config"; then + status="$STATUS_SKIP" + evidence="sshd_config not found" + else + status="$STATUS_FAIL" + evidence="${directive} not set (${default_msg})" + fi + elif (( value <= threshold )); then + evidence="SSH ${directive} = ${value}" + else + status="$STATUS_FAIL" + evidence="${directive} = ${value} (expected ${threshold} or less)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_6() { _check_ssh_max_int "5.2.6" "MaxAuthTries" 4 "default is 6"; } + +check_5_2_7() { + _check_ssh_value "5.2.7" "IgnoreRhosts" "yes" "$STATUS_PASS" \ + "default is yes" "SSH IgnoreRhosts = yes" +} + +check_5_2_8() { + _check_ssh_value "5.2.8" "PermitRootLogin" "no" "$STATUS_FAIL" \ + "default allows root login" "SSH root login is disabled (PermitRootLogin = no)" +} + +check_5_2_9() { + _check_ssh_value "5.2.9" "PermitEmptyPasswords" "no" "$STATUS_PASS" \ + "default is no" "SSH empty passwords are disabled" +} + +check_5_2_10() { + _check_ssh_value "5.2.10" "PermitUserEnvironment" "no" "$STATUS_PASS" \ + "default is no" "SSH user environment processing is disabled" +} + +_check_ssh_no_weak() { + local id="$1" + local directive="$2" + local label="$3" + shift 3 + local weak_items=("$@") + + local status="$STATUS_PASS" + local evidence="" + + local value + value=$(get_config_value "/etc/ssh/sshd_config" "$directive") || true + + if [[ -z "$value" ]]; then + if ! file_exists "/etc/ssh/sshd_config"; then + status="$STATUS_SKIP" + evidence="sshd_config not found" + else + status="$STATUS_WARN" + evidence="${directive} not explicitly configured (using defaults)" + fi + record_result "$id" "$status" "$evidence" + return + fi + + local found_weak="" + for weak in "${weak_items[@]}"; do + if echo ",$value," | grep -qi ",${weak},"; then + found_weak="${found_weak:+${found_weak}, }${weak}" + fi + done + + if [[ -n "$found_weak" ]]; then + status="$STATUS_FAIL" + evidence="Weak SSH ${label} found: ${found_weak}" + else + evidence="Only strong SSH ${label} configured" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_2_11() { + _check_ssh_no_weak "5.2.11" "Ciphers" "ciphers" \ + "3des-cbc" "aes128-cbc" "aes192-cbc" "aes256-cbc" \ + "blowfish-cbc" "cast128-cbc" "arcfour" "arcfour128" "arcfour256" +} + +check_5_2_12() { + _check_ssh_no_weak "5.2.12" "MACs" "MACs" \ + "hmac-md5" "hmac-md5-96" "hmac-md5-etm@openssh.com" \ + "hmac-md5-96-etm@openssh.com" "hmac-sha1-96" \ + "hmac-sha1-96-etm@openssh.com" "umac-64@openssh.com" \ + "umac-64-etm@openssh.com" +} + +check_5_2_13() { + _check_ssh_no_weak "5.2.13" "KexAlgorithms" "key exchange algorithms" \ + "diffie-hellman-group1-sha1" "diffie-hellman-group14-sha1" \ + "diffie-hellman-group-exchange-sha1" +} + +check_5_2_14() { _check_ssh_max_int "5.2.14" "LoginGraceTime" 60 "default is 120"; } diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/05_access_password.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/05_access_password.sh new file mode 100644 index 0000000..d6ed503 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/05_access_password.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 05_access_password.sh +# +# CIS Section 5.3-5.5 checks: Password policy and account lockout +# +# Implements controls 5.3.1, 5.4.1-5.4.3, and 5.5.1 for password +# strength and account security. Checks PAM password quality by +# verifying pam_pwquality or pam_cracklib is configured in +# /etc/pam.d/common-password. Validates /etc/login.defs parameters: +# PASS_MAX_DAYS (<=365), PASS_MIN_DAYS (>=1), and PASS_WARN_AGE (>=7) +# using the _check_login_defs_value helper with configurable comparison +# direction. Verifies account lockout is configured via pam_faillock +# or pam_tally2 in /etc/pam.d/common-auth. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - file_exists (SYSROOT-aware path resolution) + +check_5_3_1() { + local id="5.3.1" + local status="$STATUS_PASS" + local evidence="" + local common_password="${SYSROOT}/etc/pam.d/common-password" + + if [[ ! -f "$common_password" ]]; then + status="$STATUS_SKIP" + evidence="/etc/pam.d/common-password not found" + record_result "$id" "$status" "$evidence" + return + fi + + if grep -qE '^\s*password\s+.*pam_pwquality\.so' "$common_password"; then + evidence="pam_pwquality is configured in common-password" + elif grep -qE '^\s*password\s+.*pam_cracklib\.so' "$common_password"; then + evidence="pam_cracklib is configured in common-password" + else + status="$STATUS_FAIL" + evidence="No password quality module (pam_pwquality or pam_cracklib) in common-password" + fi + + record_result "$id" "$status" "$evidence" +} + +_check_login_defs_value() { + local id="$1" + local param="$2" + local comparison="$3" + local threshold="$4" + local pass_desc="$5" + local fail_desc="$6" + + local status="$STATUS_PASS" + local evidence="" + local login_defs="${SYSROOT}/etc/login.defs" + + if [[ ! -f "$login_defs" ]]; then + status="$STATUS_SKIP" + evidence="/etc/login.defs not found" + record_result "$id" "$status" "$evidence" + return + fi + + local value + value=$(grep -E "^\s*${param}\s" "$login_defs" | tail -1 | awk '{print $2}') || true + + if [[ -z "$value" ]]; then + status="$STATUS_FAIL" + evidence="${param} not set in /etc/login.defs" + elif [[ "$comparison" == "le" ]] && (( value <= threshold )); then + evidence="${pass_desc} (${param} = ${value})" + elif [[ "$comparison" == "ge" ]] && (( value >= threshold )); then + evidence="${pass_desc} (${param} = ${value})" + else + status="$STATUS_FAIL" + evidence="${param} = ${value} (${fail_desc})" + fi + + record_result "$id" "$status" "$evidence" +} + +check_5_4_1() { + _check_login_defs_value "5.4.1" "PASS_MAX_DAYS" "le" 365 \ + "Password expiration is configured" \ + "expected 365 or less" +} + +check_5_4_2() { + _check_login_defs_value "5.4.2" "PASS_MIN_DAYS" "ge" 1 \ + "Minimum days between password changes is configured" \ + "expected 1 or more" +} + +check_5_4_3() { + _check_login_defs_value "5.4.3" "PASS_WARN_AGE" "ge" 7 \ + "Password expiration warning is configured" \ + "expected 7 or more" +} + +check_5_5_1() { + local id="5.5.1" + local status="$STATUS_PASS" + local evidence="" + local common_auth="${SYSROOT}/etc/pam.d/common-auth" + + if [[ ! -f "$common_auth" ]]; then + status="$STATUS_SKIP" + evidence="/etc/pam.d/common-auth not found" + record_result "$id" "$status" "$evidence" + return + fi + + if grep -qE '^\s*auth\s+.*pam_faillock\.so' "$common_auth"; then + evidence="Account lockout configured via pam_faillock in common-auth" + elif grep -qE '^\s*auth\s+.*pam_tally2\.so' "$common_auth"; then + evidence="Account lockout configured via pam_tally2 in common-auth" + else + status="$STATUS_FAIL" + evidence="No account lockout module (pam_faillock or pam_tally2) in common-auth" + fi + + record_result "$id" "$status" "$evidence" +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/checks/06_maintenance.sh b/third_party/community/linux-cis-hardening-auditor/src/checks/06_maintenance.sh new file mode 100644 index 0000000..c3acc0f --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/checks/06_maintenance.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# 06_maintenance.sh +# +# CIS Section 6 checks: System Maintenance +# +# Implements controls 6.1.1-6.2.5 for critical file integrity and +# account hygiene. Verifies permissions and ownership on five sensitive +# files (/etc/passwd 644 root:root, /etc/shadow 640 root:shadow, +# /etc/group 644 root:root, /etc/gshadow 640 root:shadow, /etc/passwd- +# 600 root:root) via the _check_file_permissions helper using octal +# comparison. Detects duplicate UIDs, duplicate GIDs, and duplicate +# usernames in /etc/passwd and /etc/group using awk/sort/uniq. Enforces +# that only root has UID 0 and detects legacy NIS '+' entries in +# passwd, shadow, and group files. +# +# Connects to: +# lib/registry.sh - record_result for each control +# lib/utils.sh - run_cmd (stat wrapper) + +_check_file_permissions() { + local id="$1" + local filepath="$2" + local expected_perms="$3" + local expected_owner="$4" + local expected_group="$5" + + local status="$STATUS_PASS" + local evidence="" + local full_path="${SYSROOT}${filepath}" + + if [[ ! -f "$full_path" ]]; then + status="$STATUS_SKIP" + evidence="${filepath} not found" + record_result "$id" "$status" "$evidence" + return + fi + + local file_stat + if file_stat=$(run_cmd stat -c '%a %U %G' "$full_path"); then + local perms owner group + perms=$(echo "$file_stat" | awk '{print $1}') + owner=$(echo "$file_stat" | awk '{print $2}') + group=$(echo "$file_stat" | awk '{print $3}') + + local issues="" + + if [[ "$owner" != "$expected_owner" ]]; then + issues="${issues:+${issues}; }owner is ${owner} (expected ${expected_owner})" + fi + if [[ "$group" != "$expected_group" ]]; then + issues="${issues:+${issues}; }group is ${group} (expected ${expected_group})" + fi + if (( 8#$perms > 8#$expected_perms )); then + issues="${issues:+${issues}; }permissions ${perms} (expected ${expected_perms} or stricter)" + fi + + if [[ -n "$issues" ]]; then + status="$STATUS_FAIL" + evidence="${filepath}: ${issues}" + else + evidence="${filepath} permissions ${perms}, owned by ${owner}:${group}" + fi + else + evidence="${filepath} exists (permissions not verifiable in test mode)" + fi + + record_result "$id" "$status" "$evidence" +} + +check_6_1_1() { + _check_file_permissions "6.1.1" "/etc/passwd" "644" "root" "root" +} + +check_6_1_2() { + _check_file_permissions "6.1.2" "/etc/shadow" "640" "root" "shadow" +} + +check_6_1_3() { + _check_file_permissions "6.1.3" "/etc/group" "644" "root" "root" +} + +check_6_1_4() { + _check_file_permissions "6.1.4" "/etc/gshadow" "640" "root" "shadow" +} + +check_6_1_5() { + _check_file_permissions "6.1.5" "/etc/passwd-" "600" "root" "root" +} + +check_6_2_1() { + local id="6.2.1" + local status="$STATUS_PASS" + local evidence="" + + local passwd_file="${SYSROOT}/etc/passwd" + + if [[ ! -f "$passwd_file" ]]; then + status="$STATUS_SKIP" + evidence="/etc/passwd not found" + record_result "$id" "$status" "$evidence" + return + fi + + local duplicates + duplicates=$(awk -F: '{print $3}' "$passwd_file" | sort | uniq -d) + + if [[ -n "$duplicates" ]]; then + status="$STATUS_FAIL" + evidence="Duplicate UIDs found: ${duplicates}" + else + evidence="No duplicate UIDs found" + fi + + record_result "$id" "$status" "$evidence" +} + +check_6_2_2() { + local id="6.2.2" + local status="$STATUS_PASS" + local evidence="" + + local group_file="${SYSROOT}/etc/group" + + if [[ ! -f "$group_file" ]]; then + status="$STATUS_SKIP" + evidence="/etc/group not found" + record_result "$id" "$status" "$evidence" + return + fi + + local duplicates + duplicates=$(awk -F: '{print $3}' "$group_file" | sort | uniq -d) + + if [[ -n "$duplicates" ]]; then + status="$STATUS_FAIL" + evidence="Duplicate GIDs found: ${duplicates}" + else + evidence="No duplicate GIDs found" + fi + + record_result "$id" "$status" "$evidence" +} + +check_6_2_3() { + local id="6.2.3" + local status="$STATUS_PASS" + local evidence="" + + local passwd_file="${SYSROOT}/etc/passwd" + + if [[ ! -f "$passwd_file" ]]; then + status="$STATUS_SKIP" + evidence="/etc/passwd not found" + record_result "$id" "$status" "$evidence" + return + fi + + local duplicates + duplicates=$(awk -F: '{print $1}' "$passwd_file" | sort | uniq -d) + + if [[ -n "$duplicates" ]]; then + status="$STATUS_FAIL" + evidence="Duplicate user names found: ${duplicates}" + else + evidence="No duplicate user names found" + fi + + record_result "$id" "$status" "$evidence" +} + +check_6_2_4() { + local id="6.2.4" + local status="$STATUS_PASS" + local evidence="" + + local passwd_file="${SYSROOT}/etc/passwd" + + if [[ ! -f "$passwd_file" ]]; then + status="$STATUS_SKIP" + evidence="/etc/passwd not found" + record_result "$id" "$status" "$evidence" + return + fi + + local uid0_accounts + uid0_accounts=$(awk -F: '$3 == 0 {print $1}' "$passwd_file") + + local uid0_count + uid0_count=$(echo "$uid0_accounts" | wc -l) + + if [[ "$uid0_count" -eq 1 && "$uid0_accounts" == "root" ]]; then + evidence="Only root has UID 0" + elif [[ "$uid0_count" -eq 0 ]]; then + status="$STATUS_WARN" + evidence="No accounts with UID 0 found" + else + local non_root + non_root=$(echo "$uid0_accounts" | grep -v '^root$') || true + status="$STATUS_FAIL" + evidence="Non-root accounts with UID 0: ${non_root}" + fi + + record_result "$id" "$status" "$evidence" +} + +check_6_2_5() { + local id="6.2.5" + local status="$STATUS_PASS" + local evidence="" + + local files_to_check=("/etc/passwd" "/etc/shadow" "/etc/group") + local legacy_found="" + + for filepath in "${files_to_check[@]}"; do + local full_path="${SYSROOT}${filepath}" + [[ -f "$full_path" ]] || continue + if grep -q '^+:' "$full_path"; then + legacy_found="${legacy_found:+${legacy_found}, }${filepath}" + fi + done + + if [[ -n "$legacy_found" ]]; then + status="$STATUS_FAIL" + evidence="Legacy + entries found in: ${legacy_found}" + else + evidence="No legacy + entries found in passwd/shadow/group" + fi + + record_result "$id" "$status" "$evidence" +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/cisaudit.sh b/third_party/community/linux-cis-hardening-auditor/src/cisaudit.sh new file mode 100644 index 0000000..e6796c3 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/cisaudit.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# cisaudit.sh +# +# Main entry point for the CIS benchmark compliance auditor +# +# Parses CLI arguments (level filtering, output format, categories, +# baseline compare/save, test-root override, threshold), sources all +# library modules and check files, then dispatches through run_checks +# to execute registered controls, compute_scores for scoring, and +# generate_report for terminal/JSON/HTML output. Supports --list-controls +# for a dry-run control inventory. Exits non-zero when the overall score +# falls below --threshold. +# +# Connects to: +# lib/constants.sh - version, exit codes, ANSI colors, section names +# lib/utils.sh - logging, progress, environment detection helpers +# lib/registry.sh - control registration and result tracking +# lib/engine.sh - score computation (overall, per-section, per-level) +# lib/report_terminal.sh - ANSI terminal report renderer +# lib/report_json.sh - JSON report emitter +# lib/report_html.sh - standalone HTML report generator +# lib/baseline.sh - baseline save/load/diff +# controls/registry_data.sh - CIS control definitions (70+ register_control calls) +# checks/*.sh - per-section check implementations + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +declare -g QUIET="false" +declare -g OPT_LEVEL="all" +declare -g OPT_FORMAT="terminal" +declare -g OPT_OUTPUT="" +declare -g OPT_CATEGORIES="all" +declare -g OPT_BASELINE="" +declare -g OPT_SAVE_BASELINE="" +declare -g OPT_THRESHOLD=0 +declare -g OPT_LIST_CONTROLS="false" + +source "${SCRIPT_DIR}/lib/constants.sh" +source "${SCRIPT_DIR}/lib/utils.sh" +source "${SCRIPT_DIR}/lib/registry.sh" +source "${SCRIPT_DIR}/lib/engine.sh" + +_source_if_exists() { + local path="$1" + if [[ -f "$path" ]]; then + source "$path" + fi +} + +_source_if_exists "${SCRIPT_DIR}/lib/report_terminal.sh" +_source_if_exists "${SCRIPT_DIR}/lib/report_json.sh" +_source_if_exists "${SCRIPT_DIR}/lib/report_html.sh" +_source_if_exists "${SCRIPT_DIR}/lib/baseline.sh" +_source_if_exists "${SCRIPT_DIR}/controls/registry_data.sh" + +for check_file in "${SCRIPT_DIR}"/checks/*.sh; do + [[ -f "$check_file" ]] && source "$check_file" +done + +print_version() { + echo "cisaudit v${VERSION}" +} + +print_help() { + echo -e "${BOLD}cisaudit${RESET} — CIS Benchmark Compliance Auditor for Linux" + echo "" + echo -e "${BOLD}USAGE${RESET}" + echo " cisaudit [OPTIONS]" + echo "" + echo -e "${BOLD}OPTIONS${RESET}" + echo " -l, --level LEVEL Benchmark level: 1, 2, or all (default: all)" + echo " -f, --format FORMAT Output: terminal, json, html (default: terminal)" + echo " -o, --output FILE Write report to file (default: stdout)" + echo " -c, --categories LIST Categories to audit: 1,2,3,4,5,6 (default: all)" + echo " -b, --baseline FILE Compare against a previous baseline JSON" + echo " -s, --save-baseline FILE Save results as a baseline JSON file" + echo " -t, --test-root DIR Use DIR as system root (for testing)" + echo " --threshold PCT Minimum pass % to exit 0 (default: 0)" + echo " --list-controls List all registered controls and exit" + echo " -q, --quiet Suppress progress output" + echo " -v, --version Print version and exit" + echo " -h, --help Print this help and exit" + echo "" + echo -e "${BOLD}EXAMPLES${RESET}" + echo " sudo cisaudit" + echo " sudo cisaudit -l 1 -f json -o report.json" + echo " sudo cisaudit -c 5 -f terminal" + echo " cisaudit -t testdata/fixtures -f json" + echo " cisaudit --list-controls" + echo "" + echo -e "${BOLD}BENCHMARK${RESET}" + echo " ${CIS_BENCHMARK}" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -l|--level) + OPT_LEVEL="$2" + shift 2 + ;; + -f|--format) + OPT_FORMAT="$2" + shift 2 + ;; + -o|--output) + OPT_OUTPUT="$2" + shift 2 + ;; + -c|--categories) + OPT_CATEGORIES="$2" + shift 2 + ;; + -b|--baseline) + OPT_BASELINE="$2" + shift 2 + ;; + -s|--save-baseline) + OPT_SAVE_BASELINE="$2" + shift 2 + ;; + -t|--test-root) + SYSROOT="${2%/}" + shift 2 + ;; + --threshold) + OPT_THRESHOLD="$2" + shift 2 + ;; + --list-controls) + OPT_LIST_CONTROLS="true" + shift + ;; + -q|--quiet) + QUIET="true" + shift + ;; + -v|--version) + print_version + exit "$EXIT_OK" + ;; + -h|--help) + print_help + exit "$EXIT_OK" + ;; + *) + fail "Unknown option: $1 (use --help for usage)" + ;; + esac + done +} + +list_controls() { + local id + printf "${BOLD}%-10s %-8s %-8s %-50s${RESET}\n" "ID" "Level" "Scored" "Title" + printf "%-10s %-8s %-8s %-50s\n" "----------" "--------" "--------" "--------------------------------------------------" + for id in "${REGISTERED_IDS[@]}"; do + printf "%-10s %-8s %-8s %-50s\n" \ + "$id" \ + "${CTRL_LEVEL[$id]}" \ + "${CTRL_SCORED[$id]}" \ + "${CTRL_TITLE[$id]}" + done + echo "" + echo "Total: ${#REGISTERED_IDS[@]} controls" +} + +should_run_check() { + local id="$1" + + if [[ "$OPT_LEVEL" != "all" && "${CTRL_LEVEL[$id]}" != "$OPT_LEVEL" ]]; then + return 1 + fi + + if [[ "$OPT_CATEGORIES" != "all" ]]; then + local section_num="${id%%.*}" + if [[ ! ",$OPT_CATEGORIES," == *",$section_num,"* ]]; then + return 1 + fi + fi + + return 0 +} + +run_checks() { + local total="${#REGISTERED_IDS[@]}" + local count=0 + local id fn + + for id in "${REGISTERED_IDS[@]}"; do + ((count++)) || true + fn="${CTRL_CHECK_FN[$id]}" + + if ! should_run_check "$id"; then + continue + fi + + progress "${count}/${total}" "${CTRL_TITLE[$id]}" + + if declare -f "$fn" &>/dev/null; then + "$fn" || true + else + record_result "$id" "$STATUS_SKIP" "Check function ${fn} not implemented" + fi + done + + clear_progress +} + +generate_report() { + local output="" + + case "$OPT_FORMAT" in + terminal) + if declare -f emit_terminal_report &>/dev/null; then + output=$(emit_terminal_report) + else + fail "Terminal reporter not loaded" + fi + ;; + json) + if declare -f emit_json_report &>/dev/null; then + output=$(emit_json_report) + else + fail "JSON reporter not loaded" + fi + ;; + html) + if declare -f emit_html_report &>/dev/null; then + output=$(emit_html_report) + else + fail "HTML reporter not loaded" + fi + ;; + *) + fail "Unknown format: ${OPT_FORMAT} (use terminal, json, or html)" + ;; + esac + + if [[ -n "$OPT_OUTPUT" ]]; then + echo "$output" > "$OPT_OUTPUT" + info "Report written to ${OPT_OUTPUT}" + else + echo "$output" + fi +} + +main() { + check_bash_version + parse_args "$@" + + if [[ "$OPT_LIST_CONTROLS" == "true" ]]; then + list_controls + exit "$EXIT_OK" + fi + + detect_os + local is_root=0 + check_root && is_root=1 + + info "cisaudit v${VERSION} — ${CIS_BENCHMARK}" + info "Target: ${SYSROOT} | OS: ${DETECTED_ID} ${DETECTED_VERSION} | Level: ${OPT_LEVEL}" + info "Controls: $(get_total_controls) registered" + [[ "$QUIET" == "true" ]] || echo "" >&2 + + run_checks + compute_scores + + generate_report + + if [[ -n "$OPT_SAVE_BASELINE" ]]; then + if declare -f save_baseline &>/dev/null; then + save_baseline "$OPT_SAVE_BASELINE" + info "Baseline saved to ${OPT_SAVE_BASELINE}" + else + warn "Baseline module not loaded" + fi + fi + + if [[ -n "$OPT_BASELINE" ]]; then + if declare -f diff_baseline &>/dev/null; then + diff_baseline "$OPT_BASELINE" + else + warn "Baseline module not loaded" + fi + fi + + local score_int="${SCORE_OVERALL%.*}" + if (( score_int < OPT_THRESHOLD )); then + exit "$EXIT_FAIL" + fi + + exit "$EXIT_OK" +} + +main "$@" diff --git a/third_party/community/linux-cis-hardening-auditor/src/controls/registry_data.sh b/third_party/community/linux-cis-hardening-auditor/src/controls/registry_data.sh new file mode 100644 index 0000000..269c8db --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/controls/registry_data.sh @@ -0,0 +1,894 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# registry_data.sh +# +# CIS benchmark control definitions database +# +# Contains 70+ register_control calls that populate the control +# registry with every audited CIS benchmark item. Each entry +# specifies the control ID, parent section name, human-readable +# title, CIS level (1 or 2), scored flag, a rationale paragraph +# explaining the security impact, and a remediation command. Covers +# all six sections: Initial Setup (filesystem modules, /tmp hardening, +# package repos, bootloader, kernel protections), Services (xinetd +# through rsync), Network Configuration (sysctl parameters, firewall +# policies, wireless, uncommon protocols), Logging and Auditing +# (auditd, audit rules, rsyslog), Access/Authentication/Authorization +# (cron, SSH hardening, password policy, account lockout), and System +# Maintenance (file permissions, duplicate IDs, legacy entries). +# +# Connects to: +# lib/registry.sh - register_control function stores each definition +# checks/*.sh - check functions that implement each control ID + +register_control "1.1.1" \ + "Initial Setup" \ + "Ensure mounting of cramfs is disabled" \ + "1" \ + "yes" \ + "The cramfs filesystem type is a compressed read-only Linux filesystem embedded in small footprint systems. Disabling cramfs eliminates a potential attack surface by preventing rarely-used filesystem modules from being loaded. Most modern systems have no legitimate need for cramfs support." \ + "echo 'install cramfs /bin/true' >> /etc/modprobe.d/cramfs.conf && echo 'blacklist cramfs' >> /etc/modprobe.d/cramfs.conf" + +register_control "1.1.2" \ + "Initial Setup" \ + "Ensure mounting of freevxfs is disabled" \ + "1" \ + "yes" \ + "The freevxfs filesystem type is a free version of the Veritas filesystem used by HP-UX and other commercial systems. Disabling this module reduces the attack surface by removing support for an uncommon filesystem. Exploitation of vulnerabilities in rarely maintained filesystem drivers is a known threat vector." \ + "echo 'install freevxfs /bin/true' >> /etc/modprobe.d/freevxfs.conf && echo 'blacklist freevxfs' >> /etc/modprobe.d/freevxfs.conf" + +register_control "1.1.3" \ + "Initial Setup" \ + "Ensure mounting of jffs2 is disabled" \ + "1" \ + "yes" \ + "The jffs2 filesystem type is a log-structured filesystem used in flash memory devices. Disabling this module prevents potential exploitation through crafted jffs2 filesystem images. Standard servers typically do not require jffs2 support as it is designed for embedded systems." \ + "echo 'install jffs2 /bin/true' >> /etc/modprobe.d/jffs2.conf && echo 'blacklist jffs2' >> /etc/modprobe.d/jffs2.conf" + +register_control "1.1.4" \ + "Initial Setup" \ + "Ensure mounting of hfs is disabled" \ + "1" \ + "yes" \ + "The hfs filesystem type is the native filesystem for older Macintosh systems. Disabling hfs prevents potential exploits targeting this legacy filesystem driver. Linux servers rarely need to mount Apple HFS volumes and removing the capability reduces risk." \ + "echo 'install hfs /bin/true' >> /etc/modprobe.d/hfs.conf && echo 'blacklist hfs' >> /etc/modprobe.d/hfs.conf" + +register_control "1.1.5" \ + "Initial Setup" \ + "Ensure mounting of hfsplus is disabled" \ + "1" \ + "yes" \ + "The hfsplus filesystem type is the successor to hfs used on modern Apple systems. Disabling this kernel module reduces exposure to vulnerabilities in an unnecessary filesystem driver. Servers should not need to interact with Apple-formatted media." \ + "echo 'install hfsplus /bin/true' >> /etc/modprobe.d/hfsplus.conf && echo 'blacklist hfsplus' >> /etc/modprobe.d/hfsplus.conf" + +register_control "1.1.6" \ + "Initial Setup" \ + "Ensure mounting of squashfs is disabled" \ + "2" \ + "yes" \ + "The squashfs filesystem is a compressed read-only filesystem commonly used for live CDs and snap packages. Disabling squashfs prevents attackers from using crafted squashfs images to exploit kernel vulnerabilities. This is a Level 2 control because disabling squashfs may break snap package functionality." \ + "echo 'install squashfs /bin/true' >> /etc/modprobe.d/squashfs.conf && echo 'blacklist squashfs' >> /etc/modprobe.d/squashfs.conf" + +register_control "1.1.7" \ + "Initial Setup" \ + "Ensure mounting of udf is disabled" \ + "1" \ + "yes" \ + "The udf filesystem type is the universal disk format used on DVDs and newer optical media. Disabling this module prevents potential exploitation of vulnerabilities in the UDF driver through crafted media. Servers generally do not need optical disc filesystem support." \ + "echo 'install udf /bin/true' >> /etc/modprobe.d/udf.conf && echo 'blacklist udf' >> /etc/modprobe.d/udf.conf" + +register_control "1.1.8" \ + "Initial Setup" \ + "Ensure mounting of vfat is limited" \ + "2" \ + "yes" \ + "The vfat filesystem type is used for the FAT filesystem common on removable media and UEFI system partitions. On non-UEFI systems this module should be disabled to reduce the attack surface. UEFI systems require vfat for the EFI System Partition so this control must be evaluated accordingly." \ + "echo 'install vfat /bin/true' >> /etc/modprobe.d/vfat.conf && echo 'blacklist vfat' >> /etc/modprobe.d/vfat.conf" + +register_control "1.2.1" \ + "Initial Setup" \ + "Ensure /tmp is a separate partition" \ + "1" \ + "yes" \ + "The /tmp directory is a world-writable location used for temporary file storage by all users and applications. Mounting /tmp as a separate partition enables administrators to apply restrictive mount options. This prevents /tmp from consuming root filesystem space and allows enforcing noexec, nosuid, and nodev protections." \ + "echo 'tmpfs /tmp tmpfs defaults,rw,nosuid,nodev,noexec,relatime 0 0' >> /etc/fstab && mount -o remount /tmp" + +register_control "1.2.2" \ + "Initial Setup" \ + "Ensure noexec option set on /tmp" \ + "1" \ + "yes" \ + "The noexec mount option prevents execution of binaries stored on the /tmp partition. This blocks attackers from downloading and running malicious executables in the world-writable /tmp directory. Setting noexec on /tmp is a critical defense-in-depth measure against privilege escalation attacks." \ + "mount -o remount,noexec /tmp" + +register_control "1.2.3" \ + "Initial Setup" \ + "Ensure nosuid option set on /tmp" \ + "1" \ + "yes" \ + "The nosuid mount option prevents setuid and setgid bits from taking effect on the /tmp partition. This stops attackers from placing setuid binaries in /tmp to escalate privileges. Combined with noexec this provides strong protection against local privilege escalation via temporary files." \ + "mount -o remount,nosuid /tmp" + +register_control "1.2.4" \ + "Initial Setup" \ + "Ensure nodev option set on /tmp" \ + "1" \ + "yes" \ + "The nodev mount option prevents the creation and use of block and character special devices on the /tmp partition. Attackers could create device files in /tmp to access hardware directly and bypass security controls. Setting nodev on /tmp ensures that no device nodes can be exploited from temporary storage." \ + "mount -o remount,nodev /tmp" + +register_control "1.3.1" \ + "Initial Setup" \ + "Ensure package manager repositories are configured" \ + "1" \ + "no" \ + "Package repositories provide a trusted source for installing and updating software on the system. Verifying that repositories are correctly configured ensures that security patches come from legitimate sources. Misconfigured or unauthorized repositories could introduce backdoored or vulnerable packages." \ + "apt-cache policy" + +register_control "1.3.2" \ + "Initial Setup" \ + "Ensure GPG keys are configured" \ + "1" \ + "no" \ + "GPG keys are used to authenticate packages downloaded from repositories before installation. Ensuring GPG keys are properly configured prevents the installation of tampered or unsigned packages. Without valid GPG keys an attacker could perform a man-in-the-middle attack on package downloads." \ + "apt-key list" + +register_control "1.4.1" \ + "Initial Setup" \ + "Ensure bootloader password is set" \ + "1" \ + "yes" \ + "Setting a bootloader password prevents unauthorized users from modifying boot parameters at the GRUB menu. Without this protection an attacker with physical access could boot into single user mode or modify kernel parameters. A GRUB password ensures that only authorized administrators can alter the boot process." \ + "grub-mkpasswd-pbkdf2 && cat <> /etc/grub.d/40_custom\nset superusers=\"root\"\npassword_pbkdf2 root \nGRUBEOF\nupdate-grub" + +register_control "1.4.2" \ + "Initial Setup" \ + "Ensure permissions on bootloader config are configured" \ + "1" \ + "yes" \ + "The GRUB configuration file contains sensitive boot parameters and potentially the bootloader password hash. Restricting permissions ensures only root can read or modify the bootloader configuration. World-readable bootloader configs could leak password hashes or reveal system configuration details." \ + "chown root:root /boot/grub/grub.cfg && chmod 600 /boot/grub/grub.cfg" + +register_control "1.4.3" \ + "Initial Setup" \ + "Ensure authentication required for single user mode" \ + "1" \ + "yes" \ + "Single user mode provides a root shell with minimal services for system recovery. Without authentication requirements an attacker with physical access gains immediate root access through single user mode. Requiring a root password for single user mode prevents unauthorized recovery-mode access." \ + "passwd root" + +register_control "1.5.1" \ + "Initial Setup" \ + "Ensure address space layout randomization is enabled" \ + "1" \ + "yes" \ + "Address Space Layout Randomization (ASLR) randomizes the memory addresses used by processes including the stack, heap, and libraries. This makes it significantly harder for attackers to exploit memory corruption vulnerabilities reliably. ASLR should be set to mode 2 for full randomization of stack, VDSO, shared memory, and data segments." \ + "sysctl -w kernel.randomize_va_space=2 && echo 'kernel.randomize_va_space = 2' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "1.5.2" \ + "Initial Setup" \ + "Ensure core dumps are restricted" \ + "1" \ + "yes" \ + "Core dumps capture the memory contents of a process when it crashes and may contain sensitive data such as passwords or encryption keys. Restricting core dumps prevents users from creating files that could expose this information. Setuid programs must also be prevented from dumping core via the suid_dumpable sysctl." \ + "echo '* hard core 0' >> /etc/security/limits.conf && sysctl -w fs.suid_dumpable=0 && echo 'fs.suid_dumpable = 0' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "1.5.3" \ + "Initial Setup" \ + "Ensure prelink is not installed" \ + "1" \ + "yes" \ + "Prelink modifies ELF shared libraries and executables to reduce startup time by pre-computing symbol resolutions. However prelink interferes with AIDE and other integrity-checking tools by changing binary checksums after each prelink run. Removing prelink ensures that file integrity monitoring produces reliable and consistent results." \ + "prelink -ua && apt-get remove -y prelink" + +register_control "2.1.1" \ + "Services" \ + "Ensure xinetd is not installed" \ + "1" \ + "yes" \ + "The xinetd service is an extended internet services daemon that manages network connections for various legacy services. Modern systems use systemd socket activation instead making xinetd unnecessary. Removing xinetd eliminates a potential entry point for attacks targeting legacy network services." \ + "apt-get remove -y xinetd" + +register_control "2.1.2" \ + "Services" \ + "Ensure openbsd-inetd is not installed" \ + "1" \ + "yes" \ + "The openbsd-inetd package provides a legacy internet super-server similar to xinetd. Like xinetd it has been superseded by systemd socket activation on modern distributions. Removing it reduces the attack surface by eliminating an unnecessary network service dispatcher." \ + "apt-get remove -y openbsd-inetd" + +register_control "2.2.1" \ + "Services" \ + "Ensure X Window System is not installed" \ + "1" \ + "yes" \ + "The X Window System provides a graphical user interface framework that is unnecessary on server systems. Running X11 significantly increases the attack surface with a large codebase that has a history of security vulnerabilities. Servers should be administered remotely via SSH and do not require graphical display capabilities." \ + "apt-get remove -y xserver-xorg*" + +register_control "2.2.2" \ + "Services" \ + "Ensure Avahi Server is not installed" \ + "1" \ + "yes" \ + "Avahi is a system that implements multicast DNS/DNS-SD for zero-configuration networking and automatic service discovery. On a server this service is unnecessary and exposes the system to multicast-based network attacks. Removing Avahi eliminates a service that could be exploited for network reconnaissance or denial of service." \ + "systemctl disable avahi-daemon && apt-get remove -y avahi-daemon" + +register_control "2.2.3" \ + "Services" \ + "Ensure CUPS is not installed" \ + "1" \ + "no" \ + "CUPS provides the Common Unix Printing System that enables a system to act as a print server. Unless the server specifically needs to manage printers CUPS should be removed to reduce the attack surface. CUPS listens on network ports and has had multiple remote code execution vulnerabilities in the past." \ + "apt-get remove -y cups" + +register_control "2.2.4" \ + "Services" \ + "Ensure DHCP Server is not installed" \ + "1" \ + "yes" \ + "The ISC DHCP server package allows a system to act as a DHCP server assigning IP addresses to network clients. Unless the system is specifically designated as a DHCP server this service should not be present. An unauthorized DHCP server can be used to perform man-in-the-middle attacks on the local network." \ + "apt-get remove -y isc-dhcp-server" + +register_control "2.2.5" \ + "Services" \ + "Ensure LDAP server is not installed" \ + "1" \ + "yes" \ + "The slapd package provides an OpenLDAP directory server for centralized authentication and directory services. Unless the system is specifically designated as an LDAP server this service increases the attack surface unnecessarily. LDAP servers handle sensitive authentication data and must be carefully secured if required." \ + "apt-get remove -y slapd" + +register_control "2.2.6" \ + "Services" \ + "Ensure NFS is not installed" \ + "1" \ + "yes" \ + "NFS allows sharing directories over the network and is a common target for exploitation. Unless the system specifically needs to serve NFS shares the server package should be removed. NFS has historically had numerous security issues including weak authentication and data exposure." \ + "apt-get remove -y nfs-kernel-server" + +register_control "2.2.7" \ + "Services" \ + "Ensure DNS Server is not installed" \ + "1" \ + "yes" \ + "The BIND DNS server package allows a system to act as a domain name resolver for the network. DNS servers are high-value targets frequently exploited for cache poisoning and denial of service attacks. Unless the system is a designated DNS server this package should be removed." \ + "apt-get remove -y bind9" + +register_control "2.2.8" \ + "Services" \ + "Ensure FTP Server is not installed" \ + "1" \ + "yes" \ + "FTP is an insecure file transfer protocol that transmits credentials and data in cleartext. The vsftpd package or any other FTP server should be removed in favor of secure alternatives like SFTP. Attackers can trivially capture FTP credentials through network sniffing." \ + "apt-get remove -y vsftpd" + +register_control "2.2.9" \ + "Services" \ + "Ensure HTTP Server is not installed" \ + "1" \ + "yes" \ + "Web server packages such as Apache or nginx should not be installed unless the system is a designated web server. Web servers present a large attack surface with complex configurations that are frequently misconfigured. Removing unused web servers eliminates a major category of potential remote exploitation." \ + "apt-get remove -y apache2 nginx" + +register_control "2.2.10" \ + "Services" \ + "Ensure IMAP and POP3 server is not installed" \ + "1" \ + "yes" \ + "IMAP and POP3 servers like Dovecot provide email retrieval services for mail clients. Unless the system is a designated mail server these services needlessly expand the attack surface. Mail servers handle sensitive communications and require dedicated security hardening if needed." \ + "apt-get remove -y dovecot-imapd dovecot-pop3d" + +register_control "2.2.11" \ + "Services" \ + "Ensure Samba is not installed" \ + "1" \ + "yes" \ + "Samba provides Windows-compatible file and print sharing services using the SMB/CIFS protocol. Unless the system must share resources with Windows clients Samba should be removed. The SMB protocol has been the target of numerous critical vulnerabilities including the WannaCry ransomware exploit." \ + "apt-get remove -y samba" + +register_control "2.2.12" \ + "Services" \ + "Ensure HTTP Proxy Server is not installed" \ + "1" \ + "yes" \ + "HTTP proxy servers like Squid allow a system to act as an intermediary for web requests. Unless the system is a designated proxy this service increases the attack surface and could be abused for traffic interception. Proxy servers can be leveraged to bypass network security controls if misconfigured." \ + "apt-get remove -y squid" + +register_control "2.2.13" \ + "Services" \ + "Ensure SNMP Server is not installed" \ + "1" \ + "yes" \ + "SNMP provides network management and monitoring capabilities but older versions transmit community strings in cleartext. The SNMP server should be removed unless it is specifically needed for network monitoring infrastructure. Attackers can exploit SNMP to gather detailed system information or modify device configurations." \ + "apt-get remove -y snmpd" + +register_control "2.2.14" \ + "Services" \ + "Ensure NIS Server is not installed" \ + "1" \ + "yes" \ + "The Network Information Service is a legacy client-server directory service for distributing system configuration data. NIS transmits all data including password hashes unencrypted over the network. Modern systems should use LDAP with TLS or other encrypted directory services instead of NIS." \ + "apt-get remove -y nis" + +register_control "2.2.15" \ + "Services" \ + "Ensure mail transfer agent is configured for local-only mode" \ + "1" \ + "yes" \ + "Mail Transfer Agents like Postfix or Exim handle email delivery and may listen on network port 25. Unless the system is a designated mail relay the MTA should only accept connections from localhost. Configuring local-only mode prevents the system from being used as an open relay for spam or phishing attacks." \ + "sed -i 's/^inet_interfaces.*/inet_interfaces = loopback-only/' /etc/postfix/main.cf && systemctl restart postfix" + +register_control "2.2.16" \ + "Services" \ + "Ensure rsync service is not installed" \ + "1" \ + "yes" \ + "The rsync service provides fast file synchronization over the network and can run as a daemon on port 873. Unless specifically required for system administration rsync should be removed to reduce exposure. An improperly configured rsync daemon can expose sensitive files to unauthorized network access." \ + "apt-get remove -y rsync" + +register_control "3.1.1" \ + "Network Configuration" \ + "Ensure IP forwarding is disabled" \ + "1" \ + "yes" \ + "IP forwarding allows the system to route packets between network interfaces acting as a router. Unless the system is specifically designed as a router or gateway IP forwarding must be disabled. An attacker could exploit IP forwarding to redirect network traffic through a compromised system for interception." \ + "sysctl -w net.ipv4.ip_forward=0 && echo 'net.ipv4.ip_forward = 0' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.1.2" \ + "Network Configuration" \ + "Ensure packet redirect sending is disabled" \ + "1" \ + "yes" \ + "ICMP redirect messages inform hosts of more efficient routes and should only be sent by routers. A non-router system sending redirects could be exploited to manipulate routing tables on neighboring hosts. Disabling send_redirects prevents the system from being used in route manipulation attacks." \ + "sysctl -w net.ipv4.conf.all.send_redirects=0 && sysctl -w net.ipv4.conf.default.send_redirects=0 && echo 'net.ipv4.conf.all.send_redirects = 0' >> /etc/sysctl.d/99-cisaudit.conf && echo 'net.ipv4.conf.default.send_redirects = 0' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.1.3" \ + "Network Configuration" \ + "Ensure source routed packets are not accepted" \ + "1" \ + "yes" \ + "Source routed packets allow the sender to specify the route through the network bypassing normal routing decisions. Attackers use source routing to direct traffic through compromised systems for eavesdropping or to bypass firewall rules. Disabling acceptance of source routed packets forces all traffic to follow the normal routing path." \ + "sysctl -w net.ipv4.conf.all.accept_source_route=0 && sysctl -w net.ipv4.conf.default.accept_source_route=0 && echo 'net.ipv4.conf.all.accept_source_route = 0' >> /etc/sysctl.d/99-cisaudit.conf && echo 'net.ipv4.conf.default.accept_source_route = 0' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.1.4" \ + "Network Configuration" \ + "Ensure ICMP redirects are not accepted" \ + "1" \ + "yes" \ + "ICMP redirect messages can alter the routing table of a host directing traffic to a different gateway. Attackers can send forged ICMP redirects to perform man-in-the-middle attacks by rerouting traffic through a malicious host. Disabling ICMP redirect acceptance prevents these routing table manipulation attacks." \ + "sysctl -w net.ipv4.conf.all.accept_redirects=0 && sysctl -w net.ipv4.conf.default.accept_redirects=0 && echo 'net.ipv4.conf.all.accept_redirects = 0' >> /etc/sysctl.d/99-cisaudit.conf && echo 'net.ipv4.conf.default.accept_redirects = 0' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.2.1" \ + "Network Configuration" \ + "Ensure suspicious packets are logged" \ + "1" \ + "yes" \ + "Logging martian packets records instances where the system receives packets with impossible source addresses. These packets often indicate spoofing attempts, misconfigured systems, or ongoing attacks. Enabling log_martians provides valuable forensic data for security incident investigation." \ + "sysctl -w net.ipv4.conf.all.log_martians=1 && sysctl -w net.ipv4.conf.default.log_martians=1 && echo 'net.ipv4.conf.all.log_martians = 1' >> /etc/sysctl.d/99-cisaudit.conf && echo 'net.ipv4.conf.default.log_martians = 1' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.2.2" \ + "Network Configuration" \ + "Ensure broadcast ICMP requests are ignored" \ + "1" \ + "yes" \ + "Broadcast ICMP echo requests can be used in Smurf amplification attacks where an attacker sends pings to a broadcast address causing all hosts to respond. Ignoring broadcast ICMP requests prevents the system from participating in these amplification attacks. This is a fundamental network hardening measure against denial of service." \ + "sysctl -w net.ipv4.icmp_echo_ignore_broadcasts=1 && echo 'net.ipv4.icmp_echo_ignore_broadcasts = 1' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.2.3" \ + "Network Configuration" \ + "Ensure bogus ICMP responses are ignored" \ + "1" \ + "yes" \ + "Some routers send bogus ICMP error responses that violate RFC standards and can fill up log files. Ignoring these bogus responses prevents unnecessary log noise and potential log-based denial of service. This setting reduces the impact of malformed ICMP traffic on system stability." \ + "sysctl -w net.ipv4.icmp_ignore_bogus_error_responses=1 && echo 'net.ipv4.icmp_ignore_bogus_error_responses = 1' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.2.4" \ + "Network Configuration" \ + "Ensure Reverse Path Filtering is enabled" \ + "1" \ + "yes" \ + "Reverse Path Filtering validates that incoming packets arrive on the interface that would be used to reach the source address. This prevents IP spoofing by dropping packets whose source address does not match the expected routing path. Enabling strict mode (value 1) provides the strongest anti-spoofing protection." \ + "sysctl -w net.ipv4.conf.all.rp_filter=1 && sysctl -w net.ipv4.conf.default.rp_filter=1 && echo 'net.ipv4.conf.all.rp_filter = 1' >> /etc/sysctl.d/99-cisaudit.conf && echo 'net.ipv4.conf.default.rp_filter = 1' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.2.5" \ + "Network Configuration" \ + "Ensure TCP SYN Cookies is enabled" \ + "1" \ + "yes" \ + "TCP SYN Cookies protect against SYN flood attacks by encoding connection state in the sequence number instead of allocating memory for half-open connections. When the SYN queue is full the kernel uses cryptographic cookies to validate legitimate connections. This is an essential defense against one of the most common denial of service attack vectors." \ + "sysctl -w net.ipv4.tcp_syncookies=1 && echo 'net.ipv4.tcp_syncookies = 1' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.2.6" \ + "Network Configuration" \ + "Ensure IPv6 router advertisements are not accepted" \ + "1" \ + "yes" \ + "IPv6 router advertisements allow routers to automatically configure IPv6 addresses and routing on hosts. Accepting rogue router advertisements could allow an attacker to redirect IPv6 traffic or perform man-in-the-middle attacks. Disabling acceptance of router advertisements prevents unauthorized network reconfiguration via IPv6." \ + "sysctl -w net.ipv6.conf.all.accept_ra=0 && sysctl -w net.ipv6.conf.default.accept_ra=0 && echo 'net.ipv6.conf.all.accept_ra = 0' >> /etc/sysctl.d/99-cisaudit.conf && echo 'net.ipv6.conf.default.accept_ra = 0' >> /etc/sysctl.d/99-cisaudit.conf" + +register_control "3.3.1" \ + "Network Configuration" \ + "Ensure iptables is installed" \ + "1" \ + "yes" \ + "Iptables provides host-based firewall functionality that is essential for controlling network traffic to and from the system. Without a firewall all network services are exposed to the network without any filtering. Installing iptables is the prerequisite for implementing any host-based firewall policy." \ + "apt-get install -y iptables" + +register_control "3.3.2" \ + "Network Configuration" \ + "Ensure default deny firewall policy for INPUT" \ + "1" \ + "yes" \ + "A default deny policy for the INPUT chain drops all incoming packets that do not match an explicit allow rule. This ensures that only specifically authorized traffic can reach the system. Without a default deny policy any traffic not explicitly blocked will be accepted." \ + "iptables -P INPUT DROP" + +register_control "3.3.3" \ + "Network Configuration" \ + "Ensure default deny firewall policy for FORWARD" \ + "1" \ + "yes" \ + "A default deny policy for the FORWARD chain prevents the system from routing traffic between interfaces unless explicitly permitted. This is critical for systems with multiple network interfaces to prevent unauthorized traffic forwarding. Even on single-interface systems this policy provides defense in depth." \ + "iptables -P FORWARD DROP" + +register_control "3.3.4" \ + "Network Configuration" \ + "Ensure default deny firewall policy for OUTPUT" \ + "1" \ + "yes" \ + "A default deny policy for the OUTPUT chain blocks all outgoing packets that do not match an explicit allow rule. This restricts what traffic the system can send preventing compromised services from establishing outbound connections. Outbound filtering is essential for detecting and containing breaches." \ + "iptables -P OUTPUT DROP" + +register_control "3.3.5" \ + "Network Configuration" \ + "Ensure firewall rules exist for all open ports" \ + "1" \ + "yes" \ + "Every listening network port should have a corresponding firewall rule that explicitly allows or denies access. Ports without firewall rules rely solely on the default policy which may change over time. Explicit rules provide documentation of intended network access and ensure consistent security posture." \ + "iptables -L INPUT -v -n" + +register_control "3.4.1" \ + "Network Configuration" \ + "Ensure wireless interfaces are disabled" \ + "1" \ + "no" \ + "Wireless interfaces provide an additional attack vector through which unauthorized users can access the network. Server systems rarely need wireless connectivity and should use wired connections for reliability and security. Disabling wireless interfaces prevents rogue access point attacks and unauthorized wireless connections." \ + "ip link set wlan0 down && echo 'blacklist cfg80211' >> /etc/modprobe.d/wireless.conf" + +register_control "3.4.2" \ + "Network Configuration" \ + "Ensure DCCP is disabled" \ + "2" \ + "yes" \ + "The Datagram Congestion Control Protocol is a transport protocol that is rarely used on production systems. Disabling DCCP reduces the kernel attack surface by removing an unnecessary protocol handler. Vulnerabilities in rarely audited protocol implementations can provide exploitation opportunities." \ + "echo 'install dccp /bin/true' >> /etc/modprobe.d/dccp.conf && echo 'blacklist dccp' >> /etc/modprobe.d/dccp.conf" + +register_control "3.4.3" \ + "Network Configuration" \ + "Ensure SCTP is disabled" \ + "2" \ + "yes" \ + "The Stream Control Transmission Protocol is a transport protocol primarily used in telecommunications. Unless specifically required SCTP should be disabled to minimize the kernel attack surface. Reducing the number of loaded protocol modules limits the potential for kernel-level exploitation." \ + "echo 'install sctp /bin/true' >> /etc/modprobe.d/sctp.conf && echo 'blacklist sctp' >> /etc/modprobe.d/sctp.conf" + +register_control "3.4.4" \ + "Network Configuration" \ + "Ensure RDS is disabled" \ + "2" \ + "yes" \ + "The Reliable Datagram Sockets protocol is a transport protocol developed by Oracle for high-performance cluster communication. RDS is not needed on most systems and has had critical kernel vulnerabilities in the past. Disabling RDS eliminates a protocol module with a history of security issues." \ + "echo 'install rds /bin/true' >> /etc/modprobe.d/rds.conf && echo 'blacklist rds' >> /etc/modprobe.d/rds.conf" + +register_control "3.4.5" \ + "Network Configuration" \ + "Ensure TIPC is disabled" \ + "2" \ + "yes" \ + "The Transparent Inter-Process Communication protocol is a cluster communication protocol designed for intra-cluster messaging. TIPC is unnecessary on systems that are not part of a TIPC-based cluster. Disabling this module reduces kernel attack surface by removing an uncommon network protocol handler." \ + "echo 'install tipc /bin/true' >> /etc/modprobe.d/tipc.conf && echo 'blacklist tipc' >> /etc/modprobe.d/tipc.conf" + +register_control "4.1.1" \ + "Logging and Auditing" \ + "Ensure auditd is installed" \ + "2" \ + "yes" \ + "The Linux Audit daemon provides detailed system call auditing and security event logging. Auditd is essential for forensic analysis, compliance monitoring, and detecting unauthorized access attempts. Without auditd the system lacks the ability to record detailed security-relevant events." \ + "apt-get install -y auditd audispd-plugins" + +register_control "4.1.2" \ + "Logging and Auditing" \ + "Ensure auditd service is enabled" \ + "2" \ + "yes" \ + "The auditd service must be enabled to start automatically at boot to ensure continuous security event logging. A gap in audit logging between boot and manual service start could allow attackers to operate undetected. Enabling the service guarantees audit coverage from the earliest possible point in the boot process." \ + "systemctl enable auditd" + +register_control "4.1.3" \ + "Logging and Auditing" \ + "Ensure auditing for processes that start prior to auditd is enabled" \ + "2" \ + "yes" \ + "Some processes start before the auditd service is running and their activity would not be captured by default. Adding audit=1 to the kernel boot parameters ensures the kernel begins auditing from the earliest stages of boot. This closes the window where malicious activity could occur before the audit daemon starts." \ + "sed -i 's/GRUB_CMDLINE_LINUX=\"/GRUB_CMDLINE_LINUX=\"audit=1 /' /etc/default/grub && update-grub" + +register_control "4.1.4" \ + "Logging and Auditing" \ + "Ensure audit_backlog_limit is sufficient" \ + "2" \ + "yes" \ + "The audit_backlog_limit parameter sets the maximum number of audit records that can be queued before the kernel begins dropping events. An insufficient backlog limit can cause audit events to be lost during periods of high system activity. Setting this to at least 8192 ensures adequate buffer space for burst audit activity." \ + "sed -i 's/GRUB_CMDLINE_LINUX=\"/GRUB_CMDLINE_LINUX=\"audit_backlog_limit=8192 /' /etc/default/grub && update-grub" + +register_control "4.1.5" \ + "Logging and Auditing" \ + "Ensure events that modify date and time information are collected" \ + "2" \ + "yes" \ + "Monitoring changes to the system clock is critical because attackers often modify timestamps to cover their tracks. Audit rules should capture all calls to adjtimex, settimeofday, stime, and clock_settime as well as changes to /etc/localtime. Time modification events are essential for maintaining the integrity of audit log timelines." \ + "echo '-a always,exit -F arch=b64 -S adjtimex -S settimeofday -k time-change' >> /etc/audit/rules.d/cisaudit.rules && echo '-a always,exit -F arch=b32 -S adjtimex -S settimeofday -S stime -k time-change' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/localtime -p wa -k time-change' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.6" \ + "Logging and Auditing" \ + "Ensure events that modify user/group information are collected" \ + "2" \ + "yes" \ + "Changes to user and group files such as /etc/passwd, /etc/shadow, /etc/group, and /etc/gshadow must be monitored. Unauthorized modifications to these files could indicate an attacker creating backdoor accounts or escalating privileges. Audit rules on identity files provide early detection of account manipulation." \ + "echo '-w /etc/group -p wa -k identity' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/passwd -p wa -k identity' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/gshadow -p wa -k identity' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/shadow -p wa -k identity' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.7" \ + "Logging and Auditing" \ + "Ensure events that modify the system network environment are collected" \ + "2" \ + "yes" \ + "Network configuration changes including modifications to /etc/hosts, /etc/hostname, and sethostname system calls should be audited. Unauthorized network configuration changes could redirect traffic or hide malicious activity. Monitoring these events helps detect network-based attacks and unauthorized system reconfiguration." \ + "echo '-a always,exit -F arch=b64 -S sethostname -S setdomainname -k system-locale' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/issue -p wa -k system-locale' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/hosts -p wa -k system-locale' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/hostname -p wa -k system-locale' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.8" \ + "Logging and Auditing" \ + "Ensure events that modify the system MAC policy are collected" \ + "2" \ + "yes" \ + "Mandatory Access Control policies defined by AppArmor or SELinux provide an additional layer of access restrictions beyond standard permissions. Changes to MAC policies could weaken security controls or disable protection for critical services. Auditing modifications to /etc/apparmor/ and /etc/selinux/ detects unauthorized policy weakening." \ + "echo '-w /etc/apparmor/ -p wa -k MAC-policy' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /etc/apparmor.d/ -p wa -k MAC-policy' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.9" \ + "Logging and Auditing" \ + "Ensure login and logout events are collected" \ + "2" \ + "yes" \ + "Login and logout events recorded in /var/log/faillog, /var/log/lastlog, and /var/log/tallylog provide critical authentication audit trails. Monitoring these files helps detect brute force attacks, unauthorized access, and anomalous login patterns. These records are essential for security incident investigation and compliance reporting." \ + "echo '-w /var/log/faillog -p wa -k logins' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /var/log/lastlog -p wa -k logins' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /var/log/tallylog -p wa -k logins' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.10" \ + "Logging and Auditing" \ + "Ensure session initiation information is collected" \ + "2" \ + "yes" \ + "Session initiation files such as /var/run/utmp, /var/log/wtmp, and /var/log/btmp track active sessions and login history. Monitoring these files provides visibility into who is logged in and historical session data. Unauthorized modifications to session tracking files could indicate an attacker attempting to hide their presence." \ + "echo '-w /var/run/utmp -p wa -k session' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /var/log/wtmp -p wa -k logins' >> /etc/audit/rules.d/cisaudit.rules && echo '-w /var/log/btmp -p wa -k logins' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.11" \ + "Logging and Auditing" \ + "Ensure discretionary access control permission modification events are collected" \ + "2" \ + "yes" \ + "DAC permission changes via chmod, fchmod, chown, fchown, lchown, setxattr, and similar system calls should be audited. Unauthorized permission changes could grant attackers access to sensitive files or enable privilege escalation. Collecting these events provides a complete record of who changed permissions on which files." \ + "echo '-a always,exit -F arch=b64 -S chmod -S fchmod -S fchmodat -k perm_mod' >> /etc/audit/rules.d/cisaudit.rules && echo '-a always,exit -F arch=b64 -S chown -S fchown -S fchownat -S lchown -k perm_mod' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.12" \ + "Logging and Auditing" \ + "Ensure unsuccessful unauthorized file access attempts are collected" \ + "2" \ + "yes" \ + "Failed file access attempts where the system returns EACCES or EPERM indicate either misconfigured applications or potential attack activity. Monitoring these failures helps detect unauthorized users attempting to access files beyond their permission level. Patterns of access failures often precede successful exploitation." \ + "echo '-a always,exit -F arch=b64 -S creat -S open -S openat -S truncate -S ftruncate -F exit=-EACCES -F auid>=1000 -F auid!=4294967295 -k access' >> /etc/audit/rules.d/cisaudit.rules && echo '-a always,exit -F arch=b64 -S creat -S open -S openat -S truncate -S ftruncate -F exit=-EPERM -F auid>=1000 -F auid!=4294967295 -k access' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.13" \ + "Logging and Auditing" \ + "Ensure successful file system mounts are collected" \ + "2" \ + "yes" \ + "Monitoring the mount system call detects when users mount filesystems which could be used to introduce unauthorized media or access hidden data. Successful mounts by non-privileged users are particularly suspicious and should be investigated. This audit rule captures all mount operations along with the user identity for forensic analysis." \ + "echo '-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts' >> /etc/audit/rules.d/cisaudit.rules && echo '-a always,exit -F arch=b32 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.1.14" \ + "Logging and Auditing" \ + "Ensure file deletion events by users are collected" \ + "2" \ + "yes" \ + "Monitoring file deletion system calls such as unlink, unlinkat, rename, and renameat detects when users delete or overwrite files. Attackers often delete log files and other evidence after compromising a system. Collecting file deletion events ensures that evidence destruction attempts are themselves recorded in the audit log." \ + "echo '-a always,exit -F arch=b64 -S unlink -S unlinkat -S rename -S renameat -F auid>=1000 -F auid!=4294967295 -k delete' >> /etc/audit/rules.d/cisaudit.rules && echo '-a always,exit -F arch=b32 -S unlink -S unlinkat -S rename -S renameat -F auid>=1000 -F auid!=4294967295 -k delete' >> /etc/audit/rules.d/cisaudit.rules" + +register_control "4.2.1" \ + "Logging and Auditing" \ + "Ensure rsyslog is installed" \ + "1" \ + "yes" \ + "Rsyslog is the standard system logging facility that collects messages from services, the kernel, and applications. Without rsyslog critical system events including security warnings and errors go unrecorded. Installing rsyslog is the foundation for all system-level log collection and analysis." \ + "apt-get install -y rsyslog" + +register_control "4.2.2" \ + "Logging and Auditing" \ + "Ensure rsyslog service is enabled" \ + "1" \ + "yes" \ + "The rsyslog service must be enabled to start automatically at boot to ensure continuous logging coverage. A system without active logging cannot detect or record security incidents, hardware failures, or application errors. Enabling rsyslog ensures log collection begins immediately at system startup." \ + "systemctl enable rsyslog" + +register_control "4.2.3" \ + "Logging and Auditing" \ + "Ensure rsyslog default file permissions configured" \ + "1" \ + "yes" \ + "Log files should be created with restrictive permissions to prevent unauthorized users from reading sensitive log data. The FileCreateMode setting in rsyslog controls the default permissions applied to newly created log files. Setting this to 0640 ensures only root and the syslog group can access log contents." \ + "echo '\$FileCreateMode 0640' >> /etc/rsyslog.conf && systemctl restart rsyslog" + +register_control "4.2.4" \ + "Logging and Auditing" \ + "Ensure logging is configured" \ + "1" \ + "no" \ + "System logging should be configured to capture relevant events from all critical services and facilities. The rsyslog configuration should direct logs to appropriate files based on facility and severity. Proper logging configuration ensures that security events are recorded and available for review and incident response." \ + "vi /etc/rsyslog.conf" + +register_control "5.1.1" \ + "Access, Authentication and Authorization" \ + "Ensure cron daemon is enabled and running" \ + "1" \ + "yes" \ + "The cron daemon executes scheduled commands and is essential for automated system maintenance tasks including log rotation and security updates. If cron is disabled scheduled security tasks such as ClamAV scans and AIDE integrity checks will not run. Ensuring cron is enabled maintains the automated security posture of the system." \ + "systemctl enable cron && systemctl start cron" + +register_control "5.1.2" \ + "Access, Authentication and Authorization" \ + "Ensure permissions on /etc/crontab are configured" \ + "1" \ + "yes" \ + "The /etc/crontab file contains system-wide cron job definitions that run with elevated privileges. Improper permissions on this file could allow unauthorized users to schedule malicious commands as root. Restricting access to root only prevents privilege escalation through crontab manipulation." \ + "chown root:root /etc/crontab && chmod 600 /etc/crontab" + +register_control "5.1.3" \ + "Access, Authentication and Authorization" \ + "Ensure permissions on /etc/cron.hourly are configured" \ + "1" \ + "yes" \ + "The /etc/cron.hourly directory contains scripts that execute every hour with root privileges. Unrestricted access to this directory would allow any user to place malicious scripts that run as root. Setting restrictive permissions ensures only authorized administrators can manage hourly cron jobs." \ + "chown root:root /etc/cron.hourly && chmod 700 /etc/cron.hourly" + +register_control "5.1.4" \ + "Access, Authentication and Authorization" \ + "Ensure permissions on /etc/cron.daily are configured" \ + "1" \ + "yes" \ + "The /etc/cron.daily directory contains scripts that execute daily with root privileges including log rotation and package updates. Unauthorized access could allow attackers to insert persistent backdoors that execute once per day. Restricting this directory to root prevents privilege escalation through daily cron job manipulation." \ + "chown root:root /etc/cron.daily && chmod 700 /etc/cron.daily" + +register_control "5.2.1" \ + "Access, Authentication and Authorization" \ + "Ensure permissions on /etc/ssh/sshd_config are configured" \ + "1" \ + "yes" \ + "The sshd_config file controls the behavior of the SSH daemon including authentication methods and access restrictions. If this file is writable by unauthorized users an attacker could weaken SSH security settings to facilitate access. Restricting permissions ensures only root can view or modify the SSH server configuration." \ + "chown root:root /etc/ssh/sshd_config && chmod 600 /etc/ssh/sshd_config" + +register_control "5.2.2" \ + "Access, Authentication and Authorization" \ + "Ensure SSH access is limited" \ + "1" \ + "yes" \ + "SSH access should be restricted to specific users or groups using AllowUsers, AllowGroups, DenyUsers, or DenyGroups directives. Without explicit access controls any valid user account can authenticate via SSH potentially including service accounts. Limiting SSH access reduces the number of accounts that can be targeted for remote brute force attacks." \ + "sed -i 's/^#*AllowUsers.*/AllowUsers root/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.3" \ + "Access, Authentication and Authorization" \ + "Ensure permissions on SSH private host key files are configured" \ + "1" \ + "yes" \ + "SSH private host key files must be protected from unauthorized access as they authenticate the server to clients. Compromised host keys allow attackers to impersonate the server and perform man-in-the-middle attacks. These files should be owned by root with permissions set to 600 to prevent any non-root access." \ + "find /etc/ssh -xdev -type f -name 'ssh_host_*_key' -exec chown root:root {} \\; -exec chmod 600 {} \\;" + +register_control "5.2.4" \ + "Access, Authentication and Authorization" \ + "Ensure SSH LogLevel is appropriate" \ + "1" \ + "yes" \ + "SSH LogLevel controls the verbosity of the SSH daemon logging and should be set to INFO or VERBOSE for adequate security monitoring. Insufficient logging prevents detection of brute force attacks and unauthorized access attempts. The INFO level provides a balance between useful security data and log volume." \ + "sed -i 's/^#*LogLevel.*/LogLevel INFO/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.5" \ + "Access, Authentication and Authorization" \ + "Ensure SSH X11 forwarding is disabled" \ + "2" \ + "yes" \ + "X11 forwarding allows graphical applications to be tunneled over SSH which introduces security risks from the X11 protocol. The X11 protocol was not designed with security in mind and forwarding can expose the local X server to attacks from the remote system. Disabling X11 forwarding eliminates this attack vector on servers that do not need graphical application support." \ + "sed -i 's/^#*X11Forwarding.*/X11Forwarding no/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.6" \ + "Access, Authentication and Authorization" \ + "Ensure SSH MaxAuthTries is set to 4 or less" \ + "1" \ + "yes" \ + "MaxAuthTries limits the number of authentication attempts permitted per SSH connection. Setting this to 4 or less slows down brute force attacks by forcing the attacker to establish new connections frequently. A lower value combined with fail2ban provides effective protection against automated password guessing." \ + "sed -i 's/^#*MaxAuthTries.*/MaxAuthTries 4/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.7" \ + "Access, Authentication and Authorization" \ + "Ensure SSH IgnoreRhosts is enabled" \ + "1" \ + "yes" \ + "The IgnoreRhosts parameter prevents SSH from using the legacy .rhosts and .shosts files for host-based authentication. These files allow authentication without passwords based solely on the source hostname which can be trivially spoofed. Enabling IgnoreRhosts forces proper cryptographic authentication for all SSH sessions." \ + "sed -i 's/^#*IgnoreRhosts.*/IgnoreRhosts yes/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.8" \ + "Access, Authentication and Authorization" \ + "Ensure SSH root login is disabled" \ + "1" \ + "yes" \ + "Allowing direct root login via SSH provides attackers with a known username to target for brute force attacks. Disabling root login forces administrators to authenticate with personal accounts before using sudo for privilege escalation. This improves accountability by ensuring all administrative actions are traceable to individual users." \ + "sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.9" \ + "Access, Authentication and Authorization" \ + "Ensure SSH PermitEmptyPasswords is disabled" \ + "1" \ + "yes" \ + "The PermitEmptyPasswords parameter controls whether accounts with empty passwords can authenticate via SSH. Allowing empty passwords completely undermines authentication security and gives attackers trivial access. This must be disabled to ensure all SSH connections require proper credential verification." \ + "sed -i 's/^#*PermitEmptyPasswords.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.10" \ + "Access, Authentication and Authorization" \ + "Ensure SSH PermitUserEnvironment is disabled" \ + "1" \ + "yes" \ + "The PermitUserEnvironment option allows users to set environment variables that could modify the behavior of SSH sessions. Attackers can use this to set LD_PRELOAD or PATH variables to load malicious libraries or override system commands. Disabling this option prevents environment-based attacks through SSH sessions." \ + "sed -i 's/^#*PermitUserEnvironment.*/PermitUserEnvironment no/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.11" \ + "Access, Authentication and Authorization" \ + "Ensure only strong ciphers are used" \ + "1" \ + "yes" \ + "SSH cipher configuration determines the encryption algorithms used to protect session confidentiality. Weak ciphers such as 3des-cbc, arcfour, and blowfish can be broken with modern computing resources. Restricting SSH to strong ciphers like chacha20-poly1305 and aes256-gcm ensures session data remains protected." \ + "sed -i 's/^#*Ciphers.*/Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.12" \ + "Access, Authentication and Authorization" \ + "Ensure only strong MAC algorithms are used" \ + "1" \ + "yes" \ + "Message Authentication Code algorithms ensure the integrity of SSH traffic by detecting tampering. Weak MAC algorithms such as hmac-md5 and hmac-sha1 are vulnerable to collision attacks that could allow traffic modification. Configuring only strong MACs like hmac-sha2-512-etm ensures reliable integrity verification of all SSH communications." \ + "sed -i 's/^#*MACs.*/MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.13" \ + "Access, Authentication and Authorization" \ + "Ensure only strong Key Exchange algorithms are used" \ + "1" \ + "yes" \ + "Key Exchange algorithms negotiate the shared secret used to derive session encryption keys. Weak key exchange methods like diffie-hellman-group1-sha1 use small key sizes vulnerable to offline attacks. Restricting to curve25519-sha256 and strong diffie-hellman groups ensures the key exchange cannot be compromised." \ + "sed -i 's/^#*KexAlgorithms.*/KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.2.14" \ + "Access, Authentication and Authorization" \ + "Ensure SSH LoginGraceTime is set to one minute or less" \ + "1" \ + "yes" \ + "LoginGraceTime sets the maximum time allowed for a user to complete authentication after connecting to SSH. A long grace time allows attackers to hold open unauthenticated connections consuming server resources. Setting this to 60 seconds or less limits the window for brute force attacks and reduces resource consumption from idle connections." \ + "sed -i 's/^#*LoginGraceTime.*/LoginGraceTime 60/' /etc/ssh/sshd_config && systemctl restart sshd" + +register_control "5.3.1" \ + "Access, Authentication and Authorization" \ + "Ensure password creation requirements are configured" \ + "1" \ + "yes" \ + "Password quality modules like pam_pwquality enforce minimum complexity requirements for user passwords. Without these requirements users can set trivially guessable passwords that are vulnerable to brute force and dictionary attacks. The 2019 Collection #1 breach exposed 773 million credentials many of which used weak passwords that complexity rules would have prevented." \ + "apt-get install -y libpam-pwquality && echo 'password requisite pam_pwquality.so retry=3' >> /etc/pam.d/common-password" + +register_control "5.4.1" \ + "Access, Authentication and Authorization" \ + "Ensure password expiration is 365 days or less" \ + "1" \ + "yes" \ + "PASS_MAX_DAYS in /etc/login.defs sets the maximum number of days a password may be used before it must be changed. Requiring periodic password changes limits the window during which a compromised password can be exploited. The NIST 800-63B guidelines recommend expiration only when compromise is suspected but CIS maintains this control for defense in depth." \ + "sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS 365/' /etc/login.defs" + +register_control "5.4.2" \ + "Access, Authentication and Authorization" \ + "Ensure minimum days between password changes is configured" \ + "1" \ + "yes" \ + "PASS_MIN_DAYS prevents users from immediately changing passwords back to a previous value after a forced change. Without a minimum interval users can cycle through password history to reuse a compromised password. Setting this to at least 1 day forces meaningful password rotation." \ + "sed -i 's/^PASS_MIN_DAYS.*/PASS_MIN_DAYS 1/' /etc/login.defs" + +register_control "5.4.3" \ + "Access, Authentication and Authorization" \ + "Ensure password expiration warning days is 7 or more" \ + "1" \ + "yes" \ + "PASS_WARN_AGE controls how many days before password expiration the user receives a warning. Adequate warning prevents account lockouts caused by expired passwords which can disrupt operations and trigger helpdesk overhead. Seven days provides sufficient notice for users to change their password before expiration." \ + "sed -i 's/^PASS_WARN_AGE.*/PASS_WARN_AGE 7/' /etc/login.defs" + +register_control "5.5.1" \ + "Access, Authentication and Authorization" \ + "Ensure account lockout for failed password attempts is configured" \ + "1" \ + "yes" \ + "Account lockout after failed authentication attempts prevents brute force password attacks against local and remote accounts. The pam_faillock module tracks failed attempts and temporarily locks accounts after a configurable threshold. Without lockout an attacker can attempt unlimited password guesses against any account on the system." \ + "echo 'auth required pam_faillock.so preauth' >> /etc/pam.d/common-auth && echo 'auth required pam_faillock.so authfail' >> /etc/pam.d/common-auth" + +register_control "6.1.1" \ + "System Maintenance" \ + "Ensure permissions on /etc/passwd are configured" \ + "1" \ + "yes" \ + "The /etc/passwd file contains user account information that all users and many system processes need to read. This file must be owned by root with permissions set to 644 to allow read access while preventing unauthorized modifications. Incorrect permissions on /etc/passwd could allow attackers to add accounts or modify existing ones." \ + "chown root:root /etc/passwd && chmod 644 /etc/passwd" + +register_control "6.1.2" \ + "System Maintenance" \ + "Ensure permissions on /etc/shadow are configured" \ + "1" \ + "yes" \ + "The /etc/shadow file stores hashed passwords and must have the most restrictive permissions possible. This file should be owned by root with group shadow and permissions set to 640 to prevent unauthorized password hash access. Exposed password hashes can be cracked offline giving attackers valid credentials." \ + "chown root:shadow /etc/shadow && chmod 640 /etc/shadow" + +register_control "6.1.3" \ + "System Maintenance" \ + "Ensure permissions on /etc/group are configured" \ + "1" \ + "yes" \ + "The /etc/group file defines group membership information used for access control decisions across the system. This file must be owned by root with 644 permissions to allow read access while preventing unauthorized group modifications. Tampering with group membership could grant attackers access to resources protected by group permissions." \ + "chown root:root /etc/group && chmod 644 /etc/group" + +register_control "6.1.4" \ + "System Maintenance" \ + "Ensure permissions on /etc/gshadow are configured" \ + "1" \ + "yes" \ + "The /etc/gshadow file contains encrypted group passwords and group administrator information. This file should be owned by root with group shadow and permissions set to 640 similar to /etc/shadow. Unauthorized access to gshadow could reveal group passwords enabling lateral movement within the system." \ + "chown root:shadow /etc/gshadow && chmod 640 /etc/gshadow" + +register_control "6.1.5" \ + "System Maintenance" \ + "Ensure permissions on /etc/passwd- are configured" \ + "1" \ + "yes" \ + "The /etc/passwd- file is a backup copy of /etc/passwd created automatically by system utilities. This backup file must have the same restrictive permissions as the original to prevent information leakage. Attackers could read an improperly secured backup to enumerate system users and their properties." \ + "chown root:root /etc/passwd- && chmod 644 /etc/passwd-" + +register_control "6.2.1" \ + "System Maintenance" \ + "Ensure no duplicate UIDs exist" \ + "1" \ + "yes" \ + "Each user account must have a unique User ID to ensure proper access control and audit trail attribution. Duplicate UIDs cause multiple users to share the same file permissions and appear identical in log files. Resolving duplicate UIDs ensures that every action on the system can be traced to a specific user account." \ + "cat /etc/passwd | cut -f3 -d: | sort -n | uniq -d" + +register_control "6.2.2" \ + "System Maintenance" \ + "Ensure no duplicate GIDs exist" \ + "1" \ + "yes" \ + "Each group must have a unique Group ID to ensure correct group-based access control enforcement. Duplicate GIDs cause different group names to share the same access permissions leading to unintended access grants. Eliminating duplicate GIDs ensures that group membership provides the expected level of access." \ + "cat /etc/group | cut -f3 -d: | sort -n | uniq -d" + +register_control "6.2.3" \ + "System Maintenance" \ + "Ensure no duplicate user names exist" \ + "1" \ + "yes" \ + "Duplicate user names in /etc/passwd would cause ambiguity in permission assignments and audit logging. Systems may behave unpredictably when multiple entries share the same username potentially granting unintended access. Each user name must be unique to ensure consistent authentication and authorization behavior." \ + "cat /etc/passwd | cut -f1 -d: | sort | uniq -d" + +register_control "6.2.4" \ + "System Maintenance" \ + "Ensure root is the only UID 0 account" \ + "1" \ + "yes" \ + "UID 0 grants unrestricted superuser privileges and should only be assigned to the root account. Additional UID 0 accounts create hidden administrator access that bypasses normal sudo-based privilege escalation auditing. Any account other than root with UID 0 should be investigated as a potential backdoor." \ + "awk -F: '(\$3 == 0) { print \$1 }' /etc/passwd" + +register_control "6.2.5" \ + "System Maintenance" \ + "Ensure no legacy + entries exist in passwd, shadow, or group" \ + "1" \ + "yes" \ + "Legacy plus entries in /etc/passwd, /etc/shadow, or /etc/group were used with NIS to include external directory data. These entries can introduce security vulnerabilities by importing uncontrolled account or group information. Removing legacy + entries eliminates the risk of unintended account injection from external sources." \ + "sed -i '/^+/d' /etc/passwd /etc/shadow /etc/group" diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/baseline.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/baseline.sh new file mode 100644 index 0000000..7799c0e --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/baseline.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# baseline.sh +# +# Baseline persistence for audit snapshot comparison +# +# Enables saving current audit results as a JSON baseline via +# save_baseline (delegates to emit_json_report), reloading a prior +# baseline with load_baseline (regex-parses control IDs and statuses +# from the JSON without a JSON library), and diffing current results +# against the baseline via diff_baseline which categorizes each +# control as improved, regressed, unchanged, new, or removed and +# prints a colored summary with regression warnings. +# +# Connects to: +# lib/report_json.sh - emit_json_report (called by save_baseline) +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, CTRL_TITLE +# lib/utils.sh - warn logging function +# lib/constants.sh - ANSI colors, STATUS_PASS/FAIL + +declare -gA BASELINE_STATUS + +save_baseline() { + local file="$1" + emit_json_report > "$file" +} + +load_baseline() { + local file="$1" + BASELINE_STATUS=() + + if [[ ! -f "$file" ]]; then + warn "Baseline file not found: ${file}" + return 1 + fi + + local current_id="" + local id_pattern='"id":[[:space:]]*"([^"]+)"' + local status_pattern='"status":[[:space:]]*"([^"]+)"' + while IFS= read -r line; do + if [[ "$line" =~ $id_pattern ]]; then + current_id="${BASH_REMATCH[1]}" + fi + if [[ -n "$current_id" && "$line" =~ $status_pattern ]]; then + BASELINE_STATUS["$current_id"]="${BASH_REMATCH[1]}" + current_id="" + fi + done < "$file" +} + +diff_baseline() { + local file="$1" + + if ! load_baseline "$file"; then + return 1 + fi + + local regressions=0 + local improvements=0 + local new_controls=0 + local unchanged=0 + + echo "" + echo -e "${BOLD}Baseline Comparison${RESET} (vs ${file})" + echo -e "$(printf '%.0s─' {1..60})" + + local id baseline_stat current_stat + for id in "${RESULT_ORDER[@]}"; do + current_stat="${RESULT_STATUS[$id]}" + baseline_stat="${BASELINE_STATUS[$id]:-}" + + if [[ -z "$baseline_stat" ]]; then + ((new_controls++)) || true + printf " ${CYAN}+${RESET} %-10s %s ${DIM}(new)${RESET}\n" "$id" "${CTRL_TITLE[$id]}" + continue + fi + + if [[ "$baseline_stat" == "$current_stat" ]]; then + ((unchanged++)) || true + continue + fi + + if [[ "$baseline_stat" == "$STATUS_PASS" && "$current_stat" == "$STATUS_FAIL" ]]; then + ((regressions++)) || true + printf " ${RED}-${RESET} %-10s %s ${RED}(PASS → FAIL)${RESET}\n" "$id" "${CTRL_TITLE[$id]}" + elif [[ "$baseline_stat" == "$STATUS_FAIL" && "$current_stat" == "$STATUS_PASS" ]]; then + ((improvements++)) || true + printf " ${GREEN}+${RESET} %-10s %s ${GREEN}(FAIL → PASS)${RESET}\n" "$id" "${CTRL_TITLE[$id]}" + else + printf " ${YELLOW}~${RESET} %-10s %s ${YELLOW}(${baseline_stat} → ${current_stat})${RESET}\n" "$id" "${CTRL_TITLE[$id]}" + fi + done + + local removed=0 + for id in "${!BASELINE_STATUS[@]}"; do + if [[ -z "${RESULT_STATUS[$id]:-}" ]]; then + ((removed++)) || true + printf " ${DIM}x${RESET} %-10s ${DIM}(removed)${RESET}\n" "$id" + fi + done + + echo "" + echo -e "${BOLD}Summary:${RESET} ${GREEN}${improvements} improved${RESET}, ${RED}${regressions} regressed${RESET}, ${unchanged} unchanged, ${new_controls} new, ${removed} removed" + + if (( regressions > 0 )); then + warn "${regressions} regression(s) detected" + fi +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/constants.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/constants.sh new file mode 100644 index 0000000..7231f1a --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/constants.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# constants.sh +# +# Global constants and configuration values +# +# Defines the tool version string, CIS benchmark identifier, minimum +# bash version requirement, process exit codes, ANSI terminal color +# escape sequences, audit status labels (PASS/FAIL/WARN/SKIP), the +# default SYSROOT path for live vs test-mode filesystem access, and +# the six CIS section names with their display ordering. +# +# Connects to: +# cisaudit.sh - sourced first, values used by all modules +# lib/utils.sh - ANSI colors used in logging functions +# lib/engine.sh - SECTION_ORDER drives per-section score iteration + +declare -gr VERSION="1.0.0" +declare -gr CIS_BENCHMARK="CIS Debian Linux 12 Benchmark v1.1.0" +declare -gr MIN_BASH_VERSION=4 + +declare -gr EXIT_OK=0 +declare -gr EXIT_FAIL=1 +declare -gr EXIT_USAGE=2 + +declare -gr RED='\033[0;31m' +declare -gr GREEN='\033[0;32m' +declare -gr YELLOW='\033[1;33m' +declare -gr CYAN='\033[0;36m' +declare -gr BLUE='\033[0;34m' +declare -gr MAGENTA='\033[0;35m' +declare -gr BOLD='\033[1m' +declare -gr DIM='\033[2m' +declare -gr RESET='\033[0m' + +declare -gr STATUS_PASS="PASS" +declare -gr STATUS_FAIL="FAIL" +declare -gr STATUS_WARN="WARN" +declare -gr STATUS_SKIP="SKIP" + +declare -g SYSROOT="/" + +declare -gr SECTION_INITIAL_SETUP="Initial Setup" +declare -gr SECTION_SERVICES="Services" +declare -gr SECTION_NETWORK="Network Configuration" +declare -gr SECTION_LOGGING="Logging and Auditing" +declare -gr SECTION_ACCESS="Access, Authentication and Authorization" +declare -gr SECTION_MAINTENANCE="System Maintenance" + +declare -ga SECTION_ORDER=( + "$SECTION_INITIAL_SETUP" + "$SECTION_SERVICES" + "$SECTION_NETWORK" + "$SECTION_LOGGING" + "$SECTION_ACCESS" + "$SECTION_MAINTENANCE" +) diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/engine.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/engine.sh new file mode 100644 index 0000000..48c4faa --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/engine.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# engine.sh +# +# Score computation engine for CIS audit results +# +# Iterates recorded check results to tally per-section pass/fail/warn/skip +# counts, then computes percentage scores at three levels: per-section +# (SCORE_BY_SECTION), per-CIS-level (SCORE_LEVEL1, SCORE_LEVEL2), and +# overall (SCORE_OVERALL). Scores are calculated as pass/(pass+fail)*100, +# ignoring warn and skip results. Sections with zero scored results +# receive "N/A" instead of a numeric score. +# +# Connects to: +# lib/constants.sh - SECTION_ORDER, STATUS_PASS/FAIL/WARN/SKIP +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, CTRL_SECTION, +# CTRL_LEVEL, CTRL_SCORED, TOTAL_PASS/FAIL + +declare -gA SCORE_BY_SECTION +declare -gA SECTION_PASS +declare -gA SECTION_FAIL +declare -gA SECTION_WARN +declare -gA SECTION_SKIP +declare -g SCORE_OVERALL=0 +declare -g SCORE_LEVEL1=0 +declare -g SCORE_LEVEL2=0 + +compute_scores() { + local section + for section in "${SECTION_ORDER[@]}"; do + SECTION_PASS["$section"]=0 + SECTION_FAIL["$section"]=0 + SECTION_WARN["$section"]=0 + SECTION_SKIP["$section"]=0 + done + + local id + for id in "${RESULT_ORDER[@]}"; do + section="${CTRL_SECTION[$id]}" + case "${RESULT_STATUS[$id]}" in + "$STATUS_PASS") ((SECTION_PASS["$section"]++)) || true ;; + "$STATUS_FAIL") ((SECTION_FAIL["$section"]++)) || true ;; + "$STATUS_WARN") ((SECTION_WARN["$section"]++)) || true ;; + "$STATUS_SKIP") ((SECTION_SKIP["$section"]++)) || true ;; + esac + done + + for section in "${SECTION_ORDER[@]}"; do + local p="${SECTION_PASS[$section]}" + local f="${SECTION_FAIL[$section]}" + local total=$((p + f)) + if (( total > 0 )); then + SCORE_BY_SECTION["$section"]=$(awk "BEGIN { printf \"%.1f\", ($p / $total) * 100 }") + else + SCORE_BY_SECTION["$section"]="N/A" + fi + done + + local scored_total=$((TOTAL_PASS + TOTAL_FAIL)) + if (( scored_total > 0 )); then + SCORE_OVERALL=$(awk "BEGIN { printf \"%.1f\", ($TOTAL_PASS / $scored_total) * 100 }") + else + SCORE_OVERALL="0.0" + fi + + _compute_level_score 1 + SCORE_LEVEL1="$_LEVEL_SCORE" + + _compute_level_score 2 + SCORE_LEVEL2="$_LEVEL_SCORE" +} + +_compute_level_score() { + local level="$1" + local pass=0 + local fail=0 + + local id + for id in "${RESULT_ORDER[@]}"; do + if [[ "${CTRL_LEVEL[$id]}" == "$level" ]]; then + case "${RESULT_STATUS[$id]}" in + "$STATUS_PASS") ((pass++)) || true ;; + "$STATUS_FAIL") ((fail++)) || true ;; + esac + fi + done + + local total=$((pass + fail)) + if (( total > 0 )); then + _LEVEL_SCORE=$(awk "BEGIN { printf \"%.1f\", ($pass / $total) * 100 }") + else + _LEVEL_SCORE="N/A" + fi +} + +get_section_total() { + local section="$1" + local p="${SECTION_PASS[$section]:-0}" + local f="${SECTION_FAIL[$section]:-0}" + local w="${SECTION_WARN[$section]:-0}" + local s="${SECTION_SKIP[$section]:-0}" + echo $((p + f + w + s)) +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/registry.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/registry.sh new file mode 100644 index 0000000..d24794d --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/registry.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# registry.sh +# +# Control registration and result tracking system +# +# Provides the in-memory database of CIS controls and their audit +# outcomes using bash associative arrays. register_control stores +# each control's metadata (title, section, level, scored, description, +# remediation) and derives the check function name (check_X_Y_Z). +# record_result captures per-control pass/fail/warn/skip status with +# evidence and maintains running totals. Query helpers retrieve +# controls by section or level. reset_results clears all state for +# test isolation. +# +# Connects to: +# lib/constants.sh - STATUS_PASS/FAIL/WARN/SKIP +# controls/registry_data.sh - populates registry via register_control +# checks/*.sh - check functions call record_result +# lib/engine.sh - reads RESULT_ORDER, RESULT_STATUS, CTRL_* + +declare -gA CTRL_TITLE +declare -gA CTRL_SECTION +declare -gA CTRL_LEVEL +declare -gA CTRL_SCORED +declare -gA CTRL_DESCRIPTION +declare -gA CTRL_REMEDIATION +declare -gA CTRL_CHECK_FN + +declare -gA RESULT_STATUS +declare -gA RESULT_EVIDENCE +declare -ga RESULT_ORDER=() + +declare -g TOTAL_PASS=0 +declare -g TOTAL_FAIL=0 +declare -g TOTAL_WARN=0 +declare -g TOTAL_SKIP=0 +declare -ga REGISTERED_IDS=() + +register_control() { + local id="$1" + local section="$2" + local title="$3" + local level="$4" + local scored="$5" + local description="$6" + local remediation="$7" + + CTRL_TITLE["$id"]="$title" + CTRL_SECTION["$id"]="$section" + CTRL_LEVEL["$id"]="$level" + CTRL_SCORED["$id"]="$scored" + CTRL_DESCRIPTION["$id"]="$description" + CTRL_REMEDIATION["$id"]="$remediation" + + local fn_name="check_${id//\./_}" + CTRL_CHECK_FN["$id"]="$fn_name" + REGISTERED_IDS+=("$id") +} + +record_result() { + local id="$1" + local status="$2" + local evidence="$3" + + RESULT_STATUS["$id"]="$status" + RESULT_EVIDENCE["$id"]="$evidence" + RESULT_ORDER+=("$id") + + case "$status" in + "$STATUS_PASS") ((TOTAL_PASS++)) || true ;; + "$STATUS_FAIL") ((TOTAL_FAIL++)) || true ;; + "$STATUS_WARN") ((TOTAL_WARN++)) || true ;; + "$STATUS_SKIP") ((TOTAL_SKIP++)) || true ;; + esac +} + +reset_results() { + RESULT_STATUS=() + RESULT_EVIDENCE=() + RESULT_ORDER=() + TOTAL_PASS=0 + TOTAL_FAIL=0 + TOTAL_WARN=0 + TOTAL_SKIP=0 +} + +get_controls_for_section() { + local section="$1" + local id + for id in "${REGISTERED_IDS[@]}"; do + if [[ "${CTRL_SECTION[$id]}" == "$section" ]]; then + echo "$id" + fi + done +} + +get_controls_for_level() { + local level="$1" + local id + for id in "${REGISTERED_IDS[@]}"; do + if [[ "${CTRL_LEVEL[$id]}" == "$level" ]]; then + echo "$id" + fi + done +} + +get_total_controls() { + echo "${#REGISTERED_IDS[@]}" +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/report_html.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/report_html.sh new file mode 100644 index 0000000..8660441 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/report_html.sh @@ -0,0 +1,452 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# report_html.sh +# +# Standalone HTML report generator with Tokyo Night theme +# +# Produces a single-file HTML page via emit_html_report containing +# embedded CSS (dark Tokyo Night palette with print-friendly overrides), +# a responsive dashboard with score cards and level badges, a section +# breakdown table with colored progress bars, and collapsible +#
controls for each audited item showing status badges, +# control IDs, evidence blocks, and remediation hints. Failed +# controls auto-expand for immediate visibility. Responsive at +# 640px breakpoint with print media query support. +# +# Connects to: +# lib/constants.sh - VERSION, CIS_BENCHMARK, STATUS_* +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, RESULT_EVIDENCE, +# CTRL_TITLE, CTRL_SECTION, CTRL_LEVEL +# lib/engine.sh - SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2, +# SCORE_BY_SECTION, SECTION_PASS/FAIL/WARN/SKIP + +html_escape() { + local s="$1" + s="${s//&/\&}" + s="${s///\>}" + s="${s//\"/\"}" + printf '%s' "$s" +} + +_html_score_color() { + local score="$1" + if [[ "$score" == "N/A" ]]; then + printf '%s' "#565f89" + return + fi + local int_score="${score%.*}" + if (( int_score >= 80 )); then + printf '%s' "#9ece6a" + elif (( int_score >= 60 )); then + printf '%s' "#e0af68" + else + printf '%s' "#f7768e" + fi +} + +_html_status_class() { + local status="$1" + case "$status" in + "$STATUS_PASS") printf '%s' "pass" ;; + "$STATUS_FAIL") printf '%s' "fail" ;; + "$STATUS_WARN") printf '%s' "warn" ;; + "$STATUS_SKIP") printf '%s' "skip" ;; + esac +} + +_emit_html_head() { + cat <<'CSSEOF' + + + + + +CIS Benchmark Audit Report + + +CSSEOF +} + +_emit_html_header() { + local timestamp="$1" + local hostname="$2" + + local score_color + score_color=$(_html_score_color "$SCORE_OVERALL") + + local total=$((TOTAL_PASS + TOTAL_FAIL + TOTAL_WARN + TOTAL_SKIP)) + + local l1_display + if [[ "$SCORE_LEVEL1" == "N/A" ]]; then + l1_display="N/A" + else + l1_display="${SCORE_LEVEL1}%" + fi + + local l2_display + if [[ "$SCORE_LEVEL2" == "N/A" ]]; then + l2_display="N/A" + else + l2_display="${SCORE_LEVEL2}%" + fi + + local l1_color + l1_color=$(_html_score_color "$SCORE_LEVEL1") + local l2_color + l2_color=$(_html_score_color "$SCORE_LEVEL2") + + local esc_hostname + esc_hostname=$(html_escape "$hostname") + local esc_os + esc_os=$(html_escape "${DETECTED_ID} ${DETECTED_VERSION}") + local esc_bench + esc_bench=$(html_escape "$CIS_BENCHMARK") + + cat < +
+
+

CIS Benchmark Audit Report

+
+${esc_bench} +${esc_hostname} +OS: ${esc_os} +${timestamp} +
+
+
+
+
Overall Score
+
${SCORE_OVERALL}%
+
+
+
Total
+
${total}
+
+
+
Pass
+
${TOTAL_PASS}
+
+
+
Fail
+
${TOTAL_FAIL}
+
+
+
Warn
+
${TOTAL_WARN}
+
+ +
+
+
+
Level 1 Score
+
${l1_display}
+
+
+
Level 2 Score
+
${l2_display}
+
+
+HEADEREOF +} + +_emit_html_section_table() { + cat <<'TABLESTART' +

Section Breakdown

+ + + + + + + + + + + + + +TABLESTART + + local section + for section in "${SECTION_ORDER[@]}"; do + local p="${SECTION_PASS[$section]:-0}" + local f="${SECTION_FAIL[$section]:-0}" + local w="${SECTION_WARN[$section]:-0}" + local s="${SECTION_SKIP[$section]:-0}" + local score="${SCORE_BY_SECTION[$section]:-N/A}" + + local score_display + if [[ "$score" == "N/A" ]]; then + score_display="N/A" + else + score_display="${score}%" + fi + + local bar_width + if [[ "$score" == "N/A" ]]; then + bar_width="0" + else + bar_width="$score" + fi + + local bar_color + bar_color=$(_html_score_color "$score") + + local esc_section + esc_section=$(html_escape "$section") + + printf '\n' + printf '\n' "$esc_section" + printf '\n' "$p" + printf '\n' "$f" + printf '\n' "$w" + printf '\n' "$s" + printf '\n' "$bar_color" "$score_display" + printf '\n' "$bar_width" "$bar_color" + printf '\n' + done + + cat <<'TABLEEND' + +
SectionPassFailWarnSkipScoreProgress
%s%d%d%d%d%s
+TABLEEND +} + +_emit_html_details() { + printf '

Detailed Results

\n' + + local section + for section in "${SECTION_ORDER[@]}"; do + local has_results=0 + local id + for id in "${RESULT_ORDER[@]}"; do + if [[ "${CTRL_SECTION[$id]}" == "$section" ]]; then + has_results=1 + break + fi + done + + if (( has_results == 0 )); then + continue + fi + + local esc_section + esc_section=$(html_escape "$section") + + printf '
\n' + printf '

%s

\n' "$esc_section" + + for id in "${RESULT_ORDER[@]}"; do + if [[ "${CTRL_SECTION[$id]}" != "$section" ]]; then + continue + fi + + local status="${RESULT_STATUS[$id]}" + local title="${CTRL_TITLE[$id]}" + local level="${CTRL_LEVEL[$id]}" + local evidence="${RESULT_EVIDENCE[$id]:-}" + local remediation="${CTRL_REMEDIATION[$id]:-}" + + local cls + cls=$(_html_status_class "$status") + + local esc_id + esc_id=$(html_escape "$id") + local esc_title + esc_title=$(html_escape "$title") + + local open_attr="" + if [[ "$status" == "$STATUS_FAIL" ]]; then + open_attr=" open" + fi + + printf '
\n' "$cls" "$open_attr" + printf '\n' + printf '%s\n' "$cls" "$status" + printf '%s\n' "$esc_id" + printf '%s\n' "$esc_title" + printf 'L%s\n' "$level" + printf '\n' + + if [[ -n "$evidence" || -n "$remediation" ]]; then + printf '
\n' + if [[ -n "$evidence" ]]; then + local esc_evidence + esc_evidence=$(html_escape "$evidence") + printf '
%s
\n' "$esc_evidence" + fi + if [[ -n "$remediation" ]]; then + local esc_remediation + esc_remediation=$(html_escape "$remediation") + printf '

Remediation: %s

\n' "$esc_remediation" + fi + printf '
\n' + fi + + printf '
\n' + done + + printf '
\n' + done +} + +_emit_html_footer() { + local esc_version + esc_version=$(html_escape "$VERSION") + + cat < +cisaudit v${esc_version} + +
+ + +FOOTEREOF +} + +emit_html_report() { + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S %Z') + local hostname + hostname=$(hostname 2>/dev/null || echo "unknown") + + _emit_html_head + _emit_html_header "$timestamp" "$hostname" + _emit_html_section_table + _emit_html_details + _emit_html_footer +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/report_json.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/report_json.sh new file mode 100644 index 0000000..144eaa0 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/report_json.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# report_json.sh +# +# JSON report emitter for machine-readable audit output +# +# Serializes complete audit results into a structured JSON document +# via emit_json_report. Output includes version, CIS benchmark ID, +# ISO 8601 timestamp, hostname, OS detection, a summary block with +# totals and overall/level scores, a sections array with per-section +# pass/fail/warn/skip counts and scores, and a controls array with +# each control's ID, section, title, level, scored flag, status, +# evidence, and remediation. Provides json_escape for safe string +# encoding and null-coalescing for N/A scores. +# +# Connects to: +# lib/constants.sh - VERSION, CIS_BENCHMARK, STATUS_* +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, RESULT_EVIDENCE, +# CTRL_TITLE, CTRL_SECTION, CTRL_LEVEL, CTRL_SCORED +# lib/engine.sh - SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2, +# SCORE_BY_SECTION, SECTION_PASS/FAIL/WARN/SKIP +# lib/baseline.sh - save_baseline calls emit_json_report + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" +} + +_json_score() { + local val="$1" + if [[ "$val" == "N/A" ]]; then + printf 'null' + else + printf '%s' "$val" + fi +} + +_json_bool() { + local val="$1" + if [[ "$val" == "yes" ]]; then + printf 'true' + else + printf 'false' + fi +} + +emit_json_report() { + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local hn + hn=$(hostname 2>/dev/null || echo "unknown") + local total=$((TOTAL_PASS + TOTAL_FAIL + TOTAL_WARN + TOTAL_SKIP)) + + printf '{\n' + printf ' "version": "%s",\n' "$(json_escape "$VERSION")" + printf ' "cis_benchmark": "%s",\n' "$(json_escape "$CIS_BENCHMARK")" + printf ' "timestamp": "%s",\n' "$timestamp" + printf ' "hostname": "%s",\n' "$(json_escape "$hn")" + printf ' "os_id": "%s",\n' "$(json_escape "$DETECTED_ID")" + printf ' "os_version": "%s",\n' "$(json_escape "$DETECTED_VERSION")" + + printf ' "summary": {\n' + printf ' "total": %d,\n' "$total" + printf ' "pass": %d,\n' "$TOTAL_PASS" + printf ' "fail": %d,\n' "$TOTAL_FAIL" + printf ' "warn": %d,\n' "$TOTAL_WARN" + printf ' "skip": %d,\n' "$TOTAL_SKIP" + printf ' "score_percent": %s,\n' "$(_json_score "$SCORE_OVERALL")" + printf ' "level1_score": %s,\n' "$(_json_score "$SCORE_LEVEL1")" + printf ' "level2_score": %s\n' "$(_json_score "$SCORE_LEVEL2")" + printf ' },\n' + + printf ' "sections": [\n' + local sec_count=0 + local sec_total="${#SECTION_ORDER[@]}" + for section in "${SECTION_ORDER[@]}"; do + ((sec_count++)) || true + local p="${SECTION_PASS[$section]:-0}" + local f="${SECTION_FAIL[$section]:-0}" + local w="${SECTION_WARN[$section]:-0}" + local s="${SECTION_SKIP[$section]:-0}" + local score="${SCORE_BY_SECTION[$section]:-N/A}" + + printf ' {\n' + printf ' "name": "%s",\n' "$(json_escape "$section")" + printf ' "pass": %d,\n' "$p" + printf ' "fail": %d,\n' "$f" + printf ' "warn": %d,\n' "$w" + printf ' "skip": %d,\n' "$s" + printf ' "score_percent": %s\n' "$(_json_score "$score")" + printf ' }' + + if (( sec_count < sec_total )); then + printf ',' + fi + printf '\n' + done + printf ' ],\n' + + printf ' "controls": [\n' + local ctrl_count=0 + local ctrl_total="${#RESULT_ORDER[@]}" + for id in "${RESULT_ORDER[@]}"; do + ((ctrl_count++)) || true + local status="${RESULT_STATUS[$id]}" + local evidence="${RESULT_EVIDENCE[$id]:-}" + local title="${CTRL_TITLE[$id]}" + # shellcheck disable=SC2153 # CTRL_SECTION is a populated global associative array + local ctrl_section="${CTRL_SECTION[$id]}" + local level="${CTRL_LEVEL[$id]}" + local scored="${CTRL_SCORED[$id]}" + local remediation="${CTRL_REMEDIATION[$id]:-}" + + printf ' {\n' + printf ' "id": "%s",\n' "$(json_escape "$id")" + printf ' "section": "%s",\n' "$(json_escape "$ctrl_section")" + printf ' "title": "%s",\n' "$(json_escape "$title")" + printf ' "level": %d,\n' "$level" + printf ' "scored": %s,\n' "$(_json_bool "$scored")" + printf ' "status": "%s",\n' "$(json_escape "$status")" + printf ' "evidence": "%s",\n' "$(json_escape "$evidence")" + printf ' "remediation": "%s"\n' "$(json_escape "$remediation")" + printf ' }' + + if (( ctrl_count < ctrl_total )); then + printf ',' + fi + printf '\n' + done + printf ' ]\n' + + printf '}\n' +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/report_terminal.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/report_terminal.sh new file mode 100644 index 0000000..d0652c3 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/report_terminal.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# report_terminal.sh +# +# ANSI terminal report renderer +# +# Builds a rich terminal report through emit_terminal_report by +# composing four sections: an ASCII art banner with version/hostname/ +# timestamp, a bordered summary card showing overall score and +# pass/fail/warn/skip totals, a section breakdown table with inline +# progress bars and color-coded percentages, and detailed per-control +# results grouped by section with status symbols, evidence lines for +# failures, and remediation hints. All color output uses the ANSI +# escape sequences from constants.sh. +# +# Connects to: +# lib/constants.sh - ANSI colors, VERSION, CIS_BENCHMARK, STATUS_* +# lib/registry.sh - RESULT_ORDER, RESULT_STATUS, RESULT_EVIDENCE, +# CTRL_TITLE, CTRL_SECTION, CTRL_REMEDIATION +# lib/engine.sh - SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2, +# SCORE_BY_SECTION, SECTION_PASS/FAIL/WARN/SKIP + +_status_color() { + local status="$1" + case "$status" in + "$STATUS_PASS") printf '%b' "$GREEN" ;; + "$STATUS_FAIL") printf '%b' "$RED" ;; + "$STATUS_WARN") printf '%b' "$YELLOW" ;; + "$STATUS_SKIP") printf '%b' "$DIM" ;; + esac +} + +_status_symbol() { + local status="$1" + case "$status" in + "$STATUS_PASS") printf '%s' "✔" ;; + "$STATUS_FAIL") printf '%s' "✖" ;; + "$STATUS_WARN") printf '%s' "⚠" ;; + "$STATUS_SKIP") printf '%s' "─" ;; + esac +} + +_progress_bar() { + local percentage="$1" + local width=20 + local filled + local empty + + if [[ "$percentage" == "N/A" ]]; then + printf '%s' "░░░░░░░░░░░░░░░░░░░░" + return + fi + + filled=$(awk "BEGIN { printf \"%d\", ($percentage / 100) * $width + 0.5 }") + empty=$((width - filled)) + + local bar="" + local i + for (( i = 0; i < filled; i++ )); do + bar+="█" + done + for (( i = 0; i < empty; i++ )); do + bar+="░" + done + printf '%s' "$bar" +} + +_score_color() { + local score="$1" + + if [[ "$score" == "N/A" ]]; then + printf '%b' "$DIM" + return + fi + + local int_score="${score%.*}" + if (( int_score >= 80 )); then + printf '%b' "$GREEN" + elif (( int_score >= 60 )); then + printf '%b' "$YELLOW" + else + printf '%b' "$RED" + fi +} + +_repeat_char() { + local char="$1" + local count="$2" + local i + for (( i = 0; i < count; i++ )); do + printf '%s' "$char" + done +} + +_print_banner() { + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S %Z') + local hostname + hostname=$(hostname 2>/dev/null || echo "unknown") + + printf '\n' + printf '%b' "$CYAN" + printf ' _____ ___ ____ _ _ _ _\n' + printf ' / ____|_ _/ ___| / \\ _ _ __| (_) |_\n' + printf ' | | | |\\___ \\ / _ \\| | | |/ _` | | __|\n' + printf ' | |___ | | ___) / ___ \\ |_| | (_| | | |_\n' + printf ' \\____|___|____/_/ \\_\\__,_|\\__,_|_|\\__|\n' + printf '%b' "$RESET" + printf '\n' + printf ' %b%s%b\n' "$BOLD" "$CIS_BENCHMARK" "$RESET" + printf ' %bcisaudit v%s%b | %s | %s\n' "$DIM" "$VERSION" "$RESET" "$hostname" "$timestamp" + printf '\n' +} + +_print_summary_cards() { + local total=$((TOTAL_PASS + TOTAL_FAIL + TOTAL_WARN + TOTAL_SKIP)) + local score_clr + score_clr=$(_score_color "$SCORE_OVERALL") + + printf ' %b┌─────────────────────────────────────────────────────────────────────┐%b\n' "$DIM" "$RESET" + printf ' %b│%b %bOVERALL SCORE%b %b│%b\n' "$DIM" "$RESET" "$BOLD" "$RESET" "$DIM" "$RESET" + printf ' %b│%b %b│%b\n' "$DIM" "$RESET" "$DIM" "$RESET" + printf ' %b│%b %b%s%%%b %b│%b\n' "$DIM" "$RESET" "${score_clr}${BOLD}" "$SCORE_OVERALL" "$RESET" "$DIM" "$RESET" + printf ' %b│%b %b│%b\n' "$DIM" "$RESET" "$DIM" "$RESET" + printf ' %b│%b %bTotal:%b %-6d %b%bPass:%b %-6d %b%bFail:%b %-6d %b%bWarn:%b %-6d %b%bSkip:%b %-4d %b│%b\n' \ + "$DIM" "$RESET" \ + "$BOLD" "$RESET" "$total" \ + "$GREEN" "$BOLD" "$RESET" "$TOTAL_PASS" \ + "$RED" "$BOLD" "$RESET" "$TOTAL_FAIL" \ + "$YELLOW" "$BOLD" "$RESET" "$TOTAL_WARN" \ + "$DIM" "$BOLD" "$RESET" "$TOTAL_SKIP" \ + "$DIM" "$RESET" + printf ' %b└─────────────────────────────────────────────────────────────────────┘%b\n' "$DIM" "$RESET" + printf '\n' +} + +_print_section_table() { + printf ' %b%-44s %4s %4s %4s %4s %6s%b\n' \ + "$BOLD" "Section" "Pass" "Fail" "Warn" "Skip" "Score" "$RESET" + printf ' ' + _repeat_char "─" 75 + printf '\n' + + local section + for section in "${SECTION_ORDER[@]}"; do + local p="${SECTION_PASS[$section]:-0}" + local f="${SECTION_FAIL[$section]:-0}" + local w="${SECTION_WARN[$section]:-0}" + local s="${SECTION_SKIP[$section]:-0}" + local score="${SCORE_BY_SECTION[$section]:-N/A}" + local bar + bar=$(_progress_bar "$score") + local sclr + sclr=$(_score_color "$score") + + local score_display + if [[ "$score" == "N/A" ]]; then + score_display=" N/A" + else + score_display=$(printf '%5s%%' "$score") + fi + + printf ' %-44s %b%4d%b %b%4d%b %b%4d%b %b%4d%b %b%s%b %b%s%b\n' \ + "$section" \ + "$GREEN" "$p" "$RESET" \ + "$RED" "$f" "$RESET" \ + "$YELLOW" "$w" "$RESET" \ + "$DIM" "$s" "$RESET" \ + "${sclr}${BOLD}" "$score_display" "$RESET" \ + "$sclr" "$bar" "$RESET" + done + printf '\n' +} + +_print_detail_results() { + local section + for section in "${SECTION_ORDER[@]}"; do + local has_results=0 + local id + for id in "${RESULT_ORDER[@]}"; do + if [[ "${CTRL_SECTION[$id]}" == "$section" ]]; then + has_results=1 + break + fi + done + + if (( has_results == 0 )); then + continue + fi + + printf ' %b━━━ %s ━━━%b\n' "$BOLD" "$section" "$RESET" + printf '\n' + + for id in "${RESULT_ORDER[@]}"; do + if [[ "${CTRL_SECTION[$id]}" != "$section" ]]; then + continue + fi + + local status="${RESULT_STATUS[$id]}" + local title="${CTRL_TITLE[$id]}" + local symbol + symbol=$(_status_symbol "$status") + local clr + clr=$(_status_color "$status") + + local suffix="" + if [[ "$status" == "$STATUS_SKIP" ]]; then + suffix=" [SKIPPED]" + fi + + printf " ${clr} %s ${BOLD}%-9s ${RESET}${clr}%s%s${RESET}\n" \ + "$symbol" "$id" "$title" "$suffix" + + if [[ "$status" == "$STATUS_FAIL" ]]; then + local evidence="${RESULT_EVIDENCE[$id]:-}" + local remediation="${CTRL_REMEDIATION[$id]:-}" + + if [[ -n "$evidence" ]]; then + printf ' %b Evidence: %s%b\n' "$DIM" "$evidence" "$RESET" + fi + if [[ -n "$remediation" ]]; then + printf ' %b Fix: %s%b\n' "$YELLOW" "$remediation" "$RESET" + fi + fi + + if [[ "$status" == "$STATUS_WARN" ]]; then + local evidence="${RESULT_EVIDENCE[$id]:-}" + if [[ -n "$evidence" ]]; then + printf ' %b Evidence: %s%b\n' "$DIM" "$evidence" "$RESET" + fi + fi + done + printf '\n' + done +} + +_print_footer() { + local l1_clr + l1_clr=$(_score_color "$SCORE_LEVEL1") + local l2_clr + l2_clr=$(_score_color "$SCORE_LEVEL2") + local ov_clr + ov_clr=$(_score_color "$SCORE_OVERALL") + + printf ' ' + _repeat_char "═" 75 + printf '\n' + + local l1_display + if [[ "$SCORE_LEVEL1" == "N/A" ]]; then + l1_display="N/A" + else + l1_display="${SCORE_LEVEL1}%" + fi + + local l2_display + if [[ "$SCORE_LEVEL2" == "N/A" ]]; then + l2_display="N/A" + else + l2_display="${SCORE_LEVEL2}%" + fi + + printf ' %bLevel 1 Score:%b %b%s%b' "$BOLD" "$RESET" "${l1_clr}${BOLD}" "$l1_display" "$RESET" + printf ' %bLevel 2 Score:%b %b%s%b' "$BOLD" "$RESET" "${l2_clr}${BOLD}" "$l2_display" "$RESET" + printf ' %bOverall Score:%b %b%s%%%b\n' "$BOLD" "$RESET" "${ov_clr}${BOLD}" "$SCORE_OVERALL" "$RESET" + + printf ' ' + _repeat_char "═" 75 + printf '\n' + printf '\n' +} + +emit_terminal_report() { + _print_banner + _print_summary_cards + _print_section_table + _print_detail_results + _print_footer +} diff --git a/third_party/community/linux-cis-hardening-auditor/src/lib/utils.sh b/third_party/community/linux-cis-hardening-auditor/src/lib/utils.sh new file mode 100644 index 0000000..82d7aa9 --- /dev/null +++ b/third_party/community/linux-cis-hardening-auditor/src/lib/utils.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# utils.sh +# +# Shared utility functions for logging and system inspection +# +# Provides ANSI-colored log helpers (info, success, warn, fail) that +# respect the QUIET flag, inline progress display with carriage-return +# clearing, bash version enforcement, root privilege detection, and +# OS identification from /etc/os-release. Wraps all filesystem access +# through SYSROOT so checks work against both the live system and +# test fixture directories: file_exists, read_file, get_sysctl (proc +# tree then sysctl fallback), get_config_value (grep key from config), +# run_cmd (blocks in test mode), and dpkg/systemctl wrappers. +# +# Connects to: +# lib/constants.sh - ANSI color codes, EXIT_FAIL, MIN_BASH_VERSION +# checks/*.sh - check functions call file_exists, get_sysctl, +# package_is_installed, service_is_enabled, etc. + +info() { [[ "$QUIET" == "true" ]] || echo -e "${CYAN}[*]${RESET} $1" >&2; } +success() { [[ "$QUIET" == "true" ]] || echo -e "${GREEN}[✔]${RESET} $1" >&2; } +warn() { [[ "$QUIET" == "true" ]] || echo -e "${YELLOW}[!]${RESET} $1" >&2; } +fail() { echo -e "${RED}[✖]${RESET} $1" >&2; exit "$EXIT_FAIL"; } + +progress() { + [[ "$QUIET" == "true" ]] && return + printf "\r${DIM}[%s] Checking: %s${RESET}%s" "$1" "$2" "$(printf '%40s' '')" >&2 +} + +clear_progress() { + [[ "$QUIET" == "true" ]] && return + printf "\r%80s\r" "" >&2 +} + +check_bash_version() { + if (( BASH_VERSINFO[0] < MIN_BASH_VERSION )); then + fail "Bash ${MIN_BASH_VERSION}+ required (found ${BASH_VERSION})" + fi +} + +check_root() { + if [[ $EUID -ne 0 && "$SYSROOT" == "/" ]]; then + warn "Running without root privileges — some checks will be skipped" + return 1 + fi + return 0 +} + +detect_os() { + local os_release="${SYSROOT}/etc/os-release" + if [[ -f "$os_release" ]]; then + DETECTED_ID=$(grep -oP '^ID=\K.*' "$os_release" | tr -d '"') + DETECTED_VERSION=$(grep -oP '^VERSION_ID=\K.*' "$os_release" | tr -d '"') + else + DETECTED_ID="unknown" + DETECTED_VERSION="unknown" + fi +} + +run_cmd() { + if [[ "$SYSROOT" != "/" ]]; then + return 1 + fi + "$@" 2>/dev/null +} + +file_exists() { + [[ -f "${SYSROOT}${1}" ]] +} + +read_file() { + local path="${SYSROOT}${1}" + if [[ -f "$path" ]]; then + cat "$path" + else + return 1 + fi +} + +get_sysctl() { + local param="$1" + local proc_path="${SYSROOT}/proc/sys/${param//\.//}" + if [[ -f "$proc_path" ]]; then + cat "$proc_path" + return 0 + fi + + if run_cmd sysctl -n "$param"; then + return 0 + fi + + return 1 +} + +get_config_value() { + local file="$1" + local key="$2" + local path="${SYSROOT}${file}" + + if [[ ! -f "$path" ]]; then + return 1 + fi + + grep -Ei "^\s*${key}\s" "$path" | tail -1 | awk '{print $2}' +} + +service_is_enabled() { + if run_cmd systemctl is-enabled "$1"; then + return 0 + fi + return 1 +} + +service_is_active() { + if run_cmd systemctl is-active "$1"; then + return 0 + fi + return 1 +} + +package_is_installed() { + # shellcheck disable=SC2016 # ${Status} is dpkg-query template syntax, not shell expansion + if run_cmd dpkg-query -W -f='${Status}' "$1" | grep -q "install ok installed"; then + return 0 + fi + return 1 +}