@@ -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,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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
Generated
+738
-798
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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") },
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user