Friday 4:50pm. I ran pip-audit on a small internal service before cutting a release. It printed 120 lines. I pasted the whole output into a free model and asked for a fix list. The reply was long, generic, and completely unhelpful. It told me to update affected packages, apply vendor patches, and monitor CVEs. It did not tell me which package to touch first.
The problem was not the model. It was the input. Half of those 120 lines were the same two CVEs repeated across direct dependencies, lockfiles, and transitive children. I had not shown the model six distinct problems. I had shown it six copies of the same fact.
So I moved the grouping logic into a small service on a free server. It converts scanner JSON into a compact risk brief, then calls a free model to classify and recommend an action per package. It does not patch anything.
The free server and free model access I use come from MonkeyCode. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The code below is about the data transformation. Replace the model call with whatever endpoint you already use.
The scanner output is not a risk list
Most dependency scanners return one object per vulnerable path. The same CVE appears on the direct dependency, on the lockfile, on a container scan, and on a transitive child. That detail is useful for tracing. It is awful for triage.
I did not need more model context. I needed fewer redundant facts, arranged the way a human would read them.
Here is the normalization step:
from collections import defaultdict
def normalize_findings(raw_findings):
grouped = defaultdict(lambda: {
'package': None,
'severity': 0,
'cves': set(),
'paths': [],
'details': [],
})
severity_rank = {'critical': 4, 'high': 3, 'moderate': 2, 'low': 1}
for finding in raw_findings:
package = finding.get('package') or finding.get('name')
cve = finding.get('cve') or finding.get('advisory_id') or 'no-cve'
key = (package, cve)
group = grouped[key]
group['package'] = package
rank = severity_rank.get(finding.get('severity', 'low').lower(), 1)
group['severity'] = max(group['severity'], rank)
group['cves'].add(cve)
if finding.get('path') and len(group['paths']) < 3:
group['paths'].append(finding['path'])
if finding.get('description') and len(group['details']) < 2:
group['details'].append(finding['description'])
compact = []
for (package, cve), group in grouped.items():
compact.append({
'package': package,
'cve': cve,
'severity': group['severity'],
'paths': group['paths'],
'details': group['details'][:1],
})
compact.sort(key=lambda item: (-item['severity'], item['package'].lower()))
return compact
The function groups by package and CVE. It keeps the highest severity and a few example paths. It does not drop the dangerous parts. It drops the repeats.
Build a brief the model can parse
A free model does not need pretty prose. It needs a fixed format with one fact per line.
def build_risk_brief(items):
rank_labels = {4: 'critical', 3: 'high', 2: 'moderate', 1: 'low'}
lines = []
for item in items:
path = item['paths'][0] if item['paths'] else 'unknown'
sev = rank_labels.get(item['severity'], 'low')
pkg = item['package']
cve = item['cve']
lines.append(f'{sev}|{pkg}|{cve}|{path}')
return '\n'.join(lines)
The output looks like:
high|requests|CVE-2024-0001|/app/requirements.txt
critical|django|CVE-2024-1234|/app/Pipfile
moderate|cryptography|CVE-2024-5678|/app/Pipfile.lock
That is the entire prompt context. No descriptions, no remediation advice, no scanner metadata.
Ask for one line per package
The model gets a constrained task:
PROMPT = '''You are reading a compact dependency risk brief.
Format: severity|package|CVE|example_path
Return one line per unique package in this exact format:
package | recommended_action | one_sentence_reason
Do not add introductions, summaries, or generic security advice.
'''
I call the model with PROMPT + '\n' + brief. The reply is easier to scan than the original 120 lines.
What the free server runs
The service is a single POST handler. It accepts scanner findings, normalizes them, and returns both the brief and the model's classification.
from fastapi import FastAPI
app = FastAPI()
@app.post('/audit-brief')
def audit_brief(payload: dict):
compact = normalize_findings(payload['findings'])
brief = build_risk_brief(compact)
model_reply = call_model(PROMPT + '\n' + brief)
return {'brief': brief, 'model_reply': model_reply}
I call it from CI or a scheduled job. The model call is one function, so I can swap the provider without changing the normalization code.
A test that catches over-grouping
The risk with grouping is merging two different problems. This test protects against that:
def test_same_cve_grouped_but_different_cves_not():
raw = [
{'package': 'requests', 'severity': 'high', 'cve': 'CVE-2024-0001', 'path': '/app/requirements.txt', 'description': 'x'},
{'package': 'requests', 'severity': 'moderate', 'cve': 'CVE-2024-0001', 'path': '/app/Pipfile.lock', 'description': 'x'},
{'package': 'requests', 'severity': 'low', 'cve': 'CVE-2024-0002', 'path': '/app/requirements.txt', 'description': 'y'},
]
compact = normalize_findings(raw)
assert len(compact) == 2
assert compact[0]['cve'] == 'CVE-2024-0001'
assert compact[0]['severity'] == 3
Run it with:
python -m pytest test_audit_brief.py -q
If your scanner already groups findings, this test may be unnecessary. Most old wrappers do not.
What changed after I shipped it
The model stopped giving generic advice. It started returning lines like:
requests | patch to 2.32.3 | fixes CVE-2024-0001
django | read advisory | critical severity, requires migration
I still verify every version number against the advisory link. The model is doing triage, not patch management.
The first line is usually correct. The last line sometimes is not. I treat the output as a label on the risk, not a replacement for the scanner.
Where it breaks
- Different scanners use different JSON shapes. You need an adapter per scanner.
- Some findings have no CVE. The key falls back to
no-cve, which may merge unrelated advisories. - Severity labels can disagree with CVSS scores. I use the scanner's label, not the model's opinion.
- If the model hallucinates a fix version, the one-line format makes the error easy to spot, but it does not prevent it.
Who should not use this
- Small projects whose scanner already prints five clean findings.
- Teams required to log every vulnerable path for compliance. Grouping removes the path list.
- Anyone who wants the model to apply patches automatically. It should never write to your dependency files.
Try it on one noisy scan
Pick a repo where your scanner shouts. Normalize the findings, send the compact brief instead of raw JSON, and ask for one line per package. See whether the first thing you act on changes.
Top comments (0)