Merge pull request 'feat(reports): add no-email identity summary and disclosure validator (closes #305)' (#329) from feat/issue-305-report-email-redaction into master
This commit was merged in pull request #329.
This commit is contained in:
@@ -2610,3 +2610,84 @@ def build_review_mutation_proof(run_log: list[dict]) -> dict:
|
|||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Identity disclosure (#305)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EMAIL_ADDRESS_RE = re.compile(
|
||||||
|
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||||
|
|
||||||
|
EMAIL_JUSTIFICATION_MARKERS = (
|
||||||
|
"email required",
|
||||||
|
"email is required",
|
||||||
|
"email necessary",
|
||||||
|
"necessary to disambiguate",
|
||||||
|
"disambiguate identity",
|
||||||
|
"tool requires the email",
|
||||||
|
"user explicitly asked",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_identity_summary(username, profile, role=None, remote=None):
|
||||||
|
"""Return the no-email identity line for workflow reports (#305).
|
||||||
|
|
||||||
|
Standard reports identify actors as ``<username> / <profile>`` (plus
|
||||||
|
optional role/remote). Personal email is never part of the summary; if
|
||||||
|
an email lands in the username slot, only its local part is kept.
|
||||||
|
"""
|
||||||
|
name = (username or "").strip()
|
||||||
|
if "@" in name:
|
||||||
|
name = name.split("@", 1)[0]
|
||||||
|
summary = f"{name} / {(profile or '').strip()}"
|
||||||
|
extras = [str(part).strip() for part in (role, remote)
|
||||||
|
if part and str(part).strip()]
|
||||||
|
if extras:
|
||||||
|
summary += f" ({', '.join(extras)})"
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def assess_email_disclosure(
|
||||||
|
report_text,
|
||||||
|
*,
|
||||||
|
justification_markers=EMAIL_JUSTIFICATION_MARKERS,
|
||||||
|
):
|
||||||
|
"""Flag unnecessary personal-email disclosure in a report (#305).
|
||||||
|
|
||||||
|
Username/profile identity is sufficient for normal workflow reports.
|
||||||
|
An email address is tolerated only when the report itself explains why
|
||||||
|
it is necessary (tool proof, explicit request, or disambiguation).
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
emails = sorted(set(EMAIL_ADDRESS_RE.findall(text)))
|
||||||
|
if not emails:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"flagged": False,
|
||||||
|
"justified": False,
|
||||||
|
"emails": [],
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
lower = text.lower()
|
||||||
|
justified = any(marker in lower for marker in justification_markers)
|
||||||
|
if justified:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"flagged": False,
|
||||||
|
"justified": True,
|
||||||
|
"emails": emails,
|
||||||
|
"reasons": [
|
||||||
|
"email disclosure present but justified in the report",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"flagged": True,
|
||||||
|
"justified": False,
|
||||||
|
"emails": emails,
|
||||||
|
"reasons": [
|
||||||
|
f"unnecessary personal email disclosure: {email}"
|
||||||
|
for email in emails
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Identity-disclosure checks for workflow reports (#305).
|
||||||
|
|
||||||
|
Workflow reports sometimes included the authenticated user's personal
|
||||||
|
email even though username/profile identity is sufficient (observed:
|
||||||
|
``Identity: jcwalker3 / [email protected]`` in a reconciliation
|
||||||
|
handoff). These tests pin the no-email identity summary helper and the
|
||||||
|
final-report validator that flags unnecessary email disclosure.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import review_proofs
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdentitySummary(unittest.TestCase):
|
||||||
|
def test_author_summary_uses_username_and_profile_only(self):
|
||||||
|
summary = review_proofs.format_identity_summary(
|
||||||
|
"jcwalker3", "prgs-author")
|
||||||
|
self.assertEqual(summary, "jcwalker3 / prgs-author")
|
||||||
|
self.assertNotIn("@", summary)
|
||||||
|
|
||||||
|
def test_reviewer_summary_uses_username_and_profile_only(self):
|
||||||
|
summary = review_proofs.format_identity_summary(
|
||||||
|
"sysadmin", "prgs-reviewer")
|
||||||
|
self.assertEqual(summary, "sysadmin / prgs-reviewer")
|
||||||
|
|
||||||
|
def test_summary_appends_role_and_remote_without_email(self):
|
||||||
|
summary = review_proofs.format_identity_summary(
|
||||||
|
"jcwalker3", "prgs-author", role="author", remote="prgs")
|
||||||
|
self.assertIn("jcwalker3 / prgs-author", summary)
|
||||||
|
self.assertIn("author", summary)
|
||||||
|
self.assertIn("prgs", summary)
|
||||||
|
self.assertNotIn("@", summary)
|
||||||
|
|
||||||
|
def test_summary_never_leaks_email_passed_as_username(self):
|
||||||
|
"""Defense in depth: an email in the username slot is reduced."""
|
||||||
|
summary = review_proofs.format_identity_summary(
|
||||||
|
"[email protected]", "prgs-author")
|
||||||
|
self.assertNotIn("@", summary)
|
||||||
|
self.assertIn("jcwalker3", summary)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailDisclosureAssessment(unittest.TestCase):
|
||||||
|
def test_report_without_email_passes(self):
|
||||||
|
report = "\n".join([
|
||||||
|
"Controller Handoff",
|
||||||
|
"Identity: jcwalker3 / prgs-author",
|
||||||
|
"Role: author",
|
||||||
|
])
|
||||||
|
res = review_proofs.assess_email_disclosure(report)
|
||||||
|
self.assertTrue(res["proven"])
|
||||||
|
self.assertFalse(res["flagged"])
|
||||||
|
self.assertEqual(res["emails"], [])
|
||||||
|
self.assertEqual(res["reasons"], [])
|
||||||
|
|
||||||
|
def test_unnecessary_email_is_flagged(self):
|
||||||
|
report = "\n".join([
|
||||||
|
"Controller Handoff",
|
||||||
|
"Identity: jcwalker3 / [email protected]",
|
||||||
|
])
|
||||||
|
res = review_proofs.assess_email_disclosure(report)
|
||||||
|
self.assertFalse(res["proven"])
|
||||||
|
self.assertTrue(res["flagged"])
|
||||||
|
self.assertIn("[email protected]", res["emails"])
|
||||||
|
self.assertTrue(res["reasons"])
|
||||||
|
self.assertFalse(res["justified"])
|
||||||
|
|
||||||
|
def test_multiple_emails_all_reported(self):
|
||||||
|
report = (
|
||||||
|
"Identity: [email protected] author\n"
|
||||||
|
"Reviewer: [email protected]\n"
|
||||||
|
)
|
||||||
|
res = review_proofs.assess_email_disclosure(report)
|
||||||
|
self.assertTrue(res["flagged"])
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(res["emails"]),
|
||||||
|
["[email protected]", "[email protected]"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_justified_email_with_explanation_is_not_flagged(self):
|
||||||
|
report = "\n".join([
|
||||||
|
"Identity: jcwalker3 / prgs-author",
|
||||||
|
"Contact email: [email protected]",
|
||||||
|
"Email required because two accounts share the username and the",
|
||||||
|
"address is necessary to disambiguate identity.",
|
||||||
|
])
|
||||||
|
res = review_proofs.assess_email_disclosure(report)
|
||||||
|
self.assertFalse(res["flagged"])
|
||||||
|
self.assertTrue(res["justified"])
|
||||||
|
self.assertIn("[email protected]", res["emails"])
|
||||||
|
|
||||||
|
def test_email_without_justification_language_is_flagged(self):
|
||||||
|
report = "Contact email: [email protected] just in case.\n"
|
||||||
|
res = review_proofs.assess_email_disclosure(report)
|
||||||
|
self.assertTrue(res["flagged"])
|
||||||
|
self.assertFalse(res["justified"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user