@cronn/playwright-utils 0.9.0 → 0.10.0
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/index.d.mts +33 -1
- package/dist/index.mjs +208 -13
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -618,6 +618,38 @@ export declare function getCspHeader(response: Response | null): Promise<string>
|
|
|
618
618
|
*/
|
|
619
619
|
export declare function cspReport(header: string): string;
|
|
620
620
|
//#endregion
|
|
621
|
+
//#region src/security/response-header-report.d.ts
|
|
622
|
+
type ResponseHeaderType = "document" | "api";
|
|
623
|
+
interface ResponseHeaderReportOptions {
|
|
624
|
+
type: ResponseHeaderType;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Returns all headers of a response, or an empty object if the response is
|
|
628
|
+
* missing. For page responses, this uses `response.allHeaders()`, which in
|
|
629
|
+
* contrast to `response.headers()` includes security-related headers.
|
|
630
|
+
*/
|
|
631
|
+
export declare function getResponseHeaders(response: Response | APIResponse | null): Promise<Record<string, string>>;
|
|
632
|
+
/**
|
|
633
|
+
* Evaluates the headers of a response against a set of recommended security
|
|
634
|
+
* headers and returns the result as a Markdown report.
|
|
635
|
+
*
|
|
636
|
+
* The report lists all evaluated headers of the response, followed by
|
|
637
|
+
* recommendations for missing or deviating headers and for discouraged headers
|
|
638
|
+
* to remove. Unrelated headers like `date` are omitted to keep the report
|
|
639
|
+
* deterministic for file snapshots.
|
|
640
|
+
*
|
|
641
|
+
* @example
|
|
642
|
+
* ```ts
|
|
643
|
+
* const response = await page.goto("/");
|
|
644
|
+
* const headers = await getResponseHeaders(response);
|
|
645
|
+
*
|
|
646
|
+
* expect(responseHeaderReport(headers, { type: "document" })).toMatchTextFile({
|
|
647
|
+
* fileExtension: "md",
|
|
648
|
+
* });
|
|
649
|
+
* ```
|
|
650
|
+
*/
|
|
651
|
+
export declare function responseHeaderReport(headers: Record<string, string>, options: ResponseHeaderReportOptions): string;
|
|
652
|
+
//#endregion
|
|
621
653
|
//#region src/file.d.ts
|
|
622
654
|
/**
|
|
623
655
|
* Resolves the given path segments against the root directory of the closest
|
|
@@ -757,4 +789,4 @@ export declare function maskedValueWithIndex(name: string): (index: number) => s
|
|
|
757
789
|
*/
|
|
758
790
|
type PlaywrightTarget = Page | Locator;
|
|
759
791
|
//#endregion
|
|
760
|
-
export type { ConsoleMessageFilter, ConsoleMessageLevel, CspFixtures, CspViolation, ExtendedLocator, FormFields, HttpMethod, PlaywrightTarget, ResponseHandler, RoleLocators, RouteFilter };
|
|
792
|
+
export type { ConsoleMessageFilter, ConsoleMessageLevel, CspFixtures, CspViolation, ExtendedLocator, FormFields, HttpMethod, PlaywrightTarget, ResponseHandler, ResponseHeaderReportOptions, ResponseHeaderType, RoleLocators, RouteFilter };
|
package/dist/index.mjs
CHANGED
|
@@ -791,6 +791,41 @@ function maskedValueWithIndex(name) {
|
|
|
791
791
|
return (index) => maskedValue(`${name}_${index}`);
|
|
792
792
|
}
|
|
793
793
|
//#endregion
|
|
794
|
+
//#region src/utils/markdown.ts
|
|
795
|
+
const NONE = "None";
|
|
796
|
+
/**
|
|
797
|
+
* Creates a Markdown document with a top-level title and a second-level
|
|
798
|
+
* heading for each section.
|
|
799
|
+
*/
|
|
800
|
+
function markdownDocument(title, sections) {
|
|
801
|
+
return [`# ${title}`, ...sections.flatMap((section) => [`## ${section.heading}`, section.content])].join("\n\n").concat("\n");
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Creates a Markdown table, or `None` if there are no rows.
|
|
805
|
+
*/
|
|
806
|
+
function markdownTableOrNone(header, rows) {
|
|
807
|
+
return rows.length === 0 ? NONE : markdownTable([header, ...rows]);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Creates a Markdown list with an item per entry formatted as
|
|
811
|
+
* `` `name`: message ``, or `None` if there are no entries.
|
|
812
|
+
*/
|
|
813
|
+
function markdownEntriesOrNone(entries) {
|
|
814
|
+
return entries.length === 0 ? NONE : entries.map((entry) => `- ${code(entry.name)}: ${entry.message}`).join("\n");
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Formats a value as inline code, or as `_none_` if the value is blank.
|
|
818
|
+
*/
|
|
819
|
+
function codeOrNone(text) {
|
|
820
|
+
return text.trim() === "" ? "_none_" : code(text);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Formats a text as inline code. Pipes are escaped to keep tables intact.
|
|
824
|
+
*/
|
|
825
|
+
function code(text) {
|
|
826
|
+
return `\`${text.replaceAll("|", "\\|")}\``;
|
|
827
|
+
}
|
|
828
|
+
//#endregion
|
|
794
829
|
//#region src/security/csp-report.ts
|
|
795
830
|
const RECOMMENDED_DIRECTIVES = [
|
|
796
831
|
{ name: "upgrade-insecure-requests" },
|
|
@@ -899,13 +934,20 @@ async function getCspHeader(response) {
|
|
|
899
934
|
*/
|
|
900
935
|
function cspReport(header) {
|
|
901
936
|
const { directives, recommendations } = evaluateCsp(parseCsp(header));
|
|
902
|
-
return [
|
|
903
|
-
"
|
|
904
|
-
"
|
|
905
|
-
|
|
906
|
-
"
|
|
907
|
-
|
|
908
|
-
]
|
|
937
|
+
return markdownDocument("CSP Report", [{
|
|
938
|
+
heading: "Detected Directives",
|
|
939
|
+
content: markdownTableOrNone(["Directive", "Value"], maskNonces(directives).map((directive) => [code(directive.name), formatValues(directive.values)]))
|
|
940
|
+
}, {
|
|
941
|
+
heading: "Recommendations",
|
|
942
|
+
content: markdownEntriesOrNone(recommendations)
|
|
943
|
+
}]);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Masks all nonce sources of a `Content-Security-Policy` header as
|
|
947
|
+
* `'nonce-[NONCE_0]'`, `'nonce-[NONCE_1]'`, … and normalizes its formatting.
|
|
948
|
+
*/
|
|
949
|
+
function maskCspNonces(header) {
|
|
950
|
+
return maskNonces(parseCsp(header)).map((directive) => [directive.name, ...directive.values].join(" ")).join("; ");
|
|
909
951
|
}
|
|
910
952
|
function parseCsp(header) {
|
|
911
953
|
return header.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "").map((directive) => {
|
|
@@ -922,7 +964,7 @@ function evaluateCsp(directives) {
|
|
|
922
964
|
return {
|
|
923
965
|
directives,
|
|
924
966
|
recommendations: RECOMMENDED_DIRECTIVES.flatMap((recommended) => evaluateDirective(recommended, directivesByName).map((message) => ({
|
|
925
|
-
|
|
967
|
+
name: recommended.name,
|
|
926
968
|
message
|
|
927
969
|
})))
|
|
928
970
|
};
|
|
@@ -983,11 +1025,164 @@ function isNonce(value) {
|
|
|
983
1025
|
return value.startsWith("'nonce-");
|
|
984
1026
|
}
|
|
985
1027
|
function formatValues(values) {
|
|
986
|
-
|
|
987
|
-
return code(values.join(" "));
|
|
1028
|
+
return codeOrNone(values.join(" "));
|
|
988
1029
|
}
|
|
989
|
-
|
|
990
|
-
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/security/response-header-report.ts
|
|
1032
|
+
const ACTION_ORDER = [
|
|
1033
|
+
"add",
|
|
1034
|
+
"change",
|
|
1035
|
+
"remove"
|
|
1036
|
+
];
|
|
1037
|
+
const MIN_HSTS_MAX_AGE = 31536e3;
|
|
1038
|
+
const PERMISSIONS_POLICY = "geolocation=(), microphone=(), camera=()";
|
|
1039
|
+
const RECOMMENDED_COMMON_HEADERS = [
|
|
1040
|
+
{ name: "cache-control" },
|
|
1041
|
+
{ name: "content-security-policy" },
|
|
1042
|
+
{ name: "content-type" },
|
|
1043
|
+
{
|
|
1044
|
+
name: "cross-origin-opener-policy",
|
|
1045
|
+
recommended: "same-origin"
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
name: "cross-origin-resource-policy",
|
|
1049
|
+
recommended: "same-origin"
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
name: "strict-transport-security",
|
|
1053
|
+
recommended: `max-age=${MIN_HSTS_MAX_AGE}; includeSubDomains`,
|
|
1054
|
+
isAccepted: isStrictHsts
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
name: "x-content-type-options",
|
|
1058
|
+
recommended: "nosniff"
|
|
1059
|
+
},
|
|
1060
|
+
{ name: "x-robots-tag" }
|
|
1061
|
+
];
|
|
1062
|
+
const DISCOURAGED_COMMON_HEADERS = ["access-control-allow-origin"];
|
|
1063
|
+
const RECOMMENDED_DOCUMENT_HEADERS = [
|
|
1064
|
+
...RECOMMENDED_COMMON_HEADERS,
|
|
1065
|
+
{
|
|
1066
|
+
name: "permissions-policy",
|
|
1067
|
+
recommended: PERMISSIONS_POLICY,
|
|
1068
|
+
isAccepted: isRestrictivePermissionsPolicy
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
name: "referrer-policy",
|
|
1072
|
+
recommended: "no-referrer"
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
name: "x-frame-options",
|
|
1076
|
+
recommended: "DENY"
|
|
1077
|
+
}
|
|
1078
|
+
];
|
|
1079
|
+
const DISCOURAGED_DOCUMENT_HEADERS = [...DISCOURAGED_COMMON_HEADERS, "x-xss-protection"];
|
|
1080
|
+
const RECOMMENDED_HEADERS = {
|
|
1081
|
+
document: RECOMMENDED_DOCUMENT_HEADERS,
|
|
1082
|
+
api: RECOMMENDED_COMMON_HEADERS
|
|
1083
|
+
};
|
|
1084
|
+
const DISCOURAGED_HEADERS = {
|
|
1085
|
+
document: DISCOURAGED_DOCUMENT_HEADERS,
|
|
1086
|
+
api: DISCOURAGED_COMMON_HEADERS
|
|
1087
|
+
};
|
|
1088
|
+
/**
|
|
1089
|
+
* Returns all headers of a response, or an empty object if the response is
|
|
1090
|
+
* missing. For page responses, this uses `response.allHeaders()`, which in
|
|
1091
|
+
* contrast to `response.headers()` includes security-related headers.
|
|
1092
|
+
*/
|
|
1093
|
+
async function getResponseHeaders(response) {
|
|
1094
|
+
if (response === null) return {};
|
|
1095
|
+
return "allHeaders" in response ? await response.allHeaders() : response.headers();
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Evaluates the headers of a response against a set of recommended security
|
|
1099
|
+
* headers and returns the result as a Markdown report.
|
|
1100
|
+
*
|
|
1101
|
+
* The report lists all evaluated headers of the response, followed by
|
|
1102
|
+
* recommendations for missing or deviating headers and for discouraged headers
|
|
1103
|
+
* to remove. Unrelated headers like `date` are omitted to keep the report
|
|
1104
|
+
* deterministic for file snapshots.
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```ts
|
|
1108
|
+
* const response = await page.goto("/");
|
|
1109
|
+
* const headers = await getResponseHeaders(response);
|
|
1110
|
+
*
|
|
1111
|
+
* expect(responseHeaderReport(headers, { type: "document" })).toMatchTextFile({
|
|
1112
|
+
* fileExtension: "md",
|
|
1113
|
+
* });
|
|
1114
|
+
* ```
|
|
1115
|
+
*/
|
|
1116
|
+
function responseHeaderReport(headers, options) {
|
|
1117
|
+
const recommendedHeaders = RECOMMENDED_HEADERS[options.type];
|
|
1118
|
+
const discouragedHeaders = DISCOURAGED_HEADERS[options.type];
|
|
1119
|
+
const headersByName = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
|
|
1120
|
+
const detectedHeaders = [...recommendedHeaders.map((recommended) => recommended.name), ...discouragedHeaders].flatMap((name) => {
|
|
1121
|
+
const value = headersByName.get(name);
|
|
1122
|
+
return value === void 0 ? [] : [{
|
|
1123
|
+
name,
|
|
1124
|
+
value
|
|
1125
|
+
}];
|
|
1126
|
+
});
|
|
1127
|
+
const recommendations = [...recommendedHeaders.flatMap((recommended) => evaluateHeader(recommended, headersByName.get(recommended.name))), ...discouragedHeaders.filter((name) => headersByName.has(name)).map((name) => ({
|
|
1128
|
+
name,
|
|
1129
|
+
action: "remove",
|
|
1130
|
+
message: "Remove header"
|
|
1131
|
+
}))].sort((a, b) => ACTION_ORDER.indexOf(a.action) - ACTION_ORDER.indexOf(b.action));
|
|
1132
|
+
return markdownDocument("Response Header Report", [{
|
|
1133
|
+
heading: "Detected Headers",
|
|
1134
|
+
content: markdownTableOrNone(["Header", "Value"], detectedHeaders.map((header) => [code(header.name), formatValue(header)]))
|
|
1135
|
+
}, {
|
|
1136
|
+
heading: "Recommendations",
|
|
1137
|
+
content: markdownEntriesOrNone(recommendations)
|
|
1138
|
+
}]);
|
|
1139
|
+
}
|
|
1140
|
+
function evaluateHeader(recommended, value) {
|
|
1141
|
+
if (value === void 0) return [{
|
|
1142
|
+
name: recommended.name,
|
|
1143
|
+
action: "add",
|
|
1144
|
+
message: recommended.recommended === void 0 ? "Missing, add header" : `Missing, add ${code(recommended.recommended)}`
|
|
1145
|
+
}];
|
|
1146
|
+
if (recommended.recommended === void 0 || isAcceptedValue(recommended, value)) return [];
|
|
1147
|
+
return [{
|
|
1148
|
+
name: recommended.name,
|
|
1149
|
+
action: "change",
|
|
1150
|
+
message: `Change value to ${code(recommended.recommended)}`
|
|
1151
|
+
}];
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Accepts a value if it passes the custom check of the header, or otherwise
|
|
1155
|
+
* if it equals the recommended value ignoring case and whitespace.
|
|
1156
|
+
*/
|
|
1157
|
+
function isAcceptedValue(recommended, value) {
|
|
1158
|
+
if (recommended.isAccepted !== void 0) return recommended.isAccepted(value);
|
|
1159
|
+
return recommended.recommended === void 0 || normalizeValue(value) === normalizeValue(recommended.recommended);
|
|
1160
|
+
}
|
|
1161
|
+
function isStrictHsts(value) {
|
|
1162
|
+
const directives = normalizeValue(value).split("; ");
|
|
1163
|
+
const maxAge = directives.find((directive) => directive.startsWith("max-age="))?.slice(8).replaceAll("\"", "");
|
|
1164
|
+
return maxAge !== void 0 && Number(maxAge) >= MIN_HSTS_MAX_AGE && directives.includes("includesubdomains");
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Accepts a policy if it restricts at least the recommended permissions, so
|
|
1168
|
+
* stricter policies restricting further permissions are accepted as well.
|
|
1169
|
+
*/
|
|
1170
|
+
function isRestrictivePermissionsPolicy(value) {
|
|
1171
|
+
const actualPermissions = parsePermissionsPolicy(value);
|
|
1172
|
+
return Array.from(parsePermissionsPolicy(PERMISSIONS_POLICY)).every((permission) => actualPermissions.has(permission));
|
|
1173
|
+
}
|
|
1174
|
+
function parsePermissionsPolicy(value) {
|
|
1175
|
+
return new Set(value.split(",").map(normalizeValue));
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Normalizes a header value for comparison by ignoring case, whitespace and
|
|
1179
|
+
* empty `;`-separated parts.
|
|
1180
|
+
*/
|
|
1181
|
+
function normalizeValue(value) {
|
|
1182
|
+
return value.toLowerCase().split(";").map((part) => part.trim().replaceAll(/\s+/g, " ")).filter((part) => part !== "").join("; ");
|
|
1183
|
+
}
|
|
1184
|
+
function formatValue(header) {
|
|
1185
|
+
return codeOrNone(header.name === "content-security-policy" ? maskCspNonces(header.value) : header.value);
|
|
991
1186
|
}
|
|
992
1187
|
//#endregion
|
|
993
1188
|
//#region src/file.ts
|
|
@@ -1225,4 +1420,4 @@ function identity(value) {
|
|
|
1225
1420
|
return value;
|
|
1226
1421
|
}
|
|
1227
1422
|
//#endregion
|
|
1228
|
-
export { ConsoleCaptor, CspCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, captureCspViolations, createFetchAdapter, cspFixtures, cspReport, extendLocator, formFiller, getCspHeader, hasLogLevel, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot, roleLocators };
|
|
1423
|
+
export { ConsoleCaptor, CspCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, captureCspViolations, createFetchAdapter, cspFixtures, cspReport, extendLocator, formFiller, getCspHeader, getResponseHeaders, hasLogLevel, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot, responseHeaderReport, roleLocators };
|