qa-engineer 0.9.2 → 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.
Files changed (106) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +20 -30
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +336 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +90 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  14. package/packages/engine/lib/analysis/report-html.mjs +763 -0
  15. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  16. package/packages/engine/lib/analysis/xml.mjs +153 -0
  17. package/packages/engine/lib/analysis/zip.mjs +107 -0
  18. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  19. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  20. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  21. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  22. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  23. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  24. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  25. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  26. package/packages/engine/package.json +14 -0
  27. package/packages/installer/lib/commands/doctor.mjs +7 -16
  28. package/packages/installer/lib/commands/install.mjs +28 -0
  29. package/packages/installer/lib/core/bundle.mjs +69 -92
  30. package/packages/installer/lib/core/manifest.mjs +48 -57
  31. package/packages/installer/lib/core/validate-install.mjs +12 -19
  32. package/packages/installer/package.json +1 -1
  33. package/shared/tooling/qa-tool.mjs +125 -0
  34. package/skills/qa-api/SKILL.md +5 -5
  35. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  36. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  37. package/skills/qa-api/scripts/qa-tool.mjs +126 -0
  38. package/skills/qa-audit/SKILL.md +5 -5
  39. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  40. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  41. package/skills/qa-audit/scripts/qa-tool.mjs +126 -0
  42. package/skills/qa-debug/SKILL.md +6 -6
  43. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  44. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  45. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  46. package/skills/qa-debug/scripts/qa-tool.mjs +126 -0
  47. package/skills/qa-explore/SKILL.md +6 -6
  48. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  49. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  50. package/skills/qa-explore/references/report-pipeline.md +3 -3
  51. package/skills/qa-explore/scripts/qa-tool.mjs +126 -0
  52. package/skills/qa-fix/SKILL.md +4 -4
  53. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  54. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  55. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  56. package/skills/qa-fix/scripts/qa-tool.mjs +126 -0
  57. package/skills/qa-flaky/SKILL.md +4 -4
  58. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  59. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  60. package/skills/qa-flaky/scripts/qa-tool.mjs +126 -0
  61. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  62. package/skills/qa-init/SKILL.md +4 -4
  63. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  64. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  65. package/skills/qa-init/scripts/qa-tool.mjs +126 -0
  66. package/skills/qa-report/SKILL.md +7 -7
  67. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  68. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  69. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  70. package/skills/qa-report/scripts/qa-tool.mjs +126 -0
  71. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  72. package/skills/qa-run/SKILL.md +6 -6
  73. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  74. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  75. package/skills/qa-run/scripts/qa-tool.mjs +126 -0
  76. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  77. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  78. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  79. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  80. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  81. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  82. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  83. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  84. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  85. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  86. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  87. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  88. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  89. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  90. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  91. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  92. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  93. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  94. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  95. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  96. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  97. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  98. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  99. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  100. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  101. package/shared/tooling/qa_tool.py +0 -127
  102. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  103. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  104. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  105. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  106. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -1,128 +0,0 @@
