t0ul

The Risk Rancher Field Manual, Ch. 6: Sorting the Herd

Part of The Risk Rancher Field Manual, a hands-on guide to starting, scaling, and running a vulnerability management program.

This is the chapter the whole book has been walking toward, and it is the one your spreadsheet does not survive. Everything before now was setup: you sized the program, mapped the herd, built the alliance with IT, cast the nets. Now the findings pour in from every scanner and pentest and bug report, thousands of them, and they arrive as one undifferentiated wall of red.

Triage is the act of turning that wall into a short, ordered list of things worth doing today. It is the core of vulnerability management, the whole ballgame, and it is where Risk Rancher was born, because doing it by hand is where I personally lost the most hours of my career.

We are not going to talk about it in the abstract. You are going to do it, on a real file, right now: 29 rows in, a ranked list of 24 things to do out, and an answer key to check it against. You need a spreadsheet, or Python 3 if you want the script to do the arithmetic for you.

The pile we will actually work

Download these two files:

  • sample-findings.csv: 29 findings from a handful of scanners, across eight systems. The ids run F001 to F030 with F003 missing, because a real export is never tidy, and you will learn to stop expecting it to be. This is your wall of red in miniature. A real export is fourteen thousand rows of this; the moves are identical.
  • sample-assets.csv: the eight systems, with the criticality and exposure you set back in chapter two.

Open the findings file. It is a mess on purpose: duplicates, false positives, scary numbers on boxes nobody can reach, and quiet mediums on systems that would end your company. All 29 rows, as the scanners handed them over. Three columns are left off the page for width: the CVE id, the scanner’s description and its suggested fix. Read the rest left to right: the scanner that reported it, the asset, the scanner’s title, the scanner’s severity label and its CVSS score, whether the finding is on the known-exploited list (kev, explained in move 3), and the exposure and criticality you set for that asset in chapter two.

idsourceassettitlesevcvsskevexposurecriticality
F001Trivypayments-apiCVE-2021-44228 Log4Shell RCECritical10.0yesinternetcrown-jewel
F002Nessuspayments-apiApache Log4j2 Remote Code ExecutionCritical9.8yesinternetcrown-jewel
F004OWASP ZAPpayments-apiSQL Injection in /orders searchMedium6.1nointernetcrown-jewel
F005Nessusvpn-gatewayCVE-2023-27997 FortiOS heap overflow (pre-auth RCE)Critical9.8yesinternetcrown-jewel
F006Trivydev-sandboxCVE-2024-3094 xz backdoor RCECritical10.0noisolateddev
F007Semgrepjenkins-ciHardcoded AWS secret key in pipelineCritical9.1nointernalstandard
F008OWASP ZAPmarketing-siteSQL Injection (possible)High7.5nointernetstandard
F009Nessuscustomer-dbOutdated OpenSSL 1.0.2High7.5nointernalcrown-jewel
F010Nessuslegacy-billingTLS 1.0 / weak ciphers enabledHigh7.4nointernalcrown-jewel
F011SonarQubeinternal-wikiCognitive complexity too highLow3.1nointernalstandard
F012Trivycustomer-dbCVE-2022-0778 OpenSSL infinite loop (DoS)Medium5.9nointernalcrown-jewel
F013Nessusmarketing-siteMissing HTTP security headersLow2.6nointernetstandard
F014Prowlerpayments-apiS3 bucket allows public readHigh8.2nointernetcrown-jewel
F015Trivyjenkins-ciCVE-2023-44487 HTTP/2 Rapid ResetHigh7.5yesinternalstandard
F016Semgreppayments-apiUse of weak hash (MD5) for signatureMedium5.3nointernetcrown-jewel
F017Nessusinternal-wikiSelf-signed TLS certificateLow2.9nointernalstandard
F018OWASP ZAPcustomer-dbVerbose SQL error messagesMedium5.3nointernalcrown-jewel
F019Trivydev-sandboxCVE-2023-0286 X.400 type confusionHigh7.4noisolateddev
F020Prowlerpayments-apiIAM user with no MFAMedium6.5nointernetcrown-jewel
F021Nessusvpn-gatewayDeprecated SSL-VPN web mode enabledMedium5.8nointernetcrown-jewel
F022SonarQubejenkins-ciUnused variableLow1.0nointernalstandard
F023Trivycustomer-dbCVE-2021-3711 OpenSSL SM2 buffer overflowCritical9.8nointernalcrown-jewel
F024OWASP ZAPmarketing-siteCookie without Secure flagLow3.1nointernetstandard
F025Nessuslegacy-billingSNMP default community string publicMedium5.3nointernalcrown-jewel
F026Prowlerdev-sandboxSecurity group open to 0.0.0.0/0 on 22High7.3noisolateddev
F027Semgrepcustomer-dbSQL string concatenation in query builderHigh7.1nointernalcrown-jewel
F028Trivymarketing-siteCVE-2023-38545 curl SOCKS5 overflowMedium5.9nointernetstandard
F029Nessuspayments-apiCVE-2014-0160 HeartbleedCritical7.5yesinternetcrown-jewel
F030SonarQubeinternal-wikiTODO comment left in codeLow1.0nointernalstandard

