
Appsec Agent
- 45 installs
- 126 repo stars
- Updated August 4, 2026
- seqra/opentaint
Helps with ai & agent building tasks.
About
appsec-agent is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- appsec-agent
- AI & Agent Building
- AI-coding skill
Appsec Agent by the numbers
- 45 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #7,749 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/seqra/opentaint --skill appsec-agentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 126 |
| Last updated | August 4, 2026 |
| Repository | seqra/opentaint ↗ |
What it does
Helps with ai & agent building tasks.
Files
AppSec Agent
Orchestrate an end-to-end OpenTaint analysis of a JVM project: run the workflow the user picks by dispatching each step to a subagent that loads one leaf skill, verifying the artifact it returns, and tracking progress. The leaf work is never done here. OpenTaint is a dataflow (taint) SAST analyzer; the goal is real, confirmed vulnerabilities.
The run is one pipeline of a few steps, each gated by the chosen workflow; a step's detail lives in a reference loaded when you reach it, while what every workflow shares stays in this file. Default to the current directory when no target is named.
Keep every artifact under one .opentaint/ directory at the project root — models, rules, configs, approximations, test projects, results, tracking, PoCs, reports. Don't scatter files outside it.
Setup
Before anything else, confirm opentaint is on PATH (command -v opentaint / opentaint --version). If it's missing, don't proceed silently — tell the user and ask to install it, offering the command for their platform; run an install only on explicit confirmation:
macOS / Linux — try in order:
1. Homebrew — brew install --cask seqra/tap/opentaint 2. npm — npm install -g @seqra/opentaint
Windows:
1. npm — npm install -g @seqra/opentaint
After installing, run opentaint health to confirm the autobuilder/analyzer/rules/runtime resolve.
Choose a workflow
Begin by asking the user both things in a single AskUserQuestion call — two questions, scan level and triage level, presented together (never one call then another). Record the chosen scan_level and triage_level in state.yaml:
1. Scan level — lite · normal · deep
- lite — build + scan with existing rules
- normal — + approximation iteration
- deep — + discover-attack-surface for project-used dependency members + new rules (fixed first)
2. Triage level — static · dynamic
- static — classify findings from the model, no running app
- dynamic — + a PoC per confirmed TP. This launches a few test services on the user's current machine (local instances and ports); they're torn down at the end of the run. Make that clear in the option
The run is one fixed pipeline; the two levels decide which steps execute. Walk it top to bottom — when you reach a step your levels include, load its reference and do it; skip the bracketed steps your levels omit. Don't load a step's reference until you reach it.
build → references/build.md every run
[deep] discover project-used lib rules → references/discover-rules.md deep scan
scan → references/scan.md every run
[normal/deep] approximation iteration → references/approximations.md normal, deep scan
triage (generate findings + classify) → references/triage.md every run
[dynamic] PoC + assemble vulnerabilities → references/poc.md dynamic triageFrom inside any step, when a rule or approximation won't behave, load references/escalation.md. Only the approximation iteration loops (it re-scans internally); new rules are fixed before it.
Delegation
Every block's work runs in subagents. Dispatch each with this template:
Invoke the Skill tool with skill_id=<skill-name> first, then do the task.
Inputs:
<name>: <resolved path or value> # one line per input the skill lists
Return:
<the skill's Output>, plus the exact command you ran to verify
Do not run `opentaint scan`. Do not write `.opentaint/vulnerabilities.md`.Universal rules — every dispatch, every workflow:
- open the prompt with the Skill-load line — the subagent has none of this context until it loads its skill
- pass resolved paths (the
<name>-keyed.opentaint/...paths from Working directory layout), never the placeholder tokens - read the named output artifact yourself before continuing — a claim is not an artifact
- only run-scan scans the main project model; rule/approximation/triage subagents don't — the one exception is a create-rule agent running a diagnostic
--track-external-methodsscan of its own test project (never the main model) - only you write
.opentaint/vulnerabilities.mdand.opentaint/tracking/state.yaml - never swap the project model mid-analysis; every run uses the same model
- never triage yourself — verdicts come only from analyze-findings subagents
Orchestration practices:
- Units fan out in parallel — independent
<name>paths, no races - the sole sequential exception is PoC (shared app state and ports); see references/poc.md
- Steps within a unit are sequential via the artifact on disk — dispatch step N only after step N−1's named artifact exists; never bundle steps into one dispatch
- write
state.yamlat each fan-out join — a phase flips todoneonly once every unit's artifact exists on disk
Resource limits
Two limits apply to every fan-out — a global one against rate-limiting, and a tighter one against memory:
- Global cap of 7 — never dispatch more than 7 subagents at once, of any kind. Bursting more reliably trips transient rate-limiting. It binds light and heavy agents alike. Treat 7 as a starting ceiling: each time a subagent comes back rate-limited, drop the cap by 1 for the rest of the run
- RAM-heavy agents each spawn a heavy
opentaintJVM, so they take a tighter memory bound on top of the global cap. The heavy set is exactlybuild-project,run-scan,create-rule,create-dataflow-approximation, and sometimesdebug-rule(when it traces a real scan). Compute the bound at run start and never dispatch more than this many heavy subagents at once: - cores —
nproc(Linux) /sysctl -n hw.ncpu(macOS) - free memory in GB —
free -g(Linux, theavailablecolumn) /sysctl -n hw.memsize÷ 1024³ (macOS) cap_heavy = max(1, min(cores, floor(free_GB / 2), 7))— budget ~2 GB per concurrent JVM- Every other agent is not RAM-bound — discover-attack-surface, create-test-project (compiles once), triage-dependencies, analyze-external-methods, analyze-findings, create-pass-through-approximation, assemble-lib-rules, generate-poc. They're held only by the global cap of 7
It's machine state, not run state — recompute on resume, don't track it. PoC is already sequential.
State and resumption
You are the only writer of .opentaint/tracking/state.yaml — it records the chosen levels and every phase's status, written after each fan-out join.
On start, and after any compaction, reconstruct position from artifacts before doing anything — never replay a completed phase:
- read
state.yamland thetracking/tree - skip any phase whose artifact exists:
project.yaml→ build;coverage.yamlwith every entrydone→ discover; a lib unit'stests_passing: done→ that package's lib rules, and arules/join/<class>.yamlper vuln class → joins assembled;report.sarif→ scan; an approximation unit'sartifact(plustests_passingfor dataflow) → that unit; a finding withverdictset → triaged; withpocset → PoC'd - detect new work from artifacts, not memory: finding files with
verdict: pending(a fresh or reset scan) → triage; methods indropped-external-methods.yamlnot yet in any approximation unit → approximations
Tracking layout
The single source of truth for the tracking schema; each skill writes only its own slice (named in its block reference). The # comments in the YAML below are for understanding only — never copy them into produced files.
.opentaint/tracking/
state.yaml # you only — levels + phase status
coverage.yaml # triage-dependencies seeds, discover-attack-surface flips — one entry per dependency package weighed (deep)
usage/<package-kebab>.yaml # discover-attack-surface writes project-used package members (deep)
findings/<finding_name>.yaml # one per logical finding (from the SARIF→finding script; split by triage)
rules/lib/<package-kebab>.yaml # per-package project-used rule plan — new source/sink lib rules (discover plans; create-* build + test vs the marker) (deep)
rules/join/<class>.yaml # per-vuln-class security join (assemble-lib-rules writes; main scan verifies) (deep)
approximations/<package-kebab>-passthrough.yaml # simple from→to copies; write-only, scan-verified
approximations/<package-kebab>-dataflow.yaml # lambda/callback/async; tested on a test project
approximations/skipped.yaml # methods the engine asks for but that carry no taint
poc-servers.yaml # generate-poc — instances it started; you reap them at end of PoC phasestate.yaml:
scan_level: deep # lite | normal | deep
triage_level: dynamic # static | dynamic
phases: # pending | in_progress | done
build: done
discover: done # deep only
rules: done # deep only; fixed first
scan: done
approximations: in_progress # normal/deep; iterative, rescans within
triage: pending
poc: pending # dynamic triagecoverage.yaml — seeded by triage-dependencies and flipped by discover-attack-surface (deep): one entry per dependency package weighed, so you can see which libraries were drilled and which were dismissed. A pending entry is a flagged library awaiting its depth pass; the rule plan lives in rules/lib/<package-kebab>.yaml, not here:
packages:
- package: org.springframework.web.reactive.function
status: done # pending (flagged, awaiting depth) | done (drilled or dismissed)
notes: >
free-form — what was found and whyfindings/<finding_name>.yaml — created by the SARIF→finding script; verdict/notes by analyze-findings; poc/poc_script by generate-poc:
finding_name: brave-hopper
sarif_hashes: [<hash>, ...]
rule_id: java/security/sqli.yaml:sqli
verdict: pending # pending | TP | FP
notes: > # analyzer report, then triage and PoC notes
<analyzer report>
poc: pending # pending | confirmed | failed
poc_script: null # path under .opentaint/pocs/ once generate-poc writes onerules/lib/<package-kebab>.yaml — per-package rule plan for project-used sources/sinks only; description fields + sources/sinks by discover-attack-surface, test_project by create-test-project, tests_passing + rule_ids + artifact by create-rule. coverage: new ⇒ write a pattern, expand ⇒ ref the built-in plus the missing used methods:
package: org.springframework.web.reactive.function.client
dependencies: [org.springframework:spring-webflux:6.1.0]
builtin_coverage: partial # partial | none
artifact: null # create-rule
sources:
- idea: ServerRequest body/params — untrusted request data
coverage: new # new | expand
builtin: null
rule_id: null
sinks:
- vuln_class: ssrf
idea: WebClient.post/put().uri($UNTRUSTED)
coverage: expand
builtin: java/lib/generic/ssrf-sinks.yaml#java-ssrf-sink
rule_id: null
stages: # pending | in_progress | done
description: done
test_project: pending
tests_passing: pending
notes: >
free-formrules/join/<class>.yaml — one file per vuln class, written by assemble-lib-rules after the lib rules exist and verified by the main scan. A join references exactly ONE sink rule, so a class with several sinks holds several joins — one entry under joins: per sink rule, each its own file/id:
name: ssrf
sources:
- ref: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source
- ref: java/lib/spring/webflux-request-source.yaml#webflux-request-source
joins:
- rule_id: java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml:ssrf-webclient-ssrf-sink-lib-ext
artifact: .opentaint/rules/java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml
sink: { new: java/lib/spring/webclient-ssrf-sink.yaml#webclient-ssrf-sink }
- rule_id: java/security/ssrf-java-ssrf-sink-lib-ext.yaml:ssrf-java-ssrf-sink-lib-ext
artifact: .opentaint/rules/java/security/ssrf-java-ssrf-sink-lib-ext.yaml
sink: { builtin: java/lib/generic/ssrf-sinks.yaml#java-ssrf-sink }
stages: # pending | in_progress | done
written: done
verified: pending
notes: >
free-formapproximations/<package-kebab>-<kind>.yaml — created by analyze-external-methods (description + methods); <package-kebab> = the dotted package with . -> - (the YAML package: field keeps the real dotted name). The stages differ by kind:
package: com.foo
artifact: null # added once the file exists
stages:
description: done
written: pending # passthrough only (write-only, scan-verified)
# test_project / tests_passing # dataflow only (built and tested)
# dependencies: [...] # dataflow only — the GAVs its test project needs
methods:
- target: "com.foo.Wrapper#getValue"
type: passthrough # passthrough | dataflow (matches the file kind)
notes: >
free-formapproximations/skipped.yaml:
methods: # engine asks to approximate these, but they carry no taint
- "org.slf4j.Logger#info"Working directory layout
<project-root>/.opentaint/
project/ # built project model (project.yaml)
rules/java/{lib/generic,lib/spring,security}/ # custom rules
pass-through/<name>.yaml # passThrough approximation configs
dataflow/<name>/ # code-based (dataflow) approximation sources, per unit
test-projects/<name>/ # per-unit test project sources; a rule unit holds sinks/ and sources/ sub-projects, each with a test-rules/ (the generic markers + that side's test join — test-only, never loaded by the main scan)
test-compiled/<name>/ # per-unit compiled test model (a rule unit: sinks/ and sources/ models); delete once the unit's tests pass — large and unused after
test-results/<name>/ # per-unit test outputs
results/
report.sarif
dropped-external-methods.yaml # taint-killing methods → approximate
approximated-external-methods.yaml # already modeled
pocs/<finding_name>.py # PoC scripts
issues/<slug>.md # engine-issue reports
tracking/ # see Tracking layout
vulnerabilities.md # you assemble this from confirmed findingsKey constraints
- the engine models stored / second-order injection (data persisted then read back) on its own — no source, sink-side, or propagator needs to be added for the store→read path
- approximations apply only to external library methods — never an application-internal class
--passthrough-approximationsmerges with built-ins at the rule level; a provided rule overrides a built-in only when it matches one already there — it does not replace the built-in set- both approximation dir flags walk the tree recursively, so the final scan points at the parent dirs and applies every unit
--rule-iddrops every rule not named, including libraryrefs— list them all when restricting- a custom DATAFLOW approximation targeting a class that already has a built-in dataflow approximation errors at load (one class, one approximation); passThrough configs never error this way — they merge at the rule level (see above)
- a custom dataflow approximation overrides a passThrough for the same method — the passThrough→dataflow fallback when a passThrough won't converge; remove that method's passThrough config when re-planning it as dataflow, before the dataflow one is tested or scanned, to avoid override issues
Approximation iteration
Every dropped method MUST end up either modeled (a passthrough/dataflow unit) or in skipped.yaml — no exceptions, no "good enough". This loop does not finish while any method in dropped-external-methods.yaml is still unclassified. Do not stop early because the important-looking ones are done, because a batch is large, or because the remaining methods seem minor — an unmodeled method silently kills taint and hides real findings. Keep iterating until the only thing left dropped is the skip set.
Loop until stabilization:
1. analyze-external-methods — Inputs: dropped-file .opentaint/results/dropped-external-methods.yaml, tracking-dir .opentaint/tracking, <project-root>. Writes one approximations/<package>-passthrough.yaml and/or <package>-dataflow.yaml per package, plus skipped.yaml, only for methods not already in a unit. Returns one line per unit 2. Fan out per unit (capped per SKILL.md § Resource limits — these units compile and scan):
- passthrough → create-pass-through-approximation — Inputs:
<methods>from the unit,<tracking-file>, config-file.opentaint/pass-through/<name>.yaml. Write-only; setswritten+artifact. No test project - dataflow → two sequential dispatches per unit: first create-test-project (dataflow shape) produces
.opentaint/test-compiled/<name>and setstest_project: done; on its return, dispatch create-dataflow-approximation against that model (approx-src.opentaint/dataflow/<name>) — setstests_passing+artifact(test approximation runauto-applies its own fixed rule — nothing to pass)
3. Re-scan (references/scan.md) with both approximation dirs pointing at the parents (.opentaint/pass-through, .opentaint/dataflow) 4. Pass-through verify (no separate skill): the scan agent reports any method you modeled that is still in dropped-external-methods.yaml, or any config load error. Re-invoke that package's create-pass-through-approximation agent to fix (matcher / from→to / YAML), then rescan. When that agent reports the passThrough won't converge (after ~2 fixes, no clear cause), don't keep re-invoking it — a passThrough copy can't express this method's propagation. Re-plan that method as a dataflow unit (drop its passThrough config first so the two don't collide) and run it through the create-test-project → create-dataflow-approximation pipeline; the custom dataflow overrides the passThrough. A dataflow method that still drops despite passing its isolated test is an escalation case (references/escalation.md), not a re-write 5. Stabilization: keep classifying until every method in dropped-external-methods.yaml is either modeled (a passthrough/dataflow unit) or listed in skipped.yaml, and a rescan surfaces no new dropped methods — i.e. the only thing left dropped is the skip set. Otherwise feed the newly dropped methods back into step 1
Set phases.approximations: in_progress across the loop, done at stabilization.
Build
Delegate build-project. Inputs: <project-root>, model-out .opentaint/project, any build constraints (Java version, submodules, --package filters). Verify .opentaint/project/project.yaml exists, is non-empty, and — for a multi-module project — covers the expected module count, not just that the file is present. Set phases.build: done.
Discover + new rules
Triage dependencies
Delegate triage-dependencies. Inputs: <project-root>, model-dir .opentaint/project, tracking-dir .opentaint/tracking. It reads project.yaml's dependency list and writes tracking/coverage.yaml (package / status / notes) — one status: pending entry per library that could introduce a source or sink, dismissals summarised — returning one line per flagged library. Don't ask for the full list back.
Discover attack surface
Fan out discover-attack-surface in parallel, one agent per pending package in coverage.yaml (capped per SKILL.md § Resource limits). Inputs each: <package>, deps-dir .opentaint/project/dependencies, model-dir .opentaint/project, tracking-dir .opentaint/tracking. Each agent first scopes the package to functions/classes used by the project, running discover-attack-surface's bundled scripts/package-usages.sh and saving the package's method usages to tracking/usage/<package-kebab>.yaml, then reviews source/config for indirect reachability. It settles built-in coverage for that used scope (full ⇒ no unit, just coverage.yaml done; partial ⇒ expand only the missing used methods; none ⇒ plan used members from scratch). It writes the package's project-used rule plan tracking/rules/lib/<package-kebab>.yaml (new vs expand; sinks tagged by vuln class), writing no rule and running no test, then flips its coverage.yaml entry to done. Returns the sources/sinks planned.
Then a quick area cross-check over project-used boundaries only: across network, persistence, environment, serialization, rendering, naming, execution, messaging — is every boundary the project reaches through a dependency either covered by built-ins or now carrying a lib unit? If a reachable boundary has a relevant dependency but produced no unit and no clear reason, dispatch a depth pass for it. Set phases.discover: done once every coverage.yaml entry is done.
Per-package lib rules
Build the lib rules from the tracking/rules/lib/<package-kebab>.yaml units. Fan out per package (capped per SKILL.md § Resource limits — each unit compiles and scans); each unit is a two-step pipeline, dispatched one step at a time after the prior step's artifact:
1. create-test-project — Inputs: <spec> = the lib unit's sources/sinks, <project-root>, <tracking-file> .opentaint/tracking/rules/lib/<name>.yaml, test-project .opentaint/test-projects/<name>, test-compiled .opentaint/test-compiled/<name>, dependencies from the unit. Scaffolds the sinks/ and/or sources/ marker projects (test rule init, --sinks-only/--sources-only for a one-sided package), writes the generic-marker counterpart samples, compiles each sub-project. Sets test_project: done 2. create-rule — Inputs: requirements (the lib unit), test-compiled .opentaint/test-compiled/<name>, test-project .opentaint/test-projects/<name>, rules-dir .opentaint/rules, <tracking-file>, and on a re-dispatch the approximation dirs .opentaint/pass-through / .opentaint/dataflow. Writes the package's source lib rules + per-vuln-class sink lib rules into .opentaint/rules, the test joins against the markers into each test project's test-rules, and iterates test rule run per sub-project until every sample passes; sets tests_passing: done and the lib rules' rule_ids/artifact
If create-rule reports the test project drops a library method on the rule's flow, route the dropped methods through the approximation loop (references/approximations.md), then re-dispatch create-rule with the approximation dirs. If it reports non-convergence with nothing dropped, load references/escalation.md. Set phases.rules: done once every lib unit's tests_passing is done.
Assemble joins
Once the per-package lib rules are done, delegate assemble-lib-rules. Inputs: lib-units .opentaint/tracking/rules/lib, rules-dir .opentaint/rules, tracking-dir .opentaint/tracking. With every created lib rule in one view it writes the security joins — one tracking/rules/join/<class>.yaml per vuln class (listing its joins) plus one .opentaint/rules/java/security/<class>-<sink>-lib-ext.yaml per join (a join refs exactly one sink, so a class with several sinks yields several joins) — merging built-in + created sources with the new sinks, and created sources with built-in sinks (new-end combinations only). These carry no test project; the main scan verifies them (references/scan.md). One agent for the global view; fan out by vuln class only if there are many.
Escalation block
These skills write no tracking files.
1. debug-rule — Inputs: the <full-id> to trace (for an approximation, the rule whose sample routes taint through the modeled method), the <model-dir> and <results-dir> of the run that showed the problem, <dropped-file>, and the approximation dirs if the flow depends on them. Returns a diagnosis: rule fix, missing library model, or engine issue 2. Route by cause: a rule cause goes back to create-rule (references/discover-rules.md); a model cause back to the relevant create-*-approximation agent (references/approximations.md) — either to add a missing unit, or to override a built-in that debug-rule shows isn't propagating (you write the override tracking unit for the specific method, since analyze-external-methods didn't produce one); an engine cause goes to step 3 3. report-analyzer-issue — Inputs: the <diagnosis>, the existing <test-project> / <test-compiled>, the <artifact> (rule full id, or the approximation's target methods), and <open-issue> (you decide whether to also file at github.com/seqra/opentaint). It writes .opentaint/issues/<slug>.md
PoC
Run PoCs one subagent at a time, never in parallel — concurrent exploits race on shared app state and ports. For each TP finding:
- first finding: generate-poc with no
<base-url>— it builds and starts the app and returns the<base-url>it started - every later finding: pass that
<base-url>so the agent reuses the running instance
When a finding needs several services (app + DB + broker + …), have generate-poc bring them all up with one docker compose on a shared network, registered as a single compose entry — one command then tears the stack down.
Inputs each time: <finding> = the TP finding file, <project-root>, poc-dir .opentaint/pocs, and <base-url> once known. Each sets poc (confirmed/failed) + poc_script; a failed repro does not flip the triage verdict. Each PoC subagent registers any instance it starts in .opentaint/tracking/poc-servers.yaml — that registry, not memory, is what's running (so a reuse-or-start decision and teardown both survive compaction).
After all PoCs, assemble .opentaint/vulnerabilities.md from the confirmed findings yourself (subagents never write it; see SKILL.md).
Then tear down — you own this, run it directly (don't dispatch a subagent). Read poc-servers.yaml and stop every instance it lists — always terminate, no keep-vs-shutdown prompt. From each entry's kind + ref (process → kill <ref>, container → docker stop <ref>, compose → docker compose -f <ref> down), confirm its port is free, and empty the registry. Only after teardown set phases.poc: done.
Scan
Delegate run-scan. Inputs: model-dir .opentaint/project, ruleset builtin + .opentaint/rules, report .opentaint/results/report.sarif; on normal/deep also config-dir .opentaint/pass-through and approx-dir .opentaint/dataflow (both dir flags walk the tree recursively, so the parents apply every unit). Require a concise return — finding counts per rule, the methods still in dropped-external-methods.yaml that sit on a source→sink path, and any config load/parse errors — not the SARIF body. The files persist on disk for the next steps. Set phases.scan: done.
On deep runs, if the scan flags an issue with a created rule — a rule that failed to load/parse, a join that should fire but didn't, or an own rule that false-positives — dispatch create-rule to fix that rule (references/discover-rules.md), then rescan before continuing.
Triage
The scan must be stable first.
Generate finding files
Run this skill's bundled scripts/sarif-to-findings.py over .opentaint/results/report.sarif (python3 <this skill's directory>/scripts/sarif-to-findings.py .opentaint/results/report.sarif -o .opentaint/tracking/findings — the script lives in the skill directory, not the project; the project-relative paths are arguments). It writes one tracking/findings/<finding_name>.yaml per rule and is idempotent — a rescan adds new result hashes and resets changed findings to pending. This is a deterministic script with no context cost, so run it yourself, not via a subagent.
Classify — never in main
Fan out analyze-findings, one subagent per finding file (the rule bundle is the bucket). Inputs: <findings> = the finding file, report .opentaint/results/report.sarif. The agent reads each result's codeFlows[], splits the bundle into distinct logical findings, and sets verdict + notes on each. Return: one line per logical finding (name, verdict, one-clause reason). Assign no verdicts yourself. Set phases.triage: done.
#!/usr/bin/env python3
"""
sarif-to-findings.py — turn an OpenTaint SARIF report into per-rule finding
tracking files under .opentaint/tracking/findings/.
One file per rule_id, bundling that rule's result hashes into sarif_hashes.
Grouping is trivial (by rule_id) — no clustering. The triage skill
(analyze-findings) later splits a rule's bundle into distinct logical findings.
Idempotent: re-running after a re-scan adds only result hashes not already
present in any of that rule's finding files, resets the touched file's verdict
to `pending`, and leaves existing verdict/notes/poc and triage splits intact.
SARIF assumptions — adjust the two helpers below if the real OpenTaint SARIF
differs:
- result.ruleId holds the full rule id (e.g. java/security/sqli.yaml:sqli)
- a stable per-result hash comes from result.fingerprints / partialFingerprints
when present, else is computed from ruleId + locations + code-flow locations
- result.message.text seeds the analyzer report in `notes`
"""
import argparse
import glob
import hashlib
import json
import re
from pathlib import Path
ADJ = ["brave", "calm", "eager", "fuzzy", "gentle", "jolly", "keen", "lucid",
"merry", "noble", "proud", "quiet", "rapid", "sly", "tidy", "vivid",
"witty", "zesty", "amber", "bold"]
NOUN = ["hopper", "eagle", "otter", "falcon", "maple", "comet", "harbor",
"willow", "pixel", "river", "ember", "cobra", "lotus", "raven",
"quartz", "badger", "cedar", "drake", "finch", "gull"]
def docker_name(seed, taken):
"""Stable adjective-noun slug from the rule id; suffixed on collision."""
h = int(hashlib.sha1(seed.encode()).hexdigest(), 16)
base = f"{ADJ[h % len(ADJ)]}-{NOUN[(h // len(ADJ)) % len(NOUN)]}"
name, n = base, 2
while name in taken:
name, n = f"{base}-{n}", n + 1
return name
_FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash")
def result_hash(res):
fp = res.get("fingerprints") or res.get("partialFingerprints")
if isinstance(fp, dict) and fp:
for pref in _FP_PREFERENCE:
for k, v in fp.items():
if k.startswith(pref):
return str(v)[:16]
return str(sorted(fp.values())[0])[:16]
parts = [res.get("ruleId", "")]
locs = list(res.get("locations", []))
for cf in res.get("codeFlows", []):
for tf in cf.get("threadFlows", []):
locs += [st.get("location", {}) for st in tf.get("locations", [])]
for loc in locs:
pl = loc.get("physicalLocation", {})
parts.append(pl.get("artifactLocation", {}).get("uri", ""))
parts.append(json.dumps(pl.get("region", {}), sort_keys=True))
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
def scan_results(sarif):
"""rule_id -> {hash: message}"""
out = {}
for run in sarif.get("runs") or []:
for res in run.get("results") or []:
rid = res.get("ruleId") or "unknown"
msg = (res.get("message", {}) or {}).get("text", "").strip()
out.setdefault(rid, {})[result_hash(res)] = msg
return out
NAME_RE = re.compile(r'^finding_name:\s*(.+?)\s*$', re.M)
RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M)
HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M)
HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M)
def parse_hashes(text):
"""Hashes from either flow style ([a, b]) or block style (- a / - b)."""
m = HASHES_RE.search(text)
if m:
return [h.strip() for h in m.group(1).split(",") if h.strip()]
m = HASHES_BLOCK_RE.search(text)
if m:
return [ln.strip().lstrip("-").strip()
for ln in m.group(1).splitlines() if ln.strip().lstrip("-").strip()]
return []
def replace_hashes(text, merged):
"""Rewrite the sarif_hashes entry (either style) as a flow list; if the key
is missing entirely, prepend it so merged hashes are never silently lost."""
line = "sarif_hashes: " + fmt_list(merged)
if HASHES_RE.search(text):
return HASHES_RE.sub(lambda m: line, text, count=1)
if HASHES_BLOCK_RE.search(text):
return HASHES_BLOCK_RE.sub(line + "\n", text, count=1)
return line + "\n" + text
def parse_existing(text):
name = NAME_RE.search(text)
rid = RULE_RE.search(text)
return (name.group(1) if name else None,
rid.group(1) if rid else None,
parse_hashes(text))
def fmt_list(hashes):
return "[" + ", ".join(hashes) + "]"
def new_file_text(name, rid, hashes, notes):
body = "\n".join(" " + ln for ln in (notes or "(no analyzer message)").splitlines())
return (f"finding_name: {name}\n"
f"sarif_hashes: {fmt_list(hashes)}\n"
f"rule_id: {rid}\n"
f"verdict: pending\n"
f"notes: >\n{body}\n"
f"poc: pending\n"
f"poc_script: null\n")
def main():
ap = argparse.ArgumentParser(
description="SARIF -> per-rule finding tracking files (idempotent)")
ap.add_argument("sarif", help="path to report.sarif")
ap.add_argument("-o", "--out", default=".opentaint/tracking/findings",
help="findings dir (default: .opentaint/tracking/findings)")
args = ap.parse_args()
by_rule = scan_results(json.loads(Path(args.sarif).read_text(encoding="utf-8")))
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
existing = {}
taken = set()
for p in sorted(glob.glob(str(out / "*.yaml"))):
name, rid, hashes = parse_existing(Path(p).read_text(encoding="utf-8"))
if name:
taken.add(name)
if rid:
existing.setdefault(rid, []).append((Path(p), hashes))
created = updated = unchanged = 0
for rid, hashmap in sorted(by_rule.items()):
scanned = set(hashmap)
files = existing.get(rid)
if not files:
name = docker_name(rid, taken)
taken.add(name)
notes = "\n".join(sorted({m for m in hashmap.values() if m}))
(out / f"{name}.yaml").write_text(
new_file_text(name, rid, sorted(scanned), notes), encoding="utf-8")
created += 1
continue
already = set().union(*(set(h) for _, h in files))
new = sorted(scanned - already)
if not new:
unchanged += 1
continue
path, hashes = files[0]
merged = sorted(set(hashes) | set(new))
text = path.read_text(encoding="utf-8")
text = replace_hashes(text, merged)
text = re.sub(r'^verdict:\s*.+$', "verdict: pending", text, count=1, flags=re.M)
path.write_text(text, encoding="utf-8")
updated += 1
print(f"findings: {created} created, {updated} updated, {unchanged} unchanged "
f"({len(by_rule)} rules in scan)")
if __name__ == "__main__":
main()