@methodwhite/auto-hardening 1.0.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ ## 1.7.2 - 2026-08-02
4
+
5
+ - Añadido workflow de publicación npm mediante Trusted Publishing/OIDC.
6
+ - La publicación de releases se valida automáticamente antes de ejecutarse.
7
+
8
+ ## 1.7.1 - 2026-08-02
9
+
10
+ - Corregida la auditoría cuando el módulo `Microsoft.PowerShell.Security` no puede cargarse.
11
+ - La ausencia de `Get-ExecutionPolicy` se reporta como `Unavailable` sin interrumpir el análisis.
12
+
13
+ ## 1.7.0 - 2026-08-02
14
+
15
+ - Añadido inventario detallado de Scheduled Tasks.
16
+ - Añadida detección de persistencia WMI, trabajos BITS y perfiles de PowerShell.
17
+ - Añadidos hash SHA-256 y estado de firma Authenticode para archivos detectados.
18
+ - Añadida detección de políticas administradas por GPO/MDM.
19
+ - Añadida clasificación de perfiles de red.
20
+ - `--apply --yes` bloquea cambios locales cuando existen políticas administradas.
21
+ - Limitado el volumen del escaneo para evitar falsos positivos y `ENOBUFS`.
22
+
23
+ ## 1.6.0 - 2026-08-02
24
+
25
+ - Añadida auditoría de BitLocker, TPM y Secure Boot.
26
+ - Añadida auditoría de RDP/NLA, SMBv1 y firma SMB.
27
+ - Los controles de plataforma permanecen en modo `audit-only` y no se aplican automáticamente.
28
+ - Corregida la ejecución de consultas PowerShell con bloques `if/else` en Windows.
29
+
30
+ ## 1.5.0 - 2026-08-02
31
+
32
+ - Añadido detector de perfil para workstation, servidor miembro y Domain Controller.
33
+ - Añadidas auditorías de Firewall, Defender, Windows Update, persistencia y archivos de riesgo.
34
+ - Añadidos modos `--audit`, `--plan`, `--status`, `--capabilities` y `--apply --yes`.
35
+ - Añadida cuarentena reversible de archivos, tareas programadas, servicios y claves Run/RunOnce.
36
+ - Añadidos backups automáticos y rollback de Firewall y preferencias soportadas de Defender.
37
+ - Corregida la restauración de rutas de Scheduled Tasks en Windows.
38
+ - Añadida CI Linux + Windows y documentación de límites operativos.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MethodWhite Security
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @methodwhite/auto-hardening
2
+
3
+ CLI defensivo para **auditar** y preparar medidas de hardening en Windows. El modo predeterminado es seguro y no modifica el sistema. Detecta workstation, servidor, dominio, Active Directory y Domain Controller antes de aplicar políticas.
4
+
5
+ > Este proyecto no sustituye una política corporativa, un backup ni una evaluación profesional. Prueba cualquier cambio en una máquina o VM de laboratorio antes de aplicarlo.
6
+
7
+ ## Requisitos
8
+
9
+ - Node.js `>=18.18.0`
10
+ - Windows para las comprobaciones específicas del sistema
11
+ - Consola elevada solo para futuras operaciones de aplicación
12
+
13
+ ## Uso
14
+
15
+ ```bash
16
+ npx @methodwhite/auto-hardening --audit
17
+ npx @methodwhite/auto-hardening --capabilities
18
+ npx @methodwhite/auto-hardening --persistence
19
+ npx @methodwhite/auto-hardening --files
20
+ npx @methodwhite/auto-hardening --quarantine <finding-id> --yes
21
+ npx @methodwhite/auto-hardening --restore <finding-id>
22
+ npx @methodwhite/auto-hardening --audit --json
23
+ npx @methodwhite/auto-hardening --plan
24
+ npx @methodwhite/auto-hardening --apply --yes
25
+ npx @methodwhite/auto-hardening --rollback <backup-id>
26
+ npm run lint
27
+ npm test
28
+ ```
29
+
30
+ `--full` está deliberadamente protegido: no aplica políticas silenciosamente. Las políticas se habilitarán una a una después de documentar su impacto, reversión y test correspondiente. `--apply --yes` requiere Windows, una consola elevada y un backup automático; los Domain Controllers permanecen en modo `audit-only`.
31
+
32
+ La CI ejecuta validación en Ubuntu y un smoke test en Windows. La prueba de CI en Windows no reemplaza una VM de laboratorio con políticas de dominio, Defender Tamper Protection y GPO corporativas.
33
+
34
+ ## Principios
35
+
36
+ - Dry-run por defecto.
37
+ - Fail-safe: errores no se interpretan como sistema seguro.
38
+ - Sin telemetría ni red saliente propia.
39
+ - Sin secretos ni credenciales.
40
+ - Cambios explícitos, reversibles y auditables.
41
+ - Compatible con el Tier S++ SecDevOps del ecosistema MethodWhite.
42
+ - Audita BitLocker, TPM, Secure Boot, RDP/NLA, SMBv1 y firma SMB sin modificarlos automáticamente.
43
+
44
+ ## Desarrollo
45
+
46
+ ```bash
47
+ npm install
48
+ npm run lint
49
+ npm test
50
+ npm pack --dry-run
51
+ ```
52
+
53
+ ## Licencia
54
+
55
+ MIT. Ver [`LICENSE`](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,5 @@
1
+ # Security Policy
2
+
3
+ Report vulnerabilities privately to the repository maintainer. Do not include secrets, personal data or exploit payloads in public issues.
4
+
5
+ The tool is dry-run by default. Any future hardening policy must include a test, rollback procedure, documentation and an explicit opt-in.
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { randomUUID, createHash } from 'node:crypto';
8
+ import chalk from 'chalk';
9
+
10
+ const VERSION = '1.7.2';
11
+ const quarantineDir = process.env.PROGRAMDATA ? path.join(process.env.PROGRAMDATA, 'MethodWhite', 'auto-hardening', 'quarantine') : path.join(os.tmpdir(), 'methodwhite-auto-hardening', 'quarantine');
12
+ const stateDir = process.env.PROGRAMDATA ? path.join(process.env.PROGRAMDATA, 'MethodWhite', 'auto-hardening') : path.join(os.tmpdir(), 'methodwhite-auto-hardening');
13
+ const powershell = (script) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
14
+ const isWindows = process.platform === 'win32';
15
+ const profiles = {
16
+ 'workstation-home': { id: 'workstation-home', scope: 'workstation', mode: 'apply-safe' },
17
+ 'workstation-domain': { id: 'workstation-domain', scope: 'domain-member', mode: 'apply-safe' },
18
+ 'server-member': { id: 'server-member', scope: 'member-server', mode: 'apply-safe' },
19
+ 'domain-controller': { id: 'domain-controller', scope: 'domain-controller', mode: 'audit-only' },
20
+ };
21
+
22
+ function usage() {
23
+ console.log(`auto-hardening ${VERSION}\n\nUsage:\n auto-hardening --audit [--json] Inspect the current posture\n auto-hardening --plan Show changes without applying them\n auto-hardening --apply --yes Apply reversible policies\n auto-hardening --status Show supported policy status
24
+ auto-hardening --capabilities Detect OS, domain, roles and network profiles\n auto-hardening --rollback <id> Restore a previous backup\n auto-hardening --help Show this help`);
25
+ }
26
+
27
+ function requireWindows() { if (!isWindows) throw new Error('Esta operación solo puede ejecutarse en Windows.'); }
28
+ function requireAdmin() {
29
+ requireWindows();
30
+ const elevated = powershell('[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator');
31
+ if (elevated.toLowerCase() !== 'true') throw new Error('Abre PowerShell como Administrador.');
32
+ }
33
+ function psJson(script) { return JSON.parse(powershell(`${script} | ConvertTo-Json -Depth 8 -Compress`)); }
34
+ function ensureStateDir() { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); }
35
+
36
+ function detectSystem() {
37
+ if (!isWindows) return { platform: process.platform, profile: 'non-windows', isServer: false, domainJoined: false, domainController: false, networkProfiles: [] };
38
+ const computer = psJson('Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, DomainRole, Manufacturer, Model');
39
+ const osInfo = psJson('Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, ProductType');
40
+ const network = psJson('Get-NetConnectionProfile | Select-Object Name, InterfaceAlias, NetworkCategory, IPv4Connectivity');
41
+ const rolesRaw = psJson("& { if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) { Get-WindowsFeature | Where-Object {$_.InstallState -eq 'Installed'} | Select-Object Name, DisplayName } else { @() } }");
42
+ const roles = (Array.isArray(rolesRaw) ? rolesRaw : [rolesRaw]).filter(Boolean);
43
+ const domainController = [4, 5].includes(Number(computer.DomainRole));
44
+ const isServer = Number(osInfo.ProductType) !== 1;
45
+ const domainJoined = Boolean(computer.PartOfDomain);
46
+ const networkProfiles = Array.isArray(network) ? network : [network];
47
+ let profile = domainController ? 'domain-controller' : isServer && domainJoined ? 'server-member' : domainJoined ? 'workstation-domain' : 'workstation-home';
48
+ return { platform: 'win32', hostname: computer.Name, os: osInfo.Caption, version: osInfo.Version, build: osInfo.BuildNumber, isServer, domainJoined, domain: domainJoined ? computer.Domain : null, domainController, profile, networkProfiles, roles: roles.map((r) => r.Name).filter(Boolean), adDetected: domainController || roles.some((r) => ['AD-Domain-Services', 'DNS'].includes(r.Name)) };
49
+ }
50
+
51
+ function persistenceFindings() {
52
+ if (!isWindows) return { platform: process.platform, findings: [] };
53
+ const script = `
54
+ $findings = @()
55
+ $runPaths = @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Run','HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce','HKLM:\Software\Microsoft\Windows\CurrentVersion\Run','HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce')
56
+ foreach ($path in $runPaths) { if (Test-Path $path) { $p = Get-ItemProperty $path; foreach ($v in $p.PSObject.Properties) { if ($v.Name -notmatch '^PS') { $findings += [pscustomobject]@{ category='registry-run'; severity='medium'; location=$path; name=$v.Name; command=[string]$v.Value; reason='Autostart registry entry'; action='review' } } } } }
57
+ $startupPaths = @([Environment]::GetFolderPath('Startup'), [Environment]::GetFolderPath('CommonStartup'))
58
+ foreach ($path in $startupPaths) { if (Test-Path $path) { Get-ChildItem -Force $path | ForEach-Object { $findings += [pscustomobject]@{ category='startup-folder'; severity='medium'; location=$_.FullName; name=$_.Name; command=$_.FullName; reason='Startup folder item'; action='review' } } } }
59
+ if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { Get-ScheduledTask | ForEach-Object { foreach ($a in $_.Actions) { $cmd = [string]$a.Execute + ' ' + [string]$a.Arguments; if ($cmd -match '(?i)\\Temp\\|\\AppData\\|powershell|wscript|cscript|mshta|rundll32|regsvr32') { $findings += [pscustomobject]@{ category='scheduled-task'; severity='high'; location=$_.TaskPath + $_.TaskName; name=$_.TaskName; command=$cmd; reason='Task uses a user/temp path or script host'; action='review' } } } } }
60
+ Get-CimInstance Win32_Service | Where-Object { $_.PathName -match '(?i)\\Users\\|\\Temp\\|powershell|wscript|cscript|mshta|rundll32|regsvr32' } | ForEach-Object { $findings += [pscustomobject]@{ category='service'; severity='high'; location=$_.Name; name=$_.DisplayName; command=[string]$_.PathName; reason='Service uses a user/temp path or script host'; action='review' } }
61
+ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) {
62
+ try {
63
+ $filters = Get-CimInstance -Namespace root/subscription -ClassName __EventFilter -ErrorAction Stop
64
+ $consumers = Get-CimInstance -Namespace root/subscription -ClassName __EventConsumer -ErrorAction Stop
65
+ $bindings = Get-CimInstance -Namespace root/subscription -ClassName __FilterToConsumerBinding -ErrorAction Stop
66
+ foreach ($item in @($filters) + @($consumers) + @($bindings)) { $findings += [pscustomobject]@{ category='wmi-persistence'; severity='high'; location=$item.__PATH; name=$item.__CLASS; command=[string]$item.Name; reason='Permanent WMI subscription detected'; action='review' } }
67
+ } catch { }
68
+ }
69
+ if (Get-Command Get-BitsTransfer -ErrorAction SilentlyContinue) { Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue | Where-Object { $_.JobState -notin @('Transferred','Acknowledged','Cancelled') } | ForEach-Object { $findings += [pscustomobject]@{ category='bits-job'; severity='medium'; location=$_.JobId; name=$_.DisplayName; command=([string]$_.RemoteName); reason='Active BITS transfer requires review'; action='review' } } }
70
+ $profilePaths = @($PROFILE, $PROFILE.AllUsersAllHosts, $PROFILE.AllUsersCurrentHost, $PROFILE.CurrentUserCurrentHost) | Select-Object -Unique
71
+ foreach ($profilePath in $profilePaths) { if ($profilePath -and (Test-Path $profilePath)) { $findings += [pscustomobject]@{ category='powershell-profile'; severity='medium'; location=$profilePath; name=[IO.Path]::GetFileName($profilePath); command=$profilePath; reason='PowerShell profile executes at shell startup'; action='review' } } }
72
+ $findings | ConvertTo-Json -Depth 8 -Compress
73
+ `
74
+ const raw = powershell(script);
75
+ if (!raw) return { platform: process.platform, findings: [] };
76
+ const parsed = JSON.parse(raw);
77
+ return { platform: process.platform, findings: Array.isArray(parsed) ? parsed : [parsed] };
78
+ }
79
+
80
+ function findingId(finding) { return createHash('sha256').update(`${finding.category}:${finding.location}:${finding.name}`).digest('hex').slice(0, 16); }
81
+ function psLiteral(value) { return `'${String(value ?? '').replaceAll("'", "''")}'`; }
82
+ function taskPathFromLocation(location) {
83
+ const separator = String(location).lastIndexOf('\\');
84
+ return separator >= 0 ? String(location).slice(0, separator + 1) : '\\';
85
+ }
86
+
87
+ function withIds(result) { return { ...result, findings: result.findings.map((f) => ({ ...f, id: f.id || findingId(f) })) }; }
88
+
89
+ function sensitiveFiles() {
90
+ if (!isWindows) return { platform: process.platform, findings: [] };
91
+ const script = `
92
+ $roots = @($env:ProgramData, $env:PUBLIC, $env:TEMP)
93
+ $findings = @()
94
+ $trusted = '(?i)\\ProgramData\\(Microsoft\\Windows Defender|chocolatey|docker|GitHub|kind)(\\|$)'
95
+ foreach ($root in $roots) { if ($root -and (Test-Path $root)) { Get-ChildItem -LiteralPath $root -File -Recurse -Depth 5 -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'desktop.ini' -and $_.FullName -notmatch $trusted -and $_.Extension -in @('.ps1','.vbs','.js','.hta','.dll','.exe') -and $_.LastWriteTime -gt (Get-Date).AddDays(-7) } | Select-Object -First 50 | ForEach-Object { $sig=Get-AuthenticodeSignature -FilePath $_.FullName -ErrorAction SilentlyContinue; $hash=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash; $status=[string]$sig.Status; $severity=if ($status -eq 'Valid') { 'low' } else { 'medium' }; $findings += [pscustomobject]@{ category='sensitive-file'; severity=$severity; location=$_.FullName; name=$_.Name; command=$_.FullName; sha256=$hash; signatureStatus=$status; signer=[string]$sig.SignerCertificate.Subject; reason='Recent executable or script in a high-risk directory'; action='review' } } } }
96
+ $findings | ConvertTo-Json -Depth 8 -Compress
97
+ `
98
+ const raw = powershell(script);
99
+ if (!raw) return { platform: process.platform, findings: [] };
100
+ const parsed = JSON.parse(raw);
101
+ return withIds({ platform: process.platform, findings: Array.isArray(parsed) ? parsed : [parsed] });
102
+ }
103
+
104
+ function scheduledTaskInventory() {
105
+ if (!isWindows) return { platform: process.platform, tasks: [] };
106
+ const raw = psJson("& { if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { Get-ScheduledTask | ForEach-Object { $task=$_; [pscustomobject]@{TaskPath=$task.TaskPath; TaskName=$task.TaskName; State=[string]$task.State; Author=[string]$task.Author; Hidden=[bool]$task.Settings.Hidden; RunLevel=[string]$task.Principal.RunLevel; LogonType=[string]$task.Principal.LogonType; Actions=@($task.Actions | ForEach-Object { [pscustomobject]@{Execute=[string]$_.Execute; Arguments=[string]$_.Arguments} })} } } else { @() } }");
107
+ const tasks = Array.isArray(raw) ? raw : raw ? [raw] : [];
108
+ return { platform: process.platform, tasks: tasks.slice(0, 1000) };
109
+ }
110
+
111
+ function localSecurityPosture() {
112
+ const raw = psJson("$admins=Get-LocalGroupMember -Group 'Administrators' -ErrorAction SilentlyContinue | Select-Object Name, ObjectClass, PrincipalSource; $accounts=Get-LocalUser -ErrorAction SilentlyContinue | Where-Object Enabled | Select-Object Name, LastLogon, PasswordRequired, PasswordExpires; $uac=Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System' -ErrorAction SilentlyContinue; $executionPolicy=@(); try { $executionPolicy=@(Get-ExecutionPolicy -List -ErrorAction Stop | Select-Object Scope, ExecutionPolicy) } catch { $executionPolicy=@([pscustomobject]@{Scope='Unavailable'; ExecutionPolicy='Unavailable'}) }; [pscustomobject]@{Administrators=@($admins); EnabledLocalAccounts=@($accounts); UAC=[pscustomobject]@{EnableLUA=$uac.EnableLUA; ConsentPromptBehaviorAdmin=$uac.ConsentPromptBehaviorAdmin}; PowerShellExecutionPolicy=$executionPolicy}");
113
+ return raw || { Administrators: [], EnabledLocalAccounts: [], UAC: {}, PowerShellExecutionPolicy: [] };
114
+ }
115
+
116
+ function configurationSources() {
117
+ const raw = psJson("$paths=@('HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows Defender','HKLM:\\SOFTWARE\\Policies\\Microsoft\\WindowsFirewall','HKLM:\\SOFTWARE\\Microsoft\\PolicyManager\\current\\device'); $items=@(); foreach($path in $paths) { if(Test-Path $path) { $items += Get-ItemProperty -Path $path -ErrorAction SilentlyContinue | Select-Object PSPath,* -ExcludeProperty PSParentPath,PSChildName,PSDrive,PSProvider } }; [pscustomobject]@{PolicyPaths=$paths; Detected=$items.Count -gt 0; Items=$items}");
118
+ return raw || { Detected: false, Items: [] };
119
+ }
120
+
121
+ function securityPosture() {
122
+ const bitlocker = psJson("& { if (Get-Command Get-BitLockerVolume -ErrorAction SilentlyContinue) { Get-BitLockerVolume -MountPoint $env:SystemDrive | Select-Object MountPoint, ProtectionStatus, VolumeStatus, EncryptionMethod } else { [pscustomobject]@{Available=$false} } }");
123
+ const tpm = psJson("& { if (Get-Command Get-Tpm -ErrorAction SilentlyContinue) { Get-Tpm | Select-Object TpmPresent, TpmReady, LockoutHealTime } else { [pscustomobject]@{Available=$false} } }");
124
+ const secureBoot = psJson("& { try { [pscustomobject]@{Available=$true; Enabled=([bool](Confirm-SecureBootUEFI -ErrorAction Stop))} } catch { [pscustomobject]@{Available=$false; Enabled=$null; Error=$_.Exception.Message} } }");
125
+ const rdp = psJson("$path='HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server'; $nla='HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp'; [pscustomobject]@{Enabled=((Get-ItemProperty -Path $path -Name fDenyTSConnections -ErrorAction SilentlyContinue).fDenyTSConnections -eq 0); NLA=((Get-ItemProperty -Path $nla -Name UserAuthentication -ErrorAction SilentlyContinue).UserAuthentication -eq 1)}");
126
+ const smb = psJson("$feature=Get-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -ErrorAction SilentlyContinue; $server=Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters' -ErrorAction SilentlyContinue; [pscustomobject]@{SMB1State=[string]$feature.State; RequireSecuritySignature=$server.RequireSecuritySignature}");
127
+ return { bitlocker, tpm, secureBoot, rdp, smb };
128
+ }
129
+
130
+ function audit() {
131
+ if (!isWindows) return { system: detectSystem(), platform: process.platform, policies: [{ id: 'windows-only', status: 'skipped', message: 'Windows requerido' }] };
132
+ const rawProfiles = psJson('Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction');
133
+ const profiles = Array.isArray(rawProfiles) ? rawProfiles : [rawProfiles];
134
+ const defender = psJson('Get-MpComputerStatus | Select-Object AMRunningMode, AntivirusEnabled, RealTimeProtectionEnabled, NISEnabled, IsTamperProtected');
135
+ const update = psJson('Get-Service wuauserv | Select-Object Name, Status, StartType');
136
+ const posture = securityPosture();
137
+ const localSecurity = localSecurityPosture();
138
+ const sources = configurationSources();
139
+ const networkCategories = (detectSystem().networkProfiles || []).map((profile) => ({ name: profile.Name, category: ['Public', 'Private', 'DomainAuthenticated'][Number(profile.NetworkCategory)] || 'Unknown', connectivity: profile.IPv4Connectivity }));
140
+ return { system: detectSystem(), persistence: persistenceFindings(), scheduledTasks: scheduledTaskInventory(), sensitiveFiles: sensitiveFiles(), securityPosture: posture, localSecurityPosture: localSecurity, configurationSources: sources, networkCategories, platform: process.platform, policies: [
141
+ { id: 'firewall', status: profiles.every((p) => p.Enabled && p.DefaultInboundAction === 'Block'), details: profiles },
142
+ { id: 'defender', status: Boolean(defender.AntivirusEnabled && defender.RealTimeProtectionEnabled), details: defender },
143
+ { id: 'windows-update', status: update.Status === 'Running', details: update },
144
+ { id: 'bitlocker', status: posture.bitlocker.ProtectionStatus === 'On' && posture.bitlocker.VolumeStatus === 'FullyEncrypted', details: posture.bitlocker, action: 'audit-only' },
145
+ { id: 'tpm', status: posture.tpm.TpmPresent === true && posture.tpm.TpmReady === true, details: posture.tpm, action: 'audit-only' },
146
+ { id: 'secure-boot', status: posture.secureBoot.Enabled === true, details: posture.secureBoot, action: 'audit-only' },
147
+ { id: 'rdp-nla', status: posture.rdp.Enabled !== true || posture.rdp.NLA === true, details: posture.rdp, action: 'audit-only' },
148
+ { id: 'smbv1-disabled', status: posture.smb.SMB1State !== 'Enabled', details: posture.smb, action: 'audit-only' },
149
+ { id: 'smb-signing', status: posture.smb.RequireSecuritySignature === 1 || posture.smb.RequireSecuritySignature === true, details: posture.smb, action: 'audit-only' },
150
+ { id: 'network-profile', status: networkCategories.every((profile) => profile.category !== 'Unknown'), details: networkCategories, action: 'audit-only' },
151
+ { id: 'managed-policy-conflict', status: sources.Detected === false, details: sources, action: 'audit-only' },
152
+ { id: 'uac', status: localSecurity.UAC?.EnableLUA === 1 || localSecurity.UAC?.EnableLUA === true, details: localSecurity.UAC, action: 'audit-only' },
153
+ { id: 'powershell-policy', status: !Array.isArray(localSecurity.PowerShellExecutionPolicy) || localSecurity.PowerShellExecutionPolicy.every((item) => item.ExecutionPolicy !== 'Unrestricted'), details: localSecurity.PowerShellExecutionPolicy, action: 'audit-only' },
154
+ ] };
155
+ }
156
+
157
+ function plan(report = audit()) {
158
+ return report.policies.map((policy) => ({ id: policy.id, action: policy.action === 'audit-only' ? 'audit-only' : policy.status === true ? 'none' : 'apply', reason: policy.action === 'audit-only' ? 'Requires manual review' : policy.status === true ? 'Compliant' : 'Review or harden policy' }));
159
+ }
160
+
161
+ function backup() {
162
+ requireAdmin(); ensureStateDir();
163
+ const id = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}`;
164
+ const file = path.join(stateDir, `${id}.json`);
165
+ const snapshot = { id, createdAt: new Date().toISOString(), firewall: psJson('Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction'), defender: psJson('Get-MpPreference | Select-Object DisableRealtimeMonitoring, DisableBehaviorMonitoring, PUAProtection, EnableNetworkProtection') };
166
+ fs.writeFileSync(file, JSON.stringify(snapshot, null, 2), { mode: 0o600 });
167
+ return { id, file, snapshot };
168
+ }
169
+
170
+ function apply() {
171
+ requireAdmin();
172
+ const report = audit();
173
+ if (report.system.profile === 'domain-controller') throw new Error('Domain Controller detectado: el perfil es audit-only y requiere revisión explícita.');
174
+ if (report.configurationSources?.Detected === true) throw new Error('Políticas GPO/MDM detectadas: aplicación local bloqueada; revise el origen administrado.');
175
+ const changes = plan(report).filter((x) => x.action === 'apply');
176
+ if (!changes.length) return { changed: false, message: 'El sistema ya cumple las políticas soportadas.' };
177
+ const saved = backup();
178
+ try {
179
+ powershell('Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled True -DefaultInboundAction Block -DefaultOutboundAction Allow');
180
+ powershell('Set-MpPreference -DisableRealtimeMonitoring $false -DisableBehaviorMonitoring $false -PUAProtection Enabled -EnableNetworkProtection Enabled');
181
+ const after = audit();
182
+ if (after.policies.some((p) => p.status !== true && p.id !== 'windows-update' && p.action !== 'audit-only')) throw new Error('La verificación posterior no fue satisfactoria.');
183
+ return { changed: true, backupId: saved.id, policies: after.policies };
184
+ } catch (error) {
185
+ console.error(chalk.red(`Aplicación incompleta: ${error.message}`));
186
+ console.error(chalk.yellow(`Backup disponible para rollback: ${saved.id}`));
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ function rollback(id) {
192
+ requireAdmin();
193
+ if (!id || !/^[A-Za-z0-9._-]+$/.test(id)) throw new Error('Backup ID inválido.');
194
+ const file = path.join(stateDir, `${id}.json`);
195
+ if (!fs.existsSync(file)) throw new Error(`Backup no encontrado: ${id}`);
196
+ const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'));
197
+ for (const profile of Array.isArray(snapshot.firewall) ? snapshot.firewall : [snapshot.firewall]) {
198
+ if (!profile?.Name) continue;
199
+ powershell(`Set-NetFirewallProfile -Profile ${psLiteral(profile.Name)} -Enabled $${Boolean(profile.Enabled)} -DefaultInboundAction ${profile.DefaultInboundAction} -DefaultOutboundAction ${profile.DefaultOutboundAction}`);
200
+ }
201
+ if (snapshot.defender && Object.keys(snapshot.defender).length) {
202
+ const defender = snapshot.defender;
203
+ const settings = [];
204
+ if (defender.DisableRealtimeMonitoring !== null && defender.DisableRealtimeMonitoring !== undefined) settings.push(`-DisableRealtimeMonitoring $${Boolean(defender.DisableRealtimeMonitoring)}`);
205
+ if (defender.DisableBehaviorMonitoring !== null && defender.DisableBehaviorMonitoring !== undefined) settings.push(`-DisableBehaviorMonitoring $${Boolean(defender.DisableBehaviorMonitoring)}`);
206
+ if (defender.PUAProtection) settings.push(`-PUAProtection ${psLiteral(defender.PUAProtection)}`);
207
+ if (defender.EnableNetworkProtection) settings.push(`-EnableNetworkProtection ${psLiteral(defender.EnableNetworkProtection)}`);
208
+ if (settings.length) powershell(`Set-MpPreference ${settings.join(' ')}`);
209
+ }
210
+ console.log(chalk.green(`Rollback aplicado: ${id}`));
211
+ }
212
+
213
+
214
+ function quarantine(id) {
215
+ requireAdmin();
216
+ if (!id || !/^[a-f0-9]{16}$/.test(id)) throw new Error('Finding ID inválido.');
217
+ const result = persistenceFindings();
218
+ const finding = result.findings.find((f) => f.id === id);
219
+ if (!finding) {
220
+ const fileFinding = sensitiveFiles().findings.find((f) => f.id === id);
221
+ if (fileFinding) return quarantineFile(fileFinding);
222
+ throw new Error(`Finding no encontrado: ${id}`);
223
+ }
224
+ ensureStateDir();
225
+ const recordFile = path.join(stateDir, `${id}.json`);
226
+ if (fs.existsSync(recordFile)) throw new Error(`Finding ya procesado: ${id}`);
227
+ let record;
228
+ if (finding.category === 'scheduled-task') {
229
+ const taskPath = taskPathFromLocation(finding.location);
230
+ const taskName = psLiteral(finding.name);
231
+ const taskPathLiteral = psLiteral(taskPath);
232
+ const xml = powershell(`Export-ScheduledTask -TaskName ${taskName} -TaskPath ${taskPathLiteral}`);
233
+ record = { id, category: finding.category, name: finding.name, taskPath, xml, action: 'disable', createdAt: new Date().toISOString() };
234
+ powershell(`Disable-ScheduledTask -TaskName ${taskName} -TaskPath ${taskPathLiteral}`);
235
+ } else if (finding.category === 'service') {
236
+ const state = psJson(`Get-CimInstance Win32_Service -Filter "Name='${finding.location.replaceAll("'", "''")}'" | Select-Object Name, State, StartMode, PathName`);
237
+ record = { id, category: finding.category, service: state, action: 'stop-disable', createdAt: new Date().toISOString() };
238
+ powershell(`Stop-Service -Name '${finding.location.replaceAll("'", "''")}' -Force -ErrorAction SilentlyContinue; Set-Service -Name '${finding.location.replaceAll("'", "''")}' -StartupType Disabled`);
239
+ } else if (finding.category === 'registry-run') {
240
+ const value = finding.name.replaceAll("'", "''");
241
+ const key = finding.location.replaceAll("'", "''");
242
+ const oldValue = powershell(`(Get-ItemProperty -Path '${key}' -Name '${value}').${value}`);
243
+ record = { id, category: finding.category, registryPath: finding.location, valueName: finding.name, value: oldValue, action: 'remove-value', createdAt: new Date().toISOString() };
244
+ powershell(`Remove-ItemProperty -Path '${key}' -Name '${value}' -Force`);
245
+ } else {
246
+ throw new Error(`Cuarentena no soportada para categoría: ${finding.category}`);
247
+ }
248
+ fs.writeFileSync(recordFile, JSON.stringify(record, null, 2), { mode: 0o600 });
249
+ console.log(JSON.stringify({ quarantined: id, category: finding.category, backup: recordFile }, null, 2));
250
+ }
251
+
252
+ function quarantineFile(finding) {
253
+ ensureStateDir();
254
+ const source = path.resolve(finding.location);
255
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) throw new Error('El finding ya no apunta a un archivo regular.');
256
+ const destination = path.join(quarantineDir, `${finding.id}-${path.basename(source)}`);
257
+ fs.mkdirSync(quarantineDir, { recursive: true, mode: 0o700 });
258
+ fs.renameSync(source, destination);
259
+ const record = { id: finding.id, category: finding.category, source, destination, quarantinedAt: new Date().toISOString(), sha256: createHash('sha256').update(fs.readFileSync(destination)).digest('hex') };
260
+ fs.writeFileSync(path.join(quarantineDir, `${finding.id}.json`), JSON.stringify(record, null, 2), { mode: 0o600 });
261
+ console.log(JSON.stringify(record, null, 2));
262
+ }
263
+
264
+ function restore(id) {
265
+ requireAdmin();
266
+ if (!id || !/^[a-f0-9]{16}$/.test(id)) throw new Error('Finding ID inválido.');
267
+ const stateRecord = path.join(stateDir, `${id}.json`);
268
+ if (fs.existsSync(stateRecord)) {
269
+ const record = JSON.parse(fs.readFileSync(stateRecord, 'utf8'));
270
+ if (record.category === 'scheduled-task') {
271
+ const encoded = Buffer.from(record.xml, 'utf8').toString('base64');
272
+ powershell(`$xml=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); Register-ScheduledTask -TaskName ${psLiteral(record.name)} -TaskPath ${psLiteral(record.taskPath)} -Xml $xml -Force`);
273
+ } else if (record.category === 'service') {
274
+ const serviceName = psLiteral(record.service.Name);
275
+ powershell(`Set-Service -Name ${serviceName} -StartupType ${psLiteral(record.service.StartMode)}; if (${psLiteral(record.service.State)} -eq 'Running') { Start-Service -Name ${serviceName} }`);
276
+ } else if (record.category === 'registry-run') {
277
+ powershell(`New-ItemProperty -Path ${psLiteral(record.registryPath)} -Name ${psLiteral(record.valueName)} -Value ${psLiteral(record.value)} -PropertyType String -Force`);
278
+ }
279
+ fs.unlinkSync(stateRecord);
280
+ console.log(JSON.stringify({ restored: id, category: record.category }, null, 2));
281
+ return;
282
+ }
283
+ const recordFile = path.join(quarantineDir, `${id}.json`);
284
+ if (!fs.existsSync(recordFile)) throw new Error(`Registro de cuarentena no encontrado: ${id}`);
285
+ const record = JSON.parse(fs.readFileSync(recordFile, 'utf8'));
286
+ if (!fs.existsSync(record.destination)) throw new Error('El archivo en cuarentena no existe.');
287
+ fs.mkdirSync(path.dirname(record.source), { recursive: true });
288
+ if (fs.existsSync(record.source)) throw new Error('El destino original ya existe; no se sobrescribe.');
289
+ fs.renameSync(record.destination, record.source);
290
+ fs.unlinkSync(recordFile);
291
+ console.log(JSON.stringify({ restored: record.source, id }, null, 2));
292
+ }
293
+
294
+ const args = process.argv.slice(2);
295
+ const flags = new Set(args);
296
+ try {
297
+ if (flags.has('--help') || flags.has('-h')) { usage(); process.exit(0); }
298
+ if (args.includes('--rollback')) { rollback(args[args.indexOf('--rollback') + 1]); process.exit(0); }
299
+ if (args.includes('--quarantine')) { if (!flags.has('--yes')) throw new Error('--quarantine requiere --yes.'); quarantine(args[args.indexOf('--quarantine') + 1]); process.exit(0); }
300
+ if (args.includes('--restore')) { restore(args[args.indexOf('--restore') + 1]); process.exit(0); }
301
+ if (flags.has('--apply') && !flags.has('--yes')) throw new Error('--apply requiere confirmación explícita: añade --yes.');
302
+ const report = audit();
303
+ if (flags.has('--capabilities')) console.log(JSON.stringify(detectSystem(), null, 2));
304
+ if (flags.has('--persistence')) console.log(JSON.stringify(persistenceFindings(), null, 2));
305
+ if (flags.has('--files')) console.log(JSON.stringify(sensitiveFiles(), null, 2));
306
+ if (flags.has('--plan')) console.log(JSON.stringify(plan(report), null, 2));
307
+ else if (flags.has('--status') || flags.has('--audit') || flags.size === 0) console.log(flags.has('--json') ? JSON.stringify(report, null, 2) : JSON.stringify(report, null, 2));
308
+ if (flags.has('--apply')) console.log(JSON.stringify(apply(), null, 2));
309
+ } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "@methodwhite/auto-hardening",
3
- "version": "1.0.0",
4
- "description": "Auto-Hardening Tool - Aplica politicas de seguridad automaticamente en Windows",
5
- "main": "auto-hardening.js",
3
+ "version": "1.7.2",
4
+ "description": "Audita y aplica medidas de hardening defensivo en Windows con modo dry-run seguro.",
6
5
  "type": "module",
