@@ -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
|
||||
npm run deps
|
||||
|
||||
@@ -17,6 +17,12 @@ npm run dev
|
||||
|
||||
`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
|
||||
@@ -27,6 +33,18 @@ npm run verify
|
||||
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
||||
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
||||
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-контракт изменился,
|
||||
обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI
|
||||
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-dom": "19.3.0",
|
||||
"react-hook-form": "7.88.0",
|
||||
"react-i18next": "16.6.6",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-is": "19.3.0",
|
||||
"react-router": "7.18.3",
|
||||
"react-router": "8.3.1",
|
||||
"recharts": "3.10.1",
|
||||
"zod": "3.25.76"
|
||||
"zod": "4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/core": "8.0.5",
|
||||
"@biomejs/biome": "2.5.13",
|
||||
"@commitlint/cli": "21.2.2",
|
||||
"@commitlint/config-conventional": "21.2.2",
|
||||
"@rolldown/plugin-babel": "0.2.4",
|
||||
"@storybook/addon-docs": "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",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@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",
|
||||
"dependency-cruiser": "18.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "27.4.0",
|
||||
"jsdom": "30.0.1",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"lint-staged": "17.5.1",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"storybook": "10.6.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "5.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"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 MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
||||
|
||||
const moneySchema = z.string().refine((value) => {
|
||||
const cents = moneyCents(value);
|
||||
return (
|
||||
cents !== null &&
|
||||
cents >= MIN_BALANCE_KOPECKS &&
|
||||
cents <= MAX_BALANCE_KOPECKS
|
||||
);
|
||||
}, validationMessage("bank.validation.money"));
|
||||
const positiveMoneySchema = moneySchema.refine((value) => {
|
||||
const cents = moneyCents(value);
|
||||
return cents === null || cents > 0n;
|
||||
}, validationMessage("bank.validation.positive"));
|
||||
const moneySchema = z.string().refine(
|
||||
(value) => {
|
||||
const cents = moneyCents(value);
|
||||
return (
|
||||
cents !== null &&
|
||||
cents >= MIN_BALANCE_KOPECKS &&
|
||||
cents <= MAX_BALANCE_KOPECKS
|
||||
);
|
||||
},
|
||||
{ error: validationMessage("bank.validation.money") },
|
||||
);
|
||||
const positiveMoneySchema = moneySchema.refine(
|
||||
(value) => {
|
||||
const cents = moneyCents(value);
|
||||
return cents === null || cents > 0n;
|
||||
},
|
||||
{ error: validationMessage("bank.validation.positive") },
|
||||
);
|
||||
const bankDateSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
@@ -59,7 +65,7 @@ const bankDateSchema = z
|
||||
!Number.isNaN(Date.parse(`${value}T00:00:00Z`)) &&
|
||||
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value &&
|
||||
value <= bankToday(),
|
||||
validationMessage("bank.validation.date"),
|
||||
{ error: validationMessage("bank.validation.date") },
|
||||
);
|
||||
const bankCardSchema = z.object({
|
||||
name: z
|
||||
@@ -77,15 +83,12 @@ const bankCardSchema = z.object({
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
validationMessage("bank.validation.bankName")(undefined),
|
||||
),
|
||||
lastFour: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => !value || /^\d{4}$/.test(value),
|
||||
validationMessage("bank.validation.lastFour"),
|
||||
),
|
||||
lastFour: z.string().refine((value) => !value || /^\d{4}$/.test(value), {
|
||||
error: validationMessage("bank.validation.lastFour"),
|
||||
}),
|
||||
paymentSystem: z
|
||||
.enum(["", "mir", "visa", "mastercard"], {
|
||||
errorMap: validationMessage("bank.validation.paymentSystem"),
|
||||
error: validationMessage("bank.validation.paymentSystem"),
|
||||
})
|
||||
.optional(),
|
||||
openingBalance: moneySchema,
|
||||
|
||||
@@ -71,7 +71,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
.refine(
|
||||
(value) =>
|
||||
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
|
||||
validationMessage("bank.validation.category"),
|
||||
{ error: validationMessage("bank.validation.category") },
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
@@ -81,7 +81,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
c.name.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("@").every(Boolean) &&
|
||||
!value.split("").some(isEmailCharacterInvalid),
|
||||
validationMessage("auth.validation.invalidEmail"),
|
||||
{ error: validationMessage("auth.validation.invalidEmail") },
|
||||
);
|
||||
const password = z
|
||||
.string()
|
||||
@@ -33,7 +33,7 @@ const password = z
|
||||
(value) =>
|
||||
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES &&
|
||||
new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
|
||||
validationMessage("auth.validation.invalidPasswordLength"),
|
||||
{ error: validationMessage("auth.validation.invalidPasswordLength") },
|
||||
);
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -43,10 +43,10 @@ const loginSchema = z.object({
|
||||
});
|
||||
const registerSchema = loginSchema.refine(
|
||||
(value) => value.password === value.passwordConfirmation,
|
||||
(value) => ({
|
||||
{
|
||||
path: ["passwordConfirmation"],
|
||||
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
|
||||
}),
|
||||
error: validationMessage("auth.validation.passwordsDoNotMatch"),
|
||||
},
|
||||
);
|
||||
|
||||
type AuthFormValues = z.input<typeof loginSchema>;
|
||||
|
||||
@@ -44,14 +44,16 @@ type AccountIncomePageViewModel =
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const draftDate = z
|
||||
.string()
|
||||
.refine((value) => parseDraftDate(value) !== null, {
|
||||
error: validationMessage("income.validation.date"),
|
||||
})
|
||||
.refine(
|
||||
(value) => parseDraftDate(value) !== null,
|
||||
validationMessage("income.validation.date"),
|
||||
)
|
||||
.refine((value) => {
|
||||
const date = parseDraftDate(value);
|
||||
return !date || date <= moscowDate(new Date());
|
||||
}, validationMessage("income.validation.future"));
|
||||
(value) => {
|
||||
const date = parseDraftDate(value);
|
||||
return !date || date <= moscowDate(new Date());
|
||||
},
|
||||
{ error: validationMessage("income.validation.future") },
|
||||
);
|
||||
const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
|
||||
({ from, to }) => {
|
||||
const start = parseDraftDate(from);
|
||||
|
||||
Reference in New Issue
Block a user