diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 05692f6..ed1cdd3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,7 +25,12 @@ jobs: - run: pip install -e ".[docs]" - run: cp CHANGELOG.md docs/changelog.md - run: mkdocs build --strict + # Tries to switch Pages on. The built-in token is not always allowed to; if this step cannot, + # enable Settings > Pages > Source: GitHub Actions once, by hand. - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + continue-on-error: true + with: + enablement: true - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: site diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..479e89b --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,38 @@ +name: Prepare release + +# Run from the Actions tab: bumps the version, rolls the changelog, tags, and starts the release. +on: + workflow_dispatch: + inputs: + version: + description: Version to release, for example 1.0.0 + required: true + +permissions: + contents: read + +jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + env: + VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + - name: Bump the version and the changelog + run: python3 scripts/bump_release.py "$VERSION" + - name: Commit and tag + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "Release $VERSION" + git tag "v$VERSION" + git push origin HEAD "v$VERSION" + # Pushes made with the built-in token do not start other workflows, so start it explicitly. + - name: Start the release workflow + run: gh workflow run release.yml --ref "v$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c395f06..b3018e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,8 @@ name: Release on: push: tags: ["v*"] + # Started by the prepare-release workflow, whose tag push cannot trigger this one itself. + workflow_dispatch: permissions: contents: read @@ -44,7 +46,10 @@ jobs: with: name: dist path: dist/ + # With no PYPI_API_TOKEN secret this uses trusted publishing (OIDC). - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 + with: + password: ${{ secrets.PYPI_API_TOKEN }} github-release: needs: pypi @@ -98,3 +103,27 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + action-tag: + needs: github-release + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.ACTION_REPO_TOKEN }} + OWNER: ${{ github.repository_owner }} + steps: + - name: Move the major tag of the action repository + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::notice::ACTION_REPO_TOKEN is not set, skipping the action tags" + exit 0 + fi + version="${GITHUB_REF_NAME#v}" + major="v${version%%.*}" + gh auth setup-git + git clone "https://github.com/$OWNER/devshield-action.git" out + cd out + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -f "$GITHUB_REF_NAME" + git tag -f "$major" + git push -f origin "$GITHUB_REF_NAME" "$major" diff --git a/.github/workflows/sync-action.yml b/.github/workflows/sync-action.yml new file mode 100644 index 0000000..d15a4c5 --- /dev/null +++ b/.github/workflows/sync-action.yml @@ -0,0 +1,46 @@ +name: Sync action + +# Publishes packaging/devshield-action to the ByteGigs-Labs/devshield-action repository. +# It needs a secret named ACTION_REPO_TOKEN (a personal access token with the repo scope). +# Without the secret this workflow does nothing, and the action still works from this +# repository as ByteGigs-Labs/DevShield-CLI/packaging/devshield-action@main. +on: + push: + branches: [main] + paths: ["packaging/devshield-action/**", ".github/workflows/sync-action.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.ACTION_REPO_TOKEN }} + OWNER: ${{ github.repository_owner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Sync + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::notice::ACTION_REPO_TOKEN is not set, skipping the action repository sync" + exit 0 + fi + target="$OWNER/devshield-action" + gh repo view "$target" > /dev/null 2>&1 \ + || gh repo create "$target" --public --description "GitHub Action for DevShield" + gh auth setup-git + git clone "https://github.com/$target.git" out + cp packaging/devshield-action/action.yml packaging/devshield-action/README.md out/ + cd out + git checkout -B main + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Already up to date" + else + git commit -m "Sync from DevShield-CLI $GITHUB_SHA" + git push origin main + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8f2f5..e6e1f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ All notable changes to this project are documented here. The format follows - Plugin system: rules from third-party packages through the `devshield.rules` entry point, `devshield plugins list`, failure isolation, `devshield.testing.run_rule` and a plugin guide. - An example plugin in `examples/devshield-plugin-example`. -- Documentation site, pre-commit hooks, a Docker image and a GitHub Action template. +- Documentation site with screenshots, pre-commit hooks, a Docker image, a GitHub Action and + release automation (`Prepare release` workflow, PyPI publishing, action repository sync). ### Changed diff --git a/README.md b/README.md index 3bd57c4..218e238 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,33 @@ Upcoming: --- +# Screenshots + +### Scan + +devshield scan output: findings grouped by area with a risk score + +### HTML report + +A single self-contained file with a risk gauge, search and category filters, and no external requests. + +The HTML report with a risk gauge and a filterable findings table + +### Safe auto-fix + +devshield fix showing a diff of the files it would create + +### Dependency vulnerabilities + +devshield deps listing known advisories for an outdated lodash + +### Docker image inspection + +devshield docker scan results for a local image + +Regenerate them with `python scripts/gen_screenshots.py`. + + # Installation ```bash diff --git a/RELEASING.md b/RELEASING.md index 0bd324b..a743a27 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,34 +1,47 @@ # Releasing -Releases are cut from `main` by pushing a tag. Everything after the tag is automated. +## The short version -## Checklist +Open **Actions, Prepare release, Run workflow** and enter the version (for example `1.0.0`). +It bumps `__version__`, turns the `[Unreleased]` changelog section into that version, commits, +tags `vX.Y.Z` and starts the `release` workflow. Make sure CI is green on `main` first. -1. Make sure CI is green on `main`, and run the performance budget: `pytest -m perf`. -2. In `CHANGELOG.md`, rename `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` and add a fresh empty - `[Unreleased]` section above it. Update the link references if you keep any. -3. Set `__version__` in `devshield/__init__.py` to `X.Y.Z`. -4. Commit: `git commit -am "Release X.Y.Z"`, then `git tag vX.Y.Z` and `git push --follow-tags`. +You can also do it by hand: `python scripts/bump_release.py X.Y.Z`, commit, `git tag vX.Y.Z`, +`git push --follow-tags`. The `release` workflow then: - builds the sdist and wheel and checks that the tag matches `__version__`; -- publishes to PyPI with trusted publishing (no token is stored); +- publishes to PyPI; - creates a GitHub release whose notes come from the matching `CHANGELOG.md` section; -- builds and pushes `ghcr.io/bytegigs-labs/devshield` tagged `X.Y.Z`, `X.Y` and `latest`. +- builds and pushes `ghcr.io/bytegigs-labs/devshield` tagged `X.Y.Z`, `X.Y` and `latest`; +- moves the `vX` and `vX.Y.Z` tags of the action repository (when `ACTION_REPO_TOKEN` is set). The `docs` workflow redeploys the documentation site on every push to `main`. -## One-time setup - -- **PyPI:** create the `devshield-cli` project (or a pending publisher) and add a trusted - publisher: owner `ByteGigs-Labs`, repository `DevShield-CLI`, workflow `release.yml`, - environment `pypi`. Create a GitHub environment named `pypi`. -- **GitHub Pages:** Settings, Pages, Source: GitHub Actions. -- **Packages:** after the first image push, make the `devshield` package public. -- **GitHub Action:** copy `packaging/devshield-action/` into a new - `ByteGigs-Labs/devshield-action` repository, tag it `v1`, and move the `v1` tag with each release. -- **Secret patterns:** `devshield update` reads `data/secret-patterns.json` from `main`. +## What configures itself + +On the first push these need no manual setup: the `pypi` and `github-pages` environments (GitHub +creates them when a workflow first uses them), the docs build, the CI jobs and the release +automation above. The `docs` workflow also tries to switch GitHub Pages on. + +## What GitHub or PyPI only allow you to do yourself + +A workflow cannot grant itself these, so they are one-time clicks: + +- **PyPI publishing.** Either add a trusted publisher (PyPI, Your projects or Publishing, add a + pending publisher: owner `ByteGigs-Labs`, repository `DevShield-CLI`, workflow `release.yml`, + environment `pypi`), or create an API token and save it as the repository secret + `PYPI_API_TOKEN`. The workflow uses the token when it exists and trusted publishing otherwise. +- **GitHub Pages**, only if the `docs` workflow could not enable it: Settings, Pages, Source: + GitHub Actions. +- **The action repository (optional).** Save a personal access token with the `repo` scope as the + secret `ACTION_REPO_TOKEN`. The `sync-action` workflow then creates + `ByteGigs-Labs/devshield-action`, keeps it in sync, and `release` moves its `v1` tag. Without it + the action works from this repository as + `ByteGigs-Labs/DevShield-CLI/packaging/devshield-action@main`. +- **Image visibility.** After the first image push, open the `devshield` package on GitHub and set + its visibility to public. ## Version numbers diff --git a/docs/images/deps.svg b/docs/images/deps.svg new file mode 100644 index 0000000..11e2066 --- /dev/null +++ b/docs/images/deps.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + devshield deps + + + + + + + + + + $ devshield deps +Checked 1 of 1 packages from 1lockfile(s) +┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓ +┃Package┃Installed┃Advisory                                            ┃Severity┃Fixed in┃ +┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩ +│lodash │4.17.15  │GHSA-35jh-r3h4-6jhm (CVE-2021-23337, CVE-2026-4800)│high    │4.17.21 │ +│││Command Injection in lodash                         │││ +│lodash │4.17.15  │GHSA-p6mc-m468-83gw (CVE-2020-8203)│high    │4.17.19 │ +│││Prototype Pollution in lodash                       │││ +│lodash │4.17.15  │GHSA-r5fr-rjxr-66jc (CVE-2021-23337, CVE-2026-4800)│high    │4.18.0  │ +│││lodash vulnerable to Code Injection via `_.template`│││ +│││imports key names                                   │││ +│lodash │4.17.15  │GHSA-29mw-wpgm-hmr9 (CVE-2020-28500)│medium  │4.17.21 │ +│││Regular Expression Denial of Service (ReDoS) in     │││ +│││lodash                                              │││ +│lodash │4.17.15  │GHSA-f23m-r3pf-42rh (CVE-2025-13465, CVE-2026-2950)│medium  │4.18.0  │ +│││lodash vulnerable to Prototype Pollution via array  │││ +│││path bypass in `_.unset` and `_.omit`               │││ +│lodash │4.17.15  │GHSA-xxjr-mmjv-4gpg (CVE-2025-13465, CVE-2026-2950)│medium  │4.17.23 │ +│││Lodash has Prototype Pollution Vulnerability in     │││ +│││`_.unset` and `_.omit` functions                    │││ +└─────────┴───────────┴──────────────────────────────────────────────────────┴──────────┴──────────┘ +6 known vulnerabilities: 3 high, 3 medium + + + + diff --git a/docs/images/docker-scan.svg b/docs/images/docker-scan.svg new file mode 100644 index 0000000..c518994 --- /dev/null +++ b/docs/images/docker-scan.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + devshield docker scan + + + + + + + + + + $ devshield docker scan node:latest +Image   node:latest +Size    1.8 GB +Layers  7 +User    root (default) +Ports   none +Built   5 days ago + +⚠ IMG001 Image runs as root [medium] +    The image has no non-root USER, so containers start as root +    Fix: Add a USER instruction with an unprivileged account. +⚠ IMG004 Large image [low] +    The image is 1.7 GiB +    Fix: Use a slim base image and a multi-stage build. +2 findings + + + + diff --git a/docs/images/fix.svg b/docs/images/fix.svg new file mode 100644 index 0000000..eddc99b --- /dev/null +++ b/docs/images/fix.svg @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + devshield fix + + + + + + + + + + $ devshield fix --dry-run +--- /dev/null ++++ b/.env.example +@@ -0,0 +1,2 @@ ++JWT_SECRET= ++SESSION_SECRET= +--- /dev/null ++++ b/.github/workflows/ci.yml +@@ -0,0 +1,25 @@ ++name: CI ++ ++on: ++  push: ++    branches: [main] ++  pull_request: ++ ++permissions: ++  contents: read ++ ++jobs: ++  test: ++    runs-on: ubuntu-latest ++    steps: ++      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 ++      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 ++        with: ++          node-version: lts/* ++      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 ++        with: ++          path: ~/.npm ++          key: npm-${{ hashFiles('package.json') }} ++      - run: npm install ++      - run: npm run build --if-present ++      - run: npm test --if-present +--- /dev/null ++++ b/.gitignore +@@ -0,0 +1,2 @@ ++# Local environment files (added by DevShield) ++.env + + +Dry run: re-run with --yes to apply. + + + + diff --git a/docs/images/html-report.png b/docs/images/html-report.png new file mode 100644 index 0000000..a6083f8 Binary files /dev/null and b/docs/images/html-report.png differ diff --git a/docs/images/scan.svg b/docs/images/scan.svg new file mode 100644 index 0000000..da4e4f7 --- /dev/null +++ b/docs/images/scan.svg @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + devshield scan + + + + + + + + + + $ devshield scan --min-severity medium +──────────────────────────────────────────────────────────────────────────────────────────────────── +✔ Node.js detected +✔ Framework detected: Express +──────────────────────────────────────────────────────────────────────────────────────────────────── +Security + +✖ SH202 .env file committed or not ignored [critical] +    .env is committed to git +    .env +✖ SH205 Weak or empty application secret [high] +    JWT_SECRET is weak or too short +    .env:1 +✖ SH205 Weak or empty application secret [high] +    SESSION_SECRET is weak or too short +    .env:2 +✖ SH002 Permissive CORS configuration [high] +    CORS allows any origin with credentials +    server.js:6 +✖ SH008 Dynamic code execution [high] +    Code or command is built dynamically +    server.js:13 +⚠ SH003 Security headers not configured [medium] +    No helmet or manual security headers found +    package.json:4 +⚠ SH101 Insecure session cookie settings [medium] +    Session cookie sets secure to an unsafe value +    server.js:9 +⚠ SH103 No rate limiting on authentication routes [medium] +    Authentication route has no rate limiter +    server.js:11 +⚠ SH102 Unsafe JWT usage [medium] +    jwt.verify does not pin the allowed algorithms +    server.js:12 +──────────────────────────────────────────────────────────────────────────────────────────────────── +Performance + +⚠ PERF301 Lockfile missing or out of sync [medium] +    package.json has no lockfile +    package.json +⚠ PERF304 Unpinned dependency version [medium] +    lodash is declared as '*' +    package.json:10 +──────────────────────────────────────────────────────────────────────────────────────────────────── +Project Health + +⚠ DX001 No automated tests detected [medium] +    No test files or test directories found +──────────────────────────────────────────────────────────────────────────────────────────────────── +12 findings: 1 critical, 4 high, 7 medium + +Risk Score + +10 / 100 + +Critical Risk + + + + diff --git a/docs/index.md b/docs/index.md index e419348..1cb31ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,4 +15,22 @@ devshield scan - **Baselines** to adopt it on an existing project without a wall of findings - **Plugins** so you can add your own rules +## What it looks like + +![devshield scan output](images/scan.svg) + +The HTML report is a single self-contained file with a risk gauge, search and category filters: + +![The HTML report](images/html-report.png) + +`devshield fix` previews exactly what it would change before it touches anything: + +![devshield fix](images/fix.svg) + +`devshield deps` and `devshield docker scan` cover dependencies and local images: + +![devshield deps](images/deps.svg) + +![devshield docker scan](images/docker-scan.svg) + Start with [Getting started](getting-started.md). diff --git a/scripts/bump_release.py b/scripts/bump_release.py new file mode 100644 index 0000000..40189a7 --- /dev/null +++ b/scripts/bump_release.py @@ -0,0 +1,59 @@ +"""Prepare a release: python scripts/bump_release.py X.Y.Z [--date YYYY-MM-DD]. + +Sets ``__version__`` and turns the ``[Unreleased]`` changelog section into ``[X.Y.Z]``. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERSION = re.compile(r"^\d+\.\d+\.\d+$") +UNRELEASED = "## [Unreleased]" + + +def bump_version(source: str, version: str) -> str: + updated, count = re.subn(r'(__version__ = ")[^"]+(")', rf"\g<1>{version}\g<2>", source, count=1) + if count != 1: + raise ValueError("no __version__ assignment found") + return updated + + +def bump_changelog(text: str, version: str, released: str) -> str: + if UNRELEASED not in text: + raise ValueError("CHANGELOG.md has no [Unreleased] section") + if f"## [{version}]" in text: + raise ValueError(f"CHANGELOG.md already has a section for {version}") + body = text.split(UNRELEASED, 1)[1] + if not re.search(r"^\s*- ", body.split("\n## [", 1)[0], re.MULTILINE): + raise ValueError("the [Unreleased] section is empty") + return text.replace(UNRELEASED, f"{UNRELEASED}\n\n## [{version}] - {released}", 1) + + +def main(argv: list[str] | None = None, root: Path = ROOT) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("--date", default=date.today().isoformat()) + args = parser.parse_args(argv) + if not VERSION.match(args.version): + print(f"{args.version!r} is not a version like 1.2.3", file=sys.stderr) + return 2 + init, log = root / "devshield" / "__init__.py", root / "CHANGELOG.md" + try: + new_init = bump_version(init.read_text(encoding="utf-8"), args.version) + new_log = bump_changelog(log.read_text(encoding="utf-8"), args.version, args.date) + except ValueError as exc: + print(exc, file=sys.stderr) + return 1 + init.write_text(new_init, encoding="utf-8") + log.write_text(new_log, encoding="utf-8") + print(f"Prepared {args.version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gen_screenshots.py b/scripts/gen_screenshots.py new file mode 100644 index 0000000..a51fc64 --- /dev/null +++ b/scripts/gen_screenshots.py @@ -0,0 +1,136 @@ +"""Regenerate the screenshots in docs/images: python scripts/gen_screenshots.py [IMAGE]. + +Terminal screenshots are SVG exports of real output (rendered by Rich, so they stay sharp and +searchable). The HTML report screenshot needs Chrome or Chromium and is skipped without it. The +dependency screenshot queries OSV.dev; the Docker one needs a local image (default node:latest). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from devshield.core.cache import DiskCache +from devshield.core.config import Config +from devshield.core.models import Severity +from devshield.core.registry import default_registry +from devshield.core.runner import ScanOptions, run_scan +from devshield.fixers import plan_fixes, render_diff +from devshield.reports.deps import render_deps_terminal +from devshield.reports.formats import FORMATS +from devshield.reports.image import render_image_terminal +from devshield.reports.terminal import render_terminal +from devshield.scanners.deps import scan_dependencies +from devshield.scanners.image import DockerError, scan_image +from devshield.scanners.osv import OsvClient +from rich.console import Console +from rich.syntax import Syntax + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "tests" / "fixtures" +OUT = ROOT / "docs" / "images" +WIDTH = 100 + + +def recorder() -> Console: + return Console(record=True, width=WIDTH, force_terminal=True, color_system="truecolor") + + +def save(console: Console, name: str, title: str) -> None: + console.save_svg(str(OUT / name), title=title) + print(f"wrote docs/images/{name}") + + +def scan_screenshot(registry) -> None: # type: ignore[no-untyped-def] + console = recorder() + options = ScanOptions(min_severity=Severity.MEDIUM) + result = run_scan(FIXTURES / "node-express-insecure", Config(), registry, options) + titles = {rule.id: rule.title for rule in registry.all()} + console.print("$ devshield scan --min-severity medium", style="bold green") + render_terminal(result, console, titles, verbose=False) + save(console, "scan.svg", "devshield scan") + + +def fix_screenshot(registry) -> None: # type: ignore[no-untyped-def] + console = recorder() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "project" + shutil.copytree(FIXTURES / "node-express-insecure", root) + plan = plan_fixes(root, run_scan(root, Config(), registry), Config().auto_fix) + console.print("$ devshield fix --dry-run", style="bold green") + console.print(Syntax(render_diff(plan.changes), "diff", theme="monokai")) + for skipped in plan.skipped: + console.print( + f"Skipped {skipped.rule_id} ({skipped.file}): {skipped.reason}", style="dim" + ) + console.print("\nDry run: re-run with --yes to apply.", style="dim") + save(console, "fix.svg", "devshield fix") + + +def deps_screenshot() -> None: + console = recorder() + with tempfile.TemporaryDirectory() as tmp: + client = OsvClient(DiskCache(Path(tmp))) + result = scan_dependencies(FIXTURES / "node-outdated-lodash", client) + console.print("$ devshield deps", style="bold green") + render_deps_terminal(result, console) + save(console, "deps.svg", "devshield deps") + + +def docker_screenshot(image: str) -> None: + try: + report = scan_image(image) + except DockerError as exc: + print(f"skipped the Docker screenshot: {exc}") + return + console = recorder() + console.print(f"$ devshield docker scan {image}", style="bold green") + render_image_terminal(report, console) + save(console, "docker-scan.svg", "devshield docker scan") + + +def html_screenshot(registry) -> None: # type: ignore[no-untyped-def] + chrome = next( + (c for c in ("google-chrome", "chromium", "chromium-browser") if shutil.which(c)), None + ) + if chrome is None: + print("skipped the HTML screenshot: Chrome or Chromium not found") + return + result = run_scan(FIXTURES / "node-express-insecure", Config(), registry) + with tempfile.TemporaryDirectory() as tmp: + page = Path(tmp) / "report.html" + page.write_text(FORMATS["html"].render(result, registry), encoding="utf-8") + target = OUT / "html-report.png" + subprocess.run( # noqa: S603 - fixed arguments, a path we just created + [ + chrome, + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--hide-scrollbars", + f"--screenshot={target}", + "--window-size=1200,1500", + page.as_uri(), + ], + check=True, + capture_output=True, + timeout=120, + ) + print("wrote docs/images/html-report.png") + + +def main(image: str) -> None: + OUT.mkdir(parents=True, exist_ok=True) + registry = default_registry() + scan_screenshot(registry) + fix_screenshot(registry) + deps_screenshot() + docker_screenshot(image) + html_screenshot(registry) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "node:latest") diff --git a/tests/test_release_script.py b/tests/test_release_script.py new file mode 100644 index 0000000..8397425 --- /dev/null +++ b/tests/test_release_script.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[1] / "scripts" / "bump_release.py" +spec = importlib.util.spec_from_file_location("bump_release", SCRIPT) +assert spec and spec.loader +bump = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bump) + +CHANGELOG = """# Changelog + +## [Unreleased] + +### Added + +- Something new. + +## [0.9.0] - 2026-01-01 + +- Older. +""" + + +def project(tmp_path: Path, changelog: str = CHANGELOG) -> Path: + (tmp_path / "devshield").mkdir(parents=True) + (tmp_path / "devshield" / "__init__.py").write_text('__version__ = "0.1.0.dev0"\n') + (tmp_path / "CHANGELOG.md").write_text(changelog) + return tmp_path + + +def test_bumps_version_and_changelog(tmp_path: Path) -> None: + root = project(tmp_path) + assert bump.main(["1.0.0", "--date", "2026-11-01"], root) == 0 + assert (root / "devshield" / "__init__.py").read_text() == '__version__ = "1.0.0"\n' + log = (root / "CHANGELOG.md").read_text() + assert "## [Unreleased]\n\n## [1.0.0] - 2026-11-01\n\n### Added" in log + assert log.index("[1.0.0]") < log.index("[0.9.0]") + + +@pytest.mark.parametrize("version", ["1.0", "v1.0.0", "1.0.0-rc1", ""]) +def test_rejects_bad_versions(tmp_path: Path, version: str) -> None: + assert bump.main([version], project(tmp_path)) == 2 + + +def test_refuses_empty_or_duplicate_sections_without_touching_files(tmp_path: Path) -> None: + empty = project(tmp_path, "# Changelog\n\n## [Unreleased]\n\n## [0.9.0] - 2026-01-01\n- x\n") + assert bump.main(["1.0.0"], empty) == 1 + assert ( + (empty / "devshield" / "__init__.py").read_text().startswith('__version__ = "0.1.0.dev0"') + ) + assert bump.main(["0.9.0"], project(tmp_path / "b")) == 1 + assert bump.main(["1.0.0"], project(tmp_path / "c", "# Changelog\n")) == 1