@clear-capabilities/agentic-security-scanner 0.133.0 → 0.136.2
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 +299 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +348 -40
- package/dist/113.index.js +6 -6
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +270 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +13 -7
- package/dist/499.index.js +86 -0
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/609.index.js +741 -0
- 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 +56 -56
- 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 +14 -6
- package/src/badge.js +2 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/incremental.js +7 -5
- package/src/discovery/CLAUDE.md +48 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +331 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +120 -0
- package/src/discovery/memory.js +163 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +163 -7
- package/src/integrations/tickets.js +7 -6
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +30 -41
- package/src/llm-validator/providers.js +227 -0
- package/src/mcp/audit.js +2 -1
- package/src/mcp/tools.js +4 -3
- package/src/posture/CLAUDE.md +102 -1
- package/src/posture/accuracy-scorecard.js +59 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/auditor-walkthrough.js +9 -7
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-policy.js +7 -9
- 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/deterministic.js +3 -2
- package/src/posture/evidence-bundle.js +246 -0
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/exploitability-probability.js +2 -1
- 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/fleet.js +0 -0
- package/src/posture/grader-calibration.js +3 -4
- 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/logic-claims.js +266 -0
- package/src/posture/model-rescan.js +4 -3
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +404 -2
- 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/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +28 -4
- package/src/posture/risk-dollars.js +2 -2
- 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/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 +11 -1
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +9 -0
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sca/dep-confusion.js +2 -1
|
@@ -40,11 +40,12 @@
|
|
|
40
40
|
import * as fs from 'node:fs';
|
|
41
41
|
import * as path from 'node:path';
|
|
42
42
|
import * as yaml from '../util/yaml.js';
|
|
43
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
43
44
|
|
|
44
45
|
const POLICY_FILE = 'compliance.policy.yml';
|
|
45
46
|
|
|
46
47
|
export function loadPolicy(scanRoot) {
|
|
47
|
-
const fp =
|
|
48
|
+
const fp = statePath(scanRoot, POLICY_FILE);
|
|
48
49
|
if (!fs.existsSync(fp)) return null;
|
|
49
50
|
try {
|
|
50
51
|
const raw = fs.readFileSync(fp, 'utf8');
|
|
@@ -105,7 +106,7 @@ function _runCheck(check, ctx) {
|
|
|
105
106
|
}
|
|
106
107
|
if (check['sca-policy-has-entry']) {
|
|
107
108
|
const type = check['sca-policy-has-entry'];
|
|
108
|
-
const policyPath =
|
|
109
|
+
const policyPath = statePath(ctx.scanRoot, 'sca-policy.yml');
|
|
109
110
|
if (!fs.existsSync(policyPath)) return { passed: false, reason: 'sca-policy.yml not found' };
|
|
110
111
|
try {
|
|
111
112
|
const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
|
|
@@ -178,10 +179,9 @@ export function emitEvidenceJsonLd(report, scanRoot) {
|
|
|
178
179
|
narrative_evidence: c.evidence || [],
|
|
179
180
|
})),
|
|
180
181
|
};
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
} catch {}
|
|
182
|
+
// Through the seam — see the note in pqc-migration-plan.js. The report is
|
|
183
|
+
// still returned when writing is off; only the artifact is withheld.
|
|
184
|
+
safeWriteState(statePath(scanRoot, 'compliance-evidence.json'), JSON.stringify(jsonld, null, 2));
|
|
185
185
|
return jsonld;
|
|
186
186
|
}
|
|
187
187
|
|
|
@@ -209,9 +209,7 @@ export function emitEvidenceMarkdown(report, scanRoot) {
|
|
|
209
209
|
}
|
|
210
210
|
lines.push('');
|
|
211
211
|
}
|
|
212
|
-
|
|
213
|
-
fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'compliance-evidence.md'), lines.join('\n'));
|
|
214
|
-
} catch {}
|
|
212
|
+
safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), lines.join('\n'));
|
|
215
213
|
return lines.join('\n');
|
|
216
214
|
}
|
|
217
215
|
|
|
@@ -33,6 +33,7 @@ import * as yaml from '../util/yaml.js';
|
|
|
33
33
|
import { globFiles } from '../util/glob.js';
|
|
34
34
|
import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
|
|
35
35
|
|
|
36
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
36
37
|
const LANG_EXTS = {
|
|
37
38
|
javascript: ['.js', '.mjs', '.cjs', '.jsx'],
|
|
38
39
|
typescript: ['.ts', '.tsx'],
|
|
@@ -48,7 +49,7 @@ const LANG_EXTS = {
|
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
function rulesDir(scanRoot) {
|
|
51
|
-
return
|
|
52
|
+
return statePath(scanRoot, 'rules');
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export function loadCustomRules(scanRoot) {
|
|
@@ -318,10 +319,11 @@ export function applyCustomRules(scanRoot, fileContents) {
|
|
|
318
319
|
}
|
|
319
320
|
if (shadow.length) {
|
|
320
321
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
// Through the seam, so a read-only scan cannot leave shadow findings
|
|
323
|
+
// behind. Shadow rules are excluded from gates by design, which made this
|
|
324
|
+
// the least likely write for anyone to notice. (PRD M1)
|
|
325
|
+
safeWriteState(
|
|
326
|
+
statePath(scanRoot, 'shadow-findings.json'),
|
|
325
327
|
JSON.stringify({ generatedAt: new Date().toISOString(), findings: shadow }, null, 2),
|
|
326
328
|
);
|
|
327
329
|
} catch { /* non-fatal */ }
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
import * as fs from 'node:fs';
|
|
28
28
|
import * as path from 'node:path';
|
|
29
29
|
|
|
30
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
30
31
|
const OSV_API = 'https://api.osv.dev/v1/query';
|
|
31
|
-
|
|
32
|
-
const STATE_PATH = '.agentic-security/cve-alerts-state.json';
|
|
32
|
+
|
|
33
33
|
|
|
34
34
|
// Multi-ecosystem dep extraction. Each entry: { manifest filename → ecosystem }
|
|
35
35
|
const ECOSYSTEM_BY_MANIFEST = {
|
|
@@ -267,7 +267,7 @@ export async function _queryOsvForDep({ name, ecosystem }, { fetchImpl = globalT
|
|
|
267
267
|
// ─── State persistence ───────────────────────────────────────────────────
|
|
268
268
|
|
|
269
269
|
export function loadState(scanRoot) {
|
|
270
|
-
const fp =
|
|
270
|
+
const fp = statePath(scanRoot, 'cve-alerts-state.json');
|
|
271
271
|
if (!fs.existsSync(fp)) return { known: new Set(), lastRun: null };
|
|
272
272
|
try {
|
|
273
273
|
const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -276,7 +276,8 @@ export function loadState(scanRoot) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
export function persistState(scanRoot, state) {
|
|
279
|
-
const fp =
|
|
279
|
+
const fp = statePath(scanRoot, 'cve-alerts-state.json');
|
|
280
|
+
if (!stateWritesEnabled()) return;
|
|
280
281
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
281
282
|
fs.writeFileSync(fp, JSON.stringify({
|
|
282
283
|
known: [...state.known].sort(),
|
|
@@ -285,7 +286,7 @@ export function persistState(scanRoot, state) {
|
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
export function loadConfig(scanRoot) {
|
|
288
|
-
const fp =
|
|
289
|
+
const fp = statePath(scanRoot, 'cve-alerts.json');
|
|
289
290
|
if (!fs.existsSync(fp)) return null;
|
|
290
291
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
291
292
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import * as fs from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
|
|
22
|
+
import { statePath } from './state-dir.js';
|
|
22
23
|
const CACHE = path.join(process.env.HOME || '/tmp', '.claude', 'agentic-security', 'osv-cache');
|
|
23
24
|
const TYPOSQUAT_LEVENSHTEIN = 2;
|
|
24
25
|
const NEW_PACKAGE_WINDOW_DAYS = 7;
|
|
@@ -61,7 +62,7 @@ function _loadPopular(ecosystem) {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
function _loadPolicy(scanRoot) {
|
|
64
|
-
const fp =
|
|
65
|
+
const fp = statePath(scanRoot, 'sca-policy.yml');
|
|
65
66
|
if (!fs.existsSync(fp)) return { deny: [] };
|
|
66
67
|
try {
|
|
67
68
|
const body = fs.readFileSync(fp, 'utf8');
|
|
@@ -16,6 +16,7 @@ import * as path from 'node:path';
|
|
|
16
16
|
import * as crypto from 'node:crypto';
|
|
17
17
|
import { PACKS } from './rule-packs.js';
|
|
18
18
|
|
|
19
|
+
import { stateDir, statePath } from './state-dir.js';
|
|
19
20
|
export const SCANNER_VERSION = '0.39.2';
|
|
20
21
|
const LOCK_FILE = 'rules.lock.json';
|
|
21
22
|
|
|
@@ -40,7 +41,7 @@ export function buildLockfile() {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function writeLockfile(scanRoot) {
|
|
43
|
-
const dir =
|
|
44
|
+
const dir = stateDir(scanRoot);
|
|
44
45
|
fs.mkdirSync(dir, { recursive: true });
|
|
45
46
|
const fp = path.join(dir, LOCK_FILE);
|
|
46
47
|
const lock = buildLockfile();
|
|
@@ -49,7 +50,7 @@ export function writeLockfile(scanRoot) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export function readLockfile(scanRoot) {
|
|
52
|
-
const fp =
|
|
53
|
+
const fp = statePath(scanRoot, LOCK_FILE);
|
|
53
54
|
if (!fs.existsSync(fp)) return null;
|
|
54
55
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
55
56
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Per-finding evidence bundles, signed so a THIRD PARTY can verify them.
|
|
2
|
+
// PRD Phase 2 / D2.
|
|
3
|
+
//
|
|
4
|
+
// WHAT WAS MISSING
|
|
5
|
+
// ----------------
|
|
6
|
+
// `attestation.js` already turns a run into a checkable digest, and it is
|
|
7
|
+
// explicit about its own ceiling: the signature is a per-install SYMMETRIC HMAC,
|
|
8
|
+
// so it is "tamper-evidence for the operator, not third-party non-repudiation."
|
|
9
|
+
// Anyone who can verify it can also forge it. That is fine for detecting local
|
|
10
|
+
// corruption and useless for handing a finding to somebody else.
|
|
11
|
+
//
|
|
12
|
+
// This module closes that gap for the artefact that actually travels: a single
|
|
13
|
+
// finding plus the evidence behind it. A buyer, an auditor, or a downstream
|
|
14
|
+
// consumer can check the bundle with the PUBLIC key alone, having never had
|
|
15
|
+
// access to ours.
|
|
16
|
+
//
|
|
17
|
+
// WHY A SEPARATE MODULE RATHER THAN EXTENDING attestation.js
|
|
18
|
+
// ----------------------------------------------------------
|
|
19
|
+
// They answer different questions and must not be conflated. A run attestation
|
|
20
|
+
// says "this set of findings came from this engine, ruleset and bundle". An
|
|
21
|
+
// evidence bundle says "THIS finding is backed by THIS evidence, and here is
|
|
22
|
+
// proof the claim was not edited after the fact." One is about reproducibility,
|
|
23
|
+
// the other about portability. Merging them would produce an artefact that is
|
|
24
|
+
// worse at both.
|
|
25
|
+
//
|
|
26
|
+
// WHAT A BUNDLE PROVES, AND WHAT IT DOES NOT
|
|
27
|
+
// -------------------------------------------
|
|
28
|
+
// PROVES — the bundle's contents are exactly what the holder of the signing key
|
|
29
|
+
// attested, unmodified. Nothing in it has been added, removed, or edited since.
|
|
30
|
+
//
|
|
31
|
+
// DOES NOT PROVE — that the finding is real. A signature is an integrity claim,
|
|
32
|
+
// never a correctness claim. An `unproven` finding in a signed bundle is still
|
|
33
|
+
// an unproven finding; the signature only stops someone silently upgrading the
|
|
34
|
+
// word `unproven` to `execution-proven` in transit. Both statements are carried
|
|
35
|
+
// INSIDE the bundle so they travel with it and cannot be dropped by a
|
|
36
|
+
// summariser.
|
|
37
|
+
//
|
|
38
|
+
// The evidence chain is deliberately the honest one this engine already
|
|
39
|
+
// computes: the proof tier, what the sandbox observed, which backend ran it, the
|
|
40
|
+
// taint path if there is one. A bundle for a regex finding says so plainly
|
|
41
|
+
// rather than dressing it up.
|
|
42
|
+
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as os from 'node:os';
|
|
45
|
+
import * as path from 'node:path';
|
|
46
|
+
import * as crypto from 'node:crypto';
|
|
47
|
+
|
|
48
|
+
export const BUNDLE_SCHEMA = 'agentic-security/finding-evidence@1';
|
|
49
|
+
|
|
50
|
+
/** Where the Ed25519 signing key lives. Mirrors integrity.js's key handling. */
|
|
51
|
+
function keyDir() {
|
|
52
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
53
|
+
return path.join(xdg, 'agentic-security');
|
|
54
|
+
}
|
|
55
|
+
const PRIVATE_KEY_FILE = 'attest-key.pem';
|
|
56
|
+
const PUBLIC_KEY_FILE = 'attest-key.pub.pem';
|
|
57
|
+
|
|
58
|
+
export function keyPaths(dir = keyDir()) {
|
|
59
|
+
return {
|
|
60
|
+
privateKey: path.join(dir, PRIVATE_KEY_FILE),
|
|
61
|
+
publicKey: path.join(dir, PUBLIC_KEY_FILE),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load the signing key pair, generating one on first use.
|
|
67
|
+
*
|
|
68
|
+
* Ed25519 from node:crypto — no new dependency, and deliberately not a
|
|
69
|
+
* transparency-log scheme: those need a network round trip, and this project
|
|
70
|
+
* does not make runtime cloud calls. A log-backed option can layer on later
|
|
71
|
+
* without changing the bundle format.
|
|
72
|
+
*/
|
|
73
|
+
export function ensureKeyPair(dir = keyDir()) {
|
|
74
|
+
const p = keyPaths(dir);
|
|
75
|
+
|
|
76
|
+
// NO existsSync-then-read. This project's own scanner flagged the first
|
|
77
|
+
// version of this function for TOCTOU (CWE-367) and it was right: between an
|
|
78
|
+
// existence check and the read, an attacker who can write this directory can
|
|
79
|
+
// swap the file, and the thing being swapped is a SIGNING KEY. Winning that
|
|
80
|
+
// race means we sign with a key the attacker controls, which defeats the
|
|
81
|
+
// entire point of the module. Read first and treat absence as the exceptional
|
|
82
|
+
// case — there is then no window between the check and the use, because there
|
|
83
|
+
// is no check.
|
|
84
|
+
try {
|
|
85
|
+
const privateKeyPem = fs.readFileSync(p.privateKey, 'utf8');
|
|
86
|
+
const publicKeyPem = fs.readFileSync(p.publicKey, 'utf8');
|
|
87
|
+
if (privateKeyPem && publicKeyPem) {
|
|
88
|
+
return { privateKeyPem, publicKeyPem, created: false, ...p };
|
|
89
|
+
}
|
|
90
|
+
} catch { /* missing or unreadable — fall through and generate */ }
|
|
91
|
+
|
|
92
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
|
93
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
94
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
|
|
97
|
+
// `wx` — exclusive create. If another process generated a key between our
|
|
98
|
+
// failed read and this write, we must NOT clobber it: every bundle already
|
|
99
|
+
// signed with that key would silently stop verifying. On collision, re-read
|
|
100
|
+
// and use the winner's key.
|
|
101
|
+
try {
|
|
102
|
+
fs.writeFileSync(p.privateKey, privateKeyPem, { mode: 0o600, flag: 'wx' });
|
|
103
|
+
fs.writeFileSync(p.publicKey, publicKeyPem, { mode: 0o644, flag: 'w' });
|
|
104
|
+
return { privateKeyPem, publicKeyPem, created: true, ...p };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e.code !== 'EEXIST') throw e;
|
|
107
|
+
return {
|
|
108
|
+
privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
|
|
109
|
+
publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
|
|
110
|
+
created: false,
|
|
111
|
+
...p,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Canonical bytes for signing.
|
|
118
|
+
*
|
|
119
|
+
* An ALLOWLIST, exactly as attestation.js canonicalises: a field that is not
|
|
120
|
+
* named here is not signed, so a future addition cannot silently enter the
|
|
121
|
+
* signed surface. Sorted keys, so serialisation order cannot change the
|
|
122
|
+
* signature.
|
|
123
|
+
*/
|
|
124
|
+
export function canonicalBytes(bundle) {
|
|
125
|
+
const signed = {
|
|
126
|
+
schema: bundle.schema,
|
|
127
|
+
finding: bundle.finding,
|
|
128
|
+
evidence: bundle.evidence,
|
|
129
|
+
engine: bundle.engine,
|
|
130
|
+
proves: bundle.proves,
|
|
131
|
+
doesNotProve: bundle.doesNotProve,
|
|
132
|
+
};
|
|
133
|
+
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Deterministic JSON: keys sorted at EVERY level, arrays order-preserving.
|
|
138
|
+
*
|
|
139
|
+
* Written by hand rather than using JSON.stringify's replacer argument, and the
|
|
140
|
+
* reason matters. The first version of this function did
|
|
141
|
+
* `JSON.stringify(signed, Object.keys(signed).sort())`, believing the array
|
|
142
|
+
* argument was a top-level field allowlist. It is not — a replacer ARRAY is a
|
|
143
|
+
* key filter applied at every nesting depth, so every nested object serialised
|
|
144
|
+
* as `{}` and the signature covered nothing but the two prose strings. A bundle
|
|
145
|
+
* whose severity was edited from `high` to `critical` verified as authentic.
|
|
146
|
+
*
|
|
147
|
+
* That is the worst possible failure for this module: a security feature that
|
|
148
|
+
* appears to work, produces a valid-looking signature, and protects nothing. It
|
|
149
|
+
* was caught by tampering with a bundle and expecting verification to fail —
|
|
150
|
+
* which is why the tamper cases in the test file are not optional extras.
|
|
151
|
+
*/
|
|
152
|
+
export function canonicalJson(value) {
|
|
153
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
|
|
154
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
155
|
+
const keys = Object.keys(value).sort();
|
|
156
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const PROVES =
|
|
160
|
+
'The contents of this bundle are exactly what the signer attested, unmodified.';
|
|
161
|
+
const DOES_NOT_PROVE =
|
|
162
|
+
'That the finding is real. A signature is an integrity claim, never a correctness ' +
|
|
163
|
+
'claim — read `evidence.proofTier` for how well-supported the finding actually is. ' +
|
|
164
|
+
'An unproven finding in a signed bundle is still an unproven finding.';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build an unsigned bundle from a finding.
|
|
168
|
+
*
|
|
169
|
+
* Everything here is copied from what the engine already computed. Nothing is
|
|
170
|
+
* inferred, and a missing field stays missing rather than being defaulted into
|
|
171
|
+
* something more confident-looking than the truth.
|
|
172
|
+
*/
|
|
173
|
+
export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bundleSha, commit } = {}) {
|
|
174
|
+
if (!finding || typeof finding !== 'object') return null;
|
|
175
|
+
return {
|
|
176
|
+
schema: BUNDLE_SCHEMA,
|
|
177
|
+
finding: {
|
|
178
|
+
id: finding.id ?? null,
|
|
179
|
+
stableId: finding.stableId ?? null,
|
|
180
|
+
severity: finding.severity ?? null,
|
|
181
|
+
file: finding.file ?? null,
|
|
182
|
+
line: finding.line ?? null,
|
|
183
|
+
vuln: finding.vuln ?? null,
|
|
184
|
+
cwe: finding.cwe ?? null,
|
|
185
|
+
family: finding.family ?? null,
|
|
186
|
+
parser: finding.parser ?? null,
|
|
187
|
+
},
|
|
188
|
+
evidence: {
|
|
189
|
+
// The honest chain, in the vocabulary the engine already uses.
|
|
190
|
+
proofTier: finding.proofTier ?? null,
|
|
191
|
+
proofEvidence: finding.proofEvidence ?? null,
|
|
192
|
+
confidence: finding.confidence ?? null,
|
|
193
|
+
exploitability: finding.exploitability ?? null,
|
|
194
|
+
unreachable: finding.unreachable ?? null,
|
|
195
|
+
// Present only for the layers that produce them; absent means absent.
|
|
196
|
+
taintPath: finding.pathSteps ?? finding.exampleFlows ?? null,
|
|
197
|
+
discovery: finding.discovery ?? null,
|
|
198
|
+
},
|
|
199
|
+
engine: {
|
|
200
|
+
engineVersion: engineVersion ?? null,
|
|
201
|
+
rulesetVersion: rulesetVersion ?? null,
|
|
202
|
+
bundleSha: bundleSha ?? null,
|
|
203
|
+
commit: commit ?? null,
|
|
204
|
+
},
|
|
205
|
+
proves: PROVES,
|
|
206
|
+
doesNotProve: DOES_NOT_PROVE,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Sign a bundle. Returns a new object; the input is not mutated. */
|
|
211
|
+
export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
212
|
+
const sig = crypto.sign(null, canonicalBytes(bundle), privateKeyPem);
|
|
213
|
+
return {
|
|
214
|
+
...bundle,
|
|
215
|
+
signature: {
|
|
216
|
+
algorithm: 'ed25519',
|
|
217
|
+
canonicalisation: BUNDLE_SCHEMA,
|
|
218
|
+
value: sig.toString('base64'),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Verify a bundle with a PUBLIC key. This is the whole point: the verifier
|
|
225
|
+
* never needs, and never had, the signing key.
|
|
226
|
+
*
|
|
227
|
+
* Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
|
|
228
|
+
* source is an expected input, not an exceptional one.
|
|
229
|
+
*/
|
|
230
|
+
export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
231
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
232
|
+
if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
233
|
+
const sig = bundle.signature;
|
|
234
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
235
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
236
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
237
|
+
let ok = false;
|
|
238
|
+
try {
|
|
239
|
+
ok = crypto.verify(null, canonicalBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { ok: false, reason: `verification error: ${e.message}` };
|
|
242
|
+
}
|
|
243
|
+
return ok
|
|
244
|
+
? { ok: true, reason: null }
|
|
245
|
+
: { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
246
|
+
}
|
|
@@ -51,7 +51,31 @@ function _materialise(root, files) {
|
|
|
51
51
|
* patch: pass the patched contents and a still-`execution-proven` verdict
|
|
52
52
|
* means the fix did not close the hole.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
// How long a proof-of-concept gets to write its marker.
|
|
55
|
+
//
|
|
56
|
+
// WHY THIS IS GENEROUS, AND WHY THAT IS NEARLY FREE. The budget is only ever
|
|
57
|
+
// spent when a PoC is stuck: a working one writes its marker and exits in about
|
|
58
|
+
// a second, so raising the ceiling costs nothing in the common path. What a
|
|
59
|
+
// tight ceiling DOES cost is correctness — the budget covers spawning a
|
|
60
|
+
// confined process and starting a Node runtime inside it, and on a loaded
|
|
61
|
+
// machine that alone can eat several seconds. At 10s this timed out under
|
|
62
|
+
// ordinary parallel test load and reported "re-verification did not execute",
|
|
63
|
+
// which the release gate then surfaced as a failure. The measurement has to be
|
|
64
|
+
// of the proof-of-concept, not of how busy the machine happened to be.
|
|
65
|
+
//
|
|
66
|
+
// A timeout is still never evidence about the finding: `proven` is decided by
|
|
67
|
+
// the marker file, and a timed-out run falls back to the finding's static tier
|
|
68
|
+
// rather than claiming `proof-failed`. This ceiling only decides how long we
|
|
69
|
+
// wait before giving up, not what we conclude.
|
|
70
|
+
//
|
|
71
|
+
// Override on a slow or heavily-loaded runner, matching the convention used by
|
|
72
|
+
// AGENTIC_SECURITY_PY_PROBE_TIMEOUT_MS and AGENTIC_SECURITY_DEEP_TIMEOUT_MS.
|
|
73
|
+
export const DEFAULT_PROOF_TIMEOUT_MS =
|
|
74
|
+
Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS) > 0
|
|
75
|
+
? Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS)
|
|
76
|
+
: 45000;
|
|
77
|
+
|
|
78
|
+
export async function proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_MS, force, files } = {}) {
|
|
55
79
|
const poc = finding?.poc;
|
|
56
80
|
if (!poc?.code) {
|
|
57
81
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|
|
@@ -35,6 +35,7 @@ import * as fs from 'node:fs';
|
|
|
35
35
|
import * as path from 'node:path';
|
|
36
36
|
import { wilsonInterval } from './calibration.js';
|
|
37
37
|
|
|
38
|
+
import { statePath } from './state-dir.js';
|
|
38
39
|
// CISA KEV-derived base rate per CWE family. These are rough mid-2025
|
|
39
40
|
// observations of "actually exploited in the wild" rates among findings
|
|
40
41
|
// of the family, not academic numbers. Refresh annually.
|
|
@@ -138,7 +139,7 @@ function _clamp01(x) { return Math.max(0.001, Math.min(0.999, x)); }
|
|
|
138
139
|
// (cwe × language × framework) grain.
|
|
139
140
|
function _loadHistory(scanRoot) {
|
|
140
141
|
if (!scanRoot) return [];
|
|
141
|
-
const fp =
|
|
142
|
+
const fp = statePath(scanRoot, 'exploit-history.jsonl');
|
|
142
143
|
if (!fs.existsSync(fp)) return [];
|
|
143
144
|
const out = [];
|
|
144
145
|
try {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import * as fs from 'node:fs';
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
|
|
24
|
+
import { statePath } from './state-dir.js';
|
|
24
25
|
const FLAG_PATTERNS = [
|
|
25
26
|
// LaunchDarkly
|
|
26
27
|
[/\bldClient\.variation\s*\(\s*['"`]([^'"`]+)['"`]/g, 'launchdarkly'],
|
|
@@ -44,8 +45,8 @@ const FLAG_PATTERNS = [
|
|
|
44
45
|
|
|
45
46
|
function loadRollouts(scanRoot) {
|
|
46
47
|
const candidates = [
|
|
47
|
-
'
|
|
48
|
-
'
|
|
48
|
+
'feature-flag-rollouts.json',
|
|
49
|
+
'feature-flags.json',
|
|
49
50
|
];
|
|
50
51
|
for (const rel of candidates) {
|
|
51
52
|
const fp = path.join(scanRoot || process.cwd(), rel);
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
import * as fs from 'node:fs';
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
|
|
18
|
-
const STATE = '.agentic-security';
|
|
19
18
|
|
|
19
|
+
import { statePath } from './state-dir.js';
|
|
20
20
|
function _read(scanRoot, name) {
|
|
21
|
-
try { return fs.readFileSync(
|
|
21
|
+
try { return fs.readFileSync(statePath(scanRoot, name), 'utf8'); } catch { return null; }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function _readJson(scanRoot, name) {
|
|
@@ -99,7 +99,7 @@ export function queryFindingsMemory(scanRoot, query) {
|
|
|
99
99
|
|
|
100
100
|
// 3. Scan history.
|
|
101
101
|
try {
|
|
102
|
-
const histDir =
|
|
102
|
+
const histDir = statePath(scanRoot, 'scan-history');
|
|
103
103
|
if (fs.existsSync(histDir)) {
|
|
104
104
|
const files = fs.readdirSync(histDir).filter(f => f.endsWith('.json')).slice(-10);
|
|
105
105
|
for (const f of files) {
|
|
@@ -12,7 +12,7 @@ import * as fs from 'node:fs';
|
|
|
12
12
|
import * as fsp from 'node:fs/promises';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import * as crypto from 'node:crypto';
|
|
15
|
-
import { isSafeStateDir, statePath } from './state-dir.js';
|
|
15
|
+
import { isSafeStateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
16
16
|
|
|
17
17
|
function historyDir(scanRoot) {
|
|
18
18
|
return statePath(scanRoot, 'fix-history');
|
|
@@ -20,6 +20,9 @@ function historyDir(scanRoot) {
|
|
|
20
20
|
function logPath(scanRoot) { return path.join(historyDir(scanRoot), 'log.json'); }
|
|
21
21
|
|
|
22
22
|
function ensure(scanRoot) {
|
|
23
|
+
// Read-only scan: callers already treat `false` as "history unavailable",
|
|
24
|
+
// so the switch needs no new branch anywhere else.
|
|
25
|
+
if (!stateWritesEnabled()) return false;
|
|
23
26
|
const dir = historyDir(scanRoot);
|
|
24
27
|
if (!isSafeStateDir(path.dirname(dir))) return false;
|
|
25
28
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -65,7 +68,7 @@ function sha(s) { return crypto.createHash('sha256').update(s).digest('hex').sli
|
|
|
65
68
|
// findings by `id` (the finding's canonical key from the engine) so we can
|
|
66
69
|
// stash the corresponding stableId on the fix entry and verify in recover().
|
|
67
70
|
function _lastScanPath(scanRoot) {
|
|
68
|
-
return
|
|
71
|
+
return statePath(scanRoot, 'last-scan.json');
|
|
69
72
|
}
|
|
70
73
|
function _readLastScan(scanRoot) {
|
|
71
74
|
const fp = _lastScanPath(scanRoot);
|
|
@@ -34,9 +34,8 @@
|
|
|
34
34
|
|
|
35
35
|
import fs from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
|
-
import { isSafeStateDir } from './state-dir.js';
|
|
37
|
+
import { isSafeStateDir, stateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
38
38
|
|
|
39
|
-
const STATE_DIR = '.agentic-security';
|
|
40
39
|
const LOG_FILE = 'fix-metrics.jsonl';
|
|
41
40
|
|
|
42
41
|
// Below this many samples a percentile is an artifact of the sample, not a
|
|
@@ -49,7 +48,7 @@ const RELIABLE_N = 10;
|
|
|
49
48
|
export const FIX_STAGES = Object.freeze(['rescan', 'lint', 'tests', 'honesty', 'poc']);
|
|
50
49
|
|
|
51
50
|
function _logPath(scanRoot) {
|
|
52
|
-
return
|
|
51
|
+
return statePath(scanRoot, LOG_FILE);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
/**
|
|
@@ -61,9 +60,10 @@ function _logPath(scanRoot) {
|
|
|
61
60
|
export function recordFixAttempt(scanRoot, record) {
|
|
62
61
|
if (!scanRoot || !record || typeof record !== 'object') return false;
|
|
63
62
|
try {
|
|
64
|
-
const dir =
|
|
63
|
+
const dir = stateDir(scanRoot);
|
|
65
64
|
if (!isSafeStateDir(dir)) return false;
|
|
66
|
-
|
|
65
|
+
if (!stateWritesEnabled()) return;
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
67
|
// One writeSync of one newline-terminated line: a concurrent reader sees
|
|
68
68
|
// whole records or nothing, and a torn tail is dropped on read.
|
|
69
69
|
fs.appendFileSync(_logPath(scanRoot), JSON.stringify(record) + '\n', 'utf8');
|
package/src/posture/fix-plan.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as fs from 'node:fs';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
|
|
15
|
+
import { statePath } from './state-dir.js';
|
|
15
16
|
const MAX_FILES = 3;
|
|
16
17
|
const MAX_LOC = 100;
|
|
17
18
|
|
|
@@ -103,7 +104,7 @@ export function renderFixPlan(finding, opts = {}) {
|
|
|
103
104
|
// Returns the absolute path of the written file, or null on error.
|
|
104
105
|
export function emitFixPlanFile(scanRoot, finding, opts = {}) {
|
|
105
106
|
if (!scanRoot || !finding) return null;
|
|
106
|
-
const dir =
|
|
107
|
+
const dir = statePath(scanRoot, 'fix-plans');
|
|
107
108
|
try { fs.mkdirSync(dir, { recursive: true }); } catch { return null; }
|
|
108
109
|
const id = finding.stableId || finding.id || `unknown-${Date.now().toString(36)}`;
|
|
109
110
|
const fp = path.join(dir, `${id}.md`);
|
|
Binary file
|
|
@@ -26,18 +26,17 @@
|
|
|
26
26
|
import * as fs from 'node:fs';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
28
|
|
|
29
|
-
const TRIAGE_FILE = '.agentic-security/triage-feedback.json';
|
|
30
|
-
const SCAN_FILE = '.agentic-security/last-scan.json';
|
|
31
29
|
|
|
30
|
+
import { statePath } from './state-dir.js';
|
|
32
31
|
function _loadTriageFeedback(scanRoot) {
|
|
33
|
-
const fp =
|
|
32
|
+
const fp = statePath(scanRoot, 'triage-feedback.json');
|
|
34
33
|
if (!fs.existsSync(fp)) return [];
|
|
35
34
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')).entries || []; }
|
|
36
35
|
catch { return []; }
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
function _loadScanVerdicts(scanRoot) {
|
|
40
|
-
const fp =
|
|
39
|
+
const fp = statePath(scanRoot, 'last-scan.json');
|
|
41
40
|
if (!fs.existsSync(fp)) return [];
|
|
42
41
|
try {
|
|
43
42
|
const scan = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import * as fs from 'node:fs';
|
|
30
30
|
import * as path from 'node:path';
|
|
31
31
|
|
|
32
|
+
import { statePath } from './state-dir.js';
|
|
32
33
|
const INTENT_PATH_RE = /(?:^|\/)(?:examples?|demos?|tutorials?|sandbox|playground|challenges?|ctf)(?:\/|$)/i;
|
|
33
34
|
|
|
34
35
|
const FILE_HEADER_MARKERS = [
|
|
@@ -49,7 +50,7 @@ function _readSafely(fp) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function _readIntentDeclaration(scanRoot) {
|
|
52
|
-
const fp =
|
|
53
|
+
const fp = statePath(scanRoot, 'current-intent.md');
|
|
53
54
|
if (!fs.existsSync(fp)) return null;
|
|
54
55
|
const body = _readSafely(fp);
|
|
55
56
|
if (!body) return null;
|
package/src/posture/learning.js
CHANGED
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
import * as fs from 'node:fs';
|
|
23
23
|
import * as path from 'node:path';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
26
26
|
|
|
27
27
|
export function loadFeedback(scanRoot) {
|
|
28
28
|
if (!scanRoot) return { entries: [] };
|
|
29
|
-
const fp =
|
|
29
|
+
const fp = statePath(scanRoot, 'triage-feedback.json');
|
|
30
30
|
if (!fs.existsSync(fp)) return { entries: [] };
|
|
31
31
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')) || { entries: [] }; }
|
|
32
32
|
catch { return { entries: [] }; }
|
|
@@ -34,7 +34,8 @@ export function loadFeedback(scanRoot) {
|
|
|
34
34
|
|
|
35
35
|
export function saveFeedback(scanRoot, data) {
|
|
36
36
|
if (!scanRoot) return;
|
|
37
|
-
const fp =
|
|
37
|
+
const fp = statePath(scanRoot, 'triage-feedback.json');
|
|
38
|
+
if (!stateWritesEnabled()) return;
|
|
38
39
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
39
40
|
fs.writeFileSync(fp, JSON.stringify(data, null, 2));
|
|
40
41
|
}
|