From b2fed6c373f35ef33c380a3f2222d117501f4b45 Mon Sep 17 00:00:00 2001 From: Blance Date: Thu, 16 Apr 2026 21:25:49 -0500 Subject: [PATCH] Initial commit: Send-FilesToSftp.ps1 and README --- README.md | 191 ++++++++++++++++++++++++ Send-FilesToSftp.ps1 | 346 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 README.md create mode 100644 Send-FilesToSftp.ps1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a31551c --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Send-FilesToSftp.ps1 + +PowerShell script to transfer files to an SFTP server with regex filtering, secure credential handling, and logging. + +## Prerequisites + +**WinSCP .NET Assembly** (`WinSCPnet.dll`) is required. Install using one of these methods: + +| Method | Steps | +|--------|-------| +| **Drop-in** | Download the [.NET assembly package](https://winscp.net/eng/downloads.php), extract `WinSCPnet.dll` next to the script | +| **NuGet** | `Install-Package WinSCP -Source nuget.org` | +| **WinSCP Installer** | Install WinSCP and check the ".NET assembly" option during setup | + +The script auto-searches these locations in order: +1. `-WinScpDllPath` parameter (if provided) +2. Same directory as the script +3. `lib\` subdirectory of the script folder +4. `C:\Program Files (x86)\WinSCP\` +5. `C:\Program Files\WinSCP\` +6. NuGet package cache (`~\.nuget\packages\winscp`) + +## Quick Start + +```powershell +# Basic usage — prompted for password interactively +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" +``` + +## Parameters + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `-LocalPath` | Yes | — | Local source folder to scan | +| `-RemotePath` | Yes | — | Remote SFTP destination folder | +| `-HostName` | Yes | — | SFTP server hostname or IP | +| `-UserName` | Yes | — | SFTP username | +| `-FileFilter` | No | `.*` | Regex pattern to match filenames | +| `-Port` | No | `22` | SFTP port | +| `-Credential` | No | — | `PSCredential` object | +| `-CredentialFile` | No | — | Path to saved credential XML (see below) | +| `-KeyFilePath` | No | — | Path to SSH private key (`.ppk`) | +| `-SshHostKeyFingerprint` | No | — | SSH host key fingerprint for verification | +| `-Recurse` | No | `false` | Scan subdirectories | +| `-DeleteAfterTransfer` | No | `false` | Delete local files after successful upload | +| `-DryRun` | No | `false` | Preview transfers without uploading | +| `-LogFile` | No | — | Path to log file (logs to console if omitted) | +| `-WinScpDllPath` | No | — | Explicit path to `WinSCPnet.dll` | + +## Authentication + +The script supports three auth methods, checked in this order: + +### 1. Credential Object (interactive or pipeline) +```powershell +$cred = Get-Credential +.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" ` + -HostName "sftp.example.com" -UserName "brad" -Credential $cred +``` + +### 2. Saved Credential File (unattended / scheduled tasks) +```powershell +# One-time setup — run as the same user that will run the scheduled task +Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml" + +# Use in script +.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" ` + -HostName "sftp.example.com" -UserName "svc_upload" ` + -CredentialFile "C:\secure\sftp_cred.xml" +``` + +> **Note:** `Export-Clixml` encrypts credentials using Windows DPAPI, tied to the user account and machine that created the file. Only that same user on that same machine can decrypt it. + +### 3. SSH Private Key +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" ` + -HostName "sftp.example.com" -UserName "svc_upload" ` + -KeyFilePath "C:\keys\id_rsa.ppk" +``` + +### 4. Interactive Prompt +If none of the above are provided and no key file is specified, you'll be prompted for a password at runtime. + +## File Filtering (Regex) + +The `-FileFilter` parameter uses PowerShell regex (case-insensitive by default). + +| Filter | Matches | +|--------|---------| +| `'\.csv$'` | All CSV files | +| `'\.xlsx?$'` | `.xls` and `.xlsx` files | +| `'^report_\d{8}'` | Files starting with `report_` + 8 digits (e.g. `report_20260415.csv`) | +| `'(?-i)^Data'` | Files starting with `Data` (case-sensitive) | +| `'badge.*\.xlsx$'` | Excel files with `badge` anywhere in the name | +| `'\.(csv\|txt)$'` | CSV or TXT files | +| `'.*'` | Everything (default) | + +## Usage Examples + +### Transfer all CSVs +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$' +``` + +### Move files (delete after upload) with logging +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "svc_upload" ` + -CredentialFile "C:\secure\cred.xml" ` + -DeleteAfterTransfer -LogFile "C:\logs\sftp_transfer.log" +``` + +### Dry run — preview without transferring +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" -DryRun +``` + +### Recursive with subdirectories +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\data\projects" -RemotePath "/archive" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -Recurse -FileFilter '\.pdf$' +``` + +## SSH Host Key Fingerprint + +For production use, always provide the host key fingerprint to prevent MITM attacks: + +```powershell +# Get the fingerprint from the server +ssh-keyscan sftp.example.com | ssh-keygen -lf - + +# Use it in the script +.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -SshHostKeyFingerprint "ssh-rsa 2048 aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99" +``` + +Pass `"*"` to accept any key (development/testing only — **not recommended for production**). + +## Scheduled Task Setup + +To run unattended via Windows Task Scheduler: + +1. **Save credentials** as the service account user: + ```powershell + Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml" + ``` + +2. **Create the scheduled task:** + ```powershell + $action = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\scripts\Send-FilesToSftp.ps1" -LocalPath "C:\exports" -RemotePath "/incoming" -HostName "sftp.example.com" -UserName "svc_upload" -CredentialFile "C:\secure\sftp_cred.xml" -FileFilter "\.csv$" -DeleteAfterTransfer -LogFile "C:\logs\sftp.log"' + + $trigger = New-ScheduledTaskTrigger -Daily -At "2:00AM" + + Register-ScheduledTask -TaskName "SFTP Upload" ` + -Action $action -Trigger $trigger ` + -User "DOMAIN\svc_upload" -Password "****" ` + -RunLevel Highest + ``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | All files transferred (or no files matched filter) | +| `1` | One or more failures occurred | + +## Log Output + +Logs include timestamps and severity levels. Sample output: + +``` +[2026-04-16 14:30:01] [INFO] ═══ SFTP Transfer Starting ═══ +[2026-04-16 14:30:01] [INFO] Local path : C:\exports +[2026-04-16 14:30:01] [INFO] Remote path : /incoming +[2026-04-16 14:30:01] [INFO] File filter : \.csv$ +[2026-04-16 14:30:01] [INFO] Found 3 file(s) matching filter +[2026-04-16 14:30:02] [SUCCESS] Connected to sftp.example.com +[2026-04-16 14:30:03] [SUCCESS] Transferred: report_20260415.csv → /incoming/report_20260415.csv (42.3 KB) +[2026-04-16 14:30:03] [SUCCESS] Transferred: badges_export.csv → /incoming/badges_export.csv (18.1 KB) +[2026-04-16 14:30:04] [SUCCESS] Transferred: access_log.csv → /incoming/access_log.csv (7.8 KB) +[2026-04-16 14:30:04] [INFO] ═══ Transfer Complete ═══ +[2026-04-16 14:30:04] [INFO] Succeeded : 3 +[2026-04-16 14:30:04] [INFO] Mode : COPY (source files retained) +``` diff --git a/Send-FilesToSftp.ps1 b/Send-FilesToSftp.ps1 new file mode 100644 index 0000000..53f46e1 --- /dev/null +++ b/Send-FilesToSftp.ps1 @@ -0,0 +1,346 @@ +<# +.SYNOPSIS + Transfers files to an SFTP server with filtering and logging. + +.DESCRIPTION + Moves or copies files from a local folder to a remote SFTP destination. + Supports regex/wildcard file filtering, recursive scanning, and secure + credential storage. Uses the WinSCP .NET assembly for SFTP transport. + +.PARAMETER LocalPath + Local source folder to scan for files. + +.PARAMETER RemotePath + Remote SFTP destination folder (e.g. /uploads/incoming). + +.PARAMETER FileFilter + Regex pattern to match filenames (e.g. '\.csv$' or '^report_\d{8}'). + Default: '.*' (all files). + +.PARAMETER HostName + SFTP server hostname or IP. + +.PARAMETER Port + SFTP port. Default: 22. + +.PARAMETER UserName + SFTP username. + +.PARAMETER SshHostKeyFingerprint + SSH host key fingerprint for server verification. + Use "ssh-rsa 2048 xx:xx:xx..." format, or pass "*" to accept any (NOT recommended for production). + +.PARAMETER Credential + PSCredential object. If omitted, you'll be prompted interactively. + +.PARAMETER CredentialFile + Path to a saved credential file (Export-Clixml). For scheduled/unattended runs. + Create one with: Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml" + +.PARAMETER KeyFilePath + Path to a private key file for key-based auth (optional). + +.PARAMETER Recurse + Scan subdirectories in LocalPath. + +.PARAMETER DeleteAfterTransfer + Delete local files after successful upload (move behavior). + +.PARAMETER DryRun + Show what would be transferred without actually uploading. + +.PARAMETER LogFile + Path to a log file. If omitted, logs to console only. + +.PARAMETER WinScpDllPath + Path to WinSCPnet.dll. Default: looks in script directory, then common install paths. + +.EXAMPLE + # Interactive - transfer all CSVs + .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$' + +.EXAMPLE + # Unattended with saved credentials and move behavior + .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -CredentialFile "C:\secure\sftp_cred.xml" ` + -FileFilter '^report_\d{8}' -DeleteAfterTransfer -LogFile "C:\logs\sftp.log" + +.EXAMPLE + # Dry run to preview what would transfer + .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" -DryRun + +.EXAMPLE + # Key-based auth, recursive scan + .\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/archive" ` + -HostName "sftp.example.com" -UserName "svcaccount" ` + -KeyFilePath "C:\keys\id_rsa.ppk" -Recurse +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string]$LocalPath, + + [Parameter(Mandatory)] + [string]$RemotePath, + + [string]$FileFilter = '.*', + + [Parameter(Mandatory)] + [string]$HostName, + + [int]$Port = 22, + + [Parameter(Mandatory)] + [string]$UserName, + + [string]$SshHostKeyFingerprint = $null, + + [PSCredential]$Credential, + + [string]$CredentialFile, + + [string]$KeyFilePath, + + [switch]$Recurse, + + [switch]$DeleteAfterTransfer, + + [switch]$DryRun, + + [string]$LogFile, + + [string]$WinScpDllPath +) + +# ── Logging ────────────────────────────────────────────────────────────────── + +function Write-Log { + param( + [string]$Message, + [ValidateSet('INFO','WARN','ERROR','SUCCESS')] + [string]$Level = 'INFO' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $entry = "[$timestamp] [$Level] $Message" + + switch ($Level) { + 'ERROR' { Write-Host $entry -ForegroundColor Red } + 'WARN' { Write-Host $entry -ForegroundColor Yellow } + 'SUCCESS' { Write-Host $entry -ForegroundColor Green } + default { Write-Host $entry } + } + + if ($LogFile) { + $entry | Out-File -FilePath $LogFile -Append -Encoding utf8 + } +} + +# ── Locate WinSCP .NET Assembly ────────────────────────────────────────────── + +function Find-WinScpDll { + $searchPaths = @( + $WinScpDllPath + (Join-Path $PSScriptRoot 'WinSCPnet.dll') + (Join-Path $PSScriptRoot 'lib\WinSCPnet.dll') + 'C:\Program Files (x86)\WinSCP\WinSCPnet.dll' + 'C:\Program Files\WinSCP\WinSCPnet.dll' + ) | Where-Object { $_ } + + foreach ($path in $searchPaths) { + if (Test-Path $path) { + return $path + } + } + + # Try NuGet package in common locations + $nugetPath = Get-ChildItem -Path "$env:USERPROFILE\.nuget\packages\winscp" -Filter 'WinSCPnet.dll' -Recurse -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($nugetPath) { return $nugetPath.FullName } + + return $null +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +try { + Write-Log "═══ SFTP Transfer Starting ═══" + Write-Log "Local path : $LocalPath" + Write-Log "Remote path : $RemotePath" + Write-Log "File filter : $FileFilter" + Write-Log "Host : ${HostName}:${Port}" + if ($DryRun) { Write-Log "*** DRY RUN MODE - No files will be transferred ***" -Level WARN } + + # ── Find and load WinSCP ───────────────────────────────────────────── + $dllPath = Find-WinScpDll + if (-not $dllPath) { + Write-Log "WinSCPnet.dll not found. Install options:" -Level ERROR + Write-Log " 1) Install-Package WinSCP -Source nuget.org" -Level ERROR + Write-Log " 2) Download from https://winscp.net/eng/downloads.php (.NET assembly)" -Level ERROR + Write-Log " 3) Place WinSCPnet.dll in the same folder as this script" -Level ERROR + exit 1 + } + Add-Type -Path $dllPath + Write-Log "Loaded WinSCP from: $dllPath" + + # ── Resolve credentials ────────────────────────────────────────────── + $password = $null + + if ($Credential) { + $password = $Credential.GetNetworkCredential().Password + } + elseif ($CredentialFile) { + if (-not (Test-Path $CredentialFile)) { + Write-Log "Credential file not found: $CredentialFile" -Level ERROR + exit 1 + } + $savedCred = Import-Clixml -Path $CredentialFile + $password = $savedCred.GetNetworkCredential().Password + Write-Log "Loaded credentials from file" + } + elseif (-not $KeyFilePath) { + $promptCred = Get-Credential -UserName $UserName -Message "Enter SFTP password for $HostName" + if (-not $promptCred) { + Write-Log "No credentials provided. Aborting." -Level ERROR + exit 1 + } + $password = $promptCred.GetNetworkCredential().Password + } + + # ── Collect files ──────────────────────────────────────────────────── + $gciParams = @{ Path = $LocalPath; File = $true } + if ($Recurse) { $gciParams['Recurse'] = $true } + + $allFiles = Get-ChildItem @gciParams | Where-Object { $_.Name -match $FileFilter } + + if (-not $allFiles -or $allFiles.Count -eq 0) { + Write-Log "No files matched filter '$FileFilter' in $LocalPath" -Level WARN + exit 0 + } + + Write-Log "Found $($allFiles.Count) file(s) matching filter" + + if ($DryRun) { + Write-Log "Files that would be transferred:" -Level INFO + foreach ($f in $allFiles) { + $relativePath = $f.FullName.Substring($LocalPath.TrimEnd('\').Length) + $remoteDest = ($RemotePath.TrimEnd('/') + $relativePath) -replace '\\', '/' + $sizeKB = [math]::Round($f.Length / 1KB, 1) + Write-Log " $($f.FullName) → $remoteDest (${sizeKB} KB)" + } + Write-Log "DRY RUN complete. $($allFiles.Count) file(s) would be transferred." -Level SUCCESS + exit 0 + } + + # ── Configure session ──────────────────────────────────────────────── + $sessionOptions = New-Object WinSCP.SessionOptions -Property @{ + Protocol = [WinSCP.Protocol]::Sftp + HostName = $HostName + PortNumber = $Port + UserName = $UserName + } + + if ($password) { + $sessionOptions.Password = $password + } + + if ($KeyFilePath) { + if (-not (Test-Path $KeyFilePath)) { + Write-Log "Private key file not found: $KeyFilePath" -Level ERROR + exit 1 + } + $sessionOptions.SshPrivateKeyPath = $KeyFilePath + Write-Log "Using key-based auth: $KeyFilePath" + } + + if ($SshHostKeyFingerprint) { + if ($SshHostKeyFingerprint -eq '*') { + Write-Log "Accepting any SSH host key — NOT SAFE FOR PRODUCTION" -Level WARN + $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true + } + else { + $sessionOptions.SshHostKeyFingerprint = $SshHostKeyFingerprint + } + } + else { + Write-Log "No SSH host key fingerprint provided — accepting any key" -Level WARN + Write-Log " Get your fingerprint with: ssh-keyscan $HostName | ssh-keygen -lf -" -Level WARN + $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true + } + + # ── Open session and transfer ──────────────────────────────────────── + $session = New-Object WinSCP.Session + $successCount = 0 + $failCount = 0 + + try { + $session.Open($sessionOptions) + Write-Log "Connected to $HostName" -Level SUCCESS + + # Ensure remote directory exists + if (-not $session.FileExists($RemotePath)) { + Write-Log "Creating remote directory: $RemotePath" + $session.CreateDirectory($RemotePath) + } + + foreach ($file in $allFiles) { + $relativePath = '' + if ($Recurse) { + $relativePath = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/' + } + + $targetDir = $RemotePath.TrimEnd('/') + $relativePath + $targetPath = "$targetDir/$($file.Name)" + + try { + # Ensure subdirectory exists on remote when recursing + if ($Recurse -and $relativePath -and (-not $session.FileExists($targetDir))) { + $session.CreateDirectory($targetDir) + Write-Log "Created remote dir: $targetDir" + } + + $transferOptions = New-Object WinSCP.TransferOptions + $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary + + $result = $session.PutFiles($file.FullName, $targetPath, $DeleteAfterTransfer, $transferOptions) + $result.Check() + + $sizeKB = [math]::Round($file.Length / 1KB, 1) + Write-Log "Transferred: $($file.Name) → $targetPath (${sizeKB} KB)" -Level SUCCESS + $successCount++ + } + catch { + Write-Log "FAILED: $($file.Name) — $($_.Exception.Message)" -Level ERROR + $failCount++ + } + } + } + finally { + if ($session) { $session.Dispose() } + } + + # ── Summary ────────────────────────────────────────────────────────── + Write-Log "═══ Transfer Complete ═══" + Write-Log " Succeeded : $successCount" + if ($failCount -gt 0) { + Write-Log " Failed : $failCount" -Level ERROR + } + if ($DeleteAfterTransfer) { + Write-Log " Mode : MOVE (source files deleted on success)" + } + else { + Write-Log " Mode : COPY (source files retained)" + } + + if ($failCount -gt 0) { exit 1 } +} +catch { + Write-Log "Fatal error: $($_.Exception.Message)" -Level ERROR + Write-Log $_.ScriptStackTrace -Level ERROR + exit 1 +}