6
+ "main": "auto-hardening.js",
7
+ "bin": {
8
+ "auto-hardening": "auto-hardening.js"
9
+ },
7
10
  "scripts": {
8
11
  "start": "node auto-hardening.js",
9
- "harden": "node auto-hardening.js --full"
12
+ "audit": "node auto-hardening.js --audit",
13
+ "harden": "node auto-hardening.js --full",
14
+ "test": "node --test",
15
+ "lint": "node --check auto-hardening.js",
16
+ "pack:check": "npm pack --dry-run"
10
17
  },
11
18
  "keywords": [
12
19
  "security",
@@ -14,21 +21,34 @@
14
21
  "windows",
15
22
  "cis",
16
23
  "nist",
17
- "endpoint-protection"
24
+ "endpoint-protection",
25
+ "devsecops"
18
26
  ],
19
27
  "author": "MethodWhite Security",
20
28
  "license": "MIT",
29
+ "engines": {
30
+ "node": ">=18.18.0"
31
+ },
21
32
  "repository": {
22
33
  "type": "git",
23
- "url": "https://github.com/methodwhite/auto-hardening"
34
+ "url": "git+https://github.com/MethodWhite/auto-hardening.git"
24
35
  },
25
- "engines": {
26
- "node": ">=16.0.0"
36
+ "bugs": {
37
+ "url": "https://github.com/MethodWhite/auto-hardening/issues"
38
+ },
39
+ "homepage": "https://github.com/MethodWhite/auto-hardening#readme",
40
+ "files": [
41
+ "auto-hardening.js",
42
+ "profiles",
43
+ "README.md",
44
+ "LICENSE",
45
+ "SECURITY.md",
46
+ "CHANGELOG.md"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
27
50
  },
28
51
  "dependencies": {
29
52
  "chalk": "^5.3.0"
30
- },
31
- "bin": {
32
- "auto-hardening": "./auto-hardening.js"
33
53
  }
34
54
  }
@@ -0,0 +1 @@
1
+ {"id":"domain-controller","version":"1.0.0","scope":"domain-controller","mode":"audit-only","source":"Microsoft security guidance","policies":["ad-health-audit","sysvol-integrity-audit","ntds-protection-audit","kerberos-audit","gpo-conflict-audit"]}
@@ -0,0 +1 @@
1
+ {"id":"server-member","version":"1.0.0","scope":"member-server","mode":"apply-safe","source":"Microsoft security guidance","policies":["firewall-inbound-block","defender-realtime","defender-pua","network-protection","audit-policy","rdp-nla-audit","smb-signing-audit"]}
@@ -0,0 +1 @@
1
+ {"id":"workstation-domain","version":"1.0.0","scope":"domain-member","mode":"apply-safe","source":"Microsoft security guidance","policies":["firewall-inbound-block","defender-realtime","defender-pua","network-protection","audit-policy","detect-gpo-conflicts"]}
@@ -0,0 +1 @@
1
+ {"id":"workstation-home","version":"1.0.0","scope":"workstation","network":"Private/Public","mode":"apply-safe","source":"Microsoft security guidance","policies":["firewall-inbound-block","defender-realtime","defender-pua","network-protection","disable-smbv1"]}