That is what a real export looks like. There are three moves, and you do them in order: kill the noise, merge the duplicates, fix the scores.

The triage funnel: 29 raw findings narrow to 24 units of work, then sort into a prioritized queue of 5 P1, 7 P2, 3 P3, 9 P4.

The whole chapter in one picture. Three passes turn the wall of red into a ranked list, and you work the top. The counts in it are where you will be by the end of the chapter.

The triage and operate loop as a decision tree: is it real, is it the same fix, is it known-exploited, then score it into P1 to P4; then the analyst assigns a named owner, the SLA clock starts, the fix is verified or reopened, and anything that cannot be fixed in time goes through the exception loop and returns to the queue on expiry.

The whole operate loop on one page. Four questions turn a finding into a priority; then a name, a clock, and a check turn the priority into a fix, or into a signed, expiring exception that comes back. Chapters seven and eight work the bottom half.

Move 1: kill the noise

The sea of red is mostly not real. Scanners guess, and a large share of any scan is false positives, findings that are simply wrong: a patch applied but invisible to the scanner, a version string that looks vulnerable but was quietly backported, a finding on a service that is not running. Backported means the vendor fixed the bug inside the old version number without bumping it, which Linux distributions do all the time, and which a scanner reading version strings cannot see.

Walk the file and ask one question of each finding: is this actually true, on this actual system, right now? To answer it you read the finding’s description and you read what you know about the asset, which is why chapter two’s notes column exists. Two in this file are not. F007 says a hardcoded AWS secret key was found in a pipeline; its description quotes the key, and that key, AKIAIOSFODNN7EXAMPLE, is the one Amazon prints in its own documentation as an example, which a smoke test is using on purpose. F008 says SQL injection on marketing-site; your asset list says that site is static HTML with no database, so there is nothing to inject into. Neither check needed a tool. Both needed you to read.

idfindingwhy it is not real
F007Hardcoded AWS secret in the Jenkins pipelineThe string is AKIAIOSFODNN7EXAMPLE, the example key straight out of AWS documentation, used in a smoke test. Not a credential.
F008SQL Injection on marketing-siteThe marketing site is static HTML. No database, no server-side parameters. The scanner saw a query string and guessed.

Kill both. And when you kill one, write down that you killed it, because a false positive you dismiss today comes back on every scan forever unless you record the decision. Log them in removed-before-scoring.csv. A program without a memory re-litigates the same garbage every week. (In the tool, that log is a suppression rule: one entry closes the finding and keeps it closed on every scan after; chapter ten shows it.)

Twenty-nine findings, two gone. Twenty-seven left.

Move 2: merge the duplicates

The same vulnerability shows up many times: once per host, once per scanner, once per scan. Look at these two:

idscannerassettitle
F001Trivypayments-apiCVE-2021-44228 Log4Shell RCE
F002Nessuspayments-apiApache Log4j2 Remote Code Execution

