Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/security-audit-forward-remediation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# #3114 forward-remediation readiness

This note documents how to recognize a safe forward-remediation candidate for the temporary `deepmerge-ts` audit exception tracked in #3114.

## Upstream state checked on 2026-09-10

Prisma upstream merged `prisma/orm#30189` into the `v7` branch on 2026-09-01. That change updates `@prisma/config` from `deepmerge-ts` 7.1.5 to 8.0.2. The latest stable Prisma 7 release visible when this note was written is 7.10.0, released on 2026-08-25, so it predates that merge. JSMKC therefore must not assume that a currently installable stable Prisma package already contains the fix.

References:

- https://github.com/prisma/orm/issues/30052
- https://github.com/prisma/orm/pull/30189
- https://github.com/prisma/orm/releases/tag/7.10.0

## Status classification

`smkc-score-app/scripts/security-audit-status.js` remains fail-closed. The current vulnerable lock context reports `active`. Arbitrary dependency drift reports `context-changed`.

A new `forward-remediation-candidate` state is reported only when both of these lockfile facts move to the patched line:

1. the installed `node_modules/deepmerge-ts` version is semver `>= 8.0.0`; and
2. the `node_modules/@prisma/config` dependency edge itself requests a simple patched `deepmerge-ts` version/range.

This deliberately does not classify a consumer-side override as a forward-remediation candidate. For example, if the installed package is forced to 8.0.2 while `@prisma/config` still declares `deepmerge-ts: 7.1.5`, the status stays `context-changed`.

The classifier accepts only simple exact, caret, tilde, or lower-bound requirements that can be compared safely. Complex or workspace ranges are not guessed and remain generic context drift.

## Required action when the candidate appears

`forward-remediation-candidate` still exits non-zero. It is evidence that the dependency graph may now contain the upstream fix, not proof that #3114 can be closed.

Before removing the temporary exception:

1. run the canonical `node scripts/security-audit.js` path with the pinned npm runtime;
2. confirm GHSA-ggr8-5vv4-36mx is absent from the actual audit report;
3. run unit tests, lint, changed-file formatting, Prisma/D1 migration parity, and the Cloudflare build gate; and
4. remove the #3114 temporary allowlist only in the same reviewed change that records the verified forward dependency versions.

This keeps the existing policy distinction between "dependency context changed" and "security issue confirmed remediated" while making a genuine upstream dependency-edge update immediately visible to automation and maintainers.
53 changes: 48 additions & 5 deletions smkc-score-app/__tests__/scripts/security-audit-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import path from 'node:path';
import {
formatSecurityAuditExceptionStatus,
getDaysUntilReviewDeadline,
getPrismaConfigDeepmergeRequirement,
getSecurityAuditExceptionStatus,
getTrackedDependencyVersions,
hasForwardRemediationCandidate,
isPatchedDeepmergeRequirement,
isPatchedDeepmergeVersion,
parseCliOptions,
writeGitHubOutputs,
} from '../../scripts/security-audit-status.js';
Expand Down Expand Up @@ -102,23 +106,62 @@ describe('security audit exception status', () => {
).toBe('expired');
});

