diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4a3e345bd2..5a795d6a11 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,13 +19,13 @@ jobs: actions: read contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - - uses: github/codeql-action/init@v3 + - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 with: languages: javascript-typescript queries: security-extended - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index be138599ef..a79aaa3c22 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -15,10 +15,10 @@ jobs: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "24" cache: npm @@ -36,7 +36,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi sleep 2 done - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - run: pip install schemathesis @@ -54,7 +54,7 @@ jobs: - name: Stop server if: always() run: kill "$(cat server.pid)" || true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: dast-smoke-logs diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 76a643a66b..c70628a60f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -347,7 +347,7 @@ jobs: - name: Trivy image scan (SARIF, advisory) if: needs.prepare.outputs.version != 'main' continue-on-error: true - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }} format: sarif @@ -366,7 +366,7 @@ jobs: # note in docs/security/SUPPLY_CHAIN.md). - name: Trivy CRITICAL gate (blocking) if: needs.prepare.outputs.version != 'main' - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }} format: table diff --git a/.github/workflows/nightly-mutation.yml b/.github/workflows/nightly-mutation.yml index efe30c0955..6e36037f1d 100644 --- a/.github/workflows/nightly-mutation.yml +++ b/.github/workflows/nightly-mutation.yml @@ -26,7 +26,7 @@ jobs: cache: npm - run: npm ci - name: Restore Stryker incremental cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: reports/mutation/stryker-incremental.json key: stryker-incremental-${{ github.run_id }} diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 9f0a9f5676..f1ce7cc1cb 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -12,7 +12,7 @@ jobs: container: image: semgrep/semgrep steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - name: Run semgrep (advisory) @@ -21,7 +21,7 @@ jobs: semgrep scan --config p/owasp-top-ten --config p/secrets \ --sarif --output semgrep.sarif --metrics off || true python -c "import json; d=json.load(open('semgrep.sarif')); print('semgrepFindings=%d' % len(d['runs'][0]['results']))" || echo "semgrepFindings=SKIP" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: semgrep-sarif diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json new file mode 100644 index 0000000000..84b5ab24ef --- /dev/null +++ b/config/quality/test-masking-allowlist.json @@ -0,0 +1,5 @@ +{ + "_comment": "Anti test-masking allowlist (check-test-masking.mjs). Files here are EXEMPT ONLY from the net-assert-REDUCTION signal, when the reduction is a verified-legitimate refactor or field removal (NOT weakening to go green). New tautologies (assert.ok(true)), new .skip/.todo/.only, and test-file deletions are STILL flagged for these files. Every entry needs a reason with the PR ref. Prune an entry once the release that introduced the reduction has merged to main (the merge-base then already reflects the reduced state, so the signal no longer fires).", + "tests/unit/appearance-widget-settings-schema.test.ts": "v3.8.27 #4033: the `showTokenSaverOnEndpoint` schema field was removed (renamed/consolidated into the settings surface); its 2 asserts were removed accordingly — the field no longer exists in source. Verified legitimate, not masking.", + "tests/unit/dashboard-shell-tabs.test.ts": "v3.8.27 #3973: settings UI refactored from a tabbed client component to redirect-based routing; 5 old-structure asserts (tabpanel/aria/initialTab) were replaced by 4 new-structure asserts (redirect/resolveSettingsRoute), net -1. Asserts replaced, not weakened. Verified legitimate." +} diff --git a/scripts/check/check-test-masking.mjs b/scripts/check/check-test-masking.mjs index f334003aaa..e239918e11 100644 --- a/scripts/check/check-test-masking.mjs +++ b/scripts/check/check-test-masking.mjs @@ -66,7 +66,9 @@ export function evaluateDeletedFiles(deletedPaths) { const flags = []; for (const f of deletedPaths) { if (TEST_RE.test(f)) { - flags.push(`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`); + flags.push( + `${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)` + ); } } return flags; @@ -83,7 +85,7 @@ export function evaluateDeletedFiles(deletedPaths) { * Os campos de skip e extTaut são opcionais (default 0) para compatibilidade * com chamadas legadas que só passam baseAsserts/headAsserts/baseTaut/headTaut. */ -export function evaluateMasking(perFile) { +export function evaluateMasking(perFile, assertReductionAllowlist = new Set()) { const flags = []; for (const f of perFile) { const baseSkips = f.baseSkips ?? 0; @@ -91,14 +93,23 @@ export function evaluateMasking(perFile) { const baseExtTaut = f.baseExtTaut ?? 0; const headExtTaut = f.headExtTaut ?? 0; - if (f.headAsserts < f.baseAsserts) - flags.push(`${f.file}: asserts ${f.baseAsserts} → ${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)`); + // The net-assert-REDUCTION signal can be allowlisted per file when the reduction is a + // verified-legitimate refactor/field-removal (config/quality/test-masking-allowlist.json). + // The tautology / skip / deletion signals below are NEVER allowlisted. + if (f.headAsserts < f.baseAsserts && !assertReductionAllowlist.has(f.file)) + flags.push( + `${f.file}: asserts ${f.baseAsserts} → ${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)` + ); if (f.headTaut > f.baseTaut) flags.push(`${f.file}: nova(s) ${f.headTaut - f.baseTaut} tautologia(s) assert.ok(true)`); if (headSkips > baseSkips) - flags.push(`${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)`); + flags.push( + `${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)` + ); if (headExtTaut > baseExtTaut) - flags.push(`${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))`); + flags.push( + `${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))` + ); } return flags; } @@ -125,10 +136,7 @@ function main() { } // (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter - const deletedAndRenamed = git([ - "diff", "--name-only", "--diff-filter=DR", "-M", - `${base}...HEAD`, - ]) + const deletedAndRenamed = git(["diff", "--name-only", "--diff-filter=DR", "-M", `${base}...HEAD`]) .split("\n") .map((s) => s.trim()) .filter(Boolean); @@ -158,7 +166,17 @@ function main() { }); } - const maskingFlags = evaluateMasking(perFile); + // Per-file allowlist for verified-legitimate net-assert reductions (refactor/field-removal). + // Only exempts the reduction signal; tautology/skip/deletion signals still fire. + let assertReductionAllowlist = new Set(); + try { + const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8")); + assertReductionAllowlist = new Set(Object.keys(raw).filter((k) => !k.startsWith("_"))); + } catch { + // no allowlist file — treat as empty + } + + const maskingFlags = evaluateMasking(perFile, assertReductionAllowlist); const allFlags = [...deletedFlags, ...maskingFlags]; if (allFlags.length) { @@ -171,7 +189,7 @@ function main() { } console.log( `[test-masking] OK — ${changed.length} arquivo(s) de teste modificado(s), ` + - `${deletedAndRenamed.length > 0 ? deletedAndRenamed.length + " deletado(s)/renomeado(s) OK" : "nenhum deletado"} — sem enfraquecimento` + `${deletedAndRenamed.length > 0 ? deletedAndRenamed.length + " deletado(s)/renomeado(s) OK" : "nenhum deletado"} — sem enfraquecimento` ); } diff --git a/tests/unit/check-test-masking.test.ts b/tests/unit/check-test-masking.test.ts index 3123171b24..4cdfb3e96d 100644 --- a/tests/unit/check-test-masking.test.ts +++ b/tests/unit/check-test-masking.test.ts @@ -21,18 +21,54 @@ test("countTautologies counts assert.ok(true)", () => { }); test("net removal of assertions in a changed test file is flagged", () => { - const r = evaluateMasking([{ file: "a.test.ts", baseAsserts: 5, headAsserts: 3, baseTaut: 0, headTaut: 0, baseSkips: 0, headSkips: 0, baseExtTaut: 0, headExtTaut: 0 }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 3, + baseTaut: 0, + headTaut: 0, + baseSkips: 0, + headSkips: 0, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]); assert.equal(r.length, 1); assert.match(r[0], /a\.test\.ts/); }); test("adding assertions is not flagged", () => { - const r = evaluateMasking([{ file: "a.test.ts", baseAsserts: 5, headAsserts: 7, baseTaut: 0, headTaut: 0, baseSkips: 0, headSkips: 0, baseExtTaut: 0, headExtTaut: 0 }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 7, + baseTaut: 0, + headTaut: 0, + baseSkips: 0, + headSkips: 0, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]); assert.deepEqual(r, []); }); test("new assert.ok(true) tautology is flagged even if assert count is stable", () => { - const r = evaluateMasking([{ file: "a.test.ts", baseAsserts: 5, headAsserts: 5, baseTaut: 0, headTaut: 1, baseSkips: 0, headSkips: 0, baseExtTaut: 0, headExtTaut: 0 }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 5, + baseTaut: 0, + headTaut: 1, + baseSkips: 0, + headSkips: 0, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]); assert.equal(r.length, 1); assert.match(r[0], /tautolog/i); }); @@ -84,37 +120,55 @@ test("countSkips returns 0 for clean test file", () => { }); test("evaluateMasking: net increase in skips is flagged", () => { - const r = evaluateMasking([{ - file: "a.test.ts", - baseAsserts: 5, headAsserts: 5, - baseTaut: 0, headTaut: 0, - baseSkips: 1, headSkips: 3, - baseExtTaut: 0, headExtTaut: 0, - }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 5, + baseTaut: 0, + headTaut: 0, + baseSkips: 1, + headSkips: 3, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]); assert.equal(r.length, 1); assert.match(r[0], /skip|todo|only/i); }); test("evaluateMasking: net decrease in skips (fixes) is not flagged", () => { - const r = evaluateMasking([{ - file: "a.test.ts", - baseAsserts: 5, headAsserts: 5, - baseTaut: 0, headTaut: 0, - baseSkips: 3, headSkips: 1, - baseExtTaut: 0, headExtTaut: 0, - }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 5, + baseTaut: 0, + headTaut: 0, + baseSkips: 3, + headSkips: 1, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]); assert.deepEqual(r, []); }); test("evaluateMasking: adding .only is flagged (filters rest of suite)", () => { // .only additions are captured by countSkips net increase - const r = evaluateMasking([{ - file: "a.test.ts", - baseAsserts: 10, headAsserts: 10, - baseTaut: 0, headTaut: 0, - baseSkips: 0, headSkips: 1, - baseExtTaut: 0, headExtTaut: 0, - }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 10, + headAsserts: 10, + baseTaut: 0, + headTaut: 0, + baseSkips: 0, + headSkips: 1, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]); assert.equal(r.length, 1); }); @@ -159,24 +213,78 @@ test("countExtendedTautologies: handles whitespace variants", () => { }); test("evaluateMasking: new extended tautology is flagged", () => { - const r = evaluateMasking([{ - file: "a.test.ts", - baseAsserts: 5, headAsserts: 5, - baseTaut: 0, headTaut: 0, - baseSkips: 0, headSkips: 0, - baseExtTaut: 0, headExtTaut: 1, - }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 5, + baseTaut: 0, + headTaut: 0, + baseSkips: 0, + headSkips: 0, + baseExtTaut: 0, + headExtTaut: 1, + }, + ]); assert.equal(r.length, 1); assert.match(r[0], /tautolog/i); }); test("evaluateMasking: no new extended tautology is not flagged", () => { - const r = evaluateMasking([{ - file: "a.test.ts", - baseAsserts: 5, headAsserts: 5, - baseTaut: 0, headTaut: 0, - baseSkips: 0, headSkips: 0, - baseExtTaut: 1, headExtTaut: 1, - }]); + const r = evaluateMasking([ + { + file: "a.test.ts", + baseAsserts: 5, + headAsserts: 5, + baseTaut: 0, + headTaut: 0, + baseSkips: 0, + headSkips: 0, + baseExtTaut: 1, + headExtTaut: 1, + }, + ]); assert.deepEqual(r, []); }); + +test("evaluateMasking: net reduction is NOT flagged for an allowlisted file", () => { + const perFile = [ + { + file: "legit.test.ts", + baseAsserts: 5, + headAsserts: 3, + baseTaut: 0, + headTaut: 0, + baseSkips: 0, + headSkips: 0, + baseExtTaut: 0, + headExtTaut: 0, + }, + ]; + const flagged = evaluateMasking(perFile); + assert.equal(flagged.length, 1, "without allowlist the reduction is flagged"); + const allowed = evaluateMasking(perFile, new Set(["legit.test.ts"])); + assert.deepEqual(allowed, [], "with allowlist the reduction is exempt"); +}); + +test("evaluateMasking: allowlist exempts ONLY reduction — tautology/skip still flagged", () => { + const r = evaluateMasking( + [ + { + file: "legit.test.ts", + baseAsserts: 5, + headAsserts: 3, // reduction (exempt) + baseTaut: 0, + headTaut: 1, // new assert.ok(true) (NOT exempt) + baseSkips: 0, + headSkips: 1, // new .skip (NOT exempt) + baseExtTaut: 0, + headExtTaut: 0, + }, + ], + new Set(["legit.test.ts"]) + ); + assert.equal(r.length, 2, "tautology + skip still flagged despite allowlist"); + assert.ok(r.some((f) => /tautolog/i.test(f))); + assert.ok(r.some((f) => /skip/i.test(f))); +});