fix: close terminal audit findings
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-18 01:34:36 +07:00
parent 64ac5c0c66
commit 7c8e7fcaf1
14 changed files with 231 additions and 32 deletions

View file

@ -51,6 +51,15 @@ public partial class App : Application
{
InitializeComponent();
UnhandledException += (_, eventArgs) => CrashLogService.Write(eventArgs.Exception);
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
if (eventArgs.ExceptionObject is Exception exception) CrashLogService.Write(exception);
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
CrashLogService.Write(eventArgs.Exception.Flatten());
eventArgs.SetObserved();
};
}
/// <summary>

View file

@ -6,8 +6,8 @@
<RootNamespace>KronaStaffTerminal</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
<Platforms>x64</Platforms>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>

View file

@ -86,9 +86,10 @@
<TextBlock Text="Последние отметки"
Foreground="{ThemeResource KronaTextPrimaryBrush}"
Style="{StaticResource SubtitleTextBlockStyle}"/>
<Button Grid.Column="1"
Content="Очистить журнал"
Click="ClearAttendance_Click" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="Экспорт CSV" Click="ExportAttendance_Click" />
<Button Content="Очистить журнал" Click="ClearAttendance_Click" />
</StackPanel>
</Grid>
<ListView x:Name="AttendanceList"
Grid.Row="1"
@ -192,6 +193,8 @@
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
<NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/>
<ToggleSwitch x:Name="ShowEmployeeNamesSwitch" Header="Имена в публичном журнале терминала" OffContent="Скрыты" OnContent="Показывать"/>
<PasswordBox x:Name="NewAdminPinBox" Header="Новый PIN администратора" PasswordRevealMode="Peek"/>
<Button Content="Установить новый PIN" HorizontalAlignment="Left" Click="SetAdminPin_Click"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>

View file

