DEV Community

Jordan Huang
Jordan Huang

Posted on

My Dependency Scanner Spits Out 120 Warnings. My Free Server Turns Them Into 6 Lines.

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
'''
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run it with:

python -m pytest test_audit_brief.py -q
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)