Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a029b2f
Removed free license request logic
akenion Jul 16, 2026
a605a35
Added notice to README
akenion Jul 17, 2026
9b4c966
Added warning about free licenses
akenion Jul 17, 2026
6f594c0
Made additional changes to support removing free license request logic
akenion Jul 17, 2026
3cbe117
Updated version to 5.0.5rc1
akenion Jul 17, 2026
84c4d7f
Updated README
akenion Jul 20, 2026
52cf5cb
Update EOL date for free licenses
gazchap Jul 30, 2026
6af660e
Further readme amendment for clarification
gazchap Jul 30, 2026
cf2eb5a
Merge branch 'milestone/5.0.5' into 20260727
gazchap Jul 30, 2026
694730a
Update version number to 5.0.5rc2
gazchap Jul 30, 2026
6a81276
Merge remote-tracking branch 'origin/20260727' into 20260727
gazchap Jul 30, 2026
c7da04b
Merge pull request #380 from wordfence/20260727
gazchap Aug 13, 2026
848549b
Prevent symlinks from traversing outside of the specified scan root
gazchap Aug 26, 2026
9e6247a
Optimize PHP tag detection routine
gazchap Aug 26, 2026
e3f451f
Avoid recursive calls processing PHP tag transitions
gazchap Aug 26, 2026
c28745c
Fix deserialization type error
gazchap Aug 26, 2026
10ba214
Update version to 5.0.6rc1
gazchap Aug 27, 2026
e44daf1
Merge branch 'milestone/5.0.6' into 20260827--scanner-lexer-updates
gazchap Aug 27, 2026
c802c9e
Missed a date in the FAQ
gazchap Aug 28, 2026
4860606
Fix issue with premium license context being erroneously dropped
gazchap Aug 28, 2026
452c0e2
Fix `configure --default` prompting for license interactively
gazchap Aug 28, 2026
e74a23d
Update version number to 5.0.5rc3
gazchap Aug 28, 2026
49f497f
Update EOL date to reflect new schedule
gazchap Sep 2, 2026
24d56a8
Merge pull request #385 from wordfence/20260727
gazchap Sep 2, 2026
d559ca0
Merge pull request #384 from wordfence/20260827--scanner-lexer-updates
gazchap Sep 3, 2026
262e21d
Merge branch 'milestone/5.0.6' into 20260903--release-consolidation
gazchap Sep 3, 2026
d13315a
Merge pull request #386 from wordfence/20260903--release-consolidation
gazchap Sep 3, 2026
3dbd925
Fix license type resetting after get_precompiled_patterns call
gazchap Sep 4, 2026
61964db
Update version number to 5.0.6rc3
gazchap Sep 4, 2026
f6f52e9
Merge pull request #387 from wordfence/20260904--license-type-fix
gazchap Sep 4, 2026
286fdde
Update version number to 5.0.6
gazchap Sep 10, 2026
d4bdd2a
Merge pull request #388 from wordfence/5.0.6-release
gazchap Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Wordfence CLI

> [!WARNING]
> Wordfence CLI is no longer available for new Free or Premium licenses.
> The Free version of WF-CLI will reach end-of-life on October 14th, 2026. After that date, the Free version will no longer be supported.
> Existing Wordfence CLI Premium licenses will remain active and supported until their current license term expires. Premium licenses will not be renewed after expiration, and no new Premium licenses will be issued going forward.
> Current Premium customers may continue to access support, documentation, and applicable downloads through the end of their active license term.

Wordfence CLI is an open source, high performance, multi-process security scanner, written in Python, that quickly scans network filesystems to detect PHP/other malware and WordPress vulnerabilities. CLI is parallelizable, can be scheduled, can accept input via pipe, and can pipe output to other commands.

## Installation
Expand All @@ -26,10 +32,6 @@ If you'd like to install Wordfence CLI manually or use CLI for development, you
- `requests` >= 2.3
- `mysql-connector-python` >= 8.0

### Obtaining a license