Same CVE, same host, two scanners, two different titles. That is not two problems. It is one problem with one fix (bump Log4j to 2.17.1 and rebuild), and you work it as one unit. Merge F002 into F001 and note it.

The test is not “same CVE”. The test is same fix: if one change closes them all, they are one unit of work. Look at customer-db for the subtler case:

idscannerassettitleseverity
F009Nessuscustomer-dbOutdated OpenSSL 1.0.2High
F012Trivycustomer-dbCVE-2022-0778 OpenSSL infinite loop (DoS)Medium
F023Trivycustomer-dbCVE-2021-3711 OpenSSL SM2 buffer overflowCritical

Three different titles, three different CVEs, one cause: an old OpenSSL on one host. One upgrade closes all three. Merge F009 and F012 into F023, which is the worst of the three and the one that will carry the ticket. When the upgrade lands, the next scan will close the other two on its own. Note both merges in removed-before-scoring.csv so nobody re-litigates them next week.

This move is the difference between a fourteen-thousand-row list and a two-hundred-item list, and nothing else you do all week removes as much work for as little effort. You cannot work fourteen thousand rows. You can work two hundred.

In the tool, the easy half of this is automatic: Risk Rancher treats a finding with the same scanner, asset and title as the same finding, so the identical row landing again on tomorrow’s re-scan never makes a second ticket. The hard half stays with you. No tool knows that “Log4Shell RCE” from Trivy and “Log4j2 Remote Code Execution” from Nessus are one library, or that three OpenSSL titles are one upgrade. You merge those by knowing the systems.

Twenty-seven findings, three merged into two. Twenty-four units of work left.

Move 3: fix the scores

Here is the big one, the secret the scanner vendors would rather you not dwell on: the CVSS score on every finding is lying to you. Not out of malice. It is context-free. A CVSS of 9.8 means “this is bad in the abstract.” It has no idea that this instance is on an offline test box nobody can reach, or that this unremarkable Medium is sitting on your public customer database.

So you take the scanner’s severity and bend it with the two things the scanner does not know: how exposed the asset is, and how much you care about it. Here is the rubric. It is deliberately simple, because a rubric you can run in your head on a bad Monday beats a perfect model you never apply.

factorvalues
base (scanner severity)Critical 4, High 3, Medium 2, Low 1
exposureinternet-facing 2, internal 1, isolated 0.5
asset criticalitycrown-jewel 2, standard 1, dev/throwaway 0.5

score = base x exposure x criticality

One override on top: if the finding is on your known-exploited list, it is a P1 no matter what the math says. The list most people use is KEV, the Known Exploited Vulnerabilities catalog that CISA (the US government’s cybersecurity agency) publishes for free: the CVEs that attackers are confirmed to be using right now, not in theory. It is a file you download from CISA’s catalog page, cisa.gov/known-exploited-vulnerabilities-catalog, and search by CVE id. The kev column in the sample is not made up; every flag matches that catalog as of October 2026, so you can check it yourself. Something being actively exploited in the wild beats every theoretical number. It is a blunt rule on purpose.

Where do the factors come from? Base is the scanner’s own four-step label, turned into numbers that keep their spacing. Exposure doubles for the internet and halves for an isolated box, because reachability is the first thing an attacker needs. Criticality doubles for a crown jewel and halves for a throwaway, because consequence is the second. The specific numbers are a starting point, not a law; what matters is that you write yours down and apply them the same way every week. Then turn the score into a priority and a clock:

scorepriorityfix within
12 or moreP13 days
6 to 11P214 days
3 to 5P330 days
under 3P490 days

Now watch what the rubric does to two findings that look nothing like their CVSS.

F006, the 10.0 that does not matter. The xz backdoor, CVE-2024-3094, CVSS 10.0, “Critical,” on dev-sandbox. Terrifying header. But the sandbox is an isolated lab box on an air-gapped subnet. Base 4, exposure 0.5, criticality 0.5. Four times a half times a half is 1.0. That is a P4. Patch it when you get to it. A critical on a ghost is not a critical.

