diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7c42757 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# EditorConfig houdt de opmaak gelijk tussen editors en IDE's. +# VS Code, JetBrains en anderen lezen dit bestand vanzelf. +# Meer info: https://editorconfig.org +# +# Deze versie is organisatiebreed gelijk. Wijk hier niet per repository van af: +# vijf licht verschillende varianten leverden alleen ruis op, geen voordeel. +# +# De configbestanden die zelf geen comments kunnen dragen omdat het JSON is, +# staan hier genoemd zodat er ergens een aanwijzing is wat ze doen: +# +# .htmlhintrc - HTML-linting (HTMLHint). Structuur- en toegankelijkheidsregels. +# renovate.json - Renovate-bot. Automatische dependency-updates via pull requests. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +# Trailing whitespace betekent iets in Markdown (regeleinde) +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.sh] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45ff4d7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# .gitattributes zorgt voor consistente regeleindes tussen Windows, Mac en Linux. +# Zonder dit kunnen regeleindes per ontwikkelaar of OS verschillen, wat leidt +# tot onnodige git-diffs en merge-conflicten. +# +# Deze versie is organisatiebreed gelijk. Wijk hier niet per repository van af: +# vier licht verschillende varianten leverden alleen ruis op, geen voordeel. + +# Standaard: forceer LF voor alle tekstbestanden +* text=auto eol=lf + +# Binaire bestanden: geen regeleindeconversie +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.avif binary +*.pdf binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.zip binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 08e83fb..819e7c4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,17 +4,26 @@ ## Type of change -- [ ] Bug fix -- [ ] New feature / script -- [ ] Refactor / cleanup -- [ ] CI / workflow change -- [ ] Documentation +- [ ] `feat` — new feature or script +- [ ] `fix` — bug fix +- [ ] `content` — update or improve the site's landing page +- [ ] `docs` — changes to README, CONTRIBUTING, or meta documentation +- [ ] `chore` — maintenance (dependencies, config, CI/CD) +- [ ] `refactor` — restructuring without behaviour changes +- [ ] `style` — formatting, whitespace, typos +- [ ] `revert` — reverting a previous commit -## Testing +> [PR title and commit types must follow these standards — view the contributing guide](https://github.com/THectic-NL/WinDeploy/blob/main/CONTRIBUTING.md#commit-messages) -- [ ] Tested on Windows 11 25H2 +## Checklist + +- [ ] PR title follows the commit convention +- [ ] Tested on a real Windows 11 25H2 machine (required for anything under `Scripts/` or `Docs/` — CI cannot exercise BitLocker, Sysprep, WinGet or Defender) - [ ] PSScriptAnalyzer passes locally - [ ] No hardcoded IPs, credentials, or company-specific data +- [ ] Both EN (`*.md`) and NL (`*.nl.md`) versions updated, if `src/content/` changed +- [ ] No broken internal links, if `src/` changed +- [ ] Site tested locally with `cd src && hugo server`, if `src/` changed ## Notes diff --git a/.github/scripts/check-renovate-patterns.py b/.github/scripts/check-renovate-patterns.py new file mode 100755 index 0000000..bb7c695 --- /dev/null +++ b/.github/scripts/check-renovate-patterns.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +"""Flag Renovate file patterns that look like a regex but are not delimited. + +managerFilePatterns and matchFileNames accept "RegEx (re2) and glob patterns". +A value counts as a regex only when it is wrapped in slashes; everything else +is read as a glob. So a pattern like + + "^\\.github/workflows/.*\\.ya?ml$" + +matches no file at all, and the custom manager around it never fires. Nothing +reports this: renovate-config-validator says the config is valid, because it +is -- it just silently does nothing. The only visible symptom is a dependency +that stops receiving updates, which is easy to miss for months. + +Usage: check-renovate-patterns.py [config.json ...] +Missing files are skipped, so the same call works in every repository. +""" +import json +import pathlib +import re +import sys + +# Constructs that carry meaning in a regex but not in a glob. +REGEXY = re.compile(r"^\^|\$$|\\\.|\.\*|\.\+|\(\?|\[\^|\\d|\\w|\\s|[)?]\|") + +# Renovate options whose values are matched as "regex or glob". +PATTERN_KEYS = { + "managerFilePatterns", + "matchFileNames", + "fileMatch", + "matchPackageNames", +} + +problems = [] + + +def walk(node, path, source): + if isinstance(node, dict): + for key, value in node.items(): + if key in PATTERN_KEYS and isinstance(value, list): + for index, pattern in enumerate(value): + if not isinstance(pattern, str): + continue + # A trailing "i" flag is allowed: /pattern/i + delimited = pattern.startswith("/") and pattern.rstrip("i").endswith("/") + if REGEXY.search(pattern) and not delimited: + problems.append((source, f"{path}.{key}[{index}]", pattern)) + walk(value, f"{path}.{key}", source) + elif isinstance(node, list): + for index, item in enumerate(node): + walk(item, f"{path}[{index}]", source) + + +files = [path for path in (pathlib.Path(a) for a in sys.argv[1:]) if path.is_file()] +if not files: + print("No Renovate config found to check.") + sys.exit(0) + +for config in files: + walk(json.loads(config.read_text()), "$", str(config)) + +if problems: + print("Renovate file patterns that look like a regex but are not wrapped in slashes.") + print("Renovate reads these as globs, so they match nothing and the rule never fires.\n") + for source, where, pattern in problems: + print(f"::error file={source}::{where}: {pattern!r} is read as a glob, not a regex") + print(f" {source} {where}") + print(f" found: {pattern!r}") + print(f" expect: '/{pattern}/'\n") + sys.exit(1) + +print(f"Checked {len(files)} Renovate config file(s): all file patterns are well formed.") diff --git a/.github/scripts/update-tool-checksums.sh b/.github/scripts/update-tool-checksums.sh new file mode 100755 index 0000000..9b4f15e --- /dev/null +++ b/.github/scripts/update-tool-checksums.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Recalculate and optionally apply the SHA-256 checksums of the pinned CI tools. +# +# Renovate bumps the version numbers but cannot compute a checksum, so without +# this the pinned hash keeps pointing at the previous release and every bump +# fails the build with "computed checksum did NOT match". The workflow in +# .github/workflows/update-checksums.yml runs this on Renovate's own pull +# requests and commits the result back onto the branch. +# +# The hash is not simply taken from whatever the download happened to return. +# Each project publishes its own checksum file next to the release; the +# download is verified against that first, and only a verified hash is written +# into the repository. +# +# Usage: +# .github/scripts/update-tool-checksums.sh # show, then ask +# .github/scripts/update-tool-checksums.sh --apply # write without asking +# + +set -euo pipefail + +readonly RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' + +Write-Log() { + local level=$1; shift + local color=$NC + case $level in + INFO) color=$BLUE ;; + SUCCESS) color=$GREEN ;; + WARN) color=$YELLOW ;; + ERROR) color=$RED ;; + esac + if [[ $level == ERROR ]]; then + echo -e "${color}[$level]${NC} $*" >&2 + else + echo -e "${color}[$level]${NC} $*" + fi +} + +Stop-Script() { + Write-Log ERROR "$1" + exit 1 +} + +Show-Usage() { + cat <<'EOF' +Usage: update-tool-checksums.sh [--apply] + +Options: + --apply Write the checksums without prompting + -h, --help Show this help +EOF +} + +APPLY=false +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) APPLY=true; shift ;; + -h|--help) Show-Usage; exit 0 ;; + *) Write-Log ERROR "Unknown argument: $1"; Show-Usage; exit 1 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +readonly REPO_ROOT +cd "$REPO_ROOT" + +readonly CONFIG_VALIDATION=".github/workflows/config-validation.yml" +readonly PR_CHECKS=".github/workflows/pr-checks.yml" + +# ── Reading and writing the pinned values ─────────────────────────────────── + +# Usage: Get-KeyValue -> value of `KEY: "value"` +Get-KeyValue() { + sed -n "s/^[[:space:]]*$2:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$1" | head -n1 +} + +# Usage: Set-KeyValue +Set-KeyValue() { + sed -i "s|^\([[:space:]]*$2:[[:space:]]*\"\)[^\"]*\"|\1$3\"|" "$1" +} + +# ── Fetching and verifying ────────────────────────────────────────────────── + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEMP_DIR"' EXIT + +# Usage: Get-VerifiedHash +# Downloads the artifact, checks it against the hash the project published, and +# echoes that hash. Refuses to return anything if the two disagree. +Get-VerifiedHash() { + local name=$1 url=$2 expected=$3 + local file="$TEMP_DIR/$name" + + [[ "$expected" =~ ^[a-f0-9]{64}$ ]] || Stop-Script "$name: no valid checksum published upstream (got: '$expected')" + + curl -sSL --fail-with-body --retry 5 --retry-delay 3 --retry-all-errors -o "$file" "$url" \ + || Stop-Script "$name: download failed ($url)" + + local actual + actual="$(sha256sum "$file" | awk '{print $1}')" + + if [[ "$actual" != "$expected" ]]; then + Stop-Script "$name: download does not match the published checksum. published=$expected downloaded=$actual" + fi + + echo "$actual" +} + +# Usage: Get-PublishedHash +# Pulls one line out of a checksums file and returns the hash on it. +Get-PublishedHash() { + curl -sSL --fail-with-body --retry 5 --retry-delay 3 --retry-all-errors "$1" \ + | grep -- "$2" | awk '{print $1}' | head -n1 +} + +# ── The tools ─────────────────────────────────────────────────────────────── + +ACTIONLINT_VERSION="$(Get-KeyValue "$CONFIG_VALIDATION" ACTIONLINT_VERSION)" +LYCHEE_VERSION="$(Get-KeyValue "$PR_CHECKS" LYCHEE_VERSION)" + +for pair in "actionlint:$ACTIONLINT_VERSION" "lychee:$LYCHEE_VERSION"; do + [[ -n "${pair#*:}" ]] || Stop-Script "Could not read the ${pair%%:*} version. Did the file layout change?" +done + +Write-Log INFO "Versions found in the repository:" +echo " actionlint: $ACTIONLINT_VERSION" +echo " lychee: $LYCHEE_VERSION" +echo + +Write-Log INFO "Downloading and verifying against the published checksums..." + +ACTIONLINT_SHA256="$(Get-VerifiedHash "actionlint.tar.gz" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + "$(Get-PublishedHash "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" "linux_amd64.tar.gz")")" +Write-Log SUCCESS "actionlint: $ACTIONLINT_SHA256" + +LYCHEE_SHA256="$(Get-VerifiedHash "lychee.tar.gz" \ + "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz" \ + "$(Get-PublishedHash "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz.sha256" "")")" +Write-Log SUCCESS "lychee: $LYCHEE_SHA256" + +echo +if [[ "$APPLY" != true ]]; then + read -rp "Write these checksums into the repository? [y/N] " response + if [[ ! "$response" =~ ^[Yy]$ ]]; then + Write-Log INFO "No changes made" + exit 0 + fi +fi + +Set-KeyValue "$CONFIG_VALIDATION" ACTIONLINT_SHA256 "$ACTIONLINT_SHA256" +Set-KeyValue "$PR_CHECKS" LYCHEE_SHA256 "$LYCHEE_SHA256" + +Write-Log SUCCESS "Updated:" +echo " - $CONFIG_VALIDATION" +echo " - $PR_CHECKS" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 625dc37..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CodeQL - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - schedule: - - cron: '0 4 * * 1' # Every Monday at 04:00 UTC - -# Default minimal permissions — each job overrides only what it needs. -permissions: read-all - -# ============================================================ -# Overview of checks: -# -# analyze-actions → CodeQL static analysis on GitHub Actions workflows -# ============================================================ - -jobs: - - # ---------------------------------------------------------- - # GITHUB ACTIONS ANALYSIS - # Scans workflow YAML files for logic errors and misconfigurations. - # ---------------------------------------------------------- - analyze-actions: - name: Analyze GitHub Actions (CodeQL) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - languages: actions - build-mode: none - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - category: "/language:actions" diff --git a/.github/workflows/config-validation.yml b/.github/workflows/config-validation.yml new file mode 100644 index 0000000..88e08ed --- /dev/null +++ b/.github/workflows/config-validation.yml @@ -0,0 +1,126 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: Config validation + +# De bot-configs zijn het enige deel van CI dat verder nergens door wordt +# geraakt: een kapotte renovate.json of dependabot.yml laat geen build falen, +# die houdt gewoon stilletjes op met zijn werk. Deze workflow merkt dat op. + +on: + push: + branches: [main] + paths: + - 'renovate.json' + - '.github/renovate.json' + - '.github/dependabot.yml' + - '.github/dependabot.yaml' + - '.github/scripts/check-renovate-patterns.py' + # Broader than the other repos: the actionlint job below covers every + # workflow, so every workflow change is relevant here. + - '.github/workflows/**' + pull_request: + branches: [main] + paths: + - 'renovate.json' + - '.github/renovate.json' + - '.github/dependabot.yml' + - '.github/dependabot.yaml' + - '.github/scripts/check-renovate-patterns.py' + # Broader than the other repos: the actionlint job below covers every + # workflow, so every workflow change is relevant here. + - '.github/workflows/**' + workflow_dispatch: + +permissions: {} + +jobs: + bot-configs: + name: Renovate and Dependabot config + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 'lts/*' + + # Renovates eigen validator. --strict laat hem ook falen op warnings, + # bijvoorbeeld een optie die geldig maar verouderd is. Zonder argumenten + # zoekt hij de configbestanden zelf op en valideert hij ze als + # repository-config; geef je een pad mee, dan valideert hij ze als + # global config, en dat is een andere en zwakkere set regels. + # + # Bewust niet vastgezet. Dit is een linter op onze eigen config en geen + # onderdeel van wat we uitleveren, en juist de nieuwste release kent de + # nieuwste deprecations. Zijn eigen versie is geen pull request waard. + # + # NPM_CONFIG_LOGLEVEL: npm print "npm warn deprecated ..." voor packages + # diep in Renovates eigen dependency-boom. Die zeggen niets over de config + # die gevalideerd wordt, en ze lezen alsof dat wel zo is, is de fout die + # je vanzelf maakt als ze vlak boven de output van de validator staan. + - name: Validate Renovate config + env: + NPM_CONFIG_LOGLEVEL: error + run: npx --yes --package renovate -- renovate-config-validator --strict + + # De validator hierboven accepteert een correct gevormd patroon dat + # nergens op matcht; dit dekt het gat dat hij daarmee laat. + - name: Check Renovate file patterns + run: python3 .github/scripts/check-renovate-patterns.py renovate.json .github/renovate.json + + # GitHub valideert dependabot.yml pas als die op de default branch staat, + # en meldt het resultaat op een tabblad dat niemand opent. Dit haalt dat + # naar voren, naar de pull request. + - name: Validate Dependabot config + env: + # renovate: datasource=pypi depName=check-jsonschema + CHECK_JSONSCHEMA_VERSION: "0.38.0" + run: | + config="" + for candidate in .github/dependabot.yml .github/dependabot.yaml; do + if [ -f "$candidate" ]; then + config="$candidate" + break + fi + done + if [ -z "$config" ]; then + echo "No dependabot.yml in this repository; nothing to validate." + exit 0 + fi + pipx install "check-jsonschema==${CHECK_JSONSCHEMA_VERSION}" + check-jsonschema --builtin-schema vendor.dependabot "$config" + + # De workflowbestanden zijn ook config. De andere repositories draaien + # actionlint vanuit hun quality-workflow; deze had geen equivalent, dus + # het hoort hier. + # + # Als stap en niet als eigen job: GitHub rekent per job en rondt naar + # boven af op een hele minuut. actionlint is in vijf seconden klaar en + # heeft dezelfde checkout nodig als de stappen hierboven, dus een eigen + # job kostte een volle minuut extra voor niets. + # + # Vanaf hier draait elke stap op !cancelled(), zodat één rode controle de + # andere niet verbergt. De job faalt alsnog zodra er iets fout is. + - name: Install actionlint + if: ${{ !cancelled() }} + env: + # renovate: datasource=github-releases depName=rhysd/actionlint + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + run: | + curl -sSL --fail-with-body -o actionlint.tar.gz \ + --retry 5 --retry-delay 3 --retry-all-errors \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz actionlint + sudo install -m 0755 actionlint /usr/local/bin/actionlint + + - name: Run actionlint + if: ${{ !cancelled() }} + run: actionlint -color diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 1438d8f..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Dependency Review - -on: - pull_request: - branches: ["main"] - -# Default minimal permissions — each job overrides only what it needs. -permissions: read-all - -# ---------------------------------------------------------- -# DEPENDENCY REVIEW -# Blocks merging if HIGH or CRITICAL vulnerabilities are -# introduced by a dependency change in a pull request. -# ---------------------------------------------------------- - -jobs: - dependency-review: - name: Dependency review - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Review dependencies - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - with: - fail-on-severity: high diff --git a/.github/workflows/deploy-bunny.yml b/.github/workflows/deploy-bunny.yml new file mode 100644 index 0000000..36c00f5 --- /dev/null +++ b/.github/workflows/deploy-bunny.yml @@ -0,0 +1,89 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: Deploy to Bunny.net + +on: + push: + branches: ["main"] + paths: + - 'src/**' + - '.github/workflows/deploy-bunny.yml' + workflow_dispatch: + +# Geen token nodig; jobs die dat wel zijn, vragen er expliciet om. +permissions: {} + +concurrency: + group: "deploy" + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build: + name: Build and deploy to Bunny Storage + runs-on: ubuntu-latest + permissions: + contents: read + env: + HUGO_VERSION: 0.165.0 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # go-version-file houdt de Go van de runner gelijk aan wat src/go.mod + # vraagt. Zonder dit installeert setup-go een oudere Go met + # GOTOOLCHAIN=local, en dan weigert `go` de hextra-module op te halen + # omdat go.mod een nieuwere Go eist dan er staat. + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: src/go.mod + + - name: Install Hugo + run: | + wget -O "${{ runner.temp }}/hugo.deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb" \ + && sudo dpkg -i "${{ runner.temp }}/hugo.deb" + + - name: Build with Hugo + env: + HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache + HUGO_ENVIRONMENT: production + TZ: Europe/Amsterdam + run: | + cd src && hugo \ + --gc \ + --minify \ + --baseURL "https://windeploy.thectic.nl/" + + - name: Upload to Bunny Storage + env: + AWS_ACCESS_KEY_ID: ${{ secrets.BUNNY_STORAGE_ZONE }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.BUNNY_ACCESS_KEY }} + AWS_DEFAULT_REGION: de + STORAGE_ZONE: ${{ secrets.BUNNY_STORAGE_ZONE }} + STORAGE_ENDPOINT: ${{ secrets.BUNNY_STORAGE_ENDPOINT }} + run: | + aws s3 sync src/public/ "s3://${STORAGE_ZONE}/" \ + --endpoint-url "${STORAGE_ENDPOINT}" \ + --delete \ + --no-progress + + - name: Wait for storage replication + run: sleep 15 + + - name: Purge Bunny Pull Zone cache + env: + PULL_ZONE_ID: ${{ secrets.BUNNY_PULL_ZONE_ID }} + API_KEY: ${{ secrets.BUNNY_API_KEY }} + run: | + curl -sS --fail-with-body -X POST \ + "https://api.bunny.net/pullzone/${PULL_ZONE_ID}/purgeCache" \ + -H "AccessKey: ${API_KEY}" \ + -H "Content-Type: application/json" diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml new file mode 100644 index 0000000..3083bd8 --- /dev/null +++ b/.github/workflows/powershell.yml @@ -0,0 +1,63 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: PSScriptAnalyzer + +# The deployment scripts are the core of this repository, so the PowerShell +# lint keeps its own workflow. Settings live in .github/PSScriptAnalyzerSettings.psd1. +# +# microsoft/psscriptanalyzer-action is not certified by GitHub and carries no +# version tags, so it is pinned to a commit SHA. + +on: + push: + branches: [main] + paths: + - '**/*.ps1' + - '**/*.psm1' + - '**/*.psd1' + - '.github/workflows/powershell.yml' + pull_request: + branches: [main] + paths: + - '**/*.ps1' + - '**/*.psm1' + - '**/*.psd1' + - '.github/workflows/powershell.yml' + schedule: + - cron: '43 19 * * 3' + workflow_dispatch: + +permissions: {} + +concurrency: + group: powershell-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + psscriptanalyzer: + name: PSScriptAnalyzer + runs-on: ubuntu-latest + permissions: + contents: read + # For the SARIF upload to the Security tab. + security-events: write + steps: + - name: Check out source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run PSScriptAnalyzer + uses: microsoft/psscriptanalyzer-action@6b2948b1944407914a58661c49941824d149734f # v1.1 + with: + path: .\ + recurse: true + settings: .github/PSScriptAnalyzerSettings.psd1 + output: results.sarif + + - name: Upload SARIF results file + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + if: ${{ !cancelled() }} + with: + sarif_file: results.sarif + category: psscriptanalyzer diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..0fa0f86 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,206 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: PR Checks + +on: + pull_request: + branches: [main] + paths: + - 'src/**' + - '*.md' + - '.github/workflows/pr-checks.yml' + +# Snel achter elkaar naar dezelfde pull request pushen startte evenveel volledige +# runs, en de eerste zijn dan al achterhaald. +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + # Alle controles op een pull request, in een job. + # + # GitHub rekent per job en rondt elke job naar boven af op een hele minuut. + # Losse jobs voor markdownlint, de Hugo-build en de linkcheck kostten drie + # gefactureerde minuten voor werk dat samen in een minuut klaar is, plus een + # artefact met upload en download om de gebouwde site naar de linkcheck te + # krijgen. In een job leest lychee gewoon de map die de build ernaast zet. + # + # Elke stap draait op !cancelled(), zodat een rode markdownlint de Hugo-build + # niet verbergt. De job faalt alsnog zodra er iets fout is. + # + # De job draagt `pull-requests: write` omdat de laatste stap de checklist in + # de omschrijving bijwerkt. Alle actions staan op een vastgezette SHA. + pr-checks: + name: PR checks + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + env: + HUGO_VERSION: 0.165.0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # fetch-depth: 0 voor Hugo's .GitInfo en .Lastmod. + fetch-depth: 0 + persist-credentials: false + + # ── 1. Markdown-opmaak ────────────────────────────────────────────────── + - name: Markdown lint + id: markdown + if: ${{ !cancelled() }} + uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 + with: + globs: | + src/content/**/*.md + *.md + + # ── 2. Elk Engels document heeft een Nederlandse tegenhanger ──────────── + - name: Check every .md has a matching .nl.md + id: bilingual + if: ${{ !cancelled() }} + run: | + missing="" + while IFS= read -r en; do + base="${en%.md}" + nl="${base}.nl.md" + if [ ! -f "$nl" ]; then + missing="$missing\n $en → $nl missing" + fi + done < <(find src/content -name '*.md' ! -name '*.nl.md') + if [ -n "$missing" ]; then + echo -e "::error::Missing Dutch translation(s):$missing" + exit 1 + fi + echo "All content has EN + NL versions." + + # ── 3. Hugo bouwt zonder fouten ───────────────────────────────────────── + # + # go-version-file houdt de Go van de runner gelijk aan wat src/go.mod + # vraagt; anders weigert `go` de hextra-module op te halen. + - name: Setup Go + if: ${{ !cancelled() }} + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: src/go.mod + + - name: Install Hugo + if: ${{ !cancelled() }} + run: | + wget -O "${{ runner.temp }}/hugo.deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb" \ + && sudo dpkg -i "${{ runner.temp }}/hugo.deb" + + - name: Build + id: hugo + if: ${{ !cancelled() }} + env: + HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache + HUGO_ENVIRONMENT: production + TZ: Europe/Amsterdam + run: cd src && hugo --gc --minify --baseURL "http://localhost/" + + # ── 4. Kapotte interne links ──────────────────────────────────────────── + # + # Met de hand geïnstalleerd in plaats van via lycheeverse/lychee-action, + # dat zijn binary met een kale `curl -sfLO` ophaalt: geen retry, en geen + # controle op wat er terugkomt. Vastgezette versie, geverifieerde + # checksum, retry. De checksum wordt op Renovate-PR's herberekend door + # .github/workflows/update-checksums.yml. + - name: Install lychee + if: ${{ !cancelled() }} + env: + # extractVersion: lychee tagt zijn releases als "lychee-v0.24.2" en + # niet als "v0.24.2", dus het standaardpatroon leest de versie er niet + # uit. + # renovate: datasource=github-releases depName=lycheeverse/lychee extractVersion=^lychee-v(?.+)$ + LYCHEE_VERSION: "0.24.2" + # Uit de lychee-x86_64-unknown-linux-gnu.tar.gz.sha256 van de release zelf + LYCHEE_SHA256: "1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a" + run: | + curl -sSL --fail-with-body -o lychee.tar.gz \ + --retry 5 --retry-delay 3 --retry-all-errors \ + "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz" + echo "${LYCHEE_SHA256} lychee.tar.gz" | sha256sum -c - + tar -xzf lychee.tar.gz lychee-x86_64-unknown-linux-gnu/lychee + sudo install -m 0755 lychee-x86_64-unknown-linux-gnu/lychee /usr/local/bin/lychee + lychee --version + + # lychee in offline modus: elke interne href en src moet uitkomen op een + # bestand dat de build daadwerkelijk heeft opgeleverd. + # + # --index-files: Hugo serveert elke pagina als /index.html, en + # zonder dit stopt lychee bij de map en zijn #fragments naar een andere + # pagina niet te controleren. + # + # De glob staat bewust tussen quotes: zonder quotes vult bash hem eerst + # in, en zonder globstar valt ** terug op een mapniveau. + - name: Check internal links + id: links + if: ${{ !cancelled() }} + run: | + lychee --offline --include-fragments --index-files index.html \ + --root-dir "${GITHUB_WORKSPACE}/src/public" "src/public/**/*.html" + + # ── 5. Checklist in de omschrijving bijwerken ─────────────────────────── + # + # Leest de uitkomst van de stappen hierboven in plaats van van losse jobs. + # Draait op !cancelled() en niet op success(), want juist bij een rode + # controle wil je de checklist bijgewerkt zien. + - name: Update PR checklist + if: ${{ !cancelled() }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RESULT_BILINGUAL: ${{ steps.bilingual.outcome }} + RESULT_HUGO: ${{ steps.hugo.outcome }} + RESULT_LINKS: ${{ steps.links.outcome }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + + let body = pr.body || ''; + if (!body.trim()) return; + + const setCheck = (keyword, passed) => { + body = body.replace( + new RegExp(`- \\[[ xX]\\] (.*${keyword}.*)`, 'i'), + `- [${passed ? 'x' : ' '}] $1` + ); + }; + + // Dezelfde typelijst als pr-title.yml en CONTRIBUTING.md. Een scope + // en een `!` voor een breaking change zijn toegestaan: feat(nav)!: ... + const TITLE_RE = + /^(feat|fix|content|docs|chore|refactor|style|revert)(\([^)]+\))?!?: .+/; + + setCheck('PR title follows', TITLE_RE.test(pr.title)); + setCheck('Both EN', process.env.RESULT_BILINGUAL === 'success'); + setCheck('No broken', process.env.RESULT_LINKS === 'success'); + setCheck('Tested locally', process.env.RESULT_HUGO === 'success'); + + // De niet-gekozen types weghalen, maar alleen als er al een gekozen + // is. Zonder die voorwaarde stript de eerste run alle regels weg + // voordat de auteur er een heeft aangevinkt. + const TYPE_LINE = /^- \[([ xX])\] `\w+` —[^\n]*\n?/gm; + const ticked = [...body.matchAll(TYPE_LINE)] + .some(m => m[1].toLowerCase() === 'x'); + if (ticked) { + body = body.replace(/^- \[ \] `\w+` —[^\n]*\n?/gm, ''); + } + + body = body.replace(/\n{3,}/g, '\n\n'); + + if (body !== pr.body) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + body, + }); + } diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..e7840e9 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,45 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: PR title + +# De titel van de pull request is wat er op main terechtkomt zodra je squasht, +# dus dat is de plek waar Conventional Commits gecontroleerd moet worden en niet +# op de losse commits in de branch. +# +# Renovate levert zijn eigen titels al in dit formaat aan; dat is de +# semanticCommits-instelling in renovate.json. Deze controle dekt de rest. + +on: + pull_request: + # edited hoort erbij: zonder dat blijft de check rood staan nadat iemand de + # titel heeft verbeterd, want een titelwijziging is geen nieuwe push. + types: [opened, edited, synchronize, reopened] + +# Snel achter elkaar de omschrijving aanpassen startte evenveel runs. Alleen +# de laatste zegt nog iets, dus de rest mag weg. +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + pr-title: + name: Conventional commit title + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + content + docs + chore + refactor + style + revert diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index eb38b86..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: Security scanning - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - workflow_dispatch: - -# Default minimal permissions — each job overrides only what it actually needs. -permissions: read-all - -# Cancel in-progress runs for the same branch/PR when a new push arrives. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# ============================================================ -# Overview of checks: -# -# powershell-lint → PSScriptAnalyzer (all .ps1 scripts) -# trivy-scan → Trivy (filesystem vulnerability scan) -# devskim → DevSkim (insecure code patterns in PowerShell) -# semgrep → Semgrep (OWASP Top 10, secrets detection) -# actionlint → actionlint (validates GitHub Actions workflow YAML) -# ============================================================ - -jobs: - - # ---------------------------------------------------------- - # POWERSHELL LINT - # Runs PSScriptAnalyzer on all PowerShell scripts. - # Settings: .github/PSScriptAnalyzerSettings.psd1 - # ---------------------------------------------------------- - powershell-lint: - name: PowerShell lint (PSScriptAnalyzer) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run PSScriptAnalyzer (SARIF output) - uses: microsoft/psscriptanalyzer-action@6b2948b1944407914a58661c49941824d149734f # v1.1 - with: - path: ./ - recurse: true - settings: .github/PSScriptAnalyzerSettings.psd1 - output: psscriptanalyzer-results.sarif - - - name: Upload PSScriptAnalyzer results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - sarif_file: psscriptanalyzer-results.sarif - category: psscriptanalyzer - - # ---------------------------------------------------------- - # TRIVY SECURITY SCAN - # Scans the repository for HIGH/CRITICAL vulnerabilities. - # ---------------------------------------------------------- - trivy-scan: - name: Security scan (Trivy) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - scan-type: 'fs' - scan-ref: '.' - severity: 'HIGH,CRITICAL' - format: 'sarif' - output: 'trivy-results.sarif' - exit-code: '1' - - - name: Upload Trivy results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - if: always() - with: - sarif_file: 'trivy-results.sarif' - category: 'trivy' - - # ---------------------------------------------------------- - # DEVSKIM SECURITY LINT - # Catches insecure patterns in PowerShell: deprecated crypto, - # injection risks, unsafe APIs. - # ---------------------------------------------------------- - devskim: - name: Security lint (DevSkim) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run DevSkim - uses: microsoft/DevSkim-Action@4b5047945a44163b94642a1cecc0d93a3f428cc6 # v1.0.16 - - - name: Upload DevSkim results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - if: always() - with: - sarif_file: devskim-results.sarif - category: devskim - - # ---------------------------------------------------------- - # SEMGREP - # Detects hardcoded secrets and OWASP Top 10 patterns. - # Never blocks CI — findings go to Security tab only. - # ---------------------------------------------------------- - semgrep: - name: Semgrep (OWASP / secrets) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Semgrep - run: pip install semgrep - - - name: Run Semgrep - run: | - semgrep \ - --config auto \ - --config p/owasp-top-ten \ - --config p/secrets \ - --sarif \ - --output semgrep.sarif \ - Scripts/ || true - - - name: Upload Semgrep results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - if: always() - with: - sarif_file: semgrep.sarif - category: semgrep - - # ---------------------------------------------------------- - # ACTIONLINT - # Validates all GitHub Actions workflow YAML files. - # Fails CI so broken workflows are caught before merge. - # ---------------------------------------------------------- - actionlint: - name: GitHub Actions lint (actionlint) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run actionlint - uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 - with: - args: -color diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index ad51256..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Stale - -# Automatically marks inactive issues and pull requests as stale -# and closes them if there is no response. -# -# Issues: stale after 30 days of inactivity, closed after 14 more days -# PRs: stale after 30 days of inactivity, closed after 14 more days - -on: - schedule: - - cron: "0 2 * * *" # Daily at 02:00 UTC - workflow_dispatch: - -permissions: - issues: write - pull-requests: write - -jobs: - stale: - name: Mark and close stale issues and PRs - runs-on: ubuntu-latest - steps: - - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - with: - # ── Issues ────────────────────────────────────────────────── - days-before-issue-stale: 30 - days-before-issue-close: 14 - stale-issue-label: "stale" - stale-issue-message: > - This issue has not been updated in 30 days and has been marked as **stale**. - If there is no activity within 14 days it will be closed automatically. - Reply or remove the `stale` label to keep it open. - close-issue-message: > - This issue has been automatically closed due to inactivity. - Feel free to reopen it if it is still relevant. - - # ── Pull Requests ──────────────────────────────────────────── - days-before-pr-stale: 30 - days-before-pr-close: 14 - stale-pr-label: "stale" - stale-pr-message: > - This pull request has not been updated in 30 days and has been marked as **stale**. - If there is no activity within 14 days it will be closed automatically. - Reply or remove the `stale` label to keep it open. - close-pr-message: > - This pull request has been automatically closed due to inactivity. - Feel free to reopen it if the work is still relevant. - - # ── Exemptions ─────────────────────────────────────────────── - exempt-issue-labels: "pinned,security,breaking-change" - exempt-pr-labels: "pinned,security,breaking-change" - - operations-per-run: 30 diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 0000000..6422c97 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,32 @@ +name: "Trivy filesystem scan" + +on: + schedule: + - cron: '0 2 * * 0' + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + trivy-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + severity: CRITICAL,HIGH + format: sarif + output: trivy-results.sarif + + - name: Upload Trivy results to GitHub Security tab + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + if: always() + with: + sarif_file: trivy-results.sarif diff --git a/.github/workflows/update-checksums.yml b/.github/workflows/update-checksums.yml new file mode 100644 index 0000000..3017d10 --- /dev/null +++ b/.github/workflows/update-checksums.yml @@ -0,0 +1,85 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: Update tool SHA256 checksums + +# Renovate bumpt de vastgezette toolversies maar kan geen checksum berekenen, +# dus op eigen kracht landt elke bump met de hash van de vorige release er nog +# in, en stopt de build op "computed checksum did NOT match". Dit herberekent +# de hashes op Renovates pull requests en commit ze terug op de branch. +# +# Renovate moet die commits leren negeren, anders ziet hij de branch als door +# iemand anders gewijzigd en onderhoudt hij de pull request niet meer. Dat is +# de gitIgnoredAuthors-regel in renovate.json. + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + paths: + - '.github/workflows/config-validation.yml' + - '.github/workflows/pr-checks.yml' + - '.github/scripts/update-tool-checksums.sh' + +permissions: {} + +jobs: + update-checksums: + name: Recalculate SHA256 checksums + runs-on: ubuntu-latest + # Alleen Renovates eigen branches. Dit op een pull request van een mens + # draaien betekent commits pushen naar een branch waar iemand op dat moment + # aan werkt. + # + # De auteur van de pull request, niet github.actor. actor is degene die het + # meest recente event veroorzaakte, en dat is bij een synchronize degene die + # als laatste pushte; dat vergelijken met een botnaam is een controle die + # zizmor terecht spoofbaar noemt. De auteur ligt vast zodra de pull request + # geopend wordt en is niet naar een ander account te zetten. + # + # Also require the head repo to be this repo, not a fork. This job checks + # out github.head_ref and runs a script from it with a write token, so a + # PR from a fork naming its branch renovate/* would otherwise get its + # attacker-controlled script executed with push access. + if: >- + startsWith(github.head_ref, 'renovate/') && + github.event.pull_request.user.login == 'renovate[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + steps: + # persist-credentials: false, ook al pusht deze job. Anders staat het + # token de hele job in .git/config, ook terwijl het script hieronder + # release-tarballs van internet haalt. De push-stap krijgt het token in + # plaats daarvan expliciet mee, voor precies één commando. + # Pinned to the exact commit the pull_request event fired for, not the + # mutable branch name. head_ref is a moving target: a push to the + # renovate/* branch between the job's "if:" check above and this step + # would check out commits that check never evaluated. head.sha is fixed + # in the event payload, so it can't move underneath the job. + - name: Check out the pull request branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + # Het script controleert elke download tegen de checksum die het project + # naast de release publiceert voordat er iets wordt weggeschreven, dus een + # hash landt hier alleen als upstream er ook voor instaat. + - name: Recalculate and apply checksums + run: .github/scripts/update-tool-checksums.sh --apply + + - name: Commit updated checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ github.head_ref }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/workflows/config-validation.yml .github/workflows/pr-checks.yml + if git diff --staged --quiet; then + echo "Checksums are already up to date, nothing to commit." + else + git commit -m "chore: update tool SHA256 checksums" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" + fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ea4dbd2..430d1e5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,34 +1,46 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT name: Validate scripts +# A full deployment cannot run on GitHub-hosted runners (it needs Windows 11 +# 25H2 and real hardware for drivers/BitLocker), but syntax errors, missing +# functions and broken helper logic are caught here before they reach a +# production machine. Windows-latest, not ubuntu: the syntax check wants +# Windows PowerShell/PS7 parsing semantics, and the hostname/architecture +# checks read real Win32_BIOS/PROCESSOR_ARCHITECTURE values. +# +# One job rather than two: GitHub bills per job, rounded up to a whole minute, +# and both halves need the same windows-latest checkout. + on: push: - branches: ["**"] + branches: [main] + paths: + - 'Scripts/**' + - '.github/workflows/validate.yml' pull_request: - branches: ["**"] + branches: [main] + paths: + - 'Scripts/**' + - '.github/workflows/validate.yml' workflow_dispatch: -permissions: read-all +permissions: {} -# ============================================================ -# Validates that all PowerShell scripts parse and load without -# errors. A full deployment cannot run on GitHub-hosted runners -# (requires Windows 11 25H2 + real hardware for drivers/BitLocker), -# but syntax and logic errors are caught here before they reach -# a production machine. -# ============================================================ +concurrency: + group: validate-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - - # ---------------------------------------------------------- - # SYNTAX CHECK - # Parses every .ps1 file with PowerShell 7 without executing. - # Fails if any file contains a syntax error. - # ---------------------------------------------------------- - syntax-check: - name: PowerShell syntax check + validate: + name: Syntax and helper function checks runs-on: windows-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Check PowerShell version shell: pwsh @@ -64,17 +76,6 @@ jobs: Write-Host "All scripts passed syntax check." -ForegroundColor Green - # ---------------------------------------------------------- - # HELPER FUNCTION TESTS - # Dot-sources helper functions from Deploy.ps1 and Start.ps1 - # and exercises non-destructive logic in a dry-run context. - # ---------------------------------------------------------- - helper-tests: - name: PowerShell helper function tests - runs-on: windows-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Test Deploy.ps1 helper functions shell: pwsh run: | diff --git a/.gitignore b/.gitignore index f7c709a..2f79f19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Hugo +/src/public/ +/src/resources/ +/src/_vendor/ +src/.hugo_build.lock + # PowerShell *.ps1xml diff --git a/.lychee.toml b/.lychee.toml new file mode 100644 index 0000000..3cf93a0 --- /dev/null +++ b/.lychee.toml @@ -0,0 +1,10 @@ +# Lychee link checker configuration +# Used by the PR checks workflow (offline mode — internal links only) + +# Skip anchors that Hugo generates dynamically +include_fragments = true + +# Don't fail on these — they're valid Hugo internal paths +exclude = [ + "^http://localhost", +] diff --git a/.markdownlint.yml b/.markdownlint.yml new file mode 100644 index 0000000..a4ab999 --- /dev/null +++ b/.markdownlint.yml @@ -0,0 +1,55 @@ +default: true + +# ── Disabled: not applicable to Hugo docs ────────────────────────────────── + +# Line length — docs have long tables, code examples, and URLs +MD013: false + +# Inline HTML — Hugo shortcodes ({{< callout >}}, {{% steps %}}) trigger this +MD033: false + +# First line must be H1 — files start with YAML front matter +MD041: false + +# Bare URLs — allowed in code blocks and examples +MD034: false + +# Multiple H1s — Hugo steps/details shortcodes contain headings +MD025: false + +# Blank lines around fences — common inside Hugo shortcode blocks +MD031: false + +# Blank lines around lists — pervasive in existing content +MD032: false + +# Multiple consecutive blank lines — style preference, not a bug +MD012: false + +# Blank line inside blockquote — Hugo callout/details shortcodes trigger this +MD028: false + +# Ordered list prefix style — 1/2/3 vs 1/1/1 is a style choice +MD029: false + +# Emphasis used instead of heading — intentional in existing docs +MD036: false + +# Code block language — desirable but too many pre-existing violations +MD040: false + +# Headings surrounded by blank lines — pre-existing violations in some docs +MD022: false + +# Heading levels increment by one — Hugo docs intentionally start at ### because +# h1/h2 are rendered by the theme template, not the content file +MD001: false + +# Duplicate headings — only flag duplicates within the same section, not across +# different top-level sections (e.g. two separate "### Install" blocks are fine) +MD024: + siblings_only: true + +# Table column style (pipe spacing/alignment) — overly pedantic, tables render +# correctly regardless of exact pipe spacing +MD060: false diff --git a/CHANGELOG.md b/CHANGELOG.md index e69199f..c450442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.9.0] - 2026-09-02 + +Repository moved from `Stensel8/WinDeploy` to `THectic-NL/WinDeploy`. + +### Added +- `src/`. A Hugo landing page for windeploy.thectic.nl (English + Dutch), matching the site structure already in use for [BypassNRO](https://github.com/THectic-NL/BypassNRO). It documents the project; it does not host the deployment scripts. `Scripts/` and `Docs/` are unchanged in location and behaviour, and GitHub Releases stays the distribution mechanism, so a run started against one release tag keeps using that tag's scripts throughout, even if `main` changes mid-run. +- `.github/workflows/deploy-bunny.yml`, `pr-checks.yml`, `pr-title.yml`, `config-validation.yml`, `trivy-scan.yml`, `update-checksums.yml`, `.github/scripts/`. CI for the new site, matching the shared org convention. +- `renovate.json`: `gomod` and `custom.regex` managers, for the site's Go module and the hand-pinned tool versions in the new workflows. + +### Changed +- All `Stensel8/WinDeploy` references (README, SECURITY.md, `Docs/autounattend.xml`, `Scripts/Start.ps1`, `Scripts/Deploy.ps1`, old changelog release links) now point at `THectic-NL/WinDeploy`. +- `.github/workflows/validate.yml`: the syntax-check and helper-function-test jobs merged into one job (same runner, one less billed minute), path-scoped to `Scripts/**`, otherwise unchanged. +- `CONTRIBUTING.md`, `.github/pull_request_template.md`: extended with the site's bilingual-content and Hugo-build checks, alongside the existing Windows-testing requirement, which stays required for anything under `Scripts/` or `Docs/`. + +### Removed +- `.github/workflows/codeql.yml`, `dependency-review.yml`, `security.yml`, `stale.yml`, in favour of the leaner CI set above (PSScriptAnalyzer + Trivy + actionlint, no DevSkim/Semgrep/CodeQL-for-Actions/dependency-review/stale-bot). This mirrors what already happened to BypassNRO; flagging it here since it is a real reduction in automated security-scanning coverage, not just a rename. + +### Notes +- `windeploy.stensel.nl` (the "Option 3" one-liner in the README) is an external redirect that pointed at the old repository. It is not part of this repository and needs to be repointed or retired separately. + +--- + +## [0.8.0] - 2026-08-29 + +### Added +- `Scripts/Deployment/Apply-Tweaks.ps1`. Optional step that applies a [WinUtil](https://github.com/ChrisTitusTech/winutil) preset (`Standard` by default) after a Y/N prompt, listing what the preset changes before you answer. Runs in its own process so a failure there cannot take down the deployment. +- BitLocker now creates a recovery password protector, saves it to the operator's Documents folder and prints it on screen. Previously only a TPM protector was created, leaving the drive unrecoverable after a TPM clear, mainboard swap or firmware change — while the script told the operator to export a recovery key that never existed. +- BitLocker is opt-in via a Y/N prompt (`-BitLocker Ask|Yes|No`). All other hardening still applies unconditionally. +- `-NonInteractive` switch on `Deploy.ps1` and `Start.ps1`, forwarded from `autounattend.xml`, so the USB path stays zero-touch. +- Hardening extended with LSA protection (RunAsPPL), WDigest plaintext caching disabled, anonymous SAM/share enumeration restricted, SMB client and server signing required, insecure SMB guest logons blocked, LLMNR disabled, memory integrity (HVCI) enabled, SMBv1 feature removed, and 9 Defender Attack Surface Reduction rules. +- `Remove-Bloat.ps1` now implements the "prevents reinstall" its header promised, via `DisableWindowsConsumerFeatures` and related CloudContent/Store policies. + +### Fixed +- `Docs/autounattend.xml` never launched WinDeploy. The first-logon script was generated as `unattend-02.cmd` but contained PowerShell, which `cmd.exe` cannot run. It is now a `.ps1`, and the generator URL in the header comment was corrected to `FirstLogonScriptType1=Ps1` so regenerating reproduces the fix. +- `Harden-Windows.ps1` set `SMB2 = 0` under `LanmanServer\Parameters`, which disables SMB2 and SMB3 and breaks file and printer sharing. Microsoft advises against it. Removed and replaced with SMB signing and guest-logon hardening. +- `Test-IntuneEnrollment` crashed under `Set-StrictMode` when the `Enrollments` key was absent: `Get-ChildItem -ErrorAction SilentlyContinue` returns `$null`, and `$null.Count` throws. +- `Deploy.ps1` crashed under `Set-StrictMode` on the first step, because `$LASTEXITCODE` is undefined until something sets it. It also never reset between steps, so one failing step marked every later step as failed. Now reset to `0` before each step. +- Screen lock settings were written to `HKCU`, which during deployment belongs to the deployment account rather than the end user. Now written to the machine-wide policy hive. `SCRNSAVE.EXE` was also empty, so Windows never started a screen saver and the secure lock never triggered; it now points at `scrnsave.scr`. +- `winget install` was missing `--silent`, so applications could show installer UI mid-deployment. It now also passes `--exact` and `--disable-interactivity`. +- The Office ODT configuration used ``, which installs interactively. Now `None`. +- Windows Updates without a KB number (drivers, definitions) were skipped, because `Install-WindowsUpdate -KB $update.KB` cannot install them. Replaced with a single `Get-WindowsUpdate -Install` pass, which is also considerably faster. +- Seven WinGet font error codes were typed as `-1979335xxx` instead of `-1978335xxx`, so they could never match a real exit code. +- `Install-Drivers.ps1` matched HP with `-like "*hp*"`, which also matches manufacturers such as "Sharp". Now matched as a whole token. +- `Install-Drivers.ps1` installed `HPCMSL` without bootstrapping the NuGet provider or trusting PSGallery, so it prompted and stalled, or failed outright. It now does the same bootstrap `Install-WindowsUpdates.ps1` already did. +- `Install-WindowsUpdates.ps1` threw under `Set-StrictMode` if `wuauserv` could not be found, instead of reporting it. +- `Remove-Bloat.ps1` logged to `%TEMP%\WinDeploy\Logs` while every other script and the README use `C:\WinDeploy\Logs`. +- `Remove-Bloat.ps1` used the `` `e `` escape (PowerShell 6+) in a script that declares `#requires -Version 5.1`, where it prints as literal text. +- The RMM step no longer wraps the installer in a background job that `Remove-Job -Force` could kill. `Install-RMMAgent.ps1` already launches the agent detached, so it runs inline like every other step. +- "Press Enter to exit" prompts now time out after 120 seconds instead of blocking an unattended deployment. + +### Changed +- Deployment scripts log failures with `Write-Warning` instead of `Write-Error`, which printed a full error record with category and stack trace for every non-fatal skip. `Deploy.ps1` already did this. +- `Remove-Bloat.ps1` bloatware list extended with Windows 11 24H2/25H2 in-box apps: Dev Home, the new Outlook, Edge Game Assist, Cross Device (Phone Link), Start Experiences, Meet Now and the Copilot AI provider. + +--- + ## [0.7.3] - 2026-05-01 ### Fixed @@ -128,7 +184,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Bitlocker enablement issue: Sometimes Bitlocker failed to enable due to the TPM not being ready. -- Fixed an issue with RMM Agents not installing correctly. [#11](https://github.com/Stensel8/WinDeploy/issues/11) +- Fixed an issue with RMM Agents not installing correctly. [#11](https://github.com/THectic-NL/WinDeploy/issues/11) - Fixed a rare hang where deployment would stall after detecting the RMM installer on USB. The installer was being invoked via PowerShell incorrectly which could prevent it from receiving silent switches; changed to run the installer directly and wait for completion, added longer timeouts and improved logging. ## [0.5.6] - 2025-11-20 @@ -197,10 +253,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.5.0] - 2025-11-14 -### Fixed -- Fixed bloatware removal printing duplicate messages on screen by removing redundant Write-Output calls -- Improved admin elevation handling in Start.ps1 and Deploy.ps1 to prevent script crashes when not run as administrator - ### Added - Added documentation for Intune Autopilot device preparation setup (`Docs/Intune-Autopilot-Setup.md`) - Added RMM agent installation support with USB detection and download fallback @@ -216,6 +268,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed unused utility modules and scripts ### Fixed +- Fixed bloatware removal printing duplicate messages on screen by removing redundant Write-Output calls +- Improved admin elevation handling in Start.ps1 and Deploy.ps1 to prevent script crashes when not run as administrator - Improved error handling and logging across all scripts - Enhanced compatibility and reliability of deployment process @@ -250,16 +304,18 @@ First open-source release of WinDeploy - Windows Deployment Automation Toolkit. --- -[0.7.3]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.3 -[0.7.2]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.2 -[0.7.1]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.1 -[0.7.0]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.0 -[0.6.1]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.6.1 -[0.6.0]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.6.0 -[0.5.5]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.5 -[0.5.4]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.4 -[0.5.3]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.3 -[0.5.2]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.2 -[0.5.0]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.0 -[0.1.2]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.1.2 -[0.1.1]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.1.1 +[0.9.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.9.0 +[0.8.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.8.0 +[0.7.3]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.3 +[0.7.2]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.2 +[0.7.1]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.1 +[0.7.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.0 +[0.6.1]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.6.1 +[0.6.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.6.0 +[0.5.5]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.5 +[0.5.4]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.4 +[0.5.3]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.3 +[0.5.2]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.2 +[0.5.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.0 +[0.1.2]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.1.2 +[0.1.1]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.1.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39c93db..51a27f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,27 +2,73 @@ Thanks. Small, tested PRs help most. -### Quick start -1. Fork → branch → change → PR. -2. Test on Windows 11. - -### Guidelines -- Commit: `: short summary` (feat, fix, docs, style, refactor, test, chore). -- Follow PowerShell conventions (PascalCase funcs, camelCase vars, verb-noun, comment-based help). -- Avoid hard-coded paths; use config/variables. - -### Testing -- Test on a clean Windows 11 machine. -- Run PSScriptAnalyzer: Install-Module PSScriptAnalyzer; Invoke-ScriptAnalyzer -Path .\ -Recurse. -- No syntax errors; logs show expected behavior. - -### Pull requests -- Update docs if needed, include test notes and related issue references. -- Checklist: tested, linter passed, clear description/screenshots. - -### Issues -Include: short description, reproduction steps, expected vs actual, logs and env (Windows/PowerShell versions). - -### Allowed / Not allowed -Welcome: bug fixes, docs, tests, small features. -Not accepted: malware, proprietary deps, breaking changes without prior discussion. +## Commit messages + +This project uses [Conventional Commits](https://www.conventionalcommits.org/). + +**Format:** +``` +: +``` + +**Types:** + +| Type | When to use | +|------|-------------| +| `feat` | New feature or script | +| `fix` | Bug fix | +| `content` | Update or improve the site's landing page content | +| `docs` | Changes to README, CONTRIBUTING, or other meta files | +| `chore` | Maintenance — dependencies, config, CI/CD | +| `refactor` | Restructure without changing behaviour | +| `style` | Formatting, whitespace, typo fixes | +| `revert` | Reverting a previous commit | + +**Rules:** +- Use lowercase for the type and description +- Keep the subject line under 72 characters +- No period at the end +- Use the imperative mood ("add", "fix", "update" — not "added", "fixed") + +## PowerShell conventions + +- PascalCase functions, camelCase variables, `Verb-Noun` naming, comment-based help +- Avoid hard-coded paths; use config/variables +- Run PSScriptAnalyzer before opening a PR: `Install-Module PSScriptAnalyzer; Invoke-ScriptAnalyzer -Path .\ -Recurse -Settings .github/PSScriptAnalyzerSettings.psd1` + +## Testing + +- **Test on a real Windows 11 25H2 machine.** Nothing here runs in CI — GitHub-hosted runners can't exercise BitLocker, TPM, Sysprep, WinGet installs or Defender ASR — so this is the only way a change is actually verified. +- No syntax errors; logs in `C:\WinDeploy\Logs\` show the expected behaviour. + +## Pull requests + +- PR titles follow the commit convention above +- One logical change per PR +- If you touch `src/content/`, update both EN (`*.md`) and NL (`*.nl.md`) versions +- Test the site locally with `cd src && hugo server` before opening a PR that touches `src/` + +## Project layout + +``` +Scripts/ Deployment scripts (Start.ps1, Deploy.ps1, Scripts/Deployment/*) +Docs/ autounattend.xml and Intune setup docs +src/ The Hugo site (windeploy.thectic.nl) — landing page only. + Scripts/ and Docs/ are NOT duplicated here; the site + links to GitHub Releases, which stays the source of + truth for what actually gets deployed. + src/content/ Landing page content (_index.md EN, _index.nl.md NL) + src/layouts/ Template overrides on top of the Hextra theme + src/hugo.toml Site configuration +``` + +The site is built and deployed to Bunny.net from `.github/workflows/deploy-bunny.yml` on every push to `main` that touches `src/`. It is bilingual (EN + NL); keep structure and headings in sync between the two versions of a page. + +## Issues + +Include: short description, reproduction steps, expected vs actual, logs and environment (Windows/PowerShell versions). + +## Allowed / not allowed + +Welcome: bug fixes, docs, tests, small features. +Not accepted: malware, proprietary dependencies, breaking changes without prior discussion. diff --git a/Docs/autounattend.xml b/Docs/autounattend.xml index 68b7f9a..0f0a21c 100644 --- a/Docs/autounattend.xml +++ b/Docs/autounattend.xml @@ -1,6 +1,6 @@ - + @@ -567,8 +567,8 @@ Set-WallpaperImage -LiteralPath 'C:\Windows\Setup\Scripts\Wallpaper'; Install-Script winget-install -Force winget-install -Force - -iex (irm "https://raw.githubusercontent.com/Stensel8/WinDeploy/$((irm https://api.github.com/repos/Stensel8/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") + +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1"))) -NonInteractive $scripts = @( @@ -814,7 +814,7 @@ $scripts = @( & 'C:\Windows\Setup\Scripts\unattend-01.ps1'; }; { - C:\Windows\Setup\Scripts\unattend-02.cmd; + & 'C:\Windows\Setup\Scripts\unattend-02.ps1'; }; { Remove-Item -LiteralPath @( diff --git a/README.md b/README.md index e1649df..b73ca37 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ [![PowerShell 7.0+](https://img.shields.io/badge/PowerShell-7.0+-blue.svg)](https://github.com/PowerShell/PowerShell) [![Windows 11 25H2](https://img.shields.io/badge/Windows-11_25H2-0078D6.svg)](https://www.microsoft.com/windows) -[![Security scanning](https://github.com/Stensel8/WinDeploy/actions/workflows/security.yml/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/security.yml) -[![Validate scripts](https://github.com/Stensel8/WinDeploy/actions/workflows/validate.yml/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/validate.yml) -[![CodeQL](https://github.com/Stensel8/WinDeploy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/codeql.yml) -[![Dependabot Updates](https://github.com/Stensel8/WinDeploy/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/dependabot/dependabot-updates) +[![Security scanning](https://github.com/THectic-NL/WinDeploy/actions/workflows/security.yml/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/security.yml) +[![Validate scripts](https://github.com/THectic-NL/WinDeploy/actions/workflows/validate.yml/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/validate.yml) +[![CodeQL](https://github.com/THectic-NL/WinDeploy/actions/workflows/codeql.yml/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/codeql.yml) +[![Dependabot Updates](https://github.com/THectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates) Zero-touch Windows deployment with automatic driver updates, application installation, bloatware removal, and system configuration. Deploy via USB, network, RMM agents, or AutoUnattend.xml. @@ -47,10 +47,14 @@ Zero-touch Windows deployment with automatic driver updates, application install ### Option 2: Direct Execution ```powershell # Run as Administrator in PowerShell 7 -iex (irm "https://raw.githubusercontent.com/Stensel8/WinDeploy/$((irm https://api.github.com/repos/Stensel8/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +iex (irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") ``` ### Option 3: One-liner + +> [!NOTE] +> `windeploy.stensel.nl` pointed at Option 2 before this project moved to THectic-NL. That redirect needs to be repointed (or retired) separately — it isn't part of this repository. + ```powershell # Run as Administrator in PowerShell 7 iex (irm windeploy.stensel.nl) @@ -74,9 +78,17 @@ graph TD H --> I[Install RMM Agent] I --> J[Update Drivers] J --> K[Windows Hardening] - K --> L[Install Applications] + K --> K2{Enable BitLocker?} + K2 -->|Y| K3[Encrypt C: + save recovery key] + K2 -->|N / timeout| L + K3 --> L + L[Install Applications] L --> M[Remove Bloatware] - M --> N[Apply Theme] + M --> M2{Run WinUtil tweaks?} + M2 -->|Y| M3[Apply WinUtil preset] + M2 -->|N / timeout| N + M3 --> N + N[Apply Theme] N --> O[Set Hostname] O --> P[Install Windows Updates] P --> Q[Complete] @@ -84,6 +96,17 @@ graph TD `Start.ps1` ensures PowerShell 7 and WinGet are available, handles elevation, and downloads `Deploy.ps1`. `Deploy.ps1` orchestrates the deployment by downloading and executing each script in sequence. +### Interactive steps + +BitLocker (in `Harden-Windows.ps1`) and the WinUtil tweaks (`Apply-Tweaks.ps1`) each ask Y/N before running. Both time out after 90 seconds and default to **No**, so an unattended run never stalls. Everything else is applied automatically. + +```powershell +.\Deploy.ps1 -NonInteractive # no prompts, both skipped +.\Deploy.ps1 -BitLocker Yes -Tweaks Yes # no prompts, both applied +``` + +The `autounattend.xml` USB deployment passes `-NonInteractive` automatically. + --- ## Configuration @@ -118,11 +141,53 @@ Place your agent installer as `Agent.exe` (or any `*agent*.exe`) on the USB driv --- +## Security hardening + +`Harden-Windows.ps1` applies these automatically: + +| Area | Setting | +|---|---| +| Removable media | AutoRun disabled, `autorun.inf` blocked | +| SMB | SMBv1 feature removed, client + server signing required, insecure guest logons blocked | +| Credentials | LSA protection (RunAsPPL), WDigest plaintext caching off, anonymous SAM/share enumeration restricted | +| Network | LLMNR disabled | +| Code integrity | Memory integrity (HVCI) enabled | +| Defender | 9 Attack Surface Reduction rules enabled | +| Other | Device co-installers disabled, Windows Script Host disabled | +| Screen lock | Secure screen saver after 15 minutes, console lock on resume | + +Memory integrity, LSA protection and SMB signing require a restart. Windows Script Host is disabled; a few legacy MSI installers use VBScript custom actions and can fail because of it. + +### BitLocker + +Opt-in, asks Y/N. On yes: `C:` is encrypted with XTS-AES-256 (used space only, TPM-bound), a recovery password is created, written to your Documents folder and printed on screen. + +**Store that key elsewhere and delete the file.** Without it the drive cannot be recovered after a TPM clear, mainboard swap or firmware change. + +```powershell +.\Harden-Windows.ps1 -BitLocker Yes # encrypt without prompting +.\Harden-Windows.ps1 -BitLocker No # skip BitLocker, apply the rest +``` + +--- + +## Optional tweaks (WinUtil) + +`Apply-Tweaks.ps1` runs a [WinUtil](https://github.com/ChrisTitusTech/winutil) preset after a Y/N prompt, in its own process. Standard creates a restore point, then disables activity history, location, telemetry, consumer features, Delivery Optimization and Explorer folder-type auto-discovery, sets non-essential services to manual, and cleans temp files. + +```powershell +.\Apply-Tweaks.ps1 -Tweaks Yes # Standard preset +.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Minimal +.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Advanced # also removes OneDrive, widgets, Windows AI +``` + +--- + ## Logging All operations are logged to `C:\WinDeploy\Logs\`: - `Start.log`. Main entry point log. -- `Install-Drivers.log`, `Install-Applications.log`, etc. Per-script logs. +- `Install-Drivers.log`, `Install-Applications.log`, `Harden-Windows.log`, `Apply-Tweaks.log`, etc. Per-script logs. View logs in real-time: ```powershell @@ -196,8 +261,8 @@ winget-install Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). -- **Issues**: [GitHub Issues](https://github.com/Stensel8/WinDeploy/issues) -- **Discussions**: [GitHub Discussions](https://github.com/Stensel8/WinDeploy/discussions) +- **Issues**: [GitHub Issues](https://github.com/THectic-NL/WinDeploy/issues) +- **Discussions**: [GitHub Discussions](https://github.com/THectic-NL/WinDeploy/discussions) ## Disclaimer diff --git a/SECURITY.md b/SECURITY.md index 0bf7319..afaa342 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ **Do not open a public GitHub issue for security vulnerabilities.** -Report privately via [GitHub Security Advisories](https://github.com/Stensel8/WinDeploy/security/advisories/new). +Report privately via [GitHub Security Advisories](https://github.com/THectic-NL/WinDeploy/security/advisories/new). Include: - Description of the vulnerability diff --git a/Scripts/Archived/Get-InstalledSoftware.ps1 b/Scripts/Archived/Get-InstalledSoftware.ps1 index 982033a..55f57b2 100644 --- a/Scripts/Archived/Get-InstalledSoftware.ps1 +++ b/Scripts/Archived/Get-InstalledSoftware.ps1 @@ -6,7 +6,7 @@ .TAGS PowerShell Windows Software Inventory Registry AppX Reporting -.PROJECTURI https://github.com/Stensel8/WinDeploy +.PROJECTURI https://github.com/THectic-NL/WinDeploy #> @@ -30,7 +30,7 @@ Requires : PowerShell 5.1+ .LINK - Project Site: https://github.com/Stensel8/WinDeploy + Project Site: https://github.com/THectic-NL/WinDeploy #> Set-StrictMode -Version Latest diff --git a/Scripts/Deploy.ps1 b/Scripts/Deploy.ps1 index 5b0eb0b..91a3a3c 100644 --- a/Scripts/Deploy.ps1 +++ b/Scripts/Deploy.ps1 @@ -1,6 +1,52 @@ +[CmdletBinding()] +param( + # Skips every prompt (BitLocker, WinUtil tweaks, the final "press Enter"). + # Use this for fully unattended runs such as autounattend.xml deployments. + [switch]$NonInteractive, + + # Passed straight through to the steps that ask for confirmation. + [ValidateSet('Ask', 'Yes', 'No')] + [string]$BitLocker = 'Ask', + + [ValidateSet('Ask', 'Yes', 'No')] + [string]$Tweaks = 'Ask' +) + Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' +if ($NonInteractive) { + if ($BitLocker -eq 'Ask') { $BitLocker = 'No' } + if ($Tweaks -eq 'Ask') { $Tweaks = 'No' } +} + +# Waits for Enter, but never longer than $TimeoutSeconds, so an unattended +# deployment cannot sit on a prompt forever. +function Wait-ForExit { + param([int]$TimeoutSeconds = 120) + + if ($NonInteractive -or -not [Environment]::UserInteractive) { return } + try { if ([Console]::IsInputRedirected) { return } } catch { return } + try { $null = $Host.UI.RawUI.KeyAvailable } catch { return } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastShown = -1 + while ((Get-Date) -lt $deadline) { + $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) + if ($remaining -ne $lastShown) { + Write-Host ("`rPress Enter to exit (closing automatically in {0}s) " -f $remaining) -NoNewline + $lastShown = $remaining + } + if ($Host.UI.RawUI.KeyAvailable) { + $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + Write-Host "" + return + } + Start-Sleep -Milliseconds 200 + } + Write-Host "" +} + # Check for minimum PowerShell version if ($PSVersionTable.PSVersion.Major -lt 7) { Write-Output "ERROR: This script requires PowerShell 7 or higher." @@ -16,7 +62,7 @@ function Get-LatestRelease { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Stensel8/WinDeploy/releases/latest" -ErrorAction Stop + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest" -ErrorAction Stop if ($latestRelease.tag_name) { return $latestRelease.tag_name } @@ -46,15 +92,15 @@ if (!$releaseTag) { Write-Output "Please check:" Write-Output " 1. Your internet connection" Write-Output " 2. GitHub API accessibility" - Write-Output " 3. Repository has published releases: github.com/Stensel8/WinDeploy/releases" - Read-Host "Press Enter to exit" + Write-Output " 3. Repository has published releases: github.com/THectic-NL/WinDeploy/releases" + Wait-ForExit exit 1 } # Read version $version = $null try { - $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue + $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue $version = $version.Trim() if ($version) { Write-Output "Version: $version" @@ -157,7 +203,7 @@ function Get-DeploymentScript { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Deployment/$ScriptName" -OutFile $LocalPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Deployment/$ScriptName" -OutFile $LocalPath -UseBasicParsing -ErrorAction Stop Write-DeployLog "Downloaded $ScriptName to $LocalPath" return $true } catch { @@ -193,12 +239,14 @@ function Get-DeploymentScript { } # Define deployment steps (customize as needed) +# Arguments are splatted into the step, so a step can be driven non-interactively. $deploymentSteps = @( @{ Name = "RMM Agent Installation"; ScriptName = "Install-RMMAgent.ps1" } @{ Name = "Driver Installation"; ScriptName = "Install-Drivers.ps1" } - @{ Name = "Windows Hardening"; ScriptName = "Harden-Windows.ps1" } + @{ Name = "Windows Hardening"; ScriptName = "Harden-Windows.ps1"; Arguments = @{ BitLocker = $BitLocker } } @{ Name = "Application Installation"; ScriptName = "Install-Applications.ps1" } @{ Name = "Bloatware Removal"; ScriptName = "Remove-Bloat.ps1" } + @{ Name = "Optional Tweaks (WinUtil)"; ScriptName = "Apply-Tweaks.ps1"; Arguments = @{ Tweaks = $Tweaks } } @{ Name = "Theme Configuration"; ScriptName = "Set-Theme.ps1" } @{ Name = "Hostname Configuration"; ScriptName = "Set-HostName.ps1" } @{ Name = "Windows Updates"; ScriptName = "Install-WindowsUpdates.ps1" } @@ -223,37 +271,17 @@ foreach ($step in $deploymentSteps) { if ($scriptAvailable) { try { - # Special handling for RMM Agent - run async and check indicators - if ($step.ScriptName -eq "Install-RMMAgent.ps1") { - Write-Output "Starting RMM Agent installation (async)..." - $job = Start-Job -ScriptBlock { - & $using:localPath - } + # Reset first: $LASTEXITCODE is undefined until something sets it + # (which throws under Set-StrictMode), and otherwise keeps the + # previous step's value when a step returns without calling exit. + $global:LASTEXITCODE = 0 - # Wait max 30 seconds for job to complete - $timeout = 30 - $elapsed = 0 - while ($job.State -eq 'Running' -and $elapsed -lt $timeout) { - Start-Sleep -Seconds 1 - $elapsed++ - } + $stepArgs = if ($step.ContainsKey('Arguments')) { $step.Arguments } else { @{} } + & $localPath @stepArgs - # Check if job completed - if ($job.State -eq 'Running') { - Write-Output "RMM installation continuing in background..." - Remove-Job $job -Force - } else { - $jobResult = Receive-Job $job - $jobResult | ForEach-Object { Write-Output $_ } - Remove-Job $job - } - } else { - # Normal execution for all other scripts - & $localPath - if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { - Write-Warning "$($step.Name) completed with errors (Exit Code: $LASTEXITCODE)" - $allSuccessful = $false - } + if ($LASTEXITCODE -ne 0) { + Write-Warning "$($step.Name) completed with errors (Exit Code: $LASTEXITCODE)" + $allSuccessful = $false } } catch { Write-Warning "$($step.Name) failed: $_" @@ -273,7 +301,7 @@ try { $downloaded = $false for ($attempt = 1; $attempt -le 3; $attempt++) { try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Archived/Fix-Spotlight.ps1" -OutFile $fixScriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Archived/Fix-Spotlight.ps1" -OutFile $fixScriptPath -UseBasicParsing -ErrorAction Stop Write-DeployLog "Downloaded Fix-Spotlight.ps1 to $fixScriptPath as an optional script to fix missing Spotlight options on the system. This can be run manually if needed." $downloaded = $true break @@ -302,4 +330,4 @@ if ($allSuccessful) { Write-Output "Some deployment steps failed. Please review the output above." } Write-Output "" -Read-Host "Press Enter to exit" +Wait-ForExit diff --git a/Scripts/Deployment/Apply-Tweaks.ps1 b/Scripts/Deployment/Apply-Tweaks.ps1 new file mode 100644 index 0000000..f0aad77 --- /dev/null +++ b/Scripts/Deployment/Apply-Tweaks.ps1 @@ -0,0 +1,196 @@ +# ============================================================================ +# Apply-Tweaks.ps1 +# Applies a WinUtil (ChrisTitusTech) tweak preset. +# Standalone script - can be deployed via any management tool. +# +# This step downloads and runs a THIRD-PARTY script from christitus.com, so it +# is opt-in: the operator has to confirm with Y before anything runs. +# ============================================================================ + +#requires -Version 5.1 +#requires -RunAsAdministrator + +[CmdletBinding()] +param( + # Ask = prompt the operator (default) + # Yes = run without prompting + # No = skip this step + [ValidateSet('Ask', 'Yes', 'No')] + [string]$Tweaks = 'Ask', + + # WinUtil preset to apply. See Get-PresetSummary below for what each does. + [ValidateSet('Standard', 'Minimal', 'Advanced')] + [string]$Preset = 'Standard', + + # How long the prompt waits for a keypress before falling back to "No". + # Keeps unattended deployments from hanging forever. + [int]$PromptTimeoutSeconds = 90 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' + +$WinUtilUrl = 'https://christitus.com/win' + +Function Write-DeployLog { + param([string]$Message, [switch]$IsError) + $logDir = "C:\WinDeploy\Logs" + if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $scriptName = if ($PSCommandPath) { [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) } else { "Apply-Tweaks" } + $logFile = Join-Path $logDir "$scriptName.log" + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + "$timestamp - $Message" | Out-File -FilePath $logFile -Append + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } +} + +# Prompts for Y/N with a timeout. Returns $DefaultYes when the session is not +# interactive or nothing is typed in time, so a zero-touch deployment (which +# may run in a hidden window) never blocks. +function Read-YesNoWithTimeout { + param( + [Parameter(Mandatory = $true)][string]$Question, + [int]$TimeoutSeconds = 90, + [switch]$DefaultYes + ) + + $default = [bool]$DefaultYes + $defaultLabel = if ($default) { 'Y' } else { 'N' } + + # Work out whether anyone can actually answer. UserInteractive alone is not + # enough: it is $true for any process in a user session, including one + # started with a redirected stdin or from a scheduled task, where waiting + # out the full timeout would stall the deployment for nothing. + $interactive = [Environment]::UserInteractive + if ($interactive) { + try { if ([Console]::IsInputRedirected) { $interactive = $false } } catch { $interactive = $false } + } + if ($interactive) { + # Hosts without a real console (ISE, some job runners) throw here. + try { $null = $Host.UI.RawUI.KeyAvailable } catch { $interactive = $false } + } + if (-not $interactive) { + Write-Host "$Question [Y/N] -> no interactive console, using default: $defaultLabel" -ForegroundColor Cyan + return $default + } + + # Drain anything already buffered so a stray keypress doesn't answer for us. + try { + while ($Host.UI.RawUI.KeyAvailable) { $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } + } catch { + Write-Debug "Could not drain the input buffer: $($_.Exception.Message)" + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastShown = -1 + while ((Get-Date) -lt $deadline) { + $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) + # Repaint every 5s rather than every second: Start.ps1 runs a transcript, + # and a once-per-second countdown fills the log with redraw lines. + if ($lastShown -lt 0 -or ($lastShown - $remaining) -ge 5) { + Write-Host ("`r{0} [Y/N] (default {1} in {2}s) " -f $Question, $defaultLabel, $remaining) -NoNewline -ForegroundColor Yellow + $lastShown = $remaining + } + if ($Host.UI.RawUI.KeyAvailable) { + $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + if ($key.Character -eq 'y' -or $key.Character -eq 'Y') { Write-Host "`r$Question [Y/N] -> Yes " -ForegroundColor Green; return $true } + if ($key.Character -eq 'n' -or $key.Character -eq 'N') { Write-Host "`r$Question [Y/N] -> No " -ForegroundColor Cyan; return $false } + } + Start-Sleep -Milliseconds 200 + } + + Write-Host ("`r{0} [Y/N] -> timed out, using default: {1} " -f $Question, $defaultLabel) -ForegroundColor Cyan + return $default +} + +# What each preset changes, so the operator can decide before pressing Y. +function Get-PresetSummary { + param([string]$Name) + switch ($Name) { + 'Minimal' { + return @( + "Disable consumer features (stops Windows re-installing bloatware)", + "Disable WPBT (blocks OEM firmware-injected binaries)", + "Set non-essential services to manual start", + "Disable telemetry" + ) + } + 'Advanced' { + return @( + "Everything in Standard, plus:", + "Disable Store search, widgets and Windows AI/Recall", + "Restore the classic Start menu and right-click menu", + "Remove OneDrive" + ) + } + default { + return @( + "Create a system restore point first", + "Disable activity history, location tracking and telemetry", + "Disable consumer features (stops Windows re-installing bloatware)", + "Disable WPBT (blocks OEM firmware-injected binaries)", + "Disable Delivery Optimization peer-to-peer update sharing", + "Set non-essential services to manual start", + "Disable Explorer folder-type auto-discovery", + "Enable 'End task' in the taskbar right-click menu", + "Run disk cleanup and delete temporary files" + ) + } + } +} + +Write-DeployLog "=== WinUtil tweaks ($Preset preset) ===" + +switch ($Tweaks) { + 'Yes' { $runTweaks = $true } + 'No' { $runTweaks = $false } + default { + Write-Output "" + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " Optional: WinUtil tweaks - '$Preset' preset" -ForegroundColor Yellow + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " This downloads and runs a third-party script:" -ForegroundColor Gray + Write-Host " $WinUtilUrl (ChrisTitusTech/winutil)" -ForegroundColor Gray + Write-Host "" + Write-Host " The '$Preset' preset will:" -ForegroundColor Gray + foreach ($line in (Get-PresetSummary -Name $Preset)) { + Write-Host " - $line" -ForegroundColor Gray + } + Write-Host "" + Write-Host " Skipping this step leaves the rest of the deployment intact." -ForegroundColor Gray + Write-Host "" + $runTweaks = Read-YesNoWithTimeout -Question " Run WinUtil '$Preset' tweaks now?" -TimeoutSeconds $PromptTimeoutSeconds + Write-Output "" + } +} + +if (-not $runTweaks) { + Write-DeployLog "WinUtil tweaks skipped (not confirmed). Re-run with -Tweaks Yes to apply them later." + exit 0 +} + +try { + Write-DeployLog "Running WinUtil with the '$Preset' preset. This can take several minutes..." + + # Run WinUtil in its own process. It manages its own transcript, runspace + # pool and global state, and calls Stop-Transcript when it finishes - none + # of which should touch the deployment session that called us. + $command = "& ([ScriptBlock]::Create((irm $WinUtilUrl))) -Preset $Preset" + $hostExe = (Get-Process -Id $PID).Path + if ([string]::IsNullOrWhiteSpace($hostExe)) { $hostExe = 'powershell.exe' } + + $proc = Start-Process -FilePath $hostExe ` + -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $command ` + -Wait -PassThru -NoNewWindow + + if ($proc.ExitCode -eq 0) { + Write-DeployLog "SUCCESS: WinUtil '$Preset' preset applied." + } else { + Write-DeployLog "WinUtil exited with code $($proc.ExitCode). Review C:\WinDeploy\Logs and the WinUtil log for details." -IsError + } +} catch { + Write-DeployLog "Failed to run WinUtil: $($_.Exception.Message)" -IsError +} + +Write-Output "" +Write-Output "Note: some WinUtil tweaks (services, Explorer settings) only take effect after a restart." +exit 0 diff --git a/Scripts/Deployment/Harden-Windows.ps1 b/Scripts/Deployment/Harden-Windows.ps1 index fc41606..d138971 100644 --- a/Scripts/Deployment/Harden-Windows.ps1 +++ b/Scripts/Deployment/Harden-Windows.ps1 @@ -2,17 +2,106 @@ # Harden-Windows.ps1 # Applies security hardenings to Windows 11 systems. # Standalone script - can be deployed via any management tool. +# +# All baseline hardenings are applied unconditionally. BitLocker is the one +# exception: it is only enabled after an explicit Y/N confirmation, because +# it produces a recovery key that the operator MUST write down. # ============================================================================ #requires -Version 5.1 #requires -RunAsAdministrator +[CmdletBinding()] +param( + # Ask = prompt the operator (default) + # Yes = enable BitLocker without prompting + # No = skip BitLocker entirely + [ValidateSet('Ask', 'Yes', 'No')] + [string]$BitLocker = 'Ask', + + # How long the BitLocker prompt waits for a keypress before falling back + # to "No". Keeps unattended deployments from hanging forever. + [int]$PromptTimeoutSeconds = 90 +) + Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' +Function Write-DeployLog { + param([string]$Message, [switch]$IsError) + $logDir = "C:\WinDeploy\Logs" + if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $scriptName = if ($PSCommandPath) { [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) } else { "Harden-Windows" } + $logFile = Join-Path $logDir "$scriptName.log" + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + "$timestamp - $Message" | Out-File -FilePath $logFile -Append + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } +} + +# Prompts for Y/N with a timeout. Returns $DefaultYes when the session is not +# interactive or nothing is typed in time, so a zero-touch deployment (which +# may run in a hidden window) never blocks. +function Read-YesNoWithTimeout { + param( + [Parameter(Mandatory = $true)][string]$Question, + [int]$TimeoutSeconds = 90, + [switch]$DefaultYes + ) + + $default = [bool]$DefaultYes + $defaultLabel = if ($default) { 'Y' } else { 'N' } + + # Work out whether anyone can actually answer. UserInteractive alone is not + # enough: it is $true for any process in a user session, including one + # started with a redirected stdin or from a scheduled task, where waiting + # out the full timeout would stall the deployment for nothing. + $interactive = [Environment]::UserInteractive + if ($interactive) { + try { if ([Console]::IsInputRedirected) { $interactive = $false } } catch { $interactive = $false } + } + if ($interactive) { + # Hosts without a real console (ISE, some job runners) throw here. + try { $null = $Host.UI.RawUI.KeyAvailable } catch { $interactive = $false } + } + if (-not $interactive) { + Write-Host "$Question [Y/N] -> no interactive console, using default: $defaultLabel" -ForegroundColor Cyan + return $default + } + + # Drain anything already buffered so a stray keypress doesn't answer for us. + try { + while ($Host.UI.RawUI.KeyAvailable) { $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } + } catch { + Write-Debug "Could not drain the input buffer: $($_.Exception.Message)" + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastShown = -1 + while ((Get-Date) -lt $deadline) { + $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) + # Repaint every 5s rather than every second: Start.ps1 runs a transcript, + # and a once-per-second countdown fills the log with redraw lines. + if ($lastShown -lt 0 -or ($lastShown - $remaining) -ge 5) { + Write-Host ("`r{0} [Y/N] (default {1} in {2}s) " -f $Question, $defaultLabel, $remaining) -NoNewline -ForegroundColor Yellow + $lastShown = $remaining + } + if ($Host.UI.RawUI.KeyAvailable) { + $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + if ($key.Character -eq 'y' -or $key.Character -eq 'Y') { Write-Host "`r$Question [Y/N] -> Yes " -ForegroundColor Green; return $true } + if ($key.Character -eq 'n' -or $key.Character -eq 'N') { Write-Host "`r$Question [Y/N] -> No " -ForegroundColor Cyan; return $false } + } + Start-Sleep -Milliseconds 200 + } + + Write-Host ("`r{0} [Y/N] -> timed out, using default: {1} " -f $Question, $defaultLabel) -ForegroundColor Cyan + return $default +} + # Check for Intune enrollment (pre-check) function Test-IntuneEnrollment { - $enrollments = Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue + # -ErrorAction SilentlyContinue yields $null when the key is absent, and + # $null.Count throws under Set-StrictMode. @() normalises both cases. + $enrollments = @(Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue) if ($enrollments.Count -eq 0) { return $false } foreach ($enrollment in $enrollments) { $guid = $enrollment.PSChildName @@ -40,17 +129,6 @@ if ($build -lt 26200) { exit 1 } -Function Write-DeployLog { - param([string]$Message, [switch]$IsError) - $logDir = "C:\WinDeploy\Logs" - if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } - $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) - $logFile = Join-Path $logDir "$scriptName.log" - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - "$timestamp - $Message" | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } -} - #region Configuration $registryConfigs = @( @{ @@ -75,18 +153,37 @@ $registryConfigs = @( Description = "Device co-installers disabled" }, @{ + # SMB1 server. The SMB1 *feature* is removed separately below; this keeps + # the server side off even if something re-adds the feature. + # Note: there is deliberately no "SMB2 = 0" here. That value disables both + # SMB2 and SMB3 - i.e. all remaining SMB - which breaks file and printer + # sharing. Microsoft explicitly advises against it. Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters" Name = "SMB1" Value = 0 Type = "DWord" - Description = "SMBv1 disabled - https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3?tabs=server" + Description = "SMBv1 disabled - https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3" }, @{ Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters" - Name = "SMB2" + Name = "RequireSecuritySignature" + Value = 1 + Type = "DWord" + Description = "SMB server signing required" + }, + @{ + Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters" + Name = "RequireSecuritySignature" + Value = 1 + Type = "DWord" + Description = "SMB client signing required" + }, + @{ + Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters" + Name = "AllowInsecureGuestAuth" Value = 0 Type = "DWord" - Description = "SMBv2 disabled - https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3?tabs=server" + Description = "SMB insecure guest logons blocked" }, @{ Path = "HKLM:\SOFTWARE\Microsoft\Windows Script Host\Settings" @@ -95,6 +192,48 @@ $registryConfigs = @( Type = "DWord" Description = "Windows Script Host disabled" }, + @{ + # Blocks WDigest from caching plaintext credentials in LSASS. + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest" + Name = "UseLogonCredential" + Value = 0 + Type = "DWord" + Description = "WDigest plaintext credential caching disabled" + }, + @{ + # LSA runs as a Protected Process Light, blocking credential dumpers + # such as Mimikatz from opening the LSASS process. + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" + Name = "RunAsPPL" + Value = 1 + Type = "DWord" + Description = "LSA protection (RunAsPPL) enabled" + }, + @{ + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" + Name = "RestrictAnonymous" + Value = 1 + Type = "DWord" + Description = "Anonymous SAM/share enumeration restricted" + }, + @{ + # Mitigates LLMNR poisoning (Responder-style credential theft). + # DNS and NetBIOS name resolution are unaffected. + Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" + Name = "EnableMulticast" + Value = 0 + Type = "DWord" + Description = "LLMNR disabled" + }, + @{ + # Memory integrity (HVCI). Windows 11 enables this by default on + # compatible clean installs; set it explicitly so upgrades match. + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity" + Name = "Enabled" + Value = 1 + Type = "DWord" + Description = "Memory integrity (HVCI) enabled" + }, @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\FVE" Name = "EnableBDE" @@ -112,9 +251,30 @@ $registryConfigs = @( } ) +# Screen lock is enforced through the machine-wide policy hive rather than +# HKCU. During deployment HKCU belongs to the deployment/admin account, not to +# the end user, so HKCU values would silently apply to the wrong profile. +$lockPolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop" $monitorTimeoutMinutes = 10 $standbyTimeoutMinutes = 30 $screenSaverTimeoutSeconds = 900 +# A screen saver executable is required: with ScreenSaveActive=1 but no +# SCRNSAVE.EXE, Windows never starts one and the secure-lock never triggers. +$screenSaverExe = "$env:SystemRoot\System32\scrnsave.scr" + +# Attack Surface Reduction rules (Defender). Conservative set that does not +# interfere with normal business software. +$asrRules = @( + @{ Id = "56a863a9-875e-4185-98a7-b882c64b5ce5"; Description = "ASR: block abuse of vulnerable signed drivers" } + @{ Id = "d4f940ab-401b-4efc-aadc-ad5f3c50688a"; Description = "ASR: block Office apps creating child processes" } + @{ Id = "9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2"; Description = "ASR: block credential stealing from LSASS" } + @{ Id = "be9ba2d9-53ea-4cdc-84e5-9b1eeee46550"; Description = "ASR: block executable content from email/webmail" } + @{ Id = "d3e037e1-3eb8-44c8-a917-57927947596d"; Description = "ASR: block JS/VBS launching downloaded executables" } + @{ Id = "5beb7efe-fd9a-4556-801d-275e5ffc04cc"; Description = "ASR: block obfuscated scripts" } + @{ Id = "92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b"; Description = "ASR: block Office apps creating executable content" } + @{ Id = "01443614-cd74-433a-b99e-2ecdc07bfc25"; Description = "ASR: block executables unless prevalent/aged/trusted" } + @{ Id = "c1db55ab-c21a-4637-bb3f-a12568109d35"; Description = "ASR: use advanced ransomware protection" } +) #endregion Write-DeployLog "Starting Windows hardening process..." @@ -142,39 +302,150 @@ foreach ($config in $registryConfigs) { } } -# Enable BitLocker +# Remove the SMB1 client/server feature outright (the registry value above only +# covers the server side and only while the feature is still installed). try { - $tpm = Get-Tpm -ErrorAction Stop - - if (-not $tpm.TpmPresent) { - throw "TPM not present" + $smb1 = Get-WindowsOptionalFeature -Online -FeatureName 'SMB1Protocol' -ErrorAction Stop + if ($smb1.State -eq 'Enabled') { + Disable-WindowsOptionalFeature -Online -FeatureName 'SMB1Protocol' -NoRestart -ErrorAction Stop | Out-Null + $appliedConfigs += "SMBv1 feature removed" + } else { + $appliedConfigs += "SMBv1 feature already absent" } - if (-not $tpm.TpmEnabled) { - throw "TPM not enabled" +} catch { + Write-DeployLog "SMBv1 feature removal skipped: $($_.Exception.Message)" -IsError +} + +# Attack Surface Reduction rules +try { + $null = Get-Command Add-MpPreference -ErrorAction Stop + $asrApplied = 0 + foreach ($rule in $asrRules) { + try { + Add-MpPreference -AttackSurfaceReductionRules_Ids $rule.Id -AttackSurfaceReductionRules_Actions Enabled -ErrorAction Stop + $asrApplied++ + } catch { + Write-DeployLog "Failed: $($rule.Description) - $($_.Exception.Message)" -IsError + } } - if (-not $tpm.TpmActivated) { - throw "TPM not activated" + if ($asrApplied -gt 0) { + $appliedConfigs += "Defender ASR rules enabled ($asrApplied of $($asrRules.Count))" } +} catch { + Write-DeployLog "Defender ASR rules skipped: Defender cmdlets unavailable ($($_.Exception.Message))" -IsError + $failedConfigs += "Defender ASR rules" +} + +#region BitLocker +# BitLocker is opt-in: it generates a recovery key that must be written down +# before the machine leaves the bench. Everything above this point is applied +# unconditionally. +$recoveryPassword = $null +$recoveryKeyFile = $null - if (-not $tpm.TpmOwned) { - Write-DeployLog "Initializing TPM ownership..." - Initialize-Tpm -AllowClear -AllowPhysicalPresence -ErrorAction Stop +switch ($BitLocker) { + 'Yes' { $enableBitLocker = $true } + 'No' { $enableBitLocker = $false } + default { + Write-Output "" + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " BitLocker drive encryption" -ForegroundColor Yellow + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " Encrypts C: with XTS-AES-256 using the TPM." -ForegroundColor Gray # DevSkim: ignore DS187371 - XTS is the recommended BitLocker mode, not a weak one + Write-Host " A 48-digit recovery key will be generated and saved to your" -ForegroundColor Gray + Write-Host " Documents folder. You MUST store that key somewhere safe -" -ForegroundColor Gray + Write-Host " without it the drive cannot be recovered if the TPM, the" -ForegroundColor Gray + Write-Host " motherboard or the firmware configuration changes." -ForegroundColor Gray + Write-Host "" + $enableBitLocker = Read-YesNoWithTimeout -Question " Enable BitLocker on C: now?" -TimeoutSeconds $PromptTimeoutSeconds + Write-Output "" } +} - $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop - if ($bitLockerStatus.ProtectionStatus -eq "Off") { - Enable-BitLocker -MountPoint "C:" -TpmProtector -EncryptionMethod XtsAes256 -UsedSpaceOnly -ErrorAction Stop - $appliedConfigs += "BitLocker encryption started" - Write-Output "WARNING: BitLocker will be enabled after the next reboot. Make sure to export your BitLocker recovery key!" - } else { - $appliedConfigs += "BitLocker already active" +if (-not $enableBitLocker) { + Write-DeployLog "BitLocker skipped (not confirmed). Run this script again with -BitLocker Yes to enable it later." + $appliedConfigs += "BitLocker skipped by operator choice" +} else { + try { + $tpm = Get-Tpm -ErrorAction Stop + + if (-not $tpm.TpmPresent) { throw "TPM not present" } + if (-not $tpm.TpmEnabled) { throw "TPM not enabled" } + if (-not $tpm.TpmActivated) { throw "TPM not activated" } + + if (-not $tpm.TpmOwned) { + # Deliberately no -AllowClear: clearing the TPM destroys any key + # material already sealed to it. If ownership cannot be taken + # without a clear, that is an operator decision, not ours. + Write-DeployLog "Initializing TPM ownership..." + Initialize-Tpm -ErrorAction Stop | Out-Null + } + + $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop + if ($bitLockerStatus.ProtectionStatus -eq 'Off') { + Enable-BitLocker -MountPoint "C:" -TpmProtector -EncryptionMethod XtsAes256 -UsedSpaceOnly -SkipHardwareTest -ErrorAction Stop | Out-Null + $appliedConfigs += "BitLocker encryption started (XTS-AES-256)" # DevSkim: ignore DS187371 - XTS is the recommended BitLocker mode, not a weak one + } else { + $appliedConfigs += "BitLocker already active" + } + + # A TPM protector alone is not recoverable. Make sure a recovery + # password exists, then hand it to the operator. + $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop + $existing = @($bitLockerStatus.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) + if ($existing.Count -eq 0) { + Add-BitLockerKeyProtector -MountPoint "C:" -RecoveryPasswordProtector -ErrorAction Stop | Out-Null + $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop + $existing = @($bitLockerStatus.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) + } + + if ($existing.Count -gt 0) { + $recoveryPassword = $existing[0].RecoveryPassword + $recoveryId = $existing[0].KeyProtectorId + $appliedConfigs += "BitLocker recovery password created" + + # Save next to the operator, in Documents. Fall back to the + # WinDeploy folder when there is no profile (e.g. SYSTEM). + $documents = [Environment]::GetFolderPath('MyDocuments') + if ([string]::IsNullOrWhiteSpace($documents) -or -not (Test-Path $documents)) { + $documents = "C:\WinDeploy" + if (!(Test-Path $documents)) { New-Item -ItemType Directory -Path $documents -Force | Out-Null } + } + $recoveryKeyFile = Join-Path $documents ("BitLocker-Recovery-Key_{0}_{1}.txt" -f $env:COMPUTERNAME, (Get-Date -Format 'yyyy-MM-dd_HHmmss')) + + $keyFileContent = @" +BitLocker recovery key +====================== + +Computer : $env:COMPUTERNAME +Drive : C: +Created : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') +Identifier : $recoveryId + +Recovery key : $recoveryPassword + +KEEP THIS KEY SAFE. +Without it the contents of this drive cannot be recovered if the TPM is +cleared or fails, the motherboard is replaced, or the firmware/boot +configuration changes. + +Store it in your password manager or another secure location that is NOT +on this machine, then delete this file. +"@ + $keyFileContent | Out-File -FilePath $recoveryKeyFile -Encoding UTF8 -Force + Write-DeployLog "BitLocker recovery key written to $recoveryKeyFile" + } else { + Write-DeployLog "BitLocker enabled but no recovery password could be read back." -IsError + $failedConfigs += "BitLocker recovery password" + } + } catch { + Write-DeployLog "BitLocker skipped: $($_.Exception.Message)" -IsError + $failedConfigs += "BitLocker" } -} catch { - Write-DeployLog "BitLocker skipped: $($_.Exception.Message)" -IsError - $failedConfigs += "BitLocker" } +#endregion -# Configure power settings +# Configure power settings and screen lock try { & powercfg /change monitor-timeout-ac $monitorTimeoutMinutes 2>&1 | Out-Null & powercfg /change monitor-timeout-dc $monitorTimeoutMinutes 2>&1 | Out-Null @@ -184,30 +455,41 @@ try { & powercfg /setdcvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 1 2>&1 | Out-Null & powercfg /setactive SCHEME_CURRENT 2>&1 | Out-Null - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "ScreenSaverIsSecure" -Value "1" -ErrorAction Stop - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "ScreenSaveTimeOut" -Value "$screenSaverTimeoutSeconds" -ErrorAction Stop - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "ScreenSaveActive" -Value "1" -ErrorAction Stop - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "SCRNSAVE.EXE" -Value "" -ErrorAction Stop + if (!(Test-Path $lockPolicyPath)) { New-Item -Path $lockPolicyPath -Force -ErrorAction Stop | Out-Null } + Set-ItemProperty -Path $lockPolicyPath -Name "ScreenSaveActive" -Value "1" -Type String -ErrorAction Stop + Set-ItemProperty -Path $lockPolicyPath -Name "ScreenSaverIsSecure" -Value "1" -Type String -ErrorAction Stop + Set-ItemProperty -Path $lockPolicyPath -Name "ScreenSaveTimeOut" -Value "$screenSaverTimeoutSeconds" -Type String -ErrorAction Stop + if (Test-Path $screenSaverExe) { + Set-ItemProperty -Path $lockPolicyPath -Name "SCRNSAVE.EXE" -Value $screenSaverExe -Type String -ErrorAction Stop + } else { + Write-DeployLog "Screen saver executable not found at $screenSaverExe - lock-on-timeout may not trigger." + } - $appliedConfigs += "Power/lock settings configured" + $appliedConfigs += "Power settings configured" + $appliedConfigs += "Screen lock after $([int]($screenSaverTimeoutSeconds / 60)) minutes (machine policy)" } catch { Write-DeployLog "Power settings failed: $($_.Exception.Message)" -IsError $failedConfigs += "Power settings" } -# Verification +# Verification - read a few settings back rather than trusting the writes. $verifications = @( @{Path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer"; Name = "NoDriveTypeAutoRun"; Expected = 255} + @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa"; Name = "RunAsPPL"; Expected = 1} + @{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters"; Name = "AllowInsecureGuestAuth"; Expected = 0} + @{Path = $lockPolicyPath; Name = "ScreenSaverIsSecure"; Expected = "1"} ) $verifyFailed = $false foreach ($verify in $verifications) { try { $value = (Get-ItemProperty -Path $verify.Path -Name $verify.Name -ErrorAction Stop).$($verify.Name) - if ($value -ne $verify.Expected) { + if ("$value" -ne "$($verify.Expected)") { + Write-DeployLog "Verification mismatch: $($verify.Name) is '$value', expected '$($verify.Expected)'" -IsError $verifyFailed = $true } } catch { + Write-DeployLog "Verification failed to read $($verify.Name): $($_.Exception.Message)" -IsError $verifyFailed = $true } } @@ -217,11 +499,22 @@ $hardeningLinks = @{ "AutoRun disabled" = "https://en.wikipedia.org/wiki/AutoRun" "Autorun.inf blocked" = "https://en.wikipedia.org/wiki/AutoRun" "Device co-installers disabled" = "https://learn.microsoft.com/en-us/previous-versions/windows/drivers/install/co-installer-functionality" - "SMBv1 disabled" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3?tabs=server" + "SMBv1 disabled" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3" + "SMBv1 feature removed" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3" + "SMB server signing required" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing" + "SMB client signing required" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing" + "SMB insecure guest logons blocked" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/guest-access-in-smb2-is-disabled-by-default" "Windows Script Host disabled" = "https://en.wikipedia.org/wiki/Windows_Script_Host" + "WDigest plaintext credential caching disabled" = "https://learn.microsoft.com/en-us/troubleshoot/windows-server/windows-security/wdigest-authentication-disabled" + "LSA protection (RunAsPPL) enabled" = "https://learn.microsoft.com/en-us/windows-server/security/credentials-protection-and-management/configuring-additional-lsa-protection" + "Anonymous SAM/share enumeration restricted" = "https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/network-access-do-not-allow-anonymous-enumeration-of-sam-accounts-and-shares" + "LLMNR disabled" = "https://learn.microsoft.com/en-us/windows-server/networking/dns/what-s-new-in-dns-client" + "Memory integrity (HVCI) enabled" = "https://learn.microsoft.com/en-us/windows/security/hardware-security/enable-virtualization-based-protection-of-code-integrity" "BitLocker policy enabled" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" "BitLocker already active" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" - "Power/lock settings configured" = "https://learn.microsoft.com/en-us/windows/win32/power/power-management-portal" + "BitLocker encryption started (XTS-AES-256)" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" # DevSkim: ignore DS187371 - XTS is the recommended BitLocker mode, not a weak one + "BitLocker recovery password created" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/bitlocker-recovery-overview" + "Power settings configured" = "https://learn.microsoft.com/en-us/windows/win32/power/power-management-portal" } # Summary output @@ -240,8 +533,31 @@ if ($failedConfigs.Count -gt 0) { } } +# Show the recovery key last, so it is the final thing on screen. +if ($recoveryPassword) { + Write-Host "" + Write-Host "############################################################" -ForegroundColor Red + Write-Host "# BITLOCKER RECOVERY KEY - WRITE IT DOWN #" -ForegroundColor Red + Write-Host "############################################################" -ForegroundColor Red + Write-Host "" + Write-Host " $recoveryPassword" -ForegroundColor Yellow + Write-Host "" + if ($recoveryKeyFile) { + Write-Host " Also saved to: $recoveryKeyFile" -ForegroundColor Gray + } + Write-Host "" + Write-Host " Store this key in your password manager or another secure" -ForegroundColor Red + Write-Host " location that is NOT this machine, then delete the file." -ForegroundColor Red + Write-Host " Without it, an encrypted drive cannot be recovered after a" -ForegroundColor Red + Write-Host " TPM clear, mainboard swap or firmware change." -ForegroundColor Red + Write-Host "" + Write-Host "############################################################" -ForegroundColor Red + Write-Host "" +} + Write-Output "" -Write-Output "Note: For extra security, manually enable Tamper Protection and Memory Integrity in Windows Security Center." +Write-Output "Note: memory integrity, LSA protection and SMB signing take effect after a restart." +Write-Output "Note: for extra security, manually enable Tamper Protection in Windows Security Center." Write-Output "" if ($failedConfigs.Count -eq 0 -and -not $verifyFailed) { diff --git a/Scripts/Deployment/Install-Applications.ps1 b/Scripts/Deployment/Install-Applications.ps1 index da11bc5..7c6a086 100644 --- a/Scripts/Deployment/Install-Applications.ps1 +++ b/Scripts/Deployment/Install-Applications.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { @@ -181,13 +181,13 @@ try { -1978335100 = "The Microsoft Store package does not support download command." -1978335099 = "Failed to retrieve Microsoft Store package license. The Microsoft Entra Id account does not have required privilege." -1978335098 = "Downloaded zero byte installer; ensure that your network connection is working properly." - -1979335097 = "Failed installing one or more fonts." - -1979335096 = "Font file is not supported and cannot be installed." - -1979335095 = "Font package is already installed." - -1979335094 = "Font file not found." - -1979335093 = "Font uninstall failed. The font may not be in a good state. Try uninstalling after a restart." - -1979335092 = "Font validation failed." - -1979335091 = "Font rollback failed. The font may not be in a good state. Try uninstalling after a restart." + -1978335097 = "Failed installing one or more fonts." + -1978335096 = "Font file is not supported and cannot be installed." + -1978335095 = "Font package is already installed." + -1978335094 = "Font file not found." + -1978335093 = "Font uninstall failed. The font may not be in a good state. Try uninstalling after a restart." + -1978335092 = "Font validation failed." + -1978335091 = "Font rollback failed. The font may not be in a good state. Try uninstalling after a restart." -1978334975 = "Application is currently running. Exit the application then try again." -1978334974 = "Another installation is already in progress. Try again later." -1978334973 = "One or more file is being used. Exit the application then try again." @@ -255,7 +255,7 @@ try { $name = $app.Name Write-DeployLog "Installing $name ($alias)..." try { - $output = & winget install --id $alias --source winget --accept-package-agreements --accept-source-agreements 2>&1 + $output = & winget install --id $alias --exact --source winget --silent --disable-interactivity --accept-package-agreements --accept-source-agreements 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0 -or $output -match "already installed|No available upgrade") { Write-DeployLog "Installed $name ($alias)" @@ -324,7 +324,7 @@ try { - + '@ @@ -362,7 +362,7 @@ try { $name = $app.Name Write-DeployLog "Installing msstore $name ($alias)..." try { - $output = & winget install --id $alias --source msstore --accept-package-agreements --accept-source-agreements 2>&1 + $output = & winget install --id $alias --exact --source msstore --silent --disable-interactivity --accept-package-agreements --accept-source-agreements 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0 -or $output -match "already installed|No available upgrade") { Write-DeployLog "Installed msstore $name ($alias)" diff --git a/Scripts/Deployment/Install-Drivers.ps1 b/Scripts/Deployment/Install-Drivers.ps1 index 0cebbb7..ff3e807 100644 --- a/Scripts/Deployment/Install-Drivers.ps1 +++ b/Scripts/Deployment/Install-Drivers.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { @@ -29,6 +29,10 @@ try { Write-DeployLog "System: $manufacturer $model" + # "*hp*" would also match e.g. "Sharp", so match HP as a whole token. + $isDell = $manufacturer -like "*dell*" + $isHP = $manufacturer -like "*hewlett*" -or $manufacturer -match "(^|[^a-z])hp([^a-z]|$)" + # Embed supported device lists (no more JSON dependency) $supportedDellDevices = @( @@ -40,14 +44,14 @@ try { # Check if supported $isSupported = $false - if ($manufacturer -like "*dell*") { + if ($isDell) { Write-DeployLog "Checking Dell support..." $matchedPattern = $supportedDellDevices | Where-Object { $model -imatch "(?i)$([regex]::Escape($_) -replace '\\ ', '\\s+')" } | Select-Object -First 1 if ($matchedPattern) { $isSupported = $true Write-DeployLog "Matched pattern: $matchedPattern" } - } elseif ($manufacturer -like "*hewlett*" -or $manufacturer -like "*hp*") { + } elseif ($isHP) { Write-DeployLog "Checking HP support..." $matchedPattern = $supportedHPDevices | Where-Object { $model -imatch "(?i)$([regex]::Escape($_) -replace '\\ ', '\\s+')" } | Select-Object -First 1 if ($matchedPattern) { @@ -61,7 +65,7 @@ try { exit 0 } - if ($manufacturer -like "*dell*") { + if ($isDell) { Write-DeployLog "Supported Dell system detected. Installing Dell Command Update..." try { winget install --id Dell.CommandUpdate --silent --accept-package-agreements --accept-source-agreements @@ -108,12 +112,19 @@ try { Write-DeployLog "Failed to install or run Dell Command Update" Write-Warning "Dell driver installation failed. Check logs for details." } - } elseif ($manufacturer -like "*hewlett*" -or $manufacturer -like "*hp*") { + } elseif ($isHP) { Write-DeployLog "HP system detected. Installing HP Client Management Script Library..." try { # Install HPCMSL module if not present if (-not (Get-Module -Name HPCMSL -ListAvailable)) { - Install-Module -Name HPCMSL -Force -AllowClobber -ErrorAction Stop + # Bootstrap the package plumbing first, otherwise Install-Module + # prompts for the NuGet provider and for trusting PSGallery - + # both of which stall an unattended deployment. + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers -Confirm:$false | Out-Null + } + Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction SilentlyContinue + Install-Module -Name HPCMSL -Force -AllowClobber -AcceptLicense -Scope AllUsers -Confirm:$false -ErrorAction Stop } Import-Module HPCMSL -ErrorAction Stop Write-DeployLog "HPCMSL installed and imported." diff --git a/Scripts/Deployment/Install-RMMAgent.ps1 b/Scripts/Deployment/Install-RMMAgent.ps1 index 092b67d..35ee440 100644 --- a/Scripts/Deployment/Install-RMMAgent.ps1 +++ b/Scripts/Deployment/Install-RMMAgent.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { diff --git a/Scripts/Deployment/Install-WindowsUpdates.ps1 b/Scripts/Deployment/Install-WindowsUpdates.ps1 index c9aa509..f05c41e 100644 --- a/Scripts/Deployment/Install-WindowsUpdates.ps1 +++ b/Scripts/Deployment/Install-WindowsUpdates.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { @@ -49,6 +49,10 @@ try { Write-DeployLog "Verifying Windows Update service..." $wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue + if (-not $wuService) { + Write-DeployLog "Windows Update service (wuauserv) not found on this system." -IsError + exit 1 + } if ($wuService.Status -ne 'Running') { Write-DeployLog "Starting Windows Update service..." Start-Service -Name wuauserv -ErrorAction Stop @@ -67,18 +71,26 @@ try { Write-DeployLog " - $($update.Title)" } - Write-DeployLog "Installing updates..." + Write-DeployLog "Downloading and installing updates. This can take a while..." $installedCount = 0 $failedCount = 0 - foreach ($update in $updates) { - try { - Write-DeployLog " - Installing: $($update.Title)" - Install-WindowsUpdate -KB $update.KB -AcceptAll -IgnoreReboot -Confirm:$false | Out-Null - Write-DeployLog " Success" + try { + $results = @(Get-WindowsUpdate -MicrosoftUpdate -Install -AcceptAll -IgnoreReboot -Confirm:$false -ErrorAction Stop) + } catch { + Write-DeployLog "Update installation failed: $($_.Exception.Message)" -IsError + $results = @() + $failedCount = $updates.Count + } + + foreach ($result in $results) { + $title = if ($result.PSObject.Properties.Name -contains 'Title') { $result.Title } else { 'Unknown update' } + $status = if ($result.PSObject.Properties.Name -contains 'Result') { $result.Result } else { 'Unknown' } + if ($status -match 'Installed|Succeeded') { + Write-DeployLog " - Installed: $title" $installedCount++ - } catch { - Write-DeployLog " Failed: $($_.Exception.Message)" -IsError + } else { + Write-DeployLog " - $status`: $title" -IsError $failedCount++ } } diff --git a/Scripts/Deployment/Remove-Bloat.ps1 b/Scripts/Deployment/Remove-Bloat.ps1 index dbff1fb..cab00bc 100644 --- a/Scripts/Deployment/Remove-Bloat.ps1 +++ b/Scripts/Deployment/Remove-Bloat.ps1 @@ -10,12 +10,12 @@ $ErrorActionPreference = 'Continue' Function Write-DeployLog { param([string]$Message, [switch]$IsError) - $logDir = Join-Path $env:TEMP "WinDeploy\Logs" + $logDir = "C:\WinDeploy\Logs" if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Host $Message } + if ($IsError) { Write-Warning $Message } else { Write-Host $Message } } # Expanded list for common bloatware (inspired by WinDeploy Remove-Bloat.ps1, excluding Get Help) @@ -80,6 +80,15 @@ $BloatwareList = @( # AI and Assistant "Microsoft.Copilot", + "Microsoft.Windows.Ai.Copilot.Provider", + + # Newer 24H2/25H2 in-box apps + "Microsoft.Windows.DevHome", + "Microsoft.OutlookForWindows", + "Microsoft.Edge.GameAssist", + "MicrosoftWindows.CrossDevice", + "Microsoft.StartExperiencesApp", + "Microsoft.WindowsMeetNow", # System & Utility "Microsoft.PowerAutomateDesktop", @@ -211,6 +220,25 @@ try { + # Stop Windows from silently re-installing suggested apps on the next + # feature update or for the next new user profile. + Write-DeployLog "Blocking automatic reinstall of consumer apps..." + $reinstallPolicies = @( + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent"; Name = "DisableWindowsConsumerFeatures"; Value = 1; Description = "Consumer features disabled" } + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent"; Name = "DisableConsumerAccountStateContent"; Value = 1; Description = "Consumer account state content disabled" } + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent"; Name = "DisableCloudOptimizedContent"; Value = 1; Description = "Cloud optimized content disabled" } + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\WindowsStore"; Name = "AutoDownload"; Value = 2; Description = "Store app auto-download disabled" } + ) + foreach ($policy in $reinstallPolicies) { + try { + if (!(Test-Path $policy.Path)) { New-Item -Path $policy.Path -Force -ErrorAction Stop | Out-Null } + Set-ItemProperty -Path $policy.Path -Name $policy.Name -Value $policy.Value -Type DWord -ErrorAction Stop + Write-DeployLog " $($policy.Description)" + } catch { + Write-DeployLog " Failed: $($policy.Description) - $($_.Exception.Message)" -IsError + } + } + $SuccessMsg = "SUCCESS: Removed $Removed apps." Write-DeployLog $SuccessMsg @@ -224,7 +252,7 @@ try { Write-Output "This script is available as an optional download: C:\WinDeploy\Download\Fix-Spotlight.ps1" # Note: Bloatware may be reinstalled with future Windows Updates. For more control, consider using Winutil: https://github.com/ChrisTitusTech/winutil - Write-DeployLog "Note: Bloatware may be reinstalled with future Windows Updates. For more control, consider using Winutil: `e]8;;https://github.com/ChrisTitusTech/winutil`e\https://github.com/ChrisTitusTech/winutil`e]8;;`e\" + Write-DeployLog "Note: for further tweaks, the optional WinUtil step (Apply-Tweaks.ps1) uses https://github.com/ChrisTitusTech/winutil" exit 0 } catch { $ErrMsg = $_.Exception.Message diff --git a/Scripts/Deployment/Set-HostName.ps1 b/Scripts/Deployment/Set-HostName.ps1 index 45b6eed..e0e2275 100644 --- a/Scripts/Deployment/Set-HostName.ps1 +++ b/Scripts/Deployment/Set-HostName.ps1 @@ -15,7 +15,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } Write-Output "Setting hostname." diff --git a/Scripts/Deployment/Set-Theme.ps1 b/Scripts/Deployment/Set-Theme.ps1 index c8c4cad..91ea37e 100644 --- a/Scripts/Deployment/Set-Theme.ps1 +++ b/Scripts/Deployment/Set-Theme.ps1 @@ -15,7 +15,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { diff --git a/Scripts/Start.ps1 b/Scripts/Start.ps1 index 163f594..3eef48e 100644 --- a/Scripts/Start.ps1 +++ b/Scripts/Start.ps1 @@ -1,6 +1,11 @@ param( [string]$VersionTag, - [switch]$Relaunched + [switch]$Relaunched, + + # Forwarded to Deploy.ps1: skips every confirmation prompt. Used by the + # autounattend.xml / USB path, which runs in a hidden window where nobody + # can answer a prompt. + [switch]$NonInteractive ) # Fetch latest release with retry logic @@ -12,7 +17,7 @@ function Get-LatestRelease { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Stensel8/WinDeploy/releases/latest" -ErrorAction Stop + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest" -ErrorAction Stop if ($latestRelease.tag_name) { return $latestRelease.tag_name } @@ -45,14 +50,14 @@ if (!$releaseTag) { Write-Host "Releases are required for deployment. Please check:" -ForegroundColor Yellow Write-Host " 1. Your internet connection" -ForegroundColor Yellow Write-Host " 2. GitHub API accessibility" -ForegroundColor Yellow - Write-Host " 3. Repository has published releases: github.com/Stensel8/WinDeploy/releases" -ForegroundColor Yellow + Write-Host " 3. Repository has published releases: github.com/THectic-NL/WinDeploy/releases" -ForegroundColor Yellow exit 1 } # Read version $version = $null try { - $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue + $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue $version = $version.Trim() } catch { Write-Warning "Failed to fetch version information." @@ -215,13 +220,14 @@ if (-not $isAdmin) { $versionArgs = "" if ($VersionTag) { $versionArgs = "-VersionTag '$VersionTag'" } + if ($NonInteractive) { $versionArgs = "$versionArgs -NonInteractive".Trim() } $scriptPath = $PSCommandPath if (-not $scriptPath) { # Script run via iex - download to temp $scriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download Start.ps1: $_" -ForegroundColor Red exit 1 @@ -252,13 +258,14 @@ if (-not $isPwsh7) { $versionArgs = "" if ($VersionTag) { $versionArgs = "-VersionTag '$VersionTag'" } + if ($NonInteractive) { $versionArgs = "$versionArgs -NonInteractive".Trim() } $scriptPath = $PSCommandPath if (-not $scriptPath) { # Script run via iex - download to temp $scriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download Start.ps1: $_" -ForegroundColor Red exit 1 @@ -296,7 +303,7 @@ $downloadSuccess = $false for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { Write-Host "Downloading Deploy.ps1 (Attempt $attempt of $maxRetries)..." -ForegroundColor Cyan - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Deploy.ps1" -OutFile $deployPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Deploy.ps1" -OutFile $deployPath -UseBasicParsing -ErrorAction Stop Write-Host "Downloaded Deploy.ps1 to $deployPath" -ForegroundColor Green $downloadSuccess = $true break @@ -346,7 +353,7 @@ Write-Host "Starting Deploy.ps1..." -ForegroundColor Yellow Write-Host "" try { - & $deployPath + & $deployPath -NonInteractive:$NonInteractive } catch { Write-Host "Deploy.ps1 failed: $_" -ForegroundColor Red Stop-Transcript diff --git a/VERSION b/VERSION index 3d105a6..f979ade 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.7.3 +v0.9.0 diff --git a/renovate.json b/renovate.json index f5467c4..39efb16 100644 --- a/renovate.json +++ b/renovate.json @@ -1,41 +1,94 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ], - "timezone": "Europe/Amsterdam", - "forkProcessing": "enabled", - "pinDigests": true, - "assigneesFromCodeOwners": true, - "reviewersFromCodeOwners": true, - "enabledManagers": [ - "github-actions" - ], - "labels": [ - "dependencies" - ], - "packageRules": [ - { - "matchManagers": [ - "dockerfile", - "docker-compose" - ], - "groupName": "Docker images", - "addLabels": [ - "docker" - ] - }, - { - "matchManagers": [ - "github-actions" - ], - "groupName": "GitHub Actions", - "addLabels": [ - "github-actions" - ] - } - ], - "automerge": true, - "automergeType": "pr", - "semanticCommits": "enabled" +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "timezone": "Europe/Amsterdam", + "forkProcessing": "enabled", + "pinDigests": true, + "assigneesFromCodeOwners": true, + "reviewersFromCodeOwners": true, + "gitIgnoredAuthors": [ + "github-actions[bot]@users.noreply.github.com", + "41898282+github-actions[bot]@users.noreply.github.com" + ], + "enabledManagers": [ + "github-actions", + "gomod", + "custom.regex" + ], + "prHourlyLimit": 2, + "prConcurrentLimit": 5, + "labels": [ + "dependencies" + ], + "packageRules": [ + { + "matchManagers": [ + "dockerfile", + "docker-compose" + ], + "groupName": "Docker images", + "addLabels": [ + "docker" + ] + }, + { + "matchManagers": [ + "github-actions" + ], + "groupName": "GitHub Actions", + "addLabels": [ + "github-actions" + ] + }, + { + "matchManagers": [ + "gomod" + ], + "groupName": "Go modules", + "addLabels": [ + "go" + ] + }, + { + "description": "Versions pinned by hand in the workflows. Not automerged: actionlint and lychee are pinned alongside a checksum that has to be updated in the same PR.", + "matchManagers": [ + "custom.regex" + ], + "groupName": "Build tooling versions", + "addLabels": [ + "build-tooling" + ], + "automerge": false + } + ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.github/workflows/.*\\.ya?ml$/" + ], + "matchStrings": [ + "HUGO_VERSION:\\s*(?\\d+\\.\\d+\\.\\d+)" + ], + "depNameTemplate": "gohugoio/hugo", + "datasourceTemplate": "github-releases", + "versioningTemplate": "semver" + }, + { + "customType": "regex", + "description": "Tool versions pinned in workflows, annotated with a `# renovate:` comment on the line above", + "managerFilePatterns": [ + "/^\\.github/workflows/.*\\.ya?ml$/" + ], + "matchStrings": [ + "# renovate: datasource=(?[a-z-]+) depName=(?\\S+)(?: extractVersion=(?\\S+))?\\s+[A-Za-z_]+: \"(?[^\"]+)\"" + ], + "extractVersionTemplate": "^v?(?.*)$" + } + ], + "automerge": true, + "automergeType": "pr", + "semanticCommits": "enabled" } diff --git a/src/assets/css/custom.css b/src/assets/css/custom.css new file mode 100644 index 0000000..473f0b5 --- /dev/null +++ b/src/assets/css/custom.css @@ -0,0 +1,92 @@ +/* Overrides Hextra's empty custom.css stub. Concatenated after the theme's + precompiled Tailwind bundle without ever going through Tailwind itself, so + plain CSS here is the only reliable way to style markup that isn't part of + the theme's own shipped templates -- see the comment in + layouts/_partials/navbar-title.html. */ + +.brand-lockup { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.brand-mark, +.brand-title { + display: flex; + align-items: center; + transition: opacity 0.15s ease; +} + +.brand-mark { + flex-shrink: 0; +} + +.brand-mark:hover, +.brand-title:hover { + opacity: 0.75; +} + +.brand-title span { + font-weight: 800; + user-select: none; +} + +/* GitHub-style hover card on the monogram tile: appears on hover and on + keyboard focus, after a short delay. Standardised across the THectic repos -- + keep this block together with navbar-title.html. */ +.brand-home { + position: relative; + display: inline-flex; +} + +.brand-home-card { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + z-index: 30; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; + line-height: 1; + font-weight: 600; + white-space: nowrap; + color: #f6f8fa; + background: #1f2328; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + opacity: 0; + transform: translateY(-0.25rem); + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.brand-home-card::before { + content: ""; + position: absolute; + bottom: 100%; + left: 0.75rem; + border: 0.3125rem solid transparent; + border-bottom-color: #1f2328; +} + +.brand-home:hover .brand-home-card, +.brand-home:focus-within .brand-home-card { + opacity: 1; + transform: translateY(0); + transition-delay: 0.35s; +} + +.dark .brand-home-card { + color: #1f2328; + background: #f6f8fa; +} + +.dark .brand-home-card::before { + border-bottom-color: #f6f8fa; +} + +@media (prefers-reduced-motion: reduce) { + .brand-home-card { + transition: none; + } +} diff --git a/src/content/_index.md b/src/content/_index.md new file mode 100644 index 0000000..1b34646 --- /dev/null +++ b/src/content/_index.md @@ -0,0 +1,70 @@ +--- +title: "" +toc: false +--- + +
+{{< hextra/hero-headline >}} + WinDeploy +{{< /hextra/hero-headline >}} +
+ +
+{{< hextra/hero-subtitle >}} + Zero-touch Windows 11 deployment: drivers, applications, bloatware removal, security hardening and BitLocker +{{< /hextra/hero-subtitle >}} +
+ +
+{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy#quick-start" >}} + Quick start + {{< icon name="arrow-right" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy" >}} + View on GitHub + {{< icon name="github" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +
+ +
+ +## Run it + +As Administrator, in PowerShell 7: + +```powershell +iex (irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +``` + +`Start.ps1` elevates if needed, installs PowerShell 7 and WinGet if they are missing, then hands off to `Deploy.ps1`, which downloads and runs each deployment step from that release in sequence. + +{{< callout type="info" >}} +Every run is pinned to one GitHub release tag, start to finish. A deployment that starts against `v0.9.0` keeps using `v0.9.0`'s scripts even if `main` changes mid-run. + +For USB / offline installs via `autounattend.xml`, and the full configuration reference, see the [GitHub repository](https://github.com/THectic-NL/WinDeploy). +{{< /callout >}} + +## What it does + +| Step | | +|---|---| +| RMM agent | Installs a monitoring agent from a USB drive, if present | +| Drivers | Dell Command Update or HP Client Management, model-detected | +| Hardening | SMB signing, LSA protection, HVCI, Defender ASR rules, screen lock — see below | +| Applications | WinGet + Microsoft 365 via CDN/ODT | +| Bloatware removal | Removes consumer apps, blocks their reinstall via policy | +| Tweaks *(opt-in)* | A [WinUtil](https://github.com/ChrisTitusTech/winutil) preset, behind a Y/N prompt | +| Theme, hostname | Dark mode, `PC-` naming | +| Windows Update | Installs everything available | + +Two steps ask before they run: **BitLocker** and the **WinUtil tweaks**. Both time out after 90 seconds and default to no, so an unattended run never stalls. Pass `-NonInteractive` to skip both outright. + +## Security hardening + +Applied automatically: SMBv1 removed, SMB signing required, insecure guest logons blocked, LSA protection (RunAsPPL), WDigest plaintext credential caching disabled, anonymous SAM/share enumeration restricted, LLMNR disabled, memory integrity (HVCI), 9 Defender Attack Surface Reduction rules, AutoRun disabled, secure screen lock. + +BitLocker, on confirmation, encrypts `C:` with XTS-AES-256, creates a recovery password, saves it to the operator's Documents folder and prints it on screen. + +## Credits + +Built on [PowerShell](https://github.com/PowerShell/PowerShell), [WinGet](https://github.com/microsoft/winget-cli), [PSWindowsUpdate](https://www.powershellgallery.com/packages/PSWindowsUpdate), [winget-install](https://github.com/asheroto/winget-install) by asheroto, and — for the optional tweaks step — [WinUtil](https://github.com/ChrisTitusTech/winutil) by Chris Titus. diff --git a/src/content/_index.nl.md b/src/content/_index.nl.md new file mode 100644 index 0000000..34cccaa --- /dev/null +++ b/src/content/_index.nl.md @@ -0,0 +1,70 @@ +--- +title: "" +toc: false +--- + +
+{{< hextra/hero-headline >}} + WinDeploy +{{< /hextra/hero-headline >}} +
+ +
+{{< hextra/hero-subtitle >}} + Zero-touch Windows 11-deployment: drivers, applicaties, bloatware verwijderen, security hardening en BitLocker +{{< /hextra/hero-subtitle >}} +
+ +
+{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy#quick-start" >}} + Snel starten + {{< icon name="arrow-right" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy" >}} + Bekijk op GitHub + {{< icon name="github" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +
+ +
+ +## Uitvoeren + +Als Administrator, in PowerShell 7: + +```powershell +iex (irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +``` + +`Start.ps1` elevate't indien nodig, installeert PowerShell 7 en WinGet als die ontbreken, en geeft daarna door aan `Deploy.ps1`, dat elke deploymentstap van die release na elkaar downloadt en uitvoert. + +{{< callout type="info" >}} +Elke run staat vast op één GitHub release tag, van begin tot eind. Een deployment die start tegen `v0.9.0` blijft de scripts van `v0.9.0` gebruiken, ook als `main` tijdens de run verandert. + +Voor USB/offline installaties via `autounattend.xml`, en de volledige configuratiereferentie, zie de [GitHub-repository](https://github.com/THectic-NL/WinDeploy). +{{< /callout >}} + +## Wat het doet + +| Stap | | +|---|---| +| RMM-agent | Installeert een monitoring agent vanaf een USB-stick, indien aanwezig | +| Drivers | Dell Command Update of HP Client Management, gedetecteerd op model | +| Hardening | SMB signing, LSA-protection, HVCI, Defender ASR-regels, schermvergrendeling — zie hieronder | +| Applicaties | WinGet + Microsoft 365 via CDN/ODT | +| Bloatware verwijderen | Verwijdert consumer-apps, blokkeert herinstallatie via policy | +| Tweaks *(optioneel)* | Een [WinUtil](https://github.com/ChrisTitusTech/winutil)-preset, achter een Y/N-prompt | +| Thema, hostnaam | Dark mode, `PC-`-naamgeving | +| Windows Update | Installeert alles wat beschikbaar is | + +Twee stappen vragen eerst om bevestiging: **BitLocker** en de **WinUtil-tweaks**. Beide lopen na 90 seconden af en kiezen dan standaard nee, zodat een onbeheerde run nooit vastloopt. Geef `-NonInteractive` mee om beide direct over te slaan. + +## Security hardening + +Automatisch toegepast: SMBv1 verwijderd, SMB signing verplicht, onveilige guest-logons geblokkeerd, LSA-protection (RunAsPPL), WDigest plaintext-credentialcaching uitgeschakeld, anonieme SAM/share-enumeratie beperkt, LLMNR uitgeschakeld, memory integrity (HVCI), 9 Defender Attack Surface Reduction-regels, AutoRun uitgeschakeld, beveiligde schermvergrendeling. + +BitLocker versleutelt, na bevestiging, `C:` met XTS-AES-256, maakt een recovery password aan, slaat dit op in de Documenten-map van de operator en toont het op het scherm. + +## Met dank aan + +Gebouwd op [PowerShell](https://github.com/PowerShell/PowerShell), [WinGet](https://github.com/microsoft/winget-cli), [PSWindowsUpdate](https://www.powershellgallery.com/packages/PSWindowsUpdate), [winget-install](https://github.com/asheroto/winget-install) van asheroto, en — voor de optionele tweaks-stap — [WinUtil](https://github.com/ChrisTitusTech/winutil) van Chris Titus. diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..2385528 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,5 @@ +module github.com/THectic-NL/WinDeploy + +go 1.26 + +require github.com/imfing/hextra v0.12.3 // indirect diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..afa8680 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,2 @@ +github.com/imfing/hextra v0.12.3 h1:DZHY2rUWYteyzjlHi9r4n7Bb5e2Q+6LXe4C1Dqn0ZjM= +github.com/imfing/hextra v0.12.3/go.mod h1:vi+yhpq8YPp/aghvJlNKVnJKcPJ/VyAEcfC1BSV9ARo= diff --git a/src/hugo.toml b/src/hugo.toml new file mode 100644 index 0000000..baf315d --- /dev/null +++ b/src/hugo.toml @@ -0,0 +1,73 @@ +baseURL = 'https://windeploy.thectic.nl/' +title = 'WinDeploy' +defaultContentLanguage = 'en' +enableRobotsTXT = true +disableKinds = ['taxonomy', 'term', 'RSS'] +disableHugoGeneratorInject = true + +[languages] + [languages.en] + label = 'English' + weight = 1 + title = 'WinDeploy' + locale = 'en-US' + [languages.nl] + label = 'Nederlands' + weight = 2 + title = 'WinDeploy' + locale = 'nl-NL' + +[menu] + [[menu.main]] + name = 'GitHub' + weight = 1 + url = 'https://github.com/THectic-NL/WinDeploy' + [menu.main.params] + icon = 'github' + [[menu.main]] + name = 'Language' + weight = 2 + [menu.main.params] + type = 'language-switch' + [[menu.main]] + name = 'Theme' + weight = 3 + [menu.main.params] + type = 'theme-toggle' + +[params] + description = 'Zero-touch Windows 11 deployment: drivers, applications, bloatware removal, security hardening and BitLocker, orchestrated from PowerShell 7' + copyright = 'THectic' + + [params.navbar] + displayTitle = true + displayLogo = false + width = 'wide' + + [params.theme] + default = 'system' + displayToggle = false + + [params.footer] + enable = true + displayCopyright = false + displayPoweredBy = false + width = 'normal' + + displayUpdatedDate = true + dateFormat = 'January 2, 2006' + + [params.editURL] + enable = false + + [params.page] + width = 'wide' + +[markup] + [markup.goldmark] + [markup.goldmark.renderer] + unsafe = true + +[module] + [[module.imports]] + path = "github.com/imfing/hextra" diff --git a/src/layouts/_partials/custom/footer.html b/src/layouts/_partials/custom/footer.html new file mode 100644 index 0000000..5b860e0 --- /dev/null +++ b/src/layouts/_partials/custom/footer.html @@ -0,0 +1,4 @@ +
+ © {{ now.Year }} + THectic +
diff --git a/src/layouts/_partials/footer.html b/src/layouts/_partials/footer.html new file mode 100644 index 0000000..f197303 --- /dev/null +++ b/src/layouts/_partials/footer.html @@ -0,0 +1,35 @@ +{{- /* Overrides the Hextra partial to drop the footer's language/theme + switches. Both already live in the navbar (GitHub, Language, Theme menu + items), and on pages without a sidebar Hextra otherwise repeats the + language switch down here. Everything else matches the theme version. */ -}} +{{- $copyrightSectionVisible := or (.Site.Params.footer.displayPoweredBy | default true) .Site.Params.footer.displayCopyright -}} + +{{- $copyright := (T "copyright") | default "© 2024 Hextra." -}} +{{- $poweredBy := (T "poweredBy") | default "Powered by Hextra" -}} + +
+ + {{- if $copyrightSectionVisible -}} + + {{- end -}} +
+ +{{- define "theme-credit" -}} + + + {{- . | markdownify -}} + {{- if strings.Contains . "Hextra" -}} + {{- partial "utils/icon.html" (dict "name" "hextra" "attributes" `height=1em class="hx:inline-block hx:ltr:ml-1 hx:rtl:mr-1 hx:align-[-2.5px]"`) -}} + {{- end -}} + + +{{- end -}} diff --git a/src/layouts/_partials/navbar-title.html b/src/layouts/_partials/navbar-title.html new file mode 100644 index 0000000..f3515a8 --- /dev/null +++ b/src/layouts/_partials/navbar-title.html @@ -0,0 +1,38 @@ +{{- /* Overrides the Hextra partial. The navbar's left edge is a logo lockup: + the THectic monogram tile links to the umbrella site (thectic.nl, which + fronts every project subdomain) and shows a GitHub-style hover card + naming that destination; the wordmark links to this site's own home. + + Standardised across the THectic project repos: keep this file byte-for- + byte identical everywhere, and onboard a new repo by copying it in + alongside the .brand-* block in assets/css/custom.css. + + The monogram is inlined twice, one variant per theme, so the tile stays + readable on both the light and the near-black navbar. Its plate colour + and glyph path match THectic.nl's own brand mark. + + Layout lives in custom.css rather than Tailwind utility classes: Hextra + ships a precompiled bundle and never reruns Tailwind against the site's + own templates, so a `hx:` class not already used by the theme itself + (gap-2.5, hover:opacity-80, ...) silently compiles to nothing. */ -}} +{{- $displayTitle := .Site.Params.navbar.displayTitle | default true -}} +
+ + + + + + + + + + + + + + {{- if $displayTitle }} + + {{- .Site.Title -}} + + {{- end }} +
diff --git a/src/static/robots.txt b/src/static/robots.txt new file mode 100644 index 0000000..998efe4 --- /dev/null +++ b/src/static/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://windeploy.thectic.nl/sitemap.xml