|
| 1 | +"""What data an application likely holds, and what a breach of it would mean. |
| 2 | +
|
| 3 | +The dynamic scan sees a web site from the outside. The code behind it, and the |
| 4 | +tags people already put on those repositories, say what the application handles: |
| 5 | +customer records, personal data, payment cards, health data, financial reporting |
| 6 | +data under SOX, controlled unclassified information. That classification is the |
| 7 | +other half of risk. A weakness on an app that holds SOX or card data is a |
| 8 | +bigger breach than the same weakness on a brochure site, and the report should |
| 9 | +say so, in the real terms a public company cares about: fines, breach |
| 10 | +notification, reputational loss. |
| 11 | +
|
| 12 | +This module derives that profile from the repositories linked to a DAST asset, |
| 13 | +reusing the sensitivity labels, application name and repository name that the |
| 14 | +tagging already maintains, and turns it into an impact weight and a plain |
| 15 | +consequence. It is deterministic: a keyword read of tags that already exist, so |
| 16 | +the report can state the consequence as fact, not a guess dressed up as one. |
| 17 | +
|
| 18 | +Even a mailing address counts: it is personal data, and in a federal context it |
| 19 | +can be controlled unclassified information. So the classes run from the plainly |
| 20 | +regulated down to ordinary customer data, and ordinary customer data still |
| 21 | +matters. |
| 22 | +""" |
| 23 | + |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import re |
| 27 | + |
| 28 | +# Each class: a label, how much it raises the impact of a breach (0 to 2), and |
| 29 | +# the plain consequence a reader should understand. Ordered most serious first, |
| 30 | +# which is the order a dominant class is picked in. |
| 31 | +CLASSES = { |
| 32 | + "sox": { |
| 33 | + "label": "SOX financial reporting data", |
| 34 | + "bump": 2, |
| 35 | + "consequence": "For a publicly traded company a breach here can mean " |
| 36 | + "regulatory fines and a failure of financial reporting " |
| 37 | + "controls.", |
| 38 | + }, |
| 39 | + "pci": { |
| 40 | + "label": "payment card data", |
| 41 | + "bump": 2, |
| 42 | + "consequence": "Exposure can bring payment card brand penalties and the " |
| 43 | + "loss of the ability to process cards.", |
| 44 | + }, |
| 45 | + "phi": { |
| 46 | + "label": "health information", |
| 47 | + "bump": 2, |
| 48 | + "consequence": "Exposure can bring HIPAA penalties and mandatory breach " |
| 49 | + "notification.", |
| 50 | + }, |
| 51 | + "cui": { |
| 52 | + "label": "controlled unclassified information", |
| 53 | + "bump": 2, |
| 54 | + "consequence": "Exposure can breach federal handling rules for controlled " |
| 55 | + "unclassified information; even a mailing address can be CUI.", |
| 56 | + }, |
| 57 | + "spii": { |
| 58 | + "label": "sensitive personal data", |
| 59 | + "bump": 2, |
| 60 | + "consequence": "Exposure of data such as Social Security or financial " |
| 61 | + "numbers triggers breach notification laws and identity " |
| 62 | + "theft risk.", |
| 63 | + }, |
| 64 | + "pii": { |
| 65 | + "label": "personal data", |
| 66 | + "bump": 1, |
| 67 | + "consequence": "Exposure triggers privacy law obligations and direct harm " |
| 68 | + "to the people whose data it is.", |
| 69 | + }, |
| 70 | + "customer": { |
| 71 | + "label": "customer data", |
| 72 | + "bump": 1, |
| 73 | + "consequence": "A breach exposes customers and brings reputational loss.", |
| 74 | + }, |
| 75 | + "credentials": { |
| 76 | + "label": "credentials or secrets", |
| 77 | + "bump": 1, |
| 78 | + "consequence": "Exposed credentials let an attacker reach whatever they " |
| 79 | + "unlock.", |
| 80 | + }, |
| 81 | +} |
| 82 | + |
| 83 | +ORDER = ["sox", "pci", "phi", "cui", "spii", "pii", "customer", "credentials"] |
| 84 | + |
| 85 | +# Keywords that place a tag, application name or repository name into a class. |
| 86 | +# Read case insensitively over text people already wrote, so it is a floor, not |
| 87 | +# a guess: a match means the word is there. |
| 88 | +_KEYWORDS = { |
| 89 | + "sox": r"\bsox\b|sarbanes|financial report|general ledger|\bg/?l\b|\berp\b|" |
| 90 | + r"e-?business suite|\bebs\b|revenue recognition|accounts (payable|receivable)|" |
| 91 | + r"\bledger\b|audit trail", |
| 92 | + "pci": r"\bpci\b|cardholder|credit.?card|payment card|\bpan\b|card number|" |
| 93 | + r"card data", |
| 94 | + "phi": r"\bphi\b|hipaa|patient|health record|\behr\b|\bemr\b|medical record", |
| 95 | + "cui": r"\bcui\b|controlled unclassified|\bitar\b|export control|\bfouo\b|dfars|" |
| 96 | + r"\bcmmc\b", |
| 97 | + "spii": r"\bspii\b|social security|\bssn\b|passport|driver.?s?.? ?licen|biometric|" |
| 98 | + r"bank account|routing number|\btax id\b", |
| 99 | + "pii": r"\bpii\b|personal data|personally identifiable|mailing address|" |
| 100 | + r"\baddress(es)?\b|date of birth|\bdob\b|phone number|email address", |
| 101 | + "customer": r"customer|client\b|subscriber|account holder|\bmember\b|consumer|" |
| 102 | + r"\bcrm\b", |
| 103 | + "credentials": r"credential|password|\bsecret(s)?\b|api.?key|private key|token", |
| 104 | +} |
| 105 | +_COMPILED = {cls: re.compile(pat, re.I) for cls, pat in _KEYWORDS.items()} |
| 106 | + |
| 107 | + |
| 108 | +def classify(text: str) -> set: |
| 109 | + """The data classes a blob of text points to. A keyword read, nothing more.""" |
| 110 | + t = str(text or "") |
| 111 | + return {cls for cls, pat in _COMPILED.items() if pat.search(t)} |
| 112 | + |
| 113 | + |
| 114 | +def rank(classes) -> list: |
| 115 | + """The classes present, most serious first.""" |
| 116 | + present = set(classes or []) |
| 117 | + return [c for c in ORDER if c in present] |
| 118 | + |
| 119 | + |
| 120 | +def impact_bump(classes) -> tuple: |
| 121 | + """How much these classes raise the impact of a breach, and why. |
| 122 | +
|
| 123 | + The bump is the single most serious class, not a sum, so a known class lifts |
| 124 | + the impact once; it does not stack an app up to the ceiling on its own.""" |
| 125 | + ranked = rank(classes) |
| 126 | + if not ranked: |
| 127 | + return 0, "" |
| 128 | + bump = max(CLASSES[c]["bump"] for c in ranked) |
| 129 | + labels = ", ".join(CLASSES[c]["label"] for c in ranked) |
| 130 | + return bump, f"the application holds {labels}" |
| 131 | + |
| 132 | + |
| 133 | +def consequence(classes) -> str: |
| 134 | + """One plain statement of what a breach of this data would mean.""" |
| 135 | + ranked = rank(classes) |
| 136 | + if not ranked: |
| 137 | + return "" |
| 138 | + out = CLASSES[ranked[0]]["consequence"] |
| 139 | + extra = [CLASSES[c]["label"] for c in ranked[1:]] |
| 140 | + if extra: |
| 141 | + out += " The application also appears to hold " + ", ".join(extra) + "." |
| 142 | + return out |
| 143 | + |
| 144 | + |
| 145 | +def labels(classes) -> list: |
| 146 | + """Human labels for the classes, most serious first.""" |
| 147 | + return [CLASSES[c]["label"] for c in rank(classes)] |
| 148 | + |
| 149 | + |
| 150 | +def _linked_repos(db, asset): |
| 151 | + from app.core.models import ExternalAssetRepo, Repository |
| 152 | + |
| 153 | + return (db.query(Repository) |
| 154 | + .join(ExternalAssetRepo, ExternalAssetRepo.repository_id == Repository.id) |
| 155 | + .filter(ExternalAssetRepo.asset_id == asset.id).all()) |
| 156 | + |
| 157 | + |
| 158 | +def profile_for_asset(db, asset) -> dict: |
| 159 | + """Derive the data profile of a DAST asset from the code linked to it. |
| 160 | +
|
| 161 | + -> {classes: [...], labels: [...], consequence: "...", bump: int, |
| 162 | + basis: "...", from_repos: [...]}. Empty classes when the linked |
| 163 | + repositories carry nothing that places them in a class, so nothing is |
| 164 | + invented: a profile only appears when a tag, an application name or a |
| 165 | + repository name actually says so. |
| 166 | + """ |
| 167 | + classes, from_repos = set(), [] |
| 168 | + for r in _linked_repos(db, asset): |
| 169 | + text = " ".join([r.sensitivity or "", r.application_name or "", r.name or ""]) |
| 170 | + found = classify(text) |
| 171 | + if found: |
| 172 | + classes |= found |
| 173 | + from_repos.append(r.full_name) |
| 174 | + ranked = rank(classes) |
| 175 | + bump, why = impact_bump(ranked) |
| 176 | + basis = "" |
| 177 | + if ranked: |
| 178 | + basis = ("From the code behind this site: " + ", ".join(labels(ranked)) |
| 179 | + + (f" (seen in {', '.join(from_repos[:6])}" |
| 180 | + + (" and more" if len(from_repos) > 6 else "") + ")" |
| 181 | + if from_repos else "") + ".") |
| 182 | + return {"classes": ranked, "labels": labels(ranked), |
| 183 | + "consequence": consequence(ranked), "bump": bump, |
| 184 | + "basis": basis, "from_repos": from_repos} |
0 commit comments