F004, the Medium that does. A blind SQL injection on payments-api, scored Medium, CVSS 6.1. The scanner shrugged. But payments-api is internet-facing and holds cardholder data. Base 2, exposure 2, criticality 2. Two times two times two is 8. That is a P2, and it is a direct path to the data that would end your company. A medium on the crown jewels might be your biggest fire.

Contextual scoring quadrant: exposure on one axis, asset criticality on the other. A CVSS 10.0 xz backdoor on an isolated dev box sits bottom-left as a P4; a Medium SQLi on the internet-facing payments database sits top-right as a P2.

The same CVSS number lands in different quadrants. Where a finding lives decides what it is worth.

That is what “context is king” means in arithmetic. Run the number on all 24, then sort: priority first, so every P1 sits above every P2; score within a priority; and the id to break ties, which is why the three 16s sit in id order and F001 is at the very top. Do it in your sheet, or let score.py do the arithmetic. It is a Python script: save it and the findings file in one folder, open a terminal there, and run the command below. The whole rubric is a few lines. The judgment calls the formula cannot make, the false positives and the merges, are written in at the top where you can see them, and the band line is the four thresholds from the table:

BASE = {"Critical": 4, "High": 3, "Medium": 2, "Low": 1}
EXPOSURE = {"internet": 2, "internal": 1, "isolated": 0.5}
CRITICALITY = {"crown-jewel": 2, "standard": 1, "dev": 0.5}

FALSE_POSITIVES = {"F007", "F008"}
DUPLICATE_OF = {"F002": "F001", "F009": "F023", "F012": "F023"}

