chore: secure hooks and update dependencies #4 #9
Frontend security / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 4m35s

This commit is contained in:
2026-09-13 19:36:15 +03:00
parent 7a89356f87
commit 3be6e61b8f
10 changed files with 899 additions and 841 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Frontend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+1
View File
@@ -1,2 +1,3 @@
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
npx lint-staged npx lint-staged
npm run deps npm run deps
+18
View File
@@ -17,6 +17,12 @@ npm run dev
`VITE_API_BASE_URL` по умолчанию указывает на `http://localhost:3000`. `VITE_API_BASE_URL` по умолчанию указывает на `http://localhost:3000`.
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
Находка или отсутствие Gitleaks блокирует commit.
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
## Проверка ## Проверка
```bash ```bash
@@ -27,6 +33,18 @@ npm run verify
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest, затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook. Knip, dependency rules приложения и stories, сборки приложения и Storybook.
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
исключений frontend нет. Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения.
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
не используются.
Если backend-контракт изменился, Если backend-контракт изменился,
обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI
document и затем выполните `npm run openapi:generate`. document и затем выполните `npm run openapi:generate`.
+738 -798
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -44,39 +44,39 @@
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-hook-form": "7.88.0", "react-hook-form": "7.88.0",
"react-i18next": "16.6.6", "react-i18next": "17.0.14",
"react-is": "19.3.0", "react-is": "19.3.0",
"react-router": "7.18.3", "react-router": "8.3.1",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "3.25.76" "zod": "4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.7", "@babel/core": "8.0.5",
"@biomejs/biome": "2.5.13", "@biomejs/biome": "2.5.13",
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.2",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0", "@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.0",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/node": "24.13.4", "@types/node": "24.13.4",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "4.1.11", "@vitest/coverage-v8": "5.0.0",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"dependency-cruiser": "18.2.0", "dependency-cruiser": "18.2.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "27.4.0", "jsdom": "30.0.1",
"knip": "6.35.1", "knip": "6.35.1",
"lint-staged": "16.4.0", "lint-staged": "17.5.1",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"storybook": "10.6.0", "storybook": "10.6.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.0", "vite": "8.3.0",
"vitest": "4.1.11" "vitest": "5.0.0"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { test } from "node:test";
const GITHUB_TOKEN_RANDOM_BYTES = 18;
test("pre-commit scans the index, redacts findings and fails closed", () => {
const hook = resolve(".husky/pre-commit");
const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-"));
const bin = join(cwd, "bin");
const marker = join(cwd, "downstream");
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" });
const run = (path = `${bin}${delimiter}${process.env.PATH}`) =>
spawnSync("/bin/sh", ["-e", hook], {
cwd,
env: { ...process.env, PATH: path },
encoding: "utf8",
});
try {
mkdirSync(bin);
for (const command of ["npx", "npm"]) {
writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
mode: 0o755,
});
}
git("init", "--quiet");
writeFileSync(join(cwd, "sample.txt"), "safe staged content\n");
git("add", "sample.txt");
// An unstaged secret must not be mistaken for the index being committed.
writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`);
let result = run();
assert.equal(
result.status,
0,
"clean index must pass; install Gitleaks first",
);
assert.ok(existsSync(marker));
rmSync(marker);
git("add", "sample.txt");
result = run();
assert.equal(result.status, 1, "staged secret must block commit");
assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
assert.ok(!existsSync(marker), "formatting/dependency checks must not run");
result = run(bin);
assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
assert.ok(!existsSync(marker));
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
+23 -20
View File
@@ -38,18 +38,24 @@ function moneyCents(value: string): bigint | null {
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
const moneySchema = z.string().refine((value) => { const moneySchema = z.string().refine(
const cents = moneyCents(value); (value) => {
return ( const cents = moneyCents(value);
cents !== null && return (
cents >= MIN_BALANCE_KOPECKS && cents !== null &&
cents <= MAX_BALANCE_KOPECKS cents >= MIN_BALANCE_KOPECKS &&
); cents <= MAX_BALANCE_KOPECKS
}, validationMessage("bank.validation.money")); );
const positiveMoneySchema = moneySchema.refine((value) => { },
const cents = moneyCents(value); { error: validationMessage("bank.validation.money") },
return cents === null || cents > 0n; );
}, validationMessage("bank.validation.positive")); const positiveMoneySchema = moneySchema.refine(
(value) => {
const cents = moneyCents(value);
return cents === null || cents > 0n;
},
{ error: validationMessage("bank.validation.positive") },
);
const bankDateSchema = z const bankDateSchema = z
.string() .string()
.refine( .refine(
@@ -59,7 +65,7 @@ const bankDateSchema = z
!Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) &&
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value &&
value <= bankToday(), value <= bankToday(),
validationMessage("bank.validation.date"), { error: validationMessage("bank.validation.date") },
); );
const bankCardSchema = z.object({ const bankCardSchema = z.object({
name: z name: z
@@ -77,15 +83,12 @@ const bankCardSchema = z.object({
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined), validationMessage("bank.validation.bankName")(undefined),
), ),
lastFour: z lastFour: z.string().refine((value) => !value || /^\d{4}$/.test(value), {
.string() error: validationMessage("bank.validation.lastFour"),
.refine( }),
(value) => !value || /^\d{4}$/.test(value),
validationMessage("bank.validation.lastFour"),
),
paymentSystem: z paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], { .enum(["", "mir", "visa", "mastercard"], {
errorMap: validationMessage("bank.validation.paymentSystem"), error: validationMessage("bank.validation.paymentSystem"),
}) })
.optional(), .optional(),
openingBalance: moneySchema, openingBalance: moneySchema,
@@ -71,7 +71,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
.refine( .refine(
(value) => (value) =>
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
validationMessage("bank.validation.category"), { error: validationMessage("bank.validation.category") },
) )
.refine( .refine(
(value) => (value) =>
@@ -81,7 +81,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
c.name.toLocaleLowerCase("ru-RU") === c.name.toLocaleLowerCase("ru-RU") ===
value.toLocaleLowerCase("ru-RU"), value.toLocaleLowerCase("ru-RU"),
), ),
validationMessage("bank.validation.duplicate"), { error: validationMessage("bank.validation.duplicate") },
), ),
}), }),
), ),
+5 -5
View File
@@ -25,7 +25,7 @@ const email = z
value.split("@").length === 2 && value.split("@").length === 2 &&
value.split("@").every(Boolean) && value.split("@").every(Boolean) &&
!value.split("").some(isEmailCharacterInvalid), !value.split("").some(isEmailCharacterInvalid),
validationMessage("auth.validation.invalidEmail"), { error: validationMessage("auth.validation.invalidEmail") },
); );
const password = z const password = z
.string() .string()
@@ -33,7 +33,7 @@ const password = z
(value) => (value) =>
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES && new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES &&
new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
validationMessage("auth.validation.invalidPasswordLength"), { error: validationMessage("auth.validation.invalidPasswordLength") },
); );
const loginSchema = z.object({ const loginSchema = z.object({
@@ -43,10 +43,10 @@ const loginSchema = z.object({
}); });
const registerSchema = loginSchema.refine( const registerSchema = loginSchema.refine(
(value) => value.password === value.passwordConfirmation, (value) => value.password === value.passwordConfirmation,
(value) => ({ {
path: ["passwordConfirmation"], path: ["passwordConfirmation"],
...validationMessage("auth.validation.passwordsDoNotMatch")(value), error: validationMessage("auth.validation.passwordsDoNotMatch"),
}), },
); );
type AuthFormValues = z.input<typeof loginSchema>; type AuthFormValues = z.input<typeof loginSchema>;
@@ -44,14 +44,16 @@ type AccountIncomePageViewModel =
const datePattern = /^\d{4}-\d{2}-\d{2}$/; const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const draftDate = z const draftDate = z
.string() .string()
.refine((value) => parseDraftDate(value) !== null, {
error: validationMessage("income.validation.date"),
})
.refine( .refine(
(value) => parseDraftDate(value) !== null, (value) => {
validationMessage("income.validation.date"), const date = parseDraftDate(value);
) return !date || date <= moscowDate(new Date());
.refine((value) => { },
const date = parseDraftDate(value); { error: validationMessage("income.validation.future") },
return !date || date <= moscowDate(new Date()); );
}, validationMessage("income.validation.future"));
const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine( const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
({ from, to }) => { ({ from, to }) => {
const start = parseDraftDate(from); const start = parseDraftDate(from);