Companion skill:
osint-methodology(the "how to think" recon skill — see its §9 severity rubric and §16 client deliverable templates). This skill is the "how to quantify and present" layer on top of a finished recon pass: it takes findings the methodology skill's pipeline already produced and turns them into a number a board will act on.
Use this skill when: you have a completed set of recon findings (from any engagement, not just one tool's output) and need to (a) compute a defensible 0–100 + A–F risk score, (b) estimate a $-denominated loss range, (c) rank attack-path chains by exploitability, or (d) assemble a board/exec one-pager. Also use it to explain a score — "why did this grade come out D and not F" is exactly what §7 is for.
Do NOT use this skill when: you still need to go collect findings — that's
osint-methodology (methodology) / offensive-osint (arsenal). This skill does not probe
anything; it has nothing to say until a recon pass has already produced findings, assets,
and (ideally) ownership/proof annotations.
Every computation in this skill is a pure function over findings + assets you already
hold — no network calls, no new probes, no target traffic. The reference implementation
(reporting/{risk_score,loss_model,board_report,board_render,attack_paths, attack_graph,owner_confidence,proof}.py) is explicit about this: risk_score.py docstring
calls itself "Pure compute over scan.db — no network, no schema change"; loss_model.py
calls itself "Pure, no network"; attack_graph.py calls itself "Pure + offline."
That means this skill inherits the authorization posture of whatever collected the inputs
(see osint-methodology §1) but adds none of its own — quantifying findings you already
lawfully hold is never itself an intrusive act. It also means the outputs are only as good
as the inputs: garbage findings (unowned namesakes, unverified snippet matches) produce a
garbage score unless you apply the demotion cap in §7.5 first.
Reuses osint-methodology §2 verbatim — every finding you're about to score already
carries one of:
| Level | Meaning |
|---|---|
| TENTATIVE | Plausible, unverified. |
| FIRM | Directly observed, uncorroborated. |
| CONFIRMED | Multiple independent corroborations OR directly verified. |
What this skill adds is a second, orthogonal axis — ownership certainty — and the rule for how the two combine so neither one alone can inflate a number (§7.5).
Three artifacts, each a pure dataclass with a to_dict():
OrgRisk:
risk: float # 0-100 headline
grade: str # A|B|C|D|F
exposure, breach_likelihood, impact: float # the three factors x100
dominant_driver: str # "exposure" | "breach-likelihood" | "business-impact"
narrative: str # 2-3 sentence board-language explanation
LossEstimate:
records: int
low, expected, high: float # $ range
annualized: float | None
basis: str # human-readable "N records x $X/record (source)" string
BoardSummary:
target, hero (HeroNumbers), top_findings[], top_paths[], the_ask[], methodology, narrative
Rule: every number ships with its basis. LossEstimate.basis and OrgRisk.narrative
are not optional decoration — never present the bare risk float or expected dollar
figure without the sentence that explains what drove it. §4 and §12 make this a hard rule,
not a style preference.
For every quantified number, disclose:
expected) without the range (low–high).risk_trend (§10.1) is a valid
time-series comparison.FAIR (Factor Analysis of Information Risk) decomposes risk into:
Risk = Loss Event Frequency (LEF) × Loss Magnitude (LM)
External recon cannot observe LEF or LM directly — no one is handing you an actuarial table for this specific target. What recon can do is produce calibrated proxies for both halves, and that's what this skill's three-factor model does:
| FAIR concept | Recon proxy | Computed by |
|---|---|---|
| Loss Event Frequency (probability a bad event happens) | Likelihood = combine(Exposure, Threat) | §7.1–§7.2 |
| — breadth/severity of what's exposed | Exposure (E) | attack-surface findings, severity-weighted |
| — how actively that exposure is being targeted | Threat (T) | KEV/EPSS, leaked creds, adversary chatter, proven exploits, attack-path chains |
| Loss Magnitude (cost if it happens) | Impact (I) | §7.3 — worst-case, ownership-gated business value of what's exposed |
| Loss Magnitude, in dollars | loss_model.estimate() | §8 — exposed-record count × per-record cost band |
Keep it honest: a 0–100 score and a $ range are likelihood and magnitude inputs, not a claim that a breach has happened, will happen, or costs exactly that much. Every FAIR practitioner treats the output as a decision-support number with stated assumptions — this skill enforces that discipline structurally (§4, §12) rather than leaving it to the writer's memory.
This skill deepens osint-methodology §9 (severity rubric — CRITICAL/HIGH/MEDIUM/LOW/
INFO anchors) by rolling those per-finding severities into one org-level number, and §16
(client deliverable templates — exec summary, risk translation table, reporting cadence) by
adding the $ figure and the board one-pager layout those templates gesture at but don't
compute.
Source: reporting/risk_score.py. risk = round(likelihood × Impact × 100, 1), where
likelihood = combine(Exposure, Threat).
The independent-evidence combiner, used everywhere two-or-more probabilistic signals need to merge without double-counting:
combine(weights) = 1 - Π(1 - w_i) for each w_i clamped to [0, 1]; empty list -> 0
This is the same shape used for OR-of-independent-events probability — one strong signal dominates, several weak signals still add up, and nothing can exceed 1.0.
Sum a severity-weighted load S over every finding in the scan (this term is not
gated by confidence or ownership — see the sharp edge called out in §12), then saturate:
| Severity | Weight |
|---|---|
| critical | 1.0 |
| high | 0.4 |
| medium | 0.1 |
| low | 0.02 |
| info | 0.0 |
S = sum(severity_weight[f.severity] for f in findings)
E = min(1 - e^(-S / 25), 1 - 1e-15) # K=25: calibration constant
K = 25 is chosen so a couple of findings reads low and a broad/severe surface approaches
1 — deliberately not an independent-evidence combine (which would saturate to ~1.0 on a
single critical and give zero discrimination between a 2-critical and a 200-critical
target).
The proven-critical floor: if the scan carries an owned (owner_confidence ≥ 70)
finding that is severity CRITICAL and either confidence == "confirmed" or is_proven
(§7.5), E = max(E, 0.5). A proven critical on an asset you own is material risk — this
floor is the mechanism that stops such a target from grading A ("strong posture") purely
because it has few other findings.
A weighted list, combined via combine(). Each condition below appends a weight if true;
absent conditions contribute nothing (not zero-weight — simply omitted from the list):
| Signal | Weight | Gate |
|---|---|---|
| Proven exploit on an owned host, OR an owned CONFIRMED/proven critical (same predicate as the §7.1 floor) | 0.90 | owner_confidence ≥ 70 |
CISA KEV match (cve asset with attrs.kev, OR finding evidence kev_cves) |
0.90 | KEV-asset: unconditional; finding-evidence: owned host only |
| Max EPSS score across CVEs | min(1, epss_max) × 0.7 |
same split as KEV |
Active-sale (a market-escalation finding with evidence.for_sale) |
0.90 | unconditional |
| Held leaked credentials for the org (elif no active-sale) | 0.80 | credential asset: unconditional; secret asset: owned host only |
| Ransomware / adversary chatter listing, severity HIGH/CRITICAL | 0.85 | unconditional |
| An attack-path chain (§9) scoring ≥ 85 present anywhere in the scan | 0.60 | binary — see §9.4, this is NOT proportional to the chain's exact score |
T = combine(weights)
Impact is about the single most valuable owned asset that's exposed, not an average — a target with 500 low-value assets and one crown-jewel exposure scores on the crown jewel.
For every asset with owner_confidence > 0 (un-owned and typosquat-domain assets
contribute ≈0):
asset_weight =
0.1 if type == typosquat_domain
1.0 if type in {credential, secret, postman_api_key, firebase_project}
0.9 if any finding on this asset has category in
{exposed_admin_panel, ssrf, request_smuggling, host_header_injection,
xss, authentication_bypass}
0.9 if hostname contains a payment token: pay, payment, billing, checkout, wallet
0.85 if hostname contains an internal token: jenkins, gitlab, grafana, kibana,
vault, jira, confluence, vpn, internal, staging, admin, sso, idp,
okta, keycloak, adfs
0.7 if type == api_endpoint AND attrs.auth_required is False
0.4 if type in {webapp, subdomain, ip, api_endpoint, domain, service}
0.1 otherwise
I = max( asset_weight × (owner_confidence / 100) ) over all qualifying assets
Tokens match whole hostname segments split on non-alphanumerics — payroll does not
match the pay token; pay.ex.com and internal-api.ex.com do match.
likelihood = combine([E, T])
risk = round(likelihood × I × 100, 1)
| Risk | Grade |
|---|---|
| ≤ 20 | A |
| ≤ 40 | B |
| ≤ 60 | C |
| ≤ 80 | D |
| > 80 | F |
Impact is a multiplicative gate, not an additive term. A target with high Exposure and
Threat but no confidently-owned, valuable asset (I < 0.2) gets capped regardless — the
dominant driver reported is "business-impact" with the narrative explaining that few
high-value assets are confidently owned. Otherwise the dominant driver is whichever of
Exposure/Threat is larger.
Two independent gates prevent a number from being inflated by evidence that isn't solid — know which one is doing the work on a given finding.
Gate 1 — ownership caps confidence (reporting/owner_confidence.py). Every asset has a
categorical owner_tier (model.attribution.tier_of(), banded from the same underlying
0–100 attribution score risk_score gates on numerically, but on a different threshold
scheme):
| Attribution score | owner_tier |
|---|---|
| ≤ 0 | NONE |
| < 40 | WEAK |
| < 70 | MODERATE |
| < 90 | STRONG |
| ≥ 90 | CONFIRMED |
| owner_tier | Confidence ceiling |
|---|---|
| CONFIRMED / STRONG | none — finding's confidence is untouched |
| MODERATE | capped at FIRM |
| WEAK / NONE | capped at TENTATIVE |
This is demote-only — it never raises a finding's stated confidence, and it never
demotes a finding whose evidence is is_proven (Gate 2 below) below what the proof earned.
Gate 2 — proof survives ownership uncertainty (reporting/proof.py). A finding is
is_proven when its evidence carries one of these markers, each stamped by a specific
active confirmer:
| Marker | Stamped by |
|---|---|
evidence.proven == True |
explicit opt-in (cloud_buckets / validate_cloud) |
evidence.version_confirmed == True |
cve_confirm (graph-recovered running version) |
evidence.working_credential (truthy) |
validate_creds (logged in with the default) |
evidence.liveness_confirmed == True |
validate_sso (live tenant) |
evidence.verified == True |
github_recon trufflehog (verified live secret) |
evidence.status == "verified_live" |
live read-only secret validation |
evidence.validation.status == "verified_live" |
nested form of the same |
A proven finding's confidence is exempt from the ownership-tier ceiling — the ownership
context is still recorded (evidence.owner_tier) but never used to erase a confirmation.
This is why _has_owned_proven_critical (the shared predicate behind §7.1's floor and
§7.2's 0.90 Threat weight) checks confidence == "confirmed" OR is_proven(evidence) — proof
is a second, independent path to "this counts," not merely a synonym for CONFIRMED.
Write-time complement (assign_confidence, also in proof.py): any finding from an
active-heuristic module (host_header, request_smuggling, bypass403, badsecrets,
paramminer, nuclei_mod) that is not yet proven is written as TENTATIVE at scan time,
regardless of what the module's internal logic thought — the confidence tier itself only
gets promoted to CONFIRMED by proof, never by module self-assessment.
The sharp edge this does NOT cover: §7.1's Exposure severity sum S runs over every
finding regardless of confidence or ownership — only the proven-critical floor is
gated. Three TENTATIVE findings on a barely-owned domain still add their raw severity
weight to S. See the self-test trap in §13 and the guardrail in §12.
Source: reporting/loss_model.py. Turns exposed-record counts already carried in the
graph into a defensible dollar range — pure, no network, no fabricated numbers.
DEFAULT_COST = {"low": 150.0, "expected": 165.0, "high": 200.0} # USD / exposed record
Basis: IBM/Ponemon Cost of a Data Breach (~$165/record expected). Region- or
industry-tunable via the cost argument — always state which band you used (§4).
Only leaked_credential (Category.LEAKED_CRED) findings count records, from the
employees + users evidence keys (_RECORD_KEYS) — these are disjoint populations
(compromised staff vs. compromised customers who reused a password), so they're summed
within a source.
Multiple breach sources (hudsonrock, local_corpus, HIBP, …) each emit their own domain-stats finding for the same org — blindly summing across sources would double-count the population. Instead:
for each LEAKED_CRED finding:
n = employees + users (non-numeric / bool / missing -> 0, never guessed)
by_source[evidence.source or unique-per-finding-key] += n
records = max(by_source.values(), default=0) # MAX across sources, never summed
The largest single authoritative source is a defensible floor, never an inflated
cross-source sum. records = 0 (no LEAKED_CRED findings, or all zero) → $0, never an
error and never a fabricated number.
low = records × cost["low"]
expected = records × cost["expected"]
high = records × cost["high"]
annualized = expected × (breach_likelihood / 100) if breach_likelihood is given, else None
basis = f"{records:,} exposed credential record(s) x ${cost['expected']:.0f}/record (IBM/Ponemon breach cost)"
Critical nuance, easy to misstate: when the board-report caller invokes this, it passes
breach_likelihood = org_risk["breach_likelihood"] — that is the Threat factor T from
§7.2, scaled to 0–100. It is not the composite risk score from §7.4 (which already
folds in Impact). Annualizing against the wrong number silently mixes two different
probability estimates. Always ask "which likelihood?" before quoting an ALE.
A scan produces: one owned (owner_confidence = 95, tier CONFIRMED), CONFIRMED-confidence
exposed_admin_panel finding; two breach-corpus hits for the org — hudsonrock
(employees=180, users=40) and local_corpus (employees=95); a credential asset for the
same root domain; and the admin_panel+leaked_cred attack-path chain fires (§9.1, base
score 90). No KEV/EPSS data.
Loss:
by_source = {"hudsonrock": 180+40=220, "local_corpus": 95}
records = max(220, 95) = 220
low = 220 x 150 = $33,000
expected = 220 x 165 = $36,300
high = 220 x 200 = $44,000
Risk score, step by step:
| Step | Value | Why |
|---|---|---|
| S (severity load) | 1.0 | one critical finding |
| E raw | 1 − e^(−1/25) ≈ 0.039 | before the floor |
| Proven-critical floor | E = 0.5 | owned + confirmed critical → floor triggers (§7.1) |
| T weights | [0.90, 0.80, 0.60] | proven-owned-critical (0.90) + held leaked creds (0.80) + a ≥85-scoring attack-path chain present (0.60, §9.4) |
| T = combine(…) | 1 − (0.10)(0.20)(0.40) = 0.992 | → breach_likelihood = 99.2 |
| I | 0.9 × (95/100) = 0.855 | exposed_admin_panel category weight × owner_confidence |
| likelihood | combine([0.5, 0.992]) = 1 − (0.5)(0.008) = 0.996 | |
| risk | round(0.996 × 0.855 × 100, 1) ≈ 85.2 | |
| grade | F (> 80) |
Annualized loss: $36,300 × 0.992 ≈ $36,010 — annualized against T (99.2%), not
the composite risk (85.2). Both numbers are high here, but they are not the same number and
must not be swapped in a sentence.
On the board hero (§10.1): records_at_risk = 220 > 0, so the hero shows the $ band +
records, not the confirmed-criticals fallback (that fallback exists for the other case —
see §10.1).
Findings in isolation understate risk: "admin panel is exposed" and "we hold a leaked
password for this org" read as two low-drama lines. Chained, they're a single-step win any
red-teamer would take first. reporting/attack_paths.py (curated rules) and
reporting/attack_graph.py (generic graph-walk) both emit the same AttackPath dataclass
(rule_id, title, severity, score 0–100, narrative, signals, playbook, references) so the
UI renders both without a branch.
| Rule | Fires on | Severity | Base score |
|---|---|---|---|
admin_panel+leaked_cred |
exposed admin panel + credential for the same root domain | critical | 90 |
leaked_cred+identity_portal |
leaked creds + an OWNED internal/VPN/identity host (no explicit panel finding needed) | critical | 84 |
infostealer_machine+sso |
infostealer-machine finding carrying computer_name + sso_tenant evidence |
critical | 86 |
takeover |
subdomain-takeover candidate | high | 80 |
secret_leak |
secret-leak finding (score bumps with co-discovered GitHub repos) | high/medium | 75 (with repos) / 60 |
weak_email_auth[+typosquat] |
SPF/DMARC misconfiguration, worse if active typosquats exist | high/medium | 72 (+typosquat) / 50 |
expiry_chain |
any finding whose title mentions "expired"/"expires in" | worst hit's severity | 65 |
axfr_open |
AXFR zone-transfer allowed | high | 78 |
chatter_ransomware[+exposures] |
adversary chatter listing, severity HIGH/CRIT, grouped by group name | critical/high | 95 (+other HIGH/CRIT findings) / 82 |
dork_exposure[+credential] |
dorking-promoted high-gravity exposure (.env, .git, DB dump, private key, cloud bucket, Firebase RTDB, wp-config) |
critical/high | 92 (+creds) / 78 |
public_bucket |
genuinely public/listable buckets (excludes 403/unverified — the FP class already fixed once) | high | 70 |
intrusive_web:{ssrf,request_smuggling,host_header_injection,xss,authentication_bypass} |
stage-5 --validate active-web findings, one chain per (category, host) |
critical (confirmed) / high (unconfirmed) | 92 / 74 |
Every curated score is then multiplied by an exploitability multiplier pulled from any KEV/EPSS evidence on the chain's signals: KEV present → ×1.5; EPSS-only → ×(1 + max_epss); neither → ×1.0. Capped at 100.
Bounded BFS from every HIGH/CRITICAL finding ("anchor") over weighted pivot edges, stopping at the first "endpoint" reached (a MEDIUM+ finding node, or a "jewel" — a secret/credential/ cloud_account asset, an exposed-admin-panel finding, an ownership-verified public bucket, or an owned internal-token host). Max depth 3; never traverses through a hub node (pivot degree > 20, though a hub can still be an endpoint).
| Edge type | Weight | Kind |
|---|---|---|
| CONTAINS_SECRET | 1.0 | semantic (capability) |
| CONTAINS_CREDENTIAL | 1.0 | semantic |
| SECRET_GRANTS_ACCESS_TO | 1.0 | semantic |
| CRED_VALID_ON | 0.9 | semantic |
| OWNED_BY_ORG / OWNED_BY | 0.9 | semantic |
| BREACHED_FROM | 0.9 | semantic |
| EXPOSES | 0.85 | semantic |
| RESOLVES_TO | 0.7 | co-location |
| HOSTED_ON | 0.7 | co-location |
| ALIAS_OF | 0.6 | co-location |
| LISTED_IN_CERT | 0.5 | co-location |
score = round(100 × sev_factor × oc_factor × hop_penalty × min(edge weights) × exploit_mult, 1)
sev_factor = {critical:1.0, high:0.8, medium:0.55, low:0.3, info:0.2}
oc_factor = 0.5 + 0.5 × (min(anchor_owner_conf, endpoint_owner_conf) / 100)
# jewel endpoints (secret/credential/cloud_account) treat oc as 100
hop_penalty = {1:1.0, 2:0.85, 3:0.7}, else 0.6
# the final secret->cloud_account escalation hop is NOT counted toward
# hop distance — it's the secret's consequence, not extra pivot cost
A validated (working_credential/live-login-confirmed) secret extends exactly one
further bounded hop to the cloud_account it unlocks via SECRET_GRANTS_ACCESS_TO — a
proven escalation, not a reachability claim.
A chain that carries at least one semantic edge (a contained secret, a breached credential, an ownership link, an exposed endpoint) — or that terminates at a jewel — is a kill-chain: a real, traversable pivot.
A chain built only from co-location edges (RESOLVES_TO / HOSTED_ON / ALIAS_OF /
LISTED_IN_CERT — shared host, cert, DNS, or alias) is shared-fate: it means a
compromise of one asset raises the blast radius to the other, but it is explicitly not
a proven pivot. The renderer (§10.3) labels these differently on purpose — do not override
that distinction in prose. Presenting shared-fate as a kill-chain is the single easiest way
to overstate a finding to a board.
The connection is a binary trigger, not a proportional one: risk_score._threat
appends a fixed 0.60 weight to the Threat combine (§7.2) if any attack path — curated
or graph-walked — scores ≥ 85. A 99-scoring chain and an 85-scoring chain contribute
identically to T; the score above 85 only changes the board narrative (§10.3), not the
org number. Multiple qualifying chains also don't stack — it's list membership, not a sum.
Source: reporting/board_report.py (the BoardSummary data model — pure curation, no new
computation) and reporting/board_render.py (the branded HTML/PDF one-pager, headless
Chromium via Playwright, A4 print-ready).
HeroNumbers: dollar_low/expected/high, annualized, records_at_risk, risk_score,
grade, dominant_driver, confirmed_criticals, risk_trend[], risk_delta.
confirmed_criticals = count of findings where severity is critical, confidence is
confirmed or is_proven, and evidence.owner_tier is confirmed/strong. This
exists so the hero never collapses to "$0 risk" on a target with zero leaked records but a
real proven critical.
The hero's tri-state honesty logic (never a fabricated dollar figure):
| Condition | What the hero shows |
|---|---|
records_at_risk > 0 |
The $ band (expected, range low–high, annualized if present) + record count |
records_at_risk == 0 and confirmed_criticals > 0 |
"N confirmed-critical exposure(s)" — explicitly no dollar figure, captioned "graded on impact, not leaked records" |
| both zero | "No quantified record exposure" — captioned "posture graded on exposed surface + threat signals" |
risk_trend is this same target's risk score across its retained scans, oldest→newest,
capped at 6 points (matched by scan_meta.target, never cross-target). risk_delta =
current − previous point; rendered as an up/down sparkline badge only when ≥2 points exist.
Group findings by category (dropping INFO/unranked severities entirely — they never make
the board). Within a group, the representative is the highest-ranked finding by
severity×100 + confidence×10 + owner_tier_weight. Title logic:
active_typosquat → "{n} active lookalike
domain(s)", public_cloud_bucket → "{n} publicly-exposed cloud bucket(s)",
leaked_credential → "{n} credential-exposure finding(s)", secret_leak → "{n} leaked
secret(s)") → the rolled-up count line.Sorted by (severity rank, count) descending; top 3 kept.
Curated + graph-walk paths deduped by rule_id (best-scoring variant kept, with an
also_affects count of the rest), sorted by score, top 3 computed — but the one-pager only
renders #1. If it's a shared-fate chain (§9.3), the heading reads "Blast-radius exposure
(shared infrastructure)" in a neutral olive/tan block, not "The one attack path" in
alarm-red — the renderer enforces the honesty distinction structurally.
Derived by mapping each top-finding category to a canned remediation line:
| Category | The ask |
|---|---|
leaked_credential |
Force credential rotation + session revocation for exposed accounts. |
public_cloud_bucket |
Lock public cloud storage (enable block-public-access). |
active_typosquat |
Monitor / take down active lookalike domains. |
secret_leak |
Rotate the leaked secrets and scrub them from history. |
exposed_admin_panel / sso_exposure |
Put exposed admin/identity portals behind MFA + conditional access. |
The MFA line also fires implicitly when the top attack path's title mentions
"portal"/"sso"/"vpn," even without an explicit exposed_admin_panel finding. Every ask list
closes with the fixed line: "Fund continuous external monitoring — this snapshot rots in
weeks." — a standing reminder that a one-time external-surface snapshot decays.
[brand bar] <your-firm> · external risk intelligence Confidential · Board Summary
[H1] External Attack Surface — Board Summary
Target: <target>
[hero] $ band + grade block, side by side -> [trend sparkline + delta badge]
Primary risk driver: <exposure|breach-likelihood|business-impact>. <narrative sentence>
[H2] What the board must know <- top 3 findings, severity badge + title + owner-tier pill
[H2] The one attack path (or "Blast-radius exposure") <- #1 path, narrative, "+N more" note
[H2] The ask <- bullet list, always ends with the monitoring line
[footer] Basis: <loss basis string>. Generated by <brand>. Point-in-time snapshot — external
attack surface changes daily.
Rendered A4, 12mm top/bottom + 10mm left/right margins, print-background on, via headless Chromium. Concurrent PDF renders are semaphore-bounded (2) so a burst of requests can't spawn unbounded browsers or starve the app's other endpoints.
osint-methodology §16osint-methodology §16 gives the technical→business translation. This skill adds the
$ column, using the exact §8 mechanics — and is explicit about when $ is the wrong unit.
| Technical | Business language | $ / quantification |
|---|---|---|
| Listable S3 bucket with 50,000 PII records | Customer records publicly downloadable; potential GDPR/CCPA notification trigger | If the records are credential-bearing and counted in evidence.employees/.users: 50,000 × $150–200 = $7.5M–$10M range. If it's PII with no credential-count evidence, the loss model has no input here — say so; don't invent a record-cost number for a category the model doesn't score. |
| 220 employee accounts in breach corpus (hudsonrock) | Stolen corp SSO credentials circulating; active credential-stuffing risk | 220 × $150–200 = $33,000–$44,000 (expected $36,300); annualize against Threat (T), not composite risk — §8.3 |
Live AWS admin key (proven, working_credential) |
Complete cloud compromise possible | Not a record-count event — this is an Impact/Threat driver (§7.2's 0.90 proven-exploit weight, §7.3's 1.0 credential-type Impact weight), not a $-loss-model input. Quantify via the risk score, not the loss model. |
DMARC p=none |
Anyone can send email appearing to be from your domain | Not a loss-model input either — this is a likelihood-of-a-future-phishing-event signal (feeds the weak_email_auth attack-path chain, §9.1), not a present exposed-record count. |
| Vendor appliance version matches CISA KEV | Attackers are actively scanning for this exact issue | Threat factor weight 0.90 (§7.2) — a likelihood multiplier, not a dollar figure by itself. Pair with whatever asset it exposes for the Impact side. |
The pattern to internalize: the $ figure only exists where the loss model has a record count to multiply. Everything else is a Likelihood or Impact input to the 0–100 score, not an independent dollar estimate — resist the pressure to put a $ next to every finding just because the board wants dollars. State plainly when a finding is a risk-score driver with no loss-model $ of its own.
S in §7.1 counts every finding's severity weight regardless of TENTATIVE/FIRM/
CONFIRMED or owner_confidence. Spray a domain with unverified findings and Exposure ticks
up even though none of them are confirmed or owned. Before presenting an "exposure-driven"
score, always pull the actual finding list behind the driver and spot-check it — see the
trap prompt in §13.S (above) but structurally cannot trigger the proven-critical floor, the
proven-exploit Threat term, or a CONFIRMED board rollup. They can nudge a number; they
cannot single-handedly swing a grade.annualized uses the Threat factor, not the composite risk (§8.3). These will
usually be close but are never guaranteed to be the same number — check before quoting an
ALE in a sentence that also mentions the letter grade.risk_trend is same-target-only, oldest→newest, capped at 6 points. Never present two
different targets' scores side-by-side as if the 0–100 scale between them were comparable
beyond the shared A–F grade bands — the underlying finding mix, asset count, and ownership
confidence differ per target, so 60 on one target and 60 on another are not "equally bad"
in the same specific way.Drop these into a fresh session to verify the skill loads correctly.
S regardless of confidence/ownership (§7.1, §12 sharp edge) — so expect a small Exposure nudge and a heavily-discounted Impact contribution, not a grade swing.osint-methodology (§7 recon pipeline) / offensive-osint to run a recon pass first, then return here — see §0–§1.is_proven, or an already-CONFIRMED explicit confidence — is exempt from the ownership ceiling; ownership context is recorded, not used to erase the confirmation).rule_id, keep the highest-scoring variant per rule, sort by score, render only #1).reporting/risk_score.py (three-factor Exposure/Threat/Impact model, independent-evidence
combiner, A–F banding), reporting/loss_model.py (IBM/Ponemon cost bands, cross-source
record dedup, Threat-factor annualization), reporting/board_report.py +
reporting/board_render.py (BoardSummary curation + branded one-pager layout, hero
tri-state honesty logic), reporting/attack_paths.py + reporting/attack_graph.py
(curated chain catalog, generic graph-walk engine, kill-chain vs. shared-fate gate),
reporting/owner_confidence.py + reporting/proof.py (the ownership/proof demotion cap).
New §6 FAIR primer, §11 risk-translation table, and §12 honesty guardrails are original to
this skill, built to extend osint-methodology §9 and §16 rather than duplicate them.