score = BASE[severity] * EXPOSURE[exposure] * CRITICALITY[asset_criticality]
priority = "P1" if kev == "yes" else band(score)   # band: >=12 P1, >=6 P2, >=3 P3, else P4
python3 score.py sample-findings.csv
rank  pri score  id    asset           title
   1  P1     16  F001  payments-api    CVE-2021-44228 Log4Shell RCE (KEV)
   2  P1     16  F005  vpn-gateway     CVE-2023-27997 FortiOS heap overflow (pre-au (KEV)
   3  P1     16  F029  payments-api    CVE-2014-0160 Heartbleed (KEV)
   4  P1     12  F014  payments-api    S3 bucket allows public read
   5  P1      3  F015  jenkins-ci      CVE-2023-44487 HTTP/2 Rapid Reset (KEV)
   6  P2      8  F004  payments-api    SQL Injection in /orders search
   7  P2      8  F016  payments-api    Use of weak hash (MD5) for signature
   8  P2      8  F020  payments-api    IAM user with no MFA
   9  P2      8  F021  vpn-gateway     Deprecated SSL-VPN web mode enabled
  10  P2      8  F023  customer-db     CVE-2021-3711 OpenSSL SM2 buffer overflow
  11  P2      6  F010  legacy-billing  TLS 1.0 / weak ciphers enabled
  12  P2      6  F027  customer-db     SQL string concatenation in query builder
  13  P3      4  F018  customer-db     Verbose SQL error messages
  14  P3      4  F025  legacy-billing  SNMP default community string public
  15  P3      4  F028  marketing-site  CVE-2023-38545 curl SOCKS5 overflow
  16  P4      2  F013  marketing-site  Missing HTTP security headers
  17  P4      2  F024  marketing-site  Cookie without Secure flag
  18  P4      1  F006  dev-sandbox     CVE-2024-3094 xz backdoor RCE
  19  P4      1  F011  internal-wiki   Cognitive complexity too high
  20  P4      1  F017  internal-wiki   Self-signed TLS certificate
  21  P4      1  F022  jenkins-ci      Unused variable
  22  P4      1  F030  internal-wiki   TODO comment left in code
  23  P4   0.75  F019  dev-sandbox     CVE-2023-0286 X.400 type confusion
  24  P4   0.75  F026  dev-sandbox     Security group open to 0.0.0.0/0 on 22

Note rank 5. An internal HTTP/2 denial-of-service, an attack that knocks a service over rather than breaking into it, which you would normally shrug at, floated to P1 by one thing: it is on the known-exploited list. And note the very bottom: a box with SSH open to the entire internet, dead last, because the entire internet cannot route to it. The rubric does not care what the finding is called. It cares where it lives and whether anyone is using it.

Try it. Open triage-sheet.csv in a spreadsheet. It is the findings file, minus the long text columns, with seven empty columns to fill. false_positive takes yes or nothing. duplicate_of takes the id this row merges into. base, exposure_factor and criticality_factor are the three numbers from the rubric, score is their product, and priority is the band, or P1 if kev says yes. Work all 29: mark the two false positives, fill in the three merges, score the rest, sort by priority then score. Then check yourself against ranked-queue-answer-key.csv, whose why column shows the arithmetic for every row. If your P1s match, you can triage. If a 9.8 is still near your top or a crown-jewel medium is near your bottom, re-read move 3. This is the single most valuable skill in the whole book, and you now have it in your hands, not just your head.

A word on where a tool fits in this chapter. The two judgment calls, which findings are wrong and which are the same fix, are yours; no tool knows that the CVE was reported on a test fixture, or that three OpenSSL findings close with one upgrade. The arithmetic is not judgment, and the tool does it. It ships with exactly the table above, under Configuration, Priority rubric, with every number editable. Load chapter two’s inventory and every finding that lands on a listed asset is scored as it lands: the scanner’s severity as the base, times the asset’s exposure and criticality, banded into the four settings of the tool’s one severity dial. P1 is Critical, P2 is High, P3 is Medium, P4 is Low. The scanner’s own word is kept beside the result, and the drawer shows the sum. Here is the sample going through it, on a throwaway copy of the tool. I run those on ports 8093 and 8094 so the tracker I actually use, on 8080, is never touched; yours answers on 8080. Every line after a $ is exactly the line that ran, starting with the sign-in that gives curl the browser’s cookie and the adapter that maps the file’s columns, which chapter ten explains.

$ curl -s -c cookies.txt -X POST http://localhost:8094/api/auth/login -H 'Content-Type: application/json' -d '{"email":"sheriff@rr.test","password":"<yours>"}' -o /dev/null -w '%{http_code}\n'
$ curl -s -b cookies.txt -X POST http://localhost:8094/api/adapters -H 'Content-Type: application/json' -d '{"name":"Field Manual","source_name":"Field Manual","findings_path":".","mapping_title":"title","mapping_asset":"asset","mapping_severity":"severity","mapping_description":"description","mapping_remediation":"remediation"}' -o /dev/null -w '%{http_code}\n'
201

# Chapter two's inventory goes in first (Configuration > Asset inventory > Load inventory makes this call)
$ curl -s -b cookies.txt -F file=@sample-assets.csv http://localhost:8094/api/admin/assets/csv
{"loaded":8,"rescored":0}


# Then the 29 rows: the tool scores each one against its asset as it lands
$ curl -s -b cookies.txt -o /dev/null -w 'ingest %{http_code} in %{time_total}s\n' -F adapter_name='Field Manual' -F file=@sample-findings.csv http://localhost:8094/api/ingest/csv
ingest 201 in 0.003497s
$ sqlite3 -header -column data/riskrancher.db "select substr(title,1,38) as title, asset_identifier as asset, scanner_severity as scanner, rubric_score as score, severity from tickets where asset_identifier in ('payments-api','dev-sandbox') order by rubric_score desc, title"
title                                   asset         scanner   score  severity
--------------------------------------  ------------  --------  -----  --------
Apache Log4j2 Remote Code Execution     payments-api  Critical  16.0   Critical
CVE-2014-0160 Heartbleed                payments-api  Critical  16.0   Critical
CVE-2021-44228 Log4Shell RCE            payments-api  Critical  16.0   Critical
S3 bucket allows public read            payments-api  High      12.0   Critical
IAM user with no MFA                    payments-api  Medium    8.0    High    
SQL Injection in /orders search         payments-api  Medium    8.0    High    
Use of weak hash (MD5) for signature    payments-api  Medium    8.0    High    
CVE-2024-3094 xz backdoor RCE           dev-sandbox   Critical  1.0    Low     
CVE-2023-0286 X.400 type confusion      dev-sandbox   High      0.75   Low     
Security group open to 0.0.0.0/0 on 22  dev-sandbox   High      0.75   Low     
$ sqlite3 data/riskrancher.db "select severity, count(*) from tickets group by severity order by case severity when 'Critical' then 1 when 'High' then 2 when 'Medium' then 3 else 4 end"
Critical|5
High|9
Medium|6
Low|9

Read the first table against your own sheet. The three KEV rows on payments-api score 16 and land Critical; the S3 bucket is a High on an internet crown jewel, 12, Critical, exactly where you put it; the three Mediums on that box are 8, High; and the xz backdoor, a scanner Critical on the sandbox, is a 1, Low, dead last, for the reason you worked out by hand. The split at the bottom, 5, 9, 6 and 9, is the rubric’s, not the scanners’. It does not match your 24-unit answer key yet because the two false positives and the three merges are still in the pile; those calls are the next chapter’s hand-work in the tool, and the arithmetic on the 24 that remain is the same.

The override is the one thing the arithmetic cannot do alone, because it needs the list. Rank 5 in your queue, the HTTP/2 Rapid Reset on the build server, scores 3 and lands Medium until the catalog is loaded:

$ sqlite3 data/riskrancher.db "select title, scanner_severity, rubric_score, severity from tickets where title like 'CVE-2023-44487%'"
CVE-2023-44487 HTTP/2 Rapid Reset|High|3.0|Medium
$ curl -s -b cookies.txt -F cisa_catalog_file=@known_exploited_vulnerabilities.json http://localhost:8094/api/integrations/cisa/sync
{"message":"CISA KEV Catalog synced successfully","raised":1,"status":"success","vulnerabilities":1741}

$ sqlite3 data/riskrancher.db "select title, scanner_severity, rubric_score, severity, is_cisa_kev from tickets where title like 'CVE-2023-44487%'"
CVE-2023-44487 HTTP/2 Rapid Reset|High|3.0|Critical|1
$ sqlite3 data/riskrancher.db "select actor, activity_type, old_value, new_value from ticket_activity where activity_type = 'rescored'"
KEV sync|rescored|Medium|Critical
$ sqlite3 data/riskrancher.db "select severity, count(*) from tickets group by severity order by case severity when 'Critical' then 1 when 'High' then 2 when 'Medium' then 3 else 4 end"
Critical|6
High|9
Medium|5
Low|9

That is chapter six, done by the machine, with a line in the finding’s history saying who moved it. Two screens from the same run show what the reader of the queue sees.

The Holding Pen after the 29 rows, with the inventory loaded: eight assets, each with its exposure and criticality beside its name and its severity counts after the rubric; payments-api carries four Critical and three High, dev-sandbox three Low.

The pen after the rubric. Each asset wears its two labels, and the counts are the rubric’s: the sandbox, three scanner Highs and a Critical among them, shows three Low, and the one Critical on jenkins-ci is the Rapid Reset the catalog raised.

The drawer for the S3 bucket finding on payments-api: the badge says Critical, and under the asset name a line reads Scanner said High. internet x crown-jewel, score 12.

Click a finding and the drawer shows the sum, so nobody has to take the dial on faith.

Do it in the spreadsheet first anyway, so that when the tool shows you a Critical you can check its arithmetic against your own.

The output is your program

Sit with what just happened. You started with a wall of red, undifferentiated and paralyzing, and you ended with a short, ordered list where the top item is genuinely the most important thing you could fix today. That list, not the scan, is your vulnerability management program. Everything downstream, every remediation and every metric, runs off it.

Twenty-nine rows became 24 units of work, ranked, with the two false positives and the three merges written down so nobody argues them again next week, and a rubric you can run on a bad Monday. So work the top of the list, not the pile. The pile is infinite and it does not care about you. The top of the list is finite, ranked, and real. Which brings us to the next problem, because a prioritized finding is still just a finding until somebody closes it. Next: getting it fixed.

← Back to the blog