refuta 0.0.2__tar.gz → 0.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {refuta-0.0.2 → refuta-0.0.3}/PKG-INFO +32 -14
- {refuta-0.0.2 → refuta-0.0.3}/README.md +31 -13
- {refuta-0.0.2 → refuta-0.0.3}/pyproject.toml +1 -1
- {refuta-0.0.2 → refuta-0.0.3}/refuta/__init__.py +1 -1
- {refuta-0.0.2 → refuta-0.0.3}/refuta/cli.py +126 -37
- {refuta-0.0.2 → refuta-0.0.3}/refuta/runners.py +165 -5
- {refuta-0.0.2 → refuta-0.0.3}/refuta/schemas.py +1 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta/store.py +5 -1
- {refuta-0.0.2 → refuta-0.0.3}/refuta/verifier.py +35 -1
- {refuta-0.0.2 → refuta-0.0.3}/refuta.egg-info/PKG-INFO +32 -14
- {refuta-0.0.2 → refuta-0.0.3}/refuta.egg-info/SOURCES.txt +1 -0
- refuta-0.0.3/tests/test_local_diagnostics.py +481 -0
- {refuta-0.0.2 → refuta-0.0.3}/tests/test_refuta.py +182 -2
- {refuta-0.0.2 → refuta-0.0.3}/LICENSE +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta/loops.py +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta/publisher.py +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta/repo_provider.py +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta/sandbox.py +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta/scanners.py +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta.egg-info/dependency_links.txt +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta.egg-info/entry_points.txt +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/refuta.egg-info/top_level.txt +0 -0
- {refuta-0.0.2 → refuta-0.0.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refuta
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Refuta — Refutation-driven Agentic Code Security Platform for DGX Spark
|
|
5
5
|
Author: Refuta contributors
|
|
6
6
|
Author-email: "DAPADA Inc." <contact@dapada.co.kr>
|
|
@@ -67,21 +67,24 @@ refuta/
|
|
|
67
67
|
├── expected.json # Ground truth used only by the independent evaluator (`refuta eval`)
|
|
68
68
|
├── results/ # fixture / qwen / qwen-cached run outputs and metrics
|
|
69
69
|
└── tests/
|
|
70
|
-
|
|
70
|
+
├── test_refuta.py # Unit, integration, and security-boundary regression tests
|
|
71
|
+
└── test_local_diagnostics.py # Local-response failure and diagnostic privacy regressions
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
---
|
|
74
75
|
|
|
75
76
|
## 3. Benchmark and verification results
|
|
76
77
|
|
|
77
|
-
On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`),
|
|
78
|
+
On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), the recorded run (`--samples 3`) found **4/4 real vulnerabilities (including 1 `fixed_verified` through bwrap-isolated patch verification)**, **refuted 2/2 safe cases protected by upstream guards (`refuted`, 0 false positives)**, with **0 quote or line-number evidence errors**, **0 raw secret leaks**, and the **untrusted `AGENTS.md` quarantined (`quarantine_verified: true`)**. At recording time, all **22 unit and regression tests passed** (`1.46s`).
|
|
78
79
|
|
|
79
80
|
| Mode | Vulnerabilities found | Safe cases refuted | False positives | Evidence errors | Secrets exposed | Model calls | Time |
|
|
80
81
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
81
82
|
| Baseline scanners only (reference) | 4/4 | 0/2 | **2** | — | 0 | 0 | 0.005 s |
|
|
82
|
-
| Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.
|
|
83
|
-
| Real Qwen3.6 audit (`local`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 |
|
|
84
|
-
| Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (
|
|
83
|
+
| Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.354 s |
|
|
84
|
+
| Real Qwen3.6 audit (`local`, `--samples 3`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 15 | 114.173 s |
|
|
85
|
+
| Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (15 cache hits) | 0.296 s |
|
|
86
|
+
|
|
87
|
+
The `local` row is one recorded run. vLLM output is not bit-exact at temperature 0, so a single investigation can flip: on 2026-10-26, single-sample runs (`--samples 1`) matched the ground truth in 6 of 8 runs, and both failures left `CASE-02-GUARDED-ADMIN` `confirmed` instead of `refuted` (1 false positive). With `--samples 3` (strict-majority vote over independently adjudicated investigations), 10 of 11 runs matched; the other had no majority on CASE-02 and escalated it to `needs_review`. No run in either mode missed a vulnerability, produced an evidence error, or exposed a secret.
|
|
85
88
|
|
|
86
89
|
- Real Qwen run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen/report.md), [structured result](https://github.com/memtomem/refuta/blob/main/results/qwen/result.json), [SARIF](https://github.com/memtomem/refuta/blob/main/results/qwen/sarif.json), [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen/metrics.json)
|
|
87
90
|
- Cached re-run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/report.md) and [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/metrics.json)
|
|
@@ -94,8 +97,8 @@ On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), Refuta found **4/
|
|
|
94
97
|
Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip install refuta`, use `refuta` instead of `.venv/bin/refuta`.
|
|
95
98
|
|
|
96
99
|
```bash
|
|
97
|
-
# 1) All unit and integration tests
|
|
98
|
-
.venv/bin/pytest -q
|
|
100
|
+
# 1) All unit and integration tests
|
|
101
|
+
.venv/bin/pytest -q
|
|
99
102
|
|
|
100
103
|
# 2) Static, secret, and authorization scanners only
|
|
101
104
|
.venv/bin/refuta scan \
|
|
@@ -111,6 +114,7 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
|
|
|
111
114
|
.venv/bin/refuta audit \
|
|
112
115
|
--repo sample/repo \
|
|
113
116
|
--runner local \
|
|
117
|
+
--samples 3 \
|
|
114
118
|
--patches patches/manifest.json \
|
|
115
119
|
--out results/qwen
|
|
116
120
|
|
|
@@ -123,11 +127,23 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
|
|
|
123
127
|
# Offline, no model needed: --runner fixture --fixture fixtures/responses.json
|
|
124
128
|
```
|
|
125
129
|
|
|
126
|
-
`audit`
|
|
130
|
+
`audit` defaults to 3 independent investigations per candidate for `--runner local`, with a strict-majority vote; other runners default to 1. Set `--samples N` to override either default (`N >= 1`). On an uncached audit, the local default uses about three times as many model calls as `--samples 1`. Existing sample-0 cache entries are reused; only missing samples require new calls.
|
|
131
|
+
|
|
132
|
+
Local investigations run sequentially, and remaining samples are still attempted after a failure. The HTTP timeout (`RepoPolicy.max_timeout_sec`, default 120 seconds) applies to each request rather than setting a deadline for the entire audit. A stalled endpoint can therefore cause three consecutive timeout waits per candidate with the local default. Use `--samples 1` to reduce the number of requests when troubleshooting the endpoint.
|
|
133
|
+
|
|
134
|
+
`audit` exits 0 when the run completes and 2 when any investigation sample fails (`partial`), even if the other samples reach a strict majority and decide the finding. Check `result.json.errors` for failed samples and each finding's `sample_statuses` for the vote. `eval` exits 1 when the result does not match the ground truth.
|
|
135
|
+
|
|
136
|
+
### Local-response diagnostics
|
|
137
|
+
|
|
138
|
+
When a local model response has invalid UTF-8, malformed JSON, an invalid response envelope, or investigation content that is not a JSON object, the audit automatically saves a diagnostic under `<out>/diagnostics/<run_id>/<candidate_id>-sample<N>.json`. The sample number is zero-based. Each entry in `result.json.errors` points to its diagnostic; the same error list is saved in the audit's SQLite record. Failed responses are not cached. Successful responses create no diagnostic files, and a diagnostic write failure is recorded while the audit continues as `partial`.
|
|
139
|
+
|
|
140
|
+
Diagnostics contain the model, failure stage, response text, completion reason and token usage when available, and candidate/rule/sample identity. JSON errors also include the exact parser input after redaction, with character position and one-based line/column coordinates. Secrets in that parser input use equal-length `*` masks so the coordinates remain accurate after code-fence or reasoning-block removal and fallback extraction. Other diagnostic text uses the existing hashed secret markers, including for tokens split by Unicode escapes inside nested JSON or following short escapes such as `\n`. Diagnostic masking conservatively recognizes token prefixes within surrounding text and extends masking beyond scanner length caps; it can redact more text than the source scanner. The serialized diagnostic is checked again before writing.
|
|
141
|
+
|
|
142
|
+
Diagnostic directories are restricted to the owner (`0700`), files use `0600`, and symlink directories are rejected. Diagnostics are ignored by Git. Redaction covers the existing GitHub-token, `sk-`, and AWS-access-key families; truncated tokens or tokens interrupted by invalid encoding may not match. A field with unresolved escape obfuscation is replaced entirely with a redaction marker; parser input instead uses stars while preserving its length and line breaks. Diagnostics can still contain source code or other sensitive values and should be handled as sensitively as the audited snapshot. These permissions apply to diagnostics, not to the audit's other output files. HTTP errors and timeouts retain their existing error handling without response-body capture.
|
|
127
143
|
|
|
128
144
|
---
|
|
129
145
|
|
|
130
|
-
## 5. Security and verification boundaries covered by tests
|
|
146
|
+
## 5. Security and verification boundaries covered by tests
|
|
131
147
|
|
|
132
148
|
1. **Untrusted instruction-file quarantine and credential stripping (`RepoProvider`)**: when a snapshot is created, tokens in `.git/config` are removed and `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.codex/`, and `.github/copilot-instructions.md` are quarantined automatically.
|
|
133
149
|
2. **Loop 1 diff scope (`compute_diff_scope`)**: computes exactly which files were added, modified, or deleted between two snapshots.
|
|
@@ -136,19 +152,21 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
|
|
|
136
152
|
5. **Deterministic evidence verifier (`verifier.py`)**: checks that the `vulnerable_quotes` and `guard_quotes` the model cites exist verbatim in the original files, and computes line numbers itself. Missing files, path escapes (`../`), and altered code quotes are rejected immediately as `invalid_evidence`.
|
|
137
153
|
6. **Strict refutation**: even if the model claims `is_refuted: true`, the refutation is rejected (`needs_review`) unless it presents guard-code quotes (`guard_quotes`) verified against the snapshot files.
|
|
138
154
|
7. **Option D cross-model review (`adjudicate_cross_review`)**: adjudicates agreement and disagreement between the primary analyst and a second refuting reviewer, switching to `refuted` only when the refuting side presents a valid guard-code quote.
|
|
139
|
-
8.
|
|
155
|
+
8. **Sample voting (`adjudicate_samples`, `audit --samples N`)**: runs N independent investigations per candidate, adjudicates each one separately, and keeps a status only when a strict majority reaches it; anything else escalates to `needs_review`. A sample counts as `refuted` only if its guard quote verified, so a single-sample refutation without evidence cannot win the vote. Per-sample statuses are kept in `sample_statuses`, and each sample has its own cache entry.
|
|
156
|
+
9. **`bwrap` (bubblewrap) isolated patch and scanner runner (`sandbox.py`)**:
|
|
140
157
|
- Isolates test and scanner processes with `/usr/bin/bwrap` (`--unshare-all --die-with-parent --new-session --ro-bind / / --tmpfs $HOME --ro-bind <venv> <venv> --ro-bind <base interpreter under $HOME, e.g. uv Python> --tmpfs /tmp --ro-bind <work_repo> /tmp/work --clearenv`).
|
|
141
158
|
- Demonstrated inside the sandbox: (1) `$HOME` is not accessible, (2) the source under `/tmp/work` cannot be written (`Read-only file system`), (3) neither the host's `127.0.0.1:8000` nor external networks are reachable (`Network is unreachable`).
|
|
142
159
|
- A finding is promoted to `fixed_verified` only when all four conditions hold: (1) test files (`tests/`) are unmodified, (2) the security test fails before the patch (the vulnerability reproduces), (3) the security test passes after the patch, (4) the functional regression tests pass after the patch.
|
|
143
160
|
- Execution is blocked when an external binary or model weight does not match its pinned SHA256 digest (`verify_artifact_sha256`).
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
10. **Default deny for sending code off-host, and a memory guard (`RunnerRouter`)**: with `local_only=True` (the default), `ClaudeRunner` and external `CodexRunner` calls are blocked; local inference is refused with `InsufficientMemoryError` when `MemAvailable` is below the threshold (`min_mem_available_gb`, default 8).
|
|
162
|
+
11. **Accepted-risk expiry and invalidation on file change (`RefutaStore`)**: an `accepted_risk` exception is released and the finding restored to `confirmed` when the exception expires or the SHA256 of the target file changes.
|
|
163
|
+
12. **Continuous security loops and publishing safeguards (`loops.py`, `publisher.py`)**:
|
|
147
164
|
- **Loop 3 (new-CVE response)**: queries the deployment/SBOM graph to identify affected services, environments, and runtime images immediately, without any code change.
|
|
148
165
|
- **Loop 4 (attack-surface reduction)**: automatically identifies unauthenticated public routes, debug endpoints, and privileged admin paths.
|
|
149
166
|
- **Loop 6 (post-deploy re-verification)**: fails while a pre-patch commit is still running in production, and completes only once the patched commit is deployed and passes its health check.
|
|
150
167
|
- **Quiet-window guard (`QuietWindow`, `is_in_quiet_window`)**: tells heavy scheduled audits when to yield the host, e.g. to another model server sharing the GPU. Windows are configured by the caller (time zone, start/end, weekdays); the test uses a DGX Spark schedule (Mon/Thu 03:50–09:00 KST, daily 06:50–07:30 KST) as its example.
|
|
151
168
|
- **Publishing safeguards**: GitHub webhook HMAC-SHA256 signature verification, blocking publication on a commit SHA mismatch (`StaleCommitPublishError`), and automatic redaction of secret tokens in outbound text.
|
|
169
|
+
13. **Local-response diagnostics (`tests/test_local_diagnostics.py`)**: redacts supported tokens (including overlapping patterns and Unicode escapes), preserves parser coordinates, separates runs, restricts file permissions, rejects symlink directories, and keeps partial audits usable when diagnostic writes fail. Non-object local responses are rejected before caching, and malformed legacy cache entries are re-investigated.
|
|
152
170
|
|
|
153
171
|
---
|
|
154
172
|
|
|
@@ -41,21 +41,24 @@ refuta/
|
|
|
41
41
|
├── expected.json # Ground truth used only by the independent evaluator (`refuta eval`)
|
|
42
42
|
├── results/ # fixture / qwen / qwen-cached run outputs and metrics
|
|
43
43
|
└── tests/
|
|
44
|
-
|
|
44
|
+
├── test_refuta.py # Unit, integration, and security-boundary regression tests
|
|
45
|
+
└── test_local_diagnostics.py # Local-response failure and diagnostic privacy regressions
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
---
|
|
48
49
|
|
|
49
50
|
## 3. Benchmark and verification results
|
|
50
51
|
|
|
51
|
-
On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`),
|
|
52
|
+
On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), the recorded run (`--samples 3`) found **4/4 real vulnerabilities (including 1 `fixed_verified` through bwrap-isolated patch verification)**, **refuted 2/2 safe cases protected by upstream guards (`refuted`, 0 false positives)**, with **0 quote or line-number evidence errors**, **0 raw secret leaks**, and the **untrusted `AGENTS.md` quarantined (`quarantine_verified: true`)**. At recording time, all **22 unit and regression tests passed** (`1.46s`).
|
|
52
53
|
|
|
53
54
|
| Mode | Vulnerabilities found | Safe cases refuted | False positives | Evidence errors | Secrets exposed | Model calls | Time |
|
|
54
55
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
55
56
|
| Baseline scanners only (reference) | 4/4 | 0/2 | **2** | — | 0 | 0 | 0.005 s |
|
|
56
|
-
| Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.
|
|
57
|
-
| Real Qwen3.6 audit (`local`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 |
|
|
58
|
-
| Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (
|
|
57
|
+
| Canned responses (`fixture`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 5 (stub) | 0.354 s |
|
|
58
|
+
| Real Qwen3.6 audit (`local`, `--samples 3`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 15 | 114.173 s |
|
|
59
|
+
| Re-run from cached investigations (`qwen-cached`) | 4/4 (`fixed_verified` 1) | 2/2 | **0** | 0 | 0 | 0 (15 cache hits) | 0.296 s |
|
|
60
|
+
|
|
61
|
+
The `local` row is one recorded run. vLLM output is not bit-exact at temperature 0, so a single investigation can flip: on 2026-10-26, single-sample runs (`--samples 1`) matched the ground truth in 6 of 8 runs, and both failures left `CASE-02-GUARDED-ADMIN` `confirmed` instead of `refuted` (1 false positive). With `--samples 3` (strict-majority vote over independently adjudicated investigations), 10 of 11 runs matched; the other had no majority on CASE-02 and escalated it to `needs_review`. No run in either mode missed a vulnerability, produced an evidence error, or exposed a secret.
|
|
59
62
|
|
|
60
63
|
- Real Qwen run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen/report.md), [structured result](https://github.com/memtomem/refuta/blob/main/results/qwen/result.json), [SARIF](https://github.com/memtomem/refuta/blob/main/results/qwen/sarif.json), [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen/metrics.json)
|
|
61
64
|
- Cached re-run: [report](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/report.md) and [metrics](https://github.com/memtomem/refuta/blob/main/results/qwen-cached/metrics.json)
|
|
@@ -68,8 +71,8 @@ On a real host vLLM (`nvidia/Qwen3.6-35B-A3B-NVFP4`, `:8000`), Refuta found **4/
|
|
|
68
71
|
Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip install refuta`, use `refuta` instead of `.venv/bin/refuta`.
|
|
69
72
|
|
|
70
73
|
```bash
|
|
71
|
-
# 1) All unit and integration tests
|
|
72
|
-
.venv/bin/pytest -q
|
|
74
|
+
# 1) All unit and integration tests
|
|
75
|
+
.venv/bin/pytest -q
|
|
73
76
|
|
|
74
77
|
# 2) Static, secret, and authorization scanners only
|
|
75
78
|
.venv/bin/refuta scan \
|
|
@@ -85,6 +88,7 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
|
|
|
85
88
|
.venv/bin/refuta audit \
|
|
86
89
|
--repo sample/repo \
|
|
87
90
|
--runner local \
|
|
91
|
+
--samples 3 \
|
|
88
92
|
--patches patches/manifest.json \
|
|
89
93
|
--out results/qwen
|
|
90
94
|
|
|
@@ -97,11 +101,23 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
|
|
|
97
101
|
# Offline, no model needed: --runner fixture --fixture fixtures/responses.json
|
|
98
102
|
```
|
|
99
103
|
|
|
100
|
-
`audit`
|
|
104
|
+
`audit` defaults to 3 independent investigations per candidate for `--runner local`, with a strict-majority vote; other runners default to 1. Set `--samples N` to override either default (`N >= 1`). On an uncached audit, the local default uses about three times as many model calls as `--samples 1`. Existing sample-0 cache entries are reused; only missing samples require new calls.
|
|
105
|
+
|
|
106
|
+
Local investigations run sequentially, and remaining samples are still attempted after a failure. The HTTP timeout (`RepoPolicy.max_timeout_sec`, default 120 seconds) applies to each request rather than setting a deadline for the entire audit. A stalled endpoint can therefore cause three consecutive timeout waits per candidate with the local default. Use `--samples 1` to reduce the number of requests when troubleshooting the endpoint.
|
|
107
|
+
|
|
108
|
+
`audit` exits 0 when the run completes and 2 when any investigation sample fails (`partial`), even if the other samples reach a strict majority and decide the finding. Check `result.json.errors` for failed samples and each finding's `sample_statuses` for the vote. `eval` exits 1 when the result does not match the ground truth.
|
|
109
|
+
|
|
110
|
+
### Local-response diagnostics
|
|
111
|
+
|
|
112
|
+
When a local model response has invalid UTF-8, malformed JSON, an invalid response envelope, or investigation content that is not a JSON object, the audit automatically saves a diagnostic under `<out>/diagnostics/<run_id>/<candidate_id>-sample<N>.json`. The sample number is zero-based. Each entry in `result.json.errors` points to its diagnostic; the same error list is saved in the audit's SQLite record. Failed responses are not cached. Successful responses create no diagnostic files, and a diagnostic write failure is recorded while the audit continues as `partial`.
|
|
113
|
+
|
|
114
|
+
Diagnostics contain the model, failure stage, response text, completion reason and token usage when available, and candidate/rule/sample identity. JSON errors also include the exact parser input after redaction, with character position and one-based line/column coordinates. Secrets in that parser input use equal-length `*` masks so the coordinates remain accurate after code-fence or reasoning-block removal and fallback extraction. Other diagnostic text uses the existing hashed secret markers, including for tokens split by Unicode escapes inside nested JSON or following short escapes such as `\n`. Diagnostic masking conservatively recognizes token prefixes within surrounding text and extends masking beyond scanner length caps; it can redact more text than the source scanner. The serialized diagnostic is checked again before writing.
|
|
115
|
+
|
|
116
|
+
Diagnostic directories are restricted to the owner (`0700`), files use `0600`, and symlink directories are rejected. Diagnostics are ignored by Git. Redaction covers the existing GitHub-token, `sk-`, and AWS-access-key families; truncated tokens or tokens interrupted by invalid encoding may not match. A field with unresolved escape obfuscation is replaced entirely with a redaction marker; parser input instead uses stars while preserving its length and line breaks. Diagnostics can still contain source code or other sensitive values and should be handled as sensitively as the audited snapshot. These permissions apply to diagnostics, not to the audit's other output files. HTTP errors and timeouts retain their existing error handling without response-body capture.
|
|
101
117
|
|
|
102
118
|
---
|
|
103
119
|
|
|
104
|
-
## 5. Security and verification boundaries covered by tests
|
|
120
|
+
## 5. Security and verification boundaries covered by tests
|
|
105
121
|
|
|
106
122
|
1. **Untrusted instruction-file quarantine and credential stripping (`RepoProvider`)**: when a snapshot is created, tokens in `.git/config` are removed and `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.codex/`, and `.github/copilot-instructions.md` are quarantined automatically.
|
|
107
123
|
2. **Loop 1 diff scope (`compute_diff_scope`)**: computes exactly which files were added, modified, or deleted between two snapshots.
|
|
@@ -110,19 +126,21 @@ Run from a source checkout (`uv sync --group dev` creates `.venv`). After `pip i
|
|
|
110
126
|
5. **Deterministic evidence verifier (`verifier.py`)**: checks that the `vulnerable_quotes` and `guard_quotes` the model cites exist verbatim in the original files, and computes line numbers itself. Missing files, path escapes (`../`), and altered code quotes are rejected immediately as `invalid_evidence`.
|
|
111
127
|
6. **Strict refutation**: even if the model claims `is_refuted: true`, the refutation is rejected (`needs_review`) unless it presents guard-code quotes (`guard_quotes`) verified against the snapshot files.
|
|
112
128
|
7. **Option D cross-model review (`adjudicate_cross_review`)**: adjudicates agreement and disagreement between the primary analyst and a second refuting reviewer, switching to `refuted` only when the refuting side presents a valid guard-code quote.
|
|
113
|
-
8.
|
|
129
|
+
8. **Sample voting (`adjudicate_samples`, `audit --samples N`)**: runs N independent investigations per candidate, adjudicates each one separately, and keeps a status only when a strict majority reaches it; anything else escalates to `needs_review`. A sample counts as `refuted` only if its guard quote verified, so a single-sample refutation without evidence cannot win the vote. Per-sample statuses are kept in `sample_statuses`, and each sample has its own cache entry.
|
|
130
|
+
9. **`bwrap` (bubblewrap) isolated patch and scanner runner (`sandbox.py`)**:
|
|
114
131
|
- Isolates test and scanner processes with `/usr/bin/bwrap` (`--unshare-all --die-with-parent --new-session --ro-bind / / --tmpfs $HOME --ro-bind <venv> <venv> --ro-bind <base interpreter under $HOME, e.g. uv Python> --tmpfs /tmp --ro-bind <work_repo> /tmp/work --clearenv`).
|
|
115
132
|
- Demonstrated inside the sandbox: (1) `$HOME` is not accessible, (2) the source under `/tmp/work` cannot be written (`Read-only file system`), (3) neither the host's `127.0.0.1:8000` nor external networks are reachable (`Network is unreachable`).
|
|
116
133
|
- A finding is promoted to `fixed_verified` only when all four conditions hold: (1) test files (`tests/`) are unmodified, (2) the security test fails before the patch (the vulnerability reproduces), (3) the security test passes after the patch, (4) the functional regression tests pass after the patch.
|
|
117
134
|
- Execution is blocked when an external binary or model weight does not match its pinned SHA256 digest (`verify_artifact_sha256`).
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
10. **Default deny for sending code off-host, and a memory guard (`RunnerRouter`)**: with `local_only=True` (the default), `ClaudeRunner` and external `CodexRunner` calls are blocked; local inference is refused with `InsufficientMemoryError` when `MemAvailable` is below the threshold (`min_mem_available_gb`, default 8).
|
|
136
|
+
11. **Accepted-risk expiry and invalidation on file change (`RefutaStore`)**: an `accepted_risk` exception is released and the finding restored to `confirmed` when the exception expires or the SHA256 of the target file changes.
|
|
137
|
+
12. **Continuous security loops and publishing safeguards (`loops.py`, `publisher.py`)**:
|
|
121
138
|
- **Loop 3 (new-CVE response)**: queries the deployment/SBOM graph to identify affected services, environments, and runtime images immediately, without any code change.
|
|
122
139
|
- **Loop 4 (attack-surface reduction)**: automatically identifies unauthenticated public routes, debug endpoints, and privileged admin paths.
|
|
123
140
|
- **Loop 6 (post-deploy re-verification)**: fails while a pre-patch commit is still running in production, and completes only once the patched commit is deployed and passes its health check.
|
|
124
141
|
- **Quiet-window guard (`QuietWindow`, `is_in_quiet_window`)**: tells heavy scheduled audits when to yield the host, e.g. to another model server sharing the GPU. Windows are configured by the caller (time zone, start/end, weekdays); the test uses a DGX Spark schedule (Mon/Thu 03:50–09:00 KST, daily 06:50–07:30 KST) as its example.
|
|
125
142
|
- **Publishing safeguards**: GitHub webhook HMAC-SHA256 signature verification, blocking publication on a commit SHA mismatch (`StaleCommitPublishError`), and automatic redaction of secret tokens in outbound text.
|
|
143
|
+
13. **Local-response diagnostics (`tests/test_local_diagnostics.py`)**: redacts supported tokens (including overlapping patterns and Unicode escapes), preserves parser coordinates, separates runs, restricts file permissions, rejects symlink directories, and keeps partial audits usable when diagnostic writes fail. Non-object local responses are rejected before caching, and malformed legacy cache entries are re-investigated.
|
|
126
144
|
|
|
127
145
|
---
|
|
128
146
|
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
from contextlib import ExitStack
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
import hashlib
|
|
8
9
|
import json
|
|
10
|
+
import os
|
|
9
11
|
from pathlib import Path
|
|
12
|
+
import secrets
|
|
10
13
|
import sys
|
|
11
14
|
import time
|
|
12
15
|
from typing import Any
|
|
@@ -19,12 +22,15 @@ from .runners import (
|
|
|
19
22
|
CodexRunner,
|
|
20
23
|
FixtureRunner,
|
|
21
24
|
LocalRunner,
|
|
25
|
+
LocalResponseError,
|
|
26
|
+
PROMPT_VERSION,
|
|
22
27
|
RunnerRouter,
|
|
28
|
+
mask_diagnostic_text,
|
|
23
29
|
)
|
|
24
30
|
from .scanners import SECRET_PATTERNS, scan_snapshot
|
|
25
31
|
from .schemas import AuditRunResult, EvidenceQuote, Finding, FindingStatus, RepoPolicy
|
|
26
32
|
from .store import RefutaStore
|
|
27
|
-
from .verifier import adjudicate_finding, verify_patch_in_sandbox, verify_quote_in_snapshot
|
|
33
|
+
from .verifier import adjudicate_finding, adjudicate_samples, verify_patch_in_sandbox, verify_quote_in_snapshot
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
def render_markdown_report(result: AuditRunResult) -> str:
|
|
@@ -138,6 +144,55 @@ def render_sarif_report(result: AuditRunResult) -> dict[str, Any]:
|
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
|
|
147
|
+
def _write_local_response_diagnostic(
|
|
148
|
+
out_path: Path,
|
|
149
|
+
run_id: str,
|
|
150
|
+
candidate_id: str,
|
|
151
|
+
rule_id: str,
|
|
152
|
+
sample_index: int,
|
|
153
|
+
error: LocalResponseError,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Write a private diagnostic through directory descriptors without following symlinks."""
|
|
156
|
+
payload = {
|
|
157
|
+
**error.diagnostic,
|
|
158
|
+
"run_id": run_id,
|
|
159
|
+
"candidate_id": candidate_id,
|
|
160
|
+
"rule_id": rule_id,
|
|
161
|
+
"sample_index": sample_index,
|
|
162
|
+
}
|
|
163
|
+
serialized = json.dumps(payload, indent=2)
|
|
164
|
+
# Validate the serialized file without changing the saved parse coordinates.
|
|
165
|
+
if mask_diagnostic_text(serialized) != serialized:
|
|
166
|
+
raise ValueError("Supported secret pattern remained in diagnostic")
|
|
167
|
+
diagnostic_root = out_path / "diagnostics"
|
|
168
|
+
diagnostic_root.mkdir(mode=0o700, exist_ok=True)
|
|
169
|
+
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
170
|
+
filename = f"{candidate_id}-sample{sample_index}.json"
|
|
171
|
+
with ExitStack() as stack:
|
|
172
|
+
root_fd = os.open(diagnostic_root, directory_flags)
|
|
173
|
+
stack.callback(os.close, root_fd)
|
|
174
|
+
os.fchmod(root_fd, 0o700)
|
|
175
|
+
try:
|
|
176
|
+
os.mkdir(run_id, mode=0o700, dir_fd=root_fd)
|
|
177
|
+
except FileExistsError:
|
|
178
|
+
pass
|
|
179
|
+
run_fd = os.open(run_id, directory_flags, dir_fd=root_fd)
|
|
180
|
+
stack.callback(os.close, run_fd)
|
|
181
|
+
os.fchmod(run_fd, 0o700)
|
|
182
|
+
fd = os.open(
|
|
183
|
+
filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
184
|
+
0o600, dir_fd=run_fd,
|
|
185
|
+
)
|
|
186
|
+
stack.callback(os.close, fd)
|
|
187
|
+
try:
|
|
188
|
+
with os.fdopen(fd, "w", encoding="utf-8", closefd=False) as diagnostic_file:
|
|
189
|
+
diagnostic_file.write(serialized)
|
|
190
|
+
except OSError:
|
|
191
|
+
os.unlink(filename, dir_fd=run_fd)
|
|
192
|
+
raise
|
|
193
|
+
return (Path("diagnostics") / run_id / filename).as_posix()
|
|
194
|
+
|
|
195
|
+
|
|
141
196
|
def run_audit_pipeline(
|
|
142
197
|
repo_path: str | Path,
|
|
143
198
|
out_dir: str | Path,
|
|
@@ -147,8 +202,13 @@ def run_audit_pipeline(
|
|
|
147
202
|
policy: RepoPolicy | None = None,
|
|
148
203
|
patches_manifest_path: str | Path | None = None,
|
|
149
204
|
only_files: set[str] | None = None,
|
|
205
|
+
samples: int | None = None,
|
|
150
206
|
) -> AuditRunResult:
|
|
151
|
-
"""
|
|
207
|
+
"""Run the audit pipeline with 3 samples for local and 1 for other runners by default."""
|
|
208
|
+
if samples is None:
|
|
209
|
+
samples = 3 if runner_name == "local" else 1
|
|
210
|
+
if samples < 1:
|
|
211
|
+
raise ValueError("samples must be >= 1")
|
|
152
212
|
provider = RepoProvider()
|
|
153
213
|
resolved_policy = policy or RepoPolicy(repo_id=Path(repo_path).resolve().name)
|
|
154
214
|
snapshot = provider.create_snapshot(repo_path, repo_id=resolved_policy.repo_id, read_only=True)
|
|
@@ -178,6 +238,12 @@ def run_audit_pipeline(
|
|
|
178
238
|
findings: list[Finding] = []
|
|
179
239
|
model_calls = 0
|
|
180
240
|
cache_hits = 0
|
|
241
|
+
# Allocate identity before investigations so every diagnostic belongs to a run.
|
|
242
|
+
# The random suffix separates back-to-back audits within the same second.
|
|
243
|
+
run_id = (
|
|
244
|
+
f"run-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
|
245
|
+
f"-{snapshot.snapshot_hash[:8]}-{secrets.token_hex(3)}"
|
|
246
|
+
)
|
|
181
247
|
|
|
182
248
|
try:
|
|
183
249
|
candidates = scan_snapshot(snapshot.snapshot_dir, only_files=only_files)
|
|
@@ -192,40 +258,58 @@ def run_audit_pipeline(
|
|
|
192
258
|
rel: snapshot.file_hashes.get(rel, "")
|
|
193
259
|
for rel in [cand.file_path, *cand.related_files]
|
|
194
260
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
cache_hits += 1
|
|
206
|
-
else:
|
|
207
|
-
try:
|
|
208
|
-
agent_payload = selected_runner.investigate_and_refute(
|
|
209
|
-
snapshot.snapshot_dir,
|
|
210
|
-
cand,
|
|
211
|
-
resolved_policy,
|
|
212
|
-
)
|
|
213
|
-
model_calls += 1
|
|
214
|
-
digest = hashlib.sha256(
|
|
215
|
-
json.dumps(related_hashes, sort_keys=True).encode("utf-8")
|
|
216
|
-
).hexdigest()
|
|
217
|
-
store.put_cached_investigation(
|
|
218
|
-
cache_key=cache_key,
|
|
219
|
-
rule_id=cand.rule_id,
|
|
220
|
-
file_hashes_digest=digest,
|
|
221
|
-
model=selected_runner.model_name,
|
|
222
|
-
payload=agent_payload,
|
|
223
|
-
)
|
|
224
|
-
except Exception as exc:
|
|
225
|
-
errors.append(f"{cand.candidate_id} ({cand.rule_id}): {exc}")
|
|
226
|
-
agent_payload = None
|
|
261
|
+
payloads: list[dict[str, Any] | None] = []
|
|
262
|
+
for sample_index in range(samples):
|
|
263
|
+
cache_key = store.compute_cache_key(
|
|
264
|
+
rule_id=cand.rule_id,
|
|
265
|
+
file_path=cand.file_path,
|
|
266
|
+
related_hashes=related_hashes,
|
|
267
|
+
model=selected_runner.model_name,
|
|
268
|
+
prompt_version=PROMPT_VERSION,
|
|
269
|
+
sample_index=sample_index,
|
|
270
|
+
)
|
|
227
271
|
|
|
228
|
-
|
|
272
|
+
cached_payload = store.get_cached_investigation(cache_key)
|
|
273
|
+
# Older local runs could cache non-object JSON before failing
|
|
274
|
+
# adjudication. Re-investigate those entries instead of replaying them.
|
|
275
|
+
if selected_runner.runner_name == "local" and not isinstance(cached_payload, dict):
|
|
276
|
+
cached_payload = None
|
|
277
|
+
if cached_payload is not None:
|
|
278
|
+
agent_payload = cached_payload
|
|
279
|
+
cache_hits += 1
|
|
280
|
+
else:
|
|
281
|
+
try:
|
|
282
|
+
agent_payload = selected_runner.investigate_and_refute(
|
|
283
|
+
snapshot.snapshot_dir,
|
|
284
|
+
cand,
|
|
285
|
+
resolved_policy,
|
|
286
|
+
)
|
|
287
|
+
model_calls += 1
|
|
288
|
+
digest = hashlib.sha256(
|
|
289
|
+
json.dumps(related_hashes, sort_keys=True).encode("utf-8")
|
|
290
|
+
).hexdigest()
|
|
291
|
+
store.put_cached_investigation(
|
|
292
|
+
cache_key=cache_key,
|
|
293
|
+
rule_id=cand.rule_id,
|
|
294
|
+
file_hashes_digest=digest,
|
|
295
|
+
model=selected_runner.model_name,
|
|
296
|
+
payload=agent_payload,
|
|
297
|
+
)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
error_message = f"{cand.candidate_id} ({cand.rule_id}) sample {sample_index}: {exc}"
|
|
300
|
+
if isinstance(exc, LocalResponseError):
|
|
301
|
+
try:
|
|
302
|
+
diagnostic_path = _write_local_response_diagnostic(
|
|
303
|
+
out_path, run_id, cand.candidate_id, cand.rule_id, sample_index, exc,
|
|
304
|
+
)
|
|
305
|
+
error_message += f"; diagnostic: {diagnostic_path}"
|
|
306
|
+
except (OSError, ValueError) as diagnostic_exc:
|
|
307
|
+
error_message += f"; diagnostic write failed ({type(diagnostic_exc).__name__})"
|
|
308
|
+
errors.append(mask_diagnostic_text(error_message))
|
|
309
|
+
agent_payload = None
|
|
310
|
+
payloads.append(agent_payload)
|
|
311
|
+
|
|
312
|
+
finding = adjudicate_samples(snapshot.snapshot_dir, cand, payloads)
|
|
229
313
|
findings.append(finding)
|
|
230
314
|
|
|
231
315
|
findings = store.apply_risk_exceptions(
|
|
@@ -256,8 +340,6 @@ def run_audit_pipeline(
|
|
|
256
340
|
|
|
257
341
|
elapsed = time.perf_counter() - t0
|
|
258
342
|
run_status = "completed" if not errors else "partial"
|
|
259
|
-
run_id = f"run-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{snapshot.snapshot_hash[:8]}"
|
|
260
|
-
|
|
261
343
|
run_result = AuditRunResult(
|
|
262
344
|
run_id=run_id,
|
|
263
345
|
repo_id=resolved_policy.repo_id,
|
|
@@ -491,6 +573,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
491
573
|
p_audit.add_argument("--fixture", help="Path to fixture JSON when --runner fixture is used")
|
|
492
574
|
p_audit.add_argument("--state", help="Path to SQLite state DB")
|
|
493
575
|
p_audit.add_argument("--patches", help="Optional path to patches manifest JSON for isolated patch verification")
|
|
576
|
+
p_audit.add_argument(
|
|
577
|
+
"--samples",
|
|
578
|
+
type=int,
|
|
579
|
+
default=None,
|
|
580
|
+
help="Independent investigations per candidate; strict-majority vote (default: 3 for local, 1 otherwise)",
|
|
581
|
+
)
|
|
494
582
|
p_audit.add_argument(
|
|
495
583
|
"--allow-external",
|
|
496
584
|
action="store_true",
|
|
@@ -558,6 +646,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
558
646
|
state_db_path=args.state,
|
|
559
647
|
policy=policy,
|
|
560
648
|
patches_manifest_path=args.patches,
|
|
649
|
+
samples=args.samples,
|
|
561
650
|
)
|
|
562
651
|
summary = {
|
|
563
652
|
"run_id": res.run_id,
|