forked from nginx/unit
-
Notifications
You must be signed in to change notification settings - Fork 1
Add Trivy security-audit workflow and report sorter; validate cgroup paths against embedded NUL #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
andypost
wants to merge
1
commit into
roadmap
Choose a base branch
from
codex/create-security-audit-with-sorted-results
base: roadmap
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,193 @@ | ||||||
| #!/usr/bin/env python3 | ||||||
| """Create a severity-sorted Markdown security audit report from Trivy JSON.""" | ||||||
|
|
||||||
| from __future__ import annotations | ||||||
|
|
||||||
| import argparse | ||||||
| import json | ||||||
| import sys | ||||||
| from collections import Counter | ||||||
| from pathlib import Path | ||||||
| from typing import Any | ||||||
|
|
||||||
| SEVERITY_ORDER = { | ||||||
| "CRITICAL": 0, | ||||||
| "HIGH": 1, | ||||||
| "MEDIUM": 2, | ||||||
| "LOW": 3, | ||||||
| "UNKNOWN": 4, | ||||||
| } | ||||||
| DISPLAY_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") | ||||||
|
|
||||||
|
|
||||||
| def parse_args() -> argparse.Namespace: | ||||||
| parser = argparse.ArgumentParser( | ||||||
| description=( | ||||||
| "Read Trivy JSON output and write a Markdown report sorted by " | ||||||
| "vulnerability severity, with CRITICAL findings first." | ||||||
| ) | ||||||
| ) | ||||||
| parser.add_argument("trivy_json", type=Path, help="Path to Trivy JSON output") | ||||||
| parser.add_argument( | ||||||
| "--output", | ||||||
| type=Path, | ||||||
| default=Path("security-audit-report.md"), | ||||||
| help="Markdown report path (default: security-audit-report.md)", | ||||||
| ) | ||||||
| parser.add_argument( | ||||||
| "--max-rows", | ||||||
| type=int, | ||||||
| default=200, | ||||||
| help="Maximum vulnerability rows to include in the table (default: 200)", | ||||||
| ) | ||||||
| parser.add_argument( | ||||||
| "--fail-on-severity", | ||||||
| choices=(*DISPLAY_SEVERITIES, "NONE"), | ||||||
| default="CRITICAL", | ||||||
| help=( | ||||||
| "Exit with status 1 when vulnerabilities at or above this severity " | ||||||
| "are found. Use NONE to disable. (default: CRITICAL)" | ||||||
| ), | ||||||
| ) | ||||||
| return parser.parse_args() | ||||||
|
|
||||||
|
|
||||||
| def normalized_severity(value: str | None) -> str: | ||||||
| severity = (value or "UNKNOWN").upper() | ||||||
| return severity if severity in SEVERITY_ORDER else "UNKNOWN" | ||||||
|
|
||||||
|
|
||||||
| def markdown_escape(value: Any) -> str: | ||||||
| text = "" if value is None else str(value) | ||||||
| return text.replace("|", "\\|").replace("\n", " ").replace("\r", " ") | ||||||
|
|
||||||
|
|
||||||
| def cvss_v3_score(vulnerability: dict[str, Any]) -> float: | ||||||
| cvss = vulnerability.get("CVSS") | ||||||
| if not isinstance(cvss, dict): | ||||||
| return 0.0 | ||||||
|
|
||||||
| for source in ("nvd", "redhat", "ghsa"): | ||||||
| score = cvss.get(source, {}).get("V3Score", 0) | ||||||
| if score: | ||||||
| return float(score) | ||||||
|
|
||||||
| return 0.0 | ||||||
|
|
||||||
|
|
||||||
| def vulnerability_sort_key(vulnerability: dict[str, Any]) -> tuple[Any, ...]: | ||||||
| severity = normalized_severity(vulnerability.get("Severity")) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||
|
|
||||||
| return ( | ||||||
| SEVERITY_ORDER[severity], | ||||||
| -cvss_v3_score(vulnerability), | ||||||
| str(vulnerability.get("VulnerabilityID", "")), | ||||||
| str(vulnerability.get("PkgName", "")), | ||||||
| str(vulnerability.get("Target", "")), | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def collect_vulnerabilities(report: dict[str, Any]) -> list[dict[str, Any]]: | ||||||
| findings: list[dict[str, Any]] = [] | ||||||
|
|
||||||
| for result in report.get("Results", []): | ||||||
| target = result.get("Target", "") | ||||||
| result_type = result.get("Type", "") | ||||||
|
|
||||||
| for vulnerability in result.get("Vulnerabilities") or []: | ||||||
| item = dict(vulnerability) | ||||||
| item["Target"] = target | ||||||
| item["ResultType"] = result_type | ||||||
| item["Severity"] = normalized_severity(item.get("Severity")) | ||||||
| findings.append(item) | ||||||
|
|
||||||
| findings.sort(key=vulnerability_sort_key) | ||||||
| return findings | ||||||
|
|
||||||
|
|
||||||
| def format_summary(counts: Counter[str]) -> str: | ||||||
| return " | ".join(f"{severity}: {counts.get(severity, 0)}" for severity in DISPLAY_SEVERITIES) | ||||||
|
|
||||||
|
|
||||||
| def render_markdown(findings: list[dict[str, Any]], max_rows: int) -> str: | ||||||
| counts = Counter(finding["Severity"] for finding in findings) | ||||||
| lines = [ | ||||||
| "# Security Audit", | ||||||
| "", | ||||||
| "Vulnerability findings are sorted by severity, with critical findings first.", | ||||||
| "", | ||||||
| "## Summary", | ||||||
| "", | ||||||
| f"Total vulnerabilities: **{len(findings)}**", | ||||||
| "", | ||||||
| f"{format_summary(counts)}", | ||||||
| "", | ||||||
| ] | ||||||
|
|
||||||
| if not findings: | ||||||
| lines.extend(["## Vulnerabilities", "", "No vulnerabilities were reported by Trivy.", ""]) | ||||||
| return "\n".join(lines) | ||||||
|
|
||||||
| displayed_findings = findings[:max_rows] | ||||||
| lines.extend( | ||||||
| [ | ||||||
| "## Vulnerabilities", | ||||||
| "", | ||||||
| "| Severity | Vulnerability | Package | Installed | Fixed | Target | Title |", | ||||||
| "| --- | --- | --- | --- | --- | --- | --- |", | ||||||
| ] | ||||||
| ) | ||||||
|
|
||||||
| for finding in displayed_findings: | ||||||
| lines.append( | ||||||
| "| {severity} | {vuln_id} | {package} | {installed} | {fixed} | {target} | {title} |".format( | ||||||
| severity=markdown_escape(finding.get("Severity")), | ||||||
| vuln_id=markdown_escape(finding.get("VulnerabilityID")), | ||||||
| package=markdown_escape(finding.get("PkgName")), | ||||||
| installed=markdown_escape(finding.get("InstalledVersion")), | ||||||
| fixed=markdown_escape(finding.get("FixedVersion") or "not fixed"), | ||||||
| target=markdown_escape(finding.get("Target")), | ||||||
| title=markdown_escape(finding.get("Title")), | ||||||
| ) | ||||||
| ) | ||||||
|
|
||||||
| omitted = len(findings) - len(displayed_findings) | ||||||
| if omitted > 0: | ||||||
| lines.extend(["", f"_{omitted} additional vulnerabilities omitted from this table._"]) | ||||||
|
|
||||||
| lines.append("") | ||||||
| return "\n".join(lines) | ||||||
|
|
||||||
|
|
||||||
| def should_fail(findings: list[dict[str, Any]], fail_on_severity: str) -> bool: | ||||||
| if fail_on_severity == "NONE": | ||||||
| return False | ||||||
|
|
||||||
| threshold = SEVERITY_ORDER[fail_on_severity] | ||||||
| return any(SEVERITY_ORDER[finding["Severity"]] <= threshold for finding in findings) | ||||||
|
|
||||||
|
|
||||||
| def main() -> int: | ||||||
| args = parse_args() | ||||||
|
|
||||||
| with args.trivy_json.open(encoding="utf-8") as fh: | ||||||
| report = json.load(fh) | ||||||
|
|
||||||
| findings = collect_vulnerabilities(report) | ||||||
| markdown = render_markdown(findings, args.max_rows) | ||||||
| args.output.parent.mkdir(parents=True, exist_ok=True) | ||||||
| args.output.write_text(markdown, encoding="utf-8") | ||||||
| print(markdown) | ||||||
|
|
||||||
| if should_fail(findings, args.fail_on_severity): | ||||||
| print( | ||||||
| f"Security audit failed: found vulnerabilities at or above {args.fail_on_severity} severity.", | ||||||
| file=sys.stderr, | ||||||
| ) | ||||||
| return 1 | ||||||
|
|
||||||
| return 0 | ||||||
|
|
||||||
|
|
||||||
| if __name__ == "__main__": | ||||||
| raise SystemExit(main()) | ||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| name: Security Audit | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - '**/Cargo.lock' | ||
| - '**/go.mod' | ||
| - '**/go.sum' | ||
| - '**/package-lock.json' | ||
| - '**/package.json' | ||
| - '**/requirements*.txt' | ||
| - '**/Dockerfile*' | ||
| - '.github/scripts/sort-trivy-results.py' | ||
| - '.github/workflows/security-audit.yml' | ||
| push: | ||
| branches: master | ||
| paths: | ||
| - '**/Cargo.lock' | ||
| - '**/go.mod' | ||
| - '**/go.sum' | ||
| - '**/package-lock.json' | ||
| - '**/package.json' | ||
| - '**/requirements*.txt' | ||
| - '**/Dockerfile*' | ||
| - '.github/scripts/sort-trivy-results.py' | ||
| - '.github/workflows/security-audit.yml' | ||
| schedule: | ||
| - cron: '24 3 * * 1' | ||
| workflow_dispatch: | ||
| inputs: | ||
| fail_on_severity: | ||
| description: 'Fail the audit when vulnerabilities at or above this severity are found.' | ||
| type: choice | ||
| default: CRITICAL | ||
| options: | ||
| - CRITICAL | ||
| - HIGH | ||
| - MEDIUM | ||
| - LOW | ||
| - UNKNOWN | ||
| - NONE | ||
|
|
||
| permissions: | ||
| contents: read | ||
| security-events: write | ||
|
|
||
| jobs: | ||
| trivy: | ||
| name: Trivy filesystem audit | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v5 | ||
|
|
||
| - name: Prepare report directory | ||
| run: mkdir -p security-audit | ||
|
|
||
| - name: Run Trivy vulnerability audit | ||
| uses: aquasecurity/trivy-action@v0.36.0 | ||
| with: | ||
| scan-type: fs | ||
| scan-ref: . | ||
| scanners: vuln | ||
| format: json | ||
| output: security-audit/trivy-results.json | ||
| severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL | ||
| exit-code: 0 | ||
|
|
||
| - name: Run Trivy SARIF audit | ||
| uses: aquasecurity/trivy-action@v0.36.0 | ||
| with: | ||
| scan-type: fs | ||
| scan-ref: . | ||
| scanners: vuln | ||
| format: sarif | ||
| output: security-audit/trivy-results.sarif | ||
| severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL | ||
| exit-code: 0 | ||
|
|
||
| - name: Upload SARIF results | ||
| uses: github/codeql-action/upload-sarif@v4 | ||
| with: | ||
| sarif_file: security-audit/trivy-results.sarif | ||
|
|
||
| - name: Sort audit by critical severity | ||
| env: | ||
| FAIL_ON_SEVERITY: ${{ github.event.inputs.fail_on_severity || 'CRITICAL' }} | ||
| run: | | ||
| set +e | ||
| .github/scripts/sort-trivy-results.py \ | ||
| security-audit/trivy-results.json \ | ||
| --output security-audit/security-audit-report.md \ | ||
| --fail-on-severity "${FAIL_ON_SEVERITY}" | ||
| status=$? | ||
| cat security-audit/security-audit-report.md >> "$GITHUB_STEP_SUMMARY" | ||
| exit "$status" | ||
|
|
||
| - name: Upload security audit report | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: security-audit | ||
| path: security-audit/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Security Audit Findings | ||
|
|
||
| This document captures follow-up findings from a targeted manual security review of | ||
| recent security-audit changes and adjacent Unit code paths. These items are | ||
| **potential vulnerabilities** until each has a reproducer, impact analysis, and | ||
| fix or explicit dismissal. | ||
|
|
||
| ## Scope | ||
|
|
||
| Reviewed areas: | ||
|
|
||
| - Configuration validation and process isolation paths. | ||
| - Static file serving controls related to chroot, symlinks, and mount traversal. | ||
| - GitHub Actions and Docker release-image supply-chain paths. | ||
| - New Trivy audit workflow and report-generation script. | ||
|
|
||
| ## Candidate vulnerabilities | ||
|
|
||
| | ID | Severity | Confidence | Area | Summary | Primary evidence | Suggested next step | | ||
| | --- | --- | --- | --- | --- | --- | --- | | ||
| | SA-001 | High | Medium | Process isolation | `rootfs` accepts length-tracked config strings but later stores and uses them as NUL-terminated C strings. Embedded NUL bytes could truncate the effective root path passed to `chroot()`/`pivot_root()` and related mount setup. | `rootfs` is declared as a plain string without a NUL-byte validator in `src/nxt_conf_validation.c:1352-1356`; `nxt_isolation_set_rootfs()` copies the configured bytes and appends `\0` in `src/nxt_isolation.c:501-525`; the path is later used as a C string in `src/nxt_isolation.c:817-825`. | Add a rootfs validator that rejects empty, non-absolute, `/`, embedded-NUL, and path-normalization edge cases; add regression tests mirroring the cgroup embedded-NUL test. | | ||
| | SA-002 | Medium | Medium | Configuration validation | Several application path-like fields are validated only as strings even though downstream code commonly treats them as filesystem paths, module names, or C strings. This may create truncation, path confusion, or audit-bypass behavior for embedded NUL input. | Common fields such as `working_directory`, `stdout`, and `stderr` are plain strings in `src/nxt_conf_validation.c:1274-1291`; language-specific path/module fields are also plain strings, e.g. Ruby `script` and `hooks` in `src/nxt_conf_validation.c:1134-1148`, Java `webapp` and `unit_jars` in `src/nxt_conf_validation.c:1152-1170`, and Wasm module/component names in `src/nxt_conf_validation.c:1184-1239`. | Introduce reusable validators for C-string-backed paths and module identifiers, then apply them consistently to all fields consumed by C APIs. | | ||
| | SA-003 | Medium | Low | User namespace isolation | `uidmap`/`gidmap` entries require integer fields but do not appear to enforce non-negative values, kernel UID/GID-map bounds, overlap checks, or a maximum number of entries during configuration validation. Invalid or pathological maps could produce confusing authorization decisions or resource-exhaustion behavior before kernel rejection. | Required integer fields are defined in `src/nxt_conf_validation.c:1470-1485`; arrays are copied with `map->size * sizeof(nxt_clone_map_entry_t)` in `src/nxt_isolation.c:346-362`; map output is sized around 32-bit decimal entries in `src/nxt_clone.c:128-147`. | Add validation for non-negative 32-bit IDs, positive sizes, no arithmetic overflow, no overlaps, and a practical entry-count limit. Add tests for negative, oversized, overlapping, and excessive maps. | | ||
| | SA-004 | Medium | Medium | Release supply chain | Docker images clone source by mutable branch/tag name inside the Docker build rather than using the already-checked-out commit or verifying a commit SHA. A moved tag or manual workflow input could build and publish an image from code different from the workflow revision under review. | `pkg/docker/template.Dockerfile:38` runs `git clone --depth 1 -b @@VERSION@@ https://github.com/freeunitorg/freeunit unit`; `.github/workflows/docker.yml:131-147` derives a version string and rewrites Dockerfiles before publishing. | Build from the checked-out workspace, or pass and verify an immutable commit SHA; record the source revision in image labels and provenance. | | ||
| | SA-005 | Medium | Medium | CI/CD supply chain | Security-sensitive workflows use mutable action tags while granting permissions such as `security-events: write`. A compromised or retagged third-party action could affect audit integrity or code-scanning uploads. | The new audit workflow grants `security-events: write` in `.github/workflows/security-audit.yml:43-45` and uses version tags for actions in `.github/workflows/security-audit.yml:53-104`; the Docker publisher similarly uses mutable action tags in `.github/workflows/docker.yml:127-160`. | Pin third-party actions to full commit SHAs, enable Dependabot updates for GitHub Actions SHAs, and keep write permissions scoped to the minimal job/step that needs them. | | ||
| | SA-006 | Low | Medium | HTTP compatibility/security hardening | `discard_unsafe_fields` can be disabled, allowing non-token header names such as underscores and punctuation to reach applications. This is intentional configurability, but can reintroduce header-smuggling or auth-bypass risk behind intermediaries with different header-name normalization. | The parser skips unsafe fields only when `discard_unsafe_fields` is enabled in `src/nxt_http_parse.c:539-550`; tests confirm unsafe names are passed through when disabled in `test/test_http_header.py:484-490`. | Document this as a dangerous compatibility mode, consider warning on disable, and add proxy/intermediary threat-model notes. | | ||
|
|
||
| ## Recently addressed during this audit thread | ||
|
|
||
| | ID | Status | Summary | Evidence | | ||
| | --- | --- | --- | --- | | ||
| | SA-FIXED-001 | Fixed in `b928b85` | Cgroup isolation path validation now rejects embedded NUL bytes before constructing a slash-wrapped validation path. | `src/nxt_conf_validation.c:3367-3375` and `test/test_python_isolation.py:225-228`. | | ||
|
|
||
| ## Recommended prioritization | ||
|
|
||
| 1. Fix SA-001 first because it is adjacent to privileged isolation setup and is | ||
| structurally similar to the cgroup NUL issue already fixed. | ||
| 2. Triage SA-002 by enumerating every config string that is later passed to a C | ||
| API expecting NUL termination. | ||
| 3. Harden CI/CD supply-chain items SA-004 and SA-005 before relying on the new | ||
| audit workflow as a release gate. | ||
| 4. Treat SA-003 and SA-006 as defense-in-depth unless a concrete reproducer | ||
| shows direct privilege escalation or request smuggling. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current escaping logic does not handle backslashes. In Markdown tables, a backslash preceding a pipe (
\|) acts as an escape character for the pipe. If a field (such as a package title or version) ends with a backslash, it could inadvertently escape the table delimiter and break the report formatting. It is safer to escape backslashes first.