@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
|
@@ -96,7 +96,11 @@ export function annotateScaReverseBlast(findings, fileContents) {
|
|
|
96
96
|
if (!Object.keys(map).length) return findings;
|
|
97
97
|
for (const f of findings) {
|
|
98
98
|
if (!f || typeof f !== 'object') continue;
|
|
99
|
-
|
|
99
|
+
// SCA findings (engine.js's queryOSV) carry the package name as `.name`
|
|
100
|
+
// — `.package`/`.dependency`/`.pkg` are never set anywhere in this
|
|
101
|
+
// codebase; kept as a fallback in case a caller supplies a differently-
|
|
102
|
+
// shaped finding.
|
|
103
|
+
const pkg = f.name || f.package || f.dependency || f.pkg;
|
|
100
104
|
if (!pkg || !map[pkg]) continue;
|
|
101
105
|
f.reverseExposure = {
|
|
102
106
|
importerCount: map[pkg].directImporters.length,
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
|
|
26
|
-
const STATE = '.agentic-security';
|
|
27
26
|
|
|
27
|
+
import { statePath } from './state-dir.js';
|
|
28
28
|
// Base rates per family (annual probability of at-least-one exploit given
|
|
29
29
|
// an exposed instance). Rough industry estimates; tune via config.
|
|
30
30
|
const FAMILY_BASE_PROB = {
|
|
@@ -79,7 +79,7 @@ const REACH_DISCOUNT = {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
function _loadConfig(scanRoot) {
|
|
82
|
-
const fp =
|
|
82
|
+
const fp = statePath(scanRoot, 'risk-config.yml');
|
|
83
83
|
if (!fs.existsSync(fp)) return null;
|
|
84
84
|
try {
|
|
85
85
|
const body = fs.readFileSync(fp, 'utf8');
|
|
@@ -110,8 +110,25 @@ function _impactFor(finding, cfg) {
|
|
|
110
110
|
return table.default;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// SCA entries carry reachabilityTier/routeReachable (engine.js's SCA
|
|
114
|
+
// reachability pass); SAST findings never do — they carry relevanceTier/
|
|
115
|
+
// entrypointReachable instead (posture/relevance.js). Without this
|
|
116
|
+
// fallback, _reachDiscount always read 'unknown' (0.3) for every SAST
|
|
117
|
+
// finding, regardless of whether it was actually route-reachable.
|
|
118
|
+
function _relevanceTierToReachTier(relevanceTier) {
|
|
119
|
+
switch (relevanceTier) {
|
|
120
|
+
case 'direct': return 'route-reachable';
|
|
121
|
+
case 'indirect': return 'function-reachable';
|
|
122
|
+
case 'unreachable': return 'unreachable';
|
|
123
|
+
default: return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
function _reachDiscount(finding) {
|
|
114
|
-
const tier = finding.reachabilityTier
|
|
128
|
+
const tier = finding.reachabilityTier
|
|
129
|
+
|| (finding.routeReachable && 'route-reachable')
|
|
130
|
+
|| _relevanceTierToReachTier(finding.relevanceTier)
|
|
131
|
+
|| 'unknown';
|
|
115
132
|
return REACH_DISCOUNT[tier] || 0.3;
|
|
116
133
|
}
|
|
117
134
|
|
package/src/posture/router.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import * as fs from 'node:fs';
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
|
|
20
|
+
import { stateDir, statePath } from './state-dir.js';
|
|
20
21
|
function readJson(fp) {
|
|
21
22
|
if (!fs.existsSync(fp)) return null;
|
|
22
23
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
@@ -32,7 +33,7 @@ function ageHours(fp) {
|
|
|
32
33
|
// history to say anything honest. (premortem: never invent a trend from one
|
|
33
34
|
// data point.)
|
|
34
35
|
export function computeScanTrend(scanRoot) {
|
|
35
|
-
const hist = readJson(
|
|
36
|
+
const hist = readJson(statePath(scanRoot, 'scan-history.json'));
|
|
36
37
|
if (!Array.isArray(hist) || hist.length < 2) return {};
|
|
37
38
|
const cur = hist[hist.length - 1];
|
|
38
39
|
const prev = hist[hist.length - 2];
|
|
@@ -55,9 +56,9 @@ export function decide(opts) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
function baseDecision({ scanRoot, intent }) {
|
|
58
|
-
const
|
|
59
|
-
const lastScan = readJson(path.join(
|
|
60
|
-
const scanAge = ageHours(path.join(
|
|
59
|
+
const stateDirPath = stateDir(scanRoot);
|
|
60
|
+
const lastScan = readJson(path.join(stateDirPath, 'last-scan.json'));
|
|
61
|
+
const scanAge = ageHours(path.join(stateDirPath, 'last-scan.json'));
|
|
61
62
|
|
|
62
63
|
if (!lastScan) {
|
|
63
64
|
return {
|
|
@@ -25,14 +25,14 @@ import * as fs from 'node:fs';
|
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import { SCANNER_VERSION } from './version.js';
|
|
27
27
|
|
|
28
|
+
import { statePath } from './state-dir.js';
|
|
28
29
|
// Tied to scanner/package.json via posture/version.js — they cannot diverge
|
|
29
30
|
// (premortem 3R1.3).
|
|
30
31
|
export const CURRENT_RULESET_VERSION = SCANNER_VERSION;
|
|
31
32
|
|
|
32
|
-
const FILE = '.agentic-security/ruleset-version.json';
|
|
33
33
|
|
|
34
34
|
export function readPinned(scanRoot) {
|
|
35
|
-
const fp =
|
|
35
|
+
const fp = statePath(scanRoot || process.cwd(), 'ruleset-version.json');
|
|
36
36
|
if (!fs.existsSync(fp)) return null;
|
|
37
37
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
|
|
38
38
|
catch { return null; }
|
|
@@ -35,12 +35,13 @@ import * as path from 'node:path';
|
|
|
35
35
|
import * as readline from 'node:readline';
|
|
36
36
|
import { createReadStream } from 'node:fs';
|
|
37
37
|
|
|
38
|
+
import { statePath } from './state-dir.js';
|
|
38
39
|
const DEFAULT_TRACE_NAMES = ['runtime-trace.jsonl', 'runtime.jsonl', 'ebpf-trace.jsonl'];
|
|
39
40
|
const DEFAULT_OBSERVATION_WINDOW_DAYS = 30;
|
|
40
41
|
|
|
41
42
|
export async function loadTrace(scanRoot, opts = {}) {
|
|
42
43
|
const explicit = opts.tracePath || process.env.AGENTIC_SECURITY_RUNTIME_TRACE_PATH;
|
|
43
|
-
const candidates = explicit ? [explicit] : DEFAULT_TRACE_NAMES.map(n =>
|
|
44
|
+
const candidates = explicit ? [explicit] : DEFAULT_TRACE_NAMES.map(n => statePath(scanRoot, n));
|
|
44
45
|
let chosen = null;
|
|
45
46
|
for (const c of candidates) {
|
|
46
47
|
if (fs.existsSync(c)) { chosen = c; break; }
|
package/src/posture/sbom-diff.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// module produces the diff on the next scan.
|
|
22
22
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
24
25
|
import * as path from 'node:path';
|
|
25
26
|
import * as crypto from 'node:crypto';
|
|
26
27
|
import { execSync } from 'node:child_process';
|
|
@@ -28,7 +29,7 @@ import { execSync } from 'node:child_process';
|
|
|
28
29
|
const HISTORY_DIR = 'sbom-history';
|
|
29
30
|
|
|
30
31
|
function _historyDir(scanRoot) {
|
|
31
|
-
return
|
|
32
|
+
return statePath(scanRoot, HISTORY_DIR);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function _gitHead(scanRoot) {
|
|
@@ -47,7 +48,11 @@ function _snapshotKey(component) {
|
|
|
47
48
|
*/
|
|
48
49
|
export function persistSbom(scanRoot, components) {
|
|
49
50
|
const dir = _historyDir(scanRoot);
|
|
50
|
-
|
|
51
|
+
// The mkdir is inside the switch too. Guarding only the write still left an
|
|
52
|
+
// empty `sbom-history/` in the scanned tree — invisible to `git status`,
|
|
53
|
+
// because git does not track empty directories, and therefore exactly the
|
|
54
|
+
// kind of mutation that passes a clean-status check while still being one.
|
|
55
|
+
if (stateWritesEnabled()) { try { fs.mkdirSync(dir, { recursive: true }); } catch {} }
|
|
51
56
|
const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
|
|
52
57
|
const snap = {
|
|
53
58
|
sha, ts: new Date().toISOString(),
|
|
@@ -58,7 +63,11 @@ export function persistSbom(scanRoot, components) {
|
|
|
58
63
|
sha256: c.sha256 || c.integrity || null,
|
|
59
64
|
})),
|
|
60
65
|
};
|
|
61
|
-
|
|
66
|
+
// NON_MUTATING_SCAN_PRD S1 — history is a feature, but not at the cost of
|
|
67
|
+
// mutating a tree the caller only asked us to read.
|
|
68
|
+
if (stateWritesEnabled()) {
|
|
69
|
+
try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
|
|
70
|
+
}
|
|
62
71
|
return snap;
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -40,6 +40,7 @@ import * as fs from 'node:fs';
|
|
|
40
40
|
import * as path from 'node:path';
|
|
41
41
|
import * as yaml from '../util/yaml.js';
|
|
42
42
|
|
|
43
|
+
import { safeWriteState, stateDir, statePath } from './state-dir.js';
|
|
43
44
|
const DEFAULT_POLICY = {
|
|
44
45
|
acceptRisk: [],
|
|
45
46
|
sla: {},
|
|
@@ -49,7 +50,7 @@ const DEFAULT_POLICY = {
|
|
|
49
50
|
export function loadScaPolicy(scanRoot) {
|
|
50
51
|
if (!scanRoot) return null;
|
|
51
52
|
for (const name of ['sca-policy.yml', 'sca-policy.yaml', 'sca-policy.json']) {
|
|
52
|
-
const p =
|
|
53
|
+
const p = statePath(scanRoot, name);
|
|
53
54
|
if (!fs.existsSync(p)) continue;
|
|
54
55
|
try {
|
|
55
56
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -183,7 +184,7 @@ export function applyScaPolicy(findings, policy, scanTime = new Date()) {
|
|
|
183
184
|
// If the policy file doesn't exist, one is created with safe defaults.
|
|
184
185
|
export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
185
186
|
if (!scanRoot || !finding) return { ok: false, reason: 'missing arguments' };
|
|
186
|
-
const dir =
|
|
187
|
+
const dir = stateDir(scanRoot);
|
|
187
188
|
const fp = path.join(dir, 'sca-policy.yml');
|
|
188
189
|
let policy = loadScaPolicy(scanRoot);
|
|
189
190
|
if (policy && policy._error) return { ok: false, reason: policy._error };
|
|
@@ -209,7 +210,6 @@ export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
|
209
210
|
};
|
|
210
211
|
policy.acceptRisk.push(newEntry);
|
|
211
212
|
|
|
212
|
-
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
213
213
|
const serialized = yaml.dump({
|
|
214
214
|
'accept-risk': policy.acceptRisk.map(e => {
|
|
215
215
|
const o = {};
|
|
@@ -224,7 +224,10 @@ export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
|
224
224
|
sla: policy.sla && Object.keys(policy.sla).length ? Object.fromEntries(Object.entries(policy.sla).map(([k, v]) => [k, _formatSlaDuration(v)])) : undefined,
|
|
225
225
|
'major-version-freeze': policy.majorVersionFreeze && Object.keys(policy.majorVersionFreeze).length ? policy.majorVersionFreeze : undefined,
|
|
226
226
|
});
|
|
227
|
-
|
|
227
|
+
// Through the seam. This one is user-initiated (a triage decision), so a
|
|
228
|
+
// refusal is reported rather than swallowed: silently not recording an
|
|
229
|
+
// accepted risk would be worse than failing loudly. (PRD M1)
|
|
230
|
+
if (!safeWriteState(fp, serialized)) return { ok: false, reason: 'state writes disabled or path outside a project root', path: fp };
|
|
228
231
|
return { ok: true, entry: newEntry, path: fp };
|
|
229
232
|
}
|
|
230
233
|
|
|
@@ -43,6 +43,15 @@ import * as path from 'node:path';
|
|
|
43
43
|
import * as crypto from 'node:crypto';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
45
|
|
|
46
|
+
import { stateWritesEnabled } from './state-dir.js';
|
|
47
|
+
|
|
48
|
+
// DELIBERATELY NOT routed through statePath(). `resolveProjectRoot()` falls
|
|
49
|
+
// back to walking upward from process.cwd() when the given root does not exist
|
|
50
|
+
// on disk, so `checkpointPath('/some/root')` resolved into the SCANNER'S OWN
|
|
51
|
+
// SOURCE TREE — caught by the test that asserts a checkpoint is never written
|
|
52
|
+
// there. A checkpoint must land in the root it was handed or nowhere; silently
|
|
53
|
+
// relocating it into another directory is the exact failure this line of work
|
|
54
|
+
// exists to prevent. The read-only switch above is still honoured.
|
|
46
55
|
const STATE_DIR = '.agentic-security';
|
|
47
56
|
const FILE_NAME = 'scan-checkpoint.jsonl';
|
|
48
57
|
const FORMAT = 'agentic-security-scan-checkpoint/1';
|
|
@@ -191,6 +200,12 @@ function _recover(handle, file, runKey) {
|
|
|
191
200
|
*/
|
|
192
201
|
export function openCheckpoint(scanRoot, { runKey } = {}) {
|
|
193
202
|
if (!scanRoot || !runKey) return _emptyHandle('no-run-key');
|
|
203
|
+
// A read-only scan cannot checkpoint, and must not try. Resume is purely an
|
|
204
|
+
// optimisation — without it the scan recomputes, which is slower and
|
|
205
|
+
// identical — so `--no-state` wins over `AGENTIC_SECURITY_RESUME=1` rather
|
|
206
|
+
// than the two conflicting. The disabled handle no-ops through the rest of
|
|
207
|
+
// the API, so no caller needs a new branch. (PRD M1)
|
|
208
|
+
if (!stateWritesEnabled()) return _emptyHandle('state-writes-disabled');
|
|
194
209
|
const handle = _emptyHandle(null);
|
|
195
210
|
try {
|
|
196
211
|
const dir = path.join(scanRoot, STATE_DIR);
|
|
@@ -31,17 +31,26 @@ export function scanHistoryDiff(diffText, commit, detectFn) {
|
|
|
31
31
|
if (!added.trim()) return [];
|
|
32
32
|
let findings = [];
|
|
33
33
|
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
34
|
-
return findings.map((f) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
return findings.map((f) => {
|
|
35
|
+
const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
|
|
36
|
+
return {
|
|
37
|
+
...f,
|
|
38
|
+
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
39
|
+
file: `git-history@${commit}`,
|
|
40
|
+
line: 0,
|
|
41
|
+
commit,
|
|
42
|
+
_historical: true,
|
|
43
|
+
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
44
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
45
|
+
remediation,
|
|
46
|
+
// report/index.js's _remediationOf checks `.fix` before `.remediation`
|
|
47
|
+
// — the underlying detector already set `.fix` to a generic "remove
|
|
48
|
+
// the line" string, which would otherwise silently shadow this
|
|
49
|
+
// history-specific instruction ("removing it from HEAD alone is
|
|
50
|
+
// insufficient") in every report format.
|
|
51
|
+
fix: remediation,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/**
|
|
@@ -35,7 +35,13 @@ function _snapshotFromScan(scan, label) {
|
|
|
35
35
|
medium: findings.filter(f => f.severity === 'medium').length,
|
|
36
36
|
low: findings.filter(f => f.severity === 'low').length,
|
|
37
37
|
kev: findings.filter(f => f.kev).length,
|
|
38
|
-
|
|
38
|
+
// stable-id.js exists specifically because the default `id` embeds file
|
|
39
|
+
// path + line number, so any refactor that shifts a line rotates the id
|
|
40
|
+
// — using it here would report the same unfixed vulnerability as one
|
|
41
|
+
// "fixed" finding and one "introduced" finding on every such shift.
|
|
42
|
+
// stableId omits the exact line by design; fall back to `id` only for
|
|
43
|
+
// finding shapes that never got a stableId annotated.
|
|
44
|
+
ids: new Set(findings.map(f => f.stableId || f.id).filter(Boolean)),
|
|
39
45
|
};
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -185,6 +185,18 @@ function _buildPlaybook(stack) {
|
|
|
185
185
|
]});
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Express
|
|
189
|
+
if (stack.has('express')) {
|
|
190
|
+
sections.push({ title: 'Express', items: [
|
|
191
|
+
'Use helmet() to set security headers (X-Frame-Options, X-Content-Type-Options, HSTS) — Express sets none of these by default',
|
|
192
|
+
'Never use body-parser / express.json() without a size limit — set `limit` explicitly to prevent request-body DoS',
|
|
193
|
+
'Apply express-rate-limit to authentication and any expensive routes',
|
|
194
|
+
'Validate and sanitize all req.params / req.query / req.body — Express does not validate input for you',
|
|
195
|
+
'Set `app.disable(\'x-powered-by\')` so error responses and headers do not advertise the framework/version to attackers',
|
|
196
|
+
'Use a CSRF middleware (e.g. csrf-csrf) on any route that relies on cookie-based sessions',
|
|
197
|
+
]});
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
// Django
|
|
189
201
|
if (stack.has('django')) {
|
|
190
202
|
sections.push({ title: 'Django', items: [
|
|
@@ -202,7 +214,16 @@ function _buildPlaybook(stack) {
|
|
|
202
214
|
function _findingFromItem(scanRoot, stackName, item, idx) {
|
|
203
215
|
return {
|
|
204
216
|
id: `stack-playbook:${stackName.replace(/\s+/g, '_').toUpperCase()}:${idx}`,
|
|
205
|
-
|
|
217
|
+
// The findings schema requires `vuln` (root CLAUDE.md); this used to set
|
|
218
|
+
// `title` instead, which isn't a schema field at all. engine.js's generic
|
|
219
|
+
// no-vuln-name filter (`_shouldKeep`) treats any non-SCA finding with no
|
|
220
|
+
// `vuln` string as unenriched noise and drops it — silently, for every
|
|
221
|
+
// stack, confirmed live via a real scan whose logicVulns went from 6
|
|
222
|
+
// playbook findings right after they were pushed to 0 by the time the
|
|
223
|
+
// scan returned. `vuln` is now the actionable string this finding is
|
|
224
|
+
// actually about; `description`/`remediation` (already correct) keep
|
|
225
|
+
// the fuller text.
|
|
226
|
+
vuln: `[${stackName} Security Checklist] ${item.slice(0, 80)}`,
|
|
206
227
|
severity: 'info',
|
|
207
228
|
file: 'package.json',
|
|
208
229
|
line: 1,
|
package/src/posture/state-dir.js
CHANGED
|
@@ -111,9 +111,42 @@ export function isSafeStateDir(dir) {
|
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// --- Read-only scanning (NON_MUTATING_SCAN_PRD S1) ---------------------------
|
|
115
|
+
//
|
|
116
|
+
// A scan is an OBSERVATION. Pointing the engine at a directory should not
|
|
117
|
+
// change it. Until this switch existed, `scan .` wrote seven-plus files into the
|
|
118
|
+
// scanned tree, which breaks CI that asserts a clean worktree, leaves artefacts
|
|
119
|
+
// in third-party code the user does not own, and — measured on this project's
|
|
120
|
+
// own benchmark — let a second scan read the first scan's conclusions as source.
|
|
121
|
+
//
|
|
122
|
+
// This is deliberately a KILL SWITCH AT THE SEAM rather than a parameter
|
|
123
|
+
// threaded through 72 call sites. Threading it would mean 72 chances to forget,
|
|
124
|
+
// and the module this lives in exists precisely because that kind of
|
|
125
|
+
// per-caller discipline already failed once: its header records a user who
|
|
126
|
+
// uninstalled the plugin after stray state directories broke their build.
|
|
127
|
+
let _stateWritesEnabled = true;
|
|
128
|
+
|
|
129
|
+
/** Turn all state writing off (or back on) for this process. */
|
|
130
|
+
export function setStateWritesEnabled(enabled) {
|
|
131
|
+
_stateWritesEnabled = Boolean(enabled);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* False when writing is disabled by the CLI flag or the environment.
|
|
136
|
+
*
|
|
137
|
+
* The env var is read at CALL time, not captured at import, so a test or a
|
|
138
|
+
* caller can set it after the module is loaded — the same mistake that made the
|
|
139
|
+
* gate verdict cache silently never engage.
|
|
140
|
+
*/
|
|
141
|
+
export function stateWritesEnabled() {
|
|
142
|
+
if (process.env.AGENTIC_SECURITY_NO_STATE === '1') return false;
|
|
143
|
+
return _stateWritesEnabled;
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
// Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
|
|
115
147
|
// Returns the dir on success, null if refused. Logs a warning when refused.
|
|
116
148
|
export function ensureStateDir(scanRoot) {
|
|
149
|
+
if (!stateWritesEnabled()) return null;
|
|
117
150
|
const dir = stateDir(scanRoot);
|
|
118
151
|
if (!isSafeStateDir(dir)) {
|
|
119
152
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
@@ -132,6 +165,7 @@ export function ensureStateDir(scanRoot) {
|
|
|
132
165
|
// Safe write: only writes if isSafeStateDir(parent) returns true.
|
|
133
166
|
// Returns true on success, false if refused or errored.
|
|
134
167
|
export function safeWriteState(filePath, content) {
|
|
168
|
+
if (!stateWritesEnabled()) return false;
|
|
135
169
|
const dir = path.dirname(filePath);
|
|
136
170
|
if (!isSafeStateDir(dir)) {
|
|
137
171
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
import * as fs from 'node:fs';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
28
|
|
|
29
|
+
import { statePath } from './state-dir.js';
|
|
29
30
|
const CANDIDATE_PATHS = [
|
|
30
|
-
'
|
|
31
|
-
'
|
|
31
|
+
'telemetry.json',
|
|
32
|
+
'prod-telemetry.json',
|
|
32
33
|
];
|
|
33
34
|
|
|
34
35
|
const HOT_THRESHOLD = 1000; // requests / window — promotes to hot
|
|
@@ -37,7 +38,7 @@ const COLD_THRESHOLD = 0; // exactly zero requests → cold
|
|
|
37
38
|
export function loadTelemetry(scanRoot) {
|
|
38
39
|
const root = scanRoot || process.cwd();
|
|
39
40
|
for (const rel of CANDIDATE_PATHS) {
|
|
40
|
-
const fp =
|
|
41
|
+
const fp = statePath(root, rel);
|
|
41
42
|
if (!fs.existsSync(fp)) continue;
|
|
42
43
|
try {
|
|
43
44
|
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// .agentic-security/threat-model.md (human-readable).
|
|
24
24
|
|
|
25
25
|
import * as fs from 'node:fs';
|
|
26
|
+
import { stateDir, stateWritesEnabled } from './state-dir.js';
|
|
26
27
|
import * as path from 'node:path';
|
|
27
28
|
|
|
28
29
|
// STRIDE category descriptors
|
|
@@ -211,7 +212,9 @@ function _mid(id) { return String(id).replace(/[^A-Za-z0-9]/g, '_').slice(0, 60)
|
|
|
211
212
|
* Persist threat model to disk: JSON for tooling, Markdown for review.
|
|
212
213
|
*/
|
|
213
214
|
export function persistThreatModel(scanRoot, model) {
|
|
214
|
-
|
|
215
|
+
// NON_MUTATING_SCAN_PRD S1 — a scan must not modify the tree it scans.
|
|
216
|
+
if (!stateWritesEnabled()) return;
|
|
217
|
+
const dir = stateDir(scanRoot);
|
|
215
218
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
216
219
|
try { fs.writeFileSync(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2)); } catch {}
|
|
217
220
|
try { fs.writeFileSync(path.join(dir, 'threat-model.md'), renderMarkdown(model)); } catch {}
|
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
// SOC2 / HIPAA / GDPR) adds compliance-tag fields to findings in
|
|
14
14
|
// matching families (PII → HIPAA/GDPR; auth → SOC2 CC6.1; etc.).
|
|
15
15
|
// - **Stated attacker** — "## Attacker model" / "## Threat actor"
|
|
16
|
-
// section sets f.
|
|
17
|
-
// for use in downstream prioritization.
|
|
16
|
+
// section sets f.threatModel.attacker = 'script-kiddie' | 'apt' |
|
|
17
|
+
// 'insider' for use in downstream prioritization.
|
|
18
18
|
//
|
|
19
19
|
// Opt-out: AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING=1
|
|
20
20
|
|
|
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 DOC_PATHS = [
|
|
25
26
|
'CLAUDE.md',
|
|
26
27
|
'docs/THREAT-MODEL.md',
|
|
@@ -31,7 +32,16 @@ const DOC_PATHS = [
|
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
function _readDoc(scanRoot, rel) {
|
|
34
|
-
|
|
35
|
+
// DOC_PATHS is a MIXED list: most entries are ordinary repository paths
|
|
36
|
+
// (CLAUDE.md, docs/THREAT-MODEL.md) and exactly one lives under the state
|
|
37
|
+
// directory. Routing the whole list through statePath() made every document
|
|
38
|
+
// resolve to `.agentic-security/<name>` and broke threat-model loading
|
|
39
|
+
// entirely — five tests caught it. Only the state entry uses the seam.
|
|
40
|
+
const STATE_PREFIX = '.agentic-security/';
|
|
41
|
+
const fp = rel.startsWith(STATE_PREFIX)
|
|
42
|
+
? statePath(scanRoot, rel.slice(STATE_PREFIX.length))
|
|
43
|
+
: path.join(scanRoot, rel);
|
|
44
|
+
try { return fs.readFileSync(fp, 'utf8'); } catch { return ''; }
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
function _allDocs(scanRoot) {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import * as fs from 'node:fs';
|
|
14
14
|
import * as path from 'node:path';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
import { statePath } from './state-dir.js';
|
|
17
18
|
const HISTORY_FILE = 'fix-history/log.json';
|
|
18
19
|
|
|
19
20
|
// Family base estimates (hours). Tuned from typical patch shapes.
|
|
@@ -50,7 +51,7 @@ const FAMILY_BASE_HOURS = {
|
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
function _loadFixHistory(scanRoot) {
|
|
53
|
-
const fp =
|
|
54
|
+
const fp = statePath(scanRoot, HISTORY_FILE);
|
|
54
55
|
if (!fs.existsSync(fp)) return [];
|
|
55
56
|
try {
|
|
56
57
|
const arr = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
import * as fs from 'node:fs';
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
import { stateDir, stateWritesEnabled } from './state-dir.js';
|
|
25
25
|
const MEMORY_FILE = 'triage-memory.jsonl';
|
|
26
26
|
const AGENTS_FILE = 'AGENTS.md';
|
|
27
27
|
|
|
28
|
-
function _stateDir(scanRoot) { return
|
|
28
|
+
function _stateDir(scanRoot) { return stateDir(scanRoot); }
|
|
29
29
|
function _memPath(scanRoot) { return path.join(_stateDir(scanRoot), MEMORY_FILE); }
|
|
30
30
|
function _agentsPath(scanRoot) { return path.join(_stateDir(scanRoot), AGENTS_FILE); }
|
|
31
31
|
|
|
@@ -43,6 +43,7 @@ function _bucketKey(finding) {
|
|
|
43
43
|
export function recordDecision(scanRoot, finding, decision, reason) {
|
|
44
44
|
if (!scanRoot || !finding || !decision) return null;
|
|
45
45
|
if (!['wont-fix', 'false-positive'].includes(decision)) return null;
|
|
46
|
+
if (!stateWritesEnabled()) return false;
|
|
46
47
|
try { fs.mkdirSync(_stateDir(scanRoot), { recursive: true }); } catch {}
|
|
47
48
|
|
|
48
49
|
const entry = {
|
|
@@ -92,15 +92,22 @@ export function recordTriage(scanRoot, { family, verdict, stableId }) {
|
|
|
92
92
|
const data = _read(scanRoot);
|
|
93
93
|
data.productionTriage = data.productionTriage || {};
|
|
94
94
|
const row = data.productionTriage[family] = data.productionTriage[family] || { tp: 0, fp: 0, wontfix: 0, lastAt: null };
|
|
95
|
+
void stableId;
|
|
96
|
+
// Already frozen from a previous call — `_capped: true` was persisted at
|
|
97
|
+
// the moment the cap was crossed (below), so this is a deliberate,
|
|
98
|
+
// visible freeze: nothing new is written, but nothing was silently lost
|
|
99
|
+
// either. Previously the crossing call itself never called _write, so
|
|
100
|
+
// `_capped` never reached disk and EVERY call after the cap — not just
|
|
101
|
+
// more of the same verdict, any verdict — silently vanished with the
|
|
102
|
+
// on-disk row frozen one write short of the real crossing point.
|
|
103
|
+
if (row._capped) return row;
|
|
95
104
|
row[verdict] = (row[verdict] || 0) + 1;
|
|
96
105
|
row.lastAt = new Date().toISOString();
|
|
97
106
|
// Cap per-family rows so a runaway triage script can't bloat the file.
|
|
107
|
+
// The crossing call still writes — that's what makes the freeze visible.
|
|
98
108
|
if ((row.tp || 0) + (row.fp || 0) + (row.wontfix || 0) > 10_000) {
|
|
99
|
-
// Stop accumulating; the trend is well-established by now.
|
|
100
109
|
row._capped = true;
|
|
101
|
-
return row;
|
|
102
110
|
}
|
|
103
|
-
void stableId;
|
|
104
111
|
_write(scanRoot, data);
|
|
105
112
|
return row;
|
|
106
113
|
}
|