@ -3,6 +3,9 @@ using KronaStaffTerminal.Services;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Text;
using Windows.Storage;
using Windows.Storage.Pickers;
namespace KronaStaffTerminal;
@ -34,6 +37,7 @@ public sealed partial class MainPage : Page
_scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
_scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score));
_scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync);
_scanner.RegistrationCancelled += () => Enqueue(() => _registrationEmployeeId = null);
_adminSessionTimer = DispatcherQueue.CreateTimer();
_adminSessionTimer.Interval = TimeSpan.FromSeconds(15);
_adminSessionTimer.IsRepeating = true;
@ -458,6 +462,12 @@ public sealed partial class MainPage : Page
SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational);
return;
}
if (employee.IsAdministrator && !PinConfigured &&
_state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
{
SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning);
return;
}
var dialog = new ContentDialog
{
@ -487,7 +497,7 @@ public sealed partial class MainPage : Page
return;
}
if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 1)
{
SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning);
return;
@ -522,7 +532,7 @@ public sealed partial class MainPage : Page
return;
}
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 1)
{
SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning);
return;
@ -573,8 +583,10 @@ public sealed partial class MainPage : Page
return;
}
var isNewDay = last is null || last.OccurredAtUtc.ToLocalTime().Date != now.ToLocalTime().Date;
var eventType = isNewDay || last!.EventType == "Уход" ? "Приход" : "Уход";
var maximumShift = TimeSpan.FromHours(Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48));
var eventType = last is not null && last.EventType == "Приход" && now - last.OccurredAtUtc <= maximumShift
? "Уход"
: "Приход";
var record = new AttendanceRecord
{
EmployeeId = employee.Id,
@ -584,6 +596,10 @@ public sealed partial class MainPage : Page
EventType = eventType
};
_state.Attendance.Insert(0, record);
if (_state.Attendance.Count > 100_000)
{
_state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000);
}
await _store.SaveAsync(_state);
RefreshLists();
TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
@ -622,6 +638,35 @@ public sealed partial class MainPage : Page
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
}
private async void ExportAttendance_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("экспорт журнала")) return;
var picker = new FileSavePicker
{
SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}"
};
picker.FileTypeChoices.Add("CSV", new[] { ".csv" });
WinRT.Interop.InitializeWithWindow.Initialize(picker, App.WindowHandle);
var file = await picker.PickSaveFileAsync();
if (file is null) return;
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
{
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
csv.Append(Csv(item.EmployeeName)).Append(';')
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
.Append(Csv(employee?.Position ?? "")).Append(';')
.Append(Csv(item.EventType)).Append(';')
.Append(Csv(item.LocalTime)).Append(';')
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
}
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
}
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
public async Task<bool> AuthorizeExitAsync()
{
if (!SecurityConfigured)
@ -759,6 +804,8 @@ public sealed partial class MainPage : Page
_state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex);
_state.Settings.StartWithWindows = StartWithWindowsSwitch.IsOn;
_state.Settings.DuplicateScanGuardSeconds = double.IsNaN(DuplicateGuardBox.Value) ? 120 : Math.Clamp((int)DuplicateGuardBox.Value, 10, 3600);
_state.Settings.MaximumShiftHours = double.IsNaN(MaximumShiftHoursBox.Value) ? 20 : Math.Clamp((int)MaximumShiftHoursBox.Value, 1, 48);
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
_state.Settings.TelegramChatId = TelegramChatBox.Text.Trim();
@ -771,6 +818,8 @@ public sealed partial class MainPage : Page
{
StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows;
DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600);
MaximumShiftHoursBox.Value = Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48);
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
TelegramChatBox.Text = _state.Settings.TelegramChatId;
@ -797,9 +846,27 @@ public sealed partial class MainPage : Page
EmployeesList.ItemsSource = null;
EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
AttendanceList.ItemsSource = null;
AttendanceList.ItemsSource = _state.Attendance.OrderByDescending(item => item.OccurredAtUtc).Take(100).ToList();
AttendanceList.ItemsSource = _state.Attendance
.OrderByDescending(item => item.OccurredAtUtc)
.Take(100)
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
{
Id = item.Id,
EmployeeId = item.EmployeeId,
EmployeeName = MaskName(item.EmployeeName),
OccurredAtUtc = item.OccurredAtUtc,
ReaderIndex = item.ReaderIndex,
EventType = item.EventType,
NotificationDelivered = item.NotificationDelivered,
NotificationError = item.NotificationError
})
.ToList();
}
private static string MaskName(string name) => string.Join(' ', name
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***"));
private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational)
{
StatusBar.Message = message;

View file

@ -63,11 +63,13 @@ public sealed class AttendanceRecord
public sealed class TerminalSettings
{
public int ReaderIndex { get; set; }
public bool StartWithWindows { get; set; } = true;
public bool StartWithWindows { get; set; }
public string AdminPinSalt { get; set; } = "";
public string AdminPinHash { get; set; } = "";
public int AdminPinIterations { get; set; } = 120_000;
public int DuplicateScanGuardSeconds { get; set; } = 120;
public int MaximumShiftHours { get; set; } = 20;
public bool ShowEmployeeNamesOnTerminal { get; set; }
public bool TelegramEnabled { get; set; }
public string TelegramBotToken { get; set; } = "";
public string TelegramChatId { get; set; } = "";

View file

@ -1,23 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10"
IgnorableNamespaces="uap rescap systemai">
IgnorableNamespaces="uap rescap">
<Identity
Name="223A9635-262B-4298-9F19-1D2FE0A6B5F7"
Publisher="CN=AppPublisher"
Publisher="CN=OchenStarik"
Version="1.0.0.0" />
<mp:PhoneIdentity PhoneProductId="223A9635-262B-4298-9F19-1D2FE0A6B5F7" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>KronaStaffTerminal</DisplayName>
<PublisherDisplayName>AppPublisher</PublisherDisplayName>
<DisplayName>Krona Staff</DisplayName>
<PublisherDisplayName>Очень Старик</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
@ -31,12 +29,10 @@
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="KronaStaffTerminal"
Description="KronaStaffTerminal"
DisplayName="Krona Staff"
Description="Терминал учёта сотрудников Krona Staff"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
@ -48,6 +44,5 @@
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
</Capabilities>
</Package>

View file

@ -10,7 +10,9 @@
- чередование событий «Приход» и «Уход»;
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
- локальный журнал последних отметок;
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
- защищённая очистка журнала без удаления сотрудников и отпечатков;
- приватный режим, скрывающий полные имена на публичном экране;
- уведомления через Telegram Bot API и MAX Bot API;
- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
- закрытый административный раздел с входом по PIN или отпечатку администратора;
@ -35,7 +37,7 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
5. Для регистрации другого пальца повторно нажмите «Добавить отпечаток».
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход». Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход», в том числе после полуночи в пределах настроенной максимальной длительности смены. Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
## Администратор и работа в фоне
@ -43,7 +45,7 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
3. Кнопка «Заблокировать», сворачивание окна или пять минут бездействия завершают административный сеанс.
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках.
## Уведомления

View file

@ -2,6 +2,8 @@ namespace KronaStaffTerminal.Services;
public static class CrashLogService
{
private const long MaximumLogSize = 1_048_576;
private static readonly object Gate = new();
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
@ -12,13 +14,29 @@ public static class CrashLogService
{
try
{
Directory.CreateDirectory(LogDirectory);
var entry = $"[{DateTimeOffset.Now:O}]{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
File.AppendAllText(Path.Combine(LogDirectory, "crash.log"), entry);
lock (Gate)
{
Directory.CreateDirectory(LogDirectory);
var path = Path.Combine(LogDirectory, "crash.log");
RotateIfNeeded(path);
var entry = $"[{DateTimeOffset.Now:O}]{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
File.AppendAllText(path, entry);
}
}
catch
{
// Logging must never replace the original application failure.
}
}
private static void RotateIfNeeded(string path)
{
if (!File.Exists(path) || new FileInfo(path).Length < MaximumLogSize) return;
for (var index = 3; index >= 1; index--)
{
var source = index == 1 ? path : $"{path}.{index - 1}";
var destination = $"{path}.{index}";
if (File.Exists(source)) File.Move(source, destination, true);
}
}
}

View file

@ -22,6 +22,7 @@ public sealed class FingerprintScanner : IDisposable
public event Action<int, string>? RegistrationCompleted;
public event Action<string>? StatusChanged;
public event Action? ConnectionLost;
public event Action? RegistrationCancelled;
public int DeviceCount { get; private set; }
public int ReaderIndex { get; private set; }
@ -188,6 +189,7 @@ public sealed class FingerprintScanner : IDisposable
_registrationCount = 0;
_registrationExpiresAtUtc = default;
StatusChanged?.Invoke("Регистрация отпечатка отменена: за 60 секунд не получены три скана.");
RegistrationCancelled?.Invoke();
}
}
@ -206,6 +208,7 @@ public sealed class FingerprintScanner : IDisposable
_registrationFingerprintId = null;
_registrationExpiresAtUtc = default;
StatusChanged?.Invoke("Этот палец уже зарегистрирован. Регистрация отменена.");
RegistrationCancelled?.Invoke();
return;
}
}

