@superblocksteam/shared 0.9609.8 → 0.9611.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.d.ts +1 -0
- package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.js +21 -1
- package/dist/database-lifecycle/destructiveMigrations.js.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.test.js +22 -0
- package/dist/database-lifecycle/destructiveMigrations.test.js.map +1 -1
- package/dist/database-lifecycle/index.d.ts +6 -1
- package/dist/database-lifecycle/index.d.ts.map +1 -1
- package/dist/database-lifecycle/index.js +40 -8
- package/dist/database-lifecycle/index.js.map +1 -1
- package/dist/database-lifecycle/index.test.js +16 -1
- package/dist/database-lifecycle/index.test.js.map +1 -1
- package/dist/database-lifecycle/physicalInstanceResourceKey.d.ts +23 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.d.ts.map +1 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.js +45 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.js.map +1 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.test.d.ts +2 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.test.d.ts.map +1 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.test.js +58 -0
- package/dist/database-lifecycle/physicalInstanceResourceKey.test.js.map +1 -0
- package/dist/database-lifecycle/physicalPoolProvisioningResourceKey.test.d.ts +2 -0
- package/dist/database-lifecycle/physicalPoolProvisioningResourceKey.test.d.ts.map +1 -0
- package/dist/database-lifecycle/physicalPoolProvisioningResourceKey.test.js +19 -0
- package/dist/database-lifecycle/physicalPoolProvisioningResourceKey.test.js.map +1 -0
- package/dist/dbfs/types.d.ts +2 -0
- package/dist/dbfs/types.d.ts.map +1 -1
- package/dist/dbfs/types.js +4 -0
- package/dist/dbfs/types.js.map +1 -1
- package/dist/socket/protocol.d.ts +11 -3
- package/dist/socket/protocol.d.ts.map +1 -1
- package/dist/socket/protocol.js.map +1 -1
- package/dist/socket/socket.d.ts +12 -0
- package/dist/socket/socket.d.ts.map +1 -1
- package/dist/socket/socket.js +13 -1
- package/dist/socket/socket.js.map +1 -1
- package/dist/types/ai/index.d.ts +7 -0
- package/dist/types/ai/index.d.ts.map +1 -1
- package/dist/types/ai/index.js.map +1 -1
- package/dist/types/audit/ocsf.d.ts +10 -0
- package/dist/types/audit/ocsf.d.ts.map +1 -1
- package/dist/types/audit/ocsf.js +1 -0
- package/dist/types/audit/ocsf.js.map +1 -1
- package/dist/types/codeSecurity/index.d.ts +16 -0
- package/dist/types/codeSecurity/index.d.ts.map +1 -1
- package/dist/types/codeSecurity/index.js +23 -0
- package/dist/types/codeSecurity/index.js.map +1 -1
- package/dist/types/codeSecurity/index.test.js +48 -0
- package/dist/types/codeSecurity/index.test.js.map +1 -1
- package/dist/types/event/index.d.ts +13 -2
- package/dist/types/event/index.d.ts.map +1 -1
- package/dist/types/event/index.js +11 -0
- package/dist/types/event/index.js.map +1 -1
- package/dist/types/group/index.d.ts +3 -1
- package/dist/types/group/index.d.ts.map +1 -1
- package/dist/types/group/index.js +3 -1
- package/dist/types/group/index.js.map +1 -1
- package/dist/types/group/index.test.d.ts +2 -0
- package/dist/types/group/index.test.d.ts.map +1 -0
- package/dist/types/group/index.test.js +13 -0
- package/dist/types/group/index.test.js.map +1 -0
- package/dist/types/policyGate/errors.d.ts +78 -0
- package/dist/types/policyGate/errors.d.ts.map +1 -0
- package/dist/types/policyGate/errors.js +56 -0
- package/dist/types/policyGate/errors.js.map +1 -0
- package/dist/types/policyGate/index.d.ts +19 -5
- package/dist/types/policyGate/index.d.ts.map +1 -1
- package/dist/types/policyGate/index.js +51 -4
- package/dist/types/policyGate/index.js.map +1 -1
- package/dist/types/publishPipeline/displayNames.js +6 -6
- package/dist/types/publishPipeline/displayNames.js.map +1 -1
- package/dist/types/publishPipeline/displayNames.test.js +5 -5
- package/dist/types/publishPipeline/displayNames.test.js.map +1 -1
- package/dist/types/publishPipeline/index.d.ts +3 -3
- package/dist/types/publishPipeline/index.d.ts.map +1 -1
- package/dist/types/publishPipeline/plan.d.ts +1 -11
- package/dist/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist/types/publishPipeline/plan.js +2 -57
- package/dist/types/publishPipeline/plan.js.map +1 -1
- package/dist/types/publishPipeline/plan.test.js +9 -95
- package/dist/types/publishPipeline/plan.test.js.map +1 -1
- package/dist/types/response/PostApplicationCloneResponseBody.d.ts +12 -0
- package/dist/types/response/PostApplicationCloneResponseBody.d.ts.map +1 -0
- package/dist/types/response/PostApplicationCloneResponseBody.js +3 -0
- package/dist/types/response/PostApplicationCloneResponseBody.js.map +1 -0
- package/dist/types/response/index.d.ts +1 -0
- package/dist/types/response/index.d.ts.map +1 -1
- package/dist/types/response/index.js +1 -0
- package/dist/types/response/index.js.map +1 -1
- package/dist/types/reviewPolicy/index.d.ts +6 -1
- package/dist/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist/types/reviewPolicy/index.js +2 -1
- package/dist/types/reviewPolicy/index.js.map +1 -1
- package/dist/utils/attachment-upload.d.ts +31 -0
- package/dist/utils/attachment-upload.d.ts.map +1 -1
- package/dist/utils/attachment-upload.js +64 -1
- package/dist/utils/attachment-upload.js.map +1 -1
- package/dist/utils/attachment-upload.test.js +42 -0
- package/dist/utils/attachment-upload.test.js.map +1 -1
- package/dist/utils/git-url.d.ts.map +1 -1
- package/dist/utils/git-url.js +29 -6
- package/dist/utils/git-url.js.map +1 -1
- package/dist/utils/git-url.test.js +60 -0
- package/dist/utils/git-url.test.js.map +1 -1
- package/dist/utils/normalizeRoleName.js +2 -2
- package/dist/utils/normalizeRoleName.js.map +1 -1
- package/dist/utils/normalizeRoleName.test.d.ts +2 -0
- package/dist/utils/normalizeRoleName.test.d.ts.map +1 -0
- package/dist/utils/normalizeRoleName.test.js +25 -0
- package/dist/utils/normalizeRoleName.test.js.map +1 -0
- package/dist/utils/response-dto.test.d.ts +2 -0
- package/dist/utils/response-dto.test.d.ts.map +1 -0
- package/dist/utils/response-dto.test.js +28 -0
- package/dist/utils/response-dto.test.js.map +1 -0
- package/dist-esm/constants.d.ts +1 -0
- package/dist-esm/constants.d.ts.map +1 -1
- package/dist-esm/constants.js +1 -0
- package/dist-esm/constants.js.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +1 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.js +21 -2
- package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.test.js +23 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.test.js.map +1 -1
- package/dist-esm/database-lifecycle/index.d.ts +6 -1
- package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/index.js +36 -7
- package/dist-esm/database-lifecycle/index.js.map +1 -1
- package/dist-esm/database-lifecycle/index.test.js +17 -2
- package/dist-esm/database-lifecycle/index.test.js.map +1 -1
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.d.ts +23 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.d.ts.map +1 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.js +42 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.js.map +1 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.test.d.ts +2 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.test.d.ts.map +1 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.test.js +56 -0
- package/dist-esm/database-lifecycle/physicalInstanceResourceKey.test.js.map +1 -0
- package/dist-esm/database-lifecycle/physicalPoolProvisioningResourceKey.test.d.ts +2 -0
- package/dist-esm/database-lifecycle/physicalPoolProvisioningResourceKey.test.d.ts.map +1 -0
- package/dist-esm/database-lifecycle/physicalPoolProvisioningResourceKey.test.js +17 -0
- package/dist-esm/database-lifecycle/physicalPoolProvisioningResourceKey.test.js.map +1 -0
- package/dist-esm/dbfs/types.d.ts +2 -0
- package/dist-esm/dbfs/types.d.ts.map +1 -1
- package/dist-esm/dbfs/types.js +3 -1
- package/dist-esm/dbfs/types.js.map +1 -1
- package/dist-esm/socket/protocol.d.ts +11 -3
- package/dist-esm/socket/protocol.d.ts.map +1 -1
- package/dist-esm/socket/protocol.js.map +1 -1
- package/dist-esm/socket/socket.d.ts +12 -0
- package/dist-esm/socket/socket.d.ts.map +1 -1
- package/dist-esm/socket/socket.js +12 -0
- package/dist-esm/socket/socket.js.map +1 -1
- package/dist-esm/types/ai/index.d.ts +7 -0
- package/dist-esm/types/ai/index.d.ts.map +1 -1
- package/dist-esm/types/ai/index.js.map +1 -1
- package/dist-esm/types/audit/ocsf.d.ts +10 -0
- package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
- package/dist-esm/types/audit/ocsf.js +1 -0
- package/dist-esm/types/audit/ocsf.js.map +1 -1
- package/dist-esm/types/codeSecurity/index.d.ts +16 -0
- package/dist-esm/types/codeSecurity/index.d.ts.map +1 -1
- package/dist-esm/types/codeSecurity/index.js +22 -0
- package/dist-esm/types/codeSecurity/index.js.map +1 -1
- package/dist-esm/types/codeSecurity/index.test.js +49 -1
- package/dist-esm/types/codeSecurity/index.test.js.map +1 -1
- package/dist-esm/types/event/index.d.ts +13 -2
- package/dist-esm/types/event/index.d.ts.map +1 -1
- package/dist-esm/types/event/index.js +11 -0
- package/dist-esm/types/event/index.js.map +1 -1
- package/dist-esm/types/group/index.d.ts +3 -1
- package/dist-esm/types/group/index.d.ts.map +1 -1
- package/dist-esm/types/group/index.js +3 -1
- package/dist-esm/types/group/index.js.map +1 -1
- package/dist-esm/types/group/index.test.d.ts +2 -0
- package/dist-esm/types/group/index.test.d.ts.map +1 -0
- package/dist-esm/types/group/index.test.js +11 -0
- package/dist-esm/types/group/index.test.js.map +1 -0
- package/dist-esm/types/policyGate/errors.d.ts +78 -0
- package/dist-esm/types/policyGate/errors.d.ts.map +1 -0
- package/dist-esm/types/policyGate/errors.js +51 -0
- package/dist-esm/types/policyGate/errors.js.map +1 -0
- package/dist-esm/types/policyGate/index.d.ts +19 -5
- package/dist-esm/types/policyGate/index.d.ts.map +1 -1
- package/dist-esm/types/policyGate/index.js +33 -4
- package/dist-esm/types/policyGate/index.js.map +1 -1
- package/dist-esm/types/publishPipeline/displayNames.js +6 -6
- package/dist-esm/types/publishPipeline/displayNames.js.map +1 -1
- package/dist-esm/types/publishPipeline/displayNames.test.js +5 -5
- package/dist-esm/types/publishPipeline/displayNames.test.js.map +1 -1
- package/dist-esm/types/publishPipeline/index.d.ts +3 -3
- package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/plan.d.ts +1 -11
- package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/plan.js +2 -56
- package/dist-esm/types/publishPipeline/plan.js.map +1 -1
- package/dist-esm/types/publishPipeline/plan.test.js +10 -96
- package/dist-esm/types/publishPipeline/plan.test.js.map +1 -1
- package/dist-esm/types/response/PostApplicationCloneResponseBody.d.ts +12 -0
- package/dist-esm/types/response/PostApplicationCloneResponseBody.d.ts.map +1 -0
- package/dist-esm/types/response/PostApplicationCloneResponseBody.js +2 -0
- package/dist-esm/types/response/PostApplicationCloneResponseBody.js.map +1 -0
- package/dist-esm/types/response/index.d.ts +1 -0
- package/dist-esm/types/response/index.d.ts.map +1 -1
- package/dist-esm/types/response/index.js +1 -0
- package/dist-esm/types/response/index.js.map +1 -1
- package/dist-esm/types/reviewPolicy/index.d.ts +6 -1
- package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist-esm/types/reviewPolicy/index.js +2 -1
- package/dist-esm/types/reviewPolicy/index.js.map +1 -1
- package/dist-esm/utils/attachment-upload.d.ts +31 -0
- package/dist-esm/utils/attachment-upload.d.ts.map +1 -1
- package/dist-esm/utils/attachment-upload.js +60 -0
- package/dist-esm/utils/attachment-upload.js.map +1 -1
- package/dist-esm/utils/attachment-upload.test.js +43 -1
- package/dist-esm/utils/attachment-upload.test.js.map +1 -1
- package/dist-esm/utils/git-url.d.ts.map +1 -1
- package/dist-esm/utils/git-url.js +29 -6
- package/dist-esm/utils/git-url.js.map +1 -1
- package/dist-esm/utils/git-url.test.js +60 -0
- package/dist-esm/utils/git-url.test.js.map +1 -1
- package/dist-esm/utils/normalizeRoleName.js +2 -2
- package/dist-esm/utils/normalizeRoleName.js.map +1 -1
- package/dist-esm/utils/normalizeRoleName.test.d.ts +2 -0
- package/dist-esm/utils/normalizeRoleName.test.d.ts.map +1 -0
- package/dist-esm/utils/normalizeRoleName.test.js +23 -0
- package/dist-esm/utils/normalizeRoleName.test.js.map +1 -0
- package/dist-esm/utils/response-dto.test.d.ts +2 -0
- package/dist-esm/utils/response-dto.test.d.ts.map +1 -0
- package/dist-esm/utils/response-dto.test.js +26 -0
- package/dist-esm/utils/response-dto.test.js.map +1 -0
- package/package.json +5 -2
- package/src/constants.ts +1 -0
- package/src/database-lifecycle/destructiveMigrations.test.ts +27 -1
- package/src/database-lifecycle/destructiveMigrations.ts +22 -2
- package/src/database-lifecycle/index.test.ts +18 -1
- package/src/database-lifecycle/index.ts +41 -7
- package/src/database-lifecycle/physicalInstanceResourceKey.test.ts +66 -0
- package/src/database-lifecycle/physicalInstanceResourceKey.ts +51 -0
- package/src/database-lifecycle/physicalPoolProvisioningResourceKey.test.ts +22 -0
- package/src/dbfs/types.ts +5 -0
- package/src/socket/protocol.ts +10 -2
- package/src/socket/socket.ts +12 -0
- package/src/types/ai/index.ts +7 -0
- package/src/types/audit/ocsf.ts +12 -0
- package/src/types/codeSecurity/index.test.ts +58 -1
- package/src/types/codeSecurity/index.ts +49 -0
- package/src/types/event/index.ts +13 -2
- package/src/types/group/index.test.ts +13 -0
- package/src/types/group/index.ts +4 -2
- package/src/types/policyGate/errors.ts +66 -0
- package/src/types/policyGate/index.ts +44 -6
- package/src/types/publishPipeline/displayNames.test.ts +5 -5
- package/src/types/publishPipeline/displayNames.ts +6 -6
- package/src/types/publishPipeline/index.ts +3 -3
- package/src/types/publishPipeline/plan.test.ts +12 -105
- package/src/types/publishPipeline/plan.ts +3 -62
- package/src/types/response/PostApplicationCloneResponseBody.ts +12 -0
- package/src/types/response/index.ts +1 -0
- package/src/types/reviewPolicy/index.ts +7 -1
- package/src/utils/attachment-upload.test.ts +48 -1
- package/src/utils/attachment-upload.ts +78 -0
- package/src/utils/git-url.test.ts +65 -0
- package/src/utils/git-url.ts +29 -6
- package/src/utils/normalizeRoleName.test.ts +27 -0
- package/src/utils/normalizeRoleName.ts +2 -2
- package/src/utils/response-dto.test.ts +29 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ReviewFindingCategory, ReviewFindingConfidence, ReviewFindingSeverity } from '../reviewPolicy/index.js';
|
|
2
|
+
import { MAX_POLICY_GATE_RUN_ATTEMPTS } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export * from './errors.js';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* @deprecated Use the `superblocks.policy-gates.security-scans.enabled` LaunchDarkly flag instead.
|
|
@@ -28,6 +31,7 @@ export type PolicyGateReadinessFindingSummary = {
|
|
|
28
31
|
export type PolicyGateReadinessFinding = {
|
|
29
32
|
blocking: boolean;
|
|
30
33
|
humanSummary?: string | null;
|
|
34
|
+
/** Same contract as `ReviewFindingDto.locations`: primary location first. */
|
|
31
35
|
locationsJson?: unknown[];
|
|
32
36
|
remediationHintJson?: Record<string, unknown>;
|
|
33
37
|
severity: 'critical' | 'high' | 'info' | 'low' | 'medium';
|
|
@@ -60,7 +64,7 @@ export type PolicyGateReadinessItem = {
|
|
|
60
64
|
findingSummary: PolicyGateReadinessFindingSummary;
|
|
61
65
|
itemId: string;
|
|
62
66
|
itemType: 'security_agent' | 'security_scan';
|
|
63
|
-
mode: 'advisory' | 'approval' | 'blocking' | 'report_only';
|
|
67
|
+
mode: 'advisory' | 'approval' | 'blocking' | 'blocking_with_exceptions' | 'report_only';
|
|
64
68
|
policyId: string;
|
|
65
69
|
policyVersionId: string;
|
|
66
70
|
progress?: PolicyGateScanProgress;
|
|
@@ -70,6 +74,9 @@ export type PolicyGateReadinessItem = {
|
|
|
70
74
|
status: PolicyGateReadinessItemStatus;
|
|
71
75
|
};
|
|
72
76
|
|
|
77
|
+
// An outcome that stops the publish and cannot be waived. A gate that errored
|
|
78
|
+
// counts the same as findings: it never cleared the code, and this mode grants
|
|
79
|
+
// no exceptions.
|
|
73
80
|
export function isHardBlockingPolicyItem(item: Pick<PolicyGateReadinessItem, 'mode' | 'status'>): boolean {
|
|
74
81
|
if (item.mode !== 'blocking' && item.mode !== 'approval') {
|
|
75
82
|
return false;
|
|
@@ -77,15 +84,38 @@ export function isHardBlockingPolicyItem(item: Pick<PolicyGateReadinessItem, 'mo
|
|
|
77
84
|
return item.status === 'blocked' || item.status === 'failed';
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* An outcome that stops the publish but that "Publish anyway" may release. A
|
|
89
|
+
* gate that errored is waivable here because the admin picked the mode with
|
|
90
|
+
* exceptions; note this waives a review that never produced findings, not one
|
|
91
|
+
* whose findings the publisher read.
|
|
92
|
+
*/
|
|
93
|
+
export function isWaivablePolicyItem(item: Pick<PolicyGateReadinessItem, 'mode' | 'status'>): boolean {
|
|
94
|
+
return item.mode === 'blocking_with_exceptions' && (item.status === 'blocked' || item.status === 'failed');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isPublishHoldingPolicyItem(item: Pick<PolicyGateReadinessItem, 'mode' | 'status'>): boolean {
|
|
98
|
+
return isWaivablePolicyItem(item);
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/**
|
|
81
102
|
* Whether every item is an outcome an acknowledged "Publish anyway" hold is
|
|
82
|
-
* allowed to bypass. `missing`/`stale`/`running`/`
|
|
83
|
-
*
|
|
84
|
-
*
|
|
103
|
+
* allowed to bypass. `missing`/`stale`/`running`/`approval_required` mean some
|
|
104
|
+
* gate has not finished or hard-blocks unconditionally; an ack granted while
|
|
105
|
+
* any of those exist could waive a gate that is still deciding. A `blocked` or
|
|
106
|
+
* errored advisory item is settled without a waiver. Only
|
|
107
|
+
* `blocking_with_exceptions` outcomes are bypassable.
|
|
85
108
|
*/
|
|
86
|
-
export function areReadinessItemsAckEligible(items: Pick<PolicyGateReadinessItem, 'status'>[]): boolean {
|
|
109
|
+
export function areReadinessItemsAckEligible(items: Pick<PolicyGateReadinessItem, 'mode' | 'status'>[]): boolean {
|
|
87
110
|
return (
|
|
88
|
-
items.length > 0 &&
|
|
111
|
+
items.length > 0 &&
|
|
112
|
+
items.every(
|
|
113
|
+
(item) =>
|
|
114
|
+
item.status === 'passed' ||
|
|
115
|
+
item.status === 'advisory_findings' ||
|
|
116
|
+
isPublishHoldingPolicyItem(item) ||
|
|
117
|
+
(item.mode === 'advisory' && (item.status === 'blocked' || item.status === 'failed'))
|
|
118
|
+
)
|
|
89
119
|
);
|
|
90
120
|
}
|
|
91
121
|
|
|
@@ -175,3 +205,11 @@ export type ScanScopeJson = {
|
|
|
175
205
|
priorReviewRunId?: string;
|
|
176
206
|
unifiedDiff: string;
|
|
177
207
|
};
|
|
208
|
+
|
|
209
|
+
export function countFailedPolicyGateRuns(item: Pick<PolicyGateReadinessItem, 'runs'>): number {
|
|
210
|
+
return (item.runs ?? []).filter((run) => run.status === 'failed' && run.errorCode !== 'canceled').length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isPolicyGateItemExhausted(item: Pick<PolicyGateReadinessItem, 'mode' | 'runs' | 'status'>): boolean {
|
|
214
|
+
return item.status === 'failed' && item.mode !== 'advisory' && countFailedPolicyGateRuns(item) >= MAX_POLICY_GATE_RUN_ATTEMPTS;
|
|
215
|
+
}
|
|
@@ -53,10 +53,10 @@ describe('publishStepDisplayName', () => {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test('gives the policy step an active phrasing while it runs, and its plain name otherwise', () => {
|
|
56
|
-
expect(publishStepDisplayName('policy_gates', 'pending')).toBe('
|
|
57
|
-
expect(publishStepDisplayName('policy_gates', 'in_progress')).toBe('Running
|
|
58
|
-
expect(publishStepDisplayName('policy_gates', 'passed')).toBe('
|
|
59
|
-
expect(publishStepDisplayName('policy_gates', 'failed')).toBe('
|
|
56
|
+
expect(publishStepDisplayName('policy_gates', 'pending')).toBe('Run policy agents');
|
|
57
|
+
expect(publishStepDisplayName('policy_gates', 'in_progress')).toBe('Running policy agents…');
|
|
58
|
+
expect(publishStepDisplayName('policy_gates', 'passed')).toBe('Run policy agents');
|
|
59
|
+
expect(publishStepDisplayName('policy_gates', 'failed')).toBe('Run policy agents failed');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test('has a label for every kind so a new step cannot render an empty row', () => {
|
|
@@ -78,7 +78,7 @@ describe('publishStepDependencyName', () => {
|
|
|
78
78
|
expect(publishStepDependencyName('build')).toBe('the application build');
|
|
79
79
|
expect(publishStepDependencyName('database_migration')).toBe('schema migrations');
|
|
80
80
|
expect(publishStepDependencyName('database_provisioning')).toBe('database provisioning');
|
|
81
|
-
expect(publishStepDependencyName('policy_gates')).toBe('
|
|
81
|
+
expect(publishStepDependencyName('policy_gates')).toBe('policy agents');
|
|
82
82
|
expect(publishStepDependencyName('publish')).toBe('publishing');
|
|
83
83
|
});
|
|
84
84
|
|
|
@@ -19,8 +19,8 @@ type StepLabels = {
|
|
|
19
19
|
/**
|
|
20
20
|
* A step reads as an action: its plain, not-yet-running name (`idle`) while it
|
|
21
21
|
* waits, its active phrasing while it runs, and its past tense once done. The
|
|
22
|
-
* policy step
|
|
23
|
-
* its
|
|
22
|
+
* policy step uses the same idle and done name (`Run policy agents`) and only
|
|
23
|
+
* its running name gains a verb.
|
|
24
24
|
*/
|
|
25
25
|
const STEP_LABELS: Record<PublishStepKind, StepLabels> = {
|
|
26
26
|
build: {
|
|
@@ -42,10 +42,10 @@ const STEP_LABELS: Record<PublishStepKind, StepLabels> = {
|
|
|
42
42
|
idle: 'Provision database(s)'
|
|
43
43
|
},
|
|
44
44
|
policy_gates: {
|
|
45
|
-
active: 'Running
|
|
46
|
-
dependency: '
|
|
47
|
-
done: '
|
|
48
|
-
idle: '
|
|
45
|
+
active: 'Running policy agents',
|
|
46
|
+
dependency: 'policy agents',
|
|
47
|
+
done: 'Run policy agents',
|
|
48
|
+
idle: 'Run policy agents'
|
|
49
49
|
},
|
|
50
50
|
publish: {
|
|
51
51
|
active: 'Publishing',
|
|
@@ -15,8 +15,8 @@ export type PublishStepKind = 'build' | 'database_migration' | 'database_provisi
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Domain-agnostic status. Ordered here by roll-up precedence (worst first).
|
|
18
|
-
* `blocked` means this step's own check failed (remediation). Waiting on
|
|
19
|
-
*
|
|
18
|
+
* `blocked` means this step's own check failed (remediation). Waiting on a
|
|
19
|
+
* declared dependency is `pending` with optional `blockedBy`.
|
|
20
20
|
* `skipped` is a visible N/A row (e.g. Build when the app has no native-git remote).
|
|
21
21
|
*/
|
|
22
22
|
export type PublishStepStatus =
|
|
@@ -52,7 +52,7 @@ export type PublishStep = {
|
|
|
52
52
|
description?: string;
|
|
53
53
|
/** Short subtitle / one-line error. */
|
|
54
54
|
summary?: string;
|
|
55
|
-
/** When status is pending and waiting on
|
|
55
|
+
/** When status is pending and waiting on declared dependencies that are not yet clear. */
|
|
56
56
|
blockedBy?: PublishStepKind[];
|
|
57
57
|
/** Machine code when blocked/failed (e.g. POLICY_GATE_BLOCKED). */
|
|
58
58
|
blockingReason?: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import type { PublishStep,
|
|
4
|
-
import {
|
|
3
|
+
import type { PublishStep, PublishStepStatus } from './index.js';
|
|
4
|
+
import { buildPublishPlan, orderPublishSteps, rollUpPublishPlan } from './plan.js';
|
|
5
5
|
|
|
6
6
|
const TARGET = { applicationId: 'app-1', commitId: 'commit-1' };
|
|
7
7
|
|
|
@@ -81,111 +81,10 @@ describe('orderPublishSteps', () => {
|
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
describe('applyBlockedByEligibility', () => {
|
|
85
|
-
test('marks later pending steps as blockedBy earlier unclear steps', () => {
|
|
86
|
-
const steps = applyBlockedByEligibility([
|
|
87
|
-
step({ id: 'build', kind: 'build', status: 'in_progress' }),
|
|
88
|
-
step({ id: 'publish', kind: 'publish', status: 'pending' })
|
|
89
|
-
]);
|
|
90
|
-
expect(steps[1].blockedBy).toEqual<PublishStepKind[]>(['build']);
|
|
91
|
-
expect(steps[1].status).toBe('pending');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('does not overwrite a step that already failed', () => {
|
|
95
|
-
const steps = applyBlockedByEligibility([
|
|
96
|
-
step({ id: 'build', kind: 'build', status: 'failed' }),
|
|
97
|
-
step({ id: 'publish', kind: 'publish', status: 'failed' })
|
|
98
|
-
]);
|
|
99
|
-
expect(steps[1].blockedBy).toBeUndefined();
|
|
100
|
-
expect(steps[1].status).toBe('failed');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('holds an in_progress publish as pending until earlier policy gates clear', () => {
|
|
104
|
-
const steps = applyBlockedByEligibility([
|
|
105
|
-
step({
|
|
106
|
-
id: 'policy_gates:before_deploy',
|
|
107
|
-
kind: 'policy_gates',
|
|
108
|
-
status: 'in_progress',
|
|
109
|
-
displayName: 'Pre-Publish Policies'
|
|
110
|
-
}),
|
|
111
|
-
step({
|
|
112
|
-
id: 'publish',
|
|
113
|
-
kind: 'publish',
|
|
114
|
-
status: 'in_progress',
|
|
115
|
-
displayName: 'Publishing…'
|
|
116
|
-
})
|
|
117
|
-
]);
|
|
118
|
-
expect(steps[1].status).toBe('pending');
|
|
119
|
-
expect(steps[1].blockedBy).toEqual<PublishStepKind[]>(['policy_gates']);
|
|
120
|
-
expect(steps[1].displayName).toBe('Publish');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('clears blockedBy when all earlier steps are clear', () => {
|
|
124
|
-
const steps = applyBlockedByEligibility([
|
|
125
|
-
step({ id: 'build', kind: 'build', status: 'passed' }),
|
|
126
|
-
step({ id: 'publish', kind: 'publish', status: 'pending', blockedBy: ['build'] })
|
|
127
|
-
]);
|
|
128
|
-
expect(steps[1].blockedBy).toBeUndefined();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('does not block policy gates on an unfinished build (they run concurrently)', () => {
|
|
132
|
-
const steps = applyBlockedByEligibility([
|
|
133
|
-
step({ id: 'build', kind: 'build', status: 'in_progress' }),
|
|
134
|
-
step({
|
|
135
|
-
id: 'policy_gates:before_deploy',
|
|
136
|
-
kind: 'policy_gates',
|
|
137
|
-
status: 'in_progress',
|
|
138
|
-
displayName: 'Pre-Publish Policies'
|
|
139
|
-
})
|
|
140
|
-
]);
|
|
141
|
-
expect(steps[1].blockedBy).toBeUndefined();
|
|
142
|
-
expect(steps[1].status).toBe('in_progress');
|
|
143
|
-
expect(steps[1].displayName).toBe('Pre-Publish Policies');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test('keeps a pending policy gate idle rather than waiting on the build', () => {
|
|
147
|
-
const steps = applyBlockedByEligibility([
|
|
148
|
-
step({ id: 'build', kind: 'build', status: 'in_progress' }),
|
|
149
|
-
step({ id: 'policy_gates:before_deploy', kind: 'policy_gates', status: 'pending' })
|
|
150
|
-
]);
|
|
151
|
-
expect(steps[1].blockedBy).toBeUndefined();
|
|
152
|
-
expect(steps[1].status).toBe('pending');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test('still blocks publish on both the build and policy gates', () => {
|
|
156
|
-
const steps = applyBlockedByEligibility([
|
|
157
|
-
step({ id: 'build', kind: 'build', status: 'in_progress' }),
|
|
158
|
-
step({ id: 'policy_gates:before_deploy', kind: 'policy_gates', status: 'in_progress' }),
|
|
159
|
-
step({ id: 'publish', kind: 'publish', status: 'pending' })
|
|
160
|
-
]);
|
|
161
|
-
expect(steps[2].blockedBy).toEqual<PublishStepKind[]>(['build', 'policy_gates']);
|
|
162
|
-
expect(steps[2].status).toBe('pending');
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test('lists each unclear kind at most once in blockedBy', () => {
|
|
166
|
-
// Two earlier steps can share a kind (e.g. multiple database_* rows). The
|
|
167
|
-
// rail waits on kinds, not step ids, so blockedBy must not repeat a kind.
|
|
168
|
-
const steps = applyBlockedByEligibility([
|
|
169
|
-
step({
|
|
170
|
-
id: 'database_provisioning:a',
|
|
171
|
-
kind: 'database_provisioning',
|
|
172
|
-
status: 'in_progress'
|
|
173
|
-
}),
|
|
174
|
-
step({
|
|
175
|
-
id: 'database_provisioning:b',
|
|
176
|
-
kind: 'database_provisioning',
|
|
177
|
-
status: 'pending'
|
|
178
|
-
}),
|
|
179
|
-
step({ id: 'publish', kind: 'publish', status: 'pending' })
|
|
180
|
-
]);
|
|
181
|
-
expect(steps[2].blockedBy).toEqual<PublishStepKind[]>(['database_provisioning']);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
84
|
describe('buildPublishPlan', () => {
|
|
186
|
-
test('orders
|
|
85
|
+
test('orders steps on the spine and rolls up without inferring blockedBy', () => {
|
|
187
86
|
const plan = buildPublishPlan(TARGET, [
|
|
188
|
-
step({ id: 'publish', kind: 'publish', status: 'pending' }),
|
|
87
|
+
step({ id: 'publish', kind: 'publish', status: 'pending', blockedBy: ['build'] }),
|
|
189
88
|
step({ id: 'build', kind: 'build', status: 'in_progress' })
|
|
190
89
|
]);
|
|
191
90
|
expect(plan.target).toEqual(TARGET);
|
|
@@ -194,6 +93,14 @@ describe('buildPublishPlan', () => {
|
|
|
194
93
|
expect(plan.status).toBe('in_progress');
|
|
195
94
|
});
|
|
196
95
|
|
|
96
|
+
test('leaves a pending later step unblocked when blockedBy was not set', () => {
|
|
97
|
+
const plan = buildPublishPlan(TARGET, [
|
|
98
|
+
step({ id: 'publish', kind: 'publish', status: 'pending' }),
|
|
99
|
+
step({ id: 'build', kind: 'build', status: 'in_progress' })
|
|
100
|
+
]);
|
|
101
|
+
expect(plan.steps[1].blockedBy).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
197
104
|
test('an empty plan is passed', () => {
|
|
198
105
|
expect(buildPublishPlan(TARGET, []).status).toBe('passed');
|
|
199
106
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { PublishPlan, PublishPlanTarget, PublishStep, PublishStepKind, PublishStepStatus } from './index.js';
|
|
1
|
+
import type { PublishPlan, PublishPlanTarget, PublishStep, PublishStepStatus } from './index.js';
|
|
3
2
|
import { PUBLISH_STEP_SPINE } from './index.js';
|
|
4
3
|
|
|
5
4
|
/** Worst-first. When summarizing many steps into one status, first match wins. */
|
|
@@ -15,8 +14,6 @@ const STATUS_PRECEDENCE: PublishStepStatus[] = [
|
|
|
15
14
|
'skipped'
|
|
16
15
|
];
|
|
17
16
|
|
|
18
|
-
const CLEAR_STATUSES = new Set<PublishStepStatus>(['passed', 'advisory', 'skipped']);
|
|
19
|
-
|
|
20
17
|
/** Collapse ordered steps into the single status shown on the plan. Empty = passed. */
|
|
21
18
|
export function rollUpPublishPlan(steps: PublishStep[]): PublishStepStatus {
|
|
22
19
|
const present = new Set(steps.map((s) => s.status));
|
|
@@ -43,64 +40,8 @@ export function orderPublishSteps(steps: PublishStep[]): PublishStep[] {
|
|
|
43
40
|
return ordered;
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
return CLEAR_STATUSES.has(status);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Kinds that execute concurrently and therefore must not appear as each other's
|
|
52
|
-
* prerequisite. The SABS build and the policy agents both key off the commit and
|
|
53
|
-
* run in parallel — policy agents analyze the committed code directly and do not
|
|
54
|
-
* wait for the build artifact — so neither should show "Waiting on" the other in
|
|
55
|
-
* the rail. They both still gate the final publish.
|
|
56
|
-
*/
|
|
57
|
-
const CONCURRENT_KINDS = new Set<PublishStepKind>(['build', 'policy_gates']);
|
|
58
|
-
|
|
59
|
-
/** An earlier unclear step gates a later step unless the two run concurrently. */
|
|
60
|
-
function earlierKindBlocks(earlier: PublishStepKind, current: PublishStepKind): boolean {
|
|
61
|
-
return !(CONCURRENT_KINDS.has(earlier) && CONCURRENT_KINDS.has(current));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* For each step that is not yet clear, set `blockedBy` to kinds of earlier
|
|
66
|
-
* steps that are not clear and that actually gate it (concurrent steps such as
|
|
67
|
-
* build and policy gates do not gate each other). Premature `in_progress` (e.g.
|
|
68
|
-
* deploy already QUEUED while policy gates still run) is held as `pending` so
|
|
69
|
-
* the rail does not show "Publishing…" ahead of unfinished prerequisites. Failed
|
|
70
|
-
* and other terminal statuses are left alone. Clears blockedBy when the gating
|
|
71
|
-
* steps clear.
|
|
72
|
-
*/
|
|
73
|
-
export function applyBlockedByEligibility(steps: PublishStep[]): PublishStep[] {
|
|
74
|
-
// Kind set (not a list): multiple earlier steps can share a kind, but the
|
|
75
|
-
// rail waits on kinds — blockedBy must not repeat the same kind.
|
|
76
|
-
const unclearKinds = new Set<PublishStepKind>();
|
|
77
|
-
return steps.map((step) => {
|
|
78
|
-
const blockers = [...unclearKinds].filter((kind) => earlierKindBlocks(kind, step.kind));
|
|
79
|
-
if (blockers.length > 0 && (step.status === 'pending' || step.status === 'in_progress')) {
|
|
80
|
-
const next: PublishStep = {
|
|
81
|
-
...step,
|
|
82
|
-
status: 'pending',
|
|
83
|
-
blockedBy: blockers,
|
|
84
|
-
displayName: publishStepDisplayName(step.kind, 'pending')
|
|
85
|
-
};
|
|
86
|
-
unclearKinds.add(step.kind);
|
|
87
|
-
return next;
|
|
88
|
-
}
|
|
89
|
-
if (step.status === 'pending') {
|
|
90
|
-
const rest = { ...step };
|
|
91
|
-
delete rest.blockedBy;
|
|
92
|
-
unclearKinds.add(step.kind);
|
|
93
|
-
return rest;
|
|
94
|
-
}
|
|
95
|
-
if (!isClear(step.status)) {
|
|
96
|
-
unclearKinds.add(step.kind);
|
|
97
|
-
}
|
|
98
|
-
return step;
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Order, apply eligibility, roll up. */
|
|
43
|
+
/** Order and roll up. Assemble resolves each step's declared `blockedBy`. */
|
|
103
44
|
export function buildPublishPlan(target: PublishPlanTarget, steps: PublishStep[]): PublishPlan {
|
|
104
|
-
const ordered =
|
|
45
|
+
const ordered = orderPublishSteps(steps);
|
|
105
46
|
return { status: rollUpPublishPlan(ordered), steps: ordered, target };
|
|
106
47
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IApplicationV2Dto } from '../application/index.js';
|
|
2
|
+
|
|
3
|
+
export type PostApplicationCloneResponseBody = IApplicationV2Dto & {
|
|
4
|
+
/**
|
|
5
|
+
* True when the source app had a managed database. Cloning copies the source
|
|
6
|
+
* code but not the database, its migration requirement or its integration, so
|
|
7
|
+
* the clone's code still points at the source app's integration id and cannot
|
|
8
|
+
* reach a database of its own until one is provisioned and the code is
|
|
9
|
+
* repointed. The editor uses this to start that setup on first open.
|
|
10
|
+
*/
|
|
11
|
+
requiresDatabaseSetup?: boolean;
|
|
12
|
+
};
|
|
@@ -15,6 +15,7 @@ export * from './PostApplicationCommitCreateResponseBody.js';
|
|
|
15
15
|
export * from './PostApiBranchCreateResponseBody.js';
|
|
16
16
|
export * from './PostApplicationBranchCreateResponseBody.js';
|
|
17
17
|
export * from './PostApplicationRemoteCommitCreateResponseBody.js';
|
|
18
|
+
export * from './PostApplicationCloneResponseBody.js';
|
|
18
19
|
export * from './PostApplicationCreateResponseBody.js';
|
|
19
20
|
export * from './PostApplicationDeployResponseBody.js';
|
|
20
21
|
export * from './PostApplicationResetResponseBody.js';
|
|
@@ -22,7 +22,8 @@ export const REVIEW_POLICY_EXECUTION_CONTEXTS = [
|
|
|
22
22
|
'superblocks_sabs_source_snapshot'
|
|
23
23
|
] as const;
|
|
24
24
|
export const REVIEW_POLICY_RUNTIMES = ['superblocks_dev_server_agent', 'superblocks_sabs'] as const;
|
|
25
|
-
|
|
25
|
+
// Ordered from least to most strict; the mode select renders in array order.
|
|
26
|
+
export const REVIEW_POLICY_MODES = ['advisory', 'blocking_with_exceptions', 'blocking'] as const;
|
|
26
27
|
export const REVIEW_POLICY_RUNNER_TYPES = ['built_in_security_scan', 'llm_security_agent'] as const;
|
|
27
28
|
export const REVIEW_FINDING_SOURCE_PHASES = ['built_asset', 'dependency', 'edit_mode_app', 'iac', 'image', 'source'] as const;
|
|
28
29
|
export const REVIEW_FINDING_CATEGORIES = [
|
|
@@ -190,6 +191,11 @@ export interface ReviewFindingDto {
|
|
|
190
191
|
humanSummary: string | null;
|
|
191
192
|
technicalSummary: string | null;
|
|
192
193
|
evidence: Record<string, unknown>;
|
|
194
|
+
/**
|
|
195
|
+
* Scanner-specific location payloads; the primary location (where the rule
|
|
196
|
+
* fired) MUST be first. Known shapes: `{ filePath, line }` (security agent),
|
|
197
|
+
* `{ filePath, startLine, endLine }` (semgrep), `{ path | file, line }` (legacy).
|
|
198
|
+
*/
|
|
193
199
|
locations: unknown[];
|
|
194
200
|
remediationHint: Record<string, unknown>;
|
|
195
201
|
clarkRemediable: boolean;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
ATTACHMENT_CONTENT_MISMATCH_CODE,
|
|
4
5
|
buildAttachmentUploadMetadata,
|
|
5
6
|
buildAttachmentUploadFormData,
|
|
6
7
|
createUploadedAttachmentFromServer,
|
|
7
8
|
getTextAttachmentType,
|
|
9
|
+
parseUserFacingUploadError,
|
|
8
10
|
isOfficeXml,
|
|
9
11
|
normalizeAttachmentMimeType,
|
|
10
12
|
toAttachmentUploadDescriptor
|
|
@@ -74,7 +76,10 @@ describe('buildAttachmentUploadFormData', () => {
|
|
|
74
76
|
|
|
75
77
|
const uploadedFile = formData.get('file');
|
|
76
78
|
expect(uploadedFile).toBeInstanceOf(File);
|
|
77
|
-
|
|
79
|
+
if (!(uploadedFile instanceof File)) {
|
|
80
|
+
throw new Error('expected File');
|
|
81
|
+
}
|
|
82
|
+
expect(uploadedFile.name).toBe('capture.webp');
|
|
78
83
|
expect(formData.get('prefix')).toBe('captured-screenshot');
|
|
79
84
|
});
|
|
80
85
|
|
|
@@ -255,3 +260,45 @@ describe('isOfficeXml', () => {
|
|
|
255
260
|
).toBe(false);
|
|
256
261
|
});
|
|
257
262
|
});
|
|
263
|
+
|
|
264
|
+
// The server returns a user-facing sentence in `{ error }` for actionable
|
|
265
|
+
// rejections. Wrapping it in `status=400, body={...}` buries the only part
|
|
266
|
+
// the user can act on inside a developer-shaped string.
|
|
267
|
+
describe('parseUserFacingUploadError', () => {
|
|
268
|
+
test('extracts the server error sentence', () => {
|
|
269
|
+
const body = JSON.stringify({
|
|
270
|
+
code: ATTACHMENT_CONTENT_MISMATCH_CODE,
|
|
271
|
+
error: 'inspection_256551628.pdf is not a valid PDF. The file may be corrupted or saved with the wrong extension.'
|
|
272
|
+
});
|
|
273
|
+
expect(parseUserFacingUploadError(body)).toEqual({
|
|
274
|
+
code: ATTACHMENT_CONTENT_MISMATCH_CODE,
|
|
275
|
+
message: 'inspection_256551628.pdf is not a valid PDF. The file may be corrupted or saved with the wrong extension.'
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// `{ error }` is the upload route's generic error channel, used for internal
|
|
280
|
+
// 500/502 diagnostics too ('App scoped token is required...', 'Bucketeer
|
|
281
|
+
// returned an invalid response', and raw Bucketeer response text). Without a
|
|
282
|
+
// discriminator, promoting it verbatim showed users internal upstream detail
|
|
283
|
+
// as if it were actionable guidance.
|
|
284
|
+
test.each([
|
|
285
|
+
['no code at all', { error: 'Bucketeer returned an invalid response' }],
|
|
286
|
+
['an unknown code', { code: 'internal_upstream_failure', error: '<stack trace and host detail>' }],
|
|
287
|
+
['a non-string code', { code: 42, error: 'App scoped token is required for app scope uploads' }]
|
|
288
|
+
])('does not promote a server error carrying %s', (_label, body) => {
|
|
289
|
+
expect(parseUserFacingUploadError(JSON.stringify(body))).toBeUndefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test.each([
|
|
293
|
+
['non-JSON body', '<html>502 Bad Gateway</html>'],
|
|
294
|
+
['empty body', ''],
|
|
295
|
+
['whitespace body', ' '],
|
|
296
|
+
['JSON without an error field', '{"message":"nope"}'],
|
|
297
|
+
['JSON with a non-string error', '{"error":{"code":42}}'],
|
|
298
|
+
['JSON with a blank error', '{"error":" "}'],
|
|
299
|
+
['JSON array', '[1,2,3]'],
|
|
300
|
+
['JSON null', 'null']
|
|
301
|
+
])('returns undefined for %s so the caller keeps the diagnostic form', (_label, body) => {
|
|
302
|
+
expect(parseUserFacingUploadError(body)).toBeUndefined();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -630,8 +630,86 @@ export async function uploadAttachmentToServerApi(params: {
|
|
|
630
630
|
|
|
631
631
|
if (!response.ok) {
|
|
632
632
|
const errorText = await response.text().catch(() => '');
|
|
633
|
+
const parsed = parseUserFacingUploadError(errorText);
|
|
634
|
+
if (parsed) {
|
|
635
|
+
throw new AttachmentUploadApiError(parsed.message, {
|
|
636
|
+
code: parsed.code,
|
|
637
|
+
status: response.status
|
|
638
|
+
});
|
|
639
|
+
}
|
|
633
640
|
throw new Error(`Attachment upload failed: status=${response.status}${errorText ? `, body=${errorText}` : ''}`);
|
|
634
641
|
}
|
|
635
642
|
|
|
636
643
|
return (await response.json()) as AttachmentUploadServerDto;
|
|
637
644
|
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The upload route sets this code ONLY on rejections whose `{ error }` sentence
|
|
648
|
+
* is written for the end user and is safe to show verbatim.
|
|
649
|
+
*
|
|
650
|
+
* `{ error }` is otherwise the route's generic diagnostic channel. It carries
|
|
651
|
+
* internal 500/502 detail (app-scoped-token, invalid Bucketeer response, raw
|
|
652
|
+
* Bucketeer text). Promoting that to a user-facing sentence leaked upstream
|
|
653
|
+
* internals into the UI, so the verbatim path is gated on this code rather
|
|
654
|
+
* than on the presence of the field.
|
|
655
|
+
*/
|
|
656
|
+
export type UserFacingUploadErrorCode = 'attachment_content_mismatch';
|
|
657
|
+
|
|
658
|
+
export const ATTACHMENT_CONTENT_MISMATCH_CODE: UserFacingUploadErrorCode = 'attachment_content_mismatch';
|
|
659
|
+
|
|
660
|
+
export class AttachmentUploadApiError extends Error {
|
|
661
|
+
readonly code: UserFacingUploadErrorCode;
|
|
662
|
+
readonly status: number;
|
|
663
|
+
|
|
664
|
+
constructor(message: string, options: { code: UserFacingUploadErrorCode; status: number }) {
|
|
665
|
+
super(message);
|
|
666
|
+
this.name = 'AttachmentUploadApiError';
|
|
667
|
+
this.code = options.code;
|
|
668
|
+
this.status = options.status;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function isUserFacingUploadError(error: unknown): error is AttachmentUploadApiError {
|
|
673
|
+
return error instanceof AttachmentUploadApiError;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function readStringProperty(value: object, key: string): string | undefined {
|
|
677
|
+
if (!(key in value)) {
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
const property = Reflect.get(value, key);
|
|
681
|
+
return typeof property === 'string' ? property : undefined;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function isUserFacingUploadErrorCode(code: string): code is UserFacingUploadErrorCode {
|
|
685
|
+
return code === ATTACHMENT_CONTENT_MISMATCH_CODE;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Pull the server's user-facing `{ code, error }` pair out of a response body.
|
|
690
|
+
* Returns undefined unless `code` is {@link ATTACHMENT_CONTENT_MISMATCH_CODE},
|
|
691
|
+
* and for non-JSON bodies, non-string `error` fields, and blank strings, so
|
|
692
|
+
* the caller keeps the diagnostic form that still carries the HTTP status.
|
|
693
|
+
*/
|
|
694
|
+
export function parseUserFacingUploadError(body: string): { code: UserFacingUploadErrorCode; message: string } | undefined {
|
|
695
|
+
if (!body.trim()) {
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
const parsed: unknown = JSON.parse(body);
|
|
700
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
const code = readStringProperty(parsed, 'code');
|
|
704
|
+
const message = readStringProperty(parsed, 'error');
|
|
705
|
+
if (code === undefined || !isUserFacingUploadErrorCode(code)) {
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
if (message === undefined || !message.trim()) {
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
return { code, message };
|
|
712
|
+
} catch {
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
@@ -21,6 +21,10 @@ describe('isValidGitUrl', () => {
|
|
|
21
21
|
it('trims whitespace', () => {
|
|
22
22
|
expect(isValidGitUrl(' https://github.com/org/repo ')).toBe(true);
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
it('accepts ssh:// double-slash absolute-path URLs (valid git SSH syntax)', () => {
|
|
26
|
+
expect(isValidGitUrl('ssh://git@host//org/repo.git')).toBe(true);
|
|
27
|
+
});
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
describe('isGitHubUrl', () => {
|
|
@@ -144,3 +148,64 @@ describe('sshGitUrlToHttps', () => {
|
|
|
144
148
|
expect(sshGitUrlToHttps('not-a-url')).toBeNull();
|
|
145
149
|
});
|
|
146
150
|
});
|
|
151
|
+
|
|
152
|
+
describe('git-url ReDoS (SEC-78)', () => {
|
|
153
|
+
it('still validates/normalizes real URLs', () => {
|
|
154
|
+
expect(isValidGitUrl('https://github.com/org/repo.git')).toBe(true);
|
|
155
|
+
expect(isValidGitUrl('git@github.com:org/repo.git')).toBe(true);
|
|
156
|
+
expect(isValidGitUrl('ssh://git@github.com/org/repo')).toBe(true);
|
|
157
|
+
expect(isValidGitUrl('not a url')).toBe(false);
|
|
158
|
+
expect(gitRemoteUrlToWeb('git@github.com:org/repo.git')).toBe('https://github.com/org/repo');
|
|
159
|
+
expect(sshGitUrlToHttps('git@github.com:org/repo.git')).toBe('https://github.com/org/repo.git');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('still converts SCP-style paths containing spaces (e.g. Azure DevOps repo names)', () => {
|
|
163
|
+
// Raw SCP `git@host:path` strings are not URL-encoded by convention, so
|
|
164
|
+
// legitimate paths (Azure DevOps project/repo names in particular) may
|
|
165
|
+
// contain literal spaces. The bounded regex must preserve this — only the
|
|
166
|
+
// length is capped, not the character class.
|
|
167
|
+
expect(gitRemoteUrlToWeb('git@host:org/repo name.git')).toBe('https://host/org/repo name');
|
|
168
|
+
expect(sshGitUrlToHttps('git@host:org/repo name.git')).toBe('https://host/org/repo name.git');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('does not rescan trailing whitespace polynomially on the SCP sshMatch fallback', () => {
|
|
172
|
+
// Internal-whitespace shape: a long run of spaces BEFORE the final
|
|
173
|
+
// non-whitespace char, rather than after it (the existing adversarial
|
|
174
|
+
// test puts the non-whitespace run first). Against the pre-fix
|
|
175
|
+
// `(?:\.git)?\s*$` tail this drove a genuine O(4096*n) scan (measured
|
|
176
|
+
// ~3.9s at n=1,000,000) because the lazy path capture and the trailing
|
|
177
|
+
// `\s*` were two independent backtracking axes. `trimmed` never ends in
|
|
178
|
+
// whitespace, so `\s*` before the final `$` was always redundant — it
|
|
179
|
+
// was dropped in favor of a plain `$`.
|
|
180
|
+
const evil = 'git@h:' + ' '.repeat(200000) + 'X';
|
|
181
|
+
const start = performance.now();
|
|
182
|
+
gitRemoteUrlToWeb(evil);
|
|
183
|
+
sshGitUrlToHttps(evil);
|
|
184
|
+
expect(performance.now() - start).toBeLessThan(100);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not backtrack polynomially on adversarial inputs', () => {
|
|
188
|
+
const cases = [
|
|
189
|
+
'https://' + 'a.'.repeat(50000), // HTTPS_GIT_URL_RE host
|
|
190
|
+
'https://x/' + 'a/'.repeat(50000), // HTTPS_GIT_URL_RE path
|
|
191
|
+
'git@' + 'a.'.repeat(50000) + ':x', // SSH_GIT_URL_RE host
|
|
192
|
+
'ssh://git@h/' + 'a/'.repeat(50000), // SSH_PROTOCOL_RE path
|
|
193
|
+
'git@h:' + 'a'.repeat(200000), // SCP sshMatch (L72/226)
|
|
194
|
+
// Trailing-whitespace ambiguity against the `(?:\.git)?\s*$` tail: the
|
|
195
|
+
// lazy `.+?` capture and the `\s*` quantifier are two independent
|
|
196
|
+
// O(n) backtracking axes, which combine into genuine O(n^2) blowup on
|
|
197
|
+
// the pre-fix regex (confirmed: ~35ms at n=8000, ~120ms at n=16000 —
|
|
198
|
+
// consistent ~4x-per-2x quadratic scaling; would take tens of seconds
|
|
199
|
+
// at n=200000 without the bound).
|
|
200
|
+
'git@h:' + 'a'.repeat(16000) + ' '.repeat(16000) + 'X',
|
|
201
|
+
'git@ssh.dev.azure.com:v3/o/p/' + 'a'.repeat(16000) + ' '.repeat(16000) + 'X'
|
|
202
|
+
];
|
|
203
|
+
for (const evil of cases) {
|
|
204
|
+
const start = performance.now();
|
|
205
|
+
isValidGitUrl(evil);
|
|
206
|
+
gitRemoteUrlToWeb(evil);
|
|
207
|
+
sshGitUrlToHttps(evil);
|
|
208
|
+
expect(performance.now() - start).toBeLessThan(150);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|