ci(quality): unblock v3.8.27 release gates (zizmor pin + test-masking allowlist)

- zizmor ratchet (151→139, no regression): SHA-pin every action ref ADDED this
  cycle — codeql/dast-smoke/semgrep (3 new workflows) + trivy-action (docker-publish)
  + actions/cache (nightly-mutation). Pre-existing tag refs keep the repo convention.
- test-masking: add config/quality/test-masking-allowlist.json + allowlist support in
  check-test-masking.mjs (exempts ONLY the net-assert-reduction signal; tautology/skip/
  deletion still fire). Allowlists 2 verified-legitimate reductions:
  appearance-widget-settings-schema (#4033 removed showTokenSaverOnEndpoint field) and
  dashboard-shell-tabs (#3973 tabs→redirect refactor, asserts replaced). +4 gate tests.
This commit is contained in:
diegosouzapw
2026-06-17 00:42:20 -03:00
parent 9b788967cb
commit b30000607b
8 changed files with 193 additions and 62 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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."
}

View File

@@ -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`
);
}

View File

@@ -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)));
});