@cronn/playwright-utils 0.8.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 +60 -1
- package/dist/index.mjs +416 -21
- package/package.json +14 -13
package/dist/index.d.mts
CHANGED
|
@@ -591,6 +591,65 @@ export declare class RouteInterceptorFixture {
|
|
|
591
591
|
intercept(filter: RouteFilter): RouteInterceptor;
|
|
592
592
|
}
|
|
593
593
|
//#endregion
|
|
594
|
+
//#region src/security/csp-report.d.ts
|
|
595
|
+
/**
|
|
596
|
+
* Returns the `Content-Security-Policy` header of a response, or an empty
|
|
597
|
+
* string if the response or the header is missing.
|
|
598
|
+
*/
|
|
599
|
+
export declare function getCspHeader(response: Response | null): Promise<string>;
|
|
600
|
+
/**
|
|
601
|
+
* Evaluates a `Content-Security-Policy` header against a set of recommended
|
|
602
|
+
* directives and returns the result as a Markdown report.
|
|
603
|
+
*
|
|
604
|
+
* The report lists all directives of the header, followed by recommendations
|
|
605
|
+
* for missing or weak directives. Missing fetch directives are evaluated using
|
|
606
|
+
* `default-src`, just like the browser falls back to it. Nonces are masked as
|
|
607
|
+
* `[NONCE_0]`, `[NONCE_1]`, … to keep the report deterministic for file snapshots.
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* ```ts
|
|
611
|
+
* const response = await page.goto("/");
|
|
612
|
+
* const cspHeader = await getCspHeader(response);
|
|
613
|
+
*
|
|
614
|
+
* expect(cspReport(cspHeader)).toMatchTextFile({
|
|
615
|
+
* fileExtension: "md",
|
|
616
|
+
* });
|
|
617
|
+
* ```
|
|
618
|
+
*/
|
|
619
|
+
export declare function cspReport(header: string): string;
|
|
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
|
|
594
653
|
//#region src/file.d.ts
|
|
595
654
|
/**
|
|
596
655
|
* Resolves the given path segments against the root directory of the closest
|
|
@@ -730,4 +789,4 @@ export declare function maskedValueWithIndex(name: string): (index: number) => s
|
|
|
730
789
|
*/
|
|
731
790
|
type PlaywrightTarget = Page | Locator;
|
|
732
791
|
//#endregion
|
|
733
|
-
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect } from "@playwright/test";
|
|
2
|
+
import { markdownTable } from "markdown-table";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { packageDirectorySync } from "package-directory";
|
|
4
5
|
import { maskString, stringNormalizer } from "@cronn/playwright-file-snapshots";
|
|
@@ -770,6 +771,420 @@ function modifyTextBody(modifier) {
|
|
|
770
771
|
});
|
|
771
772
|
}
|
|
772
773
|
//#endregion
|
|
774
|
+
//#region src/normalizers/masked-value.ts
|
|
775
|
+
/**
|
|
776
|
+
* Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
|
|
777
|
+
* returns `[BASE_URL]`.
|
|
778
|
+
*
|
|
779
|
+
* @param name - Name of the masked value
|
|
780
|
+
*/
|
|
781
|
+
function maskedValue(name) {
|
|
782
|
+
return `[${name.toUpperCase().replace(/[ -.:,;]/g, "_")}]`;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.
|
|
786
|
+
*
|
|
787
|
+
* @param name - Name of the masked value
|
|
788
|
+
* @returns A function mapping an index to a masked placeholder
|
|
789
|
+
*/
|
|
790
|
+
function maskedValueWithIndex(name) {
|
|
791
|
+
return (index) => maskedValue(`${name}_${index}`);
|
|
792
|
+
}
|
|
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
|
|
829
|
+
//#region src/security/csp-report.ts
|
|
830
|
+
const RECOMMENDED_DIRECTIVES = [
|
|
831
|
+
{ name: "upgrade-insecure-requests" },
|
|
832
|
+
{ name: "trusted-types" },
|
|
833
|
+
{
|
|
834
|
+
name: "require-trusted-types-for",
|
|
835
|
+
values: ["'script'"]
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: "default-src",
|
|
839
|
+
values: ["'self'"]
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
name: "script-src",
|
|
843
|
+
requireNonce: true,
|
|
844
|
+
tolerated: ["'strict-dynamic'"],
|
|
845
|
+
fallback: "default-src",
|
|
846
|
+
optional: ["script-src-elem", "script-src-attr"]
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
name: "script-src-elem",
|
|
850
|
+
requireNonce: true,
|
|
851
|
+
tolerated: ["'strict-dynamic'"],
|
|
852
|
+
optional: true
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
name: "script-src-attr",
|
|
856
|
+
values: ["'none'"],
|
|
857
|
+
optional: true
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
name: "style-src",
|
|
861
|
+
requireNonce: true,
|
|
862
|
+
fallback: "default-src",
|
|
863
|
+
optional: ["style-src-elem", "style-src-attr"]
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
name: "style-src-elem",
|
|
867
|
+
requireNonce: true,
|
|
868
|
+
optional: true
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
name: "style-src-attr",
|
|
872
|
+
values: ["'none'"],
|
|
873
|
+
optional: true
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
name: "img-src",
|
|
877
|
+
values: ["'self'", "data:"],
|
|
878
|
+
fallback: "default-src"
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
name: "font-src",
|
|
882
|
+
values: ["'self'"],
|
|
883
|
+
fallback: "default-src"
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
name: "connect-src",
|
|
887
|
+
values: ["'self'"],
|
|
888
|
+
fallback: "default-src"
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
name: "object-src",
|
|
892
|
+
values: ["'none'"]
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
name: "base-uri",
|
|
896
|
+
values: ["'self'"]
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
name: "form-action",
|
|
900
|
+
values: ["'self'"]
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
name: "frame-ancestors",
|
|
904
|
+
values: ["'none'"]
|
|
905
|
+
}
|
|
906
|
+
];
|
|
907
|
+
const NONCE_PLACEHOLDER = "'nonce-[RANDOM]'";
|
|
908
|
+
const MASKED_NONCE = maskedValueWithIndex("NONCE");
|
|
909
|
+
/**
|
|
910
|
+
* Returns the `Content-Security-Policy` header of a response, or an empty
|
|
911
|
+
* string if the response or the header is missing.
|
|
912
|
+
*/
|
|
913
|
+
async function getCspHeader(response) {
|
|
914
|
+
return await response?.headerValue("content-security-policy") ?? "";
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Evaluates a `Content-Security-Policy` header against a set of recommended
|
|
918
|
+
* directives and returns the result as a Markdown report.
|
|
919
|
+
*
|
|
920
|
+
* The report lists all directives of the header, followed by recommendations
|
|
921
|
+
* for missing or weak directives. Missing fetch directives are evaluated using
|
|
922
|
+
* `default-src`, just like the browser falls back to it. Nonces are masked as
|
|
923
|
+
* `[NONCE_0]`, `[NONCE_1]`, … to keep the report deterministic for file snapshots.
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* ```ts
|
|
927
|
+
* const response = await page.goto("/");
|
|
928
|
+
* const cspHeader = await getCspHeader(response);
|
|
929
|
+
*
|
|
930
|
+
* expect(cspReport(cspHeader)).toMatchTextFile({
|
|
931
|
+
* fileExtension: "md",
|
|
932
|
+
* });
|
|
933
|
+
* ```
|
|
934
|
+
*/
|
|
935
|
+
function cspReport(header) {
|
|
936
|
+
const { directives, recommendations } = evaluateCsp(parseCsp(header));
|
|
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("; ");
|
|
951
|
+
}
|
|
952
|
+
function parseCsp(header) {
|
|
953
|
+
return header.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "").map((directive) => {
|
|
954
|
+
const [name = "", ...values] = directive.split(/\s+/);
|
|
955
|
+
return {
|
|
956
|
+
name: name.toLowerCase(),
|
|
957
|
+
values
|
|
958
|
+
};
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function evaluateCsp(directives) {
|
|
962
|
+
const directivesByName = /* @__PURE__ */ new Map();
|
|
963
|
+
for (const directive of directives) if (!directivesByName.has(directive.name)) directivesByName.set(directive.name, directive);
|
|
964
|
+
return {
|
|
965
|
+
directives,
|
|
966
|
+
recommendations: RECOMMENDED_DIRECTIVES.flatMap((recommended) => evaluateDirective(recommended, directivesByName).map((message) => ({
|
|
967
|
+
name: recommended.name,
|
|
968
|
+
message
|
|
969
|
+
})))
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
function evaluateDirective(recommended, directivesByName) {
|
|
973
|
+
const directive = directivesByName.get(recommended.name);
|
|
974
|
+
if (directive === void 0 && isOptional(recommended, directivesByName)) return [];
|
|
975
|
+
const fallback = recommended.fallback !== void 0 ? directivesByName.get(recommended.fallback) : void 0;
|
|
976
|
+
const actual = directive ?? fallback;
|
|
977
|
+
if (actual === void 0) return [`Missing, add ${code([recommended.name, ...formatRecommendedValues(recommended)].join(" "))}`];
|
|
978
|
+
const inherited = directive === void 0 ? ` (inherited from ${code(actual.name)})` : "";
|
|
979
|
+
const messages = [];
|
|
980
|
+
const recommendedValues = recommended.values;
|
|
981
|
+
const extraValues = findExtraValues(recommended, actual.values);
|
|
982
|
+
const hasRecommendedValue = extraValues.length < actual.values.length;
|
|
983
|
+
if (recommendedValues !== void 0 && extraValues.length > 0 && !hasRecommendedValue) messages.push(`Add ${formatValues(recommendedValues)}`);
|
|
984
|
+
if (recommended.requireNonce === true && !actual.values.some(isNonce)) messages.push(`Add a nonce source`);
|
|
985
|
+
for (const value of extraValues) messages.push(`Remove ${code(value)}${inherited}`);
|
|
986
|
+
return messages;
|
|
987
|
+
}
|
|
988
|
+
function isOptional(recommended, directivesByName) {
|
|
989
|
+
if (Array.isArray(recommended.optional)) return recommended.optional.every((name) => directivesByName.has(name));
|
|
990
|
+
return recommended.optional === true;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Returns all sources which are neither recommended nor tolerated. Directives
|
|
994
|
+
* without recommended sources accept any value, and `'none'` is always
|
|
995
|
+
* accepted as it is stricter than any recommendation.
|
|
996
|
+
*/
|
|
997
|
+
function findExtraValues(recommended, values) {
|
|
998
|
+
if (recommended.values === void 0 && recommended.requireNonce !== true || isNone(values)) return [];
|
|
999
|
+
const acceptedValues = [...recommended.values ?? [], ...recommended.tolerated ?? []];
|
|
1000
|
+
return values.filter((value) => !acceptedValues.includes(value) && !(recommended.requireNonce === true && isNonce(value)));
|
|
1001
|
+
}
|
|
1002
|
+
function formatRecommendedValues(recommended) {
|
|
1003
|
+
return [...recommended.requireNonce === true ? [NONCE_PLACEHOLDER] : [], ...recommended.values ?? []];
|
|
1004
|
+
}
|
|
1005
|
+
function maskNonces(directives) {
|
|
1006
|
+
const nonceIndices = /* @__PURE__ */ new Map();
|
|
1007
|
+
function maskNonce(value) {
|
|
1008
|
+
if (!isNonce(value)) return value;
|
|
1009
|
+
let index = nonceIndices.get(value);
|
|
1010
|
+
if (index === void 0) {
|
|
1011
|
+
index = nonceIndices.size;
|
|
1012
|
+
nonceIndices.set(value, index);
|
|
1013
|
+
}
|
|
1014
|
+
return `'nonce-${MASKED_NONCE(index)}'`;
|
|
1015
|
+
}
|
|
1016
|
+
return directives.map((directive) => ({
|
|
1017
|
+
...directive,
|
|
1018
|
+
values: directive.values.map(maskNonce)
|
|
1019
|
+
}));
|
|
1020
|
+
}
|
|
1021
|
+
function isNone(values) {
|
|
1022
|
+
return values.length === 1 && values[0] === "'none'";
|
|
1023
|
+
}
|
|
1024
|
+
function isNonce(value) {
|
|
1025
|
+
return value.startsWith("'nonce-");
|
|
1026
|
+
}
|
|
1027
|
+
function formatValues(values) {
|
|
1028
|
+
return codeOrNone(values.join(" "));
|
|
1029
|
+
}
|
|
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);
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
773
1188
|
//#region src/file.ts
|
|
774
1189
|
/**
|
|
775
1190
|
* Resolves the given path segments against the root directory of the closest
|
|
@@ -990,26 +1405,6 @@ function roleLocators(locator, defaultOptions = DEFAULT_OPTIONS) {
|
|
|
990
1405
|
return Object.fromEntries(ROLES.map((role) => [role, getByRole(role)]));
|
|
991
1406
|
}
|
|
992
1407
|
//#endregion
|
|
993
|
-
//#region src/normalizers/masked-value.ts
|
|
994
|
-
/**
|
|
995
|
-
* Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
|
|
996
|
-
* returns `[BASE_URL]`.
|
|
997
|
-
*
|
|
998
|
-
* @param name - Name of the masked value
|
|
999
|
-
*/
|
|
1000
|
-
function maskedValue(name) {
|
|
1001
|
-
return `[${name.toUpperCase().replace(/[ -.:,;]/g, "_")}]`;
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.
|
|
1005
|
-
*
|
|
1006
|
-
* @param name - Name of the masked value
|
|
1007
|
-
* @returns A function mapping an index to a masked placeholder
|
|
1008
|
-
*/
|
|
1009
|
-
function maskedValueWithIndex(name) {
|
|
1010
|
-
return (index) => maskedValue(`${name}_${index}`);
|
|
1011
|
-
}
|
|
1012
|
-
//#endregion
|
|
1013
1408
|
//#region src/normalizers/mask-base-url.ts
|
|
1014
1409
|
/**
|
|
1015
1410
|
* Creates a normalizer that replaces occurrences of the base URL with
|
|
@@ -1025,4 +1420,4 @@ function identity(value) {
|
|
|
1025
1420
|
return value;
|
|
1026
1421
|
}
|
|
1027
1422
|
//#endregion
|
|
1028
|
-
export { ConsoleCaptor, CspCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, captureCspViolations, createFetchAdapter, cspFixtures, extendLocator, formFiller, 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.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Utilities for writing tests with Playwright",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -29,33 +29,34 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"markdown-table": "^3.0.4",
|
|
32
33
|
"package-directory": "^8.2.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@arethetypeswrong/core": "0.18.5",
|
|
36
37
|
"@changesets/cli": "3.0.3",
|
|
37
|
-
"@cronn/element-snapshot": "0.
|
|
38
|
+
"@cronn/element-snapshot": "0.32.1",
|
|
38
39
|
"@cronn/playwright-file-snapshots": "2.2.2",
|
|
39
40
|
"@playwright/test": "1.63.0",
|
|
40
|
-
"@types/node": "24.19.
|
|
41
|
+
"@types/node": "24.19.1",
|
|
41
42
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
42
|
-
"@vitest/coverage-v8": "5.0.
|
|
43
|
-
"eslint": "10.
|
|
43
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
44
|
+
"eslint": "10.12.0",
|
|
44
45
|
"eslint-config-prettier": "10.1.8",
|
|
45
46
|
"eslint-plugin-check-file": "3.3.2",
|
|
46
47
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
47
|
-
"knip": "6.
|
|
48
|
-
"oxc-minify": "0.
|
|
49
|
-
"oxfmt": "0.
|
|
50
|
-
"publint": "0.3.
|
|
48
|
+
"knip": "6.39.0",
|
|
49
|
+
"oxc-minify": "0.153.0",
|
|
50
|
+
"oxfmt": "0.72.0",
|
|
51
|
+
"publint": "0.3.25",
|
|
51
52
|
"rimraf": "6.1.3",
|
|
52
53
|
"tsdown": "0.23.0",
|
|
53
|
-
"turbo": "2.11.
|
|
54
|
+
"turbo": "2.11.7",
|
|
54
55
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
55
|
-
"typescript-eslint": "8.
|
|
56
|
+
"typescript-eslint": "8.71.1",
|
|
56
57
|
"vitepress": "2.0.0-alpha.20",
|
|
57
58
|
"vitepress-plugin-group-icons": "1.7.6",
|
|
58
|
-
"vitest": "5.0.
|
|
59
|
+
"vitest": "5.0.3"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"@cronn/playwright-file-snapshots": "^2",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"devEngines": {
|
|
65
66
|
"packageManager": {
|
|
66
67
|
"name": "pnpm",
|
|
67
|
-
"version": "12.
|
|
68
|
+
"version": "12.11.2",
|
|
68
69
|
"onFail": "download"
|
|
69
70
|
},
|
|
70
71
|
"runtime": {
|