Visit [https://www.wordfence.com/products/wordfence-cli/](https://www.wordfence.com/products/wordfence-cli/) to obtain a license to download our signature set.

## Usage

You can run `wordfence help` for a full list of options that can be passed to Wordfence CLI. Read more about the [configuration options](docs/Configuration.md) that can be passed to Wordfence CLI.
Expand Down
3 changes: 1 addition & 2 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ Configuration can be set through command line arguments, or configured globally
## `wordfence configure` Command Line Arguments

- `-o`, `--overwrite`: Overwrite any existing configuration file without prompting
- `-r`, `--request-license`: Automatically request a free license without prompting
- `-w`, `--workers`: Specify the number of worker processes to use for malware scanning
- `-D`, `--default`: Automatically accept the default values for any options that are not explicitly specified. This will also result in a free license being requested when terms are accepted.
- `-D`, `--default`: Automatically accept the default values for any options that are not explicitly specified

## Global Command Line Arguments

Expand Down
2 changes: 1 addition & 1 deletion docs/FAQs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

#### How do I get a license?

Licenses can be obtained at [https://www.wordfence.com/products/wordfence-cli/](https://www.wordfence.com/products/wordfence-cli/).
Licenses for Wordfence CLI are no longer available. Existing Premium licenses are valid until the end of their active license term and will not be renewed. Existing Free licenses will cease to be valid on October 14th, 2026.

#### The scanner has identified malware. What do I do now?

Expand Down
20 changes: 5 additions & 15 deletions wordfence/api/noc1.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,11 @@ def _check_error_message(self, response: dict) -> None:
def validate_response(self, response, validator: Validator) -> None:
if isinstance(response, dict):
self._check_error_message(response)
paid = bool('_isPaidKey' in response and response['_isPaidKey'])
if paid != self.license.paid:
self.license.paid = paid
self._trigger_license_update_hooks(self.license)
if '_isPaidKey' in response:
paid = bool(response['_isPaidKey'])
if paid != self.license.paid:
self.license.paid = paid
self._trigger_license_update_hooks(self.license)
terms_updated = '_termsUpdated' in response
self._trigger_terms_update_hooks(terms_updated, self.license)
return super().validate_response(response, validator)
Expand Down Expand Up @@ -192,17 +193,6 @@ def get_precompiled_malware_signatures(
def ping_api_key(self) -> bool:
return self.process_simple_request('ping_api_key')

def get_cli_api_key(self, accept_terms: bool = False) -> str:
response = self.request(
'get_cli_api_key',
{'accept_terms': int(accept_terms)}
)
validator = DictionaryValidator({
'apiKey': str
})
self.validate_response(response, validator)
return response['apiKey']

def record_toupp(self) -> bool:
success = self.process_simple_request('record_toupp')
if success:
Expand Down
13 changes: 10 additions & 3 deletions wordfence/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ def invoke(self) -> int:
context,
self.helper,
license_manager,
terms_manager,
self.subcommand_definitions,
self.subcommand_definition
)
Expand All @@ -168,8 +167,16 @@ def invoke(self) -> int:
if self.subcommand_definition.requires_config:
if not configurer.check_config():
return 0
if not self.subcommand_definition.uses_license:
license_manager.check_license()
license = context.get_license()
if license is not None and not license.paid:
log.warning(
"The Free version of Wordfence CLI will reach "
"end-of-life on October 14th, 2026. Existing free "
"license holders may continue to use Wordfence CLI "
"until that time. New free license keys may no longer "
"be generated."
)

terms_manager.prompt_acceptance_if_needed()

subcommand = self.subcommand_definition.initialize_subcommand(
Expand Down
1 change: 0 additions & 1 deletion wordfence/cli/configure/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ class ConfigureSubcommand(Subcommand):
def invoke(self) -> int:
configurer = self.context.configurer
configurer.overwrite = self.config.overwrite
configurer.request_license = self.config.request_license
if self.config.workers is not None \
and self.config.workers < MIN_WORKERS:
if self.config.is_from_cli('workers'):
Expand Down
17 changes: 4 additions & 13 deletions wordfence/cli/configure/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,6 @@
"argument_type": "OPTIONAL_FLAG",
"default": None
},
"request-license": {
"short_name": "r",
"description": "Automatically request a free license without "
"prompting",
"context": "CLI",
"argument_type": "OPTIONAL_FLAG",
"default": None
},
"workers": {
"short_name": "w",
"description": "Specify the number of worker processes to "
Expand All @@ -32,9 +24,7 @@
"default": {
"short_name": "D",
"description": "Automatically accept the default values for any "
"options that are not explicitly specified. This will "
"also result in a free license being requested when "
"terms are accepted.",
"options that are not explicitly specified",
"context": "CLI",
"argument_type": "FLAG",
"default": False
Expand All @@ -51,8 +41,9 @@
UsageExample(
'Non-interactively configure Wordfence CLI to use 4 worker processes '
'and the default values for all other options, automatically '
'accepting the terms and requesting a free license',
'wordfence configure --default --workers 4 --accept-terms'
'accepting the terms and using an existing license',
'wordfence configure --default --workers 4 --accept-terms '
'--license {license}'
)
]

Expand Down
47 changes: 2 additions & 45 deletions wordfence/cli/configurer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@
InvalidInputException, InputException
from wordfence.util.io import ensure_directory_is_writable, \
ensure_file_is_writable, resolve_path, IoException
from wordfence.api.licensing import License, LICENSE_URL
from wordfence.api.licensing import License
from wordfence.logging import log
from .config import load_config
from .context import CliContext
from .subcommands import SubcommandDefinition
from .licensing import LicenseManager, LicenseValidationFailure
from .terms_management import TERMS_URL, TermsManager
from .helper import Helper
from .mailing_lists import EMAIL_SIGNUP_MESSAGE

Expand Down Expand Up @@ -137,7 +136,6 @@ def __init__(
context: CliContext,
helper: Helper,
license_manager: LicenseManager,
terms_manager: TermsManager,
subcommand_definitions: Dict[str, SubcommandDefinition],
subcommand_definition: Optional[SubcommandDefinition] = None
):
Expand All @@ -148,11 +146,9 @@ def __init__(
self.all_config[context.config.subcommand] = context.config
self.config_values = []
self.license_manager = license_manager
self.terms_manager = terms_manager
self.subcommand_definition = subcommand_definition
self.subcommand_definitions = subcommand_definitions
self.overwrite = None
self.request_license = None
self.workers = None
self.default = False
self.written = False
Expand Down Expand Up @@ -209,9 +205,7 @@ def _prompt_for_license(self) -> License:

if self.config.license is not None:
print(f'Current license: {self.config.license}')
change_license = self.request_license \
or self.default \
or prompt_yes_no(
change_license = False if self.default else prompt_yes_no(
'An existing license was found, '
'would you like to change it?',
default=False
Expand All @@ -228,43 +222,6 @@ def _prompt_for_license(self) -> License:
'a valid license.'
)

request_free = self.default or self.request_license or prompt_yes_no(
'Would you like to automatically request a free Wordfence CLI'
' license?',
default=True
)
if not request_free:
print(f'Please visit {LICENSE_URL} to obtain a license key.')

if request_free:
terms_accepted = self.config.accept_terms or prompt_yes_no(
'Your access to and use of Wordfence CLI Free edition is '
'subject to the Wordfence CLI License Terms and '
f'Conditions set forth at {TERMS_URL}. By entering "y" '
'and selecting Enter, you agree that you have read and '
'accept the Wordfence CLI License Terms and Conditions.',
default=False
)
if terms_accepted:
license = self.license_manager.request_free_license(
terms_accepted
)
self.terms_manager.record_acceptance(
license=license,
remote=False
)
print(
'Free Wordfence CLI license obtained successfully: '
f'{license}'
)
return license
else:
print(
'A license cannot be obtained automatically without'
' agreeing to the Wordfence CLI License Terms and '
'Conditions.'
)

license = prompt(
'License',
self.config.license,
Expand Down
4 changes: 0 additions & 4 deletions wordfence/cli/licensing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ def _create_noc1_client(
) -> noc1.Client:
return self.context.create_noc1_client(license)

def request_free_license(self, terms_accepted: bool = False) -> License:
client = self.context.create_noc1_client()
return License(client.get_cli_api_key(accept_terms=terms_accepted))

def validate_license(self, license: Union[License, str]) -> License:
license = to_license(license)
client = self.context.create_noc1_client(license)
Expand Down
32 changes: 28 additions & 4 deletions wordfence/php/lexing.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import re

from collections import deque
from enum import Enum, auto
from typing import Generator, BinaryIO, Optional, Union, Set

Expand Down Expand Up @@ -422,9 +421,10 @@ def __str__(self) -> str:
class Lexer:

def __init__(self, stream: BinaryIO, chunk_size: int = 4096):
self.chunks = deque()
self.chunks = []
self.chunk_size = chunk_size
self.chunk_offset = 0
self.current_chunk_index = 0
self.read = 0
self.stream = stream
self.offset = 0
Expand All @@ -445,6 +445,7 @@ def step(self) -> bool:
if self.position > self.read:
if not self._read_chunk():
return False
self._update_current_byte()
return True

def get_current(self) -> bytes:
Expand All @@ -464,11 +465,32 @@ def get_current(self) -> bytes:
remaining -= chunk_length
return b''.join(components)

def get_current_byte(self) -> bytes:
"""Return the byte at the current position without rebuilding input."""
if self.position == 0 or not self.chunks:
return b''
chunk = self.chunks[self.current_chunk_index]
offset = self.position - self.chunk_offset - 1
return chunk[offset:offset + 1]

def _update_current_byte(self) -> None:
while self.current_chunk_index < len(self.chunks) - 1 and \
self.position > self.chunk_offset + \
len(self.chunks[self.current_chunk_index]):
self.chunk_offset += len(self.chunks[self.current_chunk_index])
self.current_chunk_index += 1
while self.current_chunk_index > 0 and \
self.position <= self.chunk_offset:
self.current_chunk_index -= 1
self.chunk_offset -= len(self.chunks[self.current_chunk_index])

def step_backwards(self) -> None:
self.position -= 1
self._update_current_byte()

def reset(self) -> None:
self.position = self.offset
self._update_current_byte()

def consume_token(self, token_type: TokenType) -> Token:
value = self.get_current()
Expand Down Expand Up @@ -514,9 +536,11 @@ def extract_php_token(self, types=TokenType) -> Optional[Token]:

def extract_inline_html_or_open_tag(self) -> Optional[Token]:
partial_start = None
tag_length = len(b'<?php')
tag_suffix = b''
while self.step():
current = self.get_current()
match_type = TokenType.OPEN_TAG.match_at_end(current)
tag_suffix = (tag_suffix + self.get_current_byte())[-tag_length:]
match_type = TokenType.OPEN_TAG.match(tag_suffix)
if match_type == MatchType.PARTIAL_MATCH and partial_start is None:
partial_start = self.position
elif match_type == MatchType.FINAL_MATCH:
Expand Down
15 changes: 8 additions & 7 deletions wordfence/php/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1621,13 +1621,14 @@ def parse_any(
token_stream: TokenStream,
in_php_tag: bool = False
) -> (bool, Optional[PhpInstruction]):
try:
if in_php_tag:
return (True, self.parse_statement(token_stream))
else:
return (False, self.parse_output(token_stream))
except TagStateChanged as change:
return self.parse_any(token_stream, change.state)
while True:
try:
if in_php_tag:
return (True, self.parse_statement(token_stream))
else:
return (False, self.parse_output(token_stream))
except TagStateChanged as change:
in_php_tag = change.state

def parse(self, context: PhpContext = None) -> PhpContext:
if context is None:
Expand Down
16 changes: 16 additions & 0 deletions wordfence/scanning/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,18 @@ def __init__(
self.allow_io_errors = allow_io_errors
self.scanned_paths = scanned_paths if scanned_paths is not None \
else PathSet()
self.root_path = None
self.located_count = 0
self.skipped_count = 0

def _is_within_root(self, path: bytes) -> bool:
"""Return whether a resolved path is contained by the scan root."""
try:
return os.path.commonpath((self.root_path, path)) == self.root_path
except ValueError:
# Paths on different drives cannot be contained by one another.
return False

def _is_loop(
self,
path: bytes,
Expand Down Expand Up @@ -232,6 +241,12 @@ def search_directory(self, path: bytes, parents: Optional[list] = None):
try:
if item.is_symlink():
item_path = os.path.realpath(item.path)
if not self._is_within_root(item_path):
log.warning(
'Skipping symlink outside scan root: '
+ os.fsdecode(item.path)
)
continue
if item_path in self.scanned_paths:
continue
# This intentionally uses the unresolved path
Expand Down Expand Up @@ -264,6 +279,7 @@ def _push_file(self, path: bytes) -> None:

def locate(self):
real_path = os.path.realpath(self.path)
self.root_path = real_path
if os.path.isdir(real_path):
for path in self.search_directory(real_path):
self._push_file(path)
Expand Down
Loading