@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.0
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 +880 -0
- package/bin/agentic-security.js +189 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +192 -15
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- 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/engine.js +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- 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/clustering.js +12 -1
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- 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/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/sbom.js +2 -2
- 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/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- 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/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 +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/bin/agentic-security.js
CHANGED
|
@@ -14,7 +14,6 @@ import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
|
14
14
|
import { toPBOM } from '../src/sast/pipeline.js';
|
|
15
15
|
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
16
16
|
import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
|
|
17
|
-
import { ingestAndMerge } from '../src/sca/sarif-ingest.js';
|
|
18
17
|
import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
|
|
19
18
|
import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
|
|
20
19
|
import { applyOverrides, validateOverrides, suppressionReport, renderSuppressionSummary } from '../src/posture/rule-overrides.js';
|
|
@@ -28,7 +27,6 @@ import { syncTickets } from '../src/integrations/tickets.js';
|
|
|
28
27
|
import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
|
|
29
28
|
import * as triage from '../src/posture/triage.js';
|
|
30
29
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
31
|
-
import { globFiles } from '../src/util/glob.js';
|
|
32
30
|
|
|
33
31
|
import { stateDir, statePath } from '../src/posture/state-dir.js';
|
|
34
32
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
@@ -111,7 +109,6 @@ Options:
|
|
|
111
109
|
--show-threat-model Append the auto-derived STRIDE threat model summary
|
|
112
110
|
--show-drift Append calibration-drift alarms (overconfidence detection)
|
|
113
111
|
--sca-reachable-only Only SCA findings where the vulnerable function is reachable
|
|
114
|
-
--ingest-sarif <glob> Merge external SARIF into this scan
|
|
115
112
|
--scorecard Enrich components with OSSF Scorecard scores
|
|
116
113
|
--no-network Skip OSV/registry queries (offline mode)
|
|
117
114
|
--pr [ref] Diff-aware: scan only files changed since ref (auto-detects PR base)
|
|
@@ -297,7 +294,7 @@ function _bundleSha() {
|
|
|
297
294
|
return 'unavailable';
|
|
298
295
|
}
|
|
299
296
|
|
|
300
|
-
async function writeMachineOutput(targetAbs, scan, meta, profile) {
|
|
297
|
+
async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
|
|
301
298
|
const stateDirPath = stateDir(targetAbs);
|
|
302
299
|
const { isSafeStateDir: _isSafe, stateWritesEnabled: _writesOn } = await import('../src/posture/state-dir.js');
|
|
303
300
|
// Read-only scan (NON_MUTATING_SCAN_PRD S1). These writes bypass
|
|
@@ -311,7 +308,7 @@ async function writeMachineOutput(targetAbs, scan, meta, profile) {
|
|
|
311
308
|
// Always JSON (used by /security-fix and /security-report).
|
|
312
309
|
await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
|
|
313
310
|
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
314
|
-
if (profile.profile === 'pro' || profile.machineOutput) {
|
|
311
|
+
if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
|
|
315
312
|
await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
|
|
316
313
|
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
317
314
|
await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
|
|
@@ -436,13 +433,23 @@ async function cmdScan(args) {
|
|
|
436
433
|
if (process.stderr.isTTY) process.stderr.write(`\r[${p.phase}] ${p.current}/${p.total} ${p.file} `);
|
|
437
434
|
},
|
|
438
435
|
});
|
|
436
|
+
// The BOM/attestation emitters stamp the producing engine's version into
|
|
437
|
+
// their metadata; carry the real package version so it can never drift.
|
|
438
|
+
if (meta && meta.engineVersion == null) meta.engineVersion = PKG_VERSION;
|
|
439
439
|
if (process.stderr.isTTY) process.stderr.write('\r' + ' '.repeat(80) + '\r');
|
|
440
440
|
|
|
441
441
|
const only = args.flags.only;
|
|
442
442
|
if (only) {
|
|
443
|
+
// Four channels exist (scanner/CLAUDE.md): findings (SAST), secrets,
|
|
444
|
+
// supplyChain (SCA), logicVulns (business-logic). --only sast keeps
|
|
445
|
+
// logicVulns (business-logic is source analysis, part of the SAST
|
|
446
|
+
// pillar); --only sca/secrets must also clear it — it's neither SCA
|
|
447
|
+
// nor secrets, and normalizeFindings()/exitCodeFor() both fold it in,
|
|
448
|
+
// so leaving it meant a business-logic finding leaked into a
|
|
449
|
+
// single-pillar scan's output AND its exit code.
|
|
443
450
|
if (only === 'sast') { scan.secrets = []; scan.supplyChain = []; }
|
|
444
|
-
if (only === 'sca') { scan.findings = []; scan.secrets = []; }
|
|
445
|
-
if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; }
|
|
451
|
+
if (only === 'sca') { scan.findings = []; scan.secrets = []; scan.logicVulns = []; }
|
|
452
|
+
if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; scan.logicVulns = []; }
|
|
446
453
|
}
|
|
447
454
|
|
|
448
455
|
// --set-baseline: save current findings as baseline for future --since-baseline filtering
|
|
@@ -494,18 +501,6 @@ async function cmdScan(args) {
|
|
|
494
501
|
// 0.9.0 Feat-18: --scorecard flag enables OSSF Scorecard enrichment
|
|
495
502
|
if (args.flags['scorecard']) process.env.AGENTIC_SECURITY_SCORECARD = '1';
|
|
496
503
|
|
|
497
|
-
// 0.7.0 Feat-7: --ingest-sarif <path-or-glob> merges SARIF from external tools (Semgrep,
|
|
498
|
-
// gitleaks, Bandit, Trivy, Checkov, etc.) into this scan's findings, deduping by
|
|
499
|
-
// fingerprint and tracking provenance via sources[].
|
|
500
|
-
if (args.flags['ingest-sarif']) {
|
|
501
|
-
const glob = args.flags['ingest-sarif'];
|
|
502
|
-
const paths = await globFiles(glob);
|
|
503
|
-
if (paths.length) {
|
|
504
|
-
const r = ingestAndMerge(scan, paths);
|
|
505
|
-
if (process.stderr.isTTY) process.stderr.write(`[ingest] merged ${r.merged} / added ${r.added} findings from ${paths.length} SARIF file(s)\n`);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
504
|
// 0.6.0 Feat-1: --sca-reachable-only filters to only SCA findings where the vulnerable
|
|
510
505
|
// function was confirmed reachable from a route handler.
|
|
511
506
|
if (args.flags['sca-reachable-only']) {
|
|
@@ -566,6 +561,31 @@ async function cmdScan(args) {
|
|
|
566
561
|
try { await enrichWithEPSS(scan); } catch {}
|
|
567
562
|
}
|
|
568
563
|
|
|
564
|
+
// Re-run risk-dollars now that both signals it needs are actually
|
|
565
|
+
// available: EPSS (just enriched above — engine.js's own call ran before
|
|
566
|
+
// this, so every finding's epssScore was still unset) and relevanceTier
|
|
567
|
+
// (posture/relevance.js runs last inside runScan/engine.js, so it's
|
|
568
|
+
// populated on `scan.findings` by the time we get here, but wasn't yet
|
|
569
|
+
// when engine.js's own annotateRiskDollars call ran mid-pipeline).
|
|
570
|
+
try {
|
|
571
|
+
const { annotateRiskDollars } = await import('../src/posture/risk-dollars.js');
|
|
572
|
+
annotateRiskDollars(targetAbs, scan.findings || []);
|
|
573
|
+
} catch {}
|
|
574
|
+
|
|
575
|
+
// Re-run composite risk for the same reason as annotateRiskDollars above:
|
|
576
|
+
// engine.js's mid-pipeline annotateCompositeRisk calls ran before
|
|
577
|
+
// enrichWithEPSS (just above) had a chance to set f.exploitedNow, so
|
|
578
|
+
// every finding's 'exploited-now-floor:75' boost was computed against a
|
|
579
|
+
// field that was always undefined at the time — an actively-exploited
|
|
580
|
+
// CVE (EPSS percentile >= 0.95) never got its compositeRisk score raised.
|
|
581
|
+
// annotateCompositeRisk is pure/idempotent (only sets compositeRisk*
|
|
582
|
+
// fields, reads everything else), so re-running it here is safe.
|
|
583
|
+
try {
|
|
584
|
+
const { annotateCompositeRisk } = await import('../src/posture/composite-risk.js');
|
|
585
|
+
annotateCompositeRisk(scan.findings || []);
|
|
586
|
+
annotateCompositeRisk(scan.supplyChain || []);
|
|
587
|
+
} catch {}
|
|
588
|
+
|
|
569
589
|
// Blast-radius narrative — purely local, always safe to run.
|
|
570
590
|
if (!args.flags['no-blast-radius']) {
|
|
571
591
|
try { enrichWithBlastRadius(scan, targetAbs); } catch {}
|
|
@@ -622,7 +642,7 @@ async function cmdScan(args) {
|
|
|
622
642
|
} catch { /* attestation is metadata; never fail a scan over it */ }
|
|
623
643
|
|
|
624
644
|
// R2: Always emit machine-readable artifacts to .agentic-security/.
|
|
625
|
-
await writeMachineOutput(targetAbs, scan, meta, profile);
|
|
645
|
+
await writeMachineOutput(targetAbs, scan, meta, profile, args);
|
|
626
646
|
|
|
627
647
|
const includeSuppressed = !!args.flags['include-suppressed'];
|
|
628
648
|
let body;
|
|
@@ -664,32 +684,70 @@ async function cmdScan(args) {
|
|
|
664
684
|
// Persist last scan for /security-fix and /security-report
|
|
665
685
|
const { isSafeStateDir: _isSafeStateDir, stateWritesEnabled: _writesOnScan } = await import('../src/posture/state-dir.js');
|
|
666
686
|
const stateDirPath = stateDir(path.resolve(target));
|
|
687
|
+
// S7 (Stage-0 audit): declared here, not with `const` inside the `if`
|
|
688
|
+
// below — it used to be block-scoped there while recordScan() further
|
|
689
|
+
// down referenced it unconditionally, throwing a ReferenceError on every
|
|
690
|
+
// single scan (silently swallowed by that call's empty catch{}). streak.json
|
|
691
|
+
// — grades, streak days, achievements — never actually persisted through
|
|
692
|
+
// the real CLI as a result, reproduced live before this fix.
|
|
693
|
+
let persistedScan = null;
|
|
667
694
|
if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
|
|
668
695
|
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
669
|
-
|
|
696
|
+
persistedScan = toJSON(scan, meta);
|
|
670
697
|
// #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
|
|
671
698
|
// every finding carries an age, SLA breaches can be surfaced, and the fix
|
|
672
699
|
// loop can report time-to-clean. Best-effort; skipped under --deterministic
|
|
673
700
|
// so deterministic state stays byte-identical run-to-run.
|
|
674
701
|
if (!args.flags.deterministic) {
|
|
675
702
|
try {
|
|
676
|
-
const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
|
|
703
|
+
const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary, fingerprintFinding, computeMTTR } = await import('../src/posture/mttr.js');
|
|
677
704
|
let baselineMap = new Map();
|
|
705
|
+
let prevAll = [];
|
|
678
706
|
try {
|
|
679
707
|
const prev = JSON.parse(await fsp.readFile(path.join(stateDirPath, 'last-scan.json'), 'utf8'));
|
|
680
708
|
baselineMap = buildBaselineMap(prev);
|
|
709
|
+
// The previous scan's own findings already carry firstSeenAt/lastSeenAt
|
|
710
|
+
// from ITS baseline lookup — exactly the shape computeMTTR needs for
|
|
711
|
+
// whichever of them are no longer present now (i.e. were fixed).
|
|
712
|
+
prevAll = [
|
|
713
|
+
...(prev?.findings || []), ...(prev?.secrets || []),
|
|
714
|
+
...(prev?.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
|
|
715
|
+
];
|
|
681
716
|
} catch { /* first run — empty baseline, everything is firstSeen now */ }
|
|
682
717
|
const now = Date.now();
|
|
683
718
|
stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
|
|
684
719
|
stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
|
|
685
720
|
stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
|
|
721
|
+
// #10 — MTTR: which of the previous scan's findings are no longer
|
|
722
|
+
// present now (fixed), and how long each took. computeMTTR itself was
|
|
723
|
+
// fully built and tested but had never had a real caller — the module's
|
|
724
|
+
// own comment calls it "true MTTR," distinct from the open-backlog
|
|
725
|
+
// median-age proxy renderSlaSummary already surfaces below.
|
|
726
|
+
const currentFps = new Set([
|
|
727
|
+
...(persistedScan.findings || []), ...(persistedScan.secrets || []),
|
|
728
|
+
...(persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
|
|
729
|
+
].map(fingerprintFinding));
|
|
730
|
+
const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
|
|
731
|
+
persistedScan.mttr = computeMTTR(removed);
|
|
686
732
|
// Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
|
|
687
733
|
const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
|
|
688
734
|
if (!isJson) {
|
|
689
735
|
const sla = renderSlaSummary(persistedScan.findings || []);
|
|
690
736
|
if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
|
|
737
|
+
if (persistedScan.mttr.count > 0) {
|
|
738
|
+
process.stderr.write(`✅ agentic-security: ${persistedScan.mttr.count} finding(s) fixed since last scan, median ${Math.round(persistedScan.mttr.medianDays)}d to remediate\n`);
|
|
739
|
+
}
|
|
691
740
|
}
|
|
692
|
-
} catch {
|
|
741
|
+
} catch (e) {
|
|
742
|
+
// MTTR is best-effort — never block a scan write. But a silent catch
|
|
743
|
+
// here previously hid a real, deterministic CI-only failure (the
|
|
744
|
+
// mttr field went missing on every hosted-CI run, never locally, and
|
|
745
|
+
// nothing explained why) for long enough that it shipped several
|
|
746
|
+
// releases undiagnosed. Surface it — never fail the scan on it.
|
|
747
|
+
if (process.env.AGENTIC_SECURITY_MTTR_DEBUG === '1' || process.env.CI || process.env.GITHUB_ACTIONS) {
|
|
748
|
+
process.stderr.write(`agentic-security: MTTR computation failed (best-effort, scan unaffected): ${(e && e.stack) || e}\n`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
693
751
|
|
|
694
752
|
// R5 — report the observed time-to-validated-fix distribution from the
|
|
695
753
|
// fix attempts recorded by `verifyFix`. This is measurement, not
|
|
@@ -735,10 +793,17 @@ async function cmdScan(args) {
|
|
|
735
793
|
|
|
736
794
|
// 0.14.0 — update streak / achievements after every full scan. Suppress
|
|
737
795
|
// streak side effects when the user only wants raw JSON output (CI piping).
|
|
796
|
+
// Gated on `persistedScan !== null` (i.e. the same _writesOnScan() &&
|
|
797
|
+
// _isSafeStateDir() condition above) — recordScan()'s own isSafeStateDir
|
|
798
|
+
// check alone does not know about --no-state / AGENTIC_SECURITY_NO_STATE,
|
|
799
|
+
// so without this a "read-only, mutates nothing" scan would still write
|
|
800
|
+
// streak.json. This was accidentally masked before the persistedScan
|
|
801
|
+
// scoping fix (recordScan was never reached at all, for the wrong reason);
|
|
802
|
+
// fixing that reachability bug required adding this guard explicitly.
|
|
738
803
|
try {
|
|
739
|
-
const streak = recordScan(stateDirPath, persistedScan);
|
|
804
|
+
const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
|
|
740
805
|
// Print celebration / streak line to stderr so it doesn't pollute --format json
|
|
741
|
-
if (process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
|
|
806
|
+
if (streak && process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
|
|
742
807
|
const delta = formatGradeDelta(streak);
|
|
743
808
|
const line = formatStreakLine(streak);
|
|
744
809
|
if (delta) process.stderr.write('\n' + delta + '\n');
|
|
@@ -814,20 +879,48 @@ async function cmdCi(args) {
|
|
|
814
879
|
const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
|
|
815
880
|
if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
|
|
816
881
|
|
|
817
|
-
//
|
|
882
|
+
// R4 — same run attestation cmdScan attaches, computed here too so
|
|
883
|
+
// findings.json (the actual CI evidence artifact) carries the same
|
|
884
|
+
// tamper-evidence digest 'agentic-security scan' does, rather than
|
|
885
|
+
// always shipping attestation: null. Runs after every filter above, so
|
|
886
|
+
// it attests what actually ships. Metadata only — never fails the scan.
|
|
887
|
+
try {
|
|
888
|
+
const { computeRunAttestation } = await import('../src/posture/attestation.js');
|
|
889
|
+
const { keyProvenance } = await import('../src/posture/integrity.js');
|
|
890
|
+
const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
|
|
891
|
+
scan.attestation = computeRunAttestation({
|
|
892
|
+
findings: normalizeFindings(scan),
|
|
893
|
+
engineVersion: PKG_VERSION,
|
|
894
|
+
rulesetVersion: effectiveVersion(targetAbs).version,
|
|
895
|
+
bundleSha: _bundleSha(),
|
|
896
|
+
root: targetAbs,
|
|
897
|
+
sign: true,
|
|
898
|
+
});
|
|
899
|
+
if (scan.attestation) scan.attestation.keyProvenance = keyProvenance();
|
|
900
|
+
} catch { /* attestation is metadata; never fail a scan over it */ }
|
|
901
|
+
|
|
902
|
+
// Persist the three CI artifacts — but a refusal to write must NEVER skip
|
|
903
|
+
// the --fail-on evaluation below. S1 (Stage-0 audit, 2026): this used to be
|
|
904
|
+
// a bare `return;` inside the write-guard, so `cmdCi` returned `undefined`
|
|
905
|
+
// and `process.exit(await cmdCi(args))` became `process.exit(undefined)`,
|
|
906
|
+
// which Node treats as exit 0 — a CI pipeline reading only $? would see a
|
|
907
|
+
// passing build whose findings were never evaluated against --fail-on at
|
|
908
|
+
// all. The write and the gate are orthogonal: "we couldn't persist
|
|
909
|
+
// artifacts" is not evidence about "the scan is clean."
|
|
818
910
|
const stateDirPath = stateDir(targetAbs);
|
|
819
911
|
const { isSafeStateDir: _isSafeCi, stateWritesEnabled: _writesOnCi } = await import('../src/posture/state-dir.js');
|
|
820
|
-
|
|
912
|
+
const _canWriteCi = _writesOnCi() && _isSafeCi(stateDirPath);
|
|
913
|
+
if (!_canWriteCi) {
|
|
821
914
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
|
|
822
|
-
|
|
915
|
+
} else {
|
|
916
|
+
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
917
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
|
|
918
|
+
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
919
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
|
|
920
|
+
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
921
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
|
|
922
|
+
toJUnit(scan, meta));
|
|
823
923
|
}
|
|
824
|
-
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
825
|
-
await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
|
|
826
|
-
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
827
|
-
await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
|
|
828
|
-
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
829
|
-
await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
|
|
830
|
-
toJUnit(scan, meta));
|
|
831
924
|
|
|
832
925
|
const scanCode = exitCodeFor(scan);
|
|
833
926
|
const findings = normalizeFindings(scan);
|
|
@@ -835,7 +928,9 @@ async function cmdCi(args) {
|
|
|
835
928
|
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
836
929
|
process.stderr.write(
|
|
837
930
|
`[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
|
|
838
|
-
|
|
931
|
+
(_canWriteCi
|
|
932
|
+
? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
|
|
933
|
+
: `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
|
|
839
934
|
`[ci] fail-on=${failOn} scan-exit=${scanCode}\n`
|
|
840
935
|
);
|
|
841
936
|
// FR-SDLC-9: when --policy <file.rego> is supplied, evaluate against the
|
|
@@ -1758,16 +1853,73 @@ async function cmdAttest(args) {
|
|
|
1758
1853
|
return 0;
|
|
1759
1854
|
}
|
|
1760
1855
|
|
|
1856
|
+
// R4/D2 correctness follow-up: attestation.js ships TWO independent
|
|
1857
|
+
// verifiers — verifyEvidenceBundle (per-finding, Ed25519, self-contained —
|
|
1858
|
+
// only needs a public key) and verifyRunAttestation (whole-run, per-install
|
|
1859
|
+
// HMAC, NOT self-contained — needs the caller to also supply the finding
|
|
1860
|
+
// set to re-derive against). This CLI command only ever called the first;
|
|
1861
|
+
// the second had zero production callers anywhere in the repo (confirmed by
|
|
1862
|
+
// grep — only test/attestation.test.js called it). Auto-detect which
|
|
1863
|
+
// artifact was handed in and dispatch to the matching verifier instead of
|
|
1864
|
+
// silently misinterpreting a run-attestation as a (structurally different)
|
|
1865
|
+
// evidence bundle.
|
|
1866
|
+
function _asRunAttestation(obj) {
|
|
1867
|
+
if (obj && typeof obj.digest === 'string' && obj.canonicalisation) return obj;
|
|
1868
|
+
if (obj && obj.attestation && typeof obj.attestation.digest === 'string') return obj.attestation;
|
|
1869
|
+
return null;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// Verifies a run attestation the ONLY way it can be verified: by re-deriving
|
|
1873
|
+
// the digest from a fresh scan and comparing. Unlike an evidence bundle
|
|
1874
|
+
// (self-contained, just needs a public key), a run attestation is a claim
|
|
1875
|
+
// about a FINDING SET, not a standalone signed blob — "does scanning this
|
|
1876
|
+
// codebase right now reproduce the exact digest an earlier run attested"
|
|
1877
|
+
// is the actual, meaningful question this command answers.
|
|
1878
|
+
async function cmdVerifyRunAttestation(attestation, args) {
|
|
1879
|
+
const projectPath = path.resolve(args.flags.against || '.');
|
|
1880
|
+
if (!fs.existsSync(projectPath)) {
|
|
1881
|
+
console.error(`--against path does not exist: ${projectPath}`);
|
|
1882
|
+
return 2;
|
|
1883
|
+
}
|
|
1884
|
+
console.log(`Re-scanning ${projectPath} to check it reproduces the attested digest...`);
|
|
1885
|
+
const { runScan } = await import('../src/runScan.js');
|
|
1886
|
+
const { normalizeFindings } = await import('../src/report/index.js');
|
|
1887
|
+
const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
|
|
1888
|
+
const { verifyRunAttestation } = await import('../src/posture/attestation.js');
|
|
1889
|
+
const { scan } = await runScan(projectPath);
|
|
1890
|
+
const r = verifyRunAttestation(attestation, {
|
|
1891
|
+
findings: normalizeFindings(scan),
|
|
1892
|
+
engineVersion: PKG_VERSION,
|
|
1893
|
+
rulesetVersion: effectiveVersion(projectPath).version,
|
|
1894
|
+
bundleSha: _bundleSha(),
|
|
1895
|
+
root: projectPath,
|
|
1896
|
+
});
|
|
1897
|
+
if (!r.ok) {
|
|
1898
|
+
console.error(`✗ INVALID — ${r.reason}`);
|
|
1899
|
+
return 1;
|
|
1900
|
+
}
|
|
1901
|
+
console.log('✓ VALID — a fresh scan of this project reproduces the attested digest exactly.');
|
|
1902
|
+
console.log('');
|
|
1903
|
+
console.log(` digest: ${attestation.digest}`);
|
|
1904
|
+
console.log(` findingCount: ${attestation.findingCount}`);
|
|
1905
|
+
console.log(` proves: ${attestation.proves}`);
|
|
1906
|
+
console.log(` does NOT prove: ${attestation.doesNotProve}`);
|
|
1907
|
+
return 0;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1761
1910
|
async function cmdVerifyAttestation(args) {
|
|
1762
1911
|
const { verifyEvidenceBundle, keyPaths } = await import('../src/posture/evidence-bundle.js');
|
|
1763
1912
|
// `args._[0]` is the command name itself — the same convention cmdScan uses.
|
|
1764
1913
|
const file = args.flags.bundle || args._[1];
|
|
1765
|
-
if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json> [--public-key <path>]'); return 2; }
|
|
1914
|
+
if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
|
|
1766
1915
|
|
|
1767
1916
|
let bundle;
|
|
1768
1917
|
try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
|
|
1769
1918
|
catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
|
|
1770
1919
|
|
|
1920
|
+
const runAttestation = _asRunAttestation(bundle);
|
|
1921
|
+
if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
|
|
1922
|
+
|
|
1771
1923
|
const keyFile = args.flags['public-key'] || keyPaths().publicKey;
|
|
1772
1924
|
let publicKeyPem = null;
|
|
1773
1925
|
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
package/dist/113.index.js
CHANGED
|
@@ -78,7 +78,7 @@ function recordFixAttempt(scanRoot, record) {
|
|
|
78
78
|
try {
|
|
79
79
|
const dir = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .stateDir */ .Pn)(scanRoot);
|
|
80
80
|
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.isSafeStateDir)(dir)) return false;
|
|
81
|
-
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return;
|
|
81
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return false;
|
|
82
82
|
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
|
|
83
83
|
// One writeSync of one newline-terminated line: a concurrent reader sees
|
|
84
84
|
// whole records or nothing, and a torn tail is dropped on read.
|
|
@@ -266,7 +266,16 @@ function _detectRunner(scanRoot) {
|
|
|
266
266
|
} catch { return null; }
|
|
267
267
|
})();
|
|
268
268
|
if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
|
|
269
|
-
|
|
269
|
+
// --passWithNoTests is Jest-specific CLI syntax — appending it
|
|
270
|
+
// unconditionally broke every non-Jest npm test script (mocha, vitest,
|
|
271
|
+
// ava, tap, or a plain node script) with an "unrecognized option" exit,
|
|
272
|
+
// failing verification for a reason that has nothing to do with
|
|
273
|
+
// whether the patch actually broke anything. Only add it when Jest is
|
|
274
|
+
// actually the configured runner.
|
|
275
|
+
const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
|
|
276
|
+
|| Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
|
|
277
|
+
const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
|
|
278
|
+
return { runner: 'npm', cmd: 'npm', args };
|
|
270
279
|
}
|
|
271
280
|
if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
|
|
272
281
|
return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
|
|
@@ -406,8 +415,8 @@ var external_node_child_process_ = __webpack_require__(1421);
|
|
|
406
415
|
var external_node_fs_ = __webpack_require__(3024);
|
|
407
416
|
// EXTERNAL MODULE: external "node:path"
|
|
408
417
|
var external_node_path_ = __webpack_require__(6760);
|
|
409
|
-
// EXTERNAL MODULE: ./src/engine.js +
|
|
410
|
-
var engine = __webpack_require__(
|
|
418
|
+
// EXTERNAL MODULE: ./src/engine.js + 196 modules
|
|
419
|
+
var engine = __webpack_require__(2739);
|
|
411
420
|
;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
|
|
412
421
|
// Deterministic honesty gates on fix / finding output (#7).
|
|
413
422
|
//
|
package/dist/178.index.js
CHANGED
|
@@ -13,7 +13,7 @@ export const modules = {
|
|
|
13
13
|
/* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
|
|
14
14
|
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
|
|
15
15
|
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
|
|
16
|
-
/* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(
|
|
16
|
+
/* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(2739);
|
|
17
17
|
// Time-travel + counterfactual scanning (v0.68).
|
|
18
18
|
//
|
|
19
19
|
// Two new modes that exploit the pure-input shape of runFullScan:
|
package/dist/207.index.js
CHANGED
|
@@ -6,9 +6,10 @@ export const modules = {
|
|
|
6
6
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
7
|
|
|
8
8
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
-
/* harmony export */ computeRunAttestation: () => (/* binding */ computeRunAttestation)
|
|
9
|
+
/* harmony export */ computeRunAttestation: () => (/* binding */ computeRunAttestation),
|
|
10
|
+
/* harmony export */ verifyRunAttestation: () => (/* binding */ verifyRunAttestation)
|
|
10
11
|
/* harmony export */ });
|
|
11
|
-
/* unused harmony
|
|
12
|
+
/* unused harmony export ATTESTATION_CANONICALISATION */
|
|
12
13
|
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7598);
|
|
13
14
|
/* harmony import */ var _integrity_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1130);
|
|
14
15
|
// R4 — determinism as a contract.
|
|
@@ -202,11 +203,11 @@ function verifyRunAttestation(attestation, inputs = {}) {
|
|
|
202
203
|
}
|
|
203
204
|
if (attestation.signature) {
|
|
204
205
|
let expected;
|
|
205
|
-
try { expected = signLastScan(attestation.digest); }
|
|
206
|
+
try { expected = (0,_integrity_js__WEBPACK_IMPORTED_MODULE_1__/* .signLastScan */ .lU)(attestation.digest); }
|
|
206
207
|
catch { return { ok: false, reason: 'signature present but no key material available to check it' }; }
|
|
207
208
|
const a = Buffer.from(String(attestation.signature), 'hex');
|
|
208
209
|
const b = Buffer.from(expected, 'hex');
|
|
209
|
-
if (a.length !== b.length || !
|
|
210
|
+
if (a.length !== b.length || !node_crypto__WEBPACK_IMPORTED_MODULE_0__.timingSafeEqual(a, b)) {
|
|
210
211
|
return { ok: false, reason: 'signature does not verify under this install key' };
|
|
211
212
|
}
|
|
212
213
|
return { ok: true, reason: 'digest and per-install signature verify' };
|
package/dist/238.index.js
CHANGED
|
@@ -78,7 +78,7 @@ function recordFixAttempt(scanRoot, record) {
|
|
|
78
78
|
try {
|
|
79
79
|
const dir = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .stateDir */ .Pn)(scanRoot);
|
|
80
80
|
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.isSafeStateDir)(dir)) return false;
|
|
81
|
-
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return;
|
|
81
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return false;
|
|
82
82
|
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
|
|
83
83
|
// One writeSync of one newline-terminated line: a concurrent reader sees
|
|
84
84
|
// whole records or nothing, and a torn tail is dropped on read.
|
package/dist/317.index.js
CHANGED
|
@@ -123,12 +123,25 @@ function ensureKeyPair(dir = keyDir()) {
|
|
|
123
123
|
return { privateKeyPem, publicKeyPem, created: true, ...p };
|
|
124
124
|
} catch (e) {
|
|
125
125
|
if (e.code !== 'EEXIST') throw e;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
// We lost the race on the private key — someone else's 'wx' won.
|
|
127
|
+
// Their public-key write follows immediately after their private-key
|
|
128
|
+
// write but is not itself atomic with it, so it may not have landed
|
|
129
|
+
// yet: a bare read here would throw an uncaught ENOENT on a genuinely
|
|
130
|
+
// transient state, not a real error. Retry briefly rather than crash.
|
|
131
|
+
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
132
|
+
for (let attempt = 0; ; attempt++) {
|
|
133
|
+
try {
|
|
134
|
+
return {
|
|
135
|
+
privateKeyPem: node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p.privateKey, 'utf8'),
|
|
136
|
+
publicKeyPem: node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p.publicKey, 'utf8'),
|
|
137
|
+
created: false,
|
|
138
|
+
...p,
|
|
139
|
+
};
|
|
140
|
+
} catch (readErr) {
|
|
141
|
+
if (readErr.code !== 'ENOENT' || attempt >= 50) throw readErr;
|
|
142
|
+
Atomics.wait(sleepBuf, 0, 0, 10); // 10ms; ~500ms total budget
|
|
143
|
+
}
|
|
144
|
+
}
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
147
|
|
|
@@ -246,9 +259,26 @@ function signEvidenceBundle(bundle, privateKeyPem) {
|
|
|
246
259
|
* Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
|
|
247
260
|
* source is an expected input, not an exceptional one.
|
|
248
261
|
*/
|
|
262
|
+
// The complete set of top-level keys a legitimately-built, signed bundle can
|
|
263
|
+
// carry — buildEvidenceBundle's six plus signEvidenceBundle's `signature`.
|
|
264
|
+
// EA-03 (Stage-0 audit, 2026): canonicalBytes SIGNS an allowlist of fields;
|
|
265
|
+
// verifyEvidenceBundle never checked for keys OUTSIDE that allowlist, so a
|
|
266
|
+
// bundle with a fabricated `verdict`/`proofLevel`/anything-else stapled on
|
|
267
|
+
// after signing verified as authentic — the signature simply never covered
|
|
268
|
+
// those bytes. Rejecting unknown keys here closes that; it must exactly match
|
|
269
|
+
// what buildEvidenceBundle+signEvidenceBundle actually produce, or a
|
|
270
|
+
// legitimate bundle would start failing verification.
|
|
271
|
+
const BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
272
|
+
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
273
|
+
]);
|
|
274
|
+
|
|
249
275
|
function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
250
276
|
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
251
277
|
if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
278
|
+
const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
279
|
+
if (unknownKeys.length) {
|
|
280
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
281
|
+
}
|
|
252
282
|
const sig = bundle.signature;
|
|
253
283
|
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
254
284
|
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
package/dist/384.index.js
CHANGED
|
@@ -8,7 +8,7 @@ export const modules = {
|
|
|
8
8
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
9
|
/* harmony export */ scanCredentials: () => (/* reexport safe */ _engine_js__WEBPACK_IMPORTED_MODULE_0__.Sv)
|
|
10
10
|
/* harmony export */ });
|
|
11
|
-
/* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
|
|
11
|
+
/* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2739);
|
|
12
12
|
// Secrets submodule view of the engine — credential + entropy + TODO scanning.
|
|
13
13
|
|
|
14
14
|
|