mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge delivery trust candidate for coordination
Change-source: pulse-maintainer
This commit is contained in:
@@ -105,6 +105,12 @@ failed release-trust check still permits activation-marker inspection when the
|
||||
tag, numeric release ID, and exact source SHA are structurally valid. This
|
||||
exposes independent marker damage in the same evidence packet; it never admits
|
||||
the release or enables later delivery checks unless both trust checks pass.
|
||||
Scheduled and push-time npm audits use one-minute registry attempts and retry
|
||||
only explicit network or audit-endpoint failures. Advisory findings still fail
|
||||
without retry, and three unavailable registry responses remain a failed check.
|
||||
Audit steps defer their aggregate verdict so an unavailable advisory endpoint
|
||||
cannot suppress independent frontend checks or the production bundle build;
|
||||
the preceding clean install disables npm's duplicate best-effort audit request.
|
||||
Activation inspection also requires exactly one uploaded marker and compares
|
||||
the downloaded byte count and SHA-256 value with GitHub's release-asset
|
||||
metadata, so a valid-looking JSON response cannot silently replace or truncate
|
||||
|
||||
@@ -127,15 +127,21 @@ jobs:
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend-modern
|
||||
run: npm ci
|
||||
# The explicit bounded audits below own the advisory verdict. Avoid
|
||||
# npm ci's duplicate best-effort audit POST and its five-minute timeout.
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Audit complete frontend dependency graph
|
||||
id: audit-complete
|
||||
continue-on-error: true
|
||||
working-directory: frontend-modern
|
||||
run: npm audit
|
||||
run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh"'
|
||||
|
||||
- name: Audit production frontend dependencies
|
||||
id: audit-production
|
||||
continue-on-error: true
|
||||
working-directory: frontend-modern
|
||||
run: npm audit --omit=dev
|
||||
run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" --omit=dev'
|
||||
|
||||
# Whole-tree, not staged-only: the pre-commit formatter only ever sees
|
||||
# staged files, so drift in untouched files is invisible to it. This is
|
||||
@@ -171,6 +177,20 @@ jobs:
|
||||
working-directory: frontend-modern
|
||||
run: npm run check:bundlesize
|
||||
|
||||
# Audit service failures and real advisories stay gating, but neither may
|
||||
# suppress formatting, tests, type-checking, or the production build.
|
||||
- name: Require frontend dependency audits
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
COMPLETE_AUDIT_RESULT: ${{ steps.audit-complete.outcome }}
|
||||
PRODUCTION_AUDIT_RESULT: ${{ steps.audit-production.outcome }}
|
||||
run: |
|
||||
if [ "${COMPLETE_AUDIT_RESULT}" != success ] || \
|
||||
[ "${PRODUCTION_AUDIT_RESULT}" != success ]; then
|
||||
echo "::error::One or more frontend dependency audits failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backend:
|
||||
name: Backend tests (${{ matrix.shard }})
|
||||
needs: changes
|
||||
|
||||
@@ -383,9 +383,25 @@ jobs:
|
||||
node-version: '24'
|
||||
|
||||
- name: Audit complete dependency graph
|
||||
id: audit-complete
|
||||
continue-on-error: true
|
||||
working-directory: ${{ matrix.directory }}
|
||||
run: npm audit --package-lock-only
|
||||
run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" --package-lock-only'
|
||||
|
||||
- name: Audit production dependencies
|
||||
id: audit-production
|
||||
continue-on-error: true
|
||||
working-directory: ${{ matrix.directory }}
|
||||
run: npm audit --package-lock-only --omit=dev
|
||||
run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" --package-lock-only --omit=dev'
|
||||
|
||||
- name: Require dependency audits
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
COMPLETE_AUDIT_RESULT: ${{ steps.audit-complete.outcome }}
|
||||
PRODUCTION_AUDIT_RESULT: ${{ steps.audit-production.outcome }}
|
||||
run: |
|
||||
if [ "${COMPLETE_AUDIT_RESULT}" != success ] || \
|
||||
[ "${PRODUCTION_AUDIT_RESULT}" != success ]; then
|
||||
echo "::error::One or more dependency audits failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -2186,6 +2186,19 @@ artifact-selection behaviour.
|
||||
`scripts/trigger-release.sh` and `scripts/trigger-stable-patch.sh` must send
|
||||
the exact remote candidate SHA they already verified; branch ancestry or a
|
||||
later branch tip is not equivalent release admission.
|
||||
18. Keep frontend dependency security checks fail-closed without discarding
|
||||
independent build evidence. Push-time and scheduled complete/production
|
||||
dependency audits may retry only explicit registry transport or endpoint
|
||||
failures, with a bounded request timeout and attempt count; an advisory
|
||||
finding must fail immediately, and exhausted service failures must remain a
|
||||
failed check. In `.github/workflows/build-and-test.yml`, the aggregate audit
|
||||
verdict must run after formatting, lint, tests, type-checking, the production
|
||||
build, and the bundle-size check so an unavailable advisory service cannot
|
||||
suppress those results. The preceding `npm ci` must disable its duplicate
|
||||
best-effort audit request because the explicit checks own the security
|
||||
verdict. Keep this boundary covered in
|
||||
`scripts/installtests/build_release_assets_test.go` whenever its workflow or
|
||||
helper wiring changes.
|
||||
|
||||
## Current State
|
||||
|
||||
|
||||
@@ -3640,12 +3640,48 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFrontendDependencySecurityAuditsAreRequired(t *testing.T) {
|
||||
workflowPath := repoFile(".github", "workflows", "build-and-test.yml")
|
||||
assertFileContainsAll(t, workflowPath,
|
||||
buildWorkflowPath := repoFile(".github", "workflows", "build-and-test.yml")
|
||||
buildWorkflowBytes, err := os.ReadFile(buildWorkflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read build-and-test workflow: %v", err)
|
||||
}
|
||||
frontendJob := workflowJobBlock(t, string(buildWorkflowBytes), "frontend")
|
||||
for _, needle := range []string{
|
||||
`run: npm ci --no-audit`,
|
||||
`- name: Audit complete frontend dependency graph`,
|
||||
`run: npm audit`,
|
||||
`run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh"'`,
|
||||
`- name: Audit production frontend dependencies`,
|
||||
`run: npm audit --omit=dev`,
|
||||
`run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" --omit=dev'`,
|
||||
`continue-on-error: true`,
|
||||
`- name: Require frontend dependency audits`,
|
||||
`if: ${{ !cancelled() }}`,
|
||||
`COMPLETE_AUDIT_RESULT: ${{ steps.audit-complete.outcome }}`,
|
||||
`PRODUCTION_AUDIT_RESULT: ${{ steps.audit-production.outcome }}`,
|
||||
} {
|
||||
if !strings.Contains(frontendJob, needle) {
|
||||
t.Fatalf("build-and-test frontend job missing dependency audit contract: %s", needle)
|
||||
}
|
||||
}
|
||||
if verdictIndex, bundleIndex := strings.Index(frontendJob, "- name: Require frontend dependency audits"), strings.Index(frontendJob, "- name: Check frontend bundle size budget"); verdictIndex < bundleIndex || bundleIndex < 0 {
|
||||
t.Fatal("build-and-test must report independent frontend evidence before requiring audit success")
|
||||
}
|
||||
|
||||
securityWorkflowPath := repoFile(".github", "workflows", "security-scan.yml")
|
||||
assertFileContainsAll(t, securityWorkflowPath,
|
||||
`run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" --package-lock-only'`,
|
||||
`run: '"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" --package-lock-only --omit=dev'`,
|
||||
`- name: Require dependency audits`,
|
||||
`COMPLETE_AUDIT_RESULT: ${{ steps.audit-complete.outcome }}`,
|
||||
`PRODUCTION_AUDIT_RESULT: ${{ steps.audit-production.outcome }}`,
|
||||
)
|
||||
|
||||
auditHelperPath := repoFile("scripts", "npm-audit-retry.sh")
|
||||
assertFileContainsAll(t, auditHelperPath,
|
||||
`readonly max_attempts=3`,
|
||||
`readonly fetch_timeout_ms=60000`,
|
||||
`npm audit --fetch-timeout="${fetch_timeout_ms}" "$@"`,
|
||||
`if ! grep -Eiq`,
|
||||
`if (( attempt == max_attempts )); then`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Retry transient npm audit service failures without hiding vulnerability findings.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly max_attempts=3
|
||||
readonly fetch_timeout_ms=60000
|
||||
attempt=1
|
||||
output="$(mktemp)"
|
||||
trap 'rm -f -- "${output}"' EXIT
|
||||
|
||||
while (( attempt <= max_attempts )); do
|
||||
: >"${output}"
|
||||
set +e
|
||||
npm audit --fetch-timeout="${fetch_timeout_ms}" "$@" 2>&1 | tee "${output}"
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if (( status == 0 )); then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# npm audit uses a registry POST. npm's fetch retries cover idempotent reads,
|
||||
# so a transient timeout or audit endpoint error otherwise consumes the full
|
||||
# default five-minute timeout and fails the check without another attempt.
|
||||
# A real advisory report is not an infrastructure error and must remain an
|
||||
# immediate, fail-closed result.
|
||||
if ! grep -Eiq \
|
||||
'npm (warn|error) audit (network|endpoint|429 |5[0-9]{2} )|npm error (code )?(EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|ERR_SOCKET_TIMEOUT|FETCH_ERROR)|npm error network|npm error request to .* failed' \
|
||||
"${output}"; then
|
||||
exit "${status}"
|
||||
fi
|
||||
|
||||
if (( attempt == max_attempts )); then
|
||||
echo "npm audit registry request failed after ${max_attempts} attempts." >&2
|
||||
exit "${status}"
|
||||
fi
|
||||
|
||||
echo "npm audit registry request failed on attempt ${attempt}; retrying." >&2
|
||||
sleep 5
|
||||
((attempt += 1))
|
||||
done
|
||||
@@ -155,12 +155,18 @@ class DependabotConfigTest(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
scan_steps = jobs["npm-audit"]["steps"]
|
||||
audit_commands = [step["run"] for step in scan_steps if "run" in step]
|
||||
audit_commands = [
|
||||
step["run"]
|
||||
for step in scan_steps
|
||||
if step.get("id") in {"audit-complete", "audit-production"}
|
||||
]
|
||||
self.assertEqual(
|
||||
audit_commands,
|
||||
[
|
||||
"npm audit --package-lock-only",
|
||||
"npm audit --package-lock-only --omit=dev",
|
||||
'"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" '
|
||||
"--package-lock-only",
|
||||
'"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh" '
|
||||
"--package-lock-only --omit=dev",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "npm-audit-retry.sh"
|
||||
|
||||
|
||||
class NpmAuditRetryTest(unittest.TestCase):
|
||||
def run_check(self, mode: str, *arguments: str):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake_bin = Path(directory)
|
||||
count = fake_bin / "count"
|
||||
calls = fake_bin / "calls"
|
||||
sleep_calls = fake_bin / "sleep-calls"
|
||||
count.write_text("0\n", encoding="utf-8")
|
||||
fake_npm = fake_bin / "npm"
|
||||
fake_npm.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/bin/sh
|
||||
count=$(cat "$FAKE_NPM_COUNT")
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$count" > "$FAKE_NPM_COUNT"
|
||||
printf '%s\n' "$*" >> "$FAKE_NPM_CALLS"
|
||||
case "$FAKE_NPM_MODE" in
|
||||
success)
|
||||
echo 'found 0 vulnerabilities'
|
||||
exit 0
|
||||
;;
|
||||
transient-success)
|
||||
if [ "$count" -eq 1 ]; then
|
||||
echo 'npm warn audit network timeout at: https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
|
||||
echo 'npm error audit endpoint returned an error'
|
||||
exit 1
|
||||
fi
|
||||
echo 'found 0 vulnerabilities'
|
||||
exit 0
|
||||
;;
|
||||
transient-failure)
|
||||
echo 'npm error code ETIMEDOUT'
|
||||
echo 'npm error audit endpoint returned an error'
|
||||
exit 42
|
||||
;;
|
||||
vulnerability)
|
||||
echo '# npm audit report'
|
||||
echo 'example <2.0.0'
|
||||
echo '1 high severity vulnerability'
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 64
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_npm.chmod(0o755)
|
||||
fake_sleep = fake_bin / "sleep"
|
||||
fake_sleep.write_text(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_SLEEP_CALLS\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_sleep.chmod(0o755)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"FAKE_NPM_CALLS": str(calls),
|
||||
"FAKE_NPM_COUNT": str(count),
|
||||
"FAKE_NPM_MODE": mode,
|
||||
"FAKE_SLEEP_CALLS": str(sleep_calls),
|
||||
"PATH": f"{directory}:{env['PATH']}",
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[str(SCRIPT), *arguments],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
recorded_calls = (
|
||||
calls.read_text(encoding="utf-8").splitlines()
|
||||
if calls.exists()
|
||||
else []
|
||||
)
|
||||
recorded_sleeps = (
|
||||
sleep_calls.read_text(encoding="utf-8").splitlines()
|
||||
if sleep_calls.exists()
|
||||
else []
|
||||
)
|
||||
return result, recorded_calls, recorded_sleeps
|
||||
|
||||
def test_passes_a_clean_audit_without_retry(self) -> None:
|
||||
result, calls, sleeps = self.run_check("success", "--omit=dev")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls, ["audit --fetch-timeout=60000 --omit=dev"])
|
||||
self.assertEqual(sleeps, [])
|
||||
|
||||
def test_retries_a_transient_audit_endpoint_failure(self) -> None:
|
||||
result, calls, sleeps = self.run_check(
|
||||
"transient-success", "--package-lock-only"
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
"audit --fetch-timeout=60000 --package-lock-only",
|
||||
"audit --fetch-timeout=60000 --package-lock-only",
|
||||
],
|
||||
)
|
||||
self.assertEqual(sleeps, ["5"])
|
||||
self.assertIn("retrying", result.stderr)
|
||||
|
||||
def test_does_not_retry_a_vulnerability_report(self) -> None:
|
||||
result, calls, sleeps = self.run_check("vulnerability")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(calls, ["audit --fetch-timeout=60000"])
|
||||
self.assertEqual(sleeps, [])
|
||||
self.assertIn("1 high severity vulnerability", result.stdout)
|
||||
self.assertNotIn("retrying", result.stderr)
|
||||
|
||||
def test_persistent_registry_failure_remains_fatal(self) -> None:
|
||||
result, calls, sleeps = self.run_check("transient-failure")
|
||||
self.assertEqual(result.returncode, 42)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(sleeps, ["5", "5"])
|
||||
self.assertIn("failed after 3 attempts", result.stderr)
|
||||
|
||||
def test_all_workflow_audits_use_the_retry_boundary(self) -> None:
|
||||
for relative, job_name in (
|
||||
(".github/workflows/build-and-test.yml", "frontend"),
|
||||
(".github/workflows/security-scan.yml", "npm-audit"),
|
||||
):
|
||||
with self.subTest(workflow=relative):
|
||||
workflow = yaml.safe_load(
|
||||
(ROOT / relative).read_text(encoding="utf-8")
|
||||
)
|
||||
steps = workflow["jobs"][job_name]["steps"]
|
||||
if job_name == "frontend":
|
||||
install = next(
|
||||
step
|
||||
for step in steps
|
||||
if step.get("name") == "Install frontend dependencies"
|
||||
)
|
||||
self.assertEqual(install["run"], "npm ci --no-audit")
|
||||
audits = [
|
||||
step
|
||||
for step in steps
|
||||
if step.get("id") in {"audit-complete", "audit-production"}
|
||||
]
|
||||
self.assertEqual(len(audits), 2)
|
||||
self.assertTrue(
|
||||
all(
|
||||
step["run"].startswith(
|
||||
'"${GITHUB_WORKSPACE}/scripts/npm-audit-retry.sh"'
|
||||
)
|
||||
and step.get("continue-on-error") is True
|
||||
for step in audits
|
||||
)
|
||||
)
|
||||
verdict = steps[-1]
|
||||
self.assertIn("Require", verdict["name"])
|
||||
self.assertEqual(verdict["if"], "${{ !cancelled() }}")
|
||||
self.assertEqual(
|
||||
verdict["env"],
|
||||
{
|
||||
"COMPLETE_AUDIT_RESULT": "${{ steps.audit-complete.outcome }}",
|
||||
"PRODUCTION_AUDIT_RESULT": "${{ steps.audit-production.outcome }}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user