@redsift/products 12.5.12-alpha.2 → 12.5.12-alpha.4
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/_internal/SignalCardSpfDomain.js +869 -47
- package/_internal/SignalCardSpfDomain.js.map +1 -1
- package/_internal/bimi-checker.js +8 -2
- package/_internal/bimi-checker.js.map +1 -1
- package/bimi-checker.d.ts +2 -14
- package/index.d.ts +4 -16
- package/package.json +3 -3
- package/signal-cards.d.ts +4 -16
|
@@ -539,7 +539,31 @@ var EXTRACTOR_STRINGS = {
|
|
|
539
539
|
"investigate:card.dmarc.full-rejection-subdomain-weak_md": "**100%** of unauthorized emails with the [](FROM#:glossary) `{domain}` will be **rejected**, but subdomains use a weaker policy (sp={sp}), leaving them vulnerable to spoofing.",
|
|
540
540
|
"investigate:card.dmarc.misplaced-bad-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced and has a syntax error:**",
|
|
541
541
|
"investigate:card.dmarc.misplaced-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced.**",
|
|
542
|
-
|
|
542
|
+
// WEB-1243: standards-neutral. The previous wording said receivers "stop policy
|
|
543
|
+
// discovery and apply no DMARC policy at all", which is RFC 7489 §6.6.3 step 5 only.
|
|
544
|
+
// RFC 9989 §4.10 receivers discard the duplicates and CONTINUE the tree walk, so an
|
|
545
|
+
// ancestor policy may still apply — and DNS-DMARC-0044, the live signal on current
|
|
546
|
+
// production, is emitted by exactly those receivers. Stating the RFC 7489 behaviour
|
|
547
|
+
// as universal was therefore false for the code that now triggers this message.
|
|
548
|
+
// Rewritten to assert only what is true on both: the outcome is receiver-dependent
|
|
549
|
+
// and therefore unreliable, and the fix is the same either way.
|
|
550
|
+
"investigate:card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Handling depends on the receiver: some stop policy discovery and apply no DMARC policy at all, others discard the duplicate records and continue. Your policy cannot be relied on for any recipient while more than one record is published. Publish exactly one DMARC record.",
|
|
551
|
+
// WEB-1243: the UNCONFIRMED counterpart. `indeterminate` means the check could not
|
|
552
|
+
// finish, NOT that duplicates were found. It resolves to a danger status on purpose —
|
|
553
|
+
// an unproven absence must not read as a proven one on a security signal — but the copy
|
|
554
|
+
// must not then assert the conflict as fact. `multiple-policies_md` says duplicates ARE
|
|
555
|
+
// published and to publish exactly one record, which on this path would tell a user
|
|
556
|
+
// with a healthy single record to change it.
|
|
557
|
+
//
|
|
558
|
+
// DELIBERATELY CAUSE-NEUTRAL. `indeterminate` has more than one cause: the scan budget
|
|
559
|
+
// running out, AND a candidate entry the check could not read (a rendered display group
|
|
560
|
+
// reaching a semantic position). An earlier draft said "the domain published more
|
|
561
|
+
// records than this check inspects", which is a FALSE statement of fact on the second
|
|
562
|
+
// cause — a one-name payload got told it had published too many records. Naming any
|
|
563
|
+
// single cause here is wrong unless the reason is carried alongside the verdict, so the
|
|
564
|
+
// copy states only what is true of every path: not everything was inspected, and the
|
|
565
|
+
// reader should go and look.
|
|
566
|
+
"investigate:card.dmarc.multiple-policies-unconfirmed_md": "**We could not fully check how many [FULL](DMARC_POLICY#:glossary) records are published on `{policyDomain}`.** This check did not inspect every record, so we cannot confirm whether more than one is live. If more than one is published, your policy cannot be relied on for any recipient. Review the DNS zone for `{policyDomain}` and confirm it publishes exactly one DMARC record.",
|
|
543
567
|
"investigate:card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
|
|
544
568
|
"investigate:card.dmarc.no-txt-published-policy-on-domain_md": "No [](TXT_RECORDS#:glossary) published on `{policyDomain}`",
|
|
545
569
|
"investigate:card.dmarc.permerror-no-rua_md": "No valid reporting URI ([](RUA#:glossary)) is present, so receivers will skip DMARC processing entirely.",
|
|
@@ -1026,7 +1050,8 @@ var EXTRACTOR_STRINGS = {
|
|
|
1026
1050
|
"investigate:card.dmarc.full-rejection-subdomain-weak_md": "**100%** des courriels non autoris\xE9s avec le [](FROM#:glossary) `{domain}` seront **rejet\xE9s**, mais les sous-domaines utilisent une politique plus faible (sp={sp}), les laissant vuln\xE9rables \xE0 l'usurpation.",
|
|
1027
1051
|
"investigate:card.dmarc.misplaced-bad-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9 et a une erreur de syntaxe:**",
|
|
1028
1052
|
"investigate:card.dmarc.misplaced-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9.",
|
|
1029
|
-
"investigate:card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publi\xE9es sur `{policyDomain}`.**
|
|
1053
|
+
"investigate:card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publi\xE9es sur `{policyDomain}`.** Le traitement d\xE9pend du destinataire : certains interrompent la d\xE9couverte de la politique et n'appliquent aucune politique DMARC, d'autres ignorent les enregistrements en double et continuent. Votre politique ne peut \xEAtre consid\xE9r\xE9e comme fiable pour aucun destinataire tant que plusieurs enregistrements sont publi\xE9s. Publiez un seul enregistrement DMARC.",
|
|
1054
|
+
"investigate:card.dmarc.multiple-policies-unconfirmed_md": "**Nous n'avons pas pu v\xE9rifier enti\xE8rement combien d'enregistrements [FULL](DMARC_POLICY#:glossary) sont publi\xE9s sur `{policyDomain}`.** Cette v\xE9rification n'a pas inspect\xE9 tous les enregistrements, nous ne pouvons donc pas confirmer si plusieurs sont actifs. Si plusieurs sont publi\xE9s, votre politique ne peut \xEAtre consid\xE9r\xE9e comme fiable pour aucun destinataire. Examinez la zone DNS de `{policyDomain}` et confirmez qu'elle publie un seul enregistrement DMARC.",
|
|
1030
1055
|
"investigate:card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publi\xE9e sur le domaine**\n\n**`{policyDomain}`**",
|
|
1031
1056
|
"investigate:card.dmarc.no-txt-published-policy-on-domain_md": "Aucun [](TXT_RECORD#:glossary) publi\xE9 sur `{policyDomain}`",
|
|
1032
1057
|
"investigate:card.dmarc.permerror-no-rua_md": "Aucun URI de rapport valide ([](RUA#:glossary)) n'est pr\xE9sent, les destinataires ignoreront enti\xE8rement le traitement DMARC.",
|
|
@@ -1291,6 +1316,7 @@ var enUS = {
|
|
|
1291
1316
|
"investigate.extracted-email-short": "Extracted from your email",
|
|
1292
1317
|
"investigate.dkim-evaluation": "DKIM Evaluation",
|
|
1293
1318
|
"investigate.fcrdns-evaluation": "FCrDNS Evaluation",
|
|
1319
|
+
"investigate.card.bimi.glossary-link": "What does this mean?",
|
|
1294
1320
|
"investigate.card.spf-passed_md": "**[](SPF#:glossary) authentication passed.**",
|
|
1295
1321
|
"investigate.card.spf-failed_md": "**[](SPF#:glossary) authentication failed.**",
|
|
1296
1322
|
"investigate.card.dmarc.error-raw_md": [
|
|
@@ -1315,6 +1341,7 @@ var enUS = {
|
|
|
1315
1341
|
"investigate.card.dmarc.policy-remaining-emails-pct-reject_md": "The remaining **{remainingPercent}%** of unauthorized emails will be subject to the **quarantine** policy.",
|
|
1316
1342
|
"investigate.card.dmarc.policy-remaining-emails-pct-quarantine_md": "The remaining **{remainingPercent}%** of unauthorized emails will be **delivered normally**.",
|
|
1317
1343
|
"investigate.card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
|
|
1344
|
+
"investigate.card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Handling depends on the receiver: some stop policy discovery and apply no DMARC policy at all, others discard the duplicate records and continue. Your policy cannot be relied on for any recipient while more than one record is published. Publish exactly one DMARC record.",
|
|
1318
1345
|
"investigate.card.dmarc.dmarc-passed_md": "[](DMARC#:glossary) **passed**",
|
|
1319
1346
|
"investigate.card.dmarc.dmarc-passed-no-rua_md": "[](DMARC#:glossary) is BIMI compliant **BUT** it does not have an **aggregate report address** (rua) entry.",
|
|
1320
1347
|
"investigate.card.dmarc.dmarc-no-rua_md": "[](DMARC#:glossary) does not have an **aggregate report address** (rua) entry.",
|
|
@@ -1934,7 +1961,14 @@ var enUS = {
|
|
|
1934
1961
|
"investigate.card.dmarc.domain-aligned": "The domain is aligned with the From Domain. It can contribute to passing DMARC.",
|
|
1935
1962
|
"investigate.card.dmarc.domain-aligned-not-verified": "The domain is aligned with the From Domain, but its signature did not verify, so it cannot contribute to passing DMARC.",
|
|
1936
1963
|
"investigate.card.dmarc.spf-domain-aligned-not-verified": "The domain is aligned with the From Domain, but SPF did not pass, so it cannot contribute to passing DMARC.",
|
|
1937
|
-
"investigate.card.dmarc.domain-not-aligned": "The domain is not aligned with the From Domain. It cannot contribute to passing DMARC."
|
|
1964
|
+
"investigate.card.dmarc.domain-not-aligned": "The domain is not aligned with the From Domain. It cannot contribute to passing DMARC.",
|
|
1965
|
+
"investigate.card.dmarc.multiple-policies-unconfirmed_md": "**We could not fully check how many [FULL](DMARC_POLICY#:glossary) records are published on `{policyDomain}`.** This check did not inspect every record, so we cannot confirm whether more than one is live. If more than one is published, your policy cannot be relied on for any recipient. Review the DNS zone for `{policyDomain}` and confirm it publishes exactly one DMARC record.",
|
|
1966
|
+
"investigate.card.extracted.truncated-records": "[… {count} more records not shown]",
|
|
1967
|
+
"investigate.card.extracted.truncated-records_one": "[… 1 more record not shown]",
|
|
1968
|
+
"investigate.card.extracted.truncated-records-atleast": "[… at least {count} more records not shown]",
|
|
1969
|
+
"investigate.card.extracted.truncated-records-uninspected": "[… further records not inspected]",
|
|
1970
|
+
"investigate.card.extracted.truncated-record-length": "[… long records shortened for display]",
|
|
1971
|
+
"investigate.card.extracted.truncated-records-atleast_one": "[… at least 1 more record not shown]"
|
|
1938
1972
|
};
|
|
1939
1973
|
|
|
1940
1974
|
var frFR = {
|
|
@@ -2008,6 +2042,7 @@ var frFR = {
|
|
|
2008
2042
|
"investigate.summary.dnssec": "DNSSEC",
|
|
2009
2043
|
"investigate.summary.dns-lookups": "{count} Recherches DNS",
|
|
2010
2044
|
"investigate.summary.void-lookups": "{count} Recherches vides",
|
|
2045
|
+
"investigate.card.bimi.glossary-link": "Qu’est-ce que cela signifie ?",
|
|
2011
2046
|
"investigate.card.spf-passed_md": "**[](SPF#:glossary) authentification réussie.**",
|
|
2012
2047
|
"investigate.card.spf-failed_md": "**[](SPF#:glossary) authentification échouée.**",
|
|
2013
2048
|
"investigate.card.dmarc.error-raw_md": [
|
|
@@ -2032,6 +2067,7 @@ var frFR = {
|
|
|
2032
2067
|
"investigate.card.dmarc.policy-remaining-emails-pct-reject_md": "Les **{remainingPercent}%** restants de courriels non autorisés seront soumis à la politique de **mise en quarantaine**.",
|
|
2033
2068
|
"investigate.card.dmarc.policy-remaining-emails-pct-quarantine_md": "Les **{remainingPercent}%** restants de courriels non autorisés seront **livrés normalement**.",
|
|
2034
2069
|
"investigate.card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publiée sur le domaine**\n\n**`{policyDomain}`**",
|
|
2070
|
+
"investigate.card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publiées sur `{policyDomain}`.** Le traitement dépend du destinataire : certains interrompent la découverte de la politique et n'appliquent aucune politique DMARC, d'autres ignorent les enregistrements en double et continuent. Votre politique ne peut être considérée comme fiable pour aucun destinataire tant que plusieurs enregistrements sont publiés. Publiez un seul enregistrement DMARC.",
|
|
2035
2071
|
"investigate.card.dmarc.dmarc-passed_md": "[](DMARC#:glossary) **réussi**",
|
|
2036
2072
|
"investigate.card.dmarc.dmarc-passed-no-rua_md": "[](DMARC#:glossary) est conforme à BIMI **MAIS** n'a pas d'**adresse de rapport agrégé** (rua).",
|
|
2037
2073
|
"investigate.card.dmarc.dmarc-no-rua_md": "[](DMARC#:glossary) n'a pas d'**adresse de rapport agrégé** (rua).",
|
|
@@ -2653,7 +2689,14 @@ var frFR = {
|
|
|
2653
2689
|
"investigate.card.dmarc.domain-aligned": "Le domaine est aligné avec le domaine expéditeur. Il peut contribuer à la réussite de DMARC.",
|
|
2654
2690
|
"investigate.card.dmarc.domain-aligned-not-verified": "Le domaine est aligné avec le domaine expéditeur, mais sa signature n'a pas été vérifiée, il ne peut donc pas contribuer à la réussite de DMARC.",
|
|
2655
2691
|
"investigate.card.dmarc.spf-domain-aligned-not-verified": "Le domaine est aligné avec le domaine expéditeur, mais SPF n'a pas réussi, il ne peut donc pas contribuer à la réussite de DMARC.",
|
|
2656
|
-
"investigate.card.dmarc.domain-not-aligned": "Le domaine n'est pas aligné avec le domaine expéditeur. Il ne peut pas contribuer à la réussite de DMARC."
|
|
2692
|
+
"investigate.card.dmarc.domain-not-aligned": "Le domaine n'est pas aligné avec le domaine expéditeur. Il ne peut pas contribuer à la réussite de DMARC.",
|
|
2693
|
+
"investigate.card.dmarc.multiple-policies-unconfirmed_md": "**Nous n'avons pas pu vérifier entièrement combien d'enregistrements [FULL](DMARC_POLICY#:glossary) sont publiés sur `{policyDomain}`.** Cette vérification n'a pas inspecté tous les enregistrements, nous ne pouvons donc pas confirmer si plusieurs sont actifs. Si plusieurs sont publiés, votre politique ne peut être considérée comme fiable pour aucun destinataire. Examinez la zone DNS de `{policyDomain}` et confirmez qu'elle publie un seul enregistrement DMARC.",
|
|
2694
|
+
"investigate.card.extracted.truncated-records": "[… {count} enregistrements supplémentaires non affichés]",
|
|
2695
|
+
"investigate.card.extracted.truncated-records_one": "[… 1 enregistrement supplémentaire non affiché]",
|
|
2696
|
+
"investigate.card.extracted.truncated-records-atleast": "[… au moins {count} enregistrements supplémentaires non affichés]",
|
|
2697
|
+
"investigate.card.extracted.truncated-records-uninspected": "[… enregistrements supplémentaires non examinés]",
|
|
2698
|
+
"investigate.card.extracted.truncated-record-length": "[… enregistrements longs raccourcis pour l’affichage]",
|
|
2699
|
+
"investigate.card.extracted.truncated-records-atleast_one": "[… au moins 1 enregistrement supplémentaire non affiché]"
|
|
2657
2700
|
};
|
|
2658
2701
|
|
|
2659
2702
|
/**
|
|
@@ -4826,8 +4869,49 @@ const CodeBlock = styled.div`
|
|
|
4826
4869
|
display: none !important;
|
|
4827
4870
|
}
|
|
4828
4871
|
`;
|
|
4872
|
+
|
|
4873
|
+
/**
|
|
4874
|
+
* One logical line inside a `CodeBlock` — typically a single published DNS record.
|
|
4875
|
+
*
|
|
4876
|
+
* DS-115: a domain can publish more than one competing `_dmarc` TXT record, and the card
|
|
4877
|
+
* must show them as distinct records. `CodeBlock` forces `p { display: inline }`, which
|
|
4878
|
+
* flowed them into one unbroken line, so multi-line values use this block-level wrapper
|
|
4879
|
+
* instead of a `<p>`.
|
|
4880
|
+
*/
|
|
4881
|
+
const CodeBlockLine = styled.div`
|
|
4882
|
+
display: block;
|
|
4883
|
+
white-space: pre-wrap;
|
|
4884
|
+
word-break: break-all;
|
|
4885
|
+
|
|
4886
|
+
& + & {
|
|
4887
|
+
margin-top: 4px;
|
|
4888
|
+
}
|
|
4889
|
+
`;
|
|
4890
|
+
|
|
4891
|
+
/**
|
|
4892
|
+
* Renderer commentary about a truncated literal value — NOT zone content.
|
|
4893
|
+
*
|
|
4894
|
+
* DS-115. Deliberately its own component rather than a reused `ExtractionCaption`: that one
|
|
4895
|
+
* is `position: absolute`, anchored to an `ExtractionBox`, so on the paths that have no
|
|
4896
|
+
* positioned ancestor it would escape its container, and inside a box it would land on the
|
|
4897
|
+
* same coordinates as the ordinary caption. This stays in normal flow directly beneath the
|
|
4898
|
+
* code block.
|
|
4899
|
+
*
|
|
4900
|
+
* Visually distinct from `CodeBlockLine` on purpose — italic, not monospace — because a zone
|
|
4901
|
+
* can publish a record whose text imitates this notice, and the two must not be confusable.
|
|
4902
|
+
*/
|
|
4903
|
+
const LiteralTruncationNotice = styled.div`
|
|
4904
|
+
display: block;
|
|
4905
|
+
margin-top: 6px;
|
|
4906
|
+
font-style: italic;
|
|
4907
|
+
font-size: 12px;
|
|
4908
|
+
opacity: 0.75;
|
|
4909
|
+
word-break: break-word;
|
|
4910
|
+
`;
|
|
4829
4911
|
ExtractionBox.displayName = 'ExtractionBox';
|
|
4830
4912
|
ExtractionCaption.displayName = 'ExtractionCaption';
|
|
4913
|
+
CodeBlockLine.displayName = 'CodeBlockLine';
|
|
4914
|
+
LiteralTruncationNotice.displayName = 'LiteralTruncationNotice';
|
|
4831
4915
|
InfoItemsHighlight.displayName = 'InfoItemsHighlight';
|
|
4832
4916
|
|
|
4833
4917
|
// All theming uses CSS variables with Design System fallbacks (Poppins).
|
|
@@ -6640,19 +6724,244 @@ function getIcon(_ref) {
|
|
|
6640
6724
|
size: "small"
|
|
6641
6725
|
}) : null;
|
|
6642
6726
|
}
|
|
6643
|
-
|
|
6727
|
+
|
|
6728
|
+
/**
|
|
6729
|
+
* Lines a literal (code-block) render may emit, shared across ONE CATEGORY.
|
|
6730
|
+
*
|
|
6731
|
+
* Scope is per category, not per card: `ExtractedCategoryContent` creates one budget and
|
|
6732
|
+
* threads it through every item and leaf it renders, so a card with N literal categories can
|
|
6733
|
+
* emit up to N x this figure. That is deliberate — a category is the unit a reader looks at,
|
|
6734
|
+
* and letting one category's pathological payload blank out a sibling section would trade a
|
|
6735
|
+
* bounded render for a misleading one.
|
|
6736
|
+
*
|
|
6737
|
+
* Two earlier attempts were not bounds at all. A per-leaf cap failed because
|
|
6738
|
+
* `renderExtractedValue` recurses into arrays (5,000 one-line entries → 5,004 nodes). A
|
|
6739
|
+
* per-value cap failed because upstream emits one item PER CANDIDATE GROUP, so 20 groups each
|
|
6740
|
+
* got a fresh 50 (→ 1,000 lines, 20 notices).
|
|
6741
|
+
*
|
|
6742
|
+
* Generous for real data: a real `_dmarc` name publishes one record and the pathological
|
|
6743
|
+
* production case is two.
|
|
6744
|
+
*/
|
|
6745
|
+
const MAX_LITERAL_LINES = 50;
|
|
6746
|
+
|
|
6747
|
+
/**
|
|
6748
|
+
* Characters rendered per literal line.
|
|
6749
|
+
*
|
|
6750
|
+
* Count alone does not bound size — 50 lines of 200KB each rendered 10,001,001 characters
|
|
6751
|
+
* (measured). 4096 rather than upstream's 512 context bound: this is the UI, and legitimate
|
|
6752
|
+
* evidence exceeds 512 — a DNS TXT RR concatenates multiple 255-octet strings, and a
|
|
6753
|
+
* 4096-bit DKIM `p=` value alone is ~740 characters. 512 would have silently truncated real
|
|
6754
|
+
* keys, which on a card whose job is showing published records is a correctness bug, not a
|
|
6755
|
+
* safety measure.
|
|
6756
|
+
*/
|
|
6757
|
+
const MAX_LITERAL_LINE_CHARS = 4096;
|
|
6758
|
+
|
|
6759
|
+
/**
|
|
6760
|
+
* Total literal characters per CATEGORY, independent of the per-line cap.
|
|
6761
|
+
*
|
|
6762
|
+
* The per-line cap alone still allows `MAX_LITERAL_LINES × MAX_LITERAL_LINE_CHARS`. This is
|
|
6763
|
+
* the ceiling that actually bounds payload size. Same per-category scope as
|
|
6764
|
+
* `MAX_LITERAL_LINES` — see that note.
|
|
6765
|
+
*/
|
|
6766
|
+
const MAX_LITERAL_TOTAL_CHARS = 64000;
|
|
6767
|
+
|
|
6768
|
+
/**
|
|
6769
|
+
* Array entries VISITED per category, whatever they turn out to contain.
|
|
6770
|
+
*
|
|
6771
|
+
* Distinct from the line budget, which bounds output: entries that contribute nothing —
|
|
6772
|
+
* empty strings, whitespace, nulls — do not consume output quota, so a scan that stopped
|
|
6773
|
+
* only on the output budget could still walk a million entries. Nothing past this is read.
|
|
6774
|
+
*/
|
|
6775
|
+
const MAX_LITERAL_ENTRIES_SCANNED = 2000;
|
|
6776
|
+
|
|
6777
|
+
/**
|
|
6778
|
+
* Shared, mutable literal render budget for one CATEGORY.
|
|
6779
|
+
*
|
|
6780
|
+
* `hiddenLines` is a LOWER BOUND, not an exact count: producing an exact figure would mean
|
|
6781
|
+
* scanning every withheld character of an attacker-controlled value, which is unbounded work
|
|
6782
|
+
* for a number nobody acts on. Copy says "at least N" / "not inspected" accordingly.
|
|
6783
|
+
*/
|
|
6784
|
+
|
|
6785
|
+
const createLiteralBudget = () => ({
|
|
6786
|
+
lines: MAX_LITERAL_LINES,
|
|
6787
|
+
chars: MAX_LITERAL_TOTAL_CHARS,
|
|
6788
|
+
entries: MAX_LITERAL_ENTRIES_SCANNED,
|
|
6789
|
+
hiddenLines: 0,
|
|
6790
|
+
hiddenUncounted: false,
|
|
6791
|
+
shortenedLines: false
|
|
6792
|
+
});
|
|
6793
|
+
const budgetExhausted = b => b.lines <= 0 || b.chars <= 0;
|
|
6794
|
+
|
|
6795
|
+
/**
|
|
6796
|
+
* Truncate without splitting a surrogate pair.
|
|
6797
|
+
*
|
|
6798
|
+
* A bare `slice` cuts UTF-16 code units, so a boundary landing between a high and low
|
|
6799
|
+
* surrogate emits a replacement glyph — a rendering artifact in what is supposed to be a
|
|
6800
|
+
* verbatim record.
|
|
6801
|
+
*/
|
|
6802
|
+
const sliceWholeCodePoints = (value, max) => {
|
|
6803
|
+
if (value.length <= max) {
|
|
6804
|
+
return value;
|
|
6805
|
+
}
|
|
6806
|
+
const code = value.charCodeAt(max - 1);
|
|
6807
|
+
// High surrogate at the boundary: its pair would be cut, so drop it too.
|
|
6808
|
+
const end = code >= 0xd800 && code <= 0xdbff ? max - 1 : max;
|
|
6809
|
+
return value.slice(0, end);
|
|
6810
|
+
};
|
|
6811
|
+
|
|
6812
|
+
/**
|
|
6813
|
+
* Render an extracted value.
|
|
6814
|
+
*
|
|
6815
|
+
* `literal` is set for code-block values (DNS TXT records and similar). Those come from
|
|
6816
|
+
* the inspected domain's own zone, NOT from our translations, so they are attacker-
|
|
6817
|
+
* controlled and must never go through `parseGlossaryMarkdown` — its documented trust
|
|
6818
|
+
* model assumes source-controlled input, and it turns `[text](https://...)` into a real
|
|
6819
|
+
* anchor. See the adversarial tests in `SignalCardExtractedItems.test.tsx`.
|
|
6820
|
+
*/
|
|
6821
|
+
const renderExtractedValue = function (value, keyPrefix) {
|
|
6822
|
+
let literal = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
|
|
6823
|
+
let budget = arguments.length > 3 ? arguments[3] : undefined;
|
|
6644
6824
|
const nodes = [];
|
|
6825
|
+
// A local budget when none is shared, so non-card callers still get a bound.
|
|
6826
|
+
const b = budget !== null && budget !== void 0 ? budget : createLiteralBudget();
|
|
6645
6827
|
const process = (input, prefix) => {
|
|
6646
6828
|
if (input === null || input === undefined) {
|
|
6647
6829
|
return;
|
|
6648
6830
|
}
|
|
6649
6831
|
if (Array.isArray(input)) {
|
|
6650
|
-
|
|
6832
|
+
for (const entry of input) {
|
|
6833
|
+
// Stop traversing once nothing more can be emitted: continuing would walk the whole
|
|
6834
|
+
// attacker-controlled array to render nothing.
|
|
6835
|
+
if (literal && (budgetExhausted(b) || b.entries <= 0)) {
|
|
6836
|
+
b.hiddenUncounted = true;
|
|
6837
|
+
return;
|
|
6838
|
+
}
|
|
6839
|
+
if (literal) {
|
|
6840
|
+
b.entries -= 1;
|
|
6841
|
+
}
|
|
6842
|
+
process(entry, `${prefix}-${nodes.length}`);
|
|
6843
|
+
}
|
|
6651
6844
|
return;
|
|
6652
6845
|
}
|
|
6653
6846
|
if (typeof input === 'string') {
|
|
6654
|
-
const trimmed = input.trim();
|
|
6655
|
-
if (!trimmed) {
|
|
6847
|
+
const trimmed = literal ? undefined : input.trim();
|
|
6848
|
+
if (!literal && !trimmed) {
|
|
6849
|
+
return;
|
|
6850
|
+
}
|
|
6851
|
+
|
|
6852
|
+
// DS-115: in literal (code-block) mode a single string leaf may itself carry multiple
|
|
6853
|
+
// records separated by newlines — and so may a string nested inside an array, e.g.
|
|
6854
|
+
// `['record A\nrecord B']`. Split every string leaf, not just a top-level one, so each
|
|
6855
|
+
// record gets its own <p> (and so its own CodeBlockLine) rather than flowing together.
|
|
6856
|
+
if (literal) {
|
|
6857
|
+
// Every line becomes a React element and a styled `CodeBlockLine`, and the values are
|
|
6858
|
+
// attacker-controlled: DNS TXT records read from the domain under inspection.
|
|
6859
|
+
// Upstream deliberately does NOT bound the card path (only the context/LLM path is
|
|
6860
|
+
// capped) because a display cap that dropped a whole name could make the
|
|
6861
|
+
// multiple-records predicate deny a real conflict. That is right upstream, and it
|
|
6862
|
+
// makes the RENDER side the place to bound node count and byte size.
|
|
6863
|
+
//
|
|
6864
|
+
// Scanned with an index-bounded loop rather than `split().map().filter().slice()`:
|
|
6865
|
+
// the chained form materializes every line of an adversarial value before discarding
|
|
6866
|
+
// almost all of it, so the work is unbounded even though the output is not. The
|
|
6867
|
+
// string is never `trim()`ed as a whole either — that copies the entire input.
|
|
6868
|
+
let cursor = 0;
|
|
6869
|
+
while (cursor < input.length && !budgetExhausted(b)) {
|
|
6870
|
+
// Bound the scan for one line's end by what could still be rendered, so a single
|
|
6871
|
+
// 10-million-character line is never fully walked or normalized.
|
|
6872
|
+
const scanLimit = Math.min(input.length, cursor + MAX_LITERAL_LINE_CHARS + 1);
|
|
6873
|
+
let next = cursor;
|
|
6874
|
+
while (next < scanLimit && input[next] !== '\n' && input[next] !== '\r') {
|
|
6875
|
+
next += 1;
|
|
6876
|
+
}
|
|
6877
|
+
const overlong = next === scanLimit && next < input.length && input[next] !== '\n' && input[next] !== '\r';
|
|
6878
|
+
const raw = input.slice(cursor, next);
|
|
6879
|
+
const line = raw.replace(/\s+/g, ' ').trim();
|
|
6880
|
+
if (overlong) {
|
|
6881
|
+
// Skip this line’s untruncated remainder WITHOUT materializing it — and BOUND the skip.
|
|
6882
|
+
// Walking to the next newline is still O(input): a 40-million-character line with no
|
|
6883
|
+
// newline took ~164ms against ~97ms for 20M, so the work was linear in attacker input
|
|
6884
|
+
// even though the OUTPUT was bounded. Give up after a bounded probe and report the
|
|
6885
|
+
// rest as uninspected rather than scanning it.
|
|
6886
|
+
const skipLimit = cursor + MAX_LITERAL_LINE_CHARS;
|
|
6887
|
+
while (cursor < input.length && cursor < skipLimit && input[cursor] !== '\n' && input[cursor] !== '\r') {
|
|
6888
|
+
cursor += 1;
|
|
6889
|
+
}
|
|
6890
|
+
if (cursor < input.length && input[cursor] !== '\n' && input[cursor] !== '\r') {
|
|
6891
|
+
// The skip budget ran out before this line ended. Record that the withheld
|
|
6892
|
+
// figure is a floor, then stop consuming budget so the reporting block below
|
|
6893
|
+
// still runs from this cursor — an early `break` here escaped the loop and
|
|
6894
|
+
// skipped reporting entirely, leaving NO notice at all (caught by test).
|
|
6895
|
+
b.hiddenUncounted = true;
|
|
6896
|
+
b.lines = 0;
|
|
6897
|
+
}
|
|
6898
|
+
} else {
|
|
6899
|
+
cursor = next;
|
|
6900
|
+
}
|
|
6901
|
+
|
|
6902
|
+
// Advance past the whole run of newline characters, matching the previous
|
|
6903
|
+
// `/[\r\n]+/` split so a CRLF pair does not emit a blank line between records.
|
|
6904
|
+
while (cursor < input.length && (input[cursor] === '\n' || input[cursor] === '\r')) {
|
|
6905
|
+
cursor += 1;
|
|
6906
|
+
}
|
|
6907
|
+
if (!line) {
|
|
6908
|
+
continue;
|
|
6909
|
+
}
|
|
6910
|
+
const allowed = Math.min(MAX_LITERAL_LINE_CHARS, b.chars);
|
|
6911
|
+
const shown = sliceWholeCodePoints(line, allowed);
|
|
6912
|
+
if (shown.length < line.length || overlong) {
|
|
6913
|
+
b.shortenedLines = true;
|
|
6914
|
+
}
|
|
6915
|
+
nodes.push( /*#__PURE__*/React__default.createElement("p", {
|
|
6916
|
+
key: `${prefix}-text-${nodes.length}`
|
|
6917
|
+
}, shown));
|
|
6918
|
+
b.lines -= 1;
|
|
6919
|
+
b.chars -= shown.length;
|
|
6920
|
+
}
|
|
6921
|
+
|
|
6922
|
+
// Report — never silently drop. This card exists to tell someone which records to
|
|
6923
|
+
// remove, and a hidden remainder invites them to believe they are finished.
|
|
6924
|
+
//
|
|
6925
|
+
// The count is deliberately a LOWER BOUND: an exact figure would mean scanning every
|
|
6926
|
+
// withheld character of an attacker-controlled value. `hiddenUncounted` is what makes
|
|
6927
|
+
// the copy honest about that ("at least N"), so it MUST be set whenever the scan
|
|
6928
|
+
// stopped early. The previous version counted SEPARATORS rather than segments and
|
|
6929
|
+
// could stop on its own budget while still reporting an exact-sounding figure, so 55
|
|
6930
|
+
// records reported 4 withheld when 5 were (verified).
|
|
6931
|
+
if (cursor < input.length) {
|
|
6932
|
+
let hidden = 0;
|
|
6933
|
+
let scan = cursor;
|
|
6934
|
+
// Count SEGMENTS: a segment is a run of non-newline characters.
|
|
6935
|
+
//
|
|
6936
|
+
// BOTH loops are bounded. The segment count is capped by the line budget, and each
|
|
6937
|
+
// segment's own walk is capped by `MAX_LITERAL_LINE_CHARS` — without that inner cap a
|
|
6938
|
+
// remainder holding one very long newline-free line walked the whole string (20M
|
|
6939
|
+
// chars ≈ 42ms, linear in attacker input), which silently undid the "bound WORK, not
|
|
6940
|
+
// only output" guarantee the skip loop above exists to provide.
|
|
6941
|
+
while (scan < input.length && hidden < MAX_LITERAL_LINES) {
|
|
6942
|
+
while (scan < input.length && (input[scan] === '\n' || input[scan] === '\r')) {
|
|
6943
|
+
scan += 1;
|
|
6944
|
+
}
|
|
6945
|
+
if (scan >= input.length) {
|
|
6946
|
+
break;
|
|
6947
|
+
}
|
|
6948
|
+
hidden += 1;
|
|
6949
|
+
const segmentLimit = scan + MAX_LITERAL_LINE_CHARS;
|
|
6950
|
+
while (scan < input.length && scan < segmentLimit && input[scan] !== '\n' && input[scan] !== '\r') {
|
|
6951
|
+
scan += 1;
|
|
6952
|
+
}
|
|
6953
|
+
if (scan < input.length && input[scan] !== '\n' && input[scan] !== '\r') {
|
|
6954
|
+
// Gave up mid-segment, so anything past here is uncounted.
|
|
6955
|
+
b.hiddenUncounted = true;
|
|
6956
|
+
break;
|
|
6957
|
+
}
|
|
6958
|
+
}
|
|
6959
|
+
// Anything left unscanned means the figure is a floor, not a total.
|
|
6960
|
+
if (scan < input.length) {
|
|
6961
|
+
b.hiddenUncounted = true;
|
|
6962
|
+
}
|
|
6963
|
+
b.hiddenLines += hidden;
|
|
6964
|
+
}
|
|
6656
6965
|
return;
|
|
6657
6966
|
}
|
|
6658
6967
|
nodes.push( /*#__PURE__*/React__default.createElement("p", {
|
|
@@ -6662,11 +6971,37 @@ const renderExtractedValue = (value, keyPrefix) => {
|
|
|
6662
6971
|
}
|
|
6663
6972
|
if ( /*#__PURE__*/React__default.isValidElement(input)) {
|
|
6664
6973
|
var _input$key;
|
|
6974
|
+
// Charged against the budget too: element leaves are nodes like any other, and an
|
|
6975
|
+
// uncharged leaf type is an escape hatch — 5,000 object leaves previously rendered
|
|
6976
|
+
// 5,000 lines with no notice.
|
|
6977
|
+
if (literal) {
|
|
6978
|
+
if (budgetExhausted(b)) {
|
|
6979
|
+
b.hiddenLines += 1;
|
|
6980
|
+
return;
|
|
6981
|
+
}
|
|
6982
|
+
b.lines -= 1;
|
|
6983
|
+
}
|
|
6665
6984
|
nodes.push( /*#__PURE__*/React__default.cloneElement(input, {
|
|
6666
6985
|
key: (_input$key = input.key) !== null && _input$key !== void 0 ? _input$key : `${prefix}-element`
|
|
6667
6986
|
}));
|
|
6668
6987
|
return;
|
|
6669
6988
|
}
|
|
6989
|
+
|
|
6990
|
+
// Numbers, booleans, objects. Stringified, so they are bounded and charged exactly like
|
|
6991
|
+
// string leaves rather than bypassing the budget.
|
|
6992
|
+
if (literal) {
|
|
6993
|
+
if (budgetExhausted(b)) {
|
|
6994
|
+
b.hiddenLines += 1;
|
|
6995
|
+
return;
|
|
6996
|
+
}
|
|
6997
|
+
const text = sliceWholeCodePoints(String(input), Math.min(MAX_LITERAL_LINE_CHARS, b.chars));
|
|
6998
|
+
b.lines -= 1;
|
|
6999
|
+
b.chars -= text.length;
|
|
7000
|
+
nodes.push( /*#__PURE__*/React__default.createElement("p", {
|
|
7001
|
+
key: `${prefix}-fallback`
|
|
7002
|
+
}, text));
|
|
7003
|
+
return;
|
|
7004
|
+
}
|
|
6670
7005
|
nodes.push( /*#__PURE__*/React__default.createElement("p", {
|
|
6671
7006
|
key: `${prefix}-fallback`
|
|
6672
7007
|
}, String(input)));
|
|
@@ -6674,6 +7009,65 @@ const renderExtractedValue = (value, keyPrefix) => {
|
|
|
6674
7009
|
process(value, keyPrefix);
|
|
6675
7010
|
return nodes;
|
|
6676
7011
|
};
|
|
7012
|
+
|
|
7013
|
+
/**
|
|
7014
|
+
* Wrap already-literal, newline-split nodes in a `CodeBlock`, one `CodeBlockLine` per record.
|
|
7015
|
+
*
|
|
7016
|
+
* Shared by every code-block path so they cannot drift apart: `CodeBlock` sets
|
|
7017
|
+
* `white-space: normal`, so without one element per line multiple records flow together.
|
|
7018
|
+
*/
|
|
7019
|
+
const renderCodeBlock = nodes => /*#__PURE__*/React__default.createElement(CodeBlock, null, nodes.map((node, idx) => {
|
|
7020
|
+
if ( /*#__PURE__*/React__default.isValidElement(node) && node.type === 'p') {
|
|
7021
|
+
var _node$key;
|
|
7022
|
+
return /*#__PURE__*/React__default.createElement(CodeBlockLine, {
|
|
7023
|
+
key: (_node$key = node.key) !== null && _node$key !== void 0 ? _node$key : `code-line-${idx}`
|
|
7024
|
+
}, node.props.children);
|
|
7025
|
+
}
|
|
7026
|
+
return node;
|
|
7027
|
+
}));
|
|
7028
|
+
|
|
7029
|
+
/**
|
|
7030
|
+
* Notice that the renderer withheld part of a literal value.
|
|
7031
|
+
*
|
|
7032
|
+
* The caps above are initial-render SAFETY limits, not a completeness guarantee: DNS defines
|
|
7033
|
+
* no record-count limit for a name, a TXT RR may concatenate several 255-octet strings, and
|
|
7034
|
+
* under RFC 9989 §4.10 every competing record is discarded — so dozens of short records are
|
|
7035
|
+
* protocol-plausible and each withheld value is one the operator still has to delete.
|
|
7036
|
+
*
|
|
7037
|
+
* Reporting is therefore the minimum: truncating silently would tell someone the list is
|
|
7038
|
+
* complete when it is not. A "copy all published records" action was built and REVERTED (see
|
|
7039
|
+
* CHANGELOG) — it could not honour its own promise, so full-evidence access is tracked
|
|
7040
|
+
* separately rather than half-delivered here.
|
|
7041
|
+
*
|
|
7042
|
+
* Rendered OUTSIDE the `CodeBlock` and as its own `LiteralTruncationNotice`, for a security
|
|
7043
|
+
* reason rather than a stylistic one: inside the block it was structurally and visually
|
|
7044
|
+
* identical to a record, so a zone publishing the literal text `[… 3 more records not shown]`
|
|
7045
|
+
* produced an indistinguishable line (verified). Zone content and renderer commentary must
|
|
7046
|
+
* not share a presentation. `data-testid` is stable so tests can assert the notice
|
|
7047
|
+
* specifically, rather than matching prose an attacker could also publish.
|
|
7048
|
+
*/
|
|
7049
|
+
const renderTruncationNotice = (budget, t) => {
|
|
7050
|
+
const parts = [];
|
|
7051
|
+
if (budget.hiddenLines > 0) {
|
|
7052
|
+
parts.push(t(budget.hiddenUncounted ? 'card.extracted.truncated-records-atleast' : 'card.extracted.truncated-records', {
|
|
7053
|
+
count: budget.hiddenLines
|
|
7054
|
+
}));
|
|
7055
|
+
} else if (budget.hiddenUncounted) {
|
|
7056
|
+
parts.push(t('card.extracted.truncated-records-uninspected'));
|
|
7057
|
+
}
|
|
7058
|
+
|
|
7059
|
+
// Reported ALONGSIDE the hidden count, not instead of it: a payload that both shortened
|
|
7060
|
+
// every visible record and hid further ones must not announce only one of the two.
|
|
7061
|
+
if (budget.shortenedLines) {
|
|
7062
|
+
parts.push(t('card.extracted.truncated-record-length'));
|
|
7063
|
+
}
|
|
7064
|
+
if (!parts.length) {
|
|
7065
|
+
return null;
|
|
7066
|
+
}
|
|
7067
|
+
return /*#__PURE__*/React__default.createElement(LiteralTruncationNotice, {
|
|
7068
|
+
"data-testid": "extracted-truncation-notice"
|
|
7069
|
+
}, parts.join(' '));
|
|
7070
|
+
};
|
|
6677
7071
|
const ExtractedItem = _ref2 => {
|
|
6678
7072
|
let {
|
|
6679
7073
|
type,
|
|
@@ -6682,7 +7076,8 @@ const ExtractedItem = _ref2 => {
|
|
|
6682
7076
|
text,
|
|
6683
7077
|
inline = true,
|
|
6684
7078
|
useCodeBlock = false,
|
|
6685
|
-
extractionBox
|
|
7079
|
+
extractionBox,
|
|
7080
|
+
literalBudget
|
|
6686
7081
|
} = _ref2;
|
|
6687
7082
|
// Get translated caption for extraction box
|
|
6688
7083
|
const {
|
|
@@ -6693,29 +7088,25 @@ const ExtractedItem = _ref2 => {
|
|
|
6693
7088
|
// Parse label if it's a string with glossary markdown
|
|
6694
7089
|
const parsedLabel = typeof label === 'string' ? parseGlossaryMarkdown(label) : label;
|
|
6695
7090
|
|
|
6696
|
-
//
|
|
6697
|
-
|
|
6698
|
-
|
|
7091
|
+
// DS-115: a code-block value may legitimately be MULTIPLE records (e.g. a domain that
|
|
7092
|
+
// publishes two competing `_dmarc` TXT records). Collapsing newlines into spaces ran them
|
|
7093
|
+
// together into one flowed line with no visible boundary, which is precisely the condition
|
|
7094
|
+
// the card exists to report. `renderExtractedValue`'s literal mode splits every string leaf
|
|
7095
|
+
// on newlines (including strings nested in arrays) so each record renders on its own line.
|
|
7096
|
+
const budget = literalBudget !== null && literalBudget !== void 0 ? literalBudget : createLiteralBudget();
|
|
7097
|
+
const renderedText = renderExtractedValue(text, 'extracted-item-text', useCodeBlock, budget);
|
|
6699
7098
|
const icon = getIcon({
|
|
6700
7099
|
type,
|
|
6701
7100
|
theme
|
|
6702
7101
|
});
|
|
6703
7102
|
|
|
6704
|
-
// Wrap in CodeBlock if needed
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
const children = node.props.children;
|
|
6709
|
-
|
|
6710
|
-
// If children is a string, strip ALL newlines aggressively
|
|
6711
|
-
const cleanedChildren = typeof children === 'string' ? children.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() : children;
|
|
6712
|
-
return cleanedChildren;
|
|
6713
|
-
}
|
|
6714
|
-
return node;
|
|
6715
|
-
}))) : renderedText;
|
|
7103
|
+
// Wrap in CodeBlock if needed. The truncation notice sits OUTSIDE the block — see
|
|
7104
|
+
// `renderTruncationNotice` on why it must never be a `CodeBlockLine`.
|
|
7105
|
+
const displayText = useCodeBlock ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderCodeBlock(renderedText), renderTruncationNotice(budget, t)) : renderedText;
|
|
7106
|
+
const hasContent = renderedText.length > 0;
|
|
6716
7107
|
const content = /*#__PURE__*/React__default.createElement(SignalCardInfoBox$1, null, /*#__PURE__*/React__default.createElement(InfoItemRow, null, icon ? /*#__PURE__*/React__default.createElement(InfoItemIcon, null, icon) : null, /*#__PURE__*/React__default.createElement(InfoItemContent, null, /*#__PURE__*/React__default.createElement(InfoItemLabel, {
|
|
6717
7108
|
$inline: true
|
|
6718
|
-
}, parsedLabel), inline &&
|
|
7109
|
+
}, parsedLabel), inline && hasContent ? displayText : null)), !inline && hasContent ? /*#__PURE__*/React__default.createElement(InfoItemSubtexts, null, displayText) : null);
|
|
6719
7110
|
|
|
6720
7111
|
// Wrap in extraction box if specified
|
|
6721
7112
|
if (extractionBox) {
|
|
@@ -6725,9 +7116,12 @@ const ExtractedItem = _ref2 => {
|
|
|
6725
7116
|
}
|
|
6726
7117
|
return content;
|
|
6727
7118
|
};
|
|
6728
|
-
function renderGridItem(item, idx, t) {
|
|
7119
|
+
function renderGridItem(item, idx, t, budget) {
|
|
6729
7120
|
const parsedLabel = typeof item.label === 'string' ? parseGlossaryMarkdown(item.label) : item.label;
|
|
6730
|
-
|
|
7121
|
+
// DS-115: code-block values are attacker-controlled DNS data, so render them literally —
|
|
7122
|
+
// same trust model as `ExtractedItem`. Without this the grid path fed raw TXT records
|
|
7123
|
+
// through `parseGlossaryMarkdown`, which turns `[text](https://...)` into a real anchor.
|
|
7124
|
+
const renderedText = renderExtractedValue(item.text, `grid-item-${idx}`, !!item.useCodeBlock, budget);
|
|
6731
7125
|
const icon = getIcon({
|
|
6732
7126
|
type: item.type,
|
|
6733
7127
|
theme: item.theme
|
|
@@ -6747,7 +7141,7 @@ function renderGridItem(item, idx, t) {
|
|
|
6747
7141
|
display: 'inline-flex'
|
|
6748
7142
|
}
|
|
6749
7143
|
}, icon))) : null;
|
|
6750
|
-
const displayValue = item.useCodeBlock ? /*#__PURE__*/React__default.createElement(
|
|
7144
|
+
const displayValue = item.useCodeBlock ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderCodeBlock(renderedText), renderTruncationNotice(budget, t)) : /*#__PURE__*/React__default.createElement(GridValue, null, renderedText, alignmentIcon);
|
|
6751
7145
|
return /*#__PURE__*/React__default.createElement(React__default.Fragment, {
|
|
6752
7146
|
key: idx
|
|
6753
7147
|
}, /*#__PURE__*/React__default.createElement(GridLabel, null, icon && !item.tooltipKey ? /*#__PURE__*/React__default.createElement("span", {
|
|
@@ -6766,6 +7160,15 @@ const ExtractedCategoryContent = _ref3 => {
|
|
|
6766
7160
|
const {
|
|
6767
7161
|
t
|
|
6768
7162
|
} = useSignalCardTranslation('investigate');
|
|
7163
|
+
// ONE budget per category render: upstream emits one item per candidate group, so a
|
|
7164
|
+
// per-item budget let 20 groups render 20 x 50 lines with 20 separate notices.
|
|
7165
|
+
//
|
|
7166
|
+
// The notice is emitted per ITEM rather than once per category. Hoisting it was attempted
|
|
7167
|
+
// and reverted: the budget is mutated while the ITEMS render, and a parent cannot observe
|
|
7168
|
+
// that — `<Body/>` is only an element when the parent returns, so the notice read a pristine
|
|
7169
|
+
// budget and never appeared. Doing it properly means precomputing each category's preview
|
|
7170
|
+
// and truncation state before rendering, which is tracked separately (see CHANGELOG).
|
|
7171
|
+
const literalBudget = createLiteralBudget();
|
|
6769
7172
|
const useGridLayout = category.categoryType === 'dmarc-domain' || category.categoryType === 'dmarc-policy' || category.categoryType === 'dkim-details';
|
|
6770
7173
|
if (useGridLayout) {
|
|
6771
7174
|
var _category$items, _category$items2;
|
|
@@ -6797,12 +7200,13 @@ const ExtractedCategoryContent = _ref3 => {
|
|
|
6797
7200
|
text: text,
|
|
6798
7201
|
inline: inline,
|
|
6799
7202
|
useCodeBlock: useCodeBlock,
|
|
6800
|
-
extractionBox: undefined
|
|
7203
|
+
extractionBox: undefined,
|
|
7204
|
+
literalBudget: literalBudget
|
|
6801
7205
|
});
|
|
6802
7206
|
}), /*#__PURE__*/React__default.createElement(InfoItemsGrid, {
|
|
6803
7207
|
$hasNonInlineItems: hasNonInlineItems,
|
|
6804
7208
|
$insideCollapsible: true
|
|
6805
|
-
}, (_category$items5 = category.items) === null || _category$items5 === void 0 ? void 0 : _category$items5.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t))));
|
|
7209
|
+
}, (_category$items5 = category.items) === null || _category$items5 === void 0 ? void 0 : _category$items5.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t, literalBudget))));
|
|
6806
7210
|
} else if (hasNonInlineItems) {
|
|
6807
7211
|
var _category$items6, _category$items6$find, _category$items6$find2, _category$items7;
|
|
6808
7212
|
// Only TXT records present
|
|
@@ -6828,7 +7232,8 @@ const ExtractedCategoryContent = _ref3 => {
|
|
|
6828
7232
|
text: text,
|
|
6829
7233
|
inline: inline,
|
|
6830
7234
|
useCodeBlock: useCodeBlock,
|
|
6831
|
-
extractionBox: undefined
|
|
7235
|
+
extractionBox: undefined,
|
|
7236
|
+
literalBudget: literalBudget
|
|
6832
7237
|
});
|
|
6833
7238
|
}));
|
|
6834
7239
|
} else {
|
|
@@ -6839,7 +7244,7 @@ const ExtractedCategoryContent = _ref3 => {
|
|
|
6839
7244
|
}, /*#__PURE__*/React__default.createElement(InfoItemsGrid, {
|
|
6840
7245
|
$hasNonInlineItems: false,
|
|
6841
7246
|
$insideCollapsible: true
|
|
6842
|
-
}, (_category$items8 = category.items) === null || _category$items8 === void 0 ? void 0 : _category$items8.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t))));
|
|
7247
|
+
}, (_category$items8 = category.items) === null || _category$items8 === void 0 ? void 0 : _category$items8.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t, literalBudget))));
|
|
6843
7248
|
}
|
|
6844
7249
|
}
|
|
6845
7250
|
|
|
@@ -6865,7 +7270,8 @@ const ExtractedCategoryContent = _ref3 => {
|
|
|
6865
7270
|
text: text,
|
|
6866
7271
|
inline: inline,
|
|
6867
7272
|
useCodeBlock: useCodeBlock,
|
|
6868
|
-
extractionBox: undefined
|
|
7273
|
+
extractionBox: undefined,
|
|
7274
|
+
literalBudget: literalBudget
|
|
6869
7275
|
});
|
|
6870
7276
|
})));
|
|
6871
7277
|
};
|
|
@@ -7472,9 +7878,273 @@ function renderDisplayValue(value) {
|
|
|
7472
7878
|
return value;
|
|
7473
7879
|
}
|
|
7474
7880
|
|
|
7881
|
+
/**
|
|
7882
|
+
* BIMI code → glossary page map.
|
|
7883
|
+
*
|
|
7884
|
+
* Each BIMI finding carries a `checker_codes` schema id from `redsift/event-schemas`
|
|
7885
|
+
* (e.g. `DNS-BIMI-0020`). The glossary publishes one explainer page per code. This
|
|
7886
|
+
* module turns a code into the URL of its page so a card row can offer a
|
|
7887
|
+
* "What does this mean?" link.
|
|
7888
|
+
*
|
|
7889
|
+
* ## Why the map lives here
|
|
7890
|
+
*
|
|
7891
|
+
* Decision D4a of the glossary design: the website deliberately publishes no
|
|
7892
|
+
* code → slug manifest and performs no code → URL resolution. Its side of the
|
|
7893
|
+
* contract is that these slugs are permanent; if one ever has to change, the
|
|
7894
|
+
* website owes a redirect and an updated map here.
|
|
7895
|
+
*
|
|
7896
|
+
* ## Keyed on the code, never the slug or the message text
|
|
7897
|
+
*
|
|
7898
|
+
* The code is the only stable identifier. Titles, severities and statuses drift
|
|
7899
|
+
* (0033 changed under WEB-1155; 0021's grammar changed) and slugs are human text,
|
|
7900
|
+
* so they are the most likely thing to be re-worded later. Row message text is
|
|
7901
|
+
* worse still — it is backend prose that `formatMessageWithUrls()` rewrites before
|
|
7902
|
+
* it reaches the card, and it is not in any translation catalogue.
|
|
7903
|
+
*
|
|
7904
|
+
* ## Unmapped codes resolve to nothing, by design
|
|
7905
|
+
*
|
|
7906
|
+
* `resolveBimiGlossaryHref` returns `undefined` rather than guessing a URL. This is
|
|
7907
|
+
* an everyday path, not an edge case:
|
|
7908
|
+
* - `DNS-BIMI-0003` has no glossary page — the authored set jumps 0002 → 0004.
|
|
7909
|
+
* - `msgCode` is a free-form string upstream; real payloads carry non-schema
|
|
7910
|
+
* values such as `multiple-records`, `config-error` and `DNS-BIMI-0000-MISMATCH`.
|
|
7911
|
+
* - IPForensics currently mis-assigns some codes (`bimi-error-invalid-record` is
|
|
7912
|
+
* tagged 0021, which is the "both l and a tags missed" page). A permissive
|
|
7913
|
+
* resolver would send a reader to a page describing a *different* error, which
|
|
7914
|
+
* is worse than offering no link at all.
|
|
7915
|
+
*
|
|
7916
|
+
* @see https://redsift.atlassian.net/browse/DS-113
|
|
7917
|
+
*/
|
|
7918
|
+
|
|
7919
|
+
/** Production origin for glossary pages, used when the card renders outside the website. */
|
|
7920
|
+
const GLOSSARY_ORIGIN = 'https://redsift.com';
|
|
7921
|
+
|
|
7922
|
+
/** Site-relative path prefix for glossary pages. */
|
|
7923
|
+
const GLOSSARY_PATH = '/glossary';
|
|
7924
|
+
|
|
7925
|
+
/**
|
|
7926
|
+
* Code → glossary slug. Read from the published Prismic API on 2026-08-18.
|
|
7927
|
+
*
|
|
7928
|
+
* 36 entries. Regenerate from the content API rather than editing by hand, and
|
|
7929
|
+
* note the two known coverage gaps:
|
|
7930
|
+
* - 0003 is absent (no authored page).
|
|
7931
|
+
* - 0023–0032 are authored and live but the BIMI Checker does not emit them yet,
|
|
7932
|
+
* so they only appear on cards fed directly from the schema feed.
|
|
7933
|
+
*/
|
|
7934
|
+
const BIMI_CODE_TO_GLOSSARY_SLUG = Object.freeze(Object.assign(Object.create(null), {
|
|
7935
|
+
'DNS-BIMI-0001': 'logo-url-scheme-is-not-https-missing-vmc-in-record',
|
|
7936
|
+
'DNS-BIMI-0002': 'no-vmc-in-record',
|
|
7937
|
+
// 0003 intentionally absent — no glossary page authored.
|
|
7938
|
+
'DNS-BIMI-0004': 'missing-l-tag',
|
|
7939
|
+
'DNS-BIMI-0005': 'failed-to-fetch-logo',
|
|
7940
|
+
'DNS-BIMI-0006': 'no-version-in-record',
|
|
7941
|
+
'DNS-BIMI-0007': 'failed-to-fetch-authority-certificate',
|
|
7942
|
+
'DNS-BIMI-0008': 'no-record',
|
|
7943
|
+
'DNS-BIMI-0009': 'invalid-record',
|
|
7944
|
+
'DNS-BIMI-0010': 'valid-record-but-dmarc-isn-t-ready',
|
|
7945
|
+
'DNS-BIMI-0011': 'vmc-validation-errors',
|
|
7946
|
+
'DNS-BIMI-0012': 'certificate-can-t-be-accessed-automatically',
|
|
7947
|
+
'DNS-BIMI-0013': 'declination-to-publish',
|
|
7948
|
+
'DNS-BIMI-0014': 'certificate-not-valid-for-the-domain',
|
|
7949
|
+
'DNS-BIMI-0015': 'bimi-record-validation-errors',
|
|
7950
|
+
'DNS-BIMI-0016': 'authority-url-scheme-is-not-https',
|
|
7951
|
+
'DNS-BIMI-0017': 'logo-validation-errors',
|
|
7952
|
+
'DNS-BIMI-0018': 'bimi-record-found-on-organizational-domain',
|
|
7953
|
+
'DNS-BIMI-0019': 'multiple-bimi-records-found-on-target-domain',
|
|
7954
|
+
'DNS-BIMI-0020': 'duplicate-tag-detected-in-bimi-record',
|
|
7955
|
+
'DNS-BIMI-0021': 'both-l-and-a-tags-missed',
|
|
7956
|
+
'DNS-BIMI-0022': 'no-bimi-record-found-and-dmarc-policy-is-insufficient',
|
|
7957
|
+
'DNS-BIMI-0023': 'svg-logo-dimensions-use-non-pixel-values',
|
|
7958
|
+
'DNS-BIMI-0024': 'svg-logo-dimensions-below-96x96-pixel-minimum',
|
|
7959
|
+
'DNS-BIMI-0025': 'svg-logo-missing-width-or-height-attributes',
|
|
7960
|
+
'DNS-BIMI-0026': 'vmc-pem-starts-with-utf-8-bom',
|
|
7961
|
+
'DNS-BIMI-0027': 'vmc-pem-bundle-contains-only-the-leaf-certificate',
|
|
7962
|
+
'DNS-BIMI-0028': 'vmc-certificate-chain-signature-verification-failed',
|
|
7963
|
+
'DNS-BIMI-0029': 'vmc-certificate-chain-does-not-terminate-at-a-trusted-bimi-root-ca',
|
|
7964
|
+
'DNS-BIMI-0030': 'vmc-certificate-chain-ordering-does-not-match-expected-issuance-order',
|
|
7965
|
+
'DNS-BIMI-0031': 'vmc-pem-bundle-omits-the-root-ca-certificate',
|
|
7966
|
+
'DNS-BIMI-0032': 'non-https-url-encountered-while-fetching-the-vmc-certificate',
|
|
7967
|
+
'DNS-BIMI-0033': 'vmc-leaf-certificate-selection-is-ambiguous',
|
|
7968
|
+
'DNS-BIMI-0034': 'timed-out-fetching-the-bimi-asset',
|
|
7969
|
+
'DNS-BIMI-0035': 'bimi-asset-host-could-not-be-resolved',
|
|
7970
|
+
'DNS-BIMI-0036': 'connection-to-the-bimi-asset-host-failed',
|
|
7971
|
+
'DNS-BIMI-0037': 'tls-handshake-failed-fetching-the-bimi-asset'
|
|
7972
|
+
}));
|
|
7973
|
+
|
|
7974
|
+
/** Options for {@link resolveBimiGlossaryHref}. */
|
|
7975
|
+
|
|
7976
|
+
/**
|
|
7977
|
+
* Resolve a BIMI schema code to its glossary page URL.
|
|
7978
|
+
*
|
|
7979
|
+
* @param code - A `checker_codes` schema id, e.g. `'DNS-BIMI-0020'`. Anything not
|
|
7980
|
+
* in {@link BIMI_CODE_TO_GLOSSARY_SLUG} resolves to `undefined`.
|
|
7981
|
+
* @returns The page URL, or `undefined` when the code has no glossary page.
|
|
7982
|
+
*
|
|
7983
|
+
* @example
|
|
7984
|
+
* resolveBimiGlossaryHref('DNS-BIMI-0008');
|
|
7985
|
+
* // 'https://redsift.com/glossary/no-record'
|
|
7986
|
+
* resolveBimiGlossaryHref('DNS-BIMI-0008', { relative: true });
|
|
7987
|
+
* // '/glossary/no-record'
|
|
7988
|
+
* resolveBimiGlossaryHref('DNS-BIMI-0003'); // undefined — no page authored
|
|
7989
|
+
* resolveBimiGlossaryHref('config-error'); // undefined — not a schema code
|
|
7990
|
+
*/
|
|
7991
|
+
function resolveBimiGlossaryHref(code) {
|
|
7992
|
+
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
7993
|
+
if (!code) return undefined;
|
|
7994
|
+
|
|
7995
|
+
// Own-property check, not just a truthiness test. `code` originates from `msgCode`,
|
|
7996
|
+
// which is free-form backend data, so a key like `__proto__` or `constructor` would
|
|
7997
|
+
// otherwise resolve to an inherited value — truthy, and stringified into a URL such
|
|
7998
|
+
// as `/glossary/[object Object]`. Both maps are null-prototyped as well; this is the
|
|
7999
|
+
// second layer, so reverting either one alone cannot reintroduce the hole.
|
|
8000
|
+
// Raised in review on PR #1172.
|
|
8001
|
+
if (!Object.prototype.hasOwnProperty.call(BIMI_CODE_TO_GLOSSARY_SLUG, code)) return undefined;
|
|
8002
|
+
const slug = BIMI_CODE_TO_GLOSSARY_SLUG[code];
|
|
8003
|
+
if (typeof slug !== 'string' || !slug) return undefined;
|
|
8004
|
+
const path = `${GLOSSARY_PATH}/${slug}`;
|
|
8005
|
+
return options.relative ? path : `${GLOSSARY_ORIGIN}${path}`;
|
|
8006
|
+
}
|
|
8007
|
+
|
|
8008
|
+
/**
|
|
8009
|
+
* signal-logic translation key → BIMI schema code, for rows that carry no `code`.
|
|
8010
|
+
*
|
|
8011
|
+
* The `bimiRecordStatus` switch in signal-logic builds its rows from a fixed
|
|
8012
|
+
* translation key and never attaches a code, so those rows cannot be linked from
|
|
8013
|
+
* `InfoItem.code` alone (see WEB-1239). The key, however, *is* deterministic: it is
|
|
8014
|
+
* a literal in signal-logic's source, not backend prose, so mapping key → code is
|
|
8015
|
+
* stable in a way that matching the rendered message text is not.
|
|
8016
|
+
*
|
|
8017
|
+
* Covers two families of key:
|
|
8018
|
+
*
|
|
8019
|
+
* 1. The `bimiRecordStatus` switch rows (`record-missing_md` and friends).
|
|
8020
|
+
* 2. The per-error `recordValidationErrors` rows, keyed `card.bimi.errors.<errorCode>`.
|
|
8021
|
+
* These are the individual "Record l value needs to be a valid URL" lines listed
|
|
8022
|
+
* under an invalid record. They matter because they are the rows that actually tell
|
|
8023
|
+
* a reader *what* to fix, and the schema code that IPForensics assigned them is
|
|
8024
|
+
* destroyed before the card sees it: the website's `RecordMessageCodes` rewrites
|
|
8025
|
+
* `DNS-BIMI-00XX` into these `bimi-error-*` names, collapsing 0009/0015/0020/0021
|
|
8026
|
+
* all onto `bimi-error-invalid-record`. Mapping the name back is therefore the only
|
|
8027
|
+
* way to link these rows, and it is safe only where the relation is 1:1.
|
|
8028
|
+
*
|
|
8029
|
+
* Deliberately absent:
|
|
8030
|
+
* - `record-failed_md` — `NOT_VMC`, `WITH_ERRORS` and `CERT_INVALID` all collapse
|
|
8031
|
+
* to this one row, so it is many-to-one and cannot name a single page.
|
|
8032
|
+
* - `record-passed_md` / `record-declination_md` are pass/info states; 0013
|
|
8033
|
+
* (declination) is emitted separately as a coded report row.
|
|
8034
|
+
* - `record-unknown-error_md` has no page.
|
|
8035
|
+
* - `errors.bimi-error-unsupported-values` has no authored glossary page.
|
|
8036
|
+
* - `errors.bimi-error-invalid-record` — the website collapses 0009, 0015, 0020 and
|
|
8037
|
+
* 0021 onto this one name, and those pages describe different errors: 0009 is a
|
|
8038
|
+
* record that cannot be parsed at all, while 0020 (duplicate tag) and 0021 (both
|
|
8039
|
+
* l= and a= missing) are parseable records. Mapping it to 0009 sent a reader
|
|
8040
|
+
* whose actual error was 0020 or 0021 to a page describing a parse failure that
|
|
8041
|
+
* did not happen. Raised in review on PR #1172.
|
|
8042
|
+
* - Most `logoErrors.invalid-svg-*` rows. These describe *which* SVG rule the logo
|
|
8043
|
+
* broke (bad root element, disallowed `script` element, wrong profile, …) and the
|
|
8044
|
+
* glossary has no page per rule — only the umbrella 0017 `logo-validation-errors`.
|
|
8045
|
+
* Pointing 16 distinct failures at one generic page would tell a reader nothing
|
|
8046
|
+
* they cannot already see on the row. `invalid-svg-missing-title` is the exception:
|
|
8047
|
+
* it appears on cards whose only other SVG row is the 0017 summary, so without it
|
|
8048
|
+
* an all-SVG failure card can have no link at all.
|
|
8049
|
+
* - `logoErrors.logo-extract-error` — the website collapses 0034/0035/0036/0037
|
|
8050
|
+
* (timeout, host unresolved, connection failed, TLS handshake failed) all onto it,
|
|
8051
|
+
* and each of those has its own distinct page, so it cannot name one.
|
|
8052
|
+
* - `pemErrors.cert-not-found` — lossy: the website maps 0002+0007 onto it, and
|
|
8053
|
+
* 0034–0037 land there too.
|
|
8054
|
+
*/
|
|
8055
|
+
const BIMI_TRANSLATION_KEY_TO_CODE = Object.freeze(Object.assign(Object.create(null), {
|
|
8056
|
+
// bimiRecordStatus switch rows
|
|
8057
|
+
'card.bimi.record-missing_md': 'DNS-BIMI-0008',
|
|
8058
|
+
// no-record
|
|
8059
|
+
'card.bimi.record-invalid_md': 'DNS-BIMI-0009',
|
|
8060
|
+
// invalid-record
|
|
8061
|
+
'card.bimi.record-no-cert_md': 'DNS-BIMI-0007',
|
|
8062
|
+
// failed-to-fetch-authority-certificate
|
|
8063
|
+
// The row's own "[here]" help link is stripped by the adapter, so the glossary page
|
|
8064
|
+
// is the single destination rather than competing with it.
|
|
8065
|
+
'card.bimi.cert-forbidden_md': 'DNS-BIMI-0012',
|
|
8066
|
+
// certificate-can-t-be-accessed-automatically
|
|
8067
|
+
'card.bimi.could-not-fetch_md': 'DNS-BIMI-0005',
|
|
8068
|
+
// failed-to-fetch-logo
|
|
8069
|
+
// Per-error recordValidationErrors rows
|
|
8070
|
+
'card.bimi.errors.bimi-error-missing-l': 'DNS-BIMI-0004',
|
|
8071
|
+
// missing-l-tag
|
|
8072
|
+
'card.bimi.errors.bimi-error-missing-a': 'DNS-BIMI-0002',
|
|
8073
|
+
// no-vmc-in-record
|
|
8074
|
+
'card.bimi.errors.bimi-error-missing-v': 'DNS-BIMI-0006',
|
|
8075
|
+
// no-version-in-record
|
|
8076
|
+
'card.bimi.errors.bimi-error-invalid-l': 'DNS-BIMI-0001',
|
|
8077
|
+
// logo-url-scheme-is-not-https…
|
|
8078
|
+
'card.bimi.errors.bimi-error-invalid-a': 'DNS-BIMI-0016',
|
|
8079
|
+
// authority-url-scheme-is-not-https
|
|
8080
|
+
'card.bimi.errors.bimi-error-cert-not-valid': 'DNS-BIMI-0014',
|
|
8081
|
+
// certificate-not-valid-for-the-domain
|
|
8082
|
+
'card.bimi.errors.bimi-error-incorrect-v': 'DNS-BIMI-0006',
|
|
8083
|
+
// no-version-in-record — v= present but wrong
|
|
8084
|
+
// No page describes an unspecified error, but the row only ever appears under record
|
|
8085
|
+
// validation, and 0015 is that section's umbrella page — better than nothing for a
|
|
8086
|
+
// reader who cannot otherwise tell what to do next.
|
|
8087
|
+
'card.bimi.errors.bimi-error-unknown': 'DNS-BIMI-0015',
|
|
8088
|
+
// bimi-record-validation-errors
|
|
8089
|
+
// Safe SVG section
|
|
8090
|
+
'card.bimi.svg-missing-title_md': 'DNS-BIMI-0004',
|
|
8091
|
+
// missing-l-tag — no logo published on the record
|
|
8092
|
+
'card.bimi.record-svg-added-from-itag-invalid_md': 'DNS-BIMI-0017',
|
|
8093
|
+
// logo-validation-errors
|
|
8094
|
+
// VMC/CMC certificate rows
|
|
8095
|
+
'card.bimi.vmc-invalid-title_md': 'DNS-BIMI-0011',
|
|
8096
|
+
// vmc-validation-errors
|
|
8097
|
+
'card.bimi.vmc-not-vmc-title_md': 'DNS-BIMI-0011',
|
|
8098
|
+
// vmc-validation-errors — not a VMC/CMC at all
|
|
8099
|
+
'card.bimi.vmc.cert-ambiguous-leaf_md': 'DNS-BIMI-0033',
|
|
8100
|
+
// vmc-leaf-certificate-selection-is-ambiguous
|
|
8101
|
+
'card.bimi.logoErrors.invalid-svg-missing-title_md': 'DNS-BIMI-0017',
|
|
8102
|
+
// logo-validation-errors
|
|
8103
|
+
// signal-logic's catch-all for an svgErrors string it has no translation for. Those
|
|
8104
|
+
// strings are raw fetch/parse failures ("can't fetch SVG from ...: http status not
|
|
8105
|
+
// OK"), which IPForensics tags extractError = DNS-BIMI-0005, so failed-to-fetch-logo
|
|
8106
|
+
// is the page. Without this the row renders with no link at all, since the fallback
|
|
8107
|
+
// branch attaches neither a code nor a per-error key.
|
|
8108
|
+
'card.bimi.svg-error-fallback_md': 'DNS-BIMI-0005',
|
|
8109
|
+
// failed-to-fetch-logo
|
|
8110
|
+
// IPForensics tags the l=/certificate logo mismatch 'DNS-BIMI-0000-MISMATCH', which is
|
|
8111
|
+
// not a schema code and matches no page, so the code on the item cannot resolve.
|
|
8112
|
+
// The umbrella 0017 page covers this class, and the sibling "image with errors" row
|
|
8113
|
+
// on the same card already points there.
|
|
8114
|
+
'card.bimi.logoErrors.logo-and-evidence-mismatch_md': 'DNS-BIMI-0017',
|
|
8115
|
+
// logo-validation-errors
|
|
8116
|
+
// VMC/PEM error rows that map 1:1
|
|
8117
|
+
'card.bimi.pemErrors.cert-not-https_md': 'DNS-BIMI-0016',
|
|
8118
|
+
// authority-url-scheme-is-not-https
|
|
8119
|
+
'card.bimi.pemErrors.cert-forbidden_md': 'DNS-BIMI-0012',
|
|
8120
|
+
// certificate-can-t-be-accessed-automatically
|
|
8121
|
+
// Matches the `bimi-error-cert-not-valid` row, which resolves to the same page.
|
|
8122
|
+
// Leaving this unlinked made identical "Certificate is not valid" text linked under
|
|
8123
|
+
// BIMI record status but not under VMC/CMC Certificate on the same card.
|
|
8124
|
+
'card.bimi.pemErrors.cert-not-valid_md': 'DNS-BIMI-0014',
|
|
8125
|
+
// certificate-not-valid-for-the-domain
|
|
8126
|
+
'card.bimi.pemErrors.cert-bad-signature_md': 'DNS-BIMI-0028' // vmc-certificate-chain-signature-verification-failed
|
|
8127
|
+
}));
|
|
8128
|
+
|
|
8129
|
+
/**
|
|
8130
|
+
* Resolve the BIMI schema code for a signal-logic translation key, for rows that
|
|
8131
|
+
* signal-logic derives from status alone and therefore emits without a `code`.
|
|
8132
|
+
*
|
|
8133
|
+
* @returns The schema code, or `undefined` when the key maps to no single code.
|
|
8134
|
+
*/
|
|
8135
|
+
function resolveBimiCodeForTranslationKey(key) {
|
|
8136
|
+
if (!key) return undefined;
|
|
8137
|
+
// Same own-property guard as resolveBimiGlossaryHref: translation keys are literals
|
|
8138
|
+
// today, but this must not become the weak link if that ever changes.
|
|
8139
|
+
if (!Object.prototype.hasOwnProperty.call(BIMI_TRANSLATION_KEY_TO_CODE, key)) return undefined;
|
|
8140
|
+
const code = BIMI_TRANSLATION_KEY_TO_CODE[key];
|
|
8141
|
+
return typeof code === 'string' && code ? code : undefined;
|
|
8142
|
+
}
|
|
8143
|
+
|
|
7475
8144
|
// =============================================================================
|
|
7476
8145
|
// BIMI TRANSLATION KEY → data-testid MAPPING
|
|
7477
8146
|
// =============================================================================
|
|
8147
|
+
|
|
7478
8148
|
/**
|
|
7479
8149
|
* Maps signal-logic BIMI translation keys to the data-testid values
|
|
7480
8150
|
* that the old DS helpers used to set on checklist items.
|
|
@@ -7558,17 +8228,106 @@ function adaptExtractedItemCategory(cat) {
|
|
|
7558
8228
|
};
|
|
7559
8229
|
}
|
|
7560
8230
|
|
|
8231
|
+
/**
|
|
8232
|
+
* Build the markdown for a row's "What does this mean?" glossary link (DS-113).
|
|
8233
|
+
*
|
|
8234
|
+
* Returns `undefined` when the row carries no code, or a code with no glossary page.
|
|
8235
|
+
* The link is emitted as markdown so the existing `parseGlossaryMarkdown` path in
|
|
8236
|
+
* Checklist renders it — that path already validates the protocol and applies
|
|
8237
|
+
* `target="_blank" rel="noopener noreferrer"`, so this adds no new render surface.
|
|
8238
|
+
*
|
|
8239
|
+
* The reader never sees the raw code: `DNS-BIMI-0024` means nothing to them.
|
|
8240
|
+
*/
|
|
8241
|
+
function buildGlossaryLinkMarkdown(code, t) {
|
|
8242
|
+
const href = resolveBimiGlossaryHref(code);
|
|
8243
|
+
if (!href) return undefined;
|
|
8244
|
+
return `[${t('card.bimi.glossary-link')}](${href})`;
|
|
8245
|
+
}
|
|
8246
|
+
|
|
8247
|
+
/**
|
|
8248
|
+
* Markdown links whose target is a "how to fix it" help article, in either of the two
|
|
8249
|
+
* hosts signal-logic links to.
|
|
8250
|
+
*
|
|
8251
|
+
* Matches the whole markdown link plus any leading space, so removing it leaves no gap.
|
|
8252
|
+
* The label is matched loosely (any text) but each href is pinned:
|
|
8253
|
+
* - a bare `community.redsift.com/s/article/<n>` — no `#fragment` branch, so a
|
|
8254
|
+
* glossary *term* link like `[BIMI Record](…/article/000001019#bimi-record)`, which
|
|
8255
|
+
* defines a term mid-sentence rather than offering a fix, is left intact.
|
|
8256
|
+
* - a `knowledge.ondmarc.redsift.com/en/articles/<slug>` — the "[here]" link on the
|
|
8257
|
+
* anti-bot row.
|
|
8258
|
+
*/
|
|
8259
|
+
const HELP_ARTICLE_LINK = /\s*\[[^\]]*\]\((?:https:\/\/community\.redsift\.com\/s\/article\/\d+|https:\/\/knowledge\.ondmarc\.redsift\.com\/en\/articles\/[^)]+)\)/g;
|
|
8260
|
+
|
|
8261
|
+
/**
|
|
8262
|
+
* A trailing clause left behind once a help-article link is removed.
|
|
8263
|
+
*
|
|
8264
|
+
* Deliberately **locale-agnostic**: it cuts everything after the last sentence-ending
|
|
8265
|
+
* punctuation rather than matching known lead-in wording. Listing English phrases
|
|
8266
|
+
* would leave every other locale broken — `fr-FR` renders the anti-bot row as
|
|
8267
|
+
* "…anti-bot. En savoir plus et comment résoudre le problème [ici](…)", so an
|
|
8268
|
+
* English-only pattern strips the link and leaves the French clause dangling in front
|
|
8269
|
+
* of the glossary link.
|
|
8270
|
+
*
|
|
8271
|
+
* Requires a preceding sentence to keep: a row that is *only* a lead-in has nothing
|
|
8272
|
+
* to fall back to, so the guard in `stripHelpArticleLink` leaves it alone.
|
|
8273
|
+
*/
|
|
8274
|
+
const TRAILING_CLAUSE = /([.!?](?:\*{1,2}|_{1,2}|`)?)[^.!?]*$/;
|
|
8275
|
+
|
|
8276
|
+
/**
|
|
8277
|
+
* Drop a row's help-article link when a glossary link replaces it.
|
|
8278
|
+
*
|
|
8279
|
+
* Both answer the same question, and the glossary page is the canonical explainer —
|
|
8280
|
+
* it covers what the error means *and* how to fix it — so showing both leaves the
|
|
8281
|
+
* reader choosing between two competing links on one line.
|
|
8282
|
+
*
|
|
8283
|
+
* A clause left stranded by the removal goes with it, in any locale. When the row has
|
|
8284
|
+
* no earlier sentence to fall back on, the text is left as-is minus the link rather
|
|
8285
|
+
* than risk emptying the row.
|
|
8286
|
+
*/
|
|
8287
|
+
function stripHelpArticleLink(text) {
|
|
8288
|
+
const withoutLink = text.replace(HELP_ARTICLE_LINK, '');
|
|
8289
|
+
if (withoutLink === text) return text;
|
|
8290
|
+
const trimmedEnd = withoutLink.trimEnd();
|
|
8291
|
+
// Nothing after the final sentence break means nothing dangled — keep as-is.
|
|
8292
|
+
if (/[.!?]\s*$/.test(trimmedEnd)) return trimmedEnd;
|
|
8293
|
+
const cut = trimmedEnd.replace(TRAILING_CLAUSE, '$1');
|
|
8294
|
+
return cut === trimmedEnd ? trimmedEnd : cut;
|
|
8295
|
+
}
|
|
8296
|
+
|
|
7561
8297
|
/**
|
|
7562
8298
|
* Convert an InfoItem (DisplayValue text) → ChecklistItem-compatible shape.
|
|
8299
|
+
*
|
|
8300
|
+
* When the item carries a `code` (a `checker_codes` schema id forwarded by
|
|
8301
|
+
* signal-logic under WEB-1239) and that code has a glossary page, a
|
|
8302
|
+
* "What does this mean?" link is appended to the end of the row's own text, so it
|
|
8303
|
+
* reads inline rather than as a stacked second line — a card listing several
|
|
8304
|
+
* validation errors would otherwise double in height.
|
|
8305
|
+
*
|
|
8306
|
+
* The link can only be appended inline when the row text is a plain string. A
|
|
8307
|
+
* StructuredData row (charts, status lists, code blocks) has no meaningful "end of
|
|
8308
|
+
* the line" to append to, so those fall back to a subtext beneath the row.
|
|
8309
|
+
*
|
|
8310
|
+
* Where the row already carried a help-article link ("Learn how to fix it", or the
|
|
8311
|
+
* anti-bot row's "[here]"), that link is removed: the glossary page supersedes it, and
|
|
8312
|
+
* two links answering the same question on one line makes the reader choose between
|
|
8313
|
+
* them.
|
|
7563
8314
|
*/
|
|
7564
|
-
function adaptInfoItem(item, testId) {
|
|
8315
|
+
function adaptInfoItem(item, testId, t, fallbackCode) {
|
|
8316
|
+
var _item$code;
|
|
8317
|
+
const existing = item.subtexts ? item.subtexts.map(s => ({
|
|
8318
|
+
text: renderDisplayValue(s.text)
|
|
8319
|
+
})) : undefined;
|
|
8320
|
+
// A code carried on the item always wins; the fallback only fills in for rows
|
|
8321
|
+
// signal-logic derives from status alone (DS-113).
|
|
8322
|
+
const link = t ? buildGlossaryLinkMarkdown((_item$code = item.code) !== null && _item$code !== void 0 ? _item$code : fallbackCode, t) : undefined;
|
|
8323
|
+
const inlineable = link !== undefined && typeof item.text === 'string';
|
|
7565
8324
|
return _objectSpread2({
|
|
7566
8325
|
type: item.type,
|
|
7567
8326
|
theme: item.theme,
|
|
7568
|
-
text: renderDisplayValue(item.text),
|
|
7569
|
-
subtexts:
|
|
7570
|
-
text: renderDisplayValue(
|
|
7571
|
-
}
|
|
8327
|
+
text: renderDisplayValue(inlineable ? `${stripHelpArticleLink(item.text)} ${link}` : item.text),
|
|
8328
|
+
subtexts: link !== undefined && !inlineable ? [...(existing !== null && existing !== void 0 ? existing : []), {
|
|
8329
|
+
text: renderDisplayValue(link)
|
|
8330
|
+
}] : existing
|
|
7572
8331
|
}, testId ? {
|
|
7573
8332
|
'data-testid': testId
|
|
7574
8333
|
} : undefined);
|
|
@@ -7738,12 +8497,14 @@ function adaptBimiExtractor() {
|
|
|
7738
8497
|
const [props, t, context] = args;
|
|
7739
8498
|
|
|
7740
8499
|
// Wrap t to track which translation keys map to which returned strings.
|
|
7741
|
-
// This lets us stamp data-testid on items by matching the translated text back to its key
|
|
8500
|
+
// This lets us stamp data-testid on items by matching the translated text back to its key,
|
|
8501
|
+
// and (DS-113) recover the BIMI schema code for status-derived rows that signal-logic
|
|
8502
|
+
// emits without an `InfoItem.code`.
|
|
7742
8503
|
const translatedValueToKey = new Map();
|
|
7743
8504
|
const trackingT = (key, params) => {
|
|
7744
8505
|
const value = t(key, params);
|
|
7745
|
-
//
|
|
7746
|
-
if (BIMI_KEY_TO_TESTID[key]) {
|
|
8506
|
+
// Track keys that carry a testId mapping or a glossary code
|
|
8507
|
+
if (BIMI_KEY_TO_TESTID[key] || resolveBimiCodeForTranslationKey(key)) {
|
|
7747
8508
|
translatedValueToKey.set(value, key);
|
|
7748
8509
|
}
|
|
7749
8510
|
return value;
|
|
@@ -7784,11 +8545,49 @@ function adaptBimiExtractor() {
|
|
|
7784
8545
|
}
|
|
7785
8546
|
return undefined;
|
|
7786
8547
|
};
|
|
8548
|
+
|
|
8549
|
+
// Recover the schema code for a status-derived row (DS-113). signal-logic attaches
|
|
8550
|
+
// `code` only to rows built from a coded error; the `bimiRecordStatus`, validation
|
|
8551
|
+
// error, Safe SVG and VMC-title rows carry none, so fall back to the translation key
|
|
8552
|
+
// that produced the text.
|
|
8553
|
+
//
|
|
8554
|
+
// Mirrors resolveTestId's reverse CMC→VMC pass: signal-logic post-processes several
|
|
8555
|
+
// of these strings with localizeAssertionMarkdown() after t() returns, so the text on
|
|
8556
|
+
// the item no longer matches what the tracking wrapper recorded.
|
|
8557
|
+
const resolveFallbackCode = text => {
|
|
8558
|
+
const direct = resolveBimiCodeForTranslationKey(translatedValueToKey.get(text));
|
|
8559
|
+
if (direct) return direct;
|
|
8560
|
+
const vmcText = text.replace(/CMC/g, 'VMC');
|
|
8561
|
+
if (vmcText !== text) {
|
|
8562
|
+
const viaVmc = resolveBimiCodeForTranslationKey(translatedValueToKey.get(vmcText));
|
|
8563
|
+
if (viaVmc) return viaVmc;
|
|
8564
|
+
}
|
|
8565
|
+
return undefined;
|
|
8566
|
+
};
|
|
8567
|
+
|
|
8568
|
+
// A row whose message contains a URL is not a string: signal-logic runs it through
|
|
8569
|
+
// formatMessageWithUrls(), which returns FormattedText StructuredData with the text
|
|
8570
|
+
// split into segments. Flatten those segments back to a plain string so the key
|
|
8571
|
+
// lookups above can see rows like the svgErrors fallback ("can't fetch SVG from
|
|
8572
|
+
// <url>: http status not OK"), which would otherwise never resolve a code.
|
|
8573
|
+
const plainText = value => {
|
|
8574
|
+
if (typeof value === 'string') return value;
|
|
8575
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
8576
|
+
const segments = value.segments;
|
|
8577
|
+
if (!Array.isArray(segments)) return undefined;
|
|
8578
|
+
const joined = segments.map(s => {
|
|
8579
|
+
var _s$text;
|
|
8580
|
+
return (_s$text = s === null || s === void 0 ? void 0 : s.text) !== null && _s$text !== void 0 ? _s$text : '';
|
|
8581
|
+
}).join('');
|
|
8582
|
+
return joined || undefined;
|
|
8583
|
+
};
|
|
7787
8584
|
const adaptWithTestIds = categories => categories === null || categories === void 0 ? void 0 : categories.map(cat => ({
|
|
7788
8585
|
title: cat.title,
|
|
7789
8586
|
items: cat.items.map(item => {
|
|
7790
|
-
const
|
|
7791
|
-
|
|
8587
|
+
const text = plainText(item.text);
|
|
8588
|
+
const testId = text !== undefined ? resolveTestId(text) : undefined;
|
|
8589
|
+
const fallbackCode = text !== undefined ? resolveFallbackCode(text) : undefined;
|
|
8590
|
+
return adaptInfoItem(item, testId, t, fallbackCode);
|
|
7792
8591
|
})
|
|
7793
8592
|
}));
|
|
7794
8593
|
return {
|
|
@@ -8672,20 +9471,43 @@ const buildPublishedCertificateSections = (cert, pem, t, now, assertionMarkType)
|
|
|
8672
9471
|
|
|
8673
9472
|
// --- Verification issues ---
|
|
8674
9473
|
// Sourced from PEM evidence errors plus an ambiguous leaf-selection signal.
|
|
8675
|
-
|
|
9474
|
+
//
|
|
9475
|
+
// These rows are built here rather than in the extractor adapter, so the adapter's
|
|
9476
|
+
// glossary-link pass (DS-113) does not reach them. Append the link locally, keyed on
|
|
9477
|
+
// the same code the adapter uses, so a verification issue is explained exactly as the
|
|
9478
|
+
// equivalent row in the legacy VMC section is.
|
|
9479
|
+
const withGlossaryLink = (text, code) => {
|
|
9480
|
+
const href = resolveBimiGlossaryHref(code);
|
|
9481
|
+
return href ? `${text} [${t('card.bimi.glossary-link')}](${href})` : text;
|
|
9482
|
+
};
|
|
9483
|
+
|
|
9484
|
+
// pem.errors entries carry a code alongside the message (IPForensics BimiError);
|
|
9485
|
+
// keep it so the row can resolve its page rather than dropping to message-only.
|
|
9486
|
+
const errorEntries = ((_pem$errors = pem === null || pem === void 0 ? void 0 : pem.errors) !== null && _pem$errors !== void 0 ? _pem$errors : []).map(e => typeof e === 'string' ? {
|
|
9487
|
+
message: e,
|
|
9488
|
+
code: undefined
|
|
9489
|
+
} : {
|
|
9490
|
+
message: e === null || e === void 0 ? void 0 : e.message,
|
|
9491
|
+
code: e === null || e === void 0 ? void 0 : e.code
|
|
9492
|
+
}).filter(e => !!e.message);
|
|
8676
9493
|
const isAmbiguous = (pem === null || pem === void 0 ? void 0 : pem.leafSelectionMethod) === 'AMBIGUOUS';
|
|
8677
9494
|
const issueItems = [];
|
|
8678
9495
|
if (isAmbiguous) {
|
|
9496
|
+
const key = 'card.bimi.vmc.cert-ambiguous-leaf_md';
|
|
8679
9497
|
issueItems.push({
|
|
8680
9498
|
type: 'warning',
|
|
8681
|
-
text: t(
|
|
9499
|
+
text: withGlossaryLink(t(key), resolveBimiCodeForTranslationKey(key)),
|
|
8682
9500
|
'data-testid': 'bimi-cert-issue-ambiguous'
|
|
8683
9501
|
});
|
|
8684
9502
|
}
|
|
8685
|
-
|
|
9503
|
+
errorEntries.forEach((_ref2, idx) => {
|
|
9504
|
+
let {
|
|
9505
|
+
message,
|
|
9506
|
+
code
|
|
9507
|
+
} = _ref2;
|
|
8686
9508
|
issueItems.push({
|
|
8687
9509
|
type: 'warning',
|
|
8688
|
-
text: message,
|
|
9510
|
+
text: withGlossaryLink(message, code),
|
|
8689
9511
|
'data-testid': `bimi-cert-issue-${idx}`
|
|
8690
9512
|
});
|
|
8691
9513
|
});
|