1
- """The evidence and finding model shared by every analyzer.
2
-
3
- Every finding an analyzer produces carries the same structure, so downstream
4
- skills (qa-debug, qa-report, qa-fix) consume one shape regardless of which
5
- analyzer or framework produced it. Text fields are redacted at construction.
6
- """
7
-
8
- from dataclasses import dataclass, field, asdict
9
- from datetime import datetime, timezone
10
-
11
- from .redaction import redact_text
12
-
13
- EVIDENCE_TYPES = {
14
- "trace", "har", "junit", "report", "console", "network", "stdout",
15
- "stderr", "screenshot", "video", "log", "file", "diff",
16
- }
17
-
18
-
19
- def utc_now():
20
- """ISO 8601 UTC timestamp. Isolated so tests can monkeypatch it."""
21
- return datetime.now(timezone.utc).isoformat()
22
-
23
-
24
- @dataclass
25
- class Evidence:
26
- """One observation supporting a finding. Excerpts are redacted."""
27
- type: str
28
- description: str
29
- source: str
30
- excerpt: str = ""
31
-
32
- def __post_init__(self):
33
- if self.type not in EVIDENCE_TYPES:
34
- raise ValueError(f"unknown evidence type: {self.type}")
35
- self.excerpt = redact_text(self.excerpt)
36
-
37
- def to_dict(self):
38
- data = {"type": self.type, "description": self.description, "source": self.source}
39
- if self.excerpt:
40
- data["excerpt"] = self.excerpt
41
- return data
42
-
43
-
44
- @dataclass
45
- class Finding:
46
- """A single diagnostic conclusion, traceable to a specific artifact.
47
-
48
- Carries everything the evidence model requires: the artifact and location
49
- it came from, when, why, the supporting evidence, a calibrated confidence,
50
- the affected tests, related artifacts, and recommended actions.
51
- """
52
- classification: str
53
- reason: str
54
- artifact: str
55
- location: str
56
- confidence: float = None
57
- timestamp: str = field(default_factory=utc_now)
58
- evidence: list = field(default_factory=list)
59
- affected_tests: list = field(default_factory=list)
60
- related_artifacts: list = field(default_factory=list)
61
- recommendations: list = field(default_factory=list)
62
-
63
- def to_dict(self):
64
- data = {
65
- "classification": self.classification,
66
- "reason": self.reason,
67
- "artifact": self.artifact,
68
- "location": self.location,
69
- "timestamp": self.timestamp,
70
- "evidence": [e.to_dict() if isinstance(e, Evidence) else e for e in self.evidence],
71
- "affectedTests": self.affected_tests,
72
- "relatedArtifacts": self.related_artifacts,
73
- "recommendations": self.recommendations,
74
- }
75
- if self.confidence is not None:
76
- data["confidence"] = self.confidence
77
- return data
78
-
79
-
80
- @dataclass
81
- class AnalyzerOutput:
82
- """The envelope an analyzer emits: findings plus the artifacts it examined.
83
-
84
- A downstream skill wraps this in its own output contract; on its own it is
85
- the deterministic, machine-readable result of one analysis.
86
- """
87
- analyzer: str
88
- findings: list = field(default_factory=list)
89
- artifacts: list = field(default_factory=list)
90
- warnings: list = field(default_factory=list)
91
- generated_at: str = field(default_factory=utc_now)
92
-
93
- def to_dict(self):
94
- return {
95
- "analyzer": self.analyzer,
96
- "generatedAt": self.generated_at,
97
- "findings": [f.to_dict() if isinstance(f, Finding) else f for f in self.findings],
98
- "artifacts": [a.to_dict() if hasattr(a, "to_dict") else a for a in self.artifacts],
99
- "warnings": self.warnings,
100
- }
101
-
102
-
103
- @dataclass
104
- class Artifact:
105
- """A discovered artifact, in the common model shared with the execution engine."""
106
- type: str
107
- location: str
108
- framework: str = "unknown"
109
- ownership: str = "qa-analysis"
110
- timestamp: str = field(default_factory=utc_now)
111
- media_type: str = ""
112
- test_ref: str = ""
113
- present: bool = True
114
-
115
- def to_dict(self):
116
- data = {
117
- "type": self.type,
118
- "location": self.location,
119
- "framework": self.framework,
120
- "timestamp": self.timestamp,
121
- "ownership": self.ownership,
122
- "present": self.present,
123
- }
124
- if self.media_type:
125
- data["mediaType"] = self.media_type
126
- if self.test_ref:
127
- data["testRef"] = self.test_ref
128
- return data
@@ -1,87 +0,0 @@
1
- """HAR (HTTP Archive) parser.
2
-
3
- Framework-agnostic: a HAR is a standard JSON format, whoever produced it.
4
- Extracts request outcomes, flags failures and slow calls, and redacts headers
5
- and credentialed URLs before anything is exposed. A malformed HAR raises.
6
- """
7
-
8
- import json
9
-
10
- from .redaction import redact_text, redact_headers
11
- from .junit import MalformedArtifact
12
-
13
-
14
- def parse_har(path, slow_ms=1000):
15
- """Parse a HAR file into a redacted network summary.
16
-
17
- Returns {entries, failures, slow, redacted: True}, where each entry is
18
- {method, url, status, durationMs, headersRedacted}. Failures are entries
19
- with status >= 400 or status 0 (no response). Raises MalformedArtifact on
20
- unreadable input.
21
- """
22
- try:
23
- with open(path, "r", encoding="utf-8") as handle:
24
- data = json.load(handle)
25
- except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
26
- raise MalformedArtifact(f"could not parse HAR at {path}: {exc}") from exc
27
-
28
- try:
29
- raw_entries = data["log"]["entries"]
30
- except (KeyError, TypeError) as exc:
31
- raise MalformedArtifact(f"not a HAR document at {path}") from exc
32
-
33
- # The shape has to be checked, not assumed. `log.entries` being a *string*
34
- # satisfies the subscripting above, and iterating it then yields characters,
35
- # so the parser died on `'str' object has no attribute 'get'` — an
36
- # AttributeError, which the CLI does not catch, so the caller got a traceback
37
- # instead of the documented exit 2. Found by the Node port's parity gate.
38
- if not isinstance(raw_entries, list):
39
- raise MalformedArtifact(f"HAR log.entries is not a list at {path}")
40
-
41
- entries = []
42
- for item in raw_entries:
43
- if not isinstance(item, dict):
44
- raise MalformedArtifact(f"HAR log.entries contains a non-object at {path}")
45
- request = item.get("request") or {}
46
- response = item.get("response") or {}
47
- status = _int(response.get("status"))
48
- entry = {
49
- "method": request.get("method", ""),
50
- # Redaction strips any credentials embedded in the URL.
51
- "url": redact_text(request.get("url", "")),
52
- "status": status,
53
- "durationMs": _millis(item.get("time"), path),
54
- "requestHeaders": redact_headers(request.get("headers", [])),
55
- "responseHeaders": redact_headers(response.get("headers", [])),
56
- }
57
- entries.append(entry)
58
-
59
- failures = [e for e in entries if e["status"] == 0 or e["status"] >= 400]
60
- slow = [e for e in entries if e["durationMs"] >= slow_ms]
61
- return {"entries": entries, "failures": failures, "slow": slow, "redacted": True}
62
-
63
-
64
- def _int(value, default=0):
65
- try:
66
- return int(value)
67
- except (TypeError, ValueError):
68
- return default
69
-
70
-
71
- def _millis(raw, path):
72
- """HAR `time` is already milliseconds, rounded to a whole number.
73
-
74
- A value that is present but not a number raises rather than becoming 0 — the
75
- same rule as the JUnit parser, and for the same reason: a fabricated duration
76
- makes a malformed document look like a clean measurement. Before this, the
77
- bare `float()` escaped as a ValueError and reached the caller as a traceback.
78
- """
79
- if raw is None or raw == "":
80
- return 0
81
- try:
82
- value = float(raw)
83
- except (TypeError, ValueError):
84
- raise MalformedArtifact(f"entry time={raw!r} is not a number at {path}") from None
85
- if value != value or value in (float("inf"), float("-inf")):
86
- raise MalformedArtifact(f"entry time={raw!r} is not a finite number at {path}")
87
- return int(round(value))
@@ -1,104 +0,0 @@
1
- """JUnit XML parser.
2
-
3
- Framework-agnostic: Playwright, Selenium, Cypress, WebdriverIO, and most unit
4
- runners emit JUnit XML, so this one parser normalizes them all into the pack's
5
- per-test result shape. This is the concrete proof that different frameworks
6
- share one contract — only where the file lives differs, not how it is read.
7
-
8
- Parses deterministically; a malformed document raises rather than guessing.
9
- """
10
-
11
- import xml.etree.ElementTree as ET
12
-
13
- from .redaction import redact_text
14
-
15
-
16
- class MalformedArtifact(ValueError):
17
- """Raised when an artifact cannot be parsed. Never swallowed into a guess."""
18
-
19
-
20
- def _int(value, default=0):
21
- try:
22
- return int(value)
23
- except (TypeError, ValueError):
24
- return default
25
-
26
-
27
- def _duration_ms(raw, path):
28
- """`time` in seconds as whole milliseconds.
29
-
30
- An absent or empty attribute is zero — plenty of runners omit it. A value
31
- that is present but not a finite number means the document is not what it
32
- claims to be, so it raises: found by the Node port's parity corpus, where
33
- `time="not-a-number"` escaped as a bare ValueError and `time="nan"` as
34
- "cannot convert float NaN to integer". Both reached the caller as a traceback
35
- and exit 1, while the CLI's documented failure mode is exit 2 with
36
- {error, detail}.
37
- """
38
- if raw is None or raw == "":
39
- return 0
40
- try:
41
- seconds = float(raw)
42
- except (TypeError, ValueError):
43
- raise MalformedArtifact(
44
- f"testcase time={raw!r} is not a number at {path}"
45
- ) from None
46
- if seconds != seconds or seconds in (float("inf"), float("-inf")):
47
- raise MalformedArtifact(f"testcase time={raw!r} is not a finite number at {path}")
48
- return int(round(seconds * 1000))
49
-
50
-
51
- def parse_junit(path):
52
- """Parse a JUnit XML file into a normalized result.
53
-
54
- Returns a dict: {tests: {total, passed, failed, skipped}, executed: [...]}.
55
- Each executed entry: {title, file, status, durationMs, message?}.
56
- Raises MalformedArtifact if the XML is unreadable or not JUnit-shaped.
57
- """
58
- try:
59
- tree = ET.parse(path)
60
- except (ET.ParseError, OSError) as exc:
61
- raise MalformedArtifact(f"could not parse JUnit XML at {path}: {exc}") from exc
62
-
63
- root = tree.getroot()
64
- # Accept either a <testsuites> root or a single <testsuite> root.
65
- if root.tag == "testsuites":
66
- suites = root.findall("testsuite")
67
- elif root.tag == "testsuite":
68
- suites = [root]
69
- else:
70
- raise MalformedArtifact(f"not a JUnit document (root <{root.tag}>) at {path}")
71
-
72
- executed = []
73
- for suite in suites:
74
- for case in suite.findall("testcase"):
75
- failure = case.find("failure")
76
- error = case.find("error")
77
- skipped = case.find("skipped")
78
- if failure is not None or error is not None:
79
- status = "failed"
80
- node = failure if failure is not None else error
81
- message = redact_text((node.get("message") or node.text or "").strip())
82
- elif skipped is not None:
83
- status = "skipped"
84
- message = redact_text((skipped.get("message") or "").strip())
85
- else:
86
- status = "passed"
87
- message = ""
88
- entry = {
89
- "title": case.get("name", ""),
90
- "file": case.get("classname", ""),
91
- "status": status,
92
- "durationMs": _duration_ms(case.get("time"), path),
93
- }
94
- if message:
95
- entry["message"] = message
96
- executed.append(entry)
97
-
98
- counts = {
99
- "total": len(executed),
100
- "passed": sum(1 for e in executed if e["status"] == "passed"),
101
- "failed": sum(1 for e in executed if e["status"] == "failed"),
102
- "skipped": sum(1 for e in executed if e["status"] == "skipped"),
103
- }
104
- return {"tests": counts, "executed": executed}
@@ -1,107 +0,0 @@
1
- """Credential, secret, token, and PII redaction.
2
-
3
- Every analyzer runs artifact text through redaction before it appears in a
4
- finding, a report, or stdout. Redaction happens as evidence is captured, not
5
- after — a secret must never reach a model's context or a log. Standard library
6
- only; deterministic.
7
- """
8
-
9
- import re
10
-
11
- # Ordered (name, pattern, replacement). Order matters: match structured,
12
- # high-signal secrets (JWTs, provider keys) before generic fallbacks.
13
- _RULES = [
14
- ("jwt", re.compile(r"eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}")),
15
- ("aws-access-key", re.compile(r"AKIA[0-9A-Z]{16}")),
16
- ("github-token", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}")),
17
- ("slack-token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}")),
18
- ("openai-key", re.compile(r"sk-[A-Za-z0-9]{20,}")),
19
- ("bearer", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*")),
20
- # Sensitive header lines: keep the header name, mask the value.
21
- #
22
- # The value is `[^\r\n]+` rather than `.+$`, and the indent is `[ \t]*` rather
23
- # than `\s*`, for one reason: on CRLF text, `.+` swallows the carriage return
24
- # and `$` matches before the newline, so redacting a header silently rewrote
25
- # the line ending to LF and corrupted the rest of the document's endings.
26
- # Horizontal whitespace is also what a header indent actually is. Found by the
27
- # Node port's parity corpus, where the two languages disagreed here and
28
- # JavaScript was right.
29
- ("auth-header", re.compile(r"(?im)^([ \t]*(?:authorization|proxy-authorization)[ \t]*[:=][ \t]*)[^\r\n]+")),
30
- ("cookie-header", re.compile(r"(?im)^([ \t]*(?:set-cookie|cookie)[ \t]*[:=][ \t]*)[^\r\n]+")),
31
- # Secret-like assignments: key=value / "key": "value".
32
- ("assigned-secret", re.compile(
33
- r'(?i)(\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["\']?)'
34
- r'([^\s"\'&]{4,})')),
35
- # Credentials in URLs and query strings.
36
- ("url-credential", re.compile(r"(?i)(://[^:/@\s]+:)([^@/\s]+)(@)")),
37
- ("query-secret", re.compile(
38
- r"(?i)([?&](?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=)([^&\s#]+)")),
39
- # PII: email addresses.
40
- ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
41
- ]
42
-
43
- # Rules whose replacement preserves a leading captured group (the label/prefix).
44
- _PREFIX_PRESERVING = {
45
- "auth-header": r"\1[REDACTED:auth-header]",
46
- "cookie-header": r"\1[REDACTED:cookie-header]",
47
- "assigned-secret": r"\1[REDACTED:secret]",
48
- "url-credential": r"\1[REDACTED:credential]\3",
49
- "query-secret": r"\1[REDACTED:secret]",
50
- }
51
-
52
-
53
- def redact_text(text):
54
- """Return text with every recognized secret or PII value masked."""
55
- if not text:
56
- return text
57
- result = text
58
- for name, pattern in _RULES:
59
- if name in _PREFIX_PRESERVING:
60
- result = pattern.sub(_PREFIX_PRESERVING[name], result)
61
- else:
62
- result = pattern.sub(f"[REDACTED:{name}]", result)
63
- return result
64
-
65
-
66
- def detect_secrets(text):
67
- """Return a list of {type, start, end} for secrets found — never the values.
68
-
69
- Used to decide whether an artifact is safe to expose, without surfacing the
70
- secret itself.
71
- """
72
- findings = []
73
- if not text:
74
- return findings
75
- for name, pattern in _RULES:
76
- for match in pattern.finditer(text):
77
- findings.append({"type": name, "start": match.start(), "end": match.end()})
78
- findings.sort(key=lambda f: f["start"])
79
- return findings
80
-
81
-
82
- _SENSITIVE_HEADERS = {
83
- "authorization", "proxy-authorization", "cookie", "set-cookie",
84
- "x-api-key", "x-auth-token", "api-key", "x-csrf-token",
85
- }
86
-
87
-
88
- def redact_headers(headers):
89
- """Mask the values of sensitive HTTP headers.
90
-
91
- Accepts a dict, or a list of {"name","value"} entries (HAR shape), and
92
- returns the same shape with sensitive values replaced.
93
- """
94
- def mask(name, value):
95
- return "[REDACTED:header]" if name.strip().lower() in _SENSITIVE_HEADERS else redact_text(value)
96
-
97
- if isinstance(headers, dict):
98
- return {k: mask(k, str(v)) for k, v in headers.items()}
99
- if isinstance(headers, list):
100
- out = []
101
- for entry in headers:
102
- if isinstance(entry, dict) and "name" in entry:
103
- out.append({**entry, "value": mask(str(entry["name"]), str(entry.get("value", "")))})
104
- else:
105
- out.append(entry)
106
- return out
107
- return headers