it('reports context-changed with forward-remediation version evidence', () => {
it('classifies a patched installed version plus patched @prisma/config dependency edge as a forward remediation candidate', () => {
const remediatedLockfile = structuredClone(lockfile);
remediatedLockfile.packages['node_modules/deepmerge-ts'].version = '8.0.1';
remediatedLockfile.packages['node_modules/@prisma/config'].dependencies['deepmerge-ts'] = '8.0.1';
remediatedLockfile.packages['node_modules/deepmerge-ts'].version = '8.0.2';
remediatedLockfile.packages['node_modules/@prisma/config'].dependencies['deepmerge-ts'] = '8.0.2';

const status = getSecurityAuditExceptionStatus({
manifest,
lockfile: remediatedLockfile,
now: new Date('2026-09-08T00:00:00.000Z'),
});

expect(status.state).toBe('context-changed');
expect(status.state).toBe('forward-remediation-candidate');
expect(status.versions).toEqual({
prisma: '6.19.3',
prismaConfig: '6.19.3',
deepmergeTs: '8.0.1',
deepmergeTs: '8.0.2',
});
expect(status.message).toContain('run the full security audit and CI');
expect(getPrismaConfigDeepmergeRequirement(remediatedLockfile)).toBe('8.0.2');
expect(hasForwardRemediationCandidate(remediatedLockfile)).toBe(true);
});

it('keeps a consumer-side installed-version override as generic context-changed evidence', () => {
const overriddenLockfile = structuredClone(lockfile);
overriddenLockfile.packages['node_modules/deepmerge-ts'].version = '8.0.2';

const status = getSecurityAuditExceptionStatus({
manifest,
lockfile: overriddenLockfile,
now: new Date('2026-09-08T00:00:00.000Z'),
});

expect(status.state).toBe('context-changed');
expect(status.versions.deepmergeTs).toBe('8.0.2');
expect(getPrismaConfigDeepmergeRequirement(overriddenLockfile)).toBe('7.1.5');
expect(hasForwardRemediationCandidate(overriddenLockfile)).toBe(false);
});

it('treats only semver versions at or above the patched deepmerge-ts boundary as patched', () => {
expect(isPatchedDeepmergeVersion('7.1.6')).toBe(false);
expect(isPatchedDeepmergeVersion('8.0.0-rc.1')).toBe(false);
expect(isPatchedDeepmergeVersion('8.0.0')).toBe(true);
expect(isPatchedDeepmergeVersion('8.0.0+build.1')).toBe(true);
expect(isPatchedDeepmergeVersion('8.0.1-beta.1')).toBe(true);
expect(isPatchedDeepmergeVersion('9.0.0')).toBe(true);
expect(isPatchedDeepmergeVersion('not-semver')).toBe(false);
});

it('accepts only simple patched dependency requirements for remediation candidate classification', () => {
expect(isPatchedDeepmergeRequirement('8.0.2')).toBe(true);
expect(isPatchedDeepmergeRequirement('^8.0.2')).toBe(true);
expect(isPatchedDeepmergeRequirement('~8.0.2')).toBe(true);
expect(isPatchedDeepmergeRequirement('>= 8.0.0')).toBe(true);
expect(isPatchedDeepmergeRequirement('^7.1.5')).toBe(false);
expect(isPatchedDeepmergeRequirement('>=8.0.0 <9')).toBe(false);
expect(isPatchedDeepmergeRequirement('workspace:^8.0.0')).toBe(false);
});

it('uses null when a tracked dependency version cannot be read', () => {
Expand Down
86 changes: 86 additions & 0 deletions smkc-score-app/scripts/security-audit-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const TRACKED_DEPENDENCY_PATHS = {
deepmergeTs: 'node_modules/deepmerge-ts',
};
const SAFE_VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/;
const COMPARABLE_SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
const SIMPLE_REQUIREMENT_PATTERN = /^(?:\^|~|>=)?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
const PATCHED_DEEPMERGE_VERSION = Object.freeze({ major: 8, minor: 0, patch: 0 });
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;

function parseCliOptions(argv = process.argv.slice(2)) {
Expand All @@ -45,6 +48,71 @@ function getTrackedDependencyVersions(lockfile) {
);
}

function parseComparableSemver(version) {
if (typeof version !== 'string') {
return null;
}

const match = COMPARABLE_SEMVER_PATTERN.exec(version);
if (!match) {
return null;
}

const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (![major, minor, patch].every(Number.isSafeInteger)) {
return null;
}

return {
major,
minor,
patch,
prerelease: match[4] ?? null,
};
}

function isPatchedDeepmergeVersion(version) {
const parsed = parseComparableSemver(version);
if (!parsed) {
return false;
}

if (parsed.major !== PATCHED_DEEPMERGE_VERSION.major) {
return parsed.major > PATCHED_DEEPMERGE_VERSION.major;
}
if (parsed.minor !== PATCHED_DEEPMERGE_VERSION.minor) {
return parsed.minor > PATCHED_DEEPMERGE_VERSION.minor;
}
if (parsed.patch !== PATCHED_DEEPMERGE_VERSION.patch) {
return parsed.patch > PATCHED_DEEPMERGE_VERSION.patch;
}

return parsed.prerelease === null;
}

function getPrismaConfigDeepmergeRequirement(lockfile) {
const requirement = lockfile?.packages?.[TRACKED_DEPENDENCY_PATHS.prismaConfig]?.dependencies?.['deepmerge-ts'];
return typeof requirement === 'string' && requirement.length > 0 ? requirement : null;
}

function isPatchedDeepmergeRequirement(requirement) {
if (typeof requirement !== 'string') {
return false;
}

const match = SIMPLE_REQUIREMENT_PATTERN.exec(requirement);
return Boolean(match && isPatchedDeepmergeVersion(match[1]));
}

function hasForwardRemediationCandidate(lockfile) {
const versions = getTrackedDependencyVersions(lockfile);
const requirement = getPrismaConfigDeepmergeRequirement(lockfile);

return isPatchedDeepmergeVersion(versions.deepmergeTs) && isPatchedDeepmergeRequirement(requirement);
}

function getDaysUntilReviewDeadline(deadline, now = new Date()) {
const deadlineMs = Date.parse(deadline);
const nowMs = now.getTime();
Expand Down Expand Up @@ -88,6 +156,19 @@ function getSecurityAuditExceptionStatus({ manifest, lockfile, now = new Date()
};
}

if (hasForwardRemediationCandidate(lockfile)) {
return {
...identity,
state: 'forward-remediation-candidate',
deadline,
checkedAt,
daysUntilDeadline,
versions,
message:
'the installed deepmerge-ts version and @prisma/config dependency edge both point to >=8.0.0; run the full security audit and CI before removing the #3114 exception',
};
}

if (!hasExpectedTemporaryExceptionContext(lockfile, manifest)) {
return {
...identity,
Expand Down Expand Up @@ -204,8 +285,13 @@ if (require.main === module) {
module.exports = {
formatSecurityAuditExceptionStatus,
getDaysUntilReviewDeadline,
getPrismaConfigDeepmergeRequirement,
getSecurityAuditExceptionStatus,
getTrackedDependencyVersions,
hasForwardRemediationCandidate,
isPatchedDeepmergeRequirement,
isPatchedDeepmergeVersion,
parseCliOptions,
parseComparableSemver,
writeGitHubOutputs,
};
Loading