@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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.
- package/CHANGELOG.md +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
// The emitter is deterministic — sorts by ecosystem, name, version —
|
|
12
12
|
// so commits don't churn between scans.
|
|
13
13
|
|
|
14
|
-
import
|
|
15
|
-
import * as path from 'node:path';
|
|
14
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
16
15
|
|
|
17
16
|
function _sortKey(c) {
|
|
18
17
|
return `${c.ecosystem || 'zz'}:${c.name || ''}:${c.version || ''}`;
|
|
@@ -83,11 +82,10 @@ export function generateAttributions(components, options) {
|
|
|
83
82
|
|
|
84
83
|
export function persistAttributions(scanRoot, result) {
|
|
85
84
|
if (!result || !result.markdown) return null;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
85
|
+
// Through the seam — see the note in pqc-migration-plan.js. The result is
|
|
86
|
+
// still returned when writing is off; only the artifact is withheld.
|
|
87
|
+
safeWriteState(statePath(scanRoot, 'ATTRIBUTIONS.md'), result.markdown);
|
|
88
|
+
if (result.notice) safeWriteState(statePath(scanRoot, 'NOTICE'), result.notice);
|
|
91
89
|
return result;
|
|
92
90
|
}
|
|
93
91
|
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import * as fs from 'node:fs';
|
|
38
38
|
import * as path from 'node:path';
|
|
39
39
|
|
|
40
|
+
import { statePath } from './state-dir.js';
|
|
40
41
|
// ── License taxonomy ───────────────────────────────────────────────────────
|
|
41
42
|
|
|
42
43
|
const LICENSE_FAMILIES = {
|
|
@@ -223,7 +224,7 @@ export function analyzeLicenseGraph(components, options) {
|
|
|
223
224
|
|
|
224
225
|
export function loadLicenseGraphPolicy(scanRoot) {
|
|
225
226
|
if (!scanRoot) return { distributionMode: DEFAULT_DIST_MODE };
|
|
226
|
-
const fp =
|
|
227
|
+
const fp = statePath(scanRoot, 'license-policy.yml');
|
|
227
228
|
if (!fs.existsSync(fp)) return { distributionMode: DEFAULT_DIST_MODE };
|
|
228
229
|
try {
|
|
229
230
|
const raw = fs.readFileSync(fp, 'utf8');
|
|
@@ -16,6 +16,7 @@ import * as fs from 'node:fs';
|
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
import * as yaml from '../util/yaml.js';
|
|
18
18
|
|
|
19
|
+
import { statePath } from './state-dir.js';
|
|
19
20
|
const DEFAULT_POLICY = {
|
|
20
21
|
allow: [],
|
|
21
22
|
deny: [],
|
|
@@ -26,7 +27,7 @@ const DEFAULT_POLICY = {
|
|
|
26
27
|
export function loadLicensePolicy(scanRoot) {
|
|
27
28
|
if (!scanRoot) return null;
|
|
28
29
|
for (const name of ['license-policy.yml', 'license-policy.yaml', 'license-policy.json']) {
|
|
29
|
-
const p =
|
|
30
|
+
const p = statePath(scanRoot, name);
|
|
30
31
|
if (!fs.existsSync(p)) continue;
|
|
31
32
|
try {
|
|
32
33
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
import * as fs from 'node:fs';
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
19
19
|
|
|
20
20
|
function _readJson(scanRoot, name) {
|
|
21
|
-
try { return JSON.parse(fs.readFileSync(
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(statePath(scanRoot, name), 'utf8')); } catch { return null; }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -50,7 +50,8 @@ export function diffValidatorRuns(runA, runB) {
|
|
|
50
50
|
* Persist a model-rescan report. Returns the file path.
|
|
51
51
|
*/
|
|
52
52
|
export function persistRescanReport(scanRoot, from, to, changed) {
|
|
53
|
-
const dir =
|
|
53
|
+
const dir = statePath(scanRoot, 'model-rescan');
|
|
54
|
+
if (!stateWritesEnabled()) return null;
|
|
54
55
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
55
56
|
const safe = (s) => String(s || 'unknown').replace(/[^\w.-]/g, '-');
|
|
56
57
|
const fp = path.join(dir, `${safe(from)}-vs-${safe(to)}.json`);
|
|
@@ -73,4 +74,69 @@ export function summarizeDelta(changed) {
|
|
|
73
74
|
return lines.join('\n');
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// Stage 6 correctness audit: diffValidatorRuns/persistRescanReport/
|
|
78
|
+
// summarizeDelta above were fully built, but nothing in the codebase ever
|
|
79
|
+
// produced a `{model, results: {findingId: {verdict, reason}}}` run file
|
|
80
|
+
// for them to consume — commands/labs.md's `--model-rescan` mode was
|
|
81
|
+
// disclosed as genuinely unwired rather than fabricated. This is the
|
|
82
|
+
// missing producer: runs the SAME findings through the LLM validator twice
|
|
83
|
+
// — once under whatever model the environment currently resolves to
|
|
84
|
+
// ("from"), once under `toModel` ("to", via the existing per-role env
|
|
85
|
+
// override `AGENTIC_SECURITY_LLM_MODEL_VALIDATE` — no new plumbing needed,
|
|
86
|
+
// llm-validator/providers.js already supports it) — and turns the two runs
|
|
87
|
+
// into a real delta report. Reuses validateMany's own candidate filter
|
|
88
|
+
// (critical/high severity, low confidence, or AST parser) rather than
|
|
89
|
+
// re-validating every finding, matching normal validation scope. When no
|
|
90
|
+
// LLM endpoint is configured, validateMany degrades every finding to
|
|
91
|
+
// 'unvalidated' with no network call — this function inherits that
|
|
92
|
+
// no-network-by-default behavior rather than working around it.
|
|
93
|
+
export async function runModelRescan(scanRoot, { toModel } = {}) {
|
|
94
|
+
if (!toModel) return { ok: false, reason: 'no --model given to rescan with' };
|
|
95
|
+
const scan = _readJson(scanRoot, 'last-scan.json');
|
|
96
|
+
if (!scan) return { ok: false, reason: 'no .agentic-security/last-scan.json — run a scan first' };
|
|
97
|
+
const findings = Array.isArray(scan.findings) ? scan.findings : [];
|
|
98
|
+
if (!findings.length) return { ok: false, reason: 'last scan has no findings to re-validate' };
|
|
99
|
+
|
|
100
|
+
const fileContents = {};
|
|
101
|
+
for (const f of findings) {
|
|
102
|
+
if (!f.file || fileContents[f.file] !== undefined) continue;
|
|
103
|
+
try { fileContents[f.file] = fs.readFileSync(path.join(scanRoot, f.file), 'utf8'); }
|
|
104
|
+
catch { /* file may have moved/been deleted since the scan; validateMany skips it */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { validateMany } = await import('../llm-validator/index.js');
|
|
108
|
+
const { resolveProvider } = await import('../llm-validator/providers.js');
|
|
109
|
+
|
|
110
|
+
const runFor = async (envKey, modelOverride) => {
|
|
111
|
+
const prev = envKey ? process.env[envKey] : undefined;
|
|
112
|
+
if (envKey) process.env[envKey] = modelOverride;
|
|
113
|
+
let resolvedModel = 'unvalidated';
|
|
114
|
+
try {
|
|
115
|
+
const r = resolveProvider({ role: 'validate' });
|
|
116
|
+
if (r.ok) resolvedModel = r.config.model;
|
|
117
|
+
const clones = findings.map(f => ({ ...f }));
|
|
118
|
+
await validateMany(clones, { fileContents, scanRoot });
|
|
119
|
+
const results = {};
|
|
120
|
+
for (const f of clones) {
|
|
121
|
+
const id = f.stableId || f.id;
|
|
122
|
+
if (!id) continue;
|
|
123
|
+
results[id] = { verdict: f.validator_verdict || 'unvalidated', reason: f.validator_reasoning || null };
|
|
124
|
+
}
|
|
125
|
+
return { model: resolvedModel, results };
|
|
126
|
+
} finally {
|
|
127
|
+
if (envKey) {
|
|
128
|
+
if (prev === undefined) delete process.env[envKey];
|
|
129
|
+
else process.env[envKey] = prev;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const runA = await runFor(null, null);
|
|
135
|
+
const runB = await runFor('AGENTIC_SECURITY_LLM_MODEL_VALIDATE', toModel);
|
|
136
|
+
|
|
137
|
+
const changed = diffValidatorRuns(runA, runB);
|
|
138
|
+
const reportPath = persistRescanReport(scanRoot, runA.model, runB.model, changed);
|
|
139
|
+
return { ok: true, from: runA.model, to: runB.model, changed, reportPath, summary: summarizeDelta(changed) };
|
|
140
|
+
}
|
|
141
|
+
|
|
76
142
|
export const _internals = {};
|
package/src/posture/mttr.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
import * as crypto from 'node:crypto';
|
|
11
11
|
|
|
12
12
|
// Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
|
|
13
|
+
// Exported so a caller can compute the "removed since baseline" (i.e. fixed)
|
|
14
|
+
// set that computeMTTR needs, using the exact same identity function
|
|
15
|
+
// buildBaselineMap uses internally — a caller-side reimplementation would
|
|
16
|
+
// risk drifting from this one and silently under/over-counting fixes.
|
|
17
|
+
export function fingerprintFinding(f) { return _fingerprint(f); }
|
|
13
18
|
function _fingerprint(f) {
|
|
14
19
|
const file = (f.file || '').split(' -> ').pop();
|
|
15
20
|
const line = f.line || f.source?.line || f.sink?.line || 0;
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
import * as fs from 'node:fs';
|
|
28
28
|
import * as path from 'node:path';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
import { statePath } from './state-dir.js';
|
|
31
32
|
const K8S_DIRS = ['k8s', 'infra/k8s', 'deploy/k8s', 'kubernetes', 'manifests'];
|
|
32
33
|
|
|
33
34
|
const INTERNAL_CIDRS = [
|
|
@@ -81,7 +82,7 @@ function parseKubeManifests(scanRoot) {
|
|
|
81
82
|
|
|
82
83
|
export function loadNetworkPosture(scanRoot) {
|
|
83
84
|
const root = scanRoot || process.cwd();
|
|
84
|
-
const digestPath =
|
|
85
|
+
const digestPath = statePath(root, 'network-policy.json');
|
|
85
86
|
if (fs.existsSync(digestPath)) {
|
|
86
87
|
try {
|
|
87
88
|
const data = JSON.parse(fs.readFileSync(digestPath, 'utf8'));
|
|
@@ -119,14 +119,33 @@ function _binding(finding, call) {
|
|
|
119
119
|
// The request property the handler reads. Anchored to the request identifier
|
|
120
120
|
// the export actually binds, so a file that reads `req.query` while exporting
|
|
121
121
|
// `(request, response)` does not produce a PoC built on the wrong name.
|
|
122
|
-
|
|
122
|
+
//
|
|
123
|
+
// A handler that reads more than one request property (extremely common —
|
|
124
|
+
// e.g. a harmless query param for pagination alongside the actual body
|
|
125
|
+
// param used in the sink) used to always get the first property found in a
|
|
126
|
+
// fixed query>body>params priority, with no relationship to which one
|
|
127
|
+
// actually reaches the sink — silently building a PoC against an inert
|
|
128
|
+
// parameter while the real injection point went untouched. When sinkLine
|
|
129
|
+
// is known, prefer whichever match sits closest to it (line proximity is a
|
|
130
|
+
// cheap, effective proxy for "this is the value that flows into the sink a
|
|
131
|
+
// few lines below/above it"); otherwise fall back to the old first-found
|
|
132
|
+
// behavior for callers that can't supply a line.
|
|
133
|
+
function _requestSource(content, reqIdent, sinkLine) {
|
|
123
134
|
const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
135
|
+
const candidates = [];
|
|
124
136
|
for (const prop of ['query', 'body', 'params']) {
|
|
125
|
-
const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`, 'g');
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = re.exec(content))) {
|
|
140
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
141
|
+
candidates.push({ prop, key: m[1], line });
|
|
142
|
+
}
|
|
128
143
|
}
|
|
129
|
-
return null;
|
|
144
|
+
if (!candidates.length) return null;
|
|
145
|
+
if (typeof sinkLine === 'number') {
|
|
146
|
+
candidates.sort((a, b) => Math.abs(a.line - sinkLine) - Math.abs(b.line - sinkLine));
|
|
147
|
+
}
|
|
148
|
+
return { prop: candidates[0].prop, key: candidates[0].key };
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
// The sink must interpolate into a SHELL, not an argv array. `exec`/`execSync`
|
|
@@ -184,7 +203,7 @@ export function synthesizeInProcessPoc(finding, fileContent) {
|
|
|
184
203
|
return { ok: false, reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface' };
|
|
185
204
|
}
|
|
186
205
|
|
|
187
|
-
const src = _requestSource(fileContent, reqIdent);
|
|
206
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
188
207
|
if (!src) {
|
|
189
208
|
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
190
209
|
}
|
|
@@ -392,7 +411,7 @@ function _sqlInjectionPoc(finding, fileContent) {
|
|
|
392
411
|
if (!found) return NO_HANDLER;
|
|
393
412
|
const { call, reqIdent } = found;
|
|
394
413
|
|
|
395
|
-
const src = _requestSource(fileContent, reqIdent);
|
|
414
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
396
415
|
if (!src) {
|
|
397
416
|
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
398
417
|
}
|
|
@@ -490,7 +509,7 @@ function _pathTraversalPoc(finding, fileContent) {
|
|
|
490
509
|
if (!READ_SINK.test(fileContent)) {
|
|
491
510
|
return { ok: false, reason: 'no readFile/sendFile sink in the file, so there is no served content to observe coming back' };
|
|
492
511
|
}
|
|
493
|
-
const src = _requestSource(fileContent, reqIdent);
|
|
512
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
494
513
|
if (!src) {
|
|
495
514
|
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the traversal point is unknown` };
|
|
496
515
|
}
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
// suggestions; JSON-LD-shaped structured output is consumable by Vanta /
|
|
19
19
|
// Drata / SecureFrame or any custom rollup dashboard.
|
|
20
20
|
|
|
21
|
-
import
|
|
22
|
-
import * as path from 'node:path';
|
|
21
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
23
22
|
|
|
24
23
|
function _byHndl(findings) {
|
|
25
24
|
return {
|
|
@@ -117,9 +116,12 @@ export function buildMigrationPlan(allFindings) {
|
|
|
117
116
|
|
|
118
117
|
export function persistMigrationPlan(scanRoot, plan) {
|
|
119
118
|
if (!plan) return null;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
// Through the seam: creates the directory, enforces the project-root check,
|
|
120
|
+
// and honours the read-only scan switch. The plan is still RETURNED when
|
|
121
|
+
// writing is off — a scan must report the same result whether or not it is
|
|
122
|
+
// allowed to leave files behind.
|
|
123
|
+
safeWriteState(statePath(scanRoot, 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2));
|
|
124
|
+
safeWriteState(statePath(scanRoot, 'pqc-migration-plan.md'), _markdown(plan));
|
|
123
125
|
return plan;
|
|
124
126
|
}
|
|
125
127
|
|
|
@@ -18,6 +18,7 @@ import * as fs from 'node:fs';
|
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
import { diffScans, summarizeDiff } from './baseline-compare.js';
|
|
20
20
|
|
|
21
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
21
22
|
const REVIEWER_TRIGGERS = [
|
|
22
23
|
{ family: /^auth/, team: 'security', why: 'Auth-related findings' },
|
|
23
24
|
{ family: /^crypto/, team: 'security', why: 'Cryptography findings' },
|
|
@@ -29,7 +30,7 @@ const REVIEWER_TRIGGERS = [
|
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
function _stateFile(scanRoot, name) {
|
|
32
|
-
return
|
|
33
|
+
return statePath(scanRoot, name);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function _readJson(fp) {
|
|
@@ -38,7 +39,7 @@ function _readJson(fp) {
|
|
|
38
39
|
|
|
39
40
|
function _baselinePath(scanRoot, ref) {
|
|
40
41
|
const safe = String(ref || 'main').replace(/[^\w.-]/g, '-');
|
|
41
|
-
return
|
|
42
|
+
return statePath(scanRoot, 'scan-baselines', `${safe}.json`);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -47,8 +48,10 @@ function _baselinePath(scanRoot, ref) {
|
|
|
47
48
|
*/
|
|
48
49
|
export function persistBaseline(scanRoot, ref, scan) {
|
|
49
50
|
const fp = _baselinePath(scanRoot, ref);
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// safeWriteState creates the directory, enforces the project-root check, and
|
|
52
|
+
// honours the read-only switch. The path is still returned either way, so
|
|
53
|
+
// callers that only report where the baseline WOULD live are unaffected.
|
|
54
|
+
safeWriteState(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2));
|
|
52
55
|
return fp;
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -109,7 +112,7 @@ function _artifactLinks(scanRoot) {
|
|
|
109
112
|
];
|
|
110
113
|
const out = [];
|
|
111
114
|
for (const c of candidates) {
|
|
112
|
-
const fp =
|
|
115
|
+
const fp = statePath(scanRoot, c.file);
|
|
113
116
|
if (fs.existsSync(fp)) out.push({ name: c.name, path: `.agentic-security/${c.file}` });
|
|
114
117
|
}
|
|
115
118
|
return out;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// NIST Privacy Framework 1.1 — assessment + remediation planning.
|
|
2
|
+
//
|
|
3
|
+
// The bundled control set (`compliance-frameworks/nist-privacy-1-1.json`) is
|
|
4
|
+
// rendered by `auditor-walkthrough.js` like every other framework. This module
|
|
5
|
+
// adds the half a walkthrough cannot give you: a gap becomes a FINDING carrying
|
|
6
|
+
// a concrete remediation, so it flows through the same triage and `/fix` path
|
|
7
|
+
// as any other finding rather than sitting in a narrative nobody actions.
|
|
8
|
+
//
|
|
9
|
+
// THE INTEGRITY RULE THIS MODULE EXISTS TO ENFORCE
|
|
10
|
+
// -----------------------------------------------
|
|
11
|
+
// NIST ships a `Code Testable?` rating per control — yes / partial / no. On the
|
|
12
|
+
// 104 controls: 23 yes, 33 partial, **48 no**.
|
|
13
|
+
//
|
|
14
|
+
// That rating says whether a control COULD be assessed from source code. It does
|
|
15
|
+
// NOT say this engine assesses it. Conflating the two is how a compliance tool
|
|
16
|
+
// starts reporting governance controls — "the organization's privacy values are
|
|
17
|
+
// communicated" — as PASSED because no scanner rule fired against them. Silence
|
|
18
|
+
// is not evidence, and a clean report over 48 controls nobody measured is worse
|
|
19
|
+
// than no report: it is a false assurance that someone will hand to an auditor.
|
|
20
|
+
//
|
|
21
|
+
// So every control lands in exactly one of four buckets, and the bucket is
|
|
22
|
+
// always stated:
|
|
23
|
+
//
|
|
24
|
+
// satisfied mapped to a real engine signal, and that signal is clean
|
|
25
|
+
// gap mapped to a real engine signal, and that signal is dirty
|
|
26
|
+
// → this is the only bucket that produces a finding
|
|
27
|
+
// engine-gap NIST rates it code-testable, but this engine has no signal
|
|
28
|
+
// → disclosed BY NAME, never counted as satisfied
|
|
29
|
+
// manual NIST rates it not code-testable (governance, policy, process)
|
|
30
|
+
// → out of scope for any scanner, stated as such
|
|
31
|
+
//
|
|
32
|
+
// `engine-gap` is the bucket that keeps this honest. It is the difference
|
|
33
|
+
// between "we checked and it is fine" and "nobody checked", and it is reported
|
|
34
|
+
// with the same prominence as a failure because to a reader relying on the
|
|
35
|
+
// output they are the same thing.
|
|
36
|
+
|
|
37
|
+
import { loadFramework, evaluateFramework } from './auditor-walkthrough.js';
|
|
38
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
39
|
+
|
|
40
|
+
export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
|
|
41
|
+
|
|
42
|
+
/** Bucket names, in report order (worst first). */
|
|
43
|
+
export const BUCKETS = Object.freeze(['gap', 'engine-gap', 'manual', 'satisfied']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Remediation per control family. Deliberately ACTIONABLE — a command or a
|
|
47
|
+
* concrete artifact — rather than a restatement of the control text, which is
|
|
48
|
+
* what a compliance report usually offers and what makes them ignorable.
|
|
49
|
+
*
|
|
50
|
+
* Keyed by the control-id prefix so a new control in an existing category
|
|
51
|
+
* inherits sensible guidance instead of silently getting none.
|
|
52
|
+
*/
|
|
53
|
+
const REMEDIATION = [
|
|
54
|
+
[/^PR\.DS-P1$/, 'Encrypt data at rest with an AEAD cipher. Replace ECB mode and static IVs; run `/fix --family crypto-weak-cipher` to apply the deterministic swaps.'],
|
|
55
|
+
[/^PR\.DS-P2$/, 'Enforce TLS 1.2+ and stop disabling certificate verification. `/fix --family crypto-tls-no-verify` patches the verify-off call sites.'],
|
|
56
|
+
[/^PR\.AA-P1$/, 'Remove hardcoded credentials and add authentication to unauthenticated routes. `/fix --rotate-secret` handles the secrets; auth gaps need a route-level decision.'],
|
|
57
|
+
[/^PR\.AA-P4$/, 'Sign and verify identity assertions with a modern algorithm — no `none` alg, no weak-hash HMAC.'],
|
|
58
|
+
[/^PR\.AA-05$/, 'Add object-level authorization on endpoints that read or mutate personal data. Review the IDOR and authz findings individually; these are not safely auto-patchable.'],
|
|
59
|
+
[/^PR\.PS-P1$/, 'Tighten IaC: drop privileged pod security contexts and over-permissive IAM policies.'],
|
|
60
|
+
[/^PR\.PS-P2$/, 'Upgrade dependencies carrying known advisories. `/fix --sca` proposes the version bumps.'],
|
|
61
|
+
[/^PR\.PS-P4$/, 'Resolve dependency-confusion exposure by pinning internal scopes to your private registry.'],
|
|
62
|
+
[/^PR\.IR-P1$/, 'Restrict network and cluster access — no cluster-admin bindings, no wildcard IAM.'],
|
|
63
|
+
[/^PR\.DS-P8$/, 'Record an SBOM snapshot per release so dependency substitution is detectable.'],
|
|
64
|
+
[/^CT\.DP-P/, 'Reduce what is observable: minimise PII reaching logs, responses, and third parties; de-identify where the data action does not need identity.'],
|
|
65
|
+
[/^CT\.DM-P6$/, 'Transmit over TLS with a standard, current cipher suite.'],
|
|
66
|
+
[/^CT\.DM-P9$/, 'Retain scan history and the MCP audit log so data-processing activity is reviewable.'],
|
|
67
|
+
[/^CT\.DM-P10$/, 'Keep fix history so the technical measures you applied are demonstrably tested, not asserted.'],
|
|
68
|
+
[/^CT\.PO-P/, 'Declare the policy in `.agentic-security/compliance.policy.yml` so it is verified on every scan rather than remembered.'],
|
|
69
|
+
[/^CM\.AW-P4$/, 'Preserve the audit log — it is the record of disclosure this control asks for.'],
|
|
70
|
+
[/^CM\.AW-P6$/, 'Keep SBOM snapshots and signed scan state so provenance and lineage survive review.'],
|
|
71
|
+
[/^ID\.IM-P/, 'Produce the inventory artifacts: run a scan so the SBOM, threat model, and PII field map exist and are current.'],
|
|
72
|
+
[/^ID\.RA-P3$/, 'Triage the PII-exposure findings — each is a problematic data action this control asks you to identify.'],
|
|
73
|
+
[/^ID\.BE-P3$/, 'Generate the threat model so priority systems and their requirements are recorded.'],
|
|
74
|
+
[/^GV\.PO-P2$/, 'Commit a compliance policy file so privacy expectations are enforced in CI, not just documented.'],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** The remediation for a control, or null when none is defined. */
|
|
78
|
+
export function remediationFor(controlId) {
|
|
79
|
+
for (const [re, text] of REMEDIATION) if (re.test(controlId)) return text;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Bucket one evaluated control.
|
|
85
|
+
*
|
|
86
|
+
* `evaluateFramework` returns 'present' | 'partial' | 'manual'. That vocabulary
|
|
87
|
+
* cannot distinguish "no mapping because it is a governance control" from "no
|
|
88
|
+
* mapping because we never built the check" — which is exactly the distinction
|
|
89
|
+
* that decides whether a reader should worry. `codeTestable` supplies it.
|
|
90
|
+
*/
|
|
91
|
+
export function bucketOf(result, { assessable = true } = {}) {
|
|
92
|
+
const c = result.control || {};
|
|
93
|
+
const mapped = Array.isArray(c.mapsTo) && c.mapsTo.length > 0;
|
|
94
|
+
if (mapped) {
|
|
95
|
+
// VACUOUS SATISFACTION GUARD. `evaluateFramework` clears a `family:`
|
|
96
|
+
// mapping when no findings of that family are open — which is also true of
|
|
97
|
+
// a scan that examined nothing at all. Without this, pointing the tool at
|
|
98
|
+
// an empty directory reports privacy controls as SATISFIED, on the strength
|
|
99
|
+
// of having looked at zero files. That is the same false assurance the
|
|
100
|
+
// manual bucket exists to prevent, arriving by a different route, and it
|
|
101
|
+
// was caught by this module's own test rather than in review.
|
|
102
|
+
if (!assessable) return 'engine-gap';
|
|
103
|
+
return result.status === 'present' ? 'satisfied' : 'gap';
|
|
104
|
+
}
|
|
105
|
+
return String(c.codeTestable || 'no').toLowerCase() === 'no' ? 'manual' : 'engine-gap';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Severity for a gap. Privacy controls are graded by what the gap exposes. */
|
|
109
|
+
function severityFor(controlId) {
|
|
110
|
+
if (/^PR\.(DS|AA)/.test(controlId)) return 'high'; // encryption, identity, access
|
|
111
|
+
if (/^CT\.DP/.test(controlId)) return 'medium'; // data minimisation
|
|
112
|
+
return 'low';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Assess a scan against NIST Privacy Framework 1.1.
|
|
117
|
+
*
|
|
118
|
+
* Returns `{ frameworkId, controls[], summary, findings[] }`. Never throws:
|
|
119
|
+
* posture modules degrade to a null result rather than failing a scan.
|
|
120
|
+
*/
|
|
121
|
+
export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
122
|
+
const fw = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID);
|
|
123
|
+
if (!fw) return null;
|
|
124
|
+
|
|
125
|
+
let evaluation;
|
|
126
|
+
try { evaluation = evaluateFramework(scanRoot, fw, scan || {}); } catch { return null; }
|
|
127
|
+
|
|
128
|
+
// Did the scan actually examine anything? A clean signal from a run that read
|
|
129
|
+
// no files is not evidence — see the guard in bucketOf.
|
|
130
|
+
const filesScanned = Number(scan && (scan.filesScanned ?? scan._scanMeta?.filesScanned)) || 0;
|
|
131
|
+
const assessable = filesScanned > 0
|
|
132
|
+
|| (Array.isArray(scan?.findings) && scan.findings.length > 0)
|
|
133
|
+
|| (Array.isArray(scan?.components) && scan.components.length > 0);
|
|
134
|
+
|
|
135
|
+
const controls = [];
|
|
136
|
+
const findings = [];
|
|
137
|
+
const summary = { gap: 0, 'engine-gap': 0, manual: 0, satisfied: 0, total: 0 };
|
|
138
|
+
|
|
139
|
+
for (const r of evaluation) {
|
|
140
|
+
const c = r.control || {};
|
|
141
|
+
const bucket = bucketOf(r, { assessable });
|
|
142
|
+
summary[bucket] += 1;
|
|
143
|
+
summary.total += 1;
|
|
144
|
+
|
|
145
|
+
const row = {
|
|
146
|
+
id: c.id,
|
|
147
|
+
function: c.function || null,
|
|
148
|
+
category: c.category || null,
|
|
149
|
+
summary: c.summary || '',
|
|
150
|
+
codeTestable: c.codeTestable || 'no',
|
|
151
|
+
bucket,
|
|
152
|
+
observations: r.observations || [],
|
|
153
|
+
};
|
|
154
|
+
if (bucket === 'engine-gap') {
|
|
155
|
+
// Named, not counted as a pass. See the header.
|
|
156
|
+
row.disclosure = (Array.isArray(c.mapsTo) && c.mapsTo.length && !assessable)
|
|
157
|
+
? 'This control is mapped, but the scan examined no files — a clean signal from a run that read nothing is not evidence. NOT assessed.'
|
|
158
|
+
: `NIST rates this control code-testable (${row.codeTestable}), but this engine has no signal for it. It was NOT assessed.`;
|
|
159
|
+
}
|
|
160
|
+
if (bucket === 'manual') {
|
|
161
|
+
row.disclosure = 'NIST rates this control not code-testable — it is a governance, policy, or process control and is outside any scanner\'s reach.';
|
|
162
|
+
}
|
|
163
|
+
controls.push(row);
|
|
164
|
+
|
|
165
|
+
if (bucket !== 'gap') continue;
|
|
166
|
+
const remediation = remediationFor(c.id)
|
|
167
|
+
|| 'Review the observations for this control and close the underlying findings.';
|
|
168
|
+
findings.push({
|
|
169
|
+
id: `privacy-framework:${c.id}`,
|
|
170
|
+
severity: severityFor(c.id),
|
|
171
|
+
// A compliance gap is a property of the project, not of one line. The
|
|
172
|
+
// framework file is cited so the finding still has a real, openable
|
|
173
|
+
// location rather than a fabricated one.
|
|
174
|
+
file: '.agentic-security/compliance/nist-privacy-1-1',
|
|
175
|
+
line: 0,
|
|
176
|
+
vuln: `NIST Privacy Framework ${c.id} not satisfied — ${c.summary}`,
|
|
177
|
+
cwe: 'CWE-359',
|
|
178
|
+
description: [
|
|
179
|
+
`Control ${c.id} (${c.category || c.function || 'privacy'}) is mapped to engine signals that are currently failing.`,
|
|
180
|
+
...(r.observations || []),
|
|
181
|
+
].join(' '),
|
|
182
|
+
remediation,
|
|
183
|
+
parser: 'COMPLIANCE',
|
|
184
|
+
family: 'privacy-compliance',
|
|
185
|
+
complianceControl: { framework: PRIVACY_FRAMEWORK_ID, id: c.id, codeTestable: c.codeTestable || 'no' },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
frameworkId: PRIVACY_FRAMEWORK_ID,
|
|
191
|
+
frameworkName: fw.name,
|
|
192
|
+
controls,
|
|
193
|
+
summary,
|
|
194
|
+
findings,
|
|
195
|
+
// Carried in the result so a consumer cannot present the numbers without
|
|
196
|
+
// it. `satisfied` is a share of the ASSESSED controls, never of all 104.
|
|
197
|
+
assessable,
|
|
198
|
+
interpretation:
|
|
199
|
+
(!assessable ? 'The scan examined no files, so NO control was assessed. ' : '') +
|
|
200
|
+
`${summary.satisfied} of ${summary.satisfied + summary.gap} assessed controls satisfied. ` +
|
|
201
|
+
`${summary['engine-gap']} controls NIST rates code-testable were NOT assessed by this engine, and ` +
|
|
202
|
+
`${summary.manual} are governance controls outside any scanner's reach. ` +
|
|
203
|
+
'Neither group is evidence of compliance.',
|
|
204
|
+
...(opts.includeFindings === false ? { findings: [] } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render the assessment as Markdown, worst bucket first.
|
|
210
|
+
*
|
|
211
|
+
* Order is deliberate: gaps, then the controls nobody assessed, then governance,
|
|
212
|
+
* then satisfied. A report that opens with what passed invites the reader to
|
|
213
|
+
* stop there, which is precisely the wrong reading of a document where most
|
|
214
|
+
* controls carry no engine evidence at all.
|
|
215
|
+
*/
|
|
216
|
+
function renderPrivacyMarkdown(result) {
|
|
217
|
+
if (!result) return '';
|
|
218
|
+
const L = [];
|
|
219
|
+
L.push(`# ${result.frameworkName} — assessment`);
|
|
220
|
+
L.push('');
|
|
221
|
+
L.push(result.interpretation);
|
|
222
|
+
L.push('');
|
|
223
|
+
L.push('| Bucket | Controls | Meaning |');
|
|
224
|
+
L.push('| --- | --- | --- |');
|
|
225
|
+
L.push(`| Gap | ${result.summary.gap} | Mapped to an engine signal, and that signal is failing |`);
|
|
226
|
+
L.push(`| Not assessed | ${result.summary['engine-gap']} | Code-testable, but this engine has no signal — **not** a pass |`);
|
|
227
|
+
L.push(`| Manual | ${result.summary.manual} | Governance/policy control, outside any scanner's reach |`);
|
|
228
|
+
L.push(`| Satisfied | ${result.summary.satisfied} | Mapped, and the signal is clean |`);
|
|
229
|
+
L.push('');
|
|
230
|
+
L.push('> A control in *Not assessed* or *Manual* is not evidence of compliance.');
|
|
231
|
+
L.push('> This document organizes scanner evidence; it does not certify anything.');
|
|
232
|
+
L.push('> A licensed assessor is responsible for the attestation.');
|
|
233
|
+
L.push('');
|
|
234
|
+
for (const bucket of BUCKETS) {
|
|
235
|
+
const rows = result.controls.filter(c => c.bucket === bucket);
|
|
236
|
+
if (!rows.length) continue;
|
|
237
|
+
L.push(`## ${bucket} (${rows.length})`);
|
|
238
|
+
L.push('');
|
|
239
|
+
for (const c of rows) {
|
|
240
|
+
L.push(`### ${c.id} — ${c.summary}`);
|
|
241
|
+
L.push(`- NIST code-testable: **${c.codeTestable}**`);
|
|
242
|
+
if (c.disclosure) L.push(`- ${c.disclosure}`);
|
|
243
|
+
for (const o of c.observations || []) L.push(`- ${o}`);
|
|
244
|
+
const rem = bucket === 'gap' ? remediationFor(c.id) : null;
|
|
245
|
+
if (rem) L.push(`- **Remediation:** ${rem}`);
|
|
246
|
+
L.push('');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return L.join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Persist the assessment. Through the seam, so a read-only scan writes nothing
|
|
254
|
+
* while still RETURNING the assessment — the switch changes what is written,
|
|
255
|
+
* never what is reported.
|
|
256
|
+
*/
|
|
257
|
+
export function persistPrivacyFramework(scanRoot, result) {
|
|
258
|
+
if (!result) return null;
|
|
259
|
+
safeWriteState(statePath(scanRoot, 'privacy-framework.json'), JSON.stringify(result, null, 2));
|
|
260
|
+
safeWriteState(statePath(scanRoot, 'privacy-framework.md'), renderPrivacyMarkdown(result));
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
@@ -103,18 +103,30 @@ test('${finding.vuln.replace(/'/g, "\\'")}', async () => {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function _renderPytest(finding, poc) {
|
|
106
|
+
// Same finding-specific extraction _renderJest uses, not the hardcoded
|
|
107
|
+
// SQL-injection payload every call used to emit regardless of the actual
|
|
108
|
+
// vulnerability class. poc.code is assumed to follow this project's own
|
|
109
|
+
// URL_/METHOD/PAYLOAD synthesis convention (poc-generator.js), same as
|
|
110
|
+
// the JS PoCs this extraction already works against.
|
|
111
|
+
const url = _extractUrl(poc.code) || 'http://localhost:3000';
|
|
112
|
+
const method = (_extractMethod(poc.code) || 'POST').toLowerCase();
|
|
113
|
+
const payload = _extractPayloadValue(poc.code) || 'PoC';
|
|
106
114
|
return `# Regression test for ${finding.vuln} (${finding.cwe || 'unknown CWE'}).
|
|
107
|
-
# Auto-generated from P1.1 PoC.
|
|
115
|
+
# Auto-generated from P1.1 PoC; fails on vulnerable code, passes after the fix.
|
|
108
116
|
|
|
109
117
|
import requests
|
|
110
118
|
|
|
111
119
|
|
|
112
120
|
def test_${(finding.stableId || finding.id || 'finding').replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 40)}():
|
|
113
|
-
url =
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
url = ${JSON.stringify(url)}
|
|
122
|
+
payload = ${JSON.stringify(payload)}
|
|
123
|
+
r = requests.${method}(url, json={'input': payload}) if ${JSON.stringify(method)} != 'get' \\
|
|
124
|
+
else requests.get(url, params={'input': payload})
|
|
125
|
+
# The assertion below is the "exploit demonstrated" signal — when this
|
|
126
|
+
# test PASSES, the vuln is still present. After the fix, this assertion
|
|
127
|
+
# SHOULD fail. Inverse the assertion in a green build.
|
|
116
128
|
text = r.text
|
|
117
|
-
demonstrated = 'syntax error' in text.lower() or '<script>' in text
|
|
129
|
+
demonstrated = 'PoC' in text or 'syntax error' in text.lower() or '<script>' in text
|
|
118
130
|
assert not demonstrated, f'Vulnerability still present: {text[:200]}'
|
|
119
131
|
`;
|
|
120
132
|
}
|
|
@@ -127,10 +139,13 @@ function _extractMethod(code) {
|
|
|
127
139
|
const m = String(code || '').match(/METHOD = (['"])([A-Z]+)\1/);
|
|
128
140
|
return m ? m[2] : null;
|
|
129
141
|
}
|
|
130
|
-
function
|
|
142
|
+
function _extractPayloadValue(code) {
|
|
131
143
|
const m = String(code || '').match(/PAYLOAD = `([^`]+)`/);
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
return m ? m[1] : null;
|
|
145
|
+
}
|
|
146
|
+
function _extractPayloadLine(code) {
|
|
147
|
+
const v = _extractPayloadValue(code);
|
|
148
|
+
return `const PAYLOAD = ${JSON.stringify(v || 'PoC')};`;
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
/**
|