@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 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
- "# CSP Report",
904
- "## Detected Directives",
905
- directives.length === 0 ? "None" : markdownTable([["Directive", "Value"], ...maskNonces(directives).map((directive) => [code(directive.name), formatValues(directive.values)])]),
906
- "## Recommendations",
907
- recommendations.length === 0 ? "None" : recommendations.map((recommendation) => `- ${code(recommendation.directive)}: ${recommendation.message}`).join("\n")
908
- ].join("\n\n").concat("\n");
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
- directive: recommended.name,
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
- if (values.length === 0) return "_none_";
987
- return code(values.join(" "));
1028
+ return codeOrNone(values.join(" "));
988
1029
  }
989
- function code(text) {
990
- return `\`${text.replaceAll("|", "\\|")}\``;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronn/playwright-utils",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Utilities for writing tests with Playwright",
5
5
  "keywords": [
6
6
  "playwright",