View file

@ -13,8 +13,7 @@ public static class StartupService
?? Registry.CurrentUser.CreateSubKey(RunKey, writable: true);
if (enabled)
{
var executablePath = Environment.ProcessPath
?? throw new InvalidOperationException("Не удалось определить путь программы.");
var executablePath = ResolveExecutablePath();
key.SetValue(ValueName, $"\"{executablePath}\" --autostart", RegistryValueKind.String);
}
else
@ -23,6 +22,21 @@ public static class StartupService
}
}
private static string ResolveExecutablePath()
{
var executablePath = Environment.ProcessPath
?? throw new InvalidOperationException("Не удалось определить путь программы.");
var debugSegment = $"{Path.DirectorySeparatorChar}Debug{Path.DirectorySeparatorChar}";
if (!executablePath.Contains(debugSegment, StringComparison.OrdinalIgnoreCase)) return executablePath;
var releasePath = executablePath.Replace(
debugSegment,
$"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}",
StringComparison.OrdinalIgnoreCase);
if (File.Exists(releasePath)) return releasePath;
throw new InvalidOperationException("Автозагрузка доступна после сборки Release-версии программы.");
}
public static bool IsEnabled()
{
using var key = Registry.CurrentUser.OpenSubKey(RunKey);

View file

@ -0,0 +1,49 @@
END-USER LICENSE AGREEMENT
Important - read carefully:
This End-User License Agreement ("EULA") is a legal agreement between you (either an individual or a single entity) and the mentioned author of this Software for the software product identified above, which includes computer software and may include associated media, printed materials, and "online" or electronic documentation ("SOFTWARE PRODUCT"). By installing, copying, or otherwise using the SOFTWARE PRODUCT, you agree to be bound by the terms of this EULA. If you do not agree to the terms of this EULA, do not install or use the SOFTWARE PRODUCT.
SOFTWARE PRODUCT LICENSE
The SOFTWARE PRODUCT is protected by copyright laws and international copyright treaties, as well as other intellectual property laws and treaties. The SOFTWARE PRODUCT is licensed, not sold.
1. GRANT OF LICENSE. This EULA grants you the following rights: Installation and Use. You may install and use an unlimited number of copies of the SOFTWARE PRODUCT.
Reproduction and Distribution. You may reproduce and distribute an unlimited number of copies of the SOFTWARE PRODUCT; provided that each copy shall be a true and complete copy, including all copyright and trademark notices, and shall be accompanied by a copy of this EULA. Copies of the SOFTWARE PRODUCT may be distributed as a standalone product or included with your own product.
2. DESCRIPTION OF OTHER RIGHTS AND LIMITATIONS.
Limitations on Reverse Engineering, Recompilation, and Disassembly. You may not reverse engineer, decompile, or disassemble the SOFTWARE PRODUCT, except and only to the extent that such activity is expressly permitted by applicable law notwithstanding this limitation.
Separation of Components.
The SOFTWARE PRODUCT is licensed as a single product. Its component parts may not be separated for use on more than one computer.
Software Transfer.
You may permanently transfer all of your rights under this EULA, provided the recipient agrees to the terms of this EULA.
Termination.
Without prejudice to any other rights, the Author of this Software may terminate this EULA if you fail to comply with the terms and conditions of this EULA. In such event, you must destroy all copies of the SOFTWARE PRODUCT and all of its component parts.
Distribution.
The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products.
3. COPYRIGHT.
All title and copyrights in and to the SOFTWARE PRODUCT(including but not limited to any images, photographs, animations, video, audio, music, text, and "applets" incorporated into the SOFTWARE PRODUCT), the accompanying printed materials, and any copies of the SOFTWARE PRODUCT are owned by the Author of this Software. The SOFTWARE PRODUCT is protected by copyright laws and international treaty provisions. Therefore, you must treat the SOFTWARE PRODUCT like any other copyrighted material except that you may install the SOFTWARE PRODUCT on a single computer provided you keep the original solely for backup or archival purposes.
LIMITED WARRANTY
NO WARRANTIES.
The Author of this Software expressly disclaims any warranty for the SOFTWARE PRODUCT. The SOFTWARE PRODUCT and any related documentation is provided "as is" without warranty of any kind, either express or implied, including, without limitation, the implied warranties or merchantability, fitness for a particular purpose, or no infringement. The entire risk arising out of use or performance of the SOFTWARE PRODUCT remains with you.
NO LIABILITY FOR DAMAGES.
In no event shall the author of this Software be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or any other pecuniary loss) arising out of the use of or inability to use this product, even if the Author of this Software has been advised of the possibility of such damages.
Acknowledgment of Agreement.
I have carefully read and understand this Agreement, ZKTeco Inc.¡¯s Privacy Policy Statement.
If YOU ACCEPT the terms of this Agreement:
I acknowledge and understand that by ACCEPTING the terms of this Agreement.
IF YOU DO NOT ACCEPT the terms of this Agreement.
I acknowledge and understand that by refusing to accept these terms, I have rejected this license agreement and therefore have no legal right to install, use, or copy this Product or the Licensed Software that it incorporates.
ZKTeco Inc.

