Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2025-08-05 17:48:12 +01:00
17 changed files with 476 additions and 23 deletions

View File

@@ -226,7 +226,11 @@ class Asset extends Depreciable
foreach ($this->model->fieldset->fields as $field) {
if ($field->format == 'BOOLEAN') {
// this just casts booleans that may come through as strings to an actual boolean type
// adding !$field->field_encrypted because when the encrypted value comes through it
// screws things up for the encrypted validation rules (and the encrypted string
// is not a valid boolean type)
if ($field->format == 'BOOLEAN' && !$field->field_encrypted) {
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}

View File

@@ -16,6 +16,7 @@ class CustomField extends Model
UniqueUndeletedTrait;
/**
*
* Custom field predfined formats
*
* @var array

View File

@@ -3,12 +3,19 @@
namespace App\Models;
use App\Rules\AlphaEncrypted;
use App\Rules\BooleanEncrypted;
use App\Rules\DateEncrypted;
use App\Rules\EmailEncrypted;
use App\Rules\IPEncrypted;
use App\Rules\IPv4Encrypted;
use App\Rules\IPv6Encrypted;
use App\Rules\MacEncrypted;
use App\Rules\NumericEncrypted;
use App\Rules\RegexEncrypted;
use App\Rules\UrlEncrypted;
use Gate;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Watson\Validating\ValidatingTrait;
class CustomFieldset extends Model
@@ -99,7 +106,7 @@ class CustomFieldset extends Model
* @since [v3.0]
* @return array
*/
public function validation_rules()
public function validation_rules(): array
{
$rules = [];
foreach ($this->fields as $field) {
@@ -121,18 +128,65 @@ class CustomFieldset extends Model
$rules[$field->db_column_name()] = $rule;
// these are to replace the standard 'numeric' and 'alpha' rules if the custom field is also encrypted.
// the values need to be decrypted first, because encrypted strings are alphanumeric
if ($field->format === 'NUMERIC' && $field->field_encrypted) {
// this is to switch the rules to rules specially made for encrypted custom fields that decrypt the value before validating
if ($field->field_encrypted) {
$numericKey = array_search('numeric', $rules[$field->db_column_name()]);
$rules[$field->db_column_name()][$numericKey] = new NumericEncrypted;
$alphaKey = array_search('alpha', $rules[$field->db_column_name()]);
$emailKey = array_search('email', $rules[$field->db_column_name()]);
$dateKey = array_search('date', $rules[$field->db_column_name()]);
$urlKey = array_search('url', $rules[$field->db_column_name()]);
$ipKey = array_search('ip', $rules[$field->db_column_name()]);
$ipv4Key = array_search('ipv4', $rules[$field->db_column_name()]);
$ipv6Key = array_search('ipv6', $rules[$field->db_column_name()]);
$macKey = array_search('regex:/^[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}$/', $rules[$field->db_column_name()]);
$booleanKey = array_search('boolean', $rules[$field->db_column_name()]);
// find objects in array that start with "regex:"
// collect because i couldn't figure how to do this
// with array filter and get keys out of it
$regexCollect = collect($rules[$field->db_column_name()]);
$regexKeys = $regexCollect->filter(function ($value, $key) {
return starts_with($value, 'regex:');
})->keys()->values()->toArray();
switch ($field->format) {
case 'NUMERIC':
$rules[$field->db_column_name()][$numericKey] = new NumericEncrypted;
break;
case 'ALPHA':
$rules[$field->db_column_name()][$alphaKey] = new AlphaEncrypted;
break;
case 'EMAIL':
$rules[$field->db_column_name()][$emailKey] = new EmailEncrypted;
break;
case 'DATE':
$rules[$field->db_column_name()][$dateKey] = new DateEncrypted;
break;
case 'URL':
$rules[$field->db_column_name()][$urlKey] = new UrlEncrypted;
break;
case 'IP':
$rules[$field->db_column_name()][$ipKey] = new IPEncrypted;
break;
case 'IPV4':
$rules[$field->db_column_name()][$ipv4Key] = new IPv4Encrypted;
break;
case 'IPV6':
$rules[$field->db_column_name()][$ipv6Key] = new IPv6Encrypted;
break;
case 'MAC':
$rules[$field->db_column_name()][$macKey] = new MacEncrypted;
break;
case 'BOOLEAN':
$rules[$field->db_column_name()][$booleanKey] = new BooleanEncrypted;
break;
case starts_with($field->format, 'regex'):
foreach ($regexKeys as $regexKey) {
$rules[$field->db_column_name()][$regexKey] = new RegexEncrypted;
}
break;
}
}
if ($field->format === 'ALPHA' && $field->field_encrypted) {
$alphaKey = array_search('alpha', $rules[$field->db_column_name()]);
$rules[$field->db_column_name()][$alphaKey] = new AlphaEncrypted;
}
// add not_array to rules for all fields but checkboxes
if ($field->element != 'checkbox') {

View File

@@ -5,9 +5,11 @@ namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class AlphaEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
@@ -18,7 +20,7 @@ class AlphaEncrypted implements ValidationRule
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!ctype_alpha($decrypted) && !is_null($decrypted)) {
if (!$this->validateAlpha($attributeName, $decrypted, 'ascii') && !is_null($decrypted)) {
$fail(trans('validation.alpha', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class BooleanEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateBoolean($attributeName, $decrypted) && !is_null($decrypted)) {
$fail(trans('validation.ipv6', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class DateEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateDate($attributeName, $decrypted) && !is_null($decrypted)) {
$fail(trans('validation.date', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class EmailEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateEmail($attribute, $decrypted, []) && !is_null($decrypted)) {
$fail(trans('validation.email', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

32
app/Rules/IPEncrypted.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class IPEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateIp($attributeName, $decrypted) && !is_null($decrypted)) {
$fail(trans('validation.ip', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class IPv4Encrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateIpv4($attributeName, $decrypted) && !is_null($decrypted)) {
$fail(trans('validation.ipv4', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class IPv6Encrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateIpv6($attributeName, $decrypted) && !is_null($decrypted)) {
$fail(trans('validation.ipv6', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Rules;
use App\Models\CustomField;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class MacEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateRegex($attributeName, $decrypted, ['/^[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}$/']) && !is_null($decrypted)) {
$fail(trans('validation.mac_address', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -3,12 +3,16 @@
namespace App\Rules;
use Closure;
use Egulias\EmailValidator\Validation\RFCValidation;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class NumericEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
@@ -16,11 +20,10 @@ class NumericEncrypted implements ValidationRule
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!is_numeric($decrypted) && !is_null($decrypted)) {
if (!$this->validateNumeric($attributeName, $decrypted) && !is_null($decrypted)) {
$fail(trans('validation.numeric', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Rules;
use App\Models\CustomField;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class RegexEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$field = CustomField::where('db_column', $attribute)->first();
$regex = $field->format;
$regex = str_replace('regex:', '', $regex);
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateRegex($attributeName, $decrypted, [$regex]) && !is_null($decrypted)) {
$fail(trans('validation.regex', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e->getMessage());
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Concerns\ValidatesAttributes;
class UrlEncrypted implements ValidationRule
{
use ValidatesAttributes;
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
$decrypted = Crypt::decrypt($value);
if (!$this->validateUrl($attributeName, $decrypted, []) && !is_null($decrypted)) {
$fail(trans('validation.url', ['attribute' => $attributeName]));
}
} catch (\Exception $e) {
report($e);
$fail(trans('general.something_went_wrong'));
}
}
}

View File

@@ -93,6 +93,42 @@ class CustomFieldFactory extends Factory
});
}
public function encrypt()
{
return $this->state(function () {
return [
'field_encrypted' => '1',
];
});
}
public function alpha()
{
return $this->state(function () {
return [
'format' => 'alpha',
];
});
}
public function numeric()
{
return $this->state(function () {
return [
'format' => 'numeric',
];
});
}
public function email()
{
return $this->state(function () {
return [
'format' => 'email',
];
});
}
public function testCheckbox()
{
return $this->state(function () {

View File

@@ -731,6 +731,65 @@ class StoreAssetTest extends TestCase
$this->assertEquals('This is encrypted field', Crypt::decrypt($asset->{$field->db_column_name()}));
}
public function test_encrypted_custom_field_validation_passes()
{
$this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL');
$status = Statuslabel::factory()->readyToDeploy()->create();
$alphaField = CustomField::factory()->encrypt()->alpha()->create();
$numericField = CustomField::factory()->encrypt()->numeric()->create();
$emailField = CustomField::factory()->encrypt()->email()->create();
$fields = [$alphaField, $numericField, $emailField];
$superuser = User::factory()->superuser()->create();
$assetData = Asset::factory()->hasMultipleCustomFields($fields)->make();
$response = $this->actingAsForApi($superuser)
->postJson(route('api.assets.store'), [
$alphaField->db_column_name() => 'Thisisencryptedfield',
$numericField->db_column_name() => '1234567890',
$emailField->db_column_name() => 'poop@poop.com',
'model_id' => $assetData->model->id,
'status_id' => $status->id,
'asset_tag' => '1234',
])
->assertStatusMessageIs('success')
->assertOk()
->json();
$asset = Asset::findOrFail($response['payload']['id']);
$this->assertEquals('Thisisencryptedfield', Crypt::decrypt($asset->{$alphaField->db_column_name()}));
$this->assertEquals('1234567890', Crypt::decrypt($asset->{$numericField->db_column_name()}));
$this->assertEquals('poop@poop.com', Crypt::decrypt($asset->{$emailField->db_column_name()}));
}
public function test_encrypted_custom_field_validation_fails()
{
$this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL');
$status = Statuslabel::factory()->readyToDeploy()->create();
$alphaField = CustomField::factory()->encrypt()->alpha()->create();
$numericField = CustomField::factory()->encrypt()->numeric()->create();
$emailField = CustomField::factory()->encrypt()->email()->create();
$fields = [$alphaField, $numericField, $emailField];
$superuser = User::factory()->superuser()->create();
$assetData = Asset::factory()->hasMultipleCustomFields($fields)->make();
$cleaned_name = trim(preg_replace('/_+|snipeit|\d+/', ' ', $alphaField->db_column_name()));
$response = $this->actingAsForApi($superuser)
->postJson(route('api.assets.store'), [
$alphaField->db_column_name() => 'Thisisencryptedfield123',
'model_id' => $assetData->model->id,
'status_id' => $status->id,
'asset_tag' => '1234',
])
->dump()
->assertStatusMessageIs('error')
->assertJsonPath('messages.'.$alphaField->db_column_name(), [trans('validation.alpha', ['attribute' => $cleaned_name])])
->assertOk()
->json();
}
public function testPermissionNeededToStoreEncryptedField()
{
// @todo:

View File

@@ -29,7 +29,7 @@ class BulkEditAssetsTest extends TestCase
])->assertStatus(200);
}
public function testStandardUserCannotAccessPage()
public function test_standard_user_cannot_access_page()
{
$user = User::factory()->create();
$assets = Asset::factory()->count(2)->create();
@@ -44,7 +44,7 @@ class BulkEditAssetsTest extends TestCase
])->assertStatus(403);
}
public function testBulkEditAssetsAcceptsAllPossibleAttributes()
public function test_bulk_edit_assets_accepts_all_possible_attributes()
{
// sets up all needed models and attributes on the assets
// this test does not deal with custom fields - will be dealt with in separate cases
@@ -112,7 +112,7 @@ class BulkEditAssetsTest extends TestCase
});
}
public function testBulkEditAssetsNullsOutFieldsIfSelected()
public function test_bulk_edit_assets_nulls_out_fields_if_selected()
{
// sets up all needed models and attributes on the assets
// this test does not deal with custom fields - will be dealt with in separate cases
@@ -165,7 +165,7 @@ class BulkEditAssetsTest extends TestCase
});
}
public function testBulkEditAssetsAcceptsAndUpdatesUnencryptedCustomFields()
public function test_bulk_edit_assets_accepts_and_updates_unencrypted_custom_fields()
{
$this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL');
@@ -197,7 +197,7 @@ class BulkEditAssetsTest extends TestCase
});
}
public function testBulkEditAssetsNullsCustomFieldsIfSelected()
public function test_bulk_edit_assets_nulls_custom_fields_if_selected()
{
$this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL');
@@ -238,7 +238,7 @@ class BulkEditAssetsTest extends TestCase
});
}
public function testBulkEditAssetsAcceptsAndUpdatesEncryptedCustomFields()
public function test_bulk_edit_assets_accepts_and_updates_encrypted_custom_fields()
{
$this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL');
@@ -262,7 +262,7 @@ class BulkEditAssetsTest extends TestCase
});
}
public function testBulkEditAssetsRequiresadminToUpdateEncryptedCustomFields()
public function test_bulk_edit_assets_requires_admin_to_update_encrypted_custom_fields()
{
$this->markIncompleteIfMySQL('Custom Fields tests do not work on mysql');
$edit_user = User::factory()->editAssets()->create();