View file

@ -0,0 +1,5 @@
# ZKFinger SDK component
`libzkfpcsharp.dll` is a third-party ZKTeco SDK component and is not covered by the repository's proprietary source-code license. The vendor EULA supplied with ZKFinger SDK 5.3 is preserved in `LICENSE.txt`.
The supplied EULA permits redistribution when the copy is complete and accompanied by the EULA, but restricts inclusion in products intended to receive commercial benefit. Commercial distribution and code signing therefore require an explicit owner/legal review before release. The native `libzkfp.dll` and device driver are installed separately from the official SDK package.

View file

@ -0,0 +1,32 @@
# Устранение замечаний аудита 2026-09-18
Исходный отчёт проверял ревизию `14ed610`. Исправления подготовлены после `b88ff91`.
## Исправлено
- №1–5: регистрация отпечатка отменяется при блокировке, смене сотрудника и через 60 секунд; повторный палец отклоняется; поток захвата корректно завершается; связь контролируется и восстанавливается автоматически.
- №2–3, №27: повреждённое состояние обрабатывается fail-closed, ведутся три резервные копии, записи сериализованы и принудительно сбрасываются на диск, фоновые ошибки журналируются, `crash.log` ротируется.
- №6–8: роли мигратора и приложения разделены; runtime-роль имеет `NOSUPERUSER NOBYPASSRLS`; пароли согласованы; мигратор использует `schema_migrations`, advisory lock и откатывает только последнюю миграцию после явного подтверждения.
- №9–12: отметка обычного сотрудника не теряется при открытом диалоге администратора; PIN усилен и защищён задержкой; второй экземпляр перенаправляет активацию первому; дубликат пальца запрещён.
- №13–14: журнал экспортируется в CSV, имеет ограничение размера и восстановимые резервные копии; уход после полуночи определяется по настраиваемой максимальной длительности смены.
- №15–20: readiness возвращает `503` без деталей; маршруты соответствуют `/api/v1/staff`; OpenAPI сравнивается с runtime; пул БД обрабатывает ошибки; добавлен реальный RLS-тест двух tenant; audit log доступен runtime только на чтение/добавление; outbox хранит subject/actor; Redis-зависимости удалены до появления сценария.
- №21–23, №25–29, №31–33: автозапуск открывает приложение в трее и не сохраняет Debug-путь; новый профиль не включает автозагрузку без решения администратора; защита последнего администратора исправлена; PIN совместим со старыми значениями; публичные имена по умолчанию маскируются; проект только x64; manifest очищен; correlation ID проверяется как ULID; версия синхронизируется проверкой; graceful shutdown имеет таймаут; standalone запрещён в production; базовые Docker-образы закреплены digest.
- npm-зависимости обновлены: `npm audit` сообщает 0 уязвимостей. Форматирование включено в CI.
## Осознанно сохранено
- №24: повторная авторизация перед удалением отпечатков, сотрудников и журнала сохранена по прямому требованию владельца.
- №30: EULA ZKTeco добавлена рядом с DLL. Автоматическая Windows-сборка и подпись требуют Windows runner и сертификат подписи; этих ресурсов в текущем Forgejo окружении нет.
## Требует решения владельца, не является локальным исправлением кода
- A1: окончательный контракт терминала как edge-клиента Staff и серверная синхронизация вместо постоянного локального source of truth.
- A2: письменные согласия на биометрию, сроки хранения, правовое основание передачи уведомлений и политика трансграничной передачи должны быть утверждены с юристом.
## Проверка
- `npm run check` — пройдено.
- `npm run format:check` — пройдено.
- `npm audit --audit-level=moderate` — 0 уязвимостей.
- `dotnet build apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Release -p:Platform=x64` — 0 ошибок и 0 предупреждений.
- PostgreSQL/Docker up/down и реальный RLS-тест добавлены в CI, но локально не запускались: Docker и PostgreSQL на машине